ripbi-core 0.2.0

Static analysis engine for Power BI semantic models: TMDL and PBIR ingestion, DAX reference extraction, dependency graph, and reachability
Documentation
# 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](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.