@esneiderbravo/speclaw 0.3.5 → 0.3.8
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 +44 -15
- package/dist/cli/commands/coverage.js +49 -0
- package/dist/cli/commands/doctor.js +53 -21
- package/dist/cli/commands/telemetry.js +16 -0
- package/dist/cli/commands/update.js +21 -0
- package/dist/cli/index.js +16 -2
- package/dist/cli/lib/untrack.js +1 -0
- package/dist/modules/compass/db.js +20 -1
- package/dist/modules/compass/extract.js +59 -5
- package/dist/modules/compass/indexer.js +30 -1
- package/dist/modules/foundation/context-budget.js +16 -3
- package/dist/modules/foundation/doctor.js +626 -170
- package/dist/modules/foundation/graph.js +7 -4
- package/dist/modules/foundation/hooks.js +12 -0
- package/dist/modules/foundation/laws.js +1 -0
- package/dist/modules/foundation/register-core.js +108 -0
- package/dist/modules/foundation/register.js +18 -106
- package/dist/modules/lawbook/coverage.js +479 -0
- package/dist/modules/lawbook/engine.js +3 -0
- package/dist/modules/lawbook/register.js +13 -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/dist/shared/redact.js +90 -0
- package/package.json +1 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { openDb, indexExists } from "../compass/db.js";
|
|
4
|
+
import { formatItemId, loadSpecItems, parseItemId, parseSpecItems, } from "./spec-items.js";
|
|
5
|
+
export const DEFAULT_COVERAGE_CONFIG = {
|
|
6
|
+
defaultNeeds: ["impl", "utest"],
|
|
7
|
+
gateStatuses: ["approved"],
|
|
8
|
+
gateArchive: true,
|
|
9
|
+
sources: {
|
|
10
|
+
impl: ["src/**"],
|
|
11
|
+
utest: ["test/unit/**", "test/**/*.test.ts", "test/**/*.test.js"],
|
|
12
|
+
itest: ["test/integration/**"],
|
|
13
|
+
},
|
|
14
|
+
exclude: ["**/node_modules/**", "**/dist/**", "**/.speclaw/**"],
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Load coverage config from lawbook/config.yaml when present; otherwise defaults.
|
|
18
|
+
* Parses only a small line-oriented subset (no YAML dependency).
|
|
19
|
+
*/
|
|
20
|
+
export function loadCoverageConfig(projectPath) {
|
|
21
|
+
const cfg = structuredClone(DEFAULT_COVERAGE_CONFIG);
|
|
22
|
+
const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
|
|
23
|
+
if (!fs.existsSync(cfgPath))
|
|
24
|
+
return cfg;
|
|
25
|
+
const text = fs.readFileSync(cfgPath, "utf8");
|
|
26
|
+
const gate = /^\s*gateArchive\s*:\s*(true|false)\s*$/im.exec(text);
|
|
27
|
+
if (gate)
|
|
28
|
+
cfg.gateArchive = gate[1].toLowerCase() === "true";
|
|
29
|
+
const needs = /^\s*defaultNeeds\s*:\s*\[([^\]]*)\]\s*$/im.exec(text);
|
|
30
|
+
if (needs) {
|
|
31
|
+
cfg.defaultNeeds = needs[1]
|
|
32
|
+
.split(",")
|
|
33
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
const statuses = /^\s*gateStatuses\s*:\s*\[([^\]]*)\]\s*$/im.exec(text);
|
|
37
|
+
if (statuses) {
|
|
38
|
+
cfg.gateStatuses = statuses[1]
|
|
39
|
+
.split(",")
|
|
40
|
+
.map((s) => s
|
|
41
|
+
.trim()
|
|
42
|
+
.replace(/^["']|["']$/g, "")
|
|
43
|
+
.toLowerCase())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
return cfg;
|
|
47
|
+
}
|
|
48
|
+
/** Glob match supporting `**`, `*`, and path separators. */
|
|
49
|
+
export function matchGlob(relPath, pattern) {
|
|
50
|
+
const norm = relPath.split("\\").join("/");
|
|
51
|
+
// Expand globs before escaping regex metacharacters so `*` is not double-escaped.
|
|
52
|
+
let i = 0;
|
|
53
|
+
let re = "^";
|
|
54
|
+
const p = pattern.split("\\").join("/");
|
|
55
|
+
while (i < p.length) {
|
|
56
|
+
if (p.startsWith("**/", i) || (p.startsWith("**", i) && i + 2 === p.length)) {
|
|
57
|
+
re += ".*";
|
|
58
|
+
i += p.startsWith("**/", i) ? 3 : 2;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (p[i] === "*") {
|
|
62
|
+
re += "[^/]*";
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const ch = p[i];
|
|
67
|
+
if (/[.+^${}()|[\]\\]/.test(ch))
|
|
68
|
+
re += `\\${ch}`;
|
|
69
|
+
else
|
|
70
|
+
re += ch;
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
re += "$";
|
|
74
|
+
return new RegExp(re).test(norm);
|
|
75
|
+
}
|
|
76
|
+
/** Infer artifact type from path using configured source globs. */
|
|
77
|
+
export function inferArtifactType(relPath, cfg) {
|
|
78
|
+
const norm = relPath.split("\\").join("/");
|
|
79
|
+
if (cfg.exclude.some((g) => matchGlob(norm, g)))
|
|
80
|
+
return null;
|
|
81
|
+
for (const type of ["itest", "utest", "impl"]) {
|
|
82
|
+
const globs = cfg.sources[type] ?? [];
|
|
83
|
+
if (globs.some((g) => matchGlob(norm, g)))
|
|
84
|
+
return type;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function readIndexLinks(projectPath) {
|
|
89
|
+
if (!indexExists(projectPath))
|
|
90
|
+
return [];
|
|
91
|
+
const db = openDb(projectPath);
|
|
92
|
+
try {
|
|
93
|
+
const rows = db
|
|
94
|
+
.prepare(`SELECT artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
95
|
+
FROM coverage_links`)
|
|
96
|
+
.all();
|
|
97
|
+
return rows.map((r) => ({
|
|
98
|
+
artifactType: r.artifact_type,
|
|
99
|
+
name: r.name,
|
|
100
|
+
revision: r.revision,
|
|
101
|
+
kind: r.kind,
|
|
102
|
+
filePath: r.file_path,
|
|
103
|
+
line: r.line,
|
|
104
|
+
nodeId: r.node_id,
|
|
105
|
+
sourceType: r.source_type,
|
|
106
|
+
origin: r.origin,
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
db.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function inlineLinksAsRaw(projectPath, items, cfg) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const item of items) {
|
|
116
|
+
if (!item.id)
|
|
117
|
+
continue;
|
|
118
|
+
for (const inl of item.inlineLinks) {
|
|
119
|
+
const abs = path.join(projectPath, inl.targetPath);
|
|
120
|
+
const exists = fs.existsSync(abs);
|
|
121
|
+
const inferred = inferArtifactType(inl.targetPath, cfg) ?? (inl.kind === "test" ? "utest" : "impl");
|
|
122
|
+
out.push({
|
|
123
|
+
artifactType: item.id.artifactType,
|
|
124
|
+
name: item.id.name,
|
|
125
|
+
revision: item.id.revision,
|
|
126
|
+
kind: "covers",
|
|
127
|
+
filePath: inl.targetPath,
|
|
128
|
+
line: inl.line,
|
|
129
|
+
nodeId: null,
|
|
130
|
+
sourceType: inferred,
|
|
131
|
+
origin: "inline-test-link",
|
|
132
|
+
missingFile: !exists,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function classifyLink(link, item, idCounts, cfg) {
|
|
139
|
+
const base = {
|
|
140
|
+
artifactType: link.artifactType,
|
|
141
|
+
name: link.name,
|
|
142
|
+
revision: link.revision,
|
|
143
|
+
kind: link.kind,
|
|
144
|
+
filePath: link.filePath,
|
|
145
|
+
line: link.line,
|
|
146
|
+
nodeId: link.nodeId,
|
|
147
|
+
sourceType: link.sourceType,
|
|
148
|
+
origin: link.origin,
|
|
149
|
+
status: "Covers",
|
|
150
|
+
};
|
|
151
|
+
if (link.missingFile) {
|
|
152
|
+
return { ...base, status: "Orphaned", reason: "missing-file" };
|
|
153
|
+
}
|
|
154
|
+
if (cfg.exclude.some((g) => matchGlob(link.filePath, g))) {
|
|
155
|
+
return { ...base, status: "Orphaned", reason: "excluded-path" };
|
|
156
|
+
}
|
|
157
|
+
if (!item || !item.id) {
|
|
158
|
+
return { ...base, status: "Orphaned", reason: "unknown-item" };
|
|
159
|
+
}
|
|
160
|
+
if ((idCounts.get(item.idText) ?? 0) > 1) {
|
|
161
|
+
return { ...base, status: "Ambiguous", reason: "duplicate-id" };
|
|
162
|
+
}
|
|
163
|
+
if (item.status === "rejected") {
|
|
164
|
+
return { ...base, status: "Unwanted", reason: "item-rejected" };
|
|
165
|
+
}
|
|
166
|
+
if (link.revision < item.id.revision) {
|
|
167
|
+
return { ...base, status: "Outdated", reason: "revision-behind" };
|
|
168
|
+
}
|
|
169
|
+
if (link.revision > item.id.revision) {
|
|
170
|
+
return { ...base, status: "Predated", reason: "revision-ahead" };
|
|
171
|
+
}
|
|
172
|
+
const inferred = inferArtifactType(link.filePath, cfg);
|
|
173
|
+
if (inferred)
|
|
174
|
+
base.sourceType = inferred;
|
|
175
|
+
return base;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Build a full coverage report for a project (canonical specs by default).
|
|
179
|
+
*
|
|
180
|
+
* @param projectPath - Absolute project root.
|
|
181
|
+
* @param opts.change - Limit items to a change's delta specs (archive gate).
|
|
182
|
+
* @param opts.cfg - Optional preloaded config.
|
|
183
|
+
* @param opts.now - Optional fixed timestamp for deterministic JSON.
|
|
184
|
+
*/
|
|
185
|
+
export function buildCoverageReport(projectPath, opts = {}) {
|
|
186
|
+
const cfg = opts.cfg ?? loadCoverageConfig(projectPath);
|
|
187
|
+
const items = loadSpecItems(projectPath, { change: opts.change });
|
|
188
|
+
const identified = items.filter((i) => i.id !== null);
|
|
189
|
+
const idCounts = new Map();
|
|
190
|
+
for (const it of identified) {
|
|
191
|
+
idCounts.set(it.idText, (idCounts.get(it.idText) ?? 0) + 1);
|
|
192
|
+
}
|
|
193
|
+
const rawLinks = [
|
|
194
|
+
...readIndexLinks(projectPath),
|
|
195
|
+
...inlineLinksAsRaw(projectPath, identified, cfg),
|
|
196
|
+
];
|
|
197
|
+
const results = [];
|
|
198
|
+
const matchedKeys = new Set();
|
|
199
|
+
for (const item of identified) {
|
|
200
|
+
const idText = item.idText;
|
|
201
|
+
const needs = item.needs.length > 0 ? item.needs : [...cfg.defaultNeeds];
|
|
202
|
+
const itemLinks = rawLinks
|
|
203
|
+
.filter((l) => l.artifactType === item.id.artifactType && l.name === item.id.name)
|
|
204
|
+
.map((l) => {
|
|
205
|
+
matchedKeys.add(`${l.filePath}:${l.line}:${l.revision}:${l.kind}`);
|
|
206
|
+
return classifyLink(l, item, idCounts, cfg);
|
|
207
|
+
});
|
|
208
|
+
const covering = itemLinks.filter((l) => l.status === "Covers");
|
|
209
|
+
const coveredTypes = [...new Set(covering.map((l) => l.sourceType))];
|
|
210
|
+
const uncoveredTypes = needs.filter((n) => !coveredTypes.includes(n));
|
|
211
|
+
const shallow = uncoveredTypes.length === 0 && (idCounts.get(idText) ?? 0) === 1;
|
|
212
|
+
const directDefects = [];
|
|
213
|
+
if ((idCounts.get(idText) ?? 0) > 1) {
|
|
214
|
+
directDefects.push(`duplicate id ${idText} at ${item.specPath}:${item.line}`);
|
|
215
|
+
}
|
|
216
|
+
for (const t of uncoveredTypes) {
|
|
217
|
+
directDefects.push(`missing ${t} for ${idText} at ${item.specPath}:${item.line}`);
|
|
218
|
+
}
|
|
219
|
+
for (const l of itemLinks) {
|
|
220
|
+
if (l.status === "Outdated" ||
|
|
221
|
+
l.status === "Orphaned" ||
|
|
222
|
+
l.status === "Ambiguous" ||
|
|
223
|
+
l.status === "Unwanted") {
|
|
224
|
+
directDefects.push(`${l.status} link ${l.filePath}:${l.line} → ${idText}` +
|
|
225
|
+
(l.reason ? ` (${l.reason})` : ""));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
results.push({
|
|
229
|
+
id: idText,
|
|
230
|
+
title: item.title,
|
|
231
|
+
status: item.status,
|
|
232
|
+
needs,
|
|
233
|
+
tags: item.tags,
|
|
234
|
+
depends: item.depends,
|
|
235
|
+
covers: item.covers,
|
|
236
|
+
specPath: item.specPath,
|
|
237
|
+
line: item.line,
|
|
238
|
+
coveredTypes,
|
|
239
|
+
uncoveredTypes,
|
|
240
|
+
shallow,
|
|
241
|
+
deep: shallow,
|
|
242
|
+
links: itemLinks,
|
|
243
|
+
directDefects,
|
|
244
|
+
transitiveDefects: [],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const byId = new Map(results.map((r) => [r.id, r]));
|
|
248
|
+
const visiting = new Set();
|
|
249
|
+
const visited = new Set();
|
|
250
|
+
const isDeep = (id, stack) => {
|
|
251
|
+
const r = byId.get(id);
|
|
252
|
+
if (!r)
|
|
253
|
+
return false;
|
|
254
|
+
if (!r.shallow) {
|
|
255
|
+
r.deep = false;
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
if (visited.has(id))
|
|
259
|
+
return r.deep;
|
|
260
|
+
if (visiting.has(id)) {
|
|
261
|
+
r.transitiveDefects.push(`cycle involving ${[...stack, id].join(" → ")}`);
|
|
262
|
+
r.deep = false;
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
visiting.add(id);
|
|
266
|
+
let deep = true;
|
|
267
|
+
for (const dep of [...r.depends, ...r.covers]) {
|
|
268
|
+
if (!byId.has(dep))
|
|
269
|
+
continue;
|
|
270
|
+
if (!isDeep(dep, [...stack, id]))
|
|
271
|
+
deep = false;
|
|
272
|
+
}
|
|
273
|
+
visiting.delete(id);
|
|
274
|
+
visited.add(id);
|
|
275
|
+
r.deep = deep && r.shallow;
|
|
276
|
+
return r.deep;
|
|
277
|
+
};
|
|
278
|
+
for (const r of results)
|
|
279
|
+
isDeep(r.id, []);
|
|
280
|
+
const orphans = [];
|
|
281
|
+
for (const l of rawLinks) {
|
|
282
|
+
const key = `${l.filePath}:${l.line}:${l.revision}:${l.kind}`;
|
|
283
|
+
if (matchedKeys.has(key))
|
|
284
|
+
continue;
|
|
285
|
+
orphans.push(classifyLink(l, undefined, idCounts, cfg));
|
|
286
|
+
}
|
|
287
|
+
const gated = results.filter((r) => cfg.gateStatuses.includes(r.status));
|
|
288
|
+
const directDefects = gated.reduce((n, r) => n + r.directDefects.length, 0);
|
|
289
|
+
const transitiveDefects = results.reduce((n, r) => n + r.transitiveDefects.length, 0);
|
|
290
|
+
return {
|
|
291
|
+
schemaVersion: 1,
|
|
292
|
+
generatedAt: opts.now ?? new Date().toISOString(),
|
|
293
|
+
summary: {
|
|
294
|
+
items: items.length,
|
|
295
|
+
identified: identified.length,
|
|
296
|
+
shallowCovered: results.filter((r) => r.shallow).length,
|
|
297
|
+
deepCovered: results.filter((r) => r.deep).length,
|
|
298
|
+
directDefects,
|
|
299
|
+
transitiveDefects,
|
|
300
|
+
},
|
|
301
|
+
items: results,
|
|
302
|
+
orphans,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** Exit code for a report (0 clean / no ids, 1 gated direct defects). */
|
|
306
|
+
export function coverageExitCode(report, cfg) {
|
|
307
|
+
if (report.summary.identified === 0)
|
|
308
|
+
return 0;
|
|
309
|
+
const gated = report.items.filter((i) => cfg.gateStatuses.includes(i.status));
|
|
310
|
+
const defects = gated.reduce((n, i) => n + i.directDefects.length, 0);
|
|
311
|
+
return defects > 0 ? 1 : 0;
|
|
312
|
+
}
|
|
313
|
+
/** TAP-compatible summary (non-TTY / --tap). */
|
|
314
|
+
export function renderCoverageTap(report) {
|
|
315
|
+
if (report.summary.identified === 0) {
|
|
316
|
+
return [
|
|
317
|
+
"1..0",
|
|
318
|
+
"# no identified requirements — run: speclaw coverage --adopt",
|
|
319
|
+
"ok - 0 total",
|
|
320
|
+
].join("\n");
|
|
321
|
+
}
|
|
322
|
+
const lines = [`1..${report.items.length}`];
|
|
323
|
+
let n = 0;
|
|
324
|
+
for (const item of report.items) {
|
|
325
|
+
n++;
|
|
326
|
+
const defects = [...item.directDefects, ...item.transitiveDefects];
|
|
327
|
+
if (defects.length === 0 && item.shallow) {
|
|
328
|
+
lines.push(`ok ${n} - ${item.id} (${item.coveredTypes.join(", ") || "covered"})`);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
lines.push(`not ok ${n} - ${item.id}`);
|
|
332
|
+
for (const d of defects)
|
|
333
|
+
lines.push(` # ${d}`);
|
|
334
|
+
for (const t of item.uncoveredTypes)
|
|
335
|
+
lines.push(` # uncovered: ${t}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const { directDefects, transitiveDefects } = report.summary;
|
|
339
|
+
if (directDefects === 0 && transitiveDefects === 0) {
|
|
340
|
+
lines.push(`ok - ${report.items.length} total`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
lines.push(`not ok - ${report.items.length} total, ${directDefects} direct, ${transitiveDefects} transitive defects`);
|
|
344
|
+
}
|
|
345
|
+
return lines.join("\n");
|
|
346
|
+
}
|
|
347
|
+
/** Human table for TTY. */
|
|
348
|
+
export function renderCoverageTable(report) {
|
|
349
|
+
if (report.summary.identified === 0) {
|
|
350
|
+
return "No identified requirements. Run `speclaw coverage --adopt` to propose ids.";
|
|
351
|
+
}
|
|
352
|
+
const rows = report.items.map((i) => {
|
|
353
|
+
const mark = i.shallow ? (i.deep ? "ok" : "shallow") : "MISS";
|
|
354
|
+
return `${mark.padEnd(8)} ${i.id.padEnd(36)} ${(i.coveredTypes.join(",") || "-").padEnd(16)} ${i.specPath}:${i.line}`;
|
|
355
|
+
});
|
|
356
|
+
const s = report.summary;
|
|
357
|
+
rows.push("");
|
|
358
|
+
rows.push(`identified ${s.identified} · shallow ${s.shallowCovered} · deep ${s.deepCovered} · direct defects ${s.directDefects} · transitive ${s.transitiveDefects}`);
|
|
359
|
+
return rows.join("\n");
|
|
360
|
+
}
|
|
361
|
+
/** Defect-first agent text, capped (~600 tokens ≈ 2400 chars). */
|
|
362
|
+
export function renderCoverageAgent(report, onlyDefects = true) {
|
|
363
|
+
if (report.summary.identified === 0) {
|
|
364
|
+
return "No identified requirements. Next: run `speclaw coverage --adopt` then add `// Covers: req~…~1` above impl/tests.";
|
|
365
|
+
}
|
|
366
|
+
const items = onlyDefects
|
|
367
|
+
? report.items.filter((i) => i.directDefects.length > 0 || !i.shallow)
|
|
368
|
+
: report.items;
|
|
369
|
+
if (items.length === 0) {
|
|
370
|
+
return `Coverage clean: ${report.summary.shallowCovered}/${report.summary.identified} shallow, ${report.summary.deepCovered} deep. Next: archive when tasks and reports are done.`;
|
|
371
|
+
}
|
|
372
|
+
const lines = [
|
|
373
|
+
`Coverage defects: ${report.summary.directDefects} direct, ${report.summary.transitiveDefects} transitive.`,
|
|
374
|
+
];
|
|
375
|
+
for (const i of items.slice(0, 12)) {
|
|
376
|
+
lines.push(`- ${i.id} @ ${i.specPath}:${i.line}`);
|
|
377
|
+
for (const d of i.directDefects.slice(0, 3))
|
|
378
|
+
lines.push(` ${d}`);
|
|
379
|
+
if (i.uncoveredTypes.length) {
|
|
380
|
+
lines.push(` add Covers for: ${i.uncoveredTypes.join(", ")}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (items.length > 12)
|
|
384
|
+
lines.push(`…and ${items.length - 12} more`);
|
|
385
|
+
lines.push("Next: add `// Covers: <id>` above the impl/test, reindex, re-run coverage.");
|
|
386
|
+
let text = lines.join("\n");
|
|
387
|
+
if (text.length > 2400)
|
|
388
|
+
text = text.slice(0, 2397) + "...";
|
|
389
|
+
return text;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Archive-gate reasons for direct defects on gated statuses.
|
|
393
|
+
* Opt-in: contributes nothing when the change's delta specs have zero ids.
|
|
394
|
+
*/
|
|
395
|
+
export function coverageArchiveBlockers(projectPath, change) {
|
|
396
|
+
const cfg = loadCoverageConfig(projectPath);
|
|
397
|
+
if (!cfg.gateArchive)
|
|
398
|
+
return [];
|
|
399
|
+
const report = buildCoverageReport(projectPath, { change, cfg });
|
|
400
|
+
if (report.summary.identified === 0)
|
|
401
|
+
return [];
|
|
402
|
+
const blockers = [];
|
|
403
|
+
for (const item of report.items) {
|
|
404
|
+
if (!cfg.gateStatuses.includes(item.status))
|
|
405
|
+
continue;
|
|
406
|
+
for (const d of item.directDefects)
|
|
407
|
+
blockers.push(`coverage: ${d}`);
|
|
408
|
+
}
|
|
409
|
+
return blockers;
|
|
410
|
+
}
|
|
411
|
+
/** Propose `req~slug~1` ids for requirements that lack them. */
|
|
412
|
+
export function proposeAdopt(projectPath) {
|
|
413
|
+
const items = loadSpecItems(projectPath);
|
|
414
|
+
const used = new Set(items.filter((i) => i.idText).map((i) => i.idText));
|
|
415
|
+
const proposals = [];
|
|
416
|
+
for (const item of items) {
|
|
417
|
+
if (item.id)
|
|
418
|
+
continue;
|
|
419
|
+
const base = slugify(item.title) || "item";
|
|
420
|
+
let name = base;
|
|
421
|
+
let n = 2;
|
|
422
|
+
let id = `req~${name}~1`;
|
|
423
|
+
let collision = false;
|
|
424
|
+
while (used.has(id)) {
|
|
425
|
+
collision = true;
|
|
426
|
+
name = `${base}-${n++}`;
|
|
427
|
+
id = `req~${name}~1`;
|
|
428
|
+
}
|
|
429
|
+
used.add(id);
|
|
430
|
+
proposals.push({
|
|
431
|
+
specPath: item.specPath,
|
|
432
|
+
line: item.line,
|
|
433
|
+
title: item.title,
|
|
434
|
+
proposedId: id,
|
|
435
|
+
collision,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return proposals;
|
|
439
|
+
}
|
|
440
|
+
function slugify(title) {
|
|
441
|
+
return title
|
|
442
|
+
.toLowerCase()
|
|
443
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
444
|
+
.replace(/^-+|-+$/g, "")
|
|
445
|
+
.slice(0, 48);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Apply adopt proposals. Writes only when `write` is true; backs up to
|
|
449
|
+
* `<file>.bak` before mutating.
|
|
450
|
+
*/
|
|
451
|
+
export function applyAdopt(projectPath, proposals, opts = {}) {
|
|
452
|
+
if (!opts.write)
|
|
453
|
+
return { written: [], dryRun: true };
|
|
454
|
+
const byFile = new Map();
|
|
455
|
+
for (const p of proposals) {
|
|
456
|
+
const list = byFile.get(p.specPath) ?? [];
|
|
457
|
+
list.push(p);
|
|
458
|
+
byFile.set(p.specPath, list);
|
|
459
|
+
}
|
|
460
|
+
const written = [];
|
|
461
|
+
for (const [rel, props] of byFile) {
|
|
462
|
+
const abs = path.join(projectPath, rel);
|
|
463
|
+
const original = fs.readFileSync(abs, "utf8");
|
|
464
|
+
const lines = original.split(/\r?\n/);
|
|
465
|
+
const ordered = [...props].sort((a, b) => b.line - a.line);
|
|
466
|
+
for (const p of ordered) {
|
|
467
|
+
const idx = p.line - 1;
|
|
468
|
+
const line = lines[idx];
|
|
469
|
+
if (!line || line.includes("`req~"))
|
|
470
|
+
continue;
|
|
471
|
+
lines[idx] = line.replace(/^(###\s+Requirement:\s*)(.+)$/, `$1${p.title} \`${p.proposedId}\``);
|
|
472
|
+
}
|
|
473
|
+
fs.copyFileSync(abs, abs + ".bak");
|
|
474
|
+
fs.writeFileSync(abs, lines.join("\n"));
|
|
475
|
+
written.push(rel);
|
|
476
|
+
}
|
|
477
|
+
return { written, dryRun: false };
|
|
478
|
+
}
|
|
479
|
+
export { parseSpecItems, parseItemId, formatItemId };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { coverageArchiveBlockers } from "./coverage.js";
|
|
3
4
|
// speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
|
|
4
5
|
// (proposals, delta specs, changes, archive) but implemented from scratch and
|
|
5
6
|
// deliberately simpler: a change's specs/ holds the full intended spec for each
|
|
@@ -310,6 +311,8 @@ export function specArchivePreconditions(projectPath, change) {
|
|
|
310
311
|
blockers.push(`spec not synced: lawbook/specs/${rel} differs from the delta (run sync first)`);
|
|
311
312
|
}
|
|
312
313
|
}
|
|
314
|
+
// 4. Opt-in coverage gate: only when the change's delta specs declare ids.
|
|
315
|
+
blockers.push(...coverageArchiveBlockers(projectPath, change));
|
|
313
316
|
return blockers;
|
|
314
317
|
}
|
|
315
318
|
/**
|
|
@@ -5,6 +5,7 @@ import { shouldExpose } from "../../shared/exposure.js";
|
|
|
5
5
|
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
|
+
import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
|
|
8
9
|
const ASSETS = assetsDir(import.meta.url);
|
|
9
10
|
/**
|
|
10
11
|
* Install the spec module's workflow interface into a project's ai-specs/:
|
|
@@ -34,4 +35,16 @@ export function registerSpec(server, opts = {}) {
|
|
|
34
35
|
change: z.string(),
|
|
35
36
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
36
37
|
}, async ({ projectPath, change, date }) => text(specArchive(projectPath, change, date)));
|
|
38
|
+
add("lawbook_coverage", "Report which requirements lack impl/test coverage before declaring work done.", {
|
|
39
|
+
projectPath: z.string(),
|
|
40
|
+
change: z.string().optional(),
|
|
41
|
+
onlyDefects: z.boolean().optional(),
|
|
42
|
+
json: z.boolean().optional(),
|
|
43
|
+
}, async ({ projectPath, change, onlyDefects, json }) => {
|
|
44
|
+
const cfg = loadCoverageConfig(projectPath);
|
|
45
|
+
const report = buildCoverageReport(projectPath, { change, cfg });
|
|
46
|
+
if (json)
|
|
47
|
+
return text(JSON.stringify(report));
|
|
48
|
+
return text(renderCoverageAgent(report, onlyDefects !== false));
|
|
49
|
+
});
|
|
37
50
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const RE_REQUIREMENT = /^###\s+Requirement:\s*(.+?)\s*$/;
|
|
4
|
+
const RE_ID = /`([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)`/;
|
|
5
|
+
const RE_KEYWORD = /^(Status|Needs|Tags|Depends|Covers)\s*:\s*(.+?)\s*$/i;
|
|
6
|
+
const RE_INLINE = /\[@(test|impl)\s+([^\]]+)\]/gi;
|
|
7
|
+
const RE_ID_LOOSE = /\b([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)\b/g;
|
|
8
|
+
/** Format a SpecItemId as `type~name~rev`. */
|
|
9
|
+
export function formatItemId(id) {
|
|
10
|
+
return `${id.artifactType}~${id.name}~${id.revision}`;
|
|
11
|
+
}
|
|
12
|
+
/** Parse a single `type~name~rev` token, or null if malformed. */
|
|
13
|
+
export function parseItemId(text) {
|
|
14
|
+
const m = /^([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)$/.exec(text.trim());
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
return { artifactType: m[1], name: m[2], revision: Number(m[3]) };
|
|
18
|
+
}
|
|
19
|
+
function splitList(value) {
|
|
20
|
+
return value
|
|
21
|
+
.split(/[, ]+/)
|
|
22
|
+
.map((s) => s.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
function parseIdList(value) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const m of value.matchAll(RE_ID_LOOSE)) {
|
|
28
|
+
out.push(`${m[1]}~${m[2]}~${m[3]}`);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse one markdown spec file into requirement items. Headings without an
|
|
34
|
+
* inline-code identifier are returned with `id: null` (ignored by coverage).
|
|
35
|
+
*
|
|
36
|
+
* @param specPath - Project-relative path of the spec (for reporting).
|
|
37
|
+
* @param content - Full markdown source.
|
|
38
|
+
*/
|
|
39
|
+
export function parseSpecItems(specPath, content) {
|
|
40
|
+
const lines = content.split(/\r?\n/);
|
|
41
|
+
const items = [];
|
|
42
|
+
let current = null;
|
|
43
|
+
const flush = () => {
|
|
44
|
+
if (current)
|
|
45
|
+
items.push(current);
|
|
46
|
+
current = null;
|
|
47
|
+
};
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const line = lines[i];
|
|
50
|
+
const req = RE_REQUIREMENT.exec(line);
|
|
51
|
+
if (req) {
|
|
52
|
+
flush();
|
|
53
|
+
const rest = req[1];
|
|
54
|
+
const idMatch = RE_ID.exec(rest);
|
|
55
|
+
let title = rest;
|
|
56
|
+
let id = null;
|
|
57
|
+
let idText = null;
|
|
58
|
+
if (idMatch) {
|
|
59
|
+
id = {
|
|
60
|
+
artifactType: idMatch[1],
|
|
61
|
+
name: idMatch[2],
|
|
62
|
+
revision: Number(idMatch[3]),
|
|
63
|
+
};
|
|
64
|
+
idText = formatItemId(id);
|
|
65
|
+
title = rest.replace(idMatch[0], "").replace(/\s+/g, " ").trim();
|
|
66
|
+
// Common forms: "Title `id`" or "`id` Title"
|
|
67
|
+
title = title.replace(/^[\s—–-]+|[\s—–-]+$/g, "").trim();
|
|
68
|
+
}
|
|
69
|
+
current = {
|
|
70
|
+
id,
|
|
71
|
+
idText,
|
|
72
|
+
title,
|
|
73
|
+
status: "approved",
|
|
74
|
+
needs: [],
|
|
75
|
+
tags: [],
|
|
76
|
+
depends: [],
|
|
77
|
+
covers: [],
|
|
78
|
+
inlineLinks: [],
|
|
79
|
+
specPath,
|
|
80
|
+
line: i + 1,
|
|
81
|
+
};
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!current)
|
|
85
|
+
continue;
|
|
86
|
+
// Next ### Requirement: or # heading ends the item body for keyword purposes,
|
|
87
|
+
// but #### Scenario lines may still carry inline links.
|
|
88
|
+
if (/^###?\s+/.test(line) && !/^####\s+/.test(line)) {
|
|
89
|
+
flush();
|
|
90
|
+
// Re-process this line as a potential new requirement on next iteration
|
|
91
|
+
// by rewinding — simpler: only #### and body lines belong to current.
|
|
92
|
+
i--;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const kw = RE_KEYWORD.exec(line);
|
|
96
|
+
if (kw) {
|
|
97
|
+
const key = kw[1].toLowerCase();
|
|
98
|
+
const value = kw[2];
|
|
99
|
+
if (key === "status")
|
|
100
|
+
current.status = value.trim().toLowerCase();
|
|
101
|
+
else if (key === "needs")
|
|
102
|
+
current.needs = splitList(value).map((s) => s.toLowerCase());
|
|
103
|
+
else if (key === "tags")
|
|
104
|
+
current.tags = splitList(value);
|
|
105
|
+
else if (key === "depends")
|
|
106
|
+
current.depends = parseIdList(value);
|
|
107
|
+
else if (key === "covers")
|
|
108
|
+
current.covers = parseIdList(value);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const m of line.matchAll(RE_INLINE)) {
|
|
112
|
+
current.inlineLinks.push({
|
|
113
|
+
kind: m[1].toLowerCase(),
|
|
114
|
+
targetPath: m[2].trim(),
|
|
115
|
+
line: i + 1,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
flush();
|
|
120
|
+
return items;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Walk `lawbook/specs/**\/spec.md` (and optionally a change's delta specs) and
|
|
124
|
+
* parse every requirement item.
|
|
125
|
+
*
|
|
126
|
+
* @param projectPath - Absolute project root.
|
|
127
|
+
* @param opts.change - When set, parse only that change's delta specs.
|
|
128
|
+
*/
|
|
129
|
+
export function loadSpecItems(projectPath, opts = {}) {
|
|
130
|
+
const roots = [];
|
|
131
|
+
if (opts.change) {
|
|
132
|
+
roots.push(path.join(projectPath, "lawbook", "changes", opts.change, "specs"));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
roots.push(path.join(projectPath, "lawbook", "specs"));
|
|
136
|
+
}
|
|
137
|
+
const items = [];
|
|
138
|
+
for (const root of roots) {
|
|
139
|
+
if (!fs.existsSync(root))
|
|
140
|
+
continue;
|
|
141
|
+
for (const file of walkSpecFiles(root)) {
|
|
142
|
+
const rel = path.relative(projectPath, file).split(path.sep).join("/");
|
|
143
|
+
const content = fs.readFileSync(file, "utf8");
|
|
144
|
+
items.push(...parseSpecItems(rel, content));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return items;
|
|
148
|
+
}
|
|
149
|
+
function* walkSpecFiles(dir) {
|
|
150
|
+
const stack = [dir];
|
|
151
|
+
while (stack.length) {
|
|
152
|
+
const cur = stack.pop();
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
for (const e of entries) {
|
|
161
|
+
const full = path.join(cur, e.name);
|
|
162
|
+
if (e.isDirectory())
|
|
163
|
+
stack.push(full);
|
|
164
|
+
else if (e.isFile() && e.name === "spec.md")
|
|
165
|
+
yield full;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
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
|
-
* law_verify, speclaw_check.
|
|
8
|
+
* lawbook_coverage, law_verify, speclaw_check.
|
|
9
9
|
*/
|
|
10
10
|
export const MINIMAL_OMIT = new Set([
|
|
11
11
|
"compass_index",
|