Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

ripbi is static analysis, linting, and tree-shaking for Power BI semantic models and DAX. Power BI models accumulate bloat — unused measures, orphaned columns, dead Power Query partitions — that Microsoft's TOM/XMLA tooling cannot detect because it is report-agnostic. ripbi ingests both model schemas and report visual bindings, then isolates dead code via graph reachability.

It works without opening the reports or the semantic model, so it runs in a terminal or headless CI/CD (GitHub Actions, Azure DevOps). It is cross-platform, with no dependency on Power BI Desktop.

Today it works on local PBIP projects — a TMDL semantic model plus PBIR reports; .pbix and .pbit files are not supported yet.

This book is built from main and may document changes that have not shipped in a release yet.

Start with installation and quickstart, then the scan command for everything ripbi scan can do — flags, output shapes, ripbi.toml, and exit codes. What counts as unused explains the liveness rules behind the findings.

Installation and quickstart

Install

macOS and Linux:

curl -fsSL https://raw.githubusercontent.com/bgarcevic/ripbi/main/install.sh | sh

Windows (PowerShell):

irm https://raw.githubusercontent.com/bgarcevic/ripbi/main/install.ps1 | iex

Both scripts verify the download against the release's sha256 checksums and install the binary into ~/.local/bin as both ripbi and its short alias rib — the two names are the same tool, so rib scan works anywhere ripbi scan does. Or cargo install ripbi.

Pinning a version, reviewing the scripts first, building from source

Pin a version:

curl -fsSL https://raw.githubusercontent.com/bgarcevic/ripbi/main/install.sh | RIPBI_VERSION=v0.3.3 sh
$env:RIPBI_VERSION = 'v0.3.3'; irm https://raw.githubusercontent.com/bgarcevic/ripbi/main/install.ps1 | iex

Both scripts are plain shell and PowerShell. Download them first if you would rather read before running:

curl -fsSL https://raw.githubusercontent.com/bgarcevic/ripbi/main/install.sh -o install.sh
sh install.sh
irm https://raw.githubusercontent.com/bgarcevic/ripbi/main/install.ps1 -OutFile install.ps1
powershell -ExecutionPolicy Bypass -File .\install.ps1  # the flag is only needed if your policy blocks script files

From a clone of this repository:

cargo install --path crates/ripbi-cli

Updating

Installed with one of the scripts above? ripbi update downloads the latest release, verifies its sha256 checksum, and replaces ripbi and rib in place:

ripbi update           # update in place; exits 0 when done
ripbi update --check   # report only: exits 1 when a newer release exists

Exit codes: 0 updated or up to date, 1 update available (only from --check), 2 error (network, checksum, unsupported platform).

Every command checks for a new release at most once a day and prints a dim one-line notice when one is available. That check is a plain GET of the public release metadata — no data is sent — and RIPBI_NO_UPDATE_CHECK=1 disables it. It is also skipped automatically when stderr is not a terminal (pipes, CI logs), when CI is set, or under -q.

Cargo-installed copies are never self-replaced: ripbi update prints the cargo install ripbi --force command instead. A source build found under a target/ directory prints the install-script and cargo install --path alternatives.

On Windows a running executable cannot be deleted, so a completed update may leave ripbi.exe.old (or rib.exe.old) beside the new binary. It is safe to delete, and the next update removes it.

Quickstart

Clone the repository to get the sample projects, then scan one:

git clone https://github.com/bgarcevic/ripbi.git
cd ripbi
ripbi scan "samples/AdventureWorks Sales.pbip"

Output from the committed AdventureWorks sample (trimmed):

130 objects, 74 reachable from 51 roots, 56 unused

Measures (11)
  'Sales'[Average Sales per Order]
    ← nothing references it
Columns (28)
  'Customer'[City]
    ← only used by hierarchy 'Customer'[Geography] — hierarchy level (also unused)
  …

The summary line counts the model's objects, how many the reports reach, and how many are unused. Each finding says why it is dead: nothing references it, or its only consumer is itself unused.

ripbi scan discovers the project itself: pass a .pbip file, a project folder, a .SemanticModel, or a .Report, or nothing to scan the current directory.

Exit codes:

CodeMeaning
0Nothing unused
1Unused objects found
2Error (bad path, ingestion failure, …)
ripbi scan -q   # no output; exit code only

The scan command chapter documents every flag, the output shapes, ripbi.toml, and the exit codes in full.

ripbi scan output

The user-facing contract for the scan command: what each output mode prints, which stream it goes to, and what the exit codes mean. render.rs implements this; change the two together.

ripbi scan [PATH] [flags]
ripbi scan --model PATH [--report PATH]... [flags]

PATH is a .pbip file, a project folder, a .SemanticModel item folder, or a .Report item folder. Without PATH (and without target in ripbi.toml), scan discovers projects in the current directory: one candidate is announced and scanned, several prompt with a numbered picker (on a TTY stdin only — otherwise the scan fails listing them), none is an error. When PATH (or the ripbi.toml target) names a semantic model itself, plain --report folders are search folders exactly as with --model; any other target accepts report items only.

--model PATH names the semantic model explicitly: a .SemanticModel folder, its definition/ folder, or any folder directly containing model.tmdl. It disables current-directory discovery and the ripbi.toml target, and reinterprets every --report (or config reports) value that is not itself a report item as a search folder: the folder is walked recursively for report items bound to the model. With no report values at all, the model's parent folder is searched. A PATH that names a semantic model gets the same search-folder walk for plain --report values, but keeps its convention sibling pairing and does not gain a default search root. Reports pair with the model by their definition.pbir path first, then by the PBIP stem convention (X.Report beside X.SemanticModel), then by the dataset name in a byConnection initial catalog. A scan with no connected reports refuses with exit 2 and per-category counts.

Streams

ContentStreamNotes
Findings, summary, JSON, plain recordsstdoutthe machine-readable side
Discovery/selection announce, scanning linestderrone line each; the scanning line counts the bound reports (--verbose names them)
Pairings made by a walk (Note: by-name matches, Ignored … bound to other models exclusions)stderrinformational, never --strict-fatal; both collapse to one capped line each (--verbose lists every report)
Coverage caveatstderronce per run
Skip notices (parser drift, stale saved state, unresolved dataset references)stderrgrouped under one header; suppressed in --json mode, where the JSON carries them
Errors + hintsstderrerror: … / hint: …

-q/--quiet suppresses everything on both streams; the exit code is the only output.

Model-centric scans (--model, or a PATH naming a semantic model)

