@esneiderbravo/speclaw 2.0.12 → 2.0.13

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.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/commands/lawbook.js +4 -1
  3. package/dist/cli/commands/query.js +2 -2
  4. package/dist/cli/commands/ship-on-stop.js +46 -0
  5. package/dist/cli/commands/ship.js +44 -0
  6. package/dist/cli/commands/update.js +25 -0
  7. package/dist/cli/index.js +6 -1
  8. package/dist/cli/lib/help.js +35 -3
  9. package/dist/modules/compass/db.js +47 -9
  10. package/dist/modules/compass/explore-rich.js +50 -9
  11. package/dist/modules/compass/extract.js +220 -0
  12. package/dist/modules/compass/find-output.js +172 -0
  13. package/dist/modules/compass/hybrid.js +149 -21
  14. package/dist/modules/compass/indexer.js +26 -2
  15. package/dist/modules/compass/query.js +48 -12
  16. package/dist/modules/compass/register.js +13 -7
  17. package/dist/modules/cortex/harness.js +17 -4
  18. package/dist/modules/cortex/status.js +1 -1
  19. package/dist/modules/foundation/assets/AGENTS.template.md +10 -9
  20. package/dist/modules/foundation/assets/CLAUDE.template.md +13 -10
  21. package/dist/modules/foundation/assets/docs/compass.template.md +11 -4
  22. package/dist/modules/foundation/deps.js +5 -3
  23. package/dist/modules/foundation/graph.js +5 -5
  24. package/dist/modules/foundation/hooks.js +30 -4
  25. package/dist/modules/foundation/laws.js +5 -0
  26. package/dist/modules/lawbook/assets/commands/cortex.md +1 -1
  27. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +6 -0
  28. package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +6 -0
  29. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +17 -5
  30. package/dist/modules/lawbook/bugfix.js +12 -4
  31. package/dist/modules/lawbook/change-tool.js +7 -0
  32. package/dist/modules/lawbook/engine.js +3 -0
  33. package/dist/modules/lawbook/levels.js +35 -4
  34. package/dist/modules/lawbook/quick.js +17 -6
  35. package/dist/modules/lawbook/ship.js +325 -0
  36. package/dist/shared/mcp.js +6 -4
  37. package/dist/shared/output-budget.js +36 -12
  38. package/package.json +1 -1
@@ -36,6 +36,176 @@ function calleeName(node, lang) {
36
36
  }
37
37
  /** Python receivers that mean "this class/instance". */
38
38
  const PY_SELF = new Set(["self", "cls"]);
