Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 + `<details>` 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
Expand Down
7 changes: 4 additions & 3 deletions WHATS-NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<details>` 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.
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions changelog/0.12.0.md
Original file line number Diff line number Diff line change
@@ -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 `<details>AI cleanup prompt (copy + paste…)</details>` 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 <c> --report <path> [--output <file>]` 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 `<script>` block when concatenated. (Discovered during this release while adding `ai-prompts.js` to the inline-script manifest.)

### Tests
- 299 tests pass (was 276): 14 new for the prompt builder, 4 new for the MD embed, 6 new for the CLI subcommand. **No new runtime dependencies.**

### Notes
- Lineage will **never** run the prompt for you. The destructive action stays in the user's own shell, in the AI tool they chose, with a git commit pre-flight. This is by design — see [the design doc](../claudedocs/intake-specs/design-ai-cleanup-handoff.md) (status: locked) for the full decision table and the rationale for rejecting `--apply` flags / TMDL-on-disk pruning / vendoring `pbi-desktop`.
- v1 covers measures only (unused + dead-chain). Unused columns, dead inactive relationships, orphan pages = v2 — same builder, more categories.
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "powerbi-lineage",
"version": "0.11.7",
"version": "0.12.0",
"description": "Generate documentation from Power BI PBIP projects — runs in your browser (nothing uploaded) or as a local CLI. Outputs a searchable dashboard plus 9+ Markdown docs ready for ADO Wiki or GitHub.",
"license": "MIT",
"author": "Jonathan Papworth",
Expand Down
4 changes: 2 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Open a PBIP project folder — get a searchable dashboard plus nine Markdown doc
</p>

<p align="center">
<img alt="Tests 276/276" src="https://img.shields.io/badge/tests-276%2F276-22c55e?style=flat-square">
<img alt="Tests 299/299" src="https://img.shields.io/badge/tests-299%2F299-22c55e?style=flat-square">
<img alt="Runtime deps 0" src="https://img.shields.io/badge/runtime%20deps-0-64748b?style=flat-square">
<img alt="Node ≥18" src="https://img.shields.io/badge/node-%E2%89%A518-5E6A7B?style=flat-square">
</p>
Expand Down Expand Up @@ -199,7 +199,7 @@ scripts/
serve-browser.mjs Tiny static server for local testing

changelog/ Per-version release notes (one file per release)
tests/ node:test suites — 276 tests, zero framework deps
tests/ node:test suites — 299 tests, zero framework deps
```

## Zero runtime dependencies
Expand Down
225 changes: 225 additions & 0 deletions src/ai-prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* AI cleanup prompt generator.
*
* Turns the existing audit's "unused measures" + "dead-chain measures"
* findings into a markdown prompt the user can paste into the AI tool
* of their choice (recommended: Claude Code with the `pbi-desktop`
* plugin) to actually delete those measures from a live model.
*
* Lineage itself never mutates the model — this module is the entire
* v1 surface area of the "AI cleanup handoff" feature. See
* claudedocs/intake-specs/design-ai-cleanup-handoff.md for the
* locked decisions and the rationale for the read-only commitment.
*/
import type { FullData, ModelMeasure } from "./data-builder.js";

// Note: this module is intentionally **self-contained** so it can be
// concatenated into the dashboard's inline <script> block (a classic
// script, not a module bundle). The dead-chain algorithm is duplicated
// from improvements.ts as `findDeadChainMeasures` below — kept in sync
// by the tests in tests/ai-prompts.test.ts and tests/improvements.test.ts
// asserting the same fixture produces the same dead-chain output.

export type CleanupCategory =
| "unused-measures"
| "dead-chain-measures"
| "measures-all";

export interface BuildCleanupPromptOptions {
/** Override the ISO date stamp emitted in the prompt body. Used by tests. */
now?: Date;
}

export interface CleanupTargetCounts {
/** Directly-unused user measures, EXTERNALMEASURE proxies excluded. */
stage1: number;
/** Dead-chain user measures (reachable only via Stage 1), EXTERNALMEASURE proxies excluded. */
stage2: number;
}

interface KillTarget {
table: string;
name: string;
}

function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}

function userMeasures(data: FullData): ModelMeasure[] {
// Same filter as runImprovementChecks — auto-date tables are engine-
// generated and not user-managed, so their measures are never kill
// candidates even if they look unused.
return data.measures.filter(m => {
const t = data.tables.find(tt => tt.name === m.table);
return !t || t.origin !== "auto-date";
});
}

function isExternalProxy(m: ModelMeasure): boolean {
return m.externalProxy !== null;
}

function stage1Targets(data: FullData): KillTarget[] {
return userMeasures(data)
.filter(m => m.status === "unused" && !isExternalProxy(m))
.map(m => ({ table: m.table, name: m.name }));
}

/**
* Inline copy of improvements.deadChainMeasures so this module has no
* dependency on improvements.ts (allows it to be inlined into the
* dashboard's classic-script bundle without dragging improvements.ts
* along). Same algorithm: BFS from every status:"direct" measure
* through the dep graph; anything unreachable AND not already marked
* unused is dead-chain. Two callers, one definition — kept in sync by
* shared-fixture tests on both sides.
*/
function findDeadChainMeasures(data: FullData): string[] {
const byName = new Map<string, ModelMeasure>(data.measures.map(m => [m.name, m]));
const reachable = new Set<string>();
const queue: string[] = data.measures
.filter(m => m.status === "direct")
.map(m => m.name);
while (queue.length) {
const current = queue.shift()!;
if (reachable.has(current)) continue;
reachable.add(current);
const m = byName.get(current);
if (!m) continue;
for (const dep of m.daxDependencies) {
if (!reachable.has(dep)) queue.push(dep);
}
}
return data.measures
.filter(m => !reachable.has(m.name) && m.status !== "unused")
.map(m => m.name);
}

function stage2Targets(data: FullData): KillTarget[] {
const um = userMeasures(data);
const byName = new Map(um.map(m => [m.name, m]));
const deadNames = findDeadChainMeasures(data);
return deadNames
.map(n => byName.get(n))
.filter((m): m is ModelMeasure => m !== undefined)
.filter(m => m.status !== "unused" && !isExternalProxy(m))
.map(m => ({ table: m.table, name: m.name }));
}

function renderTargetList(targets: KillTarget[]): string {
if (targets.length === 0) return "_(none flagged)_";
return targets.map(t => `- \`${t.table}[${t.name}]\``).join("\n");
}

