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,405 +1,543 @@
1
- import type { FileRecord, SymbolKind, SymbolRecord } from "../database/repository.ts";
2
- import type { SeptumRepository } from "../database/repository.ts";
3
-
4
- export interface ParsedSymbolQuery {
5
- raw: string;
6
- container?: string; // e.g. "OrderController" or "App\\Http\\Controllers\\OrderController"
7
- member?: string; // e.g. "calculateTotal"
8
- kind?: SymbolKind;
9
- }
10
-
11
- export interface SuggestionMatch {
12
- name: string;
13
- kind: string;
14
- signature: string;
15
- line_start: number;
16
- line_end: number;
17
- similarity_score: number; // 0.0 - 1.0
18
- file_path?: string;
19
- }
20
-
21
- export interface SymbolLocationResult {
22
- query: string;
23
- parsed: ParsedSymbolQuery;
24
- found: boolean;
25
- file_path?: string;
26
- container_name?: string;
27
- exact_symbol?: {
28
- name: string;
29
- kind: string;
30
- signature: string;
31
- visibility: string;
32
- line_start: number;
33
- line_end: number;
34
- };
35
- sibling_methods?: string[];
36
- suggestions: SuggestionMatch[];
37
- message: string;
38
- }
39
-
40
- export class SymbolLocator {
41
- constructor(private repo: SeptumRepository) {}
42
-
43
- /**
44
- * Parse error log strings, method call syntax, or symbol names into container & member.
45
- * Examples:
46
- * - "Method OrderController::calculateTotal() does not exist"
47
- * - "Call to undefined method App\\Http\\Controllers\\OrderController::calculateTotal()"
48
- * - "OrderController->calculateTotal()"
49
- * - "OrderController.calculateTotal"
50
- * - "OrderController"
51
- * - "calculateTotal"
52
- */
53
- public parseQuery(rawQuery: string): ParsedSymbolQuery {
54
- const trimmed = rawQuery.trim();
55
-
56
- // 1. Error string matching: "Method X::y() does not exist" or "Call to undefined method X::y()"
57
- const methodNotExistMatch = trimmed.match(/(?:Method|method)\s+([A-Za-z0-9_\\\/\.]+)::([A-Za-z0-9_]+)\s*\(\)?/i);
58
- if (methodNotExistMatch) {
59
- return {
60
- raw: trimmed,
61
- container: methodNotExistMatch[1],
62
- member: methodNotExistMatch[2],
63
- kind: "method",
64
- };
65
- }
66
-
67
- // 2. Python / JS error: "'X' object has no attribute 'y'"
68
- const pyAttrMatch = trimmed.match(/['"]?([A-Za-z0-9_]+)['"]?\s+object has no attribute\s+['"]?([A-Za-z0-9_]+)['"]?/i);
69
- if (pyAttrMatch) {
70
- return {
71
- raw: trimmed,
72
- container: pyAttrMatch[1],
73
- member: pyAttrMatch[2],
74
- };
75
- }
76
-
77
- // 3. Class::member or Class->member or Class.member
78
- const staticCallMatch = trimmed.match(/^([A-Za-z0-9_\\\/\.]+)::([A-Za-z0-9_]+)(?:\(\))?$/);
79
- if (staticCallMatch) {
80
- return {
81
- raw: trimmed,
82
- container: staticCallMatch[1],
83
- member: staticCallMatch[2],
84
- kind: "method",
85
- };
86
- }
87
-
88
- const arrowCallMatch = trimmed.match(/^([A-Za-z0-9_\\\/\.]+)->([A-Za-z0-9_]+)(?:\(\))?$/);
89
- if (arrowCallMatch) {
90
- return {
91
- raw: trimmed,
92
- container: arrowCallMatch[1],
93
- member: arrowCallMatch[2],
94
- kind: "method",
95
- };
96
- }
97
-
98
- const dotCallMatch = trimmed.match(/^([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)(?:\(\))?$/);
99
- if (dotCallMatch) {
100
- return {
101
- raw: trimmed,
102
- container: dotCallMatch[1],
103
- member: dotCallMatch[2],
104
- };
105
- }
106
-
107
- // 4. Function call: "calculateTotal()"
108
- const funcMatch = trimmed.match(/^([A-Za-z0-9_]+)\(\)$/);
109
- if (funcMatch) {
110
- return {
111
- raw: trimmed,
112
- member: funcMatch[1],
113
- kind: "function",
114
- };
115
- }
116
-
117
- // 5. Bare word: could be class or function
118
- return {
119
- raw: trimmed,
120
- member: trimmed,
121
- };
122
- }
123
-
124
- /**
125
- * Locate the symbol or diagnostic resolution in the database.
126
- */
127
- public locate(rawQuery: string, domainFilter?: string): SymbolLocationResult {
128
- const parsed = this.parseQuery(rawQuery);
129
-
130
- // If a container (Class / Interface) is specified
131
- if (parsed.container) {
132
- return this.locateWithContainer(parsed, domainFilter);
133
- }
134
-
135
- // If only member or bare name is provided
136
- return this.locateBareMember(parsed, domainFilter);
137
- }
138
-
139
- private locateWithContainer(
140
- parsed: ParsedSymbolQuery,
141
- domainFilter?: string
142
- ): SymbolLocationResult {
143
- const containerQuery = parsed.container!;
144
- const simpleContainerName = containerQuery.split(/\\|\//).pop() || containerQuery;
145
- const memberName = parsed.member;
146
-
147
- // Search for container in symbols table or files table
148
- const containers = this.repo.findContainers(simpleContainerName);
149
-
150
- if (containers.length === 0) {
151
- return {
152
- query: parsed.raw,
153
- parsed,
154
- found: false,
155
- suggestions: [],
156
- message: `Container '${containerQuery}' was not found in any cataloged files. Run 'septum ingest' if recently added.`,
157
- };
158
- }
159
-
160
- // Pick primary container matching
161
- const primary = containers[0];
162
- const fileSymbols = this.repo.getSymbolsByFileId(primary.file.id);
163
-
164
- // Extract methods in this container / file
165
- const methodSymbols = fileSymbols.filter(
166
- (s) => s.kind === "method" || s.kind === "function"
167
- );
168
-
169
- const siblingMethodNames = Array.from(
170
- new Set(
171
- methodSymbols.map((m) => {
172
- if (m.name.includes("::")) {
173
- return m.name.split("::")[1];
174
- }
175
- return m.name;
176
- })
177
- )
178
- );
179
-
180
- if (!memberName) {
181
- // User only asked for the container
182
- return {
183
- query: parsed.raw,
184
- parsed,
185
- found: true,
186
- file_path: primary.file.path,
187
- container_name: simpleContainerName,
188
- exact_symbol: primary.symbol
189
- ? {
190
- name: primary.symbol.name,
191
- kind: primary.symbol.kind,
192
- signature: primary.symbol.signature,
193
- visibility: primary.symbol.visibility,
194
- line_start: primary.symbol.line_start,
195
- line_end: primary.symbol.line_end,
196
- }
197
- : undefined,
198
- sibling_methods: siblingMethodNames,
199
- suggestions: [],
200
- message: `Found container '${simpleContainerName}' in ${primary.file.path} with ${siblingMethodNames.length} methods.`,
201
- };
202
- }
203
-
204
- // Search for exact member match
205
- const exactMatch = methodSymbols.find((s) => {
206
- const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
207
- return bareName.toLowerCase() === memberName.toLowerCase();
208
- });
209
-
210
- if (exactMatch) {
211
- return {
212
- query: parsed.raw,
213
- parsed,
214
- found: true,
215
- file_path: primary.file.path,
216
- container_name: simpleContainerName,
217
- exact_symbol: {
218
- name: exactMatch.name,
219
- kind: exactMatch.kind,
220
- signature: exactMatch.signature,
221
- visibility: exactMatch.visibility,
222
- line_start: exactMatch.line_start,
223
- line_end: exactMatch.line_end,
224
- },
225
- sibling_methods: siblingMethodNames,
226
- suggestions: [],
227
- message: `Exact method '${memberName}' found in ${primary.file.path} at line ${exactMatch.line_start}.`,
228
- };
229
- }
230
-
231
- // Member NOT found in container: calculate fuzzy suggestions
232
- const suggestions: SuggestionMatch[] = methodSymbols
233
- .map((s) => {
234
- const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
235
- const score = calculateSimilarity(memberName, bareName);
236
- return {
237
- name: bareName,
238
- kind: s.kind,
239
- signature: s.signature,
240
- line_start: s.line_start,
241
- line_end: s.line_end,
242
- similarity_score: Math.round(score * 100) / 100,
243
- };
244
- })
245
- .filter((s) => s.similarity_score >= 0.25)
246
- .sort((a, b) => b.similarity_score - a.similarity_score)
247
- .slice(0, 5);
248
-
249
- const topSuggestion = suggestions.length > 0 ? suggestions[0].name : null;
250
- const hint = topSuggestion
251
- ? ` Method '${memberName}' does not exist! Did you mean '${topSuggestion}'?`
252
- : ` Method '${memberName}' does not exist.`;
253
-
254
- return {
255
- query: parsed.raw,
256
- parsed,
257
- found: false,
258
- file_path: primary.file.path,
259
- container_name: simpleContainerName,
260
- sibling_methods: siblingMethodNames,
261
- suggestions,
262
- message: `File: ${primary.file.path}.${hint} Available methods: [${siblingMethodNames.join(", ")}].`,
263
- };
264
- }
265
-
266
- private locateBareMember(
267
- parsed: ParsedSymbolQuery,
268
- domainFilter?: string
269
- ): SymbolLocationResult {
270
- const targetName = parsed.member!;
271
- const directMatches = this.repo.findSymbolsByName(targetName);
272
-
273
- if (directMatches.length > 0) {
274
- const first = directMatches[0];
275
- return {
276
- query: parsed.raw,
277
- parsed,
278
- found: true,
279
- file_path: first.file_path,
280
- container_name: first.name.includes("::") ? first.name.split("::")[0] : undefined,
281
- exact_symbol: {
282
- name: first.name,
283
- kind: first.kind,
284
- signature: first.signature,
285
- visibility: first.visibility,
286
- line_start: first.line_start,
287
- line_end: first.line_end,
288
- },
289
- suggestions: [],
290
- message: `Found exact symbol '${first.name}' in ${first.file_path}:${first.line_start}`,
291
- };
292
- }
293
-
294
- // If bare name is a class name
295
- const containers = this.repo.findContainers(targetName);
296
- if (containers.length > 0) {
297
- const primary = containers[0];
298
- const fileSymbols = this.repo.getSymbolsByFileId(primary.file.id);
299
- const siblingMethods = fileSymbols
300
- .filter((s) => s.kind === "method" || s.kind === "function")
301
- .map((s) => (s.name.includes("::") ? s.name.split("::")[1] : s.name));
302
-
303
- return {
304
- query: parsed.raw,
305
- parsed,
306
- found: true,
307
- file_path: primary.file.path,
308
- container_name: targetName,
309
- exact_symbol: primary.symbol
310
- ? {
311
- name: primary.symbol.name,
312
- kind: primary.symbol.kind,
313
- signature: primary.symbol.signature,
314
- visibility: primary.symbol.visibility,
315
- line_start: primary.symbol.line_start,
316
- line_end: primary.symbol.line_end,
317
- }
318
- : undefined,
319
- sibling_methods: Array.from(new Set(siblingMethods)),
320
- suggestions: [],
321
- message: `Found container '${targetName}' in ${primary.file.path}`,
322
- };
323
- }
324
-
325
- // Nothing found directly: Search all symbols for fuzzy suggestions
326
- const allSymbols = this.repo.getAllSymbolsWithFiles();
327
- const suggestions: SuggestionMatch[] = allSymbols
328
- .map((s) => {
329
- const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
330
- const score = calculateSimilarity(targetName, bareName);
331
- return {
332
- name: s.name,
333
- kind: s.kind,
334
- signature: `${s.signature} (${s.file_path}:${s.line_start})`,
335
- line_start: s.line_start,
336
- line_end: s.line_end,
337
- similarity_score: Math.round(score * 100) / 100,
338
- };
339
- })
340
- .filter((s) => s.similarity_score >= 0.35)
341
- .sort((a, b) => b.similarity_score - a.similarity_score)
342
- .slice(0, 5);
343
-
344
- return {
345
- query: parsed.raw,
346
- parsed,
347
- found: false,
348
- suggestions,
349
- message: `Symbol '${targetName}' not found. ${suggestions.length > 0 ? `Did you mean '${suggestions[0].name}'?` : ""}`,
350
- };
351
- }
352
- }
353
-
354
- /**
355
- * Standard Levenshtein distance with token substring bonus.
356
- */
357
- export function calculateSimilarity(source: string, target: string): number {
358
- const s1 = source.toLowerCase();
359
- const s2 = target.toLowerCase();
360
-
361
- if (s1 === s2) return 1.0;
362
- if (!s1 || !s2) return 0.0;
363
-
364
- // Substring inclusion bonus
365
- if (s2.includes(s1) || s1.includes(s2)) {
366
- const minLen = Math.min(s1.length, s2.length);
367
- const maxLen = Math.max(s1.length, s2.length);
368
- return 0.5 + 0.5 * (minLen / maxLen);
369
- }
370
-
371
- // Token-based matching (e.g. calculateTotal vs recalculateOrder shares 'calculate')
372
- const tokenize = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[\s_-]+/);
373
- const t1 = tokenize(s1);
374
- const t2 = tokenize(s2);
375
- const sharedTokens = t1.filter((token) => t2.some((t) => t.includes(token) || token.includes(t)));
376
- const tokenBonus = sharedTokens.length > 0 ? 0.3 : 0.0;
377
-
378
- const len1 = s1.length;
379
- const len2 = s2.length;
380
- const matrix: number[][] = [];
381
-
382
- for (let i = 0; i <= len1; i++) {
383
- matrix[i] = [i];
384
- }
385
- for (let j = 0; j <= len2; j++) {
386
- matrix[0][j] = j;
387
- }
388
-
389
- for (let i = 1; i <= len1; i++) {
390
- for (let j = 1; j <= len2; j++) {
391
- const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
392
- matrix[i][j] = Math.min(
393
- matrix[i - 1][j] + 1, // deletion
394
- matrix[i][j - 1] + 1, // insertion
395
- matrix[i - 1][j - 1] + cost // substitution
396
- );
397
- }
398
- }
399
-
400
- const distance = matrix[len1][len2];
401
- const maxLen = Math.max(len1, len2);
402
- const rawSimilarity = 1.0 - distance / maxLen;
403
-
404
- return Math.min(1.0, Math.max(0.0, rawSimilarity + tokenBonus));
405
- }
1
+ import type { FileRecord, SymbolKind, SymbolRecord } from "../database/repository.ts";
2
+ import type { SeptumRepository } from "../database/repository.ts";
3
+
4
+ export interface ParsedSymbolQuery {
5
+ raw: string;
6
+ container?: string; // e.g. "OrderController" or "App\\Http\\Controllers\\OrderController"
7
+ member?: string; // e.g. "calculateTotal"
8
+ kind?: SymbolKind;
9
+ }
10
+
11
+ export interface SuggestionMatch {
12
+ name: string;
13
+ kind: string;
14
+ signature: string;
15
+ line_start: number;
16
+ line_end: number;
17
+ similarity_score: number; // 0.0 - 1.0
18
+ file_path?: string;
19
+ }
20
+
21
+ export interface SymbolLocationResult {
22
+ query: string;
23
+ parsed: ParsedSymbolQuery;
24
+ found: boolean;
25
+ file_path?: string;
26
+ container_name?: string;
27
+ exact_symbol?: {
28
+ name: string;
29
+ kind: string;
30
+ signature: string;
31
+ visibility: string;
32
+ line_start: number;
33
+ line_end: number;
34
+ };
35
+ sibling_methods?: string[];
36
+ suggestions: SuggestionMatch[];
37
+ message: string;
38
+ }
39
+
40
+ export class SymbolLocator {
41
+ constructor(private repo: SeptumRepository) {}
42
+
43
+ /**
44
+ * Parse error log strings, method call syntax, or symbol names into container & member.
45
+ * Examples:
46
+ * - "Method OrderController::calculateTotal() does not exist"
47
+ * - "Call to undefined method App\\Http\\Controllers\\OrderController::calculateTotal()"
48
+ * - "OrderController->calculateTotal()"
49
+ * - "OrderController.calculateTotal"
50
+ * - "OrderController"
51
+ * - "calculateTotal"
52
+ */
53
+ public parseQuery(rawQuery: string): ParsedSymbolQuery {
54
+ const trimmed = rawQuery.trim();
55
+
56
+ // 1. Error string matching: "Method X::y() does not exist" or "Call to undefined method X::y()"
57
+ const methodNotExistMatch = trimmed.match(/(?:Method|method)\s+([A-Za-z0-9_\\\/\.]+)::([A-Za-z0-9_]+)\s*\(\)?/i);
58
+ if (methodNotExistMatch) {
59
+ return {
60
+ raw: trimmed,
61
+ container: methodNotExistMatch[1],
62
+ member: methodNotExistMatch[2],
63
+ kind: "method",
64
+ };
65
+ }
66
+
67
+ // 2. Python / JS error: "'X' object has no attribute 'y'"
68
+ const pyAttrMatch = trimmed.match(/['"]?([A-Za-z0-9_]+)['"]?\s+object has no attribute\s+['"]?([A-Za-z0-9_]+)['"]?/i);
69
+ if (pyAttrMatch) {
70
+ return {
71
+ raw: trimmed,
72
+ container: pyAttrMatch[1],
73
+ member: pyAttrMatch[2],
74
+ };
75
+ }
76
+
77
+ // 3. Class::member or Class->member or Class.member
78
+ const staticCallMatch = trimmed.match(/^([A-Za-z0-9_\\\/\.]+)::([A-Za-z0-9_]+)(?:\(\))?$/);
79
+ if (staticCallMatch) {
80
+ return {
81
+ raw: trimmed,
82
+ container: staticCallMatch[1],
83
+ member: staticCallMatch[2],
84
+ kind: "method",
85
+ };
86
+ }
87
+
88
+ const arrowCallMatch = trimmed.match(/^([A-Za-z0-9_\\\/\.]+)->([A-Za-z0-9_]+)(?:\(\))?$/);
89
+ if (arrowCallMatch) {
90
+ return {
91
+ raw: trimmed,
92
+ container: arrowCallMatch[1],
93
+ member: arrowCallMatch[2],
94
+ kind: "method",
95
+ };
96
+ }
97
+
98
+ const dotCallMatch = trimmed.match(/^([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)(?:\(\))?$/);
99
+ if (dotCallMatch) {
100
+ return {
101
+ raw: trimmed,
102
+ container: dotCallMatch[1],
103
+ member: dotCallMatch[2],
104
+ };
105
+ }
106
+
107
+ // 4. Function call: "calculateTotal()"
108
+ const funcMatch = trimmed.match(/^([A-Za-z0-9_]+)\(\)$/);
109
+ if (funcMatch) {
110
+ return {
111
+ raw: trimmed,
112
+ member: funcMatch[1],
113
+ kind: "function",
114
+ };
115
+ }
116
+
117
+ // 5. Bare word: could be class or function
118
+ return {
119
+ raw: trimmed,
120
+ member: trimmed,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Locate the symbol or diagnostic resolution in the database.
126
+ */
127
+ public locate(rawQuery: string, domainFilter?: string): SymbolLocationResult {
128
+ const parsed = this.parseQuery(rawQuery);
129
+
130
+ // If a container (Class / Interface) is specified
131
+ if (parsed.container) {
132
+ return this.locateWithContainer(parsed, domainFilter);
133
+ }
134
+
135
+ // If only member or bare name is provided
136
+ return this.locateBareMember(parsed, domainFilter);
137
+ }
138
+
139
+ private locateWithContainer(
140
+ parsed: ParsedSymbolQuery,
141
+ domainFilter?: string
142
+ ): SymbolLocationResult {
143
+ const containerQuery = parsed.container!;
144
+ const simpleContainerName = containerQuery.split(/\\|\//).pop() || containerQuery;
145
+ const memberName = parsed.member;
146
+
147
+ // Search for container in symbols table or files table
148
+ const containers = this.repo.findContainers(simpleContainerName);
149
+
150
+ if (containers.length === 0) {
151
+ // Container not found directly: search for fuzzy container matches across the catalog
152
+ const allSymbols = this.repo.getAllSymbolsWithFiles();
153
+ const containerCandidates = allSymbols.filter((s) =>
154
+ ["class", "interface", "trait", "enum", "struct"].includes(s.kind)
155
+ );
156
+
157
+ // Score containers by similarity
158
+ const scoredContainers = containerCandidates
159
+ .map((s) => {
160
+ const bare = s.name.includes("::")
161
+ ? s.name.split("::")[0]
162
+ : s.name.split(/\\|\//).pop() || s.name;
163
+ const score = calculateSimilarity(simpleContainerName, bare);
164
+ return {
165
+ name: s.name,
166
+ bare,
167
+ kind: s.kind,
168
+ signature: s.signature,
169
+ line_start: s.line_start,
170
+ line_end: s.line_end,
171
+ file_path: s.file_path,
172
+ similarity_score: Math.round(score * 100) / 100,
173
+ };
174
+ })
175
+ .filter((c) => c.similarity_score >= 0.3)
176
+ .sort((a, b) => b.similarity_score - a.similarity_score);
177
+
178
+ // Deduplicate by name + file_path
179
+ const seenContainers = new Set<string>();
180
+ const uniqueContainers = scoredContainers.filter((c) => {
181
+ const key = `${c.name}@${c.file_path}`;
182
+ if (seenContainers.has(key)) return false;
183
+ seenContainers.add(key);
184
+ return true;
185
+ }).slice(0, 5);
186
+
187
+ // If a member was requested, prioritize methods within top fuzzy candidate containers
188
+ let memberSuggestions: SuggestionMatch[] = [];
189
+ if (memberName) {
190
+ const topCandidateFilePaths = new Set(uniqueContainers.map((c) => c.file_path));
191
+
192
+ const candidateMethods = allSymbols.filter(
193
+ (s) =>
194
+ (s.kind === "method" || s.kind === "function") &&
195
+ topCandidateFilePaths.has(s.file_path)
196
+ );
197
+
198
+ const methodsToScore =
199
+ candidateMethods.length > 0
200
+ ? candidateMethods
201
+ : allSymbols
202
+ .filter(
203
+ (s) =>
204
+ (s.kind === "method" || s.kind === "function") &&
205
+ Math.abs(s.name.length - memberName.length) <= 8
206
+ )
207
+ .slice(0, 100);
208
+
209
+ memberSuggestions = methodsToScore
210
+ .map((s) => {
211
+ const bare = s.name.includes("::") ? s.name.split("::")[1] : s.name;
212
+ const score = calculateSimilarity(memberName, bare);
213
+ return {
214
+ name: s.name,
215
+ kind: s.kind,
216
+ signature: `${s.signature} (${s.file_path}:${s.line_start})`,
217
+ line_start: s.line_start,
218
+ line_end: s.line_end,
219
+ similarity_score: Math.round(score * 100) / 100,
220
+ file_path: s.file_path,
221
+ };
222
+ })
223
+ .filter((s) => s.similarity_score >= 0.35)
224
+ .sort((a, b) => b.similarity_score - a.similarity_score)
225
+ .slice(0, 5);
226
+ }
227
+
228
+ const suggestions: SuggestionMatch[] = uniqueContainers.map((c) => ({
229
+ name: c.name,
230
+ kind: c.kind,
231
+ signature: `${c.signature || c.kind} (${c.file_path}:${c.line_start})`,
232
+ line_start: c.line_start,
233
+ line_end: c.line_end,
234
+ similarity_score: c.similarity_score,
235
+ file_path: c.file_path,
236
+ }));
237
+
238
+ // Combine container suggestions with member suggestions if available
239
+ const combinedSuggestions = [...suggestions, ...memberSuggestions]
240
+ .sort((a, b) => b.similarity_score - a.similarity_score)
241
+ .slice(0, 7);
242
+
243
+ const topSuggestion = combinedSuggestions.length > 0 ? combinedSuggestions[0].name : null;
244
+ const hint = topSuggestion
245
+ ? ` Container '${containerQuery}' was not found. Did you mean '${topSuggestion}'?`
246
+ : ` Container '${containerQuery}' was not found in cataloged files.`;
247
+
248
+ return {
249
+ query: parsed.raw,
250
+ parsed,
251
+ found: false,
252
+ suggestions: combinedSuggestions,
253
+ message: `${hint} Run 'septum ingest' if this file was recently added.`,
254
+ };
255
+ }
256
+
257
+ // Pick primary container matching
258
+ const primary = containers[0];
259
+ const fileSymbols = this.repo.getSymbolsByFileId(primary.file.id);
260
+
261
+ // Extract methods in this container / file
262
+ const methodSymbols = fileSymbols.filter(
263
+ (s) => s.kind === "method" || s.kind === "function"
264
+ );
265
+
266
+ const siblingMethodNames = Array.from(
267
+ new Set(
268
+ methodSymbols.map((m) => {
269
+ if (m.name.includes("::")) {
270
+ return m.name.split("::")[1];
271
+ }
272
+ return m.name;
273
+ })
274
+ )
275
+ );
276
+
277
+ if (!memberName) {
278
+ // User only asked for the container
279
+ return {
280
+ query: parsed.raw,
281
+ parsed,
282
+ found: true,
283
+ file_path: primary.file.path,
284
+ container_name: simpleContainerName,
285
+ exact_symbol: primary.symbol
286
+ ? {
287
+ name: primary.symbol.name,
288
+ kind: primary.symbol.kind,
289
+ signature: primary.symbol.signature,
290
+ visibility: primary.symbol.visibility,
291
+ line_start: primary.symbol.line_start,
292
+ line_end: primary.symbol.line_end,
293
+ }
294
+ : undefined,
295
+ sibling_methods: siblingMethodNames,
296
+ suggestions: [],
297
+ message: `Found container '${simpleContainerName}' in ${primary.file.path} with ${siblingMethodNames.length} methods.`,
298
+ };
299
+ }
300
+
301
+ // Search for exact member match
302
+ const exactMatch = methodSymbols.find((s) => {
303
+ const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
304
+ return bareName.toLowerCase() === memberName.toLowerCase();
305
+ });
306
+
307
+ if (exactMatch) {
308
+ return {
309
+ query: parsed.raw,
310
+ parsed,
311
+ found: true,
312
+ file_path: primary.file.path,
313
+ container_name: simpleContainerName,
314
+ exact_symbol: {
315
+ name: exactMatch.name,
316
+ kind: exactMatch.kind,
317
+ signature: exactMatch.signature,
318
+ visibility: exactMatch.visibility,
319
+ line_start: exactMatch.line_start,
320
+ line_end: exactMatch.line_end,
321
+ },
322
+ sibling_methods: siblingMethodNames,
323
+ suggestions: [],
324
+ message: `Exact method '${memberName}' found in ${primary.file.path} at line ${exactMatch.line_start}.`,
325
+ };
326
+ }
327
+
328
+ // Member NOT found in container: calculate fuzzy suggestions
329
+ const suggestions: SuggestionMatch[] = methodSymbols
330
+ .map((s) => {
331
+ const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
332
+ const score = calculateSimilarity(memberName, bareName);
333
+ return {
334
+ name: bareName,
335
+ kind: s.kind,
336
+ signature: s.signature,
337
+ line_start: s.line_start,
338
+ line_end: s.line_end,
339
+ similarity_score: Math.round(score * 100) / 100,
340
+ };
341
+ })
342
+ .filter((s) => s.similarity_score >= 0.25)
343
+ .sort((a, b) => b.similarity_score - a.similarity_score)
344
+ .slice(0, 5);
345
+
346
+ const topSuggestion = suggestions.length > 0 ? suggestions[0].name : null;
347
+ const hint = topSuggestion
348
+ ? ` Method '${memberName}' does not exist! Did you mean '${topSuggestion}'?`
349
+ : ` Method '${memberName}' does not exist.`;
350
+
351
+ return {
352
+ query: parsed.raw,
353
+ parsed,
354
+ found: false,
355
+ file_path: primary.file.path,
356
+ container_name: simpleContainerName,
357
+ sibling_methods: siblingMethodNames,
358
+ suggestions,
359
+ message: `File: ${primary.file.path}.${hint} Available methods: [${siblingMethodNames.join(", ")}].`,
360
+ };
361
+ }
362
+
363
+ private locateBareMember(
364
+ parsed: ParsedSymbolQuery,
365
+ domainFilter?: string
366
+ ): SymbolLocationResult {
367
+ const targetName = parsed.member!;
368
+ const directMatches = this.repo.findSymbolsByName(targetName);
369
+
370
+ if (directMatches.length > 0) {
371
+ const first = directMatches[0];
372
+ return {
373
+ query: parsed.raw,
374
+ parsed,
375
+ found: true,
376
+ file_path: first.file_path,
377
+ container_name: first.name.includes("::") ? first.name.split("::")[0] : undefined,
378
+ exact_symbol: {
379
+ name: first.name,
380
+ kind: first.kind,
381
+ signature: first.signature,
382
+ visibility: first.visibility,
383
+ line_start: first.line_start,
384
+ line_end: first.line_end,
385
+ },
386
+ suggestions: [],
387
+ message: `Found exact symbol '${first.name}' in ${first.file_path}:${first.line_start}`,
388
+ };
389
+ }
390
+
391
+ // If bare name is a class name
392
+ const containers = this.repo.findContainers(targetName);
393
+ if (containers.length > 0) {
394
+ const primary = containers[0];
395
+ const fileSymbols = this.repo.getSymbolsByFileId(primary.file.id);
396
+ const siblingMethods = fileSymbols
397
+ .filter((s) => s.kind === "method" || s.kind === "function")
398
+ .map((s) => (s.name.includes("::") ? s.name.split("::")[1] : s.name));
399
+
400
+ return {
401
+ query: parsed.raw,
402
+ parsed,
403
+ found: true,
404
+ file_path: primary.file.path,
405
+ container_name: targetName,
406
+ exact_symbol: primary.symbol
407
+ ? {
408
+ name: primary.symbol.name,
409
+ kind: primary.symbol.kind,
410
+ signature: primary.symbol.signature,
411
+ visibility: primary.symbol.visibility,
412
+ line_start: primary.symbol.line_start,
413
+ line_end: primary.symbol.line_end,
414
+ }
415
+ : undefined,
416
+ sibling_methods: Array.from(new Set(siblingMethods)),
417
+ suggestions: [],
418
+ message: `Found container '${targetName}' in ${primary.file.path}`,
419
+ };
420
+ }
421
+
422
+ // Nothing found directly: Search all symbols for fuzzy suggestions
423
+ const allSymbols = this.repo.getAllSymbolsWithFiles();
424
+ const suggestions: SuggestionMatch[] = allSymbols
425
+ .map((s) => {
426
+ const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
427
+ const scoreBare = calculateSimilarity(targetName, bareName);
428
+ const scoreFull = calculateSimilarity(targetName, s.name);
429
+ const score = Math.max(scoreBare, scoreFull);
430
+ return {
431
+ name: s.name,
432
+ kind: s.kind,
433
+ signature: `${s.signature} (${s.file_path}:${s.line_start})`,
434
+ line_start: s.line_start,
435
+ line_end: s.line_end,
436
+ similarity_score: Math.round(score * 100) / 100,
437
+ file_path: s.file_path,
438
+ };
439
+ })
440
+ .filter((s) => s.similarity_score >= 0.3)
441
+ .sort((a, b) => b.similarity_score - a.similarity_score)
442
+ .slice(0, 5);
443
+
444
+ return {
445
+ query: parsed.raw,
446
+ parsed,
447
+ found: false,
448
+ suggestions,
449
+ message: `Symbol '${targetName}' not found. ${suggestions.length > 0 ? `Did you mean '${suggestions[0].name}'?` : ""}`,
450
+ };
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Enhanced similarity matching:
456
+ * Combines Damerau-Levenshtein distance (handling typos & transpositions),
457
+ * prefix/suffix matching, substring inclusion, and token Jaccard similarity.
458
+ */
459
+ export function calculateSimilarity(source: string, target: string): number {
460
+ const s1 = source.toLowerCase();
461
+ const s2 = target.toLowerCase();
462
+
463
+ if (s1 === s2) return 1.0;
464
+ if (!s1 || !s2) return 0.0;
465
+
466
+ // Prefix bonus: highly relevant for autocomplete-style / partial searches
467
+ let prefixBonus = 0;
468
+ if (s2.startsWith(s1) || s1.startsWith(s2)) {
469
+ const minLen = Math.min(s1.length, s2.length);
470
+ const maxLen = Math.max(s1.length, s2.length);
471
+ prefixBonus = 0.25 * (minLen / maxLen);
472
+ }
473
+
474
+ // Substring inclusion bonus
475
+ let substringBonus = 0;
476
+ if (s2.includes(s1) || s1.includes(s2)) {
477
+ const minLen = Math.min(s1.length, s2.length);
478
+ const maxLen = Math.max(s1.length, s2.length);
479
+ substringBonus = 0.3 * (minLen / maxLen);
480
+ }
481
+
482
+ // Token-based matching (CamelCase, snake_case, kebab-case, namespaces)
483
+ const tokenize = (str: string) =>
484
+ str
485
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
486
+ .toLowerCase()
487
+ .split(/[\s_\-\.\:\/\\]+/)
488
+ .filter((t) => t.length > 1);
489
+
490
+ const t1 = tokenize(source);
491
+ const t2 = tokenize(target);
492
+ let tokenBonus = 0;
493
+ if (t1.length > 0 && t2.length > 0) {
494
+ const sharedTokens = t1.filter((token) =>
495
+ t2.some((t) => t.includes(token) || token.includes(t))
496
+ );
497
+ const allUniqueTokens = new Set([...t1, ...t2]);
498
+ const jaccard = sharedTokens.length / (allUniqueTokens.size || 1);
499
+ tokenBonus = jaccard * 0.35;
500
+ }
501
+
502
+ // Damerau-Levenshtein Matrix
503
+ const len1 = s1.length;
504
+ const len2 = s2.length;
505
+ const matrix: number[][] = [];
506
+
507
+ for (let i = 0; i <= len1; i++) {
508
+ matrix[i] = [i];
509
+ }
510
+ for (let j = 0; j <= len2; j++) {
511
+ matrix[0][j] = j;
512
+ }
513
+
514
+ for (let i = 1; i <= len1; i++) {
515
+ for (let j = 1; j <= len2; j++) {
516
+ const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
517
+ let minCost = Math.min(
518
+ matrix[i - 1][j] + 1, // deletion
519
+ matrix[i][j - 1] + 1, // insertion
520
+ matrix[i - 1][j - 1] + cost // substitution
521
+ );
522
+
523
+ // Transposition check (Damerau)
524
+ if (
525
+ i > 1 &&
526
+ j > 1 &&
527
+ s1[i - 1] === s2[j - 2] &&
528
+ s1[i - 2] === s2[j - 1]
529
+ ) {
530
+ minCost = Math.min(minCost, matrix[i - 2][j - 2] + 1);
531
+ }
532
+
533
+ matrix[i][j] = minCost;
534
+ }
535
+ }
536
+
537
+ const distance = matrix[len1][len2];
538
+ const maxLen = Math.max(len1, len2);
539
+ const rawSimilarity = 1.0 - distance / maxLen;
540
+
541
+ const totalScore = rawSimilarity + prefixBonus + substringBonus + tokenBonus;
542
+ return Math.min(1.0, Math.max(0.0, totalScore));
543
+ }