septum 0.1.4 → 0.1.5

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 (67) 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 +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 +48 -48
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +210 -210
  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 +69 -69
  18. package/src/core/boundary/evaluator.ts +232 -210
  19. package/src/core/config/loader.ts +97 -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 +366 -349
  23. package/src/core/database/repositories/domain.repository.ts +218 -198
  24. package/src/core/database/repositories/file.repository.ts +116 -106
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +443 -443
  27. package/src/core/database/repository.ts +264 -260
  28. package/src/core/database/schema.ts +153 -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 +584 -535
  32. package/src/core/parser/boundary-tracker.ts +288 -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 +194 -194
  36. package/src/core/parser/extractors/go.ts +229 -180
  37. package/src/core/parser/extractors/laravel-semantic.ts +1039 -901
  38. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
  39. package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
  40. package/src/core/parser/extractors/php.ts +153 -153
  41. package/src/core/parser/extractors/python.ts +228 -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 +181 -181
  45. package/src/core/parser/tree-sitter.ts +41 -41
  46. package/src/core/resolver/call-graph-tracer.ts +666 -668
  47. package/src/core/resolver/module-resolver.ts +408 -342
  48. package/src/core/resolver/path-resolver.ts +147 -147
  49. package/src/core/resolver/symbol-locator.ts +543 -543
  50. package/src/core/resolver/vertical-slice-tracer.ts +301 -285
  51. package/src/core/session/session-manager.ts +56 -56
  52. package/src/core/telemetry/telemetry.ts +86 -86
  53. package/src/index.ts +21 -21
  54. package/src/mcp/schemas.ts +578 -286
  55. package/src/mcp/server.ts +875 -716
  56. package/src/mcp/tools/check-boundary.ts +171 -152
  57. package/src/mcp/tools/get-domain-catalog.ts +145 -121
  58. package/src/mcp/tools/get-feature-context.ts +212 -116
  59. package/src/mcp/tools/get-symbol-hotspots.ts +60 -59
  60. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  61. package/src/mcp/tools/get-symbol.ts +65 -63
  62. package/src/mcp/tools/locate-symbol.ts +171 -190
  63. package/src/mcp/tools/register-domain.ts +68 -64
  64. package/src/mcp/tools/register-feature.ts +100 -0
  65. package/src/mcp/tools/trace-vertical-slice.ts +74 -48
  66. package/src/types/index.ts +335 -315
  67. package/src/version.ts +3 -3
@@ -1,543 +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
- // 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
- }
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
+ }