@polycode-projects/seonix 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ // roslyn_build.mjs — shared build logic for the Roslyn C# semantic extractor
2
+ // (roslyn/publish/roslyn-extract.dll). Used by two callers: the lazy-build path
3
+ // in cs_roslyn.mjs (triggered on the first real C# extraction attempt in a
4
+ // process, not on `npm install`) and the explicit `npm run build:roslyn`
5
+ // prewarm command (e.g. baking the dll into a Docker image ahead of time).
6
+ //
7
+ // Best-effort and silent-by-default: no dotnet on PATH -> skip, tree-sitter
8
+ // (src/cs_treesitter.mjs) covers the absent case. A `dotnet publish` failure
9
+ // (e.g. no network for the first NuGet restore) is a warning, never thrown.
10
+ import { access } from "node:fs/promises";
11
+ import { spawn } from "node:child_process";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ export const ROOT = join(here, "..");
17
+ export const ROSLYN_DIR = join(ROOT, "roslyn");
18
+ export const DLL = join(ROSLYN_DIR, "publish", "roslyn-extract.dll");
19
+
20
+ function run(cmd, args, opts = {}) {
21
+ return new Promise((resolve) => {
22
+ const child = spawn(cmd, args, {
23
+ env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: "1", DOTNET_NOLOGO: "1" },
24
+ ...opts,
25
+ });
26
+ let out = "";
27
+ let err = "";
28
+ child.stdout?.on("data", (d) => (out += d));
29
+ child.stderr?.on("data", (d) => (err += d));
30
+ child.on("close", (code) => resolve({ code: code ?? -1, out, err }));
31
+ child.on("error", () => resolve({ code: -1, out, err: "" }));
32
+ });
33
+ }
34
+
35
+ async function exists(p) {
36
+ try { await access(p); return true; } catch { return false; }
37
+ }
38
+
39
+ /** Build the Roslyn dll if it's missing and `dotnet` is on PATH.
40
+ * @returns {Promise<boolean>} true if the dll is present after this call
41
+ * (already built, or built now); false otherwise (no dotnet, or publish
42
+ * failed — caller falls back to tree-sitter). */
43
+ export async function ensureRoslynBuilt({ quiet = false } = {}) {
44
+ if (await exists(DLL)) return true;
45
+
46
+ const dotnetCheck = await run("dotnet", ["--version"]);
47
+ if (dotnetCheck.code !== 0) return false; // no .NET SDK — tree-sitter fallback covers C#
48
+
49
+ const publish = await run("dotnet", ["publish", "-c", "Release", "-o", "publish"], { cwd: ROSLYN_DIR });
50
+ if (publish.code !== 0) {
51
+ if (!quiet) {
52
+ console.warn("[seonix] Roslyn C# extractor build failed — falling back to tree-sitter for C#.");
53
+ console.warn(`[seonix] dotnet publish exited ${publish.code}: ${publish.err.slice(-300)}`);
54
+ }
55
+ return false;
56
+ }
57
+ if (!quiet) console.log("[seonix] Roslyn C# extractor built (full semantic call/type resolution enabled).");
58
+ return true;
59
+ }
@@ -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). */