pbip-killer 0.5.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.
- package/README.md +172 -0
- package/dist/index.js +1102 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# pbip-killer
|
|
2
|
+
|
|
3
|
+
Audit Power BI **PBIP / PBIR** reports from the command line or CI/CD — and fail
|
|
4
|
+
the build when a report breaks your team's standards.
|
|
5
|
+
|
|
6
|
+
This is the report-side sibling of [`dataflow-killer`](../cli). Where the
|
|
7
|
+
dataflow CLI gates your data layer, this gates your **reports**: visuals,
|
|
8
|
+
accessibility, governance and consistency. It is **audit-only** — it never
|
|
9
|
+
modifies your `.pbip`.
|
|
10
|
+
|
|
11
|
+
> **Status: v1 foundation.** Report (PBIR) analysis with a fully declarative,
|
|
12
|
+
> customizable rule layer. The semantic-model (TMDL) side is intentionally out
|
|
13
|
+
> of scope for now.
|
|
14
|
+
|
|
15
|
+
## Why PBIR
|
|
16
|
+
|
|
17
|
+
When you save a Power BI Desktop project as **PBIP** with the **enhanced report
|
|
18
|
+
format (PBIR)** enabled, the report is stored as small per-page / per-visual JSON
|
|
19
|
+
files. That makes it git-versionable *and* lintable at the visual level — which
|
|
20
|
+
is exactly what this tool needs. Mandate "PBIP + PBIR on" for your team and you
|
|
21
|
+
unlock CI gating without needing workspace Git integration.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# From the terminal
|
|
27
|
+
npx pbip-killer audit ./MyProject.pbip
|
|
28
|
+
npx pbip-killer audit ./MyProject.Report --format=md
|
|
29
|
+
npx pbip-killer audit ./repo --config=pbipkiller.config.json --fail-on=warning
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Exit code is **1** when any finding meets the `failOn` gate — that is what
|
|
33
|
+
reddens a pipeline.
|
|
34
|
+
|
|
35
|
+
### Use in GitHub Actions
|
|
36
|
+
|
|
37
|
+
The [`action/`](action) wrapper installs the CLI and runs the audit. Drop this in
|
|
38
|
+
`.github/workflows/report-audit.yml`:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
name: Report audit
|
|
42
|
+
on:
|
|
43
|
+
pull_request:
|
|
44
|
+
paths: ['**/*.Report/**']
|
|
45
|
+
jobs:
|
|
46
|
+
audit:
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v4
|
|
50
|
+
- uses: Jesusveiga/DataflowKiller/pbip/action@v0.5.0
|
|
51
|
+
with:
|
|
52
|
+
path: . # a .pbip, a *.Report folder, or a repo root
|
|
53
|
+
# config: pbipkiller.config.json # optional; auto-detected if omitted
|
|
54
|
+
# fail-on: warning # optional; overrides config's failOn
|
|
55
|
+
format: md
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Input | Default | Notes |
|
|
59
|
+
|-----------|---------|-------|
|
|
60
|
+
| `path` | `.` | `.pbip` file, `*.Report` folder, or a repo folder containing one |
|
|
61
|
+
| `config` | — | Path to `pbipkiller.config.json` (auto-detected in cwd if omitted) |
|
|
62
|
+
| `fail-on` | — | Override the gate; if omitted, the config's `failOn` wins |
|
|
63
|
+
| `format` | `human` | `human` · `json` · `md` |
|
|
64
|
+
|
|
65
|
+
## The model rules see
|
|
66
|
+
|
|
67
|
+
Rules never touch raw PBIR JSON. The parser normalizes it into clean, stable
|
|
68
|
+
fields, and **those field names are the contract** for both filtering and
|
|
69
|
+
message templating:
|
|
70
|
+
|
|
71
|
+
| Scope | Fields available |
|
|
72
|
+
|-----------|------------------|
|
|
73
|
+
| `report` | `name`, `theme`, `exportDataMode`, `pageCount`, `visualCount`, `bookmarkCount` |
|
|
74
|
+
| `page` | `name`, `displayName`, `hidden`, `type`, `width`, `height`, `visualCount` |
|
|
75
|
+
| `visual` | `page`, `name`, `type`, `isCustomVisual`, `title`, `hasTitle`, `altText`, `hasAltText`, `hidden`, `width`, `height`, `x`, `y`, `tabOrder`, `fieldCount`, `measureCount`, `filterCount` |
|
|
76
|
+
|
|
77
|
+
## Configuration — `pbipkiller.config.json`
|
|
78
|
+
|
|
79
|
+
Three levels of customization, all in one file:
|
|
80
|
+
|
|
81
|
+
### 1. Tune the built-in rules
|
|
82
|
+
|
|
83
|
+
```jsonc
|
|
84
|
+
{
|
|
85
|
+
"failOn": "critical",
|
|
86
|
+
"rules": {
|
|
87
|
+
"PERF001": { "severity": "warning", "max": 12 }, // override a threshold
|
|
88
|
+
"A11Y001": { "severity": "critical" }, // raise severity
|
|
89
|
+
"CONS001": "off" // disable
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Built-in rules:
|
|
95
|
+
|
|
96
|
+
| Id | Category | What it flags | Config keys | Default |
|
|
97
|
+
|-----------|---------------|---------------|-------------|---------|
|
|
98
|
+
| `PERF001` | Performance | More than `max` data visuals on a page | `max` (15) | on |
|
|
99
|
+
| `PERF002` | Performance | More than `max` visuals report-wide | `max` (60) | on |
|
|
100
|
+
| `PERF003` | Performance | More than `max` slicers on a page | `max` (4) | on |
|
|
101
|
+
| `PERF004` | Performance | A visual binding more than `max` fields | `max` (10) | on |
|
|
102
|
+
| `PERF005` | Performance | Page total field load above `max` | `max` (60) | on |
|
|
103
|
+
| `PERF006` | Performance | A table/matrix with no visual-level filter (Top N) | — | on |
|
|
104
|
+
| `PERF009` | Performance | More than `max` pages | `max` (20) | off |
|
|
105
|
+
| `DATA001` | Data | A visual with no fields bound (renders blank) | — | on |
|
|
106
|
+
| `GOV001` | Governance | An uncertified / custom (third-party) visual | — | on |
|
|
107
|
+
| `GOV002` | Governance | A visual whose type is in your block-list | `types` ([]) | off |
|
|
108
|
+
| `GOV003` | Governance | A page still named "Page N" | — | on |
|
|
109
|
+
| `GOV005` | Governance | A visual reading from a restricted table | `entities` ([]) | off |
|
|
110
|
+
| `GOV006` | Governance | End-user data export is enabled | — | on |
|
|
111
|
+
| `GOV008` | Governance | Theme not in your approved list | `allowed` ([]) | off |
|
|
112
|
+
| `CONS001` | Consistency | A hidden page that still carries visuals | — | off |
|
|
113
|
+
| `CONS002` | Consistency | An empty page (0 visuals) | — | on |
|
|
114
|
+
| `CONS003` | Consistency | Mixed canvas sizes across the report | — | on |
|
|
115
|
+
| `CONS004` | Consistency | Data visuals that overlap or sit off-canvas | `minOverlapPct` (25) | on |
|
|
116
|
+
| `CONS005` | Consistency | Slicers placed inconsistently across pages | `tolerancePx` (10) | on |
|
|
117
|
+
| `CONS010` | Consistency | A bookmark group nothing navigates to | — | on |
|
|
118
|
+
| `A11Y001` | Accessibility | A data visual with no alt text | — | on |
|
|
119
|
+
| `A11Y002` | Accessibility | A visual with no explicit tab order | — | off |
|
|
120
|
+
| `A11Y003` | Accessibility | A decorative shape/image left in the tab order | — | on |
|
|
121
|
+
| `A11Y005` | Accessibility | A title containing banned jargon/acronyms | `terms` ([]) | off |
|
|
122
|
+
|
|
123
|
+
Rules marked "off" need configuration (a list/threshold) to do anything; enable
|
|
124
|
+
them by adding their config entry. Rules `PERF004/005/006`, `A11Y003/005`,
|
|
125
|
+
`GOV006` and `CONS005` implement official Microsoft Learn guidance (optimization
|
|
126
|
+
and accessibility checklists).
|
|
127
|
+
|
|
128
|
+
### 2. The gate
|
|
129
|
+
|
|
130
|
+
`"failOn"` ∈ `critical` (default) · `warning` · `optimization` · `none`.
|
|
131
|
+
|
|
132
|
+
### 3. Write your own rules — no code
|
|
133
|
+
|
|
134
|
+
Declare rules as data. They run in CI exactly like a built-in:
|
|
135
|
+
|
|
136
|
+
```jsonc
|
|
137
|
+
{
|
|
138
|
+
"customRules": [
|
|
139
|
+
{
|
|
140
|
+
"id": "team/no-image-visuals",
|
|
141
|
+
"title": "No static images as visuals",
|
|
142
|
+
"category": "Governance",
|
|
143
|
+
"severity": "warning",
|
|
144
|
+
"scope": "visual",
|
|
145
|
+
"when": { "field": "type", "op": "eq", "value": "image" },
|
|
146
|
+
"message": "Visual '{{name}}' on page '{{page}}' is a static image."
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"id": "team/dense-pages",
|
|
150
|
+
"category": "Performance",
|
|
151
|
+
"severity": "critical",
|
|
152
|
+
"scope": "page",
|
|
153
|
+
"when": [
|
|
154
|
+
{ "field": "visualCount", "op": "gt", "value": 20 },
|
|
155
|
+
{ "field": "hidden", "op": "eq", "value": false }
|
|
156
|
+
],
|
|
157
|
+
"message": "Page '{{displayName}}' has {{visualCount}} visuals — split it."
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- `when` is one predicate or an array (AND-ed).
|
|
164
|
+
- Operators: `eq` `ne` `gt` `gte` `lt` `lte` `in` `exists` `missing` `matches` (regex).
|
|
165
|
+
- `message` supports `{{field}}` placeholders from the table above.
|
|
166
|
+
|
|
167
|
+
## Roadmap
|
|
168
|
+
|
|
169
|
+
- ~~Vitest suite pinning fixture findings.~~ ✅ [`tests/report.test.ts`](tests/report.test.ts)
|
|
170
|
+
- ~~A GitHub Action wrapper.~~ ✅ [`action/`](action)
|
|
171
|
+
- Phase 2: extract a shared `core` engine so dataflow + pbip share one dispatcher.
|
|
172
|
+
- Phase 3: TMDL semantic-model rules (reuses the M folding engine).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// pbip/src/main.ts
|
|
4
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
|
|
7
|
+
// pbip/src/parser.ts
|
|
8
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "fs";
|
|
9
|
+
import { join, basename } from "path";
|
|
10
|
+
var CORE_VISUALS = /* @__PURE__ */ new Set([
|
|
11
|
+
"barChart",
|
|
12
|
+
"columnChart",
|
|
13
|
+
"clusteredBarChart",
|
|
14
|
+
"clusteredColumnChart",
|
|
15
|
+
"hundredPercentStackedBarChart",
|
|
16
|
+
"hundredPercentStackedColumnChart",
|
|
17
|
+
"lineChart",
|
|
18
|
+
"areaChart",
|
|
19
|
+
"stackedAreaChart",
|
|
20
|
+
"lineStackedColumnComboChart",
|
|
21
|
+
"lineClusteredColumnComboChart",
|
|
22
|
+
"pieChart",
|
|
23
|
+
"donutChart",
|
|
24
|
+
"treemap",
|
|
25
|
+
"map",
|
|
26
|
+
"filledMap",
|
|
27
|
+
"shapeMap",
|
|
28
|
+
"azureMap",
|
|
29
|
+
"scatterChart",
|
|
30
|
+
"waterfallChart",
|
|
31
|
+
"funnel",
|
|
32
|
+
"gauge",
|
|
33
|
+
"card",
|
|
34
|
+
"cardVisual",
|
|
35
|
+
"multiRowCard",
|
|
36
|
+
"kpi",
|
|
37
|
+
"slicer",
|
|
38
|
+
"advancedSlicerVisual",
|
|
39
|
+
"tableEx",
|
|
40
|
+
"pivotTable",
|
|
41
|
+
"matrix",
|
|
42
|
+
"ribbonChart",
|
|
43
|
+
"decompositionTreeVisual",
|
|
44
|
+
"keyDriversVisual",
|
|
45
|
+
"qnaVisual",
|
|
46
|
+
"scriptVisual",
|
|
47
|
+
"pythonVisual",
|
|
48
|
+
"esriVisual",
|
|
49
|
+
"image",
|
|
50
|
+
"textbox",
|
|
51
|
+
"shape",
|
|
52
|
+
"actionButton",
|
|
53
|
+
"basicShape",
|
|
54
|
+
"filterVisual",
|
|
55
|
+
"pageNavigator",
|
|
56
|
+
"bookmarkNavigator",
|
|
57
|
+
"aggregatedSparkline"
|
|
58
|
+
]);
|
|
59
|
+
var DECORATION = /* @__PURE__ */ new Set([
|
|
60
|
+
"actionButton",
|
|
61
|
+
"shape",
|
|
62
|
+
"basicShape",
|
|
63
|
+
"textbox",
|
|
64
|
+
"image",
|
|
65
|
+
"pageNavigator",
|
|
66
|
+
"bookmarkNavigator"
|
|
67
|
+
]);
|
|
68
|
+
function isDecoration(type) {
|
|
69
|
+
return DECORATION.has(type);
|
|
70
|
+
}
|
|
71
|
+
function readJson(path) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function deepFindStrings(obj, key, out = []) {
|
|
79
|
+
if (!obj || typeof obj !== "object") return out;
|
|
80
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
81
|
+
if (k === key && typeof v === "string" && v.trim()) out.push(v.trim());
|
|
82
|
+
else if (v && typeof v === "object") deepFindStrings(v, key, out);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function resolveDefinitionDir(input) {
|
|
87
|
+
let root = input;
|
|
88
|
+
if (statSync(input).isFile()) {
|
|
89
|
+
root = join(input, "..");
|
|
90
|
+
}
|
|
91
|
+
if (existsSync(join(root, "definition", "report.json"))) {
|
|
92
|
+
return join(root, "definition");
|
|
93
|
+
}
|
|
94
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
95
|
+
if (entry.isDirectory() && entry.name.endsWith(".Report")) {
|
|
96
|
+
const def = join(root, entry.name, "definition");
|
|
97
|
+
if (existsSync(join(def, "report.json"))) return def;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function firstLiteral(obj) {
|
|
103
|
+
const vals = deepFindStrings(obj, "Value");
|
|
104
|
+
for (const raw of vals) {
|
|
105
|
+
const s = raw.replace(/^'(.*)'$/s, "$1").trim();
|
|
106
|
+
if (s) return s;
|
|
107
|
+
}
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
function extractBinding(v) {
|
|
111
|
+
const state = v?.query?.queryState ?? {};
|
|
112
|
+
let fieldCount = 0;
|
|
113
|
+
let measureCount = 0;
|
|
114
|
+
const entities = /* @__PURE__ */ new Set();
|
|
115
|
+
for (const role of Object.values(state)) {
|
|
116
|
+
for (const proj of role?.projections ?? []) {
|
|
117
|
+
const f = proj?.field ?? {};
|
|
118
|
+
const node = f.Measure ?? f.Column;
|
|
119
|
+
if (!node) continue;
|
|
120
|
+
fieldCount++;
|
|
121
|
+
if (f.Measure) measureCount++;
|
|
122
|
+
const entity = node.Expression?.SourceRef?.Entity;
|
|
123
|
+
if (typeof entity === "string") entities.add(entity);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { fieldCount, measureCount, entities: [...entities] };
|
|
127
|
+
}
|
|
128
|
+
function parseVisual(visualDir, customVisualIds, refTokens) {
|
|
129
|
+
const raw = readJson(join(visualDir, "visual.json"));
|
|
130
|
+
if (!raw) return null;
|
|
131
|
+
const v = raw.visual;
|
|
132
|
+
if (!v || typeof v.visualType !== "string") return null;
|
|
133
|
+
const type = v.visualType;
|
|
134
|
+
const pos = raw.position ?? {};
|
|
135
|
+
for (const lit of deepFindStrings(v, "Value")) {
|
|
136
|
+
refTokens.add(lit.replace(/^'(.*)'$/s, "$1").trim());
|
|
137
|
+
}
|
|
138
|
+
const altCandidates = deepFindStrings(v, "altText");
|
|
139
|
+
const title = firstLiteral(v.visualContainerObjects?.title);
|
|
140
|
+
const { fieldCount, measureCount, entities } = extractBinding(v);
|
|
141
|
+
const fcFilters = raw.filterConfig?.filters;
|
|
142
|
+
const filterCount = Array.isArray(fcFilters) ? fcFilters.length : 0;
|
|
143
|
+
return {
|
|
144
|
+
name: raw.name ?? basename(visualDir),
|
|
145
|
+
type,
|
|
146
|
+
isCustomVisual: !CORE_VISUALS.has(type) || customVisualIds.has(type),
|
|
147
|
+
title,
|
|
148
|
+
hasTitle: title.length > 0,
|
|
149
|
+
altText: altCandidates[0] ?? "",
|
|
150
|
+
hasAltText: altCandidates.length > 0,
|
|
151
|
+
hidden: raw.isHidden === true,
|
|
152
|
+
width: Number(pos.width ?? 0),
|
|
153
|
+
height: Number(pos.height ?? 0),
|
|
154
|
+
x: Number(pos.x ?? 0),
|
|
155
|
+
y: Number(pos.y ?? 0),
|
|
156
|
+
tabOrder: typeof pos.tabOrder === "number" ? pos.tabOrder : -1,
|
|
157
|
+
fieldCount,
|
|
158
|
+
measureCount,
|
|
159
|
+
entities,
|
|
160
|
+
filterCount
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function parsePage(pageDir, customVisualIds, refTokens) {
|
|
164
|
+
const raw = readJson(join(pageDir, "page.json")) ?? {};
|
|
165
|
+
const visuals = [];
|
|
166
|
+
const visualsDir = join(pageDir, "visuals");
|
|
167
|
+
if (existsSync(visualsDir)) {
|
|
168
|
+
for (const entry of readdirSync(visualsDir, { withFileTypes: true })) {
|
|
169
|
+
if (!entry.isDirectory()) continue;
|
|
170
|
+
const vis = parseVisual(join(visualsDir, entry.name), customVisualIds, refTokens);
|
|
171
|
+
if (vis) visuals.push(vis);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
name: raw.name ?? basename(pageDir),
|
|
176
|
+
displayName: raw.displayName ?? raw.name ?? basename(pageDir),
|
|
177
|
+
hidden: raw.visibility === "HiddenInViewMode" || raw.visibility === 1,
|
|
178
|
+
type: typeof raw.type === "string" ? raw.type : "",
|
|
179
|
+
width: Number(raw.width ?? 0),
|
|
180
|
+
height: Number(raw.height ?? 0),
|
|
181
|
+
visuals,
|
|
182
|
+
visualCount: visuals.length
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function parseReport(input) {
|
|
186
|
+
const def = resolveDefinitionDir(input);
|
|
187
|
+
if (!def) {
|
|
188
|
+
throw new Error(`No PBIR report found at "${input}" (expected a definition/report.json).`);
|
|
189
|
+
}
|
|
190
|
+
const report = readJson(join(def, "report.json")) ?? {};
|
|
191
|
+
const customVisualIds = new Set(
|
|
192
|
+
report.publicCustomVisuals ?? []
|
|
193
|
+
);
|
|
194
|
+
const theme = report.themeCollection?.customTheme?.name ?? report.themeCollection?.baseTheme?.name ?? "";
|
|
195
|
+
const exportDataMode = report.settings?.exportDataMode ?? "";
|
|
196
|
+
const refTokens = /* @__PURE__ */ new Set();
|
|
197
|
+
const pages = [];
|
|
198
|
+
const pagesDir = join(def, "pages");
|
|
199
|
+
if (existsSync(pagesDir)) {
|
|
200
|
+
for (const entry of readdirSync(pagesDir, { withFileTypes: true })) {
|
|
201
|
+
if (entry.isDirectory()) {
|
|
202
|
+
pages.push(parsePage(join(pagesDir, entry.name), customVisualIds, refTokens));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const { bookmarkCount, orphanBookmarkGroups } = analyzeBookmarks(def, refTokens);
|
|
207
|
+
const reportFolder = basename(join(def, "..")).replace(/\.Report$/, "");
|
|
208
|
+
return {
|
|
209
|
+
name: reportFolder || "report",
|
|
210
|
+
theme,
|
|
211
|
+
exportDataMode,
|
|
212
|
+
pages,
|
|
213
|
+
pageCount: pages.length,
|
|
214
|
+
visualCount: pages.reduce((n, p) => n + p.visualCount, 0),
|
|
215
|
+
bookmarkCount,
|
|
216
|
+
orphanBookmarkGroups
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function analyzeBookmarks(def, refTokens) {
|
|
220
|
+
const meta = readJson(join(def, "bookmarks", "bookmarks.json"));
|
|
221
|
+
const items = meta?.items ?? [];
|
|
222
|
+
let bookmarkCount = 0;
|
|
223
|
+
const orphans = [];
|
|
224
|
+
for (const group of items) {
|
|
225
|
+
const children = Array.isArray(group.children) ? group.children : [];
|
|
226
|
+
bookmarkCount += children.length || 1;
|
|
227
|
+
const reachable = refTokens.has(group.name) || children.some((c) => refTokens.has(c));
|
|
228
|
+
if (!reachable) orphans.push(group.displayName ?? group.name);
|
|
229
|
+
}
|
|
230
|
+
return { bookmarkCount, orphanBookmarkGroups: orphans };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// pbip/src/config.ts
|
|
234
|
+
var DEFAULT_CONFIG = {
|
|
235
|
+
failOn: "critical",
|
|
236
|
+
rules: {},
|
|
237
|
+
customRules: []
|
|
238
|
+
};
|
|
239
|
+
function fieldsOf(node, scope) {
|
|
240
|
+
switch (scope) {
|
|
241
|
+
case "report": {
|
|
242
|
+
const m = node;
|
|
243
|
+
return {
|
|
244
|
+
name: m.name,
|
|
245
|
+
theme: m.theme,
|
|
246
|
+
exportDataMode: m.exportDataMode,
|
|
247
|
+
pageCount: m.pageCount,
|
|
248
|
+
visualCount: m.visualCount,
|
|
249
|
+
bookmarkCount: m.bookmarkCount
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
case "page": {
|
|
253
|
+
const p = node;
|
|
254
|
+
return {
|
|
255
|
+
name: p.name,
|
|
256
|
+
displayName: p.displayName,
|
|
257
|
+
hidden: p.hidden,
|
|
258
|
+
type: p.type,
|
|
259
|
+
width: p.width,
|
|
260
|
+
height: p.height,
|
|
261
|
+
visualCount: p.visualCount
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
case "visual": {
|
|
265
|
+
const { page, visual } = node;
|
|
266
|
+
return {
|
|
267
|
+
page: page.displayName,
|
|
268
|
+
name: visual.name,
|
|
269
|
+
type: visual.type,
|
|
270
|
+
isCustomVisual: visual.isCustomVisual,
|
|
271
|
+
title: visual.title,
|
|
272
|
+
hasTitle: visual.hasTitle,
|
|
273
|
+
altText: visual.altText,
|
|
274
|
+
hasAltText: visual.hasAltText,
|
|
275
|
+
hidden: visual.hidden,
|
|
276
|
+
width: visual.width,
|
|
277
|
+
height: visual.height,
|
|
278
|
+
x: visual.x,
|
|
279
|
+
y: visual.y,
|
|
280
|
+
tabOrder: visual.tabOrder,
|
|
281
|
+
fieldCount: visual.fieldCount,
|
|
282
|
+
measureCount: visual.measureCount,
|
|
283
|
+
filterCount: visual.filterCount
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function testPredicate(fields, p) {
|
|
289
|
+
const actual = fields[p.field];
|
|
290
|
+
switch (p.op) {
|
|
291
|
+
case "exists":
|
|
292
|
+
return actual !== void 0 && actual !== "" && actual !== false;
|
|
293
|
+
case "missing":
|
|
294
|
+
return actual === void 0 || actual === "" || actual === false;
|
|
295
|
+
case "eq":
|
|
296
|
+
return actual === p.value;
|
|
297
|
+
case "ne":
|
|
298
|
+
return actual !== p.value;
|
|
299
|
+
case "gt":
|
|
300
|
+
return Number(actual) > Number(p.value);
|
|
301
|
+
case "gte":
|
|
302
|
+
return Number(actual) >= Number(p.value);
|
|
303
|
+
case "lt":
|
|
304
|
+
return Number(actual) < Number(p.value);
|
|
305
|
+
case "lte":
|
|
306
|
+
return Number(actual) <= Number(p.value);
|
|
307
|
+
case "in":
|
|
308
|
+
return Array.isArray(p.value) && p.value.includes(actual);
|
|
309
|
+
case "matches":
|
|
310
|
+
return typeof actual === "string" && new RegExp(String(p.value)).test(actual);
|
|
311
|
+
default:
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function evalWhen(fields, when) {
|
|
316
|
+
const preds = Array.isArray(when) ? when : [when];
|
|
317
|
+
return preds.every((p) => testPredicate(fields, p));
|
|
318
|
+
}
|
|
319
|
+
function renderMessage(template, fields) {
|
|
320
|
+
return template.replace(
|
|
321
|
+
/\{\{\s*(\w+)\s*\}\}/g,
|
|
322
|
+
(_, key) => key in fields ? String(fields[key]) : `{{${key}}}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
function normalizeConfig(raw) {
|
|
326
|
+
const obj = raw && typeof raw === "object" ? raw : {};
|
|
327
|
+
return {
|
|
328
|
+
failOn: obj.failOn ?? DEFAULT_CONFIG.failOn,
|
|
329
|
+
rules: obj.rules ?? {},
|
|
330
|
+
customRules: Array.isArray(obj.customRules) ? obj.customRules : []
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function normalizeSeverity(s, fallback) {
|
|
334
|
+
switch ((s ?? "").toLowerCase()) {
|
|
335
|
+
case "critical":
|
|
336
|
+
return "Critical";
|
|
337
|
+
case "warning":
|
|
338
|
+
return "Warning";
|
|
339
|
+
case "optimization":
|
|
340
|
+
return "Optimization";
|
|
341
|
+
default:
|
|
342
|
+
return fallback;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
var SEVERITY_RANK = {
|
|
346
|
+
critical: 3,
|
|
347
|
+
warning: 2,
|
|
348
|
+
optimization: 1,
|
|
349
|
+
none: 0
|
|
350
|
+
};
|
|
351
|
+
function meetsThreshold(severity, failOn) {
|
|
352
|
+
const gate = SEVERITY_RANK[failOn] ?? 3;
|
|
353
|
+
if (gate === 0) return false;
|
|
354
|
+
return (SEVERITY_RANK[severity.toLowerCase()] ?? 0) >= gate;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// pbip/src/customRules.ts
|
|
358
|
+
function compile(spec) {
|
|
359
|
+
return {
|
|
360
|
+
id: spec.id,
|
|
361
|
+
title: spec.title ?? spec.id,
|
|
362
|
+
category: spec.category,
|
|
363
|
+
scope: spec.scope,
|
|
364
|
+
defaultSeverity: normalizeSeverity(spec.severity, "Warning"),
|
|
365
|
+
custom: true,
|
|
366
|
+
rationale: spec.rationale ?? "User-defined rule.",
|
|
367
|
+
evaluate(node) {
|
|
368
|
+
const fields = fieldsOf(node, spec.scope);
|
|
369
|
+
if (!evalWhen(fields, spec.when)) return [];
|
|
370
|
+
const hit = { message: renderMessage(spec.message, fields) };
|
|
371
|
+
if (spec.recommendation) hit.recommendation = spec.recommendation;
|
|
372
|
+
if (spec.scope === "visual") {
|
|
373
|
+
const { page, visual } = node;
|
|
374
|
+
hit.page = page.displayName;
|
|
375
|
+
hit.visual = visual.name;
|
|
376
|
+
} else if (spec.scope === "page") {
|
|
377
|
+
hit.page = node.displayName;
|
|
378
|
+
}
|
|
379
|
+
return [hit];
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function compileCustomRules(specs) {
|
|
384
|
+
const out = [];
|
|
385
|
+
for (const spec of specs) {
|
|
386
|
+
if (!spec.id || !spec.when || !spec.message || !spec.scope) {
|
|
387
|
+
console.error(
|
|
388
|
+
`Skipping custom rule (needs id, scope, when, message): ${JSON.stringify(spec.id ?? spec)}`
|
|
389
|
+
);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
out.push(compile(spec));
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// pbip/src/rules.ts
|
|
398
|
+
var isUtilityPage = (p) => p.type === "Tooltip" || p.type === "Drillthrough";
|
|
399
|
+
var dataVisuals = (p) => p.visuals.filter((v) => !isDecoration(v.type));
|
|
400
|
+
function num(ctx, key, def) {
|
|
401
|
+
const v = ctx.override?.[key];
|
|
402
|
+
return typeof v === "number" ? v : def;
|
|
403
|
+
}
|
|
404
|
+
function list(ctx, key, def = []) {
|
|
405
|
+
const v = ctx.override?.[key];
|
|
406
|
+
return Array.isArray(v) ? v.map(String) : def;
|
|
407
|
+
}
|
|
408
|
+
var SLICER_TYPES = /* @__PURE__ */ new Set(["slicer", "advancedSlicerVisual", "filterVisual"]);
|
|
409
|
+
var TABLE_TYPES = /* @__PURE__ */ new Set(["tableEx", "pivotTable", "matrix"]);
|
|
410
|
+
var DECORATIVE_ONLY = /* @__PURE__ */ new Set(["shape", "basicShape", "image"]);
|
|
411
|
+
var PERF001_VisualsPerPage = {
|
|
412
|
+
id: "PERF001",
|
|
413
|
+
title: "Too many visuals on a page",
|
|
414
|
+
category: "Performance",
|
|
415
|
+
scope: "page",
|
|
416
|
+
defaultSeverity: "Warning",
|
|
417
|
+
rationale: "Each data visual is a separate query against the model. Pages with many of them slow first render and multiply query load. Decoration (buttons/shapes/text) is excluded.",
|
|
418
|
+
evaluate(page, ctx) {
|
|
419
|
+
const max = num(ctx, "max", 15);
|
|
420
|
+
const count = dataVisuals(page).length;
|
|
421
|
+
if (count <= max) return [];
|
|
422
|
+
return [
|
|
423
|
+
{
|
|
424
|
+
message: `Page "${page.displayName}" has ${count} data visuals (max ${max}).`,
|
|
425
|
+
recommendation: "Split the page, or replace several cards with a single multi-row visual.",
|
|
426
|
+
page: page.displayName
|
|
427
|
+
}
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
var PERF002_VisualsPerReport = {
|
|
432
|
+
id: "PERF002",
|
|
433
|
+
title: "Report has too many visuals overall",
|
|
434
|
+
category: "Performance",
|
|
435
|
+
scope: "report",
|
|
436
|
+
defaultSeverity: "Optimization",
|
|
437
|
+
rationale: "A very large report inflates publish size and open time. A high total often signals pages that should be split into separate reports.",
|
|
438
|
+
evaluate(model, ctx) {
|
|
439
|
+
const max = num(ctx, "max", 60);
|
|
440
|
+
if (model.visualCount <= max) return [];
|
|
441
|
+
return [
|
|
442
|
+
{
|
|
443
|
+
message: `Report has ${model.visualCount} visuals across ${model.pageCount} pages (max ${max}).`,
|
|
444
|
+
recommendation: "Consider splitting into focused reports or trimming redundant visuals."
|
|
445
|
+
}
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
var PERF003_SlicersPerPage = {
|
|
450
|
+
id: "PERF003",
|
|
451
|
+
title: "Too many slicers on a page",
|
|
452
|
+
category: "Performance",
|
|
453
|
+
scope: "page",
|
|
454
|
+
defaultSeverity: "Warning",
|
|
455
|
+
rationale: "Every slicer issues its own query and re-queries on each interaction. A wall of slicers is a common refresh/interaction bottleneck.",
|
|
456
|
+
evaluate(page, ctx) {
|
|
457
|
+
const max = num(ctx, "max", 4);
|
|
458
|
+
const slicers = page.visuals.filter((v) => SLICER_TYPES.has(v.type));
|
|
459
|
+
if (slicers.length <= max) return [];
|
|
460
|
+
return [
|
|
461
|
+
{
|
|
462
|
+
message: `Page "${page.displayName}" has ${slicers.length} slicers (max ${max}).`,
|
|
463
|
+
recommendation: "Consolidate filters into the filter pane or a single multi-field slicer.",
|
|
464
|
+
page: page.displayName
|
|
465
|
+
}
|
|
466
|
+
];
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
var PERF009_PageCount = {
|
|
470
|
+
id: "PERF009",
|
|
471
|
+
title: "Report has too many pages",
|
|
472
|
+
category: "Performance",
|
|
473
|
+
scope: "report",
|
|
474
|
+
defaultSeverity: "Optimization",
|
|
475
|
+
defaultEnabled: false,
|
|
476
|
+
// varies a lot by team; opt-in
|
|
477
|
+
rationale: "Beyond a point, page count hurts navigation and load. Often a sign a report is doing several jobs.",
|
|
478
|
+
evaluate(model, ctx) {
|
|
479
|
+
const max = num(ctx, "max", 20);
|
|
480
|
+
if (model.pageCount <= max) return [];
|
|
481
|
+
return [
|
|
482
|
+
{
|
|
483
|
+
message: `Report has ${model.pageCount} pages (max ${max}).`,
|
|
484
|
+
recommendation: "Split into multiple reports or use a navigation/drill-through pattern."
|
|
485
|
+
}
|
|
486
|
+
];
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
var GOV001_UncertifiedCustomVisual = {
|
|
490
|
+
id: "GOV001",
|
|
491
|
+
title: "Uncertified custom visual in use",
|
|
492
|
+
category: "Governance",
|
|
493
|
+
scope: "visual",
|
|
494
|
+
defaultSeverity: "Warning",
|
|
495
|
+
rationale: "Custom visuals run third-party code and can exfiltrate data unless certified. Many tenants forbid them \u2014 catch them before they ship.",
|
|
496
|
+
evaluate({ page, visual }) {
|
|
497
|
+
if (!visual.isCustomVisual) return [];
|
|
498
|
+
return [
|
|
499
|
+
{
|
|
500
|
+
message: `Visual "${visual.name}" on "${page.displayName}" is a custom visual ("${visual.type}").`,
|
|
501
|
+
recommendation: "Replace with a core visual, or confirm the custom visual is org-certified.",
|
|
502
|
+
page: page.displayName,
|
|
503
|
+
visual: visual.name
|
|
504
|
+
}
|
|
505
|
+
];
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
var GOV002_BlockedVisualType = {
|
|
509
|
+
id: "GOV002",
|
|
510
|
+
title: "Blocked visual type in use",
|
|
511
|
+
category: "Governance",
|
|
512
|
+
scope: "visual",
|
|
513
|
+
defaultSeverity: "Warning",
|
|
514
|
+
defaultEnabled: false,
|
|
515
|
+
// needs a configured block-list to do anything
|
|
516
|
+
rationale: "Lets a team ban specific visual types outright (e.g. static images, Python/R visuals) via config.",
|
|
517
|
+
evaluate({ page, visual }, ctx) {
|
|
518
|
+
const blocked = list(ctx, "types");
|
|
519
|
+
if (!blocked.includes(visual.type)) return [];
|
|
520
|
+
return [
|
|
521
|
+
{
|
|
522
|
+
message: `Visual "${visual.name}" on "${page.displayName}" uses blocked type "${visual.type}".`,
|
|
523
|
+
recommendation: "Use an approved visual type per the team standard.",
|
|
524
|
+
page: page.displayName,
|
|
525
|
+
visual: visual.name
|
|
526
|
+
}
|
|
527
|
+
];
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
var GOV003_DefaultPageName = {
|
|
531
|
+
id: "GOV003",
|
|
532
|
+
title: "Page still has a default name",
|
|
533
|
+
category: "Governance",
|
|
534
|
+
scope: "page",
|
|
535
|
+
defaultSeverity: "Optimization",
|
|
536
|
+
rationale: 'Pages left as "Page 1" signal unfinished work and read poorly in navigation and bookmarks.',
|
|
537
|
+
evaluate(page) {
|
|
538
|
+
if (!/^Page\s*\d+$/i.test(page.displayName.trim())) return [];
|
|
539
|
+
return [
|
|
540
|
+
{
|
|
541
|
+
message: `Page is still named "${page.displayName}".`,
|
|
542
|
+
recommendation: "Give every page a meaningful, user-facing name.",
|
|
543
|
+
page: page.displayName
|
|
544
|
+
}
|
|
545
|
+
];
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
var GOV008_ThemeNotApproved = {
|
|
549
|
+
id: "GOV008",
|
|
550
|
+
title: "Report theme is not on the approved list",
|
|
551
|
+
category: "Governance",
|
|
552
|
+
scope: "report",
|
|
553
|
+
defaultSeverity: "Warning",
|
|
554
|
+
defaultEnabled: false,
|
|
555
|
+
// needs an approved-theme allowlist in config
|
|
556
|
+
rationale: "Enforces corporate theming: flags reports whose theme is not one your org sanctioned.",
|
|
557
|
+
evaluate(model, ctx) {
|
|
558
|
+
const allowed = list(ctx, "allowed");
|
|
559
|
+
if (allowed.length === 0) return [];
|
|
560
|
+
if (model.theme && allowed.includes(model.theme)) return [];
|
|
561
|
+
return [
|
|
562
|
+
{
|
|
563
|
+
message: `Report theme "${model.theme || "(none)"}" is not in the approved list [${allowed.join(", ")}].`,
|
|
564
|
+
recommendation: "Apply an approved corporate theme."
|
|
565
|
+
}
|
|
566
|
+
];
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
var CONS001_HiddenPageWithVisuals = {
|
|
570
|
+
id: "CONS001",
|
|
571
|
+
title: "Hidden page still loads visuals",
|
|
572
|
+
category: "Consistency",
|
|
573
|
+
scope: "page",
|
|
574
|
+
defaultSeverity: "Optimization",
|
|
575
|
+
defaultEnabled: false,
|
|
576
|
+
// stylistic — off by default
|
|
577
|
+
rationale: "A hidden page that is never a drill-through target still costs refresh/model load. Often a dev leftover.",
|
|
578
|
+
evaluate(page) {
|
|
579
|
+
if (!page.hidden || page.visualCount === 0) return [];
|
|
580
|
+
return [
|
|
581
|
+
{
|
|
582
|
+
message: `Hidden page "${page.displayName}" still contains ${page.visualCount} visuals.`,
|
|
583
|
+
recommendation: "Delete the page if it is dead, or document it as a drill-through target.",
|
|
584
|
+
page: page.displayName
|
|
585
|
+
}
|
|
586
|
+
];
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
var CONS002_EmptyPage = {
|
|
590
|
+
id: "CONS002",
|
|
591
|
+
title: "Page has no visuals",
|
|
592
|
+
category: "Consistency",
|
|
593
|
+
scope: "page",
|
|
594
|
+
defaultSeverity: "Optimization",
|
|
595
|
+
rationale: "An empty page is almost always a leftover; it confuses users in navigation.",
|
|
596
|
+
evaluate(page) {
|
|
597
|
+
if (page.visualCount > 0 || isUtilityPage(page)) return [];
|
|
598
|
+
return [
|
|
599
|
+
{
|
|
600
|
+
message: `Page "${page.displayName}" is empty (0 visuals).`,
|
|
601
|
+
recommendation: "Remove the page, or add its intended content.",
|
|
602
|
+
page: page.displayName
|
|
603
|
+
}
|
|
604
|
+
];
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
var CONS003_InconsistentPageSize = {
|
|
608
|
+
id: "CONS003",
|
|
609
|
+
title: "Pages use inconsistent canvas sizes",
|
|
610
|
+
category: "Consistency",
|
|
611
|
+
scope: "report",
|
|
612
|
+
defaultSeverity: "Optimization",
|
|
613
|
+
rationale: "Mixed canvas sizes make a report feel disjointed and break shared backgrounds/layouts. Usually unintentional.",
|
|
614
|
+
evaluate(model) {
|
|
615
|
+
const sizes = new Set(
|
|
616
|
+
model.pages.filter((p) => p.width > 0 && p.height > 0 && !isUtilityPage(p)).map((p) => `${p.width}x${p.height}`)
|
|
617
|
+
);
|
|
618
|
+
if (sizes.size <= 1) return [];
|
|
619
|
+
return [
|
|
620
|
+
{
|
|
621
|
+
message: `Report mixes ${sizes.size} canvas sizes: ${[...sizes].join(", ")}.`,
|
|
622
|
+
recommendation: "Standardize on one canvas size (e.g. 1280x720) unless a page is a tooltip/drill page."
|
|
623
|
+
}
|
|
624
|
+
];
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
var A11Y001_MissingAltText = {
|
|
628
|
+
id: "A11Y001",
|
|
629
|
+
title: "Visual is missing alt text",
|
|
630
|
+
category: "Accessibility",
|
|
631
|
+
scope: "visual",
|
|
632
|
+
defaultSeverity: "Optimization",
|
|
633
|
+
rationale: "Screen readers announce alt text. Charts without it are invisible to users relying on assistive tech.",
|
|
634
|
+
evaluate({ page, visual }) {
|
|
635
|
+
if (visual.hidden || isUtilityPage(page)) return [];
|
|
636
|
+
if (isDecoration(visual.type)) return [];
|
|
637
|
+
if (visual.hasAltText) return [];
|
|
638
|
+
return [
|
|
639
|
+
{
|
|
640
|
+
message: `Visual "${visual.name}" (${visual.type}) on "${page.displayName}" has no alt text.`,
|
|
641
|
+
recommendation: "Set alt text in the visual's General > Alt text formatting pane.",
|
|
642
|
+
page: page.displayName,
|
|
643
|
+
visual: visual.name
|
|
644
|
+
}
|
|
645
|
+
];
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
var GOV006_ExportDataEnabled = {
|
|
649
|
+
id: "GOV006",
|
|
650
|
+
title: "End-user data export is enabled",
|
|
651
|
+
category: "Governance",
|
|
652
|
+
scope: "report",
|
|
653
|
+
defaultSeverity: "Optimization",
|
|
654
|
+
rationale: "Reports that allow exporting summarized or underlying data can leak governed data outside Power BI. Many orgs restrict this.",
|
|
655
|
+
evaluate(model) {
|
|
656
|
+
const mode = model.exportDataMode;
|
|
657
|
+
if (!mode || mode === "None") return [];
|
|
658
|
+
return [
|
|
659
|
+
{
|
|
660
|
+
message: `Report allows data export (exportDataMode = "${mode}").`,
|
|
661
|
+
recommendation: 'Set export to "None" unless the audience explicitly needs to export data.'
|
|
662
|
+
}
|
|
663
|
+
];
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
var A11Y002_MissingTabOrder = {
|
|
667
|
+
id: "A11Y002",
|
|
668
|
+
title: "Visual has no explicit tab order",
|
|
669
|
+
category: "Accessibility",
|
|
670
|
+
scope: "visual",
|
|
671
|
+
defaultSeverity: "Optimization",
|
|
672
|
+
defaultEnabled: false,
|
|
673
|
+
// can be noisy; opt-in
|
|
674
|
+
rationale: "Keyboard users navigate by tab order. Visuals left at the default order often read in a confusing sequence for screen-reader users.",
|
|
675
|
+
evaluate({ page, visual }) {
|
|
676
|
+
if (visual.hidden || isUtilityPage(page) || isDecoration(visual.type)) return [];
|
|
677
|
+
if (visual.tabOrder >= 0) return [];
|
|
678
|
+
return [
|
|
679
|
+
{
|
|
680
|
+
message: `Visual "${visual.name}" (${visual.type}) on "${page.displayName}" has no explicit tab order.`,
|
|
681
|
+
recommendation: "Set tab order in the Selection pane to control keyboard navigation.",
|
|
682
|
+
page: page.displayName,
|
|
683
|
+
visual: visual.name
|
|
684
|
+
}
|
|
685
|
+
];
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
var DATA001_EmptyVisual = {
|
|
689
|
+
id: "DATA001",
|
|
690
|
+
title: "Visual has no fields bound",
|
|
691
|
+
category: "Data",
|
|
692
|
+
scope: "visual",
|
|
693
|
+
defaultSeverity: "Warning",
|
|
694
|
+
rationale: "A data visual with zero fields renders blank or broken in the report \u2014 a defect that ships silently. Decoration (buttons/shapes/navigators) is excluded.",
|
|
695
|
+
evaluate({ page, visual }) {
|
|
696
|
+
if (isDecoration(visual.type)) return [];
|
|
697
|
+
if (visual.fieldCount > 0) return [];
|
|
698
|
+
return [
|
|
699
|
+
{
|
|
700
|
+
message: `Visual "${visual.name}" (${visual.type}) on "${page.displayName}" has no fields bound \u2014 it will render blank.`,
|
|
701
|
+
recommendation: "Bind a field/measure, or delete the visual.",
|
|
702
|
+
page: page.displayName,
|
|
703
|
+
visual: visual.name
|
|
704
|
+
}
|
|
705
|
+
];
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
var PERF004_VisualTooManyFields = {
|
|
709
|
+
id: "PERF004",
|
|
710
|
+
title: "Visual binds too many fields",
|
|
711
|
+
category: "Performance",
|
|
712
|
+
scope: "visual",
|
|
713
|
+
defaultSeverity: "Warning",
|
|
714
|
+
rationale: "Each bound field widens the query the visual sends to the model. Tables/charts with many fields are slow to render and hard to read.",
|
|
715
|
+
evaluate({ page, visual }, ctx) {
|
|
716
|
+
const max = num(ctx, "max", 10);
|
|
717
|
+
if (isDecoration(visual.type) || visual.fieldCount <= max) return [];
|
|
718
|
+
return [
|
|
719
|
+
{
|
|
720
|
+
message: `Visual "${visual.name}" (${visual.type}) on "${page.displayName}" binds ${visual.fieldCount} fields (max ${max}).`,
|
|
721
|
+
recommendation: "Trim columns/series, or split into focused visuals.",
|
|
722
|
+
page: page.displayName,
|
|
723
|
+
visual: visual.name
|
|
724
|
+
}
|
|
725
|
+
];
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
var PERF005_PageQueryLoad = {
|
|
729
|
+
id: "PERF005",
|
|
730
|
+
title: "Page issues a heavy total query load",
|
|
731
|
+
category: "Performance",
|
|
732
|
+
scope: "page",
|
|
733
|
+
defaultSeverity: "Optimization",
|
|
734
|
+
rationale: "Sum of fields across all data visuals approximates how much the page asks of the model on load. A very high total is a refresh/interactivity bottleneck.",
|
|
735
|
+
evaluate(page, ctx) {
|
|
736
|
+
const max = num(ctx, "max", 60);
|
|
737
|
+
const load = dataVisuals(page).reduce((n, v) => n + v.fieldCount, 0);
|
|
738
|
+
if (load <= max) return [];
|
|
739
|
+
return [
|
|
740
|
+
{
|
|
741
|
+
message: `Page "${page.displayName}" binds ${load} fields across its data visuals (max ${max}).`,
|
|
742
|
+
recommendation: "Reduce visuals/fields, or move detail to a drill-through page.",
|
|
743
|
+
page: page.displayName
|
|
744
|
+
}
|
|
745
|
+
];
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
var GOV005_RestrictedEntity = {
|
|
749
|
+
id: "GOV005",
|
|
750
|
+
title: "Visual reads from a restricted table",
|
|
751
|
+
category: "Governance",
|
|
752
|
+
scope: "visual",
|
|
753
|
+
defaultSeverity: "Critical",
|
|
754
|
+
defaultEnabled: false,
|
|
755
|
+
// needs a configured list of restricted entities
|
|
756
|
+
rationale: "Lets a team forbid specific source tables (e.g. salary, PII) from appearing in reports. Flags any visual bound to one.",
|
|
757
|
+
evaluate({ page, visual }, ctx) {
|
|
758
|
+
const restricted = list(ctx, "entities");
|
|
759
|
+
const hit = visual.entities.filter((e) => restricted.includes(e));
|
|
760
|
+
if (hit.length === 0) return [];
|
|
761
|
+
return [
|
|
762
|
+
{
|
|
763
|
+
message: `Visual "${visual.name}" on "${page.displayName}" reads from restricted table(s): ${hit.join(", ")}.`,
|
|
764
|
+
recommendation: "Remove the field, or get the table cleared for reporting.",
|
|
765
|
+
page: page.displayName,
|
|
766
|
+
visual: visual.name
|
|
767
|
+
}
|
|
768
|
+
];
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
var CONS004_OverlappingVisuals = {
|
|
772
|
+
id: "CONS004",
|
|
773
|
+
title: "Data visuals overlap or sit off-canvas",
|
|
774
|
+
category: "Consistency",
|
|
775
|
+
scope: "page",
|
|
776
|
+
defaultSeverity: "Warning",
|
|
777
|
+
rationale: "Overlapping data visuals hide each other (data the user never sees); off-canvas visuals are invisible yet still query the model. Both are real defects, not cosmetics.",
|
|
778
|
+
evaluate(page, ctx) {
|
|
779
|
+
if (isUtilityPage(page)) return [];
|
|
780
|
+
const minOverlap = num(ctx, "minOverlapPct", 25) / 100;
|
|
781
|
+
const vs = dataVisuals(page).filter((v) => !v.hidden && v.width > 0 && v.height > 0);
|
|
782
|
+
let overlaps = 0;
|
|
783
|
+
for (let i = 0; i < vs.length; i++) {
|
|
784
|
+
for (let j = i + 1; j < vs.length; j++) {
|
|
785
|
+
const a = vs[i], b = vs[j];
|
|
786
|
+
const ix = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
|
|
787
|
+
const iy = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
|
|
788
|
+
const inter = ix * iy;
|
|
789
|
+
const smaller = Math.min(a.width * a.height, b.width * b.height);
|
|
790
|
+
if (smaller > 0 && inter / smaller >= minOverlap) overlaps++;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const offCanvas = page.width > 0 && page.height > 0 ? vs.filter((v) => v.x < -1 || v.y < -1 || v.x + v.width > page.width + 1 || v.y + v.height > page.height + 1).length : 0;
|
|
794
|
+
if (overlaps === 0 && offCanvas === 0) return [];
|
|
795
|
+
const parts = [
|
|
796
|
+
overlaps ? `${overlaps} overlapping pair(s)` : "",
|
|
797
|
+
offCanvas ? `${offCanvas} off-canvas` : ""
|
|
798
|
+
].filter(Boolean);
|
|
799
|
+
return [
|
|
800
|
+
{
|
|
801
|
+
message: `Page "${page.displayName}" has ${parts.join(" and ")} among its data visuals.`,
|
|
802
|
+
recommendation: "Use the Selection pane to align visuals and bring off-canvas ones back into view.",
|
|
803
|
+
page: page.displayName
|
|
804
|
+
}
|
|
805
|
+
];
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
var CONS010_OrphanBookmark = {
|
|
809
|
+
id: "CONS010",
|
|
810
|
+
title: "Orphaned bookmark group",
|
|
811
|
+
category: "Consistency",
|
|
812
|
+
scope: "report",
|
|
813
|
+
defaultSeverity: "Optimization",
|
|
814
|
+
rationale: "Bookmarks no navigator or button points to are dead weight: they bloat the file, confuse maintainers, and may hold stale filter state. Common after redesigns.",
|
|
815
|
+
evaluate(model) {
|
|
816
|
+
if (model.orphanBookmarkGroups.length === 0) return [];
|
|
817
|
+
return model.orphanBookmarkGroups.map((name) => ({
|
|
818
|
+
message: `Bookmark group "${name}" is not referenced by any navigator or button.`,
|
|
819
|
+
recommendation: "Delete it, or wire a navigator/button to it."
|
|
820
|
+
}));
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
var PERF006_UnfilteredTable = {
|
|
824
|
+
id: "PERF006",
|
|
825
|
+
title: "Table/matrix has no visual-level filter",
|
|
826
|
+
category: "Performance",
|
|
827
|
+
scope: "visual",
|
|
828
|
+
defaultSeverity: "Optimization",
|
|
829
|
+
rationale: "Microsoft: tables are expensive and an unfiltered table loads every row into memory. Apply a Top N (or other) filter to cap rows.",
|
|
830
|
+
evaluate({ page, visual }) {
|
|
831
|
+
if (!TABLE_TYPES.has(visual.type) || visual.filterCount > 0) return [];
|
|
832
|
+
return [
|
|
833
|
+
{
|
|
834
|
+
message: `Table "${visual.name}" on "${page.displayName}" has no visual-level filter (risk of loading all rows).`,
|
|
835
|
+
recommendation: "Add a Top N filter to cap the rows the table materializes.",
|
|
836
|
+
page: page.displayName,
|
|
837
|
+
visual: visual.name
|
|
838
|
+
}
|
|
839
|
+
];
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
var A11Y003_DecorativeInTabOrder = {
|
|
843
|
+
id: "A11Y003",
|
|
844
|
+
title: "Decorative shape/image is in the tab order",
|
|
845
|
+
category: "Accessibility",
|
|
846
|
+
scope: "visual",
|
|
847
|
+
defaultSeverity: "Optimization",
|
|
848
|
+
rationale: "Microsoft: decorative shapes and images should be hidden from the tab order so screen readers don't announce them. Interactive items (buttons) are exempt.",
|
|
849
|
+
evaluate({ page, visual }) {
|
|
850
|
+
if (isUtilityPage(page) || !DECORATIVE_ONLY.has(visual.type)) return [];
|
|
851
|
+
if (visual.tabOrder < 0) return [];
|
|
852
|
+
return [
|
|
853
|
+
{
|
|
854
|
+
message: `Decorative ${visual.type} "${visual.name}" on "${page.displayName}" is still in the tab order.`,
|
|
855
|
+
recommendation: "Hide it from tab order in the Selection pane so screen readers skip it.",
|
|
856
|
+
page: page.displayName,
|
|
857
|
+
visual: visual.name
|
|
858
|
+
}
|
|
859
|
+
];
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
var A11Y005_JargonInTitle = {
|
|
863
|
+
id: "A11Y005",
|
|
864
|
+
title: "Visual title contains banned jargon/acronym",
|
|
865
|
+
category: "Accessibility",
|
|
866
|
+
scope: "visual",
|
|
867
|
+
defaultSeverity: "Optimization",
|
|
868
|
+
defaultEnabled: false,
|
|
869
|
+
// needs a configured term list
|
|
870
|
+
rationale: "Microsoft: avoid acronyms/jargon in titles \u2014 external or new users may not understand them. Supply your org's banned terms.",
|
|
871
|
+
evaluate({ page, visual }, ctx) {
|
|
872
|
+
if (!visual.hasTitle) return [];
|
|
873
|
+
const banned = list(ctx, "terms");
|
|
874
|
+
const hit = banned.find((t) => new RegExp(`\\b${t}\\b`, "i").test(visual.title));
|
|
875
|
+
if (!hit) return [];
|
|
876
|
+
return [
|
|
877
|
+
{
|
|
878
|
+
message: `Title "${visual.title}" on "${page.displayName}" uses banned term "${hit}".`,
|
|
879
|
+
recommendation: "Spell out the term or use plain language.",
|
|
880
|
+
page: page.displayName,
|
|
881
|
+
visual: visual.name
|
|
882
|
+
}
|
|
883
|
+
];
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
var CONS005_SlicerPlacement = {
|
|
887
|
+
id: "CONS005",
|
|
888
|
+
title: "Slicers are placed inconsistently across pages",
|
|
889
|
+
category: "Consistency",
|
|
890
|
+
scope: "report",
|
|
891
|
+
defaultSeverity: "Optimization",
|
|
892
|
+
rationale: "Microsoft: keep slicers in the same spatial position on every page. Jumping slicer positions disorient users and keyboard navigation.",
|
|
893
|
+
evaluate(model, ctx) {
|
|
894
|
+
const tol = num(ctx, "tolerancePx", 10);
|
|
895
|
+
const origins = [];
|
|
896
|
+
for (const p of model.pages) {
|
|
897
|
+
if (isUtilityPage(p)) continue;
|
|
898
|
+
const slicers = p.visuals.filter((v) => SLICER_TYPES.has(v.type) && !v.hidden);
|
|
899
|
+
if (slicers.length === 0) continue;
|
|
900
|
+
const ox = Math.round(Math.min(...slicers.map((s) => s.x)) / tol) * tol;
|
|
901
|
+
const oy = Math.round(Math.min(...slicers.map((s) => s.y)) / tol) * tol;
|
|
902
|
+
origins.push({ page: p.displayName, key: `${ox},${oy}` });
|
|
903
|
+
}
|
|
904
|
+
if (origins.length < 2) return [];
|
|
905
|
+
const counts = /* @__PURE__ */ new Map();
|
|
906
|
+
for (const o of origins) counts.set(o.key, (counts.get(o.key) ?? 0) + 1);
|
|
907
|
+
const modal = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
908
|
+
const deviating = origins.filter((o) => o.key !== modal);
|
|
909
|
+
if (deviating.length === 0) return [];
|
|
910
|
+
return deviating.map((o) => ({
|
|
911
|
+
message: `Slicers on "${o.page}" sit at a different position (${o.key}) than the rest of the report (${modal}).`,
|
|
912
|
+
recommendation: "Align the slicer block to the same location used on other pages.",
|
|
913
|
+
page: o.page
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
var BUILTIN_RULES = [
|
|
918
|
+
// Performance
|
|
919
|
+
PERF001_VisualsPerPage,
|
|
920
|
+
PERF002_VisualsPerReport,
|
|
921
|
+
PERF003_SlicersPerPage,
|
|
922
|
+
PERF004_VisualTooManyFields,
|
|
923
|
+
PERF005_PageQueryLoad,
|
|
924
|
+
PERF006_UnfilteredTable,
|
|
925
|
+
PERF009_PageCount,
|
|
926
|
+
// Data integrity
|
|
927
|
+
DATA001_EmptyVisual,
|
|
928
|
+
// Governance
|
|
929
|
+
GOV001_UncertifiedCustomVisual,
|
|
930
|
+
GOV002_BlockedVisualType,
|
|
931
|
+
GOV003_DefaultPageName,
|
|
932
|
+
GOV005_RestrictedEntity,
|
|
933
|
+
GOV006_ExportDataEnabled,
|
|
934
|
+
GOV008_ThemeNotApproved,
|
|
935
|
+
// Consistency
|
|
936
|
+
CONS001_HiddenPageWithVisuals,
|
|
937
|
+
CONS002_EmptyPage,
|
|
938
|
+
CONS003_InconsistentPageSize,
|
|
939
|
+
CONS004_OverlappingVisuals,
|
|
940
|
+
CONS005_SlicerPlacement,
|
|
941
|
+
CONS010_OrphanBookmark,
|
|
942
|
+
// Accessibility
|
|
943
|
+
A11Y001_MissingAltText,
|
|
944
|
+
A11Y002_MissingTabOrder,
|
|
945
|
+
A11Y003_DecorativeInTabOrder,
|
|
946
|
+
A11Y005_JargonInTitle
|
|
947
|
+
];
|
|
948
|
+
|
|
949
|
+
// pbip/src/engine.ts
|
|
950
|
+
function nodesForScope(scope, model) {
|
|
951
|
+
switch (scope) {
|
|
952
|
+
case "report":
|
|
953
|
+
return [model];
|
|
954
|
+
case "page":
|
|
955
|
+
return model.pages;
|
|
956
|
+
case "visual": {
|
|
957
|
+
const out = [];
|
|
958
|
+
for (const page of model.pages) {
|
|
959
|
+
for (const visual of page.visuals) out.push({ page, visual });
|
|
960
|
+
}
|
|
961
|
+
return out;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
function resolveOverride(cfg2, rule) {
|
|
966
|
+
const raw = cfg2.rules[rule.id];
|
|
967
|
+
if (raw === "off") return { enabled: false, severity: rule.defaultSeverity, bag: {} };
|
|
968
|
+
if (raw && typeof raw === "object") {
|
|
969
|
+
return {
|
|
970
|
+
enabled: raw.enabled ?? true,
|
|
971
|
+
severity: normalizeSeverity(raw.severity, rule.defaultSeverity),
|
|
972
|
+
bag: raw
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
return {
|
|
976
|
+
enabled: rule.defaultEnabled !== false,
|
|
977
|
+
severity: rule.defaultSeverity,
|
|
978
|
+
bag: {}
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
function runReportAudit(model, rawConfig2 = {}) {
|
|
982
|
+
const cfg2 = normalizeConfig(rawConfig2);
|
|
983
|
+
const rules = [...BUILTIN_RULES, ...compileCustomRules(cfg2.customRules)];
|
|
984
|
+
const findings = [];
|
|
985
|
+
for (const rule of rules) {
|
|
986
|
+
const { enabled, severity, bag } = resolveOverride(cfg2, rule);
|
|
987
|
+
if (!enabled) continue;
|
|
988
|
+
const ctx = { model, override: bag };
|
|
989
|
+
for (const node of nodesForScope(rule.scope, model)) {
|
|
990
|
+
let hits;
|
|
991
|
+
try {
|
|
992
|
+
hits = rule.evaluate(node, ctx);
|
|
993
|
+
} catch (err) {
|
|
994
|
+
console.error(`Rule "${rule.id}" threw and was skipped: ${err.message}`);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
for (const hit of hits) {
|
|
998
|
+
findings.push({
|
|
999
|
+
...hit,
|
|
1000
|
+
ruleId: rule.id,
|
|
1001
|
+
ruleTitle: rule.title,
|
|
1002
|
+
category: rule.category,
|
|
1003
|
+
severity,
|
|
1004
|
+
custom: rule.custom === true
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const blocking = findings.filter((f) => meetsThreshold(f.severity, cfg2.failOn));
|
|
1010
|
+
const countsBySeverity = {
|
|
1011
|
+
Critical: findings.filter((f) => f.severity === "Critical").length,
|
|
1012
|
+
Warning: findings.filter((f) => f.severity === "Warning").length,
|
|
1013
|
+
Optimization: findings.filter((f) => f.severity === "Optimization").length
|
|
1014
|
+
};
|
|
1015
|
+
return { model, findings, blocking, countsBySeverity, passed: blocking.length === 0 };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// pbip/src/main.ts
|
|
1019
|
+
var args = process.argv.slice(2);
|
|
1020
|
+
var flag = (name, def = "") => {
|
|
1021
|
+
const hit = args.find((a) => a.startsWith(`--${name}=`));
|
|
1022
|
+
return hit ? hit.slice(name.length + 3) : def;
|
|
1023
|
+
};
|
|
1024
|
+
var sub = args[0];
|
|
1025
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
1026
|
+
console.log(`
|
|
1027
|
+
pbip-killer \u2014 audit Power BI PBIP/PBIR reports
|
|
1028
|
+
|
|
1029
|
+
Usage:
|
|
1030
|
+
pbip-killer audit <path> [options]
|
|
1031
|
+
|
|
1032
|
+
Options:
|
|
1033
|
+
--config=<file> Config file (default: pbipkiller.config.json if present).
|
|
1034
|
+
--fail-on=<lvl> Override the gate: critical | warning | optimization | none.
|
|
1035
|
+
--format=<fmt> human (default) | json | md.
|
|
1036
|
+
--help Show this help.
|
|
1037
|
+
`);
|
|
1038
|
+
process.exit(0);
|
|
1039
|
+
}
|
|
1040
|
+
if (sub !== "audit") {
|
|
1041
|
+
console.error(`Unknown command: ${sub}. Run with --help.`);
|
|
1042
|
+
process.exit(2);
|
|
1043
|
+
}
|
|
1044
|
+
var target = args.slice(1).find((a) => !a.startsWith("--"));
|
|
1045
|
+
if (!target) {
|
|
1046
|
+
console.error("No path specified. Run with --help.");
|
|
1047
|
+
process.exit(2);
|
|
1048
|
+
}
|
|
1049
|
+
var configPath = flag("config") || "pbipkiller.config.json";
|
|
1050
|
+
var rawConfig = {};
|
|
1051
|
+
if (existsSync2(resolve(configPath))) {
|
|
1052
|
+
try {
|
|
1053
|
+
rawConfig = JSON.parse(readFileSync2(resolve(configPath), "utf8"));
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
console.error(`Failed to read config ${configPath}: ${err.message}`);
|
|
1056
|
+
process.exit(2);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
var cfg = normalizeConfig(rawConfig);
|
|
1060
|
+
var failOnOverride = flag("fail-on");
|
|
1061
|
+
if (failOnOverride) cfg.failOn = failOnOverride;
|
|
1062
|
+
var format = flag("format", "human");
|
|
1063
|
+
var audit;
|
|
1064
|
+
try {
|
|
1065
|
+
const model = parseReport(resolve(target));
|
|
1066
|
+
audit = runReportAudit(model, cfg);
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
console.error(err.message);
|
|
1069
|
+
process.exit(2);
|
|
1070
|
+
}
|
|
1071
|
+
if (format === "json") {
|
|
1072
|
+
console.log(JSON.stringify(audit, null, 2));
|
|
1073
|
+
} else if (format === "md") {
|
|
1074
|
+
const icon = audit.passed ? "\u2705" : "\u274C";
|
|
1075
|
+
console.log(`## ${icon} ${audit.model.name}
|
|
1076
|
+
`);
|
|
1077
|
+
console.log(`| Pages | Visuals | Findings | Blocking |`);
|
|
1078
|
+
console.log(`|-------|---------|----------|----------|`);
|
|
1079
|
+
console.log(`| ${audit.model.pageCount} | ${audit.model.visualCount} | ${audit.findings.length} | ${audit.blocking.length} |`);
|
|
1080
|
+
if (audit.findings.length) {
|
|
1081
|
+
console.log(`
|
|
1082
|
+
| Rule | Severity | Where | Message |`);
|
|
1083
|
+
console.log(`|------|----------|-------|---------|`);
|
|
1084
|
+
for (const f of audit.findings) {
|
|
1085
|
+
const where = [f.page, f.visual].filter(Boolean).join(" \u203A ") || "-";
|
|
1086
|
+
console.log(`| ${f.ruleId}${f.custom ? " (custom)" : ""} | ${f.severity} | ${where} | ${f.message.replace(/\|/g, "\\|")} |`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
} else {
|
|
1090
|
+
console.log(`
|
|
1091
|
+
${audit.model.name} \u2014 ${audit.model.pageCount} pages, ${audit.model.visualCount} visuals`);
|
|
1092
|
+
console.log(` ${"\u2500".repeat(56)}`);
|
|
1093
|
+
for (const f of audit.findings) {
|
|
1094
|
+
const where = [f.page, f.visual].filter(Boolean).join(" \u203A ");
|
|
1095
|
+
console.log(` [${f.severity[0]}] ${f.ruleId}${f.custom ? "*" : ""} ${f.message}${where ? ` (${where})` : ""}`);
|
|
1096
|
+
}
|
|
1097
|
+
const verdict = audit.passed ? "PASS" : "FAIL";
|
|
1098
|
+
console.log(` ${"\u2500".repeat(56)}`);
|
|
1099
|
+
console.log(` ${verdict} \u2014 ${audit.findings.length} findings, ${audit.blocking.length} blocking (fail-on=${cfg.failOn})
|
|
1100
|
+
`);
|
|
1101
|
+
}
|
|
1102
|
+
process.exit(audit.passed ? 0 : 1);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pbip-killer",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Audit Power BI PBIP / PBIR reports from the command line or CI/CD, with fully customizable rules.",
|
|
5
|
+
"author": "Jesús Veiga",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"pbip-killer": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": ["dist"],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"prepublishOnly": "cd .. && npm run build:pbip"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"power-bi",
|
|
20
|
+
"pbip",
|
|
21
|
+
"pbir",
|
|
22
|
+
"report",
|
|
23
|
+
"audit",
|
|
24
|
+
"governance",
|
|
25
|
+
"quality-gate",
|
|
26
|
+
"ci-cd",
|
|
27
|
+
"fabric",
|
|
28
|
+
"accessibility"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/Jesusveiga/DataflowKiller.git",
|
|
33
|
+
"directory": "pbip"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://dataflowkiller.com"
|
|
36
|
+
}
|