@polycode-projects/seonix 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -7
- package/bin/cli.mjs +92 -11
- package/package.json +2 -9
- package/src/ask-browser.bundle.js +136 -18
- package/src/browser.mjs +9 -3
- package/src/codegraph.mjs +126 -18
- package/src/cs_treesitter.mjs +58 -3
- package/src/extract.mjs +98 -23
- package/src/extract_lang.mjs +4 -2
- package/src/interfaces.mjs +110 -16
- package/src/jsts_tsc.mjs +51 -0
- package/src/schema-docs.mjs +33 -3
- package/src/server.mjs +51 -7
- package/src/summary.mjs +14 -3
- package/src/telemetry.mjs +31 -0
package/src/interfaces.mjs
CHANGED
|
@@ -18,7 +18,9 @@ export function interfaceOpts(cfg = {}) {
|
|
|
18
18
|
verbs: cfg.verbs && cfg.verbs.length ? cfg.verbs.map((s) => String(s).toLowerCase()) : VERBS,
|
|
19
19
|
combineControllerPrefix: cfg.combineControllerPrefix !== false, // default true
|
|
20
20
|
prefixStrip: (cfg.prefixStrip || []).map((s) => normPath(String(s))),
|
|
21
|
-
//
|
|
21
|
+
// Minimum matchScore (see below) for a callsHttp client↔route link. 1.0 = exact-length
|
|
22
|
+
// matches only; the 0.5 default also admits exact-prefix overlaps (e.g. a client URL with
|
|
23
|
+
// extra trailing segments over a shorter route template).
|
|
22
24
|
matchThreshold: typeof cfg.matchThreshold === "number" ? cfg.matchThreshold : 0.5,
|
|
23
25
|
};
|
|
24
26
|
}
|
|
@@ -64,21 +66,28 @@ function parseCsAttr(attr) {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/** C# controller routes from a module's defines[] (Roslyn emits `[Http*]`/`[Route]` in decorators).
|
|
67
|
-
* Methods are named "Class.Method"
|
|
69
|
+
* Methods are named "Class.Method". ASP.NET semantics honoured here:
|
|
70
|
+
* - EVERY class-level `[Route("…")]` is a prefix (a controller can declare several — one route per
|
|
71
|
+
* prefix × method mapping, document order, deterministic).
|
|
72
|
+
* - A method-level verb attr with its own template (`[HttpGet("{id}")]`) uses that template.
|
|
73
|
+
* - A verb attr with NO template (`[HttpPost]`) takes the method-level verb-less `[Route("…")]`
|
|
74
|
+
* template(s) as its suffix — the common `[HttpPost] + [Route("process")]` pattern; with no
|
|
75
|
+
* method [Route] either, the mapping is just the prefix. */
|
|
68
76
|
function csRoutes(mod, opts) {
|
|
69
77
|
const out = [];
|
|
70
78
|
const defines = mod.defines || [];
|
|
71
|
-
// class name → {
|
|
79
|
+
// class name → { prefixes, isController } from class-level [Route]/[ApiController]
|
|
72
80
|
const classInfo = new Map();
|
|
73
81
|
for (const d of defines) {
|
|
74
82
|
if (d.kind !== "class") continue;
|
|
75
|
-
|
|
83
|
+
const prefixes = [];
|
|
84
|
+
let isController = /controller$/i.test(d.name);
|
|
76
85
|
for (const a of d.decorators || []) {
|
|
77
86
|
const p = parseCsAttr(a);
|
|
78
|
-
if (p && p.verb === null && p.path)
|
|
87
|
+
if (p && p.verb === null && p.path) prefixes.push(p.path);
|
|
79
88
|
if (/^apicontroller/i.test(String(a).trim())) isController = true;
|
|
80
89
|
}
|
|
81
|
-
classInfo.set(d.name, {
|
|
90
|
+
classInfo.set(d.name, { prefixes, isController });
|
|
82
91
|
}
|
|
83
92
|
for (const d of defines) {
|
|
84
93
|
if (d.kind !== "method") continue;
|
|
@@ -87,14 +96,27 @@ function csRoutes(mod, opts) {
|
|
|
87
96
|
const methodName = dot >= 0 ? d.name.slice(dot + 1) : d.name;
|
|
88
97
|
const info = classInfo.get(className);
|
|
89
98
|
const controller = className.replace(/Controller$/i, "");
|
|
99
|
+
// First pass over the method's attributes: verb mappings + verb-less [Route] templates.
|
|
100
|
+
const verbAttrs = [];
|
|
101
|
+
const routeTemplates = [];
|
|
90
102
|
for (const a of d.decorators || []) {
|
|
91
103
|
const p = parseCsAttr(a);
|
|
92
|
-
if (!p
|
|
104
|
+
if (!p) continue;
|
|
105
|
+
if (p.verb === null) { if (p.path) routeTemplates.push(p.path); continue; }
|
|
93
106
|
if (!opts.verbs.includes(p.verb)) continue;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
verbAttrs.push(p);
|
|
108
|
+
}
|
|
109
|
+
if (!verbAttrs.length) continue; // only [Http*] maps a method to a verb
|
|
110
|
+
const prefixes = opts.combineControllerPrefix && info?.prefixes.length ? info.prefixes : [""];
|
|
111
|
+
for (const p of verbAttrs) {
|
|
112
|
+
const suffixes = p.path ? [p.path] : (routeTemplates.length ? routeTemplates : [""]);
|
|
113
|
+
for (const prefix of prefixes) {
|
|
114
|
+
for (const suffix of suffixes) {
|
|
115
|
+
let path = subTokens(joinRoute(prefix, suffix), controller, methodName);
|
|
116
|
+
path = stripPrefixes(path, opts.prefixStrip);
|
|
117
|
+
out.push({ verb: p.verb.toUpperCase(), path, handler: d.name });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
98
120
|
}
|
|
99
121
|
}
|
|
100
122
|
return out;
|
|
@@ -124,16 +146,46 @@ function stripPrefixes(path, prefixes) {
|
|
|
124
146
|
const isCs = (p) => /\.cs$/i.test(p || "");
|
|
125
147
|
const isJsTs = (p) => /\.(m?[jt]sx?)$/i.test(p || "");
|
|
126
148
|
|
|
149
|
+
/** Normalise a URL / route template into match segments (the deterministic half of callsHttp):
|
|
150
|
+
* scheme+host stripped, query/hash dropped, duplicate slashes collapsed, and any parameter-ish
|
|
151
|
+
* segment — `{id}` / `{id:int}` (ASP.NET, also a C# interpolation hole after quote-stripping),
|
|
152
|
+
* `${expr}` / `{*}` (JS interpolation holes), `:id` (Express) — collapsed to a `*` wildcard.
|
|
153
|
+
* Literal segments compare case-insensitively (ASP.NET routing is case-insensitive).
|
|
154
|
+
* Exported for the matcher tests; pure. */
|
|
155
|
+
export function normalizeHttpTemplate(url) {
|
|
156
|
+
let u = String(url || "").trim();
|
|
157
|
+
u = u.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, ""); // scheme://host[:port]
|
|
158
|
+
u = u.split(/[?#]/)[0];
|
|
159
|
+
u = normPath(u);
|
|
160
|
+
if (!u) return [];
|
|
161
|
+
return u.split("/").map((seg) => (/[{}]|^:/.test(seg) ? "*" : seg.toLowerCase()));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Deterministic template match: segment-by-segment prefix discipline (`*` matches any single
|
|
165
|
+
* segment; the first literal mismatch is fatal), scored 2·shared/(|url|+|route|). 1.0 is an
|
|
166
|
+
* exact-length match; an exact-prefix overlap scores below 1 and is admitted (or not) by the
|
|
167
|
+
* match_threshold knob. No embeddings, no fuzzy scoring beyond this normalisation. */
|
|
168
|
+
function matchScore(a, b) {
|
|
169
|
+
if (!a.length || !b.length) return 0;
|
|
170
|
+
const n = Math.min(a.length, b.length);
|
|
171
|
+
for (let i = 0; i < n; i += 1) {
|
|
172
|
+
if (a[i] !== b[i] && a[i] !== "*" && b[i] !== "*") return 0;
|
|
173
|
+
}
|
|
174
|
+
return (2 * n) / (a.length + b.length);
|
|
175
|
+
}
|
|
176
|
+
|
|
127
177
|
/**
|
|
128
178
|
* Build the `serves` edges + `Route` individuals for a set of modules.
|
|
129
179
|
* @param modules the same module records buildEntities consumes (path, defines[], routes[]).
|
|
130
180
|
* @param fnId the id-builder buildEntities uses: (path, symbolName) → "fn:path#name".
|
|
131
181
|
* @param opts from interfaceOpts().
|
|
132
|
-
* @returns { edges, routes } — edges are {subject,object,subjectLabel,
|
|
133
|
-
* Route individuals.
|
|
182
|
+
* @returns { edges, routes, callsHttp } — edges/callsHttp are {subject,object,subjectLabel,
|
|
183
|
+
* objectLabel}; routes are Route individuals. All deterministically ordered; empty when
|
|
184
|
+
* nothing matched.
|
|
134
185
|
*/
|
|
135
186
|
export function extractServes(modules, { fnId, opts }) {
|
|
136
187
|
const routeById = new Map(); // route id → individual (deduped across modules/methods)
|
|
188
|
+
const routeMeta = []; // route id → {verb, segs} for the callsHttp matcher below
|
|
137
189
|
const edgeSet = new Set(); // dedupe subject|object
|
|
138
190
|
const edges = [];
|
|
139
191
|
for (const mod of modules || []) {
|
|
@@ -143,7 +195,7 @@ export function extractServes(modules, { fnId, opts }) {
|
|
|
143
195
|
for (const r of routes) {
|
|
144
196
|
const label = `${r.verb} ${r.path}`;
|
|
145
197
|
const rid = `route:${label}`;
|
|
146
|
-
if (!routeById.has(rid))
|
|
198
|
+
if (!routeById.has(rid)) {
|
|
147
199
|
routeById.set(rid, {
|
|
148
200
|
id: rid, label, class: "Route", derived_from: [], mentions: [],
|
|
149
201
|
attributes: [
|
|
@@ -151,6 +203,8 @@ export function extractServes(modules, { fnId, opts }) {
|
|
|
151
203
|
{ prop: "mgx:routePath", key: "path", value: r.path },
|
|
152
204
|
],
|
|
153
205
|
});
|
|
206
|
+
routeMeta.push({ id: rid, label, verb: r.verb, segs: normalizeHttpTemplate(r.path) });
|
|
207
|
+
}
|
|
154
208
|
const object = fnId(mod.path, r.handler);
|
|
155
209
|
const key = `${rid}|${object}`;
|
|
156
210
|
if (edgeSet.has(key)) continue;
|
|
@@ -158,8 +212,47 @@ export function extractServes(modules, { fnId, opts }) {
|
|
|
158
212
|
edges.push({ subject: rid, object, subjectLabel: label, objectLabel: r.handler });
|
|
159
213
|
}
|
|
160
214
|
}
|
|
161
|
-
|
|
215
|
+
const byEdge = (a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : a.object < b.object ? -1 : a.object > b.object ? 1 : 0);
|
|
216
|
+
edges.sort(byEdge);
|
|
162
217
|
const routes = [...routeById.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
218
|
+
|
|
219
|
+
// callsHttp (client→route) — the other half of the cross-service chain, PLAN_UNTYPED_INTERFACES
|
|
220
|
+
// follow-up #1. Client call sites come pre-captured from the extractors as an optional per-define
|
|
221
|
+
// `http_calls: [{verb, url}]` (fetch/axios/HttpClient first-argument URL literals). Each call is
|
|
222
|
+
// matched against the Route table just built: verbs must agree, templates match per matchScore
|
|
223
|
+
// above, and only the BEST-scoring route(s) at or above opts.matchThreshold link (ties all link —
|
|
224
|
+
// deterministic). A call matching nothing emits nothing; no orphan Route individuals are created,
|
|
225
|
+
// so callsHttp can only point at Routes that `serves` already justified.
|
|
226
|
+
const callsHttp = [];
|
|
227
|
+
const chSeen = new Set();
|
|
228
|
+
for (const mod of modules || []) {
|
|
229
|
+
for (const d of mod.defines || []) {
|
|
230
|
+
for (const c of d.http_calls || []) {
|
|
231
|
+
const verb = String(c.verb || "").toUpperCase();
|
|
232
|
+
const segs = normalizeHttpTemplate(c.url);
|
|
233
|
+
if (!segs.length) continue;
|
|
234
|
+
// No anchor, no edge: an all-wildcard template (`{base}{uri}/{id}`) would match ANY
|
|
235
|
+
// same-length route at 1.0 — at least one literal segment is required (house "no wrong edge").
|
|
236
|
+
if (!segs.some((s) => s !== "*")) continue;
|
|
237
|
+
let best = 0;
|
|
238
|
+
let hits = [];
|
|
239
|
+
for (const r of routeMeta) {
|
|
240
|
+
if (r.verb !== verb) continue;
|
|
241
|
+
const s = matchScore(segs, r.segs);
|
|
242
|
+
if (s < opts.matchThreshold || s < best) continue;
|
|
243
|
+
if (s > best) { best = s; hits = [r]; } else hits.push(r);
|
|
244
|
+
}
|
|
245
|
+
const subject = fnId(mod.path, d.name);
|
|
246
|
+
for (const r of hits) {
|
|
247
|
+
const key = `${subject}|${r.id}`;
|
|
248
|
+
if (chSeen.has(key)) continue;
|
|
249
|
+
chSeen.add(key);
|
|
250
|
+
callsHttp.push({ subject, object: r.id, subjectLabel: d.name, objectLabel: r.label });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
callsHttp.sort(byEdge);
|
|
163
256
|
// Vocabulary notes for the emitted tokens, built HERE (not in extract.mjs) so the un-typed-interface
|
|
164
257
|
// props stay scoped to this gated feature module — the core schema drift guard documents the always
|
|
165
258
|
// -on extract.mjs props; these opt-in props are self-documented inline until they earn a
|
|
@@ -167,5 +260,6 @@ export function extractServes(modules, { fnId, opts }) {
|
|
|
167
260
|
const vocab = [];
|
|
168
261
|
if (edges.length) vocab.push({ prop: "mgx:serves", predicate: "serves", note: "route-declaration→handler-symbol; the HTTP boundary a syntax-only AST misses (un-typed interface)" });
|
|
169
262
|
if (routes.length) vocab.push({ prop: "mgx:httpVerb", note: "HTTP verb of a Route individual" }, { prop: "mgx:routePath", note: "normalised path template of a Route individual" });
|
|
170
|
-
|
|
263
|
+
if (callsHttp.length) vocab.push({ prop: "mgx:callsHttp", predicate: "callsHttp", note: "client call-site→Route; HTTP URL literal matched to a route template (deterministic normalisation, match_threshold-gated)" });
|
|
264
|
+
return { edges, routes, vocab, callsHttp };
|
|
171
265
|
}
|
package/src/jsts_tsc.mjs
CHANGED
|
@@ -66,6 +66,55 @@ function decoratorsOf(sf, node) {
|
|
|
66
66
|
return (ds || []).map((d) => d.expression.getText(sf).replace(/\s+/g, " ").slice(0, 80));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// ---- HTTP-client call capture (un-typed interfaces: the callsHttp client→route half). ----
|
|
70
|
+
// The URL literal at a client call site, per define — the base calls[] strips arguments, so the
|
|
71
|
+
// route-matchable string must be captured here. Deterministic name gates, no resolution:
|
|
72
|
+
// fetch('/api/x' [, {method:'POST'}]) → verb from a literal `method:` property, else get
|
|
73
|
+
// <recv>.get|post|…('/api/x', …) where recv's text looks like an HTTP client (axios / http /
|
|
74
|
+
// client / api / request) — so Express-style registrations (router./app. receivers) and
|
|
75
|
+
// Map.get('key') never match.
|
|
76
|
+
// The URL must be a string/template literal containing "/"; template interpolation holes become
|
|
77
|
+
// `{*}` (collapsed to a wildcard segment by the matcher in interfaces.mjs). Emitted as an optional
|
|
78
|
+
// `http_calls` field on the define — absent when empty, so modules without client calls are
|
|
79
|
+
// byte-identical to before, and the DEFAULT graph ignores the field entirely (gating contract).
|
|
80
|
+
const CLIENTISH = /axios|http|client|api|request/i;
|
|
81
|
+
function urlLiteralText(sf, a) {
|
|
82
|
+
if (!a) return "";
|
|
83
|
+
if (ts.isStringLiteralLike(a)) return a.text; // 'x' / "x" / `x` (no holes)
|
|
84
|
+
if (ts.isTemplateExpression(a)) return a.head.text + a.templateSpans.map((s) => `{*}${s.literal.text}`).join("");
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
function httpCallsIn(sf, node) {
|
|
88
|
+
const out = [];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const visit = (n) => {
|
|
91
|
+
if (ts.isCallExpression(n)) {
|
|
92
|
+
const t = n.expression;
|
|
93
|
+
let verb = "";
|
|
94
|
+
if ((ts.isIdentifier(t) && t.text === "fetch") || (ts.isPropertyAccessExpression(t) && t.name.text === "fetch")) {
|
|
95
|
+
verb = "get"; // fetch defaults to GET; a literal {method:'POST'} option overrides
|
|
96
|
+
const opt = n.arguments[1];
|
|
97
|
+
if (opt && ts.isObjectLiteralExpression(opt)) {
|
|
98
|
+
for (const p of opt.properties) {
|
|
99
|
+
if (ts.isPropertyAssignment(p) && p.name && p.name.getText(sf) === "method" && ts.isStringLiteralLike(p.initializer))
|
|
100
|
+
verb = p.initializer.text.toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else if (ts.isPropertyAccessExpression(t) && ROUTE_VERBS.has(t.name.text.toLowerCase()) && CLIENTISH.test(t.expression.getText(sf))) {
|
|
104
|
+
verb = t.name.text.toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
const url = verb ? urlLiteralText(sf, n.arguments[0]) : "";
|
|
107
|
+
if (ROUTE_VERBS.has(verb) && url.includes("/")) {
|
|
108
|
+
const key = `${verb} ${url}`;
|
|
109
|
+
if (!seen.has(key)) { seen.add(key); out.push({ verb, url: url.slice(0, 160) }); }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
ts.forEachChild(n, visit);
|
|
113
|
+
};
|
|
114
|
+
ts.forEachChild(node, visit);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
69
118
|
/** Collect callee names within a node body (coarse; unparsed call target). */
|
|
70
119
|
function callsIn(sf, node) {
|
|
71
120
|
const out = new Set();
|
|
@@ -129,12 +178,14 @@ export async function extractFile(absPath, root) {
|
|
|
129
178
|
const isExportsRef = (e) => isModuleExports(e) || (ts.isIdentifier(e) && e.text === "exports");
|
|
130
179
|
|
|
131
180
|
const addFn = (name, node, kind, extra = {}) => {
|
|
181
|
+
const http = httpCallsIn(sf, node);
|
|
132
182
|
defines.push({
|
|
133
183
|
name, kind, lineno: lineOf(sf, node), end_lineno: endLineOf(sf, node),
|
|
134
184
|
decorators: decoratorsOf(sf, node),
|
|
135
185
|
params: paramsText(sf, node), returns: returnText(sf, node),
|
|
136
186
|
calls: callsIn(sf, node), doc: firstDocLine(sf, node),
|
|
137
187
|
...(visibilityOf(node) ? { visibility: visibilityOf(node) } : {}),
|
|
188
|
+
...(http.length ? { http_calls: http } : {}),
|
|
138
189
|
...extra,
|
|
139
190
|
});
|
|
140
191
|
};
|
package/src/schema-docs.mjs
CHANGED
|
@@ -71,9 +71,11 @@ export const PREDICATE_DOCS = Object.freeze([
|
|
|
71
71
|
"Module → Module. An import-backed, module-granular \"this file calls into that " +
|
|
72
72
|
"file\" edge — not resolved to a specific symbol (see callsSymbol for that)." },
|
|
73
73
|
{ prop: "mgx:callsSymbol", kind: "callsSymbol", description:
|
|
74
|
-
"Function/Method → Function/Class. A call site resolved to exactly one
|
|
75
|
-
"definition in the repository — unambiguous names only
|
|
76
|
-
"
|
|
74
|
+
"Function/Method → Function/Class/Method. A call site resolved to exactly one " +
|
|
75
|
+
"same-named definition in the repository — unambiguous names only (method targets " +
|
|
76
|
+
"resolve through a tiered simple-name fallback: same-module wins, interface members " +
|
|
77
|
+
"are excluded when one implementation remains); an ambiguous or external call is " +
|
|
78
|
+
"dropped rather than guessed (no wrong edge)." },
|
|
77
79
|
{ prop: "seon:declaresMethod", kind: "defines", description:
|
|
78
80
|
"Module → Function/Class/Method/Attribute/GlobalVariable. What a module's top-level " +
|
|
79
81
|
"scope actually declares." },
|
|
@@ -113,6 +115,23 @@ export const PREDICATE_DOCS = Object.freeze([
|
|
|
113
115
|
"split) and any doc-comment prose, used by the second-pass prose-to-symbol " +
|
|
114
116
|
"cross-reference index (PLAN_PROSE_INDEX.md) to resolve free-text object terms that " +
|
|
115
117
|
"don't exact-match an identifier." },
|
|
118
|
+
// GATED (un-typed interfaces, PLAN_UNTYPED_INTERFACES.md; ROADMAP backlog #15): documented
|
|
119
|
+
// here so "what does serves mean" is answerable, but `gated: true` means ingestSchemaDocs
|
|
120
|
+
// only materialises the SchemaPredicate individual when the predicate is ACTUALLY present
|
|
121
|
+
// in this build's vocabulary — i.e. only on an interfaces-ON graph that found at least one
|
|
122
|
+
// route. A default/OFF graph never has "mgx:serves"/"mgx:callsHttp" in its vocabulary, so
|
|
123
|
+
// the guard keeps it out, preserving byte-identity (see the extended guard test in
|
|
124
|
+
// schema-docs.test.mjs).
|
|
125
|
+
{ prop: "mgx:serves", kind: "serves", gated: true, description:
|
|
126
|
+
"Route → Function/Method. Route-declaration → handler-symbol — the HTTP boundary a " +
|
|
127
|
+
"syntax-only AST graph misses entirely (a route string is not a node, and a handler " +
|
|
128
|
+
"passed by reference at registration is not a call/import edge). Un-typed interfaces " +
|
|
129
|
+
"(PLAN_UNTYPED_INTERFACES.md), opt-in via SEONIX_INTERFACES=1 / [interfaces]." },
|
|
130
|
+
{ prop: "mgx:callsHttp", kind: "callsHttp", gated: true, description:
|
|
131
|
+
"Function/Method → Route. Client call-site → Route, the fuzzy other half of the same " +
|
|
132
|
+
"un-typed-interface chain: an HTTP client call's URL literal (fetch/axios/HttpClient) " +
|
|
133
|
+
"matched to a Route's normalised path template (verb agreement, segment-wildcard " +
|
|
134
|
+
"matching, match_threshold-gated) — completes client → route → handler." },
|
|
116
135
|
|
|
117
136
|
// ---- attributes (individual-scoped facts, not edges) ----
|
|
118
137
|
{ prop: "seon:startsAt", kind: "attribute", description:
|
|
@@ -240,6 +259,16 @@ export function ingestSchemaDocs(entities) {
|
|
|
240
259
|
}
|
|
241
260
|
}
|
|
242
261
|
|
|
262
|
+
// Present-predicate guard (ROADMAP backlog #15): a `gated: true` PREDICATE_DOCS entry (the
|
|
263
|
+
// un-typed-interfaces mgx:serves/mgx:callsHttp docs) only earns a SchemaPredicate individual
|
|
264
|
+
// when its prop is ACTUALLY present in this build's vocabulary — vocabulary entries for these
|
|
265
|
+
// props are themselves only emitted by interfaces.mjs when the gated feature is on AND found
|
|
266
|
+
// at least one match, so a default/OFF graph's vocabulary never contains them and this guard
|
|
267
|
+
// keeps their SchemaPredicate individuals out too, preserving byte-identity. Non-gated entries
|
|
268
|
+
// are unconditional, exactly as before.
|
|
269
|
+
const vocabProps = new Set((entities.vocabulary || []).map((v) => v?.prop));
|
|
270
|
+
const predicatePresent = (p) => !p.gated || vocabProps.has(p.prop);
|
|
271
|
+
|
|
243
272
|
entities.individuals ||= [];
|
|
244
273
|
const existingIds = new Set(entities.individuals.map((i) => i?.id));
|
|
245
274
|
for (const c of CLASS_DOCS) {
|
|
@@ -247,6 +276,7 @@ export function ingestSchemaDocs(entities) {
|
|
|
247
276
|
if (!existingIds.has(ind.id)) { entities.individuals.push(ind); existingIds.add(ind.id); }
|
|
248
277
|
}
|
|
249
278
|
for (const p of PREDICATE_DOCS) {
|
|
279
|
+
if (!predicatePresent(p)) continue;
|
|
250
280
|
const ind = predicateIndividual(p);
|
|
251
281
|
if (!existingIds.has(ind.id)) { entities.individuals.push(ind); existingIds.add(ind.id); }
|
|
252
282
|
}
|
package/src/server.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
20
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
21
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
22
22
|
import { loadConfig, ToolError } from "./config.mjs";
|
|
23
|
-
import { createTelemetry } from "./telemetry.mjs";
|
|
23
|
+
import { createTelemetry, truncateStr, looksLikeMiss } from "./telemetry.mjs";
|
|
24
24
|
import * as defaultSource from "./source.mjs";
|
|
25
25
|
import {
|
|
26
26
|
parseEntities,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
siteOf,
|
|
32
32
|
renderMembers,
|
|
33
33
|
renderSubclasses,
|
|
34
|
+
renderExternalSubclasses,
|
|
34
35
|
renderArchitecture,
|
|
35
36
|
renderTestsFor,
|
|
36
37
|
renderUntested,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
renderContextMore,
|
|
48
49
|
renderCalls,
|
|
49
50
|
callHint,
|
|
51
|
+
edgesOfKind,
|
|
50
52
|
renderFileHistory,
|
|
51
53
|
renderMethodHistory,
|
|
52
54
|
renderClassHistory,
|
|
@@ -319,10 +321,24 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
319
321
|
const graph = await loadGraph(config, source);
|
|
320
322
|
const { match, candidates } = resolveOrThrow(graph, symbol, "symbol");
|
|
321
323
|
const site = siteOf(match);
|
|
322
|
-
|
|
324
|
+
// Path-vs-symbol UX (WH_ESTATE_AUDIT §2b): a Module match must never fall through to the
|
|
325
|
+
// span-slicer — some extractors stamp modules with a thin 1-2-line span, which used to
|
|
326
|
+
// render a near-empty, useless "snippet" for `seonix_snippet <file>`. Whether or not a
|
|
327
|
+
// span exists, answer with the actionable alternatives: the symbols the module defines
|
|
328
|
+
// (each snippet-able directly), seonix_describe, or reading the file itself.
|
|
329
|
+
if (String(match.class || "") === "Module" || !site) {
|
|
330
|
+
const defined = edgesOfKind(graph, "defines")
|
|
331
|
+
.filter((e) => e.subject === match.id)
|
|
332
|
+
.map((e) => e.objectLabel || e.object);
|
|
333
|
+
const cap = 12;
|
|
334
|
+
const list = defined.length
|
|
335
|
+
? ` It defines: ${defined.slice(0, cap).join(", ")}${defined.length > cap ? `, +${defined.length - cap} more` : ""}.`
|
|
336
|
+
: "";
|
|
323
337
|
throw new ToolError(
|
|
324
|
-
`"${match.label}"
|
|
325
|
-
|
|
338
|
+
`"${match.label}" is a ${match.class || "Module"} — seonix_snippet takes a symbol ` +
|
|
339
|
+
`(a function/class name or Class.Method), not a file path.${list}` +
|
|
340
|
+
` seonix_snippet one of those, seonix_describe "${match.label}" for the module's full contents,` +
|
|
341
|
+
" or Read the file itself for raw source.",
|
|
326
342
|
);
|
|
327
343
|
}
|
|
328
344
|
// repo root = the dir containing .seonix/ (graphFile = <repo>/.seonix/graph.json)
|
|
@@ -389,8 +405,23 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
389
405
|
const symbol = String(args?.class || "").trim();
|
|
390
406
|
if (!symbol) throw new ToolError("class is required");
|
|
391
407
|
const graph = await loadGraph(config, source);
|
|
392
|
-
|
|
393
|
-
|
|
408
|
+
// Honest resolution (wh dogfood T14): exact match first; a fuzzy hit must never be
|
|
409
|
+
// presented as if it were the asked-for class.
|
|
410
|
+
const { match, candidates, exact } = resolveSymbol(graph, symbol);
|
|
411
|
+
if (exact) return renderSubclasses(graph, match);
|
|
412
|
+
// No individual with that exact name — an external/framework base may still appear
|
|
413
|
+
// as a supertype LABEL on inherits edges; if so, that's the real answer.
|
|
414
|
+
const external = renderExternalSubclasses(graph, symbol);
|
|
415
|
+
if (external) return external;
|
|
416
|
+
if (!match) {
|
|
417
|
+
throw new ToolError(
|
|
418
|
+
`no entity matching class "${symbol}" in the code-map graph. ` +
|
|
419
|
+
"Try a repo-relative path (e.g. django/utils/text.py), a basename, or seonix_search for a fuzzy lookup.",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
// Fuzzy-only candidates: answer for the best match but NAME the substitution up front.
|
|
423
|
+
const others = candidates.length ? ` (other near matches: ${candidates.map((c) => c.label).join(", ")})` : "";
|
|
424
|
+
return `no exact match for "${symbol}"; nearest is "${match.label}"${others}:\n${renderSubclasses(graph, match)}`;
|
|
394
425
|
}
|
|
395
426
|
if (name === "seonix_architecture") {
|
|
396
427
|
const graph = await loadGraph(config, source);
|
|
@@ -425,7 +456,16 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
425
456
|
`${cands ? `: ${cands}` : ""}. Re-ask with a more specific query.`,
|
|
426
457
|
);
|
|
427
458
|
}
|
|
428
|
-
|
|
459
|
+
// Actionable caller-miss (wh dogfood C10/C11/T9): an empty symbol-level call answer used to
|
|
460
|
+
// name the traversal but not the cause or the next move. Port seonix_calls' honest wording:
|
|
461
|
+
// method-level call edges can be sparse for a language, so a miss is weak evidence — point
|
|
462
|
+
// at the module-level fallback instead of leaving the agent at a dead end.
|
|
463
|
+
const missNote = (tmct_ask?.miss === true && /callsSymbol/.test(String(tmct_ask?.traversal || "")))
|
|
464
|
+
? "\nnote: method-level call edges (callsSymbol) can be sparse for this language — an empty answer " +
|
|
465
|
+
"is not proof of no callers. Fall back to module level: seonix_impact {\"module\":\"<file>\",\"depth\":1} " +
|
|
466
|
+
"for direct dependents, or ask \"which modules import <file>\"."
|
|
467
|
+
: "";
|
|
468
|
+
return `${content}${missNote}\n\n---seonix_ask---\n${JSON.stringify(tmct_ask, null, 2)}`;
|
|
429
469
|
}
|
|
430
470
|
if (
|
|
431
471
|
name === "seonix_tests_for" || name === "seonix_history" || name === "seonix_callers" ||
|
|
@@ -486,8 +526,12 @@ export function buildServer({ config = loadConfig(), source = defaultSource, env
|
|
|
486
526
|
const text = await dispatchTool(name, args || {}, { config, source });
|
|
487
527
|
tel?.record({
|
|
488
528
|
tool: name,
|
|
529
|
+
// wh dogfood backlog #6: telemetry recorded only tool+size — add the query/args text
|
|
530
|
+
// (truncated, correlation only), wall-clock duration, and a best-effort hit/miss flag.
|
|
531
|
+
query: { raw: truncateStr(JSON.stringify(args || {})) },
|
|
489
532
|
response: { count: text ? text.split("\n").length : 0, truncated: /truncated/i.test(text) },
|
|
490
533
|
perf: { ms_total: Date.now() - t0 },
|
|
534
|
+
quality: { miss: looksLikeMiss(text) },
|
|
491
535
|
cost: { returned_chars: text.length, returned_tokens_est: Math.ceil(text.length / 4) },
|
|
492
536
|
});
|
|
493
537
|
return { content: [{ type: "text", text }] };
|
package/src/summary.mjs
CHANGED
|
@@ -92,6 +92,7 @@ export function hubModules(entities, { prefix = "", top = 5 } = {}) {
|
|
|
92
92
|
export function buildSummary(entities, stats) {
|
|
93
93
|
const {
|
|
94
94
|
mode = "single", repos = [], skipped = [], languages = {}, graphBytes = 0,
|
|
95
|
+
store = "json", // "sqlite" ONLY when graph.db was actually built (SEONIX_STORE exported + write ok)
|
|
95
96
|
} = stats || {};
|
|
96
97
|
|
|
97
98
|
// per-class individual counts (the fix for the "7,345 = total?" misread)
|
|
@@ -133,9 +134,15 @@ export function buildSummary(entities, stats) {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
// effective history depth for the run = the deepest pass across repos
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
// (running max, not Math.max(...spread) — one arg per repo, and spread-into-call is banned
|
|
138
|
+
// over anything repo-scaled; see test/edges-of-kind.test.mjs)
|
|
139
|
+
let nameDepth = 0;
|
|
140
|
+
let symbolDepth = 0;
|
|
141
|
+
for (const r of repos) {
|
|
142
|
+
const d = r.historyDepth || { name: 0, symbol: 0 };
|
|
143
|
+
if ((d.name || 0) > nameDepth) nameDepth = d.name || 0;
|
|
144
|
+
if ((d.symbol || 0) > symbolDepth) symbolDepth = d.symbol || 0;
|
|
145
|
+
}
|
|
139
146
|
|
|
140
147
|
const repoRows = repos.map((r) => ({
|
|
141
148
|
name: r.name,
|
|
@@ -156,6 +163,7 @@ export function buildSummary(entities, stats) {
|
|
|
156
163
|
capChars: V8_STRING_CAP,
|
|
157
164
|
capPct: `~${((graphBytes / V8_STRING_CAP) * 100).toFixed(1)}%`,
|
|
158
165
|
},
|
|
166
|
+
store,
|
|
159
167
|
history: { tier: historyTier(nameDepth), depth: { name: nameDepth, symbol: symbolDepth } },
|
|
160
168
|
classes,
|
|
161
169
|
edges: { total: edgeTotal, byPredicate },
|
|
@@ -174,6 +182,9 @@ export function renderSummaryMd(summary) {
|
|
|
174
182
|
lines.push("# seonix index summary", "");
|
|
175
183
|
lines.push(`- mode: ${summary.mode}`);
|
|
176
184
|
lines.push(`- graph: ${fmt(summary.graph.bytes)} bytes (${summary.graph.capPct} of the V8 string cap)`);
|
|
185
|
+
// Store visibility: "sqlite" only when graph.db was actually built this run — a SEONIX_STORE
|
|
186
|
+
// that was set but never exported (invisible to child processes) reads `store: json` here.
|
|
187
|
+
lines.push(`- store: ${(summary.store || "json") === "sqlite" ? "sqlite (graph.db + graph.json; graph.json authoritative)" : "json (graph.json only)"}`);
|
|
177
188
|
lines.push(`- history: tier=${summary.history.tier} depth name=${summary.history.depth.name} symbol=${summary.history.depth.symbol}`);
|
|
178
189
|
const totalInds = Object.values(summary.classes).reduce((a, b) => a + b, 0);
|
|
179
190
|
lines.push(`- individuals: ${fmt(totalInds)} total`);
|
package/src/telemetry.mjs
CHANGED
|
@@ -20,6 +20,37 @@ const DROP_KEYS = new Set(["text", "content", "snippet"]);
|
|
|
20
20
|
* question, which is the correlation key and is not file content). */
|
|
21
21
|
const MAX_STR = 500;
|
|
22
22
|
|
|
23
|
+
/** Truncate a string to ~`max` chars (default 200) for a query/args preview field —
|
|
24
|
+
* the wh dogfood's "record the query/args text" ask, kept short deliberately (this is
|
|
25
|
+
* a correlation aid, not the file-content-safe `query.raw` exemption in redact() below,
|
|
26
|
+
* which some surfaces (locate/digest) still pass a short literal query through). Pure. */
|
|
27
|
+
export function truncateStr(s, max = 200) {
|
|
28
|
+
const str = String(s ?? "");
|
|
29
|
+
return str.length > max ? str.slice(0, max) : str;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Best-effort, cheap-to-tell hit/miss classifier over a tool's RENDERED text output
|
|
33
|
+
* (wh dogfood backlog #6: telemetry too thin to tell a query-mix's hit rate without
|
|
34
|
+
* re-reading prose at analysis time). Matches the honest-miss phrasings the
|
|
35
|
+
* codegraph.mjs renderers already use ("no entity matching", "none recorded", "cannot
|
|
36
|
+
* map", "unknown kind/tool/argument", the tmct_ask envelope's own `"miss": true`) plus
|
|
37
|
+
* the generic "no <kind> matches/found" shape. NOT exhaustive — a renderer can grow a
|
|
38
|
+
* new miss phrasing this never learns — so `true` is fairly reliable, `false` only
|
|
39
|
+
* means "no miss marker seen", not "definitely a hit". Pure. */
|
|
40
|
+
const MISS_PATTERNS = [
|
|
41
|
+
/no entity matching/i,
|
|
42
|
+
/no [a-z]+ (matches|found)/i,
|
|
43
|
+
/:\s*no [a-z][a-z /-]* recorded\b/i,
|
|
44
|
+
/\bnone recorded\b/i,
|
|
45
|
+
/\bcannot map\b/i,
|
|
46
|
+
/^unknown (kind|tool|argument)/i,
|
|
47
|
+
/"miss"\s*:\s*true/,
|
|
48
|
+
];
|
|
49
|
+
export function looksLikeMiss(text) {
|
|
50
|
+
if (typeof text !== "string" || !text) return false;
|
|
51
|
+
return MISS_PATTERNS.some((re) => re.test(text));
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
/** Enabled iff `SEONIX_TELEMETRY==="1"` OR `[telemetry] enabled=true` in the toml.
|
|
24
55
|
* The env wins BOTH directions: `SEONIX_TELEMETRY==="0"` force-disables even when the
|
|
25
56
|
* toml turns it on. Default OFF (anything but "1"/"0" in the env falls through to toml). */
|