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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ All notable changes to DevMap are documented in this file.

## [Unreleased]

### Added

- A "VS Code Extension" feature for files that use the `vscode` API

### Fixed

- Feature detection (found by running 0.8.2 on a snippet manager with a web app and an
editor extension): behavioral capabilities such as Content Sharing, Social Interactions
and Public Discovery no longer fuse a resource, its collections and every behavior into
one feature named after a page ("Share" held Snippet and Collection)
- A CRUD capability (`Snippet Management`) keeps its resource: it can no longer be pulled
into another resource's feature through a shared route, so Snippet and Collection stay
separate with their own entry points
- In an API with 10 or more routes, a single read-only endpoint (`GET /api/sidebar`,
`/api/user-stats`) is no longer a one-file "Management" feature; a small API keeps it
- Resources named with a dash or underscore are named like models (`user-stats` is
`UserStat`, not "User-stat")

### Planned

- Go, Java, C# and PHP (PSR-4) import resolution, after JS/TS is mature
Expand Down
38 changes: 28 additions & 10 deletions packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isOperationalSegment } from "../../detectors/nonResourceSegments.js";
import { isOperationalSegment, LARGE_API_ROUTE_COUNT } from "../../detectors/nonResourceSegments.js";
import type { RouteInfo } from "../../detectors/index.js";
import type { EntityInfo, IRouteFallbackExtractor } from "./types.js";

Expand Down Expand Up @@ -66,11 +66,13 @@ export class RouteFallbackExtractor implements IRouteFallbackExtractor {
readonly name = "route-fallback";

extract(routes: RouteInfo[]): EntityInfo[] {
const entityNames = new Set<string>();
const apiRoutes = routes.filter((r) => r.kind === "api");
const found = new Map<string, { writes: boolean; files: Set<string> }>();

for (const route of routes.filter((r) => r.kind === "api")) {
for (const route of apiRoutes) {
const segments = route.path.split("/").filter(Boolean);
let prevWasDynamic = false;
const writes = (route.methods ?? []).some((method) => method !== "GET" && method !== "HEAD" && method !== "QUERY");

for (const segment of segments) {
// Skip dynamic segments: [id], [slug], [...catchall]
Expand All @@ -90,20 +92,36 @@ export class RouteFallbackExtractor implements IRouteFallbackExtractor {
if (ROUTE_NON_ENTITY_SEGMENTS.has(segment.toLowerCase())) continue;
if (isOperationalSegment(segment)) continue;

entityNames.add(singularize(segment));
const name = resourceEntityName(segment);
const entry = found.get(name) ?? { writes: false, files: new Set<string>() };
entry.writes = entry.writes || writes || route.methods === undefined;
entry.files.add(route.file);
found.set(name, entry);
prevWasDynamic = false;
}
}

return [...entityNames].map((name) => ({
name,
fields: [],
relations: [],
source: "route-hint" as const,
}));
const large = apiRoutes.length >= LARGE_API_ROUTE_COUNT;
return [...found]
// In a large API a single read-only endpoint is plumbing, not an entity.
.filter(([, { writes, files }]) => !large || writes || files.size >= 2)
.map(([name]) => ({
name,
fields: [],
relations: [],
source: "route-hint" as const,
}));
}
}

/** `snippets` → "Snippet", `user-stats` → "UserStat". */
function resourceEntityName(segment: string): string {
const words = segment.split(/[-_]+/).filter(Boolean);
if (words.length <= 1) return singularize(segment);
words[words.length - 1] = singularize(words[words.length - 1]);
return words.map((word) => word[0].toUpperCase() + word.slice(1)).join("");
}

// ---------------------------------------------------------------------------
// Singularization
// ---------------------------------------------------------------------------
Expand Down
15 changes: 12 additions & 3 deletions packages/cli/src/analyzers/detectors/capabilityDetector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isOperationalSegment } from "./nonResourceSegments.js";
import { isOperationalSegment, LARGE_API_ROUTE_COUNT } from "./nonResourceSegments.js";
import type { RouteInfo } from "./routeDetector.js";
import type { EntityGraph } from "../analysis/index.js";

Expand Down Expand Up @@ -105,6 +105,10 @@ function detectCrudCapabilities(

if (!hasRead && !hasWrite) continue;

// In an API of any size, one read-only endpoint (`GET /api/sidebar`) is plumbing,
// not a resource someone manages. A small API keeps it: it may be the whole product.
if (!hasWrite && files.size < 2 && apiRoutes.length >= LARGE_API_ROUTE_COUNT) continue;

const entityName = resolveEntityName(resource, entityGraph);
const isFull = hasRead && hasWrite && hasDelete;

Expand Down Expand Up @@ -308,7 +312,8 @@ function extractResourceName(path: string): string | null {
}

function resolveEntityName(resource: string, entityGraph: EntityGraph): string {
const lower = resource.toLowerCase();
// `user-stats` and `user_stats` name the same thing as the model `UserStat`.
const lower = resource.toLowerCase().replace(/[-_]+/g, "");

const direct = entityGraph.entityNames.find(
(name) => name.toLowerCase() === lower
Expand All @@ -321,7 +326,11 @@ function resolveEntityName(resource: string, entityGraph: EntityGraph): string {
);
if (singularMatch) return singularMatch;

return singular.length === 0 ? singular : singular[0].toUpperCase() + singular.slice(1);
// No model: name it from the words of the segment, "user-stats" -> "UserStat".
const words = resource.toLowerCase().split(/[-_]+/).filter(Boolean);
if (words.length === 0) return "";
words[words.length - 1] = singularize(words[words.length - 1]);
return words.map((word) => word[0].toUpperCase() + word.slice(1)).join("");
}

function singularize(word: string): string {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/analyzers/detectors/nonResourceSegments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export function isOperationalSegment(segment: string): boolean {
const lower = segment.toLowerCase();
return lower.includes(".") || OPERATIONAL_SEGMENTS.has(lower);
}

/** From this many API routes on, a lone read-only endpoint (`GET /api/sidebar`) is plumbing, not a feature. */
export const LARGE_API_ROUTE_COUNT = 10;
25 changes: 23 additions & 2 deletions packages/cli/src/analyzers/features/featureCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,17 @@ function findStructuredAnchors(
return sameName(left, right) ? [{ type: "alias" as const, value: normalizeAnchor(left.label) }] : [];
}

// A behavioral capability ("Content Sharing", "Social Interactions") cuts across a resource.
// It touches the same routes as the resource's own CRUD capability, so a shared route or
// file would fuse the resource, its collections and every behavior into one giant feature.
// Only a shared entity (or the same name) makes it the same feature as something else.
if (isBehavioralCapability(left) || isBehavioralCapability(right)) {
const entities = intersection(left.entityNames, right.entityNames)
.map((entity) => ({ type: "entity" as const, value: entity }));
if (entities.length > 0) return entities;
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.
Expand Down Expand Up @@ -374,13 +385,23 @@ function sameName(left: FeatureCandidate, right: FeatureCandidate): boolean {
return featureStem(left.label) === featureStem(right.label);
}

/** A capability that is not a CRUD resource: sharing, publishing, discovery, collaboration ... */
function isBehavioralCapability(candidate: FeatureCandidate): boolean {
return candidate.source === "capability" && !/ management$/i.test(candidate.label);
}

function isPageSource(source: FeatureCandidateSource): boolean {
return source === "frontend-page" || source === "client-route";
}

/** Entity names a candidate vouches for. Only entity-sourced candidates define identity. */
/**
* Entity names a candidate vouches for. Entity-sourced candidates and CRUD capabilities
* ("Snippet Management") define identity; a capability that is not about one resource does not.
*/
function entityIdentity(candidate: FeatureCandidate): Set<string> {
if (candidate.source !== "entity") return new Set();
if (candidate.source !== "entity" && !(candidate.source === "capability" && !isBehavioralCapability(candidate))) {
return new Set();
}
return new Set(candidate.entityNames.map(normalizeAnchor).filter(Boolean));
}

Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/analyzers/features/featureDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,27 @@ export function detectFeatures(
}
}

// --- VS Code extension: files that use the `vscode` API ---
const extensionFiles = scopedFiles
.filter((file) => /\.[cm]?[jt]sx?$/.test(file.path) && VSCODE_IMPORT.test(file.content))
.filter((file) => isFeatureEvidenceFile(file.path))
.map((file) => file.path)
.sort()
.slice(0, 12);
if (extensionFiles.length > 0) {
candidates.push(toFeatureCandidate(
"registry",
"role-vscode-extension",
createFeatureInfo(
"VS Code Extension",
extensionFiles,
["vscode", "extension", "command", "activate"],
"Contains the VS Code extension: activation, commands and editor integration.",
analyses
)
));
}

// --- FEATURE_SIGNALS ---
// Pre-filter once here — avoids redundant isFeatureEvidenceFile calls inside each signal loop
// Registry files (../registry/**) are excluded: they *define* the vocabulary,
Expand Down Expand Up @@ -453,6 +474,8 @@ export function detectFeatures(
};
}

const VSCODE_IMPORT = /(?:from\s+|require\(\s*|import\(\s*)["']vscode["']/;

const CLI_LIBRARIES = "commander|yargs|citty|cac|clipanion|@oclif/core|oclif|meow|minimist|sade|cleye|gunshi|@clack/prompts|inquirer|mri|arg";
const CLI_IMPORT = new RegExp(`(?:from\\s+|require\\(\\s*|import\\(\\s*)["'](?:${CLI_LIBRARIES.replace(/[/@]/g, (c) => `\\${c}`)})(?:/[^"']*)?["']`);

Expand Down
70 changes: 65 additions & 5 deletions packages/cli/test/feature-candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ test("a bridge candidate reconciles components independently of insertion order"
}),
candidate({
id: "capability:chat",
label: "Chat Operations",
label: "Chat Management",
source: "capability",
files: ["app/chat/page.tsx"],
routePaths: ["/chat"],
Expand Down Expand Up @@ -142,14 +142,14 @@ test("nested route segment does not create standalone entity from subresource pa
const reconciliation = reconcileFeatureCandidates([
candidate({
id: "capability:workspace",
label: "Workspace Operations",
label: "Workspace Management",
source: "capability",
files: ["app/api/workspaces/route.ts"],
routePaths: ["/api/workspaces"],
}),
candidate({
id: "capability:workspace-members",
label: "Member Operations",
label: "Member Management",
source: "capability",
files: ["app/api/workspaces/[id]/members/route.ts"],
routePaths: ["/api/workspaces/[id]/members"],
Expand Down Expand Up @@ -203,7 +203,7 @@ test("high fan-in shared file does not merge unrelated candidates", () => {
const candidates = [
candidate({
id: "capability:chat",
label: "Chat",
label: "Chat Management",
source: "capability",
files: ["app/chat/route.ts", "lib/shared-utils.ts"],
}),
Expand Down Expand Up @@ -234,7 +234,7 @@ test("a bridge candidate cannot fuse two distinct entity features into one clust
const reconciliation = reconcileFeatureCandidates([
candidate({
id: "capability:route-capability:bridge",
label: "Bridge",
label: "Bridge Management",
source: "capability",
files: ["app/api/invoices/route.ts", "app/api/posts/route.ts"],
}),
Expand Down Expand Up @@ -398,3 +398,63 @@ test("a page and an entity that only share a component stay separate, but a shar
.clusters.map((cluster) => cluster.canonicalLabel).sort();
assert.deepEqual(labels, ["Message Management", "Room"]);
});

test("a CRUD capability keeps its resource: it cannot be pulled into another resource's feature", () => {
const labels = reconcileFeatureCandidates([
candidate({
id: "capability:snippets",
label: "Snippet Management",
source: "capability",
files: ["api/snippets/route.ts", "api/snippets/[id]/collections/route.ts"],
routePaths: ["/api/snippets", "/api/snippets/[id]/collections"],
entityNames: ["Snippet"],
}),
candidate({
id: "entity:collection",
label: "Collection Management",
source: "entity",
files: ["api/snippets/[id]/collections/route.ts"],
routePaths: ["/api/snippets/[id]/collections"],
entityNames: ["Collection"],
}),
candidate({
id: "entity:snippet",
label: "Snippet Management",
source: "entity",
files: ["api/snippets/route.ts"],
entityNames: ["Snippet"],
}),
]).clusters.map((cluster) => cluster.canonicalLabel).sort();

assert.deepEqual(labels, ["Collection Management", "Snippet Management"]);
});

test("a behavioral capability (sharing, publishing) never fuses a resource with its siblings through shared routes", () => {
const labels = reconcileFeatureCandidates([
candidate({
id: "capability:sharing",
label: "Content Sharing",
source: "capability",
files: ["api/snippets/[id]/share/route.ts"],
routePaths: ["/api/snippets/[id]/share"],
}),
candidate({
id: "capability:snippets",
label: "Snippet Management",
source: "capability",
files: ["api/snippets/route.ts"],
routePaths: ["/api/snippets"],
entityNames: ["Snippet"],
}),
candidate({
id: "capability:collections",
label: "Collection Management",
source: "capability",
files: ["api/collections/route.ts"],
routePaths: ["/api/collections", "/api/snippets"],
entityNames: ["Collection"],
}),
]).clusters.map((cluster) => cluster.canonicalLabel).sort();

assert.deepEqual(labels, ["Collection Management", "Content Sharing", "Snippet Management"]);
});
36 changes: 36 additions & 0 deletions packages/cli/test/feature-detection-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,39 @@ test("a feature's entry point is its page, not a script that mentions it", async
await rm(projectRoot, { recursive: true, force: true });
}
});

test("a VS Code extension is a feature of its own", async () => {
const names = await featureNames({
"package.json": JSON.stringify({ name: "ext", engines: { vscode: "^1.90.0" } }),
"src/extension.ts": "import * as vscode from 'vscode';\nexport function activate(context: vscode.ExtensionContext) { return context; }\n",
"src/commands.ts": "import { window } from 'vscode';\nexport const hello = () => window.showInformationMessage('hi');\n"
});
assert.ok(names.includes("VS Code Extension"), names.join(", "));
});

const route = (body = "return Response.json({ ok: true });") => `export async function GET() { ${body} }\n`;
const writeRoute = "export async function GET() { return Response.json([]); }\nexport async function POST() { return Response.json({}); }\n";

test("in a large API a lone read-only endpoint is not a Management feature", async () => {
const files: Record<string, string> = {
"package.json": JSON.stringify({ name: "big-api", dependencies: { next: "15.0.0" } }),
"app/api/sidebar/route.ts": route(),
"app/api/user-stats/route.ts": route(),
"app/api/notes/route.ts": writeRoute,
"app/api/notes/[id]/route.ts": writeRoute
};
for (const name of ["a", "b", "c", "d", "e", "f"]) files[`app/api/notes/[id]/${name}/route.ts`] = writeRoute;

const names = await featureNames(files);
assert.ok(!names.includes("Sidebar Management"), names.join(", "));
assert.ok(!names.some((name) => /user.?stat/i.test(name)), names.join(", "));
assert.ok(names.includes("Note Management"), names.join(", "));
});

test("a small API keeps its read-only resource", async () => {
const names = await featureNames({
"package.json": JSON.stringify({ name: "tiny-api", dependencies: { express: "5.0.0" } }),
"src/server.ts": "import express from 'express';\nconst app = express();\napp.get('/payments', (_req, res) => res.json([]));\nexport default app;\n"
});
assert.ok(names.includes("Payment Management"), names.join(", "));
});