@esneiderbravo/speclaw 0.3.8 → 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 +2 -0
- package/dist/cli/commands/drift.js +39 -0
- package/dist/cli/commands/lawbook.js +7 -0
- package/dist/cli/commands/update.js +8 -0
- package/dist/cli/commands/verify.js +14 -0
- package/dist/cli/index.js +6 -0
- package/dist/modules/compass/db.js +76 -3
- package/dist/modules/compass/extract.js +3 -0
- package/dist/modules/compass/hash.js +77 -0
- package/dist/modules/compass/indexer.js +5 -4
- 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 +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -131,6 +131,8 @@ speclaw budget # human table
|
|
|
131
131
|
speclaw budget --json # machine-readable; used by the suite gate
|
|
132
132
|
speclaw coverage # requirement → impl → test coverage (TAP / table)
|
|
133
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/
|
|
134
136
|
speclaw init --minimal # omit setup/lifecycle MCP tools from registration
|
|
135
137
|
```
|
|
136
138
|
|
|
@@ -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:
|
|
@@ -100,6 +100,14 @@ const MIGRATIONS = [
|
|
|
100
100
|
"`speclaw index`. Optionally add coverage.gateArchive / defaultNeeds under lawbook/config.yaml.\n" +
|
|
101
101
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
102
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
|
+
},
|
|
103
111
|
];
|
|
104
112
|
/**
|
|
105
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
|
@@ -38,6 +38,7 @@ Other
|
|
|
38
38
|
doctor Verify the installation (--json, --offline, --strict)
|
|
39
39
|
budget Measure always-on context cost (tools, skills, instructions)
|
|
40
40
|
coverage Requirement → impl → test coverage (--json, --tap, --adopt, --write)
|
|
41
|
+
drift Spec↔code drift (--json, --reseal, --reverse, --fail-on)
|
|
41
42
|
telemetry status Confirm speclaw ships no telemetry
|
|
42
43
|
check Evaluate an action against the laws (hooks call this; --dry-run to preview)
|
|
43
44
|
laws verify Verify the deterministic dependency/graph laws against the index
|
|
@@ -62,6 +63,7 @@ const HEADER_COMMANDS = new Set([
|
|
|
62
63
|
"doctor",
|
|
63
64
|
"budget",
|
|
64
65
|
"coverage",
|
|
66
|
+
"drift",
|
|
65
67
|
"telemetry",
|
|
66
68
|
"index",
|
|
67
69
|
"watch",
|
|
@@ -87,6 +89,8 @@ function maybeHeader(cmd, flags) {
|
|
|
87
89
|
return;
|
|
88
90
|
if (cmd === "coverage" && (flags.json || flags.tap))
|
|
89
91
|
return;
|
|
92
|
+
if (cmd === "drift" && flags.json)
|
|
93
|
+
return;
|
|
90
94
|
header();
|
|
91
95
|
}
|
|
92
96
|
/** Run the handler for a single command. Returns when the command completes. */
|
|
@@ -133,6 +137,8 @@ async function dispatch(cmd, flags) {
|
|
|
133
137
|
return (await import("./commands/budget.js")).runBudget(flags);
|
|
134
138
|
case "coverage":
|
|
135
139
|
return (await import("./commands/coverage.js")).runCoverage(flags);
|
|
140
|
+
case "drift":
|
|
141
|
+
return (await import("./commands/drift.js")).runDrift(flags);
|
|
136
142
|
case "telemetry":
|
|
137
143
|
return (await import("./commands/telemetry.js")).runTelemetry(flags);
|
|
138
144
|
case "check":
|
|
@@ -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,
|
|
@@ -73,9 +76,32 @@ CREATE TABLE IF NOT EXISTS coverage_links (
|
|
|
73
76
|
CREATE INDEX IF NOT EXISTS idx_cov_target ON coverage_links(artifact_type, name, revision);
|
|
74
77
|
CREATE INDEX IF NOT EXISTS idx_cov_file ON coverage_links(file_path);
|
|
75
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);
|
|
76
102
|
`;
|
|
77
103
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
78
|
-
export const SCHEMA_VERSION = "
|
|
104
|
+
export const SCHEMA_VERSION = "6";
|
|
79
105
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
80
106
|
function readSchemaVersion(db) {
|
|
81
107
|
try {
|
|
@@ -107,6 +133,7 @@ function isStale(db) {
|
|
|
107
133
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
108
134
|
function resetSchema(db) {
|
|
109
135
|
db.exec(`
|
|
136
|
+
DROP TABLE IF EXISTS spec_anchors;
|
|
110
137
|
DROP TABLE IF EXISTS coverage_links;
|
|
111
138
|
DROP TABLE IF EXISTS git_history_cache;
|
|
112
139
|
DROP TABLE IF EXISTS node_embeddings;
|
|
@@ -133,15 +160,61 @@ export function openDb(projectPath) {
|
|
|
133
160
|
fs.mkdirSync(dir, { recursive: true });
|
|
134
161
|
const db = new DatabaseSync(path.join(dir, "index.db"));
|
|
135
162
|
db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
136
|
-
|
|
163
|
+
const wiped = isStale(db);
|
|
164
|
+
if (wiped)
|
|
137
165
|
resetSchema(db);
|
|
138
166
|
db.exec(SCHEMA);
|
|
139
167
|
const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
140
168
|
if (!row) {
|
|
141
169
|
db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
|
|
142
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);
|
|
143
176
|
return db;
|
|
144
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
|
+
}
|
|
145
218
|
/** Absolute path to the index database file for a project. */
|
|
146
219
|
export function indexPath(projectPath) {
|
|
147
220
|
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
|
+
}
|
|
@@ -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";
|
|
@@ -108,8 +108,8 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
108
108
|
const delNodes = db.prepare("DELETE FROM nodes WHERE file_id = ?");
|
|
109
109
|
const delEdges = db.prepare("DELETE FROM edges WHERE src_file_id = ?");
|
|
110
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)
|
|
112
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
113
113
|
const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
|
|
114
114
|
const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
|
|
115
115
|
artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
@@ -157,7 +157,7 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
157
157
|
const nodeIds = [];
|
|
158
158
|
for (const s of symbols) {
|
|
159
159
|
const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
|
|
160
|
-
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);
|
|
161
161
|
nodeIds.push(id);
|
|
162
162
|
// embed the node from its name + signature (cheap, meaningful text)
|
|
163
163
|
const vec = await embedder.embed(`${s.kind} ${s.name} ${s.signature ?? ""}`);
|
|
@@ -194,6 +194,7 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
194
194
|
WHERE kind = 'call' AND dst_node_id IS NULL
|
|
195
195
|
`);
|
|
196
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);
|
|
197
198
|
db.exec("COMMIT");
|
|
198
199
|
}
|
|
199
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) {
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec-anchor extraction, resolution, and committed JSON under
|
|
3
|
+
* `lawbook/anchors/<capability>.json`. SQLite `spec_anchors` is a projection only.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { openDb, rehydrateAnchors } from "../compass/db.js";
|
|
8
|
+
import { NORMALIZER_VERSION } from "../compass/hash.js";
|
|
9
|
+
import { headSha } from "../../shared/git-history.js";
|
|
10
|
+
const STOPWORDS = new Set([
|
|
11
|
+
"SHALL",
|
|
12
|
+
"MUST",
|
|
13
|
+
"SHOULD",
|
|
14
|
+
"MAY",
|
|
15
|
+
"GIVEN",
|
|
16
|
+
"WHEN",
|
|
17
|
+
"THEN",
|
|
18
|
+
"AND",
|
|
19
|
+
"NOT",
|
|
20
|
+
"Requirement",
|
|
21
|
+
"Scenario",
|
|
22
|
+
"speclaw",
|
|
23
|
+
"Compass",
|
|
24
|
+
"Lawbook",
|
|
25
|
+
"LAWS",
|
|
26
|
+
"AGENTS",
|
|
27
|
+
"CLAUDE",
|
|
28
|
+
"JSON",
|
|
29
|
+
"YAML",
|
|
30
|
+
"SQL",
|
|
31
|
+
"CLI",
|
|
32
|
+
"MCP",
|
|
33
|
+
"API",
|
|
34
|
+
"HTTP",
|
|
35
|
+
"URL",
|
|
36
|
+
"AST",
|
|
37
|
+
"TS",
|
|
38
|
+
"JS",
|
|
39
|
+
"SQLite",
|
|
40
|
+
"TypeScript",
|
|
41
|
+
"GitHub",
|
|
42
|
+
"README",
|
|
43
|
+
"TODO",
|
|
44
|
+
"ISO",
|
|
45
|
+
"UTC",
|
|
46
|
+
]);
|
|
47
|
+
const RE_BACKTICK = /`([^`\n]{2,80})`/g;
|
|
48
|
+
const RE_CASING = /\b([a-z][a-zA-Z0-9]{2,}|[A-Z][a-z][a-zA-Z0-9]{1,})\b/g;
|
|
49
|
+
const RE_PATH = /\b((?:src|test|lib|app|packages)\/[\w./-]+\.(?:ts|tsx|js|mjs|py))\b/g;
|
|
50
|
+
const RE_COVERS = /\b([a-z]{2,6}~[A-Za-z0-9._-]+~\d+)\b/g;
|
|
51
|
+
/** Absolute path to the committed anchors directory. */
|
|
52
|
+
export function anchorsDir(projectPath) {
|
|
53
|
+
return path.join(projectPath, "lawbook", "anchors");
|
|
54
|
+
}
|
|
55
|
+
/** Absolute path to one capability's anchors file. */
|
|
56
|
+
export function anchorsPath(projectPath, capability) {
|
|
57
|
+
return path.join(anchorsDir(projectPath), `${capability}.json`);
|
|
58
|
+
}
|
|
59
|
+
/** Extract candidates from a markdown document. */
|
|
60
|
+
export function extractCandidates(markdown) {
|
|
61
|
+
const out = [];
|
|
62
|
+
let requirementId = "";
|
|
63
|
+
let scenarioId = "";
|
|
64
|
+
for (const rawLine of markdown.split("\n")) {
|
|
65
|
+
const line = rawLine.trimEnd();
|
|
66
|
+
const req = /^###\s+Requirement:\s*(.+)$/.exec(line);
|
|
67
|
+
if (req) {
|
|
68
|
+
requirementId = slug(req[1]);
|
|
69
|
+
scenarioId = "";
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const sce = /^####\s+Scenario:\s*(.+)$/.exec(line);
|
|
73
|
+
if (sce) {
|
|
74
|
+
scenarioId = slug(sce[1]);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (/^#{1,2}\s/.test(line)) {
|
|
78
|
+
requirementId = "";
|
|
79
|
+
scenarioId = "";
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!requirementId)
|
|
83
|
+
continue;
|
|
84
|
+
for (const m of line.matchAll(RE_COVERS)) {
|
|
85
|
+
out.push({ text: m[1], source: "covers-link", requirementId, scenarioId });
|
|
86
|
+
}
|
|
87
|
+
for (const m of line.matchAll(RE_BACKTICK)) {
|
|
88
|
+
const t = m[1].replace(/\(\s*\)$/, "").trim();
|
|
89
|
+
if (!t || t.includes(" "))
|
|
90
|
+
continue;
|
|
91
|
+
const source = RE_PATH.test(t) ? "path" : "backtick";
|
|
92
|
+
RE_PATH.lastIndex = 0;
|
|
93
|
+
out.push({ text: t, source, requirementId, scenarioId });
|
|
94
|
+
}
|
|
95
|
+
for (const m of line.matchAll(RE_CASING)) {
|
|
96
|
+
const t = m[1];
|
|
97
|
+
if (STOPWORDS.has(t) || t.length < 3)
|
|
98
|
+
continue;
|
|
99
|
+
if (!/[a-z]/.test(t) || !/[A-Z]/.test(t))
|
|
100
|
+
continue;
|
|
101
|
+
out.push({ text: t, source: "casing", requirementId, scenarioId });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return dedupe(out);
|
|
105
|
+
}
|
|
106
|
+
/** Resolve candidates against the Compass graph. */
|
|
107
|
+
export function resolveCandidates(db, projectPath, cands, specId, now, sha) {
|
|
108
|
+
const rows = [];
|
|
109
|
+
const byName = db.prepare(`SELECT n.id AS id, n.norm_hash AS normHash, n.body_hash AS bodyHash, f.path AS path
|
|
110
|
+
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
111
|
+
WHERE n.name = ? AND n.kind IN ('function','method','class','interface','type')`);
|
|
112
|
+
for (const c of cands) {
|
|
113
|
+
if (c.source === "path") {
|
|
114
|
+
rows.push({
|
|
115
|
+
specId,
|
|
116
|
+
requirementId: c.requirementId,
|
|
117
|
+
scenarioId: c.scenarioId,
|
|
118
|
+
anchorKind: "file",
|
|
119
|
+
symbolName: c.text,
|
|
120
|
+
filePath: c.text,
|
|
121
|
+
resolution: fs.existsSync(path.join(projectPath, c.text)) ? "unique" : "unresolved",
|
|
122
|
+
contentHash: null,
|
|
123
|
+
rawHash: null,
|
|
124
|
+
archivedAt: now,
|
|
125
|
+
commitSha: sha,
|
|
126
|
+
source: c.source,
|
|
127
|
+
normalizerVersion: NORMALIZER_VERSION,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (c.source === "covers-link") {
|
|
132
|
+
const link = db
|
|
133
|
+
.prepare(`SELECT n.id AS id, n.norm_hash AS normHash, n.body_hash AS bodyHash,
|
|
134
|
+
f.path AS path, n.name AS name
|
|
135
|
+
FROM coverage_links c
|
|
136
|
+
LEFT JOIN nodes n ON n.id = c.node_id
|
|
137
|
+
LEFT JOIN files f ON f.id = n.file_id
|
|
138
|
+
WHERE c.artifact_type || '~' || c.name || '~' || c.revision = ?
|
|
139
|
+
LIMIT 2`)
|
|
140
|
+
.all(c.text);
|
|
141
|
+
if (link.length === 1 && link[0].id != null && link[0].name) {
|
|
142
|
+
rows.push(mkSymbol(c, specId, link[0], "unique", now, sha, link[0].name));
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
rows.push(mkUnresolved(c, specId, now, sha));
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const matches = byName.all(c.text);
|
|
150
|
+
if (matches.length === 1) {
|
|
151
|
+
rows.push(mkSymbol(c, specId, matches[0], "unique", now, sha, c.text));
|
|
152
|
+
}
|
|
153
|
+
else if (matches.length > 1) {
|
|
154
|
+
rows.push({
|
|
155
|
+
specId,
|
|
156
|
+
requirementId: c.requirementId,
|
|
157
|
+
scenarioId: c.scenarioId,
|
|
158
|
+
anchorKind: "symbol",
|
|
159
|
+
symbolName: c.text,
|
|
160
|
+
filePath: matches[0].path,
|
|
161
|
+
resolution: "ambiguous",
|
|
162
|
+
contentHash: null,
|
|
163
|
+
rawHash: null,
|
|
164
|
+
archivedAt: now,
|
|
165
|
+
commitSha: sha,
|
|
166
|
+
source: c.source,
|
|
167
|
+
normalizerVersion: NORMALIZER_VERSION,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
else if (c.source === "backtick") {
|
|
171
|
+
rows.push(mkUnresolved(c, specId, now, sha));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
function mkSymbol(c, specId, hit, resolution, now, sha, name) {
|
|
177
|
+
return {
|
|
178
|
+
specId,
|
|
179
|
+
requirementId: c.requirementId,
|
|
180
|
+
scenarioId: c.scenarioId,
|
|
181
|
+
anchorKind: "symbol",
|
|
182
|
+
symbolName: name,
|
|
183
|
+
filePath: hit.path,
|
|
184
|
+
resolution,
|
|
185
|
+
contentHash: hit.normHash,
|
|
186
|
+
rawHash: hit.bodyHash,
|
|
187
|
+
archivedAt: now,
|
|
188
|
+
commitSha: sha,
|
|
189
|
+
source: c.source,
|
|
190
|
+
normalizerVersion: NORMALIZER_VERSION,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function mkUnresolved(c, specId, now, sha) {
|
|
194
|
+
return {
|
|
195
|
+
specId,
|
|
196
|
+
requirementId: c.requirementId,
|
|
197
|
+
scenarioId: c.scenarioId,
|
|
198
|
+
anchorKind: "symbol",
|
|
199
|
+
symbolName: c.text,
|
|
200
|
+
filePath: null,
|
|
201
|
+
resolution: "unresolved",
|
|
202
|
+
contentHash: null,
|
|
203
|
+
rawHash: null,
|
|
204
|
+
archivedAt: now,
|
|
205
|
+
commitSha: sha,
|
|
206
|
+
source: c.source,
|
|
207
|
+
normalizerVersion: NORMALIZER_VERSION,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/** Read a capability anchors file, or null when absent. */
|
|
211
|
+
export function readAnchorsFile(projectPath, capability) {
|
|
212
|
+
const p = anchorsPath(projectPath, capability);
|
|
213
|
+
if (!fs.existsSync(p))
|
|
214
|
+
return null;
|
|
215
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
216
|
+
}
|
|
217
|
+
/** Write anchors JSON only (caller refreshes SQLite projection). */
|
|
218
|
+
export function writeAnchorsFile(projectPath, doc) {
|
|
219
|
+
const dir = anchorsDir(projectPath);
|
|
220
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
221
|
+
const sorted = [...doc.anchors].sort((a, b) => `${a.requirementId}\0${a.scenarioId}\0${a.symbolName}`.localeCompare(`${b.requirementId}\0${b.scenarioId}\0${b.symbolName}`));
|
|
222
|
+
const dest = anchorsPath(projectPath, doc.capability);
|
|
223
|
+
fs.writeFileSync(dest, JSON.stringify({ ...doc, anchors: sorted }, null, 2) + "\n", "utf8");
|
|
224
|
+
return dest;
|
|
225
|
+
}
|
|
226
|
+
/** List capability names that already have an anchors file. */
|
|
227
|
+
export function listAnchoredCapabilities(projectPath) {
|
|
228
|
+
const dir = anchorsDir(projectPath);
|
|
229
|
+
if (!fs.existsSync(dir))
|
|
230
|
+
return [];
|
|
231
|
+
return fs
|
|
232
|
+
.readdirSync(dir)
|
|
233
|
+
.filter((n) => n.endsWith(".json"))
|
|
234
|
+
.map((n) => n.replace(/\.json$/, ""))
|
|
235
|
+
.sort();
|
|
236
|
+
}
|
|
237
|
+
/** Seal anchors for one capability from markdown and refresh the projection. */
|
|
238
|
+
export function sealCapability(projectPath, capability, markdown, opts = {}) {
|
|
239
|
+
const db = openDb(projectPath);
|
|
240
|
+
try {
|
|
241
|
+
const now = opts.now ?? new Date().toISOString();
|
|
242
|
+
const sha = headSha(projectPath);
|
|
243
|
+
const specId = opts.specId ?? capability;
|
|
244
|
+
const rows = resolveCandidates(db, projectPath, extractCandidates(markdown), specId, now, sha);
|
|
245
|
+
const dest = writeAnchorsFile(projectPath, {
|
|
246
|
+
anchorsVersion: 1,
|
|
247
|
+
capability,
|
|
248
|
+
normalizerVersion: NORMALIZER_VERSION,
|
|
249
|
+
anchors: rows,
|
|
250
|
+
});
|
|
251
|
+
rehydrateAnchors(db, projectPath);
|
|
252
|
+
return {
|
|
253
|
+
capability,
|
|
254
|
+
unique: rows.filter((r) => r.resolution === "unique").length,
|
|
255
|
+
ambiguous: rows.filter((r) => r.resolution === "ambiguous").length,
|
|
256
|
+
unresolved: rows.filter((r) => r.resolution === "unresolved").length,
|
|
257
|
+
path: path.relative(projectPath, dest),
|
|
258
|
+
warned: rows.length === 0,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
db.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Seal every canonical capability under lawbook/specs/ that has a spec.md. */
|
|
266
|
+
export function resealAll(projectPath) {
|
|
267
|
+
const specsRoot = path.join(projectPath, "lawbook", "specs");
|
|
268
|
+
if (!fs.existsSync(specsRoot))
|
|
269
|
+
return [];
|
|
270
|
+
const out = [];
|
|
271
|
+
for (const name of fs.readdirSync(specsRoot)) {
|
|
272
|
+
const specPath = path.join(specsRoot, name, "spec.md");
|
|
273
|
+
if (!fs.existsSync(specPath))
|
|
274
|
+
continue;
|
|
275
|
+
out.push(sealCapability(projectPath, name, fs.readFileSync(specPath, "utf8")));
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
function slug(title) {
|
|
280
|
+
return title
|
|
281
|
+
.replace(/`[^`]+`/g, "")
|
|
282
|
+
.trim()
|
|
283
|
+
.toLowerCase()
|
|
284
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
285
|
+
.replace(/^-|-$/g, "")
|
|
286
|
+
.slice(0, 80);
|
|
287
|
+
}
|
|
288
|
+
function dedupe(cands) {
|
|
289
|
+
const seen = new Set();
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const c of cands) {
|
|
292
|
+
const k = `${c.requirementId}\0${c.scenarioId}\0${c.source}\0${c.text}`;
|
|
293
|
+
if (seen.has(k))
|
|
294
|
+
continue;
|
|
295
|
+
seen.add(k);
|
|
296
|
+
out.push(c);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic spec↔code drift classification and reporting.
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { openDb, indexExists, needsReindex } from "../compass/db.js";
|
|
7
|
+
import { NORMALIZER_VERSION } from "../compass/hash.js";
|
|
8
|
+
import { logForPath } from "../../shared/git-history.js";
|
|
9
|
+
import { listAnchoredCapabilities, readAnchorsFile, resealAll, sealCapability, } from "./anchors.js";
|
|
10
|
+
const FAIL_RANK = { none: 0, cosmetic: 1, semantic: 2, any: 3 };
|
|
11
|
+
function stateRank(state) {
|
|
12
|
+
switch (state) {
|
|
13
|
+
case "changed-cosmetic":
|
|
14
|
+
return 1;
|
|
15
|
+
case "changed-semantic":
|
|
16
|
+
case "deleted":
|
|
17
|
+
return 2;
|
|
18
|
+
case "orphan":
|
|
19
|
+
case "ambiguous":
|
|
20
|
+
return 3;
|
|
21
|
+
default:
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Parse `--fail-on`; defaults to semantic; null when invalid. */
|
|
26
|
+
export function parseFailOn(raw) {
|
|
27
|
+
if (raw === undefined || raw === true)
|
|
28
|
+
return "semantic";
|
|
29
|
+
if (typeof raw !== "string")
|
|
30
|
+
return null;
|
|
31
|
+
if (raw === "none" || raw === "cosmetic" || raw === "semantic" || raw === "any")
|
|
32
|
+
return raw;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/** Classify one sealed anchor against the live graph. */
|
|
36
|
+
export function classifyAnchor(db, projectPath, capability, a) {
|
|
37
|
+
if (a.normalizerVersion !== NORMALIZER_VERSION) {
|
|
38
|
+
return { capability, anchor: a, state: "stale-hash" };
|
|
39
|
+
}
|
|
40
|
+
if (a.resolution === "unresolved")
|
|
41
|
+
return { capability, anchor: a, state: "orphan" };
|
|
42
|
+
if (a.resolution === "ambiguous")
|
|
43
|
+
return { capability, anchor: a, state: "ambiguous" };
|
|
44
|
+
if (a.anchorKind === "file") {
|
|
45
|
+
const ok = a.filePath != null && fs.existsSync(path.join(projectPath, a.filePath));
|
|
46
|
+
return { capability, anchor: a, state: ok ? "unchanged" : "deleted" };
|
|
47
|
+
}
|
|
48
|
+
const byName = db
|
|
49
|
+
.prepare(`SELECT n.id AS id, n.name AS name, n.kind AS kind, f.path AS path,
|
|
50
|
+
n.norm_hash AS normHash, n.body_hash AS bodyHash
|
|
51
|
+
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
52
|
+
WHERE n.name = ?`)
|
|
53
|
+
.all(a.symbolName);
|
|
54
|
+
if (byName.length === 0) {
|
|
55
|
+
if (a.contentHash) {
|
|
56
|
+
const byHash = db
|
|
57
|
+
.prepare(`SELECT n.id AS id, n.name AS name, n.kind AS kind, f.path AS path,
|
|
58
|
+
n.norm_hash AS normHash, n.body_hash AS bodyHash
|
|
59
|
+
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
60
|
+
WHERE n.norm_hash = ?
|
|
61
|
+
LIMIT 1`)
|
|
62
|
+
.get(a.contentHash);
|
|
63
|
+
if (byHash)
|
|
64
|
+
return { capability, anchor: a, state: "moved", currentFile: byHash.path };
|
|
65
|
+
}
|
|
66
|
+
return { capability, anchor: a, state: "deleted" };
|
|
67
|
+
}
|
|
68
|
+
const n = byName.find((m) => m.path === a.filePath) ?? (byName.length === 1 ? byName[0] : null);
|
|
69
|
+
if (!n)
|
|
70
|
+
return { capability, anchor: a, state: "ambiguous" };
|
|
71
|
+
if (n.normHash === a.contentHash) {
|
|
72
|
+
if (n.path !== a.filePath) {
|
|
73
|
+
return { capability, anchor: a, state: "moved", currentFile: n.path };
|
|
74
|
+
}
|
|
75
|
+
if (n.bodyHash !== a.rawHash) {
|
|
76
|
+
return { capability, anchor: a, state: "changed-cosmetic", currentFile: n.path };
|
|
77
|
+
}
|
|
78
|
+
return { capability, anchor: a, state: "unchanged", currentFile: n.path };
|
|
79
|
+
}
|
|
80
|
+
return { capability, anchor: a, state: "changed-semantic", currentFile: n.path };
|
|
81
|
+
}
|
|
82
|
+
function attachAge(projectPath, v) {
|
|
83
|
+
if (v.state !== "changed-semantic" && v.state !== "deleted")
|
|
84
|
+
return v;
|
|
85
|
+
const file = v.currentFile ?? v.anchor.filePath;
|
|
86
|
+
if (!file)
|
|
87
|
+
return { ...v, driftDays: null };
|
|
88
|
+
const touches = logForPath(projectPath, file);
|
|
89
|
+
const last = touches[0];
|
|
90
|
+
if (!last)
|
|
91
|
+
return { ...v, commitsSince: 0, driftDays: null };
|
|
92
|
+
const archived = Date.parse(v.anchor.archivedAt);
|
|
93
|
+
const driftDays = Number.isFinite(archived)
|
|
94
|
+
? Math.max(0, Math.floor((last.ts * 1000 - archived) / 86_400_000))
|
|
95
|
+
: null;
|
|
96
|
+
return { ...v, commitsSince: touches.length, driftDays };
|
|
97
|
+
}
|
|
98
|
+
function matchGlob(relPath, pattern) {
|
|
99
|
+
const norm = relPath.replace(/\\/g, "/");
|
|
100
|
+
const esc = pattern
|
|
101
|
+
.replace(/\\/g, "/")
|
|
102
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
103
|
+
.replace(/\*\*/g, "{{DS}}")
|
|
104
|
+
.replace(/\*/g, "[^/]*")
|
|
105
|
+
.replace(/{{DS}}/g, ".*");
|
|
106
|
+
return new RegExp(`^${esc}$`).test(norm);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Load `capabilities[].paths` from lawbook/config.yaml (line-oriented subset).
|
|
110
|
+
* Returns an empty map when the file is missing or no paths are declared.
|
|
111
|
+
*/
|
|
112
|
+
export function loadCapabilityPaths(projectPath) {
|
|
113
|
+
const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
|
|
114
|
+
if (!fs.existsSync(cfgPath))
|
|
115
|
+
return {};
|
|
116
|
+
const out = {};
|
|
117
|
+
let inCaps = false;
|
|
118
|
+
let current = null;
|
|
119
|
+
let inPaths = false;
|
|
120
|
+
for (const raw of fs.readFileSync(cfgPath, "utf8").split("\n")) {
|
|
121
|
+
const line = raw.replace(/\s+#.*$/, "");
|
|
122
|
+
if (/^\s*capabilities\s*:/.test(line)) {
|
|
123
|
+
inCaps = true;
|
|
124
|
+
current = null;
|
|
125
|
+
inPaths = false;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (inCaps && /^[A-Za-z_]/.test(line)) {
|
|
129
|
+
// Next top-level key ends the capabilities block.
|
|
130
|
+
inCaps = false;
|
|
131
|
+
current = null;
|
|
132
|
+
inPaths = false;
|
|
133
|
+
}
|
|
134
|
+
if (!inCaps)
|
|
135
|
+
continue;
|
|
136
|
+
const name = /^\s*-\s*name\s*:\s*["']?([^"'#]+?)["']?\s*$/.exec(line);
|
|
137
|
+
if (name) {
|
|
138
|
+
current = name[1].trim();
|
|
139
|
+
out[current] ??= [];
|
|
140
|
+
inPaths = false;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (/^\s*paths\s*:/.test(line)) {
|
|
144
|
+
inPaths = true;
|
|
145
|
+
const inline = /^\s*paths\s*:\s*\[([^\]]*)\]\s*$/.exec(line);
|
|
146
|
+
if (inline && current) {
|
|
147
|
+
out[current] = inline[1]
|
|
148
|
+
.split(",")
|
|
149
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
150
|
+
.filter(Boolean);
|
|
151
|
+
inPaths = false;
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (inPaths && current) {
|
|
156
|
+
const item = /^\s*-\s*["']?([^"'#]+?)["']?\s*$/.exec(line);
|
|
157
|
+
if (item) {
|
|
158
|
+
out[current].push(item[1].trim());
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (/^\s*-\s*name\s*:/.test(line) || /^[A-Za-z_]/.test(line)) {
|
|
162
|
+
inPaths = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Drop capabilities that declared no globs.
|
|
167
|
+
for (const k of Object.keys(out)) {
|
|
168
|
+
if (out[k].length === 0)
|
|
169
|
+
delete out[k];
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** Reverse drift: top-level symbols under capability paths with no seal. */
|
|
174
|
+
export function reverseDrift(db, capabilityPaths) {
|
|
175
|
+
if (Object.keys(capabilityPaths).length === 0) {
|
|
176
|
+
return {
|
|
177
|
+
enabled: false,
|
|
178
|
+
reason: "No capabilities[].paths configured — reverse drift disabled.",
|
|
179
|
+
hits: [],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const anchored = new Set(db.prepare(`SELECT symbol_name AS name FROM spec_anchors`).all().map((r) => r.name));
|
|
183
|
+
const nodes = db
|
|
184
|
+
.prepare(`SELECT n.name AS name, n.kind AS kind, f.path AS path
|
|
185
|
+
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
186
|
+
WHERE n.parent_id IS NULL
|
|
187
|
+
AND n.kind IN ('function','class','method','interface')`)
|
|
188
|
+
.all();
|
|
189
|
+
const hits = [];
|
|
190
|
+
for (const [capability, globs] of Object.entries(capabilityPaths)) {
|
|
191
|
+
for (const n of nodes) {
|
|
192
|
+
if (anchored.has(n.name))
|
|
193
|
+
continue;
|
|
194
|
+
if (n.path.includes(".test.") || n.path.includes("/test/"))
|
|
195
|
+
continue;
|
|
196
|
+
if (!globs.some((g) => matchGlob(n.path, g)))
|
|
197
|
+
continue;
|
|
198
|
+
hits.push({ capability, filePath: n.path, symbolName: n.name, kind: n.kind });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { enabled: true, hits };
|
|
202
|
+
}
|
|
203
|
+
function countStates(verdicts) {
|
|
204
|
+
const c = {
|
|
205
|
+
unchanged: 0,
|
|
206
|
+
changedCosmetic: 0,
|
|
207
|
+
changedSemantic: 0,
|
|
208
|
+
moved: 0,
|
|
209
|
+
deleted: 0,
|
|
210
|
+
orphan: 0,
|
|
211
|
+
ambiguous: 0,
|
|
212
|
+
unanchored: 0,
|
|
213
|
+
staleHash: 0,
|
|
214
|
+
};
|
|
215
|
+
for (const v of verdicts) {
|
|
216
|
+
switch (v.state) {
|
|
217
|
+
case "unchanged":
|
|
218
|
+
c.unchanged++;
|
|
219
|
+
break;
|
|
220
|
+
case "changed-cosmetic":
|
|
221
|
+
c.changedCosmetic++;
|
|
222
|
+
break;
|
|
223
|
+
case "changed-semantic":
|
|
224
|
+
c.changedSemantic++;
|
|
225
|
+
break;
|
|
226
|
+
case "moved":
|
|
227
|
+
c.moved++;
|
|
228
|
+
break;
|
|
229
|
+
case "deleted":
|
|
230
|
+
c.deleted++;
|
|
231
|
+
break;
|
|
232
|
+
case "orphan":
|
|
233
|
+
c.orphan++;
|
|
234
|
+
break;
|
|
235
|
+
case "ambiguous":
|
|
236
|
+
c.ambiguous++;
|
|
237
|
+
break;
|
|
238
|
+
case "unanchored":
|
|
239
|
+
c.unanchored++;
|
|
240
|
+
break;
|
|
241
|
+
case "stale-hash":
|
|
242
|
+
c.staleHash++;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return c;
|
|
247
|
+
}
|
|
248
|
+
function emptyReport(generatedAt, failOn, needs, exitCode) {
|
|
249
|
+
return {
|
|
250
|
+
schemaVersion: 1,
|
|
251
|
+
generatedAt,
|
|
252
|
+
normalizerVersion: NORMALIZER_VERSION,
|
|
253
|
+
needsReindex: needs,
|
|
254
|
+
summary: {
|
|
255
|
+
capabilities: 0,
|
|
256
|
+
anchors: 0,
|
|
257
|
+
unchanged: 0,
|
|
258
|
+
changedCosmetic: 0,
|
|
259
|
+
changedSemantic: 0,
|
|
260
|
+
moved: 0,
|
|
261
|
+
deleted: 0,
|
|
262
|
+
orphan: 0,
|
|
263
|
+
ambiguous: 0,
|
|
264
|
+
unanchored: 0,
|
|
265
|
+
staleHash: 0,
|
|
266
|
+
maxDriftDays: null,
|
|
267
|
+
failOn,
|
|
268
|
+
exitCode,
|
|
269
|
+
},
|
|
270
|
+
verdicts: [],
|
|
271
|
+
reverse: { enabled: false, hits: [] },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/** Exit code under a fail-on threshold. */
|
|
275
|
+
export function driftExitCode(verdicts, failOn) {
|
|
276
|
+
if (failOn === "none")
|
|
277
|
+
return 0;
|
|
278
|
+
const threshold = FAIL_RANK[failOn];
|
|
279
|
+
for (const v of verdicts) {
|
|
280
|
+
const rank = stateRank(v.state);
|
|
281
|
+
if (rank === 0)
|
|
282
|
+
continue;
|
|
283
|
+
// orphan/ambiguous are rank 3 — they fail only under `--fail-on any`.
|
|
284
|
+
// cosmetic (1) and semantic/deleted (2) fail when rank is in [threshold, 2].
|
|
285
|
+
if (failOn === "any")
|
|
286
|
+
return 1;
|
|
287
|
+
if (rank >= threshold && rank <= FAIL_RANK.semantic)
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
/** Build a full drift report. */
|
|
293
|
+
export function buildDriftReport(projectPath, opts = {}) {
|
|
294
|
+
const failOn = opts.failOn ?? "semantic";
|
|
295
|
+
const generatedAt = new Date().toISOString();
|
|
296
|
+
if (!indexExists(projectPath))
|
|
297
|
+
return emptyReport(generatedAt, failOn, true, 2);
|
|
298
|
+
let resealSummaries;
|
|
299
|
+
if (opts.reseal) {
|
|
300
|
+
if (opts.capability) {
|
|
301
|
+
const specPath = path.join(projectPath, "lawbook", "specs", opts.capability, "spec.md");
|
|
302
|
+
const md = fs.existsSync(specPath) ? fs.readFileSync(specPath, "utf8") : "";
|
|
303
|
+
resealSummaries = [sealCapability(projectPath, opts.capability, md)];
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
resealSummaries = resealAll(projectPath);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const db = openDb(projectPath);
|
|
310
|
+
try {
|
|
311
|
+
if (needsReindex(db))
|
|
312
|
+
return emptyReport(generatedAt, failOn, true, 2);
|
|
313
|
+
const caps = opts.capability ? [opts.capability] : listAnchoredCapabilities(projectPath);
|
|
314
|
+
const verdicts = [];
|
|
315
|
+
for (const capability of caps) {
|
|
316
|
+
const file = readAnchorsFile(projectPath, capability);
|
|
317
|
+
if (!file || file.anchors.length === 0) {
|
|
318
|
+
verdicts.push({
|
|
319
|
+
capability,
|
|
320
|
+
anchor: {
|
|
321
|
+
specId: capability,
|
|
322
|
+
requirementId: "",
|
|
323
|
+
scenarioId: "",
|
|
324
|
+
anchorKind: "symbol",
|
|
325
|
+
symbolName: "",
|
|
326
|
+
filePath: null,
|
|
327
|
+
resolution: "unresolved",
|
|
328
|
+
contentHash: null,
|
|
329
|
+
rawHash: null,
|
|
330
|
+
archivedAt: generatedAt,
|
|
331
|
+
commitSha: null,
|
|
332
|
+
source: "backtick",
|
|
333
|
+
normalizerVersion: NORMALIZER_VERSION,
|
|
334
|
+
},
|
|
335
|
+
state: "unanchored",
|
|
336
|
+
});
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
for (const a of file.anchors) {
|
|
340
|
+
verdicts.push(attachAge(projectPath, classifyAnchor(db, projectPath, capability, a)));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const reverse = opts.reverse
|
|
344
|
+
? reverseDrift(db, opts.capabilityPaths ?? loadCapabilityPaths(projectPath))
|
|
345
|
+
: { enabled: false, reason: "Pass --reverse to enable.", hits: [] };
|
|
346
|
+
const counts = countStates(verdicts);
|
|
347
|
+
const maxDriftDays = verdicts.reduce((acc, v) => {
|
|
348
|
+
if (v.driftDays == null)
|
|
349
|
+
return acc;
|
|
350
|
+
return acc == null ? v.driftDays : Math.max(acc, v.driftDays);
|
|
351
|
+
}, null);
|
|
352
|
+
return {
|
|
353
|
+
schemaVersion: 1,
|
|
354
|
+
generatedAt,
|
|
355
|
+
normalizerVersion: NORMALIZER_VERSION,
|
|
356
|
+
needsReindex: false,
|
|
357
|
+
summary: {
|
|
358
|
+
capabilities: caps.length,
|
|
359
|
+
anchors: verdicts.filter((v) => v.state !== "unanchored").length,
|
|
360
|
+
...counts,
|
|
361
|
+
maxDriftDays,
|
|
362
|
+
failOn,
|
|
363
|
+
exitCode: driftExitCode(verdicts, failOn),
|
|
364
|
+
},
|
|
365
|
+
verdicts,
|
|
366
|
+
reverse,
|
|
367
|
+
reseal: resealSummaries,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
db.close();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** Human TTY table. */
|
|
375
|
+
export function renderDriftTable(report) {
|
|
376
|
+
const s = report.summary;
|
|
377
|
+
const lines = [
|
|
378
|
+
`speclaw drift · ${s.capabilities} capabilities · ${s.anchors} anchors`,
|
|
379
|
+
"",
|
|
380
|
+
`unchanged ${s.unchanged} cosmetic ${s.changedCosmetic} moved ${s.moved} semantic ${s.changedSemantic} deleted ${s.deleted} orphan ${s.orphan} ambiguous ${s.ambiguous}`,
|
|
381
|
+
];
|
|
382
|
+
const defects = report.verdicts.filter((v) => stateRank(v.state) >= 2);
|
|
383
|
+
if (defects.length) {
|
|
384
|
+
lines.push("");
|
|
385
|
+
for (const d of defects.slice(0, 30)) {
|
|
386
|
+
lines.push(` ${d.state.padEnd(18)} ${d.capability} → ${d.anchor.symbolName || "(unanchored)"}` +
|
|
387
|
+
(d.currentFile ? ` ${d.currentFile}` : ""));
|
|
388
|
+
}
|
|
389
|
+
if (defects.length > 30)
|
|
390
|
+
lines.push(` … ${defects.length - 30} more`);
|
|
391
|
+
}
|
|
392
|
+
if (report.reverse.enabled && report.reverse.hits.length) {
|
|
393
|
+
lines.push("", `reverse · ${report.reverse.hits.length} uncovered symbol(s)`);
|
|
394
|
+
for (const h of report.reverse.hits.slice(0, 15)) {
|
|
395
|
+
lines.push(` ${h.capability} ${h.filePath} ${h.symbolName}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
else if (report.reverse.reason) {
|
|
399
|
+
lines.push("", report.reverse.reason);
|
|
400
|
+
}
|
|
401
|
+
return lines.join("\n");
|
|
402
|
+
}
|
|
403
|
+
/** Bounded agent summary. */
|
|
404
|
+
export function renderDriftAgent(report, maxItems = 10) {
|
|
405
|
+
if (report.needsReindex) {
|
|
406
|
+
return "Drift: index needs rebuild (`speclaw index`) before comparison.";
|
|
407
|
+
}
|
|
408
|
+
const s = report.summary;
|
|
409
|
+
const defects = report.verdicts.filter((v) => stateRank(v.state) >= 2);
|
|
410
|
+
if (defects.length === 0 && s.anchors > 0) {
|
|
411
|
+
return `Drift clean — ${s.anchors} anchors across ${s.capabilities} capabilities (fail-on ${s.failOn}).`;
|
|
412
|
+
}
|
|
413
|
+
if (s.anchors === 0) {
|
|
414
|
+
return "Drift: no sealed anchors yet. Run `speclaw drift --reseal` after indexing.";
|
|
415
|
+
}
|
|
416
|
+
const lines = [
|
|
417
|
+
`Drift: ${defects.length} defect(s) · semantic ${s.changedSemantic} · deleted ${s.deleted} · orphan ${s.orphan} (fail-on ${s.failOn}).`,
|
|
418
|
+
];
|
|
419
|
+
for (const d of defects.slice(0, maxItems)) {
|
|
420
|
+
lines.push(`- [${d.state}] ${d.capability} ${d.anchor.symbolName}`);
|
|
421
|
+
}
|
|
422
|
+
if (defects.length > maxItems)
|
|
423
|
+
lines.push(`- … ${defects.length - maxItems} more`);
|
|
424
|
+
lines.push("Use `speclaw drift --json` for detail.");
|
|
425
|
+
return lines.join("\n");
|
|
426
|
+
}
|
|
427
|
+
/** Semantic/deleted findings for verify --ci. */
|
|
428
|
+
export function driftFindingsForVerify(projectPath) {
|
|
429
|
+
const report = buildDriftReport(projectPath, { failOn: "semantic" });
|
|
430
|
+
const out = [];
|
|
431
|
+
for (const v of report.verdicts) {
|
|
432
|
+
if (v.state !== "changed-semantic" && v.state !== "deleted")
|
|
433
|
+
continue;
|
|
434
|
+
out.push({
|
|
435
|
+
ruleId: `drift~${v.state}`,
|
|
436
|
+
file: v.currentFile ?? v.anchor.filePath ?? "lawbook/anchors",
|
|
437
|
+
line: 1,
|
|
438
|
+
message: `Spec drift (${v.state}): ${v.capability} → ${v.anchor.symbolName}`,
|
|
439
|
+
severity: "error",
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
/** Doctor check line. */
|
|
445
|
+
export function doctorDriftCheck(projectPath) {
|
|
446
|
+
const caps = listAnchoredCapabilities(projectPath);
|
|
447
|
+
if (caps.length === 0) {
|
|
448
|
+
return {
|
|
449
|
+
id: "cfg.drift",
|
|
450
|
+
title: "spec drift",
|
|
451
|
+
status: "skip",
|
|
452
|
+
detail: "no sealed anchors",
|
|
453
|
+
remedy: "speclaw drift --reseal",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (!indexExists(projectPath)) {
|
|
457
|
+
return {
|
|
458
|
+
id: "cfg.drift",
|
|
459
|
+
title: "spec drift",
|
|
460
|
+
status: "warn",
|
|
461
|
+
detail: "index missing",
|
|
462
|
+
remedy: "speclaw index",
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const report = buildDriftReport(projectPath, { failOn: "semantic" });
|
|
466
|
+
if (report.needsReindex) {
|
|
467
|
+
return {
|
|
468
|
+
id: "cfg.drift",
|
|
469
|
+
title: "spec drift",
|
|
470
|
+
status: "warn",
|
|
471
|
+
detail: "index needs rebuild before drift can run",
|
|
472
|
+
remedy: "speclaw index",
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const bad = report.summary.changedSemantic + report.summary.deleted;
|
|
476
|
+
if (bad > 0) {
|
|
477
|
+
return {
|
|
478
|
+
id: "cfg.drift",
|
|
479
|
+
title: "spec drift",
|
|
480
|
+
status: "warn",
|
|
481
|
+
detail: `${bad} semantic/deleted across ${report.summary.anchors} anchors`,
|
|
482
|
+
remedy: "speclaw drift",
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
id: "cfg.drift",
|
|
487
|
+
title: "spec drift",
|
|
488
|
+
status: "ok",
|
|
489
|
+
detail: `${report.summary.anchors} anchors · clean`,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { coverageArchiveBlockers } from "./coverage.js";
|
|
4
|
+
import { sealCapability } from "./anchors.js";
|
|
4
5
|
// speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
|
|
5
6
|
// (proposals, delta specs, changes, archive) but implemented from scratch and
|
|
6
7
|
// deliberately simpler: a change's specs/ holds the full intended spec for each
|
|
@@ -336,12 +337,49 @@ export function specArchive(projectPath, change, date) {
|
|
|
336
337
|
throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
|
|
337
338
|
}
|
|
338
339
|
const { promoted, created, updated } = specSync(projectPath, change);
|
|
340
|
+
const seals = sealPromotedCapabilities(projectPath, change, [
|
|
341
|
+
...promoted,
|
|
342
|
+
...created,
|
|
343
|
+
...updated,
|
|
344
|
+
]);
|
|
339
345
|
const archiveDir = path.join(root, "changes", "archive", `${date}-${change}`);
|
|
340
346
|
fs.mkdirSync(path.dirname(archiveDir), { recursive: true });
|
|
341
347
|
if (fs.existsSync(archiveDir))
|
|
342
348
|
throw new Error(`archive target already exists: ${archiveDir}`);
|
|
343
349
|
fs.renameSync(changeDir, archiveDir);
|
|
344
|
-
return {
|
|
350
|
+
return {
|
|
351
|
+
change,
|
|
352
|
+
promoted,
|
|
353
|
+
created,
|
|
354
|
+
updated,
|
|
355
|
+
archivedTo: path.relative(projectPath, archiveDir),
|
|
356
|
+
seals,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Seal structural anchors for every capability whose canonical spec was
|
|
361
|
+
* promoted during archive. Missing specs are skipped; zero anchors warn via
|
|
362
|
+
* {@link SealSummary.warned} but never block archive.
|
|
363
|
+
*/
|
|
364
|
+
function sealPromotedCapabilities(projectPath, change, promotedPaths) {
|
|
365
|
+
const caps = new Set();
|
|
366
|
+
for (const p of promotedPaths) {
|
|
367
|
+
// lawbook/specs/<capability>/spec.md → capability
|
|
368
|
+
const parts = p.replace(/\\/g, "/").split("/");
|
|
369
|
+
const specsIdx = parts.indexOf("specs");
|
|
370
|
+
if (specsIdx >= 0 && parts[specsIdx + 1])
|
|
371
|
+
caps.add(parts[specsIdx + 1]);
|
|
372
|
+
}
|
|
373
|
+
const out = [];
|
|
374
|
+
for (const capability of [...caps].sort()) {
|
|
375
|
+
const specPath = path.join(specRoot(projectPath), "specs", capability, "spec.md");
|
|
376
|
+
if (!fs.existsSync(specPath))
|
|
377
|
+
continue;
|
|
378
|
+
out.push(sealCapability(projectPath, capability, fs.readFileSync(specPath, "utf8"), {
|
|
379
|
+
specId: `${capability}#${change}`,
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
345
383
|
}
|
|
346
384
|
/**
|
|
347
385
|
* List the spec workspace: active changes, archived changes, and canonical
|
|
@@ -6,6 +6,7 @@ import { assetsDir } from "../../shared/paths.js";
|
|
|
6
6
|
import { copyRendered } from "../../shared/install.js";
|
|
7
7
|
import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
|
|
8
8
|
import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
|
|
9
|
+
import { buildDriftReport, renderDriftAgent } from "./drift.js";
|
|
9
10
|
const ASSETS = assetsDir(import.meta.url);
|
|
10
11
|
/**
|
|
11
12
|
* Install the spec module's workflow interface into a project's ai-specs/:
|
|
@@ -47,4 +48,20 @@ export function registerSpec(server, opts = {}) {
|
|
|
47
48
|
return text(JSON.stringify(report));
|
|
48
49
|
return text(renderCoverageAgent(report, onlyDefects !== false));
|
|
49
50
|
});
|
|
51
|
+
add("lawbook_drift", "Report deterministic drift between sealed spec anchors and the code graph. Call before claiming a task is done.", {
|
|
52
|
+
projectPath: z.string(),
|
|
53
|
+
capability: z.string().optional(),
|
|
54
|
+
includeReverse: z.boolean().optional(),
|
|
55
|
+
maxItems: z.number().int().min(1).max(50).optional(),
|
|
56
|
+
json: z.boolean().optional(),
|
|
57
|
+
}, async ({ projectPath, capability, includeReverse, maxItems, json }) => {
|
|
58
|
+
const report = buildDriftReport(projectPath, {
|
|
59
|
+
capability,
|
|
60
|
+
reverse: includeReverse === true,
|
|
61
|
+
failOn: "semantic",
|
|
62
|
+
});
|
|
63
|
+
if (json)
|
|
64
|
+
return text(JSON.stringify(report));
|
|
65
|
+
return text(renderDriftAgent(report, maxItems ?? 10));
|
|
66
|
+
});
|
|
50
67
|
}
|
package/dist/shared/exposure.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readManifest } from "./manifest.js";
|
|
|
5
5
|
/**
|
|
6
6
|
* Tools omitted when the exposure profile is `minimal`. Kept tools are the
|
|
7
7
|
* discovery + law loop: compass_explore/search/recall, lawbook_validate/sync,
|
|
8
|
-
* lawbook_coverage, law_verify, speclaw_check.
|
|
8
|
+
* lawbook_coverage, lawbook_drift, law_verify, speclaw_check.
|
|
9
9
|
*/
|
|
10
10
|
export const MINIMAL_OMIT = new Set([
|
|
11
11
|
"compass_index",
|