@azure-id/orc 1.7.1 → 1.8.2

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 (63) hide show
  1. package/CHANGELOG.md +3869 -3381
  2. package/README-id.md +942 -844
  3. package/README.md +882 -788
  4. package/bin/build-agents.js +43 -27
  5. package/bin/cli.js +45460 -44169
  6. package/bin/graph-extract.js +3216 -0
  7. package/bin/graph-gain.js +404 -0
  8. package/bin/graph-map.js +232 -0
  9. package/bin/graph-notes.js +229 -0
  10. package/bin/graph-query.js +1770 -0
  11. package/bin/graph-resolve.js +255 -0
  12. package/bin/graph-shard.js +325 -0
  13. package/bin/graph-signals.js +277 -0
  14. package/bin/graph.js +658 -0
  15. package/bin/verify-contracts.js +4809 -4553
  16. package/bin/verify-package.js +640 -616
  17. package/bin/webui/api.js +1425 -1414
  18. package/bin/webui/fixtures/index.js +584 -576
  19. package/bin/webui/fixtures/knowledge.js +356 -291
  20. package/bin/webui/fixtures/stats.js +107 -104
  21. package/bin/webui/i18n/en/knowledge.json +182 -151
  22. package/bin/webui/i18n/en/overview.json +101 -100
  23. package/bin/webui/i18n/id/knowledge.json +182 -151
  24. package/bin/webui/i18n/id/overview.json +101 -100
  25. package/bin/webui/js/panels/knowledge.js +1130 -1006
  26. package/bin/webui/js/panels/overview.js +492 -488
  27. package/package.json +39 -39
  28. package/templates/agents/MODEL-MAPPING.md +163 -158
  29. package/templates/agents/orc-executor-haiku-4-5.md +128 -121
  30. package/templates/agents/orc-executor-opus-4-7-high.md +129 -122
  31. package/templates/agents/orc-executor-opus-4-7-med.md +129 -122
  32. package/templates/agents/orc-executor-opus-4-8-high.md +129 -122
  33. package/templates/agents/orc-executor-opus-5-high.md +129 -122
  34. package/templates/agents/orc-executor-opus-5-low.md +129 -122
  35. package/templates/agents/orc-executor-opus-5-med.md +129 -122
  36. package/templates/agents/orc-executor-sonnet-4-6-high.md +129 -122
  37. package/templates/agents/orc-executor-sonnet-4-6-med.md +129 -122
  38. package/templates/agents/orc-executor-sonnet-5-high.md +129 -122
  39. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +89 -0
  40. package/templates/hooks/README.md +454 -396
  41. package/templates/hooks/orc-graph-hook.js +471 -0
  42. package/templates/hooks/orc-statusline-render.js +922 -921
  43. package/templates/hooks/orc-statusline.js +1596 -1545
  44. package/templates/skills/_shared/README.md +4 -0
  45. package/templates/skills/_shared/code-graph.md +348 -0
  46. package/templates/skills/_shared/opus5-only.md +4 -0
  47. package/templates/skills/_shared/phases/execution.md +168 -147
  48. package/templates/skills/_shared/phases/planning.md +149 -135
  49. package/templates/skills/_shared/phases/preflight.md +132 -118
  50. package/templates/skills/_shared/phases/review.md +63 -53
  51. package/templates/skills/_shared/phases/ship.md +100 -88
  52. package/templates/skills/_shared/phases/trace.md +8 -0
  53. package/templates/skills/_shared/phases/wiki-consult.md +194 -189
  54. package/templates/skills/_shared/read-ladder.md +132 -102
  55. package/templates/skills/_shared/return-validation.md +259 -250
  56. package/templates/skills/orc/SKILL.md +255 -254
  57. package/templates/skills/orc-analyze/SKILL.md +13 -13
  58. package/templates/skills/orc-diy/references/flow-schema.md +101 -100
  59. package/templates/skills/orc-fast/SKILL.md +236 -229
  60. package/templates/skills/orc-mini/SKILL.md +267 -259
  61. package/templates/skills/orc-quick/SKILL.md +378 -361
  62. package/templates/skills/orc-quick/references/dispatch-gate.md +6 -0
  63. package/templates/skills/orc-wiki/references/staleness.md +294 -288
