@evoclock/pi-agentic-driver 0.4.3

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.
@@ -0,0 +1,882 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ import { createHash } from "node:crypto";
5
+ import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
6
+ import { extname, relative, resolve } from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { analyzeTypeScriptSource } from "./typescript_ast_metrics.mjs";
10
+ import { changedRanges } from "./adapters/diff-scope.mjs";
11
+ import { buildEvidence, buildCandidates } from "./adapters/evidence.mjs";
12
+ import { buildReviewFeedback, REVIEW_INTENT_VOCABULARY } from "./adapters/review-feedback.mjs";
13
+ import { buildNarrative, NARRATIVE_SCHEMA, validateNarrative } from "./adapters/narrative.mjs";
14
+ import { buildVisualization, THERMALL_POWER_STATION } from "./adapters/visualization.mjs";
15
+
16
+ export { buildNarrative, validateNarrative, NARRATIVE_SCHEMA, buildVisualization, THERMALL_POWER_STATION };
17
+
18
+ export const CODE_PHAGE_SCHEMA = "agentic-driver.code-phage.v1";
19
+ const MAX_GOAL_BYTES = 4_000;
20
+ const MAX_FILES = 64;
21
+ const MAX_FILE_BYTES = 512_000;
22
+ const SOURCE_EXTENSIONS = new Set([".cjs", ".js", ".mjs", ".py", ".ts", ".tsx"]);
23
+ const STOP_WORDS = new Set([
24
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "into",
25
+ "is", "it", "of", "on", "or", "the", "this", "to", "use", "with",
26
+ "test", "tests", "code", "check", "checks", "over", "under", "work", "works",
27
+ "run", "runs", "file", "files", "data", "value", "values", "report",
28
+ "reporting", "when", "where", "which", "while", "must", "should", "that",
29
+ "than", "then", "them", "they", "will", "only", "also", "each", "before",
30
+ "after", "without", "declared", "declares",
31
+ ]);
32
+ const PYTHON_METRICS_PATH = fileURLToPath(new URL("./python_ast_metrics.py", import.meta.url));
33
+ const WRITE_PATTERN = /\b(?:writeFile(?:Sync)?|appendFile(?:Sync)?|mkdir(?:Sync)?|rename(?:Sync)?|unlink(?:Sync)?|rm(?:Sync)?|open\s*\()/;
34
+ const PROCESS_PATTERN = /\b(?:spawn|spawnSync|exec|execSync|fork)\s*\(/;
35
+ const NETWORK_PATTERN = /\b(?:fetch|https?\.|axios|requests\.)/;
36
+ const NETWORK_IMPORT_PATTERN = /(?:\bimport\s+[\s\S]{0,200}?from\s*|\brequire\s*\(\s*|\bimport\s*\(\s*)(['"])(?:node:)?(?:http|https|undici|axios)\1/g;
37
+ const TIMER_PATTERN = /\b(?:setTimeout|setInterval)\s*\(/;
38
+ // Generic identifier words that carry no abstraction identity. Splitting
39
+ // snake_case/camelCase symbols yields these constantly (order_total ->
40
+ // "order", loadManifest -> "load"), so a single such word must never count
41
+ // as a structural prior-art signal. Distinctive multi-word overlap or a full
42
+ // symbol-name/signature match is still required to claim reuse.
43
+ const GENERIC_SYMBOL_WORDS = new Set([
44
+ "default", "exports", "module", "root", "main", "index", "init",
45
+ "config", "utils", "helpers", "common", "shared", "core", "base",
46
+ "load", "save", "read", "write", "open", "close", "run", "exec",
47
+ "get", "set", "add", "remove", "delete", "create", "make", "build",
48
+ "update", "process", "handle", "parse", "format", "validate",
49
+ "check", "test", "assert", "verify", "report", "print", "log",
50
+ "compute", "sum", "total", "count", "size", "length", "value",
51
+ "item", "items", "entry", "entries", "list", "map", "key", "name",
52
+ "type", "kind", "data", "info", "detail", "details", "state",
53
+ "status", "result", "results", "output", "input", "source", "target",
54
+ "path", "file", "dir", "line", "text", "message", "error", "warn",
55
+ "order", "record", "schema", "model", "view", "controller", "service",
56
+ "manager", "handler", "wrapper", "client", "server", "api", "app",
57
+ "self", "cls", "args", "params", "options", "settings", "props",
58
+ "fixture", "fixtures", "sample", "example", "demo", "mock", "stub",
59
+ ]);
60
+
61
+ function fail(message) {
62
+ throw new Error(message);
63
+ }
64
+
65
+ function text(value, field, maxBytes = MAX_GOAL_BYTES) {
66
+ if (typeof value !== "string" || !value.trim() || value !== value.trim() || value.includes("\0")) {
67
+ fail(`${field} must be non-empty text without outer whitespace or NUL`);
68
+ }
69
+ if (Buffer.byteLength(value, "utf8") > maxBytes) fail(`${field} exceeds the bounded size`);
70
+ return value;
71
+ }
72
+
73
+ function tokens(value) {
74
+ return [...new Set(
75
+ String(value).toLowerCase().replaceAll("-", " ").replaceAll("_", " ").match(/[a-z][a-z0-9]*/g) || [],
76
+ )].filter((item) => item.length > 1 && !STOP_WORDS.has(item)).sort();
77
+ }
78
+
79
+ function safeRelativePath(value) {
80
+ text(value, "path", 1_024);
81
+ const normalized = value.replaceAll("\\", "/");
82
+ if (normalized.startsWith("/") || normalized === "." || normalized.split("/").some((part) => part === ".." || part === "")) {
83
+ fail(`path must be repository-relative and contained: ${value}`);
84
+ }
85
+ if (normalized === ".git" || normalized.startsWith(".git/")) fail("Git administrative paths are not analyzable");
86
+ return normalized;
87
+ }
88
+
89
+ function boundedTextArray(value, field, pathValues = false) {
90
+ if (value === undefined) return [];
91
+ if (!Array.isArray(value) || value.length > MAX_FILES) fail(`${field} must contain at most ${MAX_FILES} items`);
92
+ return value.map((item) => {
93
+ text(item, field, MAX_GOAL_BYTES);
94
+ if (pathValues) safeRelativePath(item);
95
+ return item;
96
+ });
97
+ }
98
+
99
+ function repositoryRoot(root) {
100
+ const candidate = resolve(text(root, "root", 4_096));
101
+ try {
102
+ if (!statSync(candidate).isDirectory()) fail("root is not a directory");
103
+ return realpathSync(candidate);
104
+ } catch {
105
+ fail("root is unavailable");
106
+ }
107
+ }
108
+
109
+ function changedPaths(root) {
110
+ const result = spawnSync("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"], {
111
+ encoding: "utf8",
112
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" },
113
+ });
114
+ if (result.status !== 0) return { paths: [], status: "git-unavailable", reason: "Git status was unavailable; no changed-file inference was made." };
115
+ const paths = [];
116
+ for (const line of result.stdout.split(/\r?\n/)) {
117
+ if (line.length < 4) continue;
118
+ let value = line.slice(3).trim();
119
+ if (value.includes(" -> ")) value = value.split(" -> ").at(-1);
120
+ if (value.startsWith('"') && value.endsWith('"')) {
121
+ try { value = JSON.parse(value); } catch { continue; }
122
+ }
123
+ try { paths.push(safeRelativePath(value)); } catch { /* status output is advisory */ }
124
+ }
125
+ return { paths: [...new Set(paths)].sort(), status: "observed" };
126
+ }
127
+
128
+ function candidatePaths(root, requested) {
129
+ if (requested !== undefined && (!Array.isArray(requested) || requested.length > MAX_FILES)) {
130
+ fail(`candidatePaths must contain at most ${MAX_FILES} paths`);
131
+ }
132
+ const observed = requested === undefined ? changedPaths(root) : { paths: requested.map(safeRelativePath), status: "requested" };
133
+ const limitations = observed.reason ? [observed.reason] : [];
134
+ const files = [];
135
+ for (const value of [...new Set(observed.paths)].sort()) {
136
+ const absolute = resolve(root, value);
137
+ const rel = relative(root, absolute);
138
+ if (!rel || rel.startsWith("..") || absolute === root) {
139
+ limitations.push(`excluded path outside repository: ${value}`);
140
+ continue;
141
+ }
142
+ try {
143
+ let cursor = root;
144
+ for (const part of rel.split("/")) {
145
+ cursor = resolve(cursor, part);
146
+ if (lstatSync(cursor).isSymbolicLink()) {
147
+ limitations.push(`excluded symlinked path: ${value}`);
148
+ cursor = undefined;
149
+ break;
150
+ }
151
+ }
152
+ if (!cursor) continue;
153
+ const canonical = realpathSync(absolute);
154
+ const canonicalRelative = relative(root, canonical);
155
+ if (!canonicalRelative || canonicalRelative.startsWith("..") || canonicalRelative.includes("/../")) {
156
+ limitations.push(`excluded path resolving outside repository: ${value}`);
157
+ continue;
158
+ }
159
+ const info = lstatSync(absolute);
160
+ if (!info.isFile() || info.isSymbolicLink()) {
161
+ limitations.push(`excluded non-regular or symlink path: ${value}`);
162
+ continue;
163
+ }
164
+ if (info.size > MAX_FILE_BYTES) {
165
+ limitations.push(`excluded oversized source file: ${value}`);
166
+ continue;
167
+ }
168
+ if (SOURCE_EXTENSIONS.has(extname(value).toLowerCase())) files.push(value);
169
+ else limitations.push(`excluded unsupported source extension: ${value}`);
170
+ } catch {
171
+ limitations.push(`excluded unavailable path: ${value}`);
172
+ }
173
+ }
174
+ return { files: files.slice(0, MAX_FILES), limitations, source: observed.status };
175
+ }
176
+
177
+ function sourceLines(source) {
178
+ return source.split(/\r?\n/);
179
+ }
180
+
181
+ function codeLine(line) {
182
+ const trimmed = line.trim();
183
+ if (!trimmed) return "";
184
+ if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) return "";
185
+ return line.replace(/\/\/.*$/, "").replace(/#.*$/, "").trim();
186
+ }
187
+
188
+ function lineCounts(source) {
189
+ const lines = sourceLines(source);
190
+ let codeLines = 0;
191
+ let commentLines = 0;
192
+ for (const line of lines) {
193
+ const trimmed = line.trim();
194
+ if (!trimmed) continue;
195
+ if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) commentLines += 1;
196
+ else codeLines += 1;
197
+ }
198
+ return { lines: lines.length, codeLines, commentLines, blankLines: lines.length - codeLines - commentLines };
199
+ }
200
+
201
+ function runPythonAstMetrics(source, path) {
202
+ const result = spawnSync("python3", [PYTHON_METRICS_PATH], {
203
+ input: JSON.stringify({ source, path }),
204
+ encoding: "utf8",
205
+ maxBuffer: 2 * 1024 * 1024,
206
+ });
207
+ if (result.error || result.status !== 0) {
208
+ return {
209
+ status: "unavailable",
210
+ path,
211
+ language: "python",
212
+ method: "ast-v1",
213
+ parser: "python.ast",
214
+ ...lineCounts(source),
215
+ error: "Python AST parser could not be executed; complexity metrics are unavailable.",
216
+ };
217
+ }
218
+ try {
219
+ return JSON.parse(result.stdout);
220
+ } catch {
221
+ return {
222
+ status: "unavailable",
223
+ path,
224
+ language: "python",
225
+ method: "ast-v1",
226
+ parser: "python.ast",
227
+ ...lineCounts(source),
228
+ error: "Python AST parser returned invalid evidence; complexity metrics are unavailable.",
229
+ };
230
+ }
231
+ }
232
+
233
+ function moduleLevelMutableBindings(source, path) {
234
+ if (extname(path).toLowerCase() === ".py") {
235
+ return sourceLines(source).filter((line) => /^\s{0}(?!def\b|class\b)[A-Za-z_]\w*\s*(?::[^=]+)?=/.test(line)).length;
236
+ }
237
+ let depth = 0;
238
+ let count = 0;
239
+ for (const line of sourceLines(source)) {
240
+ if (depth === 0) {
241
+ const declaration = line.match(/^\s*(?:export\s+)?(?:let|var)\s+(.+?)(?:;|$)/);
242
+ if (declaration) count += declaration[1].split(",").length;
243
+ }
244
+ depth += (line.match(/{/g) || []).length - (line.match(/}/g) || []).length;
245
+ }
246
+ return count;
247
+ }
248
+
249
+ function analyzeSource(source, path) {
250
+ const ast = extname(path).toLowerCase() === ".py"
251
+ ? runPythonAstMetrics(source, path)
252
+ : analyzeTypeScriptSource(source, path);
253
+ const normalized = [];
254
+ for (const line of sourceLines(source)) {
255
+ const code = codeLine(line);
256
+ if (code) normalized.push(code.replace(/\s+/g, " "));
257
+ }
258
+ const frequencies = new Map();
259
+ for (const line of normalized) {
260
+ if (line.length >= 8) frequencies.set(line, (frequencies.get(line) || 0) + 1);
261
+ }
262
+ const duplicateLineCount = [...frequencies.values()]
263
+ .filter((count) => count > 1)
264
+ .reduce((sum, count) => sum + count - 1, 0);
265
+ const codeFacts = ast.codeFacts ?? {
266
+ exportedSymbols: [],
267
+ exportedFunctions: [],
268
+ dependencies: [],
269
+ purpose: null,
270
+ };
271
+ return {
272
+ ...ast,
273
+ codeFacts,
274
+ moduleLevelMutableBindings: moduleLevelMutableBindings(source, path),
275
+ duplicateLineCount,
276
+ stateSignals: {
277
+ writes: WRITE_PATTERN.test(source),
278
+ processes: PROCESS_PATTERN.test(source),
279
+ network: NETWORK_PATTERN.test(source) || NETWORK_IMPORT_PATTERN.test(source),
280
+ timers: TIMER_PATTERN.test(source),
281
+ },
282
+ };
283
+ }
284
+
285
+ function inventoryMatches(root, goal, targetRecords = []) {
286
+ const path = resolve(root, "pipeline_output", "codebase_inventory.jsonl");
287
+ const wordHits = (haystack, term) => String(haystack).toLowerCase().split(/[^a-z0-9]+/).includes(String(term).toLowerCase());
288
+ const asArray = (value) => Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
289
+ const factSources = (record) => [record, record?.codeFacts, record?.code_facts]
290
+ .filter((value) => value && typeof value === "object");
291
+ const hasField = (record, names) => factSources(record)
292
+ .some((source) => names.some((name) => Object.prototype.hasOwnProperty.call(source, name)));
293
+ const fieldValues = (record, names) => factSources(record).flatMap((source) => names.flatMap((name) => asArray(source[name])));
294
+ const stringValues = (value) => {
295
+ if (typeof value === "string") return [value];
296
+ if (Array.isArray(value)) return value.flatMap(stringValues);
297
+ if (value && typeof value === "object") return Object.values(value).flatMap(stringValues);
298
+ return [];
299
+ };
300
+ const uniqueStrings = (values) => [...new Set(values
301
+ .flatMap(stringValues)
302
+ .map((value) => value.trim())
303
+ .filter(Boolean))];
304
+ const identifierWords = (value) => [...new Set(
305
+ String(value)
306
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
307
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
308
+ .replace(/[$]/g, " ")
309
+ .replace(/[_.:/-]+/g, " ")
310
+ .toLowerCase()
311
+ .match(/[a-z][a-z0-9]*/g) || [],
312
+ )].filter((item) => item.length > 1 && !STOP_WORDS.has(item) && !GENERIC_SYMBOL_WORDS.has(item));
313
+ const symbolNames = (record) => {
314
+ const exportedFields = [
315
+ "exportedSymbols", "exported_symbols", "exportedNames", "exported_names", "symbolNames", "symbol_names", "exports", "exported",
316
+ ];
317
+ let values = uniqueStrings(fieldValues(record, exportedFields));
318
+ const hasSymbols = hasField(record, ["symbols"]);
319
+ values = [...new Set([
320
+ ...values,
321
+ ...uniqueStrings(fieldValues(record, ["symbols"])),
322
+ ])];
323
+ if (values.length || hasField(record, exportedFields) || hasSymbols) return values;
324
+ return [...new Set([
325
+ ...asArray(record?.functions)
326
+ .filter((item) => item && typeof item === "object" && item.public !== false)
327
+ .map((item) => item.name)
328
+ .filter(Boolean),
329
+ ...asArray(record?.classes)
330
+ .filter((item) => item && typeof item === "object" && item.public !== false)
331
+ .map((item) => item.name)
332
+ .filter(Boolean),
333
+ ])];
334
+ };
335
+ const signatureFromString = (value) => {
336
+ const textValue = String(value).trim();
337
+ let match = textValue.match(/^(.+?)\s*\((\d+)\)$/);
338
+ if (!match) match = textValue.match(/^(.+?)\s*[/#:](\d+)$/);
339
+ return match ? { name: match[1].trim(), parameterCount: Number(match[2]) } : undefined;
340
+ };
341
+ const signatureEntries = (record) => {
342
+ const signatureFields = [
343
+ "exportedFunctions", "exported_functions", "exportedFunctionSignatures", "exported_function_signatures", "functionSignatures", "function_signatures", "signatures", "symbols",
344
+ ];
345
+ const flattenSignatures = (value) => {
346
+ if (Array.isArray(value)) return value.flatMap(flattenSignatures);
347
+ if (value && typeof value === "object"
348
+ && !("name" in value || "symbol" in value || "parameterCount" in value
349
+ || "parameter_count" in value || "arity" in value)) {
350
+ return Object.entries(value).flatMap(([name, nested]) => {
351
+ if (Number.isInteger(nested)) return [{ name, parameterCount: nested }];
352
+ if (nested && typeof nested === "object" && !Array.isArray(nested)
353
+ && !("name" in nested || "symbol" in nested)) return [{ name, ...nested }];
354
+ return flattenSignatures(nested);
355
+ });
356
+ }
357
+ return [value];
358
+ };
359
+ const values = fieldValues(record, signatureFields).flatMap(flattenSignatures);
360
+ const functions = hasField(record, signatureFields)
361
+ ? []
362
+ : asArray(record?.functions)
363
+ .filter((item) => item && typeof item === "object" && item.public !== false)
364
+ .filter((item) => !hasField(record, [
365
+ "exportedSymbols", "exported_symbols", "exportedNames", "exported_names", "symbolNames", "symbol_names", "exports", "exported",
366
+ ]) || symbolNames(record).includes(item.name));
367
+ const entries = [...values, ...functions];
368
+ const result = [];
369
+ const seen = new Set();
370
+ for (const value of entries) {
371
+ const signature = typeof value === "string"
372
+ ? signatureFromString(value)
373
+ : value && typeof value === "object"
374
+ ? {
375
+ name: value.name ?? value.symbol,
376
+ parameterCount: value.parameterCount ?? value.parameter_count ?? value.arity
377
+ ?? (typeof value.parameters === "number" ? value.parameters : undefined)
378
+ ?? (typeof value.params === "number" ? value.params : undefined)
379
+ ?? (typeof value.args === "number" ? value.args : undefined)
380
+ ?? (Array.isArray(value.args) ? value.args.length : undefined)
381
+ ?? (Array.isArray(value.parameters) ? value.parameters.length : undefined)
382
+ ?? (Array.isArray(value.params) ? value.params.length : undefined),
383
+ }
384
+ : undefined;
385
+ if (!signature || typeof signature.name !== "string" || !signature.name.trim()) continue;
386
+ if (!Number.isInteger(signature.parameterCount) || signature.parameterCount < 0) continue;
387
+ const normalized = { name: signature.name.trim(), parameterCount: signature.parameterCount };
388
+ const key = `${normalized.name}/${normalized.parameterCount}`;
389
+ if (!seen.has(key)) {
390
+ seen.add(key);
391
+ result.push(normalized);
392
+ }
393
+ }
394
+ return result;
395
+ };
396
+ const dependencyValues = (record) => {
397
+ const dependencyFields = ["dependencies", "moduleDependencies", "module_dependencies"];
398
+ const explicit = hasField(record, dependencyFields);
399
+ const values = explicit ? fieldValues(record, dependencyFields) : fieldValues(record, ["imports"]);
400
+ return uniqueStrings(values).flatMap((value) => {
401
+ const normalized = value.replace(/^['\"]|['\"]$/g, "").replace(/^node:/, "");
402
+ if (!normalized) return [];
403
+ if (explicit) return [normalized];
404
+ // Older inventory records stored `from module import name` as module.name.
405
+ // Keep the full value and its Python-style root for compatibility.
406
+ const rootName = normalized.match(/^[A-Za-z_][A-Za-z0-9_]*/)?.[0];
407
+ return rootName && normalized.includes(".") ? [normalized, rootName] : [normalized];
408
+ });
409
+ };
410
+ const purposeText = (record) => uniqueStrings(fieldValues(record, ["purpose", "module_docstring", "header_comments"]))[0] ?? "";
411
+ const semanticFields = (record) => `${purposeText(record)} ${record?.filename ?? ""} ${symbolNames(record).join(" ")}`;
412
+ // Inventory facts retain only declared parameter counts, so compatibility is
413
+ // exact arity; unknown counts are deliberately not treated as compatible.
414
+ const compatibleArity = (left, right) => left.parameterCount === right.parameterCount;
415
+ const targetList = asArray(targetRecords).filter((record) => record && typeof record === "object");
416
+ const targetPaths = new Set(targetList.map((record) => record.path).filter(Boolean));
417
+
418
+ try {
419
+ const lines = readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean);
420
+ const records = [];
421
+ for (const line of lines) {
422
+ try { records.push(JSON.parse(line)); }
423
+ catch { return { status: "invalid", matches: [], distinctiveTerms: [], limitation: "inventory contains invalid JSON; prior-art result is incomplete." }; }
424
+ }
425
+ if (!records.length) return { status: "missing", matches: [], distinctiveTerms: [] };
426
+ const priorRecords = records.filter((record) => !targetPaths.has(record.path));
427
+
428
+ // Purpose terms are retained for diagnostics and ranking only. They never
429
+ // create a match without a code-structural signal.
430
+ const goalTokens = tokens(goal);
431
+ const documentFrequency = new Map(goalTokens.map((term) => [
432
+ term,
433
+ priorRecords.filter((record) => wordHits(semanticFields(record), term)).length,
434
+ ]));
435
+ const distinctiveTerms = goalTokens.filter((term) => {
436
+ const frequency = documentFrequency.get(term) ?? 0;
437
+ return frequency > 0 && frequency <= Math.max(1, Math.floor(records.length / 2));
438
+ });
439
+ const targetSymbolTerms = [...new Set(targetList.flatMap((record) => symbolNames(record).flatMap(identifierWords)))];
440
+ const symbolFrequency = new Map(targetSymbolTerms.map((term) => [
441
+ term,
442
+ priorRecords.filter((record) => symbolNames(record).some((name) => identifierWords(name).includes(term))).length,
443
+ ]));
444
+ const distinctiveSymbolTerms = targetSymbolTerms.filter((term) => {
445
+ const frequency = symbolFrequency.get(term) ?? 0;
446
+ return frequency > 0 && frequency <= Math.max(1, Math.floor(records.length / 2));
447
+ });
448
+
449
+ const matches = records.map((record, index) => {
450
+ if (targetPaths.has(record.path)) return undefined;
451
+ const recordSymbolTerms = new Set(symbolNames(record).flatMap(identifierWords));
452
+ const symbolMatches = distinctiveSymbolTerms.filter((term) => recordSymbolTerms.has(term));
453
+ const signatureMatches = [];
454
+ const priorSignatures = signatureEntries(record);
455
+ for (const target of targetList) {
456
+ for (const left of signatureEntries(target)) {
457
+ for (const right of priorSignatures) {
458
+ if (left.name === right.name && compatibleArity(left, right)) {
459
+ const value = { name: left.name, parameterCount: left.parameterCount };
460
+ if (!signatureMatches.some((item) => item.name === value.name && item.parameterCount === value.parameterCount)) {
461
+ signatureMatches.push(value);
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
467
+ const targetDependencies = new Set(targetList.flatMap(dependencyValues));
468
+ const dependencyMatches = [...new Set(dependencyValues(record).filter((dependency) => targetDependencies.has(dependency)))].sort();
469
+ const signals = [];
470
+ if (symbolMatches.length) signals.push("symbols");
471
+ if (signatureMatches.length) signals.push("signatures");
472
+ if (dependencyMatches.length) signals.push("dependencies");
473
+ if (!signals.length) return undefined;
474
+ const purposeMatches = distinctiveTerms.filter((term) => wordHits(purposeText(record), term));
475
+ return {
476
+ path: record.path,
477
+ name: record.name ?? record.filename ?? record.path,
478
+ matchedTerms: [...new Set([...symbolMatches, ...purposeMatches])],
479
+ structuralSignals: {
480
+ symbols: symbolMatches,
481
+ signatures: signatureMatches,
482
+ dependencies: dependencyMatches,
483
+ },
484
+ signals,
485
+ purposeTerms: purposeMatches,
486
+ signalCount: signals.length,
487
+ _index: index,
488
+ };
489
+ }).filter(Boolean)
490
+ .sort((left, right) => right.signalCount - left.signalCount
491
+ || right.purposeTerms.length - left.purposeTerms.length
492
+ || left._index - right._index)
493
+ .map(({ _index, ...match }) => match);
494
+ return { status: matches.length ? "observed" : "none", matches: matches.slice(0, 12), distinctiveTerms };
495
+ } catch (error) {
496
+ if (error?.code === "ENOENT") return { status: "missing", matches: [], distinctiveTerms: [], limitation: "codebase inventory is missing; no prior-art absence is inferred." };
497
+ return { status: "unavailable", matches: [], distinctiveTerms: [], limitation: "codebase inventory could not be read; no prior-art absence is inferred." };
498
+ }
499
+ }
500
+
501
+ function scopeAssessment(goal, files) {
502
+ const goalTerms = tokens(goal);
503
+ const possibleDriftPaths = files.filter((path) => {
504
+ const pathTerms = tokens(path.replaceAll("/", " "));
505
+ return !goalTerms.some((term) => pathTerms.includes(term));
506
+ });
507
+ const retainedWriteSet = files.filter((path) => !possibleDriftPaths.includes(path));
508
+ return {
509
+ status: possibleDriftPaths.length ? "review-required" : "aligned-signal",
510
+ goalTerms,
511
+ possibleDriftPaths,
512
+ retainedWriteSet,
513
+ excludedPaths: possibleDriftPaths,
514
+ smallestCoherentScope: retainedWriteSet,
515
+ rule: "Lexical signal only; scope fields are advisory and never block work or replace human scope judgment.",
516
+ };
517
+ }
518
+
519
+ const CREDIT_FIELDS = ["source", "version", "license", "accessed"];
520
+
521
+ function creditAssessment(credits) {
522
+ if (credits === undefined) return { incompleteCredit: false };
523
+ const values = Array.isArray(credits) ? credits : [];
524
+ return {
525
+ credits: values,
526
+ incompleteCredit: !Array.isArray(credits) || values.some((credit) =>
527
+ !credit || typeof credit !== "object" || CREDIT_FIELDS.some((field) =>
528
+ typeof credit[field] !== "string" || !credit[field].trim())),
529
+ };
530
+ }
531
+
532
+ function requirementCoverage(repository, requirements, tests, analyses) {
533
+ const searchable = (analysis) => `${analysis.path} ${JSON.stringify(analysis.codeFacts || {})} ${(analysis.callables || []).map((item) => item.name).join(" ")}`.toLowerCase();
534
+ const bindings = analyses.map((analysis) => ({
535
+ path: analysis.path,
536
+ acceptedRequirements: requirements.filter((requirement) => {
537
+ const terms = tokens(requirement);
538
+ const haystack = searchable(analysis);
539
+ return terms.length > 0 && terms.some((term) => haystack.includes(term));
540
+ }),
541
+ }));
542
+ const bound = new Set(bindings.flatMap((item) => item.acceptedRequirements));
543
+ const uncoveredTests = tests.filter((path) => {
544
+ try {
545
+ const absolute = resolve(repository, safeRelativePath(path));
546
+ const rel = relative(repository, absolute);
547
+ return !rel || rel.startsWith("..") || !statSync(absolute).isFile();
548
+ } catch { return true; }
549
+ });
550
+ return {
551
+ candidateBindings: bindings,
552
+ unboundCandidateFiles: bindings.filter((item) => item.acceptedRequirements.length === 0).map((item) => item.path),
553
+ unboundRequirements: requirements.filter((requirement) => !bound.has(requirement)),
554
+ uncoveredTests,
555
+ advisoryOnly: true,
556
+ };
557
+ }
558
+
559
+ function comparisonMetrics(result) {
560
+ const files = Array.isArray(result.files) ? result.files : [];
561
+ const candidateFiles = result.budget?.candidateFiles || [];
562
+ const callableNames = files.flatMap((file) => (file.callables || []).map((item) => item.name));
563
+ const frequencies = callableNames.reduce((counts, name) => counts.set(name, (counts.get(name) || 0) + 1), new Map());
564
+ return {
565
+ dependencies: [...new Set(files.flatMap((file) => file.codeFacts?.dependencies || []))].sort(),
566
+ moduleLevelMutableBindings: files.reduce((sum, file) => sum + (Number(file.moduleLevelMutableBindings) || 0), 0),
567
+ testFilesInWriteSet: candidateFiles.filter((path) => /(^|\/)(?:test[^/]*|tests?)(?:\/|[._-])/i.test(path)).sort(),
568
+ rollbackProxy: {
569
+ lines: files.reduce((sum, file) => sum + (Number(file.lines) || 0), 0),
570
+ files: candidateFiles.length,
571
+ },
572
+ duplicateFunctionNames: [...frequencies].filter(([, count]) => count > 1).map(([name]) => name).sort(),
573
+ };
574
+ }
575
+
576
+ export function assessScopeDrift(goal, allowedPaths = [], path) {
577
+ const value = safeRelativePath(path);
578
+ const allowed = Array.isArray(allowedPaths) ? allowedPaths.map(safeRelativePath) : [];
579
+ if (allowed.length && !allowed.some((prefix) => value === prefix || value.startsWith(`${prefix}/`))) {
580
+ return { status: "possible-drift", path: value, reason: "path is outside the reviewed candidate scope" };
581
+ }
582
+ const assessment = scopeAssessment(goal, [value]);
583
+ return assessment.possibleDriftPaths.length
584
+ ? { status: "possible-drift", path: value, reason: "path has no lexical goal-term match" }
585
+ : undefined;
586
+ }
587
+
588
+ export function compareCodePhagePlan(plan, review) {
589
+ if (!plan || plan.schema !== CODE_PHAGE_SCHEMA || plan.phase !== "plan") {
590
+ return { status: "no-plan", reason: "no prior code-phage plan is available for comparison" };
591
+ }
592
+ if (!review || review.schema !== CODE_PHAGE_SCHEMA || review.phase !== "review") {
593
+ return { status: "not-a-review", reason: "the current result is not a code-phage review" };
594
+ }
595
+ if (plan.goal !== review.goal) {
596
+ return { status: "no-plan", reason: "the stored plan has a different goal; comparison requires a plan from the same stated goal" };
597
+ }
598
+ const planned = new Set(plan.budget?.candidateFiles || []);
599
+ const actual = new Set(review.budget?.candidateFiles || []);
600
+ const addedPaths = [...actual].filter((path) => !planned.has(path)).sort();
601
+ const removedPaths = [...planned].filter((path) => !actual.has(path)).sort();
602
+ const delta = (field) => (review.budget?.[field] || 0) - (plan.budget?.[field] || 0);
603
+ const plannedMetrics = comparisonMetrics(plan);
604
+ const realizedMetrics = comparisonMetrics(review);
605
+ const listDelta = (field) => {
606
+ const before = plan.budget?.[field] || [];
607
+ const after = review.budget?.[field] || [];
608
+ return {
609
+ planned: before,
610
+ realized: after,
611
+ added: after.filter((item) => !before.includes(item)),
612
+ removed: before.filter((item) => !after.includes(item)),
613
+ };
614
+ };
615
+ return {
616
+ status: addedPaths.length ? "scope-expanded" : "within-planned-scope",
617
+ goalMatch: true,
618
+ plannedPaths: [...planned].sort(),
619
+ realizedPaths: [...actual].sort(),
620
+ addedPaths,
621
+ removedPaths,
622
+ acceptedRequirements: listDelta("acceptedRequirements"),
623
+ testPaths: listDelta("testPaths"),
624
+ plannedDependencies: plannedMetrics.dependencies,
625
+ realizedDependencies: realizedMetrics.dependencies,
626
+ addedDependencies: realizedMetrics.dependencies.filter((item) => !plannedMetrics.dependencies.includes(item)),
627
+ removedDependencies: plannedMetrics.dependencies.filter((item) => !realizedMetrics.dependencies.includes(item)),
628
+ moduleLevelMutableBindingCount: {
629
+ planned: plannedMetrics.moduleLevelMutableBindings,
630
+ realized: realizedMetrics.moduleLevelMutableBindings,
631
+ delta: realizedMetrics.moduleLevelMutableBindings - plannedMetrics.moduleLevelMutableBindings,
632
+ },
633
+ testFilesInWriteSet: {
634
+ planned: plannedMetrics.testFilesInWriteSet,
635
+ realized: realizedMetrics.testFilesInWriteSet,
636
+ },
637
+ rollbackProxy: {
638
+ planned: plannedMetrics.rollbackProxy,
639
+ realized: realizedMetrics.rollbackProxy,
640
+ delta: {
641
+ lines: realizedMetrics.rollbackProxy.lines - plannedMetrics.rollbackProxy.lines,
642
+ files: realizedMetrics.rollbackProxy.files - plannedMetrics.rollbackProxy.files,
643
+ },
644
+ },
645
+ duplicateFunctionNames: {
646
+ planned: plannedMetrics.duplicateFunctionNames,
647
+ realized: realizedMetrics.duplicateFunctionNames,
648
+ },
649
+ metricDelta: {
650
+ lines: delta("observedLines"),
651
+ codeLines: delta("observedCodeLines"),
652
+ cognitiveComplexity: delta("observedCognitiveComplexity"),
653
+ cyclomaticComplexity: delta("observedCyclomaticComplexity"),
654
+ duplicateLines: delta("observedDuplicateLines"),
655
+ },
656
+ reviewQuestion: addedPaths.length
657
+ ? "Which accepted requirement or failure-mode check justifies each added path?"
658
+ : "Which accepted requirement or failure-mode check would fail if any realized unit were removed?",
659
+ };
660
+ }
661
+
662
+ export function analyzeCodePhage({
663
+ root,
664
+ goal,
665
+ candidatePaths: requestedPaths,
666
+ phase = "plan",
667
+ credits,
668
+ acceptedRequirements,
669
+ testPaths,
670
+ }) {
671
+ const repository = repositoryRoot(root);
672
+ const purpose = text(goal, "goal");
673
+ const requirements = boundedTextArray(acceptedRequirements, "acceptedRequirements");
674
+ const tests = boundedTextArray(testPaths, "testPaths", true);
675
+ if (!["plan", "review"].includes(phase)) fail("phase must be plan or review");
676
+ const selected = candidatePaths(repository, requestedPaths);
677
+ const analyses = [];
678
+ const limitations = [...selected.limitations];
679
+ for (const path of selected.files) {
680
+ try { analyses.push(analyzeSource(readFileSync(resolve(repository, path), "utf8"), path)); }
681
+ catch { limitations.push(`source could not be read: ${path}`); }
682
+ }
683
+ const inventory = inventoryMatches(repository, purpose, analyses);
684
+ if (inventory.limitation) limitations.push(inventory.limitation);
685
+ const total = (field) => analyses.reduce((sum, item) => sum + (Number(item[field]) || 0), 0);
686
+ const max = (field) => analyses.reduce((value, item) => Math.max(value, Number(item[field]) || 0), 0);
687
+ const coverage = requirementCoverage(repository, requirements, tests, analyses);
688
+ return {
689
+ schema: CODE_PHAGE_SCHEMA,
690
+ status: "advisory-review",
691
+ phase,
692
+ advisoryOnly: true,
693
+ authorityCreated: false,
694
+ mutated: false,
695
+ repository,
696
+ goal: purpose,
697
+ ...creditAssessment(credits),
698
+ priorArt: {
699
+ status: inventory.status,
700
+ matches: inventory.matches,
701
+ distinctiveTerms: inventory.distinctiveTerms,
702
+ absenceClaimed: false,
703
+ creditRequired: inventory.matches.length > 0,
704
+ },
705
+ scope: scopeAssessment(purpose, selected.files),
706
+ budget: {
707
+ basis: "code-phage.v1",
708
+ candidateFiles: selected.files,
709
+ fileCount: selected.files.length,
710
+ observedLines: total("lines"),
711
+ observedCodeLines: total("codeLines"),
712
+ observedCognitiveComplexity: total("totalCognitiveComplexity"),
713
+ observedCyclomaticComplexity: total("totalCyclomaticComplexity"),
714
+ observedDuplicateLines: total("duplicateLineCount"),
715
+ suggestedWriteSet: selected.files,
716
+ acceptedRequirements: requirements,
717
+ testPaths: tests,
718
+ coverageCheck: coverage,
719
+ unboundRequirements: coverage.unboundRequirements,
720
+ uncoveredTests: coverage.uncoveredTests,
721
+ acceptanceChecks: [
722
+ "Every changed unit supports an accepted requirement or failure-mode check.",
723
+ "Prior art and existing abstractions were inspected before new code.",
724
+ "Complexity signals are diagnostic, not universal rejection thresholds.",
725
+ "Scope additions receive a possible-drift warning and human review.",
726
+ ],
727
+ deletionTest: "Which accepted semantic requirement or failure-mode check would fail if this unit were removed?",
728
+ thresholdsAreAdvisory: true,
729
+ },
730
+ files: analyses,
731
+ summary: {
732
+ filesAnalyzed: analyses.length,
733
+ maxCognitiveComplexity: max("cognitiveComplexity"),
734
+ maxCyclomaticComplexity: max("cyclomaticComplexity"),
735
+ duplicateLines: total("duplicateLineCount"),
736
+ statefulFiles: analyses.filter((item) => Object.values(item.stateSignals).some(Boolean)).map((item) => item.path),
737
+ },
738
+ limitations,
739
+ nextAction: "Review prior-art matches, possible scope drift, complexity signals, and the deletion test; no source mutation is performed.",
740
+ };
741
+ }
742
+
743
+ export function canonicalDigest(value) {
744
+ return createHash("sha256").update(JSON.stringify(value, Object.keys(value).sort())).digest("hex");
745
+ }
746
+
747
+ // Map changed line ranges onto AST callables. Concept provenance: pi-simplify
748
+ // changed-line scope, extended to AST callable spans with content-bound
749
+ // anchors (Review Craft style). Pure function; no Git or filesystem access.
750
+ export function mapChangedRangesToCallables(analysis, changedFile) {
751
+ if (!analysis || analysis.status !== "parsed" || !Array.isArray(analysis.callables)) {
752
+ return { status: analysis?.status === "parse-error" ? "parse-error" : "unavailable", touchedCallables: [] };
753
+ }
754
+ const added = Array.isArray(changedFile?.added) ? changedFile.added : [];
755
+ const removed = Array.isArray(changedFile?.removed) ? changedFile.removed : [];
756
+ const classify = (callable) => {
757
+ const start = Number(callable.line) || 0;
758
+ const end = Number(callable.endLine) || start;
759
+ const addedOverlap = added.some((range) => range.start <= end && range.end >= start);
760
+ const removedOverlap = removed.some((range) => range.start <= end && range.end >= start);
761
+ if (addedOverlap && removedOverlap) return "modified";
762
+ if (addedOverlap) return "added-lines";
763
+ if (removedOverlap) return "removed-lines";
764
+ return undefined;
765
+ };
766
+ const touchedCallables = analysis.callables
767
+ .map((callable) => ({
768
+ name: callable.name,
769
+ kind: callable.kind,
770
+ line: callable.line,
771
+ endLine: callable.endLine,
772
+ cyclomaticComplexity: callable.cyclomaticComplexity,
773
+ cognitiveComplexity: callable.cognitiveComplexity,
774
+ changeClassification: changedFile?.wholeCurrentFile ? "whole-file" : classify(callable),
775
+ }))
776
+ .filter((item) => item.changeClassification)
777
+ .sort((a, b) => a.line - b.line);
778
+ return { status: "mapped", touchedCallables };
779
+ }
780
+
781
+ // Analyze the current changed scope against a ref. Read-only; advisory only.
782
+ export function analyzeChangedCodePhage({ root, goal, ref = "HEAD", phase = "review" }) {
783
+ const repository = repositoryRoot(root);
784
+ const purpose = text(goal, "goal");
785
+ const scope = changedRanges(repository, ref);
786
+ const analyses = [];
787
+ const limitations = [...scope.limitations];
788
+ const touched = [];
789
+ const candidates = [];
790
+ const candidatePaths = scope.files
791
+ .filter((file) => file.status !== "D" && SOURCE_EXTENSIONS.has(extname(file.path).toLowerCase()))
792
+ .map((file) => file.path)
793
+ .slice(0, MAX_FILES);
794
+ for (const path of candidatePaths) {
795
+ try {
796
+ const source = readFileSync(resolve(repository, path), "utf8");
797
+ const analysis = analyzeSource(source, path);
798
+ analyses.push(analysis);
799
+ const changedFile = scope.files.find((file) => file.path === path);
800
+ const mapping = mapChangedRangesToCallables(analysis, changedFile);
801
+ const evidence = buildEvidence(source, analysis);
802
+ touched.push({ path, evidence, ...mapping });
803
+ candidates.push(...buildCandidates(path, { wholeCurrentFile: changedFile?.wholeCurrentFile, touched: mapping.touchedCallables }, evidence));
804
+ } catch {
805
+ limitations.push(`source could not be read: ${path}`);
806
+ }
807
+ }
808
+ const deletedPaths = scope.files.filter((file) => file.deleted).map((file) => file.path);
809
+ const nonSourcePaths = scope.files
810
+ .filter((file) => !SOURCE_EXTENSIONS.has(extname(file.path).toLowerCase()) && file.status !== "D")
811
+ .map((file) => file.path);
812
+ const inventory = inventoryMatches(repository, purpose, analyses);
813
+ if (inventory.limitation) limitations.push(inventory.limitation);
814
+ const totalTouched = touched.reduce((sum, item) => sum + item.touchedCallables.length, 0);
815
+ const reviewFeedback = buildReviewFeedback(candidates);
816
+ const result = {
817
+ schema: CODE_PHAGE_SCHEMA,
818
+ status: "advisory-review",
819
+ phase,
820
+ advisoryOnly: true,
821
+ authorityCreated: false,
822
+ mutated: false,
823
+ repository,
824
+ goal: purpose,
825
+ changedScope: {
826
+ ref,
827
+ status: scope.status,
828
+ changedFiles: scope.files.length,
829
+ analyzedFiles: analyses.length,
830
+ deletedPaths,
831
+ nonSourcePaths,
832
+ touchedCallables: totalTouched,
833
+ },
834
+ priorArt: {
835
+ status: inventory.status,
836
+ matches: inventory.matches,
837
+ distinctiveTerms: inventory.distinctiveTerms,
838
+ absenceClaimed: false,
839
+ creditRequired: inventory.matches.length > 0,
840
+ },
841
+ budget: {
842
+ basis: "code-phage.v1",
843
+ candidateFiles: candidatePaths,
844
+ fileCount: analyses.length,
845
+ observedCognitiveComplexity: analyses.reduce((sum, item) => sum + (Number(item.totalCognitiveComplexity) || 0), 0),
846
+ observedCyclomaticComplexity: analyses.reduce((sum, item) => sum + (Number(item.totalCyclomaticComplexity) || 0), 0),
847
+ suggestedWriteSet: candidatePaths,
848
+ deletionTest: "Which accepted semantic requirement or failure-mode check would fail if this unit were removed?",
849
+ thresholdsAreAdvisory: true,
850
+ },
851
+ files: analyses,
852
+ changedCallables: touched,
853
+ candidates,
854
+ candidateOutcomeVocabulary: ["RETAIN", "TIDY", "HOLD", "PROFILE", "DESCRIBE"],
855
+ reviewFeedback,
856
+ reviewIntentVocabulary: REVIEW_INTENT_VOCABULARY,
857
+ summary: {
858
+ filesAnalyzed: analyses.length,
859
+ touchedCallables: totalTouched,
860
+ deletedFiles: deletedPaths.length,
861
+ maxCognitiveComplexity: analyses.reduce((value, item) => Math.max(value, Number(item.cognitiveComplexity) || 0), 0),
862
+ candidateCount: candidates.length,
863
+ candidateOutcomes: candidates.reduce((counts, item) => {
864
+ counts[item.suggestedOutcome] = (counts[item.suggestedOutcome] || 0) + 1;
865
+ return counts;
866
+ }, {}),
867
+ reviewFeedback: reviewFeedback.reduce(
868
+ (counts, item) => {
869
+ counts[item.intent] = (counts[item.intent] || 0) + 1;
870
+ return counts;
871
+ },
872
+ { AMEND: 0, CONSULT: 0 },
873
+ ),
874
+ },
875
+ limitations,
876
+ nextAction: "Review touched callables within changed ranges only; no source mutation is performed.",
877
+ };
878
+ // Narrative is derived from the near-final result so every reference
879
+ // resolves against the completed review-feedback and candidate sets.
880
+ result.narrative = buildNarrative(result, "walkthrough");
881
+ return result;
882
+ }