@christianmorup/review-intent 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Mørup
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # review-intent
2
+
3
+ A CLI that renders the diff between your current branch and `main` as an
4
+ **intent-annotated** HTML review page — opened in your browser — with mermaid
5
+ class & sequence diagrams.
6
+
7
+ It exists to fix shallow PR review: a diff shows *what* changed but erases *why*.
8
+ `review-intent` puts the agent's stated intent **side-by-side** with each change,
9
+ so a reviewer adjudicates decisions instead of skimming lines.
10
+
11
+ ## How it works
12
+
13
+ The CLI is a **pure renderer**. It does two things:
14
+
15
+ 1. Runs `git diff <base>...HEAD` itself (PR-style, merge-base diff).
16
+ 2. Reads an **agent-authored artifact** (`./.review/intent.json`) for the intent
17
+ prose and the mermaid diagram sources.
18
+
19
+ It joins them and emits one self-contained `review.html`, then opens it. No LLM
20
+ call, no API key, no token cost per run. The agent that *made* the changes is
21
+ responsible for writing the artifact.
22
+
23
+ ## Usage
24
+
25
+ Run it without installing:
26
+
27
+ ```sh
28
+ npx @christianmorup/review-intent # diff HEAD vs main, read ./.review/intent.json, open browser
29
+ ```
30
+
31
+ Or install it globally:
32
+
33
+ ```sh
34
+ npm install -g @christianmorup/review-intent
35
+ review-intent
36
+ ```
37
+
38
+ From a clone (development):
39
+
40
+ ```sh
41
+ npm install
42
+ npm run build
43
+ node dist/cli.js # diff HEAD vs main, read ./.review/intent.json, open browser
44
+ ```
45
+
46
+ Options:
47
+
48
+ | Flag | Default | Meaning |
49
+ |------|---------|---------|
50
+ | `--base <ref>` | `main`, else `master` | Base branch to diff against |
51
+ | `--artifact <path>` | `.review/intent.json` | Intent artifact location |
52
+ | `--out <path>` | OS temp file | Where to write the HTML |
53
+ | `--no-open` | (opens) | Write the file but don't launch the browser |
54
+ | `--allow-gaps` | (off) | Render a draft even if intent is incomplete; gaps render as red markers |
55
+
56
+ ## Blast-radius summary
57
+
58
+ The top of the page carries a **blast-radius** block — the part that earns the
59
+ tool its keep — with three parts that deliberately pit *claimed* against
60
+ *measured*:
61
+
62
+ 1. **Surface-area scorecard** *(measured, CLI-computed from the diff)* — files/
63
+ hunks/±lines, test-vs-code file *and line* counts, net line delta,
64
+ hunks-per-file concentration, new-file count, file-level reach fan-in, intent
65
+ coverage (files & hunks annotated), diagram coverage, the single most-churned
66
+ file, a count of debt/debug markers introduced (`TODO`/`FIXME`/`console.log`/
67
+ `debugger`), a noise-file count (lockfiles, generated, build output,
68
+ binaries), a **red badge when code changed but no tests did**, sensitive-path
69
+ flags (`auth`, `*.bicep`, ADO pipelines, app config, secrets/Key Vault,
70
+ `Dockerfile`, dependency manifests), and a churn flag. Objective and
71
+ un-gameable.
72
+ 2. **Risk ledger** *(claimed, agent-authored)* — `assumption → if false → how
73
+ you'd know`. If absent, an honesty nudge appears instead of a blank.
74
+ 3. **Reach graph** *(measured, CLI-computed)* — a mermaid flowchart of repo files
75
+ that import the changed files. Heuristic (import/require/from scan), labelled
76
+ as such; bounded, and any truncation is shown, never silent.
77
+
78
+ The scorecard sitting next to the ledger is the point: if the agent claims "low
79
+ risk" while the scorecard flags `touches auth/, 0 tests`, the contradiction is
80
+ visible at a glance.
81
+
82
+ ## Visual summary
83
+
84
+ Below the blast radius is a **visual summary** — five charts rendered as pure,
85
+ self-contained inline SVG (no charting dependency, deterministic output):
86
+
87
+ 1. **Diff mass** — diverging add/remove bars per file, sorted by churn, coloured
88
+ by category (test/code/noise) with a green/red dot for intent present/missing.
89
+ 2. **Change treemap** — rectangles sized by ± lines, coloured by top-level
90
+ directory; files with no intent get a red outline.
91
+ 3. **Intent-coverage rings** — donut gauges for the share of files and hunks
92
+ that carry agent rationale (the completeness contract, visualized).
93
+ 4. **Reach ripple** — the reach graph as concentric rings: changed files at the
94
+ centre, importers rippling outward.
95
+ 5. **Honesty quadrant** — the signature view: measured *blast radius* (churn +
96
+ reach) on the x-axis against claimed *candor* (intent coverage + declared
97
+ risks) on the y-axis. A dot landing in the shaded red corner is a high-impact
98
+ change that declared little risk — the contradiction made into a picture.
99
+
100
+ `npm run sample` builds and writes a representative `sample-output.html` you can
101
+ open to see the whole page.
102
+
103
+ ### Code complexity (optional, via `lizard`)
104
+
105
+ If the [`lizard`](https://github.com/terryyin/lizard) analyzer is installed
106
+ (`pip install lizard`), the scorecard also reports **measured cyclomatic
107
+ complexity** of the changed functions — max CCN, a count of hotspots at/above the
108
+ threshold, and a "complexity hotspots" bar chart in the visual summary. lizard
109
+ covers the whole Immeo stack (C#, TS/JS, Python) from source, no build required.
110
+ It's a *measured* signal, so it sits on the same un-gameable side as the
111
+ scorecard. If lizard isn't on `PATH`, the page says so rather than hiding the
112
+ gap — nothing else changes.
113
+
114
+ ### Optional repo policy — `.review/config.json`
115
+
116
+ ```jsonc
117
+ {
118
+ "sensitivePaths": [ { "label": "pii", "pattern": "(^|/)pii" } ], // regex on posix path; replaces defaults
119
+ "churnFiles": 20, // flag "large change set" above this many files
120
+ "churnLines": 600, // ...or this many ± lines
121
+ "complexityThreshold": 15 // cyclomatic complexity at/above which a function is a hotspot
122
+ }
123
+ ```
124
+
125
+ Absent → built-in defaults (tuned to the Immeo stack). It's repo *policy*, kept
126
+ out of the per-change artifact so it can't be gamed per-PR.
127
+
128
+ ## The artifact contract (`.review/intent.json`)
129
+
130
+ ```jsonc
131
+ {
132
+ "title": "Short change-set title",
133
+ "tldr": "Five-second headline: what this does + the single most important why.",
134
+ "overall": "Why this change set exists, what was rejected, what it rests on. (markdown)",
135
+ "risks": [
136
+ { "assumption": "Data is request-scoped", "ifFalse": "Cache leaks across users", "howYoudKnow": "Concurrent-session test" }
137
+ ],
138
+ "tests": [
139
+ { "describes": "Cache returns null on a miss instead of throwing.", "name": "CacheMiss_ReturnsNull", "kind": "unit" }
140
+ ],
141
+ "diagrams": {
142
+ "class": "classDiagram\n ...", // mermaid source, authored by the agent
143
+ "sequence": "sequenceDiagram\n ..." // highlight changed steps with rect / Note
144
+ },
145
+ "files": [
146
+ {
147
+ "path": "src/foo.ts",
148
+ "what": "What changed in this file.",
149
+ "why": "Why — the decision behind it. (markdown)",
150
+ "hunks": [
151
+ { "anchor": 42, "what": "What this change does.", "why": "Why this specific change." }
152
+ ]
153
+ }
154
+ ]
155
+ }
156
+ ```
157
+
158
+ `title`, `tldr`, `overall`, and every file/hunk's `what` + `why` are required.
159
+ `diagrams`, `risks`, `tests`, and `hunks` are optional. The `tldr` renders as a
160
+ lede at the top (a five-second read); `overall` is the fuller story in a
161
+ collapsible block beneath it.
162
+
163
+ ### Tests section *(claimed, agent-authored)*
164
+
165
+ `tests` is an optional list of test cases described in plain language — the point
166
+ is a reviewer reading "Cache returns null on a miss" instead of decoding a name
167
+ like `CacheMiss_ReturnsNull`. Each entry needs a `describes` sentence; `name` (the
168
+ real test identifier, for cross-reference) and `kind` (`unit`, `integration`,
169
+ `e2e`, `manual`, or anything else) are optional. Known kinds get a coloured tag
170
+ and group the list. It renders as a standalone **Tests** section between the
171
+ visual summary and the diagrams. It is pure display — review-intent never parses
172
+ or runs your tests — so it sits on the *claimed* side, like the risk ledger.
173
+
174
+ ### Completeness is enforced
175
+
176
+ The original pain point was agents leaving intent blank. So the contract has
177
+ teeth: **every changed file needs a `what` + `why`, and every diff hunk needs an
178
+ intent.** `review-intent` runs a completeness gate and **refuses to render** if
179
+ anything is missing, printing the exact files and hunks that lack rationale:
180
+
181
+ ```
182
+ Intent is incomplete — 2 gap(s) found:
183
+ - src/util.js: no what/why written for this changed file
184
+ - src/util.js: hunk @@ -1 +1 @@ has no intent
185
+ ```
186
+
187
+ `--allow-gaps` renders a work-in-progress draft anyway, with each gap shown as a
188
+ red marker in place — so even a draft can't hide an empty spot. `what` is a cheap
189
+ one-line description; `why` is the decision and must not just restate the `what`.
190
+
191
+ ### How per-hunk intent is matched
192
+
193
+ `anchor` is a **line number in the new version of the file**. The CLI attaches
194
+ the note to whichever diff hunk's new-line range contains that anchor. This is
195
+ robust to minor hunk-boundary shifts (unlike matching by hunk ordinal).
196
+
197
+ Notes that match no hunk are surfaced under "Notes not matched to a hunk" —
198
+ never silently dropped. Artifact entries for files absent from the diff appear
199
+ under "Intent for files not in this diff". (Visibility over silent truncation,
200
+ by design.)
201
+
202
+ ## Claude Code integration: authoring the artifact
203
+
204
+ Nothing *generates* `intent.json` — that's the change-making agent's job, and
205
+ whether the intent is genuine reasoning or post-hoc rationalization is the whole
206
+ ballgame. `review-intent` ships a Claude Code skill that teaches the agent to
207
+ author the artifact **honestly** (real rejected alternatives, stated
208
+ assumptions, incidental changes marked as incidental) when it finishes a change
209
+ set, then offer to render it.
210
+
211
+ ```sh
212
+ review-intent skill install # ~/.claude/skills/review-intent-authoring (all repos)
213
+ review-intent skill install --local # ./.claude/skills/review-intent-authoring (this repo only)
214
+ review-intent skill uninstall # remove user-scoped skill
215
+ review-intent skill uninstall --local # remove repo-scoped skill
216
+ ```
217
+
218
+ The skill never auto-launches anything — it teaches the agent to write the
219
+ artifact and then *ask* before opening the review. Add `--force` to overwrite or
220
+ remove a hand-edited skill file. User and `--local` scopes are independent.
221
+
222
+ The honesty contract is the point: a fluent rationalization is worse than
223
+ nothing because it lowers the reviewer's guard while adding no signal. The skill
224
+ pushes for "why I chose this over X" and "what this rests on" — and explicitly
225
+ tells the agent to admit gaps rather than invent thoroughness.
226
+
227
+ ## Development
228
+
229
+ ```sh
230
+ npm test # vitest, pure-module unit tests
231
+ npm run test:watch
232
+ ```
233
+
234
+ Modules are deliberately small and single-purpose: `git.ts` (diff), `artifact.ts`
235
+ (load + validate), `diff-parser.ts` (parse), `match.ts` (pure join), `render.ts`
236
+ (pure HTML), `cli.ts` (thin orchestrator).
@@ -0,0 +1,49 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { ArtifactSchema } from "./types.js";
4
+ export const DEFAULT_ARTIFACT_PATH = ".review/intent.json";
5
+ export class ArtifactError extends Error {
6
+ }
7
+ /**
8
+ * Locate, parse, and validate the agent-authored intent artifact.
9
+ * Throws ArtifactError with a friendly, actionable message on any problem.
10
+ */
11
+ export function loadArtifact(cwd, artifactPath = DEFAULT_ARTIFACT_PATH) {
12
+ const full = resolve(cwd, artifactPath);
13
+ if (!existsSync(full)) {
14
+ throw new ArtifactError(`No intent artifact found at ${full}\n\n` +
15
+ `The agent that made the changes must write one. Minimal example:\n\n` +
16
+ ` {\n` +
17
+ ` "title": "Short change-set title",\n` +
18
+ ` "overall": "Why this change set exists, what was rejected.",\n` +
19
+ ` "diagrams": { "class": "classDiagram\\n ...", "sequence": "sequenceDiagram\\n ..." },\n` +
20
+ ` "files": [\n` +
21
+ ` { "path": "src/foo.ts", "intent": "Why this file changed",\n` +
22
+ ` "hunks": [ { "anchor": 42, "intent": "Why this change" } ] }\n` +
23
+ ` ]\n` +
24
+ ` }\n\n` +
25
+ `Pass --artifact <path> to point at a different location.`);
26
+ }
27
+ let raw;
28
+ try {
29
+ raw = readFileSync(full, "utf8");
30
+ }
31
+ catch (err) {
32
+ throw new ArtifactError(`Could not read ${full}: ${err.message}`);
33
+ }
34
+ let json;
35
+ try {
36
+ json = JSON.parse(raw);
37
+ }
38
+ catch (err) {
39
+ throw new ArtifactError(`${full} is not valid JSON: ${err.message}`);
40
+ }
41
+ const result = ArtifactSchema.safeParse(json);
42
+ if (!result.success) {
43
+ const issues = result.error.issues
44
+ .map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
45
+ .join("\n");
46
+ throw new ArtifactError(`${full} does not match the expected schema:\n${issues}`);
47
+ }
48
+ return result.data;
49
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import open from "open";
7
+ import { resolveBase, getDiff, GitError } from "./git.js";
8
+ import { loadArtifact, ArtifactError, DEFAULT_ARTIFACT_PATH } from "./artifact.js";
9
+ import { parseDiffText } from "./diff-parser.js";
10
+ import { buildReviewModel } from "./match.js";
11
+ import { renderHtml } from "./render.js";
12
+ import { loadConfig, ConfigError } from "./config.js";
13
+ import { buildScorecard, isCodePath } from "./scorecard.js";
14
+ import { scanRepo, buildReachGraph } from "./reach.js";
15
+ import { analyzeComplexity } from "./complexity.js";
16
+ import { findGaps, formatGaps } from "./completeness.js";
17
+ import { installSkill, uninstallSkill, skillFile, } from "./skill.js";
18
+ const HELP = `review-intent — render an intent-annotated diff review in your browser.
19
+
20
+ Usage:
21
+ review-intent [options]
22
+ review-intent skill install [--local] [--force]
23
+ review-intent skill uninstall [--local] [--force]
24
+
25
+ Options:
26
+ --base <ref> Base branch to diff against (default: main, then master)
27
+ --artifact <path> Path to the intent artifact (default: ${DEFAULT_ARTIFACT_PATH})
28
+ --out <path> Where to write the HTML (default: a temp file)
29
+ --no-open Do not open the browser; just write the file
30
+ --allow-gaps Render even if intent is incomplete (gaps shown in red)
31
+ -h, --help Show this help
32
+
33
+ Commands:
34
+ skill install Install the review-intent-authoring Claude Code skill
35
+ (default: ~/.claude/skills; --local: ./.claude/skills)
36
+ skill uninstall Remove the skill
37
+ `;
38
+ function localFlag(scope) {
39
+ return scope === "local" ? " --local" : "";
40
+ }
41
+ async function runSkill(argv) {
42
+ const sub = argv[0];
43
+ const { values } = parseArgs({
44
+ args: argv.slice(1),
45
+ options: {
46
+ local: { type: "boolean", default: false },
47
+ force: { type: "boolean", default: false },
48
+ },
49
+ });
50
+ const scope = values.local ? "local" : "user";
51
+ const file = skillFile({ scope });
52
+ if (sub === "install") {
53
+ const result = await installSkill({ scope, force: values.force });
54
+ if (result === "installed")
55
+ process.stdout.write(`Installed skill: ${file}\n`);
56
+ else if (result === "updated")
57
+ process.stdout.write(`Updated skill: ${file}\n`);
58
+ else if (result === "already")
59
+ process.stdout.write(`Skill already up to date: ${file}\n`);
60
+ else {
61
+ process.stderr.write(`A different skill file already exists at ${file}.\n`);
62
+ process.stderr.write(`Run 'review-intent skill install${localFlag(scope)} --force' to overwrite.\n`);
63
+ process.exitCode = 1;
64
+ }
65
+ return;
66
+ }
67
+ if (sub === "uninstall") {
68
+ const result = await uninstallSkill({ scope, force: values.force });
69
+ if (result === "removed")
70
+ process.stdout.write(`Removed skill from ${file}\n`);
71
+ else if (result === "not-installed")
72
+ process.stdout.write(`No skill installed at ${file}\n`);
73
+ else {
74
+ process.stderr.write(`The skill file at ${file} has been modified.\n`);
75
+ process.stderr.write(`Run 'review-intent skill uninstall${localFlag(scope)} --force' to remove anyway.\n`);
76
+ process.exitCode = 1;
77
+ }
78
+ return;
79
+ }
80
+ process.stderr.write(`Unknown skill command: ${sub ?? "(none)"}\nTry: review-intent skill install | uninstall\n`);
81
+ process.exitCode = 1;
82
+ }
83
+ async function main() {
84
+ const rawArgv = process.argv.slice(2);
85
+ if (rawArgv[0] === "skill") {
86
+ await runSkill(rawArgv.slice(1));
87
+ return;
88
+ }
89
+ const { values } = parseArgs({
90
+ options: {
91
+ base: { type: "string" },
92
+ artifact: { type: "string" },
93
+ out: { type: "string" },
94
+ open: { type: "boolean", default: true },
95
+ "allow-gaps": { type: "boolean", default: false },
96
+ help: { type: "boolean", short: "h", default: false },
97
+ },
98
+ allowNegative: true,
99
+ });
100
+ if (values.help) {
101
+ process.stdout.write(HELP);
102
+ return;
103
+ }
104
+ const cwd = process.cwd();
105
+ const base = resolveBase(cwd, values.base);
106
+ const rawDiff = getDiff(cwd, base);
107
+ const artifact = loadArtifact(cwd, values.artifact);
108
+ const config = loadConfig(cwd);
109
+ const diff = parseDiffText(rawDiff);
110
+ // Part 1: objective scorecard, computed from the diff.
111
+ const scorecard = buildScorecard(diff, config);
112
+ // Part 3: file-level reach, computed by scanning the repo for importers of
113
+ // the changed code files.
114
+ const changedCodePaths = diff
115
+ .filter((f) => f.status !== "deleted" && isCodePath(f.path))
116
+ .map((f) => f.path);
117
+ const { files: repoFiles, truncated } = scanRepo(cwd);
118
+ const reach = buildReachGraph(repoFiles, changedCodePaths, {
119
+ scanTruncated: truncated,
120
+ });
121
+ // Part 1 (cont.): measured cyclomatic complexity of the changed code, via the
122
+ // external lizard analyzer. Degrades gracefully if lizard isn't installed.
123
+ const complexity = analyzeComplexity(cwd, changedCodePaths, config.complexityThreshold);
124
+ const model = buildReviewModel(artifact, diff, base, scorecard, reach, complexity);
125
+ // Strict completeness gate: refuse to render incomplete intent unless the
126
+ // author explicitly opts into a draft.
127
+ const gaps = findGaps(model);
128
+ if (gaps.length > 0 && !values["allow-gaps"]) {
129
+ process.stderr.write(`\n${formatGaps(gaps)}\n`);
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ const html = renderHtml(model);
134
+ const outPath = values.out ?? join(tmpdir(), `review-intent-${Date.now()}.html`);
135
+ writeFileSync(outPath, html, "utf8");
136
+ process.stdout.write(`Wrote review to ${outPath}\n`);
137
+ if (values.open) {
138
+ await open(outPath);
139
+ }
140
+ }
141
+ main().catch((err) => {
142
+ if (err instanceof GitError ||
143
+ err instanceof ArtifactError ||
144
+ err instanceof ConfigError) {
145
+ process.stderr.write(`\n${err.message}\n`);
146
+ }
147
+ else {
148
+ process.stderr.write(`\nUnexpected error: ${err.message}\n`);
149
+ }
150
+ process.exitCode = 1;
151
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pure: find missing-rationale gaps in the joined model. The contract is strict
3
+ * — every changed file needs a why, and every diff hunk needs at least one
4
+ * intent. This needs the model (not just the artifact) because hunk coverage is
5
+ * only knowable after the artifact's anchors are matched against git's hunks.
6
+ */
7
+ export function findGaps(model) {
8
+ const gaps = [];
9
+ for (const file of model.files) {
10
+ if (!file.why) {
11
+ gaps.push({
12
+ kind: "file",
13
+ path: file.path,
14
+ detail: "no what/why written for this changed file",
15
+ });
16
+ }
17
+ for (const hunk of file.hunks) {
18
+ if (hunk.intents.length === 0) {
19
+ gaps.push({
20
+ kind: "hunk",
21
+ path: file.path,
22
+ detail: `hunk ${hunk.header} has no intent`,
23
+ });
24
+ }
25
+ }
26
+ }
27
+ return gaps;
28
+ }
29
+ /** Render a gap list as a human-readable, copy-pasteable error body. */
30
+ export function formatGaps(gaps) {
31
+ const lines = gaps.map((g) => ` - ${g.path}: ${g.detail}`);
32
+ return (`Intent is incomplete — ${gaps.length} gap(s) found:\n` +
33
+ lines.join("\n") +
34
+ `\n\nEvery changed file needs a what/why, and every hunk needs an intent.\n` +
35
+ `Fix .review/intent.json, or pass --allow-gaps to render a draft with the gaps flagged.`);
36
+ }
@@ -0,0 +1,139 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /** Languages in the Immeo stack that `lizard` parses reliably from source. */
3
+ const ANALYZABLE_RE = /\.(cs|ts|tsx|js|jsx|mjs|cjs|py)$/i;
4
+ /** Max hotspots to keep, so a pathological change set can't flood the report. */
5
+ const MAX_HOTSPOTS = 12;
6
+ export function isAnalyzablePath(path) {
7
+ return ANALYZABLE_RE.test(path);
8
+ }
9
+ /**
10
+ * Pure: split one `lizard --csv` line into fields. lizard quotes the location,
11
+ * file, name and long-name columns with the Python csv writer, and the long-name
12
+ * column contains commas (parameter lists) — so a naive split is wrong.
13
+ */
14
+ export function parseCsvLine(line) {
15
+ const fields = [];
16
+ let cur = "";
17
+ let inQuotes = false;
18
+ for (let i = 0; i < line.length; i++) {
19
+ const ch = line[i];
20
+ if (inQuotes) {
21
+ if (ch === '"') {
22
+ if (line[i + 1] === '"') {
23
+ cur += '"'; // escaped quote
24
+ i++;
25
+ }
26
+ else {
27
+ inQuotes = false;
28
+ }
29
+ }
30
+ else {
31
+ cur += ch;
32
+ }
33
+ }
34
+ else if (ch === '"') {
35
+ inQuotes = true;
36
+ }
37
+ else if (ch === ",") {
38
+ fields.push(cur);
39
+ cur = "";
40
+ }
41
+ else {
42
+ cur += ch;
43
+ }
44
+ }
45
+ fields.push(cur);
46
+ return fields;
47
+ }
48
+ /** Pure: parse the full `lizard --csv` output into per-function records.
49
+ * Columns: nloc, ccn, token, params, length, location, file, name, long_name, start, end. */
50
+ export function parseLizardCsv(csv) {
51
+ const out = [];
52
+ for (const raw of csv.split(/\r?\n/)) {
53
+ if (raw.trim() === "")
54
+ continue;
55
+ const f = parseCsvLine(raw);
56
+ if (f.length < 11)
57
+ continue; // malformed row — skip rather than mis-read
58
+ out.push({
59
+ nloc: Number(f[0]),
60
+ ccn: Number(f[1]),
61
+ params: Number(f[3]),
62
+ file: f[6],
63
+ name: f[7],
64
+ line: Number(f[9]),
65
+ });
66
+ }
67
+ return out;
68
+ }
69
+ /** Pure: aggregate per-function records into the measured complexity model. */
70
+ export function buildComplexityModel(funcs, threshold) {
71
+ const sorted = [...funcs].sort((a, b) => b.ccn - a.ccn);
72
+ return {
73
+ available: true,
74
+ threshold,
75
+ functionsAnalyzed: funcs.length,
76
+ maxCcn: funcs.reduce((m, f) => Math.max(m, f.ccn), 0),
77
+ worst: sorted[0] ?? null,
78
+ hotspots: sorted.filter((f) => f.ccn >= threshold).slice(0, MAX_HOTSPOTS),
79
+ };
80
+ }
81
+ /** A complexity model standing in for analysis that could not run — the note is
82
+ * rendered so the absence is visible, never a silent skip. */
83
+ export function unavailableComplexity(note) {
84
+ return {
85
+ available: false,
86
+ threshold: 0,
87
+ functionsAnalyzed: 0,
88
+ maxCcn: 0,
89
+ worst: null,
90
+ hotspots: [],
91
+ note,
92
+ };
93
+ }
94
+ /** Candidate ways to invoke lizard: the console script if on PATH, else the
95
+ * Python module (covers `pip install --user` where the script isn't on PATH). */
96
+ const INVOCATIONS = [
97
+ { cmd: "lizard", pre: [] },
98
+ { cmd: "python", pre: ["-m", "lizard"] },
99
+ { cmd: "python3", pre: ["-m", "lizard"] },
100
+ { cmd: "py", pre: ["-m", "lizard"] },
101
+ ];
102
+ function runLizard(cwd, files) {
103
+ for (const inv of INVOCATIONS) {
104
+ try {
105
+ return execFileSync(inv.cmd, [...inv.pre, "--csv", ...files], {
106
+ cwd,
107
+ encoding: "utf8",
108
+ maxBuffer: 32 * 1024 * 1024,
109
+ stdio: ["ignore", "pipe", "ignore"],
110
+ });
111
+ }
112
+ catch (err) {
113
+ const e = err;
114
+ if (e.code === "ENOENT")
115
+ continue; // this invocation isn't available — try the next
116
+ // lizard ran but exited non-zero (e.g. threshold warnings); keep its output.
117
+ if (typeof e.stdout === "string" && e.stdout.trim() !== "")
118
+ return e.stdout;
119
+ return null;
120
+ }
121
+ }
122
+ return null; // no working lizard invocation
123
+ }
124
+ /**
125
+ * Side-effecting: measure complexity of the analyzable changed files via lizard.
126
+ * Degrades gracefully — a missing lizard yields an `available: false` model with
127
+ * a note rather than an error or a silent gap.
128
+ */
129
+ export function analyzeComplexity(cwd, changedPaths, threshold) {
130
+ const files = changedPaths.filter(isAnalyzablePath);
131
+ if (files.length === 0) {
132
+ return { ...buildComplexityModel([], threshold), note: "no analyzable source files in this change set" };
133
+ }
134
+ const csv = runLizard(cwd, files);
135
+ if (csv === null) {
136
+ return unavailableComplexity("lizard not found — run `pip install lizard` to enable complexity metrics");
137
+ }
138
+ return buildComplexityModel(parseLizardCsv(csv), threshold);
139
+ }