@codedology/code-forge 0.15.4 → 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 +26 -0
- package/README.md +1 -1
- 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 +4 -2
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +14 -5
- package/skill/references/review.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/review-file.mjs +5 -0
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +37 -5
- package/src/decide/escalation.mjs +5 -5
- 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/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +20 -4
- package/src/review/packet.mjs +58 -20
- package/src/session/facts.mjs +234 -64
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +17 -5
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +2 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +6 -4
- package/src/worker/queue.mjs +2 -1
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +92 -89
- 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
|
+
}
|
|
@@ -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' };
|
|
@@ -908,15 +908,31 @@ export function repoRelativePath(raw) {
|
|
|
908
908
|
/**
|
|
909
909
|
* The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
|
|
910
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.
|
|
911
|
-
* Every path goes through `repoRelativePath
|
|
912
|
-
*
|
|
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
|
|
913
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}).
|
|
914
918
|
* @returns {Promise<Array<{file: string, content_hash: string}>>}
|
|
915
919
|
*/
|
|
916
|
-
export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed }) {
|
|
920
|
+
export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed, gitRun = exec }) {
|
|
917
921
|
const all = changed ?? (await changedFiles({ repoRoot, base }));
|
|
918
922
|
const isOwned = matchOwned ?? ((/** @type {string} */ f) => ownsFile(owned, f, ownedKinds));
|
|
919
|
-
|
|
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;
|
|
920
936
|
}
|
|
921
937
|
|
|
922
938
|
/** Safety check for npm locks excluded from model review; other formats are not parsed.
|
package/src/review/packet.mjs
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* ## facts the facts-sheet excerpt
|
|
12
12
|
* ## context the current file (whole, or hunk windows — `context.mjs`)
|
|
13
13
|
* ## related files (not shown) optional related paths only, dropped first over budget
|
|
14
|
-
* ##
|
|
14
|
+
* ## definitions optional called-symbol bodies, dropped last first after related paths
|
|
15
|
+
* ## paired optional paired-file names and summary (B97), dropped after definitions
|
|
15
16
|
* ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
|
|
16
17
|
* ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
|
|
17
18
|
*
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
* Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
|
|
23
24
|
* bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
|
|
24
25
|
* section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
|
|
25
|
-
* ± `min_context_lines` (`minimal`) after dropping related files, then paired, then the digest is trimmed from the end; the diff is
|
|
26
|
+
* ± `min_context_lines` (`minimal`) after dropping related files, definitions, then paired, then the digest is trimmed from the end; the diff is
|
|
26
27
|
* never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
|
|
27
28
|
* size (`diffOnlyTokens`, `split_required`) and never trimmed.
|
|
28
29
|
*
|
|
@@ -39,6 +40,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
39
40
|
import { exec } from '../util/exec.mjs';
|
|
40
41
|
import { assertInsideRoot, assertRowPath, gitChildEnv, readRegularFileNoFollow, WorkerError } from '../worker/ticket.mjs';
|
|
41
42
|
import { buildContext, parseDiff, splitLines } from './context.mjs';
|
|
43
|
+
import { findDefinitions } from './definitions.mjs';
|
|
42
44
|
import { relatedFiles } from './related.mjs';
|
|
43
45
|
|
|
44
46
|
export const LENSES = Object.freeze(['quick', 'full', 'recheck', 'A', 'B', 'judge']);
|
|
@@ -191,15 +193,19 @@ export function assertPacketPath(repoRoot, file) {
|
|
|
191
193
|
* @property {number} plusCount
|
|
192
194
|
* @property {number} minusCount
|
|
193
195
|
* @property {string[]} [related] - related file names, never their content.
|
|
196
|
+
* @property {import('./definitions.mjs').Definition[]} [definitions] - called-symbol bodies.
|
|
194
197
|
*/
|
|
195
198
|
|
|
196
199
|
/**
|
|
197
200
|
* Read one file's diff against `base` (§4.1).
|
|
198
|
-
* @param {{repoRoot: string, file: string, base?: string | null
|
|
201
|
+
* @param {{repoRoot: string, file: string, base?: string | null, io?: {
|
|
202
|
+
* readFile?: (file: string) => string | null | Promise<string | null>,
|
|
203
|
+
* listFiles?: (repoRoot: string) => string[] | Promise<string[]>
|
|
204
|
+
* }}} opts
|
|
199
205
|
* @returns {Promise<FileDiff>}
|
|
200
206
|
* @throws {PacketError} `bad-path`, `git`
|
|
201
207
|
*/
|
|
202
|
-
export async function readFileDiff({ repoRoot, file, base }) {
|
|
208
|
+
export async function readFileDiff({ repoRoot, file, base, io }) {
|
|
203
209
|
const rel = assertPacketPath(repoRoot, file);
|
|
204
210
|
if (isSecretLike(rel)) throw new PacketError('bad-path', 'secret-like path');
|
|
205
211
|
const ref = base ?? 'HEAD';
|
|
@@ -224,20 +230,30 @@ export async function readFileDiff({ repoRoot, file, base }) {
|
|
|
224
230
|
: ['git', 'diff', '--no-color', '--no-ext-diff', ref, '--', rel];
|
|
225
231
|
const res = await exec(argv, { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS, ...(kind === 'new' ? { okExitCodes: [0, 1] } : {}) });
|
|
226
232
|
if (res.result !== 'ok') throw new PacketError('git', `git diff failed for ${rel} (exit ${res.code})`);
|
|
227
|
-
const content = onDisk ? readRepoText(repoRoot, rel) : null;
|
|
233
|
+
const content = onDisk ? await (io?.readFile ? io.readFile(full) : readRepoText(repoRoot, rel)) : null;
|
|
234
|
+
const readFile = (full) => {
|
|
235
|
+
const candidate = path.relative(repoRoot, full).split(path.sep).join('/');
|
|
236
|
+
if (isSecretLike(candidate)) return null;
|
|
237
|
+
if (candidate === rel) return content;
|
|
238
|
+
const safe = assertPacketPath(repoRoot, candidate);
|
|
239
|
+
return io?.readFile ? io.readFile(path.join(repoRoot, safe)) : readRepoText(repoRoot, safe);
|
|
240
|
+
};
|
|
228
241
|
const related = res.stdout.trim().length === 0 ? [] : await relatedFiles({
|
|
229
242
|
repoRoot, file: rel,
|
|
230
243
|
listFiles: async () => {
|
|
244
|
+
if (io?.listFiles) return io.listFiles(repoRoot);
|
|
231
245
|
const tracked = await exec(['git', 'ls-files', '-z'], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS });
|
|
232
246
|
return tracked.result === 'ok' ? tracked.stdout.split('\0').filter(Boolean) : [];
|
|
233
247
|
},
|
|
234
|
-
readFile
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
248
|
+
readFile,
|
|
249
|
+
});
|
|
250
|
+
const parsed = parseDiff(res.stdout);
|
|
251
|
+
const definitions = content === null || res.stdout.trim().length === 0 ? [] : await findDefinitions({
|
|
252
|
+
path: rel, diffLines: res.stdout.split('\n'),
|
|
253
|
+
header: splitLines(content).slice(0, importHeaderEnd(rel, content)).join('\n'),
|
|
254
|
+
io: { repoRoot, readFile, windows: parsed.hunks.map((h) => ({ start: h.newStart, end: h.newStart + h.newLines - 1 })) },
|
|
239
255
|
});
|
|
240
|
-
return { file: rel, kind, diffText: res.stdout, content, related, ...
|
|
256
|
+
return { file: rel, kind, diffText: res.stdout, content, related, definitions, ...parsed };
|
|
241
257
|
}
|
|
242
258
|
|
|
243
259
|
/**
|
|
@@ -274,22 +290,33 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
274
290
|
let pairedSection = boundedPaired(paired, budgetFor(cfg, 'full_in'));
|
|
275
291
|
const names = ['quick', 'full'].includes(lens) ? (diff.related ?? []) : [];
|
|
276
292
|
let relatedSection = names.length > 0 ? `## related files (not shown)\n${names.join('\n')}` : '';
|
|
277
|
-
|
|
293
|
+
// Optional sections are dropped before context shrinks. Choose the final windows before
|
|
294
|
+
// excluding definitions already shown there, so shrinking cannot lose a called body.
|
|
295
|
+
if (estimateTokens(render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved)) > limit) {
|
|
296
|
+
relatedSection = '';
|
|
297
|
+
pairedSection = '';
|
|
298
|
+
ctx = packetContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
|
|
299
|
+
}
|
|
300
|
+
const definitions = lens === 'judge' ? [] : (diff.definitions ?? []).filter((definition) => definition.path !== diff.file ||
|
|
301
|
+
!ctx.windows.some((w) => w.start <= definition.start && definition.end <= w.end));
|
|
302
|
+
while (definitions.length > 0 && estimateTokens(definitionsSection(definitions)) > Math.floor(budgetFor(cfg, 'full_in') / 8)) definitions.pop();
|
|
303
|
+
const renderPacket = () => render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection, relatedSection, definitionsSection(definitions));
|
|
304
|
+
let text = renderPacket();
|
|
278
305
|
if (estimateTokens(text) > limit && relatedSection.length > 0) {
|
|
279
306
|
relatedSection = '';
|
|
280
|
-
text =
|
|
307
|
+
text = renderPacket();
|
|
308
|
+
}
|
|
309
|
+
while (estimateTokens(text) > limit && definitions.length > 0) {
|
|
310
|
+
definitions.pop();
|
|
311
|
+
text = renderPacket();
|
|
281
312
|
}
|
|
282
313
|
if (estimateTokens(text) > limit && pairedSection.length > 0) {
|
|
283
314
|
pairedSection = '';
|
|
284
|
-
text =
|
|
285
|
-
}
|
|
286
|
-
if (estimateTokens(text) > limit) {
|
|
287
|
-
ctx = packetContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
|
|
288
|
-
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
315
|
+
text = renderPacket();
|
|
289
316
|
}
|
|
290
317
|
while (estimateTokens(text) > limit && digest.length > 0) {
|
|
291
318
|
digest = digest.slice(0, -1);
|
|
292
|
-
text =
|
|
319
|
+
text = renderPacket();
|
|
293
320
|
}
|
|
294
321
|
const tokensIn = estimateTokens(text);
|
|
295
322
|
return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
|
|
@@ -342,6 +369,15 @@ function boundedPaired(paired, fullIn) {
|
|
|
342
369
|
return body.length > 0 ? heading + body.trimEnd() : '';
|
|
343
370
|
}
|
|
344
371
|
|
|
372
|
+
/** @param {import('./definitions.mjs').Definition[]} definitions @returns {string} */
|
|
373
|
+
function definitionsSection(definitions) {
|
|
374
|
+
if (definitions.length === 0) return '';
|
|
375
|
+
return ['## definitions (called by changed lines)', ...definitions.flatMap((definition) => [
|
|
376
|
+
`### ${definition.path}:${definition.start}-${definition.end}`,
|
|
377
|
+
...definition.lines.map((line, i) => line === '…' && i + definition.start > definition.end ? line : `${definition.start + i}| ${line}`),
|
|
378
|
+
])].join('\n') + '\n';
|
|
379
|
+
}
|
|
380
|
+
|
|
345
381
|
/**
|
|
346
382
|
* The tokens of the diff-only packet (lens + moved code + diff, no digest, facts or context): what
|
|
347
383
|
* `assemblePacket` holds against the budget before it says `split_required`. Pass the SAME `moved`
|
|
@@ -371,8 +407,9 @@ function diffSectionOf(diff) {
|
|
|
371
407
|
* @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
|
|
372
408
|
* @param {string} [paired] - the bounded `## paired` section (heading included).
|
|
373
409
|
* @param {string} [related] - the names-only related files section (heading included).
|
|
410
|
+
* @param {string} [definitions] - the bounded called-symbol definitions section.
|
|
374
411
|
*/
|
|
375
|
-
function render(lensText, digest, facts, context, diffSection, moved = '', paired = '', related = '') {
|
|
412
|
+
function render(lensText, digest, facts, context, diffSection, moved = '', paired = '', related = '', definitions = '') {
|
|
376
413
|
return [
|
|
377
414
|
'# code-forge review packet',
|
|
378
415
|
'## lens',
|
|
@@ -384,6 +421,7 @@ function render(lensText, digest, facts, context, diffSection, moved = '', paire
|
|
|
384
421
|
'## context',
|
|
385
422
|
context.length > 0 ? context : '(none)',
|
|
386
423
|
...(related.length > 0 ? [related] : []),
|
|
424
|
+
...(definitions.length > 0 ? [definitions.trimEnd()] : []),
|
|
387
425
|
...(paired.length > 0 ? [paired] : []),
|
|
388
426
|
...(moved.length > 0 ? [moved] : []),
|
|
389
427
|
'## diff',
|