@polycode-projects/seonix 0.9.1 → 0.10.1

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.
@@ -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
  }
@@ -18,7 +18,9 @@ export function interfaceOpts(cfg = {}) {
18
18
  verbs: cfg.verbs && cfg.verbs.length ? cfg.verbs.map((s) => String(s).toLowerCase()) : VERBS,
19
19
  combineControllerPrefix: cfg.combineControllerPrefix !== false, // default true
20
20
  prefixStrip: (cfg.prefixStrip || []).map((s) => normPath(String(s))),
21
- // Forward-compat: the confidence threshold the future callsHttp client↔route matcher will tune.
21
+ // Minimum matchScore (see below) for a callsHttp client↔route link. 1.0 = exact-length
22
+ // matches only; the 0.5 default also admits exact-prefix overlaps (e.g. a client URL with
23
+ // extra trailing segments over a shorter route template).
22
24
  matchThreshold: typeof cfg.matchThreshold === "number" ? cfg.matchThreshold : 0.5,
23
25
  };
24
26
  }
@@ -64,21 +66,28 @@ function parseCsAttr(attr) {
64
66
  }
65
67
 
66
68
  /** C# controller routes from a module's defines[] (Roslyn emits `[Http*]`/`[Route]` in decorators).
67
- * Methods are named "Class.Method"; the class-level [Route] prefix is combined per opts. */
69
+ * Methods are named "Class.Method". ASP.NET semantics honoured here:
70
+ * - EVERY class-level `[Route("…")]` is a prefix (a controller can declare several — one route per
71
+ * prefix × method mapping, document order, deterministic).
72
+ * - A method-level verb attr with its own template (`[HttpGet("{id}")]`) uses that template.
73
+ * - A verb attr with NO template (`[HttpPost]`) takes the method-level verb-less `[Route("…")]`
74
+ * template(s) as its suffix — the common `[HttpPost] + [Route("process")]` pattern; with no
75
+ * method [Route] either, the mapping is just the prefix. */
68
76
  function csRoutes(mod, opts) {
69
77
  const out = [];
70
78
  const defines = mod.defines || [];
71
- // class name → { prefix, isController } from class-level [Route]/[ApiController]
79
+ // class name → { prefixes, isController } from class-level [Route]/[ApiController]
72
80
  const classInfo = new Map();
73
81
  for (const d of defines) {
74
82
  if (d.kind !== "class") continue;
75
- let prefix = "", isController = /controller$/i.test(d.name);
83
+ const prefixes = [];
84
+ let isController = /controller$/i.test(d.name);
76
85
  for (const a of d.decorators || []) {
77
86
  const p = parseCsAttr(a);
78
- if (p && p.verb === null && p.path) prefix = p.path;
87
+ if (p && p.verb === null && p.path) prefixes.push(p.path);
79
88
  if (/^apicontroller/i.test(String(a).trim())) isController = true;
80
89
  }
81
- classInfo.set(d.name, { prefix, isController });
90
+ classInfo.set(d.name, { prefixes, isController });
82
91
  }
83
92
  for (const d of defines) {
84
93
  if (d.kind !== "method") continue;
@@ -87,14 +96,27 @@ function csRoutes(mod, opts) {
87
96
  const methodName = dot >= 0 ? d.name.slice(dot + 1) : d.name;
88
97
  const info = classInfo.get(className);
89
98
  const controller = className.replace(/Controller$/i, "");
99
+ // First pass over the method's attributes: verb mappings + verb-less [Route] templates.
100
+ const verbAttrs = [];
101
+ const routeTemplates = [];
90
102
  for (const a of d.decorators || []) {
91
103
  const p = parseCsAttr(a);
92
- if (!p || p.verb === null) continue; // only [Http*] maps a method to a verb
104
+ if (!p) continue;
105
+ if (p.verb === null) { if (p.path) routeTemplates.push(p.path); continue; }
93
106
  if (!opts.verbs.includes(p.verb)) continue;
94
- const prefix = opts.combineControllerPrefix ? (info?.prefix || "") : "";
95
- let path = subTokens(joinRoute(prefix, p.path), controller, methodName);
96
- path = stripPrefixes(path, opts.prefixStrip);
97
- out.push({ verb: p.verb.toUpperCase(), path, handler: d.name });
107
+ verbAttrs.push(p);
108
+ }
109
+ if (!verbAttrs.length) continue; // only [Http*] maps a method to a verb
110
+ const prefixes = opts.combineControllerPrefix && info?.prefixes.length ? info.prefixes : [""];
111
+ for (const p of verbAttrs) {
112
+ const suffixes = p.path ? [p.path] : (routeTemplates.length ? routeTemplates : [""]);
113
+ for (const prefix of prefixes) {
114
+ for (const suffix of suffixes) {
115
+ let path = subTokens(joinRoute(prefix, suffix), controller, methodName);
116
+ path = stripPrefixes(path, opts.prefixStrip);
117
+ out.push({ verb: p.verb.toUpperCase(), path, handler: d.name });
118
+ }
119
+ }
98
120
  }
99
121
  }
100
122
  return out;
@@ -124,16 +146,46 @@ function stripPrefixes(path, prefixes) {
124
146
  const isCs = (p) => /\.cs$/i.test(p || "");
125
147
  const isJsTs = (p) => /\.(m?[jt]sx?)$/i.test(p || "");
126
148
 
149
+ /** Normalise a URL / route template into match segments (the deterministic half of callsHttp):
150
+ * scheme+host stripped, query/hash dropped, duplicate slashes collapsed, and any parameter-ish
151
+ * segment — `{id}` / `{id:int}` (ASP.NET, also a C# interpolation hole after quote-stripping),
152
+ * `${expr}` / `{*}` (JS interpolation holes), `:id` (Express) — collapsed to a `*` wildcard.
153
+ * Literal segments compare case-insensitively (ASP.NET routing is case-insensitive).
154
+ * Exported for the matcher tests; pure. */
155
+ export function normalizeHttpTemplate(url) {
156
+ let u = String(url || "").trim();
157
+ u = u.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, ""); // scheme://host[:port]
158
+ u = u.split(/[?#]/)[0];
159
+ u = normPath(u);
160
+ if (!u) return [];
161
+ return u.split("/").map((seg) => (/[{}]|^:/.test(seg) ? "*" : seg.toLowerCase()));
162
+ }
163
+
164
+ /** Deterministic template match: segment-by-segment prefix discipline (`*` matches any single
165
+ * segment; the first literal mismatch is fatal), scored 2·shared/(|url|+|route|). 1.0 is an
166
+ * exact-length match; an exact-prefix overlap scores below 1 and is admitted (or not) by the
167
+ * match_threshold knob. No embeddings, no fuzzy scoring beyond this normalisation. */
168
+ function matchScore(a, b) {
169
+ if (!a.length || !b.length) return 0;
170
+ const n = Math.min(a.length, b.length);
171
+ for (let i = 0; i < n; i += 1) {
172
+ if (a[i] !== b[i] && a[i] !== "*" && b[i] !== "*") return 0;
173
+ }
174
+ return (2 * n) / (a.length + b.length);
175
+ }
176
+
127
177
  /**
128
178
  * Build the `serves` edges + `Route` individuals for a set of modules.
129
179
  * @param modules the same module records buildEntities consumes (path, defines[], routes[]).
130
180
  * @param fnId the id-builder buildEntities uses: (path, symbolName) → "fn:path#name".
131
181
  * @param opts from interfaceOpts().
132
- * @returns { edges, routes } — edges are {subject,object,subjectLabel,objectLabel}; routes are
133
- * Route individuals. Both deterministically ordered; empty when nothing matched.
182
+ * @returns { edges, routes, callsHttp } — edges/callsHttp are {subject,object,subjectLabel,
183
+ * objectLabel}; routes are Route individuals. All deterministically ordered; empty when
184
+ * nothing matched.
134
185
  */
135
186
  export function extractServes(modules, { fnId, opts }) {
136
187
  const routeById = new Map(); // route id → individual (deduped across modules/methods)
188
+ const routeMeta = []; // route id → {verb, segs} for the callsHttp matcher below
137
189
  const edgeSet = new Set(); // dedupe subject|object
138
190
  const edges = [];
139
191
  for (const mod of modules || []) {
@@ -143,7 +195,7 @@ export function extractServes(modules, { fnId, opts }) {
143
195
  for (const r of routes) {
144
196
  const label = `${r.verb} ${r.path}`;
145
197
  const rid = `route:${label}`;
146
- if (!routeById.has(rid))
198
+ if (!routeById.has(rid)) {
147
199
  routeById.set(rid, {
148
200
  id: rid, label, class: "Route", derived_from: [], mentions: [],
149
201
  attributes: [
@@ -151,6 +203,8 @@ export function extractServes(modules, { fnId, opts }) {
151
203
  { prop: "mgx:routePath", key: "path", value: r.path },
152
204
  ],
153
205
  });
206
+ routeMeta.push({ id: rid, label, verb: r.verb, segs: normalizeHttpTemplate(r.path) });
207
+ }
154
208
  const object = fnId(mod.path, r.handler);
155
209
  const key = `${rid}|${object}`;
156
210
  if (edgeSet.has(key)) continue;
@@ -158,8 +212,47 @@ export function extractServes(modules, { fnId, opts }) {
158
212
  edges.push({ subject: rid, object, subjectLabel: label, objectLabel: r.handler });
159
213
  }
160
214
  }
161
- edges.sort((a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : a.object < b.object ? -1 : a.object > b.object ? 1 : 0));
215
+ const byEdge = (a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : a.object < b.object ? -1 : a.object > b.object ? 1 : 0);
216
+ edges.sort(byEdge);
162
217
  const routes = [...routeById.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
218
+
219
+ // callsHttp (client→route) — the other half of the cross-service chain, PLAN_UNTYPED_INTERFACES
220
+ // follow-up #1. Client call sites come pre-captured from the extractors as an optional per-define
221
+ // `http_calls: [{verb, url}]` (fetch/axios/HttpClient first-argument URL literals). Each call is
222
+ // matched against the Route table just built: verbs must agree, templates match per matchScore
223
+ // above, and only the BEST-scoring route(s) at or above opts.matchThreshold link (ties all link —
224
+ // deterministic). A call matching nothing emits nothing; no orphan Route individuals are created,
225
+ // so callsHttp can only point at Routes that `serves` already justified.
226
+ const callsHttp = [];
227
+ const chSeen = new Set();
228
+ for (const mod of modules || []) {
229
+ for (const d of mod.defines || []) {
230
+ for (const c of d.http_calls || []) {
231
+ const verb = String(c.verb || "").toUpperCase();
232
+ const segs = normalizeHttpTemplate(c.url);
233
+ if (!segs.length) continue;
234
+ // No anchor, no edge: an all-wildcard template (`{base}{uri}/{id}`) would match ANY
235
+ // same-length route at 1.0 — at least one literal segment is required (house "no wrong edge").
236
+ if (!segs.some((s) => s !== "*")) continue;
237
+ let best = 0;
238
+ let hits = [];
239
+ for (const r of routeMeta) {
240
+ if (r.verb !== verb) continue;
241
+ const s = matchScore(segs, r.segs);
242
+ if (s < opts.matchThreshold || s < best) continue;
243
+ if (s > best) { best = s; hits = [r]; } else hits.push(r);
244
+ }
245
+ const subject = fnId(mod.path, d.name);
246
+ for (const r of hits) {
247
+ const key = `${subject}|${r.id}`;
248
+ if (chSeen.has(key)) continue;
249
+ chSeen.add(key);
250
+ callsHttp.push({ subject, object: r.id, subjectLabel: d.name, objectLabel: r.label });
251
+ }
252
+ }
253
+ }
254
+ }
255
+ callsHttp.sort(byEdge);
163
256
  // Vocabulary notes for the emitted tokens, built HERE (not in extract.mjs) so the un-typed-interface
164
257
  // props stay scoped to this gated feature module — the core schema drift guard documents the always
165
258
  // -on extract.mjs props; these opt-in props are self-documented inline until they earn a
@@ -167,5 +260,6 @@ export function extractServes(modules, { fnId, opts }) {
167
260
  const vocab = [];
168
261
  if (edges.length) vocab.push({ prop: "mgx:serves", predicate: "serves", note: "route-declaration→handler-symbol; the HTTP boundary a syntax-only AST misses (un-typed interface)" });
169
262
  if (routes.length) vocab.push({ prop: "mgx:httpVerb", note: "HTTP verb of a Route individual" }, { prop: "mgx:routePath", note: "normalised path template of a Route individual" });
170
- return { edges, routes, vocab };
263
+ if (callsHttp.length) vocab.push({ prop: "mgx:callsHttp", predicate: "callsHttp", note: "client call-site→Route; HTTP URL literal matched to a route template (deterministic normalisation, match_threshold-gated)" });
264
+ return { edges, routes, vocab, callsHttp };
171
265
  }
package/src/jsts_tsc.mjs CHANGED
@@ -66,6 +66,55 @@ function decoratorsOf(sf, node) {
66
66
  return (ds || []).map((d) => d.expression.getText(sf).replace(/\s+/g, " ").slice(0, 80));
67
67
  }
68
68
 
69
+ // ---- HTTP-client call capture (un-typed interfaces: the callsHttp client→route half). ----
70
+ // The URL literal at a client call site, per define — the base calls[] strips arguments, so the
71
+ // route-matchable string must be captured here. Deterministic name gates, no resolution:
72
+ // fetch('/api/x' [, {method:'POST'}]) → verb from a literal `method:` property, else get
73
+ // <recv>.get|post|…('/api/x', …) where recv's text looks like an HTTP client (axios / http /
74
+ // client / api / request) — so Express-style registrations (router./app. receivers) and
75
+ // Map.get('key') never match.
76
+ // The URL must be a string/template literal containing "/"; template interpolation holes become
77
+ // `{*}` (collapsed to a wildcard segment by the matcher in interfaces.mjs). Emitted as an optional
78
+ // `http_calls` field on the define — absent when empty, so modules without client calls are
79
+ // byte-identical to before, and the DEFAULT graph ignores the field entirely (gating contract).
80
+ const CLIENTISH = /axios|http|client|api|request/i;
81
+ function urlLiteralText(sf, a) {
82
+ if (!a) return "";
83
+ if (ts.isStringLiteralLike(a)) return a.text; // 'x' / "x" / `x` (no holes)
84
+ if (ts.isTemplateExpression(a)) return a.head.text + a.templateSpans.map((s) => `{*}${s.literal.text}`).join("");
85
+ return "";
86
+ }
87
+ function httpCallsIn(sf, node) {
88
+ const out = [];
89
+ const seen = new Set();
90
+ const visit = (n) => {
91
+ if (ts.isCallExpression(n)) {
92
+ const t = n.expression;
93
+ let verb = "";
94
+ if ((ts.isIdentifier(t) && t.text === "fetch") || (ts.isPropertyAccessExpression(t) && t.name.text === "fetch")) {
95
+ verb = "get"; // fetch defaults to GET; a literal {method:'POST'} option overrides
96
+ const opt = n.arguments[1];
97
+ if (opt && ts.isObjectLiteralExpression(opt)) {
98
+ for (const p of opt.properties) {
99
+ if (ts.isPropertyAssignment(p) && p.name && p.name.getText(sf) === "method" && ts.isStringLiteralLike(p.initializer))
100
+ verb = p.initializer.text.toLowerCase();
101
+ }
102
+ }
103
+ } else if (ts.isPropertyAccessExpression(t) && ROUTE_VERBS.has(t.name.text.toLowerCase()) && CLIENTISH.test(t.expression.getText(sf))) {
104
+ verb = t.name.text.toLowerCase();
105
+ }
106
+ const url = verb ? urlLiteralText(sf, n.arguments[0]) : "";
107
+ if (ROUTE_VERBS.has(verb) && url.includes("/")) {
108
+ const key = `${verb} ${url}`;
109
+ if (!seen.has(key)) { seen.add(key); out.push({ verb, url: url.slice(0, 160) }); }
110
+ }
111
+ }
112
+ ts.forEachChild(n, visit);
113
+ };
114
+ ts.forEachChild(node, visit);
115
+ return out;
116
+ }
117
+
69
118
  /** Collect callee names within a node body (coarse; unparsed call target). */
70
119
  function callsIn(sf, node) {
71
120
  const out = new Set();
@@ -129,12 +178,14 @@ export async function extractFile(absPath, root) {
129
178
  const isExportsRef = (e) => isModuleExports(e) || (ts.isIdentifier(e) && e.text === "exports");
130
179
 
131
180
  const addFn = (name, node, kind, extra = {}) => {
181
+ const http = httpCallsIn(sf, node);
132
182
  defines.push({
133
183
  name, kind, lineno: lineOf(sf, node), end_lineno: endLineOf(sf, node),
134
184
  decorators: decoratorsOf(sf, node),
135
185
  params: paramsText(sf, node), returns: returnText(sf, node),
136
186
  calls: callsIn(sf, node), doc: firstDocLine(sf, node),
137
187
  ...(visibilityOf(node) ? { visibility: visibilityOf(node) } : {}),
188
+ ...(http.length ? { http_calls: http } : {}),
138
189
  ...extra,
139
190
  });
140
191
  };