@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,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static affected-test selection: reverse reachability into `files.is_test = 1`,
|
|
3
|
-
* plus
|
|
3
|
+
* plus ready-to-run commands per package (runner detected from `package.json`).
|
|
4
4
|
*/
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { changedFiles, isGitRepo } from "../../shared/git.js";
|
|
8
8
|
import { openDb, indexExists } from "./db.js";
|
|
9
9
|
import { impact } from "./query.js";
|
|
10
|
-
import { loadAffectedConfig, matchGlobalFiles, matchesAny, } from "./affected-config.js";
|
|
10
|
+
import { loadAffectedConfig, matchGlobalFiles, matchesAny, matchGlob, } from "./affected-config.js";
|
|
11
11
|
/**
|
|
12
12
|
* Select a safe superset of test files affected by a change.
|
|
13
13
|
*
|
|
@@ -40,7 +40,7 @@ export function affectedTests(projectPath, query = {}) {
|
|
|
40
40
|
reason: "no changed files",
|
|
41
41
|
tests: [],
|
|
42
42
|
skipped: { files: countTestFiles(projectPath), percent: 100 },
|
|
43
|
-
|
|
43
|
+
...planTestCommand(projectPath, [], "none"),
|
|
44
44
|
warnings,
|
|
45
45
|
};
|
|
46
46
|
}
|
|
@@ -48,12 +48,13 @@ export function affectedTests(projectPath, query = {}) {
|
|
|
48
48
|
const glob = matchGlobalFiles(files, cfg);
|
|
49
49
|
if (glob.matched.length > 0) {
|
|
50
50
|
const allTests = listTestFiles(projectPath);
|
|
51
|
+
const reason = `global file matched (${glob.matched.join(", ")})`;
|
|
51
52
|
return {
|
|
52
53
|
mode: "all",
|
|
53
|
-
reason
|
|
54
|
+
reason,
|
|
54
55
|
tests: allTests.map((file) => ({ file, nodes: 0, minDepth: 0 })),
|
|
55
56
|
skipped: { files: 0, percent: 0 },
|
|
56
|
-
|
|
57
|
+
...planTestCommand(projectPath, [], "all", reason),
|
|
57
58
|
warnings,
|
|
58
59
|
};
|
|
59
60
|
}
|
|
@@ -125,7 +126,7 @@ export function affectedTests(projectPath, query = {}) {
|
|
|
125
126
|
: "empty selection",
|
|
126
127
|
tests,
|
|
127
128
|
skipped: { files: skippedFiles, percent },
|
|
128
|
-
|
|
129
|
+
...planTestCommand(projectPath, tests.map((t) => t.file), tests.length === 0 ? "none" : "subset"),
|
|
129
130
|
warnings,
|
|
130
131
|
};
|
|
131
132
|
}
|
|
@@ -160,43 +161,359 @@ function listTestFiles(projectPath) {
|
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
/**
|
|
163
|
-
* Build
|
|
164
|
+
* Build the command for a selection as a single string (see {@link planTestCommand}).
|
|
164
165
|
*
|
|
165
166
|
* @param projectPath - Project root.
|
|
166
|
-
* @param tests - Selected test paths (ignored for mode `all`).
|
|
167
|
+
* @param tests - Selected repo-relative test paths (ignored for mode `all`).
|
|
167
168
|
* @param _cfg - Reserved for future runner overrides.
|
|
168
169
|
* @param mode - `all` | `subset` | `none`.
|
|
170
|
+
* @returns The command to run from the root, or null when nothing is selected.
|
|
169
171
|
*/
|
|
170
172
|
export function buildTestCommand(projectPath, tests, _cfg, mode) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
173
|
+
return planTestCommand(projectPath, tests, mode).command;
|
|
174
|
+
}
|
|
175
|
+
/** Read a `package.json`; null when absent or unparseable. */
|
|
176
|
+
function readPackage(dir) {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
181
182
|
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Turn a selection into runnable commands.
|
|
186
|
+
*
|
|
187
|
+
* - `none`: `command` is null with a reason; a run-nothing command is never emitted.
|
|
188
|
+
* - `all`: the root runner's full suite.
|
|
189
|
+
* - `subset`: files are grouped by their nearest `package.json` directory
|
|
190
|
+
* (bounded by the root) and each group gets its own runner's command:
|
|
191
|
+
* `npx vitest run`, `npx jest --runTestsByPath`, the `node --test` script's
|
|
192
|
+
* flags without coverage flags (mapped onto a compiled-test glob, after
|
|
193
|
+
* `npm run pretest`), or `npm test -- <files>` for an unrecognized script.
|
|
194
|
+
* Several groups compose as `(cd a && …) && (cd b && …)` from the root.
|
|
195
|
+
*
|
|
196
|
+
* @param projectPath - Project root.
|
|
197
|
+
* @param tests - Selected repo-relative test paths (ignored unless `subset`).
|
|
198
|
+
* @param mode - `all` | `subset` | `none`.
|
|
199
|
+
* @param allReason - Why the full suite was selected (mode `all`).
|
|
200
|
+
* @returns The command plan; `commands` is empty exactly when `command` is null.
|
|
201
|
+
*/
|
|
202
|
+
export function planTestCommand(projectPath, tests, mode, allReason = "the full suite was selected") {
|
|
203
|
+
// Covers: req~affected-test-selection~1
|
|
182
204
|
if (mode === "all") {
|
|
183
|
-
|
|
205
|
+
const command = fullSuiteCommand(readPackage(projectPath));
|
|
206
|
+
return {
|
|
207
|
+
command,
|
|
208
|
+
commandReason: `${allReason}; run the full suite`,
|
|
209
|
+
commands: [{ cwd: ".", command, files: [] }],
|
|
210
|
+
};
|
|
184
211
|
}
|
|
185
212
|
if (mode === "none" || tests.length === 0) {
|
|
186
|
-
return
|
|
213
|
+
return {
|
|
214
|
+
command: null,
|
|
215
|
+
commandReason: "no test file is reachable from the change; nothing to run",
|
|
216
|
+
commands: [],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const groups = new Map();
|
|
220
|
+
const nearest = new Map();
|
|
221
|
+
for (const t of [...tests].sort()) {
|
|
222
|
+
const cwd = nearestPackageDir(projectPath, path.posix.dirname(t), nearest);
|
|
223
|
+
const rel = cwd === "." ? t : t.slice(cwd.length + 1);
|
|
224
|
+
groups.set(cwd, [...(groups.get(cwd) ?? []), rel]);
|
|
225
|
+
}
|
|
226
|
+
const commands = [];
|
|
227
|
+
const notes = [];
|
|
228
|
+
const runners = [];
|
|
229
|
+
for (const cwd of [...groups.keys()].sort()) {
|
|
230
|
+
const files = groups.get(cwd);
|
|
231
|
+
const own = readPackage(path.join(projectPath, cwd));
|
|
232
|
+
const inherited = inheritedRunner(projectPath, cwd, own);
|
|
233
|
+
const plan = groupCommand(inherited?.pkg ?? own, files);
|
|
234
|
+
if (inherited) {
|
|
235
|
+
const from = inherited.dir === "." ? "the root package.json" : `${inherited.dir}/package.json`;
|
|
236
|
+
plan.note = [`${plan.runner} inherited from ${from}`, plan.note]
|
|
237
|
+
.filter((n) => n !== undefined)
|
|
238
|
+
.join("; ");
|
|
239
|
+
}
|
|
240
|
+
if (plan.note)
|
|
241
|
+
notes.push(cwd === "." ? plan.note : `${cwd}: ${plan.note}`);
|
|
242
|
+
if (plan.command === null)
|
|
243
|
+
continue;
|
|
244
|
+
commands.push({ cwd, command: plan.command, files: plan.kept ?? files });
|
|
245
|
+
runners.push(`${cwd}: ${plan.runner}`);
|
|
246
|
+
}
|
|
247
|
+
if (commands.length === 0) {
|
|
248
|
+
return {
|
|
249
|
+
command: null,
|
|
250
|
+
commandReason: `no runnable test file is selected; ${notes.join("; ")}`,
|
|
251
|
+
commands: [],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const command = commands.length === 1
|
|
255
|
+
? commands[0].cwd === "."
|
|
256
|
+
? commands[0].command
|
|
257
|
+
: `cd ${shellQuote(commands[0].cwd)} && ${commands[0].command}`
|
|
258
|
+
: commands
|
|
259
|
+
.map((c) => c.cwd === "." ? `(${c.command})` : `(cd ${shellQuote(c.cwd)} && ${c.command})`)
|
|
260
|
+
.join(" && ");
|
|
261
|
+
const head = commands.length === 1
|
|
262
|
+
? `${runners[0].split(": ")[1]} in ${commands[0].cwd} for ${tests.length} selected test file(s)`
|
|
263
|
+
: `${tests.length} selected test file(s) across ${commands.length} packages (${runners.join(", ")}); run from the repository root`;
|
|
264
|
+
return {
|
|
265
|
+
command,
|
|
266
|
+
commandReason: [head, ...notes].join("; "),
|
|
267
|
+
commands,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/** Full-suite command for a package (root `all` mode). */
|
|
271
|
+
function fullSuiteCommand(pkg) {
|
|
272
|
+
if (pkg?.scripts?.test)
|
|
273
|
+
return "npm test";
|
|
274
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
275
|
+
if (deps.vitest)
|
|
276
|
+
return "npx vitest run";
|
|
277
|
+
if (deps.jest)
|
|
278
|
+
return "npx jest";
|
|
279
|
+
return "node --test";
|
|
280
|
+
}
|
|
281
|
+
/** Nearest directory at or above `dir` (repo-relative) holding a `package.json`; `.` at worst. */
|
|
282
|
+
function nearestPackageDir(root, dir, cache) {
|
|
283
|
+
const hit = cache.get(dir);
|
|
284
|
+
if (hit !== undefined)
|
|
285
|
+
return hit;
|
|
286
|
+
let found;
|
|
287
|
+
if (dir === "." || dir === "" || dir === "/")
|
|
288
|
+
found = ".";
|
|
289
|
+
else if (fs.existsSync(path.join(root, dir, "package.json")))
|
|
290
|
+
found = dir;
|
|
291
|
+
else
|
|
292
|
+
found = nearestPackageDir(root, path.posix.dirname(dir), cache);
|
|
293
|
+
cache.set(dir, found);
|
|
294
|
+
return found;
|
|
295
|
+
}
|
|
296
|
+
/** Whether a package declares vitest or jest as a dependency. */
|
|
297
|
+
function declaresRunner(pkg) {
|
|
298
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
299
|
+
return Boolean(deps.vitest || deps.jest);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Runner inherited by a package that declares none (no `scripts.test`, no
|
|
303
|
+
* vitest/jest dependency): the nearest ancestor `package.json`, up to the
|
|
304
|
+
* root, that declares vitest or jest — the hoisted-monorepo layout. Returns
|
|
305
|
+
* the package to plan with (the leaf with the ancestor's runner dependencies)
|
|
306
|
+
* and the ancestor's directory, or null when nothing is inherited.
|
|
307
|
+
*/
|
|
308
|
+
function inheritedRunner(root, cwd, own) {
|
|
309
|
+
if (cwd === "." || own?.scripts?.test || declaresRunner(own))
|
|
310
|
+
return null;
|
|
311
|
+
let dir = cwd;
|
|
312
|
+
while (dir !== ".") {
|
|
313
|
+
dir = path.posix.dirname(dir);
|
|
314
|
+
if (dir === "" || dir === "/")
|
|
315
|
+
dir = ".";
|
|
316
|
+
const anc = readPackage(path.join(root, dir));
|
|
317
|
+
if (!declaresRunner(anc))
|
|
318
|
+
continue;
|
|
319
|
+
const deps = { ...anc?.dependencies, ...anc?.devDependencies };
|
|
320
|
+
const runnerDeps = {};
|
|
321
|
+
if (deps.vitest)
|
|
322
|
+
runnerDeps.vitest = deps.vitest;
|
|
323
|
+
if (deps.jest)
|
|
324
|
+
runnerDeps.jest = deps.jest;
|
|
325
|
+
return {
|
|
326
|
+
pkg: { ...own, devDependencies: { ...own?.devDependencies, ...runnerDeps } },
|
|
327
|
+
dir,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
/** Flags dropped from a `node --test` script: coverage thresholds fail on a subset. */
|
|
333
|
+
function isCoverageFlag(token) {
|
|
334
|
+
return token === "--experimental-test-coverage" || token.startsWith("--test-coverage-");
|
|
335
|
+
}
|
|
336
|
+
/** Node flags whose value may follow as a separate word (`--import ./x.mjs`). */
|
|
337
|
+
const NODE_VALUE_FLAGS = new Set([
|
|
338
|
+
"--import",
|
|
339
|
+
"--require",
|
|
340
|
+
"-r",
|
|
341
|
+
"--loader",
|
|
342
|
+
"--experimental-loader",
|
|
343
|
+
"--env-file",
|
|
344
|
+
"--conditions",
|
|
345
|
+
"-C",
|
|
346
|
+
"--test-reporter",
|
|
347
|
+
"--test-reporter-destination",
|
|
348
|
+
"--test-name-pattern",
|
|
349
|
+
"--test-skip-pattern",
|
|
350
|
+
"--test-concurrency",
|
|
351
|
+
"--test-timeout",
|
|
352
|
+
"--test-shard",
|
|
353
|
+
"--test-coverage-lines",
|
|
354
|
+
"--test-coverage-branches",
|
|
355
|
+
"--test-coverage-functions",
|
|
356
|
+
"--test-coverage-include",
|
|
357
|
+
"--test-coverage-exclude",
|
|
358
|
+
]);
|
|
359
|
+
/**
|
|
360
|
+
* Split a `node --test` script's words (after `node`) into the flags to keep
|
|
361
|
+
* and the positional test globs. A value-taking flag keeps its value with it;
|
|
362
|
+
* coverage flags are dropped together with their values. A directory
|
|
363
|
+
* positional (no glob character, no file extension) becomes `<dir>/**`, so it
|
|
364
|
+
* matches every file under it.
|
|
365
|
+
*/
|
|
366
|
+
function splitNodeTestArgs(words) {
|
|
367
|
+
const flags = [];
|
|
368
|
+
const globs = [];
|
|
369
|
+
for (let i = 0; i < words.length; i++) {
|
|
370
|
+
const word = words[i];
|
|
371
|
+
if (word.startsWith("-")) {
|
|
372
|
+
const group = [word];
|
|
373
|
+
if (!word.includes("=") && NODE_VALUE_FLAGS.has(word) && i + 1 < words.length) {
|
|
374
|
+
group.push(words[++i]);
|
|
375
|
+
}
|
|
376
|
+
if (!isCoverageFlag(word))
|
|
377
|
+
flags.push(...group);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const isDir = !/[*?{[]/.test(word) && !/\.[cm]?[jt]sx?$/.test(word);
|
|
381
|
+
globs.push(isDir ? `${word.replace(/\/+$/, "")}/**` : word);
|
|
187
382
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
383
|
+
return { flags, globs };
|
|
384
|
+
}
|
|
385
|
+
/** Source extensions mapped to their compiled JavaScript counterparts. */
|
|
386
|
+
const COMPILED_EXT = [
|
|
387
|
+
[/\.tsx?$/, ".js"],
|
|
388
|
+
[/\.mts$/, ".mjs"],
|
|
389
|
+
[/\.cts$/, ".cjs"],
|
|
390
|
+
];
|
|
391
|
+
/** Source path with its TypeScript extension swapped for the compiled one. */
|
|
392
|
+
function compiledPath(file) {
|
|
393
|
+
let out = file;
|
|
394
|
+
for (const [re, ext] of COMPILED_EXT)
|
|
395
|
+
out = out.replace(re, ext);
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Map a source test path onto the script's globs: the path itself when a glob
|
|
400
|
+
* matches it, else `<first glob segment>/<compiled path>` when that matches.
|
|
401
|
+
* `helper` marks a file whose name no glob's last segment can match (e.g.
|
|
402
|
+
* `test/helpers/fixtures.ts` against `*.test.js`): the runner never executes it,
|
|
403
|
+
* not even in a full run, so it is left out rather than forcing the full suite.
|
|
404
|
+
* `null` means a test-shaped file that could not be mapped.
|
|
405
|
+
*/
|
|
406
|
+
function mapOntoGlobs(file, globs) {
|
|
407
|
+
if (globs.some((g) => matchGlob(file, g)))
|
|
408
|
+
return file;
|
|
409
|
+
const base = path.posix.basename(file);
|
|
410
|
+
const testShaped = globs.some((g) => {
|
|
411
|
+
const last = g.split("/").pop() ?? g;
|
|
412
|
+
return matchGlob(base, last) || matchGlob(compiledPath(base), last);
|
|
413
|
+
});
|
|
414
|
+
if (!testShaped)
|
|
415
|
+
return "helper";
|
|
416
|
+
for (const glob of globs) {
|
|
417
|
+
const first = glob.split("/")[0];
|
|
418
|
+
if (!first || /[*{]/.test(first))
|
|
419
|
+
continue;
|
|
420
|
+
const mapped = `${first}/${compiledPath(file)}`;
|
|
421
|
+
if (matchGlob(mapped, glob))
|
|
422
|
+
return mapped;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Command for one package group, with its detected runner and an optional note.
|
|
428
|
+
* `command` is null only when every file is a helper the runner never executes;
|
|
429
|
+
* `kept` lists the files the command runs when helpers were left out.
|
|
430
|
+
*/
|
|
431
|
+
function groupCommand(pkg, files) {
|
|
432
|
+
const script = pkg?.scripts?.test;
|
|
433
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
434
|
+
const args = files.map(shellQuote).join(" ");
|
|
435
|
+
const tokens = script ? shellSplit(script) : [];
|
|
436
|
+
const isNodeTest = tokens[0] === "node" && tokens.includes("--test");
|
|
437
|
+
if ((script && /\bvitest\b/.test(script)) || (!script && deps.vitest)) {
|
|
438
|
+
return { command: `npx vitest run ${args}`, runner: "vitest" };
|
|
439
|
+
}
|
|
440
|
+
if ((script && /\bjest\b/.test(script)) || (!isNodeTest && deps.jest)) {
|
|
441
|
+
return { command: `npx jest --runTestsByPath ${args}`, runner: "jest" };
|
|
442
|
+
}
|
|
443
|
+
if (isNodeTest) {
|
|
444
|
+
const { flags, globs } = splitNodeTestArgs(tokens.slice(1));
|
|
445
|
+
const base = ["node", ...flags].map(shellQuote).join(" ");
|
|
446
|
+
if (globs.length === 0)
|
|
447
|
+
return { command: `${base} ${args}`, runner: "node --test" };
|
|
448
|
+
const mapped = files.map((f) => mapOntoGlobs(f, globs));
|
|
449
|
+
if (mapped.some((m) => m === null)) {
|
|
450
|
+
return {
|
|
451
|
+
command: "npm test",
|
|
452
|
+
runner: "node --test",
|
|
453
|
+
note: "a selected test file does not fit the test script's glob, so the full suite runs (a safe superset)",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const runnable = mapped.filter((m) => m !== null && m !== "helper");
|
|
457
|
+
const kept = files.filter((_, i) => mapped[i] !== "helper");
|
|
458
|
+
const helpers = mapped.length - runnable.length;
|
|
459
|
+
const helperNote = helpers > 0 ? `${helpers} helper file(s) the test script never runs left out` : undefined;
|
|
460
|
+
if (runnable.length === 0)
|
|
461
|
+
return { command: null, runner: "node --test", note: helperNote };
|
|
462
|
+
const compiled = runnable.some((m) => !files.includes(m));
|
|
463
|
+
const prefix = compiled && pkg?.scripts?.pretest ? "npm run pretest && " : "";
|
|
464
|
+
const notes = [
|
|
465
|
+
compiled ? "files mapped onto the compiled test glob; coverage flags dropped" : undefined,
|
|
466
|
+
helperNote,
|
|
467
|
+
].filter((n) => n !== undefined);
|
|
468
|
+
return {
|
|
469
|
+
command: `${prefix}${base} ${runnable.map(shellQuote).join(" ")}`,
|
|
470
|
+
runner: "node --test",
|
|
471
|
+
note: notes.length > 0 ? notes.join("; ") : undefined,
|
|
472
|
+
kept,
|
|
473
|
+
};
|
|
191
474
|
}
|
|
192
475
|
if (script) {
|
|
193
|
-
|
|
194
|
-
|
|
476
|
+
return {
|
|
477
|
+
command: `npm test -- ${args}`,
|
|
478
|
+
runner: "npm test",
|
|
479
|
+
note: "unrecognized test script: paths are forwarded and it may run the full suite (a superset)",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return { command: `node --test ${args}`, runner: "node --test" };
|
|
483
|
+
}
|
|
484
|
+
/** Split a shell command line into words, honoring single and double quotes. */
|
|
485
|
+
function shellSplit(line) {
|
|
486
|
+
const out = [];
|
|
487
|
+
let cur = "";
|
|
488
|
+
let quote = null;
|
|
489
|
+
let has = false;
|
|
490
|
+
for (const ch of line) {
|
|
491
|
+
if (quote) {
|
|
492
|
+
if (ch === quote)
|
|
493
|
+
quote = null;
|
|
494
|
+
else
|
|
495
|
+
cur += ch;
|
|
496
|
+
}
|
|
497
|
+
else if (ch === "'" || ch === '"') {
|
|
498
|
+
quote = ch;
|
|
499
|
+
has = true;
|
|
500
|
+
}
|
|
501
|
+
else if (/\s/.test(ch)) {
|
|
502
|
+
if (has || cur)
|
|
503
|
+
out.push(cur);
|
|
504
|
+
cur = "";
|
|
505
|
+
has = false;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
cur += ch;
|
|
509
|
+
}
|
|
195
510
|
}
|
|
196
|
-
|
|
511
|
+
if (has || cur)
|
|
512
|
+
out.push(cur);
|
|
513
|
+
return out;
|
|
197
514
|
}
|
|
198
515
|
function shellQuote(p) {
|
|
199
|
-
if (/^[A-Za-z0-9_
|
|
516
|
+
if (/^[A-Za-z0-9_./=:,@+-]+$/.test(p))
|
|
200
517
|
return p;
|
|
201
518
|
return `'${p.replace(/'/g, `'\\''`)}'`;
|
|
202
519
|
}
|
|
@@ -48,6 +48,13 @@ CREATE TABLE IF NOT EXISTS node_metrics (
|
|
|
48
48
|
branches INTEGER NOT NULL
|
|
49
49
|
);
|
|
50
50
|
-- edges: a reference from one node to a named target, resolved lazily.
|
|
51
|
+
-- src_node_id is never NULL for an edge indexed under schema 11: a reference
|
|
52
|
+
-- outside any definition is owned by its file's file-owner node (kind 'file').
|
|
53
|
+
-- is_member = 1 marks a member call on a foreign receiver (never bound by name);
|
|
54
|
+
-- 2 a member call on an import binding, bound only once the import with the
|
|
55
|
+
-- same spec in the same file resolves to a project file (a package otherwise).
|
|
56
|
+
-- spec: JS/TS module specifier of an import edge, or of the import binding a
|
|
57
|
+
-- call's receiver/callee comes from.
|
|
51
58
|
CREATE TABLE IF NOT EXISTS edges (
|
|
52
59
|
id INTEGER PRIMARY KEY,
|
|
53
60
|
src_node_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE,
|
|
@@ -55,9 +62,12 @@ CREATE TABLE IF NOT EXISTS edges (
|
|
|
55
62
|
dst_name TEXT NOT NULL,
|
|
56
63
|
dst_node_id INTEGER,
|
|
57
64
|
kind TEXT NOT NULL,
|
|
58
|
-
line INTEGER NOT NULL
|
|
65
|
+
line INTEGER NOT NULL,
|
|
66
|
+
is_member INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
spec TEXT
|
|
59
68
|
);
|
|
60
69
|
CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst_name);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_edges_srcfile ON edges(src_file_id, kind);
|
|
61
71
|
CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src_node_id);
|
|
62
72
|
CREATE INDEX IF NOT EXISTS idx_edges_dstid ON edges(dst_node_id);
|
|
63
73
|
-- embedding_cache: vectors keyed by embedder-input content hash (survives reindex).
|
|
@@ -224,7 +234,13 @@ export function probeFts5Support() {
|
|
|
224
234
|
}
|
|
225
235
|
}
|
|
226
236
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
227
|
-
export const SCHEMA_VERSION = "
|
|
237
|
+
export const SCHEMA_VERSION = "11";
|
|
238
|
+
/**
|
|
239
|
+
* `nodes.kind` of the synthetic per-file owner node. It owns the references that
|
|
240
|
+
* sit outside any definition (top-level imports, calls in callbacks) and is
|
|
241
|
+
* hidden from find, name lookup, PageRank, FTS, embeddings, and metrics.
|
|
242
|
+
*/
|
|
243
|
+
export const FILE_NODE_KIND = "file";
|
|
228
244
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
229
245
|
function readSchemaVersion(db) {
|
|
230
246
|
try {
|
|
@@ -237,7 +253,7 @@ function readSchemaVersion(db) {
|
|
|
237
253
|
}
|
|
238
254
|
/**
|
|
239
255
|
* Decide whether an existing database is from an incompatible schema and must be
|
|
240
|
-
* rebuilt. Schema 8→9
|
|
256
|
+
* rebuilt. Schema 8→9, 9→10, and 10→11 are handled by migrators instead of a wipe.
|
|
241
257
|
*/
|
|
242
258
|
function isStale(db) {
|
|
243
259
|
const hasEdges = db
|
|
@@ -246,13 +262,19 @@ function isStale(db) {
|
|
|
246
262
|
if (!hasEdges)
|
|
247
263
|
return false;
|
|
248
264
|
const ver = readSchemaVersion(db);
|
|
249
|
-
if (ver === "8" || ver === "9")
|
|
265
|
+
if (ver === "8" || ver === "9" || ver === "10")
|
|
250
266
|
return false; // migrate in openDb
|
|
267
|
+
if (ver === "11" && !hasEdgeSpec(db))
|
|
268
|
+
return false; // pre-release 11: migrate in openDb
|
|
251
269
|
if (ver !== SCHEMA_VERSION)
|
|
252
270
|
return true;
|
|
253
271
|
const edgeCols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
|
|
254
|
-
if (!edgeCols.includes("src_node_id") ||
|
|
272
|
+
if (!edgeCols.includes("src_node_id") ||
|
|
273
|
+
!edgeCols.includes("dst_node_id") ||
|
|
274
|
+
!edgeCols.includes("is_member") ||
|
|
275
|
+
!edgeCols.includes("spec")) {
|
|
255
276
|
return true;
|
|
277
|
+
}
|
|
256
278
|
const fileCols = db.prepare("PRAGMA table_info(files)").all().map((c) => c.name);
|
|
257
279
|
if (!fileCols.includes("is_test") || !fileCols.includes("module"))
|
|
258
280
|
return true;
|
|
@@ -402,15 +424,62 @@ export function migrate9to10(db) {
|
|
|
402
424
|
throw new Error(`schema 9→10 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
|
|
403
425
|
}
|
|
404
426
|
}
|
|
427
|
+
/** Whether `edges` already has the schema-11 `spec` column (or does not exist yet). */
|
|
428
|
+
function hasEdgeSpec(db) {
|
|
429
|
+
const cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
|
|
430
|
+
return cols.length === 0 || cols.includes("spec");
|
|
431
|
+
}
|
|
432
|
+
/** Reason recorded with the needs-reindex marker by the 10→11 migration. */
|
|
433
|
+
export const SCHEMA_11_REINDEX_REASON = "schema 11 adds file-owner nodes, member-call flags and full import text; reindex required";
|
|
434
|
+
/**
|
|
435
|
+
* Migrate schema 10 → 11: add `edges.is_member` and `edges.spec` (each only
|
|
436
|
+
* when missing, so a pre-release schema-11 index lacking `spec` takes the same
|
|
437
|
+
* path), set the needs-reindex marker, and stamp `"11"`, all in one
|
|
438
|
+
* `BEGIN IMMEDIATE` transaction. The next index
|
|
439
|
+
* run re-extracts every file (edge ownership and import text change), while
|
|
440
|
+
* `embedding_cache` is untouched so unchanged symbols reuse their vectors.
|
|
441
|
+
*
|
|
442
|
+
* @param db - Open connection already at schema 10.
|
|
443
|
+
* @throws If any step fails; the transaction is rolled back and the stamp stays `"10"`.
|
|
444
|
+
*/
|
|
445
|
+
export function migrate10to11(db) {
|
|
446
|
+
// Covers: req~schema-edge-membership~1
|
|
447
|
+
db.exec("BEGIN IMMEDIATE");
|
|
448
|
+
try {
|
|
449
|
+
const cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
|
|
450
|
+
// No edges table yet (a partial older index): the schema DDL creates it with the column.
|
|
451
|
+
if (cols.length > 0 && !cols.includes("is_member")) {
|
|
452
|
+
db.exec("ALTER TABLE edges ADD COLUMN is_member INTEGER NOT NULL DEFAULT 0");
|
|
453
|
+
}
|
|
454
|
+
if (cols.length > 0 && !cols.includes("spec")) {
|
|
455
|
+
db.exec("ALTER TABLE edges ADD COLUMN spec TEXT");
|
|
456
|
+
}
|
|
457
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
|
|
458
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('reindex_reason', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(SCHEMA_11_REINDEX_REASON);
|
|
459
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("11");
|
|
460
|
+
db.exec("COMMIT");
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
try {
|
|
464
|
+
db.exec("ROLLBACK");
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
/* already rolled back */
|
|
468
|
+
}
|
|
469
|
+
throw new Error(`schema 10→11 migration failed — delete .speclaw/index.db to rebuild: ${err.message}`, { cause: err });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
405
472
|
/**
|
|
406
473
|
* Open (creating if needed) the index database at `<projectPath>/.speclaw/index.db`.
|
|
407
474
|
*
|
|
408
475
|
* Ensures the `.speclaw` directory exists, enables WAL journaling and foreign
|
|
409
|
-
* keys, and applies the schema. Schema 8→9
|
|
410
|
-
* (embeddings preserved). Other incompatible schemas are wiped
|
|
476
|
+
* keys, and applies the schema. Schema 8→9, 9→10, and 10→11 migrate in place
|
|
477
|
+
* and chain forward (embeddings preserved). Other incompatible schemas are wiped
|
|
478
|
+
* and rebuilt.
|
|
411
479
|
*
|
|
412
480
|
* @param projectPath - Absolute path to the project root.
|
|
413
481
|
* @returns An open connection to the index database.
|
|
482
|
+
* @throws If an in-place migration fails (the connection is closed first).
|
|
414
483
|
*/
|
|
415
484
|
export function openDb(projectPath) {
|
|
416
485
|
const dir = path.join(projectPath, ".speclaw");
|
|
@@ -425,15 +494,18 @@ export function openDb(projectPath) {
|
|
|
425
494
|
return null;
|
|
426
495
|
}
|
|
427
496
|
})();
|
|
428
|
-
if (ver === "8") {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
497
|
+
if (ver === "8" || ver === "9" || ver === "10" || (ver === "11" && !hasEdgeSpec(db))) {
|
|
498
|
+
try {
|
|
499
|
+
if (ver === "8")
|
|
500
|
+
migrate8to9(db, projectPath);
|
|
501
|
+
if (ver === "8" || ver === "9")
|
|
502
|
+
migrate9to10(db);
|
|
503
|
+
migrate10to11(db);
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
db.close();
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
437
509
|
db.exec(SCHEMA);
|
|
438
510
|
ensureEmbeddingsView(db);
|
|
439
511
|
ensureFts(db);
|
|
@@ -496,6 +568,67 @@ export function needsReindex(db) {
|
|
|
496
568
|
export function clearNeedsReindex(db) {
|
|
497
569
|
db.prepare("DELETE FROM meta WHERE key = 'needs_reindex'").run();
|
|
498
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
|
+
}
|
|
499
632
|
/** Absolute path to the index database file for a project. */
|
|
500
633
|
export function indexPath(projectPath) {
|
|
501
634
|
return path.join(projectPath, ".speclaw", "index.db");
|