@esneiderbravo/speclaw 0.3.12 → 0.3.13
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/dist/cli/commands/lawbook.js +42 -1
- package/dist/cli/commands/update.js +8 -0
- package/dist/cli/index.js +6 -0
- package/dist/modules/foundation/doctor.js +31 -0
- package/dist/modules/lawbook/assets/commands/investigate.md +7 -0
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +8 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +1 -0
- package/dist/modules/lawbook/assets/skills/investigate/SKILL.md +10 -0
- package/dist/modules/lawbook/assets/skills/investigate/steps/01-investigate.md +7 -0
- package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +6 -0
- package/dist/modules/lawbook/bugfix.js +195 -0
- package/dist/modules/lawbook/engine.js +70 -7
- package/dist/modules/lawbook/investigate.js +358 -0
- package/dist/modules/lawbook/levels.js +49 -2
- package/dist/modules/lawbook/register.js +8 -0
- package/dist/modules/lawbook/stack-parse.js +135 -0
- package/dist/shared/exposure.js +1 -0
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { specInit, specValidate, specSync, specArchive, specList, } from "../../modules/lawbook/engine.js";
|
|
2
2
|
import { handleLevel } from "../../modules/lawbook/quick.js";
|
|
3
|
+
import { scaffoldBugfix } from "../../modules/lawbook/bugfix.js";
|
|
4
|
+
import { investigate, formatInvestigateResult } from "../../modules/lawbook/investigate.js";
|
|
3
5
|
import { list } from "../lib/args.js";
|
|
4
6
|
import { ui } from "../lib/ui.js";
|
|
5
7
|
function today() {
|
|
@@ -89,6 +91,45 @@ export async function runSpec(flags) {
|
|
|
89
91
|
r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
|
|
90
92
|
return;
|
|
91
93
|
}
|
|
94
|
+
case "investigate": {
|
|
95
|
+
const stackTrace = typeof flags["stack-trace"] === "string"
|
|
96
|
+
? flags["stack-trace"]
|
|
97
|
+
: typeof flags.stackTrace === "string"
|
|
98
|
+
? flags.stackTrace
|
|
99
|
+
: undefined;
|
|
100
|
+
const symptom = typeof flags.symptom === "string" ? flags.symptom : undefined;
|
|
101
|
+
const result = await investigate({
|
|
102
|
+
projectPath: cwd,
|
|
103
|
+
stackTrace,
|
|
104
|
+
symptom,
|
|
105
|
+
hintPaths: list(flags.path),
|
|
106
|
+
maxSuspects: flags.max !== undefined && flags.max !== true ? Number(flags.max) : undefined,
|
|
107
|
+
});
|
|
108
|
+
if (flags.json) {
|
|
109
|
+
console.log(formatInvestigateResult(result));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
console.log(formatInvestigateResult(result));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
case "draft": {
|
|
116
|
+
if (!flags.bug) {
|
|
117
|
+
ui.err("Usage: speclaw lawbook draft --bug <name> [--level N] [--json]");
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
const name = typeof flags.bug === "string" ? flags.bug : req(change, "lawbook draft --bug <name>");
|
|
121
|
+
const levelFlag = flags.level;
|
|
122
|
+
const level = levelFlag === undefined || levelFlag === true
|
|
123
|
+
? undefined
|
|
124
|
+
: Number(levelFlag);
|
|
125
|
+
const result = scaffoldBugfix(cwd, name, { level });
|
|
126
|
+
if (flags.json) {
|
|
127
|
+
console.log(JSON.stringify(result, null, 2));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
ui.ok(`bug change "${name}" scaffolded at ${result.dir}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
92
133
|
case "archive": {
|
|
93
134
|
const r = specArchive(cwd, req(change, "lawbook archive <change>"), today());
|
|
94
135
|
ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
|
|
@@ -103,7 +144,7 @@ export async function runSpec(flags) {
|
|
|
103
144
|
return;
|
|
104
145
|
}
|
|
105
146
|
default:
|
|
106
|
-
ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive|level> [change]");
|
|
147
|
+
ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive|level|draft|investigate> [change]");
|
|
107
148
|
process.exit(1);
|
|
108
149
|
}
|
|
109
150
|
}
|
|
@@ -135,6 +135,14 @@ const MIGRATIONS = [
|
|
|
135
135
|
"docs/standards/lawbook.md wording if the project still says every change needs all four artifacts.\n" +
|
|
136
136
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
137
137
|
},
|
|
138
|
+
{
|
|
139
|
+
version: "0.3.13",
|
|
140
|
+
describe: "Bugfix specs — draft --bug, bugfix.md, lawbook_investigate",
|
|
141
|
+
agentPrompt: "- Mention bug changes: `speclaw lawbook draft --bug`, `bugfix.md` (repro + regression + prevention), " +
|
|
142
|
+
"and `lawbook_investigate` / the investigate skill for graph-backed RCA. Feature ceremony unchanged; " +
|
|
143
|
+
"`changeType: bug` in `change.json`. Security-withheld mode is not in this release.\n" +
|
|
144
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
145
|
+
},
|
|
138
146
|
];
|
|
139
147
|
/**
|
|
140
148
|
* Update speclaw and bring the current project up to date without a full re-init:
|
package/dist/cli/index.js
CHANGED
|
@@ -35,6 +35,8 @@ Lawbook (spec-driven workflow)
|
|
|
35
35
|
lawbook init Create the lawbook/ workspace
|
|
36
36
|
lawbook list Active/archived changes and capabilities
|
|
37
37
|
lawbook level <mode> Propose/set/promote/explain ceremony level (--json)
|
|
38
|
+
lawbook draft --bug <c> Scaffold a bug change (bugfix.md + reports)
|
|
39
|
+
lawbook investigate Rank bug suspects from graph (--symptom / --stack-trace, --json)
|
|
38
40
|
lawbook validate <c> Validate a change's artifacts
|
|
39
41
|
lawbook sync <c> Promote delta specs to canonical
|
|
40
42
|
lawbook archive <c> Finalize and archive a change
|
|
@@ -102,6 +104,10 @@ function maybeHeader(cmd, flags) {
|
|
|
102
104
|
return;
|
|
103
105
|
if (cmd === "lawbook" && flags.json && flags._[0] === "level")
|
|
104
106
|
return;
|
|
107
|
+
if (cmd === "lawbook" && flags.json && flags._[0] === "investigate")
|
|
108
|
+
return;
|
|
109
|
+
if (cmd === "lawbook" && flags.json && flags._[0] === "draft")
|
|
110
|
+
return;
|
|
105
111
|
header();
|
|
106
112
|
}
|
|
107
113
|
/** Run the handler for a single command. Returns when the command completes. */
|
|
@@ -527,6 +527,37 @@ function ceremonyChecks(projectPath) {
|
|
|
527
527
|
? "no archived changes"
|
|
528
528
|
: `archived levels: 0=${counts["0"]}, 1=${counts["1"]}, 2=${counts["2"]}, 3=${counts["3"]} (missing change.json=${counts.missing})`,
|
|
529
529
|
});
|
|
530
|
+
const typeCounts = { feature: 0, bug: 0, unknown: 0 };
|
|
531
|
+
if (fs.existsSync(archiveRoot)) {
|
|
532
|
+
for (const name of fs.readdirSync(archiveRoot)) {
|
|
533
|
+
const dir = path.join(archiveRoot, name);
|
|
534
|
+
if (!fs.statSync(dir).isDirectory())
|
|
535
|
+
continue;
|
|
536
|
+
try {
|
|
537
|
+
const p = path.join(dir, "change.json");
|
|
538
|
+
if (!fs.existsSync(p)) {
|
|
539
|
+
typeCounts.unknown += 1;
|
|
540
|
+
typeCounts.feature += 1;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
544
|
+
if (raw.changeType === "bug")
|
|
545
|
+
typeCounts.bug += 1;
|
|
546
|
+
else
|
|
547
|
+
typeCounts.feature += 1;
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
typeCounts.unknown += 1;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
out.push({
|
|
555
|
+
id: "cfg.ceremony.changeTypes",
|
|
556
|
+
title: "change type distribution",
|
|
557
|
+
status: "ok",
|
|
558
|
+
value: JSON.stringify(typeCounts),
|
|
559
|
+
detail: `archived types: feature=${typeCounts.feature}, bug=${typeCounts.bug}`,
|
|
560
|
+
});
|
|
530
561
|
return out;
|
|
531
562
|
}
|
|
532
563
|
function configurationChecks(projectPath, initialised) {
|
|
@@ -76,3 +76,11 @@ touched — and therefore which reports are owed, including `api.md` for any
|
|
|
76
76
|
API-touching change — is the agent's responsibility to judge and satisfy before
|
|
77
77
|
archiving; the engine gate counts files but cannot infer the set of concerns a
|
|
78
78
|
change exercised.
|
|
79
|
+
|
|
80
|
+
## 5. Bug changes must show the regression test failing first
|
|
81
|
+
|
|
82
|
+
When `changeType` is **bug**, the discipline report MUST include the output of
|
|
83
|
+
the regression test **failing before the fix** (or document why instrumentation
|
|
84
|
+
substitutes for a red-green cycle when reproduction is `unreproducible:`). A test
|
|
85
|
+
that only passes after the fix — with no evidence it ever failed — does not
|
|
86
|
+
satisfy the bug gate.
|
|
@@ -13,6 +13,7 @@ Create under `lawbook/changes/<name>/` only what the level needs:
|
|
|
13
13
|
- **Level 2** — `proposal.md`, `tasks.md`, delta specs, `reports/`;
|
|
14
14
|
`design.md` optional only with justification in `record.md`.
|
|
15
15
|
- **Level 3** — `proposal.md`, `design.md`, `tasks.md`, delta specs, `reports/`.
|
|
16
|
+
- **Bug (`draft --bug`)** — `bugfix.md` instead of proposal/design; see the investigate skill for RCA first.
|
|
16
17
|
|
|
17
18
|
For every level that needs delta specs:
|
|
18
19
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: investigate
|
|
3
|
+
description: Forensic bug triage — rank suspects via lawbook_investigate before draft --bug. Use with a stack trace or symptom when starting RCA.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# investigate — Bug RCA from the graph
|
|
7
|
+
|
|
8
|
+
Use when work is **"this is broken"**, not **"build X"**.
|
|
9
|
+
|
|
10
|
+
Read `steps/01-investigate.md` and do only what it says.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Hand off to draft
|
|
2
|
+
|
|
3
|
+
- **`compass_impact`** on the confirmed root cause for blast radius.
|
|
4
|
+
- **`speclaw lawbook draft --bug <name>`** — pre-seed only; fill repro, fix, regression test, prevention.
|
|
5
|
+
|
|
6
|
+
Treat the ranking as **evidence**, not a verdict. No further steps remain — investigate workflow complete.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gatherSignals, loadCeremonyConfig, proposeLevel, setCeremonyLevel, writeCeremonyRecord, } from "./levels.js";
|
|
4
|
+
export const BUGFIX_HEADINGS = [
|
|
5
|
+
"1. Observed symptom",
|
|
6
|
+
"2. Minimal reproduction",
|
|
7
|
+
"3. Root cause",
|
|
8
|
+
"4. Blast radius",
|
|
9
|
+
"5. Proposed fix",
|
|
10
|
+
"6. Regression test",
|
|
11
|
+
"7. Prevention",
|
|
12
|
+
];
|
|
13
|
+
/** Parsed section bodies keyed by heading label. */
|
|
14
|
+
export function parseBugfixSections(content) {
|
|
15
|
+
const out = new Map();
|
|
16
|
+
for (const part of content.split(/^##\s+/m).slice(1)) {
|
|
17
|
+
const nl = part.indexOf("\n");
|
|
18
|
+
if (nl < 0) {
|
|
19
|
+
out.set(part.trim(), "");
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
out.set(part.slice(0, nl).trim(), part.slice(nl + 1).trim());
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function sectionBody(sections, key) {
|
|
27
|
+
for (const [h, b] of sections) {
|
|
28
|
+
if (h.toLowerCase().startsWith(key.toLowerCase()))
|
|
29
|
+
return b;
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
function isFilled(body) {
|
|
34
|
+
const t = body.trim();
|
|
35
|
+
if (!t)
|
|
36
|
+
return false;
|
|
37
|
+
if (/^n\/a\s*:/i.test(t))
|
|
38
|
+
return true;
|
|
39
|
+
return t.length > 2;
|
|
40
|
+
}
|
|
41
|
+
/** True when prevention says a canonical requirement was missing. */
|
|
42
|
+
export function preventionRequiresDelta(content) {
|
|
43
|
+
const prev = sectionBody(parseBugfixSections(content), "7. Prevention");
|
|
44
|
+
if (!prev)
|
|
45
|
+
return false;
|
|
46
|
+
return (/\b(requirement|spec)\b.*\b(miss|missing|absent|incomplete|add|update)\b/i.test(prev) ||
|
|
47
|
+
/\bfaltaba\b/i.test(prev) ||
|
|
48
|
+
/\bmissing requirement\b/i.test(prev));
|
|
49
|
+
}
|
|
50
|
+
/** Infer archive resolution from bugfix.md content. */
|
|
51
|
+
export function inferBugResolution(content) {
|
|
52
|
+
if (/\bnot-a-bug\b/i.test(content) || /resolution:\s*not-a-bug/i.test(content)) {
|
|
53
|
+
return "not-a-bug";
|
|
54
|
+
}
|
|
55
|
+
const repro = sectionBody(parseBugfixSections(content), "2. Minimal reproduction");
|
|
56
|
+
if (/unreproducible\s*:/i.test(repro))
|
|
57
|
+
return "mitigated";
|
|
58
|
+
return "fixed";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Validate bugfix.md sections for a ceremony level.
|
|
62
|
+
*
|
|
63
|
+
* @returns Human-readable issues (empty when valid).
|
|
64
|
+
*/
|
|
65
|
+
export function validateBugfixContent(level, content) {
|
|
66
|
+
const issues = [];
|
|
67
|
+
const sections = parseBugfixSections(content);
|
|
68
|
+
for (const h of BUGFIX_HEADINGS) {
|
|
69
|
+
if (!sections.has(h) && ![...sections.keys()].some((k) => k.startsWith(h.split(".")[0]))) {
|
|
70
|
+
issues.push(`bugfix.md missing heading "## ${h}"`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const repro = sectionBody(sections, "2. Minimal reproduction");
|
|
74
|
+
if (!isFilled(repro) && !/unreproducible\s*:/i.test(repro)) {
|
|
75
|
+
issues.push("bugfix.md §2 requires reproduction steps or an `unreproducible:` block");
|
|
76
|
+
}
|
|
77
|
+
const requiredAt0 = [
|
|
78
|
+
"1. Observed symptom",
|
|
79
|
+
"2. Minimal reproduction",
|
|
80
|
+
"3. Root cause",
|
|
81
|
+
"5. Proposed fix",
|
|
82
|
+
"6. Regression test",
|
|
83
|
+
];
|
|
84
|
+
const optionalAt0 = ["4. Blast radius", "7. Prevention"];
|
|
85
|
+
const allRequired = level >= 1 ? [...BUGFIX_HEADINGS] : requiredAt0;
|
|
86
|
+
for (const key of allRequired) {
|
|
87
|
+
const body = sectionBody(sections, key);
|
|
88
|
+
if (!isFilled(body) && !/unreproducible\s*:/i.test(body)) {
|
|
89
|
+
issues.push(`bugfix.md §${key.split(".")[0]} (${key}) is empty`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (level === 0) {
|
|
93
|
+
for (const key of optionalAt0) {
|
|
94
|
+
const body = sectionBody(sections, key);
|
|
95
|
+
if (body && !isFilled(body) && !/^n\/a\s*:/i.test(body)) {
|
|
96
|
+
issues.push(`bugfix.md §${key.split(".")[0]} must be filled or start with \`n/a:\``);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const prevention = sectionBody(sections, "7. Prevention");
|
|
101
|
+
const resolution = inferBugResolution(content);
|
|
102
|
+
if (resolution === "not-a-bug" && !isFilled(prevention)) {
|
|
103
|
+
issues.push("not-a-bug resolution requires a prevention entry (usually a spec clarity fix)");
|
|
104
|
+
}
|
|
105
|
+
else if (level >= 1 && !isFilled(prevention)) {
|
|
106
|
+
issues.push("bugfix.md §7 Prevention must be answered (law, spec gap, or explicit none with reason)");
|
|
107
|
+
}
|
|
108
|
+
const regression = sectionBody(sections, "6. Regression test");
|
|
109
|
+
const mitigated = /unreproducible\s*:/i.test(repro);
|
|
110
|
+
if (!mitigated && !isFilled(regression)) {
|
|
111
|
+
issues.push("bugfix.md §6 Regression test is required unless reproduction is unreproducible");
|
|
112
|
+
}
|
|
113
|
+
if (mitigated && !isFilled(regression) && !/\binstrument/i.test(content)) {
|
|
114
|
+
issues.push("unreproducible bugs require instrumentation (§6 or explicit instrumentation reference)");
|
|
115
|
+
}
|
|
116
|
+
return issues;
|
|
117
|
+
}
|
|
118
|
+
function bugfixTemplate(name, level, seed) {
|
|
119
|
+
const symptom = seed?.inputSymptom ??
|
|
120
|
+
"<What you see: error message, wrong value, screenshot reference. Do not interpret yet.>";
|
|
121
|
+
const root = seed?.suspects?.[0] != null
|
|
122
|
+
? `${seed.suspects[0].name} (${seed.suspects[0].file}:${seed.suspects[0].startLine}) **(candidate — verify)**`
|
|
123
|
+
: "<symbol (file:line) — must resolve against the graph>";
|
|
124
|
+
const blast = seed?.blastRadiusSummary ??
|
|
125
|
+
"<Run compass_impact on the confirmed root cause; list modules and call sites.>";
|
|
126
|
+
return `# Bugfix: ${name}
|
|
127
|
+
|
|
128
|
+
**Level:** ${level} · **Type:** bug · **Severity:** normal
|
|
129
|
+
|
|
130
|
+
## 1. Observed symptom
|
|
131
|
+
${symptom}
|
|
132
|
+
|
|
133
|
+
## 2. Minimal reproduction
|
|
134
|
+
<!-- Steps to reproduce, or \`unreproducible: <reason and what was tried>\` -->
|
|
135
|
+
|
|
136
|
+
## 3. Root cause
|
|
137
|
+
${root}
|
|
138
|
+
|
|
139
|
+
## 4. Blast radius
|
|
140
|
+
${blast}
|
|
141
|
+
|
|
142
|
+
## 5. Proposed fix
|
|
143
|
+
<!-- The change; note discarded alternatives if any. -->
|
|
144
|
+
|
|
145
|
+
## 6. Regression test
|
|
146
|
+
<!-- test/path.test.ts::case name — must fail BEFORE the fix -->
|
|
147
|
+
|
|
148
|
+
## 7. Prevention
|
|
149
|
+
<!-- New law (executable-laws block), missing spec requirement, or "none: <reason>" -->
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Scaffold a bug change: `bugfix.md`, `change.json`, and `reports/`.
|
|
154
|
+
*/
|
|
155
|
+
export function scaffoldBugfix(projectPath, name, opts = {}) {
|
|
156
|
+
const changeDir = path.join(projectPath, "lawbook", "changes", name);
|
|
157
|
+
if (fs.existsSync(changeDir)) {
|
|
158
|
+
throw new Error(`change "${name}" already exists under lawbook/changes/`);
|
|
159
|
+
}
|
|
160
|
+
const targets = opts.targets ?? { paths: [], symbols: [] };
|
|
161
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
162
|
+
const signals = gatherSignals(projectPath, targets, thresholds);
|
|
163
|
+
const proposal = proposeLevel(signals, thresholds);
|
|
164
|
+
const level = opts.level ?? (proposal.level !== null && proposal.level <= 1 ? proposal.level : 1);
|
|
165
|
+
fs.mkdirSync(path.join(changeDir, "reports"), { recursive: true });
|
|
166
|
+
fs.writeFileSync(path.join(changeDir, "bugfix.md"), bugfixTemplate(name, level, opts.seed));
|
|
167
|
+
fs.writeFileSync(path.join(changeDir, "reports", "README.md"), `# Reports — ${name}\n\nBug reports MUST include the regression test **failing before the fix**.\n`);
|
|
168
|
+
if (level >= 1) {
|
|
169
|
+
fs.writeFileSync(path.join(changeDir, "tasks.md"), `- [ ] Reproduce and confirm root cause\n- [ ] Implement fix\n- [ ] Add regression test (red before, green after)\n- [ ] Complete prevention §7\n- [ ] Write discipline report under reports/\n`);
|
|
170
|
+
}
|
|
171
|
+
if (level >= 2) {
|
|
172
|
+
fs.writeFileSync(path.join(changeDir, "design.md"), `# Design — ${name}\n\n## Approach\n\n(structural bugfix — document the fix architecture)\n`);
|
|
173
|
+
}
|
|
174
|
+
const record = setCeremonyLevel(projectPath, name, {
|
|
175
|
+
proposal,
|
|
176
|
+
level,
|
|
177
|
+
confirmedBy: "human",
|
|
178
|
+
});
|
|
179
|
+
const updated = { ...record, changeType: "bug" };
|
|
180
|
+
writeCeremonyRecord(projectPath, name, updated);
|
|
181
|
+
return { change: name, proposal, record: updated, dir: changeDir };
|
|
182
|
+
}
|
|
183
|
+
/** Read change type from an archived folder path. */
|
|
184
|
+
export function readChangeTypeFromDir(changeDir) {
|
|
185
|
+
const p = path.join(changeDir, "change.json");
|
|
186
|
+
if (!fs.existsSync(p))
|
|
187
|
+
return "feature";
|
|
188
|
+
try {
|
|
189
|
+
const raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
190
|
+
return raw.changeType === "bug" ? "bug" : "feature";
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return "feature";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { coverageArchiveBlockers } from "./coverage.js";
|
|
4
4
|
import { sealCapability } from "./anchors.js";
|
|
5
|
-
import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, } from "./levels.js";
|
|
5
|
+
import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, readChangeType, readCeremonyRecord, } from "./levels.js";
|
|
6
|
+
import { inferBugResolution, preventionRequiresDelta, validateBugfixContent } from "./bugfix.js";
|
|
6
7
|
// speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
|
|
7
8
|
// (proposals, delta specs, changes, archive) but implemented from scratch and
|
|
8
9
|
// deliberately simpler: a change's specs/ holds the full intended spec for each
|
|
@@ -176,7 +177,20 @@ export function specValidate(projectPath, change, remeasure) {
|
|
|
176
177
|
};
|
|
177
178
|
}
|
|
178
179
|
const level = confirmedLevel(projectPath, change);
|
|
179
|
-
const
|
|
180
|
+
const changeType = readChangeType(projectPath, change);
|
|
181
|
+
const needs = artifactNeeds(level, changeType);
|
|
182
|
+
if (needs.bugfix) {
|
|
183
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
184
|
+
if (!fs.existsSync(bugPath)) {
|
|
185
|
+
issues.push(`missing bugfix.md (required for bug changes at level ${level})`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
issues.push(...validateBugfixContent(level, fs.readFileSync(bugPath, "utf8")));
|
|
189
|
+
}
|
|
190
|
+
if (fs.existsSync(path.join(changeDir, "proposal.md"))) {
|
|
191
|
+
issues.push("bug changes must not include proposal.md — use bugfix.md");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
180
194
|
if (needs.record && !fs.existsSync(path.join(changeDir, "record.md"))) {
|
|
181
195
|
issues.push(`missing record.md (required at ceremony level ${level})`);
|
|
182
196
|
}
|
|
@@ -200,6 +214,14 @@ export function specValidate(projectPath, change, remeasure) {
|
|
|
200
214
|
if (needs.deltaSpecs && deltas.length === 0) {
|
|
201
215
|
issues.push(`no delta specs under specs/ (required at ceremony level ${level})`);
|
|
202
216
|
}
|
|
217
|
+
if (needs.bugfix) {
|
|
218
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
219
|
+
if (fs.existsSync(bugPath) && preventionRequiresDelta(fs.readFileSync(bugPath, "utf8"))) {
|
|
220
|
+
if (deltas.length === 0) {
|
|
221
|
+
issues.push("prevention §7 indicates a missing canonical requirement — add a delta spec under specs/");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
203
225
|
// Scope-growth: when remeasure targets provided (or change.json has prior signals
|
|
204
226
|
// with paths we cannot recover), only check if caller passes targets.
|
|
205
227
|
if (remeasure && (remeasure.paths.length > 0 || remeasure.symbols.length > 0)) {
|
|
@@ -315,8 +337,9 @@ export function specArchivePreconditions(projectPath, change) {
|
|
|
315
337
|
return [`change "${change}" not found under lawbook/changes/`];
|
|
316
338
|
const blockers = [];
|
|
317
339
|
const level = confirmedLevel(projectPath, change);
|
|
318
|
-
const
|
|
319
|
-
|
|
340
|
+
const changeType = readChangeType(projectPath, change);
|
|
341
|
+
const needs = artifactNeeds(level, changeType);
|
|
342
|
+
// 1. Every task must be checked (tasks.md, record.md, or bugfix checklist at level 0).
|
|
320
343
|
if (needs.tasksFile) {
|
|
321
344
|
const tasksPath = path.join(changeDir, "tasks.md");
|
|
322
345
|
if (!fs.existsSync(tasksPath)) {
|
|
@@ -339,12 +362,32 @@ export function specArchivePreconditions(projectPath, change) {
|
|
|
339
362
|
blockers.push(`${unchecked} unchecked task(s) in record.md`);
|
|
340
363
|
}
|
|
341
364
|
}
|
|
365
|
+
else if (needs.bugfix && level === 0) {
|
|
366
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
367
|
+
if (fs.existsSync(bugPath)) {
|
|
368
|
+
const unchecked = countUncheckedTasks(fs.readFileSync(bugPath, "utf8"));
|
|
369
|
+
if (unchecked > 0)
|
|
370
|
+
blockers.push(`${unchecked} unchecked item(s) in bugfix.md checklist`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (needs.bugfix) {
|
|
374
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
375
|
+
if (fs.existsSync(bugPath)) {
|
|
376
|
+
const content = fs.readFileSync(bugPath, "utf8");
|
|
377
|
+
const bugIssues = validateBugfixContent(level, content);
|
|
378
|
+
blockers.push(...bugIssues.filter((i) => i.includes("Regression test") || i.includes("§6")));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
342
381
|
// 2. At least one discipline report must exist (README.md scaffold aside).
|
|
343
382
|
if (!hasDisciplineReport(changeDir)) {
|
|
344
383
|
blockers.push("no discipline report under reports/ (build must record what was tested)");
|
|
345
384
|
}
|
|
346
|
-
// 3. Delta specs must already be synced —
|
|
347
|
-
|
|
385
|
+
// 3. Delta specs must already be synced — when required or when bug prevention demands it.
|
|
386
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
387
|
+
const bugNeedsDelta = needs.bugfix &&
|
|
388
|
+
fs.existsSync(bugPath) &&
|
|
389
|
+
preventionRequiresDelta(fs.readFileSync(bugPath, "utf8"));
|
|
390
|
+
if (needs.deltaSpecs || bugNeedsDelta) {
|
|
348
391
|
for (const file of deltaSpecFiles(changeDir)) {
|
|
349
392
|
const rel = path.relative(path.join(changeDir, "specs"), file);
|
|
350
393
|
const canonical = path.join(root, "specs", rel);
|
|
@@ -381,7 +424,27 @@ export function specArchive(projectPath, change, date) {
|
|
|
381
424
|
throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
|
|
382
425
|
}
|
|
383
426
|
const level = confirmedLevel(projectPath, change);
|
|
384
|
-
const
|
|
427
|
+
const changeType = readChangeType(projectPath, change);
|
|
428
|
+
const needs = artifactNeeds(level, changeType);
|
|
429
|
+
// Record bug resolution before move.
|
|
430
|
+
if (changeType === "bug") {
|
|
431
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
432
|
+
if (fs.existsSync(bugPath)) {
|
|
433
|
+
const rec = readCeremonyRecord(projectPath, change);
|
|
434
|
+
if (rec) {
|
|
435
|
+
rec.changeType = "bug";
|
|
436
|
+
rec.resolution = inferBugResolution(fs.readFileSync(bugPath, "utf8"));
|
|
437
|
+
const cj = path.join(changeDir, "change.json");
|
|
438
|
+
fs.writeFileSync(cj, JSON.stringify(rec, null, 2) + "\n");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const bugPath = path.join(changeDir, "bugfix.md");
|
|
443
|
+
const shouldSync = needs.deltaSpecs ||
|
|
444
|
+
(changeType === "bug" &&
|
|
445
|
+
fs.existsSync(bugPath) &&
|
|
446
|
+
preventionRequiresDelta(fs.readFileSync(bugPath, "utf8")));
|
|
447
|
+
const sync = shouldSync
|
|
385
448
|
? specSync(projectPath, change)
|
|
386
449
|
: { change, promoted: [], created: [], updated: [] };
|
|
387
450
|
const { promoted, created, updated } = sync;
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { indexExists, openDb } from "../compass/db.js";
|
|
4
|
+
import { explore, impact, recall } from "../compass/query.js";
|
|
5
|
+
import { affectedTests } from "../compass/affected.js";
|
|
6
|
+
import { hotspots, coupling } from "../compass/hotspots.js";
|
|
7
|
+
import { isGitRepo } from "../../shared/git.js";
|
|
8
|
+
import { lastTouch } from "../../shared/git-history.js";
|
|
9
|
+
import { frameSymbolName, parseStackTrace, } from "./stack-parse.js";
|
|
10
|
+
const WEIGHTS = {
|
|
11
|
+
"stack-frame": 40,
|
|
12
|
+
"frame-caller": 25,
|
|
13
|
+
"frame-callee": 15,
|
|
14
|
+
hotspot: 20,
|
|
15
|
+
"temporal-coupling": 15,
|
|
16
|
+
"semantic-match": 10,
|
|
17
|
+
"hint-path": 8,
|
|
18
|
+
"recently-changed": 10,
|
|
19
|
+
};
|
|
20
|
+
function resolveAtLine(projectPath, file, line) {
|
|
21
|
+
if (!indexExists(projectPath))
|
|
22
|
+
return null;
|
|
23
|
+
const db = openDb(projectPath);
|
|
24
|
+
try {
|
|
25
|
+
const row = db
|
|
26
|
+
.prepare(`SELECT s.name, s.kind, s.start_line AS startLine, s.signature
|
|
27
|
+
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
28
|
+
WHERE f.path = ? AND s.start_line <= ? AND s.end_line >= ?
|
|
29
|
+
ORDER BY (s.end_line - s.start_line) ASC
|
|
30
|
+
LIMIT 1`)
|
|
31
|
+
.get(file, line, line);
|
|
32
|
+
if (!row)
|
|
33
|
+
return null;
|
|
34
|
+
return {
|
|
35
|
+
name: row.name,
|
|
36
|
+
kind: row.kind,
|
|
37
|
+
startLine: row.startLine,
|
|
38
|
+
signature: row.signature ?? undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
db.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function callerCount(projectPath, name) {
|
|
46
|
+
if (!indexExists(projectPath))
|
|
47
|
+
return 0;
|
|
48
|
+
const ex = explore(projectPath, name);
|
|
49
|
+
return ex.callers?.length ?? 0;
|
|
50
|
+
}
|
|
51
|
+
function addCandidate(map, key, c) {
|
|
52
|
+
const prev = map.get(key);
|
|
53
|
+
const weight = c.weight ?? WEIGHTS[c.reason];
|
|
54
|
+
const entry = prev ?? {
|
|
55
|
+
name: c.name,
|
|
56
|
+
kind: c.kind,
|
|
57
|
+
file: c.file,
|
|
58
|
+
startLine: c.startLine,
|
|
59
|
+
signature: c.signature,
|
|
60
|
+
reasons: [],
|
|
61
|
+
distanceFromFrame: c.distanceFromFrame,
|
|
62
|
+
callerCount: c.callerCount,
|
|
63
|
+
hotspotScore: c.hotspotScore,
|
|
64
|
+
};
|
|
65
|
+
entry.reasons.push({ reason: c.reason, weight, detail: c.detail });
|
|
66
|
+
entry.callerCount = Math.max(entry.callerCount, c.callerCount);
|
|
67
|
+
if (c.hotspotScore !== undefined) {
|
|
68
|
+
entry.hotspotScore = Math.max(entry.hotspotScore ?? 0, c.hotspotScore);
|
|
69
|
+
}
|
|
70
|
+
map.set(key, entry);
|
|
71
|
+
}
|
|
72
|
+
function scoreCandidate(c) {
|
|
73
|
+
let raw = c.reasons.reduce((s, r) => s + r.weight, 0);
|
|
74
|
+
raw /= Math.log2(c.callerCount + 2);
|
|
75
|
+
return Math.round(Math.min(100, Math.max(0, raw)));
|
|
76
|
+
}
|
|
77
|
+
function scanArchivedRootCauses(projectPath, symbol) {
|
|
78
|
+
const archiveRoot = path.join(projectPath, "lawbook", "changes", "archive");
|
|
79
|
+
if (!fs.existsSync(archiveRoot))
|
|
80
|
+
return [];
|
|
81
|
+
const hits = [];
|
|
82
|
+
for (const dir of fs.readdirSync(archiveRoot)) {
|
|
83
|
+
const bugfix = path.join(archiveRoot, dir, "bugfix.md");
|
|
84
|
+
if (!fs.existsSync(bugfix))
|
|
85
|
+
continue;
|
|
86
|
+
const text = fs.readFileSync(bugfix, "utf8");
|
|
87
|
+
if (text.includes(symbol))
|
|
88
|
+
hits.push(dir);
|
|
89
|
+
}
|
|
90
|
+
return hits;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Rank likely bug origins from the code graph and git history.
|
|
94
|
+
*/
|
|
95
|
+
export async function investigate(args) {
|
|
96
|
+
const maxSuspects = args.maxSuspects ?? 8;
|
|
97
|
+
const degraded = [];
|
|
98
|
+
const hintPaths = (args.hintPaths ?? []).map((p) => p.replace(/^\.\//, ""));
|
|
99
|
+
if (!args.stackTrace?.trim() && !args.symptom?.trim()) {
|
|
100
|
+
throw new Error("provide stackTrace or symptom");
|
|
101
|
+
}
|
|
102
|
+
if (args.stackTrace?.trim()) {
|
|
103
|
+
const parsed = parseStackTrace(args.projectPath, args.stackTrace);
|
|
104
|
+
if (parsed.format === "unknown" && parsed.frames.length === 0 && parsed.unresolved.length > 0) {
|
|
105
|
+
return {
|
|
106
|
+
suspects: [],
|
|
107
|
+
unresolvedFrames: parsed.unresolved,
|
|
108
|
+
degraded: [],
|
|
109
|
+
guidance: "Stack trace could not be parsed. speclaw indexes TS/JS/Python only — use `symptom` for prose triage.",
|
|
110
|
+
inputSymptom: args.stackTrace.split("\n")[0],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!indexExists(args.projectPath)) {
|
|
115
|
+
return {
|
|
116
|
+
suspects: [],
|
|
117
|
+
unresolvedFrames: [],
|
|
118
|
+
degraded: ["no-index"],
|
|
119
|
+
guidance: "No Compass index — run `speclaw index` first. Without the graph, suspects cannot be verified.",
|
|
120
|
+
inputSymptom: args.symptom ?? args.stackTrace?.split("\n")[0],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const candidates = new Map();
|
|
124
|
+
let unresolvedFrames = [];
|
|
125
|
+
let frames = [];
|
|
126
|
+
if (args.stackTrace?.trim()) {
|
|
127
|
+
const parsed = parseStackTrace(args.projectPath, args.stackTrace);
|
|
128
|
+
unresolvedFrames = parsed.unresolved;
|
|
129
|
+
frames = parsed.frames;
|
|
130
|
+
if (parsed.format === "unknown" && parsed.frames.length === 0) {
|
|
131
|
+
return {
|
|
132
|
+
suspects: [],
|
|
133
|
+
unresolvedFrames,
|
|
134
|
+
degraded: [],
|
|
135
|
+
guidance: "Stack trace could not be parsed. speclaw indexes TS/JS/Python only — use `symptom` for prose triage.",
|
|
136
|
+
inputSymptom: args.stackTrace.split("\n")[0],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
frames.forEach((frame, idx) => {
|
|
140
|
+
const dist = idx;
|
|
141
|
+
const atLine = resolveAtLine(args.projectPath, frame.file, frame.line);
|
|
142
|
+
const symName = atLine?.name ?? frameSymbolName(frame) ?? frame.fn;
|
|
143
|
+
if (atLine || symName) {
|
|
144
|
+
const name = atLine?.name ?? symName;
|
|
145
|
+
const cc = callerCount(args.projectPath, name);
|
|
146
|
+
addCandidate(candidates, `${frame.file}:${name}`, {
|
|
147
|
+
name,
|
|
148
|
+
kind: atLine?.kind ?? "function",
|
|
149
|
+
file: frame.file,
|
|
150
|
+
startLine: atLine?.startLine ?? frame.line,
|
|
151
|
+
signature: atLine?.signature,
|
|
152
|
+
reason: "stack-frame",
|
|
153
|
+
detail: `frame at ${frame.file}:${frame.line}`,
|
|
154
|
+
distanceFromFrame: dist,
|
|
155
|
+
callerCount: cc,
|
|
156
|
+
});
|
|
157
|
+
if (symName) {
|
|
158
|
+
const ex = explore(args.projectPath, name);
|
|
159
|
+
if (ex.found) {
|
|
160
|
+
for (const caller of ex.callers ?? []) {
|
|
161
|
+
addCandidate(candidates, `${caller.file}:${caller.name}`, {
|
|
162
|
+
name: caller.name,
|
|
163
|
+
kind: caller.kind,
|
|
164
|
+
file: caller.file,
|
|
165
|
+
startLine: caller.line,
|
|
166
|
+
reason: "frame-caller",
|
|
167
|
+
detail: `calls ${name} from the trace`,
|
|
168
|
+
distanceFromFrame: dist + 1,
|
|
169
|
+
callerCount: callerCount(args.projectPath, caller.name),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
for (const callee of ex.callees ?? []) {
|
|
173
|
+
if (!callee.file)
|
|
174
|
+
continue;
|
|
175
|
+
addCandidate(candidates, `${callee.file}:${callee.name}`, {
|
|
176
|
+
name: callee.name,
|
|
177
|
+
kind: "function",
|
|
178
|
+
file: callee.file,
|
|
179
|
+
startLine: callee.line,
|
|
180
|
+
reason: "frame-callee",
|
|
181
|
+
detail: `called by ${name} in the trace`,
|
|
182
|
+
distanceFromFrame: dist + 1,
|
|
183
|
+
callerCount: callerCount(args.projectPath, callee.name),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (args.symptom?.trim() && candidates.size === 0) {
|
|
192
|
+
try {
|
|
193
|
+
const hits = await recall(args.projectPath, args.symptom, 15);
|
|
194
|
+
for (const h of hits) {
|
|
195
|
+
addCandidate(candidates, `${h.file}:${h.name}`, {
|
|
196
|
+
name: h.name,
|
|
197
|
+
kind: h.kind,
|
|
198
|
+
file: h.file,
|
|
199
|
+
startLine: h.line,
|
|
200
|
+
signature: h.signature ?? undefined,
|
|
201
|
+
reason: "semantic-match",
|
|
202
|
+
detail: `semantic match for symptom`,
|
|
203
|
+
distanceFromFrame: null,
|
|
204
|
+
callerCount: callerCount(args.projectPath, h.name),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
degraded.push("no-embeddings");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Hotspots + coupling
|
|
213
|
+
const files = new Set([...candidates.values()].map((c) => c.file));
|
|
214
|
+
for (const f of frames)
|
|
215
|
+
files.add(f.file);
|
|
216
|
+
for (const h of hintPaths)
|
|
217
|
+
files.add(h);
|
|
218
|
+
try {
|
|
219
|
+
const hs = hotspots(args.projectPath, { days: 90, sortBy: "combined", limit: 200 });
|
|
220
|
+
let max = 0;
|
|
221
|
+
for (const h of hs.hotspots)
|
|
222
|
+
max = Math.max(max, h.combinedScore);
|
|
223
|
+
if (max <= 0)
|
|
224
|
+
degraded.push("no-hotspots");
|
|
225
|
+
else {
|
|
226
|
+
const hotspotByFile = new Map(hs.hotspots.map((h) => [h.file, h.combinedScore / max]));
|
|
227
|
+
for (const [file, score] of hotspotByFile) {
|
|
228
|
+
if (score < 0.3)
|
|
229
|
+
continue;
|
|
230
|
+
for (const [key, c] of candidates) {
|
|
231
|
+
if (c.file !== file)
|
|
232
|
+
continue;
|
|
233
|
+
addCandidate(candidates, key, {
|
|
234
|
+
...c,
|
|
235
|
+
reason: "hotspot",
|
|
236
|
+
detail: `hotspot score ${score.toFixed(2)} on ${file}`,
|
|
237
|
+
hotspotScore: score,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
degraded.push("no-hotspots");
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
const co = coupling(args.projectPath, file, { limit: 5 });
|
|
249
|
+
for (const p of co.partners) {
|
|
250
|
+
if (p.strength < 0.2)
|
|
251
|
+
continue;
|
|
252
|
+
for (const frameFile of frames.map((f) => f.file)) {
|
|
253
|
+
if (p.file === frameFile) {
|
|
254
|
+
for (const [key, c] of candidates) {
|
|
255
|
+
if (c.file === file) {
|
|
256
|
+
addCandidate(candidates, key, {
|
|
257
|
+
...c,
|
|
258
|
+
reason: "temporal-coupling",
|
|
259
|
+
detail: `temporally coupled to ${frameFile} (strength ${p.strength.toFixed(2)})`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
degraded.push("no-coupling");
|
|
270
|
+
}
|
|
271
|
+
if (isGitRepo(args.projectPath)) {
|
|
272
|
+
for (const [key, c] of candidates) {
|
|
273
|
+
const touch = lastTouch(args.projectPath, c.file);
|
|
274
|
+
if (touch) {
|
|
275
|
+
addCandidate(candidates, key, {
|
|
276
|
+
...c,
|
|
277
|
+
reason: "recently-changed",
|
|
278
|
+
detail: `last touched ${touch}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
degraded.push("no-git");
|
|
285
|
+
}
|
|
286
|
+
for (const h of hintPaths) {
|
|
287
|
+
for (const [key, c] of candidates) {
|
|
288
|
+
if (c.file === h || c.file.endsWith(h)) {
|
|
289
|
+
addCandidate(candidates, key, {
|
|
290
|
+
...c,
|
|
291
|
+
reason: "hint-path",
|
|
292
|
+
detail: `matches hint path ${h}`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
let suspects = [...candidates.values()]
|
|
298
|
+
.map((c) => ({
|
|
299
|
+
name: c.name,
|
|
300
|
+
kind: c.kind,
|
|
301
|
+
file: c.file,
|
|
302
|
+
startLine: c.startLine,
|
|
303
|
+
signature: c.signature,
|
|
304
|
+
score: scoreCandidate(c),
|
|
305
|
+
reasons: c.reasons,
|
|
306
|
+
distanceFromFrame: c.distanceFromFrame,
|
|
307
|
+
hotspotScore: c.hotspotScore,
|
|
308
|
+
coveringTests: [],
|
|
309
|
+
}))
|
|
310
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
311
|
+
// Covering tests for top candidates
|
|
312
|
+
for (const s of suspects.slice(0, 5)) {
|
|
313
|
+
try {
|
|
314
|
+
const at = affectedTests(args.projectPath, { files: [s.file] });
|
|
315
|
+
s.coveringTests = at.tests.map((t) => t.file).slice(0, 5);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
/* soft */
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (suspects.length > 0 && suspects.length < 3) {
|
|
322
|
+
// keep all when fewer than 3
|
|
323
|
+
}
|
|
324
|
+
else if (suspects.length > maxSuspects) {
|
|
325
|
+
suspects = suspects.slice(0, maxSuspects);
|
|
326
|
+
}
|
|
327
|
+
const top = suspects[0];
|
|
328
|
+
let blastRadiusSummary;
|
|
329
|
+
let priorFixes;
|
|
330
|
+
if (top) {
|
|
331
|
+
priorFixes = scanArchivedRootCauses(args.projectPath, top.name);
|
|
332
|
+
try {
|
|
333
|
+
const imp = impact(args.projectPath, { symbol: top.name, format: "grouped", maxDepth: 3 });
|
|
334
|
+
blastRadiusSummary = `${imp.totals.nodes} node(s) in ${imp.totals.modules} module(s) reachable from ${top.name}`;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
blastRadiusSummary = `(run compass_impact on ${top.name})`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const guidance = "Treat this ranking as evidence, not a verdict — read the top suspects yourself. " +
|
|
341
|
+
"Stack frames outrank graph neighbours; external/node_modules frames are excluded. " +
|
|
342
|
+
(priorFixes?.length
|
|
343
|
+
? `Prior archive(s) mention this symbol: ${priorFixes.join(", ")}.`
|
|
344
|
+
: "No matching archived bugfix root cause found.");
|
|
345
|
+
return {
|
|
346
|
+
suspects,
|
|
347
|
+
unresolvedFrames,
|
|
348
|
+
degraded: [...new Set(degraded)],
|
|
349
|
+
guidance,
|
|
350
|
+
inputSymptom: args.symptom ?? args.stackTrace?.split("\n")[0]?.trim(),
|
|
351
|
+
blastRadiusSummary,
|
|
352
|
+
priorFixes,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** Format investigate result as stable JSON text (deterministic key order). */
|
|
356
|
+
export function formatInvestigateResult(result) {
|
|
357
|
+
return JSON.stringify(result, null, 2);
|
|
358
|
+
}
|
|
@@ -34,7 +34,45 @@ const BUCKETS = {
|
|
|
34
34
|
affectedTests: [0, 3, 15, Infinity],
|
|
35
35
|
blastRadiusNodes: [2, 10, 50, Infinity],
|
|
36
36
|
};
|
|
37
|
-
export function artifactNeeds(level) {
|
|
37
|
+
export function artifactNeeds(level, changeType = "feature") {
|
|
38
|
+
if (changeType === "bug") {
|
|
39
|
+
switch (level) {
|
|
40
|
+
case 0:
|
|
41
|
+
return {
|
|
42
|
+
record: false,
|
|
43
|
+
proposal: false,
|
|
44
|
+
design: false,
|
|
45
|
+
tasksFile: false,
|
|
46
|
+
deltaSpecs: false,
|
|
47
|
+
reports: true,
|
|
48
|
+
designOptionalWithJustification: false,
|
|
49
|
+
bugfix: true,
|
|
50
|
+
};
|
|
51
|
+
case 1:
|
|
52
|
+
return {
|
|
53
|
+
record: false,
|
|
54
|
+
proposal: false,
|
|
55
|
+
design: false,
|
|
56
|
+
tasksFile: true,
|
|
57
|
+
deltaSpecs: false,
|
|
58
|
+
reports: true,
|
|
59
|
+
designOptionalWithJustification: false,
|
|
60
|
+
bugfix: true,
|
|
61
|
+
};
|
|
62
|
+
case 2:
|
|
63
|
+
case 3:
|
|
64
|
+
return {
|
|
65
|
+
record: false,
|
|
66
|
+
proposal: false,
|
|
67
|
+
design: true,
|
|
68
|
+
tasksFile: true,
|
|
69
|
+
deltaSpecs: false,
|
|
70
|
+
reports: true,
|
|
71
|
+
designOptionalWithJustification: false,
|
|
72
|
+
bugfix: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
38
76
|
switch (level) {
|
|
39
77
|
case 0:
|
|
40
78
|
return {
|
|
@@ -45,6 +83,7 @@ export function artifactNeeds(level) {
|
|
|
45
83
|
deltaSpecs: false,
|
|
46
84
|
reports: true,
|
|
47
85
|
designOptionalWithJustification: false,
|
|
86
|
+
bugfix: false,
|
|
48
87
|
};
|
|
49
88
|
case 1:
|
|
50
89
|
return {
|
|
@@ -55,6 +94,7 @@ export function artifactNeeds(level) {
|
|
|
55
94
|
deltaSpecs: true,
|
|
56
95
|
reports: true,
|
|
57
96
|
designOptionalWithJustification: false,
|
|
97
|
+
bugfix: false,
|
|
58
98
|
};
|
|
59
99
|
case 2:
|
|
60
100
|
return {
|
|
@@ -65,6 +105,7 @@ export function artifactNeeds(level) {
|
|
|
65
105
|
deltaSpecs: true,
|
|
66
106
|
reports: true,
|
|
67
107
|
designOptionalWithJustification: true,
|
|
108
|
+
bugfix: false,
|
|
68
109
|
};
|
|
69
110
|
case 3:
|
|
70
111
|
return {
|
|
@@ -75,9 +116,15 @@ export function artifactNeeds(level) {
|
|
|
75
116
|
deltaSpecs: true,
|
|
76
117
|
reports: true,
|
|
77
118
|
designOptionalWithJustification: false,
|
|
119
|
+
bugfix: false,
|
|
78
120
|
};
|
|
79
121
|
}
|
|
80
122
|
}
|
|
123
|
+
/** Read change type from change.json; missing ⇒ feature. */
|
|
124
|
+
export function readChangeType(projectPath, change) {
|
|
125
|
+
const rec = readCeremonyRecord(projectPath, change);
|
|
126
|
+
return rec?.changeType === "bug" ? "bug" : "feature";
|
|
127
|
+
}
|
|
81
128
|
function bucketPoints(value, edges, points) {
|
|
82
129
|
const i = edges.findIndex((max) => value <= max);
|
|
83
130
|
return points[i < 0 ? 3 : i];
|
|
@@ -370,7 +417,7 @@ export function scaffoldArtifactsForLevel(projectPath, change, level) {
|
|
|
370
417
|
const changeDir = path.join(projectPath, "lawbook", "changes", change);
|
|
371
418
|
if (!fs.existsSync(changeDir))
|
|
372
419
|
return;
|
|
373
|
-
const needs = artifactNeeds(level);
|
|
420
|
+
const needs = artifactNeeds(level, readChangeType(projectPath, change));
|
|
374
421
|
const recordPath = path.join(changeDir, "record.md");
|
|
375
422
|
const recordText = fs.existsSync(recordPath) ? fs.readFileSync(recordPath, "utf8") : "";
|
|
376
423
|
const ensure = (rel, content) => {
|
|
@@ -6,6 +6,7 @@ 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
8
|
import { handleLevel } from "./quick.js";
|
|
9
|
+
import { investigate, formatInvestigateResult } from "./investigate.js";
|
|
9
10
|
import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
|
|
10
11
|
import { buildDriftReport, renderDriftAgent } from "./drift.js";
|
|
11
12
|
const ASSETS = assetsDir(import.meta.url);
|
|
@@ -39,6 +40,13 @@ export function registerSpec(server, opts = {}) {
|
|
|
39
40
|
level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
|
|
40
41
|
reason: z.string().optional(),
|
|
41
42
|
}, async (args) => text(handleLevel(args)));
|
|
43
|
+
add("lawbook_investigate", "Rank bug origins from the graph. Pass stackTrace or symptom. Returns suspects with reasons — evidence, not a verdict.", {
|
|
44
|
+
projectPath: z.string(),
|
|
45
|
+
stackTrace: z.string().optional(),
|
|
46
|
+
symptom: z.string().optional(),
|
|
47
|
+
hintPaths: z.array(z.string()).optional(),
|
|
48
|
+
maxSuspects: z.number().int().min(1).max(25).optional(),
|
|
49
|
+
}, async (args) => text(formatInvestigateResult(await investigate(args))));
|
|
42
50
|
add("lawbook_validate", "Validate a change's proposal, tasks, and delta specs before build or sync.", { projectPath: z.string(), change: z.string() }, async ({ projectPath, change }) => text(specValidate(projectPath, change)));
|
|
43
51
|
add("lawbook_sync", "Promote a change's delta specs into canonical lawbook/specs/ without archiving.", { projectPath: z.string(), change: z.string() }, async ({ projectPath, change }) => text(specSync(projectPath, change)));
|
|
44
52
|
add("lawbook_archive", "Sync a change into canonical specs, then move it under changes/archive/.", {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const V8_NAMED = /^\s*at\s+(?:async\s+)?(?:(.+?)\s+\()?([^\s()]+):(\d+):(\d+)\)?\s*$/;
|
|
3
|
+
const V8_ANON = /^\s*at\s+([^\s()]+):(\d+):(\d+)\s*$/;
|
|
4
|
+
const PY_FRAME = /^\s*File\s+"([^"]+)",\s*line\s+(\d+)(?:,\s*in\s+(.+))?\s*$/;
|
|
5
|
+
function isExternal(rawPath) {
|
|
6
|
+
const n = rawPath.replace(/\\/g, "/");
|
|
7
|
+
return (n.includes("node_modules/") ||
|
|
8
|
+
n.includes("node:internal") ||
|
|
9
|
+
n.startsWith("node:") ||
|
|
10
|
+
n.includes("/lib/python") ||
|
|
11
|
+
n.includes("site-packages/"));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Map `dist/foo.js` → `src/foo.ts` when the basename matches (no silent guess
|
|
15
|
+
* beyond that convention).
|
|
16
|
+
*/
|
|
17
|
+
export function mapDistToSrc(rel) {
|
|
18
|
+
const n = rel.replace(/\\/g, "/");
|
|
19
|
+
if (!n.startsWith("dist/"))
|
|
20
|
+
return n;
|
|
21
|
+
const base = path.basename(n, path.extname(n));
|
|
22
|
+
return `src/${base}.ts`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Normalize an absolute or relative trace path to a project-relative path.
|
|
26
|
+
*
|
|
27
|
+
* @param projectPath - Project root used to strip prefixes.
|
|
28
|
+
* @param rawPath - Path as it appears in the trace.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeTracePath(projectPath, rawPath) {
|
|
31
|
+
let p = rawPath.replace(/\\/g, "/");
|
|
32
|
+
const root = projectPath.replace(/\\/g, "/");
|
|
33
|
+
if (p.startsWith(root + "/"))
|
|
34
|
+
p = p.slice(root.length + 1);
|
|
35
|
+
if (p.startsWith("file://")) {
|
|
36
|
+
try {
|
|
37
|
+
p = decodeURIComponent(new URL(p).pathname);
|
|
38
|
+
if (p.startsWith(root + "/"))
|
|
39
|
+
p = p.slice(root.length + 1);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* keep raw */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
p = p.replace(/^\.\//, "");
|
|
46
|
+
p = mapDistToSrc(p);
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
function parseV8Line(line, projectPath) {
|
|
50
|
+
const named = V8_NAMED.exec(line);
|
|
51
|
+
if (named) {
|
|
52
|
+
const rawPath = named[2];
|
|
53
|
+
if (isExternal(rawPath))
|
|
54
|
+
return { raw: line.trim(), reason: "external" };
|
|
55
|
+
const fn = named[1]?.trim() ?? "";
|
|
56
|
+
const file = normalizeTracePath(projectPath, rawPath);
|
|
57
|
+
return { fn, file, line: Number(named[3]), rawPath };
|
|
58
|
+
}
|
|
59
|
+
const anon = V8_ANON.exec(line);
|
|
60
|
+
if (anon) {
|
|
61
|
+
const rawPath = anon[1];
|
|
62
|
+
if (isExternal(rawPath))
|
|
63
|
+
return { raw: line.trim(), reason: "external" };
|
|
64
|
+
return {
|
|
65
|
+
fn: "",
|
|
66
|
+
file: normalizeTracePath(projectPath, rawPath),
|
|
67
|
+
line: Number(anon[2]),
|
|
68
|
+
rawPath,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function parsePythonLines(lines, projectPath) {
|
|
74
|
+
const pyFrames = [];
|
|
75
|
+
const unresolved = [];
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
const m = PY_FRAME.exec(line);
|
|
78
|
+
if (!m)
|
|
79
|
+
continue;
|
|
80
|
+
const rawPath = m[1];
|
|
81
|
+
if (isExternal(rawPath)) {
|
|
82
|
+
unresolved.push({ raw: line.trim(), reason: "external" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
pyFrames.push({
|
|
86
|
+
fn: m[3]?.trim() ?? "",
|
|
87
|
+
file: normalizeTracePath(projectPath, rawPath),
|
|
88
|
+
line: Number(m[2]),
|
|
89
|
+
rawPath,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Python traces list shallow→deep; invert to deepest-first like V8.
|
|
93
|
+
pyFrames.reverse();
|
|
94
|
+
return { frames: pyFrames, unresolved, format: "python" };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Parse a V8 (Node) or Python stack trace into own-project frames.
|
|
98
|
+
*
|
|
99
|
+
* @param projectPath - Project root for path normalization.
|
|
100
|
+
* @param stackTrace - Raw stack trace text.
|
|
101
|
+
*/
|
|
102
|
+
export function parseStackTrace(projectPath, stackTrace) {
|
|
103
|
+
const lines = stackTrace.split("\n");
|
|
104
|
+
const pyProbe = lines.some((l) => PY_FRAME.test(l));
|
|
105
|
+
if (pyProbe)
|
|
106
|
+
return parsePythonLines(lines, projectPath);
|
|
107
|
+
const frames = [];
|
|
108
|
+
const unresolved = [];
|
|
109
|
+
let sawV8 = false;
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const parsed = parseV8Line(line, projectPath);
|
|
112
|
+
if (!parsed)
|
|
113
|
+
continue;
|
|
114
|
+
sawV8 = true;
|
|
115
|
+
if ("reason" in parsed)
|
|
116
|
+
unresolved.push(parsed);
|
|
117
|
+
else
|
|
118
|
+
frames.push(parsed);
|
|
119
|
+
}
|
|
120
|
+
if (!sawV8 && stackTrace.trim()) {
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (line.trim())
|
|
123
|
+
unresolved.push({ raw: line.trim(), reason: "unparseable" });
|
|
124
|
+
}
|
|
125
|
+
return { frames: [], unresolved, format: "unknown" };
|
|
126
|
+
}
|
|
127
|
+
return { frames, unresolved, format: "v8" };
|
|
128
|
+
}
|
|
129
|
+
/** Extract a simple method name from `Class.method` frames. */
|
|
130
|
+
export function frameSymbolName(frame) {
|
|
131
|
+
if (!frame.fn)
|
|
132
|
+
return "";
|
|
133
|
+
const dot = frame.fn.lastIndexOf(".");
|
|
134
|
+
return dot >= 0 ? frame.fn.slice(dot + 1) : frame.fn;
|
|
135
|
+
}
|
package/dist/shared/exposure.js
CHANGED