@pbiplint/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +29 -0
  3. package/README.md +34 -0
  4. package/dist/engine/config.d.ts +33 -0
  5. package/dist/engine/config.d.ts.map +1 -0
  6. package/dist/engine/config.js +61 -0
  7. package/dist/engine/config.js.map +1 -0
  8. package/dist/engine/ignore.d.ts +8 -0
  9. package/dist/engine/ignore.d.ts.map +1 -0
  10. package/dist/engine/ignore.js +17 -0
  11. package/dist/engine/ignore.js.map +1 -0
  12. package/dist/engine/lint.d.ts +38 -0
  13. package/dist/engine/lint.d.ts.map +1 -0
  14. package/dist/engine/lint.js +33 -0
  15. package/dist/engine/lint.js.map +1 -0
  16. package/dist/engine/rank.d.ts +24 -0
  17. package/dist/engine/rank.d.ts.map +1 -0
  18. package/dist/engine/rank.js +38 -0
  19. package/dist/engine/rank.js.map +1 -0
  20. package/dist/engine/run.d.ts +21 -0
  21. package/dist/engine/run.d.ts.map +1 -0
  22. package/dist/engine/run.js +43 -0
  23. package/dist/engine/run.js.map +1 -0
  24. package/dist/format/index.d.ts +11 -0
  25. package/dist/format/index.d.ts.map +1 -0
  26. package/dist/format/index.js +21 -0
  27. package/dist/format/index.js.map +1 -0
  28. package/dist/format/json.d.ts +4 -0
  29. package/dist/format/json.d.ts.map +1 -0
  30. package/dist/format/json.js +20 -0
  31. package/dist/format/json.js.map +1 -0
  32. package/dist/format/markdown.d.ts +4 -0
  33. package/dist/format/markdown.d.ts.map +1 -0
  34. package/dist/format/markdown.js +28 -0
  35. package/dist/format/markdown.js.map +1 -0
  36. package/dist/format/sarif.d.ts +4 -0
  37. package/dist/format/sarif.d.ts.map +1 -0
  38. package/dist/format/sarif.js +77 -0
  39. package/dist/format/sarif.js.map +1 -0
  40. package/dist/format/text.d.ts +31 -0
  41. package/dist/format/text.d.ts.map +1 -0
  42. package/dist/format/text.js +57 -0
  43. package/dist/format/text.js.map +1 -0
  44. package/dist/index/build.d.ts +11 -0
  45. package/dist/index/build.d.ts.map +1 -0
  46. package/dist/index/build.js +11 -0
  47. package/dist/index/build.js.map +1 -0
  48. package/dist/index/references.d.ts +37 -0
  49. package/dist/index/references.d.ts.map +1 -0
  50. package/dist/index/references.js +126 -0
  51. package/dist/index/references.js.map +1 -0
  52. package/dist/index/relationships.d.ts +10 -0
  53. package/dist/index/relationships.d.ts.map +1 -0
  54. package/dist/index/relationships.js +24 -0
  55. package/dist/index/relationships.js.map +1 -0
  56. package/dist/index/usage.d.ts +11 -0
  57. package/dist/index/usage.d.ts.map +1 -0
  58. package/dist/index/usage.js +25 -0
  59. package/dist/index/usage.js.map +1 -0
  60. package/dist/index.d.ts +25 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +21 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/model/build.d.ts +9 -0
  65. package/dist/model/build.d.ts.map +1 -0
  66. package/dist/model/build.js +282 -0
  67. package/dist/model/build.js.map +1 -0
  68. package/dist/model/names.d.ts +12 -0
  69. package/dist/model/names.d.ts.map +1 -0
  70. package/dist/model/names.js +21 -0
  71. package/dist/model/names.js.map +1 -0
  72. package/dist/model/types.d.ts +150 -0
  73. package/dist/model/types.d.ts.map +1 -0
  74. package/dist/model/types.js +2 -0
  75. package/dist/model/types.js.map +1 -0
  76. package/dist/rules/helpers.d.ts +54 -0
  77. package/dist/rules/helpers.d.ts.map +1 -0
  78. package/dist/rules/helpers.js +189 -0
  79. package/dist/rules/helpers.js.map +1 -0
  80. package/dist/rules/index.d.ts +3 -0
  81. package/dist/rules/index.d.ts.map +1 -0
  82. package/dist/rules/index.js +4 -0
  83. package/dist/rules/index.js.map +1 -0
  84. package/dist/rules/microsoft-bpa/bpa-rules.data.d.ts +12 -0
  85. package/dist/rules/microsoft-bpa/bpa-rules.data.d.ts.map +1 -0
  86. package/dist/rules/microsoft-bpa/bpa-rules.data.js +664 -0
  87. package/dist/rules/microsoft-bpa/bpa-rules.data.js.map +1 -0
  88. package/dist/rules/microsoft-bpa/columns.d.ts +14 -0
  89. package/dist/rules/microsoft-bpa/columns.d.ts.map +1 -0
  90. package/dist/rules/microsoft-bpa/columns.js +117 -0
  91. package/dist/rules/microsoft-bpa/columns.js.map +1 -0
  92. package/dist/rules/microsoft-bpa/define.d.ts +14 -0
  93. package/dist/rules/microsoft-bpa/define.d.ts.map +1 -0
  94. package/dist/rules/microsoft-bpa/define.js +75 -0
  95. package/dist/rules/microsoft-bpa/define.js.map +1 -0
  96. package/dist/rules/microsoft-bpa/dependencies.d.ts +11 -0
  97. package/dist/rules/microsoft-bpa/dependencies.d.ts.map +1 -0
  98. package/dist/rules/microsoft-bpa/dependencies.js +56 -0
  99. package/dist/rules/microsoft-bpa/dependencies.js.map +1 -0
  100. package/dist/rules/microsoft-bpa/index.d.ts +4 -0
  101. package/dist/rules/microsoft-bpa/index.d.ts.map +1 -0
  102. package/dist/rules/microsoft-bpa/index.js +20 -0
  103. package/dist/rules/microsoft-bpa/index.js.map +1 -0
  104. package/dist/rules/microsoft-bpa/live-model.d.ts +3 -0
  105. package/dist/rules/microsoft-bpa/live-model.d.ts.map +1 -0
  106. package/dist/rules/microsoft-bpa/live-model.js +10 -0
  107. package/dist/rules/microsoft-bpa/live-model.js.map +1 -0
  108. package/dist/rules/microsoft-bpa/measures.d.ts +15 -0
  109. package/dist/rules/microsoft-bpa/measures.d.ts.map +1 -0
  110. package/dist/rules/microsoft-bpa/measures.js +69 -0
  111. package/dist/rules/microsoft-bpa/measures.js.map +1 -0
  112. package/dist/rules/microsoft-bpa/naming.d.ts +14 -0
  113. package/dist/rules/microsoft-bpa/naming.d.ts.map +1 -0
  114. package/dist/rules/microsoft-bpa/naming.js +47 -0
  115. package/dist/rules/microsoft-bpa/naming.js.map +1 -0
  116. package/dist/rules/microsoft-bpa/relationships.d.ts +14 -0
  117. package/dist/rules/microsoft-bpa/relationships.d.ts.map +1 -0
  118. package/dist/rules/microsoft-bpa/relationships.js +97 -0
  119. package/dist/rules/microsoft-bpa/relationships.js.map +1 -0
  120. package/dist/rules/microsoft-bpa/tables.d.ts +16 -0
  121. package/dist/rules/microsoft-bpa/tables.d.ts.map +1 -0
  122. package/dist/rules/microsoft-bpa/tables.js +147 -0
  123. package/dist/rules/microsoft-bpa/tables.js.map +1 -0
  124. package/dist/rules/parse-issue.d.ts +4 -0
  125. package/dist/rules/parse-issue.d.ts.map +1 -0
  126. package/dist/rules/parse-issue.js +19 -0
  127. package/dist/rules/parse-issue.js.map +1 -0
  128. package/dist/rules/rule-summaries.data.d.ts +3 -0
  129. package/dist/rules/rule-summaries.data.d.ts.map +1 -0
  130. package/dist/rules/rule-summaries.data.js +78 -0
  131. package/dist/rules/rule-summaries.data.js.map +1 -0
  132. package/dist/rules/types.d.ts +42 -0
  133. package/dist/rules/types.d.ts.map +1 -0
  134. package/dist/rules/types.js +15 -0
  135. package/dist/rules/types.js.map +1 -0
  136. package/dist/tmdl/parse.d.ts +7 -0
  137. package/dist/tmdl/parse.d.ts.map +1 -0
  138. package/dist/tmdl/parse.js +196 -0
  139. package/dist/tmdl/parse.js.map +1 -0
  140. package/dist/tmdl/quote.d.ts +5 -0
  141. package/dist/tmdl/quote.d.ts.map +1 -0
  142. package/dist/tmdl/quote.js +15 -0
  143. package/dist/tmdl/quote.js.map +1 -0
  144. package/dist/tmdl/types.d.ts +32 -0
  145. package/dist/tmdl/types.d.ts.map +1 -0
  146. package/dist/tmdl/types.js +2 -0
  147. package/dist/tmdl/types.js.map +1 -0
  148. package/dist/version.d.ts +2 -0
  149. package/dist/version.d.ts.map +1 -0
  150. package/dist/version.js +3 -0
  151. package/dist/version.js.map +1 -0
  152. package/package.json +51 -0
  153. package/src/engine/config.ts +91 -0
  154. package/src/engine/ignore.ts +18 -0
  155. package/src/engine/lint.ts +79 -0
  156. package/src/engine/rank.ts +67 -0
  157. package/src/engine/run.ts +67 -0
  158. package/src/format/index.ts +37 -0
  159. package/src/format/json.ts +22 -0
  160. package/src/format/markdown.ts +37 -0
  161. package/src/format/sarif.ts +85 -0
  162. package/src/format/text.ts +90 -0
  163. package/src/index/build.ts +18 -0
  164. package/src/index/references.ts +170 -0
  165. package/src/index/relationships.ts +33 -0
  166. package/src/index/usage.ts +33 -0
  167. package/src/index.ts +80 -0
  168. package/src/model/build.ts +306 -0
  169. package/src/model/names.ts +31 -0
  170. package/src/model/types.ts +165 -0
  171. package/src/rules/helpers.ts +223 -0
  172. package/src/rules/index.ts +5 -0
  173. package/src/rules/microsoft-bpa/bpa-rules.data.ts +782 -0
  174. package/src/rules/microsoft-bpa/columns.ts +192 -0
  175. package/src/rules/microsoft-bpa/define.ts +84 -0
  176. package/src/rules/microsoft-bpa/dependencies.ts +95 -0
  177. package/src/rules/microsoft-bpa/index.ts +22 -0
  178. package/src/rules/microsoft-bpa/live-model.ts +10 -0
  179. package/src/rules/microsoft-bpa/measures.ts +143 -0
  180. package/src/rules/microsoft-bpa/naming.ts +104 -0
  181. package/src/rules/microsoft-bpa/relationships.ts +189 -0
  182. package/src/rules/microsoft-bpa/tables.ts +249 -0
  183. package/src/rules/parse-issue.ts +23 -0
  184. package/src/rules/rule-summaries.data.ts +144 -0
  185. package/src/rules/types.ts +87 -0
  186. package/src/tmdl/parse.ts +194 -0
  187. package/src/tmdl/quote.ts +15 -0
  188. package/src/tmdl/types.ts +34 -0
  189. package/src/version.ts +2 -0
