@polycode-projects/seonix 0.7.3 → 0.9.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
@@ -45,6 +45,7 @@ const PROP_KIND = {
45
45
  "mgx:reexports": "reexports",
46
46
  "mgx:touchessymbol": "touchesSymbol",
47
47
  "mgx:callssymbol": "callsSymbol",
48
+ "mgx:serves": "serves", // un-typed interfaces (gated); inert on a default graph
48
49
  };
49
50
 
50
51
  const pathOfId = (id) => {
@@ -0,0 +1,37 @@
1
+ // chat-shim.mjs — `seonix chat` is now tmct's chat over seonix's graph.
2
+ //
3
+ // PLAN_CHAT_EXTRACTION.md, the inversion: the conversational surface belongs to tmct
4
+ // (@polycode-projects/the-mechanical-code-talker). seonix stops running its own REPL and
5
+ // launches tmct's, handing it graph truth through tmct's provider seam. seonix's on-disk
6
+ // graph is interned v2, which tmct cannot read raw, so we expand it first (seonix's own
7
+ // parseEntities does the same). The old opt-in --with-claude/--with-copilot fallback does
8
+ // not port: tmct is no-LLM by contract.
9
+ import { runChat } from "@polycode-projects/the-mechanical-code-talker/chat";
10
+ import { registerProvider } from "@polycode-projects/the-mechanical-code-talker/fetchEntities";
11
+ import { join } from "node:path";
12
+ import { expandGraphPayload } from "./graph-format.mjs";
13
+ import { fetchEntities } from "./source.mjs";
14
+ import { loadConfig, DEFAULT_GRAPH_REL } from "./config.mjs";
15
+
16
+ /** The seonix graph config for a repo (mirrors cli.mjs's configFor): an explicit
17
+ * --repo dir points at its .seonix/graph.json; otherwise the loaded (cwd) config. */
18
+ function seonixConfigFor(repoPath) {
19
+ return repoPath ? { graphFile: join(repoPath, DEFAULT_GRAPH_REL) } : loadConfig();
20
+ }
21
+
22
+ /**
23
+ * Run tmct's chat over seonix's native graph. Registers the (expanded) seonix graph as
24
+ * tmct's in-process data provider, runs the REPL, then clears the provider so nothing
25
+ * leaks across calls (important for tests and repeated invocations).
26
+ * @param {{repoPath?: string, input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream,
27
+ * env?: object, cwd?: string, gitRoot?: Function}} [opts]
28
+ */
29
+ export async function runSeonixChat({ repoPath, input, output, env, cwd, gitRoot } = {}) {
30
+ const payload = expandGraphPayload(await fetchEntities(seonixConfigFor(repoPath)));
31
+ registerProvider(() => payload);
32
+ try {
33
+ return await runChat({ repoPath, input, output, env, cwd, gitRoot });
34
+ } finally {
35
+ registerProvider(null);
36
+ }
37
+ }
package/src/codegraph.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { lookupByProseTokens, proseLayerHits } from "./prose.mjs";
2
2
  import { cosine } from "./embed.mjs";
3
3
  import { expandGraphPayload } from "./graph-format.mjs";
4
+ import { isTestLabel, isNonProdLabel, NONPROD_DEMOTE, hubDemote } from "./paths.mjs";
4
5
 
5
6
  // Pure (no-network, no-fs) query logic over the typed `entities` payload that the
6
7
  // deterministic indexer writes to <repo>/.seonix/graph.json (shape produced by
@@ -58,7 +59,7 @@ export function parseEntities(payload) {
58
59
  truncated,
59
60
  generatedAt: payload?.generated_at || null,
60
61
  // Second pass (PLAN_PROSE_INDEX.md): word -> [individual ids], passed through
61
- // byte-identical from the payload so ask.mjs's resolveObject can consult it as a
62
+ // byte-identical from the payload so tmct's resolveObject can consult it as a
62
63
  // fallback tier without reaching back into the raw payload itself. {} when the
63
64
  // build had prose disabled or the payload predates this field.
64
65
  proseIndex: payload?.proseIndex || {},
@@ -88,6 +89,9 @@ const PROP_KIND = {
88
89
  // closure (module-coarse) is unchanged.
89
90
  "mgx:touchessymbol": "touchesSymbol",
90
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.
94
+ "mgx:serves": "serves",
91
95
  // legacy tokens (pre-realign graphs) — kept so a stale artifact still classifies
92
96
  "seon:usescomplextype": "imports",
93
97
  "seon:invokesmethod": "calls",
@@ -428,15 +432,8 @@ const SYM_MATCH_CAP = 4; // only the top-K highest-IDF symbol-COMPONENT hits c
428
432
  // bag-of-symbols module (e.g. db/backends features) can't accrete noise
429
433
  const PROX_FRAC = 0.2; // import-adjacency bonus = this × the strongest matched neighbour …
430
434
  const PROX_CAP_FRAC = 0.35; // … capped at this × the module's own score (a nudge — hubs can't run away)
431
- const isTestLabel = (s) => /(^|\/)tests?\//.test(s) || /(^|\/)test_[^/]*\.py$/.test(s) || /\.tests(\.|$)/.test(s);
432
- // B016 R1a (opt-in via demoteNonProd): non-production paths examples, fixtures, sample/demo
433
- // apps, and test-* harness packages — share path/symbol vocabulary with the production module
434
- // and shadow it in locate (B015: js-express injected examples/route-middleware/index.js at
435
- // rank 1; java-gson's TOP2 slot 2 was a test-shrinker fixture). DEMOTED, not excluded: none of
436
- // the B015 truths live under these paths (checked corpus/instances-*/…/spec.json 2026-07-02),
437
- // but a future task whose truth IS a test/example file must stay reachable.
438
- const NONPROD_DEMOTE = 0.15;
439
- const isNonProdLabel = (s) => /(^|\/)(examples?|fixtures?|samples?|demos?|benchmarks?|test-[^/]+)(\/|$)/.test(s);
435
+ // isTestLabel / isNonProdLabel / NONPROD_DEMOTE now live in ./paths.mjs (shared with summary.mjs
436
+ // so the two hub-ranking sites can't drift); imported at the top of this module.
440
437
  // B016 E1a (opt-in via callAdjacency): resolved-call adjacency, same bounded-nudge shape as the
441
438
  // import-proximity bonus. Python graphs carry call edges (django: 993 calls / 23,596 callsSymbol);
442
439
  // the syntax-level C#/Java extractors emit ~none today, so this flag is Python-value only.
@@ -1141,7 +1138,7 @@ export function selectRankedModules(ranked, { top_k = 2, scoreGapK = null } = {}
1141
1138
  return picked;
1142
1139
  }
1143
1140
 
1144
- export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "" } = {}) {
1141
+ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", decorator = "", name = "", page = 0 } = {}) {
1145
1142
  const tokens = String(query || "").toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
1146
1143
  const wantKind = String(kind || "").trim().toLowerCase();
1147
1144
  const decFilter = String(decorator || "").trim().toLowerCase();
@@ -1159,12 +1156,24 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1159
1156
  if (!scored.length) {
1160
1157
  return `no module matches "${query}". Try broader keywords, or seonix_describe <path> if you know where it lives.`;
1161
1158
  }
1162
- const hits = scored.slice(0, limit);
1163
- const lines = [`${scored.length} module(s) match "${query}" (top ${hits.length}):`];
1164
- for (const { ind, defineCount, matching } of hits) {
1159
+ const renderRows = (rows) => rows.map(({ ind, defineCount, matching }) => {
1165
1160
  const m = matching.length ? ` — matching: ${capJoin([...new Set(matching)], SEARCH_SYMBOLS_SHOWN)}` : "";
1166
- lines.push(`- ${ind.label} (defines ${defineCount} symbol(s))${m}`);
1161
+ return `- ${ind.label} (defines ${defineCount} symbol(s))`.concat(m);
1162
+ });
1163
+ // C3 (copilot-UX): opt-in pagination. `page` absent (0) → today's "top N" output, byte-identical.
1164
+ // page>=1 → a stable window + a "call again with page N+1" trailer for large result sets.
1165
+ if (page >= 1) {
1166
+ const pages = Math.max(1, Math.ceil(scored.length / limit));
1167
+ const p = Math.min(page, pages);
1168
+ const hits = scored.slice((p - 1) * limit, p * limit);
1169
+ const lines = [`${scored.length} module(s) match "${query}" (page ${p}/${pages}, showing ${hits.length}):`, ...renderRows(hits)];
1170
+ lines.push(p < pages
1171
+ ? `… ${scored.length - p * limit} more — call seonix_search again with page: ${p + 1}.`
1172
+ : "Then seonix_describe <path> for the full sibling list + typed edges, or seonix_impact <path> for dependents.");
1173
+ return lines.join("\n");
1167
1174
  }
1175
+ const hits = scored.slice(0, limit);
1176
+ const lines = [`${scored.length} module(s) match "${query}" (top ${hits.length}):`, ...renderRows(hits)];
1168
1177
  lines.push("Then seonix_describe <path> for the full sibling list + typed edges, or seonix_impact <path> for dependents.");
1169
1178
  return lines.join("\n");
1170
1179
  }
@@ -1176,7 +1185,7 @@ export function renderSearch(graph, query, { limit = SEARCH_LIMIT, kind = "", de
1176
1185
  /** All edges whose relation classifies to `kind`, flattened across relation groups. */
1177
1186
  /** All edges of a classified relation kind (imports/calls/defines/tests/touches/inherits/
1178
1187
  * cochange/reexports/callsSymbol/touchesSymbol/contains — see relationKind/PROP_KIND above),
1179
- * flattened across every raw relation group that classifies to it. Exported for ask.mjs's
1188
+ * flattened across every raw relation group that classifies to it. Exported for tmct's
1180
1189
  * mechanical NL-query engine (PLAN_MECHANICAL_CHAT.md) to orchestrate rather than duplicate. */
1181
1190
  export function edgesOfKind(graph, kind) {
1182
1191
  const out = [];
@@ -1344,9 +1353,13 @@ export function renderArchitecture(graph, { pkg = "" } = {}) {
1344
1353
  const inDeg = new Map();
1345
1354
  for (const e of edgesOfKind(graph, "imports")) inDeg.set(e.object, (inDeg.get(e.object) || 0) + 1);
1346
1355
  const modSet = new Set(modules.map((m) => m.id));
1356
+ // Sort by importer count DOWN-WEIGHTED for test/non-prod paths (0.8.1) so a shared test harness
1357
+ // or fixtures package can't dominate the hub view — the wh estate's E2E harness was the top
1358
+ // "hub" purely as a test-import sink. The displayed count stays the raw importer total.
1347
1359
  const hubs = [...inDeg.entries()]
1348
1360
  .filter(([id]) => modSet.has(id))
1349
- .sort((a, b) => b[1] - a[1])
1361
+ .map(([id, n]) => [id, n, n * hubDemote(graph.byId.get(id)?.label || id)])
1362
+ .sort((a, b) => b[2] - a[2])
1350
1363
  .slice(0, ARCH_HUB_CAP)
1351
1364
  .map(([id, n]) => `${graph.byId.get(id)?.label || id} (${n} importers)`);
1352
1365
  const pkgs = [...pkgCount.entries()].sort((a, b) => b[1] - a[1]);
package/src/extract.mjs CHANGED
@@ -27,6 +27,7 @@ import { loadIgnores } from "./walk.mjs";
27
27
  import { attachProseTokens, buildProseIndex } from "./prose.mjs";
28
28
  import { ingestSchemaDocs } from "./schema-docs.mjs";
29
29
  import { foldInSessions, readSessionRecords } from "./sessions.mjs";
30
+ import { extractServes, interfaceOpts } from "./interfaces.mjs";
30
31
  import { encodeGraphV2 } from "./graph-format.mjs";
31
32
  import { buildSummary, writeSummaryArtifacts } from "./summary.mjs";
32
33
  import { fingerprintRepo, readManifest, writeManifest, diffManifest } from "./manifest.mjs";
@@ -216,7 +217,7 @@ async function runGitLogHunks(repoPath, depth = historySymbolDepth()) {
216
217
  /** Build the `entities` payload from the parsed modules + git history.
217
218
  * `symbolHistory` (optional) is the runGitLogHunks() output — per-commit changed
218
219
  * line ranges, intersected with symbol spans to emit mgx:touchesSymbol edges. */
219
- export function buildEntities(modules, commits, { generatedAt = "", symbolHistory = [], prose = true } = {}) {
220
+ export function buildEntities(modules, commits, { generatedAt = "", symbolHistory = [], prose = true, interfaces = null } = {}) {
220
221
  const modById = new Map(); // path -> module record
221
222
  const dottedToPath = new Map(); // dotted module name -> path
222
223
  const nameToPaths = new Map(); // top-level symbol name -> Set<path> (for call resolution)
@@ -538,9 +539,16 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
538
539
  const countClass = (c) => fnIndividuals.filter((i) => i.class === c).length;
539
540
  const sampleClass = (c) => fnIndividuals.filter((i) => i.class === c).slice(0, 3).map((i) => i.label);
540
541
 
542
+ // Un-typed interfaces (PLAN_UNTYPED_INTERFACES.md, GATED): route→handler `serves` edges + Route
543
+ // individuals, ONLY when [interfaces] is enabled. Disabled → empty arrays → every spread below is
544
+ // a no-op → byte-identical graph.json/summary.json (the measurement contract).
545
+ const serves = interfaces?.enabled
546
+ ? extractServes(modules, { fnId, opts: interfaceOpts(interfaces) })
547
+ : { edges: [], routes: [], vocab: [] };
548
+
541
549
  // Second pass (PLAN_PROSE_INDEX.md) — see the returned `proseIndex` field's comment below.
542
550
  const allIndividuals = attachProseTokens(
543
- [...moduleIndividuals, ...fnIndividuals, ...commitIndividuals], { enabled: prose },
551
+ [...moduleIndividuals, ...fnIndividuals, ...serves.routes, ...commitIndividuals], { enabled: prose },
544
552
  );
545
553
  const proseIndex = prose ? buildProseIndex(allIndividuals) : {};
546
554
 
@@ -581,9 +589,14 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
581
589
  { prop: "seon:subKind", note: "type flavour on a Class define when not a plain class (interface/enum/struct/record); kind stays class" },
582
590
  { prop: "seon:hasAccessModifier", note: "visibility from leading underscore (private/protected); public omitted" },
583
591
  { prop: "seon:hasDoc", note: "first docstring line (capped) — one-line purpose without the body" },
592
+ // GATED (PLAN_UNTYPED_INTERFACES.md): the un-typed-interface vocab is built in interfaces.mjs
593
+ // and spread here (empty when OFF → byte-identical; the prop tokens are scoped to that module,
594
+ // outside the core extract.mjs schema drift guard).
595
+ ...serves.vocab,
584
596
  ],
585
597
  classes: [
586
598
  { name: "Module", count: moduleIndividuals.length, sample: moduleIndividuals.slice(0, 3).map((i) => i.label) },
599
+ ...(serves.routes.length ? [{ name: "Route", count: serves.routes.length, sample: serves.routes.slice(0, 3).map((i) => i.label) }] : []),
587
600
  { name: "Class", count: countClass("Class"), sample: sampleClass("Class") },
588
601
  { name: "Function", count: countClass("Function"), sample: sampleClass("Function") },
589
602
  { name: "Method", count: countClass("Method"), sample: sampleClass("Method") },
@@ -603,6 +616,9 @@ export function buildEntities(modules, commits, { generatedAt = "", symbolHistor
603
616
  rel("inherits", "seon:hasSuperType", inheritsEdges),
604
617
  rel("cochange", "mgx:changeCoupledWith", cochangeEdges),
605
618
  rel("reexports", "mgx:reExports", reExportEdges),
619
+ // 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.
621
+ ...(serves.edges.length ? [rel("serves", "mgx:serves", serves.edges)] : []),
606
622
  ],
607
623
  individuals: allIndividuals,
608
624
  // Second pass (PLAN_PROSE_INDEX.md): word -> [individual ids], inverted from the
@@ -775,8 +791,15 @@ async function extractRepo(repoPath, { python = DEFAULT_PYTHON(), ignores = true
775
791
 
776
792
  /** Assemble entities from (possibly merged) extractions and write the artifacts
777
793
  * (<rootDir>/.seonix/graph.json + TOOLS.md). Shared by both index modes. */
778
- async function assembleAndWrite(rootDir, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions = [] }) {
779
- const entities = buildEntities(modules, commits, { generatedAt, symbolHistory, prose: proseEnabled });
794
+ async function assembleAndWrite(rootDir, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions = [], interfaces = null }) {
795
+ // Un-typed interfaces gate (PLAN_UNTYPED_INTERFACES.md): SEONIX_INTERFACES env wins both ways,
796
+ // else the seonix.toml [interfaces].enabled flag; default OFF → byte-identical. Same precedence
797
+ // shape as SEONIX_STORE / SEONIX_GRAPH_FORMAT below.
798
+ const interfacesEnabled = process.env.SEONIX_INTERFACES === "1" ? true
799
+ : process.env.SEONIX_INTERFACES === "0" ? false
800
+ : !!(interfaces && interfaces.enabled);
801
+ const interfacesCfg = interfacesEnabled ? { ...(interfaces || {}), enabled: true } : null;
802
+ const entities = buildEntities(modules, commits, { generatedAt, symbolHistory, prose: proseEnabled, interfaces: interfacesCfg });
780
803
  // Schema self-documentation (schema-docs.mjs): static, repo-independent — merges in
781
804
  // SchemaClass/SchemaPredicate individuals + backfills entities.classes[].description
782
805
  // and entities.vocabulary[].note, so "what does cochange mean" is answerable by the
@@ -855,7 +878,7 @@ function buildCounts(entities, { modules, languages, commits, proseEnabled, base
855
878
  * symbol-history (line-range) pass so the latter can be kept within the ≤10% budget.
856
879
  * @returns {Promise<{graphFile, counts}>}
857
880
  */
858
- export async function indexRepository(repoPath, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, historyDepth, extraIgnores = null, languages } = {}) {
881
+ export async function indexRepository(repoPath, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, historyDepth, extraIgnores = null, languages, interfaces = null } = {}) {
859
882
  // Second pass (PLAN_PROSE_INDEX.md): on by default; SEONIX_PROSE_INDEX=0 disables it
860
883
  // (mirrors SEONIX_HISTORY_SYMBOL_DEPTH=0's disable convention above).
861
884
  const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
@@ -863,7 +886,7 @@ export async function indexRepository(repoPath, { python = DEFAULT_PYTHON(), gen
863
886
  const { modules, perLang, commits, symbolHistory, baseMs, historyMs, gitErrors } = r;
864
887
  warnGitErrors(gitErrors, repoPath); // surface any history-pass failure immediately (never fatal)
865
888
  const sessions = await readSessionRecords(repoPath); // recorded chat sessions (.seonix/sessions/*.jsonl), [] when none
866
- const { entities, graphFile, graphBytes } = await assembleAndWrite(repoPath, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions });
889
+ const { entities, graphFile, graphBytes } = await assembleAndWrite(repoPath, { modules, commits, symbolHistory, generatedAt, proseEnabled, sessions, interfaces });
867
890
  // A1 post-index summary: SUMMARY.md + summary.json next to graph.json (NEW files; the
868
891
  // graph.json bytes are untouched). The rendered MD is also printed to stdout by cli.mjs.
869
892
  const summary = buildSummary(entities, {
@@ -976,7 +999,7 @@ export async function discoverRepos(multiRoot) {
976
999
  * `log` (optional) receives one progress line per repo.
977
1000
  * @returns {Promise<{graphFile, outRoot, repos, counts}>}
978
1001
  */
979
- export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, outRoot = "", log = () => {}, historyDepth, skipped = [], extraIgnores = null, languages: languageAllow } = {}) {
1002
+ export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, outRoot = "", log = () => {}, historyDepth, skipped = [], extraIgnores = null, languages: languageAllow, interfaces = null } = {}) {
980
1003
  const paths = [...new Set((repoPaths || []).map((p) => resolve(p)))];
981
1004
  if (!paths.length) throw new Error("indexRepositories requires at least one repo path");
982
1005
  const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
@@ -1014,7 +1037,7 @@ export async function indexRepositories(repoPaths, { python = DEFAULT_PYTHON(),
1014
1037
  // yet — their recorded ids would need the same repo-name prefixing as modules to
1015
1038
  // re-resolve against the merged graph. Deferred; single-path fold-in is the contract.
1016
1039
  const { entities, graphFile, graphBytes } = await assembleAndWrite(root, {
1017
- modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled,
1040
+ modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled, interfaces,
1018
1041
  });
1019
1042
  // A1 post-index summary (merged): per-repo rows (name = prefix) + any discovery
1020
1043
  // `skipped` rows threaded through by the cli. NEW files next to graph.json.
@@ -1094,7 +1117,7 @@ async function discoverSyncRepos(root) {
1094
1117
  * `log` receives one line per re-extracted repo (and the "N unchanged" no-op line).
1095
1118
  * @returns {Promise<{graphFile, outRoot, noop, added, changed, removed, reextracted, unchanged, summary?, counts?}>}
1096
1119
  */
1097
- export async function syncRepositories(multiRoot, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, historyDepth, log = () => {} } = {}) {
1120
+ export async function syncRepositories(multiRoot, { python = DEFAULT_PYTHON(), generatedAt = "", ignores = true, historyDepth, log = () => {}, interfaces = null } = {}) {
1098
1121
  const root = resolve(multiRoot);
1099
1122
  const artifactDir = join(root, ".seonix");
1100
1123
  const proseEnabled = process.env.SEONIX_PROSE_INDEX !== "0";
@@ -1186,7 +1209,7 @@ export async function syncRepositories(multiRoot, { python = DEFAULT_PYTHON(), g
1186
1209
 
1187
1210
  // ONE buildEntities over the union (cached + fresh) → the exact full-re-index graph.
1188
1211
  const { entities, graphFile, graphBytes } = await assembleAndWrite(root, {
1189
- modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled,
1212
+ modules: allModules, commits: allCommits, symbolHistory: allSymbolHistory, generatedAt, proseEnabled, interfaces,
1190
1213
  });
1191
1214
  const summary = buildSummary(entities, { mode: "multi", repos: repoRows, skipped: [], languages, graphBytes });
1192
1215
  await writeSummaryArtifacts(dirname(graphFile), summary);
@@ -0,0 +1,171 @@
1
+ // Un-typed interface extraction (PLAN_UNTYPED_INTERFACES.md, 0.9.0) — the `serves` predicate:
2
+ // route-declaration → handler-symbol. These are the HTTP boundaries a syntax-only AST graph misses
3
+ // entirely: the route STRING (`[HttpGet("api/orders/{id}")]` / `app.get('/orders/:id', …)`) is not a
4
+ // node or an edge target anywhere in the base graph, and a handler passed by reference at
5
+ // registration is not a call/import edge — so "what serves GET api/orders" is otherwise unanswerable.
6
+ //
7
+ // GATED: buildEntities only calls this when the [interfaces] feature is enabled, and only emits the
8
+ // `serves` group / route individuals when there is at least one edge, so the DEFAULT graph is
9
+ // byte-identical (the measurement contract). Pure — no fs, no process, deterministic order.
10
+
11
+ // HTTP verbs we recognise on an attribute / a router-registration call.
12
+ const VERBS = ["get", "post", "put", "delete", "patch", "head", "options"];
13
+
14
+ /** Resolve the effective knobs, arg > toml > default. `enabled` is decided by the caller. */
15
+ export function interfaceOpts(cfg = {}) {
16
+ return {
17
+ frameworks: cfg.frameworks && cfg.frameworks.length ? cfg.frameworks.map((s) => String(s).toLowerCase()) : ["aspnet", "express"],
18
+ verbs: cfg.verbs && cfg.verbs.length ? cfg.verbs.map((s) => String(s).toLowerCase()) : VERBS,
19
+ combineControllerPrefix: cfg.combineControllerPrefix !== false, // default true
20
+ prefixStrip: (cfg.prefixStrip || []).map((s) => normPath(String(s))),
21
+ // Forward-compat: the confidence threshold the future callsHttp client↔route matcher will tune.
22
+ matchThreshold: typeof cfg.matchThreshold === "number" ? cfg.matchThreshold : 0.5,
23
+ };
24
+ }
25
+
26
+ /** Collapse duplicate/edge slashes and a trailing slash; keep a single leading slash off. */
27
+ function normPath(p) {
28
+ return String(p || "").replace(/\\/g, "/").replace(/\/{2,}/g, "/").replace(/^\/|\/$/g, "");
29
+ }
30
+
31
+ /** Join a controller-level route prefix with a method-level suffix into one normalised path. */
32
+ function joinRoute(prefix, suffix) {
33
+ const a = normPath(prefix), b = normPath(suffix);
34
+ if (!a) return b;
35
+ if (!b) return a;
36
+ return `${a}/${b}`;
37
+ }
38
+
39
+ /** Substitute ASP.NET route tokens: [controller] → controller name sans "Controller", [action] → method. */
40
+ function subTokens(path, controller, action) {
41
+ return path
42
+ .replace(/\[controller\]/gi, controller)
43
+ .replace(/\[action\]/gi, action);
44
+ }
45
+
46
+ /** The quoted first string argument of an attribute/call text, e.g. `HttpGet("api/x")` → "api/x". */
47
+ function firstStringArg(text) {
48
+ const m = /["'`]([^"'`]*)["'`]/.exec(String(text || ""));
49
+ return m ? m[1] : "";
50
+ }
51
+
52
+ /** From a C# attribute one-liner (`HttpGet("x")`, `Route("api/[controller]")`, `ApiController`)
53
+ * return {verb|null, path}. A bare `[Route("…")]` has verb=null (a prefix, not a mapping). */
54
+ function parseCsAttr(attr) {
55
+ const name = /^([A-Za-z]+)/.exec(String(attr || "").trim());
56
+ if (!name) return null;
57
+ const id = name[1].toLowerCase();
58
+ if (id.startsWith("http")) {
59
+ const verb = id.slice(4); // httpget → get
60
+ if (VERBS.includes(verb)) return { verb, path: firstStringArg(attr) };
61
+ }
62
+ if (id === "route") return { verb: null, path: firstStringArg(attr) };
63
+ return null;
64
+ }
65
+
66
+ /** 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. */
68
+ function csRoutes(mod, opts) {
69
+ const out = [];
70
+ const defines = mod.defines || [];
71
+ // class name → { prefix, isController } from class-level [Route]/[ApiController]
72
+ const classInfo = new Map();
73
+ for (const d of defines) {
74
+ if (d.kind !== "class") continue;
75
+ let prefix = "", isController = /controller$/i.test(d.name);
76
+ for (const a of d.decorators || []) {
77
+ const p = parseCsAttr(a);
78
+ if (p && p.verb === null && p.path) prefix = p.path;
79
+ if (/^apicontroller/i.test(String(a).trim())) isController = true;
80
+ }
81
+ classInfo.set(d.name, { prefix, isController });
82
+ }
83
+ for (const d of defines) {
84
+ if (d.kind !== "method") continue;
85
+ const dot = d.name.lastIndexOf(".");
86
+ const className = dot >= 0 ? d.name.slice(0, dot) : "";
87
+ const methodName = dot >= 0 ? d.name.slice(dot + 1) : d.name;
88
+ const info = classInfo.get(className);
89
+ const controller = className.replace(/Controller$/i, "");
90
+ for (const a of d.decorators || []) {
91
+ const p = parseCsAttr(a);
92
+ if (!p || p.verb === null) continue; // only [Http*] maps a method to a verb
93
+ 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 });
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** JS/TS routes come pre-structured from the extractor as `mod.routes: [{verb, path, handler}]`
104
+ * (Express/Koa/Fastify `app.get('/x', handler)`), so we only normalise + filter here. */
105
+ function jsRoutes(mod, opts) {
106
+ const out = [];
107
+ for (const r of mod.routes || []) {
108
+ const verb = String(r.verb || "").toLowerCase();
109
+ if (!opts.verbs.includes(verb)) continue;
110
+ if (!r.handler) continue;
111
+ out.push({ verb: verb.toUpperCase(), path: stripPrefixes(normPath(r.path), opts.prefixStrip), handler: r.handler });
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function stripPrefixes(path, prefixes) {
117
+ let p = normPath(path);
118
+ for (const pre of prefixes) {
119
+ if (pre && (p === pre || p.startsWith(pre + "/"))) { p = p.slice(pre.length).replace(/^\//, ""); break; }
120
+ }
121
+ return p;
122
+ }
123
+
124
+ const isCs = (p) => /\.cs$/i.test(p || "");
125
+ const isJsTs = (p) => /\.(m?[jt]sx?)$/i.test(p || "");
126
+
127
+ /**
128
+ * Build the `serves` edges + `Route` individuals for a set of modules.
129
+ * @param modules the same module records buildEntities consumes (path, defines[], routes[]).
130
+ * @param fnId the id-builder buildEntities uses: (path, symbolName) → "fn:path#name".
131
+ * @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.
134
+ */
135
+ export function extractServes(modules, { fnId, opts }) {
136
+ const routeById = new Map(); // route id → individual (deduped across modules/methods)
137
+ const edgeSet = new Set(); // dedupe subject|object
138
+ const edges = [];
139
+ for (const mod of modules || []) {
140
+ let routes = [];
141
+ if (isCs(mod.path) && opts.frameworks.includes("aspnet")) routes = csRoutes(mod, opts);
142
+ else if (isJsTs(mod.path) && opts.frameworks.includes("express")) routes = jsRoutes(mod, opts);
143
+ for (const r of routes) {
144
+ const label = `${r.verb} ${r.path}`;
145
+ const rid = `route:${label}`;
146
+ if (!routeById.has(rid))
147
+ routeById.set(rid, {
148
+ id: rid, label, class: "Route", derived_from: [], mentions: [],
149
+ attributes: [
150
+ { prop: "mgx:httpVerb", key: "verb", value: r.verb },
151
+ { prop: "mgx:routePath", key: "path", value: r.path },
152
+ ],
153
+ });
154
+ const object = fnId(mod.path, r.handler);
155
+ const key = `${rid}|${object}`;
156
+ if (edgeSet.has(key)) continue;
157
+ edgeSet.add(key);
158
+ edges.push({ subject: rid, object, subjectLabel: label, objectLabel: r.handler });
159
+ }
160
+ }
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));
162
+ const routes = [...routeById.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
163
+ // Vocabulary notes for the emitted tokens, built HERE (not in extract.mjs) so the un-typed-interface
164
+ // props stay scoped to this gated feature module — the core schema drift guard documents the always
165
+ // -on extract.mjs props; these opt-in props are self-documented inline until they earn a
166
+ // PREDICATE_DOCS/SchemaPredicate entry (see PLAN_UNTYPED_INTERFACES.md follow-up #4).
167
+ const vocab = [];
168
+ 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
+ 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 };
171
+ }
package/src/jsts_tsc.mjs CHANGED
@@ -23,6 +23,9 @@ import ts from "typescript";
23
23
  import { walk, relPath } from "./walk.mjs";
24
24
 
25
25
  const EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
26
+ // HTTP verbs recognised on an Express/Koa/Fastify router registration (`.use`/`.all` excluded —
27
+ // they are middleware, not a verb→route mapping). Used for un-typed-interface `serves` extraction.
28
+ const ROUTE_VERBS = new Set(["get", "post", "put", "delete", "patch", "head", "options"]);
26
29
  const stripExt = (p) => p.replace(/\.(tsx?|jsx?|mjs|cjs)$/i, "");
27
30
 
28
31
  function scriptKind(path) {
@@ -223,7 +226,11 @@ export async function extractFile(absPath, root) {
223
226
  }
224
227
  }
225
228
 
226
- // module-level coarse calls (whole file)
229
+ // module-level coarse calls (whole file) + un-typed-interface route registrations
230
+ // (PLAN_UNTYPED_INTERFACES.md): `<obj>.get|post|…('/path', …, handler)` → a {verb,path,handler}
231
+ // record consumed downstream ONLY when the gated [interfaces] feature is on. Emitted as an
232
+ // optional `routes` field, so a module with no routes is byte-identical to before.
233
+ const routes = [];
227
234
  const visit = (n) => {
228
235
  if (ts.isCallExpression(n)) {
229
236
  const t = n.expression;
@@ -231,6 +238,14 @@ export async function extractFile(absPath, root) {
231
238
  if (ts.isIdentifier(t)) name = t.text;
232
239
  else if (ts.isPropertyAccessExpression(t)) name = t.getText(sf);
233
240
  if (name) calls.add(name.slice(0, 80));
241
+ if (ts.isPropertyAccessExpression(t) && ROUTE_VERBS.has(t.name.text.toLowerCase()) && n.arguments.length >= 2) {
242
+ const a0 = n.arguments[0];
243
+ if (ts.isStringLiteralLike(a0)) {
244
+ const last = n.arguments[n.arguments.length - 1];
245
+ const handler = ts.isIdentifier(last) ? last.text : ts.isPropertyAccessExpression(last) ? last.name.text : "";
246
+ if (handler) routes.push({ verb: t.name.text.toLowerCase(), path: a0.text, handler });
247
+ }
248
+ }
234
249
  }
235
250
  ts.forEachChild(n, visit);
236
251
  };
@@ -239,6 +254,7 @@ export async function extractFile(absPath, root) {
239
254
  return {
240
255
  path, dotted: stripExt(path),
241
256
  imports: [...imports].sort(), defines, calls: [...calls].sort(), exports: [...exports].sort(),
257
+ ...(routes.length ? { routes } : {}),
242
258
  };
243
259
  }
244
260
 
package/src/paths.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // Path/label classification shared by the locate scorer (codegraph.mjs), the architecture
2
+ // digest (codegraph.mjs renderArchitecture), and the post-index summary (summary.mjs). Kept in
3
+ // its own leaf module so summary.mjs can reuse the canonical regexes without importing the large
4
+ // codegraph query module (and so the two never drift). Pure, no IO.
5
+
6
+ // A module label under a test directory / a python test_*.py / a *.tests.* namespace.
7
+ export const isTestLabel = (s) =>
8
+ /(^|\/)tests?\//.test(s) || /(^|\/)test_[^/]*\.py$/.test(s) || /\.tests(\.|$)/.test(s);
9
+
10
+ // B016 R1a: non-production paths — examples, fixtures, sample/demo apps, and test-* harness
11
+ // packages — share path/symbol vocabulary with the production module and shadow it in locate
12
+ // (B015: js-express injected examples/route-middleware/index.js at rank 1; java-gson's TOP2 slot 2
13
+ // was a test-shrinker fixture). DEMOTED, not excluded: a future task whose truth IS a
14
+ // test/example file must stay reachable.
15
+ export const isNonProdLabel = (s) =>
16
+ /(^|\/)(examples?|fixtures?|samples?|demos?|benchmarks?|test-[^/]+)(\/|$)/.test(s);
17
+
18
+ // Locate/summary demote weights. Non-prod is the harshest (0.15, B016 R1a); a plain test dir is
19
+ // milder (0.4, the locate test-demote).
20
+ export const NONPROD_DEMOTE = 0.15;
21
+ export const TEST_DEMOTE = 0.4;
22
+
23
+ /** Hub-ranking sort multiplier for a module label: down-weight test/non-prod so a shared test
24
+ * harness or fixtures package can't dominate the "hub modules" view (0.8.1, from wh dogfooding).
25
+ * Non-prod < test < prod; the displayed importer/degree count stays the raw value. */
26
+ export const hubDemote = (label) => {
27
+ const s = String(label || "");
28
+ if (isNonProdLabel(s)) return NONPROD_DEMOTE;
29
+ if (isTestLabel(s)) return TEST_DEMOTE;
30
+ return 1;
31
+ };
package/src/prose.mjs CHANGED
@@ -124,7 +124,7 @@ export function buildProseIndex(individuals) {
124
124
 
125
125
  /** Consumer-facing lookup: individual ids whose prose tokens overlap `query` (free text,
126
126
  * tokenized the same way as a docstring), ranked by overlap count (most shared words
127
- * first). This is the integration point for ask.mjs's resolveObject (fuzzy object-term
127
+ * first). This is the integration point for tmct's resolveObject (fuzzy object-term
128
128
  * resolution beyond exact/substring match) and codegraph.mjs's scoreModules (a lexical
129
129
  * boost source) — see PLAN_PROSE_INDEX.md for the exact call-site recommendation; not
130
130
  * wired into either file here to avoid colliding with concurrent work on them.
@@ -148,7 +148,7 @@ export function lookupByProseTokens(proseIndex, query, { limit = 10 } = {}) {
148
148
  * build is unchanged — this only READS the layers the pre-pass already wrote). Given a query
149
149
  * `token`, return the individual ids reachable through the NORMALISED prose layers
150
150
  * (spell-corrected / canonical-schema-term / stem / lemma) stored under
151
- * `proseIndex["seonix:layers"]` — the same normalised layers ask.mjs's resolveObject consults,
151
+ * `proseIndex["seonix:layers"]` — the same normalised layers tmct's resolveObject consults,
152
152
  * here surfaced for the locate SCORER so a task-text word that only overlaps a module via a
153
153
  * normalised form still resolves.
154
154
  *
@@ -158,7 +158,7 @@ export function lookupByProseTokens(proseIndex, query, { limit = 10 } = {}) {
158
158
  * A posting may be a plain id array or `{ ids: [...] }` — both are tolerated. The raw query
159
159
  * token is looked up directly against every layer's keys, so a token whose surface form is
160
160
  * already a canonical/stem/lemma/spell-corrected key hits; the accessor never itself normalises
161
- * the query (it owns no normaliser — those live in the concurrent ask/prose-nlp surface), so it
161
+ * the query (it owns no normaliser — those live in tmct's ask surface), so it
162
162
  * can never disagree with the build's normalisation, only under-fire safely.
163
163
  *
164
164
  * Returns { ids, via }: `ids` a deduped, sorted (stable/deterministic) id list; `via` the
@@ -201,7 +201,7 @@ const classIndividual = ({ name, description }) => ({
201
201
  const predicateIndividual = ({ prop, kind, description }) => ({
202
202
  id: `schema:predicate:${prop}`,
203
203
  // The label is the human-facing relation name (what a user would actually type — "what
204
- // does cochange mean" — matching ask-vocab.mjs's VERB_TO_KIND/kind vocabulary), not the
204
+ // does cochange mean" — matching tmct's verb-to-kind vocabulary), not the
205
205
  // raw prop token; the token is kept as a separate attribute for exact lookup.
206
206
  label: kind,
207
207
  class: "SchemaPredicate",