@polycode-projects/the-mechanical-code-talker 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ask.mjs CHANGED
@@ -130,8 +130,12 @@ function nounFor(entityType, n) {
130
130
  // name is a bare noun/verb stem ("X cochange Y" is wrong; "X cochanges Y" is right) —
131
131
  // so the reverse-shape zero-hit template below reads off this table instead of
132
132
  // unconditionally appending "s" (which used to double-pluralize every other kind:
133
- // "callss", "importss", "touchess").
134
- 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" };
135
139
  function verbFor(kind) {
136
140
  return REVERSE_MISS_VERB[kind] || kind;
137
141
  }
@@ -279,6 +283,7 @@ function parseComposite(text, nlp) {
279
283
  || parseAnaphora(w, lc, nlp)
280
284
  || parseAggregate(w, lc, nlp)
281
285
  || parseSuperlative(w, lc, nlp)
286
+ || parseFind(w, lc, nlp, 0)
282
287
  || parseList(w, lc, nlp, 0)
283
288
  || parseNested(w, lc, nlp, 0)
284
289
  || parseRelationalOrQualified(w, lc, nlp, 0);
@@ -673,6 +678,62 @@ function parseSuperlative(w, lc, nlp) {
673
678
  return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
674
679
  }
675
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
+
676
737
  /** RELATIONAL / BOOLEAN / QUALIFIER (subject-first): "[which] [<qualifier>…] <entity>
677
738
  * [that] <predicate>", where <predicate> is one or more relation clauses joined by
678
739
  * and/or/but-not over the SAME subject, a "<of|in> <term>" membership, or empty (a
@@ -681,7 +742,78 @@ function parseSuperlative(w, lc, nlp) {
681
742
  * reverse query ("which functions call helper") and the bare-template ambiguous case
682
743
  * ("which classes extends Base and couples to logging", no marker) both fall through
683
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
+
684
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
+
685
817
  let i = 0;
686
818
  while (i < lc.length && FRAME_WORDS.has(lc[i])) i += 1;
687
819
  const framed = i > 0;
@@ -689,15 +821,19 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
689
821
  while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
690
822
  const noun = i < lc.length ? entityNoun(lc[i]) : null;
691
823
  if (!noun) {
692
- // an unknown adjective sitting in the qualifier slot, right before a known
693
- // entity noun ("which shiny methods", "static frobnicated functions") — an
694
- // honest miss that NAMES the supported qualifiers, never a guess (PLAN P3).
695
- // STOPWORDS are excluded so a normal question auxiliary in that position ("what
696
- // DID commit X touch", "what WAS in <sha>") is left for the existing parser, not
697
- // mistaken for an unknown qualifier.
698
- if ((framed || quals.length) && i + 1 < lc.length && /^[a-z]+$/.test(lc[i])
699
- && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && entityNoun(lc[i + 1])) {
700
- return { node: "miss", reason: `unknown qualifier "${lc[i]}" — supported: ${Object.keys(QUALIFIERS).join(", ")}` };
824
+ // An unknown adjective sitting in the qualifier slot, right before a known entity
825
+ // noun ("list payment modules", "which shiny methods") — try it as a predicate-find
826
+ // fuzzy term FIRST ("payment" filtering Module labels/attributes) before declaring
827
+ // it an unrecognized qualifier: a real, honest answer beats an error message, and a
828
+ // genuine zero-hit still renders find's own honest "no <noun> found matching <term>"
829
+ // miss (never a confident-wrong guess either way — same discipline as everywhere
830
+ // else this AST node is produced). STOPWORDS are excluded so a normal question
831
+ // auxiliary in that position ("what DID commit X touch") is left for the existing
832
+ // parser, not mistaken for a term.
833
+ const nextNoun = i + 1 < lc.length ? entityNoun(lc[i + 1]) : null;
834
+ if ((framed || quals.length) && nextNoun && /^[a-z]+$/.test(lc[i])
835
+ && !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i])) {
836
+ return { node: "find", entityType: nextNoun.entityType, term: w[i] };
701
837
  }
702
838
  return null; // no subject entity → not this shape
703
839
  }
@@ -864,6 +1000,13 @@ function qualSets(graph) {
864
1000
  qualCache.set(graph, c);
865
1001
  return c;
866
1002
  }
1003
+ // KNOWN DIVERGENCE (not a bug, do not merge): this moduleIdOf is DEFINES-EDGE-keyed
1004
+ // (walks the `defines` edge Module->symbol, built once in qualSets above), while
1005
+ // codegraph.mjs's own moduleIdOf (codegraph.mjs:1198) is SITE-ATTRIBUTE-keyed (reads
1006
+ // the individual's `site` attribute / a `fn:<path>#name` id shape) — the two can
1007
+ // disagree for a symbol whose site attribute and defines edge point at different
1008
+ // modules (a genuine, currently-untested cross-file edge case), so this file
1009
+ // deliberately keeps its own copy rather than importing codegraph.mjs's.
867
1010
  function moduleIdOf(graph, ind) {
868
1011
  if (!ind) return null;
869
1012
  if (ind.class === "Module") return ind.id;
@@ -894,11 +1037,185 @@ function qualHolds(graph, ind, spec) {
894
1037
  }
895
1038
  }
896
1039
 
1040
+ // ---- predicate-find (Workstream 2) — the narrow-then-broaden inheritance cascade
1041
+ // over `inherits` edges (Class->Class today; ANY entityType that later gains such
1042
+ // edges between individuals of the SAME class extends automatically — detected
1043
+ // dynamically via inheritsApplicable, never hardcoded to "Class"). ----
1044
+
1045
+ /** All `inherits` edges (subject inherits FROM object — the derived class is the
1046
+ * subject, the base class is the object; RELATIONS.inherits' own comment). */
1047
+ function inheritsEdges(graph) {
1048
+ return edgesOfKind(graph, "inherits");
1049
+ }
1050
+ /** Direct subclasses of `id` (edges whose OBJECT is id). */
1051
+ function directChildrenOf(graph, id) {
1052
+ return inheritsEdges(graph).filter((e) => e.object === id).map((e) => e.subject);
1053
+ }
1054
+ /** Direct superclasses of `id` (edges whose SUBJECT is id). */
1055
+ function directParentsOf(graph, id) {
1056
+ return inheritsEdges(graph).filter((e) => e.subject === id).map((e) => e.object);
1057
+ }
1058
+ /** Every descendant (subclass, transitively) of `id` — cycle-safe BFS. */
1059
+ function descendantsOf(graph, id) {
1060
+ const out = new Set();
1061
+ const queue = [...directChildrenOf(graph, id)];
1062
+ while (queue.length) {
1063
+ const next = queue.shift();
1064
+ if (out.has(next)) continue;
1065
+ out.add(next);
1066
+ for (const c of directChildrenOf(graph, next)) if (!out.has(c)) queue.push(c);
1067
+ }
1068
+ return out;
1069
+ }
1070
+ /** Every ancestor (superclass, transitively) of `id` — cycle-safe BFS. */
1071
+ function ancestorsOf(graph, id) {
1072
+ const out = new Set();
1073
+ const queue = [...directParentsOf(graph, id)];
1074
+ while (queue.length) {
1075
+ const next = queue.shift();
1076
+ if (out.has(next)) continue;
1077
+ out.add(next);
1078
+ for (const p of directParentsOf(graph, next)) if (!out.has(p)) queue.push(p);
1079
+ }
1080
+ return out;
1081
+ }
1082
+ /** Does `entityType` participate in an `inherits`-style subsumption relation TODAY —
1083
+ * at least one inherits edge whose subject AND object are both individuals of this
1084
+ * class? Detected dynamically (never hardcoded to "Class") so the cascade below
1085
+ * extends automatically to any future type that gains such edges; when false, the
1086
+ * broad (ancestor/sibling) pass is simply a no-op and predicate-find degrades to a
1087
+ * flat own-label+attributes match — the common case for Module/Function today.
1088
+ * Memoized per graph (a WeakMap so it never leaks/needs manual invalidation). */
1089
+ const inheritsApplicableCache = new WeakMap();
1090
+ function inheritsApplicable(graph, entityType) {
1091
+ let byType = inheritsApplicableCache.get(graph);
1092
+ if (!byType) { byType = new Map(); inheritsApplicableCache.set(graph, byType); }
1093
+ if (byType.has(entityType)) return byType.get(entityType);
1094
+ const ok = inheritsEdges(graph).some((e) => {
1095
+ const s = graph.byId.get(e.subject); const o = graph.byId.get(e.object);
1096
+ return !!s && !!o && s.class === entityType && o.class === entityType;
1097
+ });
1098
+ byType.set(entityType, ok);
1099
+ return ok;
1100
+ }
1101
+
1102
+ /** Does `ind`'s OWN property surface (label, or an attribute value) contain EVERY
1103
+ * token of the fuzzy term (AND across tokens, same tokenizer resolveObject's own
1104
+ * tier-3 uses)? Returns "label" | "attr" | null — the provenance tag findSortHits
1105
+ * scores label hits above attribute-only hits (per the match-scope design). */
1106
+ function ownSurfaceHit(ind, termTokens) {
1107
+ const labelLc = String(ind.label || "").toLowerCase();
1108
+ if (termTokens.every((tok) => labelLc.includes(tok))) return "label";
1109
+ const attrs = (ind.attributes || []).map((a) => String(a.value ?? "").toLowerCase());
1110
+ if (termTokens.every((tok) => attrs.some((v) => v.includes(tok)))) return "attr";
1111
+ return null;
1112
+ }
1113
+ // own-label hits rank above inheritance-chain hits above attribute-only hits (the
1114
+ // match-scope design's stated scoring); tie-break by shorter label (the same
1115
+ // convention resolveObject's own tiers use for a scored tie).
1116
+ const FIND_TIER = { label: 3, chain: 2, attr: 1 };
1117
+ function sortFindHits(hits) {
1118
+ return hits.slice()
1119
+ .sort((a, b) => (FIND_TIER[b.via] - FIND_TIER[a.via]) || (String(a.ind.label).length - String(b.ind.label).length))
1120
+ .map((h) => h.ind);
1121
+ }
1122
+
1123
+ /** A BOUNDED-FUZZY (Damerau-Levenshtein, same budget resolveObject's own tier-5
1124
+ * uses) near-match of the WHOLE term against `ind`'s label or any of its
1125
+ * components. Used ONLY by the broad pass below — never the narrow pass, whose
1126
+ * exact-substring `ownSurfaceHit` test already runs against EVERY individual of
1127
+ * the type (ancestors and siblings included, being ordinary pool members too),
1128
+ * so an exact-substring re-test in the broad pass would be logically vacuous: if
1129
+ * narrow found nothing, no individual's own surface can contain the term as a
1130
+ * substring, full stop. Fuzzy near-matching is what makes the broad pass find
1131
+ * something narrow genuinely couldn't (a typo'd or partial name on a relative),
1132
+ * which is also why a broad-pass hit is always rendered "related, not exact" —
1133
+ * it is a near-miss by construction, not a confident equal. */
1134
+ function fuzzyFindHit(ind, term) {
1135
+ const tLc = String(term || "").trim().toLowerCase();
1136
+ if (tLc.length < 4) return false; // same floor resolveObject's tier-5 uses
1137
+ const bound = fuzzyBound(tLc);
1138
+ if (editDistance(String(ind.label || "").toLowerCase(), tLc, bound) <= bound) return true;
1139
+ for (const comp of componentSet(ind.label)) {
1140
+ if (editDistance(comp, tLc, bound) <= bound) return true;
1141
+ }
1142
+ return false;
1143
+ }
1144
+
1145
+ /** The narrow-then-broaden search behind evalSet's "find" case and evalComposite's
1146
+ * dedicated "find" handling (predicate-find, Workstream 2):
1147
+ * 1. NARROW — for each `entityType` individual, a hit if its OWN surface matches
1148
+ * every term token, OR (when the type participates in `inherits` today) any of
1149
+ * its DESCENDANTS' own surface does — a subclass genuinely IS a kind of its
1150
+ * superclass, so a hit anywhere in the subtree counts as the candidate itself
1151
+ * matching. If this pass finds ≥1 hit anywhere in the pool, it is the WHOLE
1152
+ * answer — never silently widened when a specific answer exists (the same
1153
+ * discipline Bug C's grain-aware resolution establishes). Every individual of
1154
+ * the type is tested here, ancestors and siblings included (they are ordinary
1155
+ * pool members too) — so an EMPTY narrow pass means no individual's own
1156
+ * surface anywhere in the pool contains the term as a substring.
1157
+ * 2. BROAD — only when the narrow pass is EMPTY across the WHOLE pool AND the
1158
+ * type participates in `inherits`: for each candidate, walk UP to its
1159
+ * superclass(es); a bounded-FUZZY near-match (fuzzyFindHit, above — never a
1160
+ * repeat of narrow's exact test, which the previous point shows would find
1161
+ * nothing new) on a superclass's own surface counts, and so does one on that
1162
+ * superclass's OTHER direct children (siblings) — always rendered as
1163
+ * "related, not exact" (renderComposite), never an unqualified match.
1164
+ * Returns {narrow, broad} — `broad` is only ever non-empty when `narrow` is empty.
1165
+ * When the type has no inherits edges at all, the broad pass is a no-op and this
1166
+ * degrades to a flat own-label+attributes match (Module/Function today). */
1167
+ function computeFind(graph, entityType, term) {
1168
+ const pool = graph.individuals.filter((i) => i.class === entityType);
1169
+ const termTokens = [...componentSet(term)];
1170
+ if (!termTokens.length || !pool.length) return { narrow: [], broad: [] };
1171
+ const cascade = inheritsApplicable(graph, entityType);
1172
+
1173
+ const narrowHits = [];
1174
+ for (const ind of pool) {
1175
+ const own = ownSurfaceHit(ind, termTokens);
1176
+ if (own) { narrowHits.push({ ind, via: own }); continue; }
1177
+ if (!cascade) continue;
1178
+ const viaChain = [...descendantsOf(graph, ind.id)].some((did) => {
1179
+ const d = graph.byId.get(did);
1180
+ return !!d && !!ownSurfaceHit(d, termTokens);
1181
+ });
1182
+ if (viaChain) narrowHits.push({ ind, via: "chain" });
1183
+ }
1184
+ if (narrowHits.length || !cascade) return { narrow: sortFindHits(narrowHits), broad: [] };
1185
+
1186
+ const broadHits = new Map(); // id -> {ind, via}
1187
+ for (const ind of pool) {
1188
+ for (const ancId of ancestorsOf(graph, ind.id)) {
1189
+ if (!broadHits.has(ancId)) {
1190
+ const anc = graph.byId.get(ancId);
1191
+ if (anc && anc.class === entityType && fuzzyFindHit(anc, term)) {
1192
+ broadHits.set(ancId, { ind: anc, via: "chain" });
1193
+ }
1194
+ }
1195
+ for (const sibId of directChildrenOf(graph, ancId)) {
1196
+ if (sibId === ind.id || broadHits.has(sibId)) continue;
1197
+ const sib = graph.byId.get(sibId);
1198
+ if (sib && sib.class === entityType && fuzzyFindHit(sib, term)) broadHits.set(sibId, { ind: sib, via: "chain" });
1199
+ }
1200
+ }
1201
+ }
1202
+ return { narrow: [], broad: sortFindHits([...broadHits.values()]) };
1203
+ }
1204
+
897
1205
  /** Compile a set-producing AST into an array of individuals. */
898
1206
  function evalSet(graph, ast, opts) {
899
1207
  switch (ast.node) {
900
1208
  case "clause": return traverse(graph, ast.clause, opts).matches || [];
901
1209
  case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
1210
+ // predicate-find (Workstream 2), embedded as a set atom (§6 generalization —
1211
+ // a find-seed inside a boolean/qualifier fold): the narrow-then-broaden
1212
+ // cascade's result, transparently flattened (the "related, not exact" framing
1213
+ // is a top-level RENDER concern — evalComposite's dedicated "find" handling
1214
+ // below, not this generic embedding).
1215
+ case "find": {
1216
+ const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
1217
+ return narrow.length ? narrow : broad;
1218
+ }
902
1219
  // the SUBJECTS that have ANY edge of a kind (the existential "modules that import
903
1220
  // anything") — the positive set an existential negation ("do not import anything")
904
1221
  // differences off allOfClass to yield "modules that import nothing".
@@ -1046,6 +1363,17 @@ export function evalComposite(graph, ast, opts = {}) {
1046
1363
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
1047
1364
  if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
1048
1365
  if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
1366
+ // predicate-find (Workstream 2), TOP-LEVEL: unlike evalSet's "find" case (used
1367
+ // when a find-seed is embedded inside a boolean/qualifier fold, §6), this keeps
1368
+ // the broad-pass provenance so renderComposite can label a "related, not exact"
1369
+ // hit distinctly rather than presenting it as an unqualified match.
1370
+ if (ast.node === "find") {
1371
+ const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
1372
+ return {
1373
+ compositeKind: "find", entityType: ast.entityType, term: ast.term,
1374
+ matches: narrow.length ? narrow : broad, broad: !narrow.length && broad.length > 0,
1375
+ };
1376
+ }
1049
1377
  return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
1050
1378
  }
1051
1379
 
@@ -1059,7 +1387,17 @@ const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
1059
1387
  /** A compositional worked example for the rephrase hint (§honest miss now shows a
1060
1388
  * compositional phrasing too). */
1061
1389
  export function compositionalHint() {
1062
- 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"';
1390
+ 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"';
1391
+ }
1392
+
1393
+ /** A short citation line for a SINGLE predicate-find hit — the module it lives in,
1394
+ * when known (mirrors the plain reverse-shape render's grouping convention, just
1395
+ * condensed to one line since there is exactly one hit to cite). */
1396
+ function describeFindHit(ind) {
1397
+ const label = ["Function", "Method"].includes(ind.class) ? `${ind.label}()` : ind.label;
1398
+ if (ind.class === "Module") return label;
1399
+ const mod = moduleLabelOf(ind);
1400
+ return mod && mod !== "(unknown module)" ? `${label} in ${mod}` : label;
1063
1401
  }
1064
1402
 
1065
1403
  function renderComposite(parsed, result) {
@@ -1086,6 +1424,25 @@ function renderComposite(parsed, result) {
1086
1424
  : "";
1087
1425
  return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
1088
1426
  }
1427
+ // predicate-find (Workstream 2): zero hits -> an honest miss naming BOTH the type
1428
+ // and the term; the broad ("related, not exact") pass is ALWAYS clearly labeled,
1429
+ // never presented as an unqualified match — the confident-wrong discipline Bug
1430
+ // C's grain-aware resolution established; one hit -> a short citation; many hits
1431
+ // -> the standard compositeList/OVERFLOW_CAP convention, reused verbatim.
1432
+ if (result.compositeKind === "find") {
1433
+ const typeNoun = nounFor(result.entityType, 1);
1434
+ if (!result.matches.length) {
1435
+ return { content: `no ${nounFor(result.entityType, 2)} found matching "${result.term}".`, miss: true, ambiguous: false, matches: [] };
1436
+ }
1437
+ const cited = result.matches.length === 1 ? describeFindHit(result.matches[0]) : compositeList(result.matches);
1438
+ if (result.broad) {
1439
+ return {
1440
+ content: `no exact ${typeNoun} named "${result.term}", but found a related ${result.matches.length === 1 ? typeNoun : nounFor(result.entityType, 2)}: ${cited}.`,
1441
+ miss: false, ambiguous: false, matches: result.matches, relatedNotExact: true,
1442
+ };
1443
+ }
1444
+ return { content: `${cited}.`, miss: false, ambiguous: false, matches: result.matches };
1445
+ }
1089
1446
  if (result.compositeKind === "superlative") {
1090
1447
  if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
1091
1448
  const lead = result.extreme === "most" ? "the most" : "the fewest";
@@ -1170,12 +1527,25 @@ function componentSet(s) {
1170
1527
  * only) nor to terms under 4 chars (the bound would cover half of everything).
1171
1528
  * (6) no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
1172
1529
  * [, matchedVia]} — ambiguous on a true tier-3 score tie, a tier-4 overlap-count
1173
- * tie, or a tier-5 distance tie. */
1174
- export function resolveObject(graph, term) {
1530
+ * tie, or a tier-5 distance tie.
1531
+ *
1532
+ * `opts.expectedClass` (grain-aware resolution, Bug C+D fix): when set, narrows
1533
+ * the candidate POOL to `i.class === expectedClass` before every pool-driven tier
1534
+ * (exact/tier-3/tier-5) — the ranking code within each tier is untouched, only the
1535
+ * universe it ranks over shrinks. The ext: tier (synthetic matches with
1536
+ * `class: null`, never a real individual) is skipped outright when a class is
1537
+ * expected — it can never BE that class. The prose tier (tier 4) filters its hits
1538
+ * to the expected class before picking a winner. Every existing call site passes
1539
+ * no 3rd argument, so `expectedClass` defaults to null and behavior is
1540
+ * byte-identical to before this option existed — this is purely opt-in narrowing
1541
+ * for a caller (traverse()'s reverse case) that already knows what class the
1542
+ * relation's object slot expects ("which modules import logger" must never
1543
+ * resolve "logger" to a same-stem Class). */
1544
+ export function resolveObject(graph, term, { expectedClass = null } = {}) {
1175
1545
  const t = String(term || "").trim();
1176
1546
  if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
1177
1547
  const tLc = t.toLowerCase();
1178
- const pool = graph.individuals;
1548
+ const pool = expectedClass ? graph.individuals.filter((i) => i.class === expectedClass) : graph.individuals;
1179
1549
 
1180
1550
  // commit-sha tier (checked first, only for sha-shaped terms): "ef74e44e25c8",
1181
1551
  // "commit ef74e44e25c8", "commit:ef74e44", or a full 40-char sha resolve against
@@ -1212,7 +1582,10 @@ export function resolveObject(graph, term) {
1212
1582
  if (String(e.object).toLowerCase() === extLc) { extId = e.object; break outer; }
1213
1583
  }
1214
1584
  }
1215
- if (extId) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
1585
+ // ext: matches are synthetic (class: null, no real individual) with a class
1586
+ // expected, they can never satisfy it, so skip this tier entirely rather than
1587
+ // returning a match whose class silently doesn't match what the caller asked for.
1588
+ if (extId && !expectedClass) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
1216
1589
 
1217
1590
  // tier 3 — two disjoint regimes (dotted-symbol fix, 2026-07-02, advisor-verified
1218
1591
  // bug): a DOTTED term with no slash ("res.json", "Widget.render", "walk.mjs") is
@@ -1274,7 +1647,9 @@ export function resolveObject(graph, term) {
1274
1647
  // side door — a dotted term names an identifier, and identifiers resolve by
1275
1648
  // label (tiers above) or the bounded fuzzy pass below, or they honestly miss.
1276
1649
  let proseResult = null;
1277
- const proseHits = !dotted && typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, t) : [];
1650
+ const proseHits = !dotted && typeof lookupByProseTokens === "function"
1651
+ ? lookupByProseTokens(graph.proseIndex, t).filter((h) => !expectedClass || graph.byId.get(h.id)?.class === expectedClass)
1652
+ : [];
1278
1653
  if (proseHits.length) {
1279
1654
  const [best, ...rest] = proseHits;
1280
1655
  const bestInd = graph.byId.get(best.id);
@@ -1660,22 +2035,81 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1660
2035
  return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}${widenNote}`, ambiguous, matchedVia };
1661
2036
  }
1662
2037
 
2038
+ // §grain-aware object resolution (Bug C+D, HANDOVER follow-up #2, checked BEFORE
2039
+ // the edge filter below): a predicate's OBJECT slot carries one particular class
2040
+ // (kindObjectClass) — resolveObject itself is blind to that, so a same-stem term
2041
+ // ("logger") can resolve to the WRONG grain (a Class named Logger) instead of the
2042
+ // Module the "imports"/"calls"/… edge actually points at, and the edge filter
2043
+ // below then legitimately returns [] for the wrong-grain id — a confident-wrong
2044
+ // empty, not an honest miss. `wantClass` is null for a kind whose edges span more
2045
+ // than one object class (e.g. "contains") — no grain check applies there, byte-
2046
+ // identical to before. objMatch.class === null (an ext: synthetic match, no real
2047
+ // individual — see resolveObject's tier 2) is likewise never grain-checked: it has
2048
+ // no better class to compare against, and is already the most specific resolution
2049
+ // available.
2050
+ let gObjMatch = objMatch;
2051
+ let gCandidates = candidates;
2052
+ let gAmbiguous = ambiguous;
2053
+ let gMatchedVia = matchedVia;
2054
+ let grainRefinedNote = "";
2055
+ const wantClass = kindObjectClass(graph, kind);
2056
+ if (wantClass && gObjMatch.class && gObjMatch.class !== wantClass) {
2057
+ // (1) retry resolution SCOPED to the expected class — "logger" now only
2058
+ // considers Module individuals, so it lands on src/lib/logger.mjs instead of
2059
+ // the same-stem Class (fixes Bug C).
2060
+ const retry = resolveObject(graph, parsed.object, { expectedClass: wantClass });
2061
+ if (retry.match && !retry.ambiguous) {
2062
+ gObjMatch = retry.match;
2063
+ gCandidates = retry.candidates;
2064
+ gAmbiguous = retry.ambiguous;
2065
+ gMatchedVia = retry.matchedVia;
2066
+ } else if ((kind === "tests" || kind === "cochange") && gObjMatch.class !== "Module") {
2067
+ // (2) tests/cochange are always Module->Module — no same-grain alternative
2068
+ // exists (the retry above genuinely found nothing), but the resolved
2069
+ // fine-grain entity (a Function, say) DOES live in a module, and that
2070
+ // module is the real, honest subject of a tests/cochange question ("does
2071
+ // createTask have tests" — fixes Bug D). Up-refine via the same moduleIdOf
2072
+ // qualHolds's "tested" case already uses (see its divergence comment above).
2073
+ const mid = moduleIdOf(graph, gObjMatch);
2074
+ const mod = mid && graph.byId.get(mid);
2075
+ if (mod) {
2076
+ grainRefinedNote = `, refined from ${gObjMatch.label} to its containing module`;
2077
+ gObjMatch = mod;
2078
+ } else {
2079
+ return {
2080
+ matches: [], objMatch: gObjMatch, candidates: gCandidates, ambiguous: gAmbiguous, matchedVia: gMatchedVia,
2081
+ wrongGrainMiss: true, wantClass,
2082
+ 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)`,
2083
+ };
2084
+ }
2085
+ } else {
2086
+ // (3) neither a same-grain resolution nor an up-refinement applies — an
2087
+ // honest wrong-grain miss, distinct from both "unresolved" (the existing
2088
+ // objMatch-null branch below, untouched) and "resolved + genuinely empty".
2089
+ return {
2090
+ matches: [], objMatch: gObjMatch, candidates: gCandidates, ambiguous: gAmbiguous, matchedVia: gMatchedVia,
2091
+ wrongGrainMiss: true, wantClass,
2092
+ traversal: `"${parsed.object}" resolved to ${gObjMatch.class} ${gObjMatch.label} (grain mismatch: this "${kind}" question needs a ${wantClass})`,
2093
+ };
2094
+ }
2095
+ }
2096
+
1663
2097
  // General case: some predicates are already fine-grained (inherits: Class->Class, contains:
1664
2098
  // Class->Member) and some are module-coarse (imports/calls/tests/cochange: Module->Module).
1665
2099
  // Rather than assume one or the other, check what the edge's actual subjects ARE: if they
1666
2100
  // already match the requested entityType, use them directly (inherits); only when they're
1667
2101
  // Module individuals and a FINER entityType was asked for do we refine via `defines`
1668
2102
  // (imports) — never blindly treat an edge's subject id as if it were always a module id.
1669
- let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
2103
+ let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === gObjMatch.id);
1670
2104
  let extNote = "";
1671
- if (!edges.length && objMatch.class) {
2105
+ if (!edges.length && gObjMatch.class) {
1672
2106
  // Unresolved ext:<Name> endpoints with the SAME name as the resolved entity:
1673
2107
  // the extractor declined to assert identity (e.g. commander's every "class X
1674
2108
  // extends Command" edge points at ext:Command, never the Class node), so a
1675
2109
  // strict id match renders a FALSE blank. Count them by NAME instead and say
1676
2110
  // so in the receipt — name-grade evidence, labeled as such, same standard as
1677
2111
  // resolveObject's own ext: tier.
1678
- const extId = `ext:${String(objMatch.label).toLowerCase()}`;
2112
+ const extId = `ext:${String(gObjMatch.label).toLowerCase()}`;
1679
2113
  edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => String(e.object).toLowerCase() === extId);
1680
2114
  if (edges.length) extNote = ` (by name, via unresolved ${extId} references)`;
1681
2115
  }
@@ -1706,7 +2140,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
1706
2140
  matches = [];
1707
2141
  }
