pbiplint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3377 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.ts
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { basename as basename2, dirname as dirname4, relative as relative2, resolve as resolve3 } from "node:path";
6
+
7
+ // ../core/src/version.ts
8
+ var VERSION = "0.1.0";
9
+
10
+ // ../core/src/engine/config.ts
11
+ var ConfigError = class extends Error {
12
+ };
13
+ function bindConfig(config, rules) {
14
+ const idByUpper = new Map(rules.map((r) => [r.id.toUpperCase(), r.id]));
15
+ const bound = { disabled: /* @__PURE__ */ new Set(), severity: /* @__PURE__ */ new Map(), failOn: config.failOn };
16
+ const unknownRules = [];
17
+ for (const id of config.disabled) {
18
+ const real = idByUpper.get(id.toUpperCase());
19
+ if (real === void 0) unknownRules.push(id);
20
+ else bound.disabled.add(real);
21
+ }
22
+ for (const [id, severity] of config.severity) {
23
+ const real = idByUpper.get(id.toUpperCase());
24
+ if (real === void 0) unknownRules.push(id);
25
+ else bound.severity.set(real, severity);
26
+ }
27
+ return { config: bound, unknownRules };
28
+ }
29
+ var SEVERITY_BY_NAME = { info: 1, warning: 2, error: 3 };
30
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
31
+ function isResolvedConfig(v) {
32
+ return isRecord(v) && v.disabled instanceof Set && v.severity instanceof Map;
33
+ }
34
+ function resolveConfig(raw = {}) {
35
+ if (!isRecord(raw)) throw new ConfigError("pbiplint.config.json must be a JSON object");
36
+ for (const k of Object.keys(raw))
37
+ if (k !== "rules" && k !== "failOn" && k !== "$schema")
38
+ throw new ConfigError(`pbiplint.config.json: unknown key "${k}"`);
39
+ const out = { disabled: /* @__PURE__ */ new Set(), severity: /* @__PURE__ */ new Map(), failOn: 3 };
40
+ if (raw.rules !== void 0) {
41
+ if (!isRecord(raw.rules))
42
+ throw new ConfigError(
43
+ 'pbiplint.config.json: "rules" must be an object of rule id to "off" | "info" | "warning" | "error"'
44
+ );
45
+ for (const [id, v] of Object.entries(raw.rules)) {
46
+ if (v === "off") out.disabled.add(id);
47
+ else if (v === "info" || v === "warning" || v === "error")
48
+ out.severity.set(id, SEVERITY_BY_NAME[v]);
49
+ else
50
+ throw new ConfigError(
51
+ `pbiplint.config.json: rules["${id}"] must be "off", "info", "warning", or "error"`
52
+ );
53
+ }
54
+ }
55
+ if (raw.failOn !== void 0) {
56
+ if (raw.failOn === "none") out.failOn = null;
57
+ else if (raw.failOn === "info" || raw.failOn === "warning" || raw.failOn === "error")
58
+ out.failOn = SEVERITY_BY_NAME[raw.failOn];
59
+ else
60
+ throw new ConfigError(
61
+ 'pbiplint.config.json: "failOn" must be "info", "warning", "error", or "none"'
62
+ );
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // ../core/src/tmdl/quote.ts
68
+ function unquoteName(s) {
69
+ const t = s.trim();
70
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'"))
71
+ return t.slice(1, -1).replace(/''/g, "'");
72
+ return t;
73
+ }
74
+ function unquoteValue(s) {
75
+ const t = s.trim();
76
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"'))
77
+ return t.slice(1, -1).replace(/""/g, '"');
78
+ return t;
79
+ }
80
+
81
+ // ../core/src/engine/ignore.ts
82
+ var IGNORE_ANNOTATION = "pbiplint.ignore";
83
+ function isIgnored(object, ruleId) {
84
+ const raw = object?.annotations[IGNORE_ANNOTATION];
85
+ if (raw === void 0) return false;
86
+ const wanted = ruleId.toUpperCase();
87
+ return unquoteValue(raw).split(",").map((s) => s.trim()).some((s) => s === "*" || s.toUpperCase() === wanted);
88
+ }
89
+
90
+ // ../core/src/index/references.ts
91
+ var QUALIFIED = /(?:'((?:[^']|'')+)'\s*|([\p{L}_][\p{L}\p{N}_]*))\[([^\]]+)\]/gu;
92
+ var BARE = /\[([^\]]+)\]/g;
93
+ function extractRefs(expression) {
94
+ const out = [];
95
+ const consumed = /* @__PURE__ */ new Set();
96
+ for (const m of expression.matchAll(QUALIFIED)) {
97
+ const table = m[1] !== void 0 ? m[1].replace(/''/g, "'") : m[2];
98
+ out.push({ table, name: m[3], qualified: true });
99
+ consumed.add(m.index + m[0].length - m[3].length - 2);
100
+ }
101
+ for (const m of expression.matchAll(BARE)) {
102
+ if (consumed.has(m.index)) continue;
103
+ out.push({ name: m[1], qualified: false });
104
+ }
105
+ return out;
106
+ }
107
+ var lower = (s) => s.toLowerCase();
108
+ var key = (table, name) => `${lower(table)} ${lower(name)}`;
109
+ function buildReferenceIndex(model) {
110
+ const tables = new Map(model.tables.map((t) => [lower(t.name), t]));
111
+ const columns2 = /* @__PURE__ */ new Map();
112
+ const measures = /* @__PURE__ */ new Map();
113
+ for (const t of model.tables) {
114
+ for (const c of t.columns) columns2.set(key(t.name, c.name), c);
115
+ for (const m of t.measures) measures.set(lower(m.name), m);
116
+ }
117
+ const columnOf = (t, name) => columns2.get(key(t.name, name));
118
+ const resolve4 = (raw, ownerTable, ownerKind) => {
119
+ if (raw.qualified) {
120
+ const t = tables.get(lower(raw.table));
121
+ if (!t) return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
122
+ const col = columnOf(t, raw.name);
123
+ if (col) return { kind: "column", table: t.name, name: col.name, qualified: true };
124
+ const meas2 = t.measures.find((m) => lower(m.name) === lower(raw.name));
125
+ if (meas2) return { kind: "measure", table: t.name, name: meas2.name, qualified: true };
126
+ return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
127
+ }
128
+ const meas = measures.get(lower(raw.name));
129
+ if (meas) return { kind: "measure", table: meas.table.name, name: meas.name, qualified: false };
130
+ if (ownerKind === "calculationItem")
131
+ return { kind: "unresolved", name: raw.name, qualified: false };
132
+ if (ownerTable) {
133
+ const col = columnOf(ownerTable, raw.name);
134
+ if (col) return { kind: "column", table: ownerTable.name, name: col.name, qualified: false };
135
+ }
136
+ for (const t of model.tables) {
137
+ const col = columnOf(t, raw.name);
138
+ if (col) return { kind: "column", table: t.name, name: col.name, qualified: false };
139
+ }
140
+ return { kind: "unresolved", name: raw.name, qualified: false };
141
+ };
142
+ const owners = [];
143
+ const byObject = /* @__PURE__ */ new Map();
144
+ const add = (kind, object, ownerTable, ...expressions) => {
145
+ const expression = expressions.filter((e) => e !== void 0).join("\n");
146
+ const owner = {
147
+ kind,
148
+ object,
149
+ ownerTable,
150
+ expression,
151
+ refs: extractRefs(expression).map((r) => resolve4(r, ownerTable, kind))
152
+ };
153
+ owners.push(owner);
154
+ byObject.set(object, owner);
155
+ };
156
+ for (const t of model.tables) {
157
+ for (const m of t.measures) add("measure", m, t, m.expression, m.formatStringDefinition);
158
+ for (const c of t.columns)
159
+ if (c.kind === "calculated") add("calculatedColumn", c, t, c.expression);
160
+ if (t.kind === "calculated")
161
+ add(
162
+ "calculatedTable",
163
+ t,
164
+ t,
165
+ ...t.partitions.filter((p) => p.sourceType === "calculated").map((p) => p.source)
166
+ );
167
+ for (const item of t.calculationGroup?.items ?? [])
168
+ add("calculationItem", item, t, item.expression, item.formatStringDefinition);
169
+ }
170
+ for (const role of model.roles) {
171
+ for (const tp of role.tablePermissions)
172
+ if (tp.filter !== void 0)
173
+ add("tablePermission", tp, tables.get(lower(tp.table)), tp.filter);
174
+ }
175
+ const columnRefs = /* @__PURE__ */ new Map();
176
+ const measureRefs = /* @__PURE__ */ new Map();
177
+ for (const o of owners) {
178
+ for (const r of o.refs) {
179
+ if (r.kind === "column") {
180
+ const k = key(r.table, r.name);
181
+ const arr = columnRefs.get(k) ?? [];
182
+ if (!arr.includes(o)) arr.push(o);
183
+ columnRefs.set(k, arr);
184
+ } else if (r.kind === "measure") {
185
+ const k = lower(r.name);
186
+ const arr = measureRefs.get(k) ?? [];
187
+ if (!arr.includes(o)) arr.push(o);
188
+ measureRefs.set(k, arr);
189
+ }
190
+ }
191
+ }
192
+ return {
193
+ owners,
194
+ refsOf: (object) => byObject.get(object)?.refs ?? [],
195
+ columnReferencedBy: (c) => columnRefs.get(key(c.table.name, c.name)) ?? [],
196
+ measureReferencedBy: (m) => measureRefs.get(lower(m.name)) ?? []
197
+ };
198
+ }
199
+
200
+ // ../core/src/index/relationships.ts
201
+ var key2 = (table, column) => `${table} ${column}`;
202
+ function push(map, k, r) {
203
+ const arr = map.get(k);
204
+ if (!arr) map.set(k, [r]);
205
+ else if (!arr.includes(r)) arr.push(r);
206
+ }
207
+ function buildRelationshipIndex(model) {
208
+ const byColumn = /* @__PURE__ */ new Map();
209
+ const byTable = /* @__PURE__ */ new Map();
210
+ for (const r of model.relationships) {
211
+ push(byColumn, key2(r.fromTable, r.fromColumn), r);
212
+ push(byColumn, key2(r.toTable, r.toColumn), r);
213
+ push(byTable, r.fromTable, r);
214
+ push(byTable, r.toTable, r);
215
+ }
216
+ return {
217
+ all: model.relationships,
218
+ forColumn: (table, column) => byColumn.get(key2(table, column)) ?? [],
219
+ forTable: (table) => byTable.get(table) ?? []
220
+ };
221
+ }
222
+
223
+ // ../core/src/index/usage.ts
224
+ var key3 = (table, column) => `${table} ${column}`;
225
+ function buildUsageIndex(model) {
226
+ const sortTargets = /* @__PURE__ */ new Set();
227
+ const levelColumns = /* @__PURE__ */ new Set();
228
+ const variationDefaults = /* @__PURE__ */ new Set();
229
+ for (const t of model.tables) {
230
+ for (const c of t.columns) {
231
+ if (c.sortByColumn !== void 0) sortTargets.add(key3(t.name, c.sortByColumn));
232
+ for (const v of c.variations)
233
+ if (v.defaultColumn)
234
+ variationDefaults.add(key3(v.defaultColumn.table, v.defaultColumn.column));
235
+ }
236
+ for (const h of t.hierarchies)
237
+ for (const l of h.levels) if (l.column !== void 0) levelColumns.add(key3(t.name, l.column));
238
+ }
239
+ return {
240
+ usedInSortBy: (c) => sortTargets.has(key3(c.table.name, c.name)),
241
+ usedInHierarchies: (c) => levelColumns.has(key3(c.table.name, c.name)),
242
+ usedInVariations: (c) => variationDefaults.has(key3(c.table.name, c.name))
243
+ };
244
+ }
245
+
246
+ // ../core/src/index/build.ts
247
+ function buildIndexes(model) {
248
+ return {
249
+ relationships: buildRelationshipIndex(model),
250
+ usage: buildUsageIndex(model),
251
+ references: buildReferenceIndex(model)
252
+ };
253
+ }
254
+
255
+ // ../core/src/model/build.ts
256
+ var str = (v) => typeof v === "string" ? v : void 0;
257
+ var flag = (v) => v === true || typeof v === "string" && v.toLowerCase() === "true";
258
+ var lower2 = (v) => str(v)?.toLowerCase();
259
+ var loc = (n) => ({ file: n.file, line: n.line });
260
+ var objects = (n, type) => n.children.filter((c) => c.kind === "object" && c.type === type);
261
+ function annotationsOf(n) {
262
+ const out = {};
263
+ for (const c of objects(n, "annotation")) out[c.name] = c.value ?? "";
264
+ return out;
265
+ }
266
+ function named(n, name = n.name ?? "") {
267
+ return {
268
+ name,
269
+ description: n.description,
270
+ annotations: annotationsOf(n),
271
+ location: loc(n),
272
+ node: n
273
+ };
274
+ }
275
+ function splitQualifiedName(ref) {
276
+ const m = /^('(?:[^']|'')*'|[^.]+)\.('(?:[^']|'')*'|.+)$/.exec(ref.trim());
277
+ return m ? { table: unquoteName(m[1]), column: unquoteName(m[2]) } : { table: "", column: unquoteName(ref) };
278
+ }
279
+ function buildColumn(c, table) {
280
+ const p = c.props;
281
+ const variations = objects(c, "variation").map((v) => {
282
+ const dc = str(v.props.defaultcolumn);
283
+ return {
284
+ name: v.name,
285
+ relationship: str(v.props.relationship),
286
+ defaultHierarchy: str(v.props.defaulthierarchy),
287
+ defaultColumn: dc ? splitQualifiedName(dc) : void 0
288
+ };
289
+ });
290
+ const sortBy = str(p.sortbycolumn);
291
+ return {
292
+ ...named(c),
293
+ table,
294
+ kind: "data",
295
+ dataType: str(p.datatype),
296
+ isHidden: flag(p.ishidden),
297
+ isKey: flag(p.iskey),
298
+ isAvailableInMdx: p.isavailableinmdx === void 0 ? true : flag(p.isavailableinmdx),
299
+ formatString: str(p.formatstring),
300
+ summarizeBy: str(p.summarizeby),
301
+ sourceColumn: str(p.sourcecolumn),
302
+ sortByColumn: sortBy === void 0 ? void 0 : unquoteName(sortBy),
303
+ dataCategory: str(p.datacategory),
304
+ expression: c.value,
305
+ variations,
306
+ hasAlternateOf: c.children.some((ch) => ch.type === "alternateof")
307
+ };
308
+ }
309
+ function buildMeasure(x, table) {
310
+ const p = x.props;
311
+ return {
312
+ ...named(x),
313
+ table,
314
+ expression: x.value ?? "",
315
+ formatString: str(p.formatstring),
316
+ formatStringDefinition: str(p.formatstringdefinition),
317
+ isHidden: flag(p.ishidden),
318
+ displayFolder: str(p.displayfolder)
319
+ };
320
+ }
321
+ function buildPartition(pt, table) {
322
+ const sourceNode = pt.children.find((ch) => ch.type === "source" && ch.kind === "flag");
323
+ const ds = str(sourceNode?.props.datasource);
324
+ return {
325
+ ...named(pt),
326
+ table,
327
+ sourceType: (pt.value ?? "").trim().toLowerCase(),
328
+ mode: lower2(pt.props.mode),
329
+ source: str(pt.props.source) ?? str(sourceNode?.props.query),
330
+ dataSource: ds === void 0 ? void 0 : unquoteName(ds)
331
+ };
332
+ }
333
+ function buildHierarchy(h, table) {
334
+ const hierarchy = { ...named(h), table, isHidden: flag(h.props.ishidden), levels: [] };
335
+ for (const l of objects(h, "level")) {
336
+ const col = str(l.props.column);
337
+ const level = {
338
+ ...named(l),
339
+ hierarchy,
340
+ column: col === void 0 ? void 0 : unquoteName(col)
341
+ };
342
+ hierarchy.levels.push(level);
343
+ }
344
+ return hierarchy;
345
+ }
346
+ function buildCalculationGroup(cg, table) {
347
+ const precedence = str(cg.props.precedence);
348
+ const group = {
349
+ ...named(cg, table.name),
350
+ table,
351
+ precedence: precedence === void 0 ? void 0 : Number(precedence),
352
+ items: []
353
+ };
354
+ for (const ci of objects(cg, "calculationitem")) {
355
+ const item = {
356
+ ...named(ci),
357
+ table,
358
+ expression: ci.value ?? "",
359
+ formatStringDefinition: str(ci.props.formatstringdefinition)
360
+ };
361
+ group.items.push(item);
362
+ }
363
+ return group;
364
+ }
365
+ function buildTable(r, model) {
366
+ let t = model.tables.find((x) => x.name === r.name);
367
+ if (!t) {
368
+ t = {
369
+ ...named(r),
370
+ kind: "table",
371
+ isHidden: flag(r.props.ishidden),
372
+ dataCategory: str(r.props.datacategory),
373
+ columns: [],
374
+ measures: [],
375
+ partitions: [],
376
+ hierarchies: []
377
+ };
378
+ model.tables.push(t);
379
+ } else {
380
+ if (flag(r.props.ishidden)) t.isHidden = true;
381
+ t.dataCategory ??= str(r.props.datacategory);
382
+ t.description ??= r.description;
383
+ Object.assign(t.annotations, annotationsOf(r));
384
+ }
385
+ for (const c of r.children) {
386
+ if (c.kind === "object" && c.type === "column") t.columns.push(buildColumn(c, t));
387
+ else if (c.kind === "object" && c.type === "measure") t.measures.push(buildMeasure(c, t));
388
+ else if (c.kind === "object" && c.type === "partition") t.partitions.push(buildPartition(c, t));
389
+ else if (c.kind === "object" && c.type === "hierarchy")
390
+ t.hierarchies.push(buildHierarchy(c, t));
391
+ else if (c.type === "calculationgroup") t.calculationGroup = buildCalculationGroup(c, t);
392
+ }
393
+ }
394
+ function buildRelationship(r) {
395
+ const p = r.props;
396
+ const from = splitQualifiedName(str(p.fromcolumn) ?? "");
397
+ const to = splitQualifiedName(str(p.tocolumn) ?? "");
398
+ return {
399
+ ...named(r),
400
+ fromTable: from.table,
401
+ fromColumn: from.column,
402
+ toTable: to.table,
403
+ toColumn: to.column,
404
+ isActive: p.isactive === void 0 ? true : flag(p.isactive),
405
+ crossFilteringBehavior: lower2(p.crossfilteringbehavior) ?? "onedirection",
406
+ fromCardinality: lower2(p.fromcardinality) ?? "many",
407
+ toCardinality: lower2(p.tocardinality) ?? "one"
408
+ };
409
+ }
410
+ function buildRole(r) {
411
+ const role = {
412
+ ...named(r),
413
+ modelPermission: lower2(r.props.modelpermission),
414
+ members: r.children.filter((c) => c.kind === "object" && c.type.endsWith("member")).map((c) => ({ name: c.name })),
415
+ tablePermissions: []
416
+ };
417
+ for (const tp of objects(r, "tablepermission")) {
418
+ const permission = {
419
+ ...named(tp),
420
+ role,
421
+ table: tp.name,
422
+ filter: tp.value,
423
+ metadataPermission: lower2(tp.props.metadatapermission),
424
+ columnPermissions: objects(tp, "columnpermission").map((cp) => ({
425
+ column: cp.name,
426
+ permission: (cp.value ?? "").trim().toLowerCase()
427
+ }))
428
+ };
429
+ role.tablePermissions.push(permission);
430
+ }
431
+ return role;
432
+ }
433
+ function finalizeKinds(model) {
434
+ for (const t of model.tables) {
435
+ if (t.calculationGroup) t.kind = "calculationGroup";
436
+ else if (t.partitions.some((p) => p.sourceType === "calculated")) t.kind = "calculated";
437
+ else t.kind = "table";
438
+ for (const c of t.columns)
439
+ c.kind = c.expression !== void 0 ? "calculated" : t.kind === "calculated" ? "calculatedTable" : "data";
440
+ }
441
+ }
442
+ function buildModel(files) {
443
+ const model = {
444
+ name: "Model",
445
+ annotations: {},
446
+ location: { file: "", line: 0 },
447
+ props: {},
448
+ tables: [],
449
+ relationships: [],
450
+ roles: [],
451
+ perspectives: [],
452
+ cultures: [],
453
+ expressions: [],
454
+ functions: [],
455
+ dataSources: [],
456
+ files
457
+ };
458
+ for (const f of files) {
459
+ for (const r of f.roots) {
460
+ if (r.kind === "ref" || r.kind === "prop" || r.kind === "expr") continue;
461
+ switch (r.type) {
462
+ case "model":
463
+ Object.assign(model, named(r, r.name ?? "Model"), {
464
+ annotations: { ...model.annotations, ...annotationsOf(r) },
465
+ props: r.props
466
+ });
467
+ break;
468
+ case "annotation":
469
+ if (r.name) model.annotations[r.name] = r.value ?? "";
470
+ break;
471
+ case "table":
472
+ buildTable(r, model);
473
+ break;
474
+ case "relationship":
475
+ model.relationships.push(buildRelationship(r));
476
+ break;
477
+ case "role":
478
+ model.roles.push(buildRole(r));
479
+ break;
480
+ case "perspective": {
481
+ const p = {
482
+ ...named(r),
483
+ tables: objects(r, "perspectivetable").map((t) => t.name)
484
+ };
485
+ model.perspectives.push(p);
486
+ break;
487
+ }
488
+ case "cultureinfo":
489
+ model.cultures.push(named(r));
490
+ break;
491
+ case "expression":
492
+ model.expressions.push({ ...named(r), expression: r.value ?? "" });
493
+ break;
494
+ case "function":
495
+ model.functions.push({ ...named(r), expression: r.value ?? "" });
496
+ break;
497
+ case "datasource": {
498
+ const ds = {
499
+ ...named(r),
500
+ kind: (r.value ?? "").trim().toLowerCase() === "provider" ? "provider" : "structured"
501
+ };
502
+ model.dataSources.push(ds);
503
+ break;
504
+ }
505
+ default:
506
+ break;
507
+ }
508
+ }
509
+ }
510
+ finalizeKinds(model);
511
+ return model;
512
+ }
513
+
514
+ // ../core/src/rules/microsoft-bpa/bpa-rules.data.ts
515
+ var BPA_RULES = [
516
+ {
517
+ id: "AVOID_FLOATING_POINT_DATA_TYPES",
518
+ name: "[Performance] Do not use floating point data types",
519
+ category: "Performance",
520
+ severity: 2,
521
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
522
+ expression: 'DataType = "Double"',
523
+ fixExpression: "DataType = DataType.Decimal",
524
+ description: 'The "Double" floating point data type should be avoided, as it can result in unpredictable roundoff errors and decreased performance in certain scenarios. Use "Int64" or "Decimal" where appropriate (but note that "Decimal" is limited to 4 digits after the decimal sign).'
525
+ },
526
+ {
527
+ id: "ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS",
528
+ name: "[Performance] Set IsAvailableInMdx to false on non-attribute columns",
529
+ category: "Performance",
530
+ severity: 2,
531
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
532
+ expression: "IsAvailableInMDX\nand\n\n(IsHidden or Table.IsHidden)\nand\n\nnot UsedInSortBy.Any() \nand\n\nnot UsedInHierarchies.Any()\nand\nnot UsedInVariations.Any()\nand\nSortByColumn = null",
533
+ fixExpression: "IsAvailableInMDX = false",
534
+ description: "To speed up processing time and conserve memory after processing, attribute hierarchies should not be built for columns that are never used for slicing by MDX clients. In other words, all hidden columns that are not used as a Sort By Column or referenced in user hierarchies should have their IsAvailableInMdx property set to false.\nReference: https://blog.crossjoin.co.uk/2018/07/02/isavailableinmdx-ssas-tabular/"
535
+ },
536
+ {
537
+ id: "AVOID_BI-DIRECTIONAL_RELATIONSHIPS_AGAINST_HIGH-CARDINALITY_COLUMNS",
538
+ name: "[Performance] Avoid bi-directional relationships against high-cardinality columns",
539
+ category: "Performance",
540
+ severity: 2,
541
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
542
+ expression: 'UsedInRelationships.Any(CrossFilteringBehavior == CrossFilteringBehavior.BothDirections)\n\nand\n\nConvert.ToInt64(GetAnnotation("Vertipaq_Cardinality")) > 100000',
543
+ description: "For best performance, it is recommended to avoid using bi-directional relationships against high-cardinality columns. In order to run this rule, you must first run the script shown here: https://www.elegantbi.com/post/vertipaqintabulareditor"
544
+ },
545
+ {
546
+ id: "REDUCE_USAGE_OF_LONG-LENGTH_COLUMNS_WITH_HIGH_CARDINALITY",
547
+ name: "[Performance] Reduce usage of long-length columns with high cardinality",
548
+ category: "Performance",
549
+ severity: 2,
550
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
551
+ expression: 'Convert.ToInt64(GetAnnotation("LongLengthRowCount")) > 500000',
552
+ description: "It is best to avoid lengthy text columns. This is especially true if the column has many unique values. These types of columns can cause longer processing times, bloated model sizes, as well as slower user queries. Long length is defined as more than 100 characters."
553
+ },
554
+ {
555
+ id: "SPLIT_DATE_AND_TIME",
556
+ name: "[Performance] Split date and time",
557
+ category: "Performance",
558
+ severity: 2,
559
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
560
+ expression: 'Convert.ToInt32(GetAnnotation("DateTimeWithHourMinSec")) > 0',
561
+ description: "This rule finds datetime columns that have values not at midnight. To maximize performance, the time element should be split from date element (or the time component should be rounded to midnight as this will reduce column cardinality).\nReference: https://www.sqlbi.com/articles/separate-date-and-time-in-powerpivot-and-bism-tabular/"
562
+ },
563
+ {
564
+ id: "LARGE_TABLES_SHOULD_BE_PARTITIONED",
565
+ name: "[Performance] Large tables should be partitioned",
566
+ category: "Performance",
567
+ severity: 2,
568
+ scope: "Table",
569
+ expression: 'Convert.ToInt64(GetAnnotation("Vertipaq_RowCount")) > 25000000\nand\nPartitions.Count = 1',
570
+ description: "Large tables should be partitioned in order to optimize processing. In order for this rule to run properly, you must run the script shown here: https://www.elegantbi.com/post/vertipaqintabulareditor"
571
+ },
572
+ {
573
+ id: "REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION",
574
+ name: "[Performance] Reduce usage of calculated columns that use the RELATED function",
575
+ category: "Performance",
576
+ severity: 2,
577
+ scope: "CalculatedColumn",
578
+ expression: 'RegEx.IsMatch(Expression,"(?i)RELATED\\s*\\(")',
579
+ description: "Calculated columns do not compress as well as data columns and may cause longer processing times. As such, calculated columns should be avoided if possible. One scenario where they may be easier to avoid is if they use the RELATED function.\nReference: https://www.sqlbi.com/articles/storage-differences-between-calculated-columns-and-calculated-tables/"
580
+ },
581
+ {
582
+ id: "SNOWFLAKE_SCHEMA_ARCHITECTURE",
583
+ name: "[Performance] Consider a star-schema instead of a snowflake architecture",
584
+ category: "Performance",
585
+ severity: 2,
586
+ scope: "Table, CalculatedTable",
587
+ expression: "UsedInRelationships.Any(current.Name == FromTable.Name)\nand\nUsedInRelationships.Any(current.Name == ToTable.Name)",
588
+ description: "Generally speaking, a star-schema is the optimal architecture for tabular models. That being the case, there are valid cases to use a snowflake approach. Please check your model and consider moving to a star-schema architecture.\nReference: https://docs.microsoft.com/power-bi/guidance/star-schema"
589
+ },
590
+ {
591
+ id: "MODEL_SHOULD_HAVE_A_DATE_TABLE",
592
+ name: "[Performance] Model should have a date table",
593
+ category: "Performance",
594
+ severity: 2,
595
+ scope: "Model",
596
+ expression: 'Tables.Any(DataCategory == "Time" && Columns.Any(IsKey == true && DataType == "DateTime")) == false',
597
+ description: "Generally speaking, models should generally have a date table. Models that do not have a date table generally are not taking advantage of features such as time intelligence or may not have a properly structured architecture."
598
+ },
599
+ {
600
+ id: "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE",
601
+ name: "[Performance] Date/calendar tables should be marked as a date table",
602
+ category: "Performance",
603
+ severity: 2,
604
+ scope: "Table, CalculatedTable",
605
+ expression: '(Name.ToUpper().Contains("DATE") or Name.ToUpper().Contains("CALENDAR"))\n\nand\n\n(\nDataCategory <> "Time"\n\nor\n\nColumns.Any(IsKey == true && DataType == "DateTime") == false\n)',
606
+ description: "This rule looks for tables that contain the words 'date' or 'calendar' as they should likely be marked as a date table.\nReference: https://docs.microsoft.com/power-bi/transform-model/desktop-date-tables"
607
+ },
608
+ {
609
+ id: "REMOVE_AUTO-DATE_TABLE",
610
+ name: "[Performance] Remove auto-date table",
611
+ category: "Performance",
612
+ severity: 2,
613
+ scope: "Table, CalculatedTable",
614
+ expression: 'ObjectTypeName == "Calculated Table"\n\nand\n\n(\nName.StartsWith("DateTableTemplate_") \n\nor \n\nName.StartsWith("LocalDateTable_")\n)',
615
+ description: "Avoid using auto-date tables. Make sure to turn off auto-date table in the settings in Power BI Desktop. This will save memory resources. \nReference: https://www.youtube.com/watch?v=xu3uDEHtCrg"
616
+ },
617
+ {
618
+ id: "AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS",
619
+ name: "[Performance] Avoid excessive bi-directional or many-to-many relationships",
620
+ category: "Performance",
621
+ severity: 2,
622
+ scope: "Model",
623
+ expression: '(\n\nRelationships.Where(CrossFilteringBehavior == CrossFilteringBehavior.BothDirections).Count()\n\n+\n\nRelationships.Where(FromCardinality.ToString() == "Many" && ToCardinality.ToString() == "Many").Count()\n\n)\n\n\n/\n\n\nMath.Max(Convert.ToDecimal(Relationships.Count)\n\n,1)> 0.3',
624
+ description: "Limit use of b-di and many-to-many relationships. This rule flags the model if more than 30% of relationships are bi-di or many-to-many.\nReference: https://www.sqlbi.com/articles/bidirectional-relationships-and-ambiguity-in-dax/"
625
+ },
626
+ {
627
+ id: "LIMIT_ROW_LEVEL_SECURITY_(RLS)_LOGIC",
628
+ name: "[Performance] Limit row level security (RLS) logic",
629
+ category: "Performance",
630
+ severity: 2,
631
+ scope: "Table, CalculatedTable",
632
+ expression: 'RowLevelSecurity.Any(RegEx.IsMatch(it.Replace(" ",""),"(?i)RIGHT\\s*\\("))\nor\nRowLevelSecurity.Any(RegEx.IsMatch(it.Replace(" ",""),"(?i)LEFT\\s*\\("))\nor\nRowLevelSecurity.Any(RegEx.IsMatch(it.Replace(" ",""),"(?i)UPPER\\s*\\("))\nor\nRowLevelSecurity.Any(RegEx.IsMatch(it.Replace(" ",""),"(?i)LOWER\\s*\\("))\nor\nRowLevelSecurity.Any(RegEx.IsMatch(it.Replace(" ",""),"(?i)FIND\\s*\\("))\n',
633
+ description: "Try to simplify the DAX used for row level security. Usage of the functions within this rule can likely be offloaded to the upstream systems (data warehouse)."
634
+ },
635
+ {
636
+ id: "MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS",
637
+ name: "[Performance] Consider using aggregations if using Direct Query in Power BI",
638
+ category: "Performance",
639
+ severity: 1,
640
+ scope: "Model",
641
+ expression: 'Tables.Any(ObjectTypeName == "Table (DirectQuery)")\nand\n\n\nAllColumns.Any(AlternateOf != null) == false\nand \nDefaultPowerBIDataSourceVersion.ToString() == "PowerBI_V3"',
642
+ description: "If using Direct Query in Power BI Premium, you may want to consider using aggregations in order to boost performance.\nReference: https://docs.microsoft.com/power-bi/transform-model/desktop-aggregations"
643
+ },
644
+ {
645
+ id: "MINIMIZE_POWER_QUERY_TRANSFORMATIONS",
646
+ name: "[Performance] Minimize Power Query transformations",
647
+ category: "Performance",
648
+ severity: 2,
649
+ scope: "Partition",
650
+ expression: '\nSourceType.ToString() = "M"\nand\n(\nQuery.Contains("Table.Combine(")\nor\n\nQuery.Contains("Table.Join(")\nor\n\nQuery.Contains("Table.NestedJoin(")\nor\nQuery.Contains("Table.AddColumn(")\nor\nQuery.Contains("Table.Group(")\nor\nQuery.Contains("Table.Sort(")\nor\nQuery.Contains("Table.Pivot(")\nor\nQuery.Contains("Table.Unpivot(")\nor\nQuery.Contains("Table.UnpivotOtherColumns(")\nor\nQuery.Contains("Table.Distinct(")\nor\nQuery.Contains("[Query=""SELECT")\nor\nQuery.Contains("Value.NativeQuery")\nor\nQuery.Contains("OleDb.Query")\nor\nQuery.Contains("Odbc.Query")\n)',
651
+ description: "Minimize Power Query transformations in order to improve model processing performance. It is a best practice to offload these transformations to the data warehouse if possible. Also, please check whether query folding is occurring within your model. Please reference the article below for more information on query folding.\nReference: https://docs.microsoft.com/power-query/power-query-folding"
652
+ },
653
+ {
654
+ id: "AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY",
655
+ name: "[Performance] Avoid using many-to-many relationships on tables used for dynamic row level security",
656
+ category: "Performance",
657
+ severity: 3,
658
+ scope: "Table",
659
+ expression: 'UsedInRelationships.Any(FromCardinality == "Many" and ToCardinality== "Many")\nand\nRowLevelSecurity.Any(it.Length > 0)',
660
+ description: "Using many-to-many relationships on tables which use dynamic row level security can cause serious query performance degradation. This pattern's performance problems compound when snowflaking multiple many-to-many relationships against a table which contains row level security. Instead, use one of the patterns shown in the article below where a single dimension table relates many-to-one to a security table.\n\nReference: https://www.elegantbi.com/post/dynamicrlspatterns"
661
+ },
662
+ {
663
+ id: "UNPIVOT_PIVOTED_(MONTH)_DATA",
664
+ name: "[Performance] Unpivot pivoted (month) data",
665
+ category: "Performance",
666
+ severity: 2,
667
+ scope: "Table, CalculatedTable",
668
+ expression: 'Columns.Any(Name.ToUpper().Contains("JAN") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains("FEB") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains("MAR") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains("APR") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains("MAY") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))\nand\nColumns.Any(Name.ToUpper().Contains("JUN") && (DataType == DataType.Int64 || DataType == DataType.Decimal || DataType == DataType.Double))',
669
+ description: "Avoid using pivoted data in your tables. This rule checks specifically for pivoted data by month.\nReference: https://www.elegantbi.com/post/top10bestpractices"
670
+ },
671
+ {
672
+ id: "MANY-TO-MANY_RELATIONSHIPS_SHOULD_BE_SINGLE-DIRECTION",
673
+ name: "[Performance] Many-to-many relationships should be single-direction",
674
+ category: "Performance",
675
+ severity: 2,
676
+ scope: "Relationship",
677
+ expression: 'FromCardinality == "Many"\n\nand\n\nToCardinality == "Many"\n\nand\n\nCrossFilteringBehavior == "BothDirections"',
678
+ description: ""
679
+ },
680
+ {
681
+ id: "REDUCE_USAGE_OF_CALCULATED_TABLES",
682
+ name: "[Performance] Reduce usage of calculated tables",
683
+ category: "Performance",
684
+ severity: 2,
685
+ scope: "CalculatedTable",
686
+ expression: "1=1",
687
+ description: "Migrate calculated table logic to your data warehouse. Reliance on calculated tables will lead to technical debt and potential misalignments if you have multiple models on your platform."
688
+ },
689
+ {
690
+ id: "REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES",
691
+ name: "[Performance] Remove redundant columns in related tables",
692
+ category: "Performance",
693
+ severity: 2,
694
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
695
+ expression: "UsedInRelationships.Any() == false \nand\nModel.AllColumns.Any(Name == current.Name and Table.Name != current.Table.Name and Table.UsedInRelationships.Any(FromTable.Name == current.Table.Name))",
696
+ description: "Removing unnecessary columns reduces model size and speeds up data loading."
697
+ },
698
+ {
699
+ id: "MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY",
700
+ name: "[Performance] Measures using time intelligence and model is using Direct Query",
701
+ category: "Performance",
702
+ severity: 2,
703
+ scope: "Measure, CalculationItem",
704
+ expression: 'Model.Tables.Any(ObjectTypeName == "Table (DirectQuery)")\nand\n(\nRegEx.IsMatch(Expression,"CLOSINGBALANCEMONTH\\s*\\(")\nor\nRegEx.IsMatch(Expression,"CLOSINGBALANCEQUARTER\\s*\\(")\nor\nRegEx.IsMatch(Expression,"CLOSINGBALANCEYEAR\\s*\\(")\nor\nRegEx.IsMatch(Expression,"DATEADD\\s*\\(")\nor\nRegEx.IsMatch(Expression,"DATESBETWEEN\\s*\\(")\nor\nRegEx.IsMatch(Expression,"DATESINPERIOD\\s*\\(")\nor\nRegEx.IsMatch(Expression,"DATESMTD\\s*\\(")\nor\nRegEx.IsMatch(Expression,"DATESQTD\\s*\\(")\nor\nRegEx.IsMatch(Expression,"DATESYTD\\s*\\(")\nor\nRegEx.IsMatch(Expression,"ENDOFMONTH\\s*\\(")\nor\nRegEx.IsMatch(Expression,"ENDOFQUARTER\\s*\\(")\nor\nRegEx.IsMatch(Expression,"ENDOFYEAR\\s*\\(")\nor\nRegEx.IsMatch(Expression,"FIRSTDATE\\s*\\(")\nor\nRegEx.IsMatch(Expression,"FIRSTNONBLANK\\s*\\(")\nor\nRegEx.IsMatch(Expression,"FIRSTNONBLANKVALUE\\s*\\(")\nor\nRegEx.IsMatch(Expression,"LASTDATE\\s*\\(")\nor\nRegEx.IsMatch(Expression,"LASTNONBLANK\\s*\\(")\nor\nRegEx.IsMatch(Expression,"LASTNONBLANKVALUE\\s*\\(")\nor\nRegEx.IsMatch(Expression,"NEXTDAY\\s*\\(")\nor\nRegEx.IsMatch(Expression,"NEXTMONTH\\s*\\(")\nor\nRegEx.IsMatch(Expression,"NEXTQUARTER\\s*\\(")\nor\nRegEx.IsMatch(Expression,"NEXTYEAR\\s*\\(")\nor\nRegEx.IsMatch(Expression,"OPENINGBALANCEMONTH\\s*\\(")\nor\nRegEx.IsMatch(Expression,"OPENINGBALANCEQUARTER\\s*\\(")\nor\nRegEx.IsMatch(Expression,"OPENINGBALANCEYEAR\\s*\\(")\nor\nRegEx.IsMatch(Expression,"PARALLELPERIOD\\s*\\(")\nor\nRegEx.IsMatch(Expression,"PREVIOUSDAY\\s*\\(")\nor\nRegEx.IsMatch(Expression,"PREVIOUSMONTH\\s*\\(")\nor\nRegEx.IsMatch(Expression,"PREVIOUSQUARTER\\s*\\(")\nor\nRegEx.IsMatch(Expression,"PREVIOUSYEAR\\s*\\(")\nor\nRegEx.IsMatch(Expression,"SAMEPERIODLASTYEAR\\s*\\(")\nor\nRegEx.IsMatch(Expression,"STARTOFMONTH\\s*\\(")\nor\nRegEx.IsMatch(Expression,"STARTOFQUARTER\\s*\\(")\nor\nRegEx.IsMatch(Expression,"STARTOFYEAR\\s*\\(")\nor\nRegEx.IsMatch(Expression,"TOTALMTD\\s*\\(")\nor\nRegEx.IsMatch(Expression,"TOTALQTD\\s*\\(")\nor\nRegEx.IsMatch(Expression,"TOTALYTD\\s*\\(")\n)',
705
+ description: "At present, time intelligence functions are known to not perform as well when using Direct Query. If you are having performance issues, you may want to try alternative solutions such as adding columns in the fact table that show previous year or previous month data."
706
+ },
707
+ {
708
+ id: "REDUCE_NUMBER_OF_CALCULATED_COLUMNS",
709
+ name: "[Performance] Reduce number of calculated columns",
710
+ category: "Performance",
711
+ severity: 2,
712
+ scope: "Model",
713
+ expression: 'AllColumns.Where(Type.ToString() == "Calculated").Count() > 5',
714
+ description: "Calculated columns do not compress as well as data columns so they take up more memory. They also slow down processing times for both the table as well as process recalc. Offload calculated column logic to your data warehouse and turn these calculated columns into data columns.\nReference: https://www.elegantbi.com/post/top10bestpractices"
715
+ },
716
+ {
717
+ id: "CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID",
718
+ name: "[Performance] Check if bi-directional and many-to-many relationships are valid",
719
+ category: "Performance",
720
+ severity: 1,
721
+ scope: "Relationship",
722
+ expression: 'FromCardinality.ToString() = "Many" and ToCardinality.ToString() = "Many"\nor\nCrossFilteringBehavior == CrossFilteringBehavior.BothDirections',
723
+ description: "Bi-directional and many-to-many relationships may cause performance degradation or even have unintended consequences. Make sure to check these specific relationships to ensure they are working as designed and are actually necessary.\nReference: https://www.sqlbi.com/articles/bidirectional-relationships-and-ambiguity-in-dax/"
724
+ },
725
+ {
726
+ id: "CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_(RLS)_IS_NECESSARY",
727
+ name: "[Performance] Check if dynamic row level security (RLS) is necessary",
728
+ category: "Performance",
729
+ severity: 1,
730
+ scope: "TablePermission",
731
+ expression: 'RegEx.IsMatch(Expression,"(?i)USERNAME\\(")\nor\nRegEx.IsMatch(Expression,"(?i)USERPRINCIPALNAME\\(")',
732
+ description: "Usage of dynamic row level security (RLS) can add memory and performance overhead. Please research the pros/cons of using it.\nReference: https://docs.microsoft.com/power-bi/admin/service-admin-rls"
733
+ },
734
+ {
735
+ id: "DAX_COLUMNS_FULLY_QUALIFIED",
736
+ name: "[DAX Expressions] Column references should be fully qualified",
737
+ category: "DAX Expressions",
738
+ severity: 3,
739
+ scope: "Measure, KPI, TablePermission, CalculationItem",
740
+ expression: 'DependsOn.Any(Key.ObjectType = "Column" and Value.Any(not FullyQualified))',
741
+ description: "Using fully qualified column references makes it easier to distinguish between column and measure references, and also helps avoid certain errors. When referencing a column in DAX, first specify the table name, then specify the column name in square brackets.\nReference: https://www.elegantbi.com/post/top10bestpractices"
742
+ },
743
+ {
744
+ id: "DAX_MEASURES_UNQUALIFIED",
745
+ name: "[DAX Expressions] Measure references should be unqualified",
746
+ category: "DAX Expressions",
747
+ severity: 3,
748
+ scope: "Measure, CalculatedColumn, CalculatedTable, KPI, CalculationItem",
749
+ expression: 'DependsOn.Any(Key.ObjectType = "Measure" and Value.Any(FullyQualified))',
750
+ description: "Using unqualified measure references makes it easier to distinguish between column and measure references, and also helps avoid certain errors. When referencing a measure using DAX, do not specify the table name. Use only the measure name in square brackets.\nReference: https://www.elegantbi.com/post/top10bestpractices"
751
+ },
752
+ {
753
+ id: "AVOID_DUPLICATE_MEASURES",
754
+ name: "[DAX Expressions] No two measures should have the same definition",
755
+ category: "DAX Expressions",
756
+ severity: 2,
757
+ scope: "Measure",
758
+ expression: 'Model.AllMeasures.Any(Expression.Replace(" ","").Replace("\\n","").Replace("\\r","").Replace("\\t","") = outerIt.Expression.Replace(" ","").Replace("\\n","").Replace("\\r","").Replace("\\t","") and it <> outerIt)',
759
+ description: "Two measures with different names and defined by the same DAX expression should be avoided to reduce redundancy."
760
+ },
761
+ {
762
+ id: "USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT",
763
+ name: "[DAX Expressions] Use the TREATAS function instead of INTERSECT for virtual relationships",
764
+ category: "DAX Expressions",
765
+ severity: 2,
766
+ scope: "Measure, CalculationItem",
767
+ expression: 'RegEx.IsMatch(Expression,"(?i)INTERSECT\\s*\\(")',
768
+ description: "The TREATAS function is more efficient and provides better performance than the INTERSECT function when used in virutal relationships.\nReference: https://www.sqlbi.com/articles/propagate-filters-using-treatas-in-dax/"
769
+ },
770
+ {
771
+ id: "USE_THE_DIVIDE_FUNCTION_FOR_DIVISION",
772
+ name: "[DAX Expressions] Use the DIVIDE function for division",
773
+ category: "DAX Expressions",
774
+ severity: 2,
775
+ scope: "Measure, CalculatedColumn, CalculationItem",
776
+ expression: 'RegEx.IsMatch(Expression,"\\]\\s*\\/(?!\\/)(?!\\*)")\nor\nRegEx.IsMatch(Expression,"\\)\\s*\\/(?!\\/)(?!\\*)")',
777
+ description: 'Use the DIVIDE function instead of using "/". The DIVIDE function resolves divide-by-zero cases. As such, it is recommended to use to avoid errors.\n\nReference: https://docs.microsoft.com/power-bi/guidance/dax-divide-function-operator'
778
+ },
779
+ {
780
+ id: "AVOID_USING_THE_IFERROR_FUNCTION",
781
+ name: "[DAX Expressions] Avoid using the IFERROR function",
782
+ category: "DAX Expressions",
783
+ severity: 2,
784
+ scope: "Measure, CalculatedColumn",
785
+ expression: 'RegEx.IsMatch(Expression,"(?i)IFERROR\\s*\\(")',
786
+ description: "Avoid using the IFERROR function as it may cause performance degradation. If you are concerned about a divide-by-zero error, use the DIVIDE function as it naturally resolves such errors as blank (or you can customize what should be shown in case of such an error).\nReference: https://www.elegantbi.com/post/top10bestpractices"
787
+ },
788
+ {
789
+ id: "MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES",
790
+ name: "[DAX Expressions] Measures should not be direct references of other measures",
791
+ category: "DAX Expressions",
792
+ severity: 2,
793
+ scope: "Measure",
794
+ expression: "Model.AllMeasures.Any(DaxObjectName == current.Expression)",
795
+ description: "This rule identifies measures which are simply a reference to another measure. As an example, consider a model with two measures: [MeasureA] and [MeasureB]. This rule would be triggered for MeasureB if MeasureB's DAX was MeasureB:=[MeasureA]. Such duplicative measures should be removed."
796
+ },
797
+ {
798
+ id: "FILTER_COLUMN_VALUES",
799
+ name: "[DAX Expressions] Filter column values with proper syntax",
800
+ category: "DAX Expressions",
801
+ severity: 2,
802
+ scope: "Measure, CalculatedColumn, CalculationItem",
803
+ expression: `RegEx.IsMatch(Expression,"(?i)CALCULATE\\s*\\(\\s*[^,]+,\\s*(?i)FILTER\\s*\\(\\s*\\'*[A-Za-z0-9 _]+'*\\s*,\\s*\\'*[A-Za-z0-9 _]+\\'*\\[[A-Za-z0-9 _]+\\]")
804
+ or
805
+ RegEx.IsMatch(Expression,"(?i)CALCULATETABLE\\s*\\([^,]*,\\s*(?i)FILTER\\s*\\(\\s*\\'*[A-Za-z0-9 _]+\\'*,\\s*\\'*[A-Za-z0-9 _]+\\'*\\[[A-Za-z0-9 _]+\\]")`,
806
+ description: `Instead of using this pattern FILTER('Table','Table'[Column]="Value") for the filter parameters of a CALCULATE or CALCULATETABLE function, use one of the options below. As far as whether to use the KEEPFILTERS function, see the second reference link below.
807
+
808
+ Option 1: KEEPFILTERS('Table'[Column]="Value")
809
+ Option 2: 'Table'[Column]="Value"
810
+
811
+ Reference: https://docs.microsoft.com/power-bi/guidance/dax-avoid-avoid-filter-as-filter-argument
812
+ Reference: https://www.sqlbi.com/articles/using-keepfilters-in-dax/`
813
+ },
814
+ {
815
+ id: "FILTER_MEASURE_VALUES_BY_COLUMNS",
816
+ name: "[DAX Expressions] Filter measure values by columns, not tables",
817
+ category: "DAX Expressions",
818
+ severity: 2,
819
+ scope: "Measure, CalculatedColumn, CalculationItem",
820
+ expression: `RegEx.IsMatch(Expression,"(?i)CALCULATE\\s*\\(\\s*[^,]+,\\s*(?i)FILTER\\s*\\(\\s*\\'*[A-Za-z0-9 _]+\\'*\\s*,\\s*\\[[^\\]]+\\]")
821
+ or
822
+ RegEx.IsMatch(Expression,"(?i)CALCULATETABLE\\s*\\([^,]*,\\s*(?i)FILTER\\s*\\(\\s*\\'*[A-Za-z0-9 _]+\\'*,\\s*\\[")`,
823
+ description: "Instead of using this pattern FILTER('Table',[Measure]>Value) for the filter parameters of a CALCULATE or CALCULATETABLE function, use one of the options below (if possible). Filtering on a specific column will produce a smaller table for the engine to process, thereby enabling faster performance. Using the VALUES function or the ALL function depends on the desired measure result.\n\nOption 1: FILTER(VALUES('Table'[Column]),[Measure] > Value)\nOption 2: FILTER(ALL('Table'[Column]),[Measure] > Value)\n\nReference: https://docs.microsoft.com/power-bi/guidance/dax-avoid-avoid-filter-as-filter-argument"
824
+ },
825
+ {
826
+ id: "INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED",
827
+ name: "[DAX Expressions] Inactive relationships that are never activated",
828
+ category: "DAX Expressions",
829
+ severity: 2,
830
+ scope: "Relationship",
831
+ expression: `IsActive == false
832
+ and not
833
+ (
834
+ Model.AllMeasures.Any(RegEx.IsMatch(Expression,
835
+ "(?i)USERELATIONSHIP\\s*\\(\\s*\\'*" +
836
+ current.FromTable.Name + "\\'*\\[" +
837
+ current.FromColumn.Name + "\\]\\s*,\\s*\\'*" +
838
+ current.ToTable.Name + "\\'*\\[" +
839
+ current.ToColumn.Name + "\\]"))
840
+ or
841
+ Model.AllCalculationItems.Any(RegEx.IsMatch(Expression,
842
+ "(?i)USERELATIONSHIP\\s*\\(\\s*\\'*" +
843
+ current.FromTable.Name + "\\'*\\[" +
844
+ current.FromColumn.Name + "\\]\\s*,\\s*\\'*" +
845
+ current.ToTable.Name + "\\'*\\[" +
846
+ current.ToColumn.Name + "\\]"))
847
+ )`,
848
+ description: "Inactive relationships are activated using the USERELATIONSHIP function. If an inactive relationship is not referenced in any measure via this function, the relationship will not be used. It should be determined whether the relationship is not necessary or to activate the relationship via this method.\n\nReference: https://docs.microsoft.com/power-bi/guidance/relationships-active-inactive\nReference: https://dax.guide/userelationship/"
849
+ },
850
+ {
851
+ id: "AVOID_USING_'1-(X/Y)'_SYNTAX",
852
+ name: "[DAX Expressions] Avoid using '1-(x/y)' syntax",
853
+ category: "DAX Expressions",
854
+ severity: 2,
855
+ scope: "Measure, CalculatedColumn, CalculationItem",
856
+ expression: `RegEx.IsMatch(Expression,"[0-9]+\\s*[-+]\\s*[\\(]*\\s*(?i)SUM\\s*\\(\\s*\\'*[A-Za-z0-9 _]+\\'*\\s*\\[[A-Za-z0-9 _]+\\]\\s*\\)\\s*\\/")
857
+ or
858
+ RegEx.IsMatch(Expression,"[0-9]+\\s*[-+]\\s*(?i)DIVIDE\\s*\\(")`,
859
+ description: "Instead of using the '1-(x/y)' or '1+(x/y)' syntax to achieve a percentage calculation, use the basic DAX functions (as shown below). Using the improved syntax will generally improve the performance. The '1+/-...' syntax always returns a value whereas the solution without the '1+/-...' does not (as the value may be 'blank'). Therefore the '1+/-...' syntax may return more rows/columns which may result in a slower query speed.\n\nLet's clarify with an example:\n\nAvoid this: 1 - SUM ( 'Sales'[CostAmount] ) / SUM( 'Sales'[SalesAmount] )\nBetter: DIVIDE ( SUM ( 'Sales'[SalesAmount] ) - SUM ( 'Sales'[CostAmount] ), SUM ( 'Sales'[SalesAmount] ) )\nBest: VAR x = SUM ( 'Sales'[SalesAmount] ) RETURN DIVIDE ( x - SUM ( 'Sales'[CostAmount] ), x )"
860
+ },
861
+ {
862
+ id: "EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS",
863
+ name: "[DAX Expressions] The EVALUATEANDLOG function should not be used in production models",
864
+ category: "DAX Expressions",
865
+ severity: 1,
866
+ scope: "Measure",
867
+ expression: 'RegEx.IsMatch(Expression,"(?i)EVALUATEANDLOG\\s*\\(")',
868
+ description: "The EVALUATEANDLOG function is meant to be used only in development/test environments and should not be used in production models.\n\nReference: https://pbidax.wordpress.com/2022/08/16/introduce-the-dax-evaluateandlog-function/"
869
+ },
870
+ {
871
+ id: "DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN",
872
+ name: "[Error Prevention] Data columns must have a source column",
873
+ category: "Error Prevention",
874
+ severity: 3,
875
+ scope: "DataColumn",
876
+ expression: "string.IsNullOrWhitespace(SourceColumn)",
877
+ description: "Data columns must have a source column. A data column without a source column will cause an error when processing the model."
878
+ },
879
+ {
880
+ id: "EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION",
881
+ name: "[Error Prevention] Expression-reliant objects must have an expression",
882
+ category: "Error Prevention",
883
+ severity: 3,
884
+ scope: "Measure, CalculatedColumn, CalculationItem",
885
+ expression: "string.IsNullOrWhiteSpace(Expression)",
886
+ description: "Calculated columns, calculation items and measures must have an expression. Without an expression, these objects will not show any values."
887
+ },
888
+ {
889
+ id: "AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS",
890
+ name: "[Error Prevention] Avoid structured data sources with provider partitions",
891
+ category: "Error Prevention",
892
+ severity: 2,
893
+ scope: "Partition",
894
+ expression: 'SourceType == "Query"\nand\nDataSource.Type == "Structured"',
895
+ description: "Power BI does not support provider (a.k.a. 'legacy') partitions which reference structured data sources. Partitions which reference structured data sources must use the M-language. Otherwise, 'provider' partitions must reference a 'provider' data source. This can be resolved by converting the structured data source into a provider data source (see 2nd reference link below).\n\nReference: https://docs.microsoft.com/power-bi/admin/service-premium-connect-tools#data-source-declaration\nReference: https://www.elegantbi.com/post/convertdatasources"
896
+ },
897
+ {
898
+ id: "AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE",
899
+ name: "[Error Prevention] Avoid the USERELATIONSHIP function and RLS against the same table",
900
+ category: "Error Prevention",
901
+ severity: 3,
902
+ scope: "Table, CalculatedTable",
903
+ expression: `Model.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)USERELATIONSHIP\\s*\\(\\s*.+?(?=])\\]\\s*,\\s*'*" + current.Name + "'*\\["))
904
+ and
905
+ RowLevelSecurity.Any(it <> null)`,
906
+ description: "The USERELATIONSHIP function may not be used against a table which also leverages row-level security (RLS). This will generate an error when using the particular measure in a visual. This rule will highlight the table which is used in a measure's USERELATIONSHIP function as well as RLS.\n\nReference: https://blog.crossjoin.co.uk/2013/05/10/userelationship-and-tabular-row-security/"
907
+ },
908
+ {
909
+ id: "RELATIONSHIP_COLUMNS_SAME_DATA_TYPE",
910
+ name: "[Error Prevention] Relationship columns should be of the same data type",
911
+ category: "Error Prevention",
912
+ severity: 3,
913
+ scope: "Relationship",
914
+ expression: "FromColumn.DataType != ToColumn.DataType",
915
+ description: "Columns used in a relationship should be of the same data type. Ideally, they will be of integer data type (see the related rule '[Formatting] Relationship columns should be of integer data type'). Having columns within a relationship which are of different data types may lead to various issues."
916
+ },
917
+ {
918
+ id: "AVOID_INVALID_NAME_CHARACTERS",
919
+ name: "[Error Prevention] Avoid invalid characters in names",
920
+ category: "Error Prevention",
921
+ severity: 3,
922
+ scope: "Table, Measure, Hierarchy, Level, Perspective, Partition, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, KPI, ModelRole, CalculationGroup, CalculationItem",
923
+ expression: "Name.ToCharArray().Any(char.IsControl(it) and !char.IsWhiteSpace(it))",
924
+ fixExpression: "Name = string.Concat( it.Name.ToCharArray().Select( c => (char.IsControl(c) && !char.IsWhiteSpace(c)) ? ' ': c ))",
925
+ description: "This rule identifies if a name for a given object in your model (i.e. table/column/measure) which contains an invalid character. Invalid characters will cause an error when deploying the model (and failure to deploy). This rule has a fix expression which converts the invalid character into a space, resolving the issue."
926
+ },
927
+ {
928
+ id: "AVOID_INVALID_DESCRIPTION_CHARACTERS",
929
+ name: "[Error Prevention] Avoid invalid characters in descriptions",
930
+ category: "Error Prevention",
931
+ severity: 3,
932
+ scope: "Table, Measure, Hierarchy, Level, Perspective, Partition, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, KPI, ModelRole, CalculationGroup, CalculationItem",
933
+ expression: "Description.ToCharArray().Any(char.IsControl(it) and !char.IsWhiteSpace(it))",
934
+ fixExpression: "Description = string.Concat( it.Description.ToCharArray().Select( c => (char.IsControl(c) && !char.IsWhiteSpace(c)) ? ' ': c ))",
935
+ description: "This rule identifies if a description for a given object in your model (i.e. table/column/measure) which contains an invalid character. Invalid characters will cause an error when deploying the model (and failure to deploy). This rule has a fix expression which converts the invalid character into a space, resolving the issue."
936
+ },
937
+ {
938
+ id: "SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS",
939
+ name: "[Error Prevention] Set IsAvailableInMdx to true on necessary columns",
940
+ category: "Error Prevention",
941
+ severity: 3,
942
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
943
+ expression: "IsAvailableInMDX = false\n\nand\n(\nUsedInSortBy.Any()\nor\nUsedInHierarchies.Any()\nor\nUsedInVariations.Any()\nor\nSortByColumn != null\n)",
944
+ fixExpression: "IsAvailableInMDX = true",
945
+ description: "In order to avoid errors, ensure that attribute hierarchies are enabled if a column is used for sorting another column, used in a hierarchy, used in variations, or is sorted by another column."
946
+ },
947
+ {
948
+ id: "UNNECESSARY_COLUMNS",
949
+ name: "[Maintenance] Remove unnecessary columns",
950
+ category: "Maintenance",
951
+ severity: 2,
952
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
953
+ expression: `(IsHidden or Table.IsHidden)
954
+
955
+
956
+ and ReferencedBy.Count = 0
957
+
958
+
959
+ and (not UsedInRelationships.Any())
960
+
961
+
962
+ and (not UsedInSortBy.Any())
963
+
964
+
965
+ and (not UsedInHierarchies.Any())
966
+
967
+
968
+ and (not Table.RowLevelSecurity.Any(
969
+ it <> null and it.IndexOf("[" + current.Name + "]", "OrdinalIgnoreCase") >= 0
970
+ ))
971
+
972
+ and (not Model.Roles.Any(RowLevelSecurity.Any(
973
+ it <> null and
974
+ (
975
+ it.IndexOf(current.Table.Name + "[" + current.Name + "]", "OrdinalIgnoreCase") >= 0 or
976
+ it.IndexOf("'" + current.Table.Name + "'[" + current.Name + "]", "OrdinalIgnoreCase") >= 0
977
+ )
978
+ )))
979
+ and not (
980
+ ObjectLevelSecurity.Any(it.ToString() == "None"))
981
+ and not (
982
+ Table.ObjectLevelSecurity.Any(it.ToString() == "None"))`,
983
+ fixExpression: "Delete()",
984
+ description: "Hidden columns that are not referenced by any DAX expressions, relationships, hierarchy levels or Sort By-properties should be removed."
985
+ },
986
+ {
987
+ id: "UNNECESSARY_MEASURES",
988
+ name: "[Maintenance] Remove unnecessary measures",
989
+ category: "Maintenance",
990
+ severity: 2,
991
+ scope: "Measure",
992
+ expression: "(Table.IsHidden or IsHidden) \nand ReferencedBy.Count = 0",
993
+ fixExpression: "Delete()",
994
+ description: "Hidden measures that are not referenced by any DAX expressions should be removed for maintainability"
995
+ },
996
+ {
997
+ id: "FIX_REFERENTIAL_INTEGRITY_VIOLATIONS",
998
+ name: "[Maintenance] Fix referential integrity violations",
999
+ category: "Maintenance",
1000
+ severity: 2,
1001
+ scope: "Relationship",
1002
+ expression: 'Convert.ToInt64(GetAnnotation("Vertipaq_RIViolationInvalidRows")) > 0',
1003
+ description: "This rule highlights relationships which have referential integrity violations. This indicates that there are values in the table on the 'from' side of the relationship which do not exist in the table on the 'to' side of the relationship. Referential integrity violations will also produce the 'blank' member value in slicers. It is recommended to fix these issues by ensuring that the 'to' table's primary key column has all the values in the 'from' table's foreign key column.\n\nReference: https://blog.enterprisedna.co/vertipaq-analyzer-tutorial-relationships-referential-integrity/"
1004
+ },
1005
+ {
1006
+ id: "REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS",
1007
+ name: "[Maintenance] Remove data sources not referenced by any partitions",
1008
+ category: "Maintenance",
1009
+ severity: 1,
1010
+ scope: "ProviderDataSource, StructuredDataSource",
1011
+ expression: "UsedByPartitions.Count() == 0\nand not Model.Tables.Any(SourceExpression.Contains(OuterIt.Name))\nand not Model.AllPartitions.Any(Query.Contains(OuterIt.Name))",
1012
+ fixExpression: "Delete()",
1013
+ description: "Data sources which are not referenced by any partitions may be removed."
1014
+ },
1015
+ {
1016
+ id: "REMOVE_ROLES_WITH_NO_MEMBERS",
1017
+ name: "[Maintenance] Remove roles with no members",
1018
+ category: "Maintenance",
1019
+ severity: 1,
1020
+ scope: "ModelRole",
1021
+ expression: "Members.Count() == 0",
1022
+ fixExpression: "Delete()",
1023
+ description: "May remove roles with no members."
1024
+ },
1025
+ {
1026
+ id: "ENSURE_TABLES_HAVE_RELATIONSHIPS",
1027
+ name: "[Maintenance] Ensure tables have relationships",
1028
+ category: "Maintenance",
1029
+ severity: 1,
1030
+ scope: "Table, CalculatedTable",
1031
+ expression: "UsedInRelationships.Count() == 0",
1032
+ description: "This rule highlights tables which are not connected to any other table in the model with a relationship."
1033
+ },
1034
+ {
1035
+ id: "OBJECTS_WITH_NO_DESCRIPTION",
1036
+ name: "[Maintenance] Visible objects with no description",
1037
+ category: "Maintenance",
1038
+ severity: 1,
1039
+ scope: "Table, Measure, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, CalculationGroup",
1040
+ expression: "string.IsNullOrWhitespace(Description)\nand\nIsHidden == false",
1041
+ description: "Add descriptions to objects. These descriptions are shown on hover within the Field List in Power BI Desktop. Additionally, you can leverage these descriptions to create an automated data dictionary (see link below).\nReference: https://www.elegantbi.com/post/datadictionary"
1042
+ },
1043
+ {
1044
+ id: "PERSPECTIVES_WITH_NO_OBJECTS",
1045
+ name: "[Maintenance] Perspectives with no objects",
1046
+ category: "Maintenance",
1047
+ severity: 1,
1048
+ scope: "Perspective",
1049
+ expression: "Model.Tables.Any(InPerspective[current.Name]) == false",
1050
+ fixExpression: "Delete()",
1051
+ description: "Perspectives that contain no objects (tables) are most likely not necessary. In this rule, it is only necessary to check tables as adding a column/measure/hierarchy to a perspective also adds the table to the perspective. Additionally, tables in general covers calculated tables and calculation groups as well."
1052
+ },
1053
+ {
1054
+ id: "CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS",
1055
+ name: "[Maintenance] Calculation groups with no calculation items",
1056
+ category: "Maintenance",
1057
+ severity: 2,
1058
+ scope: "CalculationGroup",
1059
+ expression: "CalculationItems.Count == 0",
1060
+ description: "Calculation groups have no function unless they have calculation items."
1061
+ },
1062
+ {
1063
+ id: "PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES",
1064
+ name: "[Naming Conventions] Partition name should match table name for single partition tables",
1065
+ category: "Naming Conventions",
1066
+ severity: 1,
1067
+ scope: "Table",
1068
+ expression: "(Partitions.Count = 1 and Partitions[0].Name <> Name)",
1069
+ fixExpression: "Partitions[0].Name = it.Name",
1070
+ description: "Tables with just one partition should match their table and partition names.Tables with more than one partition should have each partition name starting with the table name."
1071
+ },
1072
+ {
1073
+ id: "SPECIAL_CHARS_IN_OBJECT_NAMES",
1074
+ name: "[Naming Conventions] Object names must not contain special characters",
1075
+ category: "Naming Conventions",
1076
+ severity: 2,
1077
+ scope: "Model, Table, Measure, Hierarchy, Perspective, Partition, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, CalculationGroup, CalculationItem",
1078
+ expression: "Name.IndexOf(char(9)) > -1\nor\n\nName.IndexOf(char(10)) > -1 \nor\n\nName.IndexOf(char(13)) > -1",
1079
+ description: "Tabs, line breaks, etc."
1080
+ },
1081
+ {
1082
+ id: "TRIM_OBJECT_NAMES",
1083
+ name: "[Naming Conventions] Trim object names",
1084
+ category: "Naming Conventions",
1085
+ severity: 1,
1086
+ scope: "Model, Table, Measure, Hierarchy, Level, Perspective, Partition, ProviderDataSource, DataColumn, CalculatedColumn, CalculatedTable, CalculatedTableColumn, StructuredDataSource, NamedExpression, ModelRole, CalculationGroup, CalculationItem",
1087
+ expression: 'Name.StartsWith(" ") or Name.EndsWith(" ")',
1088
+ description: "Unintentionally leaving a trailing space in an object name is a common occurrence when copying/duplicating objects in Tabular Editor."
1089
+ },
1090
+ {
1091
+ id: "FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS",
1092
+ name: "[Formatting] Format flag columns as Yes/No value strings",
1093
+ category: "Formatting",
1094
+ severity: 1,
1095
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1096
+ expression: '(\nName.StartsWith("Is") and \nDataType = "Int64" and \nnot (IsHidden or Table.IsHidden)\n) \nor\n\n(\nName.EndsWith(" Flag") and \nDataType <> "String" and \nnot (IsHidden or Table.IsHidden)\n)',
1097
+ description: "Flags must be properly formatted as Yes/No as this is easier to read than using 0/1 integer values."
1098
+ },
1099
+ {
1100
+ id: "OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE",
1101
+ name: "[Formatting] Objects should not start or end with a space",
1102
+ category: "Formatting",
1103
+ severity: 3,
1104
+ scope: "Model, Table, Measure, Hierarchy, Perspective, Partition, DataColumn, CalculatedColumn",
1105
+ expression: 'Name.StartsWith(" ") or Name.EndsWith(" ")',
1106
+ description: "Objects should not start or end with a space"
1107
+ },
1108
+ {
1109
+ id: "DATECOLUMN_FORMATSTRING",
1110
+ name: '[Formatting] Provide format string for "Date" columns',
1111
+ category: "Formatting",
1112
+ severity: 1,
1113
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1114
+ expression: 'Name.IndexOf("Date", "OrdinalIgnoreCase") >= 0 \nand \nDataType = "DateTime" \nand \nFormatString <> "mm/dd/yyyy"',
1115
+ fixExpression: 'FormatString = "mm/dd/yyyy"',
1116
+ description: 'Columns of type "DateTime" that have "Month" in their names should be formatted as "mm/dd/yyyy".'
1117
+ },
1118
+ {
1119
+ id: "MONTHCOLUMN_FORMATSTRING",
1120
+ name: '[Formatting] Provide format string for "Month" columns',
1121
+ category: "Formatting",
1122
+ severity: 1,
1123
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1124
+ expression: 'Name.IndexOf("Month", "OrdinalIgnoreCase") >= 0 and DataType = "DateTime" and FormatString <> "MMMM yyyy"',
1125
+ fixExpression: 'FormatString = "MMMM yyyy"',
1126
+ description: 'Columns of type "DateTime" that have "Month" in their names should be formatted as "MMMM yyyy".'
1127
+ },
1128
+ {
1129
+ id: "PROVIDE_FORMAT_STRING_FOR_MEASURES",
1130
+ name: "[Formatting] Provide format string for measures",
1131
+ category: "Formatting",
1132
+ severity: 3,
1133
+ scope: "Measure",
1134
+ expression: "not IsHidden \nand not Table.IsHidden \nand string.IsNullOrWhitespace(FormatString) \nand string.IsNullOrWhitespace(FormatStringExpression)\n ",
1135
+ description: "Visible measures should have their format string property assigned"
1136
+ },
1137
+ {
1138
+ id: "NUMERIC_COLUMN_SUMMARIZE_BY",
1139
+ name: "[Formatting] Do not summarize numeric columns",
1140
+ category: "Formatting",
1141
+ severity: 3,
1142
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1143
+ expression: '(\nDataType = "Int64"\nor \nDataType="Decimal" \nor \nDataType="Double"\n)\n\nand \nSummarizeBy <> "None"\n\nand not (IsHidden or Table.IsHidden)',
1144
+ fixExpression: "SummarizeBy = AggregateFunction.None",
1145
+ description: 'Numeric columns (integer, decimal, double) should have their SummarizeBy property set to "None" to avoid accidental summation in Power BI (create measures instead).'
1146
+ },
1147
+ {
1148
+ id: "PERCENTAGE_FORMATTING",
1149
+ name: "[Formatting] Percentages should be formatted with thousands separators and 1 decimal",
1150
+ category: "Formatting",
1151
+ severity: 2,
1152
+ scope: "Measure",
1153
+ expression: 'FormatString.Contains("%") and FormatString <> "#,0.0%;-#,0.0%;#,0.0%"',
1154
+ fixExpression: 'FormatString = "#,0.0%\\u003B-#,0.0%\\u003B#,0.0%"',
1155
+ description: ""
1156
+ },
1157
+ {
1158
+ id: "INTEGER_FORMATTING",
1159
+ name: "[Formatting] Whole numbers should be formatted with thousands separators and no decimals",
1160
+ category: "Formatting",
1161
+ severity: 2,
1162
+ scope: "Measure",
1163
+ expression: 'not FormatString.Contains("$") and not FormatString.Contains("%") and not (FormatString = "#,0" or FormatString = "#,0.0")',
1164
+ fixExpression: 'FormatString = "#,0"',
1165
+ description: ""
1166
+ },
1167
+ {
1168
+ id: "RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE",
1169
+ name: "[Formatting] Relationship columns should be of integer data type",
1170
+ category: "Formatting",
1171
+ severity: 1,
1172
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1173
+ expression: "UsedInRelationships.Any()\n\nand \n\nDataType != DataType.Int64",
1174
+ description: "It is a best practice for relationship columns to be of integer data type. This applies not only to data warehousing but data modeling as well."
1175
+ },
1176
+ {
1177
+ id: "ADD_DATA_CATEGORY_FOR_COLUMNS",
1178
+ name: "[Formatting] Add data category for columns",
1179
+ category: "Formatting",
1180
+ severity: 1,
1181
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1182
+ expression: 'string.IsNullOrWhiteSpace(DataCategory)\n&&\n(\n (\n (\n Name.ToLower().Contains("country")\n || Name.ToLower().Contains("continent")\n || Name.ToLower().Contains("city")\n )\n && DataType == DataType.String\n )\n ||\n (\n (\n Name.ToLower() == "latitude"\n || Name.ToLower() == "longitude"\n )\n &&\n (\n DataType == DataType.Decimal\n || DataType == DataType.Double\n )\n )\n)\n',
1183
+ description: "Add Data Category property for appropriate columns.\n\nReference: https://docs.microsoft.com/power-bi/transform-model/desktop-data-categorization"
1184
+ },
1185
+ {
1186
+ id: "HIDE_FOREIGN_KEYS",
1187
+ name: "[Formatting] Hide foreign keys",
1188
+ category: "Formatting",
1189
+ severity: 2,
1190
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1191
+ expression: 'UsedInRelationships.Any(FromColumn.Name == current.Name and FromCardinality == "Many")\n\nand\n\nIsHidden == false',
1192
+ fixExpression: "IsHidden = true",
1193
+ description: "Foreign keys should always be hidden."
1194
+ },
1195
+ {
1196
+ id: "MARK_PRIMARY_KEYS",
1197
+ name: "[Formatting] Mark primary keys",
1198
+ category: "Formatting",
1199
+ severity: 1,
1200
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1201
+ expression: 'UsedInRelationships.Any(ToTable.Name == current.Table.Name and ToColumn.Name == current.Name and ToCardinality == "One")\n\nand\n\nIsKey == false\nand\ncurrent.Table.DataCategory != "Time"',
1202
+ fixExpression: "IsKey = true",
1203
+ description: "Set the 'Key' property to 'True' for primary key columns within the column properties."
1204
+ },
1205
+ {
1206
+ id: "HIDE_FACT_TABLE_COLUMNS",
1207
+ name: "[Formatting] Hide fact table columns",
1208
+ category: "Formatting",
1209
+ severity: 2,
1210
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1211
+ expression: `(
1212
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)COUNT\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1213
+
1214
+ or
1215
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)COUNTBLANK\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1216
+
1217
+ or
1218
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)SUM\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1219
+ or
1220
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)AVERAGE\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1221
+
1222
+ or
1223
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)VALUES\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1224
+
1225
+ or
1226
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)DISTINCT\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1227
+ or
1228
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)DISTINCTCOUNT\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1229
+
1230
+ or
1231
+
1232
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)MIN\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1233
+
1234
+ or
1235
+
1236
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)MAX\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1237
+
1238
+ or
1239
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)COUNTA\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1240
+
1241
+
1242
+ or
1243
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)AVERAGEA\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1244
+
1245
+ or
1246
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)MAXA\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1247
+
1248
+ or
1249
+ ReferencedBy.AllMeasures.Any(RegEx.IsMatch(Expression,"(?i)MINA\\s*\\(\\s*\\'*" + outerit.Table.Name + "\\'*\\[" + outerit.Name + "\\]\\s*\\)"))
1250
+ )
1251
+
1252
+ and IsHidden == false
1253
+
1254
+ and (DataType == "Int64" || DataType == "Decimal" || DataType == "Double")`,
1255
+ fixExpression: "IsHidden = true",
1256
+ description: "It is a best practice to hide fact table columns that are used for aggregation in measures."
1257
+ },
1258
+ {
1259
+ id: "FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED",
1260
+ name: "[Formatting] First letter of objects must be capitalized",
1261
+ category: "Formatting",
1262
+ severity: 1,
1263
+ scope: "Table, Measure, Hierarchy, CalculatedColumn, CalculatedTable, CalculatedTableColumn, CalculationGroup",
1264
+ expression: "Name.Substring(0,1).ToUpper() != Name.Substring(0,1)",
1265
+ description: ""
1266
+ },
1267
+ {
1268
+ id: "MONTH_(AS_A_STRING)_MUST_BE_SORTED",
1269
+ name: "[Formatting] Month (as a string) must be sorted",
1270
+ category: "Formatting",
1271
+ severity: 2,
1272
+ scope: "DataColumn, CalculatedColumn, CalculatedTableColumn",
1273
+ expression: 'Name.ToUpper().Contains("MONTH")\nand\n! Name.ToUpper().Contains("MONTHS") \nand \n\n\nDataType == DataType.String \nand \nSortByColumn == null',
1274
+ description: "This rule highlights month columns which are strings and are not sorted. If left unsorted, they will sort alphabetically (i.e. April, August...). Make sure to sort such columns so that they sort properly (January, February, March...)."
1275
+ }
1276
+ ];
1277
+
1278
+ // ../core/src/model/names.ts
1279
+ var tableRef = (name) => `'${name.replace(/'/g, "''")}'`;
1280
+ var bracket = (name) => `[${name.replace(/\]/g, "]]")}]`;
1281
+ var columnRef = (table, column) => `${tableRef(table)}${bracket(column)}`;
1282
+ var measureRef = (name) => bracket(name);
1283
+ var cardinalitySymbol = (c) => c === "many" ? "\u221E" : c === "one" ? "1" : "?";
1284
+ function relationshipName(r) {
1285
+ const arrow = r.crossFilteringBehavior === "bothdirections" ? "\u2194" : "\u2190";
1286
+ return `${columnRef(r.fromTable, r.fromColumn)} ${cardinalitySymbol(r.fromCardinality)}${arrow}${cardinalitySymbol(r.toCardinality)} ${columnRef(r.toTable, r.toColumn)}`;
1287
+ }
1288
+ function slug(id) {
1289
+ return id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1290
+ }
1291
+ var RULE_URL_BASE = "https://pbiplint.com/rules/";
1292
+ var ruleUrl = (id) => RULE_URL_BASE + slug(id);
1293
+
1294
+ // ../core/src/rules/helpers.ts
1295
+ var allColumns = (m) => m.tables.flatMap((t) => t.columns);
1296
+ var allMeasures = (m) => m.tables.flatMap((t) => t.measures);
1297
+ var allPartitions = (m) => m.tables.flatMap((t) => t.partitions);
1298
+ var allCalculationItems = (m) => m.tables.flatMap((t) => t.calculationGroup?.items ?? []);
1299
+ var allTablePermissions = (m) => m.roles.flatMap((r) => r.tablePermissions);
1300
+ var dataType = (c) => (c.dataType ?? "").toLowerCase();
1301
+ var isNumericType = (c) => ["int64", "decimal", "double"].includes(dataType(c));
1302
+ var hiddenOrTableHidden = (c) => c.isHidden || c.table.isHidden;
1303
+ var isBlank = (s) => s === void 0 || s.trim() === "";
1304
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1305
+ var isDirectQueryTable = (t) => t.kind === "table" && t.partitions[0]?.mode === "directquery";
1306
+ var tablesInScope = (m) => m.tables.filter((t) => t.kind !== "calculationGroup");
1307
+ var tableObjectType = (t) => t.kind === "calculated" ? "CalculatedTable" : t.kind === "calculationGroup" ? "CalculationGroupTable" : "Table";
1308
+ var columnObjectType = (c) => c.kind === "calculated" ? "CalculatedColumn" : c.kind === "calculatedTable" ? "CalculatedTableColumn" : "Column";
1309
+ var finding = {
1310
+ // A model synthesized because the folder has no model.tmdl has no real location, so Model
1311
+ // findings carry none rather than pointing at line 0 of an empty file name.
1312
+ model: (m) => ({
1313
+ objectType: "Model",
1314
+ objectName: "Model",
1315
+ ...m.location.file === "" ? {} : { location: m.location },
1316
+ object: m
1317
+ }),
1318
+ table: (t) => ({
1319
+ objectType: tableObjectType(t),
1320
+ objectName: tableRef(t.name),
1321
+ location: t.location,
1322
+ object: t
1323
+ }),
1324
+ column: (c) => ({
1325
+ objectType: columnObjectType(c),
1326
+ objectName: columnRef(c.table.name, c.name),
1327
+ location: c.location,
1328
+ object: c
1329
+ }),
1330
+ measure: (x) => ({
1331
+ objectType: "Measure",
1332
+ objectName: measureRef(x.name),
1333
+ location: x.location,
1334
+ object: x
1335
+ }),
1336
+ partition: (p) => ({
1337
+ objectType: "Partition",
1338
+ objectName: p.name,
1339
+ location: p.location,
1340
+ detail: `table ${tableRef(p.table.name)}`,
1341
+ object: p
1342
+ }),
1343
+ relationship: (r) => ({
1344
+ objectType: "Relationship",
1345
+ objectName: relationshipName(r),
1346
+ location: r.location,
1347
+ object: r
1348
+ }),
1349
+ role: (r) => ({
1350
+ objectType: "Role",
1351
+ objectName: r.name,
1352
+ location: r.location,
1353
+ object: r
1354
+ }),
1355
+ tablePermission: (tp) => ({
1356
+ objectType: "TablePermission",
1357
+ objectName: tp.table,
1358
+ location: tp.location,
1359
+ detail: `role ${tp.role.name}`,
1360
+ object: tp
1361
+ }),
1362
+ perspective: (p) => ({
1363
+ objectType: "Perspective",
1364
+ objectName: p.name,
1365
+ location: p.location,
1366
+ object: p
1367
+ }),
1368
+ hierarchy: (h) => ({
1369
+ objectType: "Hierarchy",
1370
+ objectName: h.name,
1371
+ location: h.location,
1372
+ detail: `table ${tableRef(h.table.name)}`,
1373
+ object: h
1374
+ }),
1375
+ level: (l) => ({
1376
+ objectType: "Level",
1377
+ objectName: l.name,
1378
+ location: l.location,
1379
+ detail: `hierarchy ${l.hierarchy.name} in ${tableRef(l.hierarchy.table.name)}`,
1380
+ object: l
1381
+ }),
1382
+ calculationItem: (i) => ({
1383
+ objectType: "CalculationItem",
1384
+ objectName: i.name,
1385
+ location: i.location,
1386
+ detail: `calculation group ${tableRef(i.table.name)}`,
1387
+ object: i
1388
+ }),
1389
+ expression: (e) => ({
1390
+ objectType: "NamedExpression",
1391
+ objectName: e.name,
1392
+ location: e.location,
1393
+ object: e
1394
+ }),
1395
+ dataSource: (d) => ({
1396
+ objectType: "DataSource",
1397
+ objectName: d.name,
1398
+ location: d.location,
1399
+ object: d
1400
+ })
1401
+ };
1402
+ function namedObjects(m, types) {
1403
+ const want = new Set(types);
1404
+ const out = [];
1405
+ const push2 = (f, name, description) => {
1406
+ if (want.has(f.objectType)) out.push({ finding: f, name, description });
1407
+ };
1408
+ push2(finding.model(m), m.name, m.description);
1409
+ for (const t of m.tables) {
1410
+ push2(finding.table(t), t.name, t.description);
1411
+ for (const c of t.columns) push2(finding.column(c), c.name, c.description);
1412
+ for (const x of t.measures) push2(finding.measure(x), x.name, x.description);
1413
+ for (const h of t.hierarchies) {
1414
+ push2(finding.hierarchy(h), h.name, h.description);
1415
+ for (const l of h.levels) push2(finding.level(l), l.name, l.description);
1416
+ }
1417
+ for (const p of t.partitions) push2(finding.partition(p), p.name, p.description);
1418
+ for (const i of t.calculationGroup?.items ?? [])
1419
+ push2(finding.calculationItem(i), i.name, i.description);
1420
+ }
1421
+ for (const r of m.relationships) push2(finding.relationship(r), r.name, r.description);
1422
+ for (const r of m.roles) {
1423
+ push2(finding.role(r), r.name, r.description);
1424
+ for (const tp of r.tablePermissions) push2(finding.tablePermission(tp), tp.name, tp.description);
1425
+ }
1426
+ for (const p of m.perspectives) push2(finding.perspective(p), p.name, p.description);
1427
+ for (const e of m.expressions) push2(finding.expression(e), e.name, e.description);
1428
+ for (const d of m.dataSources) push2(finding.dataSource(d), d.name, d.description);
1429
+ return out;
1430
+ }
1431
+ function expressionObjects(m, kinds) {
1432
+ const want = new Set(kinds);
1433
+ const out = [];
1434
+ for (const t of m.tables) {
1435
+ if (want.has("measure"))
1436
+ for (const x of t.measures)
1437
+ out.push({ kind: "measure", finding: finding.measure(x), expression: x.expression });
1438
+ if (want.has("calculatedColumn")) {
1439
+ for (const c of t.columns)
1440
+ if (c.kind === "calculated")
1441
+ out.push({
1442
+ kind: "calculatedColumn",
1443
+ finding: finding.column(c),
1444
+ expression: c.expression ?? ""
1445
+ });
1446
+ }
1447
+ if (want.has("calculationItem"))
1448
+ for (const i of t.calculationGroup?.items ?? [])
1449
+ out.push({
1450
+ kind: "calculationItem",
1451
+ finding: finding.calculationItem(i),
1452
+ expression: i.expression
1453
+ });
1454
+ }
1455
+ return out;
1456
+ }
1457
+
1458
+ // ../core/src/rules/rule-summaries.data.ts
1459
+ var RULE_SUMMARIES = {
1460
+ ADD_DATA_CATEGORY_FOR_COLUMNS: "Columns with no data category whose name contains country, continent, or city and whose type is text, or whose name is exactly latitude or longitude and whose type is decimal or double.",
1461
+ "AVOID_BI-DIRECTIONAL_RELATIONSHIPS_AGAINST_HIGH-CARDINALITY_COLUMNS": "Columns in a bi-directional relationship that have more than 100,000 distinct values. Cardinality is not stored in the model files, so pbiplint lists this rule but cannot run it.",
1462
+ AVOID_DUPLICATE_MEASURES: "Two or more measures whose DAX is identical once spaces, tabs, and line breaks are removed. Every copy is reported.",
1463
+ "AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS": "Models where bi-directional relationships plus many-to-many relationships make up more than 30 percent of all relationships. The finding is on the model, not on any one relationship.",
1464
+ AVOID_FLOATING_POINT_DATA_TYPES: "Columns of any kind whose data type is Double, which Power BI Desktop calls Decimal Number.",
1465
+ AVOID_INVALID_DESCRIPTION_CHARACTERS: "Descriptions containing a control character other than whitespace. Tabs and line breaks are allowed.",
1466
+ AVOID_INVALID_NAME_CHARACTERS: "Object names containing a control character other than whitespace. Tabs and line breaks are allowed here and are covered by `SPECIAL_CHARS_IN_OBJECT_NAMES`.",
1467
+ AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS: "Partitions whose source is a legacy query, a provider partition, that points at a structured data source.",
1468
+ AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE: "Tables that have a row-level security filter in any role and are named as the second argument of USERELATIONSHIP in a measure.",
1469
+ "AVOID_USING_'1-(X/Y)'_SYNTAX": "Expressions with a number, then plus or minus, then either `SUM('Table'[Column])` followed by a division operator, or a call to DIVIDE. The common shape is `1 - SUM(Sales[Cost]) / SUM(Sales[Amount])`.",
1470
+ "AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY": "Regular tables that carry a row-level security filter in any role and take part in a many-to-many relationship.",
1471
+ AVOID_USING_THE_IFERROR_FUNCTION: "Measures and calculated columns that call IFERROR.",
1472
+ CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS: "Calculation groups that contain no calculation items.",
1473
+ "CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID": "Every relationship that is bi-directional, many-to-many, or both. This is a review list at info severity, not a defect.",
1474
+ "CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_(RLS)_IS_NECESSARY": "Row-level security filters that call USERNAME or USERPRINCIPALNAME. Reported per table permission, at info severity.",
1475
+ DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN: "Data columns with no source column. Calculated columns are not checked.",
1476
+ "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "Tables with date or calendar in the name that are not marked as a date table, meaning the data category is not Time or no DateTime column is marked as the key.",
1477
+ DATECOLUMN_FORMATSTRING: "DateTime columns with date in the name whose format string is not exactly `mm/dd/yyyy`.",
1478
+ DAX_COLUMNS_FULLY_QUALIFIED: "Measures and row-level security filters that refer to a column by its bare name, `[Column]`, instead of `'Table'[Column]`.",
1479
+ DAX_MEASURES_UNQUALIFIED: "Measures, calculated columns, calculated tables, and calculation items that refer to a measure with a table prefix, `'Table'[Measure]`.",
1480
+ ENSURE_TABLES_HAVE_RELATIONSHIPS: "Tables with no relationship to any other table. Calculation groups are not checked.",
1481
+ EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS: "Measures that call EVALUATEANDLOG.",
1482
+ EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION: "Measures, calculated columns, and calculation items whose expression is empty.",
1483
+ FILTER_COLUMN_VALUES: "CALCULATE or CALCULATETABLE whose first filter argument is `FILTER('Table', 'Table'[Column] ...)`.",
1484
+ FILTER_MEASURE_VALUES_BY_COLUMNS: "CALCULATE or CALCULATETABLE whose first filter argument is `FILTER('Table', [Measure] ...)`.",
1485
+ FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED: "Tables, measures, hierarchies, calculated columns, calculated tables, and calculation groups whose first character has an upper case form and is not upper case.",
1486
+ FIX_REFERENTIAL_INTEGRITY_VIOLATIONS: "Relationships where the many side holds key values that do not exist on the one side. Row data is not in the model files, so pbiplint lists this rule but cannot run it.",
1487
+ "FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS": "Visible columns whose name starts with Is and whose type is whole number, and visible columns whose name ends with Flag and whose type is not text.",
1488
+ HIDE_FACT_TABLE_COLUMNS: "Visible numeric columns that a measure aggregates directly with a fully qualified reference, such as `SUM('Sales'[Amount])`. COUNT, SUM, AVERAGE, MIN, MAX, DISTINCTCOUNT, VALUES, DISTINCT, and their A-suffixed variants count as aggregations.",
1489
+ HIDE_FOREIGN_KEYS: "Visible columns whose name matches the from column of a relationship whose from side is many. Only the from cardinality is tested, so a many-to-many relationship counts here too, not just many-to-one.",
1490
+ INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED: "Inactive relationships that no measure or calculation item activates with USERELATIONSHIP.",
1491
+ INTEGER_FORMATTING: 'Measures whose static format string is not a recognized whole-number, currency, or percentage format. The only format strings the rule accepts are `#,0`, `#,0.0`, and any string containing `$` or `%`. A measure with no format string at all fires too, and that is the common case: the rule reads only the format string, so it cannot tell an unformatted currency or ratio from an unformatted count. Each finding says what the rule saw: `no format string`, `format string "0.00"`, or `dynamic format string only`.',
1492
+ ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS: "Hidden columns, or columns in hidden tables, that still have IsAvailableInMdx set to true and are not used to sort another column, in a hierarchy, or in a variation, and do not themselves sort by another column.",
1493
+ LARGE_TABLES_SHOULD_BE_PARTITIONED: "Tables with more than 25 million rows and a single partition. Row counts are not in the model files, so pbiplint lists this rule but cannot run it.",
1494
+ "LIMIT_ROW_LEVEL_SECURITY_(RLS)_LOGIC": "Tables whose row-level security filter, in any role, calls RIGHT, LEFT, UPPER, LOWER, or FIND.",
1495
+ "MANY-TO-MANY_RELATIONSHIPS_SHOULD_BE_SINGLE-DIRECTION": "Many-to-many relationships with bi-directional cross filtering.",
1496
+ MARK_PRIMARY_KEYS: "Columns on the one side of a relationship, outside date tables, that are not marked as the table's key.",
1497
+ MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES: "Measures whose whole expression is a reference to another measure, such as `[Total Sales]`.",
1498
+ MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY: "Measures and calculation items that call a time intelligence function, in a model where at least one table is in DirectQuery mode.",
1499
+ MINIMIZE_POWER_QUERY_TRANSFORMATIONS: "Power Query partitions whose M text contains Table.Combine, Table.Join, Table.NestedJoin, Table.AddColumn, Table.Group, Table.Sort, Table.Pivot, Table.Unpivot, Table.UnpivotOtherColumns, Table.Distinct, a native SQL query, or an OLE DB or ODBC query.",
1500
+ MODEL_SHOULD_HAVE_A_DATE_TABLE: "Models with no table that has the data category Time and a DateTime column marked as the key, which is what Mark as date table sets.",
1501
+ MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS: "Models that have at least one DirectQuery table, no aggregation table (no column has an alternateOf mapping), and the PowerBI_V3 data source version, which is every project Desktop writes today.",
1502
+ "MONTH_(AS_A_STRING)_MUST_BE_SORTED": "Text columns with month in the name, but not months, that have no sort-by column.",
1503
+ MONTHCOLUMN_FORMATSTRING: "DateTime columns with month in the name whose format string is not exactly `MMMM yyyy`.",
1504
+ NUMERIC_COLUMN_SUMMARIZE_BY: "Visible whole number, decimal, or double columns whose default summarization is anything other than None.",
1505
+ OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: "Names that start or end with a space, for the model, tables, measures, hierarchies, perspectives, partitions, data columns, and calculated columns.",
1506
+ OBJECTS_WITH_NO_DESCRIPTION: "Visible tables, columns, measures, and calculation groups with no description. Visibility is the object's own flag.",
1507
+ PARSE_ISSUE: "Lines the TMDL parser could not use: space indentation, an unterminated code fence, a line at an impossible indentation, a line in no form the parser recognizes, and a `///` description with a blank line between it and its declaration.",
1508
+ PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES: "Regular tables with exactly one partition whose name differs from the table name. Calculated tables and calculation groups are not checked.",
1509
+ PERCENTAGE_FORMATTING: "Measures with a percent format string other than `#,0.0%;-#,0.0%;#,0.0%`.",
1510
+ PERSPECTIVES_WITH_NO_OBJECTS: "Perspectives that contain no tables. Adding any column, measure, or hierarchy to a perspective adds its table, so a perspective with no tables is empty.",
1511
+ PROVIDE_FORMAT_STRING_FOR_MEASURES: "Visible measures with no format string and no dynamic format string.",
1512
+ REDUCE_NUMBER_OF_CALCULATED_COLUMNS: "Models with more than five calculated columns across all tables. Columns of calculated tables do not count, and the finding is on the model.",
1513
+ REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION: "Calculated columns whose DAX calls RELATED.",
1514
+ REDUCE_USAGE_OF_CALCULATED_TABLES: "Every calculated table. Calculation groups are not included.",
1515
+ "REDUCE_USAGE_OF_LONG-LENGTH_COLUMNS_WITH_HIGH_CARDINALITY": "Text columns where more than 500,000 rows hold values longer than 100 characters. Row data is not in the model files, so pbiplint lists this rule but cannot run it.",
1516
+ RELATIONSHIP_COLUMNS_SAME_DATA_TYPE: "Relationships whose two columns have different data types.",
1517
+ RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE: "Any column that takes part in a relationship and is not a whole number.",
1518
+ "REMOVE_AUTO-DATE_TABLE": "Calculated tables generated by the Auto date/time option, whose names start with DateTableTemplate_ or LocalDateTable_.",
1519
+ REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS: "Data sources that no partition names and that appear nowhere in any partition's query text.",
1520
+ REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES: "Columns that take part in no relationship and share a name with a column on a table that this table relates to from the many side. In practice, a fact table column that duplicates a dimension attribute.",
1521
+ REMOVE_ROLES_WITH_NO_MEMBERS: "Roles with no members.",
1522
+ SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS: "Columns with IsAvailableInMdx set to false that are used to sort another column, appear in a hierarchy or a variation, or sort by another column.",
1523
+ SNOWFLAKE_SCHEMA_ARCHITECTURE: "Tables that are on the from side of one relationship and the to side of another, which is what a dimension related to a sub-dimension looks like.",
1524
+ SPECIAL_CHARS_IN_OBJECT_NAMES: "Names containing a tab, line feed, or carriage return.",
1525
+ SPLIT_DATE_AND_TIME: "DateTime columns holding values that are not at midnight. Row data is not in the model files, so pbiplint lists this rule but cannot run it.",
1526
+ TRIM_OBJECT_NAMES: "Names that start or end with a space, across every named object type in the model.",
1527
+ UNNECESSARY_COLUMNS: "Hidden columns, or columns in hidden tables, that nothing references: no DAX expression, relationship, hierarchy, sort-by column, row-level security filter, or object-level security rule.",
1528
+ UNNECESSARY_MEASURES: "Hidden measures, or measures on hidden tables, that no DAX expression references.",
1529
+ "UNPIVOT_PIVOTED_(MONTH)_DATA": "Tables that have a numeric column for each of Jan, Feb, Mar, Apr, May, and Jun, matched as substrings of the column names.",
1530
+ USE_THE_DIVIDE_FUNCTION_FOR_DIVISION: "Expressions that use the division operator right after a closing bracket or parenthesis, such as `[Sales] / [Cost]` or `SUM(...) / SUM(...)`. A slash that starts a comment is ignored.",
1531
+ USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT: "Measures and calculation items that call INTERSECT."
1532
+ };
1533
+
1534
+ // ../core/src/rules/microsoft-bpa/define.ts
1535
+ var byId = new Map(BPA_RULES.map((r) => [r.id, r]));
1536
+ var SCOPE_MAP = {
1537
+ Model: "Model",
1538
+ Table: "Table",
1539
+ CalculatedTable: "CalculatedTable",
1540
+ CalculationGroup: "CalculationGroupTable",
1541
+ DataColumn: "Column",
1542
+ CalculatedColumn: "CalculatedColumn",
1543
+ CalculatedTableColumn: "CalculatedTableColumn",
1544
+ Measure: "Measure",
1545
+ Partition: "Partition",
1546
+ Relationship: "Relationship",
1547
+ ModelRole: "Role",
1548
+ TablePermission: "TablePermission",
1549
+ Perspective: "Perspective",
1550
+ Hierarchy: "Hierarchy",
1551
+ Level: "Level",
1552
+ CalculationItem: "CalculationItem",
1553
+ NamedExpression: "NamedExpression",
1554
+ ProviderDataSource: "DataSource",
1555
+ StructuredDataSource: "DataSource",
1556
+ KPI: null
1557
+ };
1558
+ function mapScope(scope) {
1559
+ const out = [];
1560
+ for (const s of scope.split(",").map((x) => x.trim()).filter((x) => x.length > 0)) {
1561
+ if (!(s in SCOPE_MAP)) throw new Error(`Unknown BPA scope: ${s}`);
1562
+ const t = SCOPE_MAP[s];
1563
+ if (t && !out.includes(t)) out.push(t);
1564
+ }
1565
+ return out;
1566
+ }
1567
+ var metaOf = (id) => {
1568
+ const meta = byId.get(id);
1569
+ if (!meta) throw new Error(`Unknown BPA rule id: ${id}`);
1570
+ return meta;
1571
+ };
1572
+ var stripCategory = (name) => name.replace(/^\[[^\]]*\]\s*/, "");
1573
+ var extractUrls = (text) => [
1574
+ ...new Set((text.match(/https?:\/\/[^\s)"]+/g) ?? []).map((u) => u.replace(/[.,]$/, "")))
1575
+ ];
1576
+ function bpaRule(id, check) {
1577
+ const meta = metaOf(id);
1578
+ return {
1579
+ id,
1580
+ name: stripCategory(meta.name),
1581
+ category: meta.category,
1582
+ severity: meta.severity,
1583
+ scope: mapScope(meta.scope),
1584
+ description: RULE_SUMMARIES[id] ?? stripCategory(meta.name),
1585
+ fixExpression: meta.fixExpression,
1586
+ references: extractUrls(meta.description),
1587
+ status: "ported",
1588
+ check
1589
+ };
1590
+ }
1591
+ function liveModelRule(id) {
1592
+ return { ...bpaRule(id, () => []), status: "needsLiveModel" };
1593
+ }
1594
+
1595
+ // ../core/src/rules/microsoft-bpa/columns.ts
1596
+ var columns = (m, pred) => allColumns(m).filter(pred).map(finding.column);
1597
+ var AVOID_FLOATING_POINT_DATA_TYPES = bpaRule(
1598
+ "AVOID_FLOATING_POINT_DATA_TYPES",
1599
+ (m) => columns(m, (c) => dataType(c) === "double")
1600
+ );
1601
+ var DATECOLUMN_FORMATSTRING = bpaRule(
1602
+ "DATECOLUMN_FORMATSTRING",
1603
+ (m) => columns(
1604
+ m,
1605
+ (c) => /date/i.test(c.name) && dataType(c) === "datetime" && (c.formatString ?? "") !== "mm/dd/yyyy"
1606
+ )
1607
+ );
1608
+ var MONTHCOLUMN_FORMATSTRING = bpaRule(
1609
+ "MONTHCOLUMN_FORMATSTRING",
1610
+ (m) => columns(
1611
+ m,
1612
+ (c) => /month/i.test(c.name) && dataType(c) === "datetime" && (c.formatString ?? "") !== "MMMM yyyy"
1613
+ )
1614
+ );
1615
+ var ADD_DATA_CATEGORY_FOR_COLUMNS = bpaRule(
1616
+ "ADD_DATA_CATEGORY_FOR_COLUMNS",
1617
+ (m) => columns(m, (c) => {
1618
+ const n = c.name.toLowerCase();
1619
+ const d = dataType(c);
1620
+ return isBlank(c.dataCategory) && ((n.includes("country") || n.includes("continent") || n.includes("city")) && d === "string" || (n === "latitude" || n === "longitude") && (d === "decimal" || d === "double"));
1621
+ })
1622
+ );
1623
+ var MONTH_AS_A_STRING_MUST_BE_SORTED = bpaRule(
1624
+ "MONTH_(AS_A_STRING)_MUST_BE_SORTED",
1625
+ (m) => columns(m, (c) => {
1626
+ const u = c.name.toUpperCase();
1627
+ return u.includes("MONTH") && !u.includes("MONTHS") && dataType(c) === "string" && c.sortByColumn === void 0;
1628
+ })
1629
+ );
1630
+ var NUMERIC_COLUMN_SUMMARIZE_BY = bpaRule(
1631
+ "NUMERIC_COLUMN_SUMMARIZE_BY",
1632
+ (m) => columns(
1633
+ m,
1634
+ (c) => isNumericType(c) && (c.summarizeBy ?? "default").toLowerCase() !== "none" && !hiddenOrTableHidden(c)
1635
+ )
1636
+ );
1637
+ var FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS = bpaRule(
1638
+ "FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS",
1639
+ (m) => columns(
1640
+ m,
1641
+ (c) => !hiddenOrTableHidden(c) && (c.name.startsWith("Is") && dataType(c) === "int64" || c.name.endsWith(" Flag") && dataType(c) !== "string")
1642
+ )
1643
+ );
1644
+ var DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN = bpaRule(
1645
+ "DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN",
1646
+ (m) => columns(m, (c) => c.kind === "data" && isBlank(c.sourceColumn))
1647
+ );
1648
+ var ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS = bpaRule(
1649
+ "ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS",
1650
+ (m, { indexes: { usage } }) => columns(
1651
+ m,
1652
+ (c) => c.isAvailableInMdx && hiddenOrTableHidden(c) && !usage.usedInSortBy(c) && !usage.usedInHierarchies(c) && !usage.usedInVariations(c) && c.sortByColumn === void 0
1653
+ )
1654
+ );
1655
+ var SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS = bpaRule(
1656
+ "SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS",
1657
+ (m, { indexes: { usage } }) => columns(
1658
+ m,
1659
+ (c) => !c.isAvailableInMdx && (usage.usedInSortBy(c) || usage.usedInHierarchies(c) || usage.usedInVariations(c) || c.sortByColumn !== void 0)
1660
+ )
1661
+ );
1662
+ var UNNECESSARY_COLUMNS = bpaRule("UNNECESSARY_COLUMNS", (m, { indexes }) => {
1663
+ const permissions = allTablePermissions(m);
1664
+ return columns(m, (c) => {
1665
+ if (!hiddenOrTableHidden(c)) return false;
1666
+ if (indexes.references.columnReferencedBy(c).length > 0) return false;
1667
+ if (indexes.relationships.forColumn(c.table.name, c.name).length > 0) return false;
1668
+ if (indexes.usage.usedInSortBy(c) || indexes.usage.usedInHierarchies(c)) return false;
1669
+ const bare = `[${c.name}]`.toLowerCase();
1670
+ const qualified = [
1671
+ `${c.table.name}[${c.name}]`.toLowerCase(),
1672
+ `'${c.table.name}'[${c.name}]`.toLowerCase()
1673
+ ];
1674
+ for (const tp of permissions) {
1675
+ const f = tp.filter?.toLowerCase();
1676
+ if (f === void 0) continue;
1677
+ if (tp.table === c.table.name && f.includes(bare)) return false;
1678
+ if (qualified.some((q) => f.includes(q))) return false;
1679
+ }
1680
+ for (const tp of permissions) {
1681
+ if (tp.table !== c.table.name) continue;
1682
+ if (tp.metadataPermission === "none") return false;
1683
+ if (tp.columnPermissions.some((cp) => cp.column === c.name && cp.permission === "none"))
1684
+ return false;
1685
+ }
1686
+ return true;
1687
+ });
1688
+ });
1689
+ var AGGREGATIONS = [
1690
+ "COUNT",
1691
+ "COUNTBLANK",
1692
+ "SUM",
1693
+ "AVERAGE",
1694
+ "VALUES",
1695
+ "DISTINCT",
1696
+ "DISTINCTCOUNT",
1697
+ "MIN",
1698
+ "MAX",
1699
+ "COUNTA",
1700
+ "AVERAGEA",
1701
+ "MAXA",
1702
+ "MINA"
1703
+ ];
1704
+ var HIDE_FACT_TABLE_COLUMNS = bpaRule("HIDE_FACT_TABLE_COLUMNS", (m) => {
1705
+ const measures = allMeasures(m);
1706
+ return columns(m, (c) => {
1707
+ if (c.isHidden || !isNumericType(c)) return false;
1708
+ const re = new RegExp(
1709
+ `(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${escapeRegExp(c.table.name)}'*\\[${escapeRegExp(c.name)}\\]\\s*\\)`,
1710
+ "i"
1711
+ );
1712
+ return measures.some((x) => re.test(x.expression));
1713
+ });
1714
+ });
1715
+ var columnRules = [
1716
+ AVOID_FLOATING_POINT_DATA_TYPES,
1717
+ DATECOLUMN_FORMATSTRING,
1718
+ MONTHCOLUMN_FORMATSTRING,
1719
+ ADD_DATA_CATEGORY_FOR_COLUMNS,
1720
+ MONTH_AS_A_STRING_MUST_BE_SORTED,
1721
+ NUMERIC_COLUMN_SUMMARIZE_BY,
1722
+ FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS,
1723
+ DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN,
1724
+ ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS,
1725
+ SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS,
1726
+ UNNECESSARY_COLUMNS,
1727
+ HIDE_FACT_TABLE_COLUMNS
1728
+ ];
1729
+
1730
+ // ../core/src/rules/microsoft-bpa/dependencies.ts
1731
+ function ownerFinding(o) {
1732
+ switch (o.kind) {
1733
+ case "measure":
1734
+ return finding.measure(o.object);
1735
+ case "calculatedColumn":
1736
+ return finding.column(o.object);
1737
+ case "calculatedTable":
1738
+ return finding.table(o.object);
1739
+ case "tablePermission":
1740
+ return finding.tablePermission(o.object);
1741
+ case "calculationItem":
1742
+ return finding.calculationItem(o.object);
1743
+ }
1744
+ }
1745
+ var DAX_COLUMNS_FULLY_QUALIFIED = bpaRule(
1746
+ "DAX_COLUMNS_FULLY_QUALIFIED",
1747
+ (_m, { indexes: { references } }) => references.owners.filter(
1748
+ (o) => (o.kind === "measure" || o.kind === "tablePermission" || o.kind === "calculationItem") && o.refs.some((r) => r.kind === "column" && !r.qualified)
1749
+ ).map(ownerFinding)
1750
+ );
1751
+ var DAX_MEASURES_UNQUALIFIED = bpaRule(
1752
+ "DAX_MEASURES_UNQUALIFIED",
1753
+ (_m, { indexes: { references } }) => references.owners.filter(
1754
+ (o) => o.kind !== "tablePermission" && o.refs.some((r) => r.kind === "measure" && r.qualified)
1755
+ ).map(ownerFinding)
1756
+ );
1757
+ var stripWhitespace = (s) => s.replace(/[ \n\r\t]/g, "");
1758
+ var AVOID_DUPLICATE_MEASURES = bpaRule("AVOID_DUPLICATE_MEASURES", (m) => {
1759
+ const all = allMeasures(m);
1760
+ const counts = /* @__PURE__ */ new Map();
1761
+ for (const x of all) {
1762
+ const k = stripWhitespace(x.expression);
1763
+ counts.set(k, (counts.get(k) ?? 0) + 1);
1764
+ }
1765
+ return all.filter((x) => (counts.get(stripWhitespace(x.expression)) ?? 0) > 1).map(finding.measure);
1766
+ });
1767
+ var MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES = bpaRule(
1768
+ "MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES",
1769
+ (m) => {
1770
+ const all = allMeasures(m);
1771
+ const names = new Set(all.map((x) => measureRef(x.name)));
1772
+ return all.filter((x) => names.has(x.expression)).map(finding.measure);
1773
+ }
1774
+ );
1775
+ var UNNECESSARY_MEASURES = bpaRule(
1776
+ "UNNECESSARY_MEASURES",
1777
+ (m, { indexes: { references } }) => allMeasures(m).filter(
1778
+ (x) => (x.table.isHidden || x.isHidden) && references.measureReferencedBy(x).length === 0
1779
+ ).map(finding.measure)
1780
+ );
1781
+ var dependencyRules = [
1782
+ DAX_COLUMNS_FULLY_QUALIFIED,
1783
+ DAX_MEASURES_UNQUALIFIED,
1784
+ AVOID_DUPLICATE_MEASURES,
1785
+ MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES,
1786
+ UNNECESSARY_MEASURES
1787
+ ];
1788
+
1789
+ // ../core/src/rules/microsoft-bpa/live-model.ts
1790
+ var liveModelRules = [
1791
+ liveModelRule("AVOID_BI-DIRECTIONAL_RELATIONSHIPS_AGAINST_HIGH-CARDINALITY_COLUMNS"),
1792
+ liveModelRule("REDUCE_USAGE_OF_LONG-LENGTH_COLUMNS_WITH_HIGH_CARDINALITY"),
1793
+ liveModelRule("SPLIT_DATE_AND_TIME"),
1794
+ liveModelRule("LARGE_TABLES_SHOULD_BE_PARTITIONED"),
1795
+ liveModelRule("FIX_REFERENTIAL_INTEGRITY_VIOLATIONS")
1796
+ ];
1797
+
1798
+ // ../core/src/rules/microsoft-bpa/measures.ts
1799
+ var M = "measure";
1800
+ var CC = "calculatedColumn";
1801
+ var CI = "calculationItem";
1802
+ var patternRule = (id, kinds, patterns) => bpaRule(
1803
+ id,
1804
+ (m) => expressionObjects(m, kinds).filter((o) => patterns.some((p) => p.test(o.expression))).map((o) => o.finding)
1805
+ );
1806
+ var PROVIDE_FORMAT_STRING_FOR_MEASURES = bpaRule(
1807
+ "PROVIDE_FORMAT_STRING_FOR_MEASURES",
1808
+ (m) => allMeasures(m).filter(
1809
+ (x) => !x.isHidden && !x.table.isHidden && isBlank(x.formatString) && isBlank(x.formatStringDefinition)
1810
+ ).map(finding.measure)
1811
+ );
1812
+ var formatStringDetail = (x) => {
1813
+ const fs = x.formatString ?? "";
1814
+ if (fs.trim() !== "") return `format string "${fs}"`;
1815
+ return isBlank(x.formatStringDefinition) ? "no format string" : "dynamic format string only";
1816
+ };
1817
+ var INTEGER_FORMATTING = bpaRule(
1818
+ "INTEGER_FORMATTING",
1819
+ (m) => allMeasures(m).filter((x) => {
1820
+ const fs = x.formatString ?? "";
1821
+ return !fs.includes("$") && !fs.includes("%") && !(fs === "#,0" || fs === "#,0.0");
1822
+ }).map((x) => ({ ...finding.measure(x), detail: formatStringDetail(x) }))
1823
+ );
1824
+ var PERCENTAGE_FORMATTING = bpaRule(
1825
+ "PERCENTAGE_FORMATTING",
1826
+ (m) => allMeasures(m).filter(
1827
+ (x) => (x.formatString ?? "").includes("%") && x.formatString !== "#,0.0%;-#,0.0%;#,0.0%"
1828
+ ).map(finding.measure)
1829
+ );
1830
+ var USE_THE_DIVIDE_FUNCTION_FOR_DIVISION = patternRule(
1831
+ "USE_THE_DIVIDE_FUNCTION_FOR_DIVISION",
1832
+ [M, CC, CI],
1833
+ [/\]\s*\/(?!\/)(?!\*)/, /\)\s*\/(?!\/)(?!\*)/]
1834
+ );
1835
+ var AVOID_USING_THE_IFERROR_FUNCTION = patternRule(
1836
+ "AVOID_USING_THE_IFERROR_FUNCTION",
1837
+ [M, CC],
1838
+ [/IFERROR\s*\(/i]
1839
+ );
1840
+ var USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT = patternRule(
1841
+ "USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT",
1842
+ [M, CI],
1843
+ [/INTERSECT\s*\(/i]
1844
+ );
1845
+ var FILTER_COLUMN_VALUES = patternRule(
1846
+ "FILTER_COLUMN_VALUES",
1847
+ [M, CC, CI],
1848
+ [
1849
+ /CALCULATE\s*\(\s*[^,]+,\s*FILTER\s*\(\s*'*[A-Za-z0-9 _]+'*\s*,\s*'*[A-Za-z0-9 _]+'*\[[A-Za-z0-9 _]+\]/i,
1850
+ /CALCULATETABLE\s*\([^,]*,\s*FILTER\s*\(\s*'*[A-Za-z0-9 _]+'*,\s*'*[A-Za-z0-9 _]+'*\[[A-Za-z0-9 _]+\]/i
1851
+ ]
1852
+ );
1853
+ var FILTER_MEASURE_VALUES_BY_COLUMNS = patternRule(
1854
+ "FILTER_MEASURE_VALUES_BY_COLUMNS",
1855
+ [M, CC, CI],
1856
+ [
1857
+ /CALCULATE\s*\(\s*[^,]+,\s*FILTER\s*\(\s*'*[A-Za-z0-9 _]+'*\s*,\s*\[[^\]]+\]/i,
1858
+ /CALCULATETABLE\s*\([^,]*,\s*FILTER\s*\(\s*'*[A-Za-z0-9 _]+'*,\s*\[/i
1859
+ ]
1860
+ );
1861
+ var AVOID_USING_1_X_Y_SYNTAX = patternRule(
1862
+ "AVOID_USING_'1-(X/Y)'_SYNTAX",
1863
+ [M, CC, CI],
1864
+ [
1865
+ /[0-9]+\s*[-+]\s*[(]*\s*SUM\s*\(\s*'*[A-Za-z0-9 _]+'*\s*\[[A-Za-z0-9 _]+\]\s*\)\s*\//i,
1866
+ /[0-9]+\s*[-+]\s*DIVIDE\s*\(/i
1867
+ ]
1868
+ );
1869
+ var EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS = patternRule(
1870
+ "EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS",
1871
+ [M],
1872
+ [/EVALUATEANDLOG\s*\(/i]
1873
+ );
1874
+ var REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION = patternRule(
1875
+ "REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION",
1876
+ [CC],
1877
+ [/RELATED\s*\(/i]
1878
+ );
1879
+ var EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION = bpaRule(
1880
+ "EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION",
1881
+ (m) => expressionObjects(m, [M, CC, CI]).filter((o) => isBlank(o.expression)).map((o) => o.finding)
1882
+ );
1883
+ var measureRules = [
1884
+ PROVIDE_FORMAT_STRING_FOR_MEASURES,
1885
+ INTEGER_FORMATTING,
1886
+ PERCENTAGE_FORMATTING,
1887
+ USE_THE_DIVIDE_FUNCTION_FOR_DIVISION,
1888
+ AVOID_USING_THE_IFERROR_FUNCTION,
1889
+ USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT,
1890
+ FILTER_COLUMN_VALUES,
1891
+ FILTER_MEASURE_VALUES_BY_COLUMNS,
1892
+ AVOID_USING_1_X_Y_SYNTAX,
1893
+ EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS,
1894
+ REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION,
1895
+ EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION
1896
+ ];
1897
+
1898
+ // ../core/src/rules/microsoft-bpa/naming.ts
1899
+ var namedObjectRule = (id, test) => bpaRule(
1900
+ id,
1901
+ (m) => namedObjects(m, mapScope(metaOf(id).scope)).filter((o) => test(o.name, o.description)).map((o) => o.finding)
1902
+ );
1903
+ var startsOrEndsWithSpace = (name) => name.startsWith(" ") || name.endsWith(" ");
1904
+ var TRIM_OBJECT_NAMES = namedObjectRule("TRIM_OBJECT_NAMES", startsOrEndsWithSpace);
1905
+ var OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE = namedObjectRule(
1906
+ "OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE",
1907
+ startsOrEndsWithSpace
1908
+ );
1909
+ var SPECIAL_CHARS_IN_OBJECT_NAMES = namedObjectRule(
1910
+ "SPECIAL_CHARS_IN_OBJECT_NAMES",
1911
+ (name) => /[\t\n\r]/.test(name)
1912
+ );
1913
+ var CONTROL_NOT_WHITESPACE = /[\x00-\x08\x0E-\x1F\x7F-\x84\x86-\x9F]/;
1914
+ var AVOID_INVALID_NAME_CHARACTERS = namedObjectRule(
1915
+ "AVOID_INVALID_NAME_CHARACTERS",
1916
+ (name) => CONTROL_NOT_WHITESPACE.test(name)
1917
+ );
1918
+ var AVOID_INVALID_DESCRIPTION_CHARACTERS = namedObjectRule(
1919
+ "AVOID_INVALID_DESCRIPTION_CHARACTERS",
1920
+ (_name, description) => CONTROL_NOT_WHITESPACE.test(description ?? "")
1921
+ );
1922
+ var FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED = namedObjectRule(
1923
+ "FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED",
1924
+ (name) => name.length > 0 && name.slice(0, 1).toUpperCase() !== name.slice(0, 1)
1925
+ );
1926
+ var PERSPECTIVES_WITH_NO_OBJECTS = bpaRule(
1927
+ "PERSPECTIVES_WITH_NO_OBJECTS",
1928
+ (m) => m.perspectives.filter((p) => p.tables.length === 0).map(finding.perspective)
1929
+ );
1930
+ var CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS = bpaRule(
1931
+ "CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS",
1932
+ (m) => m.tables.filter((t) => t.calculationGroup !== void 0 && t.calculationGroup.items.length === 0).map(finding.table)
1933
+ );
1934
+ var REMOVE_ROLES_WITH_NO_MEMBERS = bpaRule(
1935
+ "REMOVE_ROLES_WITH_NO_MEMBERS",
1936
+ (m) => m.roles.filter((r) => r.members.length === 0).map(finding.role)
1937
+ );
1938
+ var REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS = bpaRule(
1939
+ "REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS",
1940
+ (m) => {
1941
+ const partitions = allPartitions(m);
1942
+ return m.dataSources.filter(
1943
+ (ds) => !partitions.some((p) => p.dataSource === ds.name) && !partitions.some((p) => (p.source ?? "").includes(ds.name))
1944
+ ).map(finding.dataSource);
1945
+ }
1946
+ );
1947
+ var AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS = bpaRule(
1948
+ "AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS",
1949
+ (m) => allPartitions(m).filter(
1950
+ (p) => p.sourceType === "query" && m.dataSources.some((ds) => ds.name === p.dataSource && ds.kind === "structured")
1951
+ ).map(finding.partition)
1952
+ );
1953
+ var namingRules = [
1954
+ TRIM_OBJECT_NAMES,
1955
+ OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE,
1956
+ SPECIAL_CHARS_IN_OBJECT_NAMES,
1957
+ AVOID_INVALID_NAME_CHARACTERS,
1958
+ AVOID_INVALID_DESCRIPTION_CHARACTERS,
1959
+ FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED,
1960
+ PERSPECTIVES_WITH_NO_OBJECTS,
1961
+ CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS,
1962
+ REMOVE_ROLES_WITH_NO_MEMBERS,
1963
+ REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS,
1964
+ AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS
1965
+ ];
1966
+
1967
+ // ../core/src/rules/microsoft-bpa/relationships.ts
1968
+ var isManyToMany = (r) => r.fromCardinality === "many" && r.toCardinality === "many";
1969
+ var isBidirectional = (r) => r.crossFilteringBehavior === "bothdirections";
1970
+ var RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE = bpaRule(
1971
+ "RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE",
1972
+ (m, { indexes: { relationships } }) => allColumns(m).filter(
1973
+ (c) => relationships.forColumn(c.table.name, c.name).length > 0 && dataType(c) !== "int64"
1974
+ ).map(finding.column)
1975
+ );
1976
+ var HIDE_FOREIGN_KEYS = bpaRule(
1977
+ "HIDE_FOREIGN_KEYS",
1978
+ (m, { indexes: { relationships } }) => allColumns(m).filter(
1979
+ (c) => !c.isHidden && relationships.forColumn(c.table.name, c.name).some((r) => r.fromColumn === c.name && r.fromCardinality === "many")
1980
+ ).map(finding.column)
1981
+ );
1982
+ var MARK_PRIMARY_KEYS = bpaRule(
1983
+ "MARK_PRIMARY_KEYS",
1984
+ (m, { indexes: { relationships } }) => allColumns(m).filter(
1985
+ (c) => !c.isKey && c.table.dataCategory !== "Time" && relationships.forColumn(c.table.name, c.name).some(
1986
+ (r) => r.toTable === c.table.name && r.toColumn === c.name && r.toCardinality === "one"
1987
+ )
1988
+ ).map(finding.column)
1989
+ );
1990
+ var REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES = bpaRule(
1991
+ "REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES",
1992
+ (m, { indexes: { relationships } }) => {
1993
+ const all = allColumns(m);
1994
+ return all.filter(
1995
+ (c) => relationships.forColumn(c.table.name, c.name).length === 0 && all.some(
1996
+ (o) => o.name === c.name && o.table !== c.table && relationships.forTable(o.table.name).some((r) => r.fromTable === c.table.name)
1997
+ )
1998
+ ).map(finding.column);
1999
+ }
2000
+ );
2001
+ var SNOWFLAKE_SCHEMA_ARCHITECTURE = bpaRule(
2002
+ "SNOWFLAKE_SCHEMA_ARCHITECTURE",
2003
+ (m, { indexes: { relationships } }) => tablesInScope(m).filter((t) => {
2004
+ const rels = relationships.forTable(t.name);
2005
+ return rels.some((r) => r.fromTable === t.name) && rels.some((r) => r.toTable === t.name);
2006
+ }).map(finding.table)
2007
+ );
2008
+ var ENSURE_TABLES_HAVE_RELATIONSHIPS = bpaRule(
2009
+ "ENSURE_TABLES_HAVE_RELATIONSHIPS",
2010
+ (m, { indexes: { relationships } }) => tablesInScope(m).filter((t) => relationships.forTable(t.name).length === 0).map(finding.table)
2011
+ );
2012
+ var MANY_TO_MANY_RELATIONSHIPS_SHOULD_BE_SINGLE_DIRECTION = bpaRule(
2013
+ "MANY-TO-MANY_RELATIONSHIPS_SHOULD_BE_SINGLE-DIRECTION",
2014
+ (m) => m.relationships.filter((r) => isManyToMany(r) && isBidirectional(r)).map(finding.relationship)
2015
+ );
2016
+ var CHECK_IF_BIDIRECTIONAL_AND_MANY_TO_MANY_RELATIONSHIPS_ARE_VALID = bpaRule(
2017
+ "CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID",
2018
+ (m) => m.relationships.filter((r) => isManyToMany(r) || isBidirectional(r)).map(finding.relationship)
2019
+ );
2020
+ var RELATIONSHIP_COLUMNS_SAME_DATA_TYPE = bpaRule(
2021
+ "RELATIONSHIP_COLUMNS_SAME_DATA_TYPE",
2022
+ (m) => {
2023
+ const column = (table, name) => m.tables.find((t) => t.name === table)?.columns.find((c) => c.name === name);
2024
+ return m.relationships.filter((r) => {
2025
+ const from = column(r.fromTable, r.fromColumn);
2026
+ const to = column(r.toTable, r.toColumn);
2027
+ return from !== void 0 && to !== void 0 && dataType(from) !== dataType(to);
2028
+ }).map(finding.relationship);
2029
+ }
2030
+ );
2031
+ var INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED = bpaRule(
2032
+ "INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED",
2033
+ (m) => {
2034
+ const expressions = [
2035
+ ...allMeasures(m).map((x) => x.expression),
2036
+ ...allCalculationItems(m).map((i) => i.expression)
2037
+ ];
2038
+ return m.relationships.filter((r) => {
2039
+ if (r.isActive) return false;
2040
+ const re = new RegExp(
2041
+ `USERELATIONSHIP\\s*\\(\\s*'*${escapeRegExp(r.fromTable)}'*\\[${escapeRegExp(r.fromColumn)}\\]\\s*,\\s*'*${escapeRegExp(r.toTable)}'*\\[${escapeRegExp(r.toColumn)}\\]`,
2042
+ "i"
2043
+ );
2044
+ return !expressions.some((e) => re.test(e));
2045
+ }).map(finding.relationship);
2046
+ }
2047
+ );
2048
+ var AVOID_EXCESSIVE_BIDIRECTIONAL_OR_MANY_TO_MANY_RELATIONSHIPS = bpaRule(
2049
+ "AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS",
2050
+ (m) => {
2051
+ const rels = m.relationships;
2052
+ const count = rels.filter(isBidirectional).length + rels.filter(isManyToMany).length;
2053
+ return count / Math.max(rels.length, 1) > 0.3 ? [finding.model(m)] : [];
2054
+ }
2055
+ );
2056
+ var AVOID_USING_MANY_TO_MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY = bpaRule(
2057
+ "AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY",
2058
+ (m, { indexes: { relationships } }) => {
2059
+ const permissions = allTablePermissions(m);
2060
+ return m.tables.filter(
2061
+ (t) => t.kind === "table" && relationships.forTable(t.name).some(isManyToMany) && permissions.some((tp) => tp.table === t.name && (tp.filter ?? "").length > 0)
2062
+ ).map(finding.table);
2063
+ }
2064
+ );
2065
+ var relationshipRules = [
2066
+ RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE,
2067
+ HIDE_FOREIGN_KEYS,
2068
+ MARK_PRIMARY_KEYS,
2069
+ REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES,
2070
+ SNOWFLAKE_SCHEMA_ARCHITECTURE,
2071
+ ENSURE_TABLES_HAVE_RELATIONSHIPS,
2072
+ MANY_TO_MANY_RELATIONSHIPS_SHOULD_BE_SINGLE_DIRECTION,
2073
+ CHECK_IF_BIDIRECTIONAL_AND_MANY_TO_MANY_RELATIONSHIPS_ARE_VALID,
2074
+ RELATIONSHIP_COLUMNS_SAME_DATA_TYPE,
2075
+ INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED,
2076
+ AVOID_EXCESSIVE_BIDIRECTIONAL_OR_MANY_TO_MANY_RELATIONSHIPS,
2077
+ AVOID_USING_MANY_TO_MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY
2078
+ ];
2079
+
2080
+ // ../core/src/rules/microsoft-bpa/tables.ts
2081
+ var hasDateTimeKey = (t) => t.columns.some((c) => c.isKey && dataType(c) === "datetime");
2082
+ var MODEL_SHOULD_HAVE_A_DATE_TABLE = bpaRule(
2083
+ "MODEL_SHOULD_HAVE_A_DATE_TABLE",
2084
+ (m) => m.tables.some((t) => t.dataCategory === "Time" && hasDateTimeKey(t)) ? [] : [finding.model(m)]
2085
+ );
2086
+ var DATE_CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE = bpaRule(
2087
+ "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE",
2088
+ (m) => tablesInScope(m).filter((t) => {
2089
+ const u = t.name.toUpperCase();
2090
+ return (u.includes("DATE") || u.includes("CALENDAR")) && (t.dataCategory !== "Time" || !hasDateTimeKey(t));
2091
+ }).map(finding.table)
2092
+ );
2093
+ var REMOVE_AUTO_DATE_TABLE = bpaRule(
2094
+ "REMOVE_AUTO-DATE_TABLE",
2095
+ (m) => m.tables.filter(
2096
+ (t) => t.kind === "calculated" && (t.name.startsWith("DateTableTemplate_") || t.name.startsWith("LocalDateTable_"))
2097
+ ).map(finding.table)
2098
+ );
2099
+ var REDUCE_USAGE_OF_CALCULATED_TABLES = bpaRule(
2100
+ "REDUCE_USAGE_OF_CALCULATED_TABLES",
2101
+ (m) => m.tables.filter((t) => t.kind === "calculated").map(finding.table)
2102
+ );
2103
+ var REDUCE_NUMBER_OF_CALCULATED_COLUMNS = bpaRule(
2104
+ "REDUCE_NUMBER_OF_CALCULATED_COLUMNS",
2105
+ (m) => allColumns(m).filter((c) => c.kind === "calculated").length > 5 ? [finding.model(m)] : []
2106
+ );
2107
+ var MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN"];
2108
+ var UNPIVOT_PIVOTED_MONTH_DATA = bpaRule(
2109
+ "UNPIVOT_PIVOTED_(MONTH)_DATA",
2110
+ (m) => tablesInScope(m).filter(
2111
+ (t) => MONTHS.every(
2112
+ (mo) => t.columns.some((c) => c.name.toUpperCase().includes(mo) && isNumericType(c))
2113
+ )
2114
+ ).map(finding.table)
2115
+ );
2116
+ var PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES = bpaRule(
2117
+ "PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES",
2118
+ (m) => m.tables.filter(
2119
+ (t) => t.kind === "table" && t.partitions.length === 1 && t.partitions[0].name !== t.name
2120
+ ).map(finding.table)
2121
+ );
2122
+ var POWER_QUERY_PATTERNS = [
2123
+ "Table.Combine(",
2124
+ "Table.Join(",
2125
+ "Table.NestedJoin(",
2126
+ "Table.AddColumn(",
2127
+ "Table.Group(",
2128
+ "Table.Sort(",
2129
+ "Table.Pivot(",
2130
+ "Table.Unpivot(",
2131
+ "Table.UnpivotOtherColumns(",
2132
+ "Table.Distinct(",
2133
+ '[Query="SELECT',
2134
+ "Value.NativeQuery",
2135
+ "OleDb.Query",
2136
+ "Odbc.Query"
2137
+ ];
2138
+ var MINIMIZE_POWER_QUERY_TRANSFORMATIONS = bpaRule(
2139
+ "MINIMIZE_POWER_QUERY_TRANSFORMATIONS",
2140
+ (m) => allPartitions(m).filter(
2141
+ (p) => p.sourceType === "m" && POWER_QUERY_PATTERNS.some((s) => (p.source ?? "").includes(s))
2142
+ ).map(finding.partition)
2143
+ );
2144
+ var MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS = bpaRule(
2145
+ "MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS",
2146
+ (m) => m.tables.some(isDirectQueryTable) && !allColumns(m).some((c) => c.hasAlternateOf) && String(m.props.defaultpowerbidatasourceversion ?? "").toLowerCase() === "powerbi_v3" ? [finding.model(m)] : []
2147
+ );
2148
+ var TIME_INTELLIGENCE_FUNCTIONS = [
2149
+ "CLOSINGBALANCEMONTH",
2150
+ "CLOSINGBALANCEQUARTER",
2151
+ "CLOSINGBALANCEYEAR",
2152
+ "DATEADD",
2153
+ "DATESBETWEEN",
2154
+ "DATESINPERIOD",
2155
+ "DATESMTD",
2156
+ "DATESQTD",
2157
+ "DATESYTD",
2158
+ "ENDOFMONTH",
2159
+ "ENDOFQUARTER",
2160
+ "ENDOFYEAR",
2161
+ "FIRSTDATE",
2162
+ "FIRSTNONBLANK",
2163
+ "FIRSTNONBLANKVALUE",
2164
+ "LASTDATE",
2165
+ "LASTNONBLANK",
2166
+ "LASTNONBLANKVALUE",
2167
+ "NEXTDAY",
2168
+ "NEXTMONTH",
2169
+ "NEXTQUARTER",
2170
+ "NEXTYEAR",
2171
+ "OPENINGBALANCEMONTH",
2172
+ "OPENINGBALANCEQUARTER",
2173
+ "OPENINGBALANCEYEAR",
2174
+ "PARALLELPERIOD",
2175
+ "PREVIOUSDAY",
2176
+ "PREVIOUSMONTH",
2177
+ "PREVIOUSQUARTER",
2178
+ "PREVIOUSYEAR",
2179
+ "SAMEPERIODLASTYEAR",
2180
+ "STARTOFMONTH",
2181
+ "STARTOFQUARTER",
2182
+ "STARTOFYEAR",
2183
+ "TOTALMTD",
2184
+ "TOTALQTD",
2185
+ "TOTALYTD"
2186
+ ];
2187
+ var TIME_INTELLIGENCE = new RegExp(`(?:${TIME_INTELLIGENCE_FUNCTIONS.join("|")})\\s*\\(`);
2188
+ var MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY = bpaRule(
2189
+ "MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY",
2190
+ (m) => m.tables.some(isDirectQueryTable) ? expressionObjects(m, ["measure", "calculationItem"]).filter((o) => TIME_INTELLIGENCE.test(o.expression)).map((o) => o.finding) : []
2191
+ );
2192
+ var RLS_FUNCTIONS = [/RIGHT\s*\(/i, /LEFT\s*\(/i, /UPPER\s*\(/i, /LOWER\s*\(/i, /FIND\s*\(/i];
2193
+ var LIMIT_ROW_LEVEL_SECURITY_LOGIC = bpaRule(
2194
+ "LIMIT_ROW_LEVEL_SECURITY_(RLS)_LOGIC",
2195
+ (m) => {
2196
+ const permissions = allTablePermissions(m);
2197
+ return tablesInScope(m).filter(
2198
+ (t) => permissions.some(
2199
+ (tp) => tp.table === t.name && tp.filter !== void 0 && RLS_FUNCTIONS.some((re) => re.test(tp.filter.replace(/ /g, "")))
2200
+ )
2201
+ ).map(finding.table);
2202
+ }
2203
+ );
2204
+ var CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_IS_NECESSARY = bpaRule(
2205
+ "CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_(RLS)_IS_NECESSARY",
2206
+ (m) => allTablePermissions(m).filter(
2207
+ (tp) => tp.filter !== void 0 && (/USERNAME\(/i.test(tp.filter) || /USERPRINCIPALNAME\(/i.test(tp.filter))
2208
+ ).map(finding.tablePermission)
2209
+ );
2210
+ var AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE = bpaRule(
2211
+ "AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE",
2212
+ (m) => {
2213
+ const permissions = allTablePermissions(m);
2214
+ const measures = allMeasures(m);
2215
+ return tablesInScope(m).filter((t) => {
2216
+ if (!permissions.some((tp) => tp.table === t.name && tp.filter !== void 0)) return false;
2217
+ const re = new RegExp(
2218
+ `USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${escapeRegExp(t.name)}'*\\[`,
2219
+ "i"
2220
+ );
2221
+ return measures.some((x) => re.test(x.expression));
2222
+ }).map(finding.table);
2223
+ }
2224
+ );
2225
+ var OBJECTS_WITH_NO_DESCRIPTION = bpaRule(
2226
+ "OBJECTS_WITH_NO_DESCRIPTION",
2227
+ (m) => m.tables.flatMap((t) => [
2228
+ ...isBlank(t.description) && !t.isHidden ? [finding.table(t)] : [],
2229
+ ...t.columns.filter((c) => isBlank(c.description) && !c.isHidden).map(finding.column),
2230
+ ...t.measures.filter((x) => isBlank(x.description) && !x.isHidden).map(finding.measure)
2231
+ ])
2232
+ );
2233
+ var tableRules = [
2234
+ MODEL_SHOULD_HAVE_A_DATE_TABLE,
2235
+ DATE_CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE,
2236
+ REMOVE_AUTO_DATE_TABLE,
2237
+ REDUCE_USAGE_OF_CALCULATED_TABLES,
2238
+ REDUCE_NUMBER_OF_CALCULATED_COLUMNS,
2239
+ UNPIVOT_PIVOTED_MONTH_DATA,
2240
+ PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES,
2241
+ MINIMIZE_POWER_QUERY_TRANSFORMATIONS,
2242
+ MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS,
2243
+ MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY,
2244
+ LIMIT_ROW_LEVEL_SECURITY_LOGIC,
2245
+ CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_IS_NECESSARY,
2246
+ AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE,
2247
+ OBJECTS_WITH_NO_DESCRIPTION
2248
+ ];
2249
+
2250
+ // ../core/src/rules/microsoft-bpa/index.ts
2251
+ var order = new Map(BPA_RULES.map((r, i) => [r.id, i]));
2252
+ var microsoftBpaRules = [
2253
+ ...columnRules,
2254
+ ...relationshipRules,
2255
+ ...measureRules,
2256
+ ...dependencyRules,
2257
+ ...tableRules,
2258
+ ...namingRules,
2259
+ ...liveModelRules
2260
+ ].sort((a, b) => order.get(a.id) - order.get(b.id));
2261
+
2262
+ // ../core/src/rules/parse-issue.ts
2263
+ var PARSE_ISSUE = {
2264
+ id: "PARSE_ISSUE",
2265
+ name: "TMDL could not be fully parsed",
2266
+ category: "Error Prevention",
2267
+ severity: 3,
2268
+ scope: ["File"],
2269
+ description: RULE_SUMMARIES["PARSE_ISSUE"] ?? "TMDL could not be fully parsed",
2270
+ references: ["https://learn.microsoft.com/analysis-services/tmdl/tmdl-overview"],
2271
+ status: "builtin",
2272
+ check: (model) => model.files.flatMap(
2273
+ (f) => f.issues.map((issue) => ({
2274
+ objectType: "File",
2275
+ objectName: issue.file,
2276
+ location: { file: issue.file, line: issue.line },
2277
+ detail: `${issue.reason}: ${issue.text.trim()}`
2278
+ }))
2279
+ )
2280
+ };
2281
+
2282
+ // ../core/src/rules/index.ts
2283
+ var defaultRules = [PARSE_ISSUE, ...microsoftBpaRules];
2284
+
2285
+ // ../core/src/tmdl/parse.ts
2286
+ var HEADER = /^([A-Za-z_]\w*)(?:\s+(.+))?$/;
2287
+ var PROP = /^([A-Za-z_]\w*):(?:\s(.*))?$/;
2288
+ var REF = /^ref\s+([A-Za-z_]\w*)\s+(.+)$/;
2289
+ var tabIndent = (line) => {
2290
+ let n = 0;
2291
+ while (line[n] === " ") n++;
2292
+ return n;
2293
+ };
2294
+ var leadingWs = (line) => line.length - line.trimStart().length;
2295
+ function splitHeader(content) {
2296
+ let inQuote = false;
2297
+ let eqAt = -1;
2298
+ for (let i = 0; i < content.length; i++) {
2299
+ const ch = content[i];
2300
+ if (ch === "'") inQuote = !inQuote;
2301
+ else if (ch === "=" && !inQuote) {
2302
+ eqAt = i;
2303
+ break;
2304
+ }
2305
+ }
2306
+ const left = (eqAt >= 0 ? content.slice(0, eqAt) : content).trim();
2307
+ const inline = eqAt >= 0 ? content.slice(eqAt + 1).trim() : "";
2308
+ const m = HEADER.exec(left);
2309
+ if (!m) return null;
2310
+ return {
2311
+ type: m[1],
2312
+ name: m[2] === void 0 ? void 0 : unquoteName(m[2]),
2313
+ hasEq: eqAt >= 0,
2314
+ inline
2315
+ };
2316
+ }
2317
+ function parseTmdl(file, text) {
2318
+ const body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
2319
+ const lines = body.replace(/\r\n?/g, "\n").split("\n");
2320
+ const roots = [];
2321
+ const issues = [];
2322
+ const stack = [];
2323
+ let pendingDescription = [];
2324
+ let descriptionLine = 0;
2325
+ let descriptionText = "";
2326
+ let i = 0;
2327
+ while (i < lines.length) {
2328
+ const raw = lines[i];
2329
+ const lineNo = i + 1;
2330
+ if (raw.trim() === "") {
2331
+ if (pendingDescription.length) {
2332
+ issues.push({
2333
+ file,
2334
+ line: descriptionLine,
2335
+ text: descriptionText,
2336
+ reason: "description is not followed by a declaration"
2337
+ });
2338
+ pendingDescription = [];
2339
+ }
2340
+ i++;
2341
+ continue;
2342
+ }
2343
+ const indent = tabIndent(raw);
2344
+ const content = raw.slice(indent);
2345
+ if (content.startsWith("///")) {
2346
+ if (!pendingDescription.length) {
2347
+ descriptionLine = lineNo;
2348
+ descriptionText = raw;
2349
+ }
2350
+ pendingDescription.push(content.replace(/^\/\/\/ ?/, ""));
2351
+ i++;
2352
+ continue;
2353
+ }
2354
+ if (/^\s/.test(content)) {
2355
+ issues.push({
2356
+ file,
2357
+ line: lineNo,
2358
+ text: raw,
2359
+ reason: "space indentation (TMDL requires tabs)"
2360
+ });
2361
+ i++;
2362
+ continue;
2363
+ }
2364
+ const collectBlock = () => {
2365
+ let j = i + 1;
2366
+ while (j < lines.length && lines[j].trim() === "") j++;
2367
+ if (j >= lines.length) return "";
2368
+ const blockIndent = leadingWs(lines[j]);
2369
+ if (blockIndent <= indent) return "";
2370
+ const out = [];
2371
+ let lastNonBlank = -1;
2372
+ for (; j < lines.length; j++) {
2373
+ const l = lines[j];
2374
+ if (l.trim() === "") {
2375
+ out.push("");
2376
+ continue;
2377
+ }
2378
+ if (leadingWs(l) < blockIndent) break;
2379
+ out.push(l.slice(blockIndent));
2380
+ lastNonBlank = out.length - 1;
2381
+ }
2382
+ i = j - 1;
2383
+ return out.slice(0, lastNonBlank + 1).join("\n");
2384
+ };
2385
+ const collectFenced = () => {
2386
+ const out = [];
2387
+ let j = i + 1;
2388
+ while (j < lines.length && lines[j].trim() !== "```") {
2389
+ out.push(lines[j]);
2390
+ j++;
2391
+ }
2392
+ if (j >= lines.length)
2393
+ issues.push({ file, line: lineNo, text: raw, reason: "unterminated ``` fence" });
2394
+ const boundary = j < lines.length ? leadingWs(lines[j]) : 0;
2395
+ i = j;
2396
+ return out.map((l) => l.slice(Math.min(boundary, leadingWs(l)))).join("\n");
2397
+ };
2398
+ const base = {
2399
+ props: {},
2400
+ children: [],
2401
+ file,
2402
+ line: lineNo,
2403
+ indent
2404
+ };
2405
+ let node;
2406
+ let m;
2407
+ if (m = REF.exec(content)) {
2408
+ node = { ...base, kind: "ref", type: m[1].toLowerCase(), name: unquoteName(m[2]) };
2409
+ } else if (m = PROP.exec(content)) {
2410
+ node = { ...base, kind: "prop", type: m[1].toLowerCase(), value: unquoteValue(m[2] ?? "") };
2411
+ } else {
2412
+ const h = splitHeader(content);
2413
+ if (!h) {
2414
+ issues.push({ file, line: lineNo, text: raw, reason: "unrecognized line" });
2415
+ i++;
2416
+ continue;
2417
+ }
2418
+ if (h.hasEq) {
2419
+ const value = h.inline === "```" ? collectFenced() : h.inline === "" ? collectBlock() : h.inline;
2420
+ node = h.name === void 0 ? { ...base, kind: "expr", type: h.type.toLowerCase(), value } : { ...base, kind: "object", type: h.type.toLowerCase(), name: h.name, value };
2421
+ } else if (h.name !== void 0) {
2422
+ node = { ...base, kind: "object", type: h.type.toLowerCase(), name: h.name };
2423
+ } else {
2424
+ node = { ...base, kind: "flag", type: h.type.toLowerCase() };
2425
+ }
2426
+ }
2427
+ if (pendingDescription.length) {
2428
+ node.description = pendingDescription.join("\n");
2429
+ pendingDescription = [];
2430
+ }
2431
+ stack.length = indent;
2432
+ const parent = indent > 0 ? stack[indent - 1] : void 0;
2433
+ if (indent > 0 && !parent) {
2434
+ issues.push({ file, line: lineNo, text: raw, reason: "orphan indentation" });
2435
+ i++;
2436
+ continue;
2437
+ }
2438
+ if (parent) {
2439
+ if (node.kind === "prop" || node.kind === "expr") parent.props[node.type] = node.value ?? "";
2440
+ else if (node.kind === "flag") parent.props[node.type] = true;
2441
+ parent.children.push(node);
2442
+ } else {
2443
+ roots.push(node);
2444
+ }
2445
+ stack[indent] = node;
2446
+ i++;
2447
+ }
2448
+ return { file, roots, issues, lineCount: lines.length };
2449
+ }
2450
+
2451
+ // ../core/src/rules/types.ts
2452
+ var CATEGORY_ORDER = [
2453
+ "Performance",
2454
+ "Error Prevention",
2455
+ "DAX Expressions",
2456
+ "Maintenance",
2457
+ "Formatting",
2458
+ "Naming Conventions"
2459
+ ];
2460
+ var SEVERITY_LABEL = {
2461
+ 1: "info",
2462
+ 2: "warning",
2463
+ 3: "error"
2464
+ };
2465
+
2466
+ // ../core/src/engine/rank.ts
2467
+ var effectiveSeverity = (rule, config) => config.severity.get(rule.id) ?? rule.severity;
2468
+ function summarizeRule(rule, config) {
2469
+ return {
2470
+ id: rule.id,
2471
+ name: rule.name,
2472
+ category: rule.category,
2473
+ severity: effectiveSeverity(rule, config),
2474
+ slug: slug(rule.id),
2475
+ url: ruleUrl(rule.id),
2476
+ status: rule.status
2477
+ };
2478
+ }
2479
+ function rank(findings, rules, config) {
2480
+ const byId2 = new Map(rules.map((r) => [r.id, r]));
2481
+ const groups = /* @__PURE__ */ new Map();
2482
+ for (const f of findings) {
2483
+ let g = groups.get(f.ruleId);
2484
+ if (!g) {
2485
+ const rule = byId2.get(f.ruleId);
2486
+ if (!rule) throw new Error(`Finding for unknown rule ${f.ruleId}`);
2487
+ g = { rule: summarizeRule(rule, config), findings: [] };
2488
+ groups.set(f.ruleId, g);
2489
+ }
2490
+ g.findings.push(f);
2491
+ }
2492
+ return [...groups.values()].sort(
2493
+ (a, b) => b.rule.severity - a.rule.severity || CATEGORY_ORDER.indexOf(a.rule.category) - CATEGORY_ORDER.indexOf(b.rule.category) || b.findings.length - a.findings.length || a.rule.id.localeCompare(b.rule.id)
2494
+ );
2495
+ }
2496
+
2497
+ // ../core/src/engine/run.ts
2498
+ function runRules(model, indexes, rules, config) {
2499
+ const result = {
2500
+ findings: [],
2501
+ rulesRun: [],
2502
+ rulesSkipped: [],
2503
+ ruleErrors: [],
2504
+ ignored: 0
2505
+ };
2506
+ for (const rule of rules) {
2507
+ if (config.disabled.has(rule.id)) {
2508
+ result.rulesSkipped.push({ id: rule.id, reason: "disabled" });
2509
+ continue;
2510
+ }
2511
+ if (rule.status === "needsLiveModel") {
2512
+ result.rulesSkipped.push({ id: rule.id, reason: "needsLiveModel" });
2513
+ continue;
2514
+ }
2515
+ result.rulesRun.push(rule.id);
2516
+ let raw;
2517
+ try {
2518
+ raw = rule.check(model, { indexes });
2519
+ } catch (e) {
2520
+ result.ruleErrors.push({ id: rule.id, message: e instanceof Error ? e.message : String(e) });
2521
+ continue;
2522
+ }
2523
+ for (const f of raw) {
2524
+ if (isIgnored(f.object, rule.id)) {
2525
+ result.ignored++;
2526
+ continue;
2527
+ }
2528
+ const out = { ruleId: rule.id, objectType: f.objectType, objectName: f.objectName };
2529
+ if (f.location) out.location = f.location;
2530
+ if (f.detail !== void 0) out.detail = f.detail;
2531
+ result.findings.push(out);
2532
+ }
2533
+ }
2534
+ return result;
2535
+ }
2536
+
2537
+ // ../core/src/engine/lint.ts
2538
+ function lint(files, options = {}) {
2539
+ const rules = options.rules ?? defaultRules;
2540
+ const { config, unknownRules } = bindConfig(
2541
+ isResolvedConfig(options.config) ? options.config : resolveConfig(options.config),
2542
+ rules
2543
+ );
2544
+ const parsed = files.map((f) => parseTmdl(f.path, f.text));
2545
+ const model = buildModel(parsed);
2546
+ const indexes = buildIndexes(model);
2547
+ const run = runRules(model, indexes, rules, config);
2548
+ const groups = rank(run.findings, rules, config);
2549
+ const count = (severity) => groups.filter((g) => g.rule.severity === severity).reduce((n, g) => n + g.findings.length, 0);
2550
+ const summary = {
2551
+ files: files.length,
2552
+ findings: run.findings.length,
2553
+ errors: count(3),
2554
+ warnings: count(2),
2555
+ infos: count(1),
2556
+ rulesRun: run.rulesRun.length,
2557
+ rulesSkipped: run.rulesSkipped,
2558
+ ruleErrors: run.ruleErrors,
2559
+ ignored: run.ignored,
2560
+ unknownRules
2561
+ };
2562
+ const failed = config.failOn !== null && groups.some((g) => g.rule.severity >= config.failOn);
2563
+ return { model, findings: run.findings, groups, summary, failed };
2564
+ }
2565
+
2566
+ // ../core/src/format/json.ts
2567
+ function formatJson(result, options = {}) {
2568
+ const doc = {
2569
+ version: 1,
2570
+ tool: { name: "pbiplint", version: options.toolVersion ?? VERSION },
2571
+ summary: result.summary,
2572
+ groups: result.groups.map((g) => ({
2573
+ rule: g.rule,
2574
+ count: g.findings.length,
2575
+ findings: g.findings.map((f) => ({
2576
+ objectType: f.objectType,
2577
+ objectName: f.objectName,
2578
+ ...f.location ? { file: f.location.file, line: f.location.line } : {},
2579
+ ...f.detail !== void 0 ? { detail: f.detail } : {}
2580
+ }))
2581
+ }))
2582
+ };
2583
+ return JSON.stringify(doc, null, 2) + "\n";
2584
+ }
2585
+
2586
+ // ../core/src/format/text.ts
2587
+ var SEVERITY_TAG = { 3: "ERROR", 2: "WARN ", 1: "INFO " };
2588
+ var locationOf = (f) => f.location ? `${f.location.file}:${f.location.line}` : "";
2589
+ var plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
2590
+ function summaryLine(result) {
2591
+ const s = result.summary;
2592
+ return `${plural(s.findings, "finding")} (${plural(s.errors, "error")}, ${plural(s.warnings, "warning")}, ${s.infos} info) in ${plural(s.files, "file")}`;
2593
+ }
2594
+ function skippedLine(result) {
2595
+ const s = result.summary;
2596
+ const live = s.rulesSkipped.filter((r) => r.reason === "needsLiveModel").length;
2597
+ const disabled = s.rulesSkipped.filter((r) => r.reason === "disabled").length;
2598
+ const parts = [`${plural(s.rulesRun, "rule")} run`];
2599
+ if (live) parts.push(`${plural(live, "rule")} skipped (need a live model)`);
2600
+ if (disabled) parts.push(`${plural(disabled, "rule")} disabled by config`);
2601
+ if (s.ignored) parts.push(`${plural(s.ignored, "finding")} ignored by annotation`);
2602
+ return parts.join(", ");
2603
+ }
2604
+ var topGroups = (result, n = 5) => result.groups.slice(0, n);
2605
+ function formatText(result, _options = {}) {
2606
+ const out = [`pbiplint: ${summaryLine(result)}`, skippedLine(result), ""];
2607
+ if (result.groups.length === 0) {
2608
+ out.push("No findings.", "");
2609
+ } else {
2610
+ out.push("Fix these first:");
2611
+ topGroups(result).forEach(
2612
+ (g, i) => out.push(
2613
+ ` ${i + 1}. ${g.rule.name} (${plural(g.findings.length, SEVERITY_LABEL[g.rule.severity])})`
2614
+ )
2615
+ );
2616
+ out.push("");
2617
+ for (const g of result.groups) {
2618
+ out.push(
2619
+ `${SEVERITY_TAG[g.rule.severity]} ${g.rule.name} ${g.rule.id} (${g.findings.length})`
2620
+ );
2621
+ out.push(` ${g.rule.url}`);
2622
+ const width = Math.max(...g.findings.map((f) => f.objectName.length));
2623
+ const locWidth = Math.max(...g.findings.map((f) => locationOf(f).length));
2624
+ for (const f of g.findings) {
2625
+ const cols = [f.objectName.padEnd(width), locationOf(f).padEnd(locWidth), f.detail ?? ""];
2626
+ out.push(` ${cols.join(" ")}`.trimEnd());
2627
+ }
2628
+ out.push("");
2629
+ }
2630
+ }
2631
+ if (result.summary.ruleErrors.length) {
2632
+ out.push("Rule errors (please report these):");
2633
+ for (const e of result.summary.ruleErrors) out.push(` ${e.id}: ${e.message}`);
2634
+ out.push("");
2635
+ }
2636
+ return out.join("\n");
2637
+ }
2638
+
2639
+ // ../core/src/format/markdown.ts
2640
+ var cell = (s) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
2641
+ function formatMarkdown(result, _options = {}) {
2642
+ const out = [
2643
+ "# pbiplint report",
2644
+ "",
2645
+ `${summaryLine(result)}. ${skippedLine(result)}.`,
2646
+ ""
2647
+ ];
2648
+ if (result.groups.length === 0) {
2649
+ out.push("No findings.", "");
2650
+ return out.join("\n");
2651
+ }
2652
+ out.push("## Fix these first", "");
2653
+ topGroups(result).forEach(
2654
+ (g, i) => out.push(`${i + 1}. **${g.rule.name}** (${g.findings.length}) [${g.rule.id}](${g.rule.url})`)
2655
+ );
2656
+ out.push("");
2657
+ for (const g of result.groups) {
2658
+ out.push(
2659
+ `## ${SEVERITY_LABEL[g.rule.severity].toUpperCase()}: ${g.rule.name} (${g.findings.length})`,
2660
+ ""
2661
+ );
2662
+ out.push(`[${g.rule.id}](${g.rule.url}) \xB7 ${g.rule.category}`, "");
2663
+ out.push("| Object | Type | Location | Detail |", "|---|---|---|---|");
2664
+ for (const f of g.findings)
2665
+ out.push(
2666
+ `| \`${cell(f.objectName)}\` | ${f.objectType} | ${locationOf(f)} | ${cell(f.detail ?? "")} |`
2667
+ );
2668
+ out.push("");
2669
+ }
2670
+ return out.join("\n");
2671
+ }
2672
+
2673
+ // ../core/src/format/sarif.ts
2674
+ var LEVEL = {
2675
+ 3: "error",
2676
+ 2: "warning",
2677
+ 1: "note"
2678
+ };
2679
+ var plain = (markdown) => markdown.replace(/`/g, "");
2680
+ function formatSarif(result, options = {}) {
2681
+ const byId2 = new Map((options.rules ?? defaultRules).map((r) => [r.id, r]));
2682
+ const prefix = options.pathPrefix ?? "";
2683
+ const encodePath = (p) => p.split("/").map(encodeURIComponent).join("/");
2684
+ const uri = (file) => encodePath(prefix ? `${prefix}/${file}` : file);
2685
+ const helpFor = (id, description, url) => options.help?.[id] ?? {
2686
+ text: `${plain(description)}
2687
+
2688
+ Read more: ${url}`,
2689
+ markdown: `${description}
2690
+
2691
+ Read more: ${url}`
2692
+ };
2693
+ const rules = result.groups.map((g) => {
2694
+ const full = byId2.get(g.rule.id);
2695
+ return {
2696
+ id: g.rule.id,
2697
+ name: g.rule.name,
2698
+ shortDescription: { text: g.rule.name },
2699
+ fullDescription: full ? { text: plain(full.description), markdown: full.description } : { text: g.rule.name },
2700
+ help: helpFor(g.rule.id, full?.description ?? g.rule.name, g.rule.url),
2701
+ helpUri: g.rule.url,
2702
+ defaultConfiguration: { level: LEVEL[g.rule.severity] },
2703
+ properties: { category: g.rule.category }
2704
+ };
2705
+ });
2706
+ const results = result.groups.flatMap(
2707
+ (g, ruleIndex) => g.findings.map((f) => ({
2708
+ ruleId: g.rule.id,
2709
+ ruleIndex,
2710
+ level: LEVEL[g.rule.severity],
2711
+ message: { text: `${f.objectName}: ${g.rule.name}${f.detail ? ` (${f.detail})` : ""}` },
2712
+ ...f.location ? {
2713
+ locations: [
2714
+ {
2715
+ physicalLocation: {
2716
+ artifactLocation: { uri: uri(f.location.file) },
2717
+ region: { startLine: f.location.line }
2718
+ }
2719
+ }
2720
+ ]
2721
+ } : {}
2722
+ }))
2723
+ );
2724
+ const doc = {
2725
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
2726
+ version: "2.1.0",
2727
+ runs: [
2728
+ {
2729
+ tool: {
2730
+ driver: {
2731
+ name: "pbiplint",
2732
+ version: options.toolVersion ?? VERSION,
2733
+ informationUri: "https://pbiplint.com",
2734
+ rules
2735
+ }
2736
+ },
2737
+ results
2738
+ }
2739
+ ]
2740
+ };
2741
+ return JSON.stringify(doc, null, 2) + "\n";
2742
+ }
2743
+
2744
+ // ../core/src/format/index.ts
2745
+ var FORMATS = ["text", "json", "markdown", "sarif"];
2746
+ function formatResult(name, result, options = {}) {
2747
+ switch (name) {
2748
+ case "text":
2749
+ return formatText(result, options);
2750
+ case "json":
2751
+ return formatJson(result, options);
2752
+ case "markdown":
2753
+ return formatMarkdown(result, options);
2754
+ case "sarif":
2755
+ return formatSarif(result, options);
2756
+ default:
2757
+ throw new Error(`Unknown format: ${String(name)}`);
2758
+ }
2759
+ }
2760
+
2761
+ // src/args.ts
2762
+ var UsageError = class extends Error {
2763
+ };
2764
+ var FAIL_ON = ["error", "warning", "info", "none"];
2765
+ function parseArgs(argv) {
2766
+ const opts = { command: "lint", format: "text", sample: false };
2767
+ const positional = [];
2768
+ for (let i = 0; i < argv.length; i++) {
2769
+ let arg = argv[i];
2770
+ let inlineValue;
2771
+ const eq = arg.indexOf("=");
2772
+ if (arg.startsWith("--") && eq > 0) {
2773
+ inlineValue = arg.slice(eq + 1);
2774
+ arg = arg.slice(0, eq);
2775
+ }
2776
+ const value = () => {
2777
+ if (inlineValue !== void 0) return inlineValue;
2778
+ const v = argv[++i];
2779
+ if (v === void 0) throw new UsageError(`${arg} needs a value`);
2780
+ return v;
2781
+ };
2782
+ switch (arg) {
2783
+ case "--help":
2784
+ case "-h":
2785
+ return { ...opts, command: "help" };
2786
+ case "--version":
2787
+ case "-v":
2788
+ return { ...opts, command: "version" };
2789
+ case "--sample":
2790
+ opts.sample = true;
2791
+ break;
2792
+ case "--format": {
2793
+ const f = value();
2794
+ if (!FORMATS.includes(f))
2795
+ throw new UsageError(`--format must be one of ${FORMATS.join(", ")}`);
2796
+ opts.format = f;
2797
+ break;
2798
+ }
2799
+ case "--fail-on": {
2800
+ const f = value();
2801
+ if (!FAIL_ON.includes(f))
2802
+ throw new UsageError(`--fail-on must be one of ${FAIL_ON.join(", ")}`);
2803
+ opts.failOn = f;
2804
+ break;
2805
+ }
2806
+ case "--config":
2807
+ opts.config = value();
2808
+ break;
2809
+ case "--output":
2810
+ case "-o":
2811
+ opts.output = value();
2812
+ break;
2813
+ default:
2814
+ if (arg.startsWith("-")) throw new UsageError(`Unknown option ${arg}`);
2815
+ positional.push(arg);
2816
+ }
2817
+ }
2818
+ if (positional[0] === "rules") {
2819
+ if (positional.length > 1) throw new UsageError("rules takes no arguments");
2820
+ return { ...opts, command: "rules" };
2821
+ }
2822
+ if (positional.length > 1) throw new UsageError("Expected one path");
2823
+ if (positional.length === 1 && opts.sample)
2824
+ throw new UsageError("Give either a path or --sample, not both");
2825
+ if (positional.length === 0 && !opts.sample) return { ...opts, command: "help" };
2826
+ if (positional.length === 1) opts.path = positional[0];
2827
+ return opts;
2828
+ }
2829
+ var HELP = `Usage: pbiplint <path> [options]
2830
+ pbiplint --sample [options]
2831
+ pbiplint rules
2832
+
2833
+ Lint a Power BI semantic model (TMDL) for best-practice violations. Nothing is uploaded.
2834
+
2835
+ <path> a .SemanticModel folder, a PBIP folder, a definition folder, or one .tmdl file
2836
+ --sample lint the bundled sample project instead of a path
2837
+ --format <name> text (default), json, sarif, markdown
2838
+ --fail-on <level> error (default), warning, info, none: lowest severity that exits 1
2839
+ --config <file> pbiplint.config.json to use (default: nearest one above the model)
2840
+ --output <file> write the report to a file instead of stdout (a one-line summary goes to stderr)
2841
+ --help, --version
2842
+
2843
+ Exit codes: 0 no findings at or above --fail-on, 1 findings, 2 usage or input error.
2844
+ Rule pages: https://pbiplint.com/rules/
2845
+ `;
2846
+
2847
+ // src/config.ts
2848
+ import { existsSync, readFileSync } from "node:fs";
2849
+ import { dirname, join, resolve } from "node:path";
2850
+ var CONFIG_FILE = "pbiplint.config.json";
2851
+ function readConfig(path) {
2852
+ let parsed;
2853
+ try {
2854
+ parsed = JSON.parse(readFileSync(path, "utf8"));
2855
+ } catch (e) {
2856
+ throw new UsageError(`Could not read ${path}: ${e instanceof Error ? e.message : String(e)}`);
2857
+ }
2858
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
2859
+ throw new UsageError(`${path}: pbiplint.config.json must be a JSON object`);
2860
+ return parsed;
2861
+ }
2862
+ function findConfig(startDir, explicit) {
2863
+ if (explicit) {
2864
+ const path = resolve(explicit);
2865
+ if (!existsSync(path)) throw new UsageError(`${explicit} does not exist`);
2866
+ return { path, config: readConfig(path) };
2867
+ }
2868
+ let dir = resolve(startDir);
2869
+ for (; ; ) {
2870
+ const candidate = join(dir, CONFIG_FILE);
2871
+ if (existsSync(candidate)) return { path: candidate, config: readConfig(candidate) };
2872
+ const parent = dirname(dir);
2873
+ if (parent === dir) return { config: {} };
2874
+ dir = parent;
2875
+ }
2876
+ }
2877
+
2878
+ // src/sample.ts
2879
+ import { existsSync as existsSync2 } from "node:fs";
2880
+ import { dirname as dirname2, join as join2 } from "node:path";
2881
+ import { fileURLToPath } from "node:url";
2882
+ function sampleDir() {
2883
+ const here = dirname2(fileURLToPath(import.meta.url));
2884
+ for (const candidate of [
2885
+ join2(here, "..", "sample"),
2886
+ join2(here, "..", "..", "sample"),
2887
+ join2(here, "..", "..", "..", "examples", "messy-sales")
2888
+ ]) {
2889
+ if (existsSync2(join2(candidate, "definition"))) return candidate;
2890
+ }
2891
+ throw new Error("Bundled sample project not found");
2892
+ }
2893
+
2894
+ // src/rule-help.data.ts
2895
+ var RULE_HELP = {
2896
+ ADD_DATA_CATEGORY_FOR_COLUMNS: {
2897
+ text: "Why it matters\n\nMap visuals bind a field by its data category, not by its name. Without one, a City column is geocoded by guesswork and can land in the wrong country when names repeat, and Latitude and Longitude are treated as ordinary numbers, so they are summed by default and the map draws a single point in the ocean. The category lives on the model, so every report inherits it once it is set, and it costs nothing at refresh or query time.\n\nHow to fix it\n\nIn Power BI Desktop, select the column, open Column tools, and choose the Data category. In the TMDL file, add dataCategory: City, dataCategory: Country, dataCategory: Continent, dataCategory: Latitude, or dataCategory: Longitude under the column.\n\nQuirks\n\n- The name test is a substring, so a text column called City Code or Country Manager fires too. Latitude and Longitude must match the whole name.\n\nRead more: https://pbiplint.com/rules/add-data-category-for-columns",
2898
+ markdown: "### Why it matters\n\nMap visuals bind a field by its data category, not by its name. Without one, a City column is geocoded by guesswork and can land in the wrong country when names repeat, and Latitude and Longitude are treated as ordinary numbers, so they are summed by default and the map draws a single point in the ocean. The category lives on the model, so every report inherits it once it is set, and it costs nothing at refresh or query time.\n\n### How to fix it\n\nIn Power BI Desktop, select the column, open Column tools, and choose the Data category. In the TMDL file, add `dataCategory: City`, `dataCategory: Country`, `dataCategory: Continent`, `dataCategory: Latitude`, or `dataCategory: Longitude` under the column.\n\n### Quirks\n\n- The name test is a substring, so a text column called City Code or Country Manager fires too. Latitude and Longitude must match the whole name.\n\nRead more: https://pbiplint.com/rules/add-data-category-for-columns"
2899
+ },
2900
+ "AVOID_BI-DIRECTIONAL_RELATIONSHIPS_AGAINST_HIGH-CARDINALITY_COLUMNS": {
2901
+ text: `Why it matters
2902
+
2903
+ A bi-directional relationship makes the engine propagate filters both ways on every query that touches either table. On a key with a few hundred values that is cheap. On a key with hundreds of thousands of values, the expanded filter list is built and applied on every evaluation, and the cost shows up as slow visuals that look innocent in the model view.
2904
+
2905
+ How to fix it
2906
+
2907
+ Find the cardinality with DAX Studio's VertiPaq Analyzer, or with a query such as EVALUATE ROW("n", DISTINCTCOUNT('Sales'[Order ID])) in Power BI Desktop's DAX query view. Then set the relationship back to single direction and, where one report needs the reverse filter, get it from a measure with CROSSFILTER or TREATAS. If you already use Tabular Editor, the script in the links loads the same statistics into its Best Practice Analyzer, which can then run this rule directly.
2908
+
2909
+ pbiplint cannot evaluate this rule from files; it appears in pbiplint rules as needing a live model.
2910
+
2911
+ Read more: https://pbiplint.com/rules/avoid-bi-directional-relationships-against-high-cardinality-columns`,
2912
+ markdown: "### Why it matters\n\nA bi-directional relationship makes the engine propagate filters both ways on every query that touches either table. On a key with a few hundred values that is cheap. On a key with hundreds of thousands of values, the expanded filter list is built and applied on every evaluation, and the cost shows up as slow visuals that look innocent in the model view.\n\n### How to fix it\n\nFind the cardinality with DAX Studio's VertiPaq Analyzer, or with a query such as `EVALUATE ROW(\"n\", DISTINCTCOUNT('Sales'[Order ID]))` in Power BI Desktop's DAX query view. Then set the relationship back to single direction and, where one report needs the reverse filter, get it from a measure with CROSSFILTER or TREATAS. If you already use Tabular Editor, the script in the links loads the same statistics into its Best Practice Analyzer, which can then run this rule directly.\n\npbiplint cannot evaluate this rule from files; it appears in `pbiplint rules` as needing a live model.\n\nRead more: https://pbiplint.com/rules/avoid-bi-directional-relationships-against-high-cardinality-columns"
2913
+ },
2914
+ AVOID_DUPLICATE_MEASURES: {
2915
+ text: "Why it matters\n\nTwo names for one calculation split the reader's trust: nobody can tell which is the real one, reports end up using both, and the next change gets made to one copy only. When both appear in the same visual the engine also evaluates them separately, so the duplicate costs query time as well as confusion.\n\nHow to fix it\n\nKeep one measure and delete the other. If existing reports depend on both names, keep the second one for now as a plain reference to the first, Sales Amount = [Total Sales], and retire it once the reports are moved.\n\nQuirks\n\n- The comparison is exact apart from whitespace. Two measures that differ only in a comment or in letter case are not duplicates to this rule.\n\nRead more: https://pbiplint.com/rules/avoid-duplicate-measures",
2916
+ markdown: "### Why it matters\n\nTwo names for one calculation split the reader's trust: nobody can tell which is the real one, reports end up using both, and the next change gets made to one copy only. When both appear in the same visual the engine also evaluates them separately, so the duplicate costs query time as well as confusion.\n\n### How to fix it\n\nKeep one measure and delete the other. If existing reports depend on both names, keep the second one for now as a plain reference to the first, `Sales Amount = [Total Sales]`, and retire it once the reports are moved.\n\n### Quirks\n\n- The comparison is exact apart from whitespace. Two measures that differ only in a comment or in letter case are not duplicates to this rule.\n\nRead more: https://pbiplint.com/rules/avoid-duplicate-measures"
2917
+ },
2918
+ "AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS": {
2919
+ text: "Why it matters\n\nEach bi-directional or many-to-many relationship adds a filter path the engine has to consider on every query. A few in the right places are fine. When they are a third of the model, most queries pay for filter propagation they do not need, and the model starts to show ambiguity: two routes between the same tables, the engine picking one, and totals that stop adding up.\n\nHow to fix it\n\nIn the model view, set each bi-directional relationship back to single direction unless a report needs the reverse filter; where one does, get it from a measure with CROSSFILTER instead. Replace many-to-many relationships with a bridge table that relates many-to-one to both sides. In the relationships TMDL file the properties are crossFilteringBehavior: bothDirections and the two cardinality lines.\n\nQuirks\n\n- A relationship that is both bi-directional and many-to-many counts twice.\n\nRead more: https://pbiplint.com/rules/avoid-excessive-bi-directional-or-many-to-many-relationships",
2920
+ markdown: "### Why it matters\n\nEach bi-directional or many-to-many relationship adds a filter path the engine has to consider on every query. A few in the right places are fine. When they are a third of the model, most queries pay for filter propagation they do not need, and the model starts to show ambiguity: two routes between the same tables, the engine picking one, and totals that stop adding up.\n\n### How to fix it\n\nIn the model view, set each bi-directional relationship back to single direction unless a report needs the reverse filter; where one does, get it from a measure with CROSSFILTER instead. Replace many-to-many relationships with a bridge table that relates many-to-one to both sides. In the relationships TMDL file the properties are `crossFilteringBehavior: bothDirections` and the two cardinality lines.\n\n### Quirks\n\n- A relationship that is both bi-directional and many-to-many counts twice.\n\nRead more: https://pbiplint.com/rules/avoid-excessive-bi-directional-or-many-to-many-relationships"
2921
+ },
2922
+ AVOID_FLOATING_POINT_DATA_TYPES: {
2923
+ text: "Why it matters\n\nDouble is binary floating point, so values like 0.1 have no exact representation and sums drift in the last digits. Two totals that should match can differ by a fraction of a cent, and a money column stored as Double compresses worse than the same values as Fixed Decimal Number, so it costs memory as well. Fixed Decimal Number stores four decimal places exactly, and Whole Number compresses best of all.\n\nHow to fix it\n\nIn Power Query, change the column type to Fixed Decimal Number or Whole Number so the conversion happens before load. Changing it in the model view works too. In the TMDL file the property is dataType: decimal or dataType: int64. Keep Double only for values that need more than four decimal places, such as scientific measurements.\n\nRead more: https://pbiplint.com/rules/avoid-floating-point-data-types",
2924
+ markdown: "### Why it matters\n\nDouble is binary floating point, so values like 0.1 have no exact representation and sums drift in the last digits. Two totals that should match can differ by a fraction of a cent, and a money column stored as Double compresses worse than the same values as Fixed Decimal Number, so it costs memory as well. Fixed Decimal Number stores four decimal places exactly, and Whole Number compresses best of all.\n\n### How to fix it\n\nIn Power Query, change the column type to Fixed Decimal Number or Whole Number so the conversion happens before load. Changing it in the model view works too. In the TMDL file the property is `dataType: decimal` or `dataType: int64`. Keep Double only for values that need more than four decimal places, such as scientific measurements.\n\nRead more: https://pbiplint.com/rules/avoid-floating-point-data-types"
2925
+ },
2926
+ AVOID_INVALID_DESCRIPTION_CHARACTERS: {
2927
+ text: "Why it matters\n\nA control character in a description is invisible in Power BI Desktop and fails the deployment: the service rejects the metadata and the publish stops with an error that names no object. Finding the character by eye is close to impossible.\n\nHow to fix it\n\nRetype the description in Desktop, or remove the character from the /// comment lines above the object in the TMDL file. A text editor that shows invisible characters makes it easy to spot.\n\nQuirks\n\n- In practice this rule cannot fire on a project loaded from TMDL files, because the format does not carry these characters. It is kept so that models built by other means are covered.\n\nRead more: https://pbiplint.com/rules/avoid-invalid-description-characters",
2928
+ markdown: "### Why it matters\n\nA control character in a description is invisible in Power BI Desktop and fails the deployment: the service rejects the metadata and the publish stops with an error that names no object. Finding the character by eye is close to impossible.\n\n### How to fix it\n\nRetype the description in Desktop, or remove the character from the `///` comment lines above the object in the TMDL file. A text editor that shows invisible characters makes it easy to spot.\n\n### Quirks\n\n- In practice this rule cannot fire on a project loaded from TMDL files, because the format does not carry these characters. It is kept so that models built by other means are covered.\n\nRead more: https://pbiplint.com/rules/avoid-invalid-description-characters"
2929
+ },
2930
+ AVOID_INVALID_NAME_CHARACTERS: {
2931
+ text: "Why it matters\n\nA control character in a name is invisible in Power BI Desktop and fails the deployment: the service rejects the metadata and the publish stops with an error that names no object. Every DAX reference and report binding also has to reproduce the hidden character exactly, so the object is fragile even before it is deployed.\n\nHow to fix it\n\nRename the object in Desktop, or edit the name in the TMDL file. A text editor that shows invisible characters makes it easy to spot.\n\nQuirks\n\n- In practice this rule cannot fire on a project loaded from TMDL files, because the format does not carry these characters. It is kept so that models built by other means are covered.\n\nRead more: https://pbiplint.com/rules/avoid-invalid-name-characters",
2932
+ markdown: "### Why it matters\n\nA control character in a name is invisible in Power BI Desktop and fails the deployment: the service rejects the metadata and the publish stops with an error that names no object. Every DAX reference and report binding also has to reproduce the hidden character exactly, so the object is fragile even before it is deployed.\n\n### How to fix it\n\nRename the object in Desktop, or edit the name in the TMDL file. A text editor that shows invisible characters makes it easy to spot.\n\n### Quirks\n\n- In practice this rule cannot fire on a project loaded from TMDL files, because the format does not carry these characters. It is kept so that models built by other means are covered.\n\nRead more: https://pbiplint.com/rules/avoid-invalid-name-characters"
2933
+ },
2934
+ AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS: {
2935
+ text: 'Why it matters\n\nPower BI supports two combinations: a Power Query partition against any data source, or a legacy provider partition against a legacy provider data source. A provider partition against a structured data source is a mix the service refuses, so the model fails to deploy or refresh. Power BI Desktop never produces the combination; it appears in models migrated from Analysis Services or assembled by hand.\n\nHow to fix it\n\nRewrite the partition as Power Query, which is what Desktop would write: in the TMDL file, change partition Sales = query to partition Sales = m and replace the query text with an M expression such as let Source = Sql.Database("server", "db") in Source{[Schema="dbo",Item="Sales"]}[Data]. The other option is to change the data source itself to a provider data source so both halves are legacy; the elegantbi post in the links shows that conversion with Tabular Editor.\n\nRead more: https://pbiplint.com/rules/avoid-structured-data-sources-with-provider-partitions',
2936
+ markdown: '### Why it matters\n\nPower BI supports two combinations: a Power Query partition against any data source, or a legacy provider partition against a legacy provider data source. A provider partition against a structured data source is a mix the service refuses, so the model fails to deploy or refresh. Power BI Desktop never produces the combination; it appears in models migrated from Analysis Services or assembled by hand.\n\n### How to fix it\n\nRewrite the partition as Power Query, which is what Desktop would write: in the TMDL file, change `partition Sales = query` to `partition Sales = m` and replace the query text with an M expression such as `let Source = Sql.Database("server", "db") in Source{[Schema="dbo",Item="Sales"]}[Data]`. The other option is to change the data source itself to a provider data source so both halves are legacy; the elegantbi post in the links shows that conversion with Tabular Editor.\n\nRead more: https://pbiplint.com/rules/avoid-structured-data-sources-with-provider-partitions'
2937
+ },
2938
+ AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE: {
2939
+ text: "Why it matters\n\nWhen a role filters a table, the engine has to apply that filter through the active relationship, and USERELATIONSHIP asks it to swap in an inactive one. The two instructions conflict, so the measure fails for every user in the role while it works for the model owner, who tests without roles. That is a bug you find after publishing.\n\nHow to fix it\n\nKeep row-level security and USERELATIONSHIP on different tables. Either move the filter to another table in the same role, or replace USERELATIONSHIP with a second copy of the dimension that has an active relationship of its own, which is the usual answer for an order date and a ship date.\n\nQuirks\n\n- Only the second argument of USERELATIONSHIP is compared, and only measures are scanned. A calculation item that calls USERELATIONSHIP is not checked, and a measure that names the secured table as the first argument passes.\n\nRead more: https://pbiplint.com/rules/avoid-the-userelationship-function-and-rls-against-the-same-table",
2940
+ markdown: "### Why it matters\n\nWhen a role filters a table, the engine has to apply that filter through the active relationship, and USERELATIONSHIP asks it to swap in an inactive one. The two instructions conflict, so the measure fails for every user in the role while it works for the model owner, who tests without roles. That is a bug you find after publishing.\n\n### How to fix it\n\nKeep row-level security and USERELATIONSHIP on different tables. Either move the filter to another table in the same role, or replace USERELATIONSHIP with a second copy of the dimension that has an active relationship of its own, which is the usual answer for an order date and a ship date.\n\n### Quirks\n\n- Only the second argument of USERELATIONSHIP is compared, and only measures are scanned. A calculation item that calls USERELATIONSHIP is not checked, and a measure that names the secured table as the first argument passes.\n\nRead more: https://pbiplint.com/rules/avoid-the-userelationship-function-and-rls-against-the-same-table"
2941
+ },
2942
+ "AVOID_USING_'1-(X/Y)'_SYNTAX": {
2943
+ text: "Why it matters\n\nWritten that way the measure always returns a value. When there are no rows, the division is blank, one minus blank is one, and every empty cell in the matrix shows 100 percent. The visual fills with rows that should not be there, and the query does extra work to produce them. Written as a single DIVIDE over the difference, the measure is blank when the data is blank and the engine skips those rows.\n\nHow to fix it\n\nRewrite 1 - x / y as DIVIDE(y - x, y), and hold the shared denominator in a variable when it is used twice:\n\nMargin % =\nVAR Sales = SUM ( Sales[Amount] )\nRETURN DIVIDE ( Sales - SUM ( Sales[Cost] ), Sales )\n\nQuirks\n\n- The pattern needs SUM as the numerator, or DIVIDE right after the number. 1 - [Cost] / [Sales] and 1 - AVERAGE(...) / ... are not matched.\n- Table and column names must contain only letters, digits, spaces, and underscores for the SUM form to match.\n\nRead more: https://pbiplint.com/rules/avoid-using-1-x-y-syntax",
2944
+ markdown: "### Why it matters\n\nWritten that way the measure always returns a value. When there are no rows, the division is blank, one minus blank is one, and every empty cell in the matrix shows 100 percent. The visual fills with rows that should not be there, and the query does extra work to produce them. Written as a single DIVIDE over the difference, the measure is blank when the data is blank and the engine skips those rows.\n\n### How to fix it\n\nRewrite `1 - x / y` as `DIVIDE(y - x, y)`, and hold the shared denominator in a variable when it is used twice:\n\n```\nMargin % =\nVAR Sales = SUM ( Sales[Amount] )\nRETURN DIVIDE ( Sales - SUM ( Sales[Cost] ), Sales )\n```\n\n### Quirks\n\n- The pattern needs SUM as the numerator, or DIVIDE right after the number. `1 - [Cost] / [Sales]` and `1 - AVERAGE(...) / ...` are not matched.\n- Table and column names must contain only letters, digits, spaces, and underscores for the SUM form to match.\n\nRead more: https://pbiplint.com/rules/avoid-using-1-x-y-syntax"
2945
+ },
2946
+ "AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY": {
2947
+ text: "Why it matters\n\nA security filter is pushed through every relationship leading away from the secured table, on every query, for every user in the role. Through a many-to-many relationship that push is an expansion over the distinct values on both sides rather than a lookup, and it runs before the query proper. The slowdown grows with every such hop, and the model owner never sees it, because Desktop tests without roles.\n\nHow to fix it\n\nPut the security filter on a small security table that relates many-to-one to a single dimension, and let the dimension filter the facts through ordinary one-to-many relationships. The elegantbi post in the links walks through the patterns.\n\nQuirks\n\n- Any row-level security filter counts, not only dynamic filters that call USERNAME or USERPRINCIPALNAME.\n- Calculated tables are out of scope.\n\nRead more: https://pbiplint.com/rules/avoid-using-many-to-many-relationships-on-tables-used-for-dynamic-row-level-security",
2948
+ markdown: "### Why it matters\n\nA security filter is pushed through every relationship leading away from the secured table, on every query, for every user in the role. Through a many-to-many relationship that push is an expansion over the distinct values on both sides rather than a lookup, and it runs before the query proper. The slowdown grows with every such hop, and the model owner never sees it, because Desktop tests without roles.\n\n### How to fix it\n\nPut the security filter on a small security table that relates many-to-one to a single dimension, and let the dimension filter the facts through ordinary one-to-many relationships. The elegantbi post in the links walks through the patterns.\n\n### Quirks\n\n- Any row-level security filter counts, not only dynamic filters that call USERNAME or USERPRINCIPALNAME.\n- Calculated tables are out of scope.\n\nRead more: https://pbiplint.com/rules/avoid-using-many-to-many-relationships-on-tables-used-for-dynamic-row-level-security"
2949
+ },
2950
+ AVOID_USING_THE_IFERROR_FUNCTION: {
2951
+ text: "Why it matters\n\nIFERROR makes the engine evaluate the expression row by row so it can catch a failure, which switches off the bulk evaluation that makes DAX fast. Most uses guard a division, and DIVIDE handles that case without the penalty. The rest usually hide a data problem, such as text in a numeric column, that is better fixed in Power Query where the failure cannot happen.\n\nHow to fix it\n\nReplace IFERROR([A] / [B], 0) with DIVIDE([A], [B], 0). For type conversions, clean the column in Power Query so the conversion cannot fail. If a guard is unavoidable, test the condition with IF instead of catching the error.\n\nRead more: https://pbiplint.com/rules/avoid-using-the-iferror-function",
2952
+ markdown: "### Why it matters\n\nIFERROR makes the engine evaluate the expression row by row so it can catch a failure, which switches off the bulk evaluation that makes DAX fast. Most uses guard a division, and DIVIDE handles that case without the penalty. The rest usually hide a data problem, such as text in a numeric column, that is better fixed in Power Query where the failure cannot happen.\n\n### How to fix it\n\nReplace `IFERROR([A] / [B], 0)` with `DIVIDE([A], [B], 0)`. For type conversions, clean the column in Power Query so the conversion cannot fail. If a guard is unavoidable, test the condition with IF instead of catching the error.\n\nRead more: https://pbiplint.com/rules/avoid-using-the-iferror-function"
2953
+ },
2954
+ CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS: {
2955
+ text: "Why it matters\n\nA calculation group with no items still appears in the field list as a table with one column, and dropping that column on a visual does nothing. It is usually a group that was started and abandoned, and it puzzles whoever finds it later.\n\nHow to fix it\n\nAdd the calculation items, or delete the table. In the TMDL file a calculation group is a table with a calculationGroup block, and each item is a calculationItem inside it.\n\nRead more: https://pbiplint.com/rules/calculation-groups-with-no-calculation-items",
2956
+ markdown: "### Why it matters\n\nA calculation group with no items still appears in the field list as a table with one column, and dropping that column on a visual does nothing. It is usually a group that was started and abandoned, and it puzzles whoever finds it later.\n\n### How to fix it\n\nAdd the calculation items, or delete the table. In the TMDL file a calculation group is a table with a `calculationGroup` block, and each item is a `calculationItem` inside it.\n\nRead more: https://pbiplint.com/rules/calculation-groups-with-no-calculation-items"
2957
+ },
2958
+ "CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID": {
2959
+ text: "Why it matters\n\nBoth kinds have real uses, and both are easy to create by accident: Desktop offers bi-directional filtering as a dropdown, and it falls back to many-to-many when it finds duplicates on both sides of a key. An accidental one costs query time on every visual that touches the tables and can open a second filter path, which is where totals stop adding up without any error.\n\nHow to fix it\n\nConfirm each one is deliberate. If a bi-directional relationship exists only so one slicer narrows another, replace it with a measure-based visual filter or CROSSFILTER in the measures that need it. If a many-to-many relationship was created because a key column has duplicates, fix the duplicates in the source and go back to many-to-one.\n\nRead more: https://pbiplint.com/rules/check-if-bi-directional-and-many-to-many-relationships-are-valid",
2960
+ markdown: "### Why it matters\n\nBoth kinds have real uses, and both are easy to create by accident: Desktop offers bi-directional filtering as a dropdown, and it falls back to many-to-many when it finds duplicates on both sides of a key. An accidental one costs query time on every visual that touches the tables and can open a second filter path, which is where totals stop adding up without any error.\n\n### How to fix it\n\nConfirm each one is deliberate. If a bi-directional relationship exists only so one slicer narrows another, replace it with a measure-based visual filter or CROSSFILTER in the measures that need it. If a many-to-many relationship was created because a key column has duplicates, fix the duplicates in the source and go back to many-to-one.\n\nRead more: https://pbiplint.com/rules/check-if-bi-directional-and-many-to-many-relationships-are-valid"
2961
+ },
2962
+ "CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_(RLS)_IS_NECESSARY": {
2963
+ text: 'Why it matters\n\nA dynamic filter is evaluated for each user separately, so the engine cannot share a cached result between two people with the same access, and every query carries the lookup that maps the user to their rows. That is the right trade when the audience is large or changes often. When a handful of fixed groups each see a fixed slice, static roles with a plain filter are faster and simpler to audit.\n\nHow to fix it\n\nKeep the dynamic filter when the user-to-data mapping lives in a table and changes without a redeploy. Otherwise create one role per audience with a static filter such as [Region] = "East" and assign members in the service.\n\nQuirks\n\n- A space before the parenthesis, as some DAX formatters write, is not matched: USERPRINCIPALNAME () passes.\n\nRead more: https://pbiplint.com/rules/check-if-dynamic-row-level-security-rls-is-necessary',
2964
+ markdown: '### Why it matters\n\nA dynamic filter is evaluated for each user separately, so the engine cannot share a cached result between two people with the same access, and every query carries the lookup that maps the user to their rows. That is the right trade when the audience is large or changes often. When a handful of fixed groups each see a fixed slice, static roles with a plain filter are faster and simpler to audit.\n\n### How to fix it\n\nKeep the dynamic filter when the user-to-data mapping lives in a table and changes without a redeploy. Otherwise create one role per audience with a static filter such as `[Region] = "East"` and assign members in the service.\n\n### Quirks\n\n- A space before the parenthesis, as some DAX formatters write, is not matched: `USERPRINCIPALNAME ()` passes.\n\nRead more: https://pbiplint.com/rules/check-if-dynamic-row-level-security-rls-is-necessary'
2965
+ },
2966
+ DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN: {
2967
+ text: "Why it matters\n\nA data column is filled from a column in the partition query, and the source column name is how the engine finds it. Without it, processing fails for the whole table, with an error that names the column but not the cause.\n\nHow to fix it\n\nSet sourceColumn on the column in the TMDL file to the name the query produces, or delete the column. Power BI Desktop always writes the property, so this appears only in hand-built or migrated files.\n\nRead more: https://pbiplint.com/rules/data-columns-must-have-a-source-column",
2968
+ markdown: "### Why it matters\n\nA data column is filled from a column in the partition query, and the source column name is how the engine finds it. Without it, processing fails for the whole table, with an error that names the column but not the cause.\n\n### How to fix it\n\nSet `sourceColumn` on the column in the TMDL file to the name the query produces, or delete the column. Power BI Desktop always writes the property, so this appears only in hand-built or migrated files.\n\nRead more: https://pbiplint.com/rules/data-columns-must-have-a-source-column"
2969
+ },
2970
+ "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": {
2971
+ text: "Why it matters\n\nMarking the date table tells the engine which column is the calendar key, and every time intelligence function relies on it: DATESYTD, SAMEPERIODLASTYEAR, and the rest return wrong or blank results over an unmarked table without raising any error. Marking it also lets you turn off Auto date/time, which otherwise adds a hidden date table for every date column in the model.\n\nHow to fix it\n\nIn Power BI Desktop, select the table, open Table tools, choose Mark as date table, and pick the date column. In the TMDL file the result is dataCategory: Time on the table and isKey on the date column.\n\nQuirks\n\n- The name test is a substring, so a table called Updates or Candidates fires too.\n\nRead more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table",
2972
+ markdown: "### Why it matters\n\nMarking the date table tells the engine which column is the calendar key, and every time intelligence function relies on it: DATESYTD, SAMEPERIODLASTYEAR, and the rest return wrong or blank results over an unmarked table without raising any error. Marking it also lets you turn off Auto date/time, which otherwise adds a hidden date table for every date column in the model.\n\n### How to fix it\n\nIn Power BI Desktop, select the table, open Table tools, choose Mark as date table, and pick the date column. In the TMDL file the result is `dataCategory: Time` on the table and `isKey` on the date column.\n\n### Quirks\n\n- The name test is a substring, so a table called Updates or Candidates fires too.\n\nRead more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table"
2973
+ },
2974
+ DATECOLUMN_FORMATSTRING: {
2975
+ text: "Why it matters\n\nA date column with no format string is shown however the viewer's locale and the visual decide, so the same column can read 3/4/2026 in one visual and 4 March 2026 in another. A format string on the column fixes the presentation once for every report. The source ruleset picked the US short date as its convention.\n\nHow to fix it\n\nIn Power BI Desktop, select the column and set the Format under Column tools. In the TMDL file, add formatString: mm/dd/yyyy under the column. The expected format is US-centric; if your convention differs, set the format you want and turn this rule off in pbiplint.config.json, because only the exact string passes.\n\nQuirks\n\n- The name test is a substring, so any column containing the letters date, such as Update Time, is matched.\n- Any other format fires, including dd/mm/yyyy and yyyy-mm-dd.\n\nRead more: https://pbiplint.com/rules/datecolumn-formatstring",
2976
+ markdown: "### Why it matters\n\nA date column with no format string is shown however the viewer's locale and the visual decide, so the same column can read 3/4/2026 in one visual and 4 March 2026 in another. A format string on the column fixes the presentation once for every report. The source ruleset picked the US short date as its convention.\n\n### How to fix it\n\nIn Power BI Desktop, select the column and set the Format under Column tools. In the TMDL file, add `formatString: mm/dd/yyyy` under the column. The expected format is US-centric; if your convention differs, set the format you want and turn this rule off in `pbiplint.config.json`, because only the exact string passes.\n\n### Quirks\n\n- The name test is a substring, so any column containing the letters date, such as Update Time, is matched.\n- Any other format fires, including `dd/mm/yyyy` and `yyyy-mm-dd`.\n\nRead more: https://pbiplint.com/rules/datecolumn-formatstring"
2977
+ },
2978
+ DAX_COLUMNS_FULLY_QUALIFIED: {
2979
+ text: "Why it matters\n\nIn DAX a bare [Name] is the convention for a measure. A column written the same way reads as a measure to everyone who maintains the model, and the two behave differently in a row context, so the expression is misread before it is ever debugged. The bare form also breaks when the column moves to another table, or when a measure with the same name is added and the engine binds to that instead.\n\nHow to fix it\n\nWrite 'Table'[Column] for every column reference. The formula bar in Power BI Desktop completes the qualified form when you start typing the table name.\n\nQuirks\n\n- Calculation items are in the rule's scope but never fire, because Tabular Editor does not resolve bare column references inside calculation items and pbiplint matches that.\n- A bare name that matches any measure in the model is treated as a measure reference, so a column that shares its name with a measure is never flagged.\n- References are found by pattern matching, so a [Column] inside a string or a comment counts.\n\nRead more: https://pbiplint.com/rules/dax-columns-fully-qualified",
2980
+ markdown: "### Why it matters\n\nIn DAX a bare `[Name]` is the convention for a measure. A column written the same way reads as a measure to everyone who maintains the model, and the two behave differently in a row context, so the expression is misread before it is ever debugged. The bare form also breaks when the column moves to another table, or when a measure with the same name is added and the engine binds to that instead.\n\n### How to fix it\n\nWrite `'Table'[Column]` for every column reference. The formula bar in Power BI Desktop completes the qualified form when you start typing the table name.\n\n### Quirks\n\n- Calculation items are in the rule's scope but never fire, because Tabular Editor does not resolve bare column references inside calculation items and pbiplint matches that.\n- A bare name that matches any measure in the model is treated as a measure reference, so a column that shares its name with a measure is never flagged.\n- References are found by pattern matching, so a `[Column]` inside a string or a comment counts.\n\nRead more: https://pbiplint.com/rules/dax-columns-fully-qualified"
2981
+ },
2982
+ DAX_MEASURES_UNQUALIFIED: {
2983
+ text: "Why it matters\n\nA measure belongs to the model, not to the table it sits in; the table is only its home in the field list. Writing 'Sales'[Total Sales] makes it look like a column, which changes what the next reader expects it to do, and it breaks the moment someone moves the measure to a measure table, which is a routine tidy-up.\n\nHow to fix it\n\nWrite [Measure] with no table name.\n\nQuirks\n\n- Row-level security filters are not checked.\n- References are found by pattern matching, so a qualified measure reference inside a string or a comment counts.\n\nRead more: https://pbiplint.com/rules/dax-measures-unqualified",
2984
+ markdown: "### Why it matters\n\nA measure belongs to the model, not to the table it sits in; the table is only its home in the field list. Writing `'Sales'[Total Sales]` makes it look like a column, which changes what the next reader expects it to do, and it breaks the moment someone moves the measure to a measure table, which is a routine tidy-up.\n\n### How to fix it\n\nWrite `[Measure]` with no table name.\n\n### Quirks\n\n- Row-level security filters are not checked.\n- References are found by pattern matching, so a qualified measure reference inside a string or a comment counts.\n\nRead more: https://pbiplint.com/rules/dax-measures-unqualified"
2985
+ },
2986
+ ENSURE_TABLES_HAVE_RELATIONSHIPS: {
2987
+ text: "Why it matters\n\nA table that relates to nothing filters nothing and is filtered by nothing, so a visual that mixes its columns with another table's shows the same value repeated on every row. Sometimes that is the point: a parameter table, a measure table, or a security lookup that is read from DAX. More often it is a table that was loaded and never wired up, or a relationship that was deleted by accident.\n\nHow to fix it\n\nAdd the relationship in the model view, or confirm the table is disconnected on purpose and leave it. Hiding the table does not clear the finding, and a measure table with a single hidden column is reported like any other.\n\nRead more: https://pbiplint.com/rules/ensure-tables-have-relationships",
2988
+ markdown: "### Why it matters\n\nA table that relates to nothing filters nothing and is filtered by nothing, so a visual that mixes its columns with another table's shows the same value repeated on every row. Sometimes that is the point: a parameter table, a measure table, or a security lookup that is read from DAX. More often it is a table that was loaded and never wired up, or a relationship that was deleted by accident.\n\n### How to fix it\n\nAdd the relationship in the model view, or confirm the table is disconnected on purpose and leave it. Hiding the table does not clear the finding, and a measure table with a single hidden column is reported like any other.\n\nRead more: https://pbiplint.com/rules/ensure-tables-have-relationships"
2989
+ },
2990
+ EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS: {
2991
+ text: "Why it matters\n\nEVALUATEANDLOG is a debugging aid: in Power BI Desktop it emits a trace event with its argument on every evaluation so you can watch intermediate values. The service ignores it. Leaving it in a published model ships debug scaffolding that every reader has to look past, and anyone who opens the file in Desktop gets trace output they did not ask for.\n\nHow to fix it\n\nRemove the EVALUATEANDLOG wrapper and keep the expression inside it.\n\nRead more: https://pbiplint.com/rules/evaluateandlog-should-not-be-used-in-production-models",
2992
+ markdown: "### Why it matters\n\nEVALUATEANDLOG is a debugging aid: in Power BI Desktop it emits a trace event with its argument on every evaluation so you can watch intermediate values. The service ignores it. Leaving it in a published model ships debug scaffolding that every reader has to look past, and anyone who opens the file in Desktop gets trace output they did not ask for.\n\n### How to fix it\n\nRemove the EVALUATEANDLOG wrapper and keep the expression inside it.\n\nRead more: https://pbiplint.com/rules/evaluateandlog-should-not-be-used-in-production-models"
2993
+ },
2994
+ EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION: {
2995
+ text: "Why it matters\n\nWithout an expression the object cannot be evaluated: a measure returns nothing, a calculated column is empty, and a calculation item does nothing. Depending on the engine version the deployment fails outright, and the error points at the object without saying why.\n\nHow to fix it\n\nAdd the DAX, or delete the object.\n\nQuirks\n\n- Cannot fire on a TMDL file: the TMDL reader takes the next indented line as the expression, so an empty expression never survives loading.\n\nRead more: https://pbiplint.com/rules/expression-reliant-objects-must-have-an-expression",
2996
+ markdown: "### Why it matters\n\nWithout an expression the object cannot be evaluated: a measure returns nothing, a calculated column is empty, and a calculation item does nothing. Depending on the engine version the deployment fails outright, and the error points at the object without saying why.\n\n### How to fix it\n\nAdd the DAX, or delete the object.\n\n### Quirks\n\n- Cannot fire on a TMDL file: the TMDL reader takes the next indented line as the expression, so an empty expression never survives loading.\n\nRead more: https://pbiplint.com/rules/expression-reliant-objects-must-have-an-expression"
2997
+ },
2998
+ FILTER_COLUMN_VALUES: {
2999
+ text: `Why it matters
3000
+
3001
+ FILTER over a whole table walks every row, keeps the ones that pass, and hands that row set to CALCULATE. A plain column predicate, 'Table'[Column] = "Value", is applied to the column's distinct values before any row is touched, which is far cheaper on a fact table and lets the storage engine do the work. The two forms also differ in meaning: FILTER over the table respects a filter already on the column, which the plain predicate replaces, so KEEPFILTERS is the exact equivalent.
3002
+
3003
+ How to fix it
3004
+
3005
+ Replace FILTER('Table', 'Table'[Column] = "Value") with KEEPFILTERS('Table'[Column] = "Value") to keep whatever filter is already on the column, or with 'Table'[Column] = "Value" to replace it. The SQLBI article in the links covers which one you want.
3006
+
3007
+ Quirks
3008
+
3009
+ - The pattern accepts a space as the table name, so FILTER('Table', [Measure] > 1) is also flagged by this rule and by FILTER_MEASURE_VALUES_BY_COLUMNS.
3010
+ - Only the first filter argument is checked, and only when the expression before it contains no comma. CALCULATE(DIVIDE([A], [B]), FILTER(...)) passes.
3011
+ - Table and column names must contain only letters, digits, spaces, and underscores.
3012
+
3013
+ Read more: https://pbiplint.com/rules/filter-column-values`,
3014
+ markdown: "### Why it matters\n\nFILTER over a whole table walks every row, keeps the ones that pass, and hands that row set to CALCULATE. A plain column predicate, `'Table'[Column] = \"Value\"`, is applied to the column's distinct values before any row is touched, which is far cheaper on a fact table and lets the storage engine do the work. The two forms also differ in meaning: FILTER over the table respects a filter already on the column, which the plain predicate replaces, so KEEPFILTERS is the exact equivalent.\n\n### How to fix it\n\nReplace `FILTER('Table', 'Table'[Column] = \"Value\")` with `KEEPFILTERS('Table'[Column] = \"Value\")` to keep whatever filter is already on the column, or with `'Table'[Column] = \"Value\"` to replace it. The SQLBI article in the links covers which one you want.\n\n### Quirks\n\n- The pattern accepts a space as the table name, so `FILTER('Table', [Measure] > 1)` is also flagged by this rule and by `FILTER_MEASURE_VALUES_BY_COLUMNS`.\n- Only the first filter argument is checked, and only when the expression before it contains no comma. `CALCULATE(DIVIDE([A], [B]), FILTER(...))` passes.\n- Table and column names must contain only letters, digits, spaces, and underscores.\n\nRead more: https://pbiplint.com/rules/filter-column-values"
3015
+ },
3016
+ FILTER_MEASURE_VALUES_BY_COLUMNS: {
3017
+ text: "Why it matters\n\nFILTER over a whole table evaluates the measure once per row of the table. Over a fact table that is millions of measure evaluations to keep a few rows. Filtering the distinct values of one column instead evaluates the measure once per value, usually thousands of times fewer, and produces the same rows.\n\nHow to fix it\n\nReplace FILTER('Table', [Measure] > 0) with FILTER(VALUES('Table'[Column]), [Measure] > 0) to respect the current filter on the column, or FILTER(ALL('Table'[Column]), [Measure] > 0) to ignore it. Pick the column with the fewest distinct values that still gives the right answer.\n\nQuirks\n\n- Only the first filter argument is checked, and only when the expression before it contains no comma.\n- Table names must contain only letters, digits, spaces, and underscores.\n\nRead more: https://pbiplint.com/rules/filter-measure-values-by-columns",
3018
+ markdown: "### Why it matters\n\nFILTER over a whole table evaluates the measure once per row of the table. Over a fact table that is millions of measure evaluations to keep a few rows. Filtering the distinct values of one column instead evaluates the measure once per value, usually thousands of times fewer, and produces the same rows.\n\n### How to fix it\n\nReplace `FILTER('Table', [Measure] > 0)` with `FILTER(VALUES('Table'[Column]), [Measure] > 0)` to respect the current filter on the column, or `FILTER(ALL('Table'[Column]), [Measure] > 0)` to ignore it. Pick the column with the fewest distinct values that still gives the right answer.\n\n### Quirks\n\n- Only the first filter argument is checked, and only when the expression before it contains no comma.\n- Table names must contain only letters, digits, spaces, and underscores.\n\nRead more: https://pbiplint.com/rules/filter-measure-values-by-columns"
3019
+ },
3020
+ FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED: {
3021
+ text: `Why it matters
3022
+
3023
+ Object names are the model's user interface: they appear in the field list, on axis labels, in tooltips, and in every export, and nothing capitalizes them for you. A field list that mixes "Sales Amount" with "total cost" reads as unfinished, and it tells a report author that the two fields came from different places and may not be equally trustworthy. Capitalizing the first letter costs nothing, survives every refresh, and is the convention nearly every published model follows. A name starting with a digit or a symbol is not flagged, because those characters have no upper case form.
3024
+
3025
+ How to fix it
3026
+
3027
+ Rename the object in Power BI Desktop, which updates the visuals and the DAX that reference it, or edit the name in the TMDL file.
3028
+
3029
+ Quirks
3030
+
3031
+ - Data columns are not in scope; only calculated and calculated-table columns are checked.
3032
+
3033
+ Read more: https://pbiplint.com/rules/first-letter-of-objects-must-be-capitalized`,
3034
+ markdown: `### Why it matters
3035
+
3036
+ Object names are the model's user interface: they appear in the field list, on axis labels, in tooltips, and in every export, and nothing capitalizes them for you. A field list that mixes "Sales Amount" with "total cost" reads as unfinished, and it tells a report author that the two fields came from different places and may not be equally trustworthy. Capitalizing the first letter costs nothing, survives every refresh, and is the convention nearly every published model follows. A name starting with a digit or a symbol is not flagged, because those characters have no upper case form.
3037
+
3038
+ ### How to fix it
3039
+
3040
+ Rename the object in Power BI Desktop, which updates the visuals and the DAX that reference it, or edit the name in the TMDL file.
3041
+
3042
+ ### Quirks
3043
+
3044
+ - Data columns are not in scope; only calculated and calculated-table columns are checked.
3045
+
3046
+ Read more: https://pbiplint.com/rules/first-letter-of-objects-must-be-capitalized`
3047
+ },
3048
+ FIX_REFERENTIAL_INTEGRITY_VIOLATIONS: {
3049
+ text: "Why it matters\n\nEvery orphan key is grouped under a single blank row of the dimension, so slicers grow a blank entry, totals include amounts that no category explains, and a filter on any dimension attribute silently drops those rows. The blank row is also added to the dimension in memory on every refresh.\n\nHow to fix it\n\nFind the orphans with a query in Power BI Desktop's DAX query view, such as EVALUATE EXCEPT(VALUES(Sales[Product Key]), VALUES(Product[Product Key])), or read the violation count per relationship in DAX Studio's VertiPaq Analyzer. Then fix the source: add the missing dimension rows, or add an Unknown row and map the orphans to it.\n\npbiplint cannot evaluate this rule from files; it appears in pbiplint rules as needing a live model.\n\nRead more: https://pbiplint.com/rules/fix-referential-integrity-violations",
3050
+ markdown: "### Why it matters\n\nEvery orphan key is grouped under a single blank row of the dimension, so slicers grow a blank entry, totals include amounts that no category explains, and a filter on any dimension attribute silently drops those rows. The blank row is also added to the dimension in memory on every refresh.\n\n### How to fix it\n\nFind the orphans with a query in Power BI Desktop's DAX query view, such as `EVALUATE EXCEPT(VALUES(Sales[Product Key]), VALUES(Product[Product Key]))`, or read the violation count per relationship in DAX Studio's VertiPaq Analyzer. Then fix the source: add the missing dimension rows, or add an Unknown row and map the orphans to it.\n\npbiplint cannot evaluate this rule from files; it appears in `pbiplint rules` as needing a live model.\n\nRead more: https://pbiplint.com/rules/fix-referential-integrity-violations"
3051
+ },
3052
+ "FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS": {
3053
+ text: "Why it matters\n\nA 0 or 1 in a slicer, a legend, or a table column tells the reader nothing without a lookup, and a whole-number flag is summed by default, so a card labeled Is Active shows a count of true rows that looks like something else. Yes and No read correctly everywhere and cannot be aggregated by accident.\n\nHow to fix it\n\nIn Power Query, add a conditional column or use Replace Values so the column holds Yes and No, and set its type to Text. Keep the numeric version hidden if a measure needs it for counting.\n\nQuirks\n\n- The name tests are case-sensitive prefix and suffix checks, so a whole-number column called Island or Issue Count fires, and the Flag suffix needs a space before it.\n\nRead more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings",
3054
+ markdown: "### Why it matters\n\nA 0 or 1 in a slicer, a legend, or a table column tells the reader nothing without a lookup, and a whole-number flag is summed by default, so a card labeled Is Active shows a count of true rows that looks like something else. Yes and No read correctly everywhere and cannot be aggregated by accident.\n\n### How to fix it\n\nIn Power Query, add a conditional column or use Replace Values so the column holds Yes and No, and set its type to Text. Keep the numeric version hidden if a measure needs it for counting.\n\n### Quirks\n\n- The name tests are case-sensitive prefix and suffix checks, so a whole-number column called Island or Issue Count fires, and the Flag suffix needs a space before it.\n\nRead more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings"
3055
+ },
3056
+ HIDE_FACT_TABLE_COLUMNS: {
3057
+ text: "Why it matters\n\nOnce a measure exists for a column, the column itself is the wrong thing to drag onto a visual: it produces an implicit sum that may not match the measure, ignores whatever logic the measure adds, and sits in the field list right next to the measure under a similar name. Hiding the column leaves one correct choice.\n\nHow to fix it\n\nHide the column in the model view, or add isHidden under the column in the TMDL file.\n\nQuirks\n\n- Only fully qualified references count. SUM([Amount]) inside a measure on the same table does not fire.\n- A visible column in a hidden table is still reported.\n\nRead more: https://pbiplint.com/rules/hide-fact-table-columns",
3058
+ markdown: "### Why it matters\n\nOnce a measure exists for a column, the column itself is the wrong thing to drag onto a visual: it produces an implicit sum that may not match the measure, ignores whatever logic the measure adds, and sits in the field list right next to the measure under a similar name. Hiding the column leaves one correct choice.\n\n### How to fix it\n\nHide the column in the model view, or add `isHidden` under the column in the TMDL file.\n\n### Quirks\n\n- Only fully qualified references count. `SUM([Amount])` inside a measure on the same table does not fire.\n- A visible column in a hidden table is still reported.\n\nRead more: https://pbiplint.com/rules/hide-fact-table-columns"
3059
+ },
3060
+ HIDE_FOREIGN_KEYS: {
3061
+ text: "Why it matters\n\nA key column on the many side of a relationship carries no meaning for a report author: the values are surrogate integers, and grouping by the fact table's key gives one row per key value labeled with a number nobody recognizes. Leaving it visible also puts two versions of the same field in the field list, one on the fact table and one on the dimension, and only the dimension's version filters the way people expect. Hiding the key removes the wrong choice from the field list without changing anything about how the model behaves.\n\nHow to fix it\n\nHide the column in the model view, or add isHidden under the column in the TMDL file.\n\nQuirks\n\n- The source rule compares from-column names only, not table plus column, so a dimension's key that shares a name with the fact table's foreign key is flagged too. pbiplint keeps this to match Tabular Editor.\n\nRead more: https://pbiplint.com/rules/hide-foreign-keys",
3062
+ markdown: "### Why it matters\n\nA key column on the many side of a relationship carries no meaning for a report author: the values are surrogate integers, and grouping by the fact table's key gives one row per key value labeled with a number nobody recognizes. Leaving it visible also puts two versions of the same field in the field list, one on the fact table and one on the dimension, and only the dimension's version filters the way people expect. Hiding the key removes the wrong choice from the field list without changing anything about how the model behaves.\n\n### How to fix it\n\nHide the column in the model view, or add `isHidden` under the column in the TMDL file.\n\n### Quirks\n\n- The source rule compares from-column names only, not table plus column, so a dimension's key that shares a name with the fact table's foreign key is flagged too. pbiplint keeps this to match Tabular Editor.\n\nRead more: https://pbiplint.com/rules/hide-foreign-keys"
3063
+ },
3064
+ INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED: {
3065
+ text: "Why it matters\n\nAn inactive relationship does nothing on its own. It exists so a measure can switch it on with USERELATIONSHIP, typically for a second date on a fact table. If no measure does, the relationship is either a leftover from a design that changed or a plan that was never finished, and a report author who sees the dotted line in the model view will assume the filter works.\n\nHow to fix it\n\nWrite the measure that uses it, CALCULATE([Total Sales], USERELATIONSHIP(Sales[Ship Date], 'Date'[Date])), or delete the relationship in the model view.\n\nQuirks\n\n- Only USERELATIONSHIP(from column, to column) counts as activation; the reversed argument order does not.\n- pbiplint escapes table and column names before building the pattern, which the source rule does not, so names with parentheses cannot break the check.\n\nRead more: https://pbiplint.com/rules/inactive-relationships-that-are-never-activated",
3066
+ markdown: "### Why it matters\n\nAn inactive relationship does nothing on its own. It exists so a measure can switch it on with USERELATIONSHIP, typically for a second date on a fact table. If no measure does, the relationship is either a leftover from a design that changed or a plan that was never finished, and a report author who sees the dotted line in the model view will assume the filter works.\n\n### How to fix it\n\nWrite the measure that uses it, `CALCULATE([Total Sales], USERELATIONSHIP(Sales[Ship Date], 'Date'[Date]))`, or delete the relationship in the model view.\n\n### Quirks\n\n- Only `USERELATIONSHIP(from column, to column)` counts as activation; the reversed argument order does not.\n- pbiplint escapes table and column names before building the pattern, which the source rule does not, so names with parentheses cannot break the check.\n\nRead more: https://pbiplint.com/rules/inactive-relationships-that-are-never-activated"
3067
+ },
3068
+ INTEGER_FORMATTING: {
3069
+ text: "Why it matters\n\nAn unformatted whole number is rendered with whatever default the client picks, so a measure that should read 1,234,567 can appear as 1234567 and leave the reader counting digits. Thousands separators are the single biggest readability win on a card or in a table column, and setting the format on the measure means every visual inherits it instead of each report author fixing it by hand and getting it slightly different. Currency and percentage measures follow their own conventions, which is why a format string containing $ or % is left alone by this rule.\n\nHow to fix it\n\nSet a format string that matches what the measure represents: #,0 for counts and other whole numbers, a currency format such as $#,0.00 for money, or #,0.0%;-#,0.0%;#,0.0% for percentages, which is the exact string PERCENTAGE_FORMATTING expects. In Power BI Desktop the Format box under Measure tools takes any of these. In the TMDL file the property is formatString: #,0 under the measure.\n\nQuirks\n\n- A measure with no format string at all is flagged by this rule as well as by PROVIDE_FORMAT_STRING_FOR_MEASURES. Setting the format string once clears both findings.\n- A measure with a dynamic format string but no static format string is flagged, because the source rule reads only the static format string.\n- Currency formats that do not use the $ character, for example \u20AC#,0.00, are flagged as though they were unformatted numbers, because the source rule looks for $ only.\n\nRead more: https://pbiplint.com/rules/integer-formatting",
3070
+ markdown: "### Why it matters\n\nAn unformatted whole number is rendered with whatever default the client picks, so a measure that should read 1,234,567 can appear as 1234567 and leave the reader counting digits. Thousands separators are the single biggest readability win on a card or in a table column, and setting the format on the measure means every visual inherits it instead of each report author fixing it by hand and getting it slightly different. Currency and percentage measures follow their own conventions, which is why a format string containing $ or % is left alone by this rule.\n\n### How to fix it\n\nSet a format string that matches what the measure represents: `#,0` for counts and other whole numbers, a currency format such as `$#,0.00` for money, or `#,0.0%;-#,0.0%;#,0.0%` for percentages, which is the exact string `PERCENTAGE_FORMATTING` expects. In Power BI Desktop the Format box under Measure tools takes any of these. In the TMDL file the property is `formatString: #,0` under the measure.\n\n### Quirks\n\n- A measure with no format string at all is flagged by this rule as well as by `PROVIDE_FORMAT_STRING_FOR_MEASURES`. Setting the format string once clears both findings.\n- A measure with a dynamic format string but no static format string is flagged, because the source rule reads only the static format string.\n- Currency formats that do not use the `$` character, for example `\u20AC#,0.00`, are flagged as though they were unformatted numbers, because the source rule looks for `$` only.\n\nRead more: https://pbiplint.com/rules/integer-formatting"
3071
+ },
3072
+ ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS: {
3073
+ text: "Why it matters\n\nWhen IsAvailableInMdx is true the engine builds an attribute hierarchy for the column at every refresh: a sorted structure that lets Excel and other MDX clients browse the column's values. A hidden column is never browsed, so the structure is built, stored, and rebuilt for nothing. On wide tables with many hidden keys and helper columns that is measurable refresh time and memory.\n\nHow to fix it\n\nAdd isAvailableInMdx: false under the column in the TMDL file. Power BI Desktop has no setting for this property but keeps the value once it is in the file. With many columns to change, Tabular Editor can set the property on every selected column in one edit; the TMDL edit needs no other tool.\n\nQuirks\n\n- Power BI Desktop never writes this property, so a Desktop-authored model gets one finding per hidden column until they are set.\n\nRead more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns",
3074
+ markdown: "### Why it matters\n\nWhen IsAvailableInMdx is true the engine builds an attribute hierarchy for the column at every refresh: a sorted structure that lets Excel and other MDX clients browse the column's values. A hidden column is never browsed, so the structure is built, stored, and rebuilt for nothing. On wide tables with many hidden keys and helper columns that is measurable refresh time and memory.\n\n### How to fix it\n\nAdd `isAvailableInMdx: false` under the column in the TMDL file. Power BI Desktop has no setting for this property but keeps the value once it is in the file. With many columns to change, Tabular Editor can set the property on every selected column in one edit; the TMDL edit needs no other tool.\n\n### Quirks\n\n- Power BI Desktop never writes this property, so a Desktop-authored model gets one finding per hidden column until they are set.\n\nRead more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns"
3075
+ },
3076
+ LARGE_TABLES_SHOULD_BE_PARTITIONED: {
3077
+ text: `Why it matters
3078
+
3079
+ A single partition means every refresh reloads the whole table, and a 25-million-row table reloaded nightly is the usual reason a refresh runs for hours or times out. With partitions, only the ones whose data changed are processed. In Power BI that is what incremental refresh sets up for you.
3080
+
3081
+ How to fix it
3082
+
3083
+ Configure incremental refresh on the table in Power BI Desktop, which creates date-based partitions when the model is published. Check the row count with DAX Studio's VertiPaq Analyzer or with EVALUATE ROW("rows", COUNTROWS(Sales)) in DAX query view.
3084
+
3085
+ pbiplint cannot evaluate this rule from files; it appears in pbiplint rules as needing a live model.
3086
+
3087
+ Read more: https://pbiplint.com/rules/large-tables-should-be-partitioned`,
3088
+ markdown: '### Why it matters\n\nA single partition means every refresh reloads the whole table, and a 25-million-row table reloaded nightly is the usual reason a refresh runs for hours or times out. With partitions, only the ones whose data changed are processed. In Power BI that is what incremental refresh sets up for you.\n\n### How to fix it\n\nConfigure incremental refresh on the table in Power BI Desktop, which creates date-based partitions when the model is published. Check the row count with DAX Studio\'s VertiPaq Analyzer or with `EVALUATE ROW("rows", COUNTROWS(Sales))` in DAX query view.\n\npbiplint cannot evaluate this rule from files; it appears in `pbiplint rules` as needing a live model.\n\nRead more: https://pbiplint.com/rules/large-tables-should-be-partitioned'
3089
+ },
3090
+ "LIMIT_ROW_LEVEL_SECURITY_(RLS)_LOGIC": {
3091
+ text: "Why it matters\n\nA security filter runs on every query from every user in the role, and string functions in it are evaluated row by row on the secured table. A filter that compares a precomputed key column with equals is applied as a lookup instead. The string logic usually exists to derive a key from an email address or a code, which the source can produce once at load.\n\nHow to fix it\n\nAdd the derived key as a column in Power Query or in the source, and write the filter as a plain comparison such as [Email] = USERPRINCIPALNAME() or [Region Key] = LOOKUPVALUE(...).\n\nQuirks\n\n- Spaces are removed before matching and the match is a substring, so BRIGHT( or L E F T( also match.\n\nRead more: https://pbiplint.com/rules/limit-row-level-security-rls-logic",
3092
+ markdown: "### Why it matters\n\nA security filter runs on every query from every user in the role, and string functions in it are evaluated row by row on the secured table. A filter that compares a precomputed key column with equals is applied as a lookup instead. The string logic usually exists to derive a key from an email address or a code, which the source can produce once at load.\n\n### How to fix it\n\nAdd the derived key as a column in Power Query or in the source, and write the filter as a plain comparison such as `[Email] = USERPRINCIPALNAME()` or `[Region Key] = LOOKUPVALUE(...)`.\n\n### Quirks\n\n- Spaces are removed before matching and the match is a substring, so `BRIGHT(` or `L E F T(` also match.\n\nRead more: https://pbiplint.com/rules/limit-row-level-security-rls-logic"
3093
+ },
3094
+ "MANY-TO-MANY_RELATIONSHIPS_SHOULD_BE_SINGLE-DIRECTION": {
3095
+ text: "Why it matters\n\nA many-to-many relationship has no unique key on either side, so the engine resolves it through a set of distinct values rather than a direct lookup. Making that relationship bi-directional as well lets filters travel back through the same expansion, and that is where filter ambiguity begins: as soon as two paths reach the same table, the result depends on which path the engine chooses, and totals stop agreeing with the sum of their parts. The extra direction also costs at query time, because every filter has to be expanded across the distinct values on both sides instead of one. Single direction keeps one predictable filter path and is the accepted default; add the reverse direction only where a specific report needs it and you have confirmed the model has no second path.\n\nHow to fix it\n\nSet the cross filter direction to Single in the relationship dialog, or remove the crossFilteringBehavior: bothDirections line from the relationship in the relationships TMDL file.\n\nRead more: https://pbiplint.com/rules/many-to-many-relationships-should-be-single-direction",
3096
+ markdown: "### Why it matters\n\nA many-to-many relationship has no unique key on either side, so the engine resolves it through a set of distinct values rather than a direct lookup. Making that relationship bi-directional as well lets filters travel back through the same expansion, and that is where filter ambiguity begins: as soon as two paths reach the same table, the result depends on which path the engine chooses, and totals stop agreeing with the sum of their parts. The extra direction also costs at query time, because every filter has to be expanded across the distinct values on both sides instead of one. Single direction keeps one predictable filter path and is the accepted default; add the reverse direction only where a specific report needs it and you have confirmed the model has no second path.\n\n### How to fix it\n\nSet the cross filter direction to Single in the relationship dialog, or remove the `crossFilteringBehavior: bothDirections` line from the relationship in the relationships TMDL file.\n\nRead more: https://pbiplint.com/rules/many-to-many-relationships-should-be-single-direction"
3097
+ },
3098
+ MARK_PRIMARY_KEYS: {
3099
+ text: "Why it matters\n\nThe key flag declares that the column is unique, which lets the engine and client tools treat it as the identifier of the row rather than one more attribute to aggregate, and the engine enforces the uniqueness at refresh, so a duplicate key fails loudly instead of quietly doubling a total. It also documents intent: the next person reading the model can see at a glance which column defines the grain of the dimension, without tracing every relationship to work it out. Tables marked as date tables are skipped, because marking a table as a date table already sets the key on its date column.\n\nHow to fix it\n\nAdd isKey under the column in the TMDL file. Power BI Desktop has no setting for it on ordinary tables but keeps the value once it is in the file. Make sure the column really is unique first, because refresh fails if it is not.\n\nRead more: https://pbiplint.com/rules/mark-primary-keys",
3100
+ markdown: "### Why it matters\n\nThe key flag declares that the column is unique, which lets the engine and client tools treat it as the identifier of the row rather than one more attribute to aggregate, and the engine enforces the uniqueness at refresh, so a duplicate key fails loudly instead of quietly doubling a total. It also documents intent: the next person reading the model can see at a glance which column defines the grain of the dimension, without tracing every relationship to work it out. Tables marked as date tables are skipped, because marking a table as a date table already sets the key on its date column.\n\n### How to fix it\n\nAdd `isKey` under the column in the TMDL file. Power BI Desktop has no setting for it on ordinary tables but keeps the value once it is in the file. Make sure the column really is unique first, because refresh fails if it is not.\n\nRead more: https://pbiplint.com/rules/mark-primary-keys"
3101
+ },
3102
+ MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES: {
3103
+ text: "Why it matters\n\nAn alias measure is a second name for the same number. Reports pick one or the other, the two drift apart the first time someone edits the alias instead of the original, and anyone reading the model has to follow the reference to learn what it means.\n\nHow to fix it\n\nPoint the reports at the original and delete the alias. If the alias exists only for a friendlier name, rename the original instead; Power BI Desktop updates the visuals that use it.\n\nQuirks\n\n- Only the exact form matches: [Measure] and nothing else. A table prefix, a comment, or a surrounding function passes.\n\nRead more: https://pbiplint.com/rules/measures-should-not-be-direct-references-of-other-measures",
3104
+ markdown: "### Why it matters\n\nAn alias measure is a second name for the same number. Reports pick one or the other, the two drift apart the first time someone edits the alias instead of the original, and anyone reading the model has to follow the reference to learn what it means.\n\n### How to fix it\n\nPoint the reports at the original and delete the alias. If the alias exists only for a friendlier name, rename the original instead; Power BI Desktop updates the visuals that use it.\n\n### Quirks\n\n- Only the exact form matches: `[Measure]` and nothing else. A table prefix, a comment, or a surrounding function passes.\n\nRead more: https://pbiplint.com/rules/measures-should-not-be-direct-references-of-other-measures"
3105
+ },
3106
+ MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY: {
3107
+ text: "Why it matters\n\nTime intelligence functions build sets of dates and evaluate the measure over each set. In Import mode that is in-memory work. In DirectQuery each set becomes a query, or a long list of dates inside one, sent to the source on every visual refresh, and sources are rarely fast at it. The functions work, but a page of year-to-date and prior-year cards can take many seconds to render.\n\nHow to fix it\n\nIf the fact table can be imported, import it and keep DirectQuery for the tables that need it. If it cannot, add prior-period columns to the fact table in the source, such as the same day's amount one year earlier on each row, so the measure becomes a plain SUM.\n\nQuirks\n\n- Function names are matched case-sensitively, in upper case only, as in the source rule.\n\nRead more: https://pbiplint.com/rules/measures-using-time-intelligence-and-model-is-using-direct-query",
3108
+ markdown: "### Why it matters\n\nTime intelligence functions build sets of dates and evaluate the measure over each set. In Import mode that is in-memory work. In DirectQuery each set becomes a query, or a long list of dates inside one, sent to the source on every visual refresh, and sources are rarely fast at it. The functions work, but a page of year-to-date and prior-year cards can take many seconds to render.\n\n### How to fix it\n\nIf the fact table can be imported, import it and keep DirectQuery for the tables that need it. If it cannot, add prior-period columns to the fact table in the source, such as the same day's amount one year earlier on each row, so the measure becomes a plain SUM.\n\n### Quirks\n\n- Function names are matched case-sensitively, in upper case only, as in the source rule.\n\nRead more: https://pbiplint.com/rules/measures-using-time-intelligence-and-model-is-using-direct-query"
3109
+ },
3110
+ MINIMIZE_POWER_QUERY_TRANSFORMATIONS: {
3111
+ text: "Why it matters\n\nThese are the steps most likely to stop query folding. When folding stops, Power Query pulls the raw rows and does the work itself on the refresh machine, on every refresh, instead of asking the source for the finished result. On a large table that is the difference between a five-minute refresh and an hour, and the same logic in a view or the warehouse runs once, with indexes.\n\nHow to fix it\n\nMove the join, grouping, or pivot into the source as a view or a table and point the query at that. Where a step has to stay in Power Query, check that the steps before it still fold by right-clicking the step and looking for View Native Query. A native query folds nothing after it, so put it first or replace it with a view.\n\nQuirks\n\n- The check is a case-sensitive substring match on the M text, so a function name inside a comment counts too.\n\nRead more: https://pbiplint.com/rules/minimize-power-query-transformations",
3112
+ markdown: "### Why it matters\n\nThese are the steps most likely to stop query folding. When folding stops, Power Query pulls the raw rows and does the work itself on the refresh machine, on every refresh, instead of asking the source for the finished result. On a large table that is the difference between a five-minute refresh and an hour, and the same logic in a view or the warehouse runs once, with indexes.\n\n### How to fix it\n\nMove the join, grouping, or pivot into the source as a view or a table and point the query at that. Where a step has to stay in Power Query, check that the steps before it still fold by right-clicking the step and looking for View Native Query. A native query folds nothing after it, so put it first or replace it with a view.\n\n### Quirks\n\n- The check is a case-sensitive substring match on the M text, so a function name inside a comment counts too.\n\nRead more: https://pbiplint.com/rules/minimize-power-query-transformations"
3113
+ },
3114
+ MODEL_SHOULD_HAVE_A_DATE_TABLE: {
3115
+ text: "Why it matters\n\nEvery time intelligence function needs a contiguous date column to work over, and the marked date table is where it finds one. Without it, the model either leans on Auto date/time, which adds a hidden date table per date column and cannot be extended with fiscal periods or holidays, or does no time intelligence at all. A single shared date table also gives every fact table the same month, quarter, and year attributes, so visuals from different tables line up.\n\nHow to fix it\n\nAdd a date table with one row per day covering every date in the model, from the source, from Power Query, or with CALENDAR in DAX. Mark it as a date table under Table tools and relate each fact table's date column to it. A date table built in DAX satisfies this rule, though REDUCE_USAGE_OF_CALCULATED_TABLES will list it.\n\nRead more: https://pbiplint.com/rules/model-should-have-a-date-table",
3116
+ markdown: "### Why it matters\n\nEvery time intelligence function needs a contiguous date column to work over, and the marked date table is where it finds one. Without it, the model either leans on Auto date/time, which adds a hidden date table per date column and cannot be extended with fiscal periods or holidays, or does no time intelligence at all. A single shared date table also gives every fact table the same month, quarter, and year attributes, so visuals from different tables line up.\n\n### How to fix it\n\nAdd a date table with one row per day covering every date in the model, from the source, from Power Query, or with CALENDAR in DAX. Mark it as a date table under Table tools and relate each fact table's date column to it. A date table built in DAX satisfies this rule, though `REDUCE_USAGE_OF_CALCULATED_TABLES` will list it.\n\nRead more: https://pbiplint.com/rules/model-should-have-a-date-table"
3117
+ },
3118
+ MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS: {
3119
+ text: "Why it matters\n\nIn DirectQuery every visual sends a query to the source. Aggregation tables let the engine answer the common high-level questions, totals by month or by region, from a small imported table and send only the detail queries through. Without them, the summary page of a dashboard pays the full round trip to the source on every interaction. This is an info-level prompt to consider the feature, not a defect.\n\nHow to fix it\n\nCreate a summary table at the grain the reports use most, import it, and set it up under Manage aggregations in Power BI Desktop. The guide in the links covers the setup and the rules the engine uses to match queries to the aggregation.\n\nRead more: https://pbiplint.com/rules/model-using-direct-query-and-no-aggregations",
3120
+ markdown: "### Why it matters\n\nIn DirectQuery every visual sends a query to the source. Aggregation tables let the engine answer the common high-level questions, totals by month or by region, from a small imported table and send only the detail queries through. Without them, the summary page of a dashboard pays the full round trip to the source on every interaction. This is an info-level prompt to consider the feature, not a defect.\n\n### How to fix it\n\nCreate a summary table at the grain the reports use most, import it, and set it up under Manage aggregations in Power BI Desktop. The guide in the links covers the setup and the rules the engine uses to match queries to the aggregation.\n\nRead more: https://pbiplint.com/rules/model-using-direct-query-and-no-aggregations"
3121
+ },
3122
+ "MONTH_(AS_A_STRING)_MUST_BE_SORTED": {
3123
+ text: "Why it matters\n\nA text month sorts alphabetically: April, August, December. Every axis and slicer that uses the column shows that order until someone notices, and the fix has to be repeated in each visual unless it is made once on the column.\n\nHow to fix it\n\nAdd a month number column, then in Power BI Desktop select the month name column and set Sort by column under Column tools to the number. In the TMDL file the property is sortByColumn: 'Month Number' under the column.\n\nQuirks\n\n- The name test is a substring, so Month Name fires and so does a text column called Monthly Target. Months is excluded, so Months Elapsed passes.\n\nRead more: https://pbiplint.com/rules/month-as-a-string-must-be-sorted",
3124
+ markdown: "### Why it matters\n\nA text month sorts alphabetically: April, August, December. Every axis and slicer that uses the column shows that order until someone notices, and the fix has to be repeated in each visual unless it is made once on the column.\n\n### How to fix it\n\nAdd a month number column, then in Power BI Desktop select the month name column and set Sort by column under Column tools to the number. In the TMDL file the property is `sortByColumn: 'Month Number'` under the column.\n\n### Quirks\n\n- The name test is a substring, so Month Name fires and so does a text column called Monthly Target. Months is excluded, so Months Elapsed passes.\n\nRead more: https://pbiplint.com/rules/month-as-a-string-must-be-sorted"
3125
+ },
3126
+ MONTHCOLUMN_FORMATSTRING: {
3127
+ text: "Why it matters\n\nA DateTime column named Month usually holds the first day of each month, and with a default date format it reads as March 1, 2026 rather than March 2026. The MMMM yyyy format shows the month and year, and setting it on the column fixes every visual at once.\n\nHow to fix it\n\nSet the Format under Column tools in Power BI Desktop, or add formatString: MMMM yyyy under the column in the TMDL file. If you prefer a shorter form such as MMM yyyy, set it and turn this rule off in pbiplint.config.json, because only the exact string passes.\n\nQuirks\n\n- The name test is a substring, so any DateTime column with month in its name is checked.\n\nRead more: https://pbiplint.com/rules/monthcolumn-formatstring",
3128
+ markdown: "### Why it matters\n\nA DateTime column named Month usually holds the first day of each month, and with a default date format it reads as March 1, 2026 rather than March 2026. The `MMMM yyyy` format shows the month and year, and setting it on the column fixes every visual at once.\n\n### How to fix it\n\nSet the Format under Column tools in Power BI Desktop, or add `formatString: MMMM yyyy` under the column in the TMDL file. If you prefer a shorter form such as `MMM yyyy`, set it and turn this rule off in `pbiplint.config.json`, because only the exact string passes.\n\n### Quirks\n\n- The name test is a substring, so any DateTime column with month in its name is checked.\n\nRead more: https://pbiplint.com/rules/monthcolumn-formatstring"
3129
+ },
3130
+ NUMERIC_COLUMN_SUMMARIZE_BY: {
3131
+ text: "Why it matters\n\nWith a default summarization, dragging the column onto a visual produces an implicit sum, and it is easy to sum something that should never be summed: a year, a unit price, a percentage, a key. The implicit measure also bypasses the format string and the logic of the real measures, so two visuals of the same thing disagree. With summarization off, the column lands on a visual as a category and the author reaches for a measure.\n\nHow to fix it\n\nIn Power BI Desktop, select the column and set Summarization to Don't summarize under Column tools. In the TMDL file the property is summarizeBy: none. Create explicit measures for the aggregations reports need.\n\nQuirks\n\n- A column with no summarizeBy property is treated as Default, which is not None, so it is flagged.\n\nRead more: https://pbiplint.com/rules/numeric-column-summarize-by",
3132
+ markdown: "### Why it matters\n\nWith a default summarization, dragging the column onto a visual produces an implicit sum, and it is easy to sum something that should never be summed: a year, a unit price, a percentage, a key. The implicit measure also bypasses the format string and the logic of the real measures, so two visuals of the same thing disagree. With summarization off, the column lands on a visual as a category and the author reaches for a measure.\n\n### How to fix it\n\nIn Power BI Desktop, select the column and set Summarization to Don't summarize under Column tools. In the TMDL file the property is `summarizeBy: none`. Create explicit measures for the aggregations reports need.\n\n### Quirks\n\n- A column with no summarizeBy property is treated as Default, which is not None, so it is flagged.\n\nRead more: https://pbiplint.com/rules/numeric-column-summarize-by"
3133
+ },
3134
+ OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: {
3135
+ text: 'Why it matters\n\nA leading or trailing space is invisible on screen but is part of the name, so "Sales " and "Sales" are two different objects to the engine. That is enough to break a DAX reference, a report visual binding, or a deployment that expects the trimmed name, and the error you get back will name an object that looks perfectly correct. Stray spaces almost always arrive by accident, pasted in or inherited from a source column name, so trimming them is safe and rarely breaks anything downstream. TRIM_OBJECT_NAMES makes the same check across more object types at a lower severity, so every finding here appears there as well.\n\nHow to fix it\n\nRename the object without the space in Power BI Desktop, which updates the visuals and DAX that reference it, or edit the name in the TMDL file.\n\nQuirks\n\n- Narrower scope than TRIM_OBJECT_NAMES: levels, roles, expressions, calculation items, calculated tables, and calculated table columns are not checked here.\n\nRead more: https://pbiplint.com/rules/objects-should-not-start-or-end-with-a-space',
3136
+ markdown: '### Why it matters\n\nA leading or trailing space is invisible on screen but is part of the name, so "Sales " and "Sales" are two different objects to the engine. That is enough to break a DAX reference, a report visual binding, or a deployment that expects the trimmed name, and the error you get back will name an object that looks perfectly correct. Stray spaces almost always arrive by accident, pasted in or inherited from a source column name, so trimming them is safe and rarely breaks anything downstream. `TRIM_OBJECT_NAMES` makes the same check across more object types at a lower severity, so every finding here appears there as well.\n\n### How to fix it\n\nRename the object without the space in Power BI Desktop, which updates the visuals and DAX that reference it, or edit the name in the TMDL file.\n\n### Quirks\n\n- Narrower scope than `TRIM_OBJECT_NAMES`: levels, roles, expressions, calculation items, calculated tables, and calculated table columns are not checked here.\n\nRead more: https://pbiplint.com/rules/objects-should-not-start-or-end-with-a-space'
3137
+ },
3138
+ OBJECTS_WITH_NO_DESCRIPTION: {
3139
+ text: "Why it matters\n\nThe description is the tooltip a report author sees when hovering a field in the field list, and it is the only place in the model to say what a measure counts, which currency a column is in, or which of two similar fields to use. Without it, every author works that out from the name, and gets it wrong at about the same rate. Descriptions also feed documentation tools, so the same sentence pays off twice.\n\nHow to fix it\n\nIn Power BI Desktop, open Model view, select the object, and type the Description in the Properties pane. In the TMDL file a description is one or more /// lines directly above the object. With hundreds of objects, Tabular Editor can paste descriptions into many objects at once; the TMDL comment lines need no other tool.\n\nQuirks\n\n- Visibility is the object's own isHidden flag: a visible column inside a hidden table is still reported.\n- A calculation group table is reported once, as a calculation group.\n\nRead more: https://pbiplint.com/rules/objects-with-no-description",
3140
+ markdown: "### Why it matters\n\nThe description is the tooltip a report author sees when hovering a field in the field list, and it is the only place in the model to say what a measure counts, which currency a column is in, or which of two similar fields to use. Without it, every author works that out from the name, and gets it wrong at about the same rate. Descriptions also feed documentation tools, so the same sentence pays off twice.\n\n### How to fix it\n\nIn Power BI Desktop, open Model view, select the object, and type the Description in the Properties pane. In the TMDL file a description is one or more `///` lines directly above the object. With hundreds of objects, Tabular Editor can paste descriptions into many objects at once; the TMDL comment lines need no other tool.\n\n### Quirks\n\n- Visibility is the object's own isHidden flag: a visible column inside a hidden table is still reported.\n- A calculation group table is reported once, as a calculation group.\n\nRead more: https://pbiplint.com/rules/objects-with-no-description"
3141
+ },
3142
+ PARSE_ISSUE: {
3143
+ text: "Why it matters\n\nThe parser skipped the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor's TMDL reader is stricter and refuses to open a file that puts a blank line after a /// line at all.\n\nHow to fix it\n\nOpen the file at the reported line. TMDL is indented with tabs, and expression blocks open and close with on their own lines. A /// description must sit directly above its declaration, with no blank line between them. Power BI Desktop writes valid TMDL, so a parse issue usually means a hand edit or a merge conflict marker.\n\nRead more: https://pbiplint.com/rules/parse-issue",
3144
+ markdown: "### Why it matters\n\nThe parser skipped the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor's TMDL reader is stricter and refuses to open a file that puts a blank line after a `///` line at all.\n\n### How to fix it\n\nOpen the file at the reported line. TMDL is indented with tabs, and expression blocks open and close with ``` on their own lines. A `///` description must sit directly above its declaration, with no blank line between them. Power BI Desktop writes valid TMDL, so a parse issue usually means a hand edit or a merge conflict marker.\n\nRead more: https://pbiplint.com/rules/parse-issue"
3145
+ },
3146
+ PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES: {
3147
+ text: "Why it matters\n\nA single-partition table has no reason for its partition to carry a different name, and when it does it is usually the table's old name from before a rename. Refresh logs, error messages, and the TMDL file all name the partition, so a mismatch sends the reader looking for a table that no longer exists.\n\nHow to fix it\n\nRename the partition in the TMDL file: the line partition 'Old Name' = m becomes partition 'Table Name' = m. Power BI Desktop names the partition after the table when it creates it, so on a Desktop project this usually points at a hand edit or a migrated model.\n\nRead more: https://pbiplint.com/rules/partition-name-should-match-table-name-for-single-partition-tables",
3148
+ markdown: "### Why it matters\n\nA single-partition table has no reason for its partition to carry a different name, and when it does it is usually the table's old name from before a rename. Refresh logs, error messages, and the TMDL file all name the partition, so a mismatch sends the reader looking for a table that no longer exists.\n\n### How to fix it\n\nRename the partition in the TMDL file: the line `partition 'Old Name' = m` becomes `partition 'Table Name' = m`. Power BI Desktop names the partition after the table when it creates it, so on a Desktop project this usually points at a hand edit or a migrated model.\n\nRead more: https://pbiplint.com/rules/partition-name-should-match-table-name-for-single-partition-tables"
3149
+ },
3150
+ PERCENTAGE_FORMATTING: {
3151
+ text: "Why it matters\n\nPercentages formatted inconsistently end up side by side in the same report, so one card reads 12.3% while the next reads 12.34% or 12%, and the reader is left wondering whether the numbers disagree or only the formatting does. The three-part string this rule expects sets the positive, negative, and zero cases together, so a negative percentage keeps its sign and a thousands separator appears once values pass 1000%. Setting it on the measure fixes the presentation for every report that uses the model, rather than leaving each report author to format the visual by hand. This is house style rather than correctness: if your standard uses a different number of decimals, disable the rule in pbiplint.config.json instead of working around it.\n\nHow to fix it\n\nUse the format string #,0.0%;-#,0.0%;#,0.0%. In Power BI Desktop, paste it into the Format box under Measure tools. In the TMDL file, set formatString: #,0.0%;-#,0.0%;#,0.0% under the measure.\n\nRead more: https://pbiplint.com/rules/percentage-formatting",
3152
+ markdown: "### Why it matters\n\nPercentages formatted inconsistently end up side by side in the same report, so one card reads 12.3% while the next reads 12.34% or 12%, and the reader is left wondering whether the numbers disagree or only the formatting does. The three-part string this rule expects sets the positive, negative, and zero cases together, so a negative percentage keeps its sign and a thousands separator appears once values pass 1000%. Setting it on the measure fixes the presentation for every report that uses the model, rather than leaving each report author to format the visual by hand. This is house style rather than correctness: if your standard uses a different number of decimals, disable the rule in `pbiplint.config.json` instead of working around it.\n\n### How to fix it\n\nUse the format string `#,0.0%;-#,0.0%;#,0.0%`. In Power BI Desktop, paste it into the Format box under Measure tools. In the TMDL file, set `formatString: #,0.0%;-#,0.0%;#,0.0%` under the measure.\n\nRead more: https://pbiplint.com/rules/percentage-formatting"
3153
+ },
3154
+ PERSPECTIVES_WITH_NO_OBJECTS: {
3155
+ text: "Why it matters\n\nAn empty perspective still shows up in clients that offer perspectives, such as Excel, as a named view of the model that contains nothing. It is either an abandoned start or the remains of objects that were removed, and it leaves the next person asking what it was for.\n\nHow to fix it\n\nAdd the objects the perspective should show, or delete its file from the perspectives folder. Power BI Desktop does not manage perspectives, so they appear only in models built or edited with other tools.\n\nRead more: https://pbiplint.com/rules/perspectives-with-no-objects",
3156
+ markdown: "### Why it matters\n\nAn empty perspective still shows up in clients that offer perspectives, such as Excel, as a named view of the model that contains nothing. It is either an abandoned start or the remains of objects that were removed, and it leaves the next person asking what it was for.\n\n### How to fix it\n\nAdd the objects the perspective should show, or delete its file from the `perspectives` folder. Power BI Desktop does not manage perspectives, so they appear only in models built or edited with other tools.\n\nRead more: https://pbiplint.com/rules/perspectives-with-no-objects"
3157
+ },
3158
+ PROVIDE_FORMAT_STRING_FOR_MEASURES: {
3159
+ text: "Why it matters\n\nA measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone.\n\nHow to fix it\n\nSet the Format under Measure tools in Power BI Desktop, or add formatString under the measure in the TMDL file: #,0 for whole numbers, #,0.00 for decimals, a currency format such as $#,0.00, or #,0.0%;-#,0.0%;#,0.0% for percentages.\n\nQuirks\n\n- A measure with only a dynamic format string passes here but fires INTEGER_FORMATTING, which reads the static format string alone.\n\nRead more: https://pbiplint.com/rules/provide-format-string-for-measures",
3160
+ markdown: "### Why it matters\n\nA measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone.\n\n### How to fix it\n\nSet the Format under Measure tools in Power BI Desktop, or add `formatString` under the measure in the TMDL file: `#,0` for whole numbers, `#,0.00` for decimals, a currency format such as `$#,0.00`, or `#,0.0%;-#,0.0%;#,0.0%` for percentages.\n\n### Quirks\n\n- A measure with only a dynamic format string passes here but fires `INTEGER_FORMATTING`, which reads the static format string alone.\n\nRead more: https://pbiplint.com/rules/provide-format-string-for-measures"
3161
+ },
3162
+ REDUCE_NUMBER_OF_CALCULATED_COLUMNS: {
3163
+ text: "Why it matters\n\nA calculated column is computed after load, one row at a time, and stored without the compression the engine gets for a column it loaded from the source, so each one costs refresh time and memory out of proportion to its size. Five is a budget rather than a limit: past it, the model is usually doing in DAX what Power Query or the source would do once and better.\n\nHow to fix it\n\nMove the logic into Power Query as a custom column, or into the source as a view. Keep DAX calculated columns for the few cases that need the model, such as a value that depends on a measure.\n\nRead more: https://pbiplint.com/rules/reduce-number-of-calculated-columns",
3164
+ markdown: "### Why it matters\n\nA calculated column is computed after load, one row at a time, and stored without the compression the engine gets for a column it loaded from the source, so each one costs refresh time and memory out of proportion to its size. Five is a budget rather than a limit: past it, the model is usually doing in DAX what Power Query or the source would do once and better.\n\n### How to fix it\n\nMove the logic into Power Query as a custom column, or into the source as a view. Keep DAX calculated columns for the few cases that need the model, such as a value that depends on a measure.\n\nRead more: https://pbiplint.com/rules/reduce-number-of-calculated-columns"
3165
+ },
3166
+ REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION: {
3167
+ text: "Why it matters\n\nRELATED in a calculated column copies a value from the one side of a relationship onto every row of the many side. That is a lookup the source can do with a join, or Power Query with a merge, at load time and often folded to the source. Done in DAX it is computed row by row after load and stored without full compression, and the copied column then duplicates a dimension attribute, which REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES also flags.\n\nHow to fix it\n\nAdd the column in Power Query with Merge Queries, or join it in the source view. If the value is only needed inside a measure, use RELATED in the measure instead of storing a column.\n\nQuirks\n\n- RELATEDTABLE( does not match; the pattern requires a parenthesis right after RELATED.\n\nRead more: https://pbiplint.com/rules/reduce-usage-of-calculated-columns-that-use-the-related-function",
3168
+ markdown: "### Why it matters\n\nRELATED in a calculated column copies a value from the one side of a relationship onto every row of the many side. That is a lookup the source can do with a join, or Power Query with a merge, at load time and often folded to the source. Done in DAX it is computed row by row after load and stored without full compression, and the copied column then duplicates a dimension attribute, which `REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES` also flags.\n\n### How to fix it\n\nAdd the column in Power Query with Merge Queries, or join it in the source view. If the value is only needed inside a measure, use RELATED in the measure instead of storing a column.\n\n### Quirks\n\n- RELATEDTABLE( does not match; the pattern requires a parenthesis right after RELATED.\n\nRead more: https://pbiplint.com/rules/reduce-usage-of-calculated-columns-that-use-the-related-function"
3169
+ },
3170
+ REDUCE_USAGE_OF_CALCULATED_TABLES: {
3171
+ text: "Why it matters\n\nA calculated table is rebuilt from DAX after every refresh, holds a copy of data that exists somewhere else, and is invisible to the source's lineage and to every other model. When two models need the same table, each rebuilds it its own way and they drift. A date table is the usual exception, and even there a shared table in the source or in a dataflow serves every model the same way.\n\nHow to fix it\n\nBuild the table in the source or in Power Query so it loads as data. Where a calculated table stays, keep it small and give it a description that says why.\n\nQuirks\n\n- With Auto date/time on, each hidden LocalDateTable is a calculated table and fires here as well as REMOVE_AUTO-DATE_TABLE. Turning the option off clears both.\n\nRead more: https://pbiplint.com/rules/reduce-usage-of-calculated-tables",
3172
+ markdown: "### Why it matters\n\nA calculated table is rebuilt from DAX after every refresh, holds a copy of data that exists somewhere else, and is invisible to the source's lineage and to every other model. When two models need the same table, each rebuilds it its own way and they drift. A date table is the usual exception, and even there a shared table in the source or in a dataflow serves every model the same way.\n\n### How to fix it\n\nBuild the table in the source or in Power Query so it loads as data. Where a calculated table stays, keep it small and give it a description that says why.\n\n### Quirks\n\n- With Auto date/time on, each hidden LocalDateTable is a calculated table and fires here as well as `REMOVE_AUTO-DATE_TABLE`. Turning the option off clears both.\n\nRead more: https://pbiplint.com/rules/reduce-usage-of-calculated-tables"
3173
+ },
3174
+ "REDUCE_USAGE_OF_LONG-LENGTH_COLUMNS_WITH_HIGH_CARDINALITY": {
3175
+ text: "Why it matters\n\nThe engine stores each distinct text value once in a dictionary and encodes the rows against it. Long unique strings, such as comments, descriptions, or URLs with query strings, defeat that: the dictionary grows as large as the data, memory and refresh time follow, and every visual that touches the column pays to decode it. Such a column is usually never shown in a visual anyway.\n\nHow to fix it\n\nLeave the column out of the model unless a report shows it. If it is needed, shorten it in Power Query, keep only the rows that matter, or move it to a detail table reached by drillthrough. Check column sizes with DAX Studio's VertiPaq Analyzer.\n\npbiplint cannot evaluate this rule from files; it appears in pbiplint rules as needing a live model.\n\nRead more: https://pbiplint.com/rules/reduce-usage-of-long-length-columns-with-high-cardinality",
3176
+ markdown: "### Why it matters\n\nThe engine stores each distinct text value once in a dictionary and encodes the rows against it. Long unique strings, such as comments, descriptions, or URLs with query strings, defeat that: the dictionary grows as large as the data, memory and refresh time follow, and every visual that touches the column pays to decode it. Such a column is usually never shown in a visual anyway.\n\n### How to fix it\n\nLeave the column out of the model unless a report shows it. If it is needed, shorten it in Power Query, keep only the rows that matter, or move it to a detail table reached by drillthrough. Check column sizes with DAX Studio's VertiPaq Analyzer.\n\npbiplint cannot evaluate this rule from files; it appears in `pbiplint rules` as needing a live model.\n\nRead more: https://pbiplint.com/rules/reduce-usage-of-long-length-columns-with-high-cardinality"
3177
+ },
3178
+ RELATIONSHIP_COLUMNS_SAME_DATA_TYPE: {
3179
+ text: "Why it matters\n\nThe engine relates columns by value, and when the types differ it converts one side for every query. A text key on one side and a whole number on the other works until a value like 007 meets 7, at which point rows quietly fall into the blank member. Matching types remove both the conversion cost and the surprise.\n\nHow to fix it\n\nChange both columns to the same type in Power Query, and prefer whole numbers for keys. In the TMDL file the property is dataType on each column.\n\nRead more: https://pbiplint.com/rules/relationship-columns-same-data-type",
3180
+ markdown: "### Why it matters\n\nThe engine relates columns by value, and when the types differ it converts one side for every query. A text key on one side and a whole number on the other works until a value like 007 meets 7, at which point rows quietly fall into the blank member. Matching types remove both the conversion cost and the surprise.\n\n### How to fix it\n\nChange both columns to the same type in Power Query, and prefer whole numbers for keys. In the TMDL file the property is `dataType` on each column.\n\nRead more: https://pbiplint.com/rules/relationship-columns-same-data-type"
3181
+ },
3182
+ RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE: {
3183
+ text: "Why it matters\n\nA relationship is evaluated by matching values, and whole numbers match fastest and compress smallest. Text keys carry their dictionary into every join, and DateTime keys work but store more than an integer date key would. On the largest fact tables the key columns are often the biggest, so the choice shows up in memory as much as in query time.\n\nHow to fix it\n\nUse integer surrogate keys from the source where they exist. Where the natural key is text, add a numeric key in the source or with a merge in Power Query. Date relationships on a DateTime column are common and work; an integer date key such as 20260904 is the stricter option.\n\nQuirks\n\n- Every date relationship on a DateTime column fires this rule. That is what the source rule does; disable it in pbiplint.config.json if date keys are your standard.\n\nRead more: https://pbiplint.com/rules/relationship-columns-should-be-of-integer-data-type",
3184
+ markdown: "### Why it matters\n\nA relationship is evaluated by matching values, and whole numbers match fastest and compress smallest. Text keys carry their dictionary into every join, and DateTime keys work but store more than an integer date key would. On the largest fact tables the key columns are often the biggest, so the choice shows up in memory as much as in query time.\n\n### How to fix it\n\nUse integer surrogate keys from the source where they exist. Where the natural key is text, add a numeric key in the source or with a merge in Power Query. Date relationships on a DateTime column are common and work; an integer date key such as 20260904 is the stricter option.\n\n### Quirks\n\n- Every date relationship on a DateTime column fires this rule. That is what the source rule does; disable it in `pbiplint.config.json` if date keys are your standard.\n\nRead more: https://pbiplint.com/rules/relationship-columns-should-be-of-integer-data-type"
3185
+ },
3186
+ "REMOVE_AUTO-DATE_TABLE": {
3187
+ text: "Why it matters\n\nWith Auto date/time on, Power BI Desktop creates a hidden calculated date table for every date column in the model, each with its own year, quarter, month, and day hierarchy. A model with a dozen date columns carries a dozen hidden tables, all rebuilt on every refresh, and none of them can be extended with fiscal periods or holidays or shared between fact tables. One real date table does everything they do, once.\n\nHow to fix it\n\nIn Power BI Desktop, open Options, then Current file, then Data Load, and clear Auto date/time. The hidden tables disappear on the next save. Then add a date table and mark it as a date table so the date hierarchies come from it.\n\nQuirks\n\n- With the option on, each hidden table also fires REDUCE_USAGE_OF_CALCULATED_TABLES.\n\nRead more: https://pbiplint.com/rules/remove-auto-date-table",
3188
+ markdown: "### Why it matters\n\nWith Auto date/time on, Power BI Desktop creates a hidden calculated date table for every date column in the model, each with its own year, quarter, month, and day hierarchy. A model with a dozen date columns carries a dozen hidden tables, all rebuilt on every refresh, and none of them can be extended with fiscal periods or holidays or shared between fact tables. One real date table does everything they do, once.\n\n### How to fix it\n\nIn Power BI Desktop, open Options, then Current file, then Data Load, and clear Auto date/time. The hidden tables disappear on the next save. Then add a date table and mark it as a date table so the date hierarchies come from it.\n\n### Quirks\n\n- With the option on, each hidden table also fires `REDUCE_USAGE_OF_CALCULATED_TABLES`.\n\nRead more: https://pbiplint.com/rules/remove-auto-date-table"
3189
+ },
3190
+ REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS: {
3191
+ text: "Why it matters\n\nAn unused data source is a connection string, and usually a credential, that the model still asks to have configured on every deployment, so the service keeps prompting for credentials to a source nothing reads. It is a leftover from a migration or a source that was replaced.\n\nHow to fix it\n\nDelete the data source's file from the dataSources folder of the project.\n\nQuirks\n\n- Tabular Editor 3 CLI 0.5.2 did not report this rule when loading from TMDL, although it did from .bim; pbiplint follows the rule text.\n- Power BI Desktop never writes data sources, so this rule matters only for hand-built or migrated models.\n\nRead more: https://pbiplint.com/rules/remove-data-sources-not-referenced-by-any-partitions",
3192
+ markdown: "### Why it matters\n\nAn unused data source is a connection string, and usually a credential, that the model still asks to have configured on every deployment, so the service keeps prompting for credentials to a source nothing reads. It is a leftover from a migration or a source that was replaced.\n\n### How to fix it\n\nDelete the data source's file from the `dataSources` folder of the project.\n\n### Quirks\n\n- Tabular Editor 3 CLI 0.5.2 did not report this rule when loading from TMDL, although it did from .bim; pbiplint follows the rule text.\n- Power BI Desktop never writes data sources, so this rule matters only for hand-built or migrated models.\n\nRead more: https://pbiplint.com/rules/remove-data-sources-not-referenced-by-any-partitions"
3193
+ },
3194
+ REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES: {
3195
+ text: "Why it matters\n\nA fact table row that carries the product name as well as the product key stores the name once per sale instead of once per product, and offers the report author two Product Name fields that behave differently: the fact table's version cannot filter other fact tables and shows only the names that have sales. The dimension's copy is the one that should exist.\n\nHow to fix it\n\nRemove the column from the fact table's query in Power Query and use the dimension's column in reports. If a measure needs the value, RELATED reaches it through the relationship.\n\nQuirks\n\n- Matching is by column name only, so two unrelated columns that happen to share a name fire too.\n\nRead more: https://pbiplint.com/rules/remove-redundant-columns-in-related-tables",
3196
+ markdown: "### Why it matters\n\nA fact table row that carries the product name as well as the product key stores the name once per sale instead of once per product, and offers the report author two Product Name fields that behave differently: the fact table's version cannot filter other fact tables and shows only the names that have sales. The dimension's copy is the one that should exist.\n\n### How to fix it\n\nRemove the column from the fact table's query in Power Query and use the dimension's column in reports. If a measure needs the value, RELATED reaches it through the relationship.\n\n### Quirks\n\n- Matching is by column name only, so two unrelated columns that happen to share a name fire too.\n\nRead more: https://pbiplint.com/rules/remove-redundant-columns-in-related-tables"
3197
+ },
3198
+ REMOVE_ROLES_WITH_NO_MEMBERS: {
3199
+ text: 'Why it matters\n\nIn a Power BI project the role definitions live in the files and the membership lives in the service, so a role in the files never has members and this rule fires for every role. pbiplint keeps that behavior to match the source ruleset. On models where membership is in the file, such as ones built for Analysis Services, a role with no members grants nothing and leaves the next reviewer guessing whether membership was never assigned, was removed on purpose, or was dropped by a failed deployment, which is exactly the question a security review needs answered.\n\nHow to fix it\n\nOn a Power BI project, turn the rule off with "REMOVE_ROLES_WITH_NO_MEMBERS": "off" in pbiplint.config.json, or annotate the roles that are meant to be empty. Elsewhere, assign the members or delete the role.\n\nQuirks\n\n- Power BI Desktop never writes role members, so every role in a Desktop-authored project is flagged.\n\nRead more: https://pbiplint.com/rules/remove-roles-with-no-members',
3200
+ markdown: '### Why it matters\n\nIn a Power BI project the role definitions live in the files and the membership lives in the service, so a role in the files never has members and this rule fires for every role. pbiplint keeps that behavior to match the source ruleset. On models where membership is in the file, such as ones built for Analysis Services, a role with no members grants nothing and leaves the next reviewer guessing whether membership was never assigned, was removed on purpose, or was dropped by a failed deployment, which is exactly the question a security review needs answered.\n\n### How to fix it\n\nOn a Power BI project, turn the rule off with `"REMOVE_ROLES_WITH_NO_MEMBERS": "off"` in `pbiplint.config.json`, or annotate the roles that are meant to be empty. Elsewhere, assign the members or delete the role.\n\n### Quirks\n\n- Power BI Desktop never writes role members, so every role in a Desktop-authored project is flagged.\n\nRead more: https://pbiplint.com/rules/remove-roles-with-no-members'
3201
+ },
3202
+ SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS: {
3203
+ text: "Why it matters\n\nA column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory.\n\nHow to fix it\n\nRemove the isAvailableInMdx: false line from the column in the TMDL file, so the property returns to its default of true.\n\nRead more: https://pbiplint.com/rules/set-isavailableinmdx-to-true-on-necessary-columns",
3204
+ markdown: "### Why it matters\n\nA column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory.\n\n### How to fix it\n\nRemove the `isAvailableInMdx: false` line from the column in the TMDL file, so the property returns to its default of true.\n\nRead more: https://pbiplint.com/rules/set-isavailableinmdx-to-true-on-necessary-columns"
3205
+ },
3206
+ SNOWFLAKE_SCHEMA_ARCHITECTURE: {
3207
+ text: "Why it matters\n\nIn a star schema every dimension relates directly to the fact table, so a filter on Category reaches Sales in one hop. When Category hangs off Product, which hangs off Sales, the filter travels two hops, the model view is harder to read, and any bi-directional relationship along the chain doubles the chance of ambiguity. The engine handles a snowflake, but it handles a star faster, and a report author understands a star at a glance.\n\nHow to fix it\n\nFlatten the sub-dimension into its parent with a merge in Power Query, so Product carries Category Name and the Category table goes away. Keep a snowflake only where the sub-dimension is shared by several dimensions or is very large.\n\nQuirks\n\n- The test is the from side and the to side of a relationship, not the many side and the one side, so a table with a one-to-one relationship can count.\n\nRead more: https://pbiplint.com/rules/snowflake-schema-architecture",
3208
+ markdown: "### Why it matters\n\nIn a star schema every dimension relates directly to the fact table, so a filter on Category reaches Sales in one hop. When Category hangs off Product, which hangs off Sales, the filter travels two hops, the model view is harder to read, and any bi-directional relationship along the chain doubles the chance of ambiguity. The engine handles a snowflake, but it handles a star faster, and a report author understands a star at a glance.\n\n### How to fix it\n\nFlatten the sub-dimension into its parent with a merge in Power Query, so Product carries Category Name and the Category table goes away. Keep a snowflake only where the sub-dimension is shared by several dimensions or is very large.\n\n### Quirks\n\n- The test is the from side and the to side of a relationship, not the many side and the one side, so a table with a one-to-one relationship can count.\n\nRead more: https://pbiplint.com/rules/snowflake-schema-architecture"
3209
+ },
3210
+ SPECIAL_CHARS_IN_OBJECT_NAMES: {
3211
+ text: "Why it matters\n\nTabs and line breaks inside a name are invisible in most of the interface, so the object looks correct in the field list while every DAX reference, report binding, and deployment script has to reproduce the hidden character exactly. When one of them does not, you get a broken reference between two names that look identical on screen, which is one of the slowest kinds of model bug to track down. The same characters break CSV and Excel exports, where a line feed inside a column header splits the header across rows. Names should contain printable characters and ordinary spaces only.\n\nHow to fix it\n\nRename the object without the character in Power BI Desktop, or edit the name in the TMDL file.\n\nQuirks\n\n- In practice this rule cannot fire on a project loaded from TMDL files, because the format does not carry these characters. It is kept so that models built by other means are covered.\n\nRead more: https://pbiplint.com/rules/special-chars-in-object-names",
3212
+ markdown: "### Why it matters\n\nTabs and line breaks inside a name are invisible in most of the interface, so the object looks correct in the field list while every DAX reference, report binding, and deployment script has to reproduce the hidden character exactly. When one of them does not, you get a broken reference between two names that look identical on screen, which is one of the slowest kinds of model bug to track down. The same characters break CSV and Excel exports, where a line feed inside a column header splits the header across rows. Names should contain printable characters and ordinary spaces only.\n\n### How to fix it\n\nRename the object without the character in Power BI Desktop, or edit the name in the TMDL file.\n\n### Quirks\n\n- In practice this rule cannot fire on a project loaded from TMDL files, because the format does not carry these characters. It is kept so that models built by other means are covered.\n\nRead more: https://pbiplint.com/rules/special-chars-in-object-names"
3213
+ },
3214
+ SPLIT_DATE_AND_TIME: {
3215
+ text: "Why it matters\n\nA timestamp column has nearly as many distinct values as rows, so it does not compress and it cannot relate to a date table. Split into a date and a time, each column has a few thousand distinct values, compresses well, relates to a date table and a time table, and supports the questions people actually ask, which are by day and by hour rather than by second.\n\nHow to fix it\n\nIn Power Query, add a Date column and a Time column from the timestamp and remove the original, or round the timestamp to midnight if the time is never used. Check cardinality with DAX Studio's VertiPaq Analyzer or with DISTINCTCOUNT in DAX query view.\n\npbiplint cannot evaluate this rule from files; it appears in pbiplint rules as needing a live model.\n\nRead more: https://pbiplint.com/rules/split-date-and-time",
3216
+ markdown: "### Why it matters\n\nA timestamp column has nearly as many distinct values as rows, so it does not compress and it cannot relate to a date table. Split into a date and a time, each column has a few thousand distinct values, compresses well, relates to a date table and a time table, and supports the questions people actually ask, which are by day and by hour rather than by second.\n\n### How to fix it\n\nIn Power Query, add a Date column and a Time column from the timestamp and remove the original, or round the timestamp to midnight if the time is never used. Check cardinality with DAX Studio's VertiPaq Analyzer or with DISTINCTCOUNT in DAX query view.\n\npbiplint cannot evaluate this rule from files; it appears in `pbiplint rules` as needing a live model.\n\nRead more: https://pbiplint.com/rules/split-date-and-time"
3217
+ },
3218
+ TRIM_OBJECT_NAMES: {
3219
+ text: 'Why it matters\n\nA leading or trailing space is invisible in the field list but part of the name, so "Sales " and "Sales" are two objects to the engine, and a DAX reference, a visual binding, or a deployment script that uses the trimmed name fails against something that looks correct. The space usually arrives with a source column name or a paste. OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE reports the same names at error severity for a narrower set of object types.\n\nHow to fix it\n\nRename the object without the space in Power BI Desktop, or edit the name in the TMDL file. Trim column names in Power Query so they arrive clean.\n\nRead more: https://pbiplint.com/rules/trim-object-names',
3220
+ markdown: '### Why it matters\n\nA leading or trailing space is invisible in the field list but part of the name, so "Sales " and "Sales" are two objects to the engine, and a DAX reference, a visual binding, or a deployment script that uses the trimmed name fails against something that looks correct. The space usually arrives with a source column name or a paste. `OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE` reports the same names at error severity for a narrower set of object types.\n\n### How to fix it\n\nRename the object without the space in Power BI Desktop, or edit the name in the TMDL file. Trim column names in Power Query so they arrive clean.\n\nRead more: https://pbiplint.com/rules/trim-object-names'
3221
+ },
3222
+ UNNECESSARY_COLUMNS: {
3223
+ text: "Why it matters\n\nA hidden column that nothing uses is loaded, compressed, and refreshed for no reader. Key columns and helper columns pile up this way as a model evolves, and each one costs memory and refresh time in proportion to its cardinality. Removing them is the cheapest model diet there is.\n\nHow to fix it\n\nRemove the column in Power Query with Choose Columns or Remove Columns, so it is never loaded.\n\nQuirks\n\n- DAX references are approximated by pattern matching: references inside strings or comments count, and a bare [Column] reference resolves measure-first, then the expression's own table, then the first table with that column.\n- Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.\n\nRead more: https://pbiplint.com/rules/unnecessary-columns",
3224
+ markdown: "### Why it matters\n\nA hidden column that nothing uses is loaded, compressed, and refreshed for no reader. Key columns and helper columns pile up this way as a model evolves, and each one costs memory and refresh time in proportion to its cardinality. Removing them is the cheapest model diet there is.\n\n### How to fix it\n\nRemove the column in Power Query with Choose Columns or Remove Columns, so it is never loaded.\n\n### Quirks\n\n- DAX references are approximated by pattern matching: references inside strings or comments count, and a bare [Column] reference resolves measure-first, then the expression's own table, then the first table with that column.\n- Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.\n\nRead more: https://pbiplint.com/rules/unnecessary-columns"
3225
+ },
3226
+ UNNECESSARY_MEASURES: {
3227
+ text: "Why it matters\n\nA hidden measure that no other measure uses can only be reached by a report that already had it, so it is either dead or a hidden dependency that breaks the day someone deletes it as dead. Either way it belongs in the open or in the bin.\n\nHow to fix it\n\nDelete the measure, or unhide it if reports still use it.\n\nQuirks\n\n- References from calculation items and from other hidden measures count as usage.\n- Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.\n\nRead more: https://pbiplint.com/rules/unnecessary-measures",
3228
+ markdown: "### Why it matters\n\nA hidden measure that no other measure uses can only be reached by a report that already had it, so it is either dead or a hidden dependency that breaks the day someone deletes it as dead. Either way it belongs in the open or in the bin.\n\n### How to fix it\n\nDelete the measure, or unhide it if reports still use it.\n\n### Quirks\n\n- References from calculation items and from other hidden measures count as usage.\n- Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.\n\nRead more: https://pbiplint.com/rules/unnecessary-measures"
3229
+ },
3230
+ "UNPIVOT_PIVOTED_(MONTH)_DATA": {
3231
+ text: "Why it matters\n\nA column per month is a spreadsheet layout. In a model it means a measure per month, no way to filter by date, no relationship to the date table, and a schema change every year. Unpivoted into one Month column and one Value column, the same data relates to the date table and every measure and time intelligence function works over it.\n\nHow to fix it\n\nIn Power Query, select the month columns, choose Unpivot Columns, and rename the Attribute and Value columns. Then relate the month to the date table.\n\nQuirks\n\n- Only the first six months are tested, and full names count, so a table with January through June fires and one with only Jul through Dec does not.\n\nRead more: https://pbiplint.com/rules/unpivot-pivoted-month-data",
3232
+ markdown: "### Why it matters\n\nA column per month is a spreadsheet layout. In a model it means a measure per month, no way to filter by date, no relationship to the date table, and a schema change every year. Unpivoted into one Month column and one Value column, the same data relates to the date table and every measure and time intelligence function works over it.\n\n### How to fix it\n\nIn Power Query, select the month columns, choose Unpivot Columns, and rename the Attribute and Value columns. Then relate the month to the date table.\n\n### Quirks\n\n- Only the first six months are tested, and full names count, so a table with January through June fires and one with only Jul through Dec does not.\n\nRead more: https://pbiplint.com/rules/unpivot-pivoted-month-data"
3233
+ },
3234
+ USE_THE_DIVIDE_FUNCTION_FOR_DIVISION: {
3235
+ text: "Why it matters\n\nDividing by a zero or blank denominator with / produces an error, and an error in one cell takes down the whole visual with a generic message. DIVIDE returns blank in that case, or an alternate result you choose, so the visual shows a gap where the data has one instead of failing.\n\nHow to fix it\n\nWrite DIVIDE([Sales], [Cost]), with a third argument when you want something other than blank for a zero denominator.\n\nQuirks\n\n- A slash after a number or a variable name is not matched: 1 / [Sales] and total / count pass.\n- Division by a constant, [Sales] / 100, is flagged even though it cannot fail.\n\nRead more: https://pbiplint.com/rules/use-the-divide-function-for-division",
3236
+ markdown: "### Why it matters\n\nDividing by a zero or blank denominator with `/` produces an error, and an error in one cell takes down the whole visual with a generic message. DIVIDE returns blank in that case, or an alternate result you choose, so the visual shows a gap where the data has one instead of failing.\n\n### How to fix it\n\nWrite `DIVIDE([Sales], [Cost])`, with a third argument when you want something other than blank for a zero denominator.\n\n### Quirks\n\n- A slash after a number or a variable name is not matched: `1 / [Sales]` and `total / count` pass.\n- Division by a constant, `[Sales] / 100`, is flagged even though it cannot fail.\n\nRead more: https://pbiplint.com/rules/use-the-divide-function-for-division"
3237
+ },
3238
+ USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT: {
3239
+ text: "Why it matters\n\nINTERSECT is used to push a filter from one table to another when no relationship exists: take the values on one side and intersect them with the other. TREATAS does the same job by treating the first table's values as a filter on the second table's columns, and the engine applies it as a filter, which is much cheaper than materializing both sets and intersecting them.\n\nHow to fix it\n\nReplace CALCULATE([Measure], INTERSECT(VALUES(Table2[Key]), VALUES(Table1[Key]))) with CALCULATE([Measure], TREATAS(VALUES(Table1[Key]), Table2[Key])). The SQLBI article in the links covers the pattern.\n\nQuirks\n\n- INTERSECT used for anything other than a virtual relationship, such as set logic inside a measure, is flagged too.\n\nRead more: https://pbiplint.com/rules/use-the-treatas-function-instead-of-intersect",
3240
+ markdown: "### Why it matters\n\nINTERSECT is used to push a filter from one table to another when no relationship exists: take the values on one side and intersect them with the other. TREATAS does the same job by treating the first table's values as a filter on the second table's columns, and the engine applies it as a filter, which is much cheaper than materializing both sets and intersecting them.\n\n### How to fix it\n\nReplace `CALCULATE([Measure], INTERSECT(VALUES(Table2[Key]), VALUES(Table1[Key])))` with `CALCULATE([Measure], TREATAS(VALUES(Table1[Key]), Table2[Key]))`. The SQLBI article in the links covers the pattern.\n\n### Quirks\n\n- INTERSECT used for anything other than a virtual relationship, such as set logic inside a measure, is flagged too.\n\nRead more: https://pbiplint.com/rules/use-the-treatas-function-instead-of-intersect"
3241
+ }
3242
+ };
3243
+
3244
+ // src/walk.ts
3245
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
3246
+ import { basename, dirname as dirname3, join as join3, relative, resolve as resolve2 } from "node:path";
3247
+ var toPosix = (p) => p.split("\\").join("/");
3248
+ function readTmdlFiles(root, dir, out) {
3249
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
3250
+ (a, b) => a.name.localeCompare(b.name)
3251
+ )) {
3252
+ const p = join3(dir, entry.name);
3253
+ if (entry.isDirectory()) readTmdlFiles(root, p, out);
3254
+ else if (entry.name.endsWith(".tmdl"))
3255
+ out.push({ path: toPosix(relative(root, p)), text: readFileSync2(p, "utf8") });
3256
+ }
3257
+ }
3258
+ function resolveModel(input) {
3259
+ const path = resolve2(input);
3260
+ if (!existsSync3(path)) throw new UsageError(`${input} does not exist`);
3261
+ const stat = statSync(path);
3262
+ if (stat.isFile()) {
3263
+ if (!path.endsWith(".tmdl")) throw new UsageError(`${input} is not a .tmdl file or a folder`);
3264
+ return {
3265
+ root: dirname3(path),
3266
+ files: [{ path: basename(path), text: readFileSync2(path, "utf8") }]
3267
+ };
3268
+ }
3269
+ if (existsSync3(join3(path, "definition")) && statSync(join3(path, "definition")).isDirectory()) {
3270
+ const files = [];
3271
+ readTmdlFiles(path, join3(path, "definition"), files);
3272
+ if (files.length) return { root: path, files };
3273
+ }
3274
+ const models = readdirSync(path, { withFileTypes: true }).filter(
3275
+ (e) => e.isDirectory() && e.name.endsWith(".SemanticModel")
3276
+ );
3277
+ if (models.length === 1) return resolveModel(join3(path, models[0].name));
3278
+ if (models.length > 1)
3279
+ throw new UsageError(
3280
+ `${input} contains ${models.length} semantic models; point at one of them: ${models.map((m) => m.name).join(", ")}`
3281
+ );
3282
+ const direct = [];
3283
+ readTmdlFiles(path, path, direct);
3284
+ if (direct.length) return { root: path, files: direct };
3285
+ throw new UsageError(
3286
+ `No semantic model found at ${input} (expected a .SemanticModel folder, a PBIP folder, a definition folder, or .tmdl files)`
3287
+ );
3288
+ }
3289
+
3290
+ // src/main.ts
3291
+ var VERSION2 = true ? "0.1.0" : "0.0.0-dev";
3292
+ function listRules() {
3293
+ const width = Math.max(...defaultRules.map((r) => r.id.length));
3294
+ return defaultRules.map(
3295
+ (r) => `${r.id.padEnd(width)} ${(r.status === "needsLiveModel" ? "needs live model" : r.status).padEnd(16)} ${SEVERITY_LABEL[r.severity].padEnd(7)} ${r.category.padEnd(18)} ${r.name}`
3296
+ ).join("\n");
3297
+ }
3298
+ async function main(argv, io) {
3299
+ try {
3300
+ const opts = parseArgs(argv);
3301
+ if (opts.command === "help") {
3302
+ io.stdout(HELP);
3303
+ return 0;
3304
+ }
3305
+ if (opts.command === "version") {
3306
+ io.stdout(`pbiplint ${VERSION2}
3307
+ `);
3308
+ return 0;
3309
+ }
3310
+ if (opts.command === "rules") {
3311
+ io.stdout(listRules() + "\n");
3312
+ return 0;
3313
+ }
3314
+ const target = opts.sample ? sampleDir() : resolve3(io.cwd(), opts.path);
3315
+ const model = resolveModel(target);
3316
+ const found = findConfig(model.root, opts.config ? resolve3(io.cwd(), opts.config) : void 0);
3317
+ const config = resolveConfig({
3318
+ ...found.config,
3319
+ ...opts.failOn ? { failOn: opts.failOn } : {}
3320
+ });
3321
+ const result = lint(model.files, { config });
3322
+ const pathPrefix = relative2(io.cwd(), model.root).split("\\").join("/");
3323
+ const report = formatResult(opts.format, result, {
3324
+ toolVersion: VERSION2,
3325
+ pathPrefix,
3326
+ help: RULE_HELP
3327
+ });
3328
+ if (opts.output) {
3329
+ const out = resolve3(io.cwd(), opts.output);
3330
+ mkdirSync(dirname4(out), { recursive: true });
3331
+ writeFileSync(out, report);
3332
+ io.stderr(`pbiplint: ${summaryLine(result)}, wrote ${opts.output}
3333
+ `);
3334
+ } else {
3335
+ io.stdout(report);
3336
+ }
3337
+ for (const e of result.summary.ruleErrors) io.stderr(`rule ${e.id} failed: ${e.message}
3338
+ `);
3339
+ const configName = basename2(found.path ?? CONFIG_FILE);
3340
+ for (const id of result.summary.unknownRules)
3341
+ io.stderr(
3342
+ `pbiplint: ${configName}: no rule named "${id}" (run pbiplint rules for the list)
3343
+ `
3344
+ );
3345
+ return result.failed ? 1 : 0;
3346
+ } catch (e) {
3347
+ if (e instanceof UsageError || e instanceof ConfigError) {
3348
+ io.stderr(`pbiplint: ${e.message}
3349
+ `);
3350
+ if (e instanceof UsageError) io.stderr(`Run pbiplint --help for usage.
3351
+ `);
3352
+ return 2;
3353
+ }
3354
+ io.stderr(
3355
+ `pbiplint: unexpected error: ${e instanceof Error ? e.stack ?? e.message : String(e)}
3356
+ `
3357
+ );
3358
+ return 2;
3359
+ }
3360
+ }
3361
+
3362
+ // src/bin.ts
3363
+ process.stdout.on("error", (e) => {
3364
+ if (e.code === "EPIPE") process.exit(0);
3365
+ throw e;
3366
+ });
3367
+ main(process.argv.slice(2), {
3368
+ stdout: (s) => process.stdout.write(s),
3369
+ stderr: (s) => process.stderr.write(s),
3370
+ cwd: () => process.cwd()
3371
+ }).then((code) => {
3372
+ process.exitCode = code;
3373
+ }).catch((e) => {
3374
+ process.stderr.write(`pbiplint: ${e instanceof Error ? e.message : String(e)}
3375
+ `);
3376
+ process.exitCode = 2;
3377
+ });