Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions docs/for-me-personal/DEBUG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 24 additions & 1 deletion docs/for-me-personal/PROGRESS.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
15 changes: 15 additions & 0 deletions docs/for-me-personal/TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+.
Expand Down
74 changes: 58 additions & 16 deletions packages/cli/src/analyzers/detectors/nestRouteDetector.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
});
}
}
}
}
}
Expand All @@ -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;
}
4 changes: 3 additions & 1 deletion packages/cli/src/analyzers/detectors/routeDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/analyzers/graph/sourceScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/;
Expand Down
37 changes: 32 additions & 5 deletions packages/cli/src/onboarding/modelBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
62 changes: 62 additions & 0 deletions packages/cli/test/framework-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading