From b941bc3c10f962ddcd2b7e25e4f0136def3c7a82 Mon Sep 17 00:00:00 2001 From: Muhammad Fadil Date: Fri, 6 Nov 2026 18:44:31 +0800 Subject: [PATCH] fix: read Nest controller object paths, skip test routes, describe libraries as libraries - nestRouteDetector reads @Controller({ path }) and array paths for controllers and methods, so the controller prefix is no longer dropped - detectRoutes drops routes declared outside architecture source (specs, e2e, integration/, sample/) - onboarding tagline and summary call a package with no app, API or CLI a library instead of a "Personal app" - add tests for all three and record the work in the changelog and personal notes Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AgTNRXkpcmiaaLEb9KjGz1 --- CHANGELOG.md | 12 +++ docs/for-me-personal/DEBUG.md | 43 +++++++++++ docs/for-me-personal/PROGRESS.md | 25 ++++++- docs/for-me-personal/TEST.md | 15 ++++ .../analyzers/detectors/nestRouteDetector.ts | 74 +++++++++++++++---- .../src/analyzers/detectors/routeDetector.ts | 4 +- .../cli/src/analyzers/graph/sourceScope.ts | 10 ++- packages/cli/src/onboarding/modelBuilder.ts | 37 ++++++++-- packages/cli/test/framework-routes.test.ts | 62 ++++++++++++++++ packages/cli/test/onboarding-library.test.ts | 42 +++++++++++ 10 files changed, 300 insertions(+), 24 deletions(-) create mode 100644 packages/cli/test/onboarding-library.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e5eb6dd..4bebcac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to DevMap are documented in this file. ## [Unreleased] +### Fixed + +- NestJS routes read the `@Controller({ path })` object form and array paths (`@Controller(['a', 'b'])`, + `@Post(['x', 'y'])`). The controller prefix used to be dropped, so + `@Controller({ path: '/v2/clients/:clientId' })` with `@Get('/')` was reported as `/` +- Routes no longer come from specs, e2e suites or sample apps. `isArchitectureSource` now also treats + a root-level or workspace-member `integration/` and `sample/` folder as non-production, so a repo + like NestJS no longer reports 633 test-only routes (1 left) or `integration/*/app.module.ts` as + entry points +- Onboarding calls a published package with no app, API or CLI in it a library ("fastify — a + reusable library.") instead of "Personal app with caching as the core feature" + ### Planned - Go, Java, C# and PHP (PSR-4) import resolution, after JS/TS is mature diff --git a/docs/for-me-personal/DEBUG.md b/docs/for-me-personal/DEBUG.md index 5c07b61..ae53a1a 100644 --- a/docs/for-me-personal/DEBUG.md +++ b/docs/for-me-personal/DEBUG.md @@ -1689,3 +1689,46 @@ test, dan baru setelah test MCP ada. Satu run publish yang gagal di langkah test aman: tidak ada yang terbit. - Jika log CI terpotong, nama test yang gagal bisa dicari dengan mereproduksi lokal: fresh clone, `CI=true`, user non-root, `env -i`, lalu loop berulang. + +--- + +## 32. Route Nest Kehilangan Prefix, Route Test Ikut Terhitung, Onboarding Library Salah + +**Tanggal:** 2026-11-06 + +### Gejala + +- cal.com: `@Controller({ path: "/v2/atoms/auth/oauth2/clients/:clientId" })` dengan `@Get("/")` + dilaporkan sebagai route `/`. +- nest: 634 route, 633 di antaranya dari `*.spec.ts`, `e2e/`, `integration/` dan `sample/`. + Entry point juga berisi `integration/*/app.module.ts`. +- fastify: `devmap onboarding` menulis "Personal app with caching as the core feature". + +### Akar masalah + +- `detectNestRoutes` hanya membaca argumen `@Controller` yang berupa string literal. Bentuk objek + dan array menghasilkan prefix kosong. +- `detectRoutes` hanya memfilter file test di detector Next, Astro, Nuxt dan SvelteKit, bukan di + detector lain. `isArchitectureSource` juga belum mengenal `integration/` dan `sample/`. +- `resolveOwnershipHint` menganggap project tanpa capability sharing, real-time atau social sebagai + "Personal app", termasuk library yang tidak punya user sama sekali. + +### Solusi + +- Nest membaca `{ path }` dan array path (controller maupun method), lalu menghapus trailing slash. +- `detectRoutes` membuang route yang `file`-nya bukan `isArchitectureSource`. `integration` dan + `sample` masuk ke `SAMPLE_SEGMENTS` (hanya dikenali di root atau root workspace member). +- `isLibraryOnly` di `modelBuilder.ts`: tagline dan `whatThisIs` memakai kata library. + +### Verifikasi + +- 3 test baru, `pnpm test` 541 pass / 0 fail. +- Jalankan ulang di clone nest, cal.com dan fastify: nest 1 route, cal.com route benar, fastify + "a reusable library.". + +### Pelajaran + +- Fingerprint memuat versi DevMap, jadi snapshot lama dipakai ulang ("Project is unchanged") + walau kode analyzer sudah berubah. Saat memverifikasi dari source, hapus `.devmap/` atau pakai + `--fresh`. +- Filter file test sebaiknya satu titik di `detectRoutes`, bukan diulang di tiap detector. diff --git a/docs/for-me-personal/PROGRESS.md b/docs/for-me-personal/PROGRESS.md index 25aac9a..6295c40 100644 --- a/docs/for-me-personal/PROGRESS.md +++ b/docs/for-me-personal/PROGRESS.md @@ -1,6 +1,29 @@ # Progress DevMap -Terakhir diperbarui: 2026-10-31 +Terakhir diperbarui: 2026-11-06 + +## Update 2026-11-06 + +### Uji stabilitas di repo populer dan tiga perbaikan analyzer + +Menjalankan `devmap analyze` 0.9.1 ke express, fastify, vuejs/core, nest dan cal.com (216 sampai +7.038 file, 3 sampai 56 detik). Tidak ada crash dan snapshot dua run identik, tapi tiga hasil salah: + +- Route Nest mengabaikan prefix `@Controller({ path })` (`nestRouteDetector.ts`). +- Route dan entry point dari spec, e2e, `integration/` dan `sample/` ikut terhitung + (`routeDetector.ts`, `graph/sourceScope.ts`). +- Onboarding fastify menyebut "Personal app with caching as the core feature" + (`onboarding/modelBuilder.ts`, `isLibraryOnly`). + +**Hasil di repo uji setelah perbaikan:** nest 634 route menjadi 1, route cal.com +`/v2/atoms/auth/oauth2/clients/:clientId`, onboarding fastify "a reusable library." + +**Verifikasi:** `pnpm build` dan `pnpm test` di `packages/cli`: 541 pass / 0 fail, `tsc --noEmit` bersih. +Test baru ada di `framework-routes.test.ts` dan `onboarding-library.test.ts`. + +**Belum:** versi tidak dinaikkan dan tidak ada tag. Menunggu keputusan bump. + +--- ## Update 2026-10-31 diff --git a/docs/for-me-personal/TEST.md b/docs/for-me-personal/TEST.md index 035474a..6b01f70 100644 --- a/docs/for-me-personal/TEST.md +++ b/docs/for-me-personal/TEST.md @@ -15,6 +15,21 @@ Ada beberapa versi DevMap yang dapat diuji: | npm link | CLI global sementara | Menguji command `devmap` dari folder mana pun | | CI/runtime | OS dan versi Node berbeda | Verifikasi lintas platform sebelum release | +## Uji di repo populer (2026-11-06) + +Memastikan analyzer stabil dan tidak mengarang di project yang tidak dikenal. + +```bash +git clone --depth 1 https://github.com/nestjs/nest /tmp/nest +rm -rf /tmp/nest/.devmap # fingerprint memuat versi, snapshot lama akan dipakai ulang +node packages/cli/dist/index.js analyze /tmp/nest +node packages/cli/dist/index.js onboarding /tmp/nest +``` + +Hasil yang diharapkan: exit 0, dua run menghasilkan snapshot sama, route tidak berasal dari +`*.spec.ts`, `e2e/`, `integration/` atau `sample/`, dan library (fastify, express) tidak disebut +"Personal app". Repo yang sudah dicoba: express, fastify, vuejs/core, nest, calcom/cal.com. + ## MCP server (2026-10-30) Prasyarat: `pnpm install --frozen-lockfile`, Node 22.12+. diff --git a/packages/cli/src/analyzers/detectors/nestRouteDetector.ts b/packages/cli/src/analyzers/detectors/nestRouteDetector.ts index d7137ff..e0d685d 100644 --- a/packages/cli/src/analyzers/detectors/nestRouteDetector.ts +++ b/packages/cli/src/analyzers/detectors/nestRouteDetector.ts @@ -1,4 +1,4 @@ -import { Project, SyntaxKind } from "ts-morph"; +import { Node, Project } from "ts-morph"; import type { ScannedFile } from "../analysis/index.js"; import type { RouteInfo } from "./routeDetector.js"; import { compareText } from "../../utils/compare.js"; @@ -21,9 +21,12 @@ const ALL_HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "H * per-line regex cannot associate a @Get(':id') with the @Controller('users') * that scopes it. ts-morph gives per-class/method decorator scope for free. * - * Known v1 limitations (from update5.md): - * - @Controller({ path: '...' }) object form is skipped (prefix becomes ""). - * - app.setGlobalPrefix('api') is not composed into route paths. + * Paths are read from string literals, no-substitution templates and arrays of + * them, for both `@Controller('users')` and `@Controller({ path: 'users' })`. + * + * Known limitations: + * - Paths built from constants or template substitutions are not resolved. + * - app.setGlobalPrefix('api') and `version` are not composed into route paths. */ export function detectNestRoutes(files: ScannedFile[]): RouteInfo[] { // Cheap pre-filter before any ts-morph parse — most scans have no controllers. @@ -51,9 +54,7 @@ export function detectNestRoutes(files: ScannedFile[]): RouteInfo[] { continue; } - const prefixArg = controllerDecorator.getArguments()[0]; - const prefix = - prefixArg?.asKind(SyntaxKind.StringLiteral)?.getLiteralText() ?? ""; + const prefixes = controllerPrefixes(controllerDecorator.getArguments()[0]); for (const method of cls.getMethods()) { const routeDecorator = method @@ -63,21 +64,23 @@ export function detectNestRoutes(files: ScannedFile[]): RouteInfo[] { continue; } - const pathArg = routeDecorator.getArguments()[0]; - const subPath = - pathArg?.asKind(SyntaxKind.StringLiteral)?.getLiteralText() ?? ""; + const subPaths = literalPaths(routeDecorator.getArguments()[0]); const decoratorName = routeDecorator.getName().toUpperCase(); // @All() matches every HTTP method — expand instead of inventing a // placeholder value. const methods = decoratorName === "ALL" ? ALL_HTTP_METHODS : [decoratorName]; - routes.push({ - path: `/${[prefix, subPath].filter(Boolean).join("/")}`.replace(/\/+/g, "/"), - file: file.path, - kind: "api", - methods - }); + for (const prefix of prefixes) { + for (const subPath of subPaths.length > 0 ? subPaths : [""]) { + routes.push({ + path: joinRoutePath(prefix, subPath), + file: file.path, + kind: "api", + methods + }); + } + } } } } @@ -86,3 +89,42 @@ export function detectNestRoutes(files: ScannedFile[]): RouteInfo[] { compareText(left.path, right.path) || compareText(left.file, right.file) ); } + +function literalText(node: Node | undefined): string | undefined { + if (node && (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node))) { + return node.getLiteralText(); + } + return undefined; +} + +function literalPaths(node: Node | undefined): string[] { + const text = literalText(node); + if (text !== undefined) { + return [text]; + } + if (node && Node.isArrayLiteralExpression(node)) { + return node + .getElements() + .map((element) => literalText(element)) + .filter((value): value is string => value !== undefined); + } + return []; +} + +function controllerPrefixes(argument: Node | undefined): string[] { + let paths: string[] = []; + if (argument && Node.isObjectLiteralExpression(argument)) { + const property = argument.getProperty("path"); + if (property && Node.isPropertyAssignment(property)) { + paths = literalPaths(property.getInitializer()); + } + } else { + paths = literalPaths(argument); + } + return paths.length > 0 ? paths : [""]; +} + +function joinRoutePath(prefix: string, subPath: string): string { + const joined = `/${[prefix, subPath].filter(Boolean).join("/")}`.replace(/\/+/g, "/"); + return joined.length > 1 ? joined.replace(/\/$/, "") : joined; +} diff --git a/packages/cli/src/analyzers/detectors/routeDetector.ts b/packages/cli/src/analyzers/detectors/routeDetector.ts index 4754f2b..cfb2e86 100644 --- a/packages/cli/src/analyzers/detectors/routeDetector.ts +++ b/packages/cli/src/analyzers/detectors/routeDetector.ts @@ -101,7 +101,9 @@ export function detectRoutes( routes.push(...detectAdonisRoutes(files, graph)); routes.push(...detectClientRoutes(files, graph)); - return sortRoutes(routes); + // Controllers declared inside specs, e2e suites and sample apps register + // routes on throwaway test servers, not on the project itself. + return sortRoutes(routes.filter((route) => isArchitectureSource(route.file))); } // Files that Next serves at a fixed URL. diff --git a/packages/cli/src/analyzers/graph/sourceScope.ts b/packages/cli/src/analyzers/graph/sourceScope.ts index 15c12fc..de991a9 100644 --- a/packages/cli/src/analyzers/graph/sourceScope.ts +++ b/packages/cli/src/analyzers/graph/sourceScope.ts @@ -16,7 +16,15 @@ const NON_PRODUCTION_SEGMENTS = new Set([ // Sample material is only recognised where a repo keeps it (the root or a // workspace member's root). A product feature that happens to live in // src/features/demo or src/docs is real code. -const SAMPLE_SEGMENTS = new Set(["demo", "docs", "example", "examples", "samples"]); +const SAMPLE_SEGMENTS = new Set([ + "demo", + "docs", + "example", + "examples", + "integration", + "sample", + "samples" +]); const WORKSPACE_PARENTS = new Set(["apps", "packages", "libs", "services", "projects"]); const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]+\.py$|_test\.(?:py|go)$|(?:^|\/)conftest\.py$/; diff --git a/packages/cli/src/onboarding/modelBuilder.ts b/packages/cli/src/onboarding/modelBuilder.ts index 8248c25..bf95a21 100644 --- a/packages/cli/src/onboarding/modelBuilder.ts +++ b/packages/cli/src/onboarding/modelBuilder.ts @@ -32,6 +32,13 @@ function buildTagline(snapshot: ProjectMap, language: OnboardingLanguage): strin return firstSentence.endsWith(".") ? firstSentence : `${firstSentence}.`; } + if (isLibraryOnly(snapshot)) { + const name = snapshot.project.name || "Project"; + return language === "id" + ? `${name} — library yang dipakai ulang oleh project lain.` + : `${name} — a reusable library.`; + } + const ownershipHint = resolveOwnershipHint(snapshot, language); const primaryFeature = snapshot.features[0]; @@ -53,6 +60,18 @@ function buildTagline(snapshot: ProjectMap, language: OnboardingLanguage): strin : `${snapshot.project.name || "Project"} — ${fw} application.`; } +/** + * A published package with no app or service in it. Its capabilities say + * nothing about users or sharing, so app wording ("Personal app") would be wrong. + */ +function isLibraryOnly(snapshot: ProjectMap): boolean { + const types = snapshot.project.projectTypes; + return types.includes("library") + && !types.includes("web-app") + && !types.includes("api-service") + && !types.includes("node-cli"); +} + function resolveOwnershipHint(snapshot: ProjectMap, language: OnboardingLanguage): string | null { const caps = snapshot.capabilities ?? []; const hasSharing = caps.some((c) => c.kind === "sharing" || c.kind === "collaboration"); @@ -107,11 +126,19 @@ function buildWhatThisIs(snapshot: ProjectMap, language: OnboardingLanguage): st sentences.push(snapshot.ai.architecture); } else if (features.length > 0) { const topFeatures = features.slice(0, 3).map((f) => f.toLowerCase()).join(", "); - sentences.push( - language === "id" - ? `${name} menangani ${topFeatures}.` - : `${name} handles ${topFeatures}.` - ); + if (isLibraryOnly(snapshot)) { + sentences.push( + language === "id" + ? `${name} adalah library; kodenya menyentuh area ${topFeatures}.` + : `${name} is a library; its code touches ${topFeatures}.` + ); + } else { + sentences.push( + language === "id" + ? `${name} menangani ${topFeatures}.` + : `${name} handles ${topFeatures}.` + ); + } } const domainPattern = (snapshot.domain as { ownershipPattern?: string })?.ownershipPattern; diff --git a/packages/cli/test/framework-routes.test.ts b/packages/cli/test/framework-routes.test.ts index 108321c..1b9a08d 100644 --- a/packages/cli/test/framework-routes.test.ts +++ b/packages/cli/test/framework-routes.test.ts @@ -447,6 +447,68 @@ test("detectRoutes composes @Controller prefix with method decorator paths", () ); }); +test("detectRoutes reads the @Controller object form and array paths", () => { + const files = [ + createScannedFile("package.json", JSON.stringify({ dependencies: { "@nestjs/common": "^11.0.0" } })), + createScannedFile("src/oauth/oauth.controller.ts", [ + 'import { Controller, Get, Post } from "@nestjs/common";', + "@Controller({", + " path: \"/v2/clients/:clientId\",", + " version: [\"1\", \"2\"]", + "})", + "export class OAuthController {", + " @Get(\"/\")", + " getClient() {}", + "", + " @Post([\"token\", \"refresh\"])", + " issue() {}", + "}" + ].join("\n")), + createScannedFile("src/aliases/aliases.controller.ts", [ + 'import { Controller, Get } from "@nestjs/common";', + "@Controller([\"cats\", \"felines\"])", + "export class AliasesController {", + " @Get(`:id`)", + " findOne() {}", + "}" + ].join("\n")) + ]; + + const routes = detectRoutes(files, ["nestjs"]); + assert.deepEqual( + routes.map((route) => [route.path, route.methods?.join(",")]), + [ + ["/cats/:id", "GET"], + ["/felines/:id", "GET"], + ["/v2/clients/:clientId", "GET"], + ["/v2/clients/:clientId/refresh", "POST"], + ["/v2/clients/:clientId/token", "POST"] + ] + ); +}); + +test("detectRoutes ignores controllers declared in specs, e2e suites and sample apps", () => { + const controller = (name: string) => [ + 'import { Controller, Get } from "@nestjs/common";', + "@Controller()", + `export class ${name} {`, + " @Get()", + " root() {}", + "}" + ].join("\n"); + const files = [ + createScannedFile("package.json", JSON.stringify({ dependencies: { "@nestjs/common": "^11.0.0" } })), + createScannedFile("src/app.controller.ts", controller("AppController")), + createScannedFile("src/app.controller.spec.ts", controller("SpecController")), + createScannedFile("integration/cors/e2e/cors.ts", controller("E2eController")), + createScannedFile("integration/hello-world/src/app.controller.ts", controller("HarnessController")), + createScannedFile("sample/01-cats-app/src/cats.controller.ts", controller("SampleController")) + ]; + + const routes = detectRoutes(files, ["nestjs"]); + assert.deepEqual(routes.map((route) => route.file), ["src/app.controller.ts"]); +}); + test("NestJS routes surface through project map and service stays classified", async () => { const projectRoot = await buildFixture({ "package.json": JSON.stringify({ diff --git a/packages/cli/test/onboarding-library.test.ts b/packages/cli/test/onboarding-library.test.ts new file mode 100644 index 0000000..c0c0e16 --- /dev/null +++ b/packages/cli/test/onboarding-library.test.ts @@ -0,0 +1,42 @@ +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 { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; +import { buildOnboardingModel } from "../src/onboarding/modelBuilder.js"; + +async function buildFixture(files: Record): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-onboarding-library-")); + for (const [path, content] of Object.entries(files)) { + const target = join(projectRoot, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); + } + return projectRoot; +} + +test("onboarding describes a published library as a library, not a personal app", async () => { + const projectRoot = await buildFixture({ + "package.json": JSON.stringify({ name: "tiny-cache", version: "1.0.0", main: "index.js" }), + "index.js": [ + "const store = new Map();", + "module.exports.get = (key) => store.get(key);", + "module.exports.set = (key, value) => store.set(key, value);" + ].join("\n") + }); + + try { + const snapshot = await createProjectMap(projectRoot); + assert.ok(snapshot.project.projectTypes.includes("library")); + + for (const language of ["en", "id"] as const) { + const model = buildOnboardingModel(snapshot, language); + assert.match(model.tagline, /library/i); + assert.doesNotMatch(model.tagline, /personal/i); + assert.doesNotMatch(model.whatThisIs, /personal|handles|menangani/i); + } + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +});