@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.
@@ -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 };