@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.
Files changed (143) hide show
  1. package/bin/forge.mjs +100 -0
  2. package/bin/forgekit.mjs +84 -0
  3. package/bin/review.mjs +82 -0
  4. package/package.json +46 -0
  5. package/scripts/prepack.mjs +78 -0
  6. package/scripts/run-tests.mjs +43 -0
  7. package/src/adr.mjs +236 -0
  8. package/src/adr.test.mjs +170 -0
  9. package/src/change.mjs +234 -0
  10. package/src/change.test.mjs +83 -0
  11. package/src/cleanup-sessions.mjs +70 -0
  12. package/src/config.mjs +103 -0
  13. package/src/defer.mjs +75 -0
  14. package/src/doctor.mjs +341 -0
  15. package/src/doctor.test.mjs +114 -0
  16. package/src/init.mjs +575 -0
  17. package/src/install.mjs +777 -0
  18. package/src/install.test.mjs +104 -0
  19. package/src/integrity-check.mjs +58 -0
  20. package/src/integrity.mjs +317 -0
  21. package/src/integrity.test.mjs +296 -0
  22. package/src/lib/workspaces.mjs +55 -0
  23. package/src/lib.mjs +138 -0
  24. package/src/models.defaults.json +41 -0
  25. package/src/new-session.mjs +82 -0
  26. package/src/openspec-overlays/README.md +19 -0
  27. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -0
  28. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -0
  29. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -0
  30. package/src/paths.mjs +92 -0
  31. package/src/plan-engine.mjs +260 -0
  32. package/src/plan-engine.test.mjs +245 -0
  33. package/src/preferences.defaults.json +78 -0
  34. package/src/preferences.mjs +438 -0
  35. package/src/preferences.test.mjs +174 -0
  36. package/src/record-evidence.mjs +204 -0
  37. package/src/record-evidence.test.mjs +260 -0
  38. package/src/resolve-model.mjs +312 -0
  39. package/src/resolve-model.test.mjs +194 -0
  40. package/src/review/carryforward.mjs +413 -0
  41. package/src/review/carryforward.test.mjs +587 -0
  42. package/src/review/cli.test.mjs +117 -0
  43. package/src/review/export.mjs +172 -0
  44. package/src/review/export.test.mjs +197 -0
  45. package/src/review/fixtures/valid-review.json +42 -0
  46. package/src/review/lib.mjs +894 -0
  47. package/src/review/lib.test.mjs +266 -0
  48. package/src/review/merge-tentative.mjs +292 -0
  49. package/src/review/merge-tentative.test.mjs +363 -0
  50. package/src/review/new-review.mjs +200 -0
  51. package/src/review/render.mjs +108 -0
  52. package/src/review/schema-consistency.test.mjs +83 -0
  53. package/src/review/schema.json +196 -0
  54. package/src/review/signals.mjs +144 -0
  55. package/src/review/signals.test.mjs +62 -0
  56. package/src/score-cli.mjs +68 -0
  57. package/src/score.mjs +489 -0
  58. package/src/score.test.mjs +253 -0
  59. package/src/session-reminder.mjs +168 -0
  60. package/src/session-status.mjs +70 -0
  61. package/src/set-models.mjs +186 -0
  62. package/src/set-phase.mjs +177 -0
  63. package/src/set-phase.test.mjs +317 -0
  64. package/src/set-prefs.mjs +294 -0
  65. package/src/spine.mjs +91 -0
  66. package/src/triage-prompt.mjs +175 -0
  67. package/src/triage-prompt.test.mjs +50 -0
  68. package/src/vendor-openspec-overlays.mjs +176 -0
  69. package/src/vendor-openspec-overlays.test.mjs +62 -0
  70. package/vendor/skills/archive-to-adr/SKILL.md +149 -0
  71. package/vendor/skills/forge/SKILL.md +136 -0
  72. package/vendor/skills/forge/phases/brainstorm.md +23 -0
  73. package/vendor/skills/forge/phases/finish.md +87 -0
  74. package/vendor/skills/forge/phases/implement.md +76 -0
  75. package/vendor/skills/forge/phases/plan-openspec.md +40 -0
  76. package/vendor/skills/forge/phases/plan-specs.md +97 -0
  77. package/vendor/skills/forge/phases/review.md +25 -0
  78. package/vendor/skills/forge/phases/verify.md +120 -0
  79. package/vendor/skills/forge/references/forge-layout.md +85 -0
  80. package/vendor/skills/forge/references/pace.md +115 -0
  81. package/vendor/skills/forge/references/plan-routing.md +51 -0
  82. package/vendor/skills/forge/references/runtime-integrity.md +157 -0
  83. package/vendor/skills/forge/references/substantial-work.md +37 -0
  84. package/vendor/skills/forge/references/tdd-core.md +29 -0
  85. package/vendor/skills/forge/references/test-evidence.md +30 -0
  86. package/vendor/skills/forge/references/test-strategy.md +68 -0
  87. package/vendor/skills/forge/skills/NOTICE.md +17 -0
  88. package/vendor/skills/forge/skills/brainstorming/SKILL.md +120 -0
  89. package/vendor/skills/forge/skills/requesting-code-review/SKILL.md +67 -0
  90. package/vendor/skills/forge/skills/requesting-code-review/code-reviewer.md +146 -0
  91. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -0
  92. package/vendor/skills/forge/skills/systematic-debugging/SKILL.md +234 -0
  93. package/vendor/skills/forge/skills/systematic-debugging/condition-based-waiting.md +115 -0
  94. package/vendor/skills/forge/skills/systematic-debugging/defense-in-depth.md +122 -0
  95. package/vendor/skills/forge/skills/systematic-debugging/find-polluter.sh +63 -0
  96. package/vendor/skills/forge/skills/systematic-debugging/root-cause-tracing.md +169 -0
  97. package/vendor/skills/forge/skills/test-driven-development/SKILL.md +290 -0
  98. package/vendor/skills/forge/skills/test-driven-development/testing-anti-patterns.md +299 -0
  99. package/vendor/skills/forge/skills/verification-before-completion/SKILL.md +59 -0
  100. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +53 -0
  101. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -0
  102. package/vendor/skills/forge/subagents/task-reviewer-prompt.md +61 -0
  103. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -0
  104. package/vendor/skills/thorough-code-review/SKILL.md +290 -0
  105. package/vendor/skills/thorough-code-review/examples/accepted-risks-janus.md +32 -0
  106. package/vendor/skills/thorough-code-review/examples.md +133 -0
  107. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -0
  108. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -0
  109. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -0
  110. package/vendor/skills/thorough-code-review/reference/phase1c-coverage.md +44 -0
  111. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -0
  112. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -0
  113. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -0
  114. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -0
  115. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -0
  116. package/vendor/templates/adr/README.md +7 -0
  117. package/vendor/templates/adr/decisions.md +141 -0
  118. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -0
  119. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -0
  120. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -0
  121. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -0
  122. package/vendor/templates/project/claude/commands/forge-apply.md +75 -0
  123. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -0
  124. package/vendor/templates/project/claude/commands/forge-build.md +17 -0
  125. package/vendor/templates/project/claude/commands/forge-plan.md +12 -0
  126. package/vendor/templates/project/claude/commands/forge-skip.md +14 -0
  127. package/vendor/templates/project/claude/commands/forge-status.md +16 -0
  128. package/vendor/templates/project/claude/commands/forge.md +16 -0
  129. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -0
  130. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -0
  131. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -0
  132. package/vendor/templates/project/claude/rules/forge.md +16 -0
  133. package/vendor/templates/project/codex/rules/forge.md +10 -0
  134. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -0
  135. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -0
  136. package/vendor/templates/project/cursor/commands/forge-build.md +17 -0
  137. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -0
  138. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -0
  139. package/vendor/templates/project/cursor/commands/forge-status.md +16 -0
  140. package/vendor/templates/project/cursor/commands/forge.md +16 -0
  141. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -0
  142. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -0
  143. 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
+ }