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
mainand 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:
| Code | Meaning |
|---|---|
0 | Nothing unused |
1 | Unused objects found |
2 | Error (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
| Content | Stream | Notes |
|---|---|---|
| Findings, summary, JSON, plain records | stdout | the machine-readable side |
| Discovery/selection announce, scanning line | stderr | one 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) | stderr | informational, never --strict-fatal; both collapse to one capped line each (--verbose lists every report) |
| Coverage caveat | stderr | once per run |
| Skip notices (parser drift, stale saved state, unresolved dataset references) | stderr | grouped under one header; suppressed in --json mode, where the JSON carries them |
| Errors + hints | stderr | error: … / 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
--reportitems included.--verboseappends 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 writtendefinition.pbirpath — a wrong pairing means wrong findings, so the fact always shows. One line perinitial 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--verboselists 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--verbosepointer; 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 becomeunresolved_dataset_referenceskip notices instead, and a*.Reportfolder with noreport.jsonanchor becomes amalformed_report_itemnotice; both do fail--strict. -
Reports passed explicitly with
--reportare 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
| Code | Meaning |
|---|---|
0 | Clean: 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) |
1 | Unused objects found, auto date/time machinery no report binds — or, under --broken, broken visual bindings found |
2 | Error: 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
| Flag | Effect |
|---|---|
--json | JSON on stdout (schema below). Mutually exclusive with --plain and --summary |
--plain | One <type>\t<id> record per finding, for grep/awk |
-s, --summary | Counts only: the summary line and per-type totals, no findings list. Mutually exclusive with --json and --plain |
-q, --quiet | No output; exit code only |
-v, --verbose | Full 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-measures | Report 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 |
--broken | Report 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-query | Also print the ⭘ Power Query also names it annotations (human output; a no-op in --plain, --json, and -q, whose consumers filter themselves) |
--strict | Any parser skip notice becomes exit code 2 |
--no-color | Never color (color is also off off-TTY, under NO_COLOR, or TERM=dumb) |
--no-input | Never 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].ignoresuppressed 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, so0 unusedfrom 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 tooIt 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.
--jsonalways carries the underlyingnamed_in_power_queryfield 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--brokenselects 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_objectskip 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: anunknown_propertynotice means the object was parsed with its name regardless, so it does not suppress. The suppressed count rides on the summary line, and--strictsurfaces 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 fixedValue/Value1…Ndefaults. 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 (
DATATABLEheaders,ADDCOLUMNS/SELECTCOLUMNSnames), the constructor defaults, the columns written in the expression, and every column of the tables it references (the wrapped-table shapeFILTER/VALUESpasses 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;
Writtenreferences 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. - a KPI visual's synthesized variants (
-
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--tablesis 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 genericTablesgroup.
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--jsonreports the count assummary.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].ignoreglobs —[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 thein usetables' 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.unusedis the length ofunused— after[scan].ignoreand the type flags.summary.unused_totalcounts every unused object in the model before any suppression, filter, or section move, soreachable = objects − unused_totalalways holds and a consumer can tell a filtered-away finding from an absent one.summary.ignoredcounts findings suppressed by[scan].ignore— unused objects and broken bindings alike. On a model with auto date/time machinery, the remaining gap betweenunusedandunused_totalis the machinery:summary.auto_date_time.member_findingscounts its unused members and thedeadverdict count its nested own findings.summary.brokenis the length ofbroken— after[scan].ignore, the unknown-object suppression, and the type flags.summary.broken_totalcounts 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_timecounts the section's rows by verdict, plushidden_tables(every row, all verdicts together),date_columns(the distinct user date columns the machinery serves — the shared template serves none), andmember_findings(the machinery's unused members covered by the rows, absent fromunusedindividually).- Type flags filter the
unusedarray andsummary.unused;summary.unused_totalstays model-wide. Thebrokenarray andsummary.brokenfollow the same rule: every type flag without--brokenempties them,--brokenkeeps only them. Theauto_date_timearray andsummary.auto_date_timecounts follow the section rule: present in full when no type flags are passed or--tablesis among them, empty and zero otherwise. broken(issue #60) carries one row per reported broken visual binding:targetis the written field reference;reasonis one oftable_not_found,field_not_found,measure_not_found,hierarchy_not_found,level_not_found, orbound_artifact_broken;bound_artifactnames the broken artifact the binding lands on (present exactly when the reason isbound_artifact_broken);provenanceis the same binding-site phrase the unused findings'used_byentries carry,mobile layout …prefixed for phone-layout bindings. Bindings suppressed by the clean-ingest bar or by[scan].ignoreare absent entirely; the totals above keep the arithmetic.auto_date_timecarries one row perLocalDateTable_*/DateTableTemplate_*table:verdictisin_use,unused_by_reports, ordead;source_columnnames the varied user column the machinery serves (nullwhen none resolves, e.g. the template);findingis the table's own unused finding — with itsused_bychain — present exactly whenverdictis"dead"(the row moved here fromunused). Rows suppressed by[scan].ignoreare absent entirely. The machinery's other unused members are in no array at all: the row covers them (issue #47), andsummary.auto_date_time.member_findingscounts them.typeis one oftable,column,measure,hierarchy,partition,relationship,role,calculation_item,expression,function,report_measure.tableis the finding's model table, quoted ('Sales') — a relationship reports its "from" side. It isnullfor the kinds with no model table (role,expression,function,report_measure).--plaindeliberately omits it: its records are a two-column grep contract.provenanceis 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 prefixesmobile layout(mobile layout field well 'Y' — visual 'V' on page 'P' in report 'R'), so an audit names the right surface.named_in_power_querylists 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.noticescarries{path, location, kind, detail}per parser skip;kindis one ofunknown_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 usabledatasetReference) andmalformed_report_item(an anchor-less.Reportfolder the search walk pruned). Under--strict,count > 0corresponds to exit code2.
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); arolefinding has no flag and is filtered out whenever any type flag is passed. A broken binding's kind (broken_visual) also never appears in theunusedarray or the generic groups — it has noObjectIdof its own. - Only TMDL semantic models and PBIR reports can be ingested;
.pbix/.pbit/model.bimare 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
--modelneeds a PATH (orripbi.tomltarget) that names a semantic model itself. Any other target — a.pbip, project folder,.Report, or a cwd-discovered project — rejects a plain--reportfolder with a hint naming the mode switch (--reportaccepts report items only). byConnectionpairs by dataset name only (case-insensitiveinitial catalogagainst the model's.platformdisplay name or item stem). ServicesemanticmodelidGUIDs and local.platformlogicalIdGUIDs 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
--modeltarget; 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 thenamed_in_power_queryJSON field and the⭘ Power Query also names itannotation): 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:
- 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.
- 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
USERELATIONSHIPreference, 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::Bindingroot (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_model
→ TabularDatabase) and PBIR reports (ingest::report → ReportModel, its
own section below).
Detection
ingest::semantic_model(path) accepts:
- a
.SemanticModelitem folder (itsdefinition/subfolder is located automatically), or - a
definition/folder itself (any directory that directly contains amodel.tmdl, or is nameddefinition).
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:
| Descriptor | Maps to |
|---|---|
table N (+ isHidden) | Table (defaultDetailRowsDefinition → detail rows) |
column N / column N = dax | Column (data / ColumnKind::Calculated; sortByColumn, nested relatedColumnDetails → group-by columns) |
measure N = dax | Measure (+ formatStringDefinition, detailRowsDefinition, kpi) |
partition N = m|calculated|query|<other> | PartitionSource::M/Calculated/Query/Other |
refreshPolicy | Table::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 = filter | Role / TablePermission |
calculationGroup + calculationItem N = dax | CalculationGroup / CalculationItem |
expression N = m | SharedExpression |
function N = dax | Function |
model / database | ordering/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.json → definition.pbir → reportExtensions.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.
| File | Maps to | Deliberately ignored |
|---|---|---|
report.json (the anchor) | filterConfig → report filters | themeCollection, settings, resourcePackages, slowDataSourceSettings, objects (canvas formatting) |
definition.pbir | DatasetReference | version |
reportExtensions.json | entities[].measures[] → report measures (name, expression, formatString) | dataType, hidden, dataCategory, displayFolder, measureTemplate, references, … |
pages/pages.json | pageOrder 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 state | activePageName, landingPageName |
pages/<dir>/page.json | name, displayName, visibility (HiddenInViewMode → is_hidden), filterConfig, pageBinding (type, parameters[].fieldExpr → drillthrough) | displayOption, height, width, objects, type, visualInteractions |
pages/<dir>/visuals/<dir>/visual.json | container 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 page | position, isHidden, parentGroupName, howCreated, visualGroup (a group container carries no query and is skipped whole), syncGroup, expansionStates, drillFilterOtherVisuals |
pages/<dir>/visuals/<dir>/mobile.json | never 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.json | same 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.json | same policy as the desktop visual row | same 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.json | explorationState.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 wells | options, explorationState.objects/version/dataSourceVariables, visualContainerGroups, singleVisual.display/orderBy/expansionStates/… |
version.json | never 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, orTransformTableRefnames a visual-calculation local, not a model object, so it produces no binding and no notice; a subquery's ownSelectcolumns are walked with the subquery's aliases, so the model fields behind a calculation do bind. ASelectRefname (a calculation output referenced elsewhere in the same visual) cannot be resolved here, and aNativeVisualCalculationprojection 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), andhighlight.selectionare deliberately unmodeled. - Tooltip pages are read from
sectionexpr literals; any other spelling yields aMalformedValuenotice 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:
kpiblocks carrystatusGraphicplustargetExpression,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 usedefaultDetailRowsDefinition(the engine rejects the measure spelling on a table). - Calculation-group selection expressions:
noSelectionExpressionandmultipleOrEmptySelectionExpressionare objects — the expression itself, with an optional nestedformatStringDefinitionchild for its dynamic format string. Standalone format-string spellings are rejected by the engine. tablePermission: the filter is the=expression (inline or block); afilterExpressionchild property also loads. A permission with no filter is justtablePermission <table>. All three shapes verified.relatedColumnDetails: a nameless object under a column with onegroupByColumn: <column>per grouped column (the shape insamples/…/Toggle for breakdown.tmdl); it feedsColumn::group_by_columns.calendar: a named object under a table (calendars require compatibility level 1701). Its column bindings live inside namelesscalendarColumnGroupobjects in two shapes — a time-related group lists plaincolumn: <column>lines, and a time-unit association carries the unit as the group's=value plusprimaryColumn:/associatedColumn:references. All of them name columns of the owning table and all feedCalendar::columns; the shapes were verified against the Analysis Services engine by round-tripping through tomix-cli.- The engine also resolves relationship
fromColumn/toColumnagainst the model's tables, and rejects///doc comments (descriptions) ontablePermission. 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 nestsrefreshPolicyunder apartitionis 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): avariationobject 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-generatedLocalDateTable_*. 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. OnlyshowAsVariationsOnly(engine-only table visibility) stays on the ignore list; the machinery's identity comes from the__PBI_LocalDateTable/__PBI_TemplateDateTableannotations,isPrivate, and the name prefixes, all mapped ontoTableflags. ColumnKind::CalculatedTableColumnhas 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-levelcalendarobject, 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:
| Field | None means |
|---|---|
page | report-level (report filter, or a report measure reference) |
visual | page-level or report-level |
bookmark | a 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-is | Deliberately not ported |
|---|---|
'' / "" doubled-delimiter escapes (scan_delimited) | Comment attachment to tokens (formatter layout) |
Dot-absorbing identifiers — NORM.DIST is one token | TRUE/FALSE upper-casing (printer normalization) |
Dot-not-absorbed trailing dot — 'Date'.[Date] is three tokens | Function-name canonicalization via the ~700-name list (printer) |
Exponent lookahead — 1.5E+10 is a number, Sales[E] is a column | DaxParser, DaxPrinter, Doc, DaxCodeFormatter (formatter pipeline) |
dt"…" date-time literals | DaxClassifier, 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.
| Token | Matches | Notes |
|---|---|---|
Identifier | [A-Za-z_]\w* with Unicode letters, internal dots absorbed | NORM.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 escape | never yields a reference |
DateTime | dt"…" (case-insensitive prefix) | never yields a reference |
Number | digits/dots, exponent only when digits follow | the Sales[E] trap |
Comment | // …, -- …, /* … */ | never yields a reference |
QueryParameter | @ident | DAX queries, not model expressions |
Operator, parens, braces, , ; : . | the obvious spellings, two-char ops first (==, <>, >=, <=, &&, ` | |
Unknown | anything else | scanning continues; nothing panics |
Eof | — | always 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):
'Table'+[Name]→ qualified field reference.Table+[Name]→ qualified field reference.Tablealone, quoted or bare → a table use (COUNTROWS(Sales)).Name+(→ a call (function candidate).[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 keepSalesalive even when no column ofSalesis 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 noVARtracking. - Calls are function candidates. A user-defined function (
TOMfunction) 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.SelectRowsis one token, and so is a dotted parameter use such asServer.Name.
| Ported from the spec/parser | Deliberately not ported |
|---|---|
"" doubled-quote escapes in strings and quoted identifiers | Line-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 tokens | The full keyword list — M keywords are contextual, so the lexer emits identifiers and extraction carries a small keyword set |
#!"…" verbatim literals | Section documents, section/shared headers — ingestion already knows each expression's owner |
// and /* */ comments, hex 0x numbers, ??/=>/.. operators | The 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.
| Token | Matches | Notes |
|---|---|---|
Identifier | [A-Za-z_]\w* with Unicode letters, internal dots absorbed; #name included | Source, 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 escape | only yields a reference inside a whitelisted call |
Number | digits/dots, 0x hex, exponent only when digits follow | {1..5} lexes tolerantly; numbers never yield references |
Comment | // …, /* … */ | never yields a reference |
[ ] | punctuator tokens, not swallowed | the extractor tells field access from record literals |
Operator, parens, braces, , ; . | longest operator first (..., .., ??, <>, <=, >=, =>) | @ is a plain M punctuator |
Unknown | anything else | scanning continues; nothing panics |
Eof | — | always 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:
- 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. - Names. Every bare or quoted identifier is a table/shared-expression candidate —
most are
letvariables 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 deliberateServer-inside-Server.Nameover-marking. Bare M keywords (each,let, …) are never candidates —each [Amount]is row context, not a table namedeach. The one special qualifier is#shared[Name]: its pieces are query names, not fields. - 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.ReplaceValueis 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, viaModelIndex::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 strings → every 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::Medges 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.pbixDataMashup ingestion. TheRawRefsurface 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 whenubuntu-latestmoves on.
0.3.2 - 2026-09-18
Added
- Prebuilt binaries for Linux ARM64 — releases now carry an
aarch64-unknown-linux-gnuarchive, built natively on GitHub'subuntu-24.04-armrunner (no cross-compilation, same packaging and sha256 checksums as the other targets).install.shpicks it for bothaarch64andarm64uname -moutput, andripbi updateself-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 afterupdatetoo, 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 followsupdatenow (--checkalready 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, anADDCOLUMNS's string-named columns, the columns of a wrappedFILTER/VALUEStable), 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 aDATATABLEheader 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
brokenclaim, 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 SQLBIADDCOLUMNS(…, "@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 fixedValue/Value1…Ndefaults (newdax::quoted_namesbesidedax::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;
--verboseexpands 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 bindbyConnection: 26 thin reports printed 26 near-identicalNote:lines before the actual findings. Issue #65's collapse — one line perinitial 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/--verboseflag 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 notices —
database.tmdl'sid,compatibilityMode, andlanguage,report.json'spublicCustomVisuals, andpage.json'spageBinding.referenceScopejoin the known-key tables as ignored metadata: real exports carry them on every item and they name no model object. The trigger wasscan'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;
--brokengates them (#60) —scanno 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 asbroken_visualfindings naming the written field, the page, the visual, and the reason:table_not_found,field_not_found,measure_not_found,hierarchy_not_found, orlevel_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,Writtenreferences 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 (--strictsurfaces 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-levelbrokenarray plussummary.broken/broken_totalin--json) but never changing the exit code — and--brokenjoins 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'squery.queryFieldParametersByRole— the role-keyed map some exports hang off the query instead of the per-rolefieldParametersarrays — is now a known shape: each role's entries'exprfields 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'sNAMEOF()calls), and an auto date/time project where a date-hierarchy visual over a varied date column keeps theLocalDateTable_*machinery at anin_useverdict with none of the generated columns flagged — while a plain date-column binding shows the machinery reporting through itsdeadverdict instead. Validation showed the per-rolefieldParameterspath already bound (the fixtures lock it); onlyqueryFieldParametersByRolewas 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'sparameterValuesColumnproperty (the authoritative half of the binding; the column side is an anonymousParameterMetadatamarker). A consumed parameter keeps its bound column alive with thedynamic M parameter bindingprovenance, 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-columnsourceProviderTypeand the model'svalueFilterBehaviorare now recognized as Tier-1 metadata instead of drift notices. - Incremental refresh change-detection expressions confer liveness (
#53) — a table'srefreshPolicywas the one partition child the TMDL parser did not read, yet its expressions run at refresh time:pollingExpression(change detection) andsourceExpression(theRangeStart/RangeEnd-filtered source) name model objects — a measure-based "detect data changes" pick, the shared query a custom polling expression reads, theRangeStart/RangeEndparameters 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:pollingExpressionthrough both the DAX pipeline (provenancechange detection expression) and the existing M bindings,sourceExpressionthrough 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 ordinaryUnknownPropertynotice. - 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 readsmobile 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 palette —
scan::run_inprobed the process's real stdout/stderr for terminal detection instead of theStreamsit 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).Streamsnow carriesstdout_is_ttybesidestderr_is_tty, both palettes derive from the injected streams, and the binary passes the real terminals' state. Test runs are deterministic regardless of the terminalcargo testruns in.
Changed
- A plain
--reportfolder 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--strictbehavior as--modelmode, instead of rejecting it with the mode-switch hint. Theripbi.tomltargetcounts 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.Reportfolders 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 underLocalDateTable_*/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--jsoncounts them insummary.auto_date_time.member_findings. - The
--summaryauto date/time line aggregates the machinery (#47) — it now readsAuto date/time: 6 hidden tables over 5 date columns (1 in use, 5 dead), counting the distinct date columns the local tables serve; the sharedDateTableTemplate_*serves none, so the columns can be fewer than the tables, and a model whose machinery pairs with no column drops the clause.--jsongainssummary.auto_date_time.hidden_tablesandsummary.auto_date_time.date_columnsalongside the verdict counts. - A documented opt-out for legacy auto date/time models (
#47) —docs/output.mdnow gives the[scan].ignorerecipe (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 thein usetables' replace-with-a-real-date-table advice.
0.2.2 - 2026-09-13
Fixed
ripbi updateno longer fails with access denied on Windows — the install scripts create theribalias as a hard link toripbi, so once the running binary had been replaced, the alias was still a link to the mapped old image and the plain rename intorib.exefailed withos error 5, leavingripbiupdated whileribstayed behind (and the next check reported "up to date"). Locked targets now get the same move-aside-to-<name>.oldtreatment as the running binary, the running binary is replaced last so a failure cannot leave a half-installed pair, and stale.oldbackups 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, theNote:lines for reports matched only by abyConnectioninitial catalognow 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-qstill suppresses everything. The notes stay informational: never--strict-fatal and never in the JSONskipsarray.
Fixed
--modelsearch walks surface malformed.Reportfolders (#66) — a*.Reportfolder found under a search folder without areport.json/definition/report.jsonanchor was pruned silently, so its bindings could not keep objects alive and they surfaced as false "unused" findings. The walk now records it as amalformed_report_itemskip notice, which stderr,--json, and--strictall see — matching the explicit--reporterror path.- Power Query labels escape apostrophes in names (
#63) —named_in_power_querylabels hand-wrapped names in single quotes without doubling internal ones, so a tableO'Brienrendered as'O'Brien' partitioninstead of the DAX-escaped'O''Brien' partitionthat ObjectId's ownDisplayproduces. Partition and shared-expression labels now go throughNameKey::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--reportfolders become search folders walked recursively for report items; pairing is bydefinition.pbirbyPath, then the PBIP stem convention, thenbyConnectioninitial catalog(name only, since servicesemanticmodelids and locallogicalIds are disjoint GUID namespaces). By-name matches are flagged with aNote: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--strictand--jsonalready 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 aWorst 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].ignorefindings 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.--jsongains atablefield on every finding — the quoted model table,nullwhen 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-measuresreport only unused objects of the passed types, in every output mode and in the exit code; the auto date/time section prints only when--tablesis among them.--jsongainssummary.unused_total, the model-wide unused count, so consumers can tell a filtered-away finding from an absent one. --power-queryflag (#57) — the "⭘ Power Query also names it" annotation on unused Data columns is now hidden by default and shown on request.--jsonalways carries the underlyingnamed_in_power_queryfield.- 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 asnamed_in_power_queryinstead of an edge.named_by_mattaches 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 aUSERELATIONSHIPcall 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 TOMisPrivate, the__PBI_*DateTableannotations, and theLocalDateTable_/DateTableTemplate_name prefixes.scanrenders 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 onColumn, so a report's date-hierarchy binding written over a varied column resolves through the model onto the relatedLocalDateTable_*and used machinery is no longer false-flagged. - Short
ribalias (#56) —ribis the same tool asripbiunder a shorter name: both binaries share one entry point, clap derives the displayed name fromargv[0], the installers hard-link the alias next to the binary, and the release archives ship both names. ripbi updateand a daily update notice (#44) — the newupdatesubcommand resolves the latest GitHub release, verifies the archive's sha256 againstsha256sums.txt, and atomically replacesripbiandribfor script-installed binaries.--checkreports 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 byRIPBI_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 ofpages.jsonpageOrderand thepages/folders; when the two disagree, that is itself a notice). A stale section is skipped whole with oneStaleStatenotice naming the bookmark and section. On the Artificial Intelligence Sample this closes the bookmark-kept-alive delta against the external baseline:unused_total155 → 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 —
.SemanticModelitems (folder,definition/, or amodel.tmdldirectory) normalize into a unifiedTabularDatabase: tables, columns, measures, hierarchies, partitions, calculation groups, calendars, relationships, and RLS roles. - PBIR report ingestion —
.Reportitems normalize into aReportModel: pages, visuals, and every field binding (values, filters, tooltips, conditional formatting) with report/page/visual provenance. A report finds its model throughdatasetReference.byPath. - Resilient schemas — unknown or drifted properties and objects produce
skip notices, never failures;
--strictturns them into errors. - DAX reference resolution — a zero-copy lexer extracts table, column,
measure, and function references from expression strings, including
USERELATIONSHIPand calculation-item logic. - Dependency graph and reachability — a
petgraphDAG 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.pbipprojects,.SemanticModel/.Reportstem pairing, and connected-report resolution; human,--plain, and--jsonoutput; 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.tomlproject config —target,reports, and[scan].ignoreobject-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 installers —
install.sh(macOS/Linux) andinstall.ps1(Windows) resolve the latest GitHub release, verify the archive's sha256 againstsha256sums.txt, and install into~/.local/bin; both can be piped straight from the repository or downloaded, reviewed, and run from a file, withRIPBI_VERSIONpinning a release. - crates.io publishing — the release workflow publishes
ripbiandripbi-coreon every tag (gated on theCARGO_REGISTRY_TOKENsecret), socargo install ripbiworks from 0.1.0 on; the binary crate is now namedripbi(library target unchanged). - README — install instructions, a 30-second quickstart with real AdventureWorks output, the exit-code table, and CI/release/crates.io badges.