@izkac/forgekit 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/bin/forge.mjs +100 -0
- package/bin/forgekit.mjs +84 -0
- package/bin/review.mjs +82 -0
- package/package.json +46 -0
- package/scripts/prepack.mjs +78 -0
- package/scripts/run-tests.mjs +43 -0
- package/src/adr.mjs +236 -0
- package/src/adr.test.mjs +170 -0
- package/src/change.mjs +234 -0
- package/src/change.test.mjs +83 -0
- package/src/cleanup-sessions.mjs +70 -0
- package/src/config.mjs +103 -0
- package/src/defer.mjs +75 -0
- package/src/doctor.mjs +341 -0
- package/src/doctor.test.mjs +114 -0
- package/src/init.mjs +575 -0
- package/src/install.mjs +777 -0
- package/src/install.test.mjs +104 -0
- package/src/integrity-check.mjs +58 -0
- package/src/integrity.mjs +317 -0
- package/src/integrity.test.mjs +296 -0
- package/src/lib/workspaces.mjs +55 -0
- package/src/lib.mjs +138 -0
- package/src/models.defaults.json +41 -0
- package/src/new-session.mjs +82 -0
- package/src/openspec-overlays/README.md +19 -0
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -0
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -0
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -0
- package/src/paths.mjs +92 -0
- package/src/plan-engine.mjs +260 -0
- package/src/plan-engine.test.mjs +245 -0
- package/src/preferences.defaults.json +78 -0
- package/src/preferences.mjs +438 -0
- package/src/preferences.test.mjs +174 -0
- package/src/record-evidence.mjs +204 -0
- package/src/record-evidence.test.mjs +260 -0
- package/src/resolve-model.mjs +312 -0
- package/src/resolve-model.test.mjs +194 -0
- package/src/review/carryforward.mjs +413 -0
- package/src/review/carryforward.test.mjs +587 -0
- package/src/review/cli.test.mjs +117 -0
- package/src/review/export.mjs +172 -0
- package/src/review/export.test.mjs +197 -0
- package/src/review/fixtures/valid-review.json +42 -0
- package/src/review/lib.mjs +894 -0
- package/src/review/lib.test.mjs +266 -0
- package/src/review/merge-tentative.mjs +292 -0
- package/src/review/merge-tentative.test.mjs +363 -0
- package/src/review/new-review.mjs +200 -0
- package/src/review/render.mjs +108 -0
- package/src/review/schema-consistency.test.mjs +83 -0
- package/src/review/schema.json +196 -0
- package/src/review/signals.mjs +144 -0
- package/src/review/signals.test.mjs +62 -0
- package/src/score-cli.mjs +68 -0
- package/src/score.mjs +489 -0
- package/src/score.test.mjs +253 -0
- package/src/session-reminder.mjs +168 -0
- package/src/session-status.mjs +70 -0
- package/src/set-models.mjs +186 -0
- package/src/set-phase.mjs +177 -0
- package/src/set-phase.test.mjs +317 -0
- package/src/set-prefs.mjs +294 -0
- package/src/spine.mjs +91 -0
- package/src/triage-prompt.mjs +175 -0
- package/src/triage-prompt.test.mjs +50 -0
- package/src/vendor-openspec-overlays.mjs +176 -0
- package/src/vendor-openspec-overlays.test.mjs +62 -0
- package/vendor/skills/archive-to-adr/SKILL.md +149 -0
- package/vendor/skills/forge/SKILL.md +136 -0
- package/vendor/skills/forge/phases/brainstorm.md +23 -0
- package/vendor/skills/forge/phases/finish.md +87 -0
- package/vendor/skills/forge/phases/implement.md +76 -0
- package/vendor/skills/forge/phases/plan-openspec.md +40 -0
- package/vendor/skills/forge/phases/plan-specs.md +97 -0
- package/vendor/skills/forge/phases/review.md +25 -0
- package/vendor/skills/forge/phases/verify.md +120 -0
- package/vendor/skills/forge/references/forge-layout.md +85 -0
- package/vendor/skills/forge/references/pace.md +115 -0
- package/vendor/skills/forge/references/plan-routing.md +51 -0
- package/vendor/skills/forge/references/runtime-integrity.md +157 -0
- package/vendor/skills/forge/references/substantial-work.md +37 -0
- package/vendor/skills/forge/references/tdd-core.md +29 -0
- package/vendor/skills/forge/references/test-evidence.md +30 -0
- package/vendor/skills/forge/references/test-strategy.md +68 -0
- package/vendor/skills/forge/skills/NOTICE.md +17 -0
- package/vendor/skills/forge/skills/brainstorming/SKILL.md +120 -0
- package/vendor/skills/forge/skills/requesting-code-review/SKILL.md +67 -0
- package/vendor/skills/forge/skills/requesting-code-review/code-reviewer.md +146 -0
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -0
- package/vendor/skills/forge/skills/systematic-debugging/SKILL.md +234 -0
- package/vendor/skills/forge/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/vendor/skills/forge/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/vendor/skills/forge/skills/systematic-debugging/find-polluter.sh +63 -0
- package/vendor/skills/forge/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/vendor/skills/forge/skills/test-driven-development/SKILL.md +290 -0
- package/vendor/skills/forge/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/vendor/skills/forge/skills/verification-before-completion/SKILL.md +59 -0
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +53 -0
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -0
- package/vendor/skills/forge/subagents/task-reviewer-prompt.md +61 -0
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -0
- package/vendor/skills/thorough-code-review/SKILL.md +290 -0
- package/vendor/skills/thorough-code-review/examples/accepted-risks-janus.md +32 -0
- package/vendor/skills/thorough-code-review/examples.md +133 -0
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -0
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -0
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -0
- package/vendor/skills/thorough-code-review/reference/phase1c-coverage.md +44 -0
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -0
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -0
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -0
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -0
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -0
- package/vendor/templates/adr/README.md +7 -0
- package/vendor/templates/adr/decisions.md +141 -0
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -0
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -0
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -0
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -0
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -0
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -0
- package/vendor/templates/project/claude/commands/forge-build.md +17 -0
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -0
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -0
- package/vendor/templates/project/claude/commands/forge-status.md +16 -0
- package/vendor/templates/project/claude/commands/forge.md +16 -0
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -0
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -0
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -0
- package/vendor/templates/project/claude/rules/forge.md +16 -0
- package/vendor/templates/project/codex/rules/forge.md +10 -0
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -0
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -0
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -0
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -0
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -0
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -0
- package/vendor/templates/project/cursor/commands/forge.md +16 -0
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -0
- package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -0
- package/vendor/templates/project/cursor/rules/forge.mdc +21 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import {
|
|
7
|
+
parseArgs,
|
|
8
|
+
locationFile,
|
|
9
|
+
firstLineNumber,
|
|
10
|
+
isDuplicate,
|
|
11
|
+
dedupeFindings,
|
|
12
|
+
renumberFindings,
|
|
13
|
+
runMerge,
|
|
14
|
+
} from './merge-tentative.mjs';
|
|
15
|
+
|
|
16
|
+
function tmp(prefix) {
|
|
17
|
+
return fs.mkdtempSync(path.join(tmpdir(), prefix));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Record<string, unknown>} overrides
|
|
22
|
+
* @returns {Record<string, unknown>}
|
|
23
|
+
*/
|
|
24
|
+
function makeFinding(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
id: 'S1-001',
|
|
27
|
+
lens: 'correctness',
|
|
28
|
+
location: 'src/a.ts:10',
|
|
29
|
+
claim: 'claim text',
|
|
30
|
+
evidence: 'evidence text',
|
|
31
|
+
tentative_severity: 'minor',
|
|
32
|
+
confidence: 'medium',
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} dir
|
|
39
|
+
* @param {string} base
|
|
40
|
+
* @param {unknown} content JSON-serialisable content (or a raw string).
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function writeScoutFile(dir, base, content) {
|
|
44
|
+
const p = path.join(dir, base);
|
|
45
|
+
const text = typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`;
|
|
46
|
+
fs.writeFileSync(p, text, 'utf8');
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// parseArgs
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
test('parseArgs parses --dir and rejects unknown args', () => {
|
|
55
|
+
const opts = parseArgs(['--dir', '.reviews/x-tentative']);
|
|
56
|
+
assert.equal(opts.dir, '.reviews/x-tentative');
|
|
57
|
+
assert.equal(opts.help, false);
|
|
58
|
+
assert.throws(() => parseArgs(['--bogus']), /unknown argument/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('parseArgs defaults dir to null and parses --help', () => {
|
|
62
|
+
assert.equal(parseArgs([]).dir, null);
|
|
63
|
+
assert.equal(parseArgs(['--help']).help, true);
|
|
64
|
+
assert.equal(parseArgs(['-h']).help, true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// pure helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
test('locationFile strips :line and :start-end suffixes', () => {
|
|
72
|
+
assert.equal(locationFile('services/foo/src/bar.ts:42'), 'services/foo/src/bar.ts');
|
|
73
|
+
assert.equal(locationFile('path/to/x.ts:10-20'), 'path/to/x.ts');
|
|
74
|
+
assert.equal(locationFile('path/to/x.ts'), 'path/to/x.ts');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('firstLineNumber extracts the first line number, null when absent', () => {
|
|
78
|
+
assert.equal(firstLineNumber('a.ts:42'), 42);
|
|
79
|
+
assert.equal(firstLineNumber('a.ts:10-20'), 10);
|
|
80
|
+
assert.equal(firstLineNumber('a.ts'), null);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('isDuplicate requires same file, same lens, lines within ±5', () => {
|
|
84
|
+
const base = makeFinding({ location: 'a.ts:10' });
|
|
85
|
+
assert.equal(isDuplicate(base, makeFinding({ id: 'S2-001', location: 'a.ts:15' })), true);
|
|
86
|
+
assert.equal(isDuplicate(base, makeFinding({ id: 'S2-002', location: 'a.ts:5' })), true);
|
|
87
|
+
// 6 lines apart — NOT duplicates.
|
|
88
|
+
assert.equal(isDuplicate(base, makeFinding({ id: 'S2-003', location: 'a.ts:16' })), false);
|
|
89
|
+
assert.equal(isDuplicate(base, makeFinding({ id: 'S2-004', location: 'a.ts:4' })), false);
|
|
90
|
+
// Different file or lens — never duplicates.
|
|
91
|
+
assert.equal(isDuplicate(base, makeFinding({ id: 'S2-005', location: 'b.ts:10' })), false);
|
|
92
|
+
assert.equal(
|
|
93
|
+
isDuplicate(base, makeFinding({ id: 'S2-006', location: 'a.ts:10', lens: 'security' })),
|
|
94
|
+
false,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('isDuplicate: no line number only dupes another no-line finding in the same file', () => {
|
|
99
|
+
const noLine = makeFinding({ id: 'S1-001', location: 'a.ts' });
|
|
100
|
+
assert.equal(isDuplicate(noLine, makeFinding({ id: 'S2-001', location: 'a.ts' })), true);
|
|
101
|
+
assert.equal(isDuplicate(noLine, makeFinding({ id: 'S2-002', location: 'a.ts:3' })), false);
|
|
102
|
+
assert.equal(isDuplicate(makeFinding({ id: 'S2-003', location: 'a.ts:3' }), noLine), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// dedupeFindings
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
test('dedupeFindings keeps higher severity and records loser id in related', () => {
|
|
110
|
+
const minor = makeFinding({ id: 'S1-001', location: 'a.ts:10', tentative_severity: 'minor' });
|
|
111
|
+
const critical = makeFinding({
|
|
112
|
+
id: 'S2-001',
|
|
113
|
+
location: 'a.ts:12',
|
|
114
|
+
tentative_severity: 'critical',
|
|
115
|
+
});
|
|
116
|
+
const { survivors, collapsed } = dedupeFindings([minor, critical]);
|
|
117
|
+
assert.equal(survivors.length, 1);
|
|
118
|
+
assert.equal(collapsed, 1);
|
|
119
|
+
assert.equal(survivors[0].id, 'S2-001');
|
|
120
|
+
assert.equal(survivors[0].tentative_severity, 'critical');
|
|
121
|
+
assert.deepEqual(survivors[0].related, ['S1-001']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('dedupeFindings ties on severity break by higher confidence', () => {
|
|
125
|
+
const low = makeFinding({ id: 'S1-001', location: 'a.ts:10', confidence: 'low' });
|
|
126
|
+
const high = makeFinding({ id: 'S2-001', location: 'a.ts:11', confidence: 'high' });
|
|
127
|
+
const { survivors } = dedupeFindings([low, high]);
|
|
128
|
+
assert.equal(survivors.length, 1);
|
|
129
|
+
assert.equal(survivors[0].id, 'S2-001');
|
|
130
|
+
assert.deepEqual(survivors[0].related, ['S1-001']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('dedupeFindings full tie keeps the first encountered', () => {
|
|
134
|
+
const first = makeFinding({ id: 'S1-001', location: 'a.ts:10' });
|
|
135
|
+
const second = makeFinding({ id: 'S2-001', location: 'a.ts:10' });
|
|
136
|
+
const { survivors } = dedupeFindings([first, second]);
|
|
137
|
+
assert.equal(survivors.length, 1);
|
|
138
|
+
assert.equal(survivors[0].id, 'S1-001');
|
|
139
|
+
assert.deepEqual(survivors[0].related, ['S2-001']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('dedupeFindings appends to an existing related array without losing entries', () => {
|
|
143
|
+
const winner = makeFinding({
|
|
144
|
+
id: 'S1-001',
|
|
145
|
+
location: 'a.ts:10',
|
|
146
|
+
tentative_severity: 'important',
|
|
147
|
+
related: ['S0-009'],
|
|
148
|
+
});
|
|
149
|
+
const loser = makeFinding({ id: 'S2-001', location: 'a.ts:12', tentative_severity: 'minor' });
|
|
150
|
+
const { survivors } = dedupeFindings([winner, loser]);
|
|
151
|
+
assert.deepEqual(survivors[0].related, ['S0-009', 'S2-001']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('dedupeFindings does not collapse findings 6 lines apart', () => {
|
|
155
|
+
const a = makeFinding({ id: 'S1-001', location: 'a.ts:10' });
|
|
156
|
+
const b = makeFinding({ id: 'S2-001', location: 'a.ts:16' });
|
|
157
|
+
const { survivors, collapsed } = dedupeFindings([a, b]);
|
|
158
|
+
assert.equal(survivors.length, 2);
|
|
159
|
+
assert.equal(collapsed, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// renumberFindings
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
test('renumberFindings sorts by (file, first line, original id) and assigns F-###', () => {
|
|
167
|
+
const findings = [
|
|
168
|
+
makeFinding({ id: 'S3-001', location: 'z.ts:5' }),
|
|
169
|
+
makeFinding({ id: 'S1-002', location: 'a.ts:100' }),
|
|
170
|
+
makeFinding({ id: 'S2-001', location: 'a.ts:1' }),
|
|
171
|
+
makeFinding({ id: 'S1-001', location: 'a.ts:1' }),
|
|
172
|
+
];
|
|
173
|
+
const renumbered = renumberFindings(findings);
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
renumbered.map((f) => [f.id, f.location]),
|
|
176
|
+
[
|
|
177
|
+
['F-001', 'a.ts:1'], // S1-001 before S2-001 (original id tie-break)
|
|
178
|
+
['F-002', 'a.ts:1'],
|
|
179
|
+
['F-003', 'a.ts:100'],
|
|
180
|
+
['F-004', 'z.ts:5'],
|
|
181
|
+
],
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('renumberFindings orders no-line findings before numbered ones in the same file', () => {
|
|
186
|
+
const renumbered = renumberFindings([
|
|
187
|
+
makeFinding({ id: 'S1-001', location: 'a.ts:2' }),
|
|
188
|
+
makeFinding({ id: 'S2-001', location: 'a.ts' }),
|
|
189
|
+
]);
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
renumbered.map((f) => [f.id, f.location]),
|
|
192
|
+
[
|
|
193
|
+
['F-001', 'a.ts'],
|
|
194
|
+
['F-002', 'a.ts:2'],
|
|
195
|
+
],
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// runMerge — end-to-end against a temp dir
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
test('runMerge merges findings across 3 scout files and writes merged.json', () => {
|
|
204
|
+
const dir = tmp('review-merge-');
|
|
205
|
+
try {
|
|
206
|
+
writeScoutFile(dir, 'scout-1.json', {
|
|
207
|
+
findings: [makeFinding({ id: 'S1-001', location: 'src/b.ts:20' })],
|
|
208
|
+
});
|
|
209
|
+
writeScoutFile(dir, 'scout-2.json', {
|
|
210
|
+
findings: [
|
|
211
|
+
makeFinding({ id: 'S2-001', location: 'src/a.ts:5', lens: 'security' }),
|
|
212
|
+
makeFinding({ id: 'S2-002', location: 'src/c.ts:1' }),
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
writeScoutFile(dir, 'scout-3.json', {
|
|
216
|
+
findings: [makeFinding({ id: 'S3-001', location: 'src/a.ts:50' })],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const result = runMerge({ dir });
|
|
220
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
221
|
+
|
|
222
|
+
const merged = JSON.parse(fs.readFileSync(path.join(dir, 'merged.json'), 'utf8'));
|
|
223
|
+
assert.equal(merged.findings.length, 4);
|
|
224
|
+
assert.deepEqual(
|
|
225
|
+
merged.findings.map((f) => f.id),
|
|
226
|
+
['F-001', 'F-002', 'F-003', 'F-004'],
|
|
227
|
+
);
|
|
228
|
+
assert.deepEqual(
|
|
229
|
+
merged.findings.map((f) => f.location),
|
|
230
|
+
['src/a.ts:5', 'src/a.ts:50', 'src/b.ts:20', 'src/c.ts:1'],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Per-file counts, duplicates collapsed, final count in the message.
|
|
234
|
+
assert.ok(result.message.includes('scout-1.json: 1'));
|
|
235
|
+
assert.ok(result.message.includes('scout-2.json: 2'));
|
|
236
|
+
assert.ok(result.message.includes('scout-3.json: 1'));
|
|
237
|
+
assert.ok(/collapsed:\s*0/.test(result.message));
|
|
238
|
+
assert.ok(/final:\s*4/.test(result.message));
|
|
239
|
+
} finally {
|
|
240
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('runMerge dedupes across files, keeps higher severity, records loser in related', () => {
|
|
245
|
+
const dir = tmp('review-merge-dupe-');
|
|
246
|
+
try {
|
|
247
|
+
writeScoutFile(dir, 'scout-1.json', {
|
|
248
|
+
findings: [
|
|
249
|
+
makeFinding({ id: 'S1-001', location: 'src/a.ts:10', tentative_severity: 'minor' }),
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
writeScoutFile(dir, 'scout-2.json', {
|
|
253
|
+
findings: [
|
|
254
|
+
makeFinding({ id: 'S2-001', location: 'src/a.ts:13', tentative_severity: 'critical' }),
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const result = runMerge({ dir });
|
|
259
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
260
|
+
|
|
261
|
+
const merged = JSON.parse(fs.readFileSync(path.join(dir, 'merged.json'), 'utf8'));
|
|
262
|
+
assert.equal(merged.findings.length, 1);
|
|
263
|
+
assert.equal(merged.findings[0].id, 'F-001');
|
|
264
|
+
assert.equal(merged.findings[0].tentative_severity, 'critical');
|
|
265
|
+
assert.deepEqual(merged.findings[0].related, ['S1-001']);
|
|
266
|
+
assert.ok(/collapsed:\s*1/.test(result.message));
|
|
267
|
+
assert.ok(/final:\s*1/.test(result.message));
|
|
268
|
+
} finally {
|
|
269
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('runMerge honors the ±5 window — 6 lines apart stay separate', () => {
|
|
274
|
+
const dir = tmp('review-merge-window-');
|
|
275
|
+
try {
|
|
276
|
+
writeScoutFile(dir, 'scout-1.json', {
|
|
277
|
+
findings: [makeFinding({ id: 'S1-001', location: 'src/a.ts:10' })],
|
|
278
|
+
});
|
|
279
|
+
writeScoutFile(dir, 'scout-2.json', {
|
|
280
|
+
findings: [makeFinding({ id: 'S2-001', location: 'src/a.ts:16' })],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = runMerge({ dir });
|
|
284
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
285
|
+
const merged = JSON.parse(fs.readFileSync(path.join(dir, 'merged.json'), 'utf8'));
|
|
286
|
+
assert.equal(merged.findings.length, 2);
|
|
287
|
+
} finally {
|
|
288
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('runMerge ignores an existing merged.json as input', () => {
|
|
293
|
+
const dir = tmp('review-merge-rerun-');
|
|
294
|
+
try {
|
|
295
|
+
writeScoutFile(dir, 'scout-1.json', {
|
|
296
|
+
findings: [makeFinding({ id: 'S1-001', location: 'src/a.ts:10' })],
|
|
297
|
+
});
|
|
298
|
+
writeScoutFile(dir, 'merged.json', {
|
|
299
|
+
findings: [makeFinding({ id: 'F-099', location: 'stale.ts:1' })],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const result = runMerge({ dir });
|
|
303
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
304
|
+
const merged = JSON.parse(fs.readFileSync(path.join(dir, 'merged.json'), 'utf8'));
|
|
305
|
+
assert.equal(merged.findings.length, 1);
|
|
306
|
+
assert.equal(merged.findings[0].location, 'src/a.ts:10');
|
|
307
|
+
} finally {
|
|
308
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('runMerge exits 1 naming the file on invalid JSON and writes nothing', () => {
|
|
313
|
+
const dir = tmp('review-merge-badjson-');
|
|
314
|
+
try {
|
|
315
|
+
writeScoutFile(dir, 'scout-1.json', {
|
|
316
|
+
findings: [makeFinding({ id: 'S1-001' })],
|
|
317
|
+
});
|
|
318
|
+
writeScoutFile(dir, 'scout-2.json', '{ not valid json !!!');
|
|
319
|
+
|
|
320
|
+
const result = runMerge({ dir });
|
|
321
|
+
assert.equal(result.exitCode, 1);
|
|
322
|
+
assert.ok(result.message.includes('scout-2.json'));
|
|
323
|
+
assert.equal(fs.existsSync(path.join(dir, 'merged.json')), false);
|
|
324
|
+
} finally {
|
|
325
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('runMerge exits 1 naming the file when findings array is missing', () => {
|
|
330
|
+
const dir = tmp('review-merge-nofindings-');
|
|
331
|
+
try {
|
|
332
|
+
writeScoutFile(dir, 'scout-1.json', {
|
|
333
|
+
findings: [makeFinding({ id: 'S1-001' })],
|
|
334
|
+
});
|
|
335
|
+
writeScoutFile(dir, 'scout-2.json', { results: [] });
|
|
336
|
+
|
|
337
|
+
const result = runMerge({ dir });
|
|
338
|
+
assert.equal(result.exitCode, 1);
|
|
339
|
+
assert.ok(result.message.includes('scout-2.json'));
|
|
340
|
+
assert.ok(/findings/.test(result.message));
|
|
341
|
+
assert.equal(fs.existsSync(path.join(dir, 'merged.json')), false);
|
|
342
|
+
} finally {
|
|
343
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('runMerge exits 1 without --dir, on a missing dir, and on an empty dir', () => {
|
|
348
|
+
const noDir = runMerge({ dir: null });
|
|
349
|
+
assert.equal(noDir.exitCode, 1);
|
|
350
|
+
assert.ok(/--dir/.test(noDir.message));
|
|
351
|
+
|
|
352
|
+
const missing = runMerge({ dir: path.join(tmpdir(), 'no-such-review-merge-dir') });
|
|
353
|
+
assert.equal(missing.exitCode, 1);
|
|
354
|
+
|
|
355
|
+
const dir = tmp('review-merge-empty-');
|
|
356
|
+
try {
|
|
357
|
+
const empty = runMerge({ dir });
|
|
358
|
+
assert.equal(empty.exitCode, 1);
|
|
359
|
+
assert.ok(/no input/i.test(empty.message));
|
|
360
|
+
} finally {
|
|
361
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Scaffold a new thorough-code-review JSON sidecar under `.reviews/`.
|
|
4
|
+
*
|
|
5
|
+
* Removes the hand-generated-timestamp / slug / git-SHA failure mode: this
|
|
6
|
+
* stamps the canonical ISO-UTC time, derives the review id, captures base/head
|
|
7
|
+
* SHAs from git, and writes a schema-valid skeleton with empty findings. The
|
|
8
|
+
* agent then fills in findings and runs `review:render` + `review:export`.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* review new <scope-slug> [options]
|
|
12
|
+
*
|
|
13
|
+
* Options:
|
|
14
|
+
* --type <t> uncommitted | branch | paths | commit_range | file
|
|
15
|
+
* --kind <k> review | reverify (default: review)
|
|
16
|
+
* --description "..." Human scope description
|
|
17
|
+
* --lenses a,b,c Comma list (default: all nine)
|
|
18
|
+
* --paths p1,p2 Comma list of scoped paths
|
|
19
|
+
* --parent <id> Parent review_id (required for --kind reverify)
|
|
20
|
+
* --base-branch <b> Merge-base branch for --type branch (default: main)
|
|
21
|
+
* --reviews-dir <dir> Output dir (default: .reviews)
|
|
22
|
+
* --no-git Skip git SHA capture
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { pathToFileURL } from 'node:url';
|
|
29
|
+
import { REVIEWS_DIR, LENSES, buildReviewSkeleton } from './lib.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string[]} argv
|
|
33
|
+
*/
|
|
34
|
+
export function parseArgs(argv) {
|
|
35
|
+
const opts = {
|
|
36
|
+
slug: null,
|
|
37
|
+
type: 'branch',
|
|
38
|
+
kind: 'review',
|
|
39
|
+
description: '',
|
|
40
|
+
lenses: [...LENSES],
|
|
41
|
+
paths: /** @type {string[]} */ ([]),
|
|
42
|
+
parent: null,
|
|
43
|
+
baseBranch: 'main',
|
|
44
|
+
reviewsDir: REVIEWS_DIR,
|
|
45
|
+
git: true,
|
|
46
|
+
help: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
50
|
+
const arg = argv[i];
|
|
51
|
+
if (arg === '--type') opts.type = argv[++i];
|
|
52
|
+
else if (arg === '--kind') opts.kind = argv[++i];
|
|
53
|
+
else if (arg === '--description') opts.description = argv[++i];
|
|
54
|
+
else if (arg === '--lenses') opts.lenses = splitList(argv[++i]);
|
|
55
|
+
else if (arg === '--paths') opts.paths = splitList(argv[++i]);
|
|
56
|
+
else if (arg === '--parent') opts.parent = argv[++i];
|
|
57
|
+
else if (arg === '--base-branch') opts.baseBranch = argv[++i];
|
|
58
|
+
else if (arg === '--reviews-dir') opts.reviewsDir = argv[++i];
|
|
59
|
+
else if (arg === '--no-git') opts.git = false;
|
|
60
|
+
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
61
|
+
else if (arg.startsWith('--')) throw new Error(`unknown argument: ${arg}`);
|
|
62
|
+
else if (opts.slug == null) opts.slug = arg;
|
|
63
|
+
else throw new Error(`unexpected positional argument: ${arg}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return opts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} value
|
|
71
|
+
* @returns {string[]}
|
|
72
|
+
*/
|
|
73
|
+
function splitList(value) {
|
|
74
|
+
return String(value)
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} cwd
|
|
82
|
+
* @param {string} baseBranch
|
|
83
|
+
* @param {('uncommitted'|'branch'|'paths'|'commit_range'|'file')} type
|
|
84
|
+
* @returns {{ baseSha?: string, headSha?: string }}
|
|
85
|
+
*/
|
|
86
|
+
function captureGit(cwd, baseBranch, type) {
|
|
87
|
+
const run = (args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
88
|
+
/** @type {{ baseSha?: string, headSha?: string }} */
|
|
89
|
+
const out = {};
|
|
90
|
+
try {
|
|
91
|
+
out.headSha = run(['rev-parse', 'HEAD']);
|
|
92
|
+
} catch {
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
if (type === 'branch') {
|
|
96
|
+
try {
|
|
97
|
+
out.baseSha = run(['merge-base', baseBranch, 'HEAD']);
|
|
98
|
+
} catch {
|
|
99
|
+
/* base branch may not exist locally — leave undefined */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {ReturnType<typeof parseArgs>} opts
|
|
107
|
+
* @param {{ now: Date, cwd?: string, gitImpl?: typeof captureGit }} ctx
|
|
108
|
+
* @returns {{ exitCode: number; message: string; jsonPath?: string }}
|
|
109
|
+
*/
|
|
110
|
+
export function runNew(opts, ctx) {
|
|
111
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
112
|
+
if (!opts.slug) {
|
|
113
|
+
return { exitCode: 1, message: 'a scope-slug positional argument is required' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const git = opts.git ? (ctx.gitImpl ?? captureGit)(cwd, opts.baseBranch, opts.type) : {};
|
|
117
|
+
|
|
118
|
+
let built;
|
|
119
|
+
try {
|
|
120
|
+
built = buildReviewSkeleton({
|
|
121
|
+
slug: opts.slug,
|
|
122
|
+
type: opts.type,
|
|
123
|
+
kind: opts.kind,
|
|
124
|
+
description: opts.description,
|
|
125
|
+
lenses: opts.lenses,
|
|
126
|
+
paths: opts.paths,
|
|
127
|
+
parentReport: opts.parent ?? undefined,
|
|
128
|
+
baseSha: git.baseSha,
|
|
129
|
+
headSha: git.headSha,
|
|
130
|
+
now: ctx.now,
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return { exitCode: 1, message: /** @type {Error} */ (err).message };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const reviewsDir = path.resolve(cwd, opts.reviewsDir ?? REVIEWS_DIR);
|
|
137
|
+
fs.mkdirSync(reviewsDir, { recursive: true });
|
|
138
|
+
const jsonPath = path.join(reviewsDir, `${built.fileBase}.json`);
|
|
139
|
+
|
|
140
|
+
if (fs.existsSync(jsonPath)) {
|
|
141
|
+
return { exitCode: 1, message: `refusing to overwrite existing report: ${jsonPath}` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fs.writeFileSync(jsonPath, `${JSON.stringify(built.report, null, 2)}\n`, 'utf8');
|
|
145
|
+
|
|
146
|
+
const message = [
|
|
147
|
+
`Scaffolded review: ${jsonPath}`,
|
|
148
|
+
` review_id: ${built.reviewId}`,
|
|
149
|
+
` lenses: ${opts.lenses.join(', ')}`,
|
|
150
|
+
git.headSha ? ` head_sha: ${git.headSha}` : ' head_sha: (git capture skipped)',
|
|
151
|
+
'',
|
|
152
|
+
'Next: fill findings in the JSON, then:',
|
|
153
|
+
` review render --file ${path.relative(cwd, jsonPath)}`,
|
|
154
|
+
' review export',
|
|
155
|
+
].join('\n');
|
|
156
|
+
|
|
157
|
+
return { exitCode: 0, message, jsonPath };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function printHelp() {
|
|
161
|
+
console.log(`Usage: review new <scope-slug> [options]
|
|
162
|
+
|
|
163
|
+
Scaffold a schema-valid review JSON skeleton under .reviews/.
|
|
164
|
+
|
|
165
|
+
Options:
|
|
166
|
+
--type <t> uncommitted | branch | paths | commit_range | file (default: branch)
|
|
167
|
+
--kind <k> review | reverify (default: review)
|
|
168
|
+
--description "..." Human scope description
|
|
169
|
+
--lenses a,b,c Comma list of lenses (default: all nine)
|
|
170
|
+
--paths p1,p2 Comma list of scoped paths
|
|
171
|
+
--parent <id> Parent review_id (required for --kind reverify)
|
|
172
|
+
--base-branch <b> Merge-base branch for --type branch (default: main)
|
|
173
|
+
--reviews-dir <dir> Output directory (default: .reviews)
|
|
174
|
+
--no-git Skip git SHA capture
|
|
175
|
+
-h, --help Show this help
|
|
176
|
+
`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function main() {
|
|
180
|
+
try {
|
|
181
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
182
|
+
if (opts.help) {
|
|
183
|
+
printHelp();
|
|
184
|
+
process.exit(0);
|
|
185
|
+
}
|
|
186
|
+
const result = runNew(opts, { now: new Date() });
|
|
187
|
+
console.log(result.message);
|
|
188
|
+
process.exit(result.exitCode);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(/** @type {Error} */ (err).message);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const isMain =
|
|
196
|
+
process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
197
|
+
|
|
198
|
+
if (isMain) {
|
|
199
|
+
main();
|
|
200
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Render the human-readable Markdown report from a review JSON sidecar.
|
|
4
|
+
*
|
|
5
|
+
* JSON is the single source of truth; this regenerates the paired `.md` so the
|
|
6
|
+
* two can never drift. Validates before rendering.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* review render [--file <json>] [--reviews-dir <dir>] [--stdout]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
import { REVIEWS_DIR, findLatestReviewJson, renderMarkdown, validateReport } from './lib.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string[]} argv
|
|
19
|
+
*/
|
|
20
|
+
export function parseArgs(argv) {
|
|
21
|
+
const opts = { file: null, reviewsDir: REVIEWS_DIR, stdout: false, help: false };
|
|
22
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
23
|
+
const arg = argv[i];
|
|
24
|
+
if (arg === '--file') opts.file = argv[++i];
|
|
25
|
+
else if (arg === '--reviews-dir') opts.reviewsDir = argv[++i];
|
|
26
|
+
else if (arg === '--stdout') opts.stdout = true;
|
|
27
|
+
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
28
|
+
else throw new Error(`unknown argument: ${arg}`);
|
|
29
|
+
}
|
|
30
|
+
return opts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {ReturnType<typeof parseArgs>} opts
|
|
35
|
+
* @param {string} [cwd]
|
|
36
|
+
* @returns {{ exitCode: number; message: string }}
|
|
37
|
+
*/
|
|
38
|
+
export function runRender(opts, cwd = process.cwd()) {
|
|
39
|
+
const reviewsDir = path.resolve(cwd, opts.reviewsDir ?? REVIEWS_DIR);
|
|
40
|
+
const jsonPath = opts.file
|
|
41
|
+
? path.isAbsolute(opts.file)
|
|
42
|
+
? opts.file
|
|
43
|
+
: path.resolve(cwd, opts.file)
|
|
44
|
+
: findLatestReviewJson(reviewsDir);
|
|
45
|
+
|
|
46
|
+
if (!jsonPath) return { exitCode: 1, message: `no review JSON found in ${reviewsDir}` };
|
|
47
|
+
if (!fs.existsSync(jsonPath)) return { exitCode: 1, message: `file not found: ${jsonPath}` };
|
|
48
|
+
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { exitCode: 1, message: `invalid JSON: ${/** @type {Error} */ (err).message}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const validation = validateReport(parsed);
|
|
57
|
+
if (!validation.ok) {
|
|
58
|
+
return {
|
|
59
|
+
exitCode: 1,
|
|
60
|
+
message: `validation failed for ${jsonPath}:\n${validation.errors
|
|
61
|
+
.map((e) => ` - ${e}`)
|
|
62
|
+
.join('\n')}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const md = `${renderMarkdown(validation.report)}\n`;
|
|
67
|
+
if (opts.stdout) return { exitCode: 0, message: md };
|
|
68
|
+
|
|
69
|
+
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
|
70
|
+
fs.writeFileSync(mdPath, md, 'utf8');
|
|
71
|
+
return { exitCode: 0, message: `rendered: ${mdPath}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printHelp() {
|
|
75
|
+
console.log(`Usage: review render [options]
|
|
76
|
+
|
|
77
|
+
Regenerates the paired Markdown from a review JSON sidecar.
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
--file <path> Review JSON (default: latest *-review.json in .reviews/)
|
|
81
|
+
--reviews-dir <dir> Reviews directory (default: .reviews)
|
|
82
|
+
--stdout Print Markdown to stdout instead of writing the .md file
|
|
83
|
+
-h, --help Show this help
|
|
84
|
+
`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function main() {
|
|
88
|
+
try {
|
|
89
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
90
|
+
if (opts.help) {
|
|
91
|
+
printHelp();
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
const result = runRender(opts);
|
|
95
|
+
process.stdout.write(result.message.endsWith('\n') ? result.message : `${result.message}\n`);
|
|
96
|
+
process.exit(result.exitCode);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(/** @type {Error} */ (err).message);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isMain =
|
|
104
|
+
process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
105
|
+
|
|
106
|
+
if (isMain) {
|
|
107
|
+
main();
|
|
108
|
+
}
|