diff --git a/.claude/scratchpad.md b/.claude/scratchpad.md index 95c72f1..511499d 100644 --- a/.claude/scratchpad.md +++ b/.claude/scratchpad.md @@ -22,7 +22,7 @@ ## Flaky Tests -- **`packages/cli/src/commands/commands.test.ts:119` — "should display indexing summary without storage size"** times out at 30s on GitHub CI runners. The test indexes files and the slower CI runner can't finish in time. Needs either a higher timeout, a smaller test fixture, or mocking the indexer. Seen on PR #17 CI run. +(none currently tracked) ## Test Gaps diff --git a/packages/cli/src/commands/commands.test.ts b/packages/cli/src/commands/commands.test.ts index f0b3bc7..9a701db 100644 --- a/packages/cli/src/commands/commands.test.ts +++ b/packages/cli/src/commands/commands.test.ts @@ -162,6 +162,6 @@ export class Calculator { // Verify storage size is NOT shown (deferred to `dev stats`) const hasStorageSize = loggedMessages.some((msg) => msg.includes('Storage:')); expect(hasStorageSize).toBe(false); - }, 30000); // 30s timeout for indexing + }, 60000); // 60s timeout — ts-morph project init is slow on CI runners }); }); diff --git a/packages/core/src/scanner/__tests__/scanner.test.ts b/packages/core/src/scanner/__tests__/scanner.test.ts index 7e1dac4..c481063 100644 --- a/packages/core/src/scanner/__tests__/scanner.test.ts +++ b/packages/core/src/scanner/__tests__/scanner.test.ts @@ -2,7 +2,7 @@ import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; import { MarkdownScanner } from '../markdown'; import { ScannerRegistry } from '../registry'; -import { TypeScriptScanner } from '../typescript'; +import { normalizeAndRelativize, TypeScriptScanner } from '../typescript'; // Helper to create registry function createDefaultRegistry(): ScannerRegistry { @@ -1006,3 +1006,52 @@ describe('Scanner', () => { }); }); }); + +describe('normalizeAndRelativize', () => { + const repoRoot = '/Users/dev/project'; + + it('should replace dist/ with src/', () => { + expect( + normalizeAndRelativize('/Users/dev/project/packages/core/dist/map/index.ts', repoRoot) + ).toBe('packages/core/src/map/index.ts'); + }); + + it('should replace .d.ts with .ts', () => { + expect( + normalizeAndRelativize('/Users/dev/project/packages/core/dist/types.d.ts', repoRoot) + ).toBe('packages/core/src/types.ts'); + }); + + it('should replace .js with .ts', () => { + expect(normalizeAndRelativize('/Users/dev/project/packages/core/dist/index.js', repoRoot)).toBe( + 'packages/core/src/index.ts' + ); + }); + + it('should handle multiple dist/ segments', () => { + expect( + normalizeAndRelativize( + '/Users/dev/project/packages/core/dist/formatters/dist/utils.js', + repoRoot + ) + ).toBe('packages/core/src/formatters/src/utils.ts'); + }); + + it('should make paths relative to repoRoot', () => { + expect(normalizeAndRelativize('/Users/dev/project/packages/cli/src/cli.ts', repoRoot)).toBe( + 'packages/cli/src/cli.ts' + ); + }); + + it('should return absolute path when repoRoot is empty', () => { + expect(normalizeAndRelativize('/abs/packages/core/src/index.ts', '')).toBe( + '/abs/packages/core/src/index.ts' + ); + }); + + it('should handle path without dist/', () => { + expect(normalizeAndRelativize('/Users/dev/project/packages/core/src/index.ts', repoRoot)).toBe( + 'packages/core/src/index.ts' + ); + }); +}); diff --git a/packages/core/src/scanner/typescript.ts b/packages/core/src/scanner/typescript.ts index af5e086..ee8d5d1 100644 --- a/packages/core/src/scanner/typescript.ts +++ b/packages/core/src/scanner/typescript.ts @@ -1,3 +1,4 @@ +import * as fs from 'node:fs'; import * as path from 'node:path'; import type { Logger } from '@prosdevlab/kero'; import { @@ -19,6 +20,21 @@ import { import { getCurrentSystemResources, getOptimalConcurrency } from '../utils/concurrency'; import type { CalleeInfo, Document, Scanner, ScannerCapabilities } from './types'; +/** + * Normalize a resolved file path: dist/ → src/, .d.ts → .ts, absolute → relative. + * Pure function — no I/O. + */ +export function normalizeAndRelativize(filePath: string, repoRoot: string): string { + let normalized = filePath + .replaceAll('/dist/', '/src/') + .replace(/\.d\.ts$/, '.ts') + .replace(/\.js$/, '.ts'); + if (repoRoot && normalized.startsWith(repoRoot)) { + normalized = path.relative(repoRoot, normalized); + } + return normalized; +} + /** * Enhanced TypeScript scanner using ts-morph * Provides type information and cross-file references @@ -987,21 +1003,7 @@ export class TypeScriptScanner implements Scanner { const declSourceFile = firstDecl.getSourceFile(); if (declSourceFile) { const rawPath = declSourceFile.getFilePath() as string; - // Only include if it's within the project (not node_modules) - if (rawPath && !rawPath.includes('node_modules')) { - // Normalize: dist/ → src/, .d.ts → .ts, then make relative to repo root. - // ts-morph resolves imports to absolute dist output paths - // (e.g. /abs/packages/logger/dist/types.d.ts) but we store - // relative source paths (packages/logger/src/types.ts). - let normalized = rawPath - .replaceAll('/dist/', '/src/') - .replace(/\.d\.ts$/, '.ts') - .replace(/\.js$/, '.ts'); - if (this.repoRoot && normalized.startsWith(this.repoRoot)) { - normalized = path.relative(this.repoRoot, normalized); - } - file = normalized; - } + file = this.normalizeCalleePath(rawPath); } } } @@ -1016,4 +1018,39 @@ export class TypeScriptScanner implements Scanner { file, }; } + + /** + * Normalize a callee file path to a relative source path. + * + * Handles three cases: + * 1. Direct project files (not in node_modules) — normalize dist/ → src/ + * 2. Workspace package symlinks (node_modules/@scope/pkg → packages/pkg) — resolve symlink + * 3. External node_modules — skip (return undefined) + */ + private normalizeCalleePath(rawPath: string): string | undefined { + if (!rawPath) return undefined; + + // Case 1: Not in node_modules — direct project file + if (!rawPath.includes('node_modules')) { + return normalizeAndRelativize(rawPath, this.repoRoot); + } + + // Case 2: Workspace package symlink — resolve to real path + // pnpm workspace links: node_modules/@scope/pkg → ../../actual-pkg + // After resolving, the real path is inside the repo but NOT in node_modules + if (this.repoRoot) { + try { + const realPath = fs.realpathSync(rawPath); + if (realPath.startsWith(this.repoRoot) && !realPath.includes('node_modules')) { + // It's a workspace package — normalize and make relative + return normalizeAndRelativize(realPath, this.repoRoot); + } + } catch { + // realpathSync can fail if the file doesn't exist + } + } + + // Case 3: External dependency — skip + return undefined; + } }