@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.
- package/LICENSE +373 -0
- package/README.md +108 -0
- package/ROADMAP.md +209 -0
- package/bin/cli.mjs +226 -0
- package/bin/tmct.mjs +47 -0
- package/package.json +46 -0
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +687 -0
- package/src/ask.mjs +2403 -0
- package/src/chat.mjs +642 -0
- package/src/codegraph.mjs +1972 -0
- package/src/config.mjs +27 -0
- package/src/embed.mjs +191 -0
- package/src/graph-build.mjs +428 -0
- package/src/index.mjs +25 -0
- package/src/prose-nlp.mjs +52 -0
- package/src/prose.mjs +187 -0
- package/src/schema-docs.mjs +254 -0
- package/src/server.mjs +393 -0
- package/src/sessions.mjs +220 -0
- package/src/source.mjs +54 -0
- package/src/telemetry.mjs +90 -0
- package/src/toml-config.mjs +183 -0
- package/src/uuid.mjs +16 -0
|
@@ -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
|
+
}
|