@polycode-projects/the-mechanical-code-talker 1.0.8 → 1.0.9

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
@@ -38,7 +38,7 @@
38
38
  // edges (mgx:touchedByCommit / mgx:changeCoupledWith), which is a different (and
39
39
  // simpler) question than the browser's time-scrubbing view.
40
40
 
41
- import { relationKind, impactClosure, normPath } from "./codegraph.mjs";
41
+ import { relationKind, impactClosure, normPath, HISTORY_CAP } from "./codegraph.mjs";
42
42
  import {
43
43
  VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
44
44
  CONTEXT_PRONOUNS, META_MEANING_VERBS,
@@ -1005,6 +1005,10 @@ function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, dep
1005
1005
  return { atoms };
1006
1006
  }
1007
1007
 
1008
+ // Seonix Batch 3 (3b): the closed set of temporal-lead words a bare "<lead> commits"
1009
+ // query can use — see the dedicated recentCommits AST node this feeds, below.
1010
+ const RECENT_COMMIT_LEAD = new Set(["recent", "latest", "newest"]);
1011
+
1008
1012
  function parseRelationalOrQualified(w, lc, nlp, depth) {
1009
1013
  // §6 generalization (predicate-find): seeds the SAME boolean/qualifier fold
1010
1014
  // below with a {node:"find",…} atom instead of the plain {node:"allOfClass"}
@@ -1038,6 +1042,17 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1038
1042
  // auxiliary in that position ("what DID commit X touch") is left for the existing
1039
1043
  // parser, not mistaken for a term.
1040
1044
  const nextNoun = i + 1 < lc.length ? entityNoun(lc[i + 1]) : null;
1045
+ // Seonix Batch 3 (3b) — a bare temporal-qualifier lead on a Commit noun with no
1046
+ // further term ("recent commits", "latest commits", "newest commits") used to
1047
+ // fall into the generic find-fallback just below with the qualifier WORD ITSELF
1048
+ // as the search term ("no Commit found matching 'recent'") — a false miss, since
1049
+ // "recent"/"latest"/"newest" were never meant as a name to search for, just a
1050
+ // sort direction the graph already has (mgx:commitDate). Checked BEFORE the
1051
+ // generic fallback, and only when nothing follows the noun (a real filter tail,
1052
+ // e.g. "recent commits touching a.py", is left to the ordinary parser).
1053
+ if (RECENT_COMMIT_LEAD.has(lc[i]) && nextNoun && nextNoun.entityType === "Commit" && i + 2 === lc.length) {
1054
+ return { node: "recentCommits" };
1055
+ }
1041
1056
  // CASCADE_NOISE_SET excluded alongside STOPWORDS (Tier-2 playtest, cycle 8):
1042
1057
  // "what about classes"/"how about the modules" used to reach here with
1043
1058
  // "about" sitting right where a real qualifying adjective would ("payment"
@@ -1102,9 +1117,46 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
1102
1117
  });
1103
1118
  }
1104
1119
  }
1120
+ // Batch 4/5 (HANDOVER item 4, compositional-AND): a later AND-branch with its OWN
1121
+ // DIFFERENT, but still RECOGNIZED, verb ("which functions call X and test Y" —
1122
+ // "test" maps to "tests", a different kind from the lead "call"/"calls") is ALSO
1123
+ // the compositional shape — additive to sameVerbLed above, not a replacement:
1124
+ // sameVerbLed already covers a same-kind repeat or a bare ellipsis-borrowed
1125
+ // object; this covers the one case it deliberately left closed (a branch with
1126
+ // its own explicit, different verb). The atom-building loop below needs no
1127
+ // change: it already builds one independent {kind:"set", ast} per verb-led
1128
+ // branch regardless of kind, and evalBoolean intersects them the same way a
1129
+ // qualifier atom is intersected (610915a) — this only widens the GATE that lets
1130
+ // that existing machinery fire for a mixed-kind "and" chain too.
1131
+ // NARROWED to a single-WORD later verb (`vh.end - vh.start === 1`) — same
1132
+ // "narrow, low-risk signal" discipline 610915a itself used (it deliberately did
1133
+ // NOT widen to "any boolean connective"): a bare single content word ("call",
1134
+ // "test", "tests") reads unambiguously as its own relation no matter what
1135
+ // follows it, whereas a multi-word verb PHRASE ("couples to", "is a subclass
1136
+ // of", "depends on") is exactly the shape the pre-existing compat guard pins
1137
+ // OFF (ask-compositional.test.mjs:67 and :144, both asserting
1138
+ // `ambiguousParse:true` for "which classes extends Base and couples to
1139
+ // logging" STRICTLY) — "couples to" IS recognized (VERB_TO_KIND maps it to
1140
+ // "imports"; the two-different-recognized-verbs shape is structurally
1141
+ // identical to the target case), so accepting ANY recognized different verb
1142
+ // here regressed both pinned tests when tried; the single-word restriction is
1143
+ // the narrowest rule that admits the required target case ("test") while
1144
+ // leaving the multi-word compat case exactly as closed as it always was.
1145
+ let differentVerbLed = false;
1146
+ if (sameVerbBranches.length > 1) {
1147
+ const firstBlc = sameVerbBranches[0].map((x) => x.toLowerCase());
1148
+ const firstVh = findPhrase(firstBlc, VERB_TO_KIND);
1149
+ if (firstVh && firstVh.start === 0) {
1150
+ differentVerbLed = sameVerbBranches.slice(1).every((bw) => {
1151
+ const blc = bw.map((x) => x.toLowerCase());
1152
+ const vh = findPhrase(blc, VERB_TO_KIND);
1153
+ return !!vh && vh.start === 0 && vh.end - vh.start === 1;
1154
+ });
1155
+ }
1156
+ }
1105
1157
  // marker gate — the crux of backward-compat: without one of these, this is not a
1106
1158
  // compositional query and we must NOT hijack it from the existing parser.
1107
- if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed || sameVerbLed)) return null;
1159
+ if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed || sameVerbLed || differentVerbLed)) return null;
1108
1160
 
1109
1161
  // empty predicate → a bare qualified class ("public methods")
1110
1162
  if (!predWords.length) {
@@ -1291,6 +1343,67 @@ function moduleIdOf(graph, ind) {
1291
1343
  return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
1292
1344
  }
1293
1345
 
1346
+ // ---- MEMBERSHIP inheritance cascade (HANDOVER item 6) — "<kind> of <owner>" walks
1347
+ // UP `inherits` when the owner's own surface has nothing, exactly the way
1348
+ // computeFind's narrow-then-broaden pass does for predicate-find, below. ----
1349
+
1350
+ /** OWN-ONLY membership hits for exactly ONE owner id — every MEMBERSHIP_KINDS
1351
+ * forward-hit from `id` alone (never an ancestor), filtered to `entityType` when
1352
+ * given. This IS the un-broadened lookup the "membership" case always ran before
1353
+ * item 6 — extracted so both the plain evalSet case and the inheritance-aware
1354
+ * cascade below (computeMembership) share the exact same one-node lookup. */
1355
+ function membershipOwnSet(graph, id, entityType) {
1356
+ const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, new Set([id]))));
1357
+ return entityType ? objs.filter((o) => o.class === entityType) : objs;
1358
+ }
1359
+
1360
+ /** Resolve a "<kind> of/in <term>" owner term to either a DIRECTORY scope (a bare
1361
+ * path prefix with no exact node of its own — see directoryScopeModules's own
1362
+ * doc) or a single container individual, exactly like the "membership" case's own
1363
+ * pre-item-6 resolution — extracted unchanged so evalSet's plain path and the
1364
+ * composite/disclosure path (evalMembershipComposite) can never drift. */
1365
+ function resolveMembershipOwner(graph, term) {
1366
+ const r = resolveObject(graph, term);
1367
+ if (!(r.match && r.tier === 1)) {
1368
+ const dirMods = directoryScopeModules(graph, term);
1369
+ if (dirMods.length) return { kind: "dir", mods: dirMods };
1370
+ }
1371
+ if (!r.match) return { kind: "miss" };
1372
+ return { kind: "single", id: r.match.id, entityClass: r.match.class, label: r.match.label };
1373
+ }
1374
+
1375
+ /** The narrow-then-walk inheritance cascade behind a "<kind> of <owner>" membership
1376
+ * query (HANDOVER item 6): the owner's OWN members — optionally `filterFn`-
1377
+ * filtered (a qualifier, e.g. "public") — win outright whenever non-empty; only
1378
+ * when that (possibly filtered) own result is EMPTY, and the owner's class
1379
+ * actually participates in `inherits` today (inheritsApplicable), do we walk
1380
+ * `ancestorsOf` NEAREST-FIRST, stopping at the first ancestor whose own
1381
+ * (identically filtered) member set is non-empty. Applying `filterFn` INSIDE the
1382
+ * walk — not once after it returns — is what makes a QUALIFIED query ("public
1383
+ * methods of TaskController") correctly walk up when the owner has own members
1384
+ * but none satisfy the qualifier, rather than stopping early on an unfiltered
1385
+ * non-empty own-set that the qualifier alone would have emptied (see the two
1386
+ * call sites: an omitted/identity `filterFn` is the plain unqualified case).
1387
+ * Returns {own, inherited, viaId, viaLabel} — `inherited`/`viaId`/`viaLabel` are
1388
+ * populated ONLY when the walk actually found something on an ancestor, so a
1389
+ * caller can disclose "inherited from <viaLabel>" rather than silently
1390
+ * presenting an ancestor's members as the owner's own (never-fabricate — the
1391
+ * same discipline computeFind's "related, not exact" broad pass documents). */
1392
+ function computeMembership(graph, ownerId, ownerClass, entityType, filterFn) {
1393
+ const pass = filterFn || (() => true);
1394
+ const own = membershipOwnSet(graph, ownerId, entityType).filter(pass);
1395
+ if (own.length || !inheritsApplicable(graph, ownerClass)) {
1396
+ return { own, inherited: [], viaId: null, viaLabel: null };
1397
+ }
1398
+ for (const ancId of ancestorsOf(graph, ownerId)) {
1399
+ const anc = graph.byId.get(ancId);
1400
+ if (!anc) continue;
1401
+ const ancOwn = membershipOwnSet(graph, ancId, entityType).filter(pass);
1402
+ if (ancOwn.length) return { own, inherited: ancOwn, viaId: ancId, viaLabel: anc.label };
1403
+ }
1404
+ return { own, inherited: [], viaId: null, viaLabel: null };
1405
+ }
1406
+
1294
1407
  /** Does an individual satisfy one qualifier (spec from QUALIFIERS)? Reads only
1295
1408
  * attributes/edges the graph already carries — an unpopulated attribute (e.g.
1296
1409
  * isAbstract) simply yields false, an honest empty rather than an error. */
