@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.
@@ -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 = "5";
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 cols = db.prepare("PRAGMA table_info(edges)").all().map((c) => c.name);
105
- return !cols.includes("src_node_id") || !cols.includes("dst_node_id");
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
- if (isStale(db))
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
+ }