@esneiderbravo/speclaw 2.0.12 → 2.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli/commands/init.js +3 -2
- package/dist/cli/commands/lawbook.js +4 -1
- package/dist/cli/commands/query.js +2 -2
- package/dist/cli/commands/ship-on-stop.js +46 -0
- package/dist/cli/commands/ship.js +44 -0
- package/dist/cli/commands/update.js +25 -0
- package/dist/cli/index.js +6 -1
- package/dist/cli/lib/help.js +36 -4
- package/dist/modules/compass/db.js +47 -9
- package/dist/modules/compass/explore-rich.js +50 -9
- package/dist/modules/compass/extract.js +220 -0
- package/dist/modules/compass/find-output.js +172 -0
- package/dist/modules/compass/hybrid.js +149 -21
- package/dist/modules/compass/indexer.js +26 -2
- package/dist/modules/compass/query.js +48 -12
- package/dist/modules/compass/register.js +13 -7
- package/dist/modules/cortex/harness.js +17 -4
- package/dist/modules/cortex/status.js +1 -1
- package/dist/modules/foundation/assets/AGENTS.template.md +12 -11
- package/dist/modules/foundation/assets/CLAUDE.template.md +16 -12
- package/dist/modules/foundation/assets/docs/compass.template.md +11 -4
- package/dist/modules/foundation/deps.js +5 -3
- package/dist/modules/foundation/dialects/copilot.js +3 -5
- package/dist/modules/foundation/graph.js +5 -5
- package/dist/modules/foundation/hooks.js +30 -4
- package/dist/modules/foundation/laws.js +5 -0
- package/dist/modules/foundation/scaffold.js +3 -16
- package/dist/modules/lawbook/anchors.js +38 -1
- package/dist/modules/lawbook/assets/commands/cortex.md +1 -1
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +6 -0
- package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +6 -0
- package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +17 -5
- package/dist/modules/lawbook/bugfix.js +12 -4
- package/dist/modules/lawbook/change-tool.js +7 -0
- package/dist/modules/lawbook/engine.js +3 -0
- package/dist/modules/lawbook/levels.js +35 -4
- package/dist/modules/lawbook/quick.js +17 -6
- package/dist/modules/lawbook/ship.js +325 -0
- package/dist/shared/agents.js +7 -1
- package/dist/shared/mcp.js +6 -4
- package/dist/shared/output-budget.js +36 -12
- package/package.json +1 -1
- package/dist/modules/foundation/assets/workflows/speclaw.yml +0 -26
|
@@ -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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|