@esneiderbravo/speclaw 2.0.2 → 2.0.3

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.
@@ -9,11 +9,16 @@ function requireIndex(projectPath) {
9
9
  }
10
10
  }
11
11
  /**
12
- * Structural search: find nodes whose name contains `query` (substring match),
13
- * ranking exact matches and shorter names first.
12
+ * Structural search: find nodes whose name contains `query`, or whose indexed
13
+ * file path contains `query`. Exact name matches and shorter names rank first.
14
+ *
15
+ * LIKE metacharacters `%` and `_` in `query` are escaped so they match
16
+ * literally. Name matching stays a substring of the caller's original text;
17
+ * the path comparison uses a repo-relative normalization (trim, slashes, a
18
+ * leading `./`, absolute paths under the project root).
14
19
  *
15
20
  * @param projectPath - Absolute path to the indexed project.
16
- * @param query - Name or keyword to match as a substring.
21
+ * @param query - Name substring or file path to match.
17
22
  * @param limit - Maximum number of hits to return.
18
23
  * @returns Matching nodes ordered by relevance.
19
24
  * @throws If no index exists for the project.
@@ -22,13 +27,16 @@ export function search(projectPath, query, limit = 25) {
22
27
  requireIndex(projectPath);
23
28
  const db = openDb(projectPath);
24
29
  try {
30
+ const normalized = normalizePathQuery(projectPath, query);
31
+ const pathPattern = normalized ? `%${escapeLike(normalized)}%` : "";
25
32
  const rows = db
26
33
  .prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
27
34
  FROM nodes s JOIN files f ON f.id = s.file_id
28
- WHERE s.name LIKE ?
29
- ORDER BY (s.name = ?) DESC, length(s.name) ASC
35
+ WHERE s.name LIKE ? ESCAPE '\\'
36
+ OR (? != '' AND f.path LIKE ? ESCAPE '\\')
37
+ ORDER BY (s.name = ?) DESC, length(s.name) ASC, f.path ASC, s.start_line ASC
30
38
  LIMIT ?`)
31
- .all(`%${query}%`, query, limit);
39
+ .all(`%${escapeLike(query)}%`, normalized, pathPattern, query, limit);
32
40
  return rows;
33
41
  }
34
42
  finally {
@@ -44,18 +52,205 @@ function readSource(projectPath, file, startByte, endByte) {
44
52
  return "";
45
53
  }
46
54
  }
55
+ /**
56
+ * Escape SQL `LIKE` metacharacters so `%` and `_` match literally under
57
+ * `ESCAPE '\'`.
58
+ */
59
+ function escapeLike(value) {
60
+ return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
61
+ }
62
+ /**
63
+ * Normalize a path-shaped query to a repo-relative slash path.
64
+ *
65
+ * Trims, turns backslashes into slashes, and strips one leading `./`. An
66
+ * absolute path that lies under `projectPath` becomes repo-relative; a path
67
+ * outside the project is left absolute so it will not match `files.path`.
68
+ *
69
+ * @param projectPath - Absolute path to the indexed project.
70
+ * @param query - Caller-supplied name or path.
71
+ * @returns The normalized path, or `""` when the query is only the project root.
72
+ */
73
+ function normalizePathQuery(projectPath, query) {
74
+ let q = query.trim().split("\\").join("/");
75
+ if (q.startsWith("./"))
76
+ q = q.slice(2);
77
+ if (path.isAbsolute(q)) {
78
+ const rel = path.relative(path.resolve(projectPath), q).split("\\").join("/");
79
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)))
80
+ return rel;
81
+ }
82
+ return q;
83
+ }
84
+ /** Indexed paths that equal `query` or end with `/${query}`. Exact row first. */
85
+ function matchingFiles(db, query) {
86
+ const exact = db.prepare("SELECT path FROM files WHERE path = ?").get(query);
87
+ const suffix = db
88
+ .prepare("SELECT path FROM files WHERE path LIKE ? ESCAPE '\\' ORDER BY path")
89
+ .all(`%/${escapeLike(query)}`);
90
+ return { exact: exact?.path ?? null, suffix: suffix.map((row) => row.path) };
91
+ }
92
+ /**
93
+ * Resolve `query` to indexed files after a symbol-name miss.
94
+ *
95
+ * Exact `files.path` wins. Otherwise a query that contains `/` must match
96
+ * exactly one suffix (`path` ends with `/${query}`). A query with no slash
97
+ * uses the same suffix rule as a basename; more than one file is ambiguous.
98
+ */
99
+ function resolveIndexedFile(db, projectPath, query) {
100
+ const normalized = normalizePathQuery(projectPath, query);
101
+ if (!normalized)
102
+ return { status: "none" };
103
+ const { exact, suffix } = matchingFiles(db, normalized);
104
+ if (exact)
105
+ return { status: "one", file: exact };
106
+ if (normalized.includes("/")) {
107
+ if (suffix.length === 1)
108
+ return { status: "one", file: suffix[0] };
109
+ return { status: "none" };
110
+ }
111
+ if (suffix.length === 1)
112
+ return { status: "one", file: suffix[0] };
113
+ if (suffix.length > 1)
114
+ return { status: "ambiguous", basename: normalized, files: suffix };
115
+ return { status: "none" };
116
+ }
117
+ /** Definitions in one indexed file, ordered by source position. */
118
+ function fileSymbols(db, file) {
119
+ return db
120
+ .prepare(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.start_byte, s.end_byte,
121
+ s.signature, f.path AS file
122
+ FROM nodes s JOIN files f ON f.id = s.file_id
123
+ WHERE f.path = ?
124
+ ORDER BY s.start_line ASC, s.id ASC`)
125
+ .all(file);
126
+ }
127
+ /** Symbols defined in `files`, stable across files. Empty when none exist. */
128
+ function symbolsInFiles(db, files) {
129
+ if (files.length === 0)
130
+ return [];
131
+ const placeholders = files.map(() => "?").join(", ");
132
+ return db
133
+ .prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
134
+ FROM nodes s JOIN files f ON f.id = s.file_id
135
+ WHERE f.path IN (${placeholders})
136
+ ORDER BY f.path ASC, s.start_line ASC, s.name ASC`)
137
+ .all(...files);
138
+ }
139
+ /**
140
+ * Choose the symbol a file-path explore returns.
141
+ *
142
+ * Prefer a symbol whose name equals the file stem, function before class,
143
+ * then any other kind. Otherwise the first function by `start_line`, then
144
+ * the first symbol. `symbols` must be non-empty and ordered by `start_line`.
145
+ */
146
+ function pickPrimary(symbols) {
147
+ const stem = path.parse(symbols[0].file).name;
148
+ const named = symbols.filter((s) => s.name === stem);
149
+ if (named.length > 0) {
150
+ return (named.find((s) => s.kind === "function") ?? named.find((s) => s.kind === "class") ?? named[0]);
151
+ }
152
+ return symbols.find((s) => s.kind === "function") ?? symbols[0];
153
+ }
154
+ /**
155
+ * Source, callers, and callees for one definition. `others` are the
156
+ * `otherMatches` (same-name siblings, or the rest of a file).
157
+ */
158
+ function assembleExplore(db, projectPath, best, others) {
159
+ const callees = db
160
+ .prepare(`SELECT e.dst_name AS name, e.line, f.path AS file
161
+ FROM edges e LEFT JOIN nodes s ON s.id = e.dst_node_id
162
+ LEFT JOIN files f ON f.id = s.file_id
163
+ WHERE e.src_node_id = ? AND e.kind = 'call'
164
+ ORDER BY e.line`)
165
+ .all(best.id);
166
+ // Callers: match both the resolved edge AND any call by this name, so
167
+ // dynamic dispatch (a method/function called by name across sites) is not
168
+ // missed. Over-approximates conservatively — the point of a blast radius.
169
+ const callers = db
170
+ .prepare(`SELECT DISTINCT owner.name AS name, owner.kind AS kind, f.path AS file, e.line
171
+ FROM edges e
172
+ JOIN nodes owner ON owner.id = e.src_node_id
173
+ JOIN files f ON f.id = owner.file_id
174
+ WHERE (e.dst_node_id = ? OR e.dst_name = ?) AND e.kind = 'call'
175
+ ORDER BY f.path, e.line`)
176
+ .all(best.id, best.name);
177
+ return {
178
+ found: true,
179
+ symbol: {
180
+ name: best.name,
181
+ kind: best.kind,
182
+ file: best.file,
183
+ startLine: best.start_line,
184
+ endLine: best.end_line,
185
+ signature: best.signature,
186
+ source: readSource(projectPath, best.file, best.start_byte, best.end_byte),
187
+ },
188
+ callees: callees.map((c) => ({ name: c.name, file: c.file ?? undefined, line: c.line })),
189
+ callers,
190
+ otherMatches: others.length > 0
191
+ ? others.map((m) => ({
192
+ name: m.name,
193
+ kind: m.kind,
194
+ file: m.file,
195
+ line: m.start_line,
196
+ signature: m.signature,
197
+ }))
198
+ : undefined,
199
+ };
200
+ }
201
+ /**
202
+ * Name miss: resolve a file path to one symbol, or list symbols when the
203
+ * basename is ambiguous or the file defines nothing. A non-unique suffix
204
+ * falls through to {@link search}, which also matches `files.path`.
205
+ */
206
+ function exploreWhenNameMisses(db, projectPath, query) {
207
+ // Covers: req~explore-file-path~1
208
+ const resolved = resolveIndexedFile(db, projectPath, query);
209
+ if (resolved.status === "one") {
210
+ const symbols = fileSymbols(db, resolved.file);
211
+ if (symbols.length === 0) {
212
+ const near = search(projectPath, query, 10);
213
+ return {
214
+ found: false,
215
+ message: `Indexed file "${resolved.file}" has no symbols.`,
216
+ otherMatches: near,
217
+ };
218
+ }
219
+ const primary = pickPrimary(symbols);
220
+ const result = assembleExplore(db, projectPath, primary, symbols.filter((s) => s.id !== primary.id));
221
+ return {
222
+ ...result,
223
+ message: `Path "${resolved.file}" resolved to ${primary.name}.`,
224
+ };
225
+ }
226
+ if (resolved.status === "ambiguous") {
227
+ return {
228
+ found: false,
229
+ message: `Basename "${resolved.basename}" is ambiguous.`,
230
+ otherMatches: symbolsInFiles(db, resolved.files),
231
+ };
232
+ }
233
+ const near = search(projectPath, query, 10);
234
+ return {
235
+ found: false,
236
+ message: `No exact symbol named "${query}". ${near.length} similar symbol(s) below.`,
237
+ otherMatches: near,
238
+ };
239
+ }
47
240
  /**
48
241
  * Explore an exact node by name: return its verbatim source, callees, and
49
242
  * callers (its blast radius).
50
243
  *
51
244
  * When several nodes share the name, functions and classes are preferred as the
52
- * primary result and the rest are surfaced under `otherMatches`. When no exact
53
- * match exists, falls back to a fuzzy {@link search} so the caller still gets
54
- * useful candidates.
245
+ * primary result and the rest are surfaced under `otherMatches`. When the name
246
+ * misses, a normalized file path (exact, unique suffix, or unique basename)
247
+ * resolves to one symbol in that file — stem name first (function, then
248
+ * class), otherwise the first function, otherwise the first symbol. Anything
249
+ * still unresolved falls back to a fuzzy {@link search}.
55
250
  *
56
251
  * @param projectPath - Absolute path to the indexed project.
57
- * @param query - Exact node name to explore.
58
- * @returns The explore result; `found` is `false` when no exact match exists.
252
+ * @param query - Exact node name, or a file path when no symbol has that name.
253
+ * @returns The explore result; `found` is `false` when no single symbol is selected.
59
254
  * @throws If no index exists for the project.
60
255
  */
