septum 0.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "septum",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Deterministic Bounded-Context, Structural File Catalog & Anti-Poisoning Partition for AI Agents",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -21,6 +21,7 @@ export class SeptumDatabase {
21
21
 
22
22
  private configurePragmas(): void {
23
23
  this.db.run("PRAGMA journal_mode = WAL;");
24
+ this.db.run("PRAGMA busy_timeout = 5000;");
24
25
  this.db.run("PRAGMA foreign_keys = ON;");
25
26
  this.db.run("PRAGMA synchronous = NORMAL;");
26
27
  }
@@ -201,23 +201,42 @@ export class ModuleResolver {
201
201
  return null;
202
202
  }
203
203
 
204
+ public toCanonicalPath(filePath: string): string {
205
+ let resolved = path.isAbsolute(filePath)
206
+ ? path.normalize(filePath)
207
+ : path.resolve(this.projectRoot, filePath);
208
+
209
+ if (process.platform === "win32") {
210
+ // Normalize drive letter to uppercase (e.g. c:\ -> C:\)
211
+ resolved = resolved.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
212
+ }
213
+ return resolved;
214
+ }
215
+
204
216
  private isSubPathOrSame(targetPath: string, rootPath: string): boolean {
205
- const normalizedTarget = path.normalize(targetPath);
206
- const normalizedRoot = path.normalize(rootPath);
217
+ const canonicalTarget = this.toCanonicalPath(targetPath);
218
+ const canonicalRoot = this.toCanonicalPath(rootPath);
219
+
220
+ if (process.platform === "win32") {
221
+ const lowerTarget = canonicalTarget.toLowerCase();
222
+ const lowerRoot = canonicalRoot.toLowerCase();
223
+ if (lowerTarget === lowerRoot) return true;
224
+ const rootWithSep = lowerRoot.endsWith(path.sep)
225
+ ? lowerRoot
226
+ : lowerRoot + path.sep;
227
+ return lowerTarget.startsWith(rootWithSep);
228
+ }
207
229
 
208
- if (normalizedTarget === normalizedRoot) return true;
209
- const rootWithSep = normalizedRoot.endsWith(path.sep)
210
- ? normalizedRoot
211
- : normalizedRoot + path.sep;
230
+ if (canonicalTarget === canonicalRoot) return true;
231
+ const rootWithSep = canonicalRoot.endsWith(path.sep)
232
+ ? canonicalRoot
233
+ : canonicalRoot + path.sep;
212
234
 
213
- return normalizedTarget.startsWith(rootWithSep);
235
+ return canonicalTarget.startsWith(rootWithSep);
214
236
  }
215
237
 
216
238
  private toAbsolutePath(filePath: string): string {
217
- if (path.isAbsolute(filePath)) {
218
- return path.normalize(filePath);
219
- }
220
- return path.resolve(this.projectRoot, filePath);
239
+ return this.toCanonicalPath(filePath);
221
240
  }
222
241
 
223
242
  private extractSymbolName(target: string): string | null {
@@ -148,12 +148,109 @@ export class SymbolLocator {
148
148
  const containers = this.repo.findContainers(simpleContainerName);
149
149
 
150
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
+
151
248
  return {
152
249
  query: parsed.raw,
153
250
  parsed,
154
251
  found: false,
155
- suggestions: [],
156
- message: `Container '${containerQuery}' was not found in any cataloged files. Run 'septum ingest' if recently added.`,
252
+ suggestions: combinedSuggestions,
253
+ message: `${hint} Run 'septum ingest' if this file was recently added.`,
157
254
  };
158
255
  }
159
256
 
@@ -327,7 +424,9 @@ export class SymbolLocator {
327
424
  const suggestions: SuggestionMatch[] = allSymbols
328
425
  .map((s) => {
329
426
  const bareName = s.name.includes("::") ? s.name.split("::")[1] : s.name;
330
- const score = calculateSimilarity(targetName, bareName);
427
+ const scoreBare = calculateSimilarity(targetName, bareName);
428
+ const scoreFull = calculateSimilarity(targetName, s.name);
429
+ const score = Math.max(scoreBare, scoreFull);
331
430
  return {
332
431
  name: s.name,
333
432
  kind: s.kind,
@@ -335,9 +434,10 @@ export class SymbolLocator {
335
434
  line_start: s.line_start,
336
435
  line_end: s.line_end,
337
436
  similarity_score: Math.round(score * 100) / 100,
437
+ file_path: s.file_path,
338
438
  };
339
439
  })
340
- .filter((s) => s.similarity_score >= 0.35)
440
+ .filter((s) => s.similarity_score >= 0.3)
341
441
  .sort((a, b) => b.similarity_score - a.similarity_score)
342
442
  .slice(0, 5);
343
443
 
@@ -352,7 +452,9 @@ export class SymbolLocator {
352
452
  }
353
453
 
354
454
  /**
355
- * Standard Levenshtein distance with token substring bonus.
455
+ * Enhanced similarity matching:
456
+ * Combines Damerau-Levenshtein distance (handling typos & transpositions),
457
+ * prefix/suffix matching, substring inclusion, and token Jaccard similarity.
356
458
  */
357
459
  export function calculateSimilarity(source: string, target: string): number {
358
460
  const s1 = source.toLowerCase();
@@ -361,20 +463,43 @@ export function calculateSimilarity(source: string, target: string): number {
361
463
  if (s1 === s2) return 1.0;
362
464
  if (!s1 || !s2) return 0.0;
363
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
+
364
474
  // Substring inclusion bonus
475
+ let substringBonus = 0;
365
476
  if (s2.includes(s1) || s1.includes(s2)) {
366
477
  const minLen = Math.min(s1.length, s2.length);
367
478
  const maxLen = Math.max(s1.length, s2.length);
368
- return 0.5 + 0.5 * (minLen / maxLen);
479
+ substringBonus = 0.3 * (minLen / maxLen);
369
480
  }
370
481
 
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;
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
+ }
377
501
 
502
+ // Damerau-Levenshtein Matrix
378
503
  const len1 = s1.length;
379
504
  const len2 = s2.length;
380
505
  const matrix: number[][] = [];
@@ -389,11 +514,23 @@ export function calculateSimilarity(source: string, target: string): number {
389
514
  for (let i = 1; i <= len1; i++) {
390
515
  for (let j = 1; j <= len2; j++) {
391
516
  const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
392
- matrix[i][j] = Math.min(
517
+ let minCost = Math.min(
393
518
  matrix[i - 1][j] + 1, // deletion
394
519
  matrix[i][j - 1] + 1, // insertion
395
520
  matrix[i - 1][j - 1] + cost // substitution
396
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;
397
534
  }
398
535
  }
399
536
 
@@ -401,5 +538,6 @@ export function calculateSimilarity(source: string, target: string): number {
401
538
  const maxLen = Math.max(len1, len2);
402
539
  const rawSimilarity = 1.0 - distance / maxLen;
403
540
 
404
- return Math.min(1.0, Math.max(0.0, rawSimilarity + tokenBonus));
541
+ const totalScore = rawSimilarity + prefixBonus + substringBonus + tokenBonus;
542
+ return Math.min(1.0, Math.max(0.0, totalScore));
405
543
  }