coop-dax-review
Offline, advisory DAX/model standards linter for our Power BI semantic models. It parses
TMDL (and legacy .bim) models, builds a model catalog, checks measures and model structure
against docs/standards.md (our DAX standards + Microsoft/Tabular best practices), and surfaces
anything that doesn't match. It never edits or blocks — it only reports. Human reports (a
sectioned, colorized terminal report, Markdown, or a self-contained branded HTML file) and
machine JSON for the company analytics agent.
Sibling tool to coop-sql-review — same
architecture and contracts.
Part of the Cooptimize coop suite — if your team uses
coop-agent, coop install installs this plus the
sibling tools (coop-sql-review,
coop-data-doc); coop update keeps them
current.
Install
pipx install coop-dax-review # from PyPI
Use pipx, not system pip, so the tool stays isolated from other CLIs (ms-fabric-cli,
azure-cli) it might otherwise fight over shared pins. For local development (Python 3.10–3.13;
avoid 3.14 — its venvs mis-handle editable installs):
python3 -m venv .venv && .venv/bin/python -m pip install -e ".[dev]"
Usage
coop-dax-review check [MODEL_PATHS...] [--format text|json|markdown|html|sarif] [-o FILE]
[--html FILE] [--md FILE] [--sarif FILE] [--open/--no-open]
[--color/--no-color] [--log-file FILE] [--baseline FILE]
[--write-baseline FILE] [--save-ignores]
[--min-severity error|warning|info] [--strict]
coop-dax-review rules # list every rule (id, severity, tier, agent?)
coop-dax-review explain <RULE-ID> # a rule's rationale + standards excerpt (--format json)
coop-dax-review upgrade [--check] # show the command to update (never self-applies; alias: update)
coop-dax-review --version
MODEL_PATHSpoint at a PBIP/TMDL model folder (*.SemanticModel/definition/...), any folder of.tmdlfiles, or a legacy.bimfile. Directories are searched recursively; defaults to..- Run it with no paths in a terminal and it offers a checkbox picker of the subfolders to check (all selected by default — press ENTER to scan everything).
- Advisory: exit code is always
0.--strictis the opt-in CI gate — exit2when any finding remains at/above--min-severity, or when no models were found/checked at all (so a typo'd path can't pass silently). A zero-model run still renders the full report (withmodels_checked: 0and ascan_emptydiagnostic per searched path). --standards <path>overrides the bundled standards (e.g. point it at a canonical company standards file). Its sha256 travels in the JSON so the agent knows which standards a report used.- A config file can disable rules, override severities, and tune thresholds — all with no
rebuild. It is found, first hit wins, via:
--config; theCOOP_DAX_REVIEW_CONFIGenvironment variable (point a whole CI pipeline at one config); acoop-dax-review.yml(preferred) orrules.yml(the deprecated shared name — every coop-*-review tool reads it, so two tools in one monorepo would fight over it) in the current directory or any parent up to the repo root; else beside the standards file. A broken config (bad YAML, wrong shape, a non-UTF-8 save) is a friendly one-line error naming the file, and a--configor env-var path that doesn't exist is an error too (a typo can't silently drop your overrides). For example, raise what counts as a "non-trivial" measure:rules: DAX-VAR-RETURN: params: { min_functions: 5 } # also: DAX-COMPLEX-NO-HEADER.min_vars, # DAX-DISPLAY-FOLDERS.min_measures, DAX-SIMPLE-FUNCTIONS.min_calculates
coop-dax-review check ./MyModel.SemanticModel
coop-dax-review check . --format json --strict --min-severity warning
coop-dax-review check . --format html # writes a report file and opens it in your browser
coop-dax-review check . --format markdown -o report.md
The default --format text is a sectioned terminal report: a banner, one section per model with
ERROR/WARN/INFO severity badges, and a SUMMARY panel — including a Findings by rule
count table (count desc) so you can see at a glance which rules dominate and tune or disable them
in rules.yml; the Markdown and HTML reports carry the same table. It's colorized automatically
when you're at a terminal and falls back to plain text when piped or redirected (override with
--color/--no-color; NO_COLOR is respected). On a large legacy estate (50+ findings, no
baseline in play) a one-line stderr hint points at --write-baseline ratcheting.
--format html produces a self-contained, branded HTML report (inline CSS + embedded logo, no
network). It is always written to a file — coop-dax-review-report.html by default, or wherever
-o points — and the path is printed and opened in your browser (pass --no-open to skip the open,
e.g. in CI). upgrade/update print the exact command to run yourself (pipx upgrade coop-dax-review, etc.) rather than self-applying, since a package manager can't replace the tool
while it is running; upgrade --check reports whether an update is available and stops there.
Want a report file and the usual console/JSON output in one run? --html FILE, --md FILE, and
--sarif FILE are extra sinks: they write a self-contained HTML, Markdown, and/or SARIF report to
the paths you name in addition to whatever --format prints, and never open a browser. Handy for
CI — e.g. keep the JSON contract on stdout while dropping a human-readable HTML artifact alongside
it:
coop-dax-review check . --format json --html report.html --md report.md
Annotate a pull request in CI (GitHub / Azure DevOps). --format sarif emits a standard
SARIF 2.1.0 report that GitHub code scanning (and Azure DevOps) turn into inline PR
annotations on the exact TMDL/.bim lines. Findings map to their rule/severity, agent-review
items surface as non-blocking notes, and genuinely malformed DAX (a syntax_error diagnostic)
annotates as an error. A ready-to-paste GitHub Actions step:
- name: DAX standards review
run: coop-dax-review check models/ --format sarif -o coop-dax-review.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: coop-dax-review.sarif
The tool stays advisory (exit 0) unless you add --strict, so the SARIF annotations appear
without failing the build — add --strict if you want the build to go red on remaining findings.
What it checks
Run coop-dax-review rules for the live list. Deterministic rules (reported as findings):
| Rule | § | Sev | Flags |
|---|---|---|---|
DAX-MEASURE-CATEGORY |
1 | warning | measure not named [Category: Name] |
DAX-MEASURE-NOT-PREFIXED |
1 | warning | Table[X] where X is a measure (measures take no prefix) |
DAX-COLUMN-PREFIXED |
1 | warning | bare [X] where X is a column (columns need Table[X]) |
DAX-VAR-RETURN |
2 | info | non-trivial measure with no VAR/RETURN structure |
DAX-NO-NESTED-CALCULATE |
3 | warning | CALCULATE nested directly inside CALCULATE (iterator-mediated per-row nesting is fine) |
DAX-FILTER-TABLE-IN-CALCULATE |
4 | warning | FILTER(<table>, <col> = ...) as a CALCULATE filter argument where a plain column filter suffices |
DAX-SNOWFLAKE |
6 | info | a table with relationships chained through it (snowflake link) |
DAX-BIDI-RELATIONSHIP |
7 | warning | a bidirectional cross-filter relationship |
DAX-MARKED-DATE-TABLE |
8 | warning | time-intelligence used (in a measure or calculated column) but no marked Date table |
DAX-MEASURE-IN-ITERATOR |
9 | info | a measure referenced inside a row iterator (hidden context transition) |
DAX-COMPLEX-NO-HEADER |
12 | info | a complex measure (≥3 VARs) without a /* ... */ header |
DAX-DIRECTLAKE-NO-CALC-COL |
13 | warning | a calculated column in a Direct Lake model |
DAX-USE-DIVIDE |
14 | warning | the / operator where DIVIDE() should be used (nonzero-literal divisors like / 1000 are safe and skipped) |
DAX-FORMAT-STRING |
15 | warning | a measure with no explicit formatString |
DAX-NO-FLOAT-KEYS |
16 | info | a relationship key column typed double |
DAX-HIDE-FK-COLUMNS |
17 | info | a visible foreign-key (relationship) column |
DAX-KEY-SUMMARIZEBY-NONE |
18 | info | a numeric key column that auto-aggregates (summarizeBy ≠ none) |
DAX-DISPLAY-FOLDERS |
19 | info | a measure-heavy table with no display folders |
DAX-AUTO-DATETIME |
21 | warning | auto date/time artifacts (LocalDateTable_* / DateTableTemplate_* tables) |
DAX-EARLIER-TO-VAR |
22 | warning | EARLIER/EARLIEST — the legacy pre-VAR idiom; capture the outer row value in a VAR |
DAX-DEAD-INACTIVE-RELATIONSHIP |
23 | warning | an inactive relationship never activated by USERELATIONSHIP() |
DAX-IFERROR-WRAPPING |
24 | warning | IFERROR wrapped around arithmetic (hides real errors; slower than DIVIDE()) |
DAX-MEASURE-DESCRIPTION |
25 | info | a visible measure with no description |
Agent-judgment rules — the tool detects the construct but emits to the JSON agent_review list
(never an auto-finding), because the call needs intent the linter can't infer:
| Rule | § | Judges |
|---|---|---|
DAX-KEEPFILTERS-NEEDED |
5 | whether a CALCULATE boolean filter needs KEEPFILTERS (checked per top-level filter argument) |
DAX-STAR-SCHEMA |
6 | whether a snowflake chain should be flattened to a star |
DAX-CONTEXT-TRANSITION |
9 | whether an iterator's context transition is intended/correct |
DAX-SIMPLE-FUNCTIONS |
10 | whether a CALCULATE-heavy measure could use simpler functions |
DAX-VALIDATION |
11 | whether the §11 validation checklist was run (one model-level item counting the non-trivial measures) |
DAX-IMPLICIT-MEASURE |
20 | whether a visible auto-aggregating numeric column should become an explicit measure |
See RULES.md for the full taxonomy. docs/standards.md §14–§20 are adopted Microsoft/Tabular
best practices (DIVIDE, format strings, key column types, hidden FKs, key summarizeBy, display
folders, explicit measures), and §22–§25 add EARLIER→VAR, dead inactive relationships,
IFERROR-wrapping, and measure descriptions; docs/standards-proposed-additions.md is the original
candidate list.
DAX syntax errors
Beyond the standards rules, check runs a cheap structural validation over every measure body
and every calculated-column expression and reports genuinely malformed DAX — the kind that would
import broken into Power BI — as error-severity syntax_error diagnostics. It catches:
unbalanced parentheses, unbalanced brackets, an unterminated string literal ("..." with no
closing quote), an unterminated block comment (/* ... with no */), and an empty measure body.
Parens/brackets/quotes that live inside an identifier ([Net (USD)]), a string, or a comment are
never counted, so compliant DAX is never flagged. This is drift detection, not a grammar: the
standards rules still run on whatever parsed. Because a syntax_error is error-severity, it flips
the JSON verdict to not-clean and fails --strict (exit 2) — so a broken measure never passes CI
as clean.
Tune it two ways:
rules.ymlknob —syntax_errors: error(default) |warning(demote but keep it visible in the JSON) |off(drop entirely):syntax_errors: warning
- Inline — silence a single occurrence with a
syntax(or bare*) ignore on the finding's line or the line above it. A rule-scoped ignore does not cover a syntax error:// coop-dax-review:ignore syntax
Suppressing findings (adopting on an existing model)
Three deterministic, never-blocking ways to silence findings you've already triaged. All three
apply to regular findings and to agent_review items (the judgment-call prompts in the JSON),
so a triaged item stays silenced everywhere:
-
Inline — drop a comment on a finding's line (or the line directly above it):
// coop-dax-review:ignore DAX-VAR-RETURN reason: legacy measure, rewrite scheduledList several rule ids (
ignore DAX-A, DAX-B), or use a bareignore/*to silence every rule on that line. Thereason:text is for humans; it's ignored by the parser. -
rules.ymlignore list — a human-readable, fingerprint-matched suppression list that lives in the one config file (like a baseline, but readable and hand-editable). Add anignore:block:ignore: - fingerprint: 4ad6aeb79867 rule: DAX-BIDI-RELATIONSHIP # rule / where / note are for humans; matching is by fingerprint where: Sales/FactSales[ProductId] -> DimCustomer[CustomerId] note: intentional many-to-many, reviewed 2026-07
You don't have to hand-copy fingerprints: run
check --save-ignoresand, at an interactive terminal, you get a checkbox of this run's findings grouped by rule × model (a header per group, an "ignore all N" row for multi-finding groups, individual findings nested — all unchecked, opt in to the ones you want gone); the picks are appended to the config file this run read (so a team config beside the standards file, or one found in a parent directory, is updated in place rather than shadowed by a new./rules.yml). Acoop-dax-review.ymlorrules.ymlin your current directory (or any parent up to the repo root) is auto-discovered with no--configflag, so the loop is just "run,--save-ignores, re-run". An ignore entry that no longer matches any finding (you fixed it) is reported as a diagnostic so the list self-cleans. -
Baseline (ratchet) — record today's findings and surface only new ones going forward:
coop-dax-review check . --write-baseline dax-baseline.json # once, to capture the status quo coop-dax-review check . --baseline dax-baseline.json # thereafter: only new findings appear
Each finding has a stable
fingerprint(in the JSON) — independent of line numbers and of file paths, so a baseline written on one machine or from one directory still matches from another — and the baseline is a sorted list of those. A baseline entry that no longer matches any finding (you fixed it) is reported as a diagnostic so the file self-cleans (--write-baselineto prune).One-time migration (schema_version 3): the fingerprint identity changed in this release (a stable core for the volatile-message rules + an occurrence ordinal — coordinated with coop-sql-review's schema 4, one family rule). Delete and regenerate any baseline files and
rules.ymlignore:lists written by earlier versions once:coop-dax-review check <models> --write-baseline baseline.jsonand re-runcoop-dax-review check <models> --save-ignores. Until then, old entries are reported loudly as stale diagnostics on every run — nothing goes silently missing.
Agent JSON contract
{
"tool": "coop-dax-review", "schema_version": 3, "version": "x.y.z",
"standards": {"path": "...", "sha256": "..."},
"models_checked": 2,
"verdict": {"clean": false, "highest_severity": "warning"},
"findings": [{"rule_id":"...","severity":"warning","model":"Sales","file":"...","line":12,
"object":"[Sales: Revenue YTD]","message":"...","standard_ref":"§3","fingerprint":"4ad6aeb79867"}],
"summary": {"error":0,"warning":2,"info":4},
"agent_review": [{"rule_id":"...","model":"Sales","file":"...","line":40,"object":"[...]","note":"...","standard_ref":"§5","fingerprint":"..."}],
"diagnostics": [{"severity":"warning","category":"parse_failed","file":"...","message":"..."}]
}
schema_version lets a consumer pin the shape; verdict/models_checked give a quick machine
verdict + coverage signal; each finding's fingerprint is a stable id for tracking across runs.
Project docs
SPEC.md— architecture, CLI, agent contract, milestones.RULES.md— every standard mapped to a concrete check (deterministic vs agent-judgment).docs/standards.md— the canonical DAX standards the linter checks against (bundled as package data).AGENTS.md— orientation for coding agents (and humans) working in this repo;CLAUDE.mdimports it.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file coop_dax_review-0.21.2.tar.gz.
File metadata
- Download URL: coop_dax_review-0.21.2.tar.gz
- Upload date:
- Size: 95.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aee0a331fbf066270c692631f314e4c5c01b7139f67967afe859a903e7c1a240
|
|
| MD5 |
cbd6ba978a5d82f559ac2689e1c59fe7
|
|
| BLAKE2b-256 |
293c2cd711f7bb4eb5f668c26d0f1c1793683956ee7f70fe42bc4f337643bc47
|
Provenance
The following attestation bundles were made for coop_dax_review-0.21.2.tar.gz:
Publisher:
publish.yml on kabukisensei/coop-dax-review
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
coop_dax_review-0.21.2.tar.gz -
Subject digest:
aee0a331fbf066270c692631f314e4c5c01b7139f67967afe859a903e7c1a240 - Sigstore transparency entry: 2185025674
- Sigstore integration time:
-
Permalink:
kabukisensei/coop-dax-review@b248760402d8f9ffee422cfd0bda0687821bd06a -
Branch / Tag:
refs/tags/v0.21.2 - Owner: https://github.com/kabukisensei
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b248760402d8f9ffee422cfd0bda0687821bd06a -
Trigger Event:
push
-
Statement type:
File details
Details for the file coop_dax_review-0.21.2-py3-none-any.whl.
File metadata
- Download URL: coop_dax_review-0.21.2-py3-none-any.whl
- Upload date:
- Size: 129.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
79edfdbfe3325d3d38c378df07a6e7efd56cce199f10c3413842930d41164f94
|
|
| MD5 |
78614990da021e602b1bc3f4127253e7
|
|
| BLAKE2b-256 |
77a74c893bf4e08f2eca8b294397ae919f544cd5399299b687a38cf4743289c2
|
Provenance
The following attestation bundles were made for coop_dax_review-0.21.2-py3-none-any.whl:
Publisher:
publish.yml on kabukisensei/coop-dax-review
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
coop_dax_review-0.21.2-py3-none-any.whl -
Subject digest:
79edfdbfe3325d3d38c378df07a6e7efd56cce199f10c3413842930d41164f94 - Sigstore transparency entry: 2185025792
- Sigstore integration time:
-
Permalink:
kabukisensei/coop-dax-review@b248760402d8f9ffee422cfd0bda0687821bd06a -
Branch / Tag:
refs/tags/v0.21.2 - Owner: https://github.com/kabukisensei
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b248760402d8f9ffee422cfd0bda0687821bd06a -
Trigger Event:
push
-
Statement type: