diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca427d..3330635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts b/packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts index 08149b7..6424b51 100644 --- a/packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts +++ b/packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts @@ -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"; @@ -66,11 +66,13 @@ export class RouteFallbackExtractor implements IRouteFallbackExtractor { readonly name = "route-fallback"; extract(routes: RouteInfo[]): EntityInfo[] { - const entityNames = new Set(); + const apiRoutes = routes.filter((r) => r.kind === "api"); + const found = new Map }>(); - 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] @@ -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() }; + 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 // --------------------------------------------------------------------------- diff --git a/packages/cli/src/analyzers/detectors/capabilityDetector.ts b/packages/cli/src/analyzers/detectors/capabilityDetector.ts index 65f3e3d..56d441e 100644 --- a/packages/cli/src/analyzers/detectors/capabilityDetector.ts +++ b/packages/cli/src/analyzers/detectors/capabilityDetector.ts @@ -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"; @@ -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; @@ -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 @@ -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 { diff --git a/packages/cli/src/analyzers/detectors/nonResourceSegments.ts b/packages/cli/src/analyzers/detectors/nonResourceSegments.ts index 331a0df..0406373 100644 --- a/packages/cli/src/analyzers/detectors/nonResourceSegments.ts +++ b/packages/cli/src/analyzers/detectors/nonResourceSegments.ts @@ -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; diff --git a/packages/cli/src/analyzers/features/featureCandidates.ts b/packages/cli/src/analyzers/features/featureCandidates.ts index 25154bb..e73f2ed 100644 --- a/packages/cli/src/analyzers/features/featureCandidates.ts +++ b/packages/cli/src/analyzers/features/featureCandidates.ts @@ -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. @@ -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 { - 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)); } diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index 60eaf88..a752135 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -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, @@ -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}`)})(?:/[^"']*)?["']`); diff --git a/packages/cli/test/feature-candidates.test.ts b/packages/cli/test/feature-candidates.test.ts index 2c56639..198b22d 100644 --- a/packages/cli/test/feature-candidates.test.ts +++ b/packages/cli/test/feature-candidates.test.ts @@ -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"], @@ -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"], @@ -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"], }), @@ -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"], }), @@ -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"]); +}); diff --git a/packages/cli/test/feature-detection-audit.test.ts b/packages/cli/test/feature-detection-audit.test.ts index 03dbf69..658575f 100644 --- a/packages/cli/test/feature-detection-audit.test.ts +++ b/packages/cli/test/feature-detection-audit.test.ts @@ -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 = { + "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(", ")); +});