@polycode-projects/the-mechanical-code-talker 1.0.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -32
- package/ROADMAP.md +212 -92
- package/package.json +1 -1
- package/src/ask-vocab.mjs +125 -1
- package/src/ask.mjs +510 -17
- package/src/chat.mjs +1216 -78
- package/src/codegraph.mjs +1 -1
- package/src/interpret/normalize.mjs +137 -3
- package/src/interpret/strategies/grammar.mjs +44 -11
- package/src/interpret/strategies/keywords.mjs +15 -1
- package/src/memory/core.mjs +15 -1
- package/src/syllogise.mjs +0 -0
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
|
|
1535
|
-
if (
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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 (
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|