@polycode-projects/seonix 0.1.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 +661 -0
- package/README.md +77 -0
- package/bin/cli.mjs +248 -0
- package/package.json +54 -0
- package/roslyn/Program.cs +150 -0
- package/roslyn/RoslynExtract.csproj +13 -0
- package/src/codegraph.mjs +1313 -0
- package/src/config.mjs +28 -0
- package/src/cs_roslyn.mjs +38 -0
- package/src/cs_treesitter.mjs +140 -0
- package/src/extract.mjs +624 -0
- package/src/extract_ast.py +366 -0
- package/src/extract_lang.mjs +61 -0
- package/src/jsts_tsc.mjs +211 -0
- package/src/server.mjs +389 -0
- package/src/source.mjs +35 -0
- package/src/viz.mjs +218 -0
- package/src/walk.mjs +39 -0
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
// Pure (no-network, no-fs) query logic over the typed `entities` payload that the
|
|
2
|
+
// deterministic indexer writes to <repo>/.seonix/graph.json (shape produced by
|
|
3
|
+
// src/extract.mjs):
|
|
4
|
+
//
|
|
5
|
+
// {
|
|
6
|
+
// generated_at, classes: [{name, count, sample[]}],
|
|
7
|
+
// objectProperties: [{predicate, prop, count, examples: [{subject, object,
|
|
8
|
+
// subjectLabel, objectLabel}]}],
|
|
9
|
+
// individuals: [{id, label, class, derived_from: [ref], mentions: [{id, count}],
|
|
10
|
+
// attributes?: [{prop, key, value}]}],
|
|
11
|
+
// }
|
|
12
|
+
//
|
|
13
|
+
// Ported ≈verbatim from marginalia seon-mcp/src/codegraph.mjs (the shipped,
|
|
14
|
+
// tested typed-edge query layer). The only edits: provenance/attestation wording
|
|
15
|
+
// is code-graph-generic (git:<sha> / file:line refs, not memory-node prose), and
|
|
16
|
+
// a renderSearch() is added for the local, deterministic seonix_search.
|
|
17
|
+
//
|
|
18
|
+
// Edge inventory is read DYNAMICALLY from the payload (predicate verb + the closed
|
|
19
|
+
// `prop` token like "mg:imports"); only the kind-classifier for the impact closure
|
|
20
|
+
// hardcodes the relation set.
|
|
21
|
+
|
|
22
|
+
// ---- payload parsing ---------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export function parseEntities(payload) {
|
|
25
|
+
const individuals = Array.isArray(payload?.individuals) ? payload.individuals : [];
|
|
26
|
+
const byId = new Map();
|
|
27
|
+
for (const ind of individuals) {
|
|
28
|
+
if (ind && ind.id) byId.set(ind.id, ind);
|
|
29
|
+
}
|
|
30
|
+
const relations = (Array.isArray(payload?.objectProperties) ? payload.objectProperties : [])
|
|
31
|
+
.filter((g) => g && (g.predicate || g.prop))
|
|
32
|
+
.map((g) => {
|
|
33
|
+
const edges = (Array.isArray(g.examples) ? g.examples : []).filter((e) => e && e.subject && e.object);
|
|
34
|
+
return {
|
|
35
|
+
predicate: String(g.predicate || ""),
|
|
36
|
+
prop: g.prop || null,
|
|
37
|
+
count: Number(g.count) || edges.length,
|
|
38
|
+
edges,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const truncated = relations
|
|
42
|
+
.filter((g) => g.count > g.edges.length)
|
|
43
|
+
.map((g) => ({ predicate: g.predicate, count: g.count, shown: g.edges.length }));
|
|
44
|
+
return {
|
|
45
|
+
individuals,
|
|
46
|
+
byId,
|
|
47
|
+
relations,
|
|
48
|
+
truncated,
|
|
49
|
+
generatedAt: payload?.generated_at || null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- relation-kind classifier (for impact + tests-coverage) -------------------
|
|
54
|
+
|
|
55
|
+
const KINDS = ["imports", "calls", "defines", "tests", "touches", "contains", "inherits", "callsSymbol", "touchesSymbol"];
|
|
56
|
+
|
|
57
|
+
// Closed prop tokens → relation kind. Primary vocabulary is SEON (se-on.org) +
|
|
58
|
+
// our `mgx:` extension; the legacy `mg:` tokens are kept so a stale artifact still
|
|
59
|
+
// classifies. Lower-cased keys.
|
|
60
|
+
const PROP_KIND = {
|
|
61
|
+
// v2.0 faithful tokens (SEONIX realign)
|
|
62
|
+
"mgx:importsnamespace": "imports",
|
|
63
|
+
"mgx:callscoarse": "calls",
|
|
64
|
+
"seon:declaresmethod": "defines",
|
|
65
|
+
"mgx:testscoverage": "tests",
|
|
66
|
+
"mgx:touchedbycommit": "touches",
|
|
67
|
+
"seon:containscodeentity": "contains",
|
|
68
|
+
"seon:hassupertype": "inherits",
|
|
69
|
+
"mgx:changecoupledwith": "cochange",
|
|
70
|
+
"mgx:reexports": "reexports",
|
|
71
|
+
// fine-grained symbol-level edges (Commit→symbol history, fn→fn in-repo calls).
|
|
72
|
+
// These stay SEPARATE kinds from the module-coarse "touches"/"calls" so the impact
|
|
73
|
+
// closure (module-coarse) is unchanged.
|
|
74
|
+
"mgx:touchessymbol": "touchesSymbol",
|
|
75
|
+
"mgx:callssymbol": "callsSymbol",
|
|
76
|
+
// legacy tokens (pre-realign graphs) — kept so a stale artifact still classifies
|
|
77
|
+
"seon:usescomplextype": "imports",
|
|
78
|
+
"seon:invokesmethod": "calls",
|
|
79
|
+
"seon:history": "touches",
|
|
80
|
+
"mgx:subclassof": "inherits",
|
|
81
|
+
"mg:imports": "imports",
|
|
82
|
+
"mg:calls": "calls",
|
|
83
|
+
"mg:defines": "defines",
|
|
84
|
+
"mg:tests": "tests",
|
|
85
|
+
"mg:touches": "touches",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export function relationKind(group) {
|
|
89
|
+
const prop = String(group?.prop || "").toLowerCase();
|
|
90
|
+
if (PROP_KIND[prop]) return PROP_KIND[prop];
|
|
91
|
+
const pred = String(group?.predicate || "").toLowerCase();
|
|
92
|
+
// symbol-granular fallbacks first, so a near-miss token name still classifies to the
|
|
93
|
+
// fine-grained kind rather than collapsing to module-coarse calls/touches.
|
|
94
|
+
if (/symbol/.test(pred)) {
|
|
95
|
+
if (/\b(call|invoke)/.test(pred)) return "callsSymbol";
|
|
96
|
+
if (/(touch|chang|modif)/.test(pred)) return "touchesSymbol";
|
|
97
|
+
}
|
|
98
|
+
if (/\bimport/.test(pred)) return "imports";
|
|
99
|
+
if (/\b(call|invoke)/.test(pred)) return "calls";
|
|
100
|
+
if (/\b(define|export|declare)/.test(pred)) return "defines";
|
|
101
|
+
if (/\b(test|cover)/.test(pred)) return "tests";
|
|
102
|
+
if (/\b(touch|chang|modif)/.test(pred)) return "touches";
|
|
103
|
+
if (/\bcontain/.test(pred)) return "contains";
|
|
104
|
+
if (/\b(inherit|subclass|extend|specializ)/.test(pred)) return "inherits";
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- symbol resolution (exact → normalised path → substring) ------------------
|
|
109
|
+
|
|
110
|
+
function normPath(s) {
|
|
111
|
+
return String(s || "")
|
|
112
|
+
.trim()
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/^\.\//, "")
|
|
115
|
+
.replace(/^\//, "");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function basename(p) {
|
|
119
|
+
const parts = normPath(p).split("/");
|
|
120
|
+
return parts[parts.length - 1];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Attestation: a ref prefixed `git:` (a commit that touched the entity) counts as
|
|
124
|
+
// one mention, so better-attested (more-churned) entities rank/render ahead of
|
|
125
|
+
// untouched ones even before per-node mention counts exist.
|
|
126
|
+
const isProvRef = (r) => /^(git|turn):/.test(String(r || ""));
|
|
127
|
+
|
|
128
|
+
export function turnRefCount(ind) {
|
|
129
|
+
return (ind?.derived_from || []).filter(isProvRef).length;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function mentionTotal(ind) {
|
|
133
|
+
const fromMentions = (ind?.mentions || []).reduce((n, m) => n + (Number(m?.count) || 0), 0);
|
|
134
|
+
return fromMentions + turnRefCount(ind);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Rank individuals against a symbol. Tiers:
|
|
139
|
+
* 100 exact (label or id, case-insensitive)
|
|
140
|
+
* 80 normalised path (path suffix / basename / extension-stripped basename)
|
|
141
|
+
* 50 substring (label contains symbol), minus a length penalty
|
|
142
|
+
* Ties break on mention total (better-attested first), then label length.
|
|
143
|
+
* Returns { match, candidates } — candidates are the runners-up (≤4).
|
|
144
|
+
*/
|
|
145
|
+
export function resolveSymbol(graph, symbol) {
|
|
146
|
+
const s = normPath(symbol);
|
|
147
|
+
if (!s) return { match: null, candidates: [] };
|
|
148
|
+
const sBase = basename(s);
|
|
149
|
+
const scored = [];
|
|
150
|
+
for (const ind of graph.individuals) {
|
|
151
|
+
const label = normPath(ind.label);
|
|
152
|
+
const id = String(ind.id || "").toLowerCase();
|
|
153
|
+
let score = 0;
|
|
154
|
+
if (label === s || id === s) score = 100;
|
|
155
|
+
else if (
|
|
156
|
+
label.endsWith(`/${s}`) ||
|
|
157
|
+
basename(label) === sBase ||
|
|
158
|
+
basename(label).replace(/\.[a-z]+$/, "") === sBase
|
|
159
|
+
)
|
|
160
|
+
score = 80;
|
|
161
|
+
else if (label.includes(s)) score = Math.max(10, 50 - (label.length - s.length));
|
|
162
|
+
if (score > 0) scored.push({ ind, score });
|
|
163
|
+
}
|
|
164
|
+
scored.sort(
|
|
165
|
+
(a, b) =>
|
|
166
|
+
b.score - a.score ||
|
|
167
|
+
mentionTotal(b.ind) - mentionTotal(a.ind) ||
|
|
168
|
+
String(a.ind.label).length - String(b.ind.label).length,
|
|
169
|
+
);
|
|
170
|
+
return {
|
|
171
|
+
match: scored[0]?.ind || null,
|
|
172
|
+
candidates: scored.slice(1, 5).map((x) => x.ind),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- source site (for seonix_snippet) -------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** Parse a Function/Class individual's `site` attribute ("path:start[-end]") into
|
|
179
|
+
* {path, start, end}, or null if it has none (e.g. a Module). Pure. */
|
|
180
|
+
export function siteOf(ind) {
|
|
181
|
+
const a = (ind?.attributes || []).find((x) => x.key === "site");
|
|
182
|
+
if (!a) return null;
|
|
183
|
+
const m = String(a.value).match(/^(.*):(\d+)(?:-(\d+))?$/);
|
|
184
|
+
if (!m) return null;
|
|
185
|
+
return { path: m[1], start: Number(m[2]), end: m[3] ? Number(m[3]) : Number(m[2]) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- describe ------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
function edgesFor(graph, id) {
|
|
191
|
+
const out = [];
|
|
192
|
+
const incoming = [];
|
|
193
|
+
for (const g of graph.relations) {
|
|
194
|
+
const outgoing = g.edges.filter((e) => e.subject === id);
|
|
195
|
+
const inbound = g.edges.filter((e) => e.object === id);
|
|
196
|
+
if (outgoing.length) out.push({ group: g, edges: outgoing });
|
|
197
|
+
if (inbound.length) incoming.push({ group: g, edges: inbound });
|
|
198
|
+
}
|
|
199
|
+
return { out, incoming };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function relLabel(g) {
|
|
203
|
+
return g.prop ? `${g.predicate} [${g.prop}]` : g.predicate;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Bounded list rendering — token efficiency is the whole point of the graph, so
|
|
207
|
+
// hub entities must never dump hundreds of edges. Show the first `n`, then a
|
|
208
|
+
// "+K more" tail with the true count.
|
|
209
|
+
function capJoin(items, n, sep = ", ") {
|
|
210
|
+
if (items.length <= n) return items.join(sep);
|
|
211
|
+
return items.slice(0, n).join(sep) + `, +${items.length - n} more`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const DESCRIBE_EDGE_CAP = 30;
|
|
215
|
+
const PROV_CAP = 8;
|
|
216
|
+
|
|
217
|
+
/** Compact plain-text description of one individual — for an agent consumer. */
|
|
218
|
+
export function renderDescribe(graph, ind, { candidates = [] } = {}) {
|
|
219
|
+
const lines = [];
|
|
220
|
+
lines.push(`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`);
|
|
221
|
+
|
|
222
|
+
const refs = (ind.derived_from || []).filter(isProvRef);
|
|
223
|
+
if (refs.length) lines.push(`attestation: touched by ${refs.length} commit(s)`);
|
|
224
|
+
|
|
225
|
+
for (const a of ind.attributes || []) {
|
|
226
|
+
lines.push(`attribute: ${a.key} = ${a.value}${a.prop ? ` [${a.prop}]` : ""}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { out, incoming } = edgesFor(graph, ind.id);
|
|
230
|
+
if (!out.length && !incoming.length) {
|
|
231
|
+
lines.push("edges: none in the current artifact");
|
|
232
|
+
} else {
|
|
233
|
+
lines.push("edges:");
|
|
234
|
+
for (const { group, edges } of out) {
|
|
235
|
+
lines.push(` ${relLabel(group)} (${edges.length}) → ${capJoin(edges.map((e) => e.objectLabel || e.object), DESCRIBE_EDGE_CAP)}`);
|
|
236
|
+
}
|
|
237
|
+
for (const { group, edges } of incoming) {
|
|
238
|
+
lines.push(` ← ${relLabel(group)} (${edges.length}) by ${capJoin(edges.map((e) => e.subjectLabel || e.subject), DESCRIBE_EDGE_CAP)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const prov = (ind.derived_from || []);
|
|
243
|
+
if (prov.length) {
|
|
244
|
+
lines.push(`provenance: ${capJoin(prov, PROV_CAP)}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (candidates.length) {
|
|
248
|
+
lines.push(`other matches: ${candidates.map((c) => `${c.label} (${c.class})`).join(", ")}`);
|
|
249
|
+
}
|
|
250
|
+
if (graph.truncated.length) {
|
|
251
|
+
lines.push(truncationNote(graph));
|
|
252
|
+
}
|
|
253
|
+
return lines.join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function truncationNote(graph) {
|
|
257
|
+
const list = graph.truncated.map((t) => `${t.predicate} (${t.shown}/${t.count})`).join(", ");
|
|
258
|
+
return `note: partial edge lists for: ${list}. Counts are complete; the lists are not.`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- impact (transitive reverse closure over imports/calls) ---------------------
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* BFS the REVERSE of imports/calls edges from `ind` — "what would break".
|
|
265
|
+
* Diamonds collapse (a node appears once, at its shortest depth); cycles
|
|
266
|
+
* terminate via the visited set. Each dependent carries the via-predicate and
|
|
267
|
+
* the test modules covering it (subjects of tests-kind edges pointing at it).
|
|
268
|
+
*/
|
|
269
|
+
export function impactClosure(graph, ind, { maxDepth = 8 } = {}) {
|
|
270
|
+
const dependents = new Map();
|
|
271
|
+
const coveredBy = new Map(); // moduleId → [test labels]
|
|
272
|
+
for (const g of graph.relations) {
|
|
273
|
+
const kind = relationKind(g);
|
|
274
|
+
if (kind === "imports" || kind === "calls") {
|
|
275
|
+
for (const e of g.edges) {
|
|
276
|
+
if (!dependents.has(e.object)) dependents.set(e.object, []);
|
|
277
|
+
dependents.get(e.object).push({ id: e.subject, label: e.subjectLabel || e.subject, via: g.predicate });
|
|
278
|
+
}
|
|
279
|
+
} else if (kind === "tests") {
|
|
280
|
+
for (const e of g.edges) {
|
|
281
|
+
if (!coveredBy.has(e.object)) coveredBy.set(e.object, []);
|
|
282
|
+
coveredBy.get(e.object).push(e.subjectLabel || e.subject);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const levels = []; // [[{id, label, via, tests[]}], …] indexed by depth-1
|
|
288
|
+
const visited = new Set([ind.id]);
|
|
289
|
+
let frontier = [ind.id];
|
|
290
|
+
for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
|
|
291
|
+
const next = [];
|
|
292
|
+
const level = [];
|
|
293
|
+
for (const id of frontier) {
|
|
294
|
+
for (const dep of dependents.get(id) || []) {
|
|
295
|
+
if (visited.has(dep.id)) continue;
|
|
296
|
+
visited.add(dep.id);
|
|
297
|
+
level.push({ ...dep, tests: coveredBy.get(dep.id) || [] });
|
|
298
|
+
next.push(dep.id);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (level.length) {
|
|
302
|
+
level.sort((a, b) => String(a.label).localeCompare(String(b.label)));
|
|
303
|
+
levels.push(level);
|
|
304
|
+
}
|
|
305
|
+
frontier = next;
|
|
306
|
+
}
|
|
307
|
+
return levels;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const IMPACT_DEPTHS_LISTED = 2; // list members for the first N depths; deeper = counts only
|
|
311
|
+
const IMPACT_PER_DEPTH = 25; // members listed per depth
|
|
312
|
+
const IMPACT_TESTS_PER_DEP = 3; // covering tests listed per dependent
|
|
313
|
+
|
|
314
|
+
export function renderImpact(graph, ind, { maxDepth = 8 } = {}) {
|
|
315
|
+
const levels = impactClosure(graph, ind, { maxDepth });
|
|
316
|
+
const lines = [`Impact of changing ${ind.label} (reverse closure over imports/calls edges):`];
|
|
317
|
+
if (!levels.length) {
|
|
318
|
+
lines.push("no dependents found in the current artifact — nothing imports or calls it (or its edges are not in the extracted graph yet).");
|
|
319
|
+
}
|
|
320
|
+
const totalCount = levels.reduce((n, l) => n + l.length, 0);
|
|
321
|
+
// Headline first so the magnitude is clear even when the lists are capped.
|
|
322
|
+
if (levels.length) {
|
|
323
|
+
lines.push(`total: ${totalCount} dependent(s) across ${levels.length} depth level(s) (lists capped for brevity).`);
|
|
324
|
+
}
|
|
325
|
+
levels.forEach((level, i) => {
|
|
326
|
+
if (i >= IMPACT_DEPTHS_LISTED) {
|
|
327
|
+
lines.push(`depth ${i + 1}: ${level.length} more dependent(s) (not listed)`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
lines.push(i === 0 ? `depth 1 (${level.length} direct dependents):` : `depth ${i + 1} (${level.length}):`);
|
|
331
|
+
for (const dep of level.slice(0, IMPACT_PER_DEPTH)) {
|
|
332
|
+
const tests = dep.tests.length
|
|
333
|
+
? `tests: ${capJoin(dep.tests, IMPACT_TESTS_PER_DEP)}`
|
|
334
|
+
: "tests: none recorded";
|
|
335
|
+
lines.push(` - ${dep.label} (${dep.via} it) — ${tests}`);
|
|
336
|
+
}
|
|
337
|
+
if (level.length > IMPACT_PER_DEPTH) lines.push(` …+${level.length - IMPACT_PER_DEPTH} more at depth ${i + 1}`);
|
|
338
|
+
});
|
|
339
|
+
const truncatedStructural = graph.truncated.filter((t) => {
|
|
340
|
+
const kind = relationKind({ predicate: t.predicate });
|
|
341
|
+
return kind === "imports" || kind === "calls" || kind === "tests";
|
|
342
|
+
});
|
|
343
|
+
if (truncatedStructural.length) {
|
|
344
|
+
lines.push(
|
|
345
|
+
"warning: partial edge lists (" +
|
|
346
|
+
truncatedStructural.map((t) => `${t.predicate}: ${t.shown}/${t.count}`).join(", ") +
|
|
347
|
+
") — this closure may be missing edges. Cross-check critical results with seonix_search.",
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return lines.join("\n");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---- search (local, deterministic lexical lookup) ------------------------------
|
|
354
|
+
|
|
355
|
+
/** Index subjectId → [defined symbol labels] from the defines relation, once. */
|
|
356
|
+
function definesIndex(graph) {
|
|
357
|
+
const idx = new Map();
|
|
358
|
+
for (const g of graph.relations) {
|
|
359
|
+
if (relationKind(g) !== "defines") continue;
|
|
360
|
+
for (const e of g.edges) {
|
|
361
|
+
if (!idx.has(e.subject)) idx.set(e.subject, []);
|
|
362
|
+
idx.get(e.subject).push(e.objectLabel || e.object);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return idx;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Local, deterministic free-text lookup over the typed graph — the offline
|
|
370
|
+
* replacement for marginalia's LLM-backed A2A seonix_search. Finds the MODULE
|
|
371
|
+
* where code lives ("where do template filters / validators live?"): scores
|
|
372
|
+
* modules by query-token hits in the path (strong) plus the count of DEFINED
|
|
373
|
+
* SYMBOLS whose name matches a token (capped so a giant module can't dominate),
|
|
374
|
+
* with a penalty for test modules. Renders each hit compactly with the matching
|
|
375
|
+
* symbols, so the agent can jump straight to seonix_describe. No model calls.
|
|
376
|
+
*/
|
|
377
|
+
const SEARCH_LIMIT = 10;
|
|
378
|
+
const SEARCH_SYMBOLS_SHOWN = 8;
|
|
379
|
+
// B012 locate scoring — IDF-weighted, component-aware. The rig queries with the WHOLE problem
|
|
380
|
+
// statement, so ubiquitous tokens (template/filter/value/text) would swamp the score; weight each
|
|
381
|
+
// token by rarity across modules (inverse module-frequency) so the distinctive term decides. Match
|
|
382
|
+
// identifier COMPONENTS (boundary-aware) so "text" hits utils/text.py but NOT "ci<text>". An EXACT
|
|
383
|
+
// defined-symbol-name hit is the strongest "the code lives here" signal. Deterministic; no models.
|
|
384
|
+
const PATH_W = 3; // token == a path component (django/utils/<text>.py)
|
|
385
|
+
const SYM_W = 2; // token == a component of a defined symbol name
|
|
386
|
+
const EXACT_W = 5; // token == a whole defined symbol name (strongest locate signal)
|
|
387
|
+
const SYM_MATCH_CAP = 4; // only the top-K highest-IDF symbol-COMPONENT hits count, so a giant
|
|
388
|
+
// bag-of-symbols module (e.g. db/backends features) can't accrete noise
|
|
389
|
+
const PROX_FRAC = 0.2; // import-adjacency bonus = this × the strongest matched neighbour …
|
|
390
|
+
const PROX_CAP_FRAC = 0.35; // … capped at this × the module's own score (a nudge — hubs can't run away)
|
|
391
|
+
const isTestLabel = (s) => /(^|\/)tests?\//.test(s) || /(^|\/)test_[^/]*\.py$/.test(s) || /\.tests(\.|$)/.test(s);
|
|
392
|
+
|
|
393
|
+
/** Split a lowercased path label into boundary components: django/utils/text.py →
|
|
394
|
+
* {django,utils,text,py}. Component equality (not substring) stops "text" matching "ci<text>". */
|
|
395
|
+
function pathComponents(labelLc) {
|
|
396
|
+
return new Set(labelLc.split(/[^a-z0-9]+/).filter(Boolean));
|
|
397
|
+
}
|
|
398
|
+
/** Split an identifier into lowercased components across snake_case AND camelCase boundaries:
|
|
399
|
+
* get_text_list → {get,text,list}; TruncatorLines → {truncator,lines}. */
|
|
400
|
+
function identComponents(name) {
|
|
401
|
+
return new Set(String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The shared module-ranking core behind renderSearch (text) and searchModulesRanked (path+score).
|
|
405
|
+
* IDF-weights each query token by rarity across modules (so a whole-problem-statement query is not
|
|
406
|
+
* swamped by ubiquitous words like template/filter/value), scores path-component + symbol-component
|
|
407
|
+
* + EXACT-symbol matches, re-ranks with a bounded import-proximity bonus, and breaks ties by
|
|
408
|
+
* matched-symbol DENSITY (a concrete signal — never ground truth). Pure; deterministic. */
|
|
409
|
+
function scoreModules(graph, tokens) {
|
|
410
|
+
const defIdx = definesIndex(graph);
|
|
411
|
+
// Precompute each module's path components + defined-symbol exact/component sets, once.
|
|
412
|
+
const modules = [];
|
|
413
|
+
for (const ind of graph.individuals) {
|
|
414
|
+
if ((ind.class || "") !== "Module") continue; // "where does this live" → modules
|
|
415
|
+
const label = String(ind.label);
|
|
416
|
+
const labelLc = label.toLowerCase();
|
|
417
|
+
const defines = defIdx.get(ind.id) || [];
|
|
418
|
+
const symSet = new Set(defines.map((d) => d.toLowerCase())); // exact symbol names
|
|
419
|
+
const symComps = new Set();
|
|
420
|
+
for (const d of defines) for (const c of identComponents(d)) symComps.add(c);
|
|
421
|
+
modules.push({ ind, label, labelLc, defines, symSet, symComps });
|
|
422
|
+
}
|
|
423
|
+
const N = modules.length || 1;
|
|
424
|
+
// Inverse module-frequency: a token in many modules carries little locating signal; a rare one
|
|
425
|
+
// decides. df = modules where the token appears in the path (substring — keeps "filter" matching
|
|
426
|
+
// "defaultfilters"), as a symbol component, or as an exact symbol name. A loose path substring like
|
|
427
|
+
// "text" that hits many modules therefore earns a low weight, so "ci<text>" can't beat utils/text.py.
|
|
428
|
+
// idf = log(1 + N/(1+df)) → ~0 for ubiquitous tokens, large for rare ones.
|
|
429
|
+
const idf = new Map();
|
|
430
|
+
for (const t of tokens) {
|
|
431
|
+
if (idf.has(t)) continue;
|
|
432
|
+
let df = 0;
|
|
433
|
+
for (const m of modules) if (m.labelLc.includes(t) || m.symComps.has(t) || m.symSet.has(t)) df++;
|
|
434
|
+
idf.set(t, Math.log(1 + N / (1 + df)));
|
|
435
|
+
}
|
|
436
|
+
const scored = [];
|
|
437
|
+
for (const m of modules) {
|
|
438
|
+
let exactScore = 0, pathScore = 0, matchCount = 0;
|
|
439
|
+
const compWeights = []; // weak symbol-component hits, capped below so big modules can't run away
|
|
440
|
+
for (const t of tokens) {
|
|
441
|
+
const w = idf.get(t) || 0;
|
|
442
|
+
if (!w) continue;
|
|
443
|
+
if (m.symSet.has(t)) { exactScore += w * EXACT_W; matchCount++; } // exact defined-symbol name
|
|
444
|
+
else if (m.symComps.has(t)) { compWeights.push(w); matchCount++; } // a component of a symbol name
|
|
445
|
+
if (m.labelLc.includes(t)) pathScore += w * PATH_W; // path substring (IDF-tamed)
|
|
446
|
+
}
|
|
447
|
+
compWeights.sort((a, b) => b - a);
|
|
448
|
+
let symScore = 0;
|
|
449
|
+
for (let i = 0; i < Math.min(compWeights.length, SYM_MATCH_CAP); i++) symScore += compWeights[i] * SYM_W;
|
|
450
|
+
let score = exactScore + pathScore + symScore;
|
|
451
|
+
if (!score) continue;
|
|
452
|
+
if (isTestLabel(m.label.toLowerCase())) score *= 0.4; // source first; tests still discoverable
|
|
453
|
+
const matching = m.defines.filter((d) => { const dl = d.toLowerCase(); const cs = identComponents(d); return tokens.some((t) => dl === t || cs.has(t)); });
|
|
454
|
+
const density = m.defines.length ? matchCount / m.defines.length : 0;
|
|
455
|
+
scored.push({ ind: m.ind, score, defineCount: m.defines.length, matching, density });
|
|
456
|
+
}
|
|
457
|
+
// Import-graph proximity (rescaled): a matched module that imports / is imported by a
|
|
458
|
+
// STRONGER-matching module gets a bonus proportional to that neighbour, so a genuine 2nd module
|
|
459
|
+
// (truncatelines' text.py) rises with its sibling. Only re-ranks modules that ALREADY matched.
|
|
460
|
+
if (scored.length > 1) {
|
|
461
|
+
const baseById = new Map(scored.map((s) => [s.ind.id, s.score]));
|
|
462
|
+
const adj = new Map();
|
|
463
|
+
for (const e of edgesOfKind(graph, "imports")) {
|
|
464
|
+
if (!baseById.has(e.subject) && !baseById.has(e.object)) continue;
|
|
465
|
+
if (!adj.has(e.subject)) adj.set(e.subject, new Set());
|
|
466
|
+
if (!adj.has(e.object)) adj.set(e.object, new Set());
|
|
467
|
+
adj.get(e.subject).add(e.object);
|
|
468
|
+
adj.get(e.object).add(e.subject);
|
|
469
|
+
}
|
|
470
|
+
for (const s of scored) {
|
|
471
|
+
let bestNeighbor = 0;
|
|
472
|
+
for (const nid of adj.get(s.ind.id) || []) bestNeighbor = Math.max(bestNeighbor, baseById.get(nid) || 0);
|
|
473
|
+
s.score += Math.min(bestNeighbor * PROX_FRAC, s.score * PROX_CAP_FRAC);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Tie-break: score → matched-symbol DENSITY (concrete, not ground truth) → fewer defines → shorter label.
|
|
477
|
+
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);
|
|
478
|
+
return scored;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** TUNING #3: the ranked module list as plain `{path, score}` (highest-first), using the SAME
|
|
482
|
+
* ranking renderSearch uses (path + symbol + exact-symbol + import-proximity). Lets the rig
|
|
483
|
+
* read the score GAP between rank-1 and rank-2 (which the text renderer hides) so it can keep
|
|
484
|
+
* rank-2 only when it is close. Pure; deterministic. */
|
|
485
|
+
export function searchModulesRanked(graph, query) {
|
|
486
|
+
const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
487
|
+
if (!tokens.length) return [];
|
|
488
|
+
return scoreModules(graph, tokens).map((s) => ({ path: String(s.ind.label), score: s.score }));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "" } = {}) {
|
|
492
|
+
const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
493
|
+
const wantKind = String(kind || "").trim().toLowerCase();
|
|
494
|
+
const decFilter = String(decorator || "").trim().toLowerCase();
|
|
495
|
+
let nameRe = null;
|
|
496
|
+
if (name) {
|
|
497
|
+
try { nameRe = new RegExp(name, "i"); } catch { return `invalid name pattern: ${name}`; }
|
|
498
|
+
}
|
|
499
|
+
// kind= switches to symbol search (functions/classes/methods/attributes); the
|
|
500
|
+
// default (no kind) keeps the module "where does this live" search unchanged.
|
|
501
|
+
if (wantKind && wantKind !== "module") {
|
|
502
|
+
return searchSymbols(graph, tokens, { limit, kind: wantKind, decFilter, nameRe });
|
|
503
|
+
}
|
|
504
|
+
if (!tokens.length && !nameRe && !decFilter) return "empty query";
|
|
505
|
+
const scored = scoreModules(graph, tokens);
|
|
506
|
+
if (!scored.length) {
|
|
507
|
+
return `no module matches "${query}". Try broader keywords, or seonix_describe <path> if you know where it lives.`;
|
|
508
|
+
}
|
|
509
|
+
const hits = scored.slice(0, limit);
|
|
510
|
+
const lines = [`${scored.length} module(s) match "${query}" (top ${hits.length}):`];
|
|
511
|
+
for (const { ind, defineCount, matching } of hits) {
|
|
512
|
+
const m = matching.length ? ` — matching: ${capJoin([...new Set(matching)], SEARCH_SYMBOLS_SHOWN)}` : "";
|
|
513
|
+
lines.push(`- ${ind.label} (defines ${defineCount} symbol(s))${m}`);
|
|
514
|
+
}
|
|
515
|
+
lines.push("Then seonix_describe <path> for the full sibling list + typed edges, or seonix_impact <path> for dependents.");
|
|
516
|
+
return lines.join("\n");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---- §9 read-replacing tools (members / inheritance / architecture / coverage /
|
|
520
|
+
// history / call neighbours). Each answers ONE question in one compact call so
|
|
521
|
+
// the agent need not Read/Grep. All keep the bounded-output discipline. -------
|
|
522
|
+
|
|
523
|
+
/** All edges whose relation classifies to `kind`, flattened across relation groups. */
|
|
524
|
+
function edgesOfKind(graph, kind) {
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const g of graph.relations) if (relationKind(g) === kind) out.push(...g.edges);
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The module id an individual belongs to (itself if a Module; via its site span,
|
|
531
|
+
* else parsed from an `fn:<path>#name` id). Null if it cannot be mapped. */
|
|
532
|
+
function moduleIdOf(graph, ind) {
|
|
533
|
+
if ((ind?.class || "") === "Module") return ind.id;
|
|
534
|
+
const site = siteOf(ind);
|
|
535
|
+
if (site) return `mod:${site.path}`;
|
|
536
|
+
const m = String(ind?.id || "").match(/^fn:(.+)#/);
|
|
537
|
+
return m ? `mod:${m[1]}` : null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function spanTag(site) {
|
|
541
|
+
if (!site) return "";
|
|
542
|
+
const s = site.end > site.start ? `${site.start}-${site.end}` : `${site.start}`;
|
|
543
|
+
return ` [${site.path}:${s}]`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function decoratorOf(ind) {
|
|
547
|
+
return (ind?.attributes || []).find((a) => a.key === "decorators")?.value || "";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const MEMBERS_CAP = 40;
|
|
551
|
+
const SUBCLASS_CAP = 40;
|
|
552
|
+
const CALL_CAP = 30;
|
|
553
|
+
|
|
554
|
+
/** A class's methods + attributes (with sites/decorators) in one slice — replaces
|
|
555
|
+
* reading the class body. Uses the `contains` (seon:containsCodeEntity) relation. */
|
|
556
|
+
export function renderMembers(graph, ind) {
|
|
557
|
+
const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
|
|
558
|
+
const contains = edgesOfKind(graph, "contains").filter((e) => e.subject === ind.id);
|
|
559
|
+
if (!contains.length) {
|
|
560
|
+
lines.push("members: none recorded (empty class, or members not in the extracted graph). Use seonix_describe for its edges.");
|
|
561
|
+
return lines.join("\n");
|
|
562
|
+
}
|
|
563
|
+
const methods = [];
|
|
564
|
+
const attrs = [];
|
|
565
|
+
for (const e of contains) {
|
|
566
|
+
const member = graph.byId.get(e.object);
|
|
567
|
+
const where = spanTag(member ? siteOf(member) : null);
|
|
568
|
+
const dec = member ? decoratorOf(member) : "";
|
|
569
|
+
const entry = `${e.objectLabel || e.object}${where}${dec ? ` @${dec}` : ""}`;
|
|
570
|
+
((member?.class || "") === "Attribute" ? attrs : methods).push(entry);
|
|
571
|
+
}
|
|
572
|
+
if (methods.length) lines.push(`methods (${methods.length}): ${capJoin(methods, MEMBERS_CAP)}`);
|
|
573
|
+
if (attrs.length) lines.push(`attributes (${attrs.length}): ${capJoin(attrs, MEMBERS_CAP)}`);
|
|
574
|
+
lines.push("Use seonix_snippet <Class.member> for an exact body.");
|
|
575
|
+
return lines.join("\n");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const attrVal = (ind, key) => (ind?.attributes || []).find((a) => a.key === key)?.value || "";
|
|
579
|
+
|
|
580
|
+
/** The mechanical-enrichment signature of a symbol in ONE compact block — params,
|
|
581
|
+
* return annotation, raises/catches, self-fields, flags, decorators, one-line doc —
|
|
582
|
+
* so the agent gets the API surface without reading the body. Deterministic ast facts
|
|
583
|
+
* (kept OUT of seonix_context's lean bundle; this is the targeted tool for them). */
|
|
584
|
+
export function renderSignature(graph, ind) {
|
|
585
|
+
const site = siteOf(ind);
|
|
586
|
+
const lines = [`${ind.label} — ${ind.class || "Entity"}${spanTag(site)}`];
|
|
587
|
+
const params = attrVal(ind, "params");
|
|
588
|
+
const returns = attrVal(ind, "returns");
|
|
589
|
+
if (params || returns || (ind.class || "") === "Method" || (ind.class || "") === "Function") {
|
|
590
|
+
lines.push(`signature: ${ind.label}(${params})${returns ? ` -> ${returns}` : ""}`);
|
|
591
|
+
}
|
|
592
|
+
const flags = [];
|
|
593
|
+
if (attrVal(ind, "isStatic")) flags.push("static");
|
|
594
|
+
if (attrVal(ind, "isAbstract")) flags.push("abstract");
|
|
595
|
+
if (attrVal(ind, "isConstant")) flags.push("constant");
|
|
596
|
+
const vis = attrVal(ind, "visibility");
|
|
597
|
+
if (vis) flags.push(vis);
|
|
598
|
+
if (flags.length) lines.push(`flags: ${flags.join(", ")}`);
|
|
599
|
+
const dec = decoratorOf(ind);
|
|
600
|
+
if (dec) lines.push(`decorators: @${dec.split(", ").join(", @")}`);
|
|
601
|
+
const raises = attrVal(ind, "raises");
|
|
602
|
+
if (raises) lines.push(`raises: ${raises}`);
|
|
603
|
+
const catches = attrVal(ind, "catches");
|
|
604
|
+
if (catches) lines.push(`catches: ${catches}`);
|
|
605
|
+
const fields = attrVal(ind, "self_fields");
|
|
606
|
+
if (fields) lines.push(`self fields: ${fields}`);
|
|
607
|
+
const value = attrVal(ind, "value");
|
|
608
|
+
if (value) lines.push(`value: ${value}`);
|
|
609
|
+
const doc = attrVal(ind, "doc");
|
|
610
|
+
if (doc) lines.push(`doc: ${doc}`);
|
|
611
|
+
if (lines.length === 1) lines.push("(no signature detail recorded for this symbol — likely a module or attribute; use seonix_snippet for its source.)");
|
|
612
|
+
lines.push("Use seonix_snippet for the exact body.");
|
|
613
|
+
return lines.join("\n");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Forward bases + the transitive reverse inheritance closure (who extends this) —
|
|
617
|
+
* replaces grepping `class X(Base)` across the tree. Uses `inherits` (mgx:subclassOf). */
|
|
618
|
+
export function renderSubclasses(graph, ind) {
|
|
619
|
+
const inherits = edgesOfKind(graph, "inherits");
|
|
620
|
+
const bases = inherits.filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object);
|
|
621
|
+
const childrenOf = new Map();
|
|
622
|
+
for (const e of inherits) {
|
|
623
|
+
if (!childrenOf.has(e.object)) childrenOf.set(e.object, []);
|
|
624
|
+
childrenOf.get(e.object).push({ id: e.subject, label: e.subjectLabel || e.subject });
|
|
625
|
+
}
|
|
626
|
+
const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
|
|
627
|
+
lines.push(bases.length ? `extends: ${capJoin(bases, SUBCLASS_CAP)}` : "extends: (no internal/recorded base classes)");
|
|
628
|
+
const visited = new Set([ind.id]);
|
|
629
|
+
const levels = [];
|
|
630
|
+
let frontier = [ind.id];
|
|
631
|
+
for (let depth = 1; depth <= 8 && frontier.length; depth += 1) {
|
|
632
|
+
const next = [];
|
|
633
|
+
const level = [];
|
|
634
|
+
for (const id of frontier) {
|
|
635
|
+
for (const c of childrenOf.get(id) || []) {
|
|
636
|
+
if (visited.has(c.id)) continue;
|
|
637
|
+
visited.add(c.id);
|
|
638
|
+
level.push(c.label);
|
|
639
|
+
next.push(c.id);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (level.length) {
|
|
643
|
+
level.sort((a, b) => String(a).localeCompare(String(b)));
|
|
644
|
+
levels.push(level);
|
|
645
|
+
}
|
|
646
|
+
frontier = next;
|
|
647
|
+
}
|
|
648
|
+
const total = levels.reduce((n, l) => n + l.length, 0);
|
|
649
|
+
if (!total) {
|
|
650
|
+
lines.push("subclasses: none recorded — nothing extends it in the extracted graph.");
|
|
651
|
+
} else {
|
|
652
|
+
lines.push(`subclasses: ${total} total across ${levels.length} level(s).`);
|
|
653
|
+
levels.forEach((l, i) => lines.push(` depth ${i + 1} (${l.length}): ${capJoin(l, SUBCLASS_CAP)}`));
|
|
654
|
+
}
|
|
655
|
+
return lines.join("\n");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const ARCH_PKG_CAP = 25;
|
|
659
|
+
const ARCH_HUB_CAP = 15;
|
|
660
|
+
|
|
661
|
+
/** Package/module tree + the most-imported (hub) modules — replaces reading the dir
|
|
662
|
+
* tree and many files to learn the shape. Optional `pkg` prefix scopes it. */
|
|
663
|
+
export function renderArchitecture(graph, { pkg = "" } = {}) {
|
|
664
|
+
const norm = normPath(pkg);
|
|
665
|
+
const modules = graph.individuals.filter(
|
|
666
|
+
(i) => (i.class || "") === "Module" && (!norm || normPath(i.label).startsWith(norm)),
|
|
667
|
+
);
|
|
668
|
+
if (!modules.length) return norm ? `no modules under "${pkg}".` : "no modules in the graph.";
|
|
669
|
+
const pkgCount = new Map();
|
|
670
|
+
for (const m of modules) {
|
|
671
|
+
const dir = m.label.includes("/") ? m.label.slice(0, m.label.lastIndexOf("/")) : "(root)";
|
|
672
|
+
pkgCount.set(dir, (pkgCount.get(dir) || 0) + 1);
|
|
673
|
+
}
|
|
674
|
+
const inDeg = new Map();
|
|
675
|
+
for (const e of edgesOfKind(graph, "imports")) inDeg.set(e.object, (inDeg.get(e.object) || 0) + 1);
|
|
676
|
+
const modSet = new Set(modules.map((m) => m.id));
|
|
677
|
+
const hubs = [...inDeg.entries()]
|
|
678
|
+
.filter(([id]) => modSet.has(id))
|
|
679
|
+
.sort((a, b) => b[1] - a[1])
|
|
680
|
+
.slice(0, ARCH_HUB_CAP)
|
|
681
|
+
.map(([id, n]) => `${graph.byId.get(id)?.label || id} (${n} importers)`);
|
|
682
|
+
const pkgs = [...pkgCount.entries()].sort((a, b) => b[1] - a[1]);
|
|
683
|
+
const lines = [`Architecture${norm ? ` of ${pkg}` : ""}: ${modules.length} module(s) in ${pkgs.length} package(s).`];
|
|
684
|
+
lines.push(`packages (by module count): ${capJoin(pkgs.map(([d, n]) => `${d} (${n})`), ARCH_PKG_CAP)}`);
|
|
685
|
+
lines.push(hubs.length ? `hub modules (most imported): ${hubs.join(", ")}` : "hub modules: none (no internal imports recorded).");
|
|
686
|
+
return lines.join("\n");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const COVERAGE_CAP = 40;
|
|
690
|
+
|
|
691
|
+
/** The test modules covering a symbol/module — from the `tests` (mgx:testsCoverage)
|
|
692
|
+
* relation. Replaces grepping `tests/` for who imports the target. */
|
|
693
|
+
export function renderTestsFor(graph, ind) {
|
|
694
|
+
const modId = moduleIdOf(graph, ind);
|
|
695
|
+
if (!modId) return `cannot map ${ind.label} to a module.`;
|
|
696
|
+
const modLabel = graph.byId.get(modId)?.label || modId;
|
|
697
|
+
const tests = [...new Set(edgesOfKind(graph, "tests").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))];
|
|
698
|
+
if (!tests.length) return `${modLabel}: no covering tests recorded (no test module imports it).`;
|
|
699
|
+
return `${modLabel}: covered by ${tests.length} test module(s):\n ${capJoin(tests, COVERAGE_CAP, "\n ")}`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Source modules with no covering test module — a coverage gap view. Test
|
|
703
|
+
* modules (subjects of test edges, or test-named paths) are excluded. */
|
|
704
|
+
export function renderUntested(graph) {
|
|
705
|
+
const covered = new Set();
|
|
706
|
+
const testModules = new Set();
|
|
707
|
+
for (const e of edgesOfKind(graph, "tests")) {
|
|
708
|
+
covered.add(e.object);
|
|
709
|
+
testModules.add(e.subject);
|
|
710
|
+
}
|
|
711
|
+
const untested = graph.individuals
|
|
712
|
+
.filter(
|
|
713
|
+
(i) =>
|
|
714
|
+
(i.class || "") === "Module" &&
|
|
715
|
+
!testModules.has(i.id) &&
|
|
716
|
+
!isTestLabel(String(i.label).toLowerCase()) &&
|
|
717
|
+
!covered.has(i.id),
|
|
718
|
+
)
|
|
719
|
+
.map((i) => i.label)
|
|
720
|
+
.sort();
|
|
721
|
+
if (!untested.length) return "every source module has at least one covering test module.";
|
|
722
|
+
return `${untested.length} source module(s) with no covering test module:\n ${capJoin(untested, COVERAGE_CAP, "\n ")}`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const HISTORY_CAP = 15;
|
|
726
|
+
|
|
727
|
+
/** Recent commits that touched a symbol's module — from `touches` (seon:history).
|
|
728
|
+
* Replaces `git log -- <file>`. Commits are listed newest-first (git-log order). */
|
|
729
|
+
export function renderHistory(graph, ind) {
|
|
730
|
+
const modId = moduleIdOf(graph, ind);
|
|
731
|
+
if (!modId) return `cannot map ${ind.label} to a module.`;
|
|
732
|
+
const modLabel = graph.byId.get(modId)?.label || modId;
|
|
733
|
+
const commits = edgesOfKind(graph, "touches").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject);
|
|
734
|
+
if (!commits.length) return `${modLabel}: no commit history recorded (outside the git-log window or unmodified).`;
|
|
735
|
+
return `${modLabel}: touched by ${commits.length} recent commit(s): ${capJoin(commits, HISTORY_CAP)}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Modules that call into the target's module (one hop over `calls`). */
|
|
739
|
+
export function renderCallers(graph, ind) {
|
|
740
|
+
const modId = moduleIdOf(graph, ind);
|
|
741
|
+
if (!modId) return `cannot map ${ind.label} to a module.`;
|
|
742
|
+
const modLabel = graph.byId.get(modId)?.label || modId;
|
|
743
|
+
const callers = [...new Set(edgesOfKind(graph, "calls").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))];
|
|
744
|
+
if (!callers.length) return `${modLabel}: no recorded callers (calls are coarse/import-backed — absence is not proof). Try seonix_impact for the full reverse closure.`;
|
|
745
|
+
return `${modLabel} — called by ${callers.length} module(s):\n ${capJoin(callers, CALL_CAP, "\n ")}`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Modules the target's module calls into (one hop over `calls`). */
|
|
749
|
+
export function renderCallees(graph, ind) {
|
|
750
|
+
const modId = moduleIdOf(graph, ind);
|
|
751
|
+
if (!modId) return `cannot map ${ind.label} to a module.`;
|
|
752
|
+
const modLabel = graph.byId.get(modId)?.label || modId;
|
|
753
|
+
const callees = [...new Set(edgesOfKind(graph, "calls").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object))];
|
|
754
|
+
if (!callees.length) return `${modLabel}: no recorded callees.`;
|
|
755
|
+
return `${modLabel} — calls into ${callees.length} module(s):\n ${capJoin(callees, CALL_CAP, "\n ")}`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ---- fine-grained in-repo calls (fn→fn, `callsSymbol`) ---------------------------
|
|
759
|
+
|
|
760
|
+
const CALL_HINT_CAP = 8;
|
|
761
|
+
|
|
762
|
+
/** Format one fn→fn callee edge as `name [path:line]` (path:line from the callee's site). */
|
|
763
|
+
function calleeRef(graph, e) {
|
|
764
|
+
const callee = graph.byId.get(e.object);
|
|
765
|
+
const cs = callee ? siteOf(callee) : null;
|
|
766
|
+
return `${e.objectLabel || callee?.label || e.object}${cs ? ` [${cs.path}:${cs.start}]` : ""}`;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** One-line "calls in-repo: name [path:line], …" hint for a function — appended to
|
|
770
|
+
* seonix_snippet and the seonix_context exemplar body so the agent sees the symbol's
|
|
771
|
+
* in-repo call dependencies inline. Empty string when it calls nothing in-repo. Pure. */
|
|
772
|
+
export function callHint(graph, ind) {
|
|
773
|
+
if (!ind?.id) return "";
|
|
774
|
+
const calls = edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id);
|
|
775
|
+
if (!calls.length) return "";
|
|
776
|
+
return `calls in-repo: ${capJoin(calls.map((e) => calleeRef(graph, e)), CALL_HINT_CAP)}`;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** The in-repo symbols a function calls (fn→fn `callsSymbol` edges), with file:line.
|
|
780
|
+
* Cold tool — replaces reading a body to learn its in-repo call graph. */
|
|
781
|
+
export function renderCalls(graph, ind) {
|
|
782
|
+
const calls = edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id);
|
|
783
|
+
if (!calls.length) {
|
|
784
|
+
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).`;
|
|
785
|
+
}
|
|
786
|
+
const items = calls.map((e) => calleeRef(graph, e));
|
|
787
|
+
return `${ind.label} — ${ind.class || "Entity"} calls ${calls.length} in-repo symbol(s):\n ${capJoin(items, CALL_CAP, "\n ")}`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---- commit history with author/date/subject (Commit attributes) ----------------
|
|
791
|
+
|
|
792
|
+
/** One commit rendered as "<sha> <date> <author> — <subject>", from the Commit
|
|
793
|
+
* individual's commitAuthor/commitDate/commitMessage attributes (graceful when absent). */
|
|
794
|
+
function commitLine(graph, commitId, fallbackLabel) {
|
|
795
|
+
const c = graph.byId.get(commitId);
|
|
796
|
+
const sha = c?.label || fallbackLabel || commitId;
|
|
797
|
+
// Tolerate either attribute-key convention (commitAuthor/… or the shorter author/…).
|
|
798
|
+
const date = attrVal(c, "commitDate") || attrVal(c, "date");
|
|
799
|
+
const author = attrVal(c, "commitAuthor") || attrVal(c, "author");
|
|
800
|
+
const msg = attrVal(c, "commitMessage") || attrVal(c, "message");
|
|
801
|
+
const head = [sha, date, author].filter(Boolean).join(" ");
|
|
802
|
+
return msg ? `${head} — ${msg}` : head;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** File history: commits that touched a symbol's MODULE (module-coarse `touches`
|
|
806
|
+
* edges), each with author/date/subject. Newest-first (git-log order preserved). */
|
|
807
|
+
export function renderFileHistory(graph, ind) {
|
|
808
|
+
const modId = moduleIdOf(graph, ind);
|
|
809
|
+
if (!modId) return `cannot map ${ind.label} to a module.`;
|
|
810
|
+
const modLabel = graph.byId.get(modId)?.label || modId;
|
|
811
|
+
const commits = edgesOfKind(graph, "touches").filter((e) => e.object === modId);
|
|
812
|
+
if (!commits.length) return `${modLabel}: no commit history recorded (outside the git-log window or unmodified).`;
|
|
813
|
+
const shown = commits.slice(0, HISTORY_CAP).map((e) => ` ${commitLine(graph, e.subject, e.subjectLabel)}`);
|
|
814
|
+
const tail = commits.length > HISTORY_CAP ? `\n …+${commits.length - HISTORY_CAP} more` : "";
|
|
815
|
+
return `${modLabel}: touched by ${commits.length} recent commit(s):\n${shown.join("\n")}${tail}`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Symbol-granular history: commits whose `touchesSymbol` edge points at THIS symbol's
|
|
819
|
+
* id (method/class/function), each with author/date/subject. Used by method/class history. */
|
|
820
|
+
function renderSymbolHistory(graph, ind) {
|
|
821
|
+
const commits = edgesOfKind(graph, "touchesSymbol").filter((e) => e.object === ind.id);
|
|
822
|
+
if (!commits.length) {
|
|
823
|
+
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).`;
|
|
824
|
+
}
|
|
825
|
+
const shown = commits.slice(0, HISTORY_CAP).map((e) => ` ${commitLine(graph, e.subject, e.subjectLabel)}`);
|
|
826
|
+
const tail = commits.length > HISTORY_CAP ? `\n …+${commits.length - HISTORY_CAP} more` : "";
|
|
827
|
+
return `${ind.label} — ${ind.class || "Entity"}: touched by ${commits.length} commit(s):\n${shown.join("\n")}${tail}`;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Method history — commits touching a specific method symbol (`touchesSymbol`). */
|
|
831
|
+
export function renderMethodHistory(graph, ind) {
|
|
832
|
+
return renderSymbolHistory(graph, ind);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Class history — commits touching a specific class symbol (`touchesSymbol`). */
|
|
836
|
+
export function renderClassHistory(graph, ind) {
|
|
837
|
+
return renderSymbolHistory(graph, ind);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ---- symbol search (kind=function/class/method/attribute, with name/decorator filters)
|
|
841
|
+
|
|
842
|
+
const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute" };
|
|
843
|
+
|
|
844
|
+
function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe }) {
|
|
845
|
+
const targetClass = SYMBOL_CLASSES[kind];
|
|
846
|
+
if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, or module).`;
|
|
847
|
+
const hits = [];
|
|
848
|
+
for (const ind of graph.individuals) {
|
|
849
|
+
if ((ind.class || "") !== targetClass) continue;
|
|
850
|
+
if (nameRe && !nameRe.test(ind.label)) continue;
|
|
851
|
+
if (decFilter && !decoratorOf(ind).toLowerCase().includes(decFilter)) continue;
|
|
852
|
+
const label = String(ind.label).toLowerCase();
|
|
853
|
+
let score = tokens.length ? 0 : 1;
|
|
854
|
+
for (const t of tokens) if (label.includes(t)) score += 5;
|
|
855
|
+
if (tokens.length && !score) continue;
|
|
856
|
+
hits.push({ ind, score });
|
|
857
|
+
}
|
|
858
|
+
if (!hits.length) return `no ${kind} matches the given filters.`;
|
|
859
|
+
hits.sort((a, b) => b.score - a.score || String(a.ind.label).length - String(b.ind.label).length);
|
|
860
|
+
const top = hits.slice(0, limit);
|
|
861
|
+
const lines = [`${hits.length} ${kind}(s) match (top ${top.length}):`];
|
|
862
|
+
for (const { ind } of top) lines.push(`- ${ind.label}${spanTag(siteOf(ind))}`);
|
|
863
|
+
lines.push("Then seonix_snippet <name> for the exact body, or seonix_describe for its edges.");
|
|
864
|
+
return lines.join("\n");
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ---- seonix_context: a one-shot "edit bundle" plan (pure; the server adds the file
|
|
868
|
+
// reads). Returns everything needed to add-a-sibling to a module in ONE call,
|
|
869
|
+
// so the agent need not search→describe→snippet→read×N (RepoGraph ego-network
|
|
870
|
+
// idea; LocAgent: structured, replacement-shaped output drives tool adoption).
|
|
871
|
+
|
|
872
|
+
const CONTEXT_SIBLING_CAP = 8; // Lever 1: the bundle is re-billed every turn — keep a few most-relevant siblings, not all.
|
|
873
|
+
const CLASS_MEMBER_CAP = 16; // B007 gap (a): class-internal members shown when the anchor is a class/method.
|
|
874
|
+
const COCHANGE_MID_CAP = 4; // #13: trim the MID bundle's co-change tail (was 8) — re-billed every turn.
|
|
875
|
+
const CONTEXT_TESTS_CAP = 6; // #13: cap the covering-tests list in the bundle.
|
|
876
|
+
const INSERTION_REGION_CAP = 40; // #2: contiguous tail lines shown as the "write your new sibling here" region.
|
|
877
|
+
// #6 task-size thresholds (named, next to the caps above). B1/B6: widened so the COMMON
|
|
878
|
+
// "add a small sibling util / register a filter" task lands at the lean TINY default (a 1-2
|
|
879
|
+
// param helper with a short body), and only genuinely bigger edits top up to MID/LARGE.
|
|
880
|
+
const TINY_MAX_LOC = 12; // TINY: exemplar/anchor body ≤ this many lines …
|
|
881
|
+
const TINY_MAX_ARITY = 2; // … AND ≤ this many params (value, arg) …
|
|
882
|
+
const LARGE_CLASS_MEMBERS = 8; // LARGE: anchor is a method of a class with ≥ this many members ("big class").
|
|
883
|
+
const INLINE_CALLEE_CAP = 3; // LARGE: inline at most this many depth-1 in-repo callee bodies …
|
|
884
|
+
const INLINE_CALLEE_LOC = 120; // … up to this many total lines.
|
|
885
|
+
|
|
886
|
+
const splitDecs = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
887
|
+
const tokenize = (s) =>
|
|
888
|
+
String(s || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
889
|
+
const countParams = (p) => { const s = String(p || "").trim(); return s ? s.split(",").map((x) => x.trim()).filter(Boolean).length : 0; };
|
|
890
|
+
const modeOf = (nums) => {
|
|
891
|
+
const freq = new Map();
|
|
892
|
+
let best = nums[0] ?? 0;
|
|
893
|
+
let bestN = 0;
|
|
894
|
+
for (const n of nums) { const c = (freq.get(n) || 0) + 1; freq.set(n, c); if (c > bestN) { bestN = c; best = n; } }
|
|
895
|
+
return best;
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
/** A symbol's structural profile (param count, has-returns/raises, in-repo callee set)
|
|
899
|
+
* — the shape matched by the structural-similarity component of sibling ranking. */
|
|
900
|
+
function profileOf(x) {
|
|
901
|
+
return {
|
|
902
|
+
paramCount: countParams(x?.params),
|
|
903
|
+
hasReturns: Boolean(x?.returns),
|
|
904
|
+
hasRaises: Boolean(x?.raises),
|
|
905
|
+
callees: x?.callees instanceof Set ? x.callees : new Set(),
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** When the anchor is a Module (no single anchor symbol), derive the dominant structural
|
|
910
|
+
* pattern across the siblings so the exemplar can be chosen for structural closeness too. */
|
|
911
|
+
function dominantProfile(siblings) {
|
|
912
|
+
if (!siblings.length) return { paramCount: 0, hasReturns: false, hasRaises: false, callees: new Set() };
|
|
913
|
+
const counts = siblings.map((s) => countParams(s.params));
|
|
914
|
+
const retYes = siblings.filter((s) => Boolean(s.returns)).length;
|
|
915
|
+
const raiseYes = siblings.filter((s) => Boolean(s.raises)).length;
|
|
916
|
+
const calleeFreq = new Map();
|
|
917
|
+
for (const s of siblings) for (const c of s.callees || []) calleeFreq.set(c, (calleeFreq.get(c) || 0) + 1);
|
|
918
|
+
const common = new Set([...calleeFreq.entries()].filter(([, n]) => n >= 2).map(([c]) => c));
|
|
919
|
+
return {
|
|
920
|
+
paramCount: modeOf(counts),
|
|
921
|
+
hasReturns: retYes * 2 >= siblings.length,
|
|
922
|
+
hasRaises: raiseYes * 2 >= siblings.length,
|
|
923
|
+
callees: common,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Structural affinity of a sibling to the target profile — bounded below name-affinity
|
|
928
|
+
* (max 16 < the 50/token name weight), so it only breaks ties within a name/decorator tier. */
|
|
929
|
+
function structuralScore(s, target) {
|
|
930
|
+
if (!target) return 0;
|
|
931
|
+
let score = Math.max(0, 4 - Math.abs(countParams(s.params) - target.paramCount));
|
|
932
|
+
if (Boolean(s.returns) === target.hasReturns) score += 2;
|
|
933
|
+
if (Boolean(s.raises) === target.hasRaises) score += 2;
|
|
934
|
+
const shared = [...(s.callees || [])].filter((c) => target.callees.has(c)).length;
|
|
935
|
+
return score + Math.min(shared, 4) * 2;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Lever 1: rank siblings by relevance to the anchor so the lean bundle shows the ones
|
|
939
|
+
* worth copying — shared decorator (the module's registration pattern, e.g.
|
|
940
|
+
* @register.filter) > name-affinity (shared tokens) > nearest source position. Pure;
|
|
941
|
+
* mutates a transient `_score` only. */
|
|
942
|
+
function rankSiblings(siblings, { decorators: anchorDecorators = "", label: anchorLabel = "", site: anchorSite = null } = {}, structuralTarget = null) {
|
|
943
|
+
const decCount = new Map();
|
|
944
|
+
for (const s of siblings) for (const d of splitDecs(s.decorators)) decCount.set(d, (decCount.get(d) || 0) + 1);
|
|
945
|
+
let dominant = "";
|
|
946
|
+
let bestCount = 1;
|
|
947
|
+
for (const [d, c] of decCount) if (c > bestCount) { bestCount = c; dominant = d; }
|
|
948
|
+
const anchorDecs = new Set(splitDecs(anchorDecorators));
|
|
949
|
+
const targetDecs = anchorDecs.size ? anchorDecs : new Set(dominant ? [dominant] : []);
|
|
950
|
+
const anchorTokens = new Set(tokenize(anchorLabel));
|
|
951
|
+
const anchorStart = anchorSite?.start ?? null;
|
|
952
|
+
for (const s of siblings) {
|
|
953
|
+
const decMatch = splitDecs(s.decorators).some((d) => targetDecs.has(d)) ? 1 : 0;
|
|
954
|
+
const nameAff = tokenize(s.label).filter((t) => anchorTokens.has(t)).length;
|
|
955
|
+
// #3: structural affinity (param-count / has-returns / has-raises / shared in-repo
|
|
956
|
+
// callees) sits BELOW name-affinity (max 16 < 50) — a tiebreaker within a name tier.
|
|
957
|
+
const struct = structuralScore(s, structuralTarget);
|
|
958
|
+
const pos = anchorStart != null && s.site ? 1 / (1 + Math.abs(s.site.start - anchorStart)) : 0;
|
|
959
|
+
s._score = decMatch * 1000 + nameAff * 50 + struct + pos;
|
|
960
|
+
}
|
|
961
|
+
return [...siblings].sort((a, b) => b._score - a._score || (a.site?.start || 0) - (b.site?.start || 0));
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Structured edit-context for `symbol`'s module: anchor span, RANKED top-level siblings
|
|
965
|
+
* (Function/Class) capped lean, the single closest exemplar (for its FULL body when the
|
|
966
|
+
* anchor itself is a module), registration globals, covering tests, and the insertion line.
|
|
967
|
+
* Pure — no fs. The server reads the module file once to flesh out the snippet + bodies. */
|
|
968
|
+
export function contextPlan(graph, ind) {
|
|
969
|
+
const modId = moduleIdOf(graph, ind);
|
|
970
|
+
const moduleLabel = graph.byId.get(modId)?.label || String(modId || "").replace(/^mod:/, "");
|
|
971
|
+
const defEdges = edgesOfKind(graph, "defines").filter((e) => e.subject === modId);
|
|
972
|
+
// #3: index fn→fn in-repo callees once, so siblings/anchor carry their callee set for
|
|
973
|
+
// structural ranking and the sizeBundle cross-module-call check.
|
|
974
|
+
const calleeMap = new Map();
|
|
975
|
+
for (const e of edgesOfKind(graph, "callsSymbol")) {
|
|
976
|
+
if (!calleeMap.has(e.subject)) calleeMap.set(e.subject, new Set());
|
|
977
|
+
calleeMap.get(e.subject).add(e.object);
|
|
978
|
+
}
|
|
979
|
+
let siblings = [];
|
|
980
|
+
const globals = [];
|
|
981
|
+
let insertion = 0;
|
|
982
|
+
for (const e of defEdges) {
|
|
983
|
+
const mem = graph.byId.get(e.object);
|
|
984
|
+
if (!mem) continue;
|
|
985
|
+
const cls = mem.class || "";
|
|
986
|
+
const site = siteOf(mem);
|
|
987
|
+
if (cls === "GlobalVariable") {
|
|
988
|
+
globals.push({ label: mem.label, value: (mem.attributes || []).find((a) => a.key === "value")?.value || "", site });
|
|
989
|
+
if (site) insertion = Math.max(insertion, site.end);
|
|
990
|
+
} else if (cls === "Function" || cls === "Class") {
|
|
991
|
+
// B007 gap (c): carry each sibling's `raises` + one-line doc so a validator-style
|
|
992
|
+
// task sees the error-contract without reading the body. #3 adds params/returns/callees
|
|
993
|
+
// for structural-similarity ranking.
|
|
994
|
+
siblings.push({
|
|
995
|
+
id: mem.id, label: mem.label, class: cls, site, decorators: decoratorOf(mem),
|
|
996
|
+
raises: attrVal(mem, "raises"), doc: attrVal(mem, "doc"),
|
|
997
|
+
params: attrVal(mem, "params"), returns: attrVal(mem, "returns"),
|
|
998
|
+
callees: calleeMap.get(mem.id) || new Set(),
|
|
999
|
+
});
|
|
1000
|
+
if (site) insertion = Math.max(insertion, site.end);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const anchorSite = siteOf(ind);
|
|
1004
|
+
const anchor = anchorSite && (ind.class || "") !== "Module"
|
|
1005
|
+
? {
|
|
1006
|
+
id: ind.id, label: ind.label, class: ind.class || "", site: anchorSite, decorators: decoratorOf(ind),
|
|
1007
|
+
raises: attrVal(ind, "raises"), params: attrVal(ind, "params"), returns: attrVal(ind, "returns"),
|
|
1008
|
+
callees: calleeMap.get(ind.id) || new Set(),
|
|
1009
|
+
}
|
|
1010
|
+
: null;
|
|
1011
|
+
const totalSiblings = siblings.length;
|
|
1012
|
+
// #3: the structural target the exemplar should resemble — the anchor's own shape when
|
|
1013
|
+
// there is one, else the dominant pattern across siblings (module anchor case).
|
|
1014
|
+
const structuralTarget = anchor ? profileOf(anchor) : dominantProfile(siblings);
|
|
1015
|
+
siblings = rankSiblings(siblings, anchor || { label: ind.label }, structuralTarget);
|
|
1016
|
+
// Lever 2: when the anchor is a module (no anchor body shown), surface the single
|
|
1017
|
+
// closest sibling's FULL body as the copy-this exemplar; signatures alone made the
|
|
1018
|
+
// agent fall back to Read (B006). With a function/class anchor its own body suffices.
|
|
1019
|
+
const exemplar = !anchor ? siblings.find((s) => s.site && s.label !== ind.label) || null : null;
|
|
1020
|
+
const tests = [...new Set(edgesOfKind(graph, "tests").filter((e) => e.object === modId).map((e) => e.subjectLabel || e.subject))].slice(0, CONTEXT_TESTS_CAP);
|
|
1021
|
+
const cochange = cochangeNeighbours(graph, modId).slice(0, COCHANGE_MID_CAP);
|
|
1022
|
+
const exports = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object).slice(0, 20);
|
|
1023
|
+
// B007 gap (b): the LITERAL __all__ membership (even unresolved) — the public surface a
|
|
1024
|
+
// new sibling must join to be importable.
|
|
1025
|
+
const allExports = attrVal(graph.byId.get(modId), "all");
|
|
1026
|
+
// B007 gap (a): class-internal members. When the anchor IS a class (or a method of one),
|
|
1027
|
+
// the edit often lives inside that class (e.g. add Truncator.lines), so list its
|
|
1028
|
+
// members with signatures so the agent need not read/grep the class body.
|
|
1029
|
+
const contains = edgesOfKind(graph, "contains");
|
|
1030
|
+
let classOwnerId = null;
|
|
1031
|
+
if ((ind.class || "") === "Class") classOwnerId = ind.id;
|
|
1032
|
+
else if ((ind.class || "") === "Method") classOwnerId = contains.find((e) => e.object === ind.id)?.subject || null;
|
|
1033
|
+
let classMembers = null;
|
|
1034
|
+
if (classOwnerId) {
|
|
1035
|
+
const owner = graph.byId.get(classOwnerId);
|
|
1036
|
+
const members = contains.filter((e) => e.subject === classOwnerId).map((e) => {
|
|
1037
|
+
const m = graph.byId.get(e.object);
|
|
1038
|
+
return {
|
|
1039
|
+
label: e.objectLabel || m?.label || e.object,
|
|
1040
|
+
class: m?.class || "",
|
|
1041
|
+
site: m ? siteOf(m) : null,
|
|
1042
|
+
decorators: m ? decoratorOf(m) : "",
|
|
1043
|
+
params: m ? attrVal(m, "params") : "",
|
|
1044
|
+
returns: m ? attrVal(m, "returns") : "",
|
|
1045
|
+
raises: m ? attrVal(m, "raises") : "",
|
|
1046
|
+
};
|
|
1047
|
+
}).slice(0, CLASS_MEMBER_CAP);
|
|
1048
|
+
classMembers = { className: owner?.label || String(classOwnerId).replace(/^fn:.*#/, ""), members, total: contains.filter((e) => e.subject === classOwnerId).length };
|
|
1049
|
+
}
|
|
1050
|
+
// #2: contiguous insertion region — from the LAST top-level definition (sibling/global,
|
|
1051
|
+
// or the exemplar, which is a sibling) through end-of-module. We give the start line here;
|
|
1052
|
+
// the server extends `end` to the real end-of-file (capped) using the lines it reads.
|
|
1053
|
+
let lastTop = null;
|
|
1054
|
+
for (const s of [...siblings, ...globals]) {
|
|
1055
|
+
if (s.site && (!lastTop || s.site.start > lastTop.start)) lastTop = s.site;
|
|
1056
|
+
}
|
|
1057
|
+
const insertionRegion = lastTop ? { start: lastTop.start, end: lastTop.end } : null;
|
|
1058
|
+
// #6: the focal symbol (anchor when present, else the module's exemplar) drives both the
|
|
1059
|
+
// call hint and the LARGE-tier inlined-callee bodies.
|
|
1060
|
+
const focal = anchor || exemplar;
|
|
1061
|
+
const focalInd = focal?.id ? graph.byId.get(focal.id) : null;
|
|
1062
|
+
const callHintStr = focalInd ? callHint(graph, focalInd) : "";
|
|
1063
|
+
let calleeBodies = [];
|
|
1064
|
+
if (focal?.callees) {
|
|
1065
|
+
for (const cid of focal.callees) {
|
|
1066
|
+
const c = graph.byId.get(cid);
|
|
1067
|
+
const cs = c ? siteOf(c) : null;
|
|
1068
|
+
if (cs) calleeBodies.push({ label: c.label, site: cs });
|
|
1069
|
+
if (calleeBodies.length >= INLINE_CALLEE_CAP) break;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
modId, moduleLabel, anchor, siblings, totalSiblings, exemplar, globals, tests, cochange,
|
|
1074
|
+
exports, allExports, classMembers, insertion, insertionRegion, calleeBodies, callHint: callHintStr,
|
|
1075
|
+
siblingCap: CONTEXT_SIBLING_CAP,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ---- #6 task-size-adaptive bundle (TINY / MID / LARGE) ---------------------------
|
|
1080
|
+
|
|
1081
|
+
/** Which bundle sections a tier emits. TINY is genuinely minimal (header + one short
|
|
1082
|
+
* exemplar body + registration + insertion region + __all__); MID is the full bundle;
|
|
1083
|
+
* LARGE adds inlined depth-1 callee bodies. FULL forces everything on. Pure. */
|
|
1084
|
+
export function bundleMask(tier) {
|
|
1085
|
+
const all = {
|
|
1086
|
+
anchor: true, exemplar: true, registration: true, insertionRegion: true, allExports: true,
|
|
1087
|
+
classMembers: true, siblings: true, reexports: true, tests: true, cochange: true, inlinedCallees: false,
|
|
1088
|
+
};
|
|
1089
|
+
if (tier === "TINY") return { ...all, classMembers: false, siblings: false, reexports: false, tests: false, cochange: false };
|
|
1090
|
+
if (tier === "LARGE" || tier === "FULL") return { ...all, inlinedCallees: true };
|
|
1091
|
+
return all; // MID
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** B2: a TRIMMED mask for SECONDARY (related-but-not-primary) digest modules — keep the cheap,
|
|
1095
|
+
* cache-stable signal (registration globals, ranked sibling SIGNATURES, the insertion region,
|
|
1096
|
+
* __all__) but drop the expensive bodies (anchor/exemplar/inlined callees) and the variable
|
|
1097
|
+
* tails (tests/cochange/re-exports/class members). Pure. */
|
|
1098
|
+
export function trimBundleMask(mask) {
|
|
1099
|
+
return {
|
|
1100
|
+
...mask,
|
|
1101
|
+
anchor: false, exemplar: false, inlinedCallees: false,
|
|
1102
|
+
classMembers: false, reexports: false, tests: false, cochange: false,
|
|
1103
|
+
registration: true, siblings: true, insertionRegion: true, allExports: true,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** Classify a context plan by task size and return {tier, mask, topup}. B1/B6: lean by
|
|
1108
|
+
* default — START at TINY and escalate ("top-up") one tier ONLY when the lean bundle would
|
|
1109
|
+
* omit something the edit demonstrably needs (no exemplar body, a class/method edit, or a
|
|
1110
|
+
* large/complex target → MID; a cross-module call or a big-class method → LARGE). `topup`
|
|
1111
|
+
* records whether auto-sizing escalated above TINY (surfaced in the digest header). Pure. */
|
|
1112
|
+
export function sizeBundle(plan, graph, { untuned = false } = {}) {
|
|
1113
|
+
const focal = plan.anchor || plan.exemplar;
|
|
1114
|
+
let tier = "TINY";
|
|
1115
|
+
// (a) no exemplar/anchor body to copy → the agent needs the sibling list (MID).
|
|
1116
|
+
const hasExemplarBody = Boolean((plan.anchor && plan.anchor.site) || (plan.exemplar && plan.exemplar.site));
|
|
1117
|
+
if (!hasExemplarBody) tier = "MID";
|
|
1118
|
+
// (b) the edit lives INSIDE a class (class/method anchor with members) → show members (MID).
|
|
1119
|
+
if (plan.classMembers && plan.classMembers.members && plan.classMembers.members.length) tier = "MID";
|
|
1120
|
+
if (focal) {
|
|
1121
|
+
// (c) a large/complex target (long body, many params, or it raises) → MID.
|
|
1122
|
+
const loc = focal.site ? focal.site.end - focal.site.start + 1 : Infinity;
|
|
1123
|
+
const arity = countParams(focal.params);
|
|
1124
|
+
// (c) a long/complex focal escalates TINY→MID. TUNING #1 (B011) gated this on an explicit
|
|
1125
|
+
// symbol anchor (so a long-exemplar MODULE digest stayed TINY); BENCHMARK_011 proved that
|
|
1126
|
+
// trim REGRESSED every model (the trimmed sibling/test tail was load-bearing scaffolding), so
|
|
1127
|
+
// B012 REVERTS it — escalation fires on any long focal, == the B010 digest. The `untuned`
|
|
1128
|
+
// param is now a no-op for sizing (kept so the seonix-b010 control arm's flag still resolves).
|
|
1129
|
+
if (loc > TINY_MAX_LOC || arity > TINY_MAX_ARITY || Boolean(focal.raises)) tier = "MID";
|
|
1130
|
+
// (d) LARGE — only for an EXPLICIT symbol focus (plan.anchor), where inlining the
|
|
1131
|
+
// depth-1 callee bodies / the class shape is worth the tokens: a cross-module call from
|
|
1132
|
+
// the anchor, OR an anchor that is a method of a big class. When the focal is merely a
|
|
1133
|
+
// module-EXEMPLAR (the digest/module-anchor case), a cross-module call does NOT force
|
|
1134
|
+
// LARGE — the exemplar body already shows the call, and MID's signatures suffice; this
|
|
1135
|
+
// keeps the common "register a filter" module bundle lean.
|
|
1136
|
+
let crossModule = false;
|
|
1137
|
+
if (plan.anchor) {
|
|
1138
|
+
for (const cid of focal.callees || []) {
|
|
1139
|
+
const c = graph.byId.get(cid);
|
|
1140
|
+
const cs = c ? siteOf(c) : null;
|
|
1141
|
+
if (cs && cs.path !== plan.moduleLabel) { crossModule = true; break; }
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const bigClassMethod = (plan.anchor?.class || "") === "Method" &&
|
|
1145
|
+
Number(plan.classMembers?.total || plan.classMembers?.members?.length || 0) >= LARGE_CLASS_MEMBERS;
|
|
1146
|
+
if (crossModule || bigClassMethod) tier = "LARGE";
|
|
1147
|
+
} else {
|
|
1148
|
+
tier = "MID"; // no focal symbol at all → not a tiny add; keep the fuller bundle.
|
|
1149
|
+
}
|
|
1150
|
+
return { tier, mask: bundleMask(tier), topup: tier !== "TINY" };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** B2: order SECONDARY digest modules by relevance to the PRIMARY (first) module — import
|
|
1154
|
+
* adjacency (either direction, incl. coarse calls) outranks change-coupling weight; ties keep
|
|
1155
|
+
* the caller's input order (stable, deterministic). Returns the candidate labels reordered.
|
|
1156
|
+
* Pure — no fs. Falls back to the input order when the primary can't be mapped to a module. */
|
|
1157
|
+
export function rankModulesByProximity(graph, primaryLabel, candidateLabels) {
|
|
1158
|
+
const moduleIdFor = (label) => {
|
|
1159
|
+
const { match } = resolveSymbol(graph, label);
|
|
1160
|
+
return match && (match.class || "") === "Module" ? match.id : null;
|
|
1161
|
+
};
|
|
1162
|
+
const pid = moduleIdFor(primaryLabel);
|
|
1163
|
+
if (!pid) return [...candidateLabels];
|
|
1164
|
+
const adjacent = new Set();
|
|
1165
|
+
for (const kind of ["imports", "calls"]) {
|
|
1166
|
+
for (const e of edgesOfKind(graph, kind)) {
|
|
1167
|
+
if (e.subject === pid) adjacent.add(e.object);
|
|
1168
|
+
if (e.object === pid) adjacent.add(e.subject);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const coWeight = new Map();
|
|
1172
|
+
for (const e of edgesOfKind(graph, "cochange")) {
|
|
1173
|
+
if (e.subject === pid) coWeight.set(e.object, (coWeight.get(e.object) || 0) + (e.weight || 0));
|
|
1174
|
+
else if (e.object === pid) coWeight.set(e.subject, (coWeight.get(e.subject) || 0) + (e.weight || 0));
|
|
1175
|
+
}
|
|
1176
|
+
return candidateLabels
|
|
1177
|
+
.map((label, i) => {
|
|
1178
|
+
const id = moduleIdFor(label);
|
|
1179
|
+
const score = id ? (adjacent.has(id) ? 10 : 0) + (coWeight.get(id) || 0) : 0;
|
|
1180
|
+
return { label, score, i };
|
|
1181
|
+
})
|
|
1182
|
+
.sort((a, b) => b.score - a.score || a.i - b.i)
|
|
1183
|
+
.map((s) => s.label);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ---- #7 seonix_context_more: only the sections a TINY/MID bundle omits ------------
|
|
1187
|
+
|
|
1188
|
+
/** Render ONLY the bundle sections a lean bundle omits (sibling list / class members /
|
|
1189
|
+
* re-exports / __all__ / tests / cochange) for a symbol's module. Pure (no fs). */
|
|
1190
|
+
export function renderContextMore(plan) {
|
|
1191
|
+
const out = [`Additional context for ${plan.moduleLabel} (sections omitted from the lean bundle):`];
|
|
1192
|
+
if (plan.classMembers && plan.classMembers.members.length) {
|
|
1193
|
+
out.push(`\n## members of ${plan.classMembers.className}:`);
|
|
1194
|
+
for (const m of plan.classMembers.members) {
|
|
1195
|
+
const short = String(m.label).split(".").pop();
|
|
1196
|
+
const sig = m.params != null && m.params !== "" ? `(${m.params})${m.returns ? ` -> ${m.returns}` : ""}` : "";
|
|
1197
|
+
const dec = m.decorators ? `@${m.decorators} ` : "";
|
|
1198
|
+
const r = m.raises ? ` raises=${m.raises}` : "";
|
|
1199
|
+
out.push(` ${m.class} ${short}${m.site ? ` :${m.site.start}` : ""} ${dec}${short}${sig}${r}`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (plan.siblings.length) {
|
|
1203
|
+
out.push(`\n## sibling symbols (most relevant first; ${plan.siblings.length} total):`);
|
|
1204
|
+
for (const s of plan.siblings.slice(0, plan.siblingCap)) {
|
|
1205
|
+
const dec = s.decorators ? `@${s.decorators} ` : "";
|
|
1206
|
+
const r = s.raises ? ` raises=${s.raises}` : "";
|
|
1207
|
+
out.push(` ${s.class} ${s.label}${s.site ? ` :${s.site.start}` : ""} ${dec}${r}`);
|
|
1208
|
+
}
|
|
1209
|
+
if (plan.siblings.length > plan.siblingCap) out.push(` …+${plan.siblings.length - plan.siblingCap} more`);
|
|
1210
|
+
}
|
|
1211
|
+
if (plan.allExports) out.push(`\n## module __all__: ${plan.allExports}`);
|
|
1212
|
+
if (plan.exports && plan.exports.length) out.push(`\n## re-exported symbols: ${plan.exports.join(", ")}`);
|
|
1213
|
+
if (plan.tests.length) out.push(`\n## covering tests: ${plan.tests.join(", ")}`);
|
|
1214
|
+
if (plan.cochange && plan.cochange.length) {
|
|
1215
|
+
out.push(`\n## usually changed together: ${plan.cochange.map((c) => `${c.label} (×${c.weight})`).join(", ")}`);
|
|
1216
|
+
}
|
|
1217
|
+
if (out.length === 1) out.push("(no omitted sections — the lean bundle already contained everything for this symbol.)");
|
|
1218
|
+
return out.join("\n");
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ---- #7 cold-tool catalog (written to <repo>/.seonix/TOOLS.md by the index step) --
|
|
1222
|
+
|
|
1223
|
+
/** Markdown catalog of the COLD tools (everything except the two hot MCP tools): each
|
|
1224
|
+
* with a one-line purpose and the exact Bash invocation via the CLI `cli <tool>` route.
|
|
1225
|
+
* Pure — `cliPath` is the absolute path to bin/cli.mjs the caller wants embedded. */
|
|
1226
|
+
export function renderToolsCatalog(cliPath) {
|
|
1227
|
+
const cold = [
|
|
1228
|
+
["seonix_describe", "Locate one symbol and list its typed edges (both directions) with provenance.", { symbol: "django/utils/text.py" }],
|
|
1229
|
+
["seonix_signature", "One symbol's API surface (params, returns, raises/catches, flags, decorators, doc) without the body.", { symbol: "Truncator.chars" }],
|
|
1230
|
+
["seonix_impact", "Transitive reverse closure over imports/calls — what breaks if a module changes, by depth, with tests.", { module: "django/utils/text.py" }],
|
|
1231
|
+
["seonix_search", "Free-text/ranked lookup over the code-map to find the right module or symbol.", { query: "template filters", kind: "function" }],
|
|
1232
|
+
["seonix_members", "A class's methods + attributes (file:line, decorators) in one slice.", { class: "Truncator" }],
|
|
1233
|
+
["seonix_subclasses", "A class's base classes plus the transitive set of classes that extend it.", { class: "Field" }],
|
|
1234
|
+
["seonix_architecture", "Package/module map + the most-imported hub modules (optionally scoped to a package).", { package: "django/template" }],
|
|
1235
|
+
["seonix_exports", "A module's public __all__ surface, each name resolved to the module that defines it.", { module: "django/db/models/__init__.py" }],
|
|
1236
|
+
["seonix_tests_for", "The test modules covering a symbol or module, from the typed test edges.", { symbol: "django/utils/text.py" }],
|
|
1237
|
+
["seonix_untested", "Source modules with no covering test module — a coverage-gap view (no arguments).", {}],
|
|
1238
|
+
["seonix_history", "Recent commits that touched a symbol's module (newest first).", { symbol: "django/utils/text.py" }],
|
|
1239
|
+
["seonix_file_history", "Commits that touched a symbol's module, each with author / date / subject.", { symbol: "django/utils/text.py" }],
|
|
1240
|
+
["seonix_method_history", "Commits that touched a specific method symbol (fine-grained), with author / date / subject.", { symbol: "Truncator.chars" }],
|
|
1241
|
+
["seonix_class_history", "Commits that touched a specific class symbol (fine-grained), with author / date / subject.", { symbol: "Truncator" }],
|
|
1242
|
+
["seonix_callers", "Modules that call into a symbol's module (one hop).", { symbol: "django/utils/text.py" }],
|
|
1243
|
+
["seonix_callees", "Modules a symbol's module calls into (one hop).", { symbol: "django/utils/text.py" }],
|
|
1244
|
+
["seonix_calls", "The in-repo symbols a function calls (fn→fn), each with file:line.", { symbol: "slugify" }],
|
|
1245
|
+
["seonix_cochanges", "Modules that historically change in the same commit as a symbol's module (git co-change).", { symbol: "django/utils/text.py" }],
|
|
1246
|
+
["seonix_context_more", "The bundle sections a lean seonix_context omitted (siblings / tests / cochange / class members / re-exports).", { symbol: "django/utils/text.py" }],
|
|
1247
|
+
];
|
|
1248
|
+
const lines = [
|
|
1249
|
+
"# seonix cold-tool catalog",
|
|
1250
|
+
"",
|
|
1251
|
+
"The two hot tools — `seonix_context` (start here to add/modify code; supports `depth: min|auto|full`) and `seonix_snippet` (exact source of one symbol) — are exposed as MCP tools.",
|
|
1252
|
+
"",
|
|
1253
|
+
"The cold tools below are not registered as MCP tools (to keep the surface small); invoke each via the CLI:",
|
|
1254
|
+
"",
|
|
1255
|
+
];
|
|
1256
|
+
for (const [name, purpose, args] of cold) {
|
|
1257
|
+
lines.push(`## ${name}`);
|
|
1258
|
+
lines.push(purpose);
|
|
1259
|
+
lines.push("```bash");
|
|
1260
|
+
lines.push(`node ${cliPath} cli ${name} '${JSON.stringify(args)}'`);
|
|
1261
|
+
lines.push("```");
|
|
1262
|
+
lines.push("");
|
|
1263
|
+
}
|
|
1264
|
+
return lines.join("\n");
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// ---- change-coupling (git co-change) — "what usually changes together" ----------
|
|
1268
|
+
|
|
1269
|
+
/** [{label, weight}] modules co-changed with modId, sorted by count desc. Pure. */
|
|
1270
|
+
function cochangeNeighbours(graph, modId) {
|
|
1271
|
+
const hits = [];
|
|
1272
|
+
for (const e of edgesOfKind(graph, "cochange")) {
|
|
1273
|
+
if (e.subject === modId) hits.push({ label: e.objectLabel || e.object, weight: e.weight || 0 });
|
|
1274
|
+
else if (e.object === modId) hits.push({ label: e.subjectLabel || e.subject, weight: e.weight || 0 });
|
|
1275
|
+
}
|
|
1276
|
+
return hits.sort((a, b) => b.weight - a.weight);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const COCHANGE_CAP = 20;
|
|
1280
|
+
|
|
1281
|
+
/** Modules that historically change in the same commit as the target's module. */
|
|
1282
|
+
export function renderCochanges(graph, ind) {
|
|
1283
|
+
const modId = moduleIdOf(graph, ind);
|
|
1284
|
+
if (!modId) return `cannot map ${ind.label} to a module.`;
|
|
1285
|
+
const modLabel = graph.byId.get(modId)?.label || modId;
|
|
1286
|
+
const hits = cochangeNeighbours(graph, modId);
|
|
1287
|
+
if (!hits.length) return `${modLabel}: no change-coupling recorded (rarely co-committed, or outside the git-log window).`;
|
|
1288
|
+
const list = hits.slice(0, COCHANGE_CAP).map((h) => `${h.label} (×${h.weight})`);
|
|
1289
|
+
return `${modLabel} — usually changes together with ${hits.length} module(s) (edit these too):\n ${list.join("\n ")}` +
|
|
1290
|
+
(hits.length > COCHANGE_CAP ? `\n …+${hits.length - COCHANGE_CAP} more` : "");
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ---- re-exports / public API (__all__) ------------------------------------------
|
|
1294
|
+
|
|
1295
|
+
const EXPORTS_CAP = 40;
|
|
1296
|
+
|
|
1297
|
+
/** A module's public export surface: each __all__ name → the module that actually
|
|
1298
|
+
* defines it (so re-export hubs like __init__ are explicit). */
|
|
1299
|
+
export function renderExports(graph, ind) {
|
|
1300
|
+
const modId = moduleIdOf(graph, ind);
|
|
1301
|
+
if (!modId) return `cannot map ${ind.label} to a module.`;
|
|
1302
|
+
const modLabel = graph.byId.get(modId)?.label || modId;
|
|
1303
|
+
const edges = edgesOfKind(graph, "reexports").filter((e) => e.subject === modId);
|
|
1304
|
+
if (!edges.length) return `${modLabel}: no public exports recorded (no literal __all__, or none resolved).`;
|
|
1305
|
+
const list = edges.slice(0, EXPORTS_CAP).map((e) => {
|
|
1306
|
+
const origin = graph.byId.get(e.object);
|
|
1307
|
+
const where = origin ? siteOf(origin) : null;
|
|
1308
|
+
const from = where ? ` ← ${where.path}` : "";
|
|
1309
|
+
return `${e.objectLabel || e.object}${from}`;
|
|
1310
|
+
});
|
|
1311
|
+
return `${modLabel} — public API (${edges.length} export(s) via __all__):\n ${list.join("\n ")}` +
|
|
1312
|
+
(edges.length > EXPORTS_CAP ? `\n …+${edges.length - EXPORTS_CAP} more` : "");
|
|
1313
|
+
}
|