pbiplint 0.1.0 → 0.1.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/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # pbiplint
2
2
 
3
3
  Best-practice linter for Power BI semantic models. Point it at a `.SemanticModel` folder, a PBIP
4
- folder, or one `.tmdl` file and get ranked findings with a link to a fix page for each rule.
5
- Nothing is uploaded: it reads the files you name and writes to your terminal. Node 20 or later.
4
+ folder, a `definition` folder, or one `.tmdl` file and get ranked findings with a link to a fix
5
+ page for each rule. Nothing is uploaded: it reads the files you name and writes to your terminal.
6
+ Node 20 or later.
6
7
 
7
8
  ```bash
8
9
  npx pbiplint path/to/Model.SemanticModel
@@ -10,12 +11,15 @@ npx pbiplint --sample # a bundled model wi
10
11
  npx pbiplint path/to/model --format sarif --output pbiplint.sarif
11
12
  npx pbiplint path/to/model --format markdown
12
13
  npx pbiplint rules # every rule with status and severity
14
+ npx pbiplint --help # every option, in one screen
15
+ npx pbiplint --version
13
16
  ```
14
17
 
15
18
  Formats: `text` (default), `json`, `sarif` (for GitHub code scanning and editors), `markdown`.
16
19
 
17
- Exit codes: `0` no findings at or above `--fail-on` (default `error`), `1` findings, `2` usage or
18
- input error. `--fail-on warning` tightens the gate; `--fail-on none` always exits 0.
20
+ Exit codes: `0` no findings at or above `--fail-on`, `1` findings, `2` usage or input error.
21
+ `--fail-on error` is the default; `--fail-on warning` and `--fail-on info` tighten the gate;
22
+ `--fail-on none` always exits 0.
19
23
 
20
24
  ## Configuration
21
25
 
package/dist/pbiplint.mjs CHANGED
@@ -5,7 +5,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
5
5
  import { basename as basename2, dirname as dirname4, relative as relative2, resolve as resolve3 } from "node:path";
6
6
 
7
7
  // ../core/src/version.ts
8
- var VERSION = "0.1.0";
8
+ var VERSION = "0.1.2";
9
9
 
10
10
  // ../core/src/engine/config.ts
11
11
  var ConfigError = class extends Error {
@@ -36,6 +36,8 @@ function resolveConfig(raw = {}) {
36
36
  for (const k of Object.keys(raw))
37
37
  if (k !== "rules" && k !== "failOn" && k !== "$schema")
38
38
  throw new ConfigError(`pbiplint.config.json: unknown key "${k}"`);
39
+ if (raw.$schema !== void 0 && typeof raw.$schema !== "string")
40
+ throw new ConfigError('pbiplint.config.json: "$schema" must be a string');
39
41
  const out = { disabled: /* @__PURE__ */ new Set(), severity: /* @__PURE__ */ new Map(), failOn: 3 };
40
42
  if (raw.rules !== void 0) {
41
43
  if (!isRecord(raw.rules))
@@ -2320,34 +2322,31 @@ function parseTmdl(file, text) {
2320
2322
  const roots = [];
2321
2323
  const issues = [];
2322
2324
  const stack = [];
2323
- let pendingDescription = [];
2324
- let descriptionLine = 0;
2325
- let descriptionText = "";
2325
+ let pendingDescription = null;
2326
+ const orphanDescription = (pending) => {
2327
+ issues.push({
2328
+ file,
2329
+ line: pending.line,
2330
+ text: pending.text,
2331
+ reason: "description is not followed by a declaration"
2332
+ });
2333
+ pendingDescription = null;
2334
+ };
2326
2335
  let i = 0;
2327
2336
  while (i < lines.length) {
2328
2337
  const raw = lines[i];
2329
2338
  const lineNo = i + 1;
2330
2339
  if (raw.trim() === "") {
2331
- if (pendingDescription.length) {
2332
- issues.push({
2333
- file,
2334
- line: descriptionLine,
2335
- text: descriptionText,
2336
- reason: "description is not followed by a declaration"
2337
- });
2338
- pendingDescription = [];
2339
- }
2340
+ if (pendingDescription) orphanDescription(pendingDescription);
2340
2341
  i++;
2341
2342
  continue;
2342
2343
  }
2343
2344
  const indent = tabIndent(raw);
2344
2345
  const content = raw.slice(indent);
2345
2346
  if (content.startsWith("///")) {
2346
- if (!pendingDescription.length) {
2347
- descriptionLine = lineNo;
2348
- descriptionText = raw;
2349
- }
2350
- pendingDescription.push(content.replace(/^\/\/\/ ?/, ""));
2347
+ const line = content.replace(/^\/\/\/ ?/, "");
2348
+ if (pendingDescription) pendingDescription.lines.push(line);
2349
+ else pendingDescription = { line: lineNo, text: raw, lines: [line] };
2351
2350
  i++;
2352
2351
  continue;
2353
2352
  }
@@ -2390,7 +2389,7 @@ function parseTmdl(file, text) {
2390
2389
  j++;
2391
2390
  }
2392
2391
  if (j >= lines.length)
2393
- issues.push({ file, line: lineNo, text: raw, reason: "unterminated ``` fence" });
2392
+ issues.push({ file, line: lineNo, text: raw, reason: "unterminated code fence" });
2394
2393
  const boundary = j < lines.length ? leadingWs(lines[j]) : 0;
2395
2394
  i = j;
2396
2395
  return out.map((l) => l.slice(Math.min(boundary, leadingWs(l)))).join("\n");
@@ -2424,9 +2423,9 @@ function parseTmdl(file, text) {
2424
2423
  node = { ...base, kind: "flag", type: h.type.toLowerCase() };
2425
2424
  }
2426
2425
  }
2427
- if (pendingDescription.length) {
2428
- node.description = pendingDescription.join("\n");
2429
- pendingDescription = [];
2426
+ if (pendingDescription) {
2427
+ node.description = pendingDescription.lines.join("\n");
2428
+ pendingDescription = null;
2430
2429
  }
2431
2430
  stack.length = indent;
2432
2431
  const parent = indent > 0 ? stack[indent - 1] : void 0;
@@ -2445,6 +2444,7 @@ function parseTmdl(file, text) {
2445
2444
  stack[indent] = node;
2446
2445
  i++;
2447
2446
  }
2447
+ if (pendingDescription) orphanDescription(pendingDescription);
2448
2448
  return { file, roots, issues, lineCount: lines.length };
2449
2449
  }
2450
2450
 
@@ -2490,7 +2490,7 @@ function rank(findings, rules, config) {
2490
2490
  g.findings.push(f);
2491
2491
  }
2492
2492
  return [...groups.values()].sort(
2493
- (a, b) => b.rule.severity - a.rule.severity || CATEGORY_ORDER.indexOf(a.rule.category) - CATEGORY_ORDER.indexOf(b.rule.category) || b.findings.length - a.findings.length || a.rule.id.localeCompare(b.rule.id)
2493
+ (a, b) => b.rule.severity - a.rule.severity || CATEGORY_ORDER.indexOf(a.rule.category) - CATEGORY_ORDER.indexOf(b.rule.category) || b.findings.length - a.findings.length || a.rule.id.localeCompare(b.rule.id, "en")
2494
2494
  );
2495
2495
  }
2496
2496
 
@@ -3132,15 +3132,15 @@ Read more: https://pbiplint.com/rules/large-tables-should-be-partitioned`,
3132
3132
  markdown: "### Why it matters\n\nWith a default summarization, dragging the column onto a visual produces an implicit sum, and it is easy to sum something that should never be summed: a year, a unit price, a percentage, a key. The implicit measure also bypasses the format string and the logic of the real measures, so two visuals of the same thing disagree. With summarization off, the column lands on a visual as a category and the author reaches for a measure.\n\n### How to fix it\n\nIn Power BI Desktop, select the column and set Summarization to Don't summarize under Column tools. In the TMDL file the property is `summarizeBy: none`. Create explicit measures for the aggregations reports need.\n\n### Quirks\n\n- A column with no summarizeBy property is treated as Default, which is not None, so it is flagged.\n\nRead more: https://pbiplint.com/rules/numeric-column-summarize-by"
3133
3133
  },
3134
3134
  OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: {
3135
- text: 'Why it matters\n\nA leading or trailing space is invisible on screen but is part of the name, so "Sales " and "Sales" are two different objects to the engine. That is enough to break a DAX reference, a report visual binding, or a deployment that expects the trimmed name, and the error you get back will name an object that looks perfectly correct. Stray spaces almost always arrive by accident, pasted in or inherited from a source column name, so trimming them is safe and rarely breaks anything downstream. TRIM_OBJECT_NAMES makes the same check across more object types at a lower severity, so every finding here appears there as well.\n\nHow to fix it\n\nRename the object without the space in Power BI Desktop, which updates the visuals and DAX that reference it, or edit the name in the TMDL file.\n\nQuirks\n\n- Narrower scope than TRIM_OBJECT_NAMES: levels, roles, expressions, calculation items, calculated tables, and calculated table columns are not checked here.\n\nRead more: https://pbiplint.com/rules/objects-should-not-start-or-end-with-a-space',
3136
- markdown: '### Why it matters\n\nA leading or trailing space is invisible on screen but is part of the name, so "Sales " and "Sales" are two different objects to the engine. That is enough to break a DAX reference, a report visual binding, or a deployment that expects the trimmed name, and the error you get back will name an object that looks perfectly correct. Stray spaces almost always arrive by accident, pasted in or inherited from a source column name, so trimming them is safe and rarely breaks anything downstream. `TRIM_OBJECT_NAMES` makes the same check across more object types at a lower severity, so every finding here appears there as well.\n\n### How to fix it\n\nRename the object without the space in Power BI Desktop, which updates the visuals and DAX that reference it, or edit the name in the TMDL file.\n\n### Quirks\n\n- Narrower scope than `TRIM_OBJECT_NAMES`: levels, roles, expressions, calculation items, calculated tables, and calculated table columns are not checked here.\n\nRead more: https://pbiplint.com/rules/objects-should-not-start-or-end-with-a-space'
3135
+ text: 'Why it matters\n\nA leading or trailing space is invisible on screen but is part of the name, so "Sales " and "Sales" are two different objects to the engine. That is enough to break a DAX reference, a report visual binding, or a deployment that expects the trimmed name, and the error you get back will name an object that looks perfectly correct. Stray spaces almost always arrive by accident, pasted in or inherited from a source column name, so trimming them is safe and rarely breaks anything downstream. TRIM_OBJECT_NAMES makes the same check across more object types at a lower severity, so every finding here appears there as well.\n\nHow to fix it\n\nRename the object without the space in Power BI Desktop, which updates the visuals and DAX that reference it, or edit the name in the TMDL file.\n\nQuirks\n\n- Narrower scope than TRIM_OBJECT_NAMES: levels, roles, expressions, calculation items, calculated tables, and calculated table columns are not checked here.\n- In the browser, the "Choose a folder" button in Chrome and Edge does not list a file whose name begins or ends with a space, so a table file named that way is never read on that route and its findings are missing. Drag the folder onto the page, or use the command line, to read it.\n\nRead more: https://pbiplint.com/rules/objects-should-not-start-or-end-with-a-space',
3136
+ markdown: '### Why it matters\n\nA leading or trailing space is invisible on screen but is part of the name, so "Sales " and "Sales" are two different objects to the engine. That is enough to break a DAX reference, a report visual binding, or a deployment that expects the trimmed name, and the error you get back will name an object that looks perfectly correct. Stray spaces almost always arrive by accident, pasted in or inherited from a source column name, so trimming them is safe and rarely breaks anything downstream. `TRIM_OBJECT_NAMES` makes the same check across more object types at a lower severity, so every finding here appears there as well.\n\n### How to fix it\n\nRename the object without the space in Power BI Desktop, which updates the visuals and DAX that reference it, or edit the name in the TMDL file.\n\n### Quirks\n\n- Narrower scope than `TRIM_OBJECT_NAMES`: levels, roles, expressions, calculation items, calculated tables, and calculated table columns are not checked here.\n- In the browser, the "Choose a folder" button in Chrome and Edge does not list a file whose name begins or ends with a space, so a table file named that way is never read on that route and its findings are missing. Drag the folder onto the page, or use the command line, to read it.\n\nRead more: https://pbiplint.com/rules/objects-should-not-start-or-end-with-a-space'
3137
3137
  },
3138
3138
  OBJECTS_WITH_NO_DESCRIPTION: {
3139
3139
  text: "Why it matters\n\nThe description is the tooltip a report author sees when hovering a field in the field list, and it is the only place in the model to say what a measure counts, which currency a column is in, or which of two similar fields to use. Without it, every author works that out from the name, and gets it wrong at about the same rate. Descriptions also feed documentation tools, so the same sentence pays off twice.\n\nHow to fix it\n\nIn Power BI Desktop, open Model view, select the object, and type the Description in the Properties pane. In the TMDL file a description is one or more /// lines directly above the object. With hundreds of objects, Tabular Editor can paste descriptions into many objects at once; the TMDL comment lines need no other tool.\n\nQuirks\n\n- Visibility is the object's own isHidden flag: a visible column inside a hidden table is still reported.\n- A calculation group table is reported once, as a calculation group.\n\nRead more: https://pbiplint.com/rules/objects-with-no-description",
3140
3140
  markdown: "### Why it matters\n\nThe description is the tooltip a report author sees when hovering a field in the field list, and it is the only place in the model to say what a measure counts, which currency a column is in, or which of two similar fields to use. Without it, every author works that out from the name, and gets it wrong at about the same rate. Descriptions also feed documentation tools, so the same sentence pays off twice.\n\n### How to fix it\n\nIn Power BI Desktop, open Model view, select the object, and type the Description in the Properties pane. In the TMDL file a description is one or more `///` lines directly above the object. With hundreds of objects, Tabular Editor can paste descriptions into many objects at once; the TMDL comment lines need no other tool.\n\n### Quirks\n\n- Visibility is the object's own isHidden flag: a visible column inside a hidden table is still reported.\n- A calculation group table is reported once, as a calculation group.\n\nRead more: https://pbiplint.com/rules/objects-with-no-description"
3141
3141
  },
3142
3142
  PARSE_ISSUE: {
3143
- text: "Why it matters\n\nThe parser skipped the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor's TMDL reader is stricter and refuses to open a file that puts a blank line after a /// line at all.\n\nHow to fix it\n\nOpen the file at the reported line. TMDL is indented with tabs, and expression blocks open and close with on their own lines. A /// description must sit directly above its declaration, with no blank line between them. Power BI Desktop writes valid TMDL, so a parse issue usually means a hand edit or a merge conflict marker.\n\nRead more: https://pbiplint.com/rules/parse-issue",
3143
+ text: "Why it matters\n\nThe parser skipped the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor's TMDL reader is stricter and refuses to open a file that puts a blank line after a /// line at all.\n\nHow to fix it\n\nOpen the file at the reported line. TMDL is indented with tabs, and expression blocks open and close with ``` on their own lines. A /// description must sit directly above its declaration, with no blank line between them. Power BI Desktop writes valid TMDL, so a parse issue usually means a hand edit or a merge conflict marker.\n\nRead more: https://pbiplint.com/rules/parse-issue",
3144
3144
  markdown: "### Why it matters\n\nThe parser skipped the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor's TMDL reader is stricter and refuses to open a file that puts a blank line after a `///` line at all.\n\n### How to fix it\n\nOpen the file at the reported line. TMDL is indented with tabs, and expression blocks open and close with ``` on their own lines. A `///` description must sit directly above its declaration, with no blank line between them. Power BI Desktop writes valid TMDL, so a parse issue usually means a hand edit or a merge conflict marker.\n\nRead more: https://pbiplint.com/rules/parse-issue"
3145
3145
  },
3146
3146
  PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES: {
@@ -3247,7 +3247,7 @@ import { basename, dirname as dirname3, join as join3, relative, resolve as reso
3247
3247
  var toPosix = (p) => p.split("\\").join("/");
3248
3248
  function readTmdlFiles(root, dir, out) {
3249
3249
  for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
3250
- (a, b) => a.name.localeCompare(b.name)
3250
+ (a, b) => a.name.localeCompare(b.name, "en")
3251
3251
  )) {
3252
3252
  const p = join3(dir, entry.name);
3253
3253
  if (entry.isDirectory()) readTmdlFiles(root, p, out);
@@ -3288,7 +3288,7 @@ function resolveModel(input) {
3288
3288
  }
3289
3289
 
3290
3290
  // src/main.ts
3291
- var VERSION2 = true ? "0.1.0" : "0.0.0-dev";
3291
+ var VERSION2 = true ? "0.1.2" : "0.0.0-dev";
3292
3292
  function listRules() {
3293
3293
  const width = Math.max(...defaultRules.map((r) => r.id.length));
3294
3294
  return defaultRules.map(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pbiplint",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Lint Power BI semantic models (TMDL) for best-practice violations, with text, JSON, SARIF, and Markdown output. Nothing is uploaded.",
5
5
  "keywords": [
6
6
  "power-bi",
@@ -43,6 +43,6 @@
43
43
  "test:bundle": "node dist/pbiplint.mjs --sample --format json > /dev/null; test $? -eq 1"
44
44
  },
45
45
  "engines": {
46
- "node": ">=20"
46
+ "node": "^20.19.0 || >=22.12.0"
47
47
  }
48
48
  }