commit c89c23fd1d7460b46742d462f895c8267bbf89de Author: MaKarin Date: Fri Apr 3 20:54:37 2026 +0300 git init diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md new file mode 100644 index 0000000..1b41c84 --- /dev/null +++ b/.codex/AGENTS.md @@ -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// + client/src/resources// + 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. diff --git a/.codex/agents/docs-researcher.toml b/.codex/agents/docs-researcher.toml new file mode 100644 index 0000000..547529b --- /dev/null +++ b/.codex/agents/docs-researcher.toml @@ -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. +""" diff --git a/.codex/agents/explorer.toml b/.codex/agents/explorer.toml new file mode 100644 index 0000000..7044150 --- /dev/null +++ b/.codex/agents/explorer.toml @@ -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. +""" diff --git a/.codex/agents/generator.toml b/.codex/agents/generator.toml new file mode 100644 index 0000000..9326fc8 --- /dev/null +++ b/.codex/agents/generator.toml @@ -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// — NestJS modules, controllers, services, DTOs + client/src/resources// — 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. + 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. +""" diff --git a/.codex/agents/reviewer.toml b/.codex/agents/reviewer.toml new file mode 100644 index 0000000..9ac7229 --- /dev/null +++ b/.codex/agents/reviewer.toml @@ -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.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// — backend artifacts + client/src/resources// — 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 +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..64e8f59 --- /dev/null +++ b/.codex/config.toml @@ -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 ` +[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" diff --git a/.env.portainer.example b/.env.portainer.example new file mode 100644 index 0000000..5dda750 --- /dev/null +++ b/.env.portainer.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58f091b --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7ac05a8 --- /dev/null +++ b/AGENTS.md @@ -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//` | `domain/*.api.dsl` + `prompts/backend-rules.md` | +| `client/src/resources//` | `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.` 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= +OPENAI_BASE_URL=https://openrouter.ai/api/v1 +OPENAI_MODEL= +``` + +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.` — 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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..19a24b4 --- /dev/null +++ b/README.md @@ -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. diff --git a/api-summary.json b/api-summary.json new file mode 100644 index 0000000..e29eea6 --- /dev/null +++ b/api-summary.json @@ -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": [] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a23bc93 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/AID_EXPORT_README.md b/docs/AID_EXPORT_README.md new file mode 100644 index 0000000..288f625 --- /dev/null +++ b/docs/AID_EXPORT_README.md @@ -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://:` (например `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. diff --git a/docs/api-dsl-conventions.md b/docs/api-dsl-conventions.md new file mode 100644 index 0000000..4b29e3e --- /dev/null +++ b/docs/api-dsl-conventions.md @@ -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: `.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. { + description "Human-readable description"; + + attribute { + description "Field description"; + type ; + is required; // or: is nullable; + map .; // links to a field in domain/*.api.dsl + } +} +``` + +### DTO naming convention + +| DTO name | Purpose | +|----------|---------| +| `DTO.` | Full response shape (GET by id, list items) | +| `DTO.Create` | Create request body | +| `DTO.Update` | Update request body (partial — all fields nullable) | +| `DTO.ListRequest` | Paginated list request (filters + page) | +| `DTO.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.` or `DTO.[]`. + +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. { + description "API group description"; + + endpoint { + label "METHOD /path"; + description "Endpoint description"; + + // For endpoints with a request body: + attribute request { + type DTO.; + } + + // For endpoints with a response body: + attribute response { + type DTO.; + } + + // For path parameters: + attribute { + type ; + } + } +} +``` + +### api block naming + +`API.` (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 | +|---------------|-------|------| +| `lists` | `"POST //page"` | request: `DTO.ListRequest`, response: `DTO.ListResponse` | +| `get` | `"GET //{id}"` | path param `id`, response: `DTO.` | +| `create` | `"POST /"` | request: `DTO.Create` | +| `update` | `"PUT //{id}"` | path param `id`, request: `DTO.Update` | +| `delete` | `"DELETE //{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. diff --git a/docs/future-work.md b/docs/future-work.md new file mode 100644 index 0000000..7905272 --- /dev/null +++ b/docs/future-work.md @@ -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 `` 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. 2–3 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. 3–5 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 diff --git a/docs/generation-playbook.md b/docs/generation-playbook.md new file mode 100644 index 0000000..f7dd9ae --- /dev/null +++ b/docs/generation-playbook.md @@ -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// + prompts/frontend-rules.md ──► client/src/resources// + 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 §` — compact entity index (fast-path context anchor) +3. `domain/*.api.dsl §API.` — **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 +``` + +### Step 4 — Generate backend modules + +For each `api` block in `domain/*.api.dsl`, generate: + +1. `server/src/modules//.module.ts` +2. `server/src/modules//dto/create-.dto.ts` + — fields from the `DTO.Create` block in api.dsl +3. `server/src/modules//dto/update-.dto.ts` + — fields from the `DTO.Update` block in api.dsl +4. `server/src/modules//.service.ts` + — CRUD operations using Prisma; respect type mappings from `prompts/backend-rules.md` +5. `server/src/modules//.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//List.tsx` + — columns from `DTO.` (response shape) +2. `client/src/resources//Create.tsx` + — fields from `DTO.Create` +3. `client/src/resources//Edit.tsx` + — fields from `DTO.Update` +4. `client/src/resources//Show.tsx` + — fields from `DTO.` + +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//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 3–5). +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 4–6). + +**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 4–6. 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//*.ts` | `domain/*.api.dsl` | `prompts/backend-rules.md` | `§validateApiDslCoverage` | +| `server/src/modules//dto/create-.dto.ts` | `DTO.Create` fields | `prompts/backend-rules.md §DTO-field-coverage` | `§validateApiDslCoverage` (field names) | +| `server/src/modules//dto/update-.dto.ts` | `DTO.Update` fields | `prompts/backend-rules.md §DTO-field-coverage` | `§validateApiDslCoverage` (field names) | +| `client/src/resources//*.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= +OPENAI_BASE_URL=https://openrouter.ai/api/v1 +OPENAI_MODEL= +``` + +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. diff --git a/docs/repository-structure.md b/docs/repository-structure.md new file mode 100644 index 0000000..7016a4e --- /dev/null +++ b/docs/repository-structure.md @@ -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. diff --git a/docs/source-of-truth.md b/docs/source-of-truth.md new file mode 100644 index 0000000..5a40a29 --- /dev/null +++ b/docs/source-of-truth.md @@ -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//` | `domain/*.api.dsl` + `prompts/backend-rules.md` | +| `client/src/resources//` | `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. diff --git a/domain/dsl-spec.md b/domain/dsl-spec.md new file mode 100644 index 0000000..f5fdb2d --- /dev/null +++ b/domain/dsl-spec.md @@ -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.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.Update` in api.dsl. All fields are +typically nullable for partial update semantics. +- **API response shape** — declared as `dto DTO.` 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.Create` fields; the Edit form +uses `DTO.Update` fields; List and Show use `DTO.` fields. + diff --git a/domain/toir.api.dsl b/domain/toir.api.dsl new file mode 100644 index 0000000..c1fbf51 --- /dev/null +++ b/domain/toir.api.dsl @@ -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; + } + +} + diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..e731f10 --- /dev/null +++ b/openapi.json @@ -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" + } + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..524587b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/prompts/auth-rules.md b/prompts/auth-rules.md new file mode 100644 index 0000000..c46c006 --- /dev/null +++ b/prompts/auth-rules.md @@ -0,0 +1,95 @@ +# Auth Rules + + + + + +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 diff --git a/prompts/backend-rules.md b/prompts/backend-rules.md new file mode 100644 index 0000000..0f93531 --- /dev/null +++ b/prompts/backend-rules.md @@ -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.` 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//.module.ts` +- `server/src/modules//.controller.ts` +- `server/src/modules//.service.ts` +- `server/src/modules//dto/create-.dto.ts` +- `server/src/modules//dto/update-.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.Create` defines `CreateDto`. +- `DTO.Update` defines `UpdateDto`. +- 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(':')` +- 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 \ No newline at end of file diff --git a/prompts/frontend-rules.md b/prompts/frontend-rules.md new file mode 100644 index 0000000..af7e703 --- /dev/null +++ b/prompts/frontend-rules.md @@ -0,0 +1,118 @@ +# Frontend Rules + + + + + +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.` 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//List.tsx` +- `client/src/resources//Create.tsx` +- `client/src/resources//Edit.tsx` +- `client/src/resources//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.` +- Create view uses fields from `DTO.Create` +- Edit view uses fields from `DTO.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={...}` diff --git a/prompts/general-prompt.md b/prompts/general-prompt.md new file mode 100644 index 0000000..00617a8 --- /dev/null +++ b/prompts/general-prompt.md @@ -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.` 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//...` +- `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//...` +- `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. \ No newline at end of file diff --git a/prompts/prisma-rules.md b/prompts/prisma-rules.md new file mode 100644 index 0000000..02758ba --- /dev/null +++ b/prompts/prisma-rules.md @@ -0,0 +1,119 @@ +# Prisma Rules + + + + + + + +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 ` 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: [], references: [])` + +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 diff --git a/prompts/runtime-rules.md b/prompts/runtime-rules.md new file mode 100644 index 0000000..6c5bbf8 --- /dev/null +++ b/prompts/runtime-rules.md @@ -0,0 +1,86 @@ +# Runtime Rules + + + + + +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 diff --git a/prompts/validation-rules.md b/prompts/validation-rules.md new file mode 100644 index 0000000..a20db15 --- /dev/null +++ b/prompts/validation-rules.md @@ -0,0 +1,101 @@ +# Validation Rules + + + + + +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 diff --git a/toir-realm.json b/toir-realm.json new file mode 100644 index 0000000..bae772d --- /dev/null +++ b/toir-realm.json @@ -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 + } + ] +} diff --git a/tools/api-format-to-openapi/README.md b/tools/api-format-to-openapi/README.md new file mode 100644 index 0000000..5ce3a31 --- /dev/null +++ b/tools/api-format-to-openapi/README.md @@ -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 +``` diff --git a/tools/api-format-to-openapi/convert.mjs b/tools/api-format-to-openapi/convert.mjs new file mode 100644 index 0000000..855ade0 --- /dev/null +++ b/tools/api-format-to-openapi/convert.mjs @@ -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 --out [--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); +}); diff --git a/tools/api-format-to-openapi/demo-steps.mjs b/tools/api-format-to-openapi/demo-steps.mjs new file mode 100644 index 0000000..38865fe --- /dev/null +++ b/tools/api-format-to-openapi/demo-steps.mjs @@ -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); +}); diff --git a/tools/api-format-to-openapi/examples/api-format.example.json b/tools/api-format-to-openapi/examples/api-format.example.json new file mode 100644 index 0000000..056882c --- /dev/null +++ b/tools/api-format-to-openapi/examples/api-format.example.json @@ -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"] + } + } + ] +} diff --git a/tools/api-format-to-openapi/package.json b/tools/api-format-to-openapi/package.json new file mode 100644 index 0000000..e457ff0 --- /dev/null +++ b/tools/api-format-to-openapi/package.json @@ -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" + } +} diff --git a/tools/api-format-to-openapi/prompts/llm-system.md b/tools/api-format-to-openapi/prompts/llm-system.md new file mode 100644 index 0000000..4b17758 --- /dev/null +++ b/tools/api-format-to-openapi/prompts/llm-system.md @@ -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` одним предложением. diff --git a/tools/api-summary-to-openapi.mjs b/tools/api-summary-to-openapi.mjs new file mode 100644 index 0000000..278e2f8 --- /dev/null +++ b/tools/api-summary-to-openapi.mjs @@ -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)}`); diff --git a/tools/api-summary.mjs b/tools/api-summary.mjs new file mode 100644 index 0000000..b5e2279 --- /dev/null +++ b/tools/api-summary.mjs @@ -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 { description?; value { label?; } } +// dto DTO. { description?; attribute { ... } } +// api API. { description?; endpoint { ... } } +// +// DTO attribute modifiers (any order inside the attribute block): +// 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 { 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, + })), + })), + })), + }; +} diff --git a/tools/eval/README.md b/tools/eval/README.md new file mode 100644 index 0000000..5f4f318 --- /dev/null +++ b/tools/eval/README.md @@ -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//`: + +``` +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//meta.json` +2. Create `tools/eval/fixtures//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//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. diff --git a/tools/eval/fixtures/equipment/backend.assertions.json b/tools/eval/fixtures/equipment/backend.assertions.json new file mode 100644 index 0000000..b54bb7e --- /dev/null +++ b/tools/eval/fixtures/equipment/backend.assertions.json @@ -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())." + } + } +} diff --git a/tools/eval/fixtures/equipment/frontend.assertions.json b/tools/eval/fixtures/equipment/frontend.assertions.json new file mode 100644 index 0000000..0243086 --- /dev/null +++ b/tools/eval/fixtures/equipment/frontend.assertions.json @@ -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." + } + } +} diff --git a/tools/eval/fixtures/equipment/meta.json b/tools/eval/fixtures/equipment/meta.json new file mode 100644 index 0000000..82285b9 --- /dev/null +++ b/tools/eval/fixtures/equipment/meta.json @@ -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" + ] +} diff --git a/tools/eval/fixtures/repair-order/backend.assertions.json b/tools/eval/fixtures/repair-order/backend.assertions.json new file mode 100644 index 0000000..7d65d94 --- /dev/null +++ b/tools/eval/fixtures/repair-order/backend.assertions.json @@ -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?:" + ] + } + } +} diff --git a/tools/eval/fixtures/repair-order/frontend.assertions.json b/tools/eval/fixtures/repair-order/frontend.assertions.json new file mode 100644 index 0000000..0880db1 --- /dev/null +++ b/tools/eval/fixtures/repair-order/frontend.assertions.json @@ -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" + ] + } + } +} diff --git a/tools/eval/fixtures/repair-order/meta.json b/tools/eval/fixtures/repair-order/meta.json new file mode 100644 index 0000000..9b36218 --- /dev/null +++ b/tools/eval/fixtures/repair-order/meta.json @@ -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" + ] +} diff --git a/tools/eval/run-evals.mjs b/tools/eval/run-evals.mjs new file mode 100644 index 0000000..6aadd7c --- /dev/null +++ b/tools/eval/run-evals.mjs @@ -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//*.assertions.json'); + console.log(''); + process.exit(1); +} + +console.log(''); +console.log('All evals passed.'); +console.log(''); diff --git a/tools/generate-api-summary.mjs b/tools/generate-api-summary.mjs new file mode 100644 index 0000000..5cbfc7f --- /dev/null +++ b/tools/generate-api-summary.mjs @@ -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)}`); diff --git a/tools/hooks/pre-commit b/tools/hooks/pre-commit new file mode 100644 index 0000000..a0a36a6 --- /dev/null +++ b/tools/hooks/pre-commit @@ -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 diff --git a/tools/install-hooks.mjs b/tools/install-hooks.mjs new file mode 100644 index 0000000..227319d --- /dev/null +++ b/tools/install-hooks.mjs @@ -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'); diff --git a/tools/validate-generation.mjs b/tools/validate-generation.mjs new file mode 100644 index 0000000..23f2424 --- /dev/null +++ b/tools/validate-generation.mjs @@ -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.');