@polycode-projects/the-mechanical-code-talker 0.8.1 → 0.9.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
@@ -77,7 +77,12 @@ import { nlpAdapter } from "./ask-nlp.mjs";
77
77
  * classification, so they cannot drift in meaning). */
78
78
  function edgesOfKind(graph, kind) {
79
79
  const out = [];
80
- for (const g of graph.relations) if (relationKind(g) === kind) out.push(...g.edges);
80
+ // Plain-loop append, NOT out.push(...g.edges): argument spread overflows the call
81
+ // stack past ~100k edges on graph-scale relation groups (see codegraph.mjs twin).
82
+ for (const g of graph.relations) {
83
+ if (relationKind(g) !== kind) continue;
84
+ for (const e of g.edges) out.push(e);
85
+ }
81
86
  return out;
82
87
  }
83
88
 
@@ -89,6 +94,12 @@ function edgesOfKind(graph, kind) {
89
94
  // "which functions call X" should read off callsSymbol (fn->fn), not the module-coarse "calls".
90
95
  const SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
91
96
  const FINE_ENTITY_TYPES = new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
97
+ // The fn/method FAMILY (0.8.2 WS1): callers of a symbol are recorded at whichever
98
+ // grain the extractor saw (a method Widget.render is class "Method"), but a person
99
+ // asking "which functions call X" means the callable family, not the storage class.
100
+ // Used ONLY as an empty-result fallback (see traverse's reverse symbol-grain path):
101
+ // an exact-class answer is never widened, so every non-empty answer is byte-stable.
102
+ const FINE_CLASS_SIBLING = { Function: "Method", Method: "Function" };
92
103
 
93
104
  // Query-side UNION families (2026-07-02 query families): a parsed kind that is not
94
105
  // itself a stored predicate but a curated union of stored kinds — "what uses X"
@@ -119,12 +130,34 @@ function nounFor(entityType, n) {
119
130
  // name is a bare noun/verb stem ("X cochange Y" is wrong; "X cochanges Y" is right) —
120
131
  // so the reverse-shape zero-hit template below reads off this table instead of
121
132
  // unconditionally appending "s" (which used to double-pluralize every other kind:
122
- // "callss", "importss", "touchess").
123
- const REVERSE_MISS_VERB = { cochange: "cochanges" };
133
+ // "callss", "importss", "touchess"). "reexports" -> "export" (Bug B3, HANDOVER
134
+ // follow-up #2): the raw internal kind identifier "reexports" leaked straight into
135
+ // the forward-miss prose ("X has no reexports edges in the index") — the human
136
+ // word for this relation is "export" ("X has no export edges in the index"),
137
+ // matching every other kind's already-natural phrasing.
138
+ const REVERSE_MISS_VERB = { cochange: "cochanges", reexports: "export" };
124
139
  function verbFor(kind) {
125
140
  return REVERSE_MISS_VERB[kind] || kind;
126
141
  }
127
142
 
143
+ // Leading-relation-verb strip for the tests-kind honest empty (0.8.2 WS1): the
144
+ // keyword strategy can match the "tests" NOUN as the relation verb and leave the
145
+ // user's OWN verb at the head of the object term ("do any tests touch f.mjs" →
146
+ // object "touch f.mjs"), which the old ^cover-only strip missed ("No tests cover
147
+ // touch app/lib/f.mjs."). The closed list is read from ask-vocab.mjs's exported
148
+ // VERB_TO_KIND (derived from the RELATIONS verb table — the source of truth,
149
+ // including the `tests` kind's own verbs: cover/check/verify/exercise/…), longest
150
+ // phrase first so multi-word verbs strip whole; a bare optional s/ing/ed tail keeps
151
+ // the previously-stripped inflections ("covering") without enumerating them. Only
152
+ // ever applied to the tests-kind zero-hit template's object — never to resolution.
153
+ const LEADING_RELATION_VERB_RE = new RegExp(
154
+ `^(?:${Object.keys(VERB_TO_KIND)
155
+ .sort((a, b) => b.length - a.length)
156
+ .map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
157
+ .join("|")})(?:s|ing|ed)?\\s+`,
158
+ "i",
159
+ );
160
+
128
161
  // ---- the parsing strategies + normalization + fuzzy service formerly defined
129
162
  // here now live in src/interpret/ (items 8/10/13): interpret/normalize.mjs
130
163
  // (normalizeQuery, applyNegationFrames, STOPWORDS, splitWords), interpret/
@@ -250,6 +283,7 @@ function parseComposite(text, nlp) {
250
283
  || parseAnaphora(w, lc, nlp)
251
284
  || parseAggregate(w, lc, nlp)
252
285
  || parseSuperlative(w, lc, nlp)
286
+ || parseFind(w, lc, nlp, 0)
253
287
  || parseList(w, lc, nlp, 0)
254
288
  || parseNested(w, lc, nlp, 0)
255
289
  || parseRelationalOrQualified(w, lc, nlp, 0);
@@ -644,6 +678,62 @@ function parseSuperlative(w, lc, nlp) {
644
678
  return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
645
679
  }
646
680
 
681
+ // PREDICATE-FIND (Workstream 2 — new product feature): "find [me/us] [the/a] <term>
682
+ // <entityType>" (trailing-type — "find me the payment class") or "find [me/us]
683
+ // [the/a] <entityType> <linker> <term>" (leading-type-with-linker — "find the class
684
+ // named Foo"). A TYPE FILTER ∧ FUZZY PROPERTY-SURFACE MATCH, not a literal name
685
+ // lookup (contrast parseList's plain class enumeration) — reuses the same closed
686
+ // compositional grammar (evalSet's new "find" case, renderComposite's new branch)
687
+ // so it composes for free with qualifiers/booleans later (§6 generalization below).
688
+ const FIND_LINKERS = new Set(["called", "named", "about", "like", "containing", "matching", "with"]);
689
+
690
+ /** PREDICATE-FIND: see the file comment above. Triggered ONLY by a leading "find"
691
+ * (parseNegation, earlier in parseComposite's chain, already claims "find" as an
692
+ * optional lead before an EXPLICIT set-negation marker — "find modules that don't
693
+ * import X" reaches that production first and never reaches here). Reuses
694
+ * LIST_SKIP and entityNoun/ENTITY_TO_TYPE exactly as parseList does. A clear
695
+ * imperative "find <one unknown plain word>" (mirroring parseList's own single-
696
+ * trailing-word discipline) is an honest miss naming LISTABLE_KINDS — a
697
+ * PARSE-TIME miss, structurally distinct from evalSet("find")'s zero-hit SEARCH
698
+ * miss; anything less certain — a longer uncertain remainder, or ANY relative-
699
+ * clause marker present anywhere (that/which/who) — defers (null) to the existing
700
+ * parser/cascade, so "find the file that imports store" keeps parsing via the
701
+ * established parseNested/parseRelationalOrQualified relative-clause path (the
702
+ * §6 generalization below is the ONE place a term-bearing find-with-predicate
703
+ * shape is recognized, and it is a structurally separate production). */
704
+ function parseFind(w, lc, nlp, depth) {
705
+ if (lc[0] !== "find") return null;
706
+ let i = 1;
707
+ while (i < lc.length && LIST_SKIP.has(lc[i])) i += 1;
708
+ if (i >= lc.length) return null;
709
+
710
+ // leading-type-with-linker: "find [me] [the] <entityType> <linker> <term…>"
711
+ const leadNoun = entityNoun(lc[i]);
712
+ if (leadNoun && !leadNoun.placeholder && leadNoun.entityType !== "Change"
713
+ && i + 1 < lc.length && FIND_LINKERS.has(lc[i + 1])) {
714
+ const term = w.slice(i + 2).join(" ").trim();
715
+ if (term) return { node: "find", entityType: leadNoun.entityType, term };
716
+ // a linker with nothing after it is too uncertain to claim — fall through.
717
+ }
718
+
719
+ // trailing-type: "find [me] [the] <term…> <entityType>"
720
+ const lastNoun = entityNoun(lc[lc.length - 1]);
721
+ if (lastNoun && !lastNoun.placeholder && lastNoun.entityType !== "Change") {
722
+ const term = w.slice(i, lc.length - 1).join(" ").trim();
723
+ if (term) return { node: "find", entityType: lastNoun.entityType, term };
724
+ }
725
+
726
+ // A relative-clause marker anywhere means a DIFFERENT production owns this text
727
+ // (either the plain relative-clause path, or the §6 find-with-predicate
728
+ // generalization inside parseRelationalOrQualified) — never claimed here.
729
+ if (lc.some((t) => RELATIVE_PRONOUNS.includes(t))) return null;
730
+ // A clear imperative single unknown trailing word (mirrors parseList's own rule).
731
+ if (i === lc.length - 1 && /^[a-z]+$/.test(lc[i]) && !VERB_TO_KIND[lc[i]] && !PLACEHOLDER_NOUNS.includes(lc[i])) {
732
+ return { node: "miss", reason: `"${lc[i]}" isn't a listable kind — try ${LISTABLE_KINDS}` };
733
+ }
734
+ return null;
735
+ }
736
+
647
737
  /** RELATIONAL / BOOLEAN / QUALIFIER (subject-first): "[which] [<qualifier>…] <entity>
648
738
  * [that] <predicate>", where <predicate> is one or more relation clauses joined by
649
739
  * and/or/but-not over the SAME subject, a "<of|in> <term>" membership, or empty (a
@@ -652,7 +742,78 @@ function parseSuperlative(w, lc, nlp) {
652
742
  * reverse query ("which functions call helper") and the bare-template ambiguous case
653
743
  * ("which classes extends Base and couples to logging", no marker) both fall through
654
744
  * to the existing strategies untouched. Returns an AST node, a miss, or null. */
745
+ /** §6 generalization (predicate-find, Workstream 2 follow-up) — detect the HEAD of
746
+ * "find [me/us] [the/a] <term…> <entityType> that|which|who <predicate>": mirrors
747
+ * parseFind's own trailing-type recognition (LIST_SKIP, entityNoun), but requires
748
+ * a relative-clause marker directly after the entity noun and a NON-EMPTY term
749
+ * before it. An EMPTY term ("find classes that…") is deliberately NOT this shape
750
+ * — it already reaches parseRelationalOrQualified's normal head-parsing below
751
+ * once "find" is skipped as a FRAME_WORD, with no find-seed needed. Returns
752
+ * {entityType, term, relIdx} (relIdx = the relative pronoun's token index) or
753
+ * null — null on anything less than an exact match (never a guess). */
754
+ function parseFindPredicateHead(w, lc) {
755
+ if (lc[0] !== "find") return null;
756
+ let i = 1;
757
+ while (i < lc.length && LIST_SKIP.has(lc[i])) i += 1;
758
+ let r = -1;
759
+ for (let k = i + 1; k < lc.length; k += 1) { if (RELATIVE_PRONOUNS.includes(lc[k])) { r = k; break; } }
760
+ if (r < 0) return null;
761
+ const noun = entityNoun(lc[r - 1]);
762
+ if (!noun || noun.placeholder || noun.entityType === "Change") return null;
763
+ const term = w.slice(i, r - 1).join(" ").trim();
764
+ if (!term) return null;
765
+ return { entityType: noun.entityType, term, relIdx: r };
766
+ }
767
+
768
+ /** Build boolean/qualifier atoms for a predicate whose FIRST (seed) atom the
769
+ * caller already determined externally (the §6 generalization's find-seed,
770
+ * above) — `predLc`/`predWords` are the tokens AFTER the leading relative
771
+ * pronoun has already been consumed. Every atom's op defaults to
772
+ * "intersection" (a relative clause always RESTRICTS the seed) except where an
773
+ * explicit and/or/but-not connective says otherwise. Mirrors
774
+ * parseRelationalOrQualified's own branch-classification (qualifier-only /
775
+ * membership / verb-phrase clause) in miniature, duplicated rather than
776
+ * shared, so neither path risks regressing the other. */
777
+ function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, depth) {
778
+ const { branches, ops } = splitBoolean(predLc, predWords);
779
+ let prevVerb = null;
780
+ const atoms = [];
781
+ for (let b = 0; b < branches.length; b += 1) {
782
+ const bw = branches[b];
783
+ const blc = bw.map((x) => x.toLowerCase());
784
+ const op = b === 0 ? "intersection" : ops[b - 1];
785
+ if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
786
+ if (blc[0] === "of" || blc[0] === "in") {
787
+ atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
788
+ continue;
789
+ }
790
+ let phrase = bw;
791
+ const vh = findPhrase(blc, VERB_TO_KIND);
792
+ if (vh) prevVerb = bw.slice(vh.start, vh.end);
793
+ else if (prevVerb) phrase = [...prevVerb, ...bw];
794
+ const ast = parseBranchAst(`${subjPrefix} ${phrase.join(" ")}`, nlp, depth);
795
+ if (!ast || ast.node === "miss") return { miss: (ast && ast.reason) || "a clause in the combination didn't parse" };
796
+ atoms.push({ op, kind: "set", ast });
797
+ }
798
+ return { atoms };
799
+ }
800
+
655
801
  function parseRelationalOrQualified(w, lc, nlp, depth) {
802
+ // §6 generalization (predicate-find): seeds the SAME boolean/qualifier fold
803
+ // below with a {node:"find",…} atom instead of the plain {node:"allOfClass"}
804
+ // a bare qualified class gets — see parseFindPredicateHead's own doc above.
805
+ const findHead = parseFindPredicateHead(w, lc);
806
+ if (findHead) {
807
+ const { entityType, term, relIdx } = findHead;
808
+ const predLc = lc.slice(relIdx + 1);
809
+ const predWords = w.slice(relIdx + 1);
810
+ if (!predLc.length) return { node: "miss", reason: `a relative clause needs a predicate after "${lc[relIdx]}"` };
811
+ const built = buildPredicateAtoms(entityType, `which ${lc[relIdx - 1]}`, predLc, predWords, nlp, depth + 1);
812
+ if (built.miss) return { node: "miss", reason: built.miss };
813
+ const atoms = [{ op: "seed", kind: "set", ast: { node: "find", entityType, term } }, ...built.atoms];
814
+ return atoms.length === 1 ? atoms[0].ast : { node: "boolean", entityType, atoms };
815
+ }
816
+
656
817
  let i = 0;
657
818
  while (i < lc.length && FRAME_WORDS.has(lc[i])) i += 1;
658
819
  const framed = i > 0;
@@ -835,6 +996,13 @@ function qualSets(graph) {
835
996
  qualCache.set(graph, c);
836
997
  return c;
837
998
  }
999
+ // KNOWN DIVERGENCE (not a bug, do not merge): this moduleIdOf is DEFINES-EDGE-keyed
1000
+ // (walks the `defines` edge Module->symbol, built once in qualSets above), while
1001
+ // codegraph.mjs's own moduleIdOf (codegraph.mjs:1198) is SITE-ATTRIBUTE-keyed (reads
1002
+ // the individual's `site` attribute / a `fn:<path>#name` id shape) — the two can
1003
+ // disagree for a symbol whose site attribute and defines edge point at different
1004
+ // modules (a genuine, currently-untested cross-file edge case), so this file
1005
+ // deliberately keeps its own copy rather than importing codegraph.mjs's.
838
1006
  function moduleIdOf(graph, ind) {
839
1007
  if (!ind) return null;
840
1008
  if (ind.class === "Module") return ind.id;
@@ -865,11 +1033,185 @@ function qualHolds(graph, ind, spec) {
865
1033
  }
866
1034
  }
867
1035
 
1036
+ // ---- predicate-find (Workstream 2) — the narrow-then-broaden inheritance cascade
1037
+ // over `inherits` edges (Class->Class today; ANY entityType that later gains such
1038
+ // edges between individuals of the SAME class extends automatically — detected
1039
+ // dynamically via inheritsApplicable, never hardcoded to "Class"). ----
1040
+
1041
+ /** All `inherits` edges (subject inherits FROM object — the derived class is the
1042
+ * subject, the base class is the object; RELATIONS.inherits' own comment). */
1043
+ function inheritsEdges(graph) {
1044
+ return edgesOfKind(graph, "inherits");
1045
+ }
1046
+ /** Direct subclasses of `id` (edges whose OBJECT is id). */
1047
+ function directChildrenOf(graph, id) {
1048
+ return inheritsEdges(graph).filter((e) => e.object === id).map((e) => e.subject);
1049
+ }
1050
+ /** Direct superclasses of `id` (edges whose SUBJECT is id). */
1051
+ function directParentsOf(graph, id) {
1052
+ return inheritsEdges(graph).filter((e) => e.subject === id).map((e) => e.object);
1053
+ }
1054
+ /** Every descendant (subclass, transitively) of `id` — cycle-safe BFS. */
1055
+ function descendantsOf(graph, id) {
1056
+ const out = new Set();
1057
+ const queue = [...directChildrenOf(graph, id)];
1058
+ while (queue.length) {
1059
+ const next = queue.shift();
1060
+ if (out.has(next)) continue;
1061
+ out.add(next);
1062
+ for (const c of directChildrenOf(graph, next)) if (!out.has(c)) queue.push(c);
1063
+ }
1064
+ return out;
1065
+ }
1066
+ /** Every ancestor (superclass, transitively) of `id` — cycle-safe BFS. */
1067
+ function ancestorsOf(graph, id) {
1068
+ const out = new Set();
1069
+ const queue = [...directParentsOf(graph, id)];
1070
+ while (queue.length) {
1071
+ const next = queue.shift();
1072
+ if (out.has(next)) continue;
1073
+ out.add(next);
1074
+ for (const p of directParentsOf(graph, next)) if (!out.has(p)) queue.push(p);
1075
+ }
1076
+ return out;
1077
+ }
1078
+ /** Does `entityType` participate in an `inherits`-style subsumption relation TODAY —
1079
+ * at least one inherits edge whose subject AND object are both individuals of this
1080
+ * class? Detected dynamically (never hardcoded to "Class") so the cascade below
1081
+ * extends automatically to any future type that gains such edges; when false, the
1082
+ * broad (ancestor/sibling) pass is simply a no-op and predicate-find degrades to a
1083
+ * flat own-label+attributes match — the common case for Module/Function today.
1084
+ * Memoized per graph (a WeakMap so it never leaks/needs manual invalidation). */
1085
+ const inheritsApplicableCache = new WeakMap();
1086
+ function inheritsApplicable(graph, entityType) {
1087
+ let byType = inheritsApplicableCache.get(graph);
1088
+ if (!byType) { byType = new Map(); inheritsApplicableCache.set(graph, byType); }
1089
+ if (byType.has(entityType)) return byType.get(entityType);
1090
+ const ok = inheritsEdges(graph).some((e) => {
1091
+ const s = graph.byId.get(e.subject); const o = graph.byId.get(e.object);
1092
+ return !!s && !!o && s.class === entityType && o.class === entityType;
1093
+ });
1094
+ byType.set(entityType, ok);
1095
+ return ok;
1096
+ }
1097
+
1098
+ /** Does `ind`'s OWN property surface (label, or an attribute value) contain EVERY
1099
+ * token of the fuzzy term (AND across tokens, same tokenizer resolveObject's own
1100
+ * tier-3 uses)? Returns "label" | "attr" | null — the provenance tag findSortHits
1101
+ * scores label hits above attribute-only hits (per the match-scope design). */
1102
+ function ownSurfaceHit(ind, termTokens) {
1103
+ const labelLc = String(ind.label || "").toLowerCase();
1104
+ if (termTokens.every((tok) => labelLc.includes(tok))) return "label";
1105
+ const attrs = (ind.attributes || []).map((a) => String(a.value ?? "").toLowerCase());
1106
+ if (termTokens.every((tok) => attrs.some((v) => v.includes(tok)))) return "attr";
1107
+ return null;
1108
+ }
1109
+ // own-label hits rank above inheritance-chain hits above attribute-only hits (the
1110
+ // match-scope design's stated scoring); tie-break by shorter label (the same
1111
+ // convention resolveObject's own tiers use for a scored tie).
1112
+ const FIND_TIER = { label: 3, chain: 2, attr: 1 };
1113
+ function sortFindHits(hits) {
1114
+ return hits.slice()
1115
+ .sort((a, b) => (FIND_TIER[b.via] - FIND_TIER[a.via]) || (String(a.ind.label).length - String(b.ind.label).length))
1116
+ .map((h) => h.ind);
1117
+ }
1118
+
1119
+ /** A BOUNDED-FUZZY (Damerau-Levenshtein, same budget resolveObject's own tier-5
1120
+ * uses) near-match of the WHOLE term against `ind`'s label or any of its
1121
+ * components. Used ONLY by the broad pass below — never the narrow pass, whose
1122
+ * exact-substring `ownSurfaceHit` test already runs against EVERY individual of
1123
+ * the type (ancestors and siblings included, being ordinary pool members too),
1124
+ * so an exact-substring re-test in the broad pass would be logically vacuous: if
1125
+ * narrow found nothing, no individual's own surface can contain the term as a
1126
+ * substring, full stop. Fuzzy near-matching is what makes the broad pass find
1127
+ * something narrow genuinely couldn't (a typo'd or partial name on a relative),
1128
+ * which is also why a broad-pass hit is always rendered "related, not exact" —
1129
+ * it is a near-miss by construction, not a confident equal. */
1130
+ function fuzzyFindHit(ind, term) {
1131
+ const tLc = String(term || "").trim().toLowerCase();
1132
+ if (tLc.length < 4) return false; // same floor resolveObject's tier-5 uses
1133
+ const bound = fuzzyBound(tLc);
1134
+ if (editDistance(String(ind.label || "").toLowerCase(), tLc, bound) <= bound) return true;
1135
+ for (const comp of componentSet(ind.label)) {
1136
+ if (editDistance(comp, tLc, bound) <= bound) return true;
1137
+ }
1138
+ return false;
1139
+ }
1140
+
1141
+ /** The narrow-then-broaden search behind evalSet's "find" case and evalComposite's
1142
+ * dedicated "find" handling (predicate-find, Workstream 2):
1143
+ * 1. NARROW — for each `entityType` individual, a hit if its OWN surface matches
1144
+ * every term token, OR (when the type participates in `inherits` today) any of
1145
+ * its DESCENDANTS' own surface does — a subclass genuinely IS a kind of its
1146
+ * superclass, so a hit anywhere in the subtree counts as the candidate itself
1147
+ * matching. If this pass finds ≥1 hit anywhere in the pool, it is the WHOLE
1148
+ * answer — never silently widened when a specific answer exists (the same
1149
+ * discipline Bug C's grain-aware resolution establishes). Every individual of
1150
+ * the type is tested here, ancestors and siblings included (they are ordinary
1151
+ * pool members too) — so an EMPTY narrow pass means no individual's own
1152
+ * surface anywhere in the pool contains the term as a substring.
1153
+ * 2. BROAD — only when the narrow pass is EMPTY across the WHOLE pool AND the
1154
+ * type participates in `inherits`: for each candidate, walk UP to its
1155
+ * superclass(es); a bounded-FUZZY near-match (fuzzyFindHit, above — never a
1156
+ * repeat of narrow's exact test, which the previous point shows would find
1157
+ * nothing new) on a superclass's own surface counts, and so does one on that
1158
+ * superclass's OTHER direct children (siblings) — always rendered as
1159
+ * "related, not exact" (renderComposite), never an unqualified match.
1160
+ * Returns {narrow, broad} — `broad` is only ever non-empty when `narrow` is empty.
1161
+ * When the type has no inherits edges at all, the broad pass is a no-op and this
1162
+ * degrades to a flat own-label+attributes match (Module/Function today). */
1163
+ function computeFind(graph, entityType, term) {
1164
+ const pool = graph.individuals.filter((i) => i.class === entityType);
1165
+ const termTokens = [...componentSet(term)];
1166
+ if (!termTokens.length || !pool.length) return { narrow: [], broad: [] };
1167
+ const cascade = inheritsApplicable(graph, entityType);
1168
+
1169
+ const narrowHits = [];
1170
+ for (const ind of pool) {
1171
+ const own = ownSurfaceHit(ind, termTokens);
1172
+ if (own) { narrowHits.push({ ind, via: own }); continue; }
1173
+ if (!cascade) continue;
1174
+ const viaChain = [...descendantsOf(graph, ind.id)].some((did) => {
1175
+ const d = graph.byId.get(did);
1176
+ return !!d && !!ownSurfaceHit(d, termTokens);
1177
+ });
1178
+ if (viaChain) narrowHits.push({ ind, via: "chain" });
1179
+ }
1180
+ if (narrowHits.length || !cascade) return { narrow: sortFindHits(narrowHits), broad: [] };
1181
+
1182
+ const broadHits = new Map(); // id -> {ind, via}
1183
+ for (const ind of pool) {
1184
+ for (const ancId of ancestorsOf(graph, ind.id)) {
1185
+ if (!broadHits.has(ancId)) {
1186
+ const anc = graph.byId.get(ancId);
1187
+ if (anc && anc.class === entityType && fuzzyFindHit(anc, term)) {
1188
+ broadHits.set(ancId, { ind: anc, via: "chain" });
1189
+ }
1190
+ }
1191
+ for (const sibId of directChildrenOf(graph, ancId)) {
1192
+ if (sibId === ind.id || broadHits.has(sibId)) continue;
1193
+ const sib = graph.byId.get(sibId);
1194
+ if (sib && sib.class === entityType && fuzzyFindHit(sib, term)) broadHits.set(sibId, { ind: sib, via: "chain" });
1195
+ }
1196
+ }
1197
+ }
1198
+ return { narrow: [], broad: sortFindHits([...broadHits.values()]) };
1199
+ }
1200
+
868
1201
  /** Compile a set-producing AST into an array of individuals. */
869
1202
  function evalSet(graph, ast, opts) {
870
1203
  switch (ast.node) {
871
1204
  case "clause": return traverse(graph, ast.clause, opts).matches || [];
872
1205
  case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
1206
+ // predicate-find (Workstream 2), embedded as a set atom (§6 generalization —
1207
+ // a find-seed inside a boolean/qualifier fold): the narrow-then-broaden
1208
+ // cascade's result, transparently flattened (the "related, not exact" framing
1209
+ // is a top-level RENDER concern — evalComposite's dedicated "find" handling
1210
+ // below, not this generic embedding).
1211
+ case "find": {
1212
+ const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
1213
+ return narrow.length ? narrow : broad;
1214
+ }
873
1215
  // the SUBJECTS that have ANY edge of a kind (the existential "modules that import
874
1216
  // anything") — the positive set an existential negation ("do not import anything")
875
1217
  // differences off allOfClass to yield "modules that import nothing".
@@ -1017,6 +1359,17 @@ export function evalComposite(graph, ast, opts = {}) {
1017
1359
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
1018
1360
  if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
1019
1361
  if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
1362
+ // predicate-find (Workstream 2), TOP-LEVEL: unlike evalSet's "find" case (used
1363
+ // when a find-seed is embedded inside a boolean/qualifier fold, §6), this keeps
1364
+ // the broad-pass provenance so renderComposite can label a "related, not exact"
1365
+ // hit distinctly rather than presenting it as an unqualified match.
1366
+ if (ast.node === "find") {
1367
+ const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
1368
+ return {
1369
+ compositeKind: "find", entityType: ast.entityType, term: ast.term,
1370
+ matches: narrow.length ? narrow : broad, broad: !narrow.length && broad.length > 0,
1371
+ };
1372
+ }
1020
1373
  return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
1021
1374
  }
1022
1375
 
@@ -1030,7 +1383,17 @@ const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
1030
1383
  /** A compositional worked example for the rephrase hint (§honest miss now shows a
1031
1384
  * compositional phrasing too). */
1032
1385
  export function compositionalHint() {
1033
- return 'compositional queries also work: "which functions call X and call Y", "what calls something that imports X", "public methods of X", "list functions" / "show me the classes", "how many classes", "which module has the most imports", or (after a listing) "which of those are tested"';
1386
+ return 'compositional queries also work: "which functions call X and call Y", "what calls something that imports X", "public methods of X", "list functions" / "show me the classes", "how many classes", "which module has the most imports", "find me the payment class", or (after a listing) "which of those are tested"';
1387
+ }
1388
+
1389
+ /** A short citation line for a SINGLE predicate-find hit — the module it lives in,
1390
+ * when known (mirrors the plain reverse-shape render's grouping convention, just
1391
+ * condensed to one line since there is exactly one hit to cite). */
1392
+ function describeFindHit(ind) {
1393
+ const label = ["Function", "Method"].includes(ind.class) ? `${ind.label}()` : ind.label;
1394
+ if (ind.class === "Module") return label;
1395
+ const mod = moduleLabelOf(ind);
1396
+ return mod && mod !== "(unknown module)" ? `${label} in ${mod}` : label;
1034
1397
  }
1035
1398
 
1036
1399
  function renderComposite(parsed, result) {
@@ -1057,6 +1420,25 @@ function renderComposite(parsed, result) {
1057
1420
  : "";
1058
1421
  return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
1059
1422
  }
1423
+ // predicate-find (Workstream 2): zero hits -> an honest miss naming BOTH the type
1424
+ // and the term; the broad ("related, not exact") pass is ALWAYS clearly labeled,
1425
+ // never presented as an unqualified match — the confident-wrong discipline Bug
1426
+ // C's grain-aware resolution established; one hit -> a short citation; many hits
1427
+ // -> the standard compositeList/OVERFLOW_CAP convention, reused verbatim.
1428
+ if (result.compositeKind === "find") {
1429
+ const typeNoun = nounFor(result.entityType, 1);
1430
+ if (!result.matches.length) {
1431
+ return { content: `no ${nounFor(result.entityType, 2)} found matching "${result.term}".`, miss: true, ambiguous: false, matches: [] };
1432
+ }
1433
+ const cited = result.matches.length === 1 ? describeFindHit(result.matches[0]) : compositeList(result.matches);
1434
+ if (result.broad) {
1435
+ return {
1436
+ content: `no exact ${typeNoun} named "${result.term}", but found a related ${result.matches.length === 1 ? typeNoun : nounFor(result.entityType, 2)}: ${cited}.`,
1437
+ miss: false, ambiguous: false, matches: result.matches, relatedNotExact: true,
1438
+ };
1439
+ }
1440
+ return { content: `${cited}.`, miss: false, ambiguous: false, matches: result.matches };
1441
+ }
1060
1442
  if (result.compositeKind === "superlative") {
1061
1443
  if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
1062
1444
  const lead = result.extreme === "most" ? "the most" : "the fewest";
@@ -1141,12 +1523,25 @@ function componentSet(s) {
1141
1523
  * only) nor to terms under 4 chars (the bound would cover half of everything).
1142
1524
  * (6) no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
1143
1525
  * [, matchedVia]} — ambiguous on a true tier-3 score tie, a tier-4 overlap-count
1144
- * tie, or a tier-5 distance tie. */
1145
- export function resolveObject(graph, term) {
1526
+ * tie, or a tier-5 distance tie.
1527
+ *
1528
+ * `opts.expectedClass` (grain-aware resolution, Bug C+D fix): when set, narrows
1529
+ * the candidate POOL to `i.class === expectedClass` before every pool-driven tier
1530
+ * (exact/tier-3/tier-5) — the ranking code within each tier is untouched, only the
1531
+ * universe it ranks over shrinks. The ext: tier (synthetic matches with
1532
+ * `class: null`, never a real individual) is skipped outright when a class is
1533
+ * expected — it can never BE that class. The prose tier (tier 4) filters its hits
1534
+ * to the expected class before picking a winner. Every existing call site passes
1535
+ * no 3rd argument, so `expectedClass` defaults to null and behavior is
1536
+ * byte-identical to before this option existed — this is purely opt-in narrowing
1537
+ * for a caller (traverse()'s reverse case) that already knows what class the
1538
+ * relation's object slot expects ("which modules import logger" must never
1539
+ * resolve "logger" to a same-stem Class). */
1540
+ export function resolveObject(graph, term, { expectedClass = null } = {}) {
1146
1541
  const t = String(term || "").trim();
1147
1542
  if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
1148
1543
  const tLc = t.toLowerCase();
1149
- const pool = graph.individuals;
1544
+ const pool = expectedClass ? graph.individuals.filter((i) => i.class === expectedClass) : graph.individuals;
1150
1545
 
1151
1546
  // commit-sha tier (checked first, only for sha-shaped terms): "ef74e44e25c8",
1152
1547
  // "commit ef74e44e25c8", "commit:ef74e44", or a full 40-char sha resolve against
@@ -1183,7 +1578,10 @@ export function resolveObject(graph, term) {
1183
1578
  if (String(e.object).toLowerCase() === extLc) { extId = e.object; break outer; }
1184
1579
  }
1185
1580
  }
1186
- if (extId) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
1581
+ // ext: matches are synthetic (class: null, no real individual) with a class
1582
+ // expected, they can never satisfy it, so skip this tier entirely rather than
1583
+ // returning a match whose class silently doesn't match what the caller asked for.
1584
+ if (extId && !expectedClass) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
1187
1585
 
1188
1586
  // tier 3 — two disjoint regimes (dotted-symbol fix, 2026-07-02, advisor-verified
1189
1587
  // bug): a DOTTED term with no slash ("res.json", "Widget.render", "walk.mjs") is
@@ -1245,7 +1643,9 @@ export function resolveObject(graph, term) {
1245
1643
  // side door — a dotted term names an identifier, and identifiers resolve by
1246
1644
  // label (tiers above) or the bounded fuzzy pass below, or they honestly miss.
1247
1645
  let proseResult = null;
1248
- const proseHits = !dotted && typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, t) : [];
1646
+ const proseHits = !dotted && typeof lookupByProseTokens === "function"
1647
+ ? lookupByProseTokens(graph.proseIndex, t).filter((h) => !expectedClass || graph.byId.get(h.id)?.class === expectedClass)
1648
+ : [];
1249
1649
  if (proseHits.length) {
1250
1650
  const [best, ...rest] = proseHits;
1251
1651
  const bestInd = graph.byId.get(best.id);
@@ -1416,7 +1816,29 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1416
1816
  const token = (i.attributes || []).find((a) => a.key === "token")?.value;
1417
1817
  return token && String(token).toLowerCase() === termLc;
1418
1818
  });
1419
- if (!match) return { matches: [], objMatch: null, candidates: [], traversal: `schema lookup for "${term}"`, ambiguous: false };
1819
+ if (!match) {
1820
+ // META FALLBACK TO REAL ENTITIES (0.8.2 WS1): "what is a Record" used to say
1821
+ // "'Record' isn't a term in this graph's own vocabulary" even when Record is a
1822
+ // code-graph Class individual. After the SchemaClass/SchemaPredicate miss, try
1823
+ // an exact case-insensitive UNIQUE label match against class === "Class"
1824
+ // individuals; a unique hit renders a describe-style one-liner (see render's
1825
+ // metaCodeClass branch). Anything less than a unique exact hit keeps the
1826
+ // honest vocabulary miss — never a guess.
1827
+ const classHits = (graph.individuals || []).filter((i) => i.class === "Class" && String(i.label).toLowerCase() === termLc);
1828
+ if (classHits.length === 1) {
1829
+ const hit = classHits[0];
1830
+ const mid = moduleIdOf(graph, hit);
1831
+ const modLabel = (mid && graph.byId.get(mid)?.label)
1832
+ || String((hit.attributes || []).find((a) => a.key === "site")?.value || "").split(":")[0]
1833
+ || null;
1834
+ return {
1835
+ matches: [hit], objMatch: hit, candidates: [], ambiguous: false,
1836
+ metaCodeClass: true, metaModuleLabel: modLabel,
1837
+ traversal: `schema lookup for "${term}" (miss), then unique Class individual by label`,
1838
+ };
1839
+ }
1840
+ return { matches: [], objMatch: null, candidates: [], traversal: `schema lookup for "${term}"`, ambiguous: false };
1841
+ }
1420
1842
  return {
1421
1843
  matches: [match], objMatch: match, candidates: [],
1422
1844
  traversal: `schema lookup for "${term}"`, ambiguous: false,
@@ -1536,9 +1958,22 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1536
1958
  }
1537
1959
 
1538
1960
  if (shape === "forward") {
1539
- const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
1540
- const matches = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
1541
- return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
1961
+ // FORWARD CALL UNION (0.8.2 WS1): a kind with a symbol-grain sibling scans the
1962
+ // UNION coarse+sibling when the resolved SUBJECT is itself a fine symbol —
1963
+ // "what does Widget.render call" lives on callsSymbol (fn->fn), which the
1964
+ // module-coarse scan alone can never reach (a coarse edge's subject is a
1965
+ // module, so a Function/Method subject rendered a false "no calls edges" while
1966
+ // the reverse direction answered). Module subjects never carry a sibling edge,
1967
+ // so their scan — and the traversal receipt — stays byte-identical. The receipt
1968
+ // names what was actually scanned ("calls+callsSymbol edges where subject = X").
1969
+ const fwdSibling = SYMBOL_GRAIN_SIBLING[kind];
1970
+ const subjIsFineSymbol = !!(fwdSibling && objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
1971
+ const fwdKinds = subjIsFineSymbol ? [...new Set([...kindsFor(kind), fwdSibling])] : kindsFor(kind);
1972
+ const edges = fwdKinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
1973
+ const targets = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
1974
+ // dedupe only on the widened scan — the coarse-only path keeps its exact shape.
1975
+ const matches = subjIsFineSymbol ? uniqueById(targets) : targets;
1976
+ return { matches, objMatch, candidates, traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
1542
1977
  }
1543
1978
 
1544
1979
  // reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
@@ -1577,8 +2012,82 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1577
2012
  if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsFineSymbol)) {
1578
2013
  const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
1579
2014
  const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
1580
- const matches = (!entityType || entityType === "Change") ? subjects : subjects.filter((i) => i.class === entityType);
1581
- return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}`, ambiguous, matchedVia };
2015
+ let matches = (!entityType || entityType === "Change") ? subjects : subjects.filter((i) => i.class === entityType);
2016
+ let widenNote = "";
2017
+ // FINE-GRAIN FAMILY FALLBACK (0.8.2 WS1): when the exact-class filter comes back
2018
+ // EMPTY and the asked grain is Function/Method, retry with the family sibling —
2019
+ // "which functions call fnAlpha" must not hide the recorded caller Widget.render
2020
+ // just because the extractor stored it as class Method. Fallback-only by
2021
+ // construction (the exact filter must be empty first), so every currently
2022
+ // non-empty answer is byte-identical; the widening is said in the traversal.
2023
+ const siblingClass = FINE_CLASS_SIBLING[entityType];
2024
+ if (!matches.length && siblingClass) {
2025
+ const widened = subjects.filter((i) => i.class === siblingClass);
2026
+ if (widened.length) {
2027
+ matches = widened;
2028
+ widenNote = `, widened to ${siblingClass} subjects (no ${entityType} recorded)`;
2029
+ }
2030
+ }
2031
+ return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}${widenNote}`, ambiguous, matchedVia };
2032
+ }
2033
+
2034
+ // §grain-aware object resolution (Bug C+D, HANDOVER follow-up #2, checked BEFORE
2035
+ // the edge filter below): a predicate's OBJECT slot carries one particular class
2036
+ // (kindObjectClass) — resolveObject itself is blind to that, so a same-stem term
2037
+ // ("logger") can resolve to the WRONG grain (a Class named Logger) instead of the
2038
+ // Module the "imports"/"calls"/… edge actually points at, and the edge filter
2039
+ // below then legitimately returns [] for the wrong-grain id — a confident-wrong
2040
+ // empty, not an honest miss. `wantClass` is null for a kind whose edges span more
2041
+ // than one object class (e.g. "contains") — no grain check applies there, byte-
2042
+ // identical to before. objMatch.class === null (an ext: synthetic match, no real
2043
+ // individual — see resolveObject's tier 2) is likewise never grain-checked: it has
2044
+ // no better class to compare against, and is already the most specific resolution
2045
+ // available.
2046
+ let gObjMatch = objMatch;
2047
+ let gCandidates = candidates;
2048
+ let gAmbiguous = ambiguous;
2049
+ let gMatchedVia = matchedVia;
2050
+ let grainRefinedNote = "";
2051
+ const wantClass = kindObjectClass(graph, kind);
2052
+ if (wantClass && gObjMatch.class && gObjMatch.class !== wantClass) {
2053
+ // (1) retry resolution SCOPED to the expected class — "logger" now only
2054
+ // considers Module individuals, so it lands on src/lib/logger.mjs instead of
2055
+ // the same-stem Class (fixes Bug C).
2056
+ const retry = resolveObject(graph, parsed.object, { expectedClass: wantClass });
2057
+ if (retry.match && !retry.ambiguous) {
2058
+ gObjMatch = retry.match;
2059
+ gCandidates = retry.candidates;
2060
+ gAmbiguous = retry.ambiguous;
2061
+ gMatchedVia = retry.matchedVia;
2062
+ } else if ((kind === "tests" || kind === "cochange") && gObjMatch.class !== "Module") {
2063
+ // (2) tests/cochange are always Module->Module — no same-grain alternative
2064
+ // exists (the retry above genuinely found nothing), but the resolved
2065
+ // fine-grain entity (a Function, say) DOES live in a module, and that
2066
+ // module is the real, honest subject of a tests/cochange question ("does
2067
+ // createTask have tests" — fixes Bug D). Up-refine via the same moduleIdOf
2068
+ // qualHolds's "tested" case already uses (see its divergence comment above).
2069
+ const mid = moduleIdOf(graph, gObjMatch);
2070
+ const mod = mid && graph.byId.get(mid);
2071
+ if (mod) {
2072
+ grainRefinedNote = `, refined from ${gObjMatch.label} to its containing module`;
2073
+ gObjMatch = mod;
2074
+ } else {
2075
+ return {
2076
+ matches: [], objMatch: gObjMatch, candidates: gCandidates, ambiguous: gAmbiguous, matchedVia: gMatchedVia,
2077
+ wrongGrainMiss: true, wantClass,
2078
+ traversal: `"${parsed.object}" resolved to ${gObjMatch.class} ${gObjMatch.label} (grain mismatch: this "${kind}" question needs a ${wantClass}, and no containing module could be found to refine to)`,
2079
+ };
2080
+ }
2081
+ } else {
2082
+ // (3) neither a same-grain resolution nor an up-refinement applies — an
2083
+ // honest wrong-grain miss, distinct from both "unresolved" (the existing
2084
+ // objMatch-null branch below, untouched) and "resolved + genuinely empty".
2085
+ return {
2086
+ matches: [], objMatch: gObjMatch, candidates: gCandidates, ambiguous: gAmbiguous, matchedVia: gMatchedVia,
2087
+ wrongGrainMiss: true, wantClass,
2088
+ traversal: `"${parsed.object}" resolved to ${gObjMatch.class} ${gObjMatch.label} (grain mismatch: this "${kind}" question needs a ${wantClass})`,
2089
+ };
2090
+ }
1582
2091
  }
1583
2092
 
1584
2093
  // General case: some predicates are already fine-grained (inherits: Class->Class, contains:
@@ -1587,16 +2096,16 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1587
2096
  // already match the requested entityType, use them directly (inherits); only when they're
1588
2097
  // Module individuals and a FINER entityType was asked for do we refine via `defines`
1589
2098
  // (imports) — never blindly treat an edge's subject id as if it were always a module id.
1590
- let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
2099
+ let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === gObjMatch.id);
1591
2100
  let extNote = "";
1592
- if (!edges.length && objMatch.class) {
2101
+ if (!edges.length && gObjMatch.class) {
1593
2102
  // Unresolved ext:<Name> endpoints with the SAME name as the resolved entity:
1594
2103
  // the extractor declined to assert identity (e.g. commander's every "class X
1595
2104
  // extends Command" edge points at ext:Command, never the Class node), so a
1596
2105
  // strict id match renders a FALSE blank. Count them by NAME instead and say
1597
2106
  // so in the receipt — name-grade evidence, labeled as such, same standard as
1598
2107
  // resolveObject's own ext: tier.
1599
- const extId = `ext:${String(objMatch.label).toLowerCase()}`;
2108
+ const extId = `ext:${String(gObjMatch.label).toLowerCase()}`;
1600
2109
  edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => String(e.object).toLowerCase() === extId);
1601
2110
  if (edges.length) extNote = ` (by name, via unresolved ${extId} references)`;
1602
2111
  }
@@ -1627,7 +2136,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1627
2136
  matches = [];
1628
2137
  }
1629
2138
  }
1630
- return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where object = ${objMatch.label}${extNote}${grainNote}`, ambiguous, matchedVia };
2139
+ return {
2140
+ matches, objMatch: gObjMatch, candidates: gCandidates,
2141
+ traversal: `${kindsFor(kind).join("+")} edges where object = ${gObjMatch.label}${extNote}${grainNote}${grainRefinedNote}`,
2142
+ ambiguous: gAmbiguous, matchedVia: gMatchedVia,
2143
+ };
1631
2144
  }
1632
2145
 
1633
2146
  // ---- §5 templated renderer — string interpolation + grouping/pluralization/overflow rules,
@@ -1707,6 +2220,19 @@ function renderCore(parsed, result) {
1707
2220
  miss: true, ambiguous: false,
1708
2221
  };
1709
2222
  }
2223
+ // wrong-grain honest miss (Bug C+D, traverse()'s general reverse case): the term
2224
+ // resolved to a REAL entity, just not the class this predicate's object slot
2225
+ // needs, and no same-grain alternative (nor an up-refinement to a containing
2226
+ // module) exists — distinct from both the objMatch-null "unresolved" miss below
2227
+ // and a resolved-but-genuinely-empty answer.
2228
+ if (result.wrongGrainMiss) {
2229
+ const gotNoun = result.objMatch.class ? nounFor(result.objMatch.class, 1) : "term";
2230
+ const wantNoun = nounFor(result.wantClass, 1);
2231
+ return {
2232
+ content: `"${parsed.object}" resolved to the ${gotNoun} ${result.objMatch.label}, but this question needs a ${wantNoun} — no ${wantNoun} named "${parsed.object}" was found in the index.`,
2233
+ miss: true, ambiguous: false,
2234
+ };
2235
+ }
1710
2236
  if (parsed.shape === "meta") {
1711
2237
  if (!result.objMatch) {
1712
2238
  return {
@@ -1714,6 +2240,17 @@ function renderCore(parsed, result) {
1714
2240
  miss: true, ambiguous: false,
1715
2241
  };
1716
2242
  }
2243
+ // meta fallback hit (0.8.2 WS1, see traverse's meta branch): the term is not
2244
+ // schema vocabulary but IS a unique code-graph Class — a describe-style
2245
+ // one-liner pointing at the real entity, instead of the false vocabulary miss.
2246
+ if (result.metaCodeClass) {
2247
+ const label = result.objMatch.label;
2248
+ const definedIn = result.metaModuleLabel ? `, defined in ${result.metaModuleLabel}` : "";
2249
+ return {
2250
+ content: `${label} is a class in this codebase${definedIn} — try "describe ${label}" or "which classes inherit from ${label}".`,
2251
+ miss: false, ambiguous: false, matches: result.matches,
2252
+ };
2253
+ }
1717
2254
  const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
1718
2255
  const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
1719
2256
  return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
@@ -1724,7 +2261,7 @@ function renderCore(parsed, result) {
1724
2261
  if (result.mentionsShape) {
1725
2262
  if (!result.matches.length) {
1726
2263
  return {
1727
- content: `"${parsed.object}" is not mentioned in any indexed identifier or doc-comment prose. (traversal: ${result.traversal})`,
2264
+ content: `"${parsed.object}" is not mentioned in any indexed identifier or doc-comment prose.`,
1728
2265
  miss: true, ambiguous: false,
1729
2266
  };
1730
2267
  }
@@ -1781,7 +2318,7 @@ function renderCore(parsed, result) {
1781
2318
  if (result.whenShape) {
1782
2319
  const subject = result.objMatch.label;
1783
2320
  if (!result.matches.length) {
1784
- return { content: `no recorded commit touches ${subject} in this index. (traversal: ${result.traversal})`, miss: true, ambiguous: false };
2321
+ return { content: `no recorded commit touches ${subject} in this index.`, miss: true, ambiguous: false };
1785
2322
  }
1786
2323
  const newest = result.matches[0];
1787
2324
  const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
@@ -1811,7 +2348,7 @@ function renderCore(parsed, result) {
1811
2348
  const cite = `commit ${result.objMatch.label}`;
1812
2349
  if (!result.matches.length) {
1813
2350
  return {
1814
- content: `${cite} touched nothing recorded in the index. (traversal: ${result.traversal})`,
2351
+ content: `${cite} touched nothing recorded in the index.`,
1815
2352
  miss: true, ambiguous: false,
1816
2353
  };
1817
2354
  }
@@ -1829,8 +2366,11 @@ function renderCore(parsed, result) {
1829
2366
  if (!result.objMatch || !result.subjMatch) {
1830
2367
  return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
1831
2368
  }
2369
+ // the yes render is plain words — the traversal string IS "<kind> edge from
2370
+ // <A> to <B>", so it reads as the sentence itself, not a parenthetical receipt
2371
+ // (the receipt still rides on the result's traversal field for why/verbose).
1832
2372
  return {
1833
- content: result.answer ? `Yes. (${result.traversal})` : `No — no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
2373
+ content: result.answer ? `Yes ${result.traversal}.` : `No — no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
1834
2374
  miss: !result.answer, ambiguous: false,
1835
2375
  };
1836
2376
  }
@@ -1841,21 +2381,24 @@ function renderCore(parsed, result) {
1841
2381
  // subject-first phrasing rather than reusing reverse's "found ... that OBJECT" template.
1842
2382
  if (parsed.shape === "forward") {
1843
2383
  return {
1844
- content: `${result.objMatch.label} has no ${parsed.kind} edges in the index. (traversal: ${result.traversal || "no traversal resolved"})`,
2384
+ content: `${result.objMatch.label} has no ${verbFor(parsed.kind)} edges in the index.`,
1845
2385
  miss: true, ambiguous: false,
1846
2386
  };
1847
2387
  }
1848
2388
  // "what tests cover X" / "what tests X" — the tests themselves are the search
1849
2389
  // target (no explicit entity keyword → entityType null), and "tests" reads as a
1850
2390
  // verb phrase, so the generic "No <modules> found whose module directly tests <obj>"
1851
- // template garbles: it mislabels the searched kind as "modules" and lets the leaked
1852
- // "cover " verb ride into the object ("…directly tests cover X"). Render the honest
1853
- // empty as the natural "No tests cover X." The frozen entity-keyword form ("which
1854
- // modules test X", entityType="Module") keeps its pinned wording below.
2391
+ // template garbles: it mislabels the searched kind as "modules" and lets the user's
2392
+ // leaked verb ride into the object ("…tests cover touch X"). Any leading relation
2393
+ // verb (cover/touch/check/verify/… LEADING_RELATION_VERB_RE, built from the
2394
+ // ask-vocab verb table) is stripped, so the honest empty reads as the natural
2395
+ // "No tests cover X." The frozen entity-keyword form ("which modules test X",
2396
+ // entityType="Module") keeps its pinned wording below.
1855
2397
  if (parsed.kind === "tests" && !parsed.entityType) {
1856
- const obj = String(parsed.object || "").replace(/^cover(?:s|ing)?\s+/i, "").trim();
2398
+ const stripped = String(parsed.object || "").replace(LEADING_RELATION_VERB_RE, "").trim();
2399
+ const obj = stripped || String(parsed.object || "").trim();
1857
2400
  return {
1858
- content: `No tests cover ${obj}. (traversal: ${result.traversal || "no traversal resolved"})`,
2401
+ content: `No tests cover ${obj}.`,
1859
2402
  miss: true, ambiguous: false,
1860
2403
  };
1861
2404
  }
@@ -1865,7 +2408,7 @@ function renderCore(parsed, result) {
1865
2408
  // append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
1866
2409
  const entityWord = nounFor(parsed.entityType || "Module", 2);
1867
2410
  return {
1868
- content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. (traversal: ${result.traversal || "no traversal resolved"})`,
2411
+ content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}.`,
1869
2412
  miss: true, ambiguous: false,
1870
2413
  };
1871
2414
  }