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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions docs/guide/04-entity-extraction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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.

Expand Down Expand Up @@ -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.)
Expand Down
302 changes: 302 additions & 0 deletions packages/cli/src/analyzers/analysis/extractors/drizzleExtractor.ts
Original file line number Diff line number Diff line change
@@ -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<string, EntityInfo>();
const variableOf = new Map<string, string>();

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<string, string> = { "(": ")", "[": "]", "{": "}" };
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading