This commit is contained in:
MaKarin
2026-04-03 20:54:37 +03:00
commit c89c23fd1d
50 changed files with 6716 additions and 0 deletions

108
.codex/AGENTS.md Normal file
View File

@@ -0,0 +1,108 @@
# Codex CLI — KIS-TOiR workspace supplement
This file supplements the repository root `AGENTS.md` with Codex-specific
operational notes. The root `AGENTS.md` is the authoritative contract —
if anything here contradicts root, root wins.
---
## Agent role summary
| Role | Config file | Sandbox | Write boundary |
|------|-------------|---------|----------------|
| `generator` | `agents/generator.toml` | workspace-write | Tier 3 generation zones |
| `explorer` | `agents/explorer.toml` | read-only | None |
| `reviewer` | `agents/reviewer.toml` | read-only | Proposes patches only |
| `docs_researcher` | `agents/docs-researcher.toml` | read-only | None |
Use `/agent generator` for implementation work. Use `/agent explorer` first for
discovery, `/agent docs_researcher` when framework or prompt patterns need
verification, and `/agent reviewer` before claiming a generation run is complete.
---
## Mutation boundary map
```
Tier 1 — Source of truth (NEVER written by any agent)
domain/*.api.dsl — single source of truth for all generation
prompts/*.md — generation spec / rules
AGENTS.md — agent operating rules
.codex/AGENTS.md (this file) — Codex-specific supplement
Tier 2 — Deterministic derivatives (written only by npm scripts, not by agents)
api-summary.json ← npm run generate:api-summary
openapi.json ← npm run generate:openapi (auxiliary)
Tier 3 — LLM-generated artifacts (written ONLY by generator agent)
server/src/modules/<entity>/
client/src/resources/<entity>/
server/src/app.module.ts
client/src/App.tsx
server/prisma/schema.prisma ← LLM-generated per prompts/prisma-rules.md
server/src/auth/
client/src/auth/
client/src/dataProvider.ts
toir-realm.json
docker-compose.yml
server/.env.example
client/.env.example
Tier 4 — Handwritten / framework-managed support files
framework scaffold and other manual support files outside prompt-governed outputs
```
---
## Standard generation invocation
```bash
# 1. Read AGENTS.md + prompts/general-prompt.md
# 2. Read the entity-scoped DSL block from domain/toir.api.dsl
# 3. Load only the stage-specific companion rules you need
# 4. Run generation or repair with the appropriate agent
# 5. Refresh api-summary.json only if validator/tooling expects the auxiliary freshness artifact
# 6. Verify (both stages must pass)
node tools/validate-generation.mjs --artifacts-only
npm run eval:generation
```
---
## MCP servers (project-local)
Defined in `.codex/config.toml`:
- **github** — repository access
- **context7** — library documentation lookup (use for framework questions)
- **exa** — web search
- **memory** — persistent cross-session context
- **playwright** — browser automation for smoke tests
- **sequential-thinking** — structured multi-step reasoning
Add heavier or credential-backed servers in `~/.codex/config.toml`.
---
## Validation gate
Run before every commit and after every generation:
```bash
# Stage 1 — structural gate
node tools/validate-generation.mjs --artifacts-only
# Stage 2 — eval harness
npm run eval:generation
```
The pre-commit hook (`tools/hooks/pre-commit`) runs both stages automatically
after `npm run install-hooks`.
---
## Security notes
- Never commit secrets. Use environment variables from `.env.example` templates.
- Run `npm audit` when adding new dependencies to `server/` or `client/`.
- Auth contracts live in `prompts/auth-rules.md`. Do not deviate from them.

View File

@@ -0,0 +1,10 @@
model = "anthropic/claude-sonnet-4.5"
model_reasoning_effort = "low"
sandbox_mode = "read-only"
developer_instructions = """
Verify APIs, framework behavior, and release-note claims against primary documentation before changes land.
Cite the exact docs or file paths that support each claim.
Do not invent undocumented behavior.
Use Context7 and official docs first when the current environment exposes them.
"""

View File

@@ -0,0 +1,23 @@
model = "anthropic/claude-haiku-4.5"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = """
Stay in exploration mode. Read files freely; write nothing.
Trace the real execution path, cite files and symbols, and avoid proposing
fixes unless the parent agent asks for them.
Prefer targeted search and file reads over broad scans.
KIS-TOiR source-of-truth tier reference (read-only for this agent):
Tier 1: domain/*.api.dsl, prompts/*.md, AGENTS.md
Tier 2: api-summary.json (deterministic auxiliary derivative; never authoritative)
Tier 3: server/src/modules/, client/src/resources/, server/src/app.module.ts,
client/src/App.tsx, server/prisma/schema.prisma, server/src/auth/,
client/src/auth/, client/src/dataProvider.ts, toir-realm.json,
docker-compose.yml, server/.env.example, client/.env.example
Tier 4: framework scaffold and other handwritten support files
When asked about generation output, always trace it back to its Tier 1 DSL source
and do not recommend api-summary.json as the primary input when the DSL is available.
"""

View File

@@ -0,0 +1,51 @@
model = "anthropic/claude-opus-4.6"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
approval_policy = "on-request"
developer_instructions = """
You are the LLM generation agent for KIS-TOiR.
PERMITTED write zones (Tier 3 — LLM-generated artifacts):
server/src/modules/<entity>/ — NestJS modules, controllers, services, DTOs
client/src/resources/<entity>/ — React Admin List/Create/Edit/Show
server/src/app.module.ts — module registration section only
client/src/App.tsx — resource registration section only
server/prisma/schema.prisma — LLM-generated per prompts/prisma-rules.md
server/src/auth/ — auth artifacts per prompts/auth-rules.md
client/src/auth/ — auth artifacts per prompts/auth-rules.md
client/src/dataProvider.ts — authenticated data provider seam per prompts/auth-rules.md
toir-realm.json — realm artifact per prompts/auth-rules.md
docker-compose.yml — runtime artifact per prompts/runtime-rules.md
server/.env.example — runtime defaults per prompts/runtime-rules.md
client/.env.example — runtime defaults per prompts/runtime-rules.md
FORBIDDEN write zones — never modify these files:
domain/*.api.dsl — source of truth (Tier 1)
prompts/*.md — generation spec (Tier 1)
AGENTS.md — workflow contract (Tier 1)
api-summary.json — deterministic derivative (Tier 2)
tools/ — deterministic tooling, not generated artifacts
CONTEXT BUDGET (mandatory):
1. Read prompts/general-prompt.md first.
2. Read ONLY the entity-scoped api.dsl block (api API.<EntityName> + its DTOs + enums)
from domain/toir.api.dsl. Do NOT inject the full api.dsl as a blob.
3. Read ONLY the relevant companion rule file for the active stage.
4. Before generating any DTO or component, quote the relevant DSL field definitions
verbatim, then generate from those quotes. This prevents training-data contamination.
5. Use api-summary.json only as an auxiliary inventory or validator-related artifact,
never as the source of truth or default starting point.
GENERATION WORKFLOW:
1. Read prompts/general-prompt.md.
2. Read the entity-scoped block from domain/toir.api.dsl.
3. Read the relevant stage rule docs.
4. Generate or update Tier 3 artifacts.
5. Refresh api-summary.json only if the validator/tooling requires it.
6. Run: node tools/validate-generation.mjs --artifacts-only
7. Run: npm run eval:generation
8. Fix all failures before reporting complete.
NEVER report generation complete if either validation gate fails.
"""

View File

@@ -0,0 +1,32 @@
model = "anthropic/claude-sonnet-4.5"
model_reasoning_effort = "medium"
sandbox_mode = "read-only"
developer_instructions = """
Review mode. You may propose changes as text patches but must not write files directly.
Focus on:
- Correctness: does generated code match the api.dsl and prompt contracts?
- Security: auth guard placement, CORS, env variable handling.
- Regression: do both verification gates pass?
node tools/validate-generation.mjs --artifacts-only
npm run eval:generation
- DSL fidelity: do generated DTOs contain all fields declared in DTO.<Entity>Create/Update?
- Decorator coverage: does each DTO field have the correct class-validator decorator?
- Frontend type correctness: does each field use the correct React Admin component?
- Prompt-architecture consistency: if prompts/configs changed, is domain/toir.api.dsl still clearly authoritative and api-summary.json still clearly auxiliary?
KIS-TOiR mutation boundary (reviewer must not write to these zones):
FORBIDDEN writes: domain/*.api.dsl, prompts/*.md, AGENTS.md,
api-summary.json, tools/, server/prisma/schema.prisma
ALLOWED proposal targets (propose patches, not direct writes):
server/src/modules/<entity>/ — backend artifacts
client/src/resources/<entity>/ — frontend artifacts
server/src/app.module.ts, client/src/App.tsx — registrations
server/src/auth/, client/src/auth/ — auth artifacts
client/src/dataProvider.ts — authenticated data provider seam
toir-realm.json, docker-compose.yml — runtime/realm artifacts
server/.env.example, client/.env.example — runtime defaults
docs/ — documentation updates
"""

141
.codex/config.toml Normal file
View File

@@ -0,0 +1,141 @@
#:schema https://developers.openai.com/codex/config-schema.json
# Everything Claude Code (ECC) — Codex Reference Configuration
#
# Copy this file to ~/.codex/config.toml for global defaults, or keep it in
# the project root as .codex/config.toml for project-local settings.
#
# Official docs:
# - https://developers.openai.com/codex/config-reference
# - https://developers.openai.com/codex/multi-agent
# Model selection
# This project intentionally pins a project-local provider + model so the same
# orchestration, MCP servers, and sub-agents run through OpenRouter + Claude.
# Paste your OpenRouter token directly below if you do not want to use env vars.
model_provider = "openrouter"
model = "anthropic/claude-sonnet-4.5"
[model_providers.openrouter]
name = "OpenRouter"
base_url = "https://openrouter.ai/api/v1"
# Paste your token here:
# experimental_bearer_token = "or-xxxxxxxxxxxxxxxxxxxxxxxx"
experimental_bearer_token = "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"
wire_api = "responses"
requires_openai_auth = false
[model_providers.openrouter.http_headers]
# Optional but recommended by OpenRouter for app attribution.
HTTP-Referer = "https://local.kis-toir"
X-Title = "KIS-TOiR"
# Top-level runtime settings (current Codex schema)
approval_policy = "on-request"
sandbox_mode = "workspace-write"
web_search = "live"
# External notifications receive a JSON payload on stdin.
# macOS example (uncomment on Mac if `terminal-notifier` is installed):
# notify = [
# "terminal-notifier",
# "-title", "Codex KIS",
# "-message", "Task completed!",
# "-sound", "default",
# ]
# Persistent instructions are appended to every prompt (additive, unlike
# model_instructions_file which replaces AGENTS.md).
persistent_instructions = "Follow project AGENTS.md guidelines. Use available MCP servers when they can help."
# model_instructions_file replaces built-in instructions instead of AGENTS.md,
# so leave it unset unless you intentionally want a single override file.
# model_instructions_file = "/absolute/path/to/instructions.md"
# MCP servers
# Keep the default project set lean. API-backed servers inherit credentials from
# the launching environment or can be supplied by a user-level ~/.codex/config.toml.
[mcp_servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
startup_timeout_sec = 30
[mcp_servers.context7]
command = "npx"
# Canonical Codex section name is `context7`; the package itself remains
# `@upstash/context7-mcp`.
args = ["-y", "@upstash/context7-mcp@latest"]
startup_timeout_sec = 30
[mcp_servers.exa]
url = "https://mcp.exa.ai/mcp"
[mcp_servers.memory]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-memory"]
startup_timeout_sec = 30
[mcp_servers.playwright]
command = "npx"
args = ["-y", "@playwright/mcp@latest", "--extension"]
startup_timeout_sec = 30
[mcp_servers.sequential-thinking]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]
startup_timeout_sec = 30
# Additional MCP servers (uncomment as needed):
# [mcp_servers.supabase]
# command = "npx"
# args = ["-y", "supabase-mcp-server@latest", "--read-only"]
#
# [mcp_servers.firecrawl]
# command = "npx"
# args = ["-y", "firecrawl-mcp"]
#
# [mcp_servers.fal-ai]
# command = "npx"
# args = ["-y", "fal-ai-mcp-server"]
#
# [mcp_servers.cloudflare]
# command = "npx"
# args = ["-y", "@cloudflare/mcp-server-cloudflare"]
[features]
# Codex multi-agent collaboration is stable and on by default in current builds.
# Keep the explicit toggle here so the repo documents its expectation clearly.
multi_agent = true
# Profiles — switch with `codex -p <name>`
[profiles.strict]
approval_policy = "on-request"
sandbox_mode = "read-only"
web_search = "cached"
[profiles.yolo]
approval_policy = "never"
sandbox_mode = "workspace-write"
web_search = "live"
[agents]
# Multi-agent role limits and local role definitions.
# These map to `.codex/agents/*.toml` and mirror the repo's explorer/reviewer/docs workflow.
max_threads = 6
max_depth = 1
[agents.explorer]
description = "Read-only codebase explorer for gathering evidence before changes are proposed."
config_file = "agents/explorer.toml"
[agents.reviewer]
description = "PR reviewer focused on correctness, security, and DSL fidelity. Proposes patches; writes nothing directly."
config_file = "agents/reviewer.toml"
[agents.docs_researcher]
description = "Documentation specialist that verifies APIs, framework behavior, and release notes."
config_file = "agents/docs-researcher.toml"
[agents.generator]
description = "LLM generation agent: writes server/src/modules/, client/src/resources/, app.module.ts, App.tsx only. Never touches DSL, prompts, or deterministic tooling."
config_file = "agents/generator.toml"

15
.env.portainer.example Normal file
View File

@@ -0,0 +1,15 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me
POSTGRES_DB=toir
POSTGRES_PORT=5432
CORS_ALLOWED_ORIGINS=https://toir.example.ru
KEYCLOAK_ISSUER_URL=https://sso.example.ru/realms/toir
KEYCLOAK_AUDIENCE=toir-backend
KEYCLOAK_JWKS_URL=
VITE_API_URL=/api
VITE_KEYCLOAK_URL=https://sso.example.ru
VITE_KEYCLOAK_REALM=toir
VITE_KEYCLOAK_CLIENT_ID=toir-frontend

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Dependencies
**/node_modules/
# Build outputs
**/dist/
**/dist-ssr/
**/coverage/
**/.cache/
**/*.tsbuildinfo
# Environment files
**/.env
**/.env.local
**/.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# OS files
.DS_Store
Thumbs.db
# Editor / IDE
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Generated OpenAPI (local runs; commit only if you want to publish the spec)
openapi.generated.json
openapi.llm.json
tools/api-format-to-openapi/demo-output/
.cursor/

216
AGENTS.md Normal file
View File

