septum 0.1.4 → 0.1.6

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