git init
This commit is contained in:
108
.codex/AGENTS.md
Normal file
108
.codex/AGENTS.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Codex CLI — KIS-TOiR workspace supplement
|
||||||
|
|
||||||
|
This file supplements the repository root `AGENTS.md` with Codex-specific
|
||||||
|
operational notes. The root `AGENTS.md` is the authoritative contract —
|
||||||
|
if anything here contradicts root, root wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent role summary
|
||||||
|
|
||||||
|
| Role | Config file | Sandbox | Write boundary |
|
||||||
|
|------|-------------|---------|----------------|
|
||||||
|
| `generator` | `agents/generator.toml` | workspace-write | Tier 3 generation zones |
|
||||||
|
| `explorer` | `agents/explorer.toml` | read-only | None |
|
||||||
|
| `reviewer` | `agents/reviewer.toml` | read-only | Proposes patches only |
|
||||||
|
| `docs_researcher` | `agents/docs-researcher.toml` | read-only | None |
|
||||||
|
|
||||||
|
Use `/agent generator` for implementation work. Use `/agent explorer` first for
|
||||||
|
discovery, `/agent docs_researcher` when framework or prompt patterns need
|
||||||
|
verification, and `/agent reviewer` before claiming a generation run is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mutation boundary map
|
||||||
|
|
||||||
|
```
|
||||||
|
Tier 1 — Source of truth (NEVER written by any agent)
|
||||||
|
domain/*.api.dsl — single source of truth for all generation
|
||||||
|
prompts/*.md — generation spec / rules
|
||||||
|
AGENTS.md — agent operating rules
|
||||||
|
.codex/AGENTS.md (this file) — Codex-specific supplement
|
||||||
|
|
||||||
|
Tier 2 — Deterministic derivatives (written only by npm scripts, not by agents)
|
||||||
|
api-summary.json ← npm run generate:api-summary
|
||||||
|
openapi.json ← npm run generate:openapi (auxiliary)
|
||||||
|
|
||||||
|
Tier 3 — LLM-generated artifacts (written ONLY by generator agent)
|
||||||
|
server/src/modules/<entity>/
|
||||||
|
client/src/resources/<entity>/
|
||||||
|
server/src/app.module.ts
|
||||||
|
client/src/App.tsx
|
||||||
|
server/prisma/schema.prisma ← LLM-generated per prompts/prisma-rules.md
|
||||||
|
server/src/auth/
|
||||||
|
client/src/auth/
|
||||||
|
client/src/dataProvider.ts
|
||||||
|
toir-realm.json
|
||||||
|
docker-compose.yml
|
||||||
|
server/.env.example
|
||||||
|
client/.env.example
|
||||||
|
|
||||||
|
Tier 4 — Handwritten / framework-managed support files
|
||||||
|
framework scaffold and other manual support files outside prompt-governed outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard generation invocation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Read AGENTS.md + prompts/general-prompt.md
|
||||||
|
# 2. Read the entity-scoped DSL block from domain/toir.api.dsl
|
||||||
|
# 3. Load only the stage-specific companion rules you need
|
||||||
|
# 4. Run generation or repair with the appropriate agent
|
||||||
|
# 5. Refresh api-summary.json only if validator/tooling expects the auxiliary freshness artifact
|
||||||
|
# 6. Verify (both stages must pass)
|
||||||
|
node tools/validate-generation.mjs --artifacts-only
|
||||||
|
npm run eval:generation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP servers (project-local)
|
||||||
|
|
||||||
|
Defined in `.codex/config.toml`:
|
||||||
|
|
||||||
|
- **github** — repository access
|
||||||
|
- **context7** — library documentation lookup (use for framework questions)
|
||||||
|
- **exa** — web search
|
||||||
|
- **memory** — persistent cross-session context
|
||||||
|
- **playwright** — browser automation for smoke tests
|
||||||
|
- **sequential-thinking** — structured multi-step reasoning
|
||||||
|
|
||||||
|
Add heavier or credential-backed servers in `~/.codex/config.toml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation gate
|
||||||
|
|
||||||
|
Run before every commit and after every generation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stage 1 — structural gate
|
||||||
|
node tools/validate-generation.mjs --artifacts-only
|
||||||
|
|
||||||
|
# Stage 2 — eval harness
|
||||||
|
npm run eval:generation
|
||||||
|
```
|
||||||
|
|
||||||
|
The pre-commit hook (`tools/hooks/pre-commit`) runs both stages automatically
|
||||||
|
after `npm run install-hooks`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Never commit secrets. Use environment variables from `.env.example` templates.
|
||||||
|
- Run `npm audit` when adding new dependencies to `server/` or `client/`.
|
||||||
|
- Auth contracts live in `prompts/auth-rules.md`. Do not deviate from them.
|
||||||
10
.codex/agents/docs-researcher.toml
Normal file
10
.codex/agents/docs-researcher.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
model = "anthropic/claude-sonnet-4.5"
|
||||||
|
model_reasoning_effort = "low"
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
|
||||||
|
developer_instructions = """
|
||||||
|
Verify APIs, framework behavior, and release-note claims against primary documentation before changes land.
|
||||||
|
Cite the exact docs or file paths that support each claim.
|
||||||
|
Do not invent undocumented behavior.
|
||||||
|
Use Context7 and official docs first when the current environment exposes them.
|
||||||
|
"""
|
||||||
23
.codex/agents/explorer.toml
Normal file
23
.codex/agents/explorer.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
model = "anthropic/claude-haiku-4.5"
|
||||||
|
model_reasoning_effort = "medium"
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
|
||||||
|
developer_instructions = """
|
||||||
|
Stay in exploration mode. Read files freely; write nothing.
|
||||||
|
|
||||||
|
Trace the real execution path, cite files and symbols, and avoid proposing
|
||||||
|
fixes unless the parent agent asks for them.
|
||||||
|
Prefer targeted search and file reads over broad scans.
|
||||||
|
|
||||||
|
KIS-TOiR source-of-truth tier reference (read-only for this agent):
|
||||||
|
Tier 1: domain/*.api.dsl, prompts/*.md, AGENTS.md
|
||||||
|
Tier 2: api-summary.json (deterministic auxiliary derivative; never authoritative)
|
||||||
|
Tier 3: server/src/modules/, client/src/resources/, server/src/app.module.ts,
|
||||||
|
client/src/App.tsx, server/prisma/schema.prisma, server/src/auth/,
|
||||||
|
client/src/auth/, client/src/dataProvider.ts, toir-realm.json,
|
||||||
|
docker-compose.yml, server/.env.example, client/.env.example
|
||||||
|
Tier 4: framework scaffold and other handwritten support files
|
||||||
|
|
||||||
|
When asked about generation output, always trace it back to its Tier 1 DSL source
|
||||||
|
and do not recommend api-summary.json as the primary input when the DSL is available.
|
||||||
|
"""
|
||||||
51
.codex/agents/generator.toml
Normal file
51
.codex/agents/generator.toml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
model = "anthropic/claude-opus-4.6"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
approval_policy = "on-request"
|
||||||
|
|
||||||
|
developer_instructions = """
|
||||||
|
You are the LLM generation agent for KIS-TOiR.
|
||||||
|
|
||||||
|
PERMITTED write zones (Tier 3 — LLM-generated artifacts):
|
||||||
|
server/src/modules/<entity>/ — NestJS modules, controllers, services, DTOs
|
||||||
|
client/src/resources/<entity>/ — React Admin List/Create/Edit/Show
|
||||||
|
server/src/app.module.ts — module registration section only
|
||||||
|
client/src/App.tsx — resource registration section only
|
||||||
|
server/prisma/schema.prisma — LLM-generated per prompts/prisma-rules.md
|
||||||
|
server/src/auth/ — auth artifacts per prompts/auth-rules.md
|
||||||
|
client/src/auth/ — auth artifacts per prompts/auth-rules.md
|
||||||
|
client/src/dataProvider.ts — authenticated data provider seam per prompts/auth-rules.md
|
||||||
|
toir-realm.json — realm artifact per prompts/auth-rules.md
|
||||||
|
docker-compose.yml — runtime artifact per prompts/runtime-rules.md
|
||||||
|
server/.env.example — runtime defaults per prompts/runtime-rules.md
|
||||||
|
client/.env.example — runtime defaults per prompts/runtime-rules.md
|
||||||
|
|
||||||
|
FORBIDDEN write zones — never modify these files:
|
||||||
|
domain/*.api.dsl — source of truth (Tier 1)
|
||||||
|
prompts/*.md — generation spec (Tier 1)
|
||||||
|
AGENTS.md — workflow contract (Tier 1)
|
||||||
|
api-summary.json — deterministic derivative (Tier 2)
|
||||||
|
tools/ — deterministic tooling, not generated artifacts
|
||||||
|
|
||||||
|
CONTEXT BUDGET (mandatory):
|
||||||
|
1. Read prompts/general-prompt.md first.
|
||||||
|
2. Read ONLY the entity-scoped api.dsl block (api API.<EntityName> + its DTOs + enums)
|
||||||
|
from domain/toir.api.dsl. Do NOT inject the full api.dsl as a blob.
|
||||||
|
3. Read ONLY the relevant companion rule file for the active stage.
|
||||||
|
4. Before generating any DTO or component, quote the relevant DSL field definitions
|
||||||
|
verbatim, then generate from those quotes. This prevents training-data contamination.
|
||||||
|
5. Use api-summary.json only as an auxiliary inventory or validator-related artifact,
|
||||||
|
never as the source of truth or default starting point.
|
||||||
|
|
||||||
|
GENERATION WORKFLOW:
|
||||||
|
1. Read prompts/general-prompt.md.
|
||||||
|
2. Read the entity-scoped block from domain/toir.api.dsl.
|
||||||
|
3. Read the relevant stage rule docs.
|
||||||
|
4. Generate or update Tier 3 artifacts.
|
||||||
|
5. Refresh api-summary.json only if the validator/tooling requires it.
|
||||||
|
6. Run: node tools/validate-generation.mjs --artifacts-only
|
||||||
|
7. Run: npm run eval:generation
|
||||||
|
8. Fix all failures before reporting complete.
|
||||||
|
|
||||||
|
NEVER report generation complete if either validation gate fails.
|
||||||
|
"""
|
||||||
32
.codex/agents/reviewer.toml
Normal file
32
.codex/agents/reviewer.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
model = "anthropic/claude-sonnet-4.5"
|
||||||
|
model_reasoning_effort = "medium"
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
|
||||||
|
developer_instructions = """
|
||||||
|
Review mode. You may propose changes as text patches but must not write files directly.
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- Correctness: does generated code match the api.dsl and prompt contracts?
|
||||||
|
- Security: auth guard placement, CORS, env variable handling.
|
||||||
|
- Regression: do both verification gates pass?
|
||||||
|
node tools/validate-generation.mjs --artifacts-only
|
||||||
|
npm run eval:generation
|
||||||
|
- DSL fidelity: do generated DTOs contain all fields declared in DTO.<Entity>Create/Update?
|
||||||
|
- Decorator coverage: does each DTO field have the correct class-validator decorator?
|
||||||
|
- Frontend type correctness: does each field use the correct React Admin component?
|
||||||
|
- Prompt-architecture consistency: if prompts/configs changed, is domain/toir.api.dsl still clearly authoritative and api-summary.json still clearly auxiliary?
|
||||||
|
|
||||||
|
KIS-TOiR mutation boundary (reviewer must not write to these zones):
|
||||||
|
FORBIDDEN writes: domain/*.api.dsl, prompts/*.md, AGENTS.md,
|
||||||
|
api-summary.json, tools/, server/prisma/schema.prisma
|
||||||
|
|
||||||
|
ALLOWED proposal targets (propose patches, not direct writes):
|
||||||
|
server/src/modules/<entity>/ — backend artifacts
|
||||||
|
client/src/resources/<entity>/ — frontend artifacts
|
||||||
|
server/src/app.module.ts, client/src/App.tsx — registrations
|
||||||
|
server/src/auth/, client/src/auth/ — auth artifacts
|
||||||
|
client/src/dataProvider.ts — authenticated data provider seam
|
||||||
|
toir-realm.json, docker-compose.yml — runtime/realm artifacts
|
||||||
|
server/.env.example, client/.env.example — runtime defaults
|
||||||
|
docs/ — documentation updates
|
||||||
|
"""
|
||||||
141
.codex/config.toml
Normal file
141
.codex/config.toml
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#:schema https://developers.openai.com/codex/config-schema.json
|
||||||
|
|
||||||
|
# Everything Claude Code (ECC) — Codex Reference Configuration
|
||||||
|
#
|
||||||
|
# Copy this file to ~/.codex/config.toml for global defaults, or keep it in
|
||||||
|
# the project root as .codex/config.toml for project-local settings.
|
||||||
|
#
|
||||||
|
# Official docs:
|
||||||
|
# - https://developers.openai.com/codex/config-reference
|
||||||
|
# - https://developers.openai.com/codex/multi-agent
|
||||||
|
|
||||||
|
# Model selection
|
||||||
|
# This project intentionally pins a project-local provider + model so the same
|
||||||
|
# orchestration, MCP servers, and sub-agents run through OpenRouter + Claude.
|
||||||
|
# Paste your OpenRouter token directly below if you do not want to use env vars.
|
||||||
|
model_provider = "openrouter"
|
||||||
|
model = "anthropic/claude-sonnet-4.5"
|
||||||
|
|
||||||
|
[model_providers.openrouter]
|
||||||
|
name = "OpenRouter"
|
||||||
|
base_url = "https://openrouter.ai/api/v1"
|
||||||
|
# Paste your token here:
|
||||||
|
# experimental_bearer_token = "or-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
experimental_bearer_token = "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"
|
||||||
|
wire_api = "responses"
|
||||||
|
requires_openai_auth = false
|
||||||
|
|
||||||
|
[model_providers.openrouter.http_headers]
|
||||||
|
# Optional but recommended by OpenRouter for app attribution.
|
||||||
|
HTTP-Referer = "https://local.kis-toir"
|
||||||
|
X-Title = "KIS-TOiR"
|
||||||
|
|
||||||
|
# Top-level runtime settings (current Codex schema)
|
||||||
|
approval_policy = "on-request"
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
web_search = "live"
|
||||||
|
|
||||||
|
# External notifications receive a JSON payload on stdin.
|
||||||
|
# macOS example (uncomment on Mac if `terminal-notifier` is installed):
|
||||||
|
# notify = [
|
||||||
|
# "terminal-notifier",
|
||||||
|
# "-title", "Codex KIS",
|
||||||
|
# "-message", "Task completed!",
|
||||||
|
# "-sound", "default",
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# Persistent instructions are appended to every prompt (additive, unlike
|
||||||
|
# model_instructions_file which replaces AGENTS.md).
|
||||||
|
persistent_instructions = "Follow project AGENTS.md guidelines. Use available MCP servers when they can help."
|
||||||
|
|
||||||
|
# model_instructions_file replaces built-in instructions instead of AGENTS.md,
|
||||||
|
# so leave it unset unless you intentionally want a single override file.
|
||||||
|
# model_instructions_file = "/absolute/path/to/instructions.md"
|
||||||
|
|
||||||
|
# MCP servers
|
||||||
|
# Keep the default project set lean. API-backed servers inherit credentials from
|
||||||
|
# the launching environment or can be supplied by a user-level ~/.codex/config.toml.
|
||||||
|
[mcp_servers.github]
|
||||||
|
command = "npx"
|
||||||
|
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||||
|
startup_timeout_sec = 30
|
||||||
|
|
||||||
|
[mcp_servers.context7]
|
||||||
|
command = "npx"
|
||||||
|
# Canonical Codex section name is `context7`; the package itself remains
|
||||||
|
# `@upstash/context7-mcp`.
|
||||||
|
args = ["-y", "@upstash/context7-mcp@latest"]
|
||||||
|
startup_timeout_sec = 30
|
||||||
|
|
||||||
|
[mcp_servers.exa]
|
||||||
|
url = "https://mcp.exa.ai/mcp"
|
||||||
|
|
||||||
|
[mcp_servers.memory]
|
||||||
|
command = "npx"
|
||||||
|
args = ["-y", "@modelcontextprotocol/server-memory"]
|
||||||
|
startup_timeout_sec = 30
|
||||||
|
|
||||||
|
[mcp_servers.playwright]
|
||||||
|
command = "npx"
|
||||||
|
args = ["-y", "@playwright/mcp@latest", "--extension"]
|
||||||
|
startup_timeout_sec = 30
|
||||||
|
|
||||||
|
[mcp_servers.sequential-thinking]
|
||||||
|
command = "npx"
|
||||||
|
args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||||
|
startup_timeout_sec = 30
|
||||||
|
|
||||||
|
# Additional MCP servers (uncomment as needed):
|
||||||
|
# [mcp_servers.supabase]
|
||||||
|
# command = "npx"
|
||||||
|
# args = ["-y", "supabase-mcp-server@latest", "--read-only"]
|
||||||
|
#
|
||||||
|
# [mcp_servers.firecrawl]
|
||||||
|
# command = "npx"
|
||||||
|
# args = ["-y", "firecrawl-mcp"]
|
||||||
|
#
|
||||||
|
# [mcp_servers.fal-ai]
|
||||||
|
# command = "npx"
|
||||||
|
# args = ["-y", "fal-ai-mcp-server"]
|
||||||
|
#
|
||||||
|
# [mcp_servers.cloudflare]
|
||||||
|
# command = "npx"
|
||||||
|
# args = ["-y", "@cloudflare/mcp-server-cloudflare"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Codex multi-agent collaboration is stable and on by default in current builds.
|
||||||
|
# Keep the explicit toggle here so the repo documents its expectation clearly.
|
||||||
|
multi_agent = true
|
||||||
|
|
||||||
|
# Profiles — switch with `codex -p <name>`
|
||||||
|
[profiles.strict]
|
||||||
|
approval_policy = "on-request"
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
web_search = "cached"
|
||||||
|
|
||||||
|
[profiles.yolo]
|
||||||
|
approval_policy = "never"
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
web_search = "live"
|
||||||
|
|
||||||
|
[agents]
|
||||||
|
# Multi-agent role limits and local role definitions.
|
||||||
|
# These map to `.codex/agents/*.toml` and mirror the repo's explorer/reviewer/docs workflow.
|
||||||
|
max_threads = 6
|
||||||
|
max_depth = 1
|
||||||
|
|
||||||
|
[agents.explorer]
|
||||||
|
description = "Read-only codebase explorer for gathering evidence before changes are proposed."
|
||||||
|
config_file = "agents/explorer.toml"
|
||||||
|
|
||||||
|
[agents.reviewer]
|
||||||
|
description = "PR reviewer focused on correctness, security, and DSL fidelity. Proposes patches; writes nothing directly."
|
||||||
|
config_file = "agents/reviewer.toml"
|
||||||
|
|
||||||
|
[agents.docs_researcher]
|
||||||
|
description = "Documentation specialist that verifies APIs, framework behavior, and release notes."
|
||||||
|
config_file = "agents/docs-researcher.toml"
|
||||||
|
|
||||||
|
[agents.generator]
|
||||||
|
description = "LLM generation agent: writes server/src/modules/, client/src/resources/, app.module.ts, App.tsx only. Never touches DSL, prompts, or deterministic tooling."
|
||||||
|
config_file = "agents/generator.toml"
|
||||||
15
.env.portainer.example
Normal file
15
.env.portainer.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_DB=toir
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS=https://toir.example.ru
|
||||||
|
|
||||||
|
KEYCLOAK_ISSUER_URL=https://sso.example.ru/realms/toir
|
||||||
|
KEYCLOAK_AUDIENCE=toir-backend
|
||||||
|
KEYCLOAK_JWKS_URL=
|
||||||
|
|
||||||
|
VITE_API_URL=/api
|
||||||
|
VITE_KEYCLOAK_URL=https://sso.example.ru
|
||||||
|
VITE_KEYCLOAK_REALM=toir
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID=toir-frontend
|
||||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Dependencies
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
**/dist/
|
||||||
|
**/dist-ssr/
|
||||||
|
**/coverage/
|
||||||
|
**/.cache/
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
# Generated OpenAPI (local runs; commit only if you want to publish the spec)
|
||||||
|
openapi.generated.json
|
||||||
|
openapi.llm.json
|
||||||
|
tools/api-format-to-openapi/demo-output/
|
||||||
|
|
||||||
|
.cursor/
|
||||||
216
AGENTS.md
Normal file
216
AGENTS.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# KIS-TOiR — Agent Operating Rules
|
||||||
|
|
||||||
|
Read this file at the start of every session before reading any other file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this repository is
|
||||||
|
|
||||||
|
KIS-TOiR is a fullstack CRUD application (NestJS backend + React Admin frontend)
|
||||||
|
for equipment maintenance management (Техническое обслуживание и ремонт).
|
||||||
|
|
||||||
|
Generation is driven by a single authoritative source file:
|
||||||
|
|
||||||
|
- `domain/toir.api.dsl` — the complete API contract: enums, DTOs, endpoints, HTTP methods, pagination
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source-of-truth hierarchy
|
||||||
|
|
||||||
|
### Tier 1 — authoritative (hand-authored; never overwritten by generation)
|
||||||
|
|
||||||
|
| File | Authoritative for |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `domain/*.api.dsl` | Enums, DTO shapes per operation, nullability, HTTP verb+path per endpoint, endpoint names, pagination contracts. Single source of truth. Drives: NestJS modules + React Admin resources + Prisma schema. |
|
||||||
|
| `prompts/*.md` | Auth rules, runtime rules, framework scaffold rules, Prisma rules, validation rules, generation policy, naming conventions. |
|
||||||
|
| `AGENTS.md` (this file) | Agent workflow, mutation boundaries, verification contract. |
|
||||||
|
|
||||||
|
### Tier 2 — deterministic derivative (generated by script; never edited manually)
|
||||||
|
|
||||||
|
| File | Generated from | Command |
|
||||||
|
| ------------------ | ------------------ | ------------------------------ |
|
||||||
|
| `api-summary.json` | `domain/*.api.dsl` | `npm run generate:api-summary` |
|
||||||
|
|
||||||
|
### Tier 3 — LLM-generated artifacts (never edit manually after generation)
|
||||||
|
|
||||||
|
| Zone | Generated from |
|
||||||
|
| -------------------------------- | ------------------------------------------------------ |
|
||||||
|
| `server/src/modules/<entity>/` | `domain/*.api.dsl` + `prompts/backend-rules.md` |
|
||||||
|
| `client/src/resources/<entity>/` | `domain/*.api.dsl` + `prompts/frontend-rules.md` |
|
||||||
|
| `server/src/app.module.ts` | Module registrations derived from api.dsl api blocks |
|
||||||
|
| `client/src/App.tsx` | Resource registrations derived from api.dsl api blocks |
|
||||||
|
| `server/prisma/schema.prisma` | `domain/*.api.dsl` + `prompts/prisma-rules.md` |
|
||||||
|
| `server/src/auth/` | `prompts/auth-rules.md` |
|
||||||
|
| `client/src/auth/` | `prompts/auth-rules.md` |
|
||||||
|
| `client/src/dataProvider.ts` | `prompts/auth-rules.md` |
|
||||||
|
| `toir-realm.json` | `prompts/auth-rules.md` |
|
||||||
|
| `docker-compose.yml` | `prompts/runtime-rules.md` |
|
||||||
|
| `server/.env.example` | `prompts/runtime-rules.md` |
|
||||||
|
| `client/.env.example` | `prompts/runtime-rules.md` |
|
||||||
|
|
||||||
|
### Tier 4 — handwritten / framework-managed support files
|
||||||
|
|
||||||
|
- Framework scaffold: `server/nest-cli.json`, `server/tsconfig*.json`, `client/vite.config.*`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forbidden mutations during any generation run
|
||||||
|
|
||||||
|
**NEVER write to `*.dsl` files.**
|
||||||
|
They are read-only inputs. To change the API contract or domain model, edit `domain/*.api.dsl`
|
||||||
|
as a separate explicit task.
|
||||||
|
|
||||||
|
**NEVER manually edit files under `server/src/modules/` or `client/src/resources/`.**
|
||||||
|
To change generated code: update `domain/*.api.dsl` and regenerate.
|
||||||
|
|
||||||
|
**NEVER edit `server/prisma/schema.prisma` directly.**
|
||||||
|
It is LLM-generated from `domain/*.api.dsl` following `prompts/prisma-rules.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generation workflow (required sequence)
|
||||||
|
|
||||||
|
1. Read `AGENTS.md` and `prompts/general-prompt.md`.
|
||||||
|
2. Read the active `api API.<EntityName>` block + its DTOs + its enums from `domain/toir.api.dsl` (entity-scoped — do not inject the full DSL as a blob).
|
||||||
|
3. Load the companion rule files required for the active stage:
|
||||||
|
- `prompts/prisma-rules.md`
|
||||||
|
- `prompts/backend-rules.md`
|
||||||
|
- `prompts/frontend-rules.md`
|
||||||
|
- `prompts/auth-rules.md`
|
||||||
|
- `prompts/runtime-rules.md`
|
||||||
|
- `prompts/validation-rules.md`
|
||||||
|
4. Verify or repair framework scaffolds before domain generation:
|
||||||
|
- official Nest CLI for backend workspace creation/repair
|
||||||
|
- official Vite React TypeScript CLI for frontend workspace creation/repair
|
||||||
|
- official Prisma CLI when Prisma initialization or repair is required
|
||||||
|
5. Generate `server/prisma/schema.prisma` per `prompts/prisma-rules.md`.
|
||||||
|
6. Generate backend modules and registrations per `prompts/backend-rules.md`.
|
||||||
|
7. Generate frontend resources and registrations per `prompts/frontend-rules.md`.
|
||||||
|
8. Generate auth/runtime/realm artifacts per `prompts/auth-rules.md` and `prompts/runtime-rules.md`.
|
||||||
|
9. Refresh `api-summary.json` only when validation/tooling requires the auxiliary freshness artifact: `npm run generate:api-summary`.
|
||||||
|
10. Run: `node tools/validate-generation.mjs --artifacts-only`
|
||||||
|
11. Run: `npm run eval:generation`
|
||||||
|
12. Fix all failures before considering the task complete.
|
||||||
|
|
||||||
|
**Context budget rule:** Before generating any DTO or component, quote the field
|
||||||
|
definitions from the DSL api block verbatim, then generate from those quotes. This
|
||||||
|
prevents training-data contamination. See `prompts/general-prompt.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type mappings
|
||||||
|
|
||||||
|
| DSL type | Prisma type | TS DTO type | React Admin component |
|
||||||
|
| --------- | ----------------------------- | ----------- | ----------------------------- |
|
||||||
|
| `uuid` | `String @id @default(uuid())` | `string` | `TextInput` / `TextField` |
|
||||||
|
| `string` | `String` | `string` | `TextInput` / `TextField` |
|
||||||
|
| `text` | `String` | `string` | `TextInput` / `TextField` |
|
||||||
|
| `integer` | `Int` | `number` | `NumberInput` / `NumberField` |
|
||||||
|
| `number` | `Float` | `number` | `NumberInput` / `NumberField` |
|
||||||
|
| `decimal` | `Decimal` | `string` | `NumberInput` / `NumberField` |
|
||||||
|
| `date` | `DateTime` | `string` | `DateInput` / `DateField` |
|
||||||
|
| `boolean` | `Boolean` | `boolean` | (appropriate boolean input) |
|
||||||
|
| enum name | enum name | `string` | `SelectInput` / `SelectField` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming conventions
|
||||||
|
|
||||||
|
Resource name (plural, kebab-case):
|
||||||
|
|
||||||
|
- `Equipment` → `equipment` (irregular — stays as-is)
|
||||||
|
- `EquipmentType` → `equipment-types`
|
||||||
|
- `RepairOrder` → `repair-orders`
|
||||||
|
- General: PascalCase → kebab-case → append `s` (or `es` if ends in `s`; irregular cases explicit)
|
||||||
|
|
||||||
|
Default sort field (when not declared in api.dsl):
|
||||||
|
Priority: `inventoryNumber` > `number` > `code` > `name` > primary key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification gate (two-stage)
|
||||||
|
|
||||||
|
### Stage 1 — Structural gate
|
||||||
|
|
||||||
|
```
|
||||||
|
node tools/validate-generation.mjs --artifacts-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks: file existence, api-summary freshness, auth seam contracts, natural-key handling, realm structure, runtime contract, api.dsl coverage (backend + frontend files per entity), DTO field name coverage, **DTO class-validator decorator coverage**, **@UseGuards presence on controllers**, **frontend component type correctness**.
|
||||||
|
|
||||||
|
Full structural verification (requires installed `node_modules`):
|
||||||
|
|
||||||
|
```
|
||||||
|
node tools/validate-generation.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stage 2 — Eval harness (Rule 6)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run eval:generation
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixture-based semantic checks from `tools/eval/fixtures/`. Checks: correct HTTP method decorators, Content-Range header, enum filter patterns, FK reference wiring, component type correctness, class-validator decorator patterns.
|
||||||
|
|
||||||
|
See `tools/eval/README.md` for fixture authoring and eval-driven development workflow.
|
||||||
|
|
||||||
|
**Generation is incomplete unless both stages pass.**
|
||||||
|
|
||||||
|
## Pre-commit hook
|
||||||
|
|
||||||
|
Install with `npm run install-hooks`. The hook runs **both** the structural gate and
|
||||||
|
the eval harness before every commit. Commits are blocked when either fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth and runtime defaults
|
||||||
|
|
||||||
|
Full auth contract: `prompts/auth-rules.md`
|
||||||
|
|
||||||
|
Working defaults (do not regress to localhost):
|
||||||
|
|
||||||
|
- Keycloak base: `https://sso.greact.ru`
|
||||||
|
- Realm/client: `toir` / `toir-frontend` / `toir-backend`
|
||||||
|
- Production frontend: `https://toir-frontend.greact.ru`
|
||||||
|
- CORS: `http://localhost:5173,https://toir-frontend.greact.ru`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenRouter configuration
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=<openrouter-key>
|
||||||
|
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
OPENAI_MODEL=<model-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
These variables are used by `tools/api-format-to-openapi/convert.mjs --mode llm`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading order for generation tasks
|
||||||
|
|
||||||
|
**Critical zone (load first, never drop):**
|
||||||
|
|
||||||
|
1. `AGENTS.md` (this file) — project governance, mutation boundaries, tier hierarchy
|
||||||
|
2. `prompts/general-prompt.md` — master orchestration prompt: mission, stage ownership, delegation model, completion criteria
|
||||||
|
3. `domain/toir.api.dsl §API.<EntityName>` — active api block only, plus its referenced DTOs and enums
|
||||||
|
|
||||||
|
**Companion zone (load when the matching stage is active):**
|
||||||
|
|
||||||
|
4. `prompts/prisma-rules.md` — Prisma schema generation details
|
||||||
|
5. `prompts/backend-rules.md` — NestJS generation details
|
||||||
|
6. `prompts/frontend-rules.md` — React Admin generation details
|
||||||
|
7. `prompts/auth-rules.md` — auth seam and realm requirements
|
||||||
|
8. `prompts/runtime-rules.md` — scaffold, env, and bootstrap requirements
|
||||||
|
9. `prompts/validation-rules.md` — success gate requirements
|
||||||
|
|
||||||
|
**Auxiliary zone (never authoritative):**
|
||||||
|
|
||||||
|
10. `api-summary.json` — optional inventory/freshness artifact for validators and supporting tooling; do not use it instead of the DSL
|
||||||
|
|
||||||
|
**Reference only (do not load proactively):**
|
||||||
|
|
||||||
|
- `domain/dsl-spec.md` — DSL syntax reference; load only if DSL is ambiguous
|
||||||
|
- `docs/generation-playbook.md` — step-by-step workflow reference
|
||||||
|
- `docs/future-work.md` — deferred items (Rules 7 and 8)
|
||||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
This repository keeps the current LLM-first CRUD generation architecture as the primary working baseline.
|
||||||
|
|
||||||
|
It is not a new generator engine and it is not a compiler platform. The repository remains:
|
||||||
|
|
||||||
|
- an AI generation context
|
||||||
|
- an active generated and maintained fullstack CRUD project
|
||||||
|
- `server/` as the active backend target output path
|
||||||
|
- `client/` as the active frontend target output path
|
||||||
|
- an LLM-first orchestration baseline with CLI-first framework bootstrap
|
||||||
|
- a compact rule set that strengthens the existing pipeline with:
|
||||||
|
- `api-summary.json` (deterministic intermediate context)
|
||||||
|
- a physical root-level realm artifact
|
||||||
|
- a lightweight automated validation gate
|
||||||
|
|
||||||
|
## Active knowledge blocks
|
||||||
|
|
||||||
|
The master generation prompt is `prompts/general-prompt.md`. It contains the complete
|
||||||
|
generation workflow, type mappings, naming conventions, and all core rules.
|
||||||
|
|
||||||
|
Companion rule files for artifact-specific details:
|
||||||
|
|
||||||
|
1. [prompts/general-prompt.md](prompts/general-prompt.md) — master generation prompt
|
||||||
|
2. [prompts/auth-rules.md](prompts/auth-rules.md) — auth seam / realm spec
|
||||||
|
3. [prompts/backend-rules.md](prompts/backend-rules.md) — backend reference
|
||||||
|
4. [prompts/frontend-rules.md](prompts/frontend-rules.md) — frontend reference
|
||||||
|
5. [prompts/prisma-rules.md](prompts/prisma-rules.md) — Prisma schema rules
|
||||||
|
6. [prompts/runtime-rules.md](prompts/runtime-rules.md) — runtime / bootstrap
|
||||||
|
7. [prompts/validation-rules.md](prompts/validation-rules.md) — validation gate
|
||||||
|
|
||||||
|
## Baseline contracts
|
||||||
|
|
||||||
|
- `domain/*.api.dsl` is the single source of truth for the domain model and API contract.
|
||||||
|
- [api-summary.json](api-summary.json) is a derived artifact for LLM stabilization and validation.
|
||||||
|
- [toir-realm.json](toir-realm.json) is the physical Keycloak bootstrap artifact baseline.
|
||||||
|
- `server/` and `client/` are the active target output paths for this repository.
|
||||||
|
- `server/` must remain a valid NestJS workspace baseline.
|
||||||
|
- `client/` must remain a valid Vite React TypeScript workspace baseline.
|
||||||
|
|
||||||
|
## Scaffold baseline
|
||||||
|
|
||||||
|
- Generation remains LLM-first for orchestration and domain-derived feature code.
|
||||||
|
- Framework bootstrap is CLI-first:
|
||||||
|
- backend baseline starts from official Nest CLI conventions
|
||||||
|
- frontend baseline starts from official Vite React TypeScript conventions
|
||||||
|
- If `server/` or `client/` drift away from a valid workspace, repair the workspace baseline before generating more feature code.
|
||||||
|
- Do not replace the framework workspace with a hand-written minimal skeleton.
|
||||||
|
|
||||||
|
## Anti-regression contract
|
||||||
|
|
||||||
|
- The active prompts define forbidden generation patterns, required invariants, and recovery rules for future agents.
|
||||||
|
- Buildability is part of the baseline contract, not an optional follow-up.
|
||||||
|
- Validation targets `domain/*.api.dsl` as reusable source inputs, while TOiR names remain project defaults/examples.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- [docs/repository-structure.md](docs/repository-structure.md) explains the normalized folder structure.
|
||||||
|
- Active prompts live in `prompts/`.
|
||||||
|
- Helper scripts live in `tools/`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:api-summary
|
||||||
|
npm run validate:generation
|
||||||
|
npm run validate:generation:runtime
|
||||||
|
npm run eval:generation
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run validate:generation` checks both contract shape and workspace validity. When dependencies are installed, it also verifies `npm run build` in `server/` and `client/`. If dependencies are missing, it reports build verification as skipped instead of pretending the baseline is fully green.
|
||||||
|
|
||||||
|
## AID export (OpenAPI)
|
||||||
|
|
||||||
|
HTTP-экспортёр для интеграции с AID: **`POST /aid/export/openapi`** (api-format → OpenAPI 3.0). Подробно: **[docs/AID_EXPORT_README.md](docs/AID_EXPORT_README.md)**.
|
||||||
|
|
||||||
|
> **Note:** The `POST /aid/export/app` endpoint (DSL → generated app bundle) is currently
|
||||||
|
> non-operative because its backing script (`generation/generate.mjs`) was removed during
|
||||||
|
> the architecture migration to api.dsl-first generation. See `docs/AID_EXPORT_README.md`
|
||||||
|
> for details.
|
||||||
31
api-summary.json
Normal file
31
api-summary.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"sourceFiles": [
|
||||||
|
"domain/toir.api.dsl"
|
||||||
|
],
|
||||||
|
"enums": [
|
||||||
|
{
|
||||||
|
"name": "EquipmentStatus",
|
||||||
|
"description": null,
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "Active",
|
||||||
|
"label": "В эксплуатации"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Repair",
|
||||||
|
"label": "В ремонте"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reserve",
|
||||||
|
"label": "В резерве"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WriteOff",
|
||||||
|
"label": "Списано"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dtos": [],
|
||||||
|
"apis": []
|
||||||
|
}
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: toir-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-toir}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-toir}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
120
docs/AID_EXPORT_README.md
Normal file
120
docs/AID_EXPORT_README.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# AID: экспорт OpenAPI и генератор приложения
|
||||||
|
|
||||||
|
В репозитории добавлены **сервисы-экспортёры** для интеграции с **AID** (или любым другим клиентом по HTTP): автоматическое получение **OpenAPI 3.0** из доменного **api-format**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что работает
|
||||||
|
|
||||||
|
| Компонент | Назначение |
|
||||||
|
|-----------|------------|
|
||||||
|
| **`POST /aid/export/openapi`** (NestJS) | На вход JSON **api-format** → на выход документ **OpenAPI 3.0** в поле `openapi`. |
|
||||||
|
| **`tools/api-format-to-openapi/`** | CLI и промпт для LLM: тот же конвертер, что вызывает Nest. |
|
||||||
|
| **`server/src/aid-export/`** | Модуль Nest: контроллер, сервис, краткая справка в `README.md` рядом с кодом. |
|
||||||
|
|
||||||
|
## Что временно не работает
|
||||||
|
|
||||||
|
| Компонент | Статус |
|
||||||
|
|-----------|--------|
|
||||||
|
| **`POST /aid/export/app`** (DSL → бандл/apply) | **Non-operative.** The backing script (`generation/generate.mjs`) was removed during the architecture migration to api.dsl-first LLM generation. The endpoint returns 500 with a descriptive error. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Требования к запуску
|
||||||
|
|
||||||
|
1. Репозиторий клонирован целиком (есть `tools/`, `server/`, `client/`).
|
||||||
|
2. Backend запускается из каталога **`server/`** (`npm run start` / `start:dev`), чтобы относительные пути `../tools/api-format-to-openapi/convert.mjs` были корректны.
|
||||||
|
3. Для режима OpenAPI через LLM на сервере нужны **`OPENAI_API_KEY`** (и при необходимости `OPENAI_MODEL`, `OPENAI_BASE_URL`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения (`server/.env`)
|
||||||
|
|
||||||
|
| Переменная | Зачем |
|
||||||
|
|------------|--------|
|
||||||
|
| `AID_EXPORT_API_KEY` | Если задана, к **`/aid/export/*`** нужен заголовок **`X-AID-Export-Key`** с тем же значением. |
|
||||||
|
| `OPENAI_API_KEY` | Для `POST /aid/export/openapi` с **`"mode": "llm"`**. |
|
||||||
|
|
||||||
|
Остальное как для обычного бэкенда (`DATABASE_URL`, `PORT` и т.д.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API (интеграция с AID)
|
||||||
|
|
||||||
|
Базовый URL: `http://<host>:<port>` (например `http://localhost:3000`).
|
||||||
|
|
||||||
|
### 1. OpenAPI из api-format
|
||||||
|
|
||||||
|
**`POST /aid/export/openapi`**
|
||||||
|
|
||||||
|
```http
|
||||||
|
Content-Type: application/json
|
||||||
|
X-AID-Export-Key: <если задан AID_EXPORT_API_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiFormat": {
|
||||||
|
"apiFormatVersion": "1",
|
||||||
|
"info": { "title": "API", "version": "1.0.0" },
|
||||||
|
"server": { "basePath": "/api" },
|
||||||
|
"resources": []
|
||||||
|
},
|
||||||
|
"mode": "deterministic"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`mode`**: `deterministic` (по умолчанию) — маппинг в коде для схемы версии `1`; **`llm`** — вызов OpenAI по промпту из `tools/api-format-to-openapi/prompts/llm-system.md`.
|
||||||
|
|
||||||
|
**Ответ:** `{ "openapi": { "openapi": "3.0.3", ... } }`
|
||||||
|
|
||||||
|
Пример входа для теста: `tools/api-format-to-openapi/examples/api-format.example.json` (подставьте как значение `apiFormat`).
|
||||||
|
|
||||||
|
### 2. Генератор приложения из DSL (non-operative)
|
||||||
|
|
||||||
|
**`POST /aid/export/app`**
|
||||||
|
|
||||||
|
> **Non-operative.** This endpoint depended on `generation/generate.mjs` which was removed
|
||||||
|
> during the migration to api.dsl-first LLM generation. It currently returns HTTP 500
|
||||||
|
> with a descriptive error message. Restoring this endpoint requires implementing a new
|
||||||
|
> backing script compatible with the current `domain/*.api.dsl` pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI (без Nest)
|
||||||
|
|
||||||
|
### api-format → OpenAPI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/api-format-to-openapi
|
||||||
|
node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
|
||||||
|
```
|
||||||
|
|
||||||
|
LLM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set OPENAI_API_KEY=sk-...
|
||||||
|
node convert.mjs --mode llm --in your-api-format.json --out ../../openapi.llm.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробнее: **`tools/api-format-to-openapi/README.md`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Типичный сценарий для AID
|
||||||
|
|
||||||
|
1. AID уже сформировал **api-format** (как у вас принято после DTO).
|
||||||
|
2. AID вызывает **`POST /aid/export/openapi`** → получает **OpenAPI 3.0** → сохраняет в проект / отдаёт в Swagger / в реестр.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Где смотреть код и короткую справку
|
||||||
|
|
||||||
|
- Полное описание эндпоинтов рядом с реализацией: **`server/src/aid-export/README.md`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ограничения и дальнейшие шаги
|
||||||
|
|
||||||
|
- Пример **api-format** в репозитории — **учебный**; под ваш продакшен-формат может понадобиться расширить маппинг в `convert.mjs` или отточить промпт **`llm-system.md`**.
|
||||||
|
- **`/aid/export/app`** requires a new backing implementation compatible with the `domain/*.api.dsl` pipeline to be restored. See `docs/future-work.md` for planned future work.
|
||||||
162
docs/api-dsl-conventions.md
Normal file
162
docs/api-dsl-conventions.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# api.dsl Conventions
|
||||||
|
|
||||||
|
Grammar and authoring conventions for `domain/*.api.dsl` files.
|
||||||
|
See `domain/toir.api.dsl` as the canonical example.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File location and naming
|
||||||
|
|
||||||
|
- All api.dsl files live in `domain/`.
|
||||||
|
- Naming: `<project>.api.dsl` (e.g. `toir.api.dsl`).
|
||||||
|
- Multiple api.dsl files are allowed but not required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two top-level block types
|
||||||
|
|
||||||
|
An api.dsl file contains two types of top-level blocks:
|
||||||
|
|
||||||
|
1. `dto` — defines the shape of a data transfer object
|
||||||
|
2. `api` — declares a group of API endpoints for one resource
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## dto block
|
||||||
|
|
||||||
|
```
|
||||||
|
dto DTO.<Name> {
|
||||||
|
description "Human-readable description";
|
||||||
|
|
||||||
|
attribute <fieldName> {
|
||||||
|
description "Field description";
|
||||||
|
type <type>;
|
||||||
|
is required; // or: is nullable;
|
||||||
|
map <Entity>.<field>; // links to a field in domain/*.api.dsl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DTO naming convention
|
||||||
|
|
||||||
|
| DTO name | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `DTO.<Entity>` | Full response shape (GET by id, list items) |
|
||||||
|
| `DTO.<Entity>Create` | Create request body |
|
||||||
|
| `DTO.<Entity>Update` | Update request body (partial — all fields nullable) |
|
||||||
|
| `DTO.<Entity>ListRequest` | Paginated list request (filters + page) |
|
||||||
|
| `DTO.<Entity>ListResponse` | Paginated list response (content + page info) |
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
Same scalar types as the domain DSL:
|
||||||
|
`uuid`, `string`, `text`, `integer`, `decimal`, `date`, `boolean`, or an enum name from
|
||||||
|
`domain/*.api.dsl`.
|
||||||
|
|
||||||
|
Cross-DTO references: `DTO.<Name>` or `DTO.<Name>[]`.
|
||||||
|
|
||||||
|
Standard pagination types (not entity-specific; treated as well-known):
|
||||||
|
`DTO.Filter[]`, `DTO.PageRequest`, `DTO.PageInfo`.
|
||||||
|
|
||||||
|
### is required vs is nullable
|
||||||
|
|
||||||
|
Unlike the domain DSL (where these drive DB constraints), in api.dsl these drive the
|
||||||
|
TypeScript DTO property modifier:
|
||||||
|
|
||||||
|
- `is required` → `field!: type` in the generated class
|
||||||
|
- `is nullable` → `field?: type` in the generated class
|
||||||
|
- Neither modifier → treated as optional (`field?: type`)
|
||||||
|
|
||||||
|
### map directive
|
||||||
|
|
||||||
|
`map Entity.field` links the DTO attribute to a domain entity field.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- The entity and field must exist in `domain/*.api.dsl`.
|
||||||
|
- The scalar type must match the domain DSL field type (nullability may differ).
|
||||||
|
- Omit `map` only for pagination helper types (`DTO.Filter[]`, `DTO.PageRequest`, etc.)
|
||||||
|
that have no direct entity field counterpart.
|
||||||
|
|
||||||
|
### Conflict resolution
|
||||||
|
|
||||||
|
If the type declared in a `dto` attribute conflicts with the domain DSL field type,
|
||||||
|
the domain DSL is correct. Fix the api.dsl.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## api block
|
||||||
|
|
||||||
|
```
|
||||||
|
api API.<Name> {
|
||||||
|
description "API group description";
|
||||||
|
|
||||||
|
endpoint <endpointName> {
|
||||||
|
label "METHOD /path";
|
||||||
|
description "Endpoint description";
|
||||||
|
|
||||||
|
// For endpoints with a request body:
|
||||||
|
attribute request {
|
||||||
|
type DTO.<RequestDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For endpoints with a response body:
|
||||||
|
attribute response {
|
||||||
|
type DTO.<ResponseDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For path parameters:
|
||||||
|
attribute <paramName> {
|
||||||
|
type <type>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### api block naming
|
||||||
|
|
||||||
|
`API.<EntityName>` (e.g. `API.Equipment`, `API.RepairOrder`)
|
||||||
|
|
||||||
|
### endpoint label
|
||||||
|
|
||||||
|
`label "METHOD /path"` is the authoritative declaration of HTTP verb and route.
|
||||||
|
|
||||||
|
| Label pattern | NestJS decorator | Notes |
|
||||||
|
|---------------|-----------------|-------|
|
||||||
|
| `"GET /resource/{id}"` | `@Get(':id')` | |
|
||||||
|
| `"POST /resource"` | `@Post()` | Create |
|
||||||
|
| `"POST /resource/page"` | `@Post('page')` | Paginated list (body-based filter) |
|
||||||
|
| `"PUT /resource/{id}"` | `@Put(':id')` | Full or partial update |
|
||||||
|
| `"DELETE /resource/{id}"` | `@Delete(':id')` | |
|
||||||
|
|
||||||
|
Do not infer HTTP verbs from endpoint names. Always read the `label`.
|
||||||
|
|
||||||
|
### Path parameters
|
||||||
|
|
||||||
|
Declared as a plain `attribute` inside the endpoint block (not wrapped in `request`
|
||||||
|
or `response`):
|
||||||
|
|
||||||
|
```
|
||||||
|
attribute id {
|
||||||
|
type uuid;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard 5-endpoint CRUD pattern
|
||||||
|
|
||||||
|
| Endpoint name | Label | Body |
|
||||||
|
|---------------|-------|------|
|
||||||
|
| `list<Entity>s` | `"POST /<path>/page"` | request: `DTO.<Entity>ListRequest`, response: `DTO.<Entity>ListResponse` |
|
||||||
|
| `get<Entity>` | `"GET /<path>/{id}"` | path param `id`, response: `DTO.<Entity>` |
|
||||||
|
| `create<Entity>` | `"POST /<path>"` | request: `DTO.<Entity>Create` |
|
||||||
|
| `update<Entity>` | `"PUT /<path>/{id}"` | path param `id`, request: `DTO.<Entity>Update` |
|
||||||
|
| `delete<Entity>` | `"DELETE /<path>/{id}"` | path param `id` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
1. Every `map Entity.field` must resolve to an existing entity + field in `domain/*.api.dsl`.
|
||||||
|
2. Types in api.dsl must be compatible with domain DSL field types (same scalar type).
|
||||||
|
3. api.dsl must not define entities or enums. Those belong in `domain/*.api.dsl`.
|
||||||
|
4. An api.dsl may omit domain entity fields from a DTO (e.g. no PK in Create DTO).
|
||||||
|
It must not add fields that don't exist in the domain model.
|
||||||
154
docs/future-work.md
Normal file
154
docs/future-work.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Future Work — Deferred Items
|
||||||
|
|
||||||
|
This file tracks engineering improvements that are deliberately deferred due to the
|
||||||
|
current stage of the project. They are not forgotten — they are acknowledged technical
|
||||||
|
debt that should be addressed before scaling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule 7 — Tracing, Telemetry, Cost/Latency Observability
|
||||||
|
|
||||||
|
**Status:** Deferred. No LLM calls are instrumented.
|
||||||
|
|
||||||
|
**Why it matters (Anthropic / Google / Microsoft guidance):**
|
||||||
|
Without observability, you cannot:
|
||||||
|
- Know which prompts are expensive (token count, latency)
|
||||||
|
- Detect prompt regressions via cost drift
|
||||||
|
- Attribute generation failures to specific prompt versions
|
||||||
|
- Track improvement over time
|
||||||
|
|
||||||
|
**What needs to be built:**
|
||||||
|
|
||||||
|
### 7.1 — Generation log
|
||||||
|
|
||||||
|
Create `tools/generation-log.mjs` that wraps any LLM generation call and writes a
|
||||||
|
structured JSON entry to `logs/generation.jsonl`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-03T10:00:00.000Z",
|
||||||
|
"entity": "Equipment",
|
||||||
|
"artifact": "backend",
|
||||||
|
"prompt_version": "1.0",
|
||||||
|
"model": "...",
|
||||||
|
"input_tokens": 4200,
|
||||||
|
"output_tokens": 1800,
|
||||||
|
"latency_ms": 3200,
|
||||||
|
"validation_passed": true,
|
||||||
|
"eval_passed": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 — Cost budget alerts
|
||||||
|
|
||||||
|
Add a threshold check (e.g., warn if input_tokens > 8000 for a single entity generation).
|
||||||
|
This enforces the context budget from `prompts/general-prompt.md §CONTEXT BUDGET`.
|
||||||
|
|
||||||
|
### 7.3 — Prompt version tracking
|
||||||
|
|
||||||
|
Add `<!-- prompt-version: X.Y -->` comments to all prompt files (already started in
|
||||||
|
`backend-rules.md` and `frontend-rules.md`). Increment version on any non-trivial change.
|
||||||
|
Log the prompt versions alongside the generation log entry.
|
||||||
|
|
||||||
|
### 7.4 — Drift detection
|
||||||
|
|
||||||
|
Compare generation log entries across runs. If token count for the same entity increases
|
||||||
|
by >20% without a DSL change, flag it as context rot.
|
||||||
|
|
||||||
|
**Effort estimate:** Medium. 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
|
||||||
237
docs/generation-playbook.md
Normal file
237
docs/generation-playbook.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Generation Playbook
|
||||||
|
|
||||||
|
Step-by-step instructions for generating and regenerating artifacts in this repository.
|
||||||
|
Read `AGENTS.md` and `docs/source-of-truth.md` first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Tier 1 — Single Source of Truth (hand-authored, never generated)
|
||||||
|
domain/toir.api.dsl ──┐ enums, DTOs, endpoints, HTTP methods,
|
||||||
|
│ entity field mappings, primary keys
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Tier 2 — Deterministic Preprocessing (npm scripts, no LLM)
|
||||||
|
api-summary.json ←─ npm run generate:api-summary
|
||||||
|
openapi.json ←─ npm run generate:openapi (auxiliary)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Tier 1 (api.dsl) + Tier 2 (context) + prompts/*.md ──► LLM Generation
|
||||||
|
prompts/general-prompt.md
|
||||||
|
prompts/backend-rules.md ──► server/src/modules/<entity>/
|
||||||
|
prompts/frontend-rules.md ──► client/src/resources/<entity>/
|
||||||
|
prompts/prisma-rules.md ──► server/prisma/schema.prisma
|
||||||
|
prompts/auth-rules.md ──► (auth seam reference)
|
||||||
|
prompts/runtime-rules.md ──► (env/docker reference)
|
||||||
|
prompts/validation-rules.md ──► (validation gate reference)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Tier 4 — Validation Gate
|
||||||
|
node tools/validate-generation.mjs --artifacts-only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before any generation run:
|
||||||
|
|
||||||
|
1. `domain/*.api.dsl` is current and valid.
|
||||||
|
2. Refresh the Tier 2 intermediate context:
|
||||||
|
```bash
|
||||||
|
npm run generate:api-summary
|
||||||
|
```
|
||||||
|
3. Run `node tools/validate-generation.mjs --artifacts-only` to confirm the baseline passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard generation workflow
|
||||||
|
|
||||||
|
### Step 1 — Refresh Tier 2 derived artifacts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repo root
|
||||||
|
npm run generate:api-summary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Read generation inputs (context budget)
|
||||||
|
|
||||||
|
> **`prompts/general-prompt.md` is the master generation prompt.** It contains all core
|
||||||
|
> type mappings, naming conventions, DTO/controller/service/frontend rules, mutation
|
||||||
|
> boundaries, and the complete generation workflow. Load it as the single entrypoint.
|
||||||
|
>
|
||||||
|
> For artifact-specific details (Prisma FK rules, auth JWKS chain, detailed validation
|
||||||
|
> groups), load the relevant companion file: `prompts/prisma-rules.md`,
|
||||||
|
> `prompts/auth-rules.md`, `prompts/validation-rules.md`, or `prompts/runtime-rules.md`.
|
||||||
|
>
|
||||||
|
> See `prompts/general-prompt.md §CONTEXT BUDGET` for the full budget model.
|
||||||
|
|
||||||
|
1. `prompts/general-prompt.md` — master generation prompt (always load)
|
||||||
|
2. `api-summary.json §<entity>` — compact entity index (fast-path context anchor)
|
||||||
|
3. `domain/*.api.dsl §API.<EntityName>` — **only the api block + its referenced DTOs + enums** (entity-scoped)
|
||||||
|
4. **If needed:** `prompts/prisma-rules.md` (Prisma) or `prompts/auth-rules.md` (auth seams)
|
||||||
|
|
||||||
|
Before generating any DTO or component: **quote the relevant DSL field definitions verbatim first**, then generate from those quotes. This prevents training-data contamination.
|
||||||
|
|
||||||
|
### Step 3 — Generate Prisma schema
|
||||||
|
|
||||||
|
Generate `server/prisma/schema.prisma` from `domain/*.api.dsl` following `prompts/prisma-rules.md`.
|
||||||
|
|
||||||
|
If the schema changed, run Prisma migration in `server/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npx prisma migrate dev --name <description>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Generate backend modules
|
||||||
|
|
||||||
|
For each `api` block in `domain/*.api.dsl`, generate:
|
||||||
|
|
||||||
|
1. `server/src/modules/<kebab>/<entity>.module.ts`
|
||||||
|
2. `server/src/modules/<kebab>/dto/create-<kebab>.dto.ts`
|
||||||
|
— fields from the `DTO.<Entity>Create` block in api.dsl
|
||||||
|
3. `server/src/modules/<kebab>/dto/update-<kebab>.dto.ts`
|
||||||
|
— fields from the `DTO.<Entity>Update` block in api.dsl
|
||||||
|
4. `server/src/modules/<kebab>/<entity>.service.ts`
|
||||||
|
— CRUD operations using Prisma; respect type mappings from `prompts/backend-rules.md`
|
||||||
|
5. `server/src/modules/<kebab>/<entity>.controller.ts`
|
||||||
|
— one method per `endpoint` in the `api` block; HTTP verb and path from the `label`
|
||||||
|
|
||||||
|
Register the module in `server/src/app.module.ts`.
|
||||||
|
|
||||||
|
### Step 5 — Generate frontend resources
|
||||||
|
|
||||||
|
For each `api` block in `domain/*.api.dsl`, generate:
|
||||||
|
|
||||||
|
1. `client/src/resources/<kebab>/<Entity>List.tsx`
|
||||||
|
— columns from `DTO.<Entity>` (response shape)
|
||||||
|
2. `client/src/resources/<kebab>/<Entity>Create.tsx`
|
||||||
|
— fields from `DTO.<Entity>Create`
|
||||||
|
3. `client/src/resources/<kebab>/<Entity>Edit.tsx`
|
||||||
|
— fields from `DTO.<Entity>Update`
|
||||||
|
4. `client/src/resources/<kebab>/<Entity>Show.tsx`
|
||||||
|
— fields from `DTO.<Entity>`
|
||||||
|
|
||||||
|
Register the resource in `client/src/App.tsx`.
|
||||||
|
|
||||||
|
### Step 6 — Verify (two-stage gate)
|
||||||
|
|
||||||
|
**Stage 1 — Structural gate:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/validate-generation.mjs --artifacts-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks file existence, field names, class-validator decorators, auth guards, and RA component types.
|
||||||
|
|
||||||
|
Full structural verification (requires installed deps):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/validate-generation.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stage 2 — Eval harness:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run eval:generation
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixture-based semantic checks. See `tools/eval/README.md`.
|
||||||
|
|
||||||
|
Both must pass before the task is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new entity
|
||||||
|
|
||||||
|
1. Add the entity's enums, DTOs, and `api` block to `domain/toir.api.dsl`.
|
||||||
|
2. Run `npm run generate:api-summary`.
|
||||||
|
3. **Before generating:** create `tools/eval/fixtures/<kebab>/meta.json`, `backend.assertions.json`, and `frontend.assertions.json` with expected patterns.
|
||||||
|
4. Run `npm run eval:generation` — it will fail (entity files don't exist yet). That's expected.
|
||||||
|
5. Generate backend and frontend artifacts (Steps 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/<e>/*.ts` | `domain/*.api.dsl` | `prompts/backend-rules.md` | `§validateApiDslCoverage` |
|
||||||
|
| `server/src/modules/<e>/dto/create-<e>.dto.ts` | `DTO.<E>Create` fields | `prompts/backend-rules.md §DTO-field-coverage` | `§validateApiDslCoverage` (field names) |
|
||||||
|
| `server/src/modules/<e>/dto/update-<e>.dto.ts` | `DTO.<E>Update` fields | `prompts/backend-rules.md §DTO-field-coverage` | `§validateApiDslCoverage` (field names) |
|
||||||
|
| `client/src/resources/<e>/<E>*.tsx` | `domain/*.api.dsl` | `prompts/frontend-rules.md` | `§validateApiDslCoverage` |
|
||||||
|
| `client/src/auth/keycloak.ts` | — (Tier 4 handwritten) | `prompts/auth-rules.md` | `§validateAuthChecks` |
|
||||||
|
| `toir-realm.json` | — (Tier 4 handwritten) | `prompts/auth-rules.md` | `§validateRealmChecks` |
|
||||||
|
| `docker-compose.yml` | — (Tier 4 handwritten) | `prompts/runtime-rules.md` | `§validateRuntimeContractChecks` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification commands reference
|
||||||
|
|
||||||
|
| Command | When to use |
|
||||||
|
| ----------------------------------------------------- | ------------------------------------------ |
|
||||||
|
| `npm run generate:api-summary` | After any change to `domain/*.api.dsl` |
|
||||||
|
| `npm run generate:openapi` | To regenerate OpenAPI 3.0.3 documentation |
|
||||||
|
| `node tools/validate-generation.mjs --artifacts-only` | After every generation run (required) |
|
||||||
|
| `node tools/validate-generation.mjs` | Before committing; requires installed deps |
|
||||||
|
| `node tools/validate-generation.mjs --run-runtime` | End-to-end; requires running DB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenRouter invocation
|
||||||
|
|
||||||
|
Set environment variables before any LLM-mode tool call:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_API_KEY=<openrouter-key>
|
||||||
|
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
OPENAI_MODEL=<model-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `OPENAI_BASE_URL` variable is consumed by `tools/api-format-to-openapi/convert.mjs --mode llm`.
|
||||||
|
For agent-driven generation via Cursor or direct API calls, the standard workflow above
|
||||||
|
applies regardless of model provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auxiliary tools
|
||||||
|
|
||||||
|
### `tools/api-summary-to-openapi.mjs`
|
||||||
|
|
||||||
|
Generates `openapi.json` (OpenAPI 3.0.3) from `api-summary.json` deterministically.
|
||||||
|
This is a documentation/integration artifact, not part of the core generation pipeline.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:openapi
|
||||||
|
```
|
||||||
|
|
||||||
|
### `tools/api-format-to-openapi/`
|
||||||
|
|
||||||
|
Auxiliary integration tool for external consumers using the `api-format` JSON schema.
|
||||||
|
Not connected to the main DSL pipeline. See `docs/AID_EXPORT_README.md` for details.
|
||||||
35
docs/repository-structure.md
Normal file
35
docs/repository-structure.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Repository Structure
|
||||||
|
|
||||||
|
`KIS-TOiR` keeps the existing LLM-first generation philosophy and organizes the repository by meaning:
|
||||||
|
|
||||||
|
- `domain/`
|
||||||
|
- canonical DSL inputs
|
||||||
|
- DSL specification
|
||||||
|
- `prompts/`
|
||||||
|
- active prompt corpus used to drive generation
|
||||||
|
- `docs/`
|
||||||
|
- overview and repository-level architecture notes
|
||||||
|
- `tools/`
|
||||||
|
- helper scripts for summary generation and validation
|
||||||
|
- `server/`
|
||||||
|
- active backend target output
|
||||||
|
- `client/`
|
||||||
|
- active frontend target output
|
||||||
|
|
||||||
|
The repository keeps LLM-first generation orchestration, but framework bootstrap is CLI-first:
|
||||||
|
|
||||||
|
- `server/` must remain a valid NestJS workspace baseline
|
||||||
|
- `client/` must remain a valid Vite React TypeScript workspace baseline
|
||||||
|
- repair a broken workspace before applying more domain-derived generation changes
|
||||||
|
- future agents must treat forbidden generation patterns in `prompts/` as contract violations, not suggestions
|
||||||
|
|
||||||
|
Root-level files stay limited to repository-level artifacts such as:
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `package.json`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `api-summary.json`
|
||||||
|
- `toir-realm.json`
|
||||||
|
- `.gitignore`
|
||||||
|
|
||||||
|
The repository does not introduce a new generator engine or compiler platform. It keeps the current LLM-first pipeline and makes it cleaner, more explicit, and easier to navigate.
|
||||||
96
docs/source-of-truth.md
Normal file
96
docs/source-of-truth.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Source-of-Truth Hierarchy
|
||||||
|
|
||||||
|
This document is the authoritative reference for which files own which decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 1: Authoritative sources (hand-authored; never generated)
|
||||||
|
|
||||||
|
### `domain/*.api.dsl`
|
||||||
|
|
||||||
|
**Single source of truth for the entire domain and API contract:**
|
||||||
|
|
||||||
|
- Entity names and structure
|
||||||
|
- Attribute names, scalar types, descriptions
|
||||||
|
- Primary keys (including natural string keys)
|
||||||
|
- Foreign keys and relations
|
||||||
|
- Enum definitions and their values
|
||||||
|
- Database-level constraints: `is required`, `is unique`, `default`
|
||||||
|
- DTO shapes per operation (Create, Update, Read, ListRequest, ListResponse)
|
||||||
|
- Which fields appear in each DTO and with what TypeScript modifier (`!` or `?`)
|
||||||
|
- HTTP method and path for each endpoint (via `label "METHOD /path"`)
|
||||||
|
- Endpoint names (camelCase identifiers)
|
||||||
|
- Pagination request/response contract
|
||||||
|
|
||||||
|
**Drives:** `server/prisma/schema.prisma`, `server/src/modules/`, `client/src/resources/`,
|
||||||
|
`server/src/app.module.ts`, `client/src/App.tsx`.
|
||||||
|
|
||||||
|
### `prompts/*.md` and `AGENTS.md`
|
||||||
|
|
||||||
|
**Authoritative for:**
|
||||||
|
|
||||||
|
- Agent generation workflow and reading order
|
||||||
|
- Auth seam patterns (Keycloak, JWT, PKCE S256, JWKS resolution chain)
|
||||||
|
- Runtime conventions (env examples, docker-compose topology)
|
||||||
|
- Framework scaffold baseline requirements (NestJS CLI, Vite React TypeScript)
|
||||||
|
- Filtering and sorting contract
|
||||||
|
- Naming conventions and implicit rules (pluralization, sort field priority, type mappings)
|
||||||
|
- Mutation boundaries (what agents must not overwrite)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 2: Deterministic derivatives (never edit manually)
|
||||||
|
|
||||||
|
| File | Generated from | Command |
|
||||||
|
| ----------------- | ------------------ | ------------------------------ |
|
||||||
|
| `api-summary.json` | `domain/*.api.dsl` | `npm run generate:api-summary` |
|
||||||
|
|
||||||
|
These files are regenerated from their sources. Manual edits are overwritten on the
|
||||||
|
next generation run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 3: LLM-generated artifacts (never edit manually after generation)
|
||||||
|
|
||||||
|
| Zone | Generated from |
|
||||||
|
| -------------------------------- | ------------------------------------------------ |
|
||||||
|
| `server/prisma/schema.prisma` | `domain/*.api.dsl` + `prompts/prisma-rules.md` |
|
||||||
|
| `server/src/modules/<entity>/` | `domain/*.api.dsl` + `prompts/backend-rules.md` |
|
||||||
|
| `client/src/resources/<entity>/` | `domain/*.api.dsl` + `prompts/frontend-rules.md` |
|
||||||
|
| `server/src/app.module.ts` | Module list derived from api.dsl `api` blocks |
|
||||||
|
| `client/src/App.tsx` | Resource list derived from api.dsl `api` blocks |
|
||||||
|
|
||||||
|
To change these files: update `domain/*.api.dsl` and regenerate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier 4: Handwritten (not generated; not derived)
|
||||||
|
|
||||||
|
- Auth seams: `client/src/auth/`, `server/src/auth/`
|
||||||
|
- `toir-realm.json`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `server/.env.example`, `client/.env.example`
|
||||||
|
- Framework config: `nest-cli.json`, `tsconfig*.json`, `vite.config.*`, etc.
|
||||||
|
- All `prompts/*.md`, `AGENTS.md`, `domain/dsl-spec.md`, `domain/*.api.dsl`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation gate
|
||||||
|
|
||||||
|
### Stage 1 — Structural gate
|
||||||
|
|
||||||
|
```
|
||||||
|
node tools/validate-generation.mjs --artifacts-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifies that generated artifacts satisfy the contracts declared in Tier 1 sources.
|
||||||
|
|
||||||
|
### Stage 2 — Eval harness
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run eval:generation
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixture-based semantic checks from `tools/eval/fixtures/`.
|
||||||
|
|
||||||
|
Both stages must pass before any generation task is considered complete.
|
||||||
248
domain/dsl-spec.md
Normal file
248
domain/dsl-spec.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# DSL Language Specification
|
||||||
|
|
||||||
|
This document describes the DSL system used to specify fullstack CRUD applications.
|
||||||
|
|
||||||
|
`domain/*.api.dsl` is the single source of truth for the entire domain model and API
|
||||||
|
contract. It drives Prisma schema generation, NestJS module generation, and React Admin
|
||||||
|
resource generation.
|
||||||
|
|
||||||
|
`api-summary.json` is a derived artifact generated from the api.dsl to stabilize
|
||||||
|
LLM-first generation and feed the lightweight validation gate. It must never replace the
|
||||||
|
DSL as the source of truth. The active prompt corpus that consumes this contract lives in
|
||||||
|
`prompts/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DSL Architecture
|
||||||
|
|
||||||
|
## `domain/*.api.dsl`
|
||||||
|
|
||||||
|
The api.dsl is the authoritative source of truth for:
|
||||||
|
|
||||||
|
- entities and their attributes
|
||||||
|
- scalar types and enums
|
||||||
|
- primary keys and foreign keys
|
||||||
|
- database-level constraints (required, unique, default)
|
||||||
|
- relations between entities
|
||||||
|
- DTO shapes per operation (Create, Update, Read, List)
|
||||||
|
- nullability and requiredness of each DTO attribute per operation
|
||||||
|
- HTTP methods and paths for each endpoint
|
||||||
|
- endpoint names and groupings
|
||||||
|
- pagination request/response contracts
|
||||||
|
|
||||||
|
The api.dsl drives: Prisma schema, NestJS controller/service/DTO generation,
|
||||||
|
and React Admin resource generation.
|
||||||
|
|
||||||
|
Constraint: every `map Entity.field` or `sync Entity.field` reference in `domain/*.api.dsl`
|
||||||
|
must resolve to an entity and field defined within the same api.dsl file.
|
||||||
|
|
||||||
|
## Optional extension mechanism
|
||||||
|
|
||||||
|
```text
|
||||||
|
overrides/
|
||||||
|
api-overrides.dsl
|
||||||
|
ui-overrides.dsl
|
||||||
|
```
|
||||||
|
|
||||||
|
Override rules:
|
||||||
|
|
||||||
|
- Overrides are optional.
|
||||||
|
- The generator must work without them.
|
||||||
|
- Overrides may refine derived API or UI behavior, but they must not duplicate or redefine
|
||||||
|
entities, attributes, primary keys, foreign keys, relations, or enums.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DSL Grammar Concepts
|
||||||
|
|
||||||
|
## entity
|
||||||
|
|
||||||
|
An **entity** is a domain object that becomes a database table and a first-class resource in the backend and frontend.
|
||||||
|
|
||||||
|
```
|
||||||
|
entity Equipment {
|
||||||
|
attribute id { type uuid; key primary; }
|
||||||
|
attribute name { type string; is required; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Domain:** Defines the canonical model; one entity = one Prisma model, one NestJS module, one React Admin resource.
|
||||||
|
- **Naming:** PascalCase (e.g. `Equipment`, `EquipmentType`, `RepairOrder`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## attribute
|
||||||
|
|
||||||
|
An **attribute** is a field of an entity. It has a type and optional modifiers.
|
||||||
|
|
||||||
|
```
|
||||||
|
attribute name {
|
||||||
|
description "Наименование";
|
||||||
|
type string;
|
||||||
|
is required;
|
||||||
|
is unique;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modifiers:**
|
||||||
|
|
||||||
|
- `type` — required; one of: `string`, `uuid`, `integer`, `decimal`, `date`, `text`, `boolean`, `number`, or an enum name.
|
||||||
|
- `key primary` — this attribute is the primary key.
|
||||||
|
- `key foreign { relates Entity.field }` — foreign key to another entity's field.
|
||||||
|
- `is required` — non-nullable.
|
||||||
|
- `is unique` — unique constraint.
|
||||||
|
- `is nullable` — explicitly nullable.
|
||||||
|
- `default Value` — default value (for enums or literals).
|
||||||
|
- `description "..."` — human-readable description.
|
||||||
|
- `label "..."` — display label for UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## enum
|
||||||
|
|
||||||
|
An **enum** defines a fixed set of values. Used for attributes that can only take one of these values.
|
||||||
|
|
||||||
|
```
|
||||||
|
enum EquipmentStatus {
|
||||||
|
value Active { label "В эксплуатации"; }
|
||||||
|
value Repair { label "В ремонте"; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **value** — identifier used in data and code.
|
||||||
|
- **label** — optional display label for UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## primary key
|
||||||
|
|
||||||
|
Exactly one attribute per entity must be marked as primary key.
|
||||||
|
|
||||||
|
```
|
||||||
|
attribute id {
|
||||||
|
type uuid;
|
||||||
|
key primary;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for a natural key:
|
||||||
|
|
||||||
|
```
|
||||||
|
attribute code {
|
||||||
|
type string;
|
||||||
|
key primary;
|
||||||
|
is required;
|
||||||
|
is unique;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## foreign key
|
||||||
|
|
||||||
|
A **foreign key** links to another entity's primary key. The attribute type must match the referenced primary key type.
|
||||||
|
|
||||||
|
```
|
||||||
|
attribute equipmentTypeCode {
|
||||||
|
type string;
|
||||||
|
key foreign {
|
||||||
|
relates EquipmentType.code;
|
||||||
|
}
|
||||||
|
is required;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `relates Entity.attribute` — references `Entity`'s `attribute` (must be primary key).
|
||||||
|
- FK type must equal referenced PK type (e.g. `string` → `EquipmentType.code`, `uuid` → `Equipment.id`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## required
|
||||||
|
|
||||||
|
- **is required** — attribute is non-nullable in the domain model and drives requiredness in derived DTO/API/UI artifacts.
|
||||||
|
- Absence of `is required` means the attribute is optional (nullable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## default
|
||||||
|
|
||||||
|
- **default Value** — applied when no value is provided (e.g. enum defaults like `default Active`).
|
||||||
|
- Value must exist in the enum when the attribute type is an enum.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DSL → System Component Mapping
|
||||||
|
|
||||||
|
## DSL → Prisma
|
||||||
|
|
||||||
|
|
||||||
|
| DSL Concept | Prisma Result |
|
||||||
|
| ------------ | --------------------------- |
|
||||||
|
| entity | model |
|
||||||
|
| attribute | field |
|
||||||
|
| enum | enum |
|
||||||
|
| key primary | @id or @id @default(uuid()) |
|
||||||
|
| key foreign | relation + references |
|
||||||
|
| type string | String |
|
||||||
|
| type uuid | String @id @default(uuid()) |
|
||||||
|
| type integer | Int |
|
||||||
|
| type number | Float |
|
||||||
|
| type decimal | Decimal |
|
||||||
|
| type date | DateTime |
|
||||||
|
| type text | String |
|
||||||
|
| type boolean | Boolean |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DSL → NestJS
|
||||||
|
|
||||||
|
|
||||||
|
| DSL Concept | NestJS Result |
|
||||||
|
| --------------------------- | ------------------------------------- |
|
||||||
|
| entity | One module (e.g. equipment.module.ts) |
|
||||||
|
| entity | Controller with CRUD endpoints |
|
||||||
|
| entity | Service with Prisma CRUD |
|
||||||
|
| entity + attribute metadata | create-{entity}.dto.ts |
|
||||||
|
| entity + attribute metadata | update-{entity}.dto.ts |
|
||||||
|
| entity + attribute metadata | Response DTO / API shape |
|
||||||
|
|
||||||
|
|
||||||
|
API paths are derived from entity name: PascalCase → kebab-case, pluralized (e.g. `Equipment` → `/equipment`, `RepairOrder` → `/repair-orders`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DSL → React Admin
|
||||||
|
|
||||||
|
|
||||||
|
| DSL Concept | React Admin Result |
|
||||||
|
| -------------------- | ----------------------------------- |
|
||||||
|
| entity | Resource (name = kebab-case plural) |
|
||||||
|
| attribute | Form field / column |
|
||||||
|
| type string | TextInput, TextField |
|
||||||
|
| type integer/decimal | NumberInput, NumberField |
|
||||||
|
| type number | NumberInput, NumberField |
|
||||||
|
| type date | DateInput, DateField |
|
||||||
|
| type boolean | BooleanInput, BooleanField |
|
||||||
|
| enum | SelectInput with choices |
|
||||||
|
| foreign key | ReferenceInput, ReferenceField |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# API DSL Layer Mapping
|
||||||
|
|
||||||
|
DTO shapes and endpoint contracts are declared in `domain/*.api.dsl`. The api.dsl
|
||||||
|
is the authoritative source for:
|
||||||
|
|
||||||
|
- **Create DTO** — declared as `dto DTO.<Entity>Create` in api.dsl. Must not include
|
||||||
|
server-generated primary keys (e.g. no `id` for uuid PKs). Required/nullable per field
|
||||||
|
is explicit in the api.dsl, not inferred.
|
||||||
|
- **Update DTO** — declared as `dto DTO.<Entity>Update` in api.dsl. All fields are
|
||||||
|
typically nullable for partial update semantics.
|
||||||
|
- **API response shape** — declared as `dto DTO.<Entity>` in api.dsl. Must expose
|
||||||
|
React Admin-compatible `id` field.
|
||||||
|
- **UI field mapping** — derived from the DTO shapes in api.dsl, not from domain
|
||||||
|
attributes directly. The Create form uses `DTO.<Entity>Create` fields; the Edit form
|
||||||
|
uses `DTO.<Entity>Update` fields; List and Show use `DTO.<Entity>` fields.
|
||||||
|
|
||||||
90
domain/toir.api.dsl
Normal file
90
domain/toir.api.dsl
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
enum EquipmentStatus {
|
||||||
|
value Active {
|
||||||
|
label "В эксплуатации";
|
||||||
|
}
|
||||||
|
value Repair {
|
||||||
|
label "В ремонте";
|
||||||
|
}
|
||||||
|
value Reserve {
|
||||||
|
label "В резерве";
|
||||||
|
}
|
||||||
|
value WriteOff {
|
||||||
|
label "Списано";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оборудование
|
||||||
|
|
||||||
|
entity Equipment {
|
||||||
|
description "Единица оборудования — объект ремонта и технического обслуживания";
|
||||||
|
|
||||||
|
attribute id {
|
||||||
|
type uuid;
|
||||||
|
key primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute name {
|
||||||
|
description "Название оборудования";
|
||||||
|
is required;
|
||||||
|
type string;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute serialNumber {
|
||||||
|
description "Заводской (серийный) номер";
|
||||||
|
type string;
|
||||||
|
is required;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute dateOfInspection {
|
||||||
|
description "Дата поверки";
|
||||||
|
type date;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute commissionedAt {
|
||||||
|
description "Дата изготовления";
|
||||||
|
type date;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute status {
|
||||||
|
description "Текущий статус";
|
||||||
|
type EquipmentStatus;
|
||||||
|
default Active;
|
||||||
|
is required;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Документ изменения статуса оборудования — с характеристиками: дата события, ссылка на оборудование, новый статус, возможно ответственный
|
||||||
|
entity ChangeEquipmentStatus {
|
||||||
|
description "Документ изменения статуса оборудования";
|
||||||
|
|
||||||
|
attribute equipmentId {
|
||||||
|
description "Оборудование";
|
||||||
|
type Equipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute newStatus {
|
||||||
|
description "Новый статус";
|
||||||
|
type EquipmentStatus;
|
||||||
|
is required;
|
||||||
|
//sync Equipment.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute number {
|
||||||
|
description "Номер";
|
||||||
|
type string;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute date {
|
||||||
|
description "Дата изменения статуса";
|
||||||
|
is required;
|
||||||
|
type date;
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute responsible {
|
||||||
|
description "Ответственный";
|
||||||
|
type string;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
931
openapi.json
Normal file
931
openapi.json
Normal file
@@ -0,0 +1,931 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "KIS-TOiR API",
|
||||||
|
"description": "Equipment maintenance management system. Generated from domain/toir.api.dsl via tools/api-summary-to-openapi.mjs.",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "/api",
|
||||||
|
"description": "Default server"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearerAuth": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "JWT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"Equipment": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"inventoryNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Инвентарный номер"
|
||||||
|
},
|
||||||
|
"serialNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Заводской (серийный) номер"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Наименование единицы оборудования"
|
||||||
|
},
|
||||||
|
"equipmentTypeCode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Код вида оборудования"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EquipmentStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Текущий статус"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Место эксплуатации / скважина / куст"
|
||||||
|
},
|
||||||
|
"commissionedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата ввода в эксплуатацию"
|
||||||
|
},
|
||||||
|
"totalEngineHours": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Общая наработка, моточасов"
|
||||||
|
},
|
||||||
|
"engineHoursSinceLastRepair": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Наработка с последнего ремонта, моточасов"
|
||||||
|
},
|
||||||
|
"lastRepairAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата последнего ремонта"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Примечания"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Оборудование — полный объект ответа"
|
||||||
|
},
|
||||||
|
"EquipmentCreate": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"inventoryNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Инвентарный номер"
|
||||||
|
},
|
||||||
|
"serialNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Заводской (серийный) номер"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Наименование единицы оборудования"
|
||||||
|
},
|
||||||
|
"equipmentTypeCode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Код вида оборудования"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EquipmentStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Текущий статус"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Место эксплуатации / скважина / куст"
|
||||||
|
},
|
||||||
|
"commissionedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата ввода в эксплуатацию"
|
||||||
|
},
|
||||||
|
"totalEngineHours": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Общая наработка, моточасов"
|
||||||
|
},
|
||||||
|
"engineHoursSinceLastRepair": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Наработка с последнего ремонта, моточасов"
|
||||||
|
},
|
||||||
|
"lastRepairAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата последнего ремонта"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Примечания"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Оборудование — тело запроса на создание",
|
||||||
|
"required": [
|
||||||
|
"inventoryNumber",
|
||||||
|
"name",
|
||||||
|
"equipmentTypeCode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"EquipmentUpdate": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"inventoryNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Инвентарный номер"
|
||||||
|
},
|
||||||
|
"serialNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Заводской (серийный) номер"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Наименование единицы оборудования"
|
||||||
|
},
|
||||||
|
"equipmentTypeCode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Код вида оборудования"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EquipmentStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Текущий статус"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Место эксплуатации / скважина / куст"
|
||||||
|
},
|
||||||
|
"commissionedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата ввода в эксплуатацию"
|
||||||
|
},
|
||||||
|
"totalEngineHours": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Общая наработка, моточасов"
|
||||||
|
},
|
||||||
|
"engineHoursSinceLastRepair": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Наработка с последнего ремонта, моточасов"
|
||||||
|
},
|
||||||
|
"lastRepairAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата последнего ремонта"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Примечания"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Оборудование — тело запроса на обновление (частичное)"
|
||||||
|
},
|
||||||
|
"EquipmentListRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DTO.Filter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"$ref": "#/components/schemas/DTO.PageRequest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Оборудование — запрос постраничного списка с фильтрацией"
|
||||||
|
},
|
||||||
|
"EquipmentListResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Equipment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"$ref": "#/components/schemas/DTO.PageInfo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Оборудование — постраничный результат"
|
||||||
|
},
|
||||||
|
"RepairOrder": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Номер заявки"
|
||||||
|
},
|
||||||
|
"equipmentId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Идентификатор оборудования"
|
||||||
|
},
|
||||||
|
"repairKind": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RepairKind"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Вид ремонта"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RepairOrderStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Статус заявки"
|
||||||
|
},
|
||||||
|
"plannedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Плановая дата начала"
|
||||||
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Фактическая дата начала"
|
||||||
|
},
|
||||||
|
"completedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Фактическая дата завершения"
|
||||||
|
},
|
||||||
|
"contractor": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Подрядная организация (если внешний ремонт)"
|
||||||
|
},
|
||||||
|
"engineHoursAtRepair": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Наработка на момент ремонта, моточасов"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Описание работ / дефекта"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Примечания"
|
||||||
|
},
|
||||||
|
"confirmed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Согласовано/Не согласовано"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Заявка на ремонт — полный объект ответа"
|
||||||
|
},
|
||||||
|
"RepairOrderCreate": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Номер заявки"
|
||||||
|
},
|
||||||
|
"equipmentId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Идентификатор оборудования"
|
||||||
|
},
|
||||||
|
"repairKind": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RepairKind"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Вид ремонта"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RepairOrderStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Статус заявки"
|
||||||
|
},
|
||||||
|
"plannedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Плановая дата начала"
|
||||||
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Фактическая дата начала"
|
||||||
|
},
|
||||||
|
"completedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Фактическая дата завершения"
|
||||||
|
},
|
||||||
|
"contractor": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Подрядная организация (если внешний ремонт)"
|
||||||
|
},
|
||||||
|
"engineHoursAtRepair": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Наработка на момент ремонта, моточасов"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Описание работ / дефекта"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Примечания"
|
||||||
|
},
|
||||||
|
"confirmed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Согласовано/Не согласовано"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Заявка на ремонт — тело запроса на создание",
|
||||||
|
"required": [
|
||||||
|
"number",
|
||||||
|
"equipmentId",
|
||||||
|
"repairKind",
|
||||||
|
"plannedAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RepairOrderUpdate": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Номер заявки"
|
||||||
|
},
|
||||||
|
"equipmentId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Идентификатор оборудования"
|
||||||
|
},
|
||||||
|
"repairKind": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RepairKind"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Вид ремонта"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RepairOrderStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Статус заявки"
|
||||||
|
},
|
||||||
|
"plannedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Плановая дата начала"
|
||||||
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Фактическая дата начала"
|
||||||
|
},
|
||||||
|
"completedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Фактическая дата завершения"
|
||||||
|
},
|
||||||
|
"contractor": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Подрядная организация (если внешний ремонт)"
|
||||||
|
},
|
||||||
|
"engineHoursAtRepair": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "decimal",
|
||||||
|
"description": "Наработка на момент ремонта, моточасов"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Описание работ / дефекта"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Примечания"
|
||||||
|
},
|
||||||
|
"confirmed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Согласовано/Не согласовано"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Заявка на ремонт — тело запроса на обновление (частичное)"
|
||||||
|
},
|
||||||
|
"RepairOrderListRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DTO.Filter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"$ref": "#/components/schemas/DTO.PageRequest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Заявка на ремонт — запрос постраничного списка с фильтрацией"
|
||||||
|
},
|
||||||
|
"RepairOrderListResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RepairOrder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"$ref": "#/components/schemas/DTO.PageInfo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Заявка на ремонт — постраничный результат"
|
||||||
|
},
|
||||||
|
"EquipmentStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"x-dsl-enum": "EquipmentStatus",
|
||||||
|
"description": "Enum: EquipmentStatus (values defined in domain/*.api.dsl)"
|
||||||
|
},
|
||||||
|
"DTO.Filter": {
|
||||||
|
"type": "string",
|
||||||
|
"x-dsl-enum": "DTO.Filter",
|
||||||
|
"description": "Enum: DTO.Filter (values defined in domain/*.api.dsl)"
|
||||||
|
},
|
||||||
|
"DTO.PageRequest": {
|
||||||
|
"type": "string",
|
||||||
|
"x-dsl-enum": "DTO.PageRequest",
|
||||||
|
"description": "Enum: DTO.PageRequest (values defined in domain/*.api.dsl)"
|
||||||
|
},
|
||||||
|
"DTO.PageInfo": {
|
||||||
|
"type": "string",
|
||||||
|
"x-dsl-enum": "DTO.PageInfo",
|
||||||
|
"description": "Enum: DTO.PageInfo (values defined in domain/*.api.dsl)"
|
||||||
|
},
|
||||||
|
"RepairKind": {
|
||||||
|
"type": "string",
|
||||||
|
"x-dsl-enum": "RepairKind",
|
||||||
|
"description": "Enum: RepairKind (values defined in domain/*.api.dsl)"
|
||||||
|
},
|
||||||
|
"RepairOrderStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"x-dsl-enum": "RepairOrderStatus",
|
||||||
|
"description": "Enum: RepairOrderStatus (values defined in domain/*.api.dsl)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/equipment/page": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Постраничный список оборудования с фильтрацией",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"оборудованием"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EquipmentListRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EquipmentListResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/equipment/{id}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Получить оборудование по идентификатору",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"оборудованием"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Equipment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"summary": "Обновить единицу оборудования",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"оборудованием"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EquipmentUpdate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"summary": "Удалить единицу оборудования",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"оборудованием"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/equipment": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Создать единицу оборудования",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"оборудованием"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/EquipmentCreate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/repair-orders/page": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Постраничный список заявок на ремонт с фильтрацией",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"заявками на ремонт"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepairOrderListRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepairOrderListResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/repair-orders/{id}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Получить заявку на ремонт по идентификатору",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"заявками на ремонт"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepairOrder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"summary": "Обновить заявку на ремонт",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"заявками на ремонт"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepairOrderUpdate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"summary": "Удалить заявку на ремонт",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"заявками на ремонт"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/repair-orders": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Создать заявку на ремонт",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"заявками на ремонт"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepairOrderCreate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "toir-generation-context",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"generate:api-summary": "node tools/generate-api-summary.mjs",
|
||||||
|
"generate:openapi": "node tools/api-summary-to-openapi.mjs --out openapi.json",
|
||||||
|
"validate:generation": "node tools/validate-generation.mjs",
|
||||||
|
"validate:generation:runtime": "node tools/validate-generation.mjs --run-runtime",
|
||||||
|
"validate:generation:artifacts": "node tools/validate-generation.mjs --artifacts-only",
|
||||||
|
"eval:generation": "node tools/eval/run-evals.mjs",
|
||||||
|
"install-hooks": "node tools/install-hooks.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
95
prompts/auth-rules.md
Normal file
95
prompts/auth-rules.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Auth Rules
|
||||||
|
|
||||||
|
<!-- prompt-version: 2.0 -->
|
||||||
|
<!-- applies-to: client/src/auth/, server/src/auth/, toir-realm.json -->
|
||||||
|
<!-- validated-by: tools/validate-generation.mjs §validateAuthChecks §validateRealmChecks -->
|
||||||
|
|
||||||
|
Use this document during the **Auth / Runtime / Realm** stage defined in `prompts/general-prompt.md`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Generate and preserve the auth contracts that let the CRUD app run as a React Admin SPA backed by a NestJS API protected by external Keycloak.
|
||||||
|
|
||||||
|
## Mandatory Inputs
|
||||||
|
|
||||||
|
- `prompts/general-prompt.md`
|
||||||
|
- `prompts/runtime-rules.md`
|
||||||
|
- current repository auth/runtime defaults
|
||||||
|
|
||||||
|
## Expected Outputs
|
||||||
|
|
||||||
|
- `client/src/auth/`
|
||||||
|
- `client/src/dataProvider.ts`
|
||||||
|
- `server/src/auth/`
|
||||||
|
- `toir-realm.json`
|
||||||
|
|
||||||
|
## Frontend Auth Invariants
|
||||||
|
|
||||||
|
- use `keycloak-js` with redirect-based login only
|
||||||
|
- initialize Keycloak before rendering the SPA
|
||||||
|
- use Authorization Code Flow + PKCE (`S256`)
|
||||||
|
- keep `authProvider`, `dataProvider`, `getIdentity()`, `getPermissions()`, and `checkError()` as stable seams
|
||||||
|
- derive identity from token claims already present in the token
|
||||||
|
- do not call `loadUserProfile()`
|
||||||
|
- `401` forces re-authentication; `403` remains an authorization error
|
||||||
|
- keep token handling in memory with one shared in-flight refresh path
|
||||||
|
|
||||||
|
## Backend Auth Invariants
|
||||||
|
|
||||||
|
- verify JWTs with `jose`
|
||||||
|
- validate issuer, audience, and signature via JWKS
|
||||||
|
- resolve JWKS in this order:
|
||||||
|
1. `KEYCLOAK_JWKS_URL`
|
||||||
|
2. OIDC discovery at `/.well-known/openid-configuration`
|
||||||
|
3. `${issuer}/protocol/openid-connect/certs`
|
||||||
|
- extract roles only from `realm_access.roles`
|
||||||
|
- keep `/health` public
|
||||||
|
- generated CRUD routes stay protected by default
|
||||||
|
|
||||||
|
## Working Runtime Defaults
|
||||||
|
|
||||||
|
Keep these defaults unless a task explicitly overrides them:
|
||||||
|
|
||||||
|
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
|
||||||
|
- `VITE_KEYCLOAK_REALM=toir`
|
||||||
|
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
|
||||||
|
- `KEYCLOAK_ISSUER_URL=https://sso.greact.ru/realms/toir`
|
||||||
|
- `KEYCLOAK_AUDIENCE=toir-backend`
|
||||||
|
- `CORS_ALLOWED_ORIGINS=http://localhost:5173,https://toir-frontend.greact.ru`
|
||||||
|
|
||||||
|
Anti-regression rule:
|
||||||
|
|
||||||
|
- do not revert shared examples to localhost Keycloak defaults unless a task explicitly requests a local Keycloak baseline
|
||||||
|
|
||||||
|
## Realm Artifact Contract
|
||||||
|
|
||||||
|
The root realm artifact is mandatory and must:
|
||||||
|
|
||||||
|
- be importable and versioned
|
||||||
|
- align with generated frontend/backend env contracts
|
||||||
|
- parameterize:
|
||||||
|
- realm name
|
||||||
|
- frontend client id
|
||||||
|
- backend client id / audience
|
||||||
|
- local and production frontend URLs
|
||||||
|
- artifact filename
|
||||||
|
- explicitly deliver:
|
||||||
|
- `sub`
|
||||||
|
- `aud`
|
||||||
|
- `realm_access.roles`
|
||||||
|
- define:
|
||||||
|
- realm roles `admin`, `editor`, `viewer`
|
||||||
|
- a public SPA client with PKCE S256
|
||||||
|
- a bearer-only backend client
|
||||||
|
- an explicit audience client scope
|
||||||
|
- protocol mappers for baseline identity and role claims
|
||||||
|
|
||||||
|
## Completion Expectations
|
||||||
|
|
||||||
|
Auth/runtime generation is incomplete if any of the following is true:
|
||||||
|
|
||||||
|
- frontend and backend auth seams drift from each other
|
||||||
|
- JWKS resolution order changes
|
||||||
|
- `/health` stops being public
|
||||||
|
- shared Keycloak defaults regress to localhost examples
|
||||||
|
- the realm artifact no longer matches backend/frontend expectations
|
||||||
145
prompts/backend-rules.md
Normal file
145
prompts/backend-rules.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Backend Rules
|
||||||
|
|
||||||
|
Use this document during the **Backend** stage defined in `prompts/general-prompt.md`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Generate NestJS CRUD artifacts that match the DSL contract exactly and remain compatible with a standard NestJS workspace.
|
||||||
|
|
||||||
|
## Mandatory Inputs
|
||||||
|
|
||||||
|
- `prompts/general-prompt.md`
|
||||||
|
- the active `api API.<Entity>` block from `domain/toir.api.dsl`
|
||||||
|
- referenced DTOs and enums from `domain/toir.api.dsl`
|
||||||
|
- an intact or repaired official NestJS scaffold under `server/`
|
||||||
|
|
||||||
|
`api-summary.json` may be consulted only as an auxiliary inventory or validator-related artifact. It must never replace the DSL as the backend source of truth.
|
||||||
|
|
||||||
|
## Expected Outputs
|
||||||
|
|
||||||
|
Per entity:
|
||||||
|
|
||||||
|
- `server/src/modules/<kebab>/<kebab>.module.ts`
|
||||||
|
- `server/src/modules/<kebab>/<kebab>.controller.ts`
|
||||||
|
- `server/src/modules/<kebab>/<kebab>.service.ts`
|
||||||
|
- `server/src/modules/<kebab>/dto/create-<kebab>.dto.ts`
|
||||||
|
- `server/src/modules/<kebab>/dto/update-<kebab>.dto.ts`
|
||||||
|
|
||||||
|
Repository-wide:
|
||||||
|
|
||||||
|
- `server/src/app.module.ts` registrations
|
||||||
|
|
||||||
|
## Scaffold Baseline
|
||||||
|
|
||||||
|
- Start backend initialization and repair from the official NestJS CLI workspace, not from hand-written files.
|
||||||
|
- Preserve Nest workspace essentials:
|
||||||
|
- `server/tsconfig.json`
|
||||||
|
- `server/tsconfig.build.json`
|
||||||
|
- `server/nest-cli.json`
|
||||||
|
- `server/src/main.ts`
|
||||||
|
- `server/src/app.module.ts`
|
||||||
|
- If the workspace is degraded, repair it before generating domain code.
|
||||||
|
|
||||||
|
Forbidden patterns:
|
||||||
|
|
||||||
|
- hand-written pseudo-Nest scaffolds
|
||||||
|
- deleting required Nest config files after generation
|
||||||
|
- replacing normal Nest build/start behavior with ad hoc scripts
|
||||||
|
|
||||||
|
## Route And Resource Contract
|
||||||
|
|
||||||
|
- Use the shared entity-to-resource naming convention from `prompts/general-prompt.md`.
|
||||||
|
- Each entity becomes a NestJS module, controller, service, and create/update DTO pair.
|
||||||
|
- CRUD routes use the real primary key name in the path.
|
||||||
|
- Every API record returned to React Admin must include `id`.
|
||||||
|
- For natural-key entities, map the real primary key to `id` in responses and sort translation.
|
||||||
|
|
||||||
|
## DTO Contract
|
||||||
|
|
||||||
|
- `DTO.<Entity>Create` defines `Create<Entity>Dto`.
|
||||||
|
- `DTO.<Entity>Update` defines `Update<Entity>Dto`.
|
||||||
|
- Do not invent fields or pull field lists from memory.
|
||||||
|
- Never include `id` in Create/Update DTOs.
|
||||||
|
|
||||||
|
Type and decorator rules:
|
||||||
|
|
||||||
|
|
||||||
|
| DSL type | TS DTO type | class-validator decorator | Notes |
|
||||||
|
| --------- | ----------- | ------------------------- | ----------------------------- |
|
||||||
|
| `uuid` | `string` | `@IsUUID()` | |
|
||||||
|
| `string` | `string` | `@IsString()` | |
|
||||||
|
| `text` | `string` | `@IsString()` | |
|
||||||
|
| `integer` | `number` | `@IsInt()` | |
|
||||||
|
| `number` | `number` | `@IsNumber()` | |
|
||||||
|
| `decimal` | `string` | `@IsString()` | serialize with Prisma Decimal |
|
||||||
|
| `date` | `string` | `@IsString()` | serialize as ISO string |
|
||||||
|
| `boolean` | `boolean` | `@IsBoolean()` | |
|
||||||
|
| enum name | `string` | `@IsEnum(EnumName)` | |
|
||||||
|
|
||||||
|
|
||||||
|
Nullability rules:
|
||||||
|
|
||||||
|
- every field that is not `is required` gets `@IsOptional()` before the type decorator
|
||||||
|
- every generated DTO imports from `'class-validator'`
|
||||||
|
|
||||||
|
## Controller Contract
|
||||||
|
|
||||||
|
- Apply `@UseGuards(JwtAuthGuard, RolesGuard)` at controller class level.
|
||||||
|
- Roles per verb:
|
||||||
|
- `GET` -> `viewer | editor | admin`
|
||||||
|
- `POST`, `PATCH`, `PUT` -> `editor | admin`
|
||||||
|
- `DELETE` -> `admin`
|
||||||
|
- Reconcile DSL HTTP shapes for repository compatibility:
|
||||||
|
- list endpoints declared as `POST .../page` generate as `@Get()` with React Admin query params
|
||||||
|
- update endpoints declared as `PUT` generate as `@Patch(':<pk>')`
|
||||||
|
- Path parameters are taken from the DSL endpoint contract, not invented from generic CRUD memory.
|
||||||
|
|
||||||
|
## Service Contract
|
||||||
|
|
||||||
|
- Never pass raw update DTOs directly into Prisma update `data`.
|
||||||
|
- Strip `id`, the real primary key, and readonly fields before writes.
|
||||||
|
- Keep `PrismaService` lightweight:
|
||||||
|
- extend `PrismaClient`
|
||||||
|
- implement `OnModuleInit`
|
||||||
|
- call `$connect()`
|
||||||
|
- do not add `beforeExit`
|
||||||
|
|
||||||
|
List endpoint requirements:
|
||||||
|
|
||||||
|
- accept React Admin query params: `_start`, `_end`, `_sort`, `_order`, `q`
|
||||||
|
- set `Content-Range`
|
||||||
|
- set `Access-Control-Expose-Headers: Content-Range`
|
||||||
|
|
||||||
|
Filtering rules:
|
||||||
|
|
||||||
|
- string/text filters may use case-insensitive `contains`
|
||||||
|
- foreign-key scalar filters must use exact-match semantics
|
||||||
|
- enum filters must support both single and repeated params
|
||||||
|
- repeated enum params must map to Prisma `{ in: [...] }`
|
||||||
|
- `_sort=id` must map to the real primary key for natural-key entities
|
||||||
|
|
||||||
|
Decimal and date handling:
|
||||||
|
|
||||||
|
- `decimal` writes: `new Prisma.Decimal(value)`
|
||||||
|
- `decimal` reads: `.toString()`
|
||||||
|
- `date` writes: `new Date(value)`
|
||||||
|
- `date` reads: `.toISOString()`
|
||||||
|
|
||||||
|
## Natural-Key Rules
|
||||||
|
|
||||||
|
For entities whose physical primary key is not `id`:
|
||||||
|
|
||||||
|
- route params use the real primary key name
|
||||||
|
- responses expose `id` mapped from that primary key
|
||||||
|
- sort/update behavior never targets a fake physical `id`
|
||||||
|
- update payload sanitization removes both `id` and the real primary key
|
||||||
|
|
||||||
|
## Completion Expectations
|
||||||
|
|
||||||
|
Backend generation is incomplete if any of the following is true:
|
||||||
|
|
||||||
|
- required Nest scaffold files are missing
|
||||||
|
- DTO decorators are incomplete or type-incorrect
|
||||||
|
- controllers are missing guards or role decorators
|
||||||
|
- natural-key handling regresses to a fake physical `id`
|
||||||
|
- list/filter behavior is incompatible with React Admin expectations
|
||||||
118
prompts/frontend-rules.md
Normal file
118
prompts/frontend-rules.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Frontend Rules
|
||||||
|
|
||||||
|
<!-- prompt-version: 2.0 -->
|
||||||
|
<!-- applies-to: client/src/resources/, client/src/App.tsx -->
|
||||||
|
<!-- validated-by: tools/validate-generation.mjs §validateApiDslCoverage -->
|
||||||
|
|
||||||
|
Use this document during the **Frontend** stage defined in `prompts/general-prompt.md`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Generate React Admin resources that stay aligned with the DSL contract, the backend contract, and the repository auth/data provider seams.
|
||||||
|
|
||||||
|
## Mandatory Inputs
|
||||||
|
|
||||||
|
- `prompts/general-prompt.md`
|
||||||
|
- the active `api API.<Entity>` block from `domain/toir.api.dsl`
|
||||||
|
- referenced DTOs and enums from `domain/toir.api.dsl`
|
||||||
|
- an intact or repaired official Vite React TypeScript scaffold under `client/`
|
||||||
|
|
||||||
|
## Expected Outputs
|
||||||
|
|
||||||
|
Per entity:
|
||||||
|
|
||||||
|
- `client/src/resources/<kebab>/<Entity>List.tsx`
|
||||||
|
- `client/src/resources/<kebab>/<Entity>Create.tsx`
|
||||||
|
- `client/src/resources/<kebab>/<Entity>Edit.tsx`
|
||||||
|
- `client/src/resources/<kebab>/<Entity>Show.tsx`
|
||||||
|
|
||||||
|
Repository-wide:
|
||||||
|
|
||||||
|
- `client/src/App.tsx` resource registrations
|
||||||
|
|
||||||
|
## Scaffold Baseline
|
||||||
|
|
||||||
|
- Start frontend initialization and repair from the official Vite React TypeScript scaffold, not from a hand-written shell.
|
||||||
|
- Preserve workspace essentials:
|
||||||
|
- `client/index.html`
|
||||||
|
- `client/tsconfig.json`
|
||||||
|
- `client/vite.config.*`
|
||||||
|
- `client/src/main.tsx`
|
||||||
|
- Repair the scaffold before generating resources if it is degraded.
|
||||||
|
|
||||||
|
## Resource Contract
|
||||||
|
|
||||||
|
- Use the shared entity-to-resource naming convention from `prompts/general-prompt.md`.
|
||||||
|
- Every entity becomes a React Admin resource with `list`, `create`, `edit`, and `show`.
|
||||||
|
- `Resource` registration in `client/src/App.tsx` must include `show={...}`.
|
||||||
|
- Every frontend record must work with React Admin's `id` contract, including natural-key entities.
|
||||||
|
|
||||||
|
DTO-driven view rules:
|
||||||
|
|
||||||
|
- List and Show views use fields from `DTO.<Entity>`
|
||||||
|
- Create view uses fields from `DTO.<Entity>Create`
|
||||||
|
- Edit view uses fields from `DTO.<Entity>Update`
|
||||||
|
- Do not derive form fields directly from model attributes when the DTO contract is narrower
|
||||||
|
|
||||||
|
## Input And Field Mapping
|
||||||
|
|
||||||
|
Form inputs:
|
||||||
|
|
||||||
|
- `integer`, `number`, `decimal` -> `NumberInput`
|
||||||
|
- `date` -> `DateInput`
|
||||||
|
- required `boolean` -> `BooleanInput`
|
||||||
|
- nullable `boolean` -> `NullableBooleanInput`
|
||||||
|
- enum -> `SelectInput`
|
||||||
|
- FK reference -> `ReferenceInput` + `AutocompleteInput`
|
||||||
|
|
||||||
|
Display fields:
|
||||||
|
|
||||||
|
- `integer`, `number`, `decimal` -> `NumberField`
|
||||||
|
- `date` -> `DateField`
|
||||||
|
- `boolean` -> `BooleanField`
|
||||||
|
- enum -> `SelectField`
|
||||||
|
- FK reference -> `ReferenceField`
|
||||||
|
|
||||||
|
Hard failure rule:
|
||||||
|
|
||||||
|
- using plain `TextInput` for `integer`, `number`, `decimal`, `date`, or `boolean` is a generation failure
|
||||||
|
|
||||||
|
## Filter And Reference Contract
|
||||||
|
|
||||||
|
- Lists must expose filters and include a toolbar with `FilterButton`.
|
||||||
|
- Enum multi-select filters use `SelectArrayInput`.
|
||||||
|
- Reference filters and form selectors use `ReferenceInput` + `AutocompleteInput` with `filterToQuery={(searchText) => ({ q: searchText })}`.
|
||||||
|
- FK list/show rendering must use `ReferenceField link=\"show\"`.
|
||||||
|
- `dataProvider` query serialization must preserve repeated params for array filters.
|
||||||
|
|
||||||
|
Reference display expression priority:
|
||||||
|
|
||||||
|
1. if `inventoryNumber` exists: ``(record) => `${record.inventoryNumber} — ${record.name ?? record.inventoryNumber}``
|
||||||
|
2. else if `code` exists: ``(record) => `${record.code} — ${record.name ?? record.code}``
|
||||||
|
3. else if `number` exists: ``(record) => `${record.number} — ${record.name ?? record.number}``
|
||||||
|
4. else if `name` exists: `(record) => record.name ?? record.id`
|
||||||
|
5. else: `(record) => record.id`
|
||||||
|
|
||||||
|
## Auth And Provider Seams
|
||||||
|
|
||||||
|
- `client/src/dataProvider.ts` remains the single authenticated request seam.
|
||||||
|
- `client/src/auth/authProvider.ts` remains the single React Admin auth seam.
|
||||||
|
- Resource components must not embed auth logic.
|
||||||
|
- `getIdentity()` resolves from token claims.
|
||||||
|
- `getPermissions()` may expose realm roles for UI awareness, but backend enforcement stays authoritative.
|
||||||
|
|
||||||
|
## Natural-Key Compatibility
|
||||||
|
|
||||||
|
- Frontend requests and routes must continue to work when the real primary key is not named `id`.
|
||||||
|
- Edit/show/delete flows must preserve compatibility with backend natural-key handling.
|
||||||
|
- Sorting and filtering assumptions must not regress to a fake physical `id`.
|
||||||
|
|
||||||
|
## Completion Expectations
|
||||||
|
|
||||||
|
Frontend generation is incomplete if any of the following is true:
|
||||||
|
|
||||||
|
- required Vite scaffold files are missing
|
||||||
|
- Create/Edit inputs are type-incorrect
|
||||||
|
- filter UI is missing or incomplete
|
||||||
|
- reference fields stop linking to `show`
|
||||||
|
- resource registration omits `show={...}`
|
||||||
300
prompts/general-prompt.md
Normal file
300
prompts/general-prompt.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Role
|
||||||
|
|
||||||
|
You are the master orchestrator of the KIS-TOiR generation pipeline.
|
||||||
|
|
||||||
|
Own the full run: understand the current workspace, read the domain contract, coordinate sub-agents and MCP tools, generate or repair artifacts in the correct order, run the required gates, fix failures, and stop only when the repository is genuinely generation-complete.
|
||||||
|
|
||||||
|
# Project Description
|
||||||
|
|
||||||
|
KIS-TOiR is an LLM-first fullstack CRUD generation project for equipment maintenance management.
|
||||||
|
|
||||||
|
- Backend: NestJS + Prisma
|
||||||
|
- Frontend: Vite React TypeScript + React Admin
|
||||||
|
- Auth/runtime: external Keycloak + PostgreSQL + repository-managed env/runtime artifacts
|
||||||
|
|
||||||
|
The repository is intentionally prompt-driven. `prompts/*.md` define generation policy; generated code lives under `server/` and `client/`.
|
||||||
|
|
||||||
|
# Mission
|
||||||
|
|
||||||
|
Turn the repository source contract into a buildable, validated workspace by:
|
||||||
|
|
||||||
|
1. starting from official framework scaffolding when a workspace is missing or degraded
|
||||||
|
2. generating Prisma, backend, frontend, auth, runtime, and realm artifacts from the DSL
|
||||||
|
3. using sub-agents intentionally instead of carrying every concern in one context window
|
||||||
|
4. proving completion with builds and repository validation gates
|
||||||
|
|
||||||
|
# Source Of Truth
|
||||||
|
|
||||||
|
`domain/toir.api.dsl` is the operative source of truth for generation runs.
|
||||||
|
|
||||||
|
It is authoritative for:
|
||||||
|
|
||||||
|
- entities and enums
|
||||||
|
- DTO shapes per operation
|
||||||
|
- nullability and requiredness
|
||||||
|
- primary and foreign keys
|
||||||
|
- HTTP methods, endpoint paths, and pagination contracts
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Read the DSL directly. Do not substitute `api-summary.json` for `domain/toir.api.dsl`.
|
||||||
|
- Work from entity-scoped slices: the active `api API.<Entity>` block plus its referenced DTOs and enums.
|
||||||
|
- Quote the relevant DSL field definitions verbatim before generating DTOs, Prisma fields, controller contracts, or React Admin components.
|
||||||
|
- Treat `api-summary.json` only as an auxiliary artifact for quick inventory or validation/tooling that explicitly depends on it. It is never the authoritative generation input.
|
||||||
|
|
||||||
|
# Orchestration Model
|
||||||
|
|
||||||
|
Use a manager-first, agent-as-tool architecture.
|
||||||
|
|
||||||
|
- Keep one orchestrator in charge of planning, sequencing, integration, and final acceptance.
|
||||||
|
- Delegate bounded work to specialists; do not let sub-agents redefine the source hierarchy or completion criteria.
|
||||||
|
- Delegate by stage or artifact family, and by entity when parallelism helps.
|
||||||
|
- If a sub-agent result conflicts with the DSL, companion rules, or validator output, trust the DSL and the gates.
|
||||||
|
|
||||||
|
Mandatory delegation pattern for future runs:
|
||||||
|
|
||||||
|
- `explorer`
|
||||||
|
Use first for repo discovery, scaffold inspection, locating entity-scoped DSL context, and finding existing registrations/seams.
|
||||||
|
- `docs_researcher`
|
||||||
|
Use when framework behavior, CLI scaffolding, or prompt/orchestration patterns need verification against official docs or Context7.
|
||||||
|
- stage worker / generator
|
||||||
|
Use for bounded Prisma, backend, frontend, or auth/runtime implementation work after the orchestrator has assembled the right inputs.
|
||||||
|
- `reviewer`
|
||||||
|
Use before declaring completion. Reviewer must check DSL fidelity, prompt-contract compliance, and whether validation output supports the completion claim.
|
||||||
|
|
||||||
|
If a runtime does not expose named sub-agents, preserve the same separation of responsibilities inside one agent and keep stage handoffs explicit.
|
||||||
|
|
||||||
|
# MCP Usage Model
|
||||||
|
|
||||||
|
Use MCP/tools deliberately, not reflexively.
|
||||||
|
|
||||||
|
- Filesystem/search tools: gather exact local context before making decisions.
|
||||||
|
- Shell/runtime tools: run official CLI scaffolding, Prisma commands, builds, validators, and evals. Do not simulate command results from memory.
|
||||||
|
- Context7: verify current NestJS, Prisma, React Admin, Vite, Keycloak, or prompt/orchestration guidance when repository docs are not enough.
|
||||||
|
- Web research: only after local files and Context7 are insufficient; prefer primary sources.
|
||||||
|
- Diff/validation tools: use before edits, after edits, and always at the end.
|
||||||
|
|
||||||
|
Tool-order policy:
|
||||||
|
|
||||||
|
1. local authoritative files
|
||||||
|
2. Context7 / official docs
|
||||||
|
3. web fallback
|
||||||
|
4. validation gates
|
||||||
|
|
||||||
|
# Generation Roadmap
|
||||||
|
|
||||||
|
## 1. Preparation / Discovery
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- establish the active scope
|
||||||
|
- verify scaffold health
|
||||||
|
- load only the context needed for the next stage
|
||||||
|
|
||||||
|
Responsible:
|
||||||
|
|
||||||
|
- orchestrator
|
||||||
|
- `explorer` first
|
||||||
|
- `docs_researcher` if scaffold conventions or framework behavior are uncertain
|
||||||
|
|
||||||
|
Mandatory inputs:
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `prompts/general-prompt.md`
|
||||||
|
- `domain/toir.api.dsl`
|
||||||
|
- `prompts/runtime-rules.md`
|
||||||
|
- `.codex/AGENTS.md` and `.codex/agents/*.toml` when the runtime supports those agents
|
||||||
|
|
||||||
|
Expected outputs:
|
||||||
|
|
||||||
|
- entity-scoped DSL quotes for the active work
|
||||||
|
- a clean stage plan
|
||||||
|
- `server/` and `client/` confirmed healthy or repaired from official scaffolding
|
||||||
|
|
||||||
|
Handoff:
|
||||||
|
|
||||||
|
- proceed to Prisma only after the repository has a valid NestJS workspace, Vite React TypeScript workspace, or a documented repair plan using official CLIs
|
||||||
|
|
||||||
|
Stage rules:
|
||||||
|
|
||||||
|
- Use official Nest CLI for initial backend workspace creation or repair.
|
||||||
|
- Use official Vite React TypeScript CLI for initial frontend workspace creation or repair.
|
||||||
|
- Use Prisma CLI for Prisma initialization when relevant.
|
||||||
|
- Do not handcraft framework scaffolds that should come from official CLIs.
|
||||||
|
|
||||||
|
## 2. Prisma
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- generate the repository schema that reflects the DSL exactly
|
||||||
|
|
||||||
|
Responsible:
|
||||||
|
|
||||||
|
- orchestrator
|
||||||
|
- Prisma-focused stage worker
|
||||||
|
- `docs_researcher` when Prisma behavior is uncertain
|
||||||
|
|
||||||
|
Mandatory inputs:
|
||||||
|
|
||||||
|
- entity-scoped DSL quotes from `domain/toir.api.dsl`
|
||||||
|
- `prompts/prisma-rules.md`
|
||||||
|
|
||||||
|
Expected outputs:
|
||||||
|
|
||||||
|
- `server/prisma/schema.prisma`
|
||||||
|
- Prisma initialization or repair steps completed when the workspace was missing required baseline files
|
||||||
|
|
||||||
|
Handoff:
|
||||||
|
|
||||||
|
- backend generation starts only after schema output reflects the DSL and Prisma setup is coherent with runtime rules
|
||||||
|
|
||||||
|
## 3. Backend
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- generate NestJS modules, controllers, services, DTOs, and module registration from the DSL contract
|
||||||
|
|
||||||
|
Responsible:
|
||||||
|
|
||||||
|
- orchestrator
|
||||||
|
- backend stage worker, ideally one entity at a time when parallelized
|
||||||
|
|
||||||
|
Mandatory inputs:
|
||||||
|
|
||||||
|
- entity-scoped DSL quotes from `domain/toir.api.dsl`
|
||||||
|
- `prompts/backend-rules.md`
|
||||||
|
|
||||||
|
Expected outputs:
|
||||||
|
|
||||||
|
- `server/src/modules/<entity>/...`
|
||||||
|
- `server/src/app.module.ts`
|
||||||
|
|
||||||
|
Handoff:
|
||||||
|
|
||||||
|
- frontend generation starts only after backend contracts, guards, DTOs, and natural-key behavior align with backend rules
|
||||||
|
|
||||||
|
## 4. Frontend
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- generate React Admin resources and resource registration that match backend and DSL contracts
|
||||||
|
|
||||||
|
Responsible:
|
||||||
|
|
||||||
|
- orchestrator
|
||||||
|
- frontend stage worker, ideally one entity at a time when parallelized
|
||||||
|
|
||||||
|
Mandatory inputs:
|
||||||
|
|
||||||
|
- entity-scoped DSL quotes from `domain/toir.api.dsl`
|
||||||
|
- `prompts/frontend-rules.md`
|
||||||
|
|
||||||
|
Expected outputs:
|
||||||
|
|
||||||
|
- `client/src/resources/<entity>/...`
|
||||||
|
- `client/src/App.tsx`
|
||||||
|
|
||||||
|
Handoff:
|
||||||
|
|
||||||
|
- auth/runtime integration starts only after frontend resource contracts align with DTO-derived field sets and type mappings
|
||||||
|
|
||||||
|
## 5. Auth / Runtime / Realm Artifacts
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- wire authentication, environment defaults, realm import, and runtime topology around the generated CRUD app
|
||||||
|
|
||||||
|
Responsible:
|
||||||
|
|
||||||
|
- orchestrator
|
||||||
|
- auth/runtime stage worker
|
||||||
|
- `docs_researcher` when Keycloak or framework integration behavior is uncertain
|
||||||
|
|
||||||
|
Mandatory inputs:
|
||||||
|
|
||||||
|
- `prompts/auth-rules.md`
|
||||||
|
- `prompts/runtime-rules.md`
|
||||||
|
|
||||||
|
Expected outputs:
|
||||||
|
|
||||||
|
- `server/src/auth/`
|
||||||
|
- `client/src/auth/`
|
||||||
|
- `client/src/dataProvider.ts`
|
||||||
|
- `server/.env.example`
|
||||||
|
- `client/.env.example`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `toir-realm.json`
|
||||||
|
|
||||||
|
Handoff:
|
||||||
|
|
||||||
|
- verification starts only after auth seams, runtime artifacts, and realm output are aligned with backend/frontend expectations
|
||||||
|
|
||||||
|
## 6. Verification / Success Gate
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
- prove that the generation run is complete and not just plausible
|
||||||
|
|
||||||
|
Responsible:
|
||||||
|
|
||||||
|
- orchestrator
|
||||||
|
- `reviewer` before completion
|
||||||
|
|
||||||
|
Mandatory inputs:
|
||||||
|
|
||||||
|
- `prompts/validation-rules.md`
|
||||||
|
- validation command output
|
||||||
|
- reviewer findings
|
||||||
|
|
||||||
|
Expected outputs:
|
||||||
|
|
||||||
|
- refreshed auxiliary artifacts if the validator/tooling requires them, including `api-summary.json`
|
||||||
|
- passing validation gates
|
||||||
|
- successful backend and frontend builds
|
||||||
|
|
||||||
|
Handoff:
|
||||||
|
|
||||||
|
- there is no next stage; report complete only when every success criterion below is satisfied
|
||||||
|
|
||||||
|
# Success Criteria
|
||||||
|
|
||||||
|
Generation is successful only if all of the following are true:
|
||||||
|
|
||||||
|
- `server/` exists in the project root
|
||||||
|
- `client/` exists in the project root
|
||||||
|
- the backend builds successfully
|
||||||
|
- the frontend builds successfully
|
||||||
|
- `node tools/validate-generation.mjs --artifacts-only` passes
|
||||||
|
- `npm run eval:generation` passes
|
||||||
|
- required auth/runtime/realm artifacts exist and match their companion rules
|
||||||
|
- module/resource registrations are complete
|
||||||
|
- any validator-required auxiliary artifacts, including `api-summary.json`, are refreshed and consistent
|
||||||
|
- the reviewer has not identified unresolved contract violations
|
||||||
|
|
||||||
|
# Non-Goals / Constraints
|
||||||
|
|
||||||
|
- Do not edit `domain/toir.api.dsl` during generation.
|
||||||
|
- Do not treat `api-summary.json` as the source of truth or default starting point.
|
||||||
|
- Do not inline large backend/frontend/prisma/auth/runtime/validation rule sets into this master prompt; load the companion docs instead.
|
||||||
|
- Do not generate domain artifacts on top of a broken scaffold when official CLI repair is required.
|
||||||
|
- Do not claim success from prompt reasoning alone; use builds and repository gates.
|
||||||
|
- Do not load the full DSL blob when entity-scoped context is enough.
|
||||||
|
|
||||||
|
# Companion Rule Documents
|
||||||
|
|
||||||
|
These documents are mandatory when their stage is active:
|
||||||
|
|
||||||
|
- Prisma stage: `prompts/prisma-rules.md`
|
||||||
|
- Backend stage: `prompts/backend-rules.md`
|
||||||
|
- Frontend stage: `prompts/frontend-rules.md`
|
||||||
|
- Auth / realm stage: `prompts/auth-rules.md`
|
||||||
|
- Runtime / bootstrap stage: `prompts/runtime-rules.md`
|
||||||
|
- Verification stage: `prompts/validation-rules.md`
|
||||||
|
|
||||||
|
The master prompt owns orchestration. Companion docs own artifact-specific detail.
|
||||||
119
prompts/prisma-rules.md
Normal file
119
prompts/prisma-rules.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Prisma Rules
|
||||||
|
|
||||||
|
<!-- prompt-version: 2.0 -->
|
||||||
|
<!-- applies-to: server/prisma/schema.prisma -->
|
||||||
|
<!-- generated-by: LLM following these rules -->
|
||||||
|
<!-- source-of-truth: domain/toir.api.dsl -->
|
||||||
|
<!-- validated-by: tools/validate-generation.mjs §validateBuildChecks -->
|
||||||
|
|
||||||
|
Use this document during the **Prisma** stage defined in `prompts/general-prompt.md`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Generate `server/prisma/schema.prisma` as a faithful reflection of `domain/toir.api.dsl`.
|
||||||
|
|
||||||
|
## Mandatory Inputs
|
||||||
|
|
||||||
|
- `prompts/general-prompt.md`
|
||||||
|
- the relevant entity/enum definitions from `domain/toir.api.dsl`
|
||||||
|
- the existing Prisma header if `server/prisma/schema.prisma` already exists
|
||||||
|
|
||||||
|
`api-summary.json` may be used only as an auxiliary validator/inventory artifact. It is not part of the authoritative Prisma source hierarchy.
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
- `server/prisma/schema.prisma`
|
||||||
|
|
||||||
|
Never edit the schema manually during normal generation. Change the DSL and regenerate instead.
|
||||||
|
|
||||||
|
## Source Of Truth
|
||||||
|
|
||||||
|
Entity definitions, field types, PKs, FKs, enums, optionality, uniqueness, and defaults come from `domain/toir.api.dsl`.
|
||||||
|
|
||||||
|
## Scalar Type Mapping
|
||||||
|
|
||||||
|
| DSL type | Prisma scalar type |
|
||||||
|
| --------- | ------------------ |
|
||||||
|
| `uuid` | `String` |
|
||||||
|
| `string` | `String` |
|
||||||
|
| `text` | `String` |
|
||||||
|
| `integer` | `Int` |
|
||||||
|
| `number` | `Float` |
|
||||||
|
| `decimal` | `Decimal` |
|
||||||
|
| `date` | `DateTime` |
|
||||||
|
| `boolean` | `Boolean` |
|
||||||
|
| enum name | enum name as-is |
|
||||||
|
|
||||||
|
Unknown DSL types pass through as-is for forward compatibility.
|
||||||
|
|
||||||
|
## Primary Key Rules
|
||||||
|
|
||||||
|
- a field marked `key primary` becomes `@id`
|
||||||
|
- if the primary key type is `uuid` or the field name is `id`, use `@id @default(uuid())`
|
||||||
|
- non-uuid natural keys keep plain `@id`
|
||||||
|
- every entity must resolve to exactly one primary key
|
||||||
|
|
||||||
|
## Optionality And Defaults
|
||||||
|
|
||||||
|
- primary keys are required
|
||||||
|
- fields marked `is required` are required
|
||||||
|
- all other fields are optional with Prisma `?`
|
||||||
|
- non-primary unique fields get `@unique`
|
||||||
|
- `default <value>` maps to Prisma `@default(...)`
|
||||||
|
|
||||||
|
## Foreign Key And Relation Rules
|
||||||
|
|
||||||
|
For a DSL field declared `key foreign { relates Entity.field }`:
|
||||||
|
|
||||||
|
1. emit the FK scalar field first
|
||||||
|
2. add a relation field named `lowerFirst(relatedEntity)`
|
||||||
|
3. if that relation name collides, append `Ref`
|
||||||
|
4. annotate with `@relation(fields: [<fkField>], references: [<referencedField>])`
|
||||||
|
|
||||||
|
Inverse array relations:
|
||||||
|
|
||||||
|
- add inverse array fields for referencing entities automatically
|
||||||
|
- pluralization rules:
|
||||||
|
- `equipment` stays `equipment`
|
||||||
|
- names ending in `s` add `es`
|
||||||
|
- all others add `s`
|
||||||
|
- if the inverse name collides, append `List`
|
||||||
|
|
||||||
|
## Enum Rules
|
||||||
|
|
||||||
|
- every DSL enum becomes a Prisma enum
|
||||||
|
- preserve declaration order
|
||||||
|
- preserve the enum name exactly
|
||||||
|
|
||||||
|
## Header Preservation
|
||||||
|
|
||||||
|
If `server/prisma/schema.prisma` already contains a `generator client { ... }` block, preserve everything before the first `enum` or `model` keyword.
|
||||||
|
|
||||||
|
If no valid header exists, emit:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forbidden Patterns
|
||||||
|
|
||||||
|
- do not add fields not declared in the DSL
|
||||||
|
- do not add `@@index`, `@@map`, or schema-level directives not declared by the DSL
|
||||||
|
- do not add `@db.*` modifiers
|
||||||
|
- do not change the datasource provider away from `postgresql`
|
||||||
|
|
||||||
|
## Completion Expectations
|
||||||
|
|
||||||
|
Prisma generation is incomplete if any of the following is true:
|
||||||
|
|
||||||
|
- `server/prisma/schema.prisma` does not exist
|
||||||
|
- the schema no longer reflects the DSL
|
||||||
|
- required relation fields or inverse arrays are missing
|
||||||
|
- header generation or preservation breaks Prisma baseline behavior
|
||||||
86
prompts/runtime-rules.md
Normal file
86
prompts/runtime-rules.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Runtime Rules
|
||||||
|
|
||||||
|
<!-- prompt-version: 2.0 -->
|
||||||
|
<!-- applies-to: docker-compose.yml, server/.env.example, client/.env.example -->
|
||||||
|
<!-- validated-by: tools/validate-generation.mjs §validateRuntimeContractChecks -->
|
||||||
|
|
||||||
|
Use this document during the **Preparation / Discovery** and **Auth / Runtime / Realm** stages defined in `prompts/general-prompt.md`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define the runtime topology, environment defaults, scaffold expectations, and bootstrap sequence for a buildable generated workspace.
|
||||||
|
|
||||||
|
## Mandatory Inputs
|
||||||
|
|
||||||
|
- `prompts/general-prompt.md`
|
||||||
|
- `prompts/auth-rules.md` when runtime changes affect auth defaults or seams
|
||||||
|
- current repository runtime/auth defaults
|
||||||
|
|
||||||
|
`api-summary.json` is an auxiliary artifact only. Refresh it when validator/tooling requires freshness checks or when a compact inventory helps discovery. Do not treat it as the runtime source of truth.
|
||||||
|
|
||||||
|
## Expected Outputs
|
||||||
|
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `server/.env.example`
|
||||||
|
- `client/.env.example`
|
||||||
|
- a buildable NestJS workspace under `server/`
|
||||||
|
- a buildable Vite React TypeScript workspace under `client/`
|
||||||
|
- any validator-required auxiliary artifacts such as `api-summary.json`
|
||||||
|
|
||||||
|
## Baseline Runtime Topology
|
||||||
|
|
||||||
|
- `server/` is the backend output path
|
||||||
|
- `client/` is the frontend output path
|
||||||
|
- Docker scope stays PostgreSQL-only
|
||||||
|
- Keycloak remains external to repository runtime
|
||||||
|
- the project remains LLM-first and prompt-driven
|
||||||
|
|
||||||
|
## Concrete Runtime Defaults
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
- `PORT=3000`
|
||||||
|
- `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/toir"`
|
||||||
|
- `CORS_ALLOWED_ORIGINS="http://localhost:5173,https://toir-frontend.greact.ru"`
|
||||||
|
- `KEYCLOAK_ISSUER_URL="https://sso.greact.ru/realms/toir"`
|
||||||
|
- `KEYCLOAK_AUDIENCE="toir-backend"`
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- `VITE_API_URL=http://localhost:3000`
|
||||||
|
- `VITE_KEYCLOAK_URL=https://sso.greact.ru`
|
||||||
|
- `VITE_KEYCLOAK_REALM=toir`
|
||||||
|
- `VITE_KEYCLOAK_CLIENT_ID=toir-frontend`
|
||||||
|
|
||||||
|
## Scaffold Expectations
|
||||||
|
|
||||||
|
- new or repaired backend workspaces start from the official Nest CLI
|
||||||
|
- new or repaired frontend workspaces start from the official Vite React TypeScript CLI
|
||||||
|
- Prisma initialization uses the official Prisma CLI when relevant
|
||||||
|
- the LLM may customize generated code after scaffold creation, but must not replace official initialization with ad hoc file creation
|
||||||
|
|
||||||
|
## Runtime Bootstrap
|
||||||
|
|
||||||
|
1. import `toir-realm.json` into Keycloak
|
||||||
|
2. start PostgreSQL with `docker compose up -d`
|
||||||
|
3. from `server/`:
|
||||||
|
- repair or create the workspace with official Nest CLI if needed
|
||||||
|
- install dependencies
|
||||||
|
- run Prisma commands required by the schema stage
|
||||||
|
- run `npm run build`
|
||||||
|
- run `npm run start`
|
||||||
|
4. from `client/`:
|
||||||
|
- repair or create the workspace with official Vite CLI if needed
|
||||||
|
- install dependencies
|
||||||
|
- run `npm run build`
|
||||||
|
- run `npm run dev`
|
||||||
|
|
||||||
|
## Completion Expectations
|
||||||
|
|
||||||
|
Runtime preparation is incomplete if any of the following is true:
|
||||||
|
|
||||||
|
- `server/` is missing or not buildable as a NestJS workspace
|
||||||
|
- `client/` is missing or not buildable as a Vite React TypeScript workspace
|
||||||
|
- framework scaffolding was hand-built instead of created or repaired from official CLIs
|
||||||
|
- shared env defaults drift from the repository auth/runtime contract
|
||||||
|
- runtime success is claimed without actual build verification
|
||||||
101
prompts/validation-rules.md
Normal file
101
prompts/validation-rules.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Validation Rules
|
||||||
|
|
||||||
|
<!-- prompt-version: 2.0 -->
|
||||||
|
<!-- applies-to: tools/validate-generation.mjs and npm run eval:generation -->
|
||||||
|
<!-- validated-by: self -->
|
||||||
|
|
||||||
|
Use this document during the **Verification / Success Gate** stage defined in `prompts/general-prompt.md`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define the repository gates that convert a plausible generation run into a verified one.
|
||||||
|
|
||||||
|
## Primary Gates
|
||||||
|
|
||||||
|
- `node tools/validate-generation.mjs --artifacts-only`
|
||||||
|
- `npm run eval:generation`
|
||||||
|
|
||||||
|
## Auxiliary Freshness Prep
|
||||||
|
|
||||||
|
- `npm run generate:api-summary`
|
||||||
|
|
||||||
|
Run the freshness prep when the repository validator or supporting tooling expects `api-summary.json` to exist and match the current DSL. This artifact is auxiliary to validation and inventory, not the generation source of truth.
|
||||||
|
|
||||||
|
## Prompt-Gate Alignment Rule
|
||||||
|
|
||||||
|
- every invariant marked required in the active prompt corpus must either be enforced by a gate or called out as manual/runtime-only
|
||||||
|
- validation must not silently ignore a forbidden pattern
|
||||||
|
- build verification must not be reported as green when it was skipped
|
||||||
|
|
||||||
|
## Gate Groups
|
||||||
|
|
||||||
|
### Build Checks
|
||||||
|
|
||||||
|
- at least one `domain/*.api.dsl` file exists
|
||||||
|
- required artifacts exist:
|
||||||
|
- `server/prisma/schema.prisma`
|
||||||
|
- env examples
|
||||||
|
- required scaffold files
|
||||||
|
- auth/runtime/realm artifacts
|
||||||
|
- if the current validator policy checks `api-summary.json`, it exists and is fresh relative to the DSL
|
||||||
|
- `server/` remains a valid Nest workspace
|
||||||
|
- `client/` remains a valid Vite workspace
|
||||||
|
- if dependencies are installed, backend and frontend build verification runs
|
||||||
|
- if dependencies are missing, build verification is reported as skipped with reason instead of green
|
||||||
|
|
||||||
|
### Auth Checks
|
||||||
|
|
||||||
|
- frontend auth seam files exist
|
||||||
|
- backend auth seam files exist
|
||||||
|
- `401` and `403` semantics remain split
|
||||||
|
- auth code keeps the required Keycloak/JWT contracts
|
||||||
|
- JWKS resolution order remains:
|
||||||
|
1. explicit `KEYCLOAK_JWKS_URL`
|
||||||
|
2. OIDC discovery
|
||||||
|
3. certs fallback
|
||||||
|
|
||||||
|
### Filter And UI Checks
|
||||||
|
|
||||||
|
- list resources expose filter UI including `FilterButton`
|
||||||
|
- reference filters use `ReferenceInput` + `AutocompleteInput` with `filterToQuery`
|
||||||
|
- `dataProvider` preserves repeated query params for array filters
|
||||||
|
- backend FK filters remain exact-match
|
||||||
|
- repeated enum params map to Prisma `in`
|
||||||
|
- Create/Edit forms keep type-correct inputs
|
||||||
|
- navigable references keep `ReferenceField link="show"`
|
||||||
|
- resources keep `show={...}` registration in `App.tsx`
|
||||||
|
|
||||||
|
### Natural-Key Checks
|
||||||
|
|
||||||
|
- response records expose `id`
|
||||||
|
- route/update contracts use the real primary key
|
||||||
|
- natural-key sort/update paths do not regress to a fake physical `id`
|
||||||
|
|
||||||
|
### Realm Checks
|
||||||
|
|
||||||
|
- a root `*-realm.json` artifact exists
|
||||||
|
- required roles, audience delivery, and claims remain explicit
|
||||||
|
- SPA and backend client structure remains explicit
|
||||||
|
|
||||||
|
### Runtime Checks
|
||||||
|
|
||||||
|
- Docker topology remains PostgreSQL-only
|
||||||
|
- Prisma lifecycle commands remain available where required
|
||||||
|
- `/health` remains public
|
||||||
|
- backend build runs inside `server/`
|
||||||
|
- frontend build runs inside `client/`
|
||||||
|
- client/server `.env.example` stay aligned with repository defaults
|
||||||
|
|
||||||
|
### Output Contract Checks
|
||||||
|
|
||||||
|
- every generated Create/Update DTO imports from `'class-validator'`
|
||||||
|
- DTO fields have type-correct decorators
|
||||||
|
- optional/nullable fields carry `@IsOptional()` before the type decorator
|
||||||
|
- controllers carry the required guards and roles
|
||||||
|
- React Admin components use correct input/field types
|
||||||
|
|
||||||
|
### Eval Harness
|
||||||
|
|
||||||
|
- `npm run eval:generation` runs fixture-based semantic checks
|
||||||
|
- eval failures block completion
|
||||||
|
- prompt changes that break evals are regressions, not acceptable simplifications
|
||||||
172
toir-realm.json
Normal file
172
toir-realm.json
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
{
|
||||||
|
"realm": "toir",
|
||||||
|
"enabled": true,
|
||||||
|
"displayName": "TOIR",
|
||||||
|
"sslRequired": "external",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"registrationEmailAsUsername": false,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"resetPasswordAllowed": true,
|
||||||
|
"rememberMe": true,
|
||||||
|
"verifyEmail": false,
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"description": "Full administrative access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "editor",
|
||||||
|
"description": "Can create and modify data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "viewer",
|
||||||
|
"description": "Read-only access"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clientScopes": [
|
||||||
|
{
|
||||||
|
"name": "api-audience",
|
||||||
|
"description": "Adds backend audience to SPA access token",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"display.on.consent.screen": "false",
|
||||||
|
"include.in.token.scope": "false"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "aud-toir-backend",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.client.audience": "toir-backend",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"introspection.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "toir-frontend",
|
||||||
|
"name": "toir-frontend",
|
||||||
|
"description": "Frontend SPA client",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": true,
|
||||||
|
"bearerOnly": false,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"rootUrl": "https://toir-frontend.greact.ru",
|
||||||
|
"baseUrl": "https://toir-frontend.greact.ru",
|
||||||
|
"redirectUris": [
|
||||||
|
"https://toir-frontend.greact.ru/*",
|
||||||
|
"http://localhost:5173/*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"https://toir-frontend.greact.ru",
|
||||||
|
"http://localhost:5173"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256"
|
||||||
|
},
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"api-audience"
|
||||||
|
],
|
||||||
|
"optionalClientScopes": [
|
||||||
|
"offline_access"
|
||||||
|
],
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "sub",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "id",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "sub",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "preferred_username",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "username",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "preferred_username",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "email",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "email",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-full-name-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "realm roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"multivalued": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "realm_access.roles",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "toir-backend",
|
||||||
|
"name": "toir-backend",
|
||||||
|
"description": "Backend API resource server",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"bearerOnly": true,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"fullScopeAllowed": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
78
tools/api-format-to-openapi/README.md
Normal file
78
tools/api-format-to-openapi/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# api-format → OpenAPI 3.0
|
||||||
|
|
||||||
|
Абстрактная заготовка под задачу: **из доменного описания API получить OpenAPI 3.0**, затем встроить в общий пайплайн (кнопка / CI / генератор).
|
||||||
|
|
||||||
|
## Что внутри
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| `examples/api-format.example.json` | Пример **не-OpenAPI** формата: ресурсы, поля, операции, query для списка |
|
||||||
|
| `prompts/llm-system.md` | Системный промпт для LLM: «верни только JSON OpenAPI 3.0.3» |
|
||||||
|
| `convert.mjs` | CLI: режим `deterministic` (маппинг в коде) и `llm` (OpenAI API) |
|
||||||
|
|
||||||
|
## Пошаговая демонстрация в терминале
|
||||||
|
|
||||||
|
Чтобы **постепенно** увидеть: входной api-format → что делает конвертер → структура OpenAPI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/api-format-to-openapi
|
||||||
|
npm run demo
|
||||||
|
```
|
||||||
|
|
||||||
|
С паузой после каждого шага (нажимай Enter):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run demo:pause
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат кладётся в `demo-output/openapi.json`.
|
||||||
|
|
||||||
|
## Детерминированный режим (без LLM)
|
||||||
|
|
||||||
|
Подходит для **фиксированной** схемы `apiFormatVersion: "1"` как в примере.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/api-format-to-openapi
|
||||||
|
node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/api-format-to-openapi
|
||||||
|
npm run convert
|
||||||
|
```
|
||||||
|
|
||||||
|
## Режим LLM
|
||||||
|
|
||||||
|
Когда реальный формат отличается или богаче — прогон через модель с промптом из `prompts/llm-system.md`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set OPENAI_API_KEY=sk-...
|
||||||
|
cd tools/api-format-to-openapi
|
||||||
|
node convert.mjs --mode llm --in path/to/your-api-format.json --out ../../openapi.llm.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Переменные:
|
||||||
|
|
||||||
|
- `OPENAI_API_KEY` — обязательно
|
||||||
|
- `OPENAI_MODEL` — по умолчанию `gpt-4o-mini`
|
||||||
|
- `OPENAI_BASE_URL` — по умолчанию `https://api.openai.com/v1` (совместимо с прокси)
|
||||||
|
|
||||||
|
## HTTP-экспортёр для AID (NestJS)
|
||||||
|
|
||||||
|
В `server` добавлен модуль **`AidExportModule`**: `POST /aid/export/openapi` принимает `{ "apiFormat": {...}, "mode"?: "deterministic"|"llm" }` и возвращает `{ "openapi": {...} }`. Подробности: `server/src/aid-export/README.md`.
|
||||||
|
|
||||||
|
## Интеграция позже
|
||||||
|
|
||||||
|
1. Заменить/расширить `examples/api-format.example.json` под ваш настоящий контракт из «алабужского» гита.
|
||||||
|
2. Либо расширить `toOpenApiDeterministic` в `convert.mjs`, либо перейти на `--mode llm` с отточенным промптом.
|
||||||
|
3. Согласовать с AID точный URL, заголовки и (при необходимости) обёртку ответа; при необходимости добавить отдельный маршрут «сырой» OpenAPI без `{ openapi: ... }`.
|
||||||
|
|
||||||
|
## Валидация OpenAPI (опционально)
|
||||||
|
|
||||||
|
После генерации можно проверить спеку любым валидатором, например:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y @apidevtools/swagger-cli validate ../../openapi.generated.json
|
||||||
|
```
|
||||||
341
tools/api-format-to-openapi/convert.mjs
Normal file
341
tools/api-format-to-openapi/convert.mjs
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* api-format → OpenAPI 3.0
|
||||||
|
*
|
||||||
|
* Режимы:
|
||||||
|
* --mode deterministic — маппинг только для схемы examples/api-format.example.json (и совместимых)
|
||||||
|
* --mode llm — отправка входного JSON в OpenAI Chat Completions (нужен OPENAI_API_KEY)
|
||||||
|
*
|
||||||
|
* Примеры:
|
||||||
|
* node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json
|
||||||
|
* node convert.mjs --mode llm --in my-api.json --out openapi.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = { mode: "deterministic", input: null, output: null };
|
||||||
|
for (let i = 2; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === "--mode") out.mode = argv[++i];
|
||||||
|
else if (a === "--in") out.input = argv[++i];
|
||||||
|
else if (a === "--out") out.output = argv[++i];
|
||||||
|
else if (a === "-h" || a === "--help") out.help = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`
|
||||||
|
Usage: node convert.mjs --in <api-format.json> --out <openapi.json> [--mode deterministic|llm]
|
||||||
|
|
||||||
|
Environment (llm mode):
|
||||||
|
OPENAI_API_KEY required
|
||||||
|
OPENAI_MODEL optional, default gpt-4o-mini
|
||||||
|
OPENAI_BASE_URL optional, default https://api.openai.com/v1
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} t */
|
||||||
|
function fieldToSchema(t) {
|
||||||
|
const map = {
|
||||||
|
string: { type: "string" },
|
||||||
|
uuid: { type: "string", format: "uuid" },
|
||||||
|
int: { type: "integer" },
|
||||||
|
integer: { type: "integer" },
|
||||||
|
number: { type: "number" },
|
||||||
|
float: { type: "number" },
|
||||||
|
boolean: { type: "boolean" },
|
||||||
|
date: { type: "string", format: "date" },
|
||||||
|
datetime: { type: "string", format: "date-time" },
|
||||||
|
};
|
||||||
|
return map[t] || { type: "string", description: `unknown type: ${t}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Детерминированная конвертация для apiFormatVersion "1" с полями как в example.
|
||||||
|
* @param {any} api
|
||||||
|
*/
|
||||||
|
function toOpenApiDeterministic(api) {
|
||||||
|
if (!api || api.apiFormatVersion !== "1") {
|
||||||
|
throw new Error(
|
||||||
|
'deterministic mode: ожидается apiFormatVersion "1". Для другого формата используйте --mode llm или расширьте маппинг в convert.mjs.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = (api.server?.basePath || "/api").replace(/\/$/, "");
|
||||||
|
const info = api.info || { title: "API", version: "1.0.0" };
|
||||||
|
const paths = {};
|
||||||
|
const schemas = {};
|
||||||
|
|
||||||
|
for (const res of api.resources || []) {
|
||||||
|
const name = res.name;
|
||||||
|
const seg = res.pathSegment || name.toLowerCase();
|
||||||
|
const idParam = res.idParam || "id";
|
||||||
|
const idType = res.idType || "uuid";
|
||||||
|
|
||||||
|
const props = {};
|
||||||
|
const required = [];
|
||||||
|
for (const f of res.fields || []) {
|
||||||
|
let sch;
|
||||||
|
if (f.type === "enum" && Array.isArray(f.enumValues)) {
|
||||||
|
sch = { type: "string", enum: f.enumValues };
|
||||||
|
} else {
|
||||||
|
sch = { ...fieldToSchema(f.type) };
|
||||||
|
}
|
||||||
|
if (f.readOnly) sch.readOnly = true;
|
||||||
|
props[f.name] = sch;
|
||||||
|
if (f.required) required.push(f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
schemas[name] = {
|
||||||
|
type: "object",
|
||||||
|
properties: props,
|
||||||
|
...(required.length ? { required } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPath = `${base}/${seg}`;
|
||||||
|
const itemPath = `${base}/${seg}/{${idParam}}`;
|
||||||
|
const idSchema = fieldToSchema(idType);
|
||||||
|
|
||||||
|
const listQuery = [];
|
||||||
|
const lq = res.listQuery;
|
||||||
|
if (lq?.pagination) {
|
||||||
|
for (const p of lq.pagination) {
|
||||||
|
if (p === "_start" || p === "_end")
|
||||||
|
listQuery.push({ name: p, in: "query", schema: { type: "integer" }, description: "pagination" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lq?.sort) {
|
||||||
|
for (const p of lq.sort) {
|
||||||
|
if (p === "_sort")
|
||||||
|
listQuery.push({ name: "_sort", in: "query", schema: { type: "string" }, description: "sort field" });
|
||||||
|
if (p === "_order")
|
||||||
|
listQuery.push({
|
||||||
|
name: "_order",
|
||||||
|
in: "query",
|
||||||
|
schema: { type: "string", enum: ["asc", "desc"] },
|
||||||
|
description: "sort order",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lq?.filters) {
|
||||||
|
for (const p of lq.filters) {
|
||||||
|
if (p === "q")
|
||||||
|
listQuery.push({ name: "q", in: "query", schema: { type: "string" }, description: "full-text search" });
|
||||||
|
else {
|
||||||
|
const field = (res.fields || []).find((x) => x.name === p);
|
||||||
|
const isEnum = field?.type === "enum";
|
||||||
|
listQuery.push({
|
||||||
|
name: p,
|
||||||
|
in: "query",
|
||||||
|
schema: isEnum
|
||||||
|
? { type: "array", items: { type: "string", enum: field.enumValues || [] } }
|
||||||
|
: { type: "string" },
|
||||||
|
style: isEnum ? "form" : undefined,
|
||||||
|
explode: isEnum ? true : undefined,
|
||||||
|
description: isEnum ? "repeat param for multiple values" : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops = new Set(res.operations || []);
|
||||||
|
|
||||||
|
if (ops.has("list")) {
|
||||||
|
paths[listPath] = paths[listPath] || {};
|
||||||
|
paths[listPath].get = {
|
||||||
|
tags: [name],
|
||||||
|
summary: `List ${name}`,
|
||||||
|
parameters: listQuery,
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "OK",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
data: { type: "array", items: { $ref: `#/components/schemas/${name}` } },
|
||||||
|
total: { type: "integer" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ops.has("create")) {
|
||||||
|
paths[listPath] = paths[listPath] || {};
|
||||||
|
paths[listPath].post = {
|
||||||
|
tags: [name],
|
||||||
|
summary: `Create ${name}`,
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"201": {
|
||||||
|
description: "Created",
|
||||||
|
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||||
|
},
|
||||||
|
"400": { description: "Bad request" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ops.has("get")) {
|
||||||
|
paths[itemPath] = paths[itemPath] || {};
|
||||||
|
paths[itemPath].get = {
|
||||||
|
tags: [name],
|
||||||
|
summary: `Get ${name} by ${idParam}`,
|
||||||
|
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "OK",
|
||||||
|
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||||
|
},
|
||||||
|
"404": { description: "Not found" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ops.has("update")) {
|
||||||
|
paths[itemPath] = paths[itemPath] || {};
|
||||||
|
paths[itemPath].patch = {
|
||||||
|
tags: [name],
|
||||||
|
summary: `Update ${name}`,
|
||||||
|
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||||
|
requestBody: {
|
||||||
|
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "OK",
|
||||||
|
content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` } } },
|
||||||
|
},
|
||||||
|
"404": { description: "Not found" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ops.has("delete")) {
|
||||||
|
paths[itemPath] = paths[itemPath] || {};
|
||||||
|
paths[itemPath].delete = {
|
||||||
|
tags: [name],
|
||||||
|
summary: `Delete ${name}`,
|
||||||
|
parameters: [{ name: idParam, in: "path", required: true, schema: idSchema }],
|
||||||
|
responses: {
|
||||||
|
"204": { description: "No content" },
|
||||||
|
"404": { description: "Not found" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: info.title,
|
||||||
|
version: info.version,
|
||||||
|
...(info.description ? { description: info.description } : {}),
|
||||||
|
},
|
||||||
|
servers: [{ url: base || "/" }],
|
||||||
|
paths,
|
||||||
|
components: {
|
||||||
|
schemas,
|
||||||
|
...(api.security?.type === "bearer" || api.security?.scheme === "JWT"
|
||||||
|
? {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (doc.components.securitySchemes) {
|
||||||
|
doc.security = [{ bearerAuth: [] }];
|
||||||
|
for (const method of Object.values(paths)) {
|
||||||
|
for (const op of Object.values(method)) {
|
||||||
|
if (op && typeof op === "object" && op.responses) op.security = [{ bearerAuth: [] }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toOpenApiLlm(apiJson) {
|
||||||
|
const key = process.env.OPENAI_API_KEY;
|
||||||
|
if (!key) throw new Error("OPENAI_API_KEY не задан");
|
||||||
|
|
||||||
|
const model = process.env.OPENAI_MODEL || "gpt-4o-mini";
|
||||||
|
const baseUrl = (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").replace(/\/$/, "");
|
||||||
|
const systemPath = resolve(__dirname, "prompts", "llm-system.md");
|
||||||
|
const system = readFileSync(systemPath, "utf8");
|
||||||
|
const user = JSON.stringify(apiJson, null, 2);
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
temperature: 0.1,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: system },
|
||||||
|
{ role: "user", content: `Преобразуй следующий api-format в OpenAPI 3.0.3 JSON:\n\n${user}` },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`OpenAI HTTP ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
if (!content) throw new Error("Пустой ответ от модели");
|
||||||
|
|
||||||
|
const trimmed = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv);
|
||||||
|
if (args.help || !args.input || !args.output) {
|
||||||
|
usage();
|
||||||
|
process.exit(args.help ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPath = resolve(process.cwd(), args.input);
|
||||||
|
const outputPath = resolve(process.cwd(), args.output);
|
||||||
|
const raw = readFileSync(inputPath, "utf8");
|
||||||
|
const api = JSON.parse(raw);
|
||||||
|
|
||||||
|
let openapi;
|
||||||
|
if (args.mode === "llm") {
|
||||||
|
openapi = await toOpenApiLlm(api);
|
||||||
|
} else {
|
||||||
|
openapi = toOpenApiDeterministic(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dirname(outputPath), { recursive: true });
|
||||||
|
writeFileSync(outputPath, JSON.stringify(openapi, null, 2), "utf8");
|
||||||
|
console.log(`Written: ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
117
tools/api-format-to-openapi/demo-steps.mjs
Normal file
117
tools/api-format-to-openapi/demo-steps.mjs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Пошаговая демонстрация: api-format → OpenAPI 3.0 (детерминированный режим).
|
||||||
|
*
|
||||||
|
* node demo-steps.mjs — все шаги подряд в консоли
|
||||||
|
* node demo-steps.mjs --pause — пауза после каждого шага (Enter)
|
||||||
|
*
|
||||||
|
* Результат также пишется в demo-output/openapi.json рядом со скриптом.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
import { stdin as input, stdout as output } from "node:process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const EXAMPLE = join(__dirname, "examples", "api-format.example.json");
|
||||||
|
const OUT_DIR = join(__dirname, "demo-output");
|
||||||
|
const OUT_OPENAPI = join(OUT_DIR, "openapi.json");
|
||||||
|
const usePause = process.argv.includes("--pause");
|
||||||
|
|
||||||
|
function banner(title) {
|
||||||
|
const line = "═".repeat(Math.min(60, title.length + 8));
|
||||||
|
console.log(`\n${line}\n ${title}\n${line}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pause(msg = "Нажми Enter, чтобы перейти к следующему шагу…") {
|
||||||
|
if (!usePause) return;
|
||||||
|
const rl = createInterface({ input, output });
|
||||||
|
await rl.question(msg);
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.clear?.();
|
||||||
|
|
||||||
|
banner("Шаг 0. Задача");
|
||||||
|
console.log(
|
||||||
|
"У нас есть описание API в СВОЁМ формате (api-format), не OpenAPI.\n" +
|
||||||
|
"Нужно получить стандартную спецификацию OpenAPI 3.0 — для Swagger, клиентов, AID.\n" +
|
||||||
|
"Сейчас покажем путь на учебном примере (детерминированный маппинг в convert.mjs).",
|
||||||
|
);
|
||||||
|
await pause();
|
||||||
|
|
||||||
|
banner("Шаг 1. Входной файл (фрагмент api-format)");
|
||||||
|
console.log(`Файл: ${EXAMPLE}\n`);
|
||||||
|
const rawIn = readFileSync(EXAMPLE, "utf8");
|
||||||
|
const apiFormat = JSON.parse(rawIn);
|
||||||
|
console.log(JSON.stringify(apiFormat, null, 2));
|
||||||
|
console.log(
|
||||||
|
"\n↑ Это НЕ OpenAPI. Здесь: версия формата, info, basePath, ресурс Equipment с полями и операциями CRUD.",
|
||||||
|
);
|
||||||
|
await pause();
|
||||||
|
|
||||||
|
banner("Шаг 2. Что делает конвертер (логика)");
|
||||||
|
console.log(`
|
||||||
|
• apiFormatVersion "1" → включается ветка toOpenApiDeterministic в convert.mjs
|
||||||
|
• Ресурс Equipment → components.schemas.Equipment + пути /api/equipment и /api/equipment/{id}
|
||||||
|
• Поля (string, uuid, enum…) → JSON Schema в components.schemas
|
||||||
|
• listQuery → query-параметры (_start, _end, _sort, _order, q, status…)
|
||||||
|
• security bearer → components.securitySchemes + security на операциях
|
||||||
|
`);
|
||||||
|
await pause();
|
||||||
|
|
||||||
|
banner("Шаг 3. Запуск convert.mjs");
|
||||||
|
mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
const convertScript = join(__dirname, "convert.mjs");
|
||||||
|
console.log(`Команда:\n node convert.mjs --in examples/api-format.example.json --out demo-output/openapi.json\n`);
|
||||||
|
execFileSync(process.execPath, [convertScript, "--in", EXAMPLE, "--out", OUT_OPENAPI], {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
console.log(`\nГотово. Файл: ${OUT_OPENAPI}`);
|
||||||
|
await pause();
|
||||||
|
|
||||||
|
banner("Шаг 4. Результат — структура OpenAPI");
|
||||||
|
const spec = JSON.parse(readFileSync(OUT_OPENAPI, "utf8"));
|
||||||
|
console.log(`openapi: ${spec.openapi}`);
|
||||||
|
console.log(`title: ${spec.info?.title}`);
|
||||||
|
console.log(`version: ${spec.info?.version}`);
|
||||||
|
console.log("\nПути (paths):");
|
||||||
|
for (const p of Object.keys(spec.paths || {}).sort()) {
|
||||||
|
const methods = Object.keys(spec.paths[p]).join(", ");
|
||||||
|
console.log(` ${p} [${methods}]`);
|
||||||
|
}
|
||||||
|
console.log("\nСхемы (components.schemas):", Object.keys(spec.components?.schemas || {}).join(", "));
|
||||||
|
await pause();
|
||||||
|
|
||||||
|
banner("Шаг 5. Фрагмент: GET список (одна операция)");
|
||||||
|
const listPath = Object.keys(spec.paths || {}).find((k) => k.endsWith("/equipment") && !k.includes("{"));
|
||||||
|
if (listPath && spec.paths[listPath]?.get) {
|
||||||
|
console.log(JSON.stringify({ [listPath]: { get: spec.paths[listPath].get } }, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log("(путь списка не найден — открой demo-output/openapi.json)");
|
||||||
|
}
|
||||||
|
await pause();
|
||||||
|
|
||||||
|
banner("Шаг 6. Как проверить дальше");
|
||||||
|
console.log(`
|
||||||
|
1) Открой целиком: ${OUT_OPENAPI}
|
||||||
|
2) Валидация (из корня репозитория):
|
||||||
|
npx -y @apidevtools/swagger-cli validate tools/api-format-to-openapi/demo-output/openapi.json
|
||||||
|
3) Через Nest (сервер на 3001):
|
||||||
|
POST http://127.0.0.1:3001/aid/export/openapi
|
||||||
|
тело: { "apiFormat": <содержимое api-format.example.json>, "mode": "deterministic" }
|
||||||
|
4) Режим LLM (другой входной JSON):
|
||||||
|
node convert.mjs --mode llm --in your.json --out openapi.llm.json
|
||||||
|
(нужен OPENAI_API_KEY)
|
||||||
|
`);
|
||||||
|
console.log("Демо завершено.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
36
tools/api-format-to-openapi/examples/api-format.example.json
Normal file
36
tools/api-format-to-openapi/examples/api-format.example.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"apiFormatVersion": "1",
|
||||||
|
"info": {
|
||||||
|
"title": "TOiR Demo API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Абстрактный пример доменного описания API (не OpenAPI)."
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"basePath": "/api"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"type": "bearer",
|
||||||
|
"scheme": "JWT"
|
||||||
|
},
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"name": "Equipment",
|
||||||
|
"pathSegment": "equipment",
|
||||||
|
"idParam": "id",
|
||||||
|
"idType": "uuid",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "id", "type": "uuid", "readOnly": true },
|
||||||
|
{ "name": "inventoryNumber", "type": "string", "required": true },
|
||||||
|
{ "name": "name", "type": "string", "required": true },
|
||||||
|
{ "name": "status", "type": "enum", "enumValues": ["Active", "Repair", "Decommissioned"] },
|
||||||
|
{ "name": "location", "type": "string" }
|
||||||
|
],
|
||||||
|
"operations": ["list", "get", "create", "update", "delete"],
|
||||||
|
"listQuery": {
|
||||||
|
"pagination": ["_start", "_end"],
|
||||||
|
"sort": ["_sort", "_order"],
|
||||||
|
"filters": ["q", "status"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
tools/api-format-to-openapi/package.json
Normal file
12
tools/api-format-to-openapi/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "api-format-to-openapi",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Конвертация доменного api-format в OpenAPI 3.0 (детерминированно или через LLM)",
|
||||||
|
"scripts": {
|
||||||
|
"convert": "node convert.mjs --in examples/api-format.example.json --out ../../openapi.generated.json",
|
||||||
|
"convert:llm": "node convert.mjs --mode llm --in examples/api-format.example.json --out ../../openapi.llm.json",
|
||||||
|
"demo": "node demo-steps.mjs",
|
||||||
|
"demo:pause": "node demo-steps.mjs --pause"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tools/api-format-to-openapi/prompts/llm-system.md
Normal file
30
tools/api-format-to-openapi/prompts/llm-system.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Роль
|
||||||
|
|
||||||
|
Ты конвертер доменного описания API в спецификацию **OpenAPI 3.0.3** (JSON).
|
||||||
|
|
||||||
|
# Вход
|
||||||
|
|
||||||
|
Пользователь пришлёт один JSON-файл в произвольном «доменном» формате (api-format). В нём могут быть сущности, поля, типы, пути, операции, фильтры, авторизация.
|
||||||
|
|
||||||
|
# Выход
|
||||||
|
|
||||||
|
- Верни **только** валидный JSON объекта OpenAPI 3.0.3.
|
||||||
|
- Без markdown, без комментариев, без текста до или после JSON.
|
||||||
|
- Используй `openapi: "3.0.3"`.
|
||||||
|
- Опиши `info`, `servers`, при необходимости `tags`.
|
||||||
|
- Для каждой сущности/ресурса создай `components.schemas` и `paths` с типичными REST-операциями, если они указаны.
|
||||||
|
- Типы полей маппь так:
|
||||||
|
- `string` → `type: string`
|
||||||
|
- `uuid` → `type: string`, `format: uuid`
|
||||||
|
- `int` / `integer` → `type: integer`
|
||||||
|
- `number` / `float` → `type: number`
|
||||||
|
- `boolean` → `type: boolean`
|
||||||
|
- `date` / `datetime` → `type: string`, `format: date` или `date-time`
|
||||||
|
- `enum` + список значений → `type: string`, `enum: [...]`
|
||||||
|
- Для списков с пагинацией добавь query-параметры из входа (`_start`, `_end`, `_sort`, `_order`, фильтры).
|
||||||
|
- Для `401/403/404/500` добавь минимальные `responses` с `description`.
|
||||||
|
- Если во входе указана Bearer/JWT — добавь `components.securitySchemes` и `security` на путях или глобально.
|
||||||
|
|
||||||
|
# Если чего-то не хватает
|
||||||
|
|
||||||
|
Делай разумные допущения и кратко отражай их в `info.description` одним предложением.
|
||||||
301
tools/api-summary-to-openapi.mjs
Normal file
301
tools/api-summary-to-openapi.mjs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
// Deterministic OpenAPI 3.0.3 generator from api-summary.json / toir.api.dsl.
|
||||||
|
//
|
||||||
|
// This script is part of the Tier 1 deterministic preprocessing layer.
|
||||||
|
// It converts the canonical api-summary (produced by tools/api-summary.mjs)
|
||||||
|
// into a valid OpenAPI 3.0.3 document.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node tools/api-summary-to-openapi.mjs --out openapi.json
|
||||||
|
// npm run generate:openapi
|
||||||
|
//
|
||||||
|
// No LLM involvement. The output is reproducible from DSL + this script alone.
|
||||||
|
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { buildApiSummary } from './api-summary.mjs';
|
||||||
|
|
||||||
|
const rootDir = process.cwd();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DSL scalar → OpenAPI type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function dslTypeToOpenApi(dslType) {
|
||||||
|
switch (dslType) {
|
||||||
|
case 'uuid':
|
||||||
|
return { type: 'string', format: 'uuid' };
|
||||||
|
case 'string':
|
||||||
|
return { type: 'string' };
|
||||||
|
case 'text':
|
||||||
|
return { type: 'string' };
|
||||||
|
case 'integer':
|
||||||
|
return { type: 'integer', format: 'int32' };
|
||||||
|
case 'number':
|
||||||
|
return { type: 'number' };
|
||||||
|
case 'decimal':
|
||||||
|
return { type: 'string', format: 'decimal' };
|
||||||
|
case 'date':
|
||||||
|
return { type: 'string', format: 'date-time' };
|
||||||
|
case 'boolean':
|
||||||
|
return { type: 'boolean' };
|
||||||
|
default:
|
||||||
|
// enum names and DTO references handled by caller
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolve a DSL field type to an OpenAPI schema reference or inline schema.
|
||||||
|
// dtoNames — Set of known DTO names in the api summary.
|
||||||
|
// enumNames — Set of known enum names (derived from type mappings table).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveFieldType(dslType, dtoNames, enumNames) {
|
||||||
|
if (!dslType) return { type: 'object' };
|
||||||
|
|
||||||
|
// Array type: "DTO.Foo[]"
|
||||||
|
if (dslType.endsWith('[]')) {
|
||||||
|
const inner = dslType.slice(0, -2);
|
||||||
|
return { type: 'array', items: resolveFieldType(inner, dtoNames, enumNames) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalar
|
||||||
|
const scalar = dslTypeToOpenApi(dslType);
|
||||||
|
if (scalar) return scalar;
|
||||||
|
|
||||||
|
// DTO reference
|
||||||
|
if (dtoNames.has(dslType)) {
|
||||||
|
return { $ref: `#/components/schemas/${dslType.replace(/^DTO\./, '')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum reference
|
||||||
|
if (enumNames.has(dslType)) {
|
||||||
|
return { $ref: `#/components/schemas/${dslType}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown — emit as string with x-dsl-type annotation
|
||||||
|
return { type: 'string', 'x-dsl-type': dslType };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build OpenAPI schema object from a DTO definition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildDtoSchema(dto, dtoNames, enumNames) {
|
||||||
|
const properties = {};
|
||||||
|
const required = [];
|
||||||
|
|
||||||
|
for (const field of dto.fields) {
|
||||||
|
const schema = resolveFieldType(field.type, dtoNames, enumNames);
|
||||||
|
if (field.description && schema.$ref) {
|
||||||
|
// OpenAPI 3.0.3: $ref cannot have sibling keys — wrap with allOf
|
||||||
|
properties[field.name] = { allOf: [schema], description: field.description };
|
||||||
|
} else {
|
||||||
|
if (field.description) schema.description = field.description;
|
||||||
|
properties[field.name] = schema;
|
||||||
|
}
|
||||||
|
if (field.required) required.push(field.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
type: 'object',
|
||||||
|
properties,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dto.description) schema.description = dto.description;
|
||||||
|
if (required.length > 0) schema.required = required;
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convert DSL HTTP method to OpenAPI method key
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function methodKey(method) {
|
||||||
|
return (method ?? 'get').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build OpenAPI path item for an endpoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildPathOperation(endpoint, apiDescription, dtoNames, enumNames) {
|
||||||
|
const operation = {};
|
||||||
|
|
||||||
|
if (endpoint.description) operation.summary = endpoint.description;
|
||||||
|
|
||||||
|
// Security — all endpoints require bearer auth
|
||||||
|
operation.security = [{ bearerAuth: [] }];
|
||||||
|
|
||||||
|
// Tags — derive from API name or path
|
||||||
|
const tag = apiDescription ? apiDescription.replace(/^API управления\s+/i, '').replace(/ами$/, '') : undefined;
|
||||||
|
if (tag) operation.tags = [tag];
|
||||||
|
|
||||||
|
// Parameters — detect path params by matching attribute names against {param} in the path
|
||||||
|
const pathTemplate = endpoint.path ?? '';
|
||||||
|
const pathParamNames = new Set(
|
||||||
|
[...pathTemplate.matchAll(/\{(\w+)\}/g)].map((m) => m[1]),
|
||||||
|
);
|
||||||
|
const pathParams = endpoint.attributes.filter((a) => pathParamNames.has(a.name));
|
||||||
|
if (pathParams.length > 0) {
|
||||||
|
operation.parameters = pathParams.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
schema: resolveFieldType(p.type, dtoNames, enumNames),
|
||||||
|
...(p.description ? { description: p.description } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request body
|
||||||
|
const requestAttr = endpoint.attributes.find((a) => a.name === 'request');
|
||||||
|
if (requestAttr) {
|
||||||
|
operation.requestBody = {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: resolveFieldType(requestAttr.type, dtoNames, enumNames),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
const responseAttr = endpoint.attributes.find((a) => a.name === 'response');
|
||||||
|
const responseSchema = responseAttr
|
||||||
|
? resolveFieldType(responseAttr.type, dtoNames, enumNames)
|
||||||
|
: { type: 'object' };
|
||||||
|
|
||||||
|
const successCode = endpoint.method === 'POST' && !endpoint.path?.endsWith('/page') ? '201' : '200';
|
||||||
|
|
||||||
|
operation.responses = {
|
||||||
|
[successCode]: {
|
||||||
|
description: 'Success',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: responseSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'401': { description: 'Unauthorized' },
|
||||||
|
'403': { description: 'Forbidden' },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (endpoint.method === 'DELETE') {
|
||||||
|
operation.responses = {
|
||||||
|
'204': { description: 'No content' },
|
||||||
|
'401': { description: 'Unauthorized' },
|
||||||
|
'403': { description: 'Forbidden' },
|
||||||
|
'404': { description: 'Not found' },
|
||||||
|
};
|
||||||
|
delete operation.responses['201'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildOpenApi(summary) {
|
||||||
|
const dtoNames = new Set(summary.dtos.map((d) => d.name));
|
||||||
|
|
||||||
|
// Build enum map from api-summary enums block (fully declared enums with values)
|
||||||
|
const enumMap = new Map((summary.enums ?? []).map((e) => [e.name, e]));
|
||||||
|
|
||||||
|
// Also collect enum names referenced in DTO fields that are not in the declared enums
|
||||||
|
// (covers cases where enums are declared in domain.dsl but referenced in api.dsl)
|
||||||
|
const enumNames = new Set(enumMap.keys());
|
||||||
|
for (const dto of summary.dtos) {
|
||||||
|
for (const field of dto.fields) {
|
||||||
|
const t = field.type?.replace('[]', '');
|
||||||
|
if (t && !dtoNames.has(t) && !dslTypeToOpenApi(t)) {
|
||||||
|
enumNames.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schemas — one per DTO
|
||||||
|
const schemas = {};
|
||||||
|
for (const dto of summary.dtos) {
|
||||||
|
const schemaName = dto.name.replace(/^DTO\./, '');
|
||||||
|
schemas[schemaName] = buildDtoSchema(dto, dtoNames, enumNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum schemas — use actual values when available, otherwise annotate as opaque string enum
|
||||||
|
for (const enumName of enumNames) {
|
||||||
|
const enumDef = enumMap.get(enumName);
|
||||||
|
if (enumDef && enumDef.values.length > 0) {
|
||||||
|
schemas[enumName] = {
|
||||||
|
type: 'string',
|
||||||
|
enum: enumDef.values.map((v) => v.name),
|
||||||
|
'x-enum-labels': Object.fromEntries(
|
||||||
|
enumDef.values.filter((v) => v.label).map((v) => [v.name, v.label]),
|
||||||
|
),
|
||||||
|
...(enumDef.description ? { description: enumDef.description } : {}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
schemas[enumName] = {
|
||||||
|
type: 'string',
|
||||||
|
'x-dsl-enum': enumName,
|
||||||
|
description: `Enum: ${enumName} (values defined in domain/*.api.dsl)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
const paths = {};
|
||||||
|
for (const api of summary.apis) {
|
||||||
|
for (const endpoint of api.endpoints) {
|
||||||
|
if (!endpoint.path) continue;
|
||||||
|
const pathKey = endpoint.path;
|
||||||
|
if (!paths[pathKey]) paths[pathKey] = {};
|
||||||
|
const opKey = methodKey(endpoint.method);
|
||||||
|
paths[pathKey][opKey] = buildPathOperation(endpoint, api.description, dtoNames, enumNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openapi: '3.0.3',
|
||||||
|
info: {
|
||||||
|
title: 'KIS-TOiR API',
|
||||||
|
description:
|
||||||
|
'Equipment maintenance management system. Generated from domain/toir.api.dsl via tools/api-summary-to-openapi.mjs.',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: '/api',
|
||||||
|
description: 'Default server',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemas,
|
||||||
|
},
|
||||||
|
paths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const outIndex = args.indexOf('--out');
|
||||||
|
const outPath = outIndex !== -1 ? args[outIndex + 1] : 'openapi.json';
|
||||||
|
|
||||||
|
const summary = buildApiSummary(rootDir);
|
||||||
|
const openApiDoc = buildOpenApi(summary);
|
||||||
|
const outputPath = path.resolve(rootDir, outPath);
|
||||||
|
|
||||||
|
writeFileSync(outputPath, `${JSON.stringify(openApiDoc, null, 2)}\n`, 'utf8');
|
||||||
|
console.log(`Generated ${path.relative(rootDir, outputPath)}`);
|
||||||
389
tools/api-summary.mjs
Normal file
389
tools/api-summary.mjs
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function stripInlineComment(line) {
|
||||||
|
let inString = false;
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const current = line[index];
|
||||||
|
const next = line[index + 1];
|
||||||
|
|
||||||
|
if (current === '"' && line[index - 1] !== '\\') {
|
||||||
|
inString = !inString;
|
||||||
|
result += current;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inString && current === '/' && next === '/') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File discovery
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getApiDslFiles(rootDir) {
|
||||||
|
const domainDir = path.join(rootDir, 'domain');
|
||||||
|
try {
|
||||||
|
return readdirSync(domainDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.api.dsl'))
|
||||||
|
.map((entry) => path.join(domainDir, entry.name))
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parser
|
||||||
|
//
|
||||||
|
// Parses all *.api.dsl files using a stack-based approach.
|
||||||
|
// This is the single canonical parser for the API DSL.
|
||||||
|
//
|
||||||
|
// Supported top-level blocks:
|
||||||
|
// enum <Name> { description?; value <Name> { label?; } }
|
||||||
|
// dto DTO.<Name> { description?; attribute <name> { ... } }
|
||||||
|
// api API.<Name> { description?; endpoint <name> { ... } }
|
||||||
|
//
|
||||||
|
// DTO attribute modifiers (any order inside the attribute block):
|
||||||
|
// type <type>;
|
||||||
|
// description "...";
|
||||||
|
// map Entity.field;
|
||||||
|
// sync Entity.field; (alias for map — used for computed/aggregate fields)
|
||||||
|
// is required;
|
||||||
|
// is nullable;
|
||||||
|
// is unique;
|
||||||
|
// key primary;
|
||||||
|
// label "...";
|
||||||
|
//
|
||||||
|
// Endpoint modifiers:
|
||||||
|
// label "METHOD /path";
|
||||||
|
// description "...";
|
||||||
|
// attribute <name> { type <type>; description?; }
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// {
|
||||||
|
// files: string[],
|
||||||
|
// enums: EnumBlock[],
|
||||||
|
// dtos: DtoBlock[],
|
||||||
|
// apis: ApiBlock[],
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// EnumBlock = { name, description, values: EnumValue[] }
|
||||||
|
// EnumValue = { name, label }
|
||||||
|
// DtoBlock = { name, description, fields: DtoField[] }
|
||||||
|
// DtoField = { name, type, required, nullable, unique, primary, description, map, label }
|
||||||
|
// ApiBlock = { name, description, endpoints: Endpoint[] }
|
||||||
|
// Endpoint = { name, label, method, path, description, attributes: EndpointAttr[] }
|
||||||
|
// EndpointAttr = { name, type, description }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function parseApiDsl(rootDir) {
|
||||||
|
const files = getApiDslFiles(rootDir);
|
||||||
|
const enums = [];
|
||||||
|
const dtos = [];
|
||||||
|
const apis = [];
|
||||||
|
const stack = [];
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = stripInlineComment(rawLine);
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const top = stack.at(-1);
|
||||||
|
|
||||||
|
// ── Top-level: enum block ──────────────────────────────────────────
|
||||||
|
const enumMatch = line.match(/^enum\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/);
|
||||||
|
if (!top && enumMatch) {
|
||||||
|
const enumBlock = { name: enumMatch[1], description: null, values: [] };
|
||||||
|
enums.push(enumBlock);
|
||||||
|
stack.push({ type: 'enum', ref: enumBlock });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top-level: dto block ───────────────────────────────────────────
|
||||||
|
const dtoMatch = line.match(/^dto\s+(DTO\.\w+)\s*\{$/);
|
||||||
|
if (!top && dtoMatch) {
|
||||||
|
const dto = { name: dtoMatch[1], description: null, fields: [] };
|
||||||
|
dtos.push(dto);
|
||||||
|
stack.push({ type: 'dto', ref: dto });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top-level: api block ───────────────────────────────────────────
|
||||||
|
const apiMatch = line.match(/^api\s+(API\.\w+)\s*\{$/);
|
||||||
|
if (!top && apiMatch) {
|
||||||
|
const api = { name: apiMatch[1], description: null, endpoints: [] };
|
||||||
|
apis.push(api);
|
||||||
|
stack.push({ type: 'api', ref: api });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inside enum ───────────────────────────────────────────────────
|
||||||
|
if (top?.type === 'enum') {
|
||||||
|
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||||
|
if (descMatch) {
|
||||||
|
top.ref.description = descMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueMatch = line.match(/^value\s+([^\s{]+)\s*\{$/);
|
||||||
|
if (valueMatch) {
|
||||||
|
const enumValue = { name: valueMatch[1], label: null };
|
||||||
|
top.ref.values.push(enumValue);
|
||||||
|
stack.push({ type: 'enumValue', ref: enumValue });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inside enum value ─────────────────────────────────────────────
|
||||||
|
if (top?.type === 'enumValue') {
|
||||||
|
const labelMatch = line.match(/^label\s+"(.*)"\s*;$/);
|
||||||
|
if (labelMatch) {
|
||||||
|
top.ref.label = labelMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inside dto ────────────────────────────────────────────────────
|
||||||
|
if (top?.type === 'dto') {
|
||||||
|
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||||
|
if (descMatch) {
|
||||||
|
top.ref.description = descMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrMatch = line.match(/^attribute\s+(\w+)\s*\{$/);
|
||||||
|
if (attrMatch) {
|
||||||
|
const field = {
|
||||||
|
name: attrMatch[1],
|
||||||
|
type: null,
|
||||||
|
required: false,
|
||||||
|
nullable: false,
|
||||||
|
unique: false,
|
||||||
|
primary: false,
|
||||||
|
description: null,
|
||||||
|
map: null,
|
||||||
|
label: null,
|
||||||
|
};
|
||||||
|
top.ref.fields.push(field);
|
||||||
|
stack.push({ type: 'dtoField', ref: field });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inside dto attribute field ────────────────────────────────────
|
||||||
|
if (top?.type === 'dtoField') {
|
||||||
|
const typeMatch = line.match(/^type\s+(.+?)\s*;$/);
|
||||||
|
if (typeMatch) {
|
||||||
|
top.ref.type = typeMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^is\s+required\s*;$/.test(line)) {
|
||||||
|
top.ref.required = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^is\s+nullable\s*;$/.test(line)) {
|
||||||
|
top.ref.nullable = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^is\s+unique\s*;$/.test(line)) {
|
||||||
|
top.ref.unique = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^key\s+primary\s*;$/.test(line)) {
|
||||||
|
top.ref.primary = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||||
|
if (descMatch) {
|
||||||
|
top.ref.description = descMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// map Entity.field; — canonical field mapping
|
||||||
|
const mapMatch = line.match(/^map\s+(\w+)\.(\w+)\s*;$/);
|
||||||
|
if (mapMatch) {
|
||||||
|
top.ref.map = `${mapMatch[1]}.${mapMatch[2]}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync Entity.field; — aggregate / computed field mapping (treated as map)
|
||||||
|
const syncMatch = line.match(/^sync\s+(\w+)\.(\w+)\s*;$/);
|
||||||
|
if (syncMatch) {
|
||||||
|
top.ref.map = `${syncMatch[1]}.${syncMatch[2]}`;
|
||||||
|
top.ref.sync = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelMatch = line.match(/^label\s+"(.*)"\s*;$/);
|
||||||
|
if (labelMatch) {
|
||||||
|
top.ref.label = labelMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inside api ────────────────────────────────────────────────────
|
||||||
|
if (top?.type === 'api') {
|
||||||
|
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||||
|
if (descMatch) {
|
||||||
|
top.ref.description = descMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const epMatch = line.match(/^endpoint\s+(\w+)\s*\{$/);
|
||||||
|
if (epMatch) {
|
||||||
|
const ep = {
|
||||||
|
name: epMatch[1],
|
||||||
|
label: null,
|
||||||
|
method: null,
|
||||||
|
path: null,
|
||||||
|
description: null,
|
||||||
|
attributes: [],
|
||||||
|
};
|
||||||
|
top.ref.endpoints.push(ep);
|
||||||
|
stack.push({ type: 'endpoint', ref: ep });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inside endpoint ───────────────────────────────────────────────
|
||||||
|
if (top?.type === 'endpoint') {
|
||||||
|
const labelMatch = line.match(/^label\s+"([^"]+)"\s*;$/);
|
||||||
|
if (labelMatch) {
|
||||||
|
top.ref.label = labelMatch[1];
|
||||||
|
const parts = labelMatch[1].split(' ', 2);
|
||||||
|
top.ref.method = parts[0]?.toUpperCase() ?? null;
|
||||||
|
top.ref.path = parts[1] ?? null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||||
|
if (descMatch) {
|
||||||
|
top.ref.description = descMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrMatch = line.match(/^attribute\s+(\w+)\s*\{$/);
|
||||||
|
if (attrMatch) {
|
||||||
|
const attr = { name: attrMatch[1], type: null, description: null };
|
||||||
|
top.ref.attributes.push(attr);
|
||||||
|
stack.push({ type: 'endpointAttr', ref: attr });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inside endpoint attribute ─────────────────────────────────────
|
||||||
|
if (top?.type === 'endpointAttr') {
|
||||||
|
const typeMatch = line.match(/^type\s+(.+?)\s*;$/);
|
||||||
|
if (typeMatch) {
|
||||||
|
top.ref.type = typeMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descMatch = line.match(/^description\s+"(.*)"\s*;$/);
|
||||||
|
if (descMatch) {
|
||||||
|
top.ref.description = descMatch[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Closing brace — pop the stack ─────────────────────────────────
|
||||||
|
if (/^}\s*;?$/.test(line)) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files, enums, dtos, apis };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Summary builder
|
||||||
|
//
|
||||||
|
// Produces the serialisable api-summary.json object.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function buildApiSummary(rootDir) {
|
||||||
|
const { files, enums, dtos, apis } = parseApiDsl(rootDir);
|
||||||
|
|
||||||
|
// Detect duplicate DTO names
|
||||||
|
const dtoNames = new Set();
|
||||||
|
for (const dto of dtos) {
|
||||||
|
if (dtoNames.has(dto.name)) {
|
||||||
|
throw new Error(`Duplicate DTO definition: ${dto.name}`);
|
||||||
|
}
|
||||||
|
dtoNames.add(dto.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect duplicate API names
|
||||||
|
const apiNames = new Set();
|
||||||
|
for (const api of apis) {
|
||||||
|
if (apiNames.has(api.name)) {
|
||||||
|
throw new Error(`Duplicate API definition: ${api.name}`);
|
||||||
|
}
|
||||||
|
apiNames.add(api.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceFiles: files.map((filePath) =>
|
||||||
|
path.relative(rootDir, filePath).replaceAll('\\', '/'),
|
||||||
|
),
|
||||||
|
enums: enums.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
description: e.description,
|
||||||
|
values: e.values.map((v) => ({ name: v.name, label: v.label })),
|
||||||
|
})),
|
||||||
|
dtos: dtos.map((dto) => ({
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
fields: dto.fields.map((field) => ({
|
||||||
|
name: field.name,
|
||||||
|
type: field.type,
|
||||||
|
required: field.required,
|
||||||
|
nullable: field.nullable,
|
||||||
|
unique: field.unique,
|
||||||
|
primary: field.primary,
|
||||||
|
description: field.description,
|
||||||
|
map: field.map,
|
||||||
|
sync: field.sync ?? false,
|
||||||
|
label: field.label,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
apis: apis.map((api) => ({
|
||||||
|
name: api.name,
|
||||||
|
description: api.description,
|
||||||
|
endpoints: api.endpoints.map((ep) => ({
|
||||||
|
name: ep.name,
|
||||||
|
label: ep.label,
|
||||||
|
method: ep.method,
|
||||||
|
path: ep.path,
|
||||||
|
description: ep.description,
|
||||||
|
attributes: ep.attributes.map((attr) => ({
|
||||||
|
name: attr.name,
|
||||||
|
type: attr.type,
|
||||||
|
description: attr.description,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
106
tools/eval/README.md
Normal file
106
tools/eval/README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Eval Harness — Rule 6
|
||||||
|
|
||||||
|
Fixture-based regression tests for generated artifacts.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
> "Evals are the test suite for your prompts. You would never ship code without tests;
|
||||||
|
> don't ship prompts without evals." — Anthropic Engineering
|
||||||
|
|
||||||
|
The validation gate (`tools/validate-generation.mjs`) checks **existence** and **structural compliance**.
|
||||||
|
The eval harness checks **semantic correctness**: are the right patterns present in the generated code?
|
||||||
|
Do the generated files actually follow the rules in `prompts/`?
|
||||||
|
|
||||||
|
Together they enforce:
|
||||||
|
- Gate: "file exists, field names present, auth seams wired"
|
||||||
|
- Evals: "DTO has class-validator decorators, FK uses ReferenceInput, date uses DateInput, guard is present"
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all evals
|
||||||
|
npm run eval:generation
|
||||||
|
|
||||||
|
# Run evals for one entity
|
||||||
|
node tools/eval/run-evals.mjs --entity equipment
|
||||||
|
|
||||||
|
# Verbose output (show each file being checked)
|
||||||
|
node tools/eval/run-evals.mjs --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fixture format
|
||||||
|
|
||||||
|
Each fixture lives in `tools/eval/fixtures/<entity>/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
fixtures/
|
||||||
|
equipment/
|
||||||
|
meta.json ← what this fixture tests
|
||||||
|
backend.assertions.json ← patterns the NestJS files must satisfy
|
||||||
|
frontend.assertions.json ← patterns the React Admin files must satisfy
|
||||||
|
repair-order/
|
||||||
|
meta.json
|
||||||
|
backend.assertions.json
|
||||||
|
frontend.assertions.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### `meta.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entity": "Equipment",
|
||||||
|
"kebab": "equipment",
|
||||||
|
"resource": "equipment",
|
||||||
|
"description": "...",
|
||||||
|
"tests": ["dto-decorator-coverage", "auth-guards", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `*.assertions.json`
|
||||||
|
|
||||||
|
Each file entry supports:
|
||||||
|
|
||||||
|
| Key | Type | Meaning |
|
||||||
|
|-----|------|---------|
|
||||||
|
| `path` | string | Relative path from repo root |
|
||||||
|
| `must_contain` | string[] | Each string must appear as a literal substring |
|
||||||
|
| `must_not_contain` | string[] | Each string must NOT appear |
|
||||||
|
| `must_match_regex` | string[] | Each pattern must match (multiline dot-all) |
|
||||||
|
| `must_not_match_regex` | string[] | Each pattern must NOT match |
|
||||||
|
| `comment` | string | Human-readable explanation of what is being tested |
|
||||||
|
|
||||||
|
## Eval-driven development workflow
|
||||||
|
|
||||||
|
This is the critical principle from Anthropic and Google:
|
||||||
|
|
||||||
|
1. **Write the failing eval first.** When you change a prompt or add a rule, add an
|
||||||
|
assertion that captures the new expectation *before* re-generating.
|
||||||
|
2. **Run evals**: `npm run eval:generation` → see failures.
|
||||||
|
3. **Re-generate** the affected entity (following the generation workflow in `AGENTS.md`).
|
||||||
|
4. **Run evals again**: all pass → the change is verified.
|
||||||
|
5. **Commit both** the updated fixture and the regenerated artifacts together.
|
||||||
|
|
||||||
|
A passing eval after a prompt change confirms the LLM followed the new rule.
|
||||||
|
A failing eval before a prompt change tells you exactly which prior contract was broken.
|
||||||
|
|
||||||
|
## Adding a new entity fixture
|
||||||
|
|
||||||
|
When adding a new entity to `domain/toir.api.dsl` and generating its backend + frontend:
|
||||||
|
|
||||||
|
1. Create `tools/eval/fixtures/<kebab>/meta.json`
|
||||||
|
2. Create `tools/eval/fixtures/<kebab>/backend.assertions.json` with at minimum:
|
||||||
|
- controller: `@Controller(...)`, `@UseGuards(`, `JwtAuthGuard`, HTTP methods
|
||||||
|
- create_dto: `from 'class-validator'`, required fields with `!:`, `@IsString(`, `@IsOptional(`
|
||||||
|
- update_dto: `from 'class-validator'`, fields with `?:`, `@IsOptional(`
|
||||||
|
3. Create `tools/eval/fixtures/<kebab>/frontend.assertions.json` with at minimum:
|
||||||
|
- create: `ReferenceInput` for FK fields, `NumberInput` for numeric, `DateInput` for date, `SelectInput` for enum
|
||||||
|
- show: `ReferenceField` for FK fields, `DateField` for date
|
||||||
|
4. Run `npm run eval:generation` to verify the fixture catches real issues.
|
||||||
|
|
||||||
|
## Integration with git hooks
|
||||||
|
|
||||||
|
The pre-commit hook (installed by `npm run install-hooks`) runs both:
|
||||||
|
1. `node tools/validate-generation.mjs --artifacts-only` — existence gate
|
||||||
|
2. `npm run eval:generation` — semantic eval gate
|
||||||
|
|
||||||
|
Both must pass before a commit is accepted.
|
||||||
79
tools/eval/fixtures/equipment/backend.assertions.json
Normal file
79
tools/eval/fixtures/equipment/backend.assertions.json
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"entity": "Equipment",
|
||||||
|
"files": {
|
||||||
|
"controller": {
|
||||||
|
"path": "server/src/modules/equipment/equipment.controller.ts",
|
||||||
|
"must_contain": [
|
||||||
|
"@Controller('equipments')",
|
||||||
|
"@UseGuards(",
|
||||||
|
"JwtAuthGuard",
|
||||||
|
"@Get()",
|
||||||
|
"@Post()",
|
||||||
|
"@Get(':id')",
|
||||||
|
"@Patch(':id')",
|
||||||
|
"@Delete(':id')"
|
||||||
|
],
|
||||||
|
"must_not_contain": [
|
||||||
|
"@Put(':id')",
|
||||||
|
"@Post(':id')"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"@Delete\\(':id'\\)[\\s\\S]{0,80}@Roles\\('admin'\\)|@Roles\\('admin'\\)[\\s\\S]{0,80}@Delete\\(':id'\\)"
|
||||||
|
],
|
||||||
|
"comment": "Equipment controller must expose the CRUD verbs expected by the DSL-compatible React Admin contract."
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"path": "server/src/modules/equipment/equipment.service.ts",
|
||||||
|
"must_contain": [
|
||||||
|
"setListHeaders(response",
|
||||||
|
"_start",
|
||||||
|
"_end",
|
||||||
|
"_sort",
|
||||||
|
"_order"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"mode.*insensitive|insensitive.*mode",
|
||||||
|
"status.*in\\b|\\bin\\b.*status"
|
||||||
|
],
|
||||||
|
"comment": "Service must translate React Admin list params into Prisma filters and delegate header wiring through the shared helper."
|
||||||
|
},
|
||||||
|
"create_dto": {
|
||||||
|
"path": "server/src/modules/equipment/dto/create-equipment.dto.ts",
|
||||||
|
"must_contain": [
|
||||||
|
"from 'class-validator'",
|
||||||
|
"inventoryNumber!:",
|
||||||
|
"name!:",
|
||||||
|
"equipmentType!:",
|
||||||
|
"periodicityTO!:",
|
||||||
|
"status!:",
|
||||||
|
"@IsString(",
|
||||||
|
"@IsOptional(",
|
||||||
|
"@IsEnum("
|
||||||
|
],
|
||||||
|
"must_not_contain": [
|
||||||
|
"id?:",
|
||||||
|
"id!:"
|
||||||
|
],
|
||||||
|
"comment": "Required fields use '!' suffix; optional fields use '?' with @IsOptional(); enum fields use @IsEnum(); class-validator must be imported."
|
||||||
|
},
|
||||||
|
"update_dto": {
|
||||||
|
"path": "server/src/modules/equipment/dto/update-equipment.dto.ts",
|
||||||
|
"must_contain": [
|
||||||
|
"from 'class-validator'",
|
||||||
|
"inventoryNumber?:",
|
||||||
|
"name?:",
|
||||||
|
"equipmentType?:",
|
||||||
|
"status?:",
|
||||||
|
"@IsOptional(",
|
||||||
|
"@IsString(",
|
||||||
|
"@IsEnum("
|
||||||
|
],
|
||||||
|
"must_not_contain": [
|
||||||
|
"inventoryNumber!:",
|
||||||
|
"name!:",
|
||||||
|
"status!:"
|
||||||
|
],
|
||||||
|
"comment": "Update DTO: all fields are optional ('?' suffix with @IsOptional())."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
tools/eval/fixtures/equipment/frontend.assertions.json
Normal file
57
tools/eval/fixtures/equipment/frontend.assertions.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"entity": "Equipment",
|
||||||
|
"resource": "equipment",
|
||||||
|
"files": {
|
||||||
|
"list": {
|
||||||
|
"path": "client/src/resources/equipment/EquipmentList.tsx",
|
||||||
|
"must_contain": [
|
||||||
|
"List",
|
||||||
|
"FilterButton",
|
||||||
|
"TextField",
|
||||||
|
"inventoryNumber"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"SelectArrayInput",
|
||||||
|
"source=\"status\""
|
||||||
|
],
|
||||||
|
"comment": "Equipment list must expose filter UI directly and keep enum filters."
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"path": "client/src/resources/equipment/EquipmentCreate.tsx",
|
||||||
|
"must_contain": [
|
||||||
|
"Create",
|
||||||
|
"SimpleForm",
|
||||||
|
"SelectInput"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"NumberInput[\\s\\S]{0,300}source=\"totalEngineHours\"|source=\"totalEngineHours\"[\\s\\S]{0,300}NumberInput",
|
||||||
|
"DateInput[\\s\\S]{0,300}source=\"dateOfInspection\"|source=\"dateOfInspection\"[\\s\\S]{0,300}DateInput",
|
||||||
|
"SelectInput[\\s\\S]{0,300}source=\"status\"|source=\"status\"[\\s\\S]{0,300}SelectInput"
|
||||||
|
],
|
||||||
|
"comment": "Equipment create form must keep type-correct inputs for enum, date, and decimal/number fields."
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"path": "client/src/resources/equipment/EquipmentEdit.tsx",
|
||||||
|
"must_contain": [
|
||||||
|
"Edit",
|
||||||
|
"SimpleForm",
|
||||||
|
"SelectInput"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"NumberInput[\\s\\S]{0,300}source=\"totalEngineHours\"|source=\"totalEngineHours\"[\\s\\S]{0,300}NumberInput",
|
||||||
|
"DateInput[\\s\\S]{0,300}source=\"dateOfInspection\"|source=\"dateOfInspection\"[\\s\\S]{0,300}DateInput"
|
||||||
|
],
|
||||||
|
"comment": "Equipment edit form must keep the same type-correctness guarantees as create."
|
||||||
|
},
|
||||||
|
"show": {
|
||||||
|
"path": "client/src/resources/equipment/EquipmentShow.tsx",
|
||||||
|
"must_contain": [
|
||||||
|
"Show",
|
||||||
|
"SimpleShowLayout",
|
||||||
|
"TextField",
|
||||||
|
"inventoryNumber"
|
||||||
|
],
|
||||||
|
"comment": "Show must display key fields including inventoryNumber."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
tools/eval/fixtures/equipment/meta.json
Normal file
15
tools/eval/fixtures/equipment/meta.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"entity": "Equipment",
|
||||||
|
"kebab": "equipment",
|
||||||
|
"resource": "equipment",
|
||||||
|
"description": "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"
|
||||||
|
]
|
||||||
|
}
|
||||||
62
tools/eval/fixtures/repair-order/backend.assertions.json
Normal file
62
tools/eval/fixtures/repair-order/backend.assertions.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"entity": "CategoryResource",
|
||||||
|
"files": {
|
||||||
|
"controller": {
|
||||||
|
"path": "server/src/modules/category-resource/category-resource.controller.ts",
|
||||||
|
"must_contain": [
|
||||||
|
"@Controller('category-resources')",
|
||||||
|
"@UseGuards(",
|
||||||
|
"JwtAuthGuard",
|
||||||
|
"@Get()",
|
||||||
|
"@Post()",
|
||||||
|
"@Get(':id')",
|
||||||
|
"@Patch(':id')",
|
||||||
|
"@Delete(':id')"
|
||||||
|
],
|
||||||
|
"must_not_contain": [
|
||||||
|
"@Put(':id')"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"@Delete\\(':id'\\)[\\s\\S]{0,120}@Roles\\('admin'\\)|@Roles\\('admin'\\)[\\s\\S]{0,120}@Delete\\(':id'\\)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"path": "server/src/modules/category-resource/category-resource.service.ts",
|
||||||
|
"must_contain": [
|
||||||
|
"setListHeaders",
|
||||||
|
"_start",
|
||||||
|
"_end",
|
||||||
|
"partId",
|
||||||
|
"employeeCode"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"part:\\s*\\{\\s*is:\\s*\\{\\s*name",
|
||||||
|
"employee:\\s*\\{\\s*is:\\s*\\{\\s*fullName"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"create_dto": {
|
||||||
|
"path": "server/src/modules/category-resource/dto/create-category-resource.dto.ts",
|
||||||
|
"must_contain": [
|
||||||
|
"from 'class-validator'",
|
||||||
|
"partId?:",
|
||||||
|
"employeeCode?:",
|
||||||
|
"@IsUUID(",
|
||||||
|
"@IsString(",
|
||||||
|
"@IsOptional("
|
||||||
|
],
|
||||||
|
"must_not_contain": [
|
||||||
|
"id?:",
|
||||||
|
"id!:"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"update_dto": {
|
||||||
|
"path": "server/src/modules/category-resource/dto/update-category-resource.dto.ts",
|
||||||
|
"must_contain": [
|
||||||
|
"from 'class-validator'",
|
||||||
|
"@IsOptional(",
|
||||||
|
"partId?:",
|
||||||
|
"employeeCode?:"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
tools/eval/fixtures/repair-order/frontend.assertions.json
Normal file
53
tools/eval/fixtures/repair-order/frontend.assertions.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"entity": "CategoryResource",
|
||||||
|
"resource": "category-resources",
|
||||||
|
"files": {
|
||||||
|
"list": {
|
||||||
|
"path": "client/src/resources/category-resource/CategoryResourceList.tsx",
|
||||||
|
"must_contain": [
|
||||||
|
"List",
|
||||||
|
"FilterButton",
|
||||||
|
"ReferenceField"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"ReferenceField[\\s\\S]{0,200}reference=\"parts\"|reference=\"parts\"[\\s\\S]{0,200}ReferenceField",
|
||||||
|
"ReferenceField[\\s\\S]{0,200}reference=\"employees\"|reference=\"employees\"[\\s\\S]{0,200}ReferenceField"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"path": "client/src/resources/category-resource/CategoryResourceCreate.tsx",
|
||||||
|
"must_contain": [
|
||||||
|
"Create",
|
||||||
|
"SimpleForm"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"ReferenceInput[\\s\\S]{0,200}reference=\"parts\"|reference=\"parts\"[\\s\\S]{0,200}ReferenceInput",
|
||||||
|
"ReferenceInput[\\s\\S]{0,200}reference=\"employees\"|reference=\"employees\"[\\s\\S]{0,200}ReferenceInput",
|
||||||
|
"AutocompleteInput[\\s\\S]{0,200}filterToQuery|filterToQuery[\\s\\S]{0,200}AutocompleteInput"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"path": "client/src/resources/category-resource/CategoryResourceEdit.tsx",
|
||||||
|
"must_contain": [
|
||||||
|
"Edit",
|
||||||
|
"SimpleForm"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"ReferenceInput[\\s\\S]{0,200}reference=\"parts\"|reference=\"parts\"[\\s\\S]{0,200}ReferenceInput",
|
||||||
|
"ReferenceInput[\\s\\S]{0,200}reference=\"employees\"|reference=\"employees\"[\\s\\S]{0,200}ReferenceInput"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"show": {
|
||||||
|
"path": "client/src/resources/category-resource/CategoryResourceShow.tsx",
|
||||||
|
"must_contain": [
|
||||||
|
"Show",
|
||||||
|
"SimpleShowLayout",
|
||||||
|
"ReferenceField"
|
||||||
|
],
|
||||||
|
"must_match_regex": [
|
||||||
|
"ReferenceField[\\s\\S]{0,200}reference=\"parts\"|reference=\"parts\"[\\s\\S]{0,200}ReferenceField",
|
||||||
|
"ReferenceField[\\s\\S]{0,200}reference=\"employees\"|reference=\"employees\"[\\s\\S]{0,200}ReferenceField"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tools/eval/fixtures/repair-order/meta.json
Normal file
13
tools/eval/fixtures/repair-order/meta.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"entity": "CategoryResource",
|
||||||
|
"kebab": "category-resource",
|
||||||
|
"resource": "category-resources",
|
||||||
|
"description": "Current FK-heavy entity: UUID PK with references to Part and Employee. Tests reference wiring, autocomplete filters, and protected CRUD routes.",
|
||||||
|
"tests": [
|
||||||
|
"dto-decorator-coverage",
|
||||||
|
"auth-guards",
|
||||||
|
"fk-reference-input",
|
||||||
|
"fk-reference-field",
|
||||||
|
"content-range-header"
|
||||||
|
]
|
||||||
|
}
|
||||||
184
tools/eval/run-evals.mjs
Normal file
184
tools/eval/run-evals.mjs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* tools/eval/run-evals.mjs
|
||||||
|
*
|
||||||
|
* Rule 6 — Eval harness: fixture-based regression tests for generated artifacts.
|
||||||
|
*
|
||||||
|
* Philosophy:
|
||||||
|
* - Evals are the test suite for prompts. Never ship a prompt change without
|
||||||
|
* running evals first.
|
||||||
|
* - Use deterministic pattern/regex checks ("reference-free" grading) rather
|
||||||
|
* than golden snapshot comparison. Patterns are maintainable; snapshots are
|
||||||
|
* brittle.
|
||||||
|
* - Eval-driven development: write a failing eval FIRST, then update the prompt
|
||||||
|
* or re-generate to make it pass.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node tools/eval/run-evals.mjs # run all fixtures
|
||||||
|
* node tools/eval/run-evals.mjs --entity equipment
|
||||||
|
* node tools/eval/run-evals.mjs --verbose
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = path.resolve(__dirname, '../..');
|
||||||
|
const fixturesDir = path.join(__dirname, 'fixtures');
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const verbose = args.has('--verbose') || args.has('-v');
|
||||||
|
const entityFilter = (() => {
|
||||||
|
const idx = process.argv.indexOf('--entity');
|
||||||
|
return idx !== -1 ? process.argv[idx + 1] : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assertion engine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let totalChecks = 0;
|
||||||
|
let totalFailures = 0;
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
function readArtifact(relativePath) {
|
||||||
|
const filePath = path.join(rootDir, relativePath);
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
return readFileSync(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFileAssertions(filePath, fileSpec, entityLabel) {
|
||||||
|
const content = readArtifact(filePath);
|
||||||
|
|
||||||
|
if (content === null) {
|
||||||
|
totalChecks++;
|
||||||
|
totalFailures++;
|
||||||
|
failures.push({ entity: entityLabel, file: filePath, check: 'file-exists', result: 'FAIL', detail: `File not found: ${filePath}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(` [${entityLabel}] Checking ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const expected of fileSpec.must_contain ?? []) {
|
||||||
|
totalChecks++;
|
||||||
|
if (!content.includes(expected)) {
|
||||||
|
totalFailures++;
|
||||||
|
failures.push({ entity: entityLabel, file: filePath, check: 'must_contain', result: 'FAIL', detail: `Missing: ${expected}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const forbidden of fileSpec.must_not_contain ?? []) {
|
||||||
|
totalChecks++;
|
||||||
|
if (content.includes(forbidden)) {
|
||||||
|
totalFailures++;
|
||||||
|
failures.push({ entity: entityLabel, file: filePath, check: 'must_not_contain', result: 'FAIL', detail: `Forbidden pattern found: ${forbidden}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const patternStr of fileSpec.must_match_regex ?? []) {
|
||||||
|
totalChecks++;
|
||||||
|
try {
|
||||||
|
const re = new RegExp(patternStr);
|
||||||
|
if (!re.test(content)) {
|
||||||
|
totalFailures++;
|
||||||
|
failures.push({ entity: entityLabel, file: filePath, check: 'must_match_regex', result: 'FAIL', detail: `Regex not matched: ${patternStr}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
totalFailures++;
|
||||||
|
failures.push({ entity: entityLabel, file: filePath, check: 'must_match_regex', result: 'ERROR', detail: `Bad regex: ${patternStr} — ${e.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const patternStr of fileSpec.must_not_match_regex ?? []) {
|
||||||
|
totalChecks++;
|
||||||
|
try {
|
||||||
|
const re = new RegExp(patternStr);
|
||||||
|
if (re.test(content)) {
|
||||||
|
totalFailures++;
|
||||||
|
failures.push({ entity: entityLabel, file: filePath, check: 'must_not_match_regex', result: 'FAIL', detail: `Forbidden regex matched: ${patternStr}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
totalFailures++;
|
||||||
|
failures.push({ entity: entityLabel, file: filePath, check: 'must_not_match_regex', result: 'ERROR', detail: `Bad regex: ${patternStr} — ${e.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFixture(fixtureDir) {
|
||||||
|
const metaPath = path.join(fixtureDir, 'meta.json');
|
||||||
|
if (!existsSync(metaPath)) return;
|
||||||
|
|
||||||
|
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
||||||
|
const { entity, kebab } = meta;
|
||||||
|
|
||||||
|
if (entityFilter && kebab !== entityFilter && entity.toLowerCase() !== entityFilter.toLowerCase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`\n[EVAL] ${entity} — ${meta.description ?? ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendPath = path.join(fixtureDir, 'backend.assertions.json');
|
||||||
|
if (existsSync(backendPath)) {
|
||||||
|
const spec = JSON.parse(readFileSync(backendPath, 'utf8'));
|
||||||
|
for (const [key, fileSpec] of Object.entries(spec.files ?? {})) {
|
||||||
|
runFileAssertions(fileSpec.path, fileSpec, `${entity}/${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontendPath = path.join(fixtureDir, 'frontend.assertions.json');
|
||||||
|
if (existsSync(frontendPath)) {
|
||||||
|
const spec = JSON.parse(readFileSync(frontendPath, 'utf8'));
|
||||||
|
for (const [key, fileSpec] of Object.entries(spec.files ?? {})) {
|
||||||
|
runFileAssertions(fileSpec.path, fileSpec, `${entity}/${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fixtureDirs = readdirSync(fixturesDir, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory())
|
||||||
|
.map((d) => path.join(fixturesDir, d.name));
|
||||||
|
|
||||||
|
for (const dir of fixtureDirs) {
|
||||||
|
runFixture(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Report
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('══════════════════════════════════════════════');
|
||||||
|
console.log(' KIS-TOiR Eval Report');
|
||||||
|
console.log('══════════════════════════════════════════════');
|
||||||
|
console.log(` Fixtures: ${fixtureDirs.length}`);
|
||||||
|
console.log(` Checks: ${totalChecks}`);
|
||||||
|
console.log(` Passed: ${totalChecks - totalFailures}`);
|
||||||
|
console.log(` Failed: ${totalFailures}`);
|
||||||
|
console.log('══════════════════════════════════════════════');
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log('');
|
||||||
|
console.log('Failures:');
|
||||||
|
for (const f of failures) {
|
||||||
|
console.log(` [${f.result}] ${f.entity} — ${f.file}`);
|
||||||
|
console.log(` ${f.check}: ${f.detail}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
console.log('To fix: update the prompt or re-generate the failing entity, then re-run evals.');
|
||||||
|
console.log('To update a fixture (intentional change): edit tools/eval/fixtures/<entity>/*.assertions.json');
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('All evals passed.');
|
||||||
|
console.log('');
|
||||||
13
tools/generate-api-summary.mjs
Normal file
13
tools/generate-api-summary.mjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { buildApiSummary } from './api-summary.mjs';
|
||||||
|
|
||||||
|
const rootDir = process.cwd();
|
||||||
|
const outputPath = path.join(rootDir, 'api-summary.json');
|
||||||
|
|
||||||
|
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
|
||||||
|
const summary = buildApiSummary(rootDir);
|
||||||
|
writeFileSync(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Generated ${path.relative(rootDir, outputPath)}`);
|
||||||
5
tools/hooks/pre-commit
Normal file
5
tools/hooks/pre-commit
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Pre-commit hook: runs the generation validation gate and eval harness.
|
||||||
|
# Install with: npm run install-hooks
|
||||||
|
|
||||||
|
node tools/validate-generation.mjs --artifacts-only && node tools/eval/run-evals.mjs
|
||||||
19
tools/install-hooks.mjs
Normal file
19
tools/install-hooks.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { copyFileSync, chmodSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const hooksDir = path.join(root, '.git', 'hooks');
|
||||||
|
const src = path.join(root, 'tools', 'hooks', 'pre-commit');
|
||||||
|
const dest = path.join(hooksDir, 'pre-commit');
|
||||||
|
|
||||||
|
if (!existsSync(path.join(root, '.git'))) {
|
||||||
|
console.error('Not a git repository. Run from the repo root.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(hooksDir, { recursive: true });
|
||||||
|
copyFileSync(src, dest);
|
||||||
|
try { chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||||
|
console.log('Installed pre-commit hook → .git/hooks/pre-commit');
|
||||||
814
tools/validate-generation.mjs
Normal file
814
tools/validate-generation.mjs
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { getApiDslFiles, parseApiDsl, buildApiSummary } from './api-summary.mjs';
|
||||||
|
|
||||||
|
const rootDir = process.cwd();
|
||||||
|
const args = new Set(process.argv.slice(2));
|
||||||
|
const artifactsOnly = args.has('--artifacts-only');
|
||||||
|
const runRuntime = args.has('--run-runtime');
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
function assertCondition(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
failures.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function warn(message) {
|
||||||
|
warnings.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIfExists(relativePath) {
|
||||||
|
const filePath = path.join(rootDir, relativePath);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFile(relativePath) {
|
||||||
|
assertCondition(existsSync(path.join(rootDir, relativePath)), `Missing file: ${relativePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFiles(relativePaths) {
|
||||||
|
relativePaths.forEach(requireFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireContent(relativePath, pattern, message) {
|
||||||
|
const contents = readIfExists(relativePath);
|
||||||
|
assertCondition(Boolean(contents), `Missing file: ${relativePath}`);
|
||||||
|
if (!contents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCondition(pattern.test(contents), `${message} (${relativePath})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(relativePath) {
|
||||||
|
const raw = readIfExists(relativePath);
|
||||||
|
if (!raw) {
|
||||||
|
failures.push(`Missing file: ${relativePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`Invalid JSON in ${relativePath}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kebabCase(value) {
|
||||||
|
return value
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRealmArtifactPath() {
|
||||||
|
const rootFiles = readdirSync(rootDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
|
||||||
|
const realmArtifacts = rootFiles.filter((entry) => /-realm\.json$/i.test(entry));
|
||||||
|
assertCondition(realmArtifacts.length === 1, 'Expected exactly one root-level *-realm.json artifact');
|
||||||
|
|
||||||
|
return realmArtifacts[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceInfo() {
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
dir: path.join(rootDir, 'server'),
|
||||||
|
packagePath: 'server/package.json',
|
||||||
|
scaffoldFiles: [
|
||||||
|
'server/package.json',
|
||||||
|
'server/tsconfig.json',
|
||||||
|
'server/tsconfig.build.json',
|
||||||
|
'server/nest-cli.json',
|
||||||
|
'server/src/main.ts',
|
||||||
|
'server/src/app.module.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
dir: path.join(rootDir, 'client'),
|
||||||
|
packagePath: 'client/package.json',
|
||||||
|
scaffoldFiles: [
|
||||||
|
'client/package.json',
|
||||||
|
'client/index.html',
|
||||||
|
'client/tsconfig.json',
|
||||||
|
'client/tsconfig.node.json',
|
||||||
|
'client/vite.config.ts',
|
||||||
|
'client/src/main.tsx',
|
||||||
|
'client/src/vite-env.d.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBuildChecks() {
|
||||||
|
requireFiles([
|
||||||
|
'README.md',
|
||||||
|
'package.json',
|
||||||
|
'domain/dsl-spec.md',
|
||||||
|
'api-summary.json',
|
||||||
|
'server/prisma/schema.prisma',
|
||||||
|
'server/.env.example',
|
||||||
|
'client/.env.example',
|
||||||
|
'prompts/general-prompt.md',
|
||||||
|
'prompts/auth-rules.md',
|
||||||
|
'prompts/backend-rules.md',
|
||||||
|
'prompts/frontend-rules.md',
|
||||||
|
'prompts/runtime-rules.md',
|
||||||
|
'prompts/validation-rules.md',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// rule: AGENTS.md §Tier-1 — api.dsl must exist
|
||||||
|
const apiDslFiles = getApiDslFiles(rootDir);
|
||||||
|
assertCondition(apiDslFiles.length > 0, 'Expected at least one domain/*.api.dsl file');
|
||||||
|
|
||||||
|
// rule: AGENTS.md §Tier-2 — api-summary.json must match parsed api.dsl
|
||||||
|
const actualApiSummaryRaw = readIfExists('api-summary.json');
|
||||||
|
if (actualApiSummaryRaw) {
|
||||||
|
try {
|
||||||
|
const expectedApiSummary = JSON.stringify(buildApiSummary(rootDir), null, 2);
|
||||||
|
assertCondition(
|
||||||
|
actualApiSummaryRaw.trim() === expectedApiSummary,
|
||||||
|
'api-summary.json is out of date. Run `npm run generate:api-summary`.',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`api-summary.json freshness check failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { server, client } = getWorkspaceInfo();
|
||||||
|
requireFiles(server.scaffoldFiles);
|
||||||
|
requireFiles(client.scaffoldFiles);
|
||||||
|
|
||||||
|
const serverPackage = parseJson(server.packagePath);
|
||||||
|
if (serverPackage) {
|
||||||
|
assertCondition(serverPackage.scripts?.build === 'nest build', 'server/package.json must keep `build = nest build`');
|
||||||
|
assertCondition(serverPackage.scripts?.start === 'nest start', 'server/package.json must keep `start = nest start`');
|
||||||
|
assertCondition(serverPackage.scripts?.['start:dev'] === 'nest start --watch', 'server/package.json must keep `start:dev = nest start --watch`');
|
||||||
|
assertCondition(Boolean(serverPackage.scripts?.['start:prod']), 'server/package.json must define a start:prod script');
|
||||||
|
assertCondition(Boolean(serverPackage.dependencies?.['@nestjs/core']), 'server/package.json must keep Nest runtime dependencies');
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientPackage = parseJson(client.packagePath);
|
||||||
|
if (clientPackage) {
|
||||||
|
assertCondition(clientPackage.scripts?.dev === 'vite', 'client/package.json must keep `dev = vite`');
|
||||||
|
assertCondition(clientPackage.scripts?.build === 'vite build', 'client/package.json must keep `build = vite build`');
|
||||||
|
assertCondition(clientPackage.scripts?.preview === 'vite preview', 'client/package.json must keep `preview = vite preview`');
|
||||||
|
assertCondition(Boolean(clientPackage.devDependencies?.vite), 'client/package.json must keep Vite as a dev dependency');
|
||||||
|
assertCondition(Boolean(clientPackage.devDependencies?.['@vitejs/plugin-react']), 'client/package.json must keep @vitejs/plugin-react as a dev dependency');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAuthChecks() {
|
||||||
|
requireFiles([
|
||||||
|
'client/src/auth/keycloak.ts',
|
||||||
|
'client/src/auth/authProvider.ts',
|
||||||
|
'client/src/dataProvider.ts',
|
||||||
|
'client/src/config/env.ts',
|
||||||
|
'client/src/main.tsx',
|
||||||
|
'client/src/App.tsx',
|
||||||
|
'server/src/auth/auth.module.ts',
|
||||||
|
'server/src/auth/auth.service.ts',
|
||||||
|
'server/src/auth/guards/jwt-auth.guard.ts',
|
||||||
|
'server/src/auth/guards/roles.guard.ts',
|
||||||
|
'server/src/auth/decorators/public.decorator.ts',
|
||||||
|
'server/src/auth/decorators/roles.decorator.ts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
requireContent(
|
||||||
|
'client/src/auth/keycloak.ts',
|
||||||
|
/onLoad:\s*'login-required'/,
|
||||||
|
'Frontend auth must initialize Keycloak with login-required',
|
||||||
|
);
|
||||||
|
requireContent(
|
||||||
|
'client/src/auth/keycloak.ts',
|
||||||
|
/pkceMethod:\s*'S256'/,
|
||||||
|
'Frontend auth must use PKCE S256',
|
||||||
|
);
|
||||||
|
requireContent(
|
||||||
|
'client/src/auth/keycloak.ts',
|
||||||
|
/updateToken\(/,
|
||||||
|
'Frontend auth must refresh access tokens through the Keycloak adapter',
|
||||||
|
);
|
||||||
|
|
||||||
|
const keycloakSource = readIfExists('client/src/auth/keycloak.ts') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
!/loadUserProfile\(/.test(keycloakSource),
|
||||||
|
'Frontend auth must not call keycloak.loadUserProfile()',
|
||||||
|
);
|
||||||
|
|
||||||
|
requireContent(
|
||||||
|
'client/src/dataProvider.ts',
|
||||||
|
/Authorization', `Bearer \$\{token\}`/,
|
||||||
|
'dataProvider must attach bearer tokens in the shared request seam',
|
||||||
|
);
|
||||||
|
|
||||||
|
const authProvider = readIfExists('client/src/auth/authProvider.ts') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
/status === 401/.test(authProvider) && /status === 403/.test(authProvider),
|
||||||
|
'authProvider must distinguish 401 and 403 semantics',
|
||||||
|
);
|
||||||
|
|
||||||
|
const authService = readIfExists('server/src/auth/auth.service.ts') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
/jwtVerify/.test(authService) && /KEYCLOAK_ISSUER_URL/.test(authService) && /KEYCLOAK_AUDIENCE/.test(authService),
|
||||||
|
'Backend auth must verify JWTs with issuer and audience',
|
||||||
|
);
|
||||||
|
assertCondition(/realm_access/.test(authService), 'Backend auth must extract roles from realm_access.roles');
|
||||||
|
assertCondition(/KEYCLOAK_JWKS_URL/.test(authService), 'Backend auth must support explicit KEYCLOAK_JWKS_URL');
|
||||||
|
assertCondition(/\.well-known\/openid-configuration/.test(authService), 'Backend auth must try OIDC discovery before fallback certs');
|
||||||
|
assertCondition(/protocol\/openid-connect\/certs/.test(authService), 'Backend auth must keep Keycloak certs fallback resolution');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNaturalKeyChecks() {
|
||||||
|
// rule: AGENTS.md §Tier-2 — derive natural-key entities from api-summary.json
|
||||||
|
// A natural-key entity is identified by a root DTO (DTO.X — not Create/Update/Filter/...)
|
||||||
|
// that has a field annotated with `key primary` where the field name is not 'id'.
|
||||||
|
const apiSummaryRaw = readIfExists('api-summary.json');
|
||||||
|
if (!apiSummaryRaw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiSummary;
|
||||||
|
try {
|
||||||
|
apiSummary = JSON.parse(apiSummaryRaw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const naturalKeyEntities = [];
|
||||||
|
for (const dto of apiSummary.dtos ?? []) {
|
||||||
|
// Only root DTOs: DTO.X (not DTO.XCreate / Update / Filter / ListRequest / ListResponse / Page*)
|
||||||
|
if (/Create$|Update$|Filter$|ListRequest$|ListResponse$|PageRequest$|PageInfo$/.test(dto.name)) continue;
|
||||||
|
|
||||||
|
const entityName = dto.name.replace(/^DTO\./, '');
|
||||||
|
const primaryField = (dto.fields ?? []).find((f) => f.primary === true && f.name !== 'id');
|
||||||
|
if (primaryField) {
|
||||||
|
naturalKeyEntities.push({ name: entityName, primaryKey: primaryField.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entity of naturalKeyEntities) {
|
||||||
|
const moduleName = kebabCase(entity.name);
|
||||||
|
const controllerPath = `server/src/modules/${moduleName}/${moduleName}.controller.ts`;
|
||||||
|
const servicePath = `server/src/modules/${moduleName}/${moduleName}.service.ts`;
|
||||||
|
const controller = readIfExists(controllerPath) ?? '';
|
||||||
|
const service = readIfExists(servicePath) ?? '';
|
||||||
|
|
||||||
|
assertCondition(Boolean(controller), `Missing file: ${controllerPath}`);
|
||||||
|
assertCondition(Boolean(service), `Missing file: ${servicePath}`);
|
||||||
|
if (!controller || !service) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
controller.includes(`@Get(':${entity.primaryKey}')`) &&
|
||||||
|
controller.includes(`@Patch(':${entity.primaryKey}')`) &&
|
||||||
|
controller.includes(`@Delete(':${entity.primaryKey}')`),
|
||||||
|
`${entity.name} controller must use :${entity.primaryKey} route params`,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
service.includes(`id: item.${entity.primaryKey}`) || service.includes(`id: record.${entity.primaryKey}`),
|
||||||
|
`${entity.name} service must map the natural key to React Admin id`,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
service.includes(`const { id, ${entity.primaryKey}: _pk`) || service.includes(`const { id: _pk, ${entity.primaryKey}`),
|
||||||
|
`${entity.name} update path must sanitize id and primary key from Prisma update data`,
|
||||||
|
);
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
/sortField\s*===\s*'id'/.test(service) || /sortField\s*===\s*"id"/.test(service),
|
||||||
|
`${entity.name} natural-key sort must map React Admin id sorting back to the real primary key`,
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
!service.includes("query._sort || 'id'"),
|
||||||
|
`${entity.name} natural-key sort must not fall back to the physical id field`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRealmChecks() {
|
||||||
|
const realmArtifactName = getRealmArtifactPath();
|
||||||
|
if (!realmArtifactName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifact = parseJson(realmArtifactName);
|
||||||
|
if (!artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realmRoles = artifact.roles?.realm?.map((role) => role.name) ?? [];
|
||||||
|
const frontendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-frontend'));
|
||||||
|
const backendClient = artifact.clients?.find((client) => client.clientId?.endsWith('-backend'));
|
||||||
|
const audienceScope = artifact.clientScopes?.find((scope) => scope.name === 'api-audience');
|
||||||
|
|
||||||
|
['admin', 'editor', 'viewer'].forEach((role) => {
|
||||||
|
assertCondition(realmRoles.includes(role), `Realm artifact must define realm role ${role}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertCondition(Boolean(frontendClient), 'Realm artifact must define the frontend SPA client');
|
||||||
|
assertCondition(Boolean(backendClient), 'Realm artifact must define the backend resource client');
|
||||||
|
assertCondition(Boolean(audienceScope), 'Realm artifact must define the api-audience client scope');
|
||||||
|
|
||||||
|
if (frontendClient) {
|
||||||
|
assertCondition(frontendClient.publicClient === true, 'Frontend realm client must be public');
|
||||||
|
assertCondition(
|
||||||
|
frontendClient.standardFlowEnabled === true &&
|
||||||
|
frontendClient.implicitFlowEnabled === false &&
|
||||||
|
frontendClient.directAccessGrantsEnabled === false,
|
||||||
|
'Frontend realm client must use standard flow only',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
frontendClient.attributes?.['pkce.code.challenge.method'] === 'S256',
|
||||||
|
'Frontend realm client must enforce PKCE S256',
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapperNames = new Set((frontendClient.protocolMappers ?? []).map((mapper) => mapper.name));
|
||||||
|
['sub', 'preferred_username', 'email', 'name', 'realm roles'].forEach((mapperName) => {
|
||||||
|
assertCondition(mapperNames.has(mapperName), `Frontend realm client must include protocol mapper ${mapperName}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backendClient && audienceScope) {
|
||||||
|
const audienceMapper = (audienceScope.protocolMappers ?? []).find(
|
||||||
|
(mapper) => mapper.protocolMapper === 'oidc-audience-mapper',
|
||||||
|
);
|
||||||
|
assertCondition(Boolean(audienceMapper), 'api-audience scope must include an audience mapper');
|
||||||
|
assertCondition(
|
||||||
|
audienceMapper?.config?.['included.client.audience'] === backendClient.clientId,
|
||||||
|
'api-audience scope must deliver the backend audience/client id',
|
||||||
|
);
|
||||||
|
assertCondition(backendClient.bearerOnly === true, 'Backend realm client must be bearer-only');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRuntimeContractChecks() {
|
||||||
|
requireFile('docker-compose.yml');
|
||||||
|
const compose = readIfExists('docker-compose.yml') ?? '';
|
||||||
|
assertCondition(/image:\s*postgres:16/.test(compose), 'docker-compose must provision postgres:16');
|
||||||
|
const hasKeycloakService =
|
||||||
|
/^\s{2}keycloak\s*:/m.test(compose) || /image:\s*.*keycloak/i.test(compose);
|
||||||
|
assertCondition(!hasKeycloakService, 'docker-compose must remain PostgreSQL-only (no Keycloak container)');
|
||||||
|
|
||||||
|
const serverEnvExample = readIfExists('server/.env.example') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
/KEYCLOAK_ISSUER_URL="https:\/\/sso\.greact\.ru\/realms\/toir"/.test(serverEnvExample),
|
||||||
|
'server/.env.example must keep the working Keycloak issuer example',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
/KEYCLOAK_AUDIENCE="?toir-backend"?/.test(serverEnvExample),
|
||||||
|
'server/.env.example must keep the working backend audience example',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
/CORS_ALLOWED_ORIGINS="http:\/\/localhost:5173,https:\/\/toir-frontend\.greact\.ru"/.test(serverEnvExample),
|
||||||
|
'server/.env.example must keep the working CORS example with the production frontend domain',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
!/KEYCLOAK_ISSUER_URL=http:\/\/localhost:8080\/realms\/toir/.test(serverEnvExample),
|
||||||
|
'server/.env.example must not regress to localhost Keycloak as the baseline issuer example',
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientEnvExample = readIfExists('client/.env.example') ?? '';
|
||||||
|
assertCondition(
|
||||||
|
/VITE_KEYCLOAK_URL=https:\/\/sso\.greact\.ru/.test(clientEnvExample),
|
||||||
|
'client/.env.example must keep the working domain-based Keycloak URL example',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
/VITE_KEYCLOAK_REALM=toir/.test(clientEnvExample) && /VITE_KEYCLOAK_CLIENT_ID=toir-frontend/.test(clientEnvExample),
|
||||||
|
'client/.env.example must keep the working realm and frontend client examples',
|
||||||
|
);
|
||||||
|
assertCondition(
|
||||||
|
!/VITE_KEYCLOAK_URL=http:\/\/localhost:8080/.test(clientEnvExample),
|
||||||
|
'client/.env.example must not regress to localhost Keycloak as the baseline example',
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthController = readIfExists('server/src/health/health.controller.ts') ?? '';
|
||||||
|
assertCondition(Boolean(healthController), 'Missing file: server/src/health/health.controller.ts');
|
||||||
|
if (healthController) {
|
||||||
|
assertCondition(
|
||||||
|
/@Public\(\)/.test(healthController) && /@Controller\('health'\)/.test(healthController),
|
||||||
|
'/health must stay public',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, commandArgs, workdir, failureLabel) {
|
||||||
|
const runtimeEnv = { ...process.env };
|
||||||
|
const envExamplePath = path.join(workdir, '.env.example');
|
||||||
|
if (existsSync(envExamplePath)) {
|
||||||
|
const envExample = readFileSync(envExamplePath, 'utf8');
|
||||||
|
for (const line of envExample.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = trimmed.indexOf('=');
|
||||||
|
if (separator <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separator).trim();
|
||||||
|
const value = trimmed.slice(separator + 1).trim().replace(/^"|"$/g, '');
|
||||||
|
if (!(key in runtimeEnv)) {
|
||||||
|
runtimeEnv[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandLine = [command, ...commandArgs].join(' ');
|
||||||
|
const result = spawnSync(commandLine, {
|
||||||
|
cwd: workdir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
shell: true,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
failures.push(`${failureLabel}: ${commandLine}\n${result.error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim();
|
||||||
|
const stdout = result.stdout?.trim();
|
||||||
|
failures.push(
|
||||||
|
`${failureLabel}: ${commandLine}${stderr ? `\n${stderr}` : stdout ? `\n${stdout}` : ''}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeValidateWorkspaceBuild(relativeDir) {
|
||||||
|
const workspaceDir = path.join(rootDir, relativeDir);
|
||||||
|
if (!existsSync(path.join(workspaceDir, 'package.json'))) {
|
||||||
|
failures.push(`Missing file: ${relativeDir}/package.json`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(path.join(workspaceDir, 'node_modules'))) {
|
||||||
|
warn(`Skipped build verification for ${relativeDir}: install dependencies in ${relativeDir}/ to validate workspace buildability.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand('npm', ['run', 'build'], workspaceDir, `Build verification failed in ${relativeDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBuildExecutionChecks() {
|
||||||
|
maybeValidateWorkspaceBuild('server');
|
||||||
|
maybeValidateWorkspaceBuild('client');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRuntimeExecutionChecks() {
|
||||||
|
const serverDir = path.join(rootDir, 'server');
|
||||||
|
if (!existsSync(path.join(serverDir, 'node_modules'))) {
|
||||||
|
failures.push(
|
||||||
|
'Runtime validation requires installed backend dependencies. Run `npm install` in server/ before `npm run validate:generation:runtime`.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand('npx', ['prisma', 'generate'], serverDir, 'Prisma generate failed');
|
||||||
|
runCommand(
|
||||||
|
'npx',
|
||||||
|
['prisma', 'migrate', 'dev', '--name', 'baseline', '--skip-generate'],
|
||||||
|
serverDir,
|
||||||
|
'Prisma migrate failed',
|
||||||
|
);
|
||||||
|
runCommand('npx', ['prisma', 'db', 'seed'], serverDir, 'Prisma seed failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Output contract checks — Rule 4 (grounded and schema-bound outputs)
|
||||||
|
//
|
||||||
|
// Verify that generated artifacts conform to the output contracts declared in
|
||||||
|
// prompts/backend-rules.md and prompts/frontend-rules.md.
|
||||||
|
// All checks are deterministic regex / substring patterns.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DSL field type → expected class-validator decorator (pattern fragment).
|
||||||
|
// rule: backend-rules.md §Type mappings
|
||||||
|
const DSL_TYPE_TO_CV_DECORATOR = {
|
||||||
|
uuid: '@IsUUID(',
|
||||||
|
string: '@IsString(',
|
||||||
|
text: '@IsString(',
|
||||||
|
integer: ['@IsInt(', '@IsNumber('],
|
||||||
|
number: '@IsNumber(',
|
||||||
|
decimal: '@IsString(',
|
||||||
|
date: '@IsString(',
|
||||||
|
boolean: '@IsBoolean(',
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeRegexStr(s) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that a class-validator decorator appears within 400 chars before fieldName
|
||||||
|
function fieldHasDecorator(content, fieldName, decoratorFragment) {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`${escapeRegexStr(decoratorFragment)}[\\s\\S]{0,400}${escapeRegexStr(fieldName)}[?!]?\\s*:`,
|
||||||
|
);
|
||||||
|
return pattern.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDtoDecoratorCoverage() {
|
||||||
|
const { dtos, enums } = parseApiDsl(rootDir);
|
||||||
|
const enumNames = new Set(enums.map((e) => e.name));
|
||||||
|
|
||||||
|
for (const dto of dtos) {
|
||||||
|
const createMatch = dto.name.match(/^DTO\.(\w+)Create$/);
|
||||||
|
const updateMatch = dto.name.match(/^DTO\.(\w+)Update$/);
|
||||||
|
const match = createMatch ?? updateMatch;
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const kebab = kebabCase(match[1]);
|
||||||
|
const prefix = createMatch ? 'create' : 'update';
|
||||||
|
const dtoPath = `server/src/modules/${kebab}/dto/${prefix}-${kebab}.dto.ts`;
|
||||||
|
const content = readIfExists(dtoPath) ?? '';
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
// rule: backend-rules.md §Type mappings — every DTO must import class-validator
|
||||||
|
assertCondition(
|
||||||
|
/from 'class-validator'/.test(content),
|
||||||
|
`${dtoPath}: missing import from 'class-validator' — rule: backend-rules.md §Type mappings`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const field of dto.fields) {
|
||||||
|
const { name, type, nullable, required } = field;
|
||||||
|
if (!type) continue;
|
||||||
|
|
||||||
|
// Skip DTO reference types — validated by @ValidateNested separately
|
||||||
|
if (type.startsWith('DTO.')) continue;
|
||||||
|
|
||||||
|
// rule: backend-rules.md — nullable/optional fields must carry @IsOptional()
|
||||||
|
if (!required || nullable) {
|
||||||
|
assertCondition(
|
||||||
|
fieldHasDecorator(content, name, '@IsOptional('),
|
||||||
|
`${dtoPath}: field '${name}' is optional/nullable but missing @IsOptional()`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rule: backend-rules.md §Type mappings — type-correct decorator
|
||||||
|
const bareType = type.replace('[]', '');
|
||||||
|
if (enumNames.has(bareType)) {
|
||||||
|
assertCondition(
|
||||||
|
fieldHasDecorator(content, name, `@IsEnum(${bareType}`),
|
||||||
|
`${dtoPath}: field '${name}' has enum type '${bareType}' but missing @IsEnum(${bareType})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const expected = DSL_TYPE_TO_CV_DECORATOR[bareType];
|
||||||
|
if (expected) {
|
||||||
|
const options = Array.isArray(expected) ? expected : [expected];
|
||||||
|
const found = options.some((opt) => fieldHasDecorator(content, name, opt));
|
||||||
|
assertCondition(
|
||||||
|
found,
|
||||||
|
`${dtoPath}: field '${name}' has type '${bareType}' but missing ${options.join(' or ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateControllerGuards() {
|
||||||
|
// rule: backend-rules.md §Backend auth defaults — every controller needs JwtAuthGuard
|
||||||
|
const { apis } = parseApiDsl(rootDir);
|
||||||
|
for (const api of apis) {
|
||||||
|
const resourceName = api.name.replace(/^API\./, '');
|
||||||
|
const kebab = kebabCase(resourceName);
|
||||||
|
const controllerPath = `server/src/modules/${kebab}/${kebab}.controller.ts`;
|
||||||
|
const content = readIfExists(controllerPath) ?? '';
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
// UseGuards must appear at the class level (within first 800 chars before the class declaration)
|
||||||
|
assertCondition(
|
||||||
|
/@UseGuards\s*\(/.test(content),
|
||||||
|
`${controllerPath}: missing @UseGuards(...) — all controllers must guard their routes`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// JwtAuthGuard or equivalent JWT guard must be referenced
|
||||||
|
assertCondition(
|
||||||
|
/JwtAuthGuard|JwtGuard|AuthGuard/.test(content),
|
||||||
|
`${controllerPath}: controller must use JwtAuthGuard (or equivalent) — rule: backend-rules.md §Backend auth defaults`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// rule: backend-rules.md §Backend auth defaults — DELETE must be admin-only
|
||||||
|
if (api.endpoints.some((ep) => ep.method === 'DELETE')) {
|
||||||
|
assertCondition(
|
||||||
|
/@Roles\s*\([^)]*'admin'/.test(content) || /@Roles\s*\([^)]*"admin"/.test(content),
|
||||||
|
`${controllerPath}: DELETE endpoints require @Roles('admin') — rule: backend-rules.md §Backend auth defaults`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFrontendComponentTypes() {
|
||||||
|
// rule: frontend-rules.md §Resource generation — type-safe component mapping
|
||||||
|
const { dtos, enums } = parseApiDsl(rootDir);
|
||||||
|
const enumNames = new Set(enums.map((e) => e.name));
|
||||||
|
|
||||||
|
for (const dto of dtos) {
|
||||||
|
const createMatch = dto.name.match(/^DTO\.(\w+)Create$/);
|
||||||
|
if (!createMatch) continue;
|
||||||
|
|
||||||
|
const resourceName = createMatch[1];
|
||||||
|
const kebab = kebabCase(resourceName);
|
||||||
|
const createPath = `client/src/resources/${kebab}/${resourceName}Create.tsx`;
|
||||||
|
const editPath = `client/src/resources/${kebab}/${resourceName}Edit.tsx`;
|
||||||
|
|
||||||
|
for (const componentPath of [createPath, editPath]) {
|
||||||
|
const content = readIfExists(componentPath) ?? '';
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
for (const field of dto.fields) {
|
||||||
|
const { name, type } = field;
|
||||||
|
if (!type) continue;
|
||||||
|
const bareType = type.replace('[]', '');
|
||||||
|
|
||||||
|
if (bareType === 'integer' || bareType === 'number' || bareType === 'decimal') {
|
||||||
|
// rule: frontend-rules.md — integer/number/decimal → NumberInput, never TextInput
|
||||||
|
const usesNumberInput = content.includes(`source="${name}"`) &&
|
||||||
|
new RegExp(`NumberInput[^>]*source="${escapeRegexStr(name)}"|source="${escapeRegexStr(name)}"[^>]*NumberInput`).test(content);
|
||||||
|
// Only flag if TextInput is clearly used for a numeric field
|
||||||
|
const usesTextForNumeric = new RegExp(
|
||||||
|
`TextInput[\\s\\S]{0,200}source="${escapeRegexStr(name)}"`,
|
||||||
|
).test(content);
|
||||||
|
assertCondition(
|
||||||
|
!usesTextForNumeric,
|
||||||
|
`${componentPath}: field '${name}' has type '${bareType}' but uses TextInput — must use NumberInput`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bareType === 'date') {
|
||||||
|
const usesTextForDate = new RegExp(
|
||||||
|
`TextInput[\\s\\S]{0,200}source="${escapeRegexStr(name)}"`,
|
||||||
|
).test(content);
|
||||||
|
assertCondition(
|
||||||
|
!usesTextForDate,
|
||||||
|
`${componentPath}: field '${name}' has type 'date' but uses TextInput — must use DateInput`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// api.dsl coverage checks
|
||||||
|
//
|
||||||
|
// The API DSL is parsed by tools/api-summary.mjs — the single canonical
|
||||||
|
// parser. This section contains only mechanical gate logic; no DSL parsing
|
||||||
|
// or generation semantics live here.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function validateApiDslCoverage() {
|
||||||
|
const apiDslFiles = getApiDslFiles(rootDir);
|
||||||
|
if (apiDslFiles.length === 0) {
|
||||||
|
warn('No domain/*.api.dsl files found. Skipping api.dsl coverage checks.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rule: AGENTS.md §Tier-3 generation zones, backend-rules.md §api-dsl-as-source
|
||||||
|
const { apis, dtos } = parseApiDsl(rootDir);
|
||||||
|
|
||||||
|
for (const api of apis) {
|
||||||
|
// api.name is "API.Equipment"; derive the kebab resource name
|
||||||
|
const resourceName = api.name.replace(/^API\./, '');
|
||||||
|
const kebab = kebabCase(resourceName);
|
||||||
|
|
||||||
|
// rule: backend-rules.md §module-file-structure
|
||||||
|
requireFiles([
|
||||||
|
`server/src/modules/${kebab}/${kebab}.module.ts`,
|
||||||
|
`server/src/modules/${kebab}/${kebab}.controller.ts`,
|
||||||
|
`server/src/modules/${kebab}/${kebab}.service.ts`,
|
||||||
|
`server/src/modules/${kebab}/dto/create-${kebab}.dto.ts`,
|
||||||
|
`server/src/modules/${kebab}/dto/update-${kebab}.dto.ts`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// rule: frontend-rules.md §resource-file-structure
|
||||||
|
requireFiles([
|
||||||
|
`client/src/resources/${kebab}/${resourceName}List.tsx`,
|
||||||
|
`client/src/resources/${kebab}/${resourceName}Create.tsx`,
|
||||||
|
`client/src/resources/${kebab}/${resourceName}Edit.tsx`,
|
||||||
|
`client/src/resources/${kebab}/${resourceName}Show.tsx`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const controllerContent =
|
||||||
|
readIfExists(`server/src/modules/${kebab}/${kebab}.controller.ts`) ?? '';
|
||||||
|
if (!controllerContent) continue;
|
||||||
|
|
||||||
|
// rule: backend-rules.md §endpoint-http-method-mapping
|
||||||
|
for (const ep of api.endpoints) {
|
||||||
|
if (!ep.label) continue;
|
||||||
|
const isPageEndpoint = (ep.path ?? '').endsWith('/page');
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
if (ep.method === 'GET') {
|
||||||
|
found = controllerContent.includes('@Get(');
|
||||||
|
} else if (ep.method === 'POST' && isPageEndpoint) {
|
||||||
|
found = controllerContent.includes('@Post(') || controllerContent.includes('@Get(');
|
||||||
|
} else if (ep.method === 'POST') {
|
||||||
|
found = controllerContent.includes('@Post(');
|
||||||
|
} else if (ep.method === 'PUT') {
|
||||||
|
found = controllerContent.includes('@Put(') || controllerContent.includes('@Patch(');
|
||||||
|
} else if (ep.method === 'DELETE') {
|
||||||
|
found = controllerContent.includes('@Delete(');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCondition(
|
||||||
|
found,
|
||||||
|
`${api.name} endpoint ${ep.name} (${ep.label}): no matching HTTP handler in controller`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rule: backend-rules.md §DTO-field-coverage
|
||||||
|
for (const dto of dtos) {
|
||||||
|
const createMatch = dto.name.match(/^DTO\.(\w+)Create$/);
|
||||||
|
const updateMatch = dto.name.match(/^DTO\.(\w+)Update$/);
|
||||||
|
|
||||||
|
if (createMatch) {
|
||||||
|
const kebab = kebabCase(createMatch[1]);
|
||||||
|
const dtoPath = `server/src/modules/${kebab}/dto/create-${kebab}.dto.ts`;
|
||||||
|
const content = readIfExists(dtoPath) ?? '';
|
||||||
|
if (content) {
|
||||||
|
for (const field of dto.fields) {
|
||||||
|
assertCondition(
|
||||||
|
content.includes(field.name),
|
||||||
|
`${dto.name} field '${field.name}' missing from ${dtoPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateMatch) {
|
||||||
|
const kebab = kebabCase(updateMatch[1]);
|
||||||
|
const dtoPath = `server/src/modules/${kebab}/dto/update-${kebab}.dto.ts`;
|
||||||
|
const content = readIfExists(dtoPath) ?? '';
|
||||||
|
if (content) {
|
||||||
|
for (const field of dto.fields) {
|
||||||
|
assertCondition(
|
||||||
|
content.includes(field.name),
|
||||||
|
`${dto.name} field '${field.name}' missing from ${dtoPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
validateBuildChecks();
|
||||||
|
validateAuthChecks();
|
||||||
|
validateNaturalKeyChecks();
|
||||||
|
validateRealmChecks();
|
||||||
|
validateRuntimeContractChecks();
|
||||||
|
validateApiDslCoverage();
|
||||||
|
validateDtoDecoratorCoverage();
|
||||||
|
validateControllerGuards();
|
||||||
|
validateFrontendComponentTypes();
|
||||||
|
|
||||||
|
if (!artifactsOnly) {
|
||||||
|
validateBuildExecutionChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!artifactsOnly && runRuntime) {
|
||||||
|
validateRuntimeExecutionChecks();
|
||||||
|
} else if (!artifactsOnly) {
|
||||||
|
warn('Runtime command execution skipped. Use --run-runtime after installing dependencies and starting the local database.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.warn(`WARN: ${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error('Generation validation failed:');
|
||||||
|
for (const failure of failures) {
|
||||||
|
console.error(`- ${failure}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generation validation passed.');
|
||||||
Reference in New Issue
Block a user