@@ -0,0 +1,216 @@
# KIS-TOiR — Agent Operating Rules
Read this file at the start of every session before reading any other file.
---
## What this repository is
KIS-TOiR is a fullstack CRUD application (NestJS backend + React Admin frontend)
for equipment maintenance management (Техническое обслуживание и ремонт).
Generation is driven by a single authoritative source file:
- `domain/toir.api.dsl` — the complete API contract: enums, DTOs, endpoints, HTTP methods, pagination
---
## Source-of-truth hierarchy
### Tier 1 — authoritative (hand-authored; never overwritten by generation)
| File | Authoritative for |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `domain/*.api.dsl` | Enums, DTO shapes per operation, nullability, HTTP verb+path per endpoint, endpoint names, pagination contracts. Single source of truth. Drives: NestJS modules + React Admin resources + Prisma schema. |
| `prompts/*.md` | Auth rules, runtime rules, framework scaffold rules, Prisma rules, validation rules, generation policy, naming conventions. |
| `AGENTS.md` (this file) | Agent workflow, mutation boundaries, verification contract. |
### Tier 2 — deterministic derivative (generated by script; never edited manually)
| File | Generated from | Command |
| ------------------ | ------------------ | ------------------------------ |
| `api-summary.json` | `domain/*.api.dsl` | `npm run generate:api-summary` |
### Tier 3 — LLM-generated artifacts (never edit manually after generation)
| Zone | Generated from |
| -------------------------------- | ------------------------------------------------------ |
| `server/src/modules/<entity>/` | `domain/*.api.dsl` + `prompts/backend-rules.md` |
| `client/src/resources/<entity>/` | `domain/*.api.dsl` + `prompts/frontend-rules.md` |
| `server/src/app.module.ts` | Module registrations derived from api.dsl api blocks |
| `client/src/App.tsx` | Resource registrations derived from api.dsl api blocks |
| `server/prisma/schema.prisma` | `domain/*.api.dsl` + `prompts/prisma-rules.md` |
| `server/src/auth/` | `prompts/auth-rules.md` |
| `client/src/auth/` | `prompts/auth-rules.md` |
| `client/src/dataProvider.ts` | `prompts/auth-rules.md` |
| `toir-realm.json` | `prompts/auth-rules.md` |
| `docker-compose.yml` | `prompts/runtime-rules.md` |
| `server/.env.example` | `prompts/runtime-rules.md` |
| `client/.env.example` | `prompts/runtime-rules.md` |
### Tier 4 — handwritten / framework-managed support files
- Framework scaffold: `server/nest-cli.json`, `server/tsconfig*.json`, `client/vite.config.*`, etc.
---
## Forbidden mutations during any generation run
**NEVER write to `*.dsl` files.**
They are read-only inputs. To change the API contract or domain model, edit `domain/*.api.dsl`
as a separate explicit task.
**NEVER manually edit files under `server/src/modules/` or `client/src/resources/`.**
To change generated code: update `domain/*.api.dsl` and regenerate.
**NEVER edit `server/prisma/schema.prisma` directly.**
It is LLM-generated from `domain/*.api.dsl` following `prompts/prisma-rules.md`.
---
## Generation workflow (required sequence)
1. Read `AGENTS.md` and `prompts/general-prompt.md`.
2. Read the active `api API.<EntityName>` block + its DTOs + its enums from `domain/toir.api.dsl` (entity-scoped — do not inject the full DSL as a blob).
3. Load the companion rule files required for the active stage:
- `prompts/prisma-rules.md`
- `prompts/backend-rules.md`
- `prompts/frontend-rules.md`
- `prompts/auth-rules.md`
- `prompts/runtime-rules.md`
- `prompts/validation-rules.md`
4. Verify or repair framework scaffolds before domain generation:
- official Nest CLI for backend workspace creation/repair
- official Vite React TypeScript CLI for frontend workspace creation/repair
- official Prisma CLI when Prisma initialization or repair is required
5. Generate `server/prisma/schema.prisma` per `prompts/prisma-rules.md`.
6. Generate backend modules and registrations per `prompts/backend-rules.md`.
7. Generate frontend resources and registrations per `prompts/frontend-rules.md`.
8. Generate auth/runtime/realm artifacts per `prompts/auth-rules.md` and `prompts/runtime-rules.md`.
9. Refresh `api-summary.json` only when validation/tooling requires the auxiliary freshness artifact: `npm run generate:api-summary`.
10. Run: `node tools/validate-generation.mjs --artifacts-only`
11. Run: `npm run eval:generation`
12. Fix all failures before considering the task complete.
**Context budget rule:** Before generating any DTO or component, quote the field
definitions from the DSL api block verbatim, then generate from those quotes. This
prevents training-data contamination. See `prompts/general-prompt.md`.
---
## Type mappings
| DSL type | Prisma type | TS DTO type | React Admin component |
| --------- | ----------------------------- | ----------- | ----------------------------- |
| `uuid` | `String @id @default(uuid())` | `string` | `TextInput` / `TextField` |
| `string` | `String` | `string` | `TextInput` / `TextField` |
| `text` | `String` | `string` | `TextInput` / `TextField` |
| `integer` | `Int` | `number` | `NumberInput` / `NumberField` |
| `number` | `Float` | `number` | `NumberInput` / `NumberField` |
| `decimal` | `Decimal` | `string` | `NumberInput` / `NumberField` |
| `date` | `DateTime` | `string` | `DateInput` / `DateField` |
| `boolean` | `Boolean` | `boolean` | (appropriate boolean input) |
| enum name | enum name | `string` | `SelectInput` / `SelectField` |
---
## Naming conventions
Resource name (plural, kebab-case):
- `Equipment``equipment` (irregular — stays as-is)
- `EquipmentType``equipment-types`
- `RepairOrder``repair-orders`
- General: PascalCase → kebab-case → append `s` (or `es` if ends in `s`; irregular cases explicit)
Default sort field (when not declared in api.dsl):
Priority: `inventoryNumber` > `number` > `code` > `name` > primary key
---
## Verification gate (two-stage)
### Stage 1 — Structural gate
```
node tools/validate-generation.mjs --artifacts-only
```
Checks: file existence, api-summary freshness, auth seam contracts, natural-key handling, realm structure, runtime contract, api.dsl coverage (backend + frontend files per entity), DTO field name coverage, **DTO class-validator decorator coverage**, **@UseGuards presence on controllers**, **frontend component type correctness**.
Full structural verification (requires installed `node_modules`):
```
node tools/validate-generation.mjs
```
### Stage 2 — Eval harness (Rule 6)
```
npm run eval:generation
```
Fixture-based semantic checks from `tools/eval/fixtures/`. Checks: correct HTTP method decorators, Content-Range header, enum filter patterns, FK reference wiring, component type correctness, class-validator decorator patterns.
See `tools/eval/README.md` for fixture authoring and eval-driven development workflow.
**Generation is incomplete unless both stages pass.**
## Pre-commit hook
Install with `npm run install-hooks`. The hook runs **both** the structural gate and
the eval harness before every commit. Commits are blocked when either fails.
---
## Auth and runtime defaults
Full auth contract: `prompts/auth-rules.md`
Working defaults (do not regress to localhost):
- Keycloak base: `https://sso.greact.ru`
- Realm/client: `toir` / `toir-frontend` / `toir-backend`
- Production frontend: `https://toir-frontend.greact.ru`
- CORS: `http://localhost:5173,https://toir-frontend.greact.ru`
---
## OpenRouter configuration
```
OPENAI_API_KEY=<openrouter-key>
OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_MODEL=<model-id>
```
These variables are used by `tools/api-format-to-openapi/convert.mjs --mode llm`.
---
## Reading order for generation tasks
**Critical zone (load first, never drop):**
1. `AGENTS.md` (this file) — project governance, mutation boundaries, tier hierarchy
2. `prompts/general-prompt.md` — master orchestration prompt: mission, stage ownership, delegation model, completion criteria
3. `domain/toir.api.dsl §API.<EntityName>` — active api block only, plus its referenced DTOs and enums
**Companion zone (load when the matching stage is active):**
4. `prompts/prisma-rules.md` — Prisma schema generation details
5. `prompts/backend-rules.md` — NestJS generation details
6. `prompts/frontend-rules.md` — React Admin generation details
7. `prompts/auth-rules.md` — auth seam and realm requirements
8. `prompts/runtime-rules.md` — scaffold, env, and bootstrap requirements
9. `prompts/validation-rules.md` — success gate requirements
**Auxiliary zone (never authoritative):**
10. `api-summary.json` — optional inventory/freshness artifact for validators and supporting tooling; do not use it instead of the DSL
**Reference only (do not load proactively):**
- `domain/dsl-spec.md` — DSL syntax reference; load only if DSL is ambiguous
- `docs/generation-playbook.md` — step-by-step workflow reference
- `docs/future-work.md` — deferred items (Rules 7 and 8)

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline.
It is not a new generator engine and it is not a compiler platform. The repository remains:
- an AI generation context
- an active generated and maintained fullstack CRUD project
- `server/` as the active backend target output path
- `client/` as the active frontend target output path
- an LLM-first orchestration baseline with CLI-first framework bootstrap
- a compact rule set that strengthens the existing pipeline with:
- `api-summary.json` (deterministic intermediate context)
- a physical root-level realm artifact
- a lightweight automated validation gate
## Active knowledge blocks
The master generation prompt is `prompts/general-prompt.md`. It contains the complete
generation workflow, type mappings, naming conventions, and all core rules.
Companion rule files for artifact-specific details:
1. [prompts/general-prompt.md](prompts/general-prompt.md) — master generation prompt
2. [prompts/auth-rules.md](prompts/auth-rules.md) — auth seam / realm spec
3. [prompts/backend-rules.md](prompts/backend-rules.md) — backend reference
4. [prompts/frontend-rules.md](prompts/frontend-rules.md) — frontend reference
5. [prompts/prisma-rules.md](prompts/prisma-rules.md) — Prisma schema rules
6. [prompts/runtime-rules.md](prompts/runtime-rules.md) — runtime / bootstrap
7. [prompts/validation-rules.md](prompts/validation-rules.md) — validation gate
## Baseline contracts
- `domain/*.api.dsl` is the single source of truth for the domain model and API contract.
- [api-summary.json](api-summary.json) is a derived artifact for LLM stabilization and validation.
- [toir-realm.json](toir-realm.json) is the physical Keycloak bootstrap artifact baseline.
- `server/` and `client/` are the active target output paths for this repository.
- `server/` must remain a valid NestJS workspace baseline.
- `client/` must remain a valid Vite React TypeScript workspace baseline.
## Scaffold baseline
- Generation remains LLM-first for orchestration and domain-derived feature code.
- Framework bootstrap is CLI-first:
- backend baseline starts from official Nest CLI conventions
- frontend baseline starts from official Vite React TypeScript conventions
- If `server/` or `client/` drift away from a valid workspace, repair the workspace baseline before generating more feature code.
- Do not replace the framework workspace with a hand-written minimal skeleton.
## Anti-regression contract
- The active prompts define forbidden generation patterns, required invariants, and recovery rules for future agents.
- Buildability is part of the baseline contract, not an optional follow-up.
- Validation targets `domain/*.api.dsl` as reusable source inputs, while TOiR names remain project defaults/examples.
## Repository layout
- [docs/repository-structure.md](docs/repository-structure.md) explains the normalized folder structure.
- Active prompts live in `prompts/`.
- Helper scripts live in `tools/`.
## Commands
```bash
npm run generate:api-summary
npm run validate:generation
npm run validate:generation:runtime
npm run eval:generation
```
`npm run validate:generation` checks both contract shape and workspace validity. When dependencies are installed, it also verifies `npm run build` in `server/` and `client/`. If dependencies are missing, it reports build verification as skipped instead of pretending the baseline is fully green.
## AID export (OpenAPI)
HTTP-экспортёр для интеграции с AID: **`POST /aid/export/openapi`** (api-format → OpenAPI 3.0). Подробно: **[docs/AID_EXPORT_README.md](docs/AID_EXPORT_README.md)**.
> **Note:** The `POST /aid/export/app` endpoint (DSL → generated app bundle) is currently
> non-operative because its backing script (`generation/generate.mjs`) was removed during
> the architecture migration to api.dsl-first generation. See `docs/AID_EXPORT_README.md`
> for details.

31
api-summary.json Normal file
View File

@@ -0,0 +1,31 @@
{
"sourceFiles": [
"domain/toir.api.dsl"
],
"enums": [
{
"name": "EquipmentStatus",
"description": null,
"values": [
{
"name": "Active",
"label": "В эксплуатации"
},
{
"name": "Repair",
"label": "В ремонте"
},
{
"name": "Reserve",
"label": "В резерве"
},
{
"name": "WriteOff",
"label": "Списано"
}
]
}
],
"dtos": [],
"apis": []
}

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
postgres:
image: postgres:16
container_name: toir-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-toir}
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-toir}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:

120
docs/AID_EXPORT_README.md Normal file
View File

@@ -0,0 +1,120 @@
# AID: экспорт OpenAPI и генератор приложения
В репозитории добавлены **сервисы-экспортёры** для интеграции с **AID** (или любым другим клиентом по HTTP): автоматическое получение **OpenAPI 3.0** из доменного **api-format**.
---
## Что работает
| Компонент | Назначение |
|-----------|------------|
| **`POST /aid/export/openapi`** (NestJS) | На вход JSON **api-format** → на выход документ **OpenAPI 3.0** в поле `openapi`. |
| **`tools/api-format-to-openapi/`** | CLI и промпт для LLM: тот же конвертер, что вызывает Nest. |
| **`server/src/aid-export/`** | Модуль Nest: контроллер, сервис, краткая справка в `README.md` рядом с кодом. |
## Что временно не работает
| Компонент | Статус |
|-----------|--------|
| **`POST /aid/export/app`** (DSL → бандл/apply) | **Non-operative.** The backing script (`generation/generate.mjs`) was removed during the architecture migration to api.dsl-first LLM generation. The endpoint returns 500 with a descriptive error. |
---
## Требования к запуску
1. Репозиторий клонирован целиком (есть `tools/`, `server/`, `client/`).
2. Backend запускается из каталога **`server/`** (`npm run start` / `start:dev`), чтобы относительные пути `../tools/api-format-to-openapi/convert.mjs` были корректны.
3. Для режима OpenAPI через LLM на сервере нужны **`OPENAI_API_KEY`** (и при необходимости `OPENAI_MODEL`, `OPENAI_BASE_URL`).
---
## Переменные окружения (`server/.env`)
| Переменная | Зачем |
|------------|--------|
| `AID_EXPORT_API_KEY` | Если задана, к **`/aid/export/*`** нужен заголовок **`X-AID-Export-Key`** с тем же значением. |
| `OPENAI_API_KEY` | Для `POST /aid/export/openapi` с **`"mode": "llm"`**. |
Остальное как для обычного бэкенда (`DATABASE_URL`, `PORT` и т.д.).
---
## HTTP API (интеграция с AID)
Базовый URL: `http://<host>:<port>` (например `http://localhost:3000`).
### 1. OpenAPI из api-format
**`POST /aid/export/openapi`**
```http
Content-Type: application/json
X-AID-Export-Key: <если задан AID_EXPORT_API_KEY>
```
```json
{
"apiFormat": {
"apiFormatVersion": "1",
"info": { "title": "API", "version": "1.0.0" },
"server": { "basePath": "/api" },
"resources": []
},
"mode": "deterministic"
}
```
- **`mode`**: `deterministic` (по умолчанию) — маппинг в коде для схемы версии `1`; **`llm`** — вызов OpenAI по промпту из `tools/api-format-to-openapi/prompts/llm-system.md`.
**Ответ:** `{ "openapi": { "openapi": "3.0.3", ... } }`
Пример входа для теста: `tools/api-format-to-openapi/examples/api-format.example.json` (подставьте как значение `apiFormat`).
### 2. Генератор приложения из DSL (non-operative)
**`POST /aid/export/app`**
> **Non-operative.** This endpoint depended on `generation/generate.mjs` which was removed
> during the migration to api.dsl-first LLM generation. It currently returns HTTP 500
> with a descriptive error message. Restoring this endpoint requires implementing a new
> backing script compatible with the current `domain/*.api.dsl` pipeline.
---
## CLI (без Nest)
### api-format → OpenAPI
```bash
cd tools/api-format-to-openapi
node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
```
LLM:
```bash
set OPENAI_API_KEY=sk-...
node convert.mjs --mode llm --in your-api-format.json --out ../../openapi.llm.json
```
Подробнее: **`tools/api-format-to-openapi/README.md`**.
---
## Типичный сценарий для AID
1. AID уже сформировал **api-format** (как у вас принято после DTO).
2. AID вызывает **`POST /aid/export/openapi`** → получает **OpenAPI 3.0** → сохраняет в проект / отдаёт в Swagger / в реестр.
---
## Где смотреть код и короткую справку
- Полное описание эндпоинтов рядом с реализацией: **`server/src/aid-export/README.md`**
---
## Ограничения и дальнейшие шаги
- Пример **api-format** в репозитории — **учебный**; под ваш продакшен-формат может понадобиться расширить маппинг в `convert.mjs` или отточить промпт **`llm-system.md`**.
- **`/aid/export/app`** requires a new backing implementation compatible with the `domain/*.api.dsl` pipeline to be restored. See `docs/future-work.md` for planned future work.

162
docs/api-dsl-conventions.md Normal file
View File

@@ -0,0 +1,162 @@
# api.dsl Conventions
Grammar and authoring conventions for `domain/*.api.dsl` files.
See `domain/toir.api.dsl` as the canonical example.
---
## File location and naming
- All api.dsl files live in `domain/`.
- Naming: `<project>.api.dsl` (e.g. `toir.api.dsl`).
- Multiple api.dsl files are allowed but not required.
---
## Two top-level block types
An api.dsl file contains two types of top-level blocks:
1. `dto` — defines the shape of a data transfer object
2. `api` — declares a group of API endpoints for one resource
---
## dto block
```
dto DTO.<Name> {
description "Human-readable description";
attribute <fieldName> {
description "Field description";
type <type>;
is required; // or: is nullable;
map <Entity>.<field>; // links to a field in domain/*.api.dsl
}
}
```
### DTO naming convention
| DTO name | Purpose |
|----------|---------|
| `DTO.<Entity>` | Full response shape (GET by id, list items) |
| `DTO.<Entity>Create` | Create request body |
| `DTO.<Entity>Update` | Update request body (partial — all fields nullable) |
| `DTO.<Entity>ListRequest` | Paginated list request (filters + page) |
| `DTO.<Entity>ListResponse` | Paginated list response (content + page info) |
### Types
Same scalar types as the domain DSL:
`uuid`, `string`, `text`, `integer`, `decimal`, `date`, `boolean`, or an enum name from
`domain/*.api.dsl`.
Cross-DTO references: `DTO.<Name>` or `DTO.<Name>[]`.
Standard pagination types (not entity-specific; treated as well-known):
`DTO.Filter[]`, `DTO.PageRequest`, `DTO.PageInfo`.
### is required vs is nullable
Unlike the domain DSL (where these drive DB constraints), in api.dsl these drive the
TypeScript DTO property modifier:
- `is required``field!: type` in the generated class
- `is nullable``field?: type` in the generated class
- Neither modifier → treated as optional (`field?: type`)
### map directive
`map Entity.field` links the DTO attribute to a domain entity field.
Rules:
- The entity and field must exist in `domain/*.api.dsl`.
- The scalar type must match the domain DSL field type (nullability may differ).
- Omit `map` only for pagination helper types (`DTO.Filter[]`, `DTO.PageRequest`, etc.)
that have no direct entity field counterpart.
### Conflict resolution
If the type declared in a `dto` attribute conflicts with the domain DSL field type,
the domain DSL is correct. Fix the api.dsl.
---
## api block
```
api API.<Name> {
description "API group description";
endpoint <endpointName> {
label "METHOD /path";
description "Endpoint description";
// For endpoints with a request body:
attribute request {
type DTO.<RequestDto>;
}
// For endpoints with a response body:
attribute response {
type DTO.<ResponseDto>;
}
// For path parameters:
attribute <paramName> {
type <type>;
}
}
}
```
### api block naming
`API.<EntityName>` (e.g. `API.Equipment`, `API.RepairOrder`)
### endpoint label
`label "METHOD /path"` is the authoritative declaration of HTTP verb and route.
| Label pattern | NestJS decorator | Notes |
|---------------|-----------------|-------|
| `"GET /resource/{id}"` | `@Get(':id')` | |
| `"POST /resource"` | `@Post()` | Create |
| `"POST /resource/page"` | `@Post('page')` | Paginated list (body-based filter) |
| `"PUT /resource/{id}"` | `@Put(':id')` | Full or partial update |
| `"DELETE /resource/{id}"` | `@Delete(':id')` | |
Do not infer HTTP verbs from endpoint names. Always read the `label`.
### Path parameters
Declared as a plain `attribute` inside the endpoint block (not wrapped in `request`
or `response`):
```
attribute id {
type uuid;
}
```
### Standard 5-endpoint CRUD pattern
| Endpoint name | Label | Body |
|---------------|-------|------|
| `list<Entity>s` | `"POST /<path>/page"` | request: `DTO.<Entity>ListRequest`, response: `DTO.<Entity>ListResponse` |
| `get<Entity>` | `"GET /<path>/{id}"` | path param `id`, response: `DTO.<Entity>` |
| `create<Entity>` | `"POST /<path>"` | request: `DTO.<Entity>Create` |
| `update<Entity>` | `"PUT /<path>/{id}"` | path param `id`, request: `DTO.<Entity>Update` |
| `delete<Entity>` | `"DELETE /<path>/{id}"` | path param `id` |
---
## Constraints
1. Every `map Entity.field` must resolve to an existing entity + field in `domain/*.api.dsl`.
2. Types in api.dsl must be compatible with domain DSL field types (same scalar type).
3. api.dsl must not define entities or enums. Those belong in `domain/*.api.dsl`.
4. An api.dsl may omit domain entity fields from a DTO (e.g. no PK in Create DTO).
It must not add fields that don't exist in the domain model.

154
docs/future-work.md Normal file
View File

@@ -0,0 +1,154 @@
# Future Work — Deferred Items
This file tracks engineering improvements that are deliberately deferred due to the
current stage of the project. They are not forgotten — they are acknowledged technical
debt that should be addressed before scaling.
---
## Rule 7 — Tracing, Telemetry, Cost/Latency Observability
**Status:** Deferred. No LLM calls are instrumented.
**Why it matters (Anthropic / Google / Microsoft guidance):**
Without observability, you cannot:
- Know which prompts are expensive (token count, latency)
- Detect prompt regressions via cost drift
- Attribute generation failures to specific prompt versions
- Track improvement over time
**What needs to be built:**
### 7.1 — Generation log
Create `tools/generation-log.mjs` that wraps any LLM generation call and writes a
structured JSON entry to `logs/generation.jsonl`:
```json
{
"timestamp": "2026-04-03T10:00:00.000Z",
"entity": "Equipment",
"artifact": "backend",
"prompt_version": "1.0",
"model": "...",
"input_tokens": 4200,
"output_tokens": 1800,
"latency_ms": 3200,
"validation_passed": true,
"eval_passed": true
}
```
### 7.2 — Cost budget alerts
Add a threshold check (e.g., warn if input_tokens > 8000 for a single entity generation).
This enforces the context budget from `prompts/general-prompt.md §CONTEXT BUDGET`.
### 7.3 — Prompt version tracking
Add `<!-- prompt-version: X.Y -->` comments to all prompt files (already started in
`backend-rules.md` and `frontend-rules.md`). Increment version on any non-trivial change.
Log the prompt versions alongside the generation log entry.
### 7.4 — Drift detection
Compare generation log entries across runs. If token count for the same entity increases
by >20% without a DSL change, flag it as context rot.
**Effort estimate:** Medium. 23 days to build the logging layer. Zero effort for
prompt versioning (already partially done).
**Trigger:** Implement before the system is used for more than 10 entities or before
any production deployment.
---
## Rule 8 — Risk Controls and Red-Teaming
**Status:** Deferred. No sanitization or adversarial testing exists.
**Why it matters (Anthropic / Google / Microsoft guidance):**
LLM-generated code at scale introduces risks that do not exist in hand-written code:
- **Prompt injection**: malicious content in DSL `description` fields could steer
generation (e.g., `description "Ignore previous instructions and..."`)
- **Generated credential leakage**: LLM may hallucinate hardcoded secrets that look
real (e.g., `apiKey: 'sk-...'`)
- **Missing auth guards**: already caught by Rule 4 validator, but adversarial prompts
could bypass it by generating valid-looking guard syntax that is semantically inactive
- **Supply chain**: generated package imports could reference non-existent or malicious
packages if the LLM hallucinates
**What needs to be built:**
### 8.1 — DSL input sanitization
In `tools/api-summary.mjs`, before building the summary, check all `description` and
`label` fields for injection patterns:
```javascript
function sanitizeDslString(value, fieldPath) {
const injectionPatterns = [
/ignore previous/i,
/disregard.*instruction/i,
/you are now/i,
/system:/i,
];
for (const pattern of injectionPatterns) {
if (pattern.test(value)) {
throw new Error(`Potential prompt injection in DSL field ${fieldPath}: "${value}"`);
}
}
return value;
}
```
### 8.2 — Generated code security scan
Add to `tools/validate-generation.mjs` (or a separate `tools/security-scan.mjs`):
```javascript
// Check no hardcoded secrets leaked into generated code
function validateNoSecretLeakage() {
const patterns = [
/sk-[a-zA-Z0-9]{20,}/, // OpenAI key pattern
/[a-zA-Z0-9+/]{40}={0,2}/, // Base64 secret-like
/password\s*=\s*['"][^'"]{4,}['"]/, // Hardcoded password
/apiKey\s*=\s*['"][^'"]{4,}['"]/, // Hardcoded API key
];
// Run against all generated files...
}
```
### 8.3 — UseGuards completeness audit
Beyond the current validator check (UseGuards present), add: verify that the guard
constructor arguments are non-empty and match the expected guard class names. A guard
call like `@UseGuards()` (empty) passes the current regex but provides no protection.
### 8.4 — Red-team fixture
Create `tools/eval/fixtures/_adversarial/` with a fixture that includes a DSL snippet
containing a benign injection attempt (e.g., a `description` field with "ignore format
rules") and verifies the generation still produces spec-compliant output.
### 8.5 — Generated import allowlist
Maintain a list of approved npm packages that generated code may import. Flag any
import not on the allowlist as a manual review item.
**Effort estimate:** Medium-High. 35 days. Security scan and sanitization are low
effort; red-team fixtures and import allowlisting are higher effort.
**Trigger:** Implement before any external user can influence `domain/*.api.dsl` content
(i.e., before a UI or API to edit the DSL is exposed).
---
## Tracking
| Rule | Status | Priority | Trigger |
|------|--------|----------|---------|
| Rule 7 — Telemetry | Deferred | Medium | Before >10 entities or production deployment |
| Rule 8 — Risk controls | Deferred | High | Before DSL editing is exposed to external users |
Last updated: 2026-04-03

237
docs/generation-playbook.md Normal file
View File

@@ -0,0 +1,237 @@
# Generation Playbook
Step-by-step instructions for generating and regenerating artifacts in this repository.
Read `AGENTS.md` and `docs/source-of-truth.md` first.
---
## Pipeline overview
```
Tier 1 — Single Source of Truth (hand-authored, never generated)
domain/toir.api.dsl ──┐ enums, DTOs, endpoints, HTTP methods,
│ entity field mappings, primary keys
Tier 2 — Deterministic Preprocessing (npm scripts, no LLM)
api-summary.json ←─ npm run generate:api-summary
openapi.json ←─ npm run generate:openapi (auxiliary)
Tier 1 (api.dsl) + Tier 2 (context) + prompts/*.md ──► LLM Generation
prompts/general-prompt.md
prompts/backend-rules.md ──► server/src/modules/<entity>/
prompts/frontend-rules.md ──► client/src/resources/<entity>/
prompts/prisma-rules.md ──► server/prisma/schema.prisma
prompts/auth-rules.md ──► (auth seam reference)
prompts/runtime-rules.md ──► (env/docker reference)
prompts/validation-rules.md ──► (validation gate reference)
Tier 4 — Validation Gate
node tools/validate-generation.mjs --artifacts-only
```
---
## Prerequisites
Before any generation run:
1. `domain/*.api.dsl` is current and valid.
2. Refresh the Tier 2 intermediate context:
```bash
npm run generate:api-summary
```
3. Run `node tools/validate-generation.mjs --artifacts-only` to confirm the baseline passes.
---
## Standard generation workflow
### Step 1 — Refresh Tier 2 derived artifacts
```bash
# From repo root
npm run generate:api-summary
```
### Step 2 — Read generation inputs (context budget)
> **`prompts/general-prompt.md` is the master generation prompt.** It contains all core
> type mappings, naming conventions, DTO/controller/service/frontend rules, mutation
> boundaries, and the complete generation workflow. Load it as the single entrypoint.
>
> For artifact-specific details (Prisma FK rules, auth JWKS chain, detailed validation
> groups), load the relevant companion file: `prompts/prisma-rules.md`,
> `prompts/auth-rules.md`, `prompts/validation-rules.md`, or `prompts/runtime-rules.md`.
>
> See `prompts/general-prompt.md §CONTEXT BUDGET` for the full budget model.
1. `prompts/general-prompt.md` — master generation prompt (always load)
2. `api-summary.json §<entity>` — compact entity index (fast-path context anchor)
3. `domain/*.api.dsl §API.<EntityName>` — **only the api block + its referenced DTOs + enums** (entity-scoped)
4. **If needed:** `prompts/prisma-rules.md` (Prisma) or `prompts/auth-rules.md` (auth seams)
Before generating any DTO or component: **quote the relevant DSL field definitions verbatim first**, then generate from those quotes. This prevents training-data contamination.
### Step 3 — Generate Prisma schema
Generate `server/prisma/schema.prisma` from `domain/*.api.dsl` following `prompts/prisma-rules.md`.
If the schema changed, run Prisma migration in `server/`:
```bash
cd server
npx prisma migrate dev --name <description>
```
### Step 4 — Generate backend modules
For each `api` block in `domain/*.api.dsl`, generate:
1. `server/src/modules/<kebab>/<entity>.module.ts`
2. `server/src/modules/<kebab>/dto/create-<kebab>.dto.ts`
— fields from the `DTO.<Entity>Create` block in api.dsl
3. `server/src/modules/<kebab>/dto/update-<kebab>.dto.ts`
— fields from the `DTO.<Entity>Update` block in api.dsl
4. `server/src/modules/<kebab>/<entity>.service.ts`
— CRUD operations using Prisma; respect type mappings from `prompts/backend-rules.md`
5. `server/src/modules/<kebab>/<entity>.controller.ts`
— one method per `endpoint` in the `api` block; HTTP verb and path from the `label`
Register the module in `server/src/app.module.ts`.
### Step 5 — Generate frontend resources
For each `api` block in `domain/*.api.dsl`, generate:
1. `client/src/resources/<kebab>/<Entity>List.tsx`
— columns from `DTO.<Entity>` (response shape)
2. `client/src/resources/<kebab>/<Entity>Create.tsx`
— fields from `DTO.<Entity>Create`
3. `client/src/resources/<kebab>/<Entity>Edit.tsx`
— fields from `DTO.<Entity>Update`
4. `client/src/resources/<kebab>/<Entity>Show.tsx`
— fields from `DTO.<Entity>`
Register the resource in `client/src/App.tsx`.
### Step 6 — Verify (two-stage gate)
**Stage 1 — Structural gate:**
```bash
node tools/validate-generation.mjs --artifacts-only
```
Checks file existence, field names, class-validator decorators, auth guards, and RA component types.
Full structural verification (requires installed deps):
```bash
node tools/validate-generation.mjs
```
**Stage 2 — Eval harness:**
```bash
npm run eval:generation
```
Fixture-based semantic checks. See `tools/eval/README.md`.
Both must pass before the task is complete.
---
## Adding a new entity
1. Add the entity's enums, DTOs, and `api` block to `domain/toir.api.dsl`.
2. Run `npm run generate:api-summary`.
3. **Before generating:** create `tools/eval/fixtures/<kebab>/meta.json`, `backend.assertions.json`, and `frontend.assertions.json` with expected patterns.
4. Run `npm run eval:generation` — it will fail (entity files don't exist yet). That's expected.
5. Generate backend and frontend artifacts (Steps 35).
6. Run `npm run eval:generation` again — now it should pass.
7. Run `node tools/validate-generation.mjs --artifacts-only` — both gates must pass.
> This is **eval-driven development**: write the failing eval first (step 3), then generate to make it pass (step 6). A passing eval confirms the LLM followed the rules.
---
## Changing an existing entity
**If the domain model changes** (new field, changed type, new FK, new enum):
1. Update `domain/toir.api.dsl` (enums, DTO attributes, `map` references).
2. Run `npm run generate:api-summary`.
3. Regenerate `server/prisma/schema.prisma`, run migration.
4. Regenerate the affected modules and resources (Steps 46).
**If only the API contract changes** (different nullability, new endpoint, different HTTP method):
1. Update `domain/toir.api.dsl` only.
2. Run `npm run generate:api-summary` to refresh `api-summary.json`.
3. Run Steps 46. No Prisma migration needed.
---
## Artifact traceability matrix
| Artifact | DSL source | Prompt rule | Validator check |
| ---------------------------------------------- | ---------------------- | ---------------------------------------------- | --------------------------------------- |
| `server/prisma/schema.prisma` | `domain/*.api.dsl` | `prompts/prisma-rules.md` | `§validateBuildChecks` (file exists) |
| `api-summary.json` | `domain/*.api.dsl` | — (deterministic) | `§validateBuildChecks` (freshness) |
| `server/src/modules/<e>/*.ts` | `domain/*.api.dsl` | `prompts/backend-rules.md` | `§validateApiDslCoverage` |
| `server/src/modules/<e>/dto/create-<e>.dto.ts` | `DTO.<E>Create` fields | `prompts/backend-rules.md §DTO-field-coverage` | `§validateApiDslCoverage` (field names) |
| `server/src/modules/<e>/dto/update-<e>.dto.ts` | `DTO.<E>Update` fields | `prompts/backend-rules.md §DTO-field-coverage` | `§validateApiDslCoverage` (field names) |
| `client/src/resources/<e>/<E>*.tsx` | `domain/*.api.dsl` | `prompts/frontend-rules.md` | `§validateApiDslCoverage` |
| `client/src/auth/keycloak.ts` | — (Tier 4 handwritten) | `prompts/auth-rules.md` | `§validateAuthChecks` |
| `toir-realm.json` | — (Tier 4 handwritten) | `prompts/auth-rules.md` | `§validateRealmChecks` |
| `docker-compose.yml` | — (Tier 4 handwritten) | `prompts/runtime-rules.md` | `§validateRuntimeContractChecks` |
---
## Verification commands reference
| Command | When to use |
| ----------------------------------------------------- | ------------------------------------------ |
| `npm run generate:api-summary` | After any change to `domain/*.api.dsl` |
| `npm run generate:openapi` | To regenerate OpenAPI 3.0.3 documentation |
| `node tools/validate-generation.mjs --artifacts-only` | After every generation run (required) |
| `node tools/validate-generation.mjs` | Before committing; requires installed deps |
| `node tools/validate-generation.mjs --run-runtime` | End-to-end; requires running DB |
---
## OpenRouter invocation
Set environment variables before any LLM-mode tool call:
```
OPENAI_API_KEY=<openrouter-key>
OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_MODEL=<model-id>
```
The `OPENAI_BASE_URL` variable is consumed by `tools/api-format-to-openapi/convert.mjs --mode llm`.
For agent-driven generation via Cursor or direct API calls, the standard workflow above
applies regardless of model provider.
---
## Auxiliary tools
### `tools/api-summary-to-openapi.mjs`
Generates `openapi.json` (OpenAPI 3.0.3) from `api-summary.json` deterministically.
This is a documentation/integration artifact, not part of the core generation pipeline.
```bash
npm run generate:openapi
```
### `tools/api-format-to-openapi/`
Auxiliary integration tool for external consumers using the `api-format` JSON schema.
Not connected to the main DSL pipeline. See `docs/AID_EXPORT_README.md` for details.

View File

@@ -0,0 +1,35 @@
# Repository Structure
`KIS-TOiR` keeps the existing LLM-first generation philosophy and organizes the repository by meaning:
- `domain/`
- canonical DSL inputs
- DSL specification
- `prompts/`
- active prompt corpus used to drive generation
- `docs/`
- overview and repository-level architecture notes
- `tools/`
- helper scripts for summary generation and validation
- `server/`
- active backend target output
- `client/`
- active frontend target output
The repository keeps LLM-first generation orchestration, but framework bootstrap is CLI-first:
- `server/` must remain a valid NestJS workspace baseline
- `client/` must remain a valid Vite React TypeScript workspace baseline
- repair a broken workspace before applying more domain-derived generation changes
- future agents must treat forbidden generation patterns in `prompts/` as contract violations, not suggestions
Root-level files stay limited to repository-level artifacts such as:
- `README.md`
- `package.json`
- `docker-compose.yml`
- `api-summary.json`
- `toir-realm.json`
- `.gitignore`
The repository does not introduce a new generator engine or compiler platform. It keeps the current LLM-first pipeline and makes it cleaner, more explicit, and easier to navigate.

96
docs/source-of-truth.md Normal file
View File

@@ -0,0 +1,96 @@
# Source-of-Truth Hierarchy
This document is the authoritative reference for which files own which decisions.
---
## Tier 1: Authoritative sources (hand-authored; never generated)
### `domain/*.api.dsl`
**Single source of truth for the entire domain and API contract:**
- Entity names and structure
- Attribute names, scalar types, descriptions
- Primary keys (including natural string keys)
- Foreign keys and relations
- Enum definitions and their values
- Database-level constraints: `is required`, `is unique`, `default`
- DTO shapes per operation (Create, Update, Read, ListRequest, ListResponse)
- Which fields appear in each DTO and with what TypeScript modifier (`!` or `?`)
- HTTP method and path for each endpoint (via `label "METHOD /path"`)
- Endpoint names (camelCase identifiers)
- Pagination request/response contract
**Drives:** `server/prisma/schema.prisma`, `server/src/modules/`, `client/src/resources/`,
`server/src/app.module.ts`, `client/src/App.tsx`.
### `prompts/*.md` and `AGENTS.md`
**Authoritative for:**
- Agent generation workflow and reading order
- Auth seam patterns (Keycloak, JWT, PKCE S256, JWKS resolution chain)
- Runtime conventions (env examples, docker-compose topology)
- Framework scaffold baseline requirements (NestJS CLI, Vite React TypeScript)
- Filtering and sorting contract
- Naming conventions and implicit rules (pluralization, sort field priority, type mappings)
- Mutation boundaries (what agents must not overwrite)
---
## Tier 2: Deterministic derivatives (never edit manually)
| File | Generated from | Command |
| ----------------- | ------------------ | ------------------------------ |
| `api-summary.json` | `domain/*.api.dsl` | `npm run generate:api-summary` |
These files are regenerated from their sources. Manual edits are overwritten on the
next generation run.
---
## Tier 3: LLM-generated artifacts (never edit manually after generation)
| Zone | Generated from |
| -------------------------------- | ------------------------------------------------ |
| `server/prisma/schema.prisma` | `domain/*.api.dsl` + `prompts/prisma-rules.md` |
| `server/src/modules/<entity>/` | `domain/*.api.dsl` + `prompts/backend-rules.md` |
| `client/src/resources/<entity>/` | `domain/*.api.dsl` + `prompts/frontend-rules.md` |
| `server/src/app.module.ts` | Module list derived from api.dsl `api` blocks |
| `client/src/App.tsx` | Resource list derived from api.dsl `api` blocks |
To change these files: update `domain/*.api.dsl` and regenerate.
---
## Tier 4: Handwritten (not generated; not derived)
- Auth seams: `client/src/auth/`, `server/src/auth/`
- `toir-realm.json`
- `docker-compose.yml`
- `server/.env.example`, `client/.env.example`
- Framework config: `nest-cli.json`, `tsconfig*.json`, `vite.config.*`, etc.
- All `prompts/*.md`, `AGENTS.md`, `domain/dsl-spec.md`, `domain/*.api.dsl`
---
## Validation gate
### Stage 1 — Structural gate
```
node tools/validate-generation.mjs --artifacts-only
```
Verifies that generated artifacts satisfy the contracts declared in Tier 1 sources.
### Stage 2 — Eval harness
```
npm run eval:generation
```
Fixture-based semantic checks from `tools/eval/fixtures/`.
Both stages must pass before any generation task is considered complete.

248
domain/dsl-spec.md Normal file
View File

@@ -0,0 +1,248 @@
# DSL Language Specification
This document describes the DSL system used to specify fullstack CRUD applications.
`domain/*.api.dsl` is the single source of truth for the entire domain model and API
contract. It drives Prisma schema generation, NestJS module generation, and React Admin
resource generation.
`api-summary.json` is a derived artifact generated from the api.dsl to stabilize
LLM-first generation and feed the lightweight validation gate. It must never replace the
DSL as the source of truth. The active prompt corpus that consumes this contract lives in
`prompts/`.
---
# DSL Architecture
## `domain/*.api.dsl`
The api.dsl is the authoritative source of truth for:
- entities and their attributes
- scalar types and enums
- primary keys and foreign keys
- database-level constraints (required, unique, default)
- relations between entities
- DTO shapes per operation (Create, Update, Read, List)
- nullability and requiredness of each DTO attribute per operation
- HTTP methods and paths for each endpoint
- endpoint names and groupings
- pagination request/response contracts
The api.dsl drives: Prisma schema, NestJS controller/service/DTO generation,
and React Admin resource generation.
Constraint: every `map Entity.field` or `sync Entity.field` reference in `domain/*.api.dsl`
must resolve to an entity and field defined within the same api.dsl file.
## Optional extension mechanism
```text
overrides/
api-overrides.dsl
ui-overrides.dsl
```
Override rules:
- Overrides are optional.
- The generator must work without them.
- Overrides may refine derived API or UI behavior, but they must not duplicate or redefine
entities, attributes, primary keys, foreign keys, relations, or enums.
---
# DSL Grammar Concepts
## entity
An **entity** is a domain object that becomes a database table and a first-class resource in the backend and frontend.
```
entity Equipment {
attribute id { type uuid; key primary; }
attribute name { type string; is required; }
}
```
- **Domain:** Defines the canonical model; one entity = one Prisma model, one NestJS module, one React Admin resource.
- **Naming:** PascalCase (e.g. `Equipment`, `EquipmentType`, `RepairOrder`).
---
## attribute
An **attribute** is a field of an entity. It has a type and optional modifiers.
```
attribute name {
description "Наименование";
type string;
is required;
is unique;
}
```
**Modifiers:**
- `type` — required; one of: `string`, `uuid`, `integer`, `decimal`, `date`, `text`, `boolean`, `number`, or an enum name.
- `key primary` — this attribute is the primary key.
- `key foreign { relates Entity.field }` — foreign key to another entity's field.
- `is required` — non-nullable.
- `is unique` — unique constraint.
- `is nullable` — explicitly nullable.
- `default Value` — default value (for enums or literals).
- `description "..."` — human-readable description.
- `label "..."` — display label for UI.
---
## enum
An **enum** defines a fixed set of values. Used for attributes that can only take one of these values.
```
enum EquipmentStatus {
value Active { label "В эксплуатации"; }
value Repair { label "В ремонте"; }
}
```
- **value** — identifier used in data and code.
- **label** — optional display label for UI.
---
## primary key
Exactly one attribute per entity must be marked as primary key.
```
attribute id {
type uuid;
key primary;
}
```
Or for a natural key:
```
attribute code {
type string;
key primary;
is required;
is unique;
}
```
---
## foreign key
A **foreign key** links to another entity's primary key. The attribute type must match the referenced primary key type.
```
attribute equipmentTypeCode {
type string;
key foreign {
relates EquipmentType.code;
}
is required;
}
```
- `relates Entity.attribute` — references `Entity`'s `attribute` (must be primary key).
- FK type must equal referenced PK type (e.g. `string``EquipmentType.code`, `uuid``Equipment.id`).
---
## required
- **is required** — attribute is non-nullable in the domain model and drives requiredness in derived DTO/API/UI artifacts.
- Absence of `is required` means the attribute is optional (nullable).
---
## default
- **default Value** — applied when no value is provided (e.g. enum defaults like `default Active`).
- Value must exist in the enum when the attribute type is an enum.
---
# DSL → System Component Mapping
## DSL → Prisma
| DSL Concept | Prisma Result |
| ------------ | --------------------------- |
| entity | model |
| attribute | field |
| enum | enum |
| key primary | @id or @id @default(uuid()) |
| key foreign | relation + references |
| type string | String |
| type uuid | String @id @default(uuid()) |
| type integer | Int |
| type number | Float |
| type decimal | Decimal |
| type date | DateTime |
| type text | String |
| type boolean | Boolean |
---
## DSL → NestJS
| DSL Concept | NestJS Result |
| --------------------------- | ------------------------------------- |
| entity | One module (e.g. equipment.module.ts) |
| entity | Controller with CRUD endpoints |
| entity | Service with Prisma CRUD |
| entity + attribute metadata | create-{entity}.dto.ts |
| entity + attribute metadata | update-{entity}.dto.ts |
| entity + attribute metadata | Response DTO / API shape |
API paths are derived from entity name: PascalCase → kebab-case, pluralized (e.g. `Equipment``/equipment`, `RepairOrder``/repair-orders`).
---
## DSL → React Admin
| DSL Concept | React Admin Result |
| -------------------- | ----------------------------------- |
| entity | Resource (name = kebab-case plural) |
| attribute | Form field / column |
| type string | TextInput, TextField |
| type integer/decimal | NumberInput, NumberField |
| type number | NumberInput, NumberField |
| type date | DateInput, DateField |
| type boolean | BooleanInput, BooleanField |
| enum | SelectInput with choices |
| foreign key | ReferenceInput, ReferenceField |
---
# API DSL Layer Mapping
DTO shapes and endpoint contracts are declared in `domain/*.api.dsl`. The api.dsl
is the authoritative source for:
- **Create DTO** — declared as `dto DTO.<Entity>Create` in api.dsl. Must not include
server-generated primary keys (e.g. no `id` for uuid PKs). Required/nullable per field
is explicit in the api.dsl, not inferred.
- **Update DTO** — declared as `dto DTO.<Entity>Update` in api.dsl. All fields are
typically nullable for partial update semantics.
- **API response shape** — declared as `dto DTO.<Entity>` in api.dsl. Must expose
React Admin-compatible `id` field.
- **UI field mapping** — derived from the DTO shapes in api.dsl, not from domain
attributes directly. The Create form uses `DTO.<Entity>Create` fields; the Edit form
uses `DTO.<Entity>Update` fields; List and Show use `DTO.<Entity>` fields.

90
domain/toir.api.dsl Normal file
View File

@@ -0,0 +1,90 @@
enum EquipmentStatus {
value Active {
label "В эксплуатации";
}
value Repair {
label "В ремонте";
}
value Reserve {
label "В резерве";
}
value WriteOff {
label "Списано";
}
}
// Оборудование
entity Equipment {
description "Единица оборудования — объект ремонта и технического обслуживания";
attribute id {
type uuid;
key primary;
}
attribute name {
description "Название оборудования";
is required;
type string;
}
attribute serialNumber {
description "Заводской (серийный) номер";
type string;
is required;
}
attribute dateOfInspection {
description "Дата поверки";
type date;
}
attribute commissionedAt {
description "Дата изготовления";
type date;
}
attribute status {
description "Текущий статус";
type EquipmentStatus;
default Active;
is required;
}
}
// Документ изменения статуса оборудования — с характеристиками: дата события, ссылка на оборудование, новый статус, возможно ответственный
entity ChangeEquipmentStatus {
description "Документ изменения статуса оборудования";
attribute equipmentId {
description "Оборудование";
type Equipment;
}
attribute newStatus {
description "Новый статус";
type EquipmentStatus;
is required;
//sync Equipment.status;
}
attribute number {
description "Номер";
type string;
}
attribute date {
description "Дата изменения статуса";
is required;
type date;
}
attribute responsible {
description "Ответственный";
type string;
}
}

931
openapi.json Normal file
View File

@@ -0,0 +1,931 @@
{
"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": {
"Equipment": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"inventoryNumber": {
"type": "string",
"description": "Инвентарный номер"
},
"serialNumber": {
"type": "string",
"description": "Заводской (серийный) номер"
},
"name": {
"type": "string",
"description": "Наименование единицы оборудования"
},
"equipmentTypeCode": {
"type": "string",
"description": "Код вида оборудования"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/EquipmentStatus"
}
],
"description": "Текущий статус"
},
"location": {
"type": "string",
"description": "Место эксплуатации / скважина / куст"
},
"commissionedAt": {
"type": "string",
"format": "date-time",
"description": "Дата ввода в эксплуатацию"
},
"totalEngineHours": {
"type": "string",
"format": "decimal",
"description": "Общая наработка, моточасов"
},
"engineHoursSinceLastRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка с последнего ремонта, моточасов"
},
"lastRepairAt": {
"type": "string",
"format": "date-time",
"description": "Дата последнего ремонта"
},
"notes": {
"type": "string",
"description": "Примечания"
}
},
"description": "Оборудование — полный объект ответа"
},
"EquipmentCreate": {
"type": "object",
"properties": {
"inventoryNumber": {
"type": "string",
"description": "Инвентарный номер"
},
"serialNumber": {
"type": "string",
"description": "Заводской (серийный) номер"
},
"name": {
"type": "string",
"description": "Наименование единицы оборудования"
},
"equipmentTypeCode": {
"type": "string",
"description": "Код вида оборудования"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/EquipmentStatus"
}
],
"description": "Текущий статус"
},
"location": {
"type": "string",
"description": "Место эксплуатации / скважина / куст"
},
"commissionedAt": {
"type": "string",
"format": "date-time",
"description": "Дата ввода в эксплуатацию"
},
"totalEngineHours": {
"type": "string",
"format": "decimal",
"description": "Общая наработка, моточасов"
},
"engineHoursSinceLastRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка с последнего ремонта, моточасов"
},
"lastRepairAt": {
"type": "string",
"format": "date-time",
"description": "Дата последнего ремонта"
},
"notes": {
"type": "string",
"description": "Примечания"
}
},
"description": "Оборудование — тело запроса на создание",
"required": [
"inventoryNumber",
"name",
"equipmentTypeCode"
]
},
"EquipmentUpdate": {
"type": "object",
"properties": {
"inventoryNumber": {
"type": "string",
"description": "Инвентарный номер"
},
"serialNumber": {
"type": "string",
"description": "Заводской (серийный) номер"
},
"name": {
"type": "string",
"description": "Наименование единицы оборудования"
},
"equipmentTypeCode": {
"type": "string",
"description": "Код вида оборудования"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/EquipmentStatus"
}
],
"description": "Текущий статус"
},
"location": {
"type": "string",
"description": "Место эксплуатации / скважина / куст"
},
"commissionedAt": {
"type": "string",
"format": "date-time",
"description": "Дата ввода в эксплуатацию"
},
"totalEngineHours": {
"type": "string",
"format": "decimal",
"description": "Общая наработка, моточасов"
},
"engineHoursSinceLastRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка с последнего ремонта, моточасов"
},
"lastRepairAt": {
"type": "string",
"format": "date-time",
"description": "Дата последнего ремонта"
},
"notes": {
"type": "string",
"description": "Примечания"
}
},
"description": "Оборудование — тело запроса на обновление (частичное)"
},
"EquipmentListRequest": {
"type": "object",
"properties": {
"filters": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DTO.Filter"
}
},
"page": {
"$ref": "#/components/schemas/DTO.PageRequest"
}
},
"description": "Оборудование — запрос постраничного списка с фильтрацией"
},
"EquipmentListResponse": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Equipment"
}
},
"page": {
"$ref": "#/components/schemas/DTO.PageInfo"
}
},
"description": "Оборудование — постраничный результат"
},
"RepairOrder": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"number": {
"type": "string",
"description": "Номер заявки"
},
"equipmentId": {
"type": "string",
"format": "uuid",
"description": "Идентификатор оборудования"
},
"repairKind": {
"allOf": [
{
"$ref": "#/components/schemas/RepairKind"
}
],
"description": "Вид ремонта"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/RepairOrderStatus"
}
],
"description": "Статус заявки"
},
"plannedAt": {
"type": "string",
"format": "date-time",
"description": "Плановая дата начала"
},
"startedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата начала"
},
"completedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата завершения"
},
"contractor": {
"type": "string",
"description": "Подрядная организация (если внешний ремонт)"
},
"engineHoursAtRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка на момент ремонта, моточасов"
},
"description": {
"type": "string",
"description": "Описание работ / дефекта"
},
"notes": {
"type": "string",
"description": "Примечания"
},
"confirmed": {
"type": "boolean",
"description": "Согласовано/Не согласовано"
}
},
"description": "Заявка на ремонт — полный объект ответа"
},
"RepairOrderCreate": {
"type": "object",
"properties": {
"number": {
"type": "string",
"description": "Номер заявки"
},
"equipmentId": {
"type": "string",
"format": "uuid",
"description": "Идентификатор оборудования"
},
"repairKind": {
"allOf": [
{
"$ref": "#/components/schemas/RepairKind"
}
],
"description": "Вид ремонта"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/RepairOrderStatus"
}
],
"description": "Статус заявки"
},
"plannedAt": {
"type": "string",
"format": "date-time",
"description": "Плановая дата начала"
},
"startedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата начала"
},
"completedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата завершения"
},
"contractor": {
"type": "string",
"description": "Подрядная организация (если внешний ремонт)"
},
"engineHoursAtRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка на момент ремонта, моточасов"
},
"description": {
"type": "string",
"description": "Описание работ / дефекта"
},
"notes": {
"type": "string",
"description": "Примечания"
},
"confirmed": {
"type": "boolean",
"description": "Согласовано/Не согласовано"
}
},
"description": "Заявка на ремонт — тело запроса на создание",
"required": [
"number",
"equipmentId",
"repairKind",
"plannedAt"
]
},
"RepairOrderUpdate": {
"type": "object",
"properties": {
"number": {
"type": "string",
"description": "Номер заявки"
},
"equipmentId": {
"type": "string",
"format": "uuid",
"description": "Идентификатор оборудования"
},
"repairKind": {
"allOf": [
{
"$ref": "#/components/schemas/RepairKind"
}
],
"description": "Вид ремонта"
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/RepairOrderStatus"
}
],
"description": "Статус заявки"
},
"plannedAt": {
"type": "string",
"format": "date-time",
"description": "Плановая дата начала"
},
"startedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата начала"
},
"completedAt": {
"type": "string",
"format": "date-time",
"description": "Фактическая дата завершения"
},
"contractor": {
"type": "string",
"description": "Подрядная организация (если внешний ремонт)"
},
"engineHoursAtRepair": {
"type": "string",
"format": "decimal",
"description": "Наработка на момент ремонта, моточасов"
},
"description": {
"type": "string",
"description": "Описание работ / дефекта"
},
"notes": {
"type": "string",
"description": "Примечания"
},
"confirmed": {
"type": "boolean",
"description": "Согласовано/Не согласовано"
}
},
"description": "Заявка на ремонт — тело запроса на обновление (частичное)"
},
"RepairOrderListRequest": {
"type": "object",
"properties": {
"filters": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DTO.Filter"
}
},
"page": {
"$ref": "#/components/schemas/DTO.PageRequest"
}
},
"description": "Заявка на ремонт — запрос постраничного списка с фильтрацией"
},
"RepairOrderListResponse": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RepairOrder"
}
},
"page": {
"$ref": "#/components/schemas/DTO.PageInfo"
}
},
"description": "Заявка на ремонт — постраничный результат"
},
"EquipmentStatus": {
"type": "string",
"x-dsl-enum": "EquipmentStatus",
"description": "Enum: EquipmentStatus (values defined in domain/*.api.dsl)"
},
"DTO.Filter": {
"type": "string",
"x-dsl-enum": "DTO.Filter",
"description": "Enum: DTO.Filter (values defined in domain/*.api.dsl)"
},
"DTO.PageRequest": {
"type": "string",
"x-dsl-enum": "DTO.PageRequest",
"description": "Enum: DTO.PageRequest (values defined in domain/*.api.dsl)"
},
"DTO.PageInfo": {
"type": "string",
"x-dsl-enum": "DTO.PageInfo",
"description": "Enum: DTO.PageInfo (values defined in domain/*.api.dsl)"
},
"RepairKind": {
"type": "string",
"x-dsl-enum": "RepairKind",
"description": "Enum: RepairKind (values defined in domain/*.api.dsl)"
},
"RepairOrderStatus": {
"type": "string",
"x-dsl-enum": "RepairOrderStatus",
"description": "Enum: RepairOrderStatus (values defined in domain/*.api.dsl)"
}
}
},
"paths": {
"/equipment/page": {
"post": {
"summary": "Постраничный список оборудования с фильтрацией",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EquipmentListRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EquipmentListResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/equipment/{id}": {
"get": {
"summary": "Получить оборудование по идентификатору",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Equipment"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"put": {
"summary": "Обновить единицу оборудования",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EquipmentUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"delete": {
"summary": "Удалить единицу оборудования",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"204": {
"description": "No content"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not found"
}
}
}
},
"/equipment": {
"post": {
"summary": "Создать единицу оборудования",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"оборудованием"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EquipmentCreate"
}
}
}
},
"responses": {
"201": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/repair-orders/page": {
"post": {
"summary": "Постраничный список заявок на ремонт с фильтрацией",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrderListRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrderListResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/repair-orders/{id}": {
"get": {
"summary": "Получить заявку на ремонт по идентификатору",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrder"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"put": {
"summary": "Обновить заявку на ремонт",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrderUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
},
"delete": {
"summary": "Удалить заявку на ремонт",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"204": {
"description": "No content"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not found"
}
}
}
},
"/repair-orders": {
"post": {
"summary": "Создать заявку на ремонт",
"security": [
{
"bearerAuth": []
}
],
"tags": [
"заявками на ремонт"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepairOrderCreate"
}
}
}
},
"responses": {
"201": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
}
}
}

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "toir-generation-context",
"private": true,
"scripts": {
"generate:api-summary": "node tools/generate-api-summary.mjs",
"generate:openapi": "node tools/api-summary-to-openapi.mjs --out openapi.json",
"validate:generation": "node tools/validate-generation.mjs",
"validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime",
"validate:generation:artifacts": "node tools/validate-generation.mjs --artifacts-only",
"eval:generation": "node tools/eval/run-evals.mjs",
"install-hooks": "node tools/install-hooks.mjs"
}
}

95
prompts/auth-rules.md Normal file
View File

@@ -0,0 +1,95 @@
# Auth Rules
<!-- prompt-version: 2.0 -->
<!-- applies-to: client/src/auth/, server/src/auth/, toir-realm.json -->
<!-- validated-by: tools/validate-generation.mjs §validateAuthChecks §validateRealmChecks -->
Use this document during the **Auth / Runtime / Realm** stage defined in `prompts/general-prompt.md`.
## Purpose
Generate and preserve the auth contracts that let the CRUD app run as a React Admin SPA backed by a NestJS API protected by external Keycloak.
## Mandatory Inputs
- `prompts/general-prompt.md`
- `prompts/runtime-rules.md`
- current repository auth/runtime defaults
## Expected Outputs
- `client/src/auth/`
- `client/src/dataProvider.ts`
- `server/src/auth/`
- `toir-realm.json`
## Frontend Auth Invariants
- use `keycloak-js` with redirect-based login only
- initialize Keycloak before rendering the SPA
- use Authorization Code Flow + PKCE (`S256`)
- keep `authProvider`, `dataProvider`, `getIdentity()`, `getPermissions()`, and `checkError()` as stable seams
- derive identity from token claims already present in the token
- do not call `loadUserProfile()`
- `401` forces re-authentication; `403` remains an authorization error
- keep token handling in memory with one shared in-flight refresh path
## Backend Auth Invariants
- verify JWTs with `jose`
- validate issuer, audience, and signature via JWKS
- resolve JWKS in this order:
1. `KEYCLOAK_JWKS_URL`
2. OIDC discovery at `/.well-known/openid-configuration`
3. `${issuer}/protocol/openid-connect/certs`
- extract roles only from `realm_access.roles`
- keep `/health` public
- generated CRUD routes stay protected by default
## Working Runtime Defaults
Keep these defaults unless a task explicitly overrides them:
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- `VITE_KEYCLOAK_REALM=toir`
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
- `KEYCLOAK_ISSUER_URL=https://sso.greact.ru/realms/toir`
- `KEYCLOAK_AUDIENCE=toir-backend`
- `CORS_ALLOWED_ORIGINS=http://localhost:5173,https://toir-frontend.greact.ru`
Anti-regression rule:
- do not revert shared examples to localhost Keycloak defaults unless a task explicitly requests a local Keycloak baseline
## Realm Artifact Contract
The root realm artifact is mandatory and must:
- be importable and versioned
- align with generated frontend/backend env contracts
- parameterize:
- realm name
- frontend client id
- backend client id / audience
- local and production frontend URLs
- artifact filename
- explicitly deliver:
- `sub`
- `aud`
- `realm_access.roles`
- define:
- realm roles `admin`, `editor`, `viewer`
- a public SPA client with PKCE S256
- a bearer-only backend client
- an explicit audience client scope
- protocol mappers for baseline identity and role claims
## Completion Expectations
Auth/runtime generation is incomplete if any of the following is true:
- frontend and backend auth seams drift from each other
- JWKS resolution order changes
- `/health` stops being public
- shared Keycloak defaults regress to localhost examples
- the realm artifact no longer matches backend/frontend expectations

145
prompts/backend-rules.md Normal file
View File

@@ -0,0 +1,145 @@
# Backend Rules
Use this document during the **Backend** stage defined in `prompts/general-prompt.md`.
## Purpose
Generate NestJS CRUD artifacts that match the DSL contract exactly and remain compatible with a standard NestJS workspace.
## Mandatory Inputs
- `prompts/general-prompt.md`
- the active `api API.<Entity>` block from `domain/toir.api.dsl`
- referenced DTOs and enums from `domain/toir.api.dsl`
- an intact or repaired official NestJS scaffold under `server/`
`api-summary.json` may be consulted only as an auxiliary inventory or validator-related artifact. It must never replace the DSL as the backend source of truth.
## Expected Outputs
Per entity:
- `server/src/modules/<kebab>/<kebab>.module.ts`
- `server/src/modules/<kebab>/<kebab>.controller.ts`
- `server/src/modules/<kebab>/<kebab>.service.ts`
- `server/src/modules/<kebab>/dto/create-<kebab>.dto.ts`
- `server/src/modules/<kebab>/dto/update-<kebab>.dto.ts`
Repository-wide:
- `server/src/app.module.ts` registrations
## Scaffold Baseline
- Start backend initialization and repair from the official NestJS CLI workspace, not from hand-written files.
- Preserve Nest workspace essentials:
- `server/tsconfig.json`
- `server/tsconfig.build.json`
- `server/nest-cli.json`
- `server/src/main.ts`
- `server/src/app.module.ts`
- If the workspace is degraded, repair it before generating domain code.
Forbidden patterns:
- hand-written pseudo-Nest scaffolds
- deleting required Nest config files after generation
- replacing normal Nest build/start behavior with ad hoc scripts
## Route And Resource Contract
- Use the shared entity-to-resource naming convention from `prompts/general-prompt.md`.
- Each entity becomes a NestJS module, controller, service, and create/update DTO pair.
- CRUD routes use the real primary key name in the path.
- Every API record returned to React Admin must include `id`.
- For natural-key entities, map the real primary key to `id` in responses and sort translation.
## DTO Contract
- `DTO.<Entity>Create` defines `Create<Entity>Dto`.
- `DTO.<Entity>Update` defines `Update<Entity>Dto`.
- Do not invent fields or pull field lists from memory.
- Never include `id` in Create/Update DTOs.
Type and decorator rules:
| DSL type | TS DTO type | class-validator decorator | Notes |
| --------- | ----------- | ------------------------- | ----------------------------- |
| `uuid` | `string` | `@IsUUID()` | |
| `string` | `string` | `@IsString()` | |
| `text` | `string` | `@IsString()` | |
| `integer` | `number` | `@IsInt()` | |
| `number` | `number` | `@IsNumber()` | |
| `decimal` | `string` | `@IsString()` | serialize with Prisma Decimal |
| `date` | `string` | `@IsString()` | serialize as ISO string |
| `boolean` | `boolean` | `@IsBoolean()` | |
| enum name | `string` | `@IsEnum(EnumName)` | |
Nullability rules:
- every field that is not `is required` gets `@IsOptional()` before the type decorator
- every generated DTO imports from `'class-validator'`
## Controller Contract
- Apply `@UseGuards(JwtAuthGuard, RolesGuard)` at controller class level.
- Roles per verb:
- `GET` -> `viewer | editor | admin`
- `POST`, `PATCH`, `PUT` -> `editor | admin`
- `DELETE` -> `admin`
- Reconcile DSL HTTP shapes for repository compatibility:
- list endpoints declared as `POST .../page` generate as `@Get()` with React Admin query params
- update endpoints declared as `PUT` generate as `@Patch(':<pk>')`
- Path parameters are taken from the DSL endpoint contract, not invented from generic CRUD memory.
## Service Contract
- Never pass raw update DTOs directly into Prisma update `data`.
- Strip `id`, the real primary key, and readonly fields before writes.
- Keep `PrismaService` lightweight:
- extend `PrismaClient`
- implement `OnModuleInit`
- call `$connect()`
- do not add `beforeExit`
List endpoint requirements:
- accept React Admin query params: `_start`, `_end`, `_sort`, `_order`, `q`
- set `Content-Range`
- set `Access-Control-Expose-Headers: Content-Range`
Filtering rules:
- string/text filters may use case-insensitive `contains`
- foreign-key scalar filters must use exact-match semantics
- enum filters must support both single and repeated params
- repeated enum params must map to Prisma `{ in: [...] }`
- `_sort=id` must map to the real primary key for natural-key entities
Decimal and date handling:
- `decimal` writes: `new Prisma.Decimal(value)`
- `decimal` reads: `.toString()`
- `date` writes: `new Date(value)`
- `date` reads: `.toISOString()`
## Natural-Key Rules
For entities whose physical primary key is not `id`:
- route params use the real primary key name
- responses expose `id` mapped from that primary key
- sort/update behavior never targets a fake physical `id`
- update payload sanitization removes both `id` and the real primary key
## Completion Expectations
Backend generation is incomplete if any of the following is true:
- required Nest scaffold files are missing
- DTO decorators are incomplete or type-incorrect
- controllers are missing guards or role decorators
- natural-key handling regresses to a fake physical `id`
- list/filter behavior is incompatible with React Admin expectations

