Initial commit
This commit is contained in:
357
tools/dsl-summary.mjs
Normal file
357
tools/dsl-summary.mjs
Normal file
@@ -0,0 +1,357 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function parseDefaultValue(rawValue) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function getDslFiles(rootDir) {
|
||||
const domainDir = path.join(rootDir, 'domain');
|
||||
|
||||
return readdirSync(domainDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.dsl'))
|
||||
.map((entry) => path.join(domainDir, entry.name))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function getOverrideFiles(rootDir) {
|
||||
const overridesDir = path.join(rootDir, 'overrides');
|
||||
const files = ['api-overrides.dsl', 'ui-overrides.dsl']
|
||||
.map((name) => path.join(overridesDir, name))
|
||||
.filter((filePath) => {
|
||||
try {
|
||||
return readdirSync(path.dirname(filePath)).includes(path.basename(filePath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function parseDslFiles(rootDir) {
|
||||
const dslFiles = getDslFiles(rootDir);
|
||||
const enums = [];
|
||||
const entities = [];
|
||||
const stack = [];
|
||||
|
||||
for (const filePath of dslFiles) {
|
||||
const contents = readFileSync(filePath, 'utf8');
|
||||
const lines = contents.split(/\r?\n/);
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = stripInlineComment(rawLine);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const top = stack.at(-1);
|
||||
|
||||
const enumMatch = line.match(/^enum\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (!top && enumMatch) {
|
||||
const enumDefinition = { name: enumMatch[1], values: [] };
|
||||
enums.push(enumDefinition);
|
||||
stack.push({ type: 'enum', ref: enumDefinition });
|
||||
continue;
|
||||
}
|
||||
|
||||
const entityMatch = line.match(/^entity\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (!top && entityMatch) {
|
||||
const entityDefinition = {
|
||||
name: entityMatch[1],
|
||||
primaryKey: null,
|
||||
fields: [],
|
||||
foreignKeys: [],
|
||||
};
|
||||
entities.push(entityDefinition);
|
||||
stack.push({ type: 'entity', ref: entityDefinition });
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueMatch = line.match(/^value\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (top?.type === 'enum' && valueMatch) {
|
||||
const enumValue = { name: valueMatch[1] };
|
||||
top.ref.values.push(enumValue);
|
||||
stack.push({ type: 'enumValue', ref: enumValue });
|
||||
continue;
|
||||
}
|
||||
|
||||
const attributeMatch = line.match(/^attribute\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||
if (top?.type === 'entity' && attributeMatch) {
|
||||
const field = {
|
||||
name: attributeMatch[1],
|
||||
type: null,
|
||||
required: false,
|
||||
unique: false,
|
||||
default: null,
|
||||
};
|
||||
top.ref.fields.push(field);
|
||||
stack.push({ type: 'field', ref: field, entity: top.ref });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (top?.type === 'field' && /^key\s+foreign\s*\{$/.test(line)) {
|
||||
stack.push({ type: 'foreignKey', ref: top.ref, entity: top.entity });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (top?.type === 'enumValue') {
|
||||
const labelMatch = line.match(/^label\s+"(.*)"\s*;$/);
|
||||
if (labelMatch) {
|
||||
top.ref.label = labelMatch[1];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (top?.type === 'field') {
|
||||
const typeMatch = line.match(/^type\s+([A-Za-z][A-Za-z0-9_]*)\s*;$/);
|
||||
if (typeMatch) {
|
||||
top.ref.type = typeMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^key\s+primary\s*;$/.test(line)) {
|
||||
top.ref.primary = true;
|
||||
top.entity.primaryKey = top.ref.name;
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaultMatch = line.match(/^default\s+(.+)\s*;$/);
|
||||
if (defaultMatch) {
|
||||
top.ref.default = parseDefaultValue(defaultMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^is\s+required\s*;$/.test(line)) {
|
||||
top.ref.required = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^is\s+unique\s*;$/.test(line)) {
|
||||
top.ref.unique = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (top?.type === 'foreignKey') {
|
||||
const relatesMatch = line.match(
|
||||
/^relates\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s*;$/,
|
||||
);
|
||||
if (relatesMatch) {
|
||||
const foreignKey = {
|
||||
field: top.ref.name,
|
||||
references: {
|
||||
entity: relatesMatch[1],
|
||||
field: relatesMatch[2],
|
||||
},
|
||||
};
|
||||
|
||||
top.ref.foreignKey = foreignKey.references;
|
||||
top.entity.foreignKeys.push(foreignKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^}\s*;?$/.test(line)) {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { dslFiles, enums, entities };
|
||||
}
|
||||
|
||||
function assertNoDomainRedefinition(line, filePath) {
|
||||
const blocked = [/^\s*entity\s+/i, /^\s*enum\s+/i, /^\s*attribute\s+/i, /^\s*key\s+primary/i, /^\s*key\s+foreign/i];
|
||||
for (const pattern of blocked) {
|
||||
if (pattern.test(line)) {
|
||||
throw new Error(
|
||||
`Override file ${path.basename(filePath)} attempts to redefine domain structure: ${line.trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOverrides(rootDir, entities) {
|
||||
const overrideFiles = getOverrideFiles(rootDir);
|
||||
const entityNames = new Set(entities.map((entity) => entity.name));
|
||||
const fieldNames = new Set(
|
||||
entities.flatMap((entity) => entity.fields.map((field) => `${entity.name}.${field.name}`)),
|
||||
);
|
||||
|
||||
const api = { resources: {} };
|
||||
const ui = { fields: {} };
|
||||
|
||||
for (const filePath of overrideFiles) {
|
||||
const isApi = filePath.endsWith('api-overrides.dsl');
|
||||
const isUi = filePath.endsWith('ui-overrides.dsl');
|
||||
const lines = readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = stripInlineComment(rawLine);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertNoDomainRedefinition(line, filePath);
|
||||
|
||||
if (isApi) {
|
||||
const match = line.match(/^resource\s+([A-Za-z][A-Za-z0-9_]*)\s+path\s+"([^"]+)"\s*;$/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Unsupported API override syntax in ${path.basename(filePath)}: ${line}`,
|
||||
);
|
||||
}
|
||||
const [, entityName, resourcePath] = match;
|
||||
if (!entityNames.has(entityName)) {
|
||||
throw new Error(`API override references unknown entity ${entityName}`);
|
||||
}
|
||||
api.resources[entityName] = { path: resourcePath };
|
||||
}
|
||||
|
||||
if (isUi) {
|
||||
const match = line.match(
|
||||
/^field\s+([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\s+widget\s+"([^"]+)"\s*;$/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Unsupported UI override syntax in ${path.basename(filePath)}: ${line}`,
|
||||
);
|
||||
}
|
||||
const [, entityName, fieldName, widget] = match;
|
||||
const key = `${entityName}.${fieldName}`;
|
||||
if (!fieldNames.has(key)) {
|
||||
throw new Error(`UI override references unknown field ${key}`);
|
||||
}
|
||||
ui.fields[key] = { widget };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { api, ui, sourceFiles: overrideFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')) };
|
||||
}
|
||||
|
||||
export function buildDomainSummary(rootDir) {
|
||||
const { dslFiles, enums, entities } = parseDslFiles(rootDir);
|
||||
parseOverrides(rootDir, entities);
|
||||
const entityByName = new Map(entities.map((entity) => [entity.name, entity]));
|
||||
const enumByName = new Map(enums.map((entry) => [entry.name, entry]));
|
||||
|
||||
for (const entity of entities) {
|
||||
if (!entity.primaryKey) {
|
||||
throw new Error(`Entity ${entity.name} is missing a primary key`);
|
||||
}
|
||||
|
||||
const primaryKeyField = entity.fields.find((field) => field.name === entity.primaryKey);
|
||||
if (!primaryKeyField) {
|
||||
throw new Error(
|
||||
`Entity ${entity.name} declares primary key ${entity.primaryKey}, but the field is missing`,
|
||||
);
|
||||
}
|
||||
|
||||
if (entity.fields.some((field) => !field.type)) {
|
||||
throw new Error(`Entity ${entity.name} has attributes with missing type`);
|
||||
}
|
||||
|
||||
for (const foreignKey of entity.foreignKeys) {
|
||||
const targetEntity = entityByName.get(foreignKey.references.entity);
|
||||
if (!targetEntity) {
|
||||
throw new Error(
|
||||
`Foreign key ${entity.name}.${foreignKey.field} references missing entity ${foreignKey.references.entity}`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetField = targetEntity.fields.find(
|
||||
(field) => field.name === foreignKey.references.field,
|
||||
);
|
||||
if (!targetField) {
|
||||
throw new Error(
|
||||
`Foreign key ${entity.name}.${foreignKey.field} references missing field ${foreignKey.references.entity}.${foreignKey.references.field}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceField = entity.fields.find((field) => field.name === foreignKey.field);
|
||||
if (sourceField && sourceField.type !== targetField.type) {
|
||||
throw new Error(
|
||||
`Foreign key ${entity.name}.${foreignKey.field} type ${sourceField.type} does not match ${foreignKey.references.entity}.${foreignKey.references.field} type ${targetField.type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldNames = new Set();
|
||||
for (const field of entity.fields) {
|
||||
if (fieldNames.has(field.name)) {
|
||||
throw new Error(`Entity ${entity.name} has duplicate field ${field.name}`);
|
||||
}
|
||||
fieldNames.add(field.name);
|
||||
}
|
||||
}
|
||||
|
||||
const entityNames = new Set();
|
||||
for (const entity of entities) {
|
||||
if (entityNames.has(entity.name)) {
|
||||
throw new Error(`Duplicate entity definition: ${entity.name}`);
|
||||
}
|
||||
entityNames.add(entity.name);
|
||||
}
|
||||
|
||||
for (const [enumName, enumDefinition] of enumByName.entries()) {
|
||||
const valueNames = new Set();
|
||||
for (const value of enumDefinition.values) {
|
||||
if (valueNames.has(value.name)) {
|
||||
throw new Error(`Enum ${enumName} has duplicate value ${value.name}`);
|
||||
}
|
||||
valueNames.add(value.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceFiles: dslFiles.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/')),
|
||||
entities: entities.map((entity) => ({
|
||||
name: entity.name,
|
||||
primaryKey: entity.primaryKey,
|
||||
fields: entity.fields.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
unique: field.unique,
|
||||
default: field.default,
|
||||
})),
|
||||
foreignKeys: entity.foreignKeys,
|
||||
})),
|
||||
enums,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user