@azure-id/orc 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/README.md +48 -23
  3. package/bin/build-agents.js +207 -205
  4. package/bin/cli.js +48188 -47417
  5. package/bin/fix.js +21 -18
  6. package/bin/graph-extract.js +314 -6
  7. package/bin/graph-gain.js +83 -2
  8. package/bin/graph-map.js +23 -1
  9. package/bin/graph-notes.js +1 -1
  10. package/bin/graph-query.js +1362 -37
  11. package/bin/graph-resolve.js +1 -1
  12. package/bin/graph-shard.js +29 -6
  13. package/bin/graph-signals.js +5 -3
  14. package/bin/graph.js +7 -3
  15. package/bin/habit.js +1 -1
  16. package/bin/pricing.json +8 -1
  17. package/bin/trace-write.js +40 -11
  18. package/bin/verify-contracts.js +5661 -5552
  19. package/bin/verify-package.js +716 -716
  20. package/bin/webui/api.js +8 -0
  21. package/bin/webui/fixtures/extra.js +2036 -2036
  22. package/bin/webui/fixtures/flow.js +1 -1
  23. package/bin/webui/fixtures/index.js +2 -2
  24. package/bin/webui/fixtures/knowledge.js +8 -1
  25. package/bin/webui/fixtures/pact.js +112 -111
  26. package/bin/webui/fixtures/stats.js +2 -2
  27. package/bin/webui/i18n/en/knowledge.json +2 -0
  28. package/bin/webui/i18n/id/knowledge.json +2 -0
  29. package/bin/webui/js/panels/knowledge.js +17 -0
  30. package/guides/code-graph-benefit.md +116 -0
  31. package/guides/configuration.md +150 -0
  32. package/guides/documents.md +262 -0
  33. package/guides/extra-models.md +448 -0
  34. package/guides/habits-and-gotchas.md +152 -0
  35. package/guides/knowledge-reads.md +190 -0
  36. package/guides/model-selection.md +186 -0
  37. package/guides/rules.md +305 -0
  38. package/guides/status-line.md +344 -0
  39. package/mock-run/orc-cli.md +3 -2
  40. package/mock-run/orc-doc.md +452 -448
  41. package/mock-run/orc.md +157 -157
  42. package/package.json +2 -1
  43. package/templates/agents/MODEL-MAPPING.md +176 -176
  44. package/templates/agents/{orc-executor-haiku-4-5.md → orc-executor-haiku-5-high.md} +21 -8
  45. package/templates/agents/orc-executor-opus-4-7-high.md +17 -5
  46. package/templates/agents/orc-executor-opus-4-7-med.md +17 -5
  47. package/templates/agents/orc-executor-opus-4-8-high.md +17 -5
  48. package/templates/agents/orc-executor-opus-5-high.md +17 -5
  49. package/templates/agents/orc-executor-opus-5-low.md +17 -5
  50. package/templates/agents/orc-executor-opus-5-med.md +17 -5
  51. package/templates/agents/orc-executor-sonnet-4-6-high.md +17 -5
  52. package/templates/agents/orc-executor-sonnet-5-high.md +17 -5
  53. package/templates/agents/orc-executor-sonnet-5-low.md +17 -5
  54. package/templates/agents/orc-executor-sonnet-5-med.md +17 -5
  55. package/templates/agents/{orc-graph-noter-sonnet-4-6-med.md → orc-graph-noter-haiku-5-high.md} +4 -4
  56. package/templates/agents/orc-planner-mini-opus-5-med.md +3 -1
  57. package/templates/agents/orc-planner-mini-sonnet-5-high.md +3 -1
  58. package/templates/agents/orc-planner-opus-5-med.md +3 -1
  59. package/templates/agents/{orc-trace-writer-haiku-4-5.md → orc-trace-writer-haiku-5-high.md} +4 -3
  60. package/templates/commands/orc-doc.md +128 -128
  61. package/templates/commands/orc-export.md +51 -46
  62. package/templates/hooks/README.md +8 -3
  63. package/templates/hooks/orc-effort-guard.js +105 -27
  64. package/templates/hooks/orc-graph-hook.js +252 -4
  65. package/templates/hooks/orc-statusline.js +13 -6
  66. package/templates/hooks/orc-trace.js +1 -1
  67. package/templates/skills/_shared/README.md +2 -2
  68. package/templates/skills/_shared/code-graph.md +185 -251
  69. package/templates/skills/_shared/extra-dispatch.md +1331 -1331
  70. package/templates/skills/_shared/lane-contract.md +7 -3
  71. package/templates/skills/_shared/opus5-only.md +137 -137
  72. package/templates/skills/_shared/phases/execution.md +13 -11
  73. package/templates/skills/_shared/phases/planning.md +27 -9
  74. package/templates/skills/_shared/phases/preflight.md +3 -3
  75. package/templates/skills/_shared/phases/review.md +4 -3
  76. package/templates/skills/_shared/phases/ship.md +7 -10
  77. package/templates/skills/_shared/phases/summary.md +2 -3
  78. package/templates/skills/_shared/phases/trace-verbs.md +21 -16
  79. package/templates/skills/_shared/phases/trace.md +3 -3
  80. package/templates/skills/_shared/phases/wiki-consult.md +2 -2
  81. package/templates/skills/_shared/pr-templates.md +108 -106
  82. package/templates/skills/_shared/read-ladder.md +8 -2
  83. package/templates/skills/_shared/return-validation.md +10 -6
  84. package/templates/skills/_shared/review-slice.md +1 -1
  85. package/templates/skills/_shared/stack-plan.md +135 -135
  86. package/templates/skills/_shared/wait.md +14 -2
  87. package/templates/skills/context-combiner/SKILL.md +2 -2
  88. package/templates/skills/orc/SKILL.md +227 -227
  89. package/templates/skills/orc/config.md +2 -2
  90. package/templates/skills/orc/examples/full-run-mock.md +74 -74
  91. package/templates/skills/orc/schemas/planning-output.md +3 -0
  92. package/templates/skills/orc-aftermath/SKILL.md +15 -15
  93. package/templates/skills/orc-analyze/SKILL.md +2 -2
  94. package/templates/skills/orc-analyze-mini/SKILL.md +2 -2
  95. package/templates/skills/orc-boundary/SKILL.md +15 -15
  96. package/templates/skills/orc-boundary/references/card.md +81 -78
  97. package/templates/skills/orc-boundary/references/gate.md +113 -113
  98. package/templates/skills/orc-brainstorm/SKILL.md +15 -15
  99. package/templates/skills/orc-budget/SKILL.md +15 -15
  100. package/templates/skills/orc-challenge/README.md +1 -1
  101. package/templates/skills/orc-challenge/SKILL.md +16 -16
  102. package/templates/skills/orc-challenge/examples/council-full-roster.md +1 -1
  103. package/templates/skills/orc-claude/SKILL.md +4 -4
  104. package/templates/skills/orc-claude/examples/claude-run-mock.md +1 -1
  105. package/templates/skills/orc-diy/SKILL.md +2 -2
  106. package/templates/skills/orc-diy/references/blocks/wiki.md +26 -26
  107. package/templates/skills/orc-diy/references/flow-schema.md +2 -2
  108. package/templates/skills/orc-doc/README.md +2 -2
  109. package/templates/skills/orc-doc/SKILL.md +501 -490
  110. package/templates/skills/orc-doc/examples/orc-doc-prd-run.md +12 -1
  111. package/templates/skills/orc-doc/references/chunking.md +6 -4
  112. package/templates/skills/orc-doc/references/gates.md +311 -311
  113. package/templates/skills/orc-doc/references/resume-protocol.md +229 -228
  114. package/templates/skills/orc-explain/SKILL.md +88 -88
  115. package/templates/skills/orc-export/SKILL.md +190 -187
  116. package/templates/skills/orc-fast/SKILL.md +213 -210
  117. package/templates/skills/orc-grill/SKILL.md +245 -245
  118. package/templates/skills/orc-handoff/SKILL.md +11 -11
  119. package/templates/skills/orc-learn/SKILL.md +13 -13
  120. package/templates/skills/orc-learn/examples/learn-run-mock.md +2 -2
  121. package/templates/skills/orc-mini/SKILL.md +12 -12
  122. package/templates/skills/orc-mini/examples/mini-run-mock.md +4 -5
  123. package/templates/skills/orc-pact/SKILL.md +15 -15
  124. package/templates/skills/orc-pact/references/gate.md +70 -70
  125. package/templates/skills/orc-pattern/SKILL.md +3 -3
  126. package/templates/skills/orc-poly/SKILL.md +4 -4
  127. package/templates/skills/orc-poly/examples/poly-run-mock.md +51 -51
  128. package/templates/skills/orc-pr-driver/SKILL.md +4 -3
  129. package/templates/skills/orc-pr-driver/references/orc-run-split.md +108 -99
  130. package/templates/skills/orc-pr-setup/SKILL.md +2 -2
  131. package/templates/skills/orc-quick/README.md +535 -536
  132. package/templates/skills/orc-quick/SKILL.md +29 -32
  133. package/templates/skills/orc-quick/references/context-doc.md +145 -145
  134. package/templates/skills/orc-quick/references/dispatch-gate.md +220 -220
  135. package/templates/skills/orc-quick/references/gh-mode.md +2 -1
  136. package/templates/skills/orc-quick/references/look.md +34 -12
  137. package/templates/skills/orc-retro/SKILL.md +2 -2
  138. package/templates/skills/orc-retro/examples/retro-mock.md +2 -2
  139. package/templates/skills/orc-route/SKILL.md +2 -2
  140. package/templates/skills/orc-test/SKILL.md +6 -3
  141. package/templates/skills/orc-verify/SKILL.md +2 -2
  142. package/templates/skills/orc-wait/SKILL.md +10 -2
  143. package/templates/skills/orc-wiki/SKILL.md +4 -4
  144. package/templates/skills/orc-wiki/references/pattern-prewarm.md +19 -19
  145. package/templates/agents/orc-executor-sonnet-4-6-med.md +0 -155
@@ -133,12 +133,133 @@ function filesInDir(model, dir, ext) {
133
133
  return (model.dirs.get(dir) || []).filter((f) => f.endsWith(ext));
134
134
  }
135
135
 
