septum 0.1.3 → 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 (74) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -102
  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 -90
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/commands/topology.ts +119 -0
  15. package/src/cli/index.ts +210 -199
  16. package/src/cli/templates/hooks.ts +75 -75
  17. package/src/cli/templates/rules.ts +38 -38
  18. package/src/cli/templates/skill.ts +69 -69
  19. package/src/core/boundary/evaluator.ts +232 -210
  20. package/src/core/config/loader.ts +97 -97
  21. package/src/core/config/schema.ts +39 -39
  22. package/src/core/database/client.ts +44 -44
  23. package/src/core/database/repositories/dependency.repository.ts +366 -349
  24. package/src/core/database/repositories/domain.repository.ts +218 -198
  25. package/src/core/database/repositories/environment.repository.ts +207 -0
  26. package/src/core/database/repositories/file.repository.ts +116 -106
  27. package/src/core/database/repositories/meta.repository.ts +33 -33
  28. package/src/core/database/repositories/symbol.repository.ts +443 -424
  29. package/src/core/database/repository.ts +264 -255
  30. package/src/core/database/schema.ts +34 -1
  31. package/src/core/discovery/topology-detector.ts +486 -486
  32. package/src/core/ingestion/hasher.ts +5 -5
  33. package/src/core/ingestion/pipeline.ts +584 -535
  34. package/src/core/parser/behavioral-analyzer.ts +119 -0
  35. package/src/core/parser/boundary-tracker.ts +288 -128
  36. package/src/core/parser/extractors/base.ts +62 -61
  37. package/src/core/parser/extractors/frontend.ts +194 -194
  38. package/src/core/parser/extractors/go.ts +229 -176
  39. package/src/core/parser/extractors/laravel-semantic.ts +1039 -585
  40. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
  41. package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
  42. package/src/core/parser/extractors/php.ts +153 -149
  43. package/src/core/parser/extractors/python.ts +228 -226
  44. package/src/core/parser/extractors/registry.ts +70 -70
  45. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  46. package/src/core/parser/extractors/typescript.ts +181 -177
  47. package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
  48. package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
  49. package/src/core/parser/schema/schema-provider-registry.ts +43 -0
  50. package/src/core/parser/schema/schema-provider.interface.ts +33 -0
  51. package/src/core/parser/tree-sitter.ts +41 -41
  52. package/src/core/resolver/call-graph-tracer.ts +666 -668
  53. package/src/core/resolver/module-resolver.ts +408 -342
  54. package/src/core/resolver/symbol-locator.ts +543 -543
  55. package/src/core/resolver/vertical-slice-tracer.ts +301 -252
  56. package/src/core/session/session-manager.ts +56 -56
  57. package/src/core/telemetry/telemetry.ts +86 -86
  58. package/src/core/topology/environment-detector.ts +860 -0
  59. package/src/core/topology/types.ts +83 -0
  60. package/src/index.ts +21 -18
  61. package/src/mcp/schemas.ts +578 -238
  62. package/src/mcp/server.ts +875 -656
  63. package/src/mcp/tools/check-boundary.ts +171 -152
  64. package/src/mcp/tools/get-domain-catalog.ts +145 -121
  65. package/src/mcp/tools/get-environment-topology.ts +141 -0
  66. package/src/mcp/tools/get-feature-context.ts +212 -116
  67. package/src/mcp/tools/get-symbol-hotspots.ts +60 -49
  68. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  69. package/src/mcp/tools/get-symbol.ts +65 -63
  70. package/src/mcp/tools/locate-symbol.ts +171 -190
  71. package/src/mcp/tools/register-domain.ts +68 -64
  72. package/src/mcp/tools/register-feature.ts +100 -0
  73. package/src/mcp/tools/trace-vertical-slice.ts +74 -103
  74. package/src/types/index.ts +335 -299
@@ -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
+ }