From e3020c7e54fe3a6223de2c09ce6916c08b191401 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sun, 13 Sep 2026 14:21:15 +0800 Subject: [PATCH 1/4] feat: surface feature reconciliation decisions in project map --- .../src/analyzers/features/featureDetector.ts | 32 ++++++-- packages/cli/src/analyzers/features/index.ts | 3 + .../cli/src/analyzers/pipeline/projectMap.ts | 10 ++- packages/cli/src/commands/doctor.ts | 15 ++++ packages/cli/test/analyzers.test.ts | 2 +- packages/cli/test/doctor.test.ts | 75 +++++++++++++++++++ 6 files changed, 128 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index 40bb847..1926967 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -18,6 +18,7 @@ import { reconcileFeatureCandidates, type FeatureCandidate, type FeatureCandidateSource, + type FeatureCluster, } from "./featureCandidates.js"; import { FEATURE_SIGNALS, hasAiProviderUrl, isAiProviderImport } from "../registry/index.js"; @@ -33,6 +34,12 @@ export type FeatureInfo = { evidence: string[]; }; +export type DetectFeaturesResult = { + features: FeatureInfo[]; + mergeDecisions: Array<{ candidateIds: [string, string]; outcome: string; anchors: Array<{ type: string; value: string }> }>; + rejectedCandidateIds: string[]; +}; + export type AuthSemanticRole = "auth-config" | "guard" | "provider" | "consumer"; // --------------------------------------------------------------------------- @@ -321,7 +328,7 @@ export function detectFeatures( entityGraph?: EntityGraph, capabilities?: CapabilityInfo[], fileGraph?: FileGraph -): FeatureInfo[] { +): DetectFeaturesResult { const candidates: FeatureCandidate[] = []; const scopedFiles = files.filter((file) => isArchitectureSource(file.path)); @@ -426,8 +433,13 @@ export function detectFeatures( const fileReferenceCounts = fileGraph ? countReferences(fileGraph) : {}; const reconciliation = reconcileFeatureCandidates(candidates, fileReferenceCounts); const features = projectFeatureCandidates(reconciliation.clusters); - return enrichAuthenticationFeature(features, scopedFiles, analyses) + const enriched = enrichAuthenticationFeature(features, reconciliation.clusters, scopedFiles, analyses) .sort((left, right) => left.name.localeCompare(right.name)); + return { + features: enriched, + mergeDecisions: reconciliation.mergeDecisions ?? [], + rejectedCandidateIds: reconciliation.rejectedCandidateIds + }; } function toFeatureCandidate( @@ -899,16 +911,21 @@ function escapeRegex(str: string): string { // --------------------------------------------------------------------------- function enrichAuthenticationFeature( features: FeatureInfo[], + clusters: FeatureCluster[], files: ScannedFile[], analyses: Record ): FeatureInfo[] { const authFiles = collectAuthenticationFeatureFiles(files, analyses); if (authFiles.length === 0) return features; - const existingAuth = features.find((f) => f.name === "Authentication"); - if (existingAuth) { + const authCluster = clusters.find((cluster) => + cluster.candidates.some((c) => c.source === "registry" && c.label === "Authentication") + ); + const targetName = authCluster?.canonicalLabel ?? "Authentication"; + const existingTarget = features.find((f) => f.name === targetName); + if (existingTarget) { return features.map((feature) => - feature.name === "Authentication" + feature.name === targetName ? { ...feature, files: orderAuthenticationFiles([...new Set([...feature.files, ...authFiles])]), @@ -924,7 +941,7 @@ function enrichAuthenticationFeature( return [ ...features, - createFeatureInfo("Authentication", authFiles, [ + createFeatureInfo(targetName, authFiles, [ "auth", "authentication", "login", "session", "jwt", "next-auth" ], undefined, analyses) ]; @@ -947,7 +964,8 @@ function collectAuthenticationFeatureFiles( const symbols = analysis ? analysis.symbols.map((s) => s.name) : extractSymbolsFallback(file.content); - return detectAuthenticationSemanticRole(file.path, symbols, imports, file.content) !== null; + const role = detectAuthenticationSemanticRole(file.path, symbols, imports, file.content); + return role !== null && role !== "consumer"; }) .map((file) => file.path) ); diff --git a/packages/cli/src/analyzers/features/index.ts b/packages/cli/src/analyzers/features/index.ts index 1674263..2718ce3 100644 --- a/packages/cli/src/analyzers/features/index.ts +++ b/packages/cli/src/analyzers/features/index.ts @@ -8,6 +8,7 @@ export { } from "./featureDetector.js"; export type { FeatureInfo, + DetectFeaturesResult, AuthSemanticRole, FileTier, } from "./featureDetector.js"; @@ -40,6 +41,8 @@ export type { FeatureCluster, FeatureEvidence, FeatureReconciliation, + MergeDecision, + AnchorType, ObservationReliability, } from "./featureCandidates.js"; export type { diff --git a/packages/cli/src/analyzers/pipeline/projectMap.ts b/packages/cli/src/analyzers/pipeline/projectMap.ts index 5e4a8d0..4e7b686 100644 --- a/packages/cli/src/analyzers/pipeline/projectMap.ts +++ b/packages/cli/src/analyzers/pipeline/projectMap.ts @@ -138,6 +138,10 @@ export type ProjectMap = { warnings?: string[]; /** Structured diagnostics from the analysis pipeline. */ diagnostics?: DependencyGraphDiagnostics; + featureDiagnostics?: { + mergeDecisions: Array<{ candidateIds: [string, string]; outcome: string; anchors: Array<{ type: string; value: string }> }>; + rejectedCandidateIds: string[]; + }; dependencies: Record; /** Resolved file-to-file import graph (project-relative paths). Distinct from * `dependencies`, which holds package.json npm dependency names. */ @@ -189,8 +193,9 @@ export async function createProjectMap( const capabilities = detectCapabilities(routes, entityGraph); // Step 3: Detect features — consume entityGraph + capabilities + const featureResult = detectFeatures(files, analyses, routes, database, entityGraph, capabilities, graph); const features = attachFeatureEntryPoints( - detectFeatures(files, analyses, routes, database, entityGraph, capabilities, graph), + featureResult.features, routes, entryPoints, graph, @@ -270,6 +275,9 @@ export async function createProjectMap( ...(graphDiagnostics.unresolvedAliases.length > 0 || graphDiagnostics.parserFallbacks.length > 0 ? { diagnostics: graphDiagnostics } : {}), + ...(featureResult.mergeDecisions.length > 0 || featureResult.rejectedCandidateIds.length > 0 + ? { featureDiagnostics: { mergeDecisions: featureResult.mergeDecisions, rejectedCandidateIds: featureResult.rejectedCandidateIds } } + : {}), dependencies: readPackageDependencies(files), fileGraph: graph, fileIndex diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 33ebb83..067ba0a 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -136,6 +136,21 @@ async function runDoctor( issues.push("Run devmap analyze --fresh to regenerate the snapshot."); } + if (snapshotResult.status === "valid" && snapshotResult.snapshot.featureDiagnostics) { + const risky = snapshotResult.snapshot.featureDiagnostics.mergeDecisions.filter((d) => { + if (d.outcome !== "merged" || d.anchors.length !== 1 || d.anchors[0].type !== "file") return false; + const [sourceA] = d.candidateIds[0].split(":"); + const [sourceB] = d.candidateIds[1].split(":"); + return sourceA !== sourceB; + }); + if (risky.length > 0) { + output.section("Feature merges (review disarankan)"); + for (const d of risky) { + output.warning(`${d.candidateIds[0]} <-> ${d.candidateIds[1]} via 1 file: ${d.anchors[0].value}`); + } + } + } + if (issues.length === 0) { output.success("No issues found"); } else { diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index 2867beb..017cd2e 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -428,7 +428,7 @@ test("feature detection keeps documentation and landing UI out of technical feat ) ]; - const features = detectFeatures(files, {}, []); + const { features } = detectFeatures(files, {}, []); const names = features.map((feature) => feature.name); assert.ok(names.includes("AI Integration")); diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index 381b89b..8da1ef7 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -173,6 +173,81 @@ test("doctor inspects OpenRouter with the configured user model", async () => { } }); +test("doctor shows feature merge warnings for cross-source single-file merges", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-doctor-feature-diagnostics-")); + + try { + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ name: "doctor-fixture-diagnostics", dependencies: { express: "^5.0.0" } }), + "utf8" + ); + await writeFile(join(projectRoot, "server.ts"), "export const app = true;\n", "utf8"); + const snapshot = await createProjectMap(projectRoot); + snapshot.featureDiagnostics = { + mergeDecisions: [ + { + candidateIds: ["registry:signal-Auth:auth", "frontend-page:file-page:login"], + outcome: "merged", + anchors: [{ type: "file", value: "src/pages/login.tsx" }], + reason: "Merged by shared file." + } + ], + rejectedCandidateIds: [] + }; + await saveSnapshot(projectRoot, snapshot); + + const logs = await captureOutput(() => doctorCommand({ + projectRoot, + loadConfig: async () => ({ + provider: "groq", + apiKey: "gsk_fixture", + model: "auto" + }), + inspectProvider: async () => ({ + reachable: true, + modelAvailable: true + }) + })); + + assert.match(logs, /Feature merges \(review disarankan\)/); + assert.match(logs, /registry:signal-Auth:auth <-> frontend-page:file-page:login via 1 file: src\/pages\/login\.tsx/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("doctor does not show feature merge section when no cross-source merges", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-doctor-no-diagnostics-")); + + try { + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ name: "doctor-fixture-no-diagnostics", dependencies: { express: "^5.0.0" } }), + "utf8" + ); + await writeFile(join(projectRoot, "server.ts"), "export const app = true;\n", "utf8"); + await saveSnapshot(projectRoot, await createProjectMap(projectRoot)); + + const logs = await captureOutput(() => doctorCommand({ + projectRoot, + loadConfig: async () => ({ + provider: "groq", + apiKey: "gsk_fixture", + model: "auto" + }), + inspectProvider: async () => ({ + reachable: true, + modelAvailable: true + }) + })); + + assert.doesNotMatch(logs, /Feature merges \(review disarankan\)/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + async function captureOutput(action: () => Promise): Promise { const logs: string[] = []; const originalLog = console.log; From 09cac577a442f0b0c9d747b10b2e50c5bf1ffeff Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sun, 13 Sep 2026 14:30:30 +0800 Subject: [PATCH 2/4] feat: add encryption and crypto signal descriptors to feature registry --- .../cli/src/analyzers/registry/encryption.ts | 13 +++++++++ packages/cli/src/analyzers/registry/index.ts | 2 ++ packages/cli/test/analyzers.test.ts | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 packages/cli/src/analyzers/registry/encryption.ts diff --git a/packages/cli/src/analyzers/registry/encryption.ts b/packages/cli/src/analyzers/registry/encryption.ts new file mode 100644 index 0000000..6086e41 --- /dev/null +++ b/packages/cli/src/analyzers/registry/encryption.ts @@ -0,0 +1,13 @@ +import type { SignalDescriptor } from "./types.js"; + +export const DESCRIPTORS: SignalDescriptor[] = [ + { + name: "Encryption", + category: "feature", + purpose: "Handles data-at-rest encryption and decryption.", + genericTerms: [ + "node:crypto", "crypto-js", "tweetnacl", "libsodium", "@noble/ciphers", + "aes", "aes-256-gcm", "cipher", "encrypt", "decrypt", "encryption", + ], + }, +]; diff --git a/packages/cli/src/analyzers/registry/index.ts b/packages/cli/src/analyzers/registry/index.ts index 4e8bf91..0b73ef7 100644 --- a/packages/cli/src/analyzers/registry/index.ts +++ b/packages/cli/src/analyzers/registry/index.ts @@ -16,6 +16,7 @@ import { DESCRIPTORS as rateLimitingDescriptors } from "./rate-limiting.js"; import { DESCRIPTORS as cmsContentDescriptors } from "./cms-content.js"; import { DESCRIPTORS as databaseDescriptors } from "./database.js"; import { DESCRIPTORS as firebaseDescriptors } from "./firebase.js"; +import { DESCRIPTORS as encryptionDescriptors } from "./encryption.js"; export type { SignalDescriptor }; @@ -37,6 +38,7 @@ export const REGISTRY_DESCRIPTORS: SignalDescriptor[] = [ ...cmsContentDescriptors, ...databaseDescriptors, ...firebaseDescriptors, + ...encryptionDescriptors, ]; const descriptorByName = new Map(REGISTRY_DESCRIPTORS.map((descriptor) => [descriptor.name, descriptor])); diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index 017cd2e..cc80687 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -764,3 +764,31 @@ test("snapshot inspection rejects invalid fileIndex entries", async () => { await rm(projectRoot, { recursive: true, force: true }); } }); + +test("feature detection finds Encryption from crypto imports", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-encryption-test-")); + + try { + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ name: "encryption-fixture", dependencies: {} }), + "utf8" + ); + await mkdir(join(projectRoot, "lib"), { recursive: true }); + await mkdir(join(projectRoot, "app"), { recursive: true }); + await writeFile( + join(projectRoot, "lib/encryption.ts"), + 'import { createCipheriv, createDecipheriv } from "node:crypto";\nexport function encrypt(data: string) { return data; }\n', + "utf8" + ); + await writeFile(join(projectRoot, "app/page.tsx"), 'export default function Home() { return
Hi
; }\n', "utf8"); + + const files = await scanFiles(projectRoot); + const { features } = detectFeatures(files, {}, []); + const names = features.map((f) => f.name); + + assert.ok(names.includes("Encryption"), `Expected "Encryption" in features, got: ${names.join(", ")}`); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); From f59b6a7d035737a6a58f6ec5ecae80a408be0a74 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sun, 13 Sep 2026 14:42:31 +0800 Subject: [PATCH 3/4] feat: detect trpc dependency and extract procedures as routes --- .../analyzers/detectors/capabilityDetector.ts | 4 +- packages/cli/src/analyzers/detectors/index.ts | 2 + .../src/analyzers/detectors/routeDetector.ts | 5 + .../analyzers/detectors/trpcRouteDetector.ts | 159 ++++++++++++++++++ .../test/fixtures/trpc-project/package.json | 10 ++ .../trpc-project/prisma/schema.prisma | 23 +++ .../trpc-project/src/server/routers/_app.ts | 10 ++ .../src/server/routers/message.ts | 13 ++ .../trpc-project/src/server/routers/room.ts | 13 ++ .../fixtures/trpc-project/src/server/trpc.ts | 6 + packages/cli/test/framework-routes.test.ts | 88 ++++++++++ 11 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/analyzers/detectors/trpcRouteDetector.ts create mode 100644 packages/cli/test/fixtures/trpc-project/package.json create mode 100644 packages/cli/test/fixtures/trpc-project/prisma/schema.prisma create mode 100644 packages/cli/test/fixtures/trpc-project/src/server/routers/_app.ts create mode 100644 packages/cli/test/fixtures/trpc-project/src/server/routers/message.ts create mode 100644 packages/cli/test/fixtures/trpc-project/src/server/routers/room.ts create mode 100644 packages/cli/test/fixtures/trpc-project/src/server/trpc.ts diff --git a/packages/cli/src/analyzers/detectors/capabilityDetector.ts b/packages/cli/src/analyzers/detectors/capabilityDetector.ts index 398bc33..0bffacd 100644 --- a/packages/cli/src/analyzers/detectors/capabilityDetector.ts +++ b/packages/cli/src/analyzers/detectors/capabilityDetector.ts @@ -98,8 +98,8 @@ function detectCrudCapabilities( const capabilities: CapabilityInfo[] = []; for (const [resource, { methods, files }] of resourceMap) { - const hasRead = methods.has("GET"); - const hasWrite = methods.has("POST") || methods.has("PUT") || methods.has("PATCH"); + const hasRead = methods.has("GET") || methods.has("QUERY"); + const hasWrite = methods.has("POST") || methods.has("PUT") || methods.has("PATCH") || methods.has("MUTATION"); const hasDelete = methods.has("DELETE"); if (!hasRead && !hasWrite) continue; diff --git a/packages/cli/src/analyzers/detectors/index.ts b/packages/cli/src/analyzers/detectors/index.ts index 6b00a3b..5d80fce 100644 --- a/packages/cli/src/analyzers/detectors/index.ts +++ b/packages/cli/src/analyzers/detectors/index.ts @@ -37,3 +37,5 @@ export { detectFrontendPageFeatures, detectClientRouteFeatures, } from "./frontendFeatureDetector.js"; + +export { usesTrpc, detectTrpcRoutes } from "./trpcRouteDetector.js"; diff --git a/packages/cli/src/analyzers/detectors/routeDetector.ts b/packages/cli/src/analyzers/detectors/routeDetector.ts index 27b35d0..b7c3752 100644 --- a/packages/cli/src/analyzers/detectors/routeDetector.ts +++ b/packages/cli/src/analyzers/detectors/routeDetector.ts @@ -2,6 +2,7 @@ import type { ScannedFile } from "../analysis/index.js"; import type { DetectedFramework } from "./frameworkDetector.js"; import { isArchitectureSource } from "../graph/index.js"; import { detectNestRoutes } from "./nestRouteDetector.js"; +import { usesTrpc, detectTrpcRoutes } from "./trpcRouteDetector.js"; export type RouteInfo = { path: string; @@ -29,6 +30,10 @@ export function detectRoutes( ): RouteInfo[] { const routes: RouteInfo[] = []; + if (usesTrpc(files)) { + routes.push(...detectTrpcRoutes(files, graph)); + } + if (frameworks.includes("nextjs")) { routes.push(...detectNextRoutes(files)); } diff --git a/packages/cli/src/analyzers/detectors/trpcRouteDetector.ts b/packages/cli/src/analyzers/detectors/trpcRouteDetector.ts new file mode 100644 index 0000000..c400309 --- /dev/null +++ b/packages/cli/src/analyzers/detectors/trpcRouteDetector.ts @@ -0,0 +1,159 @@ +import type { ScannedFile } from "../analysis/index.js"; +import type { RouteInfo } from "./routeDetector.js"; +import { isArchitectureSource } from "../graph/index.js"; + +type ProcedureKind = "QUERY" | "MUTATION"; + +// Known V1 limitations: +// - Only processes the first router({...}) call per file +// - Nested composition resolved max 2 levels (root → sub) + +export function usesTrpc(files: ScannedFile[]): boolean { + return files.some((f) => + f.path.endsWith("package.json") + && isArchitectureSource(f.path) + && /"@trpc\/server"/.test(f.content) + ); +} + +export function detectTrpcRoutes( + files: ScannedFile[], + graph?: Record +): RouteInfo[] { + const routes: RouteInfo[] = []; + const eligibleFiles = files.filter((f) => + isArchitectureSource(f.path) && /\.[cm]?[jt]sx?$/.test(f.path) + ); + + const routerFiles = eligibleFiles.filter((f) => + /\brouter\s*\(/.test(f.content) + ); + if (routerFiles.length === 0) return routes; + + const byPath = new Map(routerFiles.map((f) => [f.path, f])); + + const routerDefs: Array<{ + name: string; + procedures: Array<{ name: string; kind: ProcedureKind }>; + file: string; + subRouterRefs: string[]; + }> = []; + + for (const file of routerFiles) { + const defs = parseRouterFile(file.content, file.path); + routerDefs.push(...defs); + } + + const defByName = new Map(routerDefs.map((d) => [d.name, d])); + const seen = new Set(); + + for (const def of routerDefs) { + if (def.procedures.length === 0 && def.subRouterRefs.length > 0) { + for (const ref of def.subRouterRefs) { + const sub = defByName.get(ref); + if (!sub) continue; + for (const proc of sub.procedures) { + const path = `/trpc/${ref}.${proc.name}`; + const key = `${path}:${sub.file}`; + if (seen.has(key)) continue; + seen.add(key); + routes.push({ + path, + file: sub.file, + kind: "api", + methods: [proc.kind] + }); + } + } + continue; + } + + for (const proc of def.procedures) { + const path = `/trpc/${def.name}.${proc.name}`; + const key = `${path}:${def.file}`; + if (seen.has(key)) continue; + seen.add(key); + routes.push({ + path, + file: def.file, + kind: "api", + methods: [proc.kind] + }); + } + } + + return routes.sort((left, right) => + left.path.localeCompare(right.path) || left.file.localeCompare(right.file) + ); +} + +function parseRouterFile( + content: string, + filePath: string +): Array<{ + name: string; + procedures: Array<{ name: string; kind: ProcedureKind }>; + file: string; + subRouterRefs: string[]; +}> { + const results: Array<{ + name: string; + procedures: Array<{ name: string; kind: ProcedureKind }>; + file: string; + subRouterRefs: string[]; + }> = []; + + const routerCallPattern = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*router\s*\(\s*\{/g; + let routerMatch = routerCallPattern.exec(content); + if (!routerMatch) return results; + + const routerName = routerMatch[1]; + const startIdx = routerMatch.index + routerMatch[0].length; + + let depth = 1; + let endIdx = startIdx; + while (endIdx < content.length && depth > 0) { + const ch = content[endIdx]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + endIdx++; + } + + const routerBody = content.slice(startIdx - 1, endIdx); + + // Extract procedures by finding "name: publicProcedure" then scanning forward for .query or .mutation + const procStartPattern = /(\w+)\s*:\s*publicProcedure\b/g; + const procedures: Array<{ name: string; kind: ProcedureKind }> = []; + let procStartMatch = procStartPattern.exec(routerBody); + while (procStartMatch) { + const procName = procStartMatch[1]; + const afterStart = routerBody.slice(procStartMatch.index + procStartMatch[0].length); + const kindMatch = afterStart.match(/\.(query|mutation)\s*\(/); + if (kindMatch) { + procedures.push({ + name: procName, + kind: kindMatch[1].toUpperCase() as ProcedureKind + }); + } + procStartMatch = procStartPattern.exec(routerBody); + } + + // Extract sub-router references: "subRouter" used as values + const subRouterRefs: string[] = []; + const refPattern = /(\w+)\s*:\s*(\w+Router)\b/g; + let refMatch = refPattern.exec(routerBody); + while (refMatch) { + const refName = refMatch[2].replace(/Router$/, ""); + subRouterRefs.push(refName); + refMatch = refPattern.exec(routerBody); + } + + results.push({ + name: routerName.replace(/Router$/, "").replace(/^app$/, ""), + procedures, + file: filePath, + subRouterRefs + }); + + return results; +} diff --git a/packages/cli/test/fixtures/trpc-project/package.json b/packages/cli/test/fixtures/trpc-project/package.json new file mode 100644 index 0000000..ad80c28 --- /dev/null +++ b/packages/cli/test/fixtures/trpc-project/package.json @@ -0,0 +1,10 @@ +{ + "name": "trpc-fixture", + "dependencies": { + "next": "^14.0.0", + "@trpc/server": "^10.0.0", + "@trpc/client": "^10.0.0", + "zod": "^3.22.0", + "@prisma/client": "^5.0.0" + } +} diff --git a/packages/cli/test/fixtures/trpc-project/prisma/schema.prisma b/packages/cli/test/fixtures/trpc-project/prisma/schema.prisma new file mode 100644 index 0000000..d05e290 --- /dev/null +++ b/packages/cli/test/fixtures/trpc-project/prisma/schema.prisma @@ -0,0 +1,23 @@ +model User { + id Int @id @default(autoincrement()) + name String + rooms Room[] + messages Message[] +} + +model Room { + id Int @id @default(autoincrement()) + name String + userId Int + user User @relation(fields: [userId], references: [id]) + messages Message[] +} + +model Message { + id Int @id @default(autoincrement()) + content String + roomId Int + room Room @relation(fields: [roomId], references: [id]) + userId Int + user User @relation(fields: [userId], references: [id]) +} diff --git a/packages/cli/test/fixtures/trpc-project/src/server/routers/_app.ts b/packages/cli/test/fixtures/trpc-project/src/server/routers/_app.ts new file mode 100644 index 0000000..d381478 --- /dev/null +++ b/packages/cli/test/fixtures/trpc-project/src/server/routers/_app.ts @@ -0,0 +1,10 @@ +import { router } from "../trpc"; +import { roomRouter } from "./room"; +import { messageRouter } from "./message"; + +export const appRouter = router({ + room: roomRouter, + message: messageRouter, +}); + +export type AppRouter = typeof appRouter; diff --git a/packages/cli/test/fixtures/trpc-project/src/server/routers/message.ts b/packages/cli/test/fixtures/trpc-project/src/server/routers/message.ts new file mode 100644 index 0000000..e23a2ee --- /dev/null +++ b/packages/cli/test/fixtures/trpc-project/src/server/routers/message.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { router, publicProcedure } from "../trpc"; + +export const messageRouter = router({ + list: publicProcedure.query(async ({ ctx }) => { + return ctx.prisma.message.findMany(); + }), + create: publicProcedure + .input(z.object({ content: z.string(), roomId: z.number() })) + .mutation(async ({ ctx, input }) => { + return ctx.prisma.message.create({ data: input }); + }), +}); diff --git a/packages/cli/test/fixtures/trpc-project/src/server/routers/room.ts b/packages/cli/test/fixtures/trpc-project/src/server/routers/room.ts new file mode 100644 index 0000000..7d949db --- /dev/null +++ b/packages/cli/test/fixtures/trpc-project/src/server/routers/room.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { router, publicProcedure } from "../trpc"; + +export const roomRouter = router({ + list: publicProcedure.query(async ({ ctx }) => { + return ctx.prisma.room.findMany(); + }), + create: publicProcedure + .input(z.object({ name: z.string() })) + .mutation(async ({ ctx, input }) => { + return ctx.prisma.room.create({ data: input }); + }), +}); diff --git a/packages/cli/test/fixtures/trpc-project/src/server/trpc.ts b/packages/cli/test/fixtures/trpc-project/src/server/trpc.ts new file mode 100644 index 0000000..be3ed16 --- /dev/null +++ b/packages/cli/test/fixtures/trpc-project/src/server/trpc.ts @@ -0,0 +1,6 @@ +import { initTRPC } from "@trpc/server"; + +const t = initTRPC.create(); + +export const router = t.router; +export const publicProcedure = t.procedure; diff --git a/packages/cli/test/framework-routes.test.ts b/packages/cli/test/framework-routes.test.ts index 3fd5372..8c2112f 100644 --- a/packages/cli/test/framework-routes.test.ts +++ b/packages/cli/test/framework-routes.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { detectFrameworks } from "../src/analyzers/detectors/frameworkDetector.js"; import { detectRoutes } from "../src/analyzers/detectors/routeDetector.js"; @@ -488,3 +489,90 @@ test("NestJS routes surface through project map and service stays classified", a await rm(projectRoot, { recursive: true, force: true }); } }); + +// --------------------------------------------------------------------------- +// tRPC +// --------------------------------------------------------------------------- + +const trpcFixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "trpc-project"); + +test("detectRoutes extracts tRPC procedures as routes", () => { + const files = [ + createScannedFile("package.json", JSON.stringify({ + dependencies: { + next: "^14.0.0", + "@trpc/server": "^10.0.0", + "@trpc/client": "^10.0.0", + zod: "^3.22.0", + "@prisma/client": "^5.0.0" + } + })), + createScannedFile("src/server/trpc.ts", [ + 'import { initTRPC } from "@trpc/server";', + "const t = initTRPC.create();", + "export const router = t.router;", + "export const publicProcedure = t.procedure;" + ].join("\n")), + createScannedFile("src/server/routers/room.ts", [ + 'import { z } from "zod";', + 'import { router, publicProcedure } from "../trpc";', + "export const roomRouter = router({", + " list: publicProcedure.query(async ({ ctx }) => {", + " return ctx.prisma.room.findMany();", + " }),", + " create: publicProcedure", + " .input(z.object({ name: z.string() }))", + " .mutation(async ({ ctx, input }) => {", + " return ctx.prisma.room.create({ data: input });", + " }),", + "});" + ].join("\n")), + createScannedFile("src/server/routers/message.ts", [ + 'import { z } from "zod";', + 'import { router, publicProcedure } from "../trpc";', + "export const messageRouter = router({", + " list: publicProcedure.query(async ({ ctx }) => {", + " return ctx.prisma.message.findMany();", + " }),", + " create: publicProcedure", + " .input(z.object({ content: z.string(), roomId: z.number() }))", + " .mutation(async ({ ctx, input }) => {", + " return ctx.prisma.message.create({ data: input });", + " }),", + "});" + ].join("\n")), + createScannedFile("src/server/routers/_app.ts", [ + 'import { router } from "../trpc";', + 'import { roomRouter } from "./room";', + 'import { messageRouter } from "./message";', + "export const appRouter = router({", + " room: roomRouter,", + " message: messageRouter,", + "});" + ].join("\n")) + ]; + + const routes = detectRoutes(files, []); + assert.ok(routes.some((r) => r.path === "/trpc/room.list" && r.methods?.includes("QUERY"))); + assert.ok(routes.some((r) => r.path === "/trpc/room.create" && r.methods?.includes("MUTATION"))); + assert.ok(routes.some((r) => r.path === "/trpc/message.list" && r.methods?.includes("QUERY"))); + assert.ok(routes.some((r) => r.path === "/trpc/message.create" && r.methods?.includes("MUTATION"))); +}); + +test("tRPC routes surface as features through project map", async () => { + try { + const snapshot = await createProjectMap(trpcFixture); + const routePaths = snapshot.routes.map((r) => r.path).sort(); + assert.ok(routePaths.includes("/trpc/room.list"), "room.list route detected"); + assert.ok(routePaths.includes("/trpc/room.create"), "room.create route detected"); + assert.ok(routePaths.includes("/trpc/message.list"), "message.list route detected"); + assert.ok(routePaths.includes("/trpc/message.create"), "message.create route detected"); + + const names = snapshot.features.map((f) => f.name); + const hasManagement = names.some((n) => n.includes("Management")); + assert.ok(hasManagement, "at least one Management feature from tRPC entities"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return; + throw err; + } +}); From cbc024660e69bd05e154f5f99ac943ee1de8efa0 Mon Sep 17 00:00:00 2001 From: Mufacoderz Date: Sun, 13 Sep 2026 14:50:29 +0800 Subject: [PATCH 4/4] docs: prepare changelog and version for 0.4.0 --- CHANGELOG.md | 13 +++++++++++++ packages/cli/package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8485f..76d1b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ All notable changes to DevMap are documented in this file. - Public benchmark results - Feedback-driven fixes from the `0.2.0` beta +## [0.4.0] - 2026-09-13 + +### Added + +- tRPC router and procedure detection +- Encryption/crypto signal descriptor in the feature registry +- Feature reconciliation diagnostics in project map and devmap doctor + +### Changed + +- Authentication semantic-role detection participates in the same + feature candidate/reconciliation pipeline as every other detector + ## [0.3.5] - 2026-09-13 ### Fixed diff --git a/packages/cli/package.json b/packages/cli/package.json index 3ba5e1f..4d05c22 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@flaid/devmap", - "version": "0.3.5", + "version": "0.4.0", "description": "CLI that maps codebases into a reusable context layer for developers and AI agents.", "bin": { "devmap": "./dist/index.js"