From 1870baa11a1a3dfb39e04f5861a480e0d73d4acb Mon Sep 17 00:00:00 2001 From: mvoutov Date: Thu, 16 Apr 2026 19:40:44 -0700 Subject: [PATCH] nested project + show domains --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 4 ++-- src/commands/scan.js | 16 ++++++++++++++++ src/lib/scanner.js | 39 +++++++++++++++++++++++++++++++++++++++ tests/scanner.test.js | 25 +++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 936bc57..1fb02f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.7.2] - 2026-04-16 + +### Fixed +- **Nested-project source root detection** — for layouts like `.NET`'s `~/apps/MyApp/MyApp/MyApp.csproj` (where the outer dir holds `README`/`.git`/`CLAUDE.md` and a single inner dir holds the `.csproj`), `findSourceRoot()` now promotes the inner dir as the source root. Subdirectories like `Controllers/` and `Services/` surface as first-class domains instead of being rolled up under a single wrapper domain. Triggered only when the repo root has exactly one non-skip child directory containing a project manifest (`.csproj`, `.sln`, `pom.xml`, `go.mod`, `package.json`, `pyproject.toml`, etc.). +- **`scan` pretty-printer shows domains for non-JS/TS/Python projects** — the `Domains` section was hidden whenever the import graph returned 0 clusters, which always happened for C#/Java/Swift/PHP/Elixir repos. The pretty-printer now falls back to scanner's filesystem domains under a `Domains (by filesystem)` heading when the graph has no clusters. + ## [0.7.1] - 2026-04-16 ### Fixed diff --git a/package-lock.json b/package-lock.json index db662f8..3156e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aspens", - "version": "0.7.1", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aspens", - "version": "0.7.1", + "version": "0.7.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9d81984..6782f18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspens", - "version": "0.7.1", + "version": "0.7.2", "description": "Keep coding-agent context accurate as your codebase changes", "type": "module", "bin": { @@ -23,7 +23,7 @@ "test": "vitest run", "start": "node bin/cli.js", "lint": "echo 'No linter configured yet' && exit 0", - "postinstall": "echo '\n 📌 aspens v0.7.1: Scanner now recognizes C#, Java, Swift, PHP, and Elixir source files for domain discovery.\n If a prior `doc init` came up empty for one of these languages, re-run it after upgrading.\n\n 🌲 aspens is in active development — please keep it up to date.\n Run into issues? Let us know: https://github.com/aspenkit/aspens/issues\n'" + "postinstall": "echo '\n 📌 aspens v0.7.2: Nested-project layouts (e.g. `.NET ~/apps/MyApp/MyApp/MyApp.csproj`) now yield first-class domains instead of a single wrapper domain. Pretty-printed `scan` also shows domains for C#/Java/Swift/PHP/Elixir projects.\n Re-run `aspens doc init` if a prior scan came up empty or under-detailed.\n\n 🌲 aspens is in active development — please keep it up to date.\n Run into issues? Let us know: https://github.com/aspenkit/aspens/issues\n'" }, "engines": { "node": ">=20" diff --git a/src/commands/scan.js b/src/commands/scan.js index 421c503..d108674 100644 --- a/src/commands/scan.js +++ b/src/commands/scan.js @@ -120,6 +120,22 @@ export async function scanCommand(path, options) { } } console.log(); + } else if (result.domains && result.domains.length > 0) { + // No graph-derived domains (e.g. C#/Java/Swift — graph-builder parses + // JS/TS/Python only). Fall back to scanner's filesystem domains so + // users don't see an empty section for non-JS projects. + console.log(pc.bold(' Domains') + pc.dim(' (by filesystem)')); + for (const domain of result.domains) { + const dir = domain.directories && domain.directories[0] ? `${domain.directories[0]}/` : ''; + const count = domain.sourceFileCount ?? (domain.modules ? domain.modules.length : 0); + console.log(pc.dim(' ') + pc.green(domain.name) + pc.dim(` (${dir})`) + pc.dim(` \u2014 ${pc.cyan(String(count))} files`)); + 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)); + } + } + console.log(); } // Coupling section diff --git a/src/lib/scanner.js b/src/lib/scanner.js index e26035a..ff1fc6b 100644 --- a/src/lib/scanner.js +++ b/src/lib/scanner.js @@ -331,6 +331,13 @@ function detectDomains(repoPath) { const domains = []; const sourceRoot = findSourceRoot(repoPath); + // If source root is a direct child of repo root (nested-project promotion), + // skip that child when scanning repo root to avoid double-counting its contents + // as a wrapper domain alongside its own subdirectory domains. + const nestedChild = sourceRoot && sourceRoot !== repoPath + ? basename(sourceRoot) + : null; + // Scan directories under source root AND at repo root const scanRoots = new Set(); if (sourceRoot) scanRoots.add(sourceRoot); @@ -344,6 +351,7 @@ function detectDomains(repoPath) { const name = entry.toLowerCase(); if (name.startsWith('.')) continue; if (SKIP_DIR_NAMES.has(name)) continue; + if (root === repoPath && entry === nestedChild) continue; const full = join(root, entry); const relDir = relative(repoPath, full); @@ -625,11 +633,42 @@ function globRecursive(dirPath, ext, maxDepth, currentDepth = 0) { return false; } +const PROJECT_MANIFESTS = new Set([ + '.csproj', '.fsproj', '.vbproj', '.sln', + 'package.json', 'pyproject.toml', 'setup.py', 'Pipfile', + 'go.mod', 'Cargo.toml', 'pom.xml', 'build.gradle', 'build.gradle.kts', + 'Gemfile', 'composer.json', 'mix.exs', 'Package.swift', +]); + +function hasProjectManifest(dirPath) { + for (const entry of listDir(dirPath)) { + if (PROJECT_MANIFESTS.has(entry)) return true; + const ext = extname(entry); + if (ext && PROJECT_MANIFESTS.has(ext)) return true; + } + return false; +} + function findSourceRoot(repoPath) { for (const candidate of ['src', 'app', 'lib', 'server', 'pages']) { const full = join(repoPath, candidate); if (isDir(full)) return full; } + + // Nested-project layout (e.g. .NET convention `~/apps/MyApp/MyApp/MyApp.csproj`): + // if the repo root has exactly one non-skip subdirectory and that subdirectory + // contains a project manifest, promote it as the source root. + const childDirs = listDir(repoPath).filter(entry => { + const name = entry.toLowerCase(); + if (name.startsWith('.')) return false; + if (SKIP_DIR_NAMES.has(name)) return false; + return isDir(join(repoPath, entry)); + }); + if (childDirs.length === 1) { + const nested = join(repoPath, childDirs[0]); + if (hasProjectManifest(nested)) return nested; + } + return repoPath; } diff --git a/tests/scanner.test.js b/tests/scanner.test.js index 74a47a1..b340b88 100644 --- a/tests/scanner.test.js +++ b/tests/scanner.test.js @@ -259,6 +259,31 @@ describe('scanRepo', () => { expect(names).toContain('services'); }); + it('promotes nested project directory as source root (e.g. .NET MyApp/MyApp/ layout)', () => { + const dir = createFixture('nested-csharp-project', { + 'README.md': '# outer', + 'myapp/MyApp.csproj': '', + 'myapp/Controllers/UsersController.cs': 'public class UsersController {}', + 'myapp/Services/PaymentService.cs': 'public class PaymentService {}', + }); + const scan = scanRepo(dir); + const names = scan.domains.map(d => d.name); + expect(names).toContain('controllers'); + expect(names).toContain('services'); + expect(names).not.toContain('myapp'); + }); + + it('does not promote when repo root has multiple non-skip child dirs', () => { + const dir = createFixture('multi-child-project', { + 'frontend/app.js': '', + 'backend/server.js': '', + }); + const scan = scanRepo(dir); + const names = scan.domains.map(d => d.name); + expect(names).toContain('frontend'); + expect(names).toContain('backend'); + }); + it('returns empty domains for featureless project', () => { const dir = createFixture('minimal-project', { 'package.json': '{}',