@esneiderbravo/speclaw 0.3.7 → 0.3.9
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 +4 -0
- package/dist/cli/commands/coverage.js +49 -0
- package/dist/cli/commands/drift.js +39 -0
- package/dist/cli/commands/lawbook.js +7 -0
- package/dist/cli/commands/update.js +16 -0
- package/dist/cli/commands/verify.js +14 -0
- package/dist/cli/index.js +15 -2
- package/dist/cli/lib/untrack.js +1 -0
- package/dist/modules/compass/db.js +95 -3
- package/dist/modules/compass/extract.js +62 -5
- package/dist/modules/compass/hash.js +77 -0
- package/dist/modules/compass/indexer.js +34 -5
- package/dist/modules/foundation/doctor.js +11 -0
- package/dist/modules/lawbook/anchors.js +299 -0
- package/dist/modules/lawbook/coverage.js +479 -0
- package/dist/modules/lawbook/drift.js +491 -0
- package/dist/modules/lawbook/engine.js +42 -1
- package/dist/modules/lawbook/register.js +30 -0
- package/dist/modules/lawbook/spec-items.js +168 -0
- package/dist/shared/exposure.js +1 -1
- package/dist/shared/install.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,6 +129,10 @@ tokenizer on this corpus — not a BPE dependency):
|
|
|
129
129
|
```bash
|
|
130
130
|
speclaw budget # human table
|
|
131
131
|
speclaw budget --json # machine-readable; used by the suite gate
|
|
132
|
+
speclaw coverage # requirement → impl → test coverage (TAP / table)
|
|
133
|
+
speclaw coverage --json # machine-readable coverage report
|
|
134
|
+
speclaw drift # sealed spec ↔ code drift (default --fail-on semantic)
|
|
135
|
+
speclaw drift --reseal # photograph current bodies into lawbook/anchors/
|
|
132
136
|
speclaw init --minimal # omit setup/lifecycle MCP tools from registration
|
|
133
137
|
```
|
|
134
138
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ui } from "../lib/ui.js";
|
|
2
|
+
import { applyAdopt, buildCoverageReport, coverageExitCode, loadCoverageConfig, proposeAdopt, renderCoverageAgent, renderCoverageTable, renderCoverageTap, } from "../../modules/lawbook/coverage.js";
|
|
3
|
+
/**
|
|
4
|
+
* Report requirement → impl → test coverage, or propose/apply id adoption.
|
|
5
|
+
*
|
|
6
|
+
* Flags: `--json`, `--tap`, `--adopt`, `--write`, `--change <name>`.
|
|
7
|
+
* Exit codes: 0 clean / no ids, 1 gated defects, 2 invocation error.
|
|
8
|
+
*/
|
|
9
|
+
export async function runCoverage(flags) {
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
if (flags.adopt) {
|
|
12
|
+
const proposals = proposeAdopt(cwd);
|
|
13
|
+
if (proposals.length === 0) {
|
|
14
|
+
ui.ok("Every requirement already has an identifier.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (flags.write) {
|
|
18
|
+
const result = applyAdopt(cwd, proposals, { write: true });
|
|
19
|
+
ui.ok(`Wrote identifiers into ${result.written.length} file(s) (.bak backups kept).`);
|
|
20
|
+
for (const p of proposals) {
|
|
21
|
+
ui.plain(` ${p.specPath}:${p.line} ${p.title} → ${p.proposedId}${p.collision ? " (disambiguated)" : ""}`);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
ui.heading("coverage --adopt (dry run)");
|
|
26
|
+
for (const p of proposals) {
|
|
27
|
+
ui.plain(` ${p.specPath}:${p.line} ${p.title} → ${p.proposedId}${p.collision ? " (disambiguated)" : ""}`);
|
|
28
|
+
}
|
|
29
|
+
ui.plain();
|
|
30
|
+
ui.info(`Re-run with ${ui.code("--adopt --write")} to apply (backs up to .bak).`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const change = typeof flags.change === "string" ? flags.change : undefined;
|
|
34
|
+
const cfg = loadCoverageConfig(cwd);
|
|
35
|
+
const report = buildCoverageReport(cwd, { change, cfg });
|
|
36
|
+
if (flags.json) {
|
|
37
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
38
|
+
}
|
|
39
|
+
else if (flags.tap || !process.stdout.isTTY) {
|
|
40
|
+
process.stdout.write(renderCoverageTap(report) + "\n");
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
ui.heading("speclaw coverage");
|
|
44
|
+
console.log(renderCoverageTable(report));
|
|
45
|
+
ui.plain();
|
|
46
|
+
console.log(renderCoverageAgent(report, true));
|
|
47
|
+
}
|
|
48
|
+
process.exitCode = coverageExitCode(report, cfg);
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ui } from "../lib/ui.js";
|
|
2
|
+
import { buildDriftReport, parseFailOn, renderDriftAgent, renderDriftTable, } from "../../modules/lawbook/drift.js";
|
|
3
|
+
/**
|
|
4
|
+
* Report deterministic spec↔code drift, or reseal anchors from current bodies.
|
|
5
|
+
*
|
|
6
|
+
* Flags: `--json`, `--capability <name>`, `--fail-on <level>`, `--reverse`,
|
|
7
|
+
* `--reseal`, `--explain`. Default `--fail-on semantic`. Exit 0/1/2.
|
|
8
|
+
*/
|
|
9
|
+
export async function runDrift(flags) {
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const failOn = parseFailOn(flags["fail-on"]);
|
|
12
|
+
if (failOn === null) {
|
|
13
|
+
ui.err(`--fail-on must be none, cosmetic, semantic, or any.`);
|
|
14
|
+
process.exitCode = 2;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const capability = typeof flags.capability === "string" ? flags.capability : undefined;
|
|
18
|
+
const report = buildDriftReport(cwd, {
|
|
19
|
+
capability,
|
|
20
|
+
failOn,
|
|
21
|
+
reverse: Boolean(flags.reverse),
|
|
22
|
+
reseal: Boolean(flags.reseal),
|
|
23
|
+
});
|
|
24
|
+
if (flags.json) {
|
|
25
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
else if (!process.stdout.isTTY) {
|
|
28
|
+
process.stdout.write(renderDriftAgent(report) + "\n");
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
ui.heading("speclaw drift");
|
|
32
|
+
console.log(renderDriftTable(report));
|
|
33
|
+
if (flags.explain) {
|
|
34
|
+
ui.plain();
|
|
35
|
+
console.log(renderDriftAgent(report, 50));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
process.exitCode = report.summary.exitCode;
|
|
39
|
+
}
|
|
@@ -56,6 +56,13 @@ export async function runSpec(flags) {
|
|
|
56
56
|
const r = specArchive(cwd, req(change, "spec archive <change>"), today());
|
|
57
57
|
ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
|
|
58
58
|
r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
|
|
59
|
+
for (const s of r.seals) {
|
|
60
|
+
const msg = `sealed ${s.capability}: ${s.unique} unique / ${s.ambiguous} ambiguous / ${s.unresolved} unresolved → ${s.path}`;
|
|
61
|
+
if (s.warned)
|
|
62
|
+
ui.warn(msg + " (no resolvable anchors)");
|
|
63
|
+
else
|
|
64
|
+
ui.info(msg);
|
|
65
|
+
}
|
|
59
66
|
return;
|
|
60
67
|
}
|
|
61
68
|
default:
|
|
@@ -92,6 +92,22 @@ const MIGRATIONS = [
|
|
|
92
92
|
// scaffold → installHooks already rewrites .claude/settings.json when the
|
|
93
93
|
// compiled hook shape changes; no extra run() step.
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
version: "0.3.8",
|
|
97
|
+
describe: "Requirement coverage: speclaw coverage + lawbook_coverage + schema 5",
|
|
98
|
+
agentPrompt: "- Mention `speclaw coverage` / `lawbook_coverage` for requirement → impl → test coverage " +
|
|
99
|
+
"(ids like `req~name~1`, `// Covers:` comments). Compass schema is now 5 — reindex with " +
|
|
100
|
+
"`speclaw index`. Optionally add coverage.gateArchive / defaultNeeds under lawbook/config.yaml.\n" +
|
|
101
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
version: "0.3.9",
|
|
105
|
+
describe: "Spec drift: speclaw drift + lawbook_drift + schema 6 dual hashes",
|
|
106
|
+
agentPrompt: "- Mention `speclaw drift` / `lawbook_drift` for sealed spec↔code drift " +
|
|
107
|
+
"(committed `lawbook/anchors/*.json`, dual body/norm hashes). Compass schema is now 6 — " +
|
|
108
|
+
"reindex with `speclaw index`, then `speclaw drift --reseal` once to photograph current bodies.\n" +
|
|
109
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
110
|
+
},
|
|
95
111
|
];
|
|
96
112
|
/**
|
|
97
113
|
* Update speclaw and bring the current project up to date without a full re-init:
|
|
@@ -8,6 +8,7 @@ import { toMarkdown } from "../../modules/foundation/report-md.js";
|
|
|
8
8
|
import { toSarif } from "../../modules/foundation/sarif.js";
|
|
9
9
|
import { loadManifestForVerify } from "../../modules/foundation/laws.js";
|
|
10
10
|
import { verifyLaws } from "../../modules/foundation/verify.js";
|
|
11
|
+
import { driftFindingsForVerify } from "../../modules/lawbook/drift.js";
|
|
11
12
|
const FORMATS = new Set(["text", "json", "sarif", "markdown"]);
|
|
12
13
|
/**
|
|
13
14
|
* `speclaw verify` — the CI orchestrator over {@link verifyLaws}. Formats and
|
|
@@ -45,6 +46,19 @@ export async function runVerify(flags) {
|
|
|
45
46
|
engines: engines.length ? engines : undefined,
|
|
46
47
|
lawIds: list(flags.law).length ? list(flags.law) : undefined,
|
|
47
48
|
});
|
|
49
|
+
// Structural spec↔code drift (when anchors exist) contributes semantic/deleted
|
|
50
|
+
// findings into the same report stream used by SARIF / exit codes.
|
|
51
|
+
for (const f of driftFindingsForVerify(cwd)) {
|
|
52
|
+
report.findings.push({
|
|
53
|
+
lawId: f.ruleId,
|
|
54
|
+
severity: "error",
|
|
55
|
+
engine: "graph",
|
|
56
|
+
file: f.file,
|
|
57
|
+
line: f.line,
|
|
58
|
+
message: f.message,
|
|
59
|
+
});
|
|
60
|
+
report.summary.failed += 1;
|
|
61
|
+
}
|
|
48
62
|
const sarifPath = typeof flags.sarif === "string" ? flags.sarif : undefined;
|
|
49
63
|
const jsonPath = typeof flags.json === "string" ? flags.json : undefined;
|
|
50
64
|
if (sarifPath) {
|
package/dist/cli/index.js
CHANGED
|
@@ -37,6 +37,8 @@ Lawbook (spec-driven workflow)
|
|
|
37
37
|
Other
|
|
38
38
|
doctor Verify the installation (--json, --offline, --strict)
|
|
39
39
|
budget Measure always-on context cost (tools, skills, instructions)
|
|
40
|
+
coverage Requirement → impl → test coverage (--json, --tap, --adopt, --write)
|
|
41
|
+
drift Spec↔code drift (--json, --reseal, --reverse, --fail-on)
|
|
40
42
|
telemetry status Confirm speclaw ships no telemetry
|
|
41
43
|
check Evaluate an action against the laws (hooks call this; --dry-run to preview)
|
|
42
44
|
laws verify Verify the deterministic dependency/graph laws against the index
|
|
@@ -60,6 +62,8 @@ const HEADER_COMMANDS = new Set([
|
|
|
60
62
|
"agent",
|
|
61
63
|
"doctor",
|
|
62
64
|
"budget",
|
|
65
|
+
"coverage",
|
|
66
|
+
"drift",
|
|
63
67
|
"telemetry",
|
|
64
68
|
"index",
|
|
65
69
|
"watch",
|
|
@@ -70,8 +74,9 @@ const HEADER_COMMANDS = new Set([
|
|
|
70
74
|
* header-eligible command AND stdout is an interactive terminal (so pipes,
|
|
71
75
|
* redirection, and CI stay clean — mirroring the color gate in `ui.ts`). A
|
|
72
76
|
* forced-color signal counts as interactive so the header is exercisable in a
|
|
73
|
-
* child process. `budget --json
|
|
74
|
-
* suppress the
|
|
77
|
+
* child process. `budget --json`, `doctor --json`, and `coverage` when emitting
|
|
78
|
+
* TAP/JSON (or when stdout is not a TTY) are machine-consumed and suppress the
|
|
79
|
+
* header.
|
|
75
80
|
*/
|
|
76
81
|
function maybeHeader(cmd, flags) {
|
|
77
82
|
if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
|
|
@@ -82,6 +87,10 @@ function maybeHeader(cmd, flags) {
|
|
|
82
87
|
return;
|
|
83
88
|
if (cmd === "doctor" && flags.json)
|
|
84
89
|
return;
|
|
90
|
+
if (cmd === "coverage" && (flags.json || flags.tap))
|
|
91
|
+
return;
|
|
92
|
+
if (cmd === "drift" && flags.json)
|
|
93
|
+
return;
|
|
85
94
|
header();
|
|
86
95
|
}
|
|
87
96
|
/** Run the handler for a single command. Returns when the command completes. */
|
|
@@ -126,6 +135,10 @@ async function dispatch(cmd, flags) {
|
|
|
126
135
|
return (await import("./commands/doctor.js")).runDoctor(flags);
|
|
127
136
|
case "budget":
|
|
128
137
|
return (await import("./commands/budget.js")).runBudget(flags);
|
|
138
|
+
case "coverage":
|
|
139
|
+
return (await import("./commands/coverage.js")).runCoverage(flags);
|
|
140
|
+
case "drift":
|
|
141
|
+
return (await import("./commands/drift.js")).runDrift(flags);
|
|
129
142
|
case "telemetry":
|
|
130
143
|
return (await import("./commands/telemetry.js")).runTelemetry(flags);
|
|
131
144
|
case "check":
|
package/dist/cli/lib/untrack.js
CHANGED
|
@@ -12,6 +12,7 @@ import { listTrackedPaths } from "../../shared/git.js";
|
|
|
12
12
|
*
|
|
13
13
|
* @param projectPath - Project root to inspect and address.
|
|
14
14
|
*/
|
|
15
|
+
// Covers: req~agent-ide-committable~1, req~ai-specs-untrack-hint~1
|
|
15
16
|
export function reportTrackedLocalContent(projectPath) {
|
|
16
17
|
const tracked = listTrackedPaths(projectPath, ["ai-specs"]);
|
|
17
18
|
if (!tracked.length)
|
|
@@ -23,10 +23,13 @@ CREATE TABLE IF NOT EXISTS nodes (
|
|
|
23
23
|
start_byte INTEGER NOT NULL,
|
|
24
24
|
end_byte INTEGER NOT NULL,
|
|
25
25
|
parent_id INTEGER,
|
|
26
|
-
signature TEXT
|
|
26
|
+
signature TEXT,
|
|
27
|
+
body_hash TEXT,
|
|
28
|
+
norm_hash TEXT
|
|
27
29
|
);
|
|
28
30
|
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
|
29
31
|
CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_id);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_norm_hash ON nodes(norm_hash);
|
|
30
33
|
-- edges: a reference from one node to a named target, resolved lazily.
|
|
31
34
|
CREATE TABLE IF NOT EXISTS edges (
|
|
32
35
|
id INTEGER PRIMARY KEY,
|
|
@@ -55,9 +58,50 @@ CREATE TABLE IF NOT EXISTS git_history_cache (
|
|
|
55
58
|
payload TEXT NOT NULL,
|
|
56
59
|
computed_at INTEGER NOT NULL
|
|
57
60
|
);
|
|
61
|
+
-- coverage_links: derived requirement-coverage directives from comment nodes.
|
|
62
|
+
-- Spec items themselves are NOT persisted — always reparsed from disk.
|
|
63
|
+
CREATE TABLE IF NOT EXISTS coverage_links (
|
|
64
|
+
id INTEGER PRIMARY KEY,
|
|
65
|
+
artifact_type TEXT NOT NULL,
|
|
66
|
+
name TEXT NOT NULL,
|
|
67
|
+
revision INTEGER NOT NULL,
|
|
68
|
+
kind TEXT NOT NULL,
|
|
69
|
+
file_path TEXT NOT NULL,
|
|
70
|
+
line INTEGER NOT NULL,
|
|
71
|
+
node_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE,
|
|
72
|
+
source_type TEXT NOT NULL,
|
|
73
|
+
origin TEXT NOT NULL,
|
|
74
|
+
UNIQUE (artifact_type, name, revision, kind, file_path, line)
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_cov_target ON coverage_links(artifact_type, name, revision);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_cov_file ON coverage_links(file_path);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_cov_node ON coverage_links(node_id);
|
|
79
|
+
-- spec_anchors: projection of committed lawbook/anchors/*.json (source of truth on disk).
|
|
80
|
+
CREATE TABLE IF NOT EXISTS spec_anchors (
|
|
81
|
+
id INTEGER PRIMARY KEY,
|
|
82
|
+
spec_id TEXT NOT NULL,
|
|
83
|
+
capability TEXT NOT NULL,
|
|
84
|
+
requirement_id TEXT NOT NULL,
|
|
85
|
+
scenario_id TEXT NOT NULL DEFAULT '',
|
|
86
|
+
anchor_kind TEXT NOT NULL,
|
|
87
|
+
symbol_name TEXT NOT NULL,
|
|
88
|
+
file_path TEXT,
|
|
89
|
+
node_id INTEGER REFERENCES nodes(id) ON DELETE SET NULL,
|
|
90
|
+
resolution TEXT NOT NULL,
|
|
91
|
+
content_hash TEXT,
|
|
92
|
+
raw_hash TEXT,
|
|
93
|
+
archived_at TEXT NOT NULL,
|
|
94
|
+
commit_sha TEXT,
|
|
95
|
+
source TEXT NOT NULL,
|
|
96
|
+
normalizer_version INTEGER NOT NULL DEFAULT 1,
|
|
97
|
+
UNIQUE (spec_id, requirement_id, scenario_id, anchor_kind, symbol_name)
|
|
98
|
+
);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_anchors_capability ON spec_anchors(capability);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_anchors_symbol ON spec_anchors(symbol_name);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_anchors_node ON spec_anchors(node_id);
|
|
58
102
|
`;
|
|
59
103
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
60
|
-
export const SCHEMA_VERSION = "
|
|
104
|
+
export const SCHEMA_VERSION = "6";
|
|
61
105
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
62
106
|
function readSchemaVersion(db) {
|
|
63
107
|
try {
|
|
@@ -89,6 +133,8 @@ function isStale(db) {
|
|
|
89
133
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
90
134
|
function resetSchema(db) {
|
|
91
135
|
db.exec(`
|
|
136
|
+
DROP TABLE IF EXISTS spec_anchors;
|
|
137
|
+
DROP TABLE IF EXISTS coverage_links;
|
|
92
138
|
DROP TABLE IF EXISTS git_history_cache;
|
|
93
139
|
DROP TABLE IF EXISTS node_embeddings;
|
|
94
140
|
DROP TABLE IF EXISTS edges;
|
|
@@ -114,15 +160,61 @@ export function openDb(projectPath) {
|
|
|
114
160
|
fs.mkdirSync(dir, { recursive: true });
|
|
115
161
|
const db = new DatabaseSync(path.join(dir, "index.db"));
|
|
116
162
|
db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
117
|
-
|
|
163
|
+
const wiped = isStale(db);
|
|
164
|
+
if (wiped)
|
|
118
165
|
resetSchema(db);
|
|
119
166
|
db.exec(SCHEMA);
|
|
120
167
|
const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
121
168
|
if (!row) {
|
|
122
169
|
db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
|
|
123
170
|
}
|
|
171
|
+
if (wiped) {
|
|
172
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('needs_reindex', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").run();
|
|
173
|
+
}
|
|
174
|
+
// Projection from committed JSON — safe even when nodes are empty (node_id null).
|
|
175
|
+
rehydrateAnchors(db, projectPath);
|
|
124
176
|
return db;
|
|
125
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Rebuild `spec_anchors` from `lawbook/anchors/*.json`. Idempotent; called on
|
|
180
|
+
* every open so a wiped `.speclaw/` still sees committed seals.
|
|
181
|
+
*/
|
|
182
|
+
export function rehydrateAnchors(db, projectPath) {
|
|
183
|
+
const dir = path.join(projectPath, "lawbook", "anchors");
|
|
184
|
+
db.exec("DELETE FROM spec_anchors");
|
|
185
|
+
if (!fs.existsSync(dir))
|
|
186
|
+
return;
|
|
187
|
+
const ins = db.prepare(`INSERT OR REPLACE INTO spec_anchors(
|
|
188
|
+
spec_id, capability, requirement_id, scenario_id, anchor_kind, symbol_name,
|
|
189
|
+
file_path, node_id, resolution, content_hash, raw_hash, archived_at, commit_sha,
|
|
190
|
+
source, normalizer_version
|
|
191
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`);
|
|
192
|
+
for (const name of fs.readdirSync(dir)) {
|
|
193
|
+
if (!name.endsWith(".json"))
|
|
194
|
+
continue;
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const capability = parsed.capability ?? name.replace(/\.json$/, "");
|
|
203
|
+
const nv = Number(parsed.normalizerVersion ?? 1);
|
|
204
|
+
for (const a of parsed.anchors ?? []) {
|
|
205
|
+
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));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Whether the index was wiped and must be rebuilt before hash comparisons. */
|
|
210
|
+
export function needsReindex(db) {
|
|
211
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'needs_reindex'").get();
|
|
212
|
+
return row?.value === "1";
|
|
213
|
+
}
|
|
214
|
+
/** Clear the needs-reindex marker after a successful index run. */
|
|
215
|
+
export function clearNeedsReindex(db) {
|
|
216
|
+
db.prepare("DELETE FROM meta WHERE key = 'needs_reindex'").run();
|
|
217
|
+
}
|
|
126
218
|
/** Absolute path to the index database file for a project. */
|
|
127
219
|
export function indexPath(projectPath) {
|
|
128
220
|
return path.join(projectPath, ".speclaw", "index.db");
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { parse } from "./parser.js";
|
|
2
|
+
import { rawHash, structuralHash } from "./hash.js";
|
|
3
|
+
const COMMENT_TYPES = new Set(["comment", "line_comment", "block_comment"]);
|
|
4
|
+
/** `Covers:` / `Needs:` / `@covers` at the start of a comment line. */
|
|
5
|
+
const RE_DIRECTIVE = /(?:^|\s|\*)\s*(?:@)?(covers|needs)\s*:?\s+([^\n*]+)/i;
|
|
6
|
+
/** One OFT-shaped id: type~name~revision. */
|
|
7
|
+
const RE_ID = /\b([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)\b/g;
|
|
2
8
|
const DEF_LOOKUP = new WeakMap();
|
|
3
9
|
function defKindMap(lang) {
|
|
4
10
|
let m = DEF_LOOKUP.get(lang);
|
|
@@ -31,14 +37,59 @@ function calleeName(node, lang) {
|
|
|
31
37
|
function signatureOf(node) {
|
|
32
38
|
return node.text.split("\n")[0].trim().slice(0, 200);
|
|
33
39
|
}
|
|
40
|
+
/** Parse Covers:/Needs: directives from a comment node's text. */
|
|
41
|
+
function parseCoverageComment(node, ownerIndex) {
|
|
42
|
+
const text = node.text;
|
|
43
|
+
const dir = RE_DIRECTIVE.exec(text);
|
|
44
|
+
if (!dir)
|
|
45
|
+
return [];
|
|
46
|
+
const kind = dir[1].toLowerCase();
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const m of dir[2].matchAll(RE_ID)) {
|
|
49
|
+
out.push({
|
|
50
|
+
kind,
|
|
51
|
+
artifactType: m[1],
|
|
52
|
+
name: m[2],
|
|
53
|
+
revision: Number(m[3]),
|
|
54
|
+
line: node.startPosition.row + 1,
|
|
55
|
+
startByte: node.startIndex,
|
|
56
|
+
endByte: node.endIndex,
|
|
57
|
+
endLine: node.endPosition.row + 1,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// silence unused until attribution; ownerIndex filled by attachCoverage
|
|
61
|
+
void ownerIndex;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Attribute a coverage comment to a symbol: next def within 2 lines, else
|
|
66
|
+
* innermost containing symbol, else file-level (null).
|
|
67
|
+
*/
|
|
68
|
+
function attachCoverage(raw, symbols) {
|
|
69
|
+
return raw.map((c) => {
|
|
70
|
+
const next = symbols.find((s) => s.startByte >= c.endByte);
|
|
71
|
+
if (next && next.startLine - c.endLine <= 2) {
|
|
72
|
+
return { ...c, ownerIndex: symbols.indexOf(next) };
|
|
73
|
+
}
|
|
74
|
+
const containing = symbols
|
|
75
|
+
.map((s, i) => ({ s, i }))
|
|
76
|
+
.filter(({ s }) => s.startByte <= c.startByte && c.endByte <= s.endByte)
|
|
77
|
+
.sort((a, b) => a.s.endByte - a.s.startByte - (b.s.endByte - b.s.startByte));
|
|
78
|
+
if (containing.length > 0) {
|
|
79
|
+
return { ...c, ownerIndex: containing[0].i };
|
|
80
|
+
}
|
|
81
|
+
return { ...c, ownerIndex: null };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
34
84
|
/**
|
|
35
|
-
* Walk a parsed tree extracting definitions (with nesting)
|
|
36
|
-
* references
|
|
85
|
+
* Walk a parsed tree extracting definitions (with nesting), call/import
|
|
86
|
+
* references, and requirement-coverage directives from comment nodes. Single
|
|
87
|
+
* traversal, O(nodes).
|
|
37
88
|
*
|
|
38
89
|
* @param source - The full source text of the file.
|
|
39
90
|
* @param lang - Language configuration describing definition/call/import nodes.
|
|
40
|
-
* @returns The extracted symbols and
|
|
41
|
-
* fields index back into the `symbols` array
|
|
91
|
+
* @returns The extracted symbols, references, and coverage directives;
|
|
92
|
+
* `parentIndex`/`ownerIndex` fields index back into the `symbols` array.
|
|
42
93
|
* @throws If the source cannot be parsed for the given language.
|
|
43
94
|
*/
|
|
44
95
|
export async function extract(source, lang) {
|
|
@@ -47,6 +98,7 @@ export async function extract(source, lang) {
|
|
|
47
98
|
const importSet = new Set(lang.importNodes);
|
|
48
99
|
const symbols = [];
|
|
49
100
|
const refs = [];
|
|
101
|
+
const rawCoverage = [];
|
|
50
102
|
const walk = (node, ownerIndex) => {
|
|
51
103
|
let nextOwner = ownerIndex;
|
|
52
104
|
if (kinds.has(node.type)) {
|
|
@@ -62,6 +114,8 @@ export async function extract(source, lang) {
|
|
|
62
114
|
endByte: node.endIndex,
|
|
63
115
|
parentIndex: ownerIndex,
|
|
64
116
|
signature: signatureOf(node),
|
|
117
|
+
bodyHash: rawHash(source, node.startIndex, node.endIndex),
|
|
118
|
+
normHash: structuralHash(node),
|
|
65
119
|
});
|
|
66
120
|
nextOwner = index;
|
|
67
121
|
}
|
|
@@ -79,6 +133,9 @@ export async function extract(source, lang) {
|
|
|
79
133
|
ownerIndex,
|
|
80
134
|
});
|
|
81
135
|
}
|
|
136
|
+
else if (COMMENT_TYPES.has(node.type)) {
|
|
137
|
+
rawCoverage.push(...parseCoverageComment(node, ownerIndex));
|
|
138
|
+
}
|
|
82
139
|
for (let i = 0; i < node.childCount; i++) {
|
|
83
140
|
const child = node.child(i);
|
|
84
141
|
if (child)
|
|
@@ -87,5 +144,5 @@ export async function extract(source, lang) {
|
|
|
87
144
|
};
|
|
88
145
|
walk(tree.rootNode, null);
|
|
89
146
|
tree.delete();
|
|
90
|
-
return { symbols, refs };
|
|
147
|
+
return { symbols, refs, coverage: attachCoverage(rawCoverage, symbols) };
|
|
91
148
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { openDb } from "./db.js";
|
|
4
|
+
import { openDb, clearNeedsReindex } from "./db.js";
|
|
5
5
|
import { langForPath } from "./languages.js";
|
|
6
6
|
import { extract } from "./extract.js";
|
|
7
7
|
import { getEmbedder, toBlob } from "./embedder.js";
|
|
@@ -29,6 +29,24 @@ const MAX_FILE_BYTES = 1_500_000;
|
|
|
29
29
|
function hashOf(content) {
|
|
30
30
|
return createHash("sha256").update(content).digest("hex");
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Infer a covering artifact's type from its project-relative path.
|
|
34
|
+
* Full glob config lives in lawbook; this is the indexer default so links are
|
|
35
|
+
* typed even before a coverage report runs.
|
|
36
|
+
*/
|
|
37
|
+
function inferSourceType(relPath) {
|
|
38
|
+
const p = relPath.split("\\").join("/");
|
|
39
|
+
if (/(^|\/)test\/integration\//.test(p) || /(^|\/)tests\/integration\//.test(p))
|
|
40
|
+
return "itest";
|
|
41
|
+
if (/(^|\/)test\/unit\//.test(p) ||
|
|
42
|
+
/(^|\/)tests\/unit\//.test(p) ||
|
|
43
|
+
/\.test\.[cm]?[jt]sx?$/.test(p) ||
|
|
44
|
+
/\.spec\.[cm]?[jt]sx?$/.test(p) ||
|
|
45
|
+
/(^|\/)test\//.test(p)) {
|
|
46
|
+
return "utest";
|
|
47
|
+
}
|
|
48
|
+
return "impl";
|
|
49
|
+
}
|
|
32
50
|
function* walkFiles(root) {
|
|
33
51
|
const stack = [root];
|
|
34
52
|
while (stack.length) {
|
|
@@ -89,9 +107,13 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
89
107
|
const updFile = db.prepare("UPDATE files SET hash = ?, lang = ? WHERE id = ?");
|
|
90
108
|
const delNodes = db.prepare("DELETE FROM nodes WHERE file_id = ?");
|
|
91
109
|
const delEdges = db.prepare("DELETE FROM edges WHERE src_file_id = ?");
|
|
92
|
-
const
|
|
93
|
-
|
|
110
|
+
const delCoverage = db.prepare("DELETE FROM coverage_links WHERE file_path = ?");
|
|
111
|
+
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)
|
|
112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
94
113
|
const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
|
|
114
|
+
const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
|
|
115
|
+
artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
116
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
95
117
|
const insEmbed = db.prepare(`INSERT OR REPLACE INTO node_embeddings(node_id, dim, model, vec) VALUES (?, ?, ?, ?)`);
|
|
96
118
|
const allFiles = [...walkFiles(projectPath)];
|
|
97
119
|
db.exec("BEGIN");
|
|
@@ -125,16 +147,17 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
125
147
|
updFile.run(hash, lang.id, prior.id);
|
|
126
148
|
delNodes.run(prior.id);
|
|
127
149
|
delEdges.run(prior.id);
|
|
150
|
+
delCoverage.run(rel);
|
|
128
151
|
fileId = prior.id;
|
|
129
152
|
}
|
|
130
153
|
else {
|
|
131
154
|
fileId = Number(insFile.run(rel, hash, lang.id).lastInsertRowid);
|
|
132
155
|
}
|
|
133
|
-
const { symbols, refs } = await extract(content, lang);
|
|
156
|
+
const { symbols, refs, coverage } = await extract(content, lang);
|
|
134
157
|
const nodeIds = [];
|
|
135
158
|
for (const s of symbols) {
|
|
136
159
|
const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
|
|
137
|
-
const id = Number(insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature).lastInsertRowid);
|
|
160
|
+
const id = Number(insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature, s.bodyHash, s.normHash).lastInsertRowid);
|
|
138
161
|
nodeIds.push(id);
|
|
139
162
|
// embed the node from its name + signature (cheap, meaningful text)
|
|
140
163
|
const vec = await embedder.embed(`${s.kind} ${s.name} ${s.signature ?? ""}`);
|
|
@@ -146,6 +169,11 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
146
169
|
insEdge.run(srcId, fileId, r.name, r.kind, r.line);
|
|
147
170
|
stats.edges++;
|
|
148
171
|
}
|
|
172
|
+
const sourceType = inferSourceType(rel);
|
|
173
|
+
for (const c of coverage) {
|
|
174
|
+
const nodeId = c.ownerIndex !== null ? nodeIds[c.ownerIndex] : null;
|
|
175
|
+
insCoverage.run(c.artifactType, c.name, c.revision, c.kind, rel, c.line, nodeId, sourceType, "comment");
|
|
176
|
+
}
|
|
149
177
|
stats.files++;
|
|
150
178
|
stats.nodes += symbols.length;
|
|
151
179
|
}
|
|
@@ -166,6 +194,7 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
166
194
|
WHERE kind = 'call' AND dst_node_id IS NULL
|
|
167
195
|
`);
|
|
168
196
|
db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
|
|
197
|
+
clearNeedsReindex(db);
|
|
169
198
|
db.exec("COMMIT");
|
|
170
199
|
}
|
|
171
200
|
catch (err) {
|
|
@@ -7,6 +7,7 @@ import { readManifest } from "../../shared/manifest.js";
|
|
|
7
7
|
import { pkgName, pkgVersion } from "../../shared/version.js";
|
|
8
8
|
import { indexExists, openDb } from "../compass/db.js";
|
|
9
9
|
import { specList } from "../lawbook/engine.js";
|
|
10
|
+
import { doctorDriftCheck } from "../lawbook/drift.js";
|
|
10
11
|
import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
|
|
11
12
|
import { redactValue } from "../../shared/redact.js";
|
|
12
13
|
const STATUS_RANK = {
|
|
@@ -558,6 +559,16 @@ export async function doctor(projectPath, opts = {}) {
|
|
|
558
559
|
configuration.push(await budgetCheck(projectPath));
|
|
559
560
|
configuration.push(freshnessCheck(projectPath));
|
|
560
561
|
configuration.push(specsOrphansCheck(projectPath));
|
|
562
|
+
{
|
|
563
|
+
const d = doctorDriftCheck(projectPath);
|
|
564
|
+
addCheck(configuration, {
|
|
565
|
+
id: d.id,
|
|
566
|
+
title: d.title,
|
|
567
|
+
status: d.status,
|
|
568
|
+
detail: d.detail,
|
|
569
|
+
remedy: d.remedy,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
561
572
|
const configured = detectConfiguredAgents(projectPath);
|
|
562
573
|
const mcpAgents = AGENTS.filter((a) => a.mcpFile && configured.includes(a.id));
|
|
563
574
|
if (mcpAgents.length === 0) {
|