@esneiderbravo/speclaw 2.0.9 → 2.0.11
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 +6 -2
- package/dist/cli/commands/query.js +12 -2
- package/dist/cli/commands/reindex-file.js +146 -0
- package/dist/cli/commands/update.js +32 -0
- package/dist/cli/index.js +7 -3
- package/dist/cli/lib/help.js +19 -0
- package/dist/modules/compass/affected-config.js +5 -0
- package/dist/modules/compass/affected.js +344 -27
- package/dist/modules/compass/db.js +149 -16
- package/dist/modules/compass/diff-context.js +4 -2
- package/dist/modules/compass/explore-rich.js +14 -7
- package/dist/modules/compass/extract.js +163 -4
- package/dist/modules/compass/hotspots.js +11 -6
- package/dist/modules/compass/hybrid.js +9 -4
- package/dist/modules/compass/indexer.js +898 -202
- package/dist/modules/compass/map.js +2 -2
- package/dist/modules/compass/query.js +70 -24
- package/dist/modules/compass/register.js +1 -1
- package/dist/modules/compass/visualize.js +5 -3
- package/dist/modules/foundation/assets/docs/compass.template.md +22 -2
- package/dist/modules/foundation/hooks.js +48 -10
- package/dist/modules/lawbook/drift.js +2 -2
- package/dist/modules/lawbook/investigate.js +2 -1
- package/dist/shared/output-budget.js +2 -0
- package/dist/shared/paths.js +24 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { openDb } from "./db.js";
|
|
3
|
+
import { openDb, FILE_NODE_KIND } from "./db.js";
|
|
4
4
|
import { estimateTokens } from "../../shared/tokens.js";
|
|
5
5
|
import { loadDeclaredBudget } from "../../shared/exposure.js";
|
|
6
6
|
export const MAP_START = "<!-- speclaw:map:start -->";
|
|
@@ -19,7 +19,7 @@ export function generateCompactMap(projectPath) {
|
|
|
19
19
|
const db = openDb(projectPath);
|
|
20
20
|
try {
|
|
21
21
|
const fileCount = db.prepare("SELECT COUNT(*) AS c FROM files").get().c;
|
|
22
|
-
const nodeCount = db.prepare(
|
|
22
|
+
const nodeCount = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`).get().c;
|
|
23
23
|
if (fileCount === 0)
|
|
24
24
|
return null;
|
|
25
25
|
const hubs = db
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { openDb, indexExists } from "./db.js";
|
|
3
|
+
import { openDb, indexExists, FILE_NODE_KIND } from "./db.js";
|
|
4
|
+
import { BUILTIN_SQL_LIST } from "./indexer.js";
|
|
4
5
|
import { getEmbedder, fromBlob, cosine } from "./embedder.js";
|
|
5
6
|
import { loadAffectedConfig, filterFilesForTarget, matchGlobalFiles } from "./affected-config.js";
|
|
6
7
|
function requireIndex(projectPath) {
|
|
@@ -32,8 +33,8 @@ export function search(projectPath, query, limit = 25) {
|
|
|
32
33
|
const rows = db
|
|
33
34
|
.prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
|
|
34
35
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
35
|
-
WHERE s.
|
|
36
|
-
|
|
36
|
+
WHERE s.kind <> '${FILE_NODE_KIND}'
|
|
37
|
+
AND (s.name LIKE ? ESCAPE '\\' OR (? != '' AND f.path LIKE ? ESCAPE '\\'))
|
|
37
38
|
ORDER BY (s.name = ?) DESC, length(s.name) ASC, f.path ASC, s.start_line ASC
|
|
38
39
|
LIMIT ?`)
|
|
39
40
|
.all(`%${escapeLike(query)}%`, normalized, pathPattern, query, limit);
|
|
@@ -129,15 +130,18 @@ function resolveIndexedFile(db, projectPath, query) {
|
|
|
129
130
|
return { status: "ambiguous", basename: normalized, files: suffix };
|
|
130
131
|
return { status: "none" };
|
|
131
132
|
}
|
|
132
|
-
/**
|
|
133
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Nodes in one indexed file, ordered by source position: the visible
|
|
135
|
+
* definitions, or with `fileNode` only its file-owner node.
|
|
136
|
+
*/
|
|
137
|
+
function fileSymbols(db, file, fileNode = false) {
|
|
134
138
|
return db
|
|
135
139
|
.prepare(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.start_byte, s.end_byte,
|
|
136
140
|
s.signature, f.path AS file
|
|
137
141
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
138
|
-
WHERE f.path = ?
|
|
142
|
+
WHERE f.path = ? AND (s.kind = '${FILE_NODE_KIND}') = ?
|
|
139
143
|
ORDER BY s.start_line ASC, s.id ASC`)
|
|
140
|
-
.all(file);
|
|
144
|
+
.all(file, fileNode ? 1 : 0);
|
|
141
145
|
}
|
|
142
146
|
/** Symbols defined in `files`, stable across files. Empty when none exist. */
|
|
143
147
|
function symbolsInFiles(db, files) {
|
|
@@ -147,7 +151,7 @@ function symbolsInFiles(db, files) {
|
|
|
147
151
|
return db
|
|
148
152
|
.prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
|
|
149
153
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
150
|
-
WHERE f.path IN (${placeholders})
|
|
154
|
+
WHERE f.path IN (${placeholders}) AND s.kind <> '${FILE_NODE_KIND}'
|
|
151
155
|
ORDER BY f.path ASC, s.start_line ASC, s.name ASC`)
|
|
152
156
|
.all(...files);
|
|
153
157
|
}
|
|
@@ -171,24 +175,43 @@ function pickPrimary(symbols) {
|
|
|
171
175
|
* `otherMatches` (same-name siblings, or the rest of a file).
|
|
172
176
|
*/
|
|
173
177
|
function assembleExplore(db, projectPath, best, others) {
|
|
178
|
+
// Covers: req~compass-mcp-surface~1
|
|
179
|
+
// Resolved callees only, one row per target node at its first call line, so
|
|
180
|
+
// unresolved names (builtins, member calls) never crowd out project callees.
|
|
174
181
|
const callees = db
|
|
175
|
-
.prepare(`SELECT
|
|
176
|
-
FROM edges e
|
|
177
|
-
|
|
182
|
+
.prepare(`SELECT s.name AS name, MIN(e.line) AS line, f.path AS file
|
|
183
|
+
FROM edges e JOIN nodes s ON s.id = e.dst_node_id
|
|
184
|
+
JOIN files f ON f.id = s.file_id
|
|
178
185
|
WHERE e.src_node_id = ? AND e.kind = 'call'
|
|
179
|
-
|
|
186
|
+
GROUP BY s.id
|
|
187
|
+
ORDER BY line, s.id`)
|
|
180
188
|
.all(best.id);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
const unresolved = db
|
|
190
|
+
.prepare(`SELECT e.dst_name AS name, COUNT(*) AS n
|
|
191
|
+
FROM edges e
|
|
192
|
+
WHERE e.src_node_id = ? AND e.kind = 'call' AND e.dst_node_id IS NULL
|
|
193
|
+
GROUP BY e.dst_name
|
|
194
|
+
ORDER BY n DESC, e.dst_name ASC`)
|
|
195
|
+
.all(best.id);
|
|
196
|
+
// Callers: match both the resolved edge AND any non-member (`is_member = 0`)
|
|
197
|
+
// call by this name, so dynamic dispatch (a function called by name across sites) is not
|
|
198
|
+
// missed. Over-approximates conservatively — the point of a blast radius. A
|
|
199
|
+
// builtin global name (`test`, `fetch`) matches by name only within the
|
|
200
|
+
// definition's own file. A caller outside any definition is its file-owner
|
|
201
|
+
// node (`kind: "file"`).
|
|
184
202
|
const callers = db
|
|
185
203
|
.prepare(`SELECT DISTINCT owner.name AS name, owner.kind AS kind, f.path AS file, e.line
|
|
186
204
|
FROM edges e
|
|
187
205
|
JOIN nodes owner ON owner.id = e.src_node_id
|
|
188
206
|
JOIN files f ON f.id = owner.file_id
|
|
189
|
-
WHERE (e.dst_node_id = ? OR
|
|
207
|
+
WHERE (e.dst_node_id = ? OR (
|
|
208
|
+
e.dst_name = ? AND e.is_member = 0
|
|
209
|
+
AND (e.dst_name NOT IN (${BUILTIN_SQL_LIST})
|
|
210
|
+
OR e.src_file_id = (SELECT file_id FROM nodes WHERE id = ?))
|
|
211
|
+
))
|
|
212
|
+
AND e.kind = 'call'
|
|
190
213
|
ORDER BY f.path, e.line`)
|
|
191
|
-
.all(best.id, best.name);
|
|
214
|
+
.all(best.id, best.name, best.id);
|
|
192
215
|
return {
|
|
193
216
|
found: true,
|
|
194
217
|
symbol: {
|
|
@@ -200,7 +223,11 @@ function assembleExplore(db, projectPath, best, others) {
|
|
|
200
223
|
signature: best.signature,
|
|
201
224
|
source: readSource(projectPath, best.file, best.start_byte, best.end_byte),
|
|
202
225
|
},
|
|
203
|
-
callees: callees.map((c) => ({ name: c.name, file: c.file
|
|
226
|
+
callees: callees.map((c) => ({ name: c.name, file: c.file, line: c.line })),
|
|
227
|
+
unresolvedCallees: {
|
|
228
|
+
count: unresolved.reduce((sum, u) => sum + Number(u.n), 0),
|
|
229
|
+
sample: unresolved.slice(0, 10).map((u) => u.name),
|
|
230
|
+
},
|
|
204
231
|
callers,
|
|
205
232
|
otherMatches: others.length > 0
|
|
206
233
|
? others.map((m) => ({
|
|
@@ -215,14 +242,23 @@ function assembleExplore(db, projectPath, best, others) {
|
|
|
215
242
|
}
|
|
216
243
|
/**
|
|
217
244
|
* Name miss: resolve a file path to one symbol, or list symbols when the
|
|
218
|
-
* basename is ambiguous or the file defines nothing. A
|
|
219
|
-
*
|
|
245
|
+
* basename is ambiguous or the file defines nothing. A file with no visible
|
|
246
|
+
* symbol resolves to its file-owner node (the indexer gives every symbol-less
|
|
247
|
+
* file one); a file with no node at all reports that it has no symbols. A non-unique suffix falls through to {@link search}, which also
|
|
248
|
+
* matches `files.path`.
|
|
220
249
|
*/
|
|
221
250
|
function exploreWhenNameMisses(db, projectPath, query) {
|
|
222
|
-
// Covers: req~explore-file-path~
|
|
251
|
+
// Covers: req~explore-file-path~2
|
|
223
252
|
const resolved = resolveIndexedFile(db, projectPath, query);
|
|
224
253
|
if (resolved.status === "one") {
|
|
225
254
|
const symbols = fileSymbols(db, resolved.file);
|
|
255
|
+
const owner = symbols.length === 0 ? fileSymbols(db, resolved.file, true)[0] : undefined;
|
|
256
|
+
if (owner) {
|
|
257
|
+
return {
|
|
258
|
+
...assembleExplore(db, projectPath, owner, []),
|
|
259
|
+
message: `Path "${resolved.file}" declares no symbols; resolved to its file-owner node.`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
226
262
|
if (symbols.length === 0) {
|
|
227
263
|
const near = search(projectPath, query, 10);
|
|
228
264
|
return {
|
|
@@ -276,7 +312,7 @@ export function explore(projectPath, query) {
|
|
|
276
312
|
.prepare(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.start_byte, s.end_byte,
|
|
277
313
|
s.signature, f.path AS file
|
|
278
314
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
279
|
-
WHERE s.name = ?
|
|
315
|
+
WHERE s.name = ? AND s.kind <> '${FILE_NODE_KIND}'
|
|
280
316
|
ORDER BY s.kind = 'function' DESC, s.kind = 'class' DESC
|
|
281
317
|
LIMIT 10`)
|
|
282
318
|
.all(query);
|
|
@@ -391,6 +427,12 @@ export function impact(projectPath, symbolOrOpts, maxDepth = 4) {
|
|
|
391
427
|
for (const d of definitions) {
|
|
392
428
|
seedArgs.push(d.nodeId, d.name);
|
|
393
429
|
}
|
|
430
|
+
// Covers: req~impact-id-first~1
|
|
431
|
+
// A member call (`is_member` 1 or 2) never matches by name: `items.push()` is
|
|
432
|
+
// not a call to a project function named `push`, and a call on an import
|
|
433
|
+
// binding (2) counts only through the id resolveEdges gave it, so a package
|
|
434
|
+
// receiver (`path.parse()`) never reaches a project `parse`. A builtin global name
|
|
435
|
+
// (`test`, `fetch`) matches by name only within the frontier node's file.
|
|
394
436
|
// Sticky by_name: MAX(frontier.by_name, CASE WHEN edge unresolved THEN 1 ELSE 0).
|
|
395
437
|
// Import edges that resolve to ANY node in the frontier node's file count as hits.
|
|
396
438
|
const sql = `
|
|
@@ -412,7 +454,11 @@ export function impact(projectPath, symbolOrOpts, maxDepth = 4) {
|
|
|
412
454
|
AND (
|
|
413
455
|
(e.kind = 'call' AND (
|
|
414
456
|
e.dst_node_id = f.node_id
|
|
415
|
-
OR (
|
|
457
|
+
OR (
|
|
458
|
+
e.dst_node_id IS NULL AND e.is_member = 0 AND e.dst_name = f.node_name
|
|
459
|
+
AND (e.dst_name NOT IN (${BUILTIN_SQL_LIST})
|
|
460
|
+
OR e.src_file_id = (SELECT file_id FROM nodes WHERE id = f.node_id))
|
|
461
|
+
)
|
|
416
462
|
))
|
|
417
463
|
OR (
|
|
418
464
|
e.kind = 'import'
|
|
@@ -536,7 +582,7 @@ function resolveImpactSeeds(db, opts, seedFiles, warnings) {
|
|
|
536
582
|
const rows = db
|
|
537
583
|
.prepare(`SELECT n.id AS nodeId, n.name, f.path AS file, n.start_line AS line
|
|
538
584
|
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
539
|
-
WHERE n.name = ?
|
|
585
|
+
WHERE n.name = ? AND n.kind <> '${FILE_NODE_KIND}'
|
|
540
586
|
ORDER BY n.kind = 'function' DESC, n.kind = 'class' DESC, n.id ASC
|
|
541
587
|
LIMIT 50`)
|
|
542
588
|
.all(opts.symbol);
|
|
@@ -151,7 +151,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
151
151
|
});
|
|
152
152
|
defineAliasTool(server, {
|
|
153
153
|
name: "compass_affected_tests",
|
|
154
|
-
description: "Deprecated alias — use compass_diff_context or explore.",
|
|
154
|
+
description: "Deprecated alias — use compass_diff_context or explore; command may be null.",
|
|
155
155
|
inputSchema: {
|
|
156
156
|
projectPath: z.string(),
|
|
157
157
|
files: z.array(z.string()).optional(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { openDb, indexExists } from "./db.js";
|
|
3
|
+
import { openDb, indexExists, FILE_NODE_KIND } from "./db.js";
|
|
4
4
|
/**
|
|
5
5
|
* Build the graph payload from the Compass index: nodes (definitions) and the
|
|
6
6
|
* resolved call links between them. With a focus node, returns its neighborhood
|
|
@@ -17,11 +17,13 @@ export function graphData(projectPath, opts = {}) {
|
|
|
17
17
|
try {
|
|
18
18
|
const allNodes = db
|
|
19
19
|
.prepare(`SELECT n.id, n.name, n.kind, f.path AS file, n.start_line AS line
|
|
20
|
-
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
20
|
+
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
21
|
+
WHERE n.kind <> '${FILE_NODE_KIND}'`)
|
|
21
22
|
.all();
|
|
22
23
|
const allEdges = db
|
|
23
24
|
.prepare(`SELECT src_node_id AS s, dst_node_id AS t FROM edges
|
|
24
|
-
WHERE kind = 'call' AND dst_node_id IS NOT NULL AND src_node_id IS NOT NULL
|
|
25
|
+
WHERE kind = 'call' AND dst_node_id IS NOT NULL AND src_node_id IS NOT NULL
|
|
26
|
+
AND src_node_id NOT IN (SELECT id FROM nodes WHERE kind = '${FILE_NODE_KIND}')`)
|
|
25
27
|
.all();
|
|
26
28
|
// adjacency (undirected) + degree
|
|
27
29
|
const adj = new Map();
|
|
@@ -34,14 +34,34 @@ a request, not whole files.
|
|
|
34
34
|
| `compass_explore` | Read a node's verbatim source plus its callers and callees. The default before editing. Add `include: ["blast_radius"]` for what could break, or `to: <symbol>` for the call path between two nodes. |
|
|
35
35
|
| `compass_diff_context` | Symbols, blast radius, and tests for a diff (working tree or a rev). |
|
|
36
36
|
|
|
37
|
+
Callees list only references resolved to a project symbol; the rest are counted
|
|
38
|
+
in `unresolvedCallees`. A call inside a test callback or a top-level import
|
|
39
|
+
shows up as a caller of kind `file`, so tests written as bare `test(...)`
|
|
40
|
+
blocks are still selected. The affected-test `command` is `null` (with
|
|
41
|
+
`commandReason`) when no test is reachable; otherwise run it from the repository
|
|
42
|
+
root, or use `commands` (one `{ cwd, command, files }` per package, with the
|
|
43
|
+
runner detected from each `package.json`: vitest, jest, or `node --test`).
|
|
44
|
+
Method calls on local variables and parameters (`svc.run()`) are not bound to a
|
|
45
|
+
project method, so they do not appear as callers. Calls through an imported
|
|
46
|
+
re-export barrel (`index.ts` holding `export * from "./lib/core"`) bind by
|
|
47
|
+
name, preferring a definition under the barrel's directory.
|
|
48
|
+
|
|
49
|
+
Compass uses index schema 11. speclaw 2.0.9 or older, including an MCP entry
|
|
50
|
+
pinned at `@2.0.9`, rebuilds a schema-11 index from scratch (losing the
|
|
51
|
+
embedding cache). Run `speclaw update` so the pinned MCP entry moves to 2.0.10.
|
|
52
|
+
|
|
37
53
|
`compass_index` also takes `action: start|stop|status` to keep the index fresh
|
|
38
54
|
automatically (a debounced incremental re-index on file change).
|
|
39
55
|
|
|
40
56
|
Under Claude Code, a `SessionStart` hook refreshes an existing index when each
|
|
41
57
|
session starts (`speclaw session-start`: silent, never fails, skips
|
|
42
58
|
when `.speclaw/index.db` is absent, and leaves this file alone when nothing
|
|
43
|
-
changed).
|
|
44
|
-
`speclaw
|
|
59
|
+
changed). A `PostToolUse` hook re-indexes each file the agent writes or edits
|
|
60
|
+
right after the edit (`speclaw reindex-file`: detached, silent, never fails);
|
|
61
|
+
PageRank and this file's map catch up on the next full run. Files created or
|
|
62
|
+
renamed by shell commands, and edits by agents without hooks, are picked up at
|
|
63
|
+
the next session start, `compass_index`, or `speclaw watch`. The first build is
|
|
64
|
+
always an explicit `compass_index` / `speclaw index`.
|
|
45
65
|
|
|
46
66
|
If the graph is missing (no `.speclaw/index.db`), run `compass_index` first —
|
|
47
67
|
a missing graph is not license to skip Compass. The only legitimate fallbacks
|
|
@@ -27,11 +27,38 @@ const LEGACY_SESSION_START_MARKER = "speclaw index --session-start";
|
|
|
27
27
|
* Output is discarded because `SessionStart` stdout enters the agent's context,
|
|
28
28
|
* and `|| true` keeps the exit code 0 in every case.
|
|
29
29
|
*/
|
|
30
|
-
export const SESSION_START_COMMAND =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
export const SESSION_START_COMMAND = speclawCommand("session-start");
|
|
31
|
+
/**
|
|
32
|
+
* Substring every edit-reindex command contains, in each of its resolution
|
|
33
|
+
* branches. It is the merge identity of the `PostToolUse` reindex hook.
|
|
34
|
+
*/
|
|
35
|
+
export const REINDEX_FILE_MARKER = "speclaw reindex-file";
|
|
36
|
+
/**
|
|
37
|
+
* The POSIX `sh` command the edit `PostToolUse` hook runs: the session-start
|
|
38
|
+
* command with `reindex-file` as the subcommand, so it keeps the same index
|
|
39
|
+
* guard, resolution order, offline npx, discarded output, and exit 0. The hook
|
|
40
|
+
* payload on stdin passes through `sh` to speclaw unchanged. A speclaw older
|
|
41
|
+
* than the command rejects it as unknown (exit 1) before touching the index.
|
|
42
|
+
*/
|
|
43
|
+
export const REINDEX_FILE_COMMAND = speclawCommand("reindex-file");
|
|
44
|
+
/** Seconds the edit hook may run; the command returns once it has spawned the work. */
|
|
45
|
+
const REINDEX_TIMEOUT_SECONDS = 10;
|
|
46
|
+
/**
|
|
47
|
+
* Build a guarded, fail-safe speclaw hook command for one top-level subcommand
|
|
48
|
+
* (see {@link SESSION_START_COMMAND} for the shape). The only difference
|
|
49
|
+
* between the hook commands is the subcommand.
|
|
50
|
+
*
|
|
51
|
+
* @param sub - The top-level speclaw command the hook runs.
|
|
52
|
+
* @returns The POSIX `sh` command string.
|
|
53
|
+
*/
|
|
54
|
+
export function speclawCommand(sub) {
|
|
55
|
+
// Covers: req~edit-reindex-hook~1
|
|
56
|
+
return ('cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
|
|
57
|
+
`if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw ${sub}; ` +
|
|
58
|
+
`elif command -v speclaw >/dev/null 2>&1; then speclaw ${sub}; ` +
|
|
59
|
+
`else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw ${sub}; fi; ` +
|
|
60
|
+
"} >/dev/null 2>&1 || true");
|
|
61
|
+
}
|
|
35
62
|
/** `SessionStart` matcher covering every session source Claude Code reports. */
|
|
36
63
|
const SESSION_START_MATCHER = "startup|resume|clear|compact";
|
|
37
64
|
/** Seconds the blocking session-start hook may run (index wait plus a real refresh). */
|
|
@@ -72,8 +99,8 @@ export const NUDGE_MATCHER = "Read|Grep|Glob";
|
|
|
72
99
|
/**
|
|
73
100
|
* True when a hook object is one speclaw owns (safe to replace on merge): an
|
|
74
101
|
* `mcp_tool` hook on the `speclaw` server, or a `command` hook whose command
|
|
75
|
-
* contains {@link SESSION_START_MARKER}
|
|
76
|
-
* `index --session-start` marker
|
|
102
|
+
* contains {@link SESSION_START_MARKER}, {@link REINDEX_FILE_MARKER}, or the
|
|
103
|
+
* pre-release `index --session-start` marker. A user `command` hook without a
|
|
77
104
|
* marker is never speclaw's.
|
|
78
105
|
*
|
|
79
106
|
* @param h - A hook object read from an agent's settings (any shape).
|
|
@@ -85,7 +112,9 @@ export function isSpeclawHook(h) {
|
|
|
85
112
|
return true;
|
|
86
113
|
return (o?.type === "command" &&
|
|
87
114
|
typeof o?.command === "string" &&
|
|
88
|
-
(o.command.includes(SESSION_START_MARKER) ||
|
|
115
|
+
(o.command.includes(SESSION_START_MARKER) ||
|
|
116
|
+
o.command.includes(REINDEX_FILE_MARKER) ||
|
|
117
|
+
o.command.includes(LEGACY_SESSION_START_MARKER)));
|
|
89
118
|
}
|
|
90
119
|
/**
|
|
91
120
|
* Compile a law manifest into the hook groups speclaw contributes, one per event
|
|
@@ -93,8 +122,10 @@ export function isSpeclawHook(h) {
|
|
|
93
122
|
* `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
|
|
94
123
|
* (the context-coverage audit). Two groups are always emitted, even with no
|
|
95
124
|
* laws: one `PostToolUse` group matching `Read|Grep|Glob` for the Compass-first
|
|
96
|
-
* nudge,
|
|
97
|
-
*
|
|
125
|
+
* nudge, a separate `PostToolUse` group matching the mutating tools whose
|
|
126
|
+
* `command` hook re-indexes the edited file in the background, and one
|
|
127
|
+
* `SessionStart` group whose `command` hook refreshes the index silently when a
|
|
128
|
+
* session starts. A law whose scope contains a malformed glob is
|
|
98
129
|
* excluded and reported, so a bad pattern fails loudly at generation rather than
|
|
99
130
|
* silently matching nothing at runtime.
|
|
100
131
|
*
|
|
@@ -121,6 +152,13 @@ export function compileHooks(manifest) {
|
|
|
121
152
|
byEvent.PostToolUse = [{ matcher: NUDGE_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
|
|
122
153
|
if (hasFeedback)
|
|
123
154
|
byEvent.PostToolUse.unshift({ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] });
|
|
155
|
+
// Covers: req~edit-reindex-hook~1
|
|
156
|
+
// Its own group, never folded into the feedback `mcp_tool` group, and only
|
|
157
|
+
// the keys the agent's hook schema defines (no `async`).
|
|
158
|
+
byEvent.PostToolUse.push({
|
|
159
|
+
matcher: MUTATION_MATCHER,
|
|
160
|
+
hooks: [{ type: "command", command: REINDEX_FILE_COMMAND, timeout: REINDEX_TIMEOUT_SECONDS }],
|
|
161
|
+
});
|
|
124
162
|
if (hasGate)
|
|
125
163
|
byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
126
164
|
if (valid.length > 0)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { openDb, indexExists, needsReindex } from "../compass/db.js";
|
|
6
|
+
import { openDb, indexExists, needsReindex, FILE_NODE_KIND } from "../compass/db.js";
|
|
7
7
|
import { NORMALIZER_VERSION } from "../compass/hash.js";
|
|
8
8
|
import { logForPath } from "../../shared/git-history.js";
|
|
9
9
|
import { listAnchoredCapabilities, readAnchorsFile, resealAll, sealCapability, } from "./anchors.js";
|
|
@@ -49,7 +49,7 @@ export function classifyAnchor(db, projectPath, capability, a) {
|
|
|
49
49
|
.prepare(`SELECT n.id AS id, n.name AS name, n.kind AS kind, f.path AS path,
|
|
50
50
|
n.norm_hash AS normHash, n.body_hash AS bodyHash
|
|
51
51
|
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
52
|
-
WHERE n.name =
|
|
52
|
+
WHERE n.name = ? AND n.kind <> '${FILE_NODE_KIND}'`)
|
|
53
53
|
.all(a.symbolName);
|
|
54
54
|
if (byName.length === 0) {
|
|
55
55
|
if (a.contentHash) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { indexExists, openDb } from "../compass/db.js";
|
|
3
|
+
import { indexExists, openDb, FILE_NODE_KIND } from "../compass/db.js";
|
|
4
4
|
import { explore, impact, recall } from "../compass/query.js";
|
|
5
5
|
import { affectedTests } from "../compass/affected.js";
|
|
6
6
|
import { hotspots, coupling } from "../compass/hotspots.js";
|
|
@@ -26,6 +26,7 @@ function resolveAtLine(projectPath, file, line) {
|
|
|
26
26
|
.prepare(`SELECT s.name, s.kind, s.start_line AS startLine, s.signature
|
|
27
27
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
28
28
|
WHERE f.path = ? AND s.start_line <= ? AND s.end_line >= ?
|
|
29
|
+
AND s.kind <> '${FILE_NODE_KIND}'
|
|
29
30
|
ORDER BY (s.end_line - s.start_line) ASC
|
|
30
31
|
LIMIT 1`)
|
|
31
32
|
.get(file, line, line);
|
|
@@ -29,6 +29,8 @@ export function applyTextBudget(text, mode = "brief") {
|
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* Truncate known list fields on an explore-style object before serialization.
|
|
32
|
+
* `unresolvedCallees` (a count plus at most 10 names) is left whole, so the
|
|
33
|
+
* resolved `callees` keep the list budget.
|
|
32
34
|
*
|
|
33
35
|
* @param value - Plain object to mutate in place (arrays shortened, counts kept).
|
|
34
36
|
* @param mode - Output mode controlling list limits.
|
package/dist/shared/paths.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
/**
|
|
4
5
|
* The `assets/` directory that sits next to a compiled module file. Each module
|
|
@@ -8,3 +9,26 @@ import path from "node:path";
|
|
|
8
9
|
export function assetsDir(importMetaUrl) {
|
|
9
10
|
return path.join(path.dirname(fileURLToPath(importMetaUrl)), "assets");
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* `p` with symlinks resolved in its longest existing prefix (a deleted file or
|
|
14
|
+
* directory keeps its missing tail), so `/var/…` and `/private/var/…` spellings
|
|
15
|
+
* of one path compare equal even after the file is gone.
|
|
16
|
+
*
|
|
17
|
+
* @param p - An absolute path.
|
|
18
|
+
*/
|
|
19
|
+
export function realPathOf(p) {
|
|
20
|
+
const tail = [];
|
|
21
|
+
let cur = path.resolve(p);
|
|
22
|
+
for (;;) {
|
|
23
|
+
try {
|
|
24
|
+
return path.join(fs.realpathSync(cur), ...tail.reverse());
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
const parent = path.dirname(cur);
|
|
28
|
+
if (parent === cur)
|
|
29
|
+
return path.resolve(p);
|
|
30
|
+
tail.push(path.basename(cur));
|
|
31
|
+
cur = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|