@codedology/code-forge 0.11.0 → 0.12.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/CHANGELOG.md +39 -0
- package/README.md +14 -2
- package/docs/reference/blocks.json +158 -1
- package/docs/reference/config.md +6 -5
- package/package.json +2 -2
- package/schema/code-forge.schema.json +3 -2
- package/skill/SKILL.md +2 -0
- package/skill/references/code.md +11 -4
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +6 -4
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +15 -3
- package/src/cli/block.mjs +14 -0
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +101 -3
- package/src/config/validate.mjs +12 -5
- package/src/context/pack.mjs +7 -4
- package/src/gates/expand.mjs +102 -9
- package/src/gates/run.mjs +6 -4
- package/src/install/wizard/flags.mjs +1 -1
- package/src/proof/lock.mjs +23 -10
- package/src/proof/rule.mjs +9 -0
- package/src/proof/tiers.mjs +2 -14
- package/src/review/fixloop.mjs +11 -6
- package/src/review/gate-check.mjs +2 -2
- package/src/review/lenses/A.md +4 -0
- package/src/review/lenses/B.md +4 -0
- package/src/review/lenses/full.md +4 -0
- package/src/review/lenses/judge.md +4 -0
- package/src/review/lenses/quick.md +4 -0
- package/src/review/lenses/recheck.md +4 -0
- package/src/review/paired.mjs +1 -0
- package/src/review/reopen.mjs +18 -6
- package/src/util/exec.mjs +2 -1
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/loop.mjs +16 -1
- package/src/worker/queue.mjs +60 -2
- package/src/worker/watch.mjs +113 -0
|
@@ -13,10 +13,22 @@
|
|
|
13
13
|
2. Your facts diff: every contradiction between this brief and the code, in one message.
|
|
14
14
|
3. Your forecast: cases and lines.
|
|
15
15
|
|
|
16
|
+
## Proof
|
|
17
|
+
|
|
18
|
+
- Red→green: each new or changed test must FAIL on the old code first (red), then PASS on the new code (green). A red caused by an import, compile or crash error proves nothing; the red must be an assertion failure. `code-forge proof red-green` does this with the block's base code.
|
|
19
|
+
- No hand mutations. Do not break the code on purpose to test the tests. Red→green is the proof.
|
|
20
|
+
- One exact test per acceptance clause, plus one negative or boundary test where the clause can fail. Assertions say which element and how many, never only "it exists".
|
|
21
|
+
- Proof scales with risk: a light-tier file needs red→green per new test; a high-tier file must have it at the gate. Do not add tests, mutants, layers, options or abstractions that the acceptance does not ask for.
|
|
22
|
+
- A weak assertion is fixed by a stronger assertion, not by more tests or mutants.
|
|
23
|
+
|
|
16
24
|
## The file loop
|
|
17
25
|
|
|
18
26
|
After you finish each file in `owned_files` (tests written and green) run `forge review-file <path> --block <id> --run <r>`; before the next file run `forge review-file --wait <ticket> [--max 90s]` and act on the packet. A finding whose fix belongs in another owned file (a missing test, say) is fixed there and sent with `forge review-file <that file> --block <id> --run <r> --answers <ids> --of <source>`. A `worker_down` answer ⇒ print `===BLOCK <id> FAILED: worker down===` (never approval).
|
|
19
27
|
|
|
28
|
+
With `review.file_loop: overlap`, enqueue after each green file and continue coding. Before the completion sentinel, run `forge review-file --wait-block <id> --run <r> [--max 90s]`. It prints one JSON line `{block, files: [{file, ticket, status}], done}`. Inspect each finished ticket with `--wait <ticket>` and act on its findings; `done: true` means finished, not approved. A timeout has `done: false`, `reason: wait_timeout` and pending files (exit 0): wait again. `not_queued` names an owned changed file you must enqueue. Changed content with an earlier ticket is queued automatically; the worker marks an old pending ticket stale when it starts it. `worker_down` exits 3, always with `done: false`: print the FAILED sentinel. Without a `review.file_loop` setting, or with `wait`, use the per-file loop above. Fix rounds use the same selected loop before their sentinel.
|
|
29
|
+
|
|
30
|
+
With `review.file_loop: watch` (B104), commit each owned file after its tests are green and continue coding. Watch replaces per-file enqueueing. Before the completion sentinel, including fix rounds, run `forge review-file --wait-block <id> --run <r> [--max 90s]`, inspect finished tickets with `--wait <ticket>`, and fix findings. The worker watches the block worktree HEAD (otherwise repository HEAD), queues owned content from git objects with the same ticket ids as a manual request, and marks older pending tickets `refused`, `reason: superseded`. A running ticket finishes for its old hash, which cannot approve newer bytes; `--wait-block` joins the current ticket per file. `--fresh` refuses a superseded marker; enqueueing that content again removes only its superseded marker so it can run, while a real result stays cached. `queue/watch/<block>.json` stores the last seen commit; missing or unreadable state restarts at `base_sha` with idempotent tickets. A rewound tick queues nothing and drops the last seen commit so the next tick restarts at `base_sha`. A `watch.skipped {block, reason}` row is written once per reason until a healthy head clears the skip reasons: `unreadable_head` (head, tree or objects cannot be read), `not_descendant` (head does not descend from the block base), `rewound` (head no longer descends from the last seen commit), or `unowned_path` (unowned paths are ignored; owned paths still queue). A skip never fails the block. After a `watch.skipped` row, use `forge review-file <path> --block <id> --run <r>` when the file needs a manual review; use `forge review-file <fix file> --block <id> --run <r> --answers <ids> --of <source>` for a fix in another owned file that answers findings. Set watch before starting the worker. With any `review.file_loop` value other than `watch` (`wait`, the default, or `overlap`), the worker does no watch work and runs no git for it.
|
|
31
|
+
|
|
20
32
|
Proof is behaviour, not text: never read a source file in a test to assert on its text. Call the code and check what it does; a test may read a fixture. A test file that reads a source file and asserts on its text gets a `## test-quality` section in its review packet. The section is a hint, not a finding: the reviewer checks those lines and, when the test really reads source text to assert on it, raises a `warning` (unless the file read is a fixture).
|
|
21
33
|
|
|
22
34
|
## Final report — the close prints the ticket ids
|
|
@@ -36,17 +48,17 @@ The `TESTS` line stays ABOVE the sentinel; only the test summary comes after it.
|
|
|
36
48
|
|
|
37
49
|
End with `===BLOCK <id> COMPLETE===`, the test summary and `git diff --stat`.
|
|
38
50
|
|
|
39
|
-
The final report needs no `reviewed <path> ticket <id>` lines: `block close` prints one for every file the review gate accepts (approved, waived or answered), with the queue's ticket. The close prints the reviewed lines, then the `reviewed_by_waiver` lines, then the `closed` line. With `--report`, a hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr when the block closes, and the close goes on. A file with a `review.waived` finding still gets a line; only a close whose every review the human waived, or a `review.external` close, prints none. The printed line has no `via`: `--json` and the block's done file carry the lines as `reviewed: [{file, ticket, via}]`, with `via: approved` for a file with a signed approval, `via: waiver` when at least one of its open findings was waived (also when the others were answered), and `via: answered` when every one was answered. Every changed file still needs
|
|
51
|
+
The final report needs no `reviewed <path> ticket <id>` lines: `block close` prints one for every file the review gate accepts (approved, waived or answered), with the queue's ticket. The close prints the reviewed lines, then the `reviewed_by_waiver` lines, then the `closed` line. With `--report`, a hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr when the block closes, and the close goes on. A file with a `review.waived` finding still gets a line; only a close whose every review the human waived, or a `review.external` close, prints none. The printed line has no `via`: `--json` and the block's done file carry the lines as `reviewed: [{file, ticket, via}]`, with `via: approved` for a file with a signed approval, `via: waiver` when at least one of its open findings was waived (also when the others were answered), and `via: answered` when every one was answered. Every changed file still needs a review through the selected file loop: a file the review did not accept keeps the block open. "All findings addressed" is not a report.
|
|
40
52
|
|
|
41
53
|
Or `===BLOCK <id> FAILED: <reason>===`.
|
|
42
54
|
|
|
43
55
|
## Fix-round brief (round ≥ 2, or a respawn after a climb)
|
|
44
56
|
|
|
45
|
-
The open findings by id, the fix history, the files they sit in. Fix only those hunks, then run `forge review-file <path> --block <id> --run <r>` and `--wait`
|
|
57
|
+
The open findings by id, the fix history, the files they sit in. Fix only those hunks, then follow the selected file loop on **every file the round changed** (`wait`: run `forge review-file <path> --block <id> --run <r>` and `--wait`; `overlap`: enqueue each green file and continue; `watch`: commit each green file; both `overlap` and `watch`: run `forge review-file --wait-block <id> --run <r>` before the sentinel) — a fix the reviewer never saw is not a fix. A fix made in another owned file is sent with `--answers <ids> --of <source>` (The file loop). The round's final report follows the same rule as the first: it needs no ticket lines. Re-run the block's test command after the fixes and print a fresh `TESTS <command> exit=<n>` line just before the round's sentinel: a TESTS line from an earlier report does not count. In a fix round, you may use `code-forge gates run --run <r> --block <id> --changed` as the block's test command; the TESTS line names the command actually run (substitute the run and block ids). Any green `code-forge gates run --run <r> --block <id>` records the owned file hashes on `gate.done`, including runs without `--changed`; when `--slug` is omitted, these rows go to the run's project ledger. More than 200 owned files means no record, so the next `--changed` falls back with `no_baseline`; unreadable inputs also leave no record. `{test_files}` expands to changed tests and stem-paired tests of changed sources; `{changed_files}` expands to changed lint/format paths. Deleted files still count for pairing and the threshold, but both tokens receive only files that exist now. Both tokens must be whole argv items and cannot be the command or the only item; config validation refuses a gate whose command or only item is `{changed_files}`. Without `--changed`, `{changed_files}` is dropped. Gates without tokens run as before. Rows carry `scope: changed`, including fallback rows with a `reason`: `no_baseline` uses today's scope, `unpaired` uses today's test scope when a changed source has no test pair, `threshold` runs the full suite above `gates.full_suite_threshold_files` changed files, and `no_changes` uses today's scope when nothing is left to run. The orchestrator runs the full `code-forge gates run` before `block close` (D2).
|
|
46
58
|
|
|
47
59
|
## Context pack
|
|
48
60
|
|
|
49
|
-
<!-- the block's context pack, pasted as text: the block header (run, block, level, owned and test files, worktree), the project's build, test, lint and format commands, the rules files and the lessons file. `forge context pack --run <r> --block <id>` writes it; paste its contents, never its run-root path (`references/code.md` §3). Without a `context` key, leave this section out. -->
|
|
61
|
+
<!-- the block's context pack, pasted as text: the block header (run, block, level, owned and test files, worktree), the project's build, test, lint and format commands, the built-in proof rule, the rules files and the lessons file. `forge context pack --run <r> --block <id>` writes it; paste its contents, never its run-root path (`references/code.md` §3). Without a `context` key, leave this section out. -->
|
|
50
62
|
|
|
51
63
|
## Project rules
|
|
52
64
|
|
package/src/cli/block.mjs
CHANGED
|
@@ -847,6 +847,20 @@ async function coderTests(reportFlag, id) {
|
|
|
847
847
|
}
|
|
848
848
|
const sentinel = parseSentinel(text, id);
|
|
849
849
|
if (sentinel?.status !== 'complete') return { ok: false, reason: `coder report FAILED: no ===BLOCK ${id} COMPLETE=== sentinel` };
|
|
850
|
+
// Keep diagnostics in the same segment as parseSentinel: any block's sentinel ends it.
|
|
851
|
+
let malformed = null;
|
|
852
|
+
let lastMalformed = null;
|
|
853
|
+
for (const rawLine of text.split('\n')) {
|
|
854
|
+
const line = rawLine.trim();
|
|
855
|
+
const end = parseSentinel(line);
|
|
856
|
+
if (end) {
|
|
857
|
+
if (end.blockId === id) lastMalformed = malformed;
|
|
858
|
+
malformed = null;
|
|
859
|
+
} else if (line.startsWith('TESTS ') && !parseSentinel(`${line}\n===BLOCK ${id} COMPLETE===`, id)?.tests) {
|
|
860
|
+
malformed = line.slice(0, 120);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (lastMalformed !== null) return { ok: false, reason: `coder report FAILED: TESTS line malformed: ${lastMalformed}` };
|
|
850
864
|
if (!sentinel.tests) return { ok: false, reason: 'coder report FAILED: no TESTS line' };
|
|
851
865
|
if (sentinel.tests.exit !== 0) return { ok: false, reason: `coder report FAILED: TESTS exit=${sentinel.tests.exit}` };
|
|
852
866
|
return { ok: true, command: sentinel.tests.command, exit: 0 };
|
package/src/cli/gates.mjs
CHANGED
|
@@ -39,7 +39,7 @@ 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, thresholdOf } from '../gates/expand.mjs';
|
|
42
|
+
import { printableGates, scopeFields, 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';
|
|
@@ -189,6 +189,7 @@ function realOrResolved(p) {
|
|
|
189
189
|
*/
|
|
190
190
|
function scopeOf({ scope, test_count: testCount, reason }) {
|
|
191
191
|
if (scope === undefined) return {};
|
|
192
|
+
if (scope === 'changed') return { scope, test_count: testCount, ...(reason ? { reason } : {}) };
|
|
192
193
|
return scope === 'related' ? { scope, test_count: testCount } : { scope, reason };
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -251,6 +252,11 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
251
252
|
err('gates run: --run <id> and --block <id> go together, each with a value\n');
|
|
252
253
|
return 2;
|
|
253
254
|
}
|
|
255
|
+
const changed = args.includes('--changed');
|
|
256
|
+
if (changed && runFlag.value === undefined) {
|
|
257
|
+
err('gates run: --changed needs --run <id> and --block <id>\n');
|
|
258
|
+
return 2;
|
|
259
|
+
}
|
|
254
260
|
|
|
255
261
|
// B62b: a block with a worktree runs its gates in that tree, configured by the main workspace
|
|
256
262
|
const placed = runFlag.value !== undefined && blockFlag.value !== undefined ? await blockWorktree(runFlag.value, blockFlag.value) : null;
|
|
@@ -273,29 +279,32 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
273
279
|
let ran;
|
|
274
280
|
if (runFlag.value !== undefined && blockFlag.value !== undefined) {
|
|
275
281
|
const threshold = thresholdOf(configured?.full_suite_threshold_files);
|
|
276
|
-
const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, threshold, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
282
|
+
const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, threshold, changed, slug, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
277
283
|
if (guarded.result === 'proof.busy') {
|
|
278
284
|
out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
|
|
279
285
|
return 1;
|
|
280
286
|
}
|
|
281
|
-
ran = /** @type {{value: {results: any[], allOk: boolean}}} */ (guarded).value;
|
|
287
|
+
ran = /** @type {{value: {results: any[], allOk: boolean, ledger_slug?: string, files?: Record<string, string>, changed_scope?: import('../gates/expand.mjs').ChangedScope}}} */ (guarded).value;
|
|
282
288
|
} else {
|
|
283
289
|
ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
284
290
|
}
|
|
285
291
|
const { results, allOk } = ran;
|
|
286
292
|
|
|
287
|
-
|
|
293
|
+
const ledgerSlug = slug ?? ran.ledger_slug;
|
|
294
|
+
if (ledgerSlug) {
|
|
288
295
|
// B60: with --run/--block the rows name the run and the block, so the run's binnacle reads them
|
|
289
296
|
const owner = runFlag.value !== undefined ? { run: runFlag.value, block: blockFlag.value, ...inTree } : {};
|
|
290
297
|
for (const result of results) {
|
|
291
298
|
if (!result.skipped && !result.ok) {
|
|
292
|
-
await appendRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result) }, { slug });
|
|
299
|
+
await appendRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result) }, { slug: ledgerSlug });
|
|
293
300
|
}
|
|
294
301
|
}
|
|
295
302
|
// B60: one scope per run (every `{test_files}` gate shares it), named on gate.done too;
|
|
296
303
|
// without --run/--block that is `scope: full, reason: no_tests`
|
|
297
304
|
const scoped = results.find((r) => r.scope !== undefined);
|
|
298
|
-
await appendRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
|
|
305
|
+
await appendRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
|
|
306
|
+
...(ran.changed_scope ? scopeFields(ran.changed_scope) : scoped ? scopeOf(scoped) : {}),
|
|
307
|
+
...(ran.files ? { files: ran.files } : {}) }, { slug: ledgerSlug });
|
|
299
308
|
}
|
|
300
309
|
out({ stack: gates.stack, ...inTree, results, allOk });
|
|
301
310
|
return allOk ? 0 : 1;
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
|
|
6
6
|
* {ticket, status: pending, reason: wait_timeout}
|
|
7
7
|
* when --max elapses (the review is still running)
|
|
8
|
+
* review-file --wait-block <id> --run <id> join owned changed files' current tickets;
|
|
9
|
+
* prints {block, files, done}; never reviews here
|
|
8
10
|
* review-file <answering file> --block <id> --answers <ids> --of <source> [--run <id>]
|
|
9
11
|
* B71: enqueue an answers ticket; prints {ticket,
|
|
10
12
|
* status, kind: answers, file: source, by_file, answers}
|
|
@@ -67,20 +69,22 @@
|
|
|
67
69
|
|
|
68
70
|
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
69
71
|
import path from 'node:path';
|
|
70
|
-
import { answersTicketId, assertAnswers, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
72
|
+
import { answersTicketId, assertAnswers, blockTickets, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
|
|
71
73
|
import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
72
|
-
import { namedOnHash } from '../review/gate-check.mjs';
|
|
74
|
+
import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
|
|
73
75
|
import { STOP_EXITS } from '../review/stop-exits.mjs';
|
|
74
76
|
import { ownsFile } from '../state/registry.mjs';
|
|
75
77
|
import { readAllRows } from '../ledger/write.mjs';
|
|
76
78
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
77
79
|
import { StateError } from '../state/paths.mjs';
|
|
80
|
+
import { assertRunId } from '../state/paths.mjs';
|
|
78
81
|
import { readRun } from '../state/run.mjs';
|
|
79
82
|
import { blockTree } from '../proof/worktree.mjs';
|
|
80
83
|
import { writeSafe } from '../util/redact.mjs';
|
|
81
84
|
|
|
82
85
|
const USAGE =
|
|
83
86
|
'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
|
|
87
|
+
' code-forge review-file --wait-block <id> --run <id> [--max <seconds>s]\n' +
|
|
84
88
|
' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n';
|
|
85
89
|
|
|
86
90
|
export const DEFAULT_WAIT_MS = 90_000;
|
|
@@ -355,6 +359,93 @@ export function fixList(result) {
|
|
|
355
359
|
return out;
|
|
356
360
|
}
|
|
357
361
|
|
|
362
|
+
/**
|
|
363
|
+
* B100: read block scope from the workspace mirror, never the private run record. This is a
|
|
364
|
+
* progress summary, not an approval: block close still verifies the authoritative signed rows.
|
|
365
|
+
* @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
|
|
366
|
+
* @returns {Promise<{entry: Record<string, any>, tree: string}>}
|
|
367
|
+
*/
|
|
368
|
+
async function waitBlockScope(repoRoot, cwd, run, block) {
|
|
369
|
+
const top = await repoRootOf(cwd);
|
|
370
|
+
const candidates = new Set([repoRoot]);
|
|
371
|
+
for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
|
|
372
|
+
candidates.add(dir);
|
|
373
|
+
if (dir === top || dir === path.dirname(dir)) break;
|
|
374
|
+
}
|
|
375
|
+
for (const dir of candidates) {
|
|
376
|
+
let record;
|
|
377
|
+
try {
|
|
378
|
+
record = JSON.parse(readFileSync(path.join(dir, '.code-forge', 'runs', `${run}.json`), 'utf8'));
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (err.code === 'ENOENT') continue;
|
|
381
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
382
|
+
}
|
|
383
|
+
const entry = record?.blocks?.[block];
|
|
384
|
+
if (record?.run_id !== run || !entry || !Array.isArray(entry.owned_files) || entry.owned_files.length === 0) {
|
|
385
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
386
|
+
}
|
|
387
|
+
const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
|
|
388
|
+
return { entry, tree };
|
|
389
|
+
}
|
|
390
|
+
throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A block wait reads only the marker, never review results or the signing key.
|
|
394
|
+
* @param {string} repoRoot @param {string} ticket @returns {boolean}
|
|
395
|
+
*/
|
|
396
|
+
function blockTicketDone(repoRoot, ticket) {
|
|
397
|
+
try {
|
|
398
|
+
const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
|
|
399
|
+
if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return true;
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (err.code === 'ENOENT') return false;
|
|
402
|
+
}
|
|
403
|
+
throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Join current-content tickets only. Re-scan on every poll, including before returning done,
|
|
408
|
+
* so a file edited while waiting gets a new ticket. The worker alone finishes old tickets.
|
|
409
|
+
* @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
|
|
410
|
+
*/
|
|
411
|
+
async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
|
|
412
|
+
const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
|
|
413
|
+
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
|
|
414
|
+
const deadline = Date.now() + maxMs;
|
|
415
|
+
let downPolls = 0;
|
|
416
|
+
for (;;) {
|
|
417
|
+
const worker = liveWorker(repoRoot);
|
|
418
|
+
const live = worker?.run === run;
|
|
419
|
+
downPolls = live ? 0 : downPolls + 1;
|
|
420
|
+
const tickets = blockTickets({ repoRoot, run, block, ...where });
|
|
421
|
+
const current = await blockFileSet({ repoRoot: tree, base: entry.base_sha, owned: entry.owned_files, ownedKinds: entry.owned_kinds });
|
|
422
|
+
const files = current.map(({ file }) => {
|
|
423
|
+
const opts = { repoRoot, run, block, file, ...where };
|
|
424
|
+
const { ticket } = resolveTicket(opts);
|
|
425
|
+
const previous = tickets.filter((t) => t.file === file);
|
|
426
|
+
let present = previous.find((t) => t.ticket === ticket);
|
|
427
|
+
if (!present && previous.length > 0 && live) {
|
|
428
|
+
enqueue(opts);
|
|
429
|
+
present = { ...previous[previous.length - 1], ticket };
|
|
430
|
+
}
|
|
431
|
+
return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketDone(repoRoot, ticket) ? 'done' : 'pending' };
|
|
432
|
+
});
|
|
433
|
+
if (downPolls >= 2) {
|
|
434
|
+
say({ block, files, done: false, status: 'worker_down' });
|
|
435
|
+
return 3;
|
|
436
|
+
}
|
|
437
|
+
if (live && files.every((f) => f.status === 'done')) {
|
|
438
|
+
say({ block, files, done: true });
|
|
439
|
+
return 0;
|
|
440
|
+
}
|
|
441
|
+
if (live && (files.some((f) => f.status === 'not_queued') || Date.now() >= deadline)) {
|
|
442
|
+
say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
358
449
|
/**
|
|
359
450
|
* @param {string[]} args
|
|
360
451
|
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
|
|
@@ -366,10 +457,17 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
366
457
|
const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
|
|
367
458
|
const cwd = deps.cwd ?? process.cwd();
|
|
368
459
|
try {
|
|
369
|
-
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
460
|
+
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
|
|
370
461
|
// the queue and the worker: the main tree, also from inside a block's worktree
|
|
371
462
|
const repoRoot = await mainRootOf(cwd);
|
|
372
463
|
|
|
464
|
+
if (flags['wait-block'] !== undefined) {
|
|
465
|
+
if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
|
|
466
|
+
throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
|
|
467
|
+
}
|
|
468
|
+
return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
|
|
469
|
+
}
|
|
470
|
+
|
|
373
471
|
if (typeof flags.wait === 'string') {
|
|
374
472
|
if (positionals.length > 0 || flags.block !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true) throw new WorkerError('usage', '--wait takes only a ticket and --max');
|
|
375
473
|
const ticket = assertTicketId(flags.wait);
|
package/src/config/validate.mjs
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
67
67
|
import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
|
|
68
68
|
import { redact } from '../util/redact.mjs';
|
|
69
|
-
import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
69
|
+
import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
70
70
|
import { checkEffort, displayEffort } from '../engines/efforts.mjs';
|
|
71
71
|
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
|
|
72
72
|
import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
@@ -484,10 +484,10 @@ function gateMessages(key, value) {
|
|
|
484
484
|
// full-suite run would be an empty argv
|
|
485
485
|
/** @param {string[]} argv @returns {string[]} */
|
|
486
486
|
const tokenMessages = (argv) =>
|
|
487
|
-
argv.length === 1 && argv[0]
|
|
488
|
-
? [`gates.${key}: '${
|
|
489
|
-
: argv[0]
|
|
490
|
-
? [`gates.${key}: '${
|
|
487
|
+
argv.length === 1 && [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
|
|
488
|
+
? [`gates.${key}: '${argv[0]}' cannot be the only item (the full suite would run an empty command)`]
|
|
489
|
+
: [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
|
|
490
|
+
? [`gates.${key}: '${argv[0]}' cannot be the command (argv[0])`]
|
|
491
491
|
: [];
|
|
492
492
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
493
493
|
if (!isArgvArray(value)) return [`gates.${key} must be a non-empty argv array of strings, an {argv, env} object, or null`];
|
|
@@ -1162,3 +1162,10 @@ export function validateConfig(cfg, opts = {}) {
|
|
|
1162
1162
|
}
|
|
1163
1163
|
|
|
1164
1164
|
export { schema as configSchema };
|
|
1165
|
+
|
|
1166
|
+
/** The effective file loop for a validated config, including opt-in commit watch.
|
|
1167
|
+
* @param {Record<string, any>} cfg @returns {string}
|
|
1168
|
+
*/
|
|
1169
|
+
export function reviewFileLoop(cfg) {
|
|
1170
|
+
return cfg?.review?.file_loop ?? 'wait';
|
|
1171
|
+
}
|
package/src/context/pack.mjs
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
|
|
14
14
|
* owned files, test files when the block has them, worktree path and branch when it has one), the
|
|
15
|
-
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing),
|
|
16
|
-
* rules file, then the lessons file — every file section headed by its
|
|
17
|
-
* file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
15
|
+
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), the
|
|
16
|
+
* built-in proof rule, each rules file, then the lessons file — every file section headed by its
|
|
17
|
+
* source path (an empty file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
18
18
|
* rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
|
|
19
|
-
* header and
|
|
19
|
+
* header, commands and proof rule are never cut; all count toward the budget.
|
|
20
20
|
*
|
|
21
21
|
* `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
|
|
22
22
|
* lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
|
|
@@ -41,6 +41,7 @@ import path from 'node:path';
|
|
|
41
41
|
import { estimateTokens } from '../review/packet.mjs';
|
|
42
42
|
import { splitLines } from '../review/context.mjs';
|
|
43
43
|
import { redact } from '../util/redact.mjs';
|
|
44
|
+
import { PROOF_RULE } from '../proof/rule.mjs';
|
|
44
45
|
|
|
45
46
|
/** The pack's line cap when `context.max_lines` is not set. */
|
|
46
47
|
export const DEFAULT_MAX_LINES = 300;
|
|
@@ -272,6 +273,8 @@ export function assemblePack({ settings, sources, block }) {
|
|
|
272
273
|
// a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
|
|
273
274
|
for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
|
|
274
275
|
|
|
276
|
+
header.push(...splitLines(PROOF_RULE));
|
|
277
|
+
|
|
275
278
|
/** @type {Array<{heading: string, body: string[], dropped: number}>} */
|
|
276
279
|
const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
|
|
277
280
|
if (sources.lessons) sections.push({ heading: `## lessons: ${sources.lessons.path}`, body: [...sources.lessons.lines], dropped: 0 });
|
package/src/gates/expand.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* B60 (issue #13 item 3): run only the block's related tests, and give a gate its own environment.
|
|
3
|
-
*
|
|
3
|
+
* Scope and argv rules are pure; B99's fileBaseline reads bounded hashes from the workspace.
|
|
4
|
+
* A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
|
|
4
5
|
* (one whole item, never a substring, never a shell string) stands for the block's test files:
|
|
5
6
|
*
|
|
6
7
|
* - the block's `test_files` (`block open --tests`), else its owned entries that are concrete
|
|
@@ -20,8 +21,14 @@
|
|
|
20
21
|
* `CI=1` or `MODE=test` would otherwise mask unrelated text (`npm test`).
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { access, lstat, readFile, realpath } from 'node:fs/promises';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { isExactEntry, ownsFile } from '../state/registry.mjs';
|
|
29
|
+
import { pairsFor } from '../review/paired.mjs';
|
|
30
|
+
import { exec } from '../util/exec.mjs';
|
|
31
|
+
import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
25
32
|
|
|
26
33
|
export { TEST_FILES_TOKEN };
|
|
27
34
|
|
|
@@ -67,6 +74,7 @@ const isConcreteFile = (p) => typeof p === 'string' && p.length > 0 && isExactEn
|
|
|
67
74
|
|
|
68
75
|
/**
|
|
69
76
|
* @typedef {{scope: 'related', files: string[]} | {scope: 'full', reason: 'threshold' | 'no_tests'}} TestScope
|
|
77
|
+
* @typedef {{scope: 'changed', files: string[], changedFiles: string[], reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'}} ChangedScope
|
|
70
78
|
*/
|
|
71
79
|
|
|
72
80
|
/** A gate run with no block: no test file is known. */
|
|
@@ -108,30 +116,115 @@ export function hasTestFilesItem(gates) {
|
|
|
108
116
|
return Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(TEST_FILES_TOKEN) === true);
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
/** @param {Record<string, unknown> | null | undefined} gates @returns {boolean} */
|
|
120
|
+
export function hasScopeItem(gates) {
|
|
121
|
+
return hasTestFilesItem(gates) || Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(CHANGED_FILES_TOKEN));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A safe repo-relative path that cannot turn into an argv option. @param {string} file */
|
|
125
|
+
function safeFile(file) {
|
|
126
|
+
return file.length > 0 && !file.startsWith('-') && !path.isAbsolute(file) && !/[\\\x00-\x1f]/.test(file) && !file.split('/').some((s) => s === '' || s === '.' || s === '..');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bounded content baseline. Missing listed files are omitted from hashes and candidates; an
|
|
131
|
+
* owned entry with no current file has no hash. More than 200 owned files, a symlink, a
|
|
132
|
+
* non-regular owned file, a path outside the root or another read error means no record;
|
|
133
|
+
* callers retain the old scope. Git sees only argv and an isolated git environment.
|
|
134
|
+
* @param {string} cwd @param {string[]} owned @param {Record<string, string>} [kinds]
|
|
135
|
+
* @returns {Promise<{files: Record<string, string>, candidates: string[]} | null>}
|
|
136
|
+
*/
|
|
137
|
+
export async function fileBaseline(cwd, owned, kinds) {
|
|
138
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
139
|
+
env.GIT_CONFIG_NOSYSTEM = '1';
|
|
140
|
+
env.GIT_CONFIG_GLOBAL = os.devNull;
|
|
141
|
+
const listed = await exec(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd, env, timeoutMs: 30000 });
|
|
142
|
+
if (listed.result !== 'ok') return null;
|
|
143
|
+
const listedFiles = [...new Set(listed.stdout.split('\0').filter(Boolean))].sort();
|
|
144
|
+
const candidates = [];
|
|
145
|
+
const files = /** @type {Record<string, string>} */ (Object.create(null));
|
|
146
|
+
try {
|
|
147
|
+
const root = await realpath(cwd);
|
|
148
|
+
for (const file of listedFiles) {
|
|
149
|
+
const isOwned = ownsFile(owned, file, kinds);
|
|
150
|
+
if (isOwned && !safeFile(file)) return null;
|
|
151
|
+
const absolute = path.join(root, file);
|
|
152
|
+
if (!isOwned) {
|
|
153
|
+
const exists = await access(absolute).then(() => true).catch((error) => {
|
|
154
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
155
|
+
throw error;
|
|
156
|
+
});
|
|
157
|
+
if (exists) candidates.push(file);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const stat = await lstat(absolute).catch((error) => {
|
|
161
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
162
|
+
throw error;
|
|
163
|
+
});
|
|
164
|
+
if (stat === null) continue;
|
|
165
|
+
candidates.push(file);
|
|
166
|
+
if (Object.keys(files).length >= 200 || !stat.isFile() || !(await realpath(absolute)).startsWith(root + path.sep)) return null;
|
|
167
|
+
files[file] = createHash('sha256').update(await readFile(absolute)).digest('hex');
|
|
168
|
+
}
|
|
169
|
+
} catch { return null; }
|
|
170
|
+
return { files, candidates };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Compare the last green record. A source without a stem pair cannot narrow the test gate.
|
|
175
|
+
* @param {{baseline: unknown, current: Awaited<ReturnType<typeof fileBaseline>>, fallback: TestScope, threshold?: number, owned?: string[], kinds?: Record<string, string>}} opts
|
|
176
|
+
* @returns {ChangedScope}
|
|
177
|
+
*/
|
|
178
|
+
export function changedScope({ baseline, current, fallback, threshold = DEFAULT_FULL_SUITE_THRESHOLD, owned, kinds }) {
|
|
179
|
+
const old = /** @type {Record<string, string> | null} */ (baseline);
|
|
180
|
+
const candidates = new Set(current?.candidates);
|
|
181
|
+
const fallbackFiles = fallback.scope === 'related' ? fallback.files.filter((file) => !current || candidates.has(file)) : [];
|
|
182
|
+
if (!current || !old || typeof old !== 'object' || Array.isArray(old) || Object.keys(old).length > 200 ||
|
|
183
|
+
Object.entries(old).some(([file, hash]) => !safeFile(file) || (owned && !ownsFile(owned, file, kinds)) || typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash))) {
|
|
184
|
+
return { scope: 'changed', files: fallbackFiles, changedFiles: [], reason: 'no_baseline' };
|
|
185
|
+
}
|
|
186
|
+
const changes = [...new Set([...Object.keys(old), ...Object.keys(current.files)])].filter((file) => old[file] !== current.files[file]).sort();
|
|
187
|
+
if (changes.length > threshold) return { scope: 'changed', files: [], changedFiles: [], reason: 'threshold' };
|
|
188
|
+
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
|
+
const tests = new Set(changedFiles.filter(isChangedTest));
|
|
192
|
+
for (const file of changes.filter((f) => !isChangedTest(f))) {
|
|
193
|
+
const pairs = pairsFor(file, current.candidates).filter(isChangedTest);
|
|
194
|
+
if (pairs.length === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'unpaired' };
|
|
195
|
+
for (const paired of pairs) tests.add(paired);
|
|
196
|
+
}
|
|
197
|
+
if (tests.size === 0) return { scope: 'changed', files: fallbackFiles, changedFiles, reason: 'no_changes' };
|
|
198
|
+
return { scope: 'changed', files: [...tests].sort(), changedFiles };
|
|
199
|
+
}
|
|
200
|
+
|
|
111
201
|
/**
|
|
112
202
|
* One gate argv with every item that is exactly {@link TEST_FILES_TOKEN} replaced by the related
|
|
113
203
|
* test files, or dropped for a full run. Always a new array; `scoped` is false (and the items
|
|
114
204
|
* unchanged) when the gate has no such item.
|
|
115
|
-
* @param {ReadonlyArray<string>} argv @param {TestScope} scope
|
|
205
|
+
* @param {ReadonlyArray<string>} argv @param {TestScope | ChangedScope} scope
|
|
116
206
|
* @returns {{argv: string[], scoped: boolean}}
|
|
117
207
|
* @throws {TypeError} a test path that starts with `-` (it would be read as an option; `block
|
|
118
208
|
* open --tests` refuses one too).
|
|
119
209
|
*/
|
|
120
210
|
export function expandArgv(argv, scope) {
|
|
121
|
-
if (!argv.includes(TEST_FILES_TOKEN)) return { argv: [...argv], scoped: false };
|
|
122
|
-
const files = scope.scope === '
|
|
211
|
+
if (!argv.includes(TEST_FILES_TOKEN) && !argv.includes(CHANGED_FILES_TOKEN)) return { argv: [...argv], scoped: false };
|
|
212
|
+
const files = scope.scope === 'full' ? [] : scope.files;
|
|
213
|
+
const changed = scope.scope === 'changed' ? scope.changedFiles : [];
|
|
214
|
+
if (changed.some((file) => !safeFile(file))) throw new TypeError('expandArgv: unsafe changed path');
|
|
123
215
|
const dashed = files.find((f) => f.startsWith('-'));
|
|
124
216
|
if (dashed !== undefined) throw new TypeError(`expandArgv: test path ${JSON.stringify(dashed)} starts with "-"`);
|
|
125
|
-
return { argv: argv.flatMap((item) =>
|
|
217
|
+
return { argv: argv.flatMap((item) => item === TEST_FILES_TOKEN ? files : item === CHANGED_FILES_TOKEN ? changed : [item]), scoped: true };
|
|
126
218
|
}
|
|
127
219
|
|
|
128
220
|
/**
|
|
129
221
|
* The fields a scoped gate's result and ledger row carry: `scope`, plus `test_count` when related
|
|
130
222
|
* or `reason` when full.
|
|
131
|
-
* @param {TestScope} scope
|
|
132
|
-
* @returns {{scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold'
|
|
223
|
+
* @param {TestScope | ChangedScope} scope
|
|
224
|
+
* @returns {{scope: 'changed', test_count: number, reason?: 'no_baseline'|'unpaired'|'threshold'|'no_changes'} | {scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold'|'no_tests'}}
|
|
133
225
|
*/
|
|
134
226
|
export function scopeFields(scope) {
|
|
227
|
+
if (scope.scope === 'changed') return { scope: 'changed', test_count: scope.files.length, ...(scope.reason ? { reason: scope.reason } : {}) };
|
|
135
228
|
return scope.scope === 'related' ? { scope: 'related', test_count: scope.files.length } : { scope: 'full', reason: scope.reason };
|
|
136
229
|
}
|
|
137
230
|
|
package/src/gates/run.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { exec } from '../util/exec.mjs';
|
|
10
10
|
import { registerSecret } from '../util/redact.mjs';
|
|
11
|
+
import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
11
12
|
import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
|
|
12
13
|
|
|
13
14
|
/** @typedef {import('./detect.mjs').Gate} Gate */
|
|
@@ -25,9 +26,9 @@ import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } fro
|
|
|
25
26
|
* @property {number | null} code
|
|
26
27
|
* @property {string} stdout
|
|
27
28
|
* @property {string} stderr
|
|
28
|
-
* @property {'related'|'full'} [scope] -
|
|
29
|
+
* @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
|
|
29
30
|
* @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
|
|
30
|
-
* @property {'threshold'|'no_tests'} [reason] -
|
|
31
|
+
* @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'} [reason] - why narrowing fell back.
|
|
31
32
|
* @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
|
|
32
33
|
* NAMES, sorted (the values are never in a result).
|
|
33
34
|
*/
|
|
@@ -53,7 +54,7 @@ function normalizeCommand(command) {
|
|
|
53
54
|
* no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
|
|
54
55
|
* variables added to the inherited environment; a gate with neither runs exactly as before B60.
|
|
55
56
|
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
|
|
56
|
-
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope}} opts
|
|
57
|
+
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope}} opts
|
|
57
58
|
* @returns {Promise<{results: GateResult[], allOk: boolean}>}
|
|
58
59
|
*/
|
|
59
60
|
export async function runGates(gates, opts) {
|
|
@@ -77,8 +78,9 @@ export async function runGates(gates, opts) {
|
|
|
77
78
|
const command = gates[gate] ?? null;
|
|
78
79
|
if (command === null) return { gate, command: null };
|
|
79
80
|
const normalized = normalizeCommand(command);
|
|
81
|
+
if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
|
|
80
82
|
const { argv, scoped } = expandArgv(normalized.argv, scope);
|
|
81
|
-
if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once '{
|
|
83
|
+
if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once ${normalized.argv.map((item) => `'${item}'`).join(' and ')} is dropped for the full suite — put the test command before it`);
|
|
82
84
|
return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
|
|
83
85
|
});
|
|
84
86
|
/** @type {GateResult[]} */
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* wizard answer (`ANSWER_FLAGS` maps flag -> answer key); `--no-interaction` is a mode switch and
|
|
4
4
|
* overrides no answer.
|
|
5
5
|
*
|
|
6
|
-
* **Q16 = cut (
|
|
6
|
+
* **Q16 = cut (the owner, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
|
|
7
7
|
* v1.2 `--no-nightly` flag is gone and `--proof` takes only the keys the schema still has
|
|
8
8
|
* (`isolation`, `high`, `link_dirs`, `copy_untracked`). With `-g` and `-p` counted as two flags
|
|
9
9
|
* (two tokens that each set the scope answer) there are 21 answer flags: B65 added
|
package/src/proof/lock.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* with `isolation: lock` through the B6 writer.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { changedScope, fileBaseline, hasScopeItem, testScope } from '../gates/expand.mjs';
|
|
20
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
20
21
|
import { ownedFileCount } from '../gates/owned-count.mjs';
|
|
21
22
|
import { runGates } from '../gates/run.mjs';
|
|
22
23
|
import { StateError } from '../state/paths.mjs';
|
|
@@ -139,23 +140,35 @@ export async function withGateSlot({ runId, blockId, fn, pid = process.pid }) {
|
|
|
139
140
|
* run record once the slot is taken). The full suite runs instead when the block owns more than
|
|
140
141
|
* `threshold` (`gates.full_suite_threshold_files`) files, or when no test file is known. The
|
|
141
142
|
* owned files are counted by `ownedFileCount` over the tracked and new (not ignored) files at
|
|
142
|
-
* `cwd`.
|
|
143
|
-
* and
|
|
144
|
-
* @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number}} opts
|
|
143
|
+
* `cwd`. Plain gates without --changed retain the old path. B99 records bounded owned hashes
|
|
144
|
+
* after green scoped runs; --changed uses the last green record and B97's stem pairs.
|
|
145
|
+
* @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number, changed?: boolean, slug?: string}} opts
|
|
145
146
|
*/
|
|
146
|
-
export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold }) {
|
|
147
|
+
export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold, changed = false, slug }) {
|
|
147
148
|
return withGateSlot({
|
|
148
149
|
runId,
|
|
149
150
|
blockId,
|
|
150
151
|
fn: async () => {
|
|
151
|
-
//
|
|
152
|
-
if (!
|
|
152
|
+
// Plain gates without --changed need no scope or baseline.
|
|
153
|
+
if (!hasScopeItem(gates) && !changed) return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
153
154
|
// the slot checked the block is open; it may still be gone by the time it is read again
|
|
154
|
-
const
|
|
155
|
+
const record = await readRun(runId);
|
|
156
|
+
const block = record.blocks?.[blockId];
|
|
155
157
|
if (!block) throw new StateError('no-block', `block ${blockId} is not in run ${runId}`);
|
|
156
158
|
const ownedFiles = await ownedFileCount(cwd, block.owned_files, block.owned_kinds);
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
+
const fallback = testScope({ testFiles: block.test_files, owned: block.owned_files, ownedFiles, ...(threshold === undefined ? {} : { threshold }) });
|
|
160
|
+
const current = await fileBaseline(cwd, block.owned_files, block.owned_kinds);
|
|
161
|
+
let baseline;
|
|
162
|
+
if (changed) {
|
|
163
|
+
const rows = await readAllRows(slug ?? record.project);
|
|
164
|
+
baseline = rows.filter((row) => row.event === 'gate.done' && row.run === runId && row.block === blockId && row.all_ok === true && row.worktree === block.worktree?.path).at(-1)?.files;
|
|
165
|
+
}
|
|
166
|
+
const scope = changed ? changedScope({ baseline, current, fallback, owned: block.owned_files, kinds: block.owned_kinds, ...(threshold === undefined ? {} : { threshold }) }) : fallback;
|
|
167
|
+
const ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}), scope });
|
|
168
|
+
// A command that changed an input did not measure the resulting bytes. Do not baseline it.
|
|
169
|
+
const after = ran.allOk && current ? await fileBaseline(cwd, block.owned_files, block.owned_kinds) : null;
|
|
170
|
+
const files = after && JSON.stringify(current?.files) === JSON.stringify(after.files) ? after.files : undefined;
|
|
171
|
+
return { ...ran, ledger_slug: slug ?? record.project, ...(files ? { files } : {}), ...(changed ? { changed_scope: scope } : {}) };
|
|
159
172
|
},
|
|
160
173
|
});
|
|
161
174
|
}
|