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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/analyzers/detectors/capabilityDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/analyzers/detectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ export {
detectFrontendPageFeatures,
detectClientRouteFeatures,
} from "./frontendFeatureDetector.js";

export { usesTrpc, detectTrpcRoutes } from "./trpcRouteDetector.js";
5 changes: 5 additions & 0 deletions packages/cli/src/analyzers/detectors/routeDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down
159 changes: 159 additions & 0 deletions packages/cli/src/analyzers/detectors/trpcRouteDetector.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>
): 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<string>();

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;
}
32 changes: 25 additions & 7 deletions packages/cli/src/analyzers/features/featureDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
reconcileFeatureCandidates,
type FeatureCandidate,
type FeatureCandidateSource,
type FeatureCluster,
} from "./featureCandidates.js";
import { FEATURE_SIGNALS, hasAiProviderUrl, isAiProviderImport } from "../registry/index.js";

Expand All @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -899,16 +911,21 @@ function escapeRegex(str: string): string {
// ---------------------------------------------------------------------------
function enrichAuthenticationFeature(
features: FeatureInfo[],
clusters: FeatureCluster[],
files: ScannedFile[],
analyses: Record<string, FileAnalysis>
): 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])]),
Expand All @@ -924,7 +941,7 @@ function enrichAuthenticationFeature(

return [
...features,
createFeatureInfo("Authentication", authFiles, [
createFeatureInfo(targetName, authFiles, [
"auth", "authentication", "login", "session", "jwt", "next-auth"
], undefined, analyses)
];
Expand All @@ -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)
);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/analyzers/features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
} from "./featureDetector.js";
export type {
FeatureInfo,
DetectFeaturesResult,
AuthSemanticRole,
FileTier,
} from "./featureDetector.js";
Expand Down Expand Up @@ -40,6 +41,8 @@ export type {
FeatureCluster,
FeatureEvidence,
FeatureReconciliation,
MergeDecision,
AnchorType,
ObservationReliability,
} from "./featureCandidates.js";
export type {
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/analyzers/pipeline/projectMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;
/** Resolved file-to-file import graph (project-relative paths). Distinct from
* `dependencies`, which holds package.json npm dependency names. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/analyzers/registry/encryption.ts
Original file line number Diff line number Diff line change
@@ -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",
],
},
];
2 changes: 2 additions & 0 deletions packages/cli/src/analyzers/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -37,6 +38,7 @@ export const REGISTRY_DESCRIPTORS: SignalDescriptor[] = [
...cmsContentDescriptors,
...databaseDescriptors,
...firebaseDescriptors,
...encryptionDescriptors,
];

const descriptorByName = new Map(REGISTRY_DESCRIPTORS.map((descriptor) => [descriptor.name, descriptor]));
Expand Down
Loading
Loading