@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,266 @@
|
|
|
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 { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
validateReport,
|
|
8
|
+
renderMarkdown,
|
|
9
|
+
buildReviewSkeleton,
|
|
10
|
+
compactStamp,
|
|
11
|
+
slugify,
|
|
12
|
+
countOpenAtOrAbove,
|
|
13
|
+
countByVerdict,
|
|
14
|
+
} from './lib.mjs';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const validFixture = JSON.parse(
|
|
18
|
+
fs.readFileSync(path.join(__dirname, 'fixtures', 'valid-review.json'), 'utf8'),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// --- stamping / slug ---
|
|
22
|
+
|
|
23
|
+
test('compactStamp produces a compact UTC review stamp', () => {
|
|
24
|
+
assert.equal(compactStamp(new Date('2026-06-05T16:00:00.000Z')), '20260605T160000Z');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('slugify normalizes free text', () => {
|
|
28
|
+
assert.equal(slugify('Mercury VAT / checkout!'), 'mercury-vat-checkout');
|
|
29
|
+
assert.equal(slugify(' '), 'review');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// --- buildReviewSkeleton ---
|
|
33
|
+
|
|
34
|
+
test('buildReviewSkeleton yields a schema-valid review', () => {
|
|
35
|
+
const { reviewId, fileBase, report } = buildReviewSkeleton({
|
|
36
|
+
slug: 'mercury-vat',
|
|
37
|
+
type: 'branch',
|
|
38
|
+
now: new Date('2026-06-05T16:00:00.000Z'),
|
|
39
|
+
headSha: 'abc123',
|
|
40
|
+
});
|
|
41
|
+
assert.equal(reviewId, '20260605T160000Z-mercury-vat');
|
|
42
|
+
assert.equal(fileBase, '20260605T160000Z-mercury-vat-review');
|
|
43
|
+
assert.equal(report.scope.head_sha, 'abc123');
|
|
44
|
+
const result = validateReport(report);
|
|
45
|
+
assert.equal(result.ok, true, JSON.stringify(result.errors));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('buildReviewSkeleton requires parent for reverify', () => {
|
|
49
|
+
assert.throws(
|
|
50
|
+
() => buildReviewSkeleton({ slug: 'x', kind: 'reverify', now: new Date('2026-06-05T16:00:00.000Z') }),
|
|
51
|
+
/reverify/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('buildReviewSkeleton reverify with parent is valid', () => {
|
|
56
|
+
const { fileBase, report } = buildReviewSkeleton({
|
|
57
|
+
slug: 'x',
|
|
58
|
+
kind: 'reverify',
|
|
59
|
+
parentReport: '20260101T000000Z-x',
|
|
60
|
+
now: new Date('2026-06-05T16:00:00.000Z'),
|
|
61
|
+
});
|
|
62
|
+
assert.ok(fileBase.endsWith('-reverify'));
|
|
63
|
+
assert.equal(report.parent_report, '20260101T000000Z-x');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// --- hardened validation ---
|
|
67
|
+
|
|
68
|
+
test('rejects non-ISO created_at', () => {
|
|
69
|
+
const bad = { ...validFixture, created_at: 'June 5 2026' };
|
|
70
|
+
const result = validateReport(bad);
|
|
71
|
+
assert.equal(result.ok, false);
|
|
72
|
+
assert.ok(result.errors.some((e) => e.includes('created_at')));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('rejects summary counts that disagree with findings', () => {
|
|
76
|
+
const bad = { ...validFixture, summary: { ...validFixture.summary, confirmed: 5 } };
|
|
77
|
+
const result = validateReport(bad);
|
|
78
|
+
assert.equal(result.ok, false);
|
|
79
|
+
assert.ok(result.errors.some((e) => e.includes('summary.confirmed')));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('rejects tentative_count below findings count', () => {
|
|
83
|
+
const bad = { ...validFixture, summary: { ...validFixture.summary, tentative_count: 1 } };
|
|
84
|
+
const result = validateReport(bad);
|
|
85
|
+
assert.equal(result.ok, false);
|
|
86
|
+
assert.ok(result.errors.some((e) => e.includes('tentative_count')));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('rejects downgraded finding without original_severity', () => {
|
|
90
|
+
const bad = {
|
|
91
|
+
...validFixture,
|
|
92
|
+
summary: { tentative_count: 1, downgraded: 1 },
|
|
93
|
+
findings: [
|
|
94
|
+
{
|
|
95
|
+
id: 'F-001',
|
|
96
|
+
lens: 'security',
|
|
97
|
+
location: 'a.ts:1',
|
|
98
|
+
claim: 'x',
|
|
99
|
+
severity: 'minor',
|
|
100
|
+
verdict: 'downgraded',
|
|
101
|
+
verdict_reason: 'lowered',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const result = validateReport(bad);
|
|
106
|
+
assert.equal(result.ok, false);
|
|
107
|
+
assert.ok(result.errors.some((e) => e.includes('original_severity')));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('accepts a finding with a valid second_opinion', () => {
|
|
111
|
+
const ok = {
|
|
112
|
+
...validFixture,
|
|
113
|
+
summary: { tentative_count: 1, confirmed: 1 },
|
|
114
|
+
findings: [
|
|
115
|
+
{
|
|
116
|
+
id: 'F-001',
|
|
117
|
+
lens: 'security',
|
|
118
|
+
location: 'a.ts:1',
|
|
119
|
+
claim: 'auth bypass',
|
|
120
|
+
severity: 'critical',
|
|
121
|
+
verdict: 'confirmed',
|
|
122
|
+
verdict_reason: 'real',
|
|
123
|
+
second_opinion: { verdict: 'confirmed', verdict_reason: 'second skeptic agrees', agrees: true },
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
const result = validateReport(ok);
|
|
128
|
+
assert.equal(result.ok, true, JSON.stringify(result.errors));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('rejects a malformed coverage ledger', () => {
|
|
132
|
+
const bad = {
|
|
133
|
+
...validFixture,
|
|
134
|
+
coverage: { lenses_without_findings: [{ lens: 'not-a-lens', reason: 'x' }] },
|
|
135
|
+
};
|
|
136
|
+
const result = validateReport(bad);
|
|
137
|
+
assert.equal(result.ok, false);
|
|
138
|
+
assert.ok(result.errors.some((e) => e.includes('lenses_without_findings')));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('accepts a report with valid stats', () => {
|
|
142
|
+
const ok = {
|
|
143
|
+
...validFixture,
|
|
144
|
+
stats: {
|
|
145
|
+
scouts: 3,
|
|
146
|
+
skeptics_dedicated: 2,
|
|
147
|
+
skeptics_batched: 4,
|
|
148
|
+
grounded_skips: 1,
|
|
149
|
+
carried_forward: 0,
|
|
150
|
+
second_opinions: 1,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const result = validateReport(ok);
|
|
154
|
+
assert.equal(result.ok, true, JSON.stringify(result.errors));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('report without stats is still valid', () => {
|
|
158
|
+
const result = validateReport(validFixture);
|
|
159
|
+
assert.equal(result.ok, true, JSON.stringify(result.errors));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('rejects an unknown stats key', () => {
|
|
163
|
+
const bad = { ...validFixture, stats: { scouts: 1, surprise: 2 } };
|
|
164
|
+
const result = validateReport(bad);
|
|
165
|
+
assert.equal(result.ok, false);
|
|
166
|
+
assert.ok(result.errors.some((e) => e.includes('stats.surprise')));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('rejects non-integer and negative stats values', () => {
|
|
170
|
+
const nonInteger = validateReport({ ...validFixture, stats: { scouts: 1.5 } });
|
|
171
|
+
assert.equal(nonInteger.ok, false);
|
|
172
|
+
assert.ok(nonInteger.errors.some((e) => e.includes('stats.scouts')));
|
|
173
|
+
|
|
174
|
+
const negative = validateReport({ ...validFixture, stats: { carried_forward: -1 } });
|
|
175
|
+
assert.equal(negative.ok, false);
|
|
176
|
+
assert.ok(negative.errors.some((e) => e.includes('stats.carried_forward')));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('rejects non-object stats', () => {
|
|
180
|
+
const result = validateReport({ ...validFixture, stats: [3] });
|
|
181
|
+
assert.equal(result.ok, false);
|
|
182
|
+
assert.ok(result.errors.some((e) => e.includes('stats must be an object')));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// --- graded fail-on ---
|
|
186
|
+
|
|
187
|
+
test('countOpenAtOrAbove respects the severity threshold', () => {
|
|
188
|
+
const report = {
|
|
189
|
+
findings: [
|
|
190
|
+
{ severity: 'important', verdict: 'confirmed' },
|
|
191
|
+
{ severity: 'critical', verdict: 'still_open' },
|
|
192
|
+
{ severity: 'minor', verdict: 'confirmed' },
|
|
193
|
+
{ severity: 'critical', verdict: 'false_positive' },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
assert.equal(countOpenAtOrAbove(report, 'critical'), 1);
|
|
197
|
+
assert.equal(countOpenAtOrAbove(report, 'important'), 2);
|
|
198
|
+
assert.equal(countOpenAtOrAbove(report, 'minor'), 3);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// --- render ---
|
|
202
|
+
|
|
203
|
+
test('renderMarkdown reflects JSON as the single source of truth', () => {
|
|
204
|
+
const report = {
|
|
205
|
+
...validFixture,
|
|
206
|
+
summary: {
|
|
207
|
+
...validFixture.summary,
|
|
208
|
+
headline: 'Reviewed 1 file; 1 confirmed, 1 false positive.',
|
|
209
|
+
top_actions: ['Add empty-cart guard'],
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
const md = renderMarkdown(report);
|
|
213
|
+
assert.ok(md.startsWith('# Code review —'));
|
|
214
|
+
assert.ok(md.includes('Reviewed 1 file'));
|
|
215
|
+
assert.ok(md.includes('## Important')); // F-001 important confirmed in main body
|
|
216
|
+
assert.ok(md.includes('Add empty-cart guard'));
|
|
217
|
+
assert.ok(md.includes('## Appendix A — Rejected findings')); // F-002 false positive
|
|
218
|
+
assert.ok(md.includes('F-002'));
|
|
219
|
+
// false positives never appear in the main severity body
|
|
220
|
+
const mainBody = md.split('## Appendix A')[0];
|
|
221
|
+
assert.ok(!mainBody.includes('SQL injection'));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('renderMarkdown shows downgrade provenance and second opinions', () => {
|
|
225
|
+
const report = {
|
|
226
|
+
...validFixture,
|
|
227
|
+
summary: { tentative_count: 1, downgraded: 1 },
|
|
228
|
+
findings: [
|
|
229
|
+
{
|
|
230
|
+
id: 'F-001',
|
|
231
|
+
lens: 'architecture',
|
|
232
|
+
location: 'a.ts:1',
|
|
233
|
+
claim: 'coupling',
|
|
234
|
+
severity: 'minor',
|
|
235
|
+
original_severity: 'important',
|
|
236
|
+
verdict: 'downgraded',
|
|
237
|
+
verdict_reason: 'documented',
|
|
238
|
+
second_opinion: { verdict: 'downgraded', verdict_reason: 'concur', agrees: true },
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
const md = renderMarkdown(report);
|
|
243
|
+
assert.ok(md.includes('minor (was important)'));
|
|
244
|
+
assert.ok(md.includes('Second skeptic'));
|
|
245
|
+
assert.ok(md.includes('concur'));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('renderMarkdown includes pipeline stats when present and omits them otherwise', () => {
|
|
249
|
+
const md = renderMarkdown({
|
|
250
|
+
...validFixture,
|
|
251
|
+
stats: { scouts: 3, skeptics_batched: 2, second_opinions: 1 },
|
|
252
|
+
});
|
|
253
|
+
assert.ok(md.includes('## Pipeline stats'));
|
|
254
|
+
assert.ok(md.includes('**Scouts:** 3'));
|
|
255
|
+
assert.ok(md.includes('**Batched skeptics:** 2'));
|
|
256
|
+
assert.ok(md.includes('**Second opinions:** 1'));
|
|
257
|
+
|
|
258
|
+
const withoutStats = renderMarkdown(validFixture);
|
|
259
|
+
assert.ok(!withoutStats.includes('## Pipeline stats'));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('countByVerdict zero-fills every verdict bucket', () => {
|
|
263
|
+
const counts = countByVerdict({ findings: [] });
|
|
264
|
+
assert.equal(counts.confirmed, 0);
|
|
265
|
+
assert.equal(counts.regressed, 0);
|
|
266
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Merge tentative scout findings from a directory of per-scout JSON files
|
|
4
|
+
* into a single deduplicated `merged.json`.
|
|
5
|
+
*
|
|
6
|
+
* Every `*.json` file in the directory (except `merged.json`) is read; each
|
|
7
|
+
* must have shape `{ findings: [...] }` where a finding carries the scout
|
|
8
|
+
* tentative fields (`id`, `lens`, `location`, `claim`, `evidence`, optional
|
|
9
|
+
* `context`/`related`, `tentative_severity`, `confidence`).
|
|
10
|
+
*
|
|
11
|
+
* Two findings are duplicates when they point at the same location FILE
|
|
12
|
+
* (line suffix stripped), their first line numbers are within ±5 (a finding
|
|
13
|
+
* without a line number only dupes another no-line finding in the same
|
|
14
|
+
* file), and they share the same `lens`. The survivor is the one with the
|
|
15
|
+
* higher `tentative_severity` (critical > important > minor), tie-broken by
|
|
16
|
+
* higher `confidence` (high > medium > low), tie-broken by first
|
|
17
|
+
* encountered; the discarded finding's original id is appended to the
|
|
18
|
+
* survivor's `related` array.
|
|
19
|
+
*
|
|
20
|
+
* Survivors are renumbered `F-001`, `F-002`, ... in stable order sorted by
|
|
21
|
+
* (location file, first line number ascending, original id) and written to
|
|
22
|
+
* `<dir>/merged.json` as `{ findings: [...] }`.
|
|
23
|
+
*
|
|
24
|
+
* A malformed input file (invalid JSON, or no `findings` array) exits 1
|
|
25
|
+
* naming the offending file, and merged.json is NOT written.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* review merge --dir <path>
|
|
29
|
+
*
|
|
30
|
+
* Options:
|
|
31
|
+
* --dir <path> Directory of scout tentative JSON files (required),
|
|
32
|
+
* e.g. .reviews/<id>-tentative/
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { pathToFileURL } from 'node:url';
|
|
38
|
+
|
|
39
|
+
const LINE_WINDOW = 5;
|
|
40
|
+
|
|
41
|
+
/** @type {Record<string, number>} */
|
|
42
|
+
const SEVERITY_RANK = { critical: 3, important: 2, minor: 1 };
|
|
43
|
+
|
|
44
|
+
/** @type {Record<string, number>} */
|
|
45
|
+
const CONFIDENCE_RANK = { high: 3, medium: 2, low: 1 };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string[]} argv
|
|
49
|
+
*/
|
|
50
|
+
export function parseArgs(argv) {
|
|
51
|
+
const opts = {
|
|
52
|
+
dir: null,
|
|
53
|
+
help: false,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
57
|
+
const arg = argv[i];
|
|
58
|
+
if (arg === '--dir') opts.dir = argv[++i];
|
|
59
|
+
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
60
|
+
else throw new Error(`unknown argument: ${arg}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return opts;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract the file path from a finding location by stripping a trailing
|
|
68
|
+
* `:line` or `:start-end` suffix.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} location
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function locationFile(location) {
|
|
74
|
+
return String(location).replace(/:\d+(-\d+)?$/, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* First line number of a finding location (`a.ts:42` → 42, `a.ts:10-20` →
|
|
79
|
+
* 10), or null when the location has no line suffix.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} location
|
|
82
|
+
* @returns {number | null}
|
|
83
|
+
*/
|
|
84
|
+
export function firstLineNumber(location) {
|
|
85
|
+
const m = /:(\d+)(-\d+)?$/.exec(String(location));
|
|
86
|
+
return m ? Number(m[1]) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether two findings are duplicates: same location file, same lens, and
|
|
91
|
+
* first line numbers within ±LINE_WINDOW. A finding without a line number
|
|
92
|
+
* only dupes another no-line finding in the same file.
|
|
93
|
+
*
|
|
94
|
+
* @param {{ location?: unknown, lens?: unknown }} a
|
|
95
|
+
* @param {{ location?: unknown, lens?: unknown }} b
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function isDuplicate(a, b) {
|
|
99
|
+
if (a.lens !== b.lens) return false;
|
|
100
|
+
const fileA = locationFile(/** @type {string} */ (a.location ?? ''));
|
|
101
|
+
const fileB = locationFile(/** @type {string} */ (b.location ?? ''));
|
|
102
|
+
if (fileA !== fileB) return false;
|
|
103
|
+
const lineA = firstLineNumber(/** @type {string} */ (a.location ?? ''));
|
|
104
|
+
const lineB = firstLineNumber(/** @type {string} */ (b.location ?? ''));
|
|
105
|
+
if (lineA === null || lineB === null) return lineA === lineB;
|
|
106
|
+
return Math.abs(lineA - lineB) <= LINE_WINDOW;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {{ tentative_severity?: unknown, confidence?: unknown }} finding
|
|
111
|
+
* @returns {[number, number]} [severityRank, confidenceRank]
|
|
112
|
+
*/
|
|
113
|
+
function rank(finding) {
|
|
114
|
+
return [
|
|
115
|
+
SEVERITY_RANK[/** @type {string} */ (finding.tentative_severity)] ?? 0,
|
|
116
|
+
CONFIDENCE_RANK[/** @type {string} */ (finding.confidence)] ?? 0,
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Union of related-id arrays plus the discarded finding's own id, preserving
|
|
122
|
+
* first-seen order without duplicates.
|
|
123
|
+
*
|
|
124
|
+
* @param {Record<string, unknown>} winner
|
|
125
|
+
* @param {Record<string, unknown>} loser
|
|
126
|
+
* @returns {string[]}
|
|
127
|
+
*/
|
|
128
|
+
function mergedRelated(winner, loser) {
|
|
129
|
+
const out = [];
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const winnerRelated = Array.isArray(winner.related) ? winner.related : [];
|
|
132
|
+
const loserRelated = Array.isArray(loser.related) ? loser.related : [];
|
|
133
|
+
for (const id of [...winnerRelated, ...loserRelated, loser.id]) {
|
|
134
|
+
if (id === undefined || seen.has(id)) continue;
|
|
135
|
+
seen.add(id);
|
|
136
|
+
out.push(/** @type {string} */ (id));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Collapse duplicate findings (in encounter order). Each newcomer is
|
|
143
|
+
* compared against the current survivors; on a duplicate the higher-ranked
|
|
144
|
+
* finding survives and absorbs the loser's original id (and any prior
|
|
145
|
+
* `related` entries) into its `related` array.
|
|
146
|
+
*
|
|
147
|
+
* @param {Array<Record<string, unknown>>} findings In encounter order.
|
|
148
|
+
* @returns {{ survivors: Array<Record<string, unknown>>, collapsed: number }}
|
|
149
|
+
*/
|
|
150
|
+
export function dedupeFindings(findings) {
|
|
151
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
152
|
+
const survivors = [];
|
|
153
|
+
let collapsed = 0;
|
|
154
|
+
|
|
155
|
+
for (const finding of findings) {
|
|
156
|
+
const candidate = { ...finding };
|
|
157
|
+
const index = survivors.findIndex((s) => isDuplicate(s, candidate));
|
|
158
|
+
if (index === -1) {
|
|
159
|
+
survivors.push(candidate);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
collapsed += 1;
|
|
164
|
+
const incumbent = survivors[index];
|
|
165
|
+
const [sevA, confA] = rank(incumbent);
|
|
166
|
+
const [sevB, confB] = rank(candidate);
|
|
167
|
+
const candidateWins = sevB > sevA || (sevB === sevA && confB > confA);
|
|
168
|
+
const winner = candidateWins ? candidate : incumbent;
|
|
169
|
+
const loser = candidateWins ? incumbent : candidate;
|
|
170
|
+
winner.related = mergedRelated(winner, loser);
|
|
171
|
+
survivors[index] = winner;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { survivors, collapsed };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Renumber findings `F-001`, `F-002`, ... in stable order sorted by
|
|
179
|
+
* (location file, first line number ascending — no-line first, original id).
|
|
180
|
+
*
|
|
181
|
+
* @param {Array<Record<string, unknown>>} findings
|
|
182
|
+
* @returns {Array<Record<string, unknown>>}
|
|
183
|
+
*/
|
|
184
|
+
export function renumberFindings(findings) {
|
|
185
|
+
const sorted = [...findings].sort((a, b) => {
|
|
186
|
+
const locA = /** @type {string} */ (a.location ?? '');
|
|
187
|
+
const locB = /** @type {string} */ (b.location ?? '');
|
|
188
|
+
const fileCmp = locationFile(locA).localeCompare(locationFile(locB));
|
|
189
|
+
if (fileCmp !== 0) return fileCmp;
|
|
190
|
+
const lineA = firstLineNumber(locA) ?? -1;
|
|
191
|
+
const lineB = firstLineNumber(locB) ?? -1;
|
|
192
|
+
if (lineA !== lineB) return lineA - lineB;
|
|
193
|
+
return String(a.id ?? '').localeCompare(String(b.id ?? ''));
|
|
194
|
+
});
|
|
195
|
+
return sorted.map((finding, i) => ({
|
|
196
|
+
...finding,
|
|
197
|
+
id: `F-${String(i + 1).padStart(3, '0')}`,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {{ dir?: string | null }} opts
|
|
203
|
+
* @param {string} [cwd]
|
|
204
|
+
* @returns {{ exitCode: number; message: string }}
|
|
205
|
+
*/
|
|
206
|
+
export function runMerge(opts, cwd = process.cwd()) {
|
|
207
|
+
if (!opts.dir) {
|
|
208
|
+
return { exitCode: 1, message: '--dir <path> is required' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const dir = path.isAbsolute(opts.dir) ? opts.dir : path.resolve(cwd, opts.dir);
|
|
212
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
213
|
+
return { exitCode: 1, message: `not a directory: ${dir}` };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const inputNames = fs
|
|
217
|
+
.readdirSync(dir)
|
|
218
|
+
.filter((name) => name.endsWith('.json') && name !== 'merged.json')
|
|
219
|
+
.sort();
|
|
220
|
+
if (inputNames.length === 0) {
|
|
221
|
+
return { exitCode: 1, message: `no input *.json files in ${dir}` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
225
|
+
const allFindings = [];
|
|
226
|
+
const lines = [];
|
|
227
|
+
for (const name of inputNames) {
|
|
228
|
+
const full = path.join(dir, name);
|
|
229
|
+
let parsed;
|
|
230
|
+
try {
|
|
231
|
+
parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return {
|
|
234
|
+
exitCode: 1,
|
|
235
|
+
message: `invalid JSON in ${full}: ${/** @type {Error} */ (err).message}`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (!parsed || !Array.isArray(parsed.findings)) {
|
|
239
|
+
return { exitCode: 1, message: `no findings array in ${full}` };
|
|
240
|
+
}
|
|
241
|
+
allFindings.push(...parsed.findings);
|
|
242
|
+
lines.push(`${name}: ${parsed.findings.length} findings`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const { survivors, collapsed } = dedupeFindings(allFindings);
|
|
246
|
+
const merged = renumberFindings(survivors);
|
|
247
|
+
|
|
248
|
+
const outPath = path.join(dir, 'merged.json');
|
|
249
|
+
fs.writeFileSync(outPath, `${JSON.stringify({ findings: merged }, null, 2)}\n`, 'utf8');
|
|
250
|
+
|
|
251
|
+
lines.push('');
|
|
252
|
+
lines.push(`duplicates collapsed: ${collapsed}`);
|
|
253
|
+
lines.push(`final: ${merged.length} findings`);
|
|
254
|
+
lines.push(`wrote: ${outPath}`);
|
|
255
|
+
return { exitCode: 0, message: lines.join('\n') };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function printHelp() {
|
|
259
|
+
console.log(`Usage: review merge --dir <path>
|
|
260
|
+
|
|
261
|
+
Merge tentative scout findings from every *.json file in the directory
|
|
262
|
+
(except merged.json) into a deduplicated, renumbered <dir>/merged.json.
|
|
263
|
+
|
|
264
|
+
Options:
|
|
265
|
+
--dir <path> Directory of scout tentative JSON files (required),
|
|
266
|
+
e.g. .reviews/<id>-tentative/
|
|
267
|
+
-h, --help Show this help
|
|
268
|
+
`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function main() {
|
|
272
|
+
try {
|
|
273
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
274
|
+
if (opts.help) {
|
|
275
|
+
printHelp();
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
const result = runMerge(opts);
|
|
279
|
+
console.log(result.message);
|
|
280
|
+
process.exit(result.exitCode);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error(/** @type {Error} */ (err).message);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const isMain =
|
|
288
|
+
process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
289
|
+
|
|
290
|
+
if (isMain) {
|
|
291
|
+
main();
|
|
292
|
+
}
|