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 +14 -3
- package/dist/pbiplint.mjs +231 -62
- package/package.json +2 -2
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
|
|
8
|
-
|
|
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`
|
|
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.
|
|
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
|
|
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: `${
|
|
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
|
|
4230
|
-
return
|
|
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
|
|
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:
|
|
6418
|
-
warnings:
|
|
6419
|
-
infos:
|
|
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
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6467
|
+
var escaped2 = (s) => showControls(s.replace(/\\/g, "\\\\")).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").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
|
|
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 (
|
|
14759
|
-
|
|
14760
|
-
|
|
14761
|
-
throw new UsageError(`Could not read ${named2}: ${reason}`);
|
|
14762
|
-
|
|
14763
|
-
|
|
14764
|
-
|
|
14765
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14880
|
-
out.absent.model = LEGACY_MODEL_REASON;
|
|
15001
|
+
legacyPart(w, "model", path);
|
|
14881
15002
|
return out;
|
|
14882
15003
|
}
|
|
14883
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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)
|
|
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.
|
|
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",
|