@esneiderbravo/speclaw 0.4.0 → 1.0.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.
Files changed (45) hide show
  1. package/README.md +88 -72
  2. package/dist/cli/commands/index-build.js +12 -3
  3. package/dist/cli/commands/lawbook.js +1 -0
  4. package/dist/cli/commands/laws.js +149 -8
  5. package/dist/cli/commands/owners.js +44 -0
  6. package/dist/cli/commands/query.js +32 -10
  7. package/dist/cli/commands/update.js +28 -0
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +13 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/embed-input.js +28 -0
  13. package/dist/modules/compass/embedder.js +3 -1
  14. package/dist/modules/compass/explore-rich.js +10 -5
  15. package/dist/modules/compass/extract.js +86 -0
  16. package/dist/modules/compass/hybrid.js +318 -0
  17. package/dist/modules/compass/indexer.js +204 -33
  18. package/dist/modules/compass/merkle.js +76 -0
  19. package/dist/modules/compass/pagerank.js +122 -0
  20. package/dist/modules/compass/rank.js +95 -0
  21. package/dist/modules/compass/register.js +8 -4
  22. package/dist/modules/foundation/check.js +4 -2
  23. package/dist/modules/foundation/compile-laws.js +212 -0
  24. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  25. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  26. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  27. package/dist/modules/foundation/dialects/copilot.js +35 -0
  28. package/dist/modules/foundation/dialects/index.js +5 -0
  29. package/dist/modules/foundation/dialects/types.js +58 -0
  30. package/dist/modules/foundation/doctor.js +220 -14
  31. package/dist/modules/foundation/import-rules.js +67 -0
  32. package/dist/modules/foundation/integrity.js +307 -0
  33. package/dist/modules/foundation/laws-parse.js +131 -0
  34. package/dist/modules/foundation/laws.js +5 -0
  35. package/dist/modules/foundation/lock.js +283 -0
  36. package/dist/modules/foundation/ownership.js +4 -0
  37. package/dist/modules/foundation/scaffold.js +25 -0
  38. package/dist/modules/foundation/scan.js +227 -0
  39. package/dist/modules/foundation/verify.js +9 -1
  40. package/dist/modules/lawbook/coverage.js +45 -6
  41. package/dist/modules/lawbook/ears.js +417 -0
  42. package/dist/modules/lawbook/engine.js +29 -0
  43. package/dist/modules/lawbook/spec-items.js +4 -1
  44. package/dist/modules/team/owners.js +464 -0
  45. package/package.json +4 -3
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { EMBED_INPUT_VERSION } from "./embed-input.js";
2
3
  /**
3
4
  * Split identifiers into lowercase subtokens.
4
5
  *
@@ -23,7 +24,8 @@ export function tokenize(text) {
23
24
  */
