@esneiderbravo/speclaw 2.0.9 → 2.0.11

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.
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { openDb } from "./db.js";
3
+ import { openDb, FILE_NODE_KIND } from "./db.js";
4
4
  import { estimateTokens } from "../../shared/tokens.js";
5
5
  import { loadDeclaredBudget } from "../../shared/exposure.js";
6
6
  export const MAP_START = "<!-- speclaw:map:start -->";
@@ -19,7 +19,7 @@ export function generateCompactMap(projectPath) {
19
19
  const db = openDb(projectPath);
20
20
  try {
21
21
  const fileCount = db.prepare("SELECT COUNT(*) AS c FROM files").get().c;
22
- const nodeCount = db.prepare("SELECT COUNT(*) AS c FROM nodes").get().c;
22
+ const nodeCount = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE kind <> '${FILE_NODE_KIND}'`).get().c;
23
23
  if (fileCount === 0)
24
24
  return null;
25
25
  const hubs = db
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { openDb, indexExists } from "./db.js";
3
+ import { openDb, indexExists, FILE_NODE_KIND } from "./db.js";
4
+ import { BUILTIN_SQL_LIST } from "./indexer.js";
4
5
  import { getEmbedder, fromBlob, cosine } from "./embedder.js";
5
6
  import { loadAffectedConfig, filterFilesForTarget, matchGlobalFiles } from "./affected-config.js";
6
7
  function requireIndex(projectPath) {
@@ -32,8 +33,8 @@ export function search(projectPath, query, limit = 25) {
32
33
  const rows = db
33
34
  .prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
34
35
  FROM nodes s JOIN files f ON f.id = s.file_id
35
- WHERE s.name LIKE ? ESCAPE '\\'
36
- OR (? != '' AND f.path LIKE ? ESCAPE '\\')
36
+ WHERE s.kind <> '${FILE_NODE_KIND}'
37
+ AND (s.name LIKE ? ESCAPE '\\' OR (? != '' AND f.path LIKE ? ESCAPE '\\'))
37
38
  ORDER BY (s.name = ?) DESC, length(s.name) ASC, f.path ASC, s.start_line ASC
38
39
  LIMIT ?`)
39
40
  .all(`%${escapeLike(query)}%`, normalized, pathPattern, query, limit);
@@ -129,15 +130,18 @@ function resolveIndexedFile(db, projectPath, query) {
129
130
  return { status: "ambiguous", basename: normalized, files: suffix };
130
131
  return { status: "none" };
131
132
  }
132
- /** Definitions in one indexed file, ordered by source position. */
133
- function fileSymbols(db, file) {
133
+ /**
134
+ * Nodes in one indexed file, ordered by source position: the visible
135
+ * definitions, or with `fileNode` only its file-owner node.
136
+ */
137
+ function fileSymbols(db, file, fileNode = false) {
134
138
  return db
135
139
  .prepare(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.start_byte, s.end_byte,
136
140
  s.signature, f.path AS file
137
141
  FROM nodes s JOIN files f ON f.id = s.file_id
138
- WHERE f.path = ?
142
+ WHERE f.path = ? AND (s.kind = '${FILE_NODE_KIND}') = ?
139
143
  ORDER BY s.start_line ASC, s.id ASC`)
140
- .all(file);
144
+ .all(file, fileNode ? 1 : 0);
141
145
  }
142
146
  /** Symbols defined in `files`, stable across files. Empty when none exist. */
143
147
  function symbolsInFiles(db, files) {
@@ -147,7 +151,7 @@ function symbolsInFiles(db, files) {
147
151
  return db
148
152
  .prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
149
153
  FROM nodes s JOIN files f ON f.id = s.file_id
150
- WHERE f.path IN (${placeholders})
154
+ WHERE f.path IN (${placeholders}) AND s.kind <> '${FILE_NODE_KIND}'
151
155
  ORDER BY f.path ASC, s.start_line ASC, s.name ASC`)
152
156
  .all(...files);
153
157
  }
@@ -171,24 +175,43 @@ function pickPrimary(symbols) {
171
175
  * `otherMatches` (same-name siblings, or the rest of a file).
172
176
  */
173
177
  function assembleExplore(db, projectPath, best, others) {
178
+ // Covers: req~compass-mcp-surface~1
179
+ // Resolved callees only, one row per target node at its first call line, so
180
+ // unresolved names (builtins, member calls) never crowd out project callees.
174
181
  const callees = db
175
- .prepare(`SELECT e.dst_name AS name, e.line, f.path AS file
176
- FROM edges e LEFT JOIN nodes s ON s.id = e.dst_node_id
177
- LEFT JOIN files f ON f.id = s.file_id
182
+ .prepare(`SELECT s.name AS name, MIN(e.line) AS line, f.path AS file
183
+ FROM edges e JOIN nodes s ON s.id = e.dst_node_id
184
+ JOIN files f ON f.id = s.file_id
178
185
  WHERE e.src_node_id = ? AND e.kind = 'call'
179
- ORDER BY e.line`)
186
+ GROUP BY s.id
187
+ ORDER BY line, s.id`)
180
188
  .all(best.id);
181
- // Callers: match both the resolved edge AND any call by this name, so
182
- // dynamic dispatch (a method/function called by name across sites) is not
183
- // missed. Over-approximates conservatively — the point of a blast radius.
189
+ const unresolved = db
190
+ .prepare(`SELECT e.dst_name AS name, COUNT(*) AS n
191
+ FROM edges e
192
+ WHERE e.src_node_id = ? AND e.kind = 'call' AND e.dst_node_id IS NULL
193
+ GROUP BY e.dst_name
194
+ ORDER BY n DESC, e.dst_name ASC`)
195
+ .all(best.id);
196
+ // Callers: match both the resolved edge AND any non-member (`is_member = 0`)
197
+ // call by this name, so dynamic dispatch (a function called by name across sites) is not
198
+ // missed. Over-approximates conservatively — the point of a blast radius. A
199
+ // builtin global name (`test`, `fetch`) matches by name only within the
200
+ // definition's own file. A caller outside any definition is its file-owner
201
+ // node (`kind: "file"`).
184
202
  const callers = db
185
203
  .prepare(`SELECT DISTINCT owner.name AS name, owner.kind AS kind, f.path AS file, e.line
186
204
  FROM edges e
187
205
  JOIN nodes owner ON owner.id = e.src_node_id
188
206
  JOIN files f ON f.id = owner.file_id
189
- WHERE (e.dst_node_id = ? OR e.dst_name = ?) AND e.kind = 'call'
207
+ WHERE (e.dst_node_id = ? OR (
208
+ e.dst_name = ? AND e.is_member = 0
209
+ AND (e.dst_name NOT IN (${BUILTIN_SQL_LIST})
210
+ OR e.src_file_id = (SELECT file_id FROM nodes WHERE id = ?))
211
+ ))
212
+ AND e.kind = 'call'
190
213
  ORDER BY f.path, e.line`)
191
- .all(best.id, best.name);
214
+ .all(best.id, best.name, best.id);
192
215
  return {
193
216
  found: true,
194
217
  symbol: {
@@ -200,7 +223,11 @@ function assembleExplore(db, projectPath, best, others) {
200
223
  signature: best.signature,
201
224
  source: readSource(projectPath, best.file, best.start_byte, best.end_byte),
202
225
  },
203
- callees: callees.map((c) => ({ name: c.name, file: c.file ?? undefined, line: c.line })),
226
+ callees: callees.map((c) => ({ name: c.name, file: c.file, line: c.line })),
227
+ unresolvedCallees: {
228
+ count: unresolved.reduce((sum, u) => sum + Number(u.n), 0),
229
+ sample: unresolved.slice(0, 10).map((u) => u.name),
230
+ },
204
231
  callers,
205
232
  otherMatches: others.length > 0
206
233
  ? others.map((m) => ({
@@ -215,14 +242,23 @@ function assembleExplore(db, projectPath, best, others) {
215
242
  }
216
243
  /**
217
244
  * Name miss: resolve a file path to one symbol, or list symbols when the
218
- * basename is ambiguous or the file defines nothing. A non-unique suffix
219
- * falls through to {@link search}, which also matches `files.path`.
245
+ * basename is ambiguous or the file defines nothing. A file with no visible
246
+ * symbol resolves to its file-owner node (the indexer gives every symbol-less
247
+ * file one); a file with no node at all reports that it has no symbols. A non-unique suffix falls through to {@link search}, which also
248
+ * matches `files.path`.
220
249
  */
221
250
  function exploreWhenNameMisses(db, projectPath, query) {
222
- // Covers: req~explore-file-path~1
251
+ // Covers: req~explore-file-path~2
223
252
  const resolved = resolveIndexedFile(db, projectPath, query);
224
253
  if (resolved.status === "one") {
225
254
  const symbols = fileSymbols(db, resolved.file);
255
+ const owner = symbols.length === 0 ? fileSymbols(db, resolved.file, true)[0] : undefined;
256
+ if (owner) {
257
+ return {
258
+ ...assembleExplore(db, projectPath, owner, []),
259
+ message: `Path "${resolved.file}" declares no symbols; resolved to its file-owner node.`,
260
+ };
261
+ }
226
262
  if (symbols.length === 0) {
227
263
  const near = search(projectPath, query, 10);
228
264
  return {
@@ -276,7 +312,7 @@ export function explore(projectPath, query) {
276
312
  .prepare(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.start_byte, s.end_byte,
277
313
  s.signature, f.path AS file
278
314
  FROM nodes s JOIN files f ON f.id = s.file_id
279
- WHERE s.name = ?
315
+ WHERE s.name = ? AND s.kind <> '${FILE_NODE_KIND}'
280
316
  ORDER BY s.kind = 'function' DESC, s.kind = 'class' DESC
281
317
  LIMIT 10`)
282
318
  .all(query);
@@ -391,6 +427,12 @@ export function impact(projectPath, symbolOrOpts, maxDepth = 4) {
391
427
  for (const d of definitions) {
392
428
  seedArgs.push(d.nodeId, d.name);
393
429
  }
430
+ // Covers: req~impact-id-first~1
431
+ // A member call (`is_member` 1 or 2) never matches by name: `items.push()` is
432
+ // not a call to a project function named `push`, and a call on an import
433
+ // binding (2) counts only through the id resolveEdges gave it, so a package
434
+ // receiver (`path.parse()`) never reaches a project `parse`. A builtin global name
435
+ // (`test`, `fetch`) matches by name only within the frontier node's file.
394
436
  // Sticky by_name: MAX(frontier.by_name, CASE WHEN edge unresolved THEN 1 ELSE 0).
395
437
  // Import edges that resolve to ANY node in the frontier node's file count as hits.
396
438
  const sql = `
@@ -412,7 +454,11 @@ export function impact(projectPath, symbolOrOpts, maxDepth = 4) {
412
454
  AND (
413
455
  (e.kind = 'call' AND (
414
456
  e.dst_node_id = f.node_id
415
- OR (e.dst_node_id IS NULL AND e.dst_name = f.node_name)
457
+ OR (
458
+ e.dst_node_id IS NULL AND e.is_member = 0 AND e.dst_name = f.node_name
459
+ AND (e.dst_name NOT IN (${BUILTIN_SQL_LIST})
460
+ OR e.src_file_id = (SELECT file_id FROM nodes WHERE id = f.node_id))
461
+ )
416
462
  ))
417
463
  OR (
418
464
  e.kind = 'import'
@@ -536,7 +582,7 @@ function resolveImpactSeeds(db, opts, seedFiles, warnings) {
536
582
  const rows = db
537
583
  .prepare(`SELECT n.id AS nodeId, n.name, f.path AS file, n.start_line AS line
538
584
  FROM nodes n JOIN files f ON f.id = n.file_id
539
- WHERE n.name = ?
585
+ WHERE n.name = ? AND n.kind <> '${FILE_NODE_KIND}'
540
586
  ORDER BY n.kind = 'function' DESC, n.kind = 'class' DESC, n.id ASC
541
587
  LIMIT 50`)
542
588
  .all(opts.symbol);
@@ -151,7 +151,7 @@ export function registerCompass(server, opts = {}) {
151
151
  });
152
152
  defineAliasTool(server, {
153
153
  name: "compass_affected_tests",
154
- description: "Deprecated alias — use compass_diff_context or explore.",
154
+ description: "Deprecated alias — use compass_diff_context or explore; command may be null.",
155
155
  inputSchema: {
156
156
  projectPath: z.string(),
157
157
  files: z.array(z.string()).optional(),
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { openDb, indexExists } from "./db.js";
3
+ import { openDb, indexExists, FILE_NODE_KIND } from "./db.js";
4
4
  /**
5
5
  * Build the graph payload from the Compass index: nodes (definitions) and the
6
6
  * resolved call links between them. With a focus node, returns its neighborhood
@@ -17,11 +17,13 @@ export function graphData(projectPath, opts = {}) {
17
17
  try {
18
18
  const allNodes = db
19
19
  .prepare(`SELECT n.id, n.name, n.kind, f.path AS file, n.start_line AS line
20
- FROM nodes n JOIN files f ON f.id = n.file_id`)
20
+ FROM nodes n JOIN files f ON f.id = n.file_id
21
+ WHERE n.kind <> '${FILE_NODE_KIND}'`)
21
22
  .all();
22
23
  const allEdges = db
23
24
  .prepare(`SELECT src_node_id AS s, dst_node_id AS t FROM edges
24
- WHERE kind = 'call' AND dst_node_id IS NOT NULL AND src_node_id IS NOT NULL`)
25
+ WHERE kind = 'call' AND dst_node_id IS NOT NULL AND src_node_id IS NOT NULL
26
+ AND src_node_id NOT IN (SELECT id FROM nodes WHERE kind = '${FILE_NODE_KIND}')`)
25
27
  .all();
26
28
  // adjacency (undirected) + degree
27
29
  const adj = new Map();
@@ -34,14 +34,34 @@ a request, not whole files.
34
34
  | `compass_explore` | Read a node's verbatim source plus its callers and callees. The default before editing. Add `include: ["blast_radius"]` for what could break, or `to: <symbol>` for the call path between two nodes. |
35
35
  | `compass_diff_context` | Symbols, blast radius, and tests for a diff (working tree or a rev). |
36
36
 
37
+ Callees list only references resolved to a project symbol; the rest are counted
38
+ in `unresolvedCallees`. A call inside a test callback or a top-level import
39
+ shows up as a caller of kind `file`, so tests written as bare `test(...)`
40
+ blocks are still selected. The affected-test `command` is `null` (with
41
+ `commandReason`) when no test is reachable; otherwise run it from the repository
42
+ root, or use `commands` (one `{ cwd, command, files }` per package, with the
43
+ runner detected from each `package.json`: vitest, jest, or `node --test`).
44
+ Method calls on local variables and parameters (`svc.run()`) are not bound to a
45
+ project method, so they do not appear as callers. Calls through an imported
46
+ re-export barrel (`index.ts` holding `export * from "./lib/core"`) bind by
47
+ name, preferring a definition under the barrel's directory.
48
+
49
+ Compass uses index schema 11. speclaw 2.0.9 or older, including an MCP entry
50
+ pinned at `@2.0.9`, rebuilds a schema-11 index from scratch (losing the
51
+ embedding cache). Run `speclaw update` so the pinned MCP entry moves to 2.0.10.
52
+
37
53
  `compass_index` also takes `action: start|stop|status` to keep the index fresh
38
54
  automatically (a debounced incremental re-index on file change).
39
55
 
40
56
  Under Claude Code, a `SessionStart` hook refreshes an existing index when each
41
57
  session starts (`speclaw session-start`: silent, never fails, skips
42
58
  when `.speclaw/index.db` is absent, and leaves this file alone when nothing
43
- changed). The first build is always an explicit `compass_index` /
44
- `speclaw index`.
59
+ changed). A `PostToolUse` hook re-indexes each file the agent writes or edits
60
+ right after the edit (`speclaw reindex-file`: detached, silent, never fails);
61
+ PageRank and this file's map catch up on the next full run. Files created or
62
+ renamed by shell commands, and edits by agents without hooks, are picked up at
63
+ the next session start, `compass_index`, or `speclaw watch`. The first build is
64
+ always an explicit `compass_index` / `speclaw index`.
45
65
 
46
66
  If the graph is missing (no `.speclaw/index.db`), run `compass_index` first —
47
67
  a missing graph is not license to skip Compass. The only legitimate fallbacks
@@ -27,11 +27,38 @@ const LEGACY_SESSION_START_MARKER = "speclaw index --session-start";
27
27
  * Output is discarded because `SessionStart` stdout enters the agent's context,
28
28
  * and `|| true` keeps the exit code 0 in every case.
29
29
  */
30
- export const SESSION_START_COMMAND = 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
31
- "if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw session-start; " +
32
- "elif command -v speclaw >/dev/null 2>&1; then speclaw session-start; " +
33
- "else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw session-start; fi; " +
34
- "} >/dev/null 2>&1 || true";
30
+ export const SESSION_START_COMMAND = speclawCommand("session-start");
31
+ /**
32
+ * Substring every edit-reindex command contains, in each of its resolution
33
+ * branches. It is the merge identity of the `PostToolUse` reindex hook.
34
+ */
35
+ export const REINDEX_FILE_MARKER = "speclaw reindex-file";
36
+ /**
37
+ * The POSIX `sh` command the edit `PostToolUse` hook runs: the session-start
38
+ * command with `reindex-file` as the subcommand, so it keeps the same index
39
+ * guard, resolution order, offline npx, discarded output, and exit 0. The hook
40
+ * payload on stdin passes through `sh` to speclaw unchanged. A speclaw older
41
+ * than the command rejects it as unknown (exit 1) before touching the index.
42
+ */
43
+ export const REINDEX_FILE_COMMAND = speclawCommand("reindex-file");
44
+ /** Seconds the edit hook may run; the command returns once it has spawned the work. */
45
+ const REINDEX_TIMEOUT_SECONDS = 10;
46
+ /**
47
+ * Build a guarded, fail-safe speclaw hook command for one top-level subcommand
48
+ * (see {@link SESSION_START_COMMAND} for the shape). The only difference
49
+ * between the hook commands is the subcommand.
50
+ *
51
+ * @param sub - The top-level speclaw command the hook runs.
52
+ * @returns The POSIX `sh` command string.
53
+ */
54
+ export function speclawCommand(sub) {
55
+ // Covers: req~edit-reindex-hook~1
56
+ return ('cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
57
+ `if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw ${sub}; ` +
58
+ `elif command -v speclaw >/dev/null 2>&1; then speclaw ${sub}; ` +
59
+ `else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw ${sub}; fi; ` +
60
+ "} >/dev/null 2>&1 || true");
61
+ }
35
62
  /** `SessionStart` matcher covering every session source Claude Code reports. */
36
63
  const SESSION_START_MATCHER = "startup|resume|clear|compact";
37
64
  /** Seconds the blocking session-start hook may run (index wait plus a real refresh). */
@@ -72,8 +99,8 @@ export const NUDGE_MATCHER = "Read|Grep|Glob";
72
99
  /**
73
100
  * True when a hook object is one speclaw owns (safe to replace on merge): an
74
101
  * `mcp_tool` hook on the `speclaw` server, or a `command` hook whose command
75
- * contains {@link SESSION_START_MARKER} (or the pre-release
76
- * `index --session-start` marker). A user `command` hook without the
102
+ * contains {@link SESSION_START_MARKER}, {@link REINDEX_FILE_MARKER}, or the
103
+ * pre-release `index --session-start` marker. A user `command` hook without a
77
104
  * marker is never speclaw's.
78
105
  *
79
106
  * @param h - A hook object read from an agent's settings (any shape).
@@ -85,7 +112,9 @@ export function isSpeclawHook(h) {
85
112
  return true;
86
113
  return (o?.type === "command" &&
87
114
  typeof o?.command === "string" &&
88
- (o.command.includes(SESSION_START_MARKER) || o.command.includes(LEGACY_SESSION_START_MARKER)));
115
+ (o.command.includes(SESSION_START_MARKER) ||
116
+ o.command.includes(REINDEX_FILE_MARKER) ||
117
+ o.command.includes(LEGACY_SESSION_START_MARKER)));
89
118
  }
90
119
  /**
91
120
  * Compile a law manifest into the hook groups speclaw contributes, one per event
@@ -93,8 +122,10 @@ export function isSpeclawHook(h) {
93
122
  * `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
94
123
  * (the context-coverage audit). Two groups are always emitted, even with no
95
124
  * laws: one `PostToolUse` group matching `Read|Grep|Glob` for the Compass-first
96
- * nudge, and one `SessionStart` group whose `command` hook refreshes the index
97
- * silently when a session starts. A law whose scope contains a malformed glob is
125
+ * nudge, a separate `PostToolUse` group matching the mutating tools whose
126
+ * `command` hook re-indexes the edited file in the background, and one
127
+ * `SessionStart` group whose `command` hook refreshes the index silently when a
128
+ * session starts. A law whose scope contains a malformed glob is
98
129
  * excluded and reported, so a bad pattern fails loudly at generation rather than
99
130
  * silently matching nothing at runtime.
100
131
  *
@@ -121,6 +152,13 @@ export function compileHooks(manifest) {
121
152
  byEvent.PostToolUse = [{ matcher: NUDGE_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
122
153
  if (hasFeedback)
123
154
  byEvent.PostToolUse.unshift({ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] });
155
+ // Covers: req~edit-reindex-hook~1
156
+ // Its own group, never folded into the feedback `mcp_tool` group, and only
157
+ // the keys the agent's hook schema defines (no `async`).
158
+ byEvent.PostToolUse.push({
159
+ matcher: MUTATION_MATCHER,
160
+ hooks: [{ type: "command", command: REINDEX_FILE_COMMAND, timeout: REINDEX_TIMEOUT_SECONDS }],
161
+ });
124
162
  if (hasGate)
125
163
  byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
126
164
  if (valid.length > 0)
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import { openDb, indexExists, needsReindex } from "../compass/db.js";
6
+ import { openDb, indexExists, needsReindex, FILE_NODE_KIND } from "../compass/db.js";
7
7
  import { NORMALIZER_VERSION } from "../compass/hash.js";
8
8
  import { logForPath } from "../../shared/git-history.js";
9
9
  import { listAnchoredCapabilities, readAnchorsFile, resealAll, sealCapability, } from "./anchors.js";
@@ -49,7 +49,7 @@ export function classifyAnchor(db, projectPath, capability, a) {
49
49
  .prepare(`SELECT n.id AS id, n.name AS name, n.kind AS kind, f.path AS path,
50
50
  n.norm_hash AS normHash, n.body_hash AS bodyHash
51
51
  FROM nodes n JOIN files f ON f.id = n.file_id
52
- WHERE n.name = ?`)
52
+ WHERE n.name = ? AND n.kind <> '${FILE_NODE_KIND}'`)
53
53
  .all(a.symbolName);
54
54
  if (byName.length === 0) {
55
55
  if (a.contentHash) {
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { indexExists, openDb } from "../compass/db.js";
3
+ import { indexExists, openDb, FILE_NODE_KIND } from "../compass/db.js";
4
4
  import { explore, impact, recall } from "../compass/query.js";
5
5
  import { affectedTests } from "../compass/affected.js";
6
6
  import { hotspots, coupling } from "../compass/hotspots.js";
@@ -26,6 +26,7 @@ function resolveAtLine(projectPath, file, line) {
26
26
  .prepare(`SELECT s.name, s.kind, s.start_line AS startLine, s.signature
27
27
  FROM nodes s JOIN files f ON f.id = s.file_id
28
28
  WHERE f.path = ? AND s.start_line <= ? AND s.end_line >= ?
29
+ AND s.kind <> '${FILE_NODE_KIND}'
29
30
  ORDER BY (s.end_line - s.start_line) ASC
30
31
  LIMIT 1`)
31
32
  .get(file, line, line);
@@ -29,6 +29,8 @@ export function applyTextBudget(text, mode = "brief") {
29
29
  }
30
30
  /**
31
31
  * Truncate known list fields on an explore-style object before serialization.
32
+ * `unresolvedCallees` (a count plus at most 10 names) is left whole, so the
33
+ * resolved `callees` keep the list budget.
32
34
  *
33
35
  * @param value - Plain object to mutate in place (arrays shortened, counts kept).
34
36
  * @param mode - Output mode controlling list limits.
@@ -1,4 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  /**
4
5
  * The `assets/` directory that sits next to a compiled module file. Each module
@@ -8,3 +9,26 @@ import path from "node:path";
8
9
  export function assetsDir(importMetaUrl) {
9
10
  return path.join(path.dirname(fileURLToPath(importMetaUrl)), "assets");
10
11
  }
12
+ /**
13
+ * `p` with symlinks resolved in its longest existing prefix (a deleted file or
14
+ * directory keeps its missing tail), so `/var/…` and `/private/var/…` spellings
15
+ * of one path compare equal even after the file is gone.
16
+ *
17
+ * @param p - An absolute path.
18
+ */
19
+ export function realPathOf(p) {
20
+ const tail = [];
21
+ let cur = path.resolve(p);
22
+ for (;;) {
23
+ try {
24
+ return path.join(fs.realpathSync(cur), ...tail.reverse());
25
+ }
26
+ catch {
27
+ const parent = path.dirname(cur);
28
+ if (parent === cur)
29
+ return path.resolve(p);
30
+ tail.push(path.basename(cur));
31
+ cur = parent;
32
+ }
33
+ }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },