septum 0.1.0 → 0.1.2

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 (65) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +100 -100
  3. package/bin/septum.ts +4 -4
  4. package/package.json +63 -62
  5. package/src/cli/commands/check.ts +161 -161
  6. package/src/cli/commands/hook.ts +188 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +211 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +64 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +90 -90
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +199 -178
  15. package/src/cli/templates/hooks.ts +75 -75
  16. package/src/cli/templates/rules.ts +38 -38
  17. package/src/cli/templates/skill.ts +66 -66
  18. package/src/core/boundary/evaluator.ts +210 -210
  19. package/src/core/config/loader.ts +84 -84
  20. package/src/core/config/schema.ts +39 -39
  21. package/src/core/database/client.ts +44 -132
  22. package/src/core/database/repositories/dependency.repository.ts +279 -279
  23. package/src/core/database/repositories/domain.repository.ts +198 -198
  24. package/src/core/database/repositories/file.repository.ts +99 -99
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +424 -424
  27. package/src/core/database/repository.ts +242 -242
  28. package/src/core/database/schema.ts +120 -0
  29. package/src/core/discovery/topology-detector.ts +418 -418
  30. package/src/core/ingestion/hasher.ts +5 -5
  31. package/src/core/ingestion/pipeline.ts +438 -438
  32. package/src/core/parser/boundary-tracker.ts +128 -128
  33. package/src/core/parser/extractors/base.ts +60 -60
  34. package/src/core/parser/extractors/frontend.ts +194 -194
  35. package/src/core/parser/extractors/go.ts +176 -176
  36. package/src/core/parser/extractors/laravel-semantic.ts +426 -426
  37. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
  38. package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
  39. package/src/core/parser/extractors/php.ts +149 -149
  40. package/src/core/parser/extractors/python.ts +226 -226
  41. package/src/core/parser/extractors/registry.ts +49 -49
  42. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  43. package/src/core/parser/extractors/typescript.ts +177 -177
  44. package/src/core/parser/tree-sitter.ts +39 -39
  45. package/src/core/resolver/call-graph-tracer.ts +668 -668
  46. package/src/core/resolver/module-resolver.ts +342 -323
  47. package/src/core/resolver/symbol-locator.ts +543 -405
  48. package/src/core/resolver/vertical-slice-tracer.ts +237 -236
  49. package/src/core/session/session-manager.ts +56 -56
  50. package/src/core/telemetry/telemetry.ts +86 -86
  51. package/src/index.ts +18 -18
  52. package/src/mcp/schemas.ts +68 -68
  53. package/src/mcp/server.ts +612 -602
  54. package/src/mcp/tools/check-boundary.ts +102 -102
  55. package/src/mcp/tools/get-domain-catalog.ts +91 -91
  56. package/src/mcp/tools/get-feature-context.ts +116 -116
  57. package/src/mcp/tools/get-symbol-hotspots.ts +49 -49
  58. package/src/mcp/tools/get-symbol-impact.ts +26 -26
  59. package/src/mcp/tools/get-symbol.ts +63 -63
  60. package/src/mcp/tools/locate-symbol.ts +74 -74
  61. package/src/mcp/tools/register-domain.ts +64 -64
  62. package/src/mcp/tools/trace-vertical-slice.ts +102 -102
  63. package/src/types/index.ts +296 -296
  64. package/src/version.ts +3 -0
  65. package/src/core/database/schema.sql +0 -92
@@ -1,424 +1,424 @@
1
- import * as fs from "node:fs";
2
- import type { Database } from "bun:sqlite";
3
- import type {
4
- DomainRecord,
5
- ExtractedSymbol,
6
- FileRecord,
7
- GetSymbolDetailsResponse,
8
- SymbolKind,
9
- SymbolRecord,
10
- } from "../../../types/index.ts";
11
-
12
- export class SymbolRepository {
13
- constructor(private db: Database) {}
14
-
15
- public replaceFileSymbols(fileId: number, symbols: ExtractedSymbol[]): void {
16
- this.db.query("DELETE FROM symbols WHERE file_id = ?").run(fileId);
17
- if (symbols.length === 0) return;
18
-
19
- const stmt = this.db.query(
20
- `INSERT INTO symbols (file_id, name, kind, signature, visibility, line_start, line_end, line_count)
21
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
22
- );
23
-
24
- for (const sym of symbols) {
25
- const lineCount = sym.line_count ?? Math.max(1, sym.line_end - sym.line_start + 1);
26
- stmt.run(
27
- fileId,
28
- sym.name,
29
- sym.kind,
30
- sym.signature,
31
- sym.visibility,
32
- sym.line_start,
33
- sym.line_end,
34
- lineCount
35
- );
36
- }
37
- }
38
-
39
- public getSymbolsByFileId(fileId: number): SymbolRecord[] {
40
- return this.db
41
- .query<SymbolRecord, [number]>(
42
- "SELECT * FROM symbols WHERE file_id = ? ORDER BY line_start ASC"
43
- )
44
- .all(fileId);
45
- }
46
-
47
- public findSymbolsByName(name: string): Array<SymbolRecord & { file_path: string }> {
48
- const query = `
49
- SELECT s.*, f.path AS file_path
50
- FROM symbols s
51
- JOIN files f ON s.file_id = f.id
52
- WHERE s.name = ? OR s.name LIKE ?
53
- ORDER BY s.line_start ASC
54
- `;
55
- return this.db
56
- .query<SymbolRecord & { file_path: string }, [string, string]>(query)
57
- .all(name, `%::${name}`);
58
- }
59
-
60
- public findContainers(
61
- containerName: string
62
- ): Array<{ file: FileRecord; symbol?: SymbolRecord }> {
63
- const symbolQuery = `
64
- SELECT s.*, f.path AS file_path, f.id AS f_id, f.domain_id AS f_domain_id,
65
- f.archetype AS f_archetype, f.content_hash AS f_content_hash,
66
- f.line_count AS f_line_count, f.mtime_ms AS f_mtime_ms,
67
- f.size_bytes AS f_size_bytes, f.last_scanned_at AS f_last_scanned_at
68
- FROM symbols s
69
- JOIN files f ON s.file_id = f.id
70
- WHERE (s.name = ? OR s.name LIKE ? OR s.name LIKE ?)
71
- AND s.kind IN ('class', 'interface', 'trait', 'enum')
72
- LIMIT 10
73
- `;
74
- const rows = this.db
75
- .query<any, [string, string, string]>(symbolQuery)
76
- .all(containerName, `%\\${containerName}`, `%/${containerName}`);
77
-
78
- if (rows.length > 0) {
79
- return rows.map((r) => ({
80
- file: {
81
- id: r.f_id,
82
- domain_id: r.f_domain_id,
83
- path: r.file_path,
84
- archetype: r.f_archetype,
85
- content_hash: r.f_content_hash,
86
- line_count: r.f_line_count,
87
- mtime_ms: r.f_mtime_ms,
88
- size_bytes: r.f_size_bytes,
89
- last_scanned_at: r.f_last_scanned_at,
90
- },
91
- symbol: {
92
- id: r.id,
93
- file_id: r.file_id,
94
- name: r.name,
95
- kind: r.kind,
96
- signature: r.signature,
97
- visibility: r.visibility,
98
- line_start: r.line_start,
99
- line_end: r.line_end,
100
- line_count: r.line_count ?? (r.line_end - r.line_start + 1),
101
- },
102
- }));
103
- }
104
-
105
- const fileQuery = `
106
- SELECT * FROM files
107
- WHERE path LIKE ? OR path LIKE ?
108
- LIMIT 10
109
- `;
110
- const files = this.db
111
- .query<FileRecord, [string, string]>(fileQuery)
112
- .all(`%/${containerName}.%`, `%\\${containerName}.%`);
113
-
114
- return files.map((file) => ({ file }));
115
- }
116
-
117
- public getAllSymbolsWithFiles(): Array<SymbolRecord & { file_path: string }> {
118
- const query = `
119
- SELECT s.*, f.path AS file_path
120
- FROM symbols s
121
- JOIN files f ON s.file_id = f.id
122
- WHERE s.kind IN ('class', 'method', 'function')
123
- ORDER BY s.name ASC
124
- `;
125
- return this.db.query<SymbolRecord & { file_path: string }, []>(query).all();
126
- }
127
-
128
- public findSymbolWithDomain(symbolName: string): {
129
- symbol_name: string;
130
- kind: string;
131
- file_path: string;
132
- domain_name: string;
133
- } | null {
134
- const query = `
135
- SELECT
136
- s.name AS symbol_name,
137
- s.kind,
138
- f.path AS file_path,
139
- d.name AS domain_name
140
- FROM symbols s
141
- JOIN files f ON s.file_id = f.id
142
- JOIN domains d ON f.domain_id = d.id
143
- WHERE s.name = ?
144
- LIMIT 1
145
- `;
146
- return this.db.query<any, [string]>(query).get(symbolName) ?? null;
147
- }
148
-
149
- public findSymbolWithMembers(symbolName: string): {
150
- symbol_name: string;
151
- kind: string;
152
- signature: string;
153
- file_path: string;
154
- domain_name: string;
155
- methods: string[];
156
- properties: string[];
157
- } | null {
158
- const rootQuery = `
159
- SELECT
160
- s.id AS symbol_id,
161
- s.file_id,
162
- s.name AS symbol_name,
163
- s.kind,
164
- s.signature,
165
- f.path AS file_path,
166
- d.name AS domain_name
167
- FROM symbols s
168
- JOIN files f ON s.file_id = f.id
169
- JOIN domains d ON f.domain_id = d.id
170
- WHERE s.name = ?
171
- LIMIT 1
172
- `;
173
- const root = this.db.query<any, [string]>(rootQuery).get(symbolName);
174
- if (!root) return null;
175
-
176
- const membersQuery = `
177
- SELECT name, kind, signature
178
- FROM symbols
179
- WHERE file_id = ? AND name LIKE ?
180
- ORDER BY line_start ASC
181
- `;
182
- const members = this.db
183
- .query<any, [number, string]>(membersQuery)
184
- .all(root.file_id, `${symbolName}::%`);
185
-
186
- const methods: string[] = [];
187
- const properties: string[] = [];
188
-
189
- for (const m of members) {
190
- if (m.kind === "method") {
191
- methods.push(m.signature);
192
- } else if (m.kind === "property") {
193
- properties.push(m.signature);
194
- }
195
- }
196
-
197
- return {
198
- symbol_name: root.symbol_name,
199
- kind: root.kind,
200
- signature: root.signature,
201
- file_path: root.file_path,
202
- domain_name: root.domain_name,
203
- methods,
204
- properties,
205
- };
206
- }
207
-
208
- public getFileWithDomain(filePath: string): {
209
- file_id: number;
210
- file_path: string;
211
- domain_name: string;
212
- } | null {
213
- const query = `
214
- SELECT
215
- f.id AS file_id,
216
- f.path AS file_path,
217
- d.name AS domain_name
218
- FROM files f
219
- JOIN domains d ON f.domain_id = d.id
220
- WHERE f.path = ?
221
- LIMIT 1
222
- `;
223
- return this.db.query<any, [string]>(query).get(filePath) ?? null;
224
- }
225
-
226
- public getHotspotSymbols(options: {
227
- minLines?: number;
228
- kind?: SymbolKind;
229
- domain?: string;
230
- limit?: number;
231
- } = {}): Array<SymbolRecord & { file_path: string; domain_name: string }> {
232
- const minLines = options.minLines ?? 30;
233
- const limit = options.limit ?? 50;
234
-
235
- let sql = `
236
- SELECT
237
- s.id,
238
- s.file_id,
239
- s.name,
240
- s.kind,
241
- s.signature,
242
- s.visibility,
243
- s.line_start,
244
- s.line_end,
245
- s.line_count,
246
- f.path AS file_path,
247
- d.name AS domain_name
248
- FROM symbols s
249
- JOIN files f ON s.file_id = f.id
250
- JOIN domains d ON f.domain_id = d.id
251
- WHERE s.line_count >= ?
252
- `;
253
-
254
- const params: any[] = [minLines];
255
-
256
- if (options.kind) {
257
- sql += " AND s.kind = ?";
258
- params.push(options.kind);
259
- }
260
-
261
- if (options.domain) {
262
- sql += " AND d.name = ?";
263
- params.push(options.domain);
264
- }
265
-
266
- sql += " ORDER BY s.line_count DESC, s.name ASC LIMIT ?";
267
- params.push(limit);
268
-
269
- return this.db.query<any, any[]>(sql).all(...params);
270
- }
271
-
272
- public getSymbolDetails(
273
- symbolQuery: string,
274
- options: { domain?: string; includeDependencies?: boolean } = {}
275
- ): GetSymbolDetailsResponse | null {
276
- const trimmed = symbolQuery.trim();
277
- if (!trimmed) return null;
278
-
279
- let sql = `
280
- SELECT
281
- s.id,
282
- s.file_id,
283
- s.name,
284
- s.kind,
285
- s.signature,
286
- s.visibility,
287
- s.line_start,
288
- s.line_end,
289
- s.line_count,
290
- f.path AS file_path,
291
- d.name AS domain_name
292
- FROM symbols s
293
- JOIN files f ON s.file_id = f.id
294
- JOIN domains d ON f.domain_id = d.id
295
- WHERE (s.name = ? OR s.name LIKE ? OR s.name LIKE ?)
296
- `;
297
-
298
- const params: any[] = [trimmed, `%::${trimmed}`, `${trimmed}::%`];
299
-
300
- if (options.domain) {
301
- sql += " AND d.name = ?";
302
- params.push(options.domain);
303
- }
304
-
305
- sql += " ORDER BY CASE WHEN s.name = ? THEN 1 WHEN s.name LIKE ? THEN 2 ELSE 3 END, s.line_start ASC LIMIT 1";
306
- params.push(trimmed, `%::${trimmed}`);
307
-
308
- const row = this.db.query<any, any[]>(sql).get(...params);
309
- if (!row) return null;
310
-
311
- // 1. Sibling methods
312
- let containerName = "";
313
- if (row.name.includes("::")) {
314
- containerName = row.name.split("::")[0];
315
- }
316
-
317
- let siblingMethods: string[] = [];
318
- if (containerName) {
319
- const siblings = this.db
320
- .query<{ name: string }, [number, string, string]>(
321
- `SELECT name FROM symbols
322
- WHERE file_id = ? AND kind = 'method' AND name != ? AND name LIKE ?
323
- ORDER BY line_start ASC LIMIT 30`
324
- )
325
- .all(row.file_id, row.name, `${containerName}::%`);
326
- siblingMethods = siblings.map((s) => s.name.replace(`${containerName}::`, ""));
327
- } else if (row.kind === "class" || row.kind === "interface" || row.kind === "trait") {
328
- const classMethods = this.db
329
- .query<{ name: string }, [number, string]>(
330
- `SELECT name FROM symbols
331
- WHERE file_id = ? AND kind = 'method' AND name LIKE ?
332
- ORDER BY line_start ASC LIMIT 30`
333
- )
334
- .all(row.file_id, `${row.name}::%`);
335
- siblingMethods = classMethods.map((s) => s.name.replace(`${row.name}::`, ""));
336
- }
337
-
338
- // 2. Dependencies Injected (from signature type-hints and constructor)
339
- const dependenciesInjected = new Set<string>();
340
- const extractTypeHints = (sig: string) => {
341
- const paramMatch = sig.match(/\(([^)]*)\)/);
342
- if (paramMatch && paramMatch[1]) {
343
- const params = paramMatch[1].split(",");
344
- const primitiveTypes = new Set([
345
- "int", "string", "bool", "boolean", "float", "array", "iterable",
346
- "callable", "void", "mixed", "object", "null", "never", "any", "number", "unknown"
347
- ]);
348
- for (const p of params) {
349
- const parts = p.trim().replace(/^\?/, "").split(/\s+/);
350
- if (parts.length >= 2) {
351
- const rawType = parts[0].replace(/\[\]$/, "").replace(/^[\\$]/, "");
352
- if (rawType && !primitiveTypes.has(rawType.toLowerCase()) && !rawType.startsWith("$")) {
353
- dependenciesInjected.add(rawType);
354
- }
355
- }
356
- }
357
- }
358
- };
359
-
360
- extractTypeHints(row.signature);
361
-
362
- // Also check constructor injection if this is a method
363
- if (containerName) {
364
- const ctor = this.db
365
- .query<{ signature: string }, [number, string, string]>(
366
- "SELECT signature FROM symbols WHERE file_id = ? AND (name = ? OR name LIKE ?) LIMIT 1"
367
- )
368
- .get(row.file_id, `${containerName}::__construct`, `%::__construct`);
369
- if (ctor) {
370
- extractTypeHints(ctor.signature);
371
- }
372
- }
373
-
374
- // 3. Outbound Calls (inspected from physical source file lines if accessible)
375
- const outboundCalls = new Set<string>();
376
- try {
377
- if (fs.existsSync(row.file_path)) {
378
- const fileContent = fs.readFileSync(row.file_path, "utf-8");
379
- const lines = fileContent.split(/\r?\n/);
380
- const start = Math.max(0, row.line_start - 1);
381
- const end = Math.min(lines.length, row.line_end);
382
- const sliceLines = lines.slice(start, end).join("\n");
383
-
384
- // Match Static invocations: ClassName::methodName(
385
- const staticCalls = sliceLines.matchAll(/([A-Z][a-zA-Z0-9_]+)::([a-zA-Z0-9_]+)\s*\(/g);
386
- for (const m of staticCalls) {
387
- if (m[1] !== "self" && m[1] !== "parent" && m[1] !== "static") {
388
- outboundCalls.add(`${m[1]}::${m[2]}`);
389
- }
390
- }
391
-
392
- // Match property calls: $this->property->method(
393
- const propCalls = sliceLines.matchAll(/\$this->([a-zA-Z0-9_]+)->([a-zA-Z0-9_]+)\s*\(/g);
394
- for (const m of propCalls) {
395
- // Capitalize property name for clean domain inference (e.g. orderService -> OrderService::cancelOrder)
396
- const propName = m[1];
397
- const methodName = m[2];
398
- const inferredClass = propName.charAt(0).toUpperCase() + propName.slice(1);
399
- outboundCalls.add(`${inferredClass}::${methodName}`);
400
- }
401
- }
402
- } catch {
403
- // Graceful fallback if file not physically accessible
404
- }
405
-
406
- return {
407
- symbol: row.name,
408
- kind: row.kind,
409
- file_path: row.file_path,
410
- domain: row.domain_name,
411
- exact_range: {
412
- start_line: row.line_start,
413
- end_line: row.line_end,
414
- total_lines: row.line_count,
415
- },
416
- signature: row.signature,
417
- visibility: row.visibility,
418
- dependencies_injected: Array.from(dependenciesInjected),
419
- outbound_calls: Array.from(outboundCalls),
420
- sibling_methods: siblingMethods.length > 0 ? siblingMethods : undefined,
421
- };
422
- }
423
-
424
- }
1
+ import * as fs from "node:fs";
2
+ import type { Database } from "bun:sqlite";
3
+ import type {
4
+ DomainRecord,
5
+ ExtractedSymbol,
6
+ FileRecord,
7
+ GetSymbolDetailsResponse,
8
+ SymbolKind,
9
+ SymbolRecord,
10
+ } from "../../../types/index.ts";
11
+
12
+ export class SymbolRepository {
13
+ constructor(private db: Database) {}
14
+
15
+ public replaceFileSymbols(fileId: number, symbols: ExtractedSymbol[]): void {
16
+ this.db.query("DELETE FROM symbols WHERE file_id = ?").run(fileId);
17
+ if (symbols.length === 0) return;
18
+
19
+ const stmt = this.db.query(
20
+ `INSERT INTO symbols (file_id, name, kind, signature, visibility, line_start, line_end, line_count)
21
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
22
+ );
23
+
24
+ for (const sym of symbols) {
25
+ const lineCount = sym.line_count ?? Math.max(1, sym.line_end - sym.line_start + 1);
26
+ stmt.run(
27
+ fileId,
28
+ sym.name,
29
+ sym.kind,
30
+ sym.signature,
31
+ sym.visibility,
32
+ sym.line_start,
33
+ sym.line_end,
34
+ lineCount
35
+ );
36
+ }
37
+ }
38
+
39
+ public getSymbolsByFileId(fileId: number): SymbolRecord[] {
40
+ return this.db
41
+ .query<SymbolRecord, [number]>(
42
+ "SELECT * FROM symbols WHERE file_id = ? ORDER BY line_start ASC"
43
+ )
44
+ .all(fileId);
45
+ }
46
+
47
+ public findSymbolsByName(name: string): Array<SymbolRecord & { file_path: string }> {
48
+ const query = `
49
+ SELECT s.*, f.path AS file_path
50
+ FROM symbols s
51
+ JOIN files f ON s.file_id = f.id
52
+ WHERE s.name = ? OR s.name LIKE ?
53
+ ORDER BY s.line_start ASC
54
+ `;
55
+ return this.db
56
+ .query<SymbolRecord & { file_path: string }, [string, string]>(query)
57
+ .all(name, `%::${name}`);
58
+ }
59
+
60
+ public findContainers(
61
+ containerName: string
62
+ ): Array<{ file: FileRecord; symbol?: SymbolRecord }> {
63
+ const symbolQuery = `
64
+ SELECT s.*, f.path AS file_path, f.id AS f_id, f.domain_id AS f_domain_id,
65
+ f.archetype AS f_archetype, f.content_hash AS f_content_hash,
66
+ f.line_count AS f_line_count, f.mtime_ms AS f_mtime_ms,
67
+ f.size_bytes AS f_size_bytes, f.last_scanned_at AS f_last_scanned_at
68
+ FROM symbols s
69
+ JOIN files f ON s.file_id = f.id
70
+ WHERE (s.name = ? OR s.name LIKE ? OR s.name LIKE ?)
71
+ AND s.kind IN ('class', 'interface', 'trait', 'enum')
72
+ LIMIT 10
73
+ `;
74
+ const rows = this.db
75
+ .query<any, [string, string, string]>(symbolQuery)
76
+ .all(containerName, `%\\${containerName}`, `%/${containerName}`);
77
+
78
+ if (rows.length > 0) {
79
+ return rows.map((r) => ({
80
+ file: {
81
+ id: r.f_id,
82
+ domain_id: r.f_domain_id,
83
+ path: r.file_path,
84
+ archetype: r.f_archetype,
85
+ content_hash: r.f_content_hash,
86
+ line_count: r.f_line_count,
87
+ mtime_ms: r.f_mtime_ms,
88
+ size_bytes: r.f_size_bytes,
89
+ last_scanned_at: r.f_last_scanned_at,
90
+ },
91
+ symbol: {
92
+ id: r.id,
93
+ file_id: r.file_id,
94
+ name: r.name,
95
+ kind: r.kind,
96
+ signature: r.signature,
97
+ visibility: r.visibility,
98
+ line_start: r.line_start,
99
+ line_end: r.line_end,
100
+ line_count: r.line_count ?? (r.line_end - r.line_start + 1),
101
+ },
102
+ }));
103
+ }
104
+
105
+ const fileQuery = `
106
+ SELECT * FROM files
107
+ WHERE path LIKE ? OR path LIKE ?
108
+ LIMIT 10
109
+ `;
110
+ const files = this.db
111
+ .query<FileRecord, [string, string]>(fileQuery)
112
+ .all(`%/${containerName}.%`, `%\\${containerName}.%`);
113
+
114
+ return files.map((file) => ({ file }));
115
+ }
116
+
117
+ public getAllSymbolsWithFiles(): Array<SymbolRecord & { file_path: string }> {
118
+ const query = `
119
+ SELECT s.*, f.path AS file_path
120
+ FROM symbols s
121
+ JOIN files f ON s.file_id = f.id
122
+ WHERE s.kind IN ('class', 'method', 'function')
123
+ ORDER BY s.name ASC
124
+ `;
125
+ return this.db.query<SymbolRecord & { file_path: string }, []>(query).all();
126
+ }
127
+
128
+ public findSymbolWithDomain(symbolName: string): {
129
+ symbol_name: string;
130
+ kind: string;
131
+ file_path: string;
132
+ domain_name: string;
133
+ } | null {
134
+ const query = `
135
+ SELECT
136
+ s.name AS symbol_name,
137
+ s.kind,
138
+ f.path AS file_path,
139
+ d.name AS domain_name
140
+ FROM symbols s
141
+ JOIN files f ON s.file_id = f.id
142
+ JOIN domains d ON f.domain_id = d.id
143
+ WHERE s.name = ?
144
+ LIMIT 1
145
+ `;
146
+ return this.db.query<any, [string]>(query).get(symbolName) ?? null;
147
+ }
148
+
149
+ public findSymbolWithMembers(symbolName: string): {
150
+ symbol_name: string;
151
+ kind: string;
152
+ signature: string;
153
+ file_path: string;
154
+ domain_name: string;
155
+ methods: string[];
156
+ properties: string[];
157
+ } | null {
158
+ const rootQuery = `
159
+ SELECT
160
+ s.id AS symbol_id,
161
+ s.file_id,
162
+ s.name AS symbol_name,
163
+ s.kind,
164
+ s.signature,
165
+ f.path AS file_path,
166
+ d.name AS domain_name
167
+ FROM symbols s
168
+ JOIN files f ON s.file_id = f.id
169
+ JOIN domains d ON f.domain_id = d.id
170
+ WHERE s.name = ?
171
+ LIMIT 1
172
+ `;
173
+ const root = this.db.query<any, [string]>(rootQuery).get(symbolName);
174
+ if (!root) return null;
175
+
176
+ const membersQuery = `
177
+ SELECT name, kind, signature
178
+ FROM symbols
179
+ WHERE file_id = ? AND name LIKE ?
180
+ ORDER BY line_start ASC
181
+ `;
182
+ const members = this.db
183
+ .query<any, [number, string]>(membersQuery)
184
+ .all(root.file_id, `${symbolName}::%`);
185
+
186
+ const methods: string[] = [];
187
+ const properties: string[] = [];
188
+
189
+ for (const m of members) {
190
+ if (m.kind === "method") {
191
+ methods.push(m.signature);
192
+ } else if (m.kind === "property") {
193
+ properties.push(m.signature);
194
+ }
195
+ }
196
+
197
+ return {
198
+ symbol_name: root.symbol_name,
199
+ kind: root.kind,
200
+ signature: root.signature,
201
+ file_path: root.file_path,
202
+ domain_name: root.domain_name,
203
+ methods,
204
+ properties,
205
+ };
206
+ }
207
+
208
+ public getFileWithDomain(filePath: string): {
209
+ file_id: number;
210
+ file_path: string;
211
+ domain_name: string;
212
+ } | null {
213
+ const query = `
214
+ SELECT
215
+ f.id AS file_id,
216
+ f.path AS file_path,
217
+ d.name AS domain_name
218
+ FROM files f
219
+ JOIN domains d ON f.domain_id = d.id
220
+ WHERE f.path = ?
221
+ LIMIT 1
222
+ `;
223
+ return this.db.query<any, [string]>(query).get(filePath) ?? null;
224
+ }
225
+
226
+ public getHotspotSymbols(options: {
227
+ minLines?: number;
228
+ kind?: SymbolKind;
229
+ domain?: string;
230
+ limit?: number;
231
+ } = {}): Array<SymbolRecord & { file_path: string; domain_name: string }> {
232
+ const minLines = options.minLines ?? 30;
233
+ const limit = options.limit ?? 50;
234
+
235
+ let sql = `
236
+ SELECT
237
+ s.id,
238
+ s.file_id,
239
+ s.name,
240
+ s.kind,
241
+ s.signature,
242
+ s.visibility,
243
+ s.line_start,
244
+ s.line_end,
245
+ s.line_count,
246
+ f.path AS file_path,
247
+ d.name AS domain_name
248
+ FROM symbols s
249
+ JOIN files f ON s.file_id = f.id
250
+ JOIN domains d ON f.domain_id = d.id
251
+ WHERE s.line_count >= ?
252
+ `;
253
+
254
+ const params: any[] = [minLines];
255
+
256
+ if (options.kind) {
257
+ sql += " AND s.kind = ?";
258
+ params.push(options.kind);
259
+ }
260
+
261
+ if (options.domain) {
262
+ sql += " AND d.name = ?";
263
+ params.push(options.domain);
264
+ }
265
+
266
+ sql += " ORDER BY s.line_count DESC, s.name ASC LIMIT ?";
267
+ params.push(limit);
268
+
269
+ return this.db.query<any, any[]>(sql).all(...params);
270
+ }
271
+
272
+ public getSymbolDetails(
273
+ symbolQuery: string,
274
+ options: { domain?: string; includeDependencies?: boolean } = {}
275
+ ): GetSymbolDetailsResponse | null {
276
+ const trimmed = symbolQuery.trim();
277
+ if (!trimmed) return null;
278
+
279
+ let sql = `
280
+ SELECT
281
+ s.id,
282
+ s.file_id,
283
+ s.name,
284
+ s.kind,
285
+ s.signature,
286
+ s.visibility,
287
+ s.line_start,
288
+ s.line_end,
289
+ s.line_count,
290
+ f.path AS file_path,
291
+ d.name AS domain_name
292
+ FROM symbols s
293
+ JOIN files f ON s.file_id = f.id
294
+ JOIN domains d ON f.domain_id = d.id
295
+ WHERE (s.name = ? OR s.name LIKE ? OR s.name LIKE ?)
296
+ `;
297
+
298
+ const params: any[] = [trimmed, `%::${trimmed}`, `${trimmed}::%`];
299
+
300
+ if (options.domain) {
301
+ sql += " AND d.name = ?";
302
+ params.push(options.domain);
303
+ }
304
+
305
+ sql += " ORDER BY CASE WHEN s.name = ? THEN 1 WHEN s.name LIKE ? THEN 2 ELSE 3 END, s.line_start ASC LIMIT 1";
306
+ params.push(trimmed, `%::${trimmed}`);
307
+
308
+ const row = this.db.query<any, any[]>(sql).get(...params);
309
+ if (!row) return null;
310
+
311
+ // 1. Sibling methods
312
+ let containerName = "";
313
+ if (row.name.includes("::")) {
314
+ containerName = row.name.split("::")[0];
315
+ }
316
+
317
+ let siblingMethods: string[] = [];
318
+ if (containerName) {
319
+ const siblings = this.db
320
+ .query<{ name: string }, [number, string, string]>(
321
+ `SELECT name FROM symbols
322
+ WHERE file_id = ? AND kind = 'method' AND name != ? AND name LIKE ?
323
+ ORDER BY line_start ASC LIMIT 30`
324
+ )
325
+ .all(row.file_id, row.name, `${containerName}::%`);
326
+ siblingMethods = siblings.map((s) => s.name.replace(`${containerName}::`, ""));
327
+ } else if (row.kind === "class" || row.kind === "interface" || row.kind === "trait") {
328
+ const classMethods = this.db
329
+ .query<{ name: string }, [number, string]>(
330
+ `SELECT name FROM symbols
331
+ WHERE file_id = ? AND kind = 'method' AND name LIKE ?
332
+ ORDER BY line_start ASC LIMIT 30`
333
+ )
334
+ .all(row.file_id, `${row.name}::%`);
335
+ siblingMethods = classMethods.map((s) => s.name.replace(`${row.name}::`, ""));
336
+ }
337
+
338
+ // 2. Dependencies Injected (from signature type-hints and constructor)
339
+ const dependenciesInjected = new Set<string>();
340
+ const extractTypeHints = (sig: string) => {
341
+ const paramMatch = sig.match(/\(([^)]*)\)/);
342
+ if (paramMatch && paramMatch[1]) {
343
+ const params = paramMatch[1].split(",");
344
+ const primitiveTypes = new Set([
345
+ "int", "string", "bool", "boolean", "float", "array", "iterable",
346
+ "callable", "void", "mixed", "object", "null", "never", "any", "number", "unknown"
347
+ ]);
348
+ for (const p of params) {
349
+ const parts = p.trim().replace(/^\?/, "").split(/\s+/);
350
+ if (parts.length >= 2) {
351
+ const rawType = parts[0].replace(/\[\]$/, "").replace(/^[\\$]/, "");
352
+ if (rawType && !primitiveTypes.has(rawType.toLowerCase()) && !rawType.startsWith("$")) {
353
+ dependenciesInjected.add(rawType);
354
+ }
355
+ }
356
+ }
357
+ }
358
+ };
359
+
360
+ extractTypeHints(row.signature);
361
+
362
+ // Also check constructor injection if this is a method
363
+ if (containerName) {
364
+ const ctor = this.db
365
+ .query<{ signature: string }, [number, string, string]>(
366
+ "SELECT signature FROM symbols WHERE file_id = ? AND (name = ? OR name LIKE ?) LIMIT 1"
367
+ )
368
+ .get(row.file_id, `${containerName}::__construct`, `%::__construct`);
369
+ if (ctor) {
370
+ extractTypeHints(ctor.signature);
371
+ }
372
+ }
373
+
374
+ // 3. Outbound Calls (inspected from physical source file lines if accessible)
375
+ const outboundCalls = new Set<string>();
376
+ try {
377
+ if (fs.existsSync(row.file_path)) {
378
+ const fileContent = fs.readFileSync(row.file_path, "utf-8");
379
+ const lines = fileContent.split(/\r?\n/);
380
+ const start = Math.max(0, row.line_start - 1);
381
+ const end = Math.min(lines.length, row.line_end);
382
+ const sliceLines = lines.slice(start, end).join("\n");
383
+
384
+ // Match Static invocations: ClassName::methodName(
385
+ const staticCalls = sliceLines.matchAll(/([A-Z][a-zA-Z0-9_]+)::([a-zA-Z0-9_]+)\s*\(/g);
386
+ for (const m of staticCalls) {
387
+ if (m[1] !== "self" && m[1] !== "parent" && m[1] !== "static") {
388
+ outboundCalls.add(`${m[1]}::${m[2]}`);
389
+ }
390
+ }
391
+
392
+ // Match property calls: $this->property->method(
393
+ const propCalls = sliceLines.matchAll(/\$this->([a-zA-Z0-9_]+)->([a-zA-Z0-9_]+)\s*\(/g);
394
+ for (const m of propCalls) {
395
+ // Capitalize property name for clean domain inference (e.g. orderService -> OrderService::cancelOrder)
396
+ const propName = m[1];
397
+ const methodName = m[2];
398
+ const inferredClass = propName.charAt(0).toUpperCase() + propName.slice(1);
399
+ outboundCalls.add(`${inferredClass}::${methodName}`);
400
+ }
401
+ }
402
+ } catch {
403
+ // Graceful fallback if file not physically accessible
404
+ }
405
+
406
+ return {
407
+ symbol: row.name,
408
+ kind: row.kind,
409
+ file_path: row.file_path,
410
+ domain: row.domain_name,
411
+ exact_range: {
412
+ start_line: row.line_start,
413
+ end_line: row.line_end,
414
+ total_lines: row.line_count,
415
+ },
416
+ signature: row.signature,
417
+ visibility: row.visibility,
418
+ dependencies_injected: Array.from(dependenciesInjected),
419
+ outbound_calls: Array.from(outboundCalls),
420
+ sibling_methods: siblingMethods.length > 0 ? siblingMethods : undefined,
421
+ };
422
+ }
423
+
424
+ }