@@ -0,0 +1,192 @@
1
+ import type { Column, Model } from "../../model/types.js";
2
+ import {
3
+ allColumns,
4
+ allMeasures,
5
+ allTablePermissions,
6
+ dataType,
7
+ escapeRegExp,
8
+ finding,
9
+ hiddenOrTableHidden,
10
+ isBlank,
11
+ isNumericType,
12
+ } from "../helpers.js";
13
+ import type { RuleContext, RuleFinding } from "../types.js";
14
+ import { bpaRule } from "./define.js";
15
+
16
+ const columns = (m: Model, pred: (c: Column) => boolean): RuleFinding[] =>
17
+ allColumns(m).filter(pred).map(finding.column);
18
+
19
+ export const AVOID_FLOATING_POINT_DATA_TYPES = bpaRule("AVOID_FLOATING_POINT_DATA_TYPES", (m) =>
20
+ columns(m, (c) => dataType(c) === "double"),
21
+ );
22
+
23
+ export const DATECOLUMN_FORMATSTRING = bpaRule("DATECOLUMN_FORMATSTRING", (m) =>
24
+ columns(
25
+ m,
26
+ (c) =>
27
+ /date/i.test(c.name) && dataType(c) === "datetime" && (c.formatString ?? "") !== "mm/dd/yyyy",
28
+ ),
29
+ );
30
+
31
+ export const MONTHCOLUMN_FORMATSTRING = bpaRule("MONTHCOLUMN_FORMATSTRING", (m) =>
32
+ columns(
33
+ m,
34
+ (c) =>
35
+ /month/i.test(c.name) && dataType(c) === "datetime" && (c.formatString ?? "") !== "MMMM yyyy",
36
+ ),
37
+ );
38
+
39
+ export const ADD_DATA_CATEGORY_FOR_COLUMNS = bpaRule("ADD_DATA_CATEGORY_FOR_COLUMNS", (m) =>
40
+ columns(m, (c) => {
41
+ const n = c.name.toLowerCase();
42
+ const d = dataType(c);
43
+ return (
44
+ isBlank(c.dataCategory) &&
45
+ (((n.includes("country") || n.includes("continent") || n.includes("city")) &&
46
+ d === "string") ||
47
+ ((n === "latitude" || n === "longitude") && (d === "decimal" || d === "double")))
48
+ );
49
+ }),
50
+ );
51
+
52
+ export const MONTH_AS_A_STRING_MUST_BE_SORTED = bpaRule("MONTH_(AS_A_STRING)_MUST_BE_SORTED", (m) =>
53
+ columns(m, (c) => {
54
+ const u = c.name.toUpperCase();
55
+ return (
56
+ u.includes("MONTH") &&
57
+ !u.includes("MONTHS") &&
58
+ dataType(c) === "string" &&
59
+ c.sortByColumn === undefined
60
+ );
61
+ }),
62
+ );
63
+
64
+ // TOM's default SummarizeBy is Default, which is not None, so a column without the property is flagged.
65
+ export const NUMERIC_COLUMN_SUMMARIZE_BY = bpaRule("NUMERIC_COLUMN_SUMMARIZE_BY", (m) =>
66
+ columns(
67
+ m,
68
+ (c) =>
69
+ isNumericType(c) &&
70
+ (c.summarizeBy ?? "default").toLowerCase() !== "none" &&
71
+ !hiddenOrTableHidden(c),
72
+ ),
73
+ );
74
+
75
+ export const FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS = bpaRule(
76
+ "FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS",
77
+ (m) =>
78
+ columns(
79
+ m,
80
+ (c) =>
81
+ !hiddenOrTableHidden(c) &&
82
+ ((c.name.startsWith("Is") && dataType(c) === "int64") ||
83
+ (c.name.endsWith(" Flag") && dataType(c) !== "string")),
84
+ ),
85
+ );
86
+
87
+ export const DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN = bpaRule(
88
+ "DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN",
89
+ (m) => columns(m, (c) => c.kind === "data" && isBlank(c.sourceColumn)),
90
+ );
91
+
92
+ export const ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS = bpaRule(
93
+ "ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS",
94
+ (m, { indexes: { usage } }: RuleContext) =>
95
+ columns(
96
+ m,
97
+ (c) =>
98
+ c.isAvailableInMdx &&
99
+ hiddenOrTableHidden(c) &&
100
+ !usage.usedInSortBy(c) &&
101
+ !usage.usedInHierarchies(c) &&
102
+ !usage.usedInVariations(c) &&
103
+ c.sortByColumn === undefined,
104
+ ),
105
+ );
106
+
107
+ export const SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS = bpaRule(
108
+ "SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS",
109
+ (m, { indexes: { usage } }: RuleContext) =>
110
+ columns(
111
+ m,
112
+ (c) =>
113
+ !c.isAvailableInMdx &&
114
+ (usage.usedInSortBy(c) ||
115
+ usage.usedInHierarchies(c) ||
116
+ usage.usedInVariations(c) ||
117
+ c.sortByColumn !== undefined),
118
+ ),
119
+ );
120
+
121
+ export const UNNECESSARY_COLUMNS = bpaRule("UNNECESSARY_COLUMNS", (m, { indexes }: RuleContext) => {
122
+ const permissions = allTablePermissions(m);
123
+ return columns(m, (c) => {
124
+ if (!hiddenOrTableHidden(c)) return false;
125
+ if (indexes.references.columnReferencedBy(c).length > 0) return false;
126
+ if (indexes.relationships.forColumn(c.table.name, c.name).length > 0) return false;
127
+ if (indexes.usage.usedInSortBy(c) || indexes.usage.usedInHierarchies(c)) return false;
128
+ // The source rule also does plain substring checks on RLS filters (case-insensitive).
129
+ const bare = `[${c.name}]`.toLowerCase();
130
+ const qualified = [
131
+ `${c.table.name}[${c.name}]`.toLowerCase(),
132
+ `'${c.table.name}'[${c.name}]`.toLowerCase(),
133
+ ];
134
+ for (const tp of permissions) {
135
+ const f = tp.filter?.toLowerCase();
136
+ if (f === undefined) continue;
137
+ if (tp.table === c.table.name && f.includes(bare)) return false;
138
+ if (qualified.some((q) => f.includes(q))) return false;
139
+ }
140
+ // Object-level security on the column or its table.
141
+ for (const tp of permissions) {
142
+ if (tp.table !== c.table.name) continue;
143
+ if (tp.metadataPermission === "none") return false;
144
+ if (tp.columnPermissions.some((cp) => cp.column === c.name && cp.permission === "none"))
145
+ return false;
146
+ }
147
+ return true;
148
+ });
149
+ });
150
+
151
+ const AGGREGATIONS = [
152
+ "COUNT",
153
+ "COUNTBLANK",
154
+ "SUM",
155
+ "AVERAGE",
156
+ "VALUES",
157
+ "DISTINCT",
158
+ "DISTINCTCOUNT",
159
+ "MIN",
160
+ "MAX",
161
+ "COUNTA",
162
+ "AVERAGEA",
163
+ "MAXA",
164
+ "MINA",
165
+ ];
166
+
167
+ export const HIDE_FACT_TABLE_COLUMNS = bpaRule("HIDE_FACT_TABLE_COLUMNS", (m) => {
168
+ const measures = allMeasures(m);
169
+ return columns(m, (c) => {
170
+ if (c.isHidden || !isNumericType(c)) return false;
171
+ const re = new RegExp(
172
+ `(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${escapeRegExp(c.table.name)}'*\\[${escapeRegExp(c.name)}\\]\\s*\\)`,
173
+ "i",
174
+ );
175
+ return measures.some((x) => re.test(x.expression));
176
+ });
177
+ });
178
+
179
+ export const columnRules = [
180
+ AVOID_FLOATING_POINT_DATA_TYPES,
181
+ DATECOLUMN_FORMATSTRING,
182
+ MONTHCOLUMN_FORMATSTRING,
183
+ ADD_DATA_CATEGORY_FOR_COLUMNS,
184
+ MONTH_AS_A_STRING_MUST_BE_SORTED,
185
+ NUMERIC_COLUMN_SUMMARIZE_BY,
186
+ FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS,
187
+ DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN,
188
+ ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS,
189
+ SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS,
190
+ UNNECESSARY_COLUMNS,
191
+ HIDE_FACT_TABLE_COLUMNS,
192
+ ];
@@ -0,0 +1,84 @@
1
+ import type { Model } from "../../model/types.js";
2
+ import type { Category, ObjectType, Rule, RuleContext, RuleFinding, Severity } from "../types.js";
3
+ import { RULE_SUMMARIES } from "../rule-summaries.data.js";
4
+ import { BPA_RULES, type BpaRuleMeta } from "./bpa-rules.data.js";
5
+
6
+ const byId = new Map(BPA_RULES.map((r) => [r.id, r]));
7
+
8
+ /** Microsoft scope name to pbiplint object type. KPI is not modeled in v1 and maps to nothing. */
9
+ const SCOPE_MAP: Record<string, ObjectType | null> = {
10
+ Model: "Model",
11
+ Table: "Table",
12
+ CalculatedTable: "CalculatedTable",
13
+ CalculationGroup: "CalculationGroupTable",
14
+ DataColumn: "Column",
15
+ CalculatedColumn: "CalculatedColumn",
16
+ CalculatedTableColumn: "CalculatedTableColumn",
17
+ Measure: "Measure",
18
+ Partition: "Partition",
19
+ Relationship: "Relationship",
20
+ ModelRole: "Role",
21
+ TablePermission: "TablePermission",
22
+ Perspective: "Perspective",
23
+ Hierarchy: "Hierarchy",
24
+ Level: "Level",
25
+ CalculationItem: "CalculationItem",
26
+ NamedExpression: "NamedExpression",
27
+ ProviderDataSource: "DataSource",
28
+ StructuredDataSource: "DataSource",
29
+ KPI: null,
30
+ };
31
+
32
+ export function mapScope(scope: string): ObjectType[] {
33
+ const out: ObjectType[] = [];
34
+ for (const s of scope
35
+ .split(",")
36
+ .map((x) => x.trim())
37
+ .filter((x) => x.length > 0)) {
38
+ if (!(s in SCOPE_MAP)) throw new Error(`Unknown BPA scope: ${s}`);
39
+ const t = SCOPE_MAP[s];
40
+ if (t && !out.includes(t)) out.push(t);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ export const metaOf = (id: string): BpaRuleMeta => {
46
+ const meta = byId.get(id);
47
+ if (!meta) throw new Error(`Unknown BPA rule id: ${id}`);
48
+ return meta;
49
+ };
50
+
51
+ const stripCategory = (name: string): string => name.replace(/^\[[^\]]*\]\s*/, "");
52
+
53
+ const extractUrls = (text: string): string[] => [
54
+ ...new Set((text.match(/https?:\/\/[^\s)"]+/g) ?? []).map((u) => u.replace(/[.,]$/, ""))),
55
+ ];
56
+
57
+ /**
58
+ * A literal port of one Microsoft BPA rule: metadata from the ruleset, behavior from `check`.
59
+ * The description is pbiplint's own summary from the rule page, never the ruleset's text; the
60
+ * ruleset description is read only for the reference URLs it carries.
61
+ */
62
+ export function bpaRule(
63
+ id: string,
64
+ check: (model: Model, ctx: RuleContext) => RuleFinding[],
65
+ ): Rule {
66
+ const meta = metaOf(id);
67
+ return {
68
+ id,
69
+ name: stripCategory(meta.name),
70
+ category: meta.category as Category,
71
+ severity: meta.severity as Severity,
72
+ scope: mapScope(meta.scope),
73
+ description: RULE_SUMMARIES[id] ?? stripCategory(meta.name),
74
+ fixExpression: meta.fixExpression,
75
+ references: extractUrls(meta.description),
76
+ status: "ported",
77
+ check,
78
+ };
79
+ }
80
+
81
+ /** A rule that needs VertiPaq statistics: declared so it can be listed, never run. */
82
+ export function liveModelRule(id: string): Rule {
83
+ return { ...bpaRule(id, () => []), status: "needsLiveModel" };
84
+ }
@@ -0,0 +1,95 @@
1
+ import type { RefOwner } from "../../index/references.js";
2
+ import { measureRef } from "../../model/names.js";
3
+ import type {
4
+ CalculationItem,
5
+ Column,
6
+ Measure,
7
+ Table,
8
+ TablePermission,
9
+ } from "../../model/types.js";
10
+ import { allMeasures, finding } from "../helpers.js";
11
+ import type { RuleContext, RuleFinding } from "../types.js";
12
+ import { bpaRule } from "./define.js";
13
+
14
+ /** The finding shell for whichever object owns a DAX expression. */
15
+ export function ownerFinding(o: RefOwner): RuleFinding {
16
+ switch (o.kind) {
17
+ case "measure":
18
+ return finding.measure(o.object as Measure);
19
+ case "calculatedColumn":
20
+ return finding.column(o.object as Column);
21
+ case "calculatedTable":
22
+ return finding.table(o.object as Table);
23
+ case "tablePermission":
24
+ return finding.tablePermission(o.object as TablePermission);
25
+ case "calculationItem":
26
+ return finding.calculationItem(o.object as CalculationItem);
27
+ }
28
+ }
29
+
30
+ // Scope: Measure, KPI, TablePermission, CalculationItem. KPIs are not modeled in v1. Calculation items
31
+ // never resolve bare references to columns (ground-truth item 3), so they never fire here.
32
+ export const DAX_COLUMNS_FULLY_QUALIFIED = bpaRule(
33
+ "DAX_COLUMNS_FULLY_QUALIFIED",
34
+ (_m, { indexes: { references } }: RuleContext) =>
35
+ references.owners
36
+ .filter(
37
+ (o) =>
38
+ (o.kind === "measure" || o.kind === "tablePermission" || o.kind === "calculationItem") &&
39
+ o.refs.some((r) => r.kind === "column" && !r.qualified),
40
+ )
41
+ .map(ownerFinding),
42
+ );
43
+
44
+ // Scope: Measure, CalculatedColumn, CalculatedTable, KPI, CalculationItem.
45
+ export const DAX_MEASURES_UNQUALIFIED = bpaRule(
46
+ "DAX_MEASURES_UNQUALIFIED",
47
+ (_m, { indexes: { references } }: RuleContext) =>
48
+ references.owners
49
+ .filter(
50
+ (o) =>
51
+ o.kind !== "tablePermission" && o.refs.some((r) => r.kind === "measure" && r.qualified),
52
+ )
53
+ .map(ownerFinding),
54
+ );
55
+
56
+ const stripWhitespace = (s: string): string => s.replace(/[ \n\r\t]/g, "");
57
+
58
+ export const AVOID_DUPLICATE_MEASURES = bpaRule("AVOID_DUPLICATE_MEASURES", (m) => {
59
+ const all = allMeasures(m);
60
+ const counts = new Map<string, number>();
61
+ for (const x of all) {
62
+ const k = stripWhitespace(x.expression);
63
+ counts.set(k, (counts.get(k) ?? 0) + 1);
64
+ }
65
+ return all
66
+ .filter((x) => (counts.get(stripWhitespace(x.expression)) ?? 0) > 1)
67
+ .map(finding.measure);
68
+ });
69
+
70
+ export const MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES = bpaRule(
71
+ "MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES",
72
+ (m) => {
73
+ const all = allMeasures(m);
74
+ const names = new Set(all.map((x) => measureRef(x.name)));
75
+ return all.filter((x) => names.has(x.expression)).map(finding.measure);
76
+ },
77
+ );
78
+
79
+ export const UNNECESSARY_MEASURES = bpaRule(
80
+ "UNNECESSARY_MEASURES",
81
+ (m, { indexes: { references } }: RuleContext) =>
82
+ allMeasures(m)
83
+ .filter(
84
+ (x) => (x.table.isHidden || x.isHidden) && references.measureReferencedBy(x).length === 0,
85
+ )
86
+ .map(finding.measure),
87
+ );
88
+
89
+ export const dependencyRules = [
90
+ DAX_COLUMNS_FULLY_QUALIFIED,
91
+ DAX_MEASURES_UNQUALIFIED,
92
+ AVOID_DUPLICATE_MEASURES,
93
+ MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES,
94
+ UNNECESSARY_MEASURES,
95
+ ];
@@ -0,0 +1,22 @@
1
+ import type { Rule } from "../types.js";
2
+ import { BPA_RULES } from "./bpa-rules.data.js";
3
+ import { columnRules } from "./columns.js";
4
+ import { dependencyRules } from "./dependencies.js";
5
+ import { liveModelRules } from "./live-model.js";
6
+ import { measureRules } from "./measures.js";
7
+ import { namingRules } from "./naming.js";
8
+ import { relationshipRules } from "./relationships.js";
9
+ import { tableRules } from "./tables.js";
10
+
11
+ const order = new Map(BPA_RULES.map((r, i) => [r.id, i]));
12
+
13
+ /** The microsoft-bpa pack: every rule in BPARules.json, in ruleset order. */
14
+ export const microsoftBpaRules: Rule[] = [
15
+ ...columnRules,
16
+ ...relationshipRules,
17
+ ...measureRules,
18
+ ...dependencyRules,
19
+ ...tableRules,
20
+ ...namingRules,
21
+ ...liveModelRules,
22
+ ].sort((a, b) => order.get(a.id)! - order.get(b.id)!);
@@ -0,0 +1,10 @@
1
+ import { liveModelRule } from "./define.js";
2
+
3
+ /** Rules that read VertiPaq statistics stored as annotations by a Tabular Editor script. Files never carry them. */
4
+ export const liveModelRules = [
5
+ liveModelRule("AVOID_BI-DIRECTIONAL_RELATIONSHIPS_AGAINST_HIGH-CARDINALITY_COLUMNS"),
6
+ liveModelRule("REDUCE_USAGE_OF_LONG-LENGTH_COLUMNS_WITH_HIGH_CARDINALITY"),
7
+ liveModelRule("SPLIT_DATE_AND_TIME"),
8
+ liveModelRule("LARGE_TABLES_SHOULD_BE_PARTITIONED"),
9
+ liveModelRule("FIX_REFERENTIAL_INTEGRITY_VIOLATIONS"),
10
+ ];
@@ -0,0 +1,143 @@
1
+ import {
2
+ allMeasures,
3
+ expressionObjects,
4
+ finding,
5
+ isBlank,
6
+ type ExpressionKind,
7
+ } from "../helpers.js";
8
+ import type { Measure } from "../../model/types.js";
9
+ import type { Rule } from "../types.js";
10
+ import { bpaRule } from "./define.js";
11
+
12
+ const M: ExpressionKind = "measure";
13
+ const CC: ExpressionKind = "calculatedColumn";
14
+ const CI: ExpressionKind = "calculationItem";
15
+
16
+ /** A rule that flags every object in `kinds` whose expression matches any pattern. */
17
+ const patternRule = (id: string, kinds: ExpressionKind[], patterns: RegExp[]): Rule =>
18
+ bpaRule(id, (m) =>
19
+ expressionObjects(m, kinds)
20
+ .filter((o) => patterns.some((p) => p.test(o.expression)))
21
+ .map((o) => o.finding),
22
+ );
23
+
24
+ export const PROVIDE_FORMAT_STRING_FOR_MEASURES = bpaRule(
25
+ "PROVIDE_FORMAT_STRING_FOR_MEASURES",
26
+ (m) =>
27
+ allMeasures(m)
28
+ .filter(
29
+ (x) =>
30
+ !x.isHidden &&
31
+ !x.table.isHidden &&
32
+ isBlank(x.formatString) &&
33
+ isBlank(x.formatStringDefinition),
34
+ )
35
+ .map(finding.measure),
36
+ );
37
+
38
+ // The source rule reads only the static format string, so an unformatted currency or percentage
39
+ // measure fires under a name that calls it a whole number. The detail says what the rule saw.
40
+ const formatStringDetail = (x: Measure): string => {
41
+ const fs = x.formatString ?? "";
42
+ if (fs.trim() !== "") return `format string "${fs}"`;
43
+ return isBlank(x.formatStringDefinition) ? "no format string" : "dynamic format string only";
44
+ };
45
+
46
+ export const INTEGER_FORMATTING = bpaRule("INTEGER_FORMATTING", (m) =>
47
+ allMeasures(m)
48
+ .filter((x) => {
49
+ const fs = x.formatString ?? "";
50
+ return !fs.includes("$") && !fs.includes("%") && !(fs === "#,0" || fs === "#,0.0");
51
+ })
52
+ .map((x) => ({ ...finding.measure(x), detail: formatStringDetail(x) })),
53
+ );
54
+
55
+ export const PERCENTAGE_FORMATTING = bpaRule("PERCENTAGE_FORMATTING", (m) =>
56
+ allMeasures(m)
57
+ .filter(
58
+ (x) => (x.formatString ?? "").includes("%") && x.formatString !== "#,0.0%;-#,0.0%;#,0.0%",
59
+ )
60
+ .map(finding.measure),
61
+ );
62
+
63
+ export const USE_THE_DIVIDE_FUNCTION_FOR_DIVISION = patternRule(
64
+ "USE_THE_DIVIDE_FUNCTION_FOR_DIVISION",
65
+ [M, CC, CI],
66
+ [/\]\s*\/(?!\/)(?!\*)/, /\)\s*\/(?!\/)(?!\*)/],
67
+ );
68
+
69
+ export const AVOID_USING_THE_IFERROR_FUNCTION = patternRule(
70
+ "AVOID_USING_THE_IFERROR_FUNCTION",
71
+ [M, CC],
72
+ [/IFERROR\s*\(/i],
73
+ );
74
+
75
+ export const USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT = patternRule(
76
+ "USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT",
77
+ [M, CI],
78
+ [/INTERSECT\s*\(/i],
79
+ );
80
+
81
+ export const FILTER_COLUMN_VALUES = patternRule(
82
+ "FILTER_COLUMN_VALUES",
83
+ [M, CC, CI],
84
+ [
85
+ /CALCULATE\s*\(\s*[^,]+,\s*FILTER\s*\(\s*'*[A-Za-z0-9 _]+'*\s*,\s*'*[A-Za-z0-9 _]+'*\[[A-Za-z0-9 _]+\]/i,
86
+ /CALCULATETABLE\s*\([^,]*,\s*FILTER\s*\(\s*'*[A-Za-z0-9 _]+'*,\s*'*[A-Za-z0-9 _]+'*\[[A-Za-z0-9 _]+\]/i,
87
+ ],
88
+ );
89
+
90
+ export const FILTER_MEASURE_VALUES_BY_COLUMNS = patternRule(
91
+ "FILTER_MEASURE_VALUES_BY_COLUMNS",
92
+ [M, CC, CI],
93
+ [
94
+ /CALCULATE\s*\(\s*[^,]+,\s*FILTER\s*\(\s*'*[A-Za-z0-9 _]+'*\s*,\s*\[[^\]]+\]/i,
95
+ /CALCULATETABLE\s*\([^,]*,\s*FILTER\s*\(\s*'*[A-Za-z0-9 _]+'*,\s*\[/i,
96
+ ],
97
+ );
98
+
99
+ export const AVOID_USING_1_X_Y_SYNTAX = patternRule(
100
+ "AVOID_USING_'1-(X/Y)'_SYNTAX",
101
+ [M, CC, CI],
102
+ [
103
+ /[0-9]+\s*[-+]\s*[(]*\s*SUM\s*\(\s*'*[A-Za-z0-9 _]+'*\s*\[[A-Za-z0-9 _]+\]\s*\)\s*\//i,
104
+ /[0-9]+\s*[-+]\s*DIVIDE\s*\(/i,
105
+ ],
106
+ );
107
+
108
+ export const EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS = patternRule(
109
+ "EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS",
110
+ [M],
111
+ [/EVALUATEANDLOG\s*\(/i],
112
+ );
113
+
114
+ export const REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION = patternRule(
115
+ "REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION",
116
+ [CC],
117
+ [/RELATED\s*\(/i],
118
+ );
119
+
120
+ // The TMDL reader never yields an empty expression (the next indented line becomes the expression),
121
+ // so this rule can only fire on models built some other way. It is kept for completeness.
122
+ export const EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION = bpaRule(
123
+ "EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION",
124
+ (m) =>
125
+ expressionObjects(m, [M, CC, CI])
126
+ .filter((o) => isBlank(o.expression))
127
+ .map((o) => o.finding),
128
+ );
129
+
130
+ export const measureRules = [
131
+ PROVIDE_FORMAT_STRING_FOR_MEASURES,
132
+ INTEGER_FORMATTING,
133
+ PERCENTAGE_FORMATTING,
134
+ USE_THE_DIVIDE_FUNCTION_FOR_DIVISION,
135
+ AVOID_USING_THE_IFERROR_FUNCTION,
136
+ USE_THE_TREATAS_FUNCTION_INSTEAD_OF_INTERSECT,
137
+ FILTER_COLUMN_VALUES,
138
+ FILTER_MEASURE_VALUES_BY_COLUMNS,
139
+ AVOID_USING_1_X_Y_SYNTAX,
140
+ EVALUATEANDLOG_SHOULD_NOT_BE_USED_IN_PRODUCTION_MODELS,
141
+ REDUCE_USAGE_OF_CALCULATED_COLUMNS_THAT_USE_THE_RELATED_FUNCTION,
142
+ EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION,
143
+ ];
@@ -0,0 +1,104 @@
1
+ import { allPartitions, finding, namedObjects } from "../helpers.js";
2
+ import type { Rule } from "../types.js";
3
+ import { bpaRule, mapScope, metaOf } from "./define.js";
4
+
5
+ /** A rule over every object in the rule's own scope, testing name and description. */
6
+ const namedObjectRule = (
7
+ id: string,
8
+ test: (name: string, description: string | undefined) => boolean,
9
+ ): Rule =>
10
+ bpaRule(id, (m) =>
11
+ namedObjects(m, mapScope(metaOf(id).scope))
12
+ .filter((o) => test(o.name, o.description))
13
+ .map((o) => o.finding),
14
+ );
15
+
16
+ const startsOrEndsWithSpace = (name: string): boolean => name.startsWith(" ") || name.endsWith(" ");
17
+
18
+ export const TRIM_OBJECT_NAMES = namedObjectRule("TRIM_OBJECT_NAMES", startsOrEndsWithSpace);
19
+
20
+ export const OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE = namedObjectRule(
21
+ "OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE",
22
+ startsOrEndsWithSpace,
23
+ );
24
+
25
+ export const SPECIAL_CHARS_IN_OBJECT_NAMES = namedObjectRule(
26
+ "SPECIAL_CHARS_IN_OBJECT_NAMES",
27
+ (name) => /[\t\n\r]/.test(name),
28
+ );
29
+
30
+ // .NET char.IsControl minus char.IsWhiteSpace: U+0000..U+001F and U+007F..U+009F, except U+0009..U+000D and U+0085.
31
+ // eslint-disable-next-line no-control-regex -- matching control characters is the whole point of these two rules
32
+ const CONTROL_NOT_WHITESPACE = /[\x00-\x08\x0E-\x1F\x7F-\x84\x86-\x9F]/;
33
+
34
+ export const AVOID_INVALID_NAME_CHARACTERS = namedObjectRule(
35
+ "AVOID_INVALID_NAME_CHARACTERS",
36
+ (name) => CONTROL_NOT_WHITESPACE.test(name),
37
+ );
38
+
39
+ export const AVOID_INVALID_DESCRIPTION_CHARACTERS = namedObjectRule(
40
+ "AVOID_INVALID_DESCRIPTION_CHARACTERS",
41
+ (_name, description) => CONTROL_NOT_WHITESPACE.test(description ?? ""),
42
+ );
43
+
44
+ export const FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED = namedObjectRule(
45
+ "FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED",
46
+ (name) => name.length > 0 && name.slice(0, 1).toUpperCase() !== name.slice(0, 1),
47
+ );
48
+
49
+ export const PERSPECTIVES_WITH_NO_OBJECTS = bpaRule("PERSPECTIVES_WITH_NO_OBJECTS", (m) =>
50
+ m.perspectives.filter((p) => p.tables.length === 0).map(finding.perspective),
51
+ );
52
+
53
+ export const CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS = bpaRule(
54
+ "CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS",
55
+ (m) =>
56
+ m.tables
57
+ .filter((t) => t.calculationGroup !== undefined && t.calculationGroup.items.length === 0)
58
+ .map(finding.table),
59
+ );
60
+
61
+ export const REMOVE_ROLES_WITH_NO_MEMBERS = bpaRule("REMOVE_ROLES_WITH_NO_MEMBERS", (m) =>
62
+ m.roles.filter((r) => r.members.length === 0).map(finding.role),
63
+ );
64
+
65
+ // Table.SourceExpression in the source is approximated by every partition's query or M text.
66
+ export const REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS = bpaRule(
67
+ "REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS",
68
+ (m) => {
69
+ const partitions = allPartitions(m);
70
+ return m.dataSources
71
+ .filter(
72
+ (ds) =>
73
+ !partitions.some((p) => p.dataSource === ds.name) &&
74
+ !partitions.some((p) => (p.source ?? "").includes(ds.name)),
75
+ )
76
+ .map(finding.dataSource);
77
+ },
78
+ );
79
+
80
+ export const AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS = bpaRule(
81
+ "AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS",
82
+ (m) =>
83
+ allPartitions(m)
84
+ .filter(
85
+ (p) =>
86
+ p.sourceType === "query" &&
87
+ m.dataSources.some((ds) => ds.name === p.dataSource && ds.kind === "structured"),
88
+ )
89
+ .map(finding.partition),
90
+ );
91
+
92
+ export const namingRules = [
93
+ TRIM_OBJECT_NAMES,
94
+ OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE,
95
+ SPECIAL_CHARS_IN_OBJECT_NAMES,
96
+ AVOID_INVALID_NAME_CHARACTERS,
97
+ AVOID_INVALID_DESCRIPTION_CHARACTERS,
98
+ FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED,
99
+ PERSPECTIVES_WITH_NO_OBJECTS,
100
+ CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS,
101
+ REMOVE_ROLES_WITH_NO_MEMBERS,
102
+ REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS,
103
+ AVOID_STRUCTURED_DATA_SOURCES_WITH_PROVIDER_PARTITIONS,
104
+ ];