diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d994f7..1d645c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ See [`changelog/README.md`](changelog/README.md) for the full index. ## Latest releases +- **[0.12.0](changelog/0.12.0.md)** — 2026-05-18 · AI cleanup prompt handoff — Unused-tab button + `
` block in `improvements.md` + `prompt` CLI subcommand generate a markdown prompt for Claude Code (or any AI agent) to delete flagged measures. Lineage stays read-only. - **[0.11.7](changelog/0.11.7.md)** — 2026-04-28 · Default tab is now Sources (was Measures); fix Measures + Columns flipping to ascending sort on report reload - **[0.11.6](changelog/0.11.6.md)** — 2026-04-26 · Cross-doc link integrity — fix `[Sources.md](#)` regression in Model.md §3.3 + Index.md glossary entries now link to their companion docs (Measures, DataDictionary, Functions, CalcGroups) - **[0.11.5](changelog/0.11.5.md)** — 2026-04-26 · Improvements audit names the entities — tables / measures / columns missing descriptions now list the actual offenders inline diff --git a/WHATS-NEW.md b/WHATS-NEW.md index d9daceb..426bc9b 100644 --- a/WHATS-NEW.md +++ b/WHATS-NEW.md @@ -18,8 +18,9 @@ Drop in a PBIP folder — get a **searchable dashboard** plus **nine Markdown do - **Lineage** — search any measure or column from the tab itself, or click any entity in Measures / Columns / Pages / Tables. Shows upstream dependencies + source tables + downstream visuals in one view. ### Analysis -- **Unused** — orphan measures, dead-chain measures, indirect-use detection -- **Improvements** — 16-check model-health audit, severity-tiered (high · medium · low · info · strengths) — includes **broken-reference detection**: flags any DAX referencing a table / column / measure that doesn't exist + +- **Unused** — orphan measures, dead-chain measures, indirect-use detection. When measures are flagged, a top toolbar generates an **AI cleanup prompt** you can paste into Claude Code (with the [`pbi-desktop`](https://github.com/data-goblin/power-bi-agentic-development) plugin) or any AI agent that can drive TOM / Tabular Editor — PowerBI-Lineage never deletes anything itself; it hands off a ready-to-run markdown prompt with Stage 1 / Stage 2 ordering + EXTERNALMEASURE safety guards baked in. +- **Improvements** — 16-check model-health audit, severity-tiered (high · medium · low · info · strengths) — includes **broken-reference detection**: flags any DAX referencing a table / column / measure that doesn't exist. The "unused measures" and "dead-chain" findings each carry a collapsible `
` block with the matching AI cleanup prompt, so wiki readers get it too. ### Output - **Documentation tab** — Markdown ready to paste into ADO Wiki or GitHub. Up to nine files — *Model · Data Dictionary · Sources · Measures · Functions · Calc Groups · Pages · Improvements · Index*. Empty docs (e.g. no UDFs) skip automatically. @@ -28,7 +29,7 @@ Drop in a PBIP folder — get a **searchable dashboard** plus **nine Markdown do ## Under the hood - Runs **entirely in your browser** (File System Access API — nothing uploads) *or* as a **local CLI** -- **Zero runtime dependencies**, MIT-licensed, 276 tests +- **Zero runtime dependencies**, MIT-licensed, 299 tests - Three themes: dark · light · BluPulse — pick from the bottom of this overlay ## Running locally (CLI mode) diff --git a/changelog/0.12.0.md b/changelog/0.12.0.md new file mode 100644 index 0000000..60911ae --- /dev/null +++ b/changelog/0.12.0.md @@ -0,0 +1,24 @@ +## [0.12.0] — 2026-05-18 · AI cleanup prompt handoff + +Closes the gap between *"Lineage tells you what's dead"* and *"Lineage helps you delete it"* — without breaking the read-only / zero-dep promise. PowerBI-Lineage never mutates the model itself; it now generates a markdown prompt the user pastes into Claude Code (with Kurt Buhler's [`pbi-desktop`](https://github.com/data-goblin/power-bi-agentic-development) plugin) or any AI agent that can drive TOM / Tabular Editor CLI. + +### Added +- **Unused tab toolbar** — when the audit flags any unused or dead-chain measures, a top-of-tab bar appears with a single smart **"Generate AI cleanup prompt"** button. Auto-picks the right category (`unused-measures`, `dead-chain-measures`, or combined `measures-all`) from what's flagged. Counts on the button match what ends up in the prompt — EXTERNALMEASURE proxies excluded, auto-date tables excluded. +- **Preview modal** — clicking the button opens a modal with the full prompt body, **Copy to clipboard** + **Download .md** actions. Closes on Escape, backdrop click, or the × button. Clipboard fallback selects-all if `navigator.clipboard.writeText` is blocked (HTTP origins / old Safari). +- **`improvements.md` embed** — the "unused measures" and "dead-chain" findings in the generated audit doc now carry a collapsible `
AI cleanup prompt (copy + paste…)
` block. Survives the ADO Wiki + GitHub MD render path, so wiki readers can grab the prompt without opening the dashboard. +- **CLI subcommand** — `powerbi-lineage prompt --category --report [--output ]` emits the same prompt to stdout (or a file) for CI / batch flows. `--help` prints usage; unknown flags fail loudly so a typo doesn't silently produce the wrong prompt. + +### Prompt anatomy +- **Six safety constraints** baked into every prompt: confirm-before-each-deletion, Stage-1-before-Stage-2 ordering, EXTERNALMEASURE guard (read DAX from the live model and skip proxies), `_`-prefix helper-measure flag, single `SaveChanges()` at end, "make a git commit before starting" pre-flight (Power BI's Ctrl+Z does NOT undo TOM changes). +- **No DAX bodies embedded** — the AI agent is already connected to the live model via `pbi-desktop`, so it can read DAX itself before deleting. Embedding it bloated the prompt for zero added safety; the v0.12.0 prompt just lists the measures as bullets. +- **Stage 1 / Stage 2 grouping** — "directly unused" first, "dead-chain" second, with explicit "re-audit after Stage 1" warning. Deleting the top of a chain reclassifies items below it. + +### Changed +- **Inline-script export-stripper hardened** — `src/html-generator.ts` now strips `export function|const|let|var|class` and named `export { a, b }` blocks in addition to the trailing empty `export {};`. Previously a TS module with inline `export` declarations would silently kill the entire dashboard ` + `; } diff --git a/src/improvements.ts b/src/improvements.ts index d437bff..e909cb4 100644 --- a/src/improvements.ts +++ b/src/improvements.ts @@ -21,6 +21,7 @@ import type { FullData, ModelMeasure } from "./data-builder.js"; import type { ModelRelationship } from "./model-parser.js"; +import { buildCleanupPrompt, type CleanupCategory } from "./ai-prompts.js"; // ───────────────────────────────────────────────────────────────────── // Types @@ -28,6 +29,11 @@ import type { ModelRelationship } from "./model-parser.js"; export type ImprovementSeverity = "high" | "medium" | "low" | "info" | "good"; +/** Stable discriminator linking a check to downstream consumers (e.g. + * the AI cleanup prompt builder). Optional — most checks don't need + * one. Only set on checks whose findings have an automated follow-up. */ +export type ImprovementKind = "unused-measures" | "dead-chain-measures"; + export interface Improvement { severity: ImprovementSeverity; title: string; @@ -41,6 +47,8 @@ export interface Improvement { maxListed?: number; /** Optional cross-reference hint pointing at another doc / tab. */ crossRef?: string; + /** Stable identifier for downstream consumers. See ImprovementKind. */ + kind?: ImprovementKind; } // ───────────────────────────────────────────────────────────────────── @@ -436,6 +444,7 @@ export function runImprovementChecks(data: FullData): Improvement[] { items: unusedMeasures.map(m => m.table + "[" + m.name + "]"), maxListed: 15, crossRef: "The dashboard's **Unused** tab shows the same list with full per-entity lineage context.", + kind: "unused-measures", }); } const deadChainRaw = deadChainMeasures(data); @@ -450,6 +459,7 @@ export function runImprovementChecks(data: FullData): Improvement[] { summary: "These are used by other measures, but those measures are never on a visual themselves.", rationale: "The chain terminates in nothing — these measures are effectively dead. Remove the top of the chain (the directly-unused measures) and these become unused in turn.", items: deadChain.slice(0, 20), + kind: "dead-chain-measures", }); } const unusedColumns = userColumns.filter(c => c.status === "unused" && !c.isKey); @@ -700,7 +710,28 @@ const SEVERITY_META: Record = { + "unused-measures": "unused-measures", + "dead-chain-measures": "dead-chain-measures", +}; + +function renderCleanupPromptBlock(data: FullData, kind: ImprovementKind): string[] { + const prompt = buildCleanupPrompt(data, KIND_TO_CATEGORY[kind]); + return [ + "
", + "AI cleanup prompt (copy + paste into Claude Code or another AI agent)", + "", + prompt.replace(/\n+$/, ""), + "", + "
", + "", + ]; +} + +function renderImprovementItem(it: Improvement, data: FullData): string[] { const lines: string[] = []; lines.push(`### ${it.title}`); lines.push(""); @@ -723,6 +754,9 @@ function renderImprovementItem(it: Improvement): string[] { lines.push(`> ${it.crossRef}`); lines.push(""); } + if (it.kind) { + for (const ln of renderCleanupPromptBlock(data, it.kind)) lines.push(ln); + } return lines; } @@ -788,7 +822,7 @@ export function generateImprovementsMd(data: FullData, reportName: string, _mode lines.push(`## ${SEVERITY_META[sev].icon} ${SEVERITY_META[sev].label} (${group.length})`); lines.push(""); for (const it of group) { - for (const ln of renderImprovementItem(it)) lines.push(ln); + for (const ln of renderImprovementItem(it, data)) lines.push(ln); } lines.push("---"); lines.push(""); diff --git a/src/styles/dashboard.css b/src/styles/dashboard.css index 71100b4..c37d5ab 100644 --- a/src/styles/dashboard.css +++ b/src/styles/dashboard.css @@ -892,3 +892,116 @@ /* BluPulse aurora overlay is a body::after pseudo — kill it in print. */ body::after, body::before { display: none !important; } } + + /* ───────────────────────────────────────────────────────────────── + AI cleanup prompt — Unused-tab toolbar + modal. Toolbar sits + above the orphan sections; clicking the button opens the modal. + Themed via existing surface/text/border tokens for dark, light, + and BluPulse. + ───────────────────────────────────────────────────────────────── */ + .cleanup-bar { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; flex-wrap: wrap; + padding: 12px 14px; margin-bottom: 16px; + background: var(--surface-alt, rgba(255,255,255,0.04)); + border: 1px solid var(--border-soft); + border-left: 3px solid var(--clr-measure, #F59E0B); + border-radius: var(--radius-md, 6px); + } + .cleanup-bar-msg { + font-size: 13px; opacity: 0.9; flex: 1 1 280px; + } + .cleanup-bar-btn { + appearance: none; cursor: pointer; + background: var(--clr-measure, #F59E0B); color: #0B0D11; + border: 0; border-radius: 4px; + padding: 8px 14px; font-size: 13px; font-weight: 600; + white-space: nowrap; + } + .cleanup-bar-btn:hover { filter: brightness(1.05); } + .cleanup-bar-btn:focus-visible { outline: 2px solid var(--clr-measure, #F59E0B); outline-offset: 2px; } + + + .cleanup-modal[hidden] { display: none; } + .cleanup-modal { + position: fixed; inset: 0; z-index: 200; + display: flex; align-items: center; justify-content: center; + padding: 24px; + } + .cleanup-modal-backdrop { + position: absolute; inset: 0; + background: rgba(0,0,0,0.55); + backdrop-filter: blur(2px); + } + .cleanup-modal-panel { + position: relative; + width: min(880px, 100%); max-height: 88vh; + display: flex; flex-direction: column; + background: var(--surface); + color: var(--text-body); + border: 1px solid var(--border-soft); + border-radius: var(--radius-md, 8px); + box-shadow: 0 12px 40px rgba(0,0,0,0.45); + overflow: hidden; + } + .cleanup-modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border-soft); + } + .cleanup-modal-header h2 { + margin: 0; font-size: 16px; font-weight: 600; + } + .cleanup-modal-close { + appearance: none; background: transparent; border: 0; color: var(--text-body); + font-size: 22px; line-height: 1; cursor: pointer; + padding: 4px 8px; border-radius: 4px; + } + .cleanup-modal-close:hover { background: rgba(255,255,255,0.08); } + .cleanup-modal-body { + padding: 14px 18px; + overflow-y: auto; + flex: 1 1 auto; + } + .cleanup-modal-hint { + margin: 0 0 12px 0; font-size: 13px; opacity: 0.85; + } + .cleanup-modal-hint code { + padding: 1px 6px; border-radius: 3px; + background: rgba(255,255,255,0.08); font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 12px; + } + .cleanup-modal-body pre { + margin: 0; padding: 12px; + background: var(--surface-alt, rgba(255,255,255,0.04)); + border: 1px solid var(--border-soft); + border-radius: 4px; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 12px; line-height: 1.5; + white-space: pre-wrap; word-break: break-word; + max-height: 56vh; overflow: auto; + user-select: text; + } + .cleanup-modal-footer { + display: flex; align-items: center; gap: 8px; + padding: 12px 18px; + border-top: 1px solid var(--border-soft); + } + .cleanup-modal-btn { + appearance: none; cursor: pointer; + background: transparent; color: var(--text-body); + border: 1px solid var(--border-soft); + border-radius: 4px; + padding: 7px 14px; font-size: 13px; font-weight: 500; + } + .cleanup-modal-btn:hover { background: rgba(255,255,255,0.08); } + .cleanup-modal-btn-primary { + background: var(--clr-measure, #F59E0B); + color: #0B0D11; border-color: transparent; + } + .cleanup-modal-btn-primary:hover { filter: brightness(1.05); } + .cleanup-modal-status { + margin-left: auto; font-size: 12px; opacity: 0.8; min-height: 1.2em; + } + .cleanup-modal-status.is-ok { color: var(--clr-success, #22C55E); opacity: 1; } + .cleanup-modal-status.is-error { color: var(--clr-unused, #EF4444); opacity: 1; } diff --git a/tests/ai-prompts.test.ts b/tests/ai-prompts.test.ts new file mode 100644 index 0000000..a86b747 --- /dev/null +++ b/tests/ai-prompts.test.ts @@ -0,0 +1,232 @@ +/** + * AI cleanup prompt builder — unit tests. + * + * Covers the three categories, EXTERNALMEASURE exclusion, auto-date + * exclusion, stage ordering, and a few edge cases (empty stages, + * special characters in measure names). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildCleanupPrompt } from "../src/ai-prompts.js"; +import type { FullData, ModelMeasure } from "../src/data-builder.js"; + +// ───────────────────────────────────────────────────────────────────── +// Factories — mirror tests/improvements.test.ts so the two test +// files stay readable side-by-side. +// ───────────────────────────────────────────────────────────────────── + +function mk(over: Partial = {}): FullData { + return { + measures: [], columns: [], relationships: [], functions: [], + calcGroups: [], tables: [], pages: [], hiddenPages: [], + allPages: [], expressions: [], compatibilityLevel: null, + modelProperties: { + name: "m", description: "Test model.", culture: "", sourceQueryCulture: "", + discourageImplicitMeasures: false, valueFilterBehavior: "", + cultures: [], defaultPowerBIDataSourceVersion: "", + }, + totals: { + measuresInModel: 0, measuresDirect: 0, measuresIndirect: 0, measuresUnused: 0, + columnsInModel: 0, columnsDirect: 0, columnsIndirect: 0, columnsUnused: 0, + relationships: 0, functions: 0, calcGroups: 0, tables: 0, pages: 0, visuals: 0, + }, + ...over, + } as FullData; +} + +function mkMeasure(over: Partial = {}): ModelMeasure { + return { + name: "M", table: "T", daxExpression: "1", formatString: "", description: "", + displayFolder: "", daxDependencies: [], dependedOnBy: [], usedIn: [], + usageCount: 0, pageCount: 0, status: "unused", externalProxy: null, + ...over, + }; +} + +const FIXED_DATE = new Date("2026-05-17T00:00:00Z"); + +// ───────────────────────────────────────────────────────────────────── +// Header / boilerplate invariants — apply to all three categories +// ───────────────────────────────────────────────────────────────────── + +test("buildCleanupPrompt — emits ISO date stamp from injected `now`", () => { + const md = buildCleanupPrompt(mk(), "measures-all", { now: FIXED_DATE }); + assert.ok(md.includes("2026-05-17"), "expected ISO-8601 date in prompt body"); + assert.ok(!md.includes("2026-05-17T"), "date should be sliced to YYYY-MM-DD"); +}); + +test("buildCleanupPrompt — every category includes the six safety constraints", () => { + for (const cat of ["unused-measures", "dead-chain-measures", "measures-all"] as const) { + const md = buildCleanupPrompt(mk(), cat, { now: FIXED_DATE }); + assert.ok(md.includes("Safety constraints"), `${cat}: missing safety section`); + assert.ok(md.includes("Confirm with me before each deletion"), `${cat}: missing rule 1`); + assert.ok(md.includes("Stage 1 (directly unused)"), `${cat}: missing rule 2 (Stage 1 first)`); + assert.ok(md.includes("EXTERNALMEASURE("), `${cat}: missing rule 3 (EXTERNALMEASURE guard)`); + assert.ok(md.includes("`_`"), `${cat}: missing rule 4 (helper-measure flag)`); + assert.ok(md.includes("$model.SaveChanges()"), `${cat}: missing rule 5 (save once)`); + assert.ok(md.includes("Ctrl+Z does NOT"), `${cat}: missing rule 6 (no-undo warning)`); + } +}); + +test("buildCleanupPrompt — every category points at pbi-desktop as recommended tool", () => { + for (const cat of ["unused-measures", "dead-chain-measures", "measures-all"] as const) { + const md = buildCleanupPrompt(mk(), cat, { now: FIXED_DATE }); + assert.ok(md.includes("`pbi-desktop`"), `${cat}: missing pbi-desktop recommendation`); + assert.ok(md.includes("Tabular Editor CLI"), `${cat}: missing Tabular Editor fallback`); + assert.ok(md.includes("TOM via PowerShell"), `${cat}: missing TOM fallback`); + } +}); + +test("buildCleanupPrompt — verification section tells user to re-open project", () => { + const md = buildCleanupPrompt(mk(), "measures-all", { now: FIXED_DATE }); + assert.ok(md.includes("## Verification")); + assert.ok(md.includes("Re-open the `.pbip`")); + assert.ok(md.includes("git reset --hard")); +}); + +// ───────────────────────────────────────────────────────────────────── +// Stage 1 — directly unused measures +// ───────────────────────────────────────────────────────────────────── + +test("buildCleanupPrompt — Stage 1 lists `status: 'unused'` measures as bullet entries", () => { + const data = mk({ + measures: [ + mkMeasure({ table: "Sales", name: "Old Total", daxExpression: "SUM(Sales[Amount])", status: "unused" }), + mkMeasure({ table: "Sales", name: "Live", daxExpression: "SUM(Sales[Amount])", status: "direct" }), + ], + }); + const md = buildCleanupPrompt(data, "unused-measures", { now: FIXED_DATE }); + assert.ok(md.includes("- `Sales[Old Total]`"), "missing Stage 1 bullet for Old Total"); + assert.ok(!md.includes("- `Sales[Live]`"), "Live measure (status:direct) should not appear in Stage 1"); + assert.ok(md.includes("Stage 1 — directly unused (1 measure)"), "expected singular count"); + assert.ok(!md.includes("SUM(Sales[Amount])"), "DAX body should NOT be embedded — AI reads it from the live model"); + assert.ok(!md.includes("```dax"), "no dax-fenced blocks in the prompt body"); +}); + +test("buildCleanupPrompt — Stage 1 with zero candidates emits the placeholder", () => { + const md = buildCleanupPrompt(mk(), "unused-measures", { now: FIXED_DATE }); + assert.ok(md.includes("Stage 1 — directly unused (0 measures)")); + assert.ok(md.includes("_(none flagged)_")); +}); + +// ───────────────────────────────────────────────────────────────────── +// Stage 2 — dead-chain measures +// ───────────────────────────────────────────────────────────────────── + +test("buildCleanupPrompt — Stage 2 lists dead-chain measures (status: indirect, no live caller)", () => { + const data = mk({ + measures: [ + // Unused top-of-chain → Stage 1 + mkMeasure({ table: "T", name: "Dead", status: "unused", daxDependencies: ["Dead-Helper"] }), + // Only reachable from Dead → Stage 2 + mkMeasure({ table: "T", name: "Dead-Helper", status: "indirect" }), + // Direct, reachable → not in any stage + mkMeasure({ table: "T", name: "Live", status: "direct", daxDependencies: ["Live-Helper"] }), + mkMeasure({ table: "T", name: "Live-Helper", status: "indirect" }), + ], + }); + const md = buildCleanupPrompt(data, "dead-chain-measures", { now: FIXED_DATE }); + assert.ok(md.includes("- `T[Dead-Helper]`"), "Dead-Helper should be Stage 2"); + assert.ok(!md.includes("- `T[Live-Helper]`"), "Live-Helper is reachable from Live, should NOT be Stage 2"); + assert.ok(!md.includes("- `T[Dead]`"), "Dead is Stage 1, should NOT appear in a dead-chain-only prompt"); +}); + +test("buildCleanupPrompt — dead-chain-only category warns 'This is Stage 2 work' in the goal", () => { + const md = buildCleanupPrompt(mk(), "dead-chain-measures", { now: FIXED_DATE }); + assert.ok(md.includes("This is Stage 2 work"), "Stage-2-only prompt must warn about Stage 1 ordering up front"); +}); + +// ───────────────────────────────────────────────────────────────────── +// Combined — measures-all (ordering matters) +// ───────────────────────────────────────────────────────────────────── + +test("buildCleanupPrompt — measures-all emits Stage 1 strictly before Stage 2", () => { + const data = mk({ + measures: [ + mkMeasure({ table: "T", name: "S1", status: "unused", daxDependencies: ["S2"] }), + mkMeasure({ table: "T", name: "S2", status: "indirect" }), + ], + }); + const md = buildCleanupPrompt(data, "measures-all", { now: FIXED_DATE }); + const s1Idx = md.indexOf("### Stage 1"); + const s2Idx = md.indexOf("### Stage 2"); + assert.ok(s1Idx >= 0, "Stage 1 header must appear"); + assert.ok(s2Idx >= 0, "Stage 2 header must appear"); + assert.ok(s1Idx < s2Idx, `Stage 1 (@${s1Idx}) must come before Stage 2 (@${s2Idx})`); + // And the dead-chain bullet must be after Stage 2 header, not before. + const s2ItemIdx = md.indexOf("- `T[S2]`"); + assert.ok(s2ItemIdx > s2Idx, "S2 dead-chain bullet must render under the Stage 2 header"); +}); + +// ───────────────────────────────────────────────────────────────────── +// EXTERNALMEASURE exclusion — the load-bearing safety filter +// ───────────────────────────────────────────────────────────────────── + +test("buildCleanupPrompt — EXTERNALMEASURE proxies are NEVER in the kill list, even when status:unused", () => { + const data = mk({ + measures: [ + mkMeasure({ + table: "Remote", name: "Remote Revenue", + daxExpression: "EXTERNALMEASURE(\"Revenue\", DOUBLE, \"DirectQuery to AS - Sales\")", + status: "unused", + externalProxy: { + remoteName: "Revenue", type: "DOUBLE", + externalModel: "DirectQuery to AS - Sales", cluster: null, + }, + }), + mkMeasure({ table: "Sales", name: "Legit Unused", status: "unused", daxExpression: "0" }), + ], + }); + const md = buildCleanupPrompt(data, "measures-all", { now: FIXED_DATE }); + assert.ok(!md.includes("- `Remote[Remote Revenue]`"), + "EXTERNALMEASURE-bound measure must not appear in kill targets even when status:unused"); + assert.ok(md.includes("- `Sales[Legit Unused]`"), + "non-proxy unused measure should still be flagged"); +}); + +// ───────────────────────────────────────────────────────────────────── +// Auto-date exclusion — matches improvements.ts userMeasures filter +// ───────────────────────────────────────────────────────────────────── + +test("buildCleanupPrompt — measures on auto-date tables are excluded", () => { + const data = mk({ + tables: [ + { name: "LocalDateTable_x", description: "", isCalcGroup: false, origin: "auto-date" as const, + isCalculatedTable: false, parameterKind: null, columnCount: 0, measureCount: 0, keyCount: 0, + fkCount: 0, hiddenColumnCount: 0, columns: [], measures: [], relationships: [], + partitions: [], hierarchies: [] } as any, + { name: "Sales", description: "", isCalcGroup: false, origin: "user" as const, + isCalculatedTable: false, parameterKind: null, columnCount: 0, measureCount: 0, keyCount: 0, + fkCount: 0, hiddenColumnCount: 0, columns: [], measures: [], relationships: [], + partitions: [], hierarchies: [] } as any, + ], + measures: [ + mkMeasure({ table: "LocalDateTable_x", name: "Auto Year Total", status: "unused" }), + mkMeasure({ table: "Sales", name: "Old", status: "unused" }), + ], + }); + const md = buildCleanupPrompt(data, "unused-measures", { now: FIXED_DATE }); + assert.ok(!md.includes("- `LocalDateTable_x[Auto Year Total]`"), + "auto-date table measures must never appear in the kill list"); + assert.ok(md.includes("- `Sales[Old]`"), + "user-table measures with status:unused should still appear"); +}); + +// ───────────────────────────────────────────────────────────────────── +// Output shape — final byte-level sanity +// ───────────────────────────────────────────────────────────────────── + +test("buildCleanupPrompt — output ends with a single trailing newline", () => { + const md = buildCleanupPrompt(mk(), "unused-measures", { now: FIXED_DATE }); + assert.ok(md.endsWith("\n"), "prompt should end with newline (POSIX-friendly)"); + assert.ok(!md.endsWith("\n\n"), "prompt should not have double trailing newline"); +}); + +test("buildCleanupPrompt — title varies per category", () => { + const u = buildCleanupPrompt(mk(), "unused-measures", { now: FIXED_DATE }); + const d = buildCleanupPrompt(mk(), "dead-chain-measures", { now: FIXED_DATE }); + const a = buildCleanupPrompt(mk(), "measures-all", { now: FIXED_DATE }); + assert.ok(u.startsWith("# Cleanup task — delete unused Power BI measures\n")); + assert.ok(d.startsWith("# Cleanup task — delete dead-chain Power BI measures\n")); + assert.ok(a.startsWith("# Cleanup task — delete unused + dead-chain Power BI measures\n")); +}); diff --git a/tests/cli-prompt.test.ts b/tests/cli-prompt.test.ts new file mode 100644 index 0000000..e358f66 --- /dev/null +++ b/tests/cli-prompt.test.ts @@ -0,0 +1,78 @@ +/** + * CLI smoke test — `powerbi-lineage prompt …` subcommand. + * + * Spawned as a subprocess because runPromptSubcommand calls + * process.exit() — importing it directly would kill the test runner. + * The compiled app.js lives at dist-test/src/app.js under the test + * tsconfig output dir, same layout as the other server-side modules. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const APP_JS = path.resolve("dist-test/src/app.js"); +const FIXTURE = "test/Health_and_Safety.Report"; +const APP_EXISTS = fs.existsSync(APP_JS); +const FIXTURE_EXISTS = fs.existsSync(path.resolve(FIXTURE)); + +function runCli(args: string[]): { code: number | null; stdout: string; stderr: string } { + const r = spawnSync(process.execPath, [APP_JS, ...args], { + encoding: "utf8", + // Generous — the parser has to walk the H&S model. + timeout: 30_000, + }); + return { code: r.status, stdout: r.stdout || "", stderr: r.stderr || "" }; +} + +test("CLI prompt — missing --category exits non-zero with usage on stderr", { skip: !APP_EXISTS }, () => { + const r = runCli(["prompt", "--report", "anywhere"]); + assert.notEqual(r.code, 0, "expected non-zero exit for missing --category"); + assert.ok(r.stderr.includes("--category"), "stderr should mention --category"); +}); + +test("CLI prompt — unknown --category exits non-zero", { skip: !APP_EXISTS }, () => { + const r = runCli(["prompt", "--category", "nonsense", "--report", "anywhere"]); + assert.notEqual(r.code, 0, "expected non-zero exit for unknown category"); +}); + +test("CLI prompt — missing --report exits non-zero", { skip: !APP_EXISTS }, () => { + const r = runCli(["prompt", "--category", "unused-measures"]); + assert.notEqual(r.code, 0, "expected non-zero exit for missing --report"); + assert.ok(r.stderr.includes("--report"), "stderr should mention --report"); +}); + +test("CLI prompt — --help prints usage and exits 0", { skip: !APP_EXISTS }, () => { + const r = runCli(["prompt", "--help"]); + assert.equal(r.code, 0, "expected exit 0 on --help"); + assert.ok(r.stderr.includes("--category") || r.stdout.includes("--category"), + "usage output should describe --category"); +}); + +test("CLI prompt — emits prompt to stdout against H&S fixture", { skip: !APP_EXISTS || !FIXTURE_EXISTS }, () => { + const r = runCli(["prompt", "--category", "measures-all", "--report", path.resolve(FIXTURE)]); + assert.equal(r.code, 0, `expected exit 0, got ${r.code}. stderr: ${r.stderr}`); + assert.ok(r.stdout.startsWith("# Cleanup task"), + "expected prompt to start with the Cleanup task H1"); + assert.ok(r.stdout.includes("## Safety constraints"), + "expected safety constraints section"); + assert.ok(r.stdout.includes("## Targets"), + "expected Targets section"); +}); + +test("CLI prompt — --output writes to file instead of stdout", { skip: !APP_EXISTS || !FIXTURE_EXISTS }, () => { + const outFile = path.resolve("dist-test/cli-prompt-output.tmp.md"); + // Best-effort cleanup if a prior run crashed mid-flight. + try { fs.unlinkSync(outFile); } catch {} + const r = runCli(["prompt", "--category", "unused-measures", "--report", path.resolve(FIXTURE), "--output", outFile]); + try { + assert.equal(r.code, 0, `expected exit 0, got ${r.code}. stderr: ${r.stderr}`); + assert.equal(r.stdout, "", "stdout should be empty when --output is set"); + assert.ok(fs.existsSync(outFile), "output file should exist after run"); + const body = fs.readFileSync(outFile, "utf8"); + assert.ok(body.includes("# Cleanup task"), "file should contain the prompt header"); + } finally { + try { fs.unlinkSync(outFile); } catch {} + } +}); diff --git a/tests/improvements.test.ts b/tests/improvements.test.ts index 6bec9f5..a751ca9 100644 --- a/tests/improvements.test.ts +++ b/tests/improvements.test.ts @@ -208,6 +208,69 @@ test("generateImprovementsMd — auto-date entry appears when auto-date tables e assert.ok(md.includes("Auto-Date/Time is enabled")); }); +// ───────────────────────────────────────────────────────────────────── +// AI cleanup prompt — embedded
block per finding +// ───────────────────────────────────────────────────────────────────── + +test("generateImprovementsMd — unused-measures finding embeds AI cleanup
block", () => { + const data = mk({ + measures: [ + mkMeasure({ table: "Sales", name: "Old Total", status: "unused", daxExpression: "SUM(Sales[X])" }), + ], + }); + const md = generateImprovementsMd(data, "t"); + assert.ok(md.includes("### 1 unused measure"), "expected unused-measures finding to render"); + assert.ok(md.includes("
"), "expected
wrapper to render"); + assert.ok(md.includes("AI cleanup prompt (copy + paste"), "expected summary text"); + assert.ok(md.includes("# Cleanup task — delete unused Power BI measures"), + "expected the prompt's H1 title to be embedded"); + assert.ok(md.includes("Sales[Old Total]"), "expected the kill target in the embedded prompt"); +}); + +test("generateImprovementsMd — dead-chain finding embeds its own prompt block (Stage-2-only)", () => { + const data = mk({ + measures: [ + mkMeasure({ name: "Top", status: "unused", daxDependencies: ["Helper"] }), + mkMeasure({ name: "Helper", status: "indirect" }), + ], + }); + const md = generateImprovementsMd(data, "t"); + assert.ok(md.includes("only reachable through unused chains"), "expected dead-chain finding"); + // Two findings (unused + dead-chain) → two
blocks + const detailsCount = (md.match(/
/g) || []).length; + assert.equal(detailsCount, 2, "expected one
per finding (unused + dead-chain)"); + assert.ok(md.includes("# Cleanup task — delete dead-chain Power BI measures"), + "expected dead-chain-specific prompt header"); +}); + +test("generateImprovementsMd — no
block when no unused / dead-chain findings", () => { + const md = generateImprovementsMd(mk(), "t"); + assert.ok(!md.includes("
"), "empty model should not embed any cleanup prompt"); +}); + +test("generateImprovementsMd — EXTERNALMEASURE proxy never appears in the embedded prompt body", () => { + const data = mk({ + measures: [ + mkMeasure({ + table: "Remote", name: "Remote Revenue", status: "unused", + daxExpression: "EXTERNALMEASURE(\"R\", DOUBLE, \"DirectQuery to AS - X\")", + externalProxy: { remoteName: "R", type: "DOUBLE", externalModel: "DirectQuery to AS - X", cluster: null }, + }), + mkMeasure({ table: "Sales", name: "Legit", status: "unused", daxExpression: "0" }), + ], + }); + const md = generateImprovementsMd(data, "t"); + // Find just the
block content + const start = md.indexOf("
"); + const end = md.indexOf("
"); + assert.ok(start >= 0 && end > start, "expected a
block to be present"); + const block = md.slice(start, end); + assert.ok(!block.includes("Remote[Remote Revenue]"), + "EXTERNALMEASURE-bound measure must not appear in the embedded kill list"); + assert.ok(block.includes("Sales[Legit]"), + "non-proxy unused measure should be in the embedded kill list"); +}); + // ───────────────────────────────────────────────────────────────────── // Fixture integration — H&S // ───────────────────────────────────────────────────────────────────── diff --git a/tsconfig.browser.json b/tsconfig.browser.json index 2b930cc..68952d0 100644 --- a/tsconfig.browser.json +++ b/tsconfig.browser.json @@ -26,6 +26,7 @@ "src/data-builder.ts", "src/md-generator.ts", "src/improvements.ts", + "src/ai-prompts.ts", "src/report-scanner.ts", "src/pbir-reader.ts", "src/render/**/*"