index.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. /**
  2. * Extraction Orchestrator
  3. *
  4. * Coordinates file scanning, parsing, and database storage.
  5. */
  6. import * as fs from 'fs';
  7. import * as fsp from 'fs/promises';
  8. import * as path from 'path';
  9. import * as crypto from 'crypto';
  10. import { execFileSync } from 'child_process';
  11. import {
  12. Language,
  13. FileRecord,
  14. ExtractionResult,
  15. ExtractionError,
  16. CodeGraphConfig,
  17. } from '../types';
  18. import { QueryBuilder } from '../db/queries';
  19. import { extractFromSource } from './tree-sitter';
  20. import { detectLanguage, isLanguageSupported } from './grammars';
  21. import { logDebug, logWarn } from '../errors';
  22. import { captureException } from '../sentry';
  23. import { validatePathWithinRoot, normalizePath } from '../utils';
  24. /**
  25. * Number of files to read in parallel during indexing.
  26. * File reads are I/O-bound; batching overlaps I/O wait with CPU parse work.
  27. */
  28. const FILE_IO_BATCH_SIZE = 10;
  29. /**
  30. * Progress callback for indexing operations
  31. */
  32. export interface IndexProgress {
  33. phase: 'scanning' | 'parsing' | 'storing' | 'resolving';
  34. current: number;
  35. total: number;
  36. currentFile?: string;
  37. }
  38. /**
  39. * Result of an indexing operation
  40. */
  41. export interface IndexResult {
  42. success: boolean;
  43. filesIndexed: number;
  44. filesSkipped: number;
  45. nodesCreated: number;
  46. edgesCreated: number;
  47. errors: ExtractionError[];
  48. durationMs: number;
  49. }
  50. /**
  51. * Result of a sync operation
  52. */
  53. export interface SyncResult {
  54. filesChecked: number;
  55. filesAdded: number;
  56. filesModified: number;
  57. filesRemoved: number;
  58. nodesUpdated: number;
  59. durationMs: number;
  60. }
  61. /**
  62. * Calculate SHA256 hash of file contents
  63. */
  64. export function hashContent(content: string): string {
  65. return crypto.createHash('sha256').update(content).digest('hex');
  66. }
  67. /**
  68. * Check if a path matches any glob pattern (simplified)
  69. */
  70. function matchesGlob(filePath: string, pattern: string): boolean {
  71. // Normalize to forward slashes so Windows backslash paths match glob patterns
  72. filePath = normalizePath(filePath);
  73. // Convert glob to regex using placeholders to avoid conflicts
  74. let regexStr = pattern;
  75. // Replace glob patterns with placeholders first
  76. regexStr = regexStr.replace(/\*\*\//g, '\x00GLOBSTAR_SLASH\x00');
  77. regexStr = regexStr.replace(/\*\*/g, '\x00GLOBSTAR\x00');
  78. regexStr = regexStr.replace(/\*/g, '\x00STAR\x00');
  79. regexStr = regexStr.replace(/\?/g, '\x00QUESTION\x00');
  80. // Escape regex special characters
  81. regexStr = regexStr.replace(/[.+^${}()|[\]\\]/g, '\\$&');
  82. // Replace placeholders with regex equivalents
  83. regexStr = regexStr.replace(/\x00GLOBSTAR_SLASH\x00/g, '(?:.*/)?'); // **/ = zero or more dirs
  84. regexStr = regexStr.replace(/\x00GLOBSTAR\x00/g, '.*'); // ** = anything
  85. regexStr = regexStr.replace(/\x00STAR\x00/g, '[^/]*'); // * = anything except /
  86. regexStr = regexStr.replace(/\x00QUESTION\x00/g, '.'); // ? = single char
  87. const regex = new RegExp(`^${regexStr}$`);
  88. return regex.test(filePath);
  89. }
  90. /**
  91. * Check if a file should be included based on config
  92. */
  93. export function shouldIncludeFile(
  94. filePath: string,
  95. config: CodeGraphConfig
  96. ): boolean {
  97. // Check exclude patterns first
  98. for (const pattern of config.exclude) {
  99. if (matchesGlob(filePath, pattern)) {
  100. return false;
  101. }
  102. }
  103. // Check include patterns
  104. for (const pattern of config.include) {
  105. if (matchesGlob(filePath, pattern)) {
  106. return true;
  107. }
  108. }
  109. return false;
  110. }
  111. /**
  112. * Get directories ignored by .gitignore using git ls-files.
  113. * Returns a Set of normalized relative directory paths (forward slashes, no trailing slash).
  114. * Gracefully returns empty Set on any failure.
  115. */
  116. function getGitIgnoredDirectories(rootDir: string): Set<string> {
  117. try {
  118. const output = execFileSync(
  119. 'git',
  120. ['ls-files', '-oi', '--exclude-standard', '--directory'],
  121. { cwd: rootDir, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] }
  122. );
  123. const dirs = new Set<string>();
  124. for (const line of output.split('\n')) {
  125. const trimmed = line.trim();
  126. if (trimmed.endsWith('/')) {
  127. dirs.add(normalizePath(trimmed.slice(0, -1)));
  128. }
  129. }
  130. return dirs;
  131. } catch {
  132. return new Set<string>();
  133. }
  134. }
  135. /**
  136. * Marker file name that indicates a directory (and all children) should be skipped
  137. */
  138. const CODEGRAPH_IGNORE_MARKER = '.codegraphignore';
  139. /**
  140. * Recursively scan directory for source files
  141. */
  142. export function scanDirectory(
  143. rootDir: string,
  144. config: CodeGraphConfig,
  145. onProgress?: (current: number, file: string) => void
  146. ): string[] {
  147. const files: string[] = [];
  148. let count = 0;
  149. // Track visited real paths to detect symlink cycles
  150. const visitedDirs = new Set<string>();
  151. const gitIgnoredDirs = getGitIgnoredDirectories(rootDir);
  152. function walk(dir: string): void {
  153. // Resolve real path to detect symlink cycles
  154. let realDir: string;
  155. try {
  156. realDir = fs.realpathSync(dir);
  157. } catch {
  158. logDebug('Skipping unresolvable directory', { dir });
  159. return;
  160. }
  161. if (visitedDirs.has(realDir)) {
  162. logDebug('Skipping already-visited directory (symlink cycle)', { dir, realDir });
  163. return;
  164. }
  165. visitedDirs.add(realDir);
  166. // Check for .codegraphignore marker file - skip entire directory tree if present
  167. const ignoreMarker = path.join(dir, CODEGRAPH_IGNORE_MARKER);
  168. if (fs.existsSync(ignoreMarker)) {
  169. logDebug('Skipping directory due to .codegraphignore marker', { dir });
  170. return;
  171. }
  172. let entries: fs.Dirent[];
  173. try {
  174. entries = fs.readdirSync(dir, { withFileTypes: true });
  175. } catch (error) {
  176. captureException(error, { operation: 'walk-directory', dir });
  177. logDebug('Skipping unreadable directory', { dir, error: String(error) });
  178. return;
  179. }
  180. for (const entry of entries) {
  181. const fullPath = path.join(dir, entry.name);
  182. const relativePath = normalizePath(path.relative(rootDir, fullPath));
  183. // Follow symlinked directories, but skip symlinked files to non-project targets
  184. if (entry.isSymbolicLink()) {
  185. try {
  186. const realTarget = fs.realpathSync(fullPath);
  187. const stat = fs.statSync(realTarget);
  188. if (stat.isDirectory()) {
  189. // Check gitignore first (fast O(1) lookup)
  190. if (gitIgnoredDirs.has(relativePath)) {
  191. continue;
  192. }
  193. // Check exclusion, then recurse (cycle detection handles the rest)
  194. const dirPattern = relativePath + '/';
  195. let excluded = false;
  196. for (const pattern of config.exclude) {
  197. if (matchesGlob(dirPattern, pattern) || matchesGlob(relativePath, pattern)) {
  198. excluded = true;
  199. break;
  200. }
  201. }
  202. if (!excluded) {
  203. walk(fullPath);
  204. }
  205. } else if (stat.isFile()) {
  206. if (shouldIncludeFile(relativePath, config)) {
  207. files.push(relativePath);
  208. count++;
  209. if (onProgress) {
  210. onProgress(count, relativePath);
  211. }
  212. }
  213. }
  214. } catch {
  215. logDebug('Skipping broken symlink', { path: fullPath });
  216. }
  217. continue;
  218. }
  219. if (entry.isDirectory()) {
  220. // Check gitignore first (fast O(1) lookup)
  221. if (gitIgnoredDirs.has(relativePath)) {
  222. continue;
  223. }
  224. // Check if directory should be excluded
  225. const dirPattern = relativePath + '/';
  226. let excluded = false;
  227. for (const pattern of config.exclude) {
  228. if (matchesGlob(dirPattern, pattern) || matchesGlob(relativePath, pattern)) {
  229. excluded = true;
  230. break;
  231. }
  232. }
  233. if (!excluded) {
  234. walk(fullPath);
  235. }
  236. } else if (entry.isFile()) {
  237. if (shouldIncludeFile(relativePath, config)) {
  238. files.push(relativePath);
  239. count++;
  240. if (onProgress) {
  241. onProgress(count, relativePath);
  242. }
  243. }
  244. }
  245. }
  246. }
  247. walk(rootDir);
  248. return files;
  249. }
  250. /**
  251. * Extraction orchestrator
  252. */
  253. export class ExtractionOrchestrator {
  254. private rootDir: string;
  255. private config: CodeGraphConfig;
  256. private queries: QueryBuilder;
  257. constructor(rootDir: string, config: CodeGraphConfig, queries: QueryBuilder) {
  258. this.rootDir = rootDir;
  259. this.config = config;
  260. this.queries = queries;
  261. }
  262. /**
  263. * Index all files in the project
  264. */
  265. async indexAll(
  266. onProgress?: (progress: IndexProgress) => void,
  267. signal?: AbortSignal
  268. ): Promise<IndexResult> {
  269. const startTime = Date.now();
  270. const errors: ExtractionError[] = [];
  271. let filesIndexed = 0;
  272. let filesSkipped = 0;
  273. let totalNodes = 0;
  274. let totalEdges = 0;
  275. // Phase 1: Scan for files
  276. onProgress?.({
  277. phase: 'scanning',
  278. current: 0,
  279. total: 0,
  280. });
  281. const files = scanDirectory(this.rootDir, this.config, (current, file) => {
  282. onProgress?.({
  283. phase: 'scanning',
  284. current,
  285. total: 0,
  286. currentFile: file,
  287. });
  288. });
  289. if (signal?.aborted) {
  290. return {
  291. success: false,
  292. filesIndexed: 0,
  293. filesSkipped: 0,
  294. nodesCreated: 0,
  295. edgesCreated: 0,
  296. errors: [{ message: 'Aborted', severity: 'error' }],
  297. durationMs: Date.now() - startTime,
  298. };
  299. }
  300. // Phase 2: Parse files (read in parallel batches, parse/store sequentially)
  301. const total = files.length;
  302. let processed = 0;
  303. for (let i = 0; i < files.length; i += FILE_IO_BATCH_SIZE) {
  304. if (signal?.aborted) {
  305. return {
  306. success: false,
  307. filesIndexed,
  308. filesSkipped,
  309. nodesCreated: totalNodes,
  310. edgesCreated: totalEdges,
  311. errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
  312. durationMs: Date.now() - startTime,
  313. };
  314. }
  315. const batch = files.slice(i, i + FILE_IO_BATCH_SIZE);
  316. // Read files in parallel (with path validation before any I/O)
  317. const fileContents = await Promise.all(
  318. batch.map(async (fp) => {
  319. try {
  320. const fullPath = validatePathWithinRoot(this.rootDir, fp);
  321. if (!fullPath) {
  322. logWarn('Path traversal blocked in batch reader', { filePath: fp });
  323. return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: new Error('Path traversal blocked') };
  324. }
  325. const content = await fsp.readFile(fullPath, 'utf-8');
  326. const stats = await fsp.stat(fullPath);
  327. return { filePath: fp, content, stats, error: null as Error | null };
  328. } catch (err) {
  329. return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: err as Error };
  330. }
  331. })
  332. );
  333. // Parse and store sequentially
  334. for (const { filePath, content, stats, error } of fileContents) {
  335. if (signal?.aborted) {
  336. return {
  337. success: false,
  338. filesIndexed,
  339. filesSkipped,
  340. nodesCreated: totalNodes,
  341. edgesCreated: totalEdges,
  342. errors: [{ message: 'Aborted', severity: 'error' }, ...errors],
  343. durationMs: Date.now() - startTime,
  344. };
  345. }
  346. processed++;
  347. onProgress?.({
  348. phase: 'parsing',
  349. current: processed,
  350. total,
  351. currentFile: filePath,
  352. });
  353. if (error || content === null || stats === null) {
  354. errors.push({
  355. message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
  356. severity: 'error',
  357. });
  358. continue;
  359. }
  360. const result = await this.indexFileWithContent(filePath, content, stats);
  361. if (result.errors.length > 0) {
  362. errors.push(...result.errors);
  363. }
  364. if (result.nodes.length > 0) {
  365. filesIndexed++;
  366. totalNodes += result.nodes.length;
  367. totalEdges += result.edges.length;
  368. } else if (result.errors.length === 0) {
  369. filesSkipped++;
  370. }
  371. }
  372. }
  373. // Phase 3: Resolve references
  374. onProgress?.({
  375. phase: 'resolving',
  376. current: 0,
  377. total: 1,
  378. });
  379. // TODO: Implement reference resolution in Phase 3
  380. return {
  381. success: errors.filter((e) => e.severity === 'error').length === 0,
  382. filesIndexed,
  383. filesSkipped,
  384. nodesCreated: totalNodes,
  385. edgesCreated: totalEdges,
  386. errors,
  387. durationMs: Date.now() - startTime,
  388. };
  389. }
  390. /**
  391. * Index specific files
  392. */
  393. async indexFiles(filePaths: string[]): Promise<IndexResult> {
  394. const startTime = Date.now();
  395. const errors: ExtractionError[] = [];
  396. let filesIndexed = 0;
  397. let filesSkipped = 0;
  398. let totalNodes = 0;
  399. let totalEdges = 0;
  400. for (const filePath of filePaths) {
  401. const result = await this.indexFile(filePath);
  402. if (result.errors.length > 0) {
  403. errors.push(...result.errors);
  404. }
  405. if (result.nodes.length > 0) {
  406. filesIndexed++;
  407. totalNodes += result.nodes.length;
  408. totalEdges += result.edges.length;
  409. } else {
  410. filesSkipped++;
  411. }
  412. }
  413. return {
  414. success: errors.filter((e) => e.severity === 'error').length === 0,
  415. filesIndexed,
  416. filesSkipped,
  417. nodesCreated: totalNodes,
  418. edgesCreated: totalEdges,
  419. errors,
  420. durationMs: Date.now() - startTime,
  421. };
  422. }
  423. /**
  424. * Index a single file
  425. */
  426. async indexFile(relativePath: string): Promise<ExtractionResult> {
  427. const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
  428. if (!fullPath) {
  429. return {
  430. nodes: [],
  431. edges: [],
  432. unresolvedReferences: [],
  433. errors: [{ message: `Path traversal blocked: ${relativePath}`, severity: 'error' }],
  434. durationMs: 0,
  435. };
  436. }
  437. // Read file content and stats
  438. let content: string;
  439. let stats: fs.Stats;
  440. try {
  441. stats = await fsp.stat(fullPath);
  442. content = await fsp.readFile(fullPath, 'utf-8');
  443. } catch (error) {
  444. captureException(error, { operation: 'extract-file', filePath: fullPath });
  445. return {
  446. nodes: [],
  447. edges: [],
  448. unresolvedReferences: [],
  449. errors: [
  450. {
  451. message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
  452. severity: 'error',
  453. },
  454. ],
  455. durationMs: 0,
  456. };
  457. }
  458. return this.indexFileWithContent(relativePath, content, stats);
  459. }
  460. /**
  461. * Index a single file with pre-read content and stats.
  462. * Used by the parallel batch reader to avoid redundant file I/O.
  463. */
  464. async indexFileWithContent(
  465. relativePath: string,
  466. content: string,
  467. stats: fs.Stats
  468. ): Promise<ExtractionResult> {
  469. // Prevent path traversal
  470. const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
  471. if (!fullPath) {
  472. logWarn('Path traversal blocked in indexFileWithContent', { relativePath });
  473. return {
  474. nodes: [],
  475. edges: [],
  476. unresolvedReferences: [],
  477. errors: [{ message: 'Path traversal blocked', severity: 'error' }],
  478. durationMs: 0,
  479. };
  480. }
  481. // Check file size
  482. if (stats.size > this.config.maxFileSize) {
  483. return {
  484. nodes: [],
  485. edges: [],
  486. unresolvedReferences: [],
  487. errors: [
  488. {
  489. message: `File exceeds max size (${stats.size} > ${this.config.maxFileSize})`,
  490. severity: 'warning',
  491. },
  492. ],
  493. durationMs: 0,
  494. };
  495. }
  496. // Detect language
  497. const language = detectLanguage(relativePath);
  498. if (!isLanguageSupported(language)) {
  499. return {
  500. nodes: [],
  501. edges: [],
  502. unresolvedReferences: [],
  503. errors: [],
  504. durationMs: 0,
  505. };
  506. }
  507. // Extract from source
  508. const result = extractFromSource(relativePath, content, language);
  509. // Store in database
  510. if (result.nodes.length > 0 || result.errors.length === 0) {
  511. this.storeExtractionResult(relativePath, content, language, stats, result);
  512. }
  513. return result;
  514. }
  515. /**
  516. * Store extraction result in database
  517. */
  518. private storeExtractionResult(
  519. filePath: string,
  520. content: string,
  521. language: Language,
  522. stats: fs.Stats,
  523. result: ExtractionResult
  524. ): void {
  525. const contentHash = hashContent(content);
  526. // Check if file already exists and hasn't changed
  527. const existingFile = this.queries.getFileByPath(filePath);
  528. if (existingFile && existingFile.contentHash === contentHash) {
  529. return; // No changes
  530. }
  531. // Delete existing data for this file
  532. if (existingFile) {
  533. this.queries.deleteFile(filePath);
  534. }
  535. // Insert nodes
  536. if (result.nodes.length > 0) {
  537. this.queries.insertNodes(result.nodes);
  538. }
  539. // Insert edges
  540. if (result.edges.length > 0) {
  541. this.queries.insertEdges(result.edges);
  542. }
  543. // Insert unresolved references in batch with denormalized filePath/language
  544. if (result.unresolvedReferences.length > 0) {
  545. const refsWithContext = result.unresolvedReferences.map((ref) => ({
  546. ...ref,
  547. filePath: ref.filePath ?? filePath,
  548. language: ref.language ?? language,
  549. }));
  550. this.queries.insertUnresolvedRefsBatch(refsWithContext);
  551. }
  552. // Insert file record
  553. const fileRecord: FileRecord = {
  554. path: filePath,
  555. contentHash,
  556. language,
  557. size: stats.size,
  558. modifiedAt: stats.mtimeMs,
  559. indexedAt: Date.now(),
  560. nodeCount: result.nodes.length,
  561. errors: result.errors.length > 0 ? result.errors : undefined,
  562. };
  563. this.queries.upsertFile(fileRecord);
  564. }
  565. /**
  566. * Sync with current file state
  567. */
  568. async sync(onProgress?: (progress: IndexProgress) => void): Promise<SyncResult> {
  569. const startTime = Date.now();
  570. let filesChecked = 0;
  571. let filesAdded = 0;
  572. let filesModified = 0;
  573. let filesRemoved = 0;
  574. let nodesUpdated = 0;
  575. // Get current files on disk
  576. onProgress?.({
  577. phase: 'scanning',
  578. current: 0,
  579. total: 0,
  580. });
  581. const currentFiles = new Set(scanDirectory(this.rootDir, this.config));
  582. filesChecked = currentFiles.size;
  583. // Get tracked files from database
  584. const trackedFiles = this.queries.getAllFiles();
  585. // Find files to remove (in DB but not on disk)
  586. for (const tracked of trackedFiles) {
  587. if (!currentFiles.has(tracked.path)) {
  588. this.queries.deleteFile(tracked.path);
  589. filesRemoved++;
  590. }
  591. }
  592. // Find files to add or update
  593. const filesToIndex: string[] = [];
  594. for (const filePath of currentFiles) {
  595. const fullPath = path.join(this.rootDir, filePath);
  596. let content: string;
  597. try {
  598. content = fs.readFileSync(fullPath, 'utf-8');
  599. } catch (error) {
  600. captureException(error, { operation: 'sync-read-file', filePath });
  601. logDebug('Skipping unreadable file during sync', { filePath, error: String(error) });
  602. continue;
  603. }
  604. const contentHash = hashContent(content);
  605. const tracked = trackedFiles.find((f) => f.path === filePath);
  606. if (!tracked) {
  607. // New file
  608. filesToIndex.push(filePath);
  609. filesAdded++;
  610. } else if (tracked.contentHash !== contentHash) {
  611. // Modified file
  612. filesToIndex.push(filePath);
  613. filesModified++;
  614. }
  615. }
  616. // Index changed files
  617. const total = filesToIndex.length;
  618. for (let i = 0; i < filesToIndex.length; i++) {
  619. const filePath = filesToIndex[i]!;
  620. onProgress?.({
  621. phase: 'parsing',
  622. current: i + 1,
  623. total,
  624. currentFile: filePath,
  625. });
  626. const result = await this.indexFile(filePath);
  627. nodesUpdated += result.nodes.length;
  628. }
  629. return {
  630. filesChecked,
  631. filesAdded,
  632. filesModified,
  633. filesRemoved,
  634. nodesUpdated,
  635. durationMs: Date.now() - startTime,
  636. };
  637. }
  638. /**
  639. * Get files that have changed since last index
  640. */
  641. getChangedFiles(): { added: string[]; modified: string[]; removed: string[] } {
  642. const currentFiles = new Set(scanDirectory(this.rootDir, this.config));
  643. const trackedFiles = this.queries.getAllFiles();
  644. const added: string[] = [];
  645. const modified: string[] = [];
  646. const removed: string[] = [];
  647. // Find removed files
  648. for (const tracked of trackedFiles) {
  649. if (!currentFiles.has(tracked.path)) {
  650. removed.push(tracked.path);
  651. }
  652. }
  653. // Find added and modified files
  654. for (const filePath of currentFiles) {
  655. const fullPath = path.join(this.rootDir, filePath);
  656. let content: string;
  657. try {
  658. content = fs.readFileSync(fullPath, 'utf-8');
  659. } catch (error) {
  660. captureException(error, { operation: 'detect-changes-read-file', filePath });
  661. logDebug('Skipping unreadable file while detecting changes', { filePath, error: String(error) });
  662. continue;
  663. }
  664. const contentHash = hashContent(content);
  665. const tracked = trackedFiles.find((f) => f.path === filePath);
  666. if (!tracked) {
  667. added.push(filePath);
  668. } else if (tracked.contentHash !== contentHash) {
  669. modified.push(filePath);
  670. }
  671. }
  672. return { added, modified, removed };
  673. }
  674. }
  675. // Re-export useful types and functions
  676. export { extractFromSource } from './tree-sitter';
  677. export { detectLanguage, isLanguageSupported, getSupportedLanguages } from './grammars';