const RECOMMENDED_TOOL = `## Recommended tool
If you are Claude Code and the \`pbi-desktop\` plugin from data-goblins is
installed, use its \`connect-pbid\` skill. Otherwise, use TOM via PowerShell
or the Tabular Editor CLI. Do **not** edit TMDL files directly while
Power BI Desktop is open.`;

const SAFETY_CONSTRAINTS = `## Safety constraints — read before acting
1. Confirm with me before each deletion.
2. Process **Stage 1 (directly unused)** completely before touching Stage 2 (dead-chain).
After Stage 1, re-run the audit; some Stage 2 items may need to stay.
3. Before deleting each measure, read its DAX from the live model. If the
expression contains \`EXTERNALMEASURE(...)\` it is bound to a remote
Analysis Services cube and looks orphaned only from this side — skip it.
(PowerBI-Lineage already excludes proxies it can identify statically, but
verify directly as belt-and-braces.)
4. If a measure's name starts with \`_\` it may be an intentional helper —
prompt me before deleting, do not auto-skip.
5. After all deletions, call \`$model.SaveChanges()\` once; verify the model
still opens in Power BI Desktop with no broken-reference errors.
6. Power BI Desktop's Ctrl+Z does NOT undo TOM changes. Make a \`.pbip\`
git commit before starting.`;

const VERIFICATION = `## Verification
After all stages complete:
- Re-open the \`.pbip\` in Power BI Desktop; no error dialog should appear.
- Open the Improvements tab in PowerBI-Lineage; the "unused measures" and
"dead-chain measures" findings should be empty or reduced as expected.
- If anything looks wrong, \`git reset --hard\` to the pre-cleanup commit.`;

function titleFor(category: CleanupCategory): string {
switch (category) {
case "unused-measures": return "Cleanup task — delete unused Power BI measures";
case "dead-chain-measures": return "Cleanup task — delete dead-chain Power BI measures";
case "measures-all": return "Cleanup task — delete unused + dead-chain Power BI measures";
}
}

function goalFor(category: CleanupCategory, date: string): string {
const common = `This list was generated by PowerBI-Lineage's static audit on ${date}.`;
switch (category) {
case "unused-measures":
return `## Goal
Delete the measures listed in \`## Targets\` below from my Power BI model.
${common} Each measure is consumed by **zero** visuals across **zero** pages
and is not referenced by any other in-use measure.`;
case "dead-chain-measures":
return `## Goal
Delete the measures listed in \`## Targets\` below from my Power BI model.
${common} Each measure IS referenced by other measures, but those measures
are themselves never on a visual — the whole chain terminates in nothing.
**This is Stage 2 work.** Complete Stage 1 (directly unused) first and
re-run the audit; some items below may no longer apply.`;
case "measures-all":
return `## Goal
Delete the measures listed in \`## Targets\` below from my Power BI model.
${common} Stage 1 = directly unused (zero visual bindings, no other measure
references them). Stage 2 = dead-chain (referenced only by Stage 1 measures).
Do Stage 1 first, then re-audit before touching Stage 2.`;
}
}

/**
* Count what `buildCleanupPrompt` would emit for each stage, without
* rendering the prompt. Used by the Unused-tab toolbar to decide
* whether to show the "Generate AI cleanup prompt" button and what
* to label it with. Numbers match what ends up in the prompt body
* (EXTERNALMEASURE proxies excluded, auto-date tables excluded),
* which can be lower than the Unused-tab card count.
*/
export function countCleanupTargets(data: FullData): CleanupTargetCounts {
return {
stage1: stage1Targets(data).length,
stage2: stage2Targets(data).length,
};
}

export function buildCleanupPrompt(
data: FullData,
category: CleanupCategory,
opts: BuildCleanupPromptOptions = {},
): string {
const date = isoDate(opts.now ?? new Date());
const s1 = stage1Targets(data);
const s2 = stage2Targets(data);

const sections: string[] = [
`# ${titleFor(category)}`,
goalFor(category, date),
RECOMMENDED_TOOL,
SAFETY_CONSTRAINTS,
];

const targetSection: string[] = ["## Targets"];

if (category === "unused-measures" || category === "measures-all") {
targetSection.push(`### Stage 1 — directly unused (${s1.length} measure${s1.length === 1 ? "" : "s"})

${renderTargetList(s1)}`);
}

if (category === "dead-chain-measures" || category === "measures-all") {
targetSection.push(`### Stage 2 — dead-chain (${s2.length} measure${s2.length === 1 ? "" : "s"}, only kill after Stage 1 + re-audit)

${renderTargetList(s2)}`);
}

sections.push(targetSection.join("\n\n"));
sections.push(VERIFICATION);

return sections.join("\n\n") + "\n";
}
Loading
Loading