@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,413 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Carry verified findings forward from a parent review into a newer report.
|
|
4
|
+
*
|
|
5
|
+
* A finding is carried only when the file it points at is unchanged between
|
|
6
|
+
* the parent review's `scope.head_sha` and the current HEAD (and the file
|
|
7
|
+
* actually exists at that SHA) — otherwise the finding is skipped with a
|
|
8
|
+
* printed reason, because a re-verify against changed code is required.
|
|
9
|
+
*
|
|
10
|
+
* Carried findings get a fresh sequential F-### id continuing the target's
|
|
11
|
+
* numbering, and their `verdict_reason` is prefixed with the provenance
|
|
12
|
+
* (`Carried forward from <parent review_id> (unchanged since <sha8>): ...`).
|
|
13
|
+
* The target's summary verdict tallies are recomputed and the result must
|
|
14
|
+
* pass {@link validateReport} before anything is written.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* review carryforward --parent <reviewId|path> [options]
|
|
18
|
+
*
|
|
19
|
+
* Options:
|
|
20
|
+
* --parent <id|path> Parent review id (resolved to .reviews/<id>-review.json)
|
|
21
|
+
* or an explicit JSON path (required)
|
|
22
|
+
* --file <path> Target review JSON (default: newest *-review.json in
|
|
23
|
+
* .reviews/ that is not the parent and not kind=reverify)
|
|
24
|
+
* --dry-run Print the carry/skip plan, write nothing
|
|
25
|
+
* --reviews-dir <dir> Reviews directory (default: .reviews)
|
|
26
|
+
* --repo <dir> Git repo to diff against (default: cwd)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { pathToFileURL } from 'node:url';
|
|
33
|
+
import { REVIEWS_DIR, ALL_VERDICTS, countByVerdict, validateReport } from './lib.mjs';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string[]} argv
|
|
37
|
+
*/
|
|
38
|
+
export function parseArgs(argv) {
|
|
39
|
+
const opts = {
|
|
40
|
+
parent: null,
|
|
41
|
+
file: null,
|
|
42
|
+
dryRun: false,
|
|
43
|
+
reviewsDir: REVIEWS_DIR,
|
|
44
|
+
repo: null,
|
|
45
|
+
help: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
49
|
+
const arg = argv[i];
|
|
50
|
+
if (arg === '--parent') opts.parent = argv[++i];
|
|
51
|
+
else if (arg === '--file') opts.file = argv[++i];
|
|
52
|
+
else if (arg === '--dry-run') opts.dryRun = true;
|
|
53
|
+
else if (arg === '--reviews-dir') opts.reviewsDir = argv[++i];
|
|
54
|
+
else if (arg === '--repo') opts.repo = argv[++i];
|
|
55
|
+
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
56
|
+
else throw new Error(`unknown argument: ${arg}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return opts;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract the file path from a finding location by stripping a trailing
|
|
64
|
+
* `:line` or `:start-end` suffix.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} location
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function locationFile(location) {
|
|
70
|
+
return String(location).replace(/:\d+(-\d+)?$/, '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Next sequential F-### id continuing the given findings' numbering
|
|
75
|
+
* (dup-### ids are ignored).
|
|
76
|
+
*
|
|
77
|
+
* @param {Array<{ id?: string }>} findings
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function nextFindingId(findings) {
|
|
81
|
+
let max = 0;
|
|
82
|
+
for (const f of findings) {
|
|
83
|
+
const m = /^F-(\d{3})$/.exec(f.id ?? '');
|
|
84
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
85
|
+
}
|
|
86
|
+
return `F-${String(max + 1).padStart(3, '0')}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Copy a parent finding into a carried finding: fresh id, provenance-prefixed
|
|
91
|
+
* verdict_reason, everything else preserved.
|
|
92
|
+
*
|
|
93
|
+
* @param {Record<string, unknown>} finding
|
|
94
|
+
* @param {{ id: string, parentReviewId: string, shortSha: string }} ctx
|
|
95
|
+
* @returns {Record<string, unknown>}
|
|
96
|
+
*/
|
|
97
|
+
export function buildCarriedFinding(finding, ctx) {
|
|
98
|
+
/** @type {Record<string, unknown>} */
|
|
99
|
+
const carried = {
|
|
100
|
+
id: ctx.id,
|
|
101
|
+
lens: finding.lens,
|
|
102
|
+
location: finding.location,
|
|
103
|
+
claim: finding.claim,
|
|
104
|
+
severity: finding.severity,
|
|
105
|
+
verdict: finding.verdict,
|
|
106
|
+
verdict_reason:
|
|
107
|
+
`Carried forward from ${ctx.parentReviewId} (unchanged since ${ctx.shortSha}): ` +
|
|
108
|
+
`${finding.verdict_reason ?? ''}`,
|
|
109
|
+
};
|
|
110
|
+
for (const key of ['title', 'evidence', 'original_severity', 'phase1_confidence']) {
|
|
111
|
+
if (finding[key] !== undefined) carried[key] = finding[key];
|
|
112
|
+
}
|
|
113
|
+
return carried;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Recompute the target's summary verdict tallies from its findings so it
|
|
118
|
+
* reconciles with lib.mjs `validateSummary`: every verdict with a non-zero
|
|
119
|
+
* tally (or an already-present key) is set to the actual count, and
|
|
120
|
+
* `tentative_count` is bumped to at least the findings count. Non-count
|
|
121
|
+
* summary fields (headline, top_actions, ...) are preserved.
|
|
122
|
+
*
|
|
123
|
+
* @param {Record<string, unknown>} report Mutated in place.
|
|
124
|
+
*/
|
|
125
|
+
export function recomputeSummary(report) {
|
|
126
|
+
const summary = /** @type {Record<string, unknown>} */ (report.summary ?? {});
|
|
127
|
+
const counts = countByVerdict(report);
|
|
128
|
+
for (const verdict of ALL_VERDICTS) {
|
|
129
|
+
if (counts[verdict] > 0 || summary[verdict] !== undefined) {
|
|
130
|
+
summary[verdict] = counts[verdict];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const findings = /** @type {unknown[]} */ (report.findings ?? []);
|
|
134
|
+
const tentative = typeof summary.tentative_count === 'number' ? summary.tentative_count : 0;
|
|
135
|
+
summary.tentative_count = Math.max(tentative, findings.length);
|
|
136
|
+
report.summary = summary;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decide, per parent finding with a verdict, whether it carries into the
|
|
141
|
+
* target or is skipped (and why). Pure apart from the injected git ops.
|
|
142
|
+
*
|
|
143
|
+
* @param {Record<string, unknown>} parentReport
|
|
144
|
+
* @param {Record<string, unknown>} targetReport
|
|
145
|
+
* @param {{
|
|
146
|
+
* fileChanged: (sha: string, file: string) => boolean,
|
|
147
|
+
* fileExistsAt: (sha: string, file: string) => boolean,
|
|
148
|
+
* }} gitOps
|
|
149
|
+
* @returns {{
|
|
150
|
+
* carries: Array<{ parentId: string, finding: Record<string, unknown> }>,
|
|
151
|
+
* skips: Array<{ parentId: string, reason: string }>,
|
|
152
|
+
* shortSha: string | null,
|
|
153
|
+
* }}
|
|
154
|
+
*/
|
|
155
|
+
export function planCarries(parentReport, targetReport, gitOps) {
|
|
156
|
+
const scope = /** @type {{ head_sha?: unknown }} */ (parentReport.scope ?? {});
|
|
157
|
+
const headSha = typeof scope.head_sha === 'string' && scope.head_sha ? scope.head_sha : null;
|
|
158
|
+
const shortSha = headSha ? headSha.slice(0, 8) : null;
|
|
159
|
+
|
|
160
|
+
const parentFindings = /** @type {Array<Record<string, unknown>>} */ (
|
|
161
|
+
parentReport.findings ?? []
|
|
162
|
+
);
|
|
163
|
+
const targetFindings = /** @type {Array<Record<string, unknown>>} */ (
|
|
164
|
+
targetReport.findings ?? []
|
|
165
|
+
);
|
|
166
|
+
const present = new Set(targetFindings.map((f) => `${f.location}\u0000${f.claim}`));
|
|
167
|
+
|
|
168
|
+
/** @type {Array<{ parentId: string, finding: Record<string, unknown> }>} */
|
|
169
|
+
const carries = [];
|
|
170
|
+
/** @type {Array<{ parentId: string, reason: string }>} */
|
|
171
|
+
const skips = [];
|
|
172
|
+
const numbered = [...targetFindings];
|
|
173
|
+
|
|
174
|
+
for (const finding of parentFindings) {
|
|
175
|
+
if (finding.verdict === undefined) continue;
|
|
176
|
+
const parentId = /** @type {string} */ (finding.id ?? '(no id)');
|
|
177
|
+
|
|
178
|
+
if (!headSha) {
|
|
179
|
+
skips.push({ parentId, reason: 'parent report has no scope.head_sha' });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (present.has(`${finding.location}\u0000${finding.claim}`)) {
|
|
183
|
+
skips.push({ parentId, reason: 'already present in target (same location and claim)' });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const file = locationFile(/** @type {string} */ (finding.location ?? ''));
|
|
188
|
+
let changed;
|
|
189
|
+
try {
|
|
190
|
+
changed = gitOps.fileChanged(headSha, file);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
skips.push({ parentId, reason: `git error: ${/** @type {Error} */ (err).message}` });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (changed) {
|
|
196
|
+
skips.push({ parentId, reason: `${file} changed since ${shortSha}` });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!gitOps.fileExistsAt(headSha, file)) {
|
|
200
|
+
skips.push({ parentId, reason: `${file} does not exist at parent head_sha ${shortSha}` });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const carried = buildCarriedFinding(finding, {
|
|
205
|
+
id: nextFindingId(numbered),
|
|
206
|
+
parentReviewId: /** @type {string} */ (parentReport.review_id ?? '(unknown)'),
|
|
207
|
+
shortSha: /** @type {string} */ (shortSha),
|
|
208
|
+
});
|
|
209
|
+
numbered.push(carried);
|
|
210
|
+
present.add(`${carried.location}\u0000${carried.claim}`);
|
|
211
|
+
carries.push({ parentId, finding: carried });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { carries, skips, shortSha };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve `--parent` — a review id maps to `<reviewsDir>/<id>-review.json`,
|
|
219
|
+
* anything that exists as a path is used verbatim.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} parent
|
|
222
|
+
* @param {string} reviewsDir
|
|
223
|
+
* @param {string} cwd
|
|
224
|
+
* @returns {string | null}
|
|
225
|
+
*/
|
|
226
|
+
function resolveParentPath(parent, reviewsDir, cwd) {
|
|
227
|
+
const asPath = path.isAbsolute(parent) ? parent : path.resolve(cwd, parent);
|
|
228
|
+
if (fs.existsSync(asPath) && fs.statSync(asPath).isFile()) return asPath;
|
|
229
|
+
const asId = path.join(reviewsDir, `${parent}-review.json`);
|
|
230
|
+
if (fs.existsSync(asId)) return asId;
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Default target: newest `*-review.json` in the reviews dir that is not the
|
|
236
|
+
* parent and whose `kind` is not `reverify`.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} reviewsDir
|
|
239
|
+
* @param {string} parentPath
|
|
240
|
+
* @returns {string | null}
|
|
241
|
+
*/
|
|
242
|
+
function findDefaultTarget(reviewsDir, parentPath) {
|
|
243
|
+
if (!fs.existsSync(reviewsDir)) return null;
|
|
244
|
+
const candidates = fs
|
|
245
|
+
.readdirSync(reviewsDir)
|
|
246
|
+
.filter((name) => name.endsWith('-review.json'))
|
|
247
|
+
.map((name) => path.join(reviewsDir, name))
|
|
248
|
+
.filter((full) => path.resolve(full) !== path.resolve(parentPath))
|
|
249
|
+
.filter((full) => {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
252
|
+
return parsed?.kind !== 'reverify';
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
.map((full) => ({ full, mtime: fs.statSync(full).mtimeMs }))
|
|
258
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
259
|
+
return candidates[0]?.full ?? null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {string} repo
|
|
264
|
+
* @returns {{ fileChanged: (sha: string, file: string) => boolean, fileExistsAt: (sha: string, file: string) => boolean }}
|
|
265
|
+
*/
|
|
266
|
+
function makeGitOps(repo) {
|
|
267
|
+
return {
|
|
268
|
+
fileChanged(sha, file) {
|
|
269
|
+
// <sha> vs working tree (not <sha>..HEAD): uncommitted edits must block a carry
|
|
270
|
+
// in this repo's commit-late workflow.
|
|
271
|
+
const out = execFileSync('git', ['diff', '--name-only', sha, '--', file], {
|
|
272
|
+
cwd: repo,
|
|
273
|
+
encoding: 'utf8',
|
|
274
|
+
});
|
|
275
|
+
return out.trim() !== '';
|
|
276
|
+
},
|
|
277
|
+
fileExistsAt(sha, file) {
|
|
278
|
+
try {
|
|
279
|
+
execFileSync('git', ['cat-file', '-e', `${sha}:${file}`], { cwd: repo, stdio: 'ignore' });
|
|
280
|
+
return true;
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {string} jsonPath
|
|
290
|
+
* @returns {{ ok: true, report: Record<string, unknown> } | { ok: false, message: string }}
|
|
291
|
+
*/
|
|
292
|
+
function loadReport(jsonPath) {
|
|
293
|
+
if (!fs.existsSync(jsonPath)) {
|
|
294
|
+
return { ok: false, message: `file not found: ${jsonPath}` };
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
return { ok: true, report: JSON.parse(fs.readFileSync(jsonPath, 'utf8')) };
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return { ok: false, message: `invalid JSON in ${jsonPath}: ${/** @type {Error} */ (err).message}` };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {ReturnType<typeof parseArgs>} opts
|
|
305
|
+
* @param {string} [cwd]
|
|
306
|
+
* @returns {{ exitCode: number; message: string }}
|
|
307
|
+
*/
|
|
308
|
+
export function runCarryforward(opts, cwd = process.cwd()) {
|
|
309
|
+
if (!opts.parent) {
|
|
310
|
+
return { exitCode: 1, message: '--parent <reviewId|path> is required' };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const reviewsDir = path.resolve(cwd, opts.reviewsDir ?? REVIEWS_DIR);
|
|
314
|
+
const repo = opts.repo ? path.resolve(cwd, opts.repo) : cwd;
|
|
315
|
+
|
|
316
|
+
const parentPath = resolveParentPath(opts.parent, reviewsDir, cwd);
|
|
317
|
+
if (!parentPath) {
|
|
318
|
+
return {
|
|
319
|
+
exitCode: 1,
|
|
320
|
+
message: `parent review not found: ${opts.parent} (looked for a file and for ${path.join(reviewsDir, `${opts.parent}-review.json`)})`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const parentLoaded = loadReport(parentPath);
|
|
324
|
+
if (!parentLoaded.ok) return { exitCode: 1, message: parentLoaded.message };
|
|
325
|
+
const parentReport = parentLoaded.report;
|
|
326
|
+
|
|
327
|
+
const targetPath = opts.file
|
|
328
|
+
? path.isAbsolute(opts.file)
|
|
329
|
+
? opts.file
|
|
330
|
+
: path.resolve(cwd, opts.file)
|
|
331
|
+
: findDefaultTarget(reviewsDir, parentPath);
|
|
332
|
+
if (!targetPath) {
|
|
333
|
+
return { exitCode: 1, message: `no target review JSON found in ${reviewsDir}` };
|
|
334
|
+
}
|
|
335
|
+
const targetLoaded = loadReport(targetPath);
|
|
336
|
+
if (!targetLoaded.ok) return { exitCode: 1, message: targetLoaded.message };
|
|
337
|
+
const targetReport = targetLoaded.report;
|
|
338
|
+
|
|
339
|
+
const plan = planCarries(parentReport, targetReport, makeGitOps(repo));
|
|
340
|
+
|
|
341
|
+
const lines = [`parent: ${parentPath}`, `target: ${targetPath}`, ''];
|
|
342
|
+
for (const c of plan.carries) {
|
|
343
|
+
lines.push(`CARRY ${c.parentId} -> ${c.finding.id} ${c.finding.location}`);
|
|
344
|
+
}
|
|
345
|
+
for (const s of plan.skips) {
|
|
346
|
+
lines.push(`SKIP ${s.parentId} ${s.reason}`);
|
|
347
|
+
}
|
|
348
|
+
lines.push('');
|
|
349
|
+
lines.push(`${plan.carries.length} carried, ${plan.skips.length} skipped`);
|
|
350
|
+
|
|
351
|
+
if (opts.dryRun) {
|
|
352
|
+
lines.push('(dry-run — nothing written)');
|
|
353
|
+
return { exitCode: 0, message: lines.join('\n') };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (plan.carries.length > 0) {
|
|
357
|
+
const findings = /** @type {Array<Record<string, unknown>>} */ (targetReport.findings ?? []);
|
|
358
|
+
targetReport.findings = [...findings, ...plan.carries.map((c) => c.finding)];
|
|
359
|
+
recomputeSummary(targetReport);
|
|
360
|
+
|
|
361
|
+
const validation = validateReport(targetReport);
|
|
362
|
+
if (!validation.ok) {
|
|
363
|
+
return {
|
|
364
|
+
exitCode: 1,
|
|
365
|
+
message: `refusing to write ${targetPath} — validation failed:\n${validation.errors.map((e) => ` - ${e}`).join('\n')}`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
fs.writeFileSync(targetPath, `${JSON.stringify(targetReport, null, 2)}\n`, 'utf8');
|
|
370
|
+
lines.push(`wrote: ${targetPath}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { exitCode: 0, message: lines.join('\n') };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function printHelp() {
|
|
377
|
+
console.log(`Usage: review carryforward --parent <reviewId|path> [options]
|
|
378
|
+
|
|
379
|
+
Carry verified findings forward from a parent review into a newer report,
|
|
380
|
+
for findings whose files are unchanged since the parent's head SHA.
|
|
381
|
+
|
|
382
|
+
Options:
|
|
383
|
+
--parent <id|path> Parent review id (resolves to .reviews/<id>-review.json)
|
|
384
|
+
or an explicit JSON path (required)
|
|
385
|
+
--file <path> Target review JSON (default: newest *-review.json in
|
|
386
|
+
.reviews/ that is not the parent and not kind=reverify)
|
|
387
|
+
--dry-run Print the carry/skip plan, write nothing
|
|
388
|
+
-h, --help Show this help
|
|
389
|
+
`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function main() {
|
|
393
|
+
try {
|
|
394
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
395
|
+
if (opts.help) {
|
|
396
|
+
printHelp();
|
|
397
|
+
process.exit(0);
|
|
398
|
+
}
|
|
399
|
+
const result = runCarryforward(opts);
|
|
400
|
+
console.log(result.message);
|
|
401
|
+
process.exit(result.exitCode);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
console.error(/** @type {Error} */ (err).message);
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const isMain =
|
|
409
|
+
process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
410
|
+
|
|
411
|
+
if (isMain) {
|
|
412
|
+
main();
|
|
413
|
+
}
|