From 4bead01d110322f7d21da395fb2bcb9109fdf6f2 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Fri, 20 Mar 2026 17:17:42 -0700 Subject: [PATCH 1/5] feat: contributing, issues templaces --- .github/ISSUE_TEMPLATE/bug_report.yml | 58 +++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 35 ++++++ .github/pull_request_template.md | 21 ++++ CONTRIBUTING.md | 132 ++++++++++++++++----- 5 files changed, 223 insertions(+), 28 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..11cbcc2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,58 @@ +name: Bug Report +description: Something isn't working as expected +labels: ["bug"] +body: + - type: markdown + attributes: + value: Thanks for reporting! Please fill out the sections below. + + - type: input + id: command + attributes: + label: Command + description: Which aspens command were you running? + placeholder: "aspens doc init ." + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe the bug. Include the full error message if there is one. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + value: | + 1. + 2. + 3. + + - type: input + id: node-version + attributes: + label: Node.js version + placeholder: "v20.11.0" + + - type: input + id: os + attributes: + label: OS + placeholder: "macOS 15.3 / Ubuntu 24.04 / Windows 11" + + - type: textarea + id: context + attributes: + label: Additional context + description: Repo size, frameworks detected, anything else relevant. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b40b367 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Question or Discussion + url: https://github.com/aspenkit/aspens/discussions + about: Ask questions or start a discussion diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a987444 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: Have an idea? We'd love to hear it. + + - type: textarea + id: problem + attributes: + label: Problem + description: What problem does this solve? What's frustrating or missing today? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How do you think it should work? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any other approaches you thought about? + + - type: textarea + id: context + attributes: + label: Additional context + description: Screenshots, examples, links, anything helpful. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..12a19d1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## What + + + +## Why + + + +Closes # + +## How I tested + +- [ ] `npm test` passes +- [ ] Tested against a real repo: +- [ ] `--dry-run` output looks correct (if applicable) + +## Checklist + +- [ ] Changes are focused on a single feature or fix +- [ ] Tests added or updated for any logic changes +- [ ] No new dependencies added (or justified in the PR description) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 266b3e9..8ede650 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,56 +1,126 @@ # Contributing to aspens -## Dev Setup +## Prerequisites + +- [Node.js 18+](https://nodejs.org) +- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) (needed for `doc init`, `doc sync`, and `customize` commands) +- Git + +## Setup ```bash -git clone https://github.com/aspenkit/aspens.git +# 1. Fork the repo on GitHub, then: +git clone https://github.com//aspens.git cd aspens npm install + +# 2. Verify it works node bin/cli.js --help +npm test ``` No build step. The CLI runs directly from source (ES modules). +## Testing Your Changes Locally + +### Run the test suite + +```bash +npm test # Run all tests +npx vitest run tests/scanner.test.js # Run a single test file +npx vitest --watch # Watch mode during development +``` + +### Test against a real repo + +The best way to verify your changes is to run aspens against an actual codebase. + +**Option 1: `npm link` (recommended)** + +Link your local aspens globally so you can use it like the published package: + +```bash +# In the aspens directory: +npm link + +# Now test from any repo: +cd /path/to/some-project +aspens scan . +aspens doc init --dry-run . +``` + +When you're done, unlink: + +```bash +npm unlink -g aspens +``` + +**Option 2: Run directly** + +```bash +node /path/to/aspens/bin/cli.js scan /path/to/some-project +``` + +**Option 3: Use `--dry-run`** + +Most write commands support `--dry-run` to preview output without touching files: + +```bash +node bin/cli.js doc init --dry-run /path/to/some-project +node bin/cli.js doc sync --dry-run /path/to/some-project +``` + +## Code Review with CodeRabbit + +We use [CodeRabbit](https://coderabbit.ai) for automated code review on every PR. Here's what to expect: + +- **Automatic review** — CodeRabbit reviews your PR when you open it and on each push. No action needed from you. +- **Summary comment** — It posts a walkthrough summarizing your changes, sequence diagrams, and a file-by-file breakdown. +- **Inline suggestions** — It may leave inline comments with improvement suggestions. You can reply to these directly. +- **Chat commands** — You can interact with CodeRabbit in PR comments: + - `@coderabbitai resolve` — Mark a suggestion as resolved + - `@coderabbitai summary` — Regenerate the summary + - `@coderabbitai review` — Request a re-review after changes + +Don't worry about "passing" CodeRabbit — the review profile is set to **chill**. It's there to catch things, not block you. A maintainer will do the final review. + +## Submitting a Pull Request + +1. Fork the repo and create a branch (`git checkout -b my-change`) +2. Make your changes +3. Run the tests (`npm test`) +4. Test against at least one real repo (see above) +5. Open a PR with a clear description of what changed and why + +### What makes a good PR + +- **Small and focused** — one feature or fix per PR +- **Tests included** — add or update tests in `tests/` for any logic changes +- **Tested against a real repo** — mention which repo you tested with in the PR description + ## Project Structure ``` -bin/cli.js # Entry point — commander setup, welcome screen +bin/cli.js # Entry point — Commander setup, welcome screen src/ commands/ scan.js # Tech stack detection output doc-init.js # Skill generation pipeline (3 modes) doc-sync.js # Diff-based skill updates + git hook - add.js # Placeholder (Phase 4) + add.js # Add individual components + customize.js # Inject project context into agents lib/ scanner.js # Deterministic repo scanner (no LLM) - runner.js # Claude CLI wrapper, prompt loading, output parsing + graph-builder.js # Import graph construction context-builder.js # Assembles repo context for prompts + runner.js # Claude CLI wrapper, stream parsing skill-writer.js # Writes skill files to disk - prompts/ - doc-init.md # All-at-once generation prompt - doc-init-domain.md # Single domain prompt (chunked mode) - doc-init-claudemd.md # CLAUDE.md generation prompt - doc-sync.md # Sync prompt (diff -> updates) - partials/ # Shared format specs and examples -``` - -## Testing - -There is no test runner yet. Test manually against real repos: - -```bash -node bin/cli.js scan /path/to/repo -node bin/cli.js doc init --dry-run /path/to/repo + prompts/ # Markdown prompt templates + partials/ # Shared format specs + templates/ # Bundled components (agents, hooks, commands) +tests/ # Vitest test files ``` -## Submitting Changes - -1. Fork the repo -2. Create a branch (`git checkout -b my-change`) -3. Make your changes -4. Test against at least one real repo -5. Open a PR with a clear description of what changed and why - ## Code Style - **ES modules** — `import`/`export`, no `require()` @@ -58,3 +128,9 @@ node bin/cli.js doc init --dry-run /path/to/repo - **UI** — `@clack/prompts` for interactive prompts, `picocolors` for color - **Error handling** — throw descriptive errors with remediation hints, `process.exit(1)` on unrecoverable failures - **File reads** — try/catch returning `null` on failure, never throw on missing files + +## Where to Start + +Look for issues labeled [`good first issue`](https://github.com/aspenkit/aspens/labels/good%20first%20issue) — these are scoped, well-described tasks ideal for first-time contributors. + +If you have an idea but aren't sure where to start, open an issue first to discuss it. From 9b7052638d94383820183aa12eeec92b2498004e Mon Sep 17 00:00:00 2001 From: mvoutov Date: Fri, 20 Mar 2026 17:22:49 -0700 Subject: [PATCH 2/5] v0.2.0: import graph, parallel discovery, agentic skill generation --- bin/cli.js | 11 +- package-lock.json | 2 +- package.json | 3 +- src/commands/doc-init.js | 329 ++++++++++++-- src/commands/scan.js | 223 +++++++++- src/lib/graph-builder.js | 642 +++++++++++++++++++++++++++ src/lib/scanner.js | 288 ++++++++---- src/prompts/discover-architecture.md | 48 ++ src/prompts/discover-domains.md | 29 ++ tests/graph-builder.test.js | 567 +++++++++++++++++++++++ tests/scanner.test.js | 26 +- 11 files changed, 2002 insertions(+), 166 deletions(-) create mode 100644 src/lib/graph-builder.js create mode 100644 src/prompts/discover-architecture.md create mode 100644 src/prompts/discover-domains.md create mode 100644 tests/graph-builder.test.js diff --git a/bin/cli.js b/bin/cli.js index 784bf40..aca61e4 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -2,7 +2,7 @@ import { program } from 'commander'; import pc from 'picocolors'; -import { readdirSync } from 'fs'; +import { readFileSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { scanCommand } from '../src/commands/scan.js'; @@ -13,6 +13,8 @@ import { customizeCommand } from '../src/commands/customize.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', 'src', 'templates'); +const PKG = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')); +const VERSION = PKG.version; function countTemplates(subdir) { try { return readdirSync(join(TEMPLATES_DIR, subdir)).filter(f => !f.startsWith('.')).length; } catch { return '?'; } @@ -20,7 +22,7 @@ function countTemplates(subdir) { function showWelcome() { console.log(` - ${pc.cyan(pc.bold('aspens'))} ${pc.dim('v0.1.0')} — AI-ready documentation for your codebase + ${pc.cyan(pc.bold('aspens'))} ${pc.dim(`v${VERSION}`)} — AI-ready documentation for your codebase ${pc.bold('Quick Start')} ${pc.green('aspens scan')} See your repo's tech stack and domains @@ -63,7 +65,7 @@ function showWelcome() { program .name('aspens') .description('Generate and maintain AI-ready documentation for your codebase') - .version('0.1.0') + .version(VERSION) .action(() => { // No command given — show welcome showWelcome(); @@ -75,6 +77,8 @@ program .description('Scan a repo and print its tech stack and structure') .argument('[path]', 'Path to repo', '.') .option('--json', 'Output as JSON') + .option('--domains ', 'Additional domains to include (comma-separated)') + .option('--verbose', 'Show diagnostic output') .action(scanCommand); // Doc commands @@ -91,6 +95,7 @@ doc .option('--timeout ', 'Claude timeout in seconds', '300') .option('--mode ', 'Generation mode: all, chunked, base-only (skips interactive prompt)') .option('--strategy ', 'Existing docs: improve, rewrite, skip (skips interactive prompt)') + .option('--domains ', 'Additional domains to include (comma-separated, e.g., "backtest,advisory")') .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') .option('--verbose', 'Show what Claude is reading/doing in real time') .action(docInitCommand); diff --git a/package-lock.json b/package-lock.json index 59fb326..18f9a38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@clack/prompts": "^0.8.0", "commander": "^12.0.0", + "es-module-lexer": "^2.0.0", "picocolors": "^1.1.0" }, "bin": { @@ -590,7 +591,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, "license": "MIT" }, "node_modules/estree-walker": { diff --git a/package.json b/package.json index 33fb15c..c09b522 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aspens", "version": "0.1.0", - "description": "CLI for managing shared AI coding config across repos", + "description": "Generate and maintain AI-ready documentation for any codebase", "type": "module", "bin": { "aspens": "./bin/cli.js" @@ -30,6 +30,7 @@ "dependencies": { "@clack/prompts": "^0.8.0", "commander": "^12.0.0", + "es-module-lexer": "^2.0.0", "picocolors": "^1.1.0" }, "devDependencies": { diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index b6eac53..a127e60 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -3,6 +3,7 @@ import { existsSync } from 'fs'; import pc from 'picocolors'; import * as p from '@clack/prompts'; import { scanRepo } from '../lib/scanner.js'; +import { buildRepoGraph } from '../lib/graph-builder.js'; import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; import { writeSkillFiles } from '../lib/skill-writer.js'; @@ -48,18 +49,31 @@ export async function docInitCommand(path, options) { const model = options.model || null; // Reset token tracker for this run + const startTime = Date.now(); tokenTracker.promptTokens = 0; tokenTracker.toolResultTokens = 0; tokenTracker.output = 0; tokenTracker.toolUses = 0; tokenTracker.calls = 0; + // Parse --domains flag + const extraDomains = options.domains + ? options.domains.split(',').map(d => d.trim().toLowerCase()).filter(Boolean) + : undefined; + p.intro(pc.cyan('aspens doc init')); // Step 1: Scan const scanSpinner = p.spinner(); scanSpinner.start('Scanning repository...'); - const scan = scanRepo(repoPath); + const scan = scanRepo(repoPath, { extraDomains }); + + // Build import graph + let repoGraph = null; + try { + repoGraph = await buildRepoGraph(repoPath, scan.languages); + } catch { /* graph building failed — continue without it */ } + scanSpinner.stop(`Scanned ${pc.bold(scan.name)} (${scan.repoType})`); // Show what was found @@ -71,7 +85,10 @@ export async function docInitCommand(path, options) { console.log(pc.dim(' Frameworks: ') + scan.frameworks.join(', ')); } if (scan.domains.length > 0) { - console.log(pc.dim(' Domains: ') + scan.domains.map(d => d.name).join(', ')); + console.log(pc.dim(' Source modules: ') + scan.domains.map(d => d.name).join(', ')); + } + if (repoGraph) { + console.log(pc.dim(' Import graph: ') + `${repoGraph.stats.totalFiles} files, ${repoGraph.stats.totalEdges} edges`); } const timeoutMs = autoTimeout(scan, options.timeout); if (scan.size) { @@ -80,8 +97,111 @@ export async function docInitCommand(path, options) { } console.log(); - // Check for existing docs - let existingDocsStrategy = 'fresh'; // default for repos with no existing docs + // Step 2: Parallel Discovery — runs immediately, no user input needed + let discoveryFindings = null; + let discoveredDomains = []; + + if (repoGraph && repoGraph.stats.totalFiles > 0 && options.mode !== 'base-only') { + console.log(pc.dim(' Running 2 discovery agents in parallel...')); + console.log(); + const discoverSpinner = p.spinner(); + discoverSpinner.start('Discovering domains + analyzing architecture...'); + + try { + const graphContext = buildGraphContext(repoGraph); + let hotspotsSection = ''; + if (repoGraph.hotspots && repoGraph.hotspots.length > 0) { + hotspotsSection = '\n\n### Hotspots (high churn)\n'; + for (const h of repoGraph.hotspots) { + hotspotsSection += `- \`${h.path}\` — ${h.churn} changes, ${h.lines} lines\n`; + } + } + + const scanSummary = buildScanSummary(scan); + const sharedContext = `\n\n---\n\nRepository: ${repoPath}\n\n${scanSummary}\n\n${graphContext}${hotspotsSection}`; + + // Run both discovery agents in parallel + const [domainsResult, archResult] = await Promise.all([ + // Agent 1: Domain discovery (focused, fast) + (async () => { + try { + const prompt = loadPrompt('discover-domains') + sharedContext; + const { text, usage } = await runClaude(prompt, makeClaudeOptions(timeoutMs, verbose, model, null)); + trackUsage(usage, prompt.length); + const match = text.match(/([\s\S]*?)<\/findings>/); + return match ? match[1].trim() : null; + } catch { return null; } + })(), + // Agent 2: Architecture analysis (deeper, reads hub files) + (async () => { + try { + const prompt = loadPrompt('discover-architecture') + sharedContext; + const { text, usage } = await runClaude(prompt, makeClaudeOptions(timeoutMs, verbose, model, null)); + trackUsage(usage, prompt.length); + const match = text.match(/([\s\S]*?)<\/findings>/); + return match ? match[1].trim() : null; + } catch { return null; } + })(), + ]); + + // Merge findings + const findingsParts = []; + if (domainsResult) findingsParts.push(domainsResult); + if (archResult) findingsParts.push(archResult); + discoveryFindings = findingsParts.join('\n\n'); + + discoverSpinner.stop(pc.green('Discovery complete')); + + // Parse discovered domains + if (domainsResult) { + const domainMatch = domainsResult.match(/## Domains\n([\s\S]*?)(?=\n## |$)/); + if (domainMatch) { + const domainLines = domainMatch[1].trim().split('\n').filter(l => l.startsWith('- **')); + for (const line of domainLines) { + const nameMatch = line.match(/\*\*([^*]+)\*\*/); + const descMatch = line.match(/\*\*[^*]+\*\*:\s*(.+?)(?:\s*—|\s*$)/); + if (nameMatch) { + const name = nameMatch[1]; + const desc = descMatch ? descMatch[1].trim() : ''; + const filePaths = [...line.matchAll(/`([^`]+\.[a-z]+)`/g)].map(m => m[1]); + discoveredDomains.push({ + name, + description: desc, + directories: filePaths.length > 0 ? [filePaths[0].split('/').slice(0, -1).join('/')] : [], + files: filePaths, + }); + } + } + } + } + + // Show results + if (archResult) { + // Extract architecture type + const archMatch = archResult.match(/## Architecture\n([\s\S]*?)(?=\n## |$)/); + if (archMatch) { + const firstLine = archMatch[1].trim().split('\n')[0].replace(/\*\*/g, '').replace(/^Type:\s*/i, ''); + if (firstLine) console.log(pc.dim(' Architecture: ') + firstLine.slice(0, 80)); + } + } + + if (discoveredDomains.length > 0) { + console.log(pc.dim(` Discovered ${discoveredDomains.length} feature domains:`)); + for (const d of discoveredDomains) { + console.log(pc.dim(' ') + pc.green(d.name) + (d.description ? pc.dim(` — ${d.description.slice(0, 120)}`) : '')); + } + } + } catch { + discoverSpinner.stop(pc.dim('Discovery skipped — using scanner domains')); + } + console.log(); + } + + // Use discovered domains if available, otherwise fall back to scanner domains + const effectiveDomains = discoveredDomains.length > 0 ? discoveredDomains : scan.domains; + + // Step 3: Strategy for existing docs + let existingDocsStrategy = 'fresh'; if (options.strategy) { const strategyMap = { 'improve': 'improve', 'rewrite': 'rewrite', 'skip': 'skip-existing' }; existingDocsStrategy = strategyMap[options.strategy] || options.strategy; @@ -110,9 +230,9 @@ export async function docInitCommand(path, options) { } } - // Step 2: Choose generation mode + // Step 4: Choose generation mode let mode = 'all-at-once'; - let selectedDomains = scan.domains; + let selectedDomains = effectiveDomains; // --mode flag skips interactive prompt (for CI / scripted use) if (options.mode) { @@ -122,13 +242,13 @@ export async function docInitCommand(path, options) { p.log.error(`Unknown mode: ${options.mode}. Use: all, chunked, or base-only`); process.exit(1); } - } else if (scan.domains.length === 0) { + } else if (effectiveDomains.length === 0) { p.log.info('No domains detected — generating base skill only.'); mode = 'base-only'; } else { // Smart defaults based on repo size const isLarge = scan.size && (scan.size.category === 'large' || scan.size.category === 'very-large'); - const domainCount = scan.domains.length; + const domainCount = effectiveDomains.length; let defaultMode = 'all-at-once'; if (isLarge || domainCount > 6) defaultMode = 'chunked'; @@ -156,10 +276,10 @@ export async function docInitCommand(path, options) { if (mode === 'pick') { const picked = await p.multiselect({ message: 'Select domains:', - options: scan.domains.map(d => ({ + options: effectiveDomains.map(d => ({ value: d.name, label: d.name, - hint: d.directories.join(', ') || d.files?.slice(0, 2).join(', '), + hint: d.description || d.directories?.join(', ') || d.files?.slice(0, 2).join(', '), })), required: true, }); @@ -168,18 +288,18 @@ export async function docInitCommand(path, options) { p.cancel('Aborted'); process.exit(0); } - selectedDomains = scan.domains.filter(d => picked.includes(d.name)); + selectedDomains = effectiveDomains.filter(d => picked.includes(d.name)); mode = 'chunked'; } } - // Step 3: Generate + // Step 4: Generate (Layer 3) — write skills from findings let allFiles = []; if (mode === 'all-at-once') { - allFiles = await generateAllAtOnce(repoPath, scan, timeoutMs, existingDocsStrategy, verbose, model); + allFiles = await generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings); } else { - allFiles = await generateChunked(repoPath, scan, selectedDomains, mode === 'base-only', timeoutMs, existingDocsStrategy, verbose, model); + allFiles = await generateChunked(repoPath, scan, repoGraph, selectedDomains, mode === 'base-only', timeoutMs, existingDocsStrategy, verbose, model, discoveryFindings); } if (allFiles.length === 0) { @@ -205,7 +325,7 @@ export async function docInitCommand(path, options) { console.log(pc.bold(`\n--- ${file.path} ---`)); console.log(pc.dim(file.content)); } - showTokenSummary(); + showTokenSummary(startTime); p.outro('Dry run complete'); return; } @@ -241,7 +361,7 @@ export async function docInitCommand(path, options) { console.log(` ${icon} ${result.path}${status}`); } - showTokenSummary(); + showTokenSummary(startTime); console.log(); p.outro( @@ -251,8 +371,13 @@ export async function docInitCommand(path, options) { ); } -function showTokenSummary() { +function showTokenSummary(startTime) { if (tokenTracker.calls > 0) { + const elapsed = Math.round((Date.now() - startTime) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + const timeStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; + const parts = [ `${tokenTracker.calls} call(s)`, `~${tokenTracker.promptTokens.toLocaleString()} prompt`, @@ -262,6 +387,7 @@ function showTokenSummary() { if (tokenTracker.toolUses > 0) { parts.push(`${tokenTracker.toolUses} tool calls`); } + parts.push(timeStr); console.log(); console.log(pc.dim(` ${parts.join(' | ')}`)); } @@ -274,6 +400,82 @@ function buildScanSummary(scan) { return '## Scan Results\n```json\n' + JSON.stringify(cleanScan, null, 2) + '\n```'; } +function buildGraphContext(graph) { + if (!graph) return ''; + + const sections = ['## Import Graph Analysis\n']; + + // Hub files — most architecturally important + if (graph.hubs.length > 0) { + sections.push('### Hub Files (most depended on — read these first)\n'); + for (const hub of graph.hubs.slice(0, 10)) { + const fileInfo = graph.files[hub.path]; + sections.push(`- \`${hub.path}\` — ${hub.fanIn} dependents, ${fileInfo?.exportCount || 0} exports, ${fileInfo?.lines || 0} lines`); + } + sections.push(''); + } + + // Domain clusters with coupling + if (graph.clusters?.components?.length > 0) { + sections.push('### Domain Clusters (files that import each other)\n'); + for (const comp of graph.clusters.components) { + if (comp.size <= 1) continue; + const fileList = comp.files.slice(0, 10).map(f => `\`${f}\``).join(', '); + const more = comp.files.length > 10 ? ` +${comp.files.length - 10} more` : ''; + sections.push(`- **${comp.label}** (${comp.size} files): ${fileList}${more}`); + } + sections.push(''); + } + + // Coupling between domains + if (graph.clusters?.coupling?.length > 0) { + sections.push('### Cross-Domain Dependencies\n'); + for (const c of graph.clusters.coupling) { + sections.push(`- ${c.from} → ${c.to} (${c.edges} imports)`); + } + sections.push(''); + } + + // File ranking (top files Claude should prioritize reading) + if (graph.ranked.length > 0) { + sections.push('### File Priority Ranking (read in this order)\n'); + for (const file of graph.ranked.slice(0, 15)) { + sections.push(`- \`${file.path}\` — priority ${file.priority.toFixed(1)} (${file.fanIn} dependents, ${file.exportCount} exports, ${file.lines} lines)`); + } + sections.push(''); + } + + return sections.join('\n'); +} + +function buildDomainGraphContext(graph, domain) { + if (!graph) return ''; + + const sections = ['## Domain Import Context\n']; + + // Find files in this domain + const domainFiles = Object.entries(graph.files) + .filter(([path]) => domain.directories?.some(d => path.startsWith(d))) + .sort(([, a], [, b]) => (b.fanIn || 0) - (a.fanIn || 0)); + + if (domainFiles.length === 0) return ''; + + // Show each file's imports and dependents + for (const [path, info] of domainFiles) { + const deps = info.imports.length > 0 ? `imports: ${info.imports.map(i => `\`${i}\``).join(', ')}` : 'no imports'; + const depBy = info.importedBy.length > 0 ? `depended on by: ${info.importedBy.map(i => `\`${i}\``).join(', ')}` : ''; + sections.push(`- \`${path}\` (${info.lines} lines) — ${deps}${depBy ? '; ' + depBy : ''}`); + } + + // External deps used by this domain + const externalDeps = new Set(domainFiles.flatMap(([, info]) => info.externalImports)); + if (externalDeps.size > 0) { + sections.push(`\nExternal dependencies: ${[...externalDeps].join(', ')}`); + } + + return sections.join('\n'); +} + function buildStrategyInstruction(strategy) { if (strategy === 'improve') { return `\n\n**IMPORTANT — Improve mode:** This repo already has existing CLAUDE.md and/or skills in .claude/skills/. Read them first. Preserve ALL explicitly written instructions, conventions, gotchas, and team decisions in the existing CLAUDE.md — these were hand-written for a reason and must not be lost or summarized away. Update what's outdated, add what's missing, improve structure, but treat existing human-written content as authoritative.`; @@ -285,12 +487,14 @@ function buildStrategyInstruction(strategy) { return ''; } -async function generateAllAtOnce(repoPath, scan, timeoutMs, strategy, verbose, model) { +async function generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, timeoutMs, strategy, verbose, model, findings) { const today = new Date().toISOString().split('T')[0]; const systemPrompt = loadPrompt('doc-init'); const scanSummary = buildScanSummary(scan); + const graphContext = buildGraphContext(repoGraph); const strategyNote = buildStrategyInstruction(strategy); - const fullPrompt = `${systemPrompt}${strategyNote}\n\n---\n\nGenerate skills for this repository at ${repoPath}. Today's date is ${today}.\n\n${scanSummary}`; + const findingsSection = findings ? `\n\n## Architecture Analysis (from discovery pass)\n\n${findings}` : ''; + const fullPrompt = `${systemPrompt}${strategyNote}\n\n---\n\nGenerate skills for this repository at ${repoPath}. Today's date is ${today}.\n\n${scanSummary}\n\n${graphContext}${findingsSection}`; const claudeSpinner = p.spinner(); claudeSpinner.start('Exploring repo and generating skills...'); @@ -316,23 +520,25 @@ async function generateAllAtOnce(repoPath, scan, timeoutMs, strategy, verbose, m if (p.isCancel(retry) || !retry) { process.exit(1); } - return generateChunked(repoPath, scan, scan.domains, false, timeoutMs, strategy, verbose, model); + return generateChunked(repoPath, scan, repoGraph, selectedDomains, false, timeoutMs, strategy, verbose, model, findings); } } -async function generateChunked(repoPath, scan, domains, baseOnly, timeoutMs, strategy, verbose, model) { +async function generateChunked(repoPath, scan, repoGraph, domains, baseOnly, timeoutMs, strategy, verbose, model, findings) { const allFiles = []; const skippedDomains = []; const today = new Date().toISOString().split('T')[0]; const scanSummary = buildScanSummary(scan); + const graphContext = buildGraphContext(repoGraph); + const findingsSection = findings ? `\n\n## Architecture Analysis (from discovery pass)\n\n${findings}` : ''; // 1. Generate base skill const baseSpinner = p.spinner(); - baseSpinner.start('Exploring repo and generating base skill...'); + baseSpinner.start('Generating base skill...'); const strategyNote = buildStrategyInstruction(strategy); const basePrompt = loadPrompt('doc-init') + strategyNote + - `\n\n---\n\nGenerate ONLY the base skill for this repository at ${repoPath} (no domain skills, no CLAUDE.md). Today's date is ${today}.\n\n${scanSummary}`; + `\n\n---\n\nGenerate ONLY the base skill for this repository at ${repoPath} (no domain skills, no CLAUDE.md). Today's date is ${today}.\n\n${scanSummary}\n\n${graphContext}${findingsSection}`; let baseSkillContent = null; try { @@ -348,31 +554,64 @@ async function generateChunked(repoPath, scan, domains, baseOnly, timeoutMs, str return allFiles; } - // 2. Generate each domain skill + // 2. Generate domain skills (in parallel batches for speed) if (!baseOnly) { - for (const domain of domains) { - const domainSpinner = p.spinner(); - domainSpinner.start(`Exploring ${pc.bold(domain.name)} and generating skill...`); - - const domainInfo = `## Domain: ${domain.name}\nDirectories: ${domain.directories.join(', ')}\nFiles: ${(domain.files || []).join(', ')}`; - const domainPrompt = loadPrompt('doc-init-domain', { - domainName: domain.name, - }) + strategyNote + `\n\n---\n\nRepository path: ${repoPath}\nToday's date is ${today}.\n\n## Base skill (for context)\n\`\`\`\n${baseSkillContent || 'Not available'}\n\`\`\`\n\n${domainInfo}`; - - try { - const { text, usage } = await runClaude(domainPrompt, makeClaudeOptions(timeoutMs, verbose, model, domainSpinner)); - trackUsage(usage, domainPrompt.length); - const files = parseFileOutput(text); - if (files.length > 0) { - allFiles.push(...files); - domainSpinner.stop(pc.green(`${domain.name} skill generated`)); - } else { - domainSpinner.stop(pc.dim(`${domain.name} — skipped (not enough substance)`)); + const PARALLEL_LIMIT = 3; // run up to 3 domains concurrently + + for (let i = 0; i < domains.length; i += PARALLEL_LIMIT) { + const batch = domains.slice(i, i + PARALLEL_LIMIT); + const batchLabel = batch.map(d => d.name).join(', '); + + const batchSpinner = p.spinner(); + batchSpinner.start(`Generating skills: ${pc.bold(batchLabel)}...`); + + const promises = batch.map(async (domain) => { + const domainInfo = `## Domain: ${domain.name}\nDirectories: ${(domain.directories || []).join(', ')}\nFiles: ${(domain.files || []).join(', ')}`; + const domainGraph = buildDomainGraphContext(repoGraph, domain); + + // Extract domain-specific findings if available + let domainFindings = ''; + if (findings) { + const escapedName = domain.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const domainRegex = new RegExp(`-\\s*\\*\\*${escapedName}\\*\\*:([^\\n]*(?:\\n(?!-\\s*\\*\\*).*)*)`,'i'); + const domainMatch = findings.match(domainRegex); + if (domainMatch) { + domainFindings = `\n\n## Discovery Findings for ${domain.name}\n${domainMatch[0]}`; + } + } + + const domainPrompt = loadPrompt('doc-init-domain', { + domainName: domain.name, + }) + strategyNote + `\n\n---\n\nRepository path: ${repoPath}\nToday's date is ${today}.\n\n## Base skill (for context)\n\`\`\`\n${baseSkillContent || 'Not available'}\n\`\`\`\n\n${domainInfo}\n\n${domainGraph}${domainFindings}`; + + try { + const { text, usage } = await runClaude(domainPrompt, makeClaudeOptions(timeoutMs, verbose, model, null)); + trackUsage(usage, domainPrompt.length); + const files = parseFileOutput(text); + return { domain: domain.name, files, success: true }; + } catch { + return { domain: domain.name, files: [], success: false }; + } + }); + + const results = await Promise.all(promises); + + const succeeded = []; + for (const result of results) { + if (result.success && result.files.length > 0) { + allFiles.push(...result.files); + succeeded.push(result.domain); + } else if (!result.success) { + skippedDomains.push(result.domain); } - } catch (err) { - domainSpinner.stop(pc.yellow(`${domain.name} — timed out or failed`)); - skippedDomains.push(domain.name); } + + const statusParts = []; + if (succeeded.length > 0) statusParts.push(pc.green(succeeded.join(', '))); + if (results.some(r => !r.success)) statusParts.push(pc.yellow(results.filter(r => !r.success).map(r => r.domain).join(', ') + ' failed')); + if (results.some(r => r.success && r.files.length === 0)) statusParts.push(pc.dim(results.filter(r => r.success && r.files.length === 0).map(r => r.domain).join(', ') + ' skipped')); + + batchSpinner.stop(statusParts.join(pc.dim(' | '))); } } diff --git a/src/commands/scan.js b/src/commands/scan.js index 73e67cc..511a01d 100644 --- a/src/commands/scan.js +++ b/src/commands/scan.js @@ -1,10 +1,28 @@ import { resolve } from 'path'; import pc from 'picocolors'; import { scanRepo } from '../lib/scanner.js'; +import { buildRepoGraph } from '../lib/graph-builder.js'; -export function scanCommand(path, options) { +function parseDomains(domainsStr) { + if (!domainsStr) return undefined; + return domainsStr.split(',').map(d => d.trim().toLowerCase()).filter(Boolean); +} + +export async function scanCommand(path, options) { const repoPath = resolve(path); - const result = scanRepo(repoPath); + const extraDomains = parseDomains(options.domains); + const result = scanRepo(repoPath, { extraDomains }); + + // Build import graph + try { + const graph = await buildRepoGraph(repoPath, result.languages); + result.graph = formatGraphForDisplay(graph); + } catch (err) { + // Graph building failed — continue without it + if (options.verbose) { + console.error(pc.dim(` Graph building failed: ${err.message}`)); + } + } if (options.json) { console.log(JSON.stringify(result, null, 2)); @@ -49,15 +67,90 @@ export function scanCommand(path, options) { console.log(); } - if (result.domains.length > 0) { + if (result.graph) { + // Import Graph section + console.log(pc.bold(' Import Graph') + pc.dim(` (${pc.cyan(result.graph.fileCount)} files, ${pc.cyan(result.graph.edgeCount)} edges)`)); + if (result.graph.hubFiles && result.graph.hubFiles.length > 0) { + console.log(pc.dim(' Hub files:')); + const hubs = result.graph.hubFiles.slice(0, 5); + // Find longest path for alignment + const maxPath = Math.max(...hubs.map(h => h.path.length)); + for (const hub of hubs) { + const padded = hub.path.padEnd(maxPath); + const exportLabel = hub.exports === 1 ? 'export' : 'exports'; + const depLabel = hub.fanIn === 1 ? 'dependent' : 'dependents'; + console.log(pc.dim(' ') + pc.green(padded) + pc.dim(` \u2190 ${pc.cyan(String(hub.fanIn))} ${depLabel}, ${pc.cyan(String(hub.exports))} ${exportLabel}`)); + } + } + console.log(); + + // Domains (by imports) section + if (result.graph.domains && result.graph.domains.length > 0) { + // Build reverse dependency map from coupling data + const dependedOnBy = {}; + if (result.graph.coupling) { + for (const c of result.graph.coupling) { + if (!dependedOnBy[c.to]) dependedOnBy[c.to] = []; + dependedOnBy[c.to].push(c.from); + } + } + + console.log(pc.bold(' Domains') + pc.dim(' (by imports)')); + for (const domain of result.graph.domains) { + const dirSuffix = domain.directory ? `${domain.directory}/` : ''; + console.log(pc.dim(' ') + pc.green(domain.name) + pc.dim(` (${dirSuffix})`) + pc.dim(` \u2014 ${pc.cyan(String(domain.sourceFileCount))} files`)); + + // Module list + if (domain.modules && domain.modules.length > 0) { + const mods = domain.modules.slice(0, 8); + const extra = domain.modules.length > 8 ? `, +${domain.modules.length - 8} more` : ''; + console.log(pc.dim(' ') + mods.join(', ') + pc.dim(extra)); + } + + // Depends on + if (domain.importsFrom && domain.importsFrom.length > 0) { + console.log(pc.dim(' \u2192 depends on: ') + domain.importsFrom.join(', ')); + } + + // Depended on by (reverse lookup) + if (dependedOnBy[domain.name] && dependedOnBy[domain.name].length > 0) { + console.log(pc.dim(' \u2190 depended on by: ') + dependedOnBy[domain.name].join(', ')); + } + } + console.log(); + } + + // Coupling section + if (result.graph.coupling && result.graph.coupling.length > 0) { + console.log(pc.bold(' Coupling')); + const sorted = [...result.graph.coupling].sort((a, b) => b.count - a.count); + // Find longest "from → to" string for alignment + const labels = sorted.map(c => `${c.from} \u2192 ${c.to}`); + const maxLabel = Math.max(...labels.map(l => l.length)); + for (let i = 0; i < sorted.length; i++) { + const padded = labels[i].padEnd(maxLabel); + console.log(pc.dim(' ') + padded + ' ' + pc.dim(`${pc.cyan(String(sorted[i].count))} imports`)); + } + console.log(); + } + + // Hotspots section + if (result.graph.hotspots && result.graph.hotspots.length > 0) { + console.log(pc.bold(' Hotspots') + pc.dim(' (high churn, last 6 months)')); + for (const h of result.graph.hotspots) { + console.log(pc.dim(' ') + pc.yellow(h.path) + pc.dim(` — ${pc.cyan(String(h.churn))} changes, ${h.lines} lines`)); + } + console.log(); + } + } else if (result.domains.length > 0) { + // Fallback: show detected domains without graph data console.log(pc.bold(' Detected domains')); for (const domain of result.domains) { - const sources = [...domain.directories]; - if (domain.files && domain.files.length > 0) { - sources.push(...domain.files.slice(0, 3)); - if (domain.files.length > 3) sources.push(`+${domain.files.length - 3} files`); - } - console.log(pc.dim(' ') + pc.green(domain.name) + (sources.length > 0 ? pc.dim(` (${sources.join(', ')})`) : '')); + const dir = domain.directories[0] || ''; + const mods = (domain.modules || []).slice(0, 6); + const extra = (domain.modules || []).length > 6 ? ` +${domain.modules.length - 6} more` : ''; + const modStr = mods.length > 0 ? pc.dim(` \u2014 ${mods.join(', ')}${extra}`) : ''; + console.log(pc.dim(' ') + pc.green(domain.name) + pc.dim(` (${dir})`) + modStr); } console.log(); } @@ -65,7 +158,21 @@ export function scanCommand(path, options) { // Size if (result.size) { console.log(pc.bold(' Repo size')); - console.log(pc.dim(' ') + `${result.size.sourceFiles} source files (~${result.size.estimatedLines.toLocaleString()} lines) — ${result.size.category}`); + console.log(pc.dim(' ') + `${result.size.sourceFiles} source files (~${result.size.estimatedLines.toLocaleString()} lines) \u2014 ${result.size.category}`); + console.log(); + } + + // Health + if (result.health && result.health.issues.length > 0) { + console.log(pc.bold(' Health')); + for (const issue of result.health.issues) { + const icon = issue.level === 'warn' ? pc.yellow('⚠') : pc.blue('ℹ'); + console.log(` ${icon} ${pc.yellow(issue.message)}`); + if (issue.detail) { + console.log(pc.dim(` ${issue.detail}`)); + } + console.log(pc.dim(` Fix: ${issue.fix}`)); + } console.log(); } @@ -76,3 +183,99 @@ export function scanCommand(path, options) { console.log(pc.dim(' ') + `.claude/ ${claude} CLAUDE.md ${claudeMd}`); console.log(); } + +/** + * Transform raw graph builder output into the shape the display expects. + */ +function formatGraphForDisplay(graph) { + const { files, hubs, clusters, hotspots, stats } = graph; + + // Format hub files + const hubFiles = hubs.map(h => ({ + path: h.path, + fanIn: h.fanIn, + fanOut: h.fanOut, + exports: Array.isArray(h.exports) ? h.exports.length : (h.exports || 0), + lines: files[h.path]?.lines || 0, + })); + + // Format domains from clusters — merge small components by parent directory + const SKIP_STEMS = new Set(['__init__', 'index', 'mod']); + const rawComponents = clusters.components || []; + + // Group files by their parent directory (e.g. src/backtest, tests/) + const dirGroups = {}; + for (const comp of rawComponents) { + for (const filePath of comp.files) { + const parts = filePath.split('/'); + // For files directly in a top-level dir (tests/foo.py), use that dir + // For deeper files (src/backtest/engine.py), use first two segments + let dir; + if (parts.length <= 2) { + dir = parts[0]; // e.g. "tests" for "tests/test_foo.py" + } else { + dir = parts.slice(0, 2).join('/'); // e.g. "src/backtest" + } + if (!dirGroups[dir]) dirGroups[dir] = { files: [], label: dir }; + dirGroups[dir].files.push(filePath); + } + } + + const domains = Object.values(dirGroups) + .filter(group => { + // Skip groups that are just __init__.py files with no real modules + const realFiles = group.files.filter(f => { + const stem = f.split('/').pop().replace(/\.[^.]+$/, ''); + return !SKIP_STEMS.has(stem); + }); + return realFiles.length > 0; + }) + .map(group => { + const modules = group.files.map(f => { + const stem = f.split('/').pop().replace(/\.[^.]+$/, ''); + return SKIP_STEMS.has(stem) ? null : stem; + }).filter(Boolean); + + const name = group.label.split('/').pop() || group.label; + + // Find imports from other directories + const importsFrom = new Set(); + for (const filePath of group.files) { + const fileInfo = files[filePath]; + if (!fileInfo) continue; + for (const imp of (fileInfo.imports || [])) { + const impParts = imp.split('/'); + const impDir = impParts.length > 2 ? impParts.slice(0, 2).join('/') : impParts[0]; + if (impDir !== group.label) { + importsFrom.add(impDir.split('/').pop() || impDir); + } + } + } + + return { + name, + directory: group.label, + files: group.files, + modules, + sourceFileCount: group.files.length, + importsFrom: [...importsFrom], + }; + }) + .sort((a, b) => b.sourceFileCount - a.sourceFileCount); + + // Format coupling + const coupling = (clusters.coupling || []).map(c => ({ + from: c.from, + to: c.to, + count: c.edges, + })); + + return { + fileCount: stats.totalFiles, + edgeCount: stats.totalEdges, + hubFiles, + domains, + coupling, + hotspots: (hotspots || []).slice(0, 5), + }; +} diff --git a/src/lib/graph-builder.js b/src/lib/graph-builder.js new file mode 100644 index 0000000..0ab6f1e --- /dev/null +++ b/src/lib/graph-builder.js @@ -0,0 +1,642 @@ +import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; +import { join, basename, extname, relative, dirname, resolve } from 'path'; +import { execSync } from 'child_process'; +import { init, parse } from 'es-module-lexer'; +import { detectEntryPoints } from './scanner.js'; + +/** + * Build the import graph for a repository. + * Returns adjacency list, edges, file metrics, hub files, and domain clusters. + */ +export async function buildRepoGraph(repoPath, languages = []) { + await init; + + const entryPoints = detectEntryPoints(repoPath); + const entryPointSet = new Set(entryPoints); + + // Load path aliases from tsconfig.json (e.g. "@/*": ["./src/*"]) + const pathAliases = loadPathAliases(repoPath); + + // Detect Python package roots (directories containing pyproject.toml, setup.py, or __init__.py at top level) + const pythonRoots = detectPythonRoots(repoPath); + + // 1. Walk all source files + const filePaths = walkSourceFiles(repoPath); + + // 2. Parse each file for imports and exports + const files = {}; + const edges = []; + + for (const absPath of filePaths) { + const relPath = relative(repoPath, absPath); + const content = readFileSafe(absPath); + if (content === null) continue; + + const lines = content.split('\n').length; + const ext = extname(absPath); + + let rawImports = []; + let exportNames = []; + + if (JS_EXTS.has(ext)) { + const parsed = parseJsImports(content, relPath); + rawImports = parsed.imports; + exportNames = parsed.exports; + } else if (ext === '.py') { + rawImports = parsePyImports(content); + } + + // Resolve imports + const internalImports = []; + const externalImports = []; + + for (const specifier of rawImports) { + if (isRelativeImport(specifier) && JS_EXTS.has(ext)) { + const resolved = resolveRelativeImport(repoPath, relPath, specifier); + if (resolved) { + internalImports.push(resolved); + } else { + externalImports.push(specifier); + } + } else if (JS_EXTS.has(ext) && pathAliases.length > 0) { + // Try resolving path aliases (@/components/..., ~/utils, etc.) + const resolved = resolveAliasImport(repoPath, specifier, pathAliases); + if (resolved) { + internalImports.push(resolved); + } else { + externalImports.push(specifier); + } + } else if (ext === '.py' && isPythonRelativeImport(specifier)) { + const resolved = resolvePythonRelativeImport(repoPath, relPath, specifier); + if (resolved) { + internalImports.push(resolved); + } else { + externalImports.push(specifier); + } + } else if (ext === '.py') { + // Try resolving absolute Python imports from multiple roots + const resolved = resolvePythonAbsoluteImport(repoPath, specifier, pythonRoots); + if (resolved) { + internalImports.push(resolved); + } else { + externalImports.push(specifier); + } + } else { + externalImports.push(specifier); + } + } + + files[relPath] = { + imports: internalImports, + importedBy: [], // populated in second pass + exports: exportNames, + externalImports, + lines, + }; + } + + // 3. Second pass — populate importedBy and build edges + for (const [filePath, info] of Object.entries(files)) { + for (const imported of info.imports) { + if (files[imported]) { + files[imported].importedBy.push(filePath); + edges.push({ from: filePath, to: imported }); + } + } + } + + // 4. Git churn analysis (files changed most recently = more important) + const gitChurn = analyzeGitChurn(repoPath); + + // 5. Compute file metrics + for (const [filePath, info] of Object.entries(files)) { + const depth = filePath.split('/').length - 1; + const isEntry = entryPointSet.has(filePath); + const churn = gitChurn[filePath] || 0; + + info.fanIn = info.importedBy.length; + info.fanOut = info.imports.length; + info.exportCount = info.exports.length; + info.churn = churn; + info.priority = ( + info.fanIn * 3.0 + + info.exportCount * 1.5 + + (isEntry ? 10.0 : 0) + + churn * 2.0 + + (1 / (depth + 1)) * 1.0 + ); + } + + // 6. Rank files by importance (descending priority) + const ranked = Object.entries(files) + .map(([path, info]) => ({ path, ...info })) + .sort((a, b) => b.priority - a.priority); + + // 7. Find hub files — top files by fanIn + const hubs = Object.entries(files) + .filter(([, info]) => info.fanIn > 0) + .sort(([, a], [, b]) => b.fanIn - a.fanIn) + .slice(0, 20) + .map(([path, info]) => ({ + path, + fanIn: info.fanIn, + fanOut: info.fanOut, + exports: info.exports, + })); + + // 8. Compute domain clustering via connected components + const clusters = computeDomainClusters(files, edges); + + // 9. Identify hotspots (high churn + high lines) + const hotspots = Object.entries(files) + .filter(([, info]) => info.churn > 3 && info.lines > 50) + .sort(([, a], [, b]) => (b.churn * b.lines) - (a.churn * a.lines)) + .slice(0, 10) + .map(([path, info]) => ({ path, churn: info.churn, lines: info.lines })); + + return { + files, + edges, + ranked, + hubs, + clusters, + hotspots, + entryPoints, + stats: { + totalFiles: Object.keys(files).length, + totalEdges: edges.length, + totalExternalImports: new Set( + Object.values(files).flatMap(f => f.externalImports) + ).size, + }, + }; +} + +// --- File walking --- + +const SOURCE_EXTS = new Set(['.py', '.ts', '.js', '.tsx', '.jsx', '.rb', '.go', '.rs']); +const JS_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs']); + +const SKIP_DIRS = new Set([ + 'node_modules', '__pycache__', 'dist', 'build', '.git', + '.next', 'vendor', 'coverage', 'third_party', +]); + +function walkSourceFiles(repoPath, maxDepth = 12) { + const results = []; + + function walk(dir, depth) { + if (depth > maxDepth) return; + let entries; + try { entries = readdirSync(dir); } catch { return; } + + for (const entry of entries) { + if (entry.startsWith('.') || SKIP_DIRS.has(entry)) continue; + + const full = join(dir, entry); + try { + const stat = statSync(full); + if (stat.isDirectory()) { + walk(full, depth + 1); + } else if (SOURCE_EXTS.has(extname(entry))) { + results.push(full); + } + } catch { /* skip unreadable files */ } + } + } + + walk(repoPath, 0); + return results; +} + +// --- JS/TS import parsing --- + +function parseJsImports(content, relPath) { + const result = { imports: [], exports: [] }; + + try { + const [imports, exports] = parse(content); + + for (const imp of imports) { + // imp.n is the import specifier (null for dynamic imports without a string literal) + if (imp.n) { + result.imports.push(imp.n); + } + } + + for (const exp of exports) { + if (exp.n) { + result.exports.push(exp.n); + } + } + } catch { + // File couldn't be parsed — skip silently + } + + return result; +} + +// --- Python import parsing --- + +const PY_FROM_IMPORT_RE = /^from\s+(\.+[\w.]*|[\w.]+)\s+import\s+/gm; +const PY_IMPORT_RE = /^import\s+([\w.]+)/gm; + +function parsePyImports(content) { + const imports = []; + + // Strip triple-quoted strings to avoid matching imports in docstrings + const stripped = content.replace(/('{3}|"{3})[\s\S]*?\1/g, ''); + + let match; + + // Reset lastIndex for global regexes + PY_FROM_IMPORT_RE.lastIndex = 0; + while ((match = PY_FROM_IMPORT_RE.exec(stripped)) !== null) { + imports.push(match[1]); + } + + PY_IMPORT_RE.lastIndex = 0; + while ((match = PY_IMPORT_RE.exec(stripped)) !== null) { + imports.push(match[1]); + } + + return imports; +} + +// --- Path alias loading --- + +/** + * Load path aliases from tsconfig.json (e.g. "@/*": ["./src/*"]). + * Returns array of { prefix, replacement } for resolution. + */ +function loadPathAliases(repoPath) { + const aliases = []; + + // Try tsconfig.json, then jsconfig.json + for (const configName of ['tsconfig.json', 'jsconfig.json']) { + const configPath = join(repoPath, configName); + if (!existsSync(configPath)) continue; + + try { + const content = readFileSafe(configPath); + if (!content) continue; + + // Strip comments for JSON parsing — careful not to mangle strings + // Only strip // comments that aren't inside quotes + const stripped = content + .replace(/("(?:[^"\\]|\\.)*")|\/\/.*$/gm, (m, str) => str || '') + .replace(/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, (m, str) => str || '') + .replace(/,\s*([\]}])/g, '$1'); // trailing commas + + const config = JSON.parse(stripped); + const paths = config?.compilerOptions?.paths; + const baseUrl = config?.compilerOptions?.baseUrl || '.'; + + if (paths) { + for (const [pattern, targets] of Object.entries(paths)) { + if (!targets || targets.length === 0) continue; + // "@/*" → prefix "@/", "*" is the wildcard + const prefix = pattern.replace(/\*$/, ''); + const target = targets[0].replace(/\*$/, ''); + const replacement = join(repoPath, baseUrl, target); + aliases.push({ prefix, replacement }); + } + } + } catch { /* malformed config — skip */ } + + break; // only use first found config + } + + return aliases; +} + +/** + * Resolve an aliased import (e.g. @/components/Button) to a repo-relative path. + */ +function resolveAliasImport(repoPath, specifier, aliases) { + for (const { prefix, replacement } of aliases) { + if (!specifier.startsWith(prefix)) continue; + + const rest = specifier.slice(prefix.length); + const targetBase = join(replacement, rest); + + // Try with extension + if (extname(rest)) { + if (existsSync(targetBase)) { + return relative(repoPath, targetBase); + } + continue; + } + + // Try extensions + for (const ext of ['.ts', '.tsx', '.js', '.jsx', '.mjs']) { + const candidate = targetBase + ext; + if (existsSync(candidate)) { + return relative(repoPath, candidate); + } + } + + // Try /index variants + for (const ext of ['.ts', '.tsx', '.js', '.jsx']) { + const candidate = join(targetBase, 'index' + ext); + if (existsSync(candidate)) { + return relative(repoPath, candidate); + } + } + } + + return null; +} + +// --- Python package root detection --- + +/** + * Detect Python source roots — directories from which absolute imports resolve. + * Checks: repo root, plus common subdirs (backend/, src/, app/, etc.) that contain + * pyproject.toml, setup.py, or __init__.py. + */ +function detectPythonRoots(repoPath) { + const roots = [repoPath]; // always try repo root + + const candidates = ['backend', 'src', 'app', 'server', 'api', 'lib']; + for (const dir of candidates) { + const full = join(repoPath, dir); + if (!existsSync(full)) continue; + // Check if it looks like a Python package root + if (existsSync(join(full, 'pyproject.toml')) || + existsSync(join(full, 'setup.py')) || + existsSync(join(full, '__init__.py')) || + existsSync(join(full, 'app')) || // common pattern: backend/app/ + existsSync(join(full, 'main.py'))) { + roots.push(full); + } + } + + return roots; +} + +// --- Import resolution --- + +function isRelativeImport(specifier) { + return specifier.startsWith('./') || specifier.startsWith('../'); +} + +function isPythonRelativeImport(specifier) { + return specifier.startsWith('.'); +} + +/** + * Resolve a JS/TS relative import to a repo-relative file path. + * Tries extensions and /index variants. + */ +function resolveRelativeImport(repoPath, fromFile, specifier) { + const fromDir = dirname(join(repoPath, fromFile)); + const targetBase = resolve(fromDir, specifier); + const targetRel = relative(repoPath, targetBase); + + // If the specifier already has an extension, check directly + if (extname(specifier)) { + if (existsSync(targetBase)) { + return targetRel; + } + return null; + } + + // Try extensions + const extensions = ['.js', '.ts', '.tsx', '.jsx', '.mjs']; + for (const ext of extensions) { + const candidate = targetBase + ext; + if (existsSync(candidate)) { + return relative(repoPath, candidate); + } + } + + // Try /index variants (directory import) + const indexExts = ['.js', '.ts', '.tsx', '.jsx']; + for (const ext of indexExts) { + const candidate = join(targetBase, 'index' + ext); + if (existsSync(candidate)) { + return relative(repoPath, candidate); + } + } + + return null; +} + +/** + * Resolve a Python relative import to a repo-relative file path. + * E.g. `.utils` in `app/services/main.py` -> `app/services/utils.py` + */ +function resolvePythonRelativeImport(repoPath, fromFile, specifier) { + const fromDir = dirname(join(repoPath, fromFile)); + + // Count leading dots + let dots = 0; + while (dots < specifier.length && specifier[dots] === '.') dots++; + + const modulePart = specifier.slice(dots); + + // Each dot means go up one directory (first dot = current, additional = parent) + let baseDir = fromDir; + for (let i = 1; i < dots; i++) { + baseDir = dirname(baseDir); + } + + if (!modulePart) { + // Just dots — importing the package itself (__init__.py) + const initPath = join(baseDir, '__init__.py'); + if (existsSync(initPath)) { + return relative(repoPath, initPath); + } + return null; + } + + // Convert dotted module path to filesystem path + const parts = modulePart.split('.'); + const modulePath = join(baseDir, ...parts); + + // Try as a .py file + const pyFile = modulePath + '.py'; + if (existsSync(pyFile)) { + return relative(repoPath, pyFile); + } + + // Try as a package (__init__.py) + const initFile = join(modulePath, '__init__.py'); + if (existsSync(initFile)) { + return relative(repoPath, initFile); + } + + return null; +} + +/** + * Resolve an absolute Python import to a repo-relative file path. + * Tries from multiple roots: repo root, then detected Python source roots. + * E.g. `app.services.db` in a repo with `backend/app/services/db.py` + * → tries /app/services/db.py (miss) + * → tries /backend/app/services/db.py (hit) + */ +function resolvePythonAbsoluteImport(repoPath, specifier, pythonRoots = [repoPath]) { + const parts = specifier.split('.'); + + for (const root of pythonRoots) { + const modulePath = join(root, ...parts); + + // Try as a .py file + const pyFile = modulePath + '.py'; + if (existsSync(pyFile)) { + return relative(repoPath, pyFile); + } + + // Try as a package (__init__.py) + const initFile = join(modulePath, '__init__.py'); + if (existsSync(initFile)) { + return relative(repoPath, initFile); + } + } + + return null; +} + +// --- Git churn analysis --- + +/** + * Analyze git history to find file change frequency (last 6 months). + * Returns { "src/lib/scanner.js": 14, ... } + */ +function analyzeGitChurn(repoPath) { + const churn = {}; + + try { + const output = execSync( + 'git log --format=format: --name-only --since="6 months ago"', + { cwd: repoPath, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] } + ); + + for (const line of output.split('\n')) { + const trimmed = line.trim(); + if (trimmed) { + churn[trimmed] = (churn[trimmed] || 0) + 1; + } + } + } catch { + // Not a git repo or git not available — return empty + } + + return churn; +} + +// --- Domain clustering (connected components) --- + +function computeDomainClusters(files, edges) { + const filePaths = Object.keys(files); + if (filePaths.length === 0) return { components: [], coupling: [] }; + + // Build undirected adjacency list + const adj = {}; + for (const filePath of filePaths) { + adj[filePath] = new Set(); + } + + for (const edge of edges) { + if (adj[edge.from] && adj[edge.to]) { + adj[edge.from].add(edge.to); + adj[edge.to].add(edge.from); + } + } + + // BFS to find connected components + const visited = new Set(); + const components = []; + + for (const filePath of filePaths) { + if (visited.has(filePath)) continue; + + const component = []; + const queue = [filePath]; + visited.add(filePath); + + while (queue.length > 0) { + const current = queue.shift(); + component.push(current); + + for (const neighbor of adj[current]) { + if (!visited.has(neighbor)) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + + components.push(component); + } + + // Label each component by its primary directory and compute coupling + const clusters = components.map(component => { + // Count directories to find the primary one + const dirCounts = {}; + for (const filePath of component) { + const topDir = filePath.split('/')[0] || filePath; + dirCounts[topDir] = (dirCounts[topDir] || 0) + 1; + } + + const primaryDir = Object.entries(dirCounts) + .sort(([, a], [, b]) => b - a)[0][0]; + + return { + label: primaryDir, + files: component, + size: component.length, + }; + }); + + // Compute coupling between components (cross-component edges) + const fileToCluster = {}; + for (let i = 0; i < clusters.length; i++) { + for (const filePath of clusters[i].files) { + fileToCluster[filePath] = i; + } + } + + const couplingMap = {}; + for (const edge of edges) { + const fromCluster = fileToCluster[edge.from]; + const toCluster = fileToCluster[edge.to]; + if (fromCluster !== undefined && toCluster !== undefined && fromCluster !== toCluster) { + const key = [Math.min(fromCluster, toCluster), Math.max(fromCluster, toCluster)].join('-'); + couplingMap[key] = (couplingMap[key] || 0) + 1; + } + } + + const coupling = Object.entries(couplingMap).map(([key, count]) => { + const [a, b] = key.split('-').map(Number); + return { + from: clusters[a].label, + to: clusters[b].label, + edges: count, + }; + }).sort((a, b) => b.edges - a.edges); + + return { + components: clusters.sort((a, b) => b.size - a.size), + coupling, + }; +} + +// --- Helpers --- + +function readFileSafe(filePath) { + try { + return readFileSync(filePath, 'utf8'); + } catch { + return null; + } +} + +export { + parseJsImports, + parsePyImports, + resolveRelativeImport, + computeDomainClusters, +}; diff --git a/src/lib/scanner.js b/src/lib/scanner.js index e311849..ab3505d 100644 --- a/src/lib/scanner.js +++ b/src/lib/scanner.js @@ -5,7 +5,7 @@ import { join, basename, extname, relative } from 'path'; * Scan a repository and return its tech stack, structure, and domains. * Fully deterministic — no LLM calls. */ -export function scanRepo(repoPath) { +export function scanRepo(repoPath, { extraDomains } = {}) { const result = { path: repoPath, name: basename(repoPath), @@ -18,12 +18,54 @@ export function scanRepo(repoPath) { hasClaudeMd: existsSync(join(repoPath, 'CLAUDE.md')), }; + // Merge user-specified domains + if (extraDomains && extraDomains.length > 0) { + mergeExtraDomains(result, repoPath, extraDomains); + } + result.repoType = inferRepoType(result); result.size = estimateRepoSize(repoPath); + result.health = checkHealth(repoPath, result); return result; } +function mergeExtraDomains(result, repoPath, extraDomains) { + const sourceRoot = findSourceRoot(repoPath); + const existingNames = new Set(result.domains.map(d => d.name)); + + for (const name of extraDomains) { + if (existingNames.has(name)) continue; + + const candidates = [ + sourceRoot ? join(sourceRoot, name) : null, + join(repoPath, name), + ].filter(Boolean); + + const matchedDir = candidates.find(d => existsSync(d) && isDir(d)); + + if (matchedDir) { + const modules = collectModules(matchedDir, 3); + result.domains.push({ + name, + directories: [relative(repoPath, matchedDir)], + modules, + files: [], + userSpecified: true, + sourceFileCount: modules.length || undefined, + }); + } else { + result.domains.push({ + name, + directories: [], + modules: [], + files: [], + userSpecified: true, + }); + } + } +} + // --- Repo Size Estimation --- function estimateRepoSize(repoPath) { @@ -271,58 +313,51 @@ function detectStructure(repoPath) { // --- Domain Detection --- -// Directories that are scaffolding/infrastructure, not product domains -const SCAFFOLD_DIR_NAMES = new Set(['templates', 'template', 'scaffolds', 'scaffold', 'fixtures', 'mocks', 'stubs', 'examples', 'example', 'samples', 'sample', 'boilerplate', 'skeleton']); +// Always skip — purely structural, build output, dependencies, IDE +const SKIP_DIR_NAMES = new Set([ + 'src', 'app', 'bin', 'cmd', 'pkg', 'internal', 'vendor', + 'dist', 'build', 'out', 'output', 'target', 'coverage', + 'node_modules', '__pycache__', '.next', '.nuxt', '.cache', + '.github', '.vscode', '.idea', '.git', + 'assets', 'static', 'public', 'images', 'icons', 'fonts', + 'styles', 'css', +]); function detectDomains(repoPath) { const domains = []; const sourceRoot = findSourceRoot(repoPath); - if (!sourceRoot) return domains; - // Collect all directory names up to 3 levels deep in source - const allDirs = collectDirs(sourceRoot, 3); + // Scan directories under source root AND at repo root + const scanRoots = new Set(); + if (sourceRoot) scanRoots.add(sourceRoot); + scanRoots.add(repoPath); - // Filter out scaffold/template directories and their children - const productDirs = allDirs.filter(d => { - const parts = relative(repoPath, d).split(/[/\\]/); - return !parts.some(p => SCAFFOLD_DIR_NAMES.has(p.toLowerCase())); - }); - const dirNames = productDirs.map(d => basename(d).toLowerCase()); - - // Also collect file names (without extension) in key directories for flat-structure repos - // e.g. backend/app/services/billing_service.py → "billing" - const fileHints = collectFileHints(sourceRoot, repoPath, 3); - - // Common domain patterns - const domainPatterns = { - auth: ['auth', 'authentication', 'login', 'signup', 'session', 'oauth'], - billing: ['billing', 'payment', 'payments', 'stripe', 'subscription', 'pricing'], - users: ['users', 'user', 'profile', 'profiles', 'account', 'accounts'], - admin: ['admin', 'dashboard', 'backoffice'], - notifications: ['notifications', 'notification', 'alerts', 'emails', 'email'], - search: ['search', 'discovery', 'explore'], - messaging: ['messaging', 'messages', 'chat', 'conversations'], - media: ['media', 'upload', 'uploads', 'files', 'storage', 'assets'], - analytics: ['analytics', 'metrics', 'tracking', 'reporting', 'reports'], - settings: ['settings', 'preferences', 'configuration'], - onboarding: ['onboarding', 'setup', 'wizard', 'welcome'], - api: ['api', 'endpoints', 'routes', 'graphql'], - }; + const seen = new Set(); // avoid duplicates when sourceRoot === repoPath + + for (const root of scanRoots) { + const entries = listDir(root); + for (const entry of entries) { + const name = entry.toLowerCase(); + if (name.startsWith('.')) continue; + if (SKIP_DIR_NAMES.has(name)) continue; + + const full = join(root, entry); + const relDir = relative(repoPath, full); + if (seen.has(relDir)) continue; + seen.add(relDir); - for (const [domain, patterns] of Object.entries(domainPatterns)) { - // Match on directory names - const dirMatches = patterns.filter(p => dirNames.includes(p)); - const matchingDirs = productDirs.filter(d => patterns.includes(basename(d).toLowerCase())); + if (!isDir(full)) continue; - // Match on file name hints (e.g. billing_service.py → "billing") - const fileMatches = fileHints.filter(h => patterns.some(p => h.hint.includes(p))); + // Collect modules (source file stems) inside this directory + const modules = collectModules(full, 3); + if (modules.length === 0) continue; - if (dirMatches.length > 0 || fileMatches.length > 0) { domains.push({ - name: domain, - matchedOn: [...dirMatches, ...fileMatches.map(f => f.hint)].filter((v, i, a) => a.indexOf(v) === i), - directories: matchingDirs.map(d => relative(repoPath, d)), - files: fileMatches.map(f => f.path), + name: name, + directories: [relDir], + modules: modules, + files: [], + sourceFileCount: modules.length, }); } } @@ -330,9 +365,42 @@ function detectDomains(repoPath) { return domains; } +/** + * Collect source file names (without extension) from a directory tree. + * Skips __init__.py, index.js/ts and similar boilerplate entry files. + */ +const BOILERPLATE_STEMS = new Set(['__init__', 'index', 'mod']); + +function collectModules(dirPath, maxDepth, depth = 0) { + if (depth >= maxDepth) return []; + const results = []; + const entries = listDir(dirPath); + + for (const entry of entries) { + if (entry.startsWith('.') || entry === 'node_modules' || entry === '__pycache__') continue; + const full = join(dirPath, entry); + const ext = extname(entry); + + if (SOURCE_EXTS.has(ext)) { + const stem = basename(entry, ext); + if (!BOILERPLATE_STEMS.has(stem)) { + results.push(stem); + } + } else if (isDir(full)) { + // For subdirectories, add the dir name as a module if it has source files + const subModules = collectModules(full, maxDepth, depth + 1); + if (subModules.length > 0) { + results.push(basename(full) + '/'); + } + } + } + + return results; +} + // --- Entry Points --- -function detectEntryPoints(repoPath) { +export function detectEntryPoints(repoPath) { const entries = []; const candidates = [ // JS/TS @@ -379,6 +447,88 @@ function inferRepoType(scanResult) { return 'unknown'; } +// --- Health Checks --- + +function checkHealth(repoPath, scanResult) { + const issues = []; + + const gitignorePath = join(repoPath, '.gitignore'); + const hasGitignore = existsSync(gitignorePath); + + if (!hasGitignore) { + issues.push({ + level: 'warn', + id: 'no-gitignore', + message: 'No .gitignore found', + detail: 'Installing dependencies (npm install, pip install, etc.) will pollute your git history with thousands of files.', + fix: 'Add a .gitignore file — most frameworks provide one via their init/create commands.', + }); + } else { + const content = readFileContent(gitignorePath) || ''; + const lines = content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); + + // Check language-specific ignores + const langs = scanResult.languages || []; + const hasJS = langs.includes('javascript') || langs.includes('typescript'); + const hasPython = langs.includes('python'); + const hasRust = langs.includes('rust'); + + if (hasJS && !lines.some(l => l === 'node_modules' || l === 'node_modules/' || l === '/node_modules' || l === '/node_modules/')) { + issues.push({ + level: 'warn', + id: 'missing-node-modules-ignore', + message: 'node_modules/ is not in .gitignore', + detail: 'Running npm install will add hundreds of files to git.', + fix: 'Add node_modules/ to your .gitignore', + }); + } + + if (hasPython && !lines.some(l => l === '__pycache__' || l === '__pycache__/' || l.includes('__pycache__') || l === '*.pyc')) { + issues.push({ + level: 'warn', + id: 'missing-pycache-ignore', + message: '__pycache__/ is not in .gitignore', + fix: 'Add __pycache__/ and *.pyc to your .gitignore', + }); + } + + if (hasPython && !lines.some(l => l === '.venv' || l === '.venv/' || l === 'venv' || l === 'venv/' || l === 'env' || l === 'env/')) { + issues.push({ + level: 'info', + id: 'missing-venv-ignore', + message: 'Virtual environment directory not in .gitignore', + fix: 'Add .venv/ to your .gitignore', + }); + } + + if (hasRust && !lines.some(l => l === 'target' || l === 'target/' || l === '/target')) { + issues.push({ + level: 'warn', + id: 'missing-target-ignore', + message: 'target/ is not in .gitignore', + fix: 'Add target/ to your .gitignore', + }); + } + } + + // Check for .env files that might be committed + if (existsSync(join(repoPath, '.env'))) { + const gitignoreContent = hasGitignore ? (readFileContent(gitignorePath) || '') : ''; + const lines = gitignoreContent.split('\n').map(l => l.trim()); + if (!lines.some(l => l === '.env' || l === '.env*' || l === '.env.*' || l === '*.env')) { + issues.push({ + level: 'warn', + id: 'env-not-ignored', + message: '.env file exists but is not in .gitignore', + detail: 'This may leak secrets (API keys, passwords) into your git history.', + fix: 'Add .env to your .gitignore', + }); + } + } + + return { issues }; +} + // --- Helpers --- function readJson(filePath) { @@ -479,54 +629,4 @@ function findSourceRoot(repoPath) { return repoPath; } -const GENERIC_FILE_NAMES = new Set(['index', 'main', 'app', 'init', 'test', 'spec', 'utils', 'helpers', 'types', 'models', 'config', 'deps', 'base', 'core', 'common', 'constants', 'middleware', 'router', 'routes', 'service', 'controller']); const SOURCE_EXTS = new Set(['.py', '.ts', '.js', '.tsx', '.jsx', '.rb', '.go', '.rs']); - -function collectFileHints(rootPath, repoPath, maxDepth, currentDepth = 0) { - if (currentDepth >= maxDepth) return []; - - const results = []; - const entries = listDir(rootPath); - - for (const entry of entries) { - if (entry.startsWith('.') || entry === 'node_modules' || entry === '__pycache__' || entry === '.git') continue; - - const full = join(rootPath, entry); - if (isDir(full)) { - results.push(...collectFileHints(full, repoPath, maxDepth, currentDepth + 1)); - } else { - const ext = extname(entry); - if (SOURCE_EXTS.has(ext)) { - const stem = basename(entry, ext).toLowerCase(); - const parts = stem.split(/[_\-.]/).filter(p => p.length > 2); - for (const part of parts) { - if (!GENERIC_FILE_NAMES.has(part)) { - results.push({ hint: part, path: relative(repoPath, full) }); - } - } - } - } - } - - return results; -} - -function collectDirs(rootPath, maxDepth, currentDepth = 0) { - if (currentDepth >= maxDepth) return []; - - const results = []; - const entries = listDir(rootPath); - - for (const entry of entries) { - if (entry.startsWith('.') || entry === 'node_modules' || entry === '__pycache__' || entry === '.git' || entry === 'dist' || entry === 'build' || entry === '.next') { - continue; - } - const full = join(rootPath, entry); - if (isDir(full)) { - results.push(full); - results.push(...collectDirs(full, maxDepth, currentDepth + 1)); - } - } - - return results; -} diff --git a/src/prompts/discover-architecture.md b/src/prompts/discover-architecture.md new file mode 100644 index 0000000..b412f84 --- /dev/null +++ b/src/prompts/discover-architecture.md @@ -0,0 +1,48 @@ +You are analyzing a codebase's architecture and patterns. You have an import graph with hub files and tools to explore. + +**Your ONLY job: understand the architecture, patterns, and critical rules.** + +## What to find + +1. **Architecture** — Read the top 3-5 hub files (most-imported). What's the overall pattern? (MVC? layered? event-driven?) +2. **Core abstractions** — What are the 3-5 most important modules/classes/types? (from hub files) +3. **Patterns** — Error handling, config loading, state management, data fetching, testing +4. **Critical rules** — What breaks if you don't know it? Read hub files for implicit contracts. +5. **Commands** — Find dev/build/test commands in package.json, Makefile, pyproject.toml + +## How to explore + +- **Start with hub files** — they're the most important, read them first +- **Grep** for patterns: error handling (`catch`, `throw`, `Error`), config (`process.env`, `config`) +- **Read** package.json scripts, Makefile targets + +Focus on understanding, not coverage. 5 files read deeply > 20 files skimmed. + +## Output Format + +Output in `` tags with EXACTLY this structure: + + +## Architecture +[type, entry points, request/data flow — be specific with file paths] + +## Core Abstractions +[3-5 most important modules with file paths and what they do] + +## Patterns +- **Error handling:** [specific pattern] +- **Config:** [how config is loaded] +- **State:** [state management approach] +- **Data fetching:** [pattern] +- **Testing:** [framework and patterns] + +## Critical Rules +1. [specific rule with file path] +2. [specific rule with file path] + +## Anti-Patterns +[things NOT to do and why] + +## Commands +[dev, build, test, lint — exact commands] + diff --git a/src/prompts/discover-domains.md b/src/prompts/discover-domains.md new file mode 100644 index 0000000..4278fe9 --- /dev/null +++ b/src/prompts/discover-domains.md @@ -0,0 +1,29 @@ +You are discovering the feature domains in a codebase. You have an import graph and tools to explore. + +**Your ONLY job: find the real feature domains.** Not directory names — actual product features. + +## How to discover domains + +1. **Look inside large directories** — `components/`, `features/`, `modules/`, `pages/`, `app/`, `services/` + - Use Glob to list subdirectories: `src/components/*/` + - Each subdirectory with 3+ files is likely a separate domain + +2. **Check the hub files** from the graph — they reveal what the app actually does + +3. **Look for feature-specific patterns:** + - Hooks: `useAuth`, `useBilling`, `useCourses` → auth, billing, courses + - Routes/pages: `app/billing/page.tsx`, `pages/courses/` → billing, courses + - Services: `services/payment.ts`, `api/users.ts` → payment, users + - Models: `models/Order.ts`, `types/Course.ts` → orders, courses + +## Output Format + +Output in `` tags with EXACTLY this structure: + + +## Domains +- **domain-name**: one-sentence description — `key/file/1.ts`, `key/file/2.ts` (N files total) +- **domain-name**: one-sentence description — `key/file/1.ts`, `key/file/2.ts` (N files total) + + +List EVERY feature domain you find. Be specific about what each does. diff --git a/tests/graph-builder.test.js b/tests/graph-builder.test.js new file mode 100644 index 0000000..be5c0dc --- /dev/null +++ b/tests/graph-builder.test.js @@ -0,0 +1,567 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { + parseJsImports, + parsePyImports, + resolveRelativeImport, + buildRepoGraph, + computeDomainClusters, +} from '../src/lib/graph-builder.js'; + +const FIXTURES_DIR = join(import.meta.dirname, 'fixtures', 'graph-builder'); + +function createFixture(name, files) { + const dir = join(FIXTURES_DIR, name); + mkdirSync(dir, { recursive: true }); + for (const [path, content] of Object.entries(files)) { + const full = join(dir, path); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, content); + } + return dir; +} + +beforeAll(() => { + mkdirSync(FIXTURES_DIR, { recursive: true }); +}); + +afterAll(() => { + try { + if (existsSync(FIXTURES_DIR)) { + rmSync(FIXTURES_DIR, { recursive: true, force: true }); + } + } catch { /* ignore cleanup race with other test files */ } +}); + +// --------------------------------------------------------------------------- +// 1. JS/TS import parsing +// parseJsImports(content, relPath) → { imports: string[], exports: string[] } +// imports are raw specifiers; exports are export names +// --------------------------------------------------------------------------- +describe('parseJsImports', () => { + it('extracts named ES module imports', () => { + const result = parseJsImports(`import { foo, bar } from './bar.js';`, 'src/app.js'); + expect(result.imports).toContain('./bar.js'); + }); + + it('extracts default imports', () => { + const result = parseJsImports(`import foo from './bar';`, 'src/app.js'); + expect(result.imports).toContain('./bar'); + }); + + it('extracts side-effect imports', () => { + const result = parseJsImports(`import './styles.css';`, 'src/app.js'); + expect(result.imports).toContain('./styles.css'); + }); + + it('extracts re-exports', () => { + const result = parseJsImports(`export { foo } from './bar';`, 'src/app.js'); + expect(result.imports).toContain('./bar'); + }); + + it('extracts dynamic imports', () => { + const result = parseJsImports(`const mod = await import('./bar');`, 'src/app.js'); + expect(result.imports).toContain('./bar'); + }); + + it('classifies bare node/npm imports as external', () => { + const result = parseJsImports(`import fs from 'fs';`, 'src/app.js'); + expect(result.imports).toContain('fs'); + }); + + it('classifies scoped packages as external', () => { + const result = parseJsImports(`import { x } from '@clack/prompts';`, 'src/app.js'); + expect(result.imports).toContain('@clack/prompts'); + }); + + it('classifies node: protocol imports as external', () => { + const result = parseJsImports(`import { readFile } from 'node:fs/promises';`, 'src/app.js'); + expect(result.imports).toContain('node:fs/promises'); + }); + + it('handles multiple imports in one file', () => { + const code = [ + `import { join } from 'path';`, + `import { readFile } from 'fs';`, + `import { helper } from './utils.js';`, + `import config from '../config.js';`, + ].join('\n'); + const result = parseJsImports(code, 'src/app.js'); + expect(result.imports).toContain('path'); + expect(result.imports).toContain('fs'); + expect(result.imports).toContain('./utils.js'); + expect(result.imports).toContain('../config.js'); + // 2 external (path, fs) + 2 local (./utils.js, ../config.js) = 4 total + expect(result.imports).toHaveLength(4); + }); + + it('ignores commented-out imports', () => { + const code = [ + `// import { old } from './old.js';`, + `import { active } from './active.js';`, + ].join('\n'); + const result = parseJsImports(code, 'src/app.js'); + expect(result.imports).not.toContain('./old.js'); + expect(result.imports).toContain('./active.js'); + }); + + it('handles require() calls as imports', () => { + const code = `const foo = require('./bar');`; + const result = parseJsImports(code, 'src/app.js'); + // es-module-lexer may or may not pick up require() — it focuses on ESM. + // If it does, great; if not, this is expected behavior. + // The implementation relies on es-module-lexer which only parses ESM syntax. + expect(result).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Python import parsing +// parsePyImports(content) → string[] (raw specifiers) +// --------------------------------------------------------------------------- +describe('parsePyImports', () => { + it('parses bare import', () => { + const result = parsePyImports(`import os`); + expect(result).toContain('os'); + }); + + it('parses from-import', () => { + const result = parsePyImports(`from pathlib import Path`); + expect(result).toContain('pathlib'); + }); + + it('parses relative dot import', () => { + const result = parsePyImports(`from .utils import helper`); + expect(result).toContain('.utils'); + }); + + it('parses double-dot relative import', () => { + const result = parsePyImports(`from ..models import User`); + expect(result).toContain('..models'); + }); + + it('parses dotted internal module path', () => { + const result = parsePyImports(`from src.lib.scanner import scanRepo`); + expect(result).toContain('src.lib.scanner'); + }); + + it('handles multiple imports', () => { + const code = [ + `import os`, + `import sys`, + `from .utils import helper`, + `from pathlib import Path`, + ].join('\n'); + const result = parsePyImports(code); + expect(result).toHaveLength(4); + expect(result).toContain('os'); + expect(result).toContain('sys'); + expect(result).toContain('.utils'); + expect(result).toContain('pathlib'); + }); + + it('ignores commented-out imports', () => { + const code = [ + `# import os`, + `from pathlib import Path`, + ].join('\n'); + const result = parsePyImports(code); + expect(result).not.toContain('os'); + expect(result).toContain('pathlib'); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Import resolution +// resolveRelativeImport(repoPath, fromFile, specifier) → string|null +// --------------------------------------------------------------------------- +describe('resolveRelativeImport', () => { + it('resolves relative import in same directory', () => { + const dir = createFixture('resolve-same-dir', { + 'src/lib/runner.js': '', + 'src/lib/scanner.js': '', + }); + const resolved = resolveRelativeImport(dir, 'src/lib/runner.js', './scanner.js'); + expect(resolved).toBe('src/lib/scanner.js'); + }); + + it('resolves parent-directory import', () => { + const dir = createFixture('resolve-parent', { + 'src/commands/scan.js': '', + 'src/lib/scanner.js': '', + }); + const resolved = resolveRelativeImport(dir, 'src/commands/scan.js', '../lib/scanner.js'); + expect(resolved).toBe('src/lib/scanner.js'); + }); + + it('resolves import without extension by trying .js', () => { + const dir = createFixture('resolve-no-ext-js', { + 'src/lib/runner.js': '', + 'src/lib/scanner.js': '', + }); + const resolved = resolveRelativeImport(dir, 'src/lib/runner.js', './scanner'); + expect(resolved).toBe('src/lib/scanner.js'); + }); + + it('resolves import without extension by trying .ts', () => { + const dir = createFixture('resolve-no-ext-ts', { + 'src/lib/runner.ts': '', + 'src/lib/scanner.ts': '', + }); + const resolved = resolveRelativeImport(dir, 'src/lib/runner.ts', './scanner'); + expect(resolved).toBe('src/lib/scanner.ts'); + }); + + it('resolves import without extension by trying .tsx', () => { + const dir = createFixture('resolve-no-ext-tsx', { + 'src/components/App.tsx': '', + 'src/components/Button.tsx': '', + }); + const resolved = resolveRelativeImport(dir, 'src/components/App.tsx', './Button'); + expect(resolved).toBe('src/components/Button.tsx'); + }); + + it('resolves import without extension by trying .jsx', () => { + const dir = createFixture('resolve-no-ext-jsx', { + 'src/components/App.jsx': '', + 'src/components/Button.jsx': '', + }); + const resolved = resolveRelativeImport(dir, 'src/components/App.jsx', './Button'); + expect(resolved).toBe('src/components/Button.jsx'); + }); + + it('resolves import without extension by trying .mjs', () => { + const dir = createFixture('resolve-no-ext-mjs', { + 'src/lib/runner.mjs': '', + 'src/lib/scanner.mjs': '', + }); + const resolved = resolveRelativeImport(dir, 'src/lib/runner.mjs', './scanner'); + expect(resolved).toBe('src/lib/scanner.mjs'); + }); + + it('resolves directory import to index.js', () => { + const dir = createFixture('resolve-dir-index-js', { + 'src/app.js': '', + 'src/utils/index.js': '', + }); + const resolved = resolveRelativeImport(dir, 'src/app.js', './utils'); + expect(resolved).toBe('src/utils/index.js'); + }); + + it('resolves directory import to index.ts', () => { + const dir = createFixture('resolve-dir-index-ts', { + 'src/app.ts': '', + 'src/utils/index.ts': '', + }); + const resolved = resolveRelativeImport(dir, 'src/app.ts', './utils'); + expect(resolved).toBe('src/utils/index.ts'); + }); + + it('returns null for unresolvable imports', () => { + const dir = createFixture('resolve-missing', { + 'src/app.js': '', + }); + const resolved = resolveRelativeImport(dir, 'src/app.js', './nonexistent'); + expect(resolved).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Graph building +// buildRepoGraph(repoPath, languages?) → async { files, edges, ranked, hubs, ... } +// files[relPath] = { imports, importedBy, exports, externalImports, lines, fanIn, fanOut, ... } +// --------------------------------------------------------------------------- +describe('buildRepoGraph', () => { + it('builds correct imports and importedBy arrays', async () => { + const dir = createFixture('graph-basic', { + 'src/index.js': [ + `import { run } from './lib/runner.js';`, + `import { scan } from './lib/scanner.js';`, + ].join('\n'), + 'src/lib/runner.js': [ + `import { scan } from './scanner.js';`, + ].join('\n'), + 'src/lib/scanner.js': `export function scan() {}`, + }); + + const graph = await buildRepoGraph(dir); + + // index.js imports runner and scanner + const indexNode = graph.files['src/index.js']; + expect(indexNode.imports).toContain('src/lib/runner.js'); + expect(indexNode.imports).toContain('src/lib/scanner.js'); + + // runner.js imports scanner + const runnerNode = graph.files['src/lib/runner.js']; + expect(runnerNode.imports).toContain('src/lib/scanner.js'); + + // scanner is imported by both index and runner + const scannerNode = graph.files['src/lib/scanner.js']; + expect(scannerNode.importedBy).toContain('src/index.js'); + expect(scannerNode.importedBy).toContain('src/lib/runner.js'); + }); + + it('builds correct edges array', async () => { + const dir = createFixture('graph-edges', { + 'src/a.js': `import { b } from './b.js';`, + 'src/b.js': `import { c } from './c.js';`, + 'src/c.js': `export const c = 1;`, + }); + + const graph = await buildRepoGraph(dir); + + expect(graph.edges).toContainEqual({ from: 'src/a.js', to: 'src/b.js' }); + expect(graph.edges).toContainEqual({ from: 'src/b.js', to: 'src/c.js' }); + expect(graph.edges).toHaveLength(2); + }); + + it('computes correct fanIn and fanOut metrics', async () => { + const dir = createFixture('graph-metrics', { + 'src/index.js': [ + `import { a } from './a.js';`, + `import { b } from './b.js';`, + `import { c } from './c.js';`, + ].join('\n'), + 'src/a.js': `import { c } from './c.js';`, + 'src/b.js': `import { c } from './c.js';`, + 'src/c.js': `export const c = 1;`, + }); + + const graph = await buildRepoGraph(dir); + + // index.js: fanOut=3, fanIn=0 + expect(graph.files['src/index.js'].fanOut).toBe(3); + expect(graph.files['src/index.js'].fanIn).toBe(0); + + // c.js: fanOut=0, fanIn=3 (imported by index, a, b) + expect(graph.files['src/c.js'].fanOut).toBe(0); + expect(graph.files['src/c.js'].fanIn).toBe(3); + + // a.js: fanOut=1, fanIn=1 + expect(graph.files['src/a.js'].fanOut).toBe(1); + expect(graph.files['src/a.js'].fanIn).toBe(1); + }); + + it('sorts hubs by fanIn descending', async () => { + const dir = createFixture('graph-hubs', { + 'src/index.js': [ + `import { a } from './a.js';`, + `import { b } from './b.js';`, + `import { shared } from './shared.js';`, + ].join('\n'), + 'src/a.js': `import { shared } from './shared.js';`, + 'src/b.js': [ + `import { shared } from './shared.js';`, + `import { a } from './a.js';`, + ].join('\n'), + 'src/shared.js': `export const shared = 1;`, + }); + + const graph = await buildRepoGraph(dir); + + // shared.js has fanIn=3, a.js has fanIn=2, b.js has fanIn=1, index has fanIn=0 + expect(graph.hubs[0].path).toBe('src/shared.js'); + expect(graph.hubs[1].path).toBe('src/a.js'); + }); + + it('excludes external imports from the graph edges', async () => { + const dir = createFixture('graph-externals', { + 'src/app.js': [ + `import path from 'path';`, + `import { helper } from './helper.js';`, + ].join('\n'), + 'src/helper.js': `import fs from 'fs';`, + }); + + const graph = await buildRepoGraph(dir); + + // Only one internal edge: app -> helper + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toEqual({ from: 'src/app.js', to: 'src/helper.js' }); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Domain clustering (connected components) +// computeDomainClusters(files, edges) → { components: [{label, files, size}], coupling: [...] } +// --------------------------------------------------------------------------- +describe('computeDomainClusters', () => { + it('finds two separate clusters with no cross-imports', async () => { + const dir = createFixture('cluster-two', { + // Cluster 1: auth domain + 'src/auth/login.js': `import { validate } from './validate.js';`, + 'src/auth/validate.js': `export function validate() {}`, + // Cluster 2: billing domain + 'src/billing/invoice.js': `import { calc } from './calc.js';`, + 'src/billing/calc.js': `export function calc() {}`, + }); + + const graph = await buildRepoGraph(dir); + const result = computeDomainClusters(graph.files, graph.edges); + + expect(result.components).toHaveLength(2); + const allFiles = result.components.map(c => c.files.sort()); + expect(allFiles).toContainEqual( + ['src/auth/login.js', 'src/auth/validate.js'].sort(), + ); + expect(allFiles).toContainEqual( + ['src/billing/calc.js', 'src/billing/invoice.js'].sort(), + ); + }); + + it('finds one cluster when all files are connected', async () => { + const dir = createFixture('cluster-one', { + 'src/a.js': `import { b } from './b.js';`, + 'src/b.js': `import { c } from './c.js';`, + 'src/c.js': `export const c = 1;`, + }); + + const graph = await buildRepoGraph(dir); + const result = computeDomainClusters(graph.files, graph.edges); + + expect(result.components).toHaveLength(1); + expect(result.components[0].files.sort()).toEqual( + ['src/a.js', 'src/b.js', 'src/c.js'].sort(), + ); + }); + + it('treats files with only external imports as isolated nodes', async () => { + const dir = createFixture('cluster-isolated', { + 'src/a.js': `import fs from 'fs';`, + 'src/b.js': `import path from 'path';`, + }); + + const graph = await buildRepoGraph(dir); + const result = computeDomainClusters(graph.files, graph.edges); + + // Each file is its own isolated cluster + expect(result.components).toHaveLength(2); + const allFiles = result.components.map(c => c.files); + expect(allFiles).toContainEqual(['src/a.js']); + expect(allFiles).toContainEqual(['src/b.js']); + }); + + it('merges clusters connected by a bridge file', async () => { + const dir = createFixture('cluster-bridge', { + 'src/auth/login.js': `import { validate } from './validate.js';`, + 'src/auth/validate.js': `export function validate() {}`, + 'src/billing/invoice.js': `import { calc } from './calc.js';`, + 'src/billing/calc.js': `export function calc() {}`, + // Bridge file connects both clusters + 'src/index.js': [ + `import { validate } from './auth/validate.js';`, + `import { calc } from './billing/calc.js';`, + ].join('\n'), + }); + + const graph = await buildRepoGraph(dir); + const result = computeDomainClusters(graph.files, graph.edges); + + // Everything is connected through index.js + expect(result.components).toHaveLength(1); + expect(result.components[0].files).toHaveLength(5); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Edge cases +// --------------------------------------------------------------------------- +describe('edge cases', () => { + it('handles empty file with no imports or exports', async () => { + const dir = createFixture('edge-empty', { + 'src/empty.js': '', + }); + + const graph = await buildRepoGraph(dir); + const node = graph.files['src/empty.js']; + expect(node).toBeDefined(); + expect(node.imports).toHaveLength(0); + expect(node.importedBy).toHaveLength(0); + expect(node.fanIn).toBe(0); + expect(node.fanOut).toBe(0); + }); + + it('handles file with only external imports (no internal edges)', async () => { + const dir = createFixture('edge-external-only', { + 'src/app.js': [ + `import fs from 'fs';`, + `import path from 'path';`, + `import { x } from '@clack/prompts';`, + ].join('\n'), + }); + + const graph = await buildRepoGraph(dir); + expect(graph.edges).toHaveLength(0); + expect(graph.files['src/app.js'].imports).toHaveLength(0); + expect(graph.files['src/app.js'].fanOut).toBe(0); + }); + + it('handles circular imports (A imports B, B imports A)', async () => { + const dir = createFixture('edge-circular', { + 'src/a.js': `import { b } from './b.js';`, + 'src/b.js': `import { a } from './a.js';`, + }); + + const graph = await buildRepoGraph(dir); + + // A imports B + expect(graph.files['src/a.js'].imports).toContain('src/b.js'); + // B imports A + expect(graph.files['src/b.js'].imports).toContain('src/a.js'); + // Both appear in each other's importedBy + expect(graph.files['src/a.js'].importedBy).toContain('src/b.js'); + expect(graph.files['src/b.js'].importedBy).toContain('src/a.js'); + // Both edges present + expect(graph.edges).toContainEqual({ from: 'src/a.js', to: 'src/b.js' }); + expect(graph.edges).toContainEqual({ from: 'src/b.js', to: 'src/a.js' }); + expect(graph.edges).toHaveLength(2); + }); + + it('handles file with syntax errors gracefully (no crash)', async () => { + const dir = createFixture('edge-syntax-error', { + 'src/good.js': `import { broken } from './broken.js';`, + 'src/broken.js': `{{{ this is not valid javascript >>>`, + }); + + // Should not throw + const graph = await buildRepoGraph(dir); + expect(graph).toBeDefined(); + expect(graph.files).toBeDefined(); + // The good file should still be in the graph + expect(graph.files['src/good.js']).toBeDefined(); + }); + + it('handles deeply nested imports', async () => { + const dir = createFixture('edge-deep-nesting', { + 'src/a/b/c/d/deep.js': `import { root } from '../../../../root.js';`, + 'src/root.js': `export const root = 1;`, + }); + + const graph = await buildRepoGraph(dir); + expect(graph.files['src/a/b/c/d/deep.js'].imports).toContain('src/root.js'); + expect(graph.files['src/root.js'].importedBy).toContain('src/a/b/c/d/deep.js'); + }); + + it('handles mixed JS and TS files', async () => { + const dir = createFixture('edge-mixed-lang', { + 'src/app.ts': `import { helper } from './helper';`, + 'src/helper.js': `export function helper() {}`, + }); + + const graph = await buildRepoGraph(dir); + expect(graph.files['src/app.ts'].imports).toContain('src/helper.js'); + }); + + it('handles TypeScript type-only imports', () => { + const code = `import type { User } from './types';`; + const result = parseJsImports(code, 'src/app.ts'); + expect(result.imports).toContain('./types'); + }); + + it('handles star re-exports', () => { + const code = `export * from './utils';`; + const result = parseJsImports(code, 'src/index.js'); + expect(result.imports).toContain('./utils'); + }); +}); diff --git a/tests/scanner.test.js b/tests/scanner.test.js index 0573528..3b8fcee 100644 --- a/tests/scanner.test.js +++ b/tests/scanner.test.js @@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { scanRepo } from '../src/lib/scanner.js'; -const FIXTURES_DIR = join(import.meta.dirname, 'fixtures'); +const FIXTURES_DIR = join(import.meta.dirname, 'fixtures', 'scanner'); function createFixture(name, files) { const dir = join(FIXTURES_DIR, name); @@ -21,9 +21,11 @@ beforeAll(() => { }); afterAll(() => { - if (existsSync(FIXTURES_DIR)) { - rmSync(FIXTURES_DIR, { recursive: true }); - } + try { + if (existsSync(FIXTURES_DIR)) { + rmSync(FIXTURES_DIR, { recursive: true, force: true }); + } + } catch { /* ignore cleanup race with other test files */ } }); describe('scanRepo', () => { @@ -179,27 +181,27 @@ describe('scanRepo', () => { expect(scan.domains.some(d => d.name === 'auth')).toBe(true); }); - it('detects billing domain from file names', () => { + it('detects billing domain from directory', () => { const dir = createFixture('billing-project', { 'requirements.txt': 'fastapi\n', - 'app/services/billing_service.py': 'class BillingService: pass', - 'app/services/stripe_service.py': 'class StripeService: pass', + 'app/billing/invoice_service.py': 'class InvoiceService: pass', + 'app/billing/stripe_client.py': 'class StripeClient: pass', }); const scan = scanRepo(dir); expect(scan.domains.some(d => d.name === 'billing')).toBe(true); }); - it('filters out scaffold directories', () => { + it('filters out skipped directories', () => { const dir = createFixture('scaffold-project', { 'package.json': '{}', - 'src/templates/settings/config.json': '{}', - 'src/components/auth/Login.tsx': '', + 'src/assets/settings/theme.js': 'export default {}', + 'src/auth/Login.tsx': '', }); const scan = scanRepo(dir); - // settings from templates/ should NOT be a domain + // assets is in SKIP_DIR_NAMES so settings under it should NOT appear as a domain const settingsDomain = scan.domains.find(d => d.name === 'settings'); expect(settingsDomain).toBeUndefined(); - // auth should still be detected + // auth should still be detected (direct subdirectory of src with source files) expect(scan.domains.some(d => d.name === 'auth')).toBe(true); }); From e9561be8058f72d8bd87966b7f3160655d9d11b8 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Fri, 20 Mar 2026 17:27:07 -0700 Subject: [PATCH 3/5] v0.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18f9a38..e0158c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aspens", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aspens", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@clack/prompts": "^0.8.0", diff --git a/package.json b/package.json index c09b522..eb1e2e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspens", - "version": "0.1.0", + "version": "0.2.0", "description": "Generate and maintain AI-ready documentation for any codebase", "type": "module", "bin": { From aa4fd2fb48eabe491021c89b689da6888a181759 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Fri, 20 Mar 2026 17:29:37 -0700 Subject: [PATCH 4/5] es-module-lexer WASM init issue --- tests/graph-builder.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/graph-builder.test.js b/tests/graph-builder.test.js index be5c0dc..a60c375 100644 --- a/tests/graph-builder.test.js +++ b/tests/graph-builder.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; +import { init } from 'es-module-lexer'; import { parseJsImports, parsePyImports, @@ -22,7 +23,8 @@ function createFixture(name, files) { return dir; } -beforeAll(() => { +beforeAll(async () => { + await init; // es-module-lexer WASM must be initialized before parseJsImports works mkdirSync(FIXTURES_DIR, { recursive: true }); }); From 35628dd63b599d84dc82982029ae2d4bae8f525d Mon Sep 17 00:00:00 2001 From: mvoutov Date: Fri, 20 Mar 2026 17:43:01 -0700 Subject: [PATCH 5/5] graph builder parseJsImports --- CHANGELOG.md | 38 ++++++++++++ README.md | 89 +++++++++++++++++++-------- bin/cli.js | 6 +- src/lib/graph-builder.js | 116 +++++++++++++++++++++++++----------- tests/graph-builder.test.js | 54 ++++++++--------- 5 files changed, 214 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b8097..d3c7e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [Unreleased] + +### Added +- **Import graph** — parses JS/TS imports (via `es-module-lexer`) and Python imports (regex), resolves `@/` path aliases from tsconfig.json, builds dependency map +- **Hub file detection** — identifies most-imported files with fan-in ranking and priority scores +- **Domain clustering** — groups files by import relationships (connected components), not just directory names +- **Inter-domain coupling** — shows how many imports cross domain boundaries +- **Git churn analysis** — file change frequency over 6 months, hotspot detection (high churn + high complexity) +- **Parallel discovery** — 2 Claude agents explore simultaneously (domain discovery + architecture analysis) before skill generation +- **Discovered domains** — Claude finds real feature domains (auth, billing, courses) instead of just directory names (components, lib, hooks) +- **Parallel skill generation** — generates up to 3 domain skills concurrently +- **Discovery-first flow** — discovery runs before user chooses domains, so the picker shows meaningful feature names +- **Health checks** — warns about missing .gitignore, unignored node_modules, exposed .env files +- **`--domains` flag** — for both `scan` and `doc init`, lets users specify additional domains +- **`--verbose` flag** for `scan` — shows diagnostic output when graph building fails +- **Elapsed time** in token summary — shows total wall-clock time for doc init +- **Hotspots display** in scan output — shows files with high recent churn +- **Python package root detection** — resolves absolute imports from multiple source roots (backend/, src/, etc.) +- **Dynamic version** — reads from package.json instead of hardcoding + +### Changed +- Domain detection now uses import graph clustering instead of hardcoded SaaS keyword patterns +- Scan output shows import graph (hub files, domains by imports, coupling matrix, hotspots) instead of just directory listings +- Doc init runs discovery pass before asking user to choose domains +- Skill generation uses discovery findings for richer, more specific skills + +### Fixed +- Edges no longer include unresolved imports (phantom edges to non-existent files) +- Trailing space in directory module names +- Overly broad `.gitignore` venv pattern matching (`env/` no longer matches `development/`) +- Test fixture cleanup race condition between test suites +- Python relative imports with `./` prefix no longer use JS extension resolution + +### Removed +- Hardcoded SaaS domain patterns (auth, billing, users, etc.) — replaced by import graph clustering +- Dead code: `collectFileHints()`, `collectDirs()`, `GENERIC_FILE_NAMES` +- Unused `parallel-runner.js` and `discover.md` + ## [0.1.0] - 2026-03-18 ### Added diff --git a/README.md b/README.md index b99f652..9dac39c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # aspens -Generate and maintain AI-ready documentation for any codebase. Aspens scans your repo, uses Claude to produce structured skill files, and keeps them updated on every commit. +Generate and maintain AI-ready documentation for any codebase. Aspens scans your repo, builds an import graph, uses parallel Claude agents to discover your architecture, and produces structured skill files that stay updated on every commit. Skills are concise markdown files (~35 lines) that Claude Code loads automatically when you work in specific parts of your codebase. They give Claude the context it needs to write correct code — key files, patterns, conventions, critical rules. @@ -20,7 +20,7 @@ Requires [Node.js 18+](https://nodejs.org) and [Claude Code](https://docs.anthro ### `aspens scan [path]` -Detect tech stack, frameworks, structure, and domains. No LLM calls — pure file system inspection. +Detect tech stack, frameworks, structure, and domains. Builds an import graph to identify hub files, domain coupling, and hotspots. No LLM calls — pure file system + git inspection. ``` $ aspens scan . @@ -41,9 +41,24 @@ $ aspens scan . services → src/services/ database → prisma/ - Detected domains - auth (src/services/auth, src/components/auth) - billing (src/services/billing) + Import Graph (247 files, 892 edges) + Hub files: + src/lib/db.ts ← 31 dependents, 2 exports + src/auth/middleware.ts ← 18 dependents, 3 exports + src/lib/api-client.ts ← 15 dependents, 4 exports + + Domains (by imports) + components (src/components/) — 89 files + → depends on: lib, hooks, types + lib (src/lib/) — 12 files + ← depended on by: components, services, hooks + + Coupling + components → lib 45 imports + hooks → lib 23 imports + + Hotspots (high churn, last 6 months) + src/auth/session.ts — 19 changes, 210 lines Claude Code .claude/ no CLAUDE.md no @@ -52,40 +67,60 @@ $ aspens scan . | Option | Description | |--------|-------------| | `--json` | Output as JSON | +| `--domains ` | Additional domains to include (comma-separated) | +| `--verbose` | Show diagnostic output | ### `aspens doc init [path]` -Generate skills and CLAUDE.md by scanning your repo and having Claude explore the code. +Generate skills and CLAUDE.md. Runs parallel discovery agents to understand your architecture, then generates skills based on what it found. -Claude gets read-only access to your repo (Read, Glob, Grep) and explores it to understand patterns, conventions, and critical rules. It then generates skill files that Claude Code discovers automatically. +The flow: +1. **Scan + Import Graph** — builds dependency map, finds hub files +2. **Parallel Discovery** — 2 Claude agents explore simultaneously (domains + architecture) +3. **User picks domains** — from the discovered feature domains +4. **Parallel Generation** — generates 3 domain skills at a time ``` $ aspens doc init . - ◆ aspens doc init - ◇ Scanned my-app (fullstack) Languages: typescript, javascript Frameworks: nextjs, react, tailwind, prisma - Domains: auth, billing, notifications - - ◆ 3 domains detected. Generate skills: - ● All at once - ○ One domain at a time + Source modules: components, lib, hooks, services, types + Import graph: 247 files, 892 edges + Size: 247 source files (medium) + Timeout: 300s per call + + Running 2 discovery agents in parallel... + + ◇ Discovery complete + Architecture: Layered frontend (Next.js 16 App Router) + Discovered 8 feature domains: + auth — User authentication, session management + courses — AI-powered course generation + billing — Stripe subscriptions, usage limits + profile — User profile, XP, badges + ... + + ◆ 8 domains detected. Generate skills: + ● One domain at a time ○ Pick specific domains ○ Base skill only - ◇ Exploring repo and generating skills... - ◇ Generated 5 files + ◇ Base skill generated + ◇ auth, courses, billing + ◇ profile, settings, onboarding + ◇ layout, landing + .claude/skills/base/skill.md + .claude/skills/auth/skill.md - + .claude/skills/billing/skill.md - + .claude/skills/notifications/skill.md - + CLAUDE.md + + .claude/skills/courses/skill.md + ... + + 11 call(s) | ~23,640 prompt | 35,180 output | 161 tool calls | 4m 32s - 5 created + 10 created ``` | Option | Description | @@ -95,6 +130,7 @@ $ aspens doc init . | `--timeout ` | Claude timeout (default: 300) | | `--mode ` | `all`, `chunked`, or `base-only` (skips interactive prompt) | | `--strategy ` | `improve`, `rewrite`, or `skip` for existing docs (skips interactive prompt) | +| `--domains ` | Additional domains to include (comma-separated) | | `--model ` | Claude model (e.g., sonnet, opus, haiku) | | `--verbose` | Show what Claude is reading in real time | @@ -169,15 +205,16 @@ aspens customize agents --dry-run # Preview changes ## How It Works ``` -Your Repo ──▶ Scanner ──▶ Claude ──▶ .claude/skills/ - (detect (explore (skill files - stack, code, Claude Code - domains) generate) loads auto) +Your Repo ──▶ Scanner ──▶ Import Graph ──▶ Discovery Agents ──▶ Skill Generation + (detect (parse imports, (2 parallel Claude (3 domains at a + stack, hub files, agents: domains + time, guided by + domains) coupling) architecture) graph + findings) ``` 1. **Scanner** detects your tech stack, frameworks, structure, and domains. Deterministic — no LLM, instant, free. -2. **Claude** explores your codebase with read-only tools (Read, Glob, Grep). It reads actual source files, follows patterns, and generates skills based on what it finds. -3. **Skills** are written to `.claude/skills/` in your repo. Claude Code discovers them automatically via YAML frontmatter. +2. **Import Graph** parses imports across JS/TS/Python, resolves `@/` aliases from tsconfig, builds a dependency map with hub files, coupling analysis, git churn hotspots, and file priority ranking. +3. **Discovery Agents** (2 parallel Claude calls) explore the codebase guided by the graph. One discovers feature domains, the other analyzes architecture and patterns. Results are merged. +4. **Skill Generation** uses the graph + discovery findings to write concise, actionable skills. Runs up to 3 domains in parallel. Doc sync keeps skills current: on each commit, it reads the diff, identifies affected skills, and updates them. diff --git a/bin/cli.js b/bin/cli.js index aca61e4..60be4c3 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -13,8 +13,10 @@ import { customizeCommand } from '../src/commands/customize.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', 'src', 'templates'); -const PKG = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')); -const VERSION = PKG.version; +let VERSION = '0.0.0'; +try { + VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version || '0.0.0'; +} catch { /* use fallback version */ } function countTemplates(subdir) { try { return readdirSync(join(TEMPLATES_DIR, subdir)).filter(f => !f.startsWith('.')).length; } catch { return '?'; } diff --git a/src/lib/graph-builder.js b/src/lib/graph-builder.js index 0ab6f1e..38da257 100644 --- a/src/lib/graph-builder.js +++ b/src/lib/graph-builder.js @@ -32,6 +32,9 @@ export async function buildRepoGraph(repoPath, languages = []) { const content = readFileSafe(absPath); if (content === null) continue; + // Skip generated files (check first line for markers) + if (isVendoredOrGenerated(basename(absPath), content)) continue; + const lines = content.split('\n').length; const ext = extname(absPath); @@ -39,7 +42,7 @@ export async function buildRepoGraph(repoPath, languages = []) { let exportNames = []; if (JS_EXTS.has(ext)) { - const parsed = parseJsImports(content, relPath); + const parsed = await parseJsImports(content, relPath); rawImports = parsed.imports; exportNames = parsed.exports; } else if (ext === '.py') { @@ -179,9 +182,31 @@ const JS_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs']); const SKIP_DIRS = new Set([ 'node_modules', '__pycache__', 'dist', 'build', '.git', - '.next', 'vendor', 'coverage', 'third_party', + '.next', '.nuxt', 'vendor', 'coverage', 'third_party', + 'extern', 'deps', 'bower_components', '.bundle', 'Pods', + '.cache', 'out', 'output', 'target', +]); + +// Vendored/generated file patterns — skip these from the import graph +const VENDORED_FILE_RE = /\.min\.(js|css)$|\.bundle\.js$|[-.]generated\.|_generated\.|_pb2\.py$|\.pb\.go$|\.g\.dart$/; +const GENERATED_FIRST_LINE_RE = /^\s*\/\/\s*(Code generated|AUTO-GENERATED|This file is auto)|^\s*#\s*(This file is autogenerated|AUTO-GENERATED|Generated by)/i; +const LOCK_FILES = new Set([ + 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', + 'Cargo.lock', 'Gemfile.lock', 'poetry.lock', 'go.sum', + 'composer.lock', 'Pipfile.lock', ]); +function isVendoredOrGenerated(entry, content) { + if (LOCK_FILES.has(entry)) return true; + if (VENDORED_FILE_RE.test(entry)) return true; + // Check first line for generated markers (only if content provided) + if (content) { + const firstLine = content.slice(0, 200).split('\n')[0]; + if (GENERATED_FIRST_LINE_RE.test(firstLine)) return true; + } + return false; +} + function walkSourceFiles(repoPath, maxDepth = 12) { const results = []; @@ -198,7 +223,7 @@ function walkSourceFiles(repoPath, maxDepth = 12) { const stat = statSync(full); if (stat.isDirectory()) { walk(full, depth + 1); - } else if (SOURCE_EXTS.has(extname(entry))) { + } else if (SOURCE_EXTS.has(extname(entry)) && !VENDORED_FILE_RE.test(entry)) { results.push(full); } } catch { /* skip unreadable files */ } @@ -211,7 +236,8 @@ function walkSourceFiles(repoPath, maxDepth = 12) { // --- JS/TS import parsing --- -function parseJsImports(content, relPath) { +async function parseJsImports(content, relPath) { + await init; const result = { imports: [], exports: [] }; try { @@ -272,39 +298,61 @@ function parsePyImports(content) { function loadPathAliases(repoPath) { const aliases = []; - // Try tsconfig.json, then jsconfig.json - for (const configName of ['tsconfig.json', 'jsconfig.json']) { - const configPath = join(repoPath, configName); - if (!existsSync(configPath)) continue; - + // Collect candidate tsconfig/jsconfig locations: + // 1. Repo root + // 2. Common subdirectories (frontend/, apps/*, packages/*) + const configLocations = [repoPath]; + const subdirCandidates = ['frontend', 'web', 'client', 'app', 'src']; + for (const sub of subdirCandidates) { + const full = join(repoPath, sub); + if (existsSync(full) && statSync(full).isDirectory()) { + configLocations.push(full); + } + } + // Also check apps/* and packages/* for monorepos + for (const mono of ['apps', 'packages']) { + const monoDir = join(repoPath, mono); + if (!existsSync(monoDir)) continue; try { - const content = readFileSafe(configPath); - if (!content) continue; - - // Strip comments for JSON parsing — careful not to mangle strings - // Only strip // comments that aren't inside quotes - const stripped = content - .replace(/("(?:[^"\\]|\\.)*")|\/\/.*$/gm, (m, str) => str || '') - .replace(/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, (m, str) => str || '') - .replace(/,\s*([\]}])/g, '$1'); // trailing commas - - const config = JSON.parse(stripped); - const paths = config?.compilerOptions?.paths; - const baseUrl = config?.compilerOptions?.baseUrl || '.'; - - if (paths) { - for (const [pattern, targets] of Object.entries(paths)) { - if (!targets || targets.length === 0) continue; - // "@/*" → prefix "@/", "*" is the wildcard - const prefix = pattern.replace(/\*$/, ''); - const target = targets[0].replace(/\*$/, ''); - const replacement = join(repoPath, baseUrl, target); - aliases.push({ prefix, replacement }); - } + for (const entry of readdirSync(monoDir)) { + const full = join(monoDir, entry); + if (statSync(full).isDirectory()) configLocations.push(full); } - } catch { /* malformed config — skip */ } + } catch { /* skip */ } + } - break; // only use first found config + for (const configDir of configLocations) { + for (const configName of ['tsconfig.json', 'jsconfig.json']) { + const configPath = join(configDir, configName); + if (!existsSync(configPath)) continue; + + try { + const content = readFileSafe(configPath); + if (!content) continue; + + // Strip comments for JSON parsing — careful not to mangle strings + const stripped = content + .replace(/("(?:[^"\\]|\\.)*")|\/\/.*$/gm, (m, str) => str || '') + .replace(/("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, (m, str) => str || '') + .replace(/,\s*([\]}])/g, '$1'); // trailing commas + + const config = JSON.parse(stripped); + const paths = config?.compilerOptions?.paths; + const baseUrl = config?.compilerOptions?.baseUrl || '.'; + + if (paths) { + for (const [pattern, targets] of Object.entries(paths)) { + if (!targets || targets.length === 0) continue; + // "@/*" → prefix "@/", "*" is the wildcard + const prefix = pattern.replace(/\*$/, ''); + const target = targets[0].replace(/\*$/, ''); + // Resolve relative to the tsconfig's directory, not repo root + const replacement = join(configDir, baseUrl, target); + aliases.push({ prefix, replacement }); + } + } + } catch { /* malformed config — skip */ } + } } return aliases; diff --git a/tests/graph-builder.test.js b/tests/graph-builder.test.js index a60c375..9538469 100644 --- a/tests/graph-builder.test.js +++ b/tests/graph-builder.test.js @@ -42,54 +42,54 @@ afterAll(() => { // imports are raw specifiers; exports are export names // --------------------------------------------------------------------------- describe('parseJsImports', () => { - it('extracts named ES module imports', () => { - const result = parseJsImports(`import { foo, bar } from './bar.js';`, 'src/app.js'); + it('extracts named ES module imports', async () => { + const result = await parseJsImports(`import { foo, bar } from './bar.js';`, 'src/app.js'); expect(result.imports).toContain('./bar.js'); }); - it('extracts default imports', () => { - const result = parseJsImports(`import foo from './bar';`, 'src/app.js'); + it('extracts default imports', async () => { + const result = await parseJsImports(`import foo from './bar';`, 'src/app.js'); expect(result.imports).toContain('./bar'); }); - it('extracts side-effect imports', () => { - const result = parseJsImports(`import './styles.css';`, 'src/app.js'); + it('extracts side-effect imports', async () => { + const result = await parseJsImports(`import './styles.css';`, 'src/app.js'); expect(result.imports).toContain('./styles.css'); }); - it('extracts re-exports', () => { - const result = parseJsImports(`export { foo } from './bar';`, 'src/app.js'); + it('extracts re-exports', async () => { + const result = await parseJsImports(`export { foo } from './bar';`, 'src/app.js'); expect(result.imports).toContain('./bar'); }); - it('extracts dynamic imports', () => { - const result = parseJsImports(`const mod = await import('./bar');`, 'src/app.js'); + it('extracts dynamic imports', async () => { + const result = await parseJsImports(`const mod = await import('./bar');`, 'src/app.js'); expect(result.imports).toContain('./bar'); }); - it('classifies bare node/npm imports as external', () => { - const result = parseJsImports(`import fs from 'fs';`, 'src/app.js'); + it('classifies bare node/npm imports as external', async () => { + const result = await parseJsImports(`import fs from 'fs';`, 'src/app.js'); expect(result.imports).toContain('fs'); }); - it('classifies scoped packages as external', () => { - const result = parseJsImports(`import { x } from '@clack/prompts';`, 'src/app.js'); + it('classifies scoped packages as external', async () => { + const result = await parseJsImports(`import { x } from '@clack/prompts';`, 'src/app.js'); expect(result.imports).toContain('@clack/prompts'); }); - it('classifies node: protocol imports as external', () => { - const result = parseJsImports(`import { readFile } from 'node:fs/promises';`, 'src/app.js'); + it('classifies node: protocol imports as external', async () => { + const result = await parseJsImports(`import { readFile } from 'node:fs/promises';`, 'src/app.js'); expect(result.imports).toContain('node:fs/promises'); }); - it('handles multiple imports in one file', () => { + it('handles multiple imports in one file', async () => { const code = [ `import { join } from 'path';`, `import { readFile } from 'fs';`, `import { helper } from './utils.js';`, `import config from '../config.js';`, ].join('\n'); - const result = parseJsImports(code, 'src/app.js'); + const result = await parseJsImports(code, 'src/app.js'); expect(result.imports).toContain('path'); expect(result.imports).toContain('fs'); expect(result.imports).toContain('./utils.js'); @@ -98,19 +98,19 @@ describe('parseJsImports', () => { expect(result.imports).toHaveLength(4); }); - it('ignores commented-out imports', () => { + it('ignores commented-out imports', async () => { const code = [ `// import { old } from './old.js';`, `import { active } from './active.js';`, ].join('\n'); - const result = parseJsImports(code, 'src/app.js'); + const result = await parseJsImports(code, 'src/app.js'); expect(result.imports).not.toContain('./old.js'); expect(result.imports).toContain('./active.js'); }); - it('handles require() calls as imports', () => { + it('handles require() calls as imports', async () => { const code = `const foo = require('./bar');`; - const result = parseJsImports(code, 'src/app.js'); + const result = await parseJsImports(code, 'src/app.js'); // es-module-lexer may or may not pick up require() — it focuses on ESM. // If it does, great; if not, this is expected behavior. // The implementation relies on es-module-lexer which only parses ESM syntax. @@ -163,7 +163,7 @@ describe('parsePyImports', () => { expect(result).toContain('pathlib'); }); - it('ignores commented-out imports', () => { + it('ignores commented-out imports', async () => { const code = [ `# import os`, `from pathlib import Path`, @@ -555,15 +555,15 @@ describe('edge cases', () => { expect(graph.files['src/app.ts'].imports).toContain('src/helper.js'); }); - it('handles TypeScript type-only imports', () => { + it('handles TypeScript type-only imports', async () => { const code = `import type { User } from './types';`; - const result = parseJsImports(code, 'src/app.ts'); + const result = await parseJsImports(code, 'src/app.ts'); expect(result.imports).toContain('./types'); }); - it('handles star re-exports', () => { + it('handles star re-exports', async () => { const code = `export * from './utils';`; - const result = parseJsImports(code, 'src/index.js'); + const result = await parseJsImports(code, 'src/index.js'); expect(result.imports).toContain('./utils'); }); });