git init
This commit is contained in:
78
tools/api-format-to-openapi/README.md
Normal file
78
tools/api-format-to-openapi/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# api-format → OpenAPI 3.0
|
||||
|
||||
Абстрактная заготовка под задачу: **из доменного описания API получить OpenAPI 3.0**, затем встроить в общий пайплайн (кнопка / CI / генератор).
|
||||
|
||||
## Что внутри
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `examples/api-format.example.json` | Пример **не-OpenAPI** формата: ресурсы, поля, операции, query для списка |
|
||||
| `prompts/llm-system.md` | Системный промпт для LLM: «верни только JSON OpenAPI 3.0.3» |
|
||||
| `convert.mjs` | CLI: режим `deterministic` (маппинг в коде) и `llm` (OpenAI API) |
|
||||
|
||||
## Пошаговая демонстрация в терминале
|
||||
|
||||
Чтобы **постепенно** увидеть: входной api-format → что делает конвертер → структура OpenAPI:
|
||||
|
||||
```bash
|
||||
cd tools/api-format-to-openapi
|
||||
npm run demo
|
||||
```
|
||||
|
||||
С паузой после каждого шага (нажимай Enter):
|
||||
|
||||
```bash
|
||||
npm run demo:pause
|
||||
```
|
||||
|
||||
Результат кладётся в `demo-output/openapi.json`.
|
||||
|
||||
## Детерминированный режим (без LLM)
|
||||
|
||||
Подходит для **фиксированной** схемы `apiFormatVersion: "1"` как в примере.
|
||||
|
||||
```bash
|
||||
cd tools/api-format-to-openapi
|
||||
node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
|
||||
```
|
||||
|
||||
Или через npm:
|
||||
|
||||
```bash
|
||||
cd tools/api-format-to-openapi
|
||||
npm run convert
|
||||
```
|
||||
|
||||
## Режим LLM
|
||||
|
||||
Когда реальный формат отличается или богаче — прогон через модель с промптом из `prompts/llm-system.md`.
|
||||
|
||||
```bash
|
||||
set OPENAI_API_KEY=sk-...
|
||||
cd tools/api-format-to-openapi
|
||||
node convert.mjs --mode llm --in path/to/your-api-format.json --out ../../openapi.llm.json
|
||||
```
|
||||
|
||||
Переменные:
|
||||
|
||||
- `OPENAI_API_KEY` — обязательно
|
||||
- `OPENAI_MODEL` — по умолчанию `gpt-4o-mini`
|
||||
- `OPENAI_BASE_URL` — по умолчанию `https://api.openai.com/v1` (совместимо с прокси)
|
||||
|
||||
## HTTP-экспортёр для AID (NestJS)
|
||||
|
||||
В `server` добавлен модуль **`AidExportModule`**: `POST /aid/export/openapi` принимает `{ "apiFormat": {...}, "mode"?: "deterministic"|"llm" }` и возвращает `{ "openapi": {...} }`. Подробности: `server/src/aid-export/README.md`.
|
||||
|
||||
## Интеграция позже
|
||||
|
||||
1. Заменить/расширить `examples/api-format.example.json` под ваш настоящий контракт из «алабужского» гита.
|
||||
2. Либо расширить `toOpenApiDeterministic` в `convert.mjs`, либо перейти на `--mode llm` с отточенным промптом.
|
||||
3. Согласовать с AID точный URL, заголовки и (при необходимости) обёртку ответа; при необходимости добавить отдельный маршрут «сырой» OpenAPI без `{ openapi: ... }`.
|
||||
|
||||
## Валидация OpenAPI (опционально)
|
||||
|
||||
После генерации можно проверить спеку любым валидатором, например:
|
||||
|
||||
```bash
|
||||
npx -y @apidevtools/swagger-cli validate ../../openapi.generated.json
|
||||
```
|
||||
341
tools/api-format-to-openapi/convert.mjs
Normal file
341
tools/api-format-to-openapi/convert.mjs
Normal file
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* api-format → OpenAPI 3.0
|
||||
*
|
||||
* Режимы:
|
||||
* --mode deterministic — маппинг только для схемы examples/api-format.example.json (и совместимых)
|
||||
* --mode llm — отправка входного JSON в OpenAI Chat Completions (нужен OPENAI_API_KEY)
|
||||
*
|
||||
* Примеры:
|
||||
* node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
|
||||
* node convert.mjs --mode llm --in my-api.json --out openapi.json
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { mode: "deterministic", input: null, output: null };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--mode") out.mode = argv[++i];
|
||||
else if (a === "--in") out.input = argv[++i];
|
||||
else if (a === "--out") out.output = argv[++i];
|
||||
else if (a === "-h" || a === "--help") out.help = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`
|
||||
Usage: node convert.mjs --in <api-format.json> --out <openapi.json> [--mode deterministic|llm]
|
||||
|
||||
Environment (llm mode):
|
||||
OPENAI_API_KEY required
|
||||
OPENAI_MODEL optional, default gpt-4o-mini
|
||||
OPENAI_BASE_URL optional, default https://api.openai.com/v1
|
||||
`);
|
||||
}
|
||||
|
||||
/** @param {string} t */
|
||||
function fieldToSchema(t) {
|
||||
const map = {
|
||||
string: { type: "string" },
|
||||
uuid: { type: "string", format: "uuid" },
|
||||
int: { type: "integer" },
|
||||
integer: { type: "integer" },
|
||||
number: { type: "number" },
|
||||
float: { type: "number" },
|
||||
boolean: { type: "boolean" },
|
||||
date: { type: "string", format: "date" },
|
||||
datetime: { type: "string", format: "date-time" },
|
||||
};
|
||||
return map[t] || { type: "string", description: `unknown type: ${t}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Детерминированная конвертация для apiFormatVersion "1" с полями как в example.
|
||||
* @param {any} api
|
||||
*/
|
||||
function toOpenApiDeterministic(api) {
|
||||
if (!api || api.apiFormatVersion !== "1") {
|
||||
throw new Error(
|
||||
'deterministic mode: ожидается apiFormatVersion "1". Для другого формата используйте --mode llm или расширьте маппинг в convert.mjs.',
|
||||
);
|
||||
}
|
||||
|
||||
const base = (api.server?.basePath || "/api").replace(/\/$/, "");
|
||||
const info = api.info || { title: "API", version: "1.0.0" };
|
||||
const paths = {};
|
||||
const schemas = {};
|
||||
|
||||
for (const res of api.resources || []) {
|
||||
const name = res.name;
|
||||
const seg = res.pathSegment || name.toLowerCase();
|
||||
const idParam = res.idParam || "id";
|
||||
const idType = res.idType || "uuid";
|
||||
|
||||
const props = {};
|
||||
const required = [];
|
||||
for (const f of res.fields || []) {
|
||||
let sch;
|
||||
if (f.type === "enum" && Array.isArray(f.enumValues)) {
|
||||
sch = { type: "string", enum: f.enumValues };
|
||||
} else {
|
||||
sch = { ...fieldToSchema(f.type) };
|
||||
}
|
||||
if (f.readOnly) sch.readOnly = true;
|
||||
props[f.name] = sch;
|
||||
if (f.required) required.push(f.name);
|
||||
}
|
||||
|
||||
schemas[name] = {
|
||||
type: "object",
|
||||
properties: props,
|
||||
...(required.length ? { required } : {}),
|
||||
};
|
||||
|
||||
const listPath = `${base}/${seg}`;
|
||||
const itemPath = `${base}/${seg}/{${idParam}}`;
|
||||
const idSchema = fieldToSchema(idType);
|
||||
|
||||
const listQuery = [];
|
||||
const lq = res.listQuery;
|
||||
if (lq?.pagination) {
|
||||
for (const p of lq.pagination) {
|
||||
if (p === "_start" || p === "_end")
|
||||
listQuery.push({ name: p, in: "query", schema: { type: "integer" }, description: "pagination" });
|
||||
}
|
||||
}
|
||||
if (lq?.sort) {
|
||||
for (const p of lq.sort) {
|
||||
if (p === "_sort")
|
||||
listQuery.push({ name: "_sort", in: "query", schema: { type: "string" }, description: "sort field" });
|
||||
if (p === "_order")
|
||||
listQuery.push({
|
||||
name: "_order",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["asc", "desc"] },
|
||||
description: "sort order",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (lq?.filters) {
|
||||
for (const p of lq.filters) {
|
||||
if (p === "q")
|
||||
listQuery.push({ name: "q", in: "query", schema: { type: "string" }, description: "full-text search" });
|
||||
else {
|
||||
const field = (res.fields || []).find((x) => x.name === p);
|
||||
const isEnum = field?.type === "enum";
|
||||
listQuery.push({
|
||||
name: p,
|
||||
in: "query",
|
||||
schema: isEnum
|
||||
? { type: "array", items: { type: "string", enum: field.enumValues || [] } }
|
||||
: { type: "string" },
|
||||
style: isEnum ? "form" : undefined,
|
||||
explode: isEnum ? true : undefined,
|
||||
description: isEnum ? "repeat param for multiple values" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ops = new Set(res.operations || []);
|
||||
|
||||
if (ops.has("list")) {
|
||||
paths[listPath] = paths[listPath] || {};
|
||||
paths[listPath].get = {
|
||||
tags: [name],
|
||||
summary: `List ${name}`,
|
||||
parameters: listQuery,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "OK",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: `#/components/schemas/${name}` } },
|
||||
total: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ops.has("create")) {
|
||||
paths[listPath] = paths[listPath] || {};
|
||||
paths[listPath].post = {
|
||||
tags: [name],
|
||||
summary: `Create ${name}`,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created",
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
"400": { description: "Bad request" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ops.has("get")) {
|
||||
paths[itemPath] = paths[itemPath] || {};
|
||||
paths[itemPath].get = {
|
||||
tags: [name],
|
||||
summary: `Get ${name} by ${idParam}`,
|
||||
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "OK",
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
"404": { description: "Not found" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ops.has("update")) {
|
||||
paths[itemPath] = paths[itemPath] || {};
|
||||
paths[itemPath].patch = {
|
||||
tags: [name],
|
||||
summary: `Update ${name}`,
|
||||
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||
requestBody: {
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "OK",
|
||||
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||
},
|
||||
"404": { description: "Not found" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (ops.has("delete")) {
|
||||
paths[itemPath] = paths[itemPath] || {};
|
||||
paths[itemPath].delete = {
|
||||
tags: [name],
|
||||
summary: `Delete ${name}`,
|
||||
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||
responses: {
|
||||
"204": { description: "No content" },
|
||||
"404": { description: "Not found" },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const doc = {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: info.title,
|
||||
version: info.version,
|
||||
...(info.description ? { description: info.description } : {}),
|
||||
},
|
||||
servers: [{ url: base || "/" }],
|
||||
paths,
|
||||
components: {
|
||||
schemas,
|
||||
...(api.security?.type === "bearer" || api.security?.scheme === "JWT"
|
||||
? {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (doc.components.securitySchemes) {
|
||||
doc.security = [{ bearerAuth: [] }];
|
||||
for (const method of Object.values(paths)) {
|
||||
for (const op of Object.values(method)) {
|
||||
if (op && typeof op === "object" && op.responses) op.security = [{ bearerAuth: [] }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function toOpenApiLlm(apiJson) {
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) throw new Error("OPENAI_API_KEY не задан");
|
||||
|
||||
const model = process.env.OPENAI_MODEL || "gpt-4o-mini";
|
||||
const baseUrl = (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/$/, "");
|
||||
const systemPath = resolve(__dirname, "prompts", "llm-system.md");
|
||||
const system = readFileSync(systemPath, "utf8");
|
||||
const user = JSON.stringify(apiJson, null, 2);
|
||||
|
||||
const res = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: 0.1,
|
||||
messages: [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: `Преобразуй следующий api-format в OpenAPI 3.0.3 JSON:\n\n${user}` },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`OpenAI HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error("Пустой ответ от модели");
|
||||
|
||||
const trimmed = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help || !args.input || !args.output) {
|
||||
usage();
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const inputPath = resolve(process.cwd(), args.input);
|
||||
const outputPath = resolve(process.cwd(), args.output);
|
||||
const raw = readFileSync(inputPath, "utf8");
|
||||
const api = JSON.parse(raw);
|
||||
|
||||
let openapi;
|
||||
if (args.mode === "llm") {
|
||||
openapi = await toOpenApiLlm(api);
|
||||
} else {
|
||||
openapi = toOpenApiDeterministic(api);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, JSON.stringify(openapi, null, 2), "utf8");
|
||||
console.log(`Written: ${outputPath}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e.message || e);
|
||||
process.exit(1);
|
||||
});
|
||||
117
tools/api-format-to-openapi/demo-steps.mjs
Normal file
117
tools/api-format-to-openapi/demo-steps.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Пошаговая демонстрация: api-format → OpenAPI 3.0 (детерминированный режим).
|
||||
*
|
||||
* node demo-steps.mjs — все шаги подряд в консоли
|
||||
* node demo-steps.mjs --pause — пауза после каждого шага (Enter)
|
||||
*
|
||||
* Результат также пишется в demo-output/openapi.json рядом со скриптом.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const EXAMPLE = join(__dirname, "examples", "api-format.example.json");
|
||||
const OUT_DIR = join(__dirname, "demo-output");
|
||||
const OUT_OPENAPI = join(OUT_DIR, "openapi.json");
|
||||
const usePause = process.argv.includes("--pause");
|
||||
|
||||
function banner(title) {
|
||||
const line = "═".repeat(Math.min(60, title.length + 8));
|
||||
console.log(`\n${line}\n ${title}\n${line}\n`);
|
||||
}
|
||||
|
||||
async function pause(msg = "Нажми Enter, чтобы перейти к следующему шагу…") {
|
||||
if (!usePause) return;
|
||||
const rl = createInterface({ input, output });
|
||||
await rl.question(msg);
|
||||
rl.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.clear?.();
|
||||
|
||||
banner("Шаг 0. Задача");
|
||||
console.log(
|
||||
"У нас есть описание API в СВОЁМ формате (api-format), не OpenAPI.\n" +
|
||||
"Нужно получить стандартную спецификацию OpenAPI 3.0 — для Swagger, клиентов, AID.\n" +
|
||||
"Сейчас покажем путь на учебном примере (детерминированный маппинг в convert.mjs).",
|
||||
);
|
||||
await pause();
|
||||
|
||||
banner("Шаг 1. Входной файл (фрагмент api-format)");
|
||||
console.log(`Файл: ${EXAMPLE}\n`);
|
||||
const rawIn = readFileSync(EXAMPLE, "utf8");
|
||||
const apiFormat = JSON.parse(rawIn);
|
||||
console.log(JSON.stringify(apiFormat, null, 2));
|
||||
console.log(
|
||||
"\n↑ Это НЕ OpenAPI. Здесь: версия формата, info, basePath, ресурс Equipment с полями и операциями CRUD.",
|
||||
);
|
||||
await pause();
|
||||
|
||||
banner("Шаг 2. Что делает конвертер (логика)");
|
||||
console.log(`
|
||||
• apiFormatVersion "1" → включается ветка toOpenApiDeterministic в convert.mjs
|
||||
• Ресурс Equipment → components.schemas.Equipment + пути /api/equipment и /api/equipment/{id}
|
||||
• Поля (string, uuid, enum…) → JSON Schema в components.schemas
|
||||
• listQuery → query-параметры (_start, _end, _sort, _order, q, status…)
|
||||
• security bearer → components.securitySchemes + security на операциях
|
||||
`);
|
||||
await pause();
|
||||
|
||||
banner("Шаг 3. Запуск convert.mjs");
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
const convertScript = join(__dirname, "convert.mjs");
|
||||
console.log(`Команда:\n node convert.mjs --in examples/api-format.example.json --out demo-output/openapi.json\n`);
|
||||
execFileSync(process.execPath, [convertScript, "--in", EXAMPLE, "--out", OUT_OPENAPI], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
console.log(`\nГотово. Файл: ${OUT_OPENAPI}`);
|
||||
await pause();
|
||||
|
||||
banner("Шаг 4. Результат — структура OpenAPI");
|
||||
const spec = JSON.parse(readFileSync(OUT_OPENAPI, "utf8"));
|
||||
console.log(`openapi: ${spec.openapi}`);
|
||||
console.log(`title: ${spec.info?.title}`);
|
||||
console.log(`version: ${spec.info?.version}`);
|
||||
console.log("\nПути (paths):");
|
||||
for (const p of Object.keys(spec.paths || {}).sort()) {
|
||||
const methods = Object.keys(spec.paths[p]).join(", ");
|
||||
console.log(` ${p} [${methods}]`);
|
||||
}
|
||||
console.log("\nСхемы (components.schemas):", Object.keys(spec.components?.schemas || {}).join(", "));
|
||||
await pause();
|
||||
|
||||
banner("Шаг 5. Фрагмент: GET список (одна операция)");
|
||||
const listPath = Object.keys(spec.paths || {}).find((k) => k.endsWith("/equipment") && !k.includes("{"));
|
||||
if (listPath && spec.paths[listPath]?.get) {
|
||||
console.log(JSON.stringify({ [listPath]: { get: spec.paths[listPath].get } }, null, 2));
|
||||
} else {
|
||||
console.log("(путь списка не найден — открой demo-output/openapi.json)");
|
||||
}
|
||||
await pause();
|
||||
|
||||
banner("Шаг 6. Как проверить дальше");
|
||||
console.log(`
|
||||
1) Открой целиком: ${OUT_OPENAPI}
|
||||
2) Валидация (из корня репозитория):
|
||||
npx -y @apidevtools/swagger-cli validate tools/api-format-to-openapi/demo-output/openapi.json
|
||||
3) Через Nest (сервер на 3001):
|
||||
POST http://127.0.0.1:3001/aid/export/openapi
|
||||
тело: { "apiFormat": <содержимое api-format.example.json>, "mode": "deterministic" }
|
||||
4) Режим LLM (другой входной JSON):
|
||||
node convert.mjs --mode llm --in your.json --out openapi.llm.json
|
||||
(нужен OPENAI_API_KEY)
|
||||
`);
|
||||
console.log("Демо завершено.\n");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
36
tools/api-format-to-openapi/examples/api-format.example.json
Normal file
36
tools/api-format-to-openapi/examples/api-format.example.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"apiFormatVersion": "1",
|
||||
"info": {
|
||||
"title": "TOiR Demo API",
|
||||
"version": "1.0.0",
|
||||
"description": "Абстрактный пример доменного описания API (не OpenAPI)."
|
||||
},
|
||||
"server": {
|
||||
"basePath": "/api"
|
||||
},
|
||||
"security": {
|
||||
"type": "bearer",
|
||||
"scheme": "JWT"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"name": "Equipment",
|
||||
"pathSegment": "equipment",
|
||||
"idParam": "id",
|
||||
"idType": "uuid",
|
||||
"fields": [
|
||||
{ "name": "id", "type": "uuid", "readOnly": true },
|
||||
{ "name": "inventoryNumber", "type": "string", "required": true },
|
||||
{ "name": "name", "type": "string", "required": true },
|
||||
{ "name": "status", "type": "enum", "enumValues": ["Active", "Repair", "Decommissioned"] },
|
||||
{ "name": "location", "type": "string" }
|
||||
],
|
||||
"operations": ["list", "get", "create", "update", "delete"],
|
||||
"listQuery": {
|
||||
"pagination": ["_start", "_end"],
|
||||
"sort": ["_sort", "_order"],
|
||||
"filters": ["q", "status"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
tools/api-format-to-openapi/package.json
Normal file
12
tools/api-format-to-openapi/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-format-to-openapi",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Конвертация доменного api-format в OpenAPI 3.0 (детерминированно или через LLM)",
|
||||
"scripts": {
|
||||
"convert": "node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json",
|
||||
"convert:llm": "node convert.mjs --mode llm --in examples/api-format.example.json --out ../../openapi.llm.json",
|
||||
"demo": "node demo-steps.mjs",
|
||||
"demo:pause": "node demo-steps.mjs --pause"
|
||||
}
|
||||
}
|
||||
30
tools/api-format-to-openapi/prompts/llm-system.md
Normal file
30
tools/api-format-to-openapi/prompts/llm-system.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Роль
|
||||
|
||||
Ты конвертер доменного описания API в спецификацию **OpenAPI 3.0.3** (JSON).
|
||||
|
||||
# Вход
|
||||
|
||||
Пользователь пришлёт один JSON-файл в произвольном «доменном» формате (api-format). В нём могут быть сущности, поля, типы, пути, операции, фильтры, авторизация.
|
||||
|
||||
# Выход
|
||||
|
||||
- Верни **только** валидный JSON объекта OpenAPI 3.0.3.
|
||||
- Без markdown, без комментариев, без текста до или после JSON.
|
||||
- Используй `openapi: "3.0.3"`.
|
||||
- Опиши `info`, `servers`, при необходимости `tags`.
|
||||
- Для каждой сущности/ресурса создай `components.schemas` и `paths` с типичными REST-операциями, если они указаны.
|
||||
- Типы полей маппь так:
|
||||
- `string` → `type: string`
|
||||
- `uuid` → `type: string`, `format: uuid`
|
||||
- `int` / `integer` → `type: integer`
|
||||
- `number` / `float` → `type: number`
|
||||
- `boolean` → `type: boolean`
|
||||
- `date` / `datetime` → `type: string`, `format: date` или `date-time`
|
||||
- `enum` + список значений → `type: string`, `enum: [...]`
|
||||
- Для списков с пагинацией добавь query-параметры из входа (`_start`, `_end`, `_sort`, `_order`, фильтры).
|
||||
- Для `401/403/404/500` добавь минимальные `responses` с `description`.
|
||||
- Если во входе указана Bearer/JWT — добавь `components.securitySchemes` и `security` на путях или глобально.
|
||||
|
||||
# Если чего-то не хватает
|
||||
|
||||
Делай разумные допущения и кратко отражай их в `info.description` одним предложением.
|
||||
301
tools/api-summary-to-openapi.mjs
Normal file
301
tools/api-summary-to-openapi.mjs
Normal file
@@ -0,0 +1,301 @@
|
||||
// Deterministic OpenAPI 3.0.3 generator from api-summary.json / toir.api.dsl.
|
||||
//
|
||||
// This script is part of the Tier 1 deterministic preprocessing layer.
|
||||
// It converts the canonical api-summary (produced by tools/api-summary.mjs)
|
||||
// into a valid OpenAPI 3.0.3 document.
|
||||
//
|
||||
// Usage:
|
||||
// node tools/api-summary-to-openapi.mjs --out openapi.json
|
||||
// npm run generate:openapi
|
||||
//
|
||||
// No LLM involvement. The output is reproducible from DSL + this script alone.
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildApiSummary } from './api-summary.mjs';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DSL scalar → OpenAPI type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function dslTypeToOpenApi(dslType) {
|
||||
switch (dslType) {
|
||||
case 'uuid':
|
||||
return { type: 'string', format: 'uuid' };
|
||||
case 'string':
|
||||
return { type: 'string' };
|
||||
case 'text':
|
||||
return { type: 'string' };
|
||||
case 'integer':
|
||||
return { type: 'integer', format: 'int32' };
|
||||
case 'number':
|
||||
return { type: 'number' };
|
||||
case 'decimal':
|
||||
return { type: 'string', format: 'decimal' };
|
||||
case 'date':
|
||||
return { type: 'string', format: 'date-time' };
|
||||
case 'boolean':
|
||||
return { type: 'boolean' };
|
||||
default:
|
||||
// enum names and DTO references handled by caller
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolve a DSL field type to an OpenAPI schema reference or inline schema.
|
||||
// dtoNames — Set of known DTO names in the api summary.
|
||||
// enumNames — Set of known enum names (derived from type mappings table).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveFieldType(dslType, dtoNames, enumNames) {
|
||||
if (!dslType) return { type: 'object' };
|
||||
|
||||
// Array type: "DTO.Foo[]"
|
||||
if (dslType.endsWith('[]')) {
|
||||
const inner = dslType.slice(0, -2);
|
||||
return { type: 'array', items: resolveFieldType(inner, dtoNames, enumNames) };
|
||||
}
|
||||
|
||||
// Scalar
|
||||
const scalar = dslTypeToOpenApi(dslType);
|
||||
if (scalar) return scalar;
|
||||
|
||||
// DTO reference
|
||||
if (dtoNames.has(dslType)) {
|
||||
return { $ref: `#/components/schemas/${dslType.replace(/^DTO\./, '')}` };
|
||||
}
|
||||
|
||||
// Enum reference
|
||||
if (enumNames.has(dslType)) {
|
||||
return { $ref: `#/components/schemas/${dslType}` };
|
||||
}
|
||||
|
||||
// Unknown — emit as string with x-dsl-type annotation
|
||||
return { type: 'string', 'x-dsl-type': dslType };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build OpenAPI schema object from a DTO definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDtoSchema(dto, dtoNames, enumNames) {
|
||||
const properties = {};
|
||||
const required = [];
|
||||
|
||||
for (const field of dto.fields) {
|
||||
const schema = resolveFieldType(field.type, dtoNames, enumNames);
|
||||
if (field.description && schema.$ref) {
|
||||
// OpenAPI 3.0.3: $ref cannot have sibling keys — wrap with allOf
|
||||
properties[field.name] = { allOf: [schema], description: field.description };
|
||||
} else {
|
||||
if (field.description) schema.description = field.description;
|
||||
properties[field.name] = schema;
|
||||
}
|
||||
if (field.required) required.push(field.name);
|
||||
}
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties,
|
||||
};
|
||||
|
||||
if (dto.description) schema.description = dto.description;
|
||||
if (required.length > 0) schema.required = required;
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convert DSL HTTP method to OpenAPI method key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function methodKey(method) {
|
||||
return (method ?? 'get').toLowerCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build OpenAPI path item for an endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildPathOperation(endpoint, apiDescription, dtoNames, enumNames) {
|
||||
const operation = {};
|
||||
|
||||
if (endpoint.description) operation.summary = endpoint.description;
|
||||
|
||||
// Security — all endpoints require bearer auth
|
||||
operation.security = [{ bearerAuth: [] }];
|
||||
|
||||
// Tags — derive from API name or path
|
||||
const tag = apiDescription ? apiDescription.replace(/^API управления\s+/i, '').replace(/ами$/, '') : undefined;
|
||||
if (tag) operation.tags = [tag];
|
||||
|
||||
// Parameters — detect path params by matching attribute names against {param} in the path
|
||||
const pathTemplate = endpoint.path ?? '';
|
||||
const pathParamNames = new Set(
|
||||
[...pathTemplate.matchAll(/\{(\w+)\}/g)].map((m) => m[1]),
|
||||
);
|
||||
const pathParams = endpoint.attributes.filter((a) => pathParamNames.has(a.name));
|
||||
if (pathParams.length > 0) {
|
||||
operation.parameters = pathParams.map((p) => ({
|
||||
name: p.name,
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: resolveFieldType(p.type, dtoNames, enumNames),
|
||||
...(p.description ? { description: p.description } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Request body
|
||||
const requestAttr = endpoint.attributes.find((a) => a.name === 'request');
|
||||
if (requestAttr) {
|
||||
operation.requestBody = {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolveFieldType(requestAttr.type, dtoNames, enumNames),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Response
|
||||
const responseAttr = endpoint.attributes.find((a) => a.name === 'response');
|
||||
const responseSchema = responseAttr
|
||||
? resolveFieldType(responseAttr.type, dtoNames, enumNames)
|
||||
: { type: 'object' };
|
||||
|
||||
const successCode = endpoint.method === 'POST' && !endpoint.path?.endsWith('/page') ? '201' : '200';
|
||||
|
||||
operation.responses = {
|
||||
[successCode]: {
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: responseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': { description: 'Unauthorized' },
|
||||
'403': { description: 'Forbidden' },
|
||||
};
|
||||
|
||||
if (endpoint.method === 'DELETE') {
|
||||
operation.responses = {
|
||||
'204': { description: 'No content' },
|
||||
'401': { description: 'Unauthorized' },
|
||||
'403': { description: 'Forbidden' },
|
||||
'404': { description: 'Not found' },
|
||||
};
|
||||
delete operation.responses['201'];
|
||||
}
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildOpenApi(summary) {
|
||||
const dtoNames = new Set(summary.dtos.map((d) => d.name));
|
||||
|
||||
// Build enum map from api-summary enums block (fully declared enums with values)
|
||||
const enumMap = new Map((summary.enums ?? []).map((e) => [e.name, e]));
|
||||
|
||||
// Also collect enum names referenced in DTO fields that are not in the declared enums
|
||||
// (covers cases where enums are declared in domain.dsl but referenced in api.dsl)
|
||||
const enumNames = new Set(enumMap.keys());
|
||||
for (const dto of summary.dtos) {
|
||||
for (const field of dto.fields) {
|
||||
const t = field.type?.replace('[]', '');
|
||||
if (t && !dtoNames.has(t) && !dslTypeToOpenApi(t)) {
|
||||
enumNames.add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schemas — one per DTO
|
||||
const schemas = {};
|
||||
for (const dto of summary.dtos) {
|
||||
const schemaName = dto.name.replace(/^DTO\./, '');
|
||||
schemas[schemaName] = buildDtoSchema(dto, dtoNames, enumNames);
|
||||
}
|
||||
|
||||
// Enum schemas — use actual values when available, otherwise annotate as opaque string enum
|
||||
for (const enumName of enumNames) {
|
||||
const enumDef = enumMap.get(enumName);
|
||||
if (enumDef && enumDef.values.length > 0) {
|
||||
schemas[enumName] = {
|
||||
type: 'string',
|
||||
enum: enumDef.values.map((v) => v.name),
|
||||
'x-enum-labels': Object.fromEntries(
|
||||
enumDef.values.filter((v) => v.label).map((v) => [v.name, v.label]),
|
||||
),
|
||||
...(enumDef.description ? { description: enumDef.description } : {}),
|
||||
};
|
||||
} else {
|
||||
schemas[enumName] = {
|
||||
type: 'string',
|
||||
'x-dsl-enum': enumName,
|
||||
description: `Enum: ${enumName} (values defined in domain/*.api.dsl)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Paths
|
||||
const paths = {};
|
||||
for (const api of summary.apis) {
|
||||
for (const endpoint of api.endpoints) {
|
||||
if (!endpoint.path) continue;
|
||||
const pathKey = endpoint.path;
|
||||
if (!paths[pathKey]) paths[pathKey] = {};
|
||||
const opKey = methodKey(endpoint.method);
|
||||
paths[pathKey][opKey] = buildPathOperation(endpoint, api.description, dtoNames, enumNames);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'KIS-TOiR API',
|
||||
description:
|
||||
'Equipment maintenance management system. Generated from domain/toir.api.dsl via tools/api-summary-to-openapi.mjs.',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api',
|
||||
description: 'Default server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
schemas,
|
||||
},
|
||||
paths,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const outIndex = args.indexOf('--out');
|
||||
const outPath = outIndex !== -1 ? args[outIndex + 1] : 'openapi.json';
|
||||
|
||||
const summary = buildApiSummary(rootDir);
|
||||
const openApiDoc = buildOpenApi(summary);
|
||||
const outputPath = path.resolve(rootDir, outPath);
|
||||
|
||||
writeFileSync(outputPath, `${JSON.stringify(openApiDoc, null, 2)}\n`, 'utf8');
|
||||
console.log(`Generated ${path.relative(rootDir, outputPath)}`);
|
||||
389
tools/api-summary.mjs
Normal file
389
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,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
113
tools/eval/README.md
Normal file
113
tools/eval/README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Eval Harness — Rule 6
|
||||
|
||||
Fixture-based regression tests for generated artifacts.
|
||||
|
||||
## Why this exists
|
||||
|
||||
> "Evals are the test suite for your prompts. You would never ship code without tests;
|
||||
> don't ship prompts without evals." — Anthropic Engineering
|
||||
|
||||
The validation gate (`tools/validate-generation.mjs`) checks **existence** and **structural compliance**.
|
||||
The eval harness checks **semantic correctness**: are the right patterns present in the generated code?
|
||||
Do the generated files actually follow the rules in `prompts/`?
|
||||
|
||||
Together they enforce:
|
||||
- Gate: "file exists, field names present, auth seams wired"
|
||||
- Evals: "DTO has class-validator decorators, FK uses ReferenceInput, date uses DateInput, guard is present"
|
||||
|
||||
The committed fixture corpus is a reviewed semantic contract. It may be scaffolded from source-of-truth as a helper, but it should not be auto-regenerated wholesale during every full regeneration run, or it stops acting as an independent regression signal.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run all evals
|
||||
npm run eval:generation
|
||||
|
||||
# Run evals for one entity
|
||||
node tools/eval/run-evals.mjs --entity equipment
|
||||
|
||||
# Verbose output (show each file being checked)
|
||||
node tools/eval/run-evals.mjs --verbose
|
||||
```
|
||||
|
||||
## Fixture format
|
||||
|
||||
Each fixture lives in `tools/eval/fixtures/<entity>/`:
|
||||
|
||||
```
|
||||
fixtures/
|
||||
equipment/
|
||||
meta.json ← what this fixture tests
|
||||
backend.assertions.json ← patterns the NestJS files must satisfy
|
||||
frontend.assertions.json ← patterns the React Admin files must satisfy
|
||||
repair-order/
|
||||
meta.json
|
||||
backend.assertions.json
|
||||
frontend.assertions.json
|
||||
```
|
||||
|
||||
### `meta.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"entity": "Equipment",
|
||||
"kebab": "equipment",
|
||||
"resource": "equipment",
|
||||
"description": "...",
|
||||
"tests": ["dto-decorator-coverage", "auth-guards", ...]
|
||||
}
|
||||
```
|
||||
|
||||
### `*.assertions.json`
|
||||
|
||||
Each file entry supports:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|-----|------|---------|
|
||||
| `path` | string | Relative path from repo root |
|
||||
| `must_contain` | string[] | Each string must appear as a literal substring |
|
||||
| `must_not_contain` | string[] | Each string must NOT appear |
|
||||
| `must_match_regex` | string[] | Each pattern must match (multiline dot-all) |
|
||||
| `must_not_match_regex` | string[] | Each pattern must NOT match |
|
||||
| `comment` | string | Human-readable explanation of what is being tested |
|
||||
|
||||
## Eval-driven development workflow
|
||||
|
||||
This is the critical principle from Anthropic and Google:
|
||||
|
||||
1. **Write the failing eval first.** When you change a prompt or add a rule, add an
|
||||
assertion that captures the new expectation *before* re-generating.
|
||||
2. **Run evals**: `npm run eval:generation` → see failures.
|
||||
3. **Re-generate** the affected entity (following the generation workflow in `AGENTS.md`).
|
||||
4. **Run evals again**: all pass → the change is verified.
|
||||
5. **Commit both** the updated fixture and the regenerated artifacts together.
|
||||
|
||||
A passing eval after a prompt change confirms the LLM followed the new rule.
|
||||
A failing eval before a prompt change tells you exactly which prior contract was broken.
|
||||
|
||||
Automation note:
|
||||
|
||||
- It is reasonable to generate starter fixtures or coverage manifests from source-of-truth.
|
||||
- It is not reasonable to let the same regeneration step auto-refresh the authoritative committed eval corpus, because that couples the semantic gate too tightly to the generator and can hide regressions.
|
||||
|
||||
## Adding a new entity fixture
|
||||
|
||||
When adding a new entity to `domain/toir.api.dsl` and generating its backend + frontend:
|
||||
|
||||
1. Create `tools/eval/fixtures/<kebab>/meta.json`
|
||||
2. Create `tools/eval/fixtures/<kebab>/backend.assertions.json` with at minimum:
|
||||
- controller: `@Controller(...)`, `@UseGuards(`, `JwtAuthGuard`, HTTP methods
|
||||
- create_dto: `from 'class-validator'`, required fields with `!:`, `@IsString(`, `@IsOptional(`
|
||||
- update_dto: `from 'class-validator'`, fields with `?:`, `@IsOptional(`
|
||||
3. Create `tools/eval/fixtures/<kebab>/frontend.assertions.json` with at minimum:
|
||||
- create: `ReferenceInput` for FK fields, `NumberInput` for numeric, `DateInput` for date, `SelectInput` for enum
|
||||
- show: `ReferenceField` for FK fields, `DateField` for date
|
||||
4. Run `npm run eval:generation` to verify the fixture catches real issues.
|
||||
|
||||
## Integration with git hooks
|
||||
|
||||
The pre-commit hook (installed by `npm run install-hooks`) runs both:
|
||||
1. `node tools/validate-generation.mjs --artifacts-only` — existence gate
|
||||
2. `npm run eval:generation` — semantic eval gate
|
||||
|
||||
Both must pass before a commit is accepted.
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"entity": "ChangeEquipmentStatus",
|
||||
"files": {
|
||||
"controller": {
|
||||
"path": "server/src/modules/change-equipment-status/change-equipment-status.controller.ts",
|
||||
"must_contain": [
|
||||
"@Controller('change-equipment-status')",
|
||||
"@UseGuards(",
|
||||
"JwtAuthGuard",
|
||||
"RolesGuard",
|
||||
"@Get()",
|
||||
"@Post()",
|
||||
"@Get(':equipmentId/:newStatus')",
|
||||
"@Patch(':equipmentId/:newStatus')",
|
||||
"@Delete(':equipmentId/:newStatus')"
|
||||
],
|
||||
"must_not_contain": [
|
||||
"@Put(':equipmentId/:newStatus')"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"@Delete\\(':equipmentId/:newStatus'\\)[\\s\\S]{0,120}@Roles\\('admin'\\)|@Roles\\('admin'\\)[\\s\\S]{0,120}@Delete\\(':equipmentId/:newStatus'\\)"
|
||||
]
|
||||
},
|
||||
"service": {
|
||||
"path": "server/src/modules/change-equipment-status/change-equipment-status.service.ts",
|
||||
"must_contain": [
|
||||
"setListHeaders",
|
||||
"_start",
|
||||
"_end",
|
||||
"_sort",
|
||||
"_order",
|
||||
"equipmentId",
|
||||
"newStatus"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"equipmentId.*(equals|=)",
|
||||
"newStatus.*in\\b|\\bin\\b.*newStatus"
|
||||
]
|
||||
},
|
||||
"create_dto": {
|
||||
"path": "server/src/modules/change-equipment-status/dto/create-change-equipment-status.dto.ts",
|
||||
"must_contain": [
|
||||
"from 'class-validator'",
|
||||
"equipmentId!:",
|
||||
"newStatus!:",
|
||||
"date!:",
|
||||
"number?:",
|
||||
"responsible?:",
|
||||
"@IsUUID(",
|
||||
"@IsEnum(",
|
||||
"@IsString(",
|
||||
"@IsOptional("
|
||||
],
|
||||
"must_not_contain": [
|
||||
"id?:",
|
||||
"id!:"
|
||||
]
|
||||
},
|
||||
"update_dto": {
|
||||
"path": "server/src/modules/change-equipment-status/dto/update-change-equipment-status.dto.ts",
|
||||
"must_contain": [
|
||||
"from 'class-validator'",
|
||||
"@IsOptional(",
|
||||
"equipmentId?:",
|
||||
"newStatus?:",
|
||||
"date?:",
|
||||
"number?:",
|
||||
"responsible?:"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"entity": "ChangeEquipmentStatus",
|
||||
"resource": "change-equipment-statuses",
|
||||
"files": {
|
||||
"list": {
|
||||
"path": "client/src/resources/change-equipment-status/ChangeEquipmentStatusList.tsx",
|
||||
"must_contain": [
|
||||
"List",
|
||||
"FilterButton",
|
||||
"ReferenceField",
|
||||
"SelectField",
|
||||
"TextField"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"ReferenceField[\\s\\S]{0,200}reference=\"equipment\"|reference=\"equipment\"[\\s\\S]{0,200}ReferenceField",
|
||||
"source=\"newStatus\""
|
||||
]
|
||||
},
|
||||
"create": {
|
||||
"path": "client/src/resources/change-equipment-status/ChangeEquipmentStatusCreate.tsx",
|
||||
"must_contain": [
|
||||
"Create",
|
||||
"SimpleForm",
|
||||
"ReferenceInput",
|
||||
"AutocompleteInput",
|
||||
"SelectInput",
|
||||
"DateInput"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"ReferenceInput[\\s\\S]{0,200}reference=\"equipment\"|reference=\"equipment\"[\\s\\S]{0,200}ReferenceInput",
|
||||
"AutocompleteInput[\\s\\S]{0,200}filterToQuery|filterToQuery[\\s\\S]{0,200}AutocompleteInput",
|
||||
"SelectInput[\\s\\S]{0,200}source=\"newStatus\"|source=\"newStatus\"[\\s\\S]{0,200}SelectInput",
|
||||
"DateInput[\\s\\S]{0,200}source=\"date\"|source=\"date\"[\\s\\S]{0,200}DateInput"
|
||||
]
|
||||
},
|
||||
"edit": {
|
||||
"path": "client/src/resources/change-equipment-status/ChangeEquipmentStatusEdit.tsx",
|
||||
"must_contain": [
|
||||
"Edit",
|
||||
"SimpleForm",
|
||||
"ReferenceInput",
|
||||
"AutocompleteInput",
|
||||
"SelectInput",
|
||||
"DateInput"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"ReferenceInput[\\s\\S]{0,200}reference=\"equipment\"|reference=\"equipment\"[\\s\\S]{0,200}ReferenceInput",
|
||||
"AutocompleteInput[\\s\\S]{0,200}filterToQuery|filterToQuery[\\s\\S]{0,200}AutocompleteInput",
|
||||
"SelectInput[\\s\\S]{0,200}source=\"newStatus\"|source=\"newStatus\"[\\s\\S]{0,200}SelectInput",
|
||||
"DateInput[\\s\\S]{0,200}source=\"date\"|source=\"date\"[\\s\\S]{0,200}DateInput"
|
||||
]
|
||||
},
|
||||
"show": {
|
||||
"path": "client/src/resources/change-equipment-status/ChangeEquipmentStatusShow.tsx",
|
||||
"must_contain": [
|
||||
"Show",
|
||||
"SimpleShowLayout",
|
||||
"ReferenceField",
|
||||
"SelectField",
|
||||
"DateField"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"ReferenceField[\\s\\S]{0,200}reference=\"equipment\"|reference=\"equipment\"[\\s\\S]{0,200}ReferenceField",
|
||||
"source=\"newStatus\""
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tools/eval/fixtures/change-equipment-status/meta.json
Normal file
15
tools/eval/fixtures/change-equipment-status/meta.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": "ChangeEquipmentStatus",
|
||||
"kebab": "change-equipment-status",
|
||||
"resource": "change-equipment-statuses",
|
||||
"description": "Current composite-key status history entity: equipment reference, enum status, date, and optional text metadata",
|
||||
"tests": [
|
||||
"dto-decorator-coverage",
|
||||
"auth-guards-per-http-method",
|
||||
"composite-key-route",
|
||||
"fk-reference-input",
|
||||
"fk-reference-field",
|
||||
"content-range-header-pattern",
|
||||
"enum-filter-in-operator"
|
||||
]
|
||||
}
|
||||
82
tools/eval/fixtures/equipment/backend.assertions.json
Normal file
82
tools/eval/fixtures/equipment/backend.assertions.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"entity": "Equipment",
|
||||
"files": {
|
||||
"controller": {
|
||||
"path": "server/src/modules/equipment/equipment.controller.ts",
|
||||
"must_contain": [
|
||||
"@Controller('equipment')",
|
||||
"@UseGuards(",
|
||||
"JwtAuthGuard",
|
||||
"RolesGuard",
|
||||
"@Get()",
|
||||
"@Post()",
|
||||
"@Get(':id')",
|
||||
"@Patch(':id')",
|
||||
"@Delete(':id')"
|
||||
],
|
||||
"must_not_contain": [
|
||||
"@Put(':id')",
|
||||
"@Post(':id')"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"@Delete\\(':id'\\)[\\s\\S]{0,80}@Roles\\('admin'\\)|@Roles\\('admin'\\)[\\s\\S]{0,80}@Delete\\(':id'\\)"
|
||||
],
|
||||
"comment": "Equipment controller must expose the CRUD verbs expected by the DSL-compatible React Admin contract."
|
||||
},
|
||||
"service": {
|
||||
"path": "server/src/modules/equipment/equipment.service.ts",
|
||||
"must_contain": [
|
||||
"setListHeaders(response",
|
||||
"_start",
|
||||
"_end",
|
||||
"_sort",
|
||||
"_order",
|
||||
"q"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"contains\\(|mode.*insensitive|insensitive.*mode",
|
||||
"status.*in\\b|\\bin\\b.*status"
|
||||
],
|
||||
"comment": "Service must translate React Admin list params into Prisma filters and delegate header wiring through the shared helper."
|
||||
},
|
||||
"create_dto": {
|
||||
"path": "server/src/modules/equipment/dto/create-equipment.dto.ts",
|
||||
"must_contain": [
|
||||
"from 'class-validator'",
|
||||
"name!:",
|
||||
"serialNumber!:",
|
||||
"dateOfInspection?:",
|
||||
"commissionedAt?:",
|
||||
"status?:",
|
||||
"@IsString(",
|
||||
"@IsOptional(",
|
||||
"@IsEnum("
|
||||
],
|
||||
"must_not_contain": [
|
||||
"id?:",
|
||||
"id!:"
|
||||
],
|
||||
"comment": "Required fields use '!' suffix; optional fields use '?' with @IsOptional(); enum fields use @IsEnum(); class-validator must be imported."
|
||||
},
|
||||
"update_dto": {
|
||||
"path": "server/src/modules/equipment/dto/update-equipment.dto.ts",
|
||||
"must_contain": [
|
||||
"from 'class-validator'",
|
||||
"name?:",
|
||||
"serialNumber?:",
|
||||
"dateOfInspection?:",
|
||||
"commissionedAt?:",
|
||||
"status?:",
|
||||
"@IsOptional(",
|
||||
"@IsString(",
|
||||
"@IsEnum("
|
||||
],
|
||||
"must_not_contain": [
|
||||
"name!:",
|
||||
"serialNumber!:",
|
||||
"status!:"
|
||||
],
|
||||
"comment": "Update DTO: all fields are optional ('?' suffix with @IsOptional())."
|
||||
}
|
||||
}
|
||||
}
|
||||
67
tools/eval/fixtures/equipment/frontend.assertions.json
Normal file
67
tools/eval/fixtures/equipment/frontend.assertions.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"entity": "Equipment",
|
||||
"resource": "equipment",
|
||||
"files": {
|
||||
"list": {
|
||||
"path": "client/src/resources/equipment/EquipmentList.tsx",
|
||||
"must_contain": [
|
||||
"List",
|
||||
"FilterButton",
|
||||
"TextField",
|
||||
"SelectField",
|
||||
"name",
|
||||
"serialNumber"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"SelectArrayInput",
|
||||
"source=\"status\""
|
||||
],
|
||||
"comment": "Equipment list must expose the current fields, filter UI, and enum filters."
|
||||
},
|
||||
"create": {
|
||||
"path": "client/src/resources/equipment/EquipmentCreate.tsx",
|
||||
"must_contain": [
|
||||
"Create",
|
||||
"SimpleForm",
|
||||
"SelectInput",
|
||||
"TextInput"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"TextInput[\\s\\S]{0,300}source=\"name\"|source=\"name\"[\\s\\S]{0,300}TextInput",
|
||||
"TextInput[\\s\\S]{0,300}source=\"serialNumber\"|source=\"serialNumber\"[\\s\\S]{0,300}TextInput",
|
||||
"DateInput[\\s\\S]{0,300}source=\"dateOfInspection\"|source=\"dateOfInspection\"[\\s\\S]{0,300}DateInput",
|
||||
"DateInput[\\s\\S]{0,300}source=\"commissionedAt\"|source=\"commissionedAt\"[\\s\\S]{0,300}DateInput",
|
||||
"SelectInput[\\s\\S]{0,300}source=\"status\"|source=\"status\"[\\s\\S]{0,300}SelectInput"
|
||||
],
|
||||
"comment": "Equipment create form must keep type-correct inputs for text, enum, and date fields."
|
||||
},
|
||||
"edit": {
|
||||
"path": "client/src/resources/equipment/EquipmentEdit.tsx",
|
||||
"must_contain": [
|
||||
"Edit",
|
||||
"SimpleForm",
|
||||
"SelectInput",
|
||||
"TextInput"
|
||||
],
|
||||
"must_match_regex": [
|
||||
"TextInput[\\s\\S]{0,300}source=\"name\"|source=\"name\"[\\s\\S]{0,300}TextInput",
|
||||
"TextInput[\\s\\S]{0,300}source=\"serialNumber\"|source=\"serialNumber\"[\\s\\S]{0,300}TextInput",
|
||||
"DateInput[\\s\\S]{0,300}source=\"dateOfInspection\"|source=\"dateOfInspection\"[\\s\\S]{0,300}DateInput",
|
||||
"DateInput[\\s\\S]{0,300}source=\"commissionedAt\"|source=\"commissionedAt\"[\\s\\S]{0,300}DateInput"
|
||||
],
|
||||
"comment": "Equipment edit form must keep the same type-correctness guarantees as create."
|
||||
},
|
||||
"show": {
|
||||
"path": "client/src/resources/equipment/EquipmentShow.tsx",
|
||||
"must_contain": [
|
||||
"Show",
|
||||
"SimpleShowLayout",
|
||||
"TextField",
|
||||
"SelectField",
|
||||
"name",
|
||||
"serialNumber"
|
||||
],
|
||||
"comment": "Show must display the current equipment identity and status fields."
|
||||
}
|
||||
}
|
||||
}
|
||||
15
tools/eval/fixtures/equipment/meta.json
Normal file
15
tools/eval/fixtures/equipment/meta.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"entity": "Equipment",
|
||||
"kebab": "equipment",
|
||||
"resource": "equipment",
|
||||
"description": "Current equipment entity: UUID primary key, text fields, nullable date fields, and an enum status field",
|
||||
"tests": [
|
||||
"dto-decorator-coverage",
|
||||
"auth-guards-per-http-method",
|
||||
"content-range-header-pattern",
|
||||
"enum-filter-in-operator",
|
||||
"q-filter-contains-pattern",
|
||||
"react-admin-component-types",
|
||||
"class-validator-import"
|
||||
]
|
||||
}
|
||||
184
tools/eval/run-evals.mjs
Normal file
184
tools/eval/run-evals.mjs
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/eval/run-evals.mjs
|
||||
*
|
||||
* Rule 6 — Eval harness: fixture-based regression tests for generated artifacts.
|
||||
*
|
||||
* Philosophy:
|
||||
* - Evals are the test suite for prompts. Never ship a prompt change without
|
||||
* running evals first.
|
||||
* - Use deterministic pattern/regex checks ("reference-free" grading) rather
|
||||
* than golden snapshot comparison. Patterns are maintainable; snapshots are
|
||||
* brittle.
|
||||
* - Eval-driven development: write a failing eval FIRST, then update the prompt
|
||||
* or re-generate to make it pass.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/eval/run-evals.mjs # run all fixtures
|
||||
* node tools/eval/run-evals.mjs --entity equipment
|
||||
* node tools/eval/run-evals.mjs --verbose
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
const fixturesDir = path.join(__dirname, 'fixtures');
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const verbose = args.has('--verbose') || args.has('-v');
|
||||
const entityFilter = (() => {
|
||||
const idx = process.argv.indexOf('--entity');
|
||||
return idx !== -1 ? process.argv[idx + 1] : null;
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assertion engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let totalChecks = 0;
|
||||
let totalFailures = 0;
|
||||
const failures = [];
|
||||
|
||||
function readArtifact(relativePath) {
|
||||
const filePath = path.join(rootDir, relativePath);
|
||||
if (!existsSync(filePath)) return null;
|
||||
return readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function runFileAssertions(filePath, fileSpec, entityLabel) {
|
||||
const content = readArtifact(filePath);
|
||||
|
||||
if (content === null) {
|
||||
totalChecks++;
|
||||
totalFailures++;
|
||||
failures.push({ entity: entityLabel, file: filePath, check: 'file-exists', result: 'FAIL', detail: `File not found: ${filePath}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(` [${entityLabel}] Checking ${filePath}`);
|
||||
}
|
||||
|
||||
for (const expected of fileSpec.must_contain ?? []) {
|
||||
totalChecks++;
|
||||
if (!content.includes(expected)) {
|
||||
totalFailures++;
|
||||
failures.push({ entity: entityLabel, file: filePath, check: 'must_contain', result: 'FAIL', detail: `Missing: ${expected}` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const forbidden of fileSpec.must_not_contain ?? []) {
|
||||
totalChecks++;
|
||||
if (content.includes(forbidden)) {
|
||||
totalFailures++;
|
||||
failures.push({ entity: entityLabel, file: filePath, check: 'must_not_contain', result: 'FAIL', detail: `Forbidden pattern found: ${forbidden}` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const patternStr of fileSpec.must_match_regex ?? []) {
|
||||
totalChecks++;
|
||||
try {
|
||||
const re = new RegExp(patternStr);
|
||||
if (!re.test(content)) {
|
||||
totalFailures++;
|
||||
failures.push({ entity: entityLabel, file: filePath, check: 'must_match_regex', result: 'FAIL', detail: `Regex not matched: ${patternStr}` });
|
||||
}
|
||||
} catch (e) {
|
||||
totalFailures++;
|
||||
failures.push({ entity: entityLabel, file: filePath, check: 'must_match_regex', result: 'ERROR', detail: `Bad regex: ${patternStr} — ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const patternStr of fileSpec.must_not_match_regex ?? []) {
|
||||
totalChecks++;
|
||||
try {
|
||||
const re = new RegExp(patternStr);
|
||||
if (re.test(content)) {
|
||||
totalFailures++;
|
||||
failures.push({ entity: entityLabel, file: filePath, check: 'must_not_match_regex', result: 'FAIL', detail: `Forbidden regex matched: ${patternStr}` });
|
||||
}
|
||||
} catch (e) {
|
||||
totalFailures++;
|
||||
failures.push({ entity: entityLabel, file: filePath, check: 'must_not_match_regex', result: 'ERROR', detail: `Bad regex: ${patternStr} — ${e.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runFixture(fixtureDir) {
|
||||
const metaPath = path.join(fixtureDir, 'meta.json');
|
||||
if (!existsSync(metaPath)) return;
|
||||
|
||||
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
||||
const { entity, kebab } = meta;
|
||||
|
||||
if (entityFilter && kebab !== entityFilter && entity.toLowerCase() !== entityFilter.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(`\n[EVAL] ${entity} — ${meta.description ?? ''}`);
|
||||
}
|
||||
|
||||
const backendPath = path.join(fixtureDir, 'backend.assertions.json');
|
||||
if (existsSync(backendPath)) {
|
||||
const spec = JSON.parse(readFileSync(backendPath, 'utf8'));
|
||||
for (const [key, fileSpec] of Object.entries(spec.files ?? {})) {
|
||||
runFileAssertions(fileSpec.path, fileSpec, `${entity}/${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
const frontendPath = path.join(fixtureDir, 'frontend.assertions.json');
|
||||
if (existsSync(frontendPath)) {
|
||||
const spec = JSON.parse(readFileSync(frontendPath, 'utf8'));
|
||||
for (const [key, fileSpec] of Object.entries(spec.files ?? {})) {
|
||||
runFileAssertions(fileSpec.path, fileSpec, `${entity}/${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fixtureDirs = readdirSync(fixturesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => path.join(fixturesDir, d.name));
|
||||
|
||||
for (const dir of fixtureDirs) {
|
||||
runFixture(dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('');
|
||||
console.log('══════════════════════════════════════════════');
|
||||
console.log(' KIS-TOiR Eval Report');
|
||||
console.log('══════════════════════════════════════════════');
|
||||
console.log(` Fixtures: ${fixtureDirs.length}`);
|
||||
console.log(` Checks: ${totalChecks}`);
|
||||
console.log(` Passed: ${totalChecks - totalFailures}`);
|
||||
console.log(` Failed: ${totalFailures}`);
|
||||
console.log('══════════════════════════════════════════════');
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('');
|
||||
console.log('Failures:');
|
||||
for (const f of failures) {
|
||||
console.log(` [${f.result}] ${f.entity} — ${f.file}`);
|
||||
console.log(` ${f.check}: ${f.detail}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log('To fix: update the prompt or re-generate the failing entity, then re-run evals.');
|
||||
console.log('To update a fixture (intentional change): edit tools/eval/fixtures/<entity>/*.assertions.json');
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('All evals passed.');
|
||||
console.log('');
|
||||
13
tools/generate-api-summary.mjs
Normal file
13
tools/generate-api-summary.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildApiSummary } from './api-summary.mjs';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const outputPath = path.join(rootDir, 'api-summary.json');
|
||||
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const summary = buildApiSummary(rootDir);
|
||||
writeFileSync(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
|
||||
console.log(`Generated ${path.relative(rootDir, outputPath)}`);
|
||||
5
tools/hooks/pre-commit
Normal file
5
tools/hooks/pre-commit
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Pre-commit hook: runs the generation validation gate and eval harness.
|
||||
# Install with: npm run install-hooks
|
||||
|
||||
node tools/validate-generation.mjs --artifacts-only && node tools/eval/run-evals.mjs
|
||||
19
tools/install-hooks.mjs
Normal file
19
tools/install-hooks.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import { copyFileSync, chmodSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const hooksDir = path.join(root, '.git', 'hooks');
|
||||
const src = path.join(root, 'tools', 'hooks', 'pre-commit');
|
||||
const dest = path.join(hooksDir, 'pre-commit');
|
||||
|
||||
if (!existsSync(path.join(root, '.git'))) {
|
||||
console.error('Not a git repository. Run from the repo root.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
try { chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
console.log('Installed pre-commit hook → .git/hooks/pre-commit');
|
||||
502
tools/validate-generation.mjs
Normal file
502
tools/validate-generation.mjs
Normal file
@@ -0,0 +1,502 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { getApiDslFiles, buildApiSummary } from './api-summary.mjs';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const artifactsOnly = args.has('--artifacts-only');
|
||||
const runRuntime = args.has('--run-runtime');
|
||||
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
|
||||
function assertCondition(condition, message) {
|
||||
if (!condition) {
|
||||
failures.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function warn(message) {
|
||||
warnings.push(message);
|
||||
}
|
||||
|
||||
function readIfExists(relativePath) {
|
||||
const filePath = path.join(rootDir, relativePath);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function requireFile(relativePath) {
|
||||
assertCondition(existsSync(path.join(rootDir, relativePath)), `Missing file: ${relativePath}`);
|
||||
}
|
||||
|
||||
function requireFiles(relativePaths) {
|
||||
relativePaths.forEach(requireFile);
|
||||
}
|
||||
|
||||
function requireContent(relativePath, pattern, message) {
|
||||
const contents = readIfExists(relativePath);
|
||||
assertCondition(Boolean(contents), `Missing file: ${relativePath}`);
|
||||
if (!contents) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertCondition(pattern.test(contents), `${message} (${relativePath})`);
|
||||
}
|
||||
|
||||
function parseJson(relativePath) {
|
||||
const raw = readIfExists(relativePath);
|
||||
if (!raw) {
|
||||
failures.push(`Missing file: ${relativePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
failures.push(`Invalid JSON in ${relativePath}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRealmArtifactPath() {
|
||||
const rootFiles = readdirSync(rootDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name);
|
||||
|
||||
const realmArtifacts = rootFiles.filter((entry) => /-realm\.json$/i.test(entry));
|
||||
assertCondition(realmArtifacts.length === 1, 'Expected exactly one root-level *-realm.json artifact');
|
||||
|
||||
return realmArtifacts[0] ?? null;
|
||||
}
|
||||
|
||||
function getWorkspaceInfo() {
|
||||
return {
|
||||
server: {
|
||||
dir: path.join(rootDir, 'server'),
|
||||
packagePath: 'server/package.json',
|
||||
scaffoldFiles: [
|
||||
'server/package.json',
|
||||
'server/tsconfig.json',
|
||||
'server/tsconfig.build.json',
|
||||
'server/nest-cli.json',
|
||||
'server/src/main.ts',
|
||||
'server/src/app.module.ts',
|
||||
],
|
||||
},
|
||||
client: {
|
||||
dir: path.join(rootDir, 'client'),
|
||||
packagePath: 'client/package.json',
|
||||
scaffoldFiles: [
|
||||
'client/package.json',
|
||||
'client/index.html',
|
||||
'client/tsconfig.json',
|
||||
'client/tsconfig.node.json',
|
||||
'client/vite.config.ts',
|
||||
'client/src/main.tsx',
|
||||
'client/src/vite-env.d.ts',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateBuildChecks() {
|
||||
requireFiles([
|
||||
'README.md',
|
||||
'package.json',
|
||||
'domain/dsl-spec.md',
|
||||
'api-summary.json',
|
||||
'server/prisma/schema.prisma',
|
||||
'docker-compose.yml',
|
||||
'server/Dockerfile',
|
||||
'client/Dockerfile',
|
||||
'client/nginx/default.conf',
|
||||
'server/.env.example',
|
||||
'client/.env.example',
|
||||
'prompts/general-prompt.md',
|
||||
'prompts/auth-rules.md',
|
||||
'prompts/backend-rules.md',
|
||||
'prompts/frontend-rules.md',
|
||||
'prompts/runtime-rules.md',
|
||||
'prompts/validation-rules.md',
|
||||
]);
|
||||
|
||||
// rule: AGENTS.md §Tier-1 — api.dsl must exist
|
||||
const apiDslFiles = getApiDslFiles(rootDir);
|
||||
assertCondition(apiDslFiles.length > 0, 'Expected at least one domain/*.api.dsl file');
|
||||
|
||||
// rule: AGENTS.md §Tier-2 — api-summary.json must match parsed api.dsl
|
||||
const actualApiSummaryRaw = readIfExists('api-summary.json');
|
||||
if (actualApiSummaryRaw) {
|
||||
try {
|
||||
const expectedApiSummary = JSON.stringify(buildApiSummary(rootDir), null, 2);
|
||||
assertCondition(
|
||||
actualApiSummaryRaw.trim() === expectedApiSummary,
|
||||
'api-summary.json is out of date. Run `npm run generate:api-summary`.',
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(`api-summary.json freshness check failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { server, client } = getWorkspaceInfo();
|
||||
requireFiles(server.scaffoldFiles);
|
||||
requireFiles(client.scaffoldFiles);
|
||||
|
||||
const serverPackage = parseJson(server.packagePath);
|
||||
if (serverPackage) {
|
||||
assertCondition(serverPackage.scripts?.build === 'nest build', 'server/package.json must keep `build = nest build`');
|
||||
assertCondition(serverPackage.scripts?.start === 'nest start', 'server/package.json must keep `start = nest start`');
|
||||
assertCondition(serverPackage.scripts?.['start:dev'] === 'nest start --watch', 'server/package.json must keep `start:dev = nest start --watch`');
|
||||
assertCondition(Boolean(serverPackage.scripts?.['start:prod']), 'server/package.json must define a start:prod script');
|
||||
assertCondition(Boolean(serverPackage.dependencies?.['@nestjs/core']), 'server/package.json must keep Nest runtime dependencies');
|
||||
}
|
||||
|
||||
const clientPackage = parseJson(client.packagePath);
|
||||
if (clientPackage) {
|
||||
assertCondition(clientPackage.scripts?.dev === 'vite', 'client/package.json must keep `dev = vite`');
|
||||
assertCondition(clientPackage.scripts?.build === 'vite build', 'client/package.json must keep `build = vite build`');
|
||||
assertCondition(clientPackage.scripts?.preview === 'vite preview', 'client/package.json must keep `preview = vite preview`');
|
||||
assertCondition(Boolean(clientPackage.devDependencies?.vite), 'client/package.json must keep Vite as a dev dependency');
|
||||
assertCondition(Boolean(clientPackage.devDependencies?.['@vitejs/plugin-react']), 'client/package.json must keep @vitejs/plugin-react as a dev dependency');
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthChecks() {
|
||||
requireFiles([
|
||||
'client/src/auth/keycloak.ts',
|
||||
'client/src/auth/authProvider.ts',
|
||||
'client/src/dataProvider.ts',
|
||||
'client/src/config/env.ts',
|
||||
'client/src/main.tsx',
|
||||
'client/src/App.tsx',
|
||||
'server/src/auth/auth.module.ts',
|
||||
'server/src/auth/auth.service.ts',
|
||||
'server/src/auth/guards/jwt-auth.guard.ts',
|
||||
'server/src/auth/guards/roles.guard.ts',
|
||||
'server/src/auth/decorators/public.decorator.ts',
|
||||
'server/src/auth/decorators/roles.decorator.ts',
|
||||
]);
|
||||
|
||||
requireContent(
|
||||
'client/src/auth/keycloak.ts',
|
||||
/onLoad:\s*'login-required'/,
|
||||
'Frontend auth must initialize Keycloak with login-required',
|
||||
);
|
||||
requireContent(
|
||||
'client/src/auth/keycloak.ts',
|
||||
/pkceMethod:\s*'S256'/,
|
||||
'Frontend auth must use PKCE S256',
|
||||
);
|
||||
requireContent(
|
||||
'client/src/auth/keycloak.ts',
|
||||
/updateToken\(/,
|
||||
'Frontend auth must refresh access tokens through the Keycloak adapter',
|
||||
);
|
||||
|
||||
const keycloakSource = readIfExists('client/src/auth/keycloak.ts') ?? '';
|
||||
assertCondition(
|
||||
!/loadUserProfile\(/.test(keycloakSource),
|
||||
'Frontend auth must not call keycloak.loadUserProfile()',
|
||||
);
|
||||
|
||||
requireContent(
|
||||
'client/src/dataProvider.ts',
|
||||
/Authorization', `Bearer \$\{token\}`/,
|
||||
'dataProvider must attach bearer tokens in the shared request seam',
|
||||
);
|
||||
|
||||
const authProvider = readIfExists('client/src/auth/authProvider.ts') ?? '';
|
||||
assertCondition(
|
||||
/status === 401/.test(authProvider) && /status === 403/.test(authProvider),
|
||||
'authProvider must distinguish 401 and 403 semantics',
|
||||
);
|
||||
|
||||
const authService = readIfExists('server/src/auth/auth.service.ts') ?? '';
|
||||
assertCondition(
|
||||
/jwtVerify/.test(authService) && /KEYCLOAK_ISSUER_URL/.test(authService) && /KEYCLOAK_AUDIENCE/.test(authService),
|
||||
'Backend auth must verify JWTs with issuer and audience',
|
||||
);
|
||||
assertCondition(/realm_access/.test(authService), 'Backend auth must extract roles from realm_access.roles');
|
||||
assertCondition(/KEYCLOAK_JWKS_URL/.test(authService), 'Backend auth must support explicit KEYCLOAK_JWKS_URL');
|
||||
assertCondition(/\.well-known\/openid-configuration/.test(authService), 'Backend auth must try OIDC discovery before fallback certs');
|
||||
assertCondition(/protocol\/openid-connect\/certs/.test(authService), 'Backend auth must keep Keycloak certs fallback resolution');
|
||||
}
|
||||
|
||||
function validateRealmChecks() {
|
||||
const realmArtifactName = getRealmArtifactPath();
|
||||
if (!realmArtifactName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = parseJson(realmArtifactName);
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
const realmRoles = artifact.roles?.realm?.map((role) => role.name) ?? [];
|
||||
const frontendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-frontend'));
|
||||
const backendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-backend'));
|
||||
const audienceScope = artifact.clientScopes?.find((scope) => scope.name === 'api-audience');
|
||||
|
||||
['admin', 'editor', 'viewer'].forEach((role) => {
|
||||
assertCondition(realmRoles.includes(role), `Realm artifact must define realm role ${role}`);
|
||||
});
|
||||
|
||||
assertCondition(Boolean(frontendClient), 'Realm artifact must define the frontend SPA client');
|
||||
assertCondition(Boolean(backendClient), 'Realm artifact must define the backend resource client');
|
||||
assertCondition(Boolean(audienceScope), 'Realm artifact must define the api-audience client scope');
|
||||
|
||||
if (frontendClient) {
|
||||
assertCondition(frontendClient.publicClient === true, 'Frontend realm client must be public');
|
||||
assertCondition(
|
||||
frontendClient.standardFlowEnabled === true &&
|
||||
frontendClient.implicitFlowEnabled === false &&
|
||||
frontendClient.directAccessGrantsEnabled === false,
|
||||
'Frontend realm client must use standard flow only',
|
||||
);
|
||||
assertCondition(
|
||||
frontendClient.attributes?.['pkce.code.challenge.method'] === 'S256',
|
||||
'Frontend realm client must enforce PKCE S256',
|
||||
);
|
||||
|
||||
const mapperNames = new Set((frontendClient.protocolMappers ?? []).map((mapper) => mapper.name));
|
||||
['sub', 'preferred_username', 'email', 'name', 'realm roles'].forEach((mapperName) => {
|
||||
assertCondition(mapperNames.has(mapperName), `Frontend realm client must include protocol mapper ${mapperName}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (backendClient && audienceScope) {
|
||||
const audienceMapper = (audienceScope.protocolMappers ?? []).find(
|
||||
(mapper) => mapper.protocolMapper === 'oidc-audience-mapper',
|
||||
);
|
||||
assertCondition(Boolean(audienceMapper), 'api-audience scope must include an audience mapper');
|
||||
assertCondition(
|
||||
audienceMapper?.config?.['included.client.audience'] === backendClient.clientId,
|
||||
'api-audience scope must deliver the backend audience/client id',
|
||||
);
|
||||
assertCondition(backendClient.bearerOnly === true, 'Backend realm client must be bearer-only');
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeContractChecks() {
|
||||
requireFiles([
|
||||
'docker-compose.yml',
|
||||
'server/Dockerfile',
|
||||
'client/Dockerfile',
|
||||
'client/nginx/default.conf',
|
||||
]);
|
||||
const compose = readIfExists('docker-compose.yml') ?? '';
|
||||
assertCondition(/image:\s*postgres:16/.test(compose), 'docker-compose must provision postgres:16');
|
||||
assertCondition(/^\s{2}server\s*:/m.test(compose), 'docker-compose must define a server service');
|
||||
assertCondition(/^\s{2}client\s*:/m.test(compose), 'docker-compose must define a client service');
|
||||
assertCondition(
|
||||
/^\s{4}ports:\n\s{6}-\s*"\$\{CLIENT_PORT:-8080\}:80"/m.test(compose),
|
||||
'docker-compose client service must publish the SPA on ${CLIENT_PORT:-8080}:80 by default',
|
||||
);
|
||||
const hasKeycloakService =
|
||||
/^\s{2}keycloak\s*:/m.test(compose) || /image:\s*.*keycloak/i.test(compose);
|
||||
assertCondition(!hasKeycloakService, 'docker-compose must remain PostgreSQL-only (no Keycloak container)');
|
||||
|
||||
const codexConfig = readIfExists('.codex/config.toml') ?? '';
|
||||
if (codexConfig) {
|
||||
const agentConfigPaths = [...codexConfig.matchAll(/config_file\s*=\s*"([^"]+)"/g)].map((match) => match[1]);
|
||||
for (const configPath of agentConfigPaths) {
|
||||
assertCondition(!path.isAbsolute(configPath), `.codex/config.toml must not hardcode absolute config_file paths: ${configPath}`);
|
||||
const relativeToCodex = path.join('.codex', configPath);
|
||||
assertCondition(
|
||||
existsSync(path.join(rootDir, relativeToCodex)),
|
||||
`.codex/config.toml references a missing agent config: ${relativeToCodex}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const serverDockerfile = readIfExists('server/Dockerfile') ?? '';
|
||||
assertCondition(/FROM node:/i.test(serverDockerfile), 'server/Dockerfile must build from a Node base image');
|
||||
assertCondition(/npm run build/.test(serverDockerfile), 'server/Dockerfile must build the Nest workspace');
|
||||
assertCondition(/docker-entrypoint\.sh/.test(serverDockerfile), 'server/Dockerfile must use the repository entrypoint');
|
||||
|
||||
const clientDockerfile = readIfExists('client/Dockerfile') ?? '';
|
||||
assertCondition(/FROM nginx:/i.test(clientDockerfile), 'client/Dockerfile must use nginx for runtime serving');
|
||||
assertCondition(/COPY nginx\/default\.conf/.test(clientDockerfile), 'client/Dockerfile must install the nginx proxy config');
|
||||
assertCondition(/COPY --from=build \/app\/dist/.test(clientDockerfile), 'client/Dockerfile must copy the built SPA bundle');
|
||||
|
||||
const nginxConfig = readIfExists('client/nginx/default.conf') ?? '';
|
||||
assertCondition(/location \/api\//.test(nginxConfig), 'client/nginx/default.conf must proxy /api requests');
|
||||
assertCondition(/try_files \$uri \$uri\/ \/index\.html;/.test(nginxConfig), 'client/nginx/default.conf must preserve SPA routing');
|
||||
assertCondition(/proxy_pass/.test(nginxConfig), 'client/nginx/default.conf must proxy backend traffic');
|
||||
|
||||
const serverEnvExample = readIfExists('server/.env.example') ?? '';
|
||||
assertCondition(
|
||||
/KEYCLOAK_ISSUER_URL="https:\/\/sso\.greact\.ru\/realms\/toir"/.test(serverEnvExample),
|
||||
'server/.env.example must keep the working Keycloak issuer example',
|
||||
);
|
||||
assertCondition(
|
||||
/KEYCLOAK_AUDIENCE="?toir-backend"?/.test(serverEnvExample),
|
||||
'server/.env.example must keep the working backend audience example',
|
||||
);
|
||||
assertCondition(
|
||||
/CORS_ALLOWED_ORIGINS="http:\/\/localhost:5173,https:\/\/toir-frontend\.greact\.ru"/.test(serverEnvExample),
|
||||
'server/.env.example must keep the working CORS example with the production frontend domain',
|
||||
);
|
||||
assertCondition(
|
||||
!/KEYCLOAK_ISSUER_URL=http:\/\/localhost:8080\/realms\/toir/.test(serverEnvExample),
|
||||
'server/.env.example must not regress to localhost Keycloak as the baseline issuer example',
|
||||
);
|
||||
|
||||
const clientEnvExample = readIfExists('client/.env.example') ?? '';
|
||||
assertCondition(
|
||||
/VITE_KEYCLOAK_URL=https:\/\/sso\.greact\.ru/.test(clientEnvExample),
|
||||
'client/.env.example must keep the working domain-based Keycloak URL example',
|
||||
);
|
||||
assertCondition(
|
||||
/VITE_KEYCLOAK_REALM=toir/.test(clientEnvExample) && /VITE_KEYCLOAK_CLIENT_ID=toir-frontend/.test(clientEnvExample),
|
||||
'client/.env.example must keep the working realm and frontend client examples',
|
||||
);
|
||||
assertCondition(
|
||||
!/VITE_KEYCLOAK_URL=http:\/\/localhost:8080/.test(clientEnvExample),
|
||||
'client/.env.example must not regress to localhost Keycloak as the baseline example',
|
||||
);
|
||||
|
||||
const healthController = readIfExists('server/src/health/health.controller.ts') ?? '';
|
||||
assertCondition(Boolean(healthController), 'Missing file: server/src/health/health.controller.ts');
|
||||
if (healthController) {
|
||||
assertCondition(
|
||||
/@Public\(\)/.test(healthController) && /@Controller\('health'\)/.test(healthController),
|
||||
'/health must stay public',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, commandArgs, workdir, failureLabel) {
|
||||
const runtimeEnv = { ...process.env };
|
||||
const envExamplePath = path.join(workdir, '.env.example');
|
||||
if (existsSync(envExamplePath)) {
|
||||
const envExample = readFileSync(envExamplePath, 'utf8');
|
||||
for (const line of envExample.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = trimmed.indexOf('=');
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
const value = trimmed.slice(separator + 1).trim().replace(/^"|"$/g, '');
|
||||
if (!(key in runtimeEnv)) {
|
||||
runtimeEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commandLine = [command, ...commandArgs].join(' ');
|
||||
const result = spawnSync(commandLine, {
|
||||
cwd: workdir,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
failures.push(`${failureLabel}: ${commandLine}\n${result.error.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim();
|
||||
const stdout = result.stdout?.trim();
|
||||
failures.push(
|
||||
`${failureLabel}: ${commandLine}${stderr ? `\n${stderr}` : stdout ? `\n${stdout}` : ''}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeValidateWorkspaceBuild(relativeDir) {
|
||||
const workspaceDir = path.join(rootDir, relativeDir);
|
||||
if (!existsSync(path.join(workspaceDir, 'package.json'))) {
|
||||
failures.push(`Missing file: ${relativeDir}/package.json`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(path.join(workspaceDir, 'node_modules'))) {
|
||||
warn(`Skipped build verification for ${relativeDir}: install dependencies in ${relativeDir}/ to validate workspace buildability.`);
|
||||
return;
|
||||
}
|
||||
|
||||
runCommand('npm', ['run', 'build'], workspaceDir, `Build verification failed in ${relativeDir}`);
|
||||
}
|
||||
|
||||
function validateBuildExecutionChecks() {
|
||||
maybeValidateWorkspaceBuild('server');
|
||||
maybeValidateWorkspaceBuild('client');
|
||||
}
|
||||
|
||||
function validateRuntimeExecutionChecks() {
|
||||
const serverDir = path.join(rootDir, 'server');
|
||||
if (!existsSync(path.join(serverDir, 'node_modules'))) {
|
||||
failures.push(
|
||||
'Runtime validation requires installed backend dependencies. Run `npm install` in server/ before `npm run validate:generation:runtime`.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runCommand('npx', ['prisma', 'generate'], serverDir, 'Prisma generate failed');
|
||||
const migrationsDir = path.join(serverDir, 'prisma', 'migrations');
|
||||
const hasMigrations =
|
||||
existsSync(migrationsDir) &&
|
||||
readdirSync(migrationsDir, { withFileTypes: true }).some((entry) => entry.isDirectory());
|
||||
|
||||
if (hasMigrations) {
|
||||
runCommand('npx', ['prisma', 'migrate', 'deploy'], serverDir, 'Prisma migrate deploy failed');
|
||||
} else {
|
||||
warn('No committed Prisma migrations found; runtime validation is using `prisma db push` as a temporary bootstrap fallback.');
|
||||
runCommand('npx', ['prisma', 'db', 'push'], serverDir, 'Prisma db push failed');
|
||||
}
|
||||
runCommand('npx', ['prisma', 'db', 'seed'], serverDir, 'Prisma seed failed');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural validator scope only:
|
||||
// keep stable scaffold/runtime/auth/realm/build invariants here.
|
||||
// Entity-level DSL fidelity and CRUD/UI semantics are owned by `npm run eval:generation`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
validateBuildChecks();
|
||||
validateAuthChecks();
|
||||
validateRealmChecks();
|
||||
validateRuntimeContractChecks();
|
||||
|
||||
if (!artifactsOnly) {
|
||||
validateBuildExecutionChecks();
|
||||
}
|
||||
|
||||
if (!artifactsOnly && runRuntime) {
|
||||
validateRuntimeExecutionChecks();
|
||||
} else if (!artifactsOnly) {
|
||||
warn('Runtime command execution skipped. Use --run-runtime after installing dependencies and starting the local database.');
|
||||
}
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.warn(`WARN: ${warning}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('Generation validation failed:');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Generation validation passed.');
|
||||
Reference in New Issue
Block a user