39
+ /**
40
+ * Type names that never become a `ref` edge: TypeScript lib and utility types
41
+ * and JS built-in constructors (predefined types like `string` are separate
42
+ * tree-sitter nodes and never reach here; they are listed for safety).
43
+ */
44
+ const BUILTIN_TYPES = new Set([
45
+ "string",
46
+ "number",
47
+ "boolean",
48
+ "bigint",
49
+ "symbol",
50
+ "object",
51
+ "any",
52
+ "unknown",
53
+ "never",
54
+ "void",
55
+ "undefined",
56
+ "null",
57
+ "Array",
58
+ "ReadonlyArray",
59
+ "ArrayLike",
60
+ "Promise",
61
+ "PromiseLike",
62
+ "Awaited",
63
+ "Record",
64
+ "Partial",
65
+ "Required",
66
+ "Readonly",
67
+ "Pick",
68
+ "Omit",
69
+ "Exclude",
70
+ "Extract",
71
+ "NonNullable",
72
+ "ReturnType",
73
+ "Parameters",
74
+ "ConstructorParameters",
75
+ "InstanceType",
76
+ "ThisType",
77
+ "ThisParameterType",
78
+ "OmitThisParameter",
79
+ "Uppercase",
80
+ "Lowercase",
81
+ "Capitalize",
82
+ "Uncapitalize",
83
+ "Map",
84
+ "Set",
85
+ "WeakMap",
86
+ "WeakSet",
87
+ "WeakRef",
88
+ "ReadonlyMap",
89
+ "ReadonlySet",
90
+ "Date",
91
+ "RegExp",
92
+ "Error",
93
+ "TypeError",
94
+ "RangeError",
95
+ "Function",
96
+ "Object",
97
+ "String",
98
+ "Number",
99
+ "Boolean",
100
+ "Symbol",
101
+ "BigInt",
102
+ "Iterable",
103
+ "Iterator",
104
+ "IterableIterator",
105
+ "AsyncIterable",
106
+ "AsyncIterator",
107
+ "AsyncIterableIterator",
108
+ "Generator",
109
+ "AsyncGenerator",
110
+ "PropertyKey",
111
+ "JSON",
112
+ "Math",
113
+ "ArrayBuffer",
114
+ "SharedArrayBuffer",
115
+ "DataView",
116
+ "Int8Array",
117
+ "Uint8Array",
118
+ "Uint8ClampedArray",
119
+ "Int16Array",
120
+ "Uint16Array",
121
+ "Int32Array",
122
+ "Uint32Array",
123
+ "Float32Array",
124
+ "Float64Array",
125
+ "BigInt64Array",
126
+ "BigUint64Array",
127
+ "TemplateStringsArray",
128
+ ]);
129
+ /** AST nodes whose subtree names types: annotations and heritage clauses. */
130
+ const TYPE_REF_NODES = new Set([
131
+ "type_annotation",
132
+ "extends_type_clause",
133
+ "implements_clause",
134
+ "extends_clause",
135
+ ]);
136
+ /** `Type` of a qualified `ns.Type` / `a.b.Type` node, with its qualifier. */
137
+ function qualifiedTypeName(node, nameType) {
138
+ let name = null;
139
+ let module = null;
140
+ for (let i = 0; i < node.childCount; i++) {
141
+ const c = node.child(i);
142
+ if (!c)
143
+ continue;
144
+ if (c.type === nameType)
145
+ name = c;
146
+ else if (module === null && c.type !== ".")
147
+ module = c;
148
+ }
149
+ if (!name)
150
+ return null;
151
+ return {
152
+ name: name.text,
153
+ line: name.startPosition.row + 1,
154
+ pos: name.startIndex,
155
+ qualifier: module?.type === "identifier" ? module.text : "",
156
+ };
157
+ }
158
+ /** Collect every type named inside a type subtree (generic arguments included). */
159
+ function typeNamesIn(node, out) {
160
+ if (node.type === "type_identifier") {
161
+ out.push({
162
+ name: node.text,
163
+ line: node.startPosition.row + 1,
164
+ pos: node.startIndex,
165
+ qualifier: null,
166
+ });
167
+ return;
168
+ }
169
+ if (node.type === "nested_type_identifier") {
170
+ const q = qualifiedTypeName(node, "type_identifier");
171
+ if (q)
172
+ out.push(q);
173
+ return;
174
+ }
175
+ for (let i = 0; i < node.childCount; i++) {
176
+ const c = node.child(i);
177
+ if (c)
178
+ typeNamesIn(c, out);
179
+ }
180
+ }
181
+ /**
182
+ * Types a heritage or annotation node names. A class `extends` value is an
183
+ * expression (`Base`, `ns.Base`), not a type node, so it is read directly.
184
+ */
185
+ function typeRefsOf(node) {
186
+ const out = [];
187
+ if (node.type !== "extends_clause" && node.type !== "class_heritage") {
188
+ typeNamesIn(node, out);
189
+ return out;
190
+ }
191
+ for (let i = 0; i < node.childCount; i++) {
192
+ const c = node.child(i);
193
+ if (!c)
194
+ continue;
195
+ if (c.type === "identifier") {
196
+ out.push({ name: c.text, line: c.startPosition.row + 1, pos: c.startIndex, qualifier: null });
197
+ }
198
+ else if (c.type === "member_expression") {
199
+ const q = qualifiedTypeName(c, "property_identifier");
200
+ if (q)
201
+ out.push(q);
202
+ }
203
+ else if (c.type === "type_arguments") {
204
+ typeNamesIn(c, out);
205
+ }
206
+ }
207
+ return out;
208
+ }
39
209
  /**
40
210
  * The receiver of a member call (`recv.f()`), or null for a plain call.
41
211
  * `kind` is `self` for `this`/`super`/`self`/`cls`, `ident` for a bare
@@ -339,6 +509,12 @@ export async function extract(source, lang) {
339
509
  // binding's import (matched by `spec`) resolved to a project file.
340
510
  const identReceivers = [];
341
511
  const bareCalls = [];
512
+ // Type references: one per (owner, name); qualified ones are classified like
513
+ // member calls once the import bindings are known. A bare name inside the
514
+ // declaration that introduces a same-named type parameter (`<Props>`) is that
515
+ // parameter and is dropped; the same name elsewhere in the file is kept.
516
+ const typeNames = [];
517
+ const typeParamScopes = [];
342
518
  const bindings = new Set();
343
519
  // JS/TS import binding → its import's module specifier.
344
520
  const bindingSpecs = new Map();
@@ -410,6 +586,25 @@ export async function extract(source, lang) {
410
586
  spec,
411
587
  });
412
588
  }
589
+ else if (lang.id !== "python" &&
590
+ (TYPE_REF_NODES.has(node.type) ||
591
+ (lang.id === "javascript" && node.type === "class_heritage"))) {
592
+ // Covers: req~type-ref-edges~1
593
+ for (const t of typeRefsOf(node)) {
594
+ if (t.qualifier === null && BUILTIN_TYPES.has(t.name))
595
+ continue;
596
+ typeNames.push({ t, ownerIndex });
597
+ }
598
+ }
599
+ else if (node.type === "type_parameter") {
600
+ // Scope: the declaration owning the `<…>` list (function, method, arrow,
601
+ // class, interface, type alias, function type), never the whole file.
602
+ const name = node.child(0);
603
+ const scope = node.parent?.parent ?? node.parent;
604
+ if (name?.type === "type_identifier" && scope) {
605
+ typeParamScopes.push({ name: name.text, start: scope.startIndex, end: scope.endIndex });
606
+ }
607
+ }
413
608
  else if (COMMENT_TYPES.has(node.type)) {
414
609
  rawCoverage.push(...parseCoverageComment(node, ownerIndex));
415
610
  }
@@ -433,5 +628,30 @@ export async function extract(source, lang) {
433
628
  }
434
629
  for (const ref of bareCalls)
435
630
  ref.spec = bindingSpecs.get(ref.name) ?? null;
631
+ // A bare type name carries its import binding's spec (resolved like a bare
632
+ // call); `ns.Type` on an import binding is a member ref (2), else foreign (1).
633
+ // Scoped type parameters are filtered before de-duplication, so a dropped
634
+ // parameter never hides a real reference of the same owner.
635
+ const typeRefKeys = new Set();
636
+ for (const { t, ownerIndex } of typeNames) {
637
+ if (t.qualifier === null &&
638
+ typeParamScopes.some((p) => p.name === t.name && p.start <= t.pos && t.pos < p.end)) {
639
+ continue;
640
+ }
641
+ const key = `${ownerIndex ?? "file"}:${t.name}`;
642
+ if (typeRefKeys.has(key))
643
+ continue;
644
+ typeRefKeys.add(key);
645
+ let member = 0;
646
+ let spec;
647
+ if (t.qualifier === null) {
648
+ spec = bindingSpecs.get(t.name) ?? null;
649
+ }
650
+ else {
651
+ spec = t.qualifier ? (bindingSpecs.get(t.qualifier) ?? null) : null;
652
+ member = spec !== null ? 2 : 1;
653
+ }
654
+ refs.push({ name: t.name, kind: "ref", line: t.line, ownerIndex, member, spec });
655
+ }
436
656
  return { symbols, refs, coverage: attachCoverage(rawCoverage, symbols), reexports };
437
657
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * MCP response of `compass_find` (and its `compass_search` / `compass_recall`
3
+ * aliases): compact hits and one JSON document capped as a whole at the
4
+ * caller's token budget. The CLI keeps printing `HybridSearchResult` as is.
5
+ */
6
+ import { renderTreeContext } from "./budget.js";
7
+ import { estimateTokens } from "../../shared/output-budget.js";
8
+ function compact(h) {
9
+ return { name: h.name, kind: h.kind, file: h.file, line: h.line };
10
+ }
11
+ /** Rendered blocks for the first `count` hits, the same TreeContext the search produced. */
12
+ function renderHits(hits, count) {
13
+ return renderTreeContext(hits.slice(0, count));
14
+ }
15
+ /**
16
+ * Serialise `response` behind `prefix`, writing `tokens` last so it equals the
17
+ * estimate of the whole emitted text (at most a few passes until the digit
18
+ * count is stable).
19
+ */
20
+ function emit(prefix, response) {
21
+ response.tokens = 0;
22
+ let out = prefix + JSON.stringify(response);
23
+ for (let i = 0; i < 4; i++) {
24
+ const t = estimateTokens(out);
25
+ if (t === response.tokens)
26
+ break;
27
+ response.tokens = t;
28
+ out = prefix + JSON.stringify(response);
29
+ }
30
+ return out;
31
+ }
32
+ /** Cut `rendered` to about `chars` characters at a line break, marking the elision. */
33
+ function shortenRendered(rendered, chars) {
34
+ if (chars <= 0)
35
+ return "";
36
+ if (rendered.length <= chars)
37
+ return rendered;
38
+ const head = rendered.slice(0, chars);
39
+ const cut = head.lastIndexOf("\n");
40
+ return `${cut > 0 ? head.slice(0, cut) : head}\n⋮`;
41
+ }
42
+ /**
43
+ * Largest `k` in `[0, len]` for which `fitsAt(k)` holds, assuming fitting is
44
+ * monotone in `k` (a shorter list never makes the text longer); `-1` when even
45
+ * an empty list does not fit.
46
+ */
47
+ function largestFitting(len, fitsAt) {
48
+ if (!fitsAt(0))
49
+ return -1;
50
+ let lo = 0;
51
+ let hi = len;
52
+ while (lo < hi) {
53
+ const mid = Math.ceil((lo + hi) / 2);
54
+ if (fitsAt(mid))
55
+ lo = mid;
56
+ else
57
+ hi = mid - 1;
58
+ }
59
+ return lo;
60
+ }
61
+ /**
62
+ * Format a find result as the exact text `compass_find` emits: one JSON
63
+ * document (no indentation) whose estimated tokens never exceed the cap.
64
+ * Over the cap it first trims `focusIgnored`, `focus`, and `terms` from the
65
+ * end (each trimmed list reports its original length in a `…Total` field),
66
+ * then drops the lowest-ranked hits together with their rendered blocks, then
67
+ * shortens `rendered` of the last hit, then drops `nearest` entries from the
68
+ * end. `truncated` is set when the search or the formatter removed
69
+ * anything, so the cap holds and the text parses as JSON for any input.
70
+ *
71
+ * @param result - Output of {@link findSymbols}.
72
+ * @param opts - `prefix` is emitted before the JSON (alias deprecation notice)
73
+ * and counts toward the cap and `tokens`.
74
+ * @returns The text to pass to `text(str, { maxTokens: result.cap })`.
75
+ */
76
+ // Covers: req~find-response-budget~1
77
+ export function formatFindResponse(result, opts = {}) {
78
+ const prefix = opts.prefix ?? "";
79
+ const cap = result.cap;
80
+ let count = result.hits.length;
81
+ let rendered = result.rendered;
82
+ let nearest = result.nearest ? result.nearest.map(compact) : undefined;
83
+ let truncated = result.capped;
84
+ const terms = result.terms ?? [];
85
+ // Kept lengths of the context lists; trimmed before any hit.
86
+ let termsKept = terms.length;
87
+ let focusKept = result.focus.length;
88
+ let ignoredKept = result.focusIgnored.length;
89
+ const build = () => ({
90
+ mode: result.mode,
91
+ ...(result.mode === "exact"
92
+ ? {
93
+ found: result.found ?? false,
94
+ terms: terms.slice(0, termsKept),
95
+ ...(termsKept < terms.length ? { termsTotal: terms.length } : {}),
96
+ }
97
+ : {}),
98
+ rendered,
99
+ hits: result.hits.slice(0, count).map(compact),
100
+ ...(nearest ? { nearest } : {}),
101
+ focus: result.focus.slice(0, focusKept),
102
+ ...(focusKept < result.focus.length ? { focusTotal: result.focus.length } : {}),
103
+ ...(result.focusIgnored.length > 0
104
+ ? { focusIgnored: result.focusIgnored.slice(0, ignoredKept) }
105
+ : {}),
106
+ ...(ignoredKept < result.focusIgnored.length
107
+ ? { focusIgnoredTotal: result.focusIgnored.length }
108
+ : {}),
109
+ ...(result.degraded.length > 0 ? { degraded: result.degraded } : {}),
110
+ tokens: 0,
111
+ budget: cap,
112
+ ...(truncated ? { truncated: true } : {}),
113
+ });
114
+ const fits = () => {
115
+ const out = emit(prefix, build());
116
+ return estimateTokens(out) <= cap ? out : null;
117
+ };
118
+ let out = fits();
119
+ // 1. Trim the context lists from the end: focusIgnored, focus, then terms.
120
+ // They matter less than the payload, so they go before any hit. Each list is
121
+ // cut to the longest prefix that fits (binary search, so a worktree with
122
+ // thousands of changes costs O(log n) serialisations), or emptied.
123
+ const lists = [
124
+ { len: result.focusIgnored.length, set: (k) => (ignoredKept = k) },
125
+ { len: result.focus.length, set: (k) => (focusKept = k) },
126
+ { len: terms.length, set: (k) => (termsKept = k) },
127
+ ];
128
+ for (const list of lists) {
129
+ if (out || list.len === 0)
130
+ continue;
131
+ truncated = true;
132
+ const k = largestFitting(list.len, (n) => {
133
+ list.set(n);
134
+ return fits() !== null;
135
+ });
136
+ list.set(Math.max(k, 0));
137
+ out = fits();
138
+ }
139
+ // 2. Drop the lowest-ranked hits with their rendered blocks.
140
+ while (!out && count > 1) {
141
+ count--;
142
+ rendered = renderHits(result.hits, count);
143
+ truncated = true;
144
+ out = fits();
145
+ }
146
+ // 3. One hit left: shorten its rendered block.
147
+ if (!out && rendered) {
148
+ truncated = true;
149
+ rendered = "";
150
+ const room = (cap - estimateTokens(emit(prefix, build()))) * 4 - 8;
151
+ rendered = shortenRendered(renderHits(result.hits, count), room);
152
+ out = fits();
153
+ if (!out) {
154
+ rendered = "";
155
+ out = fits();
156
+ }
157
+ }
158
+ // 4. Drop nearest entries from the end.
159
+ while (!out && nearest && nearest.length > 0) {
160
+ nearest = nearest.slice(0, -1);
161
+ truncated = true;
162
+ out = fits();
163
+ }
164
+ // 5. Last resort (a prefix or single hit larger than the cap): drop the hit.
165
+ if (!out && count > 0) {
166
+ count = 0;
167
+ rendered = "";
168
+ truncated = true;
169
+ out = fits();
170
+ }
171
+ return out ?? emit(prefix, build());
172
+ }
@@ -2,8 +2,9 @@
2
2
  * Hybrid retrieval pipeline: BM25 + KNN + name → RRF → ego expand →
3
3
  * personalized PageRank → structural rerank → token budget.
4
4
  */
5
+ import path from "node:path";
5
6
  import { openDb, indexExists, ftsAvailable, FILE_NODE_KIND } from "./db.js";
6
- import { getEmbedder, fromBlob, cosine } from "./embedder.js";
7
+ import { getEmbedder, fromBlob, cosine, tokenize } from "./embedder.js";
7
8
  import { isGitRepo, worktreeChangedFiles } from "../../shared/git.js";
8
9
  import { escapeFtsQuery, nameBoost, routeWeights, rrfFuse, structuralScore, MAX_DEGREE, isSymbolQuery, } from "./rank.js";
9
10
  import { personalizedPageRank, edgeWeightMul } from "./pagerank.js";
@@ -13,18 +14,48 @@ function requireIndex(projectPath) {
13
14
  throw new Error("No index found. Build it first with the index_build tool (creates .speclaw/index.db).");
14
15
  }
15
16
  }
17
+ /** Repo-relative, slash-separated form of a focus path (absolute paths under the root too). */
18
+ function normaliseFocusPath(projectPath, raw) {
19
+ let p = raw.trim().split("\\").join("/");
20
+ if (path.isAbsolute(p)) {
21
+ const rel = path.relative(projectPath, p).split(path.sep).join("/");
22
+ if (!rel.startsWith("..") && !path.isAbsolute(rel))
23
+ p = rel;
24
+ }
25
+ while (p.startsWith("./"))
26
+ p = p.slice(2);
27
+ return p;
28
+ }
16
29
  /**
17
- * Resolve focus paths: explicit list, else worktree changes, else empty.
30
+ * Resolve the focus set against the index: the explicit list, else the git
31
+ * working-tree changes, normalised and kept only when present in `files`.
32
+ * Dropped inputs are reported as given. Non-git with no explicit focus → empty.
18
33
  *
34
+ * @param db - Open index database.
19
35
  * @param projectPath - Project root.
20
36
  * @param focus - Optional explicit paths.
21
37
  */
