@polycode-projects/seonix 0.9.0 → 0.10.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/src/browser.mjs CHANGED
@@ -46,6 +46,7 @@ const PROP_KIND = {
46
46
  "mgx:touchessymbol": "touchesSymbol",
47
47
  "mgx:callssymbol": "callsSymbol",
48
48
  "mgx:serves": "serves", // un-typed interfaces (gated); inert on a default graph
49
+ "mgx:callshttp": "callsHttp", // un-typed interfaces (gated); client call-site → Route
49
50
  };
50
51
 
51
52
  const pathOfId = (id) => {
@@ -284,7 +285,10 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
284
285
  }
285
286
  const directBorn = (id) => {
286
287
  const s = touchesByNode.get(id);
287
- return s && s.size ? Math.min(...s) : null;
288
+ if (!s || !s.size) return null;
289
+ let min = Infinity; // running min, not Math.min(...spread) — touch sets scale with history
290
+ for (const o of s) if (o < min) min = o;
291
+ return min;
288
292
  };
289
293
  const fallbackBorn = (id) => {
290
294
  let cur = parentOf.get(id);
@@ -604,7 +608,8 @@ function render(){
604
608
  const hs=(S.heat&&!cmp)?heatScale(S.tg,idx):null;
605
609
  const grav=S.grav?gravityAt(S.tg,hi):null;
606
610
  const gW=grav?new Map(grav.map(e=>[e.src+'|'+e.dst,e.w])):null;
607
- const gMax=grav&&grav.length?Math.max(...grav.map(e=>e.w)):1;
611
+ let gMax=1; // running max, not Math.max(...spread) — gravity edge lists scale with the graph
612
+ if(grav&&grav.length){gMax=grav[0].w;for(const e of grav)if(e.w>gMax)gMax=e.w;}
608
613
  cy.batch(()=>{
609
614
  cy.nodes().forEach(cn=>{
610
615
  const n=S.byId.get(cn.id());
@@ -667,7 +672,8 @@ function runGravityLayout(){
667
672
  const hi=S.cursorB!=null?Math.max(S.cursor,S.cursorB):S.cursor;
668
673
  const g=gravityAt(S.tg,hi);
669
674
  const wByKey=new Map(g.map(e=>[e.src+'|'+e.dst,e.w]));
670
- const maxW=g.length?Math.max(...g.map(e=>e.w)):1;
675
+ let maxW=1; // running max, not Math.max(...spread) — gravity edge lists scale with the graph
676
+ if(g.length){maxW=g[0].w;for(const e of g)if(e.w>maxW)maxW=e.w;}
671
677
  const wOf=e=>{const a=e.data('source'),b=e.data('target');return wByKey.get(a+'|'+b)??wByKey.get(b+'|'+a)??0;};
672
678
  const vis=S.cy.elements().filter(el=>el.style('display')==='element');
673
679
  vis.layout({name:'cose',animate:false,nodeRepulsion:6000,
package/src/codegraph.mjs CHANGED
@@ -89,9 +89,10 @@ const PROP_KIND = {
89
89
  // closure (module-coarse) is unchanged.
90
90
  "mgx:touchessymbol": "touchesSymbol",
91
91
  "mgx:callssymbol": "callsSymbol",
92
- // un-typed interfaces (PLAN_UNTYPED_INTERFACES.md) — route→handler; only present when the
93
- // gated [interfaces] feature emitted serves edges, so this key is inert on a default graph.
92
+ // un-typed interfaces (PLAN_UNTYPED_INTERFACES.md) — route→handler + client→route; only present
93
+ // when the gated [interfaces] feature emitted edges, so these keys are inert on a default graph.
94
94
  "mgx:serves": "serves",
95
+ "mgx:callshttp": "callsHttp",
95
96
  // legacy tokens (pre-realign graphs) — kept so a stale artifact still classifies
96
97
  "seon:usescomplextype": "imports",
97
98
  "seon:invokesmethod": "calls",
@@ -189,6 +190,10 @@ export function resolveSymbol(graph, symbol) {
189
190
  return {
190
191
  match: scored[0]?.ind || null,
191
192
  candidates: scored.slice(1, 5).map((x) => x.ind),
193
+ // `exact` distinguishes a literal label/id hit from a path-normalised or substring
194
+ // (fuzzy) hit, so a caller can NAME a substitution instead of silently answering
195
+ // about a different symbol than the one asked for (wh dogfood T14).
196
+ exact: (scored[0]?.score ?? 0) === 100,
192
197
  };
193
198
  }
194
199
 
@@ -320,6 +325,36 @@ export function impactClosure(graph, ind, { maxDepth = 8 } = {}) {
320
325
  const subjLabel = graph.byId.get(subjModId)?.label || subjModId;
321
326
  addDependent(objModId, subjModId, subjLabel, g.predicate);
322
327
  }
328
+ } else if (kind === "serves") {
329
+ // Un-typed interfaces (interfaces.mjs, gated — these kinds never occur in a default
330
+ // graph, so the default closure is untouched). Edge direction as W4 emits it:
331
+ // subject = Route individual, object = handler symbol (fn:<path>#name). The Route
332
+ // and its handler are one interface point seen from two sides, so impact crosses
333
+ // the edge BOTH ways:
334
+ // - reverse (like imports/calls): a handler change impacts its Route — keyed on
335
+ // the handler symbol AND its module (coarsened on read, the callsSymbol
336
+ // technique), so the closure crosses the edge whether it entered at symbol or
337
+ // module level;
338
+ // - forward: a Route change impacts its handler's module — so the closure from a
339
+ // Route continues into the handler's own dependents at the next depth.
340
+ for (const e of g.edges) {
341
+ const handlerModId = moduleIdOfId(graph, e.object);
342
+ addDependent(e.object, e.subject, e.subjectLabel || e.subject, g.predicate);
343
+ if (handlerModId) {
344
+ addDependent(handlerModId, e.subject, e.subjectLabel || e.subject, g.predicate);
345
+ addDependent(e.subject, handlerModId, graph.byId.get(handlerModId)?.label || handlerModId, g.predicate);
346
+ }
347
+ }
348
+ } else if (kind === "callsHttp") {
349
+ // Gated, like serves. subject = client call-site symbol, object = Route individual.
350
+ // Reverse-only (a Route change impacts its HTTP clients; a client change never
351
+ // impacts the Route), caller coarsened to its module — completing the chain
352
+ // handler → Route (serves) → client modules (callsHttp).
353
+ for (const e of g.edges) {
354
+ const subjModId = moduleIdOfId(graph, e.subject);
355
+ if (!subjModId) continue;
356
+ addDependent(e.object, subjModId, graph.byId.get(subjModId)?.label || subjModId, g.predicate);
357
+ }
323
358
  } else if (kind === "tests") {
324
359
  for (const e of g.edges) {
325
360
  if (!coveredBy.has(e.object)) coveredBy.set(e.object, []);
@@ -689,9 +724,9 @@ function beamExpand(graph, scored, beamWidth) {
689
724
  }
690
725
  const [survivors, kindOverflow] = pruneToBeam(candidates);
691
726
  for (const [id, score] of survivors) merged.set(id, Math.max(merged.get(id) || 0, score));
692
- plyOverflow.push(...kindOverflow);
727
+ plyOverflow.push(...kindOverflow); // spread OK: bounded by BEAM_OVERFLOW_CAP (≤4 args)
693
728
  }
694
- overflow.push(...plyOverflow);
729
+ overflow.push(...plyOverflow); // spread OK: ≤ cap × edge groups = 16 args, never repo-scaled
695
730
  // Apply the bounded nudge once per module (first ply it's reached), same shape as the other
696
731
  // proximity families — a nudge, never a replacement.
697
732
  for (const [id, propagated] of merged) {
@@ -1292,21 +1327,20 @@ export function renderSignature(graph, ind) {
1292
1327
  return lines.join("\n");
1293
1328
  }
1294
1329
 
1295
- /** Forward bases + the transitive reverse inheritance closure (who extends this)
1296
- * replaces grepping `class X(Base)` across the tree. Uses `inherits` (mgx:subclassOf). */
1297
- export function renderSubclasses(graph, ind) {
1298
- const inherits = edgesOfKind(graph, "inherits");
1299
- const bases = inherits.filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object);
1330
+ /** child map over the `inherits` edges: base id [{id,label}] of direct subclasses. */
1331
+ function inheritsChildrenOf(inherits) {
1300
1332
  const childrenOf = new Map();
1301
1333
  for (const e of inherits) {
1302
1334
  if (!childrenOf.has(e.object)) childrenOf.set(e.object, []);
1303
1335
  childrenOf.get(e.object).push({ id: e.subject, label: e.subjectLabel || e.subject });
1304
1336
  }
1305
- const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
1306
- lines.push(bases.length ? `extends: ${capJoin(bases, SUBCLASS_CAP)}` : "extends: (no internal/recorded base classes)");
1307
- const visited = new Set([ind.id]);
1337
+ return childrenOf;
1338
+ }
1339
+
1340
+ /** BFS the reverse-inheritance closure below `frontier` (depth-capped at 8); returns
1341
+ * sorted label levels. `visited` guards cycles and is mutated. */
1342
+ function descendSubclassLevels(childrenOf, frontier, visited) {
1308
1343
  const levels = [];
1309
- let frontier = [ind.id];
1310
1344
  for (let depth = 1; depth <= 8 && frontier.length; depth += 1) {
1311
1345
  const next = [];
1312
1346
  const level = [];
@@ -1324,6 +1358,18 @@ export function renderSubclasses(graph, ind) {
1324
1358
  }
1325
1359
  frontier = next;
1326
1360
  }
1361
+ return levels;
1362
+ }
1363
+
1364
+ /** Forward bases + the transitive reverse inheritance closure (who extends this) —
1365
+ * replaces grepping `class X(Base)` across the tree. Uses `inherits` (mgx:subclassOf). */
1366
+ export function renderSubclasses(graph, ind) {
1367
+ const inherits = edgesOfKind(graph, "inherits");
1368
+ const bases = inherits.filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object);
1369
+ const childrenOf = inheritsChildrenOf(inherits);
1370
+ const lines = [`${ind.label} — ${ind.class || "Entity"} (id: ${ind.id})`];
1371
+ lines.push(bases.length ? `extends: ${capJoin(bases, SUBCLASS_CAP)}` : "extends: (no internal/recorded base classes)");
1372
+ const levels = descendSubclassLevels(childrenOf, [ind.id], new Set([ind.id]));
1327
1373
  const total = levels.reduce((n, l) => n + l.length, 0);
1328
1374
  if (!total) {
1329
1375
  lines.push("subclasses: none recorded — nothing extends it in the extracted graph.");
@@ -1334,6 +1380,44 @@ export function renderSubclasses(graph, ind) {
1334
1380
  return lines.join("\n");
1335
1381
  }
1336
1382
 
1383
+ /** Honest subclasses answer for a base-class NAME with no individual of its own — an
1384
+ * external/framework base (e.g. ASP.NET's ControllerBase) that appears only as a
1385
+ * supertype label on `inherits` edges. Lists the classes whose recorded supertype
1386
+ * label matches the name, plus their transitive subclasses. Returns null when no
1387
+ * inherits edge carries the label, so the caller can fall through. (wh dogfood T14:
1388
+ * the silent fuzzy substitution answered "none recorded" while 176 classes inherited
1389
+ * from the asked-for external base.) */
1390
+ export function renderExternalSubclasses(graph, name) {
1391
+ const want = String(name || "").trim().toLowerCase();
1392
+ if (!want) return null;
1393
+ const inherits = edgesOfKind(graph, "inherits");
1394
+ const matched = inherits.filter((e) => {
1395
+ const label = String(e.objectLabel || "").trim().toLowerCase();
1396
+ if (label) return label === want;
1397
+ const id = String(e.object || "").toLowerCase();
1398
+ return id === want || id.endsWith(`#${want}`); // label-less edge: match the id tail
1399
+ });
1400
+ if (!matched.length) return null;
1401
+ const visited = new Set();
1402
+ const direct = [];
1403
+ for (const e of matched) {
1404
+ if (visited.has(e.subject)) continue;
1405
+ visited.add(e.subject);
1406
+ direct.push({ id: e.subject, label: e.subjectLabel || e.subject });
1407
+ }
1408
+ const directLabels = direct.map((d) => d.label).sort((a, b) => String(a).localeCompare(String(b)));
1409
+ const deeper = descendSubclassLevels(inheritsChildrenOf(inherits), direct.map((d) => d.id), visited);
1410
+ const levels = [directLabels, ...deeper];
1411
+ const total = levels.reduce((n, l) => n + l.length, 0);
1412
+ const lines = [
1413
+ `${name} — external/framework base class: not an individual in the graph, but ` +
1414
+ `${matched.length} inherits edge(s) name it as a supertype.`,
1415
+ `subclasses: ${total} total across ${levels.length} level(s).`,
1416
+ ];
1417
+ levels.forEach((l, i) => lines.push(` depth ${i + 1} (${l.length}): ${capJoin(l, SUBCLASS_CAP)}`));
1418
+ return lines.join("\n");
1419
+ }
1420
+
1337
1421
  const ARCH_PKG_CAP = 25;
1338
1422
  const ARCH_HUB_CAP = 15;
1339
1423
 
@@ -1520,13 +1604,24 @@ export function renderClassHistory(graph, ind) {
1520
1604
  return renderSymbolHistory(graph, ind);
1521
1605
  }
1522
1606
 
1523
- // ---- symbol search (kind=function/class/method/attribute, with name/decorator filters)
1607
+ // ---- symbol search (kind=function/class/method/attribute/route, with name/decorator filters)
1524
1608
 
1525
- const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute" };
1609
+ const SYMBOL_CLASSES = { function: "Function", class: "Class", method: "Method", attribute: "Attribute", route: "Route" };
1610
+
1611
+ /** handler labels (with file:line where sited) a Route individual's `serves` edges point at —
1612
+ * a Route has no source span of its own, so the handler is what makes the line useful. */
1613
+ function routeHandlers(graph, servesEdges, routeId) {
1614
+ return servesEdges
1615
+ .filter((e) => e.subject === routeId)
1616
+ .map((e) => {
1617
+ const h = graph.byId.get(e.object);
1618
+ return `${e.objectLabel || h?.label || e.object}${spanTag(h ? siteOf(h) : null)}`;
1619
+ });
1620
+ }
1526
1621
 
1527
1622
  function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, nameRe }) {
1528
1623
  const targetClass = SYMBOL_CLASSES[kind];
1529
- if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, or module).`;
1624
+ if (!targetClass) return `unknown kind "${kind}" (use function, class, method, attribute, route, or module).`;
1530
1625
  const hits = [];
1531
1626
  for (const ind of graph.individuals) {
1532
1627
  if ((ind.class || "") !== targetClass) continue;
@@ -1542,8 +1637,21 @@ function searchSymbols(graph, tokens, { limit = SEARCH_LIMIT, kind, decFilter, n
1542
1637
  hits.sort((a, b) => b.score - a.score || String(a.ind.label).length - String(b.ind.label).length);
1543
1638
  const top = hits.slice(0, limit);
1544
1639
  const lines = [`${hits.length} ${kind}(s) match (top ${top.length}):`];
1545
- for (const { ind } of top) lines.push(`- ${ind.label}${spanTag(siteOf(ind))}`);
1546
- lines.push("Then seonix_snippet <name> for the exact body, or seonix_describe for its edges.");
1640
+ // Routes were dead data (wh dogfood C9/C18/T3/T11): 22 Route individuals + serves edges in the
1641
+ // graph, reachable from no surface. A Route line shows verb + path template (its label) → the
1642
+ // handler symbol(s) its serves edges point at, so the hit is actionable without another call.
1643
+ const servesEdges = targetClass === "Route" ? edgesOfKind(graph, "serves") : null;
1644
+ for (const { ind } of top) {
1645
+ if (servesEdges) {
1646
+ const handlers = routeHandlers(graph, servesEdges, ind.id);
1647
+ lines.push(`- ${ind.label}${handlers.length ? ` → ${capJoin(handlers, 4)}` : " (no handler edge recorded)"}`);
1648
+ } else {
1649
+ lines.push(`- ${ind.label}${spanTag(siteOf(ind))}`);
1650
+ }
1651
+ }
1652
+ lines.push(servesEdges
1653
+ ? "Then seonix_snippet <Class.method> for a handler body, or seonix_describe for a route's edges."
1654
+ : "Then seonix_snippet <name> for the exact body, or seonix_describe for its edges.");
1547
1655
  return lines.join("\n");
1548
1656
  }
1549
1657
 
@@ -54,6 +54,59 @@ function collectCalls(node) {
54
54
  return [...out].sort();
55
55
  }
56
56
 
57
+ // ---- HTTP-client call capture (un-typed interfaces: the callsHttp client→route half). ----
58
+ // `<recv>.GetAsync("api/x")`-style HttpClient invocations whose FIRST argument is a string
59
+ // literal. The verb comes from the method name (Get|Post|Put|Delete|Patch, with the common
60
+ // String/Stream/ByteArray/FromJson/AsJson suffixes, always ...Async); interpolated strings keep
61
+ // their `{expr}` holes verbatim — the matcher in interfaces.mjs collapses them to wildcard
62
+ // segments. The URL must contain "/". Emitted as an optional `http_calls` field on the method
63
+ // define — absent when empty, and the DEFAULT graph ignores the field (gating contract).
64
+ // NOTE: the tree-sitter backend only; the Roslyn backend does not capture http_calls yet.
65
+ const HTTP_METHOD = /^(get|post|put|delete|patch)(string|stream|bytearray|fromjson|asjson)?async$/;
66
+ const URL_LITERALS = new Set(["string_literal", "verbatim_string_literal", "interpolated_string_expression", "raw_string_literal"]);
67
+ function collectHttpCalls(node) {
68
+ const out = [];
69
+ const seen = new Set();
70
+ const visit = (n) => {
71
+ if (n.type === "invocation_expression") {
72
+ const fn = n.childForFieldName("function");
73
+ const name = fn && fn.type === "member_access_expression" ? (fn.childForFieldName("name")?.text || "") : "";
74
+ const m = HTTP_METHOD.exec(name.toLowerCase());
75
+ if (m) {
76
+ const arg = n.childForFieldName("arguments")?.namedChild(0); // first `argument` node
77
+ const expr = arg?.namedChild(0) || arg;
78
+ if (expr && URL_LITERALS.has(expr.type)) {
79
+ const url = expr.text.replace(/\s+/g, " ").replace(/^[$@]+/, "").replace(/^"+|"+$/g, "").slice(0, 160);
80
+ if (url.includes("/")) {
81
+ const key = `${m[1]} ${url}`;
82
+ if (!seen.has(key)) { seen.add(key); out.push({ verb: m[1], url }); }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ for (let i = 0; i < n.namedChildCount; i++) visit(n.namedChild(i)); // document order
88
+ };
89
+ visit(node);
90
+ return out;
91
+ }
92
+
93
+ /** C# attributes on a declaration node → decorator strings, matching the Roslyn
94
+ * backend's shape (`m.AttributeLists.SelectMany(a => a.Attributes)` + OneLine):
95
+ * each `attribute` child of an `attribute_list`, whitespace-collapsed to one line.
96
+ * So `[Route("api/[controller]")]` → `Route("api/[controller]")` on both backends. */
97
+ function attrsOf(node) {
98
+ const out = [];
99
+ for (let i = 0; i < node.namedChildCount; i++) {
100
+ const list = node.namedChild(i);
101
+ if (list.type !== "attribute_list") continue;
102
+ for (let j = 0; j < list.namedChildCount; j++) {
103
+ const a = list.namedChild(j);
104
+ if (a.type === "attribute") out.push(a.text.replace(/\s+/g, " ").trim());
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
57
110
  function modifiersText(node) {
58
111
  // tree-sitter-c-sharp surfaces modifiers as anonymous children before the name
59
112
  const mods = [];
@@ -76,7 +129,7 @@ function emitType(n, prefix, defines, exports) {
76
129
  const bases = [];
77
130
  const baseList = n.childForFieldName("bases") || n.namedChildren.find((c) => c.type === "base_list");
78
131
  if (baseList) for (let i = 0; i < baseList.namedChildCount; i++) bases.push(baseList.namedChild(i).text.slice(0, 80));
79
- defines.push({ name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n), bases, decorators: [],
132
+ defines.push({ name: cname, kind: "class", lineno: line(n), end_lineno: endLine(n), bases, decorators: attrsOf(n),
80
133
  ...(SUBKIND[n.type] ? { subkind: SUBKIND[n.type] } : {}),
81
134
  ...(visFrom(mods) ? { visibility: visFrom(mods) } : {}) });
82
135
  if (mods.includes("public")) exports.add(cname);
@@ -94,10 +147,12 @@ function emitType(n, prefix, defines, exports) {
94
147
  const mmods = modifiersText(mem);
95
148
  const params = (mem.childForFieldName("parameters")?.text || "").replace(/\s+/g, " ").replace(/^\(|\)$/g, "").slice(0, 160);
96
149
  const returns = (mem.childForFieldName("returns") || mem.childForFieldName("type"))?.text?.slice(0, 80) || "";
150
+ const http = collectHttpCalls(mem);
97
151
  defines.push({ name: `${cname}.${mn}`, kind: "method", lineno: line(mem), end_lineno: endLine(mem),
98
- decorators: [], params, returns, calls: collectCalls(mem),
152
+ decorators: attrsOf(mem), params, returns, calls: collectCalls(mem),
99
153
  ...(mmods.includes("static") ? { is_static: true } : {}),
100
- ...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}) });
154
+ ...(visFrom(mmods) ? { visibility: visFrom(mmods) } : {}),
155
+ ...(http.length ? { http_calls: http } : {}) });
101
156
  } else if (mem.type === "property_declaration") {
102
157
  const mn = fieldText(mem, "name");
103
158
  if (mn) defines.push({ name: `${cname}.${mn}`, kind: "attribute", lineno: line(mem), end_lineno: endLine(mem), decorators: [] });
package/src/extract.mjs CHANGED
@@ -13,7 +13,9 @@
13
13
  // mgx:testsCoverage Module → Module (a test module → the internal modules it imports)
14
14
  // seon:history Commit → Module (from git log --name-only)
15
15
  // mgx:touchesSymbol Commit → CodeEntity (commit changed-line-range ∩ symbol span)
16
- // mgx:callsSymbol Function/Method → Function/Class (symbol-granular, unambiguous)
16
+ // mgx:callsSymbol Function/Method → Function/Class/Method (symbol-granular,
17
+ // unambiguous; method targets resolve through a
18
+ // tiered simple-name fallback — see buildEntities)
17
19
  // seon:containsCodeEntity Class → Method/Attribute (class membership)
18
20
  // mgx:subclassOf Class → Class (inheritance; internal base resolved, else ext:)
19
21
 
@@ -233,9 +235,38 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
233
235
  // Registry of top-level functions/classes by simple name — used to resolve coarse
234
236
  // call targets AND inheritance bases to a single internal module (else dropped).
235
237
  const classToPaths = new Map(); // class name -> Set<path> (for base resolution)
238
+ // Method-level call-target registries. Receiver-typed call texts (`_svc.ProcessOrder(...)`,
239
+ // `self.render()`) never match a top-level function/class name, and on C#/Java estates there
240
+ // are NO top-level functions at all — so without these every method stays caller-less.
241
+ const methodToSymbolIds = new Map(); // simple method name (last ident of the define) -> Set<fnId>
242
+ const dottedMethodToSymbolIds = new Map(); // "Owner.Method" (last two idents of the define) -> Set<fnId>
243
+ const interfaceOwnedIds = new Set(); // method ids owned by a class defined with subkind "interface"
236
244
  for (const m of modules) {
245
+ // Interface class names in THIS module first (defines are document-ordered, but a full
246
+ // pre-pass keeps membership independent of class/member ordering).
247
+ const interfaceNames = new Set();
237
248
  for (const d of m.defines || []) {
238
- if (d.kind === "method" || d.kind === "attribute" || d.kind === "global") continue; // not standalone call targets
249
+ if (d.kind === "class" && d.subkind === "interface") interfaceNames.add(d.name);
250
+ }
251
+ for (const d of m.defines || []) {
252
+ if (d.kind === "method") {
253
+ const id = fnId(m.path, d.name);
254
+ const simple = lastIdent(d.name);
255
+ if (simple) {
256
+ if (!methodToSymbolIds.has(simple)) methodToSymbolIds.set(simple, new Set());
257
+ methodToSymbolIds.get(simple).add(id);
258
+ }
259
+ const parts = d.name.split(".");
260
+ if (parts.length >= 2) {
261
+ const dotted = parts.slice(-2).join(".");
262
+ if (!dottedMethodToSymbolIds.has(dotted)) dottedMethodToSymbolIds.set(dotted, new Set());
263
+ dottedMethodToSymbolIds.get(dotted).add(id);
264
+ }
265
+ const owner = d.name.includes(".") ? d.name.slice(0, d.name.lastIndexOf(".")) : "";
266
+ if (owner && interfaceNames.has(owner)) interfaceOwnedIds.add(id);
267
+ continue;
268
+ }
269
+ if (d.kind === "attribute" || d.kind === "global") continue; // not standalone call targets
239
270
  const register = (name) => {
240
271
  if (!nameToPaths.has(name)) nameToPaths.set(name, new Set());
241
272
  nameToPaths.get(name).add(m.path);
@@ -267,6 +298,32 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
267
298
  return ids[0];
268
299
  };
269
300
 
301
+ // Resolve a receiver-typed call text (`_svc.ProcessOrder`, `self.render`) to ONE method
302
+ // define, tiered and deterministic — same honest Group-A discipline as the base registry,
303
+ // mirroring its same-module-wins rule:
304
+ // 0. precision: the call's last TWO identifiers hit the dotted "Owner.Method" registry
305
+ // uniquely (qualified/static call texts) → it;
306
+ // a. exactly one candidate defined in the calling module → local win;
307
+ // b. else exactly one after excluding interface-owned members → the implementation
308
+ // wins over the interface declaration it satisfies;
309
+ // c. else exactly one overall → it;
310
+ // d. else drop (ambiguous → no edge, never guessed).
311
+ const resolveMethodCallee = (callName, ident, path) => {
312
+ const dm = String(callName).match(/([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/);
313
+ if (dm) {
314
+ const dotted = dottedMethodToSymbolIds.get(`${dm[1]}.${dm[2]}`);
315
+ if (dotted?.size === 1) return [...dotted][0];
316
+ }
317
+ const cands = methodToSymbolIds.get(ident);
318
+ if (!cands || !cands.size) return null;
319
+ const pre = `fn:${path}#`;
320
+ const local = [...cands].filter((i) => i.startsWith(pre));
321
+ if (local.length === 1) return local[0];
322
+ const impls = [...cands].filter((i) => !interfaceOwnedIds.has(i));
323
+ if (impls.length === 1) return impls[0];
324
+ return cands.size === 1 ? [...cands][0] : null;
325
+ };
326
+
270
327
  // resolve a module's import candidates to internal module paths
271
328
  const internalImports = (m) => {
272
329
  const set = new Set();
@@ -380,17 +437,22 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
380
437
  }
381
438
  }
382
439
 
383
- // symbol-granular calls: caller fn/method → callee fn/class, resolved ONLY when the
384
- // callee simple name has exactly ONE in-repo definition (same unique-name discipline
385
- // as the module-coarse calls). Ambiguous / receiver-typed / external names are dropped
386
- // (honest Group-A). Reuses the per-function call names already parsed by extract_ast.py.
440
+ // symbol-granular calls: caller fn/method → callee fn/class/method. The top-level
441
+ // function/class registry resolves first, unchanged (exactly ONE in-repo definition
442
+ // same unique-name discipline as the module-coarse calls). On a complete miss there,
443
+ // receiver-typed call texts fall through to the tiered METHOD registry above, so
444
+ // `_svc.ProcessOrder(...)` reaches `B.ProcessOrder` even though no top-level symbol
445
+ // carries that name (C#/Java estates have none). Ambiguous / external names are
446
+ // dropped (honest Group-A). Reuses the per-function call names the extractors parsed.
387
447
  if ((d.kind === "function" || d.kind === "method") && d.calls?.length) {
388
448
  for (const callName of d.calls) {
389
449
  const ident = lastIdent(callName);
390
450
  if (!ident) continue;
391
451
  const ids = nameToSymbolIds.get(ident);
392
- if (!ids || ids.size !== 1) continue; // ambiguous or external → drop
393
- const callee = [...ids][0];
452
+ let callee = null;
453
+ if (ids?.size === 1) callee = [...ids][0];
454
+ else if (!ids) callee = resolveMethodCallee(callName, ident, m.path); // method fallback on a clean miss only
455
+ if (!callee) continue; // ambiguous or external → drop
394
456
  if (callee === oid) continue; // self-recursion not an edge
395
457
  const ckey = `${oid}>${callee}`;
396
458
  if (seenCallSymbol.has(ckey)) continue;
@@ -544,7 +606,7 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
544
606
  // a no-op → byte-identical graph.json/summary.json (the measurement contract).
545
607
  const serves = interfaces?.enabled
546
608
  ? extractServes(modules, { fnId, opts: interfaceOpts(interfaces) })
547
- : { edges: [], routes: [], vocab: [] };
609
+ : { edges: [], routes: [], vocab: [], callsHttp: [] };
548
610
 
549
611
  // Second pass (PLAN_PROSE_INDEX.md) — see the returned `proseIndex` field's comment below.
550
612
  const allIndividuals = attachProseTokens(
@@ -564,7 +626,7 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
564
626
  vocabulary: [
565
627
  { prop: "mgx:importsNamespace", predicate: "imports", note: "module→module; SEON usesComplexType is type→type, so owned (cf. main:dependsOn)" },
566
628
  { prop: "mgx:callsCoarse", predicate: "calls", note: "module→module, import-backed; NOT SEON's method→method invokesMethod" },
567
- { prop: "mgx:callsSymbol", predicate: "callsSymbol", note: "caller fn/method→callee fn/class; symbol-granular, unique-name-resolved (Group A); cf. seon:invokesMethod" },
629
+ { prop: "mgx:callsSymbol", predicate: "callsSymbol", note: "caller fn/method→callee fn/class/method; symbol-granular, unique-name-resolved (Group A; method targets via a tiered simple-name fallback — same-module wins, interface members excluded when one implementation remains); cf. seon:invokesMethod" },
568
630
  { prop: "seon:declaresMethod", predicate: "defines" },
569
631
  { prop: "seon:containsCodeEntity", predicate: "contains" },
570
632
  { prop: "mgx:touchedByCommit", predicate: "touches", note: "owned; seon:history is not a real SEON property (cf. history:isCommittedIn)" },
@@ -617,8 +679,10 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
617
679
  rel("cochange", "mgx:changeCoupledWith", cochangeEdges),
618
680
  rel("reexports", "mgx:reExports", reExportEdges),
619
681
  // GATED (PLAN_UNTYPED_INTERFACES.md): emitted ONLY when the interfaces feature produced
620
- // edges, so the default graph never carries a zero-count `serves` group → byte-identical.
682
+ // edges, so the default graph never carries a zero-count `serves`/`callsHttp` group →
683
+ // byte-identical.
621
684
  ...(serves.edges.length ? [rel("serves", "mgx:serves", serves.edges)] : []),
685
+ ...(serves.callsHttp.length ? [rel("callsHttp", "mgx:callsHttp", serves.callsHttp)] : []),
622
686
  ],
623
687
  individuals: allIndividuals,
624
688
  // Second pass (PLAN_PROSE_INDEX.md): word -> [individual ids], inverted from the
@@ -828,6 +892,10 @@ async function assembleAndWrite(rootDir, { modules, commits, symbolHistory, gene
828
892
  // the same in-memory payload, stamped with the JSON's stat for freshness. Mirrors the
829
893
  // SEONIX_PROSE_INDEX env-gate precedent; unset → no import of node:sqlite, no graph.db,
830
894
  // byte-identical default path. Best-effort: graph.json stays authoritative on any failure.
895
+ // storeMode feeds the A1 summary's `store:` line — "sqlite" ONLY when graph.db was actually
896
+ // (re)built, so an inactive gate (e.g. SEONIX_STORE set in the shell but never EXPORTED) is
897
+ // visible at a glance instead of silently running the JSON single-string path.
898
+ let storeMode = "json";
831
899
  if (process.env.SEONIX_STORE === "sqlite") {
832
900
  try {
833
901
  const { writeStore, storeFileFor } = await import("./store.mjs");
@@ -837,6 +905,7 @@ async function assembleAndWrite(rootDir, { modules, commits, symbolHistory, gene
837
905
  // file, so verifyStore/readPayload-freshness would fail on every fresh v2 store. Mirrors
838
906
  // bin/cli.mjs store_rebuild (which passes sourceText: text).
839
907
  await writeStore(storeFileFor(graphFile), entities, { sourceStat, sourceText: payload });
908
+ storeMode = "sqlite";
840
909
  } catch (e) {
841
910
  process.stderr.write(`seonix: sqlite store build failed (${e?.message || e}) — graph.json is authoritative\n`);
842
911
  }
@@ -848,7 +917,7 @@ async function assembleAndWrite(rootDir, { modules, commits, symbolHistory, gene
848
917
  ? codegraph.renderToolsCatalog(CLI_SCRIPT)
849
918
  : localToolsCatalog(CLI_SCRIPT);
850
919
  await writeFile(join(rootDir, ".seonix", "TOOLS.md"), toolsMd);
851
- return { entities, graphFile, graphBytes };
920
+ return { entities, graphFile, graphBytes, storeMode };
852
921
  }
853
922
 
854
923
  function buildCounts(entities, { modules, languages, commits, proseEnabled, baseMs, historyMs }) {
@@ -886,7 +955,7 @@ export async function indexRepository(repoPath, { python = DEFAULT_PYTHON(), gen
886
955
  const { modules, perLang, commits, symbolHistory, baseMs, historyMs, gitErrors } = r;
887
956
  warnGitErrors(gitErrors, repoPath); // surface any history-pass failure immediately (never fatal)
888
957
  const sessions = await readSessionRecords(repoPath); // recorded chat sessions (.seonix/sessions/*.jsonl), [] when none
889
- const { entities, graphFile, graphBytes } = await assembleAndWrite(repoPath, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions, interfaces });
958
+ const { entities, graphFile, graphBytes, storeMode } = await assembleAndWrite(repoPath, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions, interfaces });
890
959
  // A1 post-index summary: SUMMARY.md + summary.json next to graph.json (NEW files; the
891
960
  // graph.json bytes are untouched). The rendered MD is also printed to stdout by cli.mjs.
892
961
  const summary = buildSummary(entities, {
@@ -901,6 +970,7 @@ export async function indexRepository(repoPath, { python = DEFAULT_PYTHON(), gen
901
970
  skipped: [],
902
971
  languages: perLang,
903
972
  graphBytes,
973
+ store: storeMode,
904
974
  });
905
975
  await writeSummaryArtifacts(dirname(graphFile), summary);
906
976
  return {
@@ -961,7 +1031,8 @@ export function applyRepoPrefix({ modules, commits, symbolHistory }, prefix) {
961
1031
  export function defaultOutRoot(repoPaths, cwd = process.cwd()) {
962
1032
  const segs = repoPaths.map((p) => resolve(p).split(sep));
963
1033
  const first = segs[0];
964
- let depth = Math.min(...segs.map((s) => s.length));
1034
+ let depth = first.length; // running min, not Math.min(...spread) one arg per repo path
1035
+ for (const s of segs) if (s.length < depth) depth = s.length;
965
1036
  let i = 0;
966
1037
  while (i < depth && segs.every((s) => s[i] === first[i])) i += 1;
967
1038
  const ancestor = first.slice(0, i).join(sep) || sep;
@@ -1018,9 +1089,11 @@ export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(),
1018
1089
  const r = await extractRepo(rp, { python, ignores, historyDepth, extraIgnores, languages: languageAllow });
1019
1090
  warnGitErrors(r.gitErrors, prefix); // surface any history-pass failure immediately (never fatal)
1020
1091
  applyRepoPrefix(r, prefix);
1021
- allModules.push(...r.modules);
1022
- allCommits.push(...r.commits);
1023
- allSymbolHistory.push(...r.symbolHistory);
1092
+ // NB: append iteratively — spreading into push() passes one argument per element and
1093
+ // module/commit lists scale with repo size, past V8's argument-count limit on estates.
1094
+ for (const m of r.modules) allModules.push(m);
1095
+ for (const c of r.commits) allCommits.push(c);
1096
+ for (const h of r.symbolHistory) allSymbolHistory.push(h);
1024
1097
  baseMs += r.baseMs;
1025
1098
  historyMs += r.historyMs;
1026
1099
  for (const [lang, s] of Object.entries(r.perLang)) {
@@ -1036,7 +1109,7 @@ export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(),
1036
1109
  // TODO(sessions): multi-repo merges don't fold in the member repos' .seonix/sessions
1037
1110
  // yet — their recorded ids would need the same repo-name prefixing as modules to
1038
1111
  // re-resolve against the merged graph. Deferred; single-path fold-in is the contract.
1039
- const { entities, graphFile, graphBytes } = await assembleAndWrite(root, {
1112
+ const { entities, graphFile, graphBytes, storeMode } = await assembleAndWrite(root, {
1040
1113
  modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled, interfaces,
1041
1114
  });
1042
1115
  // A1 post-index summary (merged): per-repo rows (name = prefix) + any discovery
@@ -1053,6 +1126,7 @@ export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(),
1053
1126
  skipped,
1054
1127
  languages,
1055
1128
  graphBytes,
1129
+ store: storeMode,
1056
1130
  });
1057
1131
  await writeSummaryArtifacts(dirname(graphFile), summary);
1058
1132
  return {
@@ -1188,9 +1262,10 @@ export async function syncRepositories(multiRoot, { python = DEFAULT_PYTHON(), g
1188
1262
  } else {
1189
1263
  ext = await readCache(artifactDir, name); // reused post-prefix extraction
1190
1264
  }
1191
- allModules.push(...ext.modules);
1192
- allCommits.push(...ext.commits);
1193
- allSymbolHistory.push(...ext.symbolHistory);
1265
+ // NB: append iteratively — same repo-scaled-spread hazard as indexRepositories above.
1266
+ for (const m of ext.modules) allModules.push(m);
1267
+ for (const c of ext.commits) allCommits.push(c);
1268
+ for (const h of ext.symbolHistory) allSymbolHistory.push(h);
1194
1269
  for (const [lang, s] of Object.entries(ext.perLang || {})) {
1195
1270
  const agg = languages[lang] || (languages[lang] = { lib: s.lib, files: 0, modules: 0, symbols: 0, failures: 0, ms: 0 });
1196
1271
  agg.files += s.files; agg.modules += s.modules; agg.symbols += s.symbols; agg.failures += s.failures; agg.ms += s.ms;
@@ -1208,10 +1283,10 @@ export async function syncRepositories(multiRoot, { python = DEFAULT_PYTHON(), g
1208
1283
  for (const r of diff.removed) await rm(cachePath(artifactDir, r.name), { force: true }).catch(() => {});
1209
1284
 
1210
1285
  // ONE buildEntities over the union (cached + fresh) → the exact full-re-index graph.
1211
- const { entities, graphFile, graphBytes } = await assembleAndWrite(root, {
1286
+ const { entities, graphFile, graphBytes, storeMode } = await assembleAndWrite(root, {
1212
1287
  modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled, interfaces,
1213
1288
  });
1214
- const summary = buildSummary(entities, { mode: "multi", repos: repoRows, skipped: [], languages, graphBytes });
1289
+ const summary = buildSummary(entities, { mode: "multi", repos: repoRows, skipped: [], languages, graphBytes, store: storeMode });
1215
1290
  await writeSummaryArtifacts(dirname(graphFile), summary);
1216
1291
  await writeManifest(artifactDir, { format: 1, generated_at: generatedAt, repos: manifestRows });
1217
1292
 
@@ -66,9 +66,11 @@ export async function ingestRepo(root, { cs, java, ignore = null } = {}) {
66
66
  const { modules, failures, fileCount } = await extractor.ingest(root, { ignore });
67
67
  const ms = Date.now() - t0;
68
68
  if (fileCount === 0) continue; // language not present
69
- allModules.push(...modules);
69
+ // NB: append iteratively — spreading into push() passes one argument per module and
70
+ // per-language module counts hit ~20k+ on estates, past V8's argument-count limit.
71
+ for (const m of modules) allModules.push(m);
70
72
  totalFiles += fileCount;
71
- allFailures.push(...failures);
73
+ for (const f of failures) allFailures.push(f);
72
74
  const symbols = modules.reduce((n, m) => n + (m.defines?.length || 0), 0);
73
75
  perLang[lang] = { lib: extractor.meta.lib, files: fileCount, modules: modules.length, symbols, failures: failures.length, ms };
74
76
  }