136
+ // v2.2.0 (D1): the `@/` alias. A SPA whose code lives under `client/src/`
137
+ // is not served by "`@/` means `src/`". The aliases a project DECLARES win —
138
+ // jsconfig/tsconfig `paths`, a bundler `alias`, a jest `moduleNameMapper` —
139
+ // read from disk once per directory per process (never evaluated). Then the
140
+ // nearest package `src/`, then the old root `src/`.
141
+ const ALIAS_JSON = ["jsconfig.json", "tsconfig.json"];
142
+ const ALIAS_BUNDLER = ["vue.config.js", "webpack.config.js", "vite.config.js", "vite.config.ts", "vite.config.mjs"];
143
+ const ALIAS_RX_RESOLVE = /['"]?(@[\w-]*|~)['"]?\s*:\s*(?:path\.)?(?:resolve|join)\(\s*__dirname\s*,\s*['"]([^'"]+)['"]\s*\)/g;
144
+ const ALIAS_RX_URL = /['"]?(@[\w-]*|~)['"]?\s*:\s*fileURLToPath\(\s*new\s+URL\(\s*['"]([^'"]+)['"]/g;
145
+ const ALIAS_RX_JEST = /['"]\^(@[\w-]*|~)\/\(\.\*\)\$['"]\s*:\s*['"]<rootDir>\/([^'"]*?)\/?\$1['"]/g;
146
+
147
+ const cleanRel = (p) => {
148
+ const n = posix.normalize(p).replace(/^\.\//, "");
149
+ return n === "." ? "" : n;
150
+ };
151
+
152
+ function readText(file) {
153
+ try {
154
+ return fs.readFileSync(file, "utf8");
155
+ } catch (_) {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ function stripJsonc(t) {
161
+ return t
162
+ .replace(/\/\*[\s\S]*?\*\//g, "")
163
+ .replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
164
+ .replace(/,(\s*[}\]])/g, "$1");
165
+ }
166
+
167
+ // One table per directory: [{ prefix, target, exact }]. Built once per
168
+ // directory per process; a cache hit makes no fs call.
169
+ function aliasTable(model, dir) {
170
+ if (!model.aliasTables) model.aliasTables = new Map();
171
+ if (model.aliasTables.has(dir)) return model.aliasTables.get(dir);
172
+ const out = [];
173
+ const at = (name) => path.join(model.root || "", dir, name);
174
+ for (const name of ALIAS_JSON) {
175
+ const t = readText(at(name));
176
+ if (t === null) continue;
177
+ let j = null;
178
+ try {
179
+ j = JSON.parse(stripJsonc(t));
180
+ } catch (_) {
181
+ j = null;
182
+ }
183
+ const co = (j && j.compilerOptions) || {};
184
+ const baseUrl = typeof co.baseUrl === "string" ? co.baseUrl : ".";
185
+ for (const [k, v] of Object.entries(co.paths || {})) {
186
+ const first = Array.isArray(v) ? v[0] : v;
187
+ if (typeof first !== "string") continue;
188
+ const target = cleanRel(posix.join(dir, baseUrl, first.replace(/\*$/, "")));
189
+ if (k.endsWith("*")) out.push({ prefix: k.slice(0, -1), target, exact: false });
190
+ else out.push({ prefix: k, target, exact: true });
191
+ }
192
+ }
193
+ const scan = (t, rx) => {
194
+ rx.lastIndex = 0;
195
+ let m;
196
+ while ((m = rx.exec(t))) out.push({ prefix: m[1] + "/", target: cleanRel(posix.join(dir, m[2])), exact: false });
197
+ };
198
+ for (const name of ALIAS_BUNDLER) {
199
+ const t = readText(at(name));
200
+ if (t === null) continue;
201
+ scan(t, ALIAS_RX_RESOLVE);
202
+ scan(t, ALIAS_RX_URL);
203
+ }
204
+ const jt = readText(at("jest.config.js"));
205
+ if (jt !== null) scan(jt, ALIAS_RX_JEST);
206
+ model.aliasTables.set(dir, out);
207
+ return out;
208
+ }
209
+
210
+ // Nearest ancestor first, the repository root ("") last.
211
+ function ancestorDirs(from) {
212
+ const out = [];
213
+ let d = posix.dirname(from);
214
+ for (;;) {
215
+ if (d === "." || d === "/" || d === "") {
216
+ out.push("");
217
+ break;
218
+ }
219
+ out.push(d);
220
+ d = posix.dirname(d);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ // Every directory D for which the index holds a file under `D/src/`.
226
+ function srcRoots(model) {
227
+ if (model.srcRoots) return model.srcRoots;
228
+ const out = new Set();
229
+ for (const f of model.fileSet) {
230
+ const parts = f.split("/");
231
+ for (let i = 0; i < parts.length - 1; i++) if (parts[i] === "src") out.add(parts.slice(0, i).join("/"));
232
+ }
233
+ model.srcRoots = out;
234
+ return out;
235
+ }
236
+
237
+ function aliasBase(model, from, spec) {
238
+ const dirs = ancestorDirs(from);
239
+ for (const d of dirs) {
240
+ let best = null;
241
+ for (const e of aliasTable(model, d)) {
242
+ const ok = e.exact ? spec === e.prefix : spec.startsWith(e.prefix);
243
+ if (ok && (!best || e.prefix.length > best.prefix.length)) best = e;
244
+ }
245
+ if (best) return cleanRel(best.exact ? best.target : posix.join(best.target, spec.slice(best.prefix.length)));
246
+ }
247
+ if (/^[@~]\//.test(spec)) {
248
+ const roots = srcRoots(model);
249
+ for (const d of dirs) if (roots.has(d)) return cleanRel(posix.join(d, "src", spec.slice(2)));
250
+ return "src/" + spec.slice(2);
251
+ }
252
+ return null;
253
+ }
254
+
136
255
  function resolveJs(model, from, spec) {
137
256
  let base;
138
257
  if (spec.startsWith(".")) base = posix.normalize(posix.join(posix.dirname(from), spec));
139
- else if (/^[@~]\//.test(spec)) base = "src/" + spec.slice(2);
140
258
  else if (spec.startsWith("/")) base = spec.slice(1);
141
- else return [];
259
+ else {
260
+ base = aliasBase(model, from, spec);
261
+ if (base === null) return [];
262
+ }
142
263
  const cands = [base, ...JS_EXT.map((e) => base + e), ...JS_EXT.map((e) => `${base}/index${e}`)];
143
264
  // TS ESM writes `./x.js` for a file that is `x.ts` on disk.
144
265
  if (/\.(js|jsx|mjs|cjs)$/.test(base)) {
@@ -378,9 +499,66 @@ function inheritedMember(model, rel, className, member, depth, seen) {
378
499
  return null;
379
500
  }
380
501
 
502
+ // v2.2.0: SPA route navigation. `{ name: "<route name>", route: true }`, or
503
+ // `{ name: "IDENT.MEMBER", route: true, via: true }` where IDENT is a const
504
+ // object (this file's own, or imported) whose `values[MEMBER]` holds the
505
+ // route name. Never subject to the `call.ref` restriction.
506
+ function routeNameIndex(model) {
507
+ if (model.routeNames) return model.routeNames;
508
+ const idx = new Map();
509
+ for (const s of model.symbols.values()) {
510
+ if (s.kind !== "route" || typeof s.route_name !== "string") continue;
511
+ if (!idx.has(s.route_name)) idx.set(s.route_name, []);
512
+ idx.get(s.route_name).push(s);
513
+ }
514
+ model.routeNames = idx;
515
+ return idx;
516
+ }
517
+
518
+ function resolveRouteCall(model, rel, call) {
519
+ // The sharded fast model holds no symbol table to search for route names,
520
+ // so it DECLINES and the full model answers.
521
+ if (model.fast) {
522
+ const S = require("./graph-shard.js");
523
+ throw new S.FastUnavailable("route-call");
524
+ }
525
+ let rname = call.name;
526
+ if (call.via) {
527
+ const parts = String(call.name).split(".");
528
+ const id = parts[0];
529
+ const member = parts.slice(1).join(".");
530
+ const f = model.byFile[rel] || {};
531
+ let s = (f.symbols || []).find((x) => x.kind !== "module" && x.qname === id) || null;
532
+ if (!s) {
533
+ const bd = fileBindings(model, rel).map.get(id);
534
+ if (bd && bd.files.length) {
535
+ const wanted = bd.kind === "named" ? bd.orig : bd.local;
536
+ for (const pair of barrelTargets(model, bd.files, wanted, 0, new Set())) {
537
+ const tsyms = ((model.byFile[pair.file] || {}).symbols || []).filter((x) => x.kind !== "module");
538
+ s = tsyms.find((x) => x.qname === pair.name) || null;
539
+ if (s) break;
540
+ }
541
+ }
542
+ }
543
+ const values = s && s.values;
544
+ const v = values && Object.prototype.hasOwnProperty.call(values, member) ? values[member] : undefined;
545
+ if (typeof v !== "string") return { state: "UNRESOLVED" };
546
+ rname = v;
547
+ }
548
+ const hits = routeNameIndex(model).get(rname) || [];
549
+ if (hits.length === 1) return { state: "ROUTE", target: hits[0].id };
550
+ if (hits.length > 1) return { state: "AMBIGUOUS", candidates: hits.map((s) => s.id) };
551
+ return { state: "UNRESOLVED", missing_route: rname };
552
+ }
553
+
381
554
  function resolveCall(model, rel, caller, call) {
382
555
  const cls = caller ? classOf(caller) : null;
383
- 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}`;
556
+ 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}\0${call.route ? (call.via ? 2 : 1) : 0}`;
557
+ if (call.route) {
558
+ const rr = resolveRouteCall(model, rel, call);
559
+ model.resolved.set(key, rr);
560
+ return rr;
561
+ }
384
562
  if (model.resolved.has(key)) return model.resolved.get(key);
385
563
  const parts = call.name.split(".");
386
564
  const last = parts[parts.length - 1];
@@ -486,7 +664,10 @@ function resolveCall(model, rel, caller, call) {
486
664
  // W1: a bare `get(…)` is never a class METHOD — a method is reached through
487
665
  // a receiver, `this`, or inheritance. Before this, `request(app).get("/p")`
488
666
  // resolved UNIQUE to the only method named `get` in the repository.
489
- const all = model.byName.get(last) || [];
667
+ // v2.2.0 (D5): a source file never resolves into a test helper. A test
668
+ // caller keeps every candidate.
669
+ const named = model.byName.get(last) || [];
670
+ const all = TEST_FILE.test(rel) ? named : named.filter((s) => !TEST_FILE.test(s.file));
490
671
  const cands = head || call.self ? all : all.filter((s) => !classOf(s));
491
672
  if (head) {
492
673
  const hl = head.toLowerCase();
@@ -766,6 +947,9 @@ function calleesOf(model, sym) {
766
947
  if (seen.has(c.name)) continue;
767
948
  seen.add(c.name);
768
949
  if (stored) {
950
+ // v2.2.0: a route call is answered by the full model only (its
951
+ // UNRESOLVED answer names the missing route, which no shard stores).
952
+ if (c.route) throw new (require("./graph-shard.js").FastUnavailable)("route-call");
769
953
  const row = stored.get(i);
770
954
  const S = require("./graph-shard.js");
771
955
  out.push({
@@ -816,6 +1000,14 @@ function findTarget(model, q) {
816
1000
  const named = model.byName.get(s) || [];
817
1001
  if (named.length === 1) return { kind: "symbol", sym: named[0] };
818
1002
  if (named.length > 1) return { kind: "ambiguous", candidates: named };
1003
+ // v2.2.0 (T6): lanes type SHORT file names — `OutwardRemittance.vue`, or a
1004
+ // path suffix. The ONE indexed file whose path ends with `/<it>` is the
1005
+ // target; several is an answer that lists them.
1006
+ if (/\.[A-Za-z0-9]+$/.test(s) || s.includes("/")) {
1007
+ const suf = bySuffix(model, s);
1008
+ if (suf.length === 1) return { kind: "file", rel: suf[0] };
1009
+ if (suf.length > 1) return { kind: "ambiguous-file", files: suf.slice().sort() };
1010
+ }
819
1011
  const ql = s.toLowerCase();
820
1012
  const nearest = [];
821
1013
  for (const [n, arr] of model.byName) if (n.toLowerCase().includes(ql)) nearest.push(...arr);
@@ -970,7 +1162,7 @@ function fit(items, budget, footer, tail, blocks) {
970
1162
  // It is a FORMAT, never a filter: the same rows, the same order, the same JSON.
971
1163
  // `--format prose` stays the default, because a shorter card that is read wrong
972
1164
  // costs more than it saves.
973
- 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" };
1165
+ const KIND_SHORT = { prop: "p", emit: "e", inject: "i", template: "t", function: "fn", method: "me", class: "cl", route: "rt", module: "mo", const: "co", var: "va", type: "ty", interface: "in", enum: "en", struct: "st" };
974
1166
  const STATE_SHORT = { LOCAL: "L", IMPORT: "I", UNIQUE: "U", ROUTE: "R", AMBIGUOUS: "A", UNRESOLVED: "-" };
975
1167
  const shortKind = (k) => KIND_SHORT[k] || String(k).slice(0, 2);
976
1168
 
@@ -1188,6 +1380,7 @@ function ctx(model, query, opts) {
1188
1380
  const t = findTarget(model, query);
1189
1381
  if (t.kind === "none") return { ok: false, state: "not-found", query, nearest: t.nearest.map(brief), exit: 4 };
1190
1382
  if (t.kind === "ambiguous") return { ok: false, state: "ambiguous-target", query, candidates: t.candidates.map(brief), exit: 4 };
1383
+ if (t.kind === "ambiguous-file") return { ok: false, state: "ambiguous-target", query, candidates: t.files.map((file) => ({ file })), exit: 4 };
1191
1384
  if (t.kind === "file") return fileCard(model, t.rel, budget, opts);
1192
1385
 
1193
1386
  const sym = t.sym;
@@ -1241,6 +1434,13 @@ function ctx(model, query, opts) {
1241
1434
  const walk = (s, level, indent) => {
1242
1435
  for (const { call, res } of calleesOf(model, s)) {
1243
1436
  if (res.state === "UNRESOLVED") {
1437
+ // v2.2.0: a route call whose name no route record carries is SAID,
1438
+ // one line, no guess — it is shown, so it is not counted as hidden.
1439
+ if (level === 1 && call.route && res.missing_route) {
1440
+ const miss = `${indent}→ route ${res.missing_route} NO ROUTE — no record has this name`;
1441
+ push(2, "calls", miss, { item: "call", name: call.name, line: call.line, state: "UNRESOLVED", missing_route: res.missing_route, level }, miss);
1442
+ continue;
1443
+ }
1244
1444
  if (level === 1) unresolved++;
1245
1445
  continue;
1246
1446
  }
@@ -1255,7 +1455,10 @@ function ctx(model, query, opts) {
1255
1455
  const target = model.symbols.get(res.target);
1256
1456
  if (!target) continue;
1257
1457
  const mark = res.inherited ? " (inherited)" : call.alias ? ` (via ${call.alias})` : "";
1258
- const row = (short) => `${indent}→ ${call.ref ? "uses " : "calls"} ${target.qname} ${where(target)} ${stateWord(res.state, short)}${mark}`;
1458
+ const row = (short) =>
1459
+ call.route && res.state === "ROUTE"
1460
+ ? `${indent}→ route ${call.name} ${target.qname} ${where(target)} ${stateWord(res.state, short)}`
1461
+ : `${indent}→ ${call.ref ? "uses " : "calls"} ${target.qname} ${where(target)} ${stateWord(res.state, short)}${mark}`;
1259
1462
  push(level === 1 ? 2 : 3 + level, "calls", row(false), {
1260
1463
  item: "call",
1261
1464
  name: call.name,
@@ -1283,6 +1486,39 @@ function ctx(model, query, opts) {
1283
1486
  if (wiki.length) push(6, "wiki", ` wiki ${wiki.join(", ")}`, { item: "wiki", docs: wiki });
1284
1487
  const tests = [...new Set(callers.confident.filter((c) => TEST_FILE.test(c.sym.file)).map((c) => c.sym.file))];
1285
1488
  if (tests.length) push(5, "tests", ` tests ${tests.join(", ")}`, { item: "tests", files: tests });
1489
+ // R11 (v2.2.0): a target no test calls DIRECTLY is often reached by a spec
1490
+ // THROUGH one of its callers. One hop, confident callers only, test files
1491
+ // only, max 6 — so the reader does not grep for the tests.
1492
+ const testsVia = [];
1493
+ const plainCallers = callers.confident.filter((c) => !TEST_FILE.test(c.sym.file));
1494
+ if (!tests.length && plainCallers.length) {
1495
+ // R11b: the SAME three rules the file card uses for its tests — a test
1496
+ // that CALLS a symbol of the caller's file, IMPORTS that file, or is
1497
+ // NAMED after it. Ranked call > import > name. Only TEST files are asked
1498
+ // who they import, so the fast model loads test blobs and nothing else.
1499
+ const SIG = require("./graph-signals.js");
1500
+ const seenVia = new Set();
1501
+ const add = (file, via) => {
1502
+ if (testsVia.length >= 6 || seenVia.has(file) || !TEST_FILE.test(file)) return;
1503
+ seenVia.add(file);
1504
+ testsVia.push({ file, via });
1505
+ };
1506
+ const callerFiles = [...new Set(plainCallers.map((c) => c.sym.file))];
1507
+ for (const c of plainCallers) for (const cc of callersOf(model, c.sym).confident) add(cc.sym.file, c.sym.qname);
1508
+ for (const rel of callerFiles)
1509
+ for (const s of ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind !== "module"))
1510
+ for (const cc of callersOf(model, s).confident) add(cc.sym.file, s.qname);
1511
+ for (const rel of callerFiles) for (const f of importersOf(model, rel, true).sort()) add(f, rel);
1512
+ for (const rel of callerFiles) for (const f of SIG.testsByName(model, rel)) add(f, rel);
1513
+ if (testsVia.length) push(5, "tests", ` tests (via callers) ${testsVia.map((x) => `${x.file} ← ${x.via}`).join(", ")}`, { item: "tests_via", rows: testsVia });
1514
+ for (const x of testsVia) if (!tests.includes(x.file)) tests.push(x.file);
1515
+ }
1516
+ // R11: the parse-coverage verdict of the target file + every caller file —
1517
+ // the same per-path reading `orc graph coverage` prints (G.coverageOf).
1518
+ const covFiles = [...new Set([sym.file, ...callers.confident.map((c) => c.sym.file)])];
1519
+ const covPartial = covFiles.filter((rel) => G.coverageOf(model.byFile[rel]).coverage !== "full");
1520
+ const coverage = { verdict: covPartial.length ? "partial" : "full", files: covFiles.length, gaps: covPartial };
1521
+ push(5, "coverage", ` coverage ${coverage.verdict} · ${covFiles.length} files${covPartial.length ? ` · ${covPartial.join(", ")}` : ""}`, { item: "coverage" });
1286
1522
 
1287
1523
  const short = pickShort(items);
1288
1524
  // The footer is printed ONLY when the card hid something, dropped an
@@ -1316,6 +1552,8 @@ function ctx(model, query, opts) {
1316
1552
  effects: kept.filter((d) => d.item ==="effect"),
1317
1553
  wiki,
1318
1554
  tests,
1555
+ tests_via: testsVia,
1556
+ coverage,
1319
1557
  unresolved,
1320
1558
  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,
1321
1559
  ...callersSource,
@@ -1326,10 +1564,11 @@ function ctx(model, query, opts) {
1326
1564
  };
1327
1565
  }
1328
1566
 
1329
- function importersOf(model, rel) {
1567
+ function importersOf(model, rel, testsOnly) {
1330
1568
  const out = [];
1331
1569
  for (const other of model.fileSet) {
1332
1570
  if (other === rel) continue;
1571
+ if (testsOnly && !TEST_FILE.test(other)) continue;
1333
1572
  const { map, includes } = fileBindings(model, other);
1334
1573
  let hit = includes.includes(rel);
1335
1574
  if (!hit) for (const bd of map.values()) if (bd.files.includes(rel)) { hit = true; break; }
@@ -1355,7 +1594,17 @@ function fileCard(model, rel, budget, opts) {
1355
1594
  const fanIn = callersOf(model, s).confident.length;
1356
1595
  return { s, fanIn, imp: SIG.importance(model, s, fanIn) };
1357
1596
  });
1358
- allRows.sort((a, b) => b.imp - a.imp || b.fanIn - a.fanIn || a.s.lines[0] - b.s.lines[0]);
1597
+ // v2.2.0: a component's interface leads — props, emits and injects in
1598
+ // source order, then its template, then the rest by importance.
1599
+ const GROUP = { prop: 0, emit: 0, inject: 0, template: 1 };
1600
+ const grp = (s) => (GROUP[s.kind] === undefined ? 2 : GROUP[s.kind]);
1601
+ allRows.sort((a, b) => {
1602
+ const ga = grp(a.s);
1603
+ const gb = grp(b.s);
1604
+ if (ga !== gb) return ga - gb;
1605
+ if (ga < 2) return a.s.lines[0] - b.s.lines[0];
1606
+ return b.imp - a.imp || b.fanIn - a.fanIn || a.s.lines[0] - b.s.lines[0];
1607
+ });
1359
1608
  // `--offset` pages the SYMBOL rows. Paging is not truncation: `has_more` says
1360
1609
  // there is a next page, and the budget footer still says what it hid.
1361
1610
  const offset = Math.max(0, Number(opts.offset) || 0);
@@ -1507,7 +1756,8 @@ function sliceBlock(model, rel, budget, opts) {
1507
1756
  const importers = importersOf(model, rel);
1508
1757
  // Only the importers that call NOTHING — the ones the caller rows already
1509
1758
  // name are the same fact printed twice.
1510
- const quiet = importers.filter((x) => !byFile.has(x));
1759
+ // T6c: a test file is never on the `imp` line — it stays on `tst`.
1760
+ const quiet = importers.filter((x) => !byFile.has(x) && !TEST_FILE.test(x));
1511
1761
  if (quiet.length) {
1512
1762
  const pre = dirPrefix(quiet);
1513
1763
  if (pre) push(1, "importers", ` dir ${pre}`, null);
@@ -1523,7 +1773,8 @@ function sliceBlock(model, rel, budget, opts) {
1523
1773
  .filter((r) => r.sym.file === rel)
1524
1774
  .map((r) => `${r.method} ${r.full || r.own}${r.mounted === "known" || r.mounted === "none" ? "" : " (mount ambiguous)"}`);
1525
1775
  const unreached = routes.filter((x) => !reached.has(x));
1526
- if (unreached.length) push(1, "routes", ` rt ${unreached.join(" ")} (no caller in the graph)`, { item: "routes", routes: unreached });
1776
+ // T6c: at most 8 routes on the line, then `+<n>` — a router answers 85.
1777
+ if (unreached.length) push(1, "routes", ` rt ${unreached.slice(0, 8).join(" ")}${unreached.length > 8 ? ` +${unreached.length - 8}` : ""} (no caller in the graph)`, { item: "routes", routes: unreached });
1527
1778
 
1528
1779
  const shownFiles = new Set(rows.map((r) => r.file));
1529
1780
  const allTests = [
@@ -1624,7 +1875,10 @@ function impact(model, files, opts) {
1624
1875
 
1625
1876
  const tree = opts.format === "tree";
1626
1877
  const offset = Math.max(0, Number(opts.offset) || 0);
1627
- const page = hits.slice(offset);
1878
+ // T6b: a caller in a TEST file is not a caller row — the budget shows the
1879
+ // CODE that depends on the change, and the tests get one line of their own.
1880
+ const codeHits = hits.filter((h) => !TEST_FILE.test(h.sym.file));
1881
+ const page = codeHits.slice(offset);
1628
1882
  const items = [];
1629
1883
  const push = (pri, section, text, data) => items.push({ pri, section, text, data });
1630
1884
  push(0, "header", `impact of ${rels.join(", ")} — ${targets.length} symbols · depth ${depth}`);
@@ -1651,7 +1905,7 @@ function impact(model, files, opts) {
1651
1905
  }
1652
1906
  for (const r of callRows) push(r.pri, "callers", useTree ? r.tree : r.prose, r.data);
1653
1907
  const j = useTree ? " " : ", ";
1654
- if (tests.length) push(1, "tests", ` tests${useTree ? "" : " "} ${tests.join(j)}`, { item: "tests" });
1908
+ if (tests.length) push(1, "tests", ` tests ${tests.length} files: ${tests.slice(0, 6).join(", ")}${tests.length > 6 ? `, +${tests.length - 6}` : ""}`, { item: "tests" });
1655
1909
  if (wiki.length) push(2, "wiki", ` wiki${useTree ? "" : " "} ${wiki.join(j)}`, { item: "wiki" });
1656
1910
  if (maybe) push(2, "maybe", useTree ? ` maybe ${maybe} ambiguous, not followed` : ` maybe ${maybe} AMBIGUOUS caller(s) not followed`, { item: "maybe" });
1657
1911
  if (missing.length) push(1, "missing", useTree ? ` absent ${missing.join(" ")}` : ` not in the graph: ${missing.join(", ")}`, { item: "missing" });
@@ -1664,12 +1918,12 @@ function impact(model, files, opts) {
1664
1918
  state: "found",
1665
1919
  format: useTree ? "tree" : "prose",
1666
1920
  offset,
1667
- has_more: offset + keptCallers.length < hits.length,
1921
+ has_more: offset + keptCallers.length < codeHits.length,
1668
1922
  files: rels,
1669
1923
  missing,
1670
1924
  symbols: targets.length,
1671
1925
  callers: kept.filter((d) => d.item ==="caller"),
1672
- total_callers: hits.length,
1926
+ total_callers: codeHits.length,
1673
1927
  maybe_callers: maybe,
1674
1928
  tests,
1675
1929
  wiki,
@@ -1681,15 +1935,31 @@ function impact(model, files, opts) {
1681
1935
  }
1682
1936
 
1683
1937
  // ── path ────────────────────────────────────────────────────────────────────
1938
+ // T6b: a FILE as an end means ANY symbol in that file — the shortest
1939
+ // confident path to (or from) any of them.
1940
+ function pathEnd(model, q) {
1941
+ const t = findTarget(model, q);
1942
+ if (t.kind === "symbol") return { t, syms: [t.sym], label: t.sym.qname };
1943
+ if (t.kind === "file") {
1944
+ const syms = ((model.byFile[t.rel] || {}).symbols || []).filter((s) => s.kind !== "module");
1945
+ if (syms.length) return { t, syms, label: t.rel, file: t.rel };
1946
+ }
1947
+ return { t, syms: null };
1948
+ }
1949
+
1684
1950
  function pathBetween(model, fromQ, toQ) {
1685
- const a = findTarget(model, fromQ);
1686
- const b = findTarget(model, toQ);
1687
- if (a.kind !== "symbol" || b.kind !== "symbol") {
1951
+ const A = pathEnd(model, fromQ);
1952
+ const B = pathEnd(model, toQ);
1953
+ if (!A.syms || !B.syms) {
1954
+ const a = A.t;
1955
+ const b = B.t;
1688
1956
  return { ok: false, state: "not-found", from: a.kind, to: b.kind, candidates: [...(a.candidates || []), ...(b.candidates || [])].map(brief), exit: 4 };
1689
1957
  }
1690
- const prev = new Map([[a.sym.id, null]]);
1691
- let frontier = [a.sym];
1692
- for (let d = 0; d < 8 && frontier.length && !prev.has(b.sym.id); d++) {
1958
+ const goals = new Set(B.syms.map((x) => x.id));
1959
+ const prev = new Map(A.syms.map((x) => [x.id, null]));
1960
+ let frontier = A.syms.slice();
1961
+ let reached = (A.syms.find((x) => goals.has(x.id)) || {}).id || null;
1962
+ for (let d = 0; d < 8 && frontier.length && !reached; d++) {
1693
1963
  const next = [];
1694
1964
  for (const s of frontier) {
1695
1965
  for (const { call, res } of calleesOf(model, s)) {
@@ -1698,11 +1968,16 @@ function pathBetween(model, fromQ, toQ) {
1698
1968
  if (!t) continue;
1699
1969
  prev.set(t.id, { from: s, line: call.line, state: res.state });
1700
1970
  next.push(t);
1971
+ if (!reached && goals.has(t.id)) reached = t.id;
1701
1972
  }
1702
1973
  }
1703
1974
  frontier = next;
1704
1975
  }
1705
- 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)` };
1976
+ if (!reached) {
1977
+ const end = (E) => (E.file ? { file: E.file, kind: "file" } : brief(E.syms[0]));
1978
+ return { ok: true, state: "none", from: end(A), to: end(B), exit: 4, card: `no confident call path from ${A.label} to ${B.label} (AMBIGUOUS edges are not followed)` };
1979
+ }
1980
+ const b = { sym: model.symbols.get(reached) };
1706
1981
  const chain = [];
1707
1982
  for (let id = b.sym.id; id; ) {
1708
1983
  const p = prev.get(id);
@@ -1729,7 +2004,6 @@ function pathBetween(model, fromQ, toQ) {
1729
2004
  // how the list is cut, which is the same contract every other card keeps: rows
1730
2005
  // in a fixed order, the budget cuts from the BOTTOM, and a card that hid
1731
2006
  // something says so.
1732
- const MAP_MAX_ROWS = 200;
1733
2007
  const MAP_FOCUS_MASS = 100;
1734
2008
  const MAP_CAVEAT = " rank is a HINT about where to look first — it is never proof a file matters to this change";
1735
2009
 
@@ -1756,20 +2030,143 @@ function mapRow(model, rel, fan, pre) {
1756
2030
  // A focus token is a FILE when the graph holds that path, and a NAME otherwise.
1757
2031
  // A name contributes every file that DEFINES it — which is how "focus on
1758
2032
  // `createOrder`" reaches the file a request never spelled out.
2033
+ //
2034
+ // v2.1.2 (F05): a token that is neither is read as plain WORDS. Two lane spines
2035
+ // pass "<3–6 words from the request>", and before this step every such map was
2036
+ // the plain repository ranking with a trace that still said `focused`. A word
2037
+ // reaches the files whose PATH or whose symbol NAMES hold it.
2038
+ const FOCUS_STOP = new Set(
2039
+ "the and for with from into that this when where what which how why not all any add fix get set new use make src lib index test tests spec file files code".split(" ")
2040
+ );
2041
+
2042
+ // Light on purpose: `rates` → `rate`, `taxes` → `tax`, `orders` → `order`,
2043
+ // `address` stays. A real stemmer would join words a request keeps apart.
2044
+ function focusStem(w) {
2045
+ if (w.length > 3 && w.endsWith("ies")) return w.slice(0, -3) + "y";
2046
+ if (/(s|x|z|ch|sh)es$/.test(w)) return w.slice(0, -2);
2047
+ if (w.length >= 4 && w.endsWith("s") && !w.endsWith("ss")) return w.slice(0, -1);
2048
+ return w;
2049
+ }
2050
+
2051
+ // camelCase, `_`, `-`, `.`, `/` and whitespace all end a word.
2052
+ function focusWords(text) {
2053
+ return String(text)
2054
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
2055
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
2056
+ .split(/[^A-Za-z0-9]+/)
2057
+ .filter(Boolean)
2058
+ .map((w) => focusStem(w.toLowerCase()));
2059
+ }
2060
+
2061
+ // R4: a focus word matches a path/name segment when they are equal, or one is
2062
+ // the other's simple plural (`+s`, `+es`), both ways. Both sides are already
2063
+ // lower case and stemmed by focusWords, so this only adds what the light
2064
+ // stemmer leaves apart.
2065
+ function wordMatch(w, seg) {
2066
+ return seg === w || seg === w + "s" || seg === w + "es" || w === seg + "s";
2067
+ }
2068
+
2069
+ // ONE index per call, built only when a token needs it: word → the files whose
2070
+ // path (directories + basename without its extension) or symbol names hold it.
2071
+ // v2.2.0 (D6): word → Map(file → 2 when the word is a PATH segment, 1 when
2072
+ // only a symbol name holds it).
2073
+ function focusWordIndex(model) {
2074
+ const index = new Map();
2075
+ for (const rel of model.fileSet) {
2076
+ const words = new Map();
2077
+ for (const w of focusWords(rel.replace(/\.[^./]+$/, ""))) words.set(w, { path: true, n: 0 });
2078
+ for (const s of (model.byFile[rel] && model.byFile[rel].symbols) || []) {
2079
+ if (s.kind === "module") continue;
2080
+ for (const w of new Set(focusWords(s.name || s.qname || ""))) {
2081
+ const e = words.get(w) || { path: false, n: 0 };
2082
+ e.n++;
2083
+ words.set(w, e);
2084
+ }
2085
+ }
2086
+ for (const [w, x] of words) {
2087
+ if (!index.has(w)) index.set(w, new Map());
2088
+ index.get(w).set(rel, x);
2089
+ }
2090
+ }
2091
+ return index;
2092
+ }
2093
+
2094
+ // v2.2.0 (D6): a word is never dropped. It is WEIGHTED by how rare it is —
2095
+ // log(1 + N/hits) / log(1 + N), so a rare word is near 1 and a word in every
2096
+ // file is small but above 0. A file named directly is the strongest (3).
2097
+ //
2098
+ // v2.2.0 (T6b): a plain lower-case token is ALWAYS a word — `amount` used to
2099
+ // be read as a NAME (only the files defining it) and vanished from the words
2100
+ // line. A token with `/`, `.` or `#` is a file or a qname; a camelCase,
2101
+ // PascalCase or `_` token is a NAME and a word. A PATH-segment hit is worth
2102
+ // 2w; a symbol-only hit is worth w × min(1, matched / sqrt(total)), so a
2103
+ // constants file with 177 symbols and 6 matches is not a strong hit.
2104
+ // `strong` is every file reached by a path segment, a file token or a name —
2105
+ // the files hub damping never touches.
2106
+ const FOCUS_FILE_MASS = 3;
1759
2107
  function focusFiles(model, tokens) {
1760
- const files = [];
2108
+ const mass = new Map();
2109
+ const strong = new Set();
2110
+ const add = (rel, m) => mass.set(rel, (mass.get(rel) || 0) + m);
1761
2111
  const missing = [];
2112
+ const common = [];
2113
+ const words = [];
2114
+ const seen = new Set();
2115
+ let index = null;
2116
+ const N = model.fileSet.size;
2117
+ const symCount = (rel) => ((model.byFile[rel] && model.byFile[rel].symbols) || []).filter((s) => s.kind !== "module").length;
1762
2118
  for (const t of tokens) {
1763
- const rel = norm(t).replace(/:\d+$/, "");
2119
+ const raw = String(t);
2120
+ const rel = norm(raw).replace(/:\d+$/, "");
1764
2121
  if (model.fileSet.has(rel)) {
1765
- files.push(rel);
2122
+ add(rel, FOCUS_FILE_MASS);
2123
+ strong.add(rel);
1766
2124
  continue;
1767
2125
  }
1768
- const defs = model.byName.get(String(t).split(".").pop()) || [];
1769
- if (defs.length) for (const s of defs) files.push(s.file);
1770
- else missing.push(t);
2126
+ if (/[\/.#]/.test(raw)) {
2127
+ const defs = model.byName.get(raw.split(/[.#]/).pop()) || [];
2128
+ for (const s of defs) {
2129
+ add(s.file, 1);
2130
+ strong.add(s.file);
2131
+ }
2132
+ if (!defs.length) missing.push(raw);
2133
+ continue;
2134
+ }
2135
+ const isName = /[a-z0-9][A-Z]|^[A-Z]|_/.test(raw);
2136
+ let named = false;
2137
+ if (isName) {
2138
+ for (const s of model.byName.get(raw) || []) {
2139
+ add(s.file, 1);
2140
+ strong.add(s.file);
2141
+ named = true;
2142
+ }
2143
+ }
2144
+ index = index || focusWordIndex(model);
2145
+ const none = [];
2146
+ let held = false;
2147
+ for (const w of focusWords(raw)) {
2148
+ if (w.length < 3 || FOCUS_STOP.has(w) || seen.has(w)) continue;
2149
+ seen.add(w);
2150
+ const hit = index.get(w) || new Map();
2151
+ const weight = hit.size ? Math.log(1 + N / hit.size) / Math.log(1 + N) : 0;
2152
+ words.push({ word: w, files: hit.size, used: hit.size > 0, weight: Math.round(weight * 1000) / 1000, kind: isName ? "name" : "word" });
2153
+ if (!hit.size) none.push(w);
2154
+ else {
2155
+ held = true;
2156
+ for (const [f, x] of hit) {
2157
+ if (x.path) {
2158
+ add(f, weight * 2);
2159
+ strong.add(f);
2160
+ } else add(f, weight * Math.min(1, x.n / Math.sqrt(Math.max(1, symCount(f)))));
2161
+ }
2162
+ }
2163
+ }
2164
+ // A token with no word in the graph is reported WHOLE, as it was typed —
2165
+ // the answer a caller got before the word step, unchanged.
2166
+ if (held) missing.push(...none);
2167
+ else if (!named) missing.push(raw);
1771
2168
  }
1772
- return { files: [...new Set(files)], missing };
2169
+ return { files: [...mass.keys()], mass, strong, missing, common, words };
1773
2170
  }
1774
2171
 
1775
2172
  function graphMap(claudeDir, model, opts) {
@@ -1778,7 +2175,7 @@ function graphMap(claudeDir, model, opts) {
1778
2175
  const { files, edges, base, cached } = M.edgesFor(claudeDir, model);
1779
2176
  const idOf = new Map(files.map((f, i) => [f, i]));
1780
2177
  const tokens = (opts.focus || []).filter(Boolean);
1781
- const foc = tokens.length ? focusFiles(model, tokens) : { files: [], missing: [] };
2178
+ const foc = tokens.length ? focusFiles(model, tokens) : { files: [], mass: new Map(), strong: new Set(), missing: [], common: [], words: [] };
1782
2179
 
1783
2180
  let rank;
1784
2181
  if (foc.files.length) {
@@ -1792,19 +2189,37 @@ function graphMap(claudeDir, model, opts) {
1792
2189
  // The mass is FIXED, so a fifth focus file dilutes the other four rather
1793
2190
  // than making the focus louder overall. That is Aider's rule and the reason
1794
2191
  // is the same: a focus list is a hint about where to start, not a filter.
2192
+ //
2193
+ // v2.2.0 (D6): the fixed mass is shared by each file's focus MASS (its
2194
+ // weighted word hits), not in equal parts.
1795
2195
  const pers = new Float64Array(files.length).fill(1);
1796
- const each = MAP_FOCUS_MASS / foc.files.length;
2196
+ let total = 0;
2197
+ for (const f of foc.files) total += foc.mass.get(f) || 0;
1797
2198
  for (const f of foc.files) {
1798
2199
  const i = idOf.get(f);
1799
- if (i !== undefined) pers[i] += each;
2200
+ if (i !== undefined && total > 0) pers[i] += (MAP_FOCUS_MASS * (foc.mass.get(f) || 0)) / total;
1800
2201
  }
1801
2202
  rank = M.pageRank(files.length, edges, pers);
1802
2203
  } else rank = base || M.pageRank(files.length, edges, null);
1803
2204
 
1804
- const scored = files
1805
- .map((rel, i) => ({ rel, score: rank[i] * M.multiplier(model, rel) }))
1806
- .sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
1807
- const candidates = scored.slice(0, MAP_MAX_ROWS);
2205
+ // v2.2.0 (D6): hub damping, focus only. A file the focus never reached that
2206
+ // is a shared hub — by its path, or by an in-degree in the top 2 % — would
2207
+ // otherwise outrank the files the request names.
2208
+ // T6b: only a file the focus reached by a PATH, a file token or a name is
2209
+ // exempt — a file reached by symbol hits alone is damped when it is a hub.
2210
+ const damp = foc.files.length ? M.hubDamping(files, edges, foc.strong) : null;
2211
+ // Every indexed file is ranked — a file with no edge keeps its teleport share.
2212
+ let scored = files.map((rel, i) => ({ rel, score: (rank[i] || 0) * M.multiplier(model, rel) * (damp ? damp[i] : 1) }));
2213
+ // T6b: personalised PageRank sends rank from the focused files into what
2214
+ // they IMPORT — the hubs. With a focus, the final score is half the walk and
2215
+ // half the file's own focus mass, each normalised to [0, 1].
2216
+ if (foc.files.length) {
2217
+ const maxPr = Math.max(...scored.map((x) => x.score), 0) || 1;
2218
+ const maxMass = Math.max(...foc.files.map((f) => foc.mass.get(f) || 0), 0) || 1;
2219
+ scored = scored.map((x) => ({ rel: x.rel, score: 0.5 * (x.score / maxPr) + 0.5 * ((foc.mass.get(x.rel) || 0) / maxMass) }));
2220
+ }
2221
+ scored.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
2222
+ const candidates = scored;
1808
2223
  const fan = M.fanCounts(model);
1809
2224
  const pre = dirPrefix(candidates.map((c) => c.rel));
1810
2225
  const rows = candidates.map((c, i) => {
@@ -1829,6 +2244,15 @@ function graphMap(claudeDir, model, opts) {
1829
2244
  items.push({ pri: 0, section: "header", text: `graph map (gen ${model.meta.generation}) — ${files.length} file(s) by rank${focusNote}`, data: null });
1830
2245
  if (pre) items.push({ pri: 0, section: "header", text: ` dir ${pre}`, data: null });
1831
2246
  if (foc.missing.length) items.push({ pri: 0, section: "focus", text: ` focus not in the graph: ${foc.missing.join(", ")}`, data: { item: "focus-missing" } });
2247
+ // F05: what each plain word reached, so a reader can tell a focused map
2248
+ // from one whose words all missed.
2249
+ if (foc.words.length)
2250
+ items.push({
2251
+ pri: 0,
2252
+ section: "focus",
2253
+ text: ` focus words: ${foc.words.map((w) => (w.files ? `${w.word} ${w.files}×${w.weight.toFixed(2)}` : `${w.word} ${w.files}`)).join(" · ")}`,
2254
+ data: { item: "focus-words" },
2255
+ });
1832
2256
  for (let i = 0; i < k; i++) items.push({ pri: 1, section: "files", text: rows[i].text, data: rows[i].data });
1833
2257
  // pri 0: never cut, and CHARGED to the budget like everything else. It is
1834
2258
  // the map's whole equivalent of "the graph is a LOCATOR" — a card that
@@ -1864,6 +2288,8 @@ function graphMap(claudeDir, model, opts) {
1864
2288
  state: "found",
1865
2289
  focus: foc.files,
1866
2290
  focus_missing: foc.missing,
2291
+ focus_common: foc.common,
2292
+ focus_words: foc.words,
1867
2293
  cached_map: !!cached,
1868
2294
  total_files: files.length,
1869
2295
  edges: edges.length,
@@ -1909,4 +2335,903 @@ function nameHits(model, name) {
1909
2335
  return calls + defs;
1910
2336
  }
1911
2337
 
1912
- module.exports = { loadModel, resolveCall, resolveUrl, routeTable, urlIndex, findTarget, callersOf, nameHits, warmBlobs, ctx, forSlice, impact, graphMap, pathBetween, importsOf, TEST_FILE, SOURCE_DEFAULT, SOURCE_CAP, CALLERS_SOURCE_MAX, CALLERS_SOURCE_LINES };
2338
+ // ── v2.2.0 (T6-a): the executor PACK ────────────────────────────────────────
2339
+ // On a real Vue repository the executor never used the graph: it read ~180 KB
2340
+ // of REFERENCE code (the files it copies behaviour from) through `cat` and
2341
+ // `sed`, in about 30 calls. An outside-view card did not change that. The pack
2342
+ // is ONE file that holds the outline of each reference file and the exact
2343
+ // lines of the ranges the executor was about to go looking for.
2344
+ //
2345
+ // It is a LOCATOR, not a copy of the repository: the quoted ranges are charged
2346
+ // to a budget, a range is at most 80 lines, nothing is quoted twice, and a
2347
+ // file whose blob moved since the index says so beside every quote from it.
2348
+ const PACK_BIG_LINES = 150;
2349
+ const PACK_RANGE_LINES = 80;
2350
+ const PACK_MAX_REFS = 8;
2351
+ const PACK_AUTO_REFS = 5;
2352
+ const PACK_OUTLINE_BUDGET = 2200;
2353
+ const PACK_TESTS_BUDGET = 700;
2354
+ // R2 (lean pack v2): outline first. Only a short helper is quoted — whole.
2355
+ const PACK_QUOTE_BUDGET = 600;
2356
+ const PACK_QUOTE_MAX_LINES = 30;
2357
+ const PACK_READ_RANGES_LINES = 300;
2358
+ const PACK_PARENTS_PER_REF = 2;
2359
+ const PACK_PARENTS_MAX = 3;
2360
+ // R3: the component NEIGHBOURHOOD — outline-only refs around the given ones.
2361
+ const PACK_CHILDREN_PER_REF = 3;
2362
+ const PACK_NAME_REFS = 5;
2363
+ const PACK_NEIGH_MAX = 12;
2364
+ const PACK_SLICE_BUDGET = 600;
2365
+ const PACK_QUOTE_KINDS = new Set(["function", "method", "computed", "route"]);
2366
+ const PACK_CONTRACT = [["props", "prop"], ["emits", "emit"], ["inject", "inject"]];
2367
+ const PACK_NEVER_QUOTE = new Set(["prop", "emit", "inject", "template", "module"]);
2368
+ const LANG_FENCE = { vue: "vue", svelte: "svelte", js: "js", mjs: "js", cjs: "js", jsx: "jsx", ts: "ts", tsx: "tsx", mts: "ts", cts: "ts", py: "python", go: "go", java: "java", cs: "csharp", php: "php", rb: "ruby", rs: "rust", kt: "kotlin", kts: "kotlin", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp" };
2369
+
2370
+ // A `--refs=` operand: a file, a path suffix, or a symbol (its file).
2371
+ function resolveRef(model, q) {
2372
+ const s = norm(q).replace(/:\d+$/, "");
2373
+ if (model.fileSet.has(s)) return { file: s };
2374
+ const t = findTarget(model, s);
2375
+ if (t.kind === "file") return { file: t.rel };
2376
+ if (t.kind === "symbol") return { file: t.sym.file };
2377
+ if (t.kind === "ambiguous-file") return { candidates: t.files };
2378
+ if (t.kind === "ambiguous") {
2379
+ const files = [...new Set(t.candidates.map((c) => c.file))].sort();
2380
+ return files.length === 1 ? { file: files[0] } : { candidates: files };
2381
+ }
2382
+ return { missing: String(q) };
2383
+ }
2384
+
2385
+ // The top-level blocks of a single-file component, read from the working tree.
2386
+ // A block opens on a line that STARTS with `<template`, `<script` or `<style`
2387
+ // and closes on the first line that starts with its closing tag.
2388
+ function blockRanges(model, rel) {
2389
+ const lines = fileLines(model, rel) || [];
2390
+ const out = [];
2391
+ let open = null;
2392
+ for (let i = 0; i < lines.length; i++) {
2393
+ const l = lines[i];
2394
+ if (!open) {
2395
+ const o = /^<(template|script|style)\b/.exec(l);
2396
+ if (!o) continue;
2397
+ open = { tag: o[1], from: i + 1 };
2398
+ if (new RegExp(`</${open.tag}>\\s*$`).test(l)) {
2399
+ out.push(`${open.tag} ${open.from}-${i + 1}`);
2400
+ open = null;
2401
+ }
2402
+ continue;
2403
+ }
2404
+ if (new RegExp(`^</${open.tag}>`).test(l)) {
2405
+ out.push(`${open.tag} ${open.from}-${i + 1}`);
2406
+ open = null;
2407
+ }
2408
+ }
2409
+ return out;
2410
+ }
2411
+
2412
+ function packTestsOf(model, rel) {
2413
+ const SIG = require("./graph-signals.js");
2414
+ const syms = ((model.byFile[rel] || {}).symbols || []).filter((s) => s.kind !== "module");
2415
+ return [
2416
+ ...new Set([
2417
+ ...syms.flatMap((s) => callersOf(model, s).confident.map((c) => c.sym.file)).filter((x) => TEST_FILE.test(x)),
2418
+ ...importersOf(model, rel).filter((x) => TEST_FILE.test(x)),
2419
+ ...SIG.testsByName(model, rel),
2420
+ ]),
2421
+ ].sort();
2422
+ }
2423
+
2424
+ const memberName = (s) => (s.qname.includes(".") ? s.qname.split(".").slice(1).join(".") : s.qname);
2425
+
2426
+ // The OUTLINE of one file: a header, then rows that each name the part they
2427
+ // are, so the size rule can cut `members` first.
2428
+ // T6c: `members` are kept as entries so the OUTLINE budget can drop the
2429
+ // smallest first; `routes` (a router edit target) are their own rows.
2430
+ const VUE_MEMBER_ORDER = { computed: 1, method: 2, watch: 3 };
2431
+ const PACK_ROUTE_ROWS = 40;
2432
+ function packOutline(model, rel, withRoutes, tag, focus) {
2433
+ const f = model.byFile[rel] || {};
2434
+ const syms = (f.symbols || []).filter((s) => s.kind !== "module");
2435
+ const sfc = /\.(vue|svelte)$/.test(rel);
2436
+ const blocks = sfc ? blockRanges(model, rel) : [];
2437
+ const nLines = Number(f.lines) || (fileLines(model, rel) || []).length;
2438
+ const head = `${rel} · ${nLines} lines${blocks.length ? " · " + blocks.join(" · ") : ""}${tag ? ` · ${tag}` : ""}${nLines > PACK_READ_RANGES_LINES ? " · read ranges, not the file" : ""}`;
2439
+ const rows = [];
2440
+ const contract = PACK_CONTRACT.map(([label, kind]) => {
2441
+ const n = syms.filter((s) => s.kind === kind).map((s) => s.name);
2442
+ return n.length ? `${label} ${n.join(", ")}` : null;
2443
+ }).filter(Boolean);
2444
+ if (contract.length) rows.push({ part: "contract", text: ` contract ${contract.join(" · ")}` });
2445
+ const routes = withRoutes ? syms.filter((s) => s.kind === "route").sort((a, b) => a.lines[0] - b.lines[0]) : [];
2446
+ if (routes.length) {
2447
+ // R3: a router-like target (>= 10 routes) keeps the rows whose path or
2448
+ // name holds a focus word, plus 2 rows before and after each hit. No hit
2449
+ // -> the first 10.
2450
+ let keep = routes.slice(0, PACK_ROUTE_ROWS);
2451
+ if (routes.length >= 10) {
2452
+ const words = (focus || []).map((w) => String(w).toLowerCase()).filter((w) => w.length >= 2);
2453
+ const idx = new Set();
2454
+ routes.forEach((r, i) => {
2455
+ const hay = `${r.qname} ${r.route_name || ""}`.toLowerCase();
2456
+ if (words.some((w) => hay.includes(w))) for (let k = i - 2; k <= i + 2; k++) if (k >= 0 && k < routes.length) idx.add(k);
2457
+ });
2458
+ keep = idx.size ? routes.filter((_, i) => idx.has(i)) : routes.slice(0, 10);
2459
+ }
2460
+ for (const s of keep) rows.push({ part: "routes", text: ` ${s.qname}${s.route_name ? ` ${s.route_name}` : ""} ${s.lines[0]}-${s.lines[1]}` });
2461
+ if (routes.length > keep.length) rows.push({ part: "routes", text: ` +${routes.length - keep.length} other routes` });
2462
+ }
2463
+ const contractKinds = new Set(PACK_CONTRACT.map((x) => x[1]));
2464
+ // A component's `data` leads, then computed, methods and watchers.
2465
+ const rank = (s) => (!sfc ? 0 : s.name === "data" ? 0 : VUE_MEMBER_ORDER[s.kind] || 4);
2466
+ const members = syms
2467
+ .filter((s) => !contractKinds.has(s.kind) && s.kind !== "template" && !(routes.length && s.kind === "route") && !(sfc && s.kind === "class" && !s.qname.includes(".")))
2468
+ .sort((a, b) => rank(a) - rank(b) || a.lines[0] - b.lines[0])
2469
+ .map((s) => ({ text: `${memberName(s)} ${s.lines[0]}-${s.lines[1]}`, span: s.lines[1] - s.lines[0], keep: true }));
2470
+ const membersRow = { part: "members", members, text: null };
2471
+ if (members.length) rows.push(membersRow);
2472
+ const firstUse = new Map();
2473
+ for (const s of syms.filter((x) => x.kind === "template"))
2474
+ for (const c of s.calls || []) if (c.ref && /^[A-Z][a-z0-9]/.test(c.name) && (!firstUse.has(c.name) || c.line < firstUse.get(c.name))) firstUse.set(c.name, c.line);
2475
+ if (firstUse.size) rows.push({ part: "uses", text: ` uses ${[...firstUse].map(([n, l]) => `${n} ${l}`).join(", ")}` });
2476
+ const imports = [...new Set((f.imports || []).flatMap((imp) => importTargets(model, rel, f.lang, imp)))].slice(0, 8);
2477
+ if (imports.length) rows.push({ part: "imports", text: ` imports ${imports.join(", ")}` });
2478
+ const used = [];
2479
+ for (const s of syms) for (const c of callersOf(model, s).confident) if (c.sym.file !== rel) used.push(`${c.sym.file}:${c.line}`);
2480
+ const usedBy = [...new Set(used)].sort();
2481
+ if (usedBy.length < 5) for (const imp of importersOf(model, rel).sort()) if (!usedBy.some((u) => u.startsWith(imp + ":"))) usedBy.push(imp);
2482
+ if (usedBy.length) rows.push({ part: "used", text: ` used by ${usedBy.slice(0, 5).join(" ")}` });
2483
+ const tests = packTestsOf(model, rel).slice(0, 3);
2484
+ if (tests.length) rows.push({ part: "tests", text: ` tests ${tests.join(" ")}` });
2485
+ return { head, rows };
2486
+ }
2487
+
2488
+ function outlineText(ol) {
2489
+ return ol
2490
+ .map((x) =>
2491
+ [
2492
+ x.head,
2493
+ ...x.rows.map((r) => {
2494
+ if (r.part !== "members") return r.text;
2495
+ const kept = r.members.filter((m) => m.keep);
2496
+ const gone = r.members.length - kept.length;
2497
+ return ` members ${kept.map((m) => m.text).join(", ")}${gone ? `${kept.length ? ", " : ""}+${gone} members` : ""}`;
2498
+ }),
2499
+ ].join("\n")
2500
+ )
2501
+ .join("\n");
2502
+ }
2503
+
2504
+ // §4: the first test file's own rows — describe / it / test names, and its
2505
+ // top-level helpers — read from the working tree.
2506
+ function testRows(model, rel) {
2507
+ const lines = fileLines(model, rel) || [];
2508
+ const out = [];
2509
+ for (let i = 0; i < lines.length && out.length < 12; i++) {
2510
+ const l = lines[i];
2511
+ const t = /^\s*(describe|it|test)(?:\.\w+)?\s*\(\s*(['"`])(.*?)\2/.exec(l);
2512
+ if (t) {
2513
+ out.push(`${t[1]} "${t[3]}" ${i + 1}`);
2514
+ continue;
2515
+ }
2516
+ const h = /^(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)|^(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.exec(l);
2517
+ if (h) out.push(`${h[1] || h[2]}() ${i + 1}`);
2518
+ }
2519
+ return out;
2520
+ }
2521
+
2522
+ // R2: the files whose `template` uses a child component (the `<Tag>` users).
2523
+ // R2b: through RESOLVED edges first — the `<Owner>.template` symbols with a
2524
+ // confident edge to the child's component class. The tag-name match is the
2525
+ // fallback for a ref with no class symbol.
2526
+ function templateParents(model, child) {
2527
+ const cls = ((model.byFile[child] || {}).symbols || []).find((x) => x.kind === "class" && !x.qname.includes("."));
2528
+ if (cls) {
2529
+ const files = callersOf(model, cls).confident.filter((c) => c.sym.kind === "template" && c.sym.file !== child && !TEST_FILE.test(c.sym.file)).map((c) => c.sym.file);
2530
+ return [...new Set(files)].sort();
2531
+ }
2532
+ const base = child.split("/").pop().replace(/\.(vue|svelte)$/, "");
2533
+ const kebab = base.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
2534
+ const out = [];
2535
+ for (const rel of [...model.fileSet].sort()) {
2536
+ if (rel === child || TEST_FILE.test(rel) || !/\.(vue|svelte)$/.test(rel)) continue;
2537
+ const syms = ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind === "template");
2538
+ if (syms.some((t) => (t.calls || []).some((c) => c.name === base || c.name === kebab))) out.push(rel);
2539
+ }
2540
+ return out;
2541
+ }
2542
+
2543
+ // R3: the in-repo components a file's `template` uses — the resolved `ref`
2544
+ // edges from `<Owner>.template` to another file's component class.
2545
+ function templateChildren(model, rel) {
2546
+ const out = [];
2547
+ for (const t of ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind === "template")) {
2548
+ for (const { res } of calleesOf(model, t)) {
2549
+ if (!CONFIDENT.has(res.state)) continue;
2550
+ const s = model.symbols.get(res.target);
2551
+ if (s && s.kind === "class" && s.file !== rel && !TEST_FILE.test(s.file) && !out.includes(s.file)) out.push(s.file);
2552
+ }
2553
+ }
2554
+ return out;
2555
+ }
2556
+
2557
+ // R3b: the CHILDREN of a component — the in-repo `.vue`/`.svelte` files it
2558
+ // IMPORTS (resolved import edges) plus its template-tag edges. A dynamic
2559
+ // `<component :is>` has no tags, so the imports are the only signal there.
2560
+ function componentChildren(model, rel) {
2561
+ const f = model.byFile[rel] || {};
2562
+ const out = [];
2563
+ for (const t of [...new Set((f.imports || []).flatMap((imp) => importTargets(model, rel, f.lang, imp)))])
2564
+ if (/\.(vue|svelte)$/.test(t) && t !== rel && !TEST_FILE.test(t) && !out.includes(t)) out.push(t);
2565
+ for (const t of templateChildren(model, rel)) if (!out.includes(t)) out.push(t);
2566
+ return out;
2567
+ }
2568
+
2569
+ function packExcerpt(model, ex, fresh) {
2570
+ const lines = fileLines(model, ex.file) || [];
2571
+ const to = Math.min(ex.b, ex.a + PACK_RANGE_LINES - 1);
2572
+ const ext = (ex.file.split(".").pop() || "").toLowerCase();
2573
+ const body = lines.slice(ex.a - 1, to).join("\n");
2574
+ const more = ex.b > to ? `\n… ${ex.b - to} more lines, read ${to + 1}-${ex.b}` : "";
2575
+ const changed = fresh !== "current" ? " (CHANGED since index — lines may have moved)" : "";
2576
+ return `### ${ex.file}:${ex.a}-${ex.b} · ${ex.names.join(", ")}${changed}\n\`\`\`${LANG_FENCE[ext] || ext}\n${body}\n\`\`\`${more}`;
2577
+ }
2578
+
2579
+ // R4: the spec closest to a file. By NAME first (`<Name>*.spec.*` /
2580
+ // `<Name>*.test.*` under a tests folder), then a test that reaches it.
2581
+ const PACK_SPEC_FILES = 2;
2582
+ const PACK_SPEC_SETUP_LINES = 45;
2583
+ function closestSpec(model, rel) {
2584
+ const name = rel.split("/").pop().replace(/\.[^.]+$/, "");
2585
+ const byName = [...model.fileSet]
2586
+ .filter((f) => /(^|\/)(tests?|__tests__)\//.test(f))
2587
+ .filter((f) => {
2588
+ const b = f.split("/").pop();
2589
+ return b.startsWith(name) && /\.(spec|test)\.[^.]+$/.test(b);
2590
+ })
2591
+ .sort((a, b) => a.length - b.length || a.localeCompare(b));
2592
+ if (byName.length) return byName[0];
2593
+ return packTestsOf(model, rel)[0] || null;
2594
+ }
2595
+
2596
+ // The end line of a block that opens on line `i` (0-based): bracket balance,
2597
+ // strings stripped.
2598
+ function blockEnd(lines, i) {
2599
+ let depth = 0;
2600
+ let opened = false;
2601
+ for (let k = i; k < lines.length; k++) {
2602
+ for (const ch of lines[k].replace(/(['"`])(?:\\.|(?!\1).)*\1/g, "")) {
2603
+ if (ch === "{" || ch === "(" || ch === "[") {
2604
+ depth++;
2605
+ opened = true;
2606
+ } else if (ch === "}" || ch === ")" || ch === "]") depth--;
2607
+ }
2608
+ if (opened && depth <= 0) return k;
2609
+ }
2610
+ return i;
2611
+ }
2612
+
2613
+ // The spec's top-level outline (helpers and `describe` blocks with ranges)
2614
+ // and its SETUP quoted: line 1 through the end of the last top-level
2615
+ // jest.mock / import / const / let before the first `describe`.
2616
+ function specSetup(model, spec, rel) {
2617
+ const lines = fileLines(model, spec) || [];
2618
+ if (!lines.length) return null;
2619
+ const outline = [];
2620
+ let firstDescribe = -1;
2621
+ for (let i = 0; i < lines.length; i++) {
2622
+ const l = lines[i];
2623
+ const d = /^describe(?:\.\w+)?\s*\(\s*(['"`])(.*?)\1/.exec(l);
2624
+ const h = /^(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)|^(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.exec(l);
2625
+ if (d) {
2626
+ if (firstDescribe < 0) firstDescribe = i;
2627
+ const e = blockEnd(lines, i);
2628
+ outline.push(`describe "${d[2]}" ${i + 1}-${e + 1}`);
2629
+ i = e;
2630
+ } else if (h) {
2631
+ const e = blockEnd(lines, i);
2632
+ outline.push(`${h[1] || h[2]}() ${i + 1}-${e + 1}`);
2633
+ i = e;
2634
+ }
2635
+ }
2636
+ const stop = firstDescribe < 0 ? lines.length : firstDescribe;
2637
+ let last = -1;
2638
+ for (let i = 0; i < stop; i++) {
2639
+ if (!/^(jest\.mock\s*\(|import\b|const\b|let\b)/.test(lines[i])) continue;
2640
+ let e = i;
2641
+ if (/^import\b/.test(lines[i])) {
2642
+ while (e < stop - 1 && !/\bfrom\b|^import\s+['"]|;\s*$/.test(lines[e])) e++;
2643
+ } else e = blockEnd(lines, i);
2644
+ last = Math.max(i, e);
2645
+ i = last;
2646
+ }
2647
+ const head = `closest spec for ${rel.split("/").pop()}: ${spec} · ${lines.length} lines${outline.length ? ` — ${outline.join("; ")}` : ""}`;
2648
+ if (last < 0) return head;
2649
+ const to = Math.min(last + 1, PACK_SPEC_SETUP_LINES);
2650
+ const ext = (spec.split(".").pop() || "").toLowerCase();
2651
+ const more = last + 1 > to ? `\n… setup goes on to line ${last + 1}` : "";
2652
+ return `${head}\nsetup 1-${to}:\n\`\`\`${LANG_FENCE[ext] || ext}\n${lines.slice(0, to).join("\n")}\n\`\`\`${more}`;
2653
+ }
2654
+
2655
+ // `opts`: { declared, refs (null = pick from the map), focus, budget, sliceCard }
2656
+ function buildPack(claudeDir, model, opts) {
2657
+ const SIG = require("./graph-signals.js");
2658
+ const o = opts || {};
2659
+ const budget = Math.max(500, Math.min(12000, Number(o.budget) || PACK_QUOTE_BUDGET));
2660
+ const declared = [...new Set((o.declared || []).map(norm))];
2661
+ const known = declared.filter((r) => model.fileSet.has(r));
2662
+ const fresh = declared.filter((r) => !model.fileSet.has(r));
2663
+ const declaredSet = new Set(known);
2664
+ const focusList = (o.focus || []).filter(Boolean);
2665
+
2666
+ // ── refs ──
2667
+ let refs = [];
2668
+ const refMissing = [];
2669
+ if (o.refs && o.refs.length) {
2670
+ for (const q of o.refs) {
2671
+ const r = resolveRef(model, q);
2672
+ if (r.candidates) return { ok: false, state: "ambiguous-target", query: String(q), candidates: r.candidates.map((file) => ({ file })), exit: 4 };
2673
+ if (r.missing) refMissing.push(r.missing);
2674
+ else if (!refs.includes(r.file)) refs.push(r.file);
2675
+ }
2676
+ } else {
2677
+ const m = graphMap(claudeDir, model, { focus: [...known, ...focusList], budget: 8000 });
2678
+ refs = (m.files || []).map((x) => x.file).filter((f) => !declaredSet.has(f) && !TEST_FILE.test(f)).slice(0, PACK_AUTO_REFS);
2679
+ }
2680
+ refs = refs.slice(0, PACK_MAX_REFS);
2681
+ // R3: the NEIGHBOURHOOD — outline only, never quoted. Ranked: children of
2682
+ // the given refs (two hops), their parents, FILE-NAME focus matches, then
2683
+ // the children of a declared component. Tests and files already present
2684
+ // are skipped; cap 12.
2685
+ const parents = [];
2686
+ const isSfc = (f) => /\.(vue|svelte)$/.test(f);
2687
+ const base = (f) => f.split("/").pop();
2688
+ const addNeigh = (file, tag) => {
2689
+ if (parents.length >= PACK_NEIGH_MAX || TEST_FILE.test(file) || refs.includes(file) || declaredSet.has(file) || parents.some((x) => x.file === file)) return false;
2690
+ parents.push({ file, tag });
2691
+ return true;
2692
+ };
2693
+ const addChildren = (rel) => {
2694
+ const got = [];
2695
+ for (const c of componentChildren(model, rel)) {
2696
+ if (got.length >= PACK_CHILDREN_PER_REF) break;
2697
+ if (addNeigh(c, `child of ${base(rel)}`)) got.push(c);
2698
+ }
2699
+ return got;
2700
+ };
2701
+ const hop1 = refs.filter(isSfc).flatMap((rel) => addChildren(rel));
2702
+ for (const c of hop1) addChildren(c);
2703
+ for (const rel of refs.filter(isSfc)) {
2704
+ let n = 0;
2705
+ for (const p of templateParents(model, rel)) if (n < PACK_PARENTS_PER_REF && addNeigh(p, `parent of ${base(rel)}`)) n++;
2706
+ }
2707
+ // R3b: name matches — the focus words found in the file's OWN name (no
2708
+ // folder, no extension). Tie-break: the number of files that import it.
2709
+ let multiHits = [];
2710
+ let fw = [...new Set(focusList.flatMap((w) => focusWords(w)).filter((w) => w.length >= 2))];
2711
+ if (fw.length && parents.length < PACK_NEIGH_MAX) {
2712
+ // R3c: each word weighs IDF_name = log(1 + N / n_word) over the FILE
2713
+ // NAMES of the indexed non-test files, so a word in dozens of names
2714
+ // weighs little. Ties: the number of files that import it.
2715
+ const ownWords = new Map();
2716
+ for (const rel of model.fileSet) if (!TEST_FILE.test(rel)) ownWords.set(rel, new Set(focusWords(base(rel).replace(/\.[^.]+$/, ""))));
2717
+ // R3d: a word in a DECLARED file's own name describes the target — it
2718
+ // only finds siblings, so it does not count.
2719
+ const declWords = new Set(known.flatMap((rel) => focusWords(base(rel).replace(/\.[^.]+$/, ""))));
2720
+ fw = fw.filter((w) => !declWords.has(w));
2721
+ const N = ownWords.size;
2722
+ const idf = new Map(fw.map((w) => [w, Math.log(1 + N / Math.max(1, [...ownWords.values()].filter((o) => [...o].some((seg) => wordMatch(w, seg))).length))]));
2723
+ const hits = [];
2724
+ for (const [rel, own] of ownWords) {
2725
+ if (declaredSet.has(rel) || refs.includes(rel)) continue;
2726
+ const words = fw.filter((w) => [...own].some((seg) => wordMatch(w, seg)));
2727
+ // R4: a file whose name holds several focus words is the likely
2728
+ // copy-from file: (sum of IDF) x (1 + 0.5 x (k - 1)), and k >= 2 always
2729
+ // ranks before any single-word hit.
2730
+ if (words.length) hits.push({ rel, words, score: words.reduce((a, w) => a + idf.get(w), 0) * (1 + 0.5 * (words.length - 1)), imp: importersOf(model, rel).length });
2731
+ }
2732
+ hits.sort((a, b) => (b.words.length >= 2) - (a.words.length >= 2) || b.score - a.score || b.imp - a.imp || a.rel.localeCompare(b.rel));
2733
+ multiHits = hits.filter((h) => h.words.length >= 2).map((h) => h.rel);
2734
+ let n = 0;
2735
+ for (const h of hits) {
2736
+ if (n >= PACK_NAME_REFS) break;
2737
+ if (addNeigh(h.rel, `name match: ${h.words.join(", ")} (${h.score.toFixed(2)})`)) n++;
2738
+ }
2739
+ }
2740
+ for (const rel of known.filter(isSfc)) addChildren(rel);
2741
+ warmFiles(model, new Set([...known, ...refs, ...parents.map((x) => x.file)]));
2742
+
2743
+ // ── §3: the candidates, scored ──
2744
+ const want = new Set(focusList.flatMap((w) => focusWords(w)).filter((w) => w.length >= 2));
2745
+ const cands = new Map();
2746
+ for (const rel of refs) for (const s of model.byFile[rel].symbols || []) if (PACK_QUOTE_KINDS.has(s.kind)) cands.set(s.id, s);
2747
+ for (const s of [...cands.values()]) {
2748
+ for (const { res } of calleesOf(model, s)) {
2749
+ if (!CONFIDENT.has(res.state)) continue;
2750
+ const t = model.symbols.get(res.target);
2751
+ if (t && declaredSet.has(t.file) && !PACK_NEVER_QUOTE.has(t.kind)) cands.set(t.id, t);
2752
+ }
2753
+ }
2754
+ const called = new Set();
2755
+ for (const s of cands.values()) {
2756
+ for (const { res } of calleesOf(model, s)) if (CONFIDENT.has(res.state) && res.target !== s.id && cands.has(res.target)) called.add(res.target);
2757
+ }
2758
+ // R2: only a short helper (<= 30 lines) is quoted, whole; a member of a
2759
+ // big edit target (outlined in §1, read in full anyway) is never quoted.
2760
+ const bigSet = new Set(known.filter((rel) => (Number((model.byFile[rel] || {}).lines) || 0) > PACK_BIG_LINES));
2761
+ for (const [id, s] of [...cands]) if (s.lines[1] - s.lines[0] + 1 > PACK_QUOTE_MAX_LINES || bigSet.has(s.file)) cands.delete(id);
2762
+ const scored = [...cands.values()].map((s) => {
2763
+ const words = new Set(focusWords(s.qname));
2764
+ let score = 0;
2765
+ for (const w of want) if (words.has(w)) score += 3;
2766
+ if (called.has(s.id)) score += 2;
2767
+ if (s.exported || !/^[_#]/.test(s.name)) score += 1;
2768
+ const imp = SIG.importance(model, s, callersOf(model, s).confident.length);
2769
+ const relevance = score - (s.exported || !/^[_#]/.test(s.name) ? 1 : 0);
2770
+ return { s, score, imp, relevance };
2771
+ });
2772
+ scored.sort((a, b) => b.score - a.score || b.imp - a.imp || a.s.file.localeCompare(b.s.file) || a.s.lines[0] - b.s.lines[0]);
2773
+ // T6c: the quotes are SPREAD — round 1 takes the best candidate of EACH ref
2774
+ // file (in ref order), round 2 the second best, and so on. A file whose best
2775
+ // candidate has no focus word and no caller among the candidates gets its
2776
+ // single most important one, in round 1 only.
2777
+ const groupOrder = [...refs, ...known];
2778
+ const groups = groupOrder.map((rel) => {
2779
+ const g = scored.filter((x) => x.s.file === rel);
2780
+ if (g.length && g[0].relevance <= 0) return g.slice().sort((a, b) => b.imp - a.imp || a.s.lines[0] - b.s.lines[0]).slice(0, 1);
2781
+ return g;
2782
+ });
2783
+ const spread = [];
2784
+ for (let round = 0; groups.some((g) => g.length > round); round++) for (const g of groups) if (g[round]) spread.push(g[round]);
2785
+
2786
+ // In score order until the budget is used. A range that overlaps one
2787
+ // already taken is MERGED into it; one that sits inside it is skipped.
2788
+ const chosen = [];
2789
+ let used = 0;
2790
+ let overBudget = 0;
2791
+ const costOf = (ex) => tok(packExcerpt(model, ex, blobState(model, ex.file))) + 1;
2792
+ for (const { s } of spread) {
2793
+ const a = s.lines[0];
2794
+ const b = s.lines[1];
2795
+ const over = chosen.filter((x) => x.file === s.file && x.a <= b && a <= x.b);
2796
+ if (over.some((x) => x.a <= a && b <= x.b)) continue;
2797
+ const merged = {
2798
+ file: s.file,
2799
+ a: Math.min(a, ...over.map((x) => x.a)),
2800
+ b: Math.max(b, ...over.map((x) => x.b)),
2801
+ names: [...over.flatMap((x) => x.names), s.qname],
2802
+ };
2803
+ merged.tokens = costOf(merged);
2804
+ const was = over.reduce((n, x) => n + x.tokens, 0);
2805
+ if (used - was + merged.tokens > budget) {
2806
+ overBudget++;
2807
+ continue;
2808
+ }
2809
+ for (const x of over) chosen.splice(chosen.indexOf(x), 1);
2810
+ chosen.push(merged);
2811
+ used += merged.tokens - was;
2812
+ }
2813
+ const order = new Map([...refs, ...known].map((f, i) => [f, i]));
2814
+ chosen.sort((x, y) => (order.get(x.file) || 0) - (order.get(y.file) || 0) || x.a - y.a);
2815
+
2816
+ // ── §1, §2, §4 ── (T6c: three budgets, never traded)
2817
+ const big = known.filter((rel) => (Number((model.byFile[rel] || {}).lines) || 0) > PACK_BIG_LINES);
2818
+ const outlines1 = big.map((rel) => packOutline(model, rel, true, null, focusList));
2819
+ const outlines2 = [...refs.map((rel) => packOutline(model, rel, false)), ...parents.map((p) => packOutline(model, p.file, false, p.tag))];
2820
+ const outlineBudget = Math.max(200, Math.min(12000, Number(o.outlineBudget) || PACK_OUTLINE_BUDGET));
2821
+ const cut = [];
2822
+ // The OUTLINE budget: outlines are never cut for quotes. Over their own
2823
+ // budget, the SMALLEST members go first, and each row says `+<n> members`.
2824
+ const all = [...outlines1, ...outlines2];
2825
+ let outlineTok = tok(outlineText(all));
2826
+ if (outlineTok > outlineBudget) {
2827
+ // The members get what the fixed rows leave — but never less than 40 % of
2828
+ // the outline budget — shared EQUALLY by the files that have members, so
2829
+ // one long router outline cannot empty every other file's row. Inside a
2830
+ // file the largest members are kept and the smallest go first.
2831
+ const rowsWith = all.flatMap((x) => x.rows.filter((r) => r.part === "members"));
2832
+ const memberTok = rowsWith.reduce((a, r) => a + r.members.reduce((n, m) => n + Math.ceil((m.text.length + 2) / 4), 0), 0);
2833
+ const fixed = outlineTok - memberTok;
2834
+ const share = Math.floor(Math.max(outlineBudget - fixed, Math.ceil(outlineBudget * 0.4)) / Math.max(1, rowsWith.length));
2835
+ let dropped = 0;
2836
+ for (const r of rowsWith) {
2837
+ let used = 0;
2838
+ for (const m of r.members.slice().sort((a, b) => b.span - a.span)) {
2839
+ const c = Math.ceil((m.text.length + 2) / 4);
2840
+ if (used + c <= share) used += c;
2841
+ else {
2842
+ m.keep = false;
2843
+ dropped++;
2844
+ }
2845
+ }
2846
+ }
2847
+ if (dropped) cut.push(`§1/§2 ${dropped} smallest member(s) over the ${outlineBudget}-token outline budget`);
2848
+ // R2b: §1 and §2 outlines share ONE hard budget. Still over → route rows
2849
+ // past the first 10, then the smallest kept members, then the
2850
+ // imports / used-by / tests rows (last file first).
2851
+ let hard = 0;
2852
+ const over = () => tok(outlineText(all)) > outlineBudget;
2853
+ while (over()) {
2854
+ const rt = all.find((x) => x.rows.filter((r) => r.part === "routes" && !/^ {2}\+\d+ (?:other )?routes$/.test(r.text)).length > 10);
2855
+ if (rt) {
2856
+ const rows = rt.rows.filter((r) => r.part === "routes" && !/^ {2}\+\d+ (?:other )?routes$/.test(r.text));
2857
+ rt.rows.splice(rt.rows.indexOf(rows[rows.length - 1]), 1);
2858
+ const more = rt.rows.find((r) => r.part === "routes" && /^ {2}\+\d+ (?:other )?routes$/.test(r.text));
2859
+ if (more) more.text = ` +${Number(/\d+/.exec(more.text)[0]) + 1} other routes`;
2860
+ else rt.rows.splice(rt.rows.indexOf(rows[rows.length - 2]) + 1, 0, { part: "routes", text: " +1 other routes" });
2861
+ hard++;
2862
+ continue;
2863
+ }
2864
+ const kept = all.flatMap((x) => x.rows.filter((r) => r.part === "members").flatMap((r) => r.members.filter((m) => m.keep)));
2865
+ if (kept.length) {
2866
+ kept.sort((a, b) => a.span - b.span)[0].keep = false;
2867
+ hard++;
2868
+ continue;
2869
+ }
2870
+ const opt = [...all].reverse().find((x) => x.rows.some((r) => ["imports", "used", "tests"].includes(r.part)));
2871
+ if (!opt) break;
2872
+ const i = opt.rows.map((r) => ["imports", "used", "tests"].includes(r.part)).lastIndexOf(true);
2873
+ opt.rows.splice(i, 1);
2874
+ hard++;
2875
+ }
2876
+ if (hard) cut.push(`§1/§2 ${hard} more row(s) or member(s) to keep the ${outlineBudget}-token outline budget`);
2877
+ }
2878
+ const testFiles = [...new Set([...known, ...refs].flatMap((rel) => packTestsOf(model, rel)))].sort();
2879
+ const quoted = chosen.map((ex) => packExcerpt(model, ex, blobState(model, ex.file)));
2880
+ const gen = model.meta.generation || 0;
2881
+ const nFiles = new Set([...declared, ...refs]).size;
2882
+
2883
+ // R4: the CLOSEST spec's setup. For each given ref and each name match of
2884
+ // >= 2 words (max 2 files): its spec's top-level outline and the setup lines
2885
+ // (top of file through the last top-level jest.mock / import / const before
2886
+ // the first describe), max 45 lines.
2887
+ const specFor = [...(o.refs && o.refs.length ? refs : []), ...multiHits.filter((f) => parents.some((p) => p.file === f))].slice(0, PACK_SPEC_FILES);
2888
+ const specBlocks = [];
2889
+ const specSeen = new Set();
2890
+ for (const rel of specFor) {
2891
+ const spec = closestSpec(model, rel);
2892
+ if (!spec || specSeen.has(spec)) continue;
2893
+ specSeen.add(spec);
2894
+ const b = specSetup(model, spec, rel);
2895
+ if (b) specBlocks.push(b);
2896
+ }
2897
+
2898
+ // The TESTS budget (700 tokens): rows go from the end.
2899
+ let testLines = [];
2900
+ if (testFiles.length) {
2901
+ const rows = testRows(model, testFiles[0]);
2902
+ testLines = [`${testFiles[0]}${rows.length ? ` — ${rows.join("; ")}` : ""}`, ...testFiles.slice(1, 5)];
2903
+ let n = 0;
2904
+ while (testLines.length > 1 && tok([...specBlocks, ...testLines].join("\n")) > PACK_TESTS_BUDGET) {
2905
+ testLines.pop();
2906
+ n++;
2907
+ }
2908
+ if (tok(testLines.join("\n")) > PACK_TESTS_BUDGET) {
2909
+ testLines = [testFiles[0]];
2910
+ n++;
2911
+ }
2912
+ if (n) cut.push(`§4 ${n} row(s) over the ${PACK_TESTS_BUDGET}-token tests budget`);
2913
+ }
2914
+ while (specBlocks.length && tok([...specBlocks, ...testLines].join("\n")) > PACK_TESTS_BUDGET) {
2915
+ specBlocks.pop();
2916
+ cut.push(`§4 a spec setup over the ${PACK_TESTS_BUDGET}-token tests budget`);
2917
+ }
2918
+
2919
+ const parts = [
2920
+ `# graph pack · gen ${gen} · ${nFiles} files`,
2921
+ "Read this pack ALONE, with the Read tool, before anything else. It is a map: file outlines with line",
2922
+ "ranges, plus a few short helpers quoted whole. Then read ONLY the ranges you need, with Read",
2923
+ "offset/limit or `sed -n a,bp` — one range per member you need, never a whole file over 300 lines",
2924
+ "that the outline covers. A file you will EDIT: read it in full first (read-ladder exception 1).",
2925
+ "",
2926
+ "## 1. Files to change — outside view",
2927
+ ];
2928
+ // R2b: inside a pack the outside view gets at most ${PACK_SLICE_BUDGET} tokens in total.
2929
+ if (o.sliceCard) {
2930
+ const sc = known.length ? forSlice(model, known, { budget: PACK_SLICE_BUDGET }) : null;
2931
+ parts.push(sc && sc.ok ? sc.card : o.sliceCard);
2932
+ }
2933
+ if (outlines1.length) parts.push(outlineText(outlines1));
2934
+ if (fresh.length) parts.push(`new (no record yet): ${fresh.join(", ")}`);
2935
+ if (!o.sliceCard && !outlines1.length && !fresh.length) parts.push("(no declared file)");
2936
+ parts.push("", "## 2. Reference files — outline");
2937
+ parts.push(outlines2.length ? outlineText(outlines2) : "(no reference file)");
2938
+ if (refMissing.length) parts.push(`not in the graph: ${refMissing.join(", ")}`);
2939
+ parts.push("", "## 3. Quoted ranges");
2940
+ parts.push(quoted.length ? quoted.join("\n\n") : "(nothing quoted)");
2941
+ parts.push("", "## 4. Tests that reach these files");
2942
+ parts.push(testLines.length ? testLines.join("\n") : "(no test reaches these files)");
2943
+ if (specBlocks.length) parts.push("", specBlocks.join("\n\n"));
2944
+ let text = parts.join("\n");
2945
+ // §3 keeps to its own budget; a range the budget left out is named.
2946
+ if (overBudget) cut.push(`§3 ${overBudget} range(s) over the ${budget}-token quote budget`);
2947
+ if (cut.length) text += `\n\ncut: ${cut.join(", ")}`;
2948
+ text += "\n";
2949
+ return {
2950
+ ok: true,
2951
+ files: nFiles,
2952
+ refs,
2953
+ parents: parents.map((p) => ({ file: p.file, tag: p.tag })),
2954
+ refs_missing: refMissing,
2955
+ excerpts: chosen.length,
2956
+ quoted: chosen.map((x) => ({ file: x.file, from: x.a, to: x.b, names: x.names })),
2957
+ tokens: tok(text),
2958
+ cut,
2959
+ big_declared: big,
2960
+ budget,
2961
+ text,
2962
+ };
2963
+ }
2964
+
2965
+ // ── v2.2.0 (R6): the executor ANCHORS ───────────────────────────────────────
2966
+ // The pack file was read first but did not shorten the search: it STEERED the
2967
+ // first calls toward the files it outlined, and it rode along every later
2968
+ // turn. Turns, not bytes, drive cost. The anchors are the opposite shape: at
2969
+ // most 12 `path:a-b` ranges INLINE in the slice, and ONE shell command that
2970
+ // prints them all — one call, then the executor reads on from there.
2971
+ //
2972
+ // Never a whole file, never a test helper, ranked by focus score, overlapping
2973
+ // ranges in one file merged. No file is written.
2974
+ const ANCHOR_MAX = 12;
2975
+ const ANCHOR_REF_MEMBERS = 3;
2976
+ const ANCHOR_DECL_MEMBERS = 2;
2977
+ const ANCHOR_RANGE_LINES = 80;
2978
+ // v2.2.0 (R8): the card carries the code inline, so its cap follows the read cap.
2979
+ const ANCHOR_CARD_BYTES = 11264;
2980
+ // v2.2.0 (R7): five-line ranges were not enough to copy from; executors
2981
+ // `cat` the whole file. Each range is padded, and the read is capped.
2982
+ const ANCHOR_PAD = 6;
2983
+ const ANCHOR_READ_TOKENS = 2500;
2984
+ const ANCHOR_CALLERS = 6;
2985
+ const ANCHOR_CALLER_PAD = 4;
2986
+ const ANCHOR_CALLER_SCORE = 10;
2987
+ // v2.2.0 (R10): the caller cap when a focus word names a declared export.
2988
+ const ANCHOR_FOCUS_CALLERS = 8;
2989
+ const ANCHOR_NEVER = new Set(["module", "template", "prop", "emit", "inject"]);
2990
+ const SAFE_PATH = /^[A-Za-z0-9._@+\/-]+$/;
2991
+
2992
+ // v2.2.0: a declared file with this many route records is a ROUTER.
2993
+ const ANCHOR_ROUTER_MIN = 10;
2994
+ const ANCHOR_COMMENT_REACH = 15;
2995
+ const QUOTED_PATH_COMMENT = /(\/\/|\/\*|^\s*\*).*['"`][^'"`\s]*\/[^'"`\s]*['"`]/;
2996
+
2997
+ // The top feature folder: the first segment after `src/views/`, else after `src/`.
2998
+ function featureOf(rel) {
2999
+ const m = rel.match(/(?:^|\/)src\/views\/([^/]+)\//) || rel.match(/(?:^|\/)src\/([^/]+)\//);
3000
+ return m ? m[1] : "";
3001
+ }
3002
+
3003
+ function anchorHits(want, s) {
3004
+ const words = focusWords(`${s.qname} ${s.route_name || ""}`);
3005
+ return [...want].filter((w) => words.some((seg) => wordMatch(w, seg)));
3006
+ }
3007
+
3008
+ function anchorMembers(model, rel) {
3009
+ const f = model.byFile[rel] || {};
3010
+ const nLines = Number(f.lines) || (fileLines(model, rel) || []).length;
3011
+ const sfc = /\.(vue|svelte)$/.test(rel);
3012
+ return (f.symbols || []).filter((s) => {
3013
+ if (ANCHOR_NEVER.has(s.kind)) return false;
3014
+ if (sfc && s.kind === "class" && !s.qname.includes(".")) return false;
3015
+ const span = s.lines[1] - s.lines[0] + 1;
3016
+ return !(nLines && span >= Math.max(10, nLines * 0.9));
3017
+ });
3018
+ }
3019
+
3020
+ // `opts`: { declared, refs (null = name match), focus }
3021
+ function buildAnchors(claudeDir, model, opts) {
3022
+ const SIG = require("./graph-signals.js");
3023
+ const o = opts || {};
3024
+ const declared = [...new Set((o.declared || []).map(norm))];
3025
+ const known = declared.filter((r) => model.fileSet.has(r) && !TEST_FILE.test(r));
3026
+ const declaredSet = new Set(known);
3027
+ const focusList = (o.focus || []).filter(Boolean);
3028
+ const want = new Set(focusList.flatMap((w) => focusWords(w)).filter((w) => w.length >= 2));
3029
+ const base = (f) => f.split("/").pop();
3030
+ const tokens = new Set(focusList.flatMap((w) => String(w).split(/[\s,]+/)).map((w) => w.toLowerCase()).filter(Boolean));
3031
+ const focusCallees = [];
3032
+ for (const rel of known) {
3033
+ for (const s of (model.byFile[rel] || {}).symbols || []) {
3034
+ // R10b: CALLABLE only — a function or method, or an exported const with a
3035
+ // confident caller (an arrow helper). Never a class (a Vue SFC component).
3036
+ if (!s.exported || !(s.kind === "function" || s.kind === "method" || (s.kind === "const" && callersOf(model, s).confident.length))) continue;
3037
+ const nm = s.name || String(s.qname || "").split(".").pop();
3038
+ const nw = focusWords(nm);
3039
+ if (tokens.has(String(nm).toLowerCase()) || (nw.length >= 2 && nw.every((w) => [...want].some((x) => wordMatch(x, w))))) focusCallees.push({ rel, s, nm });
3040
+ }
3041
+ }
3042
+ const focusMode = focusCallees.length > 0;
3043
+
3044
+ // ── refs: given, else a name match of >= 2 focus words, else the best one ──
3045
+ let refs = [];
3046
+ const refMissing = [];
3047
+ if (o.refs && o.refs.length) {
3048
+ for (const q of o.refs) {
3049
+ const r = resolveRef(model, q);
3050
+ if (r.candidates) return { ok: false, state: "ambiguous-target", query: String(q), candidates: r.candidates.map((file) => ({ file })), exit: 4 };
3051
+ if (r.missing) refMissing.push(r.missing);
3052
+ else if (!refs.includes(r.file) && !TEST_FILE.test(r.file)) refs.push(r.file);
3053
+ }
3054
+ } else if (want.size && !focusMode) {
3055
+ // R10b: in focus mode the call sites are the work; no auto name-matched refs.
3056
+ const declWords = new Set(known.flatMap((rel) => focusWords(base(rel).replace(/\.[^.]+$/, ""))));
3057
+ const fw = [...want].filter((w) => !declWords.has(w));
3058
+ const hits = [];
3059
+ for (const rel of model.fileSet) {
3060
+ if (TEST_FILE.test(rel) || declaredSet.has(rel)) continue;
3061
+ const own = focusWords(base(rel).replace(/\.[^.]+$/, ""));
3062
+ const words = fw.filter((w) => own.some((seg) => wordMatch(w, seg)));
3063
+ if (words.length) hits.push({ rel, n: words.length, imp: importersOf(model, rel).length });
3064
+ }
3065
+ const featA = new Set(known.map(featureOf).filter(Boolean));
3066
+ const xf = (rel) => (featA.has(featureOf(rel)) ? 0 : 1);
3067
+ hits.sort((a, b) => xf(a.rel) - xf(b.rel) || b.n - a.n || b.imp - a.imp || a.rel.localeCompare(b.rel));
3068
+ const multi = hits.filter((h) => h.n >= 2).map((h) => h.rel);
3069
+ refs = multi.length ? multi.slice(0, PACK_AUTO_REFS) : hits.slice(0, 1).map((h) => h.rel);
3070
+ }
3071
+ refs = refs.slice(0, PACK_MAX_REFS);
3072
+ warmFiles(model, new Set([...known, ...refs]));
3073
+
3074
+ // ── candidates ──
3075
+ const cands = [];
3076
+ const add = (s, score, why) => {
3077
+ const a = s.lines[0];
3078
+ const b = Math.min(s.lines[1], a + ANCHOR_RANGE_LINES - 1);
3079
+ cands.push({ file: s.file, a, b, names: [s.kind === "route" && !/^VIEW\b/.test(s.qname) ? `VIEW ${s.qname}` : s.qname], score, why: [why] });
3080
+ };
3081
+ // v2.2.0: a ROUTER edit target always gives the route record nearest the
3082
+ // change, plus any comment within reach that quotes a path.
3083
+ for (const rel of known) {
3084
+ const routes = ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind === "route");
3085
+ if (routes.length < ANCHOR_ROUTER_MIN) continue;
3086
+ const best = routes
3087
+ .map((r) => ({ r, n: anchorHits(want, r).length }))
3088
+ .sort((x, y) => y.n - x.n || x.r.lines[0] - y.r.lines[0])[0].r;
3089
+ const src = fileLines(model, rel) || [];
3090
+ let a = Math.max(1, best.lines[0] - ANCHOR_PAD);
3091
+ let b = Math.min(src.length || best.lines[1], best.lines[1] + ANCHOR_PAD);
3092
+ const lo = Math.max(1, best.lines[0] - ANCHOR_COMMENT_REACH);
3093
+ const hi = Math.min(src.length, best.lines[1] + ANCHOR_COMMENT_REACH);
3094
+ for (let i = lo; i <= hi; i++) {
3095
+ if (QUOTED_PATH_COMMENT.test(src[i - 1] || "")) { a = Math.min(a, i); b = Math.max(b, i); }
3096
+ }
3097
+ const hits = anchorHits(want, best);
3098
+ cands.push({ file: rel, a, b, names: [/^VIEW\b/.test(best.qname) ? best.qname : `VIEW ${best.qname}`], score: 1000, why: [`router: ${hits.length ? hits.join(", ") : "nearest route"}`], fixed: true });
3099
+ }
3100
+ for (const rel of focusMode ? [] : known) {
3101
+ // R10b: in focus mode the call sites ARE the edit targets — no generic member scan.
3102
+ if ((Number((model.byFile[rel] || {}).lines) || 0) <= PACK_BIG_LINES) continue;
3103
+ const scored = anchorMembers(model, rel)
3104
+ .map((s) => ({ s, hits: anchorHits(want, s) }))
3105
+ .filter((x) => x.hits.length)
3106
+ .sort((x, y) => y.hits.length - x.hits.length || (x.s.kind === "route") - (y.s.kind === "route") || x.s.lines[0] - y.s.lines[0]);
3107
+ for (const { s, hits } of scored.slice(0, ANCHOR_DECL_MEMBERS)) add(s, hits.length * 3 + 1, `edit target: ${hits.join(", ")}`);
3108
+ }
3109
+ // v2.2.0: a declared file's exported symbols called from OTHER files give up
3110
+ // to 6 CALLER anchors (the call line +-4, inside the caller's own symbol) —
3111
+ // the edit sites of a signature change. Ranked by focus words, then file.
3112
+ // v2.2.0 (R10): FOCUS CALLEES — an export of a declared file whose name IS a
3113
+ // focus token, or whose >= 2 name words are all focus words. When one exists,
3114
+ // only its callers count, declared files INCLUDED (those are the call sites a
3115
+ // signature change edits), up to 8, above every edit-target hit, and its own
3116
+ // definition comes first. With none, the rule above holds unchanged.
3117
+ for (const { rel, s, nm } of focusCallees) {
3118
+ const a = s.lines[0];
3119
+ cands.push({ file: rel, a, b: Math.min(s.lines[1], a + ANCHOR_RANGE_LINES - 1), names: [s.qname], score: ANCHOR_CALLER_SCORE + 30, why: [`edit target: ${nm}`], fixed: true });
3120
+ }
3121
+ const callerSrc = focusMode
3122
+ ? focusCallees.map((x) => ({ rel: x.rel, syms: [x.s] }))
3123
+ : known.map((rel) => ({ rel, syms: ((model.byFile[rel] || {}).symbols || []).filter((s) => s.exported && !ANCHOR_NEVER.has(s.kind) && s.kind !== "route") }));
3124
+ const callerC = [];
3125
+ for (const { rel, syms } of callerSrc) {
3126
+ for (const s of syms) {
3127
+ for (const c of callersOf(model, s).confident) {
3128
+ const cs = c.sym;
3129
+ if (!cs || !cs.file || cs.file === rel || (!focusMode && declaredSet.has(cs.file)) || TEST_FILE.test(cs.file) || !c.line) continue;
3130
+ const hits = anchorHits(want, cs).concat(focusWords(base(cs.file).replace(/\.[^.]+$/, "")).filter((w) => want.has(w)));
3131
+ const a = Math.max(1, cs.lines[0], c.line - ANCHOR_CALLER_PAD);
3132
+ const b = Math.min(cs.lines[1], c.line + ANCHOR_CALLER_PAD);
3133
+ callerC.push({ file: cs.file, a, b: Math.max(a, b), names: [cs.qname], score: ANCHOR_CALLER_SCORE + (focusMode ? 20 : 0) + new Set(hits).size, why: [`caller of ${s.qname}`], fixed: true });
3134
+ }
3135
+ }
3136
+ }
3137
+ callerC.sort((x, y) => y.score - x.score || x.file.localeCompare(y.file) || x.a - y.a);
3138
+ const callerSeen = new Set();
3139
+ for (const c of callerC) {
3140
+ const k = `${c.file}:${c.a}`;
3141
+ if (callerSeen.has(k) || callerSeen.size >= (focusMode ? ANCHOR_FOCUS_CALLERS : ANCHOR_CALLERS)) continue;
3142
+ callerSeen.add(k);
3143
+ cands.push(c);
3144
+ }
3145
+ const featD = new Set(known.map(featureOf).filter(Boolean));
3146
+ const given = new Set(o.refs && o.refs.length ? refs : []);
3147
+ for (const rel of refs) {
3148
+ // v2.2.0: a cross-feature copy-from ranks after a same-feature one, unless given.
3149
+ const pen = !given.has(rel) && featD.size && !featD.has(featureOf(rel)) ? 100 : 0;
3150
+ const scored = anchorMembers(model, rel)
3151
+ .map((s) => ({ s, hits: anchorHits(want, s), imp: SIG.importance(model, s, callersOf(model, s).confident.length) }))
3152
+ .sort((x, y) => y.hits.length - x.hits.length || y.imp - x.imp || x.s.lines[0] - y.s.lines[0]);
3153
+ for (const { s, hits } of scored.slice(0, ANCHOR_REF_MEMBERS)) add(s, hits.length * 3 - pen, hits.length ? `copy-from: ${hits.join(", ")}` : "copy-from");
3154
+ // v2.2.0 (R7): a .vue ref also gives its CONTRACT, the props/emits lines merged.
3155
+ if (/\.vue$/.test(rel)) {
3156
+ const con = ((model.byFile[rel] || {}).symbols || []).filter((x) => x.kind === "prop" || x.kind === "emit");
3157
+ if (con.length) {
3158
+ const a = Math.min(...con.map((x) => x.lines[0]));
3159
+ const b = Math.max(...con.map((x) => x.lines[1]));
3160
+ cands.push({ file: rel, a, b: Math.min(b, a + ANCHOR_RANGE_LINES - 1), names: ["props/emits"], score: 1 - pen, why: ["contract"] });
3161
+ }
3162
+ }
3163
+ }
3164
+
3165
+ // v2.2.0 (R7): pad every range, clipped to the file and to its owner block.
3166
+ for (const c of cands) {
3167
+ if (c.fixed) continue;
3168
+ const f = model.byFile[c.file] || {};
3169
+ const n = Number(f.lines) || (fileLines(model, c.file) || []).length || c.b;
3170
+ let lo = 1;
3171
+ let hi = n;
3172
+ const owner = (f.symbols || [])
3173
+ .filter((x) => x.lines[0] <= c.a && x.lines[1] >= c.b && x.lines[1] - x.lines[0] > c.b - c.a)
3174
+ .sort((x, y) => (x.lines[1] - x.lines[0]) - (y.lines[1] - y.lines[0]))[0];
3175
+ if (owner) { lo = owner.lines[0]; hi = owner.lines[1]; }
3176
+ c.a = Math.max(1, lo, c.a - ANCHOR_PAD);
3177
+ c.b = Math.min(n, hi, c.b + ANCHOR_PAD);
3178
+ if (c.b < c.a) c.b = c.a;
3179
+ }
3180
+
3181
+ // Rank, merge overlapping ranges in one file, then keep the best 12.
3182
+ cands.sort((x, y) => y.score - x.score || x.file.localeCompare(y.file) || x.a - y.a);
3183
+ let chosen = [];
3184
+ for (const c of cands) {
3185
+ if (!SAFE_PATH.test(c.file)) continue;
3186
+ const over = chosen.find((x) => x.file === c.file && x.a <= c.b && c.a <= x.b);
3187
+ if (over) {
3188
+ over.a = Math.min(over.a, c.a);
3189
+ over.b = Math.max(over.b, c.b);
3190
+ for (const n of c.names) if (!over.names.includes(n)) over.names.push(n);
3191
+ continue;
3192
+ }
3193
+ if (chosen.length < ANCHOR_MAX) chosen.push({ ...c });
3194
+ }
3195
+ const gen = model.meta.generation || 0;
3196
+ const inlineOf = (x) => /^(edit target|router|caller)/.test(x.why[0]);
3197
+ const render = (list) => {
3198
+ const lines = list.map((x) => {
3199
+ const changed = blobState(model, x.file) !== "current" ? ", CHANGED since index" : "";
3200
+ return `${x.file}:${x.a}-${x.b} ${x.names.join(", ")} (${x.why[0]}${changed})`;
3201
+ });
3202
+ const readCmd = list.length ? `for r in ${list.map((x) => `"${x.file} ${x.a} ${x.b}"`).join(" ")}; do set -- $r; echo "== $1:$2-$3"; sed -n "$2,$3p" "$1"; done` : "";
3203
+ // v2.2.0 (R8): the code of the EDIT-SITE ranges (edit target, router, caller)
3204
+ // INLINE — the same bytes `read all` prints. Copy-from and contract ranges stay
3205
+ // `path:a-b` lines: the executor reads those wider anyway.
3206
+ const code = list.filter(inlineOf).map((x) => `== ${x.file}:${x.a}-${x.b} · ${x.names[0]}\n${(fileLines(model, x.file) || []).slice(x.a - 1, x.b).join("\n")}`).join("\n");
3207
+ const ticks = Math.max(2, ...((code.match(/`+/g) || []).map((t) => t.length))) + 1;
3208
+ const fence = "`".repeat(ticks);
3209
+ const card = list.length ? `${lines.join("\n")}\nread all: ${readCmd}${code ? `\n${fence}\n${code}\n${fence}` : ""}` : "";
3210
+ return { lines, readCmd, code, card };
3211
+ };
3212
+ const readOf = (list) => list.filter(inlineOf).reduce((n, x) => n + tok((fileLines(model, x.file) || []).slice(x.a - 1, x.b).join("\n")), 0);
3213
+ while (chosen.length > 1 && readOf(chosen) > ANCHOR_READ_TOKENS) chosen = chosen.slice(0, -1);
3214
+ let out = render(chosen);
3215
+ while (chosen.length > 1 && Buffer.byteLength(out.card) > ANCHOR_CARD_BYTES) {
3216
+ chosen = chosen.slice(0, -1);
3217
+ out = render(chosen);
3218
+ }
3219
+ const files = new Set(chosen.map((x) => x.file)).size;
3220
+ const readTok = readOf(chosen);
3221
+ return {
3222
+ ok: true,
3223
+ refs,
3224
+ refs_missing: refMissing,
3225
+ ranges: chosen.length,
3226
+ files,
3227
+ lines: out.lines,
3228
+ read_cmd: out.readCmd,
3229
+ code: out.code,
3230
+ read_tokens: readTok,
3231
+ tokens: tok(out.card),
3232
+ card: out.card,
3233
+ gen,
3234
+ };
3235
+ }
3236
+
3237
+ module.exports = { buildPack, buildAnchors, loadModel, resolveCall, resolveUrl, routeTable, urlIndex, findTarget, callersOf, nameHits, warmBlobs, ctx, forSlice, impact, graphMap, pathBetween, importsOf, TEST_FILE, SOURCE_DEFAULT, SOURCE_CAP, CALLERS_SOURCE_MAX, CALLERS_SOURCE_LINES };