@esneiderbravo/speclaw 0.3.7 → 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 +2 -0
- package/dist/cli/commands/coverage.js +49 -0
- package/dist/cli/commands/update.js +8 -0
- package/dist/cli/index.js +9 -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 +29 -1
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,6 +129,8 @@ tokenizer on this corpus — not a BPE dependency):
|
|
|
129
129
|
```bash
|
|
130
130
|
speclaw budget # human table
|
|
131
131
|
speclaw budget --json # machine-readable; used by the suite gate
|
|
132
|
+
speclaw coverage # requirement → impl → test coverage (TAP / table)
|
|
133
|
+
speclaw coverage --json # machine-readable coverage report
|
|
132
134
|
speclaw init --minimal # omit setup/lifecycle MCP tools from registration
|
|
133
135
|
```
|
|
134
136
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ui } from "../lib/ui.js";
|
|
2
|
+
import { applyAdopt, buildCoverageReport, coverageExitCode, loadCoverageConfig, proposeAdopt, renderCoverageAgent, renderCoverageTable, renderCoverageTap, } from "../../modules/lawbook/coverage.js";
|
|
3
|
+
/**
|
|
4
|
+
* Report requirement → impl → test coverage, or propose/apply id adoption.
|
|
5
|
+
*
|
|
6
|
+
* Flags: `--json`, `--tap`, `--adopt`, `--write`, `--change <name>`.
|
|
7
|
+
* Exit codes: 0 clean / no ids, 1 gated defects, 2 invocation error.
|
|
8
|
+
*/
|
|
9
|
+
export async function runCoverage(flags) {
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
if (flags.adopt) {
|
|
12
|
+
const proposals = proposeAdopt(cwd);
|
|
13
|
+
if (proposals.length === 0) {
|
|
14
|
+
ui.ok("Every requirement already has an identifier.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (flags.write) {
|
|
18
|
+
const result = applyAdopt(cwd, proposals, { write: true });
|
|
19
|
+
ui.ok(`Wrote identifiers into ${result.written.length} file(s) (.bak backups kept).`);
|
|
20
|
+
for (const p of proposals) {
|
|
21
|
+
ui.plain(` ${p.specPath}:${p.line} ${p.title} → ${p.proposedId}${p.collision ? " (disambiguated)" : ""}`);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
ui.heading("coverage --adopt (dry run)");
|
|
26
|
+
for (const p of proposals) {
|
|
27
|
+
ui.plain(` ${p.specPath}:${p.line} ${p.title} → ${p.proposedId}${p.collision ? " (disambiguated)" : ""}`);
|
|
28
|
+
}
|
|
29
|
+
ui.plain();
|
|
30
|
+
ui.info(`Re-run with ${ui.code("--adopt --write")} to apply (backs up to .bak).`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const change = typeof flags.change === "string" ? flags.change : undefined;
|
|
34
|
+
const cfg = loadCoverageConfig(cwd);
|
|
35
|
+
const report = buildCoverageReport(cwd, { change, cfg });
|
|
36
|
+
if (flags.json) {
|
|
37
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
38
|
+
}
|
|
39
|
+
else if (flags.tap || !process.stdout.isTTY) {
|
|
40
|
+
process.stdout.write(renderCoverageTap(report) + "\n");
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
ui.heading("speclaw coverage");
|
|
44
|
+
console.log(renderCoverageTable(report));
|
|
45
|
+
ui.plain();
|
|
46
|
+
console.log(renderCoverageAgent(report, true));
|
|
47
|
+
}
|
|
48
|
+
process.exitCode = coverageExitCode(report, cfg);
|
|
49
|
+
}
|
|
@@ -92,6 +92,14 @@ const MIGRATIONS = [
|
|
|
92
92
|
// scaffold → installHooks already rewrites .claude/settings.json when the
|
|
93
93
|
// compiled hook shape changes; no extra run() step.
|
|
94
94
|
},
|
|
95
|
+
{
|
|
96
|
+
version: "0.3.8",
|
|
97
|
+
describe: "Requirement coverage: speclaw coverage + lawbook_coverage + schema 5",
|
|
98
|
+
agentPrompt: "- Mention `speclaw coverage` / `lawbook_coverage` for requirement → impl → test coverage " +
|
|
99
|
+
"(ids like `req~name~1`, `// Covers:` comments). Compass schema is now 5 — reindex with " +
|
|
100
|
+
"`speclaw index`. Optionally add coverage.gateArchive / defaultNeeds under lawbook/config.yaml.\n" +
|
|
101
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
102
|
+
},
|
|
95
103
|
];
|
|
96
104
|
/**
|
|
97
105
|
* Update speclaw and bring the current project up to date without a full re-init:
|
package/dist/cli/index.js
CHANGED
|
@@ -37,6 +37,7 @@ Lawbook (spec-driven workflow)
|
|
|
37
37
|
Other
|
|
38
38
|
doctor Verify the installation (--json, --offline, --strict)
|
|
39
39
|
budget Measure always-on context cost (tools, skills, instructions)
|
|
40
|
+
coverage Requirement → impl → test coverage (--json, --tap, --adopt, --write)
|
|
40
41
|
telemetry status Confirm speclaw ships no telemetry
|
|
41
42
|
check Evaluate an action against the laws (hooks call this; --dry-run to preview)
|
|
42
43
|
laws verify Verify the deterministic dependency/graph laws against the index
|
|
@@ -60,6 +61,7 @@ const HEADER_COMMANDS = new Set([
|
|
|
60
61
|
"agent",
|
|
61
62
|
"doctor",
|
|
62
63
|
"budget",
|
|
64
|
+
"coverage",
|
|
63
65
|
"telemetry",
|
|
64
66
|
"index",
|
|
65
67
|
"watch",
|
|
@@ -70,8 +72,9 @@ const HEADER_COMMANDS = new Set([
|
|
|
70
72
|
* header-eligible command AND stdout is an interactive terminal (so pipes,
|
|
71
73
|
* redirection, and CI stay clean — mirroring the color gate in `ui.ts`). A
|
|
72
74
|
* forced-color signal counts as interactive so the header is exercisable in a
|
|
73
|
-
* child process. `budget --json
|
|
74
|
-
* suppress the
|
|
75
|
+
* child process. `budget --json`, `doctor --json`, and `coverage` when emitting
|
|
76
|
+
* TAP/JSON (or when stdout is not a TTY) are machine-consumed and suppress the
|
|
77
|
+
* header.
|
|
75
78
|
*/
|
|
76
79
|
function maybeHeader(cmd, flags) {
|
|
77
80
|
if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
|
|
@@ -82,6 +85,8 @@ function maybeHeader(cmd, flags) {
|
|
|
82
85
|
return;
|
|
83
86
|
if (cmd === "doctor" && flags.json)
|
|
84
87
|
return;
|
|
88
|
+
if (cmd === "coverage" && (flags.json || flags.tap))
|
|
89
|
+
return;
|
|
85
90
|
header();
|
|
86
91
|
}
|
|
87
92
|
/** Run the handler for a single command. Returns when the command completes. */
|
|
@@ -126,6 +131,8 @@ async function dispatch(cmd, flags) {
|
|
|
126
131
|
return (await import("./commands/doctor.js")).runDoctor(flags);
|
|
127
132
|
case "budget":
|
|
128
133
|
return (await import("./commands/budget.js")).runBudget(flags);
|
|
134
|
+
case "coverage":
|
|
135
|
+
return (await import("./commands/coverage.js")).runCoverage(flags);
|
|
129
136
|
case "telemetry":
|
|
130
137
|
return (await import("./commands/telemetry.js")).runTelemetry(flags);
|
|
131
138
|
case "check":
|
package/dist/cli/lib/untrack.js
CHANGED
|
@@ -12,6 +12,7 @@ import { listTrackedPaths } from "../../shared/git.js";
|
|
|
12
12
|
*
|
|
13
13
|
* @param projectPath - Project root to inspect and address.
|
|
14
14
|
*/
|
|
15
|
+
// Covers: req~agent-ide-committable~1, req~ai-specs-untrack-hint~1
|
|
15
16
|
export function reportTrackedLocalContent(projectPath) {
|
|
16
17
|
const tracked = listTrackedPaths(projectPath, ["ai-specs"]);
|
|
17
18
|
if (!tracked.length)
|
|
@@ -55,9 +55,27 @@ CREATE TABLE IF NOT EXISTS git_history_cache (
|
|
|
55
55
|
payload TEXT NOT NULL,
|
|
56
56
|
computed_at INTEGER NOT NULL
|
|
57
57
|
);
|
|
58
|
+
-- coverage_links: derived requirement-coverage directives from comment nodes.
|
|
59
|
+
-- Spec items themselves are NOT persisted — always reparsed from disk.
|
|
60
|
+
CREATE TABLE IF NOT EXISTS coverage_links (
|
|
61
|
+
id INTEGER PRIMARY KEY,
|
|
62
|
+
artifact_type TEXT NOT NULL,
|
|
63
|
+
name TEXT NOT NULL,
|
|
64
|
+
revision INTEGER NOT NULL,
|
|
65
|
+
kind TEXT NOT NULL,
|
|
66
|
+
file_path TEXT NOT NULL,
|
|
67
|
+
line INTEGER NOT NULL,
|
|
68
|
+
node_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE,
|
|
69
|
+
source_type TEXT NOT NULL,
|
|
70
|
+
origin TEXT NOT NULL,
|
|
71
|
+
UNIQUE (artifact_type, name, revision, kind, file_path, line)
|
|
72
|
+
);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_cov_target ON coverage_links(artifact_type, name, revision);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_cov_file ON coverage_links(file_path);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_cov_node ON coverage_links(node_id);
|
|
58
76
|
`;
|
|
59
77
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
60
|
-
export const SCHEMA_VERSION = "
|
|
78
|
+
export const SCHEMA_VERSION = "5";
|
|
61
79
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
62
80
|
function readSchemaVersion(db) {
|
|
63
81
|
try {
|
|
@@ -89,6 +107,7 @@ function isStale(db) {
|
|
|
89
107
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
90
108
|
function resetSchema(db) {
|
|
91
109
|
db.exec(`
|
|
110
|
+
DROP TABLE IF EXISTS coverage_links;
|
|
92
111
|
DROP TABLE IF EXISTS git_history_cache;
|
|
93
112
|
DROP TABLE IF EXISTS node_embeddings;
|
|
94
113
|
DROP TABLE IF EXISTS edges;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { parse } from "./parser.js";
|
|
2
|
+
const COMMENT_TYPES = new Set(["comment", "line_comment", "block_comment"]);
|
|
3
|
+
/** `Covers:` / `Needs:` / `@covers` at the start of a comment line. */
|
|
4
|
+
const RE_DIRECTIVE = /(?:^|\s|\*)\s*(?:@)?(covers|needs)\s*:?\s+([^\n*]+)/i;
|
|
5
|
+
/** One OFT-shaped id: type~name~revision. */
|
|
6
|
+
const RE_ID = /\b([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)\b/g;
|
|
2
7
|
const DEF_LOOKUP = new WeakMap();
|
|
3
8
|
function defKindMap(lang) {
|
|
4
9
|
let m = DEF_LOOKUP.get(lang);
|
|
@@ -31,14 +36,59 @@ function calleeName(node, lang) {
|
|
|
31
36
|
function signatureOf(node) {
|
|
32
37
|
return node.text.split("\n")[0].trim().slice(0, 200);
|
|
33
38
|
}
|
|
39
|
+
/** Parse Covers:/Needs: directives from a comment node's text. */
|
|
40
|
+
function parseCoverageComment(node, ownerIndex) {
|
|
41
|
+
const text = node.text;
|
|
42
|
+
const dir = RE_DIRECTIVE.exec(text);
|
|
43
|
+
if (!dir)
|
|
44
|
+
return [];
|
|
45
|
+
const kind = dir[1].toLowerCase();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const m of dir[2].matchAll(RE_ID)) {
|
|
48
|
+
out.push({
|
|
49
|
+
kind,
|
|
50
|
+
artifactType: m[1],
|
|
51
|
+
name: m[2],
|
|
52
|
+
revision: Number(m[3]),
|
|
53
|
+
line: node.startPosition.row + 1,
|
|
54
|
+
startByte: node.startIndex,
|
|
55
|
+
endByte: node.endIndex,
|
|
56
|
+
endLine: node.endPosition.row + 1,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// silence unused until attribution; ownerIndex filled by attachCoverage
|
|
60
|
+
void ownerIndex;
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Attribute a coverage comment to a symbol: next def within 2 lines, else
|
|
65
|
+
* innermost containing symbol, else file-level (null).
|
|
66
|
+
*/
|
|
67
|
+
function attachCoverage(raw, symbols) {
|
|
68
|
+
return raw.map((c) => {
|
|
69
|
+
const next = symbols.find((s) => s.startByte >= c.endByte);
|
|
70
|
+
if (next && next.startLine - c.endLine <= 2) {
|
|
71
|
+
return { ...c, ownerIndex: symbols.indexOf(next) };
|
|
72
|
+
}
|
|
73
|
+
const containing = symbols
|
|
74
|
+
.map((s, i) => ({ s, i }))
|
|
75
|
+
.filter(({ s }) => s.startByte <= c.startByte && c.endByte <= s.endByte)
|
|
76
|
+
.sort((a, b) => a.s.endByte - a.s.startByte - (b.s.endByte - b.s.startByte));
|
|
77
|
+
if (containing.length > 0) {
|
|
78
|
+
return { ...c, ownerIndex: containing[0].i };
|
|
79
|
+
}
|
|
80
|
+
return { ...c, ownerIndex: null };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
34
83
|
/**
|
|
35
|
-
* Walk a parsed tree extracting definitions (with nesting)
|
|
36
|
-
* references
|
|
84
|
+
* Walk a parsed tree extracting definitions (with nesting), call/import
|
|
85
|
+
* references, and requirement-coverage directives from comment nodes. Single
|
|
86
|
+
* traversal, O(nodes).
|
|
37
87
|
*
|
|
38
88
|
* @param source - The full source text of the file.
|
|
39
89
|
* @param lang - Language configuration describing definition/call/import nodes.
|
|
40
|
-
* @returns The extracted symbols and
|
|
41
|
-
* fields index back into the `symbols` array
|
|
90
|
+
* @returns The extracted symbols, references, and coverage directives;
|
|
91
|
+
* `parentIndex`/`ownerIndex` fields index back into the `symbols` array.
|
|
42
92
|
* @throws If the source cannot be parsed for the given language.
|
|
43
93
|
*/
|
|
44
94
|
export async function extract(source, lang) {
|
|
@@ -47,6 +97,7 @@ export async function extract(source, lang) {
|
|
|
47
97
|
const importSet = new Set(lang.importNodes);
|
|
48
98
|
const symbols = [];
|
|
49
99
|
const refs = [];
|
|
100
|
+
const rawCoverage = [];
|
|
50
101
|
const walk = (node, ownerIndex) => {
|
|
51
102
|
let nextOwner = ownerIndex;
|
|
52
103
|
if (kinds.has(node.type)) {
|
|
@@ -79,6 +130,9 @@ export async function extract(source, lang) {
|
|
|
79
130
|
ownerIndex,
|
|
80
131
|
});
|
|
81
132
|
}
|
|
133
|
+
else if (COMMENT_TYPES.has(node.type)) {
|
|
134
|
+
rawCoverage.push(...parseCoverageComment(node, ownerIndex));
|
|
135
|
+
}
|
|
82
136
|
for (let i = 0; i < node.childCount; i++) {
|
|
83
137
|
const child = node.child(i);
|
|
84
138
|
if (child)
|
|
@@ -87,5 +141,5 @@ export async function extract(source, lang) {
|
|
|
87
141
|
};
|
|
88
142
|
walk(tree.rootNode, null);
|
|
89
143
|
tree.delete();
|
|
90
|
-
return { symbols, refs };
|
|
144
|
+
return { symbols, refs, coverage: attachCoverage(rawCoverage, symbols) };
|
|
91
145
|
}
|
|
@@ -29,6 +29,24 @@ const MAX_FILE_BYTES = 1_500_000;
|
|
|
29
29
|
function hashOf(content) {
|
|
30
30
|
return createHash("sha256").update(content).digest("hex");
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Infer a covering artifact's type from its project-relative path.
|
|
34
|
+
* Full glob config lives in lawbook; this is the indexer default so links are
|
|
35
|
+
* typed even before a coverage report runs.
|
|
36
|
+
*/
|
|
37
|
+
function inferSourceType(relPath) {
|
|
38
|
+
const p = relPath.split("\\").join("/");
|
|
39
|
+
if (/(^|\/)test\/integration\//.test(p) || /(^|\/)tests\/integration\//.test(p))
|
|
40
|
+
return "itest";
|
|
41
|
+
if (/(^|\/)test\/unit\//.test(p) ||
|
|
42
|
+
/(^|\/)tests\/unit\//.test(p) ||
|
|
43
|
+
/\.test\.[cm]?[jt]sx?$/.test(p) ||
|
|
44
|
+
/\.spec\.[cm]?[jt]sx?$/.test(p) ||
|
|
45
|
+
/(^|\/)test\//.test(p)) {
|
|
46
|
+
return "utest";
|
|
47
|
+
}
|
|
48
|
+
return "impl";
|
|
49
|
+
}
|
|
32
50
|
function* walkFiles(root) {
|
|
33
51
|
const stack = [root];
|
|
34
52
|
while (stack.length) {
|
|
@@ -89,9 +107,13 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
89
107
|
const updFile = db.prepare("UPDATE files SET hash = ?, lang = ? WHERE id = ?");
|
|
90
108
|
const delNodes = db.prepare("DELETE FROM nodes WHERE file_id = ?");
|
|
91
109
|
const delEdges = db.prepare("DELETE FROM edges WHERE src_file_id = ?");
|
|
110
|
+
const delCoverage = db.prepare("DELETE FROM coverage_links WHERE file_path = ?");
|
|
92
111
|
const insNode = db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature)
|
|
93
112
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
94
113
|
const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
|
|
114
|
+
const insCoverage = db.prepare(`INSERT OR REPLACE INTO coverage_links(
|
|
115
|
+
artifact_type, name, revision, kind, file_path, line, node_id, source_type, origin
|
|
116
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
95
117
|
const insEmbed = db.prepare(`INSERT OR REPLACE INTO node_embeddings(node_id, dim, model, vec) VALUES (?, ?, ?, ?)`);
|
|
96
118
|
const allFiles = [...walkFiles(projectPath)];
|
|
97
119
|
db.exec("BEGIN");
|
|
@@ -125,12 +147,13 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
125
147
|
updFile.run(hash, lang.id, prior.id);
|
|
126
148
|
delNodes.run(prior.id);
|
|
127
149
|
delEdges.run(prior.id);
|
|
150
|
+
delCoverage.run(rel);
|
|
128
151
|
fileId = prior.id;
|
|
129
152
|
}
|
|
130
153
|
else {
|
|
131
154
|
fileId = Number(insFile.run(rel, hash, lang.id).lastInsertRowid);
|
|
132
155
|
}
|
|
133
|
-
const { symbols, refs } = await extract(content, lang);
|
|
156
|
+
const { symbols, refs, coverage } = await extract(content, lang);
|
|
134
157
|
const nodeIds = [];
|
|
135
158
|
for (const s of symbols) {
|
|
136
159
|
const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
|
|
@@ -146,6 +169,11 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
146
169
|
insEdge.run(srcId, fileId, r.name, r.kind, r.line);
|
|
147
170
|
stats.edges++;
|
|
148
171
|
}
|
|
172
|
+
const sourceType = inferSourceType(rel);
|
|
173
|
+
for (const c of coverage) {
|
|
174
|
+
const nodeId = c.ownerIndex !== null ? nodeIds[c.ownerIndex] : null;
|
|
175
|
+
insCoverage.run(c.artifactType, c.name, c.revision, c.kind, rel, c.line, nodeId, sourceType, "comment");
|
|
176
|
+
}
|
|
149
177
|
stats.files++;
|
|
150
178
|
stats.nodes += symbols.length;
|
|
151
179
|
}
|
|
@@ -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",
|
package/dist/shared/install.js
CHANGED
|
@@ -94,6 +94,7 @@ export function copyRendered(srcDir, destDir, vars, report, opts) {
|
|
|
94
94
|
* @param comment - Comment line written above the entry when it is added.
|
|
95
95
|
* @param report - Report mutated in place; the appended entry is recorded under `written`.
|
|
96
96
|
*/
|
|
97
|
+
// Covers: req~ai-specs-gitignore~1
|
|
97
98
|
export function ensureGitignore(projectPath, entry, comment, report) {
|
|
98
99
|
const gitignorePath = path.join(projectPath, ".gitignore");
|
|
99
100
|
let content = "";
|