@esneiderbravo/speclaw 2.0.10 → 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 +5 -1
- package/dist/cli/commands/reindex-file.js +146 -0
- package/dist/cli/commands/update.js +15 -0
- package/dist/cli/index.js +7 -3
- package/dist/cli/lib/help.js +19 -0
- package/dist/modules/compass/db.js +61 -0
- package/dist/modules/compass/indexer.js +549 -123
- package/dist/modules/foundation/assets/docs/compass.template.md +6 -2
- package/dist/modules/foundation/hooks.js +48 -10
- package/dist/shared/paths.js +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -250,7 +250,11 @@ hooks into that agent's settings **by identity** — it never touches hooks you
|
|
|
250
250
|
added yourself. The same merge adds one `SessionStart` command hook that
|
|
251
251
|
silently refreshes an existing Compass index when a session starts
|
|
252
252
|
(`speclaw session-start`; it skips when no index exists, never downloads or
|
|
253
|
-
contacts the registry, and always exits 0)
|
|
253
|
+
contacts the registry, and always exits 0), and one separate `PostToolUse`
|
|
254
|
+
command hook for `Write|Edit|MultiEdit|NotebookEdit` that hands each edited
|
|
255
|
+
file to `speclaw reindex-file`, which re-indexes it in a detached background
|
|
256
|
+
process (silent, always exit 0; PageRank and the compact map catch up on the
|
|
257
|
+
next full run). The compiled law manifest lives in `.speclaw/laws-manifest.json`
|
|
254
258
|
(gitignored) and is **adapted to the target tree** on `init`/`update` — speclaw's
|
|
255
259
|
own architecture laws are seeded only when those paths exist, and the cycle law
|
|
256
260
|
follows `apps/*/src`, `packages/*/src`, or `src/` rather than copying
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { helpFor } from "../lib/help.js";
|
|
5
|
+
import { realPathOf } from "../../shared/paths.js";
|
|
6
|
+
/** Hook payloads are small JSON objects; anything larger is not one. */
|
|
7
|
+
const MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* Split raw arguments into file paths: every token after `--`, and every
|
|
10
|
+
* token before it that does not look like a flag.
|
|
11
|
+
*
|
|
12
|
+
* @param args - The arguments after `reindex-file`.
|
|
13
|
+
* @returns The path arguments, in order.
|
|
14
|
+
*/
|
|
15
|
+
export function reindexPathArgs(args) {
|
|
16
|
+
const out = [];
|
|
17
|
+
let rest = false;
|
|
18
|
+
for (const a of args) {
|
|
19
|
+
if (rest)
|
|
20
|
+
out.push(a);
|
|
21
|
+
else if (a === "--")
|
|
22
|
+
rest = true;
|
|
23
|
+
else if (!a.startsWith("-"))
|
|
24
|
+
out.push(a);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The absolute file a `PostToolUse` hook payload edited: the first non-empty of
|
|
30
|
+
* `tool_input.file_path` and `tool_input.notebook_path`, a relative value
|
|
31
|
+
* resolved against the payload's absolute `cwd`, else against `fallbackCwd`.
|
|
32
|
+
*
|
|
33
|
+
* @param payload - The parsed hook JSON.
|
|
34
|
+
* @param fallbackCwd - Base for a relative path when the payload has no absolute `cwd`.
|
|
35
|
+
* @returns The absolute path, or `null` when the payload names no file.
|
|
36
|
+
*/
|
|
37
|
+
export function hookTarget(payload, fallbackCwd) {
|
|
38
|
+
if (!payload || typeof payload !== "object")
|
|
39
|
+
return null;
|
|
40
|
+
const p = payload;
|
|
41
|
+
const input = p.tool_input;
|
|
42
|
+
if (!input || typeof input !== "object")
|
|
43
|
+
return null;
|
|
44
|
+
const pick = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : null;
|
|
45
|
+
const target = pick(input.file_path) ?? pick(input.notebook_path);
|
|
46
|
+
if (target === null)
|
|
47
|
+
return null;
|
|
48
|
+
const base = typeof p.cwd === "string" && path.isAbsolute(p.cwd) ? p.cwd : fallbackCwd;
|
|
49
|
+
return path.resolve(base, target);
|
|
50
|
+
}
|
|
51
|
+
/** Whether `abs` lies strictly inside `root` (symlinked prefixes such as macOS `/var` resolved). */
|
|
52
|
+
function insideRoot(root, abs) {
|
|
53
|
+
const rel = path.relative(realPathOf(root), realPathOf(abs));
|
|
54
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
55
|
+
}
|
|
56
|
+
/** Read stdin to its end; `null` when it exceeds the payload cap (still drained). */
|
|
57
|
+
async function readPayload(stdin) {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let size = 0;
|
|
60
|
+
for await (const chunk of stdin) {
|
|
61
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
62
|
+
size += buf.length;
|
|
63
|
+
if (size <= MAX_PAYLOAD_BYTES)
|
|
64
|
+
chunks.push(buf);
|
|
65
|
+
}
|
|
66
|
+
return size > MAX_PAYLOAD_BYTES ? null : Buffer.concat(chunks).toString("utf8");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `speclaw reindex-file [--] <path>...` and its hook mode.
|
|
70
|
+
*
|
|
71
|
+
* - **Path mode** (paths given): re-index exactly those files in the foreground
|
|
72
|
+
* with Compass `indexFiles` — the detached child, tests, the bench, and manual
|
|
73
|
+
* use run this.
|
|
74
|
+
* - **Hook mode** (no path): read the Claude Code `PostToolUse` JSON from stdin,
|
|
75
|
+
* take `tool_input.file_path` / `notebook_path`, and hand it to a detached
|
|
76
|
+
* `speclaw reindex-file -- <abs>` child, then return without waiting, so the
|
|
77
|
+
* edit is not slowed. This branch never opens the database or loads Compass
|
|
78
|
+
* (or tree-sitter). With stdin on a terminal it prints usage instead of
|
|
79
|
+
* blocking.
|
|
80
|
+
*
|
|
81
|
+
* The project root is the working directory (where the hook guard found
|
|
82
|
+
* `.speclaw/index.db`); a payload `cwd` only resolves a relative file path, so
|
|
83
|
+
* a payload cannot redirect writes into another project's index. With no index
|
|
84
|
+
* it does nothing and creates nothing. It prints nothing, is never logged as a
|
|
85
|
+
* Compass call, and swallows every failure (including `SQLITE_BUSY`): the
|
|
86
|
+
* caller always sees exit 0.
|
|
87
|
+
*
|
|
88
|
+
* @param args - Raw arguments after `reindex-file` (`--` honored).
|
|
89
|
+
* @param cwd - Project root (defaults to the process working directory).
|
|
90
|
+
* @param opts - `entry`: the CLI script the detached child runs (defaults to
|
|
91
|
+
* this process's `argv[1]`); `stdin`: the payload stream (defaults to
|
|
92
|
+
* `process.stdin`). Both are test seams.
|
|
93
|
+
*/
|
|
94
|
+
// Covers: req~reindex-on-edit~1
|
|
95
|
+
export async function runReindexFile(args, cwd = process.cwd(), opts = {}) {
|
|
96
|
+
// Node lines that still flag `node:sqlite` as experimental would print an
|
|
97
|
+
// ExperimentalWarning to stderr; this command must print nothing.
|
|
98
|
+
process.removeAllListeners("warning");
|
|
99
|
+
try {
|
|
100
|
+
const paths = reindexPathArgs(args);
|
|
101
|
+
const hasIndex = () => fs.existsSync(path.join(cwd, ".speclaw", "index.db"));
|
|
102
|
+
if (paths.length > 0) {
|
|
103
|
+
if (!hasIndex())
|
|
104
|
+
return;
|
|
105
|
+
const { indexFiles } = await import("../../modules/compass/indexer.js");
|
|
106
|
+
await indexFiles(cwd, paths);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
110
|
+
if (stdin.isTTY) {
|
|
111
|
+
process.stdout.write(helpFor("reindex-file") ?? "");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const raw = await readPayload(stdin);
|
|
115
|
+
if (raw === null)
|
|
116
|
+
return;
|
|
117
|
+
let payload;
|
|
118
|
+
try {
|
|
119
|
+
payload = JSON.parse(raw);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const target = hookTarget(payload, cwd);
|
|
125
|
+
if (target === null || !hasIndex() || !insideRoot(cwd, target))
|
|
126
|
+
return;
|
|
127
|
+
const entry = opts.entry ?? process.argv[1];
|
|
128
|
+
if (!entry)
|
|
129
|
+
return;
|
|
130
|
+
// detached: its own process group, so neither the hook timeout nor the
|
|
131
|
+
// agent's cleanup of the hook's group kills it. `--` keeps a path that
|
|
132
|
+
// starts with "-" from reading as a flag.
|
|
133
|
+
const child = spawn(process.execPath, [entry, "reindex-file", "--", target], {
|
|
134
|
+
cwd,
|
|
135
|
+
detached: true,
|
|
136
|
+
stdio: "ignore",
|
|
137
|
+
windowsHide: true,
|
|
138
|
+
});
|
|
139
|
+
child.on("error", () => { });
|
|
140
|
+
child.unref();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// A concurrent writer (SQLITE_BUSY after busy_timeout) or any other failure
|
|
144
|
+
// must never fail or print into the agent's edit.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -254,6 +254,21 @@ const MIGRATIONS = [
|
|
|
254
254
|
"project's docs or scripts run `command` blindly, make them handle `null`.\n" +
|
|
255
255
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
256
256
|
},
|
|
257
|
+
{
|
|
258
|
+
version: "2.0.11",
|
|
259
|
+
describe: "Compass re-indexes each file an agent edits (PostToolUse → speclaw reindex-file)",
|
|
260
|
+
agentPrompt: "- For hook-capable agents (Claude Code), `init`/`update` now add one more `PostToolUse` " +
|
|
261
|
+
"command hook (matcher `Write|Edit|MultiEdit|NotebookEdit`, timeout 10) that hands the " +
|
|
262
|
+
"edited file to `speclaw reindex-file` in a detached background process: silent, always " +
|
|
263
|
+
"exit 0, skipped when `.speclaw/index.db` is absent, never downloads. This update reports " +
|
|
264
|
+
"the agent settings file under `refreshedDiverged` once; that is expected.\n" +
|
|
265
|
+
"- Where the project documents how the Compass index stays fresh (session start, " +
|
|
266
|
+
"`compass_index`, `speclaw watch`), add: each Write/Edit is re-indexed right after the " +
|
|
267
|
+
"edit; PageRank and the compact map in `docs/compass.md` catch up on the next full run; " +
|
|
268
|
+
"files created or renamed by shell commands and edits by agents without hooks are picked " +
|
|
269
|
+
"up at the next session start or `compass_index`.\n" +
|
|
270
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
271
|
+
},
|
|
257
272
|
];
|
|
258
273
|
/**
|
|
259
274
|
* Bring the current project up to date without a full re-init. When the npm
|
package/dist/cli/index.js
CHANGED
|
@@ -36,7 +36,8 @@ const HEADER_COMMANDS = new Set([
|
|
|
36
36
|
* forced-color signal counts as interactive so the header is exercisable in a
|
|
37
37
|
* child process. `budget --json`, `doctor --json`, and `coverage` when emitting
|
|
38
38
|
* TAP/JSON (or when stdout is not a TTY) are machine-consumed and suppress the
|
|
39
|
-
* header. `session-start`
|
|
39
|
+
* header. `session-start` and `reindex-file` are not header-eligible: they must print
|
|
40
|
+
* nothing.
|
|
40
41
|
*/
|
|
41
42
|
function maybeHeader(cmd, flags) {
|
|
42
43
|
if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
|
|
@@ -106,6 +107,9 @@ async function dispatch(cmd, flags) {
|
|
|
106
107
|
return (await import("./commands/index-build.js")).runWatch(flags);
|
|
107
108
|
case "session-start":
|
|
108
109
|
return (await import("./commands/session-start.js")).runSessionStart();
|
|
110
|
+
case "reindex-file":
|
|
111
|
+
// Raw argv: the shared flag parser would read `-- <path>` as a flag value.
|
|
112
|
+
return (await import("./commands/reindex-file.js")).runReindexFile(process.argv.slice(3));
|
|
109
113
|
case "explore":
|
|
110
114
|
case "search":
|
|
111
115
|
case "recall":
|
|
@@ -165,8 +169,8 @@ async function main() {
|
|
|
165
169
|
const flags = parseFlags(rest);
|
|
166
170
|
maybeHeader(cmd, flags);
|
|
167
171
|
await dispatch(cmd, flags);
|
|
168
|
-
// The SessionStart
|
|
169
|
-
if (cmd === "session-start")
|
|
172
|
+
// The SessionStart and edit hooks must stay silent: no update notice either.
|
|
173
|
+
if (cmd === "session-start" || cmd === "reindex-file")
|
|
170
174
|
return;
|
|
171
175
|
await maybeNotifyUpdate(cmd);
|
|
172
176
|
}
|
package/dist/cli/lib/help.js
CHANGED
|
@@ -24,6 +24,7 @@ Setup
|
|
|
24
24
|
Compass (code intelligence — the same surface agents use via MCP)
|
|
25
25
|
index (Re)build the local code graph (--force / --prune / --json)
|
|
26
26
|
session-start Silent, fail-safe refresh of an existing index (SessionStart hook)
|
|
27
|
+
reindex-file [paths...] Silent re-index of edited files (PostToolUse hook; stdin JSON)
|
|
27
28
|
watch Keep the index fresh on file changes
|
|
28
29
|
explore <node> A node's source + callers/callees
|
|
29
30
|
search <query> Hybrid find (BM25+vector+name); --focus --max-tokens --explain
|
|
@@ -171,6 +172,24 @@ Keep the Compass index fresh: re-index changed files until interrupted (Ctrl-C).
|
|
|
171
172
|
|
|
172
173
|
Silent, fail-safe refresh of an existing Compass index. The SessionStart hook
|
|
173
174
|
runs it; it prints nothing and never fails the session.
|
|
175
|
+
`,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "reindex-file",
|
|
179
|
+
usage: `Usage: speclaw reindex-file [--] <path>...
|
|
180
|
+
speclaw reindex-file < hook.json
|
|
181
|
+
|
|
182
|
+
Re-index single files in an existing Compass index, silently and fail-safe.
|
|
183
|
+
|
|
184
|
+
With paths, re-indexes exactly those files now (a deleted file is removed;
|
|
185
|
+
paths outside the project, in skipped directories, of no indexed language, or
|
|
186
|
+
over the size cap are ignored). With no path, reads a PostToolUse hook payload
|
|
187
|
+
from stdin and re-indexes tool_input.file_path (or notebook_path) in a detached
|
|
188
|
+
background process, returning at once. The PostToolUse hook for
|
|
189
|
+
Write|Edit|MultiEdit|NotebookEdit runs it.
|
|
190
|
+
|
|
191
|
+
PageRank and docs/compass.md are left to the next full run (session start,
|
|
192
|
+
speclaw index, compass_index, watch). Prints nothing and always exits 0.
|
|
174
193
|
`,
|
|
175
194
|
},
|
|
176
195
|
{
|
|
@@ -568,6 +568,67 @@ export function needsReindex(db) {
|
|
|
568
568
|
export function clearNeedsReindex(db) {
|
|
569
569
|
db.prepare("DELETE FROM meta WHERE key = 'needs_reindex'").run();
|
|
570
570
|
}
|
|
571
|
+
/** `meta` key set by a per-file reindex until the next full post-processing pass. */
|
|
572
|
+
const POST_PENDING_KEY = "post_pending";
|
|
573
|
+
/**
|
|
574
|
+
* Whether a per-file reindex left global post-processing (PageRank, the compact
|
|
575
|
+
* map, cache upkeep) for the next full run. While set, a full run may not take
|
|
576
|
+
* its no-op fast path.
|
|
577
|
+
*/
|
|
578
|
+
export function postPending(db) {
|
|
579
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(POST_PENDING_KEY);
|
|
580
|
+
return row?.value === "1";
|
|
581
|
+
}
|
|
582
|
+
/** Record that a per-file reindex wrote rows and deferred the global pass. */
|
|
583
|
+
export function setPostPending(db) {
|
|
584
|
+
db.prepare("INSERT INTO meta(key, value) VALUES (?, '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(POST_PENDING_KEY);
|
|
585
|
+
}
|
|
586
|
+
/** Clear the per-file marker once a full run has done the global pass. */
|
|
587
|
+
export function clearPostPending(db) {
|
|
588
|
+
db.prepare("DELETE FROM meta WHERE key = ?").run(POST_PENDING_KEY);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Whether an open index is current for a per-file write: this release's schema
|
|
592
|
+
* version and edge shape, not stale, and no full reindex pending. Callers that
|
|
593
|
+
* probe before taking the write lock re-check under it.
|
|
594
|
+
*/
|
|
595
|
+
export function isCurrentIndex(db) {
|
|
596
|
+
return (readSchemaVersion(db) === SCHEMA_VERSION && hasEdgeSpec(db) && !isStale(db) && !needsReindex(db));
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Open an existing index for a per-file write only when it is current.
|
|
600
|
+
*
|
|
601
|
+
* Unlike {@link openDb}, this never creates the `.speclaw` directory or the
|
|
602
|
+
* database, and never migrates, wipes, or re-stamps a schema: a missing index,
|
|
603
|
+
* an old or stale schema, or a pending full reindex returns `null` after a
|
|
604
|
+
* read-only probe, so the caller writes nothing and leaves the repair to the
|
|
605
|
+
* next full run.
|
|
606
|
+
*
|
|
607
|
+
* @param projectPath - Absolute path to the project root.
|
|
608
|
+
* @returns A read-write connection (WAL, foreign keys, 5 s busy timeout), or `null`.
|
|
609
|
+
*/
|
|
610
|
+
export function openCurrentDb(projectPath) {
|
|
611
|
+
const file = indexPath(projectPath);
|
|
612
|
+
if (!fs.existsSync(file))
|
|
613
|
+
return null;
|
|
614
|
+
let current;
|
|
615
|
+
const probe = new DatabaseSync(file, { readOnly: true });
|
|
616
|
+
try {
|
|
617
|
+
probe.exec("PRAGMA busy_timeout = 5000;");
|
|
618
|
+
current = isCurrentIndex(probe);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
current = false;
|
|
622
|
+
}
|
|
623
|
+
finally {
|
|
624
|
+
probe.close();
|
|
625
|
+
}
|
|
626
|
+
if (!current)
|
|
627
|
+
return null;
|
|
628
|
+
const db = new DatabaseSync(file);
|
|
629
|
+
db.exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
|
|
630
|
+
return db;
|
|
631
|
+
}
|
|
571
632
|
/** Absolute path to the index database file for a project. */
|
|
572
633
|
export function indexPath(projectPath) {
|
|
573
634
|
return path.join(projectPath, ".speclaw", "index.db");
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { openDb, clearNeedsReindex, needsReindex, FILE_NODE_KIND } from "./db.js";
|
|
4
|
+
import { openDb, openCurrentDb, isCurrentIndex, clearNeedsReindex, needsReindex, postPending, setPostPending, clearPostPending, FILE_NODE_KIND, } from "./db.js";
|
|
5
5
|
import { langForPath } from "./languages.js";
|
|
6
6
|
import { extract, importSpecifier } from "./extract.js";
|
|
7
7
|
import { getEmbedder, toBlob } from "./embedder.js";
|
|
8
8
|
import { contentHashFor, defaultEmbedText } from "./embed-input.js";
|
|
9
|
-
import { buildDirHashMap } from "./merkle.js";
|
|
10
|
-
import { loadAffectedConfig, isTestPath, inferModule } from "./affected-config.js";
|
|
9
|
+
import { buildDirHashMap, dirHash, HASH_EMPTY } from "./merkle.js";
|
|
10
|
+
import { loadAffectedConfig, isTestPath, inferModule, } from "./affected-config.js";
|
|
11
|
+
import { realPathOf } from "../../shared/paths.js";
|
|
11
12
|
import { personalizedPageRank, edgeWeightMul } from "./pagerank.js";
|
|
12
13
|
const SKIP_DIRS = new Set([
|
|
13
14
|
".git",
|
|
@@ -30,6 +31,132 @@ const SKIP_DIRS = new Set([
|
|
|
30
31
|
".cache",
|
|
31
32
|
]);
|
|
32
33
|
const MAX_FILE_BYTES = 1_500_000;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a directory or file name inside the project is walked by a full index.
|
|
36
|
+
* The single source of the skip rule shared by the walk and per-file reindex.
|
|
37
|
+
*/
|
|
38
|
+
function isSkippedDir(name) {
|
|
39
|
+
return SKIP_DIRS.has(name);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `p` with symlinks resolved and, where the platform reports it (macOS,
|
|
43
|
+
* Windows), each existing segment spelled as stored on disk. A missing tail
|
|
44
|
+
* keeps its given spelling. Falls back to {@link realPathOf} when the native
|
|
45
|
+
* resolver fails.
|
|
46
|
+
*
|
|
47
|
+
* @param p - An absolute path.
|
|
48
|
+
*/
|
|
49
|
+
function canonicalPathOf(p) {
|
|
50
|
+
const tail = [];
|
|
51
|
+
let cur = path.resolve(p);
|
|
52
|
+
for (;;) {
|
|
53
|
+
try {
|
|
54
|
+
return path.join(fs.realpathSync.native(cur), ...tail.reverse());
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const code = err.code;
|
|
58
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
59
|
+
return realPathOf(p);
|
|
60
|
+
const parent = path.dirname(cur);
|
|
61
|
+
if (parent === cur)
|
|
62
|
+
return realPathOf(p);
|
|
63
|
+
tail.push(path.basename(cur));
|
|
64
|
+
cur = parent;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Classify one path against the full walk's file-set rules, so a per-file
|
|
70
|
+
* reindex and a full index agree on which files are indexed (and the directory
|
|
71
|
+
* Merkle tree stays the hash of the same file set).
|
|
72
|
+
*
|
|
73
|
+
* A path is eligible when it lies inside `projectPath` (symlinks in its existing
|
|
74
|
+
* prefix resolved, and its spelling taken from the filesystem where the platform
|
|
75
|
+
* reports it, so a case variant on a case-insensitive volume yields the stored
|
|
76
|
+
* spelling), no directory segment is a skipped directory, its extension
|
|
77
|
+
* maps to an indexed language, it is a regular file, and it does not exceed the
|
|
78
|
+
* walk's size cap. A path that does not exist yields `missing` with its
|
|
79
|
+
* repo-relative form, so the caller can drop a deleted file's rows.
|
|
80
|
+
*
|
|
81
|
+
* @param projectPath - Absolute project root.
|
|
82
|
+
* @param absPath - Absolute path to classify.
|
|
83
|
+
*/
|
|
84
|
+
export function classifyIndexPath(projectPath, absPath) {
|
|
85
|
+
const relNative = path.relative(canonicalPathOf(projectPath), canonicalPathOf(absPath));
|
|
86
|
+
if (!relNative || relNative.startsWith("..") || path.isAbsolute(relNative)) {
|
|
87
|
+
return { eligible: false, rel: null, reason: "outside" };
|
|
88
|
+
}
|
|
89
|
+
const rel = relNative.split(path.sep).join("/");
|
|
90
|
+
const parts = rel.split("/");
|
|
91
|
+
if (parts.slice(0, -1).some(isSkippedDir))
|
|
92
|
+
return { eligible: false, rel, reason: "ignored" };
|
|
93
|
+
const lang = langForPath(rel);
|
|
94
|
+
if (!lang)
|
|
95
|
+
return { eligible: false, rel, reason: "language" };
|
|
96
|
+
let stat;
|
|
97
|
+
try {
|
|
98
|
+
stat = fs.statSync(path.join(projectPath, rel));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { eligible: false, rel, reason: "missing" };
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isFile())
|
|
104
|
+
return { eligible: false, rel, reason: "ignored" };
|
|
105
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
106
|
+
return { eligible: false, rel, reason: "size" };
|
|
107
|
+
return { eligible: true, rel, lang, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Whether a full walk of `projectPath` would yield exactly `rel`: every segment
|
|
111
|
+
* is listed by its parent directory with exactly that spelling (a case-sensitive
|
|
112
|
+
* compare, so a case-only rename on a case-insensitive volume does not match),
|
|
113
|
+
* every directory segment is a real, non-skipped directory and the leaf a
|
|
114
|
+
* regular file (no segment is a symlink, as the walk never follows one), and
|
|
115
|
+
* {@link classifyIndexPath} finds it eligible under the same `rel`.
|
|
116
|
+
*
|
|
117
|
+
* A full run keeps a stored row its walk did not see only when this holds;
|
|
118
|
+
* any other row would duplicate the file's symbols under a second path.
|
|
119
|
+
*
|
|
120
|
+
* @param projectPath - Absolute project root (as walked).
|
|
121
|
+
* @param rel - Repo-relative path with `/` separators.
|
|
122
|
+
* @param listings - Optional cache of directory listings, keyed by absolute
|
|
123
|
+
* directory, shared across calls in one run.
|
|
124
|
+
*/
|
|
125
|
+
export function walkWouldYield(projectPath, rel, listings = new Map()) {
|
|
126
|
+
const parts = rel.split("/");
|
|
127
|
+
if (parts.some((p) => p === "" || p === "." || p === ".."))
|
|
128
|
+
return false;
|
|
129
|
+
let dir = projectPath;
|
|
130
|
+
for (let i = 0; i < parts.length; i++) {
|
|
131
|
+
const name = parts[i];
|
|
132
|
+
let names = listings.get(dir);
|
|
133
|
+
if (names === undefined) {
|
|
134
|
+
try {
|
|
135
|
+
names = new Set(fs.readdirSync(dir));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
names = null;
|
|
139
|
+
}
|
|
140
|
+
listings.set(dir, names);
|
|
141
|
+
}
|
|
142
|
+
if (!names?.has(name))
|
|
143
|
+
return false;
|
|
144
|
+
const full = path.join(dir, name);
|
|
145
|
+
let stat;
|
|
146
|
+
try {
|
|
147
|
+
stat = fs.lstatSync(full);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const leaf = i === parts.length - 1;
|
|
153
|
+
if (leaf ? !stat.isFile() : !stat.isDirectory() || isSkippedDir(name))
|
|
154
|
+
return false;
|
|
155
|
+
dir = full;
|
|
156
|
+
}
|
|
157
|
+
const c = classifyIndexPath(projectPath, path.join(projectPath, rel));
|
|
158
|
+
return c.eligible && c.rel === rel;
|
|
159
|
+
}
|
|
33
160
|
function hashOf(content) {
|
|
34
161
|
return createHash("sha256").update(content).digest("hex");
|
|
35
162
|
}
|
|
@@ -431,7 +558,7 @@ function* walkFiles(root) {
|
|
|
431
558
|
for (const entry of entries) {
|
|
432
559
|
const full = path.join(dir, entry.name);
|
|
433
560
|
if (entry.isDirectory()) {
|
|
434
|
-
if (!
|
|
561
|
+
if (!isSkippedDir(entry.name))
|
|
435
562
|
stack.push(full);
|
|
436
563
|
}
|
|
437
564
|
else if (entry.isFile()) {
|
|
@@ -441,6 +568,152 @@ function* walkFiles(root) {
|
|
|
441
568
|
}
|
|
442
569
|
}
|
|
443
570
|
}
|
|
571
|
+
function prepareFileWriter(db, embedder, cfg, touch = true) {
|
|
572
|
+
return {
|
|
573
|
+
db,
|
|
574
|
+
touch,
|
|
575
|
+
collectOwners: !touch,
|
|
576
|
+
embedder,
|
|
577
|
+
cfg,
|
|
578
|
+
insFile: db.prepare("INSERT INTO files(path, hash, lang, is_test, module, mtime_ms, size) VALUES (?, ?, ?, ?, ?, ?, ?)"),
|
|
579
|
+
updFile: db.prepare("UPDATE files SET hash = ?, lang = ?, is_test = ?, module = ?, mtime_ms = ?, size = ? WHERE id = ?"),
|
|
580
|
+
detach: db.prepare("UPDATE edges SET dst_node_id = NULL WHERE dst_node_id IN (SELECT id FROM nodes WHERE file_id = ?)"),
|
|
581
|
+
detachedOwners: db.prepare(`SELECT DISTINCT src_file_id AS id FROM edges
|
|
582
|
+
WHERE src_file_id <> ? AND dst_node_id IN (SELECT id FROM nodes WHERE file_id = ?)`),
|
|
583
|
+
delNodes: db.prepare("DELETE FROM nodes WHERE file_id = ?"),
|
|
584
|
+
delEdges: db.prepare("DELETE FROM edges WHERE src_file_id = ?"),
|
|
585
|
+
delCoverage: db.prepare("DELETE FROM coverage_links WHERE file_path = ?"),
|
|
586
|
+
delFile: db.prepare("DELETE FROM files WHERE id = ?"),
|
|
587
|
+
insNode: db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature, body_hash, norm_hash, content_hash)
|
|
588
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
589
|
+
insMetrics: db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`),
|
|
590
|
+
insEdge: db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line, is_member, spec) VALUES (?, ?, ?, ?, ?, ?, ?)`),
|
|
591
|
+
insCoverage: db.prepare(`INSERT OR REPLACE INTO coverage_links(
|
|
592
|
+
artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
593
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
594
|
+
insCache: db.prepare(`INSERT INTO embedding_cache(content_hash, model, dim, vec, created_at, last_seen_at)
|
|
595
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
596
|
+
ON CONFLICT(content_hash, model) DO UPDATE SET last_seen_at = excluded.last_seen_at`),
|
|
597
|
+
hasCache: db.prepare(`SELECT 1 AS ok FROM embedding_cache WHERE content_hash = ? AND model = ?`),
|
|
598
|
+
touchCache: db.prepare(`UPDATE embedding_cache SET last_seen_at = ? WHERE content_hash = ? AND model = ?`),
|
|
599
|
+
insNodeText: db.prepare(`INSERT INTO node_text(node_id, name, subtokens, signature, doc) VALUES (?, ?, ?, ?, ?)
|
|
600
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
601
|
+
name = excluded.name,
|
|
602
|
+
subtokens = excluded.subtokens,
|
|
603
|
+
signature = excluded.signature,
|
|
604
|
+
doc = excluded.doc`),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Reset every edge whose destination is one of a file's nodes, before those
|
|
609
|
+
* nodes are deleted. Without this, a rowid freed by the delete and reused by a
|
|
610
|
+
* node inserted later in the same run would silently re-point another file's
|
|
611
|
+
* edge at an unrelated symbol; a post-hoc "id not in nodes" check cannot see
|
|
612
|
+
* that. The run's resolution pass then re-binds the edges by their rules.
|
|
613
|
+
*
|
|
614
|
+
* @returns The ids of the other files whose edges were reset (empty on a full run, which does not need them).
|
|
615
|
+
*/
|
|
616
|
+
function detachFileNodes(w, fileId) {
|
|
617
|
+
// Covers: req~edge-ids-survive-reindex~1
|
|
618
|
+
const owners = w.collectOwners
|
|
619
|
+
? w.detachedOwners.all(fileId, fileId).map((r) => r.id)
|
|
620
|
+
: [];
|
|
621
|
+
w.detach.run(fileId);
|
|
622
|
+
return owners;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Drop a removed file: detach edges into it, delete its coverage links (the
|
|
626
|
+
* file-level ones have no node to cascade from), then delete its row (nodes and
|
|
627
|
+
* edges cascade).
|
|
628
|
+
*/
|
|
629
|
+
function removeFileRows(w, fileId, rel) {
|
|
630
|
+
const owners = detachFileNodes(w, fileId);
|
|
631
|
+
w.delCoverage.run(rel);
|
|
632
|
+
w.delFile.run(fileId);
|
|
633
|
+
return owners;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* (Re-)extract one file and write its whole fragment: upsert its `files` row,
|
|
637
|
+
* replace its nodes, file-owner node, edges, `node_text`/FTS rows, metrics, and
|
|
638
|
+
* coverage links, and embed cache misses through `embedding_cache`. Edges are
|
|
639
|
+
* written unresolved; the caller runs {@link resolveEdges}.
|
|
640
|
+
*
|
|
641
|
+
* @returns The file id and the ids of other files whose edges were detached.
|
|
642
|
+
*/
|
|
643
|
+
async function writeFileFragment(w, f) {
|
|
644
|
+
const { rel, lang, content, stats } = f;
|
|
645
|
+
let fileId;
|
|
646
|
+
let detachedOwners = [];
|
|
647
|
+
const isTest = isTestPath(rel, w.cfg.testGlobs) ? 1 : 0;
|
|
648
|
+
const mod = inferModule(rel);
|
|
649
|
+
if (f.priorId !== null) {
|
|
650
|
+
w.updFile.run(f.hash, lang.id, isTest, mod, f.mtimeMs, f.size, f.priorId);
|
|
651
|
+
// Covers: req~edge-ids-survive-reindex~1
|
|
652
|
+
detachedOwners = detachFileNodes(w, f.priorId);
|
|
653
|
+
w.delNodes.run(f.priorId);
|
|
654
|
+
w.delEdges.run(f.priorId);
|
|
655
|
+
w.delCoverage.run(rel);
|
|
656
|
+
fileId = f.priorId;
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
fileId = Number(w.insFile.run(rel, f.hash, lang.id, isTest, mod, f.mtimeMs, f.size).lastInsertRowid);
|
|
660
|
+
}
|
|
661
|
+
const { symbols, refs, coverage, reexports } = await extract(content, lang);
|
|
662
|
+
const nodeIds = [];
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
for (const s of symbols) {
|
|
665
|
+
const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
|
|
666
|
+
const embedText = defaultEmbedText(s.kind, s.name, s.signature);
|
|
667
|
+
const ch = contentHashFor({
|
|
668
|
+
lang: lang.id,
|
|
669
|
+
kind: s.kind,
|
|
670
|
+
name: s.name,
|
|
671
|
+
signature: s.signature,
|
|
672
|
+
embedText,
|
|
673
|
+
});
|
|
674
|
+
const id = Number(w.insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature, s.bodyHash, s.normHash, ch).lastInsertRowid);
|
|
675
|
+
nodeIds.push(id);
|
|
676
|
+
w.insMetrics.run(id, s.loc, s.maxNesting, s.branches);
|
|
677
|
+
w.insNodeText.run(id, s.name, s.subtokens, s.signature ?? "", s.docstring);
|
|
678
|
+
const hit = w.hasCache.get(ch, w.embedder.id);
|
|
679
|
+
if (hit) {
|
|
680
|
+
if (w.touch)
|
|
681
|
+
w.touchCache.run(now, ch, w.embedder.id);
|
|
682
|
+
stats.fromCache++;
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
const vec = await w.embedder.embed(embedText);
|
|
686
|
+
w.insCache.run(ch, w.embedder.id, w.embedder.dim, toBlob(vec), now, now);
|
|
687
|
+
stats.computed++;
|
|
688
|
+
}
|
|
689
|
+
stats.embeddings++;
|
|
690
|
+
}
|
|
691
|
+
// Covers: req~impact-id-first~1
|
|
692
|
+
// Imports and calls outside any definition belong to the file: give the
|
|
693
|
+
// file one hidden owner node so no edge is ever stored ownerless. A file
|
|
694
|
+
// with no symbols or with re-exports (a barrel: `export * from "./x"`)
|
|
695
|
+
// gets one too, so an import of it resolves to the file instead of
|
|
696
|
+
// looking like a package. It gets no text, embedding, or metrics.
|
|
697
|
+
const isOrphan = (r) => r.kind === "import" || r.ownerIndex === null;
|
|
698
|
+
let fileOwner = null;
|
|
699
|
+
if (refs.some(isOrphan) || symbols.length === 0 || reexports.length > 0) {
|
|
700
|
+
const lastLine = content.length === 0 ? 1 : content.replace(/\n$/, "").split("\n").length;
|
|
701
|
+
fileOwner = Number(w.insNode.run(fileId, rel, FILE_NODE_KIND, 1, lastLine, 0, content.length, null, null, null, null, null).lastInsertRowid);
|
|
702
|
+
}
|
|
703
|
+
for (const r of refs) {
|
|
704
|
+
const srcId = isOrphan(r) ? fileOwner : nodeIds[r.ownerIndex];
|
|
705
|
+
w.insEdge.run(srcId, fileId, r.name, r.kind, r.line, r.member, r.spec);
|
|
706
|
+
stats.edges++;
|
|
707
|
+
}
|
|
708
|
+
const sourceType = inferSourceType(rel);
|
|
709
|
+
for (const c of coverage) {
|
|
710
|
+
const nodeId = c.ownerIndex !== null ? nodeIds[c.ownerIndex] : null;
|
|
711
|
+
w.insCoverage.run(c.artifactType, c.name, c.revision, c.kind, rel, c.line, nodeId, sourceType, "comment");
|
|
712
|
+
}
|
|
713
|
+
stats.files++;
|
|
714
|
+
stats.nodes += symbols.length;
|
|
715
|
+
return { fileId, detachedOwners };
|
|
716
|
+
}
|
|
444
717
|
/**
|
|
445
718
|
* Build or incrementally refresh the index for a project.
|
|
446
719
|
*
|
|
@@ -467,7 +740,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
467
740
|
const maxCacheMB = opts.maxCacheMB ?? DEFAULT_MAX_CACHE_MB;
|
|
468
741
|
const retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
469
742
|
const db = openDb(projectPath);
|
|
470
|
-
|
|
743
|
+
opts.hooks?.onOpen?.(db);
|
|
471
744
|
const embedder = getEmbedder();
|
|
472
745
|
const stats = {
|
|
473
746
|
files: 0,
|
|
@@ -485,61 +758,45 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
485
758
|
nextStep: "",
|
|
486
759
|
};
|
|
487
760
|
const cfg = loadAffectedConfig(projectPath);
|
|
488
|
-
const existing = new Map();
|
|
489
|
-
for (const row of db.prepare("SELECT id, path, hash, mtime_ms, size FROM files").all()) {
|
|
490
|
-
existing.set(row.path, {
|
|
491
|
-
id: row.id,
|
|
492
|
-
hash: row.hash,
|
|
493
|
-
mtime_ms: row.mtime_ms,
|
|
494
|
-
size: row.size,
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
const prevRoot = db.prepare("SELECT hash FROM dir_hashes WHERE path = ''").get();
|
|
498
761
|
const seen = new Set();
|
|
499
762
|
const fileHashes = new Map();
|
|
500
|
-
const
|
|
501
|
-
const updFile = db.prepare("UPDATE files SET hash = ?, lang = ?, is_test = ?, module = ?, mtime_ms = ?, size = ? WHERE id = ?");
|
|
502
|
-
const delNodes = db.prepare("DELETE FROM nodes WHERE file_id = ?");
|
|
503
|
-
const delEdges = db.prepare("DELETE FROM edges WHERE src_file_id = ?");
|
|
504
|
-
const delCoverage = db.prepare("DELETE FROM coverage_links WHERE file_path = ?");
|
|
505
|
-
const insNode = db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature, body_hash, norm_hash, content_hash)
|
|
506
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
507
|
-
const insMetrics = db.prepare(`INSERT INTO node_metrics(node_id, loc, max_nesting, branches) VALUES (?, ?, ?, ?)`);
|
|
508
|
-
const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line, is_member, spec) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
509
|
-
const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
|
|
510
|
-
artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
511
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
512
|
-
const insCache = db.prepare(`INSERT INTO embedding_cache(content_hash, model, dim, vec, created_at, last_seen_at)
|
|
513
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
514
|
-
ON CONFLICT(content_hash, model) DO UPDATE SET last_seen_at = excluded.last_seen_at`);
|
|
515
|
-
const hasCache = db.prepare(`SELECT 1 AS ok FROM embedding_cache WHERE content_hash = ? AND model = ?`);
|
|
516
|
-
const insNodeText = db.prepare(`INSERT INTO node_text(node_id, name, subtokens, signature, doc) VALUES (?, ?, ?, ?, ?)
|
|
517
|
-
ON CONFLICT(node_id) DO UPDATE SET
|
|
518
|
-
name = excluded.name,
|
|
519
|
-
subtokens = excluded.subtokens,
|
|
520
|
-
signature = excluded.signature,
|
|
521
|
-
doc = excluded.doc`);
|
|
763
|
+
const w = prepareFileWriter(db, embedder, cfg);
|
|
522
764
|
const allFiles = [...walkFiles(projectPath)];
|
|
523
765
|
// Set inside the transaction; read after it to decide whether the map is written.
|
|
524
766
|
let noop;
|
|
525
|
-
db.exec("BEGIN");
|
|
526
767
|
try {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
768
|
+
await opts.hooks?.afterWalk?.();
|
|
769
|
+
// Take the write lock before reading the stored file set: a per-file
|
|
770
|
+
// reindex committing a new file's row between that read and this run's
|
|
771
|
+
// insert would otherwise fail the whole run on the unique path. Per-file
|
|
772
|
+
// runs wait for (or give up on) this lock instead. Inside the try so a
|
|
773
|
+
// SQLITE_BUSY here still closes the connection.
|
|
774
|
+
db.exec("BEGIN IMMEDIATE");
|
|
775
|
+
const force = Boolean(opts.force) || needsReindex(db);
|
|
776
|
+
const existing = new Map();
|
|
777
|
+
for (const row of db
|
|
778
|
+
.prepare("SELECT id, path, hash, mtime_ms, size FROM files")
|
|
779
|
+
.all()) {
|
|
780
|
+
existing.set(row.path, {
|
|
781
|
+
id: row.id,
|
|
782
|
+
hash: row.hash,
|
|
783
|
+
mtime_ms: row.mtime_ms,
|
|
784
|
+
size: row.size,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
const prevRoot = db.prepare("SELECT hash FROM dir_hashes WHERE path = ''").get();
|
|
788
|
+
// Stat, hash, and (when changed) re-extract one walked file.
|
|
789
|
+
const visit = async (filePath, rel) => {
|
|
533
790
|
seen.add(rel);
|
|
534
791
|
const lang = langForPath(filePath);
|
|
535
792
|
let stat;
|
|
536
793
|
try {
|
|
537
794
|
stat = fs.statSync(filePath);
|
|
538
795
|
if (stat.size > MAX_FILE_BYTES)
|
|
539
|
-
|
|
796
|
+
return;
|
|
540
797
|
}
|
|
541
798
|
catch {
|
|
542
|
-
|
|
799
|
+
return;
|
|
543
800
|
}
|
|
544
801
|
const prior = existing.get(rel);
|
|
545
802
|
const mtimeMs = Math.trunc(stat.mtimeMs);
|
|
@@ -553,98 +810,60 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
553
810
|
fileHashes.set(rel, prior.hash);
|
|
554
811
|
stats.skippedByStat++;
|
|
555
812
|
stats.unchanged++;
|
|
556
|
-
|
|
813
|
+
return;
|
|
557
814
|
}
|
|
558
815
|
let content;
|
|
559
816
|
try {
|
|
560
817
|
content = fs.readFileSync(filePath, "utf8");
|
|
561
818
|
}
|
|
562
819
|
catch {
|
|
563
|
-
|
|
820
|
+
return;
|
|
564
821
|
}
|
|
565
822
|
const hash = hashOf(content);
|
|
566
823
|
fileHashes.set(rel, hash);
|
|
567
824
|
if (!force && prior && prior.hash === hash) {
|
|
568
|
-
updFile.run(hash, lang.id, isTestPath(rel, cfg.testGlobs) ? 1 : 0, inferModule(rel), mtimeMs, size, prior.id);
|
|
825
|
+
w.updFile.run(hash, lang.id, isTestPath(rel, cfg.testGlobs) ? 1 : 0, inferModule(rel), mtimeMs, size, prior.id);
|
|
569
826
|
stats.unchanged++;
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
let fileId;
|
|
573
|
-
const isTest = isTestPath(rel, cfg.testGlobs) ? 1 : 0;
|
|
574
|
-
const mod = inferModule(rel);
|
|
575
|
-
if (prior) {
|
|
576
|
-
updFile.run(hash, lang.id, isTest, mod, mtimeMs, size, prior.id);
|
|
577
|
-
delNodes.run(prior.id);
|
|
578
|
-
delEdges.run(prior.id);
|
|
579
|
-
delCoverage.run(rel);
|
|
580
|
-
fileId = prior.id;
|
|
581
|
-
}
|
|
582
|
-
else {
|
|
583
|
-
fileId = Number(insFile.run(rel, hash, lang.id, isTest, mod, mtimeMs, size).lastInsertRowid);
|
|
827
|
+
return;
|
|
584
828
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const hit = hasCache.get(ch, embedder.id);
|
|
604
|
-
if (hit) {
|
|
605
|
-
touchCache.run(now, ch, embedder.id);
|
|
606
|
-
stats.fromCache++;
|
|
607
|
-
}
|
|
608
|
-
else {
|
|
609
|
-
const vec = await embedder.embed(embedText);
|
|
610
|
-
insCache.run(ch, embedder.id, embedder.dim, toBlob(vec), now, now);
|
|
611
|
-
stats.computed++;
|
|
612
|
-
}
|
|
613
|
-
stats.embeddings++;
|
|
614
|
-
}
|
|
615
|
-
// Covers: req~impact-id-first~1
|
|
616
|
-
// Imports and calls outside any definition belong to the file: give the
|
|
617
|
-
// file one hidden owner node so no edge is ever stored ownerless. A file
|
|
618
|
-
// with no symbols or with re-exports (a barrel: `export * from "./x"`)
|
|
619
|
-
// gets one too, so an import of it resolves to the file instead of
|
|
620
|
-
// looking like a package. It gets no text, embedding, or metrics.
|
|
621
|
-
const isOrphan = (r) => r.kind === "import" || r.ownerIndex === null;
|
|
622
|
-
let fileOwner = null;
|
|
623
|
-
if (refs.some(isOrphan) || symbols.length === 0 || reexports.length > 0) {
|
|
624
|
-
const lastLine = content.length === 0 ? 1 : content.replace(/\n$/, "").split("\n").length;
|
|
625
|
-
fileOwner = Number(insNode.run(fileId, rel, FILE_NODE_KIND, 1, lastLine, 0, content.length, null, null, null, null, null).lastInsertRowid);
|
|
626
|
-
}
|
|
627
|
-
for (const r of refs) {
|
|
628
|
-
const srcId = isOrphan(r) ? fileOwner : nodeIds[r.ownerIndex];
|
|
629
|
-
insEdge.run(srcId, fileId, r.name, r.kind, r.line, r.member, r.spec);
|
|
630
|
-
stats.edges++;
|
|
631
|
-
}
|
|
632
|
-
const sourceType = inferSourceType(rel);
|
|
633
|
-
for (const c of coverage) {
|
|
634
|
-
const nodeId = c.ownerIndex !== null ? nodeIds[c.ownerIndex] : null;
|
|
635
|
-
insCoverage.run(c.artifactType, c.name, c.revision, c.kind, rel, c.line, nodeId, sourceType, "comment");
|
|
636
|
-
}
|
|
637
|
-
stats.files++;
|
|
638
|
-
stats.nodes += symbols.length;
|
|
829
|
+
await writeFileFragment(w, {
|
|
830
|
+
rel,
|
|
831
|
+
lang,
|
|
832
|
+
content,
|
|
833
|
+
hash,
|
|
834
|
+
mtimeMs,
|
|
835
|
+
size,
|
|
836
|
+
priorId: prior?.id ?? null,
|
|
837
|
+
stats,
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
let done = 0;
|
|
841
|
+
for (const filePath of allFiles) {
|
|
842
|
+
const rel = path.relative(projectPath, filePath).split(path.sep).join("/");
|
|
843
|
+
done++;
|
|
844
|
+
if (onProgress)
|
|
845
|
+
onProgress({ file: rel, done, total: allFiles.length });
|
|
846
|
+
await visit(filePath, rel);
|
|
639
847
|
}
|
|
848
|
+
const listings = new Map();
|
|
640
849
|
for (const [rel, row] of existing) {
|
|
641
850
|
if (!seen.has(rel)) {
|
|
642
|
-
|
|
643
|
-
|
|
851
|
+
// The walk ran before the lock: a per-file reindex may have committed
|
|
852
|
+
// a row for a file created after the walk passed its directory. Keep
|
|
853
|
+
// and index a row only when the walk would now yield that exact path;
|
|
854
|
+
// drop the rest (deleted, ineligible, renamed by case only, or reached
|
|
855
|
+
// only through a symlink) so no file is held under two paths.
|
|
856
|
+
const abs = path.join(projectPath, rel);
|
|
857
|
+
if (!walkWouldYield(projectPath, rel, listings)) {
|
|
858
|
+
// Covers: req~edge-ids-survive-reindex~1
|
|
859
|
+
removeFileRows(w, row.id, rel);
|
|
860
|
+
stats.removed++;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
await visit(abs, rel);
|
|
644
864
|
}
|
|
645
|
-
|
|
865
|
+
if (!fileHashes.has(rel))
|
|
646
866
|
fileHashes.set(rel, row.hash);
|
|
647
|
-
}
|
|
648
867
|
}
|
|
649
868
|
const dirMap = buildDirHashMap(fileHashes);
|
|
650
869
|
const rootHash = dirMap.get("") ?? "";
|
|
@@ -655,7 +874,14 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
655
874
|
// would write — skip them so a run on every session start stays cheap.
|
|
656
875
|
// `rootUnchanged` already implies no force (explicit or needs_reindex); an
|
|
657
876
|
// explicit prune or cache cap is a maintenance request and runs in full.
|
|
658
|
-
|
|
877
|
+
// A per-file reindex already moved the stored hashes to match the tree but
|
|
878
|
+
// deferred PageRank and the map to here: its marker disables the fast path.
|
|
879
|
+
noop =
|
|
880
|
+
stats.rootUnchanged &&
|
|
881
|
+
!prune &&
|
|
882
|
+
opts.maxCacheMB === undefined &&
|
|
883
|
+
stats.removed === 0 &&
|
|
884
|
+
!postPending(db);
|
|
659
885
|
if (!noop) {
|
|
660
886
|
const now = Date.now();
|
|
661
887
|
db.prepare("DELETE FROM dir_hashes").run();
|
|
@@ -666,6 +892,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
666
892
|
}
|
|
667
893
|
resolveEdges(db);
|
|
668
894
|
recomputeGlobalPagerank(db);
|
|
895
|
+
clearPostPending(db);
|
|
669
896
|
// Touch last_seen for all live content hashes under active model
|
|
670
897
|
db.prepare(`UPDATE embedding_cache SET last_seen_at = ?
|
|
671
898
|
WHERE model = ?
|
|
@@ -688,7 +915,10 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
688
915
|
db.exec("COMMIT");
|
|
689
916
|
}
|
|
690
917
|
catch (err) {
|
|
691
|
-
|
|
918
|
+
// SQLite may already have rolled back (or BEGIN never succeeded): an
|
|
919
|
+
// unguarded ROLLBACK would throw and mask the original error.
|
|
920
|
+
if (db.isTransaction)
|
|
921
|
+
db.exec("ROLLBACK");
|
|
692
922
|
throw err;
|
|
693
923
|
}
|
|
694
924
|
finally {
|
|
@@ -706,6 +936,202 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
706
936
|
}
|
|
707
937
|
return stats;
|
|
708
938
|
}
|
|
939
|
+
/**
|
|
940
|
+
* Re-index exactly the given files in an existing, current index — the
|
|
941
|
+
* per-edit path behind `speclaw reindex-file`.
|
|
942
|
+
*
|
|
943
|
+
* Inside one `BEGIN IMMEDIATE` transaction (so each file is read after the
|
|
944
|
+
* write lock is held, and the last run to begin saw the last edit), each path
|
|
945
|
+
* is classified with the full walk's rules ({@link classifyIndexPath}): a
|
|
946
|
+
* changed file is re-extracted with the same fragment writer as
|
|
947
|
+
* {@link buildIndex}, a deleted file's rows are dropped, an unchanged file only
|
|
948
|
+
* refreshes its stat columns, and anything else is skipped. Edges are then
|
|
949
|
+
* resolved by {@link resolveEdges} scoped to the re-indexed files plus the files
|
|
950
|
+
* whose edges were detached from them (that scope also re-tries NULL edges that
|
|
951
|
+
* name a symbol the re-indexed files now define), and the directory hashes of
|
|
952
|
+
* the touched files' ancestors are recomputed.
|
|
953
|
+
*
|
|
954
|
+
* Global post-processing is deferred: no PageRank recomputation (the old rows of
|
|
955
|
+
* replaced nodes drop by cascade; new symbols have none until the next full
|
|
956
|
+
* run), no embedding-cache touch or eviction, no `docs/compass.md` write, no
|
|
957
|
+
* `meta.indexed_at` change, and no Compass call log entry. When it writes any
|
|
958
|
+
* row it sets `meta.post_pending`, which makes the next full run skip its no-op
|
|
959
|
+
* fast path and do that work.
|
|
960
|
+
*
|
|
961
|
+
* Caveat inherited from scoped resolution: an edge already bound to a node
|
|
962
|
+
* elsewhere is never re-ranked, so when an edit adds a better candidate for an
|
|
963
|
+
* ambiguous name the old binding stays until the next full run that re-extracts
|
|
964
|
+
* the calling file.
|
|
965
|
+
*
|
|
966
|
+
* Never creates, migrates, or wipes the database: a missing index or one that
|
|
967
|
+
* needs a full reindex returns `stale: true` with nothing written.
|
|
968
|
+
*
|
|
969
|
+
* @param projectPath - Absolute project root.
|
|
970
|
+
* @param paths - File paths, absolute or relative to `projectPath`.
|
|
971
|
+
* @throws When the database stays locked past the busy timeout (`SQLITE_BUSY`) or a write fails; the transaction is rolled back.
|
|
972
|
+
*/
|
|
973
|
+
export async function indexFiles(projectPath, paths) {
|
|
974
|
+
// Covers: req~reindex-on-edit~1
|
|
975
|
+
const result = {
|
|
976
|
+
reindexed: [],
|
|
977
|
+
removed: [],
|
|
978
|
+
unchanged: [],
|
|
979
|
+
skipped: [],
|
|
980
|
+
stale: false,
|
|
981
|
+
};
|
|
982
|
+
const db = openCurrentDb(projectPath);
|
|
983
|
+
if (!db) {
|
|
984
|
+
result.stale = true;
|
|
985
|
+
return result;
|
|
986
|
+
}
|
|
987
|
+
try {
|
|
988
|
+
const embedder = getEmbedder();
|
|
989
|
+
const w = prepareFileWriter(db, embedder, loadAffectedConfig(projectPath), false);
|
|
990
|
+
const getFile = db.prepare("SELECT id, hash FROM files WHERE path = ?");
|
|
991
|
+
const stats = {
|
|
992
|
+
files: 0,
|
|
993
|
+
nodes: 0,
|
|
994
|
+
edges: 0,
|
|
995
|
+
embeddings: 0,
|
|
996
|
+
computed: 0,
|
|
997
|
+
fromCache: 0,
|
|
998
|
+
};
|
|
999
|
+
const scope = new Set();
|
|
1000
|
+
const changed = [];
|
|
1001
|
+
const targets = [...new Set(paths.map((p) => path.resolve(projectPath, p)))];
|
|
1002
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1003
|
+
try {
|
|
1004
|
+
// The probe ran before the lock: a full run (or another version) may
|
|
1005
|
+
// have migrated, wiped, or flagged the index since. Re-check under the
|
|
1006
|
+
// lock and leave it to the next full run if so.
|
|
1007
|
+
if (!isCurrentIndex(db)) {
|
|
1008
|
+
db.exec("ROLLBACK");
|
|
1009
|
+
result.stale = true;
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
for (const abs of targets) {
|
|
1013
|
+
let c = classifyIndexPath(projectPath, abs);
|
|
1014
|
+
// Register only the path a full walk would yield (exact spelling, no
|
|
1015
|
+
// symlink segment); a full run would otherwise drop the row again.
|
|
1016
|
+
if (c.eligible && !walkWouldYield(projectPath, c.rel)) {
|
|
1017
|
+
c = { eligible: false, rel: c.rel, reason: "ignored" };
|
|
1018
|
+
}
|
|
1019
|
+
if (!c.eligible) {
|
|
1020
|
+
const row = c.reason === "missing" && c.rel !== null
|
|
1021
|
+
? getFile.get(c.rel)
|
|
1022
|
+
: undefined;
|
|
1023
|
+
if (row && c.rel !== null) {
|
|
1024
|
+
// Covers: req~edge-ids-survive-reindex~1
|
|
1025
|
+
for (const id of removeFileRows(w, row.id, c.rel))
|
|
1026
|
+
scope.add(id);
|
|
1027
|
+
result.removed.push(c.rel);
|
|
1028
|
+
changed.push(c.rel);
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
result.skipped.push({ path: c.rel ?? abs, reason: c.reason });
|
|
1032
|
+
}
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
let content;
|
|
1036
|
+
try {
|
|
1037
|
+
content = fs.readFileSync(path.join(projectPath, c.rel), "utf8");
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
result.skipped.push({ path: c.rel, reason: "missing" });
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
const hash = hashOf(content);
|
|
1044
|
+
const prior = getFile.get(c.rel);
|
|
1045
|
+
if (prior && prior.hash === hash) {
|
|
1046
|
+
w.updFile.run(hash, c.lang.id, isTestPath(c.rel, w.cfg.testGlobs) ? 1 : 0, inferModule(c.rel), c.mtimeMs, c.size, prior.id);
|
|
1047
|
+
result.unchanged.push(c.rel);
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
const written = await writeFileFragment(w, {
|
|
1051
|
+
rel: c.rel,
|
|
1052
|
+
lang: c.lang,
|
|
1053
|
+
content,
|
|
1054
|
+
hash,
|
|
1055
|
+
mtimeMs: c.mtimeMs,
|
|
1056
|
+
size: c.size,
|
|
1057
|
+
priorId: prior?.id ?? null,
|
|
1058
|
+
stats,
|
|
1059
|
+
});
|
|
1060
|
+
scope.add(written.fileId);
|
|
1061
|
+
for (const id of written.detachedOwners)
|
|
1062
|
+
scope.add(id);
|
|
1063
|
+
result.reindexed.push(c.rel);
|
|
1064
|
+
changed.push(c.rel);
|
|
1065
|
+
}
|
|
1066
|
+
if (changed.length > 0) {
|
|
1067
|
+
resolveEdges(db, [...scope]);
|
|
1068
|
+
updateAncestorDirHashes(db, changed);
|
|
1069
|
+
setPostPending(db);
|
|
1070
|
+
}
|
|
1071
|
+
db.exec("COMMIT");
|
|
1072
|
+
}
|
|
1073
|
+
catch (err) {
|
|
1074
|
+
if (db.isTransaction)
|
|
1075
|
+
db.exec("ROLLBACK");
|
|
1076
|
+
throw err;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
finally {
|
|
1080
|
+
db.close();
|
|
1081
|
+
}
|
|
1082
|
+
return result;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Recompute the `dir_hashes` rows of every ancestor directory of the given
|
|
1086
|
+
* files (deepest first, root last) from the stored `files.hash` values and the
|
|
1087
|
+
* child directory rows, with the same hash rule and `n_files` count as the full
|
|
1088
|
+
* walk ({@link buildDirHashMap}). A directory left with no indexed file loses
|
|
1089
|
+
* its row; the root row always exists.
|
|
1090
|
+
*/
|
|
1091
|
+
function updateAncestorDirHashes(db, rels) {
|
|
1092
|
+
const dirs = new Set([""]);
|
|
1093
|
+
for (const rel of rels) {
|
|
1094
|
+
const parts = rel.split("/");
|
|
1095
|
+
for (let i = 1; i < parts.length; i++)
|
|
1096
|
+
dirs.add(parts.slice(0, i).join("/"));
|
|
1097
|
+
}
|
|
1098
|
+
const depth = (d) => (d === "" ? 0 : d.split("/").length);
|
|
1099
|
+
const ordered = [...dirs].sort((a, b) => depth(b) - depth(a));
|
|
1100
|
+
const all = db.prepare("SELECT path, hash FROM files");
|
|
1101
|
+
// '/' + 1 is '0': [dir/, dir0) is every path under dir/ in byte order.
|
|
1102
|
+
const under = db.prepare("SELECT path, hash FROM files WHERE path >= ? AND path < ?");
|
|
1103
|
+
const dirRow = db.prepare("SELECT hash FROM dir_hashes WHERE path = ?");
|
|
1104
|
+
const upsert = db.prepare(`INSERT INTO dir_hashes(path, hash, n_files, updated_at) VALUES (?, ?, ?, ?)
|
|
1105
|
+
ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, n_files = excluded.n_files,
|
|
1106
|
+
updated_at = excluded.updated_at`);
|
|
1107
|
+
const del = db.prepare("DELETE FROM dir_hashes WHERE path = ?");
|
|
1108
|
+
const now = Date.now();
|
|
1109
|
+
for (const dir of ordered) {
|
|
1110
|
+
const prefix = dir === "" ? "" : `${dir}/`;
|
|
1111
|
+
const rows = (dir === "" ? all.all() : under.all(prefix, `${dir}0`));
|
|
1112
|
+
if (rows.length === 0 && dir !== "") {
|
|
1113
|
+
del.run(dir);
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
const children = new Map();
|
|
1117
|
+
for (const r of rows) {
|
|
1118
|
+
const rest = r.path.slice(prefix.length);
|
|
1119
|
+
const slash = rest.indexOf("/");
|
|
1120
|
+
if (slash < 0) {
|
|
1121
|
+
children.set(rest, r.hash);
|
|
1122
|
+
}
|
|
1123
|
+
else {
|
|
1124
|
+
const name = rest.slice(0, slash);
|
|
1125
|
+
if (!children.has(name)) {
|
|
1126
|
+
const sub = dirRow.get(prefix + name);
|
|
1127
|
+
children.set(name, sub?.hash ?? HASH_EMPTY);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
const hash = dirHash([...children].map(([name, h]) => ({ name, hash: h })));
|
|
1132
|
+
upsert.run(dir, hash, rows.length, now);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
709
1135
|
function countTotals(db) {
|
|
710
1136
|
const count = (table) => Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
|
|
711
1137
|
const nodes = Number(db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`).get().n);
|
|
@@ -56,8 +56,12 @@ automatically (a debounced incremental re-index on file change).
|
|
|
56
56
|
Under Claude Code, a `SessionStart` hook refreshes an existing index when each
|
|
57
57
|
session starts (`speclaw session-start`: silent, never fails, skips
|
|
58
58
|
when `.speclaw/index.db` is absent, and leaves this file alone when nothing
|
|
59
|
-
changed).
|
|
60
|
-
`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`.
|
|
61
65
|
|
|
62
66
|
If the graph is missing (no `.speclaw/index.db`), run `compass_index` first —
|
|
63
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)
|
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
|
+
}
|