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
8 changes: 4 additions & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
"preview": "astro preview"
},
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/check": "^0.9.10",
"astro": "5.18.2",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"autoprefixer": "^10.6.1",
"postcss": "^8.5.30",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3"
},
"dependencies": {
"gsap": "^3.15.0",
"lenis": "^1.3.26"
"lenis": "^1.3.27"
}
}
8 changes: 4 additions & 4 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@
"type": "module",
"dependencies": {
"commander": "^15.0.0",
"ignore": "^7.0.6",
"p-limit": "^7.3.0",
"ignore": "^7.0.12",
"p-limit": "^7.3.3",
"ts-morph": "^28.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.22.3",
"@types/node": "^22.20.5",
"tsx": "^4.23.15",
"typescript": "^6.0.3"
}
}
35 changes: 29 additions & 6 deletions packages/cli/src/analyzers/analysis/fileScanner.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Dirent, Stats } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import { extname, join, relative } from "node:path";
import pLimit from "p-limit";
Expand All @@ -16,8 +17,20 @@ export async function scanFiles(projectRoot: string): Promise<ScannedFile[]> {
const files: ScannedFile[] = [];
const limit = pLimit(50);

async function visit(directory: string): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true });
// The limiter only guards actual filesystem I/O (readdir, stat + readFile).
// visit() must NOT run inside limit(): a directory task that awaits its
// children while holding a slot starves those children once 50 directories
// are open at the same time, and the whole scan deadlocks.
async function visit(directory: string, isRoot = false): Promise<void> {
let entries: Dirent[];
try {
entries = await limit(() => readdir(directory, { withFileTypes: true }));
} catch (error) {
// A missing/unreadable project root is a real error; an unreadable
// subdirectory (EACCES, removed mid-scan) must not abort the run.
if (isRoot) throw error;
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));

const tasks: Promise<void>[] = [];
Expand All @@ -31,12 +44,22 @@ export async function scanFiles(projectRoot: string): Promise<ScannedFile[]> {
}

if (entry.isDirectory()) {
tasks.push(limit(() => visit(absolutePath)));
tasks.push(visit(absolutePath));
continue;
}

tasks.push(limit(async () => {
const metadata = await stat(absolutePath);
let metadata: Stats;
try {
// stat() follows symlinks. Dangling links throw ENOENT; links to
// directories, FIFOs and sockets are not regular files and must not
// be read (readFile on a FIFO would block forever).
metadata = await stat(absolutePath);
} catch {
return;
}
if (!metadata.isFile()) return;

const content = await readFile(absolutePath, "utf8").catch(() => "");

files.push({
Expand All @@ -53,9 +76,9 @@ export async function scanFiles(projectRoot: string): Promise<ScannedFile[]> {
await Promise.all(tasks);
}

await visit(projectRoot);
await visit(projectRoot, true);

// visit() recurses concurrently (pLimit), so files.push() order depends on
// visit() recurses concurrently, so files.push() order depends on
// I/O completion timing, not directory structure. Sort here so every
// downstream consumer (fileIndex ordering, onboarding path ties, etc.)
// gets a stable, reproducible order across runs on an unchanged project.
Expand Down
10 changes: 7 additions & 3 deletions packages/cli/src/analyzers/detectors/capabilityDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,13 +340,17 @@ function deduplicateCapabilities(capabilities: CapabilityInfo[]): CapabilityInfo
const seen = new Map<string, CapabilityInfo>();

for (const cap of capabilities) {
const existing = seen.get(cap.kind);
// Key on kind AND name: every CRUD resource ("Post Management", "Invoice
// Management", ...) is its own capability. Keying on kind alone collapsed
// them into one capability named after the first resource.
const key = `${cap.kind}:${cap.name}`;
const existing = seen.get(key);
if (!existing) {
seen.set(cap.kind, cap);
seen.set(key, cap);
continue;
}

seen.set(cap.kind, {
seen.set(key, {
...existing,
entities: [...new Set([...existing.entities, ...cap.entities])],
evidence: [...new Set([...existing.evidence, ...cap.evidence])].slice(0, 5),
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/analyzers/features/featureCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export function reconcileFeatureCandidates(
const anchorsByPair = new Map<string, string[]>();
const structuredAnchorsByPair = new Map<string, Array<{ type: AnchorType; value: string }>>();
const mergeDecisions: MergeDecision[] = [];
// Entity identity per union-find root. Anchors are pairwise, but union-find
// merging is transitive, so a single "bridge" candidate (e.g. a behavioral
// capability whose evidence touches two resources) could otherwise fuse two
// genuinely different entity features into one cluster.
const entityIdentities = new Map<number, Set<string>>();
deterministicCandidates.forEach((candidate, index) => {
const identity = entityIdentity(candidate);
if (identity.size > 0) entityIdentities.set(index, identity);
});

for (const rejectedId of rejectedCandidateIds) {
mergeDecisions.push({
Expand All @@ -130,7 +139,29 @@ export function reconcileFeatureCandidates(
}

const anchorStrings = structured.map((a) => `${a.type}:${a.value}`);
const leftRoot = unionFind.find(left);
const rightRoot = unionFind.find(right);
const leftIdentity = entityIdentities.get(leftRoot);
const rightIdentity = entityIdentities.get(rightRoot);
if (leftRoot !== rightRoot && entitiesConflict(leftIdentity, rightIdentity)) {
mergeDecisions.push({
candidateIds: [leftCandidate.id, rightCandidate.id],
outcome: "retained",
anchors: structured,
reason: `Anchors found (${anchorStrings.join(", ")}), but merging would join distinct entities `
+ `(${[...leftIdentity!].join(", ")} / ${[...rightIdentity!].join(", ")}) through a shared candidate.`,
});
continue;
}

unionFind.union(left, right);
const mergedRoot = unionFind.find(left);
if (leftIdentity || rightIdentity) {
entityIdentities.set(mergedRoot, new Set([...(leftIdentity ?? []), ...(rightIdentity ?? [])]));
for (const staleRoot of [leftRoot, rightRoot]) {
if (staleRoot !== mergedRoot) entityIdentities.delete(staleRoot);
}
}
anchorsByPair.set(`${left}:${right}`, anchorStrings);
structuredAnchorsByPair.set(`${left}:${right}`, structured);
mergeDecisions.push({
Expand Down Expand Up @@ -273,6 +304,21 @@ function findStructuredAnchors(
return [...entityAnchors, ...fileAnchors, ...routeAnchors];
}

/** Entity names a candidate vouches for. Only entity-sourced candidates define identity. */
function entityIdentity(candidate: FeatureCandidate): Set<string> {
if (candidate.source !== "entity") return new Set();
return new Set(candidate.entityNames.map(normalizeAnchor).filter(Boolean));
}

/** Two clusters conflict when both name entities and share none of them. */
function entitiesConflict(left: Set<string> | undefined, right: Set<string> | undefined): boolean {
if (!left || !right || left.size === 0 || right.size === 0) return false;
for (const name of left) {
if (right.has(name)) return false;
}
return true;
}

function routeResource(routePath: string): string {
const parts = routePath
.split("/")
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/test/crud-feature-separation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js";

const ROUTE = "export async function GET() { return Response.json([]); }\nexport async function POST() { return Response.json({}); }\n";

test("each CRUD resource becomes its own feature instead of collapsing into one", async () => {
const root = await mkdtemp(join(tmpdir(), "devmap-crud-"));
try {
const write = async (path: string, content: string) => {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), content);
};
await write("package.json", JSON.stringify({
name: "app",
dependencies: { next: "15", react: "19", "react-dom": "19", "@prisma/client": "6" },
}));
await write("prisma/schema.prisma", [
'datasource db { provider = "postgresql" url = env("DATABASE_URL") }',
"model User { id Int @id name String posts Post[] invoices Invoice[] }",
"model Post { id Int @id title String authorId Int author User @relation(fields: [authorId], references: [id]) }",
"model Invoice { id Int @id amount Int userId Int user User @relation(fields: [userId], references: [id]) }",
].join("\n"));
for (const resource of ["posts", "invoices", "users"]) {
await write(`app/api/${resource}/route.ts`, ROUTE);
}

const map = await createProjectMap(root);
const byName = new Map(map.features.map((feature) => [feature.name, feature]));

for (const [name, resource] of [
["Post Management", "posts"],
["Invoice Management", "invoices"],
["User Management", "users"],
] as const) {
const feature = byName.get(name);
assert.ok(feature, `${name} should exist; got ${[...byName.keys()].join(", ")}`);
assert.ok(feature.files.includes(`app/api/${resource}/route.ts`));
const foreign = feature.files.filter((file) => file.startsWith("app/api/") && !file.includes(`/${resource}/`));
assert.deepEqual(foreign, [], `${name} must not contain other resources' routes`);
}

const crudCapabilities = (map.capabilities ?? []).filter((capability) => capability.kind === "crud");
assert.equal(crudCapabilities.length, 3);
} finally {
await rm(root, { recursive: true, force: true });
}
});
56 changes: 56 additions & 0 deletions packages/cli/test/feature-candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,59 @@ test("high fan-in shared file does not merge unrelated candidates", () => {

assert.equal(reconciliationControl.clusters.length, 1, "Low refcount file should still merge");
});

test("a bridge candidate cannot fuse two distinct entity features into one cluster", () => {
const reconciliation = reconcileFeatureCandidates([
candidate({
id: "capability:route-capability:bridge",
label: "Bridge",
source: "capability",
files: ["app/api/invoices/route.ts", "app/api/posts/route.ts"],
}),
candidate({
id: "entity:entity-Invoice Management:invoice-management",
label: "Invoice Management",
source: "entity",
files: ["app/api/invoices/route.ts"],
entityNames: ["Invoice"],
}),
candidate({
id: "entity:entity-Post Management:post-management",
label: "Post Management",
source: "entity",
files: ["app/api/posts/route.ts"],
entityNames: ["Post"],
}),
]);

const labels = reconciliation.clusters
.map((cluster) => cluster.candidates.filter((item) => item.source === "entity").map((item) => item.label))
.sort();
assert.deepEqual(labels, [["Invoice Management"], ["Post Management"]]);
assert.ok(
reconciliation.mergeDecisions?.some((decision) =>
decision.outcome === "retained" && /distinct entities/.test(decision.reason)
)
);
});

test("entity candidates that name the same entity still merge", () => {
const reconciliation = reconcileFeatureCandidates([
candidate({
id: "entity:entity-Post Management:post-management",
label: "Post Management",
source: "entity",
files: ["lib/post.ts"],
entityNames: ["Post"],
}),
candidate({
id: "frontend-page:file-page:posts",
label: "Posts",
source: "frontend-page",
files: ["lib/post.ts"],
entityNames: ["Post"],
}),
]);

assert.equal(reconciliation.clusters.length, 1);
});
82 changes: 82 additions & 0 deletions packages/cli/test/scanner-robustness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { scanFiles } from "../src/analyzers/analysis/fileScanner.js";

async function withTempProject(run: (root: string) => Promise<void>): Promise<void> {
const root = await mkdtemp(join(tmpdir(), "devmap-scan-"));
try {
await run(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}

// A regression of the nested-limiter deadlock never settles, so race the scan
// against a timer to turn a hang into a clear test failure.
async function scanWithTimeout(root: string, ms = 10_000) {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`scanFiles did not finish within ${ms}ms (deadlock?)`)), ms);
});
try {
return await Promise.race([scanFiles(root), timeout]);
} finally {
clearTimeout(timer);
}
}

test("scanFiles finishes on a wide tree with more sibling directories than the concurrency limit", async () => {
await withTempProject(async (root) => {
for (let index = 0; index < 120; index += 1) {
await mkdir(join(root, `dir-${index}`));
await writeFile(join(root, `dir-${index}`, "a.ts"), `export const a${index} = ${index};\n`);
await writeFile(join(root, `dir-${index}`, "b.ts"), `export const b${index} = ${index};\n`);
}

const files = await scanWithTimeout(root);
assert.equal(files.length, 240);
});
});

test("scanFiles finishes on a nested tree with more open directories than the concurrency limit", async () => {
await withTempProject(async (root) => {
for (let outer = 0; outer < 8; outer += 1) {
for (let inner = 0; inner < 8; inner += 1) {
const directory = join(root, `group-${outer}`, `leaf-${inner}`);
await mkdir(directory, { recursive: true });
await writeFile(join(directory, "index.ts"), "export const value = 1;\n");
}
}

const files = await scanWithTimeout(root);
assert.equal(files.length, 64);
});
});

test("scanFiles skips dangling symlinks instead of aborting the whole scan", async () => {
await withTempProject(async (root) => {
await writeFile(join(root, "real.ts"), "export const x = 1;\n");
await symlink(join(root, "does-not-exist.ts"), join(root, "dangling.ts"));

const files = await scanWithTimeout(root);
assert.deepEqual(files.map((file) => file.path), ["real.ts"]);
});
});

test("scanFiles does not report symlinked directories as empty files", async () => {
await withTempProject(async (root) => {
await mkdir(join(root, "real-dir"));
await writeFile(join(root, "real-dir", "inner.ts"), "export const y = 1;\n");
await symlink(join(root, "real-dir"), join(root, "linked-dir"));

const files = await scanWithTimeout(root);
assert.deepEqual(files.map((file) => file.path), ["real-dir/inner.ts"]);
});
});

test("scanFiles still rejects when the project root does not exist", async () => {
await assert.rejects(() => scanFiles(join(tmpdir(), "devmap-scan-missing-root-does-not-exist")));
});
Loading
Loading