pbiplint 0.2.3 → 0.2.4

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
@@ -4,8 +4,10 @@ Best-practice linter for Power BI projects. Point it at a PBIP folder, a `.pbip`
4
4
  `.SemanticModel` folder, a `.Report` folder, a `definition` folder, or one `.tmdl` file and get
5
5
  ranked findings with a link to a fix page for each rule, and, when the input has a report, a
6
6
  "Report at a glance" block that says what the report will do when someone opens it. Nothing is
7
- uploaded: it reads the files you name and writes to your terminal. Node 20.19 or later (or 22.12
8
- or later).
7
+ uploaded: it reads the files you name and writes to your terminal, and
8
+ [the pbiplint Privacy Promise](https://pbiplint.com/privacy/) says what that covers. Node 20.19 or a
9
+ later 20 release, or 22.12 or later. [The pbiplint CLI](https://pbiplint.com/cli/) page on the site has more:
10
+ where to get it, what it reads, and how to check that it sends nothing.
9
11
 
10
12
  ```bash
11
13
  npx pbiplint path/to/Model.SemanticModel
@@ -18,10 +20,19 @@ npx pbiplint --version
18
20
  ```
19
21
 
20
22
  Formats: `text` (default), `json`, `sarif` (for GitHub code scanning and editors), `markdown`.
23
+ The Markdown export writes an email address, an `@name`, a `#123` or `GH-123`, and text
24
+ holding two or more `$` as code, so pasted into a GitHub issue or comment it links no one,
25
+ notifies no one, and renders no math.
21
26
 
22
27
  Exit codes: `0` no findings at or above `--fail-on`, `1` findings, `2` usage or input error.
23
28
  `--fail-on error` is the default; `--fail-on warning` and `--fail-on info` tighten the gate;
24
- `--fail-on none` always exits 0.
29
+ `--fail-on none` exits 0 on any run that lints something. A run that can read nothing in the input,
30
+ such as a report saved as a single `report.json` (PBIR-Legacy) with no model beside it, exits `2`
31
+ and says why, rather than passing with no findings.
32
+
33
+ Pointed at a folder that is not a project but holds one in a folder below it, as a repository
34
+ often does, pbiplint lints that project and names it in a notice. A folder that holds several exits
35
+ `2` and lists them, each with the command that lints it.
25
36
 
26
37
  ## Configuration
27
38
 
package/dist/pbiplint.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // ../core/src/version.ts
4
- var VERSION = "0.2.3";
4
+ var VERSION = "0.2.4";
5
5
 
6
6
  // ../core/src/engine/config.ts
7
7
  var ConfigError = class extends Error {
@@ -2675,13 +2675,13 @@ function buildFacts(project, indexes, knownRules, ruleOptions = /* @__PURE__ */
2675
2675
  const columns2 = shown2.reduce((s, t) => s + t.columns.length, 0);
2676
2676
  const measures = shown2.reduce((s, t) => s + t.measures.length, 0);
2677
2677
  const partly = modelPartlyRead(model);
2678
- const count = (k, noun) => k === 0 && partly ? `${noun}s: unknown` : n(k, noun);
2678
+ const count2 = (k, noun) => k === 0 && partly ? `${noun}s: unknown` : n(k, noun);
2679
2679
  const countUnknown = partly && [shown2.length, columns2, measures].includes(0);
2680
2680
  const functions = model.functions.length > 0 ? `, ${n(model.functions.length, "function")}` : "";
2681
2681
  const fact = {
2682
2682
  layer: "model",
2683
2683
  label: "Model",
2684
- value: `${count(shown2.length, "table")}, ${count(columns2, "column")}, ${count(measures, "measure")}${functions}`
2684
+ value: `${count2(shown2.length, "table")}, ${count2(columns2, "column")}, ${count2(measures, "measure")}${functions}`
2685
2685
  };
2686
2686
  const reach = indexes.reachability;
2687
2687
  const unreadModel = "a model file could not be fully read";
@@ -4226,8 +4226,8 @@ var AVOID_EXCESSIVE_BIDIRECTIONAL_OR_MANY_TO_MANY_RELATIONSHIPS = bpaRule(
4226
4226
  { skipWhenModelUnread: modelPartlyRead },
4227
4227
  (m) => {
4228
4228
  const rels = m.relationships;
4229
- const count = rels.filter(isBidirectional).length + rels.filter(isManyToMany).length;
4230
- return count / Math.max(rels.length, 1) > 0.3 ? [finding.model(m)] : [];
4229
+ const count2 = rels.filter(isBidirectional).length + rels.filter(isManyToMany).length;
4230
+ return count2 / Math.max(rels.length, 1) > 0.3 ? [finding.model(m)] : [];
4231
4231
  }
4232
4232
  );
4233
4233
  var AVOID_USING_MANY_TO_MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY = bpaRule(
@@ -6410,13 +6410,13 @@ function lint(files, options = {}) {
6410
6410
  rules.filter((r) => ran.has(r.id)).map((r) => [r.id, optionsFor(r, config)])
6411
6411
  );
6412
6412
  const facts = buildFacts(project, indexes, ran, ranOptions);
6413
- const count = (severity) => groups.filter((g) => g.rule.severity === severity).reduce((n2, g) => n2 + g.findings.length, 0);
6413
+ const count2 = (severity) => groups.filter((g) => g.rule.severity === severity).reduce((n2, g) => n2 + g.findings.length, 0);
6414
6414
  const summary = {
6415
6415
  files: routed.model.length + routed.report.length,
6416
6416
  findings: run.findings.length,
6417
- errors: count(3),
6418
- warnings: count(2),
6419
- infos: count(1),
6417
+ errors: count2(3),
6418
+ warnings: count2(2),
6419
+ infos: count2(1),
6420
6420
  rulesRun: run.rulesRun.length,
6421
6421
  rulesSkipped: run.rulesSkipped,
6422
6422
  ruleErrors: run.ruleErrors,
@@ -6464,13 +6464,47 @@ function formatJson(result, options = {}) {
6464
6464
 
6465
6465
  // ../core/src/format/markdown.ts
6466
6466
  var oneLine = (s) => s.replace(/\r\n?|\n/g, " ");
6467
- var text = (s) => showControls(s.replace(/\\/g, "\\\\")).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/[`[\]*_~|@$]/g, "\\$&").replace(/:(?=\/\/)/g, "\\:").replace(/(www)\./gi, "$1\\.");
6468
- var cell = (s) => text(oneLine(s));
6469
- function code(name) {
6470
- const shown2 = showControls(oneLine(name)).replace(
6467
+ var escaped2 = (s) => showControls(s.replace(/\\/g, "\\\\")).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/[`[\]*_~|]/g, "\\$&").replace(/:(?=\/\/)/g, "\\:").replace(/(www)\./gi, "$1\\.");
6468
+ function text(s, table) {
6469
+ const math = (s.match(/\$/g) ?? []).length > 1;
6470
+ return s.split(/( +)/).map(
6471
+ (w, i) => i % 2 === 0 && (/@|#\d|\bGH-\d/i.test(w) || math && w.includes("$")) ? spanned(w, table) : escaped2(w)
6472
+ ).join("");
6473
+ }
6474
+ var ENCLOSING = [
6475
+ ["(", ")"],
6476
+ ['"', '"'],
6477
+ ["'", "'"]
6478
+ ];
6479
+ var count = (s, ch) => s.split(ch).length - 1;
6480
+ function spanned(w, table) {
6481
+ let after = /[.,;:!?]+$/.exec(w)?.[0] ?? "";
6482
+ let core = w.slice(0, w.length - after.length);
6483
+ let before = "";
6484
+ if (core.endsWith(")") && count(core, ")") > count(core, "(")) {
6485
+ after = `)${after}`;
6486
+ core = core.slice(0, -1);
6487
+ }
6488
+ if (core.startsWith("(") && count(core, "(") > count(core, ")")) {
6489
+ before = "(";
6490
+ core = core.slice(1);
6491
+ }
6492
+ const pair = ENCLOSING.find(
6493
+ ([o, c]) => core.length > 2 && core.startsWith(o) && core.endsWith(c)
6494
+ );
6495
+ if (before === "" && pair) {
6496
+ before = pair[0];
6497
+ after = pair[1] + after;
6498
+ core = core.slice(1, -1);
6499
+ }
6500
+ return `${escaped2(before)}${code(core, table)}${escaped2(after)}`;
6501
+ }
6502
+ var cell = (s) => text(oneLine(s), true);
6503
+ function code(name, table) {
6504
+ const shown2 = table ? showControls(oneLine(name)).replace(
6471
6505
  /(\\*)\|/g,
6472
6506
  (_, run) => `${run}${run.length % 2 ? "\\" : ""}\\|`
6473
- );
6507
+ ) : showControls(name);
6474
6508
  const fence = "`".repeat(Math.max(0, ...(shown2.match(/`+/g) ?? []).map((r) => r.length)) + 1);
6475
6509
  const spaced = shown2.startsWith(" ") && shown2.endsWith(" ") && /[^ ]/.test(shown2);
6476
6510
  const pad = shown2.startsWith("`") || shown2.endsWith("`") || spaced ? " " : "";
@@ -6480,10 +6514,10 @@ function formatMarkdown(result, _options = {}) {
6480
6514
  const out = [
6481
6515
  "# pbiplint report",
6482
6516
  "",
6483
- `${summaryLine(result)}. ${text([layersLine(result), skippedLine(result)].filter(Boolean).join(" "))}.`,
6517
+ `${summaryLine(result)}. ${text([layersLine(result), skippedLine(result)].filter(Boolean).join(" "), false)}.`,
6484
6518
  ""
6485
6519
  ];
6486
- for (const d of result.diagnostics) out.push(`> Notice: ${text(d.message)}`, "");
6520
+ for (const d of result.diagnostics) out.push(`> Notice: ${text(d.message, false)}`, "");
6487
6521
  if (result.facts.length) {
6488
6522
  out.push("## Report at a glance", "", "| Fact | Value | Rule |", "|---|---|---|");
6489
6523
  for (const f of result.facts)
@@ -6512,7 +6546,7 @@ function formatMarkdown(result, _options = {}) {
6512
6546
  out.push("| Object | Type | Location | Detail |", "|---|---|---|---|");
6513
6547
  for (const f of g.findings)
6514
6548
  out.push(
6515
- `| ${code(f.objectName)} | ${f.objectType} | ${cell(locationOf(f))} | ${cell(f.detail ?? "")} |`
6549
+ `| ${code(f.objectName, true)} | ${f.objectType} | ${cell(locationOf(f))} | ${cell(f.detail ?? "")} |`
6516
6550
  );
6517
6551
  out.push("");
6518
6552
  }
@@ -6585,11 +6619,15 @@ Read more: ${url}`
6585
6619
  const incomplete = result.diagnostics.filter(
6586
6620
  (d) => d.kind === "depth-cap" || d.kind === "unread-file"
6587
6621
  );
6622
+ const version = options.toolVersion ?? VERSION;
6588
6623
  const run = {
6589
6624
  tool: {
6590
6625
  driver: {
6591
6626
  name: "pbiplint",
6592
- version: options.toolVersion ?? VERSION,
6627
+ // The name with its version, as SARIF defines fullName. GitHub Advanced Security for Azure
6628
+ // DevOps requires it (rule GHAzDO1018 of Microsoft's SARIF validator).
6629
+ fullName: `pbiplint ${version}`,
6630
+ version,
6593
6631
  informationUri: "https://pbiplint.com",
6594
6632
  rules
6595
6633
  }
@@ -6633,12 +6671,99 @@ function formatResult(name, result, options = {}) {
6633
6671
  }
6634
6672
  }
6635
6673
 
6674
+ // ../core/src/project/below.ts
6675
+ var isRecord8 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6676
+ function reportsNamed(text2) {
6677
+ const json = readJson(".pbip", text2).json;
6678
+ if (!isRecord8(json) || !Array.isArray(json.artifacts)) return [];
6679
+ return json.artifacts.flatMap(
6680
+ (a) => isRecord8(a) && isRecord8(a.report) && typeof a.report.path === "string" ? [a.report.path] : []
6681
+ );
6682
+ }
6683
+ var parentOf = (p) => p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
6684
+ function resolveIn(from, to) {
6685
+ const out = from === "" ? [] : from.split("/");
6686
+ for (const segment of to.split(/[\\/]/)) {
6687
+ if (segment === "" || segment === ".") continue;
6688
+ if (segment !== "..") out.push(segment);
6689
+ else if (out.pop() === void 0) return void 0;
6690
+ }
6691
+ return out.join("/");
6692
+ }
6693
+ function projectsBelow(found) {
6694
+ const parts = [...found.reports.map((r) => r.path), ...found.models];
6695
+ const all = [...found.pbips.map((p) => p.path), ...parts];
6696
+ const at2 = (items, path, written) => written === void 0 ? void 0 : items.find((t) => path(t) === written) ?? items.find((t) => path(t).toLowerCase() === written.toLowerCase());
6697
+ const reportAt = (written) => at2(found.reports, (r) => r.path, written);
6698
+ const modelAt = (written) => at2(found.models, (m) => m, written);
6699
+ const refOf = (report) => report.pbir === void 0 ? { kind: "none" } : datasetReference(report.pbir);
6700
+ const folderOf = (members) => {
6701
+ const dir = parentOf(members[0]);
6702
+ const own = new Set(members);
6703
+ return dir !== "" && dir.split("/").pop() !== "definition" && own.size > 1 && members.every((m) => parentOf(m) === dir) && all.every((p) => parentOf(p) !== dir || own.has(p)) ? dir : void 0;
6704
+ };
6705
+ const taken = /* @__PURE__ */ new Set();
6706
+ const paths = [];
6707
+ for (const pbip of found.pbips) {
6708
+ const dir = parentOf(pbip.path);
6709
+ const named2 = pbip.text === void 0 ? [] : reportsNamed(pbip.text);
6710
+ const members = [pbip.path];
6711
+ if (named2.length === 0) members.push(...parts.filter((p) => parentOf(p) === dir));
6712
+ let beyond = false;
6713
+ for (const written of named2) {
6714
+ const report = reportAt(resolveIn(dir, written));
6715
+ if (!report) continue;
6716
+ const ref = refOf(report);
6717
+ const model = ref.kind === "byPath" ? modelAt(resolveIn(report.path, ref.path)) : void 0;
6718
+ if (ref.kind === "byPath" && model === void 0) beyond = true;
6719
+ members.push(report.path, ...model === void 0 ? [] : [model]);
6720
+ }
6721
+ for (const m of members) taken.add(m);
6722
+ paths.push((beyond ? void 0 : folderOf(members)) ?? pbip.path);
6723
+ }
6724
+ for (const report of found.reports) {
6725
+ if (taken.has(report.path)) continue;
6726
+ const ref = refOf(report);
6727
+ const beside = found.models.filter((m) => parentOf(m) === parentOf(report.path));
6728
+ const model = ref.kind === "byPath" ? modelAt(resolveIn(report.path, ref.path)) : ref.kind === "none" && beside.length === 1 ? beside[0] : void 0;
6729
+ const folder = model === void 0 ? void 0 : folderOf([report.path, model]);
6730
+ if (folder !== void 0) taken.add(model);
6731
+ paths.push(folder ?? report.path);
6732
+ }
6733
+ for (const model of found.models) if (!taken.has(model)) paths.push(model);
6734
+ return paths.sort((a, b) => a.localeCompare(b, "en"));
6735
+ }
6736
+ function projectBelowNotice(path) {
6737
+ return {
6738
+ kind: "project-below-input",
6739
+ path,
6740
+ message: `${path} is the only project found below the folder given, so it was linted as if given directly`
6741
+ };
6742
+ }
6743
+
6744
+ // src/log-safe.ts
6745
+ var logSafe = (text2) => text2.replace(/(?<=#)#(?=\w*\[)/g, "\\u0023").replace(/^((?:[^\S\n]|\u0085)*):(?=:)/gm, "$1\\u003a");
6746
+
6636
6747
  // src/main.ts
6637
6748
  import { mkdirSync, writeFileSync } from "node:fs";
6638
6749
  import { basename as basename2, dirname as dirname4, relative as relative2, resolve as resolve3 } from "node:path";
6639
6750
 
6640
6751
  // src/args.ts
6641
6752
  var UsageError = class extends Error {
6753
+ #lines;
6754
+ constructor(message, lines = []) {
6755
+ super(message);
6756
+ this.#lines = lines;
6757
+ }
6758
+ /**
6759
+ * What the message introduces, such as a list, one line each. main prints each on a line of its
6760
+ * own, its control characters shown, so nothing in one can write a line that reads as the CLI's.
6761
+ * A getter, so a usage error still compares equal to an Error with its message, as tests compare
6762
+ * them.
6763
+ */
6764
+ get lines() {
6765
+ return this.#lines;
6766
+ }
6642
6767
  };
6643
6768
  var FAIL_ON = ["error", "warning", "info", "none"];
6644
6769
  function parseArgs(argv) {
@@ -6722,6 +6847,7 @@ violations. Either part alone is fine. Nothing is uploaded.
6722
6847
 
6723
6848
  Exit codes: 0 no findings at or above --fail-on, 1 findings, 2 usage or input error.
6724
6849
  Rule pages: https://pbiplint.com/rules/
6850
+ The pbiplint Privacy Promise: https://pbiplint.com/privacy/
6725
6851
  `;
6726
6852
 
6727
6853
  // src/config.ts
@@ -14577,7 +14703,6 @@ var statOf = (p) => statSync(p, { throwIfNoEntry: false });
14577
14703
  var isDir = (p) => statOf(p)?.isDirectory() ?? false;
14578
14704
  var isFile = (p) => statOf(p)?.isFile() ?? false;
14579
14705
  var byName = (a, b) => a.localeCompare(b, "en");
14580
- var isRecord8 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
14581
14706
  function folderAt(p) {
14582
14707
  try {
14583
14708
  const stat = statOf(p);
@@ -14639,6 +14764,13 @@ function attempt(w, p, call, part, folder = false) {
14639
14764
  return void 0;
14640
14765
  }
14641
14766
  }
14767
+ var LEGACY_NOTICE = { model: legacyModelNotice, report: legacyReportNotice };
14768
+ function legacyPart(w, layer, folder) {
14769
+ const path = toPosix(relative(w.base, folder));
14770
+ w.project.diagnostics.push(LEGACY_NOTICE[layer](path === "" ? basename(folder) : path));
14771
+ w.project.absent[layer] = layer === "model" ? LEGACY_MODEL_REASON : LEGACY_REPORT_REASON;
14772
+ w.legacy.push({ layer, path });
14773
+ }
14642
14774
  var emptyPart = (root) => ({ root, files: [], unread: [] });
14643
14775
  function readTree(w, part, dir, keep, passed) {
14644
14776
  for (const entry of readdirSync2(dir, { withFileTypes: true }).sort(
@@ -14701,11 +14833,10 @@ function readPart(w, layer, folder, read) {
14701
14833
  if (!part && layer && refused) w.project.absent[layer] = UNREAD_PART[layer];
14702
14834
  return part;
14703
14835
  }
14836
+ var isPbipEntry = (folder, e) => e.name.endsWith(".pbip") && (e.isFile() || e.isSymbolicLink() && folderAt(join3(folder, e.name)) !== true);
14704
14837
  function pbipIn(input, folder, preferred) {
14705
14838
  if (preferred !== void 0) return preferred;
14706
- const found = readdirSync2(folder, { withFileTypes: true }).filter(
14707
- (e) => e.name.endsWith(".pbip") && (e.isFile() || e.isSymbolicLink() && folderAt(join3(folder, e.name)) !== true)
14708
- ).map((e) => e.name).sort(byName);
14839
+ const found = readdirSync2(folder, { withFileTypes: true }).filter((e) => isPbipEntry(folder, e)).map((e) => e.name).sort(byName);
14709
14840
  if (found.length > 1)
14710
14841
  throw new UsageError(
14711
14842
  `${input} contains ${found.length} .pbip files; point at one of them: ${found.join(", ")}`
@@ -14753,27 +14884,23 @@ function resolveFolder(input, path, preferred) {
14753
14884
  return walked(input, path, (w) => readFolder(w, input, path, preferred));
14754
14885
  }
14755
14886
  function walked(input, base, read) {
14756
- const w = { base, project: { root: base, absent: {}, diagnostics: [] } };
14887
+ const w = { base, project: { root: base, absent: {}, diagnostics: [] }, legacy: [] };
14757
14888
  const project = read(w);
14758
- if (!project.model && !project.report && w.refusal) {
14759
- const { path: below, reason } = w.refusal;
14760
- const named2 = below === "" ? input : toPosix(join3(input, below));
14761
- throw new UsageError(`Could not read ${named2}: ${reason}`);
14762
- }
14763
- return project;
14764
- }
14765
- function reportsNamed(name, text2) {
14766
- const json = readJson(name, text2).json;
14767
- if (!isRecord8(json) || !Array.isArray(json.artifacts)) return [];
14768
- return json.artifacts.flatMap(
14769
- (a) => isRecord8(a) && isRecord8(a.report) && typeof a.report.path === "string" ? [a.report.path] : []
14889
+ if (project.model || project.report) return project;
14890
+ const named2 = (below) => below === "" ? input : toPosix(join3(input, below));
14891
+ if (w.refusal)
14892
+ throw new UsageError(`Could not read ${named2(w.refusal.path)}: ${w.refusal.reason}`);
14893
+ const [first, ...rest] = w.legacy.map((l) => LEGACY_NOTICE[l.layer](named2(l.path)).message);
14894
+ if (first !== void 0) throw new UsageError(first, rest);
14895
+ throw new UsageError(
14896
+ `No semantic model or report found at ${input} (expected ${EXPECTED_INPUT})`
14770
14897
  );
14771
14898
  }
14772
14899
  function resolvePbip(input, path) {
14773
14900
  const folder = dirname3(path);
14774
14901
  const text2 = readFileSync2(path, "utf8");
14775
14902
  const named2 = /* @__PURE__ */ new Map();
14776
- for (const written2 of reportsNamed(basename(path), text2)) {
14903
+ for (const written2 of reportsNamed(text2)) {
14777
14904
  const at2 = resolve2(folder, toPosix(written2));
14778
14905
  if (!named2.has(at2)) named2.set(at2, written2);
14779
14906
  }
@@ -14805,10 +14932,8 @@ function readNamed(w, input, pbip, text2, reportFolder, written) {
14805
14932
  const out = w.project;
14806
14933
  const at2 = (p) => toPosix(relative(w.base, p));
14807
14934
  const report = readPart(w, "report", reportFolder, reportPart);
14808
- if (!report && !out.absent.report && isFile(join3(reportFolder, "report.json"))) {
14809
- out.diagnostics.push(legacyReportNotice(at2(reportFolder)));
14810
- out.absent.report = LEGACY_REPORT_REASON;
14811
- }
14935
+ if (!report && !out.absent.report && isFile(join3(reportFolder, "report.json")))
14936
+ legacyPart(w, "report", reportFolder);
14812
14937
  if (report) report.files.push({ path: toPosix(relative(report.root, pbip)), text: text2 });
14813
14938
  const pbir = pbirOf(w, report, reportFolder);
14814
14939
  const ref = pbir === void 0 ? { kind: "none" } : datasetReference(pbir);
@@ -14819,10 +14944,8 @@ function readNamed(w, input, pbip, text2, reportFolder, written) {
14819
14944
  const modelFolder = resolve2(reportFolder, toPosix(ref.path));
14820
14945
  if (folderAt(modelFolder)) {
14821
14946
  model = readPart(w, "model", modelFolder, modelPart);
14822
- if (!model && !out.absent.model && isFile(join3(modelFolder, "model.bim"))) {
14823
- out.diagnostics.push(legacyModelNotice(at2(modelFolder)));
14824
- out.absent.model = LEGACY_MODEL_REASON;
14825
- }
14947
+ if (!model && !out.absent.model && isFile(join3(modelFolder, "model.bim")))
14948
+ legacyPart(w, "model", modelFolder);
14826
14949
  if (!model && !out.absent.model) {
14827
14950
  const { reason } = pairingDecision(ref, void 0, basename(reportFolder));
14828
14951
  if (reason) out.absent.model = reason;
@@ -14871,16 +14994,15 @@ function readFolder(w, input, path, preferred) {
14871
14994
  };
14872
14995
  }
14873
14996
  if (name.endsWith(".Report") && isFile(join3(path, "report.json"))) {
14874
- out.diagnostics.push(legacyReportNotice(name));
14875
- out.absent.report = LEGACY_REPORT_REASON;
14997
+ legacyPart(w, "report", path);
14876
14998
  return out;
14877
14999
  }
14878
15000
  if (name.endsWith(".SemanticModel") && isFile(join3(path, "model.bim"))) {
14879
- out.diagnostics.push(legacyModelNotice(name));
14880
- out.absent.model = LEGACY_MODEL_REASON;
15001
+ legacyPart(w, "model", path);
14881
15002
  return out;
14882
15003
  }
14883
- const dirs = readdirSync2(path, { withFileTypes: true }).filter((e) => e.isDirectory() || e.isSymbolicLink() && folderAt(join3(path, e.name)) === true).map((e) => e.name);
15004
+ const listed = readdirSync2(path, { withFileTypes: true });
15005
+ const dirs = listed.filter((e) => e.isDirectory() || e.isSymbolicLink() && folderAt(join3(path, e.name)) === true).map((e) => e.name);
14884
15006
  const models = dirs.filter((d) => d.endsWith(".SemanticModel")).sort(byName);
14885
15007
  const reports = dirs.filter((d) => d.endsWith(".Report")).sort(byName);
14886
15008
  if (models.length > 1)
@@ -14892,15 +15014,11 @@ function readFolder(w, input, path, preferred) {
14892
15014
  `${input} contains ${reports.length} reports; point at one of them: ${reports.join(", ")}`
14893
15015
  );
14894
15016
  let model = models[0] ? readPart(w, "model", join3(path, models[0]), modelPart) : void 0;
14895
- if (models[0] && !model && !out.absent.model && isFile(join3(path, models[0], "model.bim"))) {
14896
- out.diagnostics.push(legacyModelNotice(models[0]));
14897
- out.absent.model = LEGACY_MODEL_REASON;
14898
- }
15017
+ if (models[0] && !model && !out.absent.model && isFile(join3(path, models[0], "model.bim")))
15018
+ legacyPart(w, "model", join3(path, models[0]));
14899
15019
  const report = reports[0] ? readPart(w, "report", join3(path, reports[0]), reportPart) : void 0;
14900
- if (reports[0] && !report && !out.absent.report && isFile(join3(path, reports[0], "report.json"))) {
14901
- out.diagnostics.push(legacyReportNotice(reports[0]));
14902
- out.absent.report = LEGACY_REPORT_REASON;
14903
- }
15020
+ if (reports[0] && !report && !out.absent.report && isFile(join3(path, reports[0], "report.json")))
15021
+ legacyPart(w, "report", join3(path, reports[0]));
14904
15022
  if (report) {
14905
15023
  const pbip = pbipIn(input, path, preferred);
14906
15024
  if (pbip !== void 0) {
@@ -14921,6 +15039,20 @@ function readFolder(w, input, path, preferred) {
14921
15039
  if (model) out.model = model;
14922
15040
  if (report) out.report = report;
14923
15041
  if (model || report) return out;
15042
+ if (!namedPart && name !== "definition" && !models.length && !reports.length && !listed.some((e) => isPbipEntry(path, e))) {
15043
+ const below = projectsBelow(foundBelow(w, path));
15044
+ const at2 = (p) => toPosix(join3(input, p));
15045
+ if (below.length === 1) {
15046
+ const project = resolveProject(at2(below[0]));
15047
+ w.refusal = void 0;
15048
+ return { ...project, diagnostics: [projectBelowNotice(below[0]), ...project.diagnostics] };
15049
+ }
15050
+ if (below.length > 1)
15051
+ throw new UsageError(
15052
+ `${input} contains ${below.length} projects; point at one of them:`,
15053
+ below.map((p) => ` ${p}: pbiplint ${shellWord(at2(p))}`)
15054
+ );
15055
+ }
14924
15056
  const direct = emptyPart(path);
14925
15057
  const passed = { pbix: [], models: [] };
14926
15058
  readTree(w, direct, path, (n2) => n2.endsWith(".tmdl"), passed);
@@ -14938,16 +15070,50 @@ function readFolder(w, input, path, preferred) {
14938
15070
  `No semantic model or report found at ${input} (expected ${EXPECTED_INPUT})`
14939
15071
  );
14940
15072
  }
15073
+ function textAt(p) {
15074
+ try {
15075
+ return isLink(p) || !isFile(p) ? void 0 : readFileSync2(p, "utf8");
15076
+ } catch (e) {
15077
+ if (isSystemError(e)) return void 0;
15078
+ throw e;
15079
+ }
15080
+ }
15081
+ function foundBelow(w, dir, found = { pbips: [], reports: [], models: [] }) {
15082
+ let entries;
15083
+ try {
15084
+ entries = readdirSync2(dir, { withFileTypes: true });
15085
+ } catch (e) {
15086
+ if (isSystemError(e)) return found;
15087
+ throw e;
15088
+ }
15089
+ for (const entry of entries) {
15090
+ const p = join3(dir, entry.name);
15091
+ const path = toPosix(relative(w.base, p));
15092
+ if (entry.isFile() && entry.name.endsWith(".pbip")) found.pbips.push({ path, text: textAt(p) });
15093
+ else if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
15094
+ else if (entry.name.endsWith(".Report"))
15095
+ found.reports.push({ path, pbir: textAt(join3(p, "definition.pbir")) });
15096
+ else if (entry.name.endsWith(".SemanticModel")) found.models.push(path);
15097
+ else foundBelow(w, p, found);
15098
+ }
15099
+ return found;
15100
+ }
15101
+ var shellWord = (p) => /^[\w./:@+-]+$/.test(p) ? p : `"${p}"`;
14941
15102
 
14942
15103
  // src/main.ts
14943
- var VERSION2 = true ? "0.2.3" : "0.0.0-dev";
15104
+ var VERSION2 = true ? "0.2.4" : "0.0.0-dev";
14944
15105
  function listRules() {
14945
15106
  const width = Math.max(...defaultRules.map((r) => r.id.length));
14946
15107
  return defaultRules.map(
14947
15108
  (r) => `${r.id.padEnd(width)} ${r.layer.padEnd(7)} ${(r.status === "needsLiveModel" ? "needs live model" : r.status).padEnd(16)} ${SEVERITY_LABEL[r.severity].padEnd(7)} ${r.category.padEnd(18)} ${r.name}`
14948
15109
  ).join("\n");
14949
15110
  }
14950
- async function main(argv, io) {
15111
+ async function main(argv, given) {
15112
+ const io = {
15113
+ stdout: (text2) => given.stdout(logSafe(text2)),
15114
+ stderr: (text2) => given.stderr(logSafe(text2)),
15115
+ cwd: () => given.cwd()
15116
+ };
14951
15117
  const stderrLine = (line) => io.stderr(`${showControls(line)}
14952
15118
  `);
14953
15119
  try {
@@ -15013,7 +15179,10 @@ async function main(argv, io) {
15013
15179
  } catch (e) {
15014
15180
  if (e instanceof UsageError || e instanceof ConfigError) {
15015
15181
  stderrLine(`pbiplint: ${e.message}`);
15016
- if (e instanceof UsageError) stderrLine(`Run pbiplint --help for usage.`);
15182
+ if (e instanceof UsageError) {
15183
+ for (const line of e.lines) stderrLine(line);
15184
+ stderrLine(`Run pbiplint --help for usage.`);
15185
+ }
15017
15186
  return 2;
15018
15187
  }
15019
15188
  for (const [i, line] of unexpectedLines(e).entries())
@@ -15046,8 +15215,8 @@ main(process.argv.slice(2), {
15046
15215
  process.exitCode = code2;
15047
15216
  }).catch((e) => {
15048
15217
  process.stderr.write(
15049
- `${showControls(`pbiplint: ${e instanceof Error ? e.message : String(e)}`)}
15050
- `
15218
+ logSafe(`${showControls(`pbiplint: ${e instanceof Error ? e.message : String(e)}`)}
15219
+ `)
15051
15220
  );
15052
15221
  process.exitCode = 2;
15053
15222
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbiplint",
3
- "version": "0.2.3",
4
- "description": "Lint Power BI projects, the semantic model (TMDL) and the report (PBIR), for best-practice violations, with text, JSON, SARIF, and Markdown output. Nothing is uploaded.",
3
+ "version": "0.2.4",
4
+ "description": "Lint Power BI projects, the semantic model (TMDL) and the report (PBIR), for best-practice violations, with text, JSON, SARIF, and Markdown output. Nothing is uploaded: the pbiplint Privacy Promise, https://pbiplint.com/privacy/",
5
5
  "keywords": [
6
6
  "power-bi",
7
7
  "tmdl",