@codedology/code-forge 0.10.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 +74 -0
- package/README.md +34 -3
- package/docs/reference/blocks.json +360 -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 +3 -1
- package/skill/references/code.md +17 -8
- package/skill/references/proof.md +8 -2
- package/skill/references/review.md +32 -5
- package/skill/references/rules-core.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +17 -7
- package/src/cli/block.mjs +112 -7
- package/src/cli/gates.mjs +15 -6
- package/src/cli/review-file.mjs +118 -14
- 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/engine.mjs +59 -12
- package/src/review/fixloop.mjs +41 -16
- package/src/review/gate-check.mjs +2 -2
- package/src/review/lenses/A.md +5 -0
- package/src/review/lenses/B.md +5 -0
- package/src/review/lenses/full.md +5 -0
- package/src/review/lenses/judge.md +4 -0
- package/src/review/lenses/quick.md +4 -0
- package/src/review/lenses/recheck.md +5 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +81 -0
- package/src/review/reopen.mjs +18 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/state/block-end.mjs +10 -6
- package/src/util/exec.mjs +2 -1
- package/src/util/gate-token.mjs +3 -0
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +35 -2
- package/src/worker/queue.mjs +60 -2
- package/src/worker/watch.mjs +113 -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
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The built-in proof rule carried by every coder context pack. */
|
|
2
|
+
export const PROOF_RULE = [
|
|
3
|
+
"## proof rule (code-forge)",
|
|
4
|
+
"- 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.",
|
|
5
|
+
"- No hand mutations. Do not break the code on purpose to test the tests. Red→green is the proof.",
|
|
6
|
+
"- 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\".",
|
|
7
|
+
"- 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.",
|
|
8
|
+
"- A weak assertion is fixed by a stronger assertion, not by more tests or mutants.",
|
|
9
|
+
].join('\n');
|
package/src/proof/tiers.mjs
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
* high ⇐ risk ≥ 2, or the path matches `proof.tiers.high.paths`, or `security_sensitive`
|
|
6
6
|
* light ⇐ everything else (the review-depth band `1 ≤ risk < 2` does NOT raise the proof tier)
|
|
7
7
|
*
|
|
8
|
-
* The light tier
|
|
9
|
-
*
|
|
10
|
-
* `held` and the gate enforces red→green only until Ricardo answers. Pure: no I/O.
|
|
8
|
+
* The light tier needs red→green per new or changed test. The high tier must have red→green
|
|
9
|
+
* at the gate. Mutation testing was cut by the owner (Q16). Pure: no I/O.
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
import { ownsFile } from '../state/registry.mjs';
|
|
@@ -50,14 +49,3 @@ export function blockTier(fileTiers) {
|
|
|
50
49
|
if (!Array.isArray(fileTiers)) throw new TypeError('blockTier: fileTiers must be an array');
|
|
51
50
|
return fileTiers.some((t) => t?.tier === 'high') ? 'high' : 'light';
|
|
52
51
|
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* What the gate requires for a tier. `mutants: 'held'` = the high tier's mutation proof is
|
|
56
|
-
* specified but not enforced until Q16 is answered (B10c).
|
|
57
|
-
* @param {Tier} tier
|
|
58
|
-
* @returns {{red_green: true, mutants: 'none' | 'held'}}
|
|
59
|
-
*/
|
|
60
|
-
export function proofRequirements(tier) {
|
|
61
|
-
if (!TIERS.includes(tier)) throw new TypeError(`proofRequirements: tier must be one of ${TIERS.join(', ')}`);
|
|
62
|
-
return { red_green: true, mutants: tier === 'high' ? 'held' : 'none' };
|
|
63
|
-
}
|
package/src/review/engine.mjs
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
* The summary then carries `schema_fallback: {level, provider, model}` (`level`:
|
|
30
30
|
* `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
|
|
31
31
|
* `tokens_out` summed over both sessions;
|
|
32
|
-
* 7. a
|
|
32
|
+
* 7. a non-Markdown quick packet over budget first steps up to one full L2 session (B109).
|
|
33
|
+
* A diff alone still over the packet budget is `split_required` (with `tokens_in` and `budget`) —
|
|
33
34
|
* except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
|
|
34
35
|
* section packet runs the whole plan above (its own sessions, judge and stub guard, its own
|
|
35
36
|
* `review.plan` row with `section`), in file order, stopping at the first one that is not
|
|
@@ -64,15 +65,17 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
|
|
|
64
65
|
import { fallbackRisk } from '../decide/fallback-rules.mjs';
|
|
65
66
|
import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
|
|
66
67
|
import { tierFor } from '../proof/tiers.mjs';
|
|
67
|
-
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
68
|
+
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, isSecretLike, readFileDiff } from './packet.mjs';
|
|
68
69
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
69
70
|
import { deletionNote, renderDeletionNote } from './deletion.mjs';
|
|
70
71
|
import { testQualityNote } from './test-quality.mjs';
|
|
72
|
+
import { pairsFor, summarize } from './paired.mjs';
|
|
73
|
+
import { redact } from '../util/redact.mjs';
|
|
71
74
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
|
|
72
75
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
73
76
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
74
77
|
import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
|
|
75
|
-
import { assertRowPath } from '../worker/ticket.mjs';
|
|
78
|
+
import { assertRowPath, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
76
79
|
|
|
77
80
|
/**
|
|
78
81
|
* @typedef {{lens: 'quick' | 'full' | 'A' | 'B' | 'judge', role: 'reviewer' | 'judge', level: 'L2' | 'L3', slot: 'A' | 'B' | null, cfg?: Record<string, any>}} SessionSpec -
|
|
@@ -185,6 +188,9 @@ export function rulesRisk({ file, plusCount, cfg }) {
|
|
|
185
188
|
* code moved between this file and one of them is named in every packet (`## moved code`).
|
|
186
189
|
* @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader
|
|
187
190
|
* of those files' diffs (`peerDiffReader`), shared by the caller across the round; wins over `peers`.
|
|
191
|
+
* @property {ReadonlyArray<string>} [ownedFiles] - concrete paths owned by this block.
|
|
192
|
+
* @property {ReadonlyArray<string>} [changedFiles] - concrete changed paths of the same block.
|
|
193
|
+
* @property {ReadonlyArray<string>} [testFiles] - the block's explicit test_files, regardless of stem.
|
|
188
194
|
*/
|
|
189
195
|
|
|
190
196
|
/**
|
|
@@ -224,6 +230,31 @@ function safeNeeds(needs) {
|
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
232
|
|
|
233
|
+
/**
|
|
234
|
+
* B98: names only, from owned changed peers and explicit tests in the reviewed tree.
|
|
235
|
+
* Missing, unsafe or unreadable pairs give no hint, never a failed review.
|
|
236
|
+
* @param {{repoRoot: string, file: string, peers?: ReadonlyArray<string>, ownedFiles?: ReadonlyArray<string>, changedFiles?: ReadonlyArray<string>, testFiles?: ReadonlyArray<string>}} input
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
export function pairedContext({ repoRoot, file, peers = [], ownedFiles = [], changedFiles = peers, testFiles = [] }) {
|
|
240
|
+
if (ownedFiles.length === 0) return '';
|
|
241
|
+
const owned = new Set(ownedFiles);
|
|
242
|
+
const candidates = [...new Set([...pairsFor(file, changedFiles), ...testFiles])].filter((p) => p !== file && owned.has(p));
|
|
243
|
+
const lines = [];
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
try {
|
|
246
|
+
const rel = assertRowPath(candidate);
|
|
247
|
+
if (isSecretLike(rel)) continue;
|
|
248
|
+
const bytes = readRegularFileNoFollow(repoRoot, rel);
|
|
249
|
+
if (bytes === null) continue;
|
|
250
|
+
lines.push(`- ${redact(rel)}`, ...summarize(bytes.toString('utf8'), rel).map((name) => ` - ${name}`));
|
|
251
|
+
} catch {
|
|
252
|
+
// The summary is optional; never follow a symlink or substitute another file.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return lines.join('\n');
|
|
256
|
+
}
|
|
257
|
+
|
|
227
258
|
/**
|
|
228
259
|
* @param {ReviewInput} input @param {ReviewDeps} deps
|
|
229
260
|
* @returns {Promise<{status: string, approved: boolean, engine: string, sessions: Array<Record<string, any>>} & Record<string, any>>}
|
|
@@ -234,8 +265,8 @@ export async function reviewFile(input, deps) {
|
|
|
234
265
|
const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, cfg });
|
|
235
266
|
// no recorded kind ⇒ the reviewed file's own kind (a lone `.md` is a docs review), never a blind `code`
|
|
236
267
|
const kind = typeof input.kind === 'string' ? input.kind : blockKind({ owned: [diff.file] });
|
|
237
|
-
|
|
238
|
-
|
|
268
|
+
let plan = planSessions({ risk, cfg, kind });
|
|
269
|
+
let head = { engine: plan.mode, depth: plan.depth, risk, file: diff.file };
|
|
239
270
|
if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
|
|
240
271
|
if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
|
|
241
272
|
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
@@ -265,6 +296,7 @@ export async function reviewFile(input, deps) {
|
|
|
265
296
|
// B92: a test that reads a source file and asserts on its text gets the `## test-quality` hint
|
|
266
297
|
// (line numbers only), read once from the current content; '' for none
|
|
267
298
|
const quality = testQualityNote(diff.file, diff.content);
|
|
299
|
+
const paired = pairedContext(input);
|
|
268
300
|
/** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
|
|
269
301
|
const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys), quality].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
|
|
270
302
|
const min = minTokensOut(cfg);
|
|
@@ -280,7 +312,7 @@ export async function reviewFile(input, deps) {
|
|
|
280
312
|
const paths = safeNeeds(needs);
|
|
281
313
|
let extra = null;
|
|
282
314
|
try {
|
|
283
|
-
extra = paths ? await attachFiles({ repoRoot, paths, budgetTokens: budgetFor(cfg, 'full_in') }) : null;
|
|
315
|
+
extra = paths ? await attachFiles({ repoRoot, paths, budgetTokens: budgetFor(cfg, 'full_in'), ownedFiles: input.ownedFiles, changedFiles: input.changedFiles ?? input.peers ?? [] }) : null;
|
|
284
316
|
} catch {
|
|
285
317
|
extra = null;
|
|
286
318
|
}
|
|
@@ -290,10 +322,10 @@ export async function reviewFile(input, deps) {
|
|
|
290
322
|
return summary;
|
|
291
323
|
};
|
|
292
324
|
|
|
293
|
-
|
|
294
|
-
|
|
325
|
+
let reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
|
|
326
|
+
let judgeSpec = plan.sessions.find((s) => s.role === 'judge');
|
|
295
327
|
/** @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<number> | null} [keys] */
|
|
296
|
-
const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys) }));
|
|
328
|
+
const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys), paired }));
|
|
297
329
|
|
|
298
330
|
/**
|
|
299
331
|
* The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
|
|
@@ -327,10 +359,25 @@ export async function reviewFile(input, deps) {
|
|
|
327
359
|
return { status: 'reviewed', approved, summary: final.review.summary, findings, sessions };
|
|
328
360
|
};
|
|
329
361
|
|
|
330
|
-
|
|
331
|
-
|
|
362
|
+
let packets = packetsFor(diff);
|
|
363
|
+
let split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
|
|
364
|
+
/** @type {Record<string, any>} */
|
|
365
|
+
let stepUp = {};
|
|
366
|
+
if (split && plan.depth === 'quick' && !isMarkdownFile(diff.file)) {
|
|
367
|
+
const full = planSessions({ risk: 1, cfg, kind });
|
|
368
|
+
// A configured consensus/refusal is not the single full session this step permits.
|
|
369
|
+
if (full.depth === 'full') {
|
|
370
|
+
stepUp = { step_up: { from: 'quick', tokens_in: split.tokensIn, budget: split.budget } };
|
|
371
|
+
plan = full;
|
|
372
|
+
head = { ...head, engine: plan.mode, depth: plan.depth };
|
|
373
|
+
reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
|
|
374
|
+
judgeSpec = plan.sessions.find((s) => s.role === 'judge');
|
|
375
|
+
packets = packetsFor(diff);
|
|
376
|
+
split = /** @type {typeof split} */ (packets.find((p) => p.status === 'split_required'));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
332
379
|
if (!split) {
|
|
333
|
-
const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets);
|
|
380
|
+
const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets, stepUp);
|
|
334
381
|
if (status !== 'reviewed') return { status, reason, approved: false, ...head, sessions };
|
|
335
382
|
return { status, approved, ...head, summary, findings, sessions };
|
|
336
383
|
}
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -93,6 +93,7 @@ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
|
93
93
|
import { parseDiff } from './context.mjs';
|
|
94
94
|
import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
95
95
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
96
|
+
import { pairedContext } from './engine.mjs';
|
|
96
97
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
|
|
97
98
|
import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
|
|
98
99
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
@@ -125,6 +126,7 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
|
|
|
125
126
|
* @property {string | null} reviewed_content - the content the last counted round reviewed.
|
|
126
127
|
* @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
|
|
127
128
|
* @property {'open' | 'complete' | 'stopped'} status
|
|
129
|
+
* @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
|
|
128
130
|
* @property {NextStep | null} next
|
|
129
131
|
* @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
|
|
130
132
|
* @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
|
|
@@ -144,6 +146,9 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
|
|
|
144
146
|
* @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
|
|
145
147
|
* @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
|
|
146
148
|
* @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
|
|
149
|
+
* @property {ReadonlyArray<string>} [ownedFiles] - concrete paths owned by this block.
|
|
150
|
+
* @property {ReadonlyArray<string>} [changedFiles] - concrete changed paths of the same block.
|
|
151
|
+
* @property {ReadonlyArray<string>} [testFiles] - the block's explicit test_files.
|
|
147
152
|
* @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader of their diffs (`peerDiffReader`); wins over `peers`.
|
|
148
153
|
* @property {string} [rulesDigest] @property {string} [factsExcerpt]
|
|
149
154
|
* @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
|
|
@@ -362,11 +367,11 @@ export function recheckTokens(diff, open, moved = '') {
|
|
|
362
367
|
/**
|
|
363
368
|
* The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
|
|
364
369
|
* the open findings by id. Over budget ⇒ `minimal` (see the module doc).
|
|
365
|
-
* @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string}} opts -
|
|
370
|
+
* @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string, paired?: string}} opts -
|
|
366
371
|
* `moved`: the file's `## moved code` section (B56), '' for none.
|
|
367
372
|
*/
|
|
368
|
-
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '' }) {
|
|
369
|
-
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved });
|
|
373
|
+
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '', paired = '' }) {
|
|
374
|
+
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved, paired });
|
|
370
375
|
if (packet.status !== 'ok') return packet;
|
|
371
376
|
const text = `${packet.text}${openFindingsText(open)}`;
|
|
372
377
|
return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
|
|
@@ -436,12 +441,21 @@ export function insideHunks(f, hunks) {
|
|
|
436
441
|
* (`schema-fallback.mjs`; rechecks and the patch_check alike).
|
|
437
442
|
* @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
|
|
438
443
|
* @param {LoopDeps} deps
|
|
444
|
+
* @param {ReadonlyArray<Finding>} open - only the findings listed in this packet.
|
|
439
445
|
*/
|
|
440
|
-
async function recheckSession(packet, deps) {
|
|
446
|
+
async function recheckSession(packet, deps, open) {
|
|
441
447
|
mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
|
|
442
448
|
const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
|
|
443
449
|
writeFileSync(promptPath, packet.text, { mode: 0o600 });
|
|
444
450
|
const min = minTokensOut(deps.cfg);
|
|
451
|
+
/** @param {Record<string, any> | null} result @returns {import('./validate-review.mjs').Verdict} */
|
|
452
|
+
const validate = (result) => {
|
|
453
|
+
const verdict = validateReview(result, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
454
|
+
if (!verdict.ok) return verdict;
|
|
455
|
+
const ids = new Set((verdict.review?.resolved ?? []).map((/** @type {{id: string}} */ entry) => entry.id));
|
|
456
|
+
if (open.some((finding) => !ids.has(finding.id))) return { ok: false, reason: 'schema', detail: 'resolved does not cover the packet open findings' };
|
|
457
|
+
return verdict;
|
|
458
|
+
};
|
|
445
459
|
const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
|
|
446
460
|
// no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
|
|
447
461
|
// (each helper handles its own failed write)
|
|
@@ -485,7 +499,7 @@ async function recheckSession(packet, deps) {
|
|
|
485
499
|
} catch {
|
|
486
500
|
res = null; // the helper never throws; if it ever did, it is still an `exit` failure
|
|
487
501
|
}
|
|
488
|
-
verdict =
|
|
502
|
+
verdict = validate(res);
|
|
489
503
|
if (first?.to) {
|
|
490
504
|
// B91: a timed-out attempt was the packet's second broken result; the second level ran. The
|
|
491
505
|
// verdict is already set: whatever throws after it leaves that verdict, never an exception
|
|
@@ -510,7 +524,7 @@ async function recheckSession(packet, deps) {
|
|
|
510
524
|
const second = await tryNext(res, 'schema');
|
|
511
525
|
if (second) {
|
|
512
526
|
// compute everything first, then assign together, so a throw never leaves a mixed state
|
|
513
|
-
const secondVerdict =
|
|
527
|
+
const secondVerdict = validate(second.res);
|
|
514
528
|
const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
|
|
515
529
|
const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
|
|
516
530
|
res = second.res;
|
|
@@ -607,11 +621,11 @@ function retry(state, kind, reason) {
|
|
|
607
621
|
* not written, the open set stays as it was, and the gate-relevant `review.cap` row goes through
|
|
608
622
|
* the must-write path — a failed write is a `retry`, never a silent stop.
|
|
609
623
|
* @param {FileState} state @param {LoopDeps} deps
|
|
610
|
-
* @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file
|
|
624
|
+
* @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file @param {string} contentHash
|
|
611
625
|
* @param {{status: string, tokensIn?: number, budget?: number, section?: string | null}} packet - the non-ok packet (a Markdown section names its heading).
|
|
612
626
|
* @returns {Promise<FileState>}
|
|
613
627
|
*/
|
|
614
|
-
async function stopBeforeRound(state, deps, kind, file, packet) {
|
|
628
|
+
async function stopBeforeRound(state, deps, kind, file, contentHash, packet) {
|
|
615
629
|
const reason = packet.status;
|
|
616
630
|
const size =
|
|
617
631
|
typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
|
|
@@ -620,6 +634,7 @@ async function stopBeforeRound(state, deps, kind, file, packet) {
|
|
|
620
634
|
const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
|
|
621
635
|
if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
|
|
622
636
|
state.status = 'stopped';
|
|
637
|
+
state.stop_content_hash = contentHash;
|
|
623
638
|
state.next = { action: 'stop', reason, ...size };
|
|
624
639
|
state.pending_kind = null;
|
|
625
640
|
return state;
|
|
@@ -664,6 +679,11 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
664
679
|
s.scope === 'file'
|
|
665
680
|
? await readFileDiff({ repoRoot: deps.repoRoot, file: current.rel, base: deps.base ?? null })
|
|
666
681
|
: await fixHunkDiff({ file: current.rel, previous: state.reviewed_content ?? '', current: current.content, workDir: deps.workDir });
|
|
682
|
+
if (kind === 'recheck' && (current.content === state.reviewed_content || diff.hunks.length === 0)) {
|
|
683
|
+
state.next = { action: 'fix', level: state.level, trigger: null, reason: 'no-fix-hunks' };
|
|
684
|
+
state.pending_kind = null;
|
|
685
|
+
return state;
|
|
686
|
+
}
|
|
667
687
|
if (diff.hunks.length > 0) {
|
|
668
688
|
/** @type {Finding[]} */
|
|
669
689
|
const pending = [];
|
|
@@ -676,7 +696,8 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
676
696
|
if (pending.length > 0) {
|
|
677
697
|
const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
|
|
678
698
|
const moves = await recheckMoves(deps, current.rel, s.scope === 'file' ? diff : null);
|
|
679
|
-
const
|
|
699
|
+
const paired = pairedContext({ ...deps, file: current.rel });
|
|
700
|
+
const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null), paired });
|
|
680
701
|
/** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[], keys: number[] | null}>} */
|
|
681
702
|
let parts = [{ index: null, heading: null, diff, open: pending, keys: null }];
|
|
682
703
|
if (whole.status !== 'ok') {
|
|
@@ -684,7 +705,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
684
705
|
// a Markdown file whose packet is `split_required`, rechecked section by section (B54);
|
|
685
706
|
// any other status stops as it always did
|
|
686
707
|
const packed = whole.status === 'split_required' && isMarkdownFile(current.rel) ? packRecheck(diff, pending, whole.budget, moves) : null;
|
|
687
|
-
if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packed ?? whole);
|
|
708
|
+
if (!packed || packed.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, current.hash, packed ?? whole);
|
|
688
709
|
parts = packed.sections.map((sec) => ({ index: sec.index, heading: sec.headings[0], diff: sec.diff, open: sec.open, keys: sec.keys }));
|
|
689
710
|
}
|
|
690
711
|
// EVERY packet is built and held to the budget BEFORE any session runs: a section over it
|
|
@@ -697,11 +718,11 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
697
718
|
continue;
|
|
698
719
|
}
|
|
699
720
|
const moved = movedText(moves, diff.content, part.keys);
|
|
700
|
-
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved });
|
|
721
|
+
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved, paired });
|
|
701
722
|
// a packet that fails to build stops with ITS status (and size, when it has one)
|
|
702
|
-
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
|
|
723
|
+
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, current.hash, { ...packet, section: part.heading });
|
|
703
724
|
const size = recheckTokens(part.diff, part.open, moved);
|
|
704
|
-
if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
|
|
725
|
+
if (size > whole.budget) return stopBeforeRound(state, deps, kind, current.rel, current.hash, { status: 'split_required', tokensIn: size, budget: whole.budget, section: part.heading });
|
|
705
726
|
built.push(packet);
|
|
706
727
|
}
|
|
707
728
|
/** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
|
|
@@ -713,7 +734,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
713
734
|
for (const [i, part] of parts.entries()) {
|
|
714
735
|
const packet = /** @type {any} */ (built[i]);
|
|
715
736
|
packetInfo = mergePacketInfo(packetInfo, packet);
|
|
716
|
-
const session = await recheckSession(packet, deps);
|
|
737
|
+
const session = await recheckSession(packet, deps, part.open);
|
|
717
738
|
tokensIn = sum(tokensIn, session.tokens_in);
|
|
718
739
|
tokensOut = sum(tokensOut, session.tokens_out);
|
|
719
740
|
if (!session.verdict.ok) {
|
|
@@ -743,7 +764,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
743
764
|
draft.open = [...stillOpen, ...triaged.fix_now, ...blocking];
|
|
744
765
|
}
|
|
745
766
|
}
|
|
746
|
-
// no fix hunks
|
|
767
|
+
// A patch_check with no fix hunks closed nothing: B73 still stops it.
|
|
747
768
|
}
|
|
748
769
|
|
|
749
770
|
if (kind !== 'patch_check') {
|
|
@@ -785,7 +806,10 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
785
806
|
const after = new Set(draft.open.map((f) => f.id));
|
|
786
807
|
const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
|
|
787
808
|
draft.next = decideNext(draft, openBefore, s, kind, closedIds);
|
|
788
|
-
if (draft.next.action === 'stop')
|
|
809
|
+
if (draft.next.action === 'stop') {
|
|
810
|
+
draft.status = 'stopped';
|
|
811
|
+
draft.stop_content_hash = current.hash;
|
|
812
|
+
}
|
|
789
813
|
}
|
|
790
814
|
const rows = [...lateRows, roundRow];
|
|
791
815
|
if (draft.status === 'stopped') rows.push({ event: 'review.cap', file: current.rel, round: draft.round, reason: draft.next.reason, open: draft.open.map((f) => f.id) });
|
|
@@ -828,6 +852,7 @@ export async function converge(state, deps) {
|
|
|
828
852
|
}
|
|
829
853
|
await deps.fix?.({ file: state.file, round: state.round + 1, level: state.level, open: [...state.open], l3_patch: state.l3_rung_used });
|
|
830
854
|
await runRound(state, deps);
|
|
855
|
+
if (state.next?.reason === 'no-fix-hunks') return state;
|
|
831
856
|
}
|
|
832
857
|
return state;
|
|
833
858
|
}
|