@codedology/code-forge 0.14.1 → 0.15.1
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/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +495 -1
- package/docs/reference/config.md +7 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +22 -0
- package/scripts/first-use.mjs +11 -5
- package/skill/SKILL.md +4 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +28 -2
- package/skill/references/review.md +10 -2
- package/skill/references/security.md +2 -0
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +3 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +271 -50
- package/src/cli/gates.mjs +36 -6
- package/src/cli/ledger.mjs +21 -2
- package/src/cli/report.mjs +14 -0
- package/src/cli/review-file.mjs +56 -16
- package/src/cli/run.mjs +44 -7
- package/src/cli/status.mjs +7 -3
- package/src/config/validate.mjs +13 -1
- package/src/gates/acceptance.mjs +7 -3
- package/src/gates/expand.mjs +25 -2
- package/src/gates/run.mjs +38 -8
- package/src/ledger/report.mjs +29 -7
- package/src/review/accept-hit.mjs +22 -4
- package/src/review/engine.mjs +30 -10
- package/src/review/finding.schema.json +2 -1
- package/src/review/fixloop.mjs +79 -14
- package/src/review/gate-check.mjs +33 -7
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +196 -22
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +230 -6
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +74 -6
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +38 -8
package/src/cli/gates.mjs
CHANGED
|
@@ -39,11 +39,12 @@ import { writeSafe, redactJSON } from '../util/redact.mjs';
|
|
|
39
39
|
import { appendRow } from '../ledger/write.mjs';
|
|
40
40
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
41
41
|
import { detectGates } from '../gates/detect.mjs';
|
|
42
|
-
import { printableGates, scopeFields, thresholdOf } from '../gates/expand.mjs';
|
|
42
|
+
import { printableGates, scopeFields, testScope, thresholdOf } from '../gates/expand.mjs';
|
|
43
43
|
import { runGates } from '../gates/run.mjs';
|
|
44
44
|
import { runGatesGuarded } from '../proof/lock.mjs';
|
|
45
45
|
import { blockTree } from '../proof/worktree.mjs';
|
|
46
46
|
import { readRun } from '../state/run.mjs';
|
|
47
|
+
import { loadKey, signRow } from '../state/signer.mjs';
|
|
47
48
|
import { StateError } from '../state/paths.mjs';
|
|
48
49
|
import { scanFiles } from '../gates/secret-scan.mjs';
|
|
49
50
|
import { checkSafeEdit } from '../gates/safe-edit.mjs';
|
|
@@ -279,7 +280,29 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
279
280
|
let ran;
|
|
280
281
|
if (runFlag.value !== undefined && blockFlag.value !== undefined) {
|
|
281
282
|
const threshold = thresholdOf(configured?.full_suite_threshold_files);
|
|
282
|
-
|
|
283
|
+
/** @type {typeof gates & {repeatNewTests?: import('../gates/run.mjs').RepeatNewTests, lightTestFiles?: string[]}} */
|
|
284
|
+
let planned = gates;
|
|
285
|
+
if (configured?.repeat_new_tests !== undefined) {
|
|
286
|
+
const record = await readRun(runFlag.value);
|
|
287
|
+
const block = record.blocks?.[blockFlag.value];
|
|
288
|
+
if (block) planned = { ...gates,
|
|
289
|
+
repeatNewTests: { n: configured.repeat_new_tests, base: block.worktree?.base_sha ?? block.base_sha, owned: block.owned_files, kinds: block.owned_kinds, onSkip: (line) => err(`${line}\n`) },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// Missing records are diagnosed by the guarded runner, after its lock decision.
|
|
293
|
+
const block = await readRun(runFlag.value).then((record) => record.blocks?.[blockFlag.value]).catch((error) => {
|
|
294
|
+
if (error.code === 'no-run') return null;
|
|
295
|
+
throw error;
|
|
296
|
+
});
|
|
297
|
+
const light = block?.kind === 'light';
|
|
298
|
+
if (light) {
|
|
299
|
+
const scope = testScope({ testFiles: block.test_files, owned: block.owned_files, threshold: Infinity });
|
|
300
|
+
const command = planned.test;
|
|
301
|
+
const argv = Array.isArray(command) ? command : command?.argv;
|
|
302
|
+
if (scope.scope !== 'related' || !argv?.includes('{test_files}')) throw new StateError('light-tests', 'light-tests: block tests and a gates.test {test_files} item are required');
|
|
303
|
+
planned = { ...planned, lightTestFiles: scope.files };
|
|
304
|
+
}
|
|
305
|
+
const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates: planned, cwd, threshold, changed: light ? false : changed, slug, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
283
306
|
if (guarded.result === 'proof.busy') {
|
|
284
307
|
out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
|
|
285
308
|
return 1;
|
|
@@ -289,24 +312,31 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
289
312
|
ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
290
313
|
}
|
|
291
314
|
const { results, allOk } = ran;
|
|
315
|
+
const repeats = results.find((r) => r.repeats)?.repeats;
|
|
292
316
|
|
|
293
317
|
const ledgerSlug = slug ?? ran.ledger_slug;
|
|
294
318
|
if (ledgerSlug) {
|
|
319
|
+
// Preserve the existing rows when off; repeat evidence is covered by the run signature.
|
|
320
|
+
const writeGateRow = async (/** @type {Record<string, any>} */ row) => repeats && runFlag.value !== undefined
|
|
321
|
+
? appendRow(signRow({ run: runFlag.value, ...row }, await loadKey(runFlag.value)), { slug: ledgerSlug })
|
|
322
|
+
: appendRow(row, { slug: ledgerSlug });
|
|
295
323
|
// B60: with --run/--block the rows name the run and the block, so the run's binnacle reads them
|
|
296
324
|
const owner = runFlag.value !== undefined ? { run: runFlag.value, block: blockFlag.value, ...inTree } : {};
|
|
297
325
|
for (const result of results) {
|
|
298
326
|
if (!result.skipped && !result.ok) {
|
|
299
|
-
await
|
|
327
|
+
await writeGateRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result),
|
|
328
|
+
...(result.reason === 'flaky-test' ? { reason: 'flaky-test' } : {}),
|
|
329
|
+
...(result.repeats ? { repeats: result.repeats } : {}) });
|
|
300
330
|
}
|
|
301
331
|
}
|
|
302
332
|
// B60: one scope per run (every `{test_files}` gate shares it), named on gate.done too;
|
|
303
333
|
// without --run/--block that is `scope: full, reason: no_tests`
|
|
304
334
|
const scoped = results.find((r) => r.scope !== undefined);
|
|
305
|
-
await
|
|
335
|
+
await writeGateRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
|
|
306
336
|
...(ran.changed_scope ? scopeFields(ran.changed_scope) : scoped ? scopeOf(scoped) : {}),
|
|
307
|
-
...(ran.files ? { files: ran.files } : {})
|
|
337
|
+
...(ran.files ? { files: ran.files } : {}), ...(repeats ? { repeats } : {}) });
|
|
308
338
|
}
|
|
309
|
-
out({ stack: gates.stack, ...inTree, results, allOk });
|
|
339
|
+
out({ stack: gates.stack, ...inTree, results, allOk, ...(repeats ? { repeats } : {}) });
|
|
310
340
|
return allOk ? 0 : 1;
|
|
311
341
|
}
|
|
312
342
|
|
package/src/cli/ledger.mjs
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* coder one. A later call for the same PR replaces the earlier row in every reader.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
23
24
|
import { buildCalibration } from '../ledger/calibration.mjs';
|
|
25
|
+
import { ledgerDir, ledgerPath } from '../ledger/paths.mjs';
|
|
24
26
|
import { recordCiOutcome, reviewedEntriesFromRows, scanGitAndRecord } from '../ledger/outcome.mjs';
|
|
25
27
|
import { OUTSIDE_REVIEW_EVENT, OUTSIDE_REVIEW_USAGE, outsideReviewRecordedLine, parseOutsideReviewArgs } from '../ledger/outside-review.mjs';
|
|
26
28
|
import { roundUsd, runSpend } from '../ledger/spend.mjs';
|
|
@@ -62,6 +64,23 @@ function parseNonNegativeInt(raw) {
|
|
|
62
64
|
return Number.parseInt(raw, 10);
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/** Readers distinguish an absent ledger from an existing empty file. @param {string} slug */
|
|
68
|
+
async function readExistingRows(slug) {
|
|
69
|
+
try {
|
|
70
|
+
await stat(ledgerPath(slug));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code !== 'ENOENT') throw error;
|
|
73
|
+
const entries = await readdir(ledgerDir(), { withFileTypes: true }).catch((e) => {
|
|
74
|
+
if (e.code === 'ENOENT') return [];
|
|
75
|
+
throw e;
|
|
76
|
+
});
|
|
77
|
+
const slugs = [...new Set(entries.filter((e) => e.isFile() && /^[a-z0-9][a-z0-9-]*(?:\.\d+)?\.jsonl$/.test(e.name))
|
|
78
|
+
.map((e) => e.name.replace(/(?:\.\d+)?\.jsonl$/, '')))].sort();
|
|
79
|
+
throw new Error(`no ledger for slug "${slug}"; known slugs: ${slugs.join(', ') || 'none'}`);
|
|
80
|
+
}
|
|
81
|
+
return readAllRows(slug);
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* @param {string[]} args
|
|
67
86
|
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
|
|
@@ -89,7 +108,7 @@ export async function runLedger(args, deps = {}) {
|
|
|
89
108
|
err('ledger calibration: --question requires a value\n');
|
|
90
109
|
return 2;
|
|
91
110
|
}
|
|
92
|
-
const rows = await
|
|
111
|
+
const rows = await readExistingRows(slug);
|
|
93
112
|
const answers = rows.filter((r) => r.event === 'decision' && typeof r.confidence === 'number');
|
|
94
113
|
const outcomes = rows.filter((r) => r.event === 'outcome' && r.decision_id);
|
|
95
114
|
const { buckets, unbacked, unmapped } = buildCalibration(answers, outcomes, { questionId: question.value });
|
|
@@ -161,7 +180,7 @@ export async function runLedger(args, deps = {}) {
|
|
|
161
180
|
err(`ledger tail: --n/-n must be a non-negative integer, got "${nArg}"\n`);
|
|
162
181
|
return 2;
|
|
163
182
|
}
|
|
164
|
-
const rows = await
|
|
183
|
+
const rows = await readExistingRows(slug);
|
|
165
184
|
for (const row of n === 0 ? [] : rows.slice(-n)) {
|
|
166
185
|
out(`${JSON.stringify(row)}\n`);
|
|
167
186
|
}
|
package/src/cli/report.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* printed as text lines under its header (`outside review: none`, or one line per PR).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
9
10
|
import { writeSafe } from '../util/redact.mjs';
|
|
11
|
+
import { ledgerDir, ledgerPath } from '../ledger/paths.mjs';
|
|
10
12
|
import { buildReport, buildRunModes, buildSpend } from '../ledger/report.mjs';
|
|
11
13
|
import { exportReportSections } from '../ledger/export.mjs';
|
|
12
14
|
import { outsideReviewLines } from '../ledger/outside-review.mjs';
|
|
@@ -46,6 +48,18 @@ export async function runReport(args, deps = {}) {
|
|
|
46
48
|
// try/catch below turns that, and any other I/O failure, into a clean CLI message instead of a
|
|
47
49
|
// raw stack trace that skips the `writeSafe` redaction path.
|
|
48
50
|
try {
|
|
51
|
+
try {
|
|
52
|
+
await stat(ledgerPath(slug));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code !== 'ENOENT') throw error;
|
|
55
|
+
const entries = await readdir(ledgerDir(), { withFileTypes: true }).catch((e) => {
|
|
56
|
+
if (e.code === 'ENOENT') return [];
|
|
57
|
+
throw e;
|
|
58
|
+
});
|
|
59
|
+
const slugs = [...new Set(entries.filter((e) => e.isFile() && /^[a-z0-9][a-z0-9-]*(?:\.\d+)?\.jsonl$/.test(e.name))
|
|
60
|
+
.map((e) => e.name.replace(/(?:\.\d+)?\.jsonl$/, '')))].sort();
|
|
61
|
+
throw new Error(`no ledger for slug "${slug}"; known slugs: ${slugs.join(', ') || 'none'}`);
|
|
62
|
+
}
|
|
49
63
|
const rows = await readAllRows(slug);
|
|
50
64
|
const { sections } = buildReport(rows);
|
|
51
65
|
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -73,12 +73,14 @@ import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dro
|
|
|
73
73
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
74
74
|
import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
|
|
75
75
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
76
|
+
import { budgetFor } from '../review/packet.mjs';
|
|
76
77
|
import { ownsFile } from '../state/registry.mjs';
|
|
77
78
|
import { readAllRows } from '../ledger/write.mjs';
|
|
78
79
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
79
80
|
import { StateError } from '../state/paths.mjs';
|
|
80
81
|
import { assertRunId } from '../state/paths.mjs';
|
|
81
82
|
import { readRun } from '../state/run.mjs';
|
|
83
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
82
84
|
import { blockTree } from '../proof/worktree.mjs';
|
|
83
85
|
import { writeSafe } from '../util/redact.mjs';
|
|
84
86
|
|
|
@@ -132,6 +134,18 @@ function resultOfMarker(repoRoot, text) {
|
|
|
132
134
|
/** B75: the refused reasons that are not a verdict on the content (`--fresh` re-runs them). */
|
|
133
135
|
const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* A split is reusable only under the budget it measured; absent metadata fails closed.
|
|
139
|
+
* @param {Record<string, any> | null} result @param {Record<string, any> | null} cfg
|
|
140
|
+
* @returns {{old: number, current: number} | null}
|
|
141
|
+
*/
|
|
142
|
+
function splitBudgetChange(result, cfg) {
|
|
143
|
+
if (!cfg || !result || (result.status !== 'split_required' && !(result.status === 'stopped' && result.stopped === 'split_required'))) return null;
|
|
144
|
+
if (!['quick', 'full', 'A', 'B', 'recheck'].includes(result.lens) || !Number.isFinite(result.budget) || result.budget <= 0) return null;
|
|
145
|
+
const current = budgetFor(cfg, result.lens === 'quick' ? 'quick_in' : 'full_in');
|
|
146
|
+
return result.budget !== current ? { old: result.budget, current } : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
/**
|
|
136
150
|
* B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
|
|
137
151
|
* result that never counted as a review — `stale`, `unavailable`, round-1 `split_required`, or
|
|
@@ -141,9 +155,10 @@ const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
|
|
|
141
155
|
* ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
|
|
142
156
|
* result).
|
|
143
157
|
* @param {string} text - the done marker's text @param {Record<string, any> | null} result
|
|
158
|
+
* @param {Record<string, any> | null} [cfg] - the reloaded config, when readable.
|
|
144
159
|
* @returns {'rerun' | 'tampered' | 'counted'}
|
|
145
160
|
*/
|
|
146
|
-
export function freshVerdict(text, result) {
|
|
161
|
+
export function freshVerdict(text, result, cfg = null) {
|
|
147
162
|
let marker;
|
|
148
163
|
try {
|
|
149
164
|
marker = JSON.parse(text);
|
|
@@ -151,6 +166,7 @@ export function freshVerdict(text, result) {
|
|
|
151
166
|
return 'counted';
|
|
152
167
|
}
|
|
153
168
|
if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
|
|
169
|
+
if (splitBudgetChange(result, cfg)) return 'rerun';
|
|
154
170
|
if (result.status === 'stale' || result.status === 'unavailable' || result.status === 'split_required') return 'rerun';
|
|
155
171
|
if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
|
|
156
172
|
if (result.reason === 'review-state-tampered') return 'tampered';
|
|
@@ -168,10 +184,10 @@ const RETRY_STATUSES = new Set(['stale', 'unavailable', 'split_required']);
|
|
|
168
184
|
|
|
169
185
|
/**
|
|
170
186
|
* Classify in place and use the same byte-checked claim for fresh and automatic retries.
|
|
171
|
-
* @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>}} opts
|
|
172
|
-
* @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept'}}
|
|
187
|
+
* @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>, cfg?: Record<string, any> | null}} opts
|
|
188
|
+
* @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept', budgetChange?: {old: number, current: number} | null}}
|
|
173
189
|
*/
|
|
174
|
-
function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses }) {
|
|
190
|
+
function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses, cfg = null }) {
|
|
175
191
|
const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
|
|
176
192
|
/** @type {'rerun' | 'tampered' | 'counted'} */
|
|
177
193
|
let verdict = 'counted';
|
|
@@ -189,7 +205,7 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
|
|
|
189
205
|
// classified IN PLACE: a refusal never moves, renames or touches the marker
|
|
190
206
|
const text = bytes.toString('utf8');
|
|
191
207
|
const result = resultOfMarker(repoRoot, text);
|
|
192
|
-
verdict = freshVerdict(text, result);
|
|
208
|
+
verdict = freshVerdict(text, result, cfg);
|
|
193
209
|
if (statuses && !statuses.has(result?.status)) verdict = 'counted';
|
|
194
210
|
if (verdict !== 'rerun') break;
|
|
195
211
|
/** @type {unknown} */
|
|
@@ -208,7 +224,7 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
|
|
|
208
224
|
if (outcome === 'kept') break;
|
|
209
225
|
// an allow-list of outcomes: anything else is refused and nothing is queued
|
|
210
226
|
if (outcome !== 'dropped' && outcome !== 'gone') throw new WorkerError('fresh-claim-unknown', `the claim of the cached result of ${file} gave an unknown outcome; nothing was queued`);
|
|
211
|
-
return { verdict, outcome };
|
|
227
|
+
return { verdict, outcome, budgetChange: outcome === 'dropped' ? splitBudgetChange(result, cfg) : null };
|
|
212
228
|
}
|
|
213
229
|
return { verdict, outcome: 'kept' };
|
|
214
230
|
}
|
|
@@ -223,14 +239,15 @@ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = f
|
|
|
223
239
|
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
224
240
|
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
225
241
|
* B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
|
|
226
|
-
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
|
|
242
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean, cfg?: Record<string, any> | null, stderr: {write: (s: string) => unknown}}} opts
|
|
227
243
|
* @returns {number} the exit code.
|
|
228
244
|
*/
|
|
229
|
-
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
|
|
245
|
+
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false, cfg = null, stderr }) {
|
|
230
246
|
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
231
|
-
const { verdict, outcome } = dropFreshDone({ repoRoot, ticket, file, dropDone });
|
|
247
|
+
const { verdict, outcome, budgetChange } = dropFreshDone({ repoRoot, ticket, file, dropDone, cfg });
|
|
232
248
|
if (outcome === 'dropped' || outcome === 'gone') {
|
|
233
249
|
const queued = enqueue({ repoRoot, run, block, file, ...where });
|
|
250
|
+
if (budgetChange) writeSafe(stderr, `split_required at budget ${budgetChange.old}; budget is now ${budgetChange.current}: reviewing again\n`);
|
|
234
251
|
say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
|
|
235
252
|
return 0;
|
|
236
253
|
}
|
|
@@ -417,6 +434,17 @@ function blockTicketMarker(repoRoot, ticket) {
|
|
|
417
434
|
throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
|
|
418
435
|
}
|
|
419
436
|
|
|
437
|
+
/** Progress only: legacy reviewed markers encode approval by absence of open_findings.
|
|
438
|
+
* @param {Record<string, any> | null} marker
|
|
439
|
+
* @returns {'approved' | 'findings' | 'stopped' | 'split_required' | 'unavailable' | null}
|
|
440
|
+
*/
|
|
441
|
+
function blockTicketOutcome(marker) {
|
|
442
|
+
if (!marker) return null;
|
|
443
|
+
if (marker.status === 'reviewed') return marker.open_findings === true || marker.approved === false ? 'findings' : 'approved';
|
|
444
|
+
if (marker.status === 'stopped' || marker.status === 'split_required') return marker.status;
|
|
445
|
+
return 'unavailable';
|
|
446
|
+
}
|
|
447
|
+
|
|
420
448
|
/**
|
|
421
449
|
* A block wait uses unsigned results only to classify retries; it never reads the signing key.
|
|
422
450
|
* Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
|
|
@@ -443,15 +471,15 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
443
471
|
if (!present && previous.length > 0) {
|
|
444
472
|
const newest = previous[previous.length - 1];
|
|
445
473
|
const marker = blockTicketMarker(repoRoot, newest.ticket);
|
|
446
|
-
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
|
|
474
|
+
if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker) };
|
|
447
475
|
if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
|
|
448
476
|
if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
|
|
449
477
|
deliveredHere.add(newest.ticket);
|
|
450
|
-
return { file, ticket: newest.ticket, status: 'findings' };
|
|
478
|
+
return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker) };
|
|
451
479
|
}
|
|
452
480
|
}
|
|
453
481
|
}
|
|
454
|
-
if (!present &&
|
|
482
|
+
if (!present && live) {
|
|
455
483
|
enqueue(opts);
|
|
456
484
|
present = { ...previous[previous.length - 1], ticket };
|
|
457
485
|
}
|
|
@@ -460,12 +488,17 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
|
|
|
460
488
|
dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
|
|
461
489
|
marker = blockTicketMarker(repoRoot, ticket);
|
|
462
490
|
}
|
|
463
|
-
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending' };
|
|
491
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker) };
|
|
464
492
|
});
|
|
465
493
|
if (downPolls >= 2) {
|
|
466
494
|
say({ block, files, done: false, status: 'worker_down' });
|
|
467
495
|
return 3;
|
|
468
496
|
}
|
|
497
|
+
const split = files.find((f) => f.outcome === 'split_required');
|
|
498
|
+
if (live && split) {
|
|
499
|
+
say({ block, files, done: false, reason: 'split_required', next: `The owner raises review.budgets for ${split.file}, runs run reload, then review-file ${split.file} --fresh.` });
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
469
502
|
if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
|
|
470
503
|
say({ block, files, done: true });
|
|
471
504
|
return 0;
|
|
@@ -541,10 +574,17 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
541
574
|
const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
|
|
542
575
|
assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
|
|
543
576
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
544
|
-
|
|
577
|
+
let cfg = null;
|
|
578
|
+
try {
|
|
579
|
+
cfg = configInForce(await readRun(worker.run));
|
|
580
|
+
} catch {
|
|
581
|
+
// No readable snapshot: retain the existing cache and --fresh rules.
|
|
582
|
+
}
|
|
583
|
+
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
545
584
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
546
|
-
|
|
547
|
-
|
|
585
|
+
const cached = queued.status === 'done' ? doneResult(repoRoot, queued.ticket) : null;
|
|
586
|
+
if (cached && (['unavailable', 'split_required'].includes(cached.status) || splitBudgetChange(cached, cfg))) {
|
|
587
|
+
return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
548
588
|
}
|
|
549
589
|
say(queued);
|
|
550
590
|
return 0;
|
package/src/cli/run.mjs
CHANGED
|
@@ -31,14 +31,18 @@
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import { reloadFailureText, reloadWorkspace } from '../autopilot/limits.mjs';
|
|
33
33
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
|
|
34
|
+
import { reviewWorkers } from '../config/validate.mjs';
|
|
34
35
|
import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
|
|
35
36
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
36
37
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
37
38
|
import { StateError } from '../state/paths.mjs';
|
|
39
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
38
40
|
import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
|
|
39
41
|
import { writeSafe } from '../util/redact.mjs';
|
|
42
|
+
import { withLock } from '../util/file-locks.mjs';
|
|
40
43
|
import { sweepRoots, tmpBase } from '../util/tmp.mjs';
|
|
41
44
|
import { launchWorker, repinWorker } from '../worker/loop.mjs';
|
|
45
|
+
import { liveWorker, workerRegistry } from '../worker/queue.mjs';
|
|
42
46
|
import { repoRootOf } from '../worker/ticket.mjs';
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -69,15 +73,18 @@ const ledgerWriter = (slug) => (/** @type {Record<string, any>} */ row) => appen
|
|
|
69
73
|
* A worker that `run start` launched but could not pin must not outlive the command as an orphan:
|
|
70
74
|
* stop it through the pinned-worker stopper (pid > 1, start time re-checked right before the
|
|
71
75
|
* signal, so a recycled pid is never signalled) and name the pid on stderr. `startedAt` is the
|
|
72
|
-
* start time read right after the launch; null means the process was already gone.
|
|
73
|
-
*
|
|
76
|
+
* start time read right after the launch; null means the process was already gone. When that
|
|
77
|
+
* probe threw (`undefined`), use the OS probe to obtain and re-check the identity for cleanup.
|
|
78
|
+
* @param {{pid: number, startedAt: string | null | undefined}} launched
|
|
74
79
|
* @param {{stopWorker: StopWorker, probe?: import('../state/run.mjs').StartTimeProbe, err: (s: string) => void}} deps
|
|
75
80
|
*/
|
|
76
81
|
async function stopLaunched({ pid, startedAt }, { stopWorker, probe, err }) {
|
|
77
82
|
/** @type {{stopped: boolean, reason?: string}} */
|
|
78
83
|
let outcome;
|
|
79
84
|
try {
|
|
80
|
-
|
|
85
|
+
const cleanupProbe = startedAt === undefined ? processStartTime : probe;
|
|
86
|
+
if (startedAt === undefined) startedAt = await processStartTime(pid);
|
|
87
|
+
outcome = startedAt === null ? { stopped: false, reason: 'not running' } : await stopWorker({ pid, started_at: startedAt }, { ...(cleanupProbe ? { probe: cleanupProbe } : {}) });
|
|
81
88
|
} catch (thrown) {
|
|
82
89
|
outcome = { stopped: false, reason: thrown?.message ?? String(thrown) };
|
|
83
90
|
}
|
|
@@ -107,13 +114,42 @@ export async function runRun(args, deps = {}) {
|
|
|
107
114
|
return 2;
|
|
108
115
|
}
|
|
109
116
|
try {
|
|
110
|
-
const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach'] });
|
|
117
|
+
const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach', 'add-worker'] });
|
|
111
118
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
112
119
|
const workerPid = intFlag(flags['worker-pid'], 'worker-pid');
|
|
113
120
|
const mode = typeof flags.mode === 'string' ? flags.mode : undefined;
|
|
114
|
-
if (mode !== undefined && (sub !== 'start' || flags.reattach)) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
|
|
121
|
+
if (mode !== undefined && (sub !== 'start' || flags.reattach || flags['add-worker'])) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
|
|
115
122
|
if (mode !== undefined && !RUN_MODES.includes(mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
|
|
116
123
|
|
|
124
|
+
if (flags['add-worker']) {
|
|
125
|
+
if (sub !== 'start' || !runId || flags.reattach || workerPid !== undefined) throw new StateError('usage', 'run start --add-worker needs --run and cannot use --reattach or --worker-pid');
|
|
126
|
+
if (!startWorker) throw new StateError('usage', 'run start --add-worker needs a worker launcher');
|
|
127
|
+
return await withLock(`worker-add:${runId}`, async () => {
|
|
128
|
+
const record = await readRun(runId);
|
|
129
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
130
|
+
const repoRoot = await repoRootOf(record.workspace);
|
|
131
|
+
const loaded = record.config ? { ok: true, config: configInForce(record) } : await loadProjectConfig(record.workspace);
|
|
132
|
+
if (!loaded.ok) throw new StateError('config', 'the project config cannot be loaded');
|
|
133
|
+
const limit = reviewWorkers(loaded.config);
|
|
134
|
+
const entries = workerRegistry(repoRoot, { prune: true });
|
|
135
|
+
const legacy = liveWorker(repoRoot);
|
|
136
|
+
const count = entries.filter((e) => e.live).length + (legacy && !entries.some((e) => e.worker?.pid === legacy.pid && e.worker.start_time === legacy.start_time) ? 1 : 0);
|
|
137
|
+
if (count >= limit) throw new StateError('worker-running', `review.workers limit ${limit} reached`);
|
|
138
|
+
const launched = await startWorker({ runId, workspace: record.workspace });
|
|
139
|
+
/** @type {string | null | undefined} */
|
|
140
|
+
let startedAt;
|
|
141
|
+
try {
|
|
142
|
+
startedAt = await (probe ?? processStartTime)(launched.pid);
|
|
143
|
+
await repinWorker({ runId, repoRoot: launched.repoRoot, expectPid: launched.pid, add: true, writeRow: ledgerWriter(record.project), ...(probe ? { probe } : {}) });
|
|
144
|
+
} catch (thrown) {
|
|
145
|
+
await stopLaunched({ pid: launched.pid, startedAt }, { stopWorker: stopWorker ?? stopPinnedWorker, probe, err });
|
|
146
|
+
throw thrown;
|
|
147
|
+
}
|
|
148
|
+
out(`run ${runId}: added worker pid ${launched.pid} (review.workers limit ${limit})\n`);
|
|
149
|
+
return 0;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
117
153
|
if (sub === 'start' && flags.reattach) {
|
|
118
154
|
if (!runId) throw new StateError('usage', 'run start --reattach needs --run');
|
|
119
155
|
const { project, workspace } = await readRun(runId);
|
|
@@ -159,7 +195,8 @@ export async function runRun(args, deps = {}) {
|
|
|
159
195
|
const record = await readRun(runId);
|
|
160
196
|
const { run_id, status, engine, blocks, orphans } = record;
|
|
161
197
|
const spend = await spendOf(record);
|
|
162
|
-
|
|
198
|
+
const awaiting = Object.keys(blocks).filter((id) => blocks[id].status === 'awaiting_human_check').sort();
|
|
199
|
+
out(`${JSON.stringify({ run_id, status, engine, worker_pinned: Boolean(record.worker), blocks, orphans, ...spend, ...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) }, null, 2)}\n`);
|
|
163
200
|
// on stderr, so stdout stays one JSON document
|
|
164
201
|
const n = spend.unknown_usd_sessions;
|
|
165
202
|
if (n > 0) err(`${n} session${n === 1 ? ' has' : 's have'} no known price; the budget does not count ${n === 1 ? 'it' : 'them'}\n`);
|
|
@@ -221,7 +258,7 @@ async function reloadVerb(runId, { out, err }) {
|
|
|
221
258
|
}
|
|
222
259
|
|
|
223
260
|
const USAGE =
|
|
224
|
-
'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
|
|
261
|
+
'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --add-worker --run <id> | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
|
|
225
262
|
|
|
226
263
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
227
264
|
export default async function run(args) {
|
package/src/cli/status.mjs
CHANGED
|
@@ -65,7 +65,7 @@ const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
|
|
|
65
65
|
const STATUS_MAX_LINES = 10;
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* @typedef {import('../state/brief-status.mjs').RunStatus} RunStatus
|
|
68
|
+
* @typedef {import('../state/brief-status.mjs').RunStatus & {awaiting_human_check?: string[]}} RunStatus
|
|
69
69
|
*/
|
|
70
70
|
|
|
71
71
|
/** @param {string} p @returns {Promise<string>} */
|
|
@@ -108,7 +108,9 @@ async function statusOf(record) {
|
|
|
108
108
|
if (list) list.push(r);
|
|
109
109
|
else byBlock.set(r.block, [r]);
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
const awaiting = Object.keys(record.blocks ?? {}).filter((id) => record.blocks[id].status === 'awaiting_human_check').sort();
|
|
112
|
+
return { ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
|
|
113
|
+
...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
/**
|
|
@@ -152,12 +154,14 @@ export function runLines(s) {
|
|
|
152
154
|
const spent = s.spent_usd === null ? '$?' : `${usd(s.spent_usd)}${(s.unknown_usd_sessions ?? 0) > 0 ? '+?' : ''}`;
|
|
153
155
|
const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
|
|
154
156
|
const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
|
|
155
|
-
const
|
|
157
|
+
const awaiting = s.awaiting_human_check?.length ? `awaiting_human_check: ${JSON.stringify(s.awaiting_human_check)}` : null;
|
|
158
|
+
const room = STATUS_MAX_LINES - 1 - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
|
|
156
159
|
const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
|
|
157
160
|
for (const b of shown) {
|
|
158
161
|
lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
|
|
159
162
|
}
|
|
160
163
|
if (shown.length < s.blocks.length) lines.push(`… +${s.blocks.length - shown.length} more blocks`);
|
|
164
|
+
if (awaiting !== null) lines.push(awaiting);
|
|
161
165
|
if (tally !== null) lines.push(tally);
|
|
162
166
|
return lines;
|
|
163
167
|
}
|
package/src/config/validate.mjs
CHANGED
|
@@ -513,7 +513,12 @@ function gateMessages(key, value) {
|
|
|
513
513
|
}
|
|
514
514
|
|
|
515
515
|
function checkGatesNotArgvArray(cfg) {
|
|
516
|
-
|
|
516
|
+
const messages = GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]));
|
|
517
|
+
if (Object.hasOwn(cfg?.gates ?? {}, 'repeat_new_tests')) {
|
|
518
|
+
const n = cfg.gates.repeat_new_tests;
|
|
519
|
+
if (!Number.isInteger(n) || n < 2 || n > 10) messages.push('gates.repeat_new_tests must be an integer 2..10');
|
|
520
|
+
}
|
|
521
|
+
return messages.map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message }));
|
|
517
522
|
}
|
|
518
523
|
|
|
519
524
|
// ── 10. secret-looking-value (ERROR) ────────────────────────────────────────
|
|
@@ -1169,3 +1174,10 @@ export { schema as configSchema };
|
|
|
1169
1174
|
export function reviewFileLoop(cfg) {
|
|
1170
1175
|
return cfg?.review?.file_loop ?? 'wait';
|
|
1171
1176
|
}
|
|
1177
|
+
|
|
1178
|
+
/** Maximum workers for a validated config (the schema restricts it to 1..4).
|
|
1179
|
+
* @param {Record<string, any>} cfg @returns {number}
|
|
1180
|
+
*/
|
|
1181
|
+
export function reviewWorkers(cfg) {
|
|
1182
|
+
return cfg?.review?.workers ?? 1;
|
|
1183
|
+
}
|
package/src/gates/acceptance.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Acceptance-clause coverage (plan §4.6 row 4, O17): "every clause names ≥ 1 test id; each named
|
|
2
|
+
* Acceptance-clause coverage (plan §4.6 row 4, O17): "every code clause names ≥ 1 test id; each named
|
|
3
3
|
* test exists in the tree and passed in this gate run (parsed from the runner output)." This is
|
|
4
4
|
* DETERMINISTIC — S1's `scope` question (§3.2) judges only whether hunks OUTSIDE the named tests
|
|
5
5
|
* are unrelated; whether a named test id actually passed is a fact, never a model's opinion.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* @typedef {{clause: string, tests
|
|
15
|
+
* @typedef {{clause: string, kind?: string, tests?: string[]}} AcceptanceClause
|
|
16
16
|
* @typedef {{ok: true} | {ok: false, reason: string, clause: string, test: string}} AcceptanceResult
|
|
17
17
|
*/
|
|
18
18
|
|
|
@@ -57,7 +57,7 @@ export function parseTapPassed(output) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* @param {ReadonlyArray<AcceptanceClause>} clauses -
|
|
60
|
+
* @param {ReadonlyArray<AcceptanceClause>} clauses - code clauses name ≥ 1 test id; human checks name none.
|
|
61
61
|
* @param {Set<string> | ReadonlyArray<string>} passed - test ids that passed in THIS gate run.
|
|
62
62
|
* @param {Set<string> | ReadonlyArray<string>} [failed] - test ids that FAILED in this gate run.
|
|
63
63
|
* `parseTapPassed` flattens every file's/describe-block's TAP subtests into one global name set,
|
|
@@ -77,6 +77,10 @@ export function checkAcceptance(clauses, passed, failed = new Set()) {
|
|
|
77
77
|
for (const entry of clauses) {
|
|
78
78
|
const clause = entry?.clause;
|
|
79
79
|
const tests = entry?.tests;
|
|
80
|
+
if (entry?.kind === 'human_check' && typeof clause === 'string' && clause.length > 0) {
|
|
81
|
+
if (Object.hasOwn(entry, 'tests')) return { ok: false, reason: 'human_check clause must not name tests', clause, test: '' };
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
80
84
|
if (typeof clause !== 'string' || clause.length === 0 || !Array.isArray(tests) || tests.length === 0) {
|
|
81
85
|
return { ok: false, reason: 'clause names no test id', clause: String(clause ?? ''), test: '' };
|
|
82
86
|
}
|
package/src/gates/expand.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import os from 'node:os';
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { isExactEntry, ownsFile } from '../state/registry.mjs';
|
|
29
29
|
import { pairsFor } from '../review/paired.mjs';
|
|
30
|
+
import { computeFileSet } from './scope.mjs';
|
|
30
31
|
import { exec } from '../util/exec.mjs';
|
|
31
32
|
import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
32
33
|
|
|
@@ -66,6 +67,30 @@ export function isTestPath(p) {
|
|
|
66
67
|
return /\.(test|spec)\.[^/]+$/.test(name) || name.endsWith('Test.php') || name.endsWith('_test.go') || /^test_.*\.py$/.test(name);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/** The changed-test predicate, including Swift's conventional Tests suffix. @param {string} file */
|
|
71
|
+
export function isChangedTest(file) {
|
|
72
|
+
return isTestPath(file) || file.endsWith('Tests.swift');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Existing block tests added or changed since its base, independent of the last green scope
|
|
77
|
+
* and its threshold. Deleted tests and unchanged source-paired tests are not repeat candidates.
|
|
78
|
+
* @param {{cwd: string, base: string, owned: string[], kinds?: Record<string, string>}} opts
|
|
79
|
+
* @returns {Promise<string[]>}
|
|
80
|
+
*/
|
|
81
|
+
export async function changedTestFiles({ cwd, base, owned, kinds }) {
|
|
82
|
+
const { all } = await computeFileSet({ cwd, base });
|
|
83
|
+
const files = all.filter((file) => ownsFile(owned, file, kinds) && isChangedTest(file));
|
|
84
|
+
const existing = await Promise.all(files.map(async (file) => {
|
|
85
|
+
const stat = await lstat(path.join(cwd, file)).catch((error) => {
|
|
86
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
87
|
+
throw error;
|
|
88
|
+
});
|
|
89
|
+
return stat?.isFile() === true;
|
|
90
|
+
}));
|
|
91
|
+
return files.filter((_, i) => existing[i]);
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
/**
|
|
70
95
|
* An owned entry that names one file: no glob character (`*`, `?`, `{`) and no trailing `/`.
|
|
71
96
|
* @param {string} p @returns {boolean}
|
|
@@ -186,8 +211,6 @@ export function changedScope({ baseline, current, fallback, threshold = DEFAULT_
|
|
|
186
211
|
const changes = [...new Set([...Object.keys(old), ...Object.keys(current.files)])].filter((file) => old[file] !== current.files[file]).sort();
|
|
187
212
|
if (changes.length > threshold) return { scope: 'changed', files: [], changedFiles: [], reason: 'threshold' };
|
|
188
213
|
const changedFiles = changes.filter((file) => candidates.has(file));
|
|
189
|
-
// B97 also pairs Swift Tests names outside lowercase test directories.
|
|
190
|
-
const isChangedTest = (/** @type {string} */ file) => isTestPath(file) || file.endsWith('Tests.swift');
|
|
191
214
|
const tests = new Set(changedFiles.filter(isChangedTest));
|
|
192
215
|
for (const file of changes.filter((f) => !isChangedTest(f))) {
|
|
193
216
|
const pairs = pairsFor(file, current.candidates).filter(isChangedTest);
|