@polycode-projects/seonix 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ask.mjs ADDED
@@ -0,0 +1,762 @@
1
+ // ask.mjs — a mechanical (zero-model-call) natural-language query engine over the
2
+ // seonix graph. PLAN_MECHANICAL_CHAT.md (P0): a small, closed English grammar
3
+ // compiles a free-text question into a graph traversal over the SAME classified
4
+ // relation groups codegraph.mjs's other render functions read, then renders a
5
+ // templated, citation-faithful answer. No embeddings, no fuzzy matching beyond
6
+ // plain substring/component matching, no model calls — a miss is a stated blank,
7
+ // never a guess (the extraction pipeline's "no wrong edge" ethos, held at the
8
+ // query layer too).
9
+ //
10
+ // Four pure, independently-testable stages, orchestrated by ask():
11
+ // parseQuery (grammar) -> resolveObject (mechanical term resolution) ->
12
+ // traverse (graph lookup) -> render (templates).
13
+ //
14
+ // §3.5/3.6 (2026-07-02, ELIZA/PARRY-style breadth): parseQuery normalizes the
15
+ // raw text (contractions, g-drop, filler-strip), rewrites recognized negative-
16
+ // rhetorical constructions to their affirmative form, then runs TWO INDEPENDENT
17
+ // parsing STRATEGIES against the same normalized text — the original anchored-
18
+ // template matcher (precise, fast, unweakened) and a keyword-spotting/
19
+ // decomposition matcher (ELIZA's own mechanism: find the keyword, decompose
20
+ // around it, tolerate reordering/casual phrasing) — and MERGES their results:
21
+ // one strategy hit -> use it; both hit and agree -> use it (high confidence);
22
+ // both hit and DISAGREE -> a genuine parse-level ambiguity, surfaced honestly;
23
+ // neither hits -> the honest grammar miss. STRATEGIES is a plain array so a
24
+ // third strategy could join the same way, not a hardcoded two-branch special
25
+ // case.
26
+ //
27
+ // Where a parsed intent is temporal/churn-shaped (touched/since/cochange as a
28
+ // FILTER over commits, not a structural edge), this engine does NOT re-implement
29
+ // that — see PLAN_MECHANICAL_CHAT.md §2: matchQuery/nlToQuery (temporal.mjs) already
30
+ // own that surface for the Chronograph browser; ask.mjs's own `touches`/`cochange`
31
+ // verbs here answer "which modules touch/co-change with X" as ONE-HOP structural
32
+ // edges (mgx:touchedByCommit / mgx:changeCoupledWith), which is a different (and
33
+ // simpler) question than the browser's time-scrubbing view.
34
+
35
+ import { relationKind, impactClosure } from "./codegraph.mjs";
36
+ import {
37
+ VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
38
+ CONTRACTIONS, G_DROP, FILLER_WORDS, CONTEXT_PRONOUNS, NEGATION_FRAMES,
39
+ META_MEANING_VERBS,
40
+ } from "./ask-vocab.mjs";
41
+ import { lookupByProseTokens } from "./prose.mjs";
42
+
43
+ /** All edges of a classified relation kind, flattened across relation groups —
44
+ * a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
45
+ * exported+imported to avoid coupling this file's commit boundary to concurrent
46
+ * in-flight edits elsewhere in codegraph.mjs; both read the same relationKind
47
+ * classification, so they cannot drift in meaning). */
48
+ function edgesOfKind(graph, kind) {
49
+ const out = [];
50
+ for (const g of graph.relations) if (relationKind(g) === kind) out.push(...g.edges);
51
+ return out;
52
+ }
53
+
54
+ // ---- §3 vocabulary — single-sourced in ./ask-vocab.mjs; the grammar, the
55
+ // rephrase-hint text, and the renderer's noun forms all derive from those
56
+ // three tables, so they cannot drift. ----
57
+
58
+ // Predicate kinds carrying a finer, symbol-grain sibling (module-coarse -> fn/method-precise).
59
+ // "which functions call X" should read off callsSymbol (fn->fn), not the module-coarse "calls".
60
+ const SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
61
+ const FINE_ENTITY_TYPES = new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
62
+
63
+ const OVERFLOW_CAP = 12;
64
+
65
+ const PLURAL_FORMS = {
66
+ Function: ["function", "functions"], Method: ["method", "methods"],
67
+ Class: ["class", "classes"], Module: ["module", "modules"],
68
+ Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
69
+ Commit: ["commit", "commits"],
70
+ };
71
+ function nounFor(entityType, n) {
72
+ const [s, p] = PLURAL_FORMS[entityType] || ["result", "results"];
73
+ return n === 1 ? s : p;
74
+ }
75
+
76
+ // Every relation KIND token is already the correct 3rd-person-singular verb form
77
+ // ("X imports Y", "X calls Y", "X touches Y") EXCEPT "cochange", the one kind whose
78
+ // name is a bare noun/verb stem ("X cochange Y" is wrong; "X cochanges Y" is right) —
79
+ // so the reverse-shape zero-hit template below reads off this table instead of
80
+ // unconditionally appending "s" (which used to double-pluralize every other kind:
81
+ // "callss", "importss", "touchess").
82
+ const REVERSE_MISS_VERB = { cochange: "cochanges" };
83
+ function verbFor(kind) {
84
+ return REVERSE_MISS_VERB[kind] || kind;
85
+ }
86
+
87
+ function escapeRegex(s) {
88
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89
+ }
90
+
91
+ // ---- §3.5 normalization — runs before EITHER parsing strategy sees the text ----
92
+
93
+ /** contraction/informal-spelling table -> word-boundary regex, longest phrase
94
+ * first (so "there's" doesn't get shadowed by a shorter overlapping entry). */
95
+ const CONTRACTION_RE = new RegExp(
96
+ "\\b(" + Object.keys(CONTRACTIONS).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
97
+ "gi",
98
+ );
99
+
100
+ /** Free-text -> normalized free-text: contractions expanded, g-dropped words
101
+ * restored, filler/politeness words stripped. Idempotent and pure — the same
102
+ * input always normalizes the same way, so both parsing strategies see
103
+ * identical text and their outputs are directly comparable. Deliberately
104
+ * does NOT force lowercase: object/subject terms (module names like
105
+ * "myFile", class names like "Base") are meaningfully cased, and every
106
+ * substitution below already matches case-insensitively (`i`/`gi` flags) —
107
+ * forcing the whole string to lowercase would silently corrupt every parsed
108
+ * term's case instead. */
109
+ export function normalizeQuery(text) {
110
+ let q = String(text || "");
111
+ q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
112
+ q = q.replace(G_DROP, "$1ing");
113
+ if (FILLER_WORDS.length) {
114
+ const fillerRe = new RegExp(
115
+ "\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
116
+ "gi",
117
+ );
118
+ q = q.replace(fillerRe, " ");
119
+ }
120
+ return q.replace(/\s+/g, " ").trim();
121
+ }
122
+
123
+ /** Recognized negative-rhetorical constructions, rewritten to the affirmative
124
+ * form of the SAME question (§3.6) — a small pattern set, not a general
125
+ * negation-scope parser. First matching frame wins; unmatched text passes
126
+ * through unchanged. */
127
+ export function applyNegationFrames(text) {
128
+ for (const frame of NEGATION_FRAMES) {
129
+ const m = text.match(frame.re);
130
+ if (m) return frame.to(m).replace(/\s+/g, " ").trim();
131
+ }
132
+ return text;
133
+ }
134
+
135
+ // ---- strategy 1: anchored templates — fixed precedence order; first fit wins,
136
+ // never ambiguous at the template level (a question matching two shapes is a
137
+ // design smell we test against). Unweakened from the original P0 grammar. ----
138
+
139
+ const VERB_ALT = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
140
+ const ENTITY_ALT = Object.keys(ENTITY_TO_TYPE).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
141
+ const MODIFIER_ALT = Object.keys(MODIFIER_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
142
+ const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
143
+
144
+ const TEMPLATES = [
145
+ // T1 ASK: "does X import Y" / "is X a subclass of Y" -> Yes/No. Tried FIRST: it starts with
146
+ // does/is/do, which the reverse/forward templates below never match (those start with
147
+ // which/what), so precedence between T1 and the rest is structural, not a tie-break guess.
148
+ {
149
+ name: "ask",
150
+ re: new RegExp(`^(?:does|do)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
151
+ build: (m) => ({
152
+ shape: "ask", entityType: null, modifier: "direct",
153
+ kind: VERB_TO_KIND[m[2].toLowerCase()], subject: m[1].trim(), object: m[3].trim(),
154
+ }),
155
+ },
156
+ // T2 reverse: "which <entity> [<modifier>] <verb> <object>" — the operator's own example shape.
157
+ {
158
+ name: "reverse",
159
+ re: new RegExp(`^which\\s+(${ENTITY_ALT})\\s+(?:(${MODIFIER_ALT})\\s+)?(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
160
+ build: (m) => ({
161
+ shape: "reverse",
162
+ entityType: ENTITY_TO_TYPE[m[1].toLowerCase()],
163
+ modifier: m[2] ? MODIFIER_TO_KIND[m[2].toLowerCase()] : "direct",
164
+ kind: VERB_TO_KIND[m[3].toLowerCase()],
165
+ object: m[4].trim(),
166
+ }),
167
+ },
168
+ // T3 forward: "what does <object> <verb>" — X is given, list its R-related things.
169
+ {
170
+ name: "forward",
171
+ re: new RegExp(`^what\\s+(?:does|do)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
172
+ build: (m) => ({
173
+ shape: "forward", entityType: null, modifier: "direct",
174
+ kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
175
+ }),
176
+ },
177
+ // T4 meta: "what does <term> mean" — a question about the GRAPH'S OWN VOCABULARY
178
+ // (a SchemaClass/SchemaPredicate label, e.g. "cochange", or a raw prop token, e.g.
179
+ // "mgx:callsSymbol"), not a graph traversal over code edges. Tried after T3: T3 also
180
+ // starts "what does/do", but T3 only fires when the tail is a relation VERB_ALT
181
+ // phrase ("import"/"calls"/…), which "mean"/"means"/etc never are (disjoint tables —
182
+ // ask-vocab.mjs's file comment explains why they're kept separate), so the two never
183
+ // actually compete for the same input.
184
+ {
185
+ name: "meta-mean",
186
+ re: new RegExp(`^what\\s+(?:does|do|is|are)\\s+(.+?)\\s+(?:${META_ALT})\\??$`, "i"),
187
+ build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
188
+ },
189
+ // T5 meta: "what is a/an <term>" — the OTHER worked phrasing ("what is a Commit").
190
+ // The indefinite article is REQUIRED (not optional): a bare "what is <anything>"
191
+ // would also swallow "what is the meaning of this codebase" (an existing, deliberately
192
+ // honest grammar-miss regression case — ask.test.mjs/ask-dual-strategy.test.mjs both
193
+ // assert it stays null), which never mentions "a"/"an" before its tail. Requiring the
194
+ // article keeps this template's reach to the one worked shape without reopening that.
195
+ {
196
+ name: "meta-whatis",
197
+ re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
198
+ build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
199
+ },
200
+ ];
201
+
202
+ /** Strategy 1: the original P0 anchored grammar, unmodified in behavior — the
203
+ * whole (normalized) string must match one of TEMPLATES start-to-end. Pure. */
204
+ function parseAnchored(text) {
205
+ for (const t of TEMPLATES) {
206
+ const m = text.match(t.re);
207
+ if (m) return t.build(m);
208
+ }
209
+ return null;
210
+ }
211
+
212
+ // ---- strategy 2: keyword-spotting/decomposition — ELIZA's own mechanism: find
213
+ // the keyword(s) anywhere in the text, decompose around them, tolerate reordering
214
+ // and casual phrasing. Position-independent (no `^...$` anchor), so it tolerates
215
+ // "what calls this" / "who invokes this" / "something executes this, where from"
216
+ // — real phrasings the anchored grammar's fixed shapes don't cover. ----
217
+
218
+ const STOPWORDS = new Set([
219
+ "what", "who", "which", "where", "when", "why", "how",
220
+ "does", "do", "is", "are", "the", "a", "an", "of", "to", "from", "at", "in", "on",
221
+ "there", "something", "anything", "nothing", "one", "any",
222
+ ]);
223
+
224
+ /** Find the longest phrase from `table`'s keys that appears as a contiguous
225
+ * run of `words` (case already lowercased by the caller). Longest-match-first
226
+ * (multi-word phrases before single words) so "co-changes with" isn't
227
+ * shadowed by a shorter unrelated word. Returns {kind, start, end} (end
228
+ * exclusive) or null. */
229
+ function findPhrase(lcWords, table) {
230
+ const phrases = Object.keys(table).sort((a, b) => b.split(" ").length - a.split(" ").length);
231
+ for (const p of phrases) {
232
+ const pWords = p.split(" ");
233
+ for (let i = 0; i <= lcWords.length - pWords.length; i += 1) {
234
+ if (pWords.every((w, j) => lcWords[i + j] === w)) return { kind: table[p], start: i, end: i + pWords.length };
235
+ }
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /** Strategy 2: scan (already-normalized) text for a verb keyword anywhere,
241
+ * plus optional entity/modifier keywords anywhere, then split whatever's
242
+ * left (after removing the matched spans + stopwords) into the words BEFORE
243
+ * and AFTER the verb. Which side(s) are non-empty decides the shape —
244
+ * mirrors the three anchored shapes but by decomposition instead of a fixed
245
+ * template, so it tolerates reordering/casual phrasing the anchored regexes
246
+ * don't: text on BOTH sides ("does X import Y") -> ask{subject:before,
247
+ * object:after}; only AFTER the verb ("what calls this") -> reverse{object:
248
+ * after}; only BEFORE it ("what does X import") -> forward{object:before}.
249
+ * A lone context pronoun ("this"/"it"/"that"/"here") ending up as a resolved
250
+ * term is left as plain text — resolveTermOrContext (traverse-time)
251
+ * recognizes it against an optional contextId, so no separate flag is
252
+ * needed here. A misparse here costs nothing beyond an honest object-miss
253
+ * downstream (resolveObject never guesses). Pure. */
254
+ function parseKeywordSpot(text) {
255
+ // Strip a trailing "?" (mirrors the anchored templates' own `\??$`) and turn commas into
256
+ // pauses/spaces — but NEVER strip a mid-word ".": object terms are routinely dotted file/module
257
+ // names ("a.py", "utils.mjs"), and the anchored strategy captures those raw, so keyword-spot
258
+ // must too or the two strategies would "disagree" over a period that was never part of the intent.
259
+ const words = text.replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
260
+ const lcWords = words.map((w) => w.toLowerCase());
261
+ const verbHit = findPhrase(lcWords, VERB_TO_KIND);
262
+ if (!verbHit) return null;
263
+ const entityHit = findPhrase(lcWords, ENTITY_TO_TYPE);
264
+ const modifierHit = findPhrase(lcWords, MODIFIER_TO_KIND);
265
+ const consumed = new Set();
266
+ const mark = (hit) => { if (hit) for (let i = hit.start; i < hit.end; i += 1) consumed.add(i); };
267
+ mark(verbHit); mark(entityHit); mark(modifierHit);
268
+ const sideText = (from, to) => words
269
+ .slice(from, to)
270
+ .filter((_, j) => !consumed.has(from + j) && !STOPWORDS.has(lcWords[from + j]))
271
+ .join(" ")
272
+ .trim();
273
+ const beforeText = sideText(0, verbHit.start);
274
+ const afterText = sideText(verbHit.end, words.length);
275
+ const kind = verbHit.kind;
276
+ const entityType = entityHit ? ENTITY_TO_TYPE[lcWords.slice(entityHit.start, entityHit.end).join(" ")] : null;
277
+ const modifier = modifierHit ? MODIFIER_TO_KIND[lcWords.slice(modifierHit.start, modifierHit.end).join(" ")] : "direct";
278
+
279
+ if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
280
+ if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
281
+ if (beforeText) return { shape: "forward", entityType: null, modifier: "direct", kind, object: beforeText };
282
+ return null;
283
+ }
284
+
285
+ // ---- strategy merge — run both, agree/disagree/single/neither (§ above). A
286
+ // plain array + a merge step, so a third strategy plugs in the same way. ----
287
+
288
+ const STRATEGIES = [
289
+ { name: "anchored", parse: parseAnchored },
290
+ { name: "keyword-spot", parse: parseKeywordSpot },
291
+ ];
292
+
293
+ const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
294
+
295
+ /** Do two independently-produced parses mean the same graph query? Same
296
+ * shape, same relation kind, and matching term(s) (both subject and object
297
+ * for "ask"; just object otherwise) — anything less is a genuine
298
+ * disagreement, not a near-miss to paper over. */
299
+ function sameParse(p, q) {
300
+ if (p.shape !== q.shape || p.kind !== q.kind) return false;
301
+ if (p.shape === "ask") return cmpTerm(p.subject) === cmpTerm(q.subject) && cmpTerm(p.object) === cmpTerm(q.object);
302
+ return cmpTerm(p.object) === cmpTerm(q.object);
303
+ }
304
+
305
+ /** Compile a free-text question into {shape, kind, entityType, modifier,
306
+ * object[, subject]}, or null if NEITHER strategy fits — an honest grammar
307
+ * miss (§6.3), never a best-effort guess. When both strategies parse and
308
+ * AGREE, returns that parse unchanged (no fallback ordering — either
309
+ * strategy's own result is equally valid once they agree, per §above: "use
310
+ * either"). When both parse but DISAGREE (different shape/kind/term),
311
+ * returns {ambiguousParse: true, candidates: [...]} — a genuine "this could
312
+ * mean more than one thing" case, distinct from resolveObject's later
313
+ * object-resolution ambiguity. Pure. */
314
+ export function parseQuery(query) {
315
+ const raw = String(query || "").trim().replace(/\s+/g, " ");
316
+ if (!raw) return null;
317
+ const text = applyNegationFrames(normalizeQuery(raw));
318
+ if (!text) return null;
319
+ const hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text) })).filter((r) => r.parsed);
320
+ if (hits.length === 0) return null;
321
+ if (hits.length === 1) return hits[0].parsed;
322
+ const [a, b] = hits;
323
+ if (sameParse(a.parsed, b.parsed)) return a.parsed;
324
+ return { ambiguousParse: true, candidates: hits.map((h) => h.parsed) };
325
+ }
326
+
327
+ /** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
328
+ * uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
329
+ export function rephraseHint() {
330
+ return '"which <functions|classes|modules> <imports|calls|inherits from|tests|touched> <name>" or "what does <name> <import|call|inherit from>" or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary)';
331
+ }
332
+
333
+ // ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
334
+
335
+ function componentSet(s) {
336
+ return new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
337
+ }
338
+
339
+ /** Resolve a free-text object/subject term against the graph's individuals, in priority
340
+ * order (§4, generalized beyond the module-coupling worked example to cover every verb
341
+ * family's object grain — `inherits`/`calls` resolve against Class/Function names, not
342
+ * just modules): (1) exact label/id match, (2) an `ext:` unresolved-target match (today
343
+ * ext: targets are edge-endpoint STRINGS with no individual of their own — e.g. an
344
+ * unimported inherits base, or any import target — so this tier returns a synthetic
345
+ * {id:"ext:<name>", label:<name>, class:null} match rather than an `individuals` lookup;
346
+ * see PLAN_MECHANICAL_CHAT.md §10 on why external `imports` targets land here rather
347
+ * than tier 1/3 today), (3) boundary-aware substring/component match, (4) a prose-index
348
+ * fallback (PLAN_PROSE_INDEX.md §6): the term, tokenized the same way as a docstring, is
349
+ * looked up against `graph.proseIndex` via `lookupByProseTokens` — an exact WORD-level
350
+ * overlap match against a symbol's decomposed-identifier or doc-comment tokens, never a
351
+ * substring/fuzzy guess (the same "no wrong edge" standard as tiers 1-3, just over a
352
+ * different token source: prose rather than the literal identifier). Only ever consulted
353
+ * once every literal-identifier tier above has failed, and the result is tagged
354
+ * `matchedVia: "prose"` so a caller can tell the match came from prose content rather
355
+ * than the symbol's own name — the render layer does not currently read this (it treats
356
+ * a resolved match as a resolved match, same "honest miss vs genuine hit" binary tiers
357
+ * 1-3 already use), but the field is there for any caller that wants to surface it. (5)
358
+ * no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
359
+ * [, matchedVia]} — ambiguous on a true tier-3 score tie OR a true tier-4 overlap-count
360
+ * tie. */
361
+ export function resolveObject(graph, term) {
362
+ const t = String(term || "").trim();
363
+ if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
364
+ const tLc = t.toLowerCase();
365
+ const pool = graph.individuals;
366
+
367
+ const exact = pool.find((i) => String(i.label).toLowerCase() === tLc || String(i.id).toLowerCase() === tLc);
368
+ if (exact) return { match: exact, candidates: [], tier: 1, ambiguous: false };
369
+
370
+ // ext: targets never have their own individual (they're a raw edge-endpoint id) — find
371
+ // the actual (case-preserved) id off a real edge rather than reconstructing it, so a
372
+ // typo'd term can't silently "resolve" to an ext: id nothing in the graph references.
373
+ const extLc = `ext:${tLc}`;
374
+ let extId = null;
375
+ outer: for (const g of graph.relations) {
376
+ for (const e of g.edges) {
377
+ if (String(e.object).toLowerCase() === extLc) { extId = e.object; break outer; }
378
+ }
379
+ }
380
+ if (extId) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
381
+
382
+ const termComps = componentSet(t);
383
+ const scored = [];
384
+ for (const m of pool) {
385
+ const label = String(m.label || "").toLowerCase();
386
+ if (label.includes(tLc)) {
387
+ scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
388
+ continue;
389
+ }
390
+ const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
391
+ if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
392
+ }
393
+ scored.sort((a, b) => b.score - a.score);
394
+ if (scored.length) {
395
+ const [best, ...rest] = scored;
396
+ const tied = rest.filter((x) => x.score === best.score);
397
+ return {
398
+ match: best.ind,
399
+ candidates: rest.slice(0, 4).map((x) => x.ind),
400
+ tier: 3,
401
+ ambiguous: tied.length > 0,
402
+ };
403
+ }
404
+
405
+ // tier 4: prose-index fallback (PLAN_PROSE_INDEX.md §6) — see the function doc above.
406
+ const proseHits = lookupByProseTokens(graph.proseIndex, t);
407
+ if (proseHits.length) {
408
+ const [best, ...rest] = proseHits;
409
+ const bestInd = graph.byId.get(best.id);
410
+ if (bestInd) {
411
+ const tied = rest.filter((h) => h.score === best.score);
412
+ return {
413
+ match: bestInd,
414
+ candidates: rest.slice(0, 4).map((h) => graph.byId.get(h.id)).filter(Boolean),
415
+ tier: 4,
416
+ ambiguous: tied.length > 0,
417
+ matchedVia: "prose",
418
+ };
419
+ }
420
+ }
421
+ return { match: null, candidates: [], tier: null, ambiguous: false };
422
+ }
423
+
424
+ /** Resolve a term that may be a context pronoun ("this"/"it"/"that"/"here") —
425
+ * when `contextId` is given, resolve straight to that graph entity (a real
426
+ * click/focus in the caller's UI, not a guess); with no contextId, an honest
427
+ * miss explaining exactly what's missing, distinct from "no such name in the
428
+ * index". A non-pronoun term always falls through to the ordinary
429
+ * resolveObject tiers. */
430
+ function resolveTermOrContext(graph, term, contextId) {
431
+ if (CONTEXT_PRONOUNS.includes(String(term || "").trim().toLowerCase())) {
432
+ if (!contextId) return { match: null, candidates: [], tier: null, ambiguous: false, unresolvedPronoun: true };
433
+ const ind = graph.byId.get(contextId);
434
+ return ind
435
+ ? { match: ind, candidates: [], tier: 1, ambiguous: false }
436
+ : { match: null, candidates: [], tier: null, ambiguous: false, unresolvedPronoun: true };
437
+ }
438
+ return resolveObject(graph, term);
439
+ }
440
+
441
+ // ---- traversal — orchestrates codegraph.mjs's edgesOfKind; grain-refines a module-coarse
442
+ // edge (e.g. imports: Module->Module) down to a finer requested entityType via `defines`
443
+ // (Module -> top-level Function/Class/Method/Attribute), never re-implementing edge scans. ----
444
+
445
+ function refineToEntities(graph, moduleIds, entityType) {
446
+ const out = [];
447
+ for (const e of edgesOfKind(graph, "defines")) {
448
+ if (!moduleIds.has(e.subject)) continue;
449
+ const ind = graph.byId.get(e.object);
450
+ if (ind && ind.class === entityType) out.push(ind);
451
+ }
452
+ return out;
453
+ }
454
+
455
+ /** Safety net (paired with the render-branch fix earlier in this file's history): a
456
+ * {shape, kind, entityType} combination must be explicitly listed here to receive
457
+ * real non-"direct" modifier behavior. Anything parsing to a non-"direct" modifier
458
+ * that ISN'T listed gets an honest "not supported yet" response from render() —
459
+ * never a silent fallback to direct-only behavior. This means a future
460
+ * MODIFIER_TO_KIND addition that forgets to wire traverse()/render() for it fails
461
+ * loud here, by construction, rather than quietly behaving as if the modifier had
462
+ * never been given (the exact bug class this file's own render-routing fix, above,
463
+ * just caught). Today's only non-"direct" value is "transitive" (PLAN_MECHANICAL_
464
+ * CHAT.md P1), wired below for reverse-shape imports/calls closures over
465
+ * impactClosure (codegraph.mjs) — module-coarse only; the fine-grained
466
+ * callsSymbol/touchesSymbol siblings and every other predicate kind have no
467
+ * closure primitive yet, and forward-shape currently never parses a non-"direct"
468
+ * modifier at all (both parsing strategies hardcode modifier:"direct" for it). */
469
+ function modifierIsWired(shape, kind, entityType) {
470
+ return shape === "reverse" && (kind === "imports" || kind === "calls") && (!entityType || entityType === "Module");
471
+ }
472
+ // Matches renderImpact's own default (codegraph.mjs) — impactClosure is reused as-is,
473
+ // not reimplemented, so its own depth convention is the honest one to inherit.
474
+ const TRANSITIVE_MAX_DEPTH = 8;
475
+
476
+ /** Compile a parsed query into a graph lookup. Pure given (graph, parsed, opts).
477
+ * `opts.contextId` resolves a context pronoun ("this"/"it"/…) when the parse
478
+ * needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
479
+ * answer?, unresolvedPronoun?} — `answer` only set for the "ask" shape.
480
+ * `matches` is always an array of individuals (or edge records for "ask"). */
481
+ export function traverse(graph, parsed, { contextId = null } = {}) {
482
+ if (!parsed || parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
483
+ const { shape, kind, entityType } = parsed;
484
+
485
+ // meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
486
+ // is a Commit") — looked up against the SchemaClass/SchemaPredicate individuals
487
+ // schema-docs.mjs's ingestSchemaDocs merged into the graph, not a code-edge traversal.
488
+ // Matched by exact (case-insensitive) label ("cochange", "Commit") OR the raw `token`
489
+ // attribute a SchemaPredicate also carries ("mgx:callsSymbol") — never substring/fuzzy,
490
+ // same discipline as resolveObject's own tiers: a real term match or an honest miss.
491
+ if (shape === "meta") {
492
+ const term = String(parsed.object || "").trim();
493
+ const termLc = term.toLowerCase();
494
+ const match = (graph.individuals || []).find((i) => {
495
+ if (i.class !== "SchemaClass" && i.class !== "SchemaPredicate") return false;
496
+ if (String(i.label).toLowerCase() === termLc) return true;
497
+ const token = (i.attributes || []).find((a) => a.key === "token")?.value;
498
+ return token && String(token).toLowerCase() === termLc;
499
+ });
500
+ if (!match) return { matches: [], objMatch: null, candidates: [], traversal: `schema lookup for "${term}"`, ambiguous: false };
501
+ return {
502
+ matches: [match], objMatch: match, candidates: [],
503
+ traversal: `schema lookup for "${term}"`, ambiguous: false,
504
+ };
505
+ }
506
+
507
+ // §modifier support gate (safety net, see modifierIsWired's own doc above) — checked
508
+ // BEFORE object resolution, so an unsupported modifier+kind combination gets its own
509
+ // honest capability-gap message rather than masquerading as an object-miss, or worse,
510
+ // silently behaving as if "transitively"/"indirectly" had never been said.
511
+ if (parsed.modifier && parsed.modifier !== "direct" && !modifierIsWired(shape, kind, entityType)) {
512
+ return {
513
+ matches: [], objMatch: null, candidates: [], ambiguous: false,
514
+ unsupportedModifier: true,
515
+ traversal: `modifier "${parsed.modifier}" requested for a "${kind}" query — no closure traversal exists for this combination yet`,
516
+ };
517
+ }
518
+
519
+ if (shape === "ask") {
520
+ const subj = resolveTermOrContext(graph, parsed.subject, contextId);
521
+ const obj = resolveTermOrContext(graph, parsed.object, contextId);
522
+ if (!subj.match || !obj.match) {
523
+ return {
524
+ matches: [], objMatch: obj.match, candidates: obj.candidates, traversal: null, ambiguous: false, answer: null,
525
+ unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
526
+ };
527
+ }
528
+ const edges = edgesOfKind(graph, kind).filter((e) => e.subject === subj.match.id && e.object === obj.match.id);
529
+ return {
530
+ matches: edges, answer: edges.length > 0, objMatch: obj.match, subjMatch: subj.match,
531
+ candidates: [], traversal: `${kind} edge from ${subj.match.label} to ${obj.match.label}`, ambiguous: false,
532
+ };
533
+ }
534
+
535
+ // reverse and forward both resolve one named term ("object" in the parsed shape — for
536
+ // forward it is the query's grammatical subject, e.g. "what does X import" -> parsed.object = X).
537
+ const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = resolveTermOrContext(graph, parsed.object, contextId);
538
+ if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
539
+
540
+ if (shape === "forward") {
541
+ const edges = edgesOfKind(graph, kind).filter((e) => e.subject === objMatch.id);
542
+ const matches = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
543
+ return { matches, objMatch, candidates, traversal: `${kind} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
544
+ }
545
+
546
+ // reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
547
+ // "imports" or "calls" and entityType is null/"Module" here. Reuses impactClosure
548
+ // (codegraph.mjs) AS-IS rather than reimplementing a closure — impactClosure's own
549
+ // dependents map is a REVERSE closure over imports+calls edges TOGETHER (renderImpact's
550
+ // "what would break" framing), not a strict single-predicate chain, so a query for
551
+ // "transitively imports" and one for "transitively calls" both resolve to the SAME
552
+ // mixed reverse-dependency closure. That's a real, deliberate scope decision (matching
553
+ // the plan's own instruction to wire onto "renderImpact's existing closure traversal"
554
+ // rather than build a new predicate-pure one) — the traversal receipt below says so
555
+ // honestly rather than implying a narrower single-predicate result than what was
556
+ // actually computed.
557
+ if (parsed.modifier === "transitive") {
558
+ const levels = impactClosure(graph, objMatch, { maxDepth: TRANSITIVE_MAX_DEPTH });
559
+ const matches = levels.flat().map((d) => graph.byId.get(d.id)).filter(Boolean);
560
+ return {
561
+ matches, objMatch, candidates, ambiguous, matchedVia,
562
+ traversal: `reverse dependency closure over imports+calls edges from ${objMatch.label} (impactClosure, maxDepth=${TRANSITIVE_MAX_DEPTH})`,
563
+ };
564
+ }
565
+
566
+ // reverse: "which <entityType> R <objMatch>"
567
+ const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
568
+ if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
569
+ const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
570
+ const matches = edges.map((e) => graph.byId.get(e.subject)).filter((i) => i && i.class === entityType);
571
+ return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}`, ambiguous, matchedVia };
572
+ }
573
+
574
+ // General case: some predicates are already fine-grained (inherits: Class->Class, contains:
575
+ // Class->Member) and some are module-coarse (imports/calls/tests/cochange: Module->Module).
576
+ // Rather than assume one or the other, check what the edge's actual subjects ARE: if they
577
+ // already match the requested entityType, use them directly (inherits); only when they're
578
+ // Module individuals and a FINER entityType was asked for do we refine via `defines`
579
+ // (imports) — never blindly treat an edge's subject id as if it were always a module id.
580
+ const edges = edgesOfKind(graph, kind).filter((e) => e.object === objMatch.id);
581
+ const subjects = edges.map((e) => graph.byId.get(e.subject)).filter(Boolean);
582
+ let matches, grainNote = "";
583
+ if (!entityType) {
584
+ matches = subjects;
585
+ } else {
586
+ const direct = subjects.filter((s) => s.class === entityType);
587
+ if (direct.length) {
588
+ matches = direct;
589
+ } else if (entityType !== "Module" && subjects.some((s) => s.class === "Module")) {
590
+ const moduleIds = new Set(subjects.filter((s) => s.class === "Module").map((s) => s.id));
591
+ matches = refineToEntities(graph, moduleIds, entityType);
592
+ grainNote = `, then ${entityType} defined in the matched module(s)`;
593
+ } else {
594
+ matches = [];
595
+ }
596
+ }
597
+ return { matches, objMatch, candidates, traversal: `${kind} edges where object = ${objMatch.label}${grainNote}`, ambiguous, matchedVia };
598
+ }
599
+
600
+ // ---- §5 templated renderer — string interpolation + grouping/pluralization/overflow rules,
601
+ // never generation; every sentence is read off a matched edge/individual. ----
602
+
603
+ function moduleLabelOf(ind) {
604
+ if (ind.class === "Module") return ind.label;
605
+ const site = (ind.attributes || []).find((a) => a.key === "site")?.value;
606
+ if (site) return String(site).split(":")[0];
607
+ const m = String(ind.id || "").match(/^fn:(.+)#/);
608
+ return m ? m[1] : "(unknown module)";
609
+ }
610
+
611
+ function symbolLabelOf(ind) {
612
+ const label = String(ind.label || ind.id || "");
613
+ return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
614
+ }
615
+
616
+ /** One-line, honest rephrasing of a candidate parse — used to describe a
617
+ * parse-level disagreement between strategies without pretending to pick
618
+ * a winner. Template only, reads straight off the parsed fields. */
619
+ function describeParse(p) {
620
+ const obj = p.object ?? p.subject ?? "?";
621
+ const ent = p.entityType ? nounFor(p.entityType, 2) + " that " : "";
622
+ return `${ent}${p.kind} "${obj}"`;
623
+ }
624
+
625
+ /** Render a compiled query result into {content, miss, ambiguous, matches?, candidates?}.
626
+ * Every branch is a template, not generation — §5's grouping/pluralization/overflow rules. */
627
+ export function render(parsed, result) {
628
+ if (!parsed) {
629
+ return { content: `couldn't parse this as a graph question. Try: ${rephraseHint()}`, miss: true, ambiguous: false };
630
+ }
631
+ if (parsed.ambiguousParse) {
632
+ const options = parsed.candidates.map((p, i) => `${i + 1}) ${describeParse(p)}`).join(" or ");
633
+ return {
634
+ content: `this could mean more than one thing: ${options} — try rephrasing more specifically.`,
635
+ miss: false, ambiguous: true, candidates: parsed.candidates.map(describeParse),
636
+ };
637
+ }
638
+ if (result.unresolvedPronoun) {
639
+ return {
640
+ content: `"${parsed.object ?? parsed.subject}" needs a selected node to refer to — click a node first, or name it directly.`,
641
+ miss: true, ambiguous: false,
642
+ };
643
+ }
644
+ if (result.unsupportedModifier) {
645
+ return {
646
+ content: `the "${parsed.modifier}" modifier isn't supported for "${parsed.kind}" queries yet — only imports/calls (module-level) have a transitive closure today.`,
647
+ miss: true, ambiguous: false,
648
+ };
649
+ }
650
+ if (parsed.shape === "meta") {
651
+ if (!result.objMatch) {
652
+ return {
653
+ content: `"${parsed.object}" isn't a term in this graph's own vocabulary (no matching class or predicate).`,
654
+ miss: true, ambiguous: false,
655
+ };
656
+ }
657
+ const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
658
+ const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
659
+ return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
660
+ }
661
+ if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
662
+ return {
663
+ content: `no module matching "${parsed.object}" found in the index.`,
664
+ miss: true, ambiguous: false, candidates: [],
665
+ };
666
+ }
667
+ if (result.ambiguous) {
668
+ return {
669
+ content: `"${parsed.object}" matches more than one module ambiguously — please narrow the term.`,
670
+ miss: false, ambiguous: true, candidates: [result.objMatch, ...(result.candidates || [])].map((i) => i.label),
671
+ };
672
+ }
673
+ if (parsed.shape === "ask") {
674
+ if (!result.objMatch || !result.subjMatch) {
675
+ return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
676
+ }
677
+ return {
678
+ content: result.answer ? `Yes. (${result.traversal})` : `No — no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
679
+ miss: !result.answer, ambiguous: false,
680
+ };
681
+ }
682
+ if (!result.matches.length) {
683
+ // forward: parsed.object is the GIVEN subject ("what does X import" -> X), not a
684
+ // search target — "No modules found that X." reads as broken grammar (and X's own
685
+ // relation edges are simply absent, not "not found"), so this shape gets its own,
686
+ // subject-first phrasing rather than reusing reverse's "found ... that OBJECT" template.
687
+ if (parsed.shape === "forward") {
688
+ return {
689
+ content: `${result.objMatch.label} has no ${parsed.kind} edges in the index. (traversal: ${result.traversal || "no traversal resolved"})`,
690
+ miss: true, ambiguous: false,
691
+ };
692
+ }
693
+ const entityWord = nounFor(parsed.entityType || "Module", 2);
694
+ return {
695
+ content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. (traversal: ${result.traversal || "no traversal resolved"})`,
696
+ miss: true, ambiguous: false,
697
+ };
698
+ }
699
+ // Route by the MATCHED entities' actual class, not just the parsed hint — a reverse
700
+ // query phrased without an explicit entity keyword ("what imports X", entityType null)
701
+ // still resolves to Module individuals for a module-level relation like "imports", and
702
+ // grouping those by-module (module label as its own "symbol" label) reads as nonsense
703
+ // ("in a.mjs there is a.mjs"). The fine-grained per-symbol grouping below is only
704
+ // meaningful when the matches are sub-module entities (functions/classes/etc).
705
+ if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => m.class === "Module")) {
706
+ const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.label);
707
+ const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
708
+ return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
709
+ }
710
+ // reverse, fine-grained entity: group by module, one clause per module (§5 grouping rule) —
711
+ // the FIRST module states "in {module} there is …"; each SUBSEQUENT module states
712
+ // "there is … in {module}" (module trails, not leads), matching the plan's worked example.
713
+ const byModule = new Map();
714
+ for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
715
+ const mod = moduleLabelOf(m);
716
+ if (!byModule.has(mod)) byModule.set(mod, []);
717
+ byModule.get(mod).push(symbolLabelOf(m));
718
+ }
719
+ const listJoin = (syms) =>
720
+ syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
721
+ const clauses = [...byModule.entries()].map(([mod, syms], i) => {
722
+ const list = listJoin(syms);
723
+ return i === 0 ? `in ${mod} there is ${list}` : `there is ${list} in ${mod}`;
724
+ });
725
+ const extra = result.matches.length > OVERFLOW_CAP ? ` …and ${result.matches.length - OVERFLOW_CAP} more` : "";
726
+ return { content: clauses.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
727
+ }
728
+
729
+ // ---- orchestration — the seonix_ask entry point (§6.3: parse -> resolve -> traverse -> render) ----
730
+
731
+ /** Answer a free-text question over the graph, mechanically. `opts.contextId`
732
+ * resolves a context pronoun ("this"/"it"/…) — wired from a UI's currently-
733
+ * selected node when one exists; omit it in the bare CLI/MCP surface, where
734
+ * a pronoun then produces an honest miss rather than a guess. Returns the
735
+ * full {content, seonix_ask:{mechanical,parsed,matches,traversal,miss,
736
+ * ambiguous,candidates?}} envelope §6.2 specifies. Zero model calls. */
737
+ export function ask(graph, query, { contextId = null } = {}) {
738
+ const parsed = parseQuery(query);
739
+ const result = traverse(graph, parsed, { contextId });
740
+ const rendered = render(parsed, result);
741
+ return {
742
+ content: rendered.content,
743
+ seonix_ask: {
744
+ mechanical: true,
745
+ parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
746
+ matches: (result.matches || []).map((m) => ({
747
+ id: m.id, label: m.label, type: m.class, module: m.class ? moduleLabelOf(m) : undefined,
748
+ })),
749
+ traversal: result.traversal || null,
750
+ miss: !!rendered.miss,
751
+ ambiguous: !!rendered.ambiguous,
752
+ // Confidence provenance (PLAN_PROSE_INDEX.md §6): set only when resolveObject's
753
+ // object-term resolution fell all the way through to the tier-4 prose-index
754
+ // fallback (a word-level match against a decomposed identifier/doc-comment, not
755
+ // the symbol's own literal name) — null for every other (literal-identifier) tier,
756
+ // so a caller can distinguish "matched the thing's own name" from "matched
757
+ // something it merely talks about" without the rendered content wording changing.
758
+ matchedVia: result.matchedVia || null,
759
+ ...(rendered.ambiguous ? { candidates: rendered.candidates } : {}),
760
+ },
761
+ };
762
+ }