@polycode-projects/seonix 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -4,11 +4,24 @@
4
4
  //
5
5
  // seonix → stdio MCP server
6
6
  // seonix cli index_repository '{"repo_path":"<abs>"}' → deterministic index
7
+ // (honours <repo>/.seonixignore — gitignore-subset patterns; pass
8
+ // `"ignores":false` in the JSON arg to index everything regardless)
7
9
  // seonix cli digest '{"repo_path":"<abs>","modules":[…]}' → architecture map + per-module
8
10
  // context bundles to stdout (no-MCP arm)
11
+ // seonix cli digest '{"repo_path":"<abs>","query":"<free text>"}' → same, but auto-locates +
12
+ // score-gap-selects the modules (R1b, the shipped default) instead of requiring an explicit
13
+ // `modules` list — one call from a question to a digest. `modules` wins if both are given.
14
+ // seonix cli browser_link '{"query":"<nl-ish or grammar>","at":"<sha>","base":"<url>"}' → NL→query→URL:
15
+ // map a natural-language-ish request to the code-browser query grammar (deterministic, no model),
16
+ // SELF-TEST it against the local temporal graph (≥1 match, cursors resolve), print the URL;
17
+ // exit 1 on a link that would render empty. `"grammar":true` takes the query verbatim.
9
18
  // seonix cli <toolName> '{…args}' → invoke ANY MCP tool via Bash (no MCP)
19
+ // (e.g. seonix cli seonix_ask '{"query":"<free text>"}' — mechanical, no-LLM NL question
20
+ // over the graph, PLAN_MECHANICAL_CHAT.md; no bespoke wiring needed, this fallback covers it)
10
21
  // seonix hook-augment → PreToolUse Grep/Glob augmenter (stdin→stdout)
11
- // seonix viz [--focus <sym>] [--depth N] [--out f.html] → render a focused sub-graph to HTML
22
+ // seonix viz [--focus <sym>] [--depth N] [--out f.html] [--data-out f.json] [--repo-url <gitlab url> --ref main] [--site-nav] → render a focused sub-graph to HTML (one shared viewer; data embedded, or split out with --data-out)
23
+ // seonix viz --browser-out f.html [--browser-data-out f.json] [--scope product|<prefixes>] → also render the Chronograph code browser (temporal scrub + two-cursor diff + narration + ghost-branch merges + keyboard nav; PLAN_CODE_BROWSER.md)
24
+ // seonix viz --serve [--port N] [--focus <sym>] → serve the same viewer against THIS repo's index (the local equivalent of the site's #viz; code browser at /code-browser.html, live-reannotating on HEAD change via /code-browser-version)
12
25
  //
13
26
  // The index step is offline and model-free (Python ast + git). It writes the
14
27
  // graph artifact to <repo_path>/.seonix/graph.json; the server (started with
@@ -20,7 +33,7 @@ import { dispatchTool, buildContextBundle } from "../src/server.mjs";
20
33
  import { indexRepository } from "../src/extract.mjs";
21
34
  import { loadConfig, DEFAULT_GRAPH_REL } from "../src/config.mjs";
22
35
  import * as source from "../src/source.mjs";
23
- import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked } from "../src/codegraph.mjs";
36
+ import { parseEntities, renderSearch, rankModulesByProximity, searchModulesRanked, selectRankedModules, DEFAULT_SCORE_GAP } from "../src/codegraph.mjs";
24
37
 
25
38
  /** Build a config pointed at a specific repo's artifact (for `cli` sub-commands that
26
39
  * take a repo_path), or fall back to the cwd-derived default. */
@@ -44,6 +57,11 @@ const TIER_RANK = { NONE: 0, TINY: 1, MID: 2, LARGE: 3, FULL: 4 };
44
57
  * renderer via buildContextBundle, so no render logic is duplicated). This stdout is injected
45
58
  * into the no-MCP arm's prompt.
46
59
  *
60
+ * Two ways to say which modules: an explicit `modules` array (unchanged), or a `query` string —
61
+ * auto-locate + score-gap-select (R1b, the shipped default as of 2026-07-02) in one call, so a
62
+ * real caller no longer has to run `seonix_locate` and hand-pick a module themselves. `modules`
63
+ * wins if both are given. The header reports which modules were actually selected either way.
64
+ *
47
65
  * B2: the FIRST (primary) module gets a full size-adaptive bundle; the remaining modules are
48
66
  * RANKED by import/cochange proximity to the primary and only the top few get a TRIMMED
49
67
  * (signatures + insertion region) bundle — so a 2-module task no longer pays for two full
@@ -51,12 +69,25 @@ const TIER_RANK = { NONE: 0, TINY: 1, MID: 2, LARGE: 3, FULL: 4 };
51
69
  async function runDigest(args) {
52
70
  const repoPath = args.repo_path;
53
71
  if (!repoPath) { process.stderr.write("seonix: digest requires repo_path\n"); process.exit(2); }
54
- const modules = Array.isArray(args.modules) ? args.modules.slice(0, DIGEST_MODULE_CAP) : [];
72
+ let modules = Array.isArray(args.modules) ? args.modules.slice(0, DIGEST_MODULE_CAP) : [];
73
+ let autoSelected = null; // for the header, when `query` drove selection
74
+ if (!modules.length && args.query) {
75
+ const graph = parseEntities(await source.fetchEntities(configFor(repoPath)));
76
+ const ranked = searchModulesRanked(graph, args.query);
77
+ const scoreGapK = args.score_gap === false ? null : (Number.isFinite(args.score_gap) ? args.score_gap : DEFAULT_SCORE_GAP);
78
+ modules = selectRankedModules(ranked, { top_k: Number.isFinite(args.top_k) ? args.top_k : 2, scoreGapK }).slice(0, DIGEST_MODULE_CAP);
79
+ autoSelected = modules;
80
+ if (!modules.length) process.stderr.write(`seonix: digest query "${args.query}" matched no modules — empty digest\n`);
81
+ }
55
82
  // Tuning-flag contract (threaded to buildContextBundle → sizeBundle): `min` → leanest TINY/no
56
- // top-up; `untuned` → pre-B010 escalation. Neither → the tuned default. The digest header still
83
+ // top-up; `untuned` → the earlier escalation. Neither → the tuned default. The digest header still
57
84
  // reports the EFFECTIVE tier/topup returned per module, so rig telemetry stays correct.
58
85
  const min = Boolean(args.min);
59
86
  const untuned = Boolean(args.untuned);
87
+ // seonix-max: the injection CEILING — every requested module gets a FULL (untrimmed) bundle,
88
+ // not just the primary + 2 trimmed secondaries. Tests whether maximal injection re-bloats.
89
+ const max = Boolean(args.max);
90
+ const secondaryCap = max ? modules.length : DIGEST_SECONDARY_CAP;
60
91
  const config = configFor(repoPath);
61
92
  const body = [];
62
93
  let effTier = "NONE"; // largest tier emitted across all modules
@@ -71,7 +102,7 @@ async function runDigest(args) {
71
102
 
72
103
  const emit = async (m, trim) => {
73
104
  try {
74
- const { text, tier, topup: t } = await buildContextBundle({ symbol: m, min, untuned }, { config, source, trim });
105
+ const { text, tier, topup: t } = await buildContextBundle({ symbol: m, min, untuned, max }, { config, source, trim });
75
106
  body.push(`\n# Context bundle: ${m}${trim ? " (secondary, trimmed)" : ""}\n` + text);
76
107
  if ((TIER_RANK[tier] || 0) > (TIER_RANK[effTier] || 0)) effTier = tier;
77
108
  if (t) topup = true;
@@ -89,15 +120,18 @@ async function runDigest(args) {
89
120
  try { ranked = rankModulesByProximity(parseEntities(await source.fetchEntities(config)), primary, rest); }
90
121
  catch { ranked = rest; }
91
122
  }
92
- const secondaries = ranked.slice(0, DIGEST_SECONDARY_CAP);
93
- const overflow = ranked.slice(DIGEST_SECONDARY_CAP);
123
+ const secondaries = ranked.slice(0, secondaryCap);
124
+ const overflow = ranked.slice(secondaryCap);
94
125
  await emit(primary, false);
95
- for (const m of secondaries) await emit(m, true);
126
+ for (const m of secondaries) await emit(m, max ? false : true);
96
127
  if (overflow.length) body.push(`\n# Related modules (not expanded; query seonix_context if needed): ${overflow.join(", ")}`);
97
128
  }
98
129
 
99
- // HARD CONTRACT: first line is the machine-readable digest header the rig greps.
100
- const header = `# seonix-digest tier=${effTier} topup=${topup} modules=${emitted}`;
130
+ // HARD CONTRACT: first line is the machine-readable digest header the rig greps. Fields are
131
+ // append-only `selected=` is new (query mode only) and never changes the existing ones the
132
+ // rig's own parser depends on.
133
+ const header = `# seonix-digest tier=${effTier} topup=${topup} modules=${emitted}`
134
+ + (autoSelected ? ` selected=${autoSelected.join(",") || "(none)"}` : "");
101
135
  process.stdout.write([header, ...body].join("\n") + "\n");
102
136
  }
103
137
 
@@ -152,7 +186,7 @@ async function main() {
152
186
  process.exit(2);
153
187
  }
154
188
  const t0 = Date.now();
155
- const { graphFile, counts } = await indexRepository(repoPath);
189
+ const { graphFile, counts } = await indexRepository(repoPath, { ignores: args.ignores !== false });
156
190
  process.stderr.write(
157
191
  `seonix: indexed ${counts.modules} modules, ${counts.functions} symbols, ` +
158
192
  `${counts.edges} edges (${counts.callsSymbol} callsSymbol, ${counts.touchesSymbol} touchesSymbol), ` +
@@ -189,7 +223,14 @@ async function main() {
189
223
  const config = configFor(args.repo_path);
190
224
  try {
191
225
  const graph = parseEntities(await source.fetchEntities(config));
192
- const ranked = searchModulesRanked(graph, args.query || "");
226
+ // B016 recall-lever flags (LOCATE-phase, per-arm; output byte-identical when absent).
227
+ const ranked = searchModulesRanked(graph, args.query || "", {
228
+ demoteNonProd: !!args.demote_nonprod, // R1a: demote examples//fixtures//test-* paths
229
+ callAdjacency: !!args.call_adjacency, // E1a: resolved-call adjacency bonus
230
+ implOfInterface: !!args.impl_of_interface, // E1b: C# impl-of-interface boost
231
+ beamSearch: !!args.beam_search, // §5.15: multi-ply discriminative expansion
232
+ ...(Number.isFinite(Number(args.beam_width)) ? { beamWidth: Number(args.beam_width) } : {}),
233
+ });
193
234
  process.stdout.write(ranked.map((r) => `${r.path}\t${r.score}`).join("\n") + "\n");
194
235
  } catch (e) {
195
236
  process.stderr.write(`seonix: ${e?.message || e}\n`);
@@ -198,6 +239,48 @@ async function main() {
198
239
  return;
199
240
  }
200
241
 
242
+ // browser_link (Chronograph P2): NL-ish text → query grammar → SELF-TESTED URL.
243
+ // The mapping is deterministic keyword handling (nlToQuery — never a model call;
244
+ // a model-driven wrapper can sit on top and pass `grammar:true` with grammar it
245
+ // wrote itself). The link is printed ONLY if it will render something: the query
246
+ // must match ≥1 node of the local temporal graph and every cursor must resolve —
247
+ // the agent contract from PLAN_CODE_BROWSER.md ("no dead links").
248
+ if (sub === "browser_link") {
249
+ const args = parsePayload(payload);
250
+ if (args === null || !String(args?.query || "").trim()) {
251
+ process.stderr.write("seonix: browser_link expects a JSON arg, e.g. '{\"query\":\"classes that change with render\"}'\n");
252
+ process.exit(2);
253
+ }
254
+ const config = configFor(args.repo_path);
255
+ const { buildTemporalGraph, gitCommitOrder } = await import("../src/browser.mjs");
256
+ const { nlToQuery, validateLink, encodeViewState, VIEW_DEFAULTS } = await import("../src/temporal.mjs");
257
+ try {
258
+ const raw = await source.fetchEntities(config);
259
+ const commitIds = (raw.individuals || []).filter((i) => i.class === "Commit").map((i) => i.id);
260
+ const order = await gitCommitOrder(args.repo_path || process.cwd(), commitIds);
261
+ const tg = buildTemporalGraph(raw, order, { scope: args.scope });
262
+ const { q, notes } = args.grammar ? { q: String(args.query).trim(), notes: [] } : nlToQuery(args.query);
263
+ for (const n of notes) process.stderr.write(`seonix: note: ${n}\n`);
264
+ if (!q) {
265
+ process.stderr.write("seonix: the request maps to an empty query — nothing to link\n");
266
+ process.exit(1);
267
+ }
268
+ const v = validateLink(tg, { q, at: args.at || "", b: args.b || "" });
269
+ if (!v.ok) {
270
+ for (const r of v.reasons) process.stderr.write(`seonix: link self-test FAILED: ${r}\n`);
271
+ process.exit(1);
272
+ }
273
+ const qs = encodeViewState({ ...VIEW_DEFAULTS, q, at: args.at || "", b: args.b || "" });
274
+ const base = String(args.base || "").replace(/\/+$/, "");
275
+ process.stdout.write(`${base}/code-browser.html?${qs}\n`);
276
+ process.stderr.write(`seonix: link self-test OK — q="${q}" matches ${v.matches} node(s)\n`);
277
+ } catch (e) {
278
+ process.stderr.write(`seonix: ${e?.message || e}\n`);
279
+ process.exit(1);
280
+ }
281
+ return;
282
+ }
283
+
201
284
  // tool-query fallback: any other `cli <toolName> '{…}'` routes to the MCP dispatcher,
202
285
  // so "cold" tools are invokable from Bash without an MCP connection.
203
286
  if (sub) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/seonix",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "SEONIX — a deterministic, offline, $0 code-graph tool that makes a cheap coding agent edit like an expensive one. Indexes a repo with Python ast + git (zero model calls) and renders a bounded edit-digest.",
@@ -45,7 +45,10 @@
45
45
  "dependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.29.0",
47
47
  "cytoscape": "^3.30.0",
48
- "ts-morph": "^24.0.0"
48
+ "ts-morph": "^24.0.0",
49
+ "tree-sitter": "^0.21.1",
50
+ "tree-sitter-c-sharp": "^0.21.3",
51
+ "tree-sitter-java": "^0.21.0"
49
52
  },
50
53
  "scripts": {
51
54
  "test": "node --test \"test/**/*.test.mjs\"",
@@ -0,0 +1,258 @@
1
+ // ask-vocab.mjs — the single committed vocabulary `ask.mjs`'s grammar, rephrase
2
+ // hint, and renderer noun forms all derive from (§3 of PLAN_MECHANICAL_CHAT.md).
3
+ // A shipped, hand-curated resource — the same "closed is deliberate" ethos as
4
+ // this repo's other closed vocabularies (SEON/mgx predicates, `.seonixignore`'s
5
+ // gitignore-subset grammar): an open/unbounded phrase list is unvalidatable and
6
+ // eventually produces a false-positive match against an unrelated code
7
+ // identifier, so each relation carries a curated (not exhaustive) set of real
8
+ // phrasings a developer would type, not a general-English thesaurus dump.
9
+ //
10
+ // Modeled on marginalia's app/lib/vocab.mjs pattern (a single source of truth
11
+ // with a `verbs: [...]` trigger-phrase array per token) — minus the RDF/OWL
12
+ // external-alignment machinery, which doesn't apply here: seonix's vocabulary
13
+ // is code-relationship-specific (imports/calls/inherits/…), not a general
14
+ // knowledge-graph ontology. No NLP library, no lemmatiser — plain phrase lists
15
+ // feeding the same fixed-precedence regex grammar `ask.mjs` already used, so
16
+ // broadening coverage never risks the "closed, deterministic, zero model
17
+ // calls" contract this repo holds at every layer (extraction through query).
18
+
19
+ /** relation token -> { comment, verbs[] }. Every phrase in `verbs` must be
20
+ * something a developer would plausibly type asking about THIS relation in
21
+ * THIS codebase's terms — not a synonym so generic it risks matching an
22
+ * unrelated question (see the file-level comment; judgment calls on omitted
23
+ * phrases are logged inline below, not silently dropped).
24
+ *
25
+ * Register spread (2026-07-02, ELIZA/PARRY-style breadth, PLAN_MECHANICAL_CHAT.md
26
+ * §3.5): each relation's `verbs` list deliberately spans formal, neutral, and
27
+ * casual/colloquial phrasings — not just formal synonyms — because the value of
28
+ * a keyword-spotting matcher is SURFACE-FORM tolerance for the same underlying
29
+ * intent, not narrow correctness. Registers are grouped with an inline comment
30
+ * per relation rather than a per-phrase tag (kept flat, matching marginalia's
31
+ * `verbs: [...]` shape) since ask.mjs only needs phrase -> kind, never the
32
+ * register itself. A misparsed casual phrase costs nothing beyond an honest
33
+ * object-miss (resolveObject never guesses), so breadth here is genuinely low-risk. */
34
+ export const RELATIONS = {
35
+ imports: {
36
+ comment: "Module -> Module: subject's import graph references object (usesComplexType).",
37
+ verbs: [
38
+ // formal/neutral
39
+ "couples to", "couple to", "uses", "use", "depends on", "imports", "import",
40
+ "relies on", "rely on", "requires", "require", "references", "reference",
41
+ "pulls in", "pull in", "built on", "builds on", "build on", "uses code from",
42
+ // casual/colloquial
43
+ "grabs", "grab", "pulls from", "pull from", "leans on", "lean on",
44
+ "is wired to", "are wired to", "is hooked up to", "are hooked up to",
45
+ // gerund (g-drop normalization turns dialectal "importin'" into this — §3.5)
46
+ "importing",
47
+ ],
48
+ },
49
+ calls: {
50
+ comment: "Function/Method -> Function/Class (symbol-grain) or Module -> Module (coarse): subject invokes object.",
51
+ verbs: [
52
+ // formal
53
+ "invokes", "invoke",
54
+ // neutral
55
+ "calls", "call", "runs", "run", "executes", "execute",
56
+ // casual/colloquial
57
+ "hits", "hit", "triggers", "trigger", "fires", "fire", "kicks off", "kick off",
58
+ // gerund (g-drop normalization turns dialectal "callin'" into this — §3.5)
59
+ "calling",
60
+ ],
61
+ },
62
+ defines: {
63
+ comment: "Module -> top-level Function/Class/Method/Attribute: subject declares object.",
64
+ verbs: [
65
+ "defines", "define", "declares", "declare",
66
+ "has", "have", "holds", "hold",
67
+ // gerund (g-drop normalization — §3.5)
68
+ "defining",
69
+ ],
70
+ },
71
+ contains: {
72
+ comment: "Class -> Method/Attribute: subject's membership includes object.",
73
+ verbs: [
74
+ "contains", "contain", "lives in", "live in", "is defined in", "are defined in",
75
+ "is part of", "are part of", "sits in", "sit in", "sits inside", "sit inside",
76
+ // gerund (g-drop normalization — §3.5)
77
+ "containing",
78
+ ],
79
+ },
80
+ tests: {
81
+ comment: "Module -> Module: subject is a test module importing/covering object.",
82
+ verbs: [
83
+ "tests", "test", "covers", "cover", "verifies", "verify", "exercises", "exercise",
84
+ "checks", "check", "makes sure of", "make sure of",
85
+ // gerund (g-drop normalization — §3.5)
86
+ "testing",
87
+ ],
88
+ },
89
+ inherits: {
90
+ comment: "Class -> Class: subject's declared base resolves to object (subclassOf).",
91
+ verbs: [
92
+ "inherits from", "inherit from", "extends", "extend", "subclasses", "subclass",
93
+ "derives from", "derive from", "is a subclass of", "are a subclass of",
94
+ "is a kind of", "are a kind of", "is built off", "are built off",
95
+ "is built on top of", "are built on top of",
96
+ // gerund (g-drop normalization — §3.5)
97
+ "extending",
98
+ ],
99
+ },
100
+ touches: {
101
+ comment: "Commit -> Module (coarse) or Commit -> symbol (fine, touchesSymbol): a commit's changed-line-range intersects object.",
102
+ verbs: [
103
+ "touched", "touches", "changed", "change", "modified", "modifies",
104
+ "was changed in", "were changed in", "was edited in", "were edited in",
105
+ "was modified by", "were modified by", "was tweaked in", "were tweaked in",
106
+ "got changed in", "got edited in",
107
+ // gerund (g-drop normalization — §3.5)
108
+ "touching",
109
+ ],
110
+ },
111
+ cochange: {
112
+ comment: "Module -> Module: subject and object are frequently committed together (changeCoupledWith).",
113
+ verbs: [
114
+ "changed with", "co-changes with", "co-change with", "changes alongside",
115
+ "change alongside", "shares commits with", "share commits with",
116
+ "tends to change together with", "tend to change together with",
117
+ "moves together with", "move together with",
118
+ ],
119
+ },
120
+ reexports: {
121
+ comment: "Module -> Module: subject re-exports a name it imported from object.",
122
+ verbs: [
123
+ "exports", "export", "re-exports", "re-export", "passes through", "pass through",
124
+ // gerund (g-drop normalization — §3.5)
125
+ "exporting",
126
+ ],
127
+ },
128
+ };
129
+
130
+ /** relation token -> flat verb-phrase list, the shape ask.mjs's VERB_TO_KIND
131
+ * table needs (phrase -> kind), derived once from RELATIONS. */
132
+ export const VERB_TO_KIND = Object.freeze(
133
+ Object.fromEntries(
134
+ Object.entries(RELATIONS).flatMap(([kind, { verbs }]) => verbs.map((v) => [v, kind])),
135
+ ),
136
+ );
137
+
138
+ export const ENTITY_TO_TYPE = Object.freeze({
139
+ function: "Function", functions: "Function",
140
+ method: "Method", methods: "Method",
141
+ class: "Class", classes: "Class",
142
+ module: "Module", modules: "Module", file: "Module", files: "Module",
143
+ attribute: "Attribute", attributes: "Attribute", field: "Attribute", fields: "Attribute",
144
+ variable: "GlobalVariable", variables: "GlobalVariable", global: "GlobalVariable", globals: "GlobalVariable",
145
+ commit: "Commit", commits: "Commit",
146
+ });
147
+
148
+ export const MODIFIER_TO_KIND = Object.freeze({
149
+ explicitly: "direct", directly: "direct",
150
+ transitively: "transitive", indirectly: "transitive",
151
+ });
152
+
153
+ // ---- §3.5 normalization — contractions/informal spellings that would otherwise
154
+ // block a match, expanded BEFORE parsing (BOTH the anchored-template strategy
155
+ // and the independent keyword-spotting strategy see the same normalized text —
156
+ // this table is not owned by either). Small and code-question-scoped, not a
157
+ // general slang dictionary — every entry here exists because it appears in an
158
+ // actual worked example this file's matcher must handle. ----
159
+ export const CONTRACTIONS = Object.freeze({
160
+ "ain't": "is not", "aint": "is not",
161
+ "isn't": "is not", "isnt": "is not",
162
+ "aren't": "are not", "arent": "are not",
163
+ "doesn't": "does not", "doesnt": "does not",
164
+ "don't": "do not", "dont": "do not",
165
+ "didn't": "did not", "didnt": "did not",
166
+ "there's": "there is", "theres": "there is",
167
+ "what's": "what is", "whats": "what is",
168
+ "who's": "who is", "whos": "who is",
169
+ "gonna": "going to",
170
+ "wanna": "want to",
171
+ "gotta": "got to",
172
+ "yer": "your", "ur": "your",
173
+ "gimme": "give me",
174
+ });
175
+
176
+ /** Trailing g-drop ("callin'", "hittin'") — a plain suffix restore, not a
177
+ * lemmatiser: -in' -> -ing, applied per-word during normalization. The
178
+ * apostrophe is REQUIRED (not optional): bare "-in" endings with no
179
+ * apostrophe are real English words far more often than dropped g's
180
+ * ("cabin", "robin", "chin", "twin") — restoring those would be a false
181
+ * positive the anchored templates never had to worry about. No trailing
182
+ * `\b` after the apostrophe: `'` is a non-word character, so a `\b` there
183
+ * can never match the (non-word) whitespace/end-of-string that follows —
184
+ * the apostrophe itself already delimits the word, a second boundary check
185
+ * after it is redundant and unmatchable. */
186
+ export const G_DROP = /\b([a-z]{3,})in'/gi;
187
+
188
+ /** Words stripped during normalization/keyword-spotting once they carry no
189
+ * grammatical weight for this grammar — greetings, politeness, hedges, and
190
+ * the discourse fillers a spoken-style question picks up. Never strips a
191
+ * relation verb, entity noun, or modifier (those are checked first). */
192
+ export const FILLER_WORDS = Object.freeze([
193
+ "um", "uh", "erm", "so", "like", "yo", "hey", "bru", "bro", "fam", "mate",
194
+ "please", "could you", "can you", "would you", "tell me", "i wonder",
195
+ "just wondering", "quickly", "real quick", "kinda", "sorta",
196
+ ]);
197
+
198
+ /** Deictic/pronoun terms that refer to a context entity rather than naming one
199
+ * directly — resolved against an optional `contextId` (ask.mjs §keyword-
200
+ * spotting; wired from the graph viewer's currently-selected node, when the
201
+ * chat panel is asking "about" whatever the user last clicked). With no
202
+ * context available (the bare CLI/MCP surface), these produce an honest
203
+ * "which node does 'this' refer to?" miss — never a guess. */
204
+ export const CONTEXT_PRONOUNS = Object.freeze(["this", "it", "that", "here", "this one", "that one"]);
205
+
206
+ // ---- §3.6 negation frames — a SMALL, pattern-based set of recognized
207
+ // double-negative / negative-rhetorical constructions, normalized to the
208
+ // affirmative form of the SAME question (not a general negation-scope parser
209
+ // — ELIZA/PARRY's own scope discipline: a handful of recognized frames, not
210
+ // an attempt at full logical negation handling). Each frame is tried in
211
+ // order; the first match rewrites the whole string and normalization stops
212
+ // (a second negation frame firing on an already-rewritten string would be a
213
+ // design smell, not a feature). ----
214
+ export const NEGATION_FRAMES = Object.freeze([
215
+ // "there ain't nothin' calling it" / "there isn't nothing that calls it"
216
+ // -> "what calls it"
217
+ { re: /\bthere\s+is\s+not\s+(?:anything|nothing)\s+(?:that\s+)?(\S.*)$/i, to: (m) => `what ${m[1]}` },
218
+ // "there's no module that imports auth" -> "what imports auth"
219
+ { re: /\bthere\s+is\s+no\s+\S+\s+(?:that\s+)?(\S.*)$/i, to: (m) => `what ${m[1]}` },
220
+ // "isn't there anything calling it" -> "what calls it" (question-inverted form)
221
+ { re: /\bis\s+not\s+there\s+(?:anything|nothing)\s+(?:that\s+)?(\S.*)$/i, to: (m) => `what ${m[1]}` },
222
+ // "nobody/nothing calls it, does it" (tag-question double-negative) -> "what calls it"
223
+ { re: /\b(?:nobody|nothing|no one)\s+(\S.*?)(?:,?\s+does\s+it\??|,?\s+do\s+they\??)?$/i, to: (m) => `what ${m[1]}` },
224
+ ]);
225
+
226
+ // ---- §7 meta-vocabulary — trigger phrases for the "meta" query shape (asking what a
227
+ // graph vocabulary term itself means: "what does cochange mean", "what does mgx:
228
+ // callsSymbol mean"). Deliberately NOT folded into RELATIONS/VERB_TO_KIND: these
229
+ // phrases don't name a graph relation kind for edgesOfKind to traverse, they name
230
+ // "explain this term" intent. Adding them to VERB_TO_KIND would make
231
+ // parseKeywordSpot's decomposition matcher independently derive a *different* shape
232
+ // ("forward", the only shape its before-only-text case produces) for the very same
233
+ // sentence the anchored meta template resolves to shape:"meta" — a spurious strategy
234
+ // DISAGREEMENT (ambiguousParse) on every meta question. So this table is read directly
235
+ // by ask.mjs's own meta template, never merged into the shared verb tables. ----
236
+ export const META_MEANING_VERBS = Object.freeze([
237
+ "mean", "means", "stand for", "stands for", "signify", "signifies",
238
+ "represent", "represents", "refer to", "refers to",
239
+ ]);
240
+
241
+ // ---- omitted-on-purpose (judgment calls, not oversights) -------------------
242
+ // "runs"/"executes" (calls) are common English words with many non-code
243
+ // senses — accepted anyway, formal-template AND keyword-spotting alike,
244
+ // because a misparse costs nothing beyond an honest object-miss
245
+ // (resolveObject never guesses); the risk is bounded by the render layer,
246
+ // not by narrowing the vocabulary.
247
+ // NOT added: "needs"/"wants" for imports (too weakly code-specific — "which
248
+ // modules need auth" reads as a feature request, not a graph query, in a way
249
+ // "which modules depend on auth" doesn't); "wraps"/"decorates" for calls (a
250
+ // real but distinct relationship codegraph.mjs doesn't classify separately —
251
+ // adding the phrase would silently misroute it onto plain call edges);
252
+ // "belongs to" for contains (already claimed by a hypothetical membership
253
+ // verb elsewhere — kept out to avoid a future collision if one is added).
254
+ // NOT added: a cochange gerund ("co-changing with") — every cochange phrase
255
+ // is already 2-3 words with its own internal "-ing"/"-s" (e.g. "changes
256
+ // alongside"), so g-drop normalization has no bare stem to dialectally
257
+ // contract in the first place; only relations with a genuinely bare-verb
258
+ // casual form ("call", "touch") needed one.