@@ -1531,22 +1644,34 @@ function evalSet(graph, ast, opts) {
1531
1644
  // node match (tier 1 — a real file/symbol named that) still wins outright
1532
1645
  // (unchanged single-container-node behavior, e.g. "methods in widget.mjs");
1533
1646
  // only when there is no exact match do we try directory-prefix scope first.
1534
- const r = resolveObject(graph, ast.term);
1535
- if (!(r.match && r.tier === 1)) {
1536
- const dirMods = directoryScopeModules(graph, ast.term);
1537
- if (dirMods.length) {
1538
- if (!ast.entityType || ast.entityType === "Module") return dirMods;
1539
- const ids = new Set(dirMods.map((m) => m.id));
1540
- const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
1541
- return objs.filter((o) => o.class === ast.entityType);
1542
- }
1647
+ const owner = resolveMembershipOwner(graph, ast.term);
1648
+ if (owner.kind === "dir") {
1649
+ if (!ast.entityType || ast.entityType === "Module") return owner.mods;
1650
+ const ids = new Set(owner.mods.map((m) => m.id));
1651
+ const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
1652
+ return objs.filter((o) => o.class === ast.entityType);
1543
1653
  }
1544
- if (!r.match) return [];
1545
- const ids = new Set([r.match.id]);
1546
- const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
1547
- return ast.entityType ? objs.filter((o) => o.class === ast.entityType) : objs;
1654
+ if (owner.kind === "miss") return [];
1655
+ // item 6 (HANDOVER): the owner's own members win outright when non-empty;
1656
+ // only an EMPTY own set walks up `inherits` (computeMembership) see its
1657
+ // own doc above. evalSet's flat-array embedding (a find-seed inside a
1658
+ // boolean/qualifier fold, §6-style) transparently flattens own vs. inherited,
1659
+ // same as evalSet's "find" case does for computeFind's narrow/broad split;
1660
+ // the DISCLOSED (never-silent) version lives in evalMembershipComposite,
1661
+ // below, for the top-level (and qualifier-wrapped) membership query shapes.
1662
+ const { own, inherited } = computeMembership(graph, owner.id, owner.entityClass, ast.entityType);
1663
+ return own.length ? own : inherited;
1548
1664
  }
1549
1665
  case "qualifier": {
1666
+ // a qualifier wrapping a MEMBERSHIP inner needs the filter applied INSIDE
1667
+ // the inheritance walk, at each level, not once after a flat resolve (item
1668
+ // 6, Fix 1's per-level requirement — see computeMembership's own doc: a
1669
+ // class can own a non-empty member set that the qualifier alone empties
1670
+ // out, which must still walk up rather than stopping on the unfiltered own
1671
+ // set). evalMembershipComposite is the single source of truth for this;
1672
+ // reused here (matches-only) so evalSet's embedded-atom shape and the
1673
+ // top-level composite path can never drift on WHICH set they compute.
1674
+ if (ast.inner.node === "membership") return evalMembershipComposite(graph, ast, opts).matches;
1550
1675
  const base = evalSet(graph, ast.inner, opts);
1551
1676
  return base.filter((ind) => ast.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f])));
1552
1677
  }
@@ -1632,6 +1757,19 @@ function degreeMetric(graph, ind, metric) {
1632
1757
  }
1633
1758
  return n;
1634
1759
  }
