@polycode-projects/the-mechanical-code-talker 1.9.2 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +441 -202
- package/bin/tmct.mjs +126 -1
- package/package.json +4 -2
- package/src/answer-variants.mjs +8 -36
- package/src/ask-browser-entry.mjs +5 -23
- package/src/ask-browser.bundle.js +1 -2
- package/src/ask-nlp.mjs +9 -23
- package/src/ask-vocab.mjs +139 -589
- package/src/ask.mjs +627 -1729
- package/src/chat.mjs +1684 -2872
- package/src/cli-args.mjs +14 -28
- package/src/codegraph.mjs +236 -644
- package/src/completions/complete.mjs +18 -62
- package/src/completions/graph-adapter.mjs +14 -60
- package/src/completions/group.mjs +12 -68
- package/src/completions/infer.mjs +38 -126
- package/src/completions/prune.mjs +17 -70
- package/src/completions/rank.mjs +16 -69
- package/src/completions/search.mjs +8 -31
- package/src/concept.mjs +32 -88
- package/src/conformance.mjs +11 -15
- package/src/corpus/conceptnet.mjs +31 -89
- package/src/corpus/templates.mjs +19 -45
- package/src/corpus/unknown-ingest.mjs +31 -92
- package/src/embed.mjs +10 -22
- package/src/extensions.mjs +50 -154
- package/src/finish.mjs +35 -91
- package/src/grammar/ace.mjs +16 -40
- package/src/grammar/assert.mjs +1 -1
- package/src/grammar/lexicon-core.json +1 -1
- package/src/grammar/lexicon.mjs +9 -27
- package/src/graph-merge.mjs +2 -3
- package/src/hash.mjs +6 -14
- package/src/index.mjs +6 -10
- package/src/init.mjs +38 -125
- package/src/interpret/fuzzy.mjs +10 -29
- package/src/interpret/merge.mjs +9 -27
- package/src/interpret/normalize.mjs +137 -585
- package/src/interpret/pipeline.mjs +23 -71
- package/src/interpret/strategies/ace.mjs +7 -31
- package/src/interpret/strategies/constructions.mjs +14 -41
- package/src/interpret/strategies/grammar.mjs +21 -60
- package/src/interpret/strategies/keywords.mjs +42 -131
- package/src/interpret/strategies/noise-strip.mjs +18 -89
- package/src/memory/bias.mjs +11 -54
- package/src/memory/blocks.mjs +18 -69
- package/src/memory/core.mjs +171 -591
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +7 -25
- package/src/memory/shacl.mjs +10 -39
- package/src/memory/trust.mjs +26 -127
- package/src/memory-ask-browser-entry.mjs +7 -30
- package/src/memory-ask-browser.bundle.js +1 -1
- package/src/paraphrase.mjs +20 -53
- package/src/planning.mjs +15 -157
- package/src/prose-nlp.mjs +4 -17
- package/src/prose.mjs +19 -67
- package/src/providers/bootstrap.mjs +1 -2
- package/src/providers/fixture.mjs +1 -2
- package/src/providers/graph-service.mjs +28 -59
- package/src/repository-interface.mjs +6 -8
- package/src/router/drive.mjs +183 -0
- package/src/router/goal-reasoner.mjs +66 -231
- package/src/router/guardrail.mjs +20 -58
- package/src/router/planner.mjs +15 -46
- package/src/router/registry.mjs +13 -43
- package/src/router/resolver.mjs +46 -131
- package/src/router/results.mjs +231 -0
- package/src/schema-docs.mjs +10 -27
- package/src/server-http.mjs +10 -19
- package/src/server.mjs +22 -28
- package/src/sessions.mjs +15 -30
- package/src/source-slice.mjs +5 -7
- package/src/source.mjs +10 -20
- package/src/syllogise.mjs +187 -575
- package/src/telemetry.mjs +3 -3
- package/src/toml-config.mjs +4 -4
- package/src/tui/app.mjs +9 -19
- package/src/viz.mjs +66 -123
- package/src/wink-model.mjs +10 -24
package/src/ask.mjs
CHANGED
|
@@ -1,42 +1,20 @@
|
|
|
1
|
-
// ask.mjs — a mechanical (zero-model-call) natural-language query engine over
|
|
2
|
-
// tmct graph.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// templated, citation-faithful answer. No embeddings, no generative model calls —
|
|
6
|
-
// a miss is a stated blank, never a guess (the extraction pipeline's "no wrong
|
|
7
|
-
// edge" ethos, held at the query layer too). Term/keyword matching is TIERED
|
|
8
|
-
// (2026-07-02, two-level fuzzy): exact curated match always wins; a Node-only
|
|
9
|
-
// wink-nlp LEMMA/POS tier and a bounded Damerau-Levenshtein FUZZY tier fire only
|
|
10
|
-
// on a miss, a unique fuzzy hit is announced in the answer ("assuming you
|
|
11
|
-
// meant …"), and any tie surfaces as ambiguity — never a silently-broken guess.
|
|
1
|
+
// ask.mjs — a mechanical (zero-model-call) natural-language query engine over
|
|
2
|
+
// the tmct graph. A small, closed English grammar compiles a free-text
|
|
3
|
+
// question into a graph traversal, then renders a templated, citation-faithful
|
|
4
|
+
// answer. A miss is a stated blank, never a guess.
|
|
12
5
|
//
|
|
13
6
|
// Four pure, independently-testable stages, orchestrated by ask():
|
|
14
7
|
// parseQuery (grammar) -> resolveObject (mechanical term resolution) ->
|
|
15
8
|
// traverse (graph lookup) -> render (templates).
|
|
16
9
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// parsing STRATEGIES over the same normalized text (interpret/pipeline.mjs) —
|
|
22
|
-
// the original anchored-template matcher (interpret/strategies/grammar.mjs:
|
|
23
|
-
// precise, fast, unweakened) and a keyword-spotting/decomposition matcher
|
|
24
|
-
// (interpret/strategies/keywords.mjs — ELIZA's own mechanism: find the keyword,
|
|
25
|
-
// decompose around it, tolerate reordering/casual phrasing) — and MERGES their
|
|
26
|
-
// results (interpret/merge.mjs): one strategy hit -> use it; hits that agree ->
|
|
27
|
-
// use it (high confidence); same-class hits that DISAGREE -> a genuine
|
|
28
|
-
// parse-level ambiguity, surfaced honestly; no hits -> the honest grammar miss.
|
|
29
|
-
// STRATEGIES is a plain registration array (interpret/pipeline.mjs) so further
|
|
30
|
-
// strategies (Phase 2's ACE grammar) join the same way, not a hardcoded
|
|
31
|
-
// two-branch special case.
|
|
10
|
+
// Term/keyword matching is tiered: exact curated match always wins; a
|
|
11
|
+
// Node-only wink-nlp lemma/POS tier and a bounded Damerau-Levenshtein fuzzy
|
|
12
|
+
// tier fire only on a miss, a unique fuzzy hit is announced in the answer,
|
|
13
|
+
// and any tie surfaces as ambiguity.
|
|
32
14
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// own that surface for the Chronograph browser; ask.mjs's own `touches`/`cochange`
|
|
37
|
-
// verbs here answer "which modules touch/co-change with X" as ONE-HOP structural
|
|
38
|
-
// edges (mgx:touchedByCommit / mgx:changeCoupledWith), which is a different (and
|
|
39
|
-
// simpler) question than the browser's time-scrubbing view.
|
|
15
|
+
// ask.mjs's own `touches`/`cochange` verbs answer one-hop structural edges
|
|
16
|
+
// (mgx:touchedByCommit / mgx:changeCoupledWith) — a different, simpler
|
|
17
|
+
// question than temporal.mjs's time-scrubbing Chronograph surface.
|
|
40
18
|
|
|
41
19
|
import { relationKind, impactClosure, normPath, HISTORY_CAP } from "./codegraph.mjs";
|
|
42
20
|
import {
|
|
@@ -47,10 +25,6 @@ import {
|
|
|
47
25
|
AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, METRIC_IMPLIES_ENTITY, ANAPHORA_TRIGGERS,
|
|
48
26
|
MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
|
|
49
27
|
} from "./ask-vocab.mjs";
|
|
50
|
-
// The interpretation layer (ROADMAP items 8/10/13) — the movable conversational
|
|
51
|
-
// grammar, split out of this file: normalization pre-pass, the two parsing
|
|
52
|
-
// strategies, and the bounded-fuzzy service. Re-exported below where existing
|
|
53
|
-
// callers/tests import them from here.
|
|
54
28
|
import { normalizeQuery, applyNegationFrames, applyPhrasingFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
|
|
55
29
|
import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
56
30
|
import { parseAnchored } from "./interpret/strategies/grammar.mjs";
|
|
@@ -64,31 +38,19 @@ import { pickPhrase } from "./answer-variants.mjs";
|
|
|
64
38
|
export { normalizeQuery, applyNegationFrames };
|
|
65
39
|
import { nlpAdapter } from "./ask-nlp.mjs";
|
|
66
40
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* result from the identical relationKind classification, so two caches can never
|
|
72
|
-
* disagree, only duplicate a little memory). Correctness rests on the same
|
|
73
|
-
* invariant qualCache already relies on: a loaded graph's `relations` are never
|
|
74
|
-
* mutated in place (a refresh always builds a NEW graph object via parseEntities).
|
|
75
|
-
* Deliberately NAMED DIFFERENTLY from codegraph.mjs's `edgesOfKindCache` — the
|
|
76
|
-
* inlined viewer bundle (viz.mjs's askSource) literally CONCATENATES a stripped
|
|
77
|
-
* codegraph.mjs + this file into one classic script (test/ask-nlp.test.mjs pins
|
|
78
|
-
* this), so two `const`s with the same name would be a real SyntaxError there. */
|
|
41
|
+
// Per-graph, per-kind memo; a local copy of codegraph.mjs's private
|
|
42
|
+
// edgesOfKind. Named differently from codegraph.mjs's own cache: the inlined
|
|
43
|
+
// viewer bundle concatenates a stripped codegraph.mjs + this file into one
|
|
44
|
+
// script, so two same-named consts there would be a SyntaxError.
|
|
79
45
|
const askEdgesOfKindCache = new WeakMap();
|
|
80
46
|
|
|
81
|
-
/** All edges of a classified relation kind, flattened across relation groups —
|
|
82
|
-
* a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
|
|
83
|
-
* exported+imported to avoid coupling this file's commit. */
|
|
84
47
|
function edgesOfKind(graph, kind) {
|
|
85
48
|
let byKind = askEdgesOfKindCache.get(graph);
|
|
86
49
|
if (!byKind) { byKind = new Map(); askEdgesOfKindCache.set(graph, byKind); }
|
|
87
50
|
const cached = byKind.get(kind);
|
|
88
51
|
if (cached) return cached;
|
|
89
52
|
const out = [];
|
|
90
|
-
// Plain-loop append
|
|
91
|
-
// stack past ~100k edges on graph-scale relation groups (see codegraph.mjs twin).
|
|
53
|
+
// Plain-loop append: argument spread overflows the call stack past ~100k edges.
|
|
92
54
|
for (const g of graph.relations) {
|
|
93
55
|
if (relationKind(g) !== kind) continue;
|
|
94
56
|
for (const e of g.edges) out.push(e);
|
|
@@ -97,26 +59,16 @@ function edgesOfKind(graph, kind) {
|
|
|
97
59
|
return out;
|
|
98
60
|
}
|
|
99
61
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// three tables, so they cannot drift. ----
|
|
103
|
-
|
|
104
|
-
// Predicate kinds carrying a finer, symbol-grain sibling (module-coarse -> fn/method-precise).
|
|
105
|
-
// "which functions call X" should read off callsSymbol (fn->fn), not the module-coarse "calls".
|
|
62
|
+
// Predicate kinds carrying a finer, symbol-grain sibling: "which functions
|
|
63
|
+
// call X" should read off callsSymbol (fn->fn), not module-coarse "calls".
|
|
106
64
|
const SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
|
|
107
65
|
const FINE_ENTITY_TYPES = new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
|
|
108
|
-
//
|
|
109
|
-
// grain the extractor saw (a method Widget.render is class "Method"), but a person
|
|
110
|
-
// asking "which functions call X" means the callable family, not the storage class.
|
|
111
|
-
// Used ONLY as an empty-result fallback (see traverse's reverse symbol-grain path):
|
|
112
|
-
// an exact-class answer is never widened, so every non-empty answer is byte-stable.
|
|
66
|
+
// Empty-result fallback only: an exact-class answer is never widened.
|
|
113
67
|
const FINE_CLASS_SIBLING = { Function: "Method", Method: "Function" };
|
|
114
68
|
|
|
115
|
-
// Query-side
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// maps to itself; grain selection (asked entity type) then narrows the union's
|
|
119
|
-
// subjects the same way it narrows a single kind's.
|
|
69
|
+
// Query-side union families: a parsed kind that isn't itself a stored
|
|
70
|
+
// predicate but a curated union of stored kinds ("what uses X" means imports
|
|
71
|
+
// + calls together).
|
|
120
72
|
const KIND_UNIONS = { uses: ["imports", "calls", "callsSymbol"] };
|
|
121
73
|
const kindsFor = (kind) => KIND_UNIONS[kind] || [kind];
|
|
122
74
|
|
|
@@ -127,13 +79,7 @@ const PLURAL_FORMS = {
|
|
|
127
79
|
Class: ["class", "classes"], Module: ["module", "modules"],
|
|
128
80
|
Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
|
|
129
81
|
Commit: ["commit", "commits"],
|
|
130
|
-
// "Change" is ask-vocab.mjs's pseudo-type (a wildcard over the touch traversal's
|
|
131
|
-
// results, never a node class) — it still needs noun forms for zero-hit templates.
|
|
132
82
|
Change: ["change", "changes"],
|
|
133
|
-
// Memory-graph classes (memory/core.mjs) — real noun forms for the dynamic
|
|
134
|
-
// class count/list fallback (PLAN_BREADTH_FIRST_NLU.md (d), see
|
|
135
|
-
// dynamicClassQuery below) so "2 facts." reads naturally instead of falling
|
|
136
|
-
// back to the generic "2 results.".
|
|
137
83
|
Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"],
|
|
138
84
|
Session: ["session", "sessions"], Source: ["source", "sources"], Rule: ["rule", "rules"],
|
|
139
85
|
};
|
|
@@ -142,31 +88,16 @@ function nounFor(entityType, n) {
|
|
|
142
88
|
return n === 1 ? s : p;
|
|
143
89
|
}
|
|
144
90
|
|
|
145
|
-
// Every relation
|
|
146
|
-
//
|
|
147
|
-
// name is a bare noun/verb stem ("X cochange Y" is wrong; "X cochanges Y" is right) —
|
|
148
|
-
// so the reverse-shape zero-hit template below reads off this table instead of
|
|
149
|
-
// unconditionally appending "s" (which used to double-pluralize every other kind:
|
|
150
|
-
// "callss", "importss", "touchess"). "reexports" -> "export" (Bug B3, HANDOVER
|
|
151
|
-
// follow-up #2): the raw internal kind identifier "reexports" leaked straight into
|
|
152
|
-
// the forward-miss prose ("X has no reexports edges in the index") — the human
|
|
153
|
-
// word for this relation is "export" ("X has no export edges in the index"),
|
|
154
|
-
// matching every other kind's already-natural phrasing.
|
|
91
|
+
// Every relation kind is already the correct 3rd-person-singular verb form
|
|
92
|
+
// except "cochange" ("X cochanges Y") and "reexports" (human word "export").
|
|
155
93
|
const REVERSE_MISS_VERB = { cochange: "cochanges", reexports: "export" };
|
|
156
94
|
function verbFor(kind) {
|
|
157
95
|
return REVERSE_MISS_VERB[kind] || kind;
|
|
158
96
|
}
|
|
159
97
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// object "touch f.mjs"), which the old ^cover-only strip missed ("No tests cover
|
|
164
|
-
// touch app/lib/f.mjs."). The closed list is read from ask-vocab.mjs's exported
|
|
165
|
-
// VERB_TO_KIND (derived from the RELATIONS verb table — the source of truth,
|
|
166
|
-
// including the `tests` kind's own verbs: cover/check/verify/exercise/…), longest
|
|
167
|
-
// phrase first so multi-word verbs strip whole; a bare optional s/ing/ed tail keeps
|
|
168
|
-
// the previously-stripped inflections ("covering") without enumerating them. Only
|
|
169
|
-
// ever applied to the tests-kind zero-hit template's object — never to resolution.
|
|
98
|
+
// Strips a leading relation verb from the tests-kind honest-empty object
|
|
99
|
+
// ("do any tests touch f.mjs" -> object "touch f.mjs"), so the miss template
|
|
100
|
+
// doesn't render "No tests cover touch f.mjs." Longest phrase first.
|
|
170
101
|
const LEADING_RELATION_VERB_RE = new RegExp(
|
|
171
102
|
`^(?:${Object.keys(VERB_TO_KIND)
|
|
172
103
|
.sort((a, b) => b.length - a.length)
|
|
@@ -175,70 +106,23 @@ const LEADING_RELATION_VERB_RE = new RegExp(
|
|
|
175
106
|
"i",
|
|
176
107
|
);
|
|
177
108
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// (normalizeQuery, applyNegationFrames, STOPWORDS, splitWords), interpret/
|
|
181
|
-
// strategies/grammar.mjs (parseAnchored, the anchored TEMPLATES), interpret/
|
|
182
|
-
// strategies/keywords.mjs (parseKeywordSpot, findPhrase), interpret/fuzzy.mjs
|
|
183
|
-
// (editDistance, fuzzyBound — also resolveObject's tier-5 budget below). ----
|
|
184
|
-
|
|
185
|
-
// ---- strategy merge — now the interpret PIPELINE (item 8): the registered
|
|
186
|
-
// strategies (interpret/pipeline.mjs STRATEGIES — grammar, keyword-spot, …) run
|
|
187
|
-
// over the normalized text and interpret/merge.mjs merges them: same-class
|
|
188
|
-
// agreement dedupes to one parse, same-class disagreement is the honest
|
|
189
|
-
// {ambiguousParse, candidates} surface, and distinct-class alternates carry the
|
|
190
|
-
// "if you mean X then …" surround (unused on this synchronous path — parseQuery
|
|
191
|
-
// keeps the winning parse only, byte-identical to the original two-way merge). ----
|
|
192
|
-
|
|
193
|
-
/** The default lemma/POS adapter: wink-nlp when this is a Node process with the
|
|
194
|
-
* optional deps installed, null otherwise.. */
|
|
109
|
+
/** The default lemma/POS adapter: wink-nlp when Node has the optional deps
|
|
110
|
+
* installed, null otherwise. */
|
|
195
111
|
function defaultNlp() {
|
|
196
112
|
return typeof nlpAdapter === "function" ? nlpAdapter() : null;
|
|
197
113
|
}
|
|
198
114
|
|
|
199
115
|
/** Compile a free-text question into {shape, kind, entityType, modifier,
|
|
200
|
-
* object[, subject]}, or null if
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
* adapter (pass null to force the adapter-less browser behavior in a Node
|
|
211
|
-
* test); leaving it undefined picks the deterministic default (defaultNlp).
|
|
212
|
-
* Pure given (query, adapter) — the adapter itself is a fixed model, no
|
|
213
|
-
* sampling. */
|
|
214
|
-
// SCHEMA-TERM / COMMON-WORD "WHAT DOES X MEAN" DISAMBIGUATION (CHATBENCH decision-log
|
|
215
|
-
// item 1, g-a1-naming-8: "what does tests mean"). When the object term X is ITSELF a
|
|
216
|
-
// real RELATIONS/VERB_TO_KIND keyword ("tests", "imports", …), keyword-spot
|
|
217
|
-
// independently reads the sentence as a "reverse"-shaped query — kind:X, object:"mean"
|
|
218
|
-
// — because "mean"/"means" (META_MEANING_VERBS, ask-vocab.mjs §7) is deliberately kept
|
|
219
|
-
// OUT of the relation tables (see that table's own comment): keyword-spot has no idea
|
|
220
|
-
// it just consumed the meta question's own verb as if it were an object noun. That
|
|
221
|
-
// reading can never resolve to anything real ("mean" is never a graph entity) — it's a
|
|
222
|
-
// spurious parse, not a genuine second reading — yet it collides with the grammar
|
|
223
|
-
// strategy's own clean "meta" parse of the SAME sentence to manufacture the legacy
|
|
224
|
-
// {ambiguousParse} surface ("this could mean more than one thing: 1) meta X or 2) X
|
|
225
|
-
// 'mean' — try rephrasing"). Pruned here whenever the winning class's candidates are
|
|
226
|
-
// EXACTLY [a meta-shape parse, a same-precedence parse whose object is a
|
|
227
|
-
// META_MEANING_VERB] — collapsing back to the meta parse alone, so the term's own
|
|
228
|
-
// schema-predicate definition answers directly instead of the unhelpful two-way punt.
|
|
229
|
-
//
|
|
230
|
-
// EXCEPT "imports": am-meta-imports (chatbench/graded-pool.jsonl) and
|
|
231
|
-
// quickwins.test.mjs's "fix1: the frozen am-meta-imports ambiguity is NOT admitted"
|
|
232
|
-
// both lock this EXACT ambiguity in as the intended, honest answer for that one term —
|
|
233
|
-
// "what does imports mean" is byte-identical input to both am-meta-imports (which
|
|
234
|
-
// requires the ambiguous answer) and g-a1-naming-9 (which wants the plain definition).
|
|
235
|
-
// A deterministic function cannot satisfy both on the same input; widening the prune to
|
|
236
|
-
// "imports" would silently flip am-meta-imports from passing to failing, a real
|
|
237
|
-
// regression this project's own decision rule (SKILL_BENCHMARK_CEFR_ENGLISH.md §1)
|
|
238
|
-
// forbids. So "imports" keeps its existing ambiguous answer — the same judgment call
|
|
239
|
-
// chat.mjs's relationTermOf already makes for it (see that function's own docblock) —
|
|
240
|
-
// while every OTHER relation term this collision can hit ("tests" included) is fixed
|
|
241
|
-
// generally, not as a one-off patch.
|
|
116
|
+
* object[, subject]}, or null if no strategy fits. When strategies parse and
|
|
117
|
+
* disagree, returns {ambiguousParse: true, candidates: [...]}. `opts.nlp`
|
|
118
|
+
* overrides the lemma/POS adapter (pass null to force adapter-less browser
|
|
119
|
+
* behavior in a Node test). */
|
|
120
|
+
// Collapses a spurious ambiguity: when an object term is itself a
|
|
121
|
+
// RELATIONS/VERB_TO_KIND keyword, keyword-spot can misread "what does X mean"
|
|
122
|
+
// as a reverse query with object "mean", colliding with the grammar
|
|
123
|
+
// strategy's clean "meta" parse. That reading never resolves to anything
|
|
124
|
+
// real, so it's pruned back to the meta parse alone. "imports" is excluded —
|
|
125
|
+
// its ambiguous answer is the intended one (matches chat.mjs's relationTermOf).
|
|
242
126
|
const FROZEN_META_AMBIGUOUS_TERMS = new Set(["imports"]);
|
|
243
127
|
function pruneSpuriousMeaningAmbiguity(parsed) {
|
|
244
128
|
if (!parsed?.ambiguousParse || !Array.isArray(parsed.candidates) || parsed.candidates.length !== 2) return parsed;
|
|
@@ -256,29 +140,17 @@ export function parseQuery(query, { nlp = undefined } = {}) {
|
|
|
256
140
|
return parseQueryFull(query, { nlp }).parsed;
|
|
257
141
|
}
|
|
258
142
|
|
|
259
|
-
/** Sibling of `parseQuery` that also surfaces
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* silently dropped on every hit until now. `parseQuery`'s own contract stays
|
|
263
|
-
* byte-identical (it's defined in terms of this function's `.parsed` field,
|
|
264
|
-
* above) — 142 existing call sites are untouched. Returns
|
|
265
|
-
* `{parsed, alternates, class}`; `alternates`/`class` are `[]`/`null` on the
|
|
266
|
-
* compositional-parse path (a structurally separate grammar layer that never
|
|
267
|
-
* reaches `mergeStrategyResults`) or on a total miss. */
|
|
143
|
+
/** Sibling of `parseQuery` that also surfaces `merge.mjs`'s `class`/`alternates`.
|
|
144
|
+
* Returns `{parsed, alternates, class}`; `alternates`/`class` are `[]`/`null`
|
|
145
|
+
* on the compositional-parse path or a total miss. */
|
|
268
146
|
export function parseQueryFull(query, { nlp = undefined } = {}) {
|
|
269
147
|
const adapter = nlp === undefined ? defaultNlp() : nlp;
|
|
270
148
|
const raw = String(query || "").trim().replace(/\s+/g, " ");
|
|
271
149
|
if (!raw) return { parsed: null, alternates: [], class: null };
|
|
272
150
|
const text = applyPhrasingFrames(applyNegationFrames(normalizeQuery(raw)));
|
|
273
151
|
if (!text) return { parsed: null, alternates: [], class: null };
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
// qualifiers, aggregates, superlatives, anaphora). It fires ONLY when a
|
|
277
|
-
// compositional MARKER is present and returns null otherwise, so every plain
|
|
278
|
-
// clause falls straight through to the unchanged strategy pipeline below — the
|
|
279
|
-
// whole existing grammar is preserved bit-for-bit. When a marker IS present but
|
|
280
|
-
// the phrase cannot be compiled, it returns an honest {node:"miss"} rather than
|
|
281
|
-
// letting keyword-spot guess at a composition it never expressed.
|
|
152
|
+
// Fires only when a compositional marker is present; every plain clause
|
|
153
|
+
// falls through to the strategy pipeline below.
|
|
282
154
|
const composite = parseComposite(text, adapter);
|
|
283
155
|
if (composite) return { parsed: composite, alternates: [], class: null };
|
|
284
156
|
const merged = mergeStrategyResults(runStrategiesSync(text, { nlp: adapter, raw }));
|
|
@@ -291,64 +163,23 @@ export function parseQueryFull(query, { nlp = undefined } = {}) {
|
|
|
291
163
|
}
|
|
292
164
|
|
|
293
165
|
// ============================================================================
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
// {node:"reverseSet"|"forwardSet", kind, entityType, inner} — nested/relative:
|
|
302
|
-
// the OBJECT (reverse) / SUBJECT (forward) of the outer edge is the id-set
|
|
303
|
-
// produced by evaluating `inner` (another AST) — two-stage traversal. `inner`
|
|
304
|
-
// is usually a nested relative clause, but {node:"prevSet"} (below) is a
|
|
305
|
-
// second, discourse-shaped leaf composing with the same union logic.
|
|
306
|
-
// {node:"prevSet"} — the FULL id set of ask()'s own
|
|
307
|
-
// `prev` (the immediately-preceding list-shaped answer) — a plural pronoun's
|
|
308
|
-
// ("those"/"them") antecedent, only ever used as reverseSet/forwardSet's
|
|
309
|
-
// `inner` (see parsePluralAnaphoraObject).
|
|
310
|
-
// {node:"membership", entityType, term} — "<entity> of/in <term>"
|
|
311
|
-
// {node:"qualifier", filters:[word…], inner} — adjective post-filters on a set
|
|
312
|
-
// {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
|
|
313
|
-
// over the SAME subject (and/or/but-not); op ∈ seed/intersection/union/difference
|
|
314
|
-
// {node:"count", entityType, base} — aggregate: |eval(base)|
|
|
315
|
-
// {node:"list", entityType, base, scoped} — list the individuals of eval(base),
|
|
316
|
-
// capped at OVERFLOW_CAP; `scoped` suppresses the "narrow with …" hint when the
|
|
317
|
-
// list was already restricted (a module scope or predicate tail)
|
|
318
|
-
// {node:"superlative", entityType, metric, metricNoun, extreme} — rank by degree
|
|
319
|
-
// {node:"anaphora", mode, filter} — over ask()'s `prev` id array
|
|
320
|
-
// {node:"miss", reason} — a compositional marker was seen
|
|
321
|
-
// but could not compile: an honest stated miss, never a guess.
|
|
322
|
-
// The grammar COMPOSES the closed vocabulary (ask-vocab.mjs); it never opens it —
|
|
323
|
-
// every leaf still resolves through the existing curated clause parser + tiered
|
|
324
|
-
// resolveObject, so a term it can't resolve is still an honest object-miss.
|
|
166
|
+
// compositional grammar — recursive-descent tokenize -> AST -> compile to
|
|
167
|
+
// graph traversal, composing the closed vocabulary (ask-vocab.mjs) for
|
|
168
|
+
// multi-hop / set-algebra shapes (nested/relative, boolean, qualifiers,
|
|
169
|
+
// aggregates, superlatives, anaphora). Every AST node carries a `node` tag;
|
|
170
|
+
// {node:"miss"} means a compositional marker was seen but couldn't compile —
|
|
171
|
+
// an honest stated miss, never a guess. Every leaf still resolves through the
|
|
172
|
+
// same curated clause parser + tiered resolveObject.
|
|
325
173
|
// ============================================================================
|
|
326
174
|
|
|
327
|
-
// Depth cap on nesting (PLAN P3: "depth ≥2 nesting; guard against runaway with a
|
|
328
|
-
// sane hop cap and an honest 'too deep to resolve' if exceeded").
|
|
329
175
|
const MAX_COMPOSE_DEPTH = 4;
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
// then its `object` is discarded and replaced at eval time by the inner set. Chosen
|
|
333
|
-
// to be plainly alphabetic (not a stopword, not vocabulary) so the clause parser
|
|
334
|
-
// treats it as an ordinary object term rather than dropping it.
|
|
176
|
+
// Placeholder object term for a nested clause's outer parse; its `object` is
|
|
177
|
+
// discarded and replaced at eval time by the inner set.
|
|
335
178
|
const NEST_SENTINEL = "zzinnerset";
|
|
336
|
-
// Filler words dropped at the front of a relative predicate / anaphora filter.
|
|
337
|
-
// "then"/"though" (Tier-2 playtest, 5th pass): a trailing discourse tag on an
|
|
338
|
-
// otherwise-bare anaphora follow-up — "how many of those THEN", "which of
|
|
339
|
-
// them THOUGH" — used to be read as an (uncompilable) filter clause instead
|
|
340
|
-
// of being dropped as filler, so the follow-up MISSED at PARSE time with a
|
|
341
|
-
// generic "the follow-up filter didn't parse" instead of reaching the
|
|
342
|
-
// friendly eval-time "needs a previous answer" nudge when there was truly no
|
|
343
|
-
// prior set (or the correct count/list when there was one) — the exact same
|
|
344
|
-
// discourse-tag tolerance WHAT_ABOUT_RE already carries for "what about X
|
|
345
|
-
// then"/"what about X though".
|
|
346
179
|
const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and", "then", "though"]);
|
|
347
180
|
const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
|
|
348
|
-
// A bare copula leading a boolean branch ("...and ARE untested") is discourse
|
|
349
|
-
// not part of the qualifier
|
|
350
|
-
// qualifier-only atom (both the marker-gate probe below and the two atom-building
|
|
351
|
-
// folds — buildPredicateAtoms, parseRelationalOrQualified's own fold — apply it).
|
|
181
|
+
// A bare copula leading a boolean branch ("...and ARE untested") is discourse
|
|
182
|
+
// glue, not part of the qualifier.
|
|
352
183
|
const COPULA_WORDS = new Set(["are", "is", "was", "were"]);
|
|
353
184
|
function dropLeadCopula(bw, blc) {
|
|
354
185
|
return blc.length && COPULA_WORDS.has(blc[0]) ? { bw: bw.slice(1), blc: blc.slice(1) } : { bw, blc };
|
|
@@ -358,18 +189,13 @@ const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w],
|
|
|
358
189
|
: (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
|
|
359
190
|
const isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
|
|
360
191
|
|
|
361
|
-
/** Run the two legacy strategies on a
|
|
362
|
-
*
|
|
363
|
-
* wins — a fragment fed from the composer is already shape-constrained, so the
|
|
364
|
-
* merge's "surface an ambiguity" behavior isn't wanted here. (Equivalent to the
|
|
365
|
-
* original two-strategy scan: anchored first, keyword-spot only on a miss.) */
|
|
192
|
+
/** Run the two legacy strategies on a fragment, anchored parse taking priority
|
|
193
|
+
* on disagreement (a composer-fed fragment is already shape-constrained). */
|
|
366
194
|
function parseSimpleClause(text, nlp) {
|
|
367
195
|
return parseAnchored(text) || parseKeywordSpot(text, nlp);
|
|
368
196
|
}
|
|
369
197
|
|
|
370
|
-
/** Top compositional dispatcher — first marker-matching production wins
|
|
371
|
-
* production returns null (not this shape → fall through) or an AST node (which
|
|
372
|
-
* may itself be {node:"miss"} when the marker was present but uncompilable). */
|
|
198
|
+
/** Top compositional dispatcher — first marker-matching production wins. */
|
|
373
199
|
function parseComposite(text, nlp) {
|
|
374
200
|
const w = splitWords(text);
|
|
375
201
|
const lc = w.map((x) => x.toLowerCase());
|
|
@@ -389,23 +215,10 @@ function parseComposite(text, nlp) {
|
|
|
389
215
|
|| parseRelationalOrQualified(w, lc, nlp, 0);
|
|
390
216
|
}
|
|
391
217
|
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
// the allOfClass node is a ready-made bounded universe of a kind. The only new work is
|
|
397
|
-
// recognizing the negation marker (matchNegationSet, normalize.mjs) and assembling the
|
|
398
|
-
// boolean-difference AST — no new traversal primitive. Regression guards, all tested:
|
|
399
|
-
// (1) honest-empty stays honest — an EMPTY complement ("which functions are not
|
|
400
|
-
// exported", where the only function is exported) renders the standard honest
|
|
401
|
-
// "nothing matches" miss, never invents a member and never re-trips the literal-
|
|
402
|
-
// 'not' trap (the "not" is consumed here, so it can't leak into an object term);
|
|
403
|
-
// (2) BOUNDED UNIVERSE only — the universe is the queried kind within the loaded
|
|
404
|
-
// graph; the "Change" pseudo-type (ask-vocab.mjs) is a wildcard, not a stored
|
|
405
|
-
// enumerable class, so a complement over "changes" is REFUSED honestly rather
|
|
406
|
-
// than answered over an empty universe;
|
|
407
|
-
// (3) active-voice/positive queries are untouched — parseNegation returns null unless
|
|
408
|
-
// matchNegationSet finds an explicit set-negation marker.
|
|
218
|
+
// Negation as set complement: "which X do not <verb> Y" compiles to
|
|
219
|
+
// allOfClass(kind) DIFFERENCE (the positive result set). The "Change"
|
|
220
|
+
// pseudo-type has no bounded enumerable universe, so a complement over
|
|
221
|
+
// "changes" is refused honestly rather than answered over an empty universe.
|
|
409
222
|
function complementAst(entityType, diffAtom) {
|
|
410
223
|
return {
|
|
411
224
|
node: "boolean",
|
|
@@ -454,7 +267,7 @@ function parseNegation(text, nlp, depth = 0) {
|
|
|
454
267
|
return complementAst(entityType, { op: "difference", kind: "set", ast: positive });
|
|
455
268
|
}
|
|
456
269
|
|
|
457
|
-
//
|
|
270
|
+
// FORWARD NEGATION — the subject-side complement's mirror: "what
|
|
458
271
|
// does[n't] <subj> <verb>" ("what doesn't it import", "what does app/lib/e.mjs not import")
|
|
459
272
|
// is every individual of the verb's OBJECT grain that <subj> does NOT reach via that verb.
|
|
460
273
|
// Distinct from parseNegation (which negates a queried KIND — "which modules do not import
|
|
@@ -542,14 +355,10 @@ function parseSetPhrase(text, nlp, depth) {
|
|
|
542
355
|
*
|
|
543
356
|
* The marker is either an explicit relative pronoun ("the module THAT imports X") or a
|
|
544
357
|
* REDUCED relative clause with the pronoun dropped ("the module IMPORTING X" — a gerund
|
|
545
|
-
* verb right where the pronoun+verb would go means the same thing)
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
* "touched" itself as the flat ASK shape's subject TERM ("does 'touched' import X"),
|
|
550
|
-
* never resolving to a real entity. "the module THAT imports X" (explicit pronoun)
|
|
551
|
-
* already routed correctly through this same function; the gerund form is the identical
|
|
552
|
-
* nested-set shape and is now recognized the same way. */
|
|
358
|
+
* verb right where the pronoun+verb would go means the same thing): without recognizing
|
|
359
|
+
* the gerund form, "who touched the module importing X" falls through to the legacy
|
|
360
|
+
* strategy pipeline, which misreads the leading verb "touched" as the flat ASK shape's
|
|
361
|
+
* subject term instead of resolving to a real entity. */
|
|
553
362
|
function parseNested(w, lc, nlp, depth) {
|
|
554
363
|
for (let r = 1; r < lc.length; r += 1) {
|
|
555
364
|
const isPronoun = RELATIVE_PRONOUNS.includes(lc[r]);
|
|
@@ -575,25 +384,10 @@ function parseNested(w, lc, nlp, depth) {
|
|
|
575
384
|
return null;
|
|
576
385
|
}
|
|
577
386
|
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
// ("what tests cover those" — trailing, the reverse OBJECT; "what do those import" — leading, the
|
|
583
|
-
// forward SUBJECT) refer to the FULL id set of the immediately-preceding list-shaped answer —
|
|
584
|
-
// exactly the id array ask()'s own `prev` already threads for parseAnaphora's "of those"/"count
|
|
585
|
-
// them" shapes. Reuses reverseSet/forwardSet's existing multi-object UNION traversal (parseNested,
|
|
586
|
-
// just above) verbatim: the only new thing is a second kind of `inner` leaf, {node:"prevSet"},
|
|
587
|
-
// that reads `prev` instead of evaluating a nested clause.
|
|
588
|
-
//
|
|
589
|
-
// Two positions are recognized, both requiring the pronoun to stand ALONE (never a determiner —
|
|
590
|
-
// "list those functions" is untouched): the sentence's FINAL word (mirrors parseAnaphora's own
|
|
591
|
-
// "count them"/"list them" terminal pinning — the reverse-clause object trails its verb), or
|
|
592
|
-
// immediately followed by a known relation verb (the forward-clause subject leads its verb: "those
|
|
593
|
-
// IMPORT" is unambiguously a pronoun-then-verb, never "those <noun>"). An "of those"/"of them"
|
|
594
|
-
// tail is parseAnaphora's own territory (checked earlier in parseComposite) and is skipped here.
|
|
595
|
-
// After the substitution, `outer.object` is verified to BE the sentinel itself — guards against a
|
|
596
|
-
// keyword-spot retry silently resolving the object to some other word in the sentence instead.
|
|
387
|
+
// Plural anaphora object: "those"/"them" standing alone as a reverse-clause
|
|
388
|
+
// object ("what tests cover those") or forward-clause subject ("what do
|
|
389
|
+
// those import") refer to the full id set of ask()'s `prev`. Reuses
|
|
390
|
+
// reverseSet/forwardSet's traversal with a {node:"prevSet"} inner leaf.
|
|
597
391
|
const PLURAL_ANAPHORA_OBJECT = new Set(["those", "them"]);
|
|
598
392
|
function parsePluralAnaphoraObject(w, lc, nlp) {
|
|
599
393
|
for (let i = 0; i < lc.length; i += 1) {
|
|
@@ -612,13 +406,10 @@ function parsePluralAnaphoraObject(w, lc, nlp) {
|
|
|
612
406
|
return null;
|
|
613
407
|
}
|
|
614
408
|
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
// functions that call Y last touched"). Fires only for a RELATIVE subject (a "that/which"
|
|
620
|
-
// marker) so the single-entity "when did X change" stays on the flat path untouched; a
|
|
621
|
-
// marker present but uncompilable inner is an honest miss, never a guess.
|
|
409
|
+
// Temporal-over-relative: "when did <relative set> [last] change" composes
|
|
410
|
+
// the flat when-shape's touches->commit->date-sort machinery over a nested
|
|
411
|
+
// inner set. Fires only for a relative subject; single-entity "when did X
|
|
412
|
+
// change" stays on the flat path.
|
|
622
413
|
const TEMPORAL_AUX = new Set(["did", "was", "were", "do", "does", "has", "have", "had"]);
|
|
623
414
|
const TEMPORAL_TAIL = new Set([
|
|
624
415
|
"change", "changed", "changes", "update", "updated", "updates",
|
|
@@ -650,17 +441,11 @@ function parseTemporal(w, lc, nlp, depth = 0) {
|
|
|
650
441
|
return { node: "temporal", inner, entityType: (noun && noun.entityType) || null };
|
|
651
442
|
}
|
|
652
443
|
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// named commit — resolved at EVAL time (graph-dependent), whose own recorded date
|
|
659
|
-
// becomes the pivot and who is excluded from its own before/after comparison (never
|
|
660
|
-
// "before/after itself"). "in"/"during" are deliberately NOT among the qualifiers:
|
|
661
|
-
// "what changed in <commit>" already means something else (that commit's own touch-
|
|
662
|
-
// set — the commit-as-subject flip elsewhere in this file), and this recognizer must
|
|
663
|
-
// never shadow it.
|
|
444
|
+
// Commit filter: "what changed since/before/after/on <date-or-commit>" is a
|
|
445
|
+
// date-qualified survey of every recorded commit, distinct from the flat
|
|
446
|
+
// when-shape above (which dates one named entity's touch history). "in"/
|
|
447
|
+
// "during" are deliberately excluded — "what changed in <commit>" means that
|
|
448
|
+
// commit's own touch-set instead.
|
|
664
449
|
const COMMIT_FILTER_OPS = new Set(["since", "before", "after", "on"]);
|
|
665
450
|
function parseCommitFilter(w, lc) {
|
|
666
451
|
if (lc[0] !== "what" || lc[1] !== "changed") return null;
|
|
@@ -673,25 +458,14 @@ function parseCommitFilter(w, lc) {
|
|
|
673
458
|
return { node: "commitFilter", op, pivotRaw };
|
|
674
459
|
}
|
|
675
460
|
|
|
676
|
-
// Minimal code-identifier token shape
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
// kind of code-identifier detection in discourseRewrite) rather than imported:
|
|
680
|
-
// chat.mjs only ever imports ask.mjs LAZILY (dynamic `await import("./ask.mjs")`,
|
|
681
|
-
// per chat.mjs's own top-of-file comment), so a static ask.mjs -> chat.mjs import
|
|
682
|
-
// would invert that layering for the sake of one regex. Keep the two in sync by
|
|
683
|
-
// hand if either changes.
|
|
461
|
+
// Minimal code-identifier token shape: dotted paths, Capitalized symbols, or
|
|
462
|
+
// lowerCamelCase. Duplicated from chat.mjs's NAME_TOKEN_RE rather than
|
|
463
|
+
// imported, since chat.mjs only imports ask.mjs lazily.
|
|
684
464
|
const ANAPHORA_NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/;
|
|
685
465
|
|
|
686
|
-
/** Distinct code-identifier-shaped tokens in `words
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
* (HANDOVER.md item 1, C2 pronoun-binding): "which of them <filter>" doesn't
|
|
690
|
-
* ALWAYS mean "the previous turn's answer set" — when the SAME sentence already
|
|
691
|
-
* named 2+ real candidates before the trigger ("app/lib/e.mjs ... app/lib/f.mjs
|
|
692
|
-
* ... which of them ...", a single turn, no prior turn at all), THAT'S the
|
|
693
|
-
* referent. Ordinary English words (even repeated nouns) never match this
|
|
694
|
-
* regex, so this never widens beyond genuine code identifiers. */
|
|
466
|
+
/** Distinct code-identifier-shaped tokens in `words`, first-occurrence order.
|
|
467
|
+
* Feeds parseAnaphora's in-sentence candidate set: if the same sentence
|
|
468
|
+
* already names 2+ real candidates, they're the referent instead of `prev`. */
|
|
695
469
|
function inSentenceNameTokens(words) {
|
|
696
470
|
const seen = new Set();
|
|
697
471
|
const out = [];
|
|
@@ -710,16 +484,9 @@ function inSentenceNameTokens(words) {
|
|
|
710
484
|
* fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
|
|
711
485
|
* uncompilable), or null. */
|
|
712
486
|
function parseAnaphora(w, lc, nlp) {
|
|
713
|
-
// "which ones"/"which one"
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
// playtest, third pass, numeric/quantifier relation touches): after "how many
|
|
717
|
-
// modules import app/lib/a.mjs" / "which modules import app/lib/a.mjs", the
|
|
718
|
-
// completely natural follow-up "which ones" fell straight through to the generic
|
|
719
|
-
// orientation card — "ones" isn't an ANAPHORA_TRIGGERS pronoun and bare "which"
|
|
720
|
-
// isn't a LIST_TRIGGERS head, so neither existing branch below ever fires for it.
|
|
721
|
-
// Pinned to the WHOLE query (exactly two words) so it never shadows an ordinary
|
|
722
|
-
// "which one of these two functions …" clause, which has more words after "one".
|
|
487
|
+
// Bare "which ones"/"which one": an anaphoric re-list phrased as a
|
|
488
|
+
// question. Pinned to the whole two-word query so it never shadows
|
|
489
|
+
// "which one of these two functions …".
|
|
723
490
|
if (lc.length === 2 && lc[0] === "which" && (lc[1] === "ones" || lc[1] === "one")) {
|
|
724
491
|
return { node: "anaphora", mode: "list", filter: { type: "all" } };
|
|
725
492
|
}
|
|
@@ -728,11 +495,7 @@ function parseAnaphora(w, lc, nlp) {
|
|
|
728
495
|
for (let i = 1; i < lc.length; i += 1) {
|
|
729
496
|
if (!ANAPHORA_TRIGGERS.includes(lc[i])) continue;
|
|
730
497
|
if (lc[i - 1] === "of") { p = i; viaOf = true; break; } // "how many of those", "which of them"
|
|
731
|
-
//
|
|
732
|
-
// ("count them", "count those", "list them") — the discourse-reference count/list over
|
|
733
|
-
// the previous answer with no "of" (Cycle 5, disc+count). Pinned to the terminal
|
|
734
|
-
// position so a mid-sentence "these"/"those" used as a determiner ("list these
|
|
735
|
-
// functions") is left for the ordinary list/clause path, not seized as an anaphor.
|
|
498
|
+
// bare pronoun as the final word after a count/list trigger ("count them")
|
|
736
499
|
const headSoFar = lc.slice(0, i).join(" ");
|
|
737
500
|
if (i === lc.length - 1 && (AGGREGATE_TRIGGERS.includes(headSoFar) || LIST_TRIGGERS.includes(headSoFar))) { p = i; break; }
|
|
738
501
|
}
|
|
@@ -741,14 +504,8 @@ function parseAnaphora(w, lc, nlp) {
|
|
|
741
504
|
const mode = AGGREGATE_TRIGGERS.includes(head) || /^(how many|how much|count|number|quantity|total)\b/.test(head) ? "count" : "list";
|
|
742
505
|
const filter = parsePredicateFilter(w.slice(p + 1), nlp);
|
|
743
506
|
if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
// "app/lib/e.mjs ... because it imports app/lib/f.mjs — which of them imports
|
|
747
|
-
// app/lib/f.mjs" — those are the referent, not a previous turn's cached result
|
|
748
|
-
// set. evalAnaphora tries this FIRST and only falls back to opts.prev when it's
|
|
749
|
-
// absent or resolves to fewer than 2 real graph entities, so an ordinary
|
|
750
|
-
// multi-turn follow-up (no named entities in the current utterance at all) is
|
|
751
|
-
// completely unaffected.
|
|
507
|
+
// In-sentence candidate set: if this utterance already names 2+ real code
|
|
508
|
+
// identifiers before the trigger, those are the referent instead of `prev`.
|
|
752
509
|
const cutIdx = viaOf ? p - 1 : p;
|
|
753
510
|
const candidateTerms = inSentenceNameTokens(w.slice(0, cutIdx));
|
|
754
511
|
const ast = { node: "anaphora", mode, filter };
|
|
@@ -774,23 +531,12 @@ function parsePredicateFilter(words, nlp) {
|
|
|
774
531
|
return undefined;
|
|
775
532
|
}
|
|
776
533
|
|
|
777
|
-
/**
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
* through to the legacy keyword-spot strategy, whose lemma tier canonicalizes
|
|
784
|
-
* "called" -> "call" (a `calls` verb — ask-vocab.mjs) and silently answered a
|
|
785
|
-
* DIFFERENT question ("which classes call Store") with a confidently-wrong-shaped
|
|
786
|
-
* negative, even though a class named Store genuinely exists. "is there a class in
|
|
787
|
-
* <module>" walled out the same way — no marker in this grammar recognized it at
|
|
788
|
-
* all. Scoped to a tight closed shape: a leading "is there a/an" or "are there any"
|
|
789
|
-
* immediately followed by a recognized entity-kind noun, then ONLY "called"/"named
|
|
790
|
-
* <term>", "in <module>", the two combined, or an empty/"anywhere"/"at all" tail —
|
|
791
|
-
* anything else (a relative clause, a verb phrase: "is there a class THAT CALLS
|
|
792
|
-
* Store") is a genuine relationship question and is left untouched for the
|
|
793
|
-
* relation parsers below, never swallowed here. */
|
|
534
|
+
/** Existence: "is there a/an <kind> [called|named <term>] [in <module>] [anywhere]".
|
|
535
|
+
* Answered directly against class/kind membership rather than the relation-
|
|
536
|
+
* verb machinery — otherwise "is there a class called Store" would misparse
|
|
537
|
+
* "called" as a `calls` verb and answer a different question. A relative
|
|
538
|
+
* clause or verb phrase ("is there a class THAT CALLS Store") is left
|
|
539
|
+
* untouched for the relation parsers below. */
|
|
794
540
|
function parseExistence(w, lc) {
|
|
795
541
|
let i;
|
|
796
542
|
if (lc[0] === "is" && lc[1] === "there") i = 2;
|
|
@@ -840,25 +586,10 @@ function parseExistence(w, lc) {
|
|
|
840
586
|
// different (relationship) question; leave it for the parsers below.
|
|
841
587
|
}
|
|
842
588
|
|
|
843
|
-
/**
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
* predicative-survey filters already fold over a SET ("public methods",
|
|
848
|
-
* "which methods are public") — this is the missing single-entity sibling
|
|
849
|
-
* (0.9.15 Tier-1 single-touch playtest: "is it a public attribute?", a
|
|
850
|
-
* natural follow-up to a concept-force touch, had no recognizer at all and
|
|
851
|
-
* hit the bare grammar wall — even "is Task.title public", a concretely
|
|
852
|
-
* NAMED entity with no anaphora involved, walled the same way). Scoped
|
|
853
|
-
* tight: a leading "is"/"are", then TERM tokens up to the FIRST recognized
|
|
854
|
-
* qualifier word — a leading "the" and a trailing "a"/"an" article around
|
|
855
|
-
* the boundary are dropped, and a trailing decorative kind noun ("… public
|
|
856
|
-
* ATTRIBUTE") is simply never consumed, never required to agree with the
|
|
857
|
-
* resolved entity's real class. Guarded off "is/are THERE …" (parseExistence
|
|
858
|
-
* above owns that shape) and off any text with no qualifier word at all, so
|
|
859
|
-
* it can never swallow a genuine relationship/existence question. The term
|
|
860
|
-
* is resolved at EVAL time (a pronoun binds through the standing contextId,
|
|
861
|
-
* exactly like every other object term), never here. */
|
|
589
|
+
/** Qualifier-check: "is <term> [a/an] <qualifier> [<kind>]?" — a single-entity
|
|
590
|
+
* yes/no property check ("is Task.title public"), the missing single-entity
|
|
591
|
+
* sibling of the qualifier post-filter over a set. Guarded off "is/are
|
|
592
|
+
* there …" (parseExistence's shape). */
|
|
862
593
|
function parseQualifierCheck(w, lc) {
|
|
863
594
|
if (lc[0] !== "is" && lc[0] !== "are") return null;
|
|
864
595
|
if (lc[1] === "there") return null; // parseExistence's own shape
|
|
@@ -877,18 +608,9 @@ function parseQualifierCheck(w, lc) {
|
|
|
877
608
|
return { node: "qualCheck", term, qualifier: lc[qualIdx], negated };
|
|
878
609
|
}
|
|
879
610
|
|
|
880
|
-
/** Trailing
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
* and it must NOT be mistaken for a restrictor (that's the exact bug behind "how many
|
|
884
|
-
* classes are there" → the count-restrictor miss). Combined with STOPWORDS (which
|
|
885
|
-
* already carries are/there/is/in/the/…) at the call site, so only the non-stopword
|
|
886
|
-
* extras live here. A tail with ANY word outside this ∪ STOPWORDS is a real restrictor.
|
|
887
|
-
* "this"/"that" (0.9.14 Tier-2 playtest): "which classes exist IN THIS codebase"/
|
|
888
|
-
* "which methods exist in this codebase" used to miss — "codebase" alone was already
|
|
889
|
-
* filler, but the demonstrative right before it ("this"/"that") is not a STOPWORDS
|
|
890
|
-
* entry, so the tail read as non-filler and the whole thing fell through to the
|
|
891
|
-
* grammar wall instead of the bare list. */
|
|
611
|
+
/** Trailing filler an aggregate/list tail can carry ("how many classes are
|
|
612
|
+
* there", "list functions in total") that must not be mistaken for a
|
|
613
|
+
* restrictor. Combined with STOPWORDS at the call site. */
|
|
892
614
|
const AGG_TAIL_FILLER = new Set([
|
|
893
615
|
"total", "altogether", "overall", "exist", "exists", "existing", "present",
|
|
894
616
|
"here", "now", "currently", "graph", "index", "codebase", "repo", "repository",
|
|
@@ -924,37 +646,20 @@ function parseAggregate(w, lc, nlp) {
|
|
|
924
646
|
return { node: "count", entityType: noun.entityType, base };
|
|
925
647
|
}
|
|
926
648
|
|
|
927
|
-
// Determiners/objects skipped after a LIST trigger verb ("show me THE classes") — a
|
|
928
|
-
// superset of the aggregate skip so "give me all the modules" reaches the kind noun.
|
|
929
649
|
const LIST_SKIP = new Set(["the", "a", "an", "all", "me", "us"]);
|
|
930
650
|
const LIST_TRIGGERS_SORTED = [...LIST_TRIGGERS].sort((a, b) => b.split(" ").length - a.split(" ").length);
|
|
931
|
-
// The listable node classes, named in the honest miss and the empty-index message.
|
|
932
651
|
const LISTABLE_KINDS = "functions, classes, methods, modules, attributes, variables, or commits";
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
// tail, never a reverse-clause predicate object — "which modules import X" has no
|
|
936
|
-
// preposition there at all. Narrowly scoped to just these three words so the
|
|
937
|
-
// interrogative exception below can't be mistaken for a general tail-acceptance.
|
|
652
|
+
// A leading "in"/"inside"/"under" right after the entity noun is an
|
|
653
|
+
// unambiguous location-scope tail, never a reverse-clause predicate object.
|
|
938
654
|
const SCOPE_PREPOSITIONS = new Set(["in", "inside", "under"]);
|
|
939
655
|
|
|
940
|
-
/**
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
* (reusing membership/relational/boolean), and its `scoped` flag suppresses the
|
|
948
|
-
* "narrow with …" hint. An unknown kind after a clear imperative trigger ("list
|
|
949
|
-
* bananas") is an honest miss naming the listable kinds; anything less certain falls
|
|
950
|
-
* through (null) to the existing parser/cascade rather than guessing.
|
|
951
|
-
*
|
|
952
|
-
* A single narrowly-scoped EXCEPTION to the interrogative gate (HANDOVER item
|
|
953
|
-
* 12.2/11.1): "what/which <kind> [is|are] in|inside|under <scope>" — a leading scope
|
|
954
|
-
* preposition, past an optional copula, straight after the entity noun — is routed the
|
|
955
|
-
* SAME way the imperative "list <kind> in <scope>" already is. This does NOT widen the
|
|
956
|
-
* general decline: "which modules import X"/"which functions call X" have a VERB there,
|
|
957
|
-
* not a scope preposition, so they still fall through untouched. */
|
|
656
|
+
/** List: "list <kind>", "show me the <kind>s", "what are the <kind>". A
|
|
657
|
+
* sibling of the count node — enumerates individuals (capped at
|
|
658
|
+
* OVERFLOW_CAP) instead of counting them. Fires on a LIST_TRIGGERS verb or
|
|
659
|
+
* the bare interrogative "what/which <kind>", gated to a filler-only tail so
|
|
660
|
+
* "which functions call X" isn't hijacked into a list. One exception:
|
|
661
|
+
* "what/which <kind> [is|are] in|inside|under <scope>" routes the same way
|
|
662
|
+
* the imperative "list <kind> in <scope>" does. */
|
|
958
663
|
function parseList(w, lc, nlp, depth) {
|
|
959
664
|
let i = 0;
|
|
960
665
|
let interrogative = false;
|
|
@@ -988,7 +693,7 @@ function parseList(w, lc, nlp, depth) {
|
|
|
988
693
|
i += 1;
|
|
989
694
|
const tail = w.slice(i);
|
|
990
695
|
const tailMeaningful = lc.slice(i).some((t) => !STOPWORDS.has(t) && !AGG_TAIL_FILLER.has(t));
|
|
991
|
-
// SCOPE-PREPOSITION exception
|
|
696
|
+
// SCOPE-PREPOSITION exception: past an optional copula
|
|
992
697
|
// ("is"/"are"), does the tail open with "in"/"inside"/"under"? If so this is a
|
|
993
698
|
// location-scope tail ("what modules ARE IN app/lib"), not a reverse-clause
|
|
994
699
|
// predicate — strip only the copula (the imperative form never has one: "list
|
|
@@ -1074,29 +779,14 @@ function parseSuperlative(w, lc, nlp) {
|
|
|
1074
779
|
return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
|
|
1075
780
|
}
|
|
1076
781
|
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
// named Foo"). A TYPE FILTER ∧ FUZZY PROPERTY-SURFACE MATCH, not a literal name
|
|
1081
|
-
// lookup (contrast parseList's plain class enumeration) — reuses the same closed
|
|
1082
|
-
// compositional grammar (evalSet's new "find" case, renderComposite's new branch)
|
|
1083
|
-
// so it composes for free with qualifiers/booleans later (§6 generalization below).
|
|
782
|
+
// Predicate-find: "find me the payment class" (trailing-type) or "find the
|
|
783
|
+
// class named Foo" (leading-type-with-linker) — a type filter + fuzzy
|
|
784
|
+
// property-surface match, not a literal name lookup.
|
|
1084
785
|
const FIND_LINKERS = new Set(["called", "named", "about", "like", "containing", "matching", "with"]);
|
|
1085
786
|
|
|
1086
|
-
/**
|
|
1087
|
-
* (
|
|
1088
|
-
*
|
|
1089
|
-
* import X" reaches that production first and never reaches here). Reuses
|
|
1090
|
-
* LIST_SKIP and entityNoun/ENTITY_TO_TYPE exactly as parseList does. A clear
|
|
1091
|
-
* imperative "find <one unknown plain word>" (mirroring parseList's own single-
|
|
1092
|
-
* trailing-word discipline) is an honest miss naming LISTABLE_KINDS — a
|
|
1093
|
-
* PARSE-TIME miss, structurally distinct from evalSet("find")'s zero-hit SEARCH
|
|
1094
|
-
* miss; anything less certain — a longer uncertain remainder, or ANY relative-
|
|
1095
|
-
* clause marker present anywhere (that/which/who) — defers (null) to the existing
|
|
1096
|
-
* parser/cascade, so "find the file that imports store" keeps parsing via the
|
|
1097
|
-
* established parseNested/parseRelationalOrQualified relative-clause path (the
|
|
1098
|
-
* §6 generalization below is the ONE place a term-bearing find-with-predicate
|
|
1099
|
-
* shape is recognized, and it is a structurally separate production). */
|
|
787
|
+
/** A relative-clause marker anywhere defers to the existing relative-clause
|
|
788
|
+
* path ("find the file that imports store"); this only handles the
|
|
789
|
+
* term-bearing find-with-predicate shape. */
|
|
1100
790
|
function parseFind(w, lc, nlp, depth) {
|
|
1101
791
|
if (lc[0] !== "find") return null;
|
|
1102
792
|
let i = 1;
|
|
@@ -1120,7 +810,7 @@ function parseFind(w, lc, nlp, depth) {
|
|
|
1120
810
|
}
|
|
1121
811
|
|
|
1122
812
|
// A relative-clause marker anywhere means a DIFFERENT production owns this text
|
|
1123
|
-
// (either the plain relative-clause path, or the
|
|
813
|
+
// (either the plain relative-clause path, or the find-with-predicate
|
|
1124
814
|
// generalization inside parseRelationalOrQualified) — never claimed here.
|
|
1125
815
|
if (lc.some((t) => RELATIVE_PRONOUNS.includes(t))) return null;
|
|
1126
816
|
// A clear imperative single unknown trailing word (mirrors parseList's own rule).
|
|
@@ -1130,23 +820,14 @@ function parseFind(w, lc, nlp, depth) {
|
|
|
1130
820
|
return null;
|
|
1131
821
|
}
|
|
1132
822
|
|
|
1133
|
-
/**
|
|
1134
|
-
* [that] <predicate>"
|
|
1135
|
-
* and/or/but-not
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
/** §6 generalization (predicate-find, Workstream 2 follow-up) — detect the HEAD of
|
|
1142
|
-
* "find [me/us] [the/a] <term…> <entityType> that|which|who <predicate>": mirrors
|
|
1143
|
-
* parseFind's own trailing-type recognition (LIST_SKIP, entityNoun), but requires
|
|
1144
|
-
* a relative-clause marker directly after the entity noun and a NON-EMPTY term
|
|
1145
|
-
* before it. An EMPTY term ("find classes that…") is deliberately NOT this shape
|
|
1146
|
-
* — it already reaches parseRelationalOrQualified's normal head-parsing below
|
|
1147
|
-
* once "find" is skipped as a FRAME_WORD, with no find-seed needed. Returns
|
|
1148
|
-
* {entityType, term, relIdx} (relIdx = the relative pronoun's token index) or
|
|
1149
|
-
* null — null on anything less than an exact match (never a guess). */
|
|
823
|
+
/** Relational/boolean/qualifier (subject-first): "[which] [<qualifier>…]
|
|
824
|
+
* <entity> [that] <predicate>" where predicate is relation clauses joined by
|
|
825
|
+
* and/or/but-not, a membership "<of|in> <term>", or empty (bare qualified
|
|
826
|
+
* class). Fires only on a compositional marker, so a plain reverse query
|
|
827
|
+
* falls through to the existing strategies untouched. */
|
|
828
|
+
/** Detects the head of "find [me/us] [the/a] <term…> <entityType>
|
|
829
|
+
* that|which|who <predicate>". Returns {entityType, term, relIdx} or null —
|
|
830
|
+
* an empty term ("find classes that…") is deliberately not this shape. */
|
|
1150
831
|
function parseFindPredicateHead(w, lc) {
|
|
1151
832
|
if (lc[0] !== "find") return null;
|
|
1152
833
|
let i = 1;
|
|
@@ -1161,15 +842,10 @@ function parseFindPredicateHead(w, lc) {
|
|
|
1161
842
|
return { entityType: noun.entityType, term, relIdx: r };
|
|
1162
843
|
}
|
|
1163
844
|
|
|
1164
|
-
/** Build boolean/qualifier atoms for a predicate whose
|
|
1165
|
-
* caller already determined externally
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
* "intersection" (a relative clause always RESTRICTS the seed) except where an
|
|
1169
|
-
* explicit and/or/but-not connective says otherwise. Mirrors
|
|
1170
|
-
* parseRelationalOrQualified's own branch-classification (qualifier-only /
|
|
1171
|
-
* membership / verb-phrase clause) in miniature, duplicated rather than
|
|
1172
|
-
* shared, so neither path risks regressing the other. */
|
|
845
|
+
/** Build boolean/qualifier atoms for a predicate whose first (seed) atom the
|
|
846
|
+
* caller already determined externally. Every atom's op defaults to
|
|
847
|
+
* "intersection" except where an explicit and/or/but-not connective says
|
|
848
|
+
* otherwise. */
|
|
1173
849
|
function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, depth) {
|
|
1174
850
|
const { branches, ops } = splitBoolean(predLc, predWords);
|
|
1175
851
|
let prevVerb = null;
|
|
@@ -1195,12 +871,12 @@ function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, dep
|
|
|
1195
871
|
return { atoms };
|
|
1196
872
|
}
|
|
1197
873
|
|
|
1198
|
-
//
|
|
874
|
+
// The closed set of temporal-lead words a bare "<lead> commits"
|
|
1199
875
|
// query can use — see the dedicated recentCommits AST node this feeds, below.
|
|
1200
876
|
const RECENT_COMMIT_LEAD = new Set(["recent", "latest", "newest"]);
|
|
1201
877
|
|
|
1202
878
|
function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
1203
|
-
//
|
|
879
|
+
// predicate-find generalization: seeds the SAME boolean/qualifier fold
|
|
1204
880
|
// below with a {node:"find",…} atom instead of the plain {node:"allOfClass"}
|
|
1205
881
|
// a bare qualified class gets — see parseFindPredicateHead's own doc above.
|
|
1206
882
|
const findHead = parseFindPredicateHead(w, lc);
|
|
@@ -1222,35 +898,16 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1222
898
|
while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
|
|
1223
899
|
const noun = i < lc.length ? entityNoun(lc[i]) : null;
|
|
1224
900
|
if (!noun) {
|
|
1225
|
-
// An unknown adjective
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
// it an unrecognized qualifier: a real, honest answer beats an error message, and a
|
|
1229
|
-
// genuine zero-hit still renders find's own honest "no <noun> found matching <term>"
|
|
1230
|
-
// miss (never a confident-wrong guess either way — same discipline as everywhere
|
|
1231
|
-
// else this AST node is produced). STOPWORDS are excluded so a normal question
|
|
1232
|
-
// auxiliary in that position ("what DID commit X touch") is left for the existing
|
|
1233
|
-
// parser, not mistaken for a term.
|
|
901
|
+
// An unknown adjective before a known entity noun ("list payment
|
|
902
|
+
// modules") tries as a predicate-find fuzzy term before declaring an
|
|
903
|
+
// unrecognized qualifier.
|
|
1234
904
|
const nextNoun = i + 1 < lc.length ? entityNoun(lc[i + 1]) : null;
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
// fall into the generic find-fallback just below with the qualifier WORD ITSELF
|
|
1238
|
-
// as the search term ("no Commit found matching 'recent'") — a false miss, since
|
|
1239
|
-
// "recent"/"latest"/"newest" were never meant as a name to search for, just a
|
|
1240
|
-
// sort direction the graph already has (mgx:commitDate). Checked BEFORE the
|
|
1241
|
-
// generic fallback, and only when nothing follows the noun (a real filter tail,
|
|
1242
|
-
// e.g. "recent commits touching a.py", is left to the ordinary parser).
|
|
905
|
+
// "recent/latest/newest commits" with nothing further is a sort
|
|
906
|
+
// direction, not a search term for the word itself.
|
|
1243
907
|
if (RECENT_COMMIT_LEAD.has(lc[i]) && nextNoun && nextNoun.entityType === "Commit" && i + 2 === lc.length) {
|
|
1244
908
|
return { node: "recentCommits" };
|
|
1245
909
|
}
|
|
1246
|
-
//
|
|
1247
|
-
// copula + determiner ("what IS THE newest commit", "what WAS THE latest
|
|
1248
|
-
// commit"): FRAME_WORDS only strips "what"/"which"/…, so "is the"/"was the"
|
|
1249
|
-
// left `i` sitting on the copula, never reaching the check above at all. A
|
|
1250
|
-
// narrow lookahead (never mutating `i`, so every OTHER branch here is
|
|
1251
|
-
// byte-identical) that re-tries the exact same closed RECENT_COMMIT_LEAD
|
|
1252
|
-
// check past those two filler words only — an honest decline (falls through)
|
|
1253
|
-
// the instant either word doesn't match, never a guess.
|
|
910
|
+
// Same lead past an optional copula + determiner ("what IS THE newest commit").
|
|
1254
911
|
if (COPULA_WORDS.has(lc[i])) {
|
|
1255
912
|
let j = i + 1;
|
|
1256
913
|
if (j < lc.length && (lc[j] === "the" || lc[j] === "a" || lc[j] === "an")) j += 1;
|
|
@@ -1259,17 +916,8 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1259
916
|
return { node: "recentCommits" };
|
|
1260
917
|
}
|
|
1261
918
|
}
|
|
1262
|
-
// CASCADE_NOISE_SET excluded alongside STOPWORDS
|
|
1263
|
-
//
|
|
1264
|
-
// "about" sitting right where a real qualifying adjective would ("payment"
|
|
1265
|
-
// in "list payment modules") — framed (past "what") and immediately before
|
|
1266
|
-
// a known noun ("classes") — and get misread as a fuzzy find TERM ("no
|
|
1267
|
-
// classes found matching 'about'") instead of the topic-lead-in filler it
|
|
1268
|
-
// is. CASCADE_NOISE already curates "about" for exactly this reading (see
|
|
1269
|
-
// its own docblock, ask-vocab.mjs) — checking it here too lets a real
|
|
1270
|
-
// unknown qualifier ("shiny"/"payment") still reach the find fallback while
|
|
1271
|
-
// a known no-graph-meaning filler word falls through to the ordinary bare-
|
|
1272
|
-
// kind-noun path instead (the cascade's own noise-strip terminal rule).
|
|
919
|
+
// CASCADE_NOISE_SET excluded alongside STOPWORDS: "what about classes"
|
|
920
|
+
// would otherwise misread "about" as a fuzzy find term.
|
|
1273
921
|
if ((framed || quals.length) && nextNoun && /^[a-z]+$/.test(lc[i])
|
|
1274
922
|
&& !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && !CASCADE_NOISE_SET.has(lc[i])) {
|
|
1275
923
|
return { node: "find", entityType: nextNoun.entityType, term: w[i] };
|
|
@@ -1285,31 +933,17 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1285
933
|
if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
|
|
1286
934
|
const membershipLed = predLc[0] === "of" || predLc[0] === "in";
|
|
1287
935
|
const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
|
|
1288
|
-
// A boolean branch
|
|
1289
|
-
//
|
|
1290
|
-
//
|
|
1291
|
-
// subject). Probe with the same splitBoolean+QUALIFIERS fold the atoms loop below
|
|
1292
|
-
// uses, so a bare verb+verb boolean chain with NO qualifier signal anywhere
|
|
1293
|
-
// ("which classes extends Base and couples to logging") still has no marker here
|
|
1294
|
-
// and correctly stays on the legacy ambiguous-parse path, untouched.
|
|
936
|
+
// A boolean branch that collapses to qualifier words alone (past an
|
|
937
|
+
// optional copula) is the compositional shape too ("functions that call X
|
|
938
|
+
// and are untested").
|
|
1295
939
|
const boolQualLed = predWords.length > 0 && splitBoolean(predLc, predWords).branches.some((bw) => {
|
|
1296
940
|
const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
|
|
1297
941
|
return blc.length && blc.every((x) => QUALIFIERS[x]);
|
|
1298
942
|
});
|
|
1299
|
-
// A bare "call X and call Y"
|
|
1300
|
-
//
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1303
|
-
// saveStore" == calls(loadStore) ∩ calls(saveStore)) or (b) a bare object with NO
|
|
1304
|
-
// verb of its own at all ("call saveStore but not loadStore" — "loadStore" alone
|
|
1305
|
-
// inherits "call" via the SAME ellipsis-borrowing buildPredicateAtoms/the atoms
|
|
1306
|
-
// loop below already do for OR-chains like "importing X or Y"; this only widens
|
|
1307
|
-
// the GATE that lets that borrowing fire for and/but-not too). Deliberately
|
|
1308
|
-
// narrower than "any verb+verb and": the compat-guarded bare case ("which classes
|
|
1309
|
-
// extends Base and couples to logging") gives its SECOND branch its own DIFFERENT
|
|
1310
|
-
// explicit verb (coupled-to, not inherits) and so fails case (a) and isn't bare
|
|
1311
|
-
// for case (b) either — it still has no marker and correctly stays on the legacy
|
|
1312
|
-
// ambiguous-parse path, untouched.
|
|
943
|
+
// A bare "call X and call Y" chain is also compositional when every branch
|
|
944
|
+
// after the first either repeats the same verb kind or is a bare object
|
|
945
|
+
// that inherits it — narrower than "any verb+verb and" so the legacy
|
|
946
|
+
// ambiguous-parse case (different explicit verbs per branch) stays untouched.
|
|
1313
947
|
const sameVerbBranches = predWords.length > 0 ? splitBoolean(predLc, predWords).branches : [];
|
|
1314
948
|
let sameVerbLed = false;
|
|
1315
949
|
if (sameVerbBranches.length > 1) {
|
|
@@ -1323,31 +957,10 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1323
957
|
});
|
|
1324
958
|
}
|
|
1325
959
|
}
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
//
|
|
1329
|
-
// the
|
|
1330
|
-
// sameVerbLed already covers a same-kind repeat or a bare ellipsis-borrowed
|
|
1331
|
-
// object; this covers the one case it deliberately left closed (a branch with
|
|
1332
|
-
// its own explicit, different verb). The atom-building loop below needs no
|
|
1333
|
-
// change: it already builds one independent {kind:"set", ast} per verb-led
|
|
1334
|
-
// branch regardless of kind, and evalBoolean intersects them the same way a
|
|
1335
|
-
// qualifier atom is intersected (610915a) — this only widens the GATE that lets
|
|
1336
|
-
// that existing machinery fire for a mixed-kind "and" chain too.
|
|
1337
|
-
// NARROWED to a single-WORD later verb (`vh.end - vh.start === 1`) — same
|
|
1338
|
-
// "narrow, low-risk signal" discipline 610915a itself used (it deliberately did
|
|
1339
|
-
// NOT widen to "any boolean connective"): a bare single content word ("call",
|
|
1340
|
-
// "test", "tests") reads unambiguously as its own relation no matter what
|
|
1341
|
-
// follows it, whereas a multi-word verb PHRASE ("couples to", "is a subclass
|
|
1342
|
-
// of", "depends on") is exactly the shape the pre-existing compat guard pins
|
|
1343
|
-
// OFF (ask-compositional.test.mjs:67 and :144, both asserting
|
|
1344
|
-
// `ambiguousParse:true` for "which classes extends Base and couples to
|
|
1345
|
-
// logging" STRICTLY) — "couples to" IS recognized (VERB_TO_KIND maps it to
|
|
1346
|
-
// "imports"; the two-different-recognized-verbs shape is structurally
|
|
1347
|
-
// identical to the target case), so accepting ANY recognized different verb
|
|
1348
|
-
// here regressed both pinned tests when tried; the single-word restriction is
|
|
1349
|
-
// the narrowest rule that admits the required target case ("test") while
|
|
1350
|
-
// leaving the multi-word compat case exactly as closed as it always was.
|
|
960
|
+
// A later AND-branch with its own different but recognized verb ("which
|
|
961
|
+
// functions call X and test Y") is also compositional — narrowed to a
|
|
962
|
+
// single-word later verb so a multi-word verb phrase ("couples to") stays
|
|
963
|
+
// on the legacy ambiguous-parse path.
|
|
1351
964
|
let differentVerbLed = false;
|
|
1352
965
|
if (sameVerbBranches.length > 1) {
|
|
1353
966
|
const firstBlc = sameVerbBranches[0].map((x) => x.toLowerCase());
|
|
@@ -1360,20 +973,13 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1360
973
|
});
|
|
1361
974
|
}
|
|
1362
975
|
}
|
|
1363
|
-
//
|
|
1364
|
-
// compositional query and we must NOT hijack it from the existing parser.
|
|
976
|
+
// Marker gate: without one of these, this isn't a compositional query.
|
|
1365
977
|
if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed || sameVerbLed || differentVerbLed)) return null;
|
|
1366
978
|
|
|
1367
979
|
// empty predicate → a bare qualified class ("public methods")
|
|
1368
980
|
if (!predWords.length) {
|
|
1369
981
|
let base = { node: "allOfClass", entityType };
|
|
1370
982
|
if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
|
|
1371
|
-
// entityType carried on the qualifier node itself too (not just `inner`) — a
|
|
1372
|
-
// top-level "qualifier" AST has no dedicated evalComposite case, so it falls to
|
|
1373
|
-
// the generic {compositeKind:"set", entityType: ast.entityType||null} catch-all;
|
|
1374
|
-
// without this, a zero-match qualifier query ("public methods of X" with no
|
|
1375
|
-
// public methods) rendered a bare "nothing in the index matches that." with no
|
|
1376
|
-
// entity-kind receipt, same wall-shaped miss as a genuinely unrecognized query.
|
|
1377
983
|
return { node: "qualifier", filters: quals, inner: base, entityType };
|
|
1378
984
|
}
|
|
1379
985
|
|
|
@@ -1397,15 +1003,12 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1397
1003
|
const vh = findPhrase(blc, VERB_TO_KIND);
|
|
1398
1004
|
if (vh) prevVerb = bw.slice(vh.start, vh.end);
|
|
1399
1005
|
else if (prevVerb) phrase = [...prevVerb, ...bw];
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
// re-detect the branch's own gerund/relative lead and recurse on identical text.
|
|
1006
|
+
// Parse as nested-or-simple, not back through parseSetPhrase (which would
|
|
1007
|
+
// re-detect this branch's own gerund/relative lead and recurse).
|
|
1403
1008
|
const ast = parseBranchAst(`${subjPrefix} ${phrase.join(" ")}`, nlp, depth + 1);
|
|
1404
1009
|
if (!ast || ast.node === "miss") return { node: "miss", reason: (ast && ast.reason) || "a clause in the combination didn't parse" };
|
|
1405
1010
|
atoms.push({ op, kind: "set", ast });
|
|
1406
1011
|
}
|
|
1407
|
-
// the first atom must be a base set, not a bare qualifier (a qualifier needs
|
|
1408
|
-
// something to filter). "public methods" already took the empty-predicate path above.
|
|
1409
1012
|
if (atoms[0].kind !== "set") return { node: "miss", reason: "start with a clause, then combine with and/or/but-not" };
|
|
1410
1013
|
|
|
1411
1014
|
let result;
|
|
@@ -1414,8 +1017,6 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1414
1017
|
} else {
|
|
1415
1018
|
result = { node: "boolean", entityType, atoms };
|
|
1416
1019
|
}
|
|
1417
|
-
// entityType carried on the qualifier wrapper too — see the identical comment on
|
|
1418
|
-
// the empty-predicate qualifier node above; same catch-all-miss-receipt fix.
|
|
1419
1020
|
if (quals.length) result = { node: "qualifier", filters: quals, inner: result, entityType };
|
|
1420
1021
|
return result;
|
|
1421
1022
|
}
|
|
@@ -1472,7 +1073,7 @@ function reverseOverSet(graph, kind, entityType, objectIds) {
|
|
|
1472
1073
|
const edges = edgesOfKind(graph, symbolKind).filter((e) => objectIds.has(e.object));
|
|
1473
1074
|
return uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter((s) => s && s.class === entityType));
|
|
1474
1075
|
}
|
|
1475
|
-
// GRAIN-AWARE OBJECT SET
|
|
1076
|
+
// GRAIN-AWARE OBJECT SET: when the inner set resolved to
|
|
1476
1077
|
// FINE symbols (functions/methods/…), also scan the symbol-grain sibling — the coarse
|
|
1477
1078
|
// edge (touches Commit→Module, calls Module→Module) can never point AT a symbol, so a
|
|
1478
1079
|
// two-hop whose inner clause produced symbols ("which commits touched the functions
|
|
@@ -1503,12 +1104,9 @@ function uniqueById(inds) {
|
|
|
1503
1104
|
return out;
|
|
1504
1105
|
}
|
|
1505
1106
|
|
|
1506
|
-
/**
|
|
1507
|
-
* `term` — a proper path-segment prefix match
|
|
1508
|
-
* "/"
|
|
1509
|
-
* "src/libfoo/x.mjs". Mirrors renderArchitecture's own pkg-prefix scoping
|
|
1510
|
-
* (codegraph.mjs) but returns individuals rather than a summary string — this is
|
|
1511
|
-
* the "membership" AST node's directory-scope branch (see its call site above). */
|
|
1107
|
+
/** Module individuals whose path lives strictly under the directory named by
|
|
1108
|
+
* `term` — a proper path-segment prefix match, never a bare substring, so
|
|
1109
|
+
* "src/lib" cannot spuriously catch "src/libfoo/x.mjs". */
|
|
1512
1110
|
function directoryScopeModules(graph, term) {
|
|
1513
1111
|
const norm = normPath(term);
|
|
1514
1112
|
if (!norm) return [];
|
|
@@ -1536,34 +1134,23 @@ function qualSets(graph) {
|
|
|
1536
1134
|
qualCache.set(graph, c);
|
|
1537
1135
|
return c;
|
|
1538
1136
|
}
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
// modules (a genuine, currently-untested cross-file edge case), so this file
|
|
1545
|
-
// deliberately keeps its own copy rather than importing codegraph.mjs's.
|
|
1137
|
+
// Known divergence (not a bug, do not merge): this moduleIdOf is
|
|
1138
|
+
// defines-edge-keyed, while codegraph.mjs's own (codegraph.mjs:1198) is
|
|
1139
|
+
// site-attribute-keyed; the two can disagree for a symbol whose site
|
|
1140
|
+
// attribute and defines edge point at different modules, so this file keeps
|
|
1141
|
+
// its own copy rather than importing codegraph.mjs's.
|
|
1546
1142
|
function moduleIdOf(graph, ind) {
|
|
1547
1143
|
if (!ind) return null;
|
|
1548
1144
|
if (ind.class === "Module") return ind.id;
|
|
1549
1145
|
return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
|
|
1550
1146
|
}
|
|
1551
1147
|
|
|
1552
|
-
/**
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
1558
|
-
* "what does fnAlpha mean", a Function, hit this same false vocabulary-miss
|
|
1559
|
-
* wall). Uniqueness is GLOBAL across all these classes together, not per-class:
|
|
1560
|
-
* a name colliding across two different classes stays an honest miss, never a
|
|
1561
|
-
* guess at which one was meant. Extracted (not just inlined in traverse()'s own
|
|
1562
|
-
* meta branch below) so chat.mjs's BARE "what is X" last-resort lane — no
|
|
1563
|
-
* article, so T5's structural parse never even produces a meta shape to reach
|
|
1564
|
-
* traverse() at all (CHATBENCH g-a2-naming-2: "what is Widget") — can reuse the
|
|
1565
|
-
* exact same lookup + wording, rather than risk the two silently drifting
|
|
1566
|
-
* apart. Returns null on anything less than a unique exact hit. */
|
|
1148
|
+
/** Meta fallback to real entities: after a SchemaClass/SchemaPredicate miss,
|
|
1149
|
+
* an exact case-insensitive unique label match against real code-entity
|
|
1150
|
+
* classes, so "what is a Record" answers even though Record isn't a graph
|
|
1151
|
+
* vocabulary term. Uniqueness is global across all these classes together —
|
|
1152
|
+
* a name colliding across two classes stays an honest miss. Returns null on
|
|
1153
|
+
* anything less than a unique exact hit. */
|
|
1567
1154
|
const META_FALLBACK_CLASSES = new Set(["Class", "Function", "Method", "GlobalVariable", "Attribute"]);
|
|
1568
1155
|
export function metaFallbackEntityAnswer(graph, term) {
|
|
1569
1156
|
const termLc = String(term || "").trim().toLowerCase();
|
|
@@ -1585,39 +1172,22 @@ export function metaFallbackEntityAnswer(graph, term) {
|
|
|
1585
1172
|
};
|
|
1586
1173
|
}
|
|
1587
1174
|
|
|
1588
|
-
// ----
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1175
|
+
// ---- membership inheritance cascade: "<kind> of <owner>" walks up `inherits`
|
|
1176
|
+
// when the owner's own surface has nothing, mirroring computeFind's
|
|
1177
|
+
// narrow-then-broaden pass below. ----
|
|
1591
1178
|
|
|
1592
|
-
/**
|
|
1593
|
-
*
|
|
1594
|
-
* given. This IS the un-broadened lookup the "membership" case always ran before
|
|
1595
|
-
* item 6 — extracted so both the plain evalSet case and the inheritance-aware
|
|
1596
|
-
* cascade below (computeMembership) share the exact same one-node lookup. */
|
|
1179
|
+
/** Own-only membership hits for exactly one owner id (never an ancestor),
|
|
1180
|
+
* filtered to `entityType` when given. */
|
|
1597
1181
|
function membershipOwnSet(graph, id, entityType) {
|
|
1598
1182
|
const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, new Set([id]))));
|
|
1599
1183
|
return entityType ? objs.filter((o) => o.class === entityType) : objs;
|
|
1600
1184
|
}
|
|
1601
1185
|
|
|
1602
|
-
/** Resolve a "<kind> of/in <term>" owner term to either a
|
|
1603
|
-
* path prefix with no exact node
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
* `contextId` (HANDOVER.md 2026-07-12 finding, fast-loop round 4): this used to
|
|
1609
|
-
* call bare `resolveObject(graph, term)` with no context-pronoun notion at all —
|
|
1610
|
-
* "methods of that"/"attributes of it" reached resolveObjectCore's ordinary
|
|
1611
|
-
* mechanical tiers with the raw pronoun string, an honest miss at best and, at
|
|
1612
|
-
* worst, a false-positive substring hit (a 2-4 letter pronoun is a near-certain
|
|
1613
|
-
* accidental substring of SOME real label — the exact
|
|
1614
|
-
* STACCATO_LEAKED_CONNECTIVES trap chat.mjs documents for "it"/"and"). Routed
|
|
1615
|
-
* through resolveTermOrContext instead — the SAME contextId-aware resolution
|
|
1616
|
-
* evalQualCheck and traverse()'s reverse/forward shapes already use for
|
|
1617
|
-
* subject-/object-position pronouns — so a pronoun binds to the standing focus
|
|
1618
|
-
* and a non-pronoun term resolves byte-identically to before (resolveTermOrContext
|
|
1619
|
-
* falls through to the same bare `resolveObject` call for anything that isn't a
|
|
1620
|
-
* CONTEXT_PRONOUNS member). */
|
|
1186
|
+
/** Resolve a "<kind> of/in <term>" owner term to either a directory scope (a
|
|
1187
|
+
* bare path prefix with no exact node) or a single container individual.
|
|
1188
|
+
* Routed through resolveTermOrContext (not a bare resolveObject) so a
|
|
1189
|
+
* context pronoun ("methods of that") binds to the standing focus instead of
|
|
1190
|
+
* risking a false-positive substring hit. */
|
|
1621
1191
|
function resolveMembershipOwner(graph, term, contextId = null) {
|
|
1622
1192
|
const r = resolveTermOrContext(graph, term, contextId);
|
|
1623
1193
|
if (!(r.match && r.tier === 1)) {
|
|
@@ -1628,23 +1198,12 @@ function resolveMembershipOwner(graph, term, contextId = null) {
|
|
|
1628
1198
|
return { kind: "single", id: r.match.id, entityClass: r.match.class, label: r.match.label };
|
|
1629
1199
|
}
|
|
1630
1200
|
|
|
1631
|
-
/** The narrow-then-walk inheritance cascade behind a "<kind> of <owner>"
|
|
1632
|
-
* query
|
|
1633
|
-
*
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
* (identically filtered) member set is non-empty. Applying `filterFn` INSIDE the
|
|
1638
|
-
* walk — not once after it returns — is what makes a QUALIFIED query ("public
|
|
1639
|
-
* methods of TaskController") correctly walk up when the owner has own members
|
|
1640
|
-
* but none satisfy the qualifier, rather than stopping early on an unfiltered
|
|
1641
|
-
* non-empty own-set that the qualifier alone would have emptied (see the two
|
|
1642
|
-
* call sites: an omitted/identity `filterFn` is the plain unqualified case).
|
|
1643
|
-
* Returns {own, inherited, viaId, viaLabel} — `inherited`/`viaId`/`viaLabel` are
|
|
1644
|
-
* populated ONLY when the walk actually found something on an ancestor, so a
|
|
1645
|
-
* caller can disclose "inherited from <viaLabel>" rather than silently
|
|
1646
|
-
* presenting an ancestor's members as the owner's own (never-fabricate — the
|
|
1647
|
-
* same discipline computeFind's "related, not exact" broad pass documents). */
|
|
1201
|
+
/** The narrow-then-walk inheritance cascade behind a "<kind> of <owner>"
|
|
1202
|
+
* query: the owner's own (optionally `filterFn`-filtered) members win
|
|
1203
|
+
* outright when non-empty; only when empty does it walk `ancestorsOf`
|
|
1204
|
+
* nearest-first. `filterFn` is applied inside the walk, not after, so a
|
|
1205
|
+
* qualified query ("public methods of TaskController") correctly walks up
|
|
1206
|
+
* when the owner has members but none satisfy the qualifier. */
|
|
1648
1207
|
function computeMembership(graph, ownerId, ownerClass, entityType, filterFn) {
|
|
1649
1208
|
const pass = filterFn || (() => true);
|
|
1650
1209
|
const own = membershipOwnSet(graph, ownerId, entityType).filter(pass);
|
|
@@ -1684,10 +1243,9 @@ function qualHolds(graph, ind, spec) {
|
|
|
1684
1243
|
}
|
|
1685
1244
|
}
|
|
1686
1245
|
|
|
1687
|
-
// ---- predicate-find
|
|
1688
|
-
//
|
|
1689
|
-
//
|
|
1690
|
-
// dynamically via inheritsApplicable, never hardcoded to "Class"). ----
|
|
1246
|
+
// ---- predicate-find: the narrow-then-broaden inheritance cascade over
|
|
1247
|
+
// `inherits` edges, detected dynamically via inheritsApplicable rather than
|
|
1248
|
+
// hardcoded to "Class". ----
|
|
1691
1249
|
|
|
1692
1250
|
/** All `inherits` edges (subject inherits FROM object — the derived class is the
|
|
1693
1251
|
* subject, the base class is the object; RELATIONS.inherits' own comment). */
|
|
@@ -1726,13 +1284,9 @@ function ancestorsOf(graph, id) {
|
|
|
1726
1284
|
}
|
|
1727
1285
|
return out;
|
|
1728
1286
|
}
|
|
1729
|
-
/** Does `entityType` participate in an `inherits`-style subsumption relation
|
|
1730
|
-
*
|
|
1731
|
-
*
|
|
1732
|
-
* extends automatically to any future type that gains such edges; when false, the
|
|
1733
|
-
* broad (ancestor/sibling) pass is simply a no-op and predicate-find degrades to a
|
|
1734
|
-
* flat own-label+attributes match — the common case for Module/Function today.
|
|
1735
|
-
* Memoized per graph (a WeakMap so it never leaks/needs manual invalidation). */
|
|
1287
|
+
/** Does `entityType` participate in an `inherits`-style subsumption relation
|
|
1288
|
+
* today? When false, the broad (ancestor/sibling) pass is a no-op and
|
|
1289
|
+
* predicate-find degrades to a flat own-label+attributes match. */
|
|
1736
1290
|
const inheritsApplicableCache = new WeakMap();
|
|
1737
1291
|
function inheritsApplicable(graph, entityType) {
|
|
1738
1292
|
let byType = inheritsApplicableCache.get(graph);
|
|
@@ -1746,10 +1300,8 @@ function inheritsApplicable(graph, entityType) {
|
|
|
1746
1300
|
return ok;
|
|
1747
1301
|
}
|
|
1748
1302
|
|
|
1749
|
-
/** Does `ind`'s
|
|
1750
|
-
* token of the fuzzy term
|
|
1751
|
-
* tier-3 uses)? Returns "label" | "attr" | null — the provenance tag findSortHits
|
|
1752
|
-
* scores label hits above attribute-only hits (per the match-scope design). */
|
|
1303
|
+
/** Does `ind`'s own property surface (label, or an attribute value) contain
|
|
1304
|
+
* every token of the fuzzy term? Returns "label" | "attr" | null. */
|
|
1753
1305
|
function ownSurfaceHit(ind, termTokens) {
|
|
1754
1306
|
const labelLc = String(ind.label || "").toLowerCase();
|
|
1755
1307
|
if (termTokens.every((tok) => labelLc.includes(tok))) return "label";
|
|
@@ -1757,9 +1309,8 @@ function ownSurfaceHit(ind, termTokens) {
|
|
|
1757
1309
|
if (termTokens.every((tok) => attrs.some((v) => v.includes(tok)))) return "attr";
|
|
1758
1310
|
return null;
|
|
1759
1311
|
}
|
|
1760
|
-
//
|
|
1761
|
-
//
|
|
1762
|
-
// convention resolveObject's own tiers use for a scored tie).
|
|
1312
|
+
// Own-label hits rank above inheritance-chain hits above attribute-only hits;
|
|
1313
|
+
// tie-break by shorter label.
|
|
1763
1314
|
const FIND_TIER = { label: 3, chain: 2, attr: 1 };
|
|
1764
1315
|
function sortFindHits(hits) {
|
|
1765
1316
|
return hits.slice()
|
|
@@ -1767,17 +1318,9 @@ function sortFindHits(hits) {
|
|
|
1767
1318
|
.map((h) => h.ind);
|
|
1768
1319
|
}
|
|
1769
1320
|
|
|
1770
|
-
/** A
|
|
1771
|
-
*
|
|
1772
|
-
*
|
|
1773
|
-
* exact-substring `ownSurfaceHit` test already runs against EVERY individual of
|
|
1774
|
-
* the type (ancestors and siblings included, being ordinary pool members too),
|
|
1775
|
-
* so an exact-substring re-test in the broad pass would be logically vacuous: if
|
|
1776
|
-
* narrow found nothing, no individual's own surface can contain the term as a
|
|
1777
|
-
* substring, full stop. Fuzzy near-matching is what makes the broad pass find
|
|
1778
|
-
* something narrow genuinely couldn't (a typo'd or partial name on a relative),
|
|
1779
|
-
* which is also why a broad-pass hit is always rendered "related, not exact" —
|
|
1780
|
-
* it is a near-miss by construction, not a confident equal. */
|
|
1321
|
+
/** A bounded-fuzzy (Damerau-Levenshtein) near-match of the whole term against
|
|
1322
|
+
* `ind`'s label or any of its components. Used only by the broad pass —
|
|
1323
|
+
* always rendered "related, not exact", since it's a near-miss by construction. */
|
|
1781
1324
|
function fuzzyFindHit(ind, term) {
|
|
1782
1325
|
const tLc = String(term || "").trim().toLowerCase();
|
|
1783
1326
|
if (tLc.length < 4) return false; // same floor resolveObject's tier-5 uses
|
|
@@ -1789,28 +1332,13 @@ function fuzzyFindHit(ind, term) {
|
|
|
1789
1332
|
return false;
|
|
1790
1333
|
}
|
|
1791
1334
|
|
|
1792
|
-
/** The narrow-then-broaden search behind
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1799
|
-
* answer — never silently widened when a specific answer exists (the same
|
|
1800
|
-
* discipline Bug C's grain-aware resolution establishes). Every individual of
|
|
1801
|
-
* the type is tested here, ancestors and siblings included (they are ordinary
|
|
1802
|
-
* pool members too) — so an EMPTY narrow pass means no individual's own
|
|
1803
|
-
* surface anywhere in the pool contains the term as a substring.
|
|
1804
|
-
* 2. BROAD — only when the narrow pass is EMPTY across the WHOLE pool AND the
|
|
1805
|
-
* type participates in `inherits`: for each candidate, walk UP to its
|
|
1806
|
-
* superclass(es); a bounded-FUZZY near-match (fuzzyFindHit, above — never a
|
|
1807
|
-
* repeat of narrow's exact test, which the previous point shows would find
|
|
1808
|
-
* nothing new) on a superclass's own surface counts, and so does one on that
|
|
1809
|
-
* superclass's OTHER direct children (siblings) — always rendered as
|
|
1810
|
-
* "related, not exact" (renderComposite), never an unqualified match.
|
|
1811
|
-
* Returns {narrow, broad} — `broad` is only ever non-empty when `narrow` is empty.
|
|
1812
|
-
* When the type has no inherits edges at all, the broad pass is a no-op and this
|
|
1813
|
-
* degrades to a flat own-label+attributes match (Module/Function today). */
|
|
1335
|
+
/** The narrow-then-broaden search behind "find": narrow tests every
|
|
1336
|
+
* `entityType` individual's own surface (plus descendants' surfaces, when
|
|
1337
|
+
* the type participates in `inherits`) for every term token; a non-empty
|
|
1338
|
+
* narrow pass is the whole answer. Only when narrow is empty does broad walk
|
|
1339
|
+
* up to superclasses/siblings with a bounded-fuzzy near-match, always
|
|
1340
|
+
* rendered "related, not exact". Returns {narrow, broad} — broad is only
|
|
1341
|
+
* ever non-empty when narrow is empty. */
|
|
1814
1342
|
function computeFind(graph, entityType, term) {
|
|
1815
1343
|
const pool = graph.individuals.filter((i) => i.class === entityType);
|
|
1816
1344
|
const termTokens = [...componentSet(term)];
|
|
@@ -1854,24 +1382,20 @@ function evalSet(graph, ast, opts) {
|
|
|
1854
1382
|
switch (ast.node) {
|
|
1855
1383
|
case "clause": return traverse(graph, ast.clause, opts).matches || [];
|
|
1856
1384
|
case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
// cascade's result, transparently flattened (the "related, not exact" framing
|
|
1860
|
-
// is a top-level RENDER concern — evalComposite's dedicated "find" handling
|
|
1861
|
-
// below, not this generic embedding).
|
|
1385
|
+
// Predicate-find as a set atom: the narrow-then-broaden cascade's result,
|
|
1386
|
+
// transparently flattened ("related, not exact" is a render concern).
|
|
1862
1387
|
case "find": {
|
|
1863
1388
|
const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
|
|
1864
1389
|
return narrow.length ? narrow : broad;
|
|
1865
1390
|
}
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
// differences off allOfClass to yield "modules that import nothing".
|
|
1391
|
+
// Subjects with any edge of a kind; an existential negation differences
|
|
1392
|
+
// this off allOfClass to yield "modules that import nothing".
|
|
1869
1393
|
case "existsEdge": {
|
|
1870
1394
|
const subs = new Set(kindsFor(ast.kind).flatMap((k) => edgesOfKind(graph, k)).map((e) => e.subject));
|
|
1871
1395
|
return graph.individuals.filter((i) => subs.has(i.id) && (!ast.entityType || i.class === ast.entityType));
|
|
1872
1396
|
}
|
|
1873
|
-
//
|
|
1874
|
-
//
|
|
1397
|
+
// Forward complement: the verb's object-grain universe minus what the
|
|
1398
|
+
// subject reaches via that verb ("what doesn't it import").
|
|
1875
1399
|
case "forwardComplement": {
|
|
1876
1400
|
const r = resolveTermOrContext(graph, ast.subjectTerm, opts && opts.contextId);
|
|
1877
1401
|
if (!r.match) return []; // unresolved subject / focus-less pronoun → honest empty
|
|
@@ -1888,11 +1412,9 @@ function evalSet(graph, ast, opts) {
|
|
|
1888
1412
|
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
1889
1413
|
return forwardOverSet(graph, ast.kind, ids);
|
|
1890
1414
|
}
|
|
1891
|
-
//
|
|
1892
|
-
//
|
|
1893
|
-
//
|
|
1894
|
-
// previous answer" miss, same as evalAnaphora's own no-prev branch; this is only
|
|
1895
|
-
// reached with a real, non-empty `prev` in hand.
|
|
1415
|
+
// The previous list-shaped answer's own id set. Only reached with a real,
|
|
1416
|
+
// non-empty `prev` — the no-`prev` case is intercepted earlier as an
|
|
1417
|
+
// honest "needs a previous answer" miss.
|
|
1896
1418
|
case "prevSet": {
|
|
1897
1419
|
const prev = opts && opts.prev;
|
|
1898
1420
|
return Array.isArray(prev) ? prev.map((id) => graph.byId.get(id)).filter(Boolean) : [];
|
|
@@ -1917,10 +1439,10 @@ function evalSet(graph, ast, opts) {
|
|
|
1917
1439
|
return objs.filter((o) => o.class === ast.entityType);
|
|
1918
1440
|
}
|
|
1919
1441
|
if (owner.kind === "miss") return [];
|
|
1920
|
-
//
|
|
1442
|
+
// The owner's own members win outright when non-empty;
|
|
1921
1443
|
// only an EMPTY own set walks up `inherits` (computeMembership) — see its
|
|
1922
1444
|
// own doc above. evalSet's flat-array embedding (a find-seed inside a
|
|
1923
|
-
// boolean/qualifier fold
|
|
1445
|
+
// boolean/qualifier fold) transparently flattens own vs. inherited,
|
|
1924
1446
|
// same as evalSet's "find" case does for computeFind's narrow/broad split;
|
|
1925
1447
|
// the DISCLOSED (never-silent) version lives in evalMembershipComposite,
|
|
1926
1448
|
// below, for the top-level (and qualifier-wrapped) membership query shapes.
|
|
@@ -1929,8 +1451,8 @@ function evalSet(graph, ast, opts) {
|
|
|
1929
1451
|
}
|
|
1930
1452
|
case "qualifier": {
|
|
1931
1453
|
// a qualifier wrapping a MEMBERSHIP inner needs the filter applied INSIDE
|
|
1932
|
-
// the inheritance walk, at each level, not once after a flat resolve
|
|
1933
|
-
//
|
|
1454
|
+
// the inheritance walk, at each level, not once after a flat resolve —
|
|
1455
|
+
// see computeMembership's own doc: a
|
|
1934
1456
|
// class can own a non-empty member set that the qualifier alone empties
|
|
1935
1457
|
// out, which must still walk up rather than stopping on the unfiltered own
|
|
1936
1458
|
// set). evalMembershipComposite is the single source of truth for this;
|
|
@@ -1969,12 +1491,9 @@ function evalBoolean(graph, ast, opts) {
|
|
|
1969
1491
|
return acc;
|
|
1970
1492
|
}
|
|
1971
1493
|
|
|
1972
|
-
/** Resolve parseAnaphora's in-sentence candidateTerms
|
|
1973
|
-
*
|
|
1974
|
-
*
|
|
1975
|
-
* from "named candidates that happen to fail resolution" and fall back to
|
|
1976
|
-
* opts.prev either way — never a guess, same discipline as everywhere else in
|
|
1977
|
-
* this function. */
|
|
1494
|
+
/** Resolve parseAnaphora's in-sentence candidateTerms to real graph entities,
|
|
1495
|
+
* de-duplicated by resolved id. Returns null (not []) when fewer than 2
|
|
1496
|
+
* resolve, so the caller falls back to opts.prev either way. */
|
|
1978
1497
|
function resolveInSentenceCandidates(graph, terms) {
|
|
1979
1498
|
if (!Array.isArray(terms) || terms.length < 2) return null;
|
|
1980
1499
|
const seen = new Set();
|
|
@@ -1986,11 +1505,9 @@ function resolveInSentenceCandidates(graph, terms) {
|
|
|
1986
1505
|
return resolved.length >= 2 ? resolved : null;
|
|
1987
1506
|
}
|
|
1988
1507
|
|
|
1989
|
-
/** Anaphora over the candidate set:
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
* genuine previous-turn follow-up), filtered/counted the same way either way. No
|
|
1993
|
-
* candidate set at all → honest miss (never a guess), like an unresolved pronoun. */
|
|
1508
|
+
/** Anaphora over the candidate set: the current utterance's in-sentence named
|
|
1509
|
+
* entities, tried first, or ask()'s `prev` id array otherwise. No candidate
|
|
1510
|
+
* set at all is an honest miss. */
|
|
1994
1511
|
function evalAnaphora(graph, ast, opts) {
|
|
1995
1512
|
const inSentence = resolveInSentenceCandidates(graph, ast.candidateTerms);
|
|
1996
1513
|
let baseItems = inSentence;
|
|
@@ -2007,21 +1524,17 @@ function evalAnaphora(graph, ast, opts) {
|
|
|
2007
1524
|
const r = resolveObject(graph, f.clause.object);
|
|
2008
1525
|
if (!r.match) items = [];
|
|
2009
1526
|
else {
|
|
2010
|
-
//
|
|
2011
|
-
// not just
|
|
1527
|
+
// Include the symbol-grain sibling so a fn->fn "call" filter tests
|
|
1528
|
+
// callsSymbol, not just module-coarse "calls".
|
|
2012
1529
|
const sib = SYMBOL_GRAIN_SIBLING[f.clause.kind];
|
|
2013
1530
|
const kinds = [...kindsFor(f.clause.kind), ...(sib ? [sib] : [])];
|
|
2014
1531
|
const ok = new Set(kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === r.match.id).map((e) => e.subject));
|
|
2015
1532
|
items = items.filter((ind) => ok.has(ind.id));
|
|
2016
1533
|
}
|
|
2017
1534
|
}
|
|
2018
|
-
//
|
|
2019
|
-
//
|
|
2020
|
-
//
|
|
2021
|
-
// names what was checked ("nothing in the index matches that (methods)."
|
|
2022
|
-
// instead of a bare, kind-less "nothing in the index matches that.") — the
|
|
2023
|
-
// filter genuinely found no survivors, but the entity kind it filtered is not
|
|
2024
|
-
// itself unknown, so the miss shouldn't read as if it were.
|
|
1535
|
+
// A count over a prior set names the entity kind when survivors share a
|
|
1536
|
+
// class; fall back to the prior set's own class when the filter empties it,
|
|
1537
|
+
// so the honest-empty render still names what was checked.
|
|
2025
1538
|
const sameClass = (list) => (list.length && list.every((x) => x.class === list[0].class) ? list[0].class : null);
|
|
2026
1539
|
const common = items.length ? sameClass(items) : sameClass(baseItems);
|
|
2027
1540
|
if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
|
|
@@ -2032,13 +1545,9 @@ function evalAnaphora(graph, ast, opts) {
|
|
|
2032
1545
|
// commit-history kinds are excluded so "connections" reads as the code-structure
|
|
2033
1546
|
// degree a developer means, not every recorded touch.
|
|
2034
1547
|
const DEGREE_KINDS = ["imports", "calls", "callsSymbol", "inherits", "contains", "tests"];
|
|
2035
|
-
/** Degree of an individual under a superlative metric ({kind, dir, sibling?,
|
|
2036
|
-
* Exported
|
|
2037
|
-
*
|
|
2038
|
-
* degree for a single named entity ("how many callers does X have") that this
|
|
2039
|
-
* file's own evalSuperlative already uses to rank every entity of a class
|
|
2040
|
-
* ("which module has the most callers") — one metric definition
|
|
2041
|
-
* (EDGE_NOUN_TO_METRIC), one degree computation, two call sites. */
|
|
1548
|
+
/** Degree of an individual under a superlative metric ({kind, dir, sibling?,
|
|
1549
|
+
* filter?}). Exported so chat.mjs's answerEdgeCount shares this computation
|
|
1550
|
+
* for a single-entity query. */
|
|
2042
1551
|
export function degreeMetric(graph, ind, metric) {
|
|
2043
1552
|
const kinds = metric.kind === "*" ? DEGREE_KINDS : [metric.kind, ...(metric.sibling ? [metric.sibling] : [])];
|
|
2044
1553
|
let n = 0;
|
|
@@ -2052,12 +1561,8 @@ export function degreeMetric(graph, ind, metric) {
|
|
|
2052
1561
|
}
|
|
2053
1562
|
return n;
|
|
2054
1563
|
}
|
|
2055
|
-
/**
|
|
2056
|
-
*
|
|
2057
|
-
* RECENT_COMMIT_LEAD/parseRelationalOrQualified above). Reuses the SAME
|
|
2058
|
-
* dateOf/localeCompare sort every other Commit-date reader in this file already
|
|
2059
|
-
* uses (ISO-8601 sorts correctly lexically). An empty graph is an honest empty,
|
|
2060
|
-
* never a guess. */
|
|
1564
|
+
/** Every Commit individual, newest date first (ISO-8601 sorts correctly
|
|
1565
|
+
* lexically). */
|
|
2061
1566
|
function evalRecentCommits(graph) {
|
|
2062
1567
|
const commits = graph.individuals.filter((i) => i.class === "Commit");
|
|
2063
1568
|
const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
|
|
@@ -2066,12 +1571,9 @@ function evalRecentCommits(graph) {
|
|
|
2066
1571
|
}
|
|
2067
1572
|
|
|
2068
1573
|
const COMMIT_FILTER_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
2069
|
-
/**
|
|
2070
|
-
*
|
|
2071
|
-
*
|
|
2072
|
-
* A pivot that IS itself a commit is excluded from its own comparison. An
|
|
2073
|
-
* unresolvable pivot (neither a date nor a known commit) declines honestly
|
|
2074
|
-
* (pivotResolved:false) rather than guessing an empty result. */
|
|
1574
|
+
/** Resolves the pivot (a literal ISO date, or a named commit whose own date
|
|
1575
|
+
* becomes the pivot), then filters every Commit's date against it. An
|
|
1576
|
+
* unresolvable pivot declines honestly (pivotResolved:false). */
|
|
2075
1577
|
function evalCommitFilter(graph, ast) {
|
|
2076
1578
|
const { op, pivotRaw } = ast;
|
|
2077
1579
|
const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10);
|
|
@@ -2100,18 +1602,13 @@ function evalCommitFilter(graph, ast) {
|
|
|
2100
1602
|
return { compositeKind: "commitFilter", op, pivotRaw, pivotDate, pivotResolved: true, matches };
|
|
2101
1603
|
}
|
|
2102
1604
|
|
|
2103
|
-
/**
|
|
2104
|
-
* inner set, newest
|
|
2105
|
-
* flat when-shape runs (mgx:commitDate is ISO-8601, so a lexical sort IS a date sort;
|
|
2106
|
-
* undated commits sort last and render says so). `entityType` is the inner noun, only
|
|
2107
|
-
* for phrasing. An empty inner set (nothing resolved) or no touching commit is an
|
|
2108
|
-
* honest empty — never a guess. */
|
|
1605
|
+
/** Temporal over a nested set: the commits that touched any member of the
|
|
1606
|
+
* inner set, newest date first. `entityType` is the inner noun, for phrasing only. */
|
|
2109
1607
|
function evalTemporal(graph, ast, opts) {
|
|
2110
1608
|
const inner = evalSet(graph, ast.inner, opts);
|
|
2111
1609
|
const ids = new Set(inner.map((i) => i.id));
|
|
2112
1610
|
if (!ids.size) return { compositeKind: "temporal", matches: [], entityType: ast.entityType, innerCount: 0 };
|
|
2113
|
-
// reverseOverSet(touches) collects
|
|
2114
|
-
// grain-aware object-set branch reads touchesSymbol when the inner set is symbols).
|
|
1611
|
+
// reverseOverSet(touches) collects touching commits across both grains.
|
|
2115
1612
|
const commits = reverseOverSet(graph, "touches", "Commit", ids);
|
|
2116
1613
|
const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
|
|
2117
1614
|
commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
|
|
@@ -2128,20 +1625,11 @@ function evalSuperlative(graph, ast) {
|
|
|
2128
1625
|
return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
|
|
2129
1626
|
}
|
|
2130
1627
|
|
|
2131
|
-
/**
|
|
2132
|
-
*
|
|
2133
|
-
*
|
|
2134
|
-
*
|
|
2135
|
-
*
|
|
2136
|
-
* (which would wrongly stop on a non-empty-but-unfiltered own set that the
|
|
2137
|
-
* qualifier alone would empty out — see computeMembership's own doc). Unlike
|
|
2138
|
-
* evalSet's embedded (flattening) "membership"/"qualifier" cases — used when this
|
|
2139
|
-
* shape is nested inside a boolean fold — this keeps the inheritance provenance
|
|
2140
|
-
* (`inheritedNotOwn`/`viaLabel`/`ownerLabel`) so renderComposite can disclose an
|
|
2141
|
-
* ancestor-sourced answer ("TaskController has no own public methods — inherited
|
|
2142
|
-
* from Controller: render.") rather than ever silently presenting an ancestor's
|
|
2143
|
-
* members as the owner's own. A directory scope (no single owner individual) has
|
|
2144
|
-
* no inheritance chain to walk — unaffected by item 6, same behavior as before. */
|
|
1628
|
+
/** Top-level "<kind> of/in <owner>" membership eval, covering both a bare
|
|
1629
|
+
* membership node and a qualifier node wrapping one. Keeps inheritance
|
|
1630
|
+
* provenance (`inheritedNotOwn`/`viaLabel`/`ownerLabel`) so renderComposite
|
|
1631
|
+
* can disclose an ancestor-sourced answer instead of presenting it as the
|
|
1632
|
+
* owner's own. */
|
|
2145
1633
|
function evalMembershipComposite(graph, ast, opts) {
|
|
2146
1634
|
const qualNode = ast.node === "qualifier" && ast.inner.node === "membership" ? ast : null;
|
|
2147
1635
|
const memNode = qualNode ? qualNode.inner : ast;
|
|
@@ -2169,14 +1657,10 @@ function evalMembershipComposite(graph, ast, opts) {
|
|
|
2169
1657
|
};
|
|
2170
1658
|
}
|
|
2171
1659
|
|
|
2172
|
-
/**
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2175
|
-
*
|
|
2176
|
-
* the pool to the asked kind, so "is there a class called Store" can never
|
|
2177
|
-
* resolve to a same-named function/module); a scope clause resolves the module
|
|
2178
|
-
* the same way and narrows the check to that module's own `defines` edges
|
|
2179
|
-
* (refineToEntities — the same primitive members-of-a-module questions use). */
|
|
1660
|
+
/** Existence eval — a direct membership/name check against the graph, never
|
|
1661
|
+
* routed through the relation-verb machinery. `expectedClass` pins the
|
|
1662
|
+
* resolve pool so "is there a class called Store" can't resolve to a
|
|
1663
|
+
* same-named function/module. */
|
|
2180
1664
|
function evalExists(graph, ast) {
|
|
2181
1665
|
const { entityType, term, scopeModule } = ast;
|
|
2182
1666
|
let scopeMatch = null;
|
|
@@ -2200,13 +1684,9 @@ function evalExists(graph, ast) {
|
|
|
2200
1684
|
return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
|
|
2201
1685
|
}
|
|
2202
1686
|
|
|
2203
|
-
/**
|
|
2204
|
-
* contextId,
|
|
2205
|
-
*
|
|
2206
|
-
* `holds` here means "the STATEMENT AS ASKED is true" (negation already
|
|
2207
|
-
* folded in), so the renderer can answer Yes/No directly off it without
|
|
2208
|
-
* re-deriving the negation. An unresolved term is an honest miss, never a
|
|
2209
|
-
* guess — no different from any other named-object lookup. */
|
|
1687
|
+
/** Qualifier-check eval: resolves the term (a context pronoun binds through
|
|
1688
|
+
* contextId), then reads qualHolds(). `holds` means "the statement as asked
|
|
1689
|
+
* is true" (negation already folded in). */
|
|
2210
1690
|
function evalQualCheck(graph, ast, opts) {
|
|
2211
1691
|
const { term, qualifier, negated } = ast;
|
|
2212
1692
|
const r = resolveTermOrContext(graph, term, opts.contextId);
|
|
@@ -2230,17 +1710,13 @@ export function evalComposite(graph, ast, opts = {}) {
|
|
|
2230
1710
|
if (ast.node === "recentCommits") return evalRecentCommits(graph);
|
|
2231
1711
|
if (ast.node === "commitFilter") return evalCommitFilter(graph, ast);
|
|
2232
1712
|
if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
|
|
2233
|
-
//
|
|
2234
|
-
//
|
|
2235
|
-
// evalMembershipComposite's own doc for why the two share one function and why
|
|
2236
|
-
// this keeps disclosure provenance the embedded evalSet path deliberately drops.
|
|
1713
|
+
// Membership inheritance cascade, top-level (keeps disclosure provenance
|
|
1714
|
+
// the embedded evalSet path drops).
|
|
2237
1715
|
if (ast.node === "membership" || (ast.node === "qualifier" && ast.inner.node === "membership")) {
|
|
2238
1716
|
return evalMembershipComposite(graph, ast, opts);
|
|
2239
1717
|
}
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
// the broad-pass provenance so renderComposite can label a "related, not exact"
|
|
2243
|
-
// hit distinctly rather than presenting it as an unqualified match.
|
|
1718
|
+
// Predicate-find, top-level: keeps broad-pass provenance so renderComposite
|
|
1719
|
+
// can label a "related, not exact" hit distinctly.
|
|
2244
1720
|
if (ast.node === "find") {
|
|
2245
1721
|
const { narrow, broad } = computeFind(graph, ast.entityType, ast.term);
|
|
2246
1722
|
return {
|
|
@@ -2248,10 +1724,8 @@ export function evalComposite(graph, ast, opts = {}) {
|
|
|
2248
1724
|
matches: narrow.length ? narrow : broad, broad: !narrow.length && broad.length > 0,
|
|
2249
1725
|
};
|
|
2250
1726
|
}
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
// miss evalAnaphora's own no-prev branch gives "of those"/"count them", rather than
|
|
2254
|
-
// evalSet's ordinary (and here misleading) empty-set "nothing in the index matches".
|
|
1727
|
+
// Plural-anaphora object: an empty `prev` means "those"/"them" has no
|
|
1728
|
+
// antecedent, an honest miss rather than evalSet's misleading empty-set message.
|
|
2255
1729
|
if ((ast.node === "reverseSet" || ast.node === "forwardSet") && ast.inner.node === "prevSet"
|
|
2256
1730
|
&& !(Array.isArray(opts.prev) && opts.prev.length)) {
|
|
2257
1731
|
return { compositeMiss: true, reason: "no-prev", matches: [] };
|
|
@@ -2259,22 +1733,20 @@ export function evalComposite(graph, ast, opts = {}) {
|
|
|
2259
1733
|
return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
|
|
2260
1734
|
}
|
|
2261
1735
|
|
|
2262
|
-
// ---- compositional
|
|
2263
|
-
// as renderCore. ----
|
|
1736
|
+
// ---- compositional render: templated, same "honest miss vs cited hit"
|
|
1737
|
+
// discipline as renderCore. ----
|
|
2264
1738
|
|
|
2265
1739
|
const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
|
|
2266
1740
|
.map((m) => (["Function", "Method"].includes(m.class) ? `${m.label}()` : m.label)))
|
|
2267
1741
|
+ (matches.length > OVERFLOW_CAP ? `, …and ${matches.length - OVERFLOW_CAP} more` : "");
|
|
2268
1742
|
|
|
2269
|
-
/** A compositional worked example for the rephrase hint
|
|
2270
|
-
* compositional phrasing too). */
|
|
1743
|
+
/** A compositional worked example for the rephrase hint. */
|
|
2271
1744
|
export function compositionalHint() {
|
|
2272
1745
|
return 'compositional queries also work: "which functions call X and call Y", "what calls something that imports X", "public methods of X", "list functions" / "show me the classes", "how many classes", "which module has the most imports", "find me the payment class", or (after a listing) "which of those are tested"';
|
|
2273
1746
|
}
|
|
2274
1747
|
|
|
2275
|
-
/** A short citation line for a
|
|
2276
|
-
* when known
|
|
2277
|
-
* condensed to one line since there is exactly one hit to cite). */
|
|
1748
|
+
/** A short citation line for a single predicate-find hit — the module it
|
|
1749
|
+
* lives in, when known. */
|
|
2278
1750
|
function describeFindHit(ind) {
|
|
2279
1751
|
const label = ["Function", "Method"].includes(ind.class) ? `${ind.label}()` : ind.label;
|
|
2280
1752
|
if (ind.class === "Module") return label;
|
|
@@ -2289,9 +1761,6 @@ function renderComposite(parsed, result) {
|
|
|
2289
1761
|
}
|
|
2290
1762
|
return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
|
|
2291
1763
|
}
|
|
2292
|
-
// exists: "is there a/an <kind> [called/named <term>] [in <module>]" — an
|
|
2293
|
-
// honest Yes/No membership check, never routed through the relation-verb
|
|
2294
|
-
// machinery (see parseExistence's own doc for the bug this fixes).
|
|
2295
1764
|
if (result.compositeKind === "exists") {
|
|
2296
1765
|
if (result.scopeMiss) {
|
|
2297
1766
|
return { content: `no module matching "${result.scopeModule}" found in the index.`, miss: true, ambiguous: false };
|
|
@@ -2313,12 +1782,6 @@ function renderComposite(parsed, result) {
|
|
|
2313
1782
|
}
|
|
2314
1783
|
return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2315
1784
|
}
|
|
2316
|
-
// qualCheck: "is <term> [a/an] <qualifier> […]" — the single-entity sibling of
|
|
2317
|
-
// "is there a/an <kind> …" above: a direct Yes/No over one already-named/-
|
|
2318
|
-
// focused individual, never a set listing. `result.holds` already has the
|
|
2319
|
-
// negation folded in (evalQualCheck), so the renderer states the actual truth
|
|
2320
|
-
// plainly — "No — X is tested." for "is X not tested" when X IS tested, never
|
|
2321
|
-
// an echo of the (now-false) question's own wording.
|
|
2322
1785
|
if (result.compositeKind === "qualCheck") {
|
|
2323
1786
|
if (result.qualCheckMiss === "pronoun") {
|
|
2324
1787
|
return { content: `"${result.term}" needs a selected node to refer to — click a node first, or name it directly.`, miss: true, ambiguous: false };
|
|
@@ -2342,24 +1805,16 @@ function renderComposite(parsed, result) {
|
|
|
2342
1805
|
if (!result.matches.length) {
|
|
2343
1806
|
return { content: `no ${nounFor(result.entityType, 2)} in this index.`, miss: true, ambiguous: false, matches: [] };
|
|
2344
1807
|
}
|
|
2345
|
-
//
|
|
2346
|
-
//
|
|
2347
|
-
// <module>" scope is meaningless); the scoped forms are already narrow, no hint.
|
|
2348
|
-
// Memory-graph classes (Fact/Utterance/Session/Source/Rule, dynamicClassQuery
|
|
2349
|
-
// above) never support a module scope either — there's no such parse for them —
|
|
2350
|
-
// so they're excluded here too rather than hinting at an unsupported shape.
|
|
1808
|
+
// Kinds that don't live in a module (or have no module-scope parse at
|
|
1809
|
+
// all, like memory-graph classes) get no narrow-by-module hint.
|
|
2351
1810
|
const scopeable = !["Module", "Commit", "Fact", "Utterance", "Session", "Source", "Rule"].includes(result.entityType);
|
|
2352
1811
|
const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
|
|
2353
1812
|
? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
|
|
2354
1813
|
: "";
|
|
2355
1814
|
return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2356
1815
|
}
|
|
2357
|
-
//
|
|
2358
|
-
//
|
|
2359
|
-
// set was empty, an ancestor's wasn't) is disclosed OUT LOUD — "X has no own
|
|
2360
|
-
// <kind> — inherited from <ancestor>: …" — never silently presented as though
|
|
2361
|
-
// the owner declared them itself (never-fabricate, the same discipline
|
|
2362
|
-
// computeFind's "related, not exact" broad pass documents for predicate-find).
|
|
1816
|
+
// A non-empty inherited result is disclosed out loud ("X has no own <kind>
|
|
1817
|
+
// — inherited from <ancestor>: …"), never silently presented as the owner's own.
|
|
2363
1818
|
if (result.compositeKind === "membership") {
|
|
2364
1819
|
if (!result.matches.length) {
|
|
2365
1820
|
return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}. ${touchesRephraseHint()}`, miss: true, ambiguous: false, matches: [] };
|
|
@@ -2374,10 +1829,9 @@ function renderComposite(parsed, result) {
|
|
|
2374
1829
|
}
|
|
2375
1830
|
return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2376
1831
|
}
|
|
2377
|
-
// predicate-find
|
|
1832
|
+
// predicate-find: zero hits -> an honest miss naming BOTH the type
|
|
2378
1833
|
// and the term; the broad ("related, not exact") pass is ALWAYS clearly labeled,
|
|
2379
|
-
// never presented as an unqualified match
|
|
2380
|
-
// C's grain-aware resolution established; one hit -> a short citation; many hits
|
|
1834
|
+
// never presented as an unqualified match; one hit -> a short citation; many hits
|
|
2381
1835
|
// -> the standard compositeList/OVERFLOW_CAP convention, reused verbatim.
|
|
2382
1836
|
if (result.compositeKind === "find") {
|
|
2383
1837
|
const typeNoun = nounFor(result.entityType, 1);
|
|
@@ -2393,10 +1847,10 @@ function renderComposite(parsed, result) {
|
|
|
2393
1847
|
}
|
|
2394
1848
|
return { content: `${cited}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2395
1849
|
}
|
|
2396
|
-
//
|
|
1850
|
+
// Bare "recent/latest/newest commits" — a real dated commit
|
|
2397
1851
|
// list, newest first, capped at HISTORY_CAP for consistency with renderFileHistory/
|
|
2398
1852
|
// renderSymbolHistory's own listing convention (codegraph.mjs) — never the false
|
|
2399
|
-
// "no Commit found matching 'recent'" find-miss this
|
|
1853
|
+
// "no Commit found matching 'recent'" find-miss this would otherwise fall into.
|
|
2400
1854
|
if (result.compositeKind === "recentCommits") {
|
|
2401
1855
|
if (!result.matches.length) return { content: `no commits recorded in this index.`, miss: true, ambiguous: false, matches: [] };
|
|
2402
1856
|
const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
|
|
@@ -2411,7 +1865,7 @@ function renderComposite(parsed, result) {
|
|
|
2411
1865
|
miss: false, ambiguous: false, matches: result.matches,
|
|
2412
1866
|
};
|
|
2413
1867
|
}
|
|
2414
|
-
//
|
|
1868
|
+
// "what changed since/before/after/on
|
|
2415
1869
|
// <date-or-commit>" — same dated-list rendering convention as recentCommits just
|
|
2416
1870
|
// above, scoped to the resolved pivot. An unresolvable pivot names itself and the
|
|
2417
1871
|
// two supported pivot shapes (never a silent guess); a resolved pivot with no
|
|
@@ -2480,57 +1934,35 @@ function renderComposite(parsed, result) {
|
|
|
2480
1934
|
return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2481
1935
|
}
|
|
2482
1936
|
|
|
2483
|
-
/** The rephrase hint shown on a grammar miss — generated from the
|
|
2484
|
-
* uses, so it can never suggest
|
|
1937
|
+
/** The rephrase hint shown on a grammar miss — generated from the same
|
|
1938
|
+
* tables the parser uses, so it can never suggest an unsupported phrasing. */
|
|
2485
1939
|
export function rephraseHint() {
|
|
2486
|
-
//
|
|
2487
|
-
//
|
|
2488
|
-
//
|
|
2489
|
-
// NEVER the subject of a touch: "which modules touched X" always misses, for every X, no
|
|
2490
|
-
// matter the graph (verified against test/fixtures/entities.fixture.json — every reverse
|
|
2491
|
-
// combination with "touched" and a functions/classes/modules subject returns zero
|
|
2492
|
-
// matches). The real reverse subject for this edge is Commit — "which commits touched
|
|
2493
|
-
// <name>" below, mirroring the working "who touched <name>" nudge used elsewhere
|
|
2494
|
-
// (chat.mjs's nudgeAnswer).
|
|
1940
|
+
// `touches` is a Commit->Module/symbol edge, so a Function/Class/Module is
|
|
1941
|
+
// never the subject of a touch; the real reverse subject is Commit
|
|
1942
|
+
// ("which commits touched <name>" below).
|
|
2495
1943
|
return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests> <name>" or "what does <name> <import|call|export>" or "what uses <name>" or "where is <name> defined" / "where is <name> mentioned" or "when did <name> change" or "which commits touched <name>" or "which changes touch commit <sha>"/"what did commit <sha> touch" (a commit\'s own changes) or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary). '
|
|
2496
1944
|
+ compositionalHint();
|
|
2497
1945
|
}
|
|
2498
1946
|
|
|
2499
|
-
/** A short, honest nudge for the touches/history-family of correct-but-
|
|
2500
|
-
*
|
|
2501
|
-
*
|
|
2502
|
-
*
|
|
2503
|
-
* wasn't found but gave no nudge toward a question that WOULD work). Points at the
|
|
2504
|
-
* same "who touched X" / "/describe X" shapes that produce a real answer elsewhere
|
|
2505
|
-
* in this file (see whenShape/whoLastShape's own success template, "X was last
|
|
2506
|
-
* touched by commit …", a few lines below) — never promises any PARTICULAR name
|
|
2507
|
-
* will resolve, since the whole point of the miss it's attached to is that this
|
|
2508
|
-
* one didn't. Sibling of rephraseHint() above: same purpose, narrower vocabulary,
|
|
2509
|
-
* reused verbatim by every touches/history-family miss template rather than each
|
|
2510
|
-
* one inventing its own wording. */
|
|
1947
|
+
/** A short, honest nudge for the touches/history-family of correct-but-
|
|
1948
|
+
* unhelpful misses, pointing at "who touched X" / "/describe X" shapes that
|
|
1949
|
+
* produce a real answer elsewhere. Reused verbatim by every miss template
|
|
1950
|
+
* in this family. */
|
|
2511
1951
|
export function touchesRephraseHint() {
|
|
2512
1952
|
return 'Try "who touched <a module that actually has commits>" or "/describe <module>" to see what\'s in the index.';
|
|
2513
1953
|
}
|
|
2514
1954
|
|
|
2515
|
-
// ----
|
|
1955
|
+
// ---- object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
|
|
2516
1956
|
|
|
2517
1957
|
function componentSet(s) {
|
|
2518
1958
|
return new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
2519
1959
|
}
|
|
2520
1960
|
|
|
2521
|
-
/** A minimal,
|
|
2522
|
-
* bridge (
|
|
2523
|
-
*
|
|
2524
|
-
*
|
|
2525
|
-
*
|
|
2526
|
-
* suffix set ("ing"/"er"/"ers"/"or"/"ors" — the gerund and agent-noun endings that
|
|
2527
|
-
* regularly pair up in English: "logging"/"logger", "routing"/"router") off the
|
|
2528
|
-
* END of the word, then collapses a doubled trailing letter the strip exposed (the
|
|
2529
|
-
* consonant-doubling spelling short CVC roots take before -ing/-er: "log" ->
|
|
2530
|
-
* "logging"/"logger", both reducing here to "log"). Length-floored at 5 BEFORE
|
|
2531
|
-
* stripping so it can never fire on a short word and reopen the accidental-
|
|
2532
|
-
* short-word-match bug tier 3's other floors guard against; returns the word
|
|
2533
|
-
* unchanged (so callers can detect "no-op" via `=== w`) when no suffix matches. */
|
|
1961
|
+
/** A minimal, closed derivational-suffix normalizer for tier 3's
|
|
1962
|
+
* Module-basename bridge (not a general stemmer): strips one gerund/agent-noun
|
|
1963
|
+
* suffix ("ing"/"er"/"ers"/"or"/"ors") and collapses a doubled trailing
|
|
1964
|
+
* consonant ("logging"/"logger" -> "log"). Length-floored at 5 to avoid
|
|
1965
|
+
* short-word false matches; returns the word unchanged when no suffix matches. */
|
|
2534
1966
|
function derivationalStem(w) {
|
|
2535
1967
|
if (w.length < 5) return w;
|
|
2536
1968
|
const stripped = w.replace(/(ing|ers|ors|er|or)$/, "");
|
|
@@ -2540,17 +1972,10 @@ function derivationalStem(w) {
|
|
|
2540
1972
|
: stripped;
|
|
2541
1973
|
}
|
|
2542
1974
|
|
|
2543
|
-
/** Strip
|
|
2544
|
-
* trailing
|
|
2545
|
-
* label
|
|
2546
|
-
* user would type
|
|
2547
|
-
* "paymentsystem", "westfield-payment-system/src/MyCode.cs" ->
|
|
2548
|
-
* "westfieldpaymentsystemsrcmycode", "IPaymentSystemImpl.cs" -> "ipaymentsystemimpl".
|
|
2549
|
-
* Used by the compound-term tier just below (multi-word query bridge, 2026-07-09
|
|
2550
|
-
* compound-name fix, item: "match symbols where the question breaks a symbol into
|
|
2551
|
-
* 2 words"). The extension strip is deliberately the SAME single-trailing-
|
|
2552
|
-
* extension regex tier 3's own `stem` computation already uses elsewhere in this
|
|
2553
|
-
* file — not reinvented. */
|
|
1975
|
+
/** Strip explicit separator characters only (path slashes, hyphens,
|
|
1976
|
+
* underscores, trailing extension) — never camelCase boundaries — so a
|
|
1977
|
+
* label collapses to the same joined lowercase token a naming-convention-
|
|
1978
|
+
* blind user would type ("PaymentSystem"/"payment-system" -> "paymentsystem"). */
|
|
2554
1979
|
function joinedForm(label) {
|
|
2555
1980
|
return String(label || "")
|
|
2556
1981
|
.replace(/\.[a-z0-9]+$/i, "")
|
|
@@ -2558,11 +1983,9 @@ function joinedForm(label) {
|
|
|
2558
1983
|
.replace(/[/\-_.]+/g, "");
|
|
2559
1984
|
}
|
|
2560
1985
|
|
|
2561
|
-
/** Same joined-token normalization as joinedForm(), applied to the
|
|
2562
|
-
* multi-word term: a leading article is stripped first
|
|
2563
|
-
*
|
|
2564
|
-
* joined form), then whitespace/hyphens/underscores between words collapse out —
|
|
2565
|
-
* "the payment system" -> "payment system" -> "paymentsystem". */
|
|
1986
|
+
/** Same joined-token normalization as joinedForm(), applied to the query side
|
|
1987
|
+
* of a multi-word term: a leading article is stripped first so "the payment
|
|
1988
|
+
* system" and "payment system" produce the identical joined form. */
|
|
2566
1989
|
function joinedQueryForm(term) {
|
|
2567
1990
|
return String(term || "")
|
|
2568
1991
|
.trim()
|
|
@@ -2571,63 +1994,31 @@ function joinedQueryForm(term) {
|
|
|
2571
1994
|
.replace(/[\s\-_]+/g, "");
|
|
2572
1995
|
}
|
|
2573
1996
|
|
|
2574
|
-
/** Resolve a free-text object/subject term against the graph's individuals, in
|
|
2575
|
-
* order
|
|
2576
|
-
*
|
|
2577
|
-
*
|
|
2578
|
-
*
|
|
2579
|
-
*
|
|
2580
|
-
*
|
|
2581
|
-
*
|
|
2582
|
-
*
|
|
2583
|
-
*
|
|
2584
|
-
*
|
|
2585
|
-
* fallback (PLAN_PROSE_INDEX.md §6): the term, tokenized the same way as a docstring, is
|
|
2586
|
-
* looked up against `graph.proseIndex` via `lookupByProseTokens` — an exact WORD-level
|
|
2587
|
-
* overlap match against a symbol's decomposed-identifier or doc-comment tokens, never a
|
|
2588
|
-
* substring/fuzzy guess (the same "no wrong edge" standard as tiers 1-3, just over a
|
|
2589
|
-
* different token source: prose rather than the literal identifier). Only ever consulted
|
|
2590
|
-
* once every literal-identifier tier above has failed, and the result is tagged
|
|
2591
|
-
* `matchedVia: "prose"` so a caller can tell the match came from prose content rather
|
|
2592
|
-
* than the symbol's own name — the render layer does not currently read this (it treats
|
|
2593
|
-
* a resolved match as a resolved match, same "honest miss vs genuine hit" binary tiers
|
|
2594
|
-
* 1-3 already use), but the field is there for any caller that wants to surface it. (5)
|
|
2595
|
-
* a bounded Damerau-Levenshtein pass against labels AND label components (two-level
|
|
2596
|
-
* fuzzy, 2026-07-02): a UNIQUE within-bound match resolves, tagged `matchedVia:
|
|
2597
|
-
* "fuzzy"` so render() can say "assuming you meant <label>" out loud; multiple
|
|
2598
|
-
* matches at the same best distance are an honest ambiguity listing the candidates.
|
|
2599
|
-
* Never applied to sha-shaped terms (the commit namespace is exact-or-ambiguous
|
|
2600
|
-
* only) nor to terms under 4 chars (the bound would cover half of everything).
|
|
2601
|
-
* (6) no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
|
|
2602
|
-
* [, matchedVia]} — ambiguous on a true tier-3 score tie, a tier-4 overlap-count
|
|
2603
|
-
* tie, or a tier-5 distance tie.
|
|
1997
|
+
/** Resolve a free-text object/subject term against the graph's individuals, in
|
|
1998
|
+
* priority order: a sha-shaped term first resolves against Commit
|
|
1999
|
+
* individuals by unique id/label prefix, then (1) exact label/id match, (2)
|
|
2000
|
+
* an `ext:` unresolved-target match (a synthetic match with no real
|
|
2001
|
+
* individual), (3) boundary-aware substring/component match, (4) a
|
|
2002
|
+
* prose-index fallback (exact word-level overlap against decomposed-
|
|
2003
|
+
* identifier or doc-comment tokens, tagged `matchedVia: "prose"`), (5) a
|
|
2004
|
+
* bounded Damerau-Levenshtein pass against labels and components, tagged
|
|
2005
|
+
* `matchedVia: "fuzzy"` — never applied to sha-shaped or sub-4-char terms.
|
|
2006
|
+
* (6) no match: an honest miss. Returns {match, candidates, tier, ambiguous
|
|
2007
|
+
* [, matchedVia]}; ambiguous on a same-tier score/distance tie.
|
|
2604
2008
|
*
|
|
2605
|
-
* `opts.expectedClass
|
|
2606
|
-
*
|
|
2607
|
-
*
|
|
2608
|
-
* universe it ranks over shrinks. The ext: tier (synthetic matches with
|
|
2609
|
-
* `class: null`, never a real individual) is skipped outright when a class is
|
|
2610
|
-
* expected — it can never BE that class. The prose tier (tier 4) filters its hits
|
|
2611
|
-
* to the expected class before picking a winner. Every existing call site passes
|
|
2612
|
-
* no 3rd argument, so `expectedClass` defaults to null and behavior is
|
|
2613
|
-
* byte-identical to before this option existed — this is purely opt-in narrowing
|
|
2614
|
-
* for a caller (traverse()'s reverse case) that already knows what class the
|
|
2615
|
-
* relation's object slot expects ("which modules import logger" must never
|
|
2616
|
-
* resolve "logger" to a same-stem Class). */
|
|
2009
|
+
* `opts.expectedClass`, when set, narrows the candidate pool before every
|
|
2010
|
+
* pool-driven tier — opt-in grain-aware resolution so "which modules import
|
|
2011
|
+
* logger" never resolves "logger" to a same-stem Class. */
|
|
2617
2012
|
function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
2618
2013
|
const t = String(term || "").trim();
|
|
2619
2014
|
if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
|
|
2620
2015
|
const tLc = t.toLowerCase();
|
|
2621
2016
|
const pool = expectedClass ? graph.individuals.filter((i) => i.class === expectedClass) : graph.individuals;
|
|
2622
2017
|
|
|
2623
|
-
//
|
|
2624
|
-
//
|
|
2625
|
-
//
|
|
2626
|
-
//
|
|
2627
|
-
// closed commit namespace (tier 1); a prefix shared by more than one commit is an
|
|
2628
|
-
// honest ambiguity listing the candidates — never "the first one"; a hex-looking
|
|
2629
|
-
// word matching NO commit falls through to the ordinary tiers unchanged (it may
|
|
2630
|
-
// be a real code identifier).
|
|
2018
|
+
// Commit-sha tier: a hex-looking term resolves against Commit individuals
|
|
2019
|
+
// by id/label prefix. A unique prefix is exact-grade (tier 1); a shared
|
|
2020
|
+
// prefix is an honest ambiguity; no match falls through (it may be a real
|
|
2021
|
+
// code identifier) unless the explicit "commit" noun declared intent.
|
|
2631
2022
|
const shaTerm = tLc.match(/^(commit[:\s])?([0-9a-f]{7,40})$/);
|
|
2632
2023
|
if (shaTerm) {
|
|
2633
2024
|
const sha = shaTerm[2];
|
|
@@ -2635,19 +2026,14 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2635
2026
|
&& (String(i.id).toLowerCase().startsWith(`commit:${sha}`) || String(i.label).toLowerCase().startsWith(sha)));
|
|
2636
2027
|
if (hits.length === 1) return { match: hits[0], candidates: [], tier: 1, ambiguous: false };
|
|
2637
2028
|
if (hits.length > 1) return { match: hits[0], candidates: hits.slice(1, 5), tier: 1, ambiguous: true };
|
|
2638
|
-
// the explicit "commit" noun declares intent — with no matching commit, falling
|
|
2639
|
-
// through would let the WORD "commit" component-match the Commit schema node (or
|
|
2640
|
-
// any identifier containing it): a guess, not a resolution. Bare hex still falls
|
|
2641
|
-
// through (it may be a real code identifier).
|
|
2642
2029
|
if (shaTerm[1]) return { match: null, candidates: [], tier: null, ambiguous: false };
|
|
2643
2030
|
}
|
|
2644
2031
|
|
|
2645
2032
|
const exact = pool.find((i) => String(i.label).toLowerCase() === tLc || String(i.id).toLowerCase() === tLc);
|
|
2646
2033
|
if (exact) return { match: exact, candidates: [], tier: 1, ambiguous: false };
|
|
2647
2034
|
|
|
2648
|
-
// ext: targets
|
|
2649
|
-
//
|
|
2650
|
-
// typo'd term can't silently "resolve" to an ext: id nothing in the graph references.
|
|
2035
|
+
// ext: targets have no individual of their own; find the case-preserved id
|
|
2036
|
+
// off a real edge so a typo'd term can't silently "resolve" to one.
|
|
2651
2037
|
const extLc = `ext:${tLc}`;
|
|
2652
2038
|
let extId = null;
|
|
2653
2039
|
outer: for (const g of graph.relations) {
|
|
@@ -2655,22 +2041,12 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2655
2041
|
if (String(e.object).toLowerCase() === extLc) { extId = e.object; break outer; }
|
|
2656
2042
|
}
|
|
2657
2043
|
}
|
|
2658
|
-
// ext: matches are synthetic (class: null, no real individual) — with a class
|
|
2659
|
-
// expected, they can never satisfy it, so skip this tier entirely rather than
|
|
2660
|
-
// returning a match whose class silently doesn't match what the caller asked for.
|
|
2661
2044
|
if (extId && !expectedClass) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
|
|
2662
2045
|
|
|
2663
|
-
//
|
|
2664
|
-
//
|
|
2665
|
-
//
|
|
2666
|
-
//
|
|
2667
|
-
// contains the text ("res.json" -> test/res.json.js — the wrong grain presented
|
|
2668
|
-
// as if the term were that file). Such terms now match only (a) symbol labels
|
|
2669
|
-
// (whole-term containment, or the ".member" suffix when the owner alias differs:
|
|
2670
|
-
// "res.json" -> Response.json), and (b) module labels by EXACT basename equality
|
|
2671
|
-
// ("walk.mjs" -> src/walk.mjs — extension-stripped basename equality is
|
|
2672
|
-
// deliberately NOT used; that is precisely the phantom-path vector). Symbol
|
|
2673
|
-
// matches outrank module matches. Undotted/slashed terms keep the original pass.
|
|
2046
|
+
// Tier 3, two regimes: a dotted term with no slash ("res.json") is
|
|
2047
|
+
// symbol-shaped, so it matches only symbol labels or a module by exact
|
|
2048
|
+
// basename equality (never a phantom substring-of-path match); undotted/
|
|
2049
|
+
// slashed terms keep the original containment pass.
|
|
2674
2050
|
const scored = [];
|
|
2675
2051
|
const dotted = !tLc.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(tLc);
|
|
2676
2052
|
if (dotted) {
|
|
@@ -2686,106 +2062,43 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2686
2062
|
}
|
|
2687
2063
|
}
|
|
2688
2064
|
} else {
|
|
2689
|
-
//
|
|
2690
|
-
//
|
|
2691
|
-
//
|
|
2692
|
-
//
|
|
2693
|
-
//
|
|
2694
|
-
// (ambiguous), "in"->Logger.info, "on"->sendJson, "at"->createApp,
|
|
2695
|
-
// "to"->Store, all via this exact branch. Cycle 8 patched three of these
|
|
2696
|
-
// ("and"/"also"/"so"/"then"/"now" and bare "it") one word at a time at
|
|
2697
|
-
// individual chat.mjs CALL SITES (STACCATO_LEAKED_CONNECTIVES, the
|
|
2698
|
-
// pronoun-reuse guard) — necessary there because those bugs are about
|
|
2699
|
-
// FOCUS bookkeeping, not resolution per se, but leaving resolveObject
|
|
2700
|
-
// itself unguarded meant every OTHER caller (existence checks, expectedClass
|
|
2701
|
-
// lookups, etc.) stayed exposed to the same trap for every not-yet-hit
|
|
2702
|
-
// short word. Gating the containment check at the same floor tier 5's own
|
|
2703
|
-
// fuzzy pass already uses (`tLc.length >= 4` below) closes it at the
|
|
2704
|
-
// source. A whole-token component match (just below) is unaffected by this
|
|
2705
|
-
// floor — it requires an EXACT path/identifier segment equality, never a
|
|
2706
|
-
// raw substring, so a genuinely short real identifier ("db", "fs") is
|
|
2707
|
-
// still resolvable by literally matching a whole segment.
|
|
2065
|
+
// Raw containment has no minimum-length floor, so a short word is a
|
|
2066
|
+
// near-certain accidental substring of some real label ("so"->sendJson).
|
|
2067
|
+
// Gating it at the same floor tier 5's fuzzy pass uses (>= 4 chars, below)
|
|
2068
|
+
// closes it; a whole-token component match is unaffected since it
|
|
2069
|
+
// requires exact segment equality, not a substring.
|
|
2708
2070
|
const termComps = [...componentSet(t)];
|
|
2709
|
-
// A
|
|
2710
|
-
//
|
|
2711
|
-
//
|
|
2712
|
-
// word. Isolated from the actual whitespace-delimited PATH TOKEN (not the
|
|
2713
|
-
// raw multi-word string as a whole) — "app/lib/f.mjs but untested" (a
|
|
2714
|
-
// trailing-noise leak, distinct from the leading-verb-noise shape above)
|
|
2715
|
-
// has no extension at the end of the whole string, so splitting the whole
|
|
2716
|
-
// string would strand "f.mjs but untested" as a bogus non-matching "stem";
|
|
2717
|
-
// finding the one token that itself contains "/" keeps the path term
|
|
2718
|
-
// intact regardless of what noise surrounds it on either side. Only
|
|
2719
|
-
// slash-shaped terms compute this; a bare identifier/multi-word query has
|
|
2720
|
-
// no path structure to anchor on, so it's null and the gate below is a
|
|
2721
|
-
// no-op for those (unaffected — original ANY-overlap behavior).
|
|
2071
|
+
// A slashed term's final path segment, extension stripped — the
|
|
2072
|
+
// filename stem, isolated from surrounding noise ("app/lib/f.mjs but
|
|
2073
|
+
// untested" must not strand "f.mjs but untested" as a bogus stem).
|
|
2722
2074
|
const pathToken = tLc.split(/\s+/).find((tok) => tok.includes("/"));
|
|
2723
2075
|
const slashStem = pathToken ? pathToken.split("/").pop().replace(/\.[a-z0-9]+$/, "") : null;
|
|
2724
|
-
// Compound-term bridge
|
|
2725
|
-
//
|
|
2726
|
-
//
|
|
2727
|
-
//
|
|
2728
|
-
// so "payment system" never equals/contains/overlaps "PaymentSystem" or
|
|
2729
|
-
// "payment-system" by those checks even though a human reads them as the same
|
|
2730
|
-
// concept. Computed ONCE per query (article-stripped, space-collapsed — see
|
|
2731
|
-
// joinedQueryForm) and checked per-candidate below via each candidate's OWN
|
|
2732
|
-
// joinedForm(). Single-word queries are unaffected: isMultiWord is false, the
|
|
2733
|
-
// branch below never fires, and they resolve exactly as before through the
|
|
2734
|
-
// tiers already in this loop.
|
|
2076
|
+
// Compound-term bridge: a query with 2+ space-separated words ("payment
|
|
2077
|
+
// system") is compared against each candidate's own joinedForm(), since
|
|
2078
|
+
// the literal tLc comparisons above/below never match a spaceless label
|
|
2079
|
+
// ("PaymentSystem"). Single-word queries are unaffected.
|
|
2735
2080
|
const qWords = t.trim().replace(/^(?:the|a|an)\s+/i, "").trim().split(/\s+/).filter(Boolean);
|
|
2736
2081
|
const isMultiWord = qWords.length >= 2;
|
|
2737
2082
|
const qJoined = isMultiWord ? joinedQueryForm(t) : null;
|
|
2738
2083
|
for (const m of pool) {
|
|
2739
2084
|
const label = String(m.label || "").toLowerCase();
|
|
2740
|
-
// Basename-exact/prefix/suffix tier
|
|
2741
|
-
//
|
|
2742
|
-
//
|
|
2743
|
-
//
|
|
2744
|
-
//
|
|
2745
|
-
// for a bare (unslashed) term, since `stem` is compared directly against the whole
|
|
2746
|
-
// `tLc` — a slashed query term (e.g. "src/nope.mjs") already contains "/" and can
|
|
2747
|
-
// never equal/prefix/suffix a bare stem, so it falls through unaffected to the
|
|
2748
|
-
// existing slashStem-gated overlap logic just below, unchanged.
|
|
2749
|
-
// The prefix/suffix half (not the exact-equality half) shares the SAME sub-4-char
|
|
2750
|
-
// floor as the containment tier just below it — an unguarded stem.startsWith(tLc)
|
|
2751
|
-
// reintroduces the exact short-word accidental-match bug that floor was added to
|
|
2752
|
-
// close (e.g. bare "so" prefix-matching "someOtherFile"'s stem). A full stem
|
|
2753
|
-
// EQUALITY, at any length, is never an accidental substring — "db"/"fs"-shaped
|
|
2754
|
-
// short real identifiers must still resolve — so it stays unguarded.
|
|
2085
|
+
// Basename-exact/prefix/suffix tier: a bare term that IS a file's
|
|
2086
|
+
// basename outranks a sibling that merely shares a directory/component.
|
|
2087
|
+
// Prefix/suffix shares tier 5's sub-4-char floor; exact equality is
|
|
2088
|
+
// never an accidental substring, so it stays unguarded (short real
|
|
2089
|
+
// identifiers like "db"/"fs" must still resolve).
|
|
2755
2090
|
const stem = label.split("/").pop().replace(/\.[a-z0-9]+$/, "");
|
|
2756
2091
|
if (stem === tLc) { scored.push({ ind: m, score: 5000 }); continue; }
|
|
2757
2092
|
if (tLc.length >= 4 && (stem.startsWith(tLc) || stem.endsWith(tLc))) {
|
|
2758
2093
|
scored.push({ ind: m, score: 4000 - Math.abs(stem.length - tLc.length) });
|
|
2759
2094
|
continue;
|
|
2760
2095
|
}
|
|
2761
|
-
// Compound-term bridge
|
|
2762
|
-
//
|
|
2763
|
-
//
|
|
2764
|
-
//
|
|
2765
|
-
//
|
|
2766
|
-
//
|
|
2767
|
-
// above (5000) — it is equally strong evidence, just phrased with spaces.
|
|
2768
|
-
// A CONTAINMENT match ("payment system" found inside "westfield-payment-
|
|
2769
|
-
// system"'s or "IPaymentSystemImpl.cs"'s joined form) is scored strictly
|
|
2770
|
-
// BELOW every single-word tier above (a real single-word substring/prefix/
|
|
2771
|
-
// suffix hit is stronger evidence than a multi-word query merely appearing
|
|
2772
|
-
// somewhere in a longer joined string) but ABOVE the raw component-overlap
|
|
2773
|
-
// tier further below (score <= 10). CONTAINMENT is additionally gated on the
|
|
2774
|
-
// candidate label carrying an EXPLICIT separator (path slash, hyphen,
|
|
2775
|
-
// underscore, or a real file extension) — a pure-camelCase label with none
|
|
2776
|
-
// of those (e.g. Function "calculateTotalPrice") is deliberately left to
|
|
2777
|
-
// tier 4's prose/decomposed-identifier fallback below, which already owns
|
|
2778
|
-
// exactly that "query words are a sub-sequence of a compound identifier's
|
|
2779
|
-
// OWN decomposed tokens" territory (frozen test: "total price" ->
|
|
2780
|
-
// calculateTotalPrice must resolve at tier 4 via matchedVia:"prose", not
|
|
2781
|
-
// tier 3) — without this gate, EVERY multi-word query touching prose
|
|
2782
|
-
// territory would be silently reclassified as a tier-3 containment hit and
|
|
2783
|
-
// break that precedent. The EXACT tier just above has no such gate: an
|
|
2784
|
-
// exact joined-form equality is unambiguous evidence regardless of whether
|
|
2785
|
-
// the label happens to use an explicit separator (PascalCase "PaymentSystem"
|
|
2786
|
-
// included) — only the fuzzier CONTAINMENT check needs the extra guard.
|
|
2787
|
-
// Gated on isMultiWord so a single-word query is never affected (it already
|
|
2788
|
-
// resolves via the tiers above/below, unchanged).
|
|
2096
|
+
// Compound-term bridge, multi-word analog of the tier above: an exact
|
|
2097
|
+
// joined-form match scores the same as exact-stem; a containment match
|
|
2098
|
+
// scores below every single-word tier but above raw overlap, and is
|
|
2099
|
+
// gated on an explicit separator so a pure-camelCase label is left to
|
|
2100
|
+
// tier 4's prose fallback instead (frozen test: "total price" ->
|
|
2101
|
+
// calculateTotalPrice must resolve via matchedVia:"prose", not tier 3).
|
|
2789
2102
|
if (isMultiWord) {
|
|
2790
2103
|
const candJoined = joinedForm(m.label);
|
|
2791
2104
|
if (candJoined && candJoined === qJoined) { scored.push({ ind: m, score: 5000 }); continue; }
|
|
@@ -2795,29 +2108,11 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2795
2108
|
continue;
|
|
2796
2109
|
}
|
|
2797
2110
|
}
|
|
2798
|
-
// Derivational-suffix basename bridge
|
|
2799
|
-
//
|
|
2800
|
-
//
|
|
2801
|
-
//
|
|
2802
|
-
//
|
|
2803
|
-
// dotted-branch's own Module-only exact-basename special case just above
|
|
2804
|
-
// in this file), specifically so it can never also fire for a same-stem
|
|
2805
|
-
// Class/Method/Function sharing the same root ("Logger", "Logger.info")
|
|
2806
|
-
// and manufacture a false three-way tie; a bare-word class-ambiguity like
|
|
2807
|
-
// that is exactly the documented, already-accepted expectedClass-gated
|
|
2808
|
-
// case this function's own docblock calls out ("logger" -> Class), left
|
|
2809
|
-
// untouched. Scored below the literal exact/prefix/suffix tiers above (a
|
|
2810
|
-
// real substring is always stronger evidence) but above plain containment/
|
|
2811
|
-
// overlap (a genuine one-suffix-away basename match is still far more
|
|
2812
|
-
// specific than an accidental shared word). Guarded against tier 5's OWN
|
|
2813
|
-
// territory: a term that is merely a near-miss TYPO of an already-close
|
|
2814
|
-
// literal stem ("loging" for "logging", 1 edit away) must still fall
|
|
2815
|
-
// through to the bounded-fuzzy tier and be ANNOUNCED ("assuming you
|
|
2816
|
-
// meant…") — it is not a distinct derivational word-form, it is the same
|
|
2817
|
-
// word misspelled — so this bridge only fires when the term is OUTSIDE
|
|
2818
|
-
// the fuzzy tier's own distance bound (a real morphological pair like
|
|
2819
|
-
// "logging"/"logger" is 3 edits apart, well past tier 5's 2-edit budget
|
|
2820
|
-
// for words this length, so there is no overlap between the two tiers).
|
|
2111
|
+
// Derivational-suffix basename bridge: a term that's a Module's own
|
|
2112
|
+
// basename one suffix-swap away ("logging" for basename "logger") is
|
|
2113
|
+
// still a name match — Module-only, so it can't collide with a
|
|
2114
|
+
// same-stem Class. Guarded against tier 5's territory: a near-miss typo
|
|
2115
|
+
// ("loging" for "logging") must still fall through to bounded-fuzzy.
|
|
2821
2116
|
if (m.class === "Module") {
|
|
2822
2117
|
const termRoot = derivationalStem(tLc);
|
|
2823
2118
|
if (termRoot !== tLc && termRoot === derivationalStem(stem)) {
|
|
@@ -2839,7 +2134,7 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2839
2134
|
// real module that merely shares its generic directory/extension
|
|
2840
2135
|
// segments ("src", "mjs") with every other module in the pool (found
|
|
2841
2136
|
// via the existence recognizer's "is there a class in src/nope.mjs"
|
|
2842
|
-
// scope clause
|
|
2137
|
+
// scope clause: it would otherwise ambiguously "match"
|
|
2843
2138
|
// src/core/model.mjs even though no such module exists — the same
|
|
2844
2139
|
// accidental-match disease as the short-word substring bug just above,
|
|
2845
2140
|
// just triggered by GENERIC components instead of raw containment).
|
|
@@ -2871,7 +2166,7 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2871
2166
|
};
|
|
2872
2167
|
}
|
|
2873
2168
|
|
|
2874
|
-
// tier 4: prose-index fallback
|
|
2169
|
+
// tier 4: prose-index fallback — see the function doc above.
|
|
2875
2170
|
// The typeof guard is the same viewer-bundle boundary as defaultNlp(): viz.mjs's
|
|
2876
2171
|
// askSource strips the prose.mjs import but does not inline prose.mjs, so in the
|
|
2877
2172
|
// browser `lookupByProseTokens` is an undeclared identifier — without the guard,
|
|
@@ -2882,12 +2177,10 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2882
2177
|
// reappearing through a side door — a dotted term names an identifier, and
|
|
2883
2178
|
// identifiers resolve by label (tiers above) or the bounded fuzzy pass
|
|
2884
2179
|
// below, or they honestly miss. The SAME side door was open for SLASHED
|
|
2885
|
-
// path terms too
|
|
2886
|
-
// "src/nope.mjs" — a nonexistent module — no longer false-matches tier 3
|
|
2180
|
+
// path terms too: "src/nope.mjs" — a nonexistent module — no longer false-matches tier 3
|
|
2887
2181
|
// (the AND-across-components fix just above), but fell through to THIS
|
|
2888
2182
|
// prose tier and ambiguously "matched" real modules anyway, because
|
|
2889
|
-
// lookupByProseTokens scores by ANY-token overlap
|
|
2890
|
-
// for genuine prose ranking) and "src"/"mjs" are near-universal path/
|
|
2183
|
+
// lookupByProseTokens scores by ANY-token overlap and "src"/"mjs" are near-universal path/
|
|
2891
2184
|
// extension tokens shared by every module in the pool — the identical
|
|
2892
2185
|
// accidental-match disease, just one tier further down. A slash-shaped term
|
|
2893
2186
|
// names a literal path exactly like a dotted term names a literal symbol;
|
|
@@ -2923,11 +2216,8 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2923
2216
|
}
|
|
2924
2217
|
}
|
|
2925
2218
|
|
|
2926
|
-
//
|
|
2927
|
-
//
|
|
2928
|
-
// and their components. sha-shaped terms never reach here (guard above);
|
|
2929
|
-
// sub-4-char terms are excluded because a 1-edit budget on 3 chars matches far
|
|
2930
|
-
// too much to ever be a unique intent.
|
|
2219
|
+
// Tier 5: bounded fuzzy, one honest chance for a typo'd identifier. Sub-4-char
|
|
2220
|
+
// terms are excluded — a 1-edit budget on 3 chars matches far too much.
|
|
2931
2221
|
if (!shaTerm && tLc.length >= 4) {
|
|
2932
2222
|
const bound = fuzzyBound(tLc);
|
|
2933
2223
|
let best = bound + 1;
|
|
@@ -2947,53 +2237,29 @@ function resolveObjectCore(graph, term, { expectedClass = null } = {}) {
|
|
|
2947
2237
|
return { match: hits[0], candidates: [], tier: 5, ambiguous: false, matchedVia: "fuzzy" };
|
|
2948
2238
|
}
|
|
2949
2239
|
if (best <= bound && hits.length > 1 && !proseResult) {
|
|
2950
|
-
// equidistant fuzzy tie with no prose evidence either — honest ambiguity.
|
|
2951
2240
|
const [bestInd, ...rest] = hits;
|
|
2952
2241
|
return { match: bestInd, candidates: rest.slice(0, 4), tier: 5, ambiguous: true, matchedVia: "fuzzy" };
|
|
2953
2242
|
}
|
|
2954
2243
|
}
|
|
2955
|
-
// fuzzy couldn't resolve uniquely: surface the prose tie (when there was one)
|
|
2956
|
-
// exactly as before, else the honest miss.
|
|
2957
2244
|
return proseResult || { match: null, candidates: [], tier: null, ambiguous: false };
|
|
2958
2245
|
}
|
|
2959
2246
|
|
|
2960
2247
|
/** A leading article is pure noise on a structural entity term ("the logger" ==
|
|
2961
2248
|
* "logger") — mirrors memory/core.mjs's normFactTerm article-strip for taught
|
|
2962
|
-
* facts
|
|
2249
|
+
* facts, applied here on the graph-resolution side instead. */
|
|
2963
2250
|
const LEADING_ARTICLE_RE = /^(?:the|a|an)\s+/i;
|
|
2964
2251
|
|
|
2965
|
-
/** A trailing
|
|
2966
|
-
*
|
|
2967
|
-
*
|
|
2968
|
-
*
|
|
2969
|
-
* component and can manufacture an accidental TIE between the actual module and
|
|
2970
|
-
* any same-stem Class/Method sharing its name (Tier 6 playtest, found live:
|
|
2971
|
-
* "the logger module" tied mod:src/lib/logger.mjs against fn:...#Logger and
|
|
2972
|
-
* fn:...#Logger.info, all scoring identically on the shared "logger" component
|
|
2973
|
-
* once "module"/"the" themselves matched nothing — the caller's own
|
|
2974
|
-
* `!ambiguous` gate then silently declined the whole thing, walling
|
|
2975
|
-
* moduleOrientLane/"describe the logger module"/"where is the logger module
|
|
2976
|
-
* defined"/etc. even though a human reads "module" as fully disambiguating).
|
|
2977
|
-
* Reuses ENTITY_TO_TYPE (ask-vocab.mjs) — the SAME closed noun→grain table the
|
|
2978
|
-
* grammar's own entity-slot parsing already trusts — so this never invents a
|
|
2979
|
-
* new vocabulary, only a new place the existing one gets consulted. */
|
|
2252
|
+
/** A trailing generic grain word ("the logger MODULE") is a type hint to
|
|
2253
|
+
* disambiguate which grain is meant, but tier 3's plain word-overlap scoring
|
|
2254
|
+
* can't read grammatical role — it becomes an ordinary component and can tie
|
|
2255
|
+
* the module against a same-stem Class/Method. Reuses ENTITY_TO_TYPE. */
|
|
2980
2256
|
const TRAILING_GRAIN_WORD_RE = new RegExp(`\\s+(${Object.keys(ENTITY_TO_TYPE).join("|")})$`, "i");
|
|
2981
2257
|
|
|
2982
|
-
/** resolveObject:
|
|
2983
|
-
* resolveObjectCore
|
|
2984
|
-
*
|
|
2985
|
-
*
|
|
2986
|
-
*
|
|
2987
|
-
* leading-article strip) narrows the pool to that ONE grain and retries on
|
|
2988
|
-
* just the head noun — closing exactly the accidental-tie class this
|
|
2989
|
-
* docblock above describes; (2) failing that, a plain leading-article strip
|
|
2990
|
-
* alone (no grain word) — "the logger" resolves the same way bare "logger"
|
|
2991
|
-
* always has. Either retry is used ONLY on an unambiguous hit; any miss/tie
|
|
2992
|
-
* falls through unchanged to the ORIGINAL (unstripped) term via
|
|
2993
|
-
* resolveObjectCore, so this is purely additive — a term that already
|
|
2994
|
-
* resolved before resolves exactly the same way now (a multi-word "the X
|
|
2995
|
-
* module"-shaped term never equals a real label outright, so the exact-match
|
|
2996
|
-
* tier the pre-pass could theoretically shadow is never actually in play). */
|
|
2258
|
+
/** resolveObject: a grain-aware disambiguation pre-pass wrapping
|
|
2259
|
+
* resolveObjectCore, only when the caller hasn't pinned an expectedClass.
|
|
2260
|
+
* Tries a trailing grain word (narrows the pool, retries the head noun),
|
|
2261
|
+
* then a plain leading-article strip. Either retry is used only on an
|
|
2262
|
+
* unambiguous hit; any miss/tie falls through unchanged to the original term. */
|
|
2997
2263
|
export function resolveObject(graph, term, opts = {}) {
|
|
2998
2264
|
const { expectedClass = null } = opts;
|
|
2999
2265
|
if (!expectedClass) {
|
|
@@ -3076,26 +2342,14 @@ function commitTouches(graph, commit, entityType, extra = {}) {
|
|
|
3076
2342
|
};
|
|
3077
2343
|
}
|
|
3078
2344
|
|
|
3079
|
-
/** Safety net
|
|
3080
|
-
*
|
|
3081
|
-
*
|
|
3082
|
-
*
|
|
3083
|
-
* never a silent fallback to direct-only behavior. This means a future
|
|
3084
|
-
* MODIFIER_TO_KIND addition that forgets to wire traverse()/render() for it fails
|
|
3085
|
-
* loud here, by construction, rather than quietly behaving as if the modifier had
|
|
3086
|
-
* never been given (the exact bug class this file's own render-routing fix, above,
|
|
3087
|
-
* just caught). Today's only non-"direct" value is "transitive" (PLAN_MECHANICAL_
|
|
3088
|
-
* CHAT.md P1), wired below for reverse-shape imports/calls closures over
|
|
3089
|
-
* impactClosure (codegraph.mjs) — module-coarse only; the fine-grained
|
|
3090
|
-
* callsSymbol/touchesSymbol siblings and every other predicate kind have no
|
|
3091
|
-
* closure primitive yet, and forward-shape currently never parses a non-"direct"
|
|
3092
|
-
* modifier at all (both parsing strategies hardcode modifier:"direct" for it). */
|
|
2345
|
+
/** Safety net: a {shape, kind, entityType} combination must be explicitly
|
|
2346
|
+
* listed here to receive real non-"direct" modifier behavior; anything else
|
|
2347
|
+
* gets an honest "not supported yet" response, never a silent fallback to
|
|
2348
|
+
* direct-only behavior. */
|
|
3093
2349
|
function modifierIsWired(shape, kind, entityType) {
|
|
3094
2350
|
return shape === "reverse" && (kind === "imports" || kind === "calls") && (!entityType || entityType === "Module");
|
|
3095
2351
|
}
|
|
3096
|
-
//
|
|
3097
|
-
// not reimplemented, so its own depth convention is the honest one to inherit.
|
|
3098
|
-
const TRANSITIVE_MAX_DEPTH = 8;
|
|
2352
|
+
const TRANSITIVE_MAX_DEPTH = 8; // matches renderImpact's own default (codegraph.mjs)
|
|
3099
2353
|
|
|
3100
2354
|
/** Compile a parsed query into a graph lookup. Pure given (graph, parsed, opts).
|
|
3101
2355
|
* `opts.contextId` resolves a context pronoun ("this"/"it"/…) when the parse
|
|
@@ -3104,20 +2358,12 @@ const TRANSITIVE_MAX_DEPTH = 8;
|
|
|
3104
2358
|
* `matches` is always an array of individuals (or edge records for "ask"). */
|
|
3105
2359
|
export function traverse(graph, parsed, { contextId = null, prev = null, pinnedObjMatch = null } = {}) {
|
|
3106
2360
|
if (!parsed) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
|
|
3107
|
-
//
|
|
3108
|
-
//
|
|
3109
|
-
// below completely unchanged.
|
|
2361
|
+
// Compositional AST nodes carry a `node` tag; everything else flows through
|
|
2362
|
+
// the original path below unchanged.
|
|
3110
2363
|
if (parsed.node) return evalComposite(graph, parsed, { contextId, prev });
|
|
3111
|
-
//
|
|
3112
|
-
//
|
|
3113
|
-
//
|
|
3114
|
-
// rephrasing"), never actually answer any of them — an honest admission of
|
|
3115
|
-
// ambiguity with no real content behind it. Every candidate IS a normal,
|
|
3116
|
-
// individually-resolvable parse (merge.mjs only ties same-class DISTINCT parses,
|
|
3117
|
-
// never nests another ambiguousParse inside one), so each one can be traversed
|
|
3118
|
-
// and rendered for real right here — the combined answer stays deterministic on
|
|
3119
|
-
// the same input (no guessing, no picking a winner) while actually telling the
|
|
3120
|
-
// user what each reading resolves to, instead of making them ask twice.
|
|
2364
|
+
// Every candidate in a same-class parse-level tie is independently
|
|
2365
|
+
// resolvable, so each is traversed and rendered for real here instead of
|
|
2366
|
+
// just describing the ambiguity with no content behind it.
|
|
3121
2367
|
if (parsed.ambiguousParse) {
|
|
3122
2368
|
const branches = parsed.candidates.map((c) => {
|
|
3123
2369
|
const branchResult = traverse(graph, c, { contextId, prev });
|
|
@@ -3127,12 +2373,9 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3127
2373
|
}
|
|
3128
2374
|
const { shape, kind, entityType } = parsed;
|
|
3129
2375
|
|
|
3130
|
-
// meta: a question about the graph's
|
|
3131
|
-
//
|
|
3132
|
-
//
|
|
3133
|
-
// Matched by exact (case-insensitive) label ("cochange", "Commit") OR the raw `token`
|
|
3134
|
-
// attribute a SchemaPredicate also carries ("mgx:callsSymbol") — never substring/fuzzy,
|
|
3135
|
-
// same discipline as resolveObject's own tiers: a real term match or an honest miss.
|
|
2376
|
+
// meta: a question about the graph's own vocabulary, looked up against the
|
|
2377
|
+
// SchemaClass/SchemaPredicate individuals schema-docs.mjs merged into the
|
|
2378
|
+
// graph, not a code-edge traversal. Exact label or `token` attribute match only.
|
|
3136
2379
|
if (shape === "meta") {
|
|
3137
2380
|
const term = String(parsed.object || "").trim();
|
|
3138
2381
|
const termLc = term.toLowerCase();
|
|
@@ -3143,11 +2386,6 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3143
2386
|
return token && String(token).toLowerCase() === termLc;
|
|
3144
2387
|
});
|
|
3145
2388
|
if (!match) {
|
|
3146
|
-
// META FALLBACK TO REAL ENTITIES (0.8.2 WS1; widened + extracted to
|
|
3147
|
-
// metaFallbackEntityAnswer, HANDOVER.md 2026-07-10 item 6) — see that
|
|
3148
|
-
// function's own docblock for the full "what is a Record"/"what does
|
|
3149
|
-
// fnAlpha mean" history. A unique hit renders straight from its own text
|
|
3150
|
-
// (render()'s metaCodeClass branch just passes it through).
|
|
3151
2389
|
const fallback = metaFallbackEntityAnswer(graph, term);
|
|
3152
2390
|
if (fallback) {
|
|
3153
2391
|
return {
|
|
@@ -3164,13 +2402,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3164
2402
|
};
|
|
3165
2403
|
}
|
|
3166
2404
|
|
|
3167
|
-
// mentions: "where is X mentioned"
|
|
3168
|
-
//
|
|
3169
|
-
// identifier / doc-comment tokens contain the term's words (the same index
|
|
3170
|
-
// resolveObject's tier 4 consults, surfaced directly). The term itself is NOT
|
|
3171
|
-
// resolved to an entity first — the question is about mentions of the words,
|
|
3172
|
-
// which is exactly what the prose index stores. typeof guard: same viewer-
|
|
3173
|
-
// bundle boundary as tier 4 (prose.mjs is never inlined).
|
|
2405
|
+
// mentions: "where is X mentioned" — the prose surface (resolveObject's
|
|
2406
|
+
// tier-4 index), not an edge traversal or entity resolution.
|
|
3174
2407
|
if (shape === "mentions") {
|
|
3175
2408
|
const term = String(parsed.object || "").trim();
|
|
3176
2409
|
const hits = typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, term) : [];
|
|
@@ -3181,10 +2414,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3181
2414
|
};
|
|
3182
2415
|
}
|
|
3183
2416
|
|
|
3184
|
-
//
|
|
3185
|
-
//
|
|
3186
|
-
// honest capability-gap message rather than masquerading as an object-miss, or worse,
|
|
3187
|
-
// silently behaving as if "transitively"/"indirectly" had never been said.
|
|
2417
|
+
// Checked before object resolution, so an unsupported modifier+kind
|
|
2418
|
+
// combination gets its own honest capability-gap message.
|
|
3188
2419
|
if (parsed.modifier && parsed.modifier !== "direct" && !modifierIsWired(shape, kind, entityType)) {
|
|
3189
2420
|
return {
|
|
3190
2421
|
matches: [], objMatch: null, candidates: [], ambiguous: false,
|
|
@@ -3196,33 +2427,17 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3196
2427
|
if (shape === "ask") {
|
|
3197
2428
|
const subj = resolveTermOrContext(graph, parsed.subject, contextId);
|
|
3198
2429
|
const obj = resolveTermOrContext(graph, parsed.object, contextId);
|
|
3199
|
-
//
|
|
3200
|
-
//
|
|
3201
|
-
//
|
|
3202
|
-
// slot) used to fall straight through as if `obj`/`subj` were a confident
|
|
3203
|
-
// single match, so a randomly-first-picked TIED candidate (once, live, a raw
|
|
3204
|
-
// Commit whose "label" IS its short hash, "c3d4e5f6a1b2") rode straight into
|
|
3205
|
-
// the Yes/No render's "No — no <kind> edge found from A to B" sentence — an
|
|
3206
|
-
// internal id leaking into user-facing text off the back of an unresolved tie,
|
|
3207
|
-
// not a real resolution. Every OTHER shape in this file already declines
|
|
3208
|
-
// rather than guessing on a tie (resolveObject's own "never a guess" contract,
|
|
3209
|
-
// its docblock above); this shape is the one place that never checked the
|
|
3210
|
-
// `ambiguous` flag its own resolver already computed. Declining here (leaving
|
|
3211
|
-
// subjMatch unset, same as the `!subj.match || !obj.match` miss just below)
|
|
3212
|
-
// reaches the SAME "couldn't resolve one of the terms in this question" honest
|
|
3213
|
-
// miss render() already uses — never a confident wrong answer, and never a
|
|
3214
|
-
// raw internal id surfacing off a coin-flip pick among tied candidates.
|
|
2430
|
+
// A tied, ambiguous fuzzy/prose match must decline here too (this shape
|
|
2431
|
+
// otherwise never checked `ambiguous`), or a coin-flip candidate's raw id
|
|
2432
|
+
// could leak into the Yes/No render's sentence.
|
|
3215
2433
|
if (!subj.match || !obj.match || subj.ambiguous || obj.ambiguous) {
|
|
3216
2434
|
return {
|
|
3217
2435
|
matches: [], objMatch: obj.match, candidates: obj.candidates, traversal: null, ambiguous: false, answer: null,
|
|
3218
2436
|
unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
|
|
3219
2437
|
};
|
|
3220
2438
|
}
|
|
3221
|
-
// touches edges are stored commit -> entity
|
|
3222
|
-
//
|
|
3223
|
-
// edge test by where the commit actually is instead of failing on direction;
|
|
3224
|
-
// a commit subject is also checked at the symbol grain ("does commit X touch
|
|
3225
|
-
// <function>" lives on touchesSymbol, not the module-coarse kind).
|
|
2439
|
+
// touches edges are stored commit -> entity; orient by where the commit
|
|
2440
|
+
// actually is when it's named on the object side.
|
|
3226
2441
|
let [from, to] = [subj.match, obj.match];
|
|
3227
2442
|
let kinds = kindsFor(kind); // "uses" checks the whole union ("does X use Y")
|
|
3228
2443
|
if (kind === "touches") {
|
|
@@ -3236,33 +2451,16 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3236
2451
|
};
|
|
3237
2452
|
}
|
|
3238
2453
|
|
|
3239
|
-
// reverse and forward both resolve one named term ("object" in the parsed
|
|
3240
|
-
//
|
|
3241
|
-
//
|
|
3242
|
-
//
|
|
3243
|
-
//
|
|
3244
|
-
//
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3247
|
-
//
|
|
3248
|
-
//
|
|
3249
|
-
// resolveObject's own grain-word retry just above (try a variant, keep it
|
|
3250
|
-
// ONLY on an unambiguous hit, else fall through unchanged) and
|
|
3251
|
-
// grammar/ace.mjs's parseAceAmbiguous ("keep only complete, valid parses,
|
|
3252
|
-
// dead ends pruned"). Four outcomes: primary misses/ties + alt resolves
|
|
3253
|
-
// cleanly -> the alt reading wins (the dead end is pruned); primary resolves
|
|
3254
|
-
// cleanly -> untouched, regardless of what the alt does (byte-identical to
|
|
3255
|
-
// before this existed); both resolve cleanly to the SAME entity -> untouched
|
|
3256
|
-
// either way; both resolve cleanly to DIFFERENT entities -> genuine
|
|
3257
|
-
// ambiguity, surfaced the same honest way resolveObject's own tier ties
|
|
3258
|
-
// already are, never silently guessed.
|
|
3259
|
-
// ENTITY-TIE BRANCHES (breadth-first ambiguity, PLAN_BREADTH_FIRST_NLU.md §1): a
|
|
3260
|
-
// recursive traverse() call for one already-tied candidate arrives here with
|
|
3261
|
-
// `pinnedObjMatch` set — skip re-resolution entirely (no re-derived tie is
|
|
3262
|
-
// possible, so `ambiguous` is structurally false on every such call) rather
|
|
3263
|
-
// than re-resolving by label text, which would risk a second individual
|
|
3264
|
-
// sharing the same label, a stale `altObject` re-triggering a spurious new
|
|
3265
|
-
// tie, or the test-variant-collision guard misfiring on a non-Module label.
|
|
2454
|
+
// reverse and forward both resolve one named term ("object" in the parsed
|
|
2455
|
+
// shape). altObject pruning: noise-strip.mjs's bare "where"/"mentions"
|
|
2456
|
+
// reading may carry `parsed.altObject` — a variant with a plausibly-noise
|
|
2457
|
+
// light verb also dropped. Tried here (where both readings and the graph
|
|
2458
|
+
// are available together): the alt reading wins only when the primary
|
|
2459
|
+
// misses/ties and the alt resolves cleanly; two clean, different resolves
|
|
2460
|
+
// become a genuine ambiguity, never a silent guess.
|
|
2461
|
+
// A recursive traverse() call for an already-tied candidate arrives with
|
|
2462
|
+
// `pinnedObjMatch` set — skip re-resolution entirely rather than
|
|
2463
|
+
// re-resolving by label text, which risks a spurious new tie.
|
|
3266
2464
|
let objRes;
|
|
3267
2465
|
if (pinnedObjMatch) {
|
|
3268
2466
|
objRes = { match: pinnedObjMatch, candidates: [], ambiguous: false, matchedVia: null };
|
|
@@ -3278,24 +2476,10 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3278
2476
|
objRes = { ...objRes, ambiguous: true, candidates: [altRes.match, ...(objRes.candidates || [])] };
|
|
3279
2477
|
}
|
|
3280
2478
|
}
|
|
3281
|
-
//
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
3284
|
-
//
|
|
3285
|
-
// plausibly answer "b.mjs" when the question is specifically about test
|
|
3286
|
-
// coverage. Deliberately scoped to kind==="tests" (never touches, imports,
|
|
3287
|
-
// calls, …) — the SAME bare filename in an unrelated query ("has store.mjs
|
|
3288
|
-
// been touched", chatflow-agents-debt-remeasure.test.mjs BUG-B's ground truth,
|
|
3289
|
-
// examples/mini-webapp genuinely has the same store.mjs/store.test.mjs pair) has
|
|
3290
|
-
// no such self-referential reading and must stay untouched. ALSO scoped to a
|
|
3291
|
-
// BARE (unslashed) term — same convention as resolveObjectCore's own `dotted`
|
|
3292
|
-
// tier just above resolveObject's call site: a query that already spells out
|
|
3293
|
-
// the full path ("which functions test src/core/store.mjs",
|
|
3294
|
-
// chatflow-tier4.test.mjs Batch 4/5; "app/lib/b.mjs", chatflow-tier2.test.mjs
|
|
3295
|
-
// T18) is already unambiguous by construction and must stay untouched — only
|
|
3296
|
-
// the bare basename is genuinely open to either reading. Same discipline as
|
|
3297
|
-
// the altObject prune just above: only a CLEAN primary match plus a genuinely
|
|
3298
|
-
// DIFFERENT real Module promotes to honest ambiguity, never a silent guess.
|
|
2479
|
+
// Test-variant collision ("which tests cover b.mjs"): a "tests"-kind
|
|
2480
|
+
// query's bare object may also name its own conventional test-variant
|
|
2481
|
+
// sibling ("b.mjs" -> "b.test.mjs"), a different real Module. Scoped to
|
|
2482
|
+
// kind==="tests" and a bare (unslashed) term only.
|
|
3299
2483
|
if (parsed.kind === "tests" && objRes.match && !objRes.ambiguous && !String(parsed.object || "").includes("/")) {
|
|
3300
2484
|
const stripTestInfix = (base) => base.replace(/\.(?:test|spec|tests)(?=\.[^.]+$)/, "");
|
|
3301
2485
|
const termBase = stripTestInfix(String(parsed.object || "").trim().toLowerCase());
|
|
@@ -3309,7 +2493,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3309
2493
|
}
|
|
3310
2494
|
const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = objRes;
|
|
3311
2495
|
if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
|
|
3312
|
-
// BREADTH-FIRST ENTITY-TIE RESOLUTION
|
|
2496
|
+
// BREADTH-FIRST ENTITY-TIE RESOLUTION: mirrors the
|
|
3313
2497
|
// parsed.ambiguousParse branch above — every tied candidate is independently
|
|
3314
2498
|
// traversed and rendered for real (via the pinnedObjMatch short-circuit just
|
|
3315
2499
|
// above), never left as a bare name list. Capped at OVERFLOW_CAP BEFORE
|
|
@@ -3327,7 +2511,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3327
2511
|
return { matches: [], objMatch, candidates, traversal: null, ambiguous: true, branches };
|
|
3328
2512
|
}
|
|
3329
2513
|
|
|
3330
|
-
// where: "where is X [defined]"
|
|
2514
|
+
// where: "where is X [defined]" — the resolved
|
|
3331
2515
|
// entity IS the answer; render() reads its class + site attribute ("path:
|
|
3332
2516
|
// start[-end]", seon:startsAt) for the module/line citation.
|
|
3333
2517
|
if (shape === "where") {
|
|
@@ -3338,8 +2522,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3338
2522
|
};
|
|
3339
2523
|
}
|
|
3340
2524
|
|
|
3341
|
-
// when: "when did X change" / "when was X last touched"
|
|
3342
|
-
// families) — the commits whose touch edges reach X, newest commit date first
|
|
2525
|
+
// when: "when did X change" / "when was X last touched" — the commits whose touch edges reach X, newest commit date first
|
|
3343
2526
|
// (mgx:commitDate, ISO-8601, so a lexical sort IS the date sort; undated
|
|
3344
2527
|
// commits sort last and render() says so honestly). Checked BEFORE the
|
|
3345
2528
|
// commit-as-subject flip: "when did <sha> change" asks for the commit's own
|
|
@@ -3367,7 +2550,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3367
2550
|
};
|
|
3368
2551
|
}
|
|
3369
2552
|
|
|
3370
|
-
// who-last
|
|
2553
|
+
// who-last: "who last touched X" — the SAME
|
|
3371
2554
|
// newest-commit-first resolution as "when" just above (single most-recent
|
|
3372
2555
|
// toucher, not the full touch history), rendered as the commit's AUTHOR
|
|
3373
2556
|
// instead of its date. A dedicated shape rather than reusing "when" outright:
|
|
@@ -3391,40 +2574,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3391
2574
|
};
|
|
3392
2575
|
}
|
|
3393
2576
|
|
|
3394
|
-
//
|
|
3395
|
-
//
|
|
3396
|
-
//
|
|
3397
|
-
// "what changed in abc1234" (casual reverse) — the honest reading is "what did
|
|
3398
|
-
// that commit touch": read the edges FROM the commit, grain-selected by the asked
|
|
3399
|
-
// entity type, instead of scanning for edges INTO it (a commit is never a touch
|
|
3400
|
-
// target, so the un-flipped scan would render a misleading blank).
|
|
2577
|
+
// Commit-as-subject flip: touches edges are stored commit -> entity, so
|
|
2578
|
+
// when the resolved term is itself a Commit, read edges FROM it instead of
|
|
2579
|
+
// scanning for edges into it (a commit is never a touch target).
|
|
3401
2580
|
if (kind === "touches" && objMatch.class === "Commit") {
|
|
3402
2581
|
return commitTouches(graph, objMatch, entityType, { candidates, ambiguous, matchedVia });
|
|
3403
2582
|
}
|
|
3404
2583
|
|
|
3405
2584
|
if (shape === "forward") {
|
|
3406
|
-
//
|
|
3407
|
-
//
|
|
3408
|
-
//
|
|
3409
|
-
// module-coarse scan alone can never reach (a coarse edge's subject is a
|
|
3410
|
-
// module, so a Function/Method subject rendered a false "no calls edges" while
|
|
3411
|
-
// the reverse direction answered). Module subjects never carry a sibling edge,
|
|
3412
|
-
// so their scan — and the traversal receipt — stays byte-identical. The receipt
|
|
3413
|
-
// names what was actually scanned ("calls+callsSymbol edges where subject = X").
|
|
2585
|
+
// A kind with a symbol-grain sibling scans the union coarse+sibling when
|
|
2586
|
+
// the resolved subject is itself a fine symbol ("what does Widget.render
|
|
2587
|
+
// call" lives on callsSymbol, not the module-coarse edge).
|
|
3414
2588
|
const fwdSibling = SYMBOL_GRAIN_SIBLING[kind];
|
|
3415
2589
|
const subjIsFineSymbol = !!(fwdSibling && objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
|
|
3416
2590
|
const fwdKinds = subjIsFineSymbol ? [...new Set([...kindsFor(kind), fwdSibling])] : kindsFor(kind);
|
|
3417
|
-
//
|
|
3418
|
-
//
|
|
3419
|
-
//
|
|
3420
|
-
// regardless of what class the kind's edges actually target. A kind whose real
|
|
3421
|
-
// observed target classes never include the asked entityType (nor its
|
|
3422
|
-
// FINE_CLASS_SIBLING family partner) can never honestly answer it — "what
|
|
3423
|
-
// modules does X have" via `defines`, whose real targets are only
|
|
3424
|
-
// {Class,Attribute,Method,Function}, never Module — so this is an honest decline,
|
|
3425
|
-
// not a blind filter that would silently produce a false empty. "Change" is the
|
|
3426
|
-
// touches-family wildcard pseudo-type (no individual is ever classed "Change"),
|
|
3427
|
-
// exempted the same way the reverse branch exempts it (see its own comment above).
|
|
2591
|
+
// A kind whose real target classes never include the asked entityType
|
|
2592
|
+
// (nor its FINE_CLASS_SIBLING partner) can never honestly answer it — an
|
|
2593
|
+
// honest decline, not a blind filter producing a false empty.
|
|
3428
2594
|
if (entityType && entityType !== "Change") {
|
|
3429
2595
|
const wantClasses = classesForKinds(graph, fwdKinds);
|
|
3430
2596
|
const siblingClass = FINE_CLASS_SIBLING[entityType];
|
|
@@ -3438,14 +2604,9 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3438
2604
|
}
|
|
3439
2605
|
const edges = fwdKinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
|
|
3440
2606
|
const targets = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
|
|
3441
|
-
// dedupe only on the widened scan — the coarse-only path keeps its exact shape.
|
|
3442
2607
|
const deduped = subjIsFineSymbol ? uniqueById(targets) : targets;
|
|
3443
|
-
//
|
|
3444
|
-
//
|
|
3445
|
-
// mirrors the reverse branch's own subjects.filter + sibling-widen-on-empty
|
|
3446
|
-
// fallback just below, so a forward answer never leaks a wrong-class match once
|
|
3447
|
-
// an entityType was actually asked for ("which functions does saveStore call"
|
|
3448
|
-
// must not render a Class as if it were a function).
|
|
2608
|
+
// Keep only matches of the asked class, so a forward answer never leaks
|
|
2609
|
+
// a wrong-class match once an entityType was actually asked for.
|
|
3449
2610
|
let matches = deduped;
|
|
3450
2611
|
let filterNote = "";
|
|
3451
2612
|
if (entityType && entityType !== "Change") {
|
|
@@ -3462,17 +2623,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3462
2623
|
return { matches, objMatch, candidates, traversal: `${fwdKinds.join("+")} edges where subject = ${objMatch.label}${filterNote}`, ambiguous, matchedVia };
|
|
3463
2624
|
}
|
|
3464
2625
|
|
|
3465
|
-
//
|
|
3466
|
-
//
|
|
3467
|
-
//
|
|
3468
|
-
//
|
|
3469
|
-
//
|
|
3470
|
-
// "transitively imports" and one for "transitively calls" both resolve to the SAME
|
|
3471
|
-
// mixed reverse-dependency closure. That's a real, deliberate scope decision (matching
|
|
3472
|
-
// the plan's own instruction to wire onto "renderImpact's existing closure traversal"
|
|
3473
|
-
// rather than build a new predicate-pure one) — the traversal receipt below says so
|
|
3474
|
-
// honestly rather than implying a narrower single-predicate result than what was
|
|
3475
|
-
// actually computed.
|
|
2626
|
+
// Reverse + transitive: reuses impactClosure (codegraph.mjs) as-is.
|
|
2627
|
+
// impactClosure's dependents map is a reverse closure over imports+calls
|
|
2628
|
+
// edges together, so "transitively imports" and "transitively calls" both
|
|
2629
|
+
// resolve to the same mixed closure — a deliberate scope decision, stated
|
|
2630
|
+
// honestly in the traversal receipt below.
|
|
3476
2631
|
if (parsed.modifier === "transitive") {
|
|
3477
2632
|
const levels = impactClosure(graph, objMatch, { maxDepth: TRANSITIVE_MAX_DEPTH });
|
|
3478
2633
|
const matches = levels.flat().map((d) => graph.byId.get(d.id)).filter(Boolean);
|
|
@@ -3482,17 +2637,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3482
2637
|
};
|
|
3483
2638
|
}
|
|
3484
2639
|
|
|
3485
|
-
// reverse: "which <entityType> R <objMatch>".
|
|
3486
|
-
//
|
|
3487
|
-
//
|
|
3488
|
-
//
|
|
3489
|
-
//
|
|
3490
|
-
// NEVER point at a function/method, so a bare "what calls fnAlpha" scanning the coarse
|
|
3491
|
-
// `calls` edges returned a FALSE empty ("No modules found …") while the graph records a
|
|
3492
|
-
// real symbol-level caller (Widget.render --callsSymbol--> fnAlpha). The honest answer
|
|
3493
|
-
// reads off callsSymbol at symbol grain; a truly-uncalled symbol still renders the
|
|
3494
|
-
// honest empty, now with the accurate callsSymbol receipt. (Previously scoped to touches
|
|
3495
|
-
// only, which left this exact callsSymbol caller invisible — a genuine correctness bug.)
|
|
2640
|
+
// reverse: "which <entityType> R <objMatch>". Grain-aware: a kind with a
|
|
2641
|
+
// symbol-grain sibling reads off the sibling when a fine subject grain was
|
|
2642
|
+
// asked for, or when the resolved object is itself a fine symbol — the
|
|
2643
|
+
// module-coarse edge can never point at a function/method, so a bare "what
|
|
2644
|
+
// calls fnAlpha" would otherwise return a false empty.
|
|
3496
2645
|
const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
|
|
3497
2646
|
const objIsFineSymbol = !!(objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
|
|
3498
2647
|
if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsFineSymbol)) {
|
|
@@ -3500,12 +2649,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3500
2649
|
const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
|
|
3501
2650
|
let matches = (!entityType || entityType === "Change") ? subjects : subjects.filter((i) => i.class === entityType);
|
|
3502
2651
|
let widenNote = "";
|
|
3503
|
-
//
|
|
3504
|
-
//
|
|
3505
|
-
// "which functions call fnAlpha" must not hide the recorded caller Widget.render
|
|
3506
|
-
// just because the extractor stored it as class Method. Fallback-only by
|
|
3507
|
-
// construction (the exact filter must be empty first), so every currently
|
|
3508
|
-
// non-empty answer is byte-identical; the widening is said in the traversal.
|
|
2652
|
+
// Fallback-only: when the exact-class filter is empty and the asked
|
|
2653
|
+
// grain is Function/Method, retry with the family sibling class.
|
|
3509
2654
|
const siblingClass = FINE_CLASS_SIBLING[entityType];
|
|
3510
2655
|
if (!matches.length && siblingClass) {
|
|
3511
2656
|
const widened = subjects.filter((i) => i.class === siblingClass);
|
|
@@ -3514,40 +2659,12 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3514
2659
|
widenNote = `, widened to ${siblingClass} subjects (no ${entityType} recorded)`;
|
|
3515
2660
|
}
|
|
3516
2661
|
}
|
|
3517
|
-
//
|
|
3518
|
-
//
|
|
3519
|
-
// "who calls Router" wrongly returned empty for a Class whose calls are only
|
|
3520
|
-
// recorded at module-coarse grain): an EMPTY touchesSymbol/callsSymbol lookup
|
|
3521
|
-
// on a resolved CLASS is not decisive the way it is for a Function/Method — a
|
|
3522
|
-
// Class reads naturally as "the file/unit that holds it", so "who touched
|
|
3523
|
-
// <Class>"/"who calls <Class>" with no recorded symbol-precise edge should
|
|
2662
|
+
// Class-to-module up-refinement: an empty touchesSymbol/callsSymbol
|
|
2663
|
+
// lookup on a resolved Class with no recorded `contains` members should
|
|
3524
2664
|
// still answer from the class's containing module's real touches/calls,
|
|
3525
|
-
// rather than a confident-looking-but-
|
|
3526
|
-
//
|
|
3527
|
-
//
|
|
3528
|
-
// grain-aware COUNT lever) to stay an honest 0 rather than a module-grain
|
|
3529
|
-
// false hit — symbol-level counting precision for functions/methods is a
|
|
3530
|
-
// deliberate, separate guarantee this change must not erode.
|
|
3531
|
-
//
|
|
3532
|
-
// Up-refine eligibility is gated on the Class having NO recorded `contains`
|
|
3533
|
-
// members: a class the extractor actually populated (Widget, Logger — both
|
|
3534
|
-
// carry real `contains` edges) reads as a fully-modeled unit whose own empty
|
|
3535
|
-
// symbol-grain scan IS the honest answer; a class with zero recorded members
|
|
3536
|
-
// (Router, Button — the extractor only ever saw its declaration, never a
|
|
3537
|
-
// body) is exactly the shape where the coarser module-level edge is the only
|
|
3538
|
-
// real signal recorded at all.
|
|
3539
|
-
//
|
|
3540
|
-
// Even when eligible, only actually fall through to the shared grain-aware
|
|
3541
|
-
// up-refine block below when a containing module can be resolved RIGHT NOW.
|
|
3542
|
-
// That block's own "no containing module found" case renders a DIFFERENT,
|
|
3543
|
-
// wrongGrainMiss-shaped honest miss ("'Button' resolved to the class Button,
|
|
3544
|
-
// but this question needs a module…") than the plain zero-match miss this
|
|
3545
|
-
// symbol-grain scan already renders ("No modules found whose module directly
|
|
3546
|
-
// calls Button…"). A Class with no `contains` members AND no `defines` edge
|
|
3547
|
-
// naming its module (the synthetic chatflow-tier2 fixture's Button, which the
|
|
3548
|
-
// extractor recorded a bare declaration for but never wired into `defines`)
|
|
3549
|
-
// must keep the latter, plain-miss wording — eligibility alone doesn't
|
|
3550
|
-
// guarantee the up-refine can actually complete.
|
|
2665
|
+
// rather than a confident-looking-but-wrong "nothing touched/calls it".
|
|
2666
|
+
// Not widened to Function/Method — symbol-level counting precision there
|
|
2667
|
+
// is a separate, deliberate guarantee this must not erode.
|
|
3551
2668
|
const upRefineEligible = (kind === "touches" || kind === "calls") && objMatch.class === "Class"
|
|
3552
2669
|
&& !edgesOfKind(graph, "contains").some((e) => e.subject === objMatch.id);
|
|
3553
2670
|
const upRefineModule = upRefineEligible ? graph.byId.get(moduleIdOf(graph, objMatch) || "") : null;
|
|
@@ -3556,18 +2673,11 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3556
2673
|
}
|
|
3557
2674
|
}
|
|
3558
2675
|
|
|
3559
|
-
//
|
|
3560
|
-
//
|
|
3561
|
-
//
|
|
3562
|
-
//
|
|
3563
|
-
//
|
|
3564
|
-
// below then legitimately returns [] for the wrong-grain id — a confident-wrong
|
|
3565
|
-
// empty, not an honest miss. `wantClass` is null for a kind whose edges span more
|
|
3566
|
-
// than one object class (e.g. "contains") — no grain check applies there, byte-
|
|
3567
|
-
// identical to before. objMatch.class === null (an ext: synthetic match, no real
|
|
3568
|
-
// individual — see resolveObject's tier 2) is likewise never grain-checked: it has
|
|
3569
|
-
// no better class to compare against, and is already the most specific resolution
|
|
3570
|
-
// available.
|
|
2676
|
+
// Grain-aware object resolution, checked before the edge filter below: a
|
|
2677
|
+
// predicate's object slot carries one particular class (kindObjectClass),
|
|
2678
|
+
// but resolveObject is blind to that — a same-stem term ("logger") could
|
|
2679
|
+
// resolve to the wrong grain (Class Logger) instead of the Module the edge
|
|
2680
|
+
// actually targets, producing a confident-wrong empty.
|
|
3571
2681
|
let gObjMatch = objMatch;
|
|
3572
2682
|
let gCandidates = candidates;
|
|
3573
2683
|
let gAmbiguous = ambiguous;
|
|
@@ -3575,9 +2685,7 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3575
2685
|
let grainRefinedNote = "";
|
|
3576
2686
|
const wantClass = kindObjectClass(graph, kind);
|
|
3577
2687
|
if (wantClass && gObjMatch.class && gObjMatch.class !== wantClass) {
|
|
3578
|
-
// (1) retry resolution
|
|
3579
|
-
// considers Module individuals, so it lands on src/lib/logger.mjs instead of
|
|
3580
|
-
// the same-stem Class (fixes Bug C).
|
|
2688
|
+
// (1) retry resolution scoped to the expected class.
|
|
3581
2689
|
const retry = resolveObject(graph, parsed.object, { expectedClass: wantClass });
|
|
3582
2690
|
if (retry.match && !retry.ambiguous) {
|
|
3583
2691
|
gObjMatch = retry.match;
|
|
@@ -3585,22 +2693,9 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3585
2693
|
gAmbiguous = retry.ambiguous;
|
|
3586
2694
|
gMatchedVia = retry.matchedVia;
|
|
3587
2695
|
} else if (wantClass === "Module") {
|
|
3588
|
-
// (2) up-refine to the containing module
|
|
3589
|
-
//
|
|
3590
|
-
//
|
|
3591
|
-
// kind newly recorded as Module->Module in the graph gets this for free.
|
|
3592
|
-
// No same-grain alternative exists here (the retry above genuinely found
|
|
3593
|
-
// nothing), but the resolved fine-grain entity (a Function/Class, say)
|
|
3594
|
-
// DOES live in a module, and that module is the real, honest subject of
|
|
3595
|
-
// the question ("does createTask have tests" — Bug D; "who touched Bar",
|
|
3596
|
-
// "what modules import Bar" — the same up-refine extended past
|
|
3597
|
-
// tests/cochange, HANDOVER 2026-07-12). `calls` computes to Module here
|
|
3598
|
-
// too, but never actually reaches this branch with a wrong-grain object:
|
|
3599
|
-
// the symbolKind branch above already intercepts every Class/Function/…
|
|
3600
|
-
// object for `calls` unconditionally (its empty-result IS decisive, see
|
|
3601
|
-
// that branch's own comment), so this is inert-but-correct for it. Up-
|
|
3602
|
-
// refine via the same moduleIdOf qualHolds's "tested" case already uses
|
|
3603
|
-
// (see its divergence comment above).
|
|
2696
|
+
// (2) up-refine to the containing module (any kind whose real
|
|
2697
|
+
// object-class is always Module): the resolved fine-grain entity lives
|
|
2698
|
+
// in a module, and that module is the real subject of the question.
|
|
3604
2699
|
const mid = moduleIdOf(graph, gObjMatch);
|
|
3605
2700
|
const mod = mid && graph.byId.get(mid);
|
|
3606
2701
|
if (mod) {
|
|
@@ -3614,9 +2709,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3614
2709
|
};
|
|
3615
2710
|
}
|
|
3616
2711
|
} else {
|
|
3617
|
-
// (3) neither
|
|
3618
|
-
//
|
|
3619
|
-
// objMatch-null branch below, untouched) and "resolved + genuinely empty".
|
|
2712
|
+
// (3) neither applies — an honest wrong-grain miss, distinct from
|
|
2713
|
+
// "unresolved" and "resolved + genuinely empty".
|
|
3620
2714
|
return {
|
|
3621
2715
|
matches: [], objMatch: gObjMatch, candidates: gCandidates, ambiguous: gAmbiguous, matchedVia: gMatchedVia,
|
|
3622
2716
|
wrongGrainMiss: true, wantClass,
|
|
@@ -3625,21 +2719,15 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3625
2719
|
}
|
|
3626
2720
|
}
|
|
3627
2721
|
|
|
3628
|
-
// General case: some predicates are already fine-grained (inherits
|
|
3629
|
-
//
|
|
3630
|
-
//
|
|
3631
|
-
//
|
|
3632
|
-
//
|
|
3633
|
-
// (imports) — never blindly treat an edge's subject id as if it were always a module id.
|
|
2722
|
+
// General case: some predicates are already fine-grained (inherits) and
|
|
2723
|
+
// some are module-coarse (imports/calls/tests/cochange). Check what the
|
|
2724
|
+
// edge's actual subjects are: use them directly if they already match the
|
|
2725
|
+
// requested entityType; only refine via `defines` when they're Modules and
|
|
2726
|
+
// a finer entityType was asked for.
|
|
3634
2727
|
let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === gObjMatch.id);
|
|
3635
|
-
// cochange
|
|
3636
|
-
//
|
|
3637
|
-
// subject
|
|
3638
|
-
// when X is the STORED SUBJECT of the pair, reading the OTHER endpoint (Track-1
|
|
3639
|
-
// trio, temporal lever). Flip subject<->object on that side so the subjects-
|
|
3640
|
-
// collection loop below (which reads e.subject) picks up the partner uniformly;
|
|
3641
|
-
// the object-side match above is untouched, so an existing non-empty answer is
|
|
3642
|
-
// byte-identical.
|
|
2728
|
+
// cochange is symmetric but stored as one directed edge per pair, so
|
|
2729
|
+
// "which modules cochange with X" must also match when X is the stored
|
|
2730
|
+
// subject — flip subject<->object on that side.
|
|
3643
2731
|
if (kind === "cochange") {
|
|
3644
2732
|
edges = edges.concat(
|
|
3645
2733
|
kindsFor(kind).flatMap((k) => edgesOfKind(graph, k))
|
|
@@ -3649,18 +2737,15 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3649
2737
|
}
|
|
3650
2738
|
let extNote = "";
|
|
3651
2739
|
if (!edges.length && gObjMatch.class) {
|
|
3652
|
-
// Unresolved ext:<Name> endpoints
|
|
3653
|
-
//
|
|
3654
|
-
//
|
|
3655
|
-
// strict id match renders a FALSE blank. Count them by NAME instead and say
|
|
3656
|
-
// so in the receipt — name-grade evidence, labeled as such, same standard as
|
|
3657
|
-
// resolveObject's own ext: tier.
|
|
2740
|
+
// Unresolved ext:<Name> endpoints sharing the resolved entity's name: the
|
|
2741
|
+
// extractor declined to assert identity, so a strict id match renders a
|
|
2742
|
+
// false blank. Count them by name instead and say so in the receipt.
|
|
3658
2743
|
const extId = `ext:${String(gObjMatch.label).toLowerCase()}`;
|
|
3659
2744
|
edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => String(e.object).toLowerCase() === extId);
|
|
3660
2745
|
if (edges.length) extNote = ` (by name, via unresolved ${extId} references)`;
|
|
3661
2746
|
}
|
|
3662
|
-
//
|
|
3663
|
-
// legs
|
|
2747
|
+
// Dedupe by id: a union kind ("uses") can reach the same subject through
|
|
2748
|
+
// two legs, and one answer must list it once.
|
|
3664
2749
|
const subjects = [];
|
|
3665
2750
|
const seenSubjects = new Set();
|
|
3666
2751
|
for (const e of edges) {
|
|
@@ -3668,10 +2753,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3668
2753
|
if (s && !seenSubjects.has(s.id)) { seenSubjects.add(s.id); subjects.push(s); }
|
|
3669
2754
|
}
|
|
3670
2755
|
let matches, grainNote = "";
|
|
3671
|
-
// "Change"
|
|
3672
|
-
//
|
|
3673
|
-
// to filter by (no individual is ever class "Change", so filtering would always
|
|
3674
|
-
// produce a false blank).
|
|
2756
|
+
// "Change" is a wildcard: "which changes touch walk.mjs" means the touch
|
|
2757
|
+
// edges' own subjects (commits), not a node class to filter by.
|
|
3675
2758
|
if (!entityType || entityType === "Change") {
|
|
3676
2759
|
matches = subjects;
|
|
3677
2760
|
} else {
|
|
@@ -3693,8 +2776,8 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
|
|
|
3693
2776
|
};
|
|
3694
2777
|
}
|
|
3695
2778
|
|
|
3696
|
-
// ----
|
|
3697
|
-
// never generation
|
|
2779
|
+
// ---- templated renderer: string interpolation + grouping/pluralization/
|
|
2780
|
+
// overflow rules, never generation. ----
|
|
3698
2781
|
|
|
3699
2782
|
function moduleLabelOf(ind) {
|
|
3700
2783
|
if (ind.class === "Module") return ind.label;
|
|
@@ -3709,11 +2792,8 @@ function symbolLabelOf(ind) {
|
|
|
3709
2792
|
return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
|
|
3710
2793
|
}
|
|
3711
2794
|
|
|
3712
|
-
/** A
|
|
3713
|
-
*
|
|
3714
|
-
* "author") name them beside it. The label is already the graph's short ref (the
|
|
3715
|
-
* builder stores sha.slice(0,12)), so it is used verbatim. Degrades gracefully: a
|
|
3716
|
-
* commit with no recorded author renders the sha alone, exactly as before. */
|
|
2795
|
+
/** A friendly commit reference for a "who touched X" list: names the author
|
|
2796
|
+
* beside the sha when recorded, else renders the sha alone. */
|
|
3717
2797
|
function commitRefOf(ind) {
|
|
3718
2798
|
const sha = String(ind.label || ind.id || "");
|
|
3719
2799
|
const author = (ind.attributes || []).find((a) => a.key === "author")?.value;
|
|
@@ -3733,19 +2813,11 @@ function describeParse(p) {
|
|
|
3733
2813
|
return `${ent}${p.kind} "${obj}"`;
|
|
3734
2814
|
}
|
|
3735
2815
|
|
|
3736
|
-
/**
|
|
3737
|
-
*
|
|
3738
|
-
*
|
|
3739
|
-
*
|
|
3740
|
-
*
|
|
3741
|
-
* not raw JSON, so it stays human-readable at a glance too. Every clause is
|
|
3742
|
-
* a plain template read off `parsed`'s own already-compiled fields, never
|
|
3743
|
-
* generated — the same discipline as `describeParse`/`render()`. Returns
|
|
3744
|
-
* `null` when there's nothing to canonicalize (`parsed` itself is null).
|
|
3745
|
-
* Scoped to the flat query shapes `traverse()`/`render()` operate on; a
|
|
3746
|
-
* compositional AST (`parsed.node`) gets an honest, coarser fallback —
|
|
3747
|
-
* full per-node-type canonicalization is real future work, not silently
|
|
3748
|
-
* faked here. */
|
|
2816
|
+
/** The canonical restatement of what a query was understood to mean: an
|
|
2817
|
+
* English gloss (`english`) and a compact `shape(kind, args...)` notation
|
|
2818
|
+
* (`machine`), both read off `parsed`'s already-compiled fields. A
|
|
2819
|
+
* compositional AST gets a coarse fallback — per-node-type canonicalization
|
|
2820
|
+
* isn't implemented yet. */
|
|
3749
2821
|
function canonicalOf(parsed) {
|
|
3750
2822
|
if (!parsed) return null;
|
|
3751
2823
|
if (parsed.ambiguousParse) {
|
|
@@ -3755,7 +2827,6 @@ function canonicalOf(parsed) {
|
|
|
3755
2827
|
};
|
|
3756
2828
|
}
|
|
3757
2829
|
if (parsed.node) {
|
|
3758
|
-
// Compositional AST — coarse, honest fallback (see docblock above).
|
|
3759
2830
|
return { english: `a compositional query (${parsed.node})`, machine: `composite(${parsed.node})` };
|
|
3760
2831
|
}
|
|
3761
2832
|
const q = (s) => JSON.stringify(String(s ?? ""));
|
|
@@ -3784,11 +2855,9 @@ function canonicalOf(parsed) {
|
|
|
3784
2855
|
return { english, machine };
|
|
3785
2856
|
}
|
|
3786
2857
|
|
|
3787
|
-
/** Render a compiled query result into {content, miss, ambiguous, matches?,
|
|
3788
|
-
*
|
|
3789
|
-
*
|
|
3790
|
-
* "assuming you meant <label>:" so the correction is on the record next to the result
|
|
3791
|
-
* (an unannounced fuzzy hit would be indistinguishable from an exact one — a guess). */
|
|
2858
|
+
/** Render a compiled query result into {content, miss, ambiguous, matches?,
|
|
2859
|
+
* candidates?}. A tier-5 fuzzy object resolution is announced, not silent:
|
|
2860
|
+
* prefixed "assuming you meant <label>:" so the correction is on the record. */
|
|
3792
2861
|
export function render(parsed, result) {
|
|
3793
2862
|
const r = renderCore(parsed, result);
|
|
3794
2863
|
if (result && result.matchedVia === "fuzzy" && result.objMatch && !r.ambiguous) {
|
|
@@ -3849,7 +2918,7 @@ function renderCore(parsed, result) {
|
|
|
3849
2918
|
miss: true, ambiguous: false,
|
|
3850
2919
|
};
|
|
3851
2920
|
}
|
|
3852
|
-
// forward-shape honest miss
|
|
2921
|
+
// forward-shape honest miss: the resolved SUBJECT
|
|
3853
2922
|
// is real, but the asked entityType can never appear among this kind's own real
|
|
3854
2923
|
// target classes at all — distinct from wrongGrainMiss above (which is about the
|
|
3855
2924
|
// resolved OBJECT term's class on the reverse side) and from the plain forward
|
|
@@ -3869,8 +2938,7 @@ function renderCore(parsed, result) {
|
|
|
3869
2938
|
miss: true, ambiguous: false,
|
|
3870
2939
|
};
|
|
3871
2940
|
}
|
|
3872
|
-
// meta fallback hit (
|
|
3873
|
-
// HANDOVER.md 2026-07-10 item 6, see traverse's meta branch): the term is not
|
|
2941
|
+
// meta fallback hit (see traverse's meta branch, metaFallbackEntityAnswer): the term is not
|
|
3874
2942
|
// schema vocabulary but IS a unique code-graph entity (Class/Function/Method/
|
|
3875
2943
|
// GlobalVariable/Attribute) — its pre-rendered describe-style one-liner is
|
|
3876
2944
|
// passed straight through, so this stays byte-identical to whatever chat.mjs's
|
|
@@ -3900,18 +2968,9 @@ function renderCore(parsed, result) {
|
|
|
3900
2968
|
};
|
|
3901
2969
|
}
|
|
3902
2970
|
if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
|
|
3903
|
-
//
|
|
3904
|
-
//
|
|
3905
|
-
//
|
|
3906
|
-
// reads keep priority (a bare "a.mjs" is deliberately read as a symbol-ish
|
|
3907
|
-
// shorthand regardless of the stated entity type — frozen by
|
|
3908
|
-
// test/chat.test.mjs's "no symbol matching \"a.mjs\"" case); only the
|
|
3909
|
-
// remaining catch-all default (unconditionally "module") is replaced by the
|
|
3910
|
-
// parsed AST's own entityType when one is present (Tier-2 playtest, cycle 8
|
|
3911
|
-
// — "which classes inherit from Widget" used to say "no MODULE matching
|
|
3912
|
-
// 'Widget'" even though entityType="Class" was sitting right there unused,
|
|
3913
|
-
// and "Widget" is neither sha- nor dotted-symbol-shaped so the catch-all is
|
|
3914
|
-
// exactly what fired).
|
|
2971
|
+
// Name what kind of thing was looked for: a sha-shaped term reads as
|
|
2972
|
+
// "commit", a dotted slash-free term as "symbol" (both keep priority over
|
|
2973
|
+
// entityType); otherwise fall back to the parsed AST's own entityType.
|
|
3915
2974
|
const objText = String(parsed.object || "").trim();
|
|
3916
2975
|
const fallback = parsed.entityType && PLURAL_FORMS[parsed.entityType] ? nounFor(parsed.entityType, 1) : "module";
|
|
3917
2976
|
const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
|
|
@@ -3922,20 +2981,13 @@ function renderCore(parsed, result) {
|
|
|
3922
2981
|
};
|
|
3923
2982
|
}
|
|
3924
2983
|
if (result.ambiguous) {
|
|
3925
|
-
// the candidates
|
|
3926
|
-
//
|
|
3927
|
-
// candidates in the prose (not just the structured `candidates` field) —
|
|
3928
|
-
// "narrow the term" is not itself actionable if the reader can't see what
|
|
3929
|
-
// it's ambiguous between; mirrors the mentionsShape branch's listing above.
|
|
2984
|
+
// Name the actual candidates in the prose, not just the structured field
|
|
2985
|
+
// — "narrow the term" isn't actionable if the reader can't see the options.
|
|
3930
2986
|
const pool = [result.objMatch, ...(result.candidates || [])].filter(Boolean);
|
|
3931
2987
|
const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
|
|
3932
2988
|
const shown = pool.slice(0, OVERFLOW_CAP).map((i) => i.label);
|
|
3933
2989
|
const extra = pool.length > OVERFLOW_CAP ? `, …and ${pool.length - OVERFLOW_CAP} more` : "";
|
|
3934
2990
|
const lead = `"${parsed.object}" matches more than one ${noun} ambiguously — did you mean ${listJoin(shown)}${extra}? Try one of those. If you're not sure, narrow it to one name.`;
|
|
3935
|
-
// BREADTH-FIRST (PLAN_BREADTH_FIRST_NLU.md §1): strictly additive to `lead` —
|
|
3936
|
-
// every currently-pinned assertion (test/chat-cefr-1.6.1-decision-log.test.mjs,
|
|
3937
|
-
// chatbench/graded-pool.jsonl's am-tests-cover) is a substring check against
|
|
3938
|
-
// `lead` alone, so appending each branch's real answer never breaks a pin.
|
|
3939
2991
|
const content = (result.branches && result.branches.length)
|
|
3940
2992
|
? `${lead}\n${result.branches.map((b, i) => `${i + 1}) ${b.candidate.label}: ${b.rendered.content}`).join("\n")}`
|
|
3941
2993
|
: lead;
|
|
@@ -3954,14 +3006,9 @@ function renderCore(parsed, result) {
|
|
|
3954
3006
|
}
|
|
3955
3007
|
const m = String(result.site || "").match(/^(.*):(\d+)(?:-(\d+))?$/);
|
|
3956
3008
|
if (m) {
|
|
3957
|
-
// "is defined in" stays
|
|
3958
|
-
//
|
|
3959
|
-
//
|
|
3960
|
-
// g-a1-svo-12 and g-a2-noise-svo-13, are in the promoted always-run subset),
|
|
3961
|
-
// and SKILL_BENCHMARK_CEFR_ENGLISH.md declares that pool append-only/never
|
|
3962
|
-
// edited mid-arc. The other two "defined in" call sites (metaFallbackEntityAnswer
|
|
3963
|
-
// and the composite exists-hit above) answer a DIFFERENT query shape ("what is a
|
|
3964
|
-
// X" / "is there a X"), so they carry the variety instead.
|
|
3009
|
+
// "is defined in" stays unvaried here on purpose — pinned ground truth
|
|
3010
|
+
// for "where is X defined". The other two "defined in" call sites
|
|
3011
|
+
// answer a different query shape and carry the phrasing variety instead.
|
|
3965
3012
|
const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
|
|
3966
3013
|
return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
3967
3014
|
}
|
|
@@ -3996,11 +3043,9 @@ function renderCore(parsed, result) {
|
|
|
3996
3043
|
miss: false, ambiguous: false, matches: result.matches,
|
|
3997
3044
|
};
|
|
3998
3045
|
}
|
|
3999
|
-
// who-last: newest touching commit's
|
|
4000
|
-
//
|
|
4001
|
-
//
|
|
4002
|
-
// answers with the single most recent one instead. Unlike whenShape, no date is
|
|
4003
|
-
// needed to answer "who" — an undated-but-authored commit still resolves.
|
|
3046
|
+
// who-last: newest touching commit's author. "who last touched X" would
|
|
3047
|
+
// otherwise fall into the ordinary reverse-list render and name every
|
|
3048
|
+
// toucher; this answers with just the most recent one.
|
|
4004
3049
|
if (result.whoLastShape) {
|
|
4005
3050
|
const subject = result.objMatch.label;
|
|
4006
3051
|
if (!result.matches.length) {
|
|
@@ -4020,11 +3065,9 @@ function renderCore(parsed, result) {
|
|
|
4020
3065
|
miss: false, ambiguous: false, matches: result.matches,
|
|
4021
3066
|
};
|
|
4022
3067
|
}
|
|
4023
|
-
//
|
|
4024
|
-
//
|
|
4025
|
-
//
|
|
4026
|
-
// undifferentiated list would hide which is which. Same OVERFLOW_CAP as the
|
|
4027
|
-
// other list templates; zero hits is the standard honest blank, commit cited.
|
|
3068
|
+
// Commit-as-subject answers: cite the commit, group touched entities by
|
|
3069
|
+
// class — modules and symbols are different grains, so flattening would
|
|
3070
|
+
// hide which is which.
|
|
4028
3071
|
if (result.commitSubject) {
|
|
4029
3072
|
const cite = `commit ${result.objMatch.label}`;
|
|
4030
3073
|
if (!result.matches.length) {
|
|
@@ -4047,34 +3090,23 @@ function renderCore(parsed, result) {
|
|
|
4047
3090
|
if (!result.objMatch || !result.subjMatch) {
|
|
4048
3091
|
return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
|
|
4049
3092
|
}
|
|
4050
|
-
// the yes render is plain words — the traversal string IS "<kind> edge from
|
|
4051
|
-
// <A> to <B>", so it reads as the sentence itself, not a parenthetical receipt
|
|
4052
|
-
// (the receipt still rides on the result's traversal field for why/verbose).
|
|
4053
3093
|
return {
|
|
4054
3094
|
content: result.answer ? `Yes — ${result.traversal}.` : `No — no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
|
|
4055
3095
|
miss: !result.answer, ambiguous: false,
|
|
4056
3096
|
};
|
|
4057
3097
|
}
|
|
4058
3098
|
if (!result.matches.length) {
|
|
4059
|
-
//
|
|
4060
|
-
//
|
|
4061
|
-
// relation edges are simply absent, not "not found"), so this shape gets its own,
|
|
4062
|
-
// subject-first phrasing rather than reusing reverse's "found ... that OBJECT" template.
|
|
3099
|
+
// Forward: parsed.object is the given subject, not a search target, so it
|
|
3100
|
+
// gets its own subject-first phrasing rather than reverse's template.
|
|
4063
3101
|
if (parsed.shape === "forward") {
|
|
4064
3102
|
return {
|
|
4065
3103
|
content: `${result.objMatch.label} has no ${verbFor(parsed.kind)} edges in the index.`,
|
|
4066
3104
|
miss: true, ambiguous: false,
|
|
4067
3105
|
};
|
|
4068
3106
|
}
|
|
4069
|
-
// "what tests cover X"
|
|
4070
|
-
//
|
|
4071
|
-
//
|
|
4072
|
-
// template garbles: it mislabels the searched kind as "modules" and lets the user's
|
|
4073
|
-
// leaked verb ride into the object ("…tests cover touch X"). Any leading relation
|
|
4074
|
-
// verb (cover/touch/check/verify/… — LEADING_RELATION_VERB_RE, built from the
|
|
4075
|
-
// ask-vocab verb table) is stripped, so the honest empty reads as the natural
|
|
4076
|
-
// "No tests cover X." The frozen entity-keyword form ("which modules test X",
|
|
4077
|
-
// entityType="Module") keeps its pinned wording below.
|
|
3107
|
+
// "what tests cover X" has no explicit entity keyword and "tests" reads
|
|
3108
|
+
// as a verb phrase, so any leaked leading relation verb is stripped
|
|
3109
|
+
// before rendering the honest empty.
|
|
4078
3110
|
if (parsed.kind === "tests" && !parsed.entityType) {
|
|
4079
3111
|
const stripped = String(parsed.object || "").replace(LEADING_RELATION_VERB_RE, "").trim();
|
|
4080
3112
|
const obj = stripped || String(parsed.object || "").trim();
|
|
@@ -4083,35 +3115,26 @@ function renderCore(parsed, result) {
|
|
|
4083
3115
|
miss: true, ambiguous: false,
|
|
4084
3116
|
};
|
|
4085
3117
|
}
|
|
4086
|
-
// NOTE (Cycle 5): a voice-nit rephrasing ("that directly <verb>") was reverted —
|
|
4087
|
-
// the frozen v1 cases.jsonl pins the "whose module directly <verb>s X" wording
|
|
4088
|
-
// (hm-empty-result-calls / tf-wat-calls / ns-wondering), and the case set is
|
|
4089
|
-
// append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
|
|
4090
3118
|
const entityWord = nounFor(parsed.entityType || "Module", 2);
|
|
4091
3119
|
return {
|
|
4092
3120
|
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. ${touchesRephraseHint()}`,
|
|
4093
3121
|
miss: true, ambiguous: false,
|
|
4094
3122
|
};
|
|
4095
3123
|
}
|
|
4096
|
-
// Route by the
|
|
4097
|
-
//
|
|
4098
|
-
//
|
|
4099
|
-
//
|
|
4100
|
-
// ("in a.mjs there is a.mjs"). The fine-grained per-symbol grouping below is only
|
|
4101
|
-
// meaningful when the matches are sub-module entities (functions/classes/etc) — a
|
|
4102
|
-
// Commit list ("which commits touched X") has no containing module to group by, so
|
|
4103
|
-
// anything that is not a fine entity takes the flat join.
|
|
3124
|
+
// Route by the matched entities' actual class, not just the parsed hint —
|
|
3125
|
+
// grouping module-level matches by-module would read as nonsense ("in
|
|
3126
|
+
// a.mjs there is a.mjs"). Fine-grained grouping only applies to sub-module
|
|
3127
|
+
// entities.
|
|
4104
3128
|
if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => !FINE_ENTITY_TYPES.has(m.class))) {
|
|
4105
|
-
// A reverse "who touched X" resolves to Commit individuals — render
|
|
4106
|
-
// (short sha + author) instead of the raw stored sha
|
|
4107
|
-
// labels, etc.) keeps its own label verbatim.
|
|
3129
|
+
// A reverse "who touched X" resolves to Commit individuals — render
|
|
3130
|
+
// friendly refs (short sha + author) instead of the raw stored sha.
|
|
4108
3131
|
const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.class === "Commit" ? commitRefOf(m) : m.label);
|
|
4109
3132
|
const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
|
|
4110
3133
|
return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
|
|
4111
3134
|
}
|
|
4112
|
-
// reverse, fine-grained entity: group by module, one clause per module
|
|
3135
|
+
// reverse, fine-grained entity: group by module, one clause per module —
|
|
4113
3136
|
// the FIRST module states "in {module} there is …"; each SUBSEQUENT module states
|
|
4114
|
-
// "there is … in {module}" (module trails, not leads)
|
|
3137
|
+
// "there is … in {module}" (module trails, not leads).
|
|
4115
3138
|
const byModule = new Map();
|
|
4116
3139
|
for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
|
|
4117
3140
|
const mod = moduleLabelOf(m);
|
|
@@ -4127,25 +3150,16 @@ function renderCore(parsed, result) {
|
|
|
4127
3150
|
}
|
|
4128
3151
|
|
|
4129
3152
|
// ============================================================================
|
|
4130
|
-
//
|
|
4131
|
-
//
|
|
4132
|
-
//
|
|
4133
|
-
//
|
|
4134
|
-
//
|
|
4135
|
-
// re-attempting the full parse (compositional + templates + keyword-spot) after each
|
|
4136
|
-
// transform, and bottoms out in the SAME honest miss + rephrase hint the engine
|
|
4137
|
-
// already returned — never inventing a term or guessing an entity. Deterministic:
|
|
4138
|
-
// same input → same cascade path. All of it is plain JS over the already-imported
|
|
4139
|
-
// tables + resolveObject/parseQuery, so it survives the viewer bundle's import strip.
|
|
3153
|
+
// progressive-relaxation cascade: a controlled loop that runs only when the
|
|
3154
|
+
// direct parse of the normalized query would miss. Only ever drops noise/
|
|
3155
|
+
// unmatched words or normalises a near-canonical word, re-attempting the
|
|
3156
|
+
// full parse after each transform, bottoming out in the same honest miss +
|
|
3157
|
+
// rephrase hint — never inventing a term or guessing an entity.
|
|
4140
3158
|
// ============================================================================
|
|
4141
3159
|
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
*
|
|
4145
|
-
* boolean connectives, placeholder nouns, anaphora/meta/where/mention markers,
|
|
4146
|
-
* relative pronouns, and the small synonym keys. The noise-strip pass will NEVER
|
|
4147
|
-
* remove one of these, and the drop-unmatched pass always keeps them: they carry the
|
|
4148
|
-
* intent, only the packaging around them is negotiable. */
|
|
3160
|
+
/** Every token the closed grammar gives query meaning to. The noise-strip
|
|
3161
|
+
* pass will never remove one of these, and the drop-unmatched pass always
|
|
3162
|
+
* keeps them: they carry the intent, only the packaging is negotiable. */
|
|
4149
3163
|
const CONTENT_VOCAB = new Set([
|
|
4150
3164
|
...wordsOf(Object.keys(VERB_TO_KIND)), ...wordsOf(Object.keys(ENTITY_TO_TYPE)),
|
|
4151
3165
|
...wordsOf(Object.keys(MODIFIER_TO_KIND)), ...wordsOf(Object.keys(QUALIFIERS)),
|
|
@@ -4156,46 +3170,35 @@ const CONTENT_VOCAB = new Set([
|
|
|
4156
3170
|
...wordsOf(Object.keys(CASCADE_SYNONYMS)),
|
|
4157
3171
|
]);
|
|
4158
3172
|
|
|
4159
|
-
/** Structural scaffolding words — question words,
|
|
4160
|
-
*
|
|
4161
|
-
* drop-unmatched pass keeps them
|
|
4162
|
-
*
|
|
4163
|
-
* ("the"/"a"/"show"/"me") — the two sets overlap on purpose. */
|
|
3173
|
+
/** Structural scaffolding words — question words, frame verbs, context
|
|
3174
|
+
* pronouns. Not "content", but they hold a sentence together, so the
|
|
3175
|
+
* drop-unmatched pass keeps them; the noise-strip pass may still remove the
|
|
3176
|
+
* few that are also curated noise ("the"/"a"/"show"/"me"). */
|
|
4164
3177
|
const STRUCTURAL_WORDS = new Set([...STOPWORDS, ...FRAME_WORDS, ...CONTEXT_PRONOUNS]);
|
|
4165
3178
|
const CASCADE_NOISE_SET = new Set(wordsOf(CASCADE_NOISE));
|
|
4166
|
-
/** Every token that carries
|
|
4167
|
-
*
|
|
4168
|
-
*
|
|
4169
|
-
* Layer 4) treats a query as "just a kind noun wrapped in packaging" only when every
|
|
4170
|
-
* non-kind token is one of these — so an unknown qualifier ("shiny") or a relation
|
|
4171
|
-
* verb, being neither, still blocks the default and preserves the honest miss. */
|
|
3179
|
+
/** Every token that carries no graph meaning of its own. The bare-kind-noun
|
|
3180
|
+
* terminal rule treats a query as "just a kind noun wrapped in packaging"
|
|
3181
|
+
* only when every non-kind token is one of these. */
|
|
4172
3182
|
const NOISE_OR_SCAFFOLD = new Set([...CASCADE_NOISE_SET, ...STRUCTURAL_WORDS]);
|
|
4173
3183
|
|
|
4174
|
-
/** The aggregate/list
|
|
4175
|
-
* a typo toward
|
|
4176
|
-
*
|
|
4177
|
-
* "list", "show", … — and never drags in a stray "down"/"off"/"out" from a phrasal
|
|
4178
|
-
* trigger that would mis-correct an unrelated token. */
|
|
3184
|
+
/** The aggregate/list trigger words the cascade's drop-unmatched pass will
|
|
3185
|
+
* fuzzy-correct a typo toward. Curated to clean single verbs so a phrasal
|
|
3186
|
+
* trigger's stray word ("down"/"off") never mis-corrects an unrelated token. */
|
|
4179
3187
|
const TRIGGER_FUZZY_WORDS = [
|
|
4180
3188
|
"many", "count", "number", "quantity", "total", "tally",
|
|
4181
3189
|
"list", "show", "display", "print", "dump", "enumerate", "name",
|
|
4182
3190
|
];
|
|
4183
|
-
/** Closed-vocab words a plain unknown may be fuzzy-corrected
|
|
4184
|
-
* discards it
|
|
4185
|
-
*
|
|
4186
|
-
* it, no entity resolves) and only for a UNIQUE within-bound target, so it strictly
|
|
4187
|
-
* beats dropping — a typo of a trigger keeps its intent instead of being lost. Excludes
|
|
4188
|
-
* STOPWORDS/structural words (a random unknown must never bend into "what"/"the") and
|
|
4189
|
-
* <4-char words (at the small bound they match half of English). */
|
|
3191
|
+
/** Closed-vocab words a plain unknown may be fuzzy-corrected toward before
|
|
3192
|
+
* the cascade discards it. Only fires on a unique within-bound target;
|
|
3193
|
+
* excludes stopwords and <4-char words (too many accidental matches). */
|
|
4190
3194
|
const CASCADE_FUZZY_TARGETS = [...new Set([
|
|
4191
3195
|
...wordsOf(Object.keys(VERB_TO_KIND)),
|
|
4192
3196
|
...Object.keys(ENTITY_TO_TYPE),
|
|
4193
3197
|
...TRIGGER_FUZZY_WORDS,
|
|
4194
3198
|
])].filter((wd) => /^[a-z]+$/.test(wd) && wd.length >= 4 && !STOPWORDS.has(wd));
|
|
4195
3199
|
|
|
4196
|
-
/**
|
|
4197
|
-
* a distance tie between two distinct targets is refused
|
|
4198
|
-
* vocabulary level, cf. fuzzyVocabWord). */
|
|
3200
|
+
/** Unique within-bound fuzzy correction of `w` toward CASCADE_FUZZY_TARGETS,
|
|
3201
|
+
* or null — a distance tie between two distinct targets is refused. */
|
|
4199
3202
|
function fuzzyCascadeWord(w) {
|
|
4200
3203
|
const bound = fuzzyBound(w);
|
|
4201
3204
|
let best = bound + 1; let hit = null; let tied = false;
|
|
@@ -4207,23 +3210,12 @@ function fuzzyCascadeWord(w) {
|
|
|
4207
3210
|
return best <= bound && !tied ? hit : null;
|
|
4208
3211
|
}
|
|
4209
3212
|
|
|
4210
|
-
/** The typo
|
|
4211
|
-
*
|
|
4212
|
-
*
|
|
4213
|
-
*
|
|
4214
|
-
*
|
|
4215
|
-
*
|
|
4216
|
-
* without this guard, "which modles import a.mjs" silently pivoted onto the
|
|
4217
|
-
* CLASS Module and confidently answered a question the visitor never asked
|
|
4218
|
-
* ("No — no imports edge found from Module to app/lib/a.mjs"). Reporting the
|
|
4219
|
-
* parse unanswerable sends it to the relaxation cascade, whose drop-unmatched
|
|
4220
|
-
* layer restores the kind noun (fuzzyCascadeWord) and whose winning re-parse is
|
|
4221
|
-
* ANNOUNCED as a repair receipt ('read as "which modules import a.mjs" — …').
|
|
4222
|
-
* If the cascade cannot produce a real answer, the original parse still stands
|
|
4223
|
-
* (ask() keeps the direct parse when relaxParse returns null), so a genuine
|
|
4224
|
-
* schema-adjacent question is never turned into a new kind of miss. Exact and
|
|
4225
|
-
* substring/prose matches are untouched — the guard reads matchedVia:"fuzzy"
|
|
4226
|
-
* only, and only when the kind-noun reading exists. */
|
|
3213
|
+
/** The typo->schema-term trap guard: a term that resolves only via tier-5
|
|
3214
|
+
* bounded-fuzzy onto a schema individual, while the same word also
|
|
3215
|
+
* fuzzy-corrects to an entity kind noun ("modles" -> "modules"), is a
|
|
3216
|
+
* typo'd kind noun, not a schema question. Reporting it unanswerable sends
|
|
3217
|
+
* it to the relaxation cascade instead of confidently answering the wrong
|
|
3218
|
+
* question. Exact/substring/prose matches are untouched. */
|
|
4227
3219
|
function schemaTypoTrap(resolution, term) {
|
|
4228
3220
|
if (!resolution?.match || resolution.matchedVia !== "fuzzy" || resolution.ambiguous) return false;
|
|
4229
3221
|
const cls = resolution.match.class;
|
|
@@ -4233,17 +3225,11 @@ function schemaTypoTrap(resolution, term) {
|
|
|
4233
3225
|
return !!kindNoun && kindNoun !== lc && !!ENTITY_TO_TYPE[kindNoun];
|
|
4234
3226
|
}
|
|
4235
3227
|
|
|
4236
|
-
/** Is `parsed` a genuinely
|
|
4237
|
-
* clauses) resolves its named term(s)
|
|
4238
|
-
*
|
|
4239
|
-
*
|
|
4240
|
-
*
|
|
4241
|
-
* "ambiguous"/"pronoun" — a specific outcome to keep, distinct from relaxable
|
|
4242
|
-
* false — no parse at all, a compositional {node:"miss"}, an unresolved term,
|
|
4243
|
-
* or a fuzzy-only schema-individual hit with a kind-noun reading
|
|
4244
|
-
* (schemaTypoTrap above — relaxable, so the cascade can re-read it)
|
|
4245
|
-
* ask() starts the cascade ONLY on `false`, and accepts a relaxed attempt ONLY on the
|
|
4246
|
-
* strict `true` (so the cascade can never "rescue" a query into another kind of miss). */
|
|
3228
|
+
/** Is `parsed` a genuinely answerable query — one that both parsed and (for
|
|
3229
|
+
* simple clauses) resolves its named term(s)? Returns true (real, executable
|
|
3230
|
+
* answer), "ambiguous"/"pronoun" (a specific outcome to keep, not relaxed),
|
|
3231
|
+
* or false (no parse, a compositional miss, an unresolved term, or a
|
|
3232
|
+
* schemaTypoTrap hit — relaxable). ask() starts the cascade only on false. */
|
|
4247
3233
|
function answerable(graph, parsed, contextId) {
|
|
4248
3234
|
if (!parsed) return false;
|
|
4249
3235
|
if (parsed.ambiguousParse) return "ambiguous";
|
|
@@ -4260,9 +3246,8 @@ function answerable(graph, parsed, contextId) {
|
|
|
4260
3246
|
return true;
|
|
4261
3247
|
}
|
|
4262
3248
|
|
|
4263
|
-
/** Whole-query help/orientation request
|
|
4264
|
-
*
|
|
4265
|
-
* curated HELP_TRIGGER, so a symbol named "help" in a real question is untouched. */
|
|
3249
|
+
/** Whole-query help/orientation request -> show the hint directly. Matches
|
|
3250
|
+
* only when the entire normalized query is a curated HELP_TRIGGER. */
|
|
4266
3251
|
function isHelpRequest(query) {
|
|
4267
3252
|
const q = String(query || "").trim().toLowerCase().replace(/[?.!\s]+$/, "");
|
|
4268
3253
|
return HELP_TRIGGERS.includes(q);
|
|
@@ -4305,18 +3290,11 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
|
|
|
4305
3290
|
const r = resolveObject(graph, t);
|
|
4306
3291
|
return !!r.match && r.tier != null && r.tier <= 3;
|
|
4307
3292
|
};
|
|
4308
|
-
// Does a term string carry at least one
|
|
4309
|
-
// own as vocabulary/scaffolding? Guards against a relaxation that
|
|
4310
|
-
// asked term and lets a bare marker slide into its place
|
|
4311
|
-
//
|
|
4312
|
-
//
|
|
4313
|
-
// the real, deliberate object here — it resolves through contextId, not through
|
|
4314
|
-
// vocabulary the grammar "already owns" as scaffolding — so it must count as a real
|
|
4315
|
-
// term rather than being mistaken for a dropped-into-place marker. Without this, a
|
|
4316
|
-
// relaxed candidate whose object survived layer 2 as a lone pronoun ("what else is
|
|
4317
|
-
// in that class" → drop "class"/"else" → "what does that contain") was rejected as
|
|
4318
|
-
// if it named nothing at all, even though the pronoun resolves to a real, answerable
|
|
4319
|
-
// focus (0.9.15 Tier-1 single-touch playtest).
|
|
3293
|
+
// Does a term string carry at least one real word, one the grammar doesn't
|
|
3294
|
+
// already own as vocabulary/scaffolding? Guards against a relaxation that
|
|
3295
|
+
// drops the actual asked term and lets a bare marker slide into its place.
|
|
3296
|
+
// A bare context pronoun is the one exception — it resolves through
|
|
3297
|
+
// contextId, so it counts as a real term.
|
|
4320
3298
|
const hasRealTerm = (s) => {
|
|
4321
3299
|
const whole = String(s || "").trim().toLowerCase();
|
|
4322
3300
|
if (CONTEXT_PRONOUNS.includes(whole)) return true;
|
|
@@ -4325,10 +3303,9 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
|
|
|
4325
3303
|
return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
|
|
4326
3304
|
});
|
|
4327
3305
|
};
|
|
4328
|
-
// Accept a relaxed attempt
|
|
4329
|
-
//
|
|
4330
|
-
//
|
|
4331
|
-
// never by promoting a bare marker to the asked term.
|
|
3306
|
+
// Accept a relaxed attempt only if it's genuinely answerable and renders a
|
|
3307
|
+
// real positive answer — relaxation earns a win only by turning a miss
|
|
3308
|
+
// into an answer, never a differently-worded miss.
|
|
4332
3309
|
const TERM_SHAPES = new Set(["reverse", "forward", "where", "when", "ask"]);
|
|
4333
3310
|
const attempt = (toks) => {
|
|
4334
3311
|
const text = toks.join(" ");
|
|
@@ -4372,11 +3349,9 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
|
|
|
4372
3349
|
survivors.push(t);
|
|
4373
3350
|
continue;
|
|
4374
3351
|
}
|
|
4375
|
-
//
|
|
4376
|
-
//
|
|
4377
|
-
//
|
|
4378
|
-
// discarded, so the count/list intent survives. Only a unique within-bound hit; else
|
|
4379
|
-
// the token is genuinely unrecoverable and drops exactly as before.
|
|
3352
|
+
// Before dropping an unmatched token, try a bounded fuzzy-correct to a
|
|
3353
|
+
// unique closed-vocab word, so a typo'd trigger ("manyn"->"many") is
|
|
3354
|
+
// restored rather than discarded.
|
|
4380
3355
|
const fix = fuzzyCascadeWord(lc);
|
|
4381
3356
|
if (fix && fix !== lc) { survivors.push(fix); corrected.push(`${t}→${fix}`); continue; }
|
|
4382
3357
|
nowDropped.push(t);
|
|
@@ -4403,90 +3378,54 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
|
|
|
4403
3378
|
if (hit) return done(hit);
|
|
4404
3379
|
}
|
|
4405
3380
|
|
|
4406
|
-
// Layer 4 (terminal) —
|
|
4407
|
-
//
|
|
4408
|
-
//
|
|
4409
|
-
//
|
|
4410
|
-
//
|
|
4411
|
-
//
|
|
4412
|
-
//
|
|
4413
|
-
// noise, whereas the count is the cheap useful answer the asker can then drill into
|
|
4414
|
-
// ("list them"). Deterministic — count for every kind, no cardinality cap.
|
|
4415
|
-
//
|
|
4416
|
-
// We classify the ORIGINAL normalized tokens (`from`), NOT the layer-mutated `tokens`:
|
|
4417
|
-
// drop-unmatched has by now EATEN any unknown qualifier, so "the shiny classes" would
|
|
4418
|
-
// otherwise look identical to a bare "classes". Reading the whole phrase keeps the
|
|
4419
|
-
// discipline exact — the rule fires ONLY when every non-kind token is pure packaging
|
|
4420
|
-
// (NOISE_OR_SCAFFOLD). A dangling unknown qualifier ("the shiny classes"), a relation
|
|
4421
|
-
// verb, a marker, or a real term is neither noise nor a kind noun, so it lands in
|
|
4422
|
-
// `others`, blocks the default, and the honest miss (or the real compositional query,
|
|
4423
|
-
// if a lower layer already rescued it) stands.
|
|
3381
|
+
// Layer 4 (terminal) — bare kind noun -> a bounded default action. A query
|
|
3382
|
+
// that is only a kind noun wrapped in articles/noise/question words
|
|
3383
|
+
// defaults to a count of that kind ("the classes" -> "20 classes."), never
|
|
3384
|
+
// a list (unscoped lists of hundreds are noise; count is the cheap useful
|
|
3385
|
+
// answer). Classifies the original normalized tokens (`from`), not the
|
|
3386
|
+
// layer-mutated `tokens` — drop-unmatched has already eaten any unknown
|
|
3387
|
+
// qualifier, so "the shiny classes" must be told apart from bare "classes".
|
|
4424
3388
|
const bareLc = splitWords(from).map((t) => t.toLowerCase());
|
|
4425
3389
|
const kindWords = [];
|
|
4426
3390
|
const others = [];
|
|
4427
3391
|
for (const t of bareLc) {
|
|
4428
3392
|
if (NOISE_OR_SCAFFOLD.has(t)) continue;
|
|
4429
|
-
//
|
|
4430
|
-
// node class), so it is not a countable kind; it falls into `others`.
|
|
3393
|
+
// "Change" is ask-vocab's pseudo-type, never a node class, so it's not countable.
|
|
4431
3394
|
const et = ENTITY_TO_TYPE[t];
|
|
4432
3395
|
if (et && et !== "Change") kindWords.push(t);
|
|
4433
3396
|
else others.push(t);
|
|
4434
3397
|
}
|
|
4435
3398
|
if (kindWords.length === 1 && others.length === 0) {
|
|
4436
|
-
//
|
|
4437
|
-
//
|
|
3399
|
+
// Reuse the whole aggregate pipeline: a synthesized "count <kind>" is the
|
|
3400
|
+
// exact query the cascade's other count paths land.
|
|
4438
3401
|
const hit = attempt(["count", kindWords[0]]);
|
|
4439
3402
|
if (hit) { steps.push(`bare kind "${kindWords[0]}" → count`); return done(hit); }
|
|
4440
3403
|
}
|
|
4441
|
-
// A LONE unknown noun wrapped only in packaging ("the bananas") is left to the generic
|
|
4442
|
-
// honest miss (the rephrase hint already NAMES the kinds): a crisper "isn't a listable
|
|
4443
|
-
// kind" miss here would fire on every one-word non-query the same way ("tell me a joke"),
|
|
4444
|
-
// which chat.mjs's own surface deliberately answers with the general hint — so the
|
|
4445
|
-
// bare-noun default is a COUNT of a KNOWN kind only, never a re-worded miss.
|
|
4446
3404
|
|
|
4447
|
-
return null; // exhausted — the honest bottom of the cascade
|
|
3405
|
+
return null; // exhausted — the honest bottom of the cascade
|
|
4448
3406
|
}
|
|
4449
3407
|
|
|
4450
|
-
// ---- orchestration — the tmct_ask entry point (
|
|
3408
|
+
// ---- orchestration — the tmct_ask entry point (parse -> resolve -> traverse -> render) ----
|
|
4451
3409
|
|
|
4452
3410
|
/** Answer a free-text question over the graph, mechanically. `opts.contextId`
|
|
4453
|
-
* resolves a context pronoun
|
|
4454
|
-
*
|
|
4455
|
-
*
|
|
4456
|
-
*
|
|
4457
|
-
* a
|
|
4458
|
-
*
|
|
4459
|
-
*
|
|
4460
|
-
* those are tested", "how many of them call X") filters/counts the prior result
|
|
4461
|
-
* set; omit it and anaphora questions produce an honest "needs a previous answer"
|
|
4462
|
-
* miss. Returns the full {content, tmct_ask:
|
|
4463
|
-
* {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}} envelope
|
|
4464
|
-
* §6.2 specifies. Zero generative model calls. */
|
|
4465
|
-
// Seonix Batch 3 (3b), singular subject: "the last commit"/"the latest commit"/"the
|
|
4466
|
-
// most recent commit" — literal-phrase substitution, checked as a whole-word match
|
|
4467
|
-
// (not anchored to the whole line, since it may sit mid-sentence as the subject of a
|
|
4468
|
-
// longer question, e.g. "what did the last commit touch").
|
|
3411
|
+
* resolves a context pronoun, wired from a UI's currently-selected node;
|
|
3412
|
+
* omit it in the bare CLI surface. `opts.nlp` overrides the lemma/POS
|
|
3413
|
+
* adapter. `opts.prev` is the id array of the last answer's matches, for
|
|
3414
|
+
* anaphora follow-ups; omit it and anaphora questions produce an honest
|
|
3415
|
+
* "needs a previous answer" miss. Returns {content, tmct_ask:
|
|
3416
|
+
* {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}}. Zero
|
|
3417
|
+
* generative model calls. */
|
|
4469
3418
|
const LAST_COMMIT_PHRASE_RE = /\b(?:the\s+)?(?:last|latest|most\s+recent)\s+commit\b/i;
|
|
4470
3419
|
|
|
4471
|
-
/** resolveObject has no notion of "the newest Commit individual"
|
|
4472
|
-
*
|
|
4473
|
-
*
|
|
4474
|
-
*
|
|
4475
|
-
*
|
|
4476
|
-
*
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
* change. A graph with no commits, or no date on any commit, leaves the query text
|
|
4481
|
-
* untouched — an honest miss downstream, never a guess at which commit is "last". */
|
|
4482
|
-
// A bare "when was/did commit X" with no change-verb tail at all (the shape left
|
|
4483
|
-
// once "the latest commit" is substituted out of "when was the latest commit") —
|
|
4484
|
-
// grammar.mjs's T8 "when" template requires a touches-family verb to fire, so this
|
|
4485
|
-
// would otherwise honestly miss even though traverse()'s own "when" branch already
|
|
4486
|
-
// special-cases a Commit OBJECT to answer with its own date (see the commit-as-
|
|
4487
|
-
// subject flip's sibling branch, just above the flip itself). Bridged by appending
|
|
4488
|
-
// the neutral "touched" tail — never for a query that already names its own verb
|
|
4489
|
-
// ("what did the last commit touch" is untouched).
|
|
3420
|
+
/** resolveObject has no notion of "the newest Commit individual", and the
|
|
3421
|
+
* bare word "commit" itself component-matches the Commit SchemaClass node,
|
|
3422
|
+
* so "what did the last commit touch" used to render a false empty. Fixed
|
|
3423
|
+
* by textual substitution before the normal parse/resolve pipeline runs:
|
|
3424
|
+
* the phrase is swapped for "commit <newest-sha>". A graph with no commits,
|
|
3425
|
+
* or no dated commit, leaves the query text untouched. */
|
|
3426
|
+
// A bare "when was/did commit X" with no change-verb tail (the shape left
|
|
3427
|
+
// once "the latest commit" is substituted out) needs a "touched" tail
|
|
3428
|
+
// appended, since grammar.mjs's T8 "when" template requires a verb to fire.
|
|
4490
3429
|
const BARE_WHEN_COMMIT_RE = /^when\s+(?:was|were|is|did|does|do)\s+commit\s+[0-9a-fA-F:]+$/i;
|
|
4491
3430
|
|
|
4492
3431
|
function substituteLastCommitPhrase(graph, query) {
|
|
@@ -4502,31 +3441,14 @@ function substituteLastCommitPhrase(graph, query) {
|
|
|
4502
3441
|
return BARE_WHEN_COMMIT_RE.test(bareTrimmed) ? `${bareTrimmed} touched` : out;
|
|
4503
3442
|
}
|
|
4504
3443
|
|
|
4505
|
-
// ---- dynamic memory-graph class count/list
|
|
4506
|
-
//
|
|
4507
|
-
//
|
|
4508
|
-
//
|
|
4509
|
-
//
|
|
4510
|
-
//
|
|
4511
|
-
//
|
|
4512
|
-
//
|
|
4513
|
-
//
|
|
4514
|
-
// ENTITY_TO_TYPE (ask-vocab.mjs) is a CLOSED table of code-graph nouns only
|
|
4515
|
-
// (module/function/class/…) — memory-graph classes are open-ended (taught, not a
|
|
4516
|
-
// fixed vocabulary), so they can't be added to that table the same way. Instead,
|
|
4517
|
-
// this resolves the noun against whatever classes ACTUALLY have at least one
|
|
4518
|
-
// individual in THIS graph right now (never guesses a class exists with zero
|
|
4519
|
-
// evidence — same zero-fabrication discipline as everything else here) and
|
|
4520
|
-
// reuses the exact SAME count/list AST + traverse()+render() path every
|
|
4521
|
-
// code-graph count/list query already runs through (evalComposite's
|
|
4522
|
-
// "allOfClass"/"count"/"list" nodes, already generic over any `individual.class`
|
|
4523
|
-
// string — see `evalSet`'s "allOfClass" case and renderComposite's "count"/"list"
|
|
4524
|
-
// branches above) — no new render logic, no new miss/hit wording invented.
|
|
4525
|
-
//
|
|
4526
|
-
// Fires ONLY as a fallback in ask() after the normal cascade already produced an
|
|
4527
|
-
// honest miss, and is skipped entirely for any noun ENTITY_TO_TYPE already owns
|
|
4528
|
-
// (so a real code-graph "list modules"/"how many classes" answer, including its
|
|
4529
|
-
// own honest empty-graph miss wording, is never intercepted or changed).
|
|
3444
|
+
// ---- dynamic memory-graph class count/list: a "list/count all X of class Y"
|
|
3445
|
+
// shape for memory-graph classes (Fact/Utterance/Session/Source/Rule, or any
|
|
3446
|
+
// taught class), reachable via ask.mjs alone. ENTITY_TO_TYPE is a closed
|
|
3447
|
+
// code-graph noun table, so this instead resolves against whatever classes
|
|
3448
|
+
// actually have an individual in this graph, reusing the same count/list
|
|
3449
|
+
// AST + traverse()/render() path every code-graph query runs through. Fires
|
|
3450
|
+
// only as a fallback after the normal cascade already produced an honest
|
|
3451
|
+
// miss, and is skipped for any noun ENTITY_TO_TYPE already owns. ----
|
|
4530
3452
|
function singularCandidates(word) {
|
|
4531
3453
|
const w = String(word || "").toLowerCase();
|
|
4532
3454
|
const c = new Set([w]);
|
|
@@ -4545,16 +3467,13 @@ function resolveDynamicClass(graph, word) {
|
|
|
4545
3467
|
}
|
|
4546
3468
|
const DYNAMIC_LIST_TRIGGER_RE = /^(?:list|show(?:\s+me)?)\s+(?:all\s+|the\s+)?([a-z][a-z'-]*)\s*(.*)$/i;
|
|
4547
3469
|
const DYNAMIC_COUNT_TRIGGER_RE = /^(?:how\s+many|number\s+of|count(?:\s+the)?)\s+([a-z][a-z'-]*)\s*(.*)$/i;
|
|
4548
|
-
// A closed set of harmless trailing fillers
|
|
4549
|
-
//
|
|
4550
|
-
// (a real restrictor like "that mention X") is NOT this shape and is left alone
|
|
4551
|
-
// so it stays whatever honest miss the normal cascade already produced.
|
|
3470
|
+
// A closed set of harmless trailing fillers; anything else (a real
|
|
3471
|
+
// restrictor like "that mention X") is left alone.
|
|
4552
3472
|
const DYNAMIC_TAIL_OK_RE = /^(?:are there(?:\s+in\s+total)?|is there|do you know(?:\s+about)?|do you have|exist(?:s)?|are known|in (?:the |a )?(?:graph|memory)|you know(?:\s+about)?)?[?.!\s]*$/i;
|
|
4553
3473
|
|
|
4554
|
-
/** Compile "list/how many <memory-class-noun>" into the same count/list AST
|
|
4555
|
-
* code-graph count/list query already builds, or null when this isn't
|
|
4556
|
-
*
|
|
4557
|
-
* no individual in THIS graph actually carries that class). */
|
|
3474
|
+
/** Compile "list/how many <memory-class-noun>" into the same count/list AST
|
|
3475
|
+
* every code-graph count/list query already builds, or null when this isn't
|
|
3476
|
+
* that shape. */
|
|
4558
3477
|
function dynamicClassQuery(graph, query) {
|
|
4559
3478
|
const q = String(query || "").trim();
|
|
4560
3479
|
const listM = q.match(DYNAMIC_LIST_TRIGGER_RE);
|
|
@@ -4568,9 +3487,11 @@ function dynamicClassQuery(graph, query) {
|
|
|
4568
3487
|
return listM ? { node: "list", entityType, base, scoped: false } : { node: "count", entityType, base };
|
|
4569
3488
|
}
|
|
4570
3489
|
|
|
3490
|
+
// Matches T5's own bare-object capture (grammar.mjs meta-whatis), reused below to
|
|
3491
|
+
// extract the term for the article-insertion fallback rather than duplicating it.
|
|
3492
|
+
const BARE_META_WHATIS_RE = /^what\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
|
|
3493
|
+
|
|
4571
3494
|
export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
|
|
4572
|
-
// Explicit help/orientation request → the rephrase hint directly (the honest bottom
|
|
4573
|
-
// of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
|
|
4574
3495
|
if (isHelpRequest(query)) {
|
|
4575
3496
|
return {
|
|
4576
3497
|
content: rephraseHint(),
|
|
@@ -4580,17 +3501,12 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4580
3501
|
},
|
|
4581
3502
|
};
|
|
4582
3503
|
}
|
|
4583
|
-
// Seonix Batch 3 (3b), singular subject: substitute "the last/latest/most recent
|
|
4584
|
-
// commit" for the real newest Commit's own id BEFORE anything else runs, so the
|
|
4585
|
-
// rest of the pipeline (direct parse, relaxation, resolveObject) never has to know
|
|
4586
|
-
// this phrase existed — see substituteLastCommitPhrase's own doc above.
|
|
4587
3504
|
query = substituteLastCommitPhrase(graph, query);
|
|
4588
3505
|
const directFull = parseQueryFull(query, { nlp });
|
|
4589
3506
|
const direct = directFull.parsed;
|
|
4590
|
-
// The relaxation cascade fires
|
|
4591
|
-
//
|
|
4592
|
-
//
|
|
4593
|
-
// the direct parse untouched (a hit stays instant and exact).
|
|
3507
|
+
// The relaxation cascade fires only when the direct parse would miss; a
|
|
3508
|
+
// clean hit, an ambiguous parse, an unresolved-pronoun miss, and a
|
|
3509
|
+
// real-but-empty answer all keep the direct parse untouched.
|
|
4594
3510
|
let parsed = direct;
|
|
4595
3511
|
let relaxed = null;
|
|
4596
3512
|
if (answerable(graph, direct, contextId) === false) {
|
|
@@ -4599,11 +3515,8 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4599
3515
|
}
|
|
4600
3516
|
let result = traverse(graph, parsed, { contextId, prev });
|
|
4601
3517
|
let rendered = render(parsed, result);
|
|
4602
|
-
// Dynamic memory-graph class count/list fallback
|
|
4603
|
-
//
|
|
4604
|
-
// replaces it when the fallback itself produces a real (non-miss) answer, so a
|
|
4605
|
-
// genuine "no X in this index" miss for an ENTITY_TO_TYPE-owned noun is never
|
|
4606
|
-
// touched (dynamicClassQuery declines those itself — see its own doc above).
|
|
3518
|
+
// Dynamic memory-graph class count/list fallback, only once everything
|
|
3519
|
+
// above already produced an honest miss.
|
|
4607
3520
|
if (rendered.miss && !rendered.ambiguous) {
|
|
4608
3521
|
const dyn = dynamicClassQuery(graph, query);
|
|
4609
3522
|
if (dyn) {
|
|
@@ -4612,37 +3525,33 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4612
3525
|
if (!dynRendered.miss) { parsed = dyn; result = dynResult; rendered = dynRendered; relaxed = null; }
|
|
4613
3526
|
}
|
|
4614
3527
|
}
|
|
4615
|
-
//
|
|
4616
|
-
//
|
|
3528
|
+
// Bare "what is X" (no article) meta fallback, narrow to ask() and never
|
|
3529
|
+
// touching the outer `parsed` even on a miss — chat.mjs's own gates key off
|
|
3530
|
+
// `!envelope.parsed`, so populating it here would steal turns from lanes
|
|
3531
|
+
// like moduleOrientLane ("what is X for"). Fires only once nothing else parsed.
|
|
3532
|
+
if (parsed === null && rendered.miss && !rendered.ambiguous) {
|
|
3533
|
+
const bareM = String(query || "").trim().match(BARE_META_WHATIS_RE);
|
|
3534
|
+
const bareTerm = bareM?.[1]?.trim();
|
|
3535
|
+
if (bareTerm && !/\s+(?:for|about)$/i.test(bareTerm)) {
|
|
3536
|
+
const bareParsed = parseQuery(`what is a ${bareTerm}`, { nlp });
|
|
3537
|
+
if (bareParsed?.shape === "meta") {
|
|
3538
|
+
result = traverse(graph, bareParsed, { contextId, prev });
|
|
3539
|
+
rendered = render(bareParsed, result);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
// If relaxation materially rewrote the query and produced a real answer,
|
|
3544
|
+
// note it lightly so the reader knows how the question was read.
|
|
4617
3545
|
let content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
|
|
4618
3546
|
? `read as "${relaxed.to}" — ${rendered.content}`
|
|
4619
3547
|
: rendered.content;
|
|
4620
|
-
//
|
|
4621
|
-
//
|
|
4622
|
-
//
|
|
4623
|
-
//
|
|
4624
|
-
//
|
|
4625
|
-
//
|
|
4626
|
-
//
|
|
4627
|
-
// answer. Relaxation rewrote the query, so `directFull`'s alternates no
|
|
4628
|
-
// longer describe the question actually answered — skipped rather than shown
|
|
4629
|
-
// stale.
|
|
4630
|
-
//
|
|
4631
|
-
// REAL-ANSWER-ONLY, never the bare "ask it that way" pointer (live-caught,
|
|
4632
|
-
// 2026-07-11): a lower-precedence strategy's "alternate" is often pure noise,
|
|
4633
|
-
// not a genuine second reading — e.g. keyword-spot misreading a stripped
|
|
4634
|
-
// filler phrase as the query's SUBJECT ("hey man which modules import X" ->
|
|
4635
|
-
// an "ask" parse with subject:"hey man"). That never resolves to a real graph
|
|
4636
|
-
// entity, so `alternateLines`'s default "if you mean X then ask it that way"
|
|
4637
|
-
// fallback would surface exactly the low-value dead-end nudge this whole plan
|
|
4638
|
-
// exists to eliminate — worse, it's non-deterministic across equivalent
|
|
4639
|
-
// phrasings (test/interpret.test.mjs's own noise-strip parity check caught
|
|
4640
|
-
// this: "hey man which modules import X" must answer byte-identically to the
|
|
4641
|
-
// clean phrasing, and a garbage alternate broke that). So: compute each
|
|
4642
|
-
// alternate's real answer directly (not via alternateLines' fallback-prone
|
|
4643
|
-
// default) and only ever append lines for alternates that resolved to
|
|
4644
|
-
// something real — an alternate that can't be answered is dropped silently,
|
|
4645
|
-
// never padded with an unhelpful pointer.
|
|
3548
|
+
// A genuine distinct-class alternate reading (merge.mjs's `alternates`)
|
|
3549
|
+
// is surfaced on a real direct hit, computing each alternate's own real
|
|
3550
|
+
// answer directly rather than via alternateLines' fallback-prone
|
|
3551
|
+
// "ask it that way" pointer — an alternate that can't be answered for
|
|
3552
|
+
// real is dropped silently. Skipped when relaxation rewrote the query,
|
|
3553
|
+
// since directFull's alternates would no longer describe the question
|
|
3554
|
+
// actually answered.
|
|
4646
3555
|
if (!relaxed && !rendered.miss && !rendered.ambiguous && directFull.alternates.length) {
|
|
4647
3556
|
const answered = directFull.alternates
|
|
4648
3557
|
.map((a) => {
|
|
@@ -4663,13 +3572,6 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4663
3572
|
tmct_ask: {
|
|
4664
3573
|
mechanical: true,
|
|
4665
3574
|
parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
|
|
4666
|
-
// PLAN_BREADTH_FIRST_NLU.md §Track 6 (operator directive): the canonical
|
|
4667
|
-
// restatement of what the request was understood to mean, ALWAYS present
|
|
4668
|
-
// when anything parsed at all — not gated on ambiguity/miss the way the
|
|
4669
|
-
// ambiguity-branch labels are. `english` is the human-readable gloss in
|
|
4670
|
-
// tmct's own phrasing; `machine` is the same fact in a compact,
|
|
4671
|
-
// machine-parsable notation (a plain `shape(kind, args...)` call form).
|
|
4672
|
-
// Both are read straight off `parsed` — never generated.
|
|
4673
3575
|
canonical: canonicalOf(parsed),
|
|
4674
3576
|
matches: (result.matches || []).map((m) => ({
|
|
4675
3577
|
id: m.id, label: m.label, type: m.class, module: m.class ? moduleLabelOf(m) : undefined,
|
|
@@ -4677,16 +3579,12 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
|
|
|
4677
3579
|
traversal: result.traversal || null,
|
|
4678
3580
|
miss: !!rendered.miss,
|
|
4679
3581
|
ambiguous: !!rendered.ambiguous,
|
|
4680
|
-
//
|
|
4681
|
-
//
|
|
4682
|
-
// dropped/normalised to reach an answer. A caller can assert relaxed===null to
|
|
4683
|
-
// prove the cascade never touched a direct hit.
|
|
3582
|
+
// null when the direct parse was used as-is; a caller can assert
|
|
3583
|
+
// relaxed===null to prove the cascade never touched a direct hit.
|
|
4684
3584
|
relaxed,
|
|
4685
|
-
//
|
|
4686
|
-
//
|
|
4687
|
-
//
|
|
4688
|
-
// resolved a typo'd term (the rendered content also announces it: "assuming you
|
|
4689
|
-
// meant <label>"); null for every literal-identifier tier.
|
|
3585
|
+
// "prose" (tier-4 prose-index fallback) or "fuzzy" (tier-5 bounded
|
|
3586
|
+
// edit-distance, announced in the content as "assuming you meant …");
|
|
3587
|
+
// null for every literal-identifier tier.
|
|
4690
3588
|
matchedVia: result.matchedVia || null,
|
|
4691
3589
|
...(rendered.ambiguous ? { candidates: rendered.candidates } : {}),
|
|
4692
3590
|
},
|