1708
2142
  }
1709
- return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where object = ${objMatch.label}${extNote}${grainNote}`, ambiguous, matchedVia };
2143
+ return {
2144
+ matches, objMatch: gObjMatch, candidates: gCandidates,
2145
+ traversal: `${kindsFor(kind).join("+")} edges where object = ${gObjMatch.label}${extNote}${grainNote}${grainRefinedNote}`,
2146
+ ambiguous: gAmbiguous, matchedVia: gMatchedVia,
2147
+ };
1710
2148
  }
1711
2149
 
1712
2150
  // ---- §5 templated renderer — string interpolation + grouping/pluralization/overflow rules,
@@ -1786,6 +2224,19 @@ function renderCore(parsed, result) {
1786
2224
  miss: true, ambiguous: false,
1787
2225
  };
1788
2226
  }
2227
+ // wrong-grain honest miss (Bug C+D, traverse()'s general reverse case): the term
2228
+ // resolved to a REAL entity, just not the class this predicate's object slot
2229
+ // needs, and no same-grain alternative (nor an up-refinement to a containing
2230
+ // module) exists — distinct from both the objMatch-null "unresolved" miss below
2231
+ // and a resolved-but-genuinely-empty answer.
2232
+ if (result.wrongGrainMiss) {
2233
+ const gotNoun = result.objMatch.class ? nounFor(result.objMatch.class, 1) : "term";
2234
+ const wantNoun = nounFor(result.wantClass, 1);
2235
+ return {
2236
+ 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.`,
2237
+ miss: true, ambiguous: false,
2238
+ };
2239
+ }
1789
2240
  if (parsed.shape === "meta") {
1790
2241
  if (!result.objMatch) {
1791
2242
  return {
@@ -1839,11 +2290,16 @@ function renderCore(parsed, result) {
1839
2290
  }
1840
2291
  if (result.ambiguous) {
1841
2292
  // the candidates say what KIND of thing is ambiguous — a shared commit-sha
1842
- // prefix must read "more than one commit", not "module".
2293
+ // prefix must read "more than one commit", not "module". Name the actual
2294
+ // candidates in the prose (not just the structured `candidates` field) —
2295
+ // "narrow the term" is not itself actionable if the reader can't see what
2296
+ // it's ambiguous between; mirrors the mentionsShape branch's listing above.
1843
2297
  const pool = [result.objMatch, ...(result.candidates || [])].filter(Boolean);
1844
2298
  const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
2299
+ const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
2300
+ const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
1845
2301
  return {
1846
- content: `"${parsed.object}" matches more than one ${noun} ambiguously — please narrow the term.`,
2302
+ content: `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those.`,
1847
2303
  miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
1848
2304
  };
1849
2305
  }
@@ -1934,7 +2390,7 @@ function renderCore(parsed, result) {
1934
2390
  // subject-first phrasing rather than reusing reverse's "found ... that OBJECT" template.
1935
2391
  if (parsed.shape === "forward") {
1936
2392
  return {
1937
- content: `${result.objMatch.label} has no ${parsed.kind} edges in the index.`,
2393
+ content: `${result.objMatch.label} has no ${verbFor(parsed.kind)} edges in the index.`,
1938
2394
  miss: true, ambiguous: false,
1939
2395
  };
1940
2396
  }