@polycode-projects/the-mechanical-code-talker 0.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.
@@ -0,0 +1,1972 @@
1
+ import { lookupByProseTokens, proseLayerHits } from "./prose.mjs";
2
+ import { cosine } from "./embed.mjs";
3
+
4
+ // Pure (no-network, no-fs) query logic over the typed `entities` payload that the
5
+ // deterministic indexer writes to <repo>/.tmct/graph.json (shape produced by
6
+ // src/graph-build.mjs):
7
+ //
8
+ // {
9
+ // generated_at, classes: [{name, count, sample[]}],
10
+ // objectProperties: [{predicate, prop, count, examples: [{subject, object,
11
+ // subjectLabel, objectLabel}]}],
12
+ // individuals: [{id, label, class, derived_from: [ref], mentions: [{id, count}],
13
+ // attributes?: [{prop, key, value}]}],
14
+ // }
15
+ //
16
+ // Ported ≈verbatim from marginalia seon-mcp/src/codegraph.mjs (the shipped,
17
+ // tested typed-edge query layer). The only edits: provenance/attestation wording
18
+ // is code-graph-generic (git:<sha> / file:line refs, not memory-node prose), and
19
+ // a renderSearch() is added for the local, deterministic tmct_search.
20
+ //
21
+ // Edge inventory is read DYNAMICALLY from the payload (predicate verb + the closed
22
+ // `prop` token like "mg:imports"); only the kind-classifier for the impact closure
23
+ // hardcodes the relation set.
24
+
25
+ // ---- payload parsing ---------------------------------------------------------
26
+
27
+ export function parseEntities(payload) {
28
+ const individuals = Array.isArray(payload?.individuals) ? payload.individuals : [];
29
+ const byId = new Map();
30
+ for (const ind of individuals) {
31
+ if (ind && ind.id) byId.set(ind.id, ind);
32
+ }
33
+ const relations = (Array.isArray(payload?.objectProperties) ? payload.objectProperties : [])
34
+ .filter((g) => g && (g.predicate || g.prop))
35
+ .map((g) => {
36
+ const edges = (Array.isArray(g.examples) ? g.examples : []).filter((e) => e && e.subject && e.object);
37
+ return {
38
+ predicate: String(g.predicate || ""),
39
+ prop: g.prop || null,
40
+ count: Number(g.count) || edges.length,
41
+ edges,
42
+ };
43
+ });
44
+ const truncated = relations
45
+ .filter((g) => g.count > g.edges.length)
46
+ .map((g) => ({ predicate: g.predicate, count: g.count, shown: g.edges.length }));
47
+ return {
48
+ individuals,
49
+ byId,
50
+ relations,
51
+ truncated,
52
+ generatedAt: payload?.generated_at || null,
53
+ // Second pass (PLAN_PROSE_INDEX.md): word -> [individual ids], passed through
54
+ // byte-identical from the payload so ask.mjs's resolveObject can consult it as a
55
+ // fallback tier without reaching back into the raw payload itself. {} when the
56
+ // build had prose disabled or the payload predates this field.
57
+ proseIndex: payload?.proseIndex || {},
58
+ };
59
+ }
60
+
61
+ // ---- relation-kind classifier (for impact + tests-coverage) -------------------
62
+
63
+ const KINDS = ["imports", "calls", "defines", "tests", "touches", "contains", "inherits", "callsSymbol", "touchesSymbol"];
64
+
65
+ // Closed prop tokens → relation kind. Primary vocabulary is SEON (se-on.org) +
66
+ // our `mgx:` extension; the legacy `mg:` tokens are kept so a stale artifact still
67
+ // classifies. Lower-cased keys.
68
+ const PROP_KIND = {
69
+ // v2.0 faithful tokens (SEON-faithful realign)
70
+ "mgx:importsnamespace": "imports",
71
+ "mgx:callscoarse": "calls",
72
+ "seon:declaresmethod": "defines",
73
+ "mgx:testscoverage": "tests",
74
+ "mgx:touchedbycommit": "touches",
75
+ "seon:containscodeentity": "contains",
76
+ "seon:hassupertype": "inherits",
77
+ "mgx:changecoupledwith": "cochange",
78
+ "mgx:reexports": "reexports",
79
+ // fine-grained symbol-level edges (Commit→symbol history, fn→fn in-repo calls).
80
+ // These stay SEPARATE kinds from the module-coarse "touches"/"calls" so the impact
81
+ // closure (module-coarse) is unchanged.
82
+ "mgx:touchessymbol": "touchesSymbol",
83
+ "mgx:callssymbol": "callsSymbol",
84
+ // legacy tokens (pre-realign graphs) — kept so a stale artifact still classifies
85
+ "seon:usescomplextype": "imports",
86
+ "seon:invokesmethod": "calls",
87
+ "seon:history": "touches",
88
+ "mgx:subclassof": "inherits",
89
+ "mg:imports": "imports",
90
+ "mg:calls": "calls",
91
+ "mg:defines": "defines",
92
+ "mg:tests": "tests",
93
+ "mg:touches": "touches",
94
+ };
95
+
96
+ export function relationKind(group) {
97
+ const prop = String(group?.prop || "").toLowerCase();
98
+ if (PROP_KIND[prop]) return PROP_KIND[prop];
99
+ const pred = String(group?.predicate || "").toLowerCase();
100
+ // symbol-granular fallbacks first, so a near-miss token name still classifies to the
101
+ // fine-grained kind rather than collapsing to module-coarse calls/touches.
102
+ if (/symbol/.test(pred)) {
103
+ if (/\b(call|invoke)/.test(pred)) return "callsSymbol";
104
+ if (/(touch|chang|modif)/.test(pred)) return "touchesSymbol";
105
+ }
106
+ if (/\bimport/.test(pred)) return "imports";
107
+ if (/\b(call|invoke)/.test(pred)) return "calls";
108
+ if (/\b(define|export|declare)/.test(pred)) return "defines";
109
+ if (/\b(test|cover)/.test(pred)) return "tests";
110
+ if (/\b(touch|chang|modif)/.test(pred)) return "touches";
111
+ if (/\bcontain/.test(pred)) return "contains";
112
+ if (/\b(inherit|subclass|extend|specializ)/.test(pred)) return "inherits";
113
+ return null;
114
+ }
115
+
116
+ // ---- symbol resolution (exact → normalised path → substring) ------------------
117
+
118
+ function normPath(s) {
119
+ return String(s || "")
120
+ .trim()
121
+ .toLowerCase()
122
+ .replace(/^\.\//, "")
123
+ .replace(/^\//, "");
124
+ }
125
+
126
+ function basename(p) {
127
+ const parts = normPath(p).split("/");
128
+ return parts[parts.length - 1];
129
+ }
130
+
131
+ // Attestation: a ref prefixed `git:` (a commit that touched the entity) counts as
132
+ // one mention, so better-attested (more-churned) entities rank/render ahead of
133
+ // untouched ones even before per-node mention counts exist.
134
+ const isProvRef = (r) => /^(git|turn):/.test(String(r || ""));
135
+
136
+ export function turnRefCount(ind) {
137
+ return (ind?.derived_from || []).filter(isProvRef).length;
138
+ }
139
+
140
+ export function mentionTotal(ind) {
141
+ const fromMentions = (ind?.mentions || []).reduce((n, m) => n + (Number(m?.count) || 0), 0);
142
+ return fromMentions + turnRefCount(ind);
143
+ }
144
+
145
+ /**
146
+ * Rank individuals against a symbol. Tiers:
147
+ * 100 exact (label or id, case-insensitive)
148
+ * 80 normalised path (path suffix / basename / extension-stripped basename)
149
+ * 50 substring (label contains symbol), minus a length penalty
150
+ * Ties break on mention total (better-attested first), then label length.
151
+ * Returns { match, candidates } — candidates are the runners-up (≤4).
152
+ */
153
+ export function resolveSymbol(graph, symbol) {
154
+ const s = normPath(symbol);
155
+ if (!s) return { match: null, candidates: [] };
156
+ const sBase = basename(s);
157
+ const scored = [];
158
+ for (const ind of graph.individuals) {
159
+ const label = normPath(ind.label);
160
+ const id = String(ind.id || "").toLowerCase();
161
+ let score = 0;
162
+ if (label === s || id === s) score = 100;
163
+ else if (
164
+ label.endsWith(`/${s}`) ||
165
+ basename(label) === sBase ||
166
+ basename(label).replace(/\.[a-z]+$/, "") === sBase
167
+ )
168
+ score = 80;
169
+ else if (label.includes(s)) score = Math.max(10, 50 - (label.length - s.length));
170
+ if (score > 0) scored.push({ ind, score });
171
+ }
172
+ scored.sort(
173
+ (a, b) =>
174
+ b.score - a.score ||
175
+ mentionTotal(b.ind) - mentionTotal(a.ind) ||
176
+ String(a.ind.label).length - String(b.ind.label).length,
177
+ );
178
+ return {
179
+ match: scored[0]?.ind || null,
180
+ candidates: scored.slice(1, 5).map((x) => x.ind),
181
+ };
182
+ }
183
+
184
+ // ---- source site (for tmct_snippet) -------------------------------------------
185
+
186
+ /** Parse a Function/Class individual's `site` attribute ("path:start[-end]") into
187
+ * {path, start, end}, or null if it has none (e.g. a Module). Pure. */
188
+ export function siteOf(ind) {
189
+ const a = (ind?.attributes || []).find((x) => x.key === "site");
190
+ if (!a) return null;
191
+ const m = String(a.value).match(/^(.*):(\d+)(?:-(\d+))?$/);
192
+ if (!m) return null;
193
+ return { path: m[1], start: Number(m[2]), end: m[3] ? Number(m[3]) : Number(m[2]) };
194
+ }
195
+
196
+ // ---- describe ------------------------------------------------------------------
197
+
198
+ function edgesFor(graph, id) {
199
+ const out = [];
200
+ const incoming = [];
201
+ for (const g of graph.relations) {
202
+ const outgoing = g.edges.filter((e) => e.subject === id);
203
+ const inbound = g.edges.filter((e) => e.object === id);
204
+ if (outgoing.length) out.push({ group: g, edges: outgoing });
205
+ if (inbound.length) incoming.push({ group: g, edges: inbound });
206
+ }
207
+ return { out, incoming };
208
+ }
209
+
210
+ function relLabel(g) {
211
+ return g.prop ? `${g.predicate} [${g.prop}]` : g.predicate;
212
+ }
213
+
214
+ // Bounded list rendering — token efficiency is the whole point of the graph, so
215
+ // hub entities must never dump hundreds of edges. Show the first `n`, then a
216
+ // "+K more" tail with the true count.
217
+ function capJoin(items, n, sep = ", ") {
218
+ if (items.length <= n) return items.join(sep);
219
+ return items.slice(0, n).join(sep) + `, +${items.length - n} more`;
220
+ }
221
+
222
+ const DESCRIBE_EDGE_CAP = 30;
223
+ const PROV_CAP = 8;
224
+
225
+ /** Compact plain-text description of one individual — for an agent consumer. */
226
+ export function renderDescribe(graph, ind, { candidates = [] } = {}) {
227
+ const lines = [];
228
+ lines.push(`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`);
229
+
230
+ const refs = (ind.derived_from || []).filter(isProvRef);
231
+ if (refs.length) lines.push(`attestation: touched by ${refs.length} commit(s)`);
232
+
233
+ for (const a of ind.attributes || []) {
234
+ lines.push(`attribute: ${a.key} = ${a.value}${a.prop ? ` [${a.prop}]` : ""}`);
235
+ }
236
+
237
+ const { out, incoming } = edgesFor(graph, ind.id);
238
+ if (!out.length && !incoming.length) {
239
+ lines.push("edges: none in the current artifact");
240
+ } else {
241
+ lines.push("edges:");
242
+ for (const { group, edges } of out) {
243
+ lines.push(` ${relLabel(group)} (${edges.length}) → ${capJoin(edges.map((e) => e.objectLabel || e.object), DESCRIBE_EDGE_CAP)}`);
244
+ }
245
+ for (const { group, edges } of incoming) {
246
+ lines.push(` ← ${relLabel(group)} (${edges.length}) by ${capJoin(edges.map((e) => e.subjectLabel || e.subject), DESCRIBE_EDGE_CAP)}`);
247
+ }
248
+ }
249
+
250
+ const prov = (ind.derived_from || []);
251
+ if (prov.length) {
252
+ lines.push(`provenance: ${capJoin(prov, PROV_CAP)}`);
253
+ }
254
+
255
+ if (candidates.length) {
256
+ lines.push(`other matches: ${candidates.map((c) => `${c.label} (${c.class})`).join(", ")}`);
257
+ }
258
+ if (graph.truncated.length) {
259
+ lines.push(truncationNote(graph));
260
+ }
261
+ return lines.join("\n");
262
+ }
263
+
264
+ function truncationNote(graph) {
265
+ const list = graph.truncated.map((t) => `${t.predicate} (${t.shown}/${t.count})`).join(", ");
266
+ return `note: partial edge lists for: ${list}. Counts are complete; the lists are not.`;
267
+ }
268
+
269
+ // ---- impact (transitive reverse closure over imports/calls) ---------------------
270
+
271
+ /**
272
+ * BFS the REVERSE of imports/calls edges from `ind` — "what would break".
273
+ * Diamonds collapse (a node appears once, at its shortest depth); cycles
274
+ * terminate via the visited set. Each dependent carries the via-predicate and
275
+ * the test modules covering it (subjects of tests-kind edges pointing at it).
276
+ *
277
+ * Module-coarse "calls" (`mgx:callsCoarse`, graph-build.mjs) is deliberately
278
+ * conservative — it only fires when the callee's module is ALREADY in the
279
+ * caller's import list ("coarse, import-backed calls", graph-build.mjs's own
280
+ * comment), so by construction every "calls" edge is a strict subset of an
281
+ * "imports" edge between the same pair — it never independently extends this
282
+ * closure's reach beyond what "imports" alone already gives it. `callsSymbol`
283
+ * (fn/method-granular, no import-backing requirement — same-module calls,
284
+ * ambiguous-name calls the coarse pass drops) is the richer signal; this
285
+ * closure also folds it in, coarsened to module level on read (never stored),
286
+ * mirroring the technique `adjacencyForKinds`/`BEAM_EDGE_GROUPS` already use
287
+ * for the same reason.
288
+ */
289
+ export function impactClosure(graph, ind, { maxDepth = 8 } = {}) {
290
+ const dependents = new Map();
291
+ const coveredBy = new Map(); // moduleId → [test labels]
292
+ const addDependent = (objectId, subjectId, subjectLabel, via) => {
293
+ // Self-loop guard: callsSymbol coarsens to module level, so two symbols in
294
+ // the SAME module calling each other must not produce a module pointing at
295
+ // itself (imports/calls edges are already module-to-module and can't self-loop).
296
+ if (!objectId || !subjectId || objectId === subjectId) return;
297
+ if (!dependents.has(objectId)) dependents.set(objectId, []);
298
+ dependents.get(objectId).push({ id: subjectId, label: subjectLabel, via });
299
+ };
300
+ for (const g of graph.relations) {
301
+ const kind = relationKind(g);
302
+ if (kind === "imports" || kind === "calls") {
303
+ for (const e of g.edges) addDependent(e.object, e.subject, e.subjectLabel || e.subject, g.predicate);
304
+ } else if (kind === "callsSymbol") {
305
+ for (const e of g.edges) {
306
+ const subjModId = moduleIdOfId(graph, e.subject);
307
+ const objModId = moduleIdOfId(graph, e.object);
308
+ if (!subjModId || !objModId) continue;
309
+ const subjLabel = graph.byId.get(subjModId)?.label || subjModId;
310
+ addDependent(objModId, subjModId, subjLabel, g.predicate);
311
+ }
312
+ } else if (kind === "tests") {
313
+ for (const e of g.edges) {
314
+ if (!coveredBy.has(e.object)) coveredBy.set(e.object, []);
315
+ coveredBy.get(e.object).push(e.subjectLabel || e.subject);
316
+ }
317
+ }
318
+ }
319
+
320
+ const levels = []; // [[{id, label, via, tests[]}], …] indexed by depth-1
321
+ const visited = new Set([ind.id]);
322
+ let frontier = [ind.id];
323
+ for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
324
+ const next = [];
325
+ const level = [];
326
+ for (const id of frontier) {
327
+ for (const dep of dependents.get(id) || []) {
328
+ if (visited.has(dep.id)) continue;
329
+ visited.add(dep.id);
330
+ level.push({ ...dep, tests: coveredBy.get(dep.id) || [] });
331
+ next.push(dep.id);
332
+ }
333
+ }
334
+ if (level.length) {
335
+ level.sort((a, b) => String(a.label).localeCompare(String(b.label)));
336
+ levels.push(level);
337
+ }
338
+ frontier = next;
339
+ }
340
+ return levels;
341
+ }
342
+
343
+ const IMPACT_DEPTHS_LISTED = 2; // list members for the first N depths; deeper = counts only
344
+ const IMPACT_PER_DEPTH = 25; // members listed per depth
345
+ const IMPACT_TESTS_PER_DEP = 3; // covering tests listed per dependent
346
+
347
+ export function renderImpact(graph, ind, { maxDepth = 8 } = {}) {
348
+ const levels = impactClosure(graph, ind, { maxDepth });
349
+ const lines = [`Impact of changing ${ind.label} (reverse closure over imports/calls edges, module- and function-level):`];
350
+ if (!levels.length) {
351
+ lines.push("no dependents found in the current artifact — nothing imports or calls it (or its edges are not in the extracted graph yet).");
352
+ }
353
+ const totalCount = levels.reduce((n, l) => n + l.length, 0);
354
+ // Headline first so the magnitude is clear even when the lists are capped.
355
+ if (levels.length) {
356
+ lines.push(`total: ${totalCount} dependent(s) across ${levels.length} depth level(s) (lists capped for brevity).`);
357
+ }
358
+ levels.forEach((level, i) => {
359
+ if (i >= IMPACT_DEPTHS_LISTED) {
360
+ lines.push(`depth ${i + 1}: ${level.length} more dependent(s) (not listed)`);
361
+ return;
362
+ }
363
+ lines.push(i === 0 ? `depth 1 (${level.length} direct dependents):` : `depth ${i + 1} (${level.length}):`);
364
+ for (const dep of level.slice(0, IMPACT_PER_DEPTH)) {
365
+ const tests = dep.tests.length
366
+ ? `tests: ${capJoin(dep.tests, IMPACT_TESTS_PER_DEP)}`
367
+ : "tests: none recorded";
368
+ lines.push(` - ${dep.label} (${dep.via} it) — ${tests}`);
369
+ }
370
+ if (level.length > IMPACT_PER_DEPTH) lines.push(` …+${level.length - IMPACT_PER_DEPTH} more at depth ${i + 1}`);
371
+ });
372
+ const truncatedStructural = graph.truncated.filter((t) => {
373
+ const kind = relationKind({ predicate: t.predicate });
374
+ return kind === "imports" || kind === "calls" || kind === "callsSymbol" || kind === "tests";
375
+ });
376
+ if (truncatedStructural.length) {
377
+ lines.push(
378
+ "warning: partial edge lists (" +
379
+ truncatedStructural.map((t) => `${t.predicate}: ${t.shown}/${t.count}`).join(", ") +
380
+ ") — this closure may be missing edges. Cross-check critical results with tmct_search.",
381
+ );
382
+ }
383
+ return lines.join("\n");
384
+ }
385
+
386
+ // ---- search (local, deterministic lexical lookup) ------------------------------
387
+
388
+ /** Index subjectId → [defined symbol labels] from the defines relation, once. */
389
+ function definesIndex(graph) {
390
+ const idx = new Map();
391
+ for (const g of graph.relations) {
392
+ if (relationKind(g) !== "defines") continue;
393
+ for (const e of g.edges) {
394
+ if (!idx.has(e.subject)) idx.set(e.subject, []);
395
+ idx.get(e.subject).push(e.objectLabel || e.object);
396
+ }
397
+ }
398
+ return idx;
399
+ }
400
+
401
+ /**
402
+ * Local, deterministic free-text lookup over the typed graph — the offline
403
+ * replacement for marginalia's LLM-backed A2A tmct_search. Finds the MODULE
404
+ * where code lives ("where do template filters / validators live?"): scores
405
+ * modules by query-token hits in the path (strong) plus the count of DEFINED
406
+ * SYMBOLS whose name matches a token (capped so a giant module can't dominate),
407
+ * with a penalty for test modules. Renders each hit compactly with the matching
408
+ * symbols, so the agent can jump straight to tmct_describe. No model calls.
409
+ */
410
+ const SEARCH_LIMIT = 10;
411
+ const SEARCH_SYMBOLS_SHOWN = 8;
412
+ // Locate scoring — IDF-weighted, component-aware. The rig queries with the WHOLE problem
413
+ // statement, so ubiquitous tokens (template/filter/value/text) would swamp the score; weight each
414
+ // token by rarity across modules (inverse module-frequency) so the distinctive term decides. Match
415
+ // identifier COMPONENTS (boundary-aware) so "text" hits utils/text.py but NOT "ci<text>". An EXACT
416
+ // defined-symbol-name hit is the strongest "the code lives here" signal. Deterministic; no models.
417
+ const PATH_W = 3; // token == a path component (django/utils/<text>.py)
418
+ const SYM_W = 2; // token == a component of a defined symbol name
419
+ const EXACT_W = 5; // token == a whole defined symbol name (strongest locate signal)
420
+ const SYM_MATCH_CAP = 4; // only the top-K highest-IDF symbol-COMPONENT hits count, so a giant
421
+ // bag-of-symbols module (e.g. db/backends features) can't accrete noise
422
+ const PROX_FRAC = 0.2; // import-adjacency bonus = this × the strongest matched neighbour …
423
+ const PROX_CAP_FRAC = 0.35; // … capped at this × the module's own score (a nudge — hubs can't run away)
424
+ const isTestLabel = (s) => /(^|\/)tests?\//.test(s) || /(^|\/)test_[^/]*\.py$/.test(s) || /\.tests(\.|$)/.test(s);
425
+ // B016 R1a (opt-in via demoteNonProd): non-production paths — examples, fixtures, sample/demo
426
+ // apps, and test-* harness packages — share path/symbol vocabulary with the production module
427
+ // and shadow it in locate (B015: js-express injected examples/route-middleware/index.js at
428
+ // rank 1; java-gson's TOP2 slot 2 was a test-shrinker fixture). DEMOTED, not excluded: none of
429
+ // the B015 truths live under these paths (checked corpus/instances-*/…/spec.json 2026-07-02),
430
+ // but a future task whose truth IS a test/example file must stay reachable.
431
+ const NONPROD_DEMOTE = 0.15;
432
+ const isNonProdLabel = (s) => /(^|\/)(examples?|fixtures?|samples?|demos?|benchmarks?|test-[^/]+)(\/|$)/.test(s);
433
+ // B016 E1a (opt-in via callAdjacency): resolved-call adjacency, same bounded-nudge shape as the
434
+ // import-proximity bonus. Python graphs carry call edges (django: 993 calls / 23,596 callsSymbol);
435
+ // the syntax-level C#/Java extractors emit ~none today, so this flag is Python-value only.
436
+ const CALL_PROX_FRAC = 0.2;
437
+ const CALL_PROX_CAP_FRAC = 0.35;
438
+ // B016 E1b (opt-in via implOfInterface): boost a module that implements an interface DEFINED
439
+ // in a strongly-matched module (C# IBasketService→BasketService, the rank-4 case). PLAN_B016
440
+ // §6.1 specified an `isAbstract` guard, but that field is never populated by any extractor —
441
+ // verified empirically 2026-07-02 against django/eshoponweb/java-gson .tmct/graph.json: 0
442
+ // individuals carry `isAbstract` in all three. The only real distinguishing signal in the data
443
+ // is C#'s naming convention (interfaces prefixed `I<Uppercase>`, e.g. IBasketService) — and C#'s
444
+ // `inherits` edges point at an UNRESOLVED `ext:<Name>` id rather than the interface's own
445
+ // individual, so the object must be resolved by an exact label match against internal
446
+ // Class-labeled individuals. SCOPED to `.cs` implementer modules only: without that scope, 11 of
447
+ // django's 7,014 inherits edges superficially match `I[A-Z]` (IOBase, IExact, IContains, …ordinary
448
+ // Python class names, not interfaces) and would reintroduce the over-injection E1a already showed
449
+ // on class-heavy Python graphs. Java's `inherits` predicate resolves cleanly to real individuals
450
+ // but carries no tag or naming convention distinguishing interface implementation from concrete
451
+ // inheritance (TypeAdapterFactory IS an interface in Gson, no "I" prefix) — a Java-safe guard does
452
+ // not exist without an extractor change (E1c, deferred). E1b is C#-only until then.
453
+ const IMPL_PROX_FRAC = 0.2;
454
+ const IMPL_PROX_CAP_FRAC = 0.35;
455
+ const isCsModuleLabel = (s) => /\.cs$/i.test(s);
456
+ const looksLikeCsInterface = (label) => /^I[A-Z]/.test(String(label || ""));
457
+
458
+ // PLAN_PROSE_INDEX.md §6 (opt-in via proseBoost, 2026-07-02): a matched module whose lexical
459
+ // score comes only from its path/symbol NAMES misses the case where the query's vocabulary
460
+ // only overlaps a decomposed identifier or a doc-comment elsewhere in that module (e.g. "billing
461
+ // calculation" never appears in `calculateTotalPrice`'s own path, only in its prose tokens).
462
+ // Same bounded-nudge shape/magnitude as the other proximity families — a nudge onto modules that
463
+ // ALREADY matched lexically (never a new zero-match candidate), never a replacement for the
464
+ // lexical score. NOT wired into any bench arm and NOT a shipped default — an available lever
465
+ // only, exactly like §5.15 beam search before it, pending its own gate/benchmark evidence.
466
+ const PROSE_PROX_FRAC = 0.2;
467
+ const PROSE_PROX_CAP_FRAC = 0.35;
468
+ const PROSE_LOOKUP_LIMIT = 50; // bounds lookupByProseTokens' scan; the CAP_FRAC bounds the nudge regardless
469
+
470
+ // Layered prose normalisation (opt-in via proseLayers, 2026-07-02): the prose index now carries
471
+ // NORMALISED layers (spell-corrected / canonical-schema-term / stem / lemma) under
472
+ // proseIndex["tmct:layers"] (built by the prose pre-pass; consumed read-only via prose.mjs's
473
+ // proseLayerHits). Today the locate scorer matches query tokens against a module's path/symbol
474
+ // text VERBATIM, so a task-text word that only reaches a module via its stem/lemma/canonical form
475
+ // scores nothing. With the flag on, a query token that does NOT already match a module lexically,
476
+ // but DOES resolve to one of that module's individuals through a normalised layer, contributes a
477
+ // bounded, DISCOUNTED signal — weaker evidence than a verbatim match by construction (halved, then
478
+ // the shared FRAC/CAP nudge), and, like every proximity family, it only re-ranks modules ALREADY
479
+ // in `scored` — it never invents a zero-match candidate and never overrides an exact hit. NOT a
480
+ // shipped default and NOT wired into any bench arm — an available lever pending its own gate
481
+ // evidence, exactly like proseBoost/beamSearch before it.
482
+ const PROSE_LAYER_FRAC = 0.2; // bounded nudge — same shape/magnitude as the other proximity families …
483
+ const PROSE_LAYER_CAP_FRAC = 0.35; // … capped at this × the module's own base score (a nudge; hubs can't run away)
484
+ const PROSE_LAYER_DISCOUNT = 0.5; // a normalised-layer hit is WEAKER evidence than an exact/component token
485
+ // match — halved before the FRAC/CAP nudge, so a layer hit can never rival
486
+ // a verbatim lexical match (the "a miss beats a guess" discipline).
487
+
488
+ // PLAN_SEON_TUNING.md §7.5 finding 1 / §7.6(5a) (opt-in via literalMention, 2026-07-02): the query
489
+ // tokenizer split(/[^a-z0-9_]+/) DESTROYS a literal dotted module reference present verbatim in
490
+ // task text — "django.utils.http" scatters into {django,utils,http}, tokens so common across
491
+ // 2,931 modules that utils/http.py ranked 41 on B016's domain-filter — while every Module
492
+ // individual carries an unread `dotted` attribute. The lever scans the RAW query (threaded through
493
+ // as opts.rawQuery by searchModulesRanked) for whole, boundary-checked occurrences of each
494
+ // module's `dotted` name and repo-relative path (label). Boundary rule: a match flanked by an
495
+ // identifier/dotted/path continuation char ([a-z0-9_./]) does not count — which is also
496
+ // longest-match-wins for free: a package __init__'s dotted prefix ("django.utils" inside
497
+ // "django.utils.http") is followed by ".", so only the full module's own name fires (the two
498
+ // __init__.py prefix artifacts the 2026-07-02 review flagged). Specificity floor: a candidate
499
+ // with fewer than LIT_MIN_COMPONENTS dot/slash components never fires (a bare "utils" — or
500
+ // "django.utils" — must not). A hit adds a bounded BASE-score component weighted like the
501
+ // exact-symbol channel (LIT_W = EXACT_W per component IDF, top-LIT_COMP_CAP components like
502
+ // SYM_MATCH_CAP), then capped at LIT_CAP_FRAC × the strongest base score — the FRAC/CAP shape of
503
+ // the proximity families, anchored to the query's own best lexical evidence: a verbatim mention
504
+ // can lift a module INTO the top ranks but can never become an unbounded override. Applied
505
+ // BEFORE the proximity families so a mentioned module also donates adjacency like any other
506
+ // strong match. Only modules that already matched lexically are eligible (a mentioned module
507
+ // always is — its path components are query tokens by construction), preserving the levers'
508
+ // shared no-new-candidates safety scope.
509
+ const LIT_W = EXACT_W; // per-component weight — a verbatim module mention is the strongest locate signal
510
+ const LIT_MIN_COMPONENTS = 3; // "django.utils.http" fires; "django.utils"/"utils" never do
511
+ const LIT_COMP_CAP = 4; // like SYM_MATCH_CAP: only the top-K highest-IDF components accrue
512
+ const LIT_FRAC = 1.0; // bonus = min(litWeight × this, maxBase × LIT_CAP_FRAC)
513
+ const LIT_CAP_FRAC = 0.9; // … so a mention approaches — never dwarfs — the best lexical score
514
+
515
+ // PLAN_SEON_TUNING.md §7.6(5b) (opt-in via embedRank + an injected embedder, 2026-07-02): static-
516
+ // embedding re-rank — the deterministic "near-LLM" lever. The caller loads embed.mjs's
517
+ // potion-base-8M table (loadEmbedder(); null when the one-time-fetch weights are absent) and
518
+ // passes it as opts.embedder, keeping this module pure (no fs here; the flag no-ops with a
519
+ // one-time stderr note when the embedder is missing, so CI never needs the 30 MB artifact).
520
+ // Per-module text = path components + defined symbol names + doc first-lines — all read from the
521
+ // graph, never from source — embedded lazily and cached per process (EMB_CACHE, WeakMap-keyed on
522
+ // the graph). Cosine(query, module) becomes the same bounded FRAC/CAP nudge as the proximity
523
+ // families: only re-ranks modules that ALREADY matched lexically, never introduces a candidate.
524
+ const EMB_FRAC = 0.2;
525
+ const EMB_CAP_FRAC = 0.35;
526
+ const EMB_TEXT_SYMBOL_CAP = 64; // bound the per-module text: top defines …
527
+ const EMB_TEXT_DOC_CAP = 12; // … and doc first-lines (a giant module can't grow an unbounded text)
528
+ const EMB_CACHE = new WeakMap(); // graph -> { embedder, texts, vecs: Map<moduleId, Float32Array> }
529
+ let embedWarned = false;
530
+
531
+ // PLAN_SEON_TUNING.md §5.15 "discriminative multi-hop expansion" (opt-in via beamSearch):
532
+ // generalizes the R1a/E1a/E1b family's single fixed-type, single-hop nudge into an adaptive,
533
+ // multi-PLY expansion. Terminology follows Wikipedia's "Beam search" and Lowerre & Reddy, "The
534
+ // Harpy Speech Understanding System" (Carnegie-Mellon, the paper that coined "beam search" — no
535
+ // University of Essex 1980s/90s beam-search paper exists; searched 2026-07-02, none found, this
536
+ // is the honest substitute). One hop of expansion = a PLY; the surviving candidate set at a ply =
537
+ // the BEAM; beamWidth (β) caps how many survive; discarding non-survivors = PRUNING.
538
+ //
539
+ // Harpy's own beamwidth was a MARGIN/THRESHOLD relative to the ply's best score ("candidates
540
+ // that fall below a threshold of acceptability are pruned"), not a fixed count — this is a
541
+ // threshold+cap HYBRID (keep everyone within BEAM_MARGIN_FRAC of the ply's best, THEN cap at β),
542
+ // not naive top-k. A fixed-count beam would prematurely discard exactly the kind of weak-then-
543
+ // strong candidate E1b's own motivating case demonstrated: BasketService.cs sat at lexical rank 4
544
+ // and was only promoted by considering impl-of-interface structure beyond the first pass — a
545
+ // hard top-k cut at ply 0 could drop such a candidate before any later ply had a chance to
546
+ // recover it (Russell & Norvig's "local beam search... quickly becomes concentrated in a small
547
+ // region" failure mode, which Wikipedia's article cites for exactly this risk).
548
+ //
549
+ // Successors are generated PER EDGE KIND separately (not pooled then pruned once), so a dense
550
+ // edge type (imports) cannot crowd out a sparse-but-discriminative one (inherits) — each kind's
551
+ // survivors are computed independently, then MERGED (Harpy's own "candidate merging": two states
552
+ // reaching the same successor collapse to one path, keeping the better score). A short overflow
553
+ // list of near-miss pruned candidates is kept as a safety valve: if a ply's beam runs dry, the
554
+ // overflow is reconsidered rather than the walk simply stopping.
555
+ //
556
+ // SAFETY SCOPE: like every proximity family above, this only re-ranks modules that ALREADY
557
+ // matched lexically (present in `scored`) — it never introduces a zero-match candidate, so it
558
+ // cannot regress precision/over-injection the way an unbounded multi-hop walk could.
559
+ const BEAM_MARGIN_FRAC = 0.5; // keep ply candidates scoring >= (ply-best * this), before the cap
560
+ const BEAM_PROX_FRAC = 0.2; // bounded nudge — same shape/magnitude as the other proximity families
561
+ const BEAM_PROX_CAP_FRAC = 0.35;
562
+ const BEAM_OVERFLOW_CAP = 4; // near-miss safety valve size
563
+ const BEAM_PLIES = 2; // hops of expansion
564
+ const BEAM_EDGE_GROUPS = [["imports"], ["calls", "callsSymbol"], ["inherits"], ["cochange"]];
565
+
566
+ // ---- SPIRAL expansion (opt-in, default off; BEAM_RESEARCH.md's "fix #2/#3" made concrete) ------
567
+ // Deterministic bounded-radius ego walk from the lexical seeds, ordered fewest-arcs-first, with a
568
+ // degree-quantile hub gate. UNLIKE beamExpand it MAY introduce modules that had no lexical match
569
+ // (it walks the graph from the seeds), so it can in principle lift a lexically-invisible truth into
570
+ // top-k — the whole point. cochange is dropped (temporal-coupling noise; see the research synthesis).
571
+ // • spiralDepth — max hop radius from the seeds (bounded ego expansion). Default 3.
572
+ // • mostDistinctiveBeams — degree-quantile gate q∈(0,1]: at each expansion step keep only the
573
+ // lowest-degree ⌊q·n⌋ candidates (drop the top (1−q) hubs); q=1.0 keeps
574
+ // all. Never empties the frontier (keeps ≥1 — the least-connected).
575
+ // • spiralNodeLimit — emit budget: how many newly-reached nodes the spiral surfaces. Held at
576
+ // 12 (MID-tier digest breadth, a KNOWN-DOABLE token budget) — a fixed
577
+ // budget, NOT a recall dial.
578
+ const SPIRAL_DEPTH_DEFAULT = 3;
579
+ const SPIRAL_NODE_LIMIT_DEFAULT = 12;
580
+ const SPIRAL_Q_DEFAULT = 0.9; // mild hub pruning (drop only the densest 10%) — the centre point
581
+ const SPIRAL_EXPAND_KINDS = ["imports", "calls", "callsSymbol", "inherits"]; // cochange dropped
582
+ const SPIRAL_EMIT_FRAC = 0.5; // a newly-surfaced node's base score = maxSeed × this …
583
+ const SPIRAL_HOP_DECAY = 0.6; // … decayed by this per hop from the seeds (bounded < maxSeed, so a walked-in node never dominates rank 1)
584
+ const SPIRAL_PROX_FRAC = 0.2; // an ALREADY-matched module the spiral re-reaches gets a bounded nudge …
585
+ const SPIRAL_PROX_CAP_FRAC = 0.35; // … capped at this × its own score (same shape as every other proximity family)
586
+
587
+ /** embedRank: per-module embeddable text — path components + defined symbol names + doc
588
+ * first-lines, ALL already in the graph (never re-reads source), bounded by the EMB_TEXT_*
589
+ * caps. Built once per graph and cached alongside the vectors in EMB_CACHE. */
590
+ function moduleEmbedTexts(graph) {
591
+ const texts = new Map(); // moduleId -> text
592
+ const defIdx = definesIndex(graph);
593
+ const docs = new Map(); // moduleId -> [doc first-lines]
594
+ for (const ind of graph.individuals) {
595
+ const doc = (ind.attributes || []).find((a) => a.key === "doc")?.value;
596
+ if (!doc) continue;
597
+ const modId = (ind.class || "") === "Module" ? ind.id : moduleIdOf(graph, ind);
598
+ if (!modId) continue;
599
+ let arr = docs.get(modId);
600
+ if (!arr) docs.set(modId, (arr = []));
601
+ if (arr.length < EMB_TEXT_DOC_CAP) arr.push(String(doc).split("\n")[0]);
602
+ }
603
+ for (const ind of graph.individuals) {
604
+ if ((ind.class || "") !== "Module") continue;
605
+ const parts = String(ind.label).split(/[^a-zA-Z0-9_]+/).filter(Boolean);
606
+ const syms = (defIdx.get(ind.id) || []).slice(0, EMB_TEXT_SYMBOL_CAP);
607
+ texts.set(ind.id, [...parts, ...syms, ...(docs.get(ind.id) || [])].join(" "));
608
+ }
609
+ return texts;
610
+ }
611
+
612
+ /** Split a lowercased path label into boundary components: django/utils/text.py →
613
+ * {django,utils,text,py}. Component equality (not substring) stops "text" matching "ci<text>". */
614
+ function pathComponents(labelLc) {
615
+ return new Set(labelLc.split(/[^a-z0-9]+/).filter(Boolean));
616
+ }
617
+ /** Split an identifier into lowercased components across snake_case AND camelCase boundaries:
618
+ * get_text_list → {get,text,list}; TruncatorLines → {truncator,lines}. */
619
+ function identComponents(name) {
620
+ return new Set(String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
621
+ }
622
+
623
+ /** For one edge-kind group, the depth-1 successor of `fromId` reachable via any edge in `kinds`,
624
+ * as a Map<moduleId, neighbourModuleId> adjacency (undirected — a module's neighbours via that
625
+ * kind, in either edge direction). Endpoints are mapped to their containing module first (call
626
+ * edges live at function granularity), matching the existing E1a call-adjacency convention. */
627
+ function adjacencyForKinds(graph, kinds) {
628
+ const adj = new Map();
629
+ const link = (a, b) => {
630
+ if (!a || !b || a === b) return;
631
+ if (!adj.has(a)) adj.set(a, new Set());
632
+ if (!adj.has(b)) adj.set(b, new Set());
633
+ adj.get(a).add(b);
634
+ adj.get(b).add(a);
635
+ };
636
+ for (const kind of kinds) {
637
+ for (const e of edgesOfKind(graph, kind)) {
638
+ link(moduleIdOfId(graph, e.subject), moduleIdOfId(graph, e.object));
639
+ }
640
+ }
641
+ return adj;
642
+ }
643
+
644
+ /** Beam-search-style multi-PLY expansion (PLAN_SEON_TUNING.md §5.15; see the BEAM_* constants'
645
+ * comment above for the full design rationale). Mutates `s.score` in place on `scored` entries
646
+ * it boosts — same bounded-nudge shape as the single-hop proximity families, just reachable over
647
+ * more than one hop when a ply's beam survives that far. Pure otherwise (no fs/network). */
648
+ function beamExpand(graph, scored, beamWidth) {
649
+ if (scored.length < 2) return;
650
+ const byId = new Map(scored.map((s) => [s.ind.id, s]));
651
+ const baseScore = new Map(scored.map((s) => [s.ind.id, s.score]));
652
+
653
+ // Margin+cap prune a candidate-score Map down to this ply's beam, returning [survivors, overflow].
654
+ const pruneToBeam = (candidates) => {
655
+ if (!candidates.size) return [[], []];
656
+ let best = 0;
657
+ for (const v of candidates.values()) best = Math.max(best, v);
658
+ const ranked = [...candidates.entries()].sort((a, b) => b[1] - a[1]);
659
+ const survivors = [];
660
+ const overflow = [];
661
+ for (const [id, score] of ranked) {
662
+ if (score >= best * BEAM_MARGIN_FRAC && survivors.length < beamWidth) survivors.push([id, score]);
663
+ else if (overflow.length < BEAM_OVERFLOW_CAP) overflow.push([id, score]);
664
+ }
665
+ return [survivors, overflow];
666
+ };
667
+
668
+ // Ply 0 beam = the current top-scoring already-matched modules (margin+cap over the whole set).
669
+ let [beam, overflow] = pruneToBeam(new Map(scored.map((s) => [s.ind.id, s.score])));
670
+ const boosted = new Set(beam.map(([id]) => id));
671
+
672
+ for (let ply = 0; ply < BEAM_PLIES && beam.length; ply++) {
673
+ // Per-edge-kind successor generation, scored, pruned INDEPENDENTLY per kind (so a dense kind
674
+ // like imports can't crowd out a sparse-but-discriminative one like inherits), then merged.
675
+ const merged = new Map(); // successorId -> best propagated score across all kinds this ply
676
+ const plyOverflow = [];
677
+ for (const kinds of BEAM_EDGE_GROUPS) {
678
+ const adj = adjacencyForKinds(graph, kinds);
679
+ const candidates = new Map();
680
+ for (const [parentId, parentScore] of beam) {
681
+ for (const neighbourId of adj.get(parentId) || []) {
682
+ if (!baseScore.has(neighbourId)) continue; // only re-rank already-matched modules
683
+ candidates.set(neighbourId, Math.max(candidates.get(neighbourId) || 0, parentScore));
684
+ }
685
+ }
686
+ const [survivors, kindOverflow] = pruneToBeam(candidates);
687
+ for (const [id, score] of survivors) merged.set(id, Math.max(merged.get(id) || 0, score));
688
+ plyOverflow.push(...kindOverflow);
689
+ }
690
+ overflow.push(...plyOverflow);
691
+ // Apply the bounded nudge once per module (first ply it's reached), same shape as the other
692
+ // proximity families — a nudge, never a replacement.
693
+ for (const [id, propagated] of merged) {
694
+ if (boosted.has(id)) continue;
695
+ const s = byId.get(id);
696
+ if (!s) continue;
697
+ s.score += Math.min(propagated * BEAM_PROX_FRAC, s.score * BEAM_PROX_CAP_FRAC);
698
+ boosted.add(id);
699
+ }
700
+ beam = [...merged.entries()];
701
+ // Safety valve: if this ply's beam ran dry, reconsider the near-miss overflow instead of
702
+ // just stopping — cheap insurance against a total pruning failure.
703
+ if (!beam.length && overflow.length) {
704
+ beam = overflow.splice(0, BEAM_OVERFLOW_CAP).filter(([id]) => !boosted.has(id));
705
+ }
706
+ }
707
+ }
708
+
709
+ /** SPIRAL expansion (opt-in; see the SPIRAL_* constants' comment above for the full design).
710
+ * A deterministic bounded-radius ego walk from the lexical seeds (`scored`), popped
711
+ * fewest-arcs-first via a min-heap keyed (hop ASC, in-graph degree ASC, id ASC), with a
712
+ * degree-quantile hub gate at each expansion step. Emits up to `nodeLimit` newly-reached nodes
713
+ * in pop order, scoring each seed-relative and bounded so a hub can't dominate rank 1.
714
+ * CRITICAL vs beamExpand: it deliberately OMITS the `if (!baseScore.has) continue` guard, so it
715
+ * MAY push modules that had NO lexical match into `scored` — the one path to breaking the lexical
716
+ * ceiling. Mutates `scored` (nudges re-reached matches in place; APPENDS newly-surfaced modules).
717
+ * Pure otherwise (no fs/network); deterministic total ordering throughout. */
718
+ function spiralExpand(graph, scored, { depth = SPIRAL_DEPTH_DEFAULT, q = SPIRAL_Q_DEFAULT, nodeLimit = SPIRAL_NODE_LIMIT_DEFAULT } = {}) {
719
+ if (!scored.length) return;
720
+ const byId = new Map(scored.map((s) => [s.ind.id, s]));
721
+ const seeds = new Set(byId.keys());
722
+ let maxSeed = 0;
723
+ for (const s of scored) maxSeed = Math.max(maxSeed, s.score);
724
+ if (!(maxSeed > 0)) return;
725
+ // Combined undirected adjacency over the expansion kinds (cochange dropped). In-graph degree =
726
+ // neighbour count over these kinds — the "arcs" the frontier orders and the quantile gate reads.
727
+ const adj = adjacencyForKinds(graph, SPIRAL_EXPAND_KINDS);
728
+ const degree = (id) => (adj.get(id)?.size || 0);
729
+ // Binary min-heap over the frontier, keyed (hop ASC, degree ASC, id ASC) — pop the closest,
730
+ // least-connected node first, so expansion fans through the sparse surroundings and fizzles at
731
+ // hubs. The id tiebreak makes the order a deterministic total order (no RNG, no insertion bias).
732
+ const heap = [];
733
+ const less = (a, b) => a.hop !== b.hop ? a.hop < b.hop
734
+ : a.deg !== b.deg ? a.deg < b.deg
735
+ : a.id < b.id;
736
+ const swap = (i, j) => { const t = heap[i]; heap[i] = heap[j]; heap[j] = t; };
737
+ const push = (node) => {
738
+ heap.push(node);
739
+ let i = heap.length - 1;
740
+ while (i > 0) { const p = (i - 1) >> 1; if (less(heap[i], heap[p])) { swap(i, p); i = p; } else break; }
741
+ };
742
+ const pop = () => {
743
+ const top = heap[0];
744
+ const last = heap.pop();
745
+ if (heap.length) {
746
+ heap[0] = last;
747
+ let i = 0;
748
+ for (;;) {
749
+ const l = 2 * i + 1, r = 2 * i + 2; let m = i;
750
+ if (l < heap.length && less(heap[l], heap[m])) m = l;
751
+ if (r < heap.length && less(heap[r], heap[m])) m = r;
752
+ if (m === i) break;
753
+ swap(i, m); i = m;
754
+ }
755
+ }
756
+ return top;
757
+ };
758
+ const visited = new Set(seeds);
759
+ for (const id of seeds) push({ id, hop: 0, deg: degree(id) });
760
+ const defIdx = definesIndex(graph);
761
+ let emitted = 0;
762
+ while (heap.length && emitted < nodeLimit) {
763
+ const node = pop();
764
+ if (!seeds.has(node.id)) {
765
+ // Slot this newly-reached node: seed-relative base, hop-decayed and bounded below maxSeed.
766
+ const emitScore = maxSeed * SPIRAL_EMIT_FRAC * Math.pow(SPIRAL_HOP_DECAY, node.hop - 1);
767
+ const existing = byId.get(node.id);
768
+ if (existing) { // already lexically matched (below-k) → bounded nudge, never a replacement
769
+ existing.score += Math.min(emitScore * SPIRAL_PROX_FRAC, existing.score * SPIRAL_PROX_CAP_FRAC);
770
+ } else { // lexically INVISIBLE → introduce it (the beam structurally cannot)
771
+ const ind = graph.byId?.get?.(node.id);
772
+ if (ind && (ind.class || "") === "Module") {
773
+ const defines = defIdx.get(ind.id) || [];
774
+ const entry = { ind, score: emitScore, defineCount: defines.length, matching: [], density: 0 };
775
+ scored.push(entry);
776
+ byId.set(node.id, entry);
777
+ }
778
+ }
779
+ emitted++;
780
+ }
781
+ if (node.hop >= depth) continue;
782
+ // This step's candidate set = the popped node's unvisited MODULE neighbours; quantile-gate by
783
+ // degree, keeping the lowest-degree ⌊q·n⌋ (drop the densest hubs), never fewer than one.
784
+ const cands = [];
785
+ for (const nid of adj.get(node.id) || []) {
786
+ if (visited.has(nid)) continue;
787
+ const ind = graph.byId?.get?.(nid);
788
+ if (!ind || (ind.class || "") !== "Module") continue; // no phantom (fn-fallback) module ids
789
+ cands.push({ id: nid, deg: degree(nid) });
790
+ }
791
+ if (!cands.length) continue;
792
+ cands.sort((a, b) => a.deg - b.deg || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
793
+ const keep = Math.max(1, Math.floor(q * cands.length)); // lowest-degree q-fraction; never empty
794
+ for (let i = 0; i < keep; i++) {
795
+ const c = cands[i];
796
+ visited.add(c.id);
797
+ push({ id: c.id, hop: node.hop + 1, deg: c.deg });
798
+ }
799
+ }
800
+ }
801
+
802
+ /** The shared module-ranking core behind renderSearch (text) and searchModulesRanked (path+score).
803
+ * IDF-weights each query token by rarity across modules (so a whole-problem-statement query is not
804
+ * swamped by ubiquitous words like template/filter/value), scores path-component + symbol-component
805
+ * + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
806
+ * matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
807
+ function scoreModules(graph, tokens, opts = {}) {
808
+ const { demoteNonProd = false, callAdjacency = false, implOfInterface = false, beamSearch = false, spiral = false, proseBoost = false, proseLayers = false, literalMention = false, embedRank = false, rawQuery = "" } = opts;
809
+ const beamWidth = Number.isFinite(opts.beamWidth) && opts.beamWidth > 0 ? opts.beamWidth : 8;
810
+ const defIdx = definesIndex(graph);
811
+ // Precompute each module's path components + defined-symbol exact/component sets, once.
812
+ const modules = [];
813
+ for (const ind of graph.individuals) {
814
+ if ((ind.class || "") !== "Module") continue; // "where does this live" → modules
815
+ const label = String(ind.label);
816
+ const labelLc = label.toLowerCase();
817
+ const defines = defIdx.get(ind.id) || [];
818
+ const symSet = new Set(defines.map((d) => d.toLowerCase())); // exact symbol names
819
+ const symComps = new Set();
820
+ for (const d of defines) for (const c of identComponents(d)) symComps.add(c);
821
+ // literalMention only: the Module's `dotted` attribute (mgx:dotted) — the verbatim form a
822
+ // task statement uses ("django.utils.http"); "" when absent. Gated so OFF does zero work.
823
+ const dotted = literalMention
824
+ ? String((ind.attributes || []).find((a) => a.key === "dotted")?.value || "").toLowerCase()
825
+ : "";
826
+ modules.push({ ind, label, labelLc, defines, symSet, symComps, dotted });
827
+ }
828
+ const N = modules.length || 1;
829
+ // Inverse module-frequency: a token in many modules carries little locating signal; a rare one
830
+ // decides. df = modules where the token appears in the path (substring — keeps "filter" matching
831
+ // "defaultfilters"), as a symbol component, or as an exact symbol name. A loose path substring like
832
+ // "text" that hits many modules therefore earns a low weight, so "ci<text>" can't beat utils/text.py.
833
+ // idf = log(1 + N/(1+df)) → ~0 for ubiquitous tokens, large for rare ones.
834
+ const idf = new Map();
835
+ for (const t of tokens) {
836
+ if (idf.has(t)) continue;
837
+ let df = 0;
838
+ for (const m of modules) if (m.labelLc.includes(t) || m.symComps.has(t) || m.symSet.has(t)) df++;
839
+ idf.set(t, Math.log(1 + N / (1 + df)));
840
+ }
841
+ const scored = [];
842
+ for (const m of modules) {
843
+ let exactScore = 0, pathScore = 0, matchCount = 0;
844
+ const compWeights = []; // weak symbol-component hits, capped below so big modules can't run away
845
+ for (const t of tokens) {
846
+ const w = idf.get(t) || 0;
847
+ if (!w) continue;
848
+ if (m.symSet.has(t)) { exactScore += w * EXACT_W; matchCount++; } // exact defined-symbol name
849
+ else if (m.symComps.has(t)) { compWeights.push(w); matchCount++; } // a component of a symbol name
850
+ if (m.labelLc.includes(t)) pathScore += w * PATH_W; // path substring (IDF-tamed)
851
+ }
852
+ compWeights.sort((a, b) => b - a);
853
+ let symScore = 0;
854
+ for (let i = 0; i < Math.min(compWeights.length, SYM_MATCH_CAP); i++) symScore += compWeights[i] * SYM_W;
855
+ let score = exactScore + pathScore + symScore;
856
+ if (!score) continue;
857
+ if (demoteNonProd && (isTestLabel(m.labelLc) || isNonProdLabel(m.labelLc))) score *= NONPROD_DEMOTE; // B016 R1a
858
+ else if (isTestLabel(m.labelLc)) score *= 0.4; // source first; tests still discoverable
859
+ const matching = m.defines.filter((d) => { const dl = d.toLowerCase(); const cs = identComponents(d); return tokens.some((t) => dl === t || cs.has(t)); });
860
+ const density = m.defines.length ? matchCount / m.defines.length : 0;
861
+ scored.push({ ind: m.ind, score, defineCount: m.defines.length, matching, density });
862
+ }
863
+ // §7.5/§7.6(5a) literalMention (opt-in): verbatim dotted-name/path mentions in the RAW query —
864
+ // see the LIT_* constants' comment above for the full design. Runs before the proximity
865
+ // families so a mentioned module donates adjacency like any other strong match.
866
+ if (literalMention && rawQuery && scored.length) {
867
+ const rawLc = String(rawQuery).toLowerCase();
868
+ const continues = (ch) => ch != null && /[a-z0-9_./]/.test(ch);
869
+ // Whole, boundary-checked occurrence of `cand` in the raw query (see boundary rule above).
870
+ const mentioned = (cand) => {
871
+ for (let i = rawLc.indexOf(cand); i !== -1; i = rawLc.indexOf(cand, i + 1)) {
872
+ if (!continues(rawLc[i - 1]) && !continues(rawLc[i + cand.length])) return true;
873
+ }
874
+ return false;
875
+ };
876
+ // IDF for a candidate's components: normally already in the map (they are query tokens by
877
+ // construction when tokens came from this same raw query); computed-and-cached otherwise
878
+ // (a caller passing mismatched tokens/rawQuery must not crash or skew).
879
+ const idfOf = (t) => {
880
+ if (!idf.has(t)) {
881
+ let df = 0;
882
+ for (const m of modules) if (m.labelLc.includes(t) || m.symComps.has(t) || m.symSet.has(t)) df++;
883
+ idf.set(t, Math.log(1 + N / (1 + df)));
884
+ }
885
+ return idf.get(t);
886
+ };
887
+ const byModId = new Map(modules.map((m) => [m.ind.id, m]));
888
+ let maxBase = 0;
889
+ for (const s of scored) maxBase = Math.max(maxBase, s.score);
890
+ for (const s of scored) {
891
+ const m = byModId.get(s.ind.id);
892
+ if (!m) continue;
893
+ let litWeight = 0; // best single matched candidate (dotted vs path share components anyway)
894
+ for (const cand of new Set([m.dotted, m.labelLc])) {
895
+ if (!cand) continue;
896
+ if (cand.split(/[./]+/).filter(Boolean).length < LIT_MIN_COMPONENTS) continue; // specificity floor
897
+ if (!mentioned(cand)) continue;
898
+ // IDF-weight the candidate's tokens (same tokenizer as the query), highest first.
899
+ const weights = [...new Set(cand.split(/[^a-z0-9_]+/).filter(Boolean))].map(idfOf).sort((a, b) => b - a);
900
+ let w = 0;
901
+ for (let i = 0; i < Math.min(weights.length, LIT_COMP_CAP); i++) w += weights[i] * LIT_W;
902
+ litWeight = Math.max(litWeight, w);
903
+ }
904
+ if (litWeight) s.score += Math.min(litWeight * LIT_FRAC, maxBase * LIT_CAP_FRAC);
905
+ }
906
+ }
907
+ // Import-graph proximity (rescaled): a matched module that imports / is imported by a
908
+ // STRONGER-matching module gets a bonus proportional to that neighbour, so a genuine 2nd module
909
+ // (truncatelines' text.py) rises with its sibling. Only re-ranks modules that ALREADY matched.
910
+ if (scored.length > 1) {
911
+ const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
912
+ const adj = new Map();
913
+ for (const e of edgesOfKind(graph, "imports")) {
914
+ if (!baseById.has(e.subject) && !baseById.has(e.object)) continue;
915
+ if (!adj.has(e.subject)) adj.set(e.subject, new Set());
916
+ if (!adj.has(e.object)) adj.set(e.object, new Set());
917
+ adj.get(e.subject).add(e.object);
918
+ adj.get(e.object).add(e.subject);
919
+ }
920
+ for (const s of scored) {
921
+ let bestNeighbor = 0;
922
+ for (const nid of adj.get(s.ind.id) || []) bestNeighbor = Math.max(bestNeighbor, baseById.get(nid) || 0);
923
+ s.score += Math.min(bestNeighbor * PROX_FRAC, s.score * PROX_CAP_FRAC);
924
+ }
925
+ }
926
+ // B016 E1a (opt-in): resolved-call adjacency — a matched module CALLED BY (or calling) a
927
+ // stronger-matching module rises with it (initials-filter: defaultfilters.py calls into
928
+ // utils/text.py, whose lexical rank was 8). Call edges live at function level, so endpoints
929
+ // map to their containing modules first. Same bounded-nudge formula as import-proximity;
930
+ // only re-ranks modules that already matched.
931
+ if (callAdjacency && scored.length > 1) {
932
+ const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
933
+ const adj = new Map();
934
+ for (const kind of ["calls", "callsSymbol"]) {
935
+ for (const e of edgesOfKind(graph, kind)) {
936
+ const sm = moduleIdOfId(graph, e.subject);
937
+ const om = moduleIdOfId(graph, e.object);
938
+ if (!sm || !om || sm === om) continue;
939
+ if (!baseById.has(sm) && !baseById.has(om)) continue;
940
+ if (!adj.has(sm)) adj.set(sm, new Set());
941
+ if (!adj.has(om)) adj.set(om, new Set());
942
+ adj.get(sm).add(om);
943
+ adj.get(om).add(sm);
944
+ }
945
+ }
946
+ for (const s of scored) {
947
+ let bestNeighbor = 0;
948
+ for (const nid of adj.get(s.ind.id) || []) bestNeighbor = Math.max(bestNeighbor, baseById.get(nid) || 0);
949
+ s.score += Math.min(bestNeighbor * CALL_PROX_FRAC, s.score * CALL_PROX_CAP_FRAC);
950
+ }
951
+ }
952
+ // B016 E1b (opt-in): impl-of-interface — a C# module implementing an interface DEFINED in a
953
+ // stronger-matching module rises with it (eshoponweb: IBasketService.cs rank 1, BasketService.cs
954
+ // rank 4). `inherits` edges point the OBJECT at an unresolved `ext:<Name>` id for C#, so resolve
955
+ // by exact label match against internal Class individuals. Only re-ranks modules that already
956
+ // matched, and only when both the implementer module is `.cs` and the base name looks like a C#
957
+ // interface (see the const block above for why — isAbstract does not exist in the data).
958
+ if (implOfInterface && scored.length > 1) {
959
+ const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
960
+ const classByLabel = new Map();
961
+ for (const ind of graph.individuals) {
962
+ if ((ind.class || "") === "Class" && ind.label) classByLabel.set(String(ind.label), ind);
963
+ }
964
+ for (const s of scored) {
965
+ if (!isCsModuleLabel(s.ind.label)) continue;
966
+ let bestNeighbor = 0;
967
+ for (const e of edgesOfKind(graph, "inherits")) {
968
+ const subjModId = moduleIdOfId(graph, e.subject);
969
+ if (subjModId !== s.ind.id) continue;
970
+ if (!looksLikeCsInterface(e.objectLabel)) continue;
971
+ let ifaceModId = moduleIdOfId(graph, e.object); // resolves real (non-ext:) targets
972
+ if (!ifaceModId) {
973
+ const ifaceInd = classByLabel.get(String(e.objectLabel || ""));
974
+ if (ifaceInd) ifaceModId = moduleIdOf(graph, ifaceInd);
975
+ }
976
+ if (ifaceModId) bestNeighbor = Math.max(bestNeighbor, baseById.get(ifaceModId) || 0);
977
+ }
978
+ s.score += Math.min(bestNeighbor * IMPL_PROX_FRAC, s.score * IMPL_PROX_CAP_FRAC);
979
+ }
980
+ }
981
+ // PLAN_PROSE_INDEX.md §6 (opt-in): lexical boost from decomposed-identifier/doc-comment
982
+ // prose tokens — see the PROSE_PROX_* comment above for the full rationale. One
983
+ // lookupByProseTokens call for the whole query (not per-module), then aggregated into a
984
+ // per-module signal via moduleIdOfId, same as the call-adjacency/impl-of-interface families.
985
+ // Unlike the proximity families above, this signal is absolute per-module (prose-token
986
+ // overlap), not relative to a stronger NEIGHBOUR in `scored` — so it applies even when
987
+ // only one module matched lexically (no ">1" gate needed).
988
+ if (proseBoost && scored.length && graph.proseIndex) {
989
+ const proseHits = lookupByProseTokens(graph.proseIndex, tokens.join(" "), { limit: PROSE_LOOKUP_LIMIT });
990
+ if (proseHits.length) {
991
+ const proseByModule = new Map();
992
+ for (const { id, score } of proseHits) {
993
+ const modId = moduleIdOfId(graph, id);
994
+ if (!modId) continue;
995
+ proseByModule.set(modId, (proseByModule.get(modId) || 0) + score);
996
+ }
997
+ for (const s of scored) {
998
+ const signal = proseByModule.get(s.ind.id) || 0;
999
+ if (!signal) continue;
1000
+ s.score += Math.min(signal * PROSE_PROX_FRAC, s.score * PROSE_PROX_CAP_FRAC);
1001
+ }
1002
+ }
1003
+ }
1004
+ // Layered prose normalisation (opt-in): a query token that did NOT match a module lexically but
1005
+ // resolves to one of its individuals through a NORMALISED prose layer (stem/lemma/canonical/spell)
1006
+ // adds a bounded, discounted signal — see the PROSE_LAYER_* comment above. One proseLayerHits call
1007
+ // per DISTINCT query token, ids folded to their containing module via moduleIdOfId (same as the
1008
+ // proseBoost/call-adjacency families). Only tokens NOT already matching a module lexically count
1009
+ // for that module (a layer hit is purely ADDITIVE evidence for otherwise-missed words — never
1010
+ // double-counting a token the base score already saw), weighted by the token's own IDF (so a
1011
+ // ubiquitous word contributes almost nothing) and halved (PROSE_LAYER_DISCOUNT: weaker than a
1012
+ // verbatim match), then the shared FRAC/CAP nudge. Only re-ranks modules already in `scored`.
1013
+ if (proseLayers && scored.length && graph.proseIndex) {
1014
+ const scoredById = new Map(scored.map((s) => [s.ind.id, s]));
1015
+ const modById = new Map(modules.map((m) => [m.ind.id, m]));
1016
+ const layerSignal = new Map(); // moduleId -> accumulated discounted, IDF-weighted layer signal
1017
+ for (const t of new Set(tokens)) {
1018
+ const w = idf.get(t) || 0;
1019
+ if (!w) continue;
1020
+ const { ids } = proseLayerHits(graph.proseIndex, t);
1021
+ if (!ids.length) continue;
1022
+ const hitMods = new Set();
1023
+ for (const id of ids) {
1024
+ const modId = moduleIdOfId(graph, id);
1025
+ if (!modId || hitMods.has(modId)) continue;
1026
+ hitMods.add(modId);
1027
+ if (!scoredById.has(modId)) continue; // never a new zero-match candidate
1028
+ const m = modById.get(modId);
1029
+ if (m && (m.symSet.has(t) || m.symComps.has(t) || m.labelLc.includes(t))) continue; // already matched lexically → not additive
1030
+ layerSignal.set(modId, (layerSignal.get(modId) || 0) + w * PROSE_LAYER_DISCOUNT);
1031
+ }
1032
+ }
1033
+ for (const s of scored) {
1034
+ const signal = layerSignal.get(s.ind.id) || 0;
1035
+ if (!signal) continue;
1036
+ s.score += Math.min(signal * PROSE_LAYER_FRAC, s.score * PROSE_LAYER_CAP_FRAC);
1037
+ }
1038
+ }
1039
+ // §7.6(5b) embedRank (opt-in): static-embedding cosine re-rank — see the EMB_* constants'
1040
+ // comment above. The embedder is INJECTED (opts.embedder, from embed.mjs's loadEmbedder) so
1041
+ // this module stays fs-free; absent embedder → no-op with a one-time stderr note, never a
1042
+ // failure (the 30 MB weights are a local opt-in fetch, not a test/CI dependency).
1043
+ if (embedRank) {
1044
+ if (!opts.embedder) {
1045
+ if (!embedWarned) {
1046
+ embedWarned = true;
1047
+ process.stderr.write("tmct: embedRank requested but no embedder available (weights not fetched? see `npm run refs:embeddings`) — flag is a no-op\n");
1048
+ }
1049
+ } else if (scored.length) {
1050
+ const embedder = opts.embedder;
1051
+ let cache = EMB_CACHE.get(graph);
1052
+ if (!cache || cache.embedder !== embedder) {
1053
+ cache = { embedder, texts: moduleEmbedTexts(graph), vecs: new Map() };
1054
+ EMB_CACHE.set(graph, cache);
1055
+ }
1056
+ const qv = embedder.embed(rawQuery || tokens.join(" "));
1057
+ let maxBase = 0;
1058
+ for (const s of scored) maxBase = Math.max(maxBase, s.score);
1059
+ for (const s of scored) {
1060
+ let v = cache.vecs.get(s.ind.id);
1061
+ if (!v) {
1062
+ v = embedder.embed(cache.texts.get(s.ind.id) || String(s.ind.label));
1063
+ cache.vecs.set(s.ind.id, v);
1064
+ }
1065
+ const sim = Math.max(0, cosine(qv, v)); // negative similarity never penalises
1066
+ if (!sim) continue;
1067
+ s.score += Math.min(sim * maxBase * EMB_FRAC, s.score * EMB_CAP_FRAC);
1068
+ }
1069
+ }
1070
+ }
1071
+ // §5.15 beam search (opt-in): multi-ply generalization of the single-hop families above.
1072
+ if (beamSearch && scored.length > 1) beamExpand(graph, scored, beamWidth);
1073
+ // SPIRAL (opt-in): bounded-radius ego walk that MAY introduce lexically-invisible modules — runs
1074
+ // last (after every family has finalised the seed scores) so its seed-relative emit scores and
1075
+ // hub gate read the settled ranking, and before the sort so surfaced nodes slot into it.
1076
+ if (spiral && scored.length) spiralExpand(graph, scored, {
1077
+ depth: Number.isFinite(opts.spiralDepth) && opts.spiralDepth > 0 ? opts.spiralDepth : SPIRAL_DEPTH_DEFAULT,
1078
+ q: Number.isFinite(opts.mostDistinctiveBeams) && opts.mostDistinctiveBeams > 0 ? opts.mostDistinctiveBeams : SPIRAL_Q_DEFAULT,
1079
+ nodeLimit: Number.isFinite(opts.spiralNodeLimit) && opts.spiralNodeLimit > 0 ? opts.spiralNodeLimit : SPIRAL_NODE_LIMIT_DEFAULT,
1080
+ });
1081
+ // Tie-break: score → matched-symbol DENSITY (concrete, not ground truth) → fewer defines → shorter label.
1082
+ scored.sort((a, b) => b.score - a.score || b.density - a.density || a.defineCount - b.defineCount || String(a.ind.label).length - String(b.ind.label).length);
1083
+ return scored;
1084
+ }
1085
+
1086
+ /** TUNING #3: the ranked module list as plain `{path, score}` (highest-first), using the SAME
1087
+ * ranking renderSearch uses (path + symbol + exact-symbol + import-proximity). Lets the rig
1088
+ * read the score GAP between rank-1 and rank-2 (which the text renderer hides) so it can keep
1089
+ * rank-2 only when it is close. Pure; deterministic.
1090
+ * NOTE: scoreModules still RANKS (locate always returns modules), but the score-gap top-1
1091
+ * SELECTION that consumes this gap is OFF by default in run.mjs/selectModules — it over-injected
1092
+ * on some tasks. The shipped default takes the top-2 instead. */
1093
+ export function searchModulesRanked(graph, query, opts = {}) {
1094
+ const raw = String(query || "");
1095
+ const tokens = raw.toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
1096
+ if (!tokens.length) return [];
1097
+ // literalMention needs the query BEFORE tokenization (the tokenizer destroys the dotted refs
1098
+ // it matches on) and embedRank embeds the raw phrasing; threaded only when a flag that
1099
+ // consumes it is on, so the OFF path is provably unchanged.
1100
+ const effOpts = (opts.literalMention || opts.embedRank) ? { ...opts, rawQuery: raw } : opts;
1101
+ return scoreModules(graph, tokens, effOpts).map((s) => ({ path: String(s.ind.label), score: s.score }));
1102
+ }
1103
+
1104
+ // B016 R1b, promoted to the shipped default (2026-07-02): positive in every measured cell across
1105
+ // B016's P1 (tuning task + a genuinely held-out task) and P2 (both eshoponweb tasks; clears the
1106
+ // ≥50%-vs-otb bar outright on order-service-total). See PLAN_B016.md §6.9. 0.6 is the exact ratio
1107
+ // tested throughout — do not drift it from bench/arms.mjs's arm values or scripts/rank-gate.mjs's
1108
+ // --gap default; all three should read this constant.
1109
+ export const DEFAULT_SCORE_GAP = 0.6;
1110
+
1111
+ /** Score-gap-driven module selection: take the top_k ranked hits, then extend the selection to
1112
+ * include ranks (top_k)..2 whose score sits within `scoreGapK` of rank 1 — the near-tie case
1113
+ * where a second (or third) module is genuinely as relevant as the top hit, not filler. Never
1114
+ * resurrects a suppressed (empty) selection: a top_k of 0 stays empty regardless of scoreGapK.
1115
+ * Pure — the single source of truth for gap-extension, shared by the CLI product surface
1116
+ * (cli.mjs's query-based `digest`) and the bench rig (bench/run.mjs's selectModules).
1117
+ *
1118
+ * DELIBERATELY NEUTRAL BY DEFAULT: `scoreGapK` defaults to `null` (gap-extension OFF, plain
1119
+ * top-`top_k`) here — the SHIPPED default of `DEFAULT_SCORE_GAP` is a product-surface policy
1120
+ * decision, applied explicitly by the caller (cli.mjs's digest query-mode), not baked into this
1121
+ * primitive. A library default of "on" would make every future caller who forgets to pass
1122
+ * `scoreGapK` silently inherit gap-extension — including future bench arms, breaking the
1123
+ * paired-arm "byte-identical when off" comparability this repo's whole measurement methodology
1124
+ * depends on. See test/selectRankedModules.test.mjs's "absent scoreGapK is byte-identical to
1125
+ * plain top-k" case. */
1126
+ export function selectRankedModules(ranked, { top_k = 2, scoreGapK = null } = {}) {
1127
+ if (!ranked.length || top_k <= 0) return [];
1128
+ const picked = ranked.slice(0, top_k).map((r) => r.path);
1129
+ if (scoreGapK && picked.length >= 1 && ranked[0].score > 0) {
1130
+ for (const r of ranked.slice(1, 3)) {
1131
+ if (r.score / ranked[0].score >= scoreGapK && !picked.includes(r.path)) picked.push(r.path);
1132
+ }
1133
+ }
1134
+ return picked;
1135
+ }
1136
+
1137
+ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "" } = {}) {
1138
+ const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
1139
+ const wantKind = String(kind || "").trim().toLowerCase();
1140
+ const decFilter = String(decorator || "").trim().toLowerCase();
1141
+ let nameRe = null;
1142
+ if (name) {
1143
+ try { nameRe = new RegExp(name, "i"); } catch { return `invalid name pattern: ${name}`; }
1144
+ }
1145
+ // kind= switches to symbol search (functions/classes/methods/attributes); the
1146
+ // default (no kind) keeps the module "where does this live" search unchanged.
1147
+ if (wantKind && wantKind !== "module") {
1148
+ return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe });
1149
+ }
1150
+ if (!tokens.length && !nameRe && !decFilter) return "empty query";
1151
+ const scored = scoreModules(graph, tokens);
1152
+ if (!scored.length) {
1153
+ return `no module matches "${query}". Try broader keywords, or tmct_describe <path> if you know where it lives.`;
1154
+ }
1155
+ const hits = scored.slice(0, limit);
1156
+ const lines = [`${scored.length} module(s) match "${query}" (top ${hits.length}):`];
1157
+ for (const { ind, defineCount, matching } of hits) {
1158
+ const m = matching.length ? ` — matching: ${capJoin([...new Set(matching)], SEARCH_SYMBOLS_SHOWN)}` : "";
1159
+ lines.push(`- ${ind.label} (defines ${defineCount} symbol(s))${m}`);
1160
+ }
1161
+ lines.push("Then tmct_describe <path> for the full sibling list + typed edges, or tmct_impact <path> for dependents.");
1162
+ return lines.join("\n");
1163
+ }
1164
+
1165
+ // ---- §9 read-replacing tools (members / inheritance / architecture / coverage /
1166
+ // history / call neighbours). Each answers ONE question in one compact call so
1167
+ // the agent need not Read/Grep. All keep the bounded-output discipline. -------
1168
+
1169
+ /** All edges whose relation classifies to `kind`, flattened across relation groups. */
1170
+ /** All edges of a classified relation kind (imports/calls/defines/tests/touches/inherits/
1171
+ * cochange/reexports/callsSymbol/touchesSymbol/contains — see relationKind/PROP_KIND above),
1172
+ * flattened across every raw relation group that classifies to it. Exported for ask.mjs's
1173
+ * mechanical NL-query engine (PLAN_MECHANICAL_CHAT.md) to orchestrate rather than duplicate. */
1174
+ export function edgesOfKind(graph, kind) {
1175
+ const out = [];
1176
+ for (const g of graph.relations) if (relationKind(g) === kind) out.push(...g.edges);
1177
+ return out;
1178
+ }
1179
+
1180
+ /** moduleIdOf by raw edge-endpoint id: resolves through byId when the individual exists,
1181
+ * else falls back to parsing an `fn:<path>#name` id directly (callsSymbol objects may name
1182
+ * symbols with no individual of their own). Null if it cannot be mapped. */
1183
+ function moduleIdOfId(graph, id) {
1184
+ const ind = graph.byId?.get?.(id);
1185
+ if (ind) return moduleIdOf(graph, ind);
1186
+ const m = String(id || "").match(/^fn:(.+)#/);
1187
+ return m ? `mod:${m[1]}` : null;
1188
+ }
1189
+
1190
+ /** The module id an individual belongs to (itself if a Module; via its site span,
1191
+ * else parsed from an `fn:<path>#name` id). Null if it cannot be mapped. */
1192
+ function moduleIdOf(graph, ind) {
1193
+ if ((ind?.class || "") === "Module") return ind.id;
1194
+ const site = siteOf(ind);
1195
+ if (site) return `mod:${site.path}`;
1196
+ const m = String(ind?.id || "").match(/^fn:(.+)#/);
1197
+ return m ? `mod:${m[1]}` : null;
1198
+ }
1199
+
1200
+ function spanTag(site) {
1201
+ if (!site) return "";
1202
+ const s = site.end > site.start ? `${site.start}-${site.end}` : `${site.start}`;
1203
+ return ` [${site.path}:${s}]`;
1204
+ }
1205
+
1206
+ function decoratorOf(ind) {
1207
+ return (ind?.attributes || []).find((a) => a.key === "decorators")?.value || "";
1208
+ }
1209
+
1210
+ const MEMBERS_CAP = 40;
1211
+ const SUBCLASS_CAP = 40;
1212
+ const CALL_CAP = 30;
1213
+
1214
+ /** A class's methods + attributes (with sites/decorators) in one slice — replaces
1215
+ * reading the class body. Uses the `contains` (seon:containsCodeEntity) relation. */
1216
+ export function renderMembers(graph, ind) {
1217
+ const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
1218
+ const contains = edgesOfKind(graph, "contains").filter((e) => e.subject === ind.id);
1219
+ if (!contains.length) {
1220
+ lines.push("members: none recorded (empty class, or members not in the extracted graph). Use tmct_describe for its edges.");
1221
+ return lines.join("\n");
1222
+ }
1223
+ const methods = [];
1224
+ const attrs = [];
1225
+ for (const e of contains) {
1226
+ const member = graph.byId.get(e.object);
1227
+ const where = spanTag(member ? siteOf(member) : null);
1228
+ const dec = member ? decoratorOf(member) : "";
1229
+ const entry = `${e.objectLabel || e.object}${where}${dec ? ` @${dec}` : ""}`;
1230
+ ((member?.class || "") === "Attribute" ? attrs : methods).push(entry);
1231
+ }
1232
+ if (methods.length) lines.push(`methods (${methods.length}): ${capJoin(methods, MEMBERS_CAP)}`);
1233
+ if (attrs.length) lines.push(`attributes (${attrs.length}): ${capJoin(attrs, MEMBERS_CAP)}`);
1234
+ lines.push("Use tmct_snippet <Class.member> for an exact body.");
1235
+ return lines.join("\n");
1236
+ }
1237
+
1238
+ const attrVal = (ind, key) => (ind?.attributes || []).find((a) => a.key === key)?.value || "";
1239
+
1240
+ /** The mechanical-enrichment signature of a symbol in ONE compact block — params,
1241
+ * return annotation, raises/catches, self-fields, flags, decorators, one-line doc —
1242
+ * so the agent gets the API surface without reading the body. Deterministic ast facts
1243
+ * (kept OUT of tmct_context's lean bundle; this is the targeted tool for them). */
1244
+ export function renderSignature(graph, ind) {
1245
+ const site = siteOf(ind);
1246
+ const lines = [`${ind.label} — ${ind.class || "Entity"}${spanTag(site)}`];
1247
+ const params = attrVal(ind, "params");
1248
+ const returns = attrVal(ind, "returns");
1249
+ if (params || returns || (ind.class || "") === "Method" || (ind.class || "") === "Function") {
1250
+ lines.push(`signature: ${ind.label}(${params})${returns ? ` -> ${returns}` : ""}`);
1251
+ }
1252
+ const flags = [];
1253
+ if (attrVal(ind, "isStatic")) flags.push("static");
1254
+ if (attrVal(ind, "isAbstract")) flags.push("abstract");
1255
+ if (attrVal(ind, "isConstant")) flags.push("constant");
1256
+ const vis = attrVal(ind, "visibility");
1257
+ if (vis) flags.push(vis);
1258
+ if (flags.length) lines.push(`flags: ${flags.join(", ")}`);
1259
+ const dec = decoratorOf(ind);
1260
+ if (dec) lines.push(`decorators: @${dec.split(", ").join(", @")}`);
1261
+ const raises = attrVal(ind, "raises");
1262
+ if (raises) lines.push(`raises: ${raises}`);
1263
+ const catches = attrVal(ind, "catches");
1264
+ if (catches) lines.push(`catches: ${catches}`);
1265
+ const fields = attrVal(ind, "self_fields");
1266
+ if (fields) lines.push(`self fields: ${fields}`);
1267
+ const value = attrVal(ind, "value");
1268
+ if (value) lines.push(`value: ${value}`);
1269
+ const doc = attrVal(ind, "doc");
1270
+ if (doc) lines.push(`doc: ${doc}`);
1271
+ if (lines.length === 1) lines.push("(no signature detail recorded for this symbol — likely a module or attribute; use tmct_snippet for its source.)");
1272
+ lines.push("Use tmct_snippet for the exact body.");
1273
+ return lines.join("\n");
1274
+ }
1275
+
1276
+ /** Forward bases + the transitive reverse inheritance closure (who extends this) —
1277
+ * replaces grepping `class X(Base)` across the tree. Uses `inherits` (mgx:subclassOf). */
1278
+ export function renderSubclasses(graph, ind) {
1279
+ const inherits = edgesOfKind(graph, "inherits");
1280
+ const bases = inherits.filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object);
1281
+ const childrenOf = new Map();
1282
+ for (const e of inherits) {
1283
+ if (!childrenOf.has(e.object)) childrenOf.set(e.object, []);
1284
+ childrenOf.get(e.object).push({ id: e.subject, label: e.subjectLabel || e.subject });
1285
+ }
1286
+ const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
1287
+ lines.push(bases.length ? `extends: ${capJoin(bases, SUBCLASS_CAP)}` : "extends: (no internal/recorded base classes)");
1288
+ const visited = new Set([ind.id]);
1289
+ const levels = [];
1290
+ let frontier = [ind.id];
1291
+ for (let depth = 1; depth <= 8 && frontier.length; depth += 1) {
1292
+ const next = [];
1293
+ const level = [];
1294
+ for (const id of frontier) {
1295
+ for (const c of childrenOf.get(id) || []) {
1296
+ if (visited.has(c.id)) continue;
1297
+ visited.add(c.id);
1298
+ level.push(c.label);
1299
+ next.push(c.id);
1300
+ }
1301
+ }
1302
+ if (level.length) {
1303
+ level.sort((a, b) => String(a).localeCompare(String(b)));
1304
+ levels.push(level);
1305
+ }
1306
+ frontier = next;
1307
+ }
1308
+ const total = levels.reduce((n, l) => n + l.length, 0);
1309
+ if (!total) {
1310
+ lines.push("subclasses: none recorded — nothing extends it in the extracted graph.");
1311
+ } else {
1312
+ lines.push(`subclasses: ${total} total across ${levels.length} level(s).`);
1313
+ levels.forEach((l, i) => lines.push(` depth ${i + 1} (${l.length}): ${capJoin(l, SUBCLASS_CAP)}`));
1314
+ }
1315
+ return lines.join("\n");
1316
+ }
1317
+
1318
+ const ARCH_PKG_CAP = 25;
1319
+ const ARCH_HUB_CAP = 15;
1320
+
1321
+ /** Package/module tree + the most-imported (hub) modules — replaces reading the dir
1322
+ * tree and many files to learn the shape. Optional `pkg` prefix scopes it. */
1323
+ export function renderArchitecture(graph, { pkg = "" } = {}) {
1324
+ const norm = normPath(pkg);
1325
+ const modules = graph.individuals.filter(
1326
+ (i) => (i.class || "") === "Module" && (!norm || normPath(i.label).startsWith(norm)),
1327
+ );
1328
+ if (!modules.length) return norm ? `no modules under "${pkg}".` : "no modules in the graph.";
1329
+ const pkgCount = new Map();
1330
+ for (const m of modules) {
1331
+ const dir = m.label.includes("/") ? m.label.slice(0, m.label.lastIndexOf("/")) : "(root)";
1332
+ pkgCount.set(dir, (pkgCount.get(dir) || 0) + 1);
1333
+ }
1334
+ const inDeg = new Map();
1335
+ for (const e of edgesOfKind(graph, "imports")) inDeg.set(e.object, (inDeg.get(e.object) || 0) + 1);
1336
+ const modSet = new Set(modules.map((m) => m.id));
1337
+ const hubs = [...inDeg.entries()]
1338
+ .filter(([id]) => modSet.has(id))
1339
+ .sort((a, b) => b[1] - a[1])
1340
+ .slice(0, ARCH_HUB_CAP)
1341
+ .map(([id, n]) => `${graph.byId.get(id)?.label || id} (${n} importers)`);
1342
+ const pkgs = [...pkgCount.entries()].sort((a, b) => b[1] - a[1]);
1343
+ const lines = [`Architecture${norm ? ` of ${pkg}` : ""}: ${modules.length} module(s) in ${pkgs.length} package(s).`];
1344
+ lines.push(`packages (by module count): ${capJoin(pkgs.map(([d, n]) => `${d} (${n})`), ARCH_PKG_CAP)}`);
1345
+ lines.push(hubs.length ? `hub modules (most imported): ${hubs.join(", ")}` : "hub modules: none (no internal imports recorded).");
1346
+ return lines.join("\n");
1347
+ }
1348
+
1349
+ const COVERAGE_CAP = 40;
1350
+
1351
+ /** The test modules covering a symbol/module — from the `tests` (mgx:testsCoverage)
1352
+ * relation. Replaces grepping `tests/` for who imports the target. */
1353
+ export function renderTestsFor(graph, ind) {
1354
+ const modId = moduleIdOf(graph, ind);
1355
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1356
+ const modLabel = graph.byId.get(modId)?.label || modId;
1357
+ const tests = [...new Set(edgesOfKind(graph, "tests").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))];
1358
+ if (!tests.length) return `${modLabel}: no covering tests recorded (no test module imports it).`;
1359
+ return `${modLabel}: covered by ${tests.length} test module(s):\n ${capJoin(tests, COVERAGE_CAP, "\n ")}`;
1360
+ }
1361
+
1362
+ /** Source modules with no covering test module — a coverage gap view. Test
1363
+ * modules (subjects of test edges, or test-named paths) are excluded. */
1364
+ export function renderUntested(graph) {
1365
+ const covered = new Set();
1366
+ const testModules = new Set();
1367
+ for (const e of edgesOfKind(graph, "tests")) {
1368
+ covered.add(e.object);
1369
+ testModules.add(e.subject);
1370
+ }
1371
+ const untested = graph.individuals
1372
+ .filter(
1373
+ (i) =>
1374
+ (i.class || "") === "Module" &&
1375
+ !testModules.has(i.id) &&
1376
+ !isTestLabel(String(i.label).toLowerCase()) &&
1377
+ !covered.has(i.id),
1378
+ )
1379
+ .map((i) => i.label)
1380
+ .sort();
1381
+ if (!untested.length) return "every source module has at least one covering test module.";
1382
+ return `${untested.length} source module(s) with no covering test module:\n ${capJoin(untested, COVERAGE_CAP, "\n ")}`;
1383
+ }
1384
+
1385
+ const HISTORY_CAP = 15;
1386
+
1387
+ /** Recent commits that touched a symbol's module — from `touches` (seon:history).
1388
+ * Replaces `git log -- <file>`. Commits are listed newest-first (git-log order). */
1389
+ export function renderHistory(graph, ind) {
1390
+ const modId = moduleIdOf(graph, ind);
1391
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1392
+ const modLabel = graph.byId.get(modId)?.label || modId;
1393
+ const commits = edgesOfKind(graph, "touches").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject);
1394
+ if (!commits.length) return `${modLabel}: no commit history recorded (outside the git-log window or unmodified).`;
1395
+ return `${modLabel}: touched by ${commits.length} recent commit(s): ${capJoin(commits, HISTORY_CAP)}`;
1396
+ }
1397
+
1398
+ /** Modules that call into the target's module (one hop over `calls`). */
1399
+ export function renderCallers(graph, ind) {
1400
+ const modId = moduleIdOf(graph, ind);
1401
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1402
+ const modLabel = graph.byId.get(modId)?.label || modId;
1403
+ const callers = [...new Set(edgesOfKind(graph, "calls").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))];
1404
+ if (!callers.length) return `${modLabel}: no recorded callers (calls are coarse/import-backed — absence is not proof). Try tmct_impact for the full reverse closure.`;
1405
+ return `${modLabel} — called by ${callers.length} module(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
1406
+ }
1407
+
1408
+ /** Modules the target's module calls into (one hop over `calls`). */
1409
+ export function renderCallees(graph, ind) {
1410
+ const modId = moduleIdOf(graph, ind);
1411
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1412
+ const modLabel = graph.byId.get(modId)?.label || modId;
1413
+ const callees = [...new Set(edgesOfKind(graph, "calls").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object))];
1414
+ if (!callees.length) return `${modLabel}: no recorded callees.`;
1415
+ return `${modLabel} — calls into ${callees.length} module(s):\n ${capJoin(callees, CALL_CAP, "\n ")}`;
1416
+ }
1417
+
1418
+ // ---- fine-grained in-repo calls (fn→fn, `callsSymbol`) ---------------------------
1419
+
1420
+ const CALL_HINT_CAP = 8;
1421
+
1422
+ /** Format one fn→fn callee edge as `name [path:line]` (path:line from the callee's site). */
1423
+ function calleeRef(graph, e) {
1424
+ const callee = graph.byId.get(e.object);
1425
+ const cs = callee ? siteOf(callee) : null;
1426
+ return `${e.objectLabel || callee?.label || e.object}${cs ? ` [${cs.path}:${cs.start}]` : ""}`;
1427
+ }
1428
+
1429
+ /** One-line "calls in-repo: name [path:line], …" hint for a function — appended to
1430
+ * tmct_snippet and the tmct_context exemplar body so the agent sees the symbol's
1431
+ * in-repo call dependencies inline. Empty string when it calls nothing in-repo. Pure. */
1432
+ export function callHint(graph, ind) {
1433
+ if (!ind?.id) return "";
1434
+ const calls = edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id);
1435
+ if (!calls.length) return "";
1436
+ return `calls in-repo: ${capJoin(calls.map((e) => calleeRef(graph, e)), CALL_HINT_CAP)}`;
1437
+ }
1438
+
1439
+ /** The in-repo symbols a function calls (fn→fn `callsSymbol` edges), with file:line.
1440
+ * Cold tool — replaces reading a body to learn its in-repo call graph. */
1441
+ export function renderCalls(graph, ind) {
1442
+ const calls = edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id);
1443
+ if (!calls.length) {
1444
+ return `${ind.label} — ${ind.class || "Entity"}: no in-repo calls recorded (calls only stdlib/external, or fine-grained call edges are not in the extracted graph).`;
1445
+ }
1446
+ const items = calls.map((e) => calleeRef(graph, e));
1447
+ return `${ind.label} — ${ind.class || "Entity"} calls ${calls.length} in-repo symbol(s):\n ${capJoin(items, CALL_CAP, "\n ")}`;
1448
+ }
1449
+
1450
+ // ---- commit history with author/date/subject (Commit attributes) ----------------
1451
+
1452
+ /** One commit rendered as "<sha> <date> <author> — <subject>", from the Commit
1453
+ * individual's commitAuthor/commitDate/commitMessage attributes (graceful when absent). */
1454
+ function commitLine(graph, commitId, fallbackLabel) {
1455
+ const c = graph.byId.get(commitId);
1456
+ const sha = c?.label || fallbackLabel || commitId;
1457
+ // Tolerate either attribute-key convention (commitAuthor/… or the shorter author/…).
1458
+ const date = attrVal(c, "commitDate") || attrVal(c, "date");
1459
+ const author = attrVal(c, "commitAuthor") || attrVal(c, "author");
1460
+ const msg = attrVal(c, "commitMessage") || attrVal(c, "message");
1461
+ const head = [sha, date, author].filter(Boolean).join(" ");
1462
+ return msg ? `${head} — ${msg}` : head;
1463
+ }
1464
+
1465
+ /** File history: commits that touched a symbol's MODULE (module-coarse `touches`
1466
+ * edges), each with author/date/subject. Newest-first (git-log order preserved). */
1467
+ export function renderFileHistory(graph, ind) {
1468
+ const modId = moduleIdOf(graph, ind);
1469
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1470
+ const modLabel = graph.byId.get(modId)?.label || modId;
1471
+ const commits = edgesOfKind(graph, "touches").filter((e) => e.object === modId);
1472
+ if (!commits.length) return `${modLabel}: no commit history recorded (outside the git-log window or unmodified).`;
1473
+ const shown = commits.slice(0, HISTORY_CAP).map((e) => ` ${commitLine(graph, e.subject, e.subjectLabel)}`);
1474
+ const tail = commits.length > HISTORY_CAP ? `\n …+${commits.length - HISTORY_CAP} more` : "";
1475
+ return `${modLabel}: touched by ${commits.length} recent commit(s):\n${shown.join("\n")}${tail}`;
1476
+ }
1477
+
1478
+ /** Symbol-granular history: commits whose `touchesSymbol` edge points at THIS symbol's
1479
+ * id (method/class/function), each with author/date/subject. Used by method/class history. */
1480
+ function renderSymbolHistory(graph, ind) {
1481
+ const commits = edgesOfKind(graph, "touchesSymbol").filter((e) => e.object === ind.id);
1482
+ if (!commits.length) {
1483
+ return `${ind.label} — ${ind.class || "Entity"}: no symbol-level commit history recorded (outside the git-log window, or fine-grained history is not in the extracted graph).`;
1484
+ }
1485
+ const shown = commits.slice(0, HISTORY_CAP).map((e) => ` ${commitLine(graph, e.subject, e.subjectLabel)}`);
1486
+ const tail = commits.length > HISTORY_CAP ? `\n …+${commits.length - HISTORY_CAP} more` : "";
1487
+ return `${ind.label} — ${ind.class || "Entity"}: touched by ${commits.length} commit(s):\n${shown.join("\n")}${tail}`;
1488
+ }
1489
+
1490
+ /** Method history — commits touching a specific method symbol (`touchesSymbol`). */
1491
+ export function renderMethodHistory(graph, ind) {
1492
+ return renderSymbolHistory(graph, ind);
1493
+ }
1494
+
1495
+ /** Class history — commits touching a specific class symbol (`touchesSymbol`). */
1496
+ export function renderClassHistory(graph, ind) {
1497
+ return renderSymbolHistory(graph, ind);
1498
+ }
1499
+
1500
+ // ---- symbol search (kind=function/class/method/attribute, with name/decorator filters)
1501
+
1502
+ const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute" };
1503
+
1504
+ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe }) {
1505
+ const targetClass = SYMBOL_CLASSES[kind];
1506
+ if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, or module).`;
1507
+ const hits = [];
1508
+ for (const ind of graph.individuals) {
1509
+ if ((ind.class || "") !== targetClass) continue;
1510
+ if (nameRe && !nameRe.test(ind.label)) continue;
1511
+ if (decFilter && !decoratorOf(ind).toLowerCase().includes(decFilter)) continue;
1512
+ const label = String(ind.label).toLowerCase();
1513
+ let score = tokens.length ? 0 : 1;
1514
+ for (const t of tokens) if (label.includes(t)) score += 5;
1515
+ if (tokens.length && !score) continue;
1516
+ hits.push({ ind, score });
1517
+ }
1518
+ if (!hits.length) return `no ${kind} matches the given filters.`;
1519
+ hits.sort((a, b) => b.score - a.score || String(a.ind.label).length - String(b.ind.label).length);
1520
+ const top = hits.slice(0, limit);
1521
+ const lines = [`${hits.length} ${kind}(s) match (top ${top.length}):`];
1522
+ for (const { ind } of top) lines.push(`- ${ind.label}${spanTag(siteOf(ind))}`);
1523
+ lines.push("Then tmct_snippet <name> for the exact body, or tmct_describe for its edges.");
1524
+ return lines.join("\n");
1525
+ }
1526
+
1527
+ // ---- tmct_context: a one-shot "edit bundle" plan (pure; the server adds the file
1528
+ // reads). Returns everything needed to add-a-sibling to a module in ONE call,
1529
+ // so the agent need not search→describe→snippet→read×N (RepoGraph ego-network
1530
+ // idea; LocAgent: structured, replacement-shaped output drives tool adoption).
1531
+
1532
+ const CONTEXT_SIBLING_CAP = 8; // Lever 1: the bundle is re-billed every turn — keep a few most-relevant siblings, not all.
1533
+ const CLASS_MEMBER_CAP = 16; // Class-internal members shown when the anchor is a class/method.
1534
+ const COCHANGE_MID_CAP = 4; // #13: trim the MID bundle's co-change tail (was 8) — re-billed every turn.
1535
+ const CONTEXT_TESTS_CAP = 6; // #13: cap the covering-tests list in the bundle.
1536
+ const INSERTION_REGION_CAP = 40; // #2: contiguous tail lines shown as the "write your new sibling here" region.
1537
+ // #6 task-size thresholds (named, next to the caps above). B1/B6: widened so the COMMON
1538
+ // "add a small sibling util / register a filter" task lands at the lean TINY default (a 1-2
1539
+ // param helper with a short body), and only genuinely bigger edits top up to MID/LARGE.
1540
+ const TINY_MAX_LOC = 12; // TINY: exemplar/anchor body ≤ this many lines …
1541
+ const TINY_MAX_ARITY = 2; // … AND ≤ this many params (value, arg) …
1542
+ const LARGE_CLASS_MEMBERS = 8; // LARGE: anchor is a method of a class with ≥ this many members ("big class").
1543
+ const INLINE_CALLEE_CAP = 3; // LARGE: inline at most this many depth-1 in-repo callee bodies …
1544
+ const INLINE_CALLEE_LOC = 120; // … up to this many total lines.
1545
+
1546
+ const splitDecs = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
1547
+ const tokenize = (s) =>
1548
+ String(s || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
1549
+ const countParams = (p) => { const s = String(p || "").trim(); return s ? s.split(",").map((x) => x.trim()).filter(Boolean).length : 0; };
1550
+ const modeOf = (nums) => {
1551
+ const freq = new Map();
1552
+ let best = nums[0] ?? 0;
1553
+ let bestN = 0;
1554
+ for (const n of nums) { const c = (freq.get(n) || 0) + 1; freq.set(n, c); if (c > bestN) { bestN = c; best = n; } }
1555
+ return best;
1556
+ };
1557
+
1558
+ /** A symbol's structural profile (param count, has-returns/raises, in-repo callee set)
1559
+ * — the shape matched by the structural-similarity component of sibling ranking. */
1560
+ function profileOf(x) {
1561
+ return {
1562
+ paramCount: countParams(x?.params),
1563
+ hasReturns: Boolean(x?.returns),
1564
+ hasRaises: Boolean(x?.raises),
1565
+ callees: x?.callees instanceof Set ? x.callees : new Set(),
1566
+ };
1567
+ }
1568
+
1569
+ /** When the anchor is a Module (no single anchor symbol), derive the dominant structural
1570
+ * pattern across the siblings so the exemplar can be chosen for structural closeness too. */
1571
+ function dominantProfile(siblings) {
1572
+ if (!siblings.length) return { paramCount: 0, hasReturns: false, hasRaises: false, callees: new Set() };
1573
+ const counts = siblings.map((s) => countParams(s.params));
1574
+ const retYes = siblings.filter((s) => Boolean(s.returns)).length;
1575
+ const raiseYes = siblings.filter((s) => Boolean(s.raises)).length;
1576
+ const calleeFreq = new Map();
1577
+ for (const s of siblings) for (const c of s.callees || []) calleeFreq.set(c, (calleeFreq.get(c) || 0) + 1);
1578
+ const common = new Set([...calleeFreq.entries()].filter(([, n]) => n >= 2).map(([c]) => c));
1579
+ return {
1580
+ paramCount: modeOf(counts),
1581
+ hasReturns: retYes * 2 >= siblings.length,
1582
+ hasRaises: raiseYes * 2 >= siblings.length,
1583
+ callees: common,
1584
+ };
1585
+ }
1586
+
1587
+ /** Structural affinity of a sibling to the target profile — bounded below name-affinity
1588
+ * (max 16 < the 50/token name weight), so it only breaks ties within a name/decorator tier. */
1589
+ function structuralScore(s, target) {
1590
+ if (!target) return 0;
1591
+ let score = Math.max(0, 4 - Math.abs(countParams(s.params) - target.paramCount));
1592
+ if (Boolean(s.returns) === target.hasReturns) score += 2;
1593
+ if (Boolean(s.raises) === target.hasRaises) score += 2;
1594
+ const shared = [...(s.callees || [])].filter((c) => target.callees.has(c)).length;
1595
+ return score + Math.min(shared, 4) * 2;
1596
+ }
1597
+
1598
+ /** Lever 1: rank siblings by relevance to the anchor so the lean bundle shows the ones
1599
+ * worth copying — shared decorator (the module's registration pattern, e.g.
1600
+ * @register.filter) > name-affinity (shared tokens) > nearest source position. Pure;
1601
+ * mutates a transient `_score` only. */
1602
+ function rankSiblings(siblings, { decorators: anchorDecorators = "", label: anchorLabel = "", site: anchorSite = null } = {}, structuralTarget = null) {
1603
+ const decCount = new Map();
1604
+ for (const s of siblings) for (const d of splitDecs(s.decorators)) decCount.set(d, (decCount.get(d) || 0) + 1);
1605
+ let dominant = "";
1606
+ let bestCount = 1;
1607
+ for (const [d, c] of decCount) if (c > bestCount) { bestCount = c; dominant = d; }
1608
+ const anchorDecs = new Set(splitDecs(anchorDecorators));
1609
+ const targetDecs = anchorDecs.size ? anchorDecs : new Set(dominant ? [dominant] : []);
1610
+ const anchorTokens = new Set(tokenize(anchorLabel));
1611
+ const anchorStart = anchorSite?.start ?? null;
1612
+ for (const s of siblings) {
1613
+ const decMatch = splitDecs(s.decorators).some((d) => targetDecs.has(d)) ? 1 : 0;
1614
+ const nameAff = tokenize(s.label).filter((t) => anchorTokens.has(t)).length;
1615
+ // #3: structural affinity (param-count / has-returns / has-raises / shared in-repo
1616
+ // callees) sits BELOW name-affinity (max 16 < 50) — a tiebreaker within a name tier.
1617
+ const struct = structuralScore(s, structuralTarget);
1618
+ const pos = anchorStart != null && s.site ? 1 / (1 + Math.abs(s.site.start - anchorStart)) : 0;
1619
+ s._score = decMatch * 1000 + nameAff * 50 + struct + pos;
1620
+ }
1621
+ return [...siblings].sort((a, b) => b._score - a._score || (a.site?.start || 0) - (b.site?.start || 0));
1622
+ }
1623
+
1624
+ /** Structured edit-context for `symbol`'s module: anchor span, RANKED top-level siblings
1625
+ * (Function/Class) capped lean, the single closest exemplar (for its FULL body when the
1626
+ * anchor itself is a module), registration globals, covering tests, and the insertion line.
1627
+ * Pure — no fs. The server reads the module file once to flesh out the snippet + bodies. */
1628
+ export function contextPlan(graph, ind) {
1629
+ const modId = moduleIdOf(graph, ind);
1630
+ const moduleLabel = graph.byId.get(modId)?.label || String(modId || "").replace(/^mod:/, "");
1631
+ const defEdges = edgesOfKind(graph, "defines").filter((e) => e.subject === modId);
1632
+ // #3: index fn→fn in-repo callees once, so siblings/anchor carry their callee set for
1633
+ // structural ranking and the sizeBundle cross-module-call check.
1634
+ const calleeMap = new Map();
1635
+ for (const e of edgesOfKind(graph, "callsSymbol")) {
1636
+ if (!calleeMap.has(e.subject)) calleeMap.set(e.subject, new Set());
1637
+ calleeMap.get(e.subject).add(e.object);
1638
+ }
1639
+ let siblings = [];
1640
+ const globals = [];
1641
+ let insertion = 0;
1642
+ for (const e of defEdges) {
1643
+ const mem = graph.byId.get(e.object);
1644
+ if (!mem) continue;
1645
+ const cls = mem.class || "";
1646
+ const site = siteOf(mem);
1647
+ if (cls === "GlobalVariable") {
1648
+ globals.push({ label: mem.label, value: (mem.attributes || []).find((a) => a.key === "value")?.value || "", site });
1649
+ if (site) insertion = Math.max(insertion, site.end);
1650
+ } else if (cls === "Function" || cls === "Class") {
1651
+ // Carry each sibling's `raises` + one-line doc so a validator-style task sees the
1652
+ // error-contract without reading the body. #3 adds params/returns/callees for
1653
+ // structural-similarity ranking.
1654
+ siblings.push({
1655
+ id: mem.id, label: mem.label, class: cls, site, decorators: decoratorOf(mem),
1656
+ raises: attrVal(mem, "raises"), doc: attrVal(mem, "doc"),
1657
+ params: attrVal(mem, "params"), returns: attrVal(mem, "returns"),
1658
+ callees: calleeMap.get(mem.id) || new Set(),
1659
+ });
1660
+ if (site) insertion = Math.max(insertion, site.end);
1661
+ }
1662
+ }
1663
+ const anchorSite = siteOf(ind);
1664
+ const anchor = anchorSite && (ind.class || "") !== "Module"
1665
+ ? {
1666
+ id: ind.id, label: ind.label, class: ind.class || "", site: anchorSite, decorators: decoratorOf(ind),
1667
+ raises: attrVal(ind, "raises"), params: attrVal(ind, "params"), returns: attrVal(ind, "returns"),
1668
+ callees: calleeMap.get(ind.id) || new Set(),
1669
+ }
1670
+ : null;
1671
+ const totalSiblings = siblings.length;
1672
+ // #3: the structural target the exemplar should resemble — the anchor's own shape when
1673
+ // there is one, else the dominant pattern across siblings (module anchor case).
1674
+ const structuralTarget = anchor ? profileOf(anchor) : dominantProfile(siblings);
1675
+ siblings = rankSiblings(siblings, anchor || { label: ind.label }, structuralTarget);
1676
+ // Lever 2: when the anchor is a module (no anchor body shown), surface the single
1677
+ // closest sibling's FULL body as the copy-this exemplar; signatures alone made the
1678
+ // agent fall back to Read. With a function/class anchor its own body suffices.
1679
+ const exemplar = !anchor ? siblings.find((s) => s.site && s.label !== ind.label) || null : null;
1680
+ const tests = [...new Set(edgesOfKind(graph, "tests").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))].slice(0, CONTEXT_TESTS_CAP);
1681
+ const cochange = cochangeNeighbours(graph, modId).slice(0, COCHANGE_MID_CAP);
1682
+ const exports = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object).slice(0, 20);
1683
+ // The LITERAL __all__ membership (even unresolved) — the public surface a
1684
+ // new sibling must join to be importable.
1685
+ const allExports = attrVal(graph.byId.get(modId), "all");
1686
+ // Class-internal members. When the anchor IS a class (or a method of one),
1687
+ // the edit often lives inside that class (e.g. add Truncator.lines), so list its
1688
+ // members with signatures so the agent need not read/grep the class body.
1689
+ const contains = edgesOfKind(graph, "contains");
1690
+ let classOwnerId = null;
1691
+ if ((ind.class || "") === "Class") classOwnerId = ind.id;
1692
+ else if ((ind.class || "") === "Method") classOwnerId = contains.find((e) => e.object === ind.id)?.subject || null;
1693
+ let classMembers = null;
1694
+ if (classOwnerId) {
1695
+ const owner = graph.byId.get(classOwnerId);
1696
+ const members = contains.filter((e) => e.subject === classOwnerId).map((e) => {
1697
+ const m = graph.byId.get(e.object);
1698
+ return {
1699
+ label: e.objectLabel || m?.label || e.object,
1700
+ class: m?.class || "",
1701
+ site: m ? siteOf(m) : null,
1702
+ decorators: m ? decoratorOf(m) : "",
1703
+ params: m ? attrVal(m, "params") : "",
1704
+ returns: m ? attrVal(m, "returns") : "",
1705
+ raises: m ? attrVal(m, "raises") : "",
1706
+ };
1707
+ }).slice(0, CLASS_MEMBER_CAP);
1708
+ classMembers = { className: owner?.label || String(classOwnerId).replace(/^fn:.*#/, ""), members, total: contains.filter((e) => e.subject === classOwnerId).length };
1709
+ }
1710
+ // #2: contiguous insertion region — from the LAST top-level definition (sibling/global,
1711
+ // or the exemplar, which is a sibling) through end-of-module. We give the start line here;
1712
+ // the server extends `end` to the real end-of-file (capped) using the lines it reads.
1713
+ let lastTop = null;
1714
+ for (const s of [...siblings, ...globals]) {
1715
+ if (s.site && (!lastTop || s.site.start > lastTop.start)) lastTop = s.site;
1716
+ }
1717
+ const insertionRegion = lastTop ? { start: lastTop.start, end: lastTop.end } : null;
1718
+ // #6: the focal symbol (anchor when present, else the module's exemplar) drives both the
1719
+ // call hint and the LARGE-tier inlined-callee bodies.
1720
+ const focal = anchor || exemplar;
1721
+ const focalInd = focal?.id ? graph.byId.get(focal.id) : null;
1722
+ const callHintStr = focalInd ? callHint(graph, focalInd) : "";
1723
+ let calleeBodies = [];
1724
+ if (focal?.callees) {
1725
+ for (const cid of focal.callees) {
1726
+ const c = graph.byId.get(cid);
1727
+ const cs = c ? siteOf(c) : null;
1728
+ if (cs) calleeBodies.push({ label: c.label, site: cs });
1729
+ if (calleeBodies.length >= INLINE_CALLEE_CAP) break;
1730
+ }
1731
+ }
1732
+ return {
1733
+ modId, moduleLabel, anchor, siblings, totalSiblings, exemplar, globals, tests, cochange,
1734
+ exports, allExports, classMembers, insertion, insertionRegion, calleeBodies, callHint: callHintStr,
1735
+ siblingCap: CONTEXT_SIBLING_CAP,
1736
+ };
1737
+ }
1738
+
1739
+ // ---- #6 task-size-adaptive bundle (TINY / MID / LARGE) ---------------------------
1740
+
1741
+ /** Which bundle sections a tier emits. TINY is genuinely minimal (header + one short
1742
+ * exemplar body + registration + insertion region + __all__); MID is the full bundle;
1743
+ * LARGE adds inlined depth-1 callee bodies. FULL forces everything on. Pure. */
1744
+ export function bundleMask(tier) {
1745
+ const all = {
1746
+ anchor: true, exemplar: true, registration: true, insertionRegion: true, allExports: true,
1747
+ classMembers: true, siblings: true, reexports: true, tests: true, cochange: true, inlinedCallees: false,
1748
+ };
1749
+ if (tier === "TINY") return { ...all, classMembers: false, siblings: false, reexports: false, tests: false, cochange: false };
1750
+ if (tier === "LARGE" || tier === "FULL") return { ...all, inlinedCallees: true };
1751
+ return all; // MID
1752
+ }
1753
+
1754
+ /** B2: a TRIMMED mask for SECONDARY (related-but-not-primary) digest modules — keep the cheap,
1755
+ * cache-stable signal (registration globals, ranked sibling SIGNATURES, the insertion region,
1756
+ * __all__) but drop the expensive bodies (anchor/exemplar/inlined callees) and the variable
1757
+ * tails (tests/cochange/re-exports/class members). Pure. */
1758
+ export function trimBundleMask(mask) {
1759
+ return {
1760
+ ...mask,
1761
+ anchor: false, exemplar: false, inlinedCallees: false,
1762
+ classMembers: false, reexports: false, tests: false, cochange: false,
1763
+ registration: true, siblings: true, insertionRegion: true, allExports: true,
1764
+ };
1765
+ }
1766
+
1767
+ /** Classify a context plan by task size and return {tier, mask, topup}. B1/B6: lean by
1768
+ * default — START at TINY and escalate ("top-up") one tier ONLY when the lean bundle would
1769
+ * omit something the edit demonstrably needs (no exemplar body, a class/method edit, or a
1770
+ * large/complex target → MID; a cross-module call or a big-class method → LARGE). `topup`
1771
+ * records whether auto-sizing escalated above TINY (surfaced in the digest header). Pure. */
1772
+ export function sizeBundle(plan, graph, { untuned = false } = {}) {
1773
+ const focal = plan.anchor || plan.exemplar;
1774
+ let tier = "TINY";
1775
+ // (a) no exemplar/anchor body to copy → the agent needs the sibling list (MID).
1776
+ const hasExemplarBody = Boolean((plan.anchor && plan.anchor.site) || (plan.exemplar && plan.exemplar.site));
1777
+ if (!hasExemplarBody) tier = "MID";
1778
+ // (b) the edit lives INSIDE a class (class/method anchor with members) → show members (MID).
1779
+ if (plan.classMembers && plan.classMembers.members && plan.classMembers.members.length) tier = "MID";
1780
+ if (focal) {
1781
+ // (c) a large/complex target (long body, many params, or it raises) → MID.
1782
+ const loc = focal.site ? focal.site.end - focal.site.start + 1 : Infinity;
1783
+ const arity = countParams(focal.params);
1784
+ // (c) a long/complex focal escalates TINY→MID. Escalation fires on any long focal: gating it on
1785
+ // an explicit symbol anchor (so a long-exemplar MODULE digest stayed TINY) regressed results,
1786
+ // because the trimmed sibling/test tail was load-bearing scaffolding. The `untuned` param is now
1787
+ // a no-op for sizing (kept so the tmct-b010 control arm's flag still resolves).
1788
+ if (loc > TINY_MAX_LOC || arity > TINY_MAX_ARITY || Boolean(focal.raises)) tier = "MID";
1789
+ // (d) LARGE — only for an EXPLICIT symbol focus (plan.anchor), where inlining the
1790
+ // depth-1 callee bodies / the class shape is worth the tokens: a cross-module call from
1791
+ // the anchor, OR an anchor that is a method of a big class. When the focal is merely a
1792
+ // module-EXEMPLAR (the digest/module-anchor case), a cross-module call does NOT force
1793
+ // LARGE — the exemplar body already shows the call, and MID's signatures suffice; this
1794
+ // keeps the common "register a filter" module bundle lean.
1795
+ let crossModule = false;
1796
+ if (plan.anchor) {
1797
+ for (const cid of focal.callees || []) {
1798
+ const c = graph.byId.get(cid);
1799
+ const cs = c ? siteOf(c) : null;
1800
+ if (cs && cs.path !== plan.moduleLabel) { crossModule = true; break; }
1801
+ }
1802
+ }
1803
+ const bigClassMethod = (plan.anchor?.class || "") === "Method" &&
1804
+ Number(plan.classMembers?.total || plan.classMembers?.members?.length || 0) >= LARGE_CLASS_MEMBERS;
1805
+ if (crossModule || bigClassMethod) tier = "LARGE";
1806
+ } else {
1807
+ tier = "MID"; // no focal symbol at all → not a tiny add; keep the fuller bundle.
1808
+ }
1809
+ return { tier, mask: bundleMask(tier), topup: tier !== "TINY" };
1810
+ }
1811
+
1812
+ /** B2: order SECONDARY digest modules by relevance to the PRIMARY (first) module — import
1813
+ * adjacency (either direction, incl. coarse calls) outranks change-coupling weight; ties keep
1814
+ * the caller's input order (stable, deterministic). Returns the candidate labels reordered.
1815
+ * Pure — no fs. Falls back to the input order when the primary can't be mapped to a module. */
1816
+ export function rankModulesByProximity(graph, primaryLabel, candidateLabels) {
1817
+ const moduleIdFor = (label) => {
1818
+ const { match } = resolveSymbol(graph, label);
1819
+ return match && (match.class || "") === "Module" ? match.id : null;
1820
+ };
1821
+ const pid = moduleIdFor(primaryLabel);
1822
+ if (!pid) return [...candidateLabels];
1823
+ const adjacent = new Set();
1824
+ for (const kind of ["imports", "calls"]) {
1825
+ for (const e of edgesOfKind(graph, kind)) {
1826
+ if (e.subject === pid) adjacent.add(e.object);
1827
+ if (e.object === pid) adjacent.add(e.subject);
1828
+ }
1829
+ }
1830
+ const coWeight = new Map();
1831
+ for (const e of edgesOfKind(graph, "cochange")) {
1832
+ if (e.subject === pid) coWeight.set(e.object, (coWeight.get(e.object) || 0) + (e.weight || 0));
1833
+ else if (e.object === pid) coWeight.set(e.subject, (coWeight.get(e.subject) || 0) + (e.weight || 0));
1834
+ }
1835
+ return candidateLabels
1836
+ .map((label, i) => {
1837
+ const id = moduleIdFor(label);
1838
+ const score = id ? (adjacent.has(id) ? 10 : 0) + (coWeight.get(id) || 0) : 0;
1839
+ return { label, score, i };
1840
+ })
1841
+ .sort((a, b) => b.score - a.score || a.i - b.i)
1842
+ .map((s) => s.label);
1843
+ }
1844
+
1845
+ // ---- #7 tmct_context_more: only the sections a TINY/MID bundle omits ------------
1846
+
1847
+ /** Render ONLY the bundle sections a lean bundle omits (sibling list / class members /
1848
+ * re-exports / __all__ / tests / cochange) for a symbol's module. Pure (no fs). */
1849
+ export function renderContextMore(plan) {
1850
+ const out = [`Additional context for ${plan.moduleLabel} (sections omitted from the lean bundle):`];
1851
+ if (plan.classMembers && plan.classMembers.members.length) {
1852
+ out.push(`\n## members of ${plan.classMembers.className}:`);
1853
+ for (const m of plan.classMembers.members) {
1854
+ const short = String(m.label).split(".").pop();
1855
+ const sig = m.params != null && m.params !== "" ? `(${m.params})${m.returns ? ` -> ${m.returns}` : ""}` : "";
1856
+ const dec = m.decorators ? `@${m.decorators} ` : "";
1857
+ const r = m.raises ? ` raises=${m.raises}` : "";
1858
+ out.push(` ${m.class} ${short}${m.site ? ` :${m.site.start}` : ""} ${dec}${short}${sig}${r}`);
1859
+ }
1860
+ }
1861
+ if (plan.siblings.length) {
1862
+ out.push(`\n## sibling symbols (most relevant first; ${plan.siblings.length} total):`);
1863
+ for (const s of plan.siblings.slice(0, plan.siblingCap)) {
1864
+ const dec = s.decorators ? `@${s.decorators} ` : "";
1865
+ const r = s.raises ? ` raises=${s.raises}` : "";
1866
+ out.push(` ${s.class} ${s.label}${s.site ? ` :${s.site.start}` : ""} ${dec}${r}`);
1867
+ }
1868
+ if (plan.siblings.length > plan.siblingCap) out.push(` …+${plan.siblings.length - plan.siblingCap} more`);
1869
+ }
1870
+ if (plan.allExports) out.push(`\n## module __all__: ${plan.allExports}`);
1871
+ if (plan.exports && plan.exports.length) out.push(`\n## re-exported symbols: ${plan.exports.join(", ")}`);
1872
+ if (plan.tests.length) out.push(`\n## covering tests: ${plan.tests.join(", ")}`);
1873
+ if (plan.cochange && plan.cochange.length) {
1874
+ out.push(`\n## usually changed together: ${plan.cochange.map((c) => `${c.label} (×${c.weight})`).join(", ")}`);
1875
+ }
1876
+ if (out.length === 1) out.push("(no omitted sections — the lean bundle already contained everything for this symbol.)");
1877
+ return out.join("\n");
1878
+ }
1879
+
1880
+ // ---- #7 cold-tool catalog (written to <repo>/.tmct/TOOLS.md by the index step) --
1881
+
1882
+ /** Markdown catalog of the COLD tools (everything except the hot catalog tools): each
1883
+ * with a one-line purpose and the exact Bash invocation via the CLI `cli <tool>` route.
1884
+ * Pure — `cliPath` is the absolute path to bin/cli.mjs the caller wants embedded. */
1885
+ export function renderToolsCatalog(cliPath) {
1886
+ const cold = [
1887
+ ["tmct_describe", "Locate one symbol and list its typed edges (both directions) with provenance.", { symbol: "django/utils/text.py" }],
1888
+ ["tmct_signature", "One symbol's API surface (params, returns, raises/catches, flags, decorators, doc) without the body.", { symbol: "Truncator.chars" }],
1889
+ ["tmct_impact", "Transitive reverse closure over imports/calls — what breaks if a module changes, by depth, with tests.", { module: "django/utils/text.py" }],
1890
+ ["tmct_search", "Free-text/ranked lookup over the code-map to find the right module or symbol.", { query: "template filters", kind: "function" }],
1891
+ ["tmct_members", "A class's methods + attributes (file:line, decorators) in one slice.", { class: "Truncator" }],
1892
+ ["tmct_subclasses", "A class's base classes plus the transitive set of classes that extend it.", { class: "Field" }],
1893
+ ["tmct_architecture", "Package/module map + the most-imported hub modules (optionally scoped to a package).", { package: "django/template" }],
1894
+ ["tmct_exports", "A module's public __all__ surface, each name resolved to the module that defines it.", { module: "django/db/models/__init__.py" }],
1895
+ ["tmct_tests_for", "The test modules covering a symbol or module, from the typed test edges.", { symbol: "django/utils/text.py" }],
1896
+ ["tmct_untested", "Source modules with no covering test module — a coverage-gap view (no arguments).", {}],
1897
+ ["tmct_history", "Recent commits that touched a symbol's module (newest first).", { symbol: "django/utils/text.py" }],
1898
+ ["tmct_file_history", "Commits that touched a symbol's module, each with author / date / subject.", { symbol: "django/utils/text.py" }],
1899
+ ["tmct_method_history", "Commits that touched a specific method symbol (fine-grained), with author / date / subject.", { symbol: "Truncator.chars" }],
1900
+ ["tmct_class_history", "Commits that touched a specific class symbol (fine-grained), with author / date / subject.", { symbol: "Truncator" }],
1901
+ ["tmct_callers", "Modules that call into a symbol's module (one hop).", { symbol: "django/utils/text.py" }],
1902
+ ["tmct_callees", "Modules a symbol's module calls into (one hop).", { symbol: "django/utils/text.py" }],
1903
+ ["tmct_calls", "The in-repo symbols a function calls (fn→fn), each with file:line.", { symbol: "slugify" }],
1904
+ ["tmct_cochanges", "Modules that historically change in the same commit as a symbol's module (git co-change).", { symbol: "django/utils/text.py" }],
1905
+ ["tmct_context_more", "The bundle sections a lean tmct_context omitted (siblings / tests / cochange / class members / re-exports).", { symbol: "django/utils/text.py" }],
1906
+ ];
1907
+ const lines = [
1908
+ "# tmct cold-tool catalog",
1909
+ "",
1910
+ "The hot tools — `tmct_context` (start here to add/modify code; supports `depth: min|auto|full`) and `tmct_snippet` (exact source of one symbol) — carry full schemas in the TOOLS catalog.",
1911
+ "",
1912
+ "The cold tools below invoke via the CLI:",
1913
+ "",
1914
+ ];
1915
+ for (const [name, purpose, args] of cold) {
1916
+ lines.push(`## ${name}`);
1917
+ lines.push(purpose);
1918
+ lines.push("```bash");
1919
+ lines.push(`node ${cliPath} cli ${name} '${JSON.stringify(args)}'`);
1920
+ lines.push("```");
1921
+ lines.push("");
1922
+ }
1923
+ return lines.join("\n");
1924
+ }
1925
+
1926
+ // ---- change-coupling (git co-change) — "what usually changes together" ----------
1927
+
1928
+ /** [{label, weight}] modules co-changed with modId, sorted by count desc. Pure. */
1929
+ function cochangeNeighbours(graph, modId) {
1930
+ const hits = [];
1931
+ for (const e of edgesOfKind(graph, "cochange")) {
1932
+ if (e.subject === modId) hits.push({ label: e.objectLabel || e.object, weight: e.weight || 0 });
1933
+ else if (e.object === modId) hits.push({ label: e.subjectLabel || e.subject, weight: e.weight || 0 });
1934
+ }
1935
+ return hits.sort((a, b) => b.weight - a.weight);
1936
+ }
1937
+
1938
+ const COCHANGE_CAP = 20;
1939
+
1940
+ /** Modules that historically change in the same commit as the target's module. */
1941
+ export function renderCochanges(graph, ind) {
1942
+ const modId = moduleIdOf(graph, ind);
1943
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1944
+ const modLabel = graph.byId.get(modId)?.label || modId;
1945
+ const hits = cochangeNeighbours(graph, modId);
1946
+ if (!hits.length) return `${modLabel}: no change-coupling recorded (rarely co-committed, or outside the git-log window).`;
1947
+ const list = hits.slice(0, COCHANGE_CAP).map((h) => `${h.label} (×${h.weight})`);
1948
+ return `${modLabel} — usually changes together with ${hits.length} module(s) (edit these too):\n ${list.join("\n ")}` +
1949
+ (hits.length > COCHANGE_CAP ? `\n …+${hits.length - COCHANGE_CAP} more` : "");
1950
+ }
1951
+
1952
+ // ---- re-exports / public API (__all__) ------------------------------------------
1953
+
1954
+ const EXPORTS_CAP = 40;
1955
+
1956
+ /** A module's public export surface: each __all__ name → the module that actually
1957
+ * defines it (so re-export hubs like __init__ are explicit). */
1958
+ export function renderExports(graph, ind) {
1959
+ const modId = moduleIdOf(graph, ind);
1960
+ if (!modId) return `cannot map ${ind.label} to a module.`;
1961
+ const modLabel = graph.byId.get(modId)?.label || modId;
1962
+ const edges = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId);
1963
+ if (!edges.length) return `${modLabel}: no public exports recorded (no literal __all__, or none resolved).`;
1964
+ const list = edges.slice(0, EXPORTS_CAP).map((e) => {
1965
+ const origin = graph.byId.get(e.object);
1966
+ const where = origin ? siteOf(origin) : null;
1967
+ const from = where ? ` ← ${where.path}` : "";
1968
+ return `${e.objectLabel || e.object}${from}`;
1969
+ });
1970
+ return `${modLabel} — public API (${edges.length} export(s) via __all__):\n ${list.join("\n ")}` +
1971
+ (edges.length > EXPORTS_CAP ? `\n …+${edges.length - EXPORTS_CAP} more` : "");
1972
+ }