@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/native/bin/darwin-arm64/code-forge-native +0 -0
  4. package/native/bin/darwin-x64/code-forge-native +0 -0
  5. package/native/bin/linux-arm64/code-forge-native +0 -0
  6. package/native/bin/linux-x64/code-forge-native +0 -0
  7. package/package.json +4 -2
  8. package/schema/facts.schema.json +1 -1
  9. package/skill/SKILL.md +1 -1
  10. package/skill/references/adapters/solo.md +1 -1
  11. package/skill/references/decisions.md +1 -1
  12. package/skill/references/plan.md +14 -5
  13. package/skill/references/review.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/facts-sheet.md +9 -3
  16. package/src/cli/review-file.mjs +5 -0
  17. package/src/cli/spawn.mjs +26 -22
  18. package/src/cli/status.mjs +37 -5
  19. package/src/decide/escalation.mjs +5 -5
  20. package/src/gates/dependents.mjs +200 -0
  21. package/src/gates/expand.mjs +83 -26
  22. package/src/gates/run.mjs +76 -14
  23. package/src/ledger/tail.mjs +89 -0
  24. package/src/review/definitions.mjs +170 -0
  25. package/src/review/fixloop.mjs +4 -4
  26. package/src/review/gate-check.mjs +20 -4
  27. package/src/review/packet.mjs +58 -20
  28. package/src/session/facts.mjs +234 -64
  29. package/src/session/spawn.mjs +70 -19
  30. package/src/state/block-end.mjs +3 -1
  31. package/src/state/block.mjs +17 -5
  32. package/src/state/run.mjs +12 -5
  33. package/src/util/native.mjs +2 -0
  34. package/src/worker/engine.mjs +11 -26
  35. package/src/worker/loop.mjs +6 -4
  36. package/src/worker/queue.mjs +2 -1
  37. package/src/worker/review-state.mjs +15 -3
  38. package/src/worker/watch.mjs +92 -89
  39. 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
+ }
@@ -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 block — shared with rule 5's ceiling), whose `patch_check` round is
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 block's L3 rung (rule 5 or 6) was used — once per block.
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 block's L3 patch is in the tree (§3.6).
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 block's one rung is spent: a patch that closed no old finding stops FIRST — even when
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
- * @param {{repoRoot: string, base: string, owned: string[], ownedKinds?: Record<string, string>, matchOwned?: (file: string) => boolean, changed?: string[]}} opts
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
- return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
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.
@@ -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
- * ## paired optional paired-file names and summary (B97), dropped next over budget
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}} opts
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: (full) => {
235
- const candidate = path.relative(repoRoot, full).split(path.sep).join('/');
236
- if (isSecretLike(candidate)) return null;
237
- return readRepoText(repoRoot, assertPacketPath(repoRoot, candidate));
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, ...parseDiff(res.stdout) };
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
- let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection, relatedSection);
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 = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection);
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 = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
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 = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
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',