@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
|
@@ -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
|
+
}
|