ripbi-core 0.3.2

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

[`BindingRef`]: ../src/report.rs