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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
67 changes: 61 additions & 6 deletions packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -41,18 +78,26 @@ const NON_FEATURE_PAGE_SEGMENTS = new Set([
*/
export function detectFrontendPageFeatures(
routes: RouteInfo[],
fileGraph: FileGraph
fileGraph: FileGraph,
analyses: Record<string, FileAnalysis>,
files: ScannedFile[]
): FeatureInfo[] {
const pageRoutes = routes.filter((route) => route.kind === "page");
if (pageRoutes.length === 0) return [];

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({
Expand Down Expand Up @@ -236,7 +281,8 @@ function normalizeRoutePath(path: string): string {
*/
export function detectClientRouteFeatures(
files: ScannedFile[],
fileGraph: FileGraph
fileGraph: FileGraph,
analyses: Record<string, FileAnalysis>
): FeatureInfo[] {
const routes = findClientRoutes(files);
if (routes.length === 0) return [];
Expand All @@ -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({
Expand Down Expand Up @@ -293,13 +346,15 @@ export function detectClientRouteFeatures(
function collectOwnedFiles(
seedFiles: string[],
graph: FileGraph,
reverseGraph: FileGraph
reverseGraph: FileGraph,
barrelFiles: Set<string>
): string[] {
const reachable = new Set<string>(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);
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/analyzers/features/featureCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ const CONFIDENCE_PRIORITY: Record<FeatureCandidate["conclusionConfidence"], numb
* only influence the deterministic canonical-label tie break after candidates
* are already in the same connected component.
*/
export function reconcileFeatureCandidates(candidates: FeatureCandidate[]): FeatureReconciliation {
const HUB_FILE_THRESHOLD = 5;

export function reconcileFeatureCandidates(
candidates: FeatureCandidate[],
fileReferenceCounts: Record<string, number> = {}
): FeatureReconciliation {
const deterministicCandidates = candidates
.filter((candidate) => candidate.source !== "ai")
.map(normalizeCandidate)
Expand All @@ -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],
Expand Down Expand Up @@ -252,11 +257,13 @@ function findHardAnchors(left: FeatureCandidate, right: FeatureCandidate): strin

function findStructuredAnchors(
left: FeatureCandidate,
right: FeatureCandidate
right: FeatureCandidate,
fileReferenceCounts: Record<string, number> = {}
): 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),
Expand Down
20 changes: 12 additions & 8 deletions packages/cli/src/analyzers/features/featureDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -415,15 +415,17 @@ 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 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));
}
Expand Down Expand Up @@ -650,15 +652,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")
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/test/feature-candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading
Loading