@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 +21 -0
- package/README.md +236 -0
- package/dist/artifact.js +49 -0
- package/dist/cli.js +151 -0
- package/dist/completeness.js +36 -0
- package/dist/complexity.js +139 -0
- package/dist/config.js +58 -0
- package/dist/diff-parser.js +51 -0
- package/dist/git.js +59 -0
- package/dist/match.js +64 -0
- package/dist/reach.js +138 -0
- package/dist/render.js +982 -0
- package/dist/scorecard.js +101 -0
- package/dist/skill.js +257 -0
- package/dist/types.js +65 -0
- package/package.json +53 -0
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).
|
package/dist/artifact.js
ADDED
|
@@ -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
|
+
}
|