@@ -0,0 +1,1770 @@
1
+ "use strict";
2
+ // ── orc graph — RESOLUTION and the READ commands (v1.8.0 W3) ────────────────
3
+ //
4
+ // `ctx`, `impact` and `path` answer from the stored records. Resolution is
5
+ // COMPUTED ON READ, never stored — a deliberate change from plan §3 step 5.
6
+ // W0 measured the whole resolve pass at 175 ms on Django (7K files); storing
7
+ // edges would add ~12 MB to every parse AND an invalidation path that can go
8
+ // stale in silence. The standing rule wins: a state is computed, not kept.
9
+ //
10
+ // THE STATES — never a fuzzy guess
11
+ // LOCAL defined in the same file (a `this.`/`self.` call prefers the caller's class)
12
+ // IMPORT the name, or its receiver, is bound by an import whose path resolves here
13
+ // UNIQUE exactly one symbol in the repo has that name (and the receiver agrees)
14
+ // AMBIGUOUS two or more candidates, OR a receiver that no candidate confirms — all listed
15
+ // UNRESOLVED nothing in this repo (a builtin, an external package, dynamic dispatch)
16
+ // ROUTE (v1.8.2 W1) a URL literal reaches exactly one route symbol — the
17
+ // mount chain is known and the full path matches, or the path's tail
18
+ // matches one route and no other
19
+ // (EXACT is reserved for a type-checker-backed resolution; nothing emits it yet.)
20
+ //
21
+ // Every read is BUDGETED. A card never exceeds its budget and always says what
22
+ // it hid — a graph tool that returned 33K tokens is the failure this prevents.
23
+ //
24
+ // v1.8.2 W1 — URLS ARE EDGES. R3-C measured the defect: a card listed every
25
+ // caller that NAMED a symbol and declared eleven route-level tests absent. They
26
+ // reached it through `request(app).get("/orders/search")`. The extractor now
27
+ // records every URL literal a symbol sends (`urls[]`) and every mount
28
+ // (`mounts[]`); this file resolves a URL to a route symbol on read:
29
+ // full path = the mount chain's prefix (followed across files, depth ≤ 4,
30
+ // exactly one chain) + the route's own path, with `:id` `{id}` `<id>` `[id]`
31
+ // as one segment and `*` as anything; trailing slash optional; query stripped.
32
+ // A URL whose prefix is unknown (`BASE + "/p"`) matches by its tail.
33
+
34
+ const fs = require("fs");
35
+ const path = require("path");
36
+ const posix = path.posix;
37
+ const { spawnSync } = require("child_process");
38
+ const G = require("./graph.js");
39
+ const N = require("./graph-notes.js");
40
+
41
+ const CONFIDENT = new Set(["LOCAL", "IMPORT", "UNIQUE", "ROUTE"]);
42
+ const MOUNT_DEPTH = 4;
43
+ // W5 (G6) adds the five new languages' own conventions: RSpec and minitest
44
+ // (`_spec.rb`, `_test.rb`), Rust (`*_test.rs`, and `mod tests` lives inside
45
+ // the file it tests, which no path pattern can see), Kotlin (`*Test.kt`,
46
+ // `*Spec.kt`) and a `*_test.cpp` beside its unit.
47
+ const TEST_FILE = /(^|\/)(tests?|__tests__|specs?)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py|rs|c|cc|cpp)$|(^|\/)test_[^/]*\.py$|Tests?\.(java|cs)$|Test\.php$|_(spec|test)\.rb$|(Test|Spec)\.kts?$/;
48
+ const JS_EXT = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte"];
49
+ const tok = (s) => Math.ceil(String(s).length / 4);
50
+ const norm = (p) => String(p).replace(/\\/g, "/").replace(/^\.\//, "");
51
+
52
+ function readJson(file) {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(file, "utf8"));
55
+ } catch (_) {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // EW2: `opts.noCache` is for the WRITER — `graph-resolve.build` must compute,
61
+ // never read back the cache it is about to replace.
62
+ function loadModel(claudeDir, root, opts) {
63
+ const p = G.graphPaths(claudeDir);
64
+ const meta = readJson(p.meta);
65
+ // S2: the writer hands over the index it holds; a reader parses the file.
66
+ const index = (opts && opts.index) || readJson(p.index);
67
+ if (!meta || !index || !index.by_file) return null;
68
+ // The stored resolution, and ONLY when it names this exact generation.
69
+ // Anything else — a crash between two writes, an older engine, a hand
70
+ // deletion — leaves `cached` null and every answer is computed as before.
71
+ const cached = opts && opts.noCache ? null : require("./graph-resolve.js").load(claudeDir, meta);
72
+ const byFile = index.by_file;
73
+ const symbols = new Map();
74
+ const byName = new Map();
75
+ for (const [rel, f] of Object.entries(byFile)) {
76
+ for (const s of f.symbols || []) {
77
+ s.file = rel;
78
+ symbols.set(s.id, s);
79
+ if (s.kind === "module") continue;
80
+ const a = byName.get(s.name);
81
+ if (a) a.push(s);
82
+ else byName.set(s.name, [s]);
83
+ }
84
+ }
85
+ return {
86
+ root,
87
+ meta,
88
+ notes: N.noteIndex(claudeDir),
89
+ byFile,
90
+ fileSet: new Set(Object.keys(byFile)),
91
+ symbols,
92
+ byName,
93
+ bindings: new Map(),
94
+ resolved: new Map(),
95
+ // The REVERSE direction only. EW2 measured storing the whole memo as a net
96
+ // loss (21.7 MB to parse, to save less than that), while this half is the
97
+ // one the 437 ms walk was computing.
98
+ callersCache: cached ? cached.callers || {} : null,
99
+ maybeCache: cached ? cached.maybe || {} : null,
100
+ generation: meta.generation || 0,
101
+ suffix: new Map(),
102
+ dirs: null,
103
+ goModule: undefined,
104
+ callIndex: null,
105
+ // W1: the route table and the URL memo, both built on first use.
106
+ routes: null,
107
+ urlIndex: null,
108
+ urlMemo: new Map(),
109
+ // S3: blob freshness, batched by `warmBlobs` (one git call per read).
110
+ blobStates: new Map(),
111
+ };
112
+ }
113
+
114
+ // ── import targets ──────────────────────────────────────────────────────────
115
+ function bySuffix(model, suffix) {
116
+ if (model.suffix.has(suffix)) return model.suffix.get(suffix);
117
+ const out = [];
118
+ for (const f of model.fileSet) if (f === suffix || f.endsWith("/" + suffix)) out.push(f);
119
+ model.suffix.set(suffix, out);
120
+ return out;
121
+ }
122
+
123
+ function filesInDir(model, dir, ext) {
124
+ if (!model.dirs) {
125
+ model.dirs = new Map();
126
+ for (const f of model.fileSet) {
127
+ const d = posix.dirname(f);
128
+ const a = model.dirs.get(d);
129
+ if (a) a.push(f);
130
+ else model.dirs.set(d, [f]);
131
+ }
132
+ }
133
+ return (model.dirs.get(dir) || []).filter((f) => f.endsWith(ext));
134
+ }
135
+
136
+ function resolveJs(model, from, spec) {
137
+ let base;
138
+ if (spec.startsWith(".")) base = posix.normalize(posix.join(posix.dirname(from), spec));
139
+ else if (/^[@~]\//.test(spec)) base = "src/" + spec.slice(2);
140
+ else if (spec.startsWith("/")) base = spec.slice(1);
141
+ else return [];
142
+ const cands = [base, ...JS_EXT.map((e) => base + e), ...JS_EXT.map((e) => `${base}/index${e}`)];
143
+ // TS ESM writes `./x.js` for a file that is `x.ts` on disk.
144
+ if (/\.(js|jsx|mjs|cjs)$/.test(base)) {
145
+ const stem = base.replace(/\.(js|jsx|mjs|cjs)$/, "");
146
+ cands.push(...JS_EXT.map((e) => stem + e));
147
+ }
148
+ for (const c of cands) if (model.fileSet.has(c)) return [c];
149
+ return [];
150
+ }
151
+
152
+ function resolvePy(model, from, spec) {
153
+ const lvl = /^\.*/.exec(spec)[0].length;
154
+ const parts = spec.slice(lvl).split(".").filter(Boolean);
155
+ let dir = "";
156
+ if (lvl) {
157
+ dir = posix.dirname(from);
158
+ for (let i = 1; i < lvl; i++) dir = posix.dirname(dir);
159
+ if (dir === ".") dir = "";
160
+ }
161
+ const p = [dir, ...parts].filter(Boolean).join("/");
162
+ for (const c of p ? [`${p}.py`, `${p}/__init__.py`] : [`${dir ? dir + "/" : ""}__init__.py`]) if (model.fileSet.has(c)) return [c];
163
+ if (!lvl && parts.length) {
164
+ const a = bySuffix(model, `${parts.join("/")}.py`);
165
+ if (a.length) return a;
166
+ return bySuffix(model, `${parts.join("/")}/__init__.py`);
167
+ }
168
+ return [];
169
+ }
170
+
171
+ function resolveGo(model, spec) {
172
+ if (model.goModule === undefined) {
173
+ model.goModule = null;
174
+ try {
175
+ const mm = /^module\s+(\S+)/m.exec(fs.readFileSync(path.join(model.root, "go.mod"), "utf8"));
176
+ if (mm) model.goModule = mm[1];
177
+ } catch (_) {}
178
+ }
179
+ if (!model.goModule || !spec.startsWith(model.goModule + "/")) return [];
180
+ return filesInDir(model, spec.slice(model.goModule.length + 1), ".go").filter((f) => !f.endsWith("_test.go"));
181
+ }
182
+
183
+ // W5 (G6). Every file whose DIRECTORY ends with this package path — Kotlin
184
+ // names a package, not a file, and a Kotlin file name need not match its class.
185
+ function filesInPackage(model, pkg, ext) {
186
+ if (!pkg) return [];
187
+ const key = `pkg\0${pkg}\0${ext}`;
188
+ if (model.suffix.has(key)) return model.suffix.get(key);
189
+ const out = [];
190
+ for (const f of model.fileSet) {
191
+ if (!f.endsWith(ext)) continue;
192
+ const d = posix.dirname(f);
193
+ if (d === pkg || d.endsWith("/" + pkg)) out.push(f);
194
+ }
195
+ model.suffix.set(key, out);
196
+ return out;
197
+ }
198
+
199
+ // W5 (G6). `mod store;` is a sibling file or a folder with `mod.rs`;
200
+ // `use crate::store::OrderStore` names a path from the crate root.
201
+ function resolveRs(model, from, imp) {
202
+ const dir = posix.dirname(from);
203
+ const spec = imp.from || "";
204
+ if (imp.mod) {
205
+ for (const c of [`${dir}/${spec}.rs`, `${dir}/${spec}/mod.rs`]) if (model.fileSet.has(c)) return [c];
206
+ return [];
207
+ }
208
+ const parts = spec.split("::").filter(Boolean);
209
+ if (!parts.length) return [];
210
+ // An integration test under `tests/` names the crate by its PACKAGE name,
211
+ // not by `crate` — the same fact `go.mod` carries for Go.
212
+ if (model.rustCrate === undefined) {
213
+ model.rustCrate = null;
214
+ try {
215
+ const mm = /^\s*name\s*=\s*"([^"]+)"/m.exec(fs.readFileSync(path.join(model.root, "Cargo.toml"), "utf8"));
216
+ if (mm) model.rustCrate = mm[1].replace(/-/g, "_");
217
+ } catch (_) {}
218
+ }
219
+ let base = parts;
220
+ if (parts[0] === "crate" || parts[0] === "self" || (model.rustCrate && parts[0] === model.rustCrate)) base = parts.slice(1);
221
+ else if (parts[0] === "super") {
222
+ const up = posix.dirname(dir);
223
+ base = parts.slice(1);
224
+ const p = [up === "." ? "" : up, ...base].filter(Boolean).join("/");
225
+ for (const c of [`${p}.rs`, `${p}/mod.rs`]) if (model.fileSet.has(c)) return [c];
226
+ return [];
227
+ } else return []; // an external crate
228
+ const p = base.join("/");
229
+ for (const c of [`${p}.rs`, `${p}/mod.rs`, `src/${p}.rs`, `src/${p}/mod.rs`]) if (model.fileSet.has(c)) return [c];
230
+ const a = bySuffix(model, `${p}.rs`);
231
+ return a.length ? a : bySuffix(model, `${p}/mod.rs`);
232
+ }
233
+
234
+ function importTargets(model, from, lang, imp) {
235
+ const spec = imp.from || "";
236
+ if (lang === "js" || lang === "ts" || lang === "vue" || lang === "svelte") return resolveJs(model, from, spec);
237
+ if (lang === "rb") {
238
+ if (imp.include) {
239
+ const rel = posix.normalize(posix.join(posix.dirname(from), spec));
240
+ const hit = [rel, `${rel}.rb`].find((c) => model.fileSet.has(c));
241
+ if (hit) return [hit];
242
+ }
243
+ return bySuffix(model, /\.rb$/.test(spec) ? spec : `${spec}.rb`);
244
+ }
245
+ if (lang === "rs") return resolveRs(model, from, imp);
246
+ if (lang === "kt") {
247
+ const parts = spec.split(".");
248
+ const last = parts[parts.length - 1];
249
+ const pkg = parts.slice(0, -1).join("/");
250
+ // `import a.b.OrderStore` — the file is usually `OrderStore.kt`, but a
251
+ // Kotlin file may hold several classes, so the package folder is the
252
+ // fallback, never a guess at a same-named file anywhere in the tree.
253
+ if (imp.star) return filesInPackage(model, spec.replace(/\./g, "/"), ".kt");
254
+ const byFile = bySuffix(model, `${pkg ? pkg + "/" : ""}${last}.kt`);
255
+ return byFile.length ? byFile : filesInPackage(model, pkg, ".kt");
256
+ }
257
+ if (lang === "c") {
258
+ const rel = posix.normalize(posix.join(posix.dirname(from), spec));
259
+ return model.fileSet.has(rel) ? [rel] : bySuffix(model, spec.replace(/^(\.\.?\/)+/, ""));
260
+ }
261
+ if (lang === "py") return resolvePy(model, from, spec);
262
+ if (lang === "go") return resolveGo(model, spec);
263
+ if (lang === "java") {
264
+ // A wildcard import names a package, not a file; the UNIQUE rung covers it.
265
+ if (imp.star) return [];
266
+ return bySuffix(model, `${spec.replace(/\./g, "/")}.java`);
267
+ }
268
+ if (lang === "php") {
269
+ if (imp.include) {
270
+ const rel = posix.normalize(posix.join(posix.dirname(from), spec));
271
+ return model.fileSet.has(rel) ? [rel] : bySuffix(model, spec.replace(/^(\.\.?\/)+/, ""));
272
+ }
273
+ return bySuffix(model, `${spec.replace(/\\/g, "/")}.php`);
274
+ }
275
+ return [];
276
+ }
277
+
278
+ function fileBindings(model, rel) {
279
+ if (model.bindings.has(rel)) return model.bindings.get(rel);
280
+ const b = new Map();
281
+ const includes = [];
282
+ const f = model.byFile[rel] || {};
283
+ for (const imp of f.imports || []) {
284
+ const files = importTargets(model, rel, f.lang, imp);
285
+ if (imp.include) includes.push(...files);
286
+ for (const bd of imp.bindings || []) b.set(bd.local, { ...bd, from: imp.from, files });
287
+ }
288
+ const out = { map: b, includes };
289
+ model.bindings.set(rel, out);
290
+ return out;
291
+ }
292
+
293
+ // ── resolution ──────────────────────────────────────────────────────────────
294
+ function classOf(sym) {
295
+ const q = sym.qname.split(".");
296
+ return q.length > 1 ? q.slice(0, -1).join(".") : null;
297
+ }
298
+
299
+ // ── W2 (G4): barrels ────────────────────────────────────────────────────────
300
+ // `import { OrderService } from "./orders"` reaches `orders/index.ts`, which
301
+ // defines nothing and re-exports. Follow its re-exports (depth ≤ 3, cycle-safe)
302
+ // to every file that may define `name`, remembering the name it is defined
303
+ // under there (`export { a as b }` renames). The head of the list is always
304
+ // the file the import named, so a barrel that DOES define the name still wins.
305
+ const BARREL_DEPTH = 3;
306
+ function barrelTargets(model, files, name, depth, seen) {
307
+ const out = [];
308
+ for (const file of files) {
309
+ const k = `${file}\0${name}`;
310
+ if (seen.has(k)) continue;
311
+ seen.add(k);
312
+ out.push({ file, name });
313
+ if (depth >= BARREL_DEPTH) continue;
314
+ const f = model.byFile[file] || {};
315
+ for (const rx of f.reexports || []) {
316
+ let orig = null;
317
+ if (rx.names === "*") orig = name;
318
+ else {
319
+ const hitName = (rx.names || []).find((n) => n.local === name);
320
+ if (hitName) orig = hitName.orig;
321
+ }
322
+ if (orig == null) continue;
323
+ const targets = importTargets(model, file, f.lang, { from: rx.from });
324
+ out.push(...barrelTargets(model, targets, orig, depth + 1, seen));
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+
330
+ // ── W2 (G3): inherited members ──────────────────────────────────────────────
331
+ // The class named `name` as seen from `rel`: in this file (LOCAL), through an
332
+ // import (IMPORT, barrels followed), or the only class of that name (UNIQUE).
333
+ function classSymbol(model, rel, name) {
334
+ const f = model.byFile[rel] || {};
335
+ const local = (f.symbols || []).find((s) => s.kind === "class" && s.name === name);
336
+ if (local) return { sym: local, state: "LOCAL" };
337
+ const bd = fileBindings(model, rel).map.get(name);
338
+ if (bd && bd.files.length) {
339
+ for (const t of barrelTargets(model, bd.files, bd.orig === "default" || bd.orig === "*" ? name : bd.orig, 0, new Set())) {
340
+ const s = ((model.byFile[t.file] || {}).symbols || []).find((x) => x.kind === "class" && x.name === t.name);
341
+ if (s) return { sym: s, state: "IMPORT" };
342
+ }
343
+ }
344
+ if (f.lang === "go") {
345
+ const s = filesInDir(model, posix.dirname(rel), ".go").flatMap((x) => ((model.byFile[x] || {}).symbols || []).filter((y) => y.kind === "class" && y.name === name));
346
+ if (s.length === 1) return { sym: s[0], state: "LOCAL" };
347
+ }
348
+ const all = (model.byName.get(name) || []).filter((s) => s.kind === "class");
349
+ if (all.length === 1) return { sym: all[0], state: "UNIQUE" };
350
+ return null;
351
+ }
352
+
353
+ // `this.m()` in a class with no `m` of its own: walk the base chain (depth ≤
354
+ // 4, ≤ 8 bases per class, cycle-safe) and return the base member with the
355
+ // state the BASE CLASS resolved with from the subclass's file.
356
+ const INHERIT_DEPTH = 4;
357
+ function inheritedMember(model, rel, className, member, depth, seen) {
358
+ if (depth > INHERIT_DEPTH) return null;
359
+ const own = classSymbol(model, rel, className);
360
+ if (!own) return null;
361
+ for (const base of (own.sym.bases || []).slice(0, 8)) {
362
+ if (seen.has(base)) continue;
363
+ seen.add(base);
364
+ const b = classSymbol(model, own.sym.file, base);
365
+ if (!b) continue;
366
+ const mem = ((model.byFile[b.sym.file] || {}).symbols || []).find((s) => s.kind !== "module" && s.name === member && classOf(s) && classOf(s).split(".").pop() === b.sym.name);
367
+ if (mem) return { sym: mem, state: b.state };
368
+ const deeper = inheritedMember(model, b.sym.file, b.sym.name, member, depth + 1, seen);
369
+ if (deeper) return deeper;
370
+ }
371
+ return null;
372
+ }
373
+
374
+ function resolveCall(model, rel, caller, call) {
375
+ const cls = caller ? classOf(caller) : null;
376
+ const key = `${rel}\0${cls || ""}\0${call.name}\0${call.self ? 1 : 0}\0${call.ref ? 1 : 0}\0${call.sup ? 1 : 0}\0${call.alias ? 1 : 0}`;
377
+ if (model.resolved.has(key)) return model.resolved.get(key);
378
+ const parts = call.name.split(".");
379
+ const last = parts[parts.length - 1];
380
+ const head = parts.length > 1 ? parts[0] : null;
381
+ const f = model.byFile[rel] || {};
382
+ const locals = (f.symbols || []).filter((s) => s.kind !== "module" && s.name === last);
383
+ const { map: binds, includes } = fileBindings(model, rel);
384
+ const lang = f.lang;
385
+ let r = null;
386
+ const hit = (state, s) => ({ state, target: s.id });
387
+
388
+ // `super.m()` never resolves to the class's own member.
389
+ if (!call.sup && !head && locals.length) {
390
+ const same = call.self && cls ? locals.find((s) => classOf(s) === cls) : null;
391
+ // W2: a self call on a class that has no such member is not a local
392
+ // function of the same name — it is inherited, or nothing.
393
+ const pick = same || (call.self && cls ? null : locals.find((s) => !classOf(s) || !call.self) || locals[0]);
394
+ if (pick) r = hit("LOCAL", pick);
395
+ }
396
+ if (!r && !call.sup && head) {
397
+ const m2 = locals.filter((s) => s.qname === `${head}.${last}` || s.qname.endsWith(`.${head}.${last}`));
398
+ if (m2.length) r = hit("LOCAL", m2[0]);
399
+ }
400
+ // W2 (G3): a self call with no own member walks the base chain.
401
+ if (!r && call.self && !head && cls) {
402
+ const inh = inheritedMember(model, rel, cls.split(".").pop(), last, 0, new Set());
403
+ if (inh) r = { state: inh.state, target: inh.sym.id, inherited: true };
404
+ }
405
+ if (!r) {
406
+ const bd = binds.get(head || last);
407
+ if (bd && !bd.files.length && lang !== "cs") {
408
+ // Bound by an import that does not resolve to a file here — an external
409
+ // package. Matching it against a same-named repo function would invent
410
+ // an edge (axios.get → some repo `get`).
411
+ if (!(lang === "py" && bd.kind === "named")) r = { state: "UNRESOLVED", external: bd.from };
412
+ }
413
+ if (!r && bd && bd.files.length) {
414
+ // W2 (G4): the files the binding names, and every file a barrel among
415
+ // them re-exports the name from — with the name it carries there.
416
+ const wanted = bd.kind === "named" ? bd.orig : bd.local;
417
+ const pairs = barrelTargets(model, bd.files, wanted, 0, new Set());
418
+ let s = null;
419
+ for (const pair of pairs) {
420
+ const tsyms = ((model.byFile[pair.file] || {}).symbols || []).filter((x) => x.kind !== "module");
421
+ const orig = bd.kind === "named" ? pair.name : bd.orig;
422
+ const localName = bd.kind === "named" ? pair.name : pair.name;
423
+ if (head) {
424
+ s =
425
+ tsyms.find((x) => x.qname === `${orig}.${last}` || x.qname === `${localName}.${last}`) ||
426
+ (bd.kind === "namespace" || bd.kind === "default" ? tsyms.find((x) => x.name === last && !classOf(x)) : null) ||
427
+ tsyms.find((x) => x.name === last && x.qname.startsWith(orig + "."));
428
+ } else if (bd.kind === "named") {
429
+ s = tsyms.find((x) => x.qname === orig) || tsyms.find((x) => x.name === orig && !classOf(x));
430
+ } else {
431
+ const exported = tsyms.filter((x) => x.exported && !classOf(x));
432
+ s = tsyms.find((x) => x.qname === localName) || (exported.length === 1 ? exported[0] : null);
433
+ }
434
+ if (s) break;
435
+ }
436
+ // `from pkg import module` — the name is a module, not a symbol in pkg.
437
+ if (!s && lang === "py" && bd.kind === "named") {
438
+ const sub = resolvePy(model, rel, `${bd.from}.${bd.orig}`);
439
+ const ss = sub.flatMap((t) => ((model.byFile[t] || {}).symbols || []).filter((x) => x.kind !== "module"));
440
+ s = head ? ss.find((x) => x.name === last && !classOf(x)) : null;
441
+ }
442
+ if (s) r = hit("IMPORT", s);
443
+ }
444
+ }
445
+ // A file this one INCLUDES by path names its symbols directly — a bare name
446
+ // (PHP, C) and, since W5, a member of a class declared in that header.
447
+ if (!r && includes.length) {
448
+ const s = includes.flatMap((t) => ((model.byFile[t] || {}).symbols || []).filter((x) => x.kind !== "module" && (head ? x.qname === `${head}.${last}` : x.name === last && !classOf(x))))[0];
449
+ if (s) r = hit("IMPORT", s);
450
+ }
451
+ // W1: Go's package scope is the DIRECTORY. A bare name defined in a sibling
452
+ // `.go` file is as local as one in this file — no import names it, and the
453
+ // UNIQUE rung would call a second `New` elsewhere in the module ambiguous.
454
+ // W5 (G6) gives Rust, Kotlin and C/C++ the same rung, for the same reason: a
455
+ // `mod` sibling, a class in the same Kotlin package, and a translation unit
456
+ // beside this one are all named without an import.
457
+ const DIR_SCOPE = { go: ".go", rs: ".rs", kt: ".kt", c: ".cpp" };
458
+ if (!r && DIR_SCOPE[lang] && (!head || call.alias)) {
459
+ const isTest = /_test\.go$/.test(rel);
460
+ const exts = lang === "c" ? [".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh"] : [DIR_SCOPE[lang]];
461
+ const s = exts
462
+ .flatMap((e) => filesInDir(model, posix.dirname(rel), e))
463
+ .filter((f) => f !== rel && (isTest || !/_test\.go$/.test(f)))
464
+ .flatMap((f) => ((model.byFile[f] || {}).symbols || []).filter((x) => x.kind !== "module" && (head ? x.qname === `${head}.${last}` : x.name === last && !classOf(x))));
465
+ if (s.length === 1) r = hit("LOCAL", s[0]);
466
+ }
467
+ // W5 (G6). C++ reaches a member — its own, or one it inherits — with no
468
+ // `this->`. Every rung above has already run, so a free function of that
469
+ // name still wins; this is the last step before the repository-wide guess.
470
+ if (!r && !head && !call.self && lang === "c" && cls) {
471
+ const own = (f.symbols || []).find((s) => s.name === last && classOf(s) === cls);
472
+ if (own) r = hit("LOCAL", own);
473
+ else {
474
+ const inh = inheritedMember(model, rel, cls.split(".").pop(), last, 0, new Set());
475
+ if (inh) r = { state: inh.state, target: inh.sym.id, inherited: true };
476
+ }
477
+ }
478
+ if (!r) {
479
+ // W1: a bare `get(…)` is never a class METHOD — a method is reached through
480
+ // a receiver, `this`, or inheritance. Before this, `request(app).get("/p")`
481
+ // resolved UNIQUE to the only method named `get` in the repository.
482
+ const all = model.byName.get(last) || [];
483
+ const cands = head || call.self ? all : all.filter((s) => !classOf(s));
484
+ if (head) {
485
+ const hl = head.toLowerCase();
486
+ const byClass = cands.filter((s) => {
487
+ const c = classOf(s);
488
+ return c && c.split(".").pop().toLowerCase() === hl;
489
+ });
490
+ if (byClass.length === 1) r = hit("UNIQUE", byClass[0]);
491
+ else if (byClass.length > 1) r = { state: "AMBIGUOUS", candidates: byClass.map((s) => s.id) };
492
+ // W2 (G2): the head came from an alias, so it IS a class name. A class
493
+ // named `Router` with no `get` is not "some `get` somewhere" — it is
494
+ // outside this repository (an external package), and saying AMBIGUOUS
495
+ // over every `get` in the tree was the nestjs/nest 118-maybe noise.
496
+ else if (call.alias) r = { state: "UNRESOLVED", external: head };
497
+ else if (cands.length) r = { state: "AMBIGUOUS", candidates: cands.map((s) => s.id) };
498
+ } else if (cands.length === 1) r = hit("UNIQUE", cands[0]);
499
+ else if (cands.length > 1) r = { state: "AMBIGUOUS", candidates: cands.map((s) => s.id) };
500
+ }
501
+ if (!r) r = { state: "UNRESOLVED" };
502
+ // A name passed as an argument is only an edge when this file defines it or
503
+ // imports it. `res.json(order)` must never find a repo function named `order`.
504
+ if (call.ref && r.state !== "LOCAL" && r.state !== "IMPORT") r = { state: "UNRESOLVED" };
505
+ model.resolved.set(key, r);
506
+ return r;
507
+ }
508
+
509
+ function callIndex(model) {
510
+ if (model.callIndex) return model.callIndex;
511
+ const idx = new Map();
512
+ for (const [rel, f] of Object.entries(model.byFile)) {
513
+ for (const s of f.symbols || []) {
514
+ for (const c of s.calls || []) {
515
+ const last = c.name.split(".").pop();
516
+ const a = idx.get(last);
517
+ const row = { rel, sym: s, call: c };
518
+ if (a) a.push(row);
519
+ else idx.set(last, [row]);
520
+ }
521
+ }
522
+ }
523
+ model.callIndex = idx;
524
+ return idx;
525
+ }
526
+
527
+ // ── W1: routes and URLs ─────────────────────────────────────────────────────
528
+ const PARAM_SEG = /:\w+\??|\{[^}]*\}|<[^>]*>|\[[^\]]*\]/g;
529
+ const isLiteralPath = (p) => !/[:{<\[*]/.test(p);
530
+
531
+ // A route path → a regular expression over a URL path. One segment per
532
+ // parameter, `*` is anything, the trailing slash is optional.
533
+ function pathSource(p) {
534
+ const s = String(p).replace(/\/+$/, "");
535
+ return s
536
+ .split("/")
537
+ .map((seg) => {
538
+ if (seg === "*" || seg === "**") return ".*";
539
+ let out = "";
540
+ let last = 0;
541
+ for (const mm of seg.matchAll(PARAM_SEG)) {
542
+ out += seg.slice(last, mm.index).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + (mm[0].endsWith("?") ? "[^/]*" : "[^/]+");
543
+ last = mm.index + mm[0].length;
544
+ }
545
+ out += seg.slice(last).replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*");
546
+ return out;
547
+ })
548
+ .join("/");
549
+ }
550
+
551
+ const joinPrefix = (a, b) => (String(a || "") + "/" + String(b || "")).replace(/\/{2,}/g, "/").replace(/\/+$/, "");
552
+
553
+ // Which files a mount's TARGET names: an import binding (`ordersRouter`,
554
+ // `orders.router`), an inline `require("./x")`, a Django `include("a.urls")`.
555
+ function mountTargets(model, rel, mt) {
556
+ const f = model.byFile[rel] || {};
557
+ const lang = f.lang;
558
+ const t = String(mt.target || "");
559
+ if (!t) return [];
560
+ if (mt.module) return lang === "py" ? resolvePy(model, rel, t) : resolveJs(model, rel, t);
561
+ if (/^[./]/.test(t)) return lang === "py" ? resolvePy(model, rel, t) : resolveJs(model, rel, t);
562
+ const head = t.split(".")[0];
563
+ const bd = fileBindings(model, rel).map.get(head);
564
+ if (!bd) return [];
565
+ // `from app.routers import orders` + `orders.router`: the binding names a
566
+ // MODULE, and the package `__init__.py` it also resolves to is not the file
567
+ // that holds the router.
568
+ if (lang === "py" && bd.kind === "named" && t.includes(".")) {
569
+ const sub = resolvePy(model, rel, `${bd.from}.${bd.orig}`);
570
+ if (sub.length) return sub;
571
+ }
572
+ if (bd.files.length) return bd.files;
573
+ if (lang === "py" && bd.kind === "named") return resolvePy(model, rel, `${bd.from}.${bd.orig}`);
574
+ return [];
575
+ }
576
+
577
+ // file → the mounts that reach it, built once per model.
578
+ function mountsInto(model) {
579
+ if (model.mountsInto) return model.mountsInto;
580
+ const into = new Map();
581
+ for (const [rel, f] of Object.entries(model.byFile)) {
582
+ for (const s of f.symbols || []) {
583
+ for (const mt of s.mounts || []) {
584
+ for (const target of mountTargets(model, rel, mt)) {
585
+ const a = into.get(target) || [];
586
+ a.push({ prefix: mt.prefix || "", from: rel });
587
+ into.set(target, a);
588
+ }
589
+ }
590
+ }
591
+ }
592
+ model.mountsInto = into;
593
+ return into;
594
+ }
595
+
596
+ // Every full prefix under which `rel` is reachable, following mounts across
597
+ // files. Exactly one is a KNOWN mount; none means unmounted (the route's own
598
+ // path is the full path); several means the file is mounted more than once.
599
+ function prefixesOf(model, rel, depth, seen) {
600
+ const ins = mountsInto(model).get(rel) || [];
601
+ if (!ins.length || depth >= MOUNT_DEPTH) return [""];
602
+ const out = new Set();
603
+ for (const m of ins) {
604
+ if (seen.has(m.from)) continue;
605
+ for (const p of prefixesOf(model, m.from, depth + 1, new Set([...seen, rel]))) out.add(joinPrefix(p, m.prefix));
606
+ }
607
+ return out.size ? [...out] : [""];
608
+ }
609
+
610
+ function routeTable(model) {
611
+ if (model.routes) return model.routes;
612
+ const rows = [];
613
+ for (const s of model.symbols.values()) {
614
+ if (s.kind !== "route") continue;
615
+ const mm = /^([A-Z]+) (.+)$/.exec(s.name);
616
+ if (!mm) continue;
617
+ const own = mm[2];
618
+ const prefixes = prefixesOf(model, s.file, 0, new Set());
619
+ const full = prefixes.length === 1 ? joinPrefix(prefixes[0], own) || "/" : null;
620
+ rows.push({
621
+ sym: s,
622
+ method: mm[1],
623
+ own,
624
+ full,
625
+ mounted: prefixes.length === 1 ? (prefixes[0] ? "known" : "none") : "ambiguous",
626
+ literal: isLiteralPath(own) && (full == null || isLiteralPath(full)),
627
+ reFull: full != null ? new RegExp("^" + pathSource(full) + "/?$") : null,
628
+ reTail: new RegExp("(^|/)" + pathSource(own) + "/?$"),
629
+ });
630
+ }
631
+ model.routes = rows;
632
+ return rows;
633
+ }
634
+
635
+ const methodOk = (a, b) => a === "ANY" || b === "ANY" || a === b;
636
+
637
+ // A URL (with its method) → the route it reaches. Order: an exact literal full
638
+ // path; a full-path pattern; the path's TAIL against every route's own path
639
+ // (a suffix URL, or a route whose mount is unknown). One candidate → ROUTE;
640
+ // several → AMBIGUOUS with all of them; none → UNRESOLVED.
641
+ function resolveUrl(model, u) {
642
+ const method = String(u.method || "ANY").toUpperCase();
643
+ const url = String(u.url || "").split("?")[0].split("#")[0].replace(/\/+$/, "") || "/";
644
+ const key = `${method} ${url} ${u.suffix ? 1 : 0}`;
645
+ if (model.urlMemo.has(key)) return model.urlMemo.get(key);
646
+ const cands = routeTable(model).filter((r) => methodOk(r.method, method));
647
+ const pick = (list) => {
648
+ const lit = list.filter((r) => r.literal);
649
+ return lit.length ? lit : list;
650
+ };
651
+ let found = [];
652
+ let mount = "known";
653
+ if (!u.suffix) found = pick(cands.filter((r) => r.reFull && r.reFull.test(url)));
654
+ if (!found.length) {
655
+ found = pick(cands.filter((r) => r.reTail.test(url)));
656
+ mount = "unknown";
657
+ }
658
+ let r;
659
+ if (found.length === 1) r = { state: "ROUTE", target: found[0].sym.id, ...(mount === "unknown" ? { mount } : {}) };
660
+ else if (found.length > 1) r = { state: "AMBIGUOUS", candidates: found.map((x) => x.sym.id) };
661
+ else r = { state: "UNRESOLVED" };
662
+ model.urlMemo.set(key, r);
663
+ return r;
664
+ }
665
+
666
+ // Every URL any symbol sends, once per model.
667
+ function urlIndex(model) {
668
+ if (model.urlIndex) return model.urlIndex;
669
+ const rows = [];
670
+ for (const [rel, f] of Object.entries(model.byFile)) for (const s of f.symbols || []) for (const u of s.urls || []) rows.push({ rel, sym: s, u });
671
+ model.urlIndex = rows;
672
+ return rows;
673
+ }
674
+
675
+ // Who calls `target`: confident callers, and a count of AMBIGUOUS ones that
676
+ // list it among their candidates. A route symbol is also REACHED by URL (W1).
677
+ function callersOf(model, target) {
678
+ const confident = [];
679
+ const seen = new Set();
680
+ let maybe = 0;
681
+ // The stored reverse map. EW0 measured this walk at 437 ms for one file card
682
+ // on django/django; the lookup is a property read.
683
+ const cachedRows = model.callersCache && Object.prototype.hasOwnProperty.call(model.callersCache, target.id) ? model.callersCache[target.id] : model.callersCache ? [] : null;
684
+ if (cachedRows) {
685
+ for (const r of cachedRows) {
686
+ const sym = model.symbols.get(r[0]);
687
+ if (!sym) continue;
688
+ const k = `${r[0]}${r[1]}`;
689
+ if (seen.has(k)) continue;
690
+ seen.add(k);
691
+ confident.push({
692
+ sym,
693
+ line: r[1],
694
+ state: r[2],
695
+ ...(r[3] === 1 ? { ref: true } : {}),
696
+ ...(r[3] === 2 && r[4] ? { url: r[4] } : {}),
697
+ ...(r[3] === 3 ? { inherited: true } : {}),
698
+ ...(r[3] === 0 && r[4] ? { alias: r[4] } : {}),
699
+ });
700
+ }
701
+ }
702
+ if (model.callersCache) {
703
+ stableCallers(confident);
704
+ const m = model.maybeCache;
705
+ if (m && Object.prototype.hasOwnProperty.call(m, target.id)) for (const row of m[target.id]) maybe += row[1];
706
+ return { confident, maybe };
707
+ }
708
+ for (const row of callIndex(model).get(target.name) || []) {
709
+ const r = resolveCall(model, row.rel, row.sym, row.call);
710
+ if (r.target === target.id && CONFIDENT.has(r.state)) {
711
+ const k = `${row.sym.id} ${row.call.line}`;
712
+ if (seen.has(k)) continue;
713
+ seen.add(k);
714
+ confident.push({
715
+ sym: row.sym,
716
+ line: row.call.line,
717
+ state: r.state,
718
+ ...(row.call.ref ? { ref: true } : {}),
719
+ ...(r.inherited ? { inherited: true } : {}),
720
+ ...(!row.call.ref && !r.inherited && row.call.alias ? { alias: row.call.alias } : {}),
721
+ });
722
+ } else if (r.state === "AMBIGUOUS" && r.candidates.includes(target.id)) maybe++;
723
+ }
724
+ if (target.kind === "route") {
725
+ for (const row of urlIndex(model)) {
726
+ const r = resolveUrl(model, row.u);
727
+ if (r.state === "ROUTE" && r.target === target.id) {
728
+ const k = `${row.sym.id} ${row.u.line}`;
729
+ if (seen.has(k)) continue;
730
+ seen.add(k);
731
+ confident.push({ sym: row.sym, line: row.u.line, state: "ROUTE", url: `${row.u.method} ${row.u.url}` });
732
+ } else if (r.state === "AMBIGUOUS" && r.candidates.includes(target.id)) maybe++;
733
+ }
734
+ }
735
+ stableCallers(confident);
736
+ return { confident, maybe };
737
+ }
738
+
739
+ // EW2: the stored reverse map and the on-read walk visit callers in different
740
+ // orders. Same rows, and a card that reorders itself depending on whether a
741
+ // cache file happens to exist is a card nobody can diff. One total order, both
742
+ // ways.
743
+ function stableCallers(rows) {
744
+ rows.sort((a, b) => a.sym.file.localeCompare(b.sym.file) || a.line - b.line || String(a.sym.id).localeCompare(String(b.sym.id)));
745
+ }
746
+
747
+ function calleesOf(model, sym) {
748
+ const out = [];
749
+ const seen = new Set();
750
+ // S1 (v1.8.2 W8): on the sharded fast path the resolutions were computed by
751
+ // the update and stored, so this loop reads instead of resolving. The DEDUPE
752
+ // and the order are unchanged — the rows are keyed by the call's INDEX, so
753
+ // the same calls survive in the same order and the card is the same bytes.
754
+ const fwd = model.callsFor ? model.callsFor(sym) : null;
755
+ const stored = fwd ? new Map((fwd.r || []).map((row) => [row[0], row])) : null;
756
+ const calls = sym.calls || [];
757
+ for (let i = 0; i < calls.length; i++) {
758
+ const c = calls[i];
759
+ if (seen.has(c.name)) continue;
760
+ seen.add(c.name);
761
+ if (stored) {
762
+ const row = stored.get(i);
763
+ const S = require("./graph-shard.js");
764
+ out.push({
765
+ call: c,
766
+ res: row
767
+ ? row[1] === S.STATE_CODE.AMBIGUOUS
768
+ ? { state: "AMBIGUOUS", target: null, candidates: (fwd.cands || [])[row[4]] || [] }
769
+ : { state: S.CODE_STATE[row[1]], target: row[2], ...(row[3] === 1 ? { inherited: true } : {}) }
770
+ : { state: "UNRESOLVED", target: null, candidates: [] },
771
+ });
772
+ continue;
773
+ }
774
+ out.push({ call: c, res: resolveCall(model, sym.file, sym, c) });
775
+ }
776
+ return out;
777
+ }
778
+
779
+ // ── targets ─────────────────────────────────────────────────────────────────
780
+ function findTarget(model, q) {
781
+ const s = norm(q);
782
+ const fl = /^(.+?):(\d+)$/.exec(s);
783
+ if (fl && model.fileSet.has(fl[1])) {
784
+ const line = Number(fl[2]);
785
+ const inside = (model.byFile[fl[1]].symbols || [])
786
+ .filter((x) => x.kind !== "module" && x.lines[0] <= line && line <= x.lines[1])
787
+ .sort((a, b) => a.lines[1] - a.lines[0] - (b.lines[1] - b.lines[0]));
788
+ return inside.length ? { kind: "symbol", sym: inside[0] } : { kind: "file", rel: fl[1] };
789
+ }
790
+ if (model.fileSet.has(s)) return { kind: "file", rel: s };
791
+ if (model.symbols.has(s)) return { kind: "symbol", sym: model.symbols.get(s) };
792
+ const exact = [];
793
+ for (const x of model.symbols.values()) if (x.kind !== "module" && x.qname === s) exact.push(x);
794
+ if (exact.length === 1) return { kind: "symbol", sym: exact[0] };
795
+ // W1: `GET /orders/search` or `/orders/search` — a URL, resolved like one a
796
+ // test would send, so a route is found by its FULL path, mounts included.
797
+ const asUrl = /^(?:([A-Za-z]+) )?(\/\S*)$/.exec(s);
798
+ if (!exact.length && asUrl) {
799
+ const r = resolveUrl(model, { method: asUrl[1] ? asUrl[1].toUpperCase() : "ANY", url: asUrl[2] });
800
+ if (r.state === "ROUTE") return { kind: "symbol", sym: model.symbols.get(r.target) };
801
+ if (r.state === "AMBIGUOUS") return { kind: "ambiguous", candidates: r.candidates.map((id) => model.symbols.get(id)).filter(Boolean) };
802
+ }
803
+ // Same qname, same file: one symbol declared more than once (a Go build tag,
804
+ // a Python redefinition). The widest one is the implementation.
805
+ if (exact.length > 1 && exact.every((x) => x.file === exact[0].file)) {
806
+ return { kind: "symbol", sym: exact.slice().sort((a, b) => b.lines[1] - b.lines[0] - (a.lines[1] - a.lines[0]))[0] };
807
+ }
808
+ if (exact.length > 1) return { kind: "ambiguous", candidates: exact };
809
+ const named = model.byName.get(s) || [];
810
+ if (named.length === 1) return { kind: "symbol", sym: named[0] };
811
+ if (named.length > 1) return { kind: "ambiguous", candidates: named };
812
+ const ql = s.toLowerCase();
813
+ const nearest = [];
814
+ for (const [n, arr] of model.byName) if (n.toLowerCase().includes(ql)) nearest.push(...arr);
815
+ return { kind: "none", nearest: nearest.slice(0, 5) };
816
+ }
817
+
818
+ const where = (sym, line) => `${sym.file}:${line || sym.lines[0]}`;
819
+ const brief = (sym) => ({ id: sym.id, qname: sym.qname, kind: sym.kind, file: sym.file, lines: sym.lines });
820
+
821
+ function blobState(model, rel) {
822
+ if (model.blobStates.has(rel)) return model.blobStates.get(rel);
823
+ let state;
824
+ if (!fs.existsSync(path.join(model.root, ...rel.split("/")))) state = "deleted";
825
+ else {
826
+ const r = spawnSync("git", ["hash-object", "--", rel], { cwd: model.root, encoding: "utf8" });
827
+ state = r.status !== 0 ? "unknown" : r.stdout.trim() === (model.byFile[rel] || {}).blob ? "current" : "changed";
828
+ }
829
+ model.blobStates.set(rel, state);
830
+ return state;
831
+ }
832
+
833
+ // S3 (v1.8.2 W2): one `git hash-object --stdin-paths` for every file the
834
+ // cards of this read will name. `ctx` on five targets spawned git five times,
835
+ // and a Windows process start is ~30 ms each.
836
+ function warmBlobs(model, queries) {
837
+ const rels = new Set();
838
+ for (const q of queries || []) {
839
+ const t = findTarget(model, q);
840
+ if (t.kind === "symbol") rels.add(t.sym.file);
841
+ else if (t.kind === "file") rels.add(t.rel);
842
+ }
843
+ const onDisk = [...rels].filter((rel) => !model.blobStates.has(rel) && fs.existsSync(path.join(model.root, ...rel.split("/"))));
844
+ for (const rel of rels) if (!fs.existsSync(path.join(model.root, ...rel.split("/")))) model.blobStates.set(rel, "deleted");
845
+ if (!onDisk.length) return;
846
+ const h = spawnSync("git", ["hash-object", "--stdin-paths"], { cwd: model.root, encoding: "utf8", input: onDisk.join("\n") + "\n" });
847
+ if (h.status !== 0) return;
848
+ const out = h.stdout.split(/\r?\n/).filter(Boolean);
849
+ onDisk.forEach((rel, i) => {
850
+ if (out[i]) model.blobStates.set(rel, out[i] === (model.byFile[rel] || {}).blob ? "current" : "changed");
851
+ });
852
+ }
853
+
854
+ // EW1: what the extractor saw of this file, for the card header. A card that
855
+ // hides a gap is worse than no card — the reader takes silence for absence.
856
+ function coverageTag(model, rel) {
857
+ const f = model.byFile[rel] || {};
858
+ if (f.skipped) return ` · coverage skipped:${f.skipped}`;
859
+ if (f.coverage !== "partial") return "";
860
+ const r = (f.partial || []).map((g) => `${g[0]}-${g[1]}`).join(",");
861
+ return ` · coverage partial ${r}`;
862
+ }
863
+
864
+ const BLOB_LABEL = {
865
+ current: "current",
866
+ changed: "CHANGED since index — hints only (run: orc graph update)",
867
+ deleted: "DELETED since index (run: orc graph update)",
868
+ unknown: "freshness unknown",
869
+ };
870
+
871
+ // Pick lines by priority until the budget is spent, then print them in display
872
+ // order. The footer always says what the card hid.
873
+ //
874
+ // v1.8.2 W3 — TWO changes, both about the tokens the card itself adds:
875
+ // · the footer is printed ONLY when something was hidden, a call was
876
+ // unresolved, or `--source` was cut. A card that hid nothing used to spend
877
+ // a whole line saying so, on every card, on every later turn of the agent
878
+ // that received it. `budget` stays in the JSON either way.
879
+ // · `tail` is the `--source` block (D1). It is charged against the SAME
880
+ // budget and it goes LAST, so the rows always win: source takes what the
881
+ // card left, line by line, and the footer says how many lines it cut.
882
+ function fit(items, budget, footer, tail) {
883
+ // The reserve is the LONGEST footer this card could print — one hidden
884
+ // count per section present — so the final `used` can never pass the
885
+ // budget, whatever ends up hidden. (W2: a card with five sections overran a
886
+ // 100-token budget by five tokens on the footer alone.)
887
+ const worst = {};
888
+ for (const it of items) if (it.pri > 0) worst[it.section] = 99;
889
+ const reserve = tok(footer(0, worst, tail ? { kept: 0, want: tail.want, cut: tail.want } : null)) + 2;
890
+ let used = 0;
891
+ const keep = new Set();
892
+ const hidden = {};
893
+ const order = items.map((it, i) => i).sort((a, b) => items[a].pri - items[b].pri || a - b);
894
+ for (const i of order) {
895
+ const t = tok(items[i].text) + 1;
896
+ if (items[i].pri === 0 || used + t + reserve <= budget) {
897
+ keep.add(i);
898
+ used += t;
899
+ } else hidden[items[i].section] = (hidden[items[i].section] || 0) + 1;
900
+ }
901
+ const lines = items.filter((_, i) => keep.has(i)).map((it) => it.text);
902
+ let source = null;
903
+ if (tail) {
904
+ const open = " ```" + (tail.fence || "");
905
+ const close = " ```";
906
+ let left = budget - used - reserve - tok(tail.head) - tok(open) - tok(close) - 3;
907
+ const body = [];
908
+ for (const ln of tail.lines) {
909
+ const t = tok(ln) + 1;
910
+ if (t > left) break;
911
+ left -= t;
912
+ body.push(ln);
913
+ }
914
+ if (body.length) {
915
+ const block = [tail.head, open, ...body, close];
916
+ used += block.reduce((a, l) => a + tok(l) + 1, 0);
917
+ lines.push(...block);
918
+ }
919
+ source = { kept: body.length, want: tail.want, cut: tail.want - body.length };
920
+ }
921
+ const foot = footer(used, hidden, source);
922
+ if (foot) lines.push(foot);
923
+ return { card: lines.join("\n"), kept: keep, hidden, source, used: used + tok(foot) };
924
+ }
925
+
926
+ // ── E6: the tree format ─────────────────────────────────────────────────────
927
+ // The prose card repeats a label on every row — `function`, `:12-40`,
928
+ // `exported`, `fan-in 3`, `(via X)`. A card is sent again on every later turn of
929
+ // the agent that received it, so a word repeated on forty rows is paid forty
930
+ // times, forty turns over. The tree format names each column ONCE in a header
931
+ // and then prints only values.
932
+ //
933
+ // It is a FORMAT, never a filter: the same rows, the same order, the same JSON.
934
+ // `--format prose` stays the default, because a shorter card that is read wrong
935
+ // costs more than it saves.
936
+ const KIND_SHORT = { function: "fn", method: "me", class: "cl", route: "rt", module: "mo", const: "co", var: "va", type: "ty", interface: "in", enum: "en", struct: "st" };
937
+ const STATE_SHORT = { LOCAL: "L", IMPORT: "I", UNIQUE: "U", ROUTE: "R", AMBIGUOUS: "A", UNRESOLVED: "-" };
938
+ const shortKind = (k) => KIND_SHORT[k] || String(k).slice(0, 2);
939
+
940
+ // The longest directory every path shares. A prefix line is only worth writing
941
+ // when it saves REAL bytes — CBM's rule, and the reason is that a header nobody
942
+ // needs is a header everybody still reads.
943
+ function dirPrefix(paths) {
944
+ if (paths.length < 2) return "";
945
+ const parts = paths.map((p) => p.split("/").slice(0, -1));
946
+ let n = 0;
947
+ outer: for (; n < parts[0].length; n++) {
948
+ for (const q of parts) if (q[n] !== parts[0][n]) break outer;
949
+ }
950
+ if (!n) return "";
951
+ const pre = parts[0].slice(0, n).join("/") + "/";
952
+ const saved = pre.length * paths.length - (pre.length + 6); // " dir " + the prefix
953
+ const total = paths.reduce((a, p) => a + p.length, 0);
954
+ return saved >= 64 && saved >= total * 0.15 ? pre : "";
955
+ }
956
+
957
+ function hiddenText(hidden) {
958
+ const parts = Object.entries(hidden).map(([k, v]) => `${v} ${k}`);
959
+ return parts.length ? ` · hidden: ${parts.join(", ")} (raise --budget)` : "";
960
+ }
961
+
962
+ // ── D1 (v1.8.2 W3): the card and the range in ONE call ──────────────────────
963
+ // A card is a LOCATOR: it names a range and the agent then reads it. That is
964
+ // two round trips for one question, and the second one is a `Read` the CLI
965
+ // could have answered. `--source` prints the range the card just named.
966
+ //
967
+ // It does NOT change the read ladder. A file you are about to EDIT is still
968
+ // read in full with `Read` first (exception 1) — an `old_string` rebuilt from
969
+ // a CLI print is the same corruption bug as one rebuilt from an outline.
970
+ // `--source` is for the CALLER'S range, the callee's, the neighbour you will
971
+ // not touch. The hook never prints source; this is a CLI answer to a CLI call.
972
+ const SOURCE_DEFAULT = 80;
973
+ const SOURCE_CAP = 200;
974
+ const FILE_HEAD_LINES = 40;
975
+ const FENCE_BY_EXT = {
976
+ ".ts": "ts", ".tsx": "tsx", ".js": "js", ".jsx": "jsx", ".mjs": "js", ".cjs": "js", ".mts": "ts", ".cts": "ts",
977
+ ".py": "python", ".go": "go", ".rb": "ruby", ".rs": "rust", ".java": "java", ".kt": "kotlin", ".cs": "csharp",
978
+ ".php": "php", ".sh": "bash", ".sql": "sql", ".json": "json", ".yml": "yaml", ".yaml": "yaml", ".md": "md",
979
+ };
980
+
981
+ function sourceLimit(want) {
982
+ const n = Number(want);
983
+ if (!Number.isFinite(n) || n <= 0) return SOURCE_DEFAULT;
984
+ return Math.min(SOURCE_CAP, Math.floor(n));
985
+ }
986
+
987
+ // The lines are read from the CURRENT bytes on disk, and the range comes from
988
+ // the index. When the two disagree the card header already says CHANGED, and
989
+ // the source head repeats it — a range that has moved is a wrong answer given
990
+ // confidently, which is the one thing this whole feature must never do.
991
+ function sourceTail(model, rel, from, to, want, fresh) {
992
+ if (fresh === "deleted") return null;
993
+ let text;
994
+ try {
995
+ text = fs.readFileSync(path.join(model.root, ...rel.split("/")), "utf8");
996
+ } catch (_) {
997
+ return null;
998
+ }
999
+ const all = text.split(/\r?\n/);
1000
+ const start = Math.max(1, Number(from) || 1);
1001
+ const end = Math.max(start, Math.min(all.length, Number(to) || all.length));
1002
+ const limit = sourceLimit(want);
1003
+ const stop = Math.min(end, start + limit - 1);
1004
+ const width = String(stop).length;
1005
+ const lines = [];
1006
+ for (let i = start; i <= stop; i++) lines.push(` ${String(i).padStart(width)} ${all[i - 1] === undefined ? "" : all[i - 1]}`);
1007
+ if (!lines.length) return null;
1008
+ const moved = fresh !== "current" ? " — CHANGED since index, the range may have moved" : "";
1009
+ return {
1010
+ head: ` source ${rel}:${start}-${stop}${moved}`,
1011
+ fence: FENCE_BY_EXT[posix.extname(rel)] || "",
1012
+ want: end - start + 1,
1013
+ lines,
1014
+ file: rel,
1015
+ from: start,
1016
+ to: stop,
1017
+ };
1018
+ }
1019
+
1020
+ function sourceText(tail, kept) {
1021
+ return tail.lines.slice(0, kept).join("\n");
1022
+ }
1023
+
1024
+ function sourceNote(source) {
1025
+ if (!source) return "";
1026
+ if (!source.kept) return ` · source not shown (${source.want} lines, raise --budget)`;
1027
+ return source.cut ? ` · source ${source.kept} lines · ${source.cut} cut (raise --budget)` : "";
1028
+ }
1029
+
1030
+ // ── §6 (v1.8.2 W3): the state word, once ────────────────────────────────────
1031
+ // `LOCAL` / `IMPORT` / `UNIQUE` is spelled on every row of every card, and a
1032
+ // card is sent again on each later turn of the agent that received it. The
1033
+ // one-letter form plus ONE legend line says the same thing — but only when the
1034
+ // card has enough rows to pay for the legend. So both bodies are built and the
1035
+ // SMALLER one is printed, the same rule `--format tree` already follows, and
1036
+ // the answer says which one it used. It can never be worse than the long form.
1037
+ const STATE_LEGEND = " states: L local · I import · U unique · R route · A ambiguous";
1038
+ const stateWord = (s, short) => (short ? STATE_SHORT[s] || s : s);
1039
+
1040
+ // Rewrites `items` in place to the short body and inserts the legend, but only
1041
+ // when the short body PLUS the legend is smaller than the long one. Returns
1042
+ // which body was chosen.
1043
+ function pickShort(items) {
1044
+ let long = 0;
1045
+ let short = STATE_LEGEND.length;
1046
+ for (const it of items) {
1047
+ long += it.text.length;
1048
+ short += it.short.length;
1049
+ }
1050
+ if (short >= long) return false;
1051
+ for (const it of items) it.text = it.short;
1052
+ items.splice(1, 0, { pri: 0, section: "header", text: STATE_LEGEND, short: STATE_LEGEND, data: null });
1053
+ return true;
1054
+ }
1055
+
1056
+ // ── ctx ─────────────────────────────────────────────────────────────────────
1057
+ function ctx(model, query, opts) {
1058
+ const depth = Math.max(1, Math.min(4, Number(opts.depth) || 2));
1059
+ const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1200));
1060
+ const t = findTarget(model, query);
1061
+ if (t.kind === "none") return { ok: false, state: "not-found", query, nearest: t.nearest.map(brief), exit: 4 };
1062
+ if (t.kind === "ambiguous") return { ok: false, state: "ambiguous-target", query, candidates: t.candidates.map(brief), exit: 4 };
1063
+ if (t.kind === "file") return fileCard(model, t.rel, budget, opts);
1064
+
1065
+ const sym = t.sym;
1066
+ const fresh = blobState(model, sym.file);
1067
+ const items = [];
1068
+ // `short` is the same row with the one-letter state word. Both bodies are
1069
+ // built; the smaller one is printed (§6).
1070
+ const push = (pri, section, text, data, short) => items.push({ pri, section, text, short: short == null ? text : short, data });
1071
+ push(0, "header", `${sym.qname} ${sym.file}:${sym.lines[0]}-${sym.lines[1]} [blob ${String((model.byFile[sym.file] || {}).blob || "").slice(0, 7)} · ${BLOB_LABEL[fresh]}${coverageTag(model, sym.file)}]`);
1072
+ // A note is shown ONLY while the body still hashes the same. An old
1073
+ // sentence about code that has since changed is never printed.
1074
+ const note = N.noteFor(model.notes, sym);
1075
+ if (note)
1076
+ push(
1077
+ 1,
1078
+ "note",
1079
+ note.current ? ` ${note.source === "doc" ? "doc " : "note"} ${note.text} (${note.model} · current)` : " note: stale (body changed) — it is re-noted at the next batch",
1080
+ { item: "note", ...note }
1081
+ );
1082
+
1083
+ const callers = callersOf(model, sym);
1084
+ callers.confident.sort((a, b) => (a.sym.file + a.line).localeCompare(b.sym.file + b.line));
1085
+ for (const c of callers.confident) {
1086
+ const row = (short) =>
1087
+ c.url
1088
+ ? ` ← reached via ${c.url} ${where(c.sym, c.line)} ${stateWord("ROUTE", short)}`
1089
+ : ` ← ${c.ref ? "used by " : "called by"} ${c.sym.qname} ${where(c.sym, c.line)} ${stateWord(c.state, short)}${c.inherited ? " (inherited)" : c.alias ? ` (via ${c.alias})` : ""}`;
1090
+ push(
1091
+ 2,
1092
+ "callers",
1093
+ row(false),
1094
+ {
1095
+ item: "caller",
1096
+ id: c.sym.id,
1097
+ qname: c.sym.qname,
1098
+ file: c.sym.file,
1099
+ line: c.line,
1100
+ state: c.state,
1101
+ ...(c.ref ? { ref: true } : {}),
1102
+ ...(c.url ? { url: c.url } : {}),
1103
+ ...(c.inherited ? { inherited: true } : {}),
1104
+ ...(c.alias ? { alias: c.alias } : {}),
1105
+ },
1106
+ row(true)
1107
+ );
1108
+ }
1109
+ if (callers.maybe) push(4, "callers", ` ← maybe ${callers.maybe} more caller(s) name it among AMBIGUOUS candidates`, { item: "maybe_callers", n: callers.maybe });
1110
+
1111
+ let unresolved = 0;
1112
+ const visited = new Set([sym.id]);
1113
+ const walk = (s, level, indent) => {
1114
+ for (const { call, res } of calleesOf(model, s)) {
1115
+ if (res.state === "UNRESOLVED") {
1116
+ if (level === 1) unresolved++;
1117
+ continue;
1118
+ }
1119
+ if (res.state === "AMBIGUOUS") {
1120
+ if (level > 1) continue;
1121
+ const cs = res.candidates.map((id) => model.symbols.get(id)).filter(Boolean);
1122
+ const shown = cs.slice(0, 3).map((x) => where(x)).join(", ");
1123
+ const amb = (short) => `${indent}→ calls ${call.name} ${stateWord("AMBIGUOUS", short)} (${cs.length}): ${shown}${cs.length > 3 ? `, +${cs.length - 3}` : ""}`;
1124
+ push(4, "calls", amb(false), { item: "call", name: call.name, line: call.line, state: "AMBIGUOUS", candidates: cs.map((x) => x.id) }, amb(true));
1125
+ continue;
1126
+ }
1127
+ const target = model.symbols.get(res.target);
1128
+ if (!target) continue;
1129
+ const mark = res.inherited ? " (inherited)" : call.alias ? ` (via ${call.alias})` : "";
1130
+ const row = (short) => `${indent}→ ${call.ref ? "uses " : "calls"} ${target.qname} ${where(target)} ${stateWord(res.state, short)}${mark}`;
1131
+ push(level === 1 ? 2 : 3 + level, "calls", row(false), {
1132
+ item: "call",
1133
+ name: call.name,
1134
+ line: call.line,
1135
+ state: res.state,
1136
+ target: brief(target),
1137
+ level,
1138
+ ...(call.ref ? { ref: true } : {}),
1139
+ ...(res.inherited ? { inherited: true } : {}),
1140
+ ...(call.alias ? { alias: call.alias } : {}),
1141
+ }, row(true));
1142
+ if (level < depth && !visited.has(target.id)) {
1143
+ visited.add(target.id);
1144
+ const cn = N.noteFor(model.notes, target);
1145
+ if (cn && cn.current) push(2 + level, "notes", `${indent} └ ${cn.source === "doc" ? "doc" : "note"} ${cn.text}`, { item: "callee_note", of: target.id, text: cn.text, source: cn.source });
1146
+ for (const e of target.effects || []) push(2 + level, "effects", `${indent} └ effect ${e.type} "${e.text}" ${target.file}:${e.line}`, { item: "effect", of: target.id, ...e });
1147
+ walk(target, level + 1, indent + " ");
1148
+ }
1149
+ }
1150
+ };
1151
+ walk(sym, 1, " ");
1152
+ for (const e of sym.effects || []) push(1, "effects", ` effect ${e.type} "${e.text}" ${sym.file}:${e.line}`, { item: "effect", of: sym.id, ...e });
1153
+
1154
+ const wiki = opts.wikiDocsFor ? opts.wikiDocsFor([sym.file]) : [];
1155
+ if (wiki.length) push(6, "wiki", ` wiki ${wiki.join(", ")}`, { item: "wiki", docs: wiki });
1156
+ const tests = [...new Set(callers.confident.filter((c) => TEST_FILE.test(c.sym.file)).map((c) => c.sym.file))];
1157
+ if (tests.length) push(5, "tests", ` tests ${tests.join(", ")}`, { item: "tests", files: tests });
1158
+
1159
+ const short = pickShort(items);
1160
+ // The footer is printed ONLY when the card hid something, dropped an
1161
+ // unresolved call, or cut the source block (§6).
1162
+ const footer = (used, hidden, source) => {
1163
+ const why = `${unresolved ? ` · ${unresolved} unresolved call(s) not shown` : ""}${hiddenText(hidden)}${sourceNote(source)}`;
1164
+ return why ? ` budget ${used}/${budget} tokens${why}` : "";
1165
+ };
1166
+ const tail = opts.source === undefined ? null : sourceTail(model, sym.file, sym.lines[0], sym.lines[1], opts.source, fresh);
1167
+ const f = fit(items, budget, footer, tail);
1168
+ const kept = items.filter((_, i) => f.kept.has(i)).map((it) => it.data).filter(Boolean);
1169
+ return {
1170
+ ok: true,
1171
+ state: "found",
1172
+ target: brief(sym),
1173
+ blob: fresh,
1174
+ exported: sym.exported,
1175
+ states: short ? "short" : "long",
1176
+ note: note || null,
1177
+ callers: kept.filter((d) => d.item ==="caller"),
1178
+ maybe_callers: callers.maybe,
1179
+ calls: kept.filter((d) => d.item ==="call"),
1180
+ effects: kept.filter((d) => d.item ==="effect"),
1181
+ wiki,
1182
+ tests,
1183
+ unresolved,
1184
+ source: tail && f.source && f.source.kept ? { file: tail.file, from: tail.from, to: tail.from + f.source.kept - 1, text: sourceText(tail, f.source.kept), cut: f.source.cut } : null,
1185
+ hidden: f.hidden,
1186
+ budget: { used: f.used, max: budget },
1187
+ card: f.card,
1188
+ exit: 0,
1189
+ };
1190
+ }
1191
+
1192
+ function importersOf(model, rel) {
1193
+ const out = [];
1194
+ for (const other of model.fileSet) {
1195
+ if (other === rel) continue;
1196
+ const { map, includes } = fileBindings(model, other);
1197
+ let hit = includes.includes(rel);
1198
+ if (!hit) for (const bd of map.values()) if (bd.files.includes(rel)) { hit = true; break; }
1199
+ if (!hit) for (const imp of (model.byFile[other].imports || [])) if (!(imp.bindings || []).length && importTargets(model, other, model.byFile[other].lang, imp).includes(rel)) { hit = true; break; }
1200
+ if (hit) out.push(other);
1201
+ }
1202
+ return out;
1203
+ }
1204
+
1205
+ function fileCard(model, rel, budget, opts) {
1206
+ const f = model.byFile[rel];
1207
+ const fresh = blobState(model, rel);
1208
+ const items = [];
1209
+ const push = (pri, section, text, data) => items.push({ pri, section, text, data });
1210
+ const tree = opts.format === "tree";
1211
+ const allSyms = (f.symbols || []).filter((s) => s.kind !== "module");
1212
+ push(0, "header", `${rel} (${allSyms.length} symbols) [blob ${String(f.blob).slice(0, 7)} · ${BLOB_LABEL[fresh]}${coverageTag(model, rel)}]`);
1213
+ // E7: ordered by IMPORTANCE, not by raw fan-in. A private helper called from
1214
+ // forty places is not more useful to a reader than the exported entry point
1215
+ // called from three, and a card that leads with the helper buries the answer.
1216
+ const SIG = require("./graph-signals.js");
1217
+ const allRows = allSyms.map((s) => {
1218
+ const fanIn = callersOf(model, s).confident.length;
1219
+ return { s, fanIn, imp: SIG.importance(model, s, fanIn) };
1220
+ });
1221
+ allRows.sort((a, b) => b.imp - a.imp || b.fanIn - a.fanIn || a.s.lines[0] - b.s.lines[0]);
1222
+ // `--offset` pages the SYMBOL rows. Paging is not truncation: `has_more` says
1223
+ // there is a next page, and the budget footer still says what it hid.
1224
+ const offset = Math.max(0, Number(opts.offset) || 0);
1225
+ const rows = allRows.slice(offset);
1226
+ const syms = rows.map((r) => r.s);
1227
+ // Both bodies are built, and the SMALLER one is printed. A column header and
1228
+ // a directory line are FIXED costs: on a three-row card they cost more than
1229
+ // the columns save, which is exactly what the first measurement showed. So
1230
+ // `--format tree` means "use the tree WHEN it is smaller", and the answer says
1231
+ // which one it used. It can never be worse than the prose card.
1232
+ const symRows = rows.map(({ s, fanIn, imp }) => ({
1233
+ prose: ` ${s.kind.padEnd(8)} ${s.qname} :${s.lines[0]}-${s.lines[1]}${s.exported ? " exported" : ""} fan-in ${fanIn}`,
1234
+ tree: ` ${shortKind(s.kind)} ${s.qname} ${s.lines[0]}-${s.lines[1]} ${s.exported ? "E" : "-"} ${fanIn}`,
1235
+ data: { item: "symbol", ...brief(s), fan_in: fanIn, importance: imp },
1236
+ }));
1237
+ const SYM_HEAD = " kind name lines exp fan-in";
1238
+ const useTree =
1239
+ tree && symRows.length > 0 &&
1240
+ SYM_HEAD.length + symRows.reduce((a, r) => a + r.tree.length, 0) < symRows.reduce((a, r) => a + r.prose.length, 0);
1241
+ if (useTree) push(1, "symbols", SYM_HEAD);
1242
+ for (const r of symRows) push(2, "symbols", useTree ? r.tree : r.prose, r.data);
1243
+ const imports = [...new Set((f.imports || []).flatMap((imp) => importTargets(model, rel, f.lang, imp)))];
1244
+ const importers = importersOf(model, rel);
1245
+ if (useTree) {
1246
+ const pre = dirPrefix([...imports, ...importers]);
1247
+ const strip = (a) => a.map((x) => (pre && x.startsWith(pre) ? x.slice(pre.length) : x));
1248
+ if (pre) push(3, "imports", ` dir ${pre}`, null);
1249
+ if (imports.length) push(3, "imports", ` in ${strip(imports).join(" ")}`, { item: "imports", files: imports });
1250
+ if (importers.length) push(3, "importers", ` out ${strip(importers).join(" ")}`, { item: "importers", files: importers });
1251
+ } else {
1252
+ if (imports.length) push(3, "imports", ` imports ${imports.join(", ")}`, { item: "imports", files: imports });
1253
+ if (importers.length) push(3, "importers", ` imported by ${importers.join(", ")}`, { item: "importers", files: importers });
1254
+ }
1255
+ // E7: the tests that reach this file — by CALL (an edge into one of its
1256
+ // symbols from a test file) and by NAME (`x.test.js` beside `x.js`). Both,
1257
+ // because a test that imports a module without naming a symbol is still its
1258
+ // test, and the graph alone would never find it.
1259
+ const fileTests = [
1260
+ ...new Set([
1261
+ ...allSyms.flatMap((sym) => callersOf(model, sym).confident.map((c) => c.sym.file)).filter((x) => TEST_FILE.test(x)),
1262
+ ...importers.filter((x) => TEST_FILE.test(x)),
1263
+ ...SIG.testsByName(model, rel),
1264
+ ]),
1265
+ ].sort();
1266
+ if (fileTests.length) push(4, "tests", ` tests ${fileTests.join(useTree ? " " : ", ")}`, { item: "tests", files: fileTests });
1267
+ const wiki = opts.wikiDocsFor ? opts.wikiDocsFor([rel]) : [];
1268
+ if (wiki.length) push(5, "wiki", ` wiki ${wiki.join(useTree ? " " : ", ")}`, { item: "wiki", docs: wiki });
1269
+ const footer = (used, hidden, source) => {
1270
+ const why = `${hiddenText(hidden)}${sourceNote(source)}`;
1271
+ return why ? ` budget ${used}/${budget} tokens${why}` : "";
1272
+ };
1273
+ // D1 on a FILE card is the file's head — the imports, the exports and the
1274
+ // first declaration, which is what a reader opening an unknown file looks at.
1275
+ const tail = opts.source === undefined ? null : sourceTail(model, rel, 1, Math.min(FILE_HEAD_LINES, sourceLimit(opts.source)), opts.source, fresh);
1276
+ const fitted = fit(items, budget, footer, tail);
1277
+ const kept = items.filter((_, i) => fitted.kept.has(i)).map((it) => it.data).filter(Boolean);
1278
+ const keptSymbols = kept.filter((d) => d.item === "symbol");
1279
+ return {
1280
+ ok: true,
1281
+ state: "found",
1282
+ format: useTree ? "tree" : "prose",
1283
+ offset,
1284
+ has_more: offset + keptSymbols.length < allRows.length,
1285
+ total_symbols: allRows.length,
1286
+ file: rel,
1287
+ blob: fresh,
1288
+ symbols: kept.filter((d) => d.item ==="symbol"),
1289
+ tests: fileTests,
1290
+ imports,
1291
+ importers,
1292
+ wiki,
1293
+ source: tail && fitted.source && fitted.source.kept ? { file: tail.file, from: tail.from, to: tail.from + fitted.source.kept - 1, text: sourceText(tail, fitted.source.kept), cut: fitted.source.cut } : null,
1294
+ hidden: fitted.hidden,
1295
+ budget: { used: fitted.used, max: budget },
1296
+ card: fitted.card,
1297
+ exit: 0,
1298
+ };
1299
+ }
1300
+
1301
+ // ── D2 (v1.8.2 W3): `ctx --for-slice` — the OUTSIDE view ────────────────────
1302
+ // An executor reads every declared file IN FULL before editing it (read-ladder
1303
+ // exception 1). A file card for a declared file therefore repeats what the
1304
+ // executor is about to read anyway — the symbol table, the line ranges, the
1305
+ // fan-in — on every turn of that agent, for the whole run.
1306
+ //
1307
+ // What the executor CANNOT see from inside the file is the outside: who imports
1308
+ // it, who calls its exported symbols from elsewhere, which route reaches it, and
1309
+ // which tests cover it. `--for-slice` prints ONLY that. No symbol table, no
1310
+ // callee tree, no notes.
1311
+ //
1312
+ // It is one block per declared file, always tree format, and the caller rows are
1313
+ // sorted by the IMPORTANCE of the symbol they reach — the exported entry point
1314
+ // before the private helper, the same rule the file card already follows.
1315
+ const SLICE_MAX_TARGETS = 10;
1316
+ const STATE_RANK = { LOCAL: 0, IMPORT: 1, ROUTE: 2, UNIQUE: 3 };
1317
+
1318
+ function sliceBlock(model, rel, budget, opts) {
1319
+ const f = model.byFile[rel];
1320
+ const fresh = blobState(model, rel);
1321
+ const items = [];
1322
+ const push = (pri, section, text, data) => items.push({ pri, section, text, short: text, data });
1323
+ push(0, "header", `${rel} [blob ${String(f.blob).slice(0, 7)} · ${BLOB_LABEL[fresh]}${coverageTag(model, rel)}]`);
1324
+
1325
+ const SIG = require("./graph-signals.js");
1326
+ const allSyms = (f.symbols || []).filter((s) => s.kind !== "module");
1327
+ // A route symbol is named by its own path; the MOUNTED path is the one a
1328
+ // reader outside the file recognises, and it is the one thing the file itself
1329
+ // cannot tell them.
1330
+ const fullRoute = new Map();
1331
+ for (const r of routeTable(model)) if (r.sym.file === rel) fullRoute.set(r.sym.id, `${r.method} ${r.full || r.own}`);
1332
+ // ONE ROW PER CALLING FILE, naming every symbol of this file it reaches. The
1333
+ // first measurement of this view listed one row per (symbol, caller) pair and
1334
+ // came out LARGER than the file card it replaces — six symbols and four
1335
+ // callers is twenty-four rows for four relationships. The outside question is
1336
+ // "who reaches this file, and through what", and that is one row per file.
1337
+ const OUT_NAMES = 8;
1338
+ const byFile = new Map();
1339
+ for (const s of allSyms) {
1340
+ const cs = callersOf(model, s).confident;
1341
+ const imp = SIG.importance(model, s, cs.length);
1342
+ const reaches = fullRoute.get(s.id) || s.name;
1343
+ for (const c of cs) {
1344
+ if (c.sym.file === rel) continue; // inside the file — the executor reads it
1345
+ let row = byFile.get(c.sym.file);
1346
+ if (!row) byFile.set(c.sym.file, (row = { file: c.sym.file, line: c.line, imp, state: c.state, names: new Map() }));
1347
+ if (c.line < row.line) row.line = c.line;
1348
+ if (imp > row.imp) row.imp = imp;
1349
+ // One state per row: the strongest one this file reached the target with.
1350
+ if (STATE_RANK[c.state] < STATE_RANK[row.state]) row.state = c.state;
1351
+ if (!row.names.has(reaches)) row.names.set(reaches, imp);
1352
+ }
1353
+ }
1354
+ const rows = [...byFile.values()]
1355
+ .map((r) => ({ ...r, names: [...r.names.entries()].sort((a, b) => b[1] - a[1]).map((x) => x[0]) }))
1356
+ .sort((a, b) => b.imp - a.imp || a.file.localeCompare(b.file));
1357
+ for (const r of rows) {
1358
+ const shown = r.names.slice(0, OUT_NAMES);
1359
+ push(
1360
+ 2,
1361
+ "callers",
1362
+ ` ${r.file}:${r.line} ${STATE_SHORT[r.state] || r.state} ${shown.join(" ")}${r.names.length > OUT_NAMES ? ` +${r.names.length - OUT_NAMES}` : ""}`,
1363
+ { item: "caller", file: r.file, line: r.line, state: r.state, reaches: r.names }
1364
+ );
1365
+ }
1366
+
1367
+ const importers = importersOf(model, rel);
1368
+ // Only the importers that call NOTHING — the ones the caller rows already
1369
+ // name are the same fact printed twice.
1370
+ const quiet = importers.filter((x) => !byFile.has(x));
1371
+ if (quiet.length) {
1372
+ const pre = dirPrefix(quiet);
1373
+ if (pre) push(1, "importers", ` dir ${pre}`, null);
1374
+ push(1, "importers", ` imp ${quiet.map((x) => (pre && x.startsWith(pre) ? x.slice(pre.length) : x)).join(" ")} (imports it, calls nothing)`, { item: "importers", files: quiet });
1375
+ }
1376
+
1377
+ // The routes this file answers, with the mount chain already folded in — the
1378
+ // one fact a reader of the file itself cannot get without opening the mount.
1379
+ // A route a caller row already named is the same fact twice, so only the ones
1380
+ // NOTHING reaches are printed — which is also the more interesting half.
1381
+ const reached = new Set(rows.flatMap((r) => r.names));
1382
+ const routes = routeTable(model)
1383
+ .filter((r) => r.sym.file === rel)
1384
+ .map((r) => `${r.method} ${r.full || r.own}${r.mounted === "known" || r.mounted === "none" ? "" : " (mount ambiguous)"}`);
1385
+ const unreached = routes.filter((x) => !reached.has(x));
1386
+ if (unreached.length) push(1, "routes", ` rt ${unreached.join(" ")} (no caller in the graph)`, { item: "routes", routes: unreached });
1387
+
1388
+ const shownFiles = new Set(rows.map((r) => r.file));
1389
+ const allTests = [
1390
+ ...new Set([...rows.map((r) => r.file).filter((x) => TEST_FILE.test(x)), ...importers.filter((x) => TEST_FILE.test(x)), ...SIG.testsByName(model, rel)]),
1391
+ ].sort();
1392
+ const moreTests = allTests.filter((x) => !shownFiles.has(x));
1393
+ if (moreTests.length) push(1, "tests", ` tst ${moreTests.join(" ")}`, { item: "tests", files: moreTests });
1394
+
1395
+ const footer = (used, hidden) => (hiddenText(hidden) ? ` budget ${used}/${budget} tokens${hiddenText(hidden)}` : "");
1396
+ const fitted = fit(items, budget, footer);
1397
+ const kept = items.filter((_, i) => fitted.kept.has(i)).map((it) => it.data).filter(Boolean);
1398
+ return {
1399
+ file: rel,
1400
+ blob: fresh,
1401
+ callers: kept.filter((d) => d.item === "caller"),
1402
+ total_callers: rows.length,
1403
+ importers,
1404
+ routes,
1405
+ tests: allTests,
1406
+ names: [...new Set([...rows.flatMap((r) => r.names), ...routes])],
1407
+ hidden: fitted.hidden,
1408
+ used: fitted.used,
1409
+ card: fitted.card,
1410
+ };
1411
+ }
1412
+
1413
+ function forSlice(model, files, opts) {
1414
+ const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1200));
1415
+ const all = [...new Set(files.map(norm))];
1416
+ const known = all.filter((r) => model.fileSet.has(r)).slice(0, SLICE_MAX_TARGETS);
1417
+ const skipped = all.filter((r) => model.fileSet.has(r)).slice(SLICE_MAX_TARGETS);
1418
+ const missing = all.filter((r) => !model.fileSet.has(r));
1419
+ if (!known.length) return { ok: false, state: "not-found", missing, exit: 4 };
1420
+ const each = Math.max(100, Math.floor(budget / known.length));
1421
+ const blocks = known.map((rel) => sliceBlock(model, rel, each, opts));
1422
+ const body = blocks.map((b) => b.card).join("\n\n");
1423
+ const note = missing.length ? `\n not in the graph: ${missing.join(", ")}` : "";
1424
+ // The column header is named ONCE for the whole answer, not once per block —
1425
+ // the tree format's own rule, applied one level up.
1426
+ return {
1427
+ ok: true,
1428
+ state: "found",
1429
+ view: "for-slice",
1430
+ format: "tree",
1431
+ files: known,
1432
+ missing,
1433
+ skipped,
1434
+ blocks: blocks.map((b) => ({ file: b.file, blob: b.blob, callers: b.callers, total_callers: b.total_callers, importers: b.importers, routes: b.routes, tests: b.tests, hidden: b.hidden })),
1435
+ names: [...new Set(blocks.flatMap((b) => b.names))],
1436
+ budget: { used: blocks.reduce((a, b) => a + b.used, 0), max: budget },
1437
+ card: `graph outside view — read each file itself before you edit it · rows are from:line state reaches\n${body}${note}`,
1438
+ exit: 0,
1439
+ };
1440
+ }
1441
+
1442
+
1443
+ // ── impact ──────────────────────────────────────────────────────────────────
1444
+ function impact(model, files, opts) {
1445
+ const depth = Math.max(1, Math.min(6, Number(opts.depth) || 3));
1446
+ const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1500));
1447
+ const rels = [...new Set(files.map(norm))].filter((f) => model.fileSet.has(f));
1448
+ const missing = files.map(norm).filter((f) => !model.fileSet.has(f));
1449
+ if (!rels.length) return { ok: false, state: "not-found", missing, exit: 4 };
1450
+ const targets = rels.flatMap((r) => (model.byFile[r].symbols || []).filter((s) => s.kind !== "module"));
1451
+ const inTargets = new Set(targets.map((s) => s.id));
1452
+ const seen = new Map();
1453
+ let frontier = targets;
1454
+ let maybe = 0;
1455
+ for (let d = 1; d <= depth && frontier.length; d++) {
1456
+ const next = [];
1457
+ for (const t of frontier) {
1458
+ const c = callersOf(model, t);
1459
+ if (d === 1) maybe += c.maybe;
1460
+ for (const row of c.confident) {
1461
+ if (inTargets.has(row.sym.id) || seen.has(row.sym.id)) continue;
1462
+ seen.set(row.sym.id, { sym: row.sym, depth: d, line: row.line, state: row.state, via: row.url ? `${t.qname} ← ${row.url}` : t.qname, ...(row.url ? { url: row.url } : {}) });
1463
+ if (row.sym.kind !== "module") next.push(row.sym);
1464
+ }
1465
+ }
1466
+ frontier = next;
1467
+ }
1468
+ const hits = [...seen.values()].sort(
1469
+ (a, b) => a.depth - b.depth || a.sym.file.localeCompare(b.sym.file) || a.line - b.line || String(a.sym.id).localeCompare(String(b.sym.id))
1470
+ );
1471
+ const importerTests = rels.flatMap((r) => importersOf(model, r)).filter((f) => TEST_FILE.test(f));
1472
+ const SIG = require("./graph-signals.js");
1473
+ const tests = [
1474
+ ...new Set([
1475
+ ...hits.filter((h) => TEST_FILE.test(h.sym.file)).map((h) => h.sym.file),
1476
+ ...importerTests,
1477
+ ...rels.flatMap((r) => SIG.testsByName(model, r)),
1478
+ ]),
1479
+ ].sort();
1480
+ const touched = [...new Set([...rels, ...hits.map((h) => h.sym.file)])];
1481
+ const wiki = opts.wikiDocsFor ? opts.wikiDocsFor(touched) : [];
1482
+
1483
+ const tree = opts.format === "tree";
1484
+ const offset = Math.max(0, Number(opts.offset) || 0);
1485
+ const page = hits.slice(offset);
1486
+ const items = [];
1487
+ const push = (pri, section, text, data) => items.push({ pri, section, text, data });
1488
+ push(0, "header", `impact of ${rels.join(", ")} — ${targets.length} symbols · depth ${depth}`);
1489
+ // The same never-worse rule as the file card. The fixed cost here is a column
1490
+ // header PLUS a directory line, so a two-row impact card stays prose.
1491
+ const pre = dirPrefix(page.map((h) => h.sym.file));
1492
+ const CALL_HEAD = " d qname file:line state via";
1493
+ const callRows = page.map((h) => {
1494
+ const file = pre && h.sym.file.startsWith(pre) ? h.sym.file.slice(pre.length) : h.sym.file;
1495
+ return {
1496
+ pri: h.depth,
1497
+ prose: ` d${h.depth} ${h.sym.qname} ${where(h.sym, h.line)} ${h.state} (via ${h.via})`,
1498
+ tree: ` ${h.depth} ${h.sym.qname} ${file}:${h.line || h.sym.lines[0]} ${STATE_SHORT[h.state] || h.state} ${h.via}`,
1499
+ data: { item: "caller", depth: h.depth, ...brief(h.sym), line: h.line, state: h.state, via: h.via, ...(h.url ? { url: h.url } : {}) },
1500
+ };
1501
+ });
1502
+ const fixed = CALL_HEAD.length + (pre ? pre.length + 6 : 0);
1503
+ const useTree =
1504
+ tree && callRows.length > 0 &&
1505
+ fixed + callRows.reduce((a, r) => a + r.tree.length, 0) < callRows.reduce((a, r) => a + r.prose.length, 0);
1506
+ if (useTree) {
1507
+ if (pre) push(1, "callers", ` dir ${pre}`, null);
1508
+ push(1, "callers", CALL_HEAD);
1509
+ }
1510
+ for (const r of callRows) push(r.pri, "callers", useTree ? r.tree : r.prose, r.data);
1511
+ const j = useTree ? " " : ", ";
1512
+ if (tests.length) push(1, "tests", ` tests${useTree ? "" : " "} ${tests.join(j)}`, { item: "tests" });
1513
+ if (wiki.length) push(2, "wiki", ` wiki${useTree ? "" : " "} ${wiki.join(j)}`, { item: "wiki" });
1514
+ if (maybe) push(2, "maybe", useTree ? ` maybe ${maybe} ambiguous, not followed` : ` maybe ${maybe} AMBIGUOUS caller(s) not followed`, { item: "maybe" });
1515
+ if (missing.length) push(1, "missing", useTree ? ` absent ${missing.join(" ")}` : ` not in the graph: ${missing.join(", ")}`, { item: "missing" });
1516
+ const footer = (used, hidden) => (hiddenText(hidden) ? ` budget ${used}/${budget} tokens${hiddenText(hidden)}` : "");
1517
+ const f = fit(items, budget, footer);
1518
+ const kept = items.filter((_, i) => f.kept.has(i)).map((it) => it.data).filter(Boolean);
1519
+ const keptCallers = kept.filter((d) => d.item === "caller");
1520
+ return {
1521
+ ok: true,
1522
+ state: "found",
1523
+ format: useTree ? "tree" : "prose",
1524
+ offset,
1525
+ has_more: offset + keptCallers.length < hits.length,
1526
+ files: rels,
1527
+ missing,
1528
+ symbols: targets.length,
1529
+ callers: kept.filter((d) => d.item ==="caller"),
1530
+ total_callers: hits.length,
1531
+ maybe_callers: maybe,
1532
+ tests,
1533
+ wiki,
1534
+ hidden: f.hidden,
1535
+ budget: { used: f.used, max: budget },
1536
+ card: f.card,
1537
+ exit: 0,
1538
+ };
1539
+ }
1540
+
1541
+ // ── path ────────────────────────────────────────────────────────────────────
1542
+ function pathBetween(model, fromQ, toQ) {
1543
+ const a = findTarget(model, fromQ);
1544
+ const b = findTarget(model, toQ);
1545
+ if (a.kind !== "symbol" || b.kind !== "symbol") {
1546
+ return { ok: false, state: "not-found", from: a.kind, to: b.kind, candidates: [...(a.candidates || []), ...(b.candidates || [])].map(brief), exit: 4 };
1547
+ }
1548
+ const prev = new Map([[a.sym.id, null]]);
1549
+ let frontier = [a.sym];
1550
+ for (let d = 0; d < 8 && frontier.length && !prev.has(b.sym.id); d++) {
1551
+ const next = [];
1552
+ for (const s of frontier) {
1553
+ for (const { call, res } of calleesOf(model, s)) {
1554
+ if (!CONFIDENT.has(res.state) || prev.has(res.target)) continue;
1555
+ const t = model.symbols.get(res.target);
1556
+ if (!t) continue;
1557
+ prev.set(t.id, { from: s, line: call.line, state: res.state });
1558
+ next.push(t);
1559
+ }
1560
+ }
1561
+ frontier = next;
1562
+ }
1563
+ if (!prev.has(b.sym.id)) return { ok: true, state: "none", from: brief(a.sym), to: brief(b.sym), exit: 4, card: `no confident call path from ${a.sym.qname} to ${b.sym.qname} (AMBIGUOUS edges are not followed)` };
1564
+ const chain = [];
1565
+ for (let id = b.sym.id; id; ) {
1566
+ const p = prev.get(id);
1567
+ chain.unshift({ sym: model.symbols.get(id), step: p });
1568
+ id = p ? p.from.id : null;
1569
+ }
1570
+ const lines = chain.map((c, i) => (i === 0 ? `${c.sym.qname} ${where(c.sym)}` : ` → ${c.sym.qname} ${where(c.sym)} ${c.step.state} (called at ${where(c.step.from, c.step.line)})`));
1571
+ return {
1572
+ ok: true,
1573
+ state: "found",
1574
+ hops: chain.length - 1,
1575
+ chain: chain.map((c) => ({ ...brief(c.sym), state: c.step ? c.step.state : null, called_at: c.step ? where(c.step.from, c.step.line) : null })),
1576
+ card: lines.join("\n"),
1577
+ exit: 0,
1578
+ };
1579
+ }
1580
+
1581
+ // ── D4 (v1.8.2 W7): `orc graph map` — the ranked repository map ─────────────
1582
+ // The planner's FIRST question, answered without a single Read: which files
1583
+ // are the landmarks here, and — with `--focus` — which ones surround the thing
1584
+ // the request already names.
1585
+ //
1586
+ // The rank comes from `bin/graph-map.js`. This half owns what a row says and
1587
+ // how the list is cut, which is the same contract every other card keeps: rows
1588
+ // in a fixed order, the budget cuts from the BOTTOM, and a card that hid
1589
+ // something says so.
1590
+ const MAP_MAX_ROWS = 200;
1591
+ const MAP_FOCUS_MASS = 100;
1592
+ const MAP_CAVEAT = " rank is a HINT about where to look first — it is never proof a file matters to this change";
1593
+
1594
+ function mapRow(model, rel, fan, pre) {
1595
+ const SIG = require("./graph-signals.js");
1596
+ const syms = (model.byFile[rel].symbols || []).filter((s) => s.kind !== "module");
1597
+ const ranked = syms
1598
+ .map((s) => ({ s, imp: SIG.importance(model, s, fan.get(s.id) || 0) }))
1599
+ .sort((a, b) => b.imp - a.imp || a.s.lines[0] - b.s.lines[0]);
1600
+ // Chosen by importance, then PRINTED in line order: a file is read top to
1601
+ // bottom, and a list that jumps about costs the reader more than it saves.
1602
+ const M = require("./graph-map.js");
1603
+ const shown = ranked.slice(0, M.SYMS_PER_FILE).sort((a, b) => a.s.lines[0] - b.s.lines[0]);
1604
+ const rest = ranked.length - shown.length;
1605
+ const name = pre && rel.startsWith(pre) ? rel.slice(pre.length) : rel;
1606
+ const parts = shown.map(({ s }) => `${s.qname}:${s.lines[0]}-${s.lines[1]}${s.exported ? " E" : ""}`);
1607
+ if (rest > 0) parts.push(`+${rest}`);
1608
+ return {
1609
+ text: ` ${name}${parts.length ? " " + parts.join(" · ") : ""}`,
1610
+ data: { item: "file", file: rel, symbols: shown.map(({ s }) => ({ qname: s.qname, kind: s.kind, lines: s.lines, exported: !!s.exported })), more: Math.max(0, rest) },
1611
+ };
1612
+ }
1613
+
1614
+ // A focus token is a FILE when the graph holds that path, and a NAME otherwise.
1615
+ // A name contributes every file that DEFINES it — which is how "focus on
1616
+ // `createOrder`" reaches the file a request never spelled out.
1617
+ function focusFiles(model, tokens) {
1618
+ const files = [];
1619
+ const missing = [];
1620
+ for (const t of tokens) {
1621
+ const rel = norm(t).replace(/:\d+$/, "");
1622
+ if (model.fileSet.has(rel)) {
1623
+ files.push(rel);
1624
+ continue;
1625
+ }
1626
+ const defs = model.byName.get(String(t).split(".").pop()) || [];
1627
+ if (defs.length) for (const s of defs) files.push(s.file);
1628
+ else missing.push(t);
1629
+ }
1630
+ return { files: [...new Set(files)], missing };
1631
+ }
1632
+
1633
+ function graphMap(claudeDir, model, opts) {
1634
+ const M = require("./graph-map.js");
1635
+ const budget = Math.max(100, Math.min(8000, Number(opts.budget) || 1200));
1636
+ const { files, edges, base, cached } = M.edgesFor(claudeDir, model);
1637
+ const idOf = new Map(files.map((f, i) => [f, i]));
1638
+ const tokens = (opts.focus || []).filter(Boolean);
1639
+ const foc = tokens.length ? focusFiles(model, tokens) : { files: [], missing: [] };
1640
+
1641
+ let rank;
1642
+ if (foc.files.length) {
1643
+ // W7-focus-baseline: EVERY file keeps a baseline share of 1 and the focus
1644
+ // gets `MAP_FOCUS_MASS / n` ON TOP of it. Giving the focus the whole vector
1645
+ // — the literal reading of "focus files get 100/n" — leaves every file the
1646
+ // focus does not reach at exactly zero, and a hundred tied zeroes sort
1647
+ // alphabetically. That turns the rest of the map into a directory listing,
1648
+ // which is the one thing it must never be.
1649
+ //
1650
+ // The mass is FIXED, so a fifth focus file dilutes the other four rather
1651
+ // than making the focus louder overall. That is Aider's rule and the reason
1652
+ // is the same: a focus list is a hint about where to start, not a filter.
1653
+ const pers = new Float64Array(files.length).fill(1);
1654
+ const each = MAP_FOCUS_MASS / foc.files.length;
1655
+ for (const f of foc.files) {
1656
+ const i = idOf.get(f);
1657
+ if (i !== undefined) pers[i] += each;
1658
+ }
1659
+ rank = M.pageRank(files.length, edges, pers);
1660
+ } else rank = base || M.pageRank(files.length, edges, null);
1661
+
1662
+ const scored = files
1663
+ .map((rel, i) => ({ rel, score: rank[i] * M.multiplier(model, rel) }))
1664
+ .sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
1665
+ const candidates = scored.slice(0, MAP_MAX_ROWS);
1666
+ const fan = M.fanCounts(model);
1667
+ const pre = dirPrefix(candidates.map((c) => c.rel));
1668
+ const rows = candidates.map((c, i) => {
1669
+ const row = mapRow(model, c.rel, fan, pre);
1670
+ return { text: row.text, data: { ...row.data, rank: i + 1 } };
1671
+ });
1672
+
1673
+ const focusNote = foc.files.length ? ` · focus ${foc.files.length} file(s)` : "";
1674
+ const footer = (used, hidden) => (hiddenText(hidden) ? ` budget ${used}/${budget} tokens${hiddenText(hidden)}` : "");
1675
+ // W7-map-prefix: the map is cut to a PREFIX of the ranking, never to whatever
1676
+ // happens to fit. `fit()` is greedy — after skipping one long row it will
1677
+ // still take a shorter row further down — which is right for a card whose
1678
+ // sections are independent and wrong for a list whose whole meaning is the
1679
+ // ORDER. A reader who sees rank 10 present and rank 7 missing cannot use the
1680
+ // order for anything.
1681
+ //
1682
+ // So the row count is chosen by binary search, exactly as Aider chooses its
1683
+ // map size, and `fit` is then handed a list it can keep whole. Its budget
1684
+ // arithmetic and its footer reserve are unchanged and still do the accounting.
1685
+ const itemsFor = (k) => {
1686
+ const items = [];
1687
+ items.push({ pri: 0, section: "header", text: `graph map (gen ${model.meta.generation}) — ${files.length} file(s) by rank${focusNote}`, data: null });
1688
+ if (pre) items.push({ pri: 0, section: "header", text: ` dir ${pre}`, data: null });
1689
+ if (foc.missing.length) items.push({ pri: 0, section: "focus", text: ` focus not in the graph: ${foc.missing.join(", ")}`, data: { item: "focus-missing" } });
1690
+ for (let i = 0; i < k; i++) items.push({ pri: 1, section: "files", text: rows[i].text, data: rows[i].data });
1691
+ // pri 0: never cut, and CHARGED to the budget like everything else. It is
1692
+ // the map's whole equivalent of "the graph is a LOCATOR" — a card that
1693
+ // dropped it under budget pressure would be a ranking with no warning
1694
+ // label on it.
1695
+ items.push({ pri: 0, section: "caveat", text: MAP_CAVEAT, data: null });
1696
+ return items;
1697
+ };
1698
+ const fits = (k) => {
1699
+ const f = fit(itemsFor(k), budget, footer);
1700
+ return !f.hidden.files;
1701
+ };
1702
+ let lo = 0;
1703
+ let hi = rows.length;
1704
+ if (!fits(hi)) {
1705
+ while (lo < hi) {
1706
+ const mid = (lo + hi + 1) >> 1;
1707
+ if (fits(mid)) lo = mid;
1708
+ else hi = mid - 1;
1709
+ }
1710
+ } else lo = hi;
1711
+
1712
+ const shownRows = rows.slice(0, lo);
1713
+ const items = itemsFor(lo);
1714
+ // The footer has to report what the PREFIX left out, which `fit` cannot know
1715
+ // once the rows are gone. A map that silently stops at rank 12 is a map that
1716
+ // claims the repository has twelve files.
1717
+ const cut = files.length - shownRows.length;
1718
+ const footerFinal = (used) => (cut > 0 ? ` budget ${used}/${budget} tokens · hidden: ${cut} files (raise --budget)` : "");
1719
+ const f = fit(items, budget, footerFinal);
1720
+ return {
1721
+ ok: true,
1722
+ state: "found",
1723
+ focus: foc.files,
1724
+ focus_missing: foc.missing,
1725
+ cached_map: !!cached,
1726
+ total_files: files.length,
1727
+ edges: edges.length,
1728
+ files: shownRows.map((r) => r.data),
1729
+ shown: shownRows.length,
1730
+ has_more: cut > 0,
1731
+ hidden: cut > 0 ? { files: cut } : {},
1732
+ budget: { used: f.used, max: budget },
1733
+ card: f.card,
1734
+ generation: model.meta.generation,
1735
+ exit: 0,
1736
+ };
1737
+ }
1738
+
1739
+ // EW2, for the resolution cache only: which files this one's import specifiers
1740
+ // actually reach, and whether any RELATIVE specifier reaches nothing. A file
1741
+ // added later can only ever satisfy a relative specifier, so that flag is the
1742
+ // complete set of files an addition can move.
1743
+ function importsOf(model, rel) {
1744
+ const f = model.byFile[rel] || {};
1745
+ const files = new Set();
1746
+ let unresolvedRelative = false;
1747
+ for (const imp of f.imports || []) {
1748
+ const t = importTargets(model, rel, f.lang, imp);
1749
+ for (const x of t) files.add(x);
1750
+ if (!t.length && /^\.{1,2}\//.test(String(imp.from || ""))) unresolvedRelative = true;
1751
+ }
1752
+ return { files: [...files].sort(), unresolvedRelative };
1753
+ }
1754
+
1755
+ // K2 (v1.8.2 W4b): how many lines in this repository NAME this symbol — the
1756
+ // exact size of the Grep the card replaced. The graph knows it for free: every
1757
+ // call site is already in the call index, and every definition in `byName`.
1758
+ function nameHits(model, name) {
1759
+ // S1 (v1.8.2 W8): the sharded model holds a dozen files, not the repository,
1760
+ // so it CANNOT count the lines a grep would have returned. It says so rather
1761
+ // than returning a confident zero — K2's whole rule is that the
1762
+ // counterfactual is written down, and a made-up basis is worse than none.
1763
+ if (model.fast) return null;
1764
+ const bare = String(name || "").split(".").pop();
1765
+ const calls = (callIndex(model).get(bare) || []).length;
1766
+ const defs = (model.byName.get(bare) || []).length;
1767
+ return calls + defs;
1768
+ }
1769
+
1770
+ module.exports = { loadModel, resolveCall, resolveUrl, routeTable, urlIndex, findTarget, callersOf, nameHits, warmBlobs, ctx, forSlice, impact, graphMap, pathBetween, importsOf, TEST_FILE, SOURCE_DEFAULT, SOURCE_CAP };