@esneiderbravo/speclaw 0.3.11 → 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.
Files changed (29) hide show
  1. package/dist/cli/commands/lawbook.js +84 -6
  2. package/dist/cli/commands/quick.js +35 -0
  3. package/dist/cli/commands/update.js +18 -0
  4. package/dist/cli/index.js +17 -1
  5. package/dist/modules/foundation/doctor.js +94 -0
  6. package/dist/modules/lawbook/assets/commands/archive.md +5 -6
  7. package/dist/modules/lawbook/assets/commands/draft.md +6 -7
  8. package/dist/modules/lawbook/assets/commands/investigate.md +7 -0
  9. package/dist/modules/lawbook/assets/commands/quick.md +14 -0
  10. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +8 -0
  11. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +4 -3
  12. package/dist/modules/lawbook/assets/skills/draft/SKILL.md +1 -1
  13. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +3 -0
  14. package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +29 -25
  15. package/dist/modules/lawbook/assets/skills/investigate/SKILL.md +10 -0
  16. package/dist/modules/lawbook/assets/skills/investigate/steps/01-investigate.md +7 -0
  17. package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +6 -0
  18. package/dist/modules/lawbook/assets/skills/quick/SKILL.md +11 -0
  19. package/dist/modules/lawbook/assets/skills/quick/steps/01-scaffold.md +6 -0
  20. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +7 -0
  21. package/dist/modules/lawbook/bugfix.js +195 -0
  22. package/dist/modules/lawbook/engine.js +178 -55
  23. package/dist/modules/lawbook/investigate.js +358 -0
  24. package/dist/modules/lawbook/levels.js +468 -0
  25. package/dist/modules/lawbook/quick.js +86 -0
  26. package/dist/modules/lawbook/register.js +18 -0
  27. package/dist/modules/lawbook/stack-parse.js +135 -0
  28. package/dist/shared/exposure.js +2 -0
  29. package/package.json +1 -1
@@ -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
+ }