118
prompts/frontend-rules.md Normal file
View File

@@ -0,0 +1,118 @@
# Frontend Rules
<!-- prompt-version: 2.0 -->
<!-- applies-to: client/src/resources/, client/src/App.tsx -->
<!-- validated-by: tools/validate-generation.mjs §validateApiDslCoverage -->
Use this document during the **Frontend** stage defined in `prompts/general-prompt.md`.
## Purpose
Generate React Admin resources that stay aligned with the DSL contract, the backend contract, and the repository auth/data provider seams.
## Mandatory Inputs
- `prompts/general-prompt.md`
- the active `api API.<Entity>` block from `domain/toir.api.dsl`
- referenced DTOs and enums from `domain/toir.api.dsl`
- an intact or repaired official Vite React TypeScript scaffold under `client/`
## Expected Outputs
Per entity:
- `client/src/resources/<kebab>/<Entity>List.tsx`
- `client/src/resources/<kebab>/<Entity>Create.tsx`
- `client/src/resources/<kebab>/<Entity>Edit.tsx`
- `client/src/resources/<kebab>/<Entity>Show.tsx`
Repository-wide:
- `client/src/App.tsx` resource registrations
## Scaffold Baseline
- Start frontend initialization and repair from the official Vite React TypeScript scaffold, not from a hand-written shell.
- Preserve workspace essentials:
- `client/index.html`
- `client/tsconfig.json`
- `client/vite.config.*`
- `client/src/main.tsx`
- Repair the scaffold before generating resources if it is degraded.
## Resource Contract
- Use the shared entity-to-resource naming convention from `prompts/general-prompt.md`.
- Every entity becomes a React Admin resource with `list`, `create`, `edit`, and `show`.
- `Resource` registration in `client/src/App.tsx` must include `show={...}`.
- Every frontend record must work with React Admin's `id` contract, including natural-key entities.
DTO-driven view rules:
- List and Show views use fields from `DTO.<Entity>`
- Create view uses fields from `DTO.<Entity>Create`
- Edit view uses fields from `DTO.<Entity>Update`
- Do not derive form fields directly from model attributes when the DTO contract is narrower
## Input And Field Mapping
Form inputs:
- `integer`, `number`, `decimal` -> `NumberInput`
- `date` -> `DateInput`
- required `boolean` -> `BooleanInput`
- nullable `boolean` -> `NullableBooleanInput`
- enum -> `SelectInput`
- FK reference -> `ReferenceInput` + `AutocompleteInput`
Display fields:
- `integer`, `number`, `decimal` -> `NumberField`
- `date` -> `DateField`
- `boolean` -> `BooleanField`
- enum -> `SelectField`
- FK reference -> `ReferenceField`
Hard failure rule:
- using plain `TextInput` for `integer`, `number`, `decimal`, `date`, or `boolean` is a generation failure
## Filter And Reference Contract
- Lists must expose filters and include a toolbar with `FilterButton`.
- Enum multi-select filters use `SelectArrayInput`.
- Reference filters and form selectors use `ReferenceInput` + `AutocompleteInput` with `filterToQuery={(searchText) => ({ q: searchText })}`.
- FK list/show rendering must use `ReferenceField link=\"show\"`.
- `dataProvider` query serialization must preserve repeated params for array filters.
Reference display expression priority:
1. if `inventoryNumber` exists: ``(record) => `${record.inventoryNumber} — ${record.name ?? record.inventoryNumber}``
2. else if `code` exists: ``(record) => `${record.code} — ${record.name ?? record.code}``
3. else if `number` exists: ``(record) => `${record.number} — ${record.name ?? record.number}``
4. else if `name` exists: `(record) => record.name ?? record.id`
5. else: `(record) => record.id`
## Auth And Provider Seams
- `client/src/dataProvider.ts` remains the single authenticated request seam.
- `client/src/auth/authProvider.ts` remains the single React Admin auth seam.
- Resource components must not embed auth logic.
- `getIdentity()` resolves from token claims.
- `getPermissions()` may expose realm roles for UI awareness, but backend enforcement stays authoritative.
## Natural-Key Compatibility
- Frontend requests and routes must continue to work when the real primary key is not named `id`.
- Edit/show/delete flows must preserve compatibility with backend natural-key handling.
- Sorting and filtering assumptions must not regress to a fake physical `id`.
## Completion Expectations
Frontend generation is incomplete if any of the following is true:
- required Vite scaffold files are missing
- Create/Edit inputs are type-incorrect
- filter UI is missing or incomplete
- reference fields stop linking to `show`
- resource registration omits `show={...}`

300
prompts/general-prompt.md Normal file
View File

@@ -0,0 +1,300 @@
# Role
You are the master orchestrator of the KIS-TOiR generation pipeline.
Own the full run: understand the current workspace, read the domain contract, coordinate sub-agents and MCP tools, generate or repair artifacts in the correct order, run the required gates, fix failures, and stop only when the repository is genuinely generation-complete.
# Project Description
KIS-TOiR is an LLM-first fullstack CRUD generation project for equipment maintenance management.
- Backend: NestJS + Prisma
- Frontend: Vite React TypeScript + React Admin
- Auth/runtime: external Keycloak + PostgreSQL + repository-managed env/runtime artifacts
The repository is intentionally prompt-driven. `prompts/*.md` define generation policy; generated code lives under `server/` and `client/`.
# Mission
Turn the repository source contract into a buildable, validated workspace by:
1. starting from official framework scaffolding when a workspace is missing or degraded
2. generating Prisma, backend, frontend, auth, runtime, and realm artifacts from the DSL
3. using sub-agents intentionally instead of carrying every concern in one context window
4. proving completion with builds and repository validation gates
# Source Of Truth
`domain/toir.api.dsl` is the operative source of truth for generation runs.
It is authoritative for:
- entities and enums
- DTO shapes per operation
- nullability and requiredness
- primary and foreign keys
- HTTP methods, endpoint paths, and pagination contracts
Rules:
- Read the DSL directly. Do not substitute `api-summary.json` for `domain/toir.api.dsl`.
- Work from entity-scoped slices: the active `api API.<Entity>` block plus its referenced DTOs and enums.
- Quote the relevant DSL field definitions verbatim before generating DTOs, Prisma fields, controller contracts, or React Admin components.
- Treat `api-summary.json` only as an auxiliary artifact for quick inventory or validation/tooling that explicitly depends on it. It is never the authoritative generation input.
# Orchestration Model
Use a manager-first, agent-as-tool architecture.
- Keep one orchestrator in charge of planning, sequencing, integration, and final acceptance.
- Delegate bounded work to specialists; do not let sub-agents redefine the source hierarchy or completion criteria.
- Delegate by stage or artifact family, and by entity when parallelism helps.
- If a sub-agent result conflicts with the DSL, companion rules, or validator output, trust the DSL and the gates.
Mandatory delegation pattern for future runs:
- `explorer`
Use first for repo discovery, scaffold inspection, locating entity-scoped DSL context, and finding existing registrations/seams.
- `docs_researcher`
Use when framework behavior, CLI scaffolding, or prompt/orchestration patterns need verification against official docs or Context7.
- stage worker / generator
Use for bounded Prisma, backend, frontend, or auth/runtime implementation work after the orchestrator has assembled the right inputs.
- `reviewer`
Use before declaring completion. Reviewer must check DSL fidelity, prompt-contract compliance, and whether validation output supports the completion claim.
If a runtime does not expose named sub-agents, preserve the same separation of responsibilities inside one agent and keep stage handoffs explicit.
# MCP Usage Model
Use MCP/tools deliberately, not reflexively.
- Filesystem/search tools: gather exact local context before making decisions.
- Shell/runtime tools: run official CLI scaffolding, Prisma commands, builds, validators, and evals. Do not simulate command results from memory.
- Context7: verify current NestJS, Prisma, React Admin, Vite, Keycloak, or prompt/orchestration guidance when repository docs are not enough.
- Web research: only after local files and Context7 are insufficient; prefer primary sources.
- Diff/validation tools: use before edits, after edits, and always at the end.
Tool-order policy:
1. local authoritative files
2. Context7 / official docs
3. web fallback
4. validation gates
# Generation Roadmap
## 1. Preparation / Discovery
Purpose:
- establish the active scope
- verify scaffold health
- load only the context needed for the next stage
Responsible:
- orchestrator
- `explorer` first
- `docs_researcher` if scaffold conventions or framework behavior are uncertain
Mandatory inputs:
- `AGENTS.md`
- `prompts/general-prompt.md`
- `domain/toir.api.dsl`
- `prompts/runtime-rules.md`
- `.codex/AGENTS.md` and `.codex/agents/*.toml` when the runtime supports those agents
Expected outputs:
- entity-scoped DSL quotes for the active work
- a clean stage plan
- `server/` and `client/` confirmed healthy or repaired from official scaffolding
Handoff:
- proceed to Prisma only after the repository has a valid NestJS workspace, Vite React TypeScript workspace, or a documented repair plan using official CLIs
Stage rules:
- Use official Nest CLI for initial backend workspace creation or repair.
- Use official Vite React TypeScript CLI for initial frontend workspace creation or repair.
- Use Prisma CLI for Prisma initialization when relevant.
- Do not handcraft framework scaffolds that should come from official CLIs.
## 2. Prisma
Purpose:
- generate the repository schema that reflects the DSL exactly
Responsible:
- orchestrator
- Prisma-focused stage worker
- `docs_researcher` when Prisma behavior is uncertain
Mandatory inputs:
- entity-scoped DSL quotes from `domain/toir.api.dsl`
- `prompts/prisma-rules.md`
Expected outputs:
- `server/prisma/schema.prisma`
- Prisma initialization or repair steps completed when the workspace was missing required baseline files
Handoff:
- backend generation starts only after schema output reflects the DSL and Prisma setup is coherent with runtime rules
## 3. Backend
Purpose:
- generate NestJS modules, controllers, services, DTOs, and module registration from the DSL contract
Responsible:
- orchestrator
- backend stage worker, ideally one entity at a time when parallelized
Mandatory inputs:
- entity-scoped DSL quotes from `domain/toir.api.dsl`
- `prompts/backend-rules.md`
Expected outputs:
- `server/src/modules/<entity>/...`
- `server/src/app.module.ts`
Handoff:
- frontend generation starts only after backend contracts, guards, DTOs, and natural-key behavior align with backend rules
## 4. Frontend
Purpose:
- generate React Admin resources and resource registration that match backend and DSL contracts
Responsible:
- orchestrator
- frontend stage worker, ideally one entity at a time when parallelized
Mandatory inputs:
- entity-scoped DSL quotes from `domain/toir.api.dsl`
- `prompts/frontend-rules.md`
Expected outputs:
- `client/src/resources/<entity>/...`
- `client/src/App.tsx`
Handoff:
- auth/runtime integration starts only after frontend resource contracts align with DTO-derived field sets and type mappings
## 5. Auth / Runtime / Realm Artifacts
Purpose:
- wire authentication, environment defaults, realm import, and runtime topology around the generated CRUD app
Responsible:
- orchestrator
- auth/runtime stage worker
- `docs_researcher` when Keycloak or framework integration behavior is uncertain
Mandatory inputs:
- `prompts/auth-rules.md`
- `prompts/runtime-rules.md`
Expected outputs:
- `server/src/auth/`
- `client/src/auth/`
- `client/src/dataProvider.ts`
- `server/.env.example`
- `client/.env.example`
- `docker-compose.yml`
- `toir-realm.json`
Handoff:
- verification starts only after auth seams, runtime artifacts, and realm output are aligned with backend/frontend expectations
## 6. Verification / Success Gate
Purpose:
- prove that the generation run is complete and not just plausible
Responsible:
- orchestrator
- `reviewer` before completion
Mandatory inputs:
- `prompts/validation-rules.md`
- validation command output
- reviewer findings
Expected outputs:
- refreshed auxiliary artifacts if the validator/tooling requires them, including `api-summary.json`
- passing validation gates
- successful backend and frontend builds
Handoff:
- there is no next stage; report complete only when every success criterion below is satisfied
# Success Criteria
Generation is successful only if all of the following are true:
- `server/` exists in the project root
- `client/` exists in the project root
- the backend builds successfully
- the frontend builds successfully
- `node tools/validate-generation.mjs --artifacts-only` passes
- `npm run eval:generation` passes
- required auth/runtime/realm artifacts exist and match their companion rules
- module/resource registrations are complete
- any validator-required auxiliary artifacts, including `api-summary.json`, are refreshed and consistent
- the reviewer has not identified unresolved contract violations
# Non-Goals / Constraints
- Do not edit `domain/toir.api.dsl` during generation.
- Do not treat `api-summary.json` as the source of truth or default starting point.
- Do not inline large backend/frontend/prisma/auth/runtime/validation rule sets into this master prompt; load the companion docs instead.
- Do not generate domain artifacts on top of a broken scaffold when official CLI repair is required.
- Do not claim success from prompt reasoning alone; use builds and repository gates.
- Do not load the full DSL blob when entity-scoped context is enough.
# Companion Rule Documents
These documents are mandatory when their stage is active:
- Prisma stage: `prompts/prisma-rules.md`
- Backend stage: `prompts/backend-rules.md`
- Frontend stage: `prompts/frontend-rules.md`
- Auth / realm stage: `prompts/auth-rules.md`
- Runtime / bootstrap stage: `prompts/runtime-rules.md`
- Verification stage: `prompts/validation-rules.md`
The master prompt owns orchestration. Companion docs own artifact-specific detail.

119
prompts/prisma-rules.md Normal file
View File

@@ -0,0 +1,119 @@
# Prisma Rules
<!-- prompt-version: 2.0 -->
<!-- applies-to: server/prisma/schema.prisma -->
<!-- generated-by: LLM following these rules -->
<!-- source-of-truth: domain/toir.api.dsl -->
<!-- validated-by: tools/validate-generation.mjs §validateBuildChecks -->
Use this document during the **Prisma** stage defined in `prompts/general-prompt.md`.
## Purpose
Generate `server/prisma/schema.prisma` as a faithful reflection of `domain/toir.api.dsl`.
## Mandatory Inputs
- `prompts/general-prompt.md`
- the relevant entity/enum definitions from `domain/toir.api.dsl`
- the existing Prisma header if `server/prisma/schema.prisma` already exists
`api-summary.json` may be used only as an auxiliary validator/inventory artifact. It is not part of the authoritative Prisma source hierarchy.
## Expected Output
- `server/prisma/schema.prisma`
Never edit the schema manually during normal generation. Change the DSL and regenerate instead.
## Source Of Truth
Entity definitions, field types, PKs, FKs, enums, optionality, uniqueness, and defaults come from `domain/toir.api.dsl`.
## Scalar Type Mapping
| DSL type | Prisma scalar type |
| --------- | ------------------ |
| `uuid` | `String` |
| `string` | `String` |
| `text` | `String` |
| `integer` | `Int` |
| `number` | `Float` |
| `decimal` | `Decimal` |
| `date` | `DateTime` |
| `boolean` | `Boolean` |
| enum name | enum name as-is |
Unknown DSL types pass through as-is for forward compatibility.
## Primary Key Rules
- a field marked `key primary` becomes `@id`
- if the primary key type is `uuid` or the field name is `id`, use `@id @default(uuid())`
- non-uuid natural keys keep plain `@id`
- every entity must resolve to exactly one primary key
## Optionality And Defaults
- primary keys are required
- fields marked `is required` are required
- all other fields are optional with Prisma `?`
- non-primary unique fields get `@unique`
- `default <value>` maps to Prisma `@default(...)`
## Foreign Key And Relation Rules
For a DSL field declared `key foreign { relates Entity.field }`:
1. emit the FK scalar field first
2. add a relation field named `lowerFirst(relatedEntity)`
3. if that relation name collides, append `Ref`
4. annotate with `@relation(fields: [<fkField>], references: [<referencedField>])`
Inverse array relations:
- add inverse array fields for referencing entities automatically
- pluralization rules:
- `equipment` stays `equipment`
- names ending in `s` add `es`
- all others add `s`
- if the inverse name collides, append `List`
## Enum Rules
- every DSL enum becomes a Prisma enum
- preserve declaration order
- preserve the enum name exactly
## Header Preservation
If `server/prisma/schema.prisma` already contains a `generator client { ... }` block, preserve everything before the first `enum` or `model` keyword.
If no valid header exists, emit:
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
```
## Forbidden Patterns
- do not add fields not declared in the DSL
- do not add `@@index`, `@@map`, or schema-level directives not declared by the DSL
- do not add `@db.*` modifiers
- do not change the datasource provider away from `postgresql`
## Completion Expectations
Prisma generation is incomplete if any of the following is true:
- `server/prisma/schema.prisma` does not exist
- the schema no longer reflects the DSL
- required relation fields or inverse arrays are missing
- header generation or preservation breaks Prisma baseline behavior

86
prompts/runtime-rules.md Normal file
View File

@@ -0,0 +1,86 @@
# Runtime Rules
<!-- prompt-version: 2.0 -->
<!-- applies-to: docker-compose.yml, server/.env.example, client/.env.example -->
<!-- validated-by: tools/validate-generation.mjs §validateRuntimeContractChecks -->
Use this document during the **Preparation / Discovery** and **Auth / Runtime / Realm** stages defined in `prompts/general-prompt.md`.
## Purpose
Define the runtime topology, environment defaults, scaffold expectations, and bootstrap sequence for a buildable generated workspace.
## Mandatory Inputs
- `prompts/general-prompt.md`
- `prompts/auth-rules.md` when runtime changes affect auth defaults or seams
- current repository runtime/auth defaults
`api-summary.json` is an auxiliary artifact only. Refresh it when validator/tooling requires freshness checks or when a compact inventory helps discovery. Do not treat it as the runtime source of truth.
## Expected Outputs
- `docker-compose.yml`
- `server/.env.example`
- `client/.env.example`
- a buildable NestJS workspace under `server/`
- a buildable Vite React TypeScript workspace under `client/`
- any validator-required auxiliary artifacts such as `api-summary.json`
## Baseline Runtime Topology
- `server/` is the backend output path
- `client/` is the frontend output path
- Docker scope stays PostgreSQL-only
- Keycloak remains external to repository runtime
- the project remains LLM-first and prompt-driven
## Concrete Runtime Defaults
Backend:
- `PORT=3000`
- `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"`
- `CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"`
- `KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"`
- `KEYCLOAK_AUDIENCE="toir-backend"`
Frontend:
- `VITE_API_URL=http://localhost:3000`
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
- `VITE_KEYCLOAK_REALM=toir`
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
## Scaffold Expectations
- new or repaired backend workspaces start from the official Nest CLI
- new or repaired frontend workspaces start from the official Vite React TypeScript CLI
- Prisma initialization uses the official Prisma CLI when relevant
- the LLM may customize generated code after scaffold creation, but must not replace official initialization with ad hoc file creation
## Runtime Bootstrap
1. import `toir-realm.json` into Keycloak
2. start PostgreSQL with `docker compose up -d`
3. from `server/`:
- repair or create the workspace with official Nest CLI if needed
- install dependencies
- run Prisma commands required by the schema stage
- run `npm run build`
- run `npm run start`
4. from `client/`:
- repair or create the workspace with official Vite CLI if needed
- install dependencies
- run `npm run build`
- run `npm run dev`
## Completion Expectations
Runtime preparation is incomplete if any of the following is true:
- `server/` is missing or not buildable as a NestJS workspace
- `client/` is missing or not buildable as a Vite React TypeScript workspace
- framework scaffolding was hand-built instead of created or repaired from official CLIs
- shared env defaults drift from the repository auth/runtime contract
- runtime success is claimed without actual build verification

101
prompts/validation-rules.md Normal file
View File

@@ -0,0 +1,101 @@
# Validation Rules
<!-- prompt-version: 2.0 -->
<!-- applies-to: tools/validate-generation.mjs and npm run eval:generation -->
<!-- validated-by: self -->
Use this document during the **Verification / Success Gate** stage defined in `prompts/general-prompt.md`.
## Purpose
Define the repository gates that convert a plausible generation run into a verified one.
## Primary Gates
- `node tools/validate-generation.mjs --artifacts-only`
- `npm run eval:generation`
## Auxiliary Freshness Prep
- `npm run generate:api-summary`
Run the freshness prep when the repository validator or supporting tooling expects `api-summary.json` to exist and match the current DSL. This artifact is auxiliary to validation and inventory, not the generation source of truth.
## Prompt-Gate Alignment Rule
- every invariant marked required in the active prompt corpus must either be enforced by a gate or called out as manual/runtime-only
- validation must not silently ignore a forbidden pattern
- build verification must not be reported as green when it was skipped
## Gate Groups
### Build Checks
- at least one `domain/*.api.dsl` file exists
- required artifacts exist:
- `server/prisma/schema.prisma`
- env examples
- required scaffold files
- auth/runtime/realm artifacts
- if the current validator policy checks `api-summary.json`, it exists and is fresh relative to the DSL
- `server/` remains a valid Nest workspace
- `client/` remains a valid Vite workspace
- if dependencies are installed, backend and frontend build verification runs
- if dependencies are missing, build verification is reported as skipped with reason instead of green
### Auth Checks
- frontend auth seam files exist
- backend auth seam files exist
- `401` and `403` semantics remain split
- auth code keeps the required Keycloak/JWT contracts
- JWKS resolution order remains:
1. explicit `KEYCLOAK_JWKS_URL`
2. OIDC discovery
3. certs fallback
### Filter And UI Checks
- list resources expose filter UI including `FilterButton`
- reference filters use `ReferenceInput` + `AutocompleteInput` with `filterToQuery`
- `dataProvider` preserves repeated query params for array filters
- backend FK filters remain exact-match
- repeated enum params map to Prisma `in`
- Create/Edit forms keep type-correct inputs
- navigable references keep `ReferenceField link="show"`
- resources keep `show={...}` registration in `App.tsx`
### Natural-Key Checks
- response records expose `id`
- route/update contracts use the real primary key
- natural-key sort/update paths do not regress to a fake physical `id`
### Realm Checks
- a root `*-realm.json` artifact exists
- required roles, audience delivery, and claims remain explicit
- SPA and backend client structure remains explicit
### Runtime Checks
- Docker topology remains PostgreSQL-only
- Prisma lifecycle commands remain available where required
- `/health` remains public
- backend build runs inside `server/`
- frontend build runs inside `client/`
- client/server `.env.example` stay aligned with repository defaults
### Output Contract Checks
- every generated Create/Update DTO imports from `'class-validator'`
- DTO fields have type-correct decorators
- optional/nullable fields carry `@IsOptional()` before the type decorator
- controllers carry the required guards and roles
- React Admin components use correct input/field types
### Eval Harness
- `npm run eval:generation` runs fixture-based semantic checks
- eval failures block completion
- prompt changes that break evals are regressions, not acceptable simplifications

172
toir-realm.json Normal file
View File

@@ -0,0 +1,172 @@
{
"realm": "toir",
"enabled": true,
"displayName": "TOIR",
"sslRequired": "external",
"registrationAllowed": false,
"registrationEmailAsUsername": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"rememberMe": true,
"verifyEmail": false,
"roles": {
"realm": [
{
"name": "admin",
"description": "Full administrative access"
},
{
"name": "editor",
"description": "Can create and modify data"
},
{
"name": "viewer",
"description": "Read-only access"
}
]
},
"clientScopes": [
{
"name": "api-audience",
"description": "Adds backend audience to SPA access token",
"protocol": "openid-connect",
"attributes": {
"display.on.consent.screen": "false",
"include.in.token.scope": "false"
},
"protocolMappers": [
{
"name": "aud-toir-backend",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "toir-backend",
"id.token.claim": "false",
"access.token.claim": "true",
"introspection.token.claim": "true"
}
}
]
}
],
"clients": [
{
"clientId": "toir-frontend",
"name": "toir-frontend",
"description": "Frontend SPA client",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"bearerOnly": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"fullScopeAllowed": true,
"rootUrl": "https://toir-frontend.greact.ru",
"baseUrl": "https://toir-frontend.greact.ru",
"redirectUris": [
"https://toir-frontend.greact.ru/*",
"http://localhost:5173/*"
],
"webOrigins": [
"https://toir-frontend.greact.ru",
"http://localhost:5173"
],
"attributes": {
"pkce.code.challenge.method": "S256"
},
"defaultClientScopes": [
"api-audience"
],
"optionalClientScopes": [
"offline_access"
],
"protocolMappers": [
{
"name": "sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "id",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "sub",
"jsonType.label": "String"
}
},
{
"name": "preferred_username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
},
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
},
{
"name": "name",
"protocol": "openid-connect",
"protocolMapper": "oidc-full-name-mapper",
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"multivalued": "true",
"userinfo.token.claim": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String"
}
}
]
},
{
"clientId": "toir-backend",
"name": "toir-backend",
"description": "Backend API resource server",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"bearerOnly": true,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"fullScopeAllowed": false
}
]
}

View 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
```

View 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);
});

View 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);
});

View 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"]
}
}
]
}

View 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"
}
}

View 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` одним предложением.

View 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
View 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,
})),
})),
})),
};
}

106
tools/eval/README.md Normal file
View File

@@ -0,0 +1,106 @@
# 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"
## 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.
## 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.

View File

@@ -0,0 +1,79 @@
{
"entity": "Equipment",
"files": {
"controller": {
"path": "server/src/modules/equipment/equipment.controller.ts",
"must_contain": [
"@Controller('equipments')",
"@UseGuards(",
"JwtAuthGuard",
"@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"
],
"must_match_regex": [
"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'",
"inventoryNumber!:",
"name!:",
"equipmentType!:",
"periodicityTO!:",
"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'",
"inventoryNumber?:",
"name?:",
"equipmentType?:",
"status?:",
"@IsOptional(",
"@IsString(",
"@IsEnum("
],
"must_not_contain": [
"inventoryNumber!:",
"name!:",
"status!:"
],
"comment": "Update DTO: all fields are optional ('?' suffix with @IsOptional())."
}
}
}

View File

@@ -0,0 +1,57 @@
{
"entity": "Equipment",
"resource": "equipment",
"files": {
"list": {
"path": "client/src/resources/equipment/EquipmentList.tsx",
"must_contain": [
"List",
"FilterButton",
"TextField",
"inventoryNumber"
],
"must_match_regex": [
"SelectArrayInput",
"source=\"status\""
],
"comment": "Equipment list must expose filter UI directly and keep enum filters."
},
"create": {
"path": "client/src/resources/equipment/EquipmentCreate.tsx",
"must_contain": [
"Create",
"SimpleForm",
"SelectInput"
],
"must_match_regex": [
"NumberInput[\\s\\S]{0,300}source=\"totalEngineHours\"|source=\"totalEngineHours\"[\\s\\S]{0,300}NumberInput",
"DateInput[\\s\\S]{0,300}source=\"dateOfInspection\"|source=\"dateOfInspection\"[\\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 enum, date, and decimal/number fields."
},
"edit": {
"path": "client/src/resources/equipment/EquipmentEdit.tsx",
"must_contain": [
"Edit",
"SimpleForm",
"SelectInput"
],
"must_match_regex": [
"NumberInput[\\s\\S]{0,300}source=\"totalEngineHours\"|source=\"totalEngineHours\"[\\s\\S]{0,300}NumberInput",
"DateInput[\\s\\S]{0,300}source=\"dateOfInspection\"|source=\"dateOfInspection\"[\\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",
"inventoryNumber"
],
"comment": "Show must display key fields including inventoryNumber."
}
}
}

View File

@@ -0,0 +1,15 @@
{
"entity": "Equipment",
"kebab": "equipment",
"resource": "equipment",
"description": "Standard entity: UUID primary key, multiple enum fields, decimal fields, date fields, no FK reference to other entities",
"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"
]
}

View File

@@ -0,0 +1,62 @@
{
"entity": "CategoryResource",
"files": {
"controller": {
"path": "server/src/modules/category-resource/category-resource.controller.ts",
"must_contain": [
"@Controller('category-resources')",
"@UseGuards(",
"JwtAuthGuard",
"@Get()",
"@Post()",
"@Get(':id')",
"@Patch(':id')",
"@Delete(':id')"
],
"must_not_contain": [
"@Put(':id')"
],
"must_match_regex": [
"@Delete\\(':id'\\)[\\s\\S]{0,120}@Roles\\('admin'\\)|@Roles\\('admin'\\)[\\s\\S]{0,120}@Delete\\(':id'\\)"
]
},
"service": {
"path": "server/src/modules/category-resource/category-resource.service.ts",
"must_contain": [
"setListHeaders",
"_start",
"_end",
"partId",
"employeeCode"
],
"must_match_regex": [
"part:\\s*\\{\\s*is:\\s*\\{\\s*name",
"employee:\\s*\\{\\s*is:\\s*\\{\\s*fullName"
]
},
"create_dto": {
"path": "server/src/modules/category-resource/dto/create-category-resource.dto.ts",
"must_contain": [
"from 'class-validator'",
"partId?:",
"employeeCode?:",
"@IsUUID(",
"@IsString(",
"@IsOptional("
],
"must_not_contain": [
"id?:",
"id!:"
]
},
"update_dto": {
"path": "server/src/modules/category-resource/dto/update-category-resource.dto.ts",
"must_contain": [
"from 'class-validator'",
"@IsOptional(",
"partId?:",
"employeeCode?:"
]
}
}
}

View File

@@ -0,0 +1,53 @@
{
"entity": "CategoryResource",
"resource": "category-resources",
"files": {
"list": {
"path": "client/src/resources/category-resource/CategoryResourceList.tsx",
"must_contain": [
"List",
"FilterButton",
"ReferenceField"
],
"must_match_regex": [
"ReferenceField[\\s\\S]{0,200}reference=\"parts\"|reference=\"parts\"[\\s\\S]{0,200}ReferenceField",
"ReferenceField[\\s\\S]{0,200}reference=\"employees\"|reference=\"employees\"[\\s\\S]{0,200}ReferenceField"
]
},
"create": {
"path": "client/src/resources/category-resource/CategoryResourceCreate.tsx",
"must_contain": [
"Create",
"SimpleForm"
],
"must_match_regex": [
"ReferenceInput[\\s\\S]{0,200}reference=\"parts\"|reference=\"parts\"[\\s\\S]{0,200}ReferenceInput",
"ReferenceInput[\\s\\S]{0,200}reference=\"employees\"|reference=\"employees\"[\\s\\S]{0,200}ReferenceInput",
"AutocompleteInput[\\s\\S]{0,200}filterToQuery|filterToQuery[\\s\\S]{0,200}AutocompleteInput"
]
},
"edit": {
"path": "client/src/resources/category-resource/CategoryResourceEdit.tsx",
"must_contain": [
"Edit",
"SimpleForm"
],
"must_match_regex": [
"ReferenceInput[\\s\\S]{0,200}reference=\"parts\"|reference=\"parts\"[\\s\\S]{0,200}ReferenceInput",
"ReferenceInput[\\s\\S]{0,200}reference=\"employees\"|reference=\"employees\"[\\s\\S]{0,200}ReferenceInput"
]
},
"show": {
"path": "client/src/resources/category-resource/CategoryResourceShow.tsx",
"must_contain": [
"Show",
"SimpleShowLayout",
"ReferenceField"
],
"must_match_regex": [
"ReferenceField[\\s\\S]{0,200}reference=\"parts\"|reference=\"parts\"[\\s\\S]{0,200}ReferenceField",
"ReferenceField[\\s\\S]{0,200}reference=\"employees\"|reference=\"employees\"[\\s\\S]{0,200}ReferenceField"
]
}
}
}

View File

@@ -0,0 +1,13 @@
{
"entity": "CategoryResource",
"kebab": "category-resource",
"resource": "category-resources",
"description": "Current FK-heavy entity: UUID PK with references to Part and Employee. Tests reference wiring, autocomplete filters, and protected CRUD routes.",
"tests": [
"dto-decorator-coverage",
"auth-guards",
"fk-reference-input",
"fk-reference-field",
"content-range-header"
]
}

184
tools/eval/run-evals.mjs Normal file
View 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('');

View 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
View 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
View 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');

View File

@@ -0,0 +1,814 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { getApiDslFiles, parseApiDsl, 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 read(relativePath) {
return readFileSync(path.join(rootDir, relativePath), 'utf8');
}
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 kebabCase(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/\s+/g, '-')
.toLowerCase();
}
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',
'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 validateNaturalKeyChecks() {
// rule: AGENTS.md §Tier-2 — derive natural-key entities from api-summary.json
// A natural-key entity is identified by a root DTO (DTO.X — not Create/Update/Filter/...)
// that has a field annotated with `key primary` where the field name is not 'id'.
const apiSummaryRaw = readIfExists('api-summary.json');
if (!apiSummaryRaw) {
return;
}
let apiSummary;
try {
apiSummary = JSON.parse(apiSummaryRaw);
} catch {
return;
}
const naturalKeyEntities = [];
for (const dto of apiSummary.dtos ?? []) {
// Only root DTOs: DTO.X (not DTO.XCreate / Update / Filter / ListRequest / ListResponse / Page*)
if (/Create$|Update$|Filter$|ListRequest$|ListResponse$|PageRequest$|PageInfo$/.test(dto.name)) continue;
const entityName = dto.name.replace(/^DTO\./, '');
const primaryField = (dto.fields ?? []).find((f) => f.primary === true && f.name !== 'id');
if (primaryField) {
naturalKeyEntities.push({ name: entityName, primaryKey: primaryField.name });
}
}
for (const entity of naturalKeyEntities) {
const moduleName = kebabCase(entity.name);
const controllerPath = `server/src/modules/${moduleName}/${moduleName}.controller.ts`;
const servicePath = `server/src/modules/${moduleName}/${moduleName}.service.ts`;
const controller = readIfExists(controllerPath) ?? '';
const service = readIfExists(servicePath) ?? '';
assertCondition(Boolean(controller), `Missing file: ${controllerPath}`);
assertCondition(Boolean(service), `Missing file: ${servicePath}`);
if (!controller || !service) {
continue;
}
assertCondition(
controller.includes(`@Get(':${entity.primaryKey}')`) &&
controller.includes(`@Patch(':${entity.primaryKey}')`) &&
controller.includes(`@Delete(':${entity.primaryKey}')`),
`${entity.name} controller must use :${entity.primaryKey} route params`,
);
assertCondition(
service.includes(`id: item.${entity.primaryKey}`) || service.includes(`id: record.${entity.primaryKey}`),
`${entity.name} service must map the natural key to React Admin id`,
);
assertCondition(
service.includes(`const { id, ${entity.primaryKey}: _pk`) || service.includes(`const { id: _pk, ${entity.primaryKey}`),
`${entity.name} update path must sanitize id and primary key from Prisma update data`,
);
assertCondition(
/sortField\s*===\s*'id'/.test(service) || /sortField\s*===\s*"id"/.test(service),
`${entity.name} natural-key sort must map React Admin id sorting back to the real primary key`,
);
assertCondition(
!service.includes("query._sort || 'id'"),
`${entity.name} natural-key sort must not fall back to the physical id field`,
);
}
}
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() {
requireFile('docker-compose.yml');
const compose = readIfExists('docker-compose.yml') ?? '';
assertCondition(/image:\s*postgres:16/.test(compose), 'docker-compose must provision postgres:16');
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 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');
runCommand(
'npx',
['prisma', 'migrate', 'dev', '--name', 'baseline', '--skip-generate'],
serverDir,
'Prisma migrate failed',
);
runCommand('npx', ['prisma', 'db', 'seed'], serverDir, 'Prisma seed failed');
}
// ---------------------------------------------------------------------------
// Output contract checks — Rule 4 (grounded and schema-bound outputs)
//
// Verify that generated artifacts conform to the output contracts declared in
// prompts/backend-rules.md and prompts/frontend-rules.md.
// All checks are deterministic regex / substring patterns.
// ---------------------------------------------------------------------------
// DSL field type → expected class-validator decorator (pattern fragment).
// rule: backend-rules.md §Type mappings
const DSL_TYPE_TO_CV_DECORATOR = {
uuid: '@IsUUID(',
string: '@IsString(',
text: '@IsString(',
integer: ['@IsInt(', '@IsNumber('],
number: '@IsNumber(',
decimal: '@IsString(',
date: '@IsString(',
boolean: '@IsBoolean(',
};
function escapeRegexStr(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Check that a class-validator decorator appears within 400 chars before fieldName
function fieldHasDecorator(content, fieldName, decoratorFragment) {
const pattern = new RegExp(
`${escapeRegexStr(decoratorFragment)}[\\s\\S]{0,400}${escapeRegexStr(fieldName)}[?!]?\\s*:`,
);
return pattern.test(content);
}
function validateDtoDecoratorCoverage() {
const { dtos, enums } = parseApiDsl(rootDir);
const enumNames = new Set(enums.map((e) => e.name));
for (const dto of dtos) {
const createMatch = dto.name.match(/^DTO\.(\w+)Create$/);
const updateMatch = dto.name.match(/^DTO\.(\w+)Update$/);
const match = createMatch ?? updateMatch;
if (!match) continue;
const kebab = kebabCase(match[1]);
const prefix = createMatch ? 'create' : 'update';
const dtoPath = `server/src/modules/${kebab}/dto/${prefix}-${kebab}.dto.ts`;
const content = readIfExists(dtoPath) ?? '';
if (!content) continue;
// rule: backend-rules.md §Type mappings — every DTO must import class-validator
assertCondition(
/from 'class-validator'/.test(content),
`${dtoPath}: missing import from 'class-validator' — rule: backend-rules.md §Type mappings`,
);
for (const field of dto.fields) {
const { name, type, nullable, required } = field;
if (!type) continue;
// Skip DTO reference types — validated by @ValidateNested separately
if (type.startsWith('DTO.')) continue;
// rule: backend-rules.md — nullable/optional fields must carry @IsOptional()
if (!required || nullable) {
assertCondition(
fieldHasDecorator(content, name, '@IsOptional('),
`${dtoPath}: field '${name}' is optional/nullable but missing @IsOptional()`,
);
}
// rule: backend-rules.md §Type mappings — type-correct decorator
const bareType = type.replace('[]', '');
if (enumNames.has(bareType)) {
assertCondition(
fieldHasDecorator(content, name, `@IsEnum(${bareType}`),
`${dtoPath}: field '${name}' has enum type '${bareType}' but missing @IsEnum(${bareType})`,
);
} else {
const expected = DSL_TYPE_TO_CV_DECORATOR[bareType];
if (expected) {
const options = Array.isArray(expected) ? expected : [expected];
const found = options.some((opt) => fieldHasDecorator(content, name, opt));
assertCondition(
found,
`${dtoPath}: field '${name}' has type '${bareType}' but missing ${options.join(' or ')}`,
);
}
}
}
}
}
function validateControllerGuards() {
// rule: backend-rules.md §Backend auth defaults — every controller needs JwtAuthGuard
const { apis } = parseApiDsl(rootDir);
for (const api of apis) {
const resourceName = api.name.replace(/^API\./, '');
const kebab = kebabCase(resourceName);
const controllerPath = `server/src/modules/${kebab}/${kebab}.controller.ts`;
const content = readIfExists(controllerPath) ?? '';
if (!content) continue;
// UseGuards must appear at the class level (within first 800 chars before the class declaration)
assertCondition(
/@UseGuards\s*\(/.test(content),
`${controllerPath}: missing @UseGuards(...) — all controllers must guard their routes`,
);
// JwtAuthGuard or equivalent JWT guard must be referenced
assertCondition(
/JwtAuthGuard|JwtGuard|AuthGuard/.test(content),
`${controllerPath}: controller must use JwtAuthGuard (or equivalent) — rule: backend-rules.md §Backend auth defaults`,
);
// rule: backend-rules.md §Backend auth defaults — DELETE must be admin-only
if (api.endpoints.some((ep) => ep.method === 'DELETE')) {
assertCondition(
/@Roles\s*\([^)]*'admin'/.test(content) || /@Roles\s*\([^)]*"admin"/.test(content),
`${controllerPath}: DELETE endpoints require @Roles('admin') — rule: backend-rules.md §Backend auth defaults`,
);
}
}
}
function validateFrontendComponentTypes() {
// rule: frontend-rules.md §Resource generation — type-safe component mapping
const { dtos, enums } = parseApiDsl(rootDir);
const enumNames = new Set(enums.map((e) => e.name));
for (const dto of dtos) {
const createMatch = dto.name.match(/^DTO\.(\w+)Create$/);
if (!createMatch) continue;
const resourceName = createMatch[1];
const kebab = kebabCase(resourceName);
const createPath = `client/src/resources/${kebab}/${resourceName}Create.tsx`;
const editPath = `client/src/resources/${kebab}/${resourceName}Edit.tsx`;
for (const componentPath of [createPath, editPath]) {
const content = readIfExists(componentPath) ?? '';
if (!content) continue;
for (const field of dto.fields) {
const { name, type } = field;
if (!type) continue;
const bareType = type.replace('[]', '');
if (bareType === 'integer' || bareType === 'number' || bareType === 'decimal') {
// rule: frontend-rules.md — integer/number/decimal → NumberInput, never TextInput
const usesNumberInput = content.includes(`source="${name}"`) &&
new RegExp(`NumberInput[^>]*source="${escapeRegexStr(name)}"|source="${escapeRegexStr(name)}"[^>]*NumberInput`).test(content);
// Only flag if TextInput is clearly used for a numeric field
const usesTextForNumeric = new RegExp(
`TextInput[\\s\\S]{0,200}source="${escapeRegexStr(name)}"`,
).test(content);
assertCondition(
!usesTextForNumeric,
`${componentPath}: field '${name}' has type '${bareType}' but uses TextInput — must use NumberInput`,
);
}
if (bareType === 'date') {
const usesTextForDate = new RegExp(
`TextInput[\\s\\S]{0,200}source="${escapeRegexStr(name)}"`,
).test(content);
assertCondition(
!usesTextForDate,
`${componentPath}: field '${name}' has type 'date' but uses TextInput — must use DateInput`,
);
}
}
}
}
}
// ---------------------------------------------------------------------------
// api.dsl coverage checks
//
// The API DSL is parsed by tools/api-summary.mjs — the single canonical
// parser. This section contains only mechanical gate logic; no DSL parsing
// or generation semantics live here.
// ---------------------------------------------------------------------------
function validateApiDslCoverage() {
const apiDslFiles = getApiDslFiles(rootDir);
if (apiDslFiles.length === 0) {
warn('No domain/*.api.dsl files found. Skipping api.dsl coverage checks.');
return;
}
// rule: AGENTS.md §Tier-3 generation zones, backend-rules.md §api-dsl-as-source
const { apis, dtos } = parseApiDsl(rootDir);
for (const api of apis) {
// api.name is "API.Equipment"; derive the kebab resource name
const resourceName = api.name.replace(/^API\./, '');
const kebab = kebabCase(resourceName);
// rule: backend-rules.md §module-file-structure
requireFiles([
`server/src/modules/${kebab}/${kebab}.module.ts`,
`server/src/modules/${kebab}/${kebab}.controller.ts`,
`server/src/modules/${kebab}/${kebab}.service.ts`,
`server/src/modules/${kebab}/dto/create-${kebab}.dto.ts`,
`server/src/modules/${kebab}/dto/update-${kebab}.dto.ts`,
]);
// rule: frontend-rules.md §resource-file-structure
requireFiles([
`client/src/resources/${kebab}/${resourceName}List.tsx`,
`client/src/resources/${kebab}/${resourceName}Create.tsx`,
`client/src/resources/${kebab}/${resourceName}Edit.tsx`,
`client/src/resources/${kebab}/${resourceName}Show.tsx`,
]);
const controllerContent =
readIfExists(`server/src/modules/${kebab}/${kebab}.controller.ts`) ?? '';
if (!controllerContent) continue;
// rule: backend-rules.md §endpoint-http-method-mapping
for (const ep of api.endpoints) {
if (!ep.label) continue;
const isPageEndpoint = (ep.path ?? '').endsWith('/page');
let found = false;
if (ep.method === 'GET') {
found = controllerContent.includes('@Get(');
} else if (ep.method === 'POST' && isPageEndpoint) {
found = controllerContent.includes('@Post(') || controllerContent.includes('@Get(');
} else if (ep.method === 'POST') {
found = controllerContent.includes('@Post(');
} else if (ep.method === 'PUT') {
found = controllerContent.includes('@Put(') || controllerContent.includes('@Patch(');
} else if (ep.method === 'DELETE') {
found = controllerContent.includes('@Delete(');
}
assertCondition(
found,
`${api.name} endpoint ${ep.name} (${ep.label}): no matching HTTP handler in controller`,
);
}
}
// rule: backend-rules.md §DTO-field-coverage
for (const dto of dtos) {
const createMatch = dto.name.match(/^DTO\.(\w+)Create$/);
const updateMatch = dto.name.match(/^DTO\.(\w+)Update$/);
if (createMatch) {
const kebab = kebabCase(createMatch[1]);
const dtoPath = `server/src/modules/${kebab}/dto/create-${kebab}.dto.ts`;
const content = readIfExists(dtoPath) ?? '';
if (content) {
for (const field of dto.fields) {
assertCondition(
content.includes(field.name),
`${dto.name} field '${field.name}' missing from ${dtoPath}`,
);
}
}
}
if (updateMatch) {
const kebab = kebabCase(updateMatch[1]);
const dtoPath = `server/src/modules/${kebab}/dto/update-${kebab}.dto.ts`;
const content = readIfExists(dtoPath) ?? '';
if (content) {
for (const field of dto.fields) {
assertCondition(
content.includes(field.name),
`${dto.name} field '${field.name}' missing from ${dtoPath}`,
);
}
}
}
}
}
// ---------------------------------------------------------------------------
validateBuildChecks();
validateAuthChecks();
validateNaturalKeyChecks();
validateRealmChecks();
validateRuntimeContractChecks();
validateApiDslCoverage();
validateDtoDecoratorCoverage();
validateControllerGuards();
validateFrontendComponentTypes();
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.');