diff --git a/CLAUDE.md b/CLAUDE.md index 3d022bd..89f56a9 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/ ← 51 test files (`*.test.ts`, auto-discovered by `run-tests.ts`) + `mcp-smoke.mjs`, `package-e2e.mjs` +│ ├── test/ ← 53 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 @@ -129,8 +129,8 @@ Order for a non-relative specifier: `#subpath` (package.json `imports`) → tsco ## Testing -51 test files in `packages/cli/test/`. Key covered areas: -- Analyzers, AI clients, agent navigation, entity extractors +53 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 - Heuristic language readers (`heuristic-languages`), scanner robustness, project scope/metadata diff --git a/docs/guide/04-entity-extraction.md b/docs/guide/04-entity-extraction.md index 62ba540..6710536 100644 --- a/docs/guide/04-entity-extraction.md +++ b/docs/guide/04-entity-extraction.md @@ -28,7 +28,7 @@ non-empty result**: ```ts const EXTRACTORS: IEntityExtractor[] = [ new PrismaExtractor(), - // new DrizzleExtractor(), ← reserved slot, not yet implemented + new DrizzleExtractor(), // new TypeORMExtractor(), ← reserved slot // new MongooseExtractor(), ← reserved slot new SQLExtractor(), @@ -55,7 +55,7 @@ Prisma schema → SQL query strings → route URL segments → empty (high conf) (medium conf) (low conf) ``` -Every `EntityInfo` carries its own `source: "prisma" | "sql" | "route-hint"`, +Every `EntityInfo` carries its own `source: "prisma" | "drizzle" | "sql" | "route-hint"`, so downstream consumers can weight evidence by confidence instead of treating all entities as equally reliable. @@ -92,6 +92,19 @@ rather than just reading one. The rule, implemented in `buildRelations()`: entirely — valid Prisma, but not a cross-entity relationship worth modeling. +## Tier 1b — `DrizzleExtractor` + +**Source:** `source: "drizzle"`. Reads `pgTable`, `mysqlTable`, `sqliteTable` (and the SingleStore, MSSQL and +CockroachDB builders) from TypeScript/JavaScript files that import `drizzle-orm`. + +- The entity name comes from the SQL table name (`'staff_invitations'` → `StaffInvitation`), not the variable + (`usersTable`), and is singularized. +- Fields come from the column object; `.notNull()` and `.primaryKey()` make a field non-optional. +- Relations come from `.references(() => table.id)` foreign keys (a `.unique()` key is one-to-one) and from + `relations()` declarations. Self references are ignored. +- Comments, strings and template literals are skipped when matching brackets, so a `)` inside a default value + or a `sql\`...\`` check does not cut a table short. + ## Tier 2 — `SQLExtractor` For projects with a raw SQL client (`pg`, `mysql2`, `better-sqlite3`, etc.) diff --git a/packages/cli/src/analyzers/analysis/extractors/drizzleExtractor.ts b/packages/cli/src/analyzers/analysis/extractors/drizzleExtractor.ts new file mode 100644 index 0000000..c473fa7 --- /dev/null +++ b/packages/cli/src/analyzers/analysis/extractors/drizzleExtractor.ts @@ -0,0 +1,302 @@ +import type { ScannedFile } from "../index.js"; +import { singularize } from "./fallbackExtractor.js"; +import { buildRelations } from "./prismaExtractor.js"; +import type { EntityInfo, FieldInfo, IEntityExtractor } from "./types.js"; + +const TABLE_BUILDERS = "pgTable|mysqlTable|sqliteTable|singlestoreTable|mssqlTable|cockroachTable"; +const TABLE_CALL = new RegExp(`(?:export\\s+)?const\\s+(\\w+)\\s*=\\s*(?:${TABLE_BUILDERS})\\s*\\(`, "g"); +const TABLE_CALL_PRESENT = new RegExp(`\\b(?:${TABLE_BUILDERS})\\s*\\(`); +const SOURCE_FILE = /\.[cm]?[jt]s$/; +const REFERENCES = /\.references\(\s*\([^)]*\)\s*(?::[^=]+?)?=>\s*(\w+)\s*\./; +const RELATIONS_CALL = /\brelations\s*\(\s*(\w+)\s*,/g; +const RELATION_LINK = /\b(one|many)\s*\(\s*(\w+)/g; + +type Column = { + name: string; + type: string; + optional: boolean; + unique: boolean; + references?: string; +}; + +type Table = { + variable: string; + sqlName?: string; + columns: Column[]; +}; + +type RelationDeclaration = { + variable: string; + links: Array<{ kind: "one" | "many"; target: string }>; +}; + +/** + * Reads Drizzle ORM schemas (`pgTable`, `mysqlTable`, `sqliteTable`, ...) from TypeScript/JavaScript. + * Relations come from `.references(() => table.id)` foreign keys and `relations()` declarations. + */ +export class DrizzleExtractor implements IEntityExtractor { + readonly name = "drizzle"; + + canHandle(files: ScannedFile[]): boolean { + return files.some(isDrizzleSchemaFile); + } + + extract(files: ScannedFile[]): EntityInfo[] { + const tables: Table[] = []; + const declarations: RelationDeclaration[] = []; + + for (const file of files) { + if (!isDrizzleSchemaFile(file)) continue; + const text = stripComments(file.content); + tables.push(...parseTables(text)); + declarations.push(...parseRelationDeclarations(text)); + } + + return buildRelations(toEntities(tables, declarations)); + } +} + +function isDrizzleSchemaFile(file: ScannedFile): boolean { + return SOURCE_FILE.test(file.path) + && file.content.includes("drizzle-orm") + && TABLE_CALL_PRESENT.test(file.content); +} + +function toEntities(tables: Table[], declarations: RelationDeclaration[]): EntityInfo[] { + const entityByVariable = new Map(tables.map((table) => [table.variable, entityName(table)])); + const entities = new Map(); + const variableOf = new Map(); + + for (const table of tables) { + const name = entityByVariable.get(table.variable)!; + if (entities.has(name)) continue; + entities.set(name, { name, fields: [], relations: [], source: "drizzle" }); + variableOf.set(name, table.variable); + } + + for (const table of tables) { + const name = entityByVariable.get(table.variable)!; + const entity = entities.get(name); + if (!entity || variableOf.get(name) !== table.variable) continue; + + for (const column of table.columns) { + entity.fields.push({ + name: column.name, + type: column.type, + isRelation: false, + isList: false, + isOptional: column.optional + }); + + const targetName = column.references ? entityByVariable.get(column.references) : undefined; + const target = targetName ? entities.get(targetName) : undefined; + if (!target || target === entity) continue; + + entity.fields.push({ + name: column.name.replace(/Id$/, "") || column.name, + type: target.name, + isRelation: true, + isList: false, + isOptional: column.optional + }); + target.fields.push({ + name: table.variable, + type: name, + isRelation: true, + isList: !column.unique, + isOptional: false + }); + } + } + + for (const declaration of declarations) { + const ownerName = entityByVariable.get(declaration.variable); + const owner = ownerName ? entities.get(ownerName) : undefined; + if (!owner) continue; + + for (const link of declaration.links) { + const targetName = entityByVariable.get(link.target); + if (!targetName || targetName === owner.name || !entities.has(targetName)) continue; + const isList = link.kind === "many"; + if (owner.fields.some((field) => field.isRelation && field.type === targetName && field.isList === isList)) continue; + owner.fields.push(relationField(link.target, targetName, isList)); + } + } + + return [...entities.values()]; +} + +function relationField(name: string, type: string, isList: boolean): FieldInfo { + return { name, type, isRelation: true, isList, isOptional: !isList }; +} + +/** `staff_invitation` → "StaffInvitation", `users` → "User"; the SQL name wins over the variable name (`usersTable`). */ +function entityName(table: Table): string { + const source = table.sqlName ?? table.variable; + const words = source + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[\s_.-]+/) + .filter(Boolean); + if (words.length === 0) return table.variable; + words[words.length - 1] = singularize(words[words.length - 1]); + return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(""); +} + +function parseTables(text: string): Table[] { + const tables: Table[] = []; + + for (const match of text.matchAll(TABLE_CALL)) { + const open = (match.index ?? 0) + match[0].length - 1; + const close = findClosing(text, open); + if (close < 0) continue; + + const args = text.slice(open + 1, close); + const objectStart = args.indexOf("{"); + if (objectStart < 0) continue; + const objectEnd = findClosing(args, objectStart); + if (objectEnd < 0) continue; + + const sqlName = /^\s*(['"`])([\w.-]+)\1/.exec(args)?.[2]; + tables.push({ + variable: match[1], + ...(sqlName ? { sqlName } : {}), + columns: parseColumns(args.slice(objectStart + 1, objectEnd)) + }); + } + + return tables; +} + +function parseColumns(body: string): Column[] { + const columns: Column[] = []; + + for (const segment of splitTopLevel(body)) { + const match = /^\s*(\w+)\s*:\s*([\s\S]+)$/.exec(segment); + if (!match) continue; + const expression = match[2]; + const references = REFERENCES.exec(expression)?.[1]; + + columns.push({ + name: match[1], + type: /^\s*(\w+)\s*\(/.exec(expression)?.[1] ?? "unknown", + optional: !/\.notNull\s*\(/.test(expression) && !/\.primaryKey\s*\(/.test(expression), + unique: /\.unique\s*\(/.test(expression), + ...(references ? { references } : {}) + }); + } + + return columns; +} + +function parseRelationDeclarations(text: string): RelationDeclaration[] { + const declarations: RelationDeclaration[] = []; + + for (const match of text.matchAll(RELATIONS_CALL)) { + const open = text.indexOf("(", match.index ?? 0); + const close = open < 0 ? -1 : findClosing(text, open); + if (close < 0) continue; + + const links = [...text.slice(open, close).matchAll(RELATION_LINK)] + .map((link) => ({ kind: link[1] as "one" | "many", target: link[2] })); + declarations.push({ variable: match[1], links }); + } + + return declarations; +} + +// --------------------------------------------------------------------------- +// Source scanning: just enough JS lexing to find balanced brackets and split on +// commas without being fooled by strings or comments. +// --------------------------------------------------------------------------- + +function stripComments(source: string): string { + let out = ""; + let quote: string | null = null; + + for (let i = 0; i < source.length; i++) { + const char = source[i]; + const next = source[i + 1]; + + if (quote) { + out += char; + if (char === "\\") { + out += next ?? ""; + i++; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + out += char; + } else if (char === "/" && next === "/") { + while (i < source.length && source[i] !== "\n") i++; + out += "\n"; + } else if (char === "/" && next === "*") { + const end = source.indexOf("*/", i + 2); + const stop = end < 0 ? source.length : end + 2; + out += source.slice(i, stop).replace(/[^\n]/g, " "); + i = stop - 1; + } else { + out += char; + } + } + + return out; +} + +const OPENERS: Record = { "(": ")", "[": "]", "{": "}" }; +const CLOSERS = new Set(Object.values(OPENERS)); + +/** Index of the bracket that closes the one at `open`, or -1 when the source ends first. */ +function findClosing(text: string, open: number): number { + let depth = 0; + let quote: string | null = null; + + for (let i = open; i < text.length; i++) { + const char = text[i]; + + if (quote) { + if (char === "\\") i++; + else if (char === quote) quote = null; + continue; + } + + if (char === "'" || char === '"' || char === "`") quote = char; + else if (char in OPENERS) depth++; + else if (CLOSERS.has(char) && --depth === 0) return i; + } + + return -1; +} + +/** Splits an object literal body on the commas that are not nested in brackets or strings. */ +function splitTopLevel(body: string): string[] { + const parts: string[] = []; + let depth = 0; + let quote: string | null = null; + let start = 0; + + for (let i = 0; i < body.length; i++) { + const char = body[i]; + + if (quote) { + if (char === "\\") i++; + else if (char === quote) quote = null; + continue; + } + + if (char === "'" || char === '"' || char === "`") quote = char; + else if (char in OPENERS) depth++; + else if (CLOSERS.has(char)) depth--; + else if (char === "," && depth === 0) { + parts.push(body.slice(start, i)); + start = i + 1; + } + } + + parts.push(body.slice(start)); + return parts; +} diff --git a/packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts b/packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts index 9dd7fcf..bc04cc0 100644 --- a/packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts +++ b/packages/cli/src/analyzers/analysis/extractors/fallbackExtractor.ts @@ -46,6 +46,8 @@ const IRREGULAR_SINGULAR = new Map([ ["campus", "campus"], ]); +const SINGULAR_ENDS_IN_S_PLURAL = /(?:status|bus|alias|campus|bonus|virus|canvas|bias|atlas)es$/; + // --------------------------------------------------------------------------- // Extractor // --------------------------------------------------------------------------- @@ -147,6 +149,11 @@ export function singularize(word: string): string { // A one or two letter word ("s", "us") is not a plural: stripping its "s" leaves nothing. if (lower.length <= 2) return capitalize(lower); + // "statuses" → "status", "aliases" → "alias": the singular already ends in "s", so the plural takes "-es". + if (SINGULAR_ENDS_IN_S_PLURAL.test(lower)) { + return capitalize(lower.slice(0, -2)); + } + // "categories" → "category", "replies" → "reply" if (lower.endsWith("ies")) { return capitalize(lower.slice(0, -3) + "y"); diff --git a/packages/cli/src/analyzers/analysis/extractors/index.ts b/packages/cli/src/analyzers/analysis/extractors/index.ts index 34fe291..0d31b9e 100644 --- a/packages/cli/src/analyzers/analysis/extractors/index.ts +++ b/packages/cli/src/analyzers/analysis/extractors/index.ts @@ -2,6 +2,7 @@ import type { ScannedFile } from "../index.js"; import type { RouteInfo } from "../../detectors/index.js"; import type { EntityGraph, EntityInfo, IEntityExtractor, RelationInfo } from "./types.js"; import { PrismaExtractor } from "./prismaExtractor.js"; +import { DrizzleExtractor } from "./drizzleExtractor.js"; import { SQLExtractor } from "./sqlExtractor.js"; import { RouteFallbackExtractor } from "./fallbackExtractor.js"; @@ -17,7 +18,7 @@ import { RouteFallbackExtractor } from "./fallbackExtractor.js"; * * Order penting: lebih reliable = lebih atas. * 1. Prisma — high confidence, full field + relation data - * 2. [future] Drizzle → import { DrizzleExtractor } from "./drizzleExtractor.js" + * 2. Drizzle — fields + foreign-key relations from `pgTable`/`mysqlTable`/`sqliteTable` * 3. [future] TypeORM → import { TypeORMExtractor } from "./typeormExtractor.js" * 4. [future] Mongoose → import { MongooseExtractor } from "./mongooseExtractor.js" * 5. SQL — raw pg/mysql2/better-sqlite3, table names as pseudo-entities @@ -26,7 +27,7 @@ import { RouteFallbackExtractor } from "./fallbackExtractor.js"; */ const EXTRACTORS: IEntityExtractor[] = [ new PrismaExtractor(), - // new DrizzleExtractor(), + new DrizzleExtractor(), // new TypeORMExtractor(), // new MongooseExtractor(), new SQLExtractor(), diff --git a/packages/cli/src/analyzers/analysis/extractors/prismaExtractor.ts b/packages/cli/src/analyzers/analysis/extractors/prismaExtractor.ts index 6033499..620c945 100644 --- a/packages/cli/src/analyzers/analysis/extractors/prismaExtractor.ts +++ b/packages/cli/src/analyzers/analysis/extractors/prismaExtractor.ts @@ -146,7 +146,7 @@ function parsePrismaFields(body: string): FieldInfo[] { // Field bertipe sama dengan entity sendiri → skip, ini bukan cross-entity relation. // --------------------------------------------------------------------------- -function buildRelations(entities: EntityInfo[]): EntityInfo[] { +export function buildRelations(entities: EntityInfo[]): EntityInfo[] { const entityNames = new Set(entities.map((e) => e.name)); const seenRelations = new Set(); const allRelations: RelationInfo[] = []; diff --git a/packages/cli/src/analyzers/analysis/extractors/sqlExtractor.ts b/packages/cli/src/analyzers/analysis/extractors/sqlExtractor.ts index 5fdf8ed..901c586 100644 --- a/packages/cli/src/analyzers/analysis/extractors/sqlExtractor.ts +++ b/packages/cli/src/analyzers/analysis/extractors/sqlExtractor.ts @@ -2,7 +2,7 @@ import { Project, ScriptTarget, SyntaxKind } from "ts-morph"; import type { ScannedFile } from "../index.js"; import type { EntityInfo, IEntityExtractor } from "./types.js"; import { resourceEntityName } from "./fallbackExtractor.js"; -import { isArchitectureSource } from "../../graph/index.js"; +import { isArchitectureSource } from "../../graph/sourceScope.js"; // --------------------------------------------------------------------------- // Constants diff --git a/packages/cli/src/analyzers/analysis/extractors/types.ts b/packages/cli/src/analyzers/analysis/extractors/types.ts index bf8c8c3..65c4419 100644 --- a/packages/cli/src/analyzers/analysis/extractors/types.ts +++ b/packages/cli/src/analyzers/analysis/extractors/types.ts @@ -9,7 +9,7 @@ export type EntityInfo = { name: string; fields: FieldInfo[]; relations: RelationInfo[]; - source: "prisma" | "sql" | "route-hint"; + source: "prisma" | "drizzle" | "sql" | "route-hint"; /** Files this entity was actually found in — set when the source has no * reliable filename/path convention to fall back on (e.g. a "verses" * table referenced inside src/routes/quran.ts has no file named verse.ts @@ -38,7 +38,7 @@ export type EntityGraph = { /** Flat list of entity names — shortcut buat featureDetector & capabilityDetector */ entityNames: string[]; /** Where entities came from — consumers dapat tau seberapa reliable data ini */ - source: "prisma" | "sql" | "route-hint" | "empty"; + source: "prisma" | "drizzle" | "sql" | "route-hint" | "empty"; }; // --------------------------------------------------------------------------- diff --git a/packages/cli/src/analyzers/detectors/capabilityDetector.ts b/packages/cli/src/analyzers/detectors/capabilityDetector.ts index 17bf798..08f678a 100644 --- a/packages/cli/src/analyzers/detectors/capabilityDetector.ts +++ b/packages/cli/src/analyzers/detectors/capabilityDetector.ts @@ -1,6 +1,13 @@ import { isDynamicSegment, isOperationalSegment, LARGE_API_ROUTE_COUNT } from "./nonResourceSegments.js"; import type { RouteInfo } from "./routeDetector.js"; import type { EntityGraph } from "../analysis/index.js"; +import { singularize } from "../analysis/extractors/fallbackExtractor.js"; + +/** + * Read-only endpoints under these names (`GET /api/stats`, `GET /api/reports/weekly`) are + * views over other data. They are the "Statistics & Reporting" capability, not a managed resource. + */ +const READ_ONLY_VIEW_RESOURCE = /^(?:stats?|statistics|analytics?|reports?|reporting)$/; // --------------------------------------------------------------------------- // Types @@ -104,6 +111,7 @@ function detectCrudCapabilities( const hasDelete = methods.has("DELETE"); if (!hasRead && !hasWrite) continue; + if (!hasWrite && READ_ONLY_VIEW_RESOURCE.test(resource)) continue; // In an API of any size, one read-only endpoint (`GET /api/sidebar`) is plumbing, // not a resource someone manages. A small API keeps it: it may be the whole product. @@ -333,20 +341,6 @@ function resolveEntityName(resource: string, entityGraph: EntityGraph): string { return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(""); } -function singularize(word: string): string { - if (word.endsWith("ies")) return word.slice(0, -3) + "y"; - if ( - word.endsWith("sses") || - word.endsWith("xes") || - word.endsWith("ches") || - word.endsWith("ses") - ) { - return word.slice(0, -2); - } - if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1); - return word; -} - function deduplicateCapabilities(capabilities: CapabilityInfo[]): CapabilityInfo[] { const seen = new Map(); diff --git a/packages/cli/src/analyzers/detectors/nonResourceSegments.ts b/packages/cli/src/analyzers/detectors/nonResourceSegments.ts index 601425a..b076bbc 100644 --- a/packages/cli/src/analyzers/detectors/nonResourceSegments.ts +++ b/packages/cli/src/analyzers/detectors/nonResourceSegments.ts @@ -13,6 +13,8 @@ export const OPERATIONAL_SEGMENTS: ReadonlySet = new Set([ "og", "opengraph-image", "twitter-image", "icon", "sitemap", "robots", "manifest", // verbs of the auth flow "login", "logout", "signin", "signout", "signup", "register", "verify", "refresh", + // actions performed on a resource (`/staff/reset`), not resources themselves + "reset", "toggle", "sync", "export", "import", "download", "confirm", "cancel", "approve", "reject", "retry", ]); /** A file served at a fixed URL (robots.txt, sitemap.xml, favicon.ico) is never a resource. */ diff --git a/packages/cli/src/analyzers/features/featureDetector.ts b/packages/cli/src/analyzers/features/featureDetector.ts index 43017d1..76941c4 100644 --- a/packages/cli/src/analyzers/features/featureDetector.ts +++ b/packages/cli/src/analyzers/features/featureDetector.ts @@ -742,7 +742,8 @@ function entityGraphToFeatures(entityGraph: EntityGraph, files: ScannedFile[] = const features: FeatureInfo[] = []; - const meaningfulEntities = (entityGraph.source === "prisma" + const schemaBacked = entityGraph.source === "prisma" || entityGraph.source === "drizzle"; + const meaningfulEntities = (schemaBacked ? entityGraph.entities.filter((e) => relations.some((r) => r.from === e.name || r.to === e.name) ) @@ -798,7 +799,7 @@ function entityGraphToFeatures(entityGraph: EntityGraph, files: ScannedFile[] = .slice(0, 2) .map((e) => e.file); - let confidence: FeatureInfo["confidence"] = entityGraph.source === "prisma" ? "high" : "medium"; + let confidence: FeatureInfo["confidence"] = schemaBacked ? "high" : "medium"; if (entityFiles.length === 1) { const tier = classifyFileTier(entityFiles[0]); if (tier === "reference") confidence = "low"; diff --git a/packages/cli/src/analyzers/pipeline/index.ts b/packages/cli/src/analyzers/pipeline/index.ts index 74ef7e1..c100c02 100644 --- a/packages/cli/src/analyzers/pipeline/index.ts +++ b/packages/cli/src/analyzers/pipeline/index.ts @@ -25,6 +25,7 @@ export { export { detectProjectMetadata, + languageLabel, } from "./projectMetadata.js"; export type { ProjectLanguage, diff --git a/packages/cli/src/analyzers/pipeline/projectMetadata.ts b/packages/cli/src/analyzers/pipeline/projectMetadata.ts index 768319c..5ad77d0 100644 --- a/packages/cli/src/analyzers/pipeline/projectMetadata.ts +++ b/packages/cli/src/analyzers/pipeline/projectMetadata.ts @@ -10,7 +10,41 @@ import { } from "../detectors/frameworkDetector.js"; import { isArchitectureSource } from "../graph/index.js"; -export type ProjectLanguage = "typescript" | "javascript" | "mixed" | "unknown"; +export type ProjectLanguage = + | "typescript" + | "javascript" + | "mixed" + | "php" + | "python" + | "ruby" + | "go" + | "java" + | "csharp" + | "rust" + | "unknown"; + +const LANGUAGE_LABELS: Partial> = { + typescript: "TypeScript", + javascript: "JavaScript", + php: "PHP", + csharp: "C#" +}; + +/** Display name for a detected language: "typescript" → "TypeScript", "csharp" → "C#". */ +export function languageLabel(language: ProjectLanguage): string { + return LANGUAGE_LABELS[language] ?? language.charAt(0).toUpperCase() + language.slice(1); +} + +/** Server-side languages DevMap reads heuristically. Only used to say what a project is NOT JS/TS. */ +const OTHER_LANGUAGE_EXTENSIONS: ReadonlyArray = [ + ["php", [".php"]], + ["python", [".py"]], + ["ruby", [".rb"]], + ["go", [".go"]], + ["java", [".java"]], + ["csharp", [".cs"]], + ["rust", [".rs"]] +]; export type PackageManager = "pnpm" | "npm" | "yarn" | "bun" | "unknown"; export type ProjectType = "node-cli" | "web-app" | "api-service" | "library" | "vscode-extension" | "unknown"; @@ -340,6 +374,16 @@ function detectLanguage(files: ScannedFile[]): ProjectLanguage { [".js", ".jsx", ".mjs", ".cjs"].includes(file.extension) ).length; + const dominantOther = OTHER_LANGUAGE_EXTENSIONS + .map(([language, extensions]) => ({ + language, + count: files.filter((file) => extensions.includes(file.extension)).length + })) + .reduce((best, candidate) => (candidate.count > best.count ? candidate : best), { language: "unknown" as ProjectLanguage, count: 0 }); + + // A Laravel app with a handful of Vue/JS files is a PHP project, not a JavaScript one. + if (dominantOther.count > typeScriptFiles + javaScriptFiles) return dominantOther.language; + if (typeScriptFiles === 0 && javaScriptFiles === 0) return "unknown"; if (typeScriptFiles > 0 && javaScriptFiles === 0) return "typescript"; if (javaScriptFiles > 0 && typeScriptFiles === 0) return "javascript"; diff --git a/packages/cli/src/analyzers/registry/payments.ts b/packages/cli/src/analyzers/registry/payments.ts index 13f25ae..0ba87dd 100644 --- a/packages/cli/src/analyzers/registry/payments.ts +++ b/packages/cli/src/analyzers/registry/payments.ts @@ -5,10 +5,12 @@ export const DESCRIPTORS: SignalDescriptor[] = [ name: "Payments", category: "feature", purpose: "Handles payment providers, billing, and transaction workflows.", + // "checkout" is deliberately absent: a cart or point-of-sale checkout creates an order, + // it does not prove a payment provider or billing flow. genericTerms: [ "stripe", "midtrans", "xendit", "@xendit", "paypal", "braintree", "razorpay", "paddle", "lemonsqueezy", "lemon-squeezy", - "payment", "checkout", "billing", "invoice", + "payment", "billing", "invoice", ], }, { diff --git a/packages/cli/src/cache/agentNavigation.ts b/packages/cli/src/cache/agentNavigation.ts index bf987fe..f78ff82 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 type { ProjectMap } from "../analyzers/pipeline/index.js"; +import { languageLabel, type ProjectMap } from "../analyzers/pipeline/index.js"; import { compareText } from "../utils/compare.js"; export type AgentNavigationWriteResult = { @@ -240,7 +240,7 @@ function selectCriticalFiles(feature: FeatureInfo, snapshot: ProjectMap): string } function createProjectSummary(snapshot: ProjectMap): string { - const language = formatLabel(snapshot.project.language); + const language = languageLabel(snapshot.project.language); const projectKind = describeProjectKind(snapshot); const featureNames = snapshot.features .filter((feature) => feature.confidence !== "low") @@ -278,12 +278,6 @@ function describeProjectKind(snapshot: ProjectMap): string { return `${workspace} containing ${kinds.slice(0, -1).join(", ")} and ${kinds[kinds.length - 1]}`; } -function formatLabel(value: string): string { - if (value === "typescript") return "TypeScript"; - if (value === "javascript") return "JavaScript"; - return value.charAt(0).toUpperCase() + value.slice(1); -} - function featureId(name: string): string { return name .toLowerCase() diff --git a/packages/cli/src/onboarding/modelBuilder.ts b/packages/cli/src/onboarding/modelBuilder.ts index bf95a21..760bfe8 100644 --- a/packages/cli/src/onboarding/modelBuilder.ts +++ b/packages/cli/src/onboarding/modelBuilder.ts @@ -1,5 +1,5 @@ import { REASON_TAGS } from "../analyzers/pipeline/reasonTags.js"; -import type { ProjectMap } from "../analyzers/pipeline/index.js"; +import { languageLabel, type ProjectMap } from "../analyzers/pipeline/index.js"; import type { OnboardingLanguage, OnboardingModel, @@ -99,7 +99,7 @@ function buildStackLine(snapshot: ProjectMap): string { const parts: string[] = []; if (snapshot.project.language && snapshot.project.language !== "unknown") { - parts.push(capitalize(snapshot.project.language)); + parts.push(languageLabel(snapshot.project.language)); } if (snapshot.project.framework && snapshot.project.framework !== "unknown") { diff --git a/packages/cli/test/analyzer-grounding.test.ts b/packages/cli/test/analyzer-grounding.test.ts index 9073ba0..b4c8dbc 100644 --- a/packages/cli/test/analyzer-grounding.test.ts +++ b/packages/cli/test/analyzer-grounding.test.ts @@ -7,7 +7,7 @@ import { detectFrameworks } from "../src/analyzers/detectors/frameworkDetector.j import { detectEntryPoints } from "../src/analyzers/graph/entryPoints.js"; import { isArchitectureSource } from "../src/analyzers/graph/sourceScope.js"; import { inferDomain, type DomainInferenceInput } from "../src/analyzers/inference/domainInference.js"; -import { detectProjectMetadata } from "../src/analyzers/pipeline/projectMetadata.js"; +import { detectProjectMetadata, languageLabel } from "../src/analyzers/pipeline/projectMetadata.js"; import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; import { writeAgentNavigationFiles } from "../src/cache/agentNavigation.js"; @@ -41,16 +41,51 @@ const PACKAGE = JSON.stringify({ name: "fixture", dependencies: {} }); // Project metadata: nothing is reported that the files do not show // --------------------------------------------------------------------------- -test("a project without TypeScript or JavaScript files has an unknown language, not typescript", () => { +test("a project without TypeScript or JavaScript files reports its own language, never typescript", () => { const files = [ scanned("manage.py", "import os\n"), scanned("app/models.py", "class Article:\n pass\n"), scanned("requirements.txt", "django\n") ]; + assert.equal(detectProjectMetadata("/fixture", "unknown", files).language, "python"); +}); + +test("a project with no recognised source files has an unknown language", () => { + const files = [scanned("README.md", "# notes\n"), scanned("data/seed.csv", "a,b\n")]; + assert.equal(detectProjectMetadata("/fixture", "unknown", files).language, "unknown"); }); +test("a Laravel app with a few JS files is a php project, not a javascript one", () => { + const files = [ + scanned("app/Http/Controllers/DebtController.php", " { + const files = [ + scanned("src/a.ts", "export const a = 1;\n"), + scanned("src/b.ts", "export const b = 1;\n"), + scanned("scripts/deploy.py", "print('x')\n") + ]; + + assert.equal(detectProjectMetadata("/fixture", "unknown", files).language, "typescript"); +}); + +test("languageLabel gives display names", () => { + assert.equal(languageLabel("typescript"), "TypeScript"); + assert.equal(languageLabel("php"), "PHP"); + assert.equal(languageLabel("csharp"), "C#"); + assert.equal(languageLabel("python"), "Python"); +}); + test("a TypeScript project is still reported as typescript", () => { const files = [scanned("package.json", PACKAGE), scanned("src/index.ts", "export const a = 1;\n")]; diff --git a/packages/cli/test/capability-grounding.test.ts b/packages/cli/test/capability-grounding.test.ts new file mode 100644 index 0000000..8222e38 --- /dev/null +++ b/packages/cli/test/capability-grounding.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { detectCapabilities } from "../src/analyzers/detectors/capabilityDetector.js"; +import { detectFeatures } from "../src/analyzers/features/featureDetector.js"; + +const graph = (names: string[]) => ({ + entities: names.map((name) => ({ name, fields: [], relations: [], source: "prisma" as const })), + relations: [], + entityNames: names, + source: "prisma" as const +}); + +const api = (path: string, methods: string[]) => ({ + path, + file: `src/app/api${path.replace(/^\/api/, "")}/route.ts`, + kind: "api" as const, + methods +}); + +function scanned(path: string, content = "export const x = 1;\n") { + return { + path, + absolutePath: `/fixture/${path}`, + extension: path.slice(path.lastIndexOf(".")), + size: Buffer.byteLength(content), + lines: content.split(/\r?\n/).length, + content + }; +} + +test("a plural resource resolves to the model that carries its singular name", () => { + const capabilities = detectCapabilities( + [api("/api/exercises", ["GET", "POST"]), api("/api/exercises/[id]", ["GET", "PUT", "DELETE"])], + graph(["Exercise"]) + ); + + assert.deepEqual(capabilities.map((capability) => capability.name), ["Exercise Management"]); +}); + +test("a resource without a model is named from its singular, silent-e plurals included", () => { + const capabilities = detectCapabilities( + [api("/api/responses", ["GET", "POST"]), api("/api/statuses", ["GET", "POST"])], + graph([]) + ); + + assert.deepEqual(capabilities.map((capability) => capability.name).sort(), ["Response Management", "Status Management"]); +}); + +test("read-only stats and report endpoints are reporting, not a managed resource", () => { + const capabilities = detectCapabilities( + [ + api("/api/exercises", ["GET", "POST"]), + api("/api/stats", ["GET"]), + api("/api/stats/today", ["GET"]), + api("/api/reports/weekly", ["GET"]), + api("/api/reports/monthly", ["GET"]) + ], + graph(["Exercise"]) + ); + const names = capabilities.map((capability) => capability.name); + + assert.ok(names.includes("Exercise Management")); + assert.ok(names.includes("Statistics & Reporting")); + assert.ok(!names.includes("Stat Management")); + assert.ok(!names.includes("Report Management")); +}); + +test("a stats resource that accepts writes is still a managed resource", () => { + const capabilities = detectCapabilities([api("/api/reports", ["GET", "POST", "DELETE"])], graph([])); + + assert.ok(capabilities.some((capability) => capability.name === "Report Management")); +}); + +test("a cart checkout file does not make a Payments feature", () => { + const { features } = detectFeatures( + [ + scanned("src/lib/server/domains/transactions/checkout.ts"), + scanned("src/lib/server/domains/transactions/index.ts", "export * from './checkout';\n") + ], + {}, + [] + ); + + assert.ok(!features.some((feature) => feature.name === "Payments")); +}); + +test("a payment provider module still makes a Payments feature", () => { + const { features } = detectFeatures( + [scanned("src/lib/stripe.ts"), scanned("src/lib/payment/webhook.ts")], + {}, + [] + ); + + assert.ok(features.some((feature) => feature.name === "Payments")); +}); diff --git a/packages/cli/test/entity-extractors.test.ts b/packages/cli/test/entity-extractors.test.ts index 3c2c1d6..e133969 100644 --- a/packages/cli/test/entity-extractors.test.ts +++ b/packages/cli/test/entity-extractors.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { SQLExtractor } from "../src/analyzers/analysis/extractors/sqlExtractor.js"; -import { singularize } from "../src/analyzers/analysis/extractors/fallbackExtractor.js"; +import { DrizzleExtractor } from "../src/analyzers/analysis/extractors/drizzleExtractor.js"; +import { extractEntities } from "../src/analyzers/analysis/extractors/index.js"; +import { RouteFallbackExtractor, singularize } from "../src/analyzers/analysis/extractors/fallbackExtractor.js"; import { detectDatabase } from "../src/analyzers/detectors/databaseDetector.js"; function scannedFile(path: string, content: string) { @@ -145,3 +147,143 @@ test("databaseDetector recognizes raw SQL clients (pg, mysql2, better-sqlite3)", ]; assert.equal(detectDatabase(sqliteProject)?.provider, "SQLite (raw SQL)"); }); + +test("singularize keeps the trailing s of singulars that end in s", () => { + assert.equal(singularize("statuses"), "Status"); + assert.equal(singularize("buses"), "Bus"); + assert.equal(singularize("aliases"), "Alias"); + assert.equal(singularize("exercises"), "Exercise"); + assert.equal(singularize("purchases"), "Purchase"); + assert.equal(singularize("licenses"), "License"); +}); + +test("route-hint entities skip action segments such as /staff/reset", () => { + const entities = new RouteFallbackExtractor().extract([ + { path: "/api/staff", file: "src/routes/api/staff/+server.ts", kind: "api", methods: ["GET", "POST"] }, + { path: "/api/staff/reset", file: "src/routes/api/staff/reset/+server.ts", kind: "api", methods: ["POST"] } + ]); + + assert.deepEqual(entities.map((entity) => entity.name), ["Staff"]); +}); + +const DRIZZLE_SCHEMA = [ + "import { pgTable, text, integer, timestamp, boolean } from 'drizzle-orm/pg-core';", + "import { relations, sql } from 'drizzle-orm';", + "", + "// Tabel bisnis; komentar dengan kurung ( dan tanda kutip ' tidak boleh merusak parser.", + "export const business = pgTable('business', {", + " id: text('id').primaryKey(),", + " name: text('name').notNull(),", + " note: text('note').default('a, b, {c}')", + "});", + "", + "export const user = pgTable('user', {", + " id: text('id').primaryKey(),", + " businessId: text('business_id').references(() => business.id),", + " email: text('email').notNull().unique()", + "});", + "", + "export const staffInvitations = pgTable(", + " 'staff_invitations',", + " {", + " id: text('id').primaryKey(),", + " businessId: text('business_id').notNull().references(() => business.id, { onDelete: 'cascade' }),", + " createdBy: text('created_by').references(() => user.id)", + " },", + " (t) => [sql`check (${t.id} <> '')`]", + ");", + "", + "export const profile = pgTable('profile', {", + " id: text('id').primaryKey(),", + " userId: text('user_id').notNull().unique().references(() => user.id)", + "});", + "", + "export const categoryNode = pgTable('category_node', {", + " id: text('id').primaryKey(),", + " parentId: text('parent_id').references((): any => categoryNode.id)", + "});", + "", + "export const roleEnum = pgEnum('role', ['OWNER', 'STAFF']);" +].join("\n"); + +test("DrizzleExtractor reads tables, columns and foreign keys from a schema file", () => { + const extractor = new DrizzleExtractor(); + const files = [scannedFile("src/lib/server/db/schema.ts", DRIZZLE_SCHEMA)]; + + assert.equal(extractor.canHandle(files), true); + const entities = extractor.extract(files); + + assert.deepEqual(entities.map((entity) => entity.name), [ + "Business", "User", "StaffInvitation", "Profile", "CategoryNode" + ]); + assert.ok(entities.every((entity) => entity.source === "drizzle")); + + const business = entities.find((entity) => entity.name === "Business"); + assert.deepEqual(business?.fields.filter((field) => !field.isRelation).map((field) => field.name), ["id", "name", "note"]); + assert.equal(business?.fields.find((field) => field.name === "name")?.isOptional, false); + assert.equal(business?.fields.find((field) => field.name === "note")?.isOptional, true); +}); + +test("DrizzleExtractor turns foreign keys into relations in both directions", () => { + const entities = new DrizzleExtractor().extract([scannedFile("db/schema.ts", DRIZZLE_SCHEMA)]); + const relations = entities.flatMap((entity) => entity.relations); + const has = (from: string, to: string, kind: string) => + relations.some((relation) => relation.from === from && relation.to === to && relation.kind === kind); + + assert.ok(has("Business", "User", "one-to-many")); + assert.ok(has("Business", "StaffInvitation", "one-to-many")); + assert.ok(has("User", "StaffInvitation", "one-to-many")); + // A unique foreign key is a one-to-one, and a self reference is not a cross-entity relation. + assert.ok(relations.some((relation) => relation.from.length > 0 && relation.to === "Profile" && relation.kind === "one-to-one")); + assert.ok(!relations.some((relation) => relation.from === "CategoryNode" && relation.to === "CategoryNode")); +}); + +test("DrizzleExtractor names an entity from the SQL table, not from a `usersTable` variable", () => { + const entities = new DrizzleExtractor().extract([ + scannedFile( + "src/db/schema.ts", + "import { sqliteTable, text } from 'drizzle-orm/sqlite-core';\nexport const usersTable = sqliteTable('users', { id: text('id') });\n" + ) + ]); + + assert.deepEqual(entities.map((entity) => entity.name), ["User"]); +}); + +test("DrizzleExtractor reads relations() declarations that have no foreign key", () => { + const entities = new DrizzleExtractor().extract([ + scannedFile( + "src/db/schema.ts", + [ + "import { mysqlTable, int } from 'drizzle-orm/mysql-core';", + "import { relations } from 'drizzle-orm';", + "export const author = mysqlTable('author', { id: int('id').primaryKey() });", + "export const book = mysqlTable('book', { id: int('id').primaryKey(), authorId: int('author_id') });", + "export const authorRelations = relations(author, ({ many }) => ({ books: many(book) }));" + ].join("\n") + ) + ]); + + const relations = entities.flatMap((entity) => entity.relations); + assert.ok(relations.some((relation) => relation.from === "Author" && relation.to === "Book" && relation.kind === "one-to-many")); +}); + +test("DrizzleExtractor ignores pgTable look-alikes in files that do not use drizzle-orm", () => { + const extractor = new DrizzleExtractor(); + const files = [ + scannedFile("src/other.ts", "export const pgTable = () => 1;\nexport const t = pgTable('x', {});\n"), + scannedFile("docs/schema.md", "import from 'drizzle-orm'\npgTable('x', {})") + ]; + + assert.equal(extractor.canHandle(files), false); + assert.deepEqual(extractor.extract(files), []); +}); + +test("extractEntities prefers a Prisma schema over Drizzle and Drizzle over route hints", () => { + const routes = [{ path: "/api/widgets", file: "src/routes/api/widgets/+server.ts", kind: "api" as const, methods: ["GET", "POST"] }]; + const drizzleFile = scannedFile("src/db/schema.ts", DRIZZLE_SCHEMA); + const prismaFile = scannedFile("prisma/schema.prisma", "model Order {\n id Int @id\n}\n"); + + assert.equal(extractEntities([drizzleFile], routes).source, "drizzle"); + assert.deepEqual(extractEntities([drizzleFile], routes).entityNames.includes("Widget"), false); + assert.equal(extractEntities([drizzleFile, prismaFile], routes).source, "prisma"); +});