
Validation Testing
- 71 installs
- 50 repo stars
- Updated June 18, 2026
- josiahsiegel/claude-plugin-marketplace
Helps with testing & qa tasks.
About
validation-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- validation-testing
- Testing & QA
- AI-coding skill
Validation Testing by the numbers
- 71 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,094 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/josiahsiegel/claude-plugin-marketplace --skill validation-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 71 |
|---|---|
| repo stars | ★ 50 |
| Last updated | June 18, 2026 |
| Repository | josiahsiegel/claude-plugin-marketplace ↗ |
What it does
Helps with testing & qa tasks.
Files
Power BI Validation and Self-Testing
Overview
Validation skill for any TMDL, PBIR, DAX, or M artifact a developer (or Claude) generates. The goal: catch syntax, schema, and best-practice errors locally before a Fabric REST deploy fails. This skill is essential for the powerbi-expert agent's Self-Validation Protocol -- whenever the agent writes TMDL or PBIR, it should describe (or run) the matching validation step from this skill.
As of 2026, Power BI validation has four distinct layers, each catching a different class of error:
| Layer | TMDL Tool | PBIR Tool | What it catches |
|---|---|---|---|
| 1. Syntax / parser | TmdlSerializer.DeserializeDatabaseFromFolder (.NET) | JSON schema validation ($schema URLs) | Indentation errors, invalid keywords, malformed JSON |
| 2. Object / schema | TmdlSerializer -> TmdlSerializationException (valid syntax, invalid TOM metadata) | PBIR JSON schemas in microsoft/json-schemas repo | Invalid property combinations, type mismatches, missing required properties |
| 3. Best practice (BPA) | Tabular Editor BPA rules (BPARules.json) or semantic-link-labs.run_model_bpa | PBI-InspectorV2 rules (Base-rules.json) | Anti-patterns, missing display folders, ambiguous relationships, naming conventions |
| 4. Lineage / cross-reference | DAX measure references resolve, sortByColumn exists, calculation group precedence | Bookmarks reference real pages, drillthrough targets exist, theme files present | Dangling references, broken bookmarks, missing visuals |
The cardinal rule: never deploy without passing layers 1 and 2; never merge to main without passing layer 3.
2026 Validation Tooling Snapshot
| Tool | Validates | Runtime | Status |
|---|---|---|---|
TmdlSerializer (Microsoft.AnalysisServices.Tabular) | TMDL syntax + TOM schema | .NET / pythonnet | GA |
Tabular Editor 2 CLI (free) | TMDL load + BPA + custom C# scripts | .NET CLI | GA, free |
Tabular Editor 3 CLI (paid) | Same + advanced rules + DAX debugger | .NET CLI | GA, commercial |
semantic-link-labs.run_model_bpa | TMDL/TOM model BPA from Python | Fabric notebook (Python) | GA, ~60 rules built in |
semantic-link-labs.run_model_bpa_bulk | BPA across all models in workspace | Fabric notebook | GA |
PBI-InspectorV2 ("Fab Inspector") | PBIR / PBIP / Fabric item rules | .NET CLI / Docker | v2.3+, GA |
pbi-tools | PBIX extract/compile + basic TMDL | .NET CLI | Stable for TMDL, evolving for PBIR |
fabric-cicd (built-in) | parameter.yml + repo structure pre-deployment | Python | GA |
DaxFormatter API | DAX syntax | HTTP | GA |
| Microsoft TMDL VS Code extension | TMDL syntax in editor | VS Code | GA |
Community CPIM.TMDL-language-support | TMDL + DAX + M semantic highlighting | VS Code | GA |
| INFO DAX functions | Live model introspection (replaces DMVs) | XMLA / Desktop | GA |
Self-Validation Protocol (For Generated Artifacts)
When generating TMDL or PBIR artifacts inside an agent loop, follow this minimum protocol:
1. Before writing files -- mentally validate the structure: every object reference must resolve, every required property must be set. 2. After writing files -- run a syntax-level parse (TmdlSerializer for TMDL; JSON schema validation for PBIR). 3. Before suggesting deployment -- run a BPA pass (Tabular Editor CLI or semantic-link-labs). 4. Report results inline -- never silently swallow validation errors. Surface line numbers, file paths, and the specific rule that failed.
A valid agent response that generates a 50-line TMDL measure block should always be followed by either:
- (a) A validation script the user can paste, OR
- (b) An inline Bash/PowerShell/Python validation invocation if the environment supports it.
TMDL Validation -- Layer 1 (Syntax Parser)
The fastest, lowest-dependency TMDL syntax check is TmdlSerializer.DeserializeDatabaseFromFolder. It throws:
- `TmdlFormatException` -- the TMDL text has invalid syntax (bad keyword, wrong indentation, malformed expression). Includes
Document,Line, andLineTextproperties pointing to the exact location. - `TmdlSerializationException` -- the TMDL text parses but produces invalid TOM metadata (e.g., a
columnreferences adataTypethat doesn't exist, or apartitionreferences an unknown data source).
Minimal C# validator (.NET 8):
using Microsoft.AnalysisServices.Tabular;
using Microsoft.AnalysisServices.Tabular.Tmdl;
string folder = args[0];
try
{
var db = TmdlSerializer.DeserializeDatabaseFromFolder(folder);
Console.WriteLine($"OK: TMDL parsed. CompatLevel={db.CompatibilityLevel}, Tables={db.Model.Tables.Count}");
return 0;
}
catch (TmdlFormatException fx)
{
Console.Error.WriteLine($"SYNTAX ERROR {fx.Document}:{fx.Line}");
Console.Error.WriteLine($" {fx.LineText}");
Console.Error.WriteLine($" -> {fx.Message}");
return 1;
}
catch (TmdlSerializationException sx)
{
Console.Error.WriteLine($"METADATA ERROR {sx.Document}:{sx.Line}");
Console.Error.WriteLine($" {sx.Message}");
return 2;
}One-liner via Tabular Editor 2 CLI (no C# project required):
# Loads TMDL folder; non-zero exit on parse failure
TabularEditor.exe "MyProject.SemanticModel/definition" -B "MyProject.bim"The -B (bim output) switch forces a deserialize + reserialize round-trip. Any parse failure exits non-zero with the error written to stderr.
For full scripted patterns and Python equivalents, see references/tmdl-validation-recipes.md.
TMDL Validation -- Layer 3 (Best Practice Analyzer)
The Best Practice Analyzer (BPA) is the canonical anti-pattern checker for tabular models. It is the same engine in Tabular Editor 2, Tabular Editor 3, semantic-link-labs, and Fabric > Workspace settings > Best Practice Analyzer.
Tabular Editor 2 CLI (free, recommended for CI):
# Run BPA against a TMDL folder using the official Microsoft rule set
TabularEditor.exe "MyProject.SemanticModel/definition" \
-A "https://raw.githubusercontent.com/TabularEditor/BestPracticeRules/master/BPARules.json" \
-V \
-G
# Exit codes:
# 0 = no violations
# 1 = warnings only
# 2 = errors found (any rule with Severity >= 3) -- pipeline should FAILSwitches that matter for CI/CD:
| Switch | Purpose |
|---|---|
-A <rules.json> | Run BPA with the specified rules file (URL or local path) |
-V | Verbose output (lists each violation) |
-G | GitHub Actions / Azure Pipelines log format (group sections, file paths) |
-D <conn> | Deploy after passing BPA |
-S <script> | Run a C# script before BPA (custom validation) |
Severity-driven failure: when a BPA rule is set to Error (level 3), the CLI immediately stops and exits non-zero. Set BPA rules to Error severity for any anti-pattern that should block a PR; set to Warning for advisory-only rules.
Standard Microsoft rule set: TabularEditor/BestPracticeRules -- ~60 rules covering performance, error prevention, DAX, maintenance, and naming. Always pin to a specific commit in CI.
For a complete BPA rule reference (every Microsoft rule explained, plus how to author custom rules), see references/bpa-rules-reference.md.
TMDL Validation from Python (semantic-link-labs)
%pip install semantic-link-labs -q
import sempy_labs as labs
# Run the default BPA against a deployed model
results = labs.run_model_bpa(
dataset="SalesModel",
workspace="Sales-Dev",
extended=True, # adds VertiPaq Analyzer stats for performance rules
)
results.head(20)
# Run BPA against every model in a workspace and store to delta
labs.run_model_bpa_bulk(
workspace="Sales-Dev",
extended=True,
)
# Custom rule set from a JSON file in the lakehouse
my_rules = labs.model_bpa_rules() # built-in rule definitions
my_rules.append({
"ID": "AVOID_AUTO_DATE",
"Name": "Disable auto date/time",
"Category": "Performance",
"Severity": 3,
"Scope": "Model",
"Expression": "DiscourageImplicitMeasures and not AutoDateTime",
})
labs.run_model_bpa(dataset="SalesModel", rules=my_rules)semantic-link-labs is the Python path for layer 3. Use it inside Fabric notebooks, scheduled BPA runs, or Spark pipelines. See references/tmdl-validation-recipes.md for the full Python validation cookbook including offline TMDL parse from a local folder.
PBIR Validation -- Layer 1 (JSON Schema)
Every PBIR file embeds a $schema URL pointing to the official Microsoft schema in microsoft/json-schemas. This means any JSON Schema validator can syntax-check PBIR files locally.
Python `jsonschema` validator:
import json
import urllib.request
from pathlib import Path
from jsonschema import Draft202012Validator, RefResolver
def validate_pbir_file(pbir_file: Path) -> list[str]:
doc = json.loads(pbir_file.read_text(encoding="utf-8"))
schema_url = doc.get("$schema")
if not schema_url:
return [f"{pbir_file}: no $schema declared"]
schema = json.loads(urllib.request.urlopen(schema_url).read())
validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(doc), key=lambda e: e.path)
return [f"{pbir_file}#{'/'.join(map(str, e.path))}: {e.message}" for e in errors]
# Walk the entire PBIR folder
report_root = Path("MyProject.Report/definition")
all_errors = []
for f in report_root.rglob("*.json"):
all_errors.extend(validate_pbir_file(f))
if all_errors:
print(f"FAIL: {len(all_errors)} schema violations")
for e in all_errors[:50]:
print(f" {e}")
raise SystemExit(1)
print(f"OK: validated {sum(1 for _ in report_root.rglob('*.json'))} PBIR files")Cache the schemas locally for offline CI: git clone https://github.com/microsoft/json-schemas.git once, then point RefResolver at the local copy. Stops your CI from making 1000+ HTTP calls per build.
PBIR Validation -- Layer 3 (PBI-InspectorV2 / Fab Inspector)
NatVanG/PBI-InspectorV2 (also known as Fab Inspector) is the canonical rules-based PBIR/PBIP validator. v2.3+ supports all Fabric item types (semantic models, reports, notebooks, lakehouses) via the -fabricitem switch and the new PBIR enhanced format (the original PBI-Inspector repo only handles PBIR-Legacy).
Install (cross-platform .NET tool):
# Download the latest release from https://github.com/NatVanG/PBI-InspectorV2/releases
# Or use the published Docker image
docker pull natvang/pbi-inspector-v2:latestRun against a PBIP folder:
PBIInspectorCLI \
-fabricitem "./MyProject.Report" \
-rules "./pbi-inspector-rules.json" \
-formats "JSON,HTML,GitHub" \
-output "./inspector-results"
# Exit codes:
# 0 = all rules passed
# 1 = warnings only
# 2 = at least one Error-severity rule failedRules format -- start from Base-rules.json and customize. Each rule has:
Name(display)DescriptionLogType(Error / Warning / Info)Disabled(skip without deleting)Path(JSONPath into PBIR file)Test(one ofisEqualTo,isGreaterThan,isLessThan,mustExist,mustNotExist,regex, etc.)
Common rules to enforce on every PBIR PR:
[
{
"Name": "All visuals have a title",
"LogType": "Error",
"Path": "$.visual.objects.title[0].properties.show.expr.Literal.Value",
"Test": "isEqualTo",
"Expected": "true"
},
{
"Name": "Page count under limit",
"LogType": "Error",
"Path": "$.pages",
"Test": "arrayLengthLessThan",
"Expected": 1000
},
{
"Name": "Bookmarks reference real pages",
"LogType": "Error",
"Path": "$.children[?(@.targetSection)].targetSection",
"Test": "mustResolveToPage"
}
]Full rule examples and CI gating patterns in references/pbir-validation-recipes.md.
Fabric CI/CD, DAX, Lineage, CI Gates & Error Catalog
Focused recipes for fabric-cicd pre-deployment validation, DAX syntax validation without a server, and lineage / cross-reference validation live in references/fabric-dax-lineage-validation.md. GitHub Actions CI gate patterns, common error mappings, and static-validation limits live in references/ci-gates-and-error-catalog.md.
Additional Resources
Reference Files
- `references/tmdl-validation-recipes.md` -- Full TMDL validation cookbook: TmdlSerializer C# patterns, Python pythonnet wrapper, Tabular Editor C# scripts, INFO DAX introspection, offline parsing
- `references/pbir-validation-recipes.md` -- PBIR JSON schema validation, PBI-InspectorV2 rule examples, lineage cross-reference linter, GitHub Actions integration
- `references/bpa-rules-reference.md` -- The standard Microsoft BPA ruleset summary, rule authoring guide, severity strategy, and pinning recipes
- `references/fabric-dax-lineage-validation.md` -- fabric-cicd, DAX syntax, and lineage validation recipes
- `references/ci-gates-and-error-catalog.md` -- CI gate patterns, common validation errors, and static-validation limits
Related Skills
- `powerbi-master:tmdl-mastery` -- TMDL syntax reference (use this when generating TMDL; come back here to validate it)
- `powerbi-master:programmatic-development` -- PBIR generation (use this when generating PBIR; come back here to validate it)
- `powerbi-master:performance-optimization` -- For run-time validation via DAX Studio, VertiPaq Analyzer, Performance Analyzer
Official 2026 References
- TmdlSerializer Class (Microsoft Learn)
- TmdlFormatException Class (Microsoft Learn)
- Tabular Editor BPA documentation
- TabularEditor/BestPracticeRules (official Microsoft rule set)
- NatVanG/PBI-InspectorV2 (Fab Inspector)
- semantic-link-labs Best Practice Analyzer
- microsoft/json-schemas (PBIR official JSON schemas)
- fabric-cicd parameterization
Best Practice Analyzer (BPA) Rules Reference
Complete reference for the Tabular Editor / semantic-link-labs Best Practice Analyzer rule system, the standard Microsoft rule set, severity strategy, custom rule authoring, and CI gating patterns.
What BPA Is
The Best Practice Analyzer is a rules engine that evaluates tabular models against codified anti-patterns. It runs identically in:
- Tabular Editor 2 (free, CLI + UI)
- Tabular Editor 3 (paid, full IDE)
- semantic-link-labs
run_model_bpa(Python, Fabric notebooks) - Power BI Desktop TMDL view > Best Practice Analyzer (added in 2025)
- Fabric workspace Settings > Best Practice Analyzer
The same BPARules.json file works across all of them, so you author rules once and run them everywhere.
The Standard Microsoft Rule Set
TabularEditor/BestPracticeRules is the canonical Microsoft-curated rule set. ~60 rules across 5 categories. Always pin to a specific commit in CI -- new rules can break previously-green builds.
# Pin to a specific commit
RULES_URL="https://raw.githubusercontent.com/TabularEditor/BestPracticeRules/abc1234/BPARules.json"
TabularEditor.exe model -A "$RULES_URL" -VCategories and Headline Rules
Performance (16 rules)
| Rule ID | Severity | What it catches |
|---|---|---|
MODEL_PERFORMANCE_DISABLE_AUTO_DATETIME | Warning | Auto date/time creates hidden tables that bloat the model |
MODEL_PERFORMANCE_AVOID_BIDIRECTIONAL_RELATIONSHIPS | Warning | Bidirectional cross-filter degrades query performance |
DAX_PERFORMANCE_AVOID_DIVISION_OPERATOR | Warning | / doesn't handle div-by-zero; use DIVIDE() |
DAX_PERFORMANCE_AVOID_FILTER_AS_FILTER_ARGUMENT | Warning | CALCULATE(..., FILTER(table, ...)) is slow vs explicit column filters |
DAX_PERFORMANCE_USE_VARIABLES | Info | Repeated subexpressions should be assigned to VARs |
DAX_PERFORMANCE_AVOID_USERELATIONSHIP | Info | Inactive relationships are slower than separate tables |
MODEL_PERFORMANCE_LARGE_TABLE_USE_COLUMNAR_STORAGE | Warning | High-cardinality columns should not be sorted |
MODEL_PERFORMANCE_REDUCE_USAGE_OF_LONG_LENGTH_COLUMNS | Info | Wide string columns hurt VertiPaq compression |
MODEL_PERFORMANCE_OPTIMIZE_RELATIONSHIP_COLUMNS_DATATYPE | Warning | Use integer relationships, not strings |
MODEL_PERFORMANCE_AVOID_FLOATING_POINT_DATATYPES | Warning | Float types use 64 bits per value, no compression |
MODEL_PERFORMANCE_AVOID_HIGH_CARDINALITY_DATETIME_COLUMNS | Warning | DateTime columns at second precision destroy compression |
MODEL_PERFORMANCE_USE_DIRECT_LAKE_FOR_LARGE_FACT_TABLES | Info | Direct Lake recommended over Import for tables >100M rows |
DAX Expressions (12 rules)
| Rule ID | Severity | What it catches |
|---|---|---|
DAX_PRACTICE_AVOID_FORMAT_FUNCTIONS_IN_NUMERIC_MEASURES | Warning | FORMAT() in a numeric measure breaks the data type |
DAX_PRACTICE_USE_TREATAS_INSTEAD_OF_INTERSECT | Info | TREATAS is faster and more readable |
DAX_PRACTICE_USE_COALESCE_INSTEAD_OF_ISBLANK | Info | COALESCE(x, 0) is cleaner than IF(ISBLANK(x), 0, x) |
DAX_PRACTICE_AVOID_IFERROR | Warning | IFERROR masks bugs; use DIVIDE, CONTAINS, etc. |
DAX_PRACTICE_USE_SELECTEDVALUE_INSTEAD_OF_VALUES | Info | SELECTEDVALUE is more idiomatic for single-value contexts |
Error Prevention (10 rules)
| Rule ID | Severity | What it catches |
|---|---|---|
ERROR_PREVENTION_PROVIDE_FORMAT_STRING_FOR_MEASURES | Error | Every measure should have a formatString |
ERROR_PREVENTION_PROVIDE_FORMAT_STRING_FOR_COLUMNS | Warning | Numeric/date columns need a format string |
ERROR_PREVENTION_PROVIDE_DATA_CATEGORY_FOR_GEOGRAPHY_COLUMNS | Warning | City/State/Country columns need DataCategory set |
ERROR_PREVENTION_AVOID_INVALID_RELATIONSHIPS | Error | Many-to-many without explicit handling can produce wrong results |
ERROR_PREVENTION_USE_THE_DIVIDE_FUNCTION | Warning | Same as DAX_PERFORMANCE_AVOID_DIVISION_OPERATOR |
Maintenance (15 rules)
| Rule ID | Severity | What it catches |
|---|---|---|
MAINTENANCE_REMOVE_REDUNDANT_PREFIXES_FROM_COLUMNS | Info | Sales[Sales Amount] -> Sales[Amount] |
MAINTENANCE_HIDE_FOREIGN_KEYS | Warning | FK columns should be hidden from end users |
MAINTENANCE_HIDE_FACT_TABLE_COLUMNS | Info | Use measures, not raw column drag-and-drop |
MAINTENANCE_PROVIDE_DESCRIPTION_FOR_MEASURES | Info | Documentation rule -- every measure should have a description |
MAINTENANCE_AVOID_CALCULATED_COLUMNS_USE_POWER_QUERY | Info | Calculated columns are slow vs Power Query equivalent |
MAINTENANCE_REMOVE_UNUSED_COLUMNS | Warning | Unreferenced columns waste memory |
MAINTENANCE_REMOVE_UNUSED_MEASURES | Info | Orphan measures clutter the field list |
Naming Conventions / Formatting (8 rules)
| Rule ID | Severity | What it catches |
|---|---|---|
NAMING_OBJECTS_SHOULD_NOT_START_WITH_SPACE | Error | Leading space in name |
NAMING_AVOID_SPECIAL_CHARS_IN_NAMES | Warning | &, %, # etc. in names break some clients |
NAMING_USE_TITLE_CASE_FOR_TABLE_NAMES | Info | Sales Order not salesOrder |
NAMING_NO_SNAKE_CASE_IN_NAMES | Info | total_sales should be Total Sales |
FORMATTING_SET_DEFAULT_AGGREGATION_FOR_NUMERIC_COLUMNS | Info | summarizeBy: sum for numeric columns by default |
BPA Rule File Format
[
{
"ID": "MODEL_PERFORMANCE_DISABLE_AUTO_DATETIME",
"Name": "Disable auto date/time",
"Category": "Performance",
"Description": "Auto date/time creates hidden date tables that consume memory and break time intelligence patterns. Always disable and use a proper date dimension.",
"Severity": 2,
"Scope": "Model",
"Expression": "AutoDateTime = false",
"FixExpression": null,
"CompatibilityLevel": 1200
},
{
"ID": "ERROR_PREVENTION_PROVIDE_FORMAT_STRING_FOR_MEASURES",
"Name": "Provide format string for measures",
"Category": "Error Prevention",
"Description": "Every measure should set a format string to ensure consistent display.",
"Severity": 3,
"Scope": "Measure",
"Expression": "FormatString <> \"\" and not IsHidden",
"FixExpression": "FormatString = \"#,##0\""
}
]Field Reference
| Field | Required | Purpose |
|---|---|---|
ID | Yes | Unique identifier (uppercase + underscores) |
Name | Yes | Display name in BPA UI |
Category | Yes | Performance / DAX Expressions / Error Prevention / Maintenance / Naming Conventions / Formatting |
Description | Recommended | Multi-line explanation; appears in BPA UI tooltip |
Severity | Yes | 1=Info, 2=Warning, 3=Error (blocks CI) |
Scope | Yes | Object type the rule applies to (see below) |
Expression | Yes | DAX-like predicate. Returns true if the rule passes. |
FixExpression | No | DAX-like assignment that BPA can apply via "Generate Fix Script" |
CompatibilityLevel | No | Minimum compat level the rule applies to |
Scope Values
Model-- runs once per modelTableColumnCalculatedColumnDataColumnMeasureHierarchyLevelPartitionRelationshipRoleKPIPerspectiveCultureCalculationGroupCalculationItemNamedExpressionModelRole
You can use multiple scopes via comma: "Scope": "Column, CalculatedColumn".
Expression Language
The Expression field uses a constrained DAX-like syntax that operates on TOM properties of the scoped object. Key things to know:
- Property names match TOM (e.g.,
FormatString,IsHidden,DataType) - String comparison is case-sensitive
- LINQ-style methods on collections:
Measures.Any(m => m.IsHidden),Columns.Count(c => c.SortByColumn = null) - Boolean operators:
and,or,not - Special functions:
Contains(),RegExMatch(),Split()
Example: Complex measure rule
{
"ID": "CONTOSO_MEASURE_FOLDERS",
"Name": "Measure must have a non-empty display folder",
"Category": "Maintenance",
"Severity": 2,
"Scope": "Measure",
"Expression": "DisplayFolder <> \"\" or IsHidden"
}Example: Cross-object rule using LINQ
{
"ID": "CONTOSO_NO_ORPHAN_COLUMNS",
"Name": "Column must be referenced by a measure or relationship",
"Category": "Maintenance",
"Severity": 2,
"Scope": "Column",
"Expression": "IsHidden or Model.AllMeasures.Any(m => m.Expression.Contains(DaxObjectFullName)) or Model.Relationships.Any(r => r.FromColumn = outerIt or r.ToColumn = outerIt)"
}Example: Regex naming rule
{
"ID": "CONTOSO_FACT_TABLE_NAMING",
"Name": "Fact tables must start with 'fct_'",
"Category": "Naming Conventions",
"Severity": 1,
"Scope": "Table",
"Expression": "Not Measures.Any() or RegExMatch(Name, \"^fct_\")"
}Authoring Custom Rules
From Tabular Editor 2 UI
1. Tools > Best Practice Analyzer 2. Click "Add new rule" 3. Set Scope, Category, Severity 4. Write the expression in the editor (with autocomplete) 5. Click "Test rule" to preview matches against the loaded model 6. Save -- rule is added to BPARules.json in %APPDATA%\TabularEditor\ (or local file if "Save as project file")
From semantic-link-labs (Python)
import sempy_labs as labs
# Get the built-in rules as a starting point
rules = labs.model_bpa_rules()
# Add a custom rule
rules.append({
"ID": "CONTOSO_REQUIRE_FOLDER",
"Name": "Measure must have a display folder",
"Category": "Maintenance",
"Severity": 2,
"Scope": "Measure",
"Expression": 'DisplayFolder <> ""',
})
# Save to a JSON file for reuse
import json
with open("contoso-bpa-rules.json", "w") as f:
json.dump(rules, f, indent=2)Severity Strategy for CI
The most important BPA decision is which rules are Error severity, because Errors fail the pipeline.
Recommended severity tiers
| Tier | Severity | Use for |
|---|---|---|
| Block PR | Error (3) | Issues that produce wrong results, deployment failures, or security exposure |
| Warn PR | Warning (2) | Performance and maintainability issues that should be fixed but not block |
| Notify only | Info (1) | Style preferences, naming conventions, opportunities |
Suggested Error-tier rules (block PR)
These should always be Error severity because they cause wrong data or failed deploys:
ERROR_PREVENTION_PROVIDE_FORMAT_STRING_FOR_MEASURESERROR_PREVENTION_AVOID_INVALID_RELATIONSHIPSERROR_PREVENTION_USE_THE_DIVIDE_FUNCTIONNAMING_OBJECTS_SHOULD_NOT_START_WITH_SPACEMODEL_PERFORMANCE_DISABLE_AUTO_DATETIME(production models only)- Any custom rule that enforces RLS or compliance requirements
Suggested Warning-tier rules
MAINTENANCE_HIDE_FOREIGN_KEYSMAINTENANCE_PROVIDE_DESCRIPTION_FOR_MEASURESDAX_PERFORMANCE_AVOID_FILTER_AS_FILTER_ARGUMENTMODEL_PERFORMANCE_OPTIMIZE_RELATIONSHIP_COLUMNS_DATATYPE
Per-environment severity overrides
Use different rule files per environment if dev should be lenient and prod should be strict:
# Dev: warnings only (quick iteration)
te2 model -A bpa-rules-dev.json -V
# Prod: strict
te2 model -A bpa-rules-prod.json -VPinning Rules in CI
Always pin BPA rules to a specific commit in CI. The Microsoft rule set evolves; an upstream rule addition can break a green pipeline overnight.
# .github/workflows/validate.yml
env:
BPA_RULES_COMMIT: "abc1234567890def" # pin a specific commit
BPA_RULES_URL: "https://raw.githubusercontent.com/TabularEditor/BestPracticeRules/${BPA_RULES_COMMIT}/BPARules.json"
steps:
- name: Run BPA
run: te2 model -A "${{ env.BPA_RULES_URL }}" -V -GBump the commit SHA when you intentionally adopt new rules. Treat that as a model-validation change like any other.
Rule Authoring Tips
1. Test on a real model before committing the rule -- BPA expressions are easy to write and easy to get wrong 2. Use `outerIt` when writing LINQ expressions on collections to refer to the outer-scope object 3. Prefer positive expressions -- write what should be true, not what's wrong (IsHidden = true not IsHidden = false) 4. Use `or IsHidden` as a conventional escape hatch -- hidden objects are usually internal and exempt from style rules 5. Document the why in Description -- future-you will not remember why you wrote this rule 6. Test FixExpression carefully -- it's executed against every matching object when the user clicks "Generate Fix Script" 7. Avoid network or filesystem access in expressions -- BPA runs locally on the loaded model only
Migrating from Legacy BestPracticeAnalyzer.dll
The standalone Power BI Best Practice Analyzer DLL (pre-2024) is deprecated. Migrate to the Tabular Editor BPA rule format:
| Legacy | Modern |
|---|---|
BPA DLL with .bpaRules files | TabularEditor BPARules.json |
Rule per .cs file | Single JSON array |
| Visual Studio dependency | None -- runs in TE2 CLI |
| Power BI Desktop only | Cross-platform (Linux, macOS, Windows, Fabric notebook) |
The legacy rule format is not automatically converted. Rewrite the rules in the JSON format above; the rule logic typically translates directly because both engines target TOM properties.
Troubleshooting BPA Failures
| Symptom | Cause | Fix |
|---|---|---|
Expression compile failed: 'X' is not a property | Property name typo or wrong scope | Check TOM docs for the exact property name; verify scope matches |
| Rule reports false positives on hidden objects | Missing or IsHidden escape hatch | Add or IsHidden to the expression |
| Rule passes locally but fails in CI | Different BPA rule set version | Pin both local and CI to the same rule set commit |
| Rule never fires | Expression always returns true | Negate the test value or use not (...) |
| BPA exits 0 even with violations visible | Severity is Info or Warning -- only Error blocks | Bump to Severity 3 if it should fail CI |
RegExMatch is not defined | Old BPA engine | Update to Tabular Editor 2.17+ or TE3 |
outerIt is not defined | Used outside a LINQ expression | Only valid inside Any() / All() / Where() |
| Custom rule slow on large models | Cross-object LINQ with Model.AllMeasures.Any(...) | Cache the result via a Model-scope rule that builds a set |
Power BI Validation CI Gates and Error Catalog
GitHub Actions CI gate patterns, common validation errors mapped to the tool that catches them (TMDL parser, Tabular Editor BPA, PBIR schema validation, PBI-InspectorV2 / Fab Inspector), and a concise list of runtime issues that static validation cannot catch. SKILL.md keeps the toolchain overview and per-layer validation entry points; this reference holds CI wiring and troubleshooting lookup material.
CI Gate Pattern (GitHub Actions)
Minimum gate to put on every PR that touches a PBIP project:
name: Power BI Validation Gate
on:
pull_request:
paths:
- "**/*.tmdl"
- "**/*.pbir"
- "**/*.json"
- "MyProject.SemanticModel/**"
- "MyProject.Report/**"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0'
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install validators
run: |
pip install jsonschema fabric-cicd
curl -L -o te2.zip https://github.com/TabularEditor/TabularEditor/releases/latest/download/TabularEditor.Portable.zip
unzip te2.zip -d te2
# Layer 1+2: TMDL parser + TOM schema
- name: Validate TMDL syntax and metadata
run: |
mono te2/TabularEditor.exe "MyProject.SemanticModel/definition" -B "/tmp/check.bim"
# Layer 3: BPA
- name: Run BPA (fails on Error severity)
run: |
mono te2/TabularEditor.exe "MyProject.SemanticModel/definition" \
-A "https://raw.githubusercontent.com/TabularEditor/BestPracticeRules/master/BPARules.json" \
-V -G
# Layer 1: PBIR JSON schemas
- name: Validate PBIR schemas
run: python ./scripts/validate_pbir.py "MyProject.Report/definition"
# Layer 3: PBIR rules
- name: Run PBI-InspectorV2
run: |
docker run --rm -v "$PWD:/work" natvang/pbi-inspector-v2:latest \
-fabricitem /work/MyProject.Report \
-rules /work/pbi-inspector-rules.json \
-formats GitHub
# fabric-cicd parameter.yml + structure
- name: Validate fabric-cicd parameters
run: python -m fabric_cicd.debug_parameterization --repository-directory . --environment prodThis gate runs in under 3 minutes for a typical PBIP and catches ~95% of issues that would otherwise fail at deploy time.
Common Errors Catalog (What Each Tool Catches)
| Error Class | Caught by |
|---|---|
| Indentation / keyword typo in TMDL | TmdlSerializer (TmdlFormatException), Tabular Editor CLI -B |
| Unknown property on a TMDL object | TmdlSerializer (TmdlSerializationException) |
| Measure references undefined column | TOM model.Validate(), BPA, semantic-link-labs |
| sortByColumn points to missing column | TOM model.Validate(), BPA |
| DAX syntax error | DaxFormatter API, Tabular Editor (any deploy/load) |
| M syntax error | Power Query engine on first refresh; partial check via Tabular Editor -S |
| Implicit measures used | BPA DAX_PERFORMANCE_AVOID_IMPLICIT_MEASURES |
| Auto date/time enabled | BPA MODEL_PERFORMANCE_DISABLE_AUTO_DATETIME |
| Many-to-many relationship without explicit intent | BPA MODEL_PRACTICE_AVOID_MANY_TO_MANY |
| PBIR file fails JSON schema | jsonschema Python library, VS Code with $schema IntelliSense |
| PBIR visual missing required field | PBI-InspectorV2 mustExist rules |
| PBIR bookmark references deleted page | PBI-InspectorV2 lineage rule, custom Python linter |
| PBIR page count > 1000 | PBI-InspectorV2 arrayLengthLessThan, fabric-cicd at deploy |
parameter.yml references unknown env | fabric-cicd built-in pre-deployment validation |
| Connection string still has dev GUID after parameterization | fabric-cicd debug_parameterization.py |
| Service principal lacks workspace role | Caught only at deploy -- no static check |
What Validation CANNOT Catch (Run-Time Checks)
These categories require an actual deploy or refresh and cannot be statically validated:
- Data source credentials (gateway, Key Vault, OAuth tokens)
- Direct Lake fallback to DirectQuery under load
- DAX query timeouts on large data
- Refresh failures on source schema drift
- Visual rendering bugs in specific browsers
- Mobile layout overflow
For these, rely on Fabric Deployment Pipeline test stages, scheduled refresh alerts, and semantic-link-labs.run_dax smoke-test queries after deploy.
Fabric CI/CD, DAX, and Lineage Validation
Focused recipes for fabric-cicd pre-deployment validation, DAX syntax validation without a server, and lineage / cross-reference validation. SKILL.md keeps core TMDL and PBIR validation layers; this reference holds adjacent validation checks.
fabric-cicd Pre-Deployment Validation
fabric-cicd runs automatic parameter.yml validation before publishing. If parameter.yml is malformed or contains an unknown environment, the deployment fails before touching the workspace. This is the cheapest possible CI safety net.
Trigger validation manually without deploying:
# Use the debug script shipped in the fabric-cicd devtools folder
python debug_parameterization.py \
--repository-directory ./MyProject \
--environment prod \
--item-type-in-scope SemanticModel,ReportThis parses every *.tmdl, *.json, and *.pbir file, applies the find_replace and key_value_replace transformations, and reports any unresolved placeholder. Run this in CI on every PR, regardless of whether the PR actually deploys.
DAX Syntax Validation (No Server Required)
The free DaxFormatter API parses DAX text and reports formatting + syntax errors:
import requests
def check_dax(expression: str) -> tuple[bool, str]:
r = requests.post(
"https://www.daxformatter.com/api/daxformatter/DaxRichFormat",
json={
"dax": f"EVALUATE ROW(\"x\", {expression})",
"maxLineLenght": 120,
"skipSpaceAfterFunctionName": "BestPractice",
},
)
body = r.json()
return ("error" not in body, body.get("formatted", body.get("error", "")))
ok, formatted = check_dax("CALCULATE([Total Sales], DATESYTD('Date'[Date]))")For an offline DAX parser, Tabular Editor 2's -S C# script switch can call Microsoft.AnalysisServices.Tabular.DAXLexer directly. Recipe in references/tmdl-validation-recipes.md.
Lineage and Cross-Reference Validation
Beyond syntax and BPA, an agent generating a model should verify:
1. Every measure references columns/measures that exist 2. Every `sortByColumn` resolves 3. Every relationship endpoint is a real column 4. Every PBIR bookmark `targetSection` exists in `pages.json` 5. Every PBIR drillthrough/tooltip `pageBinding` resolves 6. No circular relationships or measure references
The simplest tool: load the model with TmdlSerializer, then run model.Validate() (TOM method) which returns ValidationResult.Errors. For PBIR, walk the JSON tree comparing name references against the page/visual inventory.
A complete cross-reference linter (Python, ~80 lines) lives in references/pbir-validation-recipes.md.
PBIR Validation Recipes
Complete cookbook for validating PBIR (Power BI Enhanced Report Format) at every layer: JSON schema, structural integrity, lineage cross-references, PBI-InspectorV2 rules, and CI integration. All recipes target the 2026 PBIR rollout (PBIR is the default in the Service from January 25, 2026 and in Desktop from May 2026).
1. Layer 1: JSON Schema Validation
Every PBIR file embeds a $schema URL pointing to the official Microsoft schema in microsoft/json-schemas. Use any JSON Schema validator to syntax-check files before they hit Fabric.
1a. Python validator with jsonschema (online schemas)
import json
from pathlib import Path
import urllib.request
from jsonschema import Draft202012Validator
_SCHEMA_CACHE = {}
def fetch_schema(url: str) -> dict:
if url not in _SCHEMA_CACHE:
_SCHEMA_CACHE[url] = json.loads(urllib.request.urlopen(url).read())
return _SCHEMA_CACHE[url]
def validate_pbir_file(pbir_file: Path) -> list[str]:
doc = json.loads(pbir_file.read_text(encoding="utf-8"))
schema_url = doc.get("$schema")
if not schema_url:
return [f"{pbir_file}: no $schema declared"]
schema = fetch_schema(schema_url)
validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(doc), key=lambda e: list(e.path))
return [
f"{pbir_file}#/{'/'.join(map(str, e.absolute_path))}: {e.message}"
for e in errors
]
def validate_pbir_folder(report_root: Path) -> int:
total_files = 0
total_errors = []
for f in report_root.rglob("*.json"):
total_files += 1
total_errors.extend(validate_pbir_file(f))
if total_errors:
print(f"FAIL: {len(total_errors)} schema violations across {total_files} files")
for e in total_errors[:50]:
print(f" {e}")
if len(total_errors) > 50:
print(f" ... {len(total_errors) - 50} more")
return 1
print(f"OK: validated {total_files} PBIR files")
return 0
if __name__ == "__main__":
import sys
sys.exit(validate_pbir_folder(Path(sys.argv[1])))Run with:
python validate_pbir.py "MyProject.Report/definition"1b. Offline validator (CI without internet)
For air-gapped CI, clone the schemas once and resolve references locally.
git clone https://github.com/microsoft/json-schemas.git ./schemasimport json
from pathlib import Path
from referencing import Registry, Resource
from jsonschema import Draft202012Validator
def build_local_registry(schemas_root: Path) -> Registry:
registry = Registry()
for schema_file in schemas_root.rglob("schema.json"):
text = schema_file.read_text(encoding="utf-8")
doc = json.loads(text)
if "$id" in doc:
registry = registry.with_resource(uri=doc["$id"], resource=Resource.from_contents(doc))
return registry
REGISTRY = build_local_registry(Path("./schemas/fabric/item/report/definition"))
def validate_offline(pbir_file: Path) -> list[str]:
doc = json.loads(pbir_file.read_text(encoding="utf-8"))
schema_url = doc["$schema"]
schema_resource = REGISTRY.get_or_retrieve(schema_url)
validator = Draft202012Validator(schema_resource.value.contents, registry=REGISTRY)
return [f"{pbir_file}: {e.message}" for e in validator.iter_errors(doc)]1c. VS Code editor-time validation
VS Code already validates JSON against $schema URLs out of the box. To enforce explicit schema references for PBIR files, add to .vscode/settings.json:
{
"json.schemas": [
{
"fileMatch": ["**/visuals/*/visual.json"],
"url": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/1.0.0/schema.json"
},
{
"fileMatch": ["**/pages/*/page.json"],
"url": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/1.0.0/schema.json"
},
{
"fileMatch": ["**/definition/report.json"],
"url": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/report/1.0.0/schema.json"
},
{
"fileMatch": ["**/definition.pbir"],
"url": "https://developer.microsoft.com/json-schemas/fabric/item/report/definitionProperties/2.0.0/schema.json"
}
]
}Now every PBIR file gets red-squiggle validation while the developer types.
2. Layer 2: Structural Validation
Beyond per-file JSON schema, PBIR has structural rules that span multiple files. Catch these with a Python walker.
from pathlib import Path
import json
def validate_pbir_structure(report_root: Path) -> list[str]:
"""
Validates that a PBIR report folder has the required structure
and that all required entry files exist.
"""
errors = []
definition = report_root / "definition"
# Required entry files
required = [
report_root / "definition.pbir",
definition / "report.json",
definition / "version.json",
definition / "pages" / "pages.json",
]
for r in required:
if not r.exists():
errors.append(f"MISSING: {r}")
# Every page folder must contain a page.json
pages_dir = definition / "pages"
if pages_dir.exists():
for page_dir in pages_dir.iterdir():
if page_dir.is_dir():
page_json = page_dir / "page.json"
if not page_json.exists():
errors.append(f"PAGE missing page.json: {page_dir}")
# Every visual folder under the page must contain visual.json
visuals_dir = page_dir / "visuals"
if visuals_dir.exists():
for visual_dir in visuals_dir.iterdir():
if visual_dir.is_dir() and not (visual_dir / "visual.json").exists():
errors.append(f"VISUAL missing visual.json: {visual_dir}")
# Every bookmark file must end with .bookmark.json
bookmarks_dir = definition / "bookmarks"
if bookmarks_dir.exists():
for f in bookmarks_dir.glob("*.json"):
if f.name == "bookmarks.json":
continue
if not f.name.endswith(".bookmark.json"):
errors.append(f"BOOKMARK file must end with .bookmark.json: {f}")
return errors3. Layer 4: Lineage / Cross-Reference Linter
The linter that catches what JSON schema cannot: dangling page references in bookmarks, drillthrough targets that don't exist, theme files that aren't checked in.
from pathlib import Path
import json
from collections import defaultdict
def lint_pbir_lineage(report_root: Path) -> list[str]:
errors = []
definition = report_root / "definition"
pages_dir = definition / "pages"
# Build inventory: page name -> page metadata
pages_by_name: dict[str, dict] = {}
visuals_by_name: dict[str, set[str]] = defaultdict(set) # page_name -> set of visual names
if pages_dir.exists():
for page_dir in pages_dir.iterdir():
if not page_dir.is_dir():
continue
page_json_path = page_dir / "page.json"
if not page_json_path.exists():
continue
page_doc = json.loads(page_json_path.read_text(encoding="utf-8"))
page_name = page_doc.get("name", page_dir.name)
pages_by_name[page_name] = page_doc
visuals_dir = page_dir / "visuals"
if visuals_dir.exists():
for visual_dir in visuals_dir.iterdir():
if visual_dir.is_dir() and (visual_dir / "visual.json").exists():
v_doc = json.loads((visual_dir / "visual.json").read_text(encoding="utf-8"))
visuals_by_name[page_name].add(v_doc.get("name", visual_dir.name))
# Check bookmarks reference real pages and visuals
bookmarks_dir = definition / "bookmarks"
if bookmarks_dir.exists():
for bm_file in bookmarks_dir.glob("*.bookmark.json"):
bm = json.loads(bm_file.read_text(encoding="utf-8"))
target_page = bm.get("targetSection") or bm.get("displayName")
if target_page and target_page not in pages_by_name:
errors.append(f"BOOKMARK '{bm_file.name}' targets missing page '{target_page}'")
# Check children (visual states)
for child in bm.get("children", []):
child_page = child.get("targetSection")
if child_page and child_page not in pages_by_name:
errors.append(f"BOOKMARK '{bm_file.name}' child targets missing page '{child_page}'")
# Check drillthrough / tooltip pageBindings
for page_name, page_doc in pages_by_name.items():
for binding in page_doc.get("pageBindings", []):
target = binding.get("name")
if target and target not in pages_by_name:
errors.append(f"PAGE '{page_name}' pageBinding targets missing page '{target}'")
# Check report.json defaultPage annotation
report_json = definition / "report.json"
if report_json.exists():
rj = json.loads(report_json.read_text(encoding="utf-8"))
for ann in rj.get("annotations", []):
if ann.get("name") == "defaultPage":
if ann["value"] not in pages_by_name:
errors.append(f"report.json defaultPage annotation points to missing page '{ann['value']}'")
# Check theme references resolve to RegisteredResources
static_resources = report_root / "StaticResources" / "RegisteredResources"
if report_json.exists():
rj = json.loads(report_json.read_text(encoding="utf-8"))
for theme_loc in ("baseTheme", "customTheme"):
theme = rj.get("themeCollection", {}).get(theme_loc)
if theme and theme.get("type") == "RegisteredResources":
resource_name = theme.get("name", "")
# Resource files use a UUID prefix; check by suffix
if static_resources.exists():
matches = list(static_resources.glob(f"*{resource_name}*"))
if not matches:
errors.append(f"Theme references missing RegisteredResource: {resource_name}")
return errorsThis catches the four most common PBIR lineage breakages: 1. Bookmark targets a deleted page 2. Drillthrough pageBinding targets a deleted page 3. defaultPage annotation references a deleted page 4. Theme references a missing static resource file
4. PBI-InspectorV2 (Fab Inspector) Rules
PBI-InspectorV2 is the canonical rules-based PBIR validator. v2.3+ supports the enhanced PBIR format and all Fabric item types via the -fabricitem switch. The original PBI-Inspector repo is PBIR-Legacy only -- always use v2 for new work.
4a. Install and run
# Native binary (recommended for CI speed)
curl -L -o pbi-inspector.zip https://github.com/NatVanG/PBI-InspectorV2/releases/latest/download/PBIInspectorCLI-linux-x64.zip
unzip pbi-inspector.zip -d ./pbi-inspector
chmod +x ./pbi-inspector/PBIInspectorCLI
# Run against a PBIR folder
./pbi-inspector/PBIInspectorCLI \
-fabricitem "./MyProject.Report" \
-rules "./pbi-inspector-rules.json" \
-formats "GitHub,JSON,HTML" \
-output "./inspector-results"
# Or via Docker
docker run --rm -v "$PWD:/work" natvang/pbi-inspector-v2:latest \
-fabricitem /work/MyProject.Report \
-rules /work/pbi-inspector-rules.json \
-formats GitHubExit codes:
0= all rules passed1= warnings only2= at least one Error-severity rule failed (should fail CI)
4b. Starter rule set
Save as pbi-inspector-rules.json and customize:
{
"Description": "Contoso PBIR baseline rules",
"Version": "1.0",
"Rules": [
{
"Name": "All visuals must have a title",
"Description": "Every visual on every page should have a title for accessibility",
"LogType": "Error",
"FileType": "VisualJSON",
"Path": "$.visual.objects.title[0].properties.show.expr.Literal.Value",
"Test": [{"isEqualTo": "true"}]
},
{
"Name": "Page count must not exceed Fabric limit",
"Description": "PBIR service limit is 1000 pages per report",
"LogType": "Error",
"FileType": "PagesJSON",
"Path": "$.pageOrder",
"Test": [{"arrayLengthLessThan": 1000}]
},
{
"Name": "Visuals per page must not exceed Fabric limit",
"Description": "PBIR service limit is 1000 visuals per page",
"LogType": "Error",
"FileType": "PageJSON",
"Path": "$.visualContainers",
"Test": [{"arrayLengthLessThan": 1000}]
},
{
"Name": "No PBIR-Legacy report.json at root",
"Description": "Reports must be in PBIR enhanced format only",
"LogType": "Error",
"FileType": "Report",
"Path": "$.report.json",
"Test": [{"mustNotExist": true}]
},
{
"Name": "Custom visuals must be approved",
"Description": "Only allowlisted custom visuals are allowed",
"LogType": "Error",
"FileType": "VisualJSON",
"Path": "$.visual.visualType",
"Test": [
{"isOneOf": [
"barChart","columnChart","pieChart","tableEx","matrix","slicer","textbox",
"card","multiRowCard","kpi","actionButton","image","shape",
"ApprovedVisual_AcmeBars","ApprovedVisual_AcmeMaps"
]}
]
},
{
"Name": "Drillthrough pages must be hidden",
"Description": "Pages used only for drillthrough should not appear in nav",
"LogType": "Warning",
"FileType": "PageJSON",
"Path": "$",
"Test": [
{"if": {"path": "$.filters[?(@.type=='Drillthrough')]", "exists": true}},
{"then": {"path": "$.visibility", "isEqualTo": 1}}
]
}
]
}The Test array supports many predicates: isEqualTo, isGreaterThan, isLessThan, mustExist, mustNotExist, regex, isOneOf, arrayLengthLessThan, arrayLengthGreaterThan, if/then. See the full list at PBI-InspectorV2 README.
4c. Disabling rules without deleting
{ "Name": "Slow rule we don't care about right now", "Disabled": true, "..." }The Disabled: true flag is the recommended way to suppress a rule temporarily without losing the definition.
5. fabric-cicd Pre-Deployment Validation
fabric-cicd runs automatic parameter.yml validation at the start of every deployment. If parameter.yml is missing keys, references unknown environments, or contains malformed YAML, the deployment fails before any item is published. This is the cheapest possible CI safety net.
5a. Run validation without deploying
python -m fabric_cicd.devtools.debug_parameterization \
--repository-directory ./MyProject \
--environment prod \
--item-type-in-scope SemanticModel,ReportThis parses every *.tmdl, *.json, and *.pbir file, applies the find_replace and key_value_replace transformations, and reports any:
- Unresolved placeholders (e.g.,
find_value: "$dev_lakehouse_id"with no environment-specific replacement) - YAML syntax errors in
parameter.yml - Mismatched environment names (parameter.yml says
prod, CLI saysproduction) - Empty
replace_valueblocks
5b. Use it in CI on every PR
- name: Validate fabric-cicd parameters
run: |
pip install fabric-cicd
python -m fabric_cicd.devtools.debug_parameterization \
--repository-directory . \
--environment prod \
--item-type-in-scope SemanticModel,ReportRun this on every PR -- not just deployment branches -- so parameter drift is caught at PR review time, not at 3 AM during a release.
6. PBIR Visual Type Allowlisting
A specific PBIR linter pattern that catches unapproved custom visuals. Useful in regulated environments where only certain visuals are allowed.
from pathlib import Path
import json
ALLOWED_VISUALS = {
# Built-ins
"barChart", "columnChart", "lineChart", "areaChart", "pieChart", "donutChart",
"tableEx", "matrix", "card", "multiRowCard", "kpi", "slicer", "textbox",
"actionButton", "image", "shape", "gauge", "scatterChart", "treemap",
# Approved custom visuals
"Acme.PowerBI.Visuals.ApprovedBarChart",
"Microsoft.PowerBI.SankeyDiagram",
}
def lint_visual_types(report_root: Path) -> list[str]:
errors = []
for visual_json in (report_root / "definition" / "pages").rglob("visual.json"):
v = json.loads(visual_json.read_text(encoding="utf-8"))
vtype = v.get("visual", {}).get("visualType", "")
if vtype and vtype not in ALLOWED_VISUALS:
errors.append(f"UNAPPROVED visual type '{vtype}' in {visual_json}")
return errors7. Combined PBIR Validation Pre-Commit Hook
#!/usr/bin/env bash
set -e
# Find all PBIR JSON files in the staging area
CHANGED_PBIR=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(pbir|json)$' | grep -E '(definition|Report)/' || true)
if [ -z "$CHANGED_PBIR" ]; then
exit 0
fi
# Layer 1: JSON Schema
python ./scripts/validate_pbir.py "MyProject.Report/definition" || {
echo "PBIR schema validation FAILED. Fix and recommit."
exit 1
}
# Layer 2+4: Structure + Lineage
python ./scripts/lint_pbir_lineage.py "MyProject.Report" || {
echo "PBIR lineage check FAILED. Fix and recommit."
exit 1
}
# Layer 3: PBI-InspectorV2 (errors block; warnings allowed)
./pbi-inspector/PBIInspectorCLI \
-fabricitem "./MyProject.Report" \
-rules "./pbi-inspector-rules.json" \
-formats GitHub
INSPECTOR_EXIT=$?
if [ $INSPECTOR_EXIT -eq 2 ]; then
echo "PBI-InspectorV2 ERROR severity rules failed. Fix and recommit."
exit 1
fi
echo "PBIR validation passed."
exit 08. Common PBIR Validation Failures
| Symptom | Cause | Fix |
|---|---|---|
$schema not found | File missing $schema URL | Add the appropriate $schema for the file type |
additionalProperties not allowed: 'X' | Hand-edited file uses a property that doesn't exist on the schema | Check schema URL for valid properties; remove the unknown one |
| Bookmark targets missing page | Page deleted but bookmark not updated | Delete the bookmark or recreate the page |
pageBinding name not unique | Two drillthrough pages have the same pageBinding.name | Rename one (use a GUID for uniqueness) |
| Visual won't render after PBIR conversion | Missing query.queryState after manual edit | Reload from a known-good version; never hand-edit queryState |
Report has more than 1000 pages (deploy time) | Service-enforced limit | Split into multiple reports OR archive old pages |
| Bookmarks group references orphaned bookmark | Bookmark file deleted but bookmarks.json not updated | Run lineage linter to find and remove the orphan reference |
| Theme not applied after deployment | RegisteredResource file missing or not committed to Git | Verify the file exists in StaticResources/RegisteredResources/ |
definition.pbir rejected by Fabric Git | Schema version too low | Update to version: "4.0" |
definition.pbir byPath doesn't resolve | Relative path wrong (case sensitivity on Linux runners) | Use ./MyProject.SemanticModel (forward slash, exact case) |
TMDL Validation Recipes
Complete cookbook for validating TMDL files at every layer: syntax parser, TOM schema, BPA, lineage, and DAX/M syntax. Recipes target the 2026 toolchain (TmdlSerializer in Microsoft.AnalysisServices.Tabular, Tabular Editor 2/3, semantic-link-labs).
1. Layer 1+2: TmdlSerializer Round-Trip (.NET)
Smallest possible validator. One C# file, one NuGet package, no project required (use dotnet script or a single-file compile).
1a. Single-file C# script
#r "nuget: Microsoft.AnalysisServices.NetCore.retail.amd64, 19.84.1"
using System;
using Microsoft.AnalysisServices.Tabular;
using Microsoft.AnalysisServices.Tabular.Tmdl;
if (Args.Count == 0)
{
Console.Error.WriteLine("Usage: validate-tmdl <folder>");
return 1;
}
string folder = Args[0];
try
{
var db = TmdlSerializer.DeserializeDatabaseFromFolder(folder);
Console.WriteLine($"OK: TMDL parsed");
Console.WriteLine($" CompatibilityLevel = {db.CompatibilityLevel}");
Console.WriteLine($" Tables = {db.Model.Tables.Count}");
Console.WriteLine($" Measures = {db.Model.Tables.Sum(t => t.Measures.Count)}");
Console.WriteLine($" Relationships = {db.Model.Relationships.Count}");
// Layer 2 deeper check: TOM Validate()
var validation = db.Model.Validate();
if (validation.Errors.Count > 0)
{
Console.Error.WriteLine($"VALIDATE ERRORS ({validation.Errors.Count})");
foreach (var e in validation.Errors)
Console.Error.WriteLine($" - {e.Object?.GetType().Name}: {e.Message}");
return 3;
}
return 0;
}
catch (TmdlFormatException fx)
{
Console.Error.WriteLine($"SYNTAX ERROR {fx.Document}:{fx.Line}");
Console.Error.WriteLine($" {fx.LineText}");
Console.Error.WriteLine($" -> {fx.Message}");
return 1;
}
catch (TmdlSerializationException sx)
{
Console.Error.WriteLine($"METADATA ERROR {sx.Document}:{sx.Line}");
Console.Error.WriteLine($" {sx.Message}");
return 2;
}Run with:
dotnet script validate-tmdl.csx -- "MyProject.SemanticModel/definition"1b. Why both TmdlFormatException AND TmdlSerializationException?
| Exception | Layer | What it catches |
|---|---|---|
TmdlFormatException | Parser (text -> tokens) | Bad indentation, invalid keyword, malformed expression delimiter, missing colon |
TmdlSerializationException | Object builder (tokens -> TOM) | Property name doesn't exist on object type, value doesn't match property type, required parent missing |
model.Validate() returns Errors | TOM semantic | Measure references undefined column, sortByColumn invalid, calculation group precedence collision, role expression syntax |
Always catch all three and report them with distinct exit codes so CI can route different failure types to different log streams.
2. Layer 1+2: TmdlSerializer from Python (pythonnet)
When you need TMDL parsing inside a Python pipeline (Fabric notebook, Airflow, GitLab CI), pythonnet lets Python load the .NET TOM assembly directly. semantic-link-labs uses this internally.
%pip install pythonnet -q
import clr
import os
clr.AddReference(os.path.join(os.path.dirname(__file__), "Microsoft.AnalysisServices.Tabular.dll"))
from Microsoft.AnalysisServices.Tabular import Database
from Microsoft.AnalysisServices.Tabular.Tmdl import TmdlSerializer, TmdlFormatException, TmdlSerializationException
def validate_tmdl_folder(folder: str) -> dict:
try:
db = TmdlSerializer.DeserializeDatabaseFromFolder(folder)
result = {
"ok": True,
"compat_level": db.CompatibilityLevel,
"tables": db.Model.Tables.Count,
"measures": sum(t.Measures.Count for t in db.Model.Tables),
}
validation = db.Model.Validate()
if validation.Errors.Count > 0:
result["ok"] = False
result["validation_errors"] = [e.Message for e in validation.Errors]
return result
except TmdlFormatException as fx:
return {"ok": False, "type": "syntax", "document": fx.Document, "line": fx.Line, "message": fx.Message}
except TmdlSerializationException as sx:
return {"ok": False, "type": "metadata", "document": sx.Document, "line": sx.Line, "message": sx.Message}
print(validate_tmdl_folder("./MyProject.SemanticModel/definition"))Inside a Fabric notebook the assembly is already on the runtime, so the explicit AddReference becomes:
import sempy.fabric # implicitly loads Microsoft.AnalysisServices.Tabular
from Microsoft.AnalysisServices.Tabular.Tmdl import TmdlSerializer3. Tabular Editor 2 CLI Validation
The free Tabular Editor 2 CLI is the recommended runner for any CI pipeline that doesn't have a .NET project.
3a. Parse-only validation (no BPA, no deploy)
# Linux / macOS via mono
mono TabularEditor.exe "MyProject.SemanticModel/definition" -B "/tmp/out.bim"
# Windows
TabularEditor.exe "MyProject.SemanticModel\definition" -B "C:\temp\out.bim"The -B switch (bim output) forces a TmdlSerializer round-trip plus a TOM validation. Any failure exits non-zero with a precise error message.
3b. Parse + BPA + custom script
TabularEditor.exe "MyProject.SemanticModel/definition" \
-S "Scripts/validate-naming.csx" \
-A "https://raw.githubusercontent.com/TabularEditor/BestPracticeRules/master/BPARules.json" \
-V \
-G| Switch | Effect |
|---|---|
-S <file> | Run a C# script before BPA. Use this for custom validation that BPA can't express. |
| `-A <url\ | file>` |
-V | Verbose: list every BPA violation, not just counts. |
-G | GitHub Actions / Azure DevOps log format -- groups violations by category and renders file paths as clickable links in CI logs. |
3c. Custom validation C# script (Scripts/validate-naming.csx)
Tabular Editor C# scripts run with Model pre-bound to the loaded TOM model. Use this for validation rules that BPA cannot express because they need procedural logic.
// Fail if any measure name uses snake_case (we want PascalCase or 'Spaced Name')
foreach (var m in Model.AllMeasures)
{
if (m.Name.Contains("_"))
{
Error($"Measure '{m.Name}' uses snake_case. Use PascalCase or 'Spaced Name'.");
}
}
// Fail if any table has zero measures AND zero relationships AND is not hidden
foreach (var t in Model.Tables.Where(t => !t.IsHidden))
{
var hasMeasures = t.Measures.Any();
var inRel = Model.Relationships.Any(r => r.FromTable == t || r.ToTable == t);
if (!hasMeasures && !inRel)
Error($"Table '{t.Name}' is orphaned (no measures, no relationships, not hidden).");
}
// Fail if any calculation group has overlapping precedence
var precedenceMap = new Dictionary<int, string>();
foreach (var t in Model.Tables.Where(t => t.CalculationGroup != null))
{
var p = t.CalculationGroup.Precedence;
if (precedenceMap.ContainsKey(p))
Error($"Calc groups '{t.Name}' and '{precedenceMap[p]}' both use precedence {p}.");
else
precedenceMap[p] = t.Name;
}The Error() function in a Tabular Editor C# script causes the CLI to exit non-zero, blocking the deploy.
3d. Exit code reference
| Code | Meaning |
|---|---|
| 0 | Success: parsed, BPA passed, script passed |
| 1 | Warnings only (BPA Warning severity) |
| 2 | Errors (BPA Error severity, or Error() called in C# script) |
| 4 | Deploy failed (only relevant when using -D) |
Pin Tabular Editor 2 to a specific release (e.g., 2.25.0) in CI. Newer releases sometimes add stricter validation that breaks previously-green builds.
4. semantic-link-labs Validation (Fabric Notebooks)
For pipelines that already live inside Fabric (notebooks, Spark jobs, Data Factory), semantic-link-labs is the path of least resistance.
4a. Run BPA against a deployed model
%pip install semantic-link-labs -q
import sempy_labs as labs
results = labs.run_model_bpa(
dataset="SalesModel",
workspace="Sales-Dev",
extended=True, # Adds VertiPaq Analyzer stats so performance rules can fire
return_dataframe=True,
)
# Filter to only failing Error-severity rules
failures = results[(results["Severity"] >= 3)]
display(failures)
if len(failures) > 0:
raise Exception(f"BPA failed: {len(failures)} Error-severity violations")4b. Run BPA against every model in a workspace, write to delta
labs.run_model_bpa_bulk(
workspace="Sales-Dev",
extended=True,
)
# Results land in the lakehouse-attached notebook at:
# Tables/modelbparesults
df = spark.table("modelbparesults")
df.filter("Severity >= 3").show()This is the pattern for scheduled BPA reporting -- run it daily against every workspace, write to delta, build a Power BI report on top showing trends and per-team owners.
4c. Custom rule set
import sempy_labs as labs
# Start from the built-in ~60 rules, then add or override
my_rules = labs.model_bpa_rules() # built-in rules as a list of dicts
my_rules.append({
"ID": "CONTOSO_NO_AUTO_DATETIME",
"Name": "Auto date/time must be disabled",
"Category": "Performance",
"Severity": 3,
"Scope": "Model",
"Expression": "AutoDateTime = false",
"FixExpression": None,
"CompatibilityLevel": 1200,
})
my_rules.append({
"ID": "CONTOSO_MEASURE_FOLDER",
"Name": "Every measure must have a display folder",
"Category": "Maintenance",
"Severity": 2,
"Scope": "Measure",
"Expression": "DisplayFolder <> ''",
})
results = labs.run_model_bpa(
dataset="SalesModel",
workspace="Sales-Dev",
rules=my_rules,
)4d. Offline TMDL validation from Python
semantic-link-labs can also load a TMDL folder without connecting to a workspace, using the embedded TmdlSerializer wrapper. This is the way to run validation in a notebook before publishing.
import sempy_labs as labs
from sempy_labs.tom import import_model_from_tmdl
# Load TMDL files from a local path (or attached lakehouse) as an in-memory TOM database
db = import_model_from_tmdl(folder="./MyProject.SemanticModel/definition")
# Run TOM Validate()
errors = db.Model.Validate().Errors
if errors.Count > 0:
for e in errors:
print(f" - {e.Object.Name}: {e.Message}")
raise Exception(f"{errors.Count} TOM validation errors")
# Run BPA against the in-memory model (no deploy required)
results = labs.run_model_bpa(model=db.Model, extended=False)
display(results[results["Severity"] >= 3])This is the best path for "validate this TMDL folder I just generated, without ever talking to Power BI".
5. INFO DAX Functions (Live Model Introspection)
For models already deployed, the 2026-preferred way to introspect metadata is the INFO.* DAX function family. These are far more agent-friendly than DMVs because they return tables you can query like any other DAX expression.
// All measures with their tables, expressions, and folders
EVALUATE
SELECTCOLUMNS(
INFO.MEASURES(),
"Table", LOOKUPVALUE(INFO.TABLES()[Name], INFO.TABLES()[ID], [TableID]),
"Measure", [Name],
"Expression", [Expression],
"Folder", [DisplayFolder]
)
// Find measures referencing a missing column
EVALUATE
FILTER(
INFO.MEASURES(),
SEARCH("Sales[NonExistentColumn]", [Expression], 1, 0) > 0
)
// Find columns with no measures referencing them (candidates for hiding)
EVALUATE
VAR Referenced =
SUMMARIZE(
FILTER(INFO.MEASURES(), [Expression] <> ""),
[Name]
)
RETURN
EXCEPTALL(INFO.COLUMNS(), Referenced)Use semantic-link.evaluate_dax from a Fabric notebook to run these against any deployed model.
6. Combined Validation Pre-Commit Hook
A combined .git/hooks/pre-commit script that runs all three TMDL validation layers locally before allowing a commit.
#!/usr/bin/env bash
set -e
CHANGED_TMDL=$(git diff --cached --name-only --diff-filter=ACM | grep '\.tmdl$' || true)
if [ -z "$CHANGED_TMDL" ]; then
exit 0
fi
# Layer 1+2: TmdlSerializer parse + TOM Validate via Tabular Editor
echo "Validating TMDL syntax and TOM metadata..."
mono ~/te2/TabularEditor.exe "MyProject.SemanticModel/definition" -B /tmp/check.bim > /tmp/te2-out.txt 2>&1 || {
cat /tmp/te2-out.txt
echo
echo "Pre-commit hook FAILED: TMDL syntax or metadata error."
echo "Fix the errors above and commit again."
exit 1
}
# Layer 3: BPA (errors block; warnings allowed)
echo "Running Best Practice Analyzer..."
mono ~/te2/TabularEditor.exe "MyProject.SemanticModel/definition" \
-A "https://raw.githubusercontent.com/TabularEditor/BestPracticeRules/master/BPARules.json" \
-V > /tmp/bpa-out.txt 2>&1
BPA_EXIT=$?
if [ $BPA_EXIT -eq 2 ]; then
cat /tmp/bpa-out.txt
echo
echo "Pre-commit hook FAILED: BPA Error-severity violations."
exit 1
fi
if [ $BPA_EXIT -eq 1 ]; then
cat /tmp/bpa-out.txt
echo "BPA warnings present but allowed. Continuing."
fi
echo "TMDL validation passed."
exit 0Save as .git/hooks/pre-commit and chmod +x it. Skip with git commit --no-verify only when explicitly intended.
7. Troubleshooting Validation Failures
| Symptom | Likely cause | Fix |
|---|---|---|
TmdlFormatException mentioning indentation | Mixed tabs and spaces | Convert all indentation to single tabs (TMDL spec mandates tabs) |
TmdlFormatException: invalid keyword 'X' | Object type misspelled or wrong casing on serialize | Use camelCase for object types and properties |
TmdlSerializationException: property 'Y' is not valid on Z | Property exists on a newer compatibility level | Bump database.tmdl compatibilityLevel |
model.Validate() reports orphaned column | Column has sourceColumn referencing a non-existent source field | Fix sourceColumn or remove the column |
model.Validate() reports DAX error | Measure references undefined column or syntax error | Run DaxFormatter API on the expression to localize the issue |
BPA MODEL_PERFORMANCE_AVOID_AUTO_DATETIME fires | Auto date/time enabled in PBIP project | Set autoDateTime: false in model.tmdl |
BPA DAX_PERFORMANCE_AVOID_DIVISION_OPERATOR fires | DAX uses / instead of DIVIDE() | Replace with DIVIDE(numerator, denominator, 0) |
| Tabular Editor CLI exits 0 but Power BI Service deploy fails | Service-only validation (e.g., role member doesn't exist in tenant) | These can only be caught at deploy time |
semantic-link-labs says connect_semantic_model: not found | Workspace name has spaces or special chars | Use the workspace ID (GUID) instead of the name |