1760
+ /** Seonix Batch 3 (3b): every Commit individual, newest date first — the eval side of
1761
+ * the bare "recent commits"/"latest commits"/"newest commits" AST node (see
1762
+ * RECENT_COMMIT_LEAD/parseRelationalOrQualified above). Reuses the SAME
1763
+ * dateOf/localeCompare sort every other Commit-date reader in this file already
1764
+ * uses (ISO-8601 sorts correctly lexically). An empty graph is an honest empty,
1765
+ * never a guess. */
1766
+ function evalRecentCommits(graph) {
1767
+ const commits = graph.individuals.filter((i) => i.class === "Commit");
1768
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
1769
+ commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
1770
+ return { compositeKind: "recentCommits", matches: commits };
1771
+ }
1772
+
1635
1773
  /** TEMPORAL over a nested set (lever 3) — the commits that touched ANY member of the
1636
1774
  * inner set, newest commit date first. Reuses the SAME touches→commit→date-sort the
1637
1775
  * flat when-shape runs (mgx:commitDate is ISO-8601, so a lexical sort IS a date sort;
@@ -1660,6 +1798,47 @@ function evalSuperlative(graph, ast) {
1660
1798
  return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
1661
1799
  }
1662
1800
 
1801
+ /** TOP-LEVEL "<kind> of/in <owner>" membership eval (HANDOVER item 6), covering
1802
+ * both a bare membership node and a QUALIFIER node wrapping one ("public methods
1803
+ * of TaskController") — the two share this one function so the qualifier's
1804
+ * filter is threaded INSIDE computeMembership's inheritance walk (item 6, Fix 1's
1805
+ * per-level requirement) rather than applied once, after a plain flat resolve
1806
+ * (which would wrongly stop on a non-empty-but-unfiltered own set that the
1807
+ * qualifier alone would empty out — see computeMembership's own doc). Unlike
1808
+ * evalSet's embedded (flattening) "membership"/"qualifier" cases — used when this
1809
+ * shape is nested inside a boolean fold — this keeps the inheritance provenance
1810
+ * (`inheritedNotOwn`/`viaLabel`/`ownerLabel`) so renderComposite can disclose an
1811
+ * ancestor-sourced answer ("TaskController has no own public methods — inherited
1812
+ * from Controller: render.") rather than ever silently presenting an ancestor's
1813
+ * members as the owner's own. A directory scope (no single owner individual) has
1814
+ * no inheritance chain to walk — unaffected by item 6, same behavior as before. */
1815
+ function evalMembershipComposite(graph, ast, opts) {
1816
+ const qualNode = ast.node === "qualifier" && ast.inner.node === "membership" ? ast : null;
1817
+ const memNode = qualNode ? qualNode.inner : ast;
1818
+ const entityType = memNode.entityType;
1819
+ const filterFn = qualNode
1820
+ ? (ind) => qualNode.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]))
1821
+ : null;
1822
+ const owner = resolveMembershipOwner(graph, memNode.term);
1823
+ if (owner.kind === "dir") {
1824
+ let objs;
1825
+ if (!entityType || entityType === "Module") objs = owner.mods;
1826
+ else {
1827
+ const ids = new Set(owner.mods.map((m) => m.id));
1828
+ objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids))).filter((o) => o.class === entityType);
1829
+ }
1830
+ if (filterFn) objs = objs.filter(filterFn);
1831
+ return { compositeKind: "set", matches: objs, entityType };
1832
+ }
1833
+ if (owner.kind === "miss") return { compositeKind: "set", matches: [], entityType };
1834
+ const { own, inherited, viaLabel } = computeMembership(graph, owner.id, owner.entityClass, entityType, filterFn);
1835
+ const inheritedNotOwn = !own.length && inherited.length > 0;
1836
+ return {
1837
+ compositeKind: "membership", entityType, matches: inheritedNotOwn ? inherited : own,
1838
+ inheritedNotOwn, viaLabel, ownerLabel: owner.label,
1839
+ };
1840
+ }
1841
+
1663
1842
  /** EXISTENCE eval — "is there a/an <kind> [called/named <term>] [in <module>]": a
1664
1843
  * direct membership/name check against the graph, never routed through the
1665
1844
  * relation-verb machinery. A named check resolves the term against the SAME
@@ -1718,7 +1897,15 @@ export function evalComposite(graph, ast, opts = {}) {
1718
1897
  if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
1719
1898
  if (ast.node === "superlative") return evalSuperlative(graph, ast);
1720
1899
  if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
1900
+ if (ast.node === "recentCommits") return evalRecentCommits(graph);
1721
1901
  if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
1902
+ // membership inheritance cascade (HANDOVER item 6), TOP-LEVEL: a bare "<kind> of
1903
+ // <owner>" node, or a qualifier wrapping one ("public methods of <owner>") — see
1904
+ // evalMembershipComposite's own doc for why the two share one function and why
1905
+ // this keeps disclosure provenance the embedded evalSet path deliberately drops.
1906
+ if (ast.node === "membership" || (ast.node === "qualifier" && ast.inner.node === "membership")) {
1907
+ return evalMembershipComposite(graph, ast, opts);
1908
+ }
1722
1909
  // predicate-find (Workstream 2), TOP-LEVEL: unlike evalSet's "find" case (used
1723
1910
  // when a find-seed is embedded inside a boolean/qualifier fold, §6), this keeps
1724
1911
  // the broad-pass provenance so renderComposite can label a "related, not exact"
@@ -1825,6 +2012,26 @@ function renderComposite(parsed, result) {
1825
2012
  : "";
1826
2013
  return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
1827
2014
  }
2015
+ // membership inheritance cascade (HANDOVER item 6): a zero-hit is the ordinary
2016
+ // set-producing honest miss below; a non-empty INHERITED result (the owner's own
2017
+ // set was empty, an ancestor's wasn't) is disclosed OUT LOUD — "X has no own
2018
+ // <kind> — inherited from <ancestor>: …" — never silently presented as though
2019
+ // the owner declared them itself (never-fabricate, the same discipline
2020
+ // computeFind's "related, not exact" broad pass documents for predicate-find).
2021
+ if (result.compositeKind === "membership") {
2022
+ if (!result.matches.length) {
2023
+ return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
2024
+ }
2025
+ if (result.inheritedNotOwn) {
2026
+ const kindPlural = nounFor(result.entityType, 2);
2027
+ const ownerPhrase = result.ownerLabel ? `${result.ownerLabel} has no own ${kindPlural}` : `no own ${kindPlural}`;
2028
+ return {
2029
+ content: `${ownerPhrase} — inherited from ${result.viaLabel}: ${compositeList(result.matches)}.`,
2030
+ miss: false, ambiguous: false, matches: result.matches, inheritedNotOwn: true,
2031
+ };
2032
+ }
2033
+ return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
2034
+ }
1828
2035
  // predicate-find (Workstream 2): zero hits -> an honest miss naming BOTH the type
1829
2036
  // and the term; the broad ("related, not exact") pass is ALWAYS clearly labeled,
1830
2037
  // never presented as an unqualified match — the confident-wrong discipline Bug
@@ -1844,6 +2051,24 @@ function renderComposite(parsed, result) {
1844
2051
  }
1845
2052
  return { content: `${cited}.`, miss: false, ambiguous: false, matches: result.matches };
1846
2053
  }
2054
+ // Seonix Batch 3 (3b): bare "recent/latest/newest commits" — a real dated commit
2055
+ // list, newest first, capped at HISTORY_CAP for consistency with renderFileHistory/
2056
+ // renderSymbolHistory's own listing convention (codegraph.mjs) — never the false
2057
+ // "no Commit found matching 'recent'" find-miss this used to fall into.
2058
+ if (result.compositeKind === "recentCommits") {
2059
+ if (!result.matches.length) return { content: `no commits recorded in this index.`, miss: true, ambiguous: false, matches: [] };
2060
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
2061
+ const shown = result.matches.slice(0, HISTORY_CAP).map((c) => {
2062
+ const day = dateOf(c).slice(0, 10);
2063
+ const msg = (c.attributes || []).find((a) => a.key === "message")?.value || "";
2064
+ return `${c.label}${day ? ` (${day})` : ""}${msg ? ` — ${msg}` : ""}`;
2065
+ });
2066
+ const tail = result.matches.length > HISTORY_CAP ? ` …+${result.matches.length - HISTORY_CAP} more` : "";
2067
+ return {
2068
+ content: `${result.matches.length} recent commit(s): ${shown.join(", ")}${tail}.`,
2069
+ miss: false, ambiguous: false, matches: result.matches,
2070
+ };
2071
+ }
1847
2072
  if (result.compositeKind === "superlative") {
1848
2073
  if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
1849
2074
  const lead = result.extreme === "most" ? "the most" : "the fewest";
@@ -1899,6 +2124,59 @@ function componentSet(s) {
1899
2124
  return new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
1900
2125
  }
1901
2126
 
2127
+ /** A minimal, CLOSED derivational-suffix normalizer for tier 3's Module-basename
2128
+ * bridge (item 9 fix, 2026-07-09 playtest-freeze dead-end) — deliberately NOT a
2129
+ * general stemmer (prose.mjs's own tokenizer comment explicitly avoids a stemmer
2130
+ * dependency; this stays a few hand-picked suffixes, same "closed set over general
2131
+ * rule" preference as everywhere else in this file). Strips exactly one of a small
2132
+ * suffix set ("ing"/"er"/"ers"/"or"/"ors" — the gerund and agent-noun endings that
2133
+ * regularly pair up in English: "logging"/"logger", "routing"/"router") off the
2134
+ * END of the word, then collapses a doubled trailing letter the strip exposed (the
2135
+ * consonant-doubling spelling short CVC roots take before -ing/-er: "log" ->
2136
+ * "logging"/"logger", both reducing here to "log"). Length-floored at 5 BEFORE
2137
+ * stripping so it can never fire on a short word and reopen the accidental-
2138
+ * short-word-match bug tier 3's other floors guard against; returns the word
2139
+ * unchanged (so callers can detect "no-op" via `=== w`) when no suffix matches. */
2140
+ function derivationalStem(w) {
2141
+ if (w.length < 5) return w;
2142
+ const stripped = w.replace(/(ing|ers|ors|er|or)$/, "");
2143
+ if (stripped === w) return w;
2144
+ return stripped.length >= 3 && stripped[stripped.length - 1] === stripped[stripped.length - 2]
2145
+ ? stripped.slice(0, -1)
2146
+ : stripped;
2147
+ }
2148
+
2149
+ /** Strip EXPLICIT separator characters only (path slashes, hyphens, underscores, a
2150
+ * trailing file extension) — never camelCase boundaries — so a candidate's own
2151
+ * label/path collapses to the same joined lowercase token a naming-convention-blind
2152
+ * user would type: "PaymentSystem" -> "paymentsystem", "payment-system" ->
2153
+ * "paymentsystem", "westfield-payment-system/src/MyCode.cs" ->
2154
+ * "westfieldpaymentsystemsrcmycode", "IPaymentSystemImpl.cs" -> "ipaymentsystemimpl".
2155
+ * Used by the compound-term tier just below (multi-word query bridge, 2026-07-09
2156
+ * compound-name fix, item: "match symbols where the question breaks a symbol into
2157
+ * 2 words"). The extension strip is deliberately the SAME single-trailing-
2158
+ * extension regex tier 3's own `stem` computation already uses elsewhere in this
2159
+ * file — not reinvented. */
2160
+ function joinedForm(label) {
2161
+ return String(label || "")
2162
+ .replace(/\.[a-z0-9]+$/i, "")
2163
+ .toLowerCase()
2164
+ .replace(/[/\-_.]+/g, "");
2165
+ }
2166
+
2167
+ /** Same joined-token normalization as joinedForm(), applied to the QUERY side of a
2168
+ * multi-word term: a leading article is stripped first (mirrors LEADING_ARTICLE_RE
2169
+ * below — "the payment system" and "payment system" must produce the identical
2170
+ * joined form), then whitespace/hyphens/underscores between words collapse out —
2171
+ * "the payment system" -> "payment system" -> "paymentsystem". */
2172
+ function joinedQueryForm(term) {
2173
+ return String(term || "")
2174
+ .trim()
2175
+ .replace(/^(?:the|a|an)\s+/i, "")
2176
+ .toLowerCase()
2177
+ .replace(/[\s\-_]+/g, "");
2178
+ }
2179
+
1902
2180
  /** Resolve a free-text object/subject term against the graph's individuals, in priority
1903
2181
  * order (§4, generalized beyond the module-coupling worked example to cover every verb
1904
2182
  * family's object grain — `inherits`/`calls` resolve against Class/Function names, not
@@ -1942,7 +2220,7 @@ function componentSet(s) {
1942
2220
  * for a caller (traverse()'s reverse case) that already knows what class the
1943
2221
  * relation's object slot expects ("which modules import logger" must never
1944
2222
  * resolve "logger" to a same-stem Class). */
1945
- export function resolveObject(graph, term, { expectedClass = null } = {}) {
2223
+ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
1946
2224
  const t = String(term || "").trim();
1947
2225
  if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
1948
2226
  const tLc = t.toLowerCase();
@@ -2049,8 +2327,113 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
2049
2327
  // no-op for those (unaffected — original ANY-overlap behavior).
2050
2328
  const pathToken = tLc.split(/\s+/).find((tok) => tok.includes("/"));
2051
2329
  const slashStem = pathToken ? pathToken.split("/").pop().replace(/\.[a-z0-9]+$/, "") : null;
2330
+ // Compound-term bridge (2026-07-09, "match symbols where the question breaks a
2331
+ // symbol into 2 [words]"): a query with 2+ SPACE-SEPARATED words ("payment
2332
+ // system", "the payment system") has no separator of its own to compare against
2333
+ // a label's literal spelling — the tiers above/below all compare tLc verbatim,
2334
+ // so "payment system" never equals/contains/overlaps "PaymentSystem" or
2335
+ // "payment-system" by those checks even though a human reads them as the same
2336
+ // concept. Computed ONCE per query (article-stripped, space-collapsed — see
2337
+ // joinedQueryForm) and checked per-candidate below via each candidate's OWN
2338
+ // joinedForm(). Single-word queries are unaffected: isMultiWord is false, the
2339
+ // branch below never fires, and they resolve exactly as before through the
2340
+ // tiers already in this loop.
2341
+ const qWords = t.trim().replace(/^(?:the|a|an)\s+/i, "").trim().split(/\s+/).filter(Boolean);
2342
+ const isMultiWord = qWords.length >= 2;
2343
+ const qJoined = isMultiWord ? joinedQueryForm(t) : null;
2052
2344
  for (const m of pool) {
2053
2345
  const label = String(m.label || "").toLowerCase();
2346
+ // Basename-exact/prefix/suffix tier (large-scale-fixture bug, 2026-07-09): a bare
2347
+ // term that IS a file's basename ("verify-shipped" -> scripts/verify-shipped.mjs)
2348
+ // must outrank every sibling that merely shares a directory or a component
2349
+ // ("verify") with it — checked BEFORE the raw-containment/overlap passes below so
2350
+ // an exact stem match always wins over a same-directory partial. Only meaningful
2351
+ // for a bare (unslashed) term, since `stem` is compared directly against the whole
2352
+ // `tLc` — a slashed query term (e.g. "src/nope.mjs") already contains "/" and can
2353
+ // never equal/prefix/suffix a bare stem, so it falls through unaffected to the
2354
+ // existing slashStem-gated overlap logic just below, unchanged.
2355
+ // The prefix/suffix half (not the exact-equality half) shares the SAME sub-4-char
2356
+ // floor as the containment tier just below it — an unguarded stem.startsWith(tLc)
2357
+ // reintroduces the exact short-word accidental-match bug that floor was added to
2358
+ // close (e.g. bare "so" prefix-matching "someOtherFile"'s stem). A full stem
2359
+ // EQUALITY, at any length, is never an accidental substring — "db"/"fs"-shaped
2360
+ // short real identifiers must still resolve — so it stays unguarded.
2361
+ const stem = label.split("/").pop().replace(/\.[a-z0-9]+$/, "");
2362
+ if (stem === tLc) { scored.push({ ind: m, score: 5000 }); continue; }
2363
+ if (tLc.length >= 4 && (stem.startsWith(tLc) || stem.endsWith(tLc))) {
2364
+ scored.push({ ind: m, score: 4000 - Math.abs(stem.length - tLc.length) });
2365
+ continue;
2366
+ }
2367
+ // Compound-term bridge (2026-07-09): the multi-word analog of the exact/
2368
+ // prefix-suffix tier just above — a query that breaks a single joined symbol
2369
+ // into 2+ words ("payment system") is compared against the candidate's OWN
2370
+ // joined form (separators stripped, camelCase left alone), not its literal
2371
+ // spelling. An exact joined match ("payment system" == "PaymentSystem"'s
2372
+ // "paymentsystem") is scored the SAME as the single-word exact-stem tier
2373
+ // above (5000) — it is equally strong evidence, just phrased with spaces.
2374
+ // A CONTAINMENT match ("payment system" found inside "westfield-payment-
2375
+ // system"'s or "IPaymentSystemImpl.cs"'s joined form) is scored strictly
2376
+ // BELOW every single-word tier above (a real single-word substring/prefix/
2377
+ // suffix hit is stronger evidence than a multi-word query merely appearing
2378
+ // somewhere in a longer joined string) but ABOVE the raw component-overlap
2379
+ // tier further below (score <= 10). CONTAINMENT is additionally gated on the
2380
+ // candidate label carrying an EXPLICIT separator (path slash, hyphen,
2381
+ // underscore, or a real file extension) — a pure-camelCase label with none
2382
+ // of those (e.g. Function "calculateTotalPrice") is deliberately left to
2383
+ // tier 4's prose/decomposed-identifier fallback below, which already owns
2384
+ // exactly that "query words are a sub-sequence of a compound identifier's
2385
+ // OWN decomposed tokens" territory (frozen test: "total price" ->
2386
+ // calculateTotalPrice must resolve at tier 4 via matchedVia:"prose", not
2387
+ // tier 3) — without this gate, EVERY multi-word query touching prose
2388
+ // territory would be silently reclassified as a tier-3 containment hit and
2389
+ // break that precedent. The EXACT tier just above has no such gate: an
2390
+ // exact joined-form equality is unambiguous evidence regardless of whether
2391
+ // the label happens to use an explicit separator (PascalCase "PaymentSystem"
2392
+ // included) — only the fuzzier CONTAINMENT check needs the extra guard.
2393
+ // Gated on isMultiWord so a single-word query is never affected (it already
2394
+ // resolves via the tiers above/below, unchanged).
2395
+ if (isMultiWord) {
2396
+ const candJoined = joinedForm(m.label);
2397
+ if (candJoined && candJoined === qJoined) { scored.push({ ind: m, score: 5000 }); continue; }
2398
+ const hasExplicitSeparator = /[/_-]/.test(m.label) || /\.[a-z0-9]+$/i.test(String(m.label || ""));
2399
+ if (candJoined && hasExplicitSeparator && qJoined.length >= 4 && candJoined.includes(qJoined)) {
2400
+ scored.push({ ind: m, score: 2000 - Math.abs(candJoined.length - qJoined.length) });
2401
+ continue;
2402
+ }
2403
+ }
2404
+ // Derivational-suffix basename bridge (item 9 fix, 2026-07-09): a bare term
2405
+ // that is a MODULE's own basename one gerund/agent-noun suffix-swap away
2406
+ // ("logging" for src/lib/logger.mjs's basename "logger" — neither is a
2407
+ // substring/prefix/suffix of the other, so the tiers just above miss it)
2408
+ // is still a real NAME match, not free text — Module-only (mirrors the
2409
+ // dotted-branch's own Module-only exact-basename special case just above
2410
+ // in this file), specifically so it can never also fire for a same-stem
2411
+ // Class/Method/Function sharing the same root ("Logger", "Logger.info")
2412
+ // and manufacture a false three-way tie; a bare-word class-ambiguity like
2413
+ // that is exactly the documented, already-accepted expectedClass-gated
2414
+ // case this function's own docblock calls out ("logger" -> Class), left
2415
+ // untouched. Scored below the literal exact/prefix/suffix tiers above (a
2416
+ // real substring is always stronger evidence) but above plain containment/
2417
+ // overlap (a genuine one-suffix-away basename match is still far more
2418
+ // specific than an accidental shared word). Guarded against tier 5's OWN
2419
+ // territory: a term that is merely a near-miss TYPO of an already-close
2420
+ // literal stem ("loging" for "logging", 1 edit away) must still fall
2421
+ // through to the bounded-fuzzy tier and be ANNOUNCED ("assuming you
2422
+ // meant…") — it is not a distinct derivational word-form, it is the same
2423
+ // word misspelled — so this bridge only fires when the term is OUTSIDE
2424
+ // the fuzzy tier's own distance bound (a real morphological pair like
2425
+ // "logging"/"logger" is 3 edits apart, well past tier 5's 2-edit budget
2426
+ // for words this length, so there is no overlap between the two tiers).
2427
+ if (m.class === "Module") {
2428
+ const termRoot = derivationalStem(tLc);
2429
+ if (termRoot !== tLc && termRoot === derivationalStem(stem)) {
2430
+ const bound = fuzzyBound(tLc);
2431
+ if (editDistance(stem, tLc, bound) > bound) {
2432
+ scored.push({ ind: m, score: 3000 - Math.abs(stem.length - tLc.length) });
2433
+ continue;
2434
+ }
2435
+ }
2436
+ }
2054
2437
  if (tLc.length >= 4 && label.includes(tLc)) {
2055
2438
  scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
2056
2439
  continue;
@@ -2072,7 +2455,13 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
2072
2455
  // "cover"/"touch" themselves never overlap anything, so overlap>0 and
2073
2456
  // the stem gate both hold.
2074
2457
  if (overlap > 0 && (!slashStem || labelComps.has(slashStem))) {
2075
- scored.push({ ind: m, score: overlap * 10 });
2458
+ // Normalized by the TERM's own component count (not a flat overlap*10): a
2459
+ // 1-of-3-component partial match ("verify" out of "verify-shipped"'s two
2460
+ // components, or similar) must never outscore a clean exact/prefix/suffix
2461
+ // stem hit from the tier above, nor a fuller-fraction overlap on another
2462
+ // candidate. termComps.length > 0 is guaranteed here (overlap > 0 requires
2463
+ // at least one termComps entry to have matched).
2464
+ scored.push({ ind: m, score: (overlap / termComps.length) * 10 });
2076
2465
  }
2077
2466
  }
2078
2467
  }
@@ -2174,6 +2563,65 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
2174
2563
  return proseResult || { match: null, candidates: [], tier: null, ambiguous: false };
2175
2564
  }
