From 24081e90acf2302312c333e8dcc9741e9af6a942 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sun, 13 Sep 2026 13:07:58 +0800 Subject: [PATCH 1/4] fix: filter infrastructure and true-child entities before capping candidate list --- .../src/analyzers/features/featureDetector.ts | 10 +- .../test/feature-stability-regression.test.ts | 161 ++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index 93cee12..df17c55 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -650,15 +650,17 @@ function entityGraphToFeatures(entityGraph: EntityGraph, files: ScannedFile[] = const features: FeatureInfo[] = []; - const meaningfulEntities = entityGraph.source === "prisma" + const meaningfulEntities = (entityGraph.source === "prisma" ? entityGraph.entities.filter((e) => relations.some((r) => r.from === e.name || r.to === e.name) ) - : entityGraph.entities; + : entityGraph.entities) + .filter((e) => !trueChildNames.has(e.name) && !INFRASTRUCTURE_ENTITY_NAMES.has(e.name)) + // entity dengan implementasi nyata (sourceFiles dari route-hint/SQL) diprioritaskan + // di atas entity yang cuma eksis di schema tanpa file custom + .sort((a, b) => (b.sourceFiles?.length ?? 0) - (a.sourceFiles?.length ?? 0)); for (const entity of meaningfulEntities.slice(0, 8)) { - if (trueChildNames.has(entity.name)) continue; - if (INFRASTRUCTURE_ENTITY_NAMES.has(entity.name)) continue; const ownedNames = relations .filter((r) => r.from === entity.name && r.kind === "one-to-many") diff --git a/packages/cli/test/feature-stability-regression.test.ts b/packages/cli/test/feature-stability-regression.test.ts index b30f25a..3c68c22 100644 --- a/packages/cli/test/feature-stability-regression.test.ts +++ b/packages/cli/test/feature-stability-regression.test.ts @@ -263,3 +263,164 @@ test("native parser failure falling back to heuristic emits a diagnostic and doe await rm(projectRoot, { recursive: true, force: true }); } }); + +// WP1: Prisma schema with >8 relation-bearing models — boilerplate NextAuth +// models listed first must not starve out real domain entities when the +// meaningfulEntities list is capped at 8. +test("domain entities survive the 8-entity cap when boilerplate models appear first", async () => { + const projectRoot = await buildFixture({ + "package.json": JSON.stringify({ + name: "large-schema", + dependencies: { "next-auth": "^5.0.0", "@prisma/client": "^6.0.0" }, + }), + "prisma/schema.prisma": [ + 'generator client { provider = "prisma-client-js" }', + 'datasource db { provider = "postgresql" }', + // --- boilerplate NextAuth models (listed first) --- + "model User {", + " id String @id @default(cuid())", + " email String @unique", + " accounts Account[]", + " sessions Session[]", + " authenticators Authenticator[]", + " rooms Room[]", + " messages Message[]", + " checklistItems ChecklistItem[]", + " pushSubscriptions PushSubscription[]", + "}", + "model Account {", + " id String @id @default(cuid())", + " userId String", + " user User @relation(fields: [userId], references: [id])", + " type String", + " provider String", + " providerAccountId String", + " refresh_token String?", + " access_token String?", + " expires_at Int?", + " token_type String?", + " scope String?", + " id_token String?", + " session_state String?", + " @@unique([provider, providerAccountId])", + "}", + "model Session {", + " id String @id @default(cuid())", + " sessionToken String @unique", + " userId String", + " user User @relation(fields: [userId], references: [id])", + " expires DateTime", + "}", + "model VerificationToken {", + " identifier String", + " token String @unique", + " expires DateTime", + " @@unique([identifier, token])", + "}", + "model Authenticator {", + " id String @id @default(cuid())", + " userId String", + " user User @relation(fields: [userId], references: [id])", + " credentialPublicKey String", + " counter Int", + " credentialDeviceType String", + " credentialBackedUp Boolean", + " transports String?", + " @@unique([userId, credentialPublicKey])", + "}", + // --- domain models --- + "model Room {", + " id String @id @default(cuid())", + " name String", + " ownerId String", + " owner User @relation(fields: [ownerId], references: [id])", + " messages Message[]", + " checklistItems ChecklistItem[]", + " createdAt DateTime @default(now())", + "}", + "model Message {", + " id String @id @default(cuid())", + " content String", + " roomId String", + " room Room @relation(fields: [roomId], references: [id])", + " authorId String", + " author User @relation(fields: [authorId], references: [id])", + " createdAt DateTime @default(now())", + "}", + "model ChecklistItem {", + " id String @id @default(cuid())", + " text String", + " checked Boolean @default(false)", + " roomId String", + " room Room @relation(fields: [roomId], references: [id])", + " authorId String", + " author User @relation(fields: [authorId], references: [id])", + "}", + "model PushSubscription {", + " id String @id @default(cuid())", + " endpoint String", + " userId String", + " user User @relation(fields: [userId], references: [id])", + "}", + ].join("\n"), + "lib/auth.ts": [ + 'import NextAuth from "next-auth";', + 'import CredentialsProvider from "next-auth/providers/credentials";', + "export const auth = NextAuth({", + " providers: [CredentialsProvider({", + " credentials: { email: {}, password: {} },", + " authorize: async (credentials) => null,", + " })],", + "});", + ].join("\n"), + "app/api/session/route.ts": [ + 'import { auth } from "../../../lib/auth.js";', + "export async function GET() {", + " const session = await auth();", + " return Response.json(session);", + "}", + ].join("\n"), + "lib/room.ts": [ + 'import { PrismaClient } from "@prisma/client";', + "const prisma = new PrismaClient();", + "export async function listRooms() {", + " return prisma.room.findMany();", + "}", + ].join("\n"), + "lib/message.ts": [ + 'import { PrismaClient } from "@prisma/client";', + "const prisma = new PrismaClient();", + "export async function listMessages(roomId: string) {", + " return prisma.message.findMany({ where: { roomId } });", + "}", + ].join("\n"), + "lib/checklist.ts": [ + 'import { PrismaClient } from "@prisma/client";', + "const prisma = new PrismaClient();", + "export async function listChecklistItems(roomId: string) {", + " return prisma.checklistItem.findMany({ where: { roomId } });", + "}", + ].join("\n"), + }); + + try { + const snapshot = await createProjectMap(projectRoot); + const featureNames = snapshot.features.map((f) => f.name); + + // 2 of 4 domain entities must appear as features despite 9 total + // relation-bearing models exceeding the 8-entity cap. + // ChecklistItem and PushSubscription are excluded because + // isTrueChildEntity returns true (one parent, no children of their own) + // — they get folded into their parent entity as owned entities instead. + assert.ok(featureNames.includes("Room Management"), "Room should be a feature"); + assert.ok(featureNames.includes("Message Management"), "Message should be a feature"); + + // Boilerplate infrastructure models must NOT appear + assert.ok(!featureNames.includes("Account Management"), "Account is infrastructure"); + assert.ok(!featureNames.includes("Session Management"), "Session is infrastructure"); + assert.ok(!featureNames.includes("VerificationToken Management"), "VerificationToken is infrastructure"); + assert.ok(!featureNames.includes("Authenticator Management"), "Authenticator is infrastructure"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); From dc262a0e2eda4b7ae267c7e63e93be979bb83675 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sun, 13 Sep 2026 13:30:05 +0800 Subject: [PATCH 2/4] fix: stop barrel re-export files from inflating page feature ownership --- .../detectors/frontendFeatureDetector.ts | 67 +++++++++++++++++-- .../src/analyzers/features/featureDetector.ts | 7 +- .../test/frontend-feature-detector.test.ts | 47 +++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts b/packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts index 24b061a..a7cc52f 100644 --- a/packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts +++ b/packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts @@ -1,5 +1,5 @@ import type { RouteInfo } from "./routeDetector.js"; -import type { ScannedFile } from "../analysis/index.js"; +import type { FileAnalysis, ScannedFile } from "../analysis/index.js"; import type { FileGraph } from "../graph/dependencyGraph.js"; import { buildReverseGraph } from "../graph/index.js"; import { singularize } from "../analysis/extractors/fallbackExtractor.js"; @@ -22,6 +22,43 @@ const NON_FEATURE_PAGE_SEGMENTS = new Set([ "api", "static", "assets", "public", ]); +// --------------------------------------------------------------------------- +// Barrel file detection +// --------------------------------------------------------------------------- + +/** + * isPureBarrelFile — heuristic for files that exist solely to re-export + * symbols from other modules (index.ts barrel files). Two checks: + * 1. No value-level symbols (functions, classes, consts) — only re-exports. + * 2. Export-dominance: majority of non-empty, non-comment lines are + * `export * from` or `export { ... } from` re-export statements. + * + * A barrel file that also defines local values is NOT treated as a barrel — + * it has its own logic and should participate in ownership normally. + */ +function isPureBarrelFile( + analysis: FileAnalysis | undefined, + content: string +): boolean { + if (!analysis) return false; + if (analysis.symbols.length > 0) return false; + if (analysis.imports.length === 0) return false; + + // Export-dominance check: count re-export lines vs total meaningful lines + const lines = content.split("\n"); + const meaningful = lines.filter((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*"); + }); + if (meaningful.length === 0) return false; + + const reExportCount = meaningful.filter((line) => + /^\s*export\s+(\*\s+from|{[^}]*}\s+from)/.test(line) + ).length; + + return reExportCount / meaningful.length > 0.5; +} + // --------------------------------------------------------------------------- // Detector // --------------------------------------------------------------------------- @@ -41,7 +78,9 @@ const NON_FEATURE_PAGE_SEGMENTS = new Set([ */ export function detectFrontendPageFeatures( routes: RouteInfo[], - fileGraph: FileGraph + fileGraph: FileGraph, + analyses: Record, + files: ScannedFile[] ): FeatureInfo[] { const pageRoutes = routes.filter((route) => route.kind === "page"); if (pageRoutes.length === 0) return []; @@ -49,10 +88,16 @@ export function detectFrontendPageFeatures( const routesBySegment = groupBySegment(pageRoutes); const reverseGraph = buildReverseGraph(fileGraph); + const barrelFiles = new Set( + files + .filter((f) => isPureBarrelFile(analyses[f.path], f.content)) + .map((f) => f.path) + ); + const features: FeatureInfo[] = []; for (const [segment, segmentRoutes] of routesBySegment) { const seedFiles = [...new Set(segmentRoutes.map((route) => route.file))].sort(); - const ownedFiles = collectOwnedFiles(seedFiles, fileGraph, reverseGraph); + const ownedFiles = collectOwnedFiles(seedFiles, fileGraph, reverseGraph, barrelFiles); const name = singularize(segment); features.push({ @@ -236,7 +281,8 @@ function normalizeRoutePath(path: string): string { */ export function detectClientRouteFeatures( files: ScannedFile[], - fileGraph: FileGraph + fileGraph: FileGraph, + analyses: Record ): FeatureInfo[] { const routes = findClientRoutes(files); if (routes.length === 0) return []; @@ -259,11 +305,18 @@ export function detectClientRouteFeatures( } const reverseGraph = buildReverseGraph(fileGraph); + + const barrelFiles = new Set( + files + .filter((f) => isPureBarrelFile(analyses[f.path], f.content)) + .map((f) => f.path) + ); + const features: FeatureInfo[] = []; for (const [segment, seedFiles] of bySegment) { if (seedFiles.length === 0) continue; - const ownedFiles = collectOwnedFiles(seedFiles, fileGraph, reverseGraph); + const ownedFiles = collectOwnedFiles(seedFiles, fileGraph, reverseGraph, barrelFiles); const name = singularize(segment); features.push({ @@ -293,13 +346,15 @@ export function detectClientRouteFeatures( function collectOwnedFiles( seedFiles: string[], graph: FileGraph, - reverseGraph: FileGraph + reverseGraph: FileGraph, + barrelFiles: Set ): string[] { const reachable = new Set(seedFiles); const queue = [...seedFiles]; while (queue.length > 0) { const current = queue.shift() as string; + if (barrelFiles.has(current)) continue; // JANGAN ekspansi children barrel for (const next of graph[current] ?? []) { if (!reachable.has(next)) { reachable.add(next); diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index df17c55..8f15037 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -415,15 +415,16 @@ export function detectFeatures( } if (fileGraph) { - for (const feature of detectFrontendPageFeatures(routes, fileGraph)) { + for (const feature of detectFrontendPageFeatures(routes, fileGraph, analyses, scopedFiles)) { candidates.push(toFeatureCandidate("frontend-page", "file-page", feature, routes)); } - for (const feature of detectClientRouteFeatures(scopedFiles, fileGraph)) { + for (const feature of detectClientRouteFeatures(scopedFiles, fileGraph, analyses)) { candidates.push(toFeatureCandidate("client-route", "client-route", feature, routes)); } } - const features = projectFeatureCandidates(reconcileFeatureCandidates(candidates).clusters); + const reconciliation = reconcileFeatureCandidates(candidates); + const features = projectFeatureCandidates(reconciliation.clusters); return enrichAuthenticationFeature(features, scopedFiles, analyses) .sort((left, right) => left.name.localeCompare(right.name)); } diff --git a/packages/cli/test/frontend-feature-detector.test.ts b/packages/cli/test/frontend-feature-detector.test.ts index 5def432..f7d293f 100644 --- a/packages/cli/test/frontend-feature-detector.test.ts +++ b/packages/cli/test/frontend-feature-detector.test.ts @@ -175,3 +175,50 @@ test("API routes are not mistaken for page features, and vice versa", async () = await rm(projectRoot, { recursive: true, force: true }); } }); + +// WP2: Barrel/re-export files should not inflate page feature ownership. +// Two different pages both import from a barrel — the barrel's +// re-exported components should NOT appear in either page's files. +test("barrel re-export file does not inflate page feature ownership", async () => { + // Use "widgets.ts" instead of "index.ts" to avoid false-positive matches + // from registry signals (e.g. Search matches "index" in import paths). + const projectRoot = await buildFixture({ + "package.json": JSON.stringify({ name: "barrel-test" }), + "app/settings/page.tsx": [ + 'import { ThemeToggle } from "../../ui/widgets.js";', + 'export default function SettingsPage() { return ; }', + ].join("\n"), + "app/dashboard/page.tsx": [ + 'import { ThemeToggle } from "../../ui/widgets.js";', + 'export default function DashboardPage() { return ; }', + ].join("\n"), + "ui/widgets.ts": [ + 'export * from "./ThemeToggle.js";', + ].join("\n"), + "ui/ThemeToggle.tsx": [ + 'export function ThemeToggle() { return null; }', + ].join("\n"), + }); + + try { + const snapshot = await createProjectMap(projectRoot); + const featureSettings = snapshot.features.find((f) => f.name === "Setting"); + const featureDashboard = snapshot.features.find((f) => f.name === "Dashboard"); + + assert.ok(featureSettings, "Feature Settings should exist"); + assert.ok(featureDashboard, "Feature Dashboard should exist"); + + // ThemeToggle.tsx should NOT be claimed by either page — the barrel file + // (ui/widgets.ts) is the BFS barrier. + assert.ok( + !featureSettings.files.some((f) => f.includes("ThemeToggle.tsx")), + "Settings should not include ThemeToggle.tsx via barrel" + ); + assert.ok( + !featureDashboard.files.some((f) => f.includes("ThemeToggle.tsx")), + "Dashboard should not include ThemeToggle.tsx via barrel" + ); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); From de3c0b1931683e1d35f5391430eff72da2d8cae8 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sun, 13 Sep 2026 13:38:04 +0800 Subject: [PATCH 3/4] fix: ignore high fan-in files as feature merge anchors --- .../analyzers/features/featureCandidates.ts | 13 ++++++-- .../src/analyzers/features/featureDetector.ts | 5 +-- packages/cli/test/feature-candidates.test.ts | 32 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/analyzers/features/featureCandidates.ts b/packages/cli/src/analyzers/features/featureCandidates.ts index ae7ccb9..32011d7 100644 --- a/packages/cli/src/analyzers/features/featureCandidates.ts +++ b/packages/cli/src/analyzers/features/featureCandidates.ts @@ -86,7 +86,12 @@ const CONFIDENCE_PRIORITY: Record = {} +): FeatureReconciliation { const deterministicCandidates = candidates .filter((candidate) => candidate.source !== "ai") .map(normalizeCandidate) @@ -113,7 +118,7 @@ export function reconcileFeatureCandidates(candidates: FeatureCandidate[]): Feat for (let right = left + 1; right < deterministicCandidates.length; right += 1) { const leftCandidate = deterministicCandidates[left]; const rightCandidate = deterministicCandidates[right]; - const structured = findStructuredAnchors(leftCandidate, rightCandidate); + const structured = findStructuredAnchors(leftCandidate, rightCandidate, fileReferenceCounts); if (structured.length === 0) { mergeDecisions.push({ candidateIds: [leftCandidate.id, rightCandidate.id], @@ -252,11 +257,13 @@ function findHardAnchors(left: FeatureCandidate, right: FeatureCandidate): strin function findStructuredAnchors( left: FeatureCandidate, - right: FeatureCandidate + right: FeatureCandidate, + fileReferenceCounts: Record = {} ): Array<{ type: AnchorType; value: string }> { const entityAnchors = intersection(left.entityNames, right.entityNames) .map((entity) => ({ type: "entity" as const, value: entity })); const fileAnchors = intersection(left.files, right.files) + .filter((file) => (fileReferenceCounts[file] ?? 0) <= HUB_FILE_THRESHOLD) .map((file) => ({ type: "file" as const, value: file })); const routeAnchors = intersection( left.routePaths.map(routeResource).filter(Boolean), diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index 8f15037..40bb847 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -12,7 +12,7 @@ import type { } from "../detectors/index.js"; import { detectFrontendPageFeatures, detectClientRouteFeatures } from "../detectors/index.js"; import type { FileGraph } from "../graph/dependencyGraph.js"; -import { isArchitectureSource } from "../graph/index.js"; +import { countReferences, isArchitectureSource } from "../graph/index.js"; import { projectFeatureCandidates, reconcileFeatureCandidates, @@ -423,7 +423,8 @@ export function detectFeatures( } } - const reconciliation = reconcileFeatureCandidates(candidates); + const fileReferenceCounts = fileGraph ? countReferences(fileGraph) : {}; + const reconciliation = reconcileFeatureCandidates(candidates, fileReferenceCounts); const features = projectFeatureCandidates(reconciliation.clusters); return enrichAuthenticationFeature(features, scopedFiles, analyses) .sort((left, right) => left.name.localeCompare(right.name)); diff --git a/packages/cli/test/feature-candidates.test.ts b/packages/cli/test/feature-candidates.test.ts index 9d5f9b6..ab4cf27 100644 --- a/packages/cli/test/feature-candidates.test.ts +++ b/packages/cli/test/feature-candidates.test.ts @@ -195,3 +195,35 @@ test("author-related entity candidate does not merge into Authentication cluster const labels = reconciliation.clusters.map((c) => c.canonicalLabel).sort(); assert.deepEqual(labels, ["Authentication", "Author Management"]); }); + +// WP3: High fan-in shared files should NOT serve as merge anchors. +test("high fan-in shared file does not merge unrelated candidates", () => { + const candidates = [ + candidate({ + id: "frontend-page:chat", + label: "Chat", + source: "frontend-page", + files: ["app/chat/page.tsx", "lib/shared-utils.ts"], + }), + candidate({ + id: "frontend-page:dashboard", + label: "Dashboard", + source: "frontend-page", + files: ["app/dashboard/page.tsx", "lib/shared-utils.ts"], + }), + ]; + + // shared-utils.ts is imported by 8 different files — it's a hub, not evidence + const reconciliation = reconcileFeatureCandidates(candidates, { + "lib/shared-utils.ts": 8, + }); + + assert.equal(reconciliation.clusters.length, 2, "Hub file should not merge candidates"); + + // Control: same file with low refcount should still merge + const reconciliationControl = reconcileFeatureCandidates(candidates, { + "lib/shared-utils.ts": 1, + }); + + assert.equal(reconciliationControl.clusters.length, 1, "Low refcount file should still merge"); +}); From 66f14e5af844c7810c9903b15a84c5bb2aa8b3b6 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sun, 13 Sep 2026 13:40:28 +0800 Subject: [PATCH 4/4] docs: prepare changelog and version for 0.3.5 --- CHANGELOG.md | 12 ++++++++++++ packages/cli/package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1816a24..dc8485f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ All notable changes to DevMap are documented in this file. - Public benchmark results - Feedback-driven fixes from the `0.2.0` beta +## [0.3.5] - 2026-09-13 + +### Fixed + +- Entity feature detection no longer lets infrastructure or true-child + entities starve out real domain entities when a Prisma schema has more + than 8 relation-bearing models +- Page-feature ownership no longer inflates through barrel/re-export files, + preventing unrelated components from being attributed to the wrong page +- Feature reconciliation no longer treats high fan-in shared files as + sufficient evidence to merge unrelated feature candidates + ## [0.3.0] - 2026-08-26 ### Added diff --git a/packages/cli/package.json b/packages/cli/package.json index 878f4bc..3ba5e1f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@flaid/devmap", - "version": "0.3.4", + "version": "0.3.5", "description": "CLI that maps codebases into a reusable context layer for developers and AI agents.", "bin": { "devmap": "./dist/index.js"