@esneiderbravo/speclaw 0.3.12 → 0.4.0
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 -2
- package/dist/cli/commands/lawbook.js +42 -1
- package/dist/cli/commands/query.js +20 -0
- package/dist/cli/commands/update.js +10 -0
- package/dist/cli/index.js +8 -0
- package/dist/modules/compass/diff-context.js +134 -0
- package/dist/modules/compass/explore-rich.js +129 -0
- package/dist/modules/compass/impact-summary.js +33 -0
- package/dist/modules/compass/register.js +164 -74
- package/dist/modules/foundation/context-budget.js +1 -14
- package/dist/modules/foundation/doctor.js +77 -0
- package/dist/modules/foundation/register-core.js +57 -88
- package/dist/modules/foundation/register.js +1 -21
- package/dist/modules/foundation/setup-tool.js +96 -0
- package/dist/modules/lawbook/assets/commands/archive.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +1 -1
- package/dist/modules/lawbook/assets/commands/explore.md +1 -1
- package/dist/modules/lawbook/assets/commands/investigate.md +7 -0
- package/dist/modules/lawbook/assets/commands/sync.md +2 -2
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +8 -0
- package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
- package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +1 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
- 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/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
- package/dist/modules/lawbook/bugfix.js +195 -0
- package/dist/modules/lawbook/change-tool.js +90 -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 +102 -52
- package/dist/modules/lawbook/stack-parse.js +135 -0
- package/dist/modules/tools/register.js +4 -26
- package/dist/shared/deprecation.js +99 -0
- package/dist/shared/exposure.js +5 -19
- package/dist/shared/git.js +25 -0
- package/dist/shared/mcp.js +29 -3
- package/dist/shared/output-budget.js +68 -0
- package/dist/shared/tool-catalog.js +49 -0
- package/package.json +1 -1
|
@@ -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) => {
|