24
25
  export class LexicalEmbedder {
25
26
  dim;
26
- id = "lexical-hash-v1";
27
+ /** Identity includes embed-input recipe version so cache invalidates on bump. */
28
+ id = `lexical-hash-v1+${EMBED_INPUT_VERSION}`;
27
29
  constructor(dim = 256) {
28
30
  this.dim = dim;
29
31
  }
@@ -1,4 +1,4 @@
1
- import { explore, impact, trace, search, recall } from "./query.js";
1
+ import { explore, impact, trace } from "./query.js";
2
2
  import { affectedTests } from "./affected.js";
3
3
  import { hotspots } from "./hotspots.js";
4
4
  import { summarizeImpact } from "./impact-summary.js";
@@ -117,10 +117,15 @@ export async function exploreRich(query) {
117
117
  return out;
118
118
  }
119
119
  /** Merge lexical and semantic search behind one surface. */
120
- export async function findSymbols(projectPath, query, mode, limit) {
121
- if (mode === "exact")
122
- return search(projectPath, query, limit ?? 25);
123
- return recall(projectPath, query, limit ?? 15);
120
+ export async function findSymbols(projectPath, query, mode, limit, opts) {
121
+ const { hybridSearch } = await import("./hybrid.js");
122
+ const result = await hybridSearch(projectPath, query, {
123
+ mode,
124
+ focus: opts?.focus,
125
+ maxTokens: opts?.maxTokens,
126
+ seedLimit: limit ?? 50,
127
+ });
128
+ return result;
124
129
  }
125
130
  /** Serialize explore-rich with output budget applied. */
126
131
  export function formatExploreRich(result, mode = "brief") {
@@ -1,5 +1,6 @@
1
1
  import { parse } from "./parser.js";
2
2
  import { rawHash, structuralHash } from "./hash.js";
3
+ import { tokenize } from "./embedder.js";
3
4
  const COMMENT_TYPES = new Set(["comment", "line_comment", "block_comment"]);
4
5
  /** `Covers:` / `Needs:` / `@covers` at the start of a comment line. */
5
6
  const RE_DIRECTIVE = /(?:^|\s|\*)\s*(?:@)?(covers|needs)\s*:?\s+([^\n*]+)/i;
@@ -37,6 +38,89 @@ function calleeName(node, lang) {
37
38
  function signatureOf(node) {
38
39
  return node.text.split("\n")[0].trim().slice(0, 200);
39
40
  }
41
+ /** Strip comment delimiters from a block/line comment. */
42
+ function stripCommentText(raw) {
43
+ return raw
44
+ .replace(/^\/\*\*?/, "")
45
+ .replace(/\*\/$/, "")
46
+ .replace(/^\/\//, "")
47
+ .replace(/^\s*\*/gm, "")
48
+ .trim()
49
+ .slice(0, 2000);
50
+ }
51
+ /**
52
+ * Docstring for a definition: prior block/JSDoc comment (TS/JS) or first string
53
+ * literal in the body (Python).
54
+ *
55
+ * @param node - Definition AST node.
56
+ * @param lang - Language config (`id` selects strategy).
57
+ */
58
+ export function docstringOf(node, lang) {
59
+ if (lang.id === "python") {
60
+ const body = node.childForFieldName("body");
61
+ if (body) {
62
+ for (let i = 0; i < body.childCount; i++) {
63
+ const child = body.child(i);
64
+ if (!child)
65
+ continue;
66
+ if (child.type === "expression_statement") {
67
+ const inner = child.child(0);
68
+ if (inner && (inner.type === "string" || inner.type === "concatenated_string")) {
69
+ return inner.text
70
+ .replace(/^['"]{1,3}|['"]{1,3}$/g, "")
71
+ .trim()
72
+ .slice(0, 2000);
73
+ }
74
+ }
75
+ if (COMMENT_TYPES.has(child.type))
76
+ continue;
77
+ break;
78
+ }
79
+ }
80
+ return "";
81
+ }
82
+ let prev = node.previousSibling;
83
+ while (prev) {
84
+ if (COMMENT_TYPES.has(prev.type)) {
85
+ const t = prev.text.trim();
86
+ if (t.startsWith("/**") || t.startsWith("/*") || t.startsWith("//")) {
87
+ return stripCommentText(t);
88
+ }
89
+ prev = prev.previousSibling;
90
+ continue;
91
+ }
92
+ if (prev.type === "decorator" || prev.type === "decorator_list") {
93
+ prev = prev.previousSibling;
94
+ continue;
95
+ }
96
+ break;
97
+ }
98
+ // JSDoc often sits before `export function` / `export class` (parent statement).
99
+ const parent = node.parent;
100
+ if (parent && (parent.type === "export_statement" || parent.type === "lexical_declaration")) {
101
+ let p = parent.previousSibling;
102
+ while (p) {
103
+ if (COMMENT_TYPES.has(p.type)) {
104
+ const t = p.text.trim();
105
+ if (t.startsWith("/**") || t.startsWith("/*") || t.startsWith("//")) {
106
+ return stripCommentText(t);
107
+ }
108
+ p = p.previousSibling;
109
+ continue;
110
+ }
111
+ break;
112
+ }
113
+ }
114
+ return "";
115
+ }
116
+ /**
117
+ * Space-separated lowercase subtokens of a symbol name for FTS.
118
+ *
119
+ * @param name - Identifier.
120
+ */
121
+ export function nameSubtokens(name) {
122
+ return tokenize(name).join(" ");
123
+ }
40
124
  const BOOL_OPS = new Set(["&&", "||", "and", "or"]);
41
125
  /**
42
126
  * Compute LOC / max nesting / branch counts for a definition subtree.
@@ -155,6 +239,8 @@ export async function extract(source, lang) {
155
239
  endByte: node.endIndex,
156
240
  parentIndex: ownerIndex,
157
241
  signature: signatureOf(node),
242
+ docstring: docstringOf(node, lang),
243
+ subtokens: nameSubtokens(name),
158
244
  bodyHash: rawHash(source, node.startIndex, node.endIndex),
159
245
  normHash: structuralHash(node),
160
246
  loc: health.loc,
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Hybrid retrieval pipeline: BM25 + KNN + name → RRF → ego expand →
3
+ * personalized PageRank → structural rerank → token budget.
4
+ */
5
+ import { openDb, indexExists, ftsAvailable } from "./db.js";
6
+ import { getEmbedder, fromBlob, cosine } from "./embedder.js";
7
+ import { isGitRepo, worktreeChangedFiles } from "../../shared/git.js";
8
+ import { escapeFtsQuery, nameBoost, routeWeights, rrfFuse, structuralScore, MAX_DEGREE, isSymbolQuery, } from "./rank.js";
9
+ import { personalizedPageRank, edgeWeightMul } from "./pagerank.js";
10
+ import { defaultBudget, fitToBudget } from "./budget.js";
11
+ function requireIndex(projectPath) {
12
+ if (!indexExists(projectPath)) {
13
+ throw new Error("No index found. Build it first with the index_build tool (creates .speclaw/index.db).");
14
+ }
15
+ }
16
+ /**
17
+ * Resolve focus paths: explicit list, else worktree changes, else empty.
18
+ *
19
+ * @param projectPath - Project root.
20
+ * @param focus - Optional explicit paths.
21
+ */
22
+ export function resolveFocus(projectPath, focus) {
23
+ if (focus && focus.length > 0)
24
+ return [...new Set(focus)];
25
+ if (!isGitRepo(projectPath))
26
+ return [];
27
+ return worktreeChangedFiles(projectPath);
28
+ }
29
+ /**
30
+ * Hybrid code search over the local Compass index.
31
+ *
32
+ * @param projectPath - Absolute project root.
33
+ * @param query - Identifier or prose.
34
+ * @param opts - Focus, budget, mode.
35
+ */
36
+ export async function hybridSearch(projectPath, query, opts = {}) {
37
+ requireIndex(projectPath);
38
+ const degraded = [];
39
+ const q = query.trim();
40
+ const focus = resolveFocus(projectPath, opts.focus);
41
+ const focusSet = new Set(focus);
42
+ const mode = opts.mode;
43
+ const route = mode === "exact"
44
+ ? "symbol"
45
+ : mode === "concept"
46
+ ? "prose"
47
+ : isSymbolQuery(q)
48
+ ? "symbol"
49
+ : "prose";
50
+ const weights = route === "symbol" ? { bm25: 1.0, knn: 0.3, name: 1.0 } : routeWeights(q); // concept / prose
51
+ if (mode === "concept") {
52
+ weights.bm25 = 0.7;
53
+ weights.knn = 1.0;
54
+ weights.name = 0.5;
55
+ }
56
+ const budget = opts.maxTokens ?? defaultBudget(focus.length > 0);
57
+ const seedLimit = opts.seedLimit ?? 50;
58
+ const db = openDb(projectPath);
59
+ try {
60
+ const hasFts = ftsAvailable(db);
61
+ if (!hasFts)
62
+ degraded.push("fts5-unavailable");
63
+ const bm25Ids = [];
64
+ const bm25Rank = new Map();
65
+ if (hasFts && q) {
66
+ const match = escapeFtsQuery(q);
67
+ if (match) {
68
+ try {
69
+ const rows = db
70
+ .prepare(`SELECT f.rowid AS node_id
71
+ FROM nodes_fts f
72
+ WHERE nodes_fts MATCH ?
73
+ ORDER BY bm25(nodes_fts, 10.0, 4.0, 2.0, 1.0) ASC
74
+ LIMIT ?`)
75
+ .all(match, seedLimit);
76
+ rows.forEach((r, i) => {
77
+ bm25Ids.push(r.node_id);
78
+ bm25Rank.set(r.node_id, i + 1);
79
+ });
80
+ }
81
+ catch {
82
+ degraded.push("fts5-query-error");
83
+ }
84
+ }
85
+ }
86
+ const knnIds = [];
87
+ const knnRank = new Map();
88
+ const embedder = getEmbedder();
89
+ const embRows = db
90
+ .prepare(`SELECT n.id AS node_id, e.vec
91
+ FROM node_embeddings e
92
+ JOIN nodes n ON n.id = e.node_id
93
+ WHERE e.dim = ?`)
94
+ .all(embedder.dim);
95
+ if (embRows.length === 0) {
96
+ degraded.push("no-embeddings");
97
+ }
98
+ else if (q) {
99
+ const qvec = await embedder.embed(q);
100
+ const scored = embRows.map((r) => ({
101
+ id: r.node_id,
102
+ score: cosine(qvec, fromBlob(r.vec)),
103
+ }));
104
+ scored.sort((a, b) => b.score - a.score);
105
+ scored.slice(0, seedLimit).forEach((r, i) => {
106
+ knnIds.push(r.id);
107
+ knnRank.set(r.id, i + 1);
108
+ });
109
+ }
110
+ const nameIds = [];
111
+ const nameRank = new Map();
112
+ if (q) {
113
+ const likeRows = db
114
+ .prepare(`SELECT n.id
115
+ FROM nodes n
116
+ WHERE n.name LIKE ?
117
+ ORDER BY (n.name = ?) DESC, length(n.name) ASC
118
+ LIMIT 20`)
119
+ .all(`%${q}%`, q);
120
+ likeRows.forEach((r, i) => {
121
+ nameIds.push(r.id);
122
+ nameRank.set(r.id, i + 1);
123
+ });
124
+ }
125
+ const fused = rrfFuse({ bm25: bm25Ids, knn: knnIds, name: nameIds }, { bm25: weights.bm25, knn: weights.knn, name: weights.name });
126
+ // Apply name boost using node names.
127
+ const idMeta = new Map();
128
+ const loadMeta = db.prepare(`SELECT n.id, n.name, n.kind, f.path AS file, n.start_line AS line, n.signature
129
+ FROM nodes n JOIN files f ON f.id = n.file_id WHERE n.id = ?`);
130
+ for (const id of fused.keys()) {
131
+ const row = loadMeta.get(id);
132
+ if (row) {
133
+ idMeta.set(id, {
134
+ name: row.name,
135
+ kind: row.kind,
136
+ file: row.file,
137
+ line: row.line,
138
+ signature: row.signature,
139
+ });
140
+ const boosted = (fused.get(id) ?? 0) * nameBoost(row.name, q);
141
+ fused.set(id, boosted);
142
+ }
143
+ }
144
+ let seeds = [...fused.entries()].sort((a, b) => b[1] - a[1]).slice(0, seedLimit);
145
+ // Empty / stopword query: top by global pagerank in focus (or global).
146
+ if (!q || seeds.length === 0) {
147
+ const rows = focus.length > 0
148
+ ? db
149
+ .prepare(`SELECT n.id, pr.score FROM pagerank pr
150
+ JOIN nodes n ON n.id = pr.node_id
151
+ JOIN files f ON f.id = n.file_id
152
+ WHERE f.path IN (${focus.map(() => "?").join(",")})
153
+ ORDER BY pr.score DESC LIMIT ?`)
154
+ .all(...focus, seedLimit)
155
+ : db
156
+ .prepare(`SELECT n.id, pr.score FROM pagerank pr
157
+ JOIN nodes n ON n.id = pr.node_id
158
+ ORDER BY pr.score DESC LIMIT ?`)
159
+ .all(seedLimit);
160
+ seeds = rows.map((r) => [r.id, r.score]);
161
+ for (const [id] of seeds) {
162
+ if (!idMeta.has(id)) {
163
+ const row = loadMeta.get(id);
164
+ if (row)
165
+ idMeta.set(id, row);
166
+ }
167
+ }
168
+ }
169
+ // Ego-graph expand (1-hop, degree-capped).
170
+ const expanded = new Set(seeds.map(([id]) => id));
171
+ const hopOf = new Map();
172
+ for (const [id] of seeds)
173
+ hopOf.set(id, 0);
174
+ const neigh = db.prepare(`SELECT dst_node_id AS id FROM edges
175
+ WHERE src_node_id = ? AND kind = 'call' AND dst_node_id IS NOT NULL
176
+ UNION
177
+ SELECT src_node_id AS id FROM edges
178
+ WHERE dst_node_id = ? AND kind = 'call' AND src_node_id IS NOT NULL`);
179
+ for (const [id] of seeds) {
180
+ const rows = neigh.all(id, id);
181
+ const capped = rows.slice(0, MAX_DEGREE);
182
+ for (const r of capped) {
183
+ if (!expanded.has(r.id)) {
184
+ expanded.add(r.id);
185
+ hopOf.set(r.id, 1);
186
+ if (!idMeta.has(r.id)) {
187
+ const row = loadMeta.get(r.id);
188
+ if (row)
189
+ idMeta.set(r.id, row);
190
+ }
191
+ }
192
+ }
193
+ }
194
+ // Personalized PageRank on ego subgraph (symbols only for score table).
195
+ const files = db.prepare("SELECT id, path FROM files").all();
196
+ const pathByFileId = new Map(files.map((f) => [f.id, f.path]));
197
+ const fileIdByPath = new Map(files.map((f) => [f.path, f.id]));
198
+ const fileNodeId = (fid) => -fid;
199
+ const subgraphNodes = [...expanded];
200
+ const fileIdsNeeded = new Set();
201
+ for (const id of subgraphNodes) {
202
+ const meta = idMeta.get(id);
203
+ if (meta) {
204
+ const fid = fileIdByPath.get(meta.file);
205
+ if (fid !== undefined)
206
+ fileIdsNeeded.add(fid);
207
+ }
208
+ }
209
+ const prNodeIds = [...subgraphNodes, ...[...fileIdsNeeded].map((fid) => fileNodeId(fid))];
210
+ const defCount = new Map();
211
+ const allNames = db.prepare("SELECT name FROM nodes").all();
212
+ for (const r of allNames)
213
+ defCount.set(r.name, (defCount.get(r.name) ?? 0) + 1);
214
+ const refCount = new Map();
215
+ const mentioned = new Set(q.split(/[^A-Za-z0-9_$]+/).filter((t) => t.length > 1));
216
+ const prEdges = [];
217
+ for (const fid of fileIdsNeeded) {
218
+ const kids = db.prepare("SELECT id, name FROM nodes WHERE file_id = ?").all(fid);
219
+ for (const k of kids) {
220
+ if (!expanded.has(k.id))
221
+ continue;
222
+ prEdges.push({ from: fileNodeId(fid), to: k.id, weight: 1 });
223
+ }
224
+ }
225
+ const edgeRows = subgraphNodes.length === 0
226
+ ? []
227
+ : db
228
+ .prepare(`SELECT e.src_node_id, e.dst_node_id, e.dst_name, e.src_file_id
229
+ FROM edges e
230
+ WHERE e.kind = 'call'
231
+ AND e.src_node_id IS NOT NULL AND e.dst_node_id IS NOT NULL
232
+ AND e.src_node_id IN (${subgraphNodes.map(() => "?").join(",")})
233
+ AND e.dst_node_id IN (${subgraphNodes.map(() => "?").join(",")})`)
234
+ .all(...subgraphNodes, ...subgraphNodes);
235
+ for (const e of edgeRows) {
236
+ refCount.set(e.dst_name, (refCount.get(e.dst_name) ?? 0) + 1);
237
+ const srcPath = pathByFileId.get(e.src_file_id) ?? "";
238
+ const w = edgeWeightMul(e.dst_name, srcPath, {
239
+ mentionedIdents: mentioned,
240
+ focusFiles: focusSet,
241
+ defCount,
242
+ refCount,
243
+ });
244
+ prEdges.push({ from: e.src_node_id, to: e.dst_node_id, weight: w });
245
+ }
246
+ const personalize = focus
247
+ .map((p) => fileIdByPath.get(p))
248
+ .filter((id) => id !== undefined)
249
+ .map(fileNodeId);
250
+ const prScores = subgraphNodes.length > 0
251
+ ? personalizedPageRank(prNodeIds, prEdges, personalize)
252
+ : new Map();
253
+ // Fallback to global pagerank table when PR missing.
254
+ const globalPr = db.prepare("SELECT score FROM pagerank WHERE node_id = ?");
255
+ // Churn: cheap proxy — skip full git history; use 0 when unavailable.
256
+ const commits30 = 0;
257
+ const seedMap = new Map(seeds);
258
+ const candidates = [...expanded].filter((id) => idMeta.has(id));
259
+ const ranked = candidates
260
+ .map((id) => {
261
+ const meta = idMeta.get(id);
262
+ const seed = seedMap.get(id) ?? (prScores.get(id) ?? 0) * 0.01;
263
+ const pagerank = prScores.get(id) ?? globalPr.get(id)?.score ?? 1e-9;
264
+ const hops = hopOf.get(id) ?? 2;
265
+ const isDef = /^(function|class|method|type|interface|enum)$/.test(meta.kind);
266
+ const score = structuralScore(seed, {
267
+ pagerank,
268
+ commits30d: commits30,
269
+ hopsToFocus: focus.length === 0 ? 0 : hops,
270
+ isDefinition: isDef,
271
+ });
272
+ return {
273
+ nodeId: id,
274
+ name: meta.name,
275
+ kind: meta.kind,
276
+ file: meta.file,
277
+ line: meta.line,
278
+ signature: meta.signature,
279
+ signals: {
280
+ bm25Rank: bm25Rank.get(id),
281
+ knnRank: knnRank.get(id),
282
+ nameRank: nameRank.get(id),
283
+ pagerank,
284
+ hops,
285
+ score,
286
+ },
287
+ };
288
+ })
289
+ .sort((a, b) => b.signals.score - a.signals.score);
290
+ if (focus.length > 0) {
291
+ const known = new Set(files.map((f) => f.path));
292
+ if (focus.every((p) => !known.has(p))) {
293
+ degraded.push("focus-unindexed");
294
+ }
295
+ }
296
+ const budgetHits = ranked.map((h) => ({
297
+ name: h.name,
298
+ kind: h.kind,
299
+ file: h.file,
300
+ line: h.line,
301
+ signature: h.signature,
302
+ }));
303
+ const fitted = fitToBudget(budgetHits, budget);
304
+ const finalHits = ranked.slice(0, fitted.hitCount);
305
+ return {
306
+ rendered: fitted.rendered,
307
+ tokens: fitted.tokens,
308
+ budget: fitted.budget,
309
+ route,
310
+ focus,
311
+ hits: finalHits,
312
+ degraded,
313
+ };
314
+ }
315
+ finally {
316
+ db.close();
317
+ }
318
+ }