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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
43 changes: 30 additions & 13 deletions packages/cli/src/analyzers/features/featureCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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";
}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/analyzers/features/featureDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/analyzers/pipeline/projectMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/analyzers/registry/caching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/analyzers/registry/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
},
Expand Down
51 changes: 49 additions & 2 deletions packages/cli/test/feature-candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}),
];
Expand Down Expand Up @@ -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"],
}),
];
Expand Down Expand Up @@ -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"]);
});
49 changes: 49 additions & 0 deletions packages/cli/test/feature-detection-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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 });
}
});
Loading