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.