From c5c9161d266589a364051778c8a46dd42d76628b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 08:22:30 +0800 Subject: [PATCH] fix(features): keep signals and pages separate, better entry points Found by running 0.8.1 on a real Next.js chat app: Rate Limiting was merged into Caching, Background Jobs and Encryption swallowed the Reminder feature, Message was merged into the Room page, and a scripts/ file was the entry point of Room. - signals only merge with something of the same name - pages merge with entities/capabilities by route resource, entity or name, not by a shared component - entry points: shallowest page first, tooling folders last - scoped package terms match the package; node:crypto and a lone redis import no longer count - tests Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019HJc4sv9pSLh7vhduSUxBE --- CHANGELOG.md | 14 +++++ .../detectors/frontendFeatureDetector.ts | 9 +++- .../analyzers/features/featureCandidates.ts | 43 +++++++++++----- .../src/analyzers/features/featureDetector.ts | 3 ++ .../cli/src/analyzers/pipeline/projectMap.ts | 15 +++++- .../cli/src/analyzers/registry/caching.ts | 2 + .../cli/src/analyzers/registry/encryption.ts | 2 +- packages/cli/test/feature-candidates.test.ts | 51 ++++++++++++++++++- .../cli/test/feature-detection-audit.test.ts | 49 ++++++++++++++++++ 9 files changed, 169 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 863f39a..6dedb8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to DevMap are documented in this file. ## [Unreleased] +### Fixed + +- Feature detection (found by running 0.8.1 on a real chat app): vocabulary signals such as + Rate Limiting, Background Jobs, Encryption and Caching no longer fuse with each other or + with a Reminder feature just because they share a file, so each stays visible +- A page and an entity or capability that only share a component are separate features; + they still merge through the same route resource, entity or name (`Payments` and + `Payment Management` are the same name) +- A feature's entry point is the page closest to the root, never a file under `scripts/`, + `tools/` or `bin/` +- Scoped package terms (`@sentry/nextjs`, `@trigger.dev`) now match imports of that package +- `node:crypto` alone no longer means Encryption (it is also used for hashing), and a single + file importing `redis` is no longer Caching + ### Planned - Go, Java, C# and PHP (PSR-4) import resolution, after JS/TS is mature diff --git a/packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts b/packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts index bd62c3f..30ece8d 100644 --- a/packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts +++ b/packages/cli/src/analyzers/detectors/frontendFeatureDetector.ts @@ -100,7 +100,7 @@ export function detectFrontendPageFeatures( ); const pages = [...routesBySegment].map(([segment, segmentRoutes]) => { - const seedFiles = [...new Set(segmentRoutes.map((route) => route.file))].sort(); + const seedFiles = byShallowPath([...new Set(segmentRoutes.map((route) => route.file))]); return { segment, segmentRoutes, @@ -365,6 +365,13 @@ export function detectClientRouteFeatures( return features; } +/** The page closest to the segment root first: `room/[id]/page.tsx` before `room/[id]/info/page.tsx`. */ +function byShallowPath(files: string[]): string[] { + return [...files].sort((left, right) => + left.split("/").length - right.split("/").length || left.localeCompare(right) + ); +} + /** * A component reachable from two different pages belongs to neither: with * auto-imported or app-wide components every page reaches the same big tree, and diff --git a/packages/cli/src/analyzers/features/featureCandidates.ts b/packages/cli/src/analyzers/features/featureCandidates.ts index 799d953..25154bb 100644 --- a/packages/cli/src/analyzers/features/featureCandidates.ts +++ b/packages/cli/src/analyzers/features/featureCandidates.ts @@ -261,7 +261,11 @@ function projectCompatibilityFeature(cluster: FeatureCluster): FeatureInfo | und .filter((candidate): candidate is FeatureInfo => Boolean(candidate)); const files = uniqueSorted(projections.flatMap((feature) => feature.files)); const evidence = uniqueSorted(projections.flatMap((feature) => feature.evidence)); - const entryPoints = uniqueSorted(projections.flatMap((feature) => feature.entryPoints)); + // The canonical candidate's entry points come first; the others follow in path order. + const entryPoints = [...new Set([ + ...canonical.entryPoints, + ...uniqueSorted(projections.flatMap((feature) => feature.entryPoints)) + ])]; const searchTerms = uniqueSorted(projections.flatMap((feature) => feature.searchTerms)).slice(0, 8); return { @@ -324,24 +328,28 @@ function findStructuredAnchors( ): Array<{ type: AnchorType; value: string }> { if (left.standalone || right.standalone) return []; - // A page is an area of the product; a registry signal (auth, email, caching ...) is a - // cross-cutting concern that many pages touch. Fusing them would let one signal glue - // unrelated pages together and name the result after whichever page sorts first. - const pageVsSignal = - (isPageSource(left.source) && isSignalCandidate(right)) - || (isPageSource(right.source) && isSignalCandidate(left)); - if (pageVsSignal) { - // Only a page that carries the signal's own name is the same feature ("Search"). - return normalizeAnchor(left.label) === normalizeAnchor(right.label) - ? [{ type: "alias" as const, value: normalizeAnchor(left.label) }] - : []; + // A vocabulary signal (Authentication, Encryption, Caching ...) is a cross-cutting concern + // that many files and many features touch. Sharing a file with it, or with another signal, + // says nothing about being the same feature, and fusing them hides real features + // (Rate Limiting inside Caching, Reminders inside Background Jobs + Encryption). Only the + // same name makes a signal the same feature as something else. + if (isSignalCandidate(left) || isSignalCandidate(right)) { + return sameName(left, right) ? [{ type: "alias" as const, value: normalizeAnchor(left.label) }] : []; + } + + // A page imports components of whatever it shows, so sharing a file with an entity or + // capability does not make them one feature; a shared route resource, entity name or the + // same name does. + const pageVsOther = isPageSource(left.source) !== isPageSource(right.source); + if (pageVsOther && sameName(left, right)) { + return [{ type: "alias" as const, value: normalizeAnchor(left.label) }]; } // Two different pages are two features, however many components they share. const bothPages = isPageSource(left.source) && isPageSource(right.source); const entityAnchors = intersection(left.entityNames, right.entityNames) .map((entity) => ({ type: "entity" as const, value: entity })); - const fileAnchors = bothPages ? [] : intersection(left.files, right.files) + const fileAnchors = bothPages || pageVsOther ? [] : intersection(left.files, right.files) .filter((file) => (fileReferenceCounts[file] ?? 0) <= HUB_FILE_THRESHOLD) .map((file) => ({ type: "file" as const, value: file })); const routeAnchors = intersection( @@ -357,6 +365,15 @@ function isSignalCandidate(candidate: FeatureCandidate): boolean { return candidate.source === "registry" && !candidate.id.includes(":role-"); } +/** "Payment Management" and "Payments" are the same name. */ +function featureStem(label: string): string { + return normalizeAnchor(label).replace(/ management$/, "").replace(/s$/, ""); +} + +function sameName(left: FeatureCandidate, right: FeatureCandidate): boolean { + return featureStem(left.label) === featureStem(right.label); +} + function isPageSource(source: FeatureCandidateSource): boolean { return source === "frontend-page" || source === "client-route"; } diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index fa4b2ae..60eaf88 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -918,6 +918,9 @@ function matchesSignal( function matchesImportTerm(specifier: string, term: string): boolean { const lower = specifier.toLowerCase(); + // A term that names a scope or a full scoped package ("@sentry/nextjs", "@trigger.dev") + if (term.startsWith("@") && (lower === term || lower.startsWith(`${term}/`))) return true; + // Scoped package: @org/name — match against the name segment const scopedMatch = lower.match(/^@[^/]+\/(.+)$/); if (scopedMatch) { diff --git a/packages/cli/src/analyzers/pipeline/projectMap.ts b/packages/cli/src/analyzers/pipeline/projectMap.ts index 81871b7..7520312 100644 --- a/packages/cli/src/analyzers/pipeline/projectMap.ts +++ b/packages/cli/src/analyzers/pipeline/projectMap.ts @@ -677,10 +677,16 @@ function attachFeatureEntryPoints( const relatedRouteEntries = routes .filter((route) => feature.files.includes(route.file)) .map((route) => route.file); + // Closest to the root first, tooling folders such as scripts/ last: + // `room/[id]/page.tsx` leads `room/[id]/info/page.tsx`, and no script leads anything. const relatedEntries = [...new Set([ ...feature.files.filter((file) => entryPoints.includes(file)), ...relatedRouteEntries - ])].sort(); + ])].sort((left, right) => + Number(isToolingPath(left)) - Number(isToolingPath(right)) + || left.split("/").length - right.split("/").length + || left.localeCompare(right) + ); const orderedFiles = feature.name === "Authentication" ? orderAuthenticationFiles(feature.files) : feature.files; @@ -743,7 +749,12 @@ function chooseFeatureEntryPoint( return sorted[0]; } - return files[0]; + return files.find((file) => !isToolingPath(file)) ?? files[0]; +} + +/** Build, release and maintenance scripts: evidence for a feature, never its entry. */ +function isToolingPath(path: string): boolean { + return /(^|\/)(scripts?|tools?|bin|\.?config)\//.test(path.toLowerCase()); } function buildFeatureBusinessFlow( diff --git a/packages/cli/src/analyzers/registry/caching.ts b/packages/cli/src/analyzers/registry/caching.ts index ab8f464..e69cdfc 100644 --- a/packages/cli/src/analyzers/registry/caching.ts +++ b/packages/cli/src/analyzers/registry/caching.ts @@ -4,6 +4,8 @@ export const DESCRIPTORS: SignalDescriptor[] = [ { name: "Caching", category: "feature", + // A single file importing redis is usually a rate limiter or a session store, not caching. + minimumDistinctFiles: 2, purpose: "Handles in-memory and distributed caching strategies.", genericTerms: [ "ioredis", "redis", "@upstash/redis", "keyv", diff --git a/packages/cli/src/analyzers/registry/encryption.ts b/packages/cli/src/analyzers/registry/encryption.ts index 6086e41..461bedb 100644 --- a/packages/cli/src/analyzers/registry/encryption.ts +++ b/packages/cli/src/analyzers/registry/encryption.ts @@ -6,7 +6,7 @@ export const DESCRIPTORS: SignalDescriptor[] = [ category: "feature", purpose: "Handles data-at-rest encryption and decryption.", genericTerms: [ - "node:crypto", "crypto-js", "tweetnacl", "libsodium", "@noble/ciphers", + "crypto-js", "tweetnacl", "libsodium", "@noble/ciphers", "aes", "aes-256-gcm", "cipher", "encrypt", "decrypt", "encryption", ], }, diff --git a/packages/cli/test/feature-candidates.test.ts b/packages/cli/test/feature-candidates.test.ts index 5bb6508..2c56639 100644 --- a/packages/cli/test/feature-candidates.test.ts +++ b/packages/cli/test/feature-candidates.test.ts @@ -91,12 +91,14 @@ test("a bridge candidate reconciles components independently of insertion order" label: "Chat", source: "frontend-page", files: ["app/chat/page.tsx"], + routePaths: ["/chat"], }), candidate({ id: "capability:chat", label: "Chat Operations", source: "capability", files: ["app/chat/page.tsx"], + routePaths: ["/chat"], entityNames: ["Message"], }), ]; @@ -206,9 +208,9 @@ test("high fan-in shared file does not merge unrelated candidates", () => { files: ["app/chat/route.ts", "lib/shared-utils.ts"], }), candidate({ - id: "registry:dashboard", + id: "entity:dashboard", label: "Dashboard", - source: "registry", + source: "entity", files: ["app/dashboard/route.ts", "lib/shared-utils.ts"], }), ]; @@ -351,3 +353,48 @@ test("a standalone candidate is never merged, even through an entity or file anc const labels = reconcileFeatureCandidates([home, post]).clusters.map((cluster) => cluster.canonicalLabel).sort(); assert.deepEqual(labels, ["Home", "Post"]); }); + +test("signals that share a file stay separate features", () => { + const labels = reconcileFeatureCandidates([ + candidate({ id: "registry:signal-Caching", label: "Caching", source: "registry", files: ["src/rateLimit.ts"] }), + candidate({ id: "registry:signal-Rate Limiting", label: "Rate Limiting", source: "registry", files: ["src/rateLimit.ts", "src/trpc.ts"] }), + candidate({ + id: "capability:reminder-management", + label: "Reminder Management", + source: "capability", + files: ["src/rateLimit.ts", "src/reminders.ts"], + entityNames: ["Reminder"], + }), + ]).clusters.map((cluster) => cluster.canonicalLabel).sort(); + + assert.deepEqual(labels, ["Caching", "Rate Limiting", "Reminder Management"]); +}); + +test("a page and an entity that only share a component stay separate, but a shared route resource merges them", () => { + const room = candidate({ + id: "frontend-page:room", + label: "Room", + source: "frontend-page", + files: ["app/room/page.tsx", "components/bubble.tsx"], + routePaths: ["/room/[id]"], + }); + const message = candidate({ + id: "entity:message", + label: "Message Management", + source: "entity", + files: ["components/bubble.tsx"], + entityNames: ["Message"], + }); + const roomEntity = candidate({ + id: "entity:room", + label: "Room Management", + source: "entity", + files: ["server/room.ts"], + routePaths: ["/api/room"], + entityNames: ["Room"], + }); + + const labels = reconcileFeatureCandidates([room, message, roomEntity]) + .clusters.map((cluster) => cluster.canonicalLabel).sort(); + assert.deepEqual(labels, ["Message Management", "Room"]); +}); diff --git a/packages/cli/test/feature-detection-audit.test.ts b/packages/cli/test/feature-detection-audit.test.ts index cc224c3..03dbf69 100644 --- a/packages/cli/test/feature-detection-audit.test.ts +++ b/packages/cli/test/feature-detection-audit.test.ts @@ -181,3 +181,52 @@ test("CLI Commands needs a command line program, not just a folder or main.ts na }); assert.ok(cli.includes("CLI Commands"), cli.join(", ")); }); + +test("rate limiting, background jobs and encryption are not swallowed by each other or by Reminders", async () => { + const names = await featureNames({ + "package.json": JSON.stringify({ name: "chat", dependencies: { next: "15.0.0" } }), + "src/server/rateLimit.ts": "import { Ratelimit } from '@upstash/ratelimit';\nimport { Redis } from '@upstash/redis';\nexport const limit = new Ratelimit({ redis: Redis.fromEnv() });\n", + "src/lib/encryption.ts": "export const encrypt = (value: string) => value;\n", + "src/lib/qstash.ts": "import { Client } from '@upstash/qstash';\nexport const queue = new Client({});\n", + "src/app/api/reminders/trigger/route.ts": "import { encrypt } from '../../../../lib/encryption';\nimport { queue } from '../../../../lib/qstash';\nexport async function POST() { return Response.json([encrypt, queue].length); }\n", + "src/app/api/reminders/[id]/done/route.ts": "export async function POST() { return Response.json({ ok: true }); }\n" + }); + + for (const expected of ["Rate Limiting", "Background Jobs", "Encryption", "Reminder Management"]) { + assert.ok(names.includes(expected), `${expected} missing (got ${names.join(", ")})`); + } + assert.ok(!names.includes("Caching"), "a single redis import is not caching"); +}); + +test("scoped package terms match the full package name", async () => { + const names = await featureNames({ + "package.json": JSON.stringify({ name: "monitored" }), + "src/a.ts": "import * as Sentry from '@sentry/nextjs'; export const a = Sentry;\n", + "src/b.ts": "export const b = 1;\n" + }); + assert.ok(names.includes("Logging & Monitoring"), names.join(", ")); +}); + +test("a feature's entry point is its page, not a script that mentions it", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-feature-entry-")); + try { + const files: Record = { + "package.json": JSON.stringify({ name: "rooms", dependencies: { next: "15.0.0" } }), + "app/room/[id]/page.tsx": page("RoomPage", "import { Bubble } from '../../../components/bubble';\n"), + "app/room/[id]/info/page.tsx": page("RoomInfo"), + "components/bubble.tsx": "export const Bubble = () => null;\n", + "scripts/generate-room-icons.mjs": "console.log('room icons');\n" + }; + for (const [path, content] of Object.entries(files)) { + const target = join(projectRoot, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); + } + const snapshot = await createProjectMap(projectRoot); + const room = snapshot.features.find((feature) => feature.name === "Room"); + assert.ok(room, snapshot.features.map((feature) => feature.name).join(", ")); + assert.equal(room.entryPoint, "app/room/[id]/page.tsx"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +});