@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.
@@ -0,0 +1,101 @@
1
+ const TEST_RE = /(?:[._-](?:test|spec)\.)|(?:^|\/)(?:tests?|__tests__|spec)\//i;
2
+ const CODE_RE = /\.(ts|tsx|js|jsx|mjs|cjs|cs|go|py|java|rb|rs|cpp|cc|c|h|hpp|php|vue|svelte|kt|swift|scala)$/i;
3
+ /** Lockfiles, generated/minified output, build dirs, and binaries — high churn,
4
+ * low review value. Flagged so a reviewer knows how much of the diff is noise. */
5
+ const NOISE_RE = /(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|packages\.lock\.json)$|\.(?:lock|min\.js|min\.css|map|snap)$|(?:^|\/)(?:dist|build|node_modules|out)\/|\.(?:png|jpe?g|gif|svg|ico|webp|woff2?|ttf|eot|pdf)$/i;
6
+ /** Debt/debug markers a reviewer should never let through unnoticed. */
7
+ const DEBT_RE = /\b(?:TODO|FIXME|HACK|XXX)\b|console\.log|debugger\b/;
8
+ export function isTestPath(path) {
9
+ return TEST_RE.test(path);
10
+ }
11
+ export function isCodePath(path) {
12
+ return CODE_RE.test(path);
13
+ }
14
+ export function isNoisePath(path) {
15
+ return NOISE_RE.test(path);
16
+ }
17
+ /** Pure: derive the objective surface-area scorecard from the parsed diff. */
18
+ export function buildScorecard(diff, config) {
19
+ const byStatus = {};
20
+ let hunks = 0;
21
+ let added = 0;
22
+ let removed = 0;
23
+ let testFiles = 0;
24
+ let codeFiles = 0;
25
+ let testLines = 0;
26
+ let codeLines = 0;
27
+ let debtMarkers = 0;
28
+ let noiseFiles = 0;
29
+ let largestFile = null;
30
+ for (const file of diff) {
31
+ byStatus[file.status] = (byStatus[file.status] ?? 0) + 1;
32
+ hunks += file.hunks.length;
33
+ let fileChurn = 0;
34
+ for (const hunk of file.hunks) {
35
+ for (const line of hunk.lines) {
36
+ if (line.type === "add") {
37
+ added++;
38
+ fileChurn++;
39
+ if (DEBT_RE.test(line.content))
40
+ debtMarkers++;
41
+ }
42
+ else if (line.type === "del") {
43
+ removed++;
44
+ fileChurn++;
45
+ }
46
+ }
47
+ }
48
+ const isTest = isTestPath(file.path);
49
+ if (isTest) {
50
+ testFiles++;
51
+ testLines += fileChurn;
52
+ }
53
+ else if (isCodePath(file.path)) {
54
+ codeFiles++;
55
+ codeLines += fileChurn;
56
+ }
57
+ if (isNoisePath(file.path))
58
+ noiseFiles++;
59
+ if (!largestFile || fileChurn > largestFile.churn) {
60
+ largestFile = { path: file.path, churn: fileChurn };
61
+ }
62
+ }
63
+ const badges = [];
64
+ // Code changed but no test files touched — the headline review smell.
65
+ if (codeFiles > 0 && testFiles === 0) {
66
+ badges.push({ label: "no test changes", tone: "danger" });
67
+ }
68
+ // Sensitive paths (repo policy), de-duplicated by label.
69
+ const paths = diff.map((f) => f.path);
70
+ for (const sp of config.sensitivePaths) {
71
+ let re;
72
+ try {
73
+ re = new RegExp(sp.pattern, "i");
74
+ }
75
+ catch {
76
+ continue; // a bad policy regex shouldn't crash the scorecard
77
+ }
78
+ if (paths.some((p) => re.test(p))) {
79
+ badges.push({ label: `touches ${sp.label}`, tone: "danger" });
80
+ }
81
+ }
82
+ // Large change set (churn).
83
+ if (diff.length > config.churnFiles || added + removed > config.churnLines) {
84
+ badges.push({ label: "large change set", tone: "warn" });
85
+ }
86
+ return {
87
+ filesChanged: diff.length,
88
+ byStatus,
89
+ hunks,
90
+ added,
91
+ removed,
92
+ testFiles,
93
+ codeFiles,
94
+ testLines,
95
+ codeLines,
96
+ debtMarkers,
97
+ noiseFiles,
98
+ largestFile,
99
+ badges,
100
+ };
101
+ }
package/dist/skill.js ADDED
@@ -0,0 +1,257 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ const SKILL_NAME = "review-intent-authoring";
5
+ // Embedded content for the Claude Code skill that teaches the change-making
6
+ // agent to author an honest .review/intent.json before review. Single source
7
+ // of truth — the installer writes this byte-for-byte, and uninstall checks
8
+ // against it (line-ending-normalized) to decide whether the file is still ours.
9
+ export const SKILL_CONTENT = `---
10
+ name: review-intent-authoring
11
+ description: Use when you have just finished a set of code changes on a branch and the user (or another reviewer) is about to review the diff. Author a .review/intent.json that captures the genuine intent behind the changes — why, what you rejected, what it rests on — keyed to files and hunks, plus mermaid class and sequence diagrams. Then offer to render it with review-intent so the reviewer adjudicates decisions instead of skimming lines.
12
+ ---
13
+
14
+ # Authoring an honest intent artifact
15
+
16
+ A diff records *what* changed and erases *why*. \`review-intent\` renders your
17
+ intent side-by-side with the diff so the reviewer adjudicates decisions, not
18
+ lines. Your job is to write \`.review/intent.json\` — and to write it **honestly**,
19
+ because a fluent rationalization is worse than nothing: it lowers the reviewer's
20
+ guard while adding no real signal.
21
+
22
+ ## When to author
23
+
24
+ After you finish a logical change set on a branch and before you hand it back
25
+ for review. One artifact per branch / review.
26
+
27
+ ## When NOT to author
28
+
29
+ - A trivial or purely mechanical change (rename, formatting, dependency bump)
30
+ where the diff already tells the whole story. Say so in chat; don't manufacture
31
+ intent.
32
+ - You are mid-implementation, not at a reviewable stopping point.
33
+ - The user has declined review-intent for this change set.
34
+
35
+ ## The honesty contract (read this before writing a word)
36
+
37
+ These rules exist to keep the artifact from becoming post-hoc theater:
38
+
39
+ 1. **Intent is the reason you chose this, not a description of what it does.**
40
+ "Adds a cache" is not intent. "Chose an in-memory cache over Redis because the
41
+ data is request-scoped and we have one process" is intent.
42
+ 2. **Name what you rejected — truthfully.** If there was a real alternative, state
43
+ it and why you didn't take it. If there genuinely wasn't one, write "no real
44
+ alternative considered" — do **not** invent a rejected option to look thorough.
45
+ 3. **State what the change rests on.** The assumptions that, if false, make this
46
+ change wrong. This is the reviewer's highest-value target.
47
+ 4. **Mark incidental changes as incidental.** If a hunk is a mechanical
48
+ consequence (a signature changed so callers had to), say that plainly instead
49
+ of inventing a decision for it.
50
+ 5. **If you are uncertain, say you are uncertain.** "I think X but didn't verify
51
+ under concurrency" is more useful than false confidence.
52
+
53
+ If following these makes a section short or admits a gap, that is a success, not
54
+ a failure. The gap is the signal the reviewer needs.
55
+
56
+ ## Completeness is mandatory (this is enforced)
57
+
58
+ Every changed file needs a \`what\` and a \`why\`, and **every hunk needs a \`what\`
59
+ and a \`why\`**. \`review-intent\` runs a completeness gate and **refuses to render**
60
+ if any changed file or hunk is missing intent — it prints the exact gaps. Do not
61
+ hand back a change set with empty intent; fill it before you offer the review.
62
+ (\`--allow-gaps\` exists for an explicit work-in-progress draft, and even then the
63
+ gaps render as red markers — it is not a way to skip the work.) review-intent
64
+ also renders an **intent-coverage gauge** — the measured share of files and hunks
65
+ you annotated — so partial coverage is visible at a glance, draft or not.
66
+
67
+ \`what\` vs \`why\`: \`what\` is a one-line description of the change (cheap — write it
68
+ first). \`why\` is the decision behind it and must not restate the what. "Renamed
69
+ \`x\` to \`y\`" is a what; "renamed for consistency with the \`z\` convention so callers
70
+ don't guess" is a why. For a mechanical hunk, an honest why is "incidental —
71
+ forced by the signature change above"; write that rather than leaving it blank.
72
+
73
+ ## The artifact: \`.review/intent.json\`
74
+
75
+ Write it at the repo root. \`title\`, \`tldr\`, and \`overall\` are required, and so
76
+ are \`what\`/\`why\` on every file and hunk you list (see the completeness gate
77
+ above). The \`tldr\` is a five-second read shown as a lede at the top — the single
78
+ headline (what + the most important why); \`overall\` is the fuller story beneath
79
+ it. Don't make the tldr a copy of the title or just the "what" — it must carry a
80
+ why.
81
+
82
+ \`\`\`jsonc
83
+ {
84
+ "title": "Short change-set title",
85
+ "tldr": "One or two sentences a reviewer can read in five seconds: what this does and the single most important why.",
86
+ "overall": "Why this change set exists. What you rejected and why. What it rests on (assumptions that, if false, break it). Markdown.",
87
+ "risks": [
88
+ { "assumption": "What the change rests on",
89
+ "ifFalse": "What breaks if it does not hold",
90
+ "howYoudKnow": "How a reviewer could check (optional)" }
91
+ ],
92
+ "tests": [
93
+ { "describes": "Plain-language sentence: what this test proves.",
94
+ "name": "RealTestIdentifier (optional, for cross-reference)",
95
+ "kind": "unit | integration | e2e | manual (optional)" }
96
+ ],
97
+ "diagrams": {
98
+ "class": "classDiagram\\n ...", // structures you added/changed
99
+ "sequence": "sequenceDiagram\\n ..." // a flow the change affects; highlight changed steps
100
+ },
101
+ "files": [
102
+ {
103
+ "path": "src/foo.ts",
104
+ "what": "What changed in this file (one line is fine).",
105
+ "why": "Why — the decision behind it. REQUIRED for every changed file.",
106
+ "hunks": [
107
+ { "anchor": 42,
108
+ "what": "What this specific change does.",
109
+ "why": "Why this specific change. REQUIRED. Anchor = a line number in the NEW file." }
110
+ ]
111
+ }
112
+ ]
113
+ }
114
+ \`\`\`
115
+
116
+ ### The blast radius (\`risks\`)
117
+
118
+ The \`risks\` array is the change's blast radius — one row per thing it rests on.
119
+ This is the reviewer's highest-value target, so write it to honesty-rule #3:
120
+
121
+ - Each row is an **assumption** (what must be true), the **ifFalse** consequence
122
+ (what breaks if it isn't), and optionally **howYoudKnow** (how to check).
123
+ - If the change genuinely rests on nothing, leave \`risks\` empty — but know that
124
+ review-intent renders an explicit "No risks declared" nudge, because a truly
125
+ assumption-free change is rare. An empty ledger is a claim, not a free pass.
126
+ - **review-intent independently measures several signals and renders them next
127
+ to your ledger — you cannot edit them.** A surface-area scorecard (files,
128
+ ±lines, test-vs-code, debt/debug markers), a file-level reach graph, and the
129
+ **cyclomatic complexity of the changed functions** (hotspots above the repo
130
+ threshold are flagged, via \`lizard\`) all sit beside your claims. If you claim
131
+ "low risk" while the scorecard flags \`touches auth/, 0 test files\` or a
132
+ changed function jumps to CCN 30 and you never mention it, the contradiction is
133
+ visible at a glance.
134
+ - **There is an "honesty quadrant"** that plots your *claimed* candor (declared
135
+ risks + intent coverage) against the *measured* blast radius (churn + reach). A
136
+ large, far-reaching change that declared little lands in a red corner. Padding
137
+ the ledger with throwaway risks to nudge the dot is pointless — a human reads
138
+ the prose. Write a ledger the measured facts won't embarrass: address the gap,
139
+ or name it as a risk. If a hunk added real branching/complexity, the *why* is
140
+ the place to justify it.
141
+
142
+ ### The tests section (\`tests\`)
143
+
144
+ Optional. List the test cases that cover this change, each described in **plain
145
+ language** — the value is a reviewer reading "Cache returns null on a miss"
146
+ instead of decoding \`CacheMiss_ReturnsNull\`. Each entry needs a \`describes\`
147
+ sentence; \`name\` (the real test identifier) and \`kind\` (\`unit\`, \`integration\`,
148
+ \`e2e\`, \`manual\`) are optional — known kinds group the list.
149
+
150
+ Honesty applies here too: describe the tests that **actually exist**, not the
151
+ ones you wish you'd written. If a behaviour is untested, don't invent a case —
152
+ either leave it out or, if it matters, name the gap in the risk ledger
153
+ (\`howYoudKnow\` is often exactly that test). \`describes\` is what the test proves
154
+ for a reader, not a restatement of its name. Omit the section entirely for a
155
+ change with no meaningful tests rather than padding it.
156
+
157
+ ### Anchors
158
+
159
+ \`anchor\` is a line number in the **new** version of the file (the right side of
160
+ the diff). review-intent attaches the note to whichever hunk's new-line range
161
+ contains that anchor. Pick a line inside the change you are explaining. If you
162
+ get it wrong, the note still shows — under "Notes not matched to a hunk" — so it
163
+ is never lost, but aim to land it in the hunk.
164
+
165
+ ### Diagrams (mermaid, authored by you)
166
+
167
+ - **Class diagram**: the types/modules you added or reshaped and their relations.
168
+ Keep it to what the change touches, not the whole system.
169
+ - **Sequence diagram**: a flow the change participates in. Highlight the steps
170
+ that changed using a \`rect\` block or a \`Note\`, e.g.:
171
+
172
+ \`\`\`
173
+ sequenceDiagram
174
+ Caller->>Service: request()
175
+ rect rgb(40,80,50)
176
+ Note over Service: CHANGED: now validates input first
177
+ end
178
+ Service-->>Caller: result
179
+ \`\`\`
180
+
181
+ Omit a diagram if the change genuinely has no structural or sequential story —
182
+ don't draw a trivial two-box diagram to fill the slot.
183
+
184
+ ## After writing it
185
+
186
+ Offer to render — never auto-launch:
187
+
188
+ > I've written the review intent to \`.review/intent.json\`. Want me to open the
189
+ > side-by-side review? (\`review-intent\`)
190
+
191
+ If the user accepts, run \`review-intent\` via Bash from the repo root. It diffs
192
+ the current branch against main and opens the rendered page in the browser.
193
+
194
+ ## Why this exists
195
+
196
+ The friction of hand-writing code used to carry reflection along for free. With
197
+ that friction gone, intent has to be chosen on purpose. This artifact is where
198
+ you pay for it — deliberately, in the reviewer's currency: why, rejected
199
+ alternatives, and assumptions. Render quality is not the hard part; the honesty
200
+ of what you write here is.
201
+ `;
202
+ export function skillFile(opts = {}) {
203
+ const scope = opts.scope ?? "user";
204
+ const root = scope === "local"
205
+ ? (opts.cwd ?? process.cwd())
206
+ : (opts.home ?? os.homedir());
207
+ return path.join(root, ".claude", "skills", SKILL_NAME, "SKILL.md");
208
+ }
209
+ export async function installSkill(opts = {}) {
210
+ const file = skillFile(opts);
211
+ const existing = await readOrNull(file);
212
+ if (existing !== null && canonical(existing) === canonical(SKILL_CONTENT)) {
213
+ return "already";
214
+ }
215
+ if (existing !== null && !opts.force) {
216
+ return "conflict";
217
+ }
218
+ await fs.mkdir(path.dirname(file), { recursive: true });
219
+ await fs.writeFile(file, SKILL_CONTENT, "utf8");
220
+ return existing === null ? "installed" : "updated";
221
+ }
222
+ export async function uninstallSkill(opts = {}) {
223
+ const file = skillFile(opts);
224
+ const existing = await readOrNull(file);
225
+ if (existing === null)
226
+ return "not-installed";
227
+ if (canonical(existing) !== canonical(SKILL_CONTENT) && !opts.force) {
228
+ return "modified";
229
+ }
230
+ await fs.rm(file);
231
+ // Best-effort cleanup of the now-empty skill directory; leave it alone if the
232
+ // user dropped other files in there. Only swallow the expected codes.
233
+ try {
234
+ await fs.rmdir(path.dirname(file));
235
+ }
236
+ catch (err) {
237
+ const code = err.code;
238
+ if (code !== "ENOTEMPTY" && code !== "ENOENT" && code !== "EEXIST")
239
+ throw err;
240
+ }
241
+ return "removed";
242
+ }
243
+ // Normalize CRLF so an editor that rewrote line endings doesn't flip a clean
244
+ // install into a "conflict" / "modified" state.
245
+ function canonical(s) {
246
+ return s.replace(/\r\n/g, "\n");
247
+ }
248
+ async function readOrNull(p) {
249
+ try {
250
+ return await fs.readFile(p, "utf8");
251
+ }
252
+ catch (err) {
253
+ if (err.code === "ENOENT")
254
+ return null;
255
+ throw err;
256
+ }
257
+ }
package/dist/types.js ADDED
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The agent-authored artifact contract. The agent that made the changes writes
4
+ * this file (default `./.review/intent.json`); the CLI only ever reads it.
5
+ */
6
+ export const HunkIntentSchema = z.object({
7
+ /** A line number in the NEW version of the file. The CLI attaches this note
8
+ * to whichever diff hunk's new-line range contains the anchor. */
9
+ anchor: z.number().int().positive(),
10
+ /** What this specific change does. */
11
+ what: z.string().min(1),
12
+ /** Why it was made — the decision, not a restatement of the what. */
13
+ why: z.string().min(1),
14
+ });
15
+ export const FileIntentSchema = z.object({
16
+ path: z.string().min(1),
17
+ /** What changed in this file. */
18
+ what: z.string().min(1),
19
+ /** Why this file changed — the decision behind it (markdown). */
20
+ why: z.string().min(1),
21
+ hunks: z.array(HunkIntentSchema).optional().default([]),
22
+ });
23
+ export const DiagramsSchema = z
24
+ .object({
25
+ /** Mermaid `classDiagram` source, authored by the agent. */
26
+ class: z.string().optional(),
27
+ /** Mermaid `sequenceDiagram` source; changed steps highlighted by the agent. */
28
+ sequence: z.string().optional(),
29
+ })
30
+ .optional()
31
+ .default({});
32
+ /** One row of the agent-authored risk ledger (the "blast radius"). */
33
+ export const RiskSchema = z.object({
34
+ /** Something the change rests on; if false, the change is wrong. */
35
+ assumption: z.string().min(1),
36
+ /** What breaks if the assumption does not hold. */
37
+ ifFalse: z.string().min(1),
38
+ /** How a reviewer could find out whether it holds. Optional. */
39
+ howYoudKnow: z.string().optional(),
40
+ });
41
+ /** One agent-described test case. Pure prose — the renderer never parses or
42
+ * measures it; it sits on the "claimed" side, next to the measured test count. */
43
+ export const TestCaseSchema = z.object({
44
+ /** A short, human-readable sentence: what the test proves. The only required
45
+ * field — the point is a reviewer reading it instead of a cryptic test name. */
46
+ describes: z.string().min(1),
47
+ /** The real test identifier, for cross-reference (e.g. `CacheMiss_ReturnsNull`). */
48
+ name: z.string().optional(),
49
+ /** Free-form kind (e.g. `unit`, `integration`, `e2e`, `manual`). Known kinds
50
+ * get a coloured tag and drive grouping; anything else is shown as-is. Kept a
51
+ * free string so an unusual kind never rejects the artifact. */
52
+ kind: z.string().optional(),
53
+ });
54
+ export const ArtifactSchema = z.object({
55
+ title: z.string().min(1),
56
+ /** One- or two-sentence executive summary, shown as a lede above `overall`. */
57
+ tldr: z.string().min(1),
58
+ /** Why this change set exists, what was rejected, what it rests on (markdown). */
59
+ overall: z.string().min(1),
60
+ diagrams: DiagramsSchema,
61
+ risks: z.array(RiskSchema).optional().default([]),
62
+ /** Human-readable descriptions of the test cases covering the change (claimed). */
63
+ tests: z.array(TestCaseSchema).optional().default([]),
64
+ files: z.array(FileIntentSchema).optional().default([]),
65
+ });
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@christianmorup/review-intent",
3
+ "version": "0.1.0",
4
+ "description": "Render the diff between the current branch and main as an intent-annotated HTML review page with mermaid class & sequence diagrams.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Christian Mørup <cmo@immeo.dk>",
8
+ "homepage": "https://github.com/ChristianMorup/review-intent#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ChristianMorup/review-intent.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/ChristianMorup/review-intent/issues"
15
+ },
16
+ "keywords": [
17
+ "code-review",
18
+ "diff",
19
+ "git",
20
+ "mermaid",
21
+ "cli"
22
+ ],
23
+ "bin": {
24
+ "review-intent": "dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "dev": "node --experimental-strip-types src/cli.ts",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "sample": "tsc && node scripts/gen-sample.mjs",
35
+ "prepare": "npm run build"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "engines": {
41
+ "node": ">=18.17"
42
+ },
43
+ "dependencies": {
44
+ "open": "^10.1.0",
45
+ "parse-diff": "^0.11.1",
46
+ "zod": "^3.23.8"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.0.0",
50
+ "typescript": "^5.6.0",
51
+ "vitest": "^2.1.0"
52
+ }
53
+ }