Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions src/commands/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/lib/scanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

25 changes: 25 additions & 0 deletions tests/scanner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '<Project></Project>',
'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': '{}',
Expand Down
Loading