From b87e289485d5eb073723275e00af4d4d737744d2 Mon Sep 17 00:00:00 2001 From: itsflaid Date: Wed, 11 Nov 2026 19:04:12 +0800 Subject: [PATCH 1/5] fix(analyzer): audit fixes for v0.9.3 - Vue SFC: merge . + 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..c331dc3 100644 --- a/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts +++ b/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts @@ -22,6 +22,22 @@ 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; + +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,6 +100,8 @@ 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 lines = new LineIndex(sourceFile.getFullText()); const lineOf = (node: Node) => lines.lineAt(node.getStart()); diff --git a/packages/cli/src/analyzers/detectors/frameworkDetector.ts b/packages/cli/src/analyzers/detectors/frameworkDetector.ts index f0435cb..c262378 100644 --- a/packages/cli/src/analyzers/detectors/frameworkDetector.ts +++ b/packages/cli/src/analyzers/detectors/frameworkDetector.ts @@ -124,7 +124,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 +169,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"); } diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index 76941c4..4b159ca 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -159,6 +159,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) ); 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/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.`; } /** @@ -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/audit-regressions.test.ts b/packages/cli/test/audit-regressions.test.ts new file mode 100644 index 0000000..39a1a37 --- /dev/null +++ b/packages/cli/test/audit-regressions.test.ts @@ -0,0 +1,225 @@ +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 { 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"); + }); +});