@polycode-projects/seonix 0.7.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -114
- package/bin/cli.mjs +12 -10
- package/package.json +3 -4
- package/src/ask-browser-entry.mjs +43 -0
- package/src/ask-browser.bundle.js +3040 -0
- package/src/browser.mjs +1 -0
- package/src/chat-shim.mjs +37 -0
- package/src/codegraph.mjs +30 -17
- package/src/extract.mjs +33 -10
- package/src/interfaces.mjs +171 -0
- package/src/jsts_tsc.mjs +17 -1
- package/src/paths.mjs +31 -0
- package/src/prose.mjs +3 -3
- package/src/schema-docs.mjs +1 -1
- package/src/server.mjs +62 -8
- package/src/sessions.mjs +3 -3
- package/src/source.mjs +16 -3
- package/src/summary.mjs +7 -3
- package/src/tmct-provider.mjs +37 -0
- package/src/toml-config.mjs +13 -0
- package/src/uuid.mjs +1 -1
- package/src/viz.mjs +21 -86
- package/src/ask-nlp.mjs +0 -73
- package/src/ask-vocab.mjs +0 -687
- package/src/ask.mjs +0 -2419
- package/src/chat.mjs +0 -850
- package/src/nlp-bundle.mjs +0 -120
- package/src/prose-nlp.mjs +0 -52
package/src/ask.mjs
DELETED
|
@@ -1,2419 +0,0 @@
|
|
|
1
|
-
// ask.mjs — a mechanical (zero-model-call) natural-language query engine over the
|
|
2
|
-
// seonix graph. PLAN_MECHANICAL_CHAT.md (P0): a small, closed English grammar
|
|
3
|
-
// compiles a free-text question into a graph traversal over the SAME classified
|
|
4
|
-
// relation groups codegraph.mjs's other render functions read, then renders a
|
|
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.
|
|
12
|
-
//
|
|
13
|
-
// Four pure, independently-testable stages, orchestrated by ask():
|
|
14
|
-
// parseQuery (grammar) -> resolveObject (mechanical term resolution) ->
|
|
15
|
-
// traverse (graph lookup) -> render (templates).
|
|
16
|
-
//
|
|
17
|
-
// §3.5/3.6 (2026-07-02, ELIZA/PARRY-style breadth): parseQuery normalizes the
|
|
18
|
-
// raw text (contractions, g-drop, filler-strip), rewrites recognized negative-
|
|
19
|
-
// rhetorical constructions to their affirmative form, then runs TWO INDEPENDENT
|
|
20
|
-
// parsing STRATEGIES against the same normalized text — the original anchored-
|
|
21
|
-
// template matcher (precise, fast, unweakened) and a keyword-spotting/
|
|
22
|
-
// decomposition matcher (ELIZA's own mechanism: find the keyword, decompose
|
|
23
|
-
// around it, tolerate reordering/casual phrasing) — and MERGES their results:
|
|
24
|
-
// one strategy hit -> use it; both hit and agree -> use it (high confidence);
|
|
25
|
-
// both hit and DISAGREE -> a genuine parse-level ambiguity, surfaced honestly;
|
|
26
|
-
// neither hits -> the honest grammar miss. STRATEGIES is a plain array so a
|
|
27
|
-
// third strategy could join the same way, not a hardcoded two-branch special
|
|
28
|
-
// case.
|
|
29
|
-
//
|
|
30
|
-
// Where a parsed intent is temporal/churn-shaped (touched/since/cochange as a
|
|
31
|
-
// FILTER over commits, not a structural edge), this engine does NOT re-implement
|
|
32
|
-
// that — see PLAN_MECHANICAL_CHAT.md §2: matchQuery/nlToQuery (temporal.mjs) already
|
|
33
|
-
// own that surface for the Chronograph browser; ask.mjs's own `touches`/`cochange`
|
|
34
|
-
// verbs here answer "which modules touch/co-change with X" as ONE-HOP structural
|
|
35
|
-
// edges (mgx:touchedByCommit / mgx:changeCoupledWith), which is a different (and
|
|
36
|
-
// simpler) question than the browser's time-scrubbing view.
|
|
37
|
-
|
|
38
|
-
import { relationKind, impactClosure } from "./codegraph.mjs";
|
|
39
|
-
import {
|
|
40
|
-
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
|
|
41
|
-
CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
|
|
42
|
-
CONTEXT_PRONOUNS, NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, META_MEANING_VERBS,
|
|
43
|
-
WHERE_MARKERS, MENTION_MARKERS,
|
|
44
|
-
RELATIVE_PRONOUNS, PLACEHOLDER_NOUNS, BOOLEAN_CONNECTIVES, QUALIFIERS,
|
|
45
|
-
AGGREGATE_TRIGGERS, LIST_TRIGGERS, SUPERLATIVE_EXTREMES, EDGE_NOUN_TO_METRIC, ANAPHORA_TRIGGERS,
|
|
46
|
-
MEMBERSHIP_KINDS, CASCADE_NOISE, CASCADE_SYNONYMS, HELP_TRIGGERS,
|
|
47
|
-
} from "./ask-vocab.mjs";
|
|
48
|
-
import { lookupByProseTokens } from "./prose.mjs";
|
|
49
|
-
// The OPTIONAL Node-only wink-nlp adapter (lemma/POS tier). BOUNDARY: the inlined
|
|
50
|
-
// viewer bundle (viz.mjs askSource) strips this import line and never inlines
|
|
51
|
-
// ask-nlp.mjs, so in the browser `nlpAdapter` is simply an undeclared identifier —
|
|
52
|
-
// defaultNlp() below reads it through `typeof`, the one operator that touches an
|
|
53
|
-
// undeclared name without throwing, and the portable single-file HTML degrades to
|
|
54
|
-
// adapter-less parsing (curated tables + bounded fuzzy still on) instead of
|
|
55
|
-
// shipping a ~1MB language model inside the page.
|
|
56
|
-
import { nlpAdapter } from "./ask-nlp.mjs";
|
|
57
|
-
|
|
58
|
-
/** All edges of a classified relation kind, flattened across relation groups —
|
|
59
|
-
* a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
|
|
60
|
-
* exported+imported to avoid coupling this file's commit boundary to concurrent
|
|
61
|
-
* in-flight edits elsewhere in codegraph.mjs; both read the same relationKind
|
|
62
|
-
* classification, so they cannot drift in meaning). */
|
|
63
|
-
function edgesOfKind(graph, kind) {
|
|
64
|
-
const out = [];
|
|
65
|
-
// Append iteratively: `out.push(...g.edges)` spreads the group's edges as function arguments,
|
|
66
|
-
// overflowing the call stack on a large estate graph (mirrors the codegraph.mjs edgesOfKind fix).
|
|
67
|
-
for (const g of graph.relations) if (relationKind(g) === kind) for (const e of g.edges) out.push(e);
|
|
68
|
-
return out;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---- §3 vocabulary — single-sourced in ./ask-vocab.mjs; the grammar, the
|
|
72
|
-
// rephrase-hint text, and the renderer's noun forms all derive from those
|
|
73
|
-
// three tables, so they cannot drift. ----
|
|
74
|
-
|
|
75
|
-
// Predicate kinds carrying a finer, symbol-grain sibling (module-coarse -> fn/method-precise).
|
|
76
|
-
// "which functions call X" should read off callsSymbol (fn->fn), not the module-coarse "calls".
|
|
77
|
-
const SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
|
|
78
|
-
const FINE_ENTITY_TYPES = new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
|
|
79
|
-
|
|
80
|
-
// Query-side UNION families (2026-07-02 query families): a parsed kind that is not
|
|
81
|
-
// itself a stored predicate but a curated union of stored kinds — "what uses X"
|
|
82
|
-
// honestly means the import graph AND the call graph together. Everything else
|
|
83
|
-
// maps to itself; grain selection (asked entity type) then narrows the union's
|
|
84
|
-
// subjects the same way it narrows a single kind's.
|
|
85
|
-
const KIND_UNIONS = { uses: ["imports", "calls", "callsSymbol"] };
|
|
86
|
-
const kindsFor = (kind) => KIND_UNIONS[kind] || [kind];
|
|
87
|
-
|
|
88
|
-
const OVERFLOW_CAP = 12;
|
|
89
|
-
|
|
90
|
-
const PLURAL_FORMS = {
|
|
91
|
-
Function: ["function", "functions"], Method: ["method", "methods"],
|
|
92
|
-
Class: ["class", "classes"], Module: ["module", "modules"],
|
|
93
|
-
Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
|
|
94
|
-
Commit: ["commit", "commits"],
|
|
95
|
-
// "Change" is ask-vocab.mjs's pseudo-type (a wildcard over the touch traversal's
|
|
96
|
-
// results, never a node class) — it still needs noun forms for zero-hit templates.
|
|
97
|
-
Change: ["change", "changes"],
|
|
98
|
-
};
|
|
99
|
-
function nounFor(entityType, n) {
|
|
100
|
-
const [s, p] = PLURAL_FORMS[entityType] || ["result", "results"];
|
|
101
|
-
return n === 1 ? s : p;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Every relation KIND token is already the correct 3rd-person-singular verb form
|
|
105
|
-
// ("X imports Y", "X calls Y", "X touches Y") EXCEPT "cochange", the one kind whose
|
|
106
|
-
// name is a bare noun/verb stem ("X cochange Y" is wrong; "X cochanges Y" is right) —
|
|
107
|
-
// so the reverse-shape zero-hit template below reads off this table instead of
|
|
108
|
-
// unconditionally appending "s" (which used to double-pluralize every other kind:
|
|
109
|
-
// "callss", "importss", "touchess").
|
|
110
|
-
const REVERSE_MISS_VERB = { cochange: "cochanges" };
|
|
111
|
-
function verbFor(kind) {
|
|
112
|
-
return REVERSE_MISS_VERB[kind] || kind;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function escapeRegex(s) {
|
|
116
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// ---- §3.5 normalization — runs before EITHER parsing strategy sees the text ----
|
|
120
|
-
|
|
121
|
-
/** contraction/informal-spelling table -> word-boundary regex, longest phrase
|
|
122
|
-
* first (so "there's" doesn't get shadowed by a shorter overlapping entry). */
|
|
123
|
-
const tableRe = (table) => new RegExp(
|
|
124
|
-
"\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
125
|
-
"gi",
|
|
126
|
-
);
|
|
127
|
-
const CONTRACTION_RE = tableRe(CONTRACTIONS);
|
|
128
|
-
// misspelling/wrong-word CORRECTIONS (ask-vocab.mjs) — same mechanism, applied
|
|
129
|
-
// after contractions: restore the intended spelling first, then map misused
|
|
130
|
-
// words to their canonical schema term. Deterministic and curated, so they run
|
|
131
|
-
// BEFORE either parse strategy and ahead of the bounded edit-distance fallback.
|
|
132
|
-
// The trailing lookahead refuses to rewrite a word glued to a dotted extension:
|
|
133
|
-
// WRONG_WORDS entries are real English words that plausibly NAME modules
|
|
134
|
-
// ("revision.mjs", "property.py"), and a correction that corrupts an object
|
|
135
|
-
// term would be a guess — the exact thing these tables exist to avoid.
|
|
136
|
-
const correctionRe = (table) => new RegExp(
|
|
137
|
-
"\\b(" + Object.keys(table).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b(?!\\.[a-z0-9])",
|
|
138
|
-
"gi",
|
|
139
|
-
);
|
|
140
|
-
const MISSPELLING_RE = correctionRe(MISSPELLINGS);
|
|
141
|
-
const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
|
|
142
|
-
|
|
143
|
-
/** Free-text -> normalized free-text: contractions expanded, g-dropped words
|
|
144
|
-
* restored, filler/politeness words stripped. Idempotent and pure — the same
|
|
145
|
-
* input always normalizes the same way, so both parsing strategies see
|
|
146
|
-
* identical text and their outputs are directly comparable. Deliberately
|
|
147
|
-
* does NOT force lowercase: object/subject terms (module names like
|
|
148
|
-
* "myFile", class names like "Base") are meaningfully cased, and every
|
|
149
|
-
* substitution below already matches case-insensitively (`i`/`gi` flags) —
|
|
150
|
-
* forcing the whole string to lowercase would silently corrupt every parsed
|
|
151
|
-
* term's case instead. */
|
|
152
|
-
export function normalizeQuery(text) {
|
|
153
|
-
let q = String(text || "");
|
|
154
|
-
q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
|
|
155
|
-
q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
|
|
156
|
-
q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
|
|
157
|
-
q = q.replace(G_DROP, "$1ing");
|
|
158
|
-
if (FILLER_WORDS.length) {
|
|
159
|
-
const fillerRe = new RegExp(
|
|
160
|
-
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
161
|
-
"gi",
|
|
162
|
-
);
|
|
163
|
-
q = q.replace(fillerRe, " ");
|
|
164
|
-
}
|
|
165
|
-
return q.replace(/\s+/g, " ").trim();
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** Recognized rhetorical/idiomatic constructions rewritten to the canonical form
|
|
169
|
-
* of the SAME question before either parse strategy sees the text — a small
|
|
170
|
-
* closed pattern set, not a general rewriter. Two families, tried in order:
|
|
171
|
-
* COMMIT_CONTENT_FRAMES first ("what was in commit <sha>" -> "what did <sha>
|
|
172
|
-
* touch"; sha-anchored, so it can't swallow a containment question), then the
|
|
173
|
-
* §3.6 negative-rhetorical NEGATION_FRAMES. First matching frame across both wins
|
|
174
|
-
* and rewriting stops; unmatched text passes through unchanged. */
|
|
175
|
-
export function applyNegationFrames(text) {
|
|
176
|
-
for (const frame of [...COMMIT_CONTENT_FRAMES, ...NEGATION_FRAMES]) {
|
|
177
|
-
const m = text.match(frame.re);
|
|
178
|
-
if (m) return frame.to(m).replace(/\s+/g, " ").trim();
|
|
179
|
-
}
|
|
180
|
-
return text;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ---- strategy 1: anchored templates — fixed precedence order; first fit wins,
|
|
184
|
-
// never ambiguous at the template level (a question matching two shapes is a
|
|
185
|
-
// design smell we test against). Unweakened from the original P0 grammar. ----
|
|
186
|
-
|
|
187
|
-
const VERB_ALT = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
188
|
-
const ENTITY_ALT = Object.keys(ENTITY_TO_TYPE).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
189
|
-
const MODIFIER_ALT = Object.keys(MODIFIER_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
190
|
-
const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
191
|
-
|
|
192
|
-
const TEMPLATES = [
|
|
193
|
-
// T1 ASK: "does X import Y" / "is X a subclass of Y" -> Yes/No. Tried FIRST: it starts with
|
|
194
|
-
// does/is/do/did, which the reverse/forward templates below never match (those start with
|
|
195
|
-
// which/what), so precedence between T1 and the rest is structural, not a tie-break guess.
|
|
196
|
-
// "did" joins does/do for the past-tense commit forms ("did commit <sha> touch X").
|
|
197
|
-
{
|
|
198
|
-
name: "ask",
|
|
199
|
-
re: new RegExp(`^(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
|
|
200
|
-
build: (m) => ({
|
|
201
|
-
shape: "ask", entityType: null, modifier: "direct",
|
|
202
|
-
kind: VERB_TO_KIND[m[2].toLowerCase()], subject: m[1].trim(), object: m[3].trim(),
|
|
203
|
-
}),
|
|
204
|
-
},
|
|
205
|
-
// T2 reverse: "which <entity> [<modifier>] <verb> <object>" — the operator's own example shape.
|
|
206
|
-
{
|
|
207
|
-
name: "reverse",
|
|
208
|
-
re: new RegExp(`^which\\s+(${ENTITY_ALT})\\s+(?:(${MODIFIER_ALT})\\s+)?(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
|
|
209
|
-
build: (m) => ({
|
|
210
|
-
shape: "reverse",
|
|
211
|
-
entityType: ENTITY_TO_TYPE[m[1].toLowerCase()],
|
|
212
|
-
modifier: m[2] ? MODIFIER_TO_KIND[m[2].toLowerCase()] : "direct",
|
|
213
|
-
kind: VERB_TO_KIND[m[3].toLowerCase()],
|
|
214
|
-
object: m[4].trim(),
|
|
215
|
-
}),
|
|
216
|
-
},
|
|
217
|
-
// T3 forward: "what does <object> <verb>" — X is given, list its R-related things.
|
|
218
|
-
// "did" joins does/do for the past-tense commit forms ("what did commit <sha> touch").
|
|
219
|
-
{
|
|
220
|
-
name: "forward",
|
|
221
|
-
re: new RegExp(`^what\\s+(?:does|do|did)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
|
|
222
|
-
build: (m) => ({
|
|
223
|
-
shape: "forward", entityType: null, modifier: "direct",
|
|
224
|
-
kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
|
|
225
|
-
}),
|
|
226
|
-
},
|
|
227
|
-
// T4 meta: "what does <term> mean" — a question about the GRAPH'S OWN VOCABULARY
|
|
228
|
-
// (a SchemaClass/SchemaPredicate label, e.g. "cochange", or a raw prop token, e.g.
|
|
229
|
-
// "mgx:callsSymbol"), not a graph traversal over code edges. Tried after T3: T3 also
|
|
230
|
-
// starts "what does/do", but T3 only fires when the tail is a relation VERB_ALT
|
|
231
|
-
// phrase ("import"/"calls"/…), which "mean"/"means"/etc never are (disjoint tables —
|
|
232
|
-
// ask-vocab.mjs's file comment explains why they're kept separate), so the two never
|
|
233
|
-
// actually compete for the same input.
|
|
234
|
-
{
|
|
235
|
-
name: "meta-mean",
|
|
236
|
-
re: new RegExp(`^what\\s+(?:does|do|is|are)\\s+(.+?)\\s+(?:${META_ALT})\\??$`, "i"),
|
|
237
|
-
build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
|
|
238
|
-
},
|
|
239
|
-
// T5 meta: "what is a/an <term>" — the OTHER worked phrasing ("what is a Commit").
|
|
240
|
-
// The indefinite article is REQUIRED (not optional): a bare "what is <anything>"
|
|
241
|
-
// would also swallow "what is the meaning of this codebase" (an existing, deliberately
|
|
242
|
-
// honest grammar-miss regression case — ask.test.mjs/ask-dual-strategy.test.mjs both
|
|
243
|
-
// assert it stays null), which never mentions "a"/"an" before its tail. Requiring the
|
|
244
|
-
// article keeps this template's reach to the one worked shape without reopening that.
|
|
245
|
-
{
|
|
246
|
-
name: "meta-whatis",
|
|
247
|
-
re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
|
|
248
|
-
build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
|
|
249
|
-
},
|
|
250
|
-
// T6 mention: "where is <term> mentioned/referenced" — the prose/mentions surface
|
|
251
|
-
// (2026-07-02 query families). Tried BEFORE T7: T7's trailing marker is optional,
|
|
252
|
-
// so without this ordering it would swallow the mention question and lose the
|
|
253
|
-
// marker that distinguishes "locate the definition" from "list the prose mentions".
|
|
254
|
-
{
|
|
255
|
-
name: "mention",
|
|
256
|
-
re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)\\s+(?:${MENTION_MARKERS.map(escapeRegex).join("|")})\\??$`, "i"),
|
|
257
|
-
build: (m) => ({ shape: "mentions", entityType: null, modifier: "direct", kind: "mentions", object: m[1].trim() }),
|
|
258
|
-
},
|
|
259
|
-
// T7 where: "where is <term> [defined|declared|located|implemented]" — definition
|
|
260
|
-
// location off the site attribute / defining module. "where" starts no other
|
|
261
|
-
// template, so precedence against T1-T5 is structural.
|
|
262
|
-
{
|
|
263
|
-
name: "where",
|
|
264
|
-
re: new RegExp(`^where\\s+(?:is|are|was|were)\\s+(.+?)(?:\\s+(?:${WHERE_MARKERS.map(escapeRegex).join("|")}))?\\??$`, "i"),
|
|
265
|
-
build: (m) => ({ shape: "where", entityType: null, modifier: "direct", kind: "where", object: m[1].trim() }),
|
|
266
|
-
},
|
|
267
|
-
// T8 when: "when did <term> [last] change/touched/updated…" — temporal shape over
|
|
268
|
-
// the touches edges + commit date attributes. The verb slot reuses VERB_ALT, but
|
|
269
|
-
// only the touches family carries dates to answer with, so build() rejects any
|
|
270
|
-
// other kind (returning null falls through — parseAnchored tolerates it) rather
|
|
271
|
-
// than pretending "when did X import Y" has a temporal answer.
|
|
272
|
-
{
|
|
273
|
-
name: "when",
|
|
274
|
-
re: new RegExp(`^when\\s+(?:did|does|do|was|were|is)\\s+(.+?)\\s+(?:last\\s+)?(${VERB_ALT})\\??$`, "i"),
|
|
275
|
-
build: (m) => (VERB_TO_KIND[m[2].toLowerCase()] === "touches"
|
|
276
|
-
? { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: m[1].trim() }
|
|
277
|
-
: null),
|
|
278
|
-
},
|
|
279
|
-
];
|
|
280
|
-
|
|
281
|
-
/** Strategy 1: the original P0 anchored grammar — the whole (normalized) string
|
|
282
|
-
* must match one of TEMPLATES start-to-end. A build() may return null to reject
|
|
283
|
-
* a structural match on curated grounds (T8's non-temporal verbs); the scan then
|
|
284
|
-
* simply continues, exactly as if the regex had not matched. Pure. */
|
|
285
|
-
function parseAnchored(text) {
|
|
286
|
-
for (const t of TEMPLATES) {
|
|
287
|
-
const m = text.match(t.re);
|
|
288
|
-
if (m) {
|
|
289
|
-
const parsed = t.build(m);
|
|
290
|
-
if (parsed) return parsed;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ---- strategy 2: keyword-spotting/decomposition — ELIZA's own mechanism: find
|
|
297
|
-
// the keyword(s) anywhere in the text, decompose around them, tolerate reordering
|
|
298
|
-
// and casual phrasing. Position-independent (no `^...$` anchor), so it tolerates
|
|
299
|
-
// "what calls this" / "who invokes this" / "something executes this, where from"
|
|
300
|
-
// — real phrasings the anchored grammar's fixed shapes don't cover. ----
|
|
301
|
-
|
|
302
|
-
const STOPWORDS = new Set([
|
|
303
|
-
"what", "who", "which", "where", "when", "why", "how",
|
|
304
|
-
"does", "do", "did", "is", "are", "was", "were", "the", "a", "an", "of", "to", "from", "at", "in", "on",
|
|
305
|
-
"there", "something", "anything", "nothing", "one", "any",
|
|
306
|
-
// temporal filler in when-questions ("when was X last touched") — a symbol
|
|
307
|
-
// literally named "last" would be the accepted residual cost, same trade as
|
|
308
|
-
// every other stopword.
|
|
309
|
-
"last",
|
|
310
|
-
]);
|
|
311
|
-
|
|
312
|
-
/** Find the longest phrase from `table`'s keys that appears as a contiguous
|
|
313
|
-
* run of `words` (case already lowercased by the caller). Longest-match-first
|
|
314
|
-
* (multi-word phrases before single words) so "co-changes with" isn't
|
|
315
|
-
* shadowed by a shorter unrelated word. A span overlapping `consumed` indices
|
|
316
|
-
* is skipped: the verb and entity tables now share a surface form ("change"
|
|
317
|
-
* is both a touches verb and the Change entity noun), and a word already
|
|
318
|
-
* claimed by the verb pass must not double as the entity keyword. Returns
|
|
319
|
-
* {kind, start, end} (end exclusive) or null. */
|
|
320
|
-
function findPhrase(lcWords, table, consumed = null) {
|
|
321
|
-
const phrases = Object.keys(table).sort((a, b) => b.split(" ").length - a.split(" ").length);
|
|
322
|
-
for (const p of phrases) {
|
|
323
|
-
const pWords = p.split(" ");
|
|
324
|
-
for (let i = 0; i <= lcWords.length - pWords.length; i += 1) {
|
|
325
|
-
if (consumed && pWords.some((_, j) => consumed.has(i + j))) continue;
|
|
326
|
-
if (pWords.every((w, j) => lcWords[i + j] === w)) return { kind: table[p], start: i, end: i + pWords.length };
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
return null;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ---- bounded edit distance (two-level fuzzy, 2026-07-02) — hand-rolled
|
|
333
|
-
// Damerau-Levenshtein (optimal string alignment: substitution/insertion/deletion
|
|
334
|
-
// + adjacent transposition), bounded with an early row-minimum exit. Used by the
|
|
335
|
-
// keyword-spot FUZZY tier below and resolveObject's tier 5 — both fire only after
|
|
336
|
-
// every exact/curated tier missed, and a distance TIE is refused (keyword) or
|
|
337
|
-
// surfaced as ambiguity (object), never broken by a guess. Pure JS, no deps, so
|
|
338
|
-
// the inlined viewer bundle gets fuzzy matching for free. ----
|
|
339
|
-
|
|
340
|
-
/** Distance between a and b, or max+1 as soon as it provably exceeds `max`. */
|
|
341
|
-
function editDistance(a, b, max) {
|
|
342
|
-
if (a === b) return 0;
|
|
343
|
-
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
344
|
-
let prev2 = null;
|
|
345
|
-
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
346
|
-
for (let i = 1; i <= a.length; i += 1) {
|
|
347
|
-
const cur = [i];
|
|
348
|
-
let rowMin = i;
|
|
349
|
-
for (let j = 1; j <= b.length; j += 1) {
|
|
350
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
351
|
-
let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
352
|
-
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, prev2[j - 2] + cost);
|
|
353
|
-
cur[j] = v;
|
|
354
|
-
if (v < rowMin) rowMin = v;
|
|
355
|
-
}
|
|
356
|
-
if (rowMin > max) return max + 1;
|
|
357
|
-
prev2 = prev;
|
|
358
|
-
prev = cur;
|
|
359
|
-
}
|
|
360
|
-
return prev[b.length];
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** The curated distance budget: 1 edit for short tokens, 2 for longer ones. */
|
|
364
|
-
const fuzzyBound = (s) => (s.length <= 5 ? 1 : 2);
|
|
365
|
-
|
|
366
|
-
/** Every single word appearing in the three parse tables — the "is this word
|
|
367
|
-
* already vocabulary?" gate for the lemma/fuzzy canonicalization passes (an
|
|
368
|
-
* exact vocab word is NEVER rewritten: exact curated match always wins). */
|
|
369
|
-
const VOCAB_WORDS = new Set(
|
|
370
|
-
[...Object.keys(VERB_TO_KIND), ...Object.keys(ENTITY_TO_TYPE), ...Object.keys(MODIFIER_TO_KIND)]
|
|
371
|
-
.flatMap((p) => p.split(" ")),
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
/** Fuzzy-correction TARGETS: verb-phrase and modifier constituents only, length ≥4.
|
|
375
|
-
* Entity nouns are deliberately excluded — real identifiers collide with them at
|
|
376
|
-
* distance ≤2 far too easily ("myfile" is 2 edits from "file", "caller" 2 from
|
|
377
|
-
* "calls"-family words), and entity-noun typos are already owned by the curated
|
|
378
|
-
* MISSPELLINGS table where such calls are made deliberately. Short constituents
|
|
379
|
-
* ("of", "to", "in", "on") are excluded for the same reason: at bound 1 half of
|
|
380
|
-
* English is adjacent to them. */
|
|
381
|
-
const FUZZY_TARGET_WORDS = [...new Set(
|
|
382
|
-
[...Object.keys(VERB_TO_KIND), ...Object.keys(MODIFIER_TO_KIND)]
|
|
383
|
-
.flatMap((p) => p.split(" "))
|
|
384
|
-
.filter((w) => w.length >= 4),
|
|
385
|
-
)];
|
|
386
|
-
|
|
387
|
-
/** A query word may be canonicalized only if it is plain alphabetic, not a
|
|
388
|
-
* stopword, and not already vocabulary. Dotted/digit terms (file names, shas)
|
|
389
|
-
* are never touched. */
|
|
390
|
-
function eligibleForCanon(w) {
|
|
391
|
-
return /^[a-z]+$/.test(w) && !STOPWORDS.has(w) && !VOCAB_WORDS.has(w);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/** UNIQUE within-bound fuzzy vocab keyword for `w`, or null — a tie between two
|
|
395
|
-
* distinct target words at the same distance is refused outright (the honest-miss
|
|
396
|
-
* discipline at the vocabulary level; cf. MISSPELLINGS' curated "calss" decision). */
|
|
397
|
-
function fuzzyVocabWord(w) {
|
|
398
|
-
const bound = fuzzyBound(w);
|
|
399
|
-
let best = bound + 1;
|
|
400
|
-
let hit = null;
|
|
401
|
-
let tied = false;
|
|
402
|
-
for (const target of FUZZY_TARGET_WORDS) {
|
|
403
|
-
const d = editDistance(w, target, Math.min(best, bound));
|
|
404
|
-
if (d < best) { best = d; hit = target; tied = false; }
|
|
405
|
-
else if (d === best && d <= bound && target !== hit) tied = true;
|
|
406
|
-
}
|
|
407
|
-
return best <= bound && !tied ? hit : null;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** Strategy 2: scan (already-normalized) text for a verb keyword anywhere,
|
|
411
|
-
* plus optional entity/modifier keywords anywhere, then split whatever's
|
|
412
|
-
* left (after removing the matched spans + stopwords) into the words BEFORE
|
|
413
|
-
* and AFTER the verb. Which side(s) are non-empty decides the shape —
|
|
414
|
-
* mirrors the three anchored shapes but by decomposition instead of a fixed
|
|
415
|
-
* template, so it tolerates reordering/casual phrasing the anchored regexes
|
|
416
|
-
* don't: text on BOTH sides ("does X import Y") -> ask{subject:before,
|
|
417
|
-
* object:after}; only AFTER the verb ("what calls this") -> reverse{object:
|
|
418
|
-
* after}; only BEFORE it ("what does X import") -> forward{object:before}.
|
|
419
|
-
* A lone context pronoun ("this"/"it"/"that"/"here") ending up as a resolved
|
|
420
|
-
* term is left as plain text — resolveTermOrContext (traverse-time)
|
|
421
|
-
* recognizes it against an optional contextId, so no separate flag is
|
|
422
|
-
* needed here. A misparse here costs nothing beyond an honest object-miss
|
|
423
|
-
* downstream (resolveObject never guesses).
|
|
424
|
-
*
|
|
425
|
-
* Keyword matching is TIERED (two-level fuzzy work, 2026-07-02) — each lower
|
|
426
|
-
* tier fires ONLY when every tier above found no verb phrase at all, so an
|
|
427
|
-
* exact curated match can never be displaced:
|
|
428
|
-
* 1. exact — the words as typed (post-normalization, which already applied
|
|
429
|
-
* the curated CONTRACTIONS/MISSPELLINGS/WRONG_WORDS corrections);
|
|
430
|
-
* 2. lemma (only with the optional Node-side `nlp` adapter) — each eligible
|
|
431
|
-
* word is replaced by its wink lemma IF that lemma is itself a vocab word
|
|
432
|
-
* ("imported"/"importing" -> "import"), so inflections hit the curated
|
|
433
|
-
* phrases without enumerating them. Every verb family already stores its
|
|
434
|
-
* lemma form ("import", "call", "touch", "use", …), so a direct
|
|
435
|
-
* lemma-in-vocab check is the whole lookup — no reverse index needed;
|
|
436
|
-
* 3. fuzzy (adapter-free; works in the inlined viewer too) — a word ≥4 chars
|
|
437
|
-
* matching nothing exactly may rewrite to a UNIQUE verb/modifier
|
|
438
|
-
* constituent within the bounded edit distance (see fuzzyVocabWord; ties
|
|
439
|
-
* are refused, entity nouns are never fuzzy targets).
|
|
440
|
-
* The canonicalized words drive PHRASE FINDING only — sideText always reads the
|
|
441
|
-
* ORIGINAL words, so a correction can never corrupt an object/subject term. */
|
|
442
|
-
function parseKeywordSpot(text, nlp = null) {
|
|
443
|
-
// Strip a trailing "?" (mirrors the anchored templates' own `\??$`) and turn commas into
|
|
444
|
-
// pauses/spaces — but NEVER strip a mid-word ".": object terms are routinely dotted file/module
|
|
445
|
-
// names ("a.py", "utils.mjs"), and the anchored strategy captures those raw, so keyword-spot
|
|
446
|
-
// must too or the two strategies would "disagree" over a period that was never part of the intent.
|
|
447
|
-
const words = text.replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
448
|
-
const lcWords = words.map((w) => w.toLowerCase());
|
|
449
|
-
// where/mentions shapes (2026-07-02 query families): "where is X [defined]" and
|
|
450
|
-
// "where is X mentioned" carry NO relation verb, so the verb-driven decomposition
|
|
451
|
-
// below can never reach them. Routed here by the "where" question word + marker —
|
|
452
|
-
// but ONLY when no relation verb exists anywhere in the sentence: "something
|
|
453
|
-
// executes this, where from" (an existing worked phrasing) has a verb, and its
|
|
454
|
-
// "where" is decorative, not a location question.
|
|
455
|
-
if (lcWords.includes("where") && !findPhrase(lcWords, VERB_TO_KIND)) {
|
|
456
|
-
const mention = lcWords.some((w) => MENTION_MARKERS.includes(w));
|
|
457
|
-
const markers = new Set([...WHERE_MARKERS, ...MENTION_MARKERS]);
|
|
458
|
-
const objText = words.filter((w, i) => !STOPWORDS.has(lcWords[i]) && !markers.has(lcWords[i])).join(" ").trim();
|
|
459
|
-
if (objText) {
|
|
460
|
-
const kind = mention ? "mentions" : "where";
|
|
461
|
-
return { shape: kind, entityType: null, modifier: "direct", kind, object: objText };
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
let canonWords = lcWords;
|
|
465
|
-
let verbHit = findPhrase(lcWords, VERB_TO_KIND);
|
|
466
|
-
if (!verbHit && nlp) {
|
|
467
|
-
// tier 2: lemma (see the tier doc above) — replace only when the lemma is
|
|
468
|
-
// itself vocabulary, so unknown words ("myfile") pass through untouched.
|
|
469
|
-
const lemmaWords = lcWords.map((w) => {
|
|
470
|
-
if (!eligibleForCanon(w)) return w;
|
|
471
|
-
const l = nlp.lemma(w);
|
|
472
|
-
return VOCAB_WORDS.has(l) ? l : w;
|
|
473
|
-
});
|
|
474
|
-
verbHit = findPhrase(lemmaWords, VERB_TO_KIND);
|
|
475
|
-
if (verbHit) canonWords = lemmaWords;
|
|
476
|
-
}
|
|
477
|
-
if (!verbHit) {
|
|
478
|
-
// tier 3: bounded-edit-distance rewrite toward verb/modifier keywords only
|
|
479
|
-
// ("impotr" -> "import"); ≥4-char words only — below that the bound covers
|
|
480
|
-
// half of English (and "and" is 1 edit from the "land in" constituent).
|
|
481
|
-
const fuzzyWords = lcWords.map((w) => (w.length >= 4 && eligibleForCanon(w) ? fuzzyVocabWord(w) || w : w));
|
|
482
|
-
verbHit = findPhrase(fuzzyWords, VERB_TO_KIND);
|
|
483
|
-
if (verbHit) canonWords = fuzzyWords;
|
|
484
|
-
}
|
|
485
|
-
if (!verbHit) return null;
|
|
486
|
-
// POS consumer (wink adapter, Node-side only): rescue the ONE decomposition this
|
|
487
|
-
// strategy provably mis-parses — a relation word used as a NOUN in a "the
|
|
488
|
-
// <imports> of <term>" nominal ("show the imports of walk.mjs" otherwise
|
|
489
|
-
// decomposes to ask{subject:"show"}; bare "the imports of walk.mjs" to the
|
|
490
|
-
// reverse shape, both wrong). The wink probe showed "import" is tagged NOUN even
|
|
491
|
-
// in genuine verb use ("which modules import walk.mjs"), so the POS signal is
|
|
492
|
-
// deliberately NOT a general verb veto — it only fires inside this exact
|
|
493
|
-
// det+NOUN+"of" frame, where the nominal reading is grammatically forced.
|
|
494
|
-
if (nlp && verbHit.end - verbHit.start === 1) {
|
|
495
|
-
const i = verbHit.start;
|
|
496
|
-
const det = lcWords[i - 1];
|
|
497
|
-
if ((det === "the" || det === "these" || det === "those") && lcWords[i + 1] === "of") {
|
|
498
|
-
const tags = nlp.posTags(words);
|
|
499
|
-
if (tags[i] === "NOUN") {
|
|
500
|
-
const objText = words.slice(i + 2).filter((w, j) => !STOPWORDS.has(lcWords[i + 2 + j])).join(" ").trim();
|
|
501
|
-
if (objText) return { shape: "forward", entityType: null, modifier: "direct", kind: verbHit.kind, object: objText };
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
const consumed = new Set();
|
|
506
|
-
const mark = (hit) => { if (hit) for (let i = hit.start; i < hit.end; i += 1) consumed.add(i); };
|
|
507
|
-
mark(verbHit);
|
|
508
|
-
const entityHit = findPhrase(canonWords, ENTITY_TO_TYPE, consumed);
|
|
509
|
-
mark(entityHit);
|
|
510
|
-
const modifierHit = findPhrase(canonWords, MODIFIER_TO_KIND, consumed);
|
|
511
|
-
mark(modifierHit);
|
|
512
|
-
const sideText = (from, to) => words
|
|
513
|
-
.slice(from, to)
|
|
514
|
-
.filter((_, j) => !consumed.has(from + j) && !STOPWORDS.has(lcWords[from + j]))
|
|
515
|
-
.join(" ")
|
|
516
|
-
.trim();
|
|
517
|
-
const beforeText = sideText(0, verbHit.start);
|
|
518
|
-
const afterText = sideText(verbHit.end, words.length);
|
|
519
|
-
const kind = verbHit.kind;
|
|
520
|
-
// slices read canonWords, not lcWords: the entity/modifier spans were matched
|
|
521
|
-
// against the canonicalized array, whose word IS the table key.
|
|
522
|
-
const entityType = entityHit ? ENTITY_TO_TYPE[canonWords.slice(entityHit.start, entityHit.end).join(" ")] : null;
|
|
523
|
-
const modifier = modifierHit ? MODIFIER_TO_KIND[canonWords.slice(modifierHit.start, modifierHit.end).join(" ")] : "direct";
|
|
524
|
-
|
|
525
|
-
// when shape (2026-07-02 query families): "when did X change" / "when was X last
|
|
526
|
-
// touched" — the "when" question word turns a touches decomposition temporal.
|
|
527
|
-
// Only touches carries commit dates to answer with; a "when" next to any other
|
|
528
|
-
// relation verb falls through to the ordinary shapes (and their honest answers).
|
|
529
|
-
if (kind === "touches" && lcWords.includes("when")) {
|
|
530
|
-
const objText = beforeText || afterText;
|
|
531
|
-
if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
|
|
535
|
-
if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
|
|
536
|
-
// forward keeps the spotted entityType ("which modules did commit <sha> touch" is a
|
|
537
|
-
// forward decomposition — subject before the verb — whose asked grain would otherwise
|
|
538
|
-
// be lost); traverse() only consults it for the commit-as-subject grain selection,
|
|
539
|
-
// so plain forwards behave exactly as before. Modifier stays hardcoded: no forward
|
|
540
|
-
// closure traversal exists (see modifierIsWired).
|
|
541
|
-
if (beforeText) return { shape: "forward", entityType, modifier: "direct", kind, object: beforeText };
|
|
542
|
-
return null;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// ---- strategy merge — run both, agree/disagree/single/neither (§ above). A
|
|
546
|
-
// plain array + a merge step, so a third strategy plugs in the same way. ----
|
|
547
|
-
|
|
548
|
-
const STRATEGIES = [
|
|
549
|
-
{ name: "anchored", parse: parseAnchored },
|
|
550
|
-
{ name: "keyword-spot", parse: parseKeywordSpot },
|
|
551
|
-
];
|
|
552
|
-
|
|
553
|
-
/** The default lemma/POS adapter: wink-nlp when this is a Node process with the
|
|
554
|
-
* optional deps installed, null otherwise. BOUNDARY (see the import comment):
|
|
555
|
-
* the inlined viewer bundle strips the ask-nlp.mjs import, so `nlpAdapter` is
|
|
556
|
-
* an UNDECLARED identifier there — `typeof` reads it without throwing and the
|
|
557
|
-
* browser path degrades to no adapter, same parse pipeline otherwise. */
|
|
558
|
-
function defaultNlp() {
|
|
559
|
-
return typeof nlpAdapter === "function" ? nlpAdapter() : null;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// "commit abc1234" and bare "abc1234" are the SAME term once resolveObject's
|
|
563
|
-
// commit-sha tier strips the noun — the anchored strategy captures the noun inside
|
|
564
|
-
// its object span while keyword-spot consumes it as the entity keyword, so without
|
|
565
|
-
// this the two strategies would "disagree" over a word that names no different thing.
|
|
566
|
-
const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ").replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
|
|
567
|
-
|
|
568
|
-
/** Do two independently-produced parses mean the same graph query? Same
|
|
569
|
-
* shape, same relation kind, and matching term(s) (both subject and object
|
|
570
|
-
* for "ask"; just object otherwise) — anything less is a genuine
|
|
571
|
-
* disagreement, not a near-miss to paper over. */
|
|
572
|
-
function sameParse(p, q) {
|
|
573
|
-
if (p.shape !== q.shape || p.kind !== q.kind) return false;
|
|
574
|
-
if (p.shape === "ask") return cmpTerm(p.subject) === cmpTerm(q.subject) && cmpTerm(p.object) === cmpTerm(q.object);
|
|
575
|
-
return cmpTerm(p.object) === cmpTerm(q.object);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/** Compile a free-text question into {shape, kind, entityType, modifier,
|
|
579
|
-
* object[, subject]}, or null if NEITHER strategy fits — an honest grammar
|
|
580
|
-
* miss (§6.3), never a best-effort guess. When both strategies parse and
|
|
581
|
-
* AGREE, returns that parse unchanged (no fallback ordering — either
|
|
582
|
-
* strategy's own result is equally valid once they agree, per §above: "use
|
|
583
|
-
* either"). When both parse but DISAGREE (different shape/kind/term),
|
|
584
|
-
* returns {ambiguousParse: true, candidates: [...]} — a genuine "this could
|
|
585
|
-
* mean more than one thing" case, distinct from resolveObject's later
|
|
586
|
-
* object-resolution ambiguity. `opts.nlp` overrides the lemma/POS adapter
|
|
587
|
-
* (pass null to force the adapter-less browser behavior in a Node test);
|
|
588
|
-
* leaving it undefined picks the deterministic default (defaultNlp). Pure
|
|
589
|
-
* given (query, adapter) — the adapter itself is a fixed model, no sampling. */
|
|
590
|
-
export function parseQuery(query, { nlp = undefined } = {}) {
|
|
591
|
-
const adapter = nlp === undefined ? defaultNlp() : nlp;
|
|
592
|
-
const raw = String(query || "").trim().replace(/\s+/g, " ");
|
|
593
|
-
if (!raw) return null;
|
|
594
|
-
const text = applyNegationFrames(normalizeQuery(raw));
|
|
595
|
-
if (!text) return null;
|
|
596
|
-
// COMPOSITIONAL PARSE PATH (PLAN §5.16 P3) — the new PRIMARY layer: a recursive
|
|
597
|
-
// descent over CLAUSES for the compositional shapes (nested/relative, boolean,
|
|
598
|
-
// qualifiers, aggregates, superlatives, anaphora). It fires ONLY when a
|
|
599
|
-
// compositional MARKER is present and returns null otherwise, so every plain
|
|
600
|
-
// clause falls straight through to the unchanged two-strategy merge below — the
|
|
601
|
-
// whole existing grammar is preserved bit-for-bit. When a marker IS present but
|
|
602
|
-
// the phrase cannot be compiled, it returns an honest {node:"miss"} rather than
|
|
603
|
-
// letting keyword-spot guess at a composition it never expressed.
|
|
604
|
-
const composite = parseComposite(text, adapter);
|
|
605
|
-
if (composite) return composite;
|
|
606
|
-
const hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text, adapter) })).filter((r) => r.parsed);
|
|
607
|
-
if (hits.length === 0) return null;
|
|
608
|
-
if (hits.length === 1) return hits[0].parsed;
|
|
609
|
-
const [a, b] = hits;
|
|
610
|
-
if (sameParse(a.parsed, b.parsed)) return a.parsed;
|
|
611
|
-
return { ambiguousParse: true, candidates: hits.map((h) => h.parsed) };
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// ============================================================================
|
|
615
|
-
// §compositional grammar (PLAN §5.16 P3) — the step up from ELIZA keyword-
|
|
616
|
-
// spotting to a real recursive-descent grammar. Tokenize -> recursive-descent
|
|
617
|
-
// parse to an AST of nodes -> compile to graph traversal. The AST node shapes
|
|
618
|
-
// (all carry a `node` tag so traverse()/render() can branch without touching the
|
|
619
|
-
// simple-clause path):
|
|
620
|
-
// {node:"clause", clause} — a wrapped simple parse (the leaf)
|
|
621
|
-
// {node:"allOfClass", entityType} — every individual of a class
|
|
622
|
-
// {node:"reverseSet"|"forwardSet", kind, entityType, inner} — nested/relative:
|
|
623
|
-
// the OBJECT (reverse) / SUBJECT (forward) of the outer edge is the id-set
|
|
624
|
-
// produced by evaluating `inner` (another AST) — two-stage traversal.
|
|
625
|
-
// {node:"membership", entityType, term} — "<entity> of/in <term>"
|
|
626
|
-
// {node:"qualifier", filters:[word…], inner} — adjective post-filters on a set
|
|
627
|
-
// {node:"boolean", entityType, atoms:[{op,kind,ast|filters}…]} — set algebra
|
|
628
|
-
// over the SAME subject (and/or/but-not); op ∈ seed/intersection/union/difference
|
|
629
|
-
// {node:"count", entityType, base} — aggregate: |eval(base)|
|
|
630
|
-
// {node:"list", entityType, base, scoped} — list the individuals of eval(base),
|
|
631
|
-
// capped at OVERFLOW_CAP; `scoped` suppresses the "narrow with …" hint when the
|
|
632
|
-
// list was already restricted (a module scope or predicate tail)
|
|
633
|
-
// {node:"superlative", entityType, metric, metricNoun, extreme} — rank by degree
|
|
634
|
-
// {node:"anaphora", mode, filter} — over ask()'s `prev` id array
|
|
635
|
-
// {node:"miss", reason} — a compositional marker was seen
|
|
636
|
-
// but could not compile: an honest stated miss, never a guess.
|
|
637
|
-
// The grammar COMPOSES the closed vocabulary (ask-vocab.mjs); it never opens it —
|
|
638
|
-
// every leaf still resolves through the existing curated clause parser + tiered
|
|
639
|
-
// resolveObject, so a term it can't resolve is still an honest object-miss.
|
|
640
|
-
// ============================================================================
|
|
641
|
-
|
|
642
|
-
// Depth cap on nesting (PLAN P3: "depth ≥2 nesting; guard against runaway with a
|
|
643
|
-
// sane hop cap and an honest 'too deep to resolve' if exceeded").
|
|
644
|
-
const MAX_COMPOSE_DEPTH = 4;
|
|
645
|
-
// A resolvable-later placeholder object term for the OUTER clause of a nested
|
|
646
|
-
// parse: the outer clause is parsed normally (so its verb/shape/grain classify),
|
|
647
|
-
// then its `object` is discarded and replaced at eval time by the inner set. Chosen
|
|
648
|
-
// to be plainly alphabetic (not a stopword, not vocabulary) so the clause parser
|
|
649
|
-
// treats it as an ordinary object term rather than dropping it.
|
|
650
|
-
const NEST_SENTINEL = "zzinnerset";
|
|
651
|
-
// Filler words dropped at the front of a relative predicate / anaphora filter.
|
|
652
|
-
const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and"]);
|
|
653
|
-
const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
|
|
654
|
-
// Determiners that may sit in front of the SUBJECT noun of a subject-relative query
|
|
655
|
-
// ("THE modules that import X", "THOSE classes that extend Base"). Peeled in
|
|
656
|
-
// parseRelationalOrQualified so the marker-gated compositional path catches them exactly
|
|
657
|
-
// as it already catches the bare "modules that import X" — otherwise the leading article
|
|
658
|
-
// keeps the subject noun off position 0, the compositional production bails, and
|
|
659
|
-
// keyword-spot mis-reads the trailing "that" as a subject pronoun (→ a false
|
|
660
|
-
// "needs a selected node" miss). Kept SEPARATE from FRAME_WORDS so peeling a determiner
|
|
661
|
-
// does not by itself flag the clause as a `framed` interrogative (which would turn a
|
|
662
|
-
// trailing unknown adjective into a qualifier miss on its own).
|
|
663
|
-
const LEADING_DETERMINERS = new Set(["the", "a", "an", "these", "those"]);
|
|
664
|
-
|
|
665
|
-
const splitWords = (text) => String(text).replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
666
|
-
const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
|
|
667
|
-
: (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
|
|
668
|
-
const isGerundVerb = (w) => !!VERB_TO_KIND[w] && w.endsWith("ing");
|
|
669
|
-
|
|
670
|
-
/** Run the two existing strategies on a FRAGMENT and return a single simple clause
|
|
671
|
-
* (or null). Deterministic tie-break: on strategy disagreement the anchored parse
|
|
672
|
-
* wins (STRATEGIES[0]) — a fragment fed from the composer is already shape-
|
|
673
|
-
* constrained, so the merge's "surface an ambiguity" behavior isn't wanted here. */
|
|
674
|
-
function parseSimpleClause(text, nlp) {
|
|
675
|
-
const hits = STRATEGIES.map((s) => s.parse(text, nlp)).filter(Boolean);
|
|
676
|
-
if (!hits.length) return null;
|
|
677
|
-
if (hits.length === 1) return hits[0];
|
|
678
|
-
return sameParse(hits[0], hits[1]) ? hits[0] : hits[0];
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/** Top compositional dispatcher — first marker-matching production wins; a
|
|
682
|
-
* production returns null (not this shape → fall through) or an AST node (which
|
|
683
|
-
* may itself be {node:"miss"} when the marker was present but uncompilable). */
|
|
684
|
-
function parseComposite(text, nlp) {
|
|
685
|
-
const w = splitWords(text);
|
|
686
|
-
const lc = w.map((x) => x.toLowerCase());
|
|
687
|
-
return parseAnaphora(w, lc, nlp)
|
|
688
|
-
|| parseAggregate(w, lc, nlp)
|
|
689
|
-
|| parseSuperlative(w, lc, nlp)
|
|
690
|
-
|| parseList(w, lc, nlp, 0)
|
|
691
|
-
|| parseNested(w, lc, nlp, 0)
|
|
692
|
-
|| parseRelationalOrQualified(w, lc, nlp, 0);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/** A set-producing sub-expression (used for nested inner clauses, boolean branches,
|
|
696
|
-
* and count restrictors): nested first, then the relational/qualifier/boolean
|
|
697
|
-
* parser, then a bare simple clause. Carries `depth` for the nesting cap. */
|
|
698
|
-
function parseSetPhrase(text, nlp, depth) {
|
|
699
|
-
if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
|
|
700
|
-
const w = splitWords(text);
|
|
701
|
-
const lc = w.map((x) => x.toLowerCase());
|
|
702
|
-
const nested = parseNested(w, lc, nlp, depth);
|
|
703
|
-
if (nested) return nested;
|
|
704
|
-
const rel = parseRelationalOrQualified(w, lc, nlp, depth);
|
|
705
|
-
if (rel) return rel;
|
|
706
|
-
const clause = parseSimpleClause(text, nlp);
|
|
707
|
-
if (clause) return { node: "clause", clause };
|
|
708
|
-
return null;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/** NESTED / RELATIVE (object-position relative clause): "<outer verb> <placeholder
|
|
712
|
-
* |entity> that <inner>" — the noun before "that" is the OBJECT of the outer edge,
|
|
713
|
-
* constrained by the inner clause. Distinguished from a subject-relative ("functions
|
|
714
|
-
* that call X", handled by parseRelationalOrQualified) by requiring a VERB before the
|
|
715
|
-
* relative noun (i.e. the noun is not the leading subject). Returns a reverse/forward
|
|
716
|
-
* Set node, an honest miss (marker present, uncompilable), or null (no object-relative
|
|
717
|
-
* marker → let another production try). */
|
|
718
|
-
function parseNested(w, lc, nlp, depth) {
|
|
719
|
-
for (let r = 1; r < lc.length; r += 1) {
|
|
720
|
-
if (!RELATIVE_PRONOUNS.includes(lc[r])) continue;
|
|
721
|
-
if (r + 1 >= lc.length) continue; // nothing after "that"
|
|
722
|
-
const noun = entityNoun(lc[r - 1]);
|
|
723
|
-
if (!noun) continue; // "that" not preceded by a noun
|
|
724
|
-
const head = w.slice(0, r - 1); // outer clause words, minus the placeholder noun
|
|
725
|
-
if (!head.length) continue; // noun is the leading subject → subject-relative, not this shape
|
|
726
|
-
const outer = parseSimpleClause([...head, NEST_SENTINEL].join(" "), nlp);
|
|
727
|
-
if (!outer || (outer.shape !== "reverse" && outer.shape !== "forward")) continue;
|
|
728
|
-
if (outer.modifier && outer.modifier !== "direct") continue; // no transitive-over-set closure primitive
|
|
729
|
-
// build the inner sub-query: "which <placeholder-noun> <inner-text>" — recurses,
|
|
730
|
-
// so the inner may itself be nested/boolean/qualified (depth ≥2).
|
|
731
|
-
const innerText = `which ${lc[r - 1]} ${w.slice(r + 1).join(" ")}`;
|
|
732
|
-
const inner = parseSetPhrase(innerText, nlp, depth + 1);
|
|
733
|
-
if (!inner || inner.node === "miss") return inner ? { node: "miss", reason: inner.reason || "inner clause didn't parse" } : { node: "miss", reason: "inner clause didn't parse" };
|
|
734
|
-
return { node: outer.shape === "reverse" ? "reverseSet" : "forwardSet", kind: outer.kind, entityType: outer.entityType, inner };
|
|
735
|
-
}
|
|
736
|
-
return null;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
/** ANAPHORA over the previous result set: "which of those/them <filter>", "how many
|
|
740
|
-
* of those <filter>". Requires "of <pronoun>" (so a bare "those" in a term never
|
|
741
|
-
* fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
|
|
742
|
-
* uncompilable), or null. */
|
|
743
|
-
function parseAnaphora(w, lc, nlp) {
|
|
744
|
-
let p = -1;
|
|
745
|
-
for (let i = 1; i < lc.length; i += 1) {
|
|
746
|
-
if (ANAPHORA_TRIGGERS.includes(lc[i]) && lc[i - 1] === "of") { p = i; break; }
|
|
747
|
-
}
|
|
748
|
-
if (p < 0) return null;
|
|
749
|
-
const head = lc.slice(0, p - 1).join(" ");
|
|
750
|
-
const mode = /^(how many|how much|count)\b/.test(head) ? "count" : "list";
|
|
751
|
-
const filter = parsePredicateFilter(w.slice(p + 1), nlp);
|
|
752
|
-
if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
|
|
753
|
-
return { node: "anaphora", mode, filter };
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/** Parse a trailing filter (for anaphora, and any "of those that …" tail) into
|
|
757
|
-
* {type:"all"} | {type:"qual", filters} | {type:"clause", clause}. Returns
|
|
758
|
-
* undefined when a non-empty filter cannot be compiled (an honest miss upstream). */
|
|
759
|
-
function parsePredicateFilter(words, nlp) {
|
|
760
|
-
let i = 0;
|
|
761
|
-
const lc = words.map((x) => x.toLowerCase());
|
|
762
|
-
while (i < lc.length && PRED_LEAD_SKIP.has(lc[i])) i += 1;
|
|
763
|
-
const rest = words.slice(i);
|
|
764
|
-
const restLc = lc.slice(i);
|
|
765
|
-
if (!rest.length) return { type: "all" };
|
|
766
|
-
if (restLc.every((x) => QUALIFIERS[x])) return { type: "qual", filters: restLc };
|
|
767
|
-
const clause = parseSimpleClause(`what ${rest.join(" ")}`, nlp);
|
|
768
|
-
if (clause && (clause.shape === "reverse" || clause.shape === "forward") && clause.object) {
|
|
769
|
-
return { type: "clause", clause };
|
|
770
|
-
}
|
|
771
|
-
return undefined;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/** Trailing "and that's the whole question" filler an aggregate/list tail can carry
|
|
775
|
-
* ("how many classes are there", "list functions in total", "which classes exist in
|
|
776
|
-
* the index") — a count/list over a bare kind is frequently phrased with such a tail,
|
|
777
|
-
* and it must NOT be mistaken for a restrictor (that's the exact bug behind "how many
|
|
778
|
-
* classes are there" → the count-restrictor miss). Combined with STOPWORDS (which
|
|
779
|
-
* already carries are/there/is/in/the/…) at the call site, so only the non-stopword
|
|
780
|
-
* extras live here. A tail with ANY word outside this ∪ STOPWORDS is a real restrictor. */
|
|
781
|
-
const AGG_TAIL_FILLER = new Set([
|
|
782
|
-
"total", "altogether", "overall", "exist", "exists", "existing", "present",
|
|
783
|
-
"here", "now", "currently", "graph", "index", "codebase", "repo", "repository",
|
|
784
|
-
]);
|
|
785
|
-
|
|
786
|
-
/** AGGREGATE / COUNT: "how many <entity> [<restrictor>]", "count <entity>",
|
|
787
|
-
* "number of <entity> that …". A bare "how many classes" counts the class of
|
|
788
|
-
* individuals; a restrictor tail counts a clause's result set; a purely-filler tail
|
|
789
|
-
* ("… are there", "… in total") is treated as no restrictor (a bare count). */
|
|
790
|
-
function parseAggregate(w, lc, nlp) {
|
|
791
|
-
const trig = AGGREGATE_TRIGGERS.find((t) => lc.slice(0, t.split(" ").length).join(" ") === t);
|
|
792
|
-
if (!trig) return null;
|
|
793
|
-
let i = trig.split(" ").length;
|
|
794
|
-
while (i < lc.length && (lc[i] === "the" || lc[i] === "a" || lc[i] === "all")) i += 1;
|
|
795
|
-
const quals = [];
|
|
796
|
-
while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
|
|
797
|
-
const noun = i < lc.length ? entityNoun(lc[i]) : null;
|
|
798
|
-
if (!noun) return { node: "miss", reason: "count needs a known entity kind (functions, classes, modules, …)" };
|
|
799
|
-
const entWord = lc[i];
|
|
800
|
-
i += 1;
|
|
801
|
-
const tail = w.slice(i);
|
|
802
|
-
const tailMeaningful = lc.slice(i).some((t) => !STOPWORDS.has(t) && !AGG_TAIL_FILLER.has(t));
|
|
803
|
-
let base;
|
|
804
|
-
if (tailMeaningful) {
|
|
805
|
-
const setAst = parseSetPhrase(`which ${entWord} ${tail.join(" ")}`, nlp, 1);
|
|
806
|
-
if (!setAst || setAst.node === "miss") return { node: "miss", reason: "the count restrictor didn't parse" };
|
|
807
|
-
base = setAst;
|
|
808
|
-
} else {
|
|
809
|
-
base = { node: "allOfClass", entityType: noun.entityType };
|
|
810
|
-
}
|
|
811
|
-
if (quals.length) base = { node: "qualifier", filters: quals, inner: base };
|
|
812
|
-
return { node: "count", entityType: noun.entityType, base };
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// Determiners/objects skipped after a LIST trigger verb ("show me THE classes") — a
|
|
816
|
-
// superset of the aggregate skip so "give me all the modules" reaches the kind noun.
|
|
817
|
-
const LIST_SKIP = new Set(["the", "a", "an", "all", "me", "us"]);
|
|
818
|
-
const LIST_TRIGGERS_SORTED = [...LIST_TRIGGERS].sort((a, b) => b.split(" ").length - a.split(" ").length);
|
|
819
|
-
// The listable node classes, named in the honest miss and the empty-index message.
|
|
820
|
-
const LISTABLE_KINDS = "functions, classes, methods, modules, attributes, variables, or commits";
|
|
821
|
-
|
|
822
|
-
/** LIST: "list <kind>", "show me the <kind>s", "what are the <kind>", "list <kind> in
|
|
823
|
-
* <module>". A sibling of the count node — it enumerates the individuals of a class
|
|
824
|
-
* (rendered under OVERFLOW_CAP) instead of counting them. Fires on a LIST_TRIGGERS
|
|
825
|
-
* verb, OR the bare interrogative "what/which <kind>" — but the interrogative form is
|
|
826
|
-
* gated to a filler-only tail so an ordinary reverse query ("which functions call X")
|
|
827
|
-
* is NOT hijacked into a list (it must stay a simple clause; the compat tests pin it).
|
|
828
|
-
* A scope/predicate tail ("in walk.mjs", "that call X") is delegated to parseSetPhrase
|
|
829
|
-
* (reusing membership/relational/boolean), and its `scoped` flag suppresses the
|
|
830
|
-
* "narrow with …" hint. An unknown kind after a clear imperative trigger ("list
|
|
831
|
-
* bananas") is an honest miss naming the listable kinds; anything less certain falls
|
|
832
|
-
* through (null) to the existing parser/cascade rather than guessing. */
|
|
833
|
-
function parseList(w, lc, nlp, depth) {
|
|
834
|
-
let i = 0;
|
|
835
|
-
let interrogative = false;
|
|
836
|
-
let matched = null;
|
|
837
|
-
for (const t of LIST_TRIGGERS_SORTED) {
|
|
838
|
-
const tw = t.split(" ");
|
|
839
|
-
if (lc.slice(0, tw.length).join(" ") === t) { matched = t; i = tw.length; break; }
|
|
840
|
-
}
|
|
841
|
-
if (!matched) {
|
|
842
|
-
if (lc[0] === "what" || lc[0] === "which") { interrogative = true; i = 1; }
|
|
843
|
-
else return null;
|
|
844
|
-
}
|
|
845
|
-
while (i < lc.length && LIST_SKIP.has(lc[i])) i += 1;
|
|
846
|
-
const quals = [];
|
|
847
|
-
while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
|
|
848
|
-
const noun = i < lc.length ? entityNoun(lc[i]) : null;
|
|
849
|
-
// "Change" is ask-vocab.mjs's pseudo-type (no node is ever class "Change"), so it is
|
|
850
|
-
// not a listable class — fall through rather than render a false empty.
|
|
851
|
-
if (!noun || noun.placeholder || noun.entityType === "Change") {
|
|
852
|
-
// A clear imperative "list <one unknown plain word>" is an honest miss that NAMES
|
|
853
|
-
// the kinds; a verb-led or multi-word tail, or the interrogative form, is too
|
|
854
|
-
// uncertain to claim as a list — fall through to the existing parser/cascade.
|
|
855
|
-
if (!interrogative && i < lc.length && i === lc.length - 1
|
|
856
|
-
&& /^[a-z]+$/.test(lc[i]) && !VERB_TO_KIND[lc[i]] && !PLACEHOLDER_NOUNS.includes(lc[i])) {
|
|
857
|
-
return { node: "miss", reason: `"${lc[i]}" isn't a listable kind — try ${LISTABLE_KINDS}` };
|
|
858
|
-
}
|
|
859
|
-
return null;
|
|
860
|
-
}
|
|
861
|
-
const entityType = noun.entityType;
|
|
862
|
-
const entWord = lc[i];
|
|
863
|
-
i += 1;
|
|
864
|
-
const tail = w.slice(i);
|
|
865
|
-
const tailMeaningful = lc.slice(i).some((t) => !STOPWORDS.has(t) && !AGG_TAIL_FILLER.has(t));
|
|
866
|
-
// The bare interrogative "what/which <kind>" is a list ONLY with an explicit
|
|
867
|
-
// list-confirming filler tail ("… are there", "… that exist"): a real predicate
|
|
868
|
-
// ("which functions call X") is a reverse query, and a *bare* "which methods" is left
|
|
869
|
-
// alone deliberately — otherwise the relaxation cascade could drop an unknown
|
|
870
|
-
// qualifier ("which shiny methods" → "which methods") and silently list everything,
|
|
871
|
-
// erasing the honest "unknown qualifier" miss. Imperative triggers ("list methods")
|
|
872
|
-
// carry their own list intent, so they need no such tail.
|
|
873
|
-
if (interrogative && (tailMeaningful || tail.length === 0)) return null;
|
|
874
|
-
let base;
|
|
875
|
-
let scoped = false;
|
|
876
|
-
if (tailMeaningful) {
|
|
877
|
-
const setAst = parseSetPhrase(`which ${[...quals, entWord, ...tail].join(" ")}`, nlp, (depth || 0) + 1);
|
|
878
|
-
if (!setAst || setAst.node === "miss") return { node: "miss", reason: (setAst && setAst.reason) || "the list filter didn't parse" };
|
|
879
|
-
base = setAst;
|
|
880
|
-
scoped = true;
|
|
881
|
-
} else {
|
|
882
|
-
base = { node: "allOfClass", entityType };
|
|
883
|
-
if (quals.length) base = { node: "qualifier", filters: quals, inner: base };
|
|
884
|
-
}
|
|
885
|
-
return { node: "list", entityType, base, scoped };
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/** SUPERLATIVE: "which <entity> has the most/fewest <edge-noun>", "the most-connected
|
|
889
|
-
* <entity>", "the largest <entity>". Ranks individuals of <entity> by a degree
|
|
890
|
-
* metric over the classified edge groups. An unrecognized edge noun is an honest
|
|
891
|
-
* miss naming the supported ones. */
|
|
892
|
-
function parseSuperlative(w, lc, nlp) {
|
|
893
|
-
// extreme (single word, or "most connected" two-word)
|
|
894
|
-
let ext = null; let extIdx = -1;
|
|
895
|
-
for (let i = 0; i < lc.length; i += 1) {
|
|
896
|
-
const two = lc.slice(i, i + 2).join(" ");
|
|
897
|
-
if (SUPERLATIVE_EXTREMES[two]) { ext = SUPERLATIVE_EXTREMES[two]; extIdx = i; break; }
|
|
898
|
-
if (SUPERLATIVE_EXTREMES[lc[i]]) { ext = SUPERLATIVE_EXTREMES[lc[i]]; extIdx = i; break; }
|
|
899
|
-
}
|
|
900
|
-
if (!ext) return null;
|
|
901
|
-
// entity noun anywhere (first match, deterministic)
|
|
902
|
-
let entityType; let entWord = null;
|
|
903
|
-
for (const x of lc) { const n = entityNoun(x); if (n && !n.placeholder) { entityType = n.entityType; entWord = x; break; } }
|
|
904
|
-
if (!entWord) return { node: "miss", reason: "a superlative needs an entity kind (module, class, function, …)" };
|
|
905
|
-
// edge noun after the extreme (imports/callers/methods/…)
|
|
906
|
-
let metric = null; let metricNoun = null;
|
|
907
|
-
for (let i = extIdx; i < lc.length; i += 1) {
|
|
908
|
-
if (EDGE_NOUN_TO_METRIC[lc[i]]) { metric = EDGE_NOUN_TO_METRIC[lc[i]]; metricNoun = lc[i]; break; }
|
|
909
|
-
}
|
|
910
|
-
const connectivity = lc.includes("connected") || lc.slice(extIdx, extIdx + 2).join(" ") === "most connected"
|
|
911
|
-
|| ["largest", "biggest", "smallest"].includes(lc[extIdx]);
|
|
912
|
-
if (!metric) {
|
|
913
|
-
if (connectivity) { metric = EDGE_NOUN_TO_METRIC.connections; metricNoun = "connections"; }
|
|
914
|
-
else return { node: "miss", reason: "name what to rank by (imports, callers, methods, tests, or connections)" };
|
|
915
|
-
}
|
|
916
|
-
return { node: "superlative", entityType, metric, metricNoun, extreme: ext };
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
/** RELATIONAL / BOOLEAN / QUALIFIER (subject-first): "[which] [<qualifier>…] <entity>
|
|
920
|
-
* [that] <predicate>", where <predicate> is one or more relation clauses joined by
|
|
921
|
-
* and/or/but-not over the SAME subject, a "<of|in> <term>" membership, or empty (a
|
|
922
|
-
* bare qualified class). Fires ONLY on a compositional marker — a leading qualifier,
|
|
923
|
-
* a relative pronoun, a gerund-led predicate, or a membership "of/in" — so a plain
|
|
924
|
-
* reverse query ("which functions call helper") and the bare-template ambiguous case
|
|
925
|
-
* ("which classes extends Base and couples to logging", no marker) both fall through
|
|
926
|
-
* to the existing strategies untouched. Returns an AST node, a miss, or null. */
|
|
927
|
-
function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
928
|
-
let i = 0;
|
|
929
|
-
while (i < lc.length && FRAME_WORDS.has(lc[i])) i += 1;
|
|
930
|
-
const framed = i > 0;
|
|
931
|
-
// peel a leading determiner (see LEADING_DETERMINERS) — a bare "the"/"those" in front of
|
|
932
|
-
// the subject noun, so "the modules that import X" reaches the same production as the
|
|
933
|
-
// bare "modules that import X". Not folded into `framed` on purpose.
|
|
934
|
-
while (i < lc.length && LEADING_DETERMINERS.has(lc[i])) i += 1;
|
|
935
|
-
const quals = [];
|
|
936
|
-
while (i < lc.length && QUALIFIERS[lc[i]]) { quals.push(lc[i]); i += 1; }
|
|
937
|
-
const noun = i < lc.length ? entityNoun(lc[i]) : null;
|
|
938
|
-
if (!noun) {
|
|
939
|
-
// an unknown adjective sitting in the qualifier slot, right before a known
|
|
940
|
-
// entity noun ("which shiny methods", "static frobnicated functions") — an
|
|
941
|
-
// honest miss that NAMES the supported qualifiers, never a guess (PLAN P3).
|
|
942
|
-
// STOPWORDS are excluded so a normal question auxiliary in that position ("what
|
|
943
|
-
// DID commit X touch", "what WAS in <sha>") is left for the existing parser, not
|
|
944
|
-
// mistaken for an unknown qualifier.
|
|
945
|
-
if ((framed || quals.length) && i + 1 < lc.length && /^[a-z]+$/.test(lc[i])
|
|
946
|
-
&& !VERB_TO_KIND[lc[i]] && !STOPWORDS.has(lc[i]) && entityNoun(lc[i + 1])) {
|
|
947
|
-
return { node: "miss", reason: `unknown qualifier "${lc[i]}" — supported: ${Object.keys(QUALIFIERS).join(", ")}` };
|
|
948
|
-
}
|
|
949
|
-
return null; // no subject entity → not this shape
|
|
950
|
-
}
|
|
951
|
-
const entityType = noun.entityType;
|
|
952
|
-
const entWord = lc[i];
|
|
953
|
-
i += 1;
|
|
954
|
-
let predLc = lc.slice(i);
|
|
955
|
-
let predWords = w.slice(i);
|
|
956
|
-
let relFlag = false;
|
|
957
|
-
if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
|
|
958
|
-
const membershipLed = predLc[0] === "of" || predLc[0] === "in";
|
|
959
|
-
const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
|
|
960
|
-
// marker gate — the crux of backward-compat: without one of these, this is not a
|
|
961
|
-
// compositional query and we must NOT hijack it from the existing parser.
|
|
962
|
-
if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
|
|
963
|
-
|
|
964
|
-
// empty predicate → a bare qualified class ("public methods")
|
|
965
|
-
if (!predWords.length) {
|
|
966
|
-
let base = { node: "allOfClass", entityType };
|
|
967
|
-
if (!quals.length) return { node: "miss", reason: "nothing to filter or traverse" };
|
|
968
|
-
return { node: "qualifier", filters: quals, inner: base };
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
const subjPrefix = noun.placeholder ? "what" : `which ${entWord}`;
|
|
972
|
-
const { branches, ops } = splitBoolean(predLc, predWords);
|
|
973
|
-
// build one atom per branch, borrowing a leading verb phrase across bare branches
|
|
974
|
-
// ("importing X or Y" → the second branch inherits "importing").
|
|
975
|
-
let prevVerb = null;
|
|
976
|
-
const atoms = [];
|
|
977
|
-
for (let b = 0; b < branches.length; b += 1) {
|
|
978
|
-
const bw = branches[b];
|
|
979
|
-
const blc = bw.map((x) => x.toLowerCase());
|
|
980
|
-
const op = b === 0 ? "seed" : ops[b - 1];
|
|
981
|
-
if (bw.length && blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: blc }); continue; }
|
|
982
|
-
if (blc[0] === "of" || blc[0] === "in") {
|
|
983
|
-
atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
|
|
984
|
-
continue;
|
|
985
|
-
}
|
|
986
|
-
let phrase = bw;
|
|
987
|
-
const vh = findPhrase(blc, VERB_TO_KIND);
|
|
988
|
-
if (vh) prevVerb = bw.slice(vh.start, vh.end);
|
|
989
|
-
else if (prevVerb) phrase = [...prevVerb, ...bw];
|
|
990
|
-
// a branch is a single predicate (top-level booleans are already split out), so
|
|
991
|
-
// parse it as nested-or-simple — NOT back through parseSetPhrase, which would
|
|
992
|
-
// re-detect the branch's own gerund/relative lead and recurse on identical text.
|
|
993
|
-
const ast = parseBranchAst(`${subjPrefix} ${phrase.join(" ")}`, nlp, depth + 1);
|
|
994
|
-
if (!ast || ast.node === "miss") return { node: "miss", reason: (ast && ast.reason) || "a clause in the combination didn't parse" };
|
|
995
|
-
atoms.push({ op, kind: "set", ast });
|
|
996
|
-
}
|
|
997
|
-
// the first atom must be a base set, not a bare qualifier (a qualifier needs
|
|
998
|
-
// something to filter). "public methods" already took the empty-predicate path above.
|
|
999
|
-
if (atoms[0].kind !== "set") return { node: "miss", reason: "start with a clause, then combine with and/or/but-not" };
|
|
1000
|
-
|
|
1001
|
-
let result;
|
|
1002
|
-
if (atoms.length === 1) {
|
|
1003
|
-
result = atoms[0].ast;
|
|
1004
|
-
} else {
|
|
1005
|
-
result = { node: "boolean", entityType, atoms };
|
|
1006
|
-
}
|
|
1007
|
-
if (quals.length) result = { node: "qualifier", filters: quals, inner: result };
|
|
1008
|
-
return result;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
/** Parse a single boolean branch (one predicate over the subject) into a set-AST:
|
|
1012
|
-
* nested (the branch has its own object-relative "that") or a plain simple clause.
|
|
1013
|
-
* Deliberately does NOT re-enter parseRelationalOrQualified — the branch has no
|
|
1014
|
-
* top-level boolean of its own (it was just split off one), so descending there
|
|
1015
|
-
* would only re-detect its gerund/relative lead and recurse on the same text. */
|
|
1016
|
-
function parseBranchAst(text, nlp, depth) {
|
|
1017
|
-
if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
|
|
1018
|
-
const w = splitWords(text);
|
|
1019
|
-
const lc = w.map((x) => x.toLowerCase());
|
|
1020
|
-
const nested = parseNested(w, lc, nlp, depth);
|
|
1021
|
-
if (nested) return nested;
|
|
1022
|
-
const clause = parseSimpleClause(text, nlp);
|
|
1023
|
-
return clause ? { node: "clause", clause } : null;
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/** Split a predicate word array on boolean connectives (longest key first, so
|
|
1027
|
-
* "but not" beats a bare "not"). Returns {branches:[[word…]…], ops:[op…]} with
|
|
1028
|
-
* branches.length === ops.length + 1. */
|
|
1029
|
-
function splitBoolean(predLc, predWords) {
|
|
1030
|
-
const conns = Object.keys(BOOLEAN_CONNECTIVES).sort((a, z) => z.split(" ").length - a.split(" ").length);
|
|
1031
|
-
const branches = []; const ops = [];
|
|
1032
|
-
let start = 0; let i = 0;
|
|
1033
|
-
while (i < predLc.length) {
|
|
1034
|
-
let hit = null;
|
|
1035
|
-
for (const c of conns) {
|
|
1036
|
-
const cw = c.split(" ");
|
|
1037
|
-
if (predLc.slice(i, i + cw.length).join(" ") === c) { hit = { c, len: cw.length }; break; }
|
|
1038
|
-
}
|
|
1039
|
-
if (hit && i > start) { // a connective, and not at a branch start (avoid leading "and")
|
|
1040
|
-
branches.push(predWords.slice(start, i));
|
|
1041
|
-
ops.push(BOOLEAN_CONNECTIVES[hit.c]);
|
|
1042
|
-
i += hit.len; start = i;
|
|
1043
|
-
} else if (hit) { i += hit.len; start = i; } // connective at branch start — skip it
|
|
1044
|
-
else i += 1;
|
|
1045
|
-
}
|
|
1046
|
-
branches.push(predWords.slice(start));
|
|
1047
|
-
return { branches, ops };
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
// ---- compositional EVALUATION — compile an AST to a graph traversal, reusing the
|
|
1051
|
-
// same primitives (edgesOfKind, resolveObject, refineToEntities, traverse) the
|
|
1052
|
-
// simple path uses. Pure given (graph, ast, opts). ----
|
|
1053
|
-
|
|
1054
|
-
/** Reverse traversal over a SET of object ids (the nested "callers of {X…}" step) —
|
|
1055
|
-
* mirrors traverse()'s reverse general case (symbol-grain sibling + defines-refine),
|
|
1056
|
-
* but membership-tests e.object against a set instead of a single id. */
|
|
1057
|
-
function reverseOverSet(graph, kind, entityType, objectIds) {
|
|
1058
|
-
const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
|
|
1059
|
-
if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
|
|
1060
|
-
const edges = edgesOfKind(graph, symbolKind).filter((e) => objectIds.has(e.object));
|
|
1061
|
-
return uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter((s) => s && s.class === entityType));
|
|
1062
|
-
}
|
|
1063
|
-
const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => objectIds.has(e.object));
|
|
1064
|
-
const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
|
|
1065
|
-
if (!entityType || entityType === "Change") return subjects;
|
|
1066
|
-
const direct = subjects.filter((s) => s.class === entityType);
|
|
1067
|
-
if (direct.length) return direct;
|
|
1068
|
-
if (entityType !== "Module" && subjects.some((s) => s.class === "Module")) {
|
|
1069
|
-
return refineToEntities(graph, new Set(subjects.filter((s) => s.class === "Module").map((s) => s.id)), entityType);
|
|
1070
|
-
}
|
|
1071
|
-
return [];
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/** Forward traversal over a SET of subject ids (the "things {X…} call/define" step). */
|
|
1075
|
-
function forwardOverSet(graph, kind, subjectIds) {
|
|
1076
|
-
const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => subjectIds.has(e.subject));
|
|
1077
|
-
return uniqueById(edges.map((e) => graph.byId.get(e.object)).filter(Boolean));
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
function uniqueById(inds) {
|
|
1081
|
-
const seen = new Set(); const out = [];
|
|
1082
|
-
for (const x of inds) if (x && !seen.has(x.id)) { seen.add(x.id); out.push(x); }
|
|
1083
|
-
return out;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
// Per-graph memo for the qualifier attribute/edge sets (exported symbols, tested
|
|
1087
|
-
// modules, symbol→module map) — computed once, so a qualifier filter over a large
|
|
1088
|
-
// result set stays cheap and deterministic.
|
|
1089
|
-
const qualCache = new WeakMap();
|
|
1090
|
-
function qualSets(graph) {
|
|
1091
|
-
let c = qualCache.get(graph);
|
|
1092
|
-
if (c) return c;
|
|
1093
|
-
const exported = new Set();
|
|
1094
|
-
for (const e of edgesOfKind(graph, "reexports")) {
|
|
1095
|
-
exported.add(String(e.object).toLowerCase());
|
|
1096
|
-
const ind = graph.byId.get(e.object);
|
|
1097
|
-
if (ind) exported.add(String(ind.label).toLowerCase());
|
|
1098
|
-
}
|
|
1099
|
-
const testedModules = new Set(edgesOfKind(graph, "tests").map((e) => e.object));
|
|
1100
|
-
const moduleOfSymbol = new Map();
|
|
1101
|
-
for (const e of edgesOfKind(graph, "defines")) moduleOfSymbol.set(e.object, e.subject);
|
|
1102
|
-
c = { exported, testedModules, moduleOfSymbol };
|
|
1103
|
-
qualCache.set(graph, c);
|
|
1104
|
-
return c;
|
|
1105
|
-
}
|
|
1106
|
-
function moduleIdOf(graph, ind) {
|
|
1107
|
-
if (!ind) return null;
|
|
1108
|
-
if (ind.class === "Module") return ind.id;
|
|
1109
|
-
return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
/** Does an individual satisfy one qualifier (spec from QUALIFIERS)? Reads only
|
|
1113
|
-
* attributes/edges the graph already carries — an unpopulated attribute (e.g.
|
|
1114
|
-
* isAbstract) simply yields false, an honest empty rather than an error. */
|
|
1115
|
-
function qualHolds(graph, ind, spec) {
|
|
1116
|
-
if (!spec) return false;
|
|
1117
|
-
switch (spec.via) {
|
|
1118
|
-
case "visibility": {
|
|
1119
|
-
const v = String((ind.attributes || []).find((a) => a.key === "visibility")?.value || "public").toLowerCase();
|
|
1120
|
-
return v === spec.value;
|
|
1121
|
-
}
|
|
1122
|
-
case "attr":
|
|
1123
|
-
return !!(ind.attributes || []).find((a) => a.key === spec.attr)?.value;
|
|
1124
|
-
case "exported": {
|
|
1125
|
-
const ex = qualSets(graph).exported;
|
|
1126
|
-
return ex.has(String(ind.label).toLowerCase()) || ex.has(String(ind.id).toLowerCase());
|
|
1127
|
-
}
|
|
1128
|
-
case "tested": {
|
|
1129
|
-
const mid = moduleIdOf(graph, ind);
|
|
1130
|
-
return (!!mid && qualSets(graph).testedModules.has(mid)) === spec.value;
|
|
1131
|
-
}
|
|
1132
|
-
default: return false;
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
/** Compile a set-producing AST into an array of individuals. */
|
|
1137
|
-
function evalSet(graph, ast, opts) {
|
|
1138
|
-
switch (ast.node) {
|
|
1139
|
-
case "clause": return traverse(graph, ast.clause, opts).matches || [];
|
|
1140
|
-
case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
|
|
1141
|
-
case "reverseSet": {
|
|
1142
|
-
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
1143
|
-
return reverseOverSet(graph, ast.kind, ast.entityType, ids);
|
|
1144
|
-
}
|
|
1145
|
-
case "forwardSet": {
|
|
1146
|
-
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
1147
|
-
return forwardOverSet(graph, ast.kind, ids);
|
|
1148
|
-
}
|
|
1149
|
-
case "membership": {
|
|
1150
|
-
const r = resolveObject(graph, ast.term);
|
|
1151
|
-
if (!r.match) return [];
|
|
1152
|
-
const ids = new Set([r.match.id]);
|
|
1153
|
-
const objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids)));
|
|
1154
|
-
return ast.entityType ? objs.filter((o) => o.class === ast.entityType) : objs;
|
|
1155
|
-
}
|
|
1156
|
-
case "qualifier": {
|
|
1157
|
-
const base = evalSet(graph, ast.inner, opts);
|
|
1158
|
-
return base.filter((ind) => ast.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f])));
|
|
1159
|
-
}
|
|
1160
|
-
case "boolean": return evalBoolean(graph, ast, opts);
|
|
1161
|
-
case "anaphora": return evalAnaphora(graph, ast, opts).matches;
|
|
1162
|
-
default: return [];
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
/** Fold a boolean AST left-to-right into a result set. A qualifier atom acts as a
|
|
1167
|
-
* set filter on the accumulator (intersection keeps satisfiers, difference removes
|
|
1168
|
-
* them); a set atom contributes its own id-set for the op. */
|
|
1169
|
-
function evalBoolean(graph, ast, opts) {
|
|
1170
|
-
let acc = [];
|
|
1171
|
-
for (const atom of ast.atoms) {
|
|
1172
|
-
if (atom.op === "seed") { acc = evalSet(graph, atom.ast, opts); continue; }
|
|
1173
|
-
if (atom.kind === "qual") {
|
|
1174
|
-
const holds = (ind) => atom.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]));
|
|
1175
|
-
acc = atom.op === "difference" ? acc.filter((i) => !holds(i)) : acc.filter((i) => holds(i));
|
|
1176
|
-
continue;
|
|
1177
|
-
}
|
|
1178
|
-
const oids = new Set(evalSet(graph, atom.ast, opts).map((i) => i.id));
|
|
1179
|
-
if (atom.op === "intersection") acc = acc.filter((i) => oids.has(i.id));
|
|
1180
|
-
else if (atom.op === "difference") acc = acc.filter((i) => !oids.has(i.id));
|
|
1181
|
-
else if (atom.op === "union") {
|
|
1182
|
-
const seen = new Set(acc.map((i) => i.id));
|
|
1183
|
-
for (const other of evalSet(graph, atom.ast, opts)) if (!seen.has(other.id)) { seen.add(other.id); acc.push(other); }
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
return acc;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
/** Anaphora over ask()'s `prev` id array — filter/count the previous answer's ids.
|
|
1190
|
-
* No prev supplied → honest miss (never a guess), like an unresolved pronoun. */
|
|
1191
|
-
function evalAnaphora(graph, ast, opts) {
|
|
1192
|
-
const prev = opts && opts.prev;
|
|
1193
|
-
if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
|
|
1194
|
-
let items = prev.map((id) => graph.byId.get(id)).filter(Boolean);
|
|
1195
|
-
const f = ast.filter;
|
|
1196
|
-
if (f && f.type === "qual") {
|
|
1197
|
-
items = items.filter((ind) => f.filters.every((q) => qualHolds(graph, ind, QUALIFIERS[q])));
|
|
1198
|
-
} else if (f && f.type === "clause") {
|
|
1199
|
-
const r = resolveObject(graph, f.clause.object);
|
|
1200
|
-
if (!r.match) items = [];
|
|
1201
|
-
else {
|
|
1202
|
-
// include the symbol-grain sibling so a fn->fn "call" filter tests callsSymbol,
|
|
1203
|
-
// not just the module-coarse "calls" edge (mirrors traverse()'s reverse path).
|
|
1204
|
-
const sib = SYMBOL_GRAIN_SIBLING[f.clause.kind];
|
|
1205
|
-
const kinds = [...kindsFor(f.clause.kind), ...(sib ? [sib] : [])];
|
|
1206
|
-
const ok = new Set(kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === r.match.id).map((e) => e.subject));
|
|
1207
|
-
items = items.filter((ind) => ok.has(ind.id));
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
// a count over a prior set names the entity kind when the survivors share a class.
|
|
1211
|
-
const common = items.length && items.every((x) => x.class === items[0].class) ? items[0].class : null;
|
|
1212
|
-
if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
|
|
1213
|
-
return { compositeKind: "set", matches: items, entityType: common };
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
// Structural kinds counted for "most-connected" (total degree). Symbol-grain and
|
|
1217
|
-
// commit-history kinds are excluded so "connections" reads as the code-structure
|
|
1218
|
-
// degree a developer means, not every recorded touch.
|
|
1219
|
-
const DEGREE_KINDS = ["imports", "calls", "callsSymbol", "inherits", "contains", "tests"];
|
|
1220
|
-
/** Degree of an individual under a superlative metric ({kind, dir, sibling?, filter?}). */
|
|
1221
|
-
function degreeMetric(graph, ind, metric) {
|
|
1222
|
-
const kinds = metric.kind === "*" ? DEGREE_KINDS : [metric.kind, ...(metric.sibling ? [metric.sibling] : [])];
|
|
1223
|
-
let n = 0;
|
|
1224
|
-
for (const k of kinds) for (const e of edgesOfKind(graph, k)) {
|
|
1225
|
-
const out = e.subject === ind.id; const inc = e.object === ind.id;
|
|
1226
|
-
if (metric.dir === "out" && out) {
|
|
1227
|
-
if (metric.filter) { const o = graph.byId.get(e.object); if (!o || o.class !== metric.filter) continue; }
|
|
1228
|
-
n += 1;
|
|
1229
|
-
} else if (metric.dir === "in" && inc) n += 1;
|
|
1230
|
-
else if (metric.dir === "both" && (out || inc)) n += 1;
|
|
1231
|
-
}
|
|
1232
|
-
return n;
|
|
1233
|
-
}
|
|
1234
|
-
function evalSuperlative(graph, ast) {
|
|
1235
|
-
const pool = graph.individuals.filter((i) => i.class === ast.entityType);
|
|
1236
|
-
const scored = pool.map((ind) => ({ ind, score: degreeMetric(graph, ind, ast.metric) }))
|
|
1237
|
-
.sort((a, z) => (ast.extreme === "most" ? z.score - a.score : a.score - z.score));
|
|
1238
|
-
if (!scored.length) return { compositeKind: "superlative", entityType: ast.entityType, matches: [] };
|
|
1239
|
-
const best = scored[0].score;
|
|
1240
|
-
const winners = scored.filter((s) => s.score === best).map((s) => s.ind);
|
|
1241
|
-
return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
/** Compile any compositional AST to a result object traverse() returns for the
|
|
1245
|
-
* simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
|
|
1246
|
-
export function evalComposite(graph, ast, opts = {}) {
|
|
1247
|
-
if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
|
|
1248
|
-
if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
|
|
1249
|
-
if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
|
|
1250
|
-
if (ast.node === "superlative") return evalSuperlative(graph, ast);
|
|
1251
|
-
if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
|
|
1252
|
-
return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
// ---- compositional RENDER — templated, same "honest miss vs cited hit" discipline
|
|
1256
|
-
// as renderCore. ----
|
|
1257
|
-
|
|
1258
|
-
const compositeList = (matches) => listJoin(matches.slice(0, OVERFLOW_CAP)
|
|
1259
|
-
.map((m) => (["Function", "Method"].includes(m.class) ? `${m.label}()` : m.label)))
|
|
1260
|
-
+ (matches.length > OVERFLOW_CAP ? `, …and ${matches.length - OVERFLOW_CAP} more` : "");
|
|
1261
|
-
|
|
1262
|
-
/** A compositional worked example for the rephrase hint (§honest miss now shows a
|
|
1263
|
-
* compositional phrasing too). */
|
|
1264
|
-
export function compositionalHint() {
|
|
1265
|
-
return 'compositional queries also work: "which functions call X and call Y", "what calls something that imports X", "public methods of X", "list functions" / "show me the classes", "how many classes", "which module has the most imports", or (after a listing) "which of those are tested"';
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
function renderComposite(parsed, result) {
|
|
1269
|
-
if (result.compositeMiss) {
|
|
1270
|
-
if (result.reason === "no-prev") {
|
|
1271
|
-
return { content: `"those"/"them" needs a previous answer to refer to — ask a listing question first, then follow up.`, miss: true, ambiguous: false };
|
|
1272
|
-
}
|
|
1273
|
-
return { content: `couldn't compile this compositional question${result.reason ? ` (${result.reason})` : ""}. ${compositionalHint()}.`, miss: true, ambiguous: false };
|
|
1274
|
-
}
|
|
1275
|
-
if (result.compositeKind === "count") {
|
|
1276
|
-
const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
|
|
1277
|
-
return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
|
|
1278
|
-
}
|
|
1279
|
-
if (result.compositeKind === "list") {
|
|
1280
|
-
if (!result.matches.length) {
|
|
1281
|
-
return { content: `no ${nounFor(result.entityType, 2)} in this index.`, miss: true, ambiguous: false, matches: [] };
|
|
1282
|
-
}
|
|
1283
|
-
// an unscoped list that overflowed the cap gets a light hint to narrow by module —
|
|
1284
|
-
// but only for kinds that live IN a module (a "modules in <module>" or "commits in
|
|
1285
|
-
// <module>" scope is meaningless); the scoped forms are already narrow, no hint.
|
|
1286
|
-
const scopeable = !["Module", "Commit"].includes(result.entityType);
|
|
1287
|
-
const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
|
|
1288
|
-
? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
|
|
1289
|
-
: "";
|
|
1290
|
-
return { content: `${compositeList(result.matches)}${hint}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
1291
|
-
}
|
|
1292
|
-
if (result.compositeKind === "superlative") {
|
|
1293
|
-
if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
|
|
1294
|
-
const lead = result.extreme === "most" ? "the most" : "the fewest";
|
|
1295
|
-
const tie = result.matches.length > 1 ? ` (${result.matches.length}-way tie)` : "";
|
|
1296
|
-
return {
|
|
1297
|
-
content: `${compositeList(result.matches)} — ${lead} ${result.metricNoun} (${result.score})${tie}.`,
|
|
1298
|
-
miss: false, ambiguous: false, matches: result.matches,
|
|
1299
|
-
};
|
|
1300
|
-
}
|
|
1301
|
-
// set-producing
|
|
1302
|
-
if (!result.matches.length) {
|
|
1303
|
-
return { content: `nothing in the index matches that${result.entityType ? ` (${nounFor(result.entityType, 2)})` : ""}.`, miss: true, ambiguous: false, matches: [] };
|
|
1304
|
-
}
|
|
1305
|
-
return { content: `${compositeList(result.matches)}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
/** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
|
|
1309
|
-
* uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
|
|
1310
|
-
export function rephraseHint() {
|
|
1311
|
-
return '"which <functions|classes|modules> <imports|calls|uses|inherits from|tests|touched> <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 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). '
|
|
1312
|
-
+ compositionalHint();
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
// ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
|
|
1316
|
-
|
|
1317
|
-
function componentSet(s) {
|
|
1318
|
-
return new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
/** Resolve a free-text object/subject term against the graph's individuals, in priority
|
|
1322
|
-
* order (§4, generalized beyond the module-coupling worked example to cover every verb
|
|
1323
|
-
* family's object grain — `inherits`/`calls` resolve against Class/Function names, not
|
|
1324
|
-
* just modules): a sha-shaped term ("[commit ]<hex≥7>") first resolves against Commit
|
|
1325
|
-
* individuals by unique id/label prefix (see the inline comment), then (1) exact
|
|
1326
|
-
* label/id match, (2) an `ext:` unresolved-target match (today
|
|
1327
|
-
* ext: targets are edge-endpoint STRINGS with no individual of their own — e.g. an
|
|
1328
|
-
* unimported inherits base, or any import target — so this tier returns a synthetic
|
|
1329
|
-
* {id:"ext:<name>", label:<name>, class:null} match rather than an `individuals` lookup;
|
|
1330
|
-
* see PLAN_MECHANICAL_CHAT.md §10 on why external `imports` targets land here rather
|
|
1331
|
-
* than tier 1/3 today), (3) boundary-aware substring/component match, (4) a prose-index
|
|
1332
|
-
* fallback (PLAN_PROSE_INDEX.md §6): the term, tokenized the same way as a docstring, is
|
|
1333
|
-
* looked up against `graph.proseIndex` via `lookupByProseTokens` — an exact WORD-level
|
|
1334
|
-
* overlap match against a symbol's decomposed-identifier or doc-comment tokens, never a
|
|
1335
|
-
* substring/fuzzy guess (the same "no wrong edge" standard as tiers 1-3, just over a
|
|
1336
|
-
* different token source: prose rather than the literal identifier). Only ever consulted
|
|
1337
|
-
* once every literal-identifier tier above has failed, and the result is tagged
|
|
1338
|
-
* `matchedVia: "prose"` so a caller can tell the match came from prose content rather
|
|
1339
|
-
* than the symbol's own name — the render layer does not currently read this (it treats
|
|
1340
|
-
* a resolved match as a resolved match, same "honest miss vs genuine hit" binary tiers
|
|
1341
|
-
* 1-3 already use), but the field is there for any caller that wants to surface it. (5)
|
|
1342
|
-
* a bounded Damerau-Levenshtein pass against labels AND label components (two-level
|
|
1343
|
-
* fuzzy, 2026-07-02): a UNIQUE within-bound match resolves, tagged `matchedVia:
|
|
1344
|
-
* "fuzzy"` so render() can say "assuming you meant <label>" out loud; multiple
|
|
1345
|
-
* matches at the same best distance are an honest ambiguity listing the candidates.
|
|
1346
|
-
* Never applied to sha-shaped terms (the commit namespace is exact-or-ambiguous
|
|
1347
|
-
* only) nor to terms under 4 chars (the bound would cover half of everything).
|
|
1348
|
-
* (6) no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
|
|
1349
|
-
* [, matchedVia]} — ambiguous on a true tier-3 score tie, a tier-4 overlap-count
|
|
1350
|
-
* tie, or a tier-5 distance tie. */
|
|
1351
|
-
export function resolveObject(graph, term) {
|
|
1352
|
-
const t = String(term || "").trim();
|
|
1353
|
-
if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
|
|
1354
|
-
const tLc = t.toLowerCase();
|
|
1355
|
-
const pool = graph.individuals;
|
|
1356
|
-
|
|
1357
|
-
// commit-sha tier (checked first, only for sha-shaped terms): "ef74e44e25c8",
|
|
1358
|
-
// "commit ef74e44e25c8", "commit:ef74e44", or a full 40-char sha resolve against
|
|
1359
|
-
// Commit individuals by id/label prefix (ids are commit:<full-sha>, labels the
|
|
1360
|
-
// 12-char short sha), case-insensitive. A UNIQUE prefix is exact-grade over the
|
|
1361
|
-
// closed commit namespace (tier 1); a prefix shared by more than one commit is an
|
|
1362
|
-
// honest ambiguity listing the candidates — never "the first one"; a hex-looking
|
|
1363
|
-
// word matching NO commit falls through to the ordinary tiers unchanged (it may
|
|
1364
|
-
// be a real code identifier).
|
|
1365
|
-
const shaTerm = tLc.match(/^(commit[:\s])?([0-9a-f]{7,40})$/);
|
|
1366
|
-
if (shaTerm) {
|
|
1367
|
-
const sha = shaTerm[2];
|
|
1368
|
-
const hits = pool.filter((i) => i.class === "Commit"
|
|
1369
|
-
&& (String(i.id).toLowerCase().startsWith(`commit:${sha}`) || String(i.label).toLowerCase().startsWith(sha)));
|
|
1370
|
-
if (hits.length === 1) return { match: hits[0], candidates: [], tier: 1, ambiguous: false };
|
|
1371
|
-
if (hits.length > 1) return { match: hits[0], candidates: hits.slice(1, 5), tier: 1, ambiguous: true };
|
|
1372
|
-
// the explicit "commit" noun declares intent — with no matching commit, falling
|
|
1373
|
-
// through would let the WORD "commit" component-match the Commit schema node (or
|
|
1374
|
-
// any identifier containing it): a guess, not a resolution. Bare hex still falls
|
|
1375
|
-
// through (it may be a real code identifier).
|
|
1376
|
-
if (shaTerm[1]) return { match: null, candidates: [], tier: null, ambiguous: false };
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
const exact = pool.find((i) => String(i.label).toLowerCase() === tLc || String(i.id).toLowerCase() === tLc);
|
|
1380
|
-
if (exact) return { match: exact, candidates: [], tier: 1, ambiguous: false };
|
|
1381
|
-
|
|
1382
|
-
// ext: targets never have their own individual (they're a raw edge-endpoint id) — find
|
|
1383
|
-
// the actual (case-preserved) id off a real edge rather than reconstructing it, so a
|
|
1384
|
-
// typo'd term can't silently "resolve" to an ext: id nothing in the graph references.
|
|
1385
|
-
const extLc = `ext:${tLc}`;
|
|
1386
|
-
let extId = null;
|
|
1387
|
-
outer: for (const g of graph.relations) {
|
|
1388
|
-
for (const e of g.edges) {
|
|
1389
|
-
if (String(e.object).toLowerCase() === extLc) { extId = e.object; break outer; }
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
if (extId) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
|
|
1393
|
-
|
|
1394
|
-
// tier 3 — two disjoint regimes (dotted-symbol fix, 2026-07-02, advisor-verified
|
|
1395
|
-
// bug): a DOTTED term with no slash ("res.json", "Widget.render", "walk.mjs") is
|
|
1396
|
-
// symbol-shaped (object.member / Class.method / a bare file name), and the old
|
|
1397
|
-
// any-substring-of-any-label pass let it land on a module whose PATH merely
|
|
1398
|
-
// contains the text ("res.json" -> test/res.json.js — the wrong grain presented
|
|
1399
|
-
// as if the term were that file). Such terms now match only (a) symbol labels
|
|
1400
|
-
// (whole-term containment, or the ".member" suffix when the owner alias differs:
|
|
1401
|
-
// "res.json" -> Response.json), and (b) module labels by EXACT basename equality
|
|
1402
|
-
// ("walk.mjs" -> src/walk.mjs — extension-stripped basename equality is
|
|
1403
|
-
// deliberately NOT used; that is precisely the phantom-path vector). Symbol
|
|
1404
|
-
// matches outrank module matches. Undotted/slashed terms keep the original pass.
|
|
1405
|
-
const scored = [];
|
|
1406
|
-
const dotted = !tLc.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(tLc);
|
|
1407
|
-
if (dotted) {
|
|
1408
|
-
const lastSeg = tLc.split(".").pop();
|
|
1409
|
-
for (const m of pool) {
|
|
1410
|
-
const label = String(m.label || "").toLowerCase();
|
|
1411
|
-
if (m.class === "Module") {
|
|
1412
|
-
if (label.split("/").pop() === tLc) scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
|
|
1413
|
-
} else if (label.includes(tLc)) {
|
|
1414
|
-
scored.push({ ind: m, score: 2000 - Math.abs(label.length - tLc.length) });
|
|
1415
|
-
} else if (label.endsWith(`.${lastSeg}`)) {
|
|
1416
|
-
scored.push({ ind: m, score: 1500 - Math.abs(label.length - tLc.length) });
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
} else {
|
|
1420
|
-
const termComps = componentSet(t);
|
|
1421
|
-
for (const m of pool) {
|
|
1422
|
-
const label = String(m.label || "").toLowerCase();
|
|
1423
|
-
if (label.includes(tLc)) {
|
|
1424
|
-
scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
|
|
1425
|
-
continue;
|
|
1426
|
-
}
|
|
1427
|
-
const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
|
|
1428
|
-
if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
scored.sort((a, b) => b.score - a.score);
|
|
1432
|
-
if (scored.length) {
|
|
1433
|
-
const [best, ...rest] = scored;
|
|
1434
|
-
const tied = rest.filter((x) => x.score === best.score);
|
|
1435
|
-
return {
|
|
1436
|
-
match: best.ind,
|
|
1437
|
-
candidates: rest.slice(0, 4).map((x) => x.ind),
|
|
1438
|
-
tier: 3,
|
|
1439
|
-
ambiguous: tied.length > 0,
|
|
1440
|
-
};
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
// tier 4: prose-index fallback (PLAN_PROSE_INDEX.md §6) — see the function doc above.
|
|
1444
|
-
// The typeof guard is the same viewer-bundle boundary as defaultNlp(): viz.mjs's
|
|
1445
|
-
// askSource strips the prose.mjs import but does not inline prose.mjs, so in the
|
|
1446
|
-
// browser `lookupByProseTokens` is an undeclared identifier — without the guard,
|
|
1447
|
-
// ANY term reaching this tier threw a ReferenceError in the page instead of
|
|
1448
|
-
// rendering the honest miss (a real, previously-untested viewer bug).
|
|
1449
|
-
// DOTTED terms never consult prose: "res.json" word-matches test/res.json.js's
|
|
1450
|
-
// own path tokens, which is the tier-3 phantom-path bug reappearing through a
|
|
1451
|
-
// side door — a dotted term names an identifier, and identifiers resolve by
|
|
1452
|
-
// label (tiers above) or the bounded fuzzy pass below, or they honestly miss.
|
|
1453
|
-
let proseResult = null;
|
|
1454
|
-
const proseHits = !dotted && typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, t) : [];
|
|
1455
|
-
if (proseHits.length) {
|
|
1456
|
-
const [best, ...rest] = proseHits;
|
|
1457
|
-
const bestInd = graph.byId.get(best.id);
|
|
1458
|
-
if (bestInd) {
|
|
1459
|
-
const tied = rest.filter((h) => h.score === best.score);
|
|
1460
|
-
proseResult = {
|
|
1461
|
-
match: bestInd,
|
|
1462
|
-
candidates: rest.slice(0, 4).map((h) => graph.byId.get(h.id)).filter(Boolean),
|
|
1463
|
-
tier: 4,
|
|
1464
|
-
ambiguous: tied.length > 0,
|
|
1465
|
-
matchedVia: "prose",
|
|
1466
|
-
};
|
|
1467
|
-
// A UNIQUE SEMANTIC prose hit stands — fuzzy is never consulted (lower
|
|
1468
|
-
// tiers fire only on a miss). Two prose outcomes yield to tier 5 instead:
|
|
1469
|
-
// (a) an AMBIGUOUS tie — a typo'd identifier ("bulidContextBundle") often
|
|
1470
|
-
// word-overlaps several symbols' prose tokens at the same score while its
|
|
1471
|
-
// 1-edit NAME match is unique; (b) a hit that only exists because the
|
|
1472
|
-
// prose SPELL layer corrected the query token (via:"spell") — the same
|
|
1473
|
-
// typo tier 5 resolves with stronger (name) evidence and announces out
|
|
1474
|
-
// loud, exactly the division of labour prose.mjs's own spell-layer
|
|
1475
|
-
// comment specifies. If tier 5 cannot resolve uniquely either, the
|
|
1476
|
-
// stashed prose result is surfaced as-is.
|
|
1477
|
-
if (!proseResult.ambiguous && best.via !== "spell") return proseResult;
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
// tier 5: bounded fuzzy (see the function doc) — every exact tier above missed
|
|
1482
|
-
// (or tier 4 tied), so a typo'd identifier gets one honest chance against labels
|
|
1483
|
-
// and their components. sha-shaped terms never reach here (guard above);
|
|
1484
|
-
// sub-4-char terms are excluded because a 1-edit budget on 3 chars matches far
|
|
1485
|
-
// too much to ever be a unique intent.
|
|
1486
|
-
if (!shaTerm && tLc.length >= 4) {
|
|
1487
|
-
const bound = fuzzyBound(tLc);
|
|
1488
|
-
let best = bound + 1;
|
|
1489
|
-
let hits = [];
|
|
1490
|
-
for (const m of pool) {
|
|
1491
|
-
let d = editDistance(String(m.label || "").toLowerCase(), tLc, bound);
|
|
1492
|
-
if (d > 0) {
|
|
1493
|
-
for (const comp of componentSet(m.label)) {
|
|
1494
|
-
if (d <= 0) break;
|
|
1495
|
-
d = Math.min(d, editDistance(comp, tLc, bound));
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
if (d < best) { best = d; hits = [m]; }
|
|
1499
|
-
else if (d === best && d <= bound) hits.push(m);
|
|
1500
|
-
}
|
|
1501
|
-
if (best <= bound && hits.length === 1) {
|
|
1502
|
-
return { match: hits[0], candidates: [], tier: 5, ambiguous: false, matchedVia: "fuzzy" };
|
|
1503
|
-
}
|
|
1504
|
-
if (best <= bound && hits.length > 1 && !proseResult) {
|
|
1505
|
-
// equidistant fuzzy tie with no prose evidence either — honest ambiguity.
|
|
1506
|
-
const [bestInd, ...rest] = hits;
|
|
1507
|
-
return { match: bestInd, candidates: rest.slice(0, 4), tier: 5, ambiguous: true, matchedVia: "fuzzy" };
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
// fuzzy couldn't resolve uniquely: surface the prose tie (when there was one)
|
|
1511
|
-
// exactly as before, else the honest miss.
|
|
1512
|
-
return proseResult || { match: null, candidates: [], tier: null, ambiguous: false };
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
/** Resolve a term that may be a context pronoun ("this"/"it"/"that"/"here") —
|
|
1516
|
-
* when `contextId` is given, resolve straight to that graph entity (a real
|
|
1517
|
-
* click/focus in the caller's UI, not a guess); with no contextId, an honest
|
|
1518
|
-
* miss explaining exactly what's missing, distinct from "no such name in the
|
|
1519
|
-
* index". A non-pronoun term always falls through to the ordinary
|
|
1520
|
-
* resolveObject tiers. */
|
|
1521
|
-
function resolveTermOrContext(graph, term, contextId) {
|
|
1522
|
-
if (CONTEXT_PRONOUNS.includes(String(term || "").trim().toLowerCase())) {
|
|
1523
|
-
if (!contextId) return { match: null, candidates: [], tier: null, ambiguous: false, unresolvedPronoun: true };
|
|
1524
|
-
const ind = graph.byId.get(contextId);
|
|
1525
|
-
return ind
|
|
1526
|
-
? { match: ind, candidates: [], tier: 1, ambiguous: false }
|
|
1527
|
-
: { match: null, candidates: [], tier: null, ambiguous: false, unresolvedPronoun: true };
|
|
1528
|
-
}
|
|
1529
|
-
return resolveObject(graph, term);
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
// ---- traversal — orchestrates codegraph.mjs's edgesOfKind; grain-refines a module-coarse
|
|
1533
|
-
// edge (e.g. imports: Module->Module) down to a finer requested entityType via `defines`
|
|
1534
|
-
// (Module -> top-level Function/Class/Method/Attribute), never re-implementing edge scans. ----
|
|
1535
|
-
|
|
1536
|
-
function refineToEntities(graph, moduleIds, entityType) {
|
|
1537
|
-
const out = [];
|
|
1538
|
-
for (const e of edgesOfKind(graph, "defines")) {
|
|
1539
|
-
if (!moduleIds.has(e.subject)) continue;
|
|
1540
|
-
const ind = graph.byId.get(e.object);
|
|
1541
|
-
if (ind && ind.class === entityType) out.push(ind);
|
|
1542
|
-
}
|
|
1543
|
-
return out;
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
/** Everything a commit touched, across BOTH stored grains — touches (Commit->Module)
|
|
1547
|
-
* and touchesSymbol (Commit->fn/method/class) — narrowed by the asked entity type:
|
|
1548
|
-
* "modules"/"files" keeps the coarse grain only, a fine type keeps its own symbol
|
|
1549
|
-
* class only, and null/"Change" ("which changes touch commit X") keeps the union.
|
|
1550
|
-
* The result carries `commitSubject` so render() cites the commit and groups the
|
|
1551
|
-
* touched entities by class instead of pretending the commit was a search target. */
|
|
1552
|
-
function commitTouches(graph, commit, entityType, extra = {}) {
|
|
1553
|
-
// "Commit" counts as wildcard here too: in "what was touched by commit X" the
|
|
1554
|
-
// keyword-spotter consumes "commit" as the entity keyword though it belongs to
|
|
1555
|
-
// the object noun phrase — and no commit ever touches another commit (no
|
|
1556
|
-
// Commit->Commit edges exist), so honoring it as a class filter could only
|
|
1557
|
-
// ever manufacture a false blank.
|
|
1558
|
-
const wildcard = !entityType || entityType === "Change" || entityType === "Commit";
|
|
1559
|
-
const wantCoarse = wildcard || entityType === "Module";
|
|
1560
|
-
const wantFine = wildcard || FINE_ENTITY_TYPES.has(entityType);
|
|
1561
|
-
const kinds = [...(wantCoarse ? ["touches"] : []), ...(wantFine ? ["touchesSymbol"] : [])];
|
|
1562
|
-
let matches = kinds
|
|
1563
|
-
.flatMap((k) => edgesOfKind(graph, k))
|
|
1564
|
-
.filter((e) => e.subject === commit.id)
|
|
1565
|
-
.map((e) => graph.byId.get(e.object))
|
|
1566
|
-
.filter(Boolean);
|
|
1567
|
-
if (entityType && FINE_ENTITY_TYPES.has(entityType)) matches = matches.filter((m) => m.class === entityType);
|
|
1568
|
-
return {
|
|
1569
|
-
matches, objMatch: commit, commitSubject: true, ambiguous: false, candidates: [],
|
|
1570
|
-
traversal: `${kinds.join("+")} edges where subject = commit ${commit.label}`,
|
|
1571
|
-
...extra,
|
|
1572
|
-
};
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
/** Safety net (paired with the render-branch fix earlier in this file's history): a
|
|
1576
|
-
* {shape, kind, entityType} combination must be explicitly listed here to receive
|
|
1577
|
-
* real non-"direct" modifier behavior. Anything parsing to a non-"direct" modifier
|
|
1578
|
-
* that ISN'T listed gets an honest "not supported yet" response from render() —
|
|
1579
|
-
* never a silent fallback to direct-only behavior. This means a future
|
|
1580
|
-
* MODIFIER_TO_KIND addition that forgets to wire traverse()/render() for it fails
|
|
1581
|
-
* loud here, by construction, rather than quietly behaving as if the modifier had
|
|
1582
|
-
* never been given (the exact bug class this file's own render-routing fix, above,
|
|
1583
|
-
* just caught). Today's only non-"direct" value is "transitive" (PLAN_MECHANICAL_
|
|
1584
|
-
* CHAT.md P1), wired below for reverse-shape imports/calls closures over
|
|
1585
|
-
* impactClosure (codegraph.mjs) — module-coarse only; the fine-grained
|
|
1586
|
-
* callsSymbol/touchesSymbol siblings and every other predicate kind have no
|
|
1587
|
-
* closure primitive yet, and forward-shape currently never parses a non-"direct"
|
|
1588
|
-
* modifier at all (both parsing strategies hardcode modifier:"direct" for it). */
|
|
1589
|
-
function modifierIsWired(shape, kind, entityType) {
|
|
1590
|
-
return shape === "reverse" && (kind === "imports" || kind === "calls") && (!entityType || entityType === "Module");
|
|
1591
|
-
}
|
|
1592
|
-
// Matches renderImpact's own default (codegraph.mjs) — impactClosure is reused as-is,
|
|
1593
|
-
// not reimplemented, so its own depth convention is the honest one to inherit.
|
|
1594
|
-
const TRANSITIVE_MAX_DEPTH = 8;
|
|
1595
|
-
|
|
1596
|
-
/** Compile a parsed query into a graph lookup. Pure given (graph, parsed, opts).
|
|
1597
|
-
* `opts.contextId` resolves a context pronoun ("this"/"it"/…) when the parse
|
|
1598
|
-
* needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
|
|
1599
|
-
* answer?, unresolvedPronoun?} — `answer` only set for the "ask" shape.
|
|
1600
|
-
* `matches` is always an array of individuals (or edge records for "ask"). */
|
|
1601
|
-
export function traverse(graph, parsed, { contextId = null, prev = null } = {}) {
|
|
1602
|
-
if (!parsed) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
|
|
1603
|
-
// compositional AST (PLAN §5.16 P3) — the new grammar's nodes carry a `node` tag;
|
|
1604
|
-
// everything else (simple clauses, ambiguousParse) flows through the original path
|
|
1605
|
-
// below completely unchanged.
|
|
1606
|
-
if (parsed.node) return evalComposite(graph, parsed, { contextId, prev });
|
|
1607
|
-
if (parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
|
|
1608
|
-
const { shape, kind, entityType } = parsed;
|
|
1609
|
-
|
|
1610
|
-
// meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
|
|
1611
|
-
// is a Commit") — looked up against the SchemaClass/SchemaPredicate individuals
|
|
1612
|
-
// schema-docs.mjs's ingestSchemaDocs merged into the graph, not a code-edge traversal.
|
|
1613
|
-
// Matched by exact (case-insensitive) label ("cochange", "Commit") OR the raw `token`
|
|
1614
|
-
// attribute a SchemaPredicate also carries ("mgx:callsSymbol") — never substring/fuzzy,
|
|
1615
|
-
// same discipline as resolveObject's own tiers: a real term match or an honest miss.
|
|
1616
|
-
if (shape === "meta") {
|
|
1617
|
-
const term = String(parsed.object || "").trim();
|
|
1618
|
-
const termLc = term.toLowerCase();
|
|
1619
|
-
const match = (graph.individuals || []).find((i) => {
|
|
1620
|
-
if (i.class !== "SchemaClass" && i.class !== "SchemaPredicate") return false;
|
|
1621
|
-
if (String(i.label).toLowerCase() === termLc) return true;
|
|
1622
|
-
const token = (i.attributes || []).find((a) => a.key === "token")?.value;
|
|
1623
|
-
return token && String(token).toLowerCase() === termLc;
|
|
1624
|
-
});
|
|
1625
|
-
if (!match) return { matches: [], objMatch: null, candidates: [], traversal: `schema lookup for "${term}"`, ambiguous: false };
|
|
1626
|
-
return {
|
|
1627
|
-
matches: [match], objMatch: match, candidates: [],
|
|
1628
|
-
traversal: `schema lookup for "${term}"`, ambiguous: false,
|
|
1629
|
-
};
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
// mentions: "where is X mentioned" (2026-07-02 query families) — the prose
|
|
1633
|
-
// surface, not an edge traversal: list the individuals whose decomposed
|
|
1634
|
-
// identifier / doc-comment tokens contain the term's words (the same index
|
|
1635
|
-
// resolveObject's tier 4 consults, surfaced directly). The term itself is NOT
|
|
1636
|
-
// resolved to an entity first — the question is about mentions of the words,
|
|
1637
|
-
// which is exactly what the prose index stores. typeof guard: same viewer-
|
|
1638
|
-
// bundle boundary as tier 4 (prose.mjs is never inlined).
|
|
1639
|
-
if (shape === "mentions") {
|
|
1640
|
-
const term = String(parsed.object || "").trim();
|
|
1641
|
-
const hits = typeof lookupByProseTokens === "function" ? lookupByProseTokens(graph.proseIndex, term) : [];
|
|
1642
|
-
const matches = hits.map((h) => graph.byId.get(h.id)).filter(Boolean);
|
|
1643
|
-
return {
|
|
1644
|
-
matches, objMatch: null, candidates: [], ambiguous: false, mentionsShape: true,
|
|
1645
|
-
traversal: `proseIndex word lookup for "${term}"`,
|
|
1646
|
-
};
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
// §modifier support gate (safety net, see modifierIsWired's own doc above) — checked
|
|
1650
|
-
// BEFORE object resolution, so an unsupported modifier+kind combination gets its own
|
|
1651
|
-
// honest capability-gap message rather than masquerading as an object-miss, or worse,
|
|
1652
|
-
// silently behaving as if "transitively"/"indirectly" had never been said.
|
|
1653
|
-
if (parsed.modifier && parsed.modifier !== "direct" && !modifierIsWired(shape, kind, entityType)) {
|
|
1654
|
-
return {
|
|
1655
|
-
matches: [], objMatch: null, candidates: [], ambiguous: false,
|
|
1656
|
-
unsupportedModifier: true,
|
|
1657
|
-
traversal: `modifier "${parsed.modifier}" requested for a "${kind}" query — no closure traversal exists for this combination yet`,
|
|
1658
|
-
};
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
if (shape === "ask") {
|
|
1662
|
-
const subj = resolveTermOrContext(graph, parsed.subject, contextId);
|
|
1663
|
-
const obj = resolveTermOrContext(graph, parsed.object, contextId);
|
|
1664
|
-
if (!subj.match || !obj.match) {
|
|
1665
|
-
return {
|
|
1666
|
-
matches: [], objMatch: obj.match, candidates: obj.candidates, traversal: null, ambiguous: false, answer: null,
|
|
1667
|
-
unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
|
|
1668
|
-
};
|
|
1669
|
-
}
|
|
1670
|
-
// touches edges are stored commit -> entity, so when the question names the
|
|
1671
|
-
// commit on the OBJECT side ("was walk.mjs touched by commit X"), orient the
|
|
1672
|
-
// edge test by where the commit actually is instead of failing on direction;
|
|
1673
|
-
// a commit subject is also checked at the symbol grain ("does commit X touch
|
|
1674
|
-
// <function>" lives on touchesSymbol, not the module-coarse kind).
|
|
1675
|
-
let [from, to] = [subj.match, obj.match];
|
|
1676
|
-
let kinds = kindsFor(kind); // "uses" checks the whole union ("does X use Y")
|
|
1677
|
-
if (kind === "touches") {
|
|
1678
|
-
if (to.class === "Commit" && from.class !== "Commit") [from, to] = [to, from];
|
|
1679
|
-
if (from.class === "Commit") kinds = ["touches", "touchesSymbol"];
|
|
1680
|
-
}
|
|
1681
|
-
const edges = kinds.flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === from.id && e.object === to.id);
|
|
1682
|
-
return {
|
|
1683
|
-
matches: edges, answer: edges.length > 0, objMatch: obj.match, subjMatch: subj.match,
|
|
1684
|
-
candidates: [], traversal: `${kinds.join("+")} edge from ${from.label} to ${to.label}`, ambiguous: false,
|
|
1685
|
-
};
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
// reverse and forward both resolve one named term ("object" in the parsed shape — for
|
|
1689
|
-
// forward it is the query's grammatical subject, e.g. "what does X import" -> parsed.object = X).
|
|
1690
|
-
const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = resolveTermOrContext(graph, parsed.object, contextId);
|
|
1691
|
-
if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
|
|
1692
|
-
|
|
1693
|
-
// where: "where is X [defined]" (2026-07-02 query families) — the resolved
|
|
1694
|
-
// entity IS the answer; render() reads its class + site attribute ("path:
|
|
1695
|
-
// start[-end]", seon:startsAt) for the module/line citation.
|
|
1696
|
-
if (shape === "where") {
|
|
1697
|
-
const site = (objMatch.attributes || []).find((a) => a.key === "site")?.value || null;
|
|
1698
|
-
return {
|
|
1699
|
-
matches: [objMatch], objMatch, candidates, ambiguous, matchedVia, whereShape: true, site,
|
|
1700
|
-
traversal: site ? `site attribute of ${objMatch.label}` : `class + defining module of ${objMatch.label}`,
|
|
1701
|
-
};
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
|
-
// when: "when did X change" / "when was X last touched" (2026-07-02 query
|
|
1705
|
-
// families) — the commits whose touch edges reach X, newest commit date first
|
|
1706
|
-
// (mgx:commitDate, ISO-8601, so a lexical sort IS the date sort; undated
|
|
1707
|
-
// commits sort last and render() says so honestly). Checked BEFORE the
|
|
1708
|
-
// commit-as-subject flip: "when did <sha> change" asks for the commit's own
|
|
1709
|
-
// date, not its touched files, so a Commit object answers with itself.
|
|
1710
|
-
if (shape === "when") {
|
|
1711
|
-
const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
|
|
1712
|
-
let commits;
|
|
1713
|
-
if (objMatch.class === "Commit") {
|
|
1714
|
-
commits = [objMatch];
|
|
1715
|
-
} else {
|
|
1716
|
-
const edges = ["touches", "touchesSymbol"].flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
|
|
1717
|
-
const seen = new Set();
|
|
1718
|
-
commits = [];
|
|
1719
|
-
for (const e of edges) {
|
|
1720
|
-
if (seen.has(e.subject)) continue;
|
|
1721
|
-
seen.add(e.subject);
|
|
1722
|
-
const c = graph.byId.get(e.subject);
|
|
1723
|
-
if (c && c.class === "Commit") commits.push(c);
|
|
1724
|
-
}
|
|
1725
|
-
commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
|
|
1726
|
-
}
|
|
1727
|
-
return {
|
|
1728
|
-
matches: commits, objMatch, candidates, ambiguous, matchedVia, whenShape: true,
|
|
1729
|
-
traversal: `touches+touchesSymbol edges where object = ${objMatch.label}, newest commit date first`,
|
|
1730
|
-
};
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
// commit-as-subject flip: touches edges are stored commit -> entity, so when the
|
|
1734
|
-
// RESOLVED term of a touches question is itself a Commit — "which changes touch
|
|
1735
|
-
// commit ef74e44e25c8" (reverse), "what did commit abc1234 touch" (forward),
|
|
1736
|
-
// "what changed in abc1234" (casual reverse) — the honest reading is "what did
|
|
1737
|
-
// that commit touch": read the edges FROM the commit, grain-selected by the asked
|
|
1738
|
-
// entity type, instead of scanning for edges INTO it (a commit is never a touch
|
|
1739
|
-
// target, so the un-flipped scan would render a misleading blank).
|
|
1740
|
-
if (kind === "touches" && objMatch.class === "Commit") {
|
|
1741
|
-
return commitTouches(graph, objMatch, entityType, { candidates, ambiguous, matchedVia });
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
if (shape === "forward") {
|
|
1745
|
-
const edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.subject === objMatch.id);
|
|
1746
|
-
const matches = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
|
|
1747
|
-
return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
// reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
|
|
1751
|
-
// "imports" or "calls" and entityType is null/"Module" here. Reuses impactClosure
|
|
1752
|
-
// (codegraph.mjs) AS-IS rather than reimplementing a closure — impactClosure's own
|
|
1753
|
-
// dependents map is a REVERSE closure over imports+calls edges TOGETHER (renderImpact's
|
|
1754
|
-
// "what would break" framing), not a strict single-predicate chain, so a query for
|
|
1755
|
-
// "transitively imports" and one for "transitively calls" both resolve to the SAME
|
|
1756
|
-
// mixed reverse-dependency closure. That's a real, deliberate scope decision (matching
|
|
1757
|
-
// the plan's own instruction to wire onto "renderImpact's existing closure traversal"
|
|
1758
|
-
// rather than build a new predicate-pure one) — the traversal receipt below says so
|
|
1759
|
-
// honestly rather than implying a narrower single-predicate result than what was
|
|
1760
|
-
// actually computed.
|
|
1761
|
-
if (parsed.modifier === "transitive") {
|
|
1762
|
-
const levels = impactClosure(graph, objMatch, { maxDepth: TRANSITIVE_MAX_DEPTH });
|
|
1763
|
-
const matches = levels.flat().map((d) => graph.byId.get(d.id)).filter(Boolean);
|
|
1764
|
-
return {
|
|
1765
|
-
matches, objMatch, candidates, ambiguous, matchedVia,
|
|
1766
|
-
traversal: `reverse dependency closure over imports+calls edges from ${objMatch.label} (impactClosure, maxDepth=${TRANSITIVE_MAX_DEPTH})`,
|
|
1767
|
-
};
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
// reverse: "which <entityType> R <objMatch>"
|
|
1771
|
-
const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
|
|
1772
|
-
if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
|
|
1773
|
-
const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
|
|
1774
|
-
const matches = edges.map((e) => graph.byId.get(e.subject)).filter((i) => i && i.class === entityType);
|
|
1775
|
-
return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}`, ambiguous, matchedVia };
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
// General case: some predicates are already fine-grained (inherits: Class->Class, contains:
|
|
1779
|
-
// Class->Member) and some are module-coarse (imports/calls/tests/cochange: Module->Module).
|
|
1780
|
-
// Rather than assume one or the other, check what the edge's actual subjects ARE: if they
|
|
1781
|
-
// already match the requested entityType, use them directly (inherits); only when they're
|
|
1782
|
-
// Module individuals and a FINER entityType was asked for do we refine via `defines`
|
|
1783
|
-
// (imports) — never blindly treat an edge's subject id as if it were always a module id.
|
|
1784
|
-
let edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
|
|
1785
|
-
let extNote = "";
|
|
1786
|
-
if (!edges.length && objMatch.class) {
|
|
1787
|
-
// Unresolved ext:<Name> endpoints with the SAME name as the resolved entity:
|
|
1788
|
-
// the extractor declined to assert identity (e.g. commander's every "class X
|
|
1789
|
-
// extends Command" edge points at ext:Command, never the Class node), so a
|
|
1790
|
-
// strict id match renders a FALSE blank. Count them by NAME instead and say
|
|
1791
|
-
// so in the receipt — name-grade evidence, labeled as such, same standard as
|
|
1792
|
-
// resolveObject's own ext: tier.
|
|
1793
|
-
const extId = `ext:${String(objMatch.label).toLowerCase()}`;
|
|
1794
|
-
edges = kindsFor(kind).flatMap((k) => edgesOfKind(graph, k)).filter((e) => String(e.object).toLowerCase() === extId);
|
|
1795
|
-
if (edges.length) extNote = ` (by name, via unresolved ${extId} references)`;
|
|
1796
|
-
}
|
|
1797
|
-
// dedupe by id: a union kind ("uses") can reach the same subject through two
|
|
1798
|
-
// legs (a module that both imports AND calls X), and one answer must list it once.
|
|
1799
|
-
const subjects = [];
|
|
1800
|
-
const seenSubjects = new Set();
|
|
1801
|
-
for (const e of edges) {
|
|
1802
|
-
const s = graph.byId.get(e.subject);
|
|
1803
|
-
if (s && !seenSubjects.has(s.id)) { seenSubjects.add(s.id); subjects.push(s); }
|
|
1804
|
-
}
|
|
1805
|
-
let matches, grainNote = "";
|
|
1806
|
-
// "Change" (ask-vocab.mjs's pseudo-type) is a wildcard here: "which changes touch
|
|
1807
|
-
// walk.mjs" means the touch edges' own subjects — the commits — not a node class
|
|
1808
|
-
// to filter by (no individual is ever class "Change", so filtering would always
|
|
1809
|
-
// produce a false blank).
|
|
1810
|
-
if (!entityType || entityType === "Change") {
|
|
1811
|
-
matches = subjects;
|
|
1812
|
-
} else {
|
|
1813
|
-
const direct = subjects.filter((s) => s.class === entityType);
|
|
1814
|
-
if (direct.length) {
|
|
1815
|
-
matches = direct;
|
|
1816
|
-
} else if (entityType !== "Module" && subjects.some((s) => s.class === "Module")) {
|
|
1817
|
-
const moduleIds = new Set(subjects.filter((s) => s.class === "Module").map((s) => s.id));
|
|
1818
|
-
matches = refineToEntities(graph, moduleIds, entityType);
|
|
1819
|
-
grainNote = `, then ${entityType} defined in the matched module(s)`;
|
|
1820
|
-
} else {
|
|
1821
|
-
matches = [];
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
return { matches, objMatch, candidates, traversal: `${kindsFor(kind).join("+")} edges where object = ${objMatch.label}${extNote}${grainNote}`, ambiguous, matchedVia };
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
|
-
// ---- §5 templated renderer — string interpolation + grouping/pluralization/overflow rules,
|
|
1828
|
-
// never generation; every sentence is read off a matched edge/individual. ----
|
|
1829
|
-
|
|
1830
|
-
function moduleLabelOf(ind) {
|
|
1831
|
-
if (ind.class === "Module") return ind.label;
|
|
1832
|
-
const site = (ind.attributes || []).find((a) => a.key === "site")?.value;
|
|
1833
|
-
if (site) return String(site).split(":")[0];
|
|
1834
|
-
const m = String(ind.id || "").match(/^fn:(.+)#/);
|
|
1835
|
-
return m ? m[1] : "(unknown module)";
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
function symbolLabelOf(ind) {
|
|
1839
|
-
const label = String(ind.label || ind.id || "");
|
|
1840
|
-
return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
function listJoin(syms) {
|
|
1844
|
-
return syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
/** One-line, honest rephrasing of a candidate parse — used to describe a
|
|
1848
|
-
* parse-level disagreement between strategies without pretending to pick
|
|
1849
|
-
* a winner. Template only, reads straight off the parsed fields. */
|
|
1850
|
-
function describeParse(p) {
|
|
1851
|
-
const obj = p.object ?? p.subject ?? "?";
|
|
1852
|
-
const ent = p.entityType ? nounFor(p.entityType, 2) + " that " : "";
|
|
1853
|
-
return `${ent}${p.kind} "${obj}"`;
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
|
-
/** Render a compiled query result into {content, miss, ambiguous, matches?, candidates?}.
|
|
1857
|
-
* Every branch is a template, not generation — §5's grouping/pluralization/overflow rules.
|
|
1858
|
-
* A tier-5 fuzzy object resolution is ANNOUNCED, not silent: the answer is prefixed
|
|
1859
|
-
* "assuming you meant <label>:" so the correction is on the record next to the result
|
|
1860
|
-
* (an unannounced fuzzy hit would be indistinguishable from an exact one — a guess). */
|
|
1861
|
-
export function render(parsed, result) {
|
|
1862
|
-
const r = renderCore(parsed, result);
|
|
1863
|
-
if (result && result.matchedVia === "fuzzy" && result.objMatch && !r.ambiguous) {
|
|
1864
|
-
r.content = `assuming you meant ${result.objMatch.label}: ${r.content}`;
|
|
1865
|
-
}
|
|
1866
|
-
return r;
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
function renderCore(parsed, result) {
|
|
1870
|
-
if (!parsed) {
|
|
1871
|
-
return { content: `couldn't parse this as a graph question. Try: ${rephraseHint()}`, miss: true, ambiguous: false };
|
|
1872
|
-
}
|
|
1873
|
-
if (parsed.node) return renderComposite(parsed, result);
|
|
1874
|
-
if (parsed.ambiguousParse) {
|
|
1875
|
-
const options = parsed.candidates.map((p, i) => `${i + 1}) ${describeParse(p)}`).join(" or ");
|
|
1876
|
-
return {
|
|
1877
|
-
content: `this could mean more than one thing: ${options} — try rephrasing more specifically.`,
|
|
1878
|
-
miss: false, ambiguous: true, candidates: parsed.candidates.map(describeParse),
|
|
1879
|
-
};
|
|
1880
|
-
}
|
|
1881
|
-
if (result.unresolvedPronoun) {
|
|
1882
|
-
return {
|
|
1883
|
-
content: `"${parsed.object ?? parsed.subject}" needs a selected node to refer to — click a node first, or name it directly.`,
|
|
1884
|
-
miss: true, ambiguous: false,
|
|
1885
|
-
};
|
|
1886
|
-
}
|
|
1887
|
-
if (result.unsupportedModifier) {
|
|
1888
|
-
return {
|
|
1889
|
-
content: `the "${parsed.modifier}" modifier isn't supported for "${parsed.kind}" queries yet — only imports/calls (module-level) have a transitive closure today.`,
|
|
1890
|
-
miss: true, ambiguous: false,
|
|
1891
|
-
};
|
|
1892
|
-
}
|
|
1893
|
-
if (parsed.shape === "meta") {
|
|
1894
|
-
if (!result.objMatch) {
|
|
1895
|
-
return {
|
|
1896
|
-
content: `"${parsed.object}" isn't a term in this graph's own vocabulary (no matching class or predicate).`,
|
|
1897
|
-
miss: true, ambiguous: false,
|
|
1898
|
-
};
|
|
1899
|
-
}
|
|
1900
|
-
const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
|
|
1901
|
-
const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
|
|
1902
|
-
return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
|
|
1903
|
-
}
|
|
1904
|
-
// mentions: the prose surface — checked before the generic objMatch-null miss
|
|
1905
|
-
// below, because a mentions result deliberately carries no resolved object
|
|
1906
|
-
// (the question is about the term's words, not a graph entity).
|
|
1907
|
-
if (result.mentionsShape) {
|
|
1908
|
-
if (!result.matches.length) {
|
|
1909
|
-
return {
|
|
1910
|
-
content: `"${parsed.object}" is not mentioned in any indexed identifier or doc-comment prose. (traversal: ${result.traversal})`,
|
|
1911
|
-
miss: true, ambiguous: false,
|
|
1912
|
-
};
|
|
1913
|
-
}
|
|
1914
|
-
const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => `${m.label} (${nounFor(m.class, 1)})`);
|
|
1915
|
-
const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
|
|
1916
|
-
return {
|
|
1917
|
-
content: `"${parsed.object}" is mentioned in the prose tokens of ${listJoin(shown)}${extra}.`,
|
|
1918
|
-
miss: false, ambiguous: false, matches: result.matches,
|
|
1919
|
-
};
|
|
1920
|
-
}
|
|
1921
|
-
if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
|
|
1922
|
-
// name what kind of thing was looked for: a sha-shaped term was checked against
|
|
1923
|
-
// the commit namespace, a dotted slash-free term against symbol labels — a
|
|
1924
|
-
// generic "no module matching" would misreport both.
|
|
1925
|
-
const objText = String(parsed.object || "").trim();
|
|
1926
|
-
const what = /^(?:commit[:\s])?[0-9a-f]{7,40}$/i.test(objText) ? "commit"
|
|
1927
|
-
: (!objText.includes("/") && /^[\w$]+(\.[\w$]+)+$/.test(objText) ? "symbol" : "module");
|
|
1928
|
-
return {
|
|
1929
|
-
content: `no ${what} matching "${parsed.object}" found in the index.`,
|
|
1930
|
-
miss: true, ambiguous: false, candidates: [],
|
|
1931
|
-
};
|
|
1932
|
-
}
|
|
1933
|
-
if (result.ambiguous) {
|
|
1934
|
-
// the candidates say what KIND of thing is ambiguous — a shared commit-sha
|
|
1935
|
-
// prefix must read "more than one commit", not "module".
|
|
1936
|
-
const pool = [result.objMatch, ...(result.candidates || [])].filter(Boolean);
|
|
1937
|
-
const noun = pool.length && pool.every((i) => i.class === "Commit") ? "commit" : "module";
|
|
1938
|
-
return {
|
|
1939
|
-
content: `"${parsed.object}" matches more than one ${noun} ambiguously — please narrow the term.`,
|
|
1940
|
-
miss: false, ambiguous: true, candidates: pool.map((i) => i.label),
|
|
1941
|
-
};
|
|
1942
|
-
}
|
|
1943
|
-
// where: the resolved entity's own location, cited off the site attribute.
|
|
1944
|
-
if (result.whereShape) {
|
|
1945
|
-
const ind = result.objMatch;
|
|
1946
|
-
if (ind.class === "Module") {
|
|
1947
|
-
return { content: `${ind.label} is a module — the label is its repo path.`, miss: false, ambiguous: false, matches: result.matches };
|
|
1948
|
-
}
|
|
1949
|
-
if (ind.class === "Commit") {
|
|
1950
|
-
return { content: `${ind.label} is a commit, not a code location — try "what did commit ${ind.label} touch".`, miss: true, ambiguous: false };
|
|
1951
|
-
}
|
|
1952
|
-
const m = String(result.site || "").match(/^(.*):(\d+)(?:-(\d+))?$/);
|
|
1953
|
-
if (m) {
|
|
1954
|
-
const lines = m[3] && m[3] !== m[2] ? `lines ${m[2]}-${m[3]}` : `line ${m[2]}`;
|
|
1955
|
-
return { content: `${symbolLabelOf(ind)} is defined in ${m[1]} at ${lines}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
1956
|
-
}
|
|
1957
|
-
return {
|
|
1958
|
-
content: `${symbolLabelOf(ind)} is defined in ${moduleLabelOf(ind)} (no line span recorded in this index).`,
|
|
1959
|
-
miss: false, ambiguous: false, matches: result.matches,
|
|
1960
|
-
};
|
|
1961
|
-
}
|
|
1962
|
-
// when: newest touching commit + its date; undated commits are said out loud
|
|
1963
|
-
// (honest miss with the precise re-index hint), never silently skipped.
|
|
1964
|
-
if (result.whenShape) {
|
|
1965
|
-
const subject = result.objMatch.label;
|
|
1966
|
-
if (!result.matches.length) {
|
|
1967
|
-
return { content: `no recorded commit touches ${subject} in this index. (traversal: ${result.traversal})`, miss: true, ambiguous: false };
|
|
1968
|
-
}
|
|
1969
|
-
const newest = result.matches[0];
|
|
1970
|
-
const date = (newest.attributes || []).find((a) => a.key === "date")?.value || "";
|
|
1971
|
-
if (!date) {
|
|
1972
|
-
return {
|
|
1973
|
-
content: `commit ${newest.label} touched ${subject}, but this index records no commit dates — re-index with a current seonix to attach mgx:commitDate.`,
|
|
1974
|
-
miss: true, ambiguous: false,
|
|
1975
|
-
};
|
|
1976
|
-
}
|
|
1977
|
-
const msg = (newest.attributes || []).find((a) => a.key === "message")?.value || "";
|
|
1978
|
-
const day = String(date).slice(0, 10);
|
|
1979
|
-
if (newest.id === result.objMatch.id) {
|
|
1980
|
-
return { content: `commit ${newest.label} is dated ${day}${msg ? ` ("${msg}")` : ""}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
1981
|
-
}
|
|
1982
|
-
const more = result.matches.length - 1;
|
|
1983
|
-
return {
|
|
1984
|
-
content: `${subject} was last touched by commit ${newest.label} on ${day}${msg ? ` ("${msg}")` : ""}${more ? `; ${more} earlier commit${more === 1 ? "" : "s"} recorded` : ""}.`,
|
|
1985
|
-
miss: false, ambiguous: false, matches: result.matches,
|
|
1986
|
-
};
|
|
1987
|
-
}
|
|
1988
|
-
// commit-as-subject answers ("which changes touch commit X", "what did commit X
|
|
1989
|
-
// touch"): cite the commit, group the touched entities by CLASS — modules and
|
|
1990
|
-
// symbols are different grains of the same answer, and flattening them into one
|
|
1991
|
-
// undifferentiated list would hide which is which. Same OVERFLOW_CAP as the
|
|
1992
|
-
// other list templates; zero hits is the standard honest blank, commit cited.
|
|
1993
|
-
if (result.commitSubject) {
|
|
1994
|
-
const cite = `commit ${result.objMatch.label}`;
|
|
1995
|
-
if (!result.matches.length) {
|
|
1996
|
-
return {
|
|
1997
|
-
content: `${cite} touched nothing recorded in the index. (traversal: ${result.traversal})`,
|
|
1998
|
-
miss: true, ambiguous: false,
|
|
1999
|
-
};
|
|
2000
|
-
}
|
|
2001
|
-
const byClass = new Map();
|
|
2002
|
-
for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
|
|
2003
|
-
const cls = m.class || "Module";
|
|
2004
|
-
if (!byClass.has(cls)) byClass.set(cls, []);
|
|
2005
|
-
byClass.get(cls).push(["Function", "Method"].includes(cls) ? `${m.label}()` : m.label);
|
|
2006
|
-
}
|
|
2007
|
-
const clauses = [...byClass.entries()].map(([cls, labels]) => `${nounFor(cls, labels.length)} ${listJoin(labels)}`);
|
|
2008
|
-
const extra = result.matches.length > OVERFLOW_CAP ? `; …and ${result.matches.length - OVERFLOW_CAP} more` : "";
|
|
2009
|
-
return { content: `${cite} touched ${clauses.join("; ")}${extra}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
2010
|
-
}
|
|
2011
|
-
if (parsed.shape === "ask") {
|
|
2012
|
-
if (!result.objMatch || !result.subjMatch) {
|
|
2013
|
-
return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
|
|
2014
|
-
}
|
|
2015
|
-
return {
|
|
2016
|
-
content: result.answer ? `Yes. (${result.traversal})` : `No — no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
|
|
2017
|
-
miss: !result.answer, ambiguous: false,
|
|
2018
|
-
};
|
|
2019
|
-
}
|
|
2020
|
-
if (!result.matches.length) {
|
|
2021
|
-
// forward: parsed.object is the GIVEN subject ("what does X import" -> X), not a
|
|
2022
|
-
// search target — "No modules found that X." reads as broken grammar (and X's own
|
|
2023
|
-
// relation edges are simply absent, not "not found"), so this shape gets its own,
|
|
2024
|
-
// subject-first phrasing rather than reusing reverse's "found ... that OBJECT" template.
|
|
2025
|
-
if (parsed.shape === "forward") {
|
|
2026
|
-
return {
|
|
2027
|
-
content: `${result.objMatch.label} has no ${parsed.kind} edges in the index. (traversal: ${result.traversal || "no traversal resolved"})`,
|
|
2028
|
-
miss: true, ambiguous: false,
|
|
2029
|
-
};
|
|
2030
|
-
}
|
|
2031
|
-
const entityWord = nounFor(parsed.entityType || "Module", 2);
|
|
2032
|
-
return {
|
|
2033
|
-
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. (traversal: ${result.traversal || "no traversal resolved"})`,
|
|
2034
|
-
miss: true, ambiguous: false,
|
|
2035
|
-
};
|
|
2036
|
-
}
|
|
2037
|
-
// Route by the MATCHED entities' actual class, not just the parsed hint — a reverse
|
|
2038
|
-
// query phrased without an explicit entity keyword ("what imports X", entityType null)
|
|
2039
|
-
// still resolves to Module individuals for a module-level relation like "imports", and
|
|
2040
|
-
// grouping those by-module (module label as its own "symbol" label) reads as nonsense
|
|
2041
|
-
// ("in a.mjs there is a.mjs"). The fine-grained per-symbol grouping below is only
|
|
2042
|
-
// meaningful when the matches are sub-module entities (functions/classes/etc) — a
|
|
2043
|
-
// Commit list ("which commits touched X") has no containing module to group by, so
|
|
2044
|
-
// anything that is not a fine entity takes the flat join.
|
|
2045
|
-
if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => !FINE_ENTITY_TYPES.has(m.class))) {
|
|
2046
|
-
const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.label);
|
|
2047
|
-
const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
|
|
2048
|
-
return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
|
|
2049
|
-
}
|
|
2050
|
-
// reverse, fine-grained entity: group by module, one clause per module (§5 grouping rule) —
|
|
2051
|
-
// the FIRST module states "in {module} there is …"; each SUBSEQUENT module states
|
|
2052
|
-
// "there is … in {module}" (module trails, not leads), matching the plan's worked example.
|
|
2053
|
-
const byModule = new Map();
|
|
2054
|
-
for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
|
|
2055
|
-
const mod = moduleLabelOf(m);
|
|
2056
|
-
if (!byModule.has(mod)) byModule.set(mod, []);
|
|
2057
|
-
byModule.get(mod).push(symbolLabelOf(m));
|
|
2058
|
-
}
|
|
2059
|
-
const clauses = [...byModule.entries()].map(([mod, syms], i) => {
|
|
2060
|
-
const list = listJoin(syms);
|
|
2061
|
-
return i === 0 ? `in ${mod} there is ${list}` : `there is ${list} in ${mod}`;
|
|
2062
|
-
});
|
|
2063
|
-
const extra = result.matches.length > OVERFLOW_CAP ? ` …and ${result.matches.length - OVERFLOW_CAP} more` : "";
|
|
2064
|
-
return { content: clauses.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
// ============================================================================
|
|
2068
|
-
// §progressive-relaxation cascade (SHRDLU in a code graph, with a Zork parser's
|
|
2069
|
-
// forgiveness) — a controlled loop that wraps the WHOLE existing parse and runs
|
|
2070
|
-
// ONLY when the direct parse of the normalized query would MISS. A clean direct hit
|
|
2071
|
-
// never enters the cascade (it stays instant and exact); the cascade only ever DROPS
|
|
2072
|
-
// noise/unmatched words or NORMALISES a near-canonical word to the closed vocabulary,
|
|
2073
|
-
// re-attempting the full parse (compositional + templates + keyword-spot) after each
|
|
2074
|
-
// transform, and bottoms out in the SAME honest miss + rephrase hint the engine
|
|
2075
|
-
// already returned — never inventing a term or guessing an entity. Deterministic:
|
|
2076
|
-
// same input → same cascade path. All of it is plain JS over the already-imported
|
|
2077
|
-
// tables + resolveObject/parseQuery, so it survives the viewer bundle's import strip.
|
|
2078
|
-
// ============================================================================
|
|
2079
|
-
|
|
2080
|
-
const wordsOf = (arr) => arr.flatMap((p) => String(p).toLowerCase().split(" "));
|
|
2081
|
-
|
|
2082
|
-
/** Every token the CLOSED grammar gives QUERY MEANING to — relation verbs, entity
|
|
2083
|
-
* nouns, modifiers, qualifiers, aggregate/superlative triggers, edge-degree nouns,
|
|
2084
|
-
* boolean connectives, placeholder nouns, anaphora/meta/where/mention markers,
|
|
2085
|
-
* relative pronouns, and the small synonym keys. The noise-strip pass will NEVER
|
|
2086
|
-
* remove one of these, and the drop-unmatched pass always keeps them: they carry the
|
|
2087
|
-
* intent, only the packaging around them is negotiable. */
|
|
2088
|
-
const CONTENT_VOCAB = new Set([
|
|
2089
|
-
...wordsOf(Object.keys(VERB_TO_KIND)), ...wordsOf(Object.keys(ENTITY_TO_TYPE)),
|
|
2090
|
-
...wordsOf(Object.keys(MODIFIER_TO_KIND)), ...wordsOf(Object.keys(QUALIFIERS)),
|
|
2091
|
-
...wordsOf(AGGREGATE_TRIGGERS), ...wordsOf(Object.keys(SUPERLATIVE_EXTREMES)),
|
|
2092
|
-
...wordsOf(Object.keys(EDGE_NOUN_TO_METRIC)), ...wordsOf(Object.keys(BOOLEAN_CONNECTIVES)),
|
|
2093
|
-
...wordsOf(PLACEHOLDER_NOUNS), ...wordsOf(ANAPHORA_TRIGGERS), ...wordsOf(META_MEANING_VERBS),
|
|
2094
|
-
...wordsOf(WHERE_MARKERS), ...wordsOf(MENTION_MARKERS), ...wordsOf(RELATIVE_PRONOUNS),
|
|
2095
|
-
...wordsOf(Object.keys(CASCADE_SYNONYMS)),
|
|
2096
|
-
]);
|
|
2097
|
-
|
|
2098
|
-
/** Structural scaffolding words — question words, articles-in-questions, frame verbs,
|
|
2099
|
-
* and context pronouns. Not "content", but they hold a sentence together, so the
|
|
2100
|
-
* drop-unmatched pass keeps them (dropping "what"/"of" would corrupt the grammar);
|
|
2101
|
-
* the noise-strip pass may still remove the few of these that are ALSO curated noise
|
|
2102
|
-
* ("the"/"a"/"show"/"me") — the two sets overlap on purpose. */
|
|
2103
|
-
const STRUCTURAL_WORDS = new Set([...STOPWORDS, ...FRAME_WORDS, ...CONTEXT_PRONOUNS]);
|
|
2104
|
-
const CASCADE_NOISE_SET = new Set(wordsOf(CASCADE_NOISE));
|
|
2105
|
-
/** Every token that carries NO graph meaning of its own — curated noise (articles,
|
|
2106
|
-
* politeness, vocatives, presentation frames) PLUS the structural scaffolding
|
|
2107
|
-
* (question words, context pronouns). The bare-kind-noun terminal rule (relaxParse's
|
|
2108
|
-
* Layer 4) treats a query as "just a kind noun wrapped in packaging" only when every
|
|
2109
|
-
* non-kind token is one of these — so an unknown qualifier ("shiny") or a relation
|
|
2110
|
-
* verb, being neither, still blocks the default and preserves the honest miss. */
|
|
2111
|
-
const NOISE_OR_SCAFFOLD = new Set([...CASCADE_NOISE_SET, ...STRUCTURAL_WORDS]);
|
|
2112
|
-
|
|
2113
|
-
/** The aggregate/list TRIGGER words the cascade's drop-unmatched pass will fuzzy-correct
|
|
2114
|
-
* a typo toward (Gap 2, trigger-typo work). Curated (not derived from LIST_TRIGGERS'
|
|
2115
|
-
* multi-word phrases) so the target set stays clean single verbs — "many", "count",
|
|
2116
|
-
* "list", "show", … — and never drags in a stray "down"/"off"/"out" from a phrasal
|
|
2117
|
-
* trigger that would mis-correct an unrelated token. */
|
|
2118
|
-
const TRIGGER_FUZZY_WORDS = [
|
|
2119
|
-
"many", "count", "number", "quantity", "total", "tally",
|
|
2120
|
-
"list", "show", "display", "print", "dump", "enumerate", "name",
|
|
2121
|
-
];
|
|
2122
|
-
/** Closed-vocab words a plain unknown may be fuzzy-corrected TOWARD before the cascade
|
|
2123
|
-
* discards it: relation verbs, entity kind nouns, and the aggregate/list triggers. A
|
|
2124
|
-
* correction fires only on a token already bound for the drop pile (grammar doesn't own
|
|
2125
|
-
* it, no entity resolves) and only for a UNIQUE within-bound target, so it strictly
|
|
2126
|
-
* beats dropping — a typo of a trigger keeps its intent instead of being lost. Excludes
|
|
2127
|
-
* STOPWORDS/structural words (a random unknown must never bend into "what"/"the") and
|
|
2128
|
-
* <4-char words (at the small bound they match half of English). */
|
|
2129
|
-
const CASCADE_FUZZY_TARGETS = [...new Set([
|
|
2130
|
-
...wordsOf(Object.keys(VERB_TO_KIND)),
|
|
2131
|
-
...Object.keys(ENTITY_TO_TYPE),
|
|
2132
|
-
...TRIGGER_FUZZY_WORDS,
|
|
2133
|
-
])].filter((wd) => /^[a-z]+$/.test(wd) && wd.length >= 4 && !STOPWORDS.has(wd));
|
|
2134
|
-
|
|
2135
|
-
/** UNIQUE within-bound fuzzy correction of `w` toward CASCADE_FUZZY_TARGETS, or null —
|
|
2136
|
-
* a distance tie between two distinct targets is refused (honest-miss discipline at the
|
|
2137
|
-
* vocabulary level, cf. fuzzyVocabWord). */
|
|
2138
|
-
function fuzzyCascadeWord(w) {
|
|
2139
|
-
const bound = fuzzyBound(w);
|
|
2140
|
-
let best = bound + 1; let hit = null; let tied = false;
|
|
2141
|
-
for (const target of CASCADE_FUZZY_TARGETS) {
|
|
2142
|
-
const d = editDistance(w, target, Math.min(best, bound));
|
|
2143
|
-
if (d < best) { best = d; hit = target; tied = false; }
|
|
2144
|
-
else if (d === best && d <= bound && target !== hit) tied = true;
|
|
2145
|
-
}
|
|
2146
|
-
return best <= bound && !tied ? hit : null;
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
/** Is `parsed` a genuinely ANSWERABLE query — one that both parsed AND (for the simple
|
|
2150
|
-
* clauses) resolves its named term(s) to a graph entity? A composite non-miss node,
|
|
2151
|
-
* an ambiguous parse, and a meta/mentions surface all count; an unresolved-context
|
|
2152
|
-
* pronoun is its OWN specific honest miss (kept, not relaxed). Returns:
|
|
2153
|
-
* true — a real, executable answer (even if it later renders an empty set / "No")
|
|
2154
|
-
* "ambiguous"/"pronoun" — a specific outcome to keep, distinct from relaxable
|
|
2155
|
-
* false — no parse at all, a compositional {node:"miss"}, or an unresolved term
|
|
2156
|
-
* ask() starts the cascade ONLY on `false`, and accepts a relaxed attempt ONLY on the
|
|
2157
|
-
* strict `true` (so the cascade can never "rescue" a query into another kind of miss). */
|
|
2158
|
-
function answerable(graph, parsed, contextId) {
|
|
2159
|
-
if (!parsed) return false;
|
|
2160
|
-
if (parsed.ambiguousParse) return "ambiguous";
|
|
2161
|
-
if (parsed.node) return parsed.node !== "miss";
|
|
2162
|
-
if (parsed.shape === "meta" || parsed.shape === "mentions") return true;
|
|
2163
|
-
const o = resolveTermOrContext(graph, parsed.object, contextId);
|
|
2164
|
-
if (o.unresolvedPronoun) return "pronoun";
|
|
2165
|
-
if (!o.match) return false;
|
|
2166
|
-
if (parsed.shape === "ask") {
|
|
2167
|
-
const s = resolveTermOrContext(graph, parsed.subject, contextId);
|
|
2168
|
-
if (s.unresolvedPronoun) return "pronoun";
|
|
2169
|
-
return s.match ? true : false;
|
|
2170
|
-
}
|
|
2171
|
-
return true;
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
/** Whole-query help/orientation request → show the hint directly (never the relaxation
|
|
2175
|
-
* loop, never a pretend answer). Matches only when the ENTIRE normalized query is a
|
|
2176
|
-
* curated HELP_TRIGGER, so a symbol named "help" in a real question is untouched. */
|
|
2177
|
-
function isHelpRequest(query) {
|
|
2178
|
-
const q = String(query || "").trim().toLowerCase().replace(/[?.!\s]+$/, "");
|
|
2179
|
-
return HELP_TRIGGERS.includes(q);
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
/** The relaxation cascade. Given a query whose direct parse MISSED, walk three
|
|
2183
|
-
* increasingly-permissive layers, re-attempting the full parse after each transform,
|
|
2184
|
-
* and return the FIRST attempt that yields an answerable parse (with a trace of what
|
|
2185
|
-
* it did) — or null if none does (caller then falls back to the original honest miss):
|
|
2186
|
-
* 1. NOISE-STRIP — remove one curated noise token (leftmost) at a time, never one
|
|
2187
|
-
* that is content vocab or resolves to an entity, re-parsing each
|
|
2188
|
-
* time, until a parse answers or no noise tokens remain.
|
|
2189
|
-
* 2. DROP-UNMATCHED — drop the plain-lowercase words that are NEITHER grammar NOR a
|
|
2190
|
-
* resolvable entity (an unknown "frobnicate"); identifier-shaped
|
|
2191
|
-
* tokens (dotted files, shas, CamelCase) are never dropped —
|
|
2192
|
-
* they are content that may honestly fail to resolve.
|
|
2193
|
-
* 3. SYNONYM — rewrite surviving near-canonical words to the closed vocab.
|
|
2194
|
-
* Bounded (one token removed per noise iteration; hard guard) and deterministic. */
|
|
2195
|
-
export function relaxParse(graph, query, { nlp = undefined, contextId = null, prev = null } = {}) {
|
|
2196
|
-
const from = applyNegationFrames(normalizeQuery(String(query || "")));
|
|
2197
|
-
let tokens = splitWords(from);
|
|
2198
|
-
if (!tokens.length) return null;
|
|
2199
|
-
const dropped = [];
|
|
2200
|
-
const steps = [];
|
|
2201
|
-
|
|
2202
|
-
// Two literal-resolution guards (never the fuzzy/prose tiers, whose loose near-
|
|
2203
|
-
// matches would let a noise word masquerade as an entity):
|
|
2204
|
-
// · resolvesExact — EXACT label/id or ext: match only (tier ≤ 2). Used to protect a
|
|
2205
|
-
// curated NOISE word from being stripped: only a token that literally NAMES an
|
|
2206
|
-
// entity ("a module called `the`") is safe-listed. A mere substring coincidence
|
|
2207
|
-
// ("me" ⊂ "Method", tier 3) must NOT block stripping a genuine filler word.
|
|
2208
|
-
// · resolvesLiteral — exact/ext/substring/component (tier ≤ 3). Used to KEEP an
|
|
2209
|
-
// identifier-shaped content token ("logging" ⊂ "src/logging.mjs") through the
|
|
2210
|
-
// drop-unmatched pass, and to hold a synonym rewrite off a real entity name.
|
|
2211
|
-
const resolvesExact = (t) => {
|
|
2212
|
-
const r = resolveObject(graph, t);
|
|
2213
|
-
return !!r.match && r.tier != null && r.tier <= 2;
|
|
2214
|
-
};
|
|
2215
|
-
const resolvesLiteral = (t) => {
|
|
2216
|
-
const r = resolveObject(graph, t);
|
|
2217
|
-
return !!r.match && r.tier != null && r.tier <= 3;
|
|
2218
|
-
};
|
|
2219
|
-
// Does a term string carry at least one REAL word — one the grammar doesn't already
|
|
2220
|
-
// own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
|
|
2221
|
-
// asked term and lets a bare marker slide into its place ("where is [X] defined" →
|
|
2222
|
-
// "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
|
|
2223
|
-
const hasRealTerm = (s) => splitWords(String(s || "")).some((w) => {
|
|
2224
|
-
const lc = w.toLowerCase();
|
|
2225
|
-
return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
|
|
2226
|
-
});
|
|
2227
|
-
// Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
|
|
2228
|
-
// AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
|
|
2229
|
-
// win only by turning a miss into an answer, never a differently-worded miss) — and
|
|
2230
|
-
// never by promoting a bare marker to the asked term.
|
|
2231
|
-
const TERM_SHAPES = new Set(["reverse", "forward", "where", "when", "ask"]);
|
|
2232
|
-
const attempt = (toks) => {
|
|
2233
|
-
const text = toks.join(" ");
|
|
2234
|
-
const p = parseQuery(text, { nlp });
|
|
2235
|
-
if (answerable(graph, p, contextId) !== true) return null;
|
|
2236
|
-
if (p && !p.node && TERM_SHAPES.has(p.shape)) {
|
|
2237
|
-
if (p.object != null && !hasRealTerm(p.object)) return null;
|
|
2238
|
-
if (p.shape === "ask" && p.subject != null && !hasRealTerm(p.subject)) return null;
|
|
2239
|
-
}
|
|
2240
|
-
const rendered = render(p, traverse(graph, p, { contextId, prev }));
|
|
2241
|
-
return rendered.miss ? null : { parsed: p, text };
|
|
2242
|
-
};
|
|
2243
|
-
const done = (hit) => ({ parsed: hit.parsed, from, to: hit.text, dropped: [...dropped], steps });
|
|
2244
|
-
|
|
2245
|
-
// Layer 1 — NOISE-STRIP (one lowest-value token at a time)
|
|
2246
|
-
let guard = 0;
|
|
2247
|
-
const hardCap = Math.max(tokens.length, 1) + 12;
|
|
2248
|
-
for (; guard < hardCap; guard += 1) {
|
|
2249
|
-
let idx = -1;
|
|
2250
|
-
for (let i = 0; i < tokens.length; i += 1) {
|
|
2251
|
-
const lc = tokens[i].toLowerCase();
|
|
2252
|
-
if (CASCADE_NOISE_SET.has(lc) && !CONTENT_VOCAB.has(lc) && !resolvesExact(tokens[i])) { idx = i; break; }
|
|
2253
|
-
}
|
|
2254
|
-
if (idx < 0) break;
|
|
2255
|
-
const removed = tokens[idx];
|
|
2256
|
-
tokens = tokens.filter((_, i) => i !== idx);
|
|
2257
|
-
dropped.push(removed);
|
|
2258
|
-
steps.push(`strip noise "${removed}" → "${tokens.join(" ")}"`);
|
|
2259
|
-
const hit = attempt(tokens);
|
|
2260
|
-
if (hit) return done(hit);
|
|
2261
|
-
}
|
|
2262
|
-
|
|
2263
|
-
// Layer 2 — DROP-UNMATCHED (plain-lowercase unknowns beside the real terms)
|
|
2264
|
-
const survivors = [];
|
|
2265
|
-
const nowDropped = [];
|
|
2266
|
-
const corrected = [];
|
|
2267
|
-
for (const t of tokens) {
|
|
2268
|
-
const lc = t.toLowerCase();
|
|
2269
|
-
const plain = /^[a-z]+$/.test(lc);
|
|
2270
|
-
if (!plain || CONTENT_VOCAB.has(lc) || STRUCTURAL_WORDS.has(lc) || resolvesLiteral(t)) {
|
|
2271
|
-
survivors.push(t);
|
|
2272
|
-
continue;
|
|
2273
|
-
}
|
|
2274
|
-
// Gap 2 — before dropping an unmatched plain token, try a bounded fuzzy-correct to a
|
|
2275
|
-
// UNIQUE closed-vocab word (verbs, entity kinds, aggregate/list triggers): a typo of
|
|
2276
|
-
// a TRIGGER ("manyn"→"many", "coutn"→"count", "liist"→"list") is restored, not
|
|
2277
|
-
// discarded, so the count/list intent survives. Only a unique within-bound hit; else
|
|
2278
|
-
// the token is genuinely unrecoverable and drops exactly as before.
|
|
2279
|
-
const fix = fuzzyCascadeWord(lc);
|
|
2280
|
-
if (fix && fix !== lc) { survivors.push(fix); corrected.push(`${t}→${fix}`); continue; }
|
|
2281
|
-
nowDropped.push(t);
|
|
2282
|
-
}
|
|
2283
|
-
if ((corrected.length || nowDropped.length) && survivors.length) {
|
|
2284
|
-
tokens = survivors;
|
|
2285
|
-
dropped.push(...nowDropped);
|
|
2286
|
-
if (corrected.length) steps.push(`fuzzy-correct ${JSON.stringify(corrected)} → "${tokens.join(" ")}"`);
|
|
2287
|
-
if (nowDropped.length) steps.push(`drop unmatched ${JSON.stringify(nowDropped)} → "${tokens.join(" ")}"`);
|
|
2288
|
-
const hit = attempt(tokens);
|
|
2289
|
-
if (hit) return done(hit);
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
// Layer 3 — SYNONYM-NORMALISE the survivors onto the canonical vocabulary
|
|
2293
|
-
let changed = false;
|
|
2294
|
-
const normed = tokens.map((t) => {
|
|
2295
|
-
const lc = t.toLowerCase();
|
|
2296
|
-
if (CASCADE_SYNONYMS[lc] && !resolvesLiteral(t)) { changed = true; return CASCADE_SYNONYMS[lc]; }
|
|
2297
|
-
return t;
|
|
2298
|
-
});
|
|
2299
|
-
if (changed) {
|
|
2300
|
-
steps.push(`normalise synonyms → "${normed.join(" ")}"`);
|
|
2301
|
-
const hit = attempt(normed);
|
|
2302
|
-
if (hit) return done(hit);
|
|
2303
|
-
}
|
|
2304
|
-
|
|
2305
|
-
// Layer 4 (terminal) — BARE KIND NOUN → a bounded DEFAULT ACTION. When noise-strip,
|
|
2306
|
-
// drop-unmatched and synonym-normalise have all failed to yield an answerable parse,
|
|
2307
|
-
// give the operator's "vague enough to land" case a sensible answer instead of an
|
|
2308
|
-
// honest miss: a query that is ONLY a kind noun (class/classes, function/functions,
|
|
2309
|
-
// module, method, attribute, variable, commit, …) wrapped in articles/noise/question
|
|
2310
|
-
// words DEFAULTS TO A COUNT of that kind ("the classes" / "classes" / "tell me the
|
|
2311
|
-
// classes" → "20 classes."). Count, not list: a bare unscoped list of 647 functions is
|
|
2312
|
-
// noise, whereas the count is the cheap useful answer the asker can then drill into
|
|
2313
|
-
// ("list them"). Deterministic — count for every kind, no cardinality cap.
|
|
2314
|
-
//
|
|
2315
|
-
// We classify the ORIGINAL normalized tokens (`from`), NOT the layer-mutated `tokens`:
|
|
2316
|
-
// drop-unmatched has by now EATEN any unknown qualifier, so "the shiny classes" would
|
|
2317
|
-
// otherwise look identical to a bare "classes". Reading the whole phrase keeps the
|
|
2318
|
-
// discipline exact — the rule fires ONLY when every non-kind token is pure packaging
|
|
2319
|
-
// (NOISE_OR_SCAFFOLD). A dangling unknown qualifier ("the shiny classes"), a relation
|
|
2320
|
-
// verb, a marker, or a real term is neither noise nor a kind noun, so it lands in
|
|
2321
|
-
// `others`, blocks the default, and the honest miss (or the real compositional query,
|
|
2322
|
-
// if a lower layer already rescued it) stands.
|
|
2323
|
-
const bareLc = splitWords(from).map((t) => t.toLowerCase());
|
|
2324
|
-
const kindWords = [];
|
|
2325
|
-
const others = [];
|
|
2326
|
-
for (const t of bareLc) {
|
|
2327
|
-
if (NOISE_OR_SCAFFOLD.has(t)) continue;
|
|
2328
|
-
// real entity kinds only — "change"/"changes" is ask-vocab's pseudo-type (never a
|
|
2329
|
-
// node class), so it is not a countable kind; it falls into `others`.
|
|
2330
|
-
const et = ENTITY_TO_TYPE[t];
|
|
2331
|
-
if (et && et !== "Change") kindWords.push(t);
|
|
2332
|
-
else others.push(t);
|
|
2333
|
-
}
|
|
2334
|
-
if (kindWords.length === 1 && others.length === 0) {
|
|
2335
|
-
// reuse the whole aggregate pipeline (parseAggregate → count node → renderer): a
|
|
2336
|
-
// synthesized "count <kind>" is the exact query the cascade's other count paths land.
|
|
2337
|
-
const hit = attempt(["count", kindWords[0]]);
|
|
2338
|
-
if (hit) { steps.push(`bare kind "${kindWords[0]}" → count`); return done(hit); }
|
|
2339
|
-
}
|
|
2340
|
-
// A LONE unknown noun wrapped only in packaging ("the bananas") is left to the generic
|
|
2341
|
-
// honest miss (the rephrase hint already NAMES the kinds): a crisper "isn't a listable
|
|
2342
|
-
// kind" miss here would fire on every one-word non-query the same way ("tell me a joke"),
|
|
2343
|
-
// which chat.mjs's own surface deliberately answers with the general hint — so the
|
|
2344
|
-
// bare-noun default is a COUNT of a KNOWN kind only, never a re-worded miss.
|
|
2345
|
-
|
|
2346
|
-
return null; // exhausted — the honest bottom of the cascade (caller keeps the original miss)
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
// ---- orchestration — the seonix_ask entry point (§6.3: parse -> resolve -> traverse -> render) ----
|
|
2350
|
-
|
|
2351
|
-
/** Answer a free-text question over the graph, mechanically. `opts.contextId`
|
|
2352
|
-
* resolves a context pronoun ("this"/"it"/…) — wired from a UI's currently-
|
|
2353
|
-
* selected node when one exists; omit it in the bare CLI/MCP surface, where
|
|
2354
|
-
* a pronoun then produces an honest miss rather than a guess. `opts.nlp`
|
|
2355
|
-
* overrides the lemma/POS adapter (see parseQuery) — leave it undefined and
|
|
2356
|
-
* a Node process picks up wink automatically while the inlined viewer stays
|
|
2357
|
-
* adapter-less by construction. `opts.prev` is the id array of the LAST answer's
|
|
2358
|
-
* matches — thread it from a chat loop so a follow-up anaphora question ("which of
|
|
2359
|
-
* those are tested", "how many of them call X") filters/counts the prior result
|
|
2360
|
-
* set; omit it and anaphora questions produce an honest "needs a previous answer"
|
|
2361
|
-
* miss. Returns the full {content, seonix_ask:
|
|
2362
|
-
* {mechanical,parsed,matches,traversal,miss,ambiguous,candidates?}} envelope
|
|
2363
|
-
* §6.2 specifies. Zero generative model calls. */
|
|
2364
|
-
export function ask(graph, query, { contextId = null, nlp = undefined, prev = null } = {}) {
|
|
2365
|
-
// Explicit help/orientation request → the rephrase hint directly (the honest bottom
|
|
2366
|
-
// of the cascade, reached on demand), never a pretend answer or a relaxation attempt.
|
|
2367
|
-
if (isHelpRequest(query)) {
|
|
2368
|
-
return {
|
|
2369
|
-
content: rephraseHint(),
|
|
2370
|
-
seonix_ask: {
|
|
2371
|
-
mechanical: true, parsed: null, matches: [], traversal: null,
|
|
2372
|
-
miss: true, ambiguous: false, matchedVia: null, help: true, relaxed: null,
|
|
2373
|
-
},
|
|
2374
|
-
};
|
|
2375
|
-
}
|
|
2376
|
-
const direct = parseQuery(query, { nlp });
|
|
2377
|
-
// The relaxation cascade fires ONLY when the DIRECT parse would miss (no parse, a
|
|
2378
|
-
// compositional {node:"miss"}, or an unresolved named term) — a clean hit, an
|
|
2379
|
-
// ambiguous parse, an unresolved-pronoun miss, and a real-but-empty answer all keep
|
|
2380
|
-
// the direct parse untouched (a hit stays instant and exact).
|
|
2381
|
-
let parsed = direct;
|
|
2382
|
-
let relaxed = null;
|
|
2383
|
-
if (answerable(graph, direct, contextId) === false) {
|
|
2384
|
-
const r = relaxParse(graph, query, { nlp, contextId, prev });
|
|
2385
|
-
if (r) { parsed = r.parsed; relaxed = { from: r.from, to: r.to, dropped: r.dropped, steps: r.steps }; }
|
|
2386
|
-
}
|
|
2387
|
-
const result = traverse(graph, parsed, { contextId, prev });
|
|
2388
|
-
const rendered = render(parsed, result);
|
|
2389
|
-
// If relaxation materially rewrote the query and produced a real answer, note it
|
|
2390
|
-
// lightly (terse, honest) so the reader knows how the question was read.
|
|
2391
|
-
const content = (relaxed && !rendered.miss && relaxed.to !== relaxed.from)
|
|
2392
|
-
? `read as "${relaxed.to}" — ${rendered.content}`
|
|
2393
|
-
: rendered.content;
|
|
2394
|
-
return {
|
|
2395
|
-
content,
|
|
2396
|
-
seonix_ask: {
|
|
2397
|
-
mechanical: true,
|
|
2398
|
-
parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
|
|
2399
|
-
matches: (result.matches || []).map((m) => ({
|
|
2400
|
-
id: m.id, label: m.label, type: m.class, module: m.class ? moduleLabelOf(m) : undefined,
|
|
2401
|
-
})),
|
|
2402
|
-
traversal: result.traversal || null,
|
|
2403
|
-
miss: !!rendered.miss,
|
|
2404
|
-
ambiguous: !!rendered.ambiguous,
|
|
2405
|
-
// The relaxation trace: null when the direct parse was used as-is (a clean hit or
|
|
2406
|
-
// an honest miss the cascade couldn't/shouldn't rescue), else what the cascade
|
|
2407
|
-
// dropped/normalised to reach an answer. A caller can assert relaxed===null to
|
|
2408
|
-
// prove the cascade never touched a direct hit.
|
|
2409
|
-
relaxed,
|
|
2410
|
-
// Confidence provenance: "prose" when resolveObject fell through to the tier-4
|
|
2411
|
-
// prose-index fallback (PLAN_PROSE_INDEX.md §6 — matched what the symbol talks
|
|
2412
|
-
// about, not its name); "fuzzy" when the tier-5 bounded-edit-distance pass
|
|
2413
|
-
// resolved a typo'd term (the rendered content also announces it: "assuming you
|
|
2414
|
-
// meant <label>"); null for every literal-identifier tier.
|
|
2415
|
-
matchedVia: result.matchedVia || null,
|
|
2416
|
-
...(rendered.ambiguous ? { candidates: rendered.candidates } : {}),
|
|
2417
|
-
},
|
|
2418
|
-
};
|
|
2419
|
-
}
|