@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
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<br/>
|
|
6
6
|
|
|
7
7
|
<a href="https://www.npmjs.com/package/@esneiderbravo/speclaw"><img src="https://img.shields.io/npm/v/@esneiderbravo/speclaw?color=0E8E8E&labelColor=0B0F10&style=flat-square&label=npm" alt="npm"></a>
|
|
8
|
+
<a href="https://github.com/esneiderbravo/speclaw/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esneiderbravo/speclaw/ci.yml?branch=main&label=CI&labelColor=0B0F10&style=flat-square&color=0E8E8E" alt="CI"></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/@esneiderbravo/speclaw?activeTab=versions"><img src="https://img.shields.io/badge/provenance-SLSA-0E8E8E?labelColor=0B0F10&style=flat-square" alt="npm provenance"></a>
|
|
8
10
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0E8E8E?labelColor=0B0F10&style=flat-square" alt="MIT"></a>
|
|
9
11
|
<img src="https://img.shields.io/badge/node-%E2%89%A522-0E8E8E?labelColor=0B0F10&style=flat-square" alt="Node >= 22">
|
|
10
12
|
|
|
@@ -29,32 +31,40 @@ One command. No cloud, no LLM, no API keys — <b>everything runs on your machin
|
|
|
29
31
|
<br/>
|
|
30
32
|
|
|
31
33
|
> [!TIP]
|
|
32
|
-
> **
|
|
33
|
-
> `speclaw init
|
|
34
|
-
>
|
|
34
|
+
> **One command. Detects your agents and wires only those.** Paste
|
|
35
|
+
> `npx @esneiderbravo/speclaw@latest init` — speclaw detects Claude Code, Cursor,
|
|
36
|
+
> Codex, Windsurf, and generic `AGENTS.md` surfaces, scaffolds the constitution +
|
|
37
|
+
> lawbook, indexes your code, and registers the local MCP server — only for the
|
|
38
|
+
> agents you pick. This one-liner is a **stable contract** (see
|
|
39
|
+
> [CONTRIBUTING.md](CONTRIBUTING.md)); do not invent alternate install commands in
|
|
40
|
+
> directories or newsletters.
|
|
35
41
|
|
|
36
42
|
<br/>
|
|
37
43
|
|
|
38
44
|
## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle"> Quick start
|
|
39
45
|
|
|
40
|
-
Install speclaw globally (once), then run `init` in your project root:
|
|
41
|
-
|
|
42
46
|
```bash
|
|
43
|
-
|
|
44
|
-
speclaw init
|
|
47
|
+
npx @esneiderbravo/speclaw@latest init
|
|
45
48
|
```
|
|
46
49
|
|
|
47
50
|
<p align="center">
|
|
48
51
|
<img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/terminal-quickstart.png" width="800" alt="speclaw init">
|
|
49
52
|
</p>
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
Prefer a global install for repeated CLI use?
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm i -g @esneiderbravo/speclaw
|
|
58
|
+
speclaw init
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The `speclaw` command is then available everywhere — run `speclaw index`,
|
|
62
|
+
`speclaw doctor`, `speclaw visualize`, or `speclaw lawbook …` directly.
|
|
53
63
|
|
|
54
64
|
`init` will:
|
|
55
65
|
|
|
56
|
-
1. **Ask which agents you use** (Claude Code, Cursor, Codex, …) — and
|
|
57
|
-
only those. Add more later; nothing is forced on you.
|
|
66
|
+
1. **Ask which agents you use** (Claude Code, Cursor, Codex, Windsurf, …) — and
|
|
67
|
+
configure only those. Add more later; nothing is forced on you.
|
|
58
68
|
2. Write the **foundation** (constitution + standards) and the **lawbook workflow**,
|
|
59
69
|
and compile your blocking laws into **agent hooks** for the agents that support them.
|
|
60
70
|
3. **Index your code** with a live progress bar and a summary of what it found.
|
|
@@ -62,9 +72,23 @@ The `speclaw` command is now available everywhere — run `speclaw index`,
|
|
|
62
72
|
5. Print a prompt to paste into your agent so it fills the constitution with your
|
|
63
73
|
project's real architecture and conventions.
|
|
64
74
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
When something breaks, run `speclaw doctor --json` and paste it into an issue
|
|
76
|
+
(required on bug reports). Output is redacted by default.
|
|
77
|
+
|
|
78
|
+
<br/>
|
|
79
|
+
|
|
80
|
+
## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle"> Verify a release (provenance)
|
|
81
|
+
|
|
82
|
+
Every npm publish is signed via **Trusted Publishing (OIDC)** and carries a
|
|
83
|
+
SLSA provenance attestation tied to this repository and workflow. That proves
|
|
84
|
+
*where* the tarball was built — not that its contents are benign. Pair it with
|
|
85
|
+
your own review and (later) law-integrity pinning.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm audit signatures
|
|
89
|
+
# After downloading the tarball from the registry:
|
|
90
|
+
gh attestation verify <tarball> --owner esneiderbravo
|
|
91
|
+
```
|
|
68
92
|
|
|
69
93
|
<br/>
|
|
70
94
|
|
|
@@ -105,6 +129,8 @@ tokenizer on this corpus — not a BPE dependency):
|
|
|
105
129
|
```bash
|
|
106
130
|
speclaw budget # human table
|
|
107
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
|
|
108
134
|
speclaw init --minimal # omit setup/lifecycle MCP tools from registration
|
|
109
135
|
```
|
|
110
136
|
|
|
@@ -202,7 +228,10 @@ you** — commit your own skills and commands there if you want to.
|
|
|
202
228
|
|
|
203
229
|
**Enforcement artifacts.** For agents that support hooks, speclaw merges its law
|
|
204
230
|
hooks into that agent's settings (e.g. `.claude/settings.json`) **by identity** —
|
|
205
|
-
it never touches hooks you added yourself.
|
|
231
|
+
it never touches hooks you added yourself. Each speclaw `mcp_tool` hook includes
|
|
232
|
+
an `input` map Claude Code substitutes from the hook event (`${cwd}`,
|
|
233
|
+
`${hook_event_name}`, `${tool_input.file_path}`, …) so `speclaw_check` receives
|
|
234
|
+
`projectPath` / `event` / `payload`. The compiled law manifest lives in
|
|
206
235
|
`.speclaw/laws-manifest.json` (gitignored, regenerated on `init`/`update`), and a
|
|
207
236
|
context-coverage log in `.speclaw/context-log.jsonl` feeds `speclaw doctor`.
|
|
208
237
|
|
|
@@ -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
|
+
}
|
|
@@ -1,31 +1,63 @@
|
|
|
1
1
|
import { doctor } from "../../modules/foundation/doctor.js";
|
|
2
|
-
import { measureInstallBudget } from "../../modules/foundation/context-budget.js";
|
|
3
2
|
import { ui } from "../lib/ui.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
const STATUS_MARK = {
|
|
4
|
+
ok: "ok",
|
|
5
|
+
warn: "warn",
|
|
6
|
+
error: "error",
|
|
7
|
+
skip: "skip",
|
|
8
|
+
};
|
|
9
|
+
function printHuman(report) {
|
|
8
10
|
ui.heading("speclaw doctor");
|
|
9
|
-
for (const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
for (const section of report.sections) {
|
|
12
|
+
ui.plain();
|
|
13
|
+
ui.ok(`${section.id} [${STATUS_MARK[section.status]}]`);
|
|
14
|
+
for (const c of section.checks) {
|
|
15
|
+
const line = `${c.title.padEnd(24)} ${c.detail ?? c.status}`;
|
|
16
|
+
if (c.status === "ok" || c.status === "skip")
|
|
17
|
+
ui.ok(` ${line}`);
|
|
18
|
+
else if (c.status === "warn") {
|
|
19
|
+
ui.warn(` ${line}`);
|
|
20
|
+
if (c.remedy)
|
|
21
|
+
ui.plain(` → ${c.remedy}`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
ui.err(` ${line}`);
|
|
25
|
+
if (c.remedy)
|
|
26
|
+
ui.plain(` → ${c.remedy}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
22
29
|
}
|
|
23
|
-
const failed = checks.filter((c) => !c.ok).length;
|
|
24
30
|
ui.plain();
|
|
25
|
-
|
|
31
|
+
const warns = report.sections.flatMap((s) => s.checks).filter((c) => c.status === "warn").length;
|
|
32
|
+
const errs = report.sections.flatMap((s) => s.checks).filter((c) => c.status === "error").length;
|
|
33
|
+
if (errs === 0 && warns === 0)
|
|
26
34
|
ui.ok("Everything is within the law.");
|
|
35
|
+
else if (errs === 0) {
|
|
36
|
+
ui.warn(`${warns} warning(s). Run \`speclaw doctor --json\` and paste it into an issue.`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
ui.err(`${errs} error(s), ${warns} warning(s).`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Verify the installation. Supports `--json`, `--offline`, `--strict`,
|
|
44
|
+
* `--redact` (default) / `--no-redact`.
|
|
45
|
+
*/
|
|
46
|
+
export async function runDoctor(flags) {
|
|
47
|
+
const cwd = process.cwd();
|
|
48
|
+
const redact = flags["no-redact"] ? false : true;
|
|
49
|
+
const report = await doctor(cwd, {
|
|
50
|
+
offline: Boolean(flags.offline),
|
|
51
|
+
redact,
|
|
52
|
+
});
|
|
53
|
+
if (flags.json) {
|
|
54
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
55
|
+
}
|
|
27
56
|
else {
|
|
28
|
-
|
|
57
|
+
printHuman(report);
|
|
58
|
+
}
|
|
59
|
+
const strict = Boolean(flags.strict);
|
|
60
|
+
if (report.status === "error" || (strict && report.status === "warn")) {
|
|
29
61
|
process.exit(1);
|
|
30
62
|
}
|
|
31
63
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ui } from "../lib/ui.js";
|
|
2
|
+
/**
|
|
3
|
+
* Telemetry posture command. speclaw ships **no** telemetry code — `status`
|
|
4
|
+
* says so; enable/disable/log are rejected.
|
|
5
|
+
*/
|
|
6
|
+
export async function runTelemetry(flags) {
|
|
7
|
+
const sub = flags._[0] ?? "status";
|
|
8
|
+
if (sub === "status") {
|
|
9
|
+
ui.ok("speclaw includes no telemetry — nothing is collected or transmitted.");
|
|
10
|
+
ui.plain("Policy: 100% local. There is no enable path and no analytics endpoint.");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
ui.err(`telemetry ${sub} is unavailable: speclaw does not include telemetry. ` +
|
|
14
|
+
"Run `speclaw telemetry status`.");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
@@ -79,6 +79,27 @@ const MIGRATIONS = [
|
|
|
79
79
|
"(no server-side defer_loading).\n" +
|
|
80
80
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
81
81
|
},
|
|
82
|
+
{
|
|
83
|
+
version: "0.3.6",
|
|
84
|
+
describe: "Doctor JSON diagnostics and stable install one-liner",
|
|
85
|
+
agentPrompt: "- Mention that `speclaw doctor --json` is the support report (redacted by default) " +
|
|
86
|
+
"and that the stable install command is `npx @esneiderbravo/speclaw@latest init`.\n" +
|
|
87
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
version: "0.3.7",
|
|
91
|
+
describe: "Claude Code mcp_tool hooks pass speclaw_check input via ${…} templates",
|
|
92
|
+
// scaffold → installHooks already rewrites .claude/settings.json when the
|
|
93
|
+
// compiled hook shape changes; no extra run() step.
|
|
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
|
+
},
|
|
82
103
|
];
|
|
83
104
|
/**
|
|
84
105
|
* Update speclaw and bring the current project up to date without a full re-init:
|
package/dist/cli/index.js
CHANGED
|
@@ -35,8 +35,10 @@ Lawbook (spec-driven workflow)
|
|
|
35
35
|
lawbook archive <c> Finalize and archive a change
|
|
36
36
|
|
|
37
37
|
Other
|
|
38
|
-
doctor Verify the installation
|
|
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)
|
|
41
|
+
telemetry status Confirm speclaw ships no telemetry
|
|
40
42
|
check Evaluate an action against the laws (hooks call this; --dry-run to preview)
|
|
41
43
|
laws verify Verify the deterministic dependency/graph laws against the index
|
|
42
44
|
verify Verify laws for CI: exit codes, --sarif, --json, --strict-engines
|
|
@@ -59,6 +61,8 @@ const HEADER_COMMANDS = new Set([
|
|
|
59
61
|
"agent",
|
|
60
62
|
"doctor",
|
|
61
63
|
"budget",
|
|
64
|
+
"coverage",
|
|
65
|
+
"telemetry",
|
|
62
66
|
"index",
|
|
63
67
|
"watch",
|
|
64
68
|
"lawbook",
|
|
@@ -68,7 +72,9 @@ const HEADER_COMMANDS = new Set([
|
|
|
68
72
|
* header-eligible command AND stdout is an interactive terminal (so pipes,
|
|
69
73
|
* redirection, and CI stay clean — mirroring the color gate in `ui.ts`). A
|
|
70
74
|
* forced-color signal counts as interactive so the header is exercisable in a
|
|
71
|
-
* child process. `budget --json`
|
|
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.
|
|
72
78
|
*/
|
|
73
79
|
function maybeHeader(cmd, flags) {
|
|
74
80
|
if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
|
|
@@ -77,6 +83,10 @@ function maybeHeader(cmd, flags) {
|
|
|
77
83
|
return;
|
|
78
84
|
if (cmd === "budget" && flags.json)
|
|
79
85
|
return;
|
|
86
|
+
if (cmd === "doctor" && flags.json)
|
|
87
|
+
return;
|
|
88
|
+
if (cmd === "coverage" && (flags.json || flags.tap))
|
|
89
|
+
return;
|
|
80
90
|
header();
|
|
81
91
|
}
|
|
82
92
|
/** Run the handler for a single command. Returns when the command completes. */
|
|
@@ -121,6 +131,10 @@ async function dispatch(cmd, flags) {
|
|
|
121
131
|
return (await import("./commands/doctor.js")).runDoctor(flags);
|
|
122
132
|
case "budget":
|
|
123
133
|
return (await import("./commands/budget.js")).runBudget(flags);
|
|
134
|
+
case "coverage":
|
|
135
|
+
return (await import("./commands/coverage.js")).runCoverage(flags);
|
|
136
|
+
case "telemetry":
|
|
137
|
+
return (await import("./commands/telemetry.js")).runTelemetry(flags);
|
|
124
138
|
case "check":
|
|
125
139
|
return (await import("./commands/check.js")).runCheck(flags);
|
|
126
140
|
case "laws":
|
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
|
}
|
|
@@ -165,6 +193,7 @@ export async function buildIndex(projectPath, onProgress) {
|
|
|
165
193
|
)
|
|
166
194
|
WHERE kind = 'call' AND dst_node_id IS NULL
|
|
167
195
|
`);
|
|
196
|
+
db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
|
|
168
197
|
db.exec("COMMIT");
|
|
169
198
|
}
|
|
170
199
|
catch (err) {
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { registerCompass } from "../compass/register.js";
|
|
2
3
|
import { registerSpec } from "../lawbook/register.js";
|
|
3
4
|
import { registerTools } from "../tools/register.js";
|
|
4
|
-
import {
|
|
5
|
+
import { registerFoundationCore } from "./register-core.js";
|
|
5
6
|
import { measureBudget } from "../../shared/budget.js";
|
|
6
|
-
import { isMinimalMode, packageRoot } from "../../shared/exposure.js";
|
|
7
|
+
import { isMinimalMode, packageRoot, shouldExpose } from "../../shared/exposure.js";
|
|
8
|
+
/** Mirrors the `doctor` tool surface without importing `doctor.ts` (avoids a cycle). */
|
|
9
|
+
const DOCTOR_TOOL_FOR_BUDGET = {
|
|
10
|
+
name: "doctor",
|
|
11
|
+
description: "Verify the speclaw install; returns a versioned DoctorReport (schemaVersion 1).",
|
|
12
|
+
inputSchema: { projectPath: z.string() },
|
|
13
|
+
};
|
|
7
14
|
/**
|
|
8
15
|
* Collect tool definitions as the MCP server would register them for a profile.
|
|
9
16
|
*
|
|
17
|
+
* Uses `registerFoundationCore` plus a static `doctor` stub so budget/doctor
|
|
18
|
+
* measurement never imports the live `doctor` implementation (module cycle).
|
|
19
|
+
*
|
|
10
20
|
* @param minimal - Exposure profile.
|
|
11
21
|
*/
|
|
12
22
|
export function collectRegisteredTools(minimal) {
|
|
@@ -22,7 +32,10 @@ export function collectRegisteredTools(minimal) {
|
|
|
22
32
|
};
|
|
23
33
|
const opts = { minimal };
|
|
24
34
|
const stub = server;
|
|
25
|
-
|
|
35
|
+
registerFoundationCore(stub, opts);
|
|
36
|
+
if (shouldExpose("doctor", minimal)) {
|
|
37
|
+
tools.push(DOCTOR_TOOL_FOR_BUDGET);
|
|
38
|
+
}
|
|
26
39
|
registerSpec(stub, opts);
|
|
27
40
|
registerCompass(stub, opts);
|
|
28
41
|
registerTools(stub, opts);
|