rebase generation
This commit is contained in:
389
.claude/worktrees/goofy-haslett/tools/api-summary.mjs
Normal file
389
.claude/worktrees/goofy-haslett/tools/api-summary.mjs
Normal file
@@ -0,0 +1,389 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripInlineComment(line) {
|
||||
let inString = false;
|
||||
let result = '';
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const current = line[index];
|
||||
const next = line[index + 1];
|
||||
|
||||
if (current === '"' && line[index - 1] !== '\\') {
|
||||
inString = !inString;
|
||||
result += current;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString && current === '/' && next === '/') {
|
||||
break;
|
||||
}
|
||||
|
||||
result += current;
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getApiDslFiles(rootDir) {
|
||||
const domainDir = path.join(rootDir, 'domain');
|
||||
try {
|
||||
return readdirSync(domainDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.api.dsl'))
|
||||
.map((entry) => path.join(domainDir, entry.name))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
//
|
||||
// Parses all *.api.dsl files using a stack-based approach.
|
||||
// This is the single canonical parser for the API DSL.
|
||||
//
|
||||
// Supported top-level blocks:
|
||||
// enum <Name> { description?; value <Name> { label?; } }
|
||||
// dto DTO.<Name> { description?; attribute <name> { ... } }
|
||||
// api API.<Name> { description?; endpoint <name> { ... } }
|
||||
//
|
||||
// DTO attribute modifiers (any order inside the attribute block):
|
||||
// type <type>;
|
||||
// description "...";
|
||||
// map Entity.field;
|
||||
// sync Entity.field; (alias for map — used for computed/aggregate fields)
|
||||
// is required;
|
||||
// is nullable;
|
||||
// is unique;
|
||||
// key primary;
|
||||
// label "...";
|
||||
//
|
||||
// Endpoint modifiers:
|
||||
// label "METHOD /path";
|
||||
// description "...";
|
||||
// attribute <name> { type <type>; description?; }
|
||||
//
|
||||
// Returns:
|
||||
// {
|
||||
// files: string[],
|
||||
// enums: EnumBlock[],
|
||||
// dtos: DtoBlock[],
|
||||
// apis: ApiBlock[],
|
||||
// }
|
||||
//
|
||||
// EnumBlock = { name, description, values: EnumValue[] }
|
||||
// EnumValue = { name, label }
|
||||
// DtoBlock = { name, description, fields: DtoField[] }
|
||||
// DtoField = { name, type, required, nullable, unique, primary, description, map, label }
|
||||
// ApiBlock = { name, description, endpoints: Endpoint[] }
|
||||
// Endpoint = { name, label, method, path, description, attributes: EndpointAttr[] }
|
||||
// EndpointAttr = { name, type, description }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseApiDsl(rootDir) {
|
||||
const files = getApiDslFiles(rootDir);
|
||||
const enums = [];
|
||||
const dtos = [];
|
||||
const apis = [];
|
||||
const stack = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = stripInlineComment(rawLine);
|
||||
if (!line) continue;
|
||||
|
||||
const top = stack.at(-1);
|
||||
|
||||
// ── Top-level: enum block ──────────────────────────────────────────
|
||||
const enumMatch = line.match(/^enum\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (!top && enumMatch) {
|
||||
const enumBlock = { name: enumMatch[1], description: null, values: [] };
|
||||
enums.push(enumBlock);
|
||||
stack.push({ type: 'enum', ref: enumBlock });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Top-level: dto block ───────────────────────────────────────────
|
||||
const dtoMatch = line.match(/^dto\s+(DTO\.\w+)\s*\{$/);
|
||||
if (!top && dtoMatch) {
|
||||
const dto = { name: dtoMatch[1], description: null, fields: [] };
|
||||
dtos.push(dto);
|
||||
stack.push({ type: 'dto', ref: dto });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Top-level: api block ───────────────────────────────────────────
|
||||
const apiMatch = line.match(/^api\s+(API\.\w+)\s*\{$/);
|
||||
if (!top && apiMatch) {
|
||||
const api = { name: apiMatch[1], description: null, endpoints: [] };
|
||||
apis.push(api);
|
||||
stack.push({ type: 'api', ref: api });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Inside enum ───────────────────────────────────────────────────
|
||||
if (top?.type === 'enum') {
|
||||
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||
if (descMatch) {
|
||||
top.ref.description = descMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueMatch = line.match(/^value\s+([^\s{]+)\s*\{$/);
|
||||
if (valueMatch) {
|
||||
const enumValue = { name: valueMatch[1], label: null };
|
||||
top.ref.values.push(enumValue);
|
||||
stack.push({ type: 'enumValue', ref: enumValue });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside enum value ─────────────────────────────────────────────
|
||||
if (top?.type === 'enumValue') {
|
||||
const labelMatch = line.match(/^label\s+"(.*)"\s*;$/);
|
||||
if (labelMatch) {
|
||||
top.ref.label = labelMatch[1];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside dto ────────────────────────────────────────────────────
|
||||
if (top?.type === 'dto') {
|
||||
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||
if (descMatch) {
|
||||
top.ref.description = descMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
const attrMatch = line.match(/^attribute\s+(\w+)\s*\{$/);
|
||||
if (attrMatch) {
|
||||
const field = {
|
||||
name: attrMatch[1],
|
||||
type: null,
|
||||
required: false,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
primary: false,
|
||||
description: null,
|
||||
map: null,
|
||||
label: null,
|
||||
};
|
||||
top.ref.fields.push(field);
|
||||
stack.push({ type: 'dtoField', ref: field });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside dto attribute field ────────────────────────────────────
|
||||
if (top?.type === 'dtoField') {
|
||||
const typeMatch = line.match(/^type\s+(.+?)\s*;$/);
|
||||
if (typeMatch) {
|
||||
top.ref.type = typeMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^is\s+required\s*;$/.test(line)) {
|
||||
top.ref.required = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^is\s+nullable\s*;$/.test(line)) {
|
||||
top.ref.nullable = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^is\s+unique\s*;$/.test(line)) {
|
||||
top.ref.unique = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^key\s+primary\s*;$/.test(line)) {
|
||||
top.ref.primary = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||
if (descMatch) {
|
||||
top.ref.description = descMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
// map Entity.field; — canonical field mapping
|
||||
const mapMatch = line.match(/^map\s+(\w+)\.(\w+)\s*;$/);
|
||||
if (mapMatch) {
|
||||
top.ref.map = `${mapMatch[1]}.${mapMatch[2]}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// sync Entity.field; — aggregate / computed field mapping (treated as map)
|
||||
const syncMatch = line.match(/^sync\s+(\w+)\.(\w+)\s*;$/);
|
||||
if (syncMatch) {
|
||||
top.ref.map = `${syncMatch[1]}.${syncMatch[2]}`;
|
||||
top.ref.sync = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelMatch = line.match(/^label\s+"(.*)"\s*;$/);
|
||||
if (labelMatch) {
|
||||
top.ref.label = labelMatch[1];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside api ────────────────────────────────────────────────────
|
||||
if (top?.type === 'api') {
|
||||
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||
if (descMatch) {
|
||||
top.ref.description = descMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
const epMatch = line.match(/^endpoint\s+(\w+)\s*\{$/);
|
||||
if (epMatch) {
|
||||
const ep = {
|
||||
name: epMatch[1],
|
||||
label: null,
|
||||
method: null,
|
||||
path: null,
|
||||
description: null,
|
||||
attributes: [],
|
||||
};
|
||||
top.ref.endpoints.push(ep);
|
||||
stack.push({ type: 'endpoint', ref: ep });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside endpoint ───────────────────────────────────────────────
|
||||
if (top?.type === 'endpoint') {
|
||||
const labelMatch = line.match(/^label\s+"([^"]+)"\s*;$/);
|
||||
if (labelMatch) {
|
||||
top.ref.label = labelMatch[1];
|
||||
const parts = labelMatch[1].split(' ', 2);
|
||||
top.ref.method = parts[0]?.toUpperCase() ?? null;
|
||||
top.ref.path = parts[1] ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||
if (descMatch) {
|
||||
top.ref.description = descMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
const attrMatch = line.match(/^attribute\s+(\w+)\s*\{$/);
|
||||
if (attrMatch) {
|
||||
const attr = { name: attrMatch[1], type: null, description: null };
|
||||
top.ref.attributes.push(attr);
|
||||
stack.push({ type: 'endpointAttr', ref: attr });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inside endpoint attribute ─────────────────────────────────────
|
||||
if (top?.type === 'endpointAttr') {
|
||||
const typeMatch = line.match(/^type\s+(.+?)\s*;$/);
|
||||
if (typeMatch) {
|
||||
top.ref.type = typeMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||
if (descMatch) {
|
||||
top.ref.description = descMatch[1];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Closing brace — pop the stack ─────────────────────────────────
|
||||
if (/^}\s*;?$/.test(line)) {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { files, enums, dtos, apis };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary builder
|
||||
//
|
||||
// Produces the serialisable api-summary.json object.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildApiSummary(rootDir) {
|
||||
const { files, enums, dtos, apis } = parseApiDsl(rootDir);
|
||||
|
||||
// Detect duplicate DTO names
|
||||
const dtoNames = new Set();
|
||||
for (const dto of dtos) {
|
||||
if (dtoNames.has(dto.name)) {
|
||||
throw new Error(`Duplicate DTO definition: ${dto.name}`);
|
||||
}
|
||||
dtoNames.add(dto.name);
|
||||
}
|
||||
|
||||
// Detect duplicate API names
|
||||
const apiNames = new Set();
|
||||
for (const api of apis) {
|
||||
if (apiNames.has(api.name)) {
|
||||
throw new Error(`Duplicate API definition: ${api.name}`);
|
||||
}
|
||||
apiNames.add(api.name);
|
||||
}
|
||||
|
||||
return {
|
||||
sourceFiles: files.map((filePath) =>
|
||||
path.relative(rootDir, filePath).replaceAll('\\', '/'),
|
||||
),
|
||||
enums: enums.map((e) => ({
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
values: e.values.map((v) => ({ name: v.name, label: v.label })),
|
||||
})),
|
||||
dtos: dtos.map((dto) => ({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
fields: dto.fields.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
nullable: field.nullable,
|
||||
unique: field.unique,
|
||||
primary: field.primary,
|
||||
description: field.description,
|
||||
map: field.map,
|
||||
sync: field.sync ?? false,
|
||||
label: field.label,
|
||||
})),
|
||||
})),
|
||||
apis: apis.map((api) => ({
|
||||
name: api.name,
|
||||
description: api.description,
|
||||
endpoints: api.endpoints.map((ep) => ({
|
||||
name: ep.name,
|
||||
label: ep.label,
|
||||
method: ep.method,
|
||||
path: ep.path,
|
||||
description: ep.description,
|
||||
attributes: ep.attributes.map((attr) => ({
|
||||
name: attr.name,
|
||||
type: attr.type,
|
||||
description: attr.description,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user