2176
2565
 
2566
+ /** A leading article is pure noise on a structural entity term ("the logger" ==
2567
+ * "logger") — mirrors memory/core.mjs's normFactTerm article-strip for taught
2568
+ * facts (Tier 5, T1), applied here on the graph-resolution side instead. */
2569
+ const LEADING_ARTICLE_RE = /^(?:the|a|an)\s+/i;
2570
+
2571
+ /** A trailing GENERIC GRAIN WORD ("the logger MODULE", "the Task CLASS") is a
2572
+ * TYPE HINT a real user attaches to disambiguate WHICH grain they mean — but
2573
+ * resolveObjectCore's plain word-overlap scoring (tier 3) can't read
2574
+ * grammatical role, so the grain word instead becomes an ordinary overlapping
2575
+ * component and can manufacture an accidental TIE between the actual module and
2576
+ * any same-stem Class/Method sharing its name (Tier 6 playtest, found live:
2577
+ * "the logger module" tied mod:src/lib/logger.mjs against fn:...#Logger and
2578
+ * fn:...#Logger.info, all scoring identically on the shared "logger" component
2579
+ * once "module"/"the" themselves matched nothing — the caller's own
2580
+ * `!ambiguous` gate then silently declined the whole thing, walling
2581
+ * moduleOrientLane/"describe the logger module"/"where is the logger module
2582
+ * defined"/etc. even though a human reads "module" as fully disambiguating).
2583
+ * Reuses ENTITY_TO_TYPE (ask-vocab.mjs) — the SAME closed noun→grain table the
2584
+ * grammar's own entity-slot parsing already trusts — so this never invents a
2585
+ * new vocabulary, only a new place the existing one gets consulted. */
2586
+ const TRAILING_GRAIN_WORD_RE = new RegExp(`\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i");
2587
+
2588
+ /** resolveObject: the grain-aware disambiguation PRE-PASS, wrapping
2589
+ * resolveObjectCore (the tiered resolver, unchanged) — only when the CALLER
2590
+ * hasn't already pinned an expectedClass (a caller that already knows the
2591
+ * class, e.g. traverse()'s reverse case, needs no help). Tries, in order:
2592
+ * (1) a trailing grain word ("module"/"class"/"function"/"method"/…, after a
2593
+ * leading-article strip) narrows the pool to that ONE grain and retries on
2594
+ * just the head noun — closing exactly the accidental-tie class this
2595
+ * docblock above describes; (2) failing that, a plain leading-article strip
2596
+ * alone (no grain word) — "the logger" resolves the same way bare "logger"
2597
+ * always has. Either retry is used ONLY on an unambiguous hit; any miss/tie
2598
+ * falls through unchanged to the ORIGINAL (unstripped) term via
2599
+ * resolveObjectCore, so this is purely additive — a term that already
2600
+ * resolved before resolves exactly the same way now (a multi-word "the X
2601
+ * module"-shaped term never equals a real label outright, so the exact-match
2602
+ * tier the pre-pass could theoretically shadow is never actually in play). */
2603
+ export function resolveObject(graph, term, opts = {}) {
2604
+ const { expectedClass = null } = opts;
2605
+ if (!expectedClass) {
2606
+ const raw = String(term || "").trim();
2607
+ const stripped = raw.replace(LEADING_ARTICLE_RE, "").trim();
2608
+ const grainMatch = stripped.match(TRAILING_GRAIN_WORD_RE);
2609
+ if (grainMatch) {
2610
+ const head = stripped.slice(0, grainMatch.index).trim();
2611
+ const grainClass = ENTITY_TO_TYPE[grainMatch[1].toLowerCase()];
2612
+ if (head && grainClass) {
2613
+ const rGrain = resolveObjectCore(graph, head, { expectedClass: grainClass });
2614
+ if (rGrain?.match?.id && !rGrain.ambiguous) return rGrain;
2615
+ }
2616
+ }
2617
+ if (stripped && stripped !== raw) {
2618
+ const rStripped = resolveObjectCore(graph, stripped, opts);
2619
+ if (rStripped?.match?.id && !rStripped.ambiguous) return rStripped;
2620
+ }
2621
+ }
2622
+ return resolveObjectCore(graph, term, opts);
2623
+ }
2624
+
2177
2625
  /** Resolve a term that may be a context pronoun ("this"/"it"/"that"/"here") —
2178
2626
  * when `contextId` is given, resolve straight to that graph entity (a real
2179
2627
  * click/focus in the caller's UI, not a guess); with no contextId, an honest
@@ -3279,6 +3727,46 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
3279
3727
  * miss. Returns the full {content, tmct_ask:
3280
3728
  * {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}} envelope
3281
3729
  * §6.2 specifies. Zero generative model calls. */
3730
+ // Seonix Batch 3 (3b), singular subject: "the last commit"/"the latest commit"/"the
3731
+ // most recent commit" — literal-phrase substitution, checked as a whole-word match
3732
+ // (not anchored to the whole line, since it may sit mid-sentence as the subject of a
3733
+ // longer question, e.g. "what did the last commit touch").
3734
+ const LAST_COMMIT_PHRASE_RE = /\b(?:the\s+)?(?:last|latest|most\s+recent)\s+commit\b/i;
3735
+
3736
+ /** resolveObject has no notion of "the newest Commit individual" — it only matches
3737
+ * literal graph labels, and the bare word "commit" itself component-matches the
3738
+ * Commit SchemaClass node (a known risk noted around resolveObject's own tier-3
3739
+ * comments), so "what did the last commit touch" used to render a false "Commit
3740
+ * has no touches edges in the index" instead of an honest answer. Fixed by textual
3741
+ * substitution BEFORE the normal parse/resolve pipeline runs: the phrase is swapped
3742
+ * for "commit <newest-sha>" (the SAME dateOf/localeCompare sort every other
3743
+ * Commit-date reader in this file already uses), so the rest of the pipeline sees
3744
+ * exactly what it would for "what did commit <realsha> touch" and needs no other
3745
+ * change. A graph with no commits, or no date on any commit, leaves the query text
3746
+ * untouched — an honest miss downstream, never a guess at which commit is "last". */
3747
+ // A bare "when was/did commit X" with no change-verb tail at all (the shape left
3748
+ // once "the latest commit" is substituted out of "when was the latest commit") —
3749
+ // grammar.mjs's T8 "when" template requires a touches-family verb to fire, so this
3750
+ // would otherwise honestly miss even though traverse()'s own "when" branch already
3751
+ // special-cases a Commit OBJECT to answer with its own date (see the commit-as-
3752
+ // subject flip's sibling branch, just above the flip itself). Bridged by appending
3753
+ // the neutral "touched" tail — never for a query that already names its own verb
3754
+ // ("what did the last commit touch" is untouched).
3755
+ const BARE_WHEN_COMMIT_RE = /^when\s+(?:was|were|is|did|does|do)\s+commit\s+[0-9a-fA-F:]+$/i;
3756
+
3757
+ function substituteLastCommitPhrase(graph, query) {
3758
+ const q = String(query || "");
3759
+ if (!graph || !LAST_COMMIT_PHRASE_RE.test(q)) return q;
3760
+ const commits = graph.individuals.filter((i) => i.class === "Commit");
3761
+ if (!commits.length) return q;
3762
+ const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
3763
+ const newest = [...commits].sort((a, b) => dateOf(b).localeCompare(dateOf(a)))[0];
3764
+ if (!newest) return q;
3765
+ const out = q.replace(LAST_COMMIT_PHRASE_RE, `commit ${newest.label}`);
3766
+ const bareTrimmed = out.trim().replace(/[?.!]+$/, "");
3767
+ return BARE_WHEN_COMMIT_RE.test(bareTrimmed) ? `${bareTrimmed} touched` : out;
3768
+ }
3769
+
3282
3770
  export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
3283
3771
  // Explicit help/orientation request → the rephrase hint directly (the honest bottom
3284
3772
  // of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
@@ -3291,6 +3779,11 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
3291
3779
  },
3292
3780
  };
3293
3781
  }
3782
+ // Seonix Batch 3 (3b), singular subject: substitute "the last/latest/most recent
3783
+ // commit" for the real newest Commit's own id BEFORE anything else runs, so the
3784
+ // rest of the pipeline (direct parse, relaxation, resolveObject) never has to know
3785
+ // this phrase existed — see substituteLastCommitPhrase's own doc above.
3786
+ query = substituteLastCommitPhrase(graph, query);
3294
3787
  const direct = parseQuery(query, { nlp });
3295
3788
  // The relaxation cascade fires ONLY when the DIRECT parse would miss (no parse, a
3296
3789
  // compositional {node:"miss"}, or an unresolved named term) — a clean hit, an