@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,67 @@
1
+ import type { Indexes } from "../index/build.js";
2
+ import type { Model } from "../model/types.js";
3
+ import type { Finding, Rule } from "../rules/types.js";
4
+ import type { ResolvedConfig } from "./config.js";
5
+ import { isIgnored } from "./ignore.js";
6
+
7
+ export interface SkippedRule {
8
+ id: string;
9
+ reason: "disabled" | "needsLiveModel";
10
+ }
11
+
12
+ export interface RuleError {
13
+ id: string;
14
+ message: string;
15
+ }
16
+
17
+ export interface RunResult {
18
+ findings: Finding[];
19
+ rulesRun: string[];
20
+ rulesSkipped: SkippedRule[];
21
+ ruleErrors: RuleError[];
22
+ ignored: number;
23
+ }
24
+
25
+ export function runRules(
26
+ model: Model,
27
+ indexes: Indexes,
28
+ rules: Rule[],
29
+ config: ResolvedConfig,
30
+ ): RunResult {
31
+ const result: RunResult = {
32
+ findings: [],
33
+ rulesRun: [],
34
+ rulesSkipped: [],
35
+ ruleErrors: [],
36
+ ignored: 0,
37
+ };
38
+ for (const rule of rules) {
39
+ if (config.disabled.has(rule.id)) {
40
+ result.rulesSkipped.push({ id: rule.id, reason: "disabled" });
41
+ continue;
42
+ }
43
+ if (rule.status === "needsLiveModel") {
44
+ result.rulesSkipped.push({ id: rule.id, reason: "needsLiveModel" });
45
+ continue;
46
+ }
47
+ result.rulesRun.push(rule.id);
48
+ let raw;
49
+ try {
50
+ raw = rule.check(model, { indexes });
51
+ } catch (e) {
52
+ result.ruleErrors.push({ id: rule.id, message: e instanceof Error ? e.message : String(e) });
53
+ continue;
54
+ }
55
+ for (const f of raw) {
56
+ if (isIgnored(f.object, rule.id)) {
57
+ result.ignored++;
58
+ continue;
59
+ }
60
+ const out: Finding = { ruleId: rule.id, objectType: f.objectType, objectName: f.objectName };
61
+ if (f.location) out.location = f.location;
62
+ if (f.detail !== undefined) out.detail = f.detail;
63
+ result.findings.push(out);
64
+ }
65
+ }
66
+ return result;
67
+ }
@@ -0,0 +1,37 @@
1
+ import type { LintResult } from "../engine/lint.js";
2
+ import { formatJson } from "./json.js";
3
+ import { formatMarkdown } from "./markdown.js";
4
+ import { formatSarif } from "./sarif.js";
5
+ import {
6
+ formatText,
7
+ skippedLine,
8
+ summaryLine,
9
+ topGroups,
10
+ type FormatOptions,
11
+ type RuleHelp,
12
+ } from "./text.js";
13
+
14
+ export const FORMATS = ["text", "json", "markdown", "sarif"] as const;
15
+ export type FormatName = (typeof FORMATS)[number];
16
+
17
+ export function formatResult(
18
+ name: FormatName,
19
+ result: LintResult,
20
+ options: FormatOptions = {},
21
+ ): string {
22
+ switch (name) {
23
+ case "text":
24
+ return formatText(result, options);
25
+ case "json":
26
+ return formatJson(result, options);
27
+ case "markdown":
28
+ return formatMarkdown(result, options);
29
+ case "sarif":
30
+ return formatSarif(result, options);
31
+ default:
32
+ throw new Error(`Unknown format: ${String(name)}`);
33
+ }
34
+ }
35
+
36
+ export { formatJson, formatMarkdown, formatSarif, formatText, skippedLine, summaryLine, topGroups };
37
+ export type { FormatOptions, RuleHelp };
@@ -0,0 +1,22 @@
1
+ import type { LintResult } from "../engine/lint.js";
2
+ import { VERSION } from "../version.js";
3
+ import type { FormatOptions } from "./text.js";
4
+
5
+ export function formatJson(result: LintResult, options: FormatOptions = {}): string {
6
+ const doc = {
7
+ version: 1,
8
+ tool: { name: "pbiplint", version: options.toolVersion ?? VERSION },
9
+ summary: result.summary,
10
+ groups: result.groups.map((g) => ({
11
+ rule: g.rule,
12
+ count: g.findings.length,
13
+ findings: g.findings.map((f) => ({
14
+ objectType: f.objectType,
15
+ objectName: f.objectName,
16
+ ...(f.location ? { file: f.location.file, line: f.location.line } : {}),
17
+ ...(f.detail !== undefined ? { detail: f.detail } : {}),
18
+ })),
19
+ })),
20
+ };
21
+ return JSON.stringify(doc, null, 2) + "\n";
22
+ }
@@ -0,0 +1,37 @@
1
+ import type { LintResult } from "../engine/lint.js";
2
+ import { SEVERITY_LABEL } from "../rules/types.js";
3
+ import { locationOf, skippedLine, summaryLine, topGroups, type FormatOptions } from "./text.js";
4
+
5
+ const cell = (s: string): string => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
6
+
7
+ export function formatMarkdown(result: LintResult, _options: FormatOptions = {}): string {
8
+ const out: string[] = [
9
+ "# pbiplint report",
10
+ "",
11
+ `${summaryLine(result)}. ${skippedLine(result)}.`,
12
+ "",
13
+ ];
14
+ if (result.groups.length === 0) {
15
+ out.push("No findings.", "");
16
+ return out.join("\n");
17
+ }
18
+ out.push("## Fix these first", "");
19
+ topGroups(result).forEach((g, i) =>
20
+ out.push(`${i + 1}. **${g.rule.name}** (${g.findings.length}) [${g.rule.id}](${g.rule.url})`),
21
+ );
22
+ out.push("");
23
+ for (const g of result.groups) {
24
+ out.push(
25
+ `## ${SEVERITY_LABEL[g.rule.severity].toUpperCase()}: ${g.rule.name} (${g.findings.length})`,
26
+ "",
27
+ );
28
+ out.push(`[${g.rule.id}](${g.rule.url}) · ${g.rule.category}`, "");
29
+ out.push("| Object | Type | Location | Detail |", "|---|---|---|---|");
30
+ for (const f of g.findings)
31
+ out.push(
32
+ `| \`${cell(f.objectName)}\` | ${f.objectType} | ${locationOf(f)} | ${cell(f.detail ?? "")} |`,
33
+ );
34
+ out.push("");
35
+ }
36
+ return out.join("\n");
37
+ }
@@ -0,0 +1,85 @@
1
+ import type { LintResult } from "../engine/lint.js";
2
+ import { defaultRules } from "../rules/index.js";
3
+ import type { Severity } from "../rules/types.js";
4
+ import { VERSION } from "../version.js";
5
+ import type { FormatOptions, RuleHelp } from "./text.js";
6
+
7
+ const LEVEL: Record<Severity, "error" | "warning" | "note"> = {
8
+ 3: "error",
9
+ 2: "warning",
10
+ 1: "note",
11
+ };
12
+
13
+ /** Rule summaries are Markdown with inline code; SARIF's plain-text slot gets them without the backticks. */
14
+ const plain = (markdown: string): string => markdown.replace(/`/g, "");
15
+
16
+ export function formatSarif(result: LintResult, options: FormatOptions = {}): string {
17
+ const byId = new Map((options.rules ?? defaultRules).map((r) => [r.id, r]));
18
+ // Code scanning resolves artifact URIs against the repository root, so the caller can prefix the
19
+ // model root's path. Finding locations themselves stay relative to the model root.
20
+ const prefix = options.pathPrefix ?? "";
21
+ // SARIF artifact URIs are URIs, so each path segment is percent-encoded: a model folder with a
22
+ // space in its name would otherwise produce a location code scanning cannot resolve.
23
+ const encodePath = (p: string): string => p.split("/").map(encodeURIComponent).join("/");
24
+ const uri = (file: string): string => encodePath(prefix ? `${prefix}/${file}` : file);
25
+ // GitHub renders help.markdown beside the alert and ignores helpUri, so the page link rides
26
+ // inside the help block as well.
27
+ const helpFor = (id: string, description: string, url: string): RuleHelp =>
28
+ options.help?.[id] ?? {
29
+ text: `${plain(description)}\n\nRead more: ${url}`,
30
+ markdown: `${description}\n\nRead more: ${url}`,
31
+ };
32
+ const rules = result.groups.map((g) => {
33
+ const full = byId.get(g.rule.id);
34
+ return {
35
+ id: g.rule.id,
36
+ name: g.rule.name,
37
+ shortDescription: { text: g.rule.name },
38
+ fullDescription: full
39
+ ? { text: plain(full.description), markdown: full.description }
40
+ : { text: g.rule.name },
41
+ help: helpFor(g.rule.id, full?.description ?? g.rule.name, g.rule.url),
42
+ helpUri: g.rule.url,
43
+ defaultConfiguration: { level: LEVEL[g.rule.severity] },
44
+ properties: { category: g.rule.category },
45
+ };
46
+ });
47
+ const results = result.groups.flatMap((g, ruleIndex) =>
48
+ g.findings.map((f) => ({
49
+ ruleId: g.rule.id,
50
+ ruleIndex,
51
+ level: LEVEL[g.rule.severity],
52
+ message: { text: `${f.objectName}: ${g.rule.name}${f.detail ? ` (${f.detail})` : ""}` },
53
+ ...(f.location
54
+ ? {
55
+ locations: [
56
+ {
57
+ physicalLocation: {
58
+ artifactLocation: { uri: uri(f.location.file) },
59
+ region: { startLine: f.location.line },
60
+ },
61
+ },
62
+ ],
63
+ }
64
+ : {}),
65
+ })),
66
+ );
67
+ const doc = {
68
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
69
+ version: "2.1.0",
70
+ runs: [
71
+ {
72
+ tool: {
73
+ driver: {
74
+ name: "pbiplint",
75
+ version: options.toolVersion ?? VERSION,
76
+ informationUri: "https://pbiplint.com",
77
+ rules,
78
+ },
79
+ },
80
+ results,
81
+ },
82
+ ],
83
+ };
84
+ return JSON.stringify(doc, null, 2) + "\n";
85
+ }
@@ -0,0 +1,90 @@
1
+ import type { LintResult } from "../engine/lint.js";
2
+ import type { RankedGroup } from "../engine/rank.js";
3
+ import { SEVERITY_LABEL, type Finding } from "../rules/types.js";
4
+
5
+ /** Guidance for one rule in the two forms SARIF carries: plain text and Markdown. */
6
+ export interface RuleHelp {
7
+ text: string;
8
+ markdown: string;
9
+ }
10
+
11
+ export interface FormatOptions {
12
+ toolVersion?: string;
13
+ /**
14
+ * Help per rule id for the SARIF help block, which code scanning shows beside each alert.
15
+ * The CLI passes the rule pages' Why, How to fix, and Quirks sections. Without it the block
16
+ * falls back to the rule's description and page URL.
17
+ */
18
+ help?: Readonly<Record<string, RuleHelp>>;
19
+ rules?: import("../rules/types.js").Rule[];
20
+ /**
21
+ * Posix path (forward slashes, no leading "./", no trailing slash) joined in front of each
22
+ * SARIF artifact URI so code scanning can resolve it from the repository root. The text, JSON,
23
+ * and markdown formats ignore it: their paths stay relative to the model root.
24
+ */
25
+ pathPrefix?: string;
26
+ }
27
+
28
+ const SEVERITY_TAG = { 3: "ERROR", 2: "WARN ", 1: "INFO " } as const;
29
+
30
+ export const locationOf = (f: Finding): string =>
31
+ f.location ? `${f.location.file}:${f.location.line}` : "";
32
+
33
+ /** "1 rule", "2 rules". Nouns that do not take an s ("info") are written out by the caller. */
34
+ const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? "" : "s"}`;
35
+
36
+ /** Summary sentence shared by the text and markdown formats. */
37
+ export function summaryLine(result: LintResult): string {
38
+ const s = result.summary;
39
+ return `${plural(s.findings, "finding")} (${plural(s.errors, "error")}, ${plural(s.warnings, "warning")}, ${s.infos} info) in ${plural(s.files, "file")}`;
40
+ }
41
+
42
+ export function skippedLine(result: LintResult): string {
43
+ const s = result.summary;
44
+ const live = s.rulesSkipped.filter((r) => r.reason === "needsLiveModel").length;
45
+ const disabled = s.rulesSkipped.filter((r) => r.reason === "disabled").length;
46
+ const parts = [`${plural(s.rulesRun, "rule")} run`];
47
+ if (live) parts.push(`${plural(live, "rule")} skipped (need a live model)`);
48
+ if (disabled) parts.push(`${plural(disabled, "rule")} disabled by config`);
49
+ if (s.ignored) parts.push(`${plural(s.ignored, "finding")} ignored by annotation`);
50
+ return parts.join(", ");
51
+ }
52
+
53
+ export const topGroups = (result: LintResult, n = 5): RankedGroup[] => result.groups.slice(0, n);
54
+
55
+ export function formatText(result: LintResult, _options: FormatOptions = {}): string {
56
+ const out: string[] = [`pbiplint: ${summaryLine(result)}`, skippedLine(result), ""];
57
+ if (result.groups.length === 0) {
58
+ out.push("No findings.", "");
59
+ } else {
60
+ out.push("Fix these first:");
61
+ topGroups(result).forEach((g, i) =>
62
+ out.push(
63
+ ` ${i + 1}. ${g.rule.name} (${plural(g.findings.length, SEVERITY_LABEL[g.rule.severity])})`,
64
+ ),
65
+ );
66
+ out.push("");
67
+ for (const g of result.groups) {
68
+ out.push(
69
+ `${SEVERITY_TAG[g.rule.severity]} ${g.rule.name} ${g.rule.id} (${g.findings.length})`,
70
+ );
71
+ out.push(` ${g.rule.url}`);
72
+ // The location column is always emitted, empty or not, so a finding without a location never
73
+ // shifts its detail into the location column. Widths align within the group only.
74
+ const width = Math.max(...g.findings.map((f) => f.objectName.length));
75
+ const locWidth = Math.max(...g.findings.map((f) => locationOf(f).length));
76
+ for (const f of g.findings) {
77
+ const cols = [f.objectName.padEnd(width), locationOf(f).padEnd(locWidth), f.detail ?? ""];
78
+ out.push(` ${cols.join(" ")}`.trimEnd());
79
+ }
80
+ out.push("");
81
+ }
82
+ }
83
+ // Rule crashes are always reported, including on a run where nothing else fired.
84
+ if (result.summary.ruleErrors.length) {
85
+ out.push("Rule errors (please report these):");
86
+ for (const e of result.summary.ruleErrors) out.push(` ${e.id}: ${e.message}`);
87
+ out.push("");
88
+ }
89
+ return out.join("\n");
90
+ }
@@ -0,0 +1,18 @@
1
+ import type { Model } from "../model/types.js";
2
+ import { buildReferenceIndex, type ReferenceIndex } from "./references.js";
3
+ import { buildRelationshipIndex, type RelationshipIndex } from "./relationships.js";
4
+ import { buildUsageIndex, type UsageIndex } from "./usage.js";
5
+
6
+ export interface Indexes {
7
+ relationships: RelationshipIndex;
8
+ usage: UsageIndex;
9
+ references: ReferenceIndex;
10
+ }
11
+
12
+ export function buildIndexes(model: Model): Indexes {
13
+ return {
14
+ relationships: buildRelationshipIndex(model),
15
+ usage: buildUsageIndex(model),
16
+ references: buildReferenceIndex(model),
17
+ };
18
+ }
@@ -0,0 +1,170 @@
1
+ import type {
2
+ CalculationItem,
3
+ Column,
4
+ Measure,
5
+ Model,
6
+ Table,
7
+ TablePermission,
8
+ } from "../model/types.js";
9
+
10
+ export type RefOwnerKind =
11
+ "measure" | "calculatedColumn" | "calculatedTable" | "tablePermission" | "calculationItem";
12
+
13
+ export interface DaxRef {
14
+ kind: "column" | "measure" | "unresolved";
15
+ /** Canonical table name of the resolved object. */
16
+ table?: string;
17
+ /** Canonical name of the resolved object, or the raw name when unresolved. */
18
+ name: string;
19
+ qualified: boolean;
20
+ }
21
+
22
+ export interface RefOwner {
23
+ kind: RefOwnerKind;
24
+ object: Measure | Column | Table | TablePermission | CalculationItem;
25
+ ownerTable?: Table;
26
+ expression: string;
27
+ refs: DaxRef[];
28
+ }
29
+
30
+ export interface ReferenceIndex {
31
+ owners: RefOwner[];
32
+ refsOf(object: object): DaxRef[];
33
+ columnReferencedBy(c: Column): RefOwner[];
34
+ measureReferencedBy(m: Measure): RefOwner[];
35
+ }
36
+
37
+ interface RawRef {
38
+ table?: string;
39
+ name: string;
40
+ qualified: boolean;
41
+ }
42
+
43
+ // Qualified: 'Table Name'[Column] or TableName[Column]. Bare: [Name]. An unquoted table name may
44
+ // contain Unicode letters and digits (Año, Größe), so the identifier branch matches \p{L} and
45
+ // \p{N} rather than ASCII word characters.
46
+ const QUALIFIED = /(?:'((?:[^']|'')+)'\s*|([\p{L}_][\p{L}\p{N}_]*))\[([^\]]+)\]/gu;
47
+ const BARE = /\[([^\]]+)\]/g;
48
+
49
+ /**
50
+ * Regex approximation of DAX dependencies: qualified refs first, then bare refs whose `[` was not
51
+ * part of a qualified match. Strings and comments are not skipped; that is the upgrade path if a
52
+ * fixture ever breaks parity because of it.
53
+ */
54
+ export function extractRefs(expression: string): RawRef[] {
55
+ const out: RawRef[] = [];
56
+ const consumed = new Set<number>();
57
+ for (const m of expression.matchAll(QUALIFIED)) {
58
+ const table = m[1] !== undefined ? m[1].replace(/''/g, "'") : m[2]!;
59
+ out.push({ table, name: m[3]!, qualified: true });
60
+ consumed.add(m.index! + m[0].length - m[3]!.length - 2);
61
+ }
62
+ for (const m of expression.matchAll(BARE)) {
63
+ if (consumed.has(m.index!)) continue;
64
+ out.push({ name: m[1]!, qualified: false });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ const lower = (s: string): string => s.toLowerCase();
70
+ const key = (table: string, name: string): string => `${lower(table)} ${lower(name)}`;
71
+
72
+ export function buildReferenceIndex(model: Model): ReferenceIndex {
73
+ const tables = new Map<string, Table>(model.tables.map((t) => [lower(t.name), t]));
74
+ const columns = new Map<string, Column>();
75
+ const measures = new Map<string, Measure>();
76
+ for (const t of model.tables) {
77
+ for (const c of t.columns) columns.set(key(t.name, c.name), c);
78
+ for (const m of t.measures) measures.set(lower(m.name), m);
79
+ }
80
+ const columnOf = (t: Table, name: string): Column | undefined => columns.get(key(t.name, name));
81
+
82
+ const resolve = (raw: RawRef, ownerTable: Table | undefined, ownerKind: RefOwnerKind): DaxRef => {
83
+ if (raw.qualified) {
84
+ const t = tables.get(lower(raw.table!));
85
+ if (!t) return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
86
+ const col = columnOf(t, raw.name);
87
+ if (col) return { kind: "column", table: t.name, name: col.name, qualified: true };
88
+ const meas = t.measures.find((m) => lower(m.name) === lower(raw.name));
89
+ if (meas) return { kind: "measure", table: t.name, name: meas.name, qualified: true };
90
+ return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
91
+ }
92
+ const meas = measures.get(lower(raw.name));
93
+ if (meas) return { kind: "measure", table: meas.table.name, name: meas.name, qualified: false };
94
+ if (ownerKind === "calculationItem")
95
+ return { kind: "unresolved", name: raw.name, qualified: false };
96
+ if (ownerTable) {
97
+ const col = columnOf(ownerTable, raw.name);
98
+ if (col) return { kind: "column", table: ownerTable.name, name: col.name, qualified: false };
99
+ }
100
+ for (const t of model.tables) {
101
+ const col = columnOf(t, raw.name);
102
+ if (col) return { kind: "column", table: t.name, name: col.name, qualified: false };
103
+ }
104
+ return { kind: "unresolved", name: raw.name, qualified: false };
105
+ };
106
+
107
+ const owners: RefOwner[] = [];
108
+ const byObject = new Map<object, RefOwner>();
109
+ const add = (
110
+ kind: RefOwnerKind,
111
+ object: RefOwner["object"],
112
+ ownerTable: Table | undefined,
113
+ ...expressions: (string | undefined)[]
114
+ ) => {
115
+ const expression = expressions.filter((e): e is string => e !== undefined).join("\n");
116
+ const owner: RefOwner = {
117
+ kind,
118
+ object,
119
+ ownerTable,
120
+ expression,
121
+ refs: extractRefs(expression).map((r) => resolve(r, ownerTable, kind)),
122
+ };
123
+ owners.push(owner);
124
+ byObject.set(object, owner);
125
+ };
126
+ for (const t of model.tables) {
127
+ for (const m of t.measures) add("measure", m, t, m.expression, m.formatStringDefinition);
128
+ for (const c of t.columns)
129
+ if (c.kind === "calculated") add("calculatedColumn", c, t, c.expression);
130
+ if (t.kind === "calculated")
131
+ add(
132
+ "calculatedTable",
133
+ t,
134
+ t,
135
+ ...t.partitions.filter((p) => p.sourceType === "calculated").map((p) => p.source),
136
+ );
137
+ for (const item of t.calculationGroup?.items ?? [])
138
+ add("calculationItem", item, t, item.expression, item.formatStringDefinition);
139
+ }
140
+ for (const role of model.roles) {
141
+ for (const tp of role.tablePermissions)
142
+ if (tp.filter !== undefined)
143
+ add("tablePermission", tp, tables.get(lower(tp.table)), tp.filter);
144
+ }
145
+
146
+ const columnRefs = new Map<string, RefOwner[]>();
147
+ const measureRefs = new Map<string, RefOwner[]>();
148
+ for (const o of owners) {
149
+ for (const r of o.refs) {
150
+ if (r.kind === "column") {
151
+ const k = key(r.table!, r.name);
152
+ const arr = columnRefs.get(k) ?? [];
153
+ if (!arr.includes(o)) arr.push(o);
154
+ columnRefs.set(k, arr);
155
+ } else if (r.kind === "measure") {
156
+ const k = lower(r.name);
157
+ const arr = measureRefs.get(k) ?? [];
158
+ if (!arr.includes(o)) arr.push(o);
159
+ measureRefs.set(k, arr);
160
+ }
161
+ }
162
+ }
163
+
164
+ return {
165
+ owners,
166
+ refsOf: (object) => byObject.get(object)?.refs ?? [],
167
+ columnReferencedBy: (c) => columnRefs.get(key(c.table.name, c.name)) ?? [],
168
+ measureReferencedBy: (m) => measureRefs.get(lower(m.name)) ?? [],
169
+ };
170
+ }
@@ -0,0 +1,33 @@
1
+ import type { Model, Relationship } from "../model/types.js";
2
+
3
+ export interface RelationshipIndex {
4
+ all: Relationship[];
5
+ /** Relationships in which the column is the from-side or the to-side. */
6
+ forColumn(table: string, column: string): Relationship[];
7
+ /** Relationships in which the table is on either side. */
8
+ forTable(table: string): Relationship[];
9
+ }
10
+
11
+ const key = (table: string, column: string): string => `${table} ${column}`;
12
+
13
+ function push(map: Map<string, Relationship[]>, k: string, r: Relationship): void {
14
+ const arr = map.get(k);
15
+ if (!arr) map.set(k, [r]);
16
+ else if (!arr.includes(r)) arr.push(r);
17
+ }
18
+
19
+ export function buildRelationshipIndex(model: Model): RelationshipIndex {
20
+ const byColumn = new Map<string, Relationship[]>();
21
+ const byTable = new Map<string, Relationship[]>();
22
+ for (const r of model.relationships) {
23
+ push(byColumn, key(r.fromTable, r.fromColumn), r);
24
+ push(byColumn, key(r.toTable, r.toColumn), r);
25
+ push(byTable, r.fromTable, r);
26
+ push(byTable, r.toTable, r);
27
+ }
28
+ return {
29
+ all: model.relationships,
30
+ forColumn: (table, column) => byColumn.get(key(table, column)) ?? [],
31
+ forTable: (table) => byTable.get(table) ?? [],
32
+ };
33
+ }
@@ -0,0 +1,33 @@
1
+ import type { Column, Model } from "../model/types.js";
2
+
3
+ export interface UsageIndex {
4
+ /** Another column in the same table sorts by this column. */
5
+ usedInSortBy(c: Column): boolean;
6
+ /** A hierarchy level in the same table uses this column. */
7
+ usedInHierarchies(c: Column): boolean;
8
+ /** A variation anywhere names this column as its default column. */
9
+ usedInVariations(c: Column): boolean;
10
+ }
11
+
12
+ const key = (table: string, column: string): string => `${table} ${column}`;
13
+
14
+ export function buildUsageIndex(model: Model): UsageIndex {
15
+ const sortTargets = new Set<string>();
16
+ const levelColumns = new Set<string>();
17
+ const variationDefaults = new Set<string>();
18
+ for (const t of model.tables) {
19
+ for (const c of t.columns) {
20
+ if (c.sortByColumn !== undefined) sortTargets.add(key(t.name, c.sortByColumn));
21
+ for (const v of c.variations)
22
+ if (v.defaultColumn)
23
+ variationDefaults.add(key(v.defaultColumn.table, v.defaultColumn.column));
24
+ }
25
+ for (const h of t.hierarchies)
26
+ for (const l of h.levels) if (l.column !== undefined) levelColumns.add(key(t.name, l.column));
27
+ }
28
+ return {
29
+ usedInSortBy: (c) => sortTargets.has(key(c.table.name, c.name)),
30
+ usedInHierarchies: (c) => levelColumns.has(key(c.table.name, c.name)),
31
+ usedInVariations: (c) => variationDefaults.has(key(c.table.name, c.name)),
32
+ };
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ export { VERSION } from "./version.js";
2
+ export {
3
+ ConfigError,
4
+ resolveConfig,
5
+ SEVERITY_BY_NAME,
6
+ type PbiplintConfig,
7
+ type ResolvedConfig,
8
+ type SeverityName,
9
+ } from "./engine/config.js";
10
+ export { IGNORE_ANNOTATION, isIgnored } from "./engine/ignore.js";
11
+ export {
12
+ lint,
13
+ type LintFile,
14
+ type LintOptions,
15
+ type LintResult,
16
+ type LintSummary,
17
+ } from "./engine/lint.js";
18
+ export {
19
+ effectiveSeverity,
20
+ rank,
21
+ summarizeRule,
22
+ type RankedGroup,
23
+ type RuleSummary,
24
+ } from "./engine/rank.js";
25
+ export { runRules, type RuleError, type RunResult, type SkippedRule } from "./engine/run.js";
26
+ export {
27
+ FORMATS,
28
+ formatResult,
29
+ formatJson,
30
+ formatMarkdown,
31
+ formatSarif,
32
+ formatText,
33
+ skippedLine,
34
+ summaryLine,
35
+ topGroups,
36
+ type FormatName,
37
+ type FormatOptions,
38
+ type RuleHelp,
39
+ } from "./format/index.js";
40
+ export { buildIndexes, type Indexes } from "./index/build.js";
41
+ export {
42
+ extractRefs,
43
+ type DaxRef,
44
+ type RefOwner,
45
+ type RefOwnerKind,
46
+ type ReferenceIndex,
47
+ } from "./index/references.js";
48
+ export type { RelationshipIndex } from "./index/relationships.js";
49
+ export type { UsageIndex } from "./index/usage.js";
50
+ export { buildModel, splitQualifiedName } from "./model/build.js";
51
+ export {
52
+ columnRef,
53
+ measureRef,
54
+ relationshipName,
55
+ RULE_URL_BASE,
56
+ ruleUrl,
57
+ slug,
58
+ tableRef,
59
+ } from "./model/names.js";
60
+ export type * from "./model/types.js";
61
+ export {
62
+ allCalculationItems,
63
+ allColumns,
64
+ allMeasures,
65
+ allPartitions,
66
+ allTablePermissions,
67
+ columnObjectType,
68
+ finding,
69
+ namedObjects,
70
+ tableObjectType,
71
+ } from "./rules/helpers.js";
72
+ export { defaultRules } from "./rules/index.js";
73
+ export { BPA_RULES, type BpaRuleMeta } from "./rules/microsoft-bpa/bpa-rules.data.js";
74
+ export { bpaRule, liveModelRule, mapScope } from "./rules/microsoft-bpa/define.js";
75
+ export { microsoftBpaRules } from "./rules/microsoft-bpa/index.js";
76
+ export { PARSE_ISSUE } from "./rules/parse-issue.js";
77
+ export * from "./rules/types.js";
78
+ export { parseTmdl } from "./tmdl/parse.js";
79
+ export { unquoteName, unquoteValue } from "./tmdl/quote.js";
80
+ export type { ParsedFile, ParseIssue, TmdlNode, TmdlNodeKind } from "./tmdl/types.js";