pbiplint 0.2.1 → 0.2.2

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.
package/dist/pbiplint.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // ../core/src/version.ts
4
- var VERSION = "0.2.1";
4
+ var VERSION = "0.2.2";
5
5
 
6
6
  // ../core/src/engine/config.ts
7
7
  var ConfigError = class extends Error {
8
8
  };
9
9
  function bindConfig(config, rules) {
10
10
  const ruleByUpper = new Map(rules.map((r) => [r.id.toUpperCase(), r]));
11
- const bound = {
11
+ const bound2 = {
12
12
  disabled: /* @__PURE__ */ new Set(),
13
13
  severity: /* @__PURE__ */ new Map(),
14
14
  options: /* @__PURE__ */ new Map(),
@@ -18,12 +18,12 @@ function bindConfig(config, rules) {
18
18
  for (const id of config.disabled) {
19
19
  const real = ruleByUpper.get(id.toUpperCase())?.id;
20
20
  if (real === void 0) unknownRules.push(id);
21
- else bound.disabled.add(real);
21
+ else bound2.disabled.add(real);
22
22
  }
23
23
  for (const [id, severity] of config.severity) {
24
24
  const real = ruleByUpper.get(id.toUpperCase())?.id;
25
25
  if (real === void 0) unknownRules.push(id);
26
- else bound.severity.set(real, severity);
26
+ else bound2.severity.set(real, severity);
27
27
  }
28
28
  for (const [id, options] of config.options) {
29
29
  const rule = ruleByUpper.get(id.toUpperCase());
@@ -51,9 +51,9 @@ function bindConfig(config, rules) {
51
51
  `pbiplint.config.json: rules["${real}"].${name} must be one of ${decl.values.join(", ")}`
52
52
  );
53
53
  }
54
- bound.options.set(real, { ...options });
54
+ bound2.options.set(real, { ...options });
55
55
  }
56
- return { config: bound, unknownRules: [...new Set(unknownRules)] };
56
+ return { config: bound2, unknownRules: [...new Set(unknownRules)] };
57
57
  }
58
58
  var SEVERITY_BY_NAME = { info: 1, warning: 2, error: 3 };
59
59
  var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -205,12 +205,14 @@ function buildColumn(c, table) {
205
205
  }
206
206
  function buildMeasure(x, table) {
207
207
  const p = x.props;
208
+ const kpi = x.children.find((c) => c.type === "kpi")?.props;
208
209
  return {
209
210
  ...named(x),
210
211
  table,
211
212
  expression: x.value ?? "",
212
213
  formatString: str(p.formatstring),
213
214
  formatStringDefinition: str(p.formatstringdefinition),
215
+ kpiExpressions: kpi && [kpi.targetexpression, kpi.statusexpression, kpi.trendexpression].map(str).filter((e) => e !== void 0),
214
216
  isHidden: flag(p.ishidden),
215
217
  displayFolder: str(p.displayfolder)
216
218
  };
@@ -586,48 +588,215 @@ function buildReachabilityIndex(model, references, reportRefs) {
586
588
  };
587
589
  }
588
590
 
589
- // ../core/src/index/references.ts
590
- var QUALIFIED = /(?:'((?:[^']|'')+)'\s*|([\p{L}_][\p{L}\p{N}_]*))\[([^\]]+)\]/gu;
591
- var BARE = /\[([^\]]+)\]/g;
592
- function extractRefs(expression) {
593
- const out = [];
594
- const consumed = /* @__PURE__ */ new Set();
595
- for (const m of expression.matchAll(QUALIFIED)) {
596
- const table = m[1] !== void 0 ? m[1].replace(/''/g, "'") : m[2];
597
- out.push({ table, name: m[3], qualified: true });
598
- consumed.add(m.index + m[0].length - m[3].length - 2);
591
+ // ../core/src/dax/tokenize.ts
592
+ var OPERATORS = ["==", "<>", "<=", ">=", "&&", "||", "=", "<", ">", "+", "-", "*", "/", "^", "&"];
593
+ var NUMBER = /(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/y;
594
+ var IDENTIFIER = /[\p{L}_][\p{L}\p{M}\p{N}_.]*/uy;
595
+ var WORD_CHAR = /[\p{L}\p{M}\p{N}_]/u;
596
+ var DIGIT = /[0-9]/;
597
+ var isWord = (t, word) => t?.kind === "identifier" && t.text.toUpperCase() === word;
598
+ var isPunctuation = (t, char) => t?.kind === "punctuation" && t.text === char;
599
+ function quoted(text2, from, close) {
600
+ let value = "";
601
+ let j = from + 1;
602
+ while (j < text2.length) {
603
+ const c = text2[j];
604
+ if (c === close) {
605
+ if (text2[j + 1] !== close) return { value, end: j + 1 };
606
+ j++;
607
+ }
608
+ value += c;
609
+ j++;
610
+ }
611
+ return { value, end: text2.length };
612
+ }
613
+ function tokenizeDax(expression) {
614
+ const s = expression;
615
+ const tokens = [];
616
+ const push2 = (kind, text2, start, end) => {
617
+ tokens.push({ kind, text: text2, start, end, depth: 0 });
618
+ };
619
+ let i = 0;
620
+ while (i < s.length) {
621
+ const c = s[i];
622
+ if (/\s/.test(c)) {
623
+ i++;
624
+ } else if (s.startsWith("//", i) || s.startsWith("--", i)) {
625
+ const nl = s.indexOf("\n", i);
626
+ i = nl === -1 ? s.length : nl;
627
+ } else if (s.startsWith("/*", i)) {
628
+ const close = s.indexOf("*/", i + 2);
629
+ i = close === -1 ? s.length : close + 2;
630
+ } else if (c === '"') {
631
+ const q2 = quoted(s, i, '"');
632
+ push2("string", q2.value, i, q2.end);
633
+ i = q2.end;
634
+ } else if ((c === "d" || c === "D") && (s[i + 1] === "t" || s[i + 1] === "T") && s[i + 2] === '"' && !(i > 0 && WORD_CHAR.test(s[i - 1]))) {
635
+ const q2 = quoted(s, i + 2, '"');
636
+ push2("date", q2.value, i, q2.end);
637
+ i = q2.end;
638
+ } else if (c === "'" || c === "[") {
639
+ const q2 = quoted(s, i, c === "'" ? "'" : "]");
640
+ push2(c === "'" ? "table" : "column", q2.value, i, q2.end);
641
+ i = q2.end;
642
+ } else if (DIGIT.test(c) || c === "." && DIGIT.test(s[i + 1] ?? "")) {
643
+ NUMBER.lastIndex = i;
644
+ const text2 = NUMBER.exec(s)[0];
645
+ push2("number", text2, i, i + text2.length);
646
+ i += text2.length;
647
+ } else {
648
+ IDENTIFIER.lastIndex = i;
649
+ const id = IDENTIFIER.exec(s)?.[0];
650
+ const op = id === void 0 ? OPERATORS.find((o) => s.startsWith(o, i)) : void 0;
651
+ const text2 = id ?? op ?? c;
652
+ push2(
653
+ id !== void 0 ? "identifier" : op !== void 0 ? "operator" : "punctuation",
654
+ text2,
655
+ i,
656
+ i + text2.length
657
+ );
658
+ i += text2.length;
659
+ }
599
660
  }
600
- for (const m of expression.matchAll(BARE)) {
601
- if (consumed.has(m.index)) continue;
602
- out.push({ name: m[1], qualified: false });
661
+ annotate(tokens);
662
+ return tokens;
663
+ }
664
+ function annotate(tokens) {
665
+ const open = [];
666
+ const args = [];
667
+ const place = (t) => {
668
+ t.depth = open.length;
669
+ if (open.length === 0) return;
670
+ t.parent = open[open.length - 1];
671
+ t.arg = args[args.length - 1];
672
+ };
673
+ tokens.forEach((t, k) => {
674
+ if (isPunctuation(t, "(") || isPunctuation(t, "{")) {
675
+ place(t);
676
+ const before = tokens[k - 1];
677
+ if (t.text === "(" && before?.kind === "identifier") t.call = before.text.toUpperCase();
678
+ open.push(k);
679
+ args.push(0);
680
+ } else if (isPunctuation(t, ")") || isPunctuation(t, "}")) {
681
+ const o = open.pop();
682
+ args.pop();
683
+ if (o !== void 0) {
684
+ tokens[o].close = k;
685
+ t.open = o;
686
+ }
687
+ place(t);
688
+ } else {
689
+ place(t);
690
+ if (isPunctuation(t, ",") && args.length > 0) args[args.length - 1] = args.at(-1) + 1;
691
+ }
692
+ });
693
+ }
694
+ function opensBlock(tokens, k) {
695
+ if (isWord(tokens[k - 1], "RETURN")) return true;
696
+ const eq = tokens[k - 1];
697
+ return isWord(tokens[k - 3], "VAR") && tokens[k - 2]?.kind === "identifier" && eq?.kind === "operator" && eq.text === "=";
698
+ }
699
+ function daxVariables(tokens) {
700
+ const out = [];
701
+ tokens.forEach((t, k) => {
702
+ const name = tokens[k + 1];
703
+ const eq = tokens[k + 2];
704
+ if (!isWord(t, "VAR") || name?.kind !== "identifier" || eq?.kind !== "operator") return;
705
+ if (eq.text !== "=") return;
706
+ const from = k + 3;
707
+ let to = from;
708
+ let nested = 0;
709
+ for (; to < tokens.length; to++) {
710
+ const x = tokens[to];
711
+ if (x.depth < t.depth) break;
712
+ if (x.depth !== t.depth) continue;
713
+ if (isWord(x, "VAR")) {
714
+ if (opensBlock(tokens, to)) nested++;
715
+ else if (nested === 0) break;
716
+ } else if (isWord(x, "RETURN")) {
717
+ if (nested === 0) break;
718
+ nested--;
719
+ }
720
+ }
721
+ let blockEnd2 = to;
722
+ for (; blockEnd2 < tokens.length; blockEnd2++) {
723
+ const x = tokens[blockEnd2];
724
+ if (x.depth < t.depth || x.depth === t.depth && isPunctuation(x, ",")) break;
725
+ }
726
+ out.push({ name: name.text, at: k, from, to, blockEnd: blockEnd2 });
727
+ });
728
+ for (const v of out)
729
+ for (const d of out) if (d.from <= v.at && v.at < d.to && d.to < v.blockEnd) v.blockEnd = d.to;
730
+ return out;
731
+ }
732
+ function variableAt(vars, name, use) {
733
+ const key4 = name.toUpperCase();
734
+ let found;
735
+ for (const v of vars)
736
+ if (v.name.toUpperCase() === key4 && v.at < use && use < v.blockEnd && !(v.from <= use && use < v.to))
737
+ found = v;
738
+ return found;
739
+ }
740
+
741
+ // ../core/src/index/references.ts
742
+ var CREATES_COLUMNS = /* @__PURE__ */ new Set([
743
+ "ADDCOLUMNS",
744
+ "SELECTCOLUMNS",
745
+ "SUMMARIZE",
746
+ "SUMMARIZECOLUMNS",
747
+ "ROW",
748
+ "DATATABLE"
749
+ ]);
750
+ function createdColumns(tokens) {
751
+ const out = /* @__PURE__ */ new Map();
752
+ for (const t of tokens) {
753
+ if (t.kind !== "string" || t.parent === void 0) continue;
754
+ const open = tokens[t.parent];
755
+ if (open.call === void 0 || !CREATES_COLUMNS.has(open.call)) continue;
756
+ const name = t.text.toLowerCase();
757
+ out.set(name, [...out.get(name) ?? [], [t.parent, open.close ?? tokens.length]]);
603
758
  }
604
759
  return out;
605
760
  }
761
+ function refsInTokens(tokens) {
762
+ const created = createdColumns(tokens);
763
+ const out = [];
764
+ tokens.forEach((t, k) => {
765
+ if (t.kind !== "column") return;
766
+ const before = tokens[k - 1];
767
+ if (isPunctuation(before, ".") && tokens[k - 2]?.kind === "column") return;
768
+ if (before?.kind === "table" || before?.kind === "identifier" && before.end === t.start) {
769
+ out.push({ table: before.text, name: t.text, qualified: true });
770
+ return;
771
+ }
772
+ const calls = created.get(t.text.toLowerCase());
773
+ if (calls?.every(([open, close]) => k < open || k > close))
774
+ out.push({ name: t.text, qualified: false, created: true });
775
+ else out.push({ name: t.text, qualified: false });
776
+ });
777
+ return out;
778
+ }
606
779
  var lower2 = (s) => s.toLowerCase();
607
780
  var key = (table, name) => `${lower2(table)} ${lower2(name)}`;
608
- var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
609
781
  function functionCallReader(functions) {
610
782
  if (functions.length === 0) return () => [];
611
783
  const byName2 = /* @__PURE__ */ new Map();
612
784
  for (const f of functions) if (!byName2.has(lower2(f.name))) byName2.set(lower2(f.name), f);
613
- const call = new RegExp(
614
- `(^|[^\\p{L}\\p{N}_.])(${[...byName2.keys()].map(escapeRegExp).join("|")})(?=\\s*\\()`,
615
- "giu"
616
- );
617
785
  const order3 = new Map(functions.map((f, i) => [f, i]));
618
- return (expression) => {
786
+ return (tokens) => {
619
787
  const found = /* @__PURE__ */ new Set();
620
- for (const m of expression.matchAll(call)) {
621
- const f = byName2.get(lower2(m[2]));
788
+ tokens.forEach((t, k) => {
789
+ const f = t.call === void 0 ? void 0 : byName2.get(lower2(tokens[k - 1].text));
622
790
  if (f) found.add(f);
623
- }
791
+ });
624
792
  return [...found].sort((a, b) => order3.get(a) - order3.get(b));
625
793
  };
626
794
  }
627
- function resolveBareName(name, owner, lookup) {
795
+ function resolveBareName(ref, owner, lookup) {
796
+ const { name } = ref;
628
797
  const measure = lookup.measureNamed(name);
629
798
  if (measure !== void 0) return { kind: "measure", measure };
630
- if (owner.kind === "calculationItem") return { kind: "none" };
799
+ if (ref.created || owner.kind === "calculationItem") return { kind: "none" };
631
800
  if (owner.kind === "function") {
632
801
  const columns2 = lookup.tables.flatMap((t) => lookup.columnOf(t, name) ?? []);
633
802
  return columns2.length > 0 ? { kind: "columns", columns: columns2 } : { kind: "none" };
@@ -664,7 +833,7 @@ function buildReferenceIndex(model) {
664
833
  if (meas) return { kind: "measure", table: t.name, name: meas.name, qualified: true };
665
834
  return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
666
835
  }
667
- const bare = resolveBareName(raw.name, { kind: ownerKind, table: ownerTable }, lookup);
836
+ const bare = resolveBareName(raw, { kind: ownerKind, table: ownerTable }, lookup);
668
837
  if (bare.kind === "measure")
669
838
  return {
670
839
  kind: "measure",
@@ -686,19 +855,26 @@ function buildReferenceIndex(model) {
686
855
  const byObject = /* @__PURE__ */ new Map();
687
856
  const add = (of, ownerTable, ...expressions) => {
688
857
  const expression = expressions.filter((e) => e !== void 0).join("\n");
858
+ const tokens = tokenizeDax(expression);
689
859
  const owner = {
690
860
  ...of,
691
861
  ownerTable,
692
862
  expression,
693
- refs: extractRefs(expression).flatMap((r) => resolve4(r, ownerTable, of.kind)),
694
- calls: callsIn(expression)
863
+ refs: refsInTokens(tokens).flatMap((r) => resolve4(r, ownerTable, of.kind)),
864
+ calls: callsIn(tokens)
695
865
  };
696
866
  owners.push(owner);
697
867
  byObject.set(of.object, owner);
698
868
  };
699
869
  for (const t of model.tables) {
700
870
  for (const m of t.measures)
701
- add({ kind: "measure", object: m }, t, m.expression, m.formatStringDefinition);
871
+ add(
872
+ { kind: "measure", object: m },
873
+ t,
874
+ m.expression,
875
+ m.formatStringDefinition,
876
+ ...m.kpiExpressions ?? []
877
+ );
702
878
  for (const c of t.columns)
703
879
  if (c.kind === "calculated") add({ kind: "calculatedColumn", object: c }, t, c.expression);
704
880
  if (t.kind === "calculated")
@@ -932,9 +1108,14 @@ function buildReportReferenceIndex(report, model) {
932
1108
  return inModel ? { table: inModel.table.name, name: inModel.name } : reportMeasuresByName.get(lower3(name));
933
1109
  }
934
1110
  };
1111
+ const callsIn = functionCallReader(model?.functions ?? []);
1112
+ const functionCalls = [];
935
1113
  for (const m of report.measures) {
936
1114
  const owner = { kind: "reportMeasure", object: m };
937
- for (const raw of extractRefs(m.expression)) {
1115
+ const tokens = tokenizeDax(m.expression);
1116
+ const calls = callsIn(tokens);
1117
+ if (calls.length > 0) functionCalls.push({ measure: m, calls });
1118
+ for (const raw of refsInTokens(tokens)) {
938
1119
  if (raw.qualified) {
939
1120
  const t = tables.get(lower3(raw.table));
940
1121
  const kind = t && measureOf(t, raw.name) || reportMeasures.has(`${lower3(raw.table)}\0${lower3(raw.name)}`) ? "measure" : "column";
@@ -942,7 +1123,7 @@ function buildReportReferenceIndex(report, model) {
942
1123
  continue;
943
1124
  }
944
1125
  const bare = resolveBareName(
945
- raw.name,
1126
+ raw,
946
1127
  { kind: "reportMeasure", table: tables.get(lower3(m.table)) },
947
1128
  bareLookup
948
1129
  );
@@ -970,19 +1151,8 @@ function buildReportReferenceIndex(report, model) {
970
1151
  });
971
1152
  }
972
1153
  }
973
- const callsIn = functionCallReader(model?.functions ?? []);
974
- const functionCalls = report.measures.map((measure) => ({ measure, calls: callsIn(measure.expression) })).filter((c) => c.calls.length > 0);
975
- const byTarget = /* @__PURE__ */ new Map();
976
- for (const r of refs) {
977
- const target = r.resolution.kind === "column" ? r.resolution.column : r.resolution.kind === "measure" ? r.resolution.measure : void 0;
978
- if (!target) continue;
979
- const arr = byTarget.get(target) ?? [];
980
- arr.push(r);
981
- byTarget.set(target, arr);
982
- }
983
1154
  return {
984
1155
  refs,
985
- referencedBy: (target) => byTarget.get(target) ?? [],
986
1156
  unresolved: () => refs.filter((r) => r.resolution.kind === "unresolved"),
987
1157
  fieldsOf: (v) => refs.filter((r) => r.owner.kind === "visualField" && r.owner.object === v),
988
1158
  functionCalls
@@ -1037,7 +1207,7 @@ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1037
1207
  var kindOf = (v) => v === null ? "null" : Array.isArray(v) ? "an array" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : "a boolean";
1038
1208
  var CONFLICT_MARKER = /^(?:<{7}|={7}|>{7})(?:\s|$)/;
1039
1209
  var LINE_BREAK = /\r\n?|\n/;
1040
- function quoted(line) {
1210
+ function quoted2(line) {
1041
1211
  if (line === void 0 || line.length <= 120) return line ?? "";
1042
1212
  const text2 = line.trimStart();
1043
1213
  if (text2.length <= 120) return text2;
@@ -1086,7 +1256,7 @@ function readJson(file, text2, options = {}) {
1086
1256
  const body = text2.charCodeAt(0) === 65279 ? text2.slice(1) : text2;
1087
1257
  const lines = body.split(LINE_BREAK);
1088
1258
  const issues = lines.flatMap(
1089
- (line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text: quoted(line), reason: "merge conflict marker" }] : []
1259
+ (line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text: quoted2(line), reason: "merge conflict marker" }] : []
1090
1260
  );
1091
1261
  if (issues.length > 0) return { json: void 0, issues };
1092
1262
  let json;
@@ -1098,7 +1268,7 @@ function readJson(file, text2, options = {}) {
1098
1268
  const flat = message.replace(/\s+/g, " ");
1099
1269
  return {
1100
1270
  json: void 0,
1101
- issues: [{ file, line, text: quoted(lines[line - 1]), reason: `not valid JSON (${flat})` }]
1271
+ issues: [{ file, line, text: quoted2(lines[line - 1]), reason: `not valid JSON (${flat})` }]
1102
1272
  };
1103
1273
  }
1104
1274
  const deep = tooDeepAt(body);
@@ -1110,7 +1280,7 @@ function readJson(file, text2, options = {}) {
1110
1280
  {
1111
1281
  file,
1112
1282
  line,
1113
- text: quoted(lines[line - 1]),
1283
+ text: quoted2(lines[line - 1]),
1114
1284
  reason: `nested more than ${MAX_DEPTH} levels deep`
1115
1285
  }
1116
1286
  ]
@@ -1125,7 +1295,7 @@ function readJson(file, text2, options = {}) {
1125
1295
  {
1126
1296
  file,
1127
1297
  line: start + 1,
1128
- text: quoted(lines[start]),
1298
+ text: quoted2(lines[start]),
1129
1299
  reason: `not a JSON object (the file holds ${kindOf(json)})`
1130
1300
  }
1131
1301
  ]
@@ -1357,13 +1527,16 @@ function filtersOf(filterConfig, file, pointer) {
1357
1527
  if (!isRecord4(f)) return [];
1358
1528
  const p = `${pointer}/filters/${i}`;
1359
1529
  const field = collectFieldRefs(f.field, `${p}/field`)[0];
1530
+ const where = isRecord4(f.filter) && Array.isArray(f.filter.Where) ? f.filter.Where : void 0;
1360
1531
  return [
1361
1532
  {
1362
1533
  name: str2(f.name) ?? String(i),
1363
1534
  type: str2(f.type),
1535
+ howCreated: str2(f.howCreated),
1364
1536
  ...field ? { field } : {},
1365
1537
  refs: collectFieldRefs(f, p),
1366
1538
  applied: isRecord4(f.filter),
1539
+ ...where ? { where } : {},
1367
1540
  file,
1368
1541
  pointer: p
1369
1542
  }
@@ -1533,6 +1706,8 @@ function buildBookmark(id, file, text2, json) {
1533
1706
  visual,
1534
1707
  pointer: `/explorationState/sections/${escapePointer(page)}/visualContainers/${escapePointer(visual)}`
1535
1708
  });
1709
+ const options = isRecord4(json.options) ? json.options : {};
1710
+ const names = Array.isArray(options.targetVisualNames) ? options.targetVisualNames : [];
1536
1711
  return {
1537
1712
  id: str2(json.name) ?? id,
1538
1713
  displayName: str2(json.displayName) ?? id,
@@ -1541,6 +1716,11 @@ function buildBookmark(id, file, text2, json) {
1541
1716
  ...str2(state.activeSection) !== void 0 ? { activePage: str2(state.activeSection) } : {},
1542
1717
  pages: Object.keys(sections),
1543
1718
  visuals,
1719
+ ...options.applyOnlyToTargetVisuals === true ? {
1720
+ targetVisuals: names.flatMap(
1721
+ (visual, i) => typeof visual === "string" ? [{ visual, pointer: `/options/targetVisualNames/${i}` }] : []
1722
+ )
1723
+ } : {},
1544
1724
  refs: collectFieldRefs(state, "/explorationState")
1545
1725
  };
1546
1726
  }
@@ -1893,7 +2073,7 @@ var dataType = (c) => (c.dataType ?? "").toLowerCase();
1893
2073
  var isNumericType = (c) => ["int64", "decimal", "double"].includes(dataType(c));
1894
2074
  var hiddenOrTableHidden = (c) => c.isHidden || c.table.isHidden;
1895
2075
  var isBlank = (s) => s === void 0 || s.trim() === "";
1896
- var escapeRegExp2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2076
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1897
2077
  var isDirectQueryTable = (t) => t.kind === "table" && t.partitions[0]?.mode === "directquery";
1898
2078
  var modelPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropObjects));
1899
2079
  var tablesPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropTableLine));
@@ -1986,6 +2166,13 @@ var finding = {
1986
2166
  location: e.location,
1987
2167
  object: e
1988
2168
  }),
2169
+ /** A user-defined function, named bare (`Local.AddTax`) as Tabular Editor names it. */
2170
+ function: (f) => ({
2171
+ objectType: "Function",
2172
+ objectName: f.name,
2173
+ location: f.location,
2174
+ object: f
2175
+ }),
1989
2176
  dataSource: (d) => ({
1990
2177
  objectType: "DataSource",
1991
2178
  objectName: d.name,
@@ -2059,6 +2246,11 @@ var reportMeasureLabel = (m) => `[${m.name}] (report)`;
2059
2246
  var pageFilterLabel = (p) => `Page filter on "${p.displayName}"`;
2060
2247
  var REPORT_LABEL = "Report";
2061
2248
  var REPORT_FILTER_LABEL = "Report filter";
2249
+ var fieldLabel = (ref) => {
2250
+ const inTable = (name) => ref.table === "" ? measureRef(name) : columnRef(ref.table, name);
2251
+ const field = ref.variation ? `${inTable(ref.variation.column)}.${measureRef(ref.name)}` : ref.kind === "measure" ? measureRef(ref.name) : inTable(ref.name);
2252
+ return ref.kind === "hierarchyLevel" && ref.level ? `${field}.${measureRef(ref.level)}` : field;
2253
+ };
2062
2254
 
2063
2255
  // ../core/src/rules/report-helpers.ts
2064
2256
  var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -3304,7 +3496,7 @@ var RULE_SUMMARIES = {
3304
3496
  "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.",
3305
3497
  AVOID_USING_THE_IFERROR_FUNCTION: "Measures and calculated columns that call IFERROR.",
3306
3498
  BROKEN_ACTION_TARGET: "Buttons, shapes, and images whose page navigation, drillthrough, or bookmark action names a page or a bookmark the report does not have, matched against the `name` in each page.json and bookmark file.",
3307
- BROKEN_BOOKMARK_REFERENCE: "Bookmarks whose active page, or another page they capture, is not in the report, or which capture a visual that is not on its page, matched against the `name` in each page.json and visual.json.",
3499
+ BROKEN_BOOKMARK_REFERENCE: "Bookmarks whose active page, or another page they capture, is not in the report, which capture a visual that is not on its page, or which apply only to selected visuals and name one that is not on their active page, matched against the `name` in each page.json and visual.json.",
3308
3500
  BROKEN_FIELD_REFERENCE: "References in the report to a table, column, measure, hierarchy, or hierarchy level that the model does not have, wherever the report names a field: a visual's wells, formatting, and sort, a filter on a visual, a page, or the whole report, a drillthrough or tooltip page's fields, and a bookmark.",
3309
3501
  CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS: "Calculation groups that contain no calculation items.",
3310
3502
  "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.",
@@ -3328,6 +3520,7 @@ var RULE_SUMMARIES = {
3328
3520
  FIX_REFERENTIAL_INTEGRITY_VIOLATIONS: "Relationships where the many side holds key values that do not exist on the one side. The count of offending rows is a statistic of the loaded data, not of the model files, so pbiplint lists this rule but does not run it: it needs statistics that only a live model carries.",
3329
3521
  "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 the word Flag after a space and whose type is not text.",
3330
3522
  HARDCODED_PERIOD_IN_DAX: "Measures, calculated columns, and calculation items whose DAX fixes a year or a date, and date tables whose CALENDAR ends on a fixed date.",
3523
+ HARDCODED_YEAR_IN_FILTER: "Filters in the Filters pane, on a visual, a page, or all pages, that hold a year column to fixed years: years picked in Basic filtering, a year set with Advanced filtering's is, years kept up to one with is less than or is less than or equal to, and a range between two years.",
3331
3524
  HIDDEN_VISUAL_WITH_FIELDS: "Visuals hidden in the Selection pane, with their own eye icon or with that of a group they sit in, that still have fields in their wells.",
3332
3525
  HIDE_FACT_TABLE_COLUMNS: "Visible numeric columns that a measure aggregates directly with a fully qualified reference, such as `SUM('Sales'[Amount])`. COUNT, COUNTBLANK, SUM, AVERAGE, MIN, MAX, DISTINCTCOUNT, VALUES, DISTINCT, and the A-suffixed COUNTA, AVERAGEA, MAXA, and MINA count as aggregations.",
3333
3526
  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.",
@@ -3382,6 +3575,9 @@ var RULE_SUMMARIES = {
3382
3575
  SPLIT_DATE_AND_TIME: "DateTime columns holding values that are not at midnight. Whether any row carries a time is a statistic of the loaded data, not of the model files, so pbiplint lists this rule but does not run it: it needs statistics that only a live model carries.",
3383
3576
  TAB_ORDER_FOLLOWS_LAYOUT: "Pages whose tab order, the order keyboard users move through the visuals in, disagrees with the order the layout reads in: rows from top to bottom, and left to right within a row. The rule checks this only when the project's policy asks for tab order to follow the layout, and reports nothing without it.",
3384
3577
  TRIM_OBJECT_NAMES: "Names that start or end with a space, across every named object type in the model.",
3578
+ UDF_NOT_CALLED: "User-defined functions that no measure, calculated column, calculated table, calculation item, row-level security filter, format string expression, KPI, or other function in the model calls. The functions of a DAX Lib package, which share one `DAXLIB_PackageId` annotation, count as one: the package is reported once, on its first function, when nothing outside it calls any of them.",
3579
+ UDF_USE_COMPOUND_NAMES: "User-defined functions whose name holds neither a dot nor an underscore, such as `AddTax`. Tabular Editor 3 has a built-in rule with the same test.",
3580
+ UDF_WITHOUT_DESCRIPTION: "User-defined functions with no description, or one of only spaces, other than functions installed from a DAX Lib package. Tabular Editor 3 has a built-in rule with the same test, which also reports package functions.",
3385
3581
  UNNECESSARY_COLUMNS: "Hidden columns, or columns in hidden tables, that nothing references: no DAX expression, relationship, hierarchy, sort-by column, group-by column, row-level security filter, or object-level security rule.",
3386
3582
  UNNECESSARY_MEASURES: "Hidden measures, or measures on hidden tables, that no DAX expression references.",
3387
3583
  "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.",
@@ -3413,6 +3609,7 @@ var SCOPE_MAP = {
3413
3609
  NamedExpression: "NamedExpression",
3414
3610
  ProviderDataSource: "DataSource",
3415
3611
  StructuredDataSource: "DataSource",
3612
+ UserDefinedFunction: "Function",
3416
3613
  KPI: null
3417
3614
  };
3418
3615
  function mapScope(scope) {
@@ -3580,7 +3777,7 @@ var HIDE_FACT_TABLE_COLUMNS = bpaRule("HIDE_FACT_TABLE_COLUMNS", (m) => {
3580
3777
  return columns(m, (c) => {
3581
3778
  if (c.isHidden || !isNumericType(c)) return false;
3582
3779
  const re = new RegExp(
3583
- `(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${escapeRegExp2(c.table.name)}'*\\[${escapeRegExp2(c.name)}\\]\\s*\\)`,
3780
+ `(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${escapeRegExp(c.table.name)}'*\\[${escapeRegExp(c.name)}\\]\\s*\\)`,
3584
3781
  "i"
3585
3782
  );
3586
3783
  return measures.some((x) => re.test(x.expression));
@@ -3925,7 +4122,7 @@ var INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED = bpaRule(
3925
4122
  return m.relationships.filter((r) => {
3926
4123
  if (r.isActive) return false;
3927
4124
  const re = new RegExp(
3928
- `USERELATIONSHIP\\s*\\(\\s*'*${escapeRegExp2(r.fromTable)}'*\\[${escapeRegExp2(r.fromColumn)}\\]\\s*,\\s*'*${escapeRegExp2(r.toTable)}'*\\[${escapeRegExp2(r.toColumn)}\\]`,
4125
+ `USERELATIONSHIP\\s*\\(\\s*'*${escapeRegExp(r.fromTable)}'*\\[${escapeRegExp(r.fromColumn)}\\]\\s*,\\s*'*${escapeRegExp(r.toTable)}'*\\[${escapeRegExp(r.toColumn)}\\]`,
3929
4126
  "i"
3930
4127
  );
3931
4128
  return !expressions.some((e) => re.test(e));
@@ -4107,7 +4304,7 @@ var AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE = bpaRule(
4107
4304
  return tablesInScope(m).filter((t) => {
4108
4305
  if (!permissions.some((tp) => tp.table === t.name && tp.filter !== void 0)) return false;
4109
4306
  const re = new RegExp(
4110
- `USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${escapeRegExp2(t.name)}'*\\[`,
4307
+ `USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${escapeRegExp(t.name)}'*\\[`,
4111
4308
  "i"
4112
4309
  );
4113
4310
  return measures.some((x) => re.test(x.expression));
@@ -4489,404 +4686,34 @@ function pbiplintRule(spec) {
4489
4686
  };
4490
4687
  }
4491
4688
 
4492
- // ../core/src/rules/pbiplint/actions.ts
4493
- var CHECKED = /* @__PURE__ */ new Map([
4494
- ["pagenavigation", { action: "Page navigation", object: "page" }],
4495
- ["drillthrough", { action: "Drillthrough", object: "page" }],
4496
- ["bookmark", { action: "Bookmark", object: "bookmark" }]
4497
- ]);
4498
- var BROKEN_ACTION_TARGET = pbiplintRule({
4499
- id: "BROKEN_ACTION_TARGET",
4500
- name: "Action points at nothing",
4501
- category: "Error Prevention",
4502
- severity: 3,
4503
- scope: ["Visual"],
4504
- layer: "report",
4505
- check: ({ report }) => {
4506
- if (!report) return [];
4507
- const pages = new Set(report.pages.map((p) => p.id));
4508
- const bookmarks = new Set(report.bookmarks.map((b) => b.id));
4509
- const exists = {
4510
- page: (name) => pages.has(name) || pageUnread(report, name),
4511
- bookmark: (name) => bookmarks.has(name) || bookmarkUnread(report, name)
4512
- };
4513
- return allVisuals(report).flatMap(
4514
- (v) => v.actions.flatMap((a) => {
4515
- const checked = CHECKED.get(a.type.toLowerCase());
4516
- if (!checked || !a.on || a.target === void 0) return [];
4517
- if (exists[checked.object](a.target)) return [];
4518
- return [
4519
- reportFinding.visual(
4520
- v,
4521
- a.pointer,
4522
- `${checked.action} action points at ${checked.object} "${a.target}", which does not exist`
4523
- )
4524
- ];
4525
- })
4526
- );
4527
- }
4528
- });
4529
- var ACTION_WITHOUT_DESTINATION = pbiplintRule({
4530
- id: "ACTION_WITHOUT_DESTINATION",
4531
- name: "Action has no destination",
4532
- category: "Report Design",
4533
- severity: 2,
4534
- scope: ["Visual"],
4535
- layer: "report",
4536
- // The same three types as BROKEN_ACTION_TARGET, on any visual, hidden or not. The pointer is the
4537
- // destination property when it is there and empty, else the entry's properties.
4538
- check: ({ report }) => report ? allVisuals(report).flatMap(
4539
- (v) => v.actions.flatMap((a) => {
4540
- const checked = CHECKED.get(a.type.toLowerCase());
4541
- if (!checked || !a.on || a.target !== void 0 || a.conditional) return [];
4542
- return [
4543
- reportFinding.visual(v, a.pointer, `${checked.action} action has no destination`)
4544
- ];
4545
- })
4546
- ) : []
4547
- });
4548
- var SECTIONS = "/explorationState/sections";
4549
- var BROKEN_BOOKMARK_REFERENCE = pbiplintRule({
4550
- id: "BROKEN_BOOKMARK_REFERENCE",
4551
- name: "Bookmark refers to a missing page or visual",
4552
- category: "Error Prevention",
4553
- severity: 2,
4554
- scope: ["Bookmark"],
4555
- layer: "report",
4556
- // Groups are captured apart from visuals and are not read, nor is the list of target visuals.
4557
- // A page or a visual whose own file could not be read is there, under the folder name Desktop
4558
- // gives it, so it is never reported missing, and neither is one a folder that could not be read
4559
- // could hold.
4560
- check: ({ report }) => {
4561
- if (!report) return [];
4562
- const pages = new Map(report.pages.map((p) => [p.id, p]));
4563
- const missing = (id) => !pages.has(id) && !pageUnread(report, id);
4564
- return report.bookmarks.flatMap((b) => {
4565
- const out = [];
4566
- if (b.activePage !== void 0 && missing(b.activePage))
4567
- out.push(
4568
- reportFinding.bookmark(
4569
- b,
4570
- `active page "${b.activePage}" does not exist`,
4571
- "/explorationState/activeSection"
4572
- )
4573
- );
4574
- for (const id of b.pages)
4575
- if (id !== b.activePage && missing(id))
4576
- out.push(
4577
- reportFinding.bookmark(
4578
- b,
4579
- `captured page "${id}" does not exist`,
4580
- `${SECTIONS}/${escapePointer(id)}`
4581
- )
4582
- );
4583
- for (const { page, visual, pointer } of b.visuals) {
4584
- const p = pages.get(page);
4585
- if (p && !p.visuals.some((v) => v.id === visual) && !visualUnread(report, p, visual))
4586
- out.push(
4587
- reportFinding.bookmark(
4588
- b,
4589
- `captured visual "${visual}" is not on page "${p.displayName}"`,
4590
- pointer
4591
- )
4592
- );
4593
- }
4594
- return out;
4595
- });
4596
- }
4597
- });
4598
- var actionRules = [
4599
- BROKEN_ACTION_TARGET,
4600
- ACTION_WITHOUT_DESTINATION,
4601
- BROKEN_BOOKMARK_REFERENCE
4602
- ];
4603
-
4604
- // ../core/src/rules/pbiplint/measures.ts
4605
- var REPORT_LEVEL_MEASURES = pbiplintRule({
4606
- id: "REPORT_LEVEL_MEASURES",
4607
- name: "Measure defined in the report",
4608
- category: "Maintenance",
4609
- severity: 2,
4610
- scope: ["ReportMeasure"],
4611
- layer: "report",
4612
- // Its findings are report objects, yet it reports only beside the model the report reads, so a
4613
- // run without the model skips it rather than counting it as run.
4614
- needs: ["model", "report"],
4615
- // The detail says where the measure lives; the rule's page gives the fix.
4616
- check: (project) => reportMeasuresToMove(project).map(
4617
- (m) => reportFinding.reportMeasure(m, `defined in the report on table "${m.table}"`)
4618
- )
4619
- });
4620
- var measureRules2 = [REPORT_LEVEL_MEASURES];
4621
-
4622
- // ../core/src/rules/pbiplint/opening.ts
4623
- var LANDING_PAGE_NOT_SET = pbiplintRule({
4624
- id: "LANDING_PAGE_NOT_SET",
4625
- name: "No landing page set",
4626
- category: "Report Design",
4627
- severity: 1,
4628
- scope: ["Report"],
4629
- layer: "report",
4630
- check: ({ report }) => {
4631
- const opens = report && landingPageNotSet(report) ? openingPage(report) : void 0;
4632
- if (!report || !opens) return [];
4633
- const pointer = opens.by === "active" ? "/activePageName" : void 0;
4634
- if (opens.page === void 0 && !opens.unread)
4635
- return [
4636
- reportFinding.pagesHeader(
4637
- report,
4638
- pointer,
4639
- `no landing page set; the active page "${opens.name}" does not exist`
4640
- )
4641
- ];
4642
- const name = opens.page?.displayName ?? opens.name;
4643
- const which = opens.by === "first" ? "the first page" : "the page open when it was saved";
4644
- return [reportFinding.pagesHeader(report, pointer, `opens on "${name}", ${which}`)];
4645
- }
4646
- });
4647
- var OPENING_PAGE_INVALID = pbiplintRule({
4648
- id: "OPENING_PAGE_INVALID",
4649
- name: "Opening page missing or hidden",
4650
- category: "Error Prevention",
4651
- severity: 3,
4652
- scope: ["Report"],
4653
- layer: "report",
4654
- check: ({ report }) => {
4655
- const opens = report && openingPage(report);
4656
- if (!report || !opens || !openingPageInvalid(opens)) return [];
4657
- const pointer = opens.by === "landing" ? "/landingPageName" : "/activePageName";
4658
- const detail = opens.page === void 0 ? `${opens.by} page "${opens.name}" does not exist` : `active page "${opens.page.displayName}" is hidden from readers`;
4659
- return [reportFinding.pagesHeader(report, pointer, detail)];
4660
- }
4661
- });
4662
- var FILTERS_PANE_STATE = pbiplintRule({
4663
- id: "FILTERS_PANE_STATE",
4664
- name: "Filters pane state differs from policy",
4665
- category: "Report Design",
4666
- severity: 2,
4667
- scope: ["Report"],
4668
- layer: "report",
4669
- options: [{ name: "expect", type: "string", values: ["open", "closed"] }],
4670
- check: ({ report }, ctx) => {
4671
- const expect = ctx.options.expect;
4672
- if (!report || expect === void 0) return [];
4673
- const pane = filtersPaneState(report);
4674
- if (!pane || pane.state === expect) return [];
4675
- const { state, recordedAt } = pane;
4676
- const saved = recordedAt === void 0 ? `not recorded, read as ${state}` : `saved ${state}`;
4677
- return [
4678
- reportFinding.report(report, `${saved}; the policy expects ${expect}`, "report", recordedAt)
4679
- ];
4680
- }
4681
- });
4682
- var openingRules = [LANDING_PAGE_NOT_SET, OPENING_PAGE_INVALID, FILTERS_PANE_STATE];
4683
-
4684
- // ../core/src/rules/pbiplint/pages.ts
4685
- var NEW_PAGE = "is the name Power BI Desktop gives a new page";
4686
- var DUPLICATE = "is the name Power BI Desktop gives a duplicated page";
4687
- var DEFAULT_NAMES = [
4688
- [/^(?:Page|Seite|Página|Pagina|ページ) [1-9]\d*$/, NEW_PAGE],
4689
- [/^(?:Duplicate of|Duplicado de|Doublon de|Duplicata de|Duplikat av) .+$/, DUPLICATE],
4690
- [/^Duplikat von ".+"$/, DUPLICATE],
4691
- [/^.+ \(copy\)$/, "is named as a copy"]
4692
- ];
4693
- var recordedName = (p) => {
4694
- const name = p.json?.displayName;
4695
- return typeof name === "string" ? name : void 0;
4696
- };
4697
- var DEFAULT_PAGE_NAME = pbiplintRule({
4698
- id: "DEFAULT_PAGE_NAME",
4699
- name: "Page keeps its default name",
4700
- category: "Report Design",
4701
- severity: 2,
4702
- scope: ["Page"],
4703
- layer: "report",
4704
- check: ({ report }) => report ? report.pages.flatMap((p) => {
4705
- const name = recordedName(p);
4706
- const match = name === void 0 ? void 0 : DEFAULT_NAMES.find(([re]) => re.test(name));
4707
- return match ? [reportFinding.page(p, "/displayName", `"${name}" ${match[1]}`)] : [];
4708
- }) : []
4709
- });
4710
- var pageRules2 = [DEFAULT_PAGE_NAME];
4711
-
4712
- // ../core/src/dax/tokenize.ts
4713
- var OPERATORS = ["==", "<>", "<=", ">=", "&&", "||", "=", "<", ">", "+", "-", "*", "/", "^", "&"];
4714
- var NUMBER = /(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/y;
4715
- var IDENTIFIER = /[\p{L}_][\p{L}\p{M}\p{N}_.]*/uy;
4716
- var WORD_CHAR = /[\p{L}\p{M}\p{N}_]/u;
4717
- var DIGIT = /[0-9]/;
4718
- var isWord = (t, word) => t?.kind === "identifier" && t.text.toUpperCase() === word;
4719
- var isPunctuation = (t, char) => t?.kind === "punctuation" && t.text === char;
4720
- function quoted2(text2, from, close) {
4721
- let value = "";
4722
- let j = from + 1;
4723
- while (j < text2.length) {
4724
- const c = text2[j];
4725
- if (c === close) {
4726
- if (text2[j + 1] !== close) return { value, end: j + 1 };
4727
- j++;
4728
- }
4729
- value += c;
4730
- j++;
4731
- }
4732
- return { value, end: text2.length };
4733
- }
4734
- function tokenizeDax(expression) {
4735
- const s = expression;
4736
- const tokens = [];
4737
- const push2 = (kind, text2, start, end) => {
4738
- tokens.push({ kind, text: text2, start, end, depth: 0 });
4739
- };
4740
- let i = 0;
4741
- while (i < s.length) {
4742
- const c = s[i];
4743
- if (/\s/.test(c)) {
4744
- i++;
4745
- } else if (s.startsWith("//", i) || s.startsWith("--", i)) {
4746
- const nl = s.indexOf("\n", i);
4747
- i = nl === -1 ? s.length : nl;
4748
- } else if (s.startsWith("/*", i)) {
4749
- const close = s.indexOf("*/", i + 2);
4750
- i = close === -1 ? s.length : close + 2;
4751
- } else if (c === '"') {
4752
- const q2 = quoted2(s, i, '"');
4753
- push2("string", q2.value, i, q2.end);
4754
- i = q2.end;
4755
- } else if ((c === "d" || c === "D") && (s[i + 1] === "t" || s[i + 1] === "T") && s[i + 2] === '"' && !(i > 0 && WORD_CHAR.test(s[i - 1]))) {
4756
- const q2 = quoted2(s, i + 2, '"');
4757
- push2("date", q2.value, i, q2.end);
4758
- i = q2.end;
4759
- } else if (c === "'" || c === "[") {
4760
- const q2 = quoted2(s, i, c === "'" ? "'" : "]");
4761
- push2(c === "'" ? "table" : "column", q2.value, i, q2.end);
4762
- i = q2.end;
4763
- } else if (DIGIT.test(c) || c === "." && DIGIT.test(s[i + 1] ?? "")) {
4764
- NUMBER.lastIndex = i;
4765
- const text2 = NUMBER.exec(s)[0];
4766
- push2("number", text2, i, i + text2.length);
4767
- i += text2.length;
4768
- } else {
4769
- IDENTIFIER.lastIndex = i;
4770
- const id = IDENTIFIER.exec(s)?.[0];
4771
- const op = id === void 0 ? OPERATORS.find((o) => s.startsWith(o, i)) : void 0;
4772
- const text2 = id ?? op ?? c;
4773
- push2(
4774
- id !== void 0 ? "identifier" : op !== void 0 ? "operator" : "punctuation",
4775
- text2,
4776
- i,
4777
- i + text2.length
4778
- );
4779
- i += text2.length;
4780
- }
4781
- }
4782
- annotate(tokens);
4783
- return tokens;
4784
- }
4785
- function annotate(tokens) {
4786
- const open = [];
4787
- const args = [];
4788
- const place = (t) => {
4789
- t.depth = open.length;
4790
- if (open.length === 0) return;
4791
- t.parent = open[open.length - 1];
4792
- t.arg = args[args.length - 1];
4793
- };
4794
- tokens.forEach((t, k) => {
4795
- if (isPunctuation(t, "(") || isPunctuation(t, "{")) {
4796
- place(t);
4797
- const before = tokens[k - 1];
4798
- if (t.text === "(" && before?.kind === "identifier") t.call = before.text.toUpperCase();
4799
- open.push(k);
4800
- args.push(0);
4801
- } else if (isPunctuation(t, ")") || isPunctuation(t, "}")) {
4802
- const o = open.pop();
4803
- args.pop();
4804
- if (o !== void 0) {
4805
- tokens[o].close = k;
4806
- t.open = o;
4807
- }
4808
- place(t);
4809
- } else {
4810
- place(t);
4811
- if (isPunctuation(t, ",") && args.length > 0) args[args.length - 1] = args.at(-1) + 1;
4812
- }
4813
- });
4814
- }
4815
- function opensBlock(tokens, k) {
4816
- if (isWord(tokens[k - 1], "RETURN")) return true;
4817
- const eq = tokens[k - 1];
4818
- return isWord(tokens[k - 3], "VAR") && tokens[k - 2]?.kind === "identifier" && eq?.kind === "operator" && eq.text === "=";
4819
- }
4820
- function daxVariables(tokens) {
4821
- const out = [];
4822
- tokens.forEach((t, k) => {
4823
- const name = tokens[k + 1];
4824
- const eq = tokens[k + 2];
4825
- if (!isWord(t, "VAR") || name?.kind !== "identifier" || eq?.kind !== "operator") return;
4826
- if (eq.text !== "=") return;
4827
- const from = k + 3;
4828
- let to = from;
4829
- let nested = 0;
4830
- for (; to < tokens.length; to++) {
4831
- const x = tokens[to];
4832
- if (x.depth < t.depth) break;
4833
- if (x.depth !== t.depth) continue;
4834
- if (isWord(x, "VAR")) {
4835
- if (opensBlock(tokens, to)) nested++;
4836
- else if (nested === 0) break;
4837
- } else if (isWord(x, "RETURN")) {
4838
- if (nested === 0) break;
4839
- nested--;
4840
- }
4841
- }
4842
- let blockEnd2 = to;
4843
- for (; blockEnd2 < tokens.length; blockEnd2++) {
4844
- const x = tokens[blockEnd2];
4845
- if (x.depth < t.depth || x.depth === t.depth && isPunctuation(x, ",")) break;
4846
- }
4847
- out.push({ name: name.text, at: k, from, to, blockEnd: blockEnd2 });
4848
- });
4849
- for (const v of out)
4850
- for (const d of out) if (d.from <= v.at && v.at < d.to && d.to < v.blockEnd) v.blockEnd = d.to;
4851
- return out;
4852
- }
4853
- function variableAt(vars, name, use) {
4854
- const key4 = name.toUpperCase();
4855
- let found;
4856
- for (const v of vars)
4857
- if (v.name.toUpperCase() === key4 && v.at < use && use < v.blockEnd && !(v.from <= use && use < v.to))
4858
- found = v;
4859
- return found;
4860
- }
4861
-
4862
- // ../core/src/rules/pbiplint/period-words.ts
4863
- var YEAR_WORDS = /* @__PURE__ */ new Set([
4864
- "year",
4865
- "years",
4866
- "yr",
4867
- "yrs",
4868
- "a\xF1o",
4869
- "a\xF1os",
4870
- "ano",
4871
- "anos",
4872
- "anio",
4873
- "jahr",
4874
- "ann\xE9e",
4875
- "annee",
4876
- "anno",
4877
- "jaar",
4878
- "\xE5r",
4879
- "rok",
4880
- "vuosi",
4881
- "ejercicio",
4882
- "exercice",
4883
- "fy",
4884
- "ay",
4885
- "cy",
4886
- "ly",
4887
- "py",
4888
- "yyyy",
4889
- "y"
4689
+ // ../core/src/rules/pbiplint/period-words.ts
4690
+ var YEAR_WORDS = /* @__PURE__ */ new Set([
4691
+ "year",
4692
+ "years",
4693
+ "yr",
4694
+ "yrs",
4695
+ "a\xF1o",
4696
+ "a\xF1os",
4697
+ "ano",
4698
+ "anos",
4699
+ "anio",
4700
+ "jahr",
4701
+ "ann\xE9e",
4702
+ "annee",
4703
+ "anno",
4704
+ "jaar",
4705
+ "\xE5r",
4706
+ "rok",
4707
+ "vuosi",
4708
+ "ejercicio",
4709
+ "exercice",
4710
+ "fy",
4711
+ "ay",
4712
+ "cy",
4713
+ "ly",
4714
+ "py",
4715
+ "yyyy",
4716
+ "y"
4890
4717
  ]);
4891
4718
  var MONTH_WORDS = /* @__PURE__ */ new Set([
4892
4719
  "month",
@@ -4933,6 +4760,7 @@ function nameClass(name) {
4933
4760
  // ../core/src/rules/pbiplint/period-forms.ts
4934
4761
  var FIRST_YEAR = 1950;
4935
4762
  var LAST_YEAR = 2049;
4763
+ var inYearRange = (year) => year >= FIRST_YEAR && year <= LAST_YEAR;
4936
4764
  var COMPARISONS = /* @__PURE__ */ new Set(["=", "==", "<>"]);
4937
4765
  var WRAPPERS = /* @__PURE__ */ new Set([
4938
4766
  "SELECTEDVALUE",
@@ -4966,7 +4794,7 @@ function yearIn(t, strings) {
4966
4794
  const text2 = t?.kind === "number" ? t.text : strings && t?.kind === "string" ? t.text.trim() : void 0;
4967
4795
  if (text2 === void 0 || !/^\d{4}$/.test(text2)) return void 0;
4968
4796
  const year = Number(text2);
4969
- return year >= FIRST_YEAR && year <= LAST_YEAR ? year : void 0;
4797
+ return inYearRange(year) ? year : void 0;
4970
4798
  }
4971
4799
  function argumentsOf(tokens, open) {
4972
4800
  const end = tokens[open]?.close ?? tokens.length;
@@ -5066,14 +4894,14 @@ function expressionPeriods(expression) {
5066
4894
  }
5067
4895
  if (t.call === "DATE" && !isBound(tokens, k) && !isYearFreeFormat(tokens, k)) {
5068
4896
  const [y, m, d] = argumentsOf(tokens, k).map((span) => only(tokens, span));
5069
- const fixed = yearIn(y, false);
5070
- if (fixed !== void 0) {
4897
+ const fixed2 = yearIn(y, false);
4898
+ if (fixed2 !== void 0) {
5071
4899
  if (isWhole(m) && isWhole(d)) {
5072
4900
  const [month, day] = [Number(m.text), Number(d.text)];
5073
- const epoch = fixed === UNIX_EPOCH.year && month === UNIX_EPOCH.month && day === UNIX_EPOCH.day;
5074
- const date = daxDate(fixed, month, day);
5075
- if (!epoch && date) found.push({ at: tokens[k - 1].start, year: fixed, date });
5076
- } else year(y, fixed);
4901
+ const epoch = fixed2 === UNIX_EPOCH.year && month === UNIX_EPOCH.month && day === UNIX_EPOCH.day;
4902
+ const date = daxDate(fixed2, month, day);
4903
+ if (!epoch && date) found.push({ at: tokens[k - 1].start, year: fixed2, date });
4904
+ } else year(y, fixed2);
5077
4905
  }
5078
4906
  }
5079
4907
  });
@@ -5234,27 +5062,417 @@ function periodFindings(model) {
5234
5062
  )
5235
5063
  );
5236
5064
  }
5237
- return out;
5238
- }
5239
- var HARDCODED_PERIOD_IN_DAX = pbiplintRule({
5240
- id: "HARDCODED_PERIOD_IN_DAX",
5241
- name: "Hardcoded period in DAX",
5242
- category: "DAX Expressions",
5243
- severity: 1,
5244
- scope: ["Measure", "CalculatedColumn", "CalculationItem", "CalculatedTable"],
5245
- layer: "model",
5246
- // No skipWhenModelUnread: each finding rests on the object's own expression, so a file the
5247
- // parser could not read can hide an object from the rule, never put a period in one.
5248
- check: ({ model }) => model ? periodFindings(model) : []
5065
+ return out;
5066
+ }
5067
+ var HARDCODED_PERIOD_IN_DAX = pbiplintRule({
5068
+ id: "HARDCODED_PERIOD_IN_DAX",
5069
+ name: "Hardcoded period in DAX",
5070
+ category: "DAX Expressions",
5071
+ severity: 1,
5072
+ scope: ["Measure", "CalculatedColumn", "CalculationItem", "CalculatedTable"],
5073
+ layer: "model",
5074
+ // No skipWhenModelUnread: each finding rests on the object's own expression, so a file the
5075
+ // parser could not read can hide an object from the rule, never put a period in one.
5076
+ check: ({ model }) => model ? periodFindings(model) : []
5077
+ });
5078
+ var periodRules = [HARDCODED_PERIOD_IN_DAX];
5079
+
5080
+ // ../core/src/rules/pbiplint/actions.ts
5081
+ var CHECKED = /* @__PURE__ */ new Map([
5082
+ ["pagenavigation", { action: "Page navigation", object: "page" }],
5083
+ ["drillthrough", { action: "Drillthrough", object: "page" }],
5084
+ ["bookmark", { action: "Bookmark", object: "bookmark" }]
5085
+ ]);
5086
+ var BROKEN_ACTION_TARGET = pbiplintRule({
5087
+ id: "BROKEN_ACTION_TARGET",
5088
+ name: "Action points at nothing",
5089
+ category: "Error Prevention",
5090
+ severity: 3,
5091
+ scope: ["Visual"],
5092
+ layer: "report",
5093
+ check: ({ report }) => {
5094
+ if (!report) return [];
5095
+ const pages = new Set(report.pages.map((p) => p.id));
5096
+ const bookmarks = new Set(report.bookmarks.map((b) => b.id));
5097
+ const exists = {
5098
+ page: (name) => pages.has(name) || pageUnread(report, name),
5099
+ bookmark: (name) => bookmarks.has(name) || bookmarkUnread(report, name)
5100
+ };
5101
+ return allVisuals(report).flatMap(
5102
+ (v) => v.actions.flatMap((a) => {
5103
+ const checked = CHECKED.get(a.type.toLowerCase());
5104
+ if (!checked || !a.on || a.target === void 0) return [];
5105
+ if (exists[checked.object](a.target)) return [];
5106
+ return [
5107
+ reportFinding.visual(
5108
+ v,
5109
+ a.pointer,
5110
+ `${checked.action} action points at ${checked.object} "${a.target}", which does not exist`
5111
+ )
5112
+ ];
5113
+ })
5114
+ );
5115
+ }
5116
+ });
5117
+ var ACTION_WITHOUT_DESTINATION = pbiplintRule({
5118
+ id: "ACTION_WITHOUT_DESTINATION",
5119
+ name: "Action has no destination",
5120
+ category: "Report Design",
5121
+ severity: 2,
5122
+ scope: ["Visual"],
5123
+ layer: "report",
5124
+ // The same three types as BROKEN_ACTION_TARGET, on any visual, hidden or not. The pointer is the
5125
+ // destination property when it is there and empty, else the entry's properties.
5126
+ check: ({ report }) => report ? allVisuals(report).flatMap(
5127
+ (v) => v.actions.flatMap((a) => {
5128
+ const checked = CHECKED.get(a.type.toLowerCase());
5129
+ if (!checked || !a.on || a.target !== void 0 || a.conditional) return [];
5130
+ return [
5131
+ reportFinding.visual(v, a.pointer, `${checked.action} action has no destination`)
5132
+ ];
5133
+ })
5134
+ ) : []
5135
+ });
5136
+ var SECTIONS = "/explorationState/sections";
5137
+ var BROKEN_BOOKMARK_REFERENCE = pbiplintRule({
5138
+ id: "BROKEN_BOOKMARK_REFERENCE",
5139
+ name: "Bookmark refers to a missing page or visual",
5140
+ category: "Error Prevention",
5141
+ severity: 2,
5142
+ scope: ["Bookmark"],
5143
+ layer: "report",
5144
+ // Groups, captured apart from visuals under `visualContainerGroups`, are not read. A page or a
5145
+ // visual whose own file could not be read is there, under the folder name Desktop gives it, so
5146
+ // it is never reported missing, and neither is one a folder that could not be read could hold.
5147
+ check: ({ report }) => {
5148
+ if (!report) return [];
5149
+ const pages = new Map(report.pages.map((p) => [p.id, p]));
5150
+ const missing = (id) => !pages.has(id) && !pageUnread(report, id);
5151
+ const notOn = (p, id) => !p.visuals.some((v) => v.id === id) && !visualUnread(report, p, id);
5152
+ return report.bookmarks.flatMap((b) => {
5153
+ const out = [];
5154
+ if (b.activePage !== void 0 && missing(b.activePage))
5155
+ out.push(
5156
+ reportFinding.bookmark(
5157
+ b,
5158
+ `active page "${b.activePage}" does not exist`,
5159
+ "/explorationState/activeSection"
5160
+ )
5161
+ );
5162
+ for (const id of b.pages)
5163
+ if (id !== b.activePage && missing(id))
5164
+ out.push(
5165
+ reportFinding.bookmark(
5166
+ b,
5167
+ `captured page "${id}" does not exist`,
5168
+ `${SECTIONS}/${escapePointer(id)}`
5169
+ )
5170
+ );
5171
+ for (const { page, visual, pointer } of b.visuals) {
5172
+ const p = pages.get(page);
5173
+ if (p && notOn(p, visual))
5174
+ out.push(
5175
+ reportFinding.bookmark(
5176
+ b,
5177
+ `captured visual "${visual}" is not on page "${p.displayName}"`,
5178
+ pointer
5179
+ )
5180
+ );
5181
+ }
5182
+ const active = b.activePage === void 0 ? void 0 : pages.get(b.activePage);
5183
+ const stale = active ? (b.targetVisuals ?? []).filter((t) => notOn(active, t.visual)) : [];
5184
+ if (active && stale.length > 0) {
5185
+ const many = stale.length > 1;
5186
+ const names = englishList(stale.map((t) => `"${t.visual}"`));
5187
+ out.push(
5188
+ reportFinding.bookmark(
5189
+ b,
5190
+ `target visual${many ? "s" : ""} ${names} ${many ? "are" : "is"} not on page "${active.displayName}"`,
5191
+ stale[0].pointer
5192
+ )
5193
+ );
5194
+ }
5195
+ return out;
5196
+ });
5197
+ }
5198
+ });
5199
+ var actionRules = [
5200
+ BROKEN_ACTION_TARGET,
5201
+ ACTION_WITHOUT_DESTINATION,
5202
+ BROKEN_BOOKMARK_REFERENCE
5203
+ ];
5204
+
5205
+ // ../core/src/rules/pbiplint/filters.ts
5206
+ var isRecord7 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5207
+ function yearOf(e) {
5208
+ const value = isRecord7(e) && isRecord7(e.Literal) ? e.Literal.Value : void 0;
5209
+ const m = typeof value === "string" ? /^(?:(\d{4})L|'(\d{4})')$/.exec(value) : null;
5210
+ const year = m ? Number(m[1] ?? m[2]) : void 0;
5211
+ return year !== void 0 && inYearRange(year) ? year : void 0;
5212
+ }
5213
+ function isYearColumn(e) {
5214
+ if (!isRecord7(e)) return false;
5215
+ const name = isRecord7(e.Column) ? e.Column.Property : isRecord7(e.HierarchyLevel) ? e.HierarchyLevel.Level : void 0;
5216
+ return typeof name === "string" && nameClass(name) === "year";
5217
+ }
5218
+ var fixed = (years) => `fixed year${years.length > 1 ? "s" : ""} ${englishList(years.map(String))}`;
5219
+ function keptYears(condition, at2) {
5220
+ if (!isRecord7(condition)) return void 0;
5221
+ const { In: inList, Comparison: compared2 } = condition;
5222
+ if (isRecord7(inList) && Array.isArray(inList.Expressions) && inList.Expressions.length === 1 && isYearColumn(inList.Expressions[0]) && Array.isArray(inList.Values)) {
5223
+ const found = inList.Values.flatMap((row, i) => {
5224
+ const year = Array.isArray(row) && row.length === 1 ? yearOf(row[0]) : void 0;
5225
+ return year === void 0 ? [] : [{ year, at: `${at2}/In/Values/${i}/0/Literal/Value` }];
5226
+ });
5227
+ if (found.length === 0) return void 0;
5228
+ const years = [...new Set(found.map((f) => f.year))];
5229
+ return { years, detail: fixed(years), at: found[0].at };
5230
+ }
5231
+ if (isRecord7(compared2) && compared2.ComparisonKind === 0 && isYearColumn(compared2.Left)) {
5232
+ const year = yearOf(compared2.Right);
5233
+ if (year === void 0) return void 0;
5234
+ return { years: [year], detail: fixed([year]), at: `${at2}/Comparison/Right/Literal/Value` };
5235
+ }
5236
+ return void 0;
5237
+ }
5238
+ function bound(condition, at2) {
5239
+ const compared2 = isRecord7(condition) ? condition.Comparison : void 0;
5240
+ if (!isRecord7(compared2) || !isYearColumn(compared2.Left)) return void 0;
5241
+ const year = yearOf(compared2.Right);
5242
+ if (year === void 0) return void 0;
5243
+ const literal2 = `${at2}/Comparison/Right/Literal/Value`;
5244
+ switch (compared2.ComparisonKind) {
5245
+ case 1:
5246
+ return { side: "lower", year: year + 1, at: literal2 };
5247
+ case 2:
5248
+ return { side: "lower", year, at: literal2 };
5249
+ case 3:
5250
+ return { side: "upper", year: year - 1, at: literal2 };
5251
+ case 4:
5252
+ return { side: "upper", year, at: literal2 };
5253
+ default:
5254
+ return void 0;
5255
+ }
5256
+ }
5257
+ function yearsUpTo(condition, at2) {
5258
+ const alone = bound(condition, at2);
5259
+ if (alone?.side === "upper")
5260
+ return { years: [alone.year], detail: `years up to ${alone.year}`, at: alone.at };
5261
+ const both = isRecord7(condition) ? condition.And : void 0;
5262
+ if (!isRecord7(both)) return void 0;
5263
+ const sides = [bound(both.Left, `${at2}/And/Left`), bound(both.Right, `${at2}/And/Right`)];
5264
+ const lower4 = sides.find((b) => b?.side === "lower");
5265
+ const upper = sides.find((b) => b?.side === "upper");
5266
+ if (!lower4 || !upper) return void 0;
5267
+ return {
5268
+ years: [lower4.year, upper.year],
5269
+ detail: `years ${lower4.year} to ${upper.year}`,
5270
+ at: upper.at
5271
+ };
5272
+ }
5273
+ function yearFinding(f, names, make) {
5274
+ if (f.howCreated === "Drillthrough" || f.howCreated === "Drill") return [];
5275
+ const kept = (f.where ?? []).map((w, i) => {
5276
+ if (!isRecord7(w)) return void 0;
5277
+ const at2 = `${f.pointer}/filter/Where/${i}/Condition`;
5278
+ return keptYears(w.Condition, at2) ?? yearsUpTo(w.Condition, at2);
5279
+ }).find((k) => k !== void 0);
5280
+ if (!kept || kept.years.some((y) => names.some((n2) => n2 !== void 0 && namesYear(n2, y))))
5281
+ return [];
5282
+ return [make(kept.at, f.field ? `${kept.detail} on ${fieldLabel(f.field)}` : kept.detail)];
5283
+ }
5284
+ function yearFindings(report) {
5285
+ const out = report.filters.flatMap(
5286
+ (f) => yearFinding(f, [], (at2, detail) => reportFinding.reportFilter(report, at2, detail))
5287
+ );
5288
+ for (const p of report.pages) {
5289
+ for (const f of p.filters)
5290
+ out.push(...yearFinding(f, [p.displayName], (at2, d) => reportFinding.pageFilter(p, at2, d)));
5291
+ for (const v of p.visuals)
5292
+ for (const f of v.filters)
5293
+ out.push(
5294
+ ...yearFinding(f, [p.displayName, v.title], (at2, d) => reportFinding.visual(v, at2, d))
5295
+ );
5296
+ }
5297
+ return out;
5298
+ }
5299
+ var HARDCODED_YEAR_IN_FILTER = pbiplintRule({
5300
+ id: "HARDCODED_YEAR_IN_FILTER",
5301
+ name: "Hardcoded year in a filter",
5302
+ category: "Report Design",
5303
+ severity: 1,
5304
+ scope: ["Visual", "Page", "Report"],
5305
+ layer: "report",
5306
+ // No skipWhenUnread: a report file that could not be read hides its filters from the rule,
5307
+ // never adds one.
5308
+ check: ({ report }) => report ? yearFindings(report) : []
5309
+ });
5310
+ var filterRules = [HARDCODED_YEAR_IN_FILTER];
5311
+
5312
+ // ../core/src/rules/pbiplint/functions.ts
5313
+ var packageOf = (f) => f.annotations.DAXLIB_PackageId;
5314
+ function notCalled(model, references) {
5315
+ const calledBy = (f) => references.functionCalledBy(f);
5316
+ const out = [];
5317
+ const seen = /* @__PURE__ */ new Set();
5318
+ for (const f of model.functions) {
5319
+ const pkg = packageOf(f);
5320
+ if (pkg === void 0) {
5321
+ if (calledBy(f).length === 0) out.push(finding.function(f));
5322
+ continue;
5323
+ }
5324
+ if (seen.has(pkg)) continue;
5325
+ seen.add(pkg);
5326
+ const members = model.functions.filter((g) => packageOf(g) === pkg);
5327
+ const fromOutside = members.some(
5328
+ (g) => calledBy(g).some((o) => !(o.kind === "function" && members.includes(o.object)))
5329
+ );
5330
+ if (fromOutside) continue;
5331
+ const detail = members.length === 1 ? `package ${pkg}: its one function is not called` : `package ${pkg}: none of its ${members.length} functions is called`;
5332
+ out.push({ ...finding.function(f), detail });
5333
+ }
5334
+ return out;
5335
+ }
5336
+ var UDF_NOT_CALLED = pbiplintRule({
5337
+ id: "UDF_NOT_CALLED",
5338
+ name: "User-defined function nothing calls",
5339
+ category: "Maintenance",
5340
+ severity: 1,
5341
+ scope: ["Function"],
5342
+ layer: "model",
5343
+ // A model file pbiplint could not fully read may hold the call.
5344
+ skipWhenModelUnread: modelPartlyRead,
5345
+ check: ({ model }, { indexes: { references } }) => model ? notCalled(model, references) : []
5346
+ });
5347
+ var UDF_USE_COMPOUND_NAMES = pbiplintRule({
5348
+ id: "UDF_USE_COMPOUND_NAMES",
5349
+ name: "User-defined function with a one-word name",
5350
+ category: "Error Prevention",
5351
+ severity: 1,
5352
+ scope: ["Function"],
5353
+ layer: "model",
5354
+ check: ({ model }) => (model?.functions ?? []).filter((f) => !f.name.includes(".") && !f.name.includes("_")).map(finding.function)
5355
+ });
5356
+ var UDF_WITHOUT_DESCRIPTION = pbiplintRule({
5357
+ id: "UDF_WITHOUT_DESCRIPTION",
5358
+ name: "User-defined function with no description",
5359
+ category: "Maintenance",
5360
+ severity: 1,
5361
+ scope: ["Function"],
5362
+ layer: "model",
5363
+ check: ({ model }) => (model?.functions ?? []).filter((f) => packageOf(f) === void 0 && isBlank(f.description)).map(finding.function)
5364
+ });
5365
+ var functionRules = [UDF_NOT_CALLED, UDF_USE_COMPOUND_NAMES, UDF_WITHOUT_DESCRIPTION];
5366
+
5367
+ // ../core/src/rules/pbiplint/measures.ts
5368
+ var REPORT_LEVEL_MEASURES = pbiplintRule({
5369
+ id: "REPORT_LEVEL_MEASURES",
5370
+ name: "Measure defined in the report",
5371
+ category: "Maintenance",
5372
+ severity: 2,
5373
+ scope: ["ReportMeasure"],
5374
+ layer: "report",
5375
+ // Its findings are report objects, yet it reports only beside the model the report reads, so a
5376
+ // run without the model skips it rather than counting it as run.
5377
+ needs: ["model", "report"],
5378
+ // The detail says where the measure lives; the rule's page gives the fix.
5379
+ check: (project) => reportMeasuresToMove(project).map(
5380
+ (m) => reportFinding.reportMeasure(m, `defined in the report on table "${m.table}"`)
5381
+ )
5382
+ });
5383
+ var measureRules2 = [REPORT_LEVEL_MEASURES];
5384
+
5385
+ // ../core/src/rules/pbiplint/opening.ts
5386
+ var LANDING_PAGE_NOT_SET = pbiplintRule({
5387
+ id: "LANDING_PAGE_NOT_SET",
5388
+ name: "No landing page set",
5389
+ category: "Report Design",
5390
+ severity: 1,
5391
+ scope: ["Report"],
5392
+ layer: "report",
5393
+ check: ({ report }) => {
5394
+ const opens = report && landingPageNotSet(report) ? openingPage(report) : void 0;
5395
+ if (!report || !opens) return [];
5396
+ const pointer = opens.by === "active" ? "/activePageName" : void 0;
5397
+ if (opens.page === void 0 && !opens.unread)
5398
+ return [
5399
+ reportFinding.pagesHeader(
5400
+ report,
5401
+ pointer,
5402
+ `no landing page set; the active page "${opens.name}" does not exist`
5403
+ )
5404
+ ];
5405
+ const name = opens.page?.displayName ?? opens.name;
5406
+ const which = opens.by === "first" ? "the first page" : "the page open when it was saved";
5407
+ return [reportFinding.pagesHeader(report, pointer, `opens on "${name}", ${which}`)];
5408
+ }
5409
+ });
5410
+ var OPENING_PAGE_INVALID = pbiplintRule({
5411
+ id: "OPENING_PAGE_INVALID",
5412
+ name: "Opening page missing or hidden",
5413
+ category: "Error Prevention",
5414
+ severity: 3,
5415
+ scope: ["Report"],
5416
+ layer: "report",
5417
+ check: ({ report }) => {
5418
+ const opens = report && openingPage(report);
5419
+ if (!report || !opens || !openingPageInvalid(opens)) return [];
5420
+ const pointer = opens.by === "landing" ? "/landingPageName" : "/activePageName";
5421
+ const detail = opens.page === void 0 ? `${opens.by} page "${opens.name}" does not exist` : `active page "${opens.page.displayName}" is hidden from readers`;
5422
+ return [reportFinding.pagesHeader(report, pointer, detail)];
5423
+ }
5249
5424
  });
5250
- var periodRules = [HARDCODED_PERIOD_IN_DAX];
5425
+ var FILTERS_PANE_STATE = pbiplintRule({
5426
+ id: "FILTERS_PANE_STATE",
5427
+ name: "Filters pane state differs from policy",
5428
+ category: "Report Design",
5429
+ severity: 2,
5430
+ scope: ["Report"],
5431
+ layer: "report",
5432
+ options: [{ name: "expect", type: "string", values: ["open", "closed"] }],
5433
+ check: ({ report }, ctx) => {
5434
+ const expect = ctx.options.expect;
5435
+ if (!report || expect === void 0) return [];
5436
+ const pane = filtersPaneState(report);
5437
+ if (!pane || pane.state === expect) return [];
5438
+ const { state, recordedAt } = pane;
5439
+ const saved = recordedAt === void 0 ? `not recorded, read as ${state}` : `saved ${state}`;
5440
+ return [
5441
+ reportFinding.report(report, `${saved}; the policy expects ${expect}`, "report", recordedAt)
5442
+ ];
5443
+ }
5444
+ });
5445
+ var openingRules = [LANDING_PAGE_NOT_SET, OPENING_PAGE_INVALID, FILTERS_PANE_STATE];
5251
5446
 
5252
- // ../core/src/rules/pbiplint/references.ts
5253
- var fieldLabel = (ref) => {
5254
- const inTable = (name) => ref.table === "" ? measureRef(name) : columnRef(ref.table, name);
5255
- const field = ref.variation ? `${inTable(ref.variation.column)}.${measureRef(ref.name)}` : ref.kind === "measure" ? measureRef(ref.name) : inTable(ref.name);
5256
- return ref.kind === "hierarchyLevel" && ref.level ? `${field}.${measureRef(ref.level)}` : field;
5447
+ // ../core/src/rules/pbiplint/pages.ts
5448
+ var NEW_PAGE = "is the name Power BI Desktop gives a new page";
5449
+ var DUPLICATE = "is the name Power BI Desktop gives a duplicated page";
5450
+ var DEFAULT_NAMES = [
5451
+ [/^(?:Page|Seite|Página|Pagina|ページ) [1-9]\d*$/, NEW_PAGE],
5452
+ [/^(?:Duplicate of|Duplicado de|Doublon de|Duplicata de|Duplikat av) .+$/, DUPLICATE],
5453
+ [/^Duplikat von ".+"$/, DUPLICATE],
5454
+ [/^.+ \(copy\)$/, "is named as a copy"]
5455
+ ];
5456
+ var recordedName = (p) => {
5457
+ const name = p.json?.displayName;
5458
+ return typeof name === "string" ? name : void 0;
5257
5459
  };
5460
+ var DEFAULT_PAGE_NAME = pbiplintRule({
5461
+ id: "DEFAULT_PAGE_NAME",
5462
+ name: "Page keeps its default name",
5463
+ category: "Report Design",
5464
+ severity: 2,
5465
+ scope: ["Page"],
5466
+ layer: "report",
5467
+ check: ({ report }) => report ? report.pages.flatMap((p) => {
5468
+ const name = recordedName(p);
5469
+ const match = name === void 0 ? void 0 : DEFAULT_NAMES.find(([re]) => re.test(name));
5470
+ return match ? [reportFinding.page(p, "/displayName", `"${name}" ${match[1]}`)] : [];
5471
+ }) : []
5472
+ });
5473
+ var pageRules2 = [DEFAULT_PAGE_NAME];
5474
+
5475
+ // ../core/src/rules/pbiplint/references.ts
5258
5476
  var firstPerObjectAndField = (findings) => {
5259
5477
  const seen = /* @__PURE__ */ new Set();
5260
5478
  return findings.filter((f) => {
@@ -5525,6 +5743,8 @@ var pbiplintRules = [
5525
5743
  ...pageRules2,
5526
5744
  ...measureRules2,
5527
5745
  ...periodRules,
5746
+ ...filterRules,
5747
+ ...functionRules,
5528
5748
  ...actionRules,
5529
5749
  ...tabOrderRules
5530
5750
  ];
@@ -7512,17 +7732,19 @@ Why it matters
7512
7732
 
7513
7733
  A bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.
7514
7734
 
7515
- Microsoft's PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark's configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop's saved files do keep bookmarks whose active page is gone.
7735
+ With Selected visuals on, Microsoft says the bookmark "Applies the bookmark settings only to the visuals you select before creating or updating the bookmark" (Create report bookmarks (https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), and the file keeps those visuals by name. A name in that list that no visual on the page has is a visual the bookmark was made to act on and can no longer reach. A visual put in its place since has a name of its own, which the list does not hold, so the bookmark leaves it alone.
7736
+
7737
+ Microsoft's PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark's configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop's saved files do keep bookmarks whose active page is gone. Power BI Desktop's saved files also keep names in the list of visuals a bookmark applies to after their visual is gone, even from a save that removed the same visual from the bookmark's captured visuals.
7516
7738
 
7517
7739
  How to fix it
7518
7740
 
7519
- In Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark's name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new name, so point any button that used the old one at the new one; BROKEN_ACTION_TARGET reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves.
7741
+ In Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark's name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new name, so point any button that used the old one at the new one; BROKEN_ACTION_TARGET reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves. For a target visual, saving is not enough: select the visuals the bookmark should apply to, on the page or in the Selection pane (hold Ctrl to select more than one), then choose Update from the bookmark's More options (...) menu.
7520
7742
 
7521
- In the bookmark file, a captured visual is a key under explorationState.sections.<page>.visualContainers, named by the visual's name: remove the key that names nothing, as the example does. A missing page is activeSection and the matching key of sections. Pointing both at the name of a page that exists leaves the bookmark holding state for the old page's visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.
7743
+ In the bookmark file, a captured visual is a key under explorationState.sections.<page>.visualContainers, named by the visual's name: remove the key that names nothing, as the example does. A target visual is a string in options.targetVisualNames: remove each one that names nothing. To have the bookmark act on a visual added since, update it in Desktop as above. A missing page is activeSection and the matching key of sections. Pointing both at the name of a page that exists leaves the bookmark holding state for the old page's visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.
7522
7744
 
7523
7745
  When to ignore it
7524
7746
 
7525
- There is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.
7747
+ There is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for, or applies to, something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.
7526
7748
 
7527
7749
  This rule reports on bookmarks, and a bookmark's file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set "BROKEN_BOOKMARK_REFERENCE": "off" under rules in pbiplint.config.json.
7528
7750
 
@@ -7530,14 +7752,16 @@ Quirks
7530
7752
 
7531
7753
  - A bookmark captures one page. Power BI Desktop's saved files write one key in sections, the active page's, so a missing page is reported once, at activeSection. A sections key that names a different missing page is reported on its own line.
7532
7754
  - The rule reports a missing active page whether or not the bookmark's Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.
7533
- - A captured visual is checked against the page it is captured under, and only when that page exists: a missing page is reported in place of the visuals captured under it.
7534
- - Groups, which a bookmark keeps apart from visuals under visualContainerGroups, are not checked, nor is the list of visuals the Selected visuals option applies to, options.targetVisualNames. Power BI Desktop's saved files write that list in every bookmark, whether or not Selected visuals is on.
7755
+ - A captured visual is checked against the page it is captured under, and a target visual against the active page, whose visuals and groups the list names; each only when that page exists, since a missing page is reported in place of the visuals under it.
7756
+ - The list of target visuals is checked only when Selected visuals is on, which the file records as applyOnlyToTargetVisuals set to true beside the list. Power BI Desktop's saved files write the list in every bookmark whether or not the option is on, and with All visuals, Microsoft says, the bookmark applies to every visual on the page (Create report bookmarks (https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), so a name left in the list then is not reported.
7757
+ - A bookmark's stale target visuals are one finding together, while each stale captured visual is a finding of its own: one Update with the right visuals selected replaces the whole list.
7758
+ - A group the list names, which is a container on the page with no visual of its own, is found as a visual is. Groups that a bookmark keeps apart from visuals under visualContainerGroups, and the visuals listed as each group's children, are not checked.
7535
7759
  - Pages and visuals are matched by name, never by display name, and never by folder name, except when their own file cannot be read, as the next point says. Microsoft says renaming a name is supported, and that Power BI Desktop keeps the original folder names when it saves.
7536
7760
  - A page or a visual whose own file cannot be read, such as a page.json or a visual.json holding merge-conflict markers or one pbiplint could not open at all, is not reported missing, because pbiplint does not guess what a file it could not read says. It is known by its folder name instead, which Microsoft's PBIR documentation says is a page's or a visual's name by default, so a bookmark that captures it is not reported. Nor is a page or a visual that a folder under the definition folder, which pbiplint could not list, could hold, such as any visual on a page whose visuals folder could not be listed. The file's own PARSE_ISSUE finding names it, or a notice does for a file pbiplint could not open or a folder it could not list.
7537
7761
  - bookmarks.json, which holds the bookmarks' order and groups, is not checked: a name it lists with no bookmark file, or a bookmark file it does not list, is not reported.
7538
7762
 
7539
7763
  Read more: https://pbiplint.com/rules/broken-bookmark-reference`,
7540
- markdown: '### Example\n\n**Fires the rule in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {\n "8b2e41c07d95a3f6e210": {\n "singleVisual": {\n "visualType": "tableEx",\n "objects": {}\n }\n }\n }\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\n**After the fix in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {}\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\nThe Reset bookmark captures the Overview page with the state of a table, `8b2e41c07d95a3f6e210`, that is not on the page, as a bookmark file copied in from another report or merged by hand can, so the finding reads `Bookmark "Reset"` with `captured visual "8b2e41c07d95a3f6e210" is not on page "Overview"`. The fix removes the table\'s entry from `visualContainers`.\n\n### Why it matters\n\nA bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.\n\nMicrosoft\'s PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark\'s configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop\'s saved files do keep bookmarks whose active page is gone.\n\n### How to fix it\n\nIn Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark\'s name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new `name`, so point any button that used the old one at the new one; `BROKEN_ACTION_TARGET` reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves.\n\nIn the bookmark file, a captured visual is a key under `explorationState.sections.<page>.visualContainers`, named by the visual\'s `name`: remove the key that names nothing, as the example does. A missing page is `activeSection` and the matching key of `sections`. Pointing both at the `name` of a page that exists leaves the bookmark holding state for the old page\'s visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.\n\n### When to ignore it\n\nThere is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.\n\nThis rule reports on bookmarks, and a bookmark\'s file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set `"BROKEN_BOOKMARK_REFERENCE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A bookmark captures one page. Power BI Desktop\'s saved files write one key in `sections`, the active page\'s, so a missing page is reported once, at `activeSection`. A `sections` key that names a different missing page is reported on its own line.\n- The rule reports a missing active page whether or not the bookmark\'s Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.\n- A captured visual is checked against the page it is captured under, and only when that page exists: a missing page is reported in place of the visuals captured under it.\n- Groups, which a bookmark keeps apart from visuals under `visualContainerGroups`, are not checked, nor is the list of visuals the Selected visuals option applies to, `options.targetVisualNames`. Power BI Desktop\'s saved files write that list in every bookmark, whether or not Selected visuals is on.\n- Pages and visuals are matched by `name`, never by display name, and never by folder name, except when their own file cannot be read, as the next point says. Microsoft says renaming a `name` is supported, and that Power BI Desktop keeps the original folder names when it saves.\n- A page or a visual whose own file cannot be read, such as a page.json or a visual.json holding merge-conflict markers or one pbiplint could not open at all, is not reported missing, because pbiplint does not guess what a file it could not read says. It is known by its folder name instead, which Microsoft\'s PBIR documentation says is a page\'s or a visual\'s `name` by default, so a bookmark that captures it is not reported. Nor is a page or a visual that a folder under the definition folder, which pbiplint could not list, could hold, such as any visual on a page whose visuals folder could not be listed. The file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file pbiplint could not open or a folder it could not list.\n- bookmarks.json, which holds the bookmarks\' order and groups, is not checked: a name it lists with no bookmark file, or a bookmark file it does not list, is not reported.\n\nRead more: https://pbiplint.com/rules/broken-bookmark-reference'
7764
+ markdown: '### Example\n\n**Fires the rule in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {\n "8b2e41c07d95a3f6e210": {\n "singleVisual": {\n "visualType": "tableEx",\n "objects": {}\n }\n }\n }\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\n**After the fix in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {}\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\nThe Reset bookmark captures the Overview page with the state of a table, `8b2e41c07d95a3f6e210`, that is not on the page, as a bookmark file copied in from another report or merged by hand can, so the finding reads `Bookmark "Reset"` with `captured visual "8b2e41c07d95a3f6e210" is not on page "Overview"`. The fix removes the table\'s entry from `visualContainers`.\n\n### Why it matters\n\nA bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.\n\nWith Selected visuals on, Microsoft says the bookmark "Applies the bookmark settings only to the visuals you select before creating or updating the bookmark" ([Create report bookmarks](https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), and the file keeps those visuals by `name`. A name in that list that no visual on the page has is a visual the bookmark was made to act on and can no longer reach. A visual put in its place since has a `name` of its own, which the list does not hold, so the bookmark leaves it alone.\n\nMicrosoft\'s PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark\'s configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop\'s saved files do keep bookmarks whose active page is gone. Power BI Desktop\'s saved files also keep names in the list of visuals a bookmark applies to after their visual is gone, even from a save that removed the same visual from the bookmark\'s captured visuals.\n\n### How to fix it\n\nIn Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark\'s name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new `name`, so point any button that used the old one at the new one; `BROKEN_ACTION_TARGET` reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves. For a target visual, saving is not enough: select the visuals the bookmark should apply to, on the page or in the Selection pane (hold Ctrl to select more than one), then choose Update from the bookmark\'s More options (...) menu.\n\nIn the bookmark file, a captured visual is a key under `explorationState.sections.<page>.visualContainers`, named by the visual\'s `name`: remove the key that names nothing, as the example does. A target visual is a string in `options.targetVisualNames`: remove each one that names nothing. To have the bookmark act on a visual added since, update it in Desktop as above. A missing page is `activeSection` and the matching key of `sections`. Pointing both at the `name` of a page that exists leaves the bookmark holding state for the old page\'s visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.\n\n### When to ignore it\n\nThere is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for, or applies to, something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.\n\nThis rule reports on bookmarks, and a bookmark\'s file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set `"BROKEN_BOOKMARK_REFERENCE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A bookmark captures one page. Power BI Desktop\'s saved files write one key in `sections`, the active page\'s, so a missing page is reported once, at `activeSection`. A `sections` key that names a different missing page is reported on its own line.\n- The rule reports a missing active page whether or not the bookmark\'s Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.\n- A captured visual is checked against the page it is captured under, and a target visual against the active page, whose visuals and groups the list names; each only when that page exists, since a missing page is reported in place of the visuals under it.\n- The list of target visuals is checked only when Selected visuals is on, which the file records as `applyOnlyToTargetVisuals` set to `true` beside the list. Power BI Desktop\'s saved files write the list in every bookmark whether or not the option is on, and with All visuals, Microsoft says, the bookmark applies to every visual on the page ([Create report bookmarks](https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), so a name left in the list then is not reported.\n- A bookmark\'s stale target visuals are one finding together, while each stale captured visual is a finding of its own: one Update with the right visuals selected replaces the whole list.\n- A group the list names, which is a container on the page with no visual of its own, is found as a visual is. Groups that a bookmark keeps apart from visuals under `visualContainerGroups`, and the visuals listed as each group\'s `children`, are not checked.\n- Pages and visuals are matched by `name`, never by display name, and never by folder name, except when their own file cannot be read, as the next point says. Microsoft says renaming a `name` is supported, and that Power BI Desktop keeps the original folder names when it saves.\n- A page or a visual whose own file cannot be read, such as a page.json or a visual.json holding merge-conflict markers or one pbiplint could not open at all, is not reported missing, because pbiplint does not guess what a file it could not read says. It is known by its folder name instead, which Microsoft\'s PBIR documentation says is a page\'s or a visual\'s `name` by default, so a bookmark that captures it is not reported. Nor is a page or a visual that a folder under the definition folder, which pbiplint could not list, could hold, such as any visual on a page whose visuals folder could not be listed. The file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file pbiplint could not open or a folder it could not list.\n- bookmarks.json, which holds the bookmarks\' order and groups, is not checked: a name it lists with no bookmark file, or a bookmark file it does not list, is not reported.\n\nRead more: https://pbiplint.com/rules/broken-bookmark-reference'
7541
7765
  },
7542
7766
  BROKEN_FIELD_REFERENCE: {
7543
7767
  text: `Example
@@ -8081,7 +8305,7 @@ Put the table name in front of every column reference:
8081
8305
 
8082
8306
  Total Sales = SUM ( 'Sales'[Amount] )
8083
8307
 
8084
- In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after measure 'Total Sales' =, or the filter after tablePermission Sales = inside the role.
8308
+ In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after measure 'Total Sales' =, or the filter after tablePermission Sales = inside the role. A finding that only the measure's KPI raises is fixed in the TMDL file, after targetExpression =, statusExpression =, or trendExpression = in the measure's kpi block, an edit Power BI Desktop keeps.
8085
8309
 
8086
8310
  When to ignore it
8087
8311
 
@@ -8094,13 +8318,14 @@ Quirks
8094
8318
  - 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.
8095
8319
  - 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.
8096
8320
  - A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.
8097
- - References are found by pattern matching, so a bare [Column] inside a string literal or a comment counts.
8098
- - A measure's dynamic format string is read together with its expression, so a bare column reference written inside formatStringDefinition reports the measure that carries it.
8321
+ - A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is that column, not a model column, so it is not reported, even when a model column has the same name: [Share] in MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), "Share", [Total] ), [Share] ). Inside a call that creates the name, such as SELECTCOLUMNS ( 'Sales', "Region", [Region] ), the name is read as any other bare name is, since a call cannot read a column it is creating.
8322
+ - DAX is read token by token, so a bare [Column] inside a string literal or a comment is not reported, nor is the name after the dot in extended column syntax such as 'Date'[Date].[Year].
8323
+ - A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so a bare column reference written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like [Total Sales].KPI, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.
8099
8324
  - Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.
8100
8325
  - While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a bare name reads as a column only when the model has no measure of that name, and a measure of that name could be in what pbiplint missed; pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
8101
8326
 
8102
8327
  Read more: https://pbiplint.com/rules/dax-columns-fully-qualified`,
8103
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM([Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### 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\nPut the table name in front of every column reference:\n\n```\nTotal Sales = SUM ( 'Sales'[Amount] )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after `measure 'Total Sales' =`, or the filter after `tablePermission Sales =` inside the role.\n\n### When to ignore it\n\nThere is no case for the bare form. The rule is worth reading as a warning rather than a style note: the reference that fires it resolved to a column because no measure of that name exists today, and the day someone adds one, the expression silently starts reading the measure instead. Qualifying it is what stops that.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_COLUMNS_FULLY_QUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_COLUMNS_FULLY_QUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\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- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.\n- References are found by pattern matching, so a bare `[Column]` inside a string literal or a comment counts.\n- A measure's dynamic format string is read together with its expression, so a bare column reference written inside `formatStringDefinition` reports the measure that carries it.\n- Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a bare name reads as a column only when the model has no measure of that name, and a measure of that name could be in what pbiplint missed; pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/dax-columns-fully-qualified"
8328
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM([Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### 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\nPut the table name in front of every column reference:\n\n```\nTotal Sales = SUM ( 'Sales'[Amount] )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after `measure 'Total Sales' =`, or the filter after `tablePermission Sales =` inside the role. A finding that only the measure's KPI raises is fixed in the TMDL file, after `targetExpression =`, `statusExpression =`, or `trendExpression =` in the measure's `kpi` block, an edit Power BI Desktop keeps.\n\n### When to ignore it\n\nThere is no case for the bare form. The rule is worth reading as a warning rather than a style note: the reference that fires it resolved to a column because no measure of that name exists today, and the day someone adds one, the expression silently starts reading the measure instead. Qualifying it is what stops that.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_COLUMNS_FULLY_QUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_COLUMNS_FULLY_QUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\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- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is that column, not a model column, so it is not reported, even when a model column has the same name: `[Share]` in `MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), \"Share\", [Total] ), [Share] )`. Inside a call that creates the name, such as `SELECTCOLUMNS ( 'Sales', \"Region\", [Region] )`, the name is read as any other bare name is, since a call cannot read a column it is creating.\n- DAX is read token by token, so a bare `[Column]` inside a string literal or a comment is not reported, nor is the name after the dot in extended column syntax such as `'Date'[Date].[Year]`.\n- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so a bare column reference written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like `[Total Sales].KPI`, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.\n- Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a bare name reads as a column only when the model has no measure of that name, and a measure of that name could be in what pbiplint missed; pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/dax-columns-fully-qualified"
8104
8329
  },
8105
8330
  DAX_MEASURES_UNQUALIFIED: {
8106
8331
  text: `Example
@@ -8143,7 +8368,7 @@ Delete the table name from the reference and leave the brackets:
8143
8368
 
8144
8369
  Average Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )
8145
8370
 
8146
- In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after measure 'Average Price' =, after column Name = for a calculated column, after source = in a calculated table's partition, or after calculationItem Name = in the calculation group.
8371
+ In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after measure 'Average Price' =, after column Name = for a calculated column, after source = in a calculated table's partition, or after calculationItem Name = in the calculation group. A finding that only the measure's KPI raises is fixed in the TMDL file, after targetExpression =, statusExpression =, or trendExpression = in the measure's kpi block, an edit Power BI Desktop keeps.
8147
8372
 
8148
8373
  When to ignore it
8149
8374
 
@@ -8153,14 +8378,14 @@ To ignore this rule on one object, add annotation pbiplint.ignore = DAX_MEASURES
8153
8378
 
8154
8379
  Quirks
8155
8380
 
8156
- - References are found by pattern matching, so a qualified measure reference inside a string literal or a comment counts.
8157
- - A measure's dynamic format string is read together with its expression, so 'Sales'[Total Sales] written inside formatStringDefinition reports the measure that carries it.
8381
+ - DAX is read token by token, so a qualified measure reference inside a comment or a string literal, such as a field parameter's "'Sales'[Total Sales]", is not reported.
8382
+ - A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so 'Sales'[Total Sales] written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like [Total Sales].KPI, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.
8158
8383
  - The reference has to resolve. 'Sales'[Total Sales] written where the model has no table called Sales, or where Sales has no measure of that name, is not reported by this rule at all.
8159
8384
  - Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.
8160
8385
  - The table name may be written bare or in single quotes; both forms are matched.
8161
8386
 
8162
8387
  Read more: https://pbiplint.com/rules/dax-measures-unqualified`,
8163
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE('Sales'[Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE([Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n### 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\nDelete the table name from the reference and leave the brackets:\n\n```\nAverage Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after `measure 'Average Price' =`, after `column Name =` for a calculated column, after `source =` in a calculated table's partition, or after `calculationItem Name =` in the calculation group.\n\n### When to ignore it\n\nThere is no case for the table prefix on a measure. If a finding surprises you, check whether the table really does hold a measure of that name: pbiplint resolves `'Table'[Name]` to a column first and only calls it a measure when the table has no column of that name, so a finding here means the reference bound to a measure.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_MEASURES_UNQUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_MEASURES_UNQUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- References are found by pattern matching, so a qualified measure reference inside a string literal or a comment counts.\n- A measure's dynamic format string is read together with its expression, so `'Sales'[Total Sales]` written inside `formatStringDefinition` reports the measure that carries it.\n- The reference has to resolve. `'Sales'[Total Sales]` written where the model has no table called Sales, or where Sales has no measure of that name, is not reported by this rule at all.\n- Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.\n- The table name may be written bare or in single quotes; both forms are matched.\n\nRead more: https://pbiplint.com/rules/dax-measures-unqualified"
8388
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE('Sales'[Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE([Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n### 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\nDelete the table name from the reference and leave the brackets:\n\n```\nAverage Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after `measure 'Average Price' =`, after `column Name =` for a calculated column, after `source =` in a calculated table's partition, or after `calculationItem Name =` in the calculation group. A finding that only the measure's KPI raises is fixed in the TMDL file, after `targetExpression =`, `statusExpression =`, or `trendExpression =` in the measure's `kpi` block, an edit Power BI Desktop keeps.\n\n### When to ignore it\n\nThere is no case for the table prefix on a measure. If a finding surprises you, check whether the table really does hold a measure of that name: pbiplint resolves `'Table'[Name]` to a column first and only calls it a measure when the table has no column of that name, so a finding here means the reference bound to a measure.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_MEASURES_UNQUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_MEASURES_UNQUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a qualified measure reference inside a comment or a string literal, such as a field parameter's `\"'Sales'[Total Sales]\"`, is not reported.\n- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so `'Sales'[Total Sales]` written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like `[Total Sales].KPI`, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.\n- The reference has to resolve. `'Sales'[Total Sales]` written where the model has no table called Sales, or where Sales has no measure of that name, is not reported by this rule at all.\n- Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.\n- The table name may be written bare or in single quotes; both forms are matched.\n\nRead more: https://pbiplint.com/rules/dax-measures-unqualified"
8164
8389
  },
8165
8390
  DEFAULT_PAGE_NAME: {
8166
8391
  text: `Example
@@ -9075,6 +9300,134 @@ Quirks
9075
9300
  Read more: https://pbiplint.com/rules/hardcoded-period-in-dax`,
9076
9301
  markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column 'Order Date'\n dataType: dateTime\n sourceColumn: Order Date\n measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = 2025)\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column 'Order Date'\n dataType: dateTime\n sourceColumn: Order Date\n measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = YEAR(TODAY()))\n formatString: #,0\n```\n\n### Why it matters\n\nA year typed into DAX is right for the year it was written in and quietly wrong after it. A measure named Current Year Sales that filters on 2025 still shows 2025's sales all through 2026, under the same name, with no error, and a reader has no way to tell.\n\nA date table built with `CALENDAR(DATE(2020, 1, 1), DATE(2026, 12, 31))` has no rows after December 31, 2026. From January 1, 2027, new rows in a table related to it find no date there. A visual that groups by the date table's columns shows them under a blank value: the [blank virtual row](https://learn.microsoft.com/power-bi/transform-model/desktop-relationships-understand#regular-relationships) Power BI adds when a value on a relationship's many side has no match on its one side. A filter or slicer on the date table leaves them out, and time intelligence stops at the table's last day. [Microsoft's guidance on date tables](https://learn.microsoft.com/power-bi/guidance/model-date-tables#generate-with-dax) says CALENDAR's start and end can come from other DAX functions, like `MAX(Sales[OrderDate])`. An end taken from the data moves with it.\n\n### How to fix it\n\nTake the period from something that moves with time.\n\n- For the current period, use `TODAY()`: `YEAR(TODAY())` for this year, as the fixed example does, or `TODAY()` itself for an as-of date.\n- For the latest period in the data, which stays right when a refresh runs late, take it from the fact table: `YEAR(MAX(Sales[Order Date]))`. Inside a measure, MAX reads only the dates the visual's filters leave, so write `CALCULATE(MAX(Sales[Order Date]), REMOVEFILTERS())` when the measure needs the latest date in all the data.\n- When a report reader should choose the period, add a parameter: on the Modeling tab, select New parameter, then Numeric range, and set its Minimum and Maximum to the first and last years it should offer. Power BI Desktop creates the parameter and, with it, a measure that gives the parameter's current value ([what-if parameters](https://learn.microsoft.com/power-bi/transform-model/desktop-what-if#create-a-parameter)), and your measure compares with that measure instead of a number. Parameters are [designed for measures](https://learn.microsoft.com/power-bi/transform-model/desktop-what-if#considerations-and-limitations), so this route suits a measure, not a calculated column.\n\nA filter argument of CALCULATE written as a comparison, as in the example, [can't reference a measure or use a nested CALCULATE](https://learn.microsoft.com/dax/calculate-function-dax#boolean-filter-expressions), so put the latest year or the parameter's value in a variable first:\n\n```\nLatest Year Sales =\nVAR LatestYear = YEAR ( CALCULATE ( MAX ( Sales[Order Date] ), REMOVEFILTERS () ) )\nRETURN\n CALCULATE ( SUM ( Sales[Amount] ), YEAR ( Sales[Order Date] ) = LatestYear )\n```\n\nFor a date table, end CALENDAR on the data rather than on a day:\n\n```\nDate = CALENDAR ( DATE ( 2020, 1, 1 ), DATE ( YEAR ( MAX ( Sales[Order Date] ) ), 12, 31 ) )\n```\n\nThis ends on the last day of the latest year in Sales, so the table spans full years, as [Microsoft's guidance](https://learn.microsoft.com/power-bi/guidance/model-date-tables) asks of a date table, and grows when a refresh brings a new year. [CALENDARAUTO](https://learn.microsoft.com/dax/calendarauto-function-dax#remarks) does the same from every date in the model outside calculated columns and tables, so a birth date or a placeholder such as December 31, 9999 stretches it too. To reach the end of the current year whether or not the data gets there yet, end CALENDAR on `DATE ( YEAR ( TODAY () ), 12, 31 )` instead.\n\nIn Power BI Desktop, select a measure, calculated column, or calculated table in the Data pane and edit its DAX in the formula bar. A calculation item is edited in Model view: select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), then select the calculation item under its calculation group, and its DAX opens in the DAX formula bar ([calculation groups](https://learn.microsoft.com/power-bi/transform-model/calculation-groups#add-a-new-calculation-group-in-model-view)). In TMDL, edit the expression after the object's `=`, or, for a date table, the `source` of its `calculated` partition.\n\n### When to ignore it\n\nA fixed period is sometimes the point: a baseline year a measure compares against, a known event such as a change of data source or a day of bad data, a cohort such as customers whose first purchase was in 2023, a rule that changed in a given year, or sample data that never changes. A date table can end on purpose too, such as one that must stop at a contract's last day.\n\nOften the better move is a name that says so. An object whose name carries its year, such as `Sales 2024` or `Growth from 19/20`, reads as deliberate to anyone who opens the model, and the rule leaves it alone.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = HARDCODED_PERIOD_IN_DAX` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"HARDCODED_PERIOD_IN_DAX\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- An object whose name carries one of the years it fixes, as four digits (`Sales 2024`) or as the year's last two digits with no digit beside them (`Jan-24`, `19/20`), is left out, since such an object is almost always meant to fix its year. Two digits can match by chance, as `Top 20` beside a fixed 2020 does, which hides that one object.\n- A date string at a date table's end whose day and month read either way, such as `\"01/02/2026\"`, is quoted as written: DAX reads it by the model's culture, which pbiplint does not settle.\n- A year outside 1950 to 2049 is left alone, such as `DATE(9999, 12, 31)` as an open end or `DATE(1900, 1, 1)` as a default, and so is the Unix epoch, `DATE(1970, 1, 1)`, the base of a conversion such as `DATE(1970, 1, 1) + 'Log'[UnixTime] / 86400`. A date table's end is reported whatever its year or day.\n- A year compared with `<`, `<=`, `>`, or `>=` is left out, since in real models it is usually a cut-off or a cohort, and so are year-month keys such as `202306` and date strings outside a date table's end, which never went stale there. Year arithmetic such as `[Year] - 2025` and fiscal-year labels such as `\"2024/25\"` are left out too, since each came up in too few models to judge. A `DATE()` with a fixed year is read whatever it is compared with, though, so `'Sales'[Order Date] >= DATE(2024, 1, 1)` is reported.\n- From a calculated table, the rule reads only where its CALENDAR calls end, so a year compared inside a date table's ADDCOLUMNS is not reported. The rest of a calculated table's DAX, user-defined functions, and row-level security filters are left out, since in real models they mostly hold inline data, sample generators, or deliberate cut-offs. Format string expressions are not read either, since they choose how a value is shown rather than which rows are counted, and fixed dates in Power Query (M) are left to pbiplint's Power Query rules, which are still to come. A date table end reached through a measure or written with arithmetic, such as `DATE(2026, 12, 31) + 1`, is not taken for a fixed end, and a `DATE()` inside a `CALENDAR` or `GENERATESERIES` call in a measure, a calculated column, or a calculation item is taken for a table's bound and left alone.\n- A month or a quarter compared with a number, a year-end date such as `\"6/30\"` given to DATESYTD, and a `DATE()` given straight to FORMAT with a format that shows no year, as in `FORMAT(DATE(2000, [Month], 1), \"mmmm\")` for a month's name, never fire: none of them goes stale. The rule counts a format as showing the year when it has a `y` in it or is one of the named formats General Date, Long Date, Medium Date, and Short Date, so `FORMAT(DATE(2024, 12, 31), \"Long Date\")` is reported. A `DATE()` inside another call within FORMAT is reported whatever the format, as in `FORMAT(EOMONTH(DATE(2000, [Month], 1), 0), \"mmmm\")`.\n- Year names are read in several languages (year, a\xF1o, anio, jahr, ann\xE9e, anno, jaar, \xE5r, and more), whatever the model's culture.\n- Desktop's own auto date/time tables are left alone, since Desktop builds them itself.\n\nRead more: https://pbiplint.com/rules/hardcoded-period-in-dax"
9077
9302
  },
9303
+ HARDCODED_YEAR_IN_FILTER: {
9304
+ text: `Example
9305
+
9306
+ Fires the rule in page.json
9307
+
9308
+ {
9309
+ "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",
9310
+ "name": "3cea48e58036b1654474",
9311
+ "displayName": "Overview",
9312
+ "displayOption": "FitToPage",
9313
+ "height": 720,
9314
+ "width": 1280,
9315
+ "filterConfig": {
9316
+ "filters": [
9317
+ {
9318
+ "name": "21d1dc168996da6409ea",
9319
+ "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Year" } },
9320
+ "type": "Categorical",
9321
+ "filter": {
9322
+ "Version": 2,
9323
+ "From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],
9324
+ "Where": [
9325
+ {
9326
+ "Condition": {
9327
+ "In": {
9328
+ "Expressions": [
9329
+ { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Year" } }
9330
+ ],
9331
+ "Values": [[{ "Literal": { "Value": "2025L" } }]]
9332
+ }
9333
+ }
9334
+ }
9335
+ ]
9336
+ },
9337
+ "howCreated": "User"
9338
+ }
9339
+ ]
9340
+ }
9341
+ }
9342
+
9343
+ After the fix in page.json
9344
+
9345
+ {
9346
+ "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",
9347
+ "name": "3cea48e58036b1654474",
9348
+ "displayName": "Overview",
9349
+ "displayOption": "FitToPage",
9350
+ "height": 720,
9351
+ "width": 1280,
9352
+ "filterConfig": {
9353
+ "filters": [
9354
+ {
9355
+ "name": "f7a189206cee6051586c",
9356
+ "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Date" } },
9357
+ "type": "RelativeDate",
9358
+ "filter": {
9359
+ "Version": 2,
9360
+ "From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],
9361
+ "Where": [
9362
+ {
9363
+ "Condition": {
9364
+ "Comparison": {
9365
+ "ComparisonKind": 0,
9366
+ "Left": { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Date" } },
9367
+ "Right": { "DateSpan": { "Expression": { "Now": {} }, "TimeUnit": 3 } }
9368
+ }
9369
+ }
9370
+ }
9371
+ ]
9372
+ },
9373
+ "howCreated": "User"
9374
+ }
9375
+ ]
9376
+ }
9377
+ }
9378
+
9379
+ The Overview page was saved with 2025 picked for 'Date'[Year] under Filters on this page, so the finding reads Page filter on "Overview" with fixed year 2025 on 'Date'[Year]. The fix filters 'Date'[Date] instead, with a relative date filter that keeps the dates in this year. Nothing in it names a year, so on January 1 the page moves to the new one by itself.
9380
+
9381
+ Why it matters
9382
+
9383
+ A filter set in the Filters pane is saved with the report, and Microsoft says such filters "become the default filter state for all your report readers" (Reset to default values (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#reset-to-default-values)). A year picked there is right for the year it was saved in and quietly wrong after it. A page filtered to 2026 still shows 2026 all through 2027, under the same titles, and nothing on it says the year has passed.
9384
+
9385
+ A filter that keeps years up to one, or years picked one by one, goes wrong more quietly still. When a new year's data arrives the filter leaves it out, and the visuals look complete without it. A hidden filter is the hardest to catch: in Microsoft's words, "If you hide the filter, they can't even see it" (Lock or hide filters (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter#lock-or-hide-filters)).
9386
+
9387
+ How to fix it
9388
+
9389
+ Filter the date, not the year, with a filter that moves with the calendar. In Power BI Desktop:
9390
+
9391
+ 1. Drag the date column, such as 'Date'[Date], from the Data pane into the section of the Filters pane that holds the year filter: Filters on this visual, Filters on this page, or Filters on all pages (Add a filter to a visual (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)).
9392
+ 2. On the date column's filter card, select Relative date from the Filter type drop-down (Create the relative date range filter (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#create-the-relative-date-range-filter)).
9393
+ 3. Under Show items when the value, choose is in this, then year.
9394
+ 4. Remove the year filter. A filter you added can be deleted from the pane; a year that is one of the visual's own fields cannot be deleted, since the visual refers to it, so clear it instead (Types of filters (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter-types#compare-filter-types)).
9395
+
9396
+ A relative date filter needs a column whose data type is a date, and it cannot use the auto date/time hierarchy, so for a filter on a date column's Year level, filter that date column itself (Considerations and limitations (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)).
9397
+
9398
+ When the filter keeps every year up to the latest one, as a range, an upper bound, or years picked one by one, it usually means every year from the first on. On its card, choose Advanced filtering, set the first condition to is greater than or equal to the first year, leave the second empty, and select Apply filter (Add a filter to a visual (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)), so each new year is kept as it arrives.
9399
+
9400
+ When the page should follow the latest year in the data rather than the calendar, as when data for a year lands weeks after it starts, or when the model has no date column, mark the year in the model instead. Right-click the date table in the Data pane, select New column, and enter the column's DAX in the formula bar (Using calculated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns#lets-look-at-an-example)):
9401
+
9402
+ Is Latest Year = 'Date'[Year] = YEAR ( MAX ( Sales[Order Date] ) )
9403
+
9404
+ Then drag Is Latest Year into the Filters pane in place of the year filter and keep True. For the calendar's current year, write YEAR ( TODAY () ) in place of the MAX. Either way the flag moves at the first refresh of the new year, since, as Microsoft puts it for calculated columns, "Column values are recalculated as necessary, like when the underlying data is refreshed and values have changed" (Using calculated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns)).
9405
+
9406
+ In the report's files, the filter is an entry in filterConfig in report.json for all pages, the page.json for a page, or the visual.json for a visual: replace the year's entry with one on the date column, as the example does.
9407
+
9408
+ When to ignore it
9409
+
9410
+ A fixed year is sometimes the point: a page or a visual about one year, such as a review of 2024; a baseline year others are compared with; a cohort; a series that has ended, such as figures that stopped being published; or sample data that never changes.
9411
+
9412
+ Often the better move is a name that says so. A page whose name, or a visual whose title, carries one of the years its filter keeps, such as Sales 2024 or Review FY24, reads as deliberate to every reader, and the rule leaves that filter alone.
9413
+
9414
+ To ignore this rule on one page or visual, add { "name": "pbiplint.ignore", "value": "HARDCODED_YEAR_IN_FILTER" } to the annotations array of its page.json or visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "HARDCODED_YEAR_IN_FILTER": "off" under rules in pbiplint.config.json.
9415
+
9416
+ Quirks
9417
+
9418
+ - A filter that leaves years out, with is not or with Select all and years cleared, or only starts from a year, with is greater than or is greater than or equal to, is not reported, since each new year still shows.
9419
+ - Years are counted whole, so is less than 2026 reads as years up to 2025, and a range from is greater than 2017 reads as starting in 2018.
9420
+ - An upper bound joined by And to anything but a lower bound, such as is not blank, and conditions joined by Or are not read.
9421
+ - A filter kept to one day, such as a date column set to December 31, 2025, is not read, and neither is a year written as a label, such as FY2025 or 2024/25, or stored as a decimal number.
9422
+ - Filters that drilling sets are left alone: a drillthrough page's field keeps the last value passed to it, and a visual saved drilled down keeps the value drilled into, and the author picked neither.
9423
+ - A filter hidden from readers or locked is reported like any other, since it still filters.
9424
+ - Only the Filters pane is read. A year saved as a slicer's selection is SLICER_SELECTION_SAVED's to report, and bookmarks are not read.
9425
+ - A name that pairs a year with a month or a quarter, such as YearMonth, is not a year column, and neither is a count of years, such as Years of Service.
9426
+ - In the Power BI service, Microsoft says "slicer and filter relative options are always based on the time in UTC" (Considerations and limitations (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)), so a relative date filter moves to the new year at midnight UTC, not at local midnight.
9427
+
9428
+ Read more: https://pbiplint.com/rules/hardcoded-year-in-filter`,
9429
+ markdown: '### Example\n\n**Fires the rule in page.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",\n "name": "3cea48e58036b1654474",\n "displayName": "Overview",\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280,\n "filterConfig": {\n "filters": [\n {\n "name": "21d1dc168996da6409ea",\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Year" } },\n "type": "Categorical",\n "filter": {\n "Version": 2,\n "From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Year" } }\n ],\n "Values": [[{ "Literal": { "Value": "2025L" } }]]\n }\n }\n }\n ]\n },\n "howCreated": "User"\n }\n ]\n }\n}\n```\n\n**After the fix in page.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",\n "name": "3cea48e58036b1654474",\n "displayName": "Overview",\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280,\n "filterConfig": {\n "filters": [\n {\n "name": "f7a189206cee6051586c",\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Date" } },\n "type": "RelativeDate",\n "filter": {\n "Version": 2,\n "From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "Comparison": {\n "ComparisonKind": 0,\n "Left": { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Date" } },\n "Right": { "DateSpan": { "Expression": { "Now": {} }, "TimeUnit": 3 } }\n }\n }\n }\n ]\n },\n "howCreated": "User"\n }\n ]\n }\n}\n```\n\nThe Overview page was saved with 2025 picked for \'Date\'[Year] under Filters on this page, so the finding reads `Page filter on "Overview"` with `fixed year 2025 on \'Date\'[Year]`. The fix filters \'Date\'[Date] instead, with a relative date filter that keeps the dates in this year. Nothing in it names a year, so on January 1 the page moves to the new one by itself.\n\n### Why it matters\n\nA filter set in the Filters pane is saved with the report, and Microsoft says such filters "become the default filter state for all your report readers" ([Reset to default values](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#reset-to-default-values)). A year picked there is right for the year it was saved in and quietly wrong after it. A page filtered to 2026 still shows 2026 all through 2027, under the same titles, and nothing on it says the year has passed.\n\nA filter that keeps years up to one, or years picked one by one, goes wrong more quietly still. When a new year\'s data arrives the filter leaves it out, and the visuals look complete without it. A hidden filter is the hardest to catch: in Microsoft\'s words, "If you hide the filter, they can\'t even see it" ([Lock or hide filters](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter#lock-or-hide-filters)).\n\n### How to fix it\n\nFilter the date, not the year, with a filter that moves with the calendar. In Power BI Desktop:\n\n1. Drag the date column, such as \'Date\'[Date], from the Data pane into the section of the Filters pane that holds the year filter: Filters on this visual, Filters on this page, or Filters on all pages ([Add a filter to a visual](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)).\n2. On the date column\'s filter card, select Relative date from the Filter type drop-down ([Create the relative date range filter](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#create-the-relative-date-range-filter)).\n3. Under Show items when the value, choose is in this, then year.\n4. Remove the year filter. A filter you added can be deleted from the pane; a year that is one of the visual\'s own fields cannot be deleted, since the visual refers to it, so clear it instead ([Types of filters](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter-types#compare-filter-types)).\n\nA relative date filter needs a column whose data type is a date, and it cannot use the auto date/time hierarchy, so for a filter on a date column\'s Year level, filter that date column itself ([Considerations and limitations](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)).\n\nWhen the filter keeps every year up to the latest one, as a range, an upper bound, or years picked one by one, it usually means every year from the first on. On its card, choose Advanced filtering, set the first condition to is greater than or equal to the first year, leave the second empty, and select Apply filter ([Add a filter to a visual](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)), so each new year is kept as it arrives.\n\nWhen the page should follow the latest year in the data rather than the calendar, as when data for a year lands weeks after it starts, or when the model has no date column, mark the year in the model instead. Right-click the date table in the Data pane, select New column, and enter the column\'s DAX in the formula bar ([Using calculated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns#lets-look-at-an-example)):\n\n```\nIs Latest Year = \'Date\'[Year] = YEAR ( MAX ( Sales[Order Date] ) )\n```\n\nThen drag Is Latest Year into the Filters pane in place of the year filter and keep True. For the calendar\'s current year, write `YEAR ( TODAY () )` in place of the MAX. Either way the flag moves at the first refresh of the new year, since, as Microsoft puts it for calculated columns, "Column values are recalculated as necessary, like when the underlying data is refreshed and values have changed" ([Using calculated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns)).\n\nIn the report\'s files, the filter is an entry in `filterConfig` in report.json for all pages, the page.json for a page, or the visual.json for a visual: replace the year\'s entry with one on the date column, as the example does.\n\n### When to ignore it\n\nA fixed year is sometimes the point: a page or a visual about one year, such as a review of 2024; a baseline year others are compared with; a cohort; a series that has ended, such as figures that stopped being published; or sample data that never changes.\n\nOften the better move is a name that says so. A page whose name, or a visual whose title, carries one of the years its filter keeps, such as `Sales 2024` or `Review FY24`, reads as deliberate to every reader, and the rule leaves that filter alone.\n\nTo ignore this rule on one page or visual, add `{ "name": "pbiplint.ignore", "value": "HARDCODED_YEAR_IN_FILTER" }` to the `annotations` array of its page.json or visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"HARDCODED_YEAR_IN_FILTER": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter that leaves years out, with is not or with Select all and years cleared, or only starts from a year, with is greater than or is greater than or equal to, is not reported, since each new year still shows.\n- Years are counted whole, so `is less than 2026` reads as `years up to 2025`, and a range from `is greater than 2017` reads as starting in 2018.\n- An upper bound joined by And to anything but a lower bound, such as is not blank, and conditions joined by Or are not read.\n- A filter kept to one day, such as a date column set to December 31, 2025, is not read, and neither is a year written as a label, such as FY2025 or 2024/25, or stored as a decimal number.\n- Filters that drilling sets are left alone: a drillthrough page\'s field keeps the last value passed to it, and a visual saved drilled down keeps the value drilled into, and the author picked neither.\n- A filter hidden from readers or locked is reported like any other, since it still filters.\n- Only the Filters pane is read. A year saved as a slicer\'s selection is `SLICER_SELECTION_SAVED`\'s to report, and bookmarks are not read.\n- A name that pairs a year with a month or a quarter, such as YearMonth, is not a year column, and neither is a count of years, such as Years of Service.\n- In the Power BI service, Microsoft says "slicer and filter relative options are always based on the time in UTC" ([Considerations and limitations](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)), so a relative date filter moves to the new year at midnight UTC, not at local midnight.\n\nRead more: https://pbiplint.com/rules/hardcoded-year-in-filter'
9430
+ },
9078
9431
  HIDDEN_VISUAL_WITH_FIELDS: {
9079
9432
  text: `Example
9080
9433
 
@@ -10435,15 +10788,16 @@ Quirks
10435
10788
  - Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an alternateOf mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and UNNECESSARY_MEASURES already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column's mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.
10436
10789
  - Calculated tables whose names start with LocalDateTable_ or DateTableTemplate_, which pbiplint reads as Power BI Desktop's auto date/time tables the way REMOVE_AUTO-DATE_TABLE does, are left out of the findings, reached or not. So is a composite model's copy of one: a LocalDateTable_ table whose entity partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with showAsVariationsOnly, so it is shown only through a date column's hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and REMOVE_AUTO-DATE_TABLE reports them; the table a copy reads is calculated in the model it extends, and that model's own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.
10437
10790
  - UNNECESSARY_MEASURES and UNNECESSARY_COLUMNS keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model's own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.
10438
- - DAX references are found by pattern, the way the model rules find them, so a field named inside a string or a comment of a reached measure counts as reached.
10791
+ - DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.
10792
+ - A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE reaches no model column: [Share] in MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), "Share", [Total] ), [Share] ) does not reach a model column called Share. Inside a call that creates the name, the name reaches what any other bare name reaches, since a call cannot read a column it is creating.
10439
10793
  - A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.
10440
- - A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is found by pattern too: the function's name, in any letter case, followed by an opening parenthesis, with no letter, digit, underscore, or dot just before the name.
10794
+ - A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis; one written inside a string or a comment is not a call.
10441
10795
  - The rule compares the report with its model, so it runs only when both are in the input.
10442
10796
  - The rule also needs every file it reads the report's fields from: report.json (the report's filters), reportExtensions.json (the report's own measures), each page.json (a page's filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, a report file could not be read, the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual's mobile.json, which hold the report's format version, the order of its pages, the order and groups of its bookmarks, and a visual's mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.
10443
10797
  - The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt table, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, a model file could not be fully read, the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A /// description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.
10444
10798
 
10445
10799
  Read more: https://pbiplint.com/rules/not-reached-from-report`,
10446
- markdown: '### Example\n\nThe example runs against a model with one table, Sales, holding Amount and Region and the measure Total Sales.\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n }\n ]\n }\n }\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n },\n {\n "field": { "Measure": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Total Sales" } },\n "queryRef": "Sales.Total Sales",\n "nativeQueryRef": "Total Sales"\n }\n ]\n }\n }\n }\n }\n}\n```\n\nWith only Region in the table, two findings come back: `[Total Sales]`, which nothing uses, and `\'Sales\'[Amount]`, which only Total Sales uses. Here the measure was meant to be in the table, so the fix adds it, and that reaches Amount through the measure\'s DAX. When a field really is unused, the fix is to delete it from the model, as How to fix it describes.\n\n### Why it matters\n\nA column earns its place in a model in one of two ways, Microsoft\'s modeling guidance says: a report filters, groups, or summarizes by it, or the model\'s structure needs it, for a relationship, a calculation, a security role, or formatting. A column that does neither can usually be removed, and an imported one is still loaded on every refresh and held in memory, where a smaller model refreshes faster and competes less for capacity. A measure nothing reaches adds no data to the model, but it sits in the Data pane beside the measures that matter, and the next author has to read it, keep it working through model changes, and guess whether something depends on it. The findings list what this report never touches, so that clean-up can start from evidence instead of a guess.\n\n### How to fix it\n\nCheck first that nothing outside this report needs the field: another report built on the same model, a paginated report, or an Excel workbook that reads the model. Removing a column that something else uses breaks that thing, and pbiplint sees only the report in front of it.\n\nThen remove the field from the model. In Power BI Desktop, right-click the measure or calculated column in the Data pane, or select it in Model view, and choose Delete from model. For a column that Power Query loads, open Power Query Editor, select the column in the table\'s query, and choose Remove Columns, so it is no longer loaded at all. If a hierarchy level uses the column, first select the hierarchy in Model view and, in the Properties pane, set its levels without that column, then select Apply Level Changes. In the TMDL files, delete the `measure` or `column` block from the table\'s file, and, for a column Power Query loads, remove it from the table\'s query as well. When the finding names a level of a user hierarchy, also delete that `level` block, which sits under its `hierarchy` block in the same file and names the column on its `column:` line, or point that `column:` at another column of the same table. A dead chain is listed with its measures before its columns, so one pass down the list removes all of it. When a detail names a user-defined function, as in `referenced only by Sales.NetAfterReserve, which nothing reaches either`, nothing reaches that function either, and it has no finding of its own: delete its `function` block from `definition/functions.tmdl` too, or edit it so it no longer names the fields you delete, since a function that names a field the model no longer has breaks.\n\n### When to ignore it\n\nA measure kept for another report on the same model, or for people who analyze the model in Excel, is not dead because this report does not use it, and neither is a column that a paginated report or a workbook reads. When several reports share the model, a finding here says only that this report does not reach the field; weigh it against the others before deleting anything, and ignore it on the fields they need.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NOT_REACHED_FROM_REPORT` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NOT_REACHED_FROM_REPORT": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.\n- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an `alternateOf` mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and `UNNECESSARY_MEASURES` already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column\'s mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.\n- Calculated tables whose names start with `LocalDateTable_` or `DateTableTemplate_`, which pbiplint reads as Power BI Desktop\'s auto date/time tables the way `REMOVE_AUTO-DATE_TABLE` does, are left out of the findings, reached or not. So is a composite model\'s copy of one: a `LocalDateTable_` table whose `entity` partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with `showAsVariationsOnly`, so it is shown only through a date column\'s hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and `REMOVE_AUTO-DATE_TABLE` reports them; the table a copy reads is calculated in the model it extends, and that model\'s own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.\n- `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model\'s own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.\n- DAX references are found by pattern, the way the model rules find them, so a field named inside a string or a comment of a reached measure counts as reached.\n- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.\n- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is found by pattern too: the function\'s name, in any letter case, followed by an opening parenthesis, with no letter, digit, underscore, or dot just before the name.\n- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
10800
+ markdown: '### Example\n\nThe example runs against a model with one table, Sales, holding Amount and Region and the measure Total Sales.\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n }\n ]\n }\n }\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n },\n {\n "field": { "Measure": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Total Sales" } },\n "queryRef": "Sales.Total Sales",\n "nativeQueryRef": "Total Sales"\n }\n ]\n }\n }\n }\n }\n}\n```\n\nWith only Region in the table, two findings come back: `[Total Sales]`, which nothing uses, and `\'Sales\'[Amount]`, which only Total Sales uses. Here the measure was meant to be in the table, so the fix adds it, and that reaches Amount through the measure\'s DAX. When a field really is unused, the fix is to delete it from the model, as How to fix it describes.\n\n### Why it matters\n\nA column earns its place in a model in one of two ways, Microsoft\'s modeling guidance says: a report filters, groups, or summarizes by it, or the model\'s structure needs it, for a relationship, a calculation, a security role, or formatting. A column that does neither can usually be removed, and an imported one is still loaded on every refresh and held in memory, where a smaller model refreshes faster and competes less for capacity. A measure nothing reaches adds no data to the model, but it sits in the Data pane beside the measures that matter, and the next author has to read it, keep it working through model changes, and guess whether something depends on it. The findings list what this report never touches, so that clean-up can start from evidence instead of a guess.\n\n### How to fix it\n\nCheck first that nothing outside this report needs the field: another report built on the same model, a paginated report, or an Excel workbook that reads the model. Removing a column that something else uses breaks that thing, and pbiplint sees only the report in front of it.\n\nThen remove the field from the model. In Power BI Desktop, right-click the measure or calculated column in the Data pane, or select it in Model view, and choose Delete from model. For a column that Power Query loads, open Power Query Editor, select the column in the table\'s query, and choose Remove Columns, so it is no longer loaded at all. If a hierarchy level uses the column, first select the hierarchy in Model view and, in the Properties pane, set its levels without that column, then select Apply Level Changes. In the TMDL files, delete the `measure` or `column` block from the table\'s file, and, for a column Power Query loads, remove it from the table\'s query as well. When the finding names a level of a user hierarchy, also delete that `level` block, which sits under its `hierarchy` block in the same file and names the column on its `column:` line, or point that `column:` at another column of the same table. A dead chain is listed with its measures before its columns, so one pass down the list removes all of it. When a detail names a user-defined function, as in `referenced only by Sales.NetAfterReserve, which nothing reaches either`, nothing reaches that function either, and it has no finding of its own: delete its `function` block from `definition/functions.tmdl` too, or edit it so it no longer names the fields you delete, since a function that names a field the model no longer has breaks.\n\n### When to ignore it\n\nA measure kept for another report on the same model, or for people who analyze the model in Excel, is not dead because this report does not use it, and neither is a column that a paginated report or a workbook reads. When several reports share the model, a finding here says only that this report does not reach the field; weigh it against the others before deleting anything, and ignore it on the fields they need.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NOT_REACHED_FROM_REPORT` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NOT_REACHED_FROM_REPORT": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.\n- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an `alternateOf` mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and `UNNECESSARY_MEASURES` already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column\'s mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.\n- Calculated tables whose names start with `LocalDateTable_` or `DateTableTemplate_`, which pbiplint reads as Power BI Desktop\'s auto date/time tables the way `REMOVE_AUTO-DATE_TABLE` does, are left out of the findings, reached or not. So is a composite model\'s copy of one: a `LocalDateTable_` table whose `entity` partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with `showAsVariationsOnly`, so it is shown only through a date column\'s hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and `REMOVE_AUTO-DATE_TABLE` reports them; the table a copy reads is calculated in the model it extends, and that model\'s own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.\n- `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model\'s own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.\n- DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE reaches no model column: `[Share]` in `MAXX ( ADDCOLUMNS ( VALUES ( \'Sales\'[Region] ), "Share", [Total] ), [Share] )` does not reach a model column called Share. Inside a call that creates the name, the name reaches what any other bare name reaches, since a call cannot read a column it is creating.\n- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.\n- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is the function\'s whole name, dots included, in any letter case, followed by an opening parenthesis; one written inside a string or a comment is not a call.\n- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
10447
10801
  },
10448
10802
  NUMERIC_COLUMN_SUMMARIZE_BY: {
10449
10803
  text: `Example
@@ -12831,7 +13185,7 @@ To ignore this rule on one visual, add { "name": "pbiplint.ignore", "value": "SL
12831
13185
 
12832
13186
  Quirks
12833
13187
 
12834
- - A filter on the slicer in the Filters pane, which visual.json keeps in the slicer's filterConfig, is a visual-level filter, not the slicer's selection, and is not reported.
13188
+ - A filter on the slicer in the Filters pane, which visual.json keeps in the slicer's filterConfig, is a visual-level filter, not the slicer's selection, and this rule does not report it. HARDCODED_YEAR_IN_FILTER reports one that holds a year column to fixed years.
12835
13189
  - Select all saves no selection. Microsoft says it produces the same filtering result as clearing the slicer, and that Power BI does not store each item as a selection. Clearing items after Select all is a selection, though: Power BI applies an is not filter holding the cleared items, and that is reported.
12836
13190
  - In Power BI Desktop's saved files, a range or relative date slicer saves its value the same way as a list of picked items, so it is reported the same way.
12837
13191
  - Each synced copy of a slicer is reported on its own page, since Power BI Desktop's saved files write the selection into every copy.
@@ -12840,7 +13194,7 @@ Quirks
12840
13194
  - Only the slicer as saved in visual.json is read. A bookmark that captures a different selection is not.
12841
13195
 
12842
13196
  Read more: https://pbiplint.com/rules/slicer-selection-saved`,
12843
- markdown: '### Example\n\nWithout a policy the finding is info. The config below applies to both documents and sets the policy, `expect` set to `none`, which raises the finding to a warning.\n\n**pbiplint.config.json**\n\n```json\n{\n "rules": {\n "SLICER_SELECTION_SAVED": { "expect": "none" }\n }\n}\n```\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } },\n "filter": {\n "filter": {\n "Version": 2,\n "From": [{ "Name": "s", "Entity": "Sales", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "s" } }, "Property": "Region" } }\n ],\n "Values": [[{ "Literal": { "Value": "\'West\'" } }]]\n }\n }\n }\n ]\n }\n }\n }\n }\n ]\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } }\n }\n }\n ]\n }\n }\n}\n```\n\nThe Region slicer was saved with West selected, so every reader starts with the page filtered to the West region, and the finding reads `slicer (096193) on "Overview"` with `opens with this selection applied`. The fix clears the selection, which takes the `filter` out of `objects.general` and leaves the slicer\'s other settings as they were.\n\n### Why it matters\n\nA slicer narrows what the other visuals show, and its selection is saved with the report: "The slicer saves the selected values," in the words of Microsoft\'s troubleshooting guidance, which goes on to warn: "Report authors should avoid saving and publishing reports with selected items that might be inappropriate for certain users, particularly in environments that use row-level security (RLS)." It recommends clearing any selection that shouldn\'t apply to everyone before saving and distributing a report, and points out that a saved selection can stop being relevant or appropriate when the data or a user\'s permissions change.\n\nA saved selection is also what readers come back to. Microsoft says readers can always return to the state the author published with the Reset to default button, so a value left selected while the author checked one region becomes every reader\'s starting point, and the state Reset to default restores.\n\n### How to fix it\n\nIn Power BI Desktop, clear the slicer and save the report in that state, as Microsoft recommends before publishing: select the slicer\'s Clear button, an eraser icon, then save. On the original slicer the Clear button sits in the Slicer header and shows when you hover over it; on the Slicer (new) visuals it sits in the Visual container header. Either way, if the header that holds it is turned off, turn that header on first, as Microsoft advises for the Slicer header, since the Clear button is not there without it. Check any bookmark that captures the slicer as well, since a bookmark saves slicer state of its own.\n\nA custom slicer from AppSource that filters through the Visual Filters API sets and clears that filter itself, as Microsoft\'s guidance for building Power BI visuals describes, so the controls for clearing it are the visual\'s own. In Power BI Desktop, clear it on the visual the way it offers, then save the report in that state. The Chiclet Slicer, for one, is cleared with the Clear button in the visual\'s top right corner, by its README in Microsoft\'s repository for the visual. Check any bookmark that captures it as well, since Microsoft\'s guidance for building visuals says that when a reader switches bookmarks, Power BI hands the visual the filter the bookmark holds.\n\nIn visual.json, the selection is the `filter` property under `objects.general[0].properties`, on a custom slicer as on the slicers built into Power BI: remove it, as the example does, and leave the rest of `general` as it is.\n\n### When to ignore it\n\nA default selection readers are meant to start from, which Microsoft endorses for slicers other than range slicers: "you might intentionally save a default selection so that report consumers start with a specific set of filters." Examples are a button slicer or list slicer with Force selection on, or an original slicer with Single select on, which Microsoft\'s visual capability data describes the same way: only one item can be chosen, and the first available is chosen when none is. Others are a slicer saved on the scenario a page opens in, such as Actual rather than Budget, and a field parameter slicer saved on the field the visuals should open with. Microsoft recommends saving range slicers cleared, and says date range slicers typically work best when they start that way, so a saved range deserves a second look. Without a policy the finding is info, a prompt to check each selection; with `expect` set to `none`, ignore it on the slicers whose default is deliberate.\n\nTo ignore this rule on one visual, add `{ "name": "pbiplint.ignore", "value": "SLICER_SELECTION_SAVED" }` to the `annotations` array of its visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"SLICER_SELECTION_SAVED": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer\'s `filterConfig`, is a visual-level filter, not the slicer\'s selection, and is not reported.\n- Select all saves no selection. Microsoft says it produces the same filtering result as clearing the slicer, and that Power BI does not store each item as a selection. Clearing items after Select all is a selection, though: Power BI applies an is not filter holding the cleared items, and that is reported.\n- In Power BI Desktop\'s saved files, a range or relative date slicer saves its value the same way as a list of picked items, so it is reported the same way.\n- Each synced copy of a slicer is reported on its own page, since Power BI Desktop\'s saved files write the selection into every copy.\n- A hidden slicer is reported like a visible one. Microsoft notes that slicers continue to filter a report page whether or not they are visible.\n- A selection is read by where it sits, not by the visual\'s type, so custom slicers from AppSource, such as the Chiclet Slicer, the Hierarchy Slicer, and the Text Filter, are reported the same way as the slicers built into Power BI. Microsoft\'s guidance for building Power BI visuals says a visual that filters through the Visual Filters API declares a `filter` in the `general` section of its capabilities and applies its filter there. Separately, Power BI Desktop\'s saved files keep these slicers\' selections in that `filter` under `objects.general`, and in those files every kind of visual that carries that `filter` is one that filters the report.\n- Only the slicer as saved in visual.json is read. A bookmark that captures a different selection is not.\n\nRead more: https://pbiplint.com/rules/slicer-selection-saved'
13197
+ markdown: '### Example\n\nWithout a policy the finding is info. The config below applies to both documents and sets the policy, `expect` set to `none`, which raises the finding to a warning.\n\n**pbiplint.config.json**\n\n```json\n{\n "rules": {\n "SLICER_SELECTION_SAVED": { "expect": "none" }\n }\n}\n```\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } },\n "filter": {\n "filter": {\n "Version": 2,\n "From": [{ "Name": "s", "Entity": "Sales", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "s" } }, "Property": "Region" } }\n ],\n "Values": [[{ "Literal": { "Value": "\'West\'" } }]]\n }\n }\n }\n ]\n }\n }\n }\n }\n ]\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } }\n }\n }\n ]\n }\n }\n}\n```\n\nThe Region slicer was saved with West selected, so every reader starts with the page filtered to the West region, and the finding reads `slicer (096193) on "Overview"` with `opens with this selection applied`. The fix clears the selection, which takes the `filter` out of `objects.general` and leaves the slicer\'s other settings as they were.\n\n### Why it matters\n\nA slicer narrows what the other visuals show, and its selection is saved with the report: "The slicer saves the selected values," in the words of Microsoft\'s troubleshooting guidance, which goes on to warn: "Report authors should avoid saving and publishing reports with selected items that might be inappropriate for certain users, particularly in environments that use row-level security (RLS)." It recommends clearing any selection that shouldn\'t apply to everyone before saving and distributing a report, and points out that a saved selection can stop being relevant or appropriate when the data or a user\'s permissions change.\n\nA saved selection is also what readers come back to. Microsoft says readers can always return to the state the author published with the Reset to default button, so a value left selected while the author checked one region becomes every reader\'s starting point, and the state Reset to default restores.\n\n### How to fix it\n\nIn Power BI Desktop, clear the slicer and save the report in that state, as Microsoft recommends before publishing: select the slicer\'s Clear button, an eraser icon, then save. On the original slicer the Clear button sits in the Slicer header and shows when you hover over it; on the Slicer (new) visuals it sits in the Visual container header. Either way, if the header that holds it is turned off, turn that header on first, as Microsoft advises for the Slicer header, since the Clear button is not there without it. Check any bookmark that captures the slicer as well, since a bookmark saves slicer state of its own.\n\nA custom slicer from AppSource that filters through the Visual Filters API sets and clears that filter itself, as Microsoft\'s guidance for building Power BI visuals describes, so the controls for clearing it are the visual\'s own. In Power BI Desktop, clear it on the visual the way it offers, then save the report in that state. The Chiclet Slicer, for one, is cleared with the Clear button in the visual\'s top right corner, by its README in Microsoft\'s repository for the visual. Check any bookmark that captures it as well, since Microsoft\'s guidance for building visuals says that when a reader switches bookmarks, Power BI hands the visual the filter the bookmark holds.\n\nIn visual.json, the selection is the `filter` property under `objects.general[0].properties`, on a custom slicer as on the slicers built into Power BI: remove it, as the example does, and leave the rest of `general` as it is.\n\n### When to ignore it\n\nA default selection readers are meant to start from, which Microsoft endorses for slicers other than range slicers: "you might intentionally save a default selection so that report consumers start with a specific set of filters." Examples are a button slicer or list slicer with Force selection on, or an original slicer with Single select on, which Microsoft\'s visual capability data describes the same way: only one item can be chosen, and the first available is chosen when none is. Others are a slicer saved on the scenario a page opens in, such as Actual rather than Budget, and a field parameter slicer saved on the field the visuals should open with. Microsoft recommends saving range slicers cleared, and says date range slicers typically work best when they start that way, so a saved range deserves a second look. Without a policy the finding is info, a prompt to check each selection; with `expect` set to `none`, ignore it on the slicers whose default is deliberate.\n\nTo ignore this rule on one visual, add `{ "name": "pbiplint.ignore", "value": "SLICER_SELECTION_SAVED" }` to the `annotations` array of its visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"SLICER_SELECTION_SAVED": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer\'s `filterConfig`, is a visual-level filter, not the slicer\'s selection, and this rule does not report it. `HARDCODED_YEAR_IN_FILTER` reports one that holds a year column to fixed years.\n- Select all saves no selection. Microsoft says it produces the same filtering result as clearing the slicer, and that Power BI does not store each item as a selection. Clearing items after Select all is a selection, though: Power BI applies an is not filter holding the cleared items, and that is reported.\n- In Power BI Desktop\'s saved files, a range or relative date slicer saves its value the same way as a list of picked items, so it is reported the same way.\n- Each synced copy of a slicer is reported on its own page, since Power BI Desktop\'s saved files write the selection into every copy.\n- A hidden slicer is reported like a visible one. Microsoft notes that slicers continue to filter a report page whether or not they are visible.\n- A selection is read by where it sits, not by the visual\'s type, so custom slicers from AppSource, such as the Chiclet Slicer, the Hierarchy Slicer, and the Text Filter, are reported the same way as the slicers built into Power BI. Microsoft\'s guidance for building Power BI visuals says a visual that filters through the Visual Filters API declares a `filter` in the `general` section of its capabilities and applies its filter there. Separately, Power BI Desktop\'s saved files keep these slicers\' selections in that `filter` under `objects.general`, and in those files every kind of visual that carries that `filter` is one that filters the report.\n- Only the slicer as saved in visual.json is read. A bookmark that captures a different selection is not.\n\nRead more: https://pbiplint.com/rules/slicer-selection-saved'
12844
13198
  },
12845
13199
  SNOWFLAKE_SCHEMA_ARCHITECTURE: {
12846
13200
  text: `Example
@@ -13233,6 +13587,181 @@ Quirks
13233
13587
  Read more: https://pbiplint.com/rules/trim-object-names`,
13234
13588
  markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID '\n dataType: int64\n sourceColumn: OrderID\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n```\n\n### 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\nIn Power BI Desktop, double-click the field in the Data pane and retype the name without the space, or select the object and clear the space from Name in the Properties pane. In the TMDL file the name is on the declaration line, so `column 'Order ID '` becomes `column 'Order ID'`. A data column carries its own `sourceColumn`, so the model name and the source column name are independent and trimming the model name does not change what the refresh loads. Renaming in Desktop updates the visuals bound to the field; a hand edit in the TMDL file does not, so open the report and check its visuals after one. Where the space arrives with a source column name, trim it in Power Query as well, so the next column you add from that query comes in clean.\n\n### When to ignore it\n\nNo name should start or end with a space. A leading space is sometimes used to push a measure to the top of the field list; a display folder gathers the measure with the ones it belongs beside, and it does that without hiding a character in the name.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = TRIM_OBJECT_NAMES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"TRIM_OBJECT_NAMES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The test is for a space character at the start or the end of the name. A name padded with a tab is not reported here; `SPECIAL_CHARS_IN_OBJECT_NAMES` covers that one.\n- The model object is in scope, and a finding on the model is always named `Model` whatever the model is called, so a stray space in the model's own name gives a finding with nothing in the line to show it. The model's name is on the `model` line in the TMDL file.\n\nRead more: https://pbiplint.com/rules/trim-object-names"
13235
13589
  },
13590
+ UDF_NOT_CALLED: {
13591
+ text: `Example
13592
+
13593
+ Fires the rule
13594
+
13595
+ table Sales
13596
+ column Amount
13597
+ dataType: decimal
13598
+ sourceColumn: Amount
13599
+ measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
13600
+ formatString: #,0
13601
+
13602
+ /// Adds 10 percent sales tax to an amount.
13603
+ function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
13604
+
13605
+ /// Adds 20 percent VAT to an amount.
13606
+ function 'Local.AddVat' = (amount: NUMERIC) => amount * 1.2
13607
+
13608
+ After the fix
13609
+
13610
+ table Sales
13611
+ column Amount
13612
+ dataType: decimal
13613
+ sourceColumn: Amount
13614
+ measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
13615
+ formatString: #,0
13616
+
13617
+ /// Adds 10 percent sales tax to an amount.
13618
+ function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
13619
+
13620
+ Why it matters
13621
+
13622
+ A function nothing calls is dead code that looks alive. It sits under Functions in Model explorer beside the ones in use, and anyone writing DAX in the model can find it and call it, with nothing to say whether it still does what its name promises or was left behind by a rewrite.
13623
+
13624
+ It also keeps other dead code alive. A hidden measure or column that a function names counts as used, whether or not anything calls the function, so UNNECESSARY_MEASURES and UNNECESSARY_COLUMNS pass over everything the dead function names. Deleting the function brings those to light.
13625
+
13626
+ A DAX Lib package installs all of its functions at once, so a model that calls one of them carries the rest unused as a matter of course. The rule reports a package only when none of it is called: the whole library was installed and never used.
13627
+
13628
+ How to fix it
13629
+
13630
+ Delete the function, after checking the callers pbiplint cannot see, listed under When to ignore it.
13631
+
13632
+ In Power BI Desktop's Model view, select Model at the top of the Data pane to open Model explorer (https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, and choose Delete from model (Using Model explorer (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). In TMDL, remove the function's block from definition/functions.tmdl: its /// description lines, its function line, and the lines indented under it. For a package, delete each function that carries its DAXLIB_PackageId annotation.
13633
+
13634
+ When to ignore it
13635
+
13636
+ When something outside the model's own DAX calls the function:
13637
+
13638
+ - A DAX query, such as a test harness that runs a model's test functions from outside it.
13639
+ - A report's own measures, which this rule does not read, including a live-connected report's, which can call the functions of the model it connects to (Considerations and limitations (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)).
13640
+ - A visual calculation, which pbiplint does not read.
13641
+
13642
+ Or when the function is kept on purpose, such as one written ahead of the measures that will call it.
13643
+
13644
+ To ignore this rule on one object, add annotation pbiplint.ignore = UDF_NOT_CALLED under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_NOT_CALLED": "off" under rules in pbiplint.config.json.
13645
+
13646
+ Quirks
13647
+
13648
+ - A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis, so MySales.Tax( is not a call to Sales.Tax. A call written inside a string or a comment is not a call.
13649
+ - The rule looks one step. A function that only an uncalled function calls is not reported until that caller is gone, and then the next run reports it.
13650
+ - A package is known by the DAXLIB_PackageId annotation that Power BI Desktop keeps when it installs a package from DAX Lib. Functions installed without that annotation, such as through semantic-link-labs, which writes its own (_functions.py (https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are each checked as the model's own.
13651
+ - While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that calls the function could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
13652
+
13653
+ Read more: https://pbiplint.com/rules/udf-not-called`,
13654
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n\n/// Adds 20 percent VAT to an amount.\nfunction 'Local.AddVat' = (amount: NUMERIC) => amount * 1.2\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nA function nothing calls is dead code that looks alive. It sits under Functions in Model explorer beside the ones in use, and anyone writing DAX in the model can find it and call it, with nothing to say whether it still does what its name promises or was left behind by a rewrite.\n\nIt also keeps other dead code alive. A hidden measure or column that a function names counts as used, whether or not anything calls the function, so `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` pass over everything the dead function names. Deleting the function brings those to light.\n\nA DAX Lib package installs all of its functions at once, so a model that calls one of them carries the rest unused as a matter of course. The rule reports a package only when none of it is called: the whole library was installed and never used.\n\n### How to fix it\n\nDelete the function, after checking the callers pbiplint cannot see, listed under When to ignore it.\n\nIn Power BI Desktop's Model view, select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, and choose Delete from model ([Using Model explorer](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). In TMDL, remove the function's block from `definition/functions.tmdl`: its `///` description lines, its `function` line, and the lines indented under it. For a package, delete each function that carries its `DAXLIB_PackageId` annotation.\n\n### When to ignore it\n\nWhen something outside the model's own DAX calls the function:\n\n- A DAX query, such as a test harness that runs a model's test functions from outside it.\n- A report's own measures, which this rule does not read, including a live-connected report's, which can call the functions of the model it connects to ([Considerations and limitations](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)).\n- A visual calculation, which pbiplint does not read.\n\nOr when the function is kept on purpose, such as one written ahead of the measures that will call it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_NOT_CALLED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_NOT_CALLED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis, so `MySales.Tax(` is not a call to `Sales.Tax`. A call written inside a string or a comment is not a call.\n- The rule looks one step. A function that only an uncalled function calls is not reported until that caller is gone, and then the next run reports it.\n- A package is known by the `DAXLIB_PackageId` annotation that Power BI Desktop keeps when it installs a package from DAX Lib. Functions installed without that annotation, such as through semantic-link-labs, which writes its own ([`_functions.py`](https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are each checked as the model's own.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that calls the function could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/udf-not-called"
13655
+ },
13656
+ UDF_USE_COMPOUND_NAMES: {
13657
+ text: `Example
13658
+
13659
+ Fires the rule
13660
+
13661
+ table Sales
13662
+ column Amount
13663
+ dataType: decimal
13664
+ sourceColumn: Amount
13665
+ measure 'Sales With Tax' = AddTax(SUM('Sales'[Amount]))
13666
+ formatString: #,0
13667
+
13668
+ /// Adds 10 percent sales tax to an amount.
13669
+ function AddTax = (amount: NUMERIC) => amount * 1.1
13670
+
13671
+ After the fix
13672
+
13673
+ table Sales
13674
+ column Amount
13675
+ dataType: decimal
13676
+ sourceColumn: Amount
13677
+ measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
13678
+ formatString: #,0
13679
+
13680
+ /// Adds 10 percent sales tax to an amount.
13681
+ function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
13682
+
13683
+ Why it matters
13684
+
13685
+ Most of DAX's built-in functions have one-word names, and new ones arrive with Power BI releases. Microsoft's naming rules say a function's name "Must not conflict with built-in DAX functions" (Define and manage user-defined functions (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#define-and-manage-user-defined-functions)), but a one-word name that is free today can be taken by a built-in tomorrow. Microsoft does not say what happens to the model's function then. Tabular Editor's guidance says that "the built-in function takes precedence and your UDF will stop working" (Use compound names for user-defined functions (https://docs.tabulareditor.com/en/kb/bpa-udf-use-compound-names.html#why-this-matters)), so every call to it would reach the built-in instead.
13686
+
13687
+ A dot or an underscore marks the name as the model's own. SQLBI's naming conventions recommend a Local. prefix for a model's own functions "to avoid conflicts with future DAX function names" (Function names (https://docs.sqlbi.com/dax-style/dax-naming-conventions#function-names)), and a library's functions start with the library's name.
13688
+
13689
+ How to fix it
13690
+
13691
+ Rename the function to a compound name, such as Local.AddTax for a function of the model's own, or a prefix for your organization or library.
13692
+
13693
+ In Power BI Desktop's Model view, select Model at the top of the Data pane to open Model explorer (https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, choose Rename, and enter the new name; Desktop updates the measures and functions that call it.
13694
+
13695
+ In TMDL, change the name on the function's line in definition/functions.tmdl, in single quotes when it holds a dot (function 'Local.AddTax' =), and at every call in the files under definition/, where it is written without quotes (Local.AddTax(). Searching those files for the old name followed by an opening parenthesis finds the calls.
13696
+
13697
+ When to ignore it
13698
+
13699
+ When the name is fixed by something outside the model: a live-connected report's own measures, or a DAX query kept elsewhere, calls the function by that name, and renaming it would break them where nothing in the model shows it. Otherwise the finding is not noise, though the risk it guards against lies in future releases rather than in the model today.
13700
+
13701
+ To ignore this rule on one object, add annotation pbiplint.ignore = UDF_USE_COMPOUND_NAMES under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_USE_COMPOUND_NAMES": "off" under rules in pbiplint.config.json.
13702
+
13703
+ Quirks
13704
+
13705
+ - The test is Tabular Editor's: a dot or an underscore anywhere in the name passes, so _toggleButton passes, and so does add_tax.
13706
+ - A dot is no guarantee against a clash. Microsoft's own built-ins include dotted names, such as INFO.USERDEFINEDFUNCTIONS (https://learn.microsoft.com/dax/info-userdefinedfunctions-function-dax).
13707
+ - A function from a DAX Lib package is checked as any other, since its name is what callers write.
13708
+
13709
+ Read more: https://pbiplint.com/rules/udf-use-compound-names`,
13710
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction AddTax = (amount: NUMERIC) => amount * 1.1\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nMost of DAX's built-in functions have one-word names, and new ones arrive with Power BI releases. Microsoft's naming rules say a function's name \"Must not conflict with built-in DAX functions\" ([Define and manage user-defined functions](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#define-and-manage-user-defined-functions)), but a one-word name that is free today can be taken by a built-in tomorrow. Microsoft does not say what happens to the model's function then. Tabular Editor's guidance says that \"the built-in function takes precedence and your UDF will stop working\" ([Use compound names for user-defined functions](https://docs.tabulareditor.com/en/kb/bpa-udf-use-compound-names.html#why-this-matters)), so every call to it would reach the built-in instead.\n\nA dot or an underscore marks the name as the model's own. SQLBI's naming conventions recommend a `Local.` prefix for a model's own functions \"to avoid conflicts with future DAX function names\" ([Function names](https://docs.sqlbi.com/dax-style/dax-naming-conventions#function-names)), and a library's functions start with the library's name.\n\n### How to fix it\n\nRename the function to a compound name, such as `Local.AddTax` for a function of the model's own, or a prefix for your organization or library.\n\nIn Power BI Desktop's Model view, select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, choose Rename, and enter the new name; Desktop updates the measures and functions that call it.\n\nIn TMDL, change the name on the function's line in `definition/functions.tmdl`, in single quotes when it holds a dot (`function 'Local.AddTax' =`), and at every call in the files under `definition/`, where it is written without quotes (`Local.AddTax(`). Searching those files for the old name followed by an opening parenthesis finds the calls.\n\n### When to ignore it\n\nWhen the name is fixed by something outside the model: a live-connected report's own measures, or a DAX query kept elsewhere, calls the function by that name, and renaming it would break them where nothing in the model shows it. Otherwise the finding is not noise, though the risk it guards against lies in future releases rather than in the model today.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_USE_COMPOUND_NAMES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_USE_COMPOUND_NAMES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The test is Tabular Editor's: a dot or an underscore anywhere in the name passes, so `_toggleButton` passes, and so does `add_tax`.\n- A dot is no guarantee against a clash. Microsoft's own built-ins include dotted names, such as [`INFO.USERDEFINEDFUNCTIONS`](https://learn.microsoft.com/dax/info-userdefinedfunctions-function-dax).\n- A function from a DAX Lib package is checked as any other, since its name is what callers write.\n\nRead more: https://pbiplint.com/rules/udf-use-compound-names"
13711
+ },
13712
+ UDF_WITHOUT_DESCRIPTION: {
13713
+ text: `Example
13714
+
13715
+ Fires the rule
13716
+
13717
+ table Sales
13718
+ column Amount
13719
+ dataType: decimal
13720
+ sourceColumn: Amount
13721
+ measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
13722
+ formatString: #,0
13723
+
13724
+ function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
13725
+
13726
+ After the fix
13727
+
13728
+ table Sales
13729
+ column Amount
13730
+ dataType: decimal
13731
+ sourceColumn: Amount
13732
+ measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
13733
+ formatString: #,0
13734
+
13735
+ /// Adds 10 percent sales tax to an amount.
13736
+ function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
13737
+
13738
+ Why it matters
13739
+
13740
+ A function is written once and called from many places, often by someone other than its author, and its description is what they see of it while they write the call. Microsoft's guidance is to document a function with /// lines, and it notes that single-line (//) or multi-line (/* */) comments "will not appear in IntelliSense function descriptions" (General form (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#general-form)). Without a description, a caller learns what the function returns, and what its parameters expect, only by opening its DAX.
13741
+
13742
+ How to fix it
13743
+
13744
+ Write a sentence or two on what the function returns and what each parameter expects.
13745
+
13746
+ In Power BI Desktop, open the function in DAX query view: in Model view, select Model at the top of the Data pane to open Model explorer, right-click the function under Functions, and choose Quick queries, then Define and evaluate (Using Model explorer (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). Write /// lines directly above its FUNCTION line and select Update model with changes (Saving to the model (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#saving-to-the-model)); the /// syntax serves "both measure and function descriptions" (Add measure descriptions (https://learn.microsoft.com/power-bi/transform-model/dax-query-view#add-measure-descriptions)). In TMDL, add the /// lines directly above the function's function line in definition/functions.tmdl, with no blank line between the last of them and the declaration.
13747
+
13748
+ Microsoft says parameter descriptions are not supported (Considerations and limitations (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)), so say what the parameters expect in the description itself; @param and @returns tags are optional.
13749
+
13750
+ When to ignore it
13751
+
13752
+ A function whose name and parameters already say everything, such as Local.Double(amount), gains little from a sentence repeating them. A function nothing calls is better deleted than described.
13753
+
13754
+ To ignore this rule on one object, add annotation pbiplint.ignore = UDF_WITHOUT_DESCRIPTION under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_WITHOUT_DESCRIPTION": "off" under rules in pbiplint.config.json.
13755
+
13756
+ Quirks
13757
+
13758
+ - Functions with a DAXLIB_PackageId annotation, which Power BI Desktop keeps when it installs a package from DAX Lib, are skipped, since a published package version cannot be edited, only replaced by a new version (Submitting a library to DAX Lib (https://docs.daxlib.org/contribute/fork-daxlib#submitting-library-to-dax-lib)). Tabular Editor's rule reports them. Functions installed without that annotation, such as through semantic-link-labs, which writes its own (_functions.py (https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are checked as the model's own.
13759
+ - A description of only spaces or tabs counts as none.
13760
+ - Whether a function is hidden makes no difference, as in Tabular Editor's rule, whose name speaks of visible functions but which reports a hidden one too.
13761
+
13762
+ Read more: https://pbiplint.com/rules/udf-without-description`,
13763
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nA function is written once and called from many places, often by someone other than its author, and its description is what they see of it while they write the call. Microsoft's guidance is to document a function with `///` lines, and it notes that single-line (`//`) or multi-line (`/* */`) comments \"will not appear in IntelliSense function descriptions\" ([General form](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#general-form)). Without a description, a caller learns what the function returns, and what its parameters expect, only by opening its DAX.\n\n### How to fix it\n\nWrite a sentence or two on what the function returns and what each parameter expects.\n\nIn Power BI Desktop, open the function in DAX query view: in Model view, select Model at the top of the Data pane to open Model explorer, right-click the function under Functions, and choose Quick queries, then Define and evaluate ([Using Model explorer](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). Write `///` lines directly above its `FUNCTION` line and select Update model with changes ([Saving to the model](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#saving-to-the-model)); the `///` syntax serves \"both measure and function descriptions\" ([Add measure descriptions](https://learn.microsoft.com/power-bi/transform-model/dax-query-view#add-measure-descriptions)). In TMDL, add the `///` lines directly above the function's `function` line in `definition/functions.tmdl`, with no blank line between the last of them and the declaration.\n\nMicrosoft says parameter descriptions are not supported ([Considerations and limitations](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)), so say what the parameters expect in the description itself; `@param` and `@returns` tags are optional.\n\n### When to ignore it\n\nA function whose name and parameters already say everything, such as `Local.Double(amount)`, gains little from a sentence repeating them. A function nothing calls is better deleted than described.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_WITHOUT_DESCRIPTION` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_WITHOUT_DESCRIPTION\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Functions with a `DAXLIB_PackageId` annotation, which Power BI Desktop keeps when it installs a package from DAX Lib, are skipped, since a published package version cannot be edited, only replaced by a new version ([Submitting a library to DAX Lib](https://docs.daxlib.org/contribute/fork-daxlib#submitting-library-to-dax-lib)). Tabular Editor's rule reports them. Functions installed without that annotation, such as through semantic-link-labs, which writes its own ([`_functions.py`](https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are checked as the model's own.\n- A description of only spaces or tabs counts as none.\n- Whether a function is hidden makes no difference, as in Tabular Editor's rule, whose name speaks of visible functions but which reports a hidden one too.\n\nRead more: https://pbiplint.com/rules/udf-without-description"
13764
+ },
13236
13765
  UNNECESSARY_COLUMNS: {
13237
13766
  text: `Example
13238
13767
 
@@ -13285,17 +13814,18 @@ To ignore this rule on one object, add annotation pbiplint.ignore = UNNECESSARY_
13285
13814
 
13286
13815
  Quirks
13287
13816
 
13288
- - 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.
13817
+ - DAX is read token by token, so a column named only inside a string or a comment of a DAX expression is not a use (a row-level security filter's text test, below, still counts it), and in extended column syntax, 'Date'[Date].[Year], only 'Date'[Date] is. A bare [Column] reference resolves measure-first, then the expression's own table, then the first table with that column.
13818
+ - A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is not a use of a model column of that name: a hidden 'Archive'[DueDate] that DAX names only as [DueDate] outside SUMMARIZE ( 'Invoices', 'Invoices'[Key], "DueDate", MAX ( 'Invoices'[DueDate] ) ) is reported, as Tabular Editor reports it. Inside a call that creates the name, the name counts as any other bare name does, since a call cannot read a column it is creating. Outside those calls pbiplint does not work out which table a row context walks, so in DAX that also creates a column Qty, the [Qty] in SUMX ( 'Sales', [Qty] ) is not a use of 'Sales'[Qty] either.
13289
13819
  - A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.
13290
13820
  - A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside SUMX ( 'Sales', [Handling Fee] ) but reported the column a function names in MAX ( [Tax Rate] ), though deleting it would break the function.
13291
13821
  - A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its groupByColumn under relatedColumnDetails, and the parameter stops working without it. The source rule does not test groupByColumn, so Tabular Editor reports that column.
13292
13822
  - 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.
13293
13823
  - Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS does read variations.
13294
- - Row-level security is matched as text, ignoring letter case, the way the source rule matches it. A bare [Column] in any role's filter already counts as a DAX reference (see above), so the text test only adds the qualified forms Table[Column] and 'Table'[Column].
13824
+ - Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: Table[Column] or 'Table'[Column] in any role's filter, or [Column] in a filter on the column's own table, counts as a use even inside a comment or a string there.
13295
13825
  - While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
13296
13826
 
13297
13827
  Read more: https://pbiplint.com/rules/unnecessary-columns`,
13298
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n column 'Legacy Region Code'\n dataType: string\n isHidden\n sourceColumn: LegacyRegionCode\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### 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\nFor a data column, stop loading it: in Power BI Desktop, Transform data, select the query, and use Choose Columns or Remove Columns, so the column never reaches the model. Where the query reads a view or a stored procedure, drop it from the select list there instead and the refresh gets shorter too. For a calculated column, right-click it in the Data pane and choose Delete from model, or remove its `column` block from the table's TMDL file. If the column turns out to be needed after all, clear Is hidden in the Properties pane, or remove `isHidden` from under the column in the file, and the finding goes with it.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden column that a visual, a slicer, or a report-level filter binds to is in use, but this rule reads the model only, as the source rule does, so it reports that column all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete anything. A column named as the default column of a variation is in the same position: the rule does not read variations, so it reports one that Power BI Desktop is quietly relying on. A staging column you are about to reference is a fair thing to leave for a week. A hidden key that no relationship uses is not: that one is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\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- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.\n- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside `SUMX ( 'Sales', [Handling Fee] )` but reported the column a function names in `MAX ( [Tax Rate] )`, though deleting it would break the function.\n- A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its `groupByColumn` under `relatedColumnDetails`, and the parameter stops working without it. The source rule does not test `groupByColumn`, so Tabular Editor reports 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- Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. `SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` does read variations.\n- Row-level security is matched as text, ignoring letter case, the way the source rule matches it. A bare `[Column]` in any role's filter already counts as a DAX reference (see above), so the text test only adds the qualified forms `Table[Column]` and `'Table'[Column]`.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-columns"
13828
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n column 'Legacy Region Code'\n dataType: string\n isHidden\n sourceColumn: LegacyRegionCode\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### 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\nFor a data column, stop loading it: in Power BI Desktop, Transform data, select the query, and use Choose Columns or Remove Columns, so the column never reaches the model. Where the query reads a view or a stored procedure, drop it from the select list there instead and the refresh gets shorter too. For a calculated column, right-click it in the Data pane and choose Delete from model, or remove its `column` block from the table's TMDL file. If the column turns out to be needed after all, clear Is hidden in the Properties pane, or remove `isHidden` from under the column in the file, and the finding goes with it.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden column that a visual, a slicer, or a report-level filter binds to is in use, but this rule reads the model only, as the source rule does, so it reports that column all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete anything. A column named as the default column of a variation is in the same position: the rule does not read variations, so it reports one that Power BI Desktop is quietly relying on. A staging column you are about to reference is a fair thing to leave for a week. A hidden key that no relationship uses is not: that one is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a column named only inside a string or a comment of a DAX expression is not a use (a row-level security filter's text test, below, still counts it), and in extended column syntax, `'Date'[Date].[Year]`, only `'Date'[Date]` is. A bare `[Column]` reference resolves measure-first, then the expression's own table, then the first table with that column.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is not a use of a model column of that name: a hidden `'Archive'[DueDate]` that DAX names only as `[DueDate]` outside `SUMMARIZE ( 'Invoices', 'Invoices'[Key], \"DueDate\", MAX ( 'Invoices'[DueDate] ) )` is reported, as Tabular Editor reports it. Inside a call that creates the name, the name counts as any other bare name does, since a call cannot read a column it is creating. Outside those calls pbiplint does not work out which table a row context walks, so in DAX that also creates a column Qty, the `[Qty]` in `SUMX ( 'Sales', [Qty] )` is not a use of `'Sales'[Qty]` either.\n- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.\n- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside `SUMX ( 'Sales', [Handling Fee] )` but reported the column a function names in `MAX ( [Tax Rate] )`, though deleting it would break the function.\n- A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its `groupByColumn` under `relatedColumnDetails`, and the parameter stops working without it. The source rule does not test `groupByColumn`, so Tabular Editor reports 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- Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. `SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` does read variations.\n- Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: `Table[Column]` or `'Table'[Column]` in any role's filter, or `[Column]` in a filter on the column's own table, counts as a use even inside a comment or a string there.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-columns"
13299
13829
  },
13300
13830
  UNNECESSARY_MEASURES: {
13301
13831
  text: `Example
@@ -13330,7 +13860,7 @@ A hidden measure that no other measure uses can only be reached by a report that
13330
13860
 
13331
13861
  How to fix it
13332
13862
 
13333
- In Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the measure block from its table, or remove isHidden from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures and their format strings, calculated columns and tables, calculation items, row-level security filters, and user-defined functions), so what a search adds is the report files, which this rule does not read, and anything else in the model that names the measure.
13863
+ In Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the measure block from its table, or remove isHidden from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures with their format strings and KPIs, calculated columns and tables, calculation items, row-level security filters, and user-defined functions), so what a search adds is the report files, which this rule does not read, and anything else in the model that names the measure.
13334
13864
 
13335
13865
  When to ignore it
13336
13866
 
@@ -13344,11 +13874,11 @@ Quirks
13344
13874
  - Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.
13345
13875
  - A row-level security filter counts as a DAX expression, so a measure named in one is used.
13346
13876
  - A measure named in a user-defined function counts as referenced, even when nothing calls the function, as Tabular Editor counts it. When the report is in the input, NOT_REACHED_FROM_REPORT follows the calls, so it reports a measure that only an uncalled function uses.
13347
- - A bare [Measure] reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. References are found by pattern, not by parsing, so a measure named inside a string or a comment counts as used too.
13877
+ - A bare [Measure] reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. DAX is read token by token, so a measure named only inside a string or a comment is not a use.
13348
13878
  - While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that references the measure could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
13349
13879
 
13350
13880
  Read more: https://pbiplint.com/rules/unnecessary-measures`,
13351
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\n measure 'Total Sales Legacy' = SUMX('Sales', 'Sales'[Amount])\n isHidden\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### 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\nIn Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the `measure` block from its table, or remove `isHidden` from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures and their format strings, calculated columns and tables, calculation items, row-level security filters, and user-defined functions), so what a search adds is the report files, which this rule does not read, and anything else in the model that names the measure.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden measure that a visual or a report-level filter binds to directly is in use, but this rule reads the model only, as the source rule does, so it reports that measure all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete a measure. A measure you have written for a calculation item or a measure you have not finished is a fair thing to leave for as long as that lasts. A hidden measure nobody can name a caller for is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_MEASURES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_MEASURES\": \"off\"` under `rules` in `pbiplint.config.json`.\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- A row-level security filter counts as a DAX expression, so a measure named in one is used.\n- A measure named in a user-defined function counts as referenced, even when nothing calls the function, as Tabular Editor counts it. When the report is in the input, `NOT_REACHED_FROM_REPORT` follows the calls, so it reports a measure that only an uncalled function uses.\n- A bare `[Measure]` reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. References are found by pattern, not by parsing, so a measure named inside a string or a comment counts as used too.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that references the measure could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-measures"
13881
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\n measure 'Total Sales Legacy' = SUMX('Sales', 'Sales'[Amount])\n isHidden\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### 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\nIn Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the `measure` block from its table, or remove `isHidden` from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures with their format strings and KPIs, calculated columns and tables, calculation items, row-level security filters, and user-defined functions), so what a search adds is the report files, which this rule does not read, and anything else in the model that names the measure.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden measure that a visual or a report-level filter binds to directly is in use, but this rule reads the model only, as the source rule does, so it reports that measure all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete a measure. A measure you have written for a calculation item or a measure you have not finished is a fair thing to leave for as long as that lasts. A hidden measure nobody can name a caller for is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_MEASURES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_MEASURES\": \"off\"` under `rules` in `pbiplint.config.json`.\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- A row-level security filter counts as a DAX expression, so a measure named in one is used.\n- A measure named in a user-defined function counts as referenced, even when nothing calls the function, as Tabular Editor counts it. When the report is in the input, `NOT_REACHED_FROM_REPORT` follows the calls, so it reports a measure that only an uncalled function uses.\n- A bare `[Measure]` reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. DAX is read token by token, so a measure named only inside a string or a comment is not a use.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that references the measure could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-measures"
13352
13882
  },
13353
13883
  "UNPIVOT_PIVOTED_(MONTH)_DATA": {
13354
13884
  text: `Example
@@ -13743,7 +14273,7 @@ var statOf = (p) => statSync(p, { throwIfNoEntry: false });
13743
14273
  var isDir = (p) => statOf(p)?.isDirectory() ?? false;
13744
14274
  var isFile = (p) => statOf(p)?.isFile() ?? false;
13745
14275
  var byName = (a, b) => a.localeCompare(b, "en");
13746
- var isRecord7 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
14276
+ var isRecord8 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
13747
14277
  function folderAt(p) {
13748
14278
  try {
13749
14279
  const stat = statOf(p);
@@ -13930,9 +14460,9 @@ function walked(input, base, read) {
13930
14460
  }
13931
14461
  function reportsNamed(name, text2) {
13932
14462
  const json = readJson(name, text2).json;
13933
- if (!isRecord7(json) || !Array.isArray(json.artifacts)) return [];
14463
+ if (!isRecord8(json) || !Array.isArray(json.artifacts)) return [];
13934
14464
  return json.artifacts.flatMap(
13935
- (a) => isRecord7(a) && isRecord7(a.report) && typeof a.report.path === "string" ? [a.report.path] : []
14465
+ (a) => isRecord8(a) && isRecord8(a.report) && typeof a.report.path === "string" ? [a.report.path] : []
13936
14466
  );
13937
14467
  }
13938
14468
  function resolvePbip(input, path) {
@@ -14106,7 +14636,7 @@ function readFolder(w, input, path, preferred) {
14106
14636
  }
14107
14637
 
14108
14638
  // src/main.ts
14109
- var VERSION2 = true ? "0.2.1" : "0.0.0-dev";
14639
+ var VERSION2 = true ? "0.2.2" : "0.0.0-dev";
14110
14640
  function listRules() {
14111
14641
  const width = Math.max(...defaultRules.map((r) => r.id.length));
14112
14642
  return defaultRules.map(