@polycode-projects/seonix 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +95 -12
- package/package.json +5 -2
- package/src/ask-vocab.mjs +258 -0
- package/src/ask.mjs +762 -0
- package/src/browser.mjs +769 -0
- package/src/codegraph.mjs +352 -23
- package/src/cs_treesitter.mjs +2 -2
- package/src/extract.mjs +51 -8
- package/src/extract_lang.mjs +17 -2
- package/src/java_javaparser.mjs +54 -0
- package/src/java_treesitter.mjs +216 -0
- package/src/jsts_tsc.mjs +2 -2
- package/src/prose.mjs +145 -0
- package/src/schema-docs.mjs +225 -0
- package/src/server.mjs +35 -4
- package/src/temporal.mjs +559 -0
- package/src/viz.mjs +666 -94
- package/src/walk.mjs +52 -5
package/src/ask.mjs
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
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 fuzzy matching beyond
|
|
6
|
+
// plain substring/component matching, no model calls — a miss is a stated blank,
|
|
7
|
+
// never a guess (the extraction pipeline's "no wrong edge" ethos, held at the
|
|
8
|
+
// query layer too).
|
|
9
|
+
//
|
|
10
|
+
// Four pure, independently-testable stages, orchestrated by ask():
|
|
11
|
+
// parseQuery (grammar) -> resolveObject (mechanical term resolution) ->
|
|
12
|
+
// traverse (graph lookup) -> render (templates).
|
|
13
|
+
//
|
|
14
|
+
// §3.5/3.6 (2026-07-02, ELIZA/PARRY-style breadth): parseQuery normalizes the
|
|
15
|
+
// raw text (contractions, g-drop, filler-strip), rewrites recognized negative-
|
|
16
|
+
// rhetorical constructions to their affirmative form, then runs TWO INDEPENDENT
|
|
17
|
+
// parsing STRATEGIES against the same normalized text — the original anchored-
|
|
18
|
+
// template matcher (precise, fast, unweakened) and a keyword-spotting/
|
|
19
|
+
// decomposition matcher (ELIZA's own mechanism: find the keyword, decompose
|
|
20
|
+
// around it, tolerate reordering/casual phrasing) — and MERGES their results:
|
|
21
|
+
// one strategy hit -> use it; both hit and agree -> use it (high confidence);
|
|
22
|
+
// both hit and DISAGREE -> a genuine parse-level ambiguity, surfaced honestly;
|
|
23
|
+
// neither hits -> the honest grammar miss. STRATEGIES is a plain array so a
|
|
24
|
+
// third strategy could join the same way, not a hardcoded two-branch special
|
|
25
|
+
// case.
|
|
26
|
+
//
|
|
27
|
+
// Where a parsed intent is temporal/churn-shaped (touched/since/cochange as a
|
|
28
|
+
// FILTER over commits, not a structural edge), this engine does NOT re-implement
|
|
29
|
+
// that — see PLAN_MECHANICAL_CHAT.md §2: matchQuery/nlToQuery (temporal.mjs) already
|
|
30
|
+
// own that surface for the Chronograph browser; ask.mjs's own `touches`/`cochange`
|
|
31
|
+
// verbs here answer "which modules touch/co-change with X" as ONE-HOP structural
|
|
32
|
+
// edges (mgx:touchedByCommit / mgx:changeCoupledWith), which is a different (and
|
|
33
|
+
// simpler) question than the browser's time-scrubbing view.
|
|
34
|
+
|
|
35
|
+
import { relationKind, impactClosure } from "./codegraph.mjs";
|
|
36
|
+
import {
|
|
37
|
+
VERB_TO_KIND, ENTITY_TO_TYPE, MODIFIER_TO_KIND,
|
|
38
|
+
CONTRACTIONS, G_DROP, FILLER_WORDS, CONTEXT_PRONOUNS, NEGATION_FRAMES,
|
|
39
|
+
META_MEANING_VERBS,
|
|
40
|
+
} from "./ask-vocab.mjs";
|
|
41
|
+
import { lookupByProseTokens } from "./prose.mjs";
|
|
42
|
+
|
|
43
|
+
/** All edges of a classified relation kind, flattened across relation groups —
|
|
44
|
+
* a local copy of codegraph.mjs's private edgesOfKind (kept local rather than
|
|
45
|
+
* exported+imported to avoid coupling this file's commit boundary to concurrent
|
|
46
|
+
* in-flight edits elsewhere in codegraph.mjs; both read the same relationKind
|
|
47
|
+
* classification, so they cannot drift in meaning). */
|
|
48
|
+
function edgesOfKind(graph, kind) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const g of graph.relations) if (relationKind(g) === kind) out.push(...g.edges);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- §3 vocabulary — single-sourced in ./ask-vocab.mjs; the grammar, the
|
|
55
|
+
// rephrase-hint text, and the renderer's noun forms all derive from those
|
|
56
|
+
// three tables, so they cannot drift. ----
|
|
57
|
+
|
|
58
|
+
// Predicate kinds carrying a finer, symbol-grain sibling (module-coarse -> fn/method-precise).
|
|
59
|
+
// "which functions call X" should read off callsSymbol (fn->fn), not the module-coarse "calls".
|
|
60
|
+
const SYMBOL_GRAIN_SIBLING = { calls: "callsSymbol", touches: "touchesSymbol" };
|
|
61
|
+
const FINE_ENTITY_TYPES = new Set(["Function", "Method", "Class", "Attribute", "GlobalVariable"]);
|
|
62
|
+
|
|
63
|
+
const OVERFLOW_CAP = 12;
|
|
64
|
+
|
|
65
|
+
const PLURAL_FORMS = {
|
|
66
|
+
Function: ["function", "functions"], Method: ["method", "methods"],
|
|
67
|
+
Class: ["class", "classes"], Module: ["module", "modules"],
|
|
68
|
+
Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
|
|
69
|
+
Commit: ["commit", "commits"],
|
|
70
|
+
};
|
|
71
|
+
function nounFor(entityType, n) {
|
|
72
|
+
const [s, p] = PLURAL_FORMS[entityType] || ["result", "results"];
|
|
73
|
+
return n === 1 ? s : p;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Every relation KIND token is already the correct 3rd-person-singular verb form
|
|
77
|
+
// ("X imports Y", "X calls Y", "X touches Y") EXCEPT "cochange", the one kind whose
|
|
78
|
+
// name is a bare noun/verb stem ("X cochange Y" is wrong; "X cochanges Y" is right) —
|
|
79
|
+
// so the reverse-shape zero-hit template below reads off this table instead of
|
|
80
|
+
// unconditionally appending "s" (which used to double-pluralize every other kind:
|
|
81
|
+
// "callss", "importss", "touchess").
|
|
82
|
+
const REVERSE_MISS_VERB = { cochange: "cochanges" };
|
|
83
|
+
function verbFor(kind) {
|
|
84
|
+
return REVERSE_MISS_VERB[kind] || kind;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function escapeRegex(s) {
|
|
88
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- §3.5 normalization — runs before EITHER parsing strategy sees the text ----
|
|
92
|
+
|
|
93
|
+
/** contraction/informal-spelling table -> word-boundary regex, longest phrase
|
|
94
|
+
* first (so "there's" doesn't get shadowed by a shorter overlapping entry). */
|
|
95
|
+
const CONTRACTION_RE = new RegExp(
|
|
96
|
+
"\\b(" + Object.keys(CONTRACTIONS).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
97
|
+
"gi",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/** Free-text -> normalized free-text: contractions expanded, g-dropped words
|
|
101
|
+
* restored, filler/politeness words stripped. Idempotent and pure — the same
|
|
102
|
+
* input always normalizes the same way, so both parsing strategies see
|
|
103
|
+
* identical text and their outputs are directly comparable. Deliberately
|
|
104
|
+
* does NOT force lowercase: object/subject terms (module names like
|
|
105
|
+
* "myFile", class names like "Base") are meaningfully cased, and every
|
|
106
|
+
* substitution below already matches case-insensitively (`i`/`gi` flags) —
|
|
107
|
+
* forcing the whole string to lowercase would silently corrupt every parsed
|
|
108
|
+
* term's case instead. */
|
|
109
|
+
export function normalizeQuery(text) {
|
|
110
|
+
let q = String(text || "");
|
|
111
|
+
q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
|
|
112
|
+
q = q.replace(G_DROP, "$1ing");
|
|
113
|
+
if (FILLER_WORDS.length) {
|
|
114
|
+
const fillerRe = new RegExp(
|
|
115
|
+
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
116
|
+
"gi",
|
|
117
|
+
);
|
|
118
|
+
q = q.replace(fillerRe, " ");
|
|
119
|
+
}
|
|
120
|
+
return q.replace(/\s+/g, " ").trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Recognized negative-rhetorical constructions, rewritten to the affirmative
|
|
124
|
+
* form of the SAME question (§3.6) — a small pattern set, not a general
|
|
125
|
+
* negation-scope parser. First matching frame wins; unmatched text passes
|
|
126
|
+
* through unchanged. */
|
|
127
|
+
export function applyNegationFrames(text) {
|
|
128
|
+
for (const frame of NEGATION_FRAMES) {
|
|
129
|
+
const m = text.match(frame.re);
|
|
130
|
+
if (m) return frame.to(m).replace(/\s+/g, " ").trim();
|
|
131
|
+
}
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- strategy 1: anchored templates — fixed precedence order; first fit wins,
|
|
136
|
+
// never ambiguous at the template level (a question matching two shapes is a
|
|
137
|
+
// design smell we test against). Unweakened from the original P0 grammar. ----
|
|
138
|
+
|
|
139
|
+
const VERB_ALT = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
140
|
+
const ENTITY_ALT = Object.keys(ENTITY_TO_TYPE).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
141
|
+
const MODIFIER_ALT = Object.keys(MODIFIER_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
142
|
+
const META_ALT = META_MEANING_VERBS.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
143
|
+
|
|
144
|
+
const TEMPLATES = [
|
|
145
|
+
// T1 ASK: "does X import Y" / "is X a subclass of Y" -> Yes/No. Tried FIRST: it starts with
|
|
146
|
+
// does/is/do, which the reverse/forward templates below never match (those start with
|
|
147
|
+
// which/what), so precedence between T1 and the rest is structural, not a tie-break guess.
|
|
148
|
+
{
|
|
149
|
+
name: "ask",
|
|
150
|
+
re: new RegExp(`^(?:does|do)\\s+(.+?)\\s+(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
|
|
151
|
+
build: (m) => ({
|
|
152
|
+
shape: "ask", entityType: null, modifier: "direct",
|
|
153
|
+
kind: VERB_TO_KIND[m[2].toLowerCase()], subject: m[1].trim(), object: m[3].trim(),
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
// T2 reverse: "which <entity> [<modifier>] <verb> <object>" — the operator's own example shape.
|
|
157
|
+
{
|
|
158
|
+
name: "reverse",
|
|
159
|
+
re: new RegExp(`^which\\s+(${ENTITY_ALT})\\s+(?:(${MODIFIER_ALT})\\s+)?(${VERB_ALT})\\s+(.+?)\\??$`, "i"),
|
|
160
|
+
build: (m) => ({
|
|
161
|
+
shape: "reverse",
|
|
162
|
+
entityType: ENTITY_TO_TYPE[m[1].toLowerCase()],
|
|
163
|
+
modifier: m[2] ? MODIFIER_TO_KIND[m[2].toLowerCase()] : "direct",
|
|
164
|
+
kind: VERB_TO_KIND[m[3].toLowerCase()],
|
|
165
|
+
object: m[4].trim(),
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
// T3 forward: "what does <object> <verb>" — X is given, list its R-related things.
|
|
169
|
+
{
|
|
170
|
+
name: "forward",
|
|
171
|
+
re: new RegExp(`^what\\s+(?:does|do)\\s+(.+?)\\s+(${VERB_ALT})\\??$`, "i"),
|
|
172
|
+
build: (m) => ({
|
|
173
|
+
shape: "forward", entityType: null, modifier: "direct",
|
|
174
|
+
kind: VERB_TO_KIND[m[2].toLowerCase()], object: m[1].trim(),
|
|
175
|
+
}),
|
|
176
|
+
},
|
|
177
|
+
// T4 meta: "what does <term> mean" — a question about the GRAPH'S OWN VOCABULARY
|
|
178
|
+
// (a SchemaClass/SchemaPredicate label, e.g. "cochange", or a raw prop token, e.g.
|
|
179
|
+
// "mgx:callsSymbol"), not a graph traversal over code edges. Tried after T3: T3 also
|
|
180
|
+
// starts "what does/do", but T3 only fires when the tail is a relation VERB_ALT
|
|
181
|
+
// phrase ("import"/"calls"/…), which "mean"/"means"/etc never are (disjoint tables —
|
|
182
|
+
// ask-vocab.mjs's file comment explains why they're kept separate), so the two never
|
|
183
|
+
// actually compete for the same input.
|
|
184
|
+
{
|
|
185
|
+
name: "meta-mean",
|
|
186
|
+
re: new RegExp(`^what\\s+(?:does|do|is|are)\\s+(.+?)\\s+(?:${META_ALT})\\??$`, "i"),
|
|
187
|
+
build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
|
|
188
|
+
},
|
|
189
|
+
// T5 meta: "what is a/an <term>" — the OTHER worked phrasing ("what is a Commit").
|
|
190
|
+
// The indefinite article is REQUIRED (not optional): a bare "what is <anything>"
|
|
191
|
+
// would also swallow "what is the meaning of this codebase" (an existing, deliberately
|
|
192
|
+
// honest grammar-miss regression case — ask.test.mjs/ask-dual-strategy.test.mjs both
|
|
193
|
+
// assert it stays null), which never mentions "a"/"an" before its tail. Requiring the
|
|
194
|
+
// article keeps this template's reach to the one worked shape without reopening that.
|
|
195
|
+
{
|
|
196
|
+
name: "meta-whatis",
|
|
197
|
+
re: new RegExp(`^what\\s+(?:is|are)\\s+(?:an?)\\s+(.+?)\\??$`, "i"),
|
|
198
|
+
build: (m) => ({ shape: "meta", entityType: null, modifier: "direct", kind: "meta", object: m[1].trim() }),
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
/** Strategy 1: the original P0 anchored grammar, unmodified in behavior — the
|
|
203
|
+
* whole (normalized) string must match one of TEMPLATES start-to-end. Pure. */
|
|
204
|
+
function parseAnchored(text) {
|
|
205
|
+
for (const t of TEMPLATES) {
|
|
206
|
+
const m = text.match(t.re);
|
|
207
|
+
if (m) return t.build(m);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- strategy 2: keyword-spotting/decomposition — ELIZA's own mechanism: find
|
|
213
|
+
// the keyword(s) anywhere in the text, decompose around them, tolerate reordering
|
|
214
|
+
// and casual phrasing. Position-independent (no `^...$` anchor), so it tolerates
|
|
215
|
+
// "what calls this" / "who invokes this" / "something executes this, where from"
|
|
216
|
+
// — real phrasings the anchored grammar's fixed shapes don't cover. ----
|
|
217
|
+
|
|
218
|
+
const STOPWORDS = new Set([
|
|
219
|
+
"what", "who", "which", "where", "when", "why", "how",
|
|
220
|
+
"does", "do", "is", "are", "the", "a", "an", "of", "to", "from", "at", "in", "on",
|
|
221
|
+
"there", "something", "anything", "nothing", "one", "any",
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
/** Find the longest phrase from `table`'s keys that appears as a contiguous
|
|
225
|
+
* run of `words` (case already lowercased by the caller). Longest-match-first
|
|
226
|
+
* (multi-word phrases before single words) so "co-changes with" isn't
|
|
227
|
+
* shadowed by a shorter unrelated word. Returns {kind, start, end} (end
|
|
228
|
+
* exclusive) or null. */
|
|
229
|
+
function findPhrase(lcWords, table) {
|
|
230
|
+
const phrases = Object.keys(table).sort((a, b) => b.split(" ").length - a.split(" ").length);
|
|
231
|
+
for (const p of phrases) {
|
|
232
|
+
const pWords = p.split(" ");
|
|
233
|
+
for (let i = 0; i <= lcWords.length - pWords.length; i += 1) {
|
|
234
|
+
if (pWords.every((w, j) => lcWords[i + j] === w)) return { kind: table[p], start: i, end: i + pWords.length };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Strategy 2: scan (already-normalized) text for a verb keyword anywhere,
|
|
241
|
+
* plus optional entity/modifier keywords anywhere, then split whatever's
|
|
242
|
+
* left (after removing the matched spans + stopwords) into the words BEFORE
|
|
243
|
+
* and AFTER the verb. Which side(s) are non-empty decides the shape —
|
|
244
|
+
* mirrors the three anchored shapes but by decomposition instead of a fixed
|
|
245
|
+
* template, so it tolerates reordering/casual phrasing the anchored regexes
|
|
246
|
+
* don't: text on BOTH sides ("does X import Y") -> ask{subject:before,
|
|
247
|
+
* object:after}; only AFTER the verb ("what calls this") -> reverse{object:
|
|
248
|
+
* after}; only BEFORE it ("what does X import") -> forward{object:before}.
|
|
249
|
+
* A lone context pronoun ("this"/"it"/"that"/"here") ending up as a resolved
|
|
250
|
+
* term is left as plain text — resolveTermOrContext (traverse-time)
|
|
251
|
+
* recognizes it against an optional contextId, so no separate flag is
|
|
252
|
+
* needed here. A misparse here costs nothing beyond an honest object-miss
|
|
253
|
+
* downstream (resolveObject never guesses). Pure. */
|
|
254
|
+
function parseKeywordSpot(text) {
|
|
255
|
+
// Strip a trailing "?" (mirrors the anchored templates' own `\??$`) and turn commas into
|
|
256
|
+
// pauses/spaces — but NEVER strip a mid-word ".": object terms are routinely dotted file/module
|
|
257
|
+
// names ("a.py", "utils.mjs"), and the anchored strategy captures those raw, so keyword-spot
|
|
258
|
+
// must too or the two strategies would "disagree" over a period that was never part of the intent.
|
|
259
|
+
const words = text.replace(/\?+\s*$/, "").replace(/,/g, " ").split(/\s+/).filter(Boolean);
|
|
260
|
+
const lcWords = words.map((w) => w.toLowerCase());
|
|
261
|
+
const verbHit = findPhrase(lcWords, VERB_TO_KIND);
|
|
262
|
+
if (!verbHit) return null;
|
|
263
|
+
const entityHit = findPhrase(lcWords, ENTITY_TO_TYPE);
|
|
264
|
+
const modifierHit = findPhrase(lcWords, MODIFIER_TO_KIND);
|
|
265
|
+
const consumed = new Set();
|
|
266
|
+
const mark = (hit) => { if (hit) for (let i = hit.start; i < hit.end; i += 1) consumed.add(i); };
|
|
267
|
+
mark(verbHit); mark(entityHit); mark(modifierHit);
|
|
268
|
+
const sideText = (from, to) => words
|
|
269
|
+
.slice(from, to)
|
|
270
|
+
.filter((_, j) => !consumed.has(from + j) && !STOPWORDS.has(lcWords[from + j]))
|
|
271
|
+
.join(" ")
|
|
272
|
+
.trim();
|
|
273
|
+
const beforeText = sideText(0, verbHit.start);
|
|
274
|
+
const afterText = sideText(verbHit.end, words.length);
|
|
275
|
+
const kind = verbHit.kind;
|
|
276
|
+
const entityType = entityHit ? ENTITY_TO_TYPE[lcWords.slice(entityHit.start, entityHit.end).join(" ")] : null;
|
|
277
|
+
const modifier = modifierHit ? MODIFIER_TO_KIND[lcWords.slice(modifierHit.start, modifierHit.end).join(" ")] : "direct";
|
|
278
|
+
|
|
279
|
+
if (beforeText && afterText) return { shape: "ask", entityType: null, modifier: "direct", kind, subject: beforeText, object: afterText };
|
|
280
|
+
if (afterText) return { shape: "reverse", entityType, modifier, kind, object: afterText };
|
|
281
|
+
if (beforeText) return { shape: "forward", entityType: null, modifier: "direct", kind, object: beforeText };
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- strategy merge — run both, agree/disagree/single/neither (§ above). A
|
|
286
|
+
// plain array + a merge step, so a third strategy plugs in the same way. ----
|
|
287
|
+
|
|
288
|
+
const STRATEGIES = [
|
|
289
|
+
{ name: "anchored", parse: parseAnchored },
|
|
290
|
+
{ name: "keyword-spot", parse: parseKeywordSpot },
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
|
|
294
|
+
|
|
295
|
+
/** Do two independently-produced parses mean the same graph query? Same
|
|
296
|
+
* shape, same relation kind, and matching term(s) (both subject and object
|
|
297
|
+
* for "ask"; just object otherwise) — anything less is a genuine
|
|
298
|
+
* disagreement, not a near-miss to paper over. */
|
|
299
|
+
function sameParse(p, q) {
|
|
300
|
+
if (p.shape !== q.shape || p.kind !== q.kind) return false;
|
|
301
|
+
if (p.shape === "ask") return cmpTerm(p.subject) === cmpTerm(q.subject) && cmpTerm(p.object) === cmpTerm(q.object);
|
|
302
|
+
return cmpTerm(p.object) === cmpTerm(q.object);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Compile a free-text question into {shape, kind, entityType, modifier,
|
|
306
|
+
* object[, subject]}, or null if NEITHER strategy fits — an honest grammar
|
|
307
|
+
* miss (§6.3), never a best-effort guess. When both strategies parse and
|
|
308
|
+
* AGREE, returns that parse unchanged (no fallback ordering — either
|
|
309
|
+
* strategy's own result is equally valid once they agree, per §above: "use
|
|
310
|
+
* either"). When both parse but DISAGREE (different shape/kind/term),
|
|
311
|
+
* returns {ambiguousParse: true, candidates: [...]} — a genuine "this could
|
|
312
|
+
* mean more than one thing" case, distinct from resolveObject's later
|
|
313
|
+
* object-resolution ambiguity. Pure. */
|
|
314
|
+
export function parseQuery(query) {
|
|
315
|
+
const raw = String(query || "").trim().replace(/\s+/g, " ");
|
|
316
|
+
if (!raw) return null;
|
|
317
|
+
const text = applyNegationFrames(normalizeQuery(raw));
|
|
318
|
+
if (!text) return null;
|
|
319
|
+
const hits = STRATEGIES.map((s) => ({ name: s.name, parsed: s.parse(text) })).filter((r) => r.parsed);
|
|
320
|
+
if (hits.length === 0) return null;
|
|
321
|
+
if (hits.length === 1) return hits[0].parsed;
|
|
322
|
+
const [a, b] = hits;
|
|
323
|
+
if (sameParse(a.parsed, b.parsed)) return a.parsed;
|
|
324
|
+
return { ambiguousParse: true, candidates: hits.map((h) => h.parsed) };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The rephrase hint shown on a grammar miss — generated from the SAME tables the parser
|
|
328
|
+
* uses, so it can never suggest a phrasing the grammar doesn't actually support (§6.3). */
|
|
329
|
+
export function rephraseHint() {
|
|
330
|
+
return '"which <functions|classes|modules> <imports|calls|inherits from|tests|touched> <name>" or "what does <name> <import|call|inherit from>" or plainly "what calls this" (about a selected node) or "what does <term> mean"/"what is a <ClassName>" (about the graph\'s own vocabulary)';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- §4 object-term resolution — mechanical, no embeddings, tiered, stop at first hit ----
|
|
334
|
+
|
|
335
|
+
function componentSet(s) {
|
|
336
|
+
return new Set(String(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Resolve a free-text object/subject term against the graph's individuals, in priority
|
|
340
|
+
* order (§4, generalized beyond the module-coupling worked example to cover every verb
|
|
341
|
+
* family's object grain — `inherits`/`calls` resolve against Class/Function names, not
|
|
342
|
+
* just modules): (1) exact label/id match, (2) an `ext:` unresolved-target match (today
|
|
343
|
+
* ext: targets are edge-endpoint STRINGS with no individual of their own — e.g. an
|
|
344
|
+
* unimported inherits base, or any import target — so this tier returns a synthetic
|
|
345
|
+
* {id:"ext:<name>", label:<name>, class:null} match rather than an `individuals` lookup;
|
|
346
|
+
* see PLAN_MECHANICAL_CHAT.md §10 on why external `imports` targets land here rather
|
|
347
|
+
* than tier 1/3 today), (3) boundary-aware substring/component match, (4) a prose-index
|
|
348
|
+
* fallback (PLAN_PROSE_INDEX.md §6): the term, tokenized the same way as a docstring, is
|
|
349
|
+
* looked up against `graph.proseIndex` via `lookupByProseTokens` — an exact WORD-level
|
|
350
|
+
* overlap match against a symbol's decomposed-identifier or doc-comment tokens, never a
|
|
351
|
+
* substring/fuzzy guess (the same "no wrong edge" standard as tiers 1-3, just over a
|
|
352
|
+
* different token source: prose rather than the literal identifier). Only ever consulted
|
|
353
|
+
* once every literal-identifier tier above has failed, and the result is tagged
|
|
354
|
+
* `matchedVia: "prose"` so a caller can tell the match came from prose content rather
|
|
355
|
+
* than the symbol's own name — the render layer does not currently read this (it treats
|
|
356
|
+
* a resolved match as a resolved match, same "honest miss vs genuine hit" binary tiers
|
|
357
|
+
* 1-3 already use), but the field is there for any caller that wants to surface it. (5)
|
|
358
|
+
* no match at all — an honest miss. Returns {match, candidates, tier, ambiguous
|
|
359
|
+
* [, matchedVia]} — ambiguous on a true tier-3 score tie OR a true tier-4 overlap-count
|
|
360
|
+
* tie. */
|
|
361
|
+
export function resolveObject(graph, term) {
|
|
362
|
+
const t = String(term || "").trim();
|
|
363
|
+
if (!t) return { match: null, candidates: [], tier: null, ambiguous: false };
|
|
364
|
+
const tLc = t.toLowerCase();
|
|
365
|
+
const pool = graph.individuals;
|
|
366
|
+
|
|
367
|
+
const exact = pool.find((i) => String(i.label).toLowerCase() === tLc || String(i.id).toLowerCase() === tLc);
|
|
368
|
+
if (exact) return { match: exact, candidates: [], tier: 1, ambiguous: false };
|
|
369
|
+
|
|
370
|
+
// ext: targets never have their own individual (they're a raw edge-endpoint id) — find
|
|
371
|
+
// the actual (case-preserved) id off a real edge rather than reconstructing it, so a
|
|
372
|
+
// typo'd term can't silently "resolve" to an ext: id nothing in the graph references.
|
|
373
|
+
const extLc = `ext:${tLc}`;
|
|
374
|
+
let extId = null;
|
|
375
|
+
outer: for (const g of graph.relations) {
|
|
376
|
+
for (const e of g.edges) {
|
|
377
|
+
if (String(e.object).toLowerCase() === extLc) { extId = e.object; break outer; }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (extId) return { match: { id: extId, label: t, class: null }, candidates: [], tier: 2, ambiguous: false };
|
|
381
|
+
|
|
382
|
+
const termComps = componentSet(t);
|
|
383
|
+
const scored = [];
|
|
384
|
+
for (const m of pool) {
|
|
385
|
+
const label = String(m.label || "").toLowerCase();
|
|
386
|
+
if (label.includes(tLc)) {
|
|
387
|
+
scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const overlap = [...termComps].filter((c) => componentSet(m.label).has(c)).length;
|
|
391
|
+
if (overlap > 0) scored.push({ ind: m, score: overlap * 10 });
|
|
392
|
+
}
|
|
393
|
+
scored.sort((a, b) => b.score - a.score);
|
|
394
|
+
if (scored.length) {
|
|
395
|
+
const [best, ...rest] = scored;
|
|
396
|
+
const tied = rest.filter((x) => x.score === best.score);
|
|
397
|
+
return {
|
|
398
|
+
match: best.ind,
|
|
399
|
+
candidates: rest.slice(0, 4).map((x) => x.ind),
|
|
400
|
+
tier: 3,
|
|
401
|
+
ambiguous: tied.length > 0,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// tier 4: prose-index fallback (PLAN_PROSE_INDEX.md §6) — see the function doc above.
|
|
406
|
+
const proseHits = lookupByProseTokens(graph.proseIndex, t);
|
|
407
|
+
if (proseHits.length) {
|
|
408
|
+
const [best, ...rest] = proseHits;
|
|
409
|
+
const bestInd = graph.byId.get(best.id);
|
|
410
|
+
if (bestInd) {
|
|
411
|
+
const tied = rest.filter((h) => h.score === best.score);
|
|
412
|
+
return {
|
|
413
|
+
match: bestInd,
|
|
414
|
+
candidates: rest.slice(0, 4).map((h) => graph.byId.get(h.id)).filter(Boolean),
|
|
415
|
+
tier: 4,
|
|
416
|
+
ambiguous: tied.length > 0,
|
|
417
|
+
matchedVia: "prose",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return { match: null, candidates: [], tier: null, ambiguous: false };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Resolve a term that may be a context pronoun ("this"/"it"/"that"/"here") —
|
|
425
|
+
* when `contextId` is given, resolve straight to that graph entity (a real
|
|
426
|
+
* click/focus in the caller's UI, not a guess); with no contextId, an honest
|
|
427
|
+
* miss explaining exactly what's missing, distinct from "no such name in the
|
|
428
|
+
* index". A non-pronoun term always falls through to the ordinary
|
|
429
|
+
* resolveObject tiers. */
|
|
430
|
+
function resolveTermOrContext(graph, term, contextId) {
|
|
431
|
+
if (CONTEXT_PRONOUNS.includes(String(term || "").trim().toLowerCase())) {
|
|
432
|
+
if (!contextId) return { match: null, candidates: [], tier: null, ambiguous: false, unresolvedPronoun: true };
|
|
433
|
+
const ind = graph.byId.get(contextId);
|
|
434
|
+
return ind
|
|
435
|
+
? { match: ind, candidates: [], tier: 1, ambiguous: false }
|
|
436
|
+
: { match: null, candidates: [], tier: null, ambiguous: false, unresolvedPronoun: true };
|
|
437
|
+
}
|
|
438
|
+
return resolveObject(graph, term);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---- traversal — orchestrates codegraph.mjs's edgesOfKind; grain-refines a module-coarse
|
|
442
|
+
// edge (e.g. imports: Module->Module) down to a finer requested entityType via `defines`
|
|
443
|
+
// (Module -> top-level Function/Class/Method/Attribute), never re-implementing edge scans. ----
|
|
444
|
+
|
|
445
|
+
function refineToEntities(graph, moduleIds, entityType) {
|
|
446
|
+
const out = [];
|
|
447
|
+
for (const e of edgesOfKind(graph, "defines")) {
|
|
448
|
+
if (!moduleIds.has(e.subject)) continue;
|
|
449
|
+
const ind = graph.byId.get(e.object);
|
|
450
|
+
if (ind && ind.class === entityType) out.push(ind);
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Safety net (paired with the render-branch fix earlier in this file's history): a
|
|
456
|
+
* {shape, kind, entityType} combination must be explicitly listed here to receive
|
|
457
|
+
* real non-"direct" modifier behavior. Anything parsing to a non-"direct" modifier
|
|
458
|
+
* that ISN'T listed gets an honest "not supported yet" response from render() —
|
|
459
|
+
* never a silent fallback to direct-only behavior. This means a future
|
|
460
|
+
* MODIFIER_TO_KIND addition that forgets to wire traverse()/render() for it fails
|
|
461
|
+
* loud here, by construction, rather than quietly behaving as if the modifier had
|
|
462
|
+
* never been given (the exact bug class this file's own render-routing fix, above,
|
|
463
|
+
* just caught). Today's only non-"direct" value is "transitive" (PLAN_MECHANICAL_
|
|
464
|
+
* CHAT.md P1), wired below for reverse-shape imports/calls closures over
|
|
465
|
+
* impactClosure (codegraph.mjs) — module-coarse only; the fine-grained
|
|
466
|
+
* callsSymbol/touchesSymbol siblings and every other predicate kind have no
|
|
467
|
+
* closure primitive yet, and forward-shape currently never parses a non-"direct"
|
|
468
|
+
* modifier at all (both parsing strategies hardcode modifier:"direct" for it). */
|
|
469
|
+
function modifierIsWired(shape, kind, entityType) {
|
|
470
|
+
return shape === "reverse" && (kind === "imports" || kind === "calls") && (!entityType || entityType === "Module");
|
|
471
|
+
}
|
|
472
|
+
// Matches renderImpact's own default (codegraph.mjs) — impactClosure is reused as-is,
|
|
473
|
+
// not reimplemented, so its own depth convention is the honest one to inherit.
|
|
474
|
+
const TRANSITIVE_MAX_DEPTH = 8;
|
|
475
|
+
|
|
476
|
+
/** Compile a parsed query into a graph lookup. Pure given (graph, parsed, opts).
|
|
477
|
+
* `opts.contextId` resolves a context pronoun ("this"/"it"/…) when the parse
|
|
478
|
+
* needed one. Returns {matches, objMatch, candidates, traversal, ambiguous,
|
|
479
|
+
* answer?, unresolvedPronoun?} — `answer` only set for the "ask" shape.
|
|
480
|
+
* `matches` is always an array of individuals (or edge records for "ask"). */
|
|
481
|
+
export function traverse(graph, parsed, { contextId = null } = {}) {
|
|
482
|
+
if (!parsed || parsed.ambiguousParse) return { matches: [], objMatch: null, candidates: [], traversal: null, ambiguous: false };
|
|
483
|
+
const { shape, kind, entityType } = parsed;
|
|
484
|
+
|
|
485
|
+
// meta: a question about the graph's OWN vocabulary ("what does cochange mean", "what
|
|
486
|
+
// is a Commit") — looked up against the SchemaClass/SchemaPredicate individuals
|
|
487
|
+
// schema-docs.mjs's ingestSchemaDocs merged into the graph, not a code-edge traversal.
|
|
488
|
+
// Matched by exact (case-insensitive) label ("cochange", "Commit") OR the raw `token`
|
|
489
|
+
// attribute a SchemaPredicate also carries ("mgx:callsSymbol") — never substring/fuzzy,
|
|
490
|
+
// same discipline as resolveObject's own tiers: a real term match or an honest miss.
|
|
491
|
+
if (shape === "meta") {
|
|
492
|
+
const term = String(parsed.object || "").trim();
|
|
493
|
+
const termLc = term.toLowerCase();
|
|
494
|
+
const match = (graph.individuals || []).find((i) => {
|
|
495
|
+
if (i.class !== "SchemaClass" && i.class !== "SchemaPredicate") return false;
|
|
496
|
+
if (String(i.label).toLowerCase() === termLc) return true;
|
|
497
|
+
const token = (i.attributes || []).find((a) => a.key === "token")?.value;
|
|
498
|
+
return token && String(token).toLowerCase() === termLc;
|
|
499
|
+
});
|
|
500
|
+
if (!match) return { matches: [], objMatch: null, candidates: [], traversal: `schema lookup for "${term}"`, ambiguous: false };
|
|
501
|
+
return {
|
|
502
|
+
matches: [match], objMatch: match, candidates: [],
|
|
503
|
+
traversal: `schema lookup for "${term}"`, ambiguous: false,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// §modifier support gate (safety net, see modifierIsWired's own doc above) — checked
|
|
508
|
+
// BEFORE object resolution, so an unsupported modifier+kind combination gets its own
|
|
509
|
+
// honest capability-gap message rather than masquerading as an object-miss, or worse,
|
|
510
|
+
// silently behaving as if "transitively"/"indirectly" had never been said.
|
|
511
|
+
if (parsed.modifier && parsed.modifier !== "direct" && !modifierIsWired(shape, kind, entityType)) {
|
|
512
|
+
return {
|
|
513
|
+
matches: [], objMatch: null, candidates: [], ambiguous: false,
|
|
514
|
+
unsupportedModifier: true,
|
|
515
|
+
traversal: `modifier "${parsed.modifier}" requested for a "${kind}" query — no closure traversal exists for this combination yet`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (shape === "ask") {
|
|
520
|
+
const subj = resolveTermOrContext(graph, parsed.subject, contextId);
|
|
521
|
+
const obj = resolveTermOrContext(graph, parsed.object, contextId);
|
|
522
|
+
if (!subj.match || !obj.match) {
|
|
523
|
+
return {
|
|
524
|
+
matches: [], objMatch: obj.match, candidates: obj.candidates, traversal: null, ambiguous: false, answer: null,
|
|
525
|
+
unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const edges = edgesOfKind(graph, kind).filter((e) => e.subject === subj.match.id && e.object === obj.match.id);
|
|
529
|
+
return {
|
|
530
|
+
matches: edges, answer: edges.length > 0, objMatch: obj.match, subjMatch: subj.match,
|
|
531
|
+
candidates: [], traversal: `${kind} edge from ${subj.match.label} to ${obj.match.label}`, ambiguous: false,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// reverse and forward both resolve one named term ("object" in the parsed shape — for
|
|
536
|
+
// forward it is the query's grammatical subject, e.g. "what does X import" -> parsed.object = X).
|
|
537
|
+
const { match: objMatch, candidates, ambiguous, unresolvedPronoun, matchedVia } = resolveTermOrContext(graph, parsed.object, contextId);
|
|
538
|
+
if (!objMatch) return { matches: [], objMatch: null, candidates, traversal: null, ambiguous: false, unresolvedPronoun };
|
|
539
|
+
|
|
540
|
+
if (shape === "forward") {
|
|
541
|
+
const edges = edgesOfKind(graph, kind).filter((e) => e.subject === objMatch.id);
|
|
542
|
+
const matches = edges.map((e) => graph.byId.get(e.object)).filter(Boolean);
|
|
543
|
+
return { matches, objMatch, candidates, traversal: `${kind} edges where subject = ${objMatch.label}`, ambiguous, matchedVia };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// reverse + transitive (PLAN_MECHANICAL_CHAT.md P1): the gate above guarantees kind is
|
|
547
|
+
// "imports" or "calls" and entityType is null/"Module" here. Reuses impactClosure
|
|
548
|
+
// (codegraph.mjs) AS-IS rather than reimplementing a closure — impactClosure's own
|
|
549
|
+
// dependents map is a REVERSE closure over imports+calls edges TOGETHER (renderImpact's
|
|
550
|
+
// "what would break" framing), not a strict single-predicate chain, so a query for
|
|
551
|
+
// "transitively imports" and one for "transitively calls" both resolve to the SAME
|
|
552
|
+
// mixed reverse-dependency closure. That's a real, deliberate scope decision (matching
|
|
553
|
+
// the plan's own instruction to wire onto "renderImpact's existing closure traversal"
|
|
554
|
+
// rather than build a new predicate-pure one) — the traversal receipt below says so
|
|
555
|
+
// honestly rather than implying a narrower single-predicate result than what was
|
|
556
|
+
// actually computed.
|
|
557
|
+
if (parsed.modifier === "transitive") {
|
|
558
|
+
const levels = impactClosure(graph, objMatch, { maxDepth: TRANSITIVE_MAX_DEPTH });
|
|
559
|
+
const matches = levels.flat().map((d) => graph.byId.get(d.id)).filter(Boolean);
|
|
560
|
+
return {
|
|
561
|
+
matches, objMatch, candidates, ambiguous, matchedVia,
|
|
562
|
+
traversal: `reverse dependency closure over imports+calls edges from ${objMatch.label} (impactClosure, maxDepth=${TRANSITIVE_MAX_DEPTH})`,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// reverse: "which <entityType> R <objMatch>"
|
|
567
|
+
const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
|
|
568
|
+
if (symbolKind && FINE_ENTITY_TYPES.has(entityType)) {
|
|
569
|
+
const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
|
|
570
|
+
const matches = edges.map((e) => graph.byId.get(e.subject)).filter((i) => i && i.class === entityType);
|
|
571
|
+
return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}`, ambiguous, matchedVia };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// General case: some predicates are already fine-grained (inherits: Class->Class, contains:
|
|
575
|
+
// Class->Member) and some are module-coarse (imports/calls/tests/cochange: Module->Module).
|
|
576
|
+
// Rather than assume one or the other, check what the edge's actual subjects ARE: if they
|
|
577
|
+
// already match the requested entityType, use them directly (inherits); only when they're
|
|
578
|
+
// Module individuals and a FINER entityType was asked for do we refine via `defines`
|
|
579
|
+
// (imports) — never blindly treat an edge's subject id as if it were always a module id.
|
|
580
|
+
const edges = edgesOfKind(graph, kind).filter((e) => e.object === objMatch.id);
|
|
581
|
+
const subjects = edges.map((e) => graph.byId.get(e.subject)).filter(Boolean);
|
|
582
|
+
let matches, grainNote = "";
|
|
583
|
+
if (!entityType) {
|
|
584
|
+
matches = subjects;
|
|
585
|
+
} else {
|
|
586
|
+
const direct = subjects.filter((s) => s.class === entityType);
|
|
587
|
+
if (direct.length) {
|
|
588
|
+
matches = direct;
|
|
589
|
+
} else if (entityType !== "Module" && subjects.some((s) => s.class === "Module")) {
|
|
590
|
+
const moduleIds = new Set(subjects.filter((s) => s.class === "Module").map((s) => s.id));
|
|
591
|
+
matches = refineToEntities(graph, moduleIds, entityType);
|
|
592
|
+
grainNote = `, then ${entityType} defined in the matched module(s)`;
|
|
593
|
+
} else {
|
|
594
|
+
matches = [];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return { matches, objMatch, candidates, traversal: `${kind} edges where object = ${objMatch.label}${grainNote}`, ambiguous, matchedVia };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ---- §5 templated renderer — string interpolation + grouping/pluralization/overflow rules,
|
|
601
|
+
// never generation; every sentence is read off a matched edge/individual. ----
|
|
602
|
+
|
|
603
|
+
function moduleLabelOf(ind) {
|
|
604
|
+
if (ind.class === "Module") return ind.label;
|
|
605
|
+
const site = (ind.attributes || []).find((a) => a.key === "site")?.value;
|
|
606
|
+
if (site) return String(site).split(":")[0];
|
|
607
|
+
const m = String(ind.id || "").match(/^fn:(.+)#/);
|
|
608
|
+
return m ? m[1] : "(unknown module)";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function symbolLabelOf(ind) {
|
|
612
|
+
const label = String(ind.label || ind.id || "");
|
|
613
|
+
return ["Function", "Method"].includes(ind.class) ? `function ${label}()` : label;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** One-line, honest rephrasing of a candidate parse — used to describe a
|
|
617
|
+
* parse-level disagreement between strategies without pretending to pick
|
|
618
|
+
* a winner. Template only, reads straight off the parsed fields. */
|
|
619
|
+
function describeParse(p) {
|
|
620
|
+
const obj = p.object ?? p.subject ?? "?";
|
|
621
|
+
const ent = p.entityType ? nounFor(p.entityType, 2) + " that " : "";
|
|
622
|
+
return `${ent}${p.kind} "${obj}"`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Render a compiled query result into {content, miss, ambiguous, matches?, candidates?}.
|
|
626
|
+
* Every branch is a template, not generation — §5's grouping/pluralization/overflow rules. */
|
|
627
|
+
export function render(parsed, result) {
|
|
628
|
+
if (!parsed) {
|
|
629
|
+
return { content: `couldn't parse this as a graph question. Try: ${rephraseHint()}`, miss: true, ambiguous: false };
|
|
630
|
+
}
|
|
631
|
+
if (parsed.ambiguousParse) {
|
|
632
|
+
const options = parsed.candidates.map((p, i) => `${i + 1}) ${describeParse(p)}`).join(" or ");
|
|
633
|
+
return {
|
|
634
|
+
content: `this could mean more than one thing: ${options} — try rephrasing more specifically.`,
|
|
635
|
+
miss: false, ambiguous: true, candidates: parsed.candidates.map(describeParse),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
if (result.unresolvedPronoun) {
|
|
639
|
+
return {
|
|
640
|
+
content: `"${parsed.object ?? parsed.subject}" needs a selected node to refer to — click a node first, or name it directly.`,
|
|
641
|
+
miss: true, ambiguous: false,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (result.unsupportedModifier) {
|
|
645
|
+
return {
|
|
646
|
+
content: `the "${parsed.modifier}" modifier isn't supported for "${parsed.kind}" queries yet — only imports/calls (module-level) have a transitive closure today.`,
|
|
647
|
+
miss: true, ambiguous: false,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (parsed.shape === "meta") {
|
|
651
|
+
if (!result.objMatch) {
|
|
652
|
+
return {
|
|
653
|
+
content: `"${parsed.object}" isn't a term in this graph's own vocabulary (no matching class or predicate).`,
|
|
654
|
+
miss: true, ambiguous: false,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
|
|
658
|
+
const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
|
|
659
|
+
return { content: `${result.objMatch.label} is ${kindWord}: ${doc}`, miss: false, ambiguous: false, matches: result.matches };
|
|
660
|
+
}
|
|
661
|
+
if (!result.objMatch && (!result.candidates || result.candidates.length === 0) && parsed.shape !== "ask") {
|
|
662
|
+
return {
|
|
663
|
+
content: `no module matching "${parsed.object}" found in the index.`,
|
|
664
|
+
miss: true, ambiguous: false, candidates: [],
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
if (result.ambiguous) {
|
|
668
|
+
return {
|
|
669
|
+
content: `"${parsed.object}" matches more than one module ambiguously — please narrow the term.`,
|
|
670
|
+
miss: false, ambiguous: true, candidates: [result.objMatch, ...(result.candidates || [])].map((i) => i.label),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (parsed.shape === "ask") {
|
|
674
|
+
if (!result.objMatch || !result.subjMatch) {
|
|
675
|
+
return { content: `couldn't resolve one of the terms in this question.`, miss: true, ambiguous: false };
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
content: result.answer ? `Yes. (${result.traversal})` : `No — no ${parsed.kind} edge found from ${result.subjMatch.label} to ${result.objMatch.label}.`,
|
|
679
|
+
miss: !result.answer, ambiguous: false,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (!result.matches.length) {
|
|
683
|
+
// forward: parsed.object is the GIVEN subject ("what does X import" -> X), not a
|
|
684
|
+
// search target — "No modules found that X." reads as broken grammar (and X's own
|
|
685
|
+
// relation edges are simply absent, not "not found"), so this shape gets its own,
|
|
686
|
+
// subject-first phrasing rather than reusing reverse's "found ... that OBJECT" template.
|
|
687
|
+
if (parsed.shape === "forward") {
|
|
688
|
+
return {
|
|
689
|
+
content: `${result.objMatch.label} has no ${parsed.kind} edges in the index. (traversal: ${result.traversal || "no traversal resolved"})`,
|
|
690
|
+
miss: true, ambiguous: false,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const entityWord = nounFor(parsed.entityType || "Module", 2);
|
|
694
|
+
return {
|
|
695
|
+
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. (traversal: ${result.traversal || "no traversal resolved"})`,
|
|
696
|
+
miss: true, ambiguous: false,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
// Route by the MATCHED entities' actual class, not just the parsed hint — a reverse
|
|
700
|
+
// query phrased without an explicit entity keyword ("what imports X", entityType null)
|
|
701
|
+
// still resolves to Module individuals for a module-level relation like "imports", and
|
|
702
|
+
// grouping those by-module (module label as its own "symbol" label) reads as nonsense
|
|
703
|
+
// ("in a.mjs there is a.mjs"). The fine-grained per-symbol grouping below is only
|
|
704
|
+
// meaningful when the matches are sub-module entities (functions/classes/etc).
|
|
705
|
+
if (parsed.shape === "forward" || parsed.entityType === "Module" || result.matches.every((m) => m.class === "Module")) {
|
|
706
|
+
const shown = result.matches.slice(0, OVERFLOW_CAP).map((m) => m.label);
|
|
707
|
+
const extra = result.matches.length > OVERFLOW_CAP ? `, …and ${result.matches.length - OVERFLOW_CAP} more` : "";
|
|
708
|
+
return { content: shown.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
|
|
709
|
+
}
|
|
710
|
+
// reverse, fine-grained entity: group by module, one clause per module (§5 grouping rule) —
|
|
711
|
+
// the FIRST module states "in {module} there is …"; each SUBSEQUENT module states
|
|
712
|
+
// "there is … in {module}" (module trails, not leads), matching the plan's worked example.
|
|
713
|
+
const byModule = new Map();
|
|
714
|
+
for (const m of result.matches.slice(0, OVERFLOW_CAP)) {
|
|
715
|
+
const mod = moduleLabelOf(m);
|
|
716
|
+
if (!byModule.has(mod)) byModule.set(mod, []);
|
|
717
|
+
byModule.get(mod).push(symbolLabelOf(m));
|
|
718
|
+
}
|
|
719
|
+
const listJoin = (syms) =>
|
|
720
|
+
syms.length > 1 ? `${syms.slice(0, -1).join(", ")} and ${syms[syms.length - 1]}` : syms[0];
|
|
721
|
+
const clauses = [...byModule.entries()].map(([mod, syms], i) => {
|
|
722
|
+
const list = listJoin(syms);
|
|
723
|
+
return i === 0 ? `in ${mod} there is ${list}` : `there is ${list} in ${mod}`;
|
|
724
|
+
});
|
|
725
|
+
const extra = result.matches.length > OVERFLOW_CAP ? ` …and ${result.matches.length - OVERFLOW_CAP} more` : "";
|
|
726
|
+
return { content: clauses.join(" and ") + extra + ".", miss: false, ambiguous: false, matches: result.matches };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ---- orchestration — the seonix_ask entry point (§6.3: parse -> resolve -> traverse -> render) ----
|
|
730
|
+
|
|
731
|
+
/** Answer a free-text question over the graph, mechanically. `opts.contextId`
|
|
732
|
+
* resolves a context pronoun ("this"/"it"/…) — wired from a UI's currently-
|
|
733
|
+
* selected node when one exists; omit it in the bare CLI/MCP surface, where
|
|
734
|
+
* a pronoun then produces an honest miss rather than a guess. Returns the
|
|
735
|
+
* full {content, seonix_ask:{mechanical,parsed,matches,traversal,miss,
|
|
736
|
+
* ambiguous,candidates?}} envelope §6.2 specifies. Zero model calls. */
|
|
737
|
+
export function ask(graph, query, { contextId = null } = {}) {
|
|
738
|
+
const parsed = parseQuery(query);
|
|
739
|
+
const result = traverse(graph, parsed, { contextId });
|
|
740
|
+
const rendered = render(parsed, result);
|
|
741
|
+
return {
|
|
742
|
+
content: rendered.content,
|
|
743
|
+
seonix_ask: {
|
|
744
|
+
mechanical: true,
|
|
745
|
+
parsed: (parsed && !parsed.ambiguousParse) ? parsed : null,
|
|
746
|
+
matches: (result.matches || []).map((m) => ({
|
|
747
|
+
id: m.id, label: m.label, type: m.class, module: m.class ? moduleLabelOf(m) : undefined,
|
|
748
|
+
})),
|
|
749
|
+
traversal: result.traversal || null,
|
|
750
|
+
miss: !!rendered.miss,
|
|
751
|
+
ambiguous: !!rendered.ambiguous,
|
|
752
|
+
// Confidence provenance (PLAN_PROSE_INDEX.md §6): set only when resolveObject's
|
|
753
|
+
// object-term resolution fell all the way through to the tier-4 prose-index
|
|
754
|
+
// fallback (a word-level match against a decomposed identifier/doc-comment, not
|
|
755
|
+
// the symbol's own literal name) — null for every other (literal-identifier) tier,
|
|
756
|
+
// so a caller can distinguish "matched the thing's own name" from "matched
|
|
757
|
+
// something it merely talks about" without the rendered content wording changing.
|
|
758
|
+
matchedVia: result.matchedVia || null,
|
|
759
|
+
...(rendered.ambiguous ? { candidates: rendered.candidates } : {}),
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
}
|