@polycode-projects/seonix 0.8.0 → 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/bin/cli.mjs +6 -1
- package/package.json +2 -2
- package/src/browser.mjs +1 -0
- package/src/codegraph.mjs +30 -17
- package/src/extract.mjs +33 -10
- package/src/interfaces.mjs +171 -0
- package/src/jsts_tsc.mjs +17 -1
- package/src/paths.mjs +31 -0
- package/src/prose.mjs +3 -3
- package/src/schema-docs.mjs +1 -1
- package/src/server.mjs +56 -2
- package/src/sessions.mjs +3 -3
- package/src/source.mjs +16 -3
- package/src/summary.mjs +7 -3
- package/src/toml-config.mjs +13 -0
- package/src/uuid.mjs +1 -1
- package/src/viz.mjs +16 -76
package/bin/cli.mjs
CHANGED
|
@@ -353,6 +353,10 @@ async function main() {
|
|
|
353
353
|
extraIgnores = compileGlobs([...(toml.index.exclude || []), ...(toml.index.secretExclude || [])]);
|
|
354
354
|
tomlLanguages = toml.index.languages;
|
|
355
355
|
}
|
|
356
|
+
// [interfaces] → gated route↔handler `serves` edges (PLAN_UNTYPED_INTERFACES.md). Only read
|
|
357
|
+
// from a repo's seonix.toml, so config:false (the bench-immunity flag) keeps it null; a bench
|
|
358
|
+
// arm opts in via the SEONIX_INTERFACES env instead (resolved in extract.mjs assembleAndWrite).
|
|
359
|
+
const tomlInterfaces = toml && toml.interfaces ? toml.interfaces : null;
|
|
356
360
|
// A7: `"sync":true` on a multi_root routes through the SAME incremental-sync code
|
|
357
361
|
// path as `cli sync` — fingerprint the estate, re-extract only changed members.
|
|
358
362
|
if (args.multi_root && args.sync === true) {
|
|
@@ -373,7 +377,7 @@ async function main() {
|
|
|
373
377
|
};
|
|
374
378
|
|
|
375
379
|
if (args.repo_path) {
|
|
376
|
-
const { graphFile, counts, summary } = await indexRepository(args.repo_path, { ignores: args.ignores !== false, historyDepth, extraIgnores, languages: tomlLanguages });
|
|
380
|
+
const { graphFile, counts, summary } = await indexRepository(args.repo_path, { ignores: args.ignores !== false, historyDepth, extraIgnores, languages: tomlLanguages, interfaces: tomlInterfaces });
|
|
377
381
|
emitSummary(graphFile, counts, "");
|
|
378
382
|
// A1: the human summary MD goes to STDOUT (bench ignores index stdout — safe;
|
|
379
383
|
// the machine-readable counts stay on stderr above).
|
|
@@ -408,6 +412,7 @@ async function main() {
|
|
|
408
412
|
historyDepth,
|
|
409
413
|
extraIgnores,
|
|
410
414
|
languages: tomlLanguages,
|
|
415
|
+
interfaces: tomlInterfaces,
|
|
411
416
|
skipped: skippedRows,
|
|
412
417
|
log: (line) => process.stderr.write(`seonix: ${line}\n`),
|
|
413
418
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/seonix",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "SEONIX — a deterministic, offline, $0 code-graph tool that makes a cheap coding agent edit like an expensive one. Indexes a repo with Python ast + git (zero model calls) and renders a bounded edit-digest.",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
47
|
-
"@polycode-projects/the-mechanical-code-talker": "^0.
|
|
47
|
+
"@polycode-projects/the-mechanical-code-talker": "^0.8.0",
|
|
48
48
|
"cytoscape": "^3.30.0",
|
|
49
49
|
"smol-toml": "^1.7.0",
|
|
50
50
|
"tree-sitter": "^0.21.1",
|
package/src/browser.mjs
CHANGED
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
|
|
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
|
-
|
|
432
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/schema-docs.mjs
CHANGED
|
@@ -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
|
|
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",
|
package/src/server.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
//
|
|
13
13
|
// seonix_ask (PLAN_MECHANICAL_CHAT.md, hot tool): a mechanical, zero-model-call NL query
|
|
14
14
|
// over the graph — collapses the search+describe+traversal composition loop an agent would
|
|
15
|
-
// otherwise hand-compose into one deterministic round-trip
|
|
15
|
+
// otherwise hand-compose into one deterministic round-trip (now tmct's ask engine).
|
|
16
16
|
|
|
17
17
|
import { readFile } from "node:fs/promises";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
@@ -97,6 +97,7 @@ export const TOOLS = [
|
|
|
97
97
|
required: ["query"],
|
|
98
98
|
properties: {
|
|
99
99
|
query: { type: "string", description: "A free-text question, e.g. \"which functions explicitly couple to logging\"." },
|
|
100
|
+
strict: { type: "boolean", description: "Fail fast on an ambiguous query (error naming the candidates) instead of returning multiple interpretations, so you can re-ask tighter. Default false." },
|
|
100
101
|
},
|
|
101
102
|
},
|
|
102
103
|
},
|
|
@@ -260,7 +261,41 @@ export async function buildContextBundle(args, { config, source = defaultSource,
|
|
|
260
261
|
return { text: out.join("\n"), tier, topup };
|
|
261
262
|
}
|
|
262
263
|
|
|
264
|
+
// C3 (copilot-UX): per-tool accepted argument keys, so a typo'd/unknown key gets a clear error
|
|
265
|
+
// instead of the silent "X is required" the estate session hit. Deliberately generous (a superset of
|
|
266
|
+
// each tool's read keys) so a valid call is never rejected; unlisted tools skip the check.
|
|
267
|
+
const KNOWN_ARGS = {
|
|
268
|
+
seonix_context: ["symbol", "module", "depth", "min", "untuned", "max"],
|
|
269
|
+
seonix_context_more: ["symbol"],
|
|
270
|
+
seonix_describe: ["symbol"],
|
|
271
|
+
seonix_snippet: ["symbol"],
|
|
272
|
+
seonix_signature: ["symbol"],
|
|
273
|
+
seonix_impact: ["module", "depth"],
|
|
274
|
+
seonix_search: ["query", "kind", "decorator", "name", "page", "limit"],
|
|
275
|
+
seonix_members: ["class"],
|
|
276
|
+
seonix_subclasses: ["class"],
|
|
277
|
+
seonix_architecture: ["package"],
|
|
278
|
+
seonix_exports: ["module"],
|
|
279
|
+
seonix_untested: [],
|
|
280
|
+
seonix_ask: ["query", "strict"],
|
|
281
|
+
seonix_tests_for: ["symbol"], seonix_history: ["symbol"], seonix_callers: ["symbol"],
|
|
282
|
+
seonix_callees: ["symbol"], seonix_cochanges: ["symbol"], seonix_calls: ["symbol"],
|
|
283
|
+
seonix_file_history: ["symbol"], seonix_method_history: ["symbol"], seonix_class_history: ["symbol"],
|
|
284
|
+
};
|
|
285
|
+
// CLI/routing keys the cli.mjs entry always injects into `args` but no tool reads — never flagged.
|
|
286
|
+
const BENIGN_ARGS = new Set(["repo_path", "repo_paths", "out_root", "multi_root", "modules", "config", "ignores", "sync", "history_depth"]);
|
|
287
|
+
|
|
263
288
|
export async function dispatchTool(name, args, { config, source = defaultSource } = {}) {
|
|
289
|
+
const allowed = KNOWN_ARGS[name];
|
|
290
|
+
if (allowed && args && typeof args === "object" && !Array.isArray(args)) {
|
|
291
|
+
const unknown = Object.keys(args).filter((k) => !allowed.includes(k) && !BENIGN_ARGS.has(k));
|
|
292
|
+
if (unknown.length) {
|
|
293
|
+
throw new ToolError(
|
|
294
|
+
`unknown argument(s) for ${name}: ${unknown.join(", ")}` +
|
|
295
|
+
(allowed.length ? `. Valid: ${allowed.join(", ")}.` : " (this tool takes no arguments)."),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
264
299
|
if (name === "seonix_context") {
|
|
265
300
|
return (await buildContextBundle(args, { config, source })).text;
|
|
266
301
|
}
|
|
@@ -319,19 +354,28 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
319
354
|
if (name === "seonix_impact") {
|
|
320
355
|
const module = String(args?.module || "").trim();
|
|
321
356
|
if (!module) throw new ToolError("module is required");
|
|
357
|
+
// C1 (copilot-UX): optional `depth` bounds the reverse closure. Absent → renderImpact's default
|
|
358
|
+
// (full closure, maxDepth 8) → byte-unchanged. `depth:1` = direct dependents/importers only —
|
|
359
|
+
// the "who imports this, one hop" the copilot estate session asked for.
|
|
360
|
+
const depth = Number.isInteger(args?.depth) && args.depth > 0 ? args.depth : undefined;
|
|
322
361
|
const graph = await loadGraph(config, source);
|
|
323
362
|
const { match } = resolveOrThrow(graph, module, "module");
|
|
324
|
-
return renderImpact(graph, match);
|
|
363
|
+
return renderImpact(graph, match, depth ? { maxDepth: depth } : undefined);
|
|
325
364
|
}
|
|
326
365
|
if (name === "seonix_search") {
|
|
327
366
|
const query = String(args?.query || "").trim();
|
|
328
367
|
const kind = String(args?.kind || "").trim();
|
|
329
368
|
if (!query && !kind) throw new ToolError("query is required");
|
|
330
369
|
const graph = await loadGraph(config, source);
|
|
370
|
+
// C3 (copilot-UX): opt-in paging for large result sets. Absent → today's "top N" output.
|
|
371
|
+
const page = Number.isInteger(args?.page) && args.page > 0 ? args.page : 0;
|
|
372
|
+
const limit = Number.isInteger(args?.limit) && args.limit > 0 ? args.limit : undefined;
|
|
331
373
|
return renderSearch(graph, query, {
|
|
332
374
|
kind,
|
|
333
375
|
decorator: String(args?.decorator || "").trim(),
|
|
334
376
|
name: String(args?.name || "").trim(),
|
|
377
|
+
...(page ? { page } : {}),
|
|
378
|
+
...(limit ? { limit } : {}),
|
|
335
379
|
});
|
|
336
380
|
}
|
|
337
381
|
if (name === "seonix_members") {
|
|
@@ -371,6 +415,16 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
371
415
|
// Repository Interface over seonix's graph. The `---seonix_ask---` delimited envelope is preserved
|
|
372
416
|
// so every dispatchTool caller (this MCP handler, the CLI fallback) is unchanged.
|
|
373
417
|
const { content, tmct_ask } = makeSeonixProvider(graph, { sourceAccess: false }).ask(query).value;
|
|
418
|
+
// C2 (copilot-UX): `strict:true` fails fast on an ambiguous query — return an error naming the
|
|
419
|
+
// candidate interpretations so the agent re-asks tighter, instead of paging a multi-interpretation
|
|
420
|
+
// body. Default (strict absent/false) → today's envelope, byte-unchanged.
|
|
421
|
+
if (args?.strict === true && tmct_ask?.ambiguous === true) {
|
|
422
|
+
const cands = (tmct_ask.matches || []).map((m) => `${m.label}${m.module ? ` (${m.module})` : ""}`).join(", ");
|
|
423
|
+
throw new ToolError(
|
|
424
|
+
`ambiguous query "${query}" — ${(tmct_ask.matches || []).length} candidate interpretation(s)` +
|
|
425
|
+
`${cands ? `: ${cands}` : ""}. Re-ask with a more specific query.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
374
428
|
return `${content}\n\n---seonix_ask---\n${JSON.stringify(tmct_ask, null, 2)}`;
|
|
375
429
|
}
|
|
376
430
|
if (
|
package/src/sessions.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// sessions.mjs — chat sessions as first-class temporal graph data, like commits.
|
|
2
2
|
//
|
|
3
3
|
// A `seonix chat` session leaves two artifacts under the target repo:
|
|
4
|
-
// .seonix/session-<uuidv7>.log — the human-readable transcript (chat
|
|
4
|
+
// .seonix/session-<uuidv7>.log — the human-readable transcript (the chat shim)
|
|
5
5
|
// .seonix/sessions/session-<uuidv7>.jsonl — the STRUCTURED sidecar this module owns:
|
|
6
6
|
// {"type":"session", id, started, repo, seonixVersion} (header line)
|
|
7
7
|
// {"type":"turn", ts, query, resolvedIds, answeredIds, miss} (one per turn, flushed)
|
|
8
8
|
// {"type":"end", ts} (clean close marker)
|
|
9
9
|
//
|
|
10
10
|
// From the sidecar the session enters the typed graph twice:
|
|
11
|
-
// - READ TIME (chat
|
|
11
|
+
// - READ TIME (the chat shim, per turn): appendSessionToGraph() upserts one `Session`
|
|
12
12
|
// individual (`session:<uuidv7>`) + `mgx:asksAbout` edges into graph.json —
|
|
13
13
|
// atomically (temp + rename), re-reading the file first so a re-index that
|
|
14
14
|
// happened mid-session is tolerated: edges whose targets vanished are dropped,
|
|
@@ -141,7 +141,7 @@ export function upsertSession(entities, record) {
|
|
|
141
141
|
return { kept: targets.length, dropped };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
/** Read-time append (chat
|
|
144
|
+
/** Read-time append (the chat shim, once per turn): re-read graph.json FRESH (a re-index
|
|
145
145
|
* may have replaced it mid-session), upsert, write back atomically. Throws on a
|
|
146
146
|
* missing/invalid artifact — the caller treats the append as best-effort. */
|
|
147
147
|
export async function appendSessionToGraph(graphFile, record) {
|
package/src/source.mjs
CHANGED
|
@@ -19,12 +19,25 @@ export function clearCache() {
|
|
|
19
19
|
* source JSON can't be stat'd. node:sqlite is imported LAZILY here, so a gate-off load
|
|
20
20
|
* never touches sqlite (no ExperimentalWarning, no store.mjs sqlite import). */
|
|
21
21
|
async function fetchFromStore(config) {
|
|
22
|
+
const { readPayload, storeFileFor } = await import("./store.mjs");
|
|
23
|
+
const dbPath = config.storeFile || storeFileFor(config.graphFile);
|
|
22
24
|
let expectStat;
|
|
23
25
|
try { expectStat = await stat(config.graphFile); }
|
|
24
|
-
catch {
|
|
26
|
+
catch {
|
|
27
|
+
// graph.json is gone. If a built store IS on disk, the freshness check has nothing to verify
|
|
28
|
+
// against and the JSON path below would ENOENT with a generic "no graph artifact" message —
|
|
29
|
+
// confusing when a usable graph.db sits right there. Give the real diagnosis instead. (A1)
|
|
30
|
+
let hasStore = false;
|
|
31
|
+
try { await stat(dbPath); hasStore = true; } catch { /* no store either */ }
|
|
32
|
+
if (hasStore)
|
|
33
|
+
throw new ToolError(
|
|
34
|
+
`graph.db present but graph.json is gone (${config.graphFile}) — the store's freshness ` +
|
|
35
|
+
`check needs graph.json, so re-index this repo: ` +
|
|
36
|
+
`\`seonix cli index_repository '{"repo_path":"<abs>"}'\`.`,
|
|
37
|
+
);
|
|
38
|
+
return null; // no store either → let the JSON path emit its own "run index_repository" error
|
|
39
|
+
}
|
|
25
40
|
try {
|
|
26
|
-
const { readPayload, storeFileFor } = await import("./store.mjs");
|
|
27
|
-
const dbPath = config.storeFile || storeFileFor(config.graphFile);
|
|
28
41
|
return await readPayload(dbPath, { expectStat });
|
|
29
42
|
} catch (e) {
|
|
30
43
|
process.stderr.write(`seonix: sqlite store unavailable (${e?.name || e?.code || e?.message || e}); using graph.json\n`);
|
package/src/summary.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { writeFile, rename, mkdir } from "node:fs/promises";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import { hubDemote } from "./paths.mjs";
|
|
18
19
|
|
|
19
20
|
// V8's hard maximum string length (chars). A graph.json larger than this can't be
|
|
20
21
|
// JSON.parse'd back into one string — the failure mode the wh estate hit at 80%.
|
|
@@ -69,10 +70,13 @@ export function hubModules(entities, { prefix = "", top = 5 } = {}) {
|
|
|
69
70
|
const rows = [];
|
|
70
71
|
for (const [id, deg] of degree) {
|
|
71
72
|
if (!id.startsWith(want)) continue;
|
|
72
|
-
|
|
73
|
+
const label = labelById.get(id) ?? id.slice(4);
|
|
74
|
+
// Sort weight down-weights test/non-prod hubs (0.8.1) so a shared test harness can't dominate
|
|
75
|
+
// SUMMARY.md's hub list (the wh estate symptom); the reported `degree` stays the raw value.
|
|
76
|
+
rows.push({ id, label, degree: deg, weight: deg * hubDemote(label) });
|
|
73
77
|
}
|
|
74
|
-
rows.sort((a, b) => (b.
|
|
75
|
-
return rows.slice(0, top);
|
|
78
|
+
rows.sort((a, b) => (b.weight - a.weight) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
79
|
+
return rows.slice(0, top).map(({ id, label, degree }) => ({ id, label, degree }));
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
/**
|
package/src/toml-config.mjs
CHANGED
|
@@ -123,6 +123,19 @@ export async function normalizeConfig(raw, { configDir } = {}) {
|
|
|
123
123
|
const tel = src.telemetry || {};
|
|
124
124
|
if (tel.enabled !== undefined) cfg.telemetry = { enabled: tel.enabled };
|
|
125
125
|
|
|
126
|
+
// [interfaces] — un-typed interface (route↔handler) indexing (PLAN_UNTYPED_INTERFACES.md).
|
|
127
|
+
// OFF by default; enabled emits gated `serves` edges + Route individuals. The matcher knobs
|
|
128
|
+
// (frameworks/verbs/combine_controller_prefix/prefix_strip/match_threshold) are tunable.
|
|
129
|
+
const ifc = src.interfaces || {};
|
|
130
|
+
const interfaces = {};
|
|
131
|
+
if (ifc.enabled !== undefined) interfaces.enabled = ifc.enabled;
|
|
132
|
+
if (ifc.frameworks !== undefined) interfaces.frameworks = ifc.frameworks;
|
|
133
|
+
if (ifc.verbs !== undefined) interfaces.verbs = ifc.verbs;
|
|
134
|
+
if (ifc.combine_controller_prefix !== undefined) interfaces.combineControllerPrefix = ifc.combine_controller_prefix;
|
|
135
|
+
if (ifc.prefix_strip !== undefined) interfaces.prefixStrip = ifc.prefix_strip;
|
|
136
|
+
if (ifc.match_threshold !== undefined) interfaces.matchThreshold = ifc.match_threshold;
|
|
137
|
+
if (Object.keys(interfaces).length) cfg.interfaces = interfaces;
|
|
138
|
+
|
|
126
139
|
const unwired = [];
|
|
127
140
|
for (const [a, b] of UNWIRED_KEYS) {
|
|
128
141
|
if (src[a] && src[a][b] !== undefined) unwired.push(`${a}.${b}`);
|
package/src/uuid.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// uuid.mjs — RFC 9562 UUIDv7 (node:crypto ships v4 only). Lifted verbatim from
|
|
2
|
-
//
|
|
2
|
+
// the chat session id, the telemetry invocation id, and the bench
|
|
3
3
|
// per-run stamp all share ONE time-sortable implementation (no second copy, no dep).
|
|
4
4
|
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
package/src/viz.mjs
CHANGED
|
@@ -26,8 +26,8 @@ import { loadConfig } from "./config.mjs";
|
|
|
26
26
|
import * as source from "./source.mjs";
|
|
27
27
|
import { buildTemporalGraph, buildBrowserData, gitCommitOrder, gitCommitParents, renderBrowserHtml } from "./browser.mjs";
|
|
28
28
|
import { extractTimeline, renderTimelineHtml } from "./timeline.mjs";
|
|
29
|
-
// The
|
|
30
|
-
//
|
|
29
|
+
// The ask panel runs tmct's engine bundled for the browser (see askSource() below), adapter-less —
|
|
30
|
+
// there is no wink lemma tier or ~4 MB browser model.
|
|
31
31
|
|
|
32
32
|
// Portable single-file gate: above this serialized graph size, embedding the
|
|
33
33
|
// full raw graph inline yields an unopenable multi-hundred-MB HTML, so the
|
|
@@ -213,24 +213,12 @@ const inlineJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
|
|
|
213
213
|
* silently produce incomplete answers, which this project's honesty contract
|
|
214
214
|
* doesn't allow (see the file-level comment's "ONE viewer, three data channels" —
|
|
215
215
|
* this is a fourth, for the same one-viewer-many-modes reason).
|
|
216
|
-
*
|
|
217
|
-
* The OPTIONAL wink-nlp lemma tier (ask-nlp.mjs's browser twin, from nlp-bundle.mjs)
|
|
218
|
-
* reaches the chat two ways: `nlpInline` inlines the ~4 MB bundle as its own <script>
|
|
219
|
-
* so it registers `window.__seonixNlp` at page load (portable `viz --nlp`); `nlpPath`
|
|
220
|
-
* points the page at a same-origin sibling asset the chat LAZY-loads on first ask
|
|
221
|
-
* (the SITE build). Neither is present in the default local single-file — that viewer
|
|
222
|
-
* stays lemma-off and fetches nothing, exactly as before, preserving its
|
|
223
|
-
* no-external-fetch guarantee. ask() picks up whatever `window.__seonixNlp` is set,
|
|
224
|
-
* or degrades to the adapter-less tiers when it's absent.
|
|
225
216
|
*/
|
|
226
|
-
export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null, dataPath = null, askData = null, askDataPath = null
|
|
217
|
+
export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null, dataPath = null, askData = null, askDataPath = null } = {}) {
|
|
227
218
|
const embedded = embedData ? `<script type="application/json" id="seonix-data">${inlineJson(embedData)}</script>\n` : "";
|
|
228
219
|
const askEmbedded = askData ? `<script type="application/json" id="seonix-ask-data">${inlineJson(askData)}</script>\n` : "";
|
|
229
|
-
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {})
|
|
220
|
+
const cfgObj = { ...(dataPath ? { data: dataPath } : {}), ...(askDataPath ? { ask: askDataPath } : {}) };
|
|
230
221
|
const cfg = Object.keys(cfgObj).length ? `<script type="application/json" id="seonix-cfg">${inlineJson(cfgObj)}</script>\n` : "";
|
|
231
|
-
// Inline bundle: escape any literal </script inside the wink model data so the
|
|
232
|
-
// block can't be closed early (harmless inside JS strings, impossible outside them).
|
|
233
|
-
const nlpEmbedded = nlpInline ? `<script>${String(nlpInline).replace(/<\/script/gi, "<\\/script")}</script>\n` : "";
|
|
234
222
|
return `<!doctype html><html><head><meta charset="utf-8"><title>seon code-map</title>
|
|
235
223
|
<!-- generated by: seonix viz (see packages/seonix/src/viz.mjs) — one viewer, data loaded at runtime -->
|
|
236
224
|
<style>
|
|
@@ -308,7 +296,7 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
|
|
|
308
296
|
</div>
|
|
309
297
|
${embedded}${askEmbedded}${cfg}<script>${cytoscape}</script>
|
|
310
298
|
<script>${askEngine}</script>
|
|
311
|
-
|
|
299
|
+
<script>
|
|
312
300
|
// Type palette (see the CVD note in viz.mjs) — viewer styling, identical for every repo.
|
|
313
301
|
const COLORS={Module:'#3987e5',Class:'#c98500',Function:'#008300',Method:'#d55181',Attribute:'#9085e9',GlobalVariable:'#d95926',Commit:'#199e70'};
|
|
314
302
|
const $=id=>document.getElementById(id);
|
|
@@ -351,29 +339,6 @@ function loadAskData(){
|
|
|
351
339
|
})();
|
|
352
340
|
return _askGraphPromise;
|
|
353
341
|
}
|
|
354
|
-
// The OPTIONAL wink-nlp lemma adapter (window.__seonixNlp — see nlp-bundle.mjs).
|
|
355
|
-
// An inlined bundle registers it at page load; the site build ships it as a
|
|
356
|
-
// same-origin sibling this injects LAZILY on the first ask (never at boot), so the
|
|
357
|
-
// ~4 MB model is only paid for when the chat is actually used. With neither present
|
|
358
|
-
// (the default local single-file) this resolves to undefined and the chat runs
|
|
359
|
-
// adapter-less — ask() then falls back to its curated + fuzzy tiers, exactly as
|
|
360
|
-
// before, and NOTHING is fetched, keeping the no-external-fetch guarantee intact.
|
|
361
|
-
let _nlpPromise=null;
|
|
362
|
-
function loadNlp(){
|
|
363
|
-
if(window.__seonixNlp) return Promise.resolve(window.__seonixNlp);
|
|
364
|
-
if(_nlpPromise) return _nlpPromise;
|
|
365
|
-
const cfgEl=document.getElementById('seonix-cfg');
|
|
366
|
-
const url=cfgEl?JSON.parse(cfgEl.textContent).nlp:null;
|
|
367
|
-
if(!url) return Promise.resolve(undefined);
|
|
368
|
-
_nlpPromise=new Promise(res=>{
|
|
369
|
-
const s=document.createElement('script');
|
|
370
|
-
s.src=url;
|
|
371
|
-
s.onload=()=>res(window.__seonixNlp);
|
|
372
|
-
s.onerror=()=>res(undefined); // a failed asset degrades to lemma-off, never a broken chat
|
|
373
|
-
document.head.appendChild(s);
|
|
374
|
-
});
|
|
375
|
-
return _nlpPromise;
|
|
376
|
-
}
|
|
377
342
|
function init(G){
|
|
378
343
|
document.title='seon code-map — '+G.focusLabel;
|
|
379
344
|
$('focuslabel').textContent=G.focusLabel;
|
|
@@ -616,10 +581,9 @@ function init(G){
|
|
|
616
581
|
return;
|
|
617
582
|
}
|
|
618
583
|
out.textContent='thinking…';
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const {content,seonix_ask}=ask(askGraph,query,{contextId:selId,nlp});
|
|
584
|
+
// Lazy-load the graph, then run the mechanical ask over it.
|
|
585
|
+
loadAskData().then((askGraph)=>{
|
|
586
|
+
const {content,seonix_ask}=ask(askGraph,query,{contextId:selId});
|
|
623
587
|
const total=(seonix_ask.matches||[]).length;
|
|
624
588
|
const shown=total?highlightAsk(seonix_ask.matches.map(m=>m.id)):0;
|
|
625
589
|
out.classList.toggle('miss',!!seonix_ask.miss);
|
|
@@ -709,12 +673,10 @@ function resolveFocus(graph, focusArg) {
|
|
|
709
673
|
* code browser rides along at `/code-browser.html` (alias `/browser`) with a live
|
|
710
674
|
* temporal payload — the local equivalent of the site's browser section.
|
|
711
675
|
*/
|
|
712
|
-
export async function startVizServer({ values, cytoscape, askEngine = "",
|
|
676
|
+
export async function startVizServer({ values, cytoscape, askEngine = "", log = () => {} }) {
|
|
713
677
|
const port = Math.max(0, parseInt(values.port, 10) || 0);
|
|
714
678
|
const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
|
|
715
|
-
|
|
716
|
-
// is served in-process at /seonix-nlp.js and the viewer lazy-loads it on first ask.
|
|
717
|
-
const viewer = renderViewerHtml({ cytoscape, askEngine, nlpPath: nlpBundle ? "/seonix-nlp.js" : null });
|
|
679
|
+
const viewer = renderViewerHtml({ cytoscape, askEngine });
|
|
718
680
|
const browserPage = await renderBrowserHtml({ cytoscape });
|
|
719
681
|
// nav = the routes this server itself serves; injected into every payload/page
|
|
720
682
|
const nav = { home: "/", graph: "/seonix-graph.html", browser: "/code-browser.html", timeline: "/timeline.html" };
|
|
@@ -815,9 +777,6 @@ export async function startVizServer({ values, cytoscape, askEngine = "", nlpBun
|
|
|
815
777
|
res.writeHead(500, { "content-type": "application/json" });
|
|
816
778
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
817
779
|
}
|
|
818
|
-
} else if (path === "/seonix-nlp.js" && nlpBundle) {
|
|
819
|
-
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
820
|
-
res.end(nlpBundle);
|
|
821
780
|
} else {
|
|
822
781
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
823
782
|
res.end("not found");
|
|
@@ -856,11 +815,6 @@ export async function runVizCli(argv) {
|
|
|
856
815
|
// truncation ("newest N of M commits"). See src/timeline.mjs.
|
|
857
816
|
limit: { type: "string", default: "100" },
|
|
858
817
|
scope: { type: "string", default: "product" },
|
|
859
|
-
// Include the OPTIONAL wink-nlp lemma tier in the chat (see nlp-bundle.mjs).
|
|
860
|
-
// Site mode (--data-out): written as a same-origin sibling the page lazy-loads.
|
|
861
|
-
// Portable/serve mode: inlined (single-file) / served in-process. Off by default,
|
|
862
|
-
// so the local single-file stays lean and lemma-off exactly as before.
|
|
863
|
-
nlp: { type: "boolean", default: false },
|
|
864
818
|
// Portable branch only: embed the full graph inline even past the size gate
|
|
865
819
|
// (INLINE_ASKDATA_MAX_BYTES). Off by default — over-threshold graphs write
|
|
866
820
|
// sidecars instead of an unopenable multi-hundred-MB single file.
|
|
@@ -870,11 +824,8 @@ export async function runVizCli(argv) {
|
|
|
870
824
|
});
|
|
871
825
|
const cytoscape = await cytoscapeSource();
|
|
872
826
|
const askEngine = await askSource();
|
|
873
|
-
// The wink lemma tier was removed in Stage 6 (the panel runs tmct's engine adapter-less), so there
|
|
874
|
-
// is never a bundle to wire. `--nlp` is accepted but inert; the downstream null-guards no-op.
|
|
875
|
-
const nlpBundle = null;
|
|
876
827
|
if (values.serve) {
|
|
877
|
-
await startVizServer({ values, cytoscape, askEngine,
|
|
828
|
+
await startVizServer({ values, cytoscape, askEngine, log: (s) => process.stderr.write(s) });
|
|
878
829
|
return; // keeps serving until Ctrl-C
|
|
879
830
|
}
|
|
880
831
|
const { config, graph, raw, graphBytes } = await loadGraph(values);
|
|
@@ -926,32 +877,22 @@ export async function runVizCli(argv) {
|
|
|
926
877
|
const rel = relative(dirname(out), dataOut) || "seonix-graph-data.json";
|
|
927
878
|
const askDataOut = resolve(dirname(out), "seonix-ask-data.json");
|
|
928
879
|
const askRel = relative(dirname(out), askDataOut) || "seonix-ask-data.json";
|
|
929
|
-
// The wink lemma bundle rides the same sibling-asset convention as the data
|
|
930
|
-
// files — a same-origin ./seonix-nlp.js the page lazy-loads on first ask.
|
|
931
|
-
let nlpPath = null;
|
|
932
|
-
if (nlpBundle) {
|
|
933
|
-
const nlpOut = resolve(dirname(out), "seonix-nlp.js");
|
|
934
|
-
await writeFile(nlpOut, nlpBundle);
|
|
935
|
-
nlpPath = "./seonix-nlp.js";
|
|
936
|
-
}
|
|
937
880
|
await Promise.all([writeFile(dataOut, JSON.stringify(data)), writeFile(askDataOut, JSON.stringify(raw))]);
|
|
938
881
|
await writeFile(out, renderViewerHtml({
|
|
939
882
|
cytoscape, askEngine,
|
|
940
883
|
dataPath: rel === "seonix-graph-data.json" ? null : rel,
|
|
941
884
|
askDataPath: askRel === "seonix-ask-data.json" ? null : askRel,
|
|
942
|
-
nlpPath,
|
|
943
885
|
}));
|
|
944
|
-
return { dataOut, askDataOut
|
|
886
|
+
return { dataOut, askDataOut };
|
|
945
887
|
};
|
|
946
888
|
if (values["data-out"]) {
|
|
947
889
|
// site mode: viewer page + sibling data file (data updates don't touch the page).
|
|
948
890
|
// The chat panel's full-graph channel rides the same sibling-file convention,
|
|
949
891
|
// written next to the display data file rather than embedded in the page.
|
|
950
|
-
const { dataOut, askDataOut
|
|
892
|
+
const { dataOut, askDataOut } = await writeSplitViewer(resolve(values["data-out"]));
|
|
951
893
|
process.stderr.write(
|
|
952
894
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
953
|
-
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}
|
|
954
|
-
`${nlpPath ? ` + ${resolve(dirname(out), "seonix-nlp.js")} (wink lemma tier)` : ""}\n`,
|
|
895
|
+
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
|
|
955
896
|
);
|
|
956
897
|
} else if (graphBytes > inlineAskDataMaxBytes() && !values["force-inline"]) {
|
|
957
898
|
// portable mode, but the graph is too big to embed: an estate-scale full-graph
|
|
@@ -970,9 +911,8 @@ export async function runVizCli(argv) {
|
|
|
970
911
|
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out} + ${dataOut} + ${askDataOut}\n`,
|
|
971
912
|
);
|
|
972
913
|
} else {
|
|
973
|
-
// portable mode: one self-contained file, both channels embedded
|
|
974
|
-
|
|
975
|
-
await writeFile(out, renderViewerHtml({ cytoscape, askEngine, embedData: data, askData: raw, nlpInline: nlpBundle }));
|
|
914
|
+
// portable mode: one self-contained file, both data channels embedded, no external fetch.
|
|
915
|
+
await writeFile(out, renderViewerHtml({ cytoscape, askEngine, embedData: data, askData: raw }));
|
|
976
916
|
process.stderr.write(
|
|
977
917
|
`seonix viz: ${subgraph.nodes.length} nodes, ${subgraph.edges.length} edges around "${focus.label}" ` +
|
|
978
918
|
`(depth ${values.depth})${subgraph.truncated ? " [capped]" : ""} → ${out}\n`,
|