@mhalder/qdrant-mcp-server 1.4.0 → 1.5.0

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 (82) hide show
  1. package/.codecov.yml +16 -0
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +236 -9
  4. package/build/code/chunker/base.d.ts +19 -0
  5. package/build/code/chunker/base.d.ts.map +1 -0
  6. package/build/code/chunker/base.js +5 -0
  7. package/build/code/chunker/base.js.map +1 -0
  8. package/build/code/chunker/character-chunker.d.ts +22 -0
  9. package/build/code/chunker/character-chunker.d.ts.map +1 -0
  10. package/build/code/chunker/character-chunker.js +111 -0
  11. package/build/code/chunker/character-chunker.js.map +1 -0
  12. package/build/code/chunker/tree-sitter-chunker.d.ts +29 -0
  13. package/build/code/chunker/tree-sitter-chunker.d.ts.map +1 -0
  14. package/build/code/chunker/tree-sitter-chunker.js +213 -0
  15. package/build/code/chunker/tree-sitter-chunker.js.map +1 -0
  16. package/build/code/config.d.ts +11 -0
  17. package/build/code/config.d.ts.map +1 -0
  18. package/build/code/config.js +145 -0
  19. package/build/code/config.js.map +1 -0
  20. package/build/code/indexer.d.ts +42 -0
  21. package/build/code/indexer.d.ts.map +1 -0
  22. package/build/code/indexer.js +508 -0
  23. package/build/code/indexer.js.map +1 -0
  24. package/build/code/metadata.d.ts +32 -0
  25. package/build/code/metadata.d.ts.map +1 -0
  26. package/build/code/metadata.js +128 -0
  27. package/build/code/metadata.js.map +1 -0
  28. package/build/code/scanner.d.ts +35 -0
  29. package/build/code/scanner.d.ts.map +1 -0
  30. package/build/code/scanner.js +108 -0
  31. package/build/code/scanner.js.map +1 -0
  32. package/build/code/sync/merkle.d.ts +45 -0
  33. package/build/code/sync/merkle.d.ts.map +1 -0
  34. package/build/code/sync/merkle.js +116 -0
  35. package/build/code/sync/merkle.js.map +1 -0
  36. package/build/code/sync/snapshot.d.ts +41 -0
  37. package/build/code/sync/snapshot.d.ts.map +1 -0
  38. package/build/code/sync/snapshot.js +91 -0
  39. package/build/code/sync/snapshot.js.map +1 -0
  40. package/build/code/sync/synchronizer.d.ts +53 -0
  41. package/build/code/sync/synchronizer.d.ts.map +1 -0
  42. package/build/code/sync/synchronizer.js +132 -0
  43. package/build/code/sync/synchronizer.js.map +1 -0
  44. package/build/code/types.d.ts +98 -0
  45. package/build/code/types.d.ts.map +1 -0
  46. package/build/code/types.js +5 -0
  47. package/build/code/types.js.map +1 -0
  48. package/build/index.js +250 -0
  49. package/build/index.js.map +1 -1
  50. package/examples/code-search/README.md +271 -0
  51. package/package.json +13 -1
  52. package/src/code/chunker/base.ts +22 -0
  53. package/src/code/chunker/character-chunker.ts +131 -0
  54. package/src/code/chunker/tree-sitter-chunker.ts +250 -0
  55. package/src/code/config.ts +156 -0
  56. package/src/code/indexer.ts +613 -0
  57. package/src/code/metadata.ts +153 -0
  58. package/src/code/scanner.ts +124 -0
  59. package/src/code/sync/merkle.ts +136 -0
  60. package/src/code/sync/snapshot.ts +110 -0
  61. package/src/code/sync/synchronizer.ts +154 -0
  62. package/src/code/types.ts +117 -0
  63. package/src/index.ts +296 -0
  64. package/tests/code/chunker/character-chunker.test.ts +141 -0
  65. package/tests/code/chunker/tree-sitter-chunker.test.ts +275 -0
  66. package/tests/code/fixtures/sample-py/calculator.py +32 -0
  67. package/tests/code/fixtures/sample-ts/async-operations.ts +120 -0
  68. package/tests/code/fixtures/sample-ts/auth.ts +31 -0
  69. package/tests/code/fixtures/sample-ts/config.ts +52 -0
  70. package/tests/code/fixtures/sample-ts/database.ts +50 -0
  71. package/tests/code/fixtures/sample-ts/index.ts +39 -0
  72. package/tests/code/fixtures/sample-ts/types-advanced.ts +132 -0
  73. package/tests/code/fixtures/sample-ts/utils.ts +105 -0
  74. package/tests/code/fixtures/sample-ts/validator.ts +169 -0
  75. package/tests/code/indexer.test.ts +828 -0
  76. package/tests/code/integration.test.ts +708 -0
  77. package/tests/code/metadata.test.ts +457 -0
  78. package/tests/code/scanner.test.ts +131 -0
  79. package/tests/code/sync/merkle.test.ts +406 -0
  80. package/tests/code/sync/snapshot.test.ts +360 -0
  81. package/tests/code/sync/synchronizer.test.ts +501 -0
  82. package/vitest.config.ts +1 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * MetadataExtractor - Extracts metadata from code chunks
3
+ */
4
+
5
+ import { createHash } from "node:crypto";
6
+ import { extname } from "node:path";
7
+ import { LANGUAGE_MAP } from "./config.js";
8
+ import type { CodeChunk } from "./types.js";
9
+
10
+ export class MetadataExtractor {
11
+ /**
12
+ * Extract programming language from file path
13
+ */
14
+ extractLanguage(filePath: string): string {
15
+ const ext = extname(filePath);
16
+ return LANGUAGE_MAP[ext] || "unknown";
17
+ }
18
+
19
+ /**
20
+ * Generate deterministic chunk ID based on content and location
21
+ * Format: chunk_{sha256(path:start:end:content)[:16]}
22
+ */
23
+ generateChunkId(chunk: CodeChunk): string {
24
+ const { metadata, startLine, endLine, content } = chunk;
25
+ const combined = `${metadata.filePath}:${startLine}:${endLine}:${content}`;
26
+ const hash = createHash("sha256").update(combined).digest("hex");
27
+ return `chunk_${hash.substring(0, 16)}`;
28
+ }
29
+
30
+ /**
31
+ * Calculate simple code complexity score (optional)
32
+ * Based on: cyclomatic complexity indicators
33
+ */
34
+ calculateComplexity(code: string): number {
35
+ if (!code || code.trim().length === 0) {
36
+ return 0;
37
+ }
38
+
39
+ let complexity = 0;
40
+
41
+ // Count control flow statements
42
+ const controlFlowPatterns = [
43
+ /\bif\b/g,
44
+ /\belse\b/g,
45
+ /\bfor\b/g,
46
+ /\bwhile\b/g,
47
+ /\bswitch\b/g,
48
+ /\bcase\b/g,
49
+ /\bcatch\b/g,
50
+ /&&/g,
51
+ /\|\|/g,
52
+ /\?[^?]/g, // Ternary operator
53
+ ];
54
+
55
+ for (const pattern of controlFlowPatterns) {
56
+ const matches = code.match(pattern);
57
+ if (matches) {
58
+ complexity += matches.length;
59
+ }
60
+ }
61
+
62
+ // If code contains function/method/class, add base complexity of 1
63
+ if (complexity > 0 || /\b(function|class|def|fn)\b/.test(code)) {
64
+ complexity = Math.max(1, complexity);
65
+ }
66
+
67
+ return complexity;
68
+ }
69
+
70
+ /**
71
+ * Detect potential secrets in code (basic pattern matching)
72
+ */
73
+ containsSecrets(code: string): boolean {
74
+ const secretPatterns = [
75
+ /(?:api[-_]?key|apikey)\s*=\s*['"][^'"]{20,}['"]/i,
76
+ /(?:secret|SECRET)\s*=\s*['"][^'"]{20,}['"]/i,
77
+ /(?:password|PASSWORD)\s*=\s*['"][^'"]{8,}['"]/i,
78
+ /(?:token|TOKEN)\s*=\s*['"][^'"]{20,}['"]/i,
79
+ /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
80
+ /sk_live_[a-zA-Z0-9]{24,}/, // Stripe secret key
81
+ /ghp_[a-zA-Z0-9]{36,}/, // GitHub personal access token
82
+ /AIza[0-9A-Za-z\\-_]{35}/, // Google API key
83
+ /AKIA[0-9A-Z]{16}/, // AWS access key
84
+ ];
85
+
86
+ for (const pattern of secretPatterns) {
87
+ if (pattern.test(code)) {
88
+ return true;
89
+ }
90
+ }
91
+
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * Extract imports/exports from code (basic regex-based)
97
+ */
98
+ extractImportsExports(
99
+ code: string,
100
+ language: string
101
+ ): {
102
+ imports: string[];
103
+ exports: string[];
104
+ } {
105
+ const imports: string[] = [];
106
+ const exports: string[] = [];
107
+
108
+ if (language === "typescript" || language === "javascript") {
109
+ // Extract imports
110
+ const importMatches = code.matchAll(/import\s+.*?\s+from\s+['"]([^'"]+)['"]/g);
111
+ for (const match of importMatches) {
112
+ imports.push(match[1]);
113
+ }
114
+
115
+ // Extract require statements
116
+ const requireMatches = code.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
117
+ for (const match of requireMatches) {
118
+ imports.push(match[1]);
119
+ }
120
+
121
+ // Extract exports - regular declarations
122
+ const exportMatches = code.matchAll(/export\s+(?:class|function|const|let|var)\s+(\w+)/g);
123
+ for (const match of exportMatches) {
124
+ exports.push(match[1]);
125
+ }
126
+
127
+ // Extract export default
128
+ if (/export\s+default\b/.test(code)) {
129
+ exports.push("default");
130
+ }
131
+
132
+ // Extract named exports from other modules: export { name } from 'module'
133
+ const reExportMatches = code.matchAll(/export\s+\{\s*(\w+)\s*\}/g);
134
+ for (const match of reExportMatches) {
135
+ exports.push(match[1]);
136
+ }
137
+ } else if (language === "python") {
138
+ // Extract imports
139
+ const importMatches = code.matchAll(/(?:from\s+(\S+)\s+)?import\s+([^;\n]+)/g);
140
+ for (const match of importMatches) {
141
+ imports.push(match[1] || match[2]);
142
+ }
143
+
144
+ // Extract functions/classes (rough)
145
+ const defMatches = code.matchAll(/^(?:def|class)\s+(\w+)/gm);
146
+ for (const match of defMatches) {
147
+ exports.push(match[1]);
148
+ }
149
+ }
150
+
151
+ return { imports, exports };
152
+ }
153
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * FileScanner - Discovers code files in a directory while respecting ignore patterns
3
+ */
4
+
5
+ import { promises as fs } from "node:fs";
6
+ import { extname, join, relative, resolve } from "node:path";
7
+ import ignore, { type Ignore } from "ignore";
8
+ import type { ScannerConfig } from "./types.js";
9
+
10
+ export class FileScanner {
11
+ private ig: Ignore = ignore();
12
+ private supportedExts: Set<string>;
13
+
14
+ constructor(private config: ScannerConfig) {
15
+ this.supportedExts = new Set(config.supportedExtensions);
16
+ }
17
+
18
+ /**
19
+ * Load ignore patterns from .gitignore, .dockerignore, .npmignore, and .contextignore
20
+ */
21
+ async loadIgnorePatterns(rootPath: string): Promise<void> {
22
+ const ignoreFiles = [".gitignore", ".dockerignore", ".npmignore", ".contextignore"];
23
+
24
+ for (const ignoreFile of ignoreFiles) {
25
+ const ignorePath = join(rootPath, ignoreFile);
26
+ if (await this.fileExists(ignorePath)) {
27
+ try {
28
+ const content = await fs.readFile(ignorePath, "utf-8");
29
+ this.ig.add(content);
30
+ } catch (_error) {
31
+ // Silently skip if file can't be read
32
+ }
33
+ }
34
+ }
35
+
36
+ // Add default patterns from config
37
+ if (this.config.ignorePatterns && this.config.ignorePatterns.length > 0) {
38
+ this.ig.add(this.config.ignorePatterns);
39
+ }
40
+
41
+ // Add custom patterns
42
+ if (this.config.customIgnorePatterns && this.config.customIgnorePatterns.length > 0) {
43
+ this.ig.add(this.config.customIgnorePatterns);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Scan directory recursively and return all code files
49
+ */
50
+ async scanDirectory(rootPath: string): Promise<string[]> {
51
+ const absoluteRoot = resolve(rootPath);
52
+ const files: string[] = [];
53
+
54
+ await this.walkDirectory(absoluteRoot, absoluteRoot, files);
55
+
56
+ return files;
57
+ }
58
+
59
+ /**
60
+ * Check if a file should be ignored based on patterns
61
+ */
62
+ shouldIgnore(filePath: string, rootPath: string): boolean {
63
+ const relativePath = relative(rootPath, filePath);
64
+ return this.ig.ignores(relativePath);
65
+ }
66
+
67
+ /**
68
+ * Get list of supported file extensions
69
+ */
70
+ getSupportedExtensions(): string[] {
71
+ return Array.from(this.supportedExts);
72
+ }
73
+
74
+ /**
75
+ * Recursively walk directory tree
76
+ */
77
+ private async walkDirectory(
78
+ currentPath: string,
79
+ rootPath: string,
80
+ files: string[]
81
+ ): Promise<void> {
82
+ try {
83
+ const entries = await fs.readdir(currentPath, { withFileTypes: true });
84
+
85
+ for (const entry of entries) {
86
+ const fullPath = join(currentPath, entry.name);
87
+ const relativePath = relative(rootPath, fullPath);
88
+
89
+ // Skip ignored paths
90
+ if (this.ig.ignores(relativePath)) {
91
+ continue;
92
+ }
93
+
94
+ // Handle symbolic links safely to avoid infinite loops
95
+ if (entry.isSymbolicLink()) {
96
+ continue;
97
+ }
98
+
99
+ if (entry.isDirectory()) {
100
+ await this.walkDirectory(fullPath, rootPath, files);
101
+ } else if (entry.isFile()) {
102
+ const ext = extname(entry.name);
103
+ if (this.supportedExts.has(ext)) {
104
+ files.push(fullPath);
105
+ }
106
+ }
107
+ }
108
+ } catch (_error) {
109
+ // Skip directories that can't be read (permission errors, etc.)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Check if a file exists
115
+ */
116
+ private async fileExists(path: string): Promise<boolean> {
117
+ try {
118
+ await fs.access(path);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * MerkleTree - Efficient change detection using Merkle trees
3
+ * Enables incremental updates by comparing file hashes
4
+ */
5
+
6
+ import { createHash } from "node:crypto";
7
+
8
+ export class MerkleNode {
9
+ constructor(
10
+ public hash: string,
11
+ public left?: MerkleNode,
12
+ public right?: MerkleNode
13
+ ) {}
14
+ }
15
+
16
+ export class MerkleTree {
17
+ root: MerkleNode | undefined = undefined;
18
+
19
+ /**
20
+ * Build Merkle tree from file hashes
21
+ * @param fileHashes Map of file path to content hash
22
+ */
23
+ build(fileHashes: Map<string, string>): void {
24
+ if (fileHashes.size === 0) {
25
+ this.root = undefined;
26
+ return;
27
+ }
28
+
29
+ // Sort files for deterministic tree structure
30
+ const leaves = Array.from(fileHashes.entries())
31
+ .sort(([pathA], [pathB]) => pathA.localeCompare(pathB))
32
+ .map(([path, hash]) => {
33
+ const combined = `${path}:${hash}`;
34
+ const leafHash = createHash("sha256").update(combined).digest("hex");
35
+ return new MerkleNode(leafHash);
36
+ });
37
+
38
+ this.root = this.buildRecursive(leaves);
39
+ }
40
+
41
+ /**
42
+ * Recursively build tree from leaf nodes
43
+ */
44
+ private buildRecursive(nodes: MerkleNode[]): MerkleNode {
45
+ if (nodes.length === 1) {
46
+ return nodes[0];
47
+ }
48
+
49
+ const parents: MerkleNode[] = [];
50
+ for (let i = 0; i < nodes.length; i += 2) {
51
+ const left = nodes[i];
52
+ const right = nodes[i + 1] || left; // Duplicate if odd number
53
+
54
+ const combined = left.hash + right.hash;
55
+ const parentHash = createHash("sha256").update(combined).digest("hex");
56
+
57
+ parents.push(new MerkleNode(parentHash, left, right));
58
+ }
59
+
60
+ return this.buildRecursive(parents);
61
+ }
62
+
63
+ /**
64
+ * Compare two trees and return file differences
65
+ */
66
+ static compare(
67
+ oldHashes: Map<string, string>,
68
+ newHashes: Map<string, string>
69
+ ): { added: string[]; modified: string[]; deleted: string[] } {
70
+ const added: string[] = [];
71
+ const modified: string[] = [];
72
+ const deleted: string[] = [];
73
+
74
+ // Find added and modified files
75
+ for (const [path, hash] of newHashes) {
76
+ if (!oldHashes.has(path)) {
77
+ added.push(path);
78
+ } else if (oldHashes.get(path) !== hash) {
79
+ modified.push(path);
80
+ }
81
+ }
82
+
83
+ // Find deleted files
84
+ for (const [path] of oldHashes) {
85
+ if (!newHashes.has(path)) {
86
+ deleted.push(path);
87
+ }
88
+ }
89
+
90
+ return { added, modified, deleted };
91
+ }
92
+
93
+ /**
94
+ * Get root hash (quick comparison)
95
+ */
96
+ getRootHash(): string | undefined {
97
+ return this.root?.hash;
98
+ }
99
+
100
+ /**
101
+ * Serialize tree for storage
102
+ */
103
+ serialize(): string {
104
+ return JSON.stringify({
105
+ root: this.serializeNode(this.root),
106
+ });
107
+ }
108
+
109
+ private serializeNode(node: MerkleNode | undefined): any | null {
110
+ if (!node) return null;
111
+ return {
112
+ hash: node.hash,
113
+ left: this.serializeNode(node.left),
114
+ right: this.serializeNode(node.right),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Deserialize tree from storage
120
+ */
121
+ static deserialize(data: string): MerkleTree {
122
+ const tree = new MerkleTree();
123
+ const obj = JSON.parse(data);
124
+ tree.root = tree.deserializeNode(obj.root);
125
+ return tree;
126
+ }
127
+
128
+ private deserializeNode(obj: any): MerkleNode | undefined {
129
+ if (!obj) return undefined;
130
+ return new MerkleNode(
131
+ obj.hash,
132
+ this.deserializeNode(obj.left),
133
+ this.deserializeNode(obj.right)
134
+ );
135
+ }
136
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Snapshot - Persistence layer for Merkle tree snapshots
3
+ * Stores file hashes and tree structure for incremental updates
4
+ */
5
+
6
+ import { promises as fs } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { MerkleTree } from "./merkle.js";
9
+
10
+ export interface Snapshot {
11
+ codebasePath: string;
12
+ timestamp: number;
13
+ fileHashes: Record<string, string>;
14
+ merkleTree: string; // Serialized tree
15
+ }
16
+
17
+ export class SnapshotManager {
18
+ constructor(private snapshotPath: string) {}
19
+
20
+ /**
21
+ * Save snapshot to disk
22
+ */
23
+ async save(
24
+ codebasePath: string,
25
+ fileHashes: Map<string, string>,
26
+ tree: MerkleTree
27
+ ): Promise<void> {
28
+ const snapshot: Snapshot = {
29
+ codebasePath,
30
+ timestamp: Date.now(),
31
+ fileHashes: Object.fromEntries(fileHashes),
32
+ merkleTree: tree.serialize(),
33
+ };
34
+
35
+ // Ensure directory exists
36
+ await fs.mkdir(dirname(this.snapshotPath), { recursive: true });
37
+
38
+ // Write snapshot atomically (write to temp file, then rename)
39
+ // Use unique temp file name to avoid race conditions
40
+ const tempPath = `${this.snapshotPath}.tmp.${Date.now()}.${Math.random().toString(36).substring(2, 9)}`;
41
+ await fs.writeFile(tempPath, JSON.stringify(snapshot, null, 2), "utf-8");
42
+ await fs.rename(tempPath, this.snapshotPath);
43
+ }
44
+
45
+ /**
46
+ * Load snapshot from disk
47
+ */
48
+ async load(): Promise<{
49
+ codebasePath: string;
50
+ fileHashes: Map<string, string>;
51
+ merkleTree: MerkleTree;
52
+ timestamp: number;
53
+ } | null> {
54
+ try {
55
+ const data = await fs.readFile(this.snapshotPath, "utf-8");
56
+ const snapshot: Snapshot = JSON.parse(data);
57
+
58
+ const fileHashes = new Map(Object.entries(snapshot.fileHashes));
59
+ const tree = MerkleTree.deserialize(snapshot.merkleTree);
60
+
61
+ return {
62
+ codebasePath: snapshot.codebasePath,
63
+ fileHashes,
64
+ merkleTree: tree,
65
+ timestamp: snapshot.timestamp,
66
+ };
67
+ } catch (_error) {
68
+ // Snapshot doesn't exist or is corrupted
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Check if snapshot exists
75
+ */
76
+ async exists(): Promise<boolean> {
77
+ try {
78
+ await fs.access(this.snapshotPath);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Delete snapshot
87
+ */
88
+ async delete(): Promise<void> {
89
+ try {
90
+ await fs.unlink(this.snapshotPath);
91
+ } catch {
92
+ // Ignore if doesn't exist
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Validate snapshot (check for corruption)
98
+ */
99
+ async validate(): Promise<boolean> {
100
+ try {
101
+ const snapshot = await this.load();
102
+ if (!snapshot) return false;
103
+
104
+ // Basic validation: check if tree can be deserialized
105
+ return snapshot.merkleTree.getRootHash() !== undefined || snapshot.fileHashes.size === 0;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * FileSynchronizer - Manages incremental updates using Merkle trees
3
+ * Detects file changes and updates snapshots
4
+ */
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { promises as fs } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, relative } from "node:path";
10
+ import type { FileChanges } from "../types.js";
11
+ import { MerkleTree } from "./merkle.js";
12
+ import { SnapshotManager } from "./snapshot.js";
13
+
14
+ export class FileSynchronizer {
15
+ private snapshotManager: SnapshotManager;
16
+ private previousHashes: Map<string, string> = new Map();
17
+ private previousTree: MerkleTree | null = null;
18
+
19
+ constructor(
20
+ private codebasePath: string,
21
+ collectionName: string
22
+ ) {
23
+ // Store snapshots in ~/.qdrant-mcp/snapshots/
24
+ const snapshotDir = join(homedir(), ".qdrant-mcp", "snapshots");
25
+ const snapshotPath = join(snapshotDir, `${collectionName}.json`);
26
+ this.snapshotManager = new SnapshotManager(snapshotPath);
27
+ }
28
+
29
+ /**
30
+ * Initialize synchronizer by loading previous snapshot
31
+ */
32
+ async initialize(): Promise<boolean> {
33
+ const snapshot = await this.snapshotManager.load();
34
+
35
+ if (snapshot) {
36
+ this.previousHashes = snapshot.fileHashes;
37
+ this.previousTree = snapshot.merkleTree;
38
+ return true;
39
+ }
40
+
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Compute hash for a file's content
46
+ */
47
+ private async hashFile(filePath: string): Promise<string> {
48
+ try {
49
+ // Resolve path relative to codebase if not absolute
50
+ const absolutePath = filePath.startsWith(this.codebasePath)
51
+ ? filePath
52
+ : join(this.codebasePath, filePath);
53
+ const content = await fs.readFile(absolutePath, "utf-8");
54
+ return createHash("sha256").update(content).digest("hex");
55
+ } catch (_error) {
56
+ // If file can't be read, return empty hash
57
+ return "";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Compute hashes for all files
63
+ */
64
+ async computeFileHashes(filePaths: string[]): Promise<Map<string, string>> {
65
+ const fileHashes = new Map<string, string>();
66
+
67
+ for (const filePath of filePaths) {
68
+ const hash = await this.hashFile(filePath);
69
+ if (hash) {
70
+ // Normalize to relative path
71
+ const relativePath = filePath.startsWith(this.codebasePath)
72
+ ? relative(this.codebasePath, filePath)
73
+ : filePath;
74
+ fileHashes.set(relativePath, hash);
75
+ }
76
+ }
77
+
78
+ return fileHashes;
79
+ }
80
+
81
+ /**
82
+ * Detect changes since last snapshot
83
+ */
84
+ async detectChanges(currentFiles: string[]): Promise<FileChanges> {
85
+ // Compute current hashes
86
+ const currentHashes = await this.computeFileHashes(currentFiles);
87
+
88
+ // Compare with previous snapshot
89
+ const changes = MerkleTree.compare(this.previousHashes, currentHashes);
90
+
91
+ return changes;
92
+ }
93
+
94
+ /**
95
+ * Update snapshot with current state
96
+ */
97
+ async updateSnapshot(files: string[]): Promise<void> {
98
+ const fileHashes = await this.computeFileHashes(files);
99
+ const tree = new MerkleTree();
100
+ tree.build(fileHashes);
101
+
102
+ await this.snapshotManager.save(this.codebasePath, fileHashes, tree);
103
+
104
+ // Update internal state
105
+ this.previousHashes = fileHashes;
106
+ this.previousTree = tree;
107
+ }
108
+
109
+ /**
110
+ * Delete snapshot
111
+ */
112
+ async deleteSnapshot(): Promise<void> {
113
+ await this.snapshotManager.delete();
114
+ this.previousHashes.clear();
115
+ this.previousTree = null;
116
+ }
117
+
118
+ /**
119
+ * Check if snapshot exists
120
+ */
121
+ async hasSnapshot(): Promise<boolean> {
122
+ return this.snapshotManager.exists();
123
+ }
124
+
125
+ /**
126
+ * Validate snapshot integrity
127
+ */
128
+ async validateSnapshot(): Promise<boolean> {
129
+ return this.snapshotManager.validate();
130
+ }
131
+
132
+ /**
133
+ * Get snapshot age in milliseconds
134
+ */
135
+ async getSnapshotAge(): Promise<number | null> {
136
+ const snapshot = await this.snapshotManager.load();
137
+ if (!snapshot) return null;
138
+
139
+ return Date.now() - snapshot.timestamp;
140
+ }
141
+
142
+ /**
143
+ * Quick check if re-indexing is needed (compare root hashes)
144
+ */
145
+ async needsReindex(currentFiles: string[]): Promise<boolean> {
146
+ if (!this.previousTree) return true;
147
+
148
+ const currentHashes = await this.computeFileHashes(currentFiles);
149
+ const currentTree = new MerkleTree();
150
+ currentTree.build(currentHashes);
151
+
152
+ return this.previousTree.getRootHash() !== currentTree.getRootHash();
153
+ }
154
+ }