@polycode-projects/seonix 0.2.1 → 0.3.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 CHANGED
@@ -2,10 +2,13 @@
2
2
  // seonix graph. PLAN_MECHANICAL_CHAT.md (P0): a small, closed English grammar
3
3
  // compiles a free-text question into a graph traversal over the SAME classified
4
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).
5
+ // templated, citation-faithful answer. No embeddings, no generative model calls —
6
+ // a miss is a stated blank, never a guess (the extraction pipeline's "no wrong
7
+ // edge" ethos, held at the query layer too). Term/keyword matching is TIERED
8
+ // (2026-07-02, two-level fuzzy): exact curated match always wins; a Node-only
9
+ // wink-nlp LEMMA/POS tier and a bounded Damerau-Levenshtein FUZZY tier fire only
10
+ // on a miss, a unique fuzzy hit is announced in the answer ("assuming you
11
+ // meant …"), and any tie surfaces as ambiguity — never a silently-broken guess.
9
12
  //
10
13
  // Four pure, independently-testable stages, orchestrated by ask():
11
14
  // parseQuery (grammar) -> resolveObject (mechanical term resolution) ->
@@ -35,10 +38,19 @@
35
38
  import { relationKind, impactClosure } from "./codegraph.mjs";
36
39
  import {
37
40
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
38
- CONTRACTIONS, G_DROP, FILLER_WORDS, CONTEXT_PRONOUNS, NEGATION_FRAMES,
39
- META_MEANING_VERBS,
41
+ CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
42
+ CONTEXT_PRONOUNS, NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, META_MEANING_VERBS,
43
+ WHERE_MARKERS, MENTION_MARKERS,
40
44
  } from "./ask-vocab.mjs";
41
45
  import { lookupByProseTokens } from "./prose.mjs";
46
+ // The OPTIONAL Node-only wink-nlp adapter (lemma/POS tier). BOUNDARY: the inlined
47
+ // viewer bundle (viz.mjs askSource) strips this import line and never inlines
48
+ // ask-nlp.mjs, so in the browser `nlpAdapter` is simply an undeclared identifier —
49
+ // defaultNlp() below reads it through `typeof`, the one operator that touches an
50
+ // undeclared name without throwing, and the portable single-file HTML degrades to
51
+ // adapter-less parsing (curated tables + bounded fuzzy still on) instead of
52
+ // shipping a ~1MB language model inside the page.
53
+ import { nlpAdapter } from "./ask-nlp.mjs";
42
54
 
43
55
  /** All edges of a classified relation kind, flattened across relation groups —
44
56
  * a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
@@ -60,6 +72,14 @@ function edgesOfKind(graph, kind) {
60
72
  const SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
61
73
  const FINE_ENTITY_TYPES = new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
62
74
 
75
+ // Query-side UNION families (2026-07-02 query families): a parsed kind that is not
76
+ // itself a stored predicate but a curated union of stored kinds — "what uses X"
77
+ // honestly means the import graph AND the call graph together. Everything else
78
+ // maps to itself; grain selection (asked entity type) then narrows the union's
79
+ // subjects the same way it narrows a single kind's.
80
+ const KIND_UNIONS = { uses: ["imports", "calls", "callsSymbol"] };
81
+ const kindsFor = (kind) => KIND_UNIONS[kind] || [kind];
82
+
63
83
  const OVERFLOW_CAP = 12;
64
84
 
65
85
  const PLURAL_FORMS = {
@@ -67,6 +87,9 @@ const PLURAL_FORMS = {
67
87
  Class: ["class", "classes"], Module: ["module", "modules"],
68
88
  Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
69
89
  Commit: ["commit", "commits"],
90
+ // "Change" is ask-vocab.mjs's pseudo-type (a wildcard over the touch traversal's
91
+ // results, never a node class) — it still needs noun forms for zero-hit templates.
92
+ Change: ["change", "changes"],
70
93
  };
71
94
  function nounFor(entityType, n) {
72
95
  const [s, p] = PLURAL_FORMS[entityType] || ["result", "results"];
@@ -92,10 +115,25 @@ function escapeRegex(s) {
92
115
 
93
116
  /** contraction/informal-spelling table -> word-boundary regex, longest phrase
94
117
  * 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",
118
+ const tableRe = (table) => new RegExp(
119
+ "\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
120
+ "gi",
121
+ );
122
+ const CONTRACTION_RE = tableRe(CONTRACTIONS);
123
+ // misspelling/wrong-word CORRECTIONS (ask-vocab.mjs) — same mechanism, applied
124
+ // after contractions: restore the intended spelling first, then map misused
125
+ // words to their canonical schema term. Deterministic and curated, so they run
126
+ // BEFORE either parse strategy and ahead of the bounded edit-distance fallback.
127
+ // The trailing lookahead refuses to rewrite a word glued to a dotted extension:
128
+ // WRONG_WORDS entries are real English words that plausibly NAME modules
129
+ // ("revision.mjs", "property.py"), and a correction that corrupts an object
130
+ // term would be a guess — the exact thing these tables exist to avoid.
131
+ const correctionRe = (table) => new RegExp(
132
+ "\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b(?!\\.[a-z0-9])",
97
133
  "gi",
98
134
  );
135
+ const MISSPELLING_RE = correctionRe(MISSPELLINGS);
136
+ const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
99
137
 
100
138
  /** Free-text -> normalized free-text: contractions expanded, g-dropped words
101
139
  * restored, filler/politeness words stripped. Idempotent and pure — the same
@@ -109,6 +147,8 @@ const CONTRACTION_RE = new RegExp(
109
147
  export function normalizeQuery(text) {
110
148
  let q = String(text || "");
111
149
  q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
150
+ q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
151
+ q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
112
152
  q = q.replace(G_DROP, "$1ing");
113
153
  if (FILLER_WORDS.length) {
114
154
  const fillerRe = new RegExp(
@@ -120,12 +160,15 @@ export function normalizeQuery(text) {
120
160
  return q.replace(/\s+/g, " ").trim();
121
161
  }
122
162
 
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. */
163
+ /** Recognized rhetorical/idiomatic constructions rewritten to the canonical form
164
+ * of the SAME question before either parse strategy sees the text a small
165
+ * closed pattern set, not a general rewriter. Two families, tried in order:
166
+ * COMMIT_CONTENT_FRAMES first ("what was in commit <sha>" -> "what did <sha>
167
+ * touch"; sha-anchored, so it can't swallow a containment question), then the
168
+ * §3.6 negative-rhetorical NEGATION_FRAMES. First matching frame across both wins
169
+ * and rewriting stops; unmatched text passes through unchanged. */
127
170
  export function applyNegationFrames(text) {
128
- for (const frame of NEGATION_FRAMES) {
171
+ for (const frame of [...COMMIT_CONTENT_FRAMES, ...NEGATION_FRAMES]) {
129
172
  const m = text.match(frame.re);
130
173
  if (m) return frame.to(m).replace(/\s+/g, " ").trim();
131
174
  }
@@ -143,11 +186,12 @@ const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).
143
186
 
144
187
  const TEMPLATES = [
145
188
  // 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
189
+ // does/is/do/did, which the reverse/forward templates below never match (those start with
147
190
  // which/what), so precedence between T1 and the rest is structural, not a tie-break guess.
191
+ // "did" joins does/do for the past-tense commit forms ("did commit <sha> touch X").
148
192
  {
149
193
  name: "ask",
150
- re: new RegExp(`^(?:does|do)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
194
+ re: new RegExp(`^(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
151
195
  build: (m) => ({
152
196
  shape: "ask", entityType: null, modifier: "direct",
153
197
  kind: VERB_TO_KIND[m[2].toLowerCase()], subject: m[1].trim(), object: m[3].trim(),
@@ -166,9 +210,10 @@ const TEMPLATES = [
166
210
  }),
167
211
  },
168
212
  // T3 forward: "what does <object> <verb>" — X is given, list its R-related things.
213
+ // "did" joins does/do for the past-tense commit forms ("what did commit <sha> touch").
169
214
  {
170
215
  name: "forward",
171
- re: new RegExp(`^what\\s+(?:does|do)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
216
+ re: new RegExp(`^what\\s+(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
172
217
  build: (m) => ({
173
218
  shape: "forward", entityType: null, modifier: "direct",
174
219
  kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
@@ -197,14 +242,48 @@ const TEMPLATES = [
197
242
  re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
198
243
  build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
199
244
  },
245
+ // T6 mention: "where is <term> mentioned/referenced" — the prose/mentions surface
246
+ // (2026-07-02 query families). Tried BEFORE T7: T7's trailing marker is optional,
247
+ // so without this ordering it would swallow the mention question and lose the
248
+ // marker that distinguishes "locate the definition" from "list the prose mentions".
249
+ {
250
+ name: "mention",
251
+ re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)\\s+(?:${MENTION_MARKERS.map(escapeRegex).join("|")})\\??$`, "i"),
252
+ build: (m) => ({ shape: "mentions", entityType: null, modifier: "direct", kind: "mentions", object: m[1].trim() }),
253
+ },
254
+ // T7 where: "where is <term> [defined|declared|located|implemented]" — definition
255
+ // location off the site attribute / defining module. "where" starts no other
256
+ // template, so precedence against T1-T5 is structural.
257
+ {
258
+ name: "where",
259
+ re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)(?:\\s+(?:${WHERE_MARKERS.map(escapeRegex).join("|")}))?\\??$`, "i"),
260
+ build: (m) => ({ shape: "where", entityType: null, modifier: "direct", kind: "where", object: m[1].trim() }),
261
+ },
262
+ // T8 when: "when did <term> [last] change/touched/updated…" — temporal shape over
263
+ // the touches edges + commit date attributes. The verb slot reuses VERB_ALT, but
264
+ // only the touches family carries dates to answer with, so build() rejects any
265
+ // other kind (returning null falls through — parseAnchored tolerates it) rather
266
+ // than pretending "when did X import Y" has a temporal answer.
267
+ {
268
+ name: "when",
269
+ re: new RegExp(`^when\\s+(?:did|does|do|was|were|is)\\s+(.+?)\\s+(?:last\\s+)?(${VERB_ALT})\\??$`, "i"),
270
+ build: (m) => (VERB_TO_KIND[m[2].toLowerCase()] === "touches"
271
+ ? { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: m[1].trim() }
272
+ : null),
273
+ },
200
274
  ];
201
275
 
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. */
276
+ /** Strategy 1: the original P0 anchored grammar the whole (normalized) string
277
+ * must match one of TEMPLATES start-to-end. A build() may return null to reject
278
+ * a structural match on curated grounds (T8's non-temporal verbs); the scan then
279
+ * simply continues, exactly as if the regex had not matched. Pure. */
204
280
  function parseAnchored(text) {
205
281
  for (const t of TEMPLATES) {
206
282
  const m = text.match(t.re);
207
- if (m) return t.build(m);
283
+ if (m) {
284
+ const parsed = t.build(m);
285
+ if (parsed) return parsed;
286
+ }
208
287
  }
209
288
  return null;
210
289
  }
@@ -217,26 +296,112 @@ function parseAnchored(text) {
217
296
 
218
297
  const STOPWORDS = new Set([
219
298
  "what", "who", "which", "where", "when", "why", "how",
220
- "does", "do", "is", "are", "the", "a", "an", "of", "to", "from", "at", "in", "on",
299
+ "does", "do", "did", "is", "are", "was", "were", "the", "a", "an", "of", "to", "from", "at", "in", "on",
221
300
  "there", "something", "anything", "nothing", "one", "any",
301
+ // temporal filler in when-questions ("when was X last touched") — a symbol
302
+ // literally named "last" would be the accepted residual cost, same trade as
303
+ // every other stopword.
304
+ "last",
222
305
  ]);
223
306
 
224
307
  /** Find the longest phrase from `table`'s keys that appears as a contiguous
225
308
  * run of `words` (case already lowercased by the caller). Longest-match-first
226
309
  * (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) {
310
+ * shadowed by a shorter unrelated word. A span overlapping `consumed` indices
311
+ * is skipped: the verb and entity tables now share a surface form ("change"
312
+ * is both a touches verb and the Change entity noun), and a word already
313
+ * claimed by the verb pass must not double as the entity keyword. Returns
314
+ * {kind, start, end} (end exclusive) or null. */
315
+ function findPhrase(lcWords, table, consumed = null) {
230
316
  const phrases = Object.keys(table).sort((a, b) => b.split(" ").length - a.split(" ").length);
231
317
  for (const p of phrases) {
232
318
  const pWords = p.split(" ");
233
319
  for (let i = 0; i <= lcWords.length - pWords.length; i += 1) {
320
+ if (consumed && pWords.some((_, j) => consumed.has(i + j))) continue;
234
321
  if (pWords.every((w, j) => lcWords[i + j] === w)) return { kind: table[p], start: i, end: i + pWords.length };
235
322
  }
236
323
  }
237
324
  return null;
238
325
  }
239
326
 
327
+ // ---- bounded edit distance (two-level fuzzy, 2026-07-02) — hand-rolled
328
+ // Damerau-Levenshtein (optimal string alignment: substitution/insertion/deletion
329
+ // + adjacent transposition), bounded with an early row-minimum exit. Used by the
330
+ // keyword-spot FUZZY tier below and resolveObject's tier 5 — both fire only after
331
+ // every exact/curated tier missed, and a distance TIE is refused (keyword) or
332
+ // surfaced as ambiguity (object), never broken by a guess. Pure JS, no deps, so
333
+ // the inlined viewer bundle gets fuzzy matching for free. ----
334
+
335
+ /** Distance between a and b, or max+1 as soon as it provably exceeds `max`. */
336
+ function editDistance(a, b, max) {
337
+ if (a === b) return 0;
338
+ if (Math.abs(a.length - b.length) > max) return max + 1;
339
+ let prev2 = null;
340
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
341
+ for (let i = 1; i <= a.length; i += 1) {
342
+ const cur = [i];
343
+ let rowMin = i;
344
+ for (let j = 1; j <= b.length; j += 1) {
345
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
346
+ let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
347
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, prev2[j - 2] + cost);
348
+ cur[j] = v;
349
+ if (v < rowMin) rowMin = v;
350
+ }
351
+ if (rowMin > max) return max + 1;
352
+ prev2 = prev;
353
+ prev = cur;
354
+ }
355
+ return prev[b.length];
356
+ }
357
+
358
+ /** The curated distance budget: 1 edit for short tokens, 2 for longer ones. */
359
+ const fuzzyBound = (s) => (s.length <= 5 ? 1 : 2);
360
+
361
+ /** Every single word appearing in the three parse tables — the "is this word
362
+ * already vocabulary?" gate for the lemma/fuzzy canonicalization passes (an
363
+ * exact vocab word is NEVER rewritten: exact curated match always wins). */
364
+ const VOCAB_WORDS = new Set(
365
+ [...Object.keys(VERB_TO_KIND), ...Object.keys(ENTITY_TO_TYPE), ...Object.keys(MODIFIER_TO_KIND)]
366
+ .flatMap((p) => p.split(" ")),
367
+ );
368
+
369
+ /** Fuzzy-correction TARGETS: verb-phrase and modifier constituents only, length ≥4.
370
+ * Entity nouns are deliberately excluded — real identifiers collide with them at
371
+ * distance ≤2 far too easily ("myfile" is 2 edits from "file", "caller" 2 from
372
+ * "calls"-family words), and entity-noun typos are already owned by the curated
373
+ * MISSPELLINGS table where such calls are made deliberately. Short constituents
374
+ * ("of", "to", "in", "on") are excluded for the same reason: at bound 1 half of
375
+ * English is adjacent to them. */
376
+ const FUZZY_TARGET_WORDS = [...new Set(
377
+ [...Object.keys(VERB_TO_KIND), ...Object.keys(MODIFIER_TO_KIND)]
378
+ .flatMap((p) => p.split(" "))
379
+ .filter((w) => w.length >= 4),
380
+ )];
381
+
382
+ /** A query word may be canonicalized only if it is plain alphabetic, not a
383
+ * stopword, and not already vocabulary. Dotted/digit terms (file names, shas)
384
+ * are never touched. */
385
+ function eligibleForCanon(w) {
386
+ return /^[a-z]+$/.test(w) && !STOPWORDS.has(w) && !VOCAB_WORDS.has(w);
387
+ }
388
+
389
+ /** UNIQUE within-bound fuzzy vocab keyword for `w`, or null — a tie between two
390
+ * distinct target words at the same distance is refused outright (the honest-miss
391
+ * discipline at the vocabulary level; cf. MISSPELLINGS' curated "calss" decision). */
392
+ function fuzzyVocabWord(w) {
393
+ const bound = fuzzyBound(w);
394
+ let best = bound + 1;
395
+ let hit = null;
396
+ let tied = false;
397
+ for (const target of FUZZY_TARGET_WORDS) {
398
+ const d = editDistance(w, target, Math.min(best, bound));
399
+ if (d < best) { best = d; hit = target; tied = false; }
400
+ else if (d === best && d <= bound && target !== hit) tied = true;
401
+ }
402
+ return best <= bound && !tied ? hit : null;
403
+ }
404
+
240
405
  /** Strategy 2: scan (already-normalized) text for a verb keyword anywhere,
241
406
  * plus optional entity/modifier keywords anywhere, then split whatever's
242
407
  * left (after removing the matched spans + stopwords) into the words BEFORE
@@ -250,21 +415,95 @@ function findPhrase(lcWords, table) {
250
415
  * term is left as plain text — resolveTermOrContext (traverse-time)
251
416
  * recognizes it against an optional contextId, so no separate flag is
252
417
  * needed here. A misparse here costs nothing beyond an honest object-miss
253
- * downstream (resolveObject never guesses). Pure. */
254
- function parseKeywordSpot(text) {
418
+ * downstream (resolveObject never guesses).
419
+ *
420
+ * Keyword matching is TIERED (two-level fuzzy work, 2026-07-02) — each lower
421
+ * tier fires ONLY when every tier above found no verb phrase at all, so an
422
+ * exact curated match can never be displaced:
423
+ * 1. exact — the words as typed (post-normalization, which already applied
424
+ * the curated CONTRACTIONS/MISSPELLINGS/WRONG_WORDS corrections);
425
+ * 2. lemma (only with the optional Node-side `nlp` adapter) — each eligible
426
+ * word is replaced by its wink lemma IF that lemma is itself a vocab word
427
+ * ("imported"/"importing" -> "import"), so inflections hit the curated
428
+ * phrases without enumerating them. Every verb family already stores its
429
+ * lemma form ("import", "call", "touch", "use", …), so a direct
430
+ * lemma-in-vocab check is the whole lookup — no reverse index needed;
431
+ * 3. fuzzy (adapter-free; works in the inlined viewer too) — a word ≥4 chars
432
+ * matching nothing exactly may rewrite to a UNIQUE verb/modifier
433
+ * constituent within the bounded edit distance (see fuzzyVocabWord; ties
434
+ * are refused, entity nouns are never fuzzy targets).
435
+ * The canonicalized words drive PHRASE FINDING only — sideText always reads the
436
+ * ORIGINAL words, so a correction can never corrupt an object/subject term. */
437
+ function parseKeywordSpot(text, nlp = null) {
255
438
  // Strip a trailing "?" (mirrors the anchored templates' own `\??$`) and turn commas into
256
439
  // pauses/spaces — but NEVER strip a mid-word ".": object terms are routinely dotted file/module
257
440
  // names ("a.py", "utils.mjs"), and the anchored strategy captures those raw, so keyword-spot
258
441
  // must too or the two strategies would "disagree" over a period that was never part of the intent.
259
442
  const words = text.replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
260
443
  const lcWords = words.map((w) => w.toLowerCase());
261
- const verbHit = findPhrase(lcWords, VERB_TO_KIND);
444
+ // where/mentions shapes (2026-07-02 query families): "where is X [defined]" and
445
+ // "where is X mentioned" carry NO relation verb, so the verb-driven decomposition
446
+ // below can never reach them. Routed here by the "where" question word + marker —
447
+ // but ONLY when no relation verb exists anywhere in the sentence: "something
448
+ // executes this, where from" (an existing worked phrasing) has a verb, and its
449
+ // "where" is decorative, not a location question.
450
+ if (lcWords.includes("where") && !findPhrase(lcWords, VERB_TO_KIND)) {
451
+ const mention = lcWords.some((w) => MENTION_MARKERS.includes(w));
452
+ const markers = new Set([...WHERE_MARKERS, ...MENTION_MARKERS]);
453
+ const objText = words.filter((w, i) => !STOPWORDS.has(lcWords[i]) && !markers.has(lcWords[i])).join(" ").trim();
454
+ if (objText) {
455
+ const kind = mention ? "mentions" : "where";
456
+ return { shape: kind, entityType: null, modifier: "direct", kind, object: objText };
457
+ }
458
+ }
459
+ let canonWords = lcWords;
460
+ let verbHit = findPhrase(lcWords, VERB_TO_KIND);
461
+ if (!verbHit && nlp) {
462
+ // tier 2: lemma (see the tier doc above) — replace only when the lemma is
463
+ // itself vocabulary, so unknown words ("myfile") pass through untouched.
464
+ const lemmaWords = lcWords.map((w) => {
465
+ if (!eligibleForCanon(w)) return w;
466
+ const l = nlp.lemma(w);
467
+ return VOCAB_WORDS.has(l) ? l : w;
468
+ });
469
+ verbHit = findPhrase(lemmaWords, VERB_TO_KIND);
470
+ if (verbHit) canonWords = lemmaWords;
471
+ }
472
+ if (!verbHit) {
473
+ // tier 3: bounded-edit-distance rewrite toward verb/modifier keywords only
474
+ // ("impotr" -> "import"); ≥4-char words only — below that the bound covers
475
+ // half of English (and "and" is 1 edit from the "land in" constituent).
476
+ const fuzzyWords = lcWords.map((w) => (w.length >= 4 && eligibleForCanon(w) ? fuzzyVocabWord(w) || w : w));
477
+ verbHit = findPhrase(fuzzyWords, VERB_TO_KIND);
478
+ if (verbHit) canonWords = fuzzyWords;
479
+ }
262
480
  if (!verbHit) return null;
263
- const entityHit = findPhrase(lcWords, ENTITY_TO_TYPE);
264
- const modifierHit = findPhrase(lcWords, MODIFIER_TO_KIND);
481
+ // POS consumer (wink adapter, Node-side only): rescue the ONE decomposition this
482
+ // strategy provably mis-parses — a relation word used as a NOUN in a "the
483
+ // <imports> of <term>" nominal ("show the imports of walk.mjs" otherwise
484
+ // decomposes to ask{subject:"show"}; bare "the imports of walk.mjs" to the
485
+ // reverse shape, both wrong). The wink probe showed "import" is tagged NOUN even
486
+ // in genuine verb use ("which modules import walk.mjs"), so the POS signal is
487
+ // deliberately NOT a general verb veto — it only fires inside this exact
488
+ // det+NOUN+"of" frame, where the nominal reading is grammatically forced.
489
+ if (nlp && verbHit.end - verbHit.start === 1) {
490
+ const i = verbHit.start;
491
+ const det = lcWords[i - 1];
492
+ if ((det === "the" || det === "these" || det === "those") && lcWords[i + 1] === "of") {
493
+ const tags = nlp.posTags(words);
494
+ if (tags[i] === "NOUN") {
495
+ const objText = words.slice(i + 2).filter((w, j) => !STOPWORDS.has(lcWords[i + 2 + j])).join(" ").trim();
496
+ if (objText) return { shape: "forward", entityType: null, modifier: "direct", kind: verbHit.kind, object: objText };
497
+ }
498
+ }
499
+ }
265
500
  const consumed = new Set();
266
501
  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);
502
+ mark(verbHit);
503
+ const entityHit = findPhrase(canonWords, ENTITY_TO_TYPE, consumed);
504
+ mark(entityHit);
505
+ const modifierHit = findPhrase(canonWords, MODIFIER_TO_KIND, consumed);
506
+ mark(modifierHit);
268
507
  const sideText = (from, to) => words
269
508
  .slice(from, to)
270
509
  .filter((_, j) => !consumed.has(from + j) && !STOPWORDS.has(lcWords[from + j]))
@@ -273,12 +512,28 @@ function parseKeywordSpot(text) {
273
512
  const beforeText = sideText(0, verbHit.start);
274
513
  const afterText = sideText(verbHit.end, words.length);
275
514
  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";
515
+ // slices read canonWords, not lcWords: the entity/modifier spans were matched
516
+ // against the canonicalized array, whose word IS the table key.
517
+ const entityType = entityHit ? ENTITY_TO_TYPE[canonWords.slice(entityHit.start, entityHit.end).join(" ")] : null;
518
+ const modifier = modifierHit ? MODIFIER_TO_KIND[canonWords.slice(modifierHit.start, modifierHit.end).join(" ")] : "direct";
519
+
520
+ // when shape (2026-07-02 query families): "when did X change" / "when was X last
521
+ // touched" — the "when" question word turns a touches decomposition temporal.
522
+ // Only touches carries commit dates to answer with; a "when" next to any other
523
+ // relation verb falls through to the ordinary shapes (and their honest answers).
524
+ if (kind === "touches" && lcWords.includes("when")) {
525
+ const objText = beforeText || afterText;
526
+ if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
527
+ }
278
528
 
279
529
  if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
280
530
  if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
281
- if (beforeText) return { shape: "forward", entityType: null, modifier: "direct", kind, object: beforeText };
531
+ // forward keeps the spotted entityType ("which modules did commit <sha> touch" is a
532
+ // forward decomposition — subject before the verb — whose asked grain would otherwise
533
+ // be lost); traverse() only consults it for the commit-as-subject grain selection,
534
+ // so plain forwards behave exactly as before. Modifier stays hardcoded: no forward
535
+ // closure traversal exists (see modifierIsWired).
536
+ if (beforeText) return { shape: "forward", entityType, modifier: "direct", kind, object: beforeText };
282
537
  return null;
283
538
  }
284
539
 
@@ -290,7 +545,20 @@ const STRATEGIES = [
290
545
  { name: "keyword-spot", parse: parseKeywordSpot },
291
546
  ];
292
547
 
293
- const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
548
+ /** The default lemma/POS adapter: wink-nlp when this is a Node process with the
549
+ * optional deps installed, null otherwise. BOUNDARY (see the import comment):
550
+ * the inlined viewer bundle strips the ask-nlp.mjs import, so `nlpAdapter` is
551
+ * an UNDECLARED identifier there — `typeof` reads it without throwing and the
552
+ * browser path degrades to no adapter, same parse pipeline otherwise. */
553
+ function defaultNlp() {
554
+ return typeof nlpAdapter === "function" ? nlpAdapter() : null;
555
+ }
556
+
557
+ // "commit abc1234" and bare "abc1234" are the SAME term once resolveObject's
558
+ // commit-sha tier strips the noun — the anchored strategy captures the noun inside
559
+ // its object span while keyword-spot consumes it as the entity keyword, so without
560
+ // this the two strategies would "disagree" over a word that names no different thing.
561
+ const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ").replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
294
562
 
295
563
  /** Do two independently-produced parses mean the same graph query? Same
296
564
  * shape, same relation kind, and matching term(s) (both subject and object
@@ -310,13 +578,17 @@ function sameParse(p, q) {
310
578
  * either"). When both parse but DISAGREE (different shape/kind/term),
311
579
  * returns {ambiguousParse: true, candidates: [...]} — a genuine "this could
312
580
  * mean more than one thing" case, distinct from resolveObject's later
313
- * object-resolution ambiguity. Pure. */
314
- export function parseQuery(query) {
581
+ * object-resolution ambiguity. `opts.nlp` overrides the lemma/POS adapter
582
+ * (pass null to force the adapter-less browser behavior in a Node test);
583
+ * leaving it undefined picks the deterministic default (defaultNlp). Pure
584
+ * given (query, adapter) — the adapter itself is a fixed model, no sampling. */
585
+ export function parseQuery(query, { nlp = undefined } = {}) {
586
+ const adapter = nlp === undefined ? defaultNlp() : nlp;
315
587
  const raw = String(query || "").trim().replace(/\s+/g, " ");
316
588
  if (!raw) return null;
317
589
  const text = applyNegationFrames(normalizeQuery(raw));
318
590
  if (!text) return null;
319
- const hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text) })).filter((r) => r.parsed);
591
+ const hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text, adapter) })).filter((r) => r.parsed);
320
592
  if (hits.length === 0) return null;
321
593
  if (hits.length === 1) return hits[0].parsed;
322
594
  const [a, b] = hits;
@@ -327,7 +599,7 @@ export function parseQuery(query) {
327
599
  /** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
328
600
  * uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
329
601
  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)';
602
+ return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests|touched> <name>" or "what does <name> <import|call|export>" or "what uses <name>" or "where is <name> defined" / "where is <name> mentioned" or "when did <name> change" or "which changes touch commit <sha>"/"what did commit <sha> touch" (a commit\'s own changes) 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
603
  }
332
604
 
333
605
  // ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
@@ -339,7 +611,9 @@ function componentSet(s) {
339
611
  /** Resolve a free-text object/subject term against the graph's individuals, in priority
340
612
  * order (§4, generalized beyond the module-coupling worked example to cover every verb
341
613
  * 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
614
+ * just modules): a sha-shaped term ("[commit ]<hex≥7>") first resolves against Commit
615
+ * individuals by unique id/label prefix (see the inline comment), then (1) exact
616
+ * label/id match, (2) an `ext:` unresolved-target match (today
343
617
  * ext: targets are edge-endpoint STRINGS with no individual of their own — e.g. an
344
618
  * unimported inherits base, or any import target — so this tier returns a synthetic
345
619
  * {id:"ext:<name>", label:<name>, class:null} match rather than an `individuals` lookup;
@@ -355,15 +629,43 @@ function componentSet(s) {
355
629
  * than the symbol's own name — the render layer does not currently read this (it treats
356
630
  * a resolved match as a resolved match, same "honest miss vs genuine hit" binary tiers
357
631
  * 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. */
632
+ * a bounded Damerau-Levenshtein pass against labels AND label components (two-level
633
+ * fuzzy, 2026-07-02): a UNIQUE within-bound match resolves, tagged `matchedVia:
634
+ * "fuzzy"` so render() can say "assuming you meant <label>" out loud; multiple
635
+ * matches at the same best distance are an honest ambiguity listing the candidates.
636
+ * Never applied to sha-shaped terms (the commit namespace is exact-or-ambiguous
637
+ * only) nor to terms under 4 chars (the bound would cover half of everything).
638
+ * (6) no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
639
+ * [, matchedVia]} — ambiguous on a true tier-3 score tie, a tier-4 overlap-count
640
+ * tie, or a tier-5 distance tie. */
361
641
  export function resolveObject(graph, term) {
362
642
  const t = String(term || "").trim();
363
643
  if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
364
644
  const tLc = t.toLowerCase();
365
645
  const pool = graph.individuals;
366
646
 
647
+ // commit-sha tier (checked first, only for sha-shaped terms): "ef74e44e25c8",
648
+ // "commit ef74e44e25c8", "commit:ef74e44", or a full 40-char sha resolve against
649
+ // Commit individuals by id/label prefix (ids are commit:<full-sha>, labels the
650
+ // 12-char short sha), case-insensitive. A UNIQUE prefix is exact-grade over the
651
+ // closed commit namespace (tier 1); a prefix shared by more than one commit is an
652
+ // honest ambiguity listing the candidates — never "the first one"; a hex-looking
653
+ // word matching NO commit falls through to the ordinary tiers unchanged (it may
654
+ // be a real code identifier).
655
+ const shaTerm = tLc.match(/^(commit[:\s])?([0-9a-f]{7,40})$/);
656
+ if (shaTerm) {
657
+ const sha = shaTerm[2];
658
+ const hits = pool.filter((i) => i.class === "Commit"
659
+ && (String(i.id).toLowerCase().startsWith(`commit:${sha}`) || String(i.label).toLowerCase().startsWith(sha)));
660
+ if (hits.length === 1) return { match: hits[0], candidates: [], tier: 1, ambiguous: false };
661
+ if (hits.length > 1) return { match: hits[0], candidates: hits.slice(1, 5), tier: 1, ambiguous: true };
662
+ // the explicit "commit" noun declares intent — with no matching commit, falling
663
+ // through would let the WORD "commit" component-match the Commit schema node (or
664
+ // any identifier containing it): a guess, not a resolution. Bare hex still falls
665
+ // through (it may be a real code identifier).
666
+ if (shaTerm[1]) return { match: null, candidates: [], tier: null, ambiguous: false };
667
+ }
668
+
367
669
  const exact = pool.find((i) => String(i.label).toLowerCase() === tLc || String(i.id).toLowerCase() === tLc);
368
670
  if (exact) return { match: exact, candidates: [], tier: 1, ambiguous: false };
369
671
 
@@ -379,16 +681,42 @@ export function resolveObject(graph, term) {
379
681
  }
380
682
  if (extId) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
381
683
 
382
- const termComps = componentSet(t);
684
+ // tier 3 — two disjoint regimes (dotted-symbol fix, 2026-07-02, advisor-verified
685
+ // bug): a DOTTED term with no slash ("res.json", "Widget.render", "walk.mjs") is
686
+ // symbol-shaped (object.member / Class.method / a bare file name), and the old
687
+ // any-substring-of-any-label pass let it land on a module whose PATH merely
688
+ // contains the text ("res.json" -> test/res.json.js — the wrong grain presented
689
+ // as if the term were that file). Such terms now match only (a) symbol labels
690
+ // (whole-term containment, or the ".member" suffix when the owner alias differs:
691
+ // "res.json" -> Response.json), and (b) module labels by EXACT basename equality
692
+ // ("walk.mjs" -> src/walk.mjs — extension-stripped basename equality is
693
+ // deliberately NOT used; that is precisely the phantom-path vector). Symbol
694
+ // matches outrank module matches. Undotted/slashed terms keep the original pass.
383
695
  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;
696
+ const dotted = !tLc.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(tLc);
697
+ if (dotted) {
698
+ const lastSeg = tLc.split(".").pop();
699
+ for (const m of pool) {
700
+ const label = String(m.label || "").toLowerCase();
701
+ if (m.class === "Module") {
702
+ if (label.split("/").pop() === tLc) scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
703
+ } else if (label.includes(tLc)) {
704
+ scored.push({ ind: m, score: 2000 - Math.abs(label.length - tLc.length) });
705
+ } else if (label.endsWith(`.${lastSeg}`)) {
706
+ scored.push({ ind: m, score: 1500 - Math.abs(label.length - tLc.length) });
707
+ }
708
+ }
709
+ } else {
710
+ const termComps = componentSet(t);
711
+ for (const m of pool) {
712
+ const label = String(m.label || "").toLowerCase();
713
+ if (label.includes(tLc)) {
714
+ scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
715
+ continue;
716
+ }
717
+ const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
718
+ if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
389
719
  }
390
- const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
391
- if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
392
720
  }
393
721
  scored.sort((a, b) => b.score - a.score);
394
722
  if (scored.length) {
@@ -403,22 +731,75 @@ export function resolveObject(graph, term) {
403
731
  }
404
732
 
405
733
  // tier 4: prose-index fallback (PLAN_PROSE_INDEX.md §6) — see the function doc above.
406
- const proseHits = lookupByProseTokens(graph.proseIndex, t);
734
+ // The typeof guard is the same viewer-bundle boundary as defaultNlp(): viz.mjs's
735
+ // askSource strips the prose.mjs import but does not inline prose.mjs, so in the
736
+ // browser `lookupByProseTokens` is an undeclared identifier — without the guard,
737
+ // ANY term reaching this tier threw a ReferenceError in the page instead of
738
+ // rendering the honest miss (a real, previously-untested viewer bug).
739
+ // DOTTED terms never consult prose: "res.json" word-matches test/res.json.js's
740
+ // own path tokens, which is the tier-3 phantom-path bug reappearing through a
741
+ // side door — a dotted term names an identifier, and identifiers resolve by
742
+ // label (tiers above) or the bounded fuzzy pass below, or they honestly miss.
743
+ let proseResult = null;
744
+ const proseHits = !dotted && typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, t) : [];
407
745
  if (proseHits.length) {
408
746
  const [best, ...rest] = proseHits;
409
747
  const bestInd = graph.byId.get(best.id);
410
748
  if (bestInd) {
411
749
  const tied = rest.filter((h) => h.score === best.score);
412
- return {
750
+ proseResult = {
413
751
  match: bestInd,
414
752
  candidates: rest.slice(0, 4).map((h) => graph.byId.get(h.id)).filter(Boolean),
415
753
  tier: 4,
416
754
  ambiguous: tied.length > 0,
417
755
  matchedVia: "prose",
418
756
  };
757
+ // A UNIQUE SEMANTIC prose hit stands — fuzzy is never consulted (lower
758
+ // tiers fire only on a miss). Two prose outcomes yield to tier 5 instead:
759
+ // (a) an AMBIGUOUS tie — a typo'd identifier ("bulidContextBundle") often
760
+ // word-overlaps several symbols' prose tokens at the same score while its
761
+ // 1-edit NAME match is unique; (b) a hit that only exists because the
762
+ // prose SPELL layer corrected the query token (via:"spell") — the same
763
+ // typo tier 5 resolves with stronger (name) evidence and announces out
764
+ // loud, exactly the division of labour prose.mjs's own spell-layer
765
+ // comment specifies. If tier 5 cannot resolve uniquely either, the
766
+ // stashed prose result is surfaced as-is.
767
+ if (!proseResult.ambiguous && best.via !== "spell") return proseResult;
419
768
  }
420
769
  }
421
- return { match: null, candidates: [], tier: null, ambiguous: false };
770
+
771
+ // tier 5: bounded fuzzy (see the function doc) — every exact tier above missed
772
+ // (or tier 4 tied), so a typo'd identifier gets one honest chance against labels
773
+ // and their components. sha-shaped terms never reach here (guard above);
774
+ // sub-4-char terms are excluded because a 1-edit budget on 3 chars matches far
775
+ // too much to ever be a unique intent.
776
+ if (!shaTerm && tLc.length >= 4) {
777
+ const bound = fuzzyBound(tLc);
778
+ let best = bound + 1;
779
+ let hits = [];
780
+ for (const m of pool) {
781
+ let d = editDistance(String(m.label || "").toLowerCase(), tLc, bound);
782
+ if (d > 0) {
783
+ for (const comp of componentSet(m.label)) {
784
+ if (d <= 0) break;
785
+ d = Math.min(d, editDistance(comp, tLc, bound));
786
+ }
787
+ }
788
+ if (d < best) { best = d; hits = [m]; }
789
+ else if (d === best && d <= bound) hits.push(m);
790
+ }
791
+ if (best <= bound && hits.length === 1) {
792
+ return { match: hits[0], candidates: [], tier: 5, ambiguous: false, matchedVia: "fuzzy" };
793
+ }
794
+ if (best <= bound && hits.length > 1 && !proseResult) {
795
+ // equidistant fuzzy tie with no prose evidence either — honest ambiguity.
796
+ const [bestInd, ...rest] = hits;
797
+ return { match: bestInd, candidates: rest.slice(0, 4), tier: 5, ambiguous: true, matchedVia: "fuzzy" };
798
+ }
799
+ }
800
+ // fuzzy couldn't resolve uniquely: surface the prose tie (when there was one)
801
+ // exactly as before, else the honest miss.
802
+ return proseResult || { match: null, candidates: [], tier: null, ambiguous: false };
422
803
  }
423
804
 
424
805
  /** Resolve a term that may be a context pronoun ("this"/"it"/"that"/"here") —
@@ -452,6 +833,35 @@ function refineToEntities(graph, moduleIds, entityType) {
452
833
  return out;
453
834
  }
454
835
 
836
+ /** Everything a commit touched, across BOTH stored grains — touches (Commit->Module)
837
+ * and touchesSymbol (Commit->fn/method/class) — narrowed by the asked entity type:
838
+ * "modules"/"files" keeps the coarse grain only, a fine type keeps its own symbol
839
+ * class only, and null/"Change" ("which changes touch commit X") keeps the union.
840
+ * The result carries `commitSubject` so render() cites the commit and groups the
841
+ * touched entities by class instead of pretending the commit was a search target. */
842
+ function commitTouches(graph, commit, entityType, extra = {}) {
843
+ // "Commit" counts as wildcard here too: in "what was touched by commit X" the
844
+ // keyword-spotter consumes "commit" as the entity keyword though it belongs to
845
+ // the object noun phrase — and no commit ever touches another commit (no
846
+ // Commit->Commit edges exist), so honoring it as a class filter could only
847
+ // ever manufacture a false blank.
848
+ const wildcard = !entityType || entityType === "Change" || entityType === "Commit";
849
+ const wantCoarse = wildcard || entityType === "Module";
850
+ const wantFine = wildcard || FINE_ENTITY_TYPES.has(entityType);
851
+ const kinds = [...(wantCoarse ? ["touches"] : []), ...(wantFine ? ["touchesSymbol"] : [])];
852
+ let matches = kinds
853
+ .flatMap((k) => edgesOfKind(graph, k))
854
+ .filter((e) => e.subject === commit.id)
855
+ .map((e) => graph.byId.get(e.object))
856
+ .filter(Boolean);
857
+ if (entityType && FINE_ENTITY_TYPES.has(entityType)) matches = matches.filter((m) => m.class === entityType);
858
+ return {
859
+ matches, objMatch: commit, commitSubject: true, ambiguous: false, candidates: [],
860
+ traversal: `${kinds.join("+")} edges where subject = commit ${commit.label}`,
861
+ ...extra,
862
+ };
863
+ }
864
+
455
865
  /** Safety net (paired with the render-branch fix earlier in this file's history): a
456
866
  * {shape, kind, entityType} combination must be explicitly listed here to receive
457
867
  * real non-"direct" modifier behavior. Anything parsing to a non-"direct" modifier
@@ -504,6 +914,23 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
504
914
  };
505
915
  }
506
916
 
917
+ // mentions: "where is X mentioned" (2026-07-02 query families) — the prose
918
+ // surface, not an edge traversal: list the individuals whose decomposed
919
+ // identifier / doc-comment tokens contain the term's words (the same index
920
+ // resolveObject's tier 4 consults, surfaced directly). The term itself is NOT
921
+ // resolved to an entity first — the question is about mentions of the words,
922
+ // which is exactly what the prose index stores. typeof guard: same viewer-
923
+ // bundle boundary as tier 4 (prose.mjs is never inlined).
924
+ if (shape === "mentions") {
925
+ const term = String(parsed.object || "").trim();
926
+ const hits = typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, term) : [];
927
+ const matches = hits.map((h) => graph.byId.get(h.id)).filter(Boolean);
928
+ return {
929
+ matches, objMatch: null, candidates: [], ambiguous: false, mentionsShape: true,
930
+ traversal: `proseIndex word lookup for "${term}"`,
931
+ };
932
+ }
933
+
507
934
  // §modifier support gate (safety net, see modifierIsWired's own doc above) — checked
508
935
  // BEFORE object resolution, so an unsupported modifier+kind combination gets its own
509
936
  // honest capability-gap message rather than masquerading as an object-miss, or worse,
@@ -525,10 +952,21 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
525
952
  unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
526
953
  };
527
954
  }
528
- const edges = edgesOfKind(graph, kind).filter((e) => e.subject === subj.match.id && e.object === obj.match.id);
955
+ // touches edges are stored commit -> entity, so when the question names the
956
+ // commit on the OBJECT side ("was walk.mjs touched by commit X"), orient the
957
+ // edge test by where the commit actually is instead of failing on direction;
958
+ // a commit subject is also checked at the symbol grain ("does commit X touch
959
+ // <function>" lives on touchesSymbol, not the module-coarse kind).
960
+ let [from, to] = [subj.match, obj.match];
961
+ let kinds = kindsFor(kind); // "uses" checks the whole union ("does X use Y")
962
+ if (kind === "touches") {
963
+ if (to.class === "Commit" && from.class !== "Commit") [from, to] = [to, from];
964
+ if (from.class === "Commit") kinds = ["touches", "touchesSymbol"];
965
+ }
966
+ const edges = kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === from.id && e.object === to.id);
529
967
  return {
530
968
  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,
969
+ candidates: [], traversal: `${kinds.join("+")} edge from ${from.label} to ${to.label}`, ambiguous: false,
532
970
  };
533
971
  }
534
972
 
@@ -537,10 +975,61 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
537
975
  const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = resolveTermOrContext(graph, parsed.object, contextId);
538
976
  if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
539
977
 
978
+ // where: "where is X [defined]" (2026-07-02 query families) — the resolved
979
+ // entity IS the answer; render() reads its class + site attribute ("path:
980
+ // start[-end]", seon:startsAt) for the module/line citation.
981
+ if (shape === "where") {
982
+ const site = (objMatch.attributes || []).find((a) => a.key === "site")?.value || null;
983
+ return {
984
+ matches: [objMatch], objMatch, candidates, ambiguous, matchedVia, whereShape: true, site,
985
+ traversal: site ? `site attribute of ${objMatch.label}` : `class + defining module of ${objMatch.label}`,
986
+ };
987
+ }
988
+
989
+ // when: "when did X change" / "when was X last touched" (2026-07-02 query
990
+ // families) — the commits whose touch edges reach X, newest commit date first
991
+ // (mgx:commitDate, ISO-8601, so a lexical sort IS the date sort; undated
992
+ // commits sort last and render() says so honestly). Checked BEFORE the
993
+ // commit-as-subject flip: "when did <sha> change" asks for the commit's own
994
+ // date, not its touched files, so a Commit object answers with itself.
995
+ if (shape === "when") {
996
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
997
+ let commits;
998
+ if (objMatch.class === "Commit") {
999
+ commits = [objMatch];
1000
+ } else {
1001
+ const edges = ["touches", "touchesSymbol"].flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
1002
+ const seen = new Set();
1003
+ commits = [];
1004
+ for (const e of edges) {
1005
+ if (seen.has(e.subject)) continue;
1006
+ seen.add(e.subject);
1007
+ const c = graph.byId.get(e.subject);
1008
+ if (c && c.class === "Commit") commits.push(c);
1009
+ }
1010
+ commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
1011
+ }
1012
+ return {
1013
+ matches: commits, objMatch, candidates, ambiguous, matchedVia, whenShape: true,
1014
+ traversal: `touches+touchesSymbol edges where object = ${objMatch.label}, newest commit date first`,
1015
+ };
1016
+ }
1017
+
1018
+ // commit-as-subject flip: touches edges are stored commit -> entity, so when the
1019
+ // RESOLVED term of a touches question is itself a Commit — "which changes touch
1020
+ // commit ef74e44e25c8" (reverse), "what did commit abc1234 touch" (forward),
1021
+ // "what changed in abc1234" (casual reverse) — the honest reading is "what did
1022
+ // that commit touch": read the edges FROM the commit, grain-selected by the asked
1023
+ // entity type, instead of scanning for edges INTO it (a commit is never a touch
1024
+ // target, so the un-flipped scan would render a misleading blank).
1025
+ if (kind === "touches" && objMatch.class === "Commit") {
1026
+ return commitTouches(graph, objMatch, entityType, { candidates, ambiguous, matchedVia });
1027
+ }
1028
+
540
1029
  if (shape === "forward") {
541
- const edges = edgesOfKind(graph, kind).filter((e) => e.subject === objMatch.id);
1030
+ const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
542
1031
  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 };
1032
+ return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
544
1033
  }
545
1034
 
546
1035
  // reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
@@ -577,10 +1066,33 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
577
1066
  // already match the requested entityType, use them directly (inherits); only when they're
578
1067
  // Module individuals and a FINER entityType was asked for do we refine via `defines`
579
1068
  // (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);
1069
+ let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
1070
+ let extNote = "";
1071
+ if (!edges.length && objMatch.class) {
1072
+ // Unresolved ext:<Name> endpoints with the SAME name as the resolved entity:
1073
+ // the extractor declined to assert identity (e.g. commander's every "class X
1074
+ // extends Command" edge points at ext:Command, never the Class node), so a
1075
+ // strict id match renders a FALSE blank. Count them by NAME instead and say
1076
+ // so in the receipt — name-grade evidence, labeled as such, same standard as
1077
+ // resolveObject's own ext: tier.
1078
+ const extId = `ext:${String(objMatch.label).toLowerCase()}`;
1079
+ edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => String(e.object).toLowerCase() === extId);
1080
+ if (edges.length) extNote = ` (by name, via unresolved ${extId} references)`;
1081
+ }
1082
+ // dedupe by id: a union kind ("uses") can reach the same subject through two
1083
+ // legs (a module that both imports AND calls X), and one answer must list it once.
1084
+ const subjects = [];
1085
+ const seenSubjects = new Set();
1086
+ for (const e of edges) {
1087
+ const s = graph.byId.get(e.subject);
1088
+ if (s && !seenSubjects.has(s.id)) { seenSubjects.add(s.id); subjects.push(s); }
1089
+ }
582
1090
  let matches, grainNote = "";
583
- if (!entityType) {
1091
+ // "Change" (ask-vocab.mjs's pseudo-type) is a wildcard here: "which changes touch
1092
+ // walk.mjs" means the touch edges' own subjects — the commits — not a node class
1093
+ // to filter by (no individual is ever class "Change", so filtering would always
1094
+ // produce a false blank).
1095
+ if (!entityType || entityType === "Change") {
584
1096
  matches = subjects;
585
1097
  } else {
586
1098
  const direct = subjects.filter((s) => s.class === entityType);
@@ -594,7 +1106,7 @@ export function traverse(graph, parsed, { contextId = null } = {}) {
594
1106
  matches = [];
595
1107
  }
596
1108
  }
597
- return { matches, objMatch, candidates, traversal: `${kind} edges where object = ${objMatch.label}${grainNote}`, ambiguous, matchedVia };
1109
+ return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where object = ${objMatch.label}${extNote}${grainNote}`, ambiguous, matchedVia };
598
1110
  }
599
1111
 
600
1112
  // ---- §5 templated renderer — string interpolation + grouping/pluralization/overflow rules,
@@ -613,6 +1125,10 @@ function symbolLabelOf(ind) {
613
1125
  return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
614
1126
  }
615
1127
 
1128
+ function listJoin(syms) {
1129
+ return syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
1130
+ }
1131
+
616
1132
  /** One-line, honest rephrasing of a candidate parse — used to describe a
617
1133
  * parse-level disagreement between strategies without pretending to pick
618
1134
  * a winner. Template only, reads straight off the parsed fields. */
@@ -623,8 +1139,19 @@ function describeParse(p) {
623
1139
  }
624
1140
 
625
1141
  /** 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. */
1142
+ * Every branch is a template, not generation — §5's grouping/pluralization/overflow rules.
1143
+ * A tier-5 fuzzy object resolution is ANNOUNCED, not silent: the answer is prefixed
1144
+ * "assuming you meant <label>:" so the correction is on the record next to the result
1145
+ * (an unannounced fuzzy hit would be indistinguishable from an exact one — a guess). */
627
1146
  export function render(parsed, result) {
1147
+ const r = renderCore(parsed, result);
1148
+ if (result && result.matchedVia === "fuzzy" && result.objMatch && !r.ambiguous) {
1149
+ r.content = `assuming you meant ${result.objMatch.label}: ${r.content}`;
1150
+ }
1151
+ return r;
1152
+ }
1153
+
1154
+ function renderCore(parsed, result) {
628
1155
  if (!parsed) {
629
1156
  return { content: `couldn't parse this as a graph question. Try: ${rephraseHint()}`, miss: true, ambiguous: false };
630
1157
  }
@@ -658,18 +1185,113 @@ export function render(parsed, result) {
658
1185
  const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
659
1186
  return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
660
1187
  }
1188
+ // mentions: the prose surface — checked before the generic objMatch-null miss
1189
+ // below, because a mentions result deliberately carries no resolved object
1190
+ // (the question is about the term's words, not a graph entity).
1191
+ if (result.mentionsShape) {
1192
+ if (!result.matches.length) {
1193
+ return {
1194
+ content: `"${parsed.object}" is not mentioned in any indexed identifier or doc-comment prose. (traversal: ${result.traversal})`,
1195
+ miss: true, ambiguous: false,
1196
+ };
1197
+ }
1198
+ const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => `${m.label} (${nounFor(m.class, 1)})`);
1199
+ const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
1200
+ return {
1201
+ content: `"${parsed.object}" is mentioned in the prose tokens of ${listJoin(shown)}${extra}.`,
1202
+ miss: false, ambiguous: false, matches: result.matches,
1203
+ };
1204
+ }
661
1205
  if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
1206
+ // name what kind of thing was looked for: a sha-shaped term was checked against
1207
+ // the commit namespace, a dotted slash-free term against symbol labels — a
1208
+ // generic "no module matching" would misreport both.
1209
+ const objText = String(parsed.object || "").trim();
1210
+ const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
1211
+ : (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : "module");
662
1212
  return {
663
- content: `no module matching "${parsed.object}" found in the index.`,
1213
+ content: `no ${what} matching "${parsed.object}" found in the index.`,
664
1214
  miss: true, ambiguous: false, candidates: [],
665
1215
  };
666
1216
  }
667
1217
  if (result.ambiguous) {
1218
+ // the candidates say what KIND of thing is ambiguous — a shared commit-sha
1219
+ // prefix must read "more than one commit", not "module".
1220
+ const pool = [result.objMatch, ...(result.candidates || [])].filter(Boolean);
1221
+ const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
668
1222
  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),
1223
+ content: `"${parsed.object}" matches more than one ${noun} ambiguously — please narrow the term.`,
1224
+ miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
671
1225
  };
672
1226
  }
1227
+ // where: the resolved entity's own location, cited off the site attribute.
1228
+ if (result.whereShape) {
1229
+ const ind = result.objMatch;
1230
+ if (ind.class === "Module") {
1231
+ return { content: `${ind.label} is a module — the label is its repo path.`, miss: false, ambiguous: false, matches: result.matches };
1232
+ }
1233
+ if (ind.class === "Commit") {
1234
+ return { content: `${ind.label} is a commit, not a code location — try "what did commit ${ind.label} touch".`, miss: true, ambiguous: false };
1235
+ }
1236
+ const m = String(result.site || "").match(/^(.*):(\d+)(?:-(\d+))?$/);
1237
+ if (m) {
1238
+ const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
1239
+ return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
1240
+ }
1241
+ return {
1242
+ content: `${symbolLabelOf(ind)} is defined in ${moduleLabelOf(ind)} (no line span recorded in this index).`,
1243
+ miss: false, ambiguous: false, matches: result.matches,
1244
+ };
1245
+ }
1246
+ // when: newest touching commit + its date; undated commits are said out loud
1247
+ // (honest miss with the precise re-index hint), never silently skipped.
1248
+ if (result.whenShape) {
1249
+ const subject = result.objMatch.label;
1250
+ if (!result.matches.length) {
1251
+ return { content: `no recorded commit touches ${subject} in this index. (traversal: ${result.traversal})`, miss: true, ambiguous: false };
1252
+ }
1253
+ const newest = result.matches[0];
1254
+ const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
1255
+ if (!date) {
1256
+ return {
1257
+ content: `commit ${newest.label} touched ${subject}, but this index records no commit dates — re-index with a current seonix to attach mgx:commitDate.`,
1258
+ miss: true, ambiguous: false,
1259
+ };
1260
+ }
1261
+ const msg = (newest.attributes || []).find((a) => a.key === "message")?.value || "";
1262
+ const day = String(date).slice(0, 10);
1263
+ if (newest.id === result.objMatch.id) {
1264
+ return { content: `commit ${newest.label} is dated ${day}${msg ? ` ("${msg}")` : ""}.`, miss: false, ambiguous: false, matches: result.matches };
1265
+ }
1266
+ const more = result.matches.length - 1;
1267
+ return {
1268
+ content: `${subject} was last touched by commit ${newest.label} on ${day}${msg ? ` ("${msg}")` : ""}${more ? `; ${more} earlier commit${more === 1 ? "" : "s"} recorded` : ""}.`,
1269
+ miss: false, ambiguous: false, matches: result.matches,
1270
+ };
1271
+ }
1272
+ // commit-as-subject answers ("which changes touch commit X", "what did commit X
1273
+ // touch"): cite the commit, group the touched entities by CLASS — modules and
1274
+ // symbols are different grains of the same answer, and flattening them into one
1275
+ // undifferentiated list would hide which is which. Same OVERFLOW_CAP as the
1276
+ // other list templates; zero hits is the standard honest blank, commit cited.
1277
+ if (result.commitSubject) {
1278
+ const cite = `commit ${result.objMatch.label}`;
1279
+ if (!result.matches.length) {
1280
+ return {
1281
+ content: `${cite} touched nothing recorded in the index. (traversal: ${result.traversal})`,
1282
+ miss: true, ambiguous: false,
1283
+ };
1284
+ }
1285
+ const byClass = new Map();
1286
+ for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
1287
+ const cls = m.class || "Module";
1288
+ if (!byClass.has(cls)) byClass.set(cls, []);
1289
+ byClass.get(cls).push(["Function", "Method"].includes(cls) ? `${m.label}()` : m.label);
1290
+ }
1291
+ const clauses = [...byClass.entries()].map(([cls, labels]) => `${nounFor(cls, labels.length)} ${listJoin(labels)}`);
1292
+ const extra = result.matches.length > OVERFLOW_CAP ? `; …and ${result.matches.length - OVERFLOW_CAP} more` : "";
1293
+ return { content: `${cite} touched ${clauses.join("; ")}${extra}.`, miss: false, ambiguous: false, matches: result.matches };
1294
+ }
673
1295
  if (parsed.shape === "ask") {
674
1296
  if (!result.objMatch || !result.subjMatch) {
675
1297
  return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
@@ -701,8 +1323,10 @@ export function render(parsed, result) {
701
1323
  // still resolves to Module individuals for a module-level relation like "imports", and
702
1324
  // grouping those by-module (module label as its own "symbol" label) reads as nonsense
703
1325
  // ("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")) {
1326
+ // meaningful when the matches are sub-module entities (functions/classes/etc) — a
1327
+ // Commit list ("which commits touched X") has no containing module to group by, so
1328
+ // anything that is not a fine entity takes the flat join.
1329
+ if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => !FINE_ENTITY_TYPES.has(m.class))) {
706
1330
  const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.label);
707
1331
  const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
708
1332
  return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
@@ -716,8 +1340,6 @@ export function render(parsed, result) {
716
1340
  if (!byModule.has(mod)) byModule.set(mod, []);
717
1341
  byModule.get(mod).push(symbolLabelOf(m));
718
1342
  }
719
- const listJoin = (syms) =>
720
- syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
721
1343
  const clauses = [...byModule.entries()].map(([mod, syms], i) => {
722
1344
  const list = listJoin(syms);
723
1345
  return i === 0 ? `in ${mod} there is ${list}` : `there is ${list} in ${mod}`;
@@ -731,11 +1353,14 @@ export function render(parsed, result) {
731
1353
  /** Answer a free-text question over the graph, mechanically. `opts.contextId`
732
1354
  * resolves a context pronoun ("this"/"it"/…) — wired from a UI's currently-
733
1355
  * 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);
1356
+ * a pronoun then produces an honest miss rather than a guess. `opts.nlp`
1357
+ * overrides the lemma/POS adapter (see parseQuery) — leave it undefined and
1358
+ * a Node process picks up wink automatically while the inlined viewer stays
1359
+ * adapter-less by construction. Returns the full {content, seonix_ask:
1360
+ * {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}} envelope
1361
+ * §6.2 specifies. Zero generative model calls. */
1362
+ export function ask(graph, query, { contextId = null, nlp = undefined } = {}) {
1363
+ const parsed = parseQuery(query, { nlp });
739
1364
  const result = traverse(graph, parsed, { contextId });
740
1365
  const rendered = render(parsed, result);
741
1366
  return {
@@ -749,12 +1374,11 @@ export function ask(graph, query, { contextId = null } = {}) {
749
1374
  traversal: result.traversal || null,
750
1375
  miss: !!rendered.miss,
751
1376
  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.
1377
+ // Confidence provenance: "prose" when resolveObject fell through to the tier-4
1378
+ // prose-index fallback (PLAN_PROSE_INDEX.md §6 matched what the symbol talks
1379
+ // about, not its name); "fuzzy" when the tier-5 bounded-edit-distance pass
1380
+ // resolved a typo'd term (the rendered content also announces it: "assuming you
1381
+ // meant <label>"); null for every literal-identifier tier.
758
1382
  matchedVia: result.matchedVia || null,
759
1383
  ...(rendered.ambiguous ? { candidates: rendered.candidates } : {}),
760
1384
  },