@esneiderbravo/speclaw 0.3.8 → 0.3.10
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 +2 -0
- package/dist/cli/commands/drift.js +39 -0
- package/dist/cli/commands/lawbook.js +7 -0
- package/dist/cli/commands/query.js +73 -5
- package/dist/cli/commands/update.js +17 -0
- package/dist/cli/commands/verify.js +14 -0
- package/dist/cli/index.js +12 -4
- package/dist/modules/compass/affected-config.js +238 -0
- package/dist/modules/compass/affected.js +249 -0
- package/dist/modules/compass/db.js +85 -6
- package/dist/modules/compass/extract.js +3 -0
- package/dist/modules/compass/hash.js +77 -0
- package/dist/modules/compass/indexer.js +123 -10
- package/dist/modules/compass/query.js +255 -30
- package/dist/modules/compass/register.js +32 -1
- package/dist/modules/foundation/doctor.js +11 -0
- package/dist/modules/lawbook/anchors.js +299 -0
- package/dist/modules/lawbook/drift.js +491 -0
- package/dist/modules/lawbook/engine.js +39 -1
- package/dist/modules/lawbook/register.js +17 -0
- package/dist/shared/exposure.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static affected-test selection: reverse reachability into `files.is_test = 1`,
|
|
3
|
+
* plus a ready-to-run command string.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { changedFiles, isGitRepo } from "../../shared/git.js";
|
|
8
|
+
import { openDb, indexExists } from "./db.js";
|
|
9
|
+
import { impact } from "./query.js";
|
|
10
|
+
import { loadAffectedConfig, matchGlobalFiles, matchesAny, } from "./affected-config.js";
|
|
11
|
+
/**
|
|
12
|
+
* Select a safe superset of test files affected by a change.
|
|
13
|
+
*
|
|
14
|
+
* @param projectPath - Absolute project root with a Compass index.
|
|
15
|
+
* @param query - Files, symbols, and/or a git diff base ref.
|
|
16
|
+
*/
|
|
17
|
+
export function affectedTests(projectPath, query = {}) {
|
|
18
|
+
if (!indexExists(projectPath)) {
|
|
19
|
+
throw new Error("No index found. Build it first with the index_build tool (creates .speclaw/index.db).");
|
|
20
|
+
}
|
|
21
|
+
const cfg = loadAffectedConfig(projectPath);
|
|
22
|
+
const warnings = [];
|
|
23
|
+
warnings.push(...warnUnindexedLanguages(projectPath));
|
|
24
|
+
let files = [...(query.files ?? [])];
|
|
25
|
+
if (query.fromDiff !== undefined) {
|
|
26
|
+
if (!isGitRepo(projectPath)) {
|
|
27
|
+
throw new Error("fromDiff requires a git repository");
|
|
28
|
+
}
|
|
29
|
+
const base = query.fromDiff === "WORKTREE" || query.fromDiff === "" ? "HEAD" : query.fromDiff;
|
|
30
|
+
// WORKTREE ≈ uncommitted: use merge-base against HEAD's first-parent via changedFiles("HEAD")
|
|
31
|
+
// when the caller passes a branch/ref; for literal WORKTREE fall back to HEAD...working tree
|
|
32
|
+
// is not in changedFiles — use the ref as merge-base target.
|
|
33
|
+
const diffFiles = query.fromDiff === "WORKTREE"
|
|
34
|
+
? listWorktreeChanges(projectPath)
|
|
35
|
+
: changedFiles(projectPath, base);
|
|
36
|
+
files = [...new Set([...files, ...diffFiles])];
|
|
37
|
+
if (files.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
mode: "static",
|
|
40
|
+
reason: "no changed files",
|
|
41
|
+
tests: [],
|
|
42
|
+
skipped: { files: countTestFiles(projectPath), percent: 100 },
|
|
43
|
+
command: buildTestCommand(projectPath, [], cfg, "none"),
|
|
44
|
+
warnings,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const glob = matchGlobalFiles(files, cfg);
|
|
49
|
+
if (glob.matched.length > 0) {
|
|
50
|
+
const allTests = listTestFiles(projectPath);
|
|
51
|
+
return {
|
|
52
|
+
mode: "all",
|
|
53
|
+
reason: `global file matched (${glob.matched.join(", ")})`,
|
|
54
|
+
tests: allTests.map((file) => ({ file, nodes: 0, minDepth: 0 })),
|
|
55
|
+
skipped: { files: 0, percent: 0 },
|
|
56
|
+
command: buildTestCommand(projectPath, [], cfg, "all"),
|
|
57
|
+
warnings,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const impactOpts = {
|
|
61
|
+
files: files.length > 0 ? files : undefined,
|
|
62
|
+
symbol: query.symbols?.length === 1 ? query.symbols[0] : undefined,
|
|
63
|
+
maxDepth: query.maxDepth ?? 6,
|
|
64
|
+
format: "flat",
|
|
65
|
+
target: "test",
|
|
66
|
+
edgeKinds: ["call", "import"],
|
|
67
|
+
};
|
|
68
|
+
// Multiple symbols → union flat impacts.
|
|
69
|
+
const nodes = [...(impact(projectPath, impactOpts).nodes ?? [])];
|
|
70
|
+
if (query.symbols && query.symbols.length > 1) {
|
|
71
|
+
const seen = new Set(nodes.map((n) => n.nodeId));
|
|
72
|
+
for (const sym of query.symbols) {
|
|
73
|
+
for (const n of impact(projectPath, {
|
|
74
|
+
symbol: sym,
|
|
75
|
+
format: "flat",
|
|
76
|
+
maxDepth: impactOpts.maxDepth,
|
|
77
|
+
}).nodes ?? []) {
|
|
78
|
+
if (!seen.has(n.nodeId)) {
|
|
79
|
+
seen.add(n.nodeId);
|
|
80
|
+
nodes.push(n);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Also include directly changed test files.
|
|
86
|
+
const testHits = new Map();
|
|
87
|
+
for (const f of files) {
|
|
88
|
+
const norm = f.split("\\").join("/");
|
|
89
|
+
if (matchesAny(norm, cfg.testGlobs)) {
|
|
90
|
+
testHits.set(norm, { file: norm, nodes: 0, minDepth: 0 });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const db = openDb(projectPath);
|
|
94
|
+
try {
|
|
95
|
+
const isTestByPath = new Map();
|
|
96
|
+
for (const row of db.prepare("SELECT path, is_test FROM files").all()) {
|
|
97
|
+
isTestByPath.set(row.path, row.is_test === 1);
|
|
98
|
+
}
|
|
99
|
+
for (const n of nodes) {
|
|
100
|
+
if (!isTestByPath.get(n.file))
|
|
101
|
+
continue;
|
|
102
|
+
const prior = testHits.get(n.file);
|
|
103
|
+
if (!prior) {
|
|
104
|
+
testHits.set(n.file, { file: n.file, nodes: 1, minDepth: n.depth });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
prior.nodes += 1;
|
|
108
|
+
prior.minDepth = Math.min(prior.minDepth, n.depth);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
db.close();
|
|
114
|
+
}
|
|
115
|
+
const tests = [...testHits.values()].sort((a, b) => a.file.localeCompare(b.file));
|
|
116
|
+
const totalTests = countTestFiles(projectPath);
|
|
117
|
+
const skippedFiles = Math.max(0, totalTests - tests.length);
|
|
118
|
+
const percent = totalTests === 0 ? 0 : Math.round((skippedFiles / totalTests) * 100);
|
|
119
|
+
return {
|
|
120
|
+
mode: "static",
|
|
121
|
+
reason: files.length > 0
|
|
122
|
+
? `changed ${files.length} file(s)`
|
|
123
|
+
: query.symbols?.length
|
|
124
|
+
? `symbols ${query.symbols.join(", ")}`
|
|
125
|
+
: "empty selection",
|
|
126
|
+
tests,
|
|
127
|
+
skipped: { files: skippedFiles, percent },
|
|
128
|
+
command: buildTestCommand(projectPath, tests.map((t) => t.file), cfg, tests.length === 0 ? "none" : "subset"),
|
|
129
|
+
warnings,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function listWorktreeChanges(projectPath) {
|
|
133
|
+
// Prefer merge-base against main/master when available; else HEAD.
|
|
134
|
+
for (const base of ["main", "master", "HEAD"]) {
|
|
135
|
+
const files = changedFiles(projectPath, base);
|
|
136
|
+
if (files.length > 0 || base === "HEAD")
|
|
137
|
+
return files;
|
|
138
|
+
}
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
function countTestFiles(projectPath) {
|
|
142
|
+
if (!indexExists(projectPath))
|
|
143
|
+
return 0;
|
|
144
|
+
const db = openDb(projectPath);
|
|
145
|
+
try {
|
|
146
|
+
const row = db.prepare("SELECT COUNT(*) AS n FROM files WHERE is_test = 1").get();
|
|
147
|
+
return Number(row.n);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
db.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function listTestFiles(projectPath) {
|
|
154
|
+
const db = openDb(projectPath);
|
|
155
|
+
try {
|
|
156
|
+
return db.prepare("SELECT path FROM files WHERE is_test = 1 ORDER BY path").all().map((r) => r.path);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
db.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Build an executable test command from package.json scripts.test when present.
|
|
164
|
+
*
|
|
165
|
+
* @param projectPath - Project root.
|
|
166
|
+
* @param tests - Selected test paths (ignored for mode `all`).
|
|
167
|
+
* @param _cfg - Reserved for future runner overrides.
|
|
168
|
+
* @param mode - `all` | `subset` | `none`.
|
|
169
|
+
*/
|
|
170
|
+
export function buildTestCommand(projectPath, tests, _cfg, mode) {
|
|
171
|
+
const pkgPath = path.join(projectPath, "package.json");
|
|
172
|
+
let script;
|
|
173
|
+
if (fs.existsSync(pkgPath)) {
|
|
174
|
+
try {
|
|
175
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
176
|
+
script = pkg.scripts?.test;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* ignore */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (mode === "all") {
|
|
183
|
+
return script ? "npm test" : "node --test";
|
|
184
|
+
}
|
|
185
|
+
if (mode === "none" || tests.length === 0) {
|
|
186
|
+
return script ? "npm test -- --test-name-pattern=^$" : "node --test --test-name-pattern=^$";
|
|
187
|
+
}
|
|
188
|
+
const args = tests.map(shellQuote).join(" ");
|
|
189
|
+
if (script && /\bnode\s+--test\b/.test(script)) {
|
|
190
|
+
return `node --test ${args}`;
|
|
191
|
+
}
|
|
192
|
+
if (script) {
|
|
193
|
+
// Pass paths after `--` for npm/vitest/jest-style scripts.
|
|
194
|
+
return `npm test -- ${args}`;
|
|
195
|
+
}
|
|
196
|
+
return `node --test ${args}`;
|
|
197
|
+
}
|
|
198
|
+
function shellQuote(p) {
|
|
199
|
+
if (/^[A-Za-z0-9_./-]+$/.test(p))
|
|
200
|
+
return p;
|
|
201
|
+
return `'${p.replace(/'/g, `'\\''`)}'`;
|
|
202
|
+
}
|
|
203
|
+
/** Warn when present extensions are not in the indexed language set. */
|
|
204
|
+
function warnUnindexedLanguages(projectPath) {
|
|
205
|
+
const indexedExts = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"]);
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
const warnings = [];
|
|
208
|
+
walkQuick(projectPath, (rel) => {
|
|
209
|
+
const ext = path.extname(rel).toLowerCase();
|
|
210
|
+
if (!ext || indexedExts.has(ext) || seen.has(ext))
|
|
211
|
+
return;
|
|
212
|
+
// Only flag common source extensions that Compass does not parse.
|
|
213
|
+
if (![".go", ".rs", ".java", ".kt", ".rb", ".php", ".cs"].includes(ext))
|
|
214
|
+
return;
|
|
215
|
+
seen.add(ext);
|
|
216
|
+
warnings.push(`${ext} files are present but not indexed by Compass`);
|
|
217
|
+
});
|
|
218
|
+
return warnings;
|
|
219
|
+
}
|
|
220
|
+
function walkQuick(root, visit) {
|
|
221
|
+
const skip = new Set([".git", "node_modules", "dist", "dist-test", ".speclaw", "vendor"]);
|
|
222
|
+
const stack = [root];
|
|
223
|
+
let n = 0;
|
|
224
|
+
while (stack.length && n < 5000) {
|
|
225
|
+
const dir = stack.pop();
|
|
226
|
+
let entries;
|
|
227
|
+
try {
|
|
228
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
for (const e of entries) {
|
|
234
|
+
if (e.name.startsWith(".") && e.name !== ".speclaw") {
|
|
235
|
+
if (e.isDirectory() && e.name !== ".github")
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const full = path.join(dir, e.name);
|
|
239
|
+
if (e.isDirectory()) {
|
|
240
|
+
if (!skip.has(e.name))
|
|
241
|
+
stack.push(full);
|
|
242
|
+
}
|
|
243
|
+
else if (e.isFile()) {
|
|
244
|
+
n++;
|
|
245
|
+
visit(path.relative(root, full));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -10,8 +10,11 @@ CREATE TABLE IF NOT EXISTS files (
|
|
|
10
10
|
id INTEGER PRIMARY KEY,
|
|
11
11
|
path TEXT UNIQUE NOT NULL,
|
|
12
12
|
hash TEXT NOT NULL,
|
|
13
|
-
lang TEXT NOT NULL
|
|
13
|
+
lang TEXT NOT NULL,
|
|
14
|
+
is_test INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
module TEXT NOT NULL DEFAULT ''
|
|
14
16
|
);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_files_is_test ON files(is_test);
|
|
15
18
|
-- nodes: the definitions in the codebase (functions, classes, methods, types).
|
|
16
19
|
CREATE TABLE IF NOT EXISTS nodes (
|
|
17
20
|
id INTEGER PRIMARY KEY,
|
|
@@ -23,10 +26,13 @@ CREATE TABLE IF NOT EXISTS nodes (
|
|
|
23
26
|
start_byte INTEGER NOT NULL,
|
|
24
27
|
end_byte INTEGER NOT NULL,
|
|
25
28
|
parent_id INTEGER,
|
|
26
|
-
signature TEXT
|
|
29
|
+
signature TEXT,
|
|
30
|
+
body_hash TEXT,
|
|
31
|
+
norm_hash TEXT
|
|
27
32
|
);
|
|
28
33
|
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
|
29
34
|
CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_id);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_norm_hash ON nodes(norm_hash);
|
|
30
36
|
-- edges: a reference from one node to a named target, resolved lazily.
|
|
31
37
|
CREATE TABLE IF NOT EXISTS edges (
|
|
32
38
|
id INTEGER PRIMARY KEY,
|
|
@@ -73,9 +79,32 @@ CREATE TABLE IF NOT EXISTS coverage_links (
|
|
|
73
79
|
CREATE INDEX IF NOT EXISTS idx_cov_target ON coverage_links(artifact_type, name, revision);
|
|
74
80
|
CREATE INDEX IF NOT EXISTS idx_cov_file ON coverage_links(file_path);
|
|
75
81
|
CREATE INDEX IF NOT EXISTS idx_cov_node ON coverage_links(node_id);
|
|
82
|
+
-- spec_anchors: projection of committed lawbook/anchors/*.json (source of truth on disk).
|
|
83
|
+
CREATE TABLE IF NOT EXISTS spec_anchors (
|
|
84
|
+
id INTEGER PRIMARY KEY,
|
|
85
|
+
spec_id TEXT NOT NULL,
|
|
86
|
+
capability TEXT NOT NULL,
|
|
87
|
+
requirement_id TEXT NOT NULL,
|
|
88
|
+
scenario_id TEXT NOT NULL DEFAULT '',
|
|
89
|
+
anchor_kind TEXT NOT NULL,
|
|
90
|
+
symbol_name TEXT NOT NULL,
|
|
91
|
+
file_path TEXT,
|
|
92
|
+
node_id INTEGER REFERENCES nodes(id) ON DELETE SET NULL,
|
|
93
|
+
resolution TEXT NOT NULL,
|
|
94
|
+
content_hash TEXT,
|
|
95
|
+
raw_hash TEXT,
|
|
96
|
+
archived_at TEXT NOT NULL,
|
|
97
|
+
commit_sha TEXT,
|
|
98
|
+
source TEXT NOT NULL,
|
|
99
|
+
normalizer_version INTEGER NOT NULL DEFAULT 1,
|
|
100
|
+
UNIQUE (spec_id, requirement_id, scenario_id, anchor_kind, symbol_name)
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_anchors_capability ON spec_anchors(capability);
|
|
103
|
+
CREATE INDEX IF NOT EXISTS idx_anchors_symbol ON spec_anchors(symbol_name);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_anchors_node ON spec_anchors(node_id);
|
|
76
105
|
`;
|
|
77
106
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
78
|
-
export const SCHEMA_VERSION = "
|
|
107
|
+
export const SCHEMA_VERSION = "7";
|
|
79
108
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
80
109
|
function readSchemaVersion(db) {
|
|
81
110
|
try {
|
|
@@ -101,12 +130,16 @@ function isStale(db) {
|
|
|
101
130
|
return false;
|
|
102
131
|
if (readSchemaVersion(db) !== SCHEMA_VERSION)
|
|
103
132
|
return true;
|
|
104
|
-
const
|
|
105
|
-
|
|
133
|
+
const edgeCols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
|
|
134
|
+
if (!edgeCols.includes("src_node_id") || !edgeCols.includes("dst_node_id"))
|
|
135
|
+
return true;
|
|
136
|
+
const fileCols = db.prepare("PRAGMA table_info(files)").all().map((c) => c.name);
|
|
137
|
+
return !fileCols.includes("is_test") || !fileCols.includes("module");
|
|
106
138
|
}
|
|
107
139
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
108
140
|
function resetSchema(db) {
|
|
109
141
|
db.exec(`
|
|
142
|
+
DROP TABLE IF EXISTS spec_anchors;
|
|
110
143
|
DROP TABLE IF EXISTS coverage_links;
|
|
111
144
|
DROP TABLE IF EXISTS git_history_cache;
|
|
112
145
|
DROP TABLE IF EXISTS node_embeddings;
|
|
@@ -133,15 +166,61 @@ export function openDb(projectPath) {
|
|
|
133
166
|
fs.mkdirSync(dir, { recursive: true });
|
|
134
167
|
const db = new DatabaseSync(path.join(dir, "index.db"));
|
|
135
168
|
db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
136
|
-
|
|
169
|
+
const wiped = isStale(db);
|
|
170
|
+
if (wiped)
|
|
137
171
|
resetSchema(db);
|
|
138
172
|
db.exec(SCHEMA);
|
|
139
173
|
const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
140
174
|
if (!row) {
|
|
141
175
|
db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
|
|
142
176
|
}
|
|
177
|
+
if (wiped) {
|
|
178
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
|
|
179
|
+
}
|
|
180
|
+
// Projection from committed JSON — safe even when nodes are empty (node_id null).
|
|
181
|
+
rehydrateAnchors(db, projectPath);
|
|
143
182
|
return db;
|
|
144
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Rebuild `spec_anchors` from `lawbook/anchors/*.json`. Idempotent; called on
|
|
186
|
+
* every open so a wiped `.speclaw/` still sees committed seals.
|
|
187
|
+
*/
|
|
188
|
+
export function rehydrateAnchors(db, projectPath) {
|
|
189
|
+
const dir = path.join(projectPath, "lawbook", "anchors");
|
|
190
|
+
db.exec("DELETE FROM spec_anchors");
|
|
191
|
+
if (!fs.existsSync(dir))
|
|
192
|
+
return;
|
|
193
|
+
const ins = db.prepare(`INSERT OR REPLACE INTO spec_anchors(
|
|
194
|
+
spec_id, capability, requirement_id, scenario_id, anchor_kind, symbol_name,
|
|
195
|
+
file_path, node_id, resolution, content_hash, raw_hash, archived_at, commit_sha,
|
|
196
|
+
source, normalizer_version
|
|
197
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`);
|
|
198
|
+
for (const name of fs.readdirSync(dir)) {
|
|
199
|
+
if (!name.endsWith(".json"))
|
|
200
|
+
continue;
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const capability = parsed.capability ?? name.replace(/\.json$/, "");
|
|
209
|
+
const nv = Number(parsed.normalizerVersion ?? 1);
|
|
210
|
+
for (const a of parsed.anchors ?? []) {
|
|
211
|
+
ins.run(String(a.specId ?? capability), capability, String(a.requirementId ?? ""), String(a.scenarioId ?? ""), String(a.anchorKind ?? "symbol"), String(a.symbolName ?? ""), a.filePath == null ? null : String(a.filePath), String(a.resolution ?? "unresolved"), a.contentHash == null ? null : String(a.contentHash), a.rawHash == null ? null : String(a.rawHash), String(a.archivedAt ?? new Date().toISOString()), a.commitSha == null ? null : String(a.commitSha), String(a.source ?? "backtick"), Number(a.normalizerVersion ?? nv));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Whether the index was wiped and must be rebuilt before hash comparisons. */
|
|
216
|
+
export function needsReindex(db) {
|
|
217
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'needs_reindex'").get();
|
|
218
|
+
return row?.value === "1";
|
|
219
|
+
}
|
|
220
|
+
/** Clear the needs-reindex marker after a successful index run. */
|
|
221
|
+
export function clearNeedsReindex(db) {
|
|
222
|
+
db.prepare("DELETE FROM meta WHERE key = 'needs_reindex'").run();
|
|
223
|
+
}
|
|
145
224
|
/** Absolute path to the index database file for a project. */
|
|
146
225
|
export function indexPath(projectPath) {
|
|
147
226
|
return path.join(projectPath, ".speclaw", "index.db");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parse } from "./parser.js";
|
|
2
|
+
import { rawHash, structuralHash } from "./hash.js";
|
|
2
3
|
const COMMENT_TYPES = new Set(["comment", "line_comment", "block_comment"]);
|
|
3
4
|
/** `Covers:` / `Needs:` / `@covers` at the start of a comment line. */
|
|
4
5
|
const RE_DIRECTIVE = /(?:^|\s|\*)\s*(?:@)?(covers|needs)\s*:?\s+([^\n*]+)/i;
|
|
@@ -113,6 +114,8 @@ export async function extract(source, lang) {
|
|
|
113
114
|
endByte: node.endIndex,
|
|
114
115
|
parentIndex: ownerIndex,
|
|
115
116
|
signature: signatureOf(node),
|
|
117
|
+
bodyHash: rawHash(source, node.startIndex, node.endIndex),
|
|
118
|
+
normHash: structuralHash(node),
|
|
116
119
|
});
|
|
117
120
|
nextOwner = index;
|
|
118
121
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content hashes for Compass nodes: raw body bytes vs structural (tree-sitter)
|
|
3
|
+
* normal form. Structural hashes ignore comments and insignificant whitespace
|
|
4
|
+
* while preserving string literals — the dual-hash pair powers drift classification.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
/** Bump when the structural walk changes; stored anchors become `stale-hash`. */
|
|
8
|
+
export const NORMALIZER_VERSION = 1;
|
|
9
|
+
const COMMENT_TYPES = new Set([
|
|
10
|
+
"comment",
|
|
11
|
+
"line_comment",
|
|
12
|
+
"block_comment",
|
|
13
|
+
"html_comment",
|
|
14
|
+
"hash_bang_line",
|
|
15
|
+
]);
|
|
16
|
+
/** Types whose text is emitted verbatim (spaces inside matter). */
|
|
17
|
+
const VERBATIM_TYPES = new Set([
|
|
18
|
+
"string",
|
|
19
|
+
"string_literal",
|
|
20
|
+
"string_fragment",
|
|
21
|
+
"template_string",
|
|
22
|
+
"template_literal",
|
|
23
|
+
"raw_string_literal",
|
|
24
|
+
"regex",
|
|
25
|
+
"regex_pattern",
|
|
26
|
+
"concatenated_string",
|
|
27
|
+
]);
|
|
28
|
+
function digest(parts) {
|
|
29
|
+
const h = createHash("sha256");
|
|
30
|
+
for (const p of parts) {
|
|
31
|
+
h.update(p);
|
|
32
|
+
h.update("\u0000");
|
|
33
|
+
}
|
|
34
|
+
return h.digest("hex").slice(0, 32);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Hash of the exact source bytes for a symbol range (detects cosmetic edits).
|
|
38
|
+
*
|
|
39
|
+
* @param source - Full file source as UTF-8 string.
|
|
40
|
+
* @param startByte - Inclusive start offset.
|
|
41
|
+
* @param endByte - Exclusive end offset.
|
|
42
|
+
*/
|
|
43
|
+
export function rawHash(source, startByte, endByte) {
|
|
44
|
+
return createHash("sha256").update(source.slice(startByte, endByte)).digest("hex").slice(0, 32);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Structural hash of a tree-sitter subtree. Invariant to reformatting and
|
|
48
|
+
* comments; sensitive to control flow, identifiers, and string contents.
|
|
49
|
+
*
|
|
50
|
+
* @param node - Definition node from the parse tree.
|
|
51
|
+
*/
|
|
52
|
+
export function structuralHash(node) {
|
|
53
|
+
const parts = [`v${NORMALIZER_VERSION}`];
|
|
54
|
+
const walk = (n) => {
|
|
55
|
+
if (COMMENT_TYPES.has(n.type))
|
|
56
|
+
return;
|
|
57
|
+
if (VERBATIM_TYPES.has(n.type)) {
|
|
58
|
+
parts.push(`str:${n.text}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (n.namedChildCount === 0) {
|
|
62
|
+
const t = n.text.trim();
|
|
63
|
+
if (t.length > 0)
|
|
64
|
+
parts.push(t);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
parts.push(`(${n.type}`);
|
|
68
|
+
for (let i = 0; i < n.childCount; i++) {
|
|
69
|
+
const c = n.child(i);
|
|
70
|
+
if (c)
|
|
71
|
+
walk(c);
|
|
72
|
+
}
|
|
73
|
+
parts.push(")");
|
|
74
|
+
};
|
|
75
|
+
walk(node);
|
|
76
|
+
return digest(parts);
|
|
77
|
+
}
|