@codedology/code-forge 0.15.3 → 0.16.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 +51 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +9 -1
- package/docs/reference/config.md +11 -1
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +7 -2
- package/schema/code-forge.schema.json +26 -1
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +37 -0
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +18 -5
- package/skill/references/review.md +47 -9
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/block.mjs +284 -11
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/help.mjs +1 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/review-file.mjs +126 -14
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +80 -14
- package/src/decide/escalation.mjs +5 -5
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/agent-waiver.mjs +95 -8
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +33 -13
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +111 -22
- package/src/review/reopen.mjs +38 -13
- package/src/review/route.mjs +58 -4
- package/src/session/facts.mjs +252 -70
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +56 -21
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +133 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +150 -20
- package/src/worker/queue.mjs +3 -2
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +97 -83
- package/src/worker/worker-warning.mjs +35 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { open, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ledgerPath } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A worker's project-wide ledger view. Concurrent callers share one serialized
|
|
7
|
+
* cursor; incomplete UTF-8/JSON bytes stay buffered until a newline arrives.
|
|
8
|
+
* @param {string} slug
|
|
9
|
+
* @returns {{read: () => Promise<Record<string, any>[]>}}
|
|
10
|
+
*/
|
|
11
|
+
export function createTailReader(slug) {
|
|
12
|
+
const file = ledgerPath(slug);
|
|
13
|
+
const dir = path.dirname(file);
|
|
14
|
+
const rotatedName = new RegExp(`^${slug}\\.(\\d+)\\.jsonl$`);
|
|
15
|
+
/** @type {Record<string, any>[]} */
|
|
16
|
+
let rows = [];
|
|
17
|
+
let files = '';
|
|
18
|
+
let inode = '';
|
|
19
|
+
let offset = 0;
|
|
20
|
+
/** @type {Buffer} */
|
|
21
|
+
let pending = Buffer.alloc(0);
|
|
22
|
+
let chain = Promise.resolve();
|
|
23
|
+
|
|
24
|
+
/** @param {Buffer} bytes */
|
|
25
|
+
function parse(bytes) {
|
|
26
|
+
const end = bytes.lastIndexOf(0x0a) + 1;
|
|
27
|
+
return {
|
|
28
|
+
rows: bytes.subarray(0, end).toString('utf8').split('\n').filter((line) => line.length > 0).map((line) => JSON.parse(line)),
|
|
29
|
+
pending: bytes.subarray(end),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {import('node:fs/promises').FileHandle} handle @param {number} start @param {number} size */
|
|
34
|
+
async function readBytes(handle, start, size) {
|
|
35
|
+
const bytes = Buffer.alloc(Math.max(0, size - start));
|
|
36
|
+
let n = 0;
|
|
37
|
+
while (n < bytes.length) {
|
|
38
|
+
const { bytesRead } = await handle.read(bytes, n, bytes.length - n, start + n);
|
|
39
|
+
if (bytesRead === 0) break;
|
|
40
|
+
n += bytesRead;
|
|
41
|
+
}
|
|
42
|
+
return bytes.subarray(0, n);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function update() {
|
|
46
|
+
let names;
|
|
47
|
+
try { names = await readdir(dir); } catch (err) {
|
|
48
|
+
if (err.code !== 'ENOENT') throw err;
|
|
49
|
+
names = [];
|
|
50
|
+
}
|
|
51
|
+
const rotated = names.filter((name) => rotatedName.test(name))
|
|
52
|
+
.sort((a, b) => Number(rotatedName.exec(a)[1]) - Number(rotatedName.exec(b)[1]));
|
|
53
|
+
let handle;
|
|
54
|
+
try { handle = await open(file, 'r'); } catch (err) {
|
|
55
|
+
if (err.code !== 'ENOENT') throw err;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const info = await handle?.stat();
|
|
59
|
+
const identity = info ? `${info.dev}:${info.ino}` : '';
|
|
60
|
+
const listing = JSON.stringify(rotated);
|
|
61
|
+
const reset = listing !== files || identity !== inode || (info?.size ?? 0) < offset;
|
|
62
|
+
let next = reset ? [] : rows;
|
|
63
|
+
if (reset) {
|
|
64
|
+
for (const name of rotated) {
|
|
65
|
+
const history = await open(path.join(dir, name), 'r');
|
|
66
|
+
try {
|
|
67
|
+
next = next.concat(parse(await readBytes(history, 0, (await history.stat()).size)).rows);
|
|
68
|
+
} finally { await history.close(); }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const start = reset ? 0 : offset;
|
|
72
|
+
const bytes = handle ? await readBytes(handle, start, info.size) : Buffer.alloc(0);
|
|
73
|
+
const parsed = parse(Buffer.concat([reset ? Buffer.alloc(0) : pending, bytes]));
|
|
74
|
+
// Commit only after every complete line parsed, so corruption cannot poison the cursor.
|
|
75
|
+
rows = next.concat(parsed.rows);
|
|
76
|
+
pending = parsed.pending;
|
|
77
|
+
offset = start + bytes.length;
|
|
78
|
+
files = listing;
|
|
79
|
+
inode = identity;
|
|
80
|
+
return rows.slice();
|
|
81
|
+
} finally { await handle?.close(); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { read() {
|
|
85
|
+
const result = chain.then(update);
|
|
86
|
+
chain = result.then(() => {}, () => {});
|
|
87
|
+
return result;
|
|
88
|
+
} };
|
|
89
|
+
}
|
|
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
import { DELEGABLE_SEVERITIES, ownsAt, recordedSeverity, waiveFinding } from './gate-check.mjs';
|
|
6
6
|
import { checkFileForm, checkFindingId } from './verb-forms.mjs';
|
|
7
7
|
import { StateError } from '../state/paths.mjs';
|
|
8
|
+
import { readRun } from '../state/run.mjs';
|
|
8
9
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
9
10
|
import { configInForce } from '../state/config-snapshot.mjs';
|
|
10
11
|
import { readAllRows } from '../ledger/write.mjs';
|
|
@@ -15,6 +16,10 @@ import { verifyResult } from '../worker/queue.mjs';
|
|
|
15
16
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
16
17
|
import { runRootFor, spawnSession } from '../session/spawn.mjs';
|
|
17
18
|
import { spawnWithBrokenFallback } from './session-retry.mjs';
|
|
19
|
+
import { splitLines } from './context.mjs';
|
|
20
|
+
import { grantState } from '../autopilot/grant.mjs';
|
|
21
|
+
import { lastGate } from '../state/brief-status.mjs';
|
|
22
|
+
import { fileBaseline, testScope } from '../gates/expand.mjs';
|
|
18
23
|
|
|
19
24
|
const SCHEMA = {
|
|
20
25
|
type: 'object', additionalProperties: false, required: ['verdict', 'reason'],
|
|
@@ -35,6 +40,15 @@ function refuse(code, detail) {
|
|
|
35
40
|
throw new StateError(code, `${code}: ${detail}; nothing was recorded`);
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/** The checkExpiry grant rule, without lazy expiry writes on a refused waiver.
|
|
44
|
+
* @param {Record<string, any>} record
|
|
45
|
+
*/
|
|
46
|
+
function activeGrant(record) {
|
|
47
|
+
const grant = record.autopilot;
|
|
48
|
+
return record.status === 'active' && grant?.status === 'active'
|
|
49
|
+
&& Number.isFinite(Date.parse(grant.until)) && grantState(grant, new Date()) === 'active';
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
/** A citation must name a real regular file inside this tree, without following symlinks.
|
|
39
53
|
* @param {string} root @param {string} file @returns {boolean}
|
|
40
54
|
*/
|
|
@@ -47,16 +61,48 @@ function existsInTree(root, file) {
|
|
|
47
61
|
}
|
|
48
62
|
}
|
|
49
63
|
|
|
64
|
+
/** @param {string} reason */
|
|
65
|
+
const evidenceTokens = (reason) => reason.split(/[\s"'`()[\]{},;<>]+/u).filter(Boolean);
|
|
66
|
+
|
|
67
|
+
/** Numbered windows for the first ten readable repo citations, never following a link out.
|
|
68
|
+
* Each window includes ten lines either side, clipped to the file and eighty lines total.
|
|
69
|
+
* @param {string} reason @param {string} root
|
|
70
|
+
*/
|
|
71
|
+
export function citedEvidence(reason, root) {
|
|
72
|
+
const evidence = [];
|
|
73
|
+
for (const token of evidenceTokens(reason)) {
|
|
74
|
+
const match = /^(.+):([1-9][0-9]*)(?:-([1-9][0-9]*))?[.!?]?$/.exec(token);
|
|
75
|
+
if (!match) continue;
|
|
76
|
+
const first = Number(match[2]);
|
|
77
|
+
const last = Number(match[3] ?? match[2]);
|
|
78
|
+
if (!Number.isSafeInteger(first) || !Number.isSafeInteger(last) || last < first) continue;
|
|
79
|
+
try {
|
|
80
|
+
const file = assertRowPath(match[1]);
|
|
81
|
+
const bytes = readRegularFileNoFollow(root, file);
|
|
82
|
+
if (bytes === null) continue;
|
|
83
|
+
const lines = splitLines(bytes.toString('utf8'));
|
|
84
|
+
if (first > lines.length) continue;
|
|
85
|
+
const start = Math.max(1, first - 10);
|
|
86
|
+
const end = Math.min(lines.length, last + 10, start + 79);
|
|
87
|
+
const content = lines.slice(start - 1, end).map((line, i) => `${start + i}| ${line}`).join('\n');
|
|
88
|
+
evidence.push({ path: file, start, end, content });
|
|
89
|
+
if (evidence.length === 10) break;
|
|
90
|
+
} catch {
|
|
91
|
+
// Invalid, missing, unreadable and escaping citations never become packet content.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return evidence;
|
|
95
|
+
}
|
|
96
|
+
|
|
50
97
|
/** @param {string} reason @param {string} root @param {Record<string, any>} entry */
|
|
51
98
|
export function hasEvidence(reason, root, entry) {
|
|
99
|
+
if (citedEvidence(reason, root).length > 0) return true;
|
|
52
100
|
for (const match of reason.matchAll(/\bclause\s+([0-9]+)\b/gi)) {
|
|
53
101
|
const n = Number(match[1]);
|
|
54
102
|
if (n >= 1 && n <= (Array.isArray(entry.acceptance) ? entry.acceptance.length : 0)) return true;
|
|
55
103
|
}
|
|
56
|
-
const tokens = reason
|
|
104
|
+
const tokens = evidenceTokens(reason);
|
|
57
105
|
for (const token of tokens) {
|
|
58
|
-
const line = /^(.+):([1-9][0-9]*)[.!?]?$/.exec(token);
|
|
59
|
-
if (line && existsInTree(root, line[1])) return true;
|
|
60
106
|
const file = token.replace(/[.!?]+$/, '');
|
|
61
107
|
const testFile = /(^|\/)(?:test|tests|__tests__)\//.test(file) || /(?:^|[./_-])(?:test|spec)\.[^/]+$/.test(file) || entry.test_files?.includes(file);
|
|
62
108
|
if (testFile && existsInTree(root, file)) return true;
|
|
@@ -64,6 +110,37 @@ export function hasEvidence(reason, root, entry) {
|
|
|
64
110
|
return false;
|
|
65
111
|
}
|
|
66
112
|
|
|
113
|
+
/** Require a cited acceptance test measured by the latest signed gate at these exact bytes.
|
|
114
|
+
* Gate rows summarize test success, not individual assertions; only full or known related
|
|
115
|
+
* scopes prove that the cited file ran. A changed scope does not record its file list.
|
|
116
|
+
* @param {string} reason @param {string} root @param {Record<string, any>} entry
|
|
117
|
+
* @param {Record<string, any>[]} rows @param {string} id @param {Buffer} key
|
|
118
|
+
*/
|
|
119
|
+
async function passingAcceptanceTest(reason, root, entry, rows, id, key) {
|
|
120
|
+
const named = new Set((entry.acceptance ?? []).flatMap((clause) => clause.tests ?? []));
|
|
121
|
+
const cited = evidenceTokens(reason).map((token) => token.replace(/:[1-9][0-9]*(?:-[1-9][0-9]*)?[.!?]?$/, '').replace(/[.!?]+$/, ''))
|
|
122
|
+
.filter((file) => named.has(file) && existsInTree(root, file));
|
|
123
|
+
if (cited.length === 0) refuse('waive-yes-critical-no-test', 'cite an existing test file named in this block\'s acceptance tests');
|
|
124
|
+
const mine = rows.filter((row) => row.block === id);
|
|
125
|
+
const latest = mine.filter((row) => row.event === 'gate.done' || row.event === 'gate.red').at(-1);
|
|
126
|
+
if (!latest || !verifyRow(latest, key).ok || lastGate(mine).result !== 'pass'
|
|
127
|
+
|| latest.worktree !== entry.worktree?.path
|
|
128
|
+
|| !Array.isArray(latest.results)
|
|
129
|
+
|| !latest.results.some((result) => result.gate === 'test' && result.ok === true && result.skipped === false)) {
|
|
130
|
+
refuse('waive-yes-critical-no-test', 'the cited acceptance test needs a passing test result in this block\'s latest signed gate record');
|
|
131
|
+
}
|
|
132
|
+
const current = await fileBaseline(root, entry.owned_files ?? [], entry.owned_kinds);
|
|
133
|
+
if (!current || !latest.files || Object.keys(current.files).length !== Object.keys(latest.files).length
|
|
134
|
+
|| Object.entries(current.files).some(([file, hash]) => latest.files[file] !== hash)) {
|
|
135
|
+
refuse('waive-yes-critical-no-test', 'rerun the acceptance test: the latest gate record does not cover the current block content');
|
|
136
|
+
}
|
|
137
|
+
const scope = testScope({ testFiles: entry.test_files, owned: entry.owned_files ?? [] });
|
|
138
|
+
const test = cited.find((file) => latest.files[file] && (latest.scope === 'full'
|
|
139
|
+
|| (latest.scope === 'related' && scope.scope === 'related' && scope.files.includes(file))));
|
|
140
|
+
if (!test) refuse('waive-yes-critical-no-test', 'the latest passing gate record must cover the cited acceptance test file and its current content');
|
|
141
|
+
return test;
|
|
142
|
+
}
|
|
143
|
+
|
|
67
144
|
/** The full finding is normally in the signed worker result; older rows may carry it directly.
|
|
68
145
|
* @param {Record<string, any>[]} rows @param {{root: string, runId: string, id: string, file: string, finding: string, key: Buffer}} where
|
|
69
146
|
*/
|
|
@@ -130,17 +207,26 @@ export async function waiveAsAgent({ runId, id, finding, file, reason, record, w
|
|
|
130
207
|
const rows = (await readAllRows(record.project)).filter((r) => r.run === runId);
|
|
131
208
|
const key = await loadKey(runId);
|
|
132
209
|
const recorded = recordedSeverity(rows, { block: id, file, finding, key });
|
|
133
|
-
|
|
210
|
+
const critical = recorded.severity === 'critical';
|
|
211
|
+
if (!DELEGABLE_SEVERITIES.includes(recorded.severity) && !(critical && activeGrant(record))) {
|
|
212
|
+
refuse('waive-yes-critical', `finding ${finding} is ${recorded.severity ?? recorded.why}; a critical finding needs an active autopilot grant; otherwise only warning or nit findings may use --yes`);
|
|
213
|
+
}
|
|
134
214
|
const tree = await blockTree(record.workspace, entry);
|
|
135
215
|
const contentRoot = tree?.top ?? root;
|
|
136
|
-
|
|
216
|
+
const test = critical ? await passingAcceptanceTest(reason, contentRoot, entry, rows, id, key) : null;
|
|
217
|
+
if (critical ? !citedEvidence(reason, contentRoot).some((item) => item.path === file) : !hasEvidence(reason, contentRoot, entry)) {
|
|
218
|
+
refuse('waive-yes-no-evidence', critical ? `cite the finding's path ${file} with :line or :start-end`
|
|
219
|
+
: 'cite an existing repo path with :line or :start-end, an existing test file, or a block acceptance clause number');
|
|
220
|
+
}
|
|
137
221
|
const bytes = readRegularFileNoFollow(contentRoot, file);
|
|
138
222
|
if (bytes === null) refuse('waive-yes-disagreed', 'The current file cannot be read');
|
|
139
223
|
const text = await findingText(rows, { root, runId, id, file, finding, key });
|
|
140
|
-
const
|
|
224
|
+
const evidence = citedEvidence(reason, contentRoot);
|
|
225
|
+
const answer = await judgeAgentReview({ finding: { id: finding, severity: recorded.severity, ...text }, reason, file: { path: file, content: bytes.toString('utf8') }, ...(evidence.length > 0 ? { evidence } : {}) }, { record, runId, id, judge });
|
|
141
226
|
if (answer.verdict === 'broken') refuse('waive-yes-broken', answer.reason);
|
|
142
227
|
if (answer.verdict !== 'agree') refuse('waive-yes-disagreed', answer.reason);
|
|
143
|
-
|
|
228
|
+
if (critical && !activeGrant(await readRun(runId))) refuse('waive-yes-critical', 'the critical finding still needs an active autopilot grant after the judge returns');
|
|
229
|
+
return waiveFinding({ runId, id, finding, file, reason, writeRow, by: 'agent', extra: { severity: recorded.severity, verdict: 'agree', judge_reason: answer.reason, ...(critical ? { autopilot: true, test } : {}) } });
|
|
144
230
|
}
|
|
145
231
|
|
|
146
232
|
/** Verified ledger-order cancellation, scoped to one run, block, file and finding.
|
|
@@ -159,7 +245,8 @@ export function isUnwaived(rows, waiver, key, verify = (r) => verifyRow(r, key).
|
|
|
159
245
|
*/
|
|
160
246
|
export function countedAgentWaivers(rows, block, key, verify = (r) => verifyRow(r, key).ok) {
|
|
161
247
|
return rows.filter((r) => r.block === block && r.event === 'review.waived' && r.by === 'agent'
|
|
162
|
-
&& r.verdict === 'agree' && DELEGABLE_SEVERITIES.includes(r.severity)
|
|
248
|
+
&& r.verdict === 'agree' && (DELEGABLE_SEVERITIES.includes(r.severity)
|
|
249
|
+
|| (r.severity === 'critical' && r.autopilot === true && typeof r.test === 'string' && r.test.length > 0)) && verify(r)
|
|
163
250
|
&& !isUnwaived(rows, r, key, verify)
|
|
164
251
|
&& recordedSeverity(rows.filter((other) => other.run === r.run), { block, file: r.file, finding: r.finding, key, verify }).severity === r.severity);
|
|
165
252
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** Best-effort JavaScript/TypeScript definition context, with caller-supplied I/O. */
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
import { isSecretLike } from './packet.mjs';
|
|
4
|
+
|
|
5
|
+
const KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof', 'new']);
|
|
6
|
+
const EXTENSIONS = ['.mjs', '.js', '.ts', '.cjs', '.tsx'];
|
|
7
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
8
|
+
|
|
9
|
+
/** @typedef {{path: string, name: string, start: number, end: number, lines: string[]}} Definition */
|
|
10
|
+
|
|
11
|
+
/** Comments and literals stay opaque; positions still refer to the original source.
|
|
12
|
+
* @param {string} text
|
|
13
|
+
*/
|
|
14
|
+
function tokensOf(text) {
|
|
15
|
+
const tokens = [];
|
|
16
|
+
let line = 1;
|
|
17
|
+
let offset = 0;
|
|
18
|
+
for (const match of text.matchAll(/\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|`(?:\\[\s\S]|[^`\\])*`|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\n\\])+\/[dgimsuvy]*|[A-Za-z_$][\w$]*|=>|[^\s]/g)) {
|
|
19
|
+
line += text.slice(offset, match.index).split('\n').length - 1;
|
|
20
|
+
offset = match.index;
|
|
21
|
+
if (!/^\/[/\*]/.test(match[0])) tokens.push({ value: match[0], line });
|
|
22
|
+
}
|
|
23
|
+
return tokens;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} rel */
|
|
27
|
+
function safePath(rel) {
|
|
28
|
+
return !nodePath.posix.isAbsolute(rel) && !/[\\\x00-\x1f\x7f]/.test(rel) &&
|
|
29
|
+
!rel.split('/').some((part) => ['', '.', '..', '.git', '.code-forge'].includes(part)) && !isSecretLike(rel);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {ReturnType<typeof tokensOf>} tokens @param {number} start */
|
|
33
|
+
function closing(tokens, start) {
|
|
34
|
+
const open = tokens[start]?.value;
|
|
35
|
+
const close = { '(': ')', '{': '}', '[': ']' }[open];
|
|
36
|
+
let depth = 0;
|
|
37
|
+
for (let i = start; i < tokens.length; i++) {
|
|
38
|
+
if (tokens[i].value === open) depth++;
|
|
39
|
+
if (tokens[i].value === close && --depth === 0) return i;
|
|
40
|
+
}
|
|
41
|
+
return -1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {string} text @param {string} file @param {string} name @param {boolean} exported
|
|
45
|
+
* @returns {Definition | null}
|
|
46
|
+
*/
|
|
47
|
+
function declaration(text, file, name, exported) {
|
|
48
|
+
const tokens = tokensOf(text);
|
|
49
|
+
const lines = text.split(/\r?\n/);
|
|
50
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
51
|
+
if (tokens[i].value !== name) continue;
|
|
52
|
+
const prev = tokens[i - 1]?.value;
|
|
53
|
+
const kind = ['function', 'const', 'class'].includes(prev) ? prev : null;
|
|
54
|
+
let start = i - 1;
|
|
55
|
+
while (start > 0 && ['async', 'default', 'export', 'declare'].includes(tokens[start - 1].value)) start--;
|
|
56
|
+
if (exported && !tokens.slice(start, i).some((token) => token.value === 'export')) continue;
|
|
57
|
+
if (kind === null && (exported || tokens[i + 1]?.value !== '(' || !['{', '}', ';', 'async', 'static', 'get', 'set'].includes(prev))) continue;
|
|
58
|
+
if (kind === null && tokens[closing(tokens, i + 1) + 1]?.value !== '{') continue;
|
|
59
|
+
if (kind === null) start = ['async', 'static', 'get', 'set'].includes(prev) ? i - 1 : i;
|
|
60
|
+
if (kind === 'const' && tokens[i + 1]?.value !== '=') continue;
|
|
61
|
+
let body = i + 1;
|
|
62
|
+
let end = -1;
|
|
63
|
+
for (; body < tokens.length; body++) {
|
|
64
|
+
const value = tokens[body].value;
|
|
65
|
+
if (value === '(' || value === '[') {
|
|
66
|
+
const last = closing(tokens, body);
|
|
67
|
+
if (last < 0) break;
|
|
68
|
+
body = last;
|
|
69
|
+
} else if (value === '{') {
|
|
70
|
+
end = closing(tokens, body);
|
|
71
|
+
break;
|
|
72
|
+
} else if (value === ';' || (kind === 'const' && tokens[body].line > tokens[i].line && tokens[body - 1]?.value !== '=')) {
|
|
73
|
+
end = value === ';' ? body : body - 1;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (end < 0) continue; // Malformed or incomplete declaration: keep looking.
|
|
78
|
+
const firstLine = tokens[start].line;
|
|
79
|
+
const lastLine = tokens[end].line;
|
|
80
|
+
const lastShown = Math.min(lastLine, firstLine + 39);
|
|
81
|
+
return { path: file, name, start: firstLine, end: lastShown,
|
|
82
|
+
lines: [...lines.slice(firstLine - 1, lastShown), ...(lastLine > lastShown ? ['…'] : [])] };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse local bindings, preserving aliases and namespace owners.
|
|
88
|
+
* @param {string} header
|
|
89
|
+
*/
|
|
90
|
+
function importsOf(header) {
|
|
91
|
+
const tokens = tokensOf(header);
|
|
92
|
+
const imports = new Map();
|
|
93
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
94
|
+
if (tokens[i].value !== 'import' || ['(', '.'].includes(tokens[i + 1]?.value)) continue;
|
|
95
|
+
let end = i + 1;
|
|
96
|
+
while (end < tokens.length && !['from', ';', 'import'].includes(tokens[end].value)) end++;
|
|
97
|
+
if (tokens[end]?.value !== 'from') continue;
|
|
98
|
+
const source = tokens[end + 1]?.value;
|
|
99
|
+
if (!/^['"]\.\.?\//.test(source ?? '')) continue;
|
|
100
|
+
const specifier = source.slice(1, -1);
|
|
101
|
+
for (let j = i + 1; j < end; j++) {
|
|
102
|
+
const name = tokens[j].value;
|
|
103
|
+
if (!IDENTIFIER.test(name) || ['as', 'type'].includes(name)) continue;
|
|
104
|
+
if (tokens[j - 1]?.value === 'as') continue;
|
|
105
|
+
const alias = tokens[j + 1]?.value === 'as' ? tokens[j + 2]?.value : name;
|
|
106
|
+
imports.set(alias, { name, specifier, namespace: tokens[j - 1]?.value === '*' });
|
|
107
|
+
}
|
|
108
|
+
// `* as owner` has no ordinary imported name.
|
|
109
|
+
if (tokens[i + 1]?.value === '*' && tokens[i + 2]?.value === 'as') {
|
|
110
|
+
imports.set(tokens[i + 3]?.value, { name: '*', specifier, namespace: true });
|
|
111
|
+
}
|
|
112
|
+
i = end + 1;
|
|
113
|
+
}
|
|
114
|
+
return imports;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Calls on added unified-diff lines only. Missing or malformed source never aborts a packet.
|
|
119
|
+
* `readFile` takes an absolute path, as in relatedFiles; windows are current-file line ranges.
|
|
120
|
+
* @param {{path: string, diffLines: string[], header: string, io: {
|
|
121
|
+
* repoRoot: string, readFile: (file: string) => string | null | Promise<string | null>,
|
|
122
|
+
* windows?: Array<{start: number, end: number}>
|
|
123
|
+
* }}} opts
|
|
124
|
+
* @returns {Promise<Definition[]>}
|
|
125
|
+
*/
|
|
126
|
+
export async function findDefinitions({ path, diffLines, header, io }) {
|
|
127
|
+
const found = [];
|
|
128
|
+
if (!safePath(path) || !EXTENSIONS.includes(nodePath.posix.extname(path).toLowerCase())) return found;
|
|
129
|
+
try {
|
|
130
|
+
const cache = new Map();
|
|
131
|
+
const read = async (rel) => {
|
|
132
|
+
if (!safePath(rel)) return null;
|
|
133
|
+
if (!cache.has(rel)) {
|
|
134
|
+
try { cache.set(rel, await io.readFile(nodePath.resolve(io.repoRoot, rel))); }
|
|
135
|
+
catch { cache.set(rel, null); }
|
|
136
|
+
}
|
|
137
|
+
return cache.get(rel);
|
|
138
|
+
};
|
|
139
|
+
const text = await read(path);
|
|
140
|
+
if (typeof text !== 'string') return found;
|
|
141
|
+
const added = tokensOf(diffLines.filter((line) => line.startsWith('+') && !line.startsWith('+++')).map((line) => line.slice(1)).join('\n'));
|
|
142
|
+
const defined = new Set(added.filter((token, i) => ['function', 'const', 'class', 'let', 'var'].includes(added[i - 1]?.value) ||
|
|
143
|
+
(added[i + 1]?.value === '(' && added[closing(added, i + 1) + 1]?.value === '{')).map((token) => token.value));
|
|
144
|
+
const imports = importsOf(header);
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
for (let i = 0; i < added.length; i++) {
|
|
147
|
+
const name = added[i].value;
|
|
148
|
+
if (!IDENTIFIER.test(name) || added[i + 1]?.value !== '(' || KEYWORDS.has(name) || defined.has(name)) continue;
|
|
149
|
+
const receiver = added[i - 1]?.value === '.' ? added[i - 2]?.value : null;
|
|
150
|
+
const binding = imports.get(receiver ?? name);
|
|
151
|
+
let definition = declaration(text, path, name, false);
|
|
152
|
+
if (definition && io.windows?.some((w) => w.start <= definition.start && definition.end <= w.end)) definition = null;
|
|
153
|
+
if (!definition && binding && (!receiver || binding.namespace)) {
|
|
154
|
+
const target = nodePath.posix.normalize(nodePath.posix.join(nodePath.posix.dirname(path), binding.specifier));
|
|
155
|
+
if (!safePath(target)) continue;
|
|
156
|
+
const candidates = nodePath.posix.extname(target) ? [target] : [target, ...EXTENSIONS.map((ext) => target + ext), ...EXTENSIONS.map((ext) => `${target}/index${ext}`)];
|
|
157
|
+
for (const candidate of candidates) {
|
|
158
|
+
const imported = await read(candidate);
|
|
159
|
+
if (typeof imported !== 'string') continue;
|
|
160
|
+
definition = declaration(imported, candidate, binding.namespace ? name : binding.name, true);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!definition) continue;
|
|
165
|
+
const key = `${definition.path}:${definition.start}`;
|
|
166
|
+
if (!seen.has(key)) { seen.add(key); found.push(definition); }
|
|
167
|
+
}
|
|
168
|
+
} catch { /* Partial context is preferable to aborting review on unparseable source. */ }
|
|
169
|
+
return found;
|
|
170
|
+
}
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* ≥ `escalation.warning_threshold` (2) warnings or any `critical` — climb at once (rule 2c,
|
|
20
20
|
* B34: `trigger: review_warnings`); `warn_rounds_at_level` counts them and resets on a climb.
|
|
21
21
|
* 5. Cap: `review.max_rounds_per_file` (4) reached with an open finding ⇒ the L3 patch rung
|
|
22
|
-
* (rule 6; once per
|
|
22
|
+
* (rule 6; once per file — shared with rule 5's ceiling), whose `patch_check` round is
|
|
23
23
|
* OUTSIDE the cap. The rung is also rule 5: rule 2/2b at the running ceiling (L2 — L3 is never
|
|
24
24
|
* a coding level, R1) takes it, so for an L2 block the rung IS the +1 at round 2. After the
|
|
25
25
|
* patch the block continues at L2 (§3.6, Fable ruling A): `l3_rung_used = true`,
|
|
@@ -127,7 +127,7 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
|
|
|
127
127
|
* @property {string[]} [finding_history] - claim/evidence keys from earlier rounds of this file.
|
|
128
128
|
* @property {string[]} [finding_ids] - ids seen in earlier rounds, including closed findings.
|
|
129
129
|
* @property {string | null} reviewed_content - the content the last counted round reviewed.
|
|
130
|
-
* @property {boolean} l3_rung_used - the
|
|
130
|
+
* @property {boolean} l3_rung_used - the file's L3 rung (rule 5 or 6) was used — once per file.
|
|
131
131
|
* @property {'open' | 'complete' | 'stopped'} status
|
|
132
132
|
* @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
|
|
133
133
|
* @property {string} [stop_lens] - the lens whose packet stopped before a round.
|
|
@@ -146,7 +146,7 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
|
|
|
146
146
|
* @property {import('./triage.mjs').JevAsk} [jev]
|
|
147
147
|
* @property {import('./triage.mjs').RuleFn} [rule]
|
|
148
148
|
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
|
|
149
|
-
* @property {(req: {file: string, round: number, level: string, open: Finding[], l3_patch: boolean}) => Promise<unknown>} [fix] - a fresh coder session at `level`; `l3_patch` = the
|
|
149
|
+
* @property {(req: {file: string, round: number, level: string, open: Finding[], l3_patch: boolean}) => Promise<unknown>} [fix] - a fresh coder session at `level`; `l3_patch` = the file's L3 patch is in the tree (§3.6).
|
|
150
150
|
* @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
|
|
151
151
|
* @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
|
|
152
152
|
* @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
|
|
@@ -582,7 +582,7 @@ async function recheckSession(packet, deps, open) {
|
|
|
582
582
|
function decideNext(state, openBefore, s, kind, closedIds) {
|
|
583
583
|
if (state.open.length === 0) return { action: 'complete' };
|
|
584
584
|
if (kind === 'patch_check') {
|
|
585
|
-
// the
|
|
585
|
+
// the file's one rung is spent: a patch that closed no old finding stops FIRST — even when
|
|
586
586
|
// the rung was taken at the cap — then the cap; else (B73: ≥ 1 old id closed is progress,
|
|
587
587
|
// whatever new findings its fix hunks brought) the block continues at L2: a fix, no stall
|
|
588
588
|
if (closedIds === 0) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
|
|
18
18
|
* not qualify, though it still refuses) and names at least one finding, and every finding it
|
|
19
19
|
* names and every open id of the file's unsuperseded `review.cap` rows is cleared by a waiver the gate
|
|
20
|
-
* accepts (
|
|
20
|
+
* accepts (a critical needs a human or an agreed agent waiver issued in autopilot). Such a file is never
|
|
21
21
|
* approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
|
|
22
22
|
* - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
|
|
23
23
|
* - `not_approved`: (B52) the file's signed approval for its current hash is STALE — a later row of
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
* reviewed the content, {@link neverReviewed}; a `review.round` with `open_after` not 0; a
|
|
27
27
|
* `review.cap`; a refusal row with no `content_hash` counts for every hash of its file). One
|
|
28
28
|
* refusal per open finding the latest such row names, each cleared only by a valid waiver for
|
|
29
|
-
* the severity that row RECORDS (
|
|
30
|
-
*
|
|
29
|
+
* the severity that row RECORDS (a critical needs a signed human waiver or an agreed agent
|
|
30
|
+
* waiver issued in autopilot); a row that names no finding is one `not_approved <file>` that only a
|
|
31
31
|
* fresh approval clears. An old ledger's `review.result` row has no `approved` field and is
|
|
32
32
|
* trusted neither way: the ticket's signed result file decides ({@link resultFilesFor}); a
|
|
33
33
|
* file that is missing or fails its MAC refuses `not_approved <file>`. Refusal rows count
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
|
|
44
44
|
* `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
|
|
45
45
|
* AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
|
|
46
|
-
* by hand and it was reviewed), or a verified human
|
|
46
|
+
* by hand and it was reviewed), or a verified human, agreed agent or automatic reopen after the cap is followed by a
|
|
47
47
|
* signed review round of the current bytes; a finding already refused as `not_approved` for that file is
|
|
48
48
|
* not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
|
|
49
49
|
* with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
|
|
@@ -85,6 +85,7 @@ import { lstatSync } from 'node:fs';
|
|
|
85
85
|
import path from 'node:path';
|
|
86
86
|
import { acceptedTranscriptHits } from './accept-hit.mjs';
|
|
87
87
|
import { countedAgentWaivers } from './agent-waiver.mjs';
|
|
88
|
+
import { isAutomaticReopen } from './reopen.mjs';
|
|
88
89
|
import { exec } from '../util/exec.mjs';
|
|
89
90
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
90
91
|
import { StateError } from '../state/paths.mjs';
|
|
@@ -287,7 +288,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
287
288
|
// Acceptance is a review input. Every older review verdict belongs to the previous input.
|
|
288
289
|
const mine = rows.filter((r, i) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId)
|
|
289
290
|
&& !(i < amendedAt && typeof r.event === 'string' && r.event.startsWith('review.')));
|
|
290
|
-
/** A verified human
|
|
291
|
+
/** A verified human, agreed agent or automatic reopen followed by a real review clears older cap ids.
|
|
291
292
|
* @param {Record<string, any>} cap @returns {boolean}
|
|
292
293
|
*/
|
|
293
294
|
const supersededCap = (cap) => {
|
|
@@ -295,7 +296,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
295
296
|
if (hash === undefined) return false;
|
|
296
297
|
const at = mine.indexOf(cap);
|
|
297
298
|
return mine.some((r, i) => i > at && r.event === 'review.reopened' && r.run === runId && r.file === cap.file
|
|
298
|
-
&& (r.by === 'human' || (r.by === 'agent' && r.verdict?.verdict === 'agree')) && ok(r)
|
|
299
|
+
&& (r.by === 'human' || (r.by === 'agent' && r.verdict?.verdict === 'agree') || isAutomaticReopen(r)) && ok(r)
|
|
299
300
|
&& mine.some((round, j) => j > i && round.event === 'review.round' && round.run === runId && round.file === cap.file && round.content_hash === hash && ok(round)));
|
|
300
301
|
};
|
|
301
302
|
/**
|
|
@@ -368,7 +369,8 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
368
369
|
* row RECORDS for it — REQUIRED: only an explicit null (none recorded there; the signed review
|
|
369
370
|
* rows decide, {@link autopilotWaiverProblem}) or warning/nit may be delegated; anything else
|
|
370
371
|
* (`undefined`, a critical, an unknown value) is the human's alone ({@link delegableSeverity}),
|
|
371
|
-
* except that an answer
|
|
372
|
+
* except that an answer or a counted agent waiver issued in autopilot also clears a critical.
|
|
373
|
+
* `invalid`: why the autopilot waivers naming it do
|
|
372
374
|
* not count (null when none names it).
|
|
373
375
|
* `exceptTicket`: see {@link answered}. `answerSeverity`: the severity the answer rule reads
|
|
374
376
|
* (default `severity`); the late-finding, cap and proof loops pass the one RECORDED for the id,
|
|
@@ -379,9 +381,11 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
379
381
|
const waiverOf = (file, finding, severity, exceptTicket = null, answerSeverity = severity) => {
|
|
380
382
|
if (waivers.some((r) => matches(r, file, finding))) return { by: 'human', invalid: null };
|
|
381
383
|
if (answered(file, finding, answerSeverity, exceptTicket)) return { by: 'answer', invalid: null };
|
|
382
|
-
if (finding !== PROOF_FINDING && delegableSeverity(severity)) {
|
|
384
|
+
if (finding !== PROOF_FINDING && (delegableSeverity(severity) || severity === 'critical')) {
|
|
383
385
|
const recorded = recordedSeverity(mine, { block, file, finding, key, verify: ok });
|
|
384
|
-
|
|
386
|
+
const agentMayWaive = (delegableSeverity(severity) && DELEGABLE_SEVERITIES.includes(recorded.severity))
|
|
387
|
+
|| (severity === 'critical' && recorded.severity === 'critical');
|
|
388
|
+
if (agentMayWaive && agentWaivers.some((r) => matches(r, file, finding) && r.severity === recorded.severity)) return { by: 'agent', invalid: null };
|
|
385
389
|
}
|
|
386
390
|
const named = autopilotWaivers.filter((r) => matches(r, file, finding));
|
|
387
391
|
// the unverified pre-check (`verify` given) reads no autopilot waiver: it cannot check a grant
|
|
@@ -904,15 +908,31 @@ export function repoRelativePath(raw) {
|
|
|
904
908
|
/**
|
|
905
909
|
* The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
|
|
906
910
|
* `DELETED_HASH`) ∪ untracked files, filtered to `owned` (`matchOwned`, default the registry's `ownsFile` with `ownedKinds`: an exact entry owns its path and the paths below it unless recorded as a regular file, B57), each with its content hash.
|
|
907
|
-
* Every path goes through `repoRelativePath
|
|
908
|
-
*
|
|
911
|
+
* Every path goes through `repoRelativePath`; files deleted now get a git base-existence check
|
|
912
|
+
* and, if absent at base, a history check. Omit them only when git confirms commits since base
|
|
913
|
+
* touched them in this workspace. Failed or empty history checks keep the deletion for review.
|
|
914
|
+
* Other files need no extra git call; deleted files need at most two.
|
|
915
|
+
* @param {{repoRoot: string, base: string, owned: string[], ownedKinds?: Record<string, string>, matchOwned?: (file: string) => boolean, changed?: string[], gitRun?: (argv: string[], opts: Parameters<typeof exec>[1]) => Promise<{result: 'ok' | 'failed', stdout?: string, timedOut?: boolean}>}} opts
|
|
909
916
|
* `changed`: the tree's changed files when the caller already listed them ({@link changedFiles}).
|
|
917
|
+
* `gitRun`: the base-existence and history runner (defaults to {@link exec}).
|
|
910
918
|
* @returns {Promise<Array<{file: string, content_hash: string}>>}
|
|
911
919
|
*/
|
|
912
|
-
export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed }) {
|
|
920
|
+
export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed, gitRun = exec }) {
|
|
913
921
|
const all = changed ?? (await changedFiles({ repoRoot, base }));
|
|
914
922
|
const isOwned = matchOwned ?? ((/** @type {string} */ f) => ownsFile(owned, f, ownedKinds));
|
|
915
|
-
|
|
923
|
+
const files = [];
|
|
924
|
+
for (const file of all.filter((f) => isOwned(f))) {
|
|
925
|
+
const content_hash = currentHash(repoRoot, file);
|
|
926
|
+
if (content_hash === DELETED_HASH) {
|
|
927
|
+
const atBase = await gitRun(['git', 'ls-tree', '--name-only', '-z', base, '--', file], { cwd: repoRoot, env: gitChildEnv(), timeoutMs: 30000 });
|
|
928
|
+
if (atBase.result === 'ok' && atBase.stdout === '') {
|
|
929
|
+
const history = await gitRun(['git', 'log', '--format=%H', `${base}..HEAD`, '--', file], { cwd: repoRoot, env: gitChildEnv(), timeoutMs: 30000 });
|
|
930
|
+
if (history.result === 'ok' && typeof history.stdout === 'string' && history.stdout.trim() !== '') continue;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
files.push({ file, content_hash });
|
|
934
|
+
}
|
|
935
|
+
return files;
|
|
916
936
|
}
|
|
917
937
|
|
|
918
938
|
/** Safety check for npm locks excluded from model review; other formats are not parsed.
|