Whenever reports are discovered by walking search folders — under --model, or under a PATH that names a semantic model with plain --report folders (issue #67) — the pairings that would otherwise be invisible are summarized on stderr (all suppressed by -q). The default reads one line per pairing fact; --verbose expands each into the per-report audit trail:

Scanning models/Sales.SemanticModel with 3 report(s)
Note: 1 report(s) matched by dataset name only (byConnection 'initial catalog' = 'Sales'): Thin.Report
Ignored 1 report(s) bound to other models: HR.Report
  • The scanning line counts the connected reports — direct --report items included. --verbose appends the report folder names, in ingestion order: under --model, walked reports by canonical path, then explicitly passed ones; under a model-naming PATH, the convention siblings and explicit items first, then the walked ones.

  • The Note: line flags reports connected by dataset name rather than by path or stem, because that pairing is weaker than the written definition.pbir path — a wrong pairing means wrong findings, so the fact always shows. One line per initial catalog, with the count and up to three names; when a tail is capped, the line points at --verbose:

    Note: 26 report(s) matched by dataset name only (byConnection 'initial catalog' = 'Sales'): Thin1.Report, Thin2.Report, Thin3.Report, … and 23 more — rerun with --verbose to list them
    

    --verbose lists one line per report instead, with its full path.

  • The Ignored … line counts report items under the search folders that resolve to a different existing model, with up to three names — capped the same way, with the same --verbose pointer; the flag lists every one. It is informational: those reports are not ingested, they appear in no output mode, and they never fail --strict — a healthy multi-model folder must stay scannable. Report items whose reference resolves to nothing become unresolved_dataset_reference skip notices instead, and a *.Report folder with no report.json anchor becomes a malformed_report_item notice; both do fail --strict.

  • Reports passed explicitly with --report are taken at face value: they are never binding-checked and produce none of these notices. Under a model-naming PATH the convention siblings count as explicit in the same sense.

Exit codes

CodeMeaning
0Clean: nothing unused, no broken visual binding gates the run, and no auto date/time table unused by reports or dead (objects suppressed by [scan].ignore count as handled; an in use auto date/time table is informational)
1Unused objects found, auto date/time machinery no report binds — or, under --broken, broken visual bindings found
2Error: usage, bad PATH, model-only input, a --model search with no connected reports, unsupported archive, ingestion failure, ambiguous discovery off-TTY — or any skip notice under --strict

The exit code describes what was reported: findings hidden by the type flags, and an Auto date/time section hidden because --tables was not among the passed flags, cannot fail the run. Broken visual bindings (issue #60) are the one advisory kind: they are reported by default, but they gate the exit code only when --broken selects them — an unused-only gate must not start failing because one visual is broken, and a --broken gate must not fail on unused findings. --strict and -q/--quiet are unaffected.

Flags

FlagEffect
--jsonJSON on stdout (schema below). Mutually exclusive with --plain and --summary
--plainOne <type>\t<id> record per finding, for grep/awk
-s, --summaryCounts only: the summary line and per-type totals, no findings list. Mutually exclusive with --json and --plain
-q, --quietNo output; exit code only
-v, --verboseFull pairing audit trail on stderr: every report's name in the scanning line, one pairing note per by-name-matched report, the complete ignored-reports list. The default caps each to one line
--model <PATH>Analyze one named semantic model (.SemanticModel, its definition/, or a folder holding model.tmdl). Disables cwd discovery and the ripbi.toml target; plain --report folders become search folders for reports bound to this model. Conflicts with PATH
--report <PATH>Extra report root; repeatable. Replaces reports from ripbi.toml. When the target is --model or a PATH naming a semantic model, a folder that is not itself a report item is searched recursively for reports bound to the model
--measures, --columns, --hierarchies, --tables, --partitions, --relationships, --calc-items, --expressions, --functions, --report-measuresReport only unused objects of the passed types; repeatable, and passed together they union (--measures --columns). Filters every output mode and the exit code. With none of them, everything is reported
--brokenReport only broken visual bindings (issue #60) — field references that no longer resolve in the model. Unions with the type flags (--broken --measures gates on both); alone, it scopes the run to breakage so a pipeline can gate on it separately. Without --broken (and without any other type flag) breakage is still reported, but never changes the exit code. When the model ingest recorded unknown_object skips, breakage is suppressed entirely — see the precision bar under Human output
--power-queryAlso print the ⭘ Power Query also names it annotations (human output; a no-op in --plain, --json, and -q, whose consumers filter themselves)
--strictAny parser skip notice becomes exit code 2
--no-colorNever color (color is also off off-TTY, under NO_COLOR, or TERM=dumb)
--no-inputNever prompt; fail where a picker would appear

Human output (default)

130 objects, 74 reachable from 51 roots, 56 unused

Measures (11)
  'Sales'[Average Sales per Order]
    ← nothing references it
Columns (28)
  'Customer'[City]
    ← only used by hierarchy 'Customer'[Geography] — hierarchy level (also unused)
  'Customer'[Customer ID]
    ← nothing references it
  • The summary line: total graph objects, how many reachability reached, from how many report binding roots, and the unused count. Roots are report bindings — the desktop tree and the phone layout (definition.mobile/, issue #49) alike; RLS roles also seed reachability without counting here. When [scan].ignore suppressed objects, a second line says how many; when the type flags hid findings, a third line counts them ((2 unused hidden by type filters)); and when auto date/time machinery members are covered by the section's table verdicts, a fourth counts them, so 0 unused from a filtered or machinery-heavy model never reads as a clean one by accident.

  • Findings are grouped by object type (measures, columns, hierarchies, tables, partitions, relationships, calculation items, expressions, functions, report measures — fixed order, empty groups omitted), sorted by object identity. The type flags restrict the groups to the selected kinds; empty groups are still never printed.

  • Chain annotations, one per referencing object:

    • ← nothing references it — a true orphan, deletable outright;
    • ← only used by 'X' — <where> (also unused) — the sole (or all-identical case: every) consumer is itself unused, so the whole chain can go;
    • ← used by 'X' — <where> — the consumer is live but its use could not keep this object alive (a key column held only by an active relationship endpoint, or the table of an inactive relationship nothing activates).
  • The ⭘ Power Query also names it (…) annotation appears, behind --power-query, on Data columns named by M expressions. With the flag, the last finding above reads:

      'Customer'[Customer ID]
        ← nothing references it
        ⭘ Power Query also names it ('Customer' partition) — safe to stop loading; removing it from the script means editing those steps too
    

    It is supply-chain context, not a consumer: unloading the column cannot break refresh, but removing it from the Power Query script entirely means editing each named partition or expression too — cleanup-time guidance, so it is hidden by default and shown only on request. Columns without the annotation are also gone from every Power Query step — and engine-computed columns (calculated columns, auto date/time machinery) never carry it, because an M step can only name a column it produces. --json always carries the underlying named_in_power_query field regardless of the flag.

  • The Broken visual bindings section follows the findings (issue #60): one row per report binding whose written field reference resolves to nothing in the model, or that lands on an artifact whose own DAX no longer resolves — the static form of the error state the service would render. Each row names the written reference, the reason, and the binding's site:

    Broken visual bindings (2)
      'Sales'[Color]
        ← field not found in the model — field well 'Values' — visual 'V2' on page 'P1'
      'Sales'[Broken Total]
        ← bound artifact 'Sales'[Broken Total] has unresolvable references — field well 'Values' — visual 'V3' on page 'P1'
    

    The section is a different verdict than reachability's — written references, not graph liveness — so it prints even on an otherwise clean No unused objects. scan, and it is advisory: it never changes the exit code unless --broken selects it. Hidden bindings (a type flag without --broken) and suppressed ones are accounted for in the summary's arithmetic lines: (2 broken-visual bindings hidden by type filters) and (N possible broken-visual bindings suppressed — the model ingest reported skips, listed on stderr; --strict fails on those skips).

    The precision bar is the mirror image of the unused findings': a "broken" claim is itself a breakage claim, so it fires only when nothing in the model ingest could have hidden the name the binding wrote. That is exactly the unknown_object skip kind — a table or column the parser skipped never became a node, so a "field not found" verdict would be a guess — and only that kind: an unknown_property notice means the object was parsed with its name regardless, so it does not suppress. The suppressed count rides on the summary line, and --strict surfaces the skips behind it. Report-side skips do not suppress either way: a half-parsed report can only under-report breakage, never fabricate it. The remaining under-claiming is deliberate:

    • a KPI visual's synthesized variants (… Goal, … Status, … Trend, … Value) resolve, not flag, whenever the base measure exists — the engine materializes them;
    • an artifact's own DAX resolving through query-time constructs never flags: a report measure referencing a sibling report measure (the graph's ordinary report-measure edge), @-prefixed extension columns (ADDCOLUMNS(…, "@Krav", …) read back as [@Krav]), and the column names query time introduces in the same expression — string-literal extension names (SELECTCOLUMNS(t, "Ordning", …), GROUPBY) and the table constructor's fixed Value/Value1…N defaults. Verifying these needs DAX scope analysis the lexer deliberately does not do, so under-claim applies; the cost is that a typo coinciding with a string in the same measure goes unflagged;
    • a qualified field miss on a calculated table resolves when the partition expression makes the name lexically visible: string literals (DATATABLE headers, ADDCOLUMNS/SELECTCOLUMNS names), the constructor defaults, the columns written in the expression, and every column of the tables it references (the wrapped-table shape FILTER/VALUES passes through). A calculated table has no declared schema — its columns are what the expression returns — so only names visible nowhere in the expression flag; renaming a header out from under a binding still does;
    • field parameters resolve like any other field (#52), and auto date/time hierarchy references resolve through the variation machinery (#47); a variation-flavored hierarchy the machinery cannot resolve stays silent rather than risk calling serialization drift a breakage;
    • a measure referenced through a stale or wrong table qualifier still resolves (measure names are model-global), and a same-named hierarchy or calculation item behind a stale column name resolves too;
    • Written references the parser could not structure (legacy layouts, unresolved query aliases) resolve as always but never flag — the written form is too loose for a breakage claim.

    Bookmarks on deleted pages never reach this check: their sections are skipped as stale at ingestion. The phone layout binds identically to the desktop tree, so a broken binding there reports with mobile layout … provenance. A binding onto a broken artifact still counts as a root (it resolves — that is what it does), so the artifact itself is not reported unused by it; the artifact's own finding kind, bound or not, is issue #84's scope.

  • The Auto date/time section follows the findings: one verdict per LocalDateTable_*/DateTableTemplate_* table, naming the user's date column the machinery serves. It is a provenance verdict, not a reachability one — the engine's own relationship keeps the machinery alive, so "alive" says nothing. The section is table-shaped: it prints (and carries its exit-code weight) only when no type flags are passed or --tables is among them; hidden, it is absent from every output mode.

    • in use — replace with a real date table — a report binding lands on the machinery (usually a visual's date hierarchy over the varied column). Informational; it never fails the exit code.
    • unused by reports — disable auto date/time — nothing binds it, yet reachability keeps it alive: pure bloat the findings list cannot express, because the object is not dead.
    • dead — nothing reaches it at all; the table's own finding (with its chain annotations) is filed here instead of the generic Tables group.

    The section is also the only place the machinery appears (issue #47): its unused members — the GUID-named columns, hierarchies, and partitions under those tables — are never generic findings, because they are not separately actionable. Removing the table removes them, and disabling auto date/time on the named column is the fix; the summary line accounts for them ((41 unused auto date/time members covered by their tables' verdicts)), and --json reports the count as summary.auto_date_time.member_findings.

  • Auto date/time is a recommendation, not a law: a legacy model that keeps the feature can silence the section through the ordinary [scan].ignore globs —

    [scan]
    ignore = ["LocalDateTable_*", "DateTableTemplate_*"]
    

    Suppressed tables count as handled, so they cannot fail the exit code, and a dead table's own finding — suppressed with its row — counts in summary.ignored. The trade-off: the recipe also hides the in use tables' advice, which is the part worth reading when you plan the migration to a real date table.

--summary

The human mode for big models: the summary line, one label: count line per non-empty group, a Worst tables: breakdown, one Broken visual bindings: line when any are reported (issue #60), and one Auto date/time: line when the model has such tables — the machinery and the distinct date columns its local tables serve, with the verdicts as the breakdown (Auto date/time: 6 hidden tables over 5 date columns (1 in use, 5 dead); the shared DateTableTemplate_* serves no column, so the columns can be fewer than the tables, and a model whose machinery pairs with no column at all drops the clause). No findings list. Type flags filter the counts and the breakdown like any other mode. Same stdout, same exit codes, same stderr (notices still print). Use --plain or --json when you want the individual objects.

3781 objects, 1207 reachable from 2962 roots, 2574 unused

Measures: 214
Columns: 2211
Report measures: 149

Worst tables:
  'Sales'      412
  'Customer'   187
  'Date'       154
  ... and 14 more tables with findings

The Worst tables: block groups the same surviving findings the counts above come from — [scan].ignore suppressions and type-filter-hidden objects never appear in it — and names the tables carrying the most of them, so a big model answers "where do I start". Rules:

  • At most 10 rows; when more tables carry findings, a footer says how many.
  • Ordered by count descending, then table name folded case-insensitively — the model's identity ordering, so the order is stable across runs.
  • A dead relationship counts under its "from" table. Report measures, shared expressions, and functions belong to no table and are skipped here (their counts above still show them); the block is omitted entirely when nothing surviving has a table.

--plain

One record per finding on stdout, tab-separated, greppable — one broken_visual:<reason> record per reported broken binding (issue #60) — followed by one auto_date_time:<verdict> record per auto date/time table. Type flags filter the finding records (--broken selects the broken ones the same way); the auto_date_time: records print only when the section does (no type flags, or --tables among them):

measure	'Sales'[Legacy Total]
column	'Sales'[Legacy]
broken_visual:field_not_found	'Sales'[Color]
broken_visual:bound_artifact_broken	'Sales'[Broken Total]
auto_date_time:in_use	table 'LocalDateTable_9e0bbdfc-…'
auto_date_time:dead	table 'DateTableTemplate_0039983e-…'

--json

Pretty-printed JSON, stable field order, additive schema:

{
  "schema_version": 1,
  "target": "samples/AdventureWorks Sales.SemanticModel",
  "reports": ["samples/AdventureWorks Sales.Report"],
  "summary": {
    "objects": 130,
    "reachable": 74,
    "roots": 51,
    "unused": 56,
    "unused_total": 56,
    "ignored": 0,
    "broken": 2,
    "broken_total": 2,
    "auto_date_time": {
      "hidden_tables": 6,
      "date_columns": 5,
      "member_findings": 41,
      "in_use": 1,
      "unused_by_reports": 0,
      "dead": 5
    }
  },
  "unused": [
    {
      "type": "column",
      "id": "'Sales'[Order Quantity (base)]",
      "table": "'Sales'",
      "used_by": [
        {
          "id": "'Sales'[Order Quantity]",
          "provenance": "measure expression",
          "also_unused": true
        }
      ],
      "named_in_power_query": []
    }
  ],
  "broken": [
    {
      "target": "'Sales'[Color]",
      "reason": "field_not_found",
      "bound_artifact": null,
      "provenance": "field well 'Values' — visual 'V2' on page 'P1'"
    },
    {
      "target": "'Sales'[Broken Total]",
      "reason": "bound_artifact_broken",
      "bound_artifact": "'Sales'[Broken Total]",
      "provenance": "field well 'Values' — visual 'V3' on page 'P1'"
    }
  ],
  "auto_date_time": [
    {
      "verdict": "in_use",
      "id": "table 'LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228'",
      "source_column": "'Opportunity Calendar'[Date]",
      "finding": null
    }
  ],
  "skips": {
    "count": 0,
    "notices": []
  }
}
  • summary.unused is the length of unused — after [scan].ignore and the type flags. summary.unused_total counts every unused object in the model before any suppression, filter, or section move, so reachable = objects − unused_total always holds and a consumer can tell a filtered-away finding from an absent one. summary.ignored counts findings suppressed by [scan].ignore — unused objects and broken bindings alike. On a model with auto date/time machinery, the remaining gap between unused and unused_total is the machinery: summary.auto_date_time.member_findings counts its unused members and the dead verdict count its nested own findings.
  • summary.broken is the length of broken — after [scan].ignore, the unknown-object suppression, and the type flags. summary.broken_total counts every broken binding detected, before any of those, so a consumer can tell a suppressed or filtered-away binding from an absent one (issue #60).
  • summary.auto_date_time counts the section's rows by verdict, plus hidden_tables (every row, all verdicts together), date_columns (the distinct user date columns the machinery serves — the shared template serves none), and member_findings (the machinery's unused members covered by the rows, absent from unused individually).
  • Type flags filter the unused array and summary.unused; summary.unused_total stays model-wide. The broken array and summary.broken follow the same rule: every type flag without --broken empties them, --broken keeps only them. The auto_date_time array and summary.auto_date_time counts follow the section rule: present in full when no type flags are passed or --tables is among them, empty and zero otherwise.
  • broken (issue #60) carries one row per reported broken visual binding: target is the written field reference; reason is one of table_not_found, field_not_found, measure_not_found, hierarchy_not_found, level_not_found, or bound_artifact_broken; bound_artifact names the broken artifact the binding lands on (present exactly when the reason is bound_artifact_broken); provenance is the same binding-site phrase the unused findings' used_by entries carry, mobile layout … prefixed for phone-layout bindings. Bindings suppressed by the clean-ingest bar or by [scan].ignore are absent entirely; the totals above keep the arithmetic.
  • auto_date_time carries one row per LocalDateTable_*/DateTableTemplate_* table: verdict is in_use, unused_by_reports, or dead; source_column names the varied user column the machinery serves (null when none resolves, e.g. the template); finding is the table's own unused finding — with its used_by chain — present exactly when verdict is "dead" (the row moved here from unused). Rows suppressed by [scan].ignore are absent entirely. The machinery's other unused members are in no array at all: the row covers them (issue #47), and summary.auto_date_time.member_findings counts them.
  • type is one of table, column, measure, hierarchy, partition, relationship, role, calculation_item, expression, function, report_measure.
  • table is the finding's model table, quoted ('Sales') — a relationship reports its "from" side. It is null for the kinds with no model table (role, expression, function, report_measure). --plain deliberately omits it: its records are a two-column grep contract.
  • provenance is the human phrase for how the use is made (e.g. measure expression, field well 'Y' — visual 'V' on page 'P' in report 'R', hierarchy level). A binding from the phone layout prefixes mobile layout (mobile layout field well 'Y' — visual 'V' on page 'P' in report 'R'), so an audit names the right surface.
  • named_in_power_query lists the M expressions (partitions by their table, shared expressions by name) that mention the column — supply-chain context, never a consumer. Empty for every non-column finding and for columns no M step names.
  • skips.notices carries {path, location, kind, detail} per parser skip; kind is one of unknown_object, unknown_property, malformed_value, unresolved_alias, stale_state, and — when reports are discovered by walking search folders — unresolved_dataset_reference (a report item under a search folder with no usable datasetReference) and malformed_report_item (an anchor-less .Report folder the search walk pruned). Under --strict, count > 0 corresponds to exit code 2.

ripbi.toml

Found in the working directory or its nearest ancestor; relative paths resolve against the file's own directory. Flags override the file; the file overrides discovery.

target = "samples/AdventureWorks Sales.SemanticModel"  # used when no PATH is given
reports = ["samples/AdventureWorks Sales.Report"]      # extra roots when discovery finds none

[scan]
ignore = ["'*Time Intelligence'[*]", "*Legacy*"]       # object-name globs, never reported unused

ignore patterns are case-insensitive globs where * matches any run of characters and ? exactly one; everything else (quotes and brackets included — they appear in display ids) is literal. A pattern matches a finding when it matches the full display id ('Sales'[Draft Amount]) or the bare object name; for a broken binding it matches the written reference whole ('Sales'[Color] — a binding has no bare name of its own). Suppressed findings are excluded from the output and the exit code, and counted in summary.ignored. The suppression applies before the type flags: an object matched by both is simply gone. The auto date/time machinery can be silenced wholesale this way — see the recipe under the Auto date/time section.

Validation

scan's findings on three committed samples are pinned against external unused-objects analyses — tests/fixtures/adventure-works-baseline.txt, tests/fixtures/regional-sales-baseline.txt, and tests/fixtures/artificial-intelligence-baseline.txt: every object a baseline marks dead is a finding with the same chain shape, and no live object is ever flagged. The accepted deltas are documented in each fixture header: the dead Time Intelligence field-parameter cluster on Adventure Works; fully-dead relationship-only tables the exports don't list as rows (a table referenced by nothing but a relationship is unused by the documented containment rule); and, on the Artificial Intelligence sample, the auto date/time machinery of the five date columns whose hierarchies no visual binds — reported only through the auto_date_time section (its members are covered by the per-table verdicts, and the baseline's machinery rows are asserted absent from the generic findings). The one bound table — the report's date hierarchy over 'Opportunity Calendar'[Date], resolved through the model's variation declaration — is fully live, its columns are gone from the findings, and its in use verdict (with the other five tables' verdicts) is pinned by the same test through the auto_date_time section. One conservatism policy the external analyses do not share, visible in that baseline: bookmark saved filters count as bindings (re-applying a bookmark re-binds its fields) — but only for sections whose page still exists. Power BI leaves deleted pages' sections inside bookmarks forever, so those sections are skipped as stale (a stale_state notice, surfaced by --strict), and the columns their filters were the last consumers of surface as ordinary findings; on the Artificial Intelligence sample that closes the last bookmark-kept-alive delta with the export, at the cost of a documented cascade (the fully-dead 'Cases' and 'Case Calendar' tables and everything chained under them). Inactive relationships are the opposite correction: they are live only when a live USERELATIONSHIP reference activates them, so an unactivated one is a finding itself, with its key columns chained under it — only used by relationship … (also unused).

The phone layout is the same conservatism on the report side (issue #49): a report can ship a definition.mobile/ tree beside definition/, and its visuals render on phones, so its bindings enumerate as roots exactly like the desktop tree's — a field referenced only there never surfaces as a finding, because deleting it would break the report for phone users. Its provenance reads mobile layout …, and the summary's root count includes it. Page visibility stays display-only in the phone layout, as on desktop; report-level state (report measures, bookmarks) remains desktop-only.

A fourth, uncommitted validation ran against a large production model (≈3.8k graph objects, 14 reports, ≈2.5k columns/measures measured externally): 99.3% of the externally-dead objects were findings with identical chain shape, and — the direction that matters — of the objects scan flags that the external analysis calls live, none had a live consumer. Every one was a member of a chain where every consumer was itself unused: auto date/time clusters no report binds, and active relationships between otherwise-dead tables. The one other historical divergence — columns referenced only inside Power Query — closed in both directions with the M lexing of issue #39: the pipeline is M → tables/columns → DAX → reports, so an M mention of a column is its supply chain, not a consumer, and those columns surface as findings again, each carrying named_in_power_query so the script-side steps can be cleaned up alongside. What M does keep alive is what deletion would break: shared expressions and merge-source tables named by other queries' M. The incremental refresh policy extends the same rule to refresh time (issue #53): its change-detection expression and source expression are evaluated by the engine at every policy refresh, so the measures and shared expressions they name — a measure-based "detect data changes" polling expression, or the RangeStart/RangeEnd parameters named only by the policy's source in the Desktop "Full DataView" shape — stay live while the policy's partition is, with change detection expression as the provenance; the policy's scalar vocabulary (periods, granularities) names nothing and stays silent. Two external-analysis blind spots surfaced the same run: a measure bound only by a drillthrough filter on a hidden page (counted live here; the external tool skipped hidden pages), and report-level measures the external tool judges by view telemetry, which static analysis deliberately ignores.

Known boundaries

  • The type flags cover the ten object-type groups plus --broken (issue #60); a role finding has no flag and is filtered out whenever any type flag is passed. A broken binding's kind (broken_visual) also never appears in the unused array or the generic groups — it has no ObjectId of its own.
  • Only TMDL semantic models and PBIR reports can be ingested; .pbix/.pbit/ model.bim are recognized and refused with a clear error until their ingestors land.
  • Analysis covers only the ingested reports. External consumers — thin reports, Excel (Analyze in Excel), XMLA reads, other datasets' DAX — are invisible; scan prints this caveat on every run.
  • A model-only scan is refused: with no report bindings (and no RLS roles) everything is formally unused, which is never the answer the user wants. Pass --report. When search folders are walked, the same refusal lists how many report items were bound to other models and how many had unresolved dataset references.
  • Folder-walking outside --model needs a PATH (or ripbi.toml target) that names a semantic model itself. Any other target — a .pbip, project folder, .Report, or a cwd-discovered project — rejects a plain --report folder with a hint naming the mode switch (--report accepts report items only).
  • byConnection pairs by dataset name only (case-insensitive initial catalog against the model's .platform display name or item stem). Service semanticmodelid GUIDs and local .platform logicalId GUIDs are disjoint namespaces, so no static GUID match exists; report items that name a different dataset are excluded, and ones whose shape cannot be matched are unresolved notices.
  • A multi-model search folder includes only the reports bound to the --model target; reports bound to other models are listed and skipped.

Dependency graph and reachability

How the model and report ASTs become one graph, and what "unused" means. Every rule here is a decision someone made on purpose; changing one changes what scan tells users to delete. Read this before touching src/graph.rs or its submodules.

Shape

One DependencyGraph per semantic model, shared by every report that connects to it. Nodes are ObjectIds — every model object and report measure, whether or not anything references them, so an isolated object can be reported unused. Edges point from user to used and carry their Provenance as the petgraph edge weight, written once at build time. Reverse queries (consumers_of) are plain reads; a second view over the graph (ripbi deps) must be pure rendering in the CLI.

Report sites — visuals, pages, bookmarks — are not model objects and have no ObjectId, so report bindings live beside the graph as roots, each with its binding provenance. An RLS role 'Reader' filter edge, by contrast, is an object-to-object edge: the role is a node.

The conservatism rule

Marking an object used too many is harmless. Marking one too few tells a user to delete live code. Where the rules below could go either way, they go wide. In particular: an unqualified [Name] keeps every candidate alive (never UnqualifiedMatches::primary), and an extended resolution adds candidates the plain binder does not know rather than fewer.

The edge catalog

DAX references. For every expression from TabularDatabase::dax_expressions and ReportModel::dax_expressions, every reference binds through dax::bind and edges to each target carry the expression kind as provenance. This is the only path a measure's body, a calculated column, a KPI, an RLS filter, a calculation item, or a function body can keep anything alive.

Extended resolution. Beyond the binder's own answers, a qualified 'Table'[Name] also keeps a same-named hierarchy (ISINSCOPE('Date'[Calendar])) and, when the table is a calculation group, a same-named calculation item ('Time Intelligence'[YTD]) alive. A qualified reference matching nothing keeps its qualifying table alive — the nearest resolvable candidate the written form asserts. Unqualified, bare, and call references that match nothing keep nothing alive: there is nothing resolvable to point at.

M references. Every M expression — a table partition or a shared expression — is lexed (m::bind, see m-lexing.md). The pipeline is M → tables/columns → DAX → reports, so deletion never breaks upstream, and the two directions of a mention split:

  • a shared expression named in the text (a parameter, a staging query) keeps alive — deleting it deletes the query the expression reads, which breaks refresh. M-to-M chains flow one hop per edge;
  • a table named as a merge source (Table.NestedJoin(…, #"Dim Lookup", …)) or as a qualified field access (#"Dim Lookup"[Key]) keeps alive, for the same reason. A partition naming its own table creates no edge, and a dead table's partition keeps nothing alive, because every M edge flows from its owner;
  • a column named by bracket field access (each [Amount]) or by the string arguments of the column-centric built-ins (Table.ExpandTableColumn(Source, "Amount")) keeps nothing alive. The column's M mention is its supply chain — the query keeps producing the column and the model just stops mapping it — so unloading it cannot break refresh. The naming expressions travel with the finding instead (UnusedObject::named_by_m, rendered as the named_in_power_query JSON field and the ⭘ Power Query also names it annotation): unloading is safe, and removing the column from the script entirely means editing those steps too. Only Data columns carry that context — an M step can only name a column it produces, so a calculated column matching an M name is coincidence (auto date/time columns named like Desktop's date-template query), never supply chain.

Matching is by identifier tokens, case-insensitively: names inside comments and unrelated strings do not count (the one deliberate narrowing against the old substring scan, which this rule replaced), while bare identifiers still over-mark — most are let variables that resolve to nothing. Unresolved references stay data, never errors.

Dynamic M parameters. A dynamic M query parameter is a shared expression whose view-time values come from a model column, so a report slicer feeds the M query directly (issue #50). The model records the binding in two halves: the column carries an anonymous marker extended property (ParameterMetadata with "kind": 1), and the parameter expression names the column — parameterValuesColumn: DaysList.Days. Only the second half is modeled: a consumed parameter keeps its bound column alive with the dynamic M parameter binding provenance, because deleting the column breaks the binding and every partition whose M consumes the parameter. The chain is report → consuming table → partition → parameter (M references) → bound column, so the column needs no DAX or report reference of its own. The edge flows one way only: an unconsumed parameter is itself dead, and its bound column dies with it; a binding naming a column the model no longer has keeps nothing alive (misses are data). The other ParameterMetadata shapes are not bindings: "kind": 2 marks field parameters and "version": 0 marks what-if parameters — both stay unmodeled, since their columns are kept alive by ordinary DAX (SELECTEDVALUE) and report references.

Report bindings. Every ReportModel::bindings target is a reachability root with its provenance. Measure targets resolve report-first: within its report, a report measure shadows a model measure of the same name. Aggregation unwraps to its inner field; HierarchyLevel keeps the hierarchy and the level's underlying column; Written falls through the same ladder as a written qualified reference. The phone layout's pages (ReportModel::mobile_pages, issue #49) enumerate like any other page's: a field bound only by a definition.mobile/ visual is live, because phone users see it, and its roots carry the mobile marker so an audit can tell the two layouts apart.

Date-hierarchy bindings over a variation. A visual's date hierarchy under auto date/time is written against the varied (base) table — HierarchyLevel with a PropertyVariationSource — but the hierarchy lives on the engine's hidden LocalDateTable_*. When the named table carries no such hierarchy, resolution follows the model's declaration: the varied column's variation names the relationship (and the default hierarchy), and the binding lands on the related date table's hierarchy and level column. If the serialization dropped the variation object, the relationship is found by shape instead — the one touching the varied column whose other endpoint is a flagged auto date/time table. Without a resolution the coarse fallback applies: the table the binding names stays alive.

Calculation-item selection. A report binding that lands on a calculation-group column — a slicer over the field column, a filter naming an item — can select any of the group's items at query time, so it keeps every item of the group alive, carrying the binding's provenance. Written uses only: structural liveness of the group (its table kept alive by one explicitly named item) deliberately does not spread to the unselected items. DAX that references the column without naming an item ('Date Role'[Date Role] = "By Ship Date") currently keeps only the column alive — the string is data, not a reference the lexer can bind.

Report measures are nodes, not roots. An unused report measure is dead — it is exactly the accumulated bloat this tool looks for. Its body's references stay alive only through it, so they die with it, annotated.

Containment. A used member — column, measure, hierarchy, calculation item — keeps its table alive. A used table keeps its partitions, its relationships, and its engine-managed columns alive (calculated-table columns, calculation-group columns, and calendar columns are materialized with the table and cannot be dropped independently). The flow is one-directional on purpose: a table does not keep its ordinary columns alive, because unused columns in used tables are the bread and butter of the findings.

Sort-by, group-by, hierarchy levels. A used column keeps its sortByColumn and groupByColumns alive; a used hierarchy keeps its levels' columns alive. Dead chains form the other way: an unused sorted column drags its unused sort column along, annotated.

Relationships. Live if either endpoint table is reachable. An active relationship keeps both key columns alive; an inactive one is live only when a live DAX reference (USERELATIONSHIP) activates it — switching one on at query time is DAX's job, and nothing else can. Unactivated, the relationship is itself a finding, and its key columns are findings chained under it (only used by … (also unused)). Roles keep their granted tables and filtered columns alive; roles themselves are seeds, never findings, because security configuration is not bloat.

The relationship rule and the two-pass traversal

The subtle decision: a live table keeps its relationships and key columns alive, but a key column kept alive only as a relationship endpoint does not keep its table alive. A table referenced by nothing but a relationship is still unused — deleting it together with the relationship is safe, and in a star schema where every table is related, this is the difference between table findings and none.

One plain BFS cannot express that (containment would drag the far table in), so reachability runs two passes and the policy falls out of what each pass excludes:

  1. Strong pass — from the roots over every edge except relationship endpoints and the inactive-relationship edges. Containment fires; the result is everything that can keep its table alive.
  2. Weak pass — extends the strong set over every edge except containment and the inactive-relationship edges. The relationships of live tables and their active key columns join here, without propagation into tables; an inactive relationship joins only through a live USERELATIONSHIP reference, whose Dax edge the strong pass already carries.

Unused = every node in neither pass. For any unused object, every referencing object is provably either itself unused, a weakly-live key column, or the table of an inactive relationship the relationship cannot keep alive — which is exactly the also_unused: false case in UsedBy, and the reason unused_objects needs no special-casing to annotate chains.

Zero roots means everything is unused

A model scanned with no reports and no roles has no reachability roots; every object comes back unused. That is the honest answer, not a special case: callers (the CLI) decide whether it is a finding or a missing report and should say so.

The second verdict: auto date/time is a provenance question

Reachability answers "does anything keep this alive?" For the engine's auto date/time machinery (LocalDateTable_* / DateTableTemplate_*) that answer is misleading, because the framework relationship to the user's date column keeps the machinery alive for as long as that column is used — a framework-generated edge, not a real consumer. So the graph carries a second, deliberately non-reachability verdict beside unused_objects: auto_date_time_tables reads the same resolved roots and the same reachability set with a different question — does a report binding land on the machinery?

  • In use — a Provenance::Binding root (or selection edge) lands on the table or one of its members. Alive and used; the advice is to replace it with a real date table.
  • Unused by reports — nothing binds it, yet reachability keeps it alive: pure bloat no dead-code finding can express, because the object is not dead. Disable auto date/time.
  • Dead — reachability never reached it; its own finding moves into the section so the verdict and the dead chain read together.

The flags identifying the machinery (is_local_date_table, is_template_date_table, is_private) are display-only metadata and never touch either verdict's inputs.

The inverted question: broken bindings (issue #60)

Where the liveness question is "does anything keep this alive?", the broken-bindings records (broken_bindings, built in graph/broken.rs) ask the opposite: did a written report binding name anything at all? During root construction every binding's resolution is classified — resolved, resolved only to its qualifying table (the field is gone), or resolved to nothing — and the misses become BrokenBindings carrying the binding's full provenance (BindingEdge), the written FieldTarget, and a BrokenReason. A second, standalone pass walks every DAX expression the model and the reports own and records the artifacts whose own field references bind to nothing; a binding that resolves onto such an artifact stays a root but reports the inherited breakage (bound_artifact_broken, the seam issue #84 will promote to its own finding kind).

The conservatism rule inverts: a liveness claim may over-keep, a breakage claim must under-claim — a false "broken" is itself a breakage claim. So the classification never flags what the machinery might resolve: KPI-suffixed measure variants (… Goal, … Status, … Trend, … Value) resolve when the base measure exists; a variation-flavored hierarchy the variation machinery cannot resolve stays silent (rather than risk calling auto date/time serialization drift a breakage); a same-named hierarchy or calculation item behind a stale column qualifier resolves; a qualified field miss on a calculated table resolves when the name is lexically visible in that table's partition expression — its string literals, the constructor defaults, the columns it writes, and every column of the tables it references, the over-approximation of an output schema the TMDL never declares; and a Written reference the parser could not structure never flags. The qualifying-table fallback keeps its liveness role unchanged — the records ride along, they never move a node in or out of the unused set.

Determinism

Node and edge construction follows model and report source order; identical (from, to, provenance) triples dedupe; unused_objects sorts by ObjectId (folded names, so case never changes the order), and broken_bindings sorts by where the binding lives (report, page, visual, bookmark, layout), then the written target, then the reason. Two runs over the same input produce the same output, byte for byte.

Source formats — detection, grammar, and drift policy

What each ingestion format's parser accepts, how it maps into the ASTs, and — most importantly — the policy for everything it does not model. Read this before changing anything under src/ingest/.

Two formats are covered today: TMDL semantic models (ingest::semantic_modelTabularDatabase) and PBIR reports (ingest::reportReportModel, its own section below).

Detection

ingest::semantic_model(path) accepts:

  • a .SemanticModel item folder (its definition/ subfolder is located automatically), or
  • a definition/ folder itself (any directory that directly contains a model.tmdl, or is named definition).

Anything else is Error::UnsupportedFormat. The item's display name is read from .platform (metadata.displayName) beside definition/ — TMDL itself records no usable model name (model.tmdl names its root object Model). A missing or unreadable .platform yields None; a name is provenance, never liveness.

TMDL grammar subset

Stage 1 (src/ingest/tmdl.rs) scans tab-indented lines into a generic node tree; stage 2 maps known descriptors into TabularDatabase. Tab indentation only — a leading-tab count is the depth; spaces after the tabs are content.

Object headers, mapped into the AST:

DescriptorMaps to
table N (+ isHidden)Table (defaultDetailRowsDefinition → detail rows)
column N / column N = daxColumn (data / ColumnKind::Calculated; sortByColumn, nested relatedColumnDetails → group-by columns)
measure N = daxMeasure (+ formatStringDefinition, detailRowsDefinition, kpi)
partition N = m|calculated|query|<other>PartitionSource::M/Calculated/Query/Other
refreshPolicyTable::refresh_policy — its sourceExpression/pollingExpression are kept-alive consumers (see below)
relationship <guid>Relationship (isActive defaults true, like TOM)
hierarchy N + level N/column:Hierarchy / HierarchyLevel
role N + tablePermission T = filterRole / TablePermission
calculationGroup + calculationItem N = daxCalculationGroup / CalculationItem
expression N = mSharedExpression
function N = daxFunction
model / databaseordering/metadata only

Property forms: key: value (scalar), key = value (raw), bare key (flag, e.g. isHidden), annotation N = v, ref table N (fixes table order; unreferenced table files append in file-name order). cultures/ is never read. Names are single-quoted when they contain spaces or punctuation ('Sales Order'), with '' as an escaped quote; unquoting happens in the format layer. Column references are Table.Column, 'Quoted Table'.Column, 'Quoted Table'.'Quoted Column', or a bare (possibly quoted) Column for same-table references such as sortByColumn.

Multi-line expressions. A key = line with nothing after it captures the following deeper-indented block verbatim (blank lines inside preserved, dedented by the block's own first-line indent). The block closes at the first non-blank line above that indent — which is how a sibling property at depth+1, such as a measure's formatString, closes the expression. When the expression's first line sits on the header itself (measure M = VAR … followed by lines below the property level, i.e. deeper than depth+1), the value continues as first line + "\n" + block — the shape PBI Desktop serializes for multi-line DAX (see samples/…/Owners.tmdl).

Only two things are Error::Tmdl, never notices: a line that tokenizes to nothing (e.g. starting with : or =), and a flag-shaped node whose key is a known descriptor that requires a name (a bare table). Everything else unexpected is a skip notice; a run never fails because of drift.

PBIR reports

PBIR is not one grammar but a folder of one-object-per-file JSON documents, each carrying a $schema URL whose version drifts across Power BI releases (preview format; real exports run ahead of the published schemas at microsoft/json-schemas). There is therefore no fixed grammar: parsing is per-file key policies — keys the AST models are parsed, keys deliberately unmodeled are silent, anything else is UnknownProperty drift (see the Keys tables in src/ingest/pbir.rs). Files are read in a fixed order (report.jsondefinition.pbirreportExtensions.json → pages in folder-name order → bookmarks in file-name order, then the phone layout's pages in folder-name order) so notices are deterministic.

Detection. ingest::report(path) accepts a .Report item folder (its definition/ is located automatically) or a definition/ folder itself (any directory directly containing a report.json). When the report ships a phone layout — a definition.mobile/ folder beside definition/ containing a pages/ folder (issue #49) — its pages are parsed like the desktop tree's and land in ReportModel::mobile_pages; the layout is optional and anchor-less, so any absence is silent. A report is parsed standalone — the semantic model it references need not sit beside it, because several reports can share one model. The reference is read from definition.pbir beside definition/ (byPath.path or byConnection.connectionString; the schema demands exactly one); any absence or drift yields DatasetReference::Unresolved — a pairing is never fabricated. Display name from .platform, as on the model side.

What each file contributes.

FileMaps toDeliberately ignored
report.json (the anchor)filterConfig → report filtersthemeCollection, settings, resourcePackages, slowDataSourceSettings, objects (canvas formatting)
definition.pbirDatasetReferenceversion
reportExtensions.jsonentities[].measures[] → report measures (name, expression, formatString)dataType, hidden, dataCategory, displayFolder, measureTemplate, references, …
pages/pages.jsonpageOrder only — one of the two authorities on which pages exist that a bookmark section must clear (with the pages/ folders); page order and the active page themselves are display stateactivePageName, landingPageName
pages/<dir>/page.jsonname, displayName, visibility (HiddenInViewModeis_hidden), filterConfig, pageBinding (type, parameters[].fieldExpr → drillthrough)displayOption, height, width, objects, type, visualInteractions
pages/<dir>/visuals/<dir>/visual.jsoncontainer name, filterConfig; visual.visualType, query.queryState (wells, plus fieldParameters as inactive projections), query.queryFieldParametersByRole (a role-keyed field-parameter map some exports hang off the query — entries' expr joins the role's well as inactive projections), query.sortDefinition (sorts), objects (see below), visualContainerObjects.visualTooltip/visualHeaderTooltip section → tooltip pageposition, isHidden, parentGroupName, howCreated, visualGroup (a group container carries no query and is skipped whole), syncGroup, expansionStates, drillFilterOtherVisuals
pages/<dir>/visuals/<dir>/mobile.jsonnever read — a visual's phone position and styling (visualContainerMobileState); the objects selectors it can carry name fields the same visual's visual.json already binds, so skipping it never drops a root
definition.mobile/pages/<dir>/page.jsonsame policy as the desktop pages/<dir>/page.json — the phone layout binds the same model, so its pages land in ReportModel::mobile_pages and enumerate as roots (issue #49)same ignore list as the desktop page row
definition.mobile/pages/<dir>/visuals/<dir>/visual.jsonsame policy as the desktop visual rowsame ignore list as the desktop visual row
definition.mobile/* (everything else)never read — the layout carries no report anchor, report measures, or bookmarks; report-level state is desktop-only
bookmarks/*.bookmark.jsonexplorationState.filters → bookmark report-level filters; sections.<page>.filters → section filters, skipped whole as StaleState when the section's page is in neither pageOrder nor the pages/ folders; sections.<page>.visualContainers.<id>.filters → per-visual saved filters; singleVisual.projections/activeProjections → saved wellsoptions, explorationState.objects/version/dataSourceVariables, visualContainerGroups, singleVisual.display/orderBy/expansionStates/…
version.jsonnever read

Persisted automatic filters. A visual's own filter normally lives in the container's filterConfig, but an automatic filter persists only after the filter pane has been expanded in the report's authoring history — and then it appears as a filter property inside the formatting objects (objects.general[].properties.filter). Both shapes join the visual's filters; the other objects properties are conditional formatting, whose fields are collected structurally (a FillRule input, an icon rule's comparison operands).

Filters, aliases, and condition trees. One filter-entry shape serves every scope: the filtered field under field (bookmark states spell it expression), and the condition under filter — a FilterDefinition (Version: 2, From, Where). From maps query aliases to entities; a SourceRef.Source resolves through it case-insensitively, a SourceRef.Entity names the table directly, and a hierarchy on a date variation sources its table through PropertyVariationSource, whose Property (the varied column) and Name (the variation) are carried on the FieldTarget::HierarchyLevel for the graph's variation resolution. An alias that matches no From entry yields FieldTarget::Written plus an UnresolvedAlias notice — the alias is not a table name, so it is never written in as one. Condition trees are walked structurally, not schema-driven: known field containers (Column, Measure, Aggregation with function codes 0–8, Min/Max/Percentile, Hierarchy/HierarchyLevel) are extracted wherever they nest, Literal values are data and never references, and a Where clause's Target arrays are references too. Known non-drift shapes that yield no reference: ScopedEval wrappers (unwrapped transparently), RangePercent bounds in formatting rules (they reuse the Min/Max keys for gradient ends), and visual-calculation sources (below).

Bookmark staleness. Power BI leaves a deleted page's section inside every bookmark that captured it — the saved state survives, but nothing can ever navigate to or re-apply it. A bookmark section therefore counts as a binding only when its page exists, judged by both sources: pages.json pageOrder and the pages/ folders. The live set is their case-insensitive union (a page named by either source is real; stripping its bookmarks' bindings would under-count roots), a disagreement between the sources is itself a StaleState notice, and a section outside the set is skipped whole — filters and saved projections — with one StaleState notice naming the bookmark and section. A bookmark whose activeSection is stale gets the same treatment (folded into the section's notice when they coincide). Report-level explorationState.filters are page-independent and always bind.

Errors. Only the anchor report.json can fail the run (Error::Io / Error::Json) — it is what makes the folder a report. An unreadable page, visual, or bookmark file, a malformed filter field, an unknown property: all notices, never failures.

PBIR known gaps

  • Visual calculations. A field sourced from Subquery, SelectRef, or TransformTableRef names a visual-calculation local, not a model object, so it produces no binding and no notice; a subquery's own Select columns are walked with the subquery's aliases, so the model fields behind a calculation do bind. A SelectRef name (a calculation output referenced elsewhere in the same visual) cannot be resolved here, and a NativeVisualCalculation projection in a field well is not yet modeled.
  • Bookmark-saved display state. singleVisual.orderBy (saved sort), saved formatting merges (singleVisual.objects, explorationState.objects — a conditional-formatting rule changed only inside a bookmark would be missed), and highlight.selection are deliberately unmodeled.
  • Tooltip pages are read from section expr literals; any other spelling yields a MalformedValue notice rather than a silent loss.

Drift policy: two tiers of skipping

Ingestion entry points return Ingested<T> — the parsed value plus a Vec<SkipNotice> (path, TMDL line number or JSON pointer, SkipKind, detail). Notices are warnings as data: core never prints, and the CLI decides presentation. They are collected on every run, not only in debug builds, because a silent skip can surface later as a false "unused" finding.

Tier 1 — deliberately unmodeled, silent. Keys on the curated lists (the TMDL ignore list below; the PBIR Keys tables in src/ingest/pbir.rs) are skipped without a notice. Everything on them is metadata that cannot consume a model object, so skipping it cannot cause a false "unused" finding. The tests hold this honest in both directions: the samples tests fail if a list misses something the samples carry; the golden fixtures fail if a modeled key lands on a list.

Tier 2 — unexpected drift, noticed. An unknown object (root descriptor this crate does not know, unknown file or directory under definition/), an unknown property not on a list, a modeled value that fails to parse (MalformedValue), or a PBIR query alias that cannot be resolved (UnresolvedAlias).

The ignore list

Universal metadata: lineageTag, sourceLineageTag, changedProperty, description, annotation, extendedProperty. Extended properties carry no liveness of their own: the dynamic M parameter binding marker on a column (ParameterMetadata with "kind": 1) is the anonymous half of that binding — the authoritative half is the parameter expression's parameterValuesColumn property, which is modeled (see graph.md, "Dynamic M parameters"). The other ParameterMetadata shapes mark field parameters ("kind": 2) and what-if parameters ("version": 0), whose objects stay alive through ordinary DAX and report references.

Columns: dataType, formatString (static), summarizeBy, sourceColumn, sourceProviderType (the provider-side type of a DirectQuery column), dataCategory, isKey, isNameInferred, isDataTypeInferred, isUnique, isNullable, isDefaultLabel, isDefaultImage, isAvailableInMdx, keepUniqueRows, relatedColumnDetails, tableDetailPosition.

Measures: displayFolder, excludeFromModelRefresh. Tables: excludeFromModelRefresh, showAsVariationsOnly (engine-only visibility of the auto date/time machinery — the machinery itself is identified by the __PBI_LocalDateTable/__PBI_TemplateDateTable annotations, the isPrivate flag, and the LocalDateTable_/DateTableTemplate_ name prefixes, all mapped onto Table flags).

Model/database: culture, sourceQueryCulture, defaultPowerBIDataSourceVersion, discourageImplicitMeasures, dataAccessOptions, valueFilterBehavior, compatibilityLevel, createOrReplace, retainDataTillForceCalculate. Power Query query groups (queryGroup) are unmodeled in every form they take: block declarations in model.tmdl and membership properties on expressions and partitions.

Cultures (folder never read; keys listed for stray uses): cultureInfo, linguisticMetadata, contentType.

Partitions: mode. Roles: modelPermission. KPIs: statusGraphic. Hierarchies/levels: ordinal. Relationships: crossFilteringBehavior, fromCardinality, toCardinality, joinOnDateBehavior, hideArrows, securityFilteringBehavior, reliability.

Refresh policies (TMDL refreshPolicy, a table-level object) are modeled for their two expression properties only: sourceExpression (the RangeStart/RangeEnd-filtered query new policy-range partitions are created from) and pollingExpression (change detection). Both are evaluated at refresh time — deleting what they reference breaks refresh — so they flow through the same keep-alive pipeline as partition M (sourceExpression always; pollingExpression additionally through the DAX lexer, which resolves the measure references of the measure-based change-detection form). The policy's scalar vocabulary — policyType (kept for diagnostics), incrementalGranularity, incrementalPeriods, incrementalPeriodsOffset, rollingWindowGranularity, rollingWindowPeriods — names no model object and stays silent; an unrecognized key inside the policy is ordinary Tier-2 drift.

Verified spellings (validated against the AS engine)

No samples/ model exercises these, so the spellings were verified by loading probe models in the Analysis Services TMDL engine (via tomix-cli's tx load), and the golden fixture is held to the same standard — it loads clean in the engine:

  • KPI: kpi blocks carry statusGraphic plus targetExpression, statusExpression, trendExpression. The short forms (target =, status =, trend =) are not valid TMDL — the engine rejects them — so this parser notices them as drift rather than mapping them.
  • Detail rows: measures use detailRowsDefinition; tables use defaultDetailRowsDefinition (the engine rejects the measure spelling on a table).
  • Calculation-group selection expressions: noSelectionExpression and multipleOrEmptySelectionExpression are objects — the expression itself, with an optional nested formatStringDefinition child for its dynamic format string. Standalone format-string spellings are rejected by the engine.
  • tablePermission: the filter is the = expression (inline or block); a filterExpression child property also loads. A permission with no filter is just tablePermission <table>. All three shapes verified.
  • relatedColumnDetails: a nameless object under a column with one groupByColumn: <column> per grouped column (the shape in samples/…/Toggle for breakdown.tmdl); it feeds Column::group_by_columns.
  • calendar: a named object under a table (calendars require compatibility level 1701). Its column bindings live inside nameless calendarColumnGroup objects in two shapes — a time-related group lists plain column: <column> lines, and a time-unit association carries the unit as the group's = value plus primaryColumn:/associatedColumn: references. All of them name columns of the owning table and all feed Calendar::columns; the shapes were verified against the Analysis Services engine by round-tripping through tomix-cli.
  • The engine also resolves relationship fromColumn/toColumn against the model's tables, and rejects /// doc comments (descriptions) on tablePermission. This parser does not cross-validate references — missing targets are the graph layer's findings, not parse failures — but the golden fixture keeps self-consistent references to stay engine-loadable.

TMDL known gaps

  • Refresh policies are parsed at table level, matching TOM (Table.RefreshPolicy) and the folder TMDL Desktop serializes. A model that nests refreshPolicy under a partition is therefore not the modeled position; the drift policy notices it (unknown property 'refreshPolicy' on partition …) rather than silently reading it — the correct signal, since the policy's expressions keep objects alive.
  • Date variations are modeled (Column::variations): a variation object on a date column declares the relationship and the table-qualified default hierarchy through which the engine serves the column — for auto date/time, a hidden relationship to an engine-generated LocalDateTable_*. The graph layer resolves a report's date-hierarchy binding written over the varied column through this declaration, so a hierarchy reachable only through a variation is no longer mis-reported as unused. Only showAsVariationsOnly (engine-only table visibility) stays on the ignore list; the machinery's identity comes from the __PBI_LocalDateTable / __PBI_TemplateDateTable annotations, isPrivate, and the name prefixes, all mapped onto Table flags.
  • ColumnKind::CalculatedTableColumn has no sampled TMDL form; the column kind is not mapped. If it appears, the drift policy notices it — which is the correct signal, not silence. (The table-level calendar object, once in the same boat, is mapped now — see above.)
  • A multi-line expression that continues at exactly the property level (depth+1) after a non-empty = value is indistinguishable from properties and reads as a sibling; TMDL serialization keeps expression bodies below the property level, so this has not been observed.

Semantic model AST

The normalized shape every source format parses into. Power BI facts that the type definitions cannot state on their own; read this before changing the AST or adding an ingestion format.

What is modeled, and what is not

Modeled: tables, columns, measures, partitions, relationships, hierarchies, RLS roles, calculation groups, KPIs, and model-level shared M expressions. Columns also carry their variations (TOM variation) — the declaration a report's date-hierarchy binding resolves through — and tables carry the engine's auto date/time identity flags (is_private, is_local_date_table, is_template_date_table).

Deliberately absent: data sources, perspectives, cultures and translations, role memberships, annotations, linguistic metadata. None of them consume model objects, so none can keep an object alive, so none affect reachability. Adding them later is additive and breaks nothing — but do not add them speculatively. The single exception proves the rule: the two engine annotations __PBI_LocalDateTable and __PBI_TemplateDateTable are consumed, because they describe an object (which tables are auto date/time machinery) and that description is load-bearing for the linter's verdict — not because they reference anything.

Also absent for the same reason: data types, display folders, source column names, calculation-item precedence. They describe objects; they never reference them.

Power BI semantics the types don't show

A calculated table is not a flag. It is a table whose partition source is DAX rather than M. Table::is_calculated() derives it from the partition; there is no boolean to set, and an ingestion format that invents one is wrong.

A calculation group is a property of a table, not a top-level object. A calculation group table also carries synthetic columns — the group's field column and an ordinal column — in columns like any other table. Nothing distinguishes them structurally, and DAX can reference them ('Time Intelligence'[Period] = "YTD"), so ingestion must map them or those references will not resolve.

Measure names are unique across the whole model, not per table. A measure's home table is provenance for display, not part of its identity for lookup. See name-resolution.md.

Relationships default to active. TMDL omits the flag for active relationships, so Relationship::default() sets is_active: true. An inactive relationship still keeps its key columns alive — USERELATIONSHIP can switch it on at query time — so the flag is for reporting and linting, never for liveness.

A column's sort-by column is a liveness edge. A used column keeps the column it sorts by alive, even when nothing else references it.

Auto date/time is identity, not liveness. The engine generates a hidden LocalDateTable_* per date column (plus one DateTableTemplate_*), and the model says which tables those are: the __PBI_*DateTable annotations, isPrivate, and — for formats that carry neither — the LocalDateTable_/DateTableTemplate_ name prefixes. The flags are display-only metadata; they never confer or remove liveness. Two things read them: the graph's hierarchy-binding resolution (a visual's date hierarchy over a varied column lands on the related LocalDateTable_* through Column::variations), and DependencyGraph::auto_date_time_tables — the provenance-based verdict (in use / unused by reports / dead) that scan renders in its own section. That verdict is deliberately not reachability: the engine's own relationship keeps the machinery alive for as long as the user's date column is used, so "alive" says nothing about whether a report binds it.

Partition sources are four, not two. M (Power Query), DAX (calculated table), a legacy native query in the data source's own dialect, and Other for DirectLake entity partitions, inferred partitions, and kinds Microsoft has not shipped yet. Other and Query yield no DAX and no M, and must never be guessed at — an unrecognized source lands in Other, which is why its Default is not derived.

Expressions hide in unobvious places

Missing one expression site means the objects it references get no edges and are reported unused. That is a false positive, and the scan design forbids them. Beyond the obvious measure and calculated-column expressions, DAX also lives in:

  • dynamic format strings, on measures and on calculation items
  • KPI target, status, and trend expressions
  • detail-rows (drillthrough) definitions, on measures and on tables
  • RLS filters, one per role per table
  • calculation item expressions

TabularDatabase::dax_expressions() and m_expressions() are the only enumeration of these sites. The graph layer consumes those two functions instead of walking the AST, so a new expression-bearing field cannot be silently omitted from reachability analysis. Adding an expression field to the AST means adding it to the enumeration — the tests assert every kind is produced exactly once from a fixture that exercises all of them.

Enumerated expressions borrow their owner's names rather than carrying an owned ObjectId, so walking every expression in a model allocates nothing. The graph layer calls to_object_id() once per node it actually creates, instead of once per expression — a measure with a format string, detail rows, and a KPI would otherwise pay for six identical owner keys. A test pins both views as Copy to keep it that way.

Each enumerated expression carries a home table: the row-context table used to resolve unqualified references inside it. For an RLS filter that is the permission's target table, not anything belonging to the role. For a calculated table's partition it is the calculated table itself, which is deliberately conservative — unqualified columns in such an expression usually belong to the source table, so this can only add candidate edges, never drop them.

Report AST

The normalized shape every report source format parses into — PBIR (definition/ folders) and PBIR-Legacy (report.json Layout). Power BI facts the type definitions cannot state on their own; read this before changing the AST or adding an ingestion format.

What is modeled, and what is not

Modeled: report identity and its dataset link, pages (filters, drillthrough/tooltip bindings, visuals), visuals (field wells, filters, sort-by, conditional formatting, tooltip-page references), bookmarks (saved filters and active projections), report-level measures (reportExtensions.json), and the phone layout's pages (definition.mobile/, issue #49).

Deliberately absent: page order and the active/landing page (pages.json — the one exception, pageOrder, is read at ingest as a bookmark-section liveness authority, but never modeled), themes and resource packages, semanticModelDiagramLayout.json, and every literal value a filter or slicer selection persists.

The phone layout is not in that list, but it is a near miss worth spelling out. Report/definition.mobile/ mirrors definition/ page for page and binds the same model — phone users see those visuals — so its pages are parsed into ReportModel::mobile_pages and enumerate as roots exactly like desktop pages. Only the page/visual tree is read there: no report anchor, report measures, or bookmarks. What stays absent is the per-visual mobile.json inside the desktop tree (visualContainerMobileState): a visual's phone position and styling. It carries no field projections of its own — its objects selectors, where they occur at all, name fields the same visual's visual.json already binds (verified across the sample corpus) — so it is skipped silently. Themes, resource packages, and the diagram layout never reference model objects either; none of the absent artifacts can keep one alive. Adding any of them later is additive — but do not add them speculatively.

Power BI semantics the types don't show

One ReportModel per report, not one per project. A semantic model is often shared by several reports (thin reports, deployment pipelines). Reachability takes the union of all reports' bindings, so the graph core does not care — but provenance does. A BindingRef carries page/visual/bookmark/kind; which report is answered by the ReportModel the binding was enumerated from, and that report's name completes the "used by" explanation.

A slicer is not a kind of binding. A slicer is a visual with visual_type: "slicer"; its field well is the binding. Saved slicer selections are literal values (data), not references.

Bindings are written-form, never resolved. PBIR binds structured JSON entity trees (Column/Measure/HierarchyLevel/Aggregation); legacy Layout binds written names. Both normalize into FieldTarget, which keeps the column-vs-measure discrimination the entity states outright. Resolution against ModelIndex is the graph layer's job; a Measure target should resolve against report measures first — within its report, a report measure shadows a model measure of the same name — then the model.

Query aliases are the parser's problem. PBIR filter condition trees introduce aliases (From: [{Name: "p", Entity: "Product"}], then SourceRef: {Source: "p"}). By the time a filter reaches this AST, Filter::references holds alias-resolved targets. Anything the parser cannot resolve lands in FieldTarget::Written — kept rather than dropped, because a binding we cannot read is still a binding.

Hidden is not dead. Hidden pages and locked/hidden filters still apply; every flag here is display-only, never liveness. Inactive projections bind too — they are one toggle away from live.

Bookmarks are roots. Applying a bookmark re-applies its saved filters and projections, so a field kept alive only by a bookmark is still alive. Bookmark bindings enumerate with bookmark set, alongside the page and visual they captured. One bound: a section whose page the report no longer defines is not a binding — Power BI leaves deleted pages' sections inside bookmarks forever, and a filter on a page nobody can reach would keep its columns alive with no way to re-apply it. Those sections are dropped at ingest (see the bookmark staleness rule in formats.md); they never reach this AST.

Report measures bridge both directions. A report-level measure's DAX body references model objects (so ReportModel::dax_expressions is an expression source on top of TabularDatabase::dax_expressions), and visuals reference it by name. Its graph identity is ObjectId::ReportMeasure — deliberately not ObjectId::Measure, so a report measure can never be conflated with a model measure of the same name. It is a graph node, not a root: a report measure no visual binds and no other DAX names is dead, like any other object (see graph.md).

Tooltip pages are report-internal references. A visual's tooltip_page keeps a page reachable, not a model object, so it lives on the AST but never enumerates as a binding. The page's own visuals bind fields like any other page's.

Bindings hide in unobvious places

Mirroring the model side's rule for expressions: missing one binding site means the objects it references get no roots and are reported unused — a false positive the scan design forbids. ReportModel::bindings is the single enumeration; a new binding-bearing field must be added there or it is silently invisible to reachability. Beyond visual field wells, report-side roots also live in:

  • report-, page-, and visual-level filters (filterConfig) — the declared field and the fields inside the condition tree
  • drillthrough parameters (pageBinding.parameters[].fieldExpr)
  • sort definitions (sortDefinition.sort[].field)
  • conditional-formatting rule keys
  • bookmarks' saved filters — at all three levels, mirroring a live report: report-level (explorationState.filters), per page section, and per visual — and their saved projections
  • report-level measures, whose bodies consume model objects even though the measure itself is report-owned

Provenance vocabulary

A binding's site answers "where does this come from", which later powers explanations like 'Sales'[Amount] ← visual Card 1 on Sales overview (page 2) and per-report slicing:

FieldNone means
pagereport-level (report filter, or a report measure reference)
visualpage-level or report-level
bookmarka live binding, not saved state

The kind (FieldWell { role }, Filter, Sort, Drillthrough, ConditionalFormatting) says what the binding does; the role string preserves the well's name as written ("Category", "Y", "Tooltips"). The mobile flag says which surface the binding lives on — the desktop tree or the phone layout (definition.mobile/); both bind identically, the flag exists so an audit of a survivor names the right surface (issue #49).

DAX lexing and reference resolution

How a DAX expression becomes a list of object references. A lexer plus resolution — not a full parser: nothing here builds a parse tree, evaluates anything, or understands DAX semantics beyond "this shape names an object".

Provenance

The tokenizer (src/dax/lexer.rs) is a Rust port of SQLBI Whiteboard's DaxLexer (sql-bi/SQLBI-Whiteboard), © SQLBI, MIT licensed. Their implementation is the authority on DAX's fiddly corners; the port keeps its behaviours verbatim where listed below and drops everything formatter-shaped:

Ported as-isDeliberately not ported
'' / "" doubled-delimiter escapes (scan_delimited)Comment attachment to tokens (formatter layout)
Dot-absorbing identifiers — NORM.DIST is one tokenTRUE/FALSE upper-casing (printer normalization)
Dot-not-absorbed trailing dot — 'Date'.[Date] is three tokensFunction-name canonicalization via the ~700-name list (printer)
Exponent lookahead — 1.5E+10 is a number, Sales[E] is a columnDaxParser, DaxPrinter, Doc, DaxCodeFormatter (formatter pipeline)
dt"…" date-time literalsDaxClassifier, DefinedObjectName, IsQuery — ingestion already knows each expression's owner
// and -- line comments, /* */ block comments
Tolerance-first scanning: unterminated delimiters run to EOF, never fail

The original is hand-written (no generated grammar) and battle-tested against real-world models; its smoke tests (SQLBI.Whiteboard.Core.SmokeTests, DAX section) supplied the trickiest cases in the Rust test suite — the Tricky := sample where a string contains -- and must not become a comment, and the SUMX ( Sales, … ) definition that must not mistake its own defined name for a use.

Token grammar

Whitespace produces no tokens, so the token after any token is exactly its next significant neighbour — the property the extraction rules rely on.

TokenMatchesNotes
Identifier[A-Za-z_]\w* with Unicode letters, internal dots absorbedNORM.DIST, CHISQ.INV.RT, MÅNED
QuotedTable'…' with '' as the escape'Sales''s Data'
BracketName[…], no escapes — ] cannot appear in an object name[Net Price]; measures, columns, and levels are indistinguishable here
String"…" with "" as the escapenever yields a reference
DateTimedt"…" (case-insensitive prefix)never yields a reference
Numberdigits/dots, exponent only when digits followthe Sales[E] trap
Comment// …, -- …, /* … */never yields a reference
QueryParameter@identDAX queries, not model expressions
Operator, parens, braces, , ; : .the obvious spellings, two-char ops first (==, <>, >=, <=, &&, `
Unknownanything elsescanning continues; nothing panics
Eofalways the last token

Offsets are byte offsets into the expression; all token text is borrowed &str slices — lexing allocates nothing but the token vector.

Extraction rules

A reference is never a single token, so extraction merges adjacent tokens by shape (src/dax/refs.rs):

  1. 'Table' + [Name] → qualified field reference.
  2. Table + [Name] → qualified field reference.
  3. Table alone, quoted or bare → a table use (COUNTROWS(Sales)).
  4. Name + ( → a call (function candidate).
  5. [Name] alone → unqualified field reference.

Strings, comments, numbers, dt"…", and @parameters never produce references. A . [Name] after a reference (hierarchy-level syntax, not valid in DAX model expressions) lexes as an independent unqualified reference — no special handling, no corruption.

Two consequences worth stating outright:

  • The defined name is not special. [Sales Amount] = SUMX(…) lexes [Sales Amount] as an unqualified reference like any other. That is correct: ingestion strips definition headers before expressions reach the lexer, so the owner/use distinction is already resolved by the AST.
  • No function-name list. The rule "identifier + ( is a call" needs no list of ~700 built-ins. Extra references can only ever reduce false positives (they mark objects used), so the conservative direction needs no curation.

The conservatism rule

The same rule as name resolution: over-marking is harmless, under-marking deletes live code.

  • Bare identifiers are table candidates. COUNTROWS(Sales) must keep Sales alive even when no column of Sales is referenced anywhere. A variable or keyword that collides with a table name can only over-mark usage — the safe direction. This is why there is no VAR tracking.
  • Calls are function candidates. A user-defined function (TOM function) called by name is a real dependency; if calls were skipped outright, a function used only through calls would be reported unused. Built-ins (SUM, COUNTROWS, …) resolve to nothing against the model's function table and stay behind as unresolved data.
  • Unqualified [Name] keeps every candidate alive — measure and home-table column alike. See name resolution for why the ambiguity is genuine.

Resolution is data, not errors

dax::bind turns one raw reference into Binding::Bound { targets } (every candidate) or Binding::Unresolved (a stale expression, a typo, a built-in call). There is no error variant and no panic path; the graph layer decides what an unresolvable reference means. The lexer itself is total: malformed input yields degraded tokens, never a failure.

Power Query (M) lexing and reference resolution

How an M expression becomes a list of object references. A lexer plus resolution — not a full parser: nothing here builds a parse tree, evaluates anything, or understands M semantics beyond "this shape names an object". The module lives beside its DAX sibling (src/m.rs, src/m/lexer.rs, src/m/refs.rs) and exists for one reason: whether (and how) a Power Query mention of an object constrains deleting it (issue #39).

Provenance

The tokenizer follows the lexical grammar of the official Power Query specification (M language specification, Lexical Structure), with microsoft/powerquery-parser (MIT) as the battle-tested reference for what the grammar means in practice. Both are references only — ripbi is a single static Rust binary, so the TypeScript parser is not a dependency, the same relationship the DAX lexer has to SQLBI's MIT lexer.

Two spec facts shaped the token set, and both are worth stating because intuition from other languages is wrong here:

  • #"…" is only ever a quoted identifier. M has no interpolated strings; #"Amount {x}" is one identifier token, braces and all. powerquery-parser lexes it the same way.
  • Regular identifiers absorb internal dots (available-identifier dot-character regular-identifier). Table.SelectRows is one token, and so is a dotted parameter use such as Server.Name.
Ported from the spec/parserDeliberately not ported
"" doubled-quote escapes in strings and quoted identifiersLine-mode lexing for multi-line literals (error recovery)
Dot-absorbing identifiers (Table.SelectRows, Server.Name)The #(...) character-escape decoding — the token swallows them verbatim
The #-keyword family (#table, #date, #shared, …) as identifier-shaped tokensThe full keyword list — M keywords are contextual, so the lexer emits identifiers and extraction carries a small keyword set
#!"…" verbatim literalsSection documents, section/shared headers — ingestion already knows each expression's owner
// and /* */ comments, hex 0x numbers, ??/=>/.. operatorsThe parser itself (naive or combinator) — reference extraction never needs a parse tree
Tolerance-first scanning: unterminated delimiters run to EOF, never fail

Token grammar

Whitespace produces no tokens, so the token after any token is exactly its next significant neighbour — the property the extraction rules rely on.

TokenMatchesNotes
Identifier[A-Za-z_]\w* with Unicode letters, internal dots absorbed; #name includedSource, let, Table.SelectRows, #table, Server.Name
QuotedIdentifier#"…" with "" as the escape#"1998 Sales" — never an interpolated string
Verbatim#!"…"a literal that errors at runtime; never yields a reference
String"…" with "" as the escapeonly yields a reference inside a whitelisted call
Numberdigits/dots, 0x hex, exponent only when digits follow{1..5} lexes tolerantly; numbers never yield references
Comment// …, /* … */never yields a reference
[ ]punctuator tokens, not swallowedthe extractor tells field access from record literals
Operator, parens, braces, , ; .longest operator first (..., .., ??, <>, <=, >=, =>)@ is a plain M punctuator
Unknownanything elsescanning continues; nothing panics
Eofalways the last token

Offsets are byte offsets into the expression; all token text is borrowed &str slices — lexing allocates nothing but the token vector.

Extraction rules

Three shapes (src/m/refs.rs), each conservative in the same direction:

  1. Field access. [Amount], #"Sales"[Amount], Source[Amount], each [Amount]. A bracket group is a field access exactly when its contents are one generalized identifier — identifiers, dots, digits, quoted identifiers, blanks. A group like [CommandTimeout = 30] is a record literal and names nothing; the = (any non-GID token, really) is the discriminator. Contents are walked either way, so strings inside a whitelisted call's record arguments still get harvested.
  2. Names. Every bare or quoted identifier is a table/shared-expression candidate — most are let variables that resolve to nothing, which is data. Call names are candidates too: shared expressions frequently hold user-defined functions (fnEasterSunday(year)), and skipping calls would be the one direction this module must never err in. Dotted identifiers additionally emit their dot-separated parts, preserving the old substring matcher's deliberate Server-inside-Server.Name over-marking. Bare M keywords (each, let, …) are never candidates — each [Amount] is row context, not a table named each. The one special qualifier is #shared[Name]: its pieces are query names, not fields.
  3. Column strings. The string arguments of a curated whitelist of column-centric built-ins (Table.ExpandTableColumn, Table.NestedJoin, Table.TransformColumnTypes, Table.ReplaceValue, #table, …) become column candidates. Harvesting is nesting-aware, so pair lists ({{"Amount", type text}}) count. Functions whose string arguments are data (Table.SelectRows, Text.From, Sql.Database) are deliberately absent — only a real column reference may keep a column alive. Table.ReplaceValue is the judgment call: most of its arguments are values, but its trailing column list is not, and a missed column is the unsafe direction.

Strings and comments never yield references by themselves: a name inside a comment or an unrelated string is not a use. That is the one deliberate narrowing against the old whole-word substring matcher, and it is visible in scans — a shared expression named only inside a comment is now correctly reported unused.

Binding

m::bind resolves one raw reference against the model (Binding::Bound { targets } / Binding::Unresolved, same shape as dax::bind). Naming, not keeping alive — what a target is worth is the graph layer's call (next section):

  • Qualified #"Sales"[Amount] → that table and its column, via ModelIndex::resolve_table + resolve_column. No measure fallback: an M expression cannot reference a measure, unlike DAX where a stale qualifier keeps a same-named measure alive.
  • Unqualified [Name] and column stringsevery column of that name model-wide (ModelIndex::resolve_columns). M string arguments carry no row context, so the conservative set is wider than the DAX home-table rule.
  • Names → the table and/or shared expression of that name.

What a mention is worth: supply chain vs liveness

The pipeline is M → tables/columns → DAX → reports, and deletion never breaks upstream. A partition's M reads the source and produces an output table; each model column maps onto that output by name (sourceColumn). Deleting a model column leaves the M untouched — the query still runs, still outputs the column, and the unmapped output is ignored. Refresh breaks only in the other direction: a model column whose sourceColumn is missing from the M output, caused by editing the M or by source drift, never by deleting the column.

So a column named in M — a Changed Type enumeration, an each [Region] filter, a join-key string — is that column's supply chain, not a consumer, and creates deliberately no liveness edge. Unloading the column is always safe; removing it entirely (model and script) means editing the steps that name it, which is the context that rides on the finding (UnusedObject::named_by_m, the named_in_power_query JSON field, the ⭘ Power Query also names it annotation). The binding itself stays name-conservative; the graph applies one production rule on top — only Data columns carry the context, because an M step can only name a column it produces, so a calculated column matching an M name (the auto date/time columns vs Desktop's date-template query) is coincidence. Measure Killer's classification agrees column-for-column here.

A table or shared expression named in M is different: deleting it deletes the query the expression reads or joins, and that breaks refresh. Those stay real Provenance::M edges — a merge source like #"Dim Lookup" in a Table.NestedJoin keeps the whole table alive, and so does a qualified #"Dim Lookup"[Key] field access.

The conservatism rule

Over-marking is harmless; under-marking deletes live code.

  • A keep flows through its owner. The liveness edges (table and expression targets) are ordinary Provenance::M edges from the partition or shared expression, so a dead table's partition keeps nothing alive — a reference never marks anything live on its own.
  • A partition naming its own table creates no edge (no information, and it would cycle with the table-partition edge). Its naming its own columns is the supply-chain context, never a keep.
  • What is not modeled, on purpose: let-binding dataflow (resolving which table a variable denotes), a full M parser, section documents, and .pbix DataMashup ingestion. The RawRef surface is the seam a fuller parser could replace without touching the graph.

Name resolution

How a name written in DAX or a report binding becomes a model object. The rules here are Analysis Services semantics plus one project rule; they are not obvious from the code.

Analysis Services naming rules

Names are case-insensitive, compared under the invariant culture. 'Sales'[Amount] and 'SALES'[amount] are the same object.

Non-ASCII names are normal, not an edge case — Danish models are a primary target, so MÅNED and måned must fold together. Folding goes through one function so the rule has a single definition and swapping the algorithm stays a one-line change. Never fold inline at a call site.

Measure names are unique model-wide. The engine enforces it, which is why a measure resolves from its bare name with no table.

Column names are unique only within their table, and hierarchy names only within theirs. A hierarchy therefore has no unqualified form and no cross-table fallback.

The conservatism rule

Marking one object used too many is harmless. Marking one too few tells a user to delete live code. Every ambiguous case below resolves in favour of keeping objects alive, and any future resolution rule must do the same.

An unresolvable name is data, not an error — a stale expression, a typo, or a table someone deleted by hand. Resolution returns nothing and lets the graph layer decide. Duplicate names are invalid in a real model but do occur in hand-edited files; the first occurrence wins and nothing panics.

Unqualified [Name] is genuinely ambiguous

In DAX row context, [Name] binds to a column of the current table. Outside row context it binds to the measure of that name. Both can exist at once — a measure [Antal] on one table and a column [Antal] on another are both legal.

A lexer cannot tell the two apart without a full parse and semantic analysis, so resolution returns all candidates: the model-global measure and the home table's column. The graph layer must add an edge to every candidate. Resolving to the measure alone would leave a live column with no incoming edge and report it unused.

A primary() helper exists for diagnostics and display, where a single answer is needed. The graph layer must not use it — it drops a candidate by design.

Qualified Table[Name] falls through to a global measure

The named table's columns are tried first. If none matches, a measure of that name anywhere in the model does. This looks wrong and is deliberate: because measure names are model-global, a reference carrying a stale or mistaken table prefix — 'Dato'[Total Sales] for a measure that lives on Sales, or a prefix naming a table that no longer exists — still keeps that measure alive.

Column-before-measure priority matters and is pinned by a test: reversing it would resolve 'Dato'[Antal] to the Sales measure and leave the Dato column unreferenced.

Changelog

All notable changes to this project are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.3.3 - 2026-09-18

Fixed

  • Linux release binaries run on Ubuntu 22.04 — both archives were linked against Ubuntu 24.04's glibc 2.39, so running them on older distributions died with version 'GLIBC_2.39' not found; the aarch64 archive introduced in 0.3.2 never ran on 22.04 ARM CI machines at all. Linux release builds now pin the Ubuntu 22.04 runners, restoring the glibc 2.35 baseline: the binaries run on Ubuntu 22.04 and every newer distribution, and no longer drift when ubuntu-latest moves on.

0.3.2 - 2026-09-18

Added

  • Prebuilt binaries for Linux ARM64 — releases now carry an aarch64-unknown-linux-gnu archive, built natively on GitHub's ubuntu-24.04-arm runner (no cross-compilation, same packaging and sha256 checksums as the other targets). install.sh picks it for both aarch64 and arm64 uname -m output, and ripbi update self-replaces on the platform where it previously stopped with "no prebuilt ripbi binary for linux/aarch64" — ARM CI machines and Graviton boxes now install with the same one-liner as everyone else.

0.3.1 - 2026-09-17

Fixed

  • No stale "update available" notice after a successful ripbi update — the ambient daily notifier ran after update too, comparing the cached latest release against the running process's compile-time version, which a self-update cannot change: the command's last words were "Updated ripbi 0.2.2 → 0.3.0" followed by "ripbi 0.3.0 is available (you have 0.2.2) — run 'ripbi update'". The notice never follows update now (--check already prints both versions); it resumes with the next command, launched from the new binary.

0.3.0 - 2026-09-17

Added

  • Calculated tables vouch for the names their expression makes visible — a calculated table's columns exist only in its partition expression's output (a DATATABLE's headers, an ADDCOLUMNS's string-named columns, the columns of a wrapped FILTER/VALUES table), so a visual or measure reading one of them resolved to nothing and flagged as broken. A qualified miss on a calculated table now resolves when the name is lexically visible in that expression, and still flags when it is visible nowhere — so renaming a DATATABLE header out from under its bindings is real breakage again, the case a blanket "calculated ⇒ resolved" rule would have gone silent on. On a second production workspace the broken-visual findings fell from 24 to the 7 genuinely stale ones.
  • The artifact-breakage pass resolves query-time references — three real models turned every rule into a false broken claim, so the pass now resolves them: a report measure referencing a sibling report measure (the graph's ordinary report-measure edge — the pass simply wasn't mirroring it), @-prefixed extension columns (the SQLBI ADDCOLUMNS(…, "@Krav", …) pattern read back as [@Krav]), and the column names query time introduces in the same expression — string-literal extension names (SELECTCOLUMNS/GROUPBY/ROW) and the table constructor's fixed Value/Value1…N defaults (new dax::quoted_names beside dax::references). Verification needs DAX scope analysis the lexer deliberately does not do, so each rule is under-claim; on a 26-report production workspace the broken-visual findings went from 30 to the one genuinely stale filter.
  • The pairing announce reads one line per fact in every mode; --verbose expands it — the by-name pairing notes and the ignored-reports line of model-centric scans (and the report-name list in the scanning line) were the last uncollapsed walls on a workspace whose reports all bind byConnection: 26 thin reports printed 26 near-identical Note: lines before the actual findings. Issue #65's collapse — one line per initial catalog, count, up to three names, … and N more — is now every mode's shape, the ignored list is capped the same way, and a new -v/--verbose flag restores the full per-report audit trail (every name in the scanning line, one note per report, the complete ignored list). The pairing facts never disappear: a wrong pairing means wrong findings, so the counts and the weak-pairing warning always show.
  • Standard Fabric export properties parse without drift noticesdatabase.tmdl's id, compatibilityMode, and language, report.json's publicCustomVisuals, and page.json's pageBinding.referenceScope join the known-key tables as ignored metadata: real exports carry them on every item and they name no model object. The trigger was scan's clean-ingest bar for breakage findings (#60): on models whose only skips were these, every broken-visual finding was suppressed as if the parser had drifted past the objects the bindings name.
  • Broken visual bindings are findings; --broken gates them (#60) — scan no longer stays silent when a report is broken. Every report binding's resolution is now classified as it becomes a root, and the misses surface as broken_visual findings naming the written field, the page, the visual, and the reason: table_not_found, field_not_found, measure_not_found, hierarchy_not_found, or level_not_found — the static form of the error state the service would render. A binding that resolves onto an artifact whose own DAX binds a reference to nothing inherits the breakage with the artifact named (bound_artifact_broken) while staying a root; the artifact's own bound-or-unbound finding kind remains issue #84's scope. The precision bar mirrors the unused findings' conservatism rule inverted — a false "broken" is itself a breakage claim, so KPI-suffixed variants (… Goal, … Status, … Trend, … Value) resolve when the base measure exists, field parameters and auto date/time hierarchies resolve through their machinery (#52, #47), variation-flavored hierarchies the machinery cannot resolve stay silent, stale qualifiers resolve to their model-global measures, Written references never flag, and — the clean-ingest gate — unknown-object skips from the model ingest suppress the findings entirely with the count explained on the summary line (--strict surfaces the skips behind it); property-level drift cannot hide a name — the object was parsed with it — so it never suppresses. Liveness is untouched: the qualifying-table fallback still roots what it always rooted, and the unused set is byte-identical. Breakage is advisory by default — reported in every output mode (a dedicated human section, broken_visual:<reason> records in --plain, a top-level broken array plus summary.broken/broken_total in --json) but never changing the exit code — and --broken joins the type-flag family to scope the run to breakage and gate on it, so an unused-only gate never fails on a broken visual and vice versa (#84's constraint). The broken-visual PBIP fixture (a healthy card, a card on a dropped column, a card on a broken measure, a KPI-style card that must resolve) pins the contract end to end.
  • Field-parameter role bindings parse and bind; fixture locks for field parameters and auto date/time (#52) — a visual's query.queryFieldParametersByRole — the role-keyed map some exports hang off the query instead of the per-role fieldParameters arrays — is now a known shape: each role's entries' expr fields join that role's well as inactive projections, which bind like any other field, so a parameter table bound only through that key keeps its columns (and, via sort-by/group-by, the hidden auxiliary columns) alive instead of surfacing as drift plus false positives. Two PBIP fixtures lock the no-false-positive guarantees: a field-parameters project whose toggle tables are bound through both mechanisms (with the parameter expression's source columns kept alive by the calculated partition's NAMEOF() calls), and an auto date/time project where a date-hierarchy visual over a varied date column keeps the LocalDateTable_* machinery at an in_use verdict with none of the generated columns flagged — while a plain date-column binding shows the machinery reporting through its dead verdict instead. Validation showed the per-role fieldParameters path already bound (the fixtures lock it); only queryFieldParametersByRole was missed.
  • Dynamic M parameter bindings keep the bound column live (#50) — a model's dynamic M query parameters now record which column they are bound from: the parameter expression's parameterValuesColumn property (the authoritative half of the binding; the column side is an anonymous ParameterMetadata marker). A consumed parameter keeps its bound column alive with the dynamic M parameter binding provenance, so a slicer-fed parameter's column no longer surfaces as unused just because no DAX or report field names it. The chain is report → consuming partition → parameter (Power Query references) → bound column; an unconsumed parameter stays dead and takes its bound column with it, and a binding naming a column the model no longer has keeps nothing alive. Field parameters ("kind": 2) and what-if parameters ("version": 0) remain unmodeled — their columns were never at risk. DirectQuery-column sourceProviderType and the model's valueFilterBehavior are now recognized as Tier-1 metadata instead of drift notices.
  • Incremental refresh change-detection expressions confer liveness (#53) — a table's refreshPolicy was the one partition child the TMDL parser did not read, yet its expressions run at refresh time: pollingExpression (change detection) and sourceExpression (the RangeStart/RangeEnd-filtered source) name model objects — a measure-based "detect data changes" pick, the shared query a custom polling expression reads, the RangeStart/RangeEnd parameters named only there in the Desktop Full-DataView shape — and deleting any of them breaks refresh, the one direction the conservatism policy refuses to get wrong. The policy is now parsed and its two expression properties enumerated like any other expression: pollingExpression through both the DAX pipeline (provenance change detection expression) and the existing M bindings, sourceExpression through the M bindings alone. The policy's scalar vocabulary (periods, granularities) names no model object and stays silent; an unrecognized key inside the policy is an ordinary UnknownProperty notice.
  • Phone-layout bindings keep fields live (#49) — a report's phone layout (Report/definition.mobile/) is now ingested beside the desktop tree: its pages parse with the same walker, their visuals bind the same model, and the bindings become reachability roots — so a field referenced only there, still rendered for phone users, no longer surfaces as an unused finding. Only the page/visual tree is read (no report anchor, report measures, or bookmarks); a missing or anchor-less layout is the common case and is silent. The bindings' provenance reads mobile layout … so an audit of a survivor names the surface that kept it alive, and the summary's root count includes them.

Fixed

  • Injected streams decide the output palettescan::run_in probed the process's real stdout/stderr for terminal detection instead of the Streams it was given, so a test harness whose real stdout was a terminal got ANSI escapes inside otherwise plain output (and any embedder could hit the same). Streams now carries stdout_is_tty beside stderr_is_tty, both palettes derive from the injected streams, and the binary passes the real terminals' state. Test runs are deterministic regardless of the terminal cargo test runs in.

Changed

  • A plain --report folder pairs with a PATH that names a semantic model (#67) — ripbi scan models/X.SemanticModel --report references/ now walks the folder as a search root, with the same pairing tiers, notes, exclusions, and --strict behavior as --model mode, instead of rejecting it with the mode-switch hint. The ripbi.toml target counts as the same explicit model. Other targets keep the error: a .pbip, project folder, .Report, or a discovered project still accepts report items only, and anchor-less .Report folders stay malformed.
  • The auto date/time section is the machinery's only surface (#47) — the engine-generated tables' unused members (the GUID-named columns, hierarchies, and partitions under LocalDateTable_*/DateTableTemplate_*) no longer appear as generic findings indistinguishable from genuine orphans. The section's one verdict per table — with the date column it serves and the dead chain — is the deliberate, actionable report, because the members are not separately actionable: removing the table removes them, and disabling auto date/time on the named column is the fix. The summary line accounts for the covered members ((41 unused auto date/time members covered by their tables' verdicts) on the Artificial Intelligence sample), and --json counts them in summary.auto_date_time.member_findings.
  • The --summary auto date/time line aggregates the machinery (#47) — it now reads Auto date/time: 6 hidden tables over 5 date columns (1 in use, 5 dead), counting the distinct date columns the local tables serve; the shared DateTableTemplate_* serves none, so the columns can be fewer than the tables, and a model whose machinery pairs with no column drops the clause. --json gains summary.auto_date_time.hidden_tables and summary.auto_date_time.date_columns alongside the verdict counts.
  • A documented opt-out for legacy auto date/time models (#47) — docs/output.md now gives the [scan].ignore recipe (ignore = ["LocalDateTable_*", "DateTableTemplate_*"]) that silences the whole section, with the exit-code treatment (suppressed tables count as handled) and the trade-off: the recipe also hides the in use tables' replace-with-a-real-date-table advice.

0.2.2 - 2026-09-13

Fixed

  • ripbi update no longer fails with access denied on Windows — the install scripts create the rib alias as a hard link to ripbi, so once the running binary had been replaced, the alias was still a link to the mapped old image and the plain rename into rib.exe failed with os error 5, leaving ripbi updated while rib stayed behind (and the next check reported "up to date"). Locked targets now get the same move-aside-to-<name>.old treatment as the running binary, the running binary is replaced last so a failure cannot leave a half-installed pair, and stale .old backups are swept on the next update.

0.2.1 - 2026-09-13

Changed

  • By-name pairing notes collapse in count-oriented modes (#65) — in --summary, --plain, and --json, the Note: lines for reports matched only by a byConnection initial catalog now print once per catalog with the count and up to three names (… and N more), instead of one line per report. The default human mode keeps the per-report list, and -q still suppresses everything. The notes stay informational: never --strict-fatal and never in the JSON skips array.

Fixed

  • --model search walks surface malformed .Report folders (#66) — a *.Report folder found under a search folder without a report.json / definition/report.json anchor was pruned silently, so its bindings could not keep objects alive and they surfaced as false "unused" findings. The walk now records it as a malformed_report_item skip notice, which stderr, --json, and --strict all see — matching the explicit --report error path.
  • Power Query labels escape apostrophes in names (#63) — named_in_power_query labels hand-wrapped names in single quotes without doubling internal ones, so a table O'Brien rendered as 'O'Brien' partition instead of the DAX-escaped 'O''Brien' partition that ObjectId's own Display produces. Partition and shared-expression labels now go through NameKey::quoted(), the same escaping as every finding id.

0.2.0 - 2026-09-11

Added

  • Model-centric scans (#32) — ripbi scan --model <path> analyzes one named semantic model against every PBIR report bound to it. Plain --report folders become search folders walked recursively for report items; pairing is by definition.pbir byPath, then the PBIP stem convention, then byConnection initial catalog (name only, since service semanticmodelids and local logicalIds are disjoint GUID namespaces). By-name matches are flagged with a Note: line; reports bound to other models are listed (Ignored … bound to other models) and never fail --strict, while dangling or malformed references join the skip notices that --strict and --json already carry. Zero connected reports refuse with per-category diagnostics instead of scanning model-only.
  • Worst-tables breakdown in --summary (#38) — the summary mode now ends the per-type counts with a Worst tables: block: the (at most 10) tables carrying the most surviving findings, count descending then table name (case-insensitively, the model's identity order), cut from the same post-[scan].ignore findings as the counts. A dead relationship counts under its "from" table; report measures, shared expressions, and functions belong to no table and stay out of the block, and a footer announces how many further tables have findings. --json gains a table field on every finding — the quoted model table, null when it has none — so a consumer can group the same way without parsing ids.
  • Per-type scan filters (#31) — ripbi scan --measures, --columns, --tables, --hierarchies, --partitions, --relationships, --calc-items, --expressions, --functions, and --report-measures report only unused objects of the passed types, in every output mode and in the exit code; the auto date/time section prints only when --tables is among them. --json gains summary.unused_total, the model-wide unused count, so consumers can tell a filtered-away finding from an absent one.
  • --power-query flag (#57) — the "⭘ Power Query also names it" annotation on unused Data columns is now hidden by default and shown on request. --json always carries the underlying named_in_power_query field.
  • Power Query (M) reference extraction (#39) — table partitions and shared expressions are tokenized by a hand-written lexer (mirroring the DAX lexer) and their references bind against the model. A table or shared expression named in M (a merge source, a referenced parameter query) stays live, because deleting it breaks refresh; a column named in M is the column's supply chain, not a consumer, so it rides along on the finding as named_in_power_query instead of an edge. named_by_m attaches to Data columns only — an M step names a column it produces — so auto date/time columns matching Desktop's date-template query no longer carry the supply-chain note. Inactive relationships are now live only through a USERELATIONSHIP call site; an unactivated inactive relationship is a finding itself, with its key columns.
  • Auto date/time identity and in-scan verdict (#16) — tables now carry the engine identity flags (is_private, is_local_date_table, is_template_date_table) from TOM isPrivate, the __PBI_*DateTable annotations, and the LocalDateTable_/DateTableTemplate_ name prefixes. scan renders the three-state verdict (in use / unused by reports / dead) in its own section; unused-by-reports and dead gate the exit code, while in use stays informational. Date variations are modeled on Column, so a report's date-hierarchy binding written over a varied column resolves through the model onto the related LocalDateTable_* and used machinery is no longer false-flagged.
  • Short rib alias (#56) — rib is the same tool as ripbi under a shorter name: both binaries share one entry point, clap derives the displayed name from argv[0], the installers hard-link the alias next to the binary, and the release archives ship both names.
  • ripbi update and a daily update notice (#44) — the new update subcommand resolves the latest GitHub release, verifies the archive's sha256 against sha256sums.txt, and atomically replaces ripbi and rib for script-installed binaries. --check reports latest vs. current and exits 1 when newer; errors exit 2. Cargo-managed installs and source builds print the matching update command instead of self-replacing. Every foreground command also checks (at most once a day, via a detached hidden child) and prints one dim stderr line when a newer release exists; the check sends no data and is disabled by RIPBI_NO_UPDATE_CHECK, non-TTY stderr, CI, or -q.
  • User guide on GitHub Pages — an mdBook at bgarcevic.github.io/ripbi, assembled by a Pages workflow from the docs that live next to the code, with a link checker so a broken internal link fails CI.
  • CONTRIBUTING.md (#41) — dev setup, the CI gates, and where things live, so first contributions land against the same definition of done.

Fixed

  • Bookmark saved filters on deleted pages no longer bind (#48) — Power BI leaves deleted pages' sections inside bookmarks forever, and a saved filter on a page nobody can navigate to kept its columns alive with no way to re-apply it. A bookmark section now binds only when its page exists (the case-folded union of pages.json pageOrder and the pages/ folders; when the two disagree, that is itself a notice). A stale section is skipped whole with one StaleState notice naming the bookmark and section. On the Artificial Intelligence Sample this closes the bookmark-kept-alive delta against the external baseline: unused_total 155 → 171.

0.1.0 - 2026-09-07

First release: the full static-analysis pipeline plus the ripbi scan command that exposes it.

Added

  • TMDL semantic-model ingestion.SemanticModel items (folder, definition/, or a model.tmdl directory) normalize into a unified TabularDatabase: tables, columns, measures, hierarchies, partitions, calculation groups, calendars, relationships, and RLS roles.
  • PBIR report ingestion.Report items normalize into a ReportModel: pages, visuals, and every field binding (values, filters, tooltips, conditional formatting) with report/page/visual provenance. A report finds its model through datasetReference.byPath.
  • Resilient schemas — unknown or drifted properties and objects produce skip notices, never failures; --strict turns them into errors.
  • DAX reference resolution — a zero-copy lexer extracts table, column, measure, and function references from expression strings, including USERELATIONSHIP and calculation-item logic.
  • Dependency graph and reachability — a petgraph DAG with two-pass BFS (strong reachability for exact dead-object findings, weak reachability for relationship and key-column liveness). Report bindings and RLS roles seed the traversal; findings carry their dead-chain annotations (← only used by 'X' (also unused)).
  • ripbi scan [PATH] — discovery of .pbip projects, .SemanticModel/ .Report stem pairing, and connected-report resolution; human, --plain, and --json output; exit code 0 (clean) / 1 (unused found) / 2 (error); -q/--quiet, --strict, --report <PATH> (repeatable), --no-color, --no-input, and an interactive picker when several projects share a directory.
  • ripbi.toml project configtarget, reports, and [scan].ignore object-name globs (case-insensitive */?); flags override the config.
  • Output contract — the JSON schema, exit codes, and config reference are documented in crates/ripbi-cli/docs/output.md.
  • Validated findings — an end-to-end test pins the Adventure Works sample scan against a committed 44-object baseline from an external unused-objects analysis: full agreement, zero false positives.
  • One-line installersinstall.sh (macOS/Linux) and install.ps1 (Windows) resolve the latest GitHub release, verify the archive's sha256 against sha256sums.txt, and install into ~/.local/bin; both can be piped straight from the repository or downloaded, reviewed, and run from a file, with RIPBI_VERSION pinning a release.
  • crates.io publishing — the release workflow publishes ripbi and ripbi-core on every tag (gated on the CARGO_REGISTRY_TOKEN secret), so cargo install ripbi works from 0.1.0 on; the binary crate is now named ripbi (library target unchanged).
  • README — install instructions, a 30-second quickstart with real AdventureWorks output, the exit-code table, and CI/release/crates.io badges.