@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,587 @@
|
|
|
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 { execFileSync } from 'node:child_process';
|
|
7
|
+
import {
|
|
8
|
+
parseArgs,
|
|
9
|
+
locationFile,
|
|
10
|
+
nextFindingId,
|
|
11
|
+
buildCarriedFinding,
|
|
12
|
+
recomputeSummary,
|
|
13
|
+
planCarries,
|
|
14
|
+
runCarryforward,
|
|
15
|
+
} from './carryforward.mjs';
|
|
16
|
+
import { validateReport } from './lib.mjs';
|
|
17
|
+
|
|
18
|
+
function tmp(prefix) {
|
|
19
|
+
return fs.mkdtempSync(path.join(tmpdir(), prefix));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} cwd
|
|
24
|
+
* @param {string[]} args
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function git(cwd, args) {
|
|
28
|
+
return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], {
|
|
29
|
+
cwd,
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
}).trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Scratch repo with two commits: parent SHA has a.txt + b.txt; HEAD changes b.txt.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} repoDir
|
|
38
|
+
* @returns {{ parentSha: string, headSha: string }}
|
|
39
|
+
*/
|
|
40
|
+
function makeScratchRepo(repoDir) {
|
|
41
|
+
git(repoDir, ['init', '-q']);
|
|
42
|
+
fs.writeFileSync(path.join(repoDir, 'a.txt'), 'alpha\n');
|
|
43
|
+
fs.writeFileSync(path.join(repoDir, 'b.txt'), 'beta\n');
|
|
44
|
+
git(repoDir, ['add', '.']);
|
|
45
|
+
git(repoDir, ['commit', '-q', '-m', 'first']);
|
|
46
|
+
const parentSha = git(repoDir, ['rev-parse', 'HEAD']);
|
|
47
|
+
fs.writeFileSync(path.join(repoDir, 'b.txt'), 'beta changed\n');
|
|
48
|
+
git(repoDir, ['add', '.']);
|
|
49
|
+
git(repoDir, ['commit', '-q', '-m', 'second']);
|
|
50
|
+
const headSha = git(repoDir, ['rev-parse', 'HEAD']);
|
|
51
|
+
return { parentSha, headSha };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {Record<string, unknown>} overrides
|
|
56
|
+
* @returns {Record<string, unknown>}
|
|
57
|
+
*/
|
|
58
|
+
function makeParentReport(overrides = {}) {
|
|
59
|
+
return {
|
|
60
|
+
review_id: '20260101T000000Z-parent-scope',
|
|
61
|
+
kind: 'review',
|
|
62
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
63
|
+
scope: { type: 'branch', description: 'parent scope' },
|
|
64
|
+
lenses: ['correctness'],
|
|
65
|
+
summary: { tentative_count: 3, confirmed: 3 },
|
|
66
|
+
findings: [
|
|
67
|
+
{
|
|
68
|
+
id: 'F-001',
|
|
69
|
+
lens: 'correctness',
|
|
70
|
+
location: 'a.txt:10',
|
|
71
|
+
claim: 'unchanged file finding',
|
|
72
|
+
evidence: 'snippet-a',
|
|
73
|
+
severity: 'important',
|
|
74
|
+
verdict: 'confirmed',
|
|
75
|
+
verdict_reason: 'original reason A',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'F-002',
|
|
79
|
+
lens: 'correctness',
|
|
80
|
+
location: 'b.txt:5-8',
|
|
81
|
+
claim: 'changed file finding',
|
|
82
|
+
severity: 'minor',
|
|
83
|
+
verdict: 'confirmed',
|
|
84
|
+
verdict_reason: 'original reason B',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'F-003',
|
|
88
|
+
lens: 'correctness',
|
|
89
|
+
location: 'missing.txt:1',
|
|
90
|
+
claim: 'file absent at parent sha',
|
|
91
|
+
severity: 'minor',
|
|
92
|
+
verdict: 'confirmed',
|
|
93
|
+
verdict_reason: 'original reason C',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
...overrides,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {Record<string, unknown>} overrides
|
|
102
|
+
* @returns {Record<string, unknown>}
|
|
103
|
+
*/
|
|
104
|
+
function makeTargetReport(overrides = {}) {
|
|
105
|
+
return {
|
|
106
|
+
review_id: '20260201T000000Z-target-scope',
|
|
107
|
+
kind: 'review',
|
|
108
|
+
created_at: '2026-02-01T00:00:00.000Z',
|
|
109
|
+
scope: { type: 'branch', description: 'target scope' },
|
|
110
|
+
lenses: ['correctness'],
|
|
111
|
+
summary: { tentative_count: 1, confirmed: 1 },
|
|
112
|
+
findings: [
|
|
113
|
+
{
|
|
114
|
+
id: 'F-001',
|
|
115
|
+
lens: 'correctness',
|
|
116
|
+
location: 'other.txt:1',
|
|
117
|
+
claim: 'pre-existing target finding',
|
|
118
|
+
severity: 'minor',
|
|
119
|
+
verdict: 'confirmed',
|
|
120
|
+
verdict_reason: 'target reason',
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
...overrides,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} dir
|
|
129
|
+
* @param {string} base
|
|
130
|
+
* @param {Record<string, unknown>} report
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
function writeReport(dir, base, report) {
|
|
134
|
+
const p = path.join(dir, base);
|
|
135
|
+
fs.writeFileSync(p, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
136
|
+
return p;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// parseArgs
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test('parseArgs parses parent, file, dry-run, reviews-dir and repo', () => {
|
|
144
|
+
const opts = parseArgs([
|
|
145
|
+
'--parent', '20260101T000000Z-parent-scope',
|
|
146
|
+
'--file', 'target.json',
|
|
147
|
+
'--dry-run',
|
|
148
|
+
'--reviews-dir', 'rdir',
|
|
149
|
+
'--repo', 'repodir',
|
|
150
|
+
]);
|
|
151
|
+
assert.equal(opts.parent, '20260101T000000Z-parent-scope');
|
|
152
|
+
assert.equal(opts.file, 'target.json');
|
|
153
|
+
assert.equal(opts.dryRun, true);
|
|
154
|
+
assert.equal(opts.reviewsDir, 'rdir');
|
|
155
|
+
assert.equal(opts.repo, 'repodir');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('parseArgs defaults and unknown-arg rejection', () => {
|
|
159
|
+
const opts = parseArgs(['--parent', 'x']);
|
|
160
|
+
assert.equal(opts.file, null);
|
|
161
|
+
assert.equal(opts.dryRun, false);
|
|
162
|
+
assert.equal(opts.reviewsDir, '.reviews');
|
|
163
|
+
assert.equal(opts.repo, null);
|
|
164
|
+
assert.throws(() => parseArgs(['--bogus']), /unknown argument/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// pure helpers
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
test('locationFile strips :line and :start-end suffixes', () => {
|
|
172
|
+
assert.equal(locationFile('a.ts:42'), 'a.ts');
|
|
173
|
+
assert.equal(locationFile('src/lib/a.ts:10-20'), 'src/lib/a.ts');
|
|
174
|
+
assert.equal(locationFile('src/lib/a.ts'), 'src/lib/a.ts');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('nextFindingId continues F-### numbering, ignoring dup ids', () => {
|
|
178
|
+
assert.equal(nextFindingId([]), 'F-001');
|
|
179
|
+
assert.equal(
|
|
180
|
+
nextFindingId([{ id: 'F-001' }, { id: 'F-003' }, { id: 'dup-009' }]),
|
|
181
|
+
'F-004',
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('buildCarriedFinding preserves fields and prefixes verdict_reason', () => {
|
|
186
|
+
const parentFinding = {
|
|
187
|
+
id: 'F-007',
|
|
188
|
+
lens: 'security',
|
|
189
|
+
location: 'a.ts:1',
|
|
190
|
+
claim: 'claim text',
|
|
191
|
+
evidence: 'evidence text',
|
|
192
|
+
severity: 'important',
|
|
193
|
+
verdict: 'confirmed',
|
|
194
|
+
verdict_reason: 'original reason',
|
|
195
|
+
};
|
|
196
|
+
const carried = buildCarriedFinding(parentFinding, {
|
|
197
|
+
id: 'F-004',
|
|
198
|
+
parentReviewId: '20260101T000000Z-parent-scope',
|
|
199
|
+
shortSha: 'abcdef12',
|
|
200
|
+
});
|
|
201
|
+
assert.equal(carried.id, 'F-004');
|
|
202
|
+
assert.equal(carried.lens, 'security');
|
|
203
|
+
assert.equal(carried.location, 'a.ts:1');
|
|
204
|
+
assert.equal(carried.claim, 'claim text');
|
|
205
|
+
assert.equal(carried.evidence, 'evidence text');
|
|
206
|
+
assert.equal(carried.severity, 'important');
|
|
207
|
+
assert.equal(carried.verdict, 'confirmed');
|
|
208
|
+
assert.equal(
|
|
209
|
+
carried.verdict_reason,
|
|
210
|
+
'Carried forward from 20260101T000000Z-parent-scope (unchanged since abcdef12): original reason',
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('buildCarriedFinding keeps original_severity for downgraded findings', () => {
|
|
215
|
+
const carried = buildCarriedFinding(
|
|
216
|
+
{
|
|
217
|
+
id: 'F-001',
|
|
218
|
+
lens: 'correctness',
|
|
219
|
+
location: 'a.ts:1',
|
|
220
|
+
claim: 'c',
|
|
221
|
+
severity: 'minor',
|
|
222
|
+
original_severity: 'important',
|
|
223
|
+
verdict: 'downgraded',
|
|
224
|
+
verdict_reason: 'r',
|
|
225
|
+
},
|
|
226
|
+
{ id: 'F-002', parentReviewId: 'p', shortSha: '12345678' },
|
|
227
|
+
);
|
|
228
|
+
assert.equal(carried.original_severity, 'important');
|
|
229
|
+
assert.equal(carried.verdict, 'downgraded');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('recomputeSummary reconciles verdict counts and tentative_count', () => {
|
|
233
|
+
const report = makeTargetReport({
|
|
234
|
+
summary: { tentative_count: 1, confirmed: 1, headline: 'keep me' },
|
|
235
|
+
findings: [
|
|
236
|
+
...makeTargetReport().findings,
|
|
237
|
+
{
|
|
238
|
+
id: 'F-002',
|
|
239
|
+
lens: 'correctness',
|
|
240
|
+
location: 'a.txt:10',
|
|
241
|
+
claim: 'carried',
|
|
242
|
+
severity: 'important',
|
|
243
|
+
verdict: 'confirmed',
|
|
244
|
+
verdict_reason: 'r',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: 'F-003',
|
|
248
|
+
lens: 'correctness',
|
|
249
|
+
location: 'a.txt:11',
|
|
250
|
+
claim: 'fp',
|
|
251
|
+
severity: 'minor',
|
|
252
|
+
verdict: 'false_positive',
|
|
253
|
+
verdict_reason: 'r',
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
recomputeSummary(report);
|
|
258
|
+
assert.equal(report.summary.confirmed, 2);
|
|
259
|
+
assert.equal(report.summary.false_positive, 1);
|
|
260
|
+
assert.equal(report.summary.headline, 'keep me');
|
|
261
|
+
assert.ok(report.summary.tentative_count >= 3);
|
|
262
|
+
assert.equal(validateReport(report).ok, true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// planCarries — decision core with injected git ops
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
test('planCarries carries unchanged, skips changed/missing/errored', () => {
|
|
270
|
+
const parent = makeParentReport({ scope: { type: 'branch', description: 'p', head_sha: 'a'.repeat(40) } });
|
|
271
|
+
const target = makeTargetReport();
|
|
272
|
+
const gitOps = {
|
|
273
|
+
fileChanged: (sha, file) => {
|
|
274
|
+
if (file === 'missing.txt') throw new Error('git boom');
|
|
275
|
+
return file === 'b.txt';
|
|
276
|
+
},
|
|
277
|
+
fileExistsAt: (sha, file) => file !== 'missing.txt',
|
|
278
|
+
};
|
|
279
|
+
const plan = planCarries(parent, target, gitOps);
|
|
280
|
+
assert.equal(plan.carries.length, 1);
|
|
281
|
+
assert.equal(plan.carries[0].finding.id, 'F-002'); // continues target numbering
|
|
282
|
+
assert.equal(plan.carries[0].parentId, 'F-001');
|
|
283
|
+
assert.equal(plan.skips.length, 2);
|
|
284
|
+
assert.ok(plan.skips.some((s) => s.parentId === 'F-002' && /changed/.test(s.reason)));
|
|
285
|
+
assert.ok(plan.skips.some((s) => s.parentId === 'F-003' && /git boom|git error/.test(s.reason)));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('planCarries skips everything when parent has no head_sha', () => {
|
|
289
|
+
const parent = makeParentReport(); // no scope.head_sha
|
|
290
|
+
const target = makeTargetReport();
|
|
291
|
+
const plan = planCarries(parent, target, {
|
|
292
|
+
fileChanged: () => false,
|
|
293
|
+
fileExistsAt: () => true,
|
|
294
|
+
});
|
|
295
|
+
assert.equal(plan.carries.length, 0);
|
|
296
|
+
assert.equal(plan.skips.length, 3);
|
|
297
|
+
assert.ok(plan.skips.every((s) => /head_sha/.test(s.reason)));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('planCarries skips findings already present in the target', () => {
|
|
301
|
+
const parent = makeParentReport({ scope: { type: 'branch', description: 'p', head_sha: 'b'.repeat(40) } });
|
|
302
|
+
const target = makeTargetReport({
|
|
303
|
+
findings: [
|
|
304
|
+
{
|
|
305
|
+
id: 'F-001',
|
|
306
|
+
lens: 'correctness',
|
|
307
|
+
location: 'a.txt:10',
|
|
308
|
+
claim: 'unchanged file finding',
|
|
309
|
+
severity: 'important',
|
|
310
|
+
verdict: 'confirmed',
|
|
311
|
+
verdict_reason: 'already here',
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
});
|
|
315
|
+
const plan = planCarries(parent, target, {
|
|
316
|
+
fileChanged: () => false,
|
|
317
|
+
fileExistsAt: () => true,
|
|
318
|
+
});
|
|
319
|
+
assert.equal(plan.carries.length, 2); // F-002 + F-003 (git says unchanged/exists)
|
|
320
|
+
assert.ok(plan.skips.some((s) => s.parentId === 'F-001' && /already present/.test(s.reason)));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// runCarryforward — end-to-end against a scratch git repo
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
test('runCarryforward carries unchanged findings into the target report', () => {
|
|
328
|
+
const dir = tmp('review-carry-');
|
|
329
|
+
try {
|
|
330
|
+
const { parentSha } = makeScratchRepo(dir);
|
|
331
|
+
const reviewsDir = path.join(dir, 'reviews');
|
|
332
|
+
fs.mkdirSync(reviewsDir);
|
|
333
|
+
const parent = makeParentReport({
|
|
334
|
+
scope: { type: 'branch', description: 'p', head_sha: parentSha },
|
|
335
|
+
});
|
|
336
|
+
writeReport(reviewsDir, '20260101T000000Z-parent-scope-review.json', parent);
|
|
337
|
+
const targetPath = writeReport(
|
|
338
|
+
reviewsDir,
|
|
339
|
+
'20260201T000000Z-target-scope-review.json',
|
|
340
|
+
makeTargetReport(),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const result = runCarryforward({
|
|
344
|
+
parent: '20260101T000000Z-parent-scope',
|
|
345
|
+
file: targetPath,
|
|
346
|
+
dryRun: false,
|
|
347
|
+
reviewsDir,
|
|
348
|
+
repo: dir,
|
|
349
|
+
});
|
|
350
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
351
|
+
assert.ok(result.message.includes('1 carried'));
|
|
352
|
+
assert.ok(result.message.includes('2 skipped'));
|
|
353
|
+
|
|
354
|
+
const updated = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
355
|
+
assert.equal(updated.findings.length, 2);
|
|
356
|
+
const carried = updated.findings[1];
|
|
357
|
+
assert.equal(carried.id, 'F-002');
|
|
358
|
+
assert.equal(carried.location, 'a.txt:10');
|
|
359
|
+
assert.equal(
|
|
360
|
+
carried.verdict_reason,
|
|
361
|
+
`Carried forward from 20260101T000000Z-parent-scope (unchanged since ${parentSha.slice(0, 8)}): original reason A`,
|
|
362
|
+
);
|
|
363
|
+
assert.equal(updated.summary.confirmed, 2);
|
|
364
|
+
assert.equal(validateReport(updated).ok, true);
|
|
365
|
+
} finally {
|
|
366
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('runCarryforward blocks a carry when the file has uncommitted edits', () => {
|
|
371
|
+
const dir = tmp('review-carry-wt-');
|
|
372
|
+
try {
|
|
373
|
+
const { parentSha } = makeScratchRepo(dir);
|
|
374
|
+
// a.txt is unchanged in git history but edited in the working tree
|
|
375
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'alpha edited locally\n');
|
|
376
|
+
const reviewsDir = path.join(dir, 'reviews');
|
|
377
|
+
fs.mkdirSync(reviewsDir);
|
|
378
|
+
const parent = makeParentReport({
|
|
379
|
+
scope: { type: 'branch', description: 'p', head_sha: parentSha },
|
|
380
|
+
});
|
|
381
|
+
writeReport(reviewsDir, '20260101T000000Z-parent-scope-review.json', parent);
|
|
382
|
+
const targetPath = writeReport(
|
|
383
|
+
reviewsDir,
|
|
384
|
+
'20260201T000000Z-target-scope-review.json',
|
|
385
|
+
makeTargetReport(),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const result = runCarryforward({
|
|
389
|
+
parent: '20260101T000000Z-parent-scope',
|
|
390
|
+
file: targetPath,
|
|
391
|
+
dryRun: false,
|
|
392
|
+
reviewsDir,
|
|
393
|
+
repo: dir,
|
|
394
|
+
});
|
|
395
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
396
|
+
assert.ok(result.message.includes('0 carried'));
|
|
397
|
+
|
|
398
|
+
const updated = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
399
|
+
assert.equal(updated.findings.length, 1);
|
|
400
|
+
} finally {
|
|
401
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('runCarryforward --dry-run prints the plan but writes nothing', () => {
|
|
406
|
+
const dir = tmp('review-carry-dry-');
|
|
407
|
+
try {
|
|
408
|
+
const { parentSha } = makeScratchRepo(dir);
|
|
409
|
+
const reviewsDir = path.join(dir, 'reviews');
|
|
410
|
+
fs.mkdirSync(reviewsDir);
|
|
411
|
+
const parent = makeParentReport({
|
|
412
|
+
scope: { type: 'branch', description: 'p', head_sha: parentSha },
|
|
413
|
+
});
|
|
414
|
+
const parentPath = writeReport(
|
|
415
|
+
reviewsDir,
|
|
416
|
+
'20260101T000000Z-parent-scope-review.json',
|
|
417
|
+
parent,
|
|
418
|
+
);
|
|
419
|
+
const targetPath = writeReport(
|
|
420
|
+
reviewsDir,
|
|
421
|
+
'20260201T000000Z-target-scope-review.json',
|
|
422
|
+
makeTargetReport(),
|
|
423
|
+
);
|
|
424
|
+
const before = fs.readFileSync(targetPath, 'utf8');
|
|
425
|
+
|
|
426
|
+
const result = runCarryforward({
|
|
427
|
+
parent: parentPath,
|
|
428
|
+
file: targetPath,
|
|
429
|
+
dryRun: true,
|
|
430
|
+
reviewsDir,
|
|
431
|
+
repo: dir,
|
|
432
|
+
});
|
|
433
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
434
|
+
assert.ok(result.message.includes('dry-run'));
|
|
435
|
+
assert.ok(result.message.includes('CARRY'));
|
|
436
|
+
assert.ok(result.message.includes('SKIP'));
|
|
437
|
+
assert.equal(fs.readFileSync(targetPath, 'utf8'), before);
|
|
438
|
+
} finally {
|
|
439
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('runCarryforward defaults --file to newest non-parent non-reverify report', () => {
|
|
444
|
+
const dir = tmp('review-carry-default-');
|
|
445
|
+
try {
|
|
446
|
+
const { parentSha } = makeScratchRepo(dir);
|
|
447
|
+
const reviewsDir = path.join(dir, 'reviews');
|
|
448
|
+
fs.mkdirSync(reviewsDir);
|
|
449
|
+
const parent = makeParentReport({
|
|
450
|
+
scope: { type: 'branch', description: 'p', head_sha: parentSha },
|
|
451
|
+
});
|
|
452
|
+
writeReport(reviewsDir, '20260101T000000Z-parent-scope-review.json', parent);
|
|
453
|
+
const targetPath = writeReport(
|
|
454
|
+
reviewsDir,
|
|
455
|
+
'20260201T000000Z-target-scope-review.json',
|
|
456
|
+
makeTargetReport(),
|
|
457
|
+
);
|
|
458
|
+
// Newest by mtime, but kind=reverify → must be passed over.
|
|
459
|
+
const reverifyPath = writeReport(
|
|
460
|
+
reviewsDir,
|
|
461
|
+
'20260301T000000Z-later-scope-review.json',
|
|
462
|
+
makeTargetReport({
|
|
463
|
+
review_id: '20260301T000000Z-later-scope',
|
|
464
|
+
kind: 'reverify',
|
|
465
|
+
parent_report: '20260201T000000Z-target-scope',
|
|
466
|
+
findings: [],
|
|
467
|
+
summary: { tentative_count: 0 },
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
fs.utimesSync(targetPath, (now - 10_000) / 1000, (now - 10_000) / 1000);
|
|
472
|
+
fs.utimesSync(reverifyPath, now / 1000, now / 1000);
|
|
473
|
+
|
|
474
|
+
const result = runCarryforward({
|
|
475
|
+
parent: '20260101T000000Z-parent-scope',
|
|
476
|
+
file: null,
|
|
477
|
+
dryRun: false,
|
|
478
|
+
reviewsDir,
|
|
479
|
+
repo: dir,
|
|
480
|
+
});
|
|
481
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
482
|
+
const updated = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
483
|
+
assert.equal(updated.findings.length, 2);
|
|
484
|
+
const untouched = JSON.parse(fs.readFileSync(reverifyPath, 'utf8'));
|
|
485
|
+
assert.equal(untouched.findings.length, 0);
|
|
486
|
+
} finally {
|
|
487
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('runCarryforward skips all and writes nothing when parent lacks head_sha', () => {
|
|
492
|
+
const dir = tmp('review-carry-nosha-');
|
|
493
|
+
try {
|
|
494
|
+
makeScratchRepo(dir);
|
|
495
|
+
const reviewsDir = path.join(dir, 'reviews');
|
|
496
|
+
fs.mkdirSync(reviewsDir);
|
|
497
|
+
writeReport(reviewsDir, '20260101T000000Z-parent-scope-review.json', makeParentReport());
|
|
498
|
+
const targetPath = writeReport(
|
|
499
|
+
reviewsDir,
|
|
500
|
+
'20260201T000000Z-target-scope-review.json',
|
|
501
|
+
makeTargetReport(),
|
|
502
|
+
);
|
|
503
|
+
const before = fs.readFileSync(targetPath, 'utf8');
|
|
504
|
+
|
|
505
|
+
const result = runCarryforward({
|
|
506
|
+
parent: '20260101T000000Z-parent-scope',
|
|
507
|
+
file: targetPath,
|
|
508
|
+
dryRun: false,
|
|
509
|
+
reviewsDir,
|
|
510
|
+
repo: dir,
|
|
511
|
+
});
|
|
512
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
513
|
+
assert.ok(result.message.includes('0 carried'));
|
|
514
|
+
assert.ok(result.message.includes('3 skipped'));
|
|
515
|
+
assert.equal(fs.readFileSync(targetPath, 'utf8'), before);
|
|
516
|
+
} finally {
|
|
517
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('runCarryforward errors on missing parent and refuses invalid results', () => {
|
|
522
|
+
const dir = tmp('review-carry-err-');
|
|
523
|
+
try {
|
|
524
|
+
makeScratchRepo(dir);
|
|
525
|
+
const reviewsDir = path.join(dir, 'reviews');
|
|
526
|
+
fs.mkdirSync(reviewsDir);
|
|
527
|
+
|
|
528
|
+
const missing = runCarryforward({
|
|
529
|
+
parent: 'no-such-id',
|
|
530
|
+
file: null,
|
|
531
|
+
dryRun: false,
|
|
532
|
+
reviewsDir,
|
|
533
|
+
repo: dir,
|
|
534
|
+
});
|
|
535
|
+
assert.equal(missing.exitCode, 1);
|
|
536
|
+
assert.ok(missing.message.includes('no-such-id'));
|
|
537
|
+
} finally {
|
|
538
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('runCarryforward refuses to write a target that fails validation', () => {
|
|
543
|
+
const dir = tmp('review-carry-invalid-');
|
|
544
|
+
try {
|
|
545
|
+
const { parentSha } = makeScratchRepo(dir);
|
|
546
|
+
const reviewsDir = path.join(dir, 'reviews');
|
|
547
|
+
fs.mkdirSync(reviewsDir);
|
|
548
|
+
// Parent carries a reverify verdict — invalid inside a kind=review target.
|
|
549
|
+
const parent = makeParentReport({
|
|
550
|
+
kind: 'reverify',
|
|
551
|
+
parent_report: 'some-earlier-review',
|
|
552
|
+
scope: { type: 'branch', description: 'p', head_sha: parentSha },
|
|
553
|
+
summary: { tentative_count: 1, still_open: 1 },
|
|
554
|
+
findings: [
|
|
555
|
+
{
|
|
556
|
+
id: 'F-001',
|
|
557
|
+
lens: 'correctness',
|
|
558
|
+
location: 'a.txt:10',
|
|
559
|
+
claim: 'still broken',
|
|
560
|
+
severity: 'important',
|
|
561
|
+
verdict: 'still_open',
|
|
562
|
+
verdict_reason: 'r',
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
});
|
|
566
|
+
writeReport(reviewsDir, '20260101T000000Z-parent-scope-review.json', parent);
|
|
567
|
+
const targetPath = writeReport(
|
|
568
|
+
reviewsDir,
|
|
569
|
+
'20260201T000000Z-target-scope-review.json',
|
|
570
|
+
makeTargetReport(),
|
|
571
|
+
);
|
|
572
|
+
const before = fs.readFileSync(targetPath, 'utf8');
|
|
573
|
+
|
|
574
|
+
const result = runCarryforward({
|
|
575
|
+
parent: '20260101T000000Z-parent-scope',
|
|
576
|
+
file: targetPath,
|
|
577
|
+
dryRun: false,
|
|
578
|
+
reviewsDir,
|
|
579
|
+
repo: dir,
|
|
580
|
+
});
|
|
581
|
+
assert.equal(result.exitCode, 1);
|
|
582
|
+
assert.ok(/validation/i.test(result.message));
|
|
583
|
+
assert.equal(fs.readFileSync(targetPath, 'utf8'), before);
|
|
584
|
+
} finally {
|
|
585
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
586
|
+
}
|
|
587
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
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 { parseArgs as parseNewArgs, runNew } from './new-review.mjs';
|
|
7
|
+
import { parseArgs as parseRenderArgs, runRender } from './render.mjs';
|
|
8
|
+
import { validateReport } from './lib.mjs';
|
|
9
|
+
|
|
10
|
+
const FIXED_NOW = new Date('2026-06-05T16:00:00.000Z');
|
|
11
|
+
const stubGit = () => ({ baseSha: 'base000', headSha: 'head111' });
|
|
12
|
+
|
|
13
|
+
function tmp(prefix) {
|
|
14
|
+
return fs.mkdtempSync(path.join(tmpdir(), prefix));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- new-review ---
|
|
18
|
+
|
|
19
|
+
test('runNew scaffolds a schema-valid skeleton with git SHAs', () => {
|
|
20
|
+
const dir = tmp('tmp-new-');
|
|
21
|
+
try {
|
|
22
|
+
const opts = parseNewArgs(['mercury-vat', '--type', 'branch']);
|
|
23
|
+
const result = runNew(opts, { now: FIXED_NOW, cwd: dir, gitImpl: stubGit });
|
|
24
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
25
|
+
assert.ok(result.jsonPath.endsWith('20260605T160000Z-mercury-vat-review.json'));
|
|
26
|
+
const report = JSON.parse(fs.readFileSync(result.jsonPath, 'utf8'));
|
|
27
|
+
assert.equal(report.review_id, '20260605T160000Z-mercury-vat');
|
|
28
|
+
assert.equal(report.scope.base_sha, 'base000');
|
|
29
|
+
assert.equal(report.scope.head_sha, 'head111');
|
|
30
|
+
assert.equal(report.lenses.length, 9);
|
|
31
|
+
assert.equal(validateReport(report).ok, true);
|
|
32
|
+
} finally {
|
|
33
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('runNew refuses to overwrite an existing report', () => {
|
|
38
|
+
const dir = tmp('tmp-new-');
|
|
39
|
+
try {
|
|
40
|
+
const opts = parseNewArgs(['dup-scope', '--no-git']);
|
|
41
|
+
assert.equal(runNew(opts, { now: FIXED_NOW, cwd: dir }).exitCode, 0);
|
|
42
|
+
const second = runNew(opts, { now: FIXED_NOW, cwd: dir });
|
|
43
|
+
assert.equal(second.exitCode, 1);
|
|
44
|
+
assert.ok(second.message.includes('refusing to overwrite'));
|
|
45
|
+
} finally {
|
|
46
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('runNew rejects reverify without --parent', () => {
|
|
51
|
+
const dir = tmp('tmp-new-');
|
|
52
|
+
try {
|
|
53
|
+
const opts = parseNewArgs(['x', '--kind', 'reverify', '--no-git']);
|
|
54
|
+
const result = runNew(opts, { now: FIXED_NOW, cwd: dir });
|
|
55
|
+
assert.equal(result.exitCode, 1);
|
|
56
|
+
assert.ok(result.message.includes('reverify'));
|
|
57
|
+
} finally {
|
|
58
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('new-review parseArgs splits comma lists and flags', () => {
|
|
63
|
+
const opts = parseNewArgs(['scope', '--lenses', 'security,correctness', '--paths', 'a.ts,b.ts', '--no-git']);
|
|
64
|
+
assert.deepEqual(opts.lenses, ['security', 'correctness']);
|
|
65
|
+
assert.deepEqual(opts.paths, ['a.ts', 'b.ts']);
|
|
66
|
+
assert.equal(opts.git, false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// --- render ---
|
|
70
|
+
|
|
71
|
+
test('runRender writes paired .md from JSON', () => {
|
|
72
|
+
const dir = tmp('tmp-render-');
|
|
73
|
+
try {
|
|
74
|
+
const report = {
|
|
75
|
+
review_id: '20260605T160000Z-x',
|
|
76
|
+
kind: 'review',
|
|
77
|
+
created_at: '2026-06-05T16:00:00.000Z',
|
|
78
|
+
scope: { type: 'file', description: 'x.ts' },
|
|
79
|
+
lenses: ['security'],
|
|
80
|
+
summary: { tentative_count: 1, confirmed: 1, headline: 'one confirmed' },
|
|
81
|
+
findings: [
|
|
82
|
+
{
|
|
83
|
+
id: 'F-001',
|
|
84
|
+
lens: 'security',
|
|
85
|
+
location: 'x.ts:1',
|
|
86
|
+
claim: 'auth bypass',
|
|
87
|
+
severity: 'critical',
|
|
88
|
+
verdict: 'confirmed',
|
|
89
|
+
verdict_reason: 'real',
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
const jsonPath = path.join(dir, '20260605T160000Z-x-review.json');
|
|
94
|
+
fs.writeFileSync(jsonPath, JSON.stringify(report));
|
|
95
|
+
const opts = parseRenderArgs(['--file', jsonPath]);
|
|
96
|
+
const result = runRender(opts, dir);
|
|
97
|
+
assert.equal(result.exitCode, 0, result.message);
|
|
98
|
+
const md = fs.readFileSync(jsonPath.replace(/\.json$/, '.md'), 'utf8');
|
|
99
|
+
assert.ok(md.includes('## Critical'));
|
|
100
|
+
assert.ok(md.includes('one confirmed'));
|
|
101
|
+
} finally {
|
|
102
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('runRender fails on an invalid report', () => {
|
|
107
|
+
const dir = tmp('tmp-render-');
|
|
108
|
+
try {
|
|
109
|
+
const jsonPath = path.join(dir, '20260605T160000Z-bad-review.json');
|
|
110
|
+
fs.writeFileSync(jsonPath, JSON.stringify({ kind: 'review' }));
|
|
111
|
+
const result = runRender(parseRenderArgs(['--file', jsonPath]), dir);
|
|
112
|
+
assert.equal(result.exitCode, 1);
|
|
113
|
+
assert.ok(result.message.includes('validation failed'));
|
|
114
|
+
} finally {
|
|
115
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|