diff --git a/CLAUDE.md b/CLAUDE.md index 89f56a9..df821c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ devmap/ │ │ ├── mcp/ ← MCP server: tools, resources, prompts, snapshot provider, refresher + lock │ │ ├── onboarding/ ← Onboarding model and builder │ │ └── utils/ ← Config, errors, output, prompts, markdown renderer -│ ├── test/ ← 53 test files (`*.test.ts`, auto-discovered by `run-tests.ts`) + `mcp-smoke.mjs`, `package-e2e.mjs` +│ ├── test/ ← 54 test files (`*.test.ts`, auto-discovered by `run-tests.ts`) + `mcp-smoke.mjs`, `package-e2e.mjs` │ └── package.json ├── apps/web/ ← Astro marketing site (unrelated to CLI) ├── docs/ ← Public documentation @@ -86,7 +86,7 @@ Everything else (files, graph, routes, features, flows, critical files) is 100% version, generatedAt, fingerprint, projectRoot, framework, project(ProjectMetadata), stats, entryPoints, criticalFiles, routes, apiRoutes, externalServices, database?, -features, entityGraph?, capabilities?, domain?, +features, entityGraph?, capabilities?, domain?, ownership?, flows, onboarding, changeImpact, warnings?, dependencies, fileGraph(imports), ai?, fileIndex ``` @@ -129,7 +129,7 @@ Order for a non-relative specifier: `#subpath` (package.json `imports`) → tsco ## Testing -53 test files in `packages/cli/test/`. Key covered areas: +54 test files in `packages/cli/test/`. Key covered areas: - Analyzers, AI clients, agent navigation, entity extractors (Prisma, Drizzle, SQL, route hints; `capability-grounding` covers resource naming, read-only stats/report endpoints and the Payments signal) - Dependency graph: aliases, workspace packages, `#imports`, Nuxt auto-imports/aliases, entry points (`graph-resolution`, `dependency-graph-alias`, `js-ts-ecosystem`, `js-ts-frameworks`) - Feature similarity/merge, frontend features, framework routes (`framework-routes`; Remix/React Router/TanStack/Expo/Hono and bundler aliases in `js-ts-frameworks`; client-side routers, Hono factories, Next normalization and critical-file ranking in `js-ts-gaps`; SolidStart, Qwik City, Angular, Elysia, Koa, AdonisJS and constant-resolved paths in `js-ts-more-frameworks`), detector fixes @@ -153,17 +153,14 @@ cd packages/cli && pnpm build # Test cd packages/cli && pnpm test -# Lint -pnpm lint - -# Typecheck -pnpm typecheck +# Typecheck (also part of `pnpm test`) +cd packages/cli && pnpm test:types # Dev mode -pnpm dev +pnpm dev:cli # Run CLI locally -pnpm devmap +node packages/cli/dist/index.js ``` MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`. @@ -171,7 +168,7 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`. ## Important Constraints - No source files should be modified during reverse-engineering tasks -- AI errors are always non-fatal — `devmap analyze` completes even if AI fails +- AI errors are always non-fatal — `devmap analyze` completes even if AI fails. Every chat request is aborted after `DEVMAP_AI_TIMEOUT_MS` (default 120 s) so an endpoint that never answers cannot block `explain`, `flow` or the MCP server - Domain inference is cached for idempotency (`.devmap/domain-cache.json`) - Snapshot is regenerated on every `devmap analyze` run - `AGENTS.md` is never overwritten by init (append-only) @@ -184,6 +181,8 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`. ## Known Limitations +- A file with a line over 50,000 characters (a one-line bundle) is listed with low confidence and no symbols or imports: ts-morph's comment scan is quadratic on such a line + - In-memory graph, rebuilt fully on every analyze run — no incremental analysis - Regex-based heuristic analyzer is explicitly TODO'd toward tree-sitter - No AST-level analysis for non-JS/TS languages; Go/Java/C#/PHP-namespace imports produce no graph edges yet @@ -192,7 +191,7 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`. - Client router detection reads literal route arrays, JSX trees and `getParentRoute` chains; routes assembled by loops, spreads of computed values or runtime config are not followed - Elysia, Koa and Adonis detectors follow literal call chains and mounts; routers built inside factory functions, `.use()` of computed values and runtime-assembled paths are not followed. Angular reads literal `Routes` arrays and `loadChildren` imports - Feature similarity threshold (0.35) is tuned but may cause false merges -- Feature `confidence` measures parse quality, not evidence strength: `projectMap.ts` lifts any feature with two files, one parsed by ts-morph, to `high`, so a folder named `search/` reads as `high` like an imported vendor package does +- Feature `confidence` measures parse quality and evidence kind, not product importance: a vocabulary signal that only a file name or local import supports is reported at `medium` at most unless a route is served from its files (an imported vendor package keeps it `high`), but a library's own runtime folder named `cache/` is still listed as a `Caching` feature. The registry does not separate vendor terms from generic words (`vitest` next to `spec`), so a package named by a generic word still counts - Domain inference is single-shot — no conversation context - The MCP staleness check re-scans project files (cached for 10 s), so it is not free on very large repositories @@ -214,5 +213,11 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`. - Critical-file execution bonus (`projectMap.ts`) is name-based (router, controller, orchestrator, engine, dispatcher, scheduler, pipeline, registry, kernel, `commands/`) or fan-out-based (≥ 8 distinct local imports in a non-index file) — never tied to devmap's own file names - Angular, Elysia, Koa and Adonis detectors share `moduleBindings.ts` (`createModuleIndex`): imports/`require`, exports and re-exports, and `stringOf` (literals, templates, `+`, constants, `as const` objects and enums across files). Instances (Elysia/Koa) are keyed by `new` expression and only unmounted ones are walked from the top, like Hono - Feature merging (`featureCandidates.ts`): two page candidates never merge by shared file, a page never merges with a vocabulary signal unless they share the exact name, a `standalone` candidate (Home) is never merged, and a bridge candidate cannot fuse two different pages or entities. Weak signal words live in `genericTerms`; package-only words in `importTerms`; `excludePaths` removes false paths per signal +- Feature confidence has two kinds of evidence: parse quality (`calculateFeatureConfidence`, the `projectMap.ts` lift) and, for registry vocabulary signals, whether an imported npm package names the signal (`hasPackageEvidence`, `namesPackage`: a scoped package counts through its scope, `@playwright/test`, never through the name behind it, `@standard-schema/spec`). Signals without that evidence carry `nameOnly` and `detectFeatures` returns them in `nameOnlyFeatures`; `attachFeatureEntryPoints` caps them at `medium` unless a route is served from their files. An entry point is no proof, since any file nothing imports is one +- `featureStem` aliases `doc` to `documentation` (a `/docs` page and the Documentation role are one feature, kept under the concept's name) +- `project.frameworks` is the winner per category (frontend, backend) and gates route detection; `project.detectedFrameworks` lists every framework with signals (a Next.js + Expo + TanStack Start monorepo names all three; React inside Next.js is folded away) and is what `doctor`, `analyze` and `.devmap/index.json` show. Read it through `allFrameworks()`, since older snapshots lack it +- `readWorkspacePatterns` ignores patterns that name the root (`packages: ["."]`), so they do not make a monorepo +- A file TypeScript had to recover from (`parseDiagnostics`, internal to TypeScript and guarded) is `medium`, not `high`; a template-only `.vue`/`.svelte`/`.astro` file is `medium` too +- AI domain inference is grounded (`inference/domainGrounding.ts`): a domain that no entity, route or feature in the project supports is ignored with a warning, and features whose only related entities are generic (User, Account, Session) are dropped. `ownership` in the snapshot is the deterministic topology; "Personal app" wording needs `single_user_isolated`, which is inferred from missing cross-user evidence and is therefore weak - `nonResourceSegments.ts` lists operational URL segments (health, revalidate, robots.txt, login ...) that never become resources or "Management" features - Client-side routes (`clientRouteDetector.ts`) are self-gated by router imports/types and run for every project, like the other self-gating detectors; Hono factory functions are modeled as one app per call diff --git a/packages/cli/src/ai/custom.ts b/packages/cli/src/ai/custom.ts index 01799c0..9e5ebb1 100644 --- a/packages/cli/src/ai/custom.ts +++ b/packages/cli/src/ai/custom.ts @@ -1,4 +1,5 @@ import { DevmapError } from "../utils/errors.js"; +import { aiRequestSignal, isRequestTimeout } from "./requestTimeout.js"; import type { AiClient, AiCompletionRequest, @@ -124,6 +125,7 @@ export class CustomProviderClient implements AiClient { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" }, + signal: aiRequestSignal(), body: JSON.stringify({ model: request.model, messages: request.messages, @@ -135,7 +137,13 @@ export class CustomProviderClient implements AiClient { } : {}) }) }); - } catch { + } catch (error) { + if (isRequestTimeout(error)) { + throw new DevmapError( + "The custom endpoint did not answer in time.", + "Try again, or raise DEVMAP_AI_TIMEOUT_MS (milliseconds)." + ); + } throw unreachableEndpointError(); } } diff --git a/packages/cli/src/ai/groq.ts b/packages/cli/src/ai/groq.ts index cf3a9af..803b610 100644 --- a/packages/cli/src/ai/groq.ts +++ b/packages/cli/src/ai/groq.ts @@ -1,4 +1,5 @@ import { DevmapError } from "../utils/errors.js"; +import { aiRequestSignal, isRequestTimeout } from "./requestTimeout.js"; import type { AiClient, AiCompletionRequest, @@ -239,6 +240,7 @@ export class GroqClient implements AiClient { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" }, + signal: aiRequestSignal(), body: JSON.stringify({ model, messages: request.messages, @@ -250,7 +252,13 @@ export class GroqClient implements AiClient { } : {}) }) }); - } catch { + } catch (error) { + if (isRequestTimeout(error)) { + throw new DevmapError( + "Groq did not answer in time.", + "Try again, or raise DEVMAP_AI_TIMEOUT_MS (milliseconds)." + ); + } throw new DevmapError( "Could not connect to Groq.", "Check your internet connection and run devmap doctor." diff --git a/packages/cli/src/ai/openrouter.ts b/packages/cli/src/ai/openrouter.ts index 7d1ea6c..47ada8a 100644 --- a/packages/cli/src/ai/openrouter.ts +++ b/packages/cli/src/ai/openrouter.ts @@ -1,4 +1,5 @@ import { DevmapError } from "../utils/errors.js"; +import { aiRequestSignal, isRequestTimeout } from "./requestTimeout.js"; import type { AiClient, AiCompletionRequest, @@ -113,6 +114,7 @@ export class OpenRouterClient implements AiClient { "HTTP-Referer": "https://github.com/itsflaid/devmap", "X-OpenRouter-Title": "DevMap" }, + signal: aiRequestSignal(), body: JSON.stringify({ ...(models.length > 1 ? { models } : { model: models[0] }), messages: request.messages, @@ -121,7 +123,13 @@ export class OpenRouterClient implements AiClient { ...(stream ? { stream: true } : {}) }) }); - } catch { + } catch (error) { + if (isRequestTimeout(error)) { + throw new DevmapError( + "OpenRouter did not answer in time.", + "Try again, or raise DEVMAP_AI_TIMEOUT_MS (milliseconds)." + ); + } throw new DevmapError( "Could not connect to OpenRouter.", "Check your internet connection and run devmap doctor." diff --git a/packages/cli/src/ai/requestTimeout.ts b/packages/cli/src/ai/requestTimeout.ts new file mode 100644 index 0000000..84ee999 --- /dev/null +++ b/packages/cli/src/ai/requestTimeout.ts @@ -0,0 +1,15 @@ +const DEFAULT_AI_TIMEOUT_MS = 120_000; + +/** + * Abort signal for one chat request. A provider that accepts the connection and + * never answers would otherwise block `explain`, `flow` and the MCP server forever. + * `DEVMAP_AI_TIMEOUT_MS` overrides the default. + */ +export function aiRequestSignal(): AbortSignal { + const configured = Number(process.env.DEVMAP_AI_TIMEOUT_MS); + return AbortSignal.timeout(Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_AI_TIMEOUT_MS); +} + +export function isRequestTimeout(error: unknown): boolean { + return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError"); +} diff --git a/packages/cli/src/analyzers/analysis/filterEngine.ts b/packages/cli/src/analyzers/analysis/filterEngine.ts index f9b33bc..4b02ae1 100644 --- a/packages/cli/src/analyzers/analysis/filterEngine.ts +++ b/packages/cli/src/analyzers/analysis/filterEngine.ts @@ -147,6 +147,12 @@ export function shouldIgnorePath(p: string, isDirectory: boolean, projectRoot?: return true; } + // `devmap onboarding --write` creates this file after the snapshot, so counting it + // would report every project as stale right after the guide is written. + if (!isDirectory && lowerPath === "onboarding.md") { + return true; + } + if (!isDirectory && [...IGNORED_EXTENSIONS].some((ext) => lowerPath.endsWith(ext))) { return true; } diff --git a/packages/cli/src/analyzers/analysis/preprocessors/vuePreprocessor.ts b/packages/cli/src/analyzers/analysis/preprocessors/vuePreprocessor.ts index 31f1614..8f2b431 100644 --- a/packages/cli/src/analyzers/analysis/preprocessors/vuePreprocessor.ts +++ b/packages/cli/src/analyzers/analysis/preprocessors/vuePreprocessor.ts @@ -1,5 +1,37 @@ import type { ExtractedScript, LanguagePreprocessor } from "./types.js"; +function countNewlines(text: string): number { + let count = 0; + for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) count++; + return count; +} + +/** + * An SFC may carry `. + const blocks = [...content.matchAll(/]*)?\s*>([\s\S]*?)<\/script>/gi)] + .filter((block) => block[2].trim()); + if (blocks.length > 1) return mergeScriptBlocks(content, blocks); + const match = content.match(/]*)?\s*>([\s\S]*?)<\/script>/i); if (!match) return null; diff --git a/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts b/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts index 0b932fc..68acd34 100644 --- a/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts +++ b/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts @@ -22,6 +22,32 @@ import { compareText } from "../../utils/compare.js"; // Native extensions — ts-morph parses these directly, no preprocessing needed. const NATIVE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]); +// ts-morph rescans a line for comments once per statement, so a bundle written on a +// single line (a 1.7 MB `vendor.js`) takes minutes. Real source never has such a line. +const MAX_LINE_LENGTH = 50_000; + +/** + * Whether TypeScript recovered from a syntax error while parsing. The tree of such a file is + * partial, so its imports and symbols may be incomplete and it must not read as a clean parse. + * `parseDiagnostics` is internal to TypeScript; should a release drop it, every file reads as clean. + */ +function hasSyntaxErrors(sourceFile: SourceFile): boolean { + const diagnostics = (sourceFile.compilerNode as unknown as { parseDiagnostics?: unknown }).parseDiagnostics; + return Array.isArray(diagnostics) && diagnostics.length > 0; +} + +function hasOversizedLine(content: string): boolean { + if (content.length <= MAX_LINE_LENGTH) return false; + let start = 0; + while (start < content.length) { + const newline = content.indexOf("\n", start); + const end = newline === -1 ? content.length : newline; + if (end - start > MAX_LINE_LENGTH) return true; + start = end + 1; + } + return false; +} + // Preprocessors handle files that contain embedded JS/TS inside a larger format. // Order matters only for logging — each preprocessor's extensions are disjoint. const PREPROCESSORS: LanguagePreprocessor[] = [ @@ -84,7 +110,10 @@ export class TsMorphAnalyzer implements FileAnalyzer { content: string, _originalPath: string ): FileAnalysis { + if (hasOversizedLine(content)) return emptyAnalysis(this.id, "low"); + const sourceFile = this.project.createSourceFile(parsePath, content, { overwrite: true }); + const confidence: FileAnalysis["confidence"] = hasSyntaxErrors(sourceFile) ? "medium" : "high"; const lines = new LineIndex(sourceFile.getFullText()); const lineOf = (node: Node) => lines.lineAt(node.getStart()); @@ -249,7 +278,7 @@ export class TsMorphAnalyzer implements FileAnalyzer { return { analyzer: this.id, - confidence: "high", + confidence, imports: [...imports], exports, symbols, diff --git a/packages/cli/src/analyzers/detectors/frameworkDetector.ts b/packages/cli/src/analyzers/detectors/frameworkDetector.ts index f0435cb..2b9bbac 100644 --- a/packages/cli/src/analyzers/detectors/frameworkDetector.ts +++ b/packages/cli/src/analyzers/detectors/frameworkDetector.ts @@ -37,6 +37,52 @@ export function detectFramework(files: ScannedFile[]): Framework { * simply scoped per category now. */ export function detectFrameworks(files: ScannedFile[]): DetectedFramework[] { + return winnersOf(collectFrameworks(files)); +} + +/** + * Every framework the project shows signals for, in display order. Route + * detection keeps using `detectFrameworks` (one winner per category); this is + * the list a reader or an agent should see, so a monorepo with a Next.js site, + * an Expo app and a TanStack Start app names all three. + */ +export function detectAllFrameworks(files: ScannedFile[]): DetectedFramework[] { + return allOf(collectFrameworks(files)); +} + +/** Both lists from one pass over the files. */ +export function summarizeFrameworks(files: ScannedFile[]): { + frameworks: DetectedFramework[]; + detectedFrameworks: DetectedFramework[]; +} { + const detected = collectFrameworks(files); + return { frameworks: winnersOf(detected), detectedFrameworks: allOf(detected) }; +} + +function winnersOf(detected: Set): DetectedFramework[] { + return [ + FRONTEND_FRAMEWORKS.find((framework) => detected.has(framework)), + BACKEND_FRAMEWORKS.find((framework) => detected.has(framework)), + ].filter((framework): framework is FrontendFramework | BackendFramework => Boolean(framework)); +} + +// A plain UI framework (or TanStack Router) that a full framework in the same +// project already implies is not listed next to it: every Next.js app ships React. +const IMPLIED_BY: Partial> = { + react: ["nextjs", "remix", "react-router", "tanstack-start", "tanstack-router", "expo"], + vue: ["nuxt"], + svelte: ["sveltekit"], + "tanstack-router": ["tanstack-start"], +}; + +function allOf(detected: Set): DetectedFramework[] { + return [...FRONTEND_FRAMEWORKS, ...BACKEND_FRAMEWORKS].filter((framework) => + detected.has(framework) + && !(IMPLIED_BY[framework] ?? []).some((parent) => detected.has(parent)) + ); +} + +function collectFrameworks(files: ScannedFile[]): Set { const detected = new Set(); const sourceFiles = files.filter((file) => isArchitectureSource(file.path)); @@ -124,7 +170,7 @@ export function detectFrameworks(files: ScannedFile[]): DetectedFramework[] { // (Astro uses the same folder for endpoints), so it is deliberately excluded // from the Pages Router signal here. if ( - files.some((file) => /(^|\/)next\.config\.[cm]?[jt]s$/.test(file.path)) + sourceFiles.some((file) => /(^|\/)next\.config\.[cm]?[jt]s$/.test(file.path)) || sourceFiles.some((file) => // `app/routes/blog/route.tsx` is a Remix route folder, not a Next route handler. /(^|\/)(?:src\/)?app\/(?!routes\/)(?:.+\/)?(?:page|layout|route)\.[jt]sx?$/.test(file.path) @@ -169,7 +215,7 @@ export function detectFrameworks(files: ScannedFile[]): DetectedFramework[] { } // Nuxt: nuxt.config.* is as specific as next.config.* today. - if (files.some((file) => /(^|\/)nuxt\.config\.[cm]?[jt]s$/.test(file.path))) { + if (sourceFiles.some((file) => /(^|\/)nuxt\.config\.[cm]?[jt]s$/.test(file.path))) { detected.add("nuxt"); } @@ -223,10 +269,7 @@ export function detectFrameworks(files: ScannedFile[]): DetectedFramework[] { detected.add("hono"); } - return [ - FRONTEND_FRAMEWORKS.find((framework) => detected.has(framework)), - BACKEND_FRAMEWORKS.find((framework) => detected.has(framework)), - ].filter((framework): framework is FrontendFramework | BackendFramework => Boolean(framework)); + return detected; } const REMIX_PACKAGES = [ diff --git a/packages/cli/src/analyzers/detectors/index.ts b/packages/cli/src/analyzers/detectors/index.ts index 5d80fce..b54ab70 100644 --- a/packages/cli/src/analyzers/detectors/index.ts +++ b/packages/cli/src/analyzers/detectors/index.ts @@ -1,6 +1,8 @@ export { detectFramework, detectFrameworks, + detectAllFrameworks, + summarizeFrameworks, } from "./frameworkDetector.js"; export type { Framework, diff --git a/packages/cli/src/analyzers/features/featureCandidates.ts b/packages/cli/src/analyzers/features/featureCandidates.ts index 6c2c1df..e614a2f 100644 --- a/packages/cli/src/analyzers/features/featureCandidates.ts +++ b/packages/cli/src/analyzers/features/featureCandidates.ts @@ -37,6 +37,11 @@ export interface FeatureCandidate { * absorb them through shared files. */ standalone?: boolean; + /** + * A vocabulary signal whose only support is a word in a file name or a local import. No + * package it imports confirms it, so on its own it is a hint and never a high-confidence feature. + */ + nameOnly?: boolean; /** Internal compatibility payload used while the public snapshot remains V1. */ projection?: FeatureInfo; } @@ -346,6 +351,10 @@ function createCluster( } function compareCanonicalCandidates(left: FeatureCandidate, right: FeatureCandidate): number { + // "/docs" and the Documentation role are one feature: it keeps the concept's name, not the URL's. + const aliased = Number(isAliasedName(left.label)) - Number(isAliasedName(right.label)); + if (aliased !== 0) return aliased; + const source = SOURCE_PRIORITY[right.source] - SOURCE_PRIORITY[left.source]; if (source !== 0) return source; @@ -417,11 +426,23 @@ function isSignalCandidate(candidate: FeatureCandidate): boolean { return candidate.source === "registry" && !candidate.id.includes(":role-"); } +// A page is named after its URL segment and a role after the concept: /docs is "Documentation". +const NAME_ALIASES: Record = { doc: "documentation" }; + /** "Payment Management" and "Payments" are the same name. */ -function featureStem(label: string): string { +function plainStem(label: string): string { return normalizeAnchor(label).replace(/ management$/, "").replace(/s$/, ""); } +function featureStem(label: string): string { + const stem = plainStem(label); + return NAME_ALIASES[stem] ?? stem; +} + +function isAliasedName(label: string): boolean { + return plainStem(label) in NAME_ALIASES; +} + function sameName(left: FeatureCandidate, right: FeatureCandidate): boolean { return featureStem(left.label) === featureStem(right.label); } diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index 76941c4..d37fe31 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -41,6 +41,11 @@ export type DetectFeaturesResult = { features: FeatureInfo[]; mergeDecisions: Array<{ candidateIds: [string, string]; outcome: string; anchors: Array<{ type: string; value: string }> }>; rejectedCandidateIds: string[]; + /** + * Features whose only support is a vocabulary word in a file name or local import. Unless a route + * or entry point reaches them, the pipeline reports them at medium confidence at most. + */ + nameOnlyFeatures: string[]; }; export type AuthSemanticRole = "auth-config" | "guard" | "provider" | "consumer"; @@ -159,6 +164,7 @@ function isFeatureEvidenceFile(path: string): boolean { || /\.sql$/.test(lower) || /\/generated\//.test(lower) || /\.generated\./.test(lower) + || /\.(?:svg|png|jpe?g|gif|webp|avif|ico|icns|woff2?|ttf|otf|eot|mp[34]|webm|pdf)$/.test(lower) || /\/prisma\/schema\.prisma$/.test(lower) || /^prisma\/schema\.prisma$/.test(lower) ); @@ -424,13 +430,15 @@ export function detectFeatures( } if (evidence.length > 0) { - candidates.push(toFeatureCandidate("registry", `signal-${signal.name}`, createFeatureInfo( + const candidate = toFeatureCandidate("registry", `signal-${signal.name}`, createFeatureInfo( signal.name, evidence, groundedSignalTerms(signal, evidence, analyses), signal.purpose, analyses - ))); + )); + if (!signal.importOnly && !hasPackageEvidence(signal, evidence, analyses)) candidate.nameOnly = true; + candidates.push(candidate); } } @@ -469,10 +477,16 @@ export function detectFeatures( const features = projectFeatureCandidates(reconciliation.clusters); const enriched = enrichAuthenticationFeature(features, reconciliation.clusters, scopedFiles, analyses) .sort((left, right) => compareText(left.name, right.name)); + const nameOnlyFeatures = enriched + .filter((feature) => reconciliation.clusters.some((cluster) => + cluster.canonicalLabel === feature.name && cluster.candidates.every((candidate) => candidate.nameOnly) + )) + .map((feature) => feature.name); return { features: enriched, mergeDecisions: reconciliation.mergeDecisions ?? [], - rejectedCandidateIds: reconciliation.rejectedCandidateIds + rejectedCandidateIds: reconciliation.rejectedCandidateIds, + nameOnlyFeatures }; } @@ -928,6 +942,42 @@ function matchesSignal( ); } +/** An npm package specifier: not relative, not an alias such as `@/x`, `~/x`, `#x` or `$lib/x`, not `node:`. */ +function isPackageSpecifier(specifier: string): boolean { + return /^(?:@[a-z0-9][\w.-]*\/[\w.-]+|[a-z0-9][\w.-]*)(?:\/|$)/i.test(specifier); +} + +/** + * Whether a package the evidence imports names the signal. A word in a file name + * ("cache.js", "session-storage.js") or a local import says what the file is called, + * not that the project does caching or sessions; an imported `ioredis` or `next-auth` does. + */ +function hasPackageEvidence( + signal: Pick, + evidence: string[], + analyses: Record +): boolean { + const terms = [...signal.terms, ...(signal.importTerms ?? [])]; + return evidence.some((path) => + analyses[path]?.imports.some((specifier) => + isPackageSpecifier(specifier) && terms.some((term) => namesPackage(specifier, term)) + ) + ); +} + +/** + * A package is named by its scope or its own name, never by the name behind a scope: + * `@playwright/test` is Playwright, while `@standard-schema/spec` is not "spec tests". + * A term that is itself scoped (`@upstash/redis`) must match the package exactly. + */ +function namesPackage(specifier: string, term: string): boolean { + const lower = specifier.toLowerCase(); + if (term.startsWith("@")) return lower === term || lower.startsWith(`${term}/`); + const scope = lower.match(/^@([^/]+)\//)?.[1]; + const identity = scope ?? lower.split("/")[0]; + return new RegExp(`(?:^|[._-])${escapeRegex(term)}(?:[._-]|$)`).test(identity); +} + /** * The registry lists every vendor and keyword a signal knows about. Only the * ones this project's evidence really contains belong in its search terms, or an diff --git a/packages/cli/src/analyzers/graph/entryPoints.ts b/packages/cli/src/analyzers/graph/entryPoints.ts index 9e26e28..5d66e95 100644 --- a/packages/cli/src/analyzers/graph/entryPoints.ts +++ b/packages/cli/src/analyzers/graph/entryPoints.ts @@ -70,6 +70,24 @@ const ENTRY_PATTERN_SCORES: Array<{ pattern: RegExp; score: number }> = [ { pattern: /(^|\/)(index|main)\.[cm]?[jt]sx?$/, score: 5 }, ]; +// vite.config.ts, vitest.config.e2e.ts, app.config.ts, eslint.setup.js, .eslintrc.js ... +const TOOLING_CONFIG = /(^|\/)[\w.-]*\.(?:config|setup)(?:\.[\w-]+)*\.[cm]?[jt]s$|(^|\/)\.[\w.-]+rc\.[cm]?[jt]s$/; + +/** + * Angular's `app.config.ts` is an entry; Expo's or Expo-like `app.config.ts` is + * tooling. The Angular one always sits next to its component, routes or module. + */ +function isAngularAppConfig(path: string, graph: FileGraph): boolean { + if (!/(^|\/)app\.config\.ts$/.test(path)) return false; + const slash = path.lastIndexOf("/"); + const directory = slash === -1 ? "" : path.slice(0, slash + 1); + return ["app.component.ts", "app.routes.ts", "app.module.ts"].some((name) => `${directory}${name}` in graph); +} + +function isToolingConfig(path: string, graph: FileGraph): boolean { + return TOOLING_CONFIG.test(path) && !isAngularAppConfig(path, graph); +} + function entryPointScore(path: string): number { for (const { pattern, score } of ENTRY_PATTERN_SCORES) { if (pattern.test(path)) return score; @@ -107,6 +125,7 @@ export function detectEntryPoints(graph: FileGraph, manifestEntries: ManifestEnt const declared = new Map(); for (const entry of manifestEntries) { if (!(entry.path in graph) || !isSourceFile(entry.path) || !isArchitectureSource(entry.path)) continue; + if (isToolingConfig(entry.path, graph)) continue; if (entry.kind === "main" && imported.has(entry.path)) continue; const score = MANIFEST_SCORES[entry.kind]; declared.set(entry.path, Math.min(score, declared.get(entry.path) ?? score)); @@ -117,6 +136,7 @@ export function detectEntryPoints(graph: FileGraph, manifestEntries: ManifestEnt return Object.keys(graph) .filter((path) => isSourceFile(path) && !isDeclarationFile(path)) .filter((path) => isArchitectureSource(path) && !isGeneratedFile(path)) + .filter((path) => !isToolingConfig(path, graph)) .filter((path) => { if (declared.has(path)) return true; const unimportedWithImports = !imported.has(path) && (graph[path]?.length ?? 0) > 0; diff --git a/packages/cli/src/analyzers/graph/packageManifests.ts b/packages/cli/src/analyzers/graph/packageManifests.ts index b263b42..01bf651 100644 --- a/packages/cli/src/analyzers/graph/packageManifests.ts +++ b/packages/cli/src/analyzers/graph/packageManifests.ts @@ -369,7 +369,7 @@ function scriptFileTokens(command: string): string[] { } function isConfigFile(path: string): boolean { - return /(^|\/)[\w.-]*\.(?:config|setup)\.[cm]?[jt]s$/.test(path) || /(^|\/)\.[\w.-]+rc\.[cm]?[jt]s$/.test(path); + return /(^|\/)[\w.-]*\.(?:config|setup)(?:\.[\w-]+)*\.[cm]?[jt]s$/.test(path) || /(^|\/)\.[\w.-]+rc\.[cm]?[jt]s$/.test(path); } function joinPath(directory: string, path: string): string { diff --git a/packages/cli/src/analyzers/inference/domainGrounding.ts b/packages/cli/src/analyzers/inference/domainGrounding.ts new file mode 100644 index 0000000..ec578bb --- /dev/null +++ b/packages/cli/src/analyzers/inference/domainGrounding.ts @@ -0,0 +1,82 @@ +import type { DomainInferenceResult } from "./domainInference.js"; + +export type DomainEvidence = { + projectName: string; + entityNames: string[]; + featureNames: string[]; + routePaths: string[]; + capabilityKinds: string[]; +}; + +export type DomainGrounding = { + grounded: boolean; + matched: string[]; + unsupported: string[]; +}; + +// Words that fit almost any project; a claim made only of these says nothing checkable. +const FILLER = new Set([ + "a", "an", "and", "the", "for", "of", "to", "in", "on", "with", "by", "from", "or", "at", "as", + "is", "are", "be", "it", "its", "this", "that", "these", "those", "into", "over", "via", "per", + "their", "your", "our", "can", "will", "also", "both", "any", "all", "each", "more", + "system", "platform", "application", "app", "service", "manager", "management", "tool", "portal", + "web", "website", "site", "online", "digital", "based", "solution", "software", "project", + "suite", "hub", "engine", "framework", "starter", "template", "boilerplate", "clone", + "manage", "handle", "allow", "let", "enable", "provide", "help", "create", "store", "view", + "track", "build", "use", "support", "include", "offer", "built", + "user", "account", "session", "token", "verification", "role", "permission", "admin", + "profile", "setting", "dashboard", "login", "register", "signup", "sign", "auth", + "authentication", "data", "content", "item", "page", "feature", "file", "api", "next", + "react", "vue", "svelte", "nextjs", "typescript", "javascript", "node", "full", "stack", + "people", "person", "customer", "team", "company", "business", "organization", "everyone", "anyone" +]); + +// Fewer claimed words than this cannot be judged against the project. +const MIN_CLAIMED_WORDS = 2; + +function stem(word: string): string { + if (word.length > 4 && word.endsWith("ies")) return `${word.slice(0, -3)}y`; + if (word.length > 3 && word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1); + return word; +} + +function tokenize(text: string): string[] { + return text + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z]+/) + .filter((word) => word.length >= 3) + .map(stem); +} + +function claimTokens(text: string): string[] { + const filler = new Set([...FILLER].map(stem)); + return [...new Set(tokenize(text).filter((word) => !filler.has(word)))]; +} + +/** + * The model only sees entity names and capability kinds, so a domain that shares no + * word with the entities, routes or features of the project is a guess, not an inference. + */ +export function groundDomain(result: DomainInferenceResult, evidence: DomainEvidence): DomainGrounding { + const known = new Set(tokenize([ + evidence.projectName, + ...evidence.entityNames, + ...evidence.featureNames, + ...evidence.routePaths, + ...evidence.capabilityKinds + ].join(" "))); + + const domainTokens = claimTokens(result.domain); + const summaryTokens = claimTokens(result.summary).filter((word) => !domainTokens.includes(word)); + const matchedDomain = domainTokens.filter((word) => known.has(word)); + const matchedSummary = summaryTokens.filter((word) => known.has(word)); + const matched = [...matchedDomain, ...matchedSummary]; + const claimed = [...domainTokens, ...summaryTokens]; + + const grounded = claimed.length < MIN_CLAIMED_WORDS + || matchedDomain.length > 0 + || matchedSummary.length >= (domainTokens.length === 0 ? 1 : 2); + + return { grounded, matched, unsupported: claimed.filter((word) => !known.has(word)) }; +} diff --git a/packages/cli/src/analyzers/inference/domainInference.ts b/packages/cli/src/analyzers/inference/domainInference.ts index 720ceec..f4a18d3 100644 --- a/packages/cli/src/analyzers/inference/domainInference.ts +++ b/packages/cli/src/analyzers/inference/domainInference.ts @@ -256,6 +256,12 @@ const domainResultSchema = z.object({ * survives with entities that really exist in the static analysis, because an * AI feature has no files of its own to be grounded in. */ +// Every app has these; a feature that only touches them is not specific to the domain. +const GENERIC_ENTITIES = new Set([ + "user", "users", "account", "accounts", "session", "sessions", "verificationtoken", + "verification", "token", "role", "roles", "permission", "permissions", "profile" +]); + function sanitizeDomainResult( value: unknown, input: DomainInferenceInput @@ -275,7 +281,7 @@ function sanitizeDomainResult( .map((name) => knownEntities.get(name.trim().toLowerCase())) .filter((name): name is string => name !== undefined) )]; - if (relatedEntities.length === 0) continue; + if (!relatedEntities.some((name) => !GENERIC_ENTITIES.has(name.toLowerCase()))) continue; domainFeatures.push({ name: feature.data.name, diff --git a/packages/cli/src/analyzers/inference/index.ts b/packages/cli/src/analyzers/inference/index.ts index f7359e2..1e217b8 100644 --- a/packages/cli/src/analyzers/inference/index.ts +++ b/packages/cli/src/analyzers/inference/index.ts @@ -8,6 +8,8 @@ export type { DomainFeature, DomainInferenceInput, } from "./domainInference.js"; +export { groundDomain } from "./domainGrounding.js"; +export type { DomainEvidence, DomainGrounding } from "./domainGrounding.js"; export { classifyOwnershipTopology, } from "./ownershipTopology.js"; diff --git a/packages/cli/src/analyzers/pipeline/index.ts b/packages/cli/src/analyzers/pipeline/index.ts index c100c02..8d45a7e 100644 --- a/packages/cli/src/analyzers/pipeline/index.ts +++ b/packages/cli/src/analyzers/pipeline/index.ts @@ -25,6 +25,7 @@ export { export { detectProjectMetadata, + allFrameworks, languageLabel, } from "./projectMetadata.js"; export type { diff --git a/packages/cli/src/analyzers/pipeline/projectMap.ts b/packages/cli/src/analyzers/pipeline/projectMap.ts index 1828d2c..f3a0915 100644 --- a/packages/cli/src/analyzers/pipeline/projectMap.ts +++ b/packages/cli/src/analyzers/pipeline/projectMap.ts @@ -6,7 +6,7 @@ import { analyzeFiles } from "./analyzerRegistry.js"; import { detectProjectMetadata, type ProjectMetadata } from "./projectMetadata.js"; import { detectDatabase, type DatabaseInfo, - detectFramework, detectFrameworks, type Framework, + summarizeFrameworks, type Framework, detectRoutes, type RouteInfo, detectExternalServices, detectCapabilities, type CapabilityInfo, @@ -44,8 +44,11 @@ import { import { inferDomain, buildDomainInferenceInput, + classifyOwnershipTopology, domainFeaturesToFeatureInfo, + groundDomain, type DomainInferenceResult, + type OwnershipPattern, } from "../inference/index.js"; import { compareText } from "../../utils/compare.js"; @@ -135,6 +138,11 @@ export type ProjectMap = { capabilities?: CapabilityInfo[]; /** AI-inferred domain understanding — present kalau AI inference berhasil */ domain?: DomainInferenceResult; + /** Who owns the data, read from the entity graph (static, never from AI). */ + ownership?: { + pattern: OwnershipPattern; + crossUserFields: string[]; + }; flows: FlowInfo[]; onboarding: { recommendedPath: string[]; @@ -191,13 +199,14 @@ export async function createProjectMap( const aliasMappings = await loadAliasMappings(projectRoot); const { graph, diagnostics: graphDiagnostics } = buildDependencyGraph(files, analyses, aliasMappings); const references = countReferences(graph); - const detectedFramework = detectFramework(files); - const frameworks = detectFrameworks(files); + const { frameworks, detectedFrameworks } = summarizeFrameworks(files); + const detectedFramework: Framework = frameworks[0] ?? "unknown"; const project = detectProjectMetadata( projectRoot, detectedFramework, files, - frameworks + frameworks, + detectedFrameworks ); const framework = project.framework; const entryPoints = detectEntryPoints( @@ -222,7 +231,8 @@ export async function createProjectMap( routes, entryPoints, graph, - analyses + analyses, + new Set(featureResult.nameOnlyFeatures) ); activeSpinner.succeed(`Detected ${features.length} features`); @@ -230,6 +240,7 @@ export async function createProjectMap( // Kirim structured metadata ke AI, dapat domain summary + domain-specific features. // Kalau gagal atau callAI tidak ada, static features tetap lengkap. let domain: DomainInferenceResult | undefined; + const domainWarnings: string[] = []; if (callAI) { activeSpinner = output.spinner("Inferring domain with AI"); const inferenceInput = buildDomainInferenceInput( @@ -240,7 +251,20 @@ export async function createProjectMap( routes.length ); const result = await inferDomain(inferenceInput, callAI, projectRoot); - if (result) { + const grounding = result + ? groundDomain(result, { + projectName: project.name, + entityNames: entityGraph.entityNames, + featureNames: features.map((feature) => feature.name), + routePaths: routes.map((route) => route.path), + capabilityKinds: capabilities.map((capability) => capability.kind) + }) + : undefined; + if (result && grounding && !grounding.grounded) { + domainWarnings.push( + `AI domain "${result.domain}" was ignored: no entity, route or feature in the project supports it.` + ); + } else if (result) { // Merge domain-specific features ke features list. // Pakai similarity engine — bukan name equality — sehingga // "Customizable Plans" tidak duplicate "Plan Management" yang sudah ada. @@ -257,7 +281,11 @@ export async function createProjectMap( domain = undefined; } } - activeSpinner.succeed(domain ? `Domain inferred: ${domain.domain}` : "Domain inference skipped"); + activeSpinner.succeed( + domain ? `Domain inferred: ${domain.domain}` + : domainWarnings.length > 0 ? "Domain inference rejected: not supported by the project" + : "Domain inference skipped" + ); } activeSpinner = output.spinner("Building project index"); @@ -302,12 +330,13 @@ export async function createProjectMap( ...(entityGraph.source !== "empty" ? { entityGraph } : {}), ...(capabilities.length > 0 ? { capabilities } : {}), ...(domain ? { domain } : {}), + ...(entityGraph.source !== "empty" ? { ownership: ownershipOf(entityGraph) } : {}), flows, onboarding: { recommendedPath: buildOnboardingPath(files, entryPoints, criticalFiles, fileIndex) }, changeImpact: buildChangeImpact(fileIndex, features, flows, graph), - warnings: detectAnalysisWarnings(files, entryPoints, criticalFiles, features), + warnings: [...detectAnalysisWarnings(files, entryPoints, criticalFiles, features), ...domainWarnings], ...(graphDiagnostics.unresolvedAliases.length > 0 || graphDiagnostics.parserFallbacks.length > 0 ? { diagnostics: graphDiagnostics } : {}), @@ -324,6 +353,11 @@ export async function createProjectMap( } } +function ownershipOf(entityGraph: EntityGraph): NonNullable { + const { pattern, crossUserFields } = classifyOwnershipTopology(entityGraph, entityGraph.relations); + return { pattern, crossUserFields: crossUserFields.slice(0, 5) }; +} + function createAgentInstructions(): ProjectMap["agentInstructions"] { return { navigationPolicy: "index-first", @@ -687,7 +721,8 @@ function attachFeatureEntryPoints( routes: RouteInfo[], entryPoints: string[], graph: Record, - analyses?: Record + analyses?: Record, + nameOnlyFeatures: ReadonlySet = new Set() ): FeatureInfo[] { return features.map((feature) => { const relatedRouteEntries = routes @@ -708,6 +743,10 @@ function attachFeatureEntryPoints( : feature.files; const entryPoint = chooseFeatureEntryPoint(feature.name, orderedFiles, relatedEntries, routes, graph); const businessFlow = buildFeatureBusinessFlow(feature.name, entryPoint, graph, orderedFiles); + // A word in a file name is a hint; only a route served from the feature's files, + // or an imported package, turns it into evidence. An entry point is no proof: any file + // that nothing imports is one. + const hintOnly = nameOnlyFeatures.has(feature.name) && relatedRouteEntries.length === 0; const hasHighQualityEvidence = analyses ? feature.evidence.some((path) => analyses[path]?.confidence === "high") : false; @@ -718,9 +757,11 @@ function attachFeatureEntryPoints( ...(entryPoint ? { entryPoint } : {}), entryPoints: relatedEntries, businessFlow, - confidence: hasHighQualityEvidence && (feature.files.length >= 2 || relatedEntries.length > 0) - ? "high" - : feature.confidence + confidence: hintOnly + ? (feature.confidence === "low" ? "low" : "medium") + : hasHighQualityEvidence && (feature.files.length >= 2 || relatedEntries.length > 0) + ? "high" + : feature.confidence }; }); } diff --git a/packages/cli/src/analyzers/pipeline/projectMetadata.ts b/packages/cli/src/analyzers/pipeline/projectMetadata.ts index 5ad77d0..3baa1bf 100644 --- a/packages/cli/src/analyzers/pipeline/projectMetadata.ts +++ b/packages/cli/src/analyzers/pipeline/projectMetadata.ts @@ -54,7 +54,10 @@ export type ProjectMetadata = { name: string; root: string; framework: Framework; + /** The winner of each category (frontend, backend); route detection is gated on it. */ frameworks: DetectedFramework[]; + /** Every framework the project shows signals for, so a monorepo names all its frontends. Absent in older snapshots. */ + detectedFrameworks?: DetectedFramework[]; language: ProjectLanguage; packageManager: PackageManager; projectTypes: ProjectType[]; @@ -62,11 +65,17 @@ export type ProjectMetadata = { description?: string; }; +/** Every framework to show a reader; falls back to the winners for snapshots written before the field existed. */ +export function allFrameworks(project: Pick): DetectedFramework[] { + return project.detectedFrameworks ?? project.frameworks; +} + export function detectProjectMetadata( projectRoot: string, framework: Framework, files: ScannedFile[], - frameworks: DetectedFramework[] = framework === "unknown" ? [] : [framework] + frameworks: DetectedFramework[] = framework === "unknown" ? [] : [framework], + detectedFrameworks: DetectedFramework[] = frameworks ): ProjectMetadata { const manifests = readPackageManifests(files); const workspacePatterns = readWorkspacePatterns(files, manifests); @@ -85,6 +94,7 @@ export function detectProjectMetadata( root: projectRoot, framework: primaryFramework, frameworks, + detectedFrameworks, language: detectLanguage(files), packageManager: detectPackageManager(projectRoot, manifests), projectTypes, @@ -294,7 +304,13 @@ function readWorkspacePatterns(files: ScannedFile[], manifests: PackageManifest[ return [...fromManifest.filter((item): item is string => typeof item === "string"), ...fromPnpm] .map((pattern) => pattern.trim()) - .filter((pattern) => pattern.length > 0 && !pattern.startsWith("!")); + .filter((pattern) => pattern.length > 0 && !pattern.startsWith("!") && !isRootPattern(pattern)); +} + +/** `packages: ["."]` names the root itself, which is the project and not a member of it. */ +function isRootPattern(pattern: string): boolean { + const directory = pattern.replace(/^\.\//, "").replace(/\/+$/, "").replace(/\/package\.json$/, ""); + return directory === "" || directory === "."; } function readPnpmWorkspacePackages(content: string): string[] { diff --git a/packages/cli/src/cache/agentNavigation.ts b/packages/cli/src/cache/agentNavigation.ts index f78ff82..9b490cf 100644 --- a/packages/cli/src/cache/agentNavigation.ts +++ b/packages/cli/src/cache/agentNavigation.ts @@ -1,7 +1,7 @@ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { FeatureInfo } from "../analyzers/features/index.js"; -import { languageLabel, type ProjectMap } from "../analyzers/pipeline/index.js"; +import { allFrameworks, languageLabel, type ProjectMap } from "../analyzers/pipeline/index.js"; import { compareText } from "../utils/compare.js"; export type AgentNavigationWriteResult = { @@ -48,7 +48,7 @@ export async function writeAgentNavigationFiles( project: { name: snapshot.project.name, framework: snapshot.project.framework, - frameworks: snapshot.project.frameworks, + frameworks: allFrameworks(snapshot.project), language: snapshot.project.language, packageManager: snapshot.project.packageManager, projectTypes: snapshot.project.projectTypes, diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index fdf15d8..0dc712c 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -8,7 +8,7 @@ import { import { buildAnalyzeMessages } from "../ai/prompts.js"; import { enrichSnapshotWithAi } from "../ai/snapshotEnrichment.js"; import type { AiClient } from "../ai/types.js"; -import { createProjectMap } from "../analyzers/pipeline/index.js"; +import { allFrameworks, createProjectMap } from "../analyzers/pipeline/index.js"; import { acquireLock } from "../cache/analysisLock.js"; import { inspectSnapshot, saveSnapshot } from "../cache/snapshot.js"; import { writeAgentNavigationFiles } from "../cache/agentNavigation.js"; @@ -196,7 +196,7 @@ function printSnapshot( output.keyValue("Framework", snapshot.project.framework); output.keyValue( "Workspace Frameworks", - snapshot.project.frameworks.join(", ") || "none detected" + allFrameworks(snapshot.project).join(", ") || "none detected" ); output.keyValue("Language", snapshot.project.language); output.keyValue("Package Manager", snapshot.project.packageManager); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index cc27b57..34f3dc6 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -6,11 +6,8 @@ import { type ProviderInspection } from "../ai/provider.js"; import { scanFiles } from "../analyzers/analysis/index.js"; -import { - detectFramework, - detectFrameworks -} from "../analyzers/detectors/index.js"; -import { createProjectFingerprint, detectProjectMetadata } from "../analyzers/pipeline/index.js"; +import { summarizeFrameworks } from "../analyzers/detectors/index.js"; +import { allFrameworks, createProjectFingerprint, detectProjectMetadata } from "../analyzers/pipeline/index.js"; import { inspectSnapshot } from "../cache/snapshot.js"; import type { McpHealth } from "../mcp/health.js"; import { @@ -65,9 +62,9 @@ async function runDoctor( inspectSnapshot(projectRoot), scanFiles(projectRoot) ]); - const framework = detectFramework(files); - const frameworks = detectFrameworks(files); - const project = detectProjectMetadata(projectRoot, framework, files, frameworks); + const { frameworks, detectedFrameworks } = summarizeFrameworks(files); + const framework = frameworks[0] ?? "unknown"; + const project = detectProjectMetadata(projectRoot, framework, files, frameworks, detectedFrameworks); const modelSource = config && localConfig?.model ? "project override" : "global"; @@ -89,7 +86,7 @@ async function runDoctor( output.keyValue("OS", `${platform()}/${arch()}`); output.keyValue("Project", project.name); output.keyValue("Framework", project.framework); - output.keyValue("Workspace Frameworks", project.frameworks.join(", ") || "none detected"); + output.keyValue("Workspace Frameworks", allFrameworks(project).join(", ") || "none detected"); output.keyValue("Package Manager", project.packageManager); output.keyValue("Provider", config?.provider ?? "not configured"); output.keyValue("Config", config ? "exists" : "missing"); diff --git a/packages/cli/src/commands/onboarding.ts b/packages/cli/src/commands/onboarding.ts index 1e448ce..9efce95 100644 --- a/packages/cli/src/commands/onboarding.ts +++ b/packages/cli/src/commands/onboarding.ts @@ -4,6 +4,7 @@ import type { ProjectMap } from "../analyzers/pipeline/index.js"; import { isSnapshotStale, readSnapshotOrThrow } from "../cache/snapshot.js"; import { buildOnboardingModel } from "../onboarding/modelBuilder.js"; import type { OnboardingLanguage, OnboardingModel, ConceptualStep, FeatureSummary, StartHereItem } from "../onboarding/model.js"; +import { DevmapError } from "../utils/errors.js"; import { output, withJsonOutput } from "../utils/output.js"; import { createPrompt, type Prompt } from "../utils/prompt.js"; @@ -282,6 +283,12 @@ async function resolveOnboardingLanguage(options: OnboardingOptions): Promise !SUPPORTING_FEATURES.has(feature.name.toLowerCase())) + .sort((a, b) => b.files.length - a.files.length || compareText(a.name, b.name)); + const supporting = snapshot.features.filter((feature) => SUPPORTING_FEATURES.has(feature.name.toLowerCase())); + return [...domain, ...supporting]; +} + function buildTagline(snapshot: ProjectMap, language: OnboardingLanguage): string { if (snapshot.domain?.summary && snapshot.domain.summary.trim().length > 0) { const firstSentence = snapshot.domain.summary.split(/\.\s+/)[0]; @@ -40,7 +58,7 @@ function buildTagline(snapshot: ProjectMap, language: OnboardingLanguage): strin } const ownershipHint = resolveOwnershipHint(snapshot, language); - const primaryFeature = snapshot.features[0]; + const primaryFeature = rankFeatures(snapshot)[0]; if (ownershipHint && primaryFeature) { return language === "id" @@ -54,10 +72,10 @@ function buildTagline(snapshot: ProjectMap, language: OnboardingLanguage): strin : `${snapshot.project.framework} app for ${primaryFeature.name.toLowerCase()}.`; } - const fw = snapshot.project.framework !== "unknown" ? snapshot.project.framework : "application"; + const known = snapshot.project.framework !== "unknown" ? snapshot.project.framework : null; return language === "id" - ? `${snapshot.project.name || "Project"} — aplikasi ${fw}.` - : `${snapshot.project.name || "Project"} — ${fw} application.`; + ? `${snapshot.project.name || "Project"} — aplikasi${known ? ` ${known}` : ""}.` + : `${snapshot.project.name || "Project"} — ${known ? `${known} ` : ""}application.`; } /** @@ -78,17 +96,17 @@ function resolveOwnershipHint(snapshot: ProjectMap, language: OnboardingLanguage const hasRealtime = caps.some((c) => c.kind === "real-time"); const hasSocial = caps.some((c) => c.kind === "social"); - const domainPattern = (snapshot.domain as { ownershipPattern?: string })?.ownershipPattern; + const pattern = snapshot.ownership?.pattern; - if (domainPattern === "single_user_isolated" || (!hasSharing && !hasRealtime && !hasSocial)) { + if (pattern === "single_user_isolated") { return language === "id" ? "Aplikasi personal" : "Personal app"; } - if (domainPattern === "direct_messaging") { + if (pattern === "direct_messaging") { return language === "id" ? "Platform pesan langsung" : "Direct messaging platform"; } - if (hasSharing || hasRealtime || hasSocial) { + if (pattern === "shared_access" || hasSharing || hasRealtime || hasSocial) { return language === "id" ? "Aplikasi kolaborasi" : "Collaborative app"; } @@ -114,13 +132,21 @@ function buildStackLine(snapshot: ProjectMap): string { parts.push(capitalize(service)); } - return parts.slice(0, 5).join(" · "); + const seen = new Set(); + const unique = parts.filter((part) => { + const key = part.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + return unique.slice(0, 5).join(" · "); } function buildWhatThisIs(snapshot: ProjectMap, language: OnboardingLanguage): string { const sentences: string[] = []; const name = snapshot.project.name || (language === "id" ? "Project ini" : "This project"); - const features = snapshot.features.map((f) => f.name); + const features = rankFeatures(snapshot).map((f) => f.name); if (snapshot.ai?.architecture) { sentences.push(snapshot.ai.architecture); @@ -339,7 +365,7 @@ function buildFeatureWhat( language: OnboardingLanguage ): string { if (feature.purpose && !isBoilerplatePurpose(feature.purpose)) { - return feature.purpose.split(/\.\s+/)[0] + "."; + return feature.purpose.split(/\.\s+/)[0].replace(/\.$/, "") + "."; } const name = feature.name.toLowerCase(); diff --git a/packages/cli/test/analyzer-grounding.test.ts b/packages/cli/test/analyzer-grounding.test.ts index b4c8dbc..f51fa9b 100644 --- a/packages/cli/test/analyzer-grounding.test.ts +++ b/packages/cli/test/analyzer-grounding.test.ts @@ -158,6 +158,17 @@ test("pnpm-workspace.yaml packages make a monorepo, including the inline list fo assert.equal(inline.workspaceType, "monorepo"); }); +test("a workspace list that only names the root is not a monorepo", async () => { + for (const patterns of ["packages: ['.']\n", "packages:\n - ./\n", "packages: [., '!node_modules']\n"]) { + const project = await metadataOf({ + "package.json": JSON.stringify({ name: "blog", dependencies: { astro: "^5.0.0" } }), + "pnpm-workspace.yaml": patterns + }, ["astro"]); + + assert.equal(project.workspaceType, "single-package", patterns); + } +}); + test("a one-off package.json inside an app does not make it a monorepo or a library", async () => { const project = await metadataOf({ "package.json": JSON.stringify({ name: "app", dependencies: { "@remix-run/react": "^2.0.0" } }), diff --git a/packages/cli/test/analyzers.test.ts b/packages/cli/test/analyzers.test.ts index c2127a4..5886a64 100644 --- a/packages/cli/test/analyzers.test.ts +++ b/packages/cli/test/analyzers.test.ts @@ -478,6 +478,7 @@ test("project map summarizes a Next.js fixture", async () => { root: nextFixture, framework: "nextjs", frameworks: ["nextjs"], + detectedFrameworks: ["nextjs"], language: "typescript", packageManager: "unknown", projectTypes: ["web-app"], diff --git a/packages/cli/test/audit-regressions.test.ts b/packages/cli/test/audit-regressions.test.ts new file mode 100644 index 0000000..80dd677 --- /dev/null +++ b/packages/cli/test/audit-regressions.test.ts @@ -0,0 +1,482 @@ +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 test from "node:test"; +import { CustomProviderClient } from "../src/ai/custom.js"; +import { scanFiles, shouldIgnorePath, type ScannedFile } from "../src/analyzers/analysis/index.js"; +import { VuePreprocessor } from "../src/analyzers/analysis/preprocessors/index.js"; +import { TsMorphAnalyzer } from "../src/analyzers/analysis/tsMorphAnalyzer.js"; +import { detectFrameworks } from "../src/analyzers/detectors/index.js"; +import { buildDependencyGraph, detectEntryPoints, loadAliasMappings } from "../src/analyzers/graph/index.js"; +import { analyzeFiles } from "../src/analyzers/pipeline/analyzerRegistry.js"; +import { groundDomain } from "../src/analyzers/inference/domainGrounding.js"; +import { inferDomain, type DomainInferenceInput } from "../src/analyzers/inference/domainInference.js"; +import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; +import type { ProjectMap } from "../src/analyzers/pipeline/index.js"; +import { buildOnboardingModel } from "../src/onboarding/modelBuilder.js"; +import { DevmapError } from "../src/utils/errors.js"; + +function fileOf(path: string, content = ""): ScannedFile { + const extension = path.includes(".") ? path.slice(path.lastIndexOf(".")) : ""; + return { path, absolutePath: `/p/${path}`, extension, size: content.length, lines: 1, content }; +} + +async function withProject(files: Record, run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), "devmap-audit-")); + try { + for (const [path, content] of Object.entries(files)) { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); + } + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +// ------------------------------------------------------------ Vue SFC with two script blocks + +const TWO_BLOCKS = [ + "", + "", + "", + "", + "" +].join("\n"); + +test("a Vue SFC with \n\n"), + fileOf("src/Fine.vue", "\n\n") + ]); + + assert.equal(analyses["src/ok.ts"].confidence, "high"); + assert.equal(analyses["src/Fine.vue"].confidence, "high"); + assert.equal(analyses["src/broken.ts"].confidence, "medium"); + assert.equal(analyses["src/Broken.vue"].confidence, "medium"); + // what could be read is still reported + assert.ok(analyses["src/broken.ts"].imports.includes("./a")); +}); + +test("the snapshot carries the lowered confidence per file", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "app", dependencies: { express: "4.0.0" } }), + "src/server.ts": "import express from 'express';\nexport const app = express();\n", + "src/broken.ts": "export function broken( {\n" + }, async (root) => { + const { fileIndex } = await createProjectMap(root); + + assert.equal(fileIndex["src/server.ts"].analysisConfidence, "high"); + assert.equal(fileIndex["src/broken.ts"].analysisConfidence, "medium"); + }); +}); + +// ------------------------------------------------------------ vocabulary words are hints, packages are evidence + +const lines = (...rows: string[]) => rows.join("\n") + "\n"; + +test("a feature that only a file name suggests is not reported as high confidence", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "kit", version: "1.0.0" }), + "src/runtime/cache.js": lines("import { read } from './store.js';", "export const cache = new Map();", "export function get(k) { return read(cache, k); }"), + "src/runtime/query/cache.js": lines("import { cache } from '../cache.js';", "export const queries = cache;"), + "src/runtime/store.js": lines("export function read(m, k) { return m.get(k); }") + }, async (root) => { + const caching = (await createProjectMap(root)).features.find((feature) => feature.name === "Caching"); + + assert.ok(caching, "the hint is still reported"); + assert.equal(caching.confidence, "medium"); + }); +}); + +test("an imported vendor package keeps a feature at high confidence", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "api", dependencies: { ioredis: "5.0.0" } }), + "src/cache/client.ts": lines("import Redis from 'ioredis';", "export const redis = new Redis();"), + "src/cache/sessions.ts": lines("import Redis from 'ioredis';", "export const sessions = new Redis();") + }, async (root) => { + const caching = (await createProjectMap(root)).features.find((feature) => feature.name === "Caching"); + + assert.ok(caching); + assert.equal(caching.confidence, "high"); + }); +}); + +test("the name behind a package scope does not count as evidence", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "kit", dependencies: { "@standard-schema/spec": "1.0.0" } }), + "src/a.ts": lines("import type { StandardSchemaV1 } from '@standard-schema/spec';", "export type A = StandardSchemaV1;"), + "src/b.ts": lines("import type { StandardSchemaV1 } from '@standard-schema/spec';", "export type B = StandardSchemaV1;") + }, async (root) => { + const testing = (await createProjectMap(root)).features.find((feature) => feature.name === "Testing"); + + assert.ok(!testing || testing.confidence !== "high"); + }); +}); + +test("a scoped vendor package is evidence through its scope", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "api", dependencies: { "@sentry/node": "8.0.0" } }), + "src/lib/errors.ts": lines("import * as Sentry from '@sentry/node';", "export const report = (e: unknown) => Sentry.captureException(e);"), + "src/lib/boot.ts": lines("import * as Sentry from '@sentry/node';", "export const boot = () => Sentry.init({});") + }, async (root) => { + const monitoring = (await createProjectMap(root)).features.find((feature) => feature.name === "Logging & Monitoring"); + + assert.ok(monitoring); + assert.equal(monitoring.confidence, "high"); + }); +}); + +test("a feature named by its files but served by a route keeps high confidence", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "site", dependencies: { next: "15.0.0", react: "19.0.0", "react-dom": "19.0.0" } }), + "app/login/page.tsx": lines("import { signIn } from '../../lib/auth/session';", "export default function Login() { return signIn(); }"), + "app/api/session/route.ts": lines("import { current } from '../../../lib/auth/session';", "export async function GET() { return Response.json(current()); }"), + "lib/auth/session.ts": lines("export const signIn = () => null;", "export const current = () => null;") + }, async (root) => { + const authentication = (await createProjectMap(root)).features.find((feature) => feature.name === "Authentication"); + + assert.ok(authentication); + assert.equal(authentication.confidence, "high"); + }); +}); diff --git a/packages/cli/test/onboarding-command.test.ts b/packages/cli/test/onboarding-command.test.ts index 0f2b177..34cba89 100644 --- a/packages/cli/test/onboarding-command.test.ts +++ b/packages/cli/test/onboarding-command.test.ts @@ -78,6 +78,19 @@ test("onboarding write can generate Indonesian markdown after language prompt", } }); +test("onboarding rejects a language it does not support instead of answering in English", async () => { + const projectRoot = await createOnboardingProject(); + + try { + await assert.rejects( + onboardingCommand({ projectRoot, language: "fr" }), + /Unsupported onboarding language "fr"/ + ); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + async function createOnboardingProject(): Promise { const projectRoot = await mkdtemp(join(tmpdir(), "devmap-onboarding-test-")); const snapshot = await createProjectMap(nextFixture); diff --git a/packages/cli/test/onboarding-model.test.ts b/packages/cli/test/onboarding-model.test.ts index b712f08..e5807ac 100644 --- a/packages/cli/test/onboarding-model.test.ts +++ b/packages/cli/test/onboarding-model.test.ts @@ -542,7 +542,8 @@ test("buildTagline uses ownership hint and primary feature when no domain summar dependencies: {}, fileGraph: {}, fileIndex: {}, - capabilities: [] + capabilities: [], + ownership: { pattern: "single_user_isolated" as const, crossUserFields: [] } }; const model = buildOnboardingModel(snapshot, "en");