22
- export function resolveFocus(projectPath, focus) {
23
- if (focus && focus.length > 0)
24
- return [...new Set(focus)];
25
- if (!isGitRepo(projectPath))
26
- return [];
27
- return worktreeChangedFiles(projectPath);
38
+ // Covers: req~task-relative-ranking~1
39
+ export function resolveSearchFocus(db, projectPath, focus) {
40
+ const inputs = focus && focus.length > 0
41
+ ? focus
42
+ : isGitRepo(projectPath)
43
+ ? worktreeChangedFiles(projectPath)
44
+ : [];
45
+ const has = db.prepare("SELECT 1 AS ok FROM files WHERE path = ?");
46
+ const kept = [];
47
+ const ignored = [];
48
+ for (const raw of inputs) {
49
+ const p = normaliseFocusPath(projectPath, raw);
50
+ if (p && has.get(p)) {
51
+ if (!kept.includes(p))
52
+ kept.push(p);
53
+ }
54
+ else if (!ignored.includes(raw)) {
55
+ ignored.push(raw);
56
+ }
57
+ }
58
+ return { focus: kept, ignored };
28
59
  }
29
60
  /**
30
61
  * Hybrid code search over the local Compass index.
@@ -37,8 +68,6 @@ export async function hybridSearch(projectPath, query, opts = {}) {
37
68
  requireIndex(projectPath);
38
69
  const degraded = [];
39
70
  const q = query.trim();
40
- const focus = resolveFocus(projectPath, opts.focus);
41
- const focusSet = new Set(focus);
42
71
  const mode = opts.mode;
43
72
  const route = mode === "exact"
44
73
  ? "symbol"
@@ -53,10 +82,14 @@ export async function hybridSearch(projectPath, query, opts = {}) {
53
82
  weights.knn = 1.0;
54
83
  weights.name = 0.5;
55
84
  }
56
- const budget = opts.maxTokens ?? defaultBudget(focus.length > 0);
57
85
  const seedLimit = opts.seedLimit ?? 50;
86
+ const exactNames = opts.exactNames ? [...new Set(opts.exactNames)] : null;
58
87
  const db = openDb(projectPath);
59
88
  try {
89
+ const resolved = opts.resolvedFocus ?? resolveSearchFocus(db, projectPath, opts.focus);
90
+ const focus = resolved.focus;
91
+ const focusSet = new Set(focus);
92
+ const budget = opts.maxTokens ?? defaultBudget(focus.length > 0);
60
93
  const hasFts = ftsAvailable(db);
61
94
  if (!hasFts)
62
95
  degraded.push("fts5-unavailable");
@@ -109,6 +142,23 @@ export async function hybridSearch(projectPath, query, opts = {}) {
109
142
  }
110
143
  const nameIds = [];
111
144
  const nameRank = new Map();
145
+ // Covers: req~find-exact-not-found~1
146
+ // Exact mode: every symbol named exactly a term joins the name list first,
147
+ // so the full-text AND of a multi-term query cannot drop one of them.
148
+ const exactIds = new Set();
149
+ if (exactNames && exactNames.length > 0) {
150
+ const rows = db
151
+ .prepare(`SELECT n.id FROM nodes n
152
+ WHERE n.name IN (${exactNames.map(() => "?").join(",")})
153
+ AND n.kind <> '${FILE_NODE_KIND}'
154
+ ORDER BY n.id`)
155
+ .all(...exactNames);
156
+ for (const r of rows) {
157
+ exactIds.add(r.id);
158
+ nameIds.push(r.id);
159
+ nameRank.set(r.id, nameIds.length);
160
+ }
161
+ }
112
162
  if (q) {
113
163
  const likeRows = db
114
164
  .prepare(`SELECT n.id
@@ -117,10 +167,12 @@ export async function hybridSearch(projectPath, query, opts = {}) {
117
167
  ORDER BY (n.name = ?) DESC, length(n.name) ASC
118
168
  LIMIT 20`)
119
169
  .all(`%${q}%`, q);
120
- likeRows.forEach((r, i) => {
170
+ for (const r of likeRows) {
171
+ if (nameRank.has(r.id))
172
+ continue;
121
173
  nameIds.push(r.id);
122
- nameRank.set(r.id, i + 1);
123
- });
174
+ nameRank.set(r.id, nameIds.length);
175
+ }
124
176
  }
125
177
  const fused = rrfFuse({ bm25: bm25Ids, knn: knnIds, name: nameIds }, { bm25: weights.bm25, knn: weights.knn, name: weights.name });
126
178
  // Apply name boost using node names.
@@ -145,6 +197,11 @@ export async function hybridSearch(projectPath, query, opts = {}) {
145
197
  }
146
198
  }
147
199
  let seeds = [...fused.entries()].sort((a, b) => b[1] - a[1]).slice(0, seedLimit);
200
+ // An exact-named symbol is always seeded, however crowded the fused list.
201
+ for (const id of exactIds) {
202
+ if (!seeds.some(([s]) => s === id))
203
+ seeds.push([id, fused.get(id) ?? 0]);
204
+ }
148
205
  // Empty / stopword query: top by global pagerank in focus (or global).
149
206
  if (!q || seeds.length === 0) {
150
207
  const rows = focus.length > 0
@@ -292,13 +349,14 @@ export async function hybridSearch(projectPath, query, opts = {}) {
292
349
  };
293
350
  })
294
351
  .sort((a, b) => b.signals.score - a.signals.score);
295
- if (focus.length > 0) {
296
- const known = new Set(files.map((f) => f.path));
297
- if (focus.every((p) => !known.has(p))) {
298
- degraded.push("focus-unindexed");
299
- }
352
+ if (focus.length === 0 && resolved.ignored.length > 0)
353
+ degraded.push("focus-unindexed");
354
+ const kept = exactNames ? ranked.filter((h) => exactNames.includes(h.name)) : ranked;
355
+ if (opts.report) {
356
+ opts.report.ranked = kept.length;
357
+ opts.report.knnIds = [...knnIds];
300
358
  }
301
- const budgetHits = ranked.map((h) => ({
359
+ const budgetHits = kept.map((h) => ({
302
360
  name: h.name,
303
361
  kind: h.kind,
304
362
  file: h.file,
@@ -306,7 +364,7 @@ export async function hybridSearch(projectPath, query, opts = {}) {
306
364
  signature: h.signature,
307
365
  }));
308
366
  const fitted = fitToBudget(budgetHits, budget);
309
- const finalHits = ranked.slice(0, fitted.hitCount);
367
+ const finalHits = kept.slice(0, fitted.hitCount);
310
368
  return {
311
369
  rendered: fitted.rendered,
312
370
  tokens: fitted.tokens,
@@ -321,3 +379,73 @@ export async function hybridSearch(projectPath, query, opts = {}) {
321
379
  db.close();
322
380
  }
323
381
  }
382
+ /**
383
+ * Identifier terms of an exact-mode query: split on whitespace, commas, `|`,
384
+ * and `;`, keep identifier-shaped tokens (dotted names allowed), de-duplicate
385
+ * in order.
386
+ *
387
+ * @param query - Raw query text.
388
+ */
389
+ // Covers: req~find-exact-not-found~1
390
+ export function exactTerms(query) {
391
+ const out = [];
392
+ for (const tok of query.split(/[\s,|;]+/)) {
393
+ if (/^[A-Za-z_$][\w$.]*$/.test(tok) && !out.includes(tok))
394
+ out.push(tok);
395
+ }
396
+ return out;
397
+ }
398
+ const NEAREST_LIMIT = 5;
399
+ /**
400
+ * Up to five symbols near names that do not exist, de-duplicated by node and
401
+ * never file-owner nodes: indexed names (≥ 3 chars) contained in a term,
402
+ * longest first; then case-insensitive equality, then the most shared
403
+ * camelCase/snake subtokens; vector neighbours last.
404
+ *
405
+ * @param projectPath - Indexed project root.
406
+ * @param terms - The exact-mode terms that matched no symbol.
407
+ * @param knnIds - Vector-list node ids from the search, best first.
408
+ */
409
+ // Covers: req~find-exact-not-found~1
410
+ export function nearestSymbols(projectPath, terms, knnIds) {
411
+ const db = openDb(projectPath);
412
+ try {
413
+ const rows = db
414
+ .prepare(`SELECT n.id, n.name, n.kind, f.path AS file, n.start_line AS line
415
+ FROM nodes n JOIN files f ON f.id = n.file_id
416
+ WHERE n.kind <> '${FILE_NODE_KIND}'
417
+ ORDER BY n.id`)
418
+ .all();
419
+ const out = [];
420
+ const seen = new Set();
421
+ const take = (list) => {
422
+ for (const r of list) {
423
+ if (out.length >= NEAREST_LIMIT)
424
+ return;
425
+ if (seen.has(r.id))
426
+ continue;
427
+ seen.add(r.id);
428
+ out.push(r);
429
+ }
430
+ };
431
+ take(rows
432
+ .filter((r) => r.name.length >= 3 && terms.some((t) => t !== r.name && t.includes(r.name)))
433
+ .sort((a, b) => b.name.length - a.name.length || a.id - b.id));
434
+ const lowered = terms.map((t) => t.toLowerCase());
435
+ take(rows.filter((r) => lowered.includes(r.name.toLowerCase())));
436
+ const termTokens = new Set(terms.flatMap((t) => tokenize(t)));
437
+ if (termTokens.size > 0) {
438
+ take(rows
439
+ .map((r) => ({ r, shared: tokenize(r.name).filter((t) => termTokens.has(t)).length }))
440
+ .filter((x) => x.shared > 0)
441
+ .sort((a, b) => b.shared - a.shared || a.r.id - b.r.id)
442
+ .map((x) => x.r));
443
+ }
444
+ const byId = new Map(rows.map((r) => [r.id, r]));
445
+ take(knnIds.map((id) => byId.get(id)).filter((r) => !!r));
446
+ return out.map(({ name, kind, file, line }) => ({ name, kind, file, line }));
447
+ }
448
+ finally {
449
+ db.close();
450
+ }
451
+ }