61
256
  export function explore(projectPath, query) {
@@ -70,57 +265,9 @@ export function explore(projectPath, query) {
70
265
  ORDER BY s.kind = 'function' DESC, s.kind = 'class' DESC
71
266
  LIMIT 10`)
72
267
  .all(query);
73
- if (matches.length === 0) {
74
- // fall back to fuzzy search so the caller gets something useful
75
- const near = search(projectPath, query, 10);
76
- return {
77
- found: false,
78
- message: `No exact symbol named "${query}". ${near.length} similar symbol(s) below.`,
79
- otherMatches: near,
80
- };
81
- }
82
- const best = matches[0];
83
- const callees = db
84
- .prepare(`SELECT e.dst_name AS name, e.line, f.path AS file
85
- FROM edges e LEFT JOIN nodes s ON s.id = e.dst_node_id
86
- LEFT JOIN files f ON f.id = s.file_id
87
- WHERE e.src_node_id = ? AND e.kind = 'call'
88
- ORDER BY e.line`)
89
- .all(best.id);
90
- // Callers: match both the resolved edge AND any call by this name, so
91
- // dynamic dispatch (a method/function called by name across sites) is not
92
- // missed. Over-approximates conservatively — the point of a blast radius.
93
- const callers = db
94
- .prepare(`SELECT DISTINCT owner.name AS name, owner.kind AS kind, f.path AS file, e.line
95
- FROM edges e
96
- JOIN nodes owner ON owner.id = e.src_node_id
97
- JOIN files f ON f.id = owner.file_id
98
- WHERE (e.dst_node_id = ? OR e.dst_name = ?) AND e.kind = 'call'
99
- ORDER BY f.path, e.line`)
100
- .all(best.id, best.name);
101
- return {
102
- found: true,
103
- symbol: {
104
- name: best.name,
105
- kind: best.kind,
106
- file: best.file,
107
- startLine: best.start_line,
108
- endLine: best.end_line,
109
- signature: best.signature,
110
- source: readSource(projectPath, best.file, best.start_byte, best.end_byte),
111
- },
112
- callees: callees.map((c) => ({ name: c.name, file: c.file ?? undefined, line: c.line })),
113
- callers,
114
- otherMatches: matches.length > 1
115
- ? matches.slice(1).map((m) => ({
116
- name: m.name,
117
- kind: m.kind,
118
- file: m.file,
119
- line: m.start_line,
120
- signature: m.signature,
121
- }))
122
- : undefined,
123
- };
268
+ if (matches.length === 0)
269
+ return exploreWhenNameMisses(db, projectPath, query);
270
+ return assembleExplore(db, projectPath, matches[0], matches.slice(1));
124
271
  }
125
272
  finally {
126
273
  db.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },