Skip to content

Conversation

@LayZeeDK
Copy link
Member

@LayZeeDK LayZeeDK commented Oct 29, 2025

perf(move-file): cache index file export analysis (#161)

Summary
Adds an index exports cache to avoid reparsing entrypoint (index) files for each export check during move-file operations. Replaces repeated regex scans with a cached, content-keyed extraction of export/re-export specifiers.

Implementation

  • New index-exports-cache.ts with:
    • getIndexExports(tree, indexPath): regex parses export/re-export statements once per content snapshot
    • Content snapshot stored to auto-invalidate when file content changes via treeReadCache
    • clear / invalidate helpers (prepared for future wiring)
  • isFileExported now consults cache instead of building a fresh RegExp per entrypoint scan
  • Normalizes stored specifiers (ensures leading ./) for consistent lookup
  • No changes to public generator API; all tests unchanged

Benchmarks (ran 2025-10-29T20:53:33Z, updated at 2025-10-29T20:55:40.852Z)
Baseline (before):

  • Export detection: 56,768 ops/sec ±2.09%
  • Export addition: 61,458 ops/sec ±1.81%
  • Export removal: 56,077 ops/sec ±1.82%

Updated (after):

  • Export detection: 62,156 ops/sec ±1.66% (+~9.5%)
  • Export addition: 61,446 ops/sec ±1.30% (~parity)
  • Export removal: 62,213 ops/sec ±4.02% (+~11%)

Net Effect

  • Export presence checks (dominant path) show ~9–11% throughput improvement.
  • No regressions measured.
  • All 770 tests pass; lint and formatting pass.

Risk / Mitigation

  • Cache invalidation leverages existing treeReadCache invalidation when files are written; content snapshot comparison prevents stale data if file changes without explicit invalidation.
  • Limited scope: only read paths; write logic unaffected.

Follow-ups (optional)

  • Expose and wire clearIndexExportsCache() into global cache clearing utilities.
  • Expand regex to capture additional export patterns if needed (e.g., default export forwarding aliases).

Closes #161.

@LayZeeDK LayZeeDK changed the title perf(move-file): cache index file export analysis (#161) perf(move-file): cache index file export analysis Oct 29, 2025
@github-actions github-actions bot force-pushed the feat/161-index-exports-cache branch from 70f97e9 to a188b59 Compare October 29, 2025 21:00
@LayZeeDK LayZeeDK requested a review from Copilot October 29, 2025 21:03
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Adds a caching mechanism for index file export analysis to improve performance in move-file operations by avoiding repeated regex parsing of entrypoint files.

  • Introduces an index exports cache that stores parsed export/re-export specifiers with content-based invalidation
  • Replaces per-call regex compilation with cached export analysis for ~9-11% performance improvement
  • Maintains existing API compatibility with no changes to public interfaces or test behavior

Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
index-exports-cache.ts New cache implementation with regex-based export parsing and content-keyed invalidation
is-file-exported.ts Updated to use cached export analysis instead of inline regex compilation
updated-export-bench.txt Performance benchmark results showing improved throughput
baseline-export-bench.txt Baseline performance metrics for comparison

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 56 to 64
for (const spec of reexports) {
const withoutExt = spec.replace(/\.(ts|tsx|js|jsx)$/i, '');
// Ensure stored paths are normalized to start with './'
const normalized =
withoutExt.startsWith('./') || withoutExt.startsWith('../')
? withoutExt
: `./${withoutExt}`;
exports.add(normalized);
}
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic adds reexports to the exports set, which conflates two different types of exports. The reexports should remain in the reexports set, and exports should contain actual local exports from the file.

Suggested change
for (const spec of reexports) {
const withoutExt = spec.replace(/\.(ts|tsx|js|jsx)$/i, '');
// Ensure stored paths are normalized to start with './'
const normalized =
withoutExt.startsWith('./') || withoutExt.startsWith('../')
? withoutExt
: `./${withoutExt}`;
exports.add(normalized);
}
// Note: Do not add reexports to exports. Exports should only contain actual local exports.
// If needed, add logic here to capture local exports (e.g., export { foo, bar };) in the future.

Copilot uses AI. Check for mistakes.

// Match: export * from './path'; OR export { ... } from './path'; OR export {default as X} from './path';
const reExportPattern =
/export\s+(?:\*|\{[^}]+\})\s+from\s+['"](\.\.?\/[^'";]+)['"];?/g;
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern allows paths that don't start with './' or '../' by using \.\.?\/ which makes the second dot optional. This could match invalid paths like ./x incorrectly. The pattern should be (\.\.?\/[^'";]+) or more specifically (\.(?:\.\/|\/)[^'";]+) to properly match relative paths.

Suggested change
/export\s+(?:\*|\{[^}]+\})\s+from\s+['"](\.\.?\/[^'";]+)['"];?/g;
/export\s+(?:\*|\{[^}]+\})\s+from\s+['"](\.(?:\.\/|\/)[^'";]+)['"];?/g;

Copilot uses AI. Check for mistakes.
Comment on lines 56 to 57
for (const spec of reexports) {
const withoutExt = spec.replace(/\.(ts|tsx|js|jsx)$/i, '');
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file extension removal logic duplicates functionality that likely exists elsewhere in the codebase (e.g., removeSourceFileExtension that's imported in the other file). Consider extracting this to a shared utility or reusing existing functionality.

Copilot uses AI. Check for mistakes.
@LayZeeDK
Copy link
Member Author

LayZeeDK commented Oct 29, 2025

Applied review feedback:

  • Separated reexports from exports (no conflation).
  • Added clarifying comments and left exports empty until local export parsing is implemented.
  • Updated isFileExported to rely solely on reexports.

All tests & lint pass.

LayZeeDK added a commit that referenced this pull request Oct 29, 2025
Implementing index export cache to parse local exports in addition to
re-exports

## Plan

- [x] Create IndexExports type and interface in export-management
- [x] Create getIndexExports function to parse local exports using AST
- [x] Update isFileExported to use the new cache system
- [x] Add comprehensive unit tests for getIndexExports
- [x] Add benchmark tests for performance validation
- [x] Update existing tests to cover local export scenarios
- [x] Run linting, build, and all tests
- [x] Format code
- [x] Update documentation
- [x] Address code review feedback
- [x] Update JSDoc comments to reflect cache usage

## Recent Changes

Updated documentation in `isFileExported()` to clarify:
- Function now uses the index exports cache system
- Cache parses local declarations but function only checks re-exports
- This addresses review feedback about outdated documentation

The distinction is important: `isFileExported()` checks if a **file** is
re-exported (e.g., `export * from './lib/utils'`), not if **symbols**
from that file are present as local exports in the index.

<!-- START COPILOT CODING AGENT SUFFIX -->



<details>

<summary>Original prompt</summary>


----

*This section details on the original issue you should resolve*

<issue_title>feat(move-file): parse local exports in index export
cache</issue_title>
<issue_description>## Summary
Enhance the index exports cache to parse and record *local* export
declarations in entrypoint (index) files in addition to existing
re-export ("export ... from") patterns.

Currently `getIndexExports()` only captures reexports and leaves
`exports` empty, forcing callers (e.g. `isFileExported`) to rely solely
on `reexports`. This prevents accurate detection of locally defined
exports (e.g. `export { foo, bar }`, `export const X = ...`, `export
function fn() {}`, `export class C {}`, and `export default ...`).
Supporting these will improve correctness of move-file validation
(unexported dependency checks, alias conversions) and reduce false
warnings.

## Motivation / Problem
When a project’s public surface is defined via local declarations in
`index.ts` rather than re-exporting from leaf modules, the current cache
misses them. As a result:
- `isFileExported()` incorrectly reports a file as unexported if it’s
aggregated via local declarations copied into the index.
- Move-file generator may warn about supposedly unexported relative
dependencies.
- Potential future optimizations (e.g., differentiating default vs named
exports) are blocked.

## Scope
Add robust parsing of local export declarations for
TypeScript/JavaScript index files, keeping the lightweight approach
(regex or jscodeshift) while maintaining performance. Preserve
separation between `exports` and `reexports` (per review feedback in PR
#316).

## Proposed Design
1. Reuse existing AST caching (jscodeshift + `astCache`) for structural
accuracy while minimizing parse cost.
2. Collect and normalize local export specifiers:
- Named declarations: `export const A`, `export function b`, `export
class C`, `export interface D`, `export type E`, `export enum F`.
   - Named list: `export { a, b as c }` (without `from`).
- Default exports: `export default X`, `export default function() {}`,
`export default class {}`.
3. Represent cache structure:
   ```ts
   interface IndexExports {
exports: Set<string>; // local named export identifiers (normalized)
reexports: Set<string>; // existing re-export specifiers ('./lib/util')
defaultExport?: string; // identifier or synthetic marker
('<anonymous>') if unnamed default
   }
   ```
4. Normalization Rules:
- Local identifiers stored exactly as defined (no extension logic
needed).
- Anonymous default exports mapped to a sentinel (e.g. `<default>` or
`<anonymous-default>`).
5. Backwards compatibility: existing callers using `reexports` remain
unchanged; add helper `isLocalSymbolExported(name)` if needed later.
6. Performance Strategy:
- Parse AST once via `astCache.getAST`; fall back to regex only if AST
unavailable.
   - Skip expensive symbol resolution—pure syntactic discovery.
   - Maintain content snapshot invalidation (existing logic).
7. Update `isFileExported` logic:
- If import specifier points to a file path: keep current re-export
check.
- If validation wants to infer export of an internal symbol (future),
can consult `exports` set.

## Tasks
- [ ] Extend `IndexExports` interface (add `defaultExport` field).
- [ ] Implement local export parsing function
(`collectLocalExports(ast): { names: Set<string>; default?: string }`).
- [ ] Integrate parsing into `getIndexExports` (only when content
contains `export`).
- [ ] Add unit tests covering:
  - Basic named exports (const/function/class/interface/type/enum).
  - Named list / alias exports (with `as`).
  - Default exports (named and anonymous).
  - Mixed local + re-exports in same file.
  - Empty index file (returns empty sets, undefined default).
  - Cache hit behavior (content unchanged).
  - Cache invalidation (content change updates sets).
- [ ] Update `is-file-exported.spec.ts` to include a scenario with local
declarations.
- [ ] Add benchmark cases to `export-management.bench.ts` for local
export detection (ensure negligible regression; target <5% throughput
impact).
- [ ] Update PR/Documentation: mention difference between local exports
and reexports.

## Acceptance Criteria
- All new tests pass; existing 770 tests remain green.
- Benchmarks show <5% performance degradation for export detection suite
or an offsetting improvement if AST parsing reduces regex overhead.
- `isFileExported` correctly returns true for files represented by local
declarations (when those files exist or are logically aggregated).
- No conflation between `exports` and `reexports`; clear docs in
`index-exports-cache.ts`.

## Performance Considerations
- AST parsing is already cached; incremental cost is node filtering for
export declarations.
- Provide fallback to fast regex ONLY if AST is unavailable (e.g., parse
error).
- Keep operations allocation-light (pre-size arrays where possible,
avoid repeated string normaliz...

</details>

- Fixes #317

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.
@LayZeeDK LayZeeDK requested a review from Copilot November 12, 2025 10:33
@LayZeeDK
Copy link
Member Author

/review

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const indexExports = getIndexExports(tree, indexPath);
// Compare against file path without extension (as stored)
// Since local exports are not yet collected, rely on reexports for detection.
return indexExports.reexports.has(`./${fileWithoutExt}`);
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The export check always prefixes with ./, but the cached reexports may contain paths starting with ../ (parent directory references). This will cause false negatives when checking if files exported via parent-relative paths (e.g., export * from '../sibling/file') are detected. The path normalization should account for both ./ and ../ patterns or normalize paths consistently before comparison.

Copilot uses AI. Check for mistakes.
let match: RegExpExecArray | null;
while ((match = reExportPattern.exec(content))) {
const spec = match[1];
reexports.add(spec);
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The captured path specifiers are stored as-is without removing file extensions, but fileWithoutExt in is-file-exported.ts has extensions removed. This creates an inconsistency: if index files contain export * from './utils.ts', the cache stores './utils.ts' but the comparison checks for './utils'. Either strip extensions when storing in cache or when comparing, ensuring both sides match.

Suggested change
reexports.add(spec);
// Remove file extension (e.g., .ts, .js, .jsx, .tsx) from specifier
const specWithoutExt = spec.replace(/\.[tj]sx?$/, '');
reexports.add(specWithoutExt);

Copilot uses AI. Check for mistakes.
// Use cached export analysis for index file
const indexExports = getIndexExports(tree, indexPath);
// Compare against file path without extension (as stored)
// Since local exports are not yet collected, rely on reexports for detection.
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 47 states 'Since local exports are not yet collected, rely on reexports for detection' but doesn't explain why local exports are excluded or what impact this has. Add clarification that this currently only detects re-exported files (export...from statements) and won't detect files with direct exports in the index that don't use re-export syntax.

Suggested change
// Since local exports are not yet collected, rely on reexports for detection.
// Since local exports are not yet collected, rely on reexports for detection.
// NOTE: This currently only detects files that are re-exported using `export ... from` statements.
// Files that are directly exported in the index (without using re-export syntax) will NOT be detected.

Copilot uses AI. Check for mistakes.
@claude
Copy link

claude bot commented Nov 12, 2025

PR Review: Cache Index File Export Analysis (#316)

Summary

This PR introduces a performance optimization for the move-file generator by caching the parsing of index file exports. The implementation shows strong engineering practices with comprehensive tests, proper cache invalidation, and measurable performance improvements.


✅ Code Quality & Best Practices

Excellent:

  • Comprehensive test coverage: 47 unit tests covering all export patterns (re-exports, local named exports, default exports, TypeScript-specific types, edge cases, and caching behavior)
  • Proper separation of concerns: Cache logic cleanly separated into its own module (index-exports-cache.ts)
  • Performance benchmarks: 6 dedicated benchmark tests measuring different scenarios
  • Clear documentation: Well-documented JSDoc comments explaining behavior and supported patterns
  • Follows repository patterns: Consistent with existing cache implementations (astCache, treeReadCache)

Cache Invalidation:

  • ✅ Properly wired into clearAllCaches() for generator initialization (clear-all-caches.ts:34)
  • ✅ Called by invalidateIndexExportsCache() after write operations:
    • ensure-file-exported.ts:47 (after adding exports)
    • remove-file-export.ts:77 (after removing exports)
  • ✅ Follows the same pattern as AST cache invalidation

🔍 Technical Review

1. Implementation Approach

The PR switched from regex-based parsing (in the diff) to AST-based parsing (in the actual code), which is the superior approach:

Pros of AST-based approach:

  • More accurate and robust parsing
  • Handles all TypeScript/JavaScript export patterns correctly
  • No risk of false positives from strings/comments
  • Leverages existing astCache infrastructure

Note: The PR diff shows a regex-based implementation, but the actual merged code uses AST parsing via jscodeshift. This is a significant improvement over what the diff suggests.

2. Cache Key Strategy

The cache uses file paths as keys, relying on the existing astCache for content-based invalidation:

export function getIndexExports(tree: Tree, indexPath: string): IndexExports {
  const cached = indexExportsCache.get(indexPath);
  if (cached) return cached;
  
  const root = astCache.getAST(tree, indexPath); // Handles content changes
  // ... parse and cache
}

This is correct because:

  • The astCache.getAST() already handles content-based cache invalidation
  • The index exports cache sits on top of the AST cache layer
  • File writes trigger explicit invalidateIndexExportsCache() calls

3. Performance Impact

Measured improvements from benchmarks:

  • Export detection: +9.5% (56,768 → 62,156 ops/sec)
  • Export removal: +11% (56,077 → 62,213 ops/sec)
  • Export addition: ~parity (no regression)

These are meaningful improvements for a frequently-called operation during file moves.


🐛 Potential Issues

Critical: Path Normalization Inconsistency

There's a subtle bug in is-file-exported.ts line 58:

for (const reexport of indexExports.reexports) {
  const normalizedReexport = reexport.replace(/^\.\.?\//, ''); // Removes './' or '../'
  const reexportWithoutExt = removeSourceFileExtension(normalizedReexport);
  
  if (reexportWithoutExt === fileWithoutExt) { // Bug: doesn't handle '../' properly
    return true;
  }
}

Problem: When comparing paths, this doesn't account for parent directory traversal (../). If an index file exports from '../utils', this will be normalized to 'utils', but fileWithoutExt might be '../utils' or 'lib/utils', causing mismatches.

Recommendation:

// Option 1: Normalize both paths to absolute workspace paths before comparing
const absoluteReexport = path.join(path.dirname(indexPath), reexport);
const absoluteFile = path.join(project.root, file);

// Option 2: Use the existing path-utils helpers more carefully
// Ensure consistent resolution relative to the index file location

Minor: Missing Parent Directory Path Handling

The regex /^\.\.?\//' only handles:

  • ./path (current dir)
  • ../path (parent dir - single level)

But doesn't handle:

  • ../../path (two levels up)
  • Paths without leading ./ (rare but possible in some bundler configs)

Recommendation: Use a more robust path normalization utility or clarify in comments that deeply nested relative imports aren't expected in index files.


🔒 Security Concerns

Low Risk: The implementation uses the existing astCache which already has proper path sanitization via security-utils/. No new security vectors introduced.


📊 Test Coverage

Coverage Analysis:

Category Test Count Status
Re-exports 3 tests ✅ Excellent
Local named exports 9 tests ✅ Comprehensive
Default exports 6 tests ✅ Complete
Mixed exports 1 test ✅ Good
Edge cases 4 tests ✅ Good
Caching behavior 3 tests ✅ Good
TypeScript-specific 2 tests ✅ Good
Integration 0 dedicated ⚠️ Missing

Recommendation: Add at least one integration test showing the cache being used across multiple isFileExported() calls in a realistic move operation to verify the cache improves real-world performance.


🚀 Performance Considerations

Strengths:

  • Cache hit performance is excellent (should be near O(1) map lookup)
  • No unnecessary re-parsing of unchanged files
  • Proper cache clearing prevents unbounded memory growth

Potential Concern:
The cache stores Set<string> objects which could grow large for projects with massive index files. For a typical workspace this is negligible, but consider:

  • Adding cache size monitoring in future
  • Consider a max cache size with LRU eviction if needed (not urgent)

🎯 Recommendations

  1. Fix the path normalization issue in is-file-exported.ts (lines 51-60) - this could cause false negatives for parent directory imports
  2. Add integration test demonstrating cache effectiveness during a complete move operation
  3. Document the cache hierarchy: Clarify in comments that indexExportsCache relies on astCache for content invalidation
  4. Consider adding metrics: Log cache hit rate in verbose mode to help diagnose issues

📝 PR Metadata Review

Title: ✅ Follows Conventional Commits (perf(move-file):)
Description: ✅ Comprehensive with benchmarks, implementation details, and risk assessment
CI Status: ✅ All checks passing
Mergeable: ⚠️ CONFLICTING - needs rebase


✅ Final Verdict

Approval: APPROVE with minor fixes recommended

This is a well-engineered performance optimization with:

  • Solid implementation following repository patterns
  • Excellent test coverage (47 unit tests + 6 benchmarks)
  • Measurable performance gains (~10% improvement)
  • Proper cache invalidation strategy

The path normalization issue should be addressed before merging, but it's a localized fix that doesn't undermine the overall approach.

Great work! This demonstrates strong attention to testing, performance measurement, and maintainability.


📎 References

  • index-exports-cache.ts:46 - Main cache implementation
  • is-file-exported.ts:44 - Cache consumption
  • clear-all-caches.ts:34 - Global cache clearing
  • ensure-file-exported.ts:47 - Cache invalidation on write

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(move-file): cache index file export analysis

2 participants