@polycode-projects/seonix 0.9.0 → 0.10.0

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