diff --git a/.env.example b/.env.example index b8f78e9..4918256 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ───────────────────────────────────────────────────────────────────────────── -# Solarch OSS self-host configuration. +# Solarch self-host configuration. # # Easiest: run ./install.sh (Linux/macOS) or ./install.ps1 (Windows) — an interactive # wizard that fills this file for you. Or copy this to `.env`, edit, then: @@ -18,12 +18,8 @@ BIND_ADDRESS=127.0.0.1 # SOLARCH_BASIC_AUTH_USER= # SOLARCH_BASIC_AUTH_HASH= # bcrypt from: caddy hash-password --plaintext 'your-password' -# Rate limits (server-side; per IP by default in OSS mode) -# THROTTLE_BY=ip -# THROTTLE_LIMIT=60 -# THROTTLE_TTL_MS=60000 -# AI_THROTTLE_LIMIT=20 -# CODEGEN_FILL_THROTTLE_LIMIT=10 +# Rate limits (server-side; per IP by default in self-host mode) +# CODEGEN_FILL_THROTTLE_LIMIT=10 # surgical-fill requests per minute (global 60/min + AI 20/min are fixed) # ── Database (Neo4j) — minimum 8 characters (Neo4j 5 requirement) ───────────── NEO4J_PASSWORD=change_me_please diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d1fb5e..6806a85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,15 +24,15 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: OSS grep gate + - name: English-only source check run: | set -euo pipefail - if rg -i 'clerk|polar|posthog|ClerkAuthGuard|BILLING_ENABLED|isLocalAuth|Henüz lisanslanmadı|misafir bileti' apps docs deploy --glob '!**/.github/**'; then - echo "OSS grep gate failed — SaaS remnants found" + if rg '[ğüşıöçĞÜŞİÖÇ]' apps/server/src apps/server/test apps/server/deploy apps/server/README.md apps/server/scripts; then + echo "Non-English text found under apps/server" exit 1 fi - if rg '[ğüşıöçĞÜŞİÖÇ]' apps/server/src apps/server/test apps/server/deploy apps/server/README.md apps/server/scripts --glob '!**/.tr-en-cache.json'; then - echo "Turkish characters found under apps/server" + if rg -iw 'yoksa|icin|olarak|zorunlu|sekme|degil|sadece|sonra|hata|dosya|deger|sayilir|bos' apps/server/src/codegen apps/server/test; then + echo "Untranslated comments or test names found" exit 1 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51ac7e1..1b6cbd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing to Solarch -Thanks for the interest. This repository is the **OSS self-host monorepo** — backend, canvas UI, -and Docker bundle. The API surface, node families, and edge semantics still move week to week, but +Thanks for the interest. This repository is the **source-available self-host monorepo**: backend, +canvas UI, and Docker bundle. The API surface, node families, and edge semantics still move week to week, but feedback and focused PRs are very welcome. ## Ways to help diff --git a/LICENSE b/LICENSE index d055f41..4bb6b98 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,7 @@ -Copyright (c) 2025 Ugur Akdogan +Copyright (c) 2025-2026 Ugur Akdogan ## Acceptance diff --git a/README.md b/README.md index 098bda3..7d15b6d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@

- Solarch Logo + Solarch Logo   Solarch  

Bridging diagrams and code via a strict rules engine to stop AI errors.


-*Your entire workspace, on one canvas. | AI that ships. No more hallucinations.* +*Your entire workspace, on one canvas. | AI that proposes. Rules that verify.*
@@ -14,20 +14,19 @@ [![Website](https://img.shields.io/badge/Website-solarch.dev-0f0f0e?style=flat-square)](https://solarch.dev) [![Self-Host](https://img.shields.io/badge/Self--Host-docker%20compose-666?style=flat-square)](docs/getting-started.md) [![License](https://img.shields.io/badge/License-PolyForm%20NC%201.0-orange.svg?style=flat-square)](./LICENSE) -[![GitHub Stars](https://img.shields.io/github/stars/solarch-dev/solarch?style=flat-square)](https://github.com/solarch-dev/solarch/stargazers) [![Last Commit](https://img.shields.io/github/last-commit/solarch-dev/solarch?style=flat-square)](https://github.com/solarch-dev/solarch/commits/main)
-### ▶   Try it live — **[app.solarch.dev](https://app.solarch.dev)**   ·   Learn more at **[solarch.dev](https://solarch.dev)**   ·   Or [**self-host**](#-self-hosting) this repo +### ▶   Try it live: **[app.solarch.dev](https://app.solarch.dev)**   ·   Learn more at **[solarch.dev](https://solarch.dev)**   ·   Or [**self-host**](#-self-hosting) this repo
-*No install, no Docker — open the app and start drawing. Self-host below if you want your own box.* +*No install, no Docker: open the app and start drawing. Self-host below if you want your own box.*
-Solarch — AI builds rule-validated architecture from a single prompt +Solarch: AI builds rule-validated architecture from a single prompt
@@ -43,14 +42,14 @@ Most AI tools generate code and hope the architecture catches up. **Solarch flips that.** -It generates **architecture first** — grounded in a library of canonical patterns, validated by a strict Rules Engine, refined through a self-correcting loop. The AI proposes; the rules verify; only correct graphs ever land on the canvas. +It generates **architecture first**, grounded in a library of canonical patterns, validated by a strict Rules Engine, and refined through a self-correcting loop. The AI proposes, the rules verify, and only valid graphs land on the canvas. -* **One canvas for the whole system:** 21 node families — controllers, services, repositories, tables, DTOs, queues — and the 16 semantic edges between them. -* **AI Architect grounded in GraphRAG:** an agentic LLM pipeline pulls from a vector-indexed pattern library. It never starts from a blank context, never invents an API surface. -* **Rules Engine that refuses to lie:** 32 whitelist rules, 7 anti-patterns, 3 conditional checks. Frontends can't talk to tables. Controllers can't reach repositories. Period. -* **Self-correcting loop:** Rules rejection feeds back into the agent; the AI revises and tries again until the graph is clean — or never commits. +* **One canvas for the whole system:** 21 node families (controllers, services, repositories, tables, DTOs, queues) and the 16 semantic edges between them. +* **AI Architect grounded in GraphRAG:** an agentic LLM pipeline pulls from a vector-indexed pattern library, so it never plans from a blank context. Everything it proposes must pass the rules gate before it lands. +* **Rules Engine as a hard gate:** 40 whitelist rules, 7 anti-patterns, 3 conditional checks. Frontends can't talk to tables, and controllers can't reach repositories. +* **Self-correcting loop:** rules rejections feed back into the agent; the AI revises until the graph is clean, or the request ends without a commit. * **Diagram → NestJS code:** deterministic codegen scaffold from the graph, plus optional Surgical AI for method bodies. -* **Four surfaces, one project:** Canvas, Code, API, and Docs — switch from the top bar without losing context. +* **Four surfaces, one project:** Canvas, Code, API, and Docs, switched from the top bar without losing context. * **Live Instruct Mode:** Switch modes and chat with your design. Every answer cites the exact nodes; chips focus the canvas in real time. * **Single-home + reference tabs:** Each node lives in one tab. Other tabs *import* it as a reference, not a copy. One source of truth, multiple views. * **Type-safe from DB to button:** Zod schemas at the backend, OpenAPI in the middle, `openapi-fetch` on the frontend. The API contract is a compile-time check. @@ -75,7 +74,7 @@ It generates **architecture first** — grounded in a library of canonical patte 3. Watch it build itself
- Nodes pop in with zen animations, edges flow with the right semantics — every relationship rule-validated before it commits.

+ Nodes pop in with zen animations, edges flow with the right semantics. Every relationship is rule-validated before it commits.

AI canvas building @@ -86,13 +85,13 @@ It generates **architecture first** — grounded in a library of canonical patte - 5. Connect anything — legally
+ 5. Connect anything, legally
Hover a port, drag, snap. The Rules Engine rejects illegal connections instantly. Semantic edges carry meaning, not generic arrows.

Edge connecting 6. Every node, purpose-built editor
- Double-click any node. Column grids for tables, method tables for services, endpoint rows for controllers — no generic JSON forms.

+ Double-click any node. Column grids for tables, method tables for services, endpoint rows for controllers. No generic JSON forms.

Inspector reveal @@ -114,7 +113,7 @@ It generates **architecture first** — grounded in a library of canonical patte AI & Rules @@ -145,21 +144,23 @@ It generates **architecture first** — grounded in a library of canonical patte ## ✦ The Philosophy -> **Solarch doesn't fight AI hallucination — it makes hallucination structurally impossible.** -> -> The industry has spent two years asking LLMs to write code. The result: confident hallucinations, ghost APIs, codebases that compile but lie. Hallucination isn't a tuning problem — it's a category error. -> -> Architecture is the level where structure is **provable**. A controller calls a service. A service queries a repository. A repository writes a table. These relationships are either present or not. They can't be hallucinated. +> **Solarch doesn't ask the AI to be honest. It puts a deterministic gate between the AI and your architecture.** -Solarch stacks three layers that, together, leave no room for an AI to invent something that doesn't exist: +The industry has spent two years asking LLMs to write code, and the result is confident hallucinations: ghost APIs, phantom tables, codebases that compile but lie. Solarch attacks the structural half of that problem. Architectural relationships (a controller calls a service, a service queries a repository) are either present in the graph or they are not, so they can be checked deterministically instead of trusted. -1. **GraphRAG.** The agent starts every request by retrieving canonical patterns from a vector-indexed library. No blank context, no improvisation from zero. -2. **Rules Engine.** Every mutation passes a deterministic gate — 32 whitelist rules, 7 anti-patterns, 3 conditional checks. Illegal edges never land. The schema can't be coerced. +Three layers work together: + +1. **GraphRAG.** The agent starts every request by retrieving canonical patterns from a vector-indexed library, so it never plans from a blank context. +2. **Rules Engine.** Every mutation passes a deterministic gate of 40 whitelist rules, 7 anti-patterns, and 3 conditional checks. Illegal edges never land, and the schema can't be coerced. 3. **Self-correction loop.** When the Rules Engine rejects a draft, the violation message feeds back into the agent state. The AI revises until the graph is clean, or the request terminates without a commit. -The output isn't *trustworthy* code. It's *provably correct* structure. +The result: no API in the generated code that isn't in the diagram, and no edge in the diagram that breaks the rules. + +### What the Rules Engine does NOT guarantee + +The gate is structural, not semantic. Method bodies are LLM-written (the optional Surgical AI fill), gated by compiling the output with `tsc` and running tests in a verify loop, and marked unverified when that gate is skipped. The Rules Engine guarantees the shape of your architecture; it does not prove your business logic is correct. -**Provable structure. Targeted intelligence. Zero hallucinated APIs.** +Every number in this README is countable in the source: the 40 whitelist rules and 7 blacklist codes live in `apps/server/src/rules/registry`. Count them yourself. --- @@ -169,21 +170,21 @@ Don't want to clone, configure, or run Docker? Use the hosted product: | | Link | What you get | |---|------|----------------| -| **App** | **[app.solarch.dev](https://app.solarch.dev)** | Full canvas — sign up and build in the browser. Always up to date. | +| **App** | **[app.solarch.dev](https://app.solarch.dev)** | Full canvas: sign up and build in the browser. Always up to date. | | **Website** | **[solarch.dev](https://solarch.dev)** | Product overview, demos, updates. | -Zero local setup. If that fits you, stop reading here and open the app. +Zero local setup. --- ## ✦ Self-Hosting -Want the stack on **your machine** — your LLM key, your data, no vendor? This repository is the full OSS bundle: NestJS backend, canvas UI, vector-native Neo4j, local embeddings. +Want the stack on **your machine**, with your LLM key and your data? This repository is the complete source-available self-host edition: NestJS backend, canvas UI, vector-native Neo4j, local embeddings. ```bash git clone https://github.com/solarch-dev/solarch.git cd solarch -./install.sh # Linux/macOS — Neo4j password + AI provider wizard +./install.sh # Linux/macOS: Neo4j password + AI provider wizard docker compose up --build # → http://localhost:3000 ``` @@ -195,8 +196,8 @@ Windows: `./install.ps1`, then the same `docker compose` command. | Need | Details | |------|---------| | Docker + Compose v2 | Required | -| LLM provider | Tool-calling model — see [AI providers](docs/ai-providers.md) | -| LAN / VPS | Enable HTTP Basic Auth — [Self-hosting guide](docs/self-hosting.md) | +| LLM provider | Tool-calling model, see [AI providers](docs/ai-providers.md) | +| LAN / VPS | Enable HTTP Basic Auth ([Self-hosting guide](docs/self-hosting.md)) | Hosted alternative: **[app.solarch.dev](https://app.solarch.dev)** · **[solarch.dev](https://solarch.dev)** @@ -221,6 +222,18 @@ CLI / MCP / VS Code extension: [**solarch-tools**](https://github.com/solarch-de --- +## ✦ FAQ + +**Is this open source?** Strictly, no. PolyForm Noncommercial is a source-available license, not OSI-approved. It is free for personal use, research, education, and nonprofits. Commercial use needs a license: [info@solidea.tech](mailto:info@solidea.tech). + +**Isn't this just MDA / Rational Rose again?** MDA generated the scaffold and left you the hard 20%, and the diagram and code drifted by sprint 3. Here the LLM handles that 20% behind tsc/test gates, drift detection is built in, and the graph is a live Neo4j source of truth, not a dead UML file. Structurizr stops at visualization; v0 stops at the frontend. + +**Do you build Solarch with AI tools?** Yes. That's exactly why it exists: we needed a rules gate between the AI and the architecture ourselves first. + +**How is it tested?** The rules engine and codegen pipeline carry 800+ server tests, including a gate that compiles generated output with `tsc`. Frontend test coverage is early; contributions welcome. + +--- + ## ✦ Get Involved We welcome feedback, discussions, and contributions. @@ -233,15 +246,14 @@ We welcome feedback, discussions, and contributions. ## ✦ License -[PolyForm Noncommercial License 1.0.0](./LICENSE) — © 2026 Ugur Akdogan. +[PolyForm Noncommercial License 1.0.0](./LICENSE) — © 2025–2026 Ugur Akdogan. -**Free** for personal use, research, education, and non-profit organizations. Source is open: fork, learn, modify, share — go for it.
-**Commercial use requires a separate license** — reach out at [info@solidea.tech](mailto:info@solidea.tech). +**Free** for personal use, research, education, and non-profit organizations. The source is available: fork, learn, modify, and share for any noncommercial use.
+**Commercial use requires a separate license.** Reach out at [info@solidea.tech](mailto:info@solidea.tech).
- See. Understand. Plan.
- In one shot.

-
+ One canvas, one rules gate, code you can trace back to the diagram.

+
Solarch.
diff --git a/SECURITY.md b/SECURITY.md index 8657c98..3ebed35 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,5 +19,6 @@ public disclosure. ## Handling secrets Never commit real credentials. All secrets are provided via environment variables — see -`.env.example`. The server actively redacts secret-shaped values, and the codegen pipeline -is tested to never emit secrets into generated output. +`.env.example`. The codegen pipeline applies best-effort redaction: fields flagged +`IsSecret` are blanked in generated output (values in free-text fields are not detected). +If you find a bypass, please report it via the process above. diff --git a/apps/server/.env.example b/apps/server/.env.example index c73f4ed..d9855ef 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -3,11 +3,12 @@ PORT=4000 NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j -NEO4J_PASSWORD=solarch_dev_password +# Minimum 8 characters (Neo4j 5 requirement). +NEO4J_PASSWORD=change_me_please CORS_ORIGIN=http://localhost:3000 -# Local owner identity when no API key is sent (OSS default). +# Local owner identity when no API key is sent (self-host default). LOCAL_USER_ID=local_owner # ── AI agent — optional (any OpenAI-compatible provider) ────────────── diff --git a/apps/server/README.md b/apps/server/README.md index ca1b608..e3b1fdd 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -1,4 +1,4 @@ -# Solarch Server (OSS) +# Solarch Server (self-host) Architecture graph backend for the self-hosted Solarch monorepo. Node/edge CRUD, Rules Engine, GraphRAG, AI architect, and codegen. @@ -11,7 +11,7 @@ Architecture graph backend for the self-hosted Solarch monorepo. Node/edge CRUD, - **@xenova/transformers** — local embeddings (default, offline-capable) - **Vitest + Testcontainers** — unit and e2e tests -## Auth (OSS) +## Auth (self-host) Every HTTP request is handled by **`LocalAuthGuard`**: @@ -37,7 +37,7 @@ Copy root [`.env.example`](../../.env.example) and see [`src/config/env.ts`](src ## Documentation -Full OSS documentation: **[`docs/README.md`](../../docs/README.md)** (index). +Full documentation: **[`docs/README.md`](../../docs/README.md)** (index). | Guide | Link | |-------|------| diff --git a/apps/server/deploy/solarch-backend.service b/apps/server/deploy/solarch-backend.service index ad12919..3dcc2bf 100644 --- a/apps/server/deploy/solarch-backend.service +++ b/apps/server/deploy/solarch-backend.service @@ -10,9 +10,9 @@ Wants=network-online.target [Service] Type=simple User=solarch -WorkingDirectory=/opt/solarch/solarch-backend +WorkingDirectory=/opt/solarch/apps/server # Backend .env (Neo4j/LLM keys) — mode 600, outside repo. -EnvironmentFile=/opt/solarch/solarch-backend/.env +EnvironmentFile=/opt/solarch/apps/server/.env ExecStart=/usr/bin/node dist/main.js Restart=on-failure RestartSec=3 diff --git a/apps/server/deploy/solarch-neo4j-backup.service b/apps/server/deploy/solarch-neo4j-backup.service index db89eb4..15bf528 100644 --- a/apps/server/deploy/solarch-neo4j-backup.service +++ b/apps/server/deploy/solarch-neo4j-backup.service @@ -6,4 +6,4 @@ After=docker.service [Service] Type=oneshot -ExecStart=/opt/solarch/solarch-backend/scripts/neo4j-backup.sh +ExecStart=/opt/solarch/apps/server/scripts/neo4j-backup.sh diff --git a/apps/server/docker-compose.yml b/apps/server/docker-compose.yml index 8772d9d..7b752e2 100644 --- a/apps/server/docker-compose.yml +++ b/apps/server/docker-compose.yml @@ -8,10 +8,10 @@ services: - "127.0.0.1:7474:7474" - "127.0.0.1:7687:7687" environment: -# Password from .env (the .env in the backend directory is read by compose). giant default -# solarch_dev_password; Put strong NEO4J_PASSWORD in .env in PROD. NOTE: password only -# volume is written in the FIRST init — a reset is required to change it to the current volume. - NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-solarch_dev_password} +# Password comes from .env in this directory (fail-closed: compose refuses to start without it). +# Neo4j 5 requires at least 8 characters. NOTE: the password is written to the volume on FIRST +# init only — changing it later requires resetting the volume. + NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required - set it in apps/server/.env (min 8 chars)} NEO4J_PLUGINS: '["apoc"]' # Memory tuning (2vCPU/8GB single-box: shares Neo4j + backend + Caddy). Increase if necessary. NEO4J_server_memory_heap_initial__size: ${NEO4J_HEAP:-512m} diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs index 68c6c11..cbc232d 100644 --- a/apps/server/eslint.config.mjs +++ b/apps/server/eslint.config.mjs @@ -2,8 +2,10 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; import globals from "globals"; -// Flat config (ESLint 9). Syntactic recommended + TS recommended; type-aware (parserOptions. -//project) OFF → fast + green gate without blowing up existing code. After hardening. +// Flat config (ESLint 9). Syntactic recommended + TS recommended. Type-aware linting +// (parserOptions.project) is intentionally off: it keeps lint fast, and the rules below +// stay at "warn" so issues surface incrementally while the existing codebase is cleaned +// up, instead of failing the gate all at once. export default tseslint.config( { ignores: ["dist", "node_modules", "coverage"] }, js.configs.recommended, @@ -13,7 +15,7 @@ export default tseslint.config( rules: { "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], -// `const self = this` is intentional in SSE/async-generator closures (cannot be an arrow generator). + // `const self = this` is intentional in SSE/async-generator closures (generators cannot be arrows). "@typescript-eslint/no-this-alias": "warn", }, }, diff --git a/apps/server/src/ai/ai.service.spec.ts b/apps/server/src/ai/ai.service.spec.ts index d339d5a..1e4bcec 100644 --- a/apps/server/src/ai/ai.service.spec.ts +++ b/apps/server/src/ai/ai.service.spec.ts @@ -7,6 +7,7 @@ const h = vi.hoisted(() => ({ responses: [] as any[], idx: 0, onInvoke: null as vi.mock("./providers/llm.factory", () => ({ isGenerationConfigured: () => true, + modelUnavailableMessage: () => null, getGenerationChat: () => ({ bindTools: () => ({ invoke: async (messages: any) => { diff --git a/apps/server/src/ai/ai.service.ts b/apps/server/src/ai/ai.service.ts index 750722b..3481226 100644 --- a/apps/server/src/ai/ai.service.ts +++ b/apps/server/src/ai/ai.service.ts @@ -15,7 +15,7 @@ import { lintContracts } from "../codegen/contract-lint"; import type { StoredNode } from "../nodes/nodes.repository"; import type { StoredEdge } from "../edges/edges.repository"; import { env } from "../config/env"; -import { getGenerationChat, isGenerationConfigured } from "./providers/llm.factory"; +import { getGenerationChat, isGenerationConfigured, modelUnavailableMessage } from "./providers/llm.factory"; import { buildSystemPrompt } from "./prompts/system-prompt"; import { ApplyArchitectureArgsSchema } from "./tools/apply-architecture-graph.tool"; import { @@ -184,12 +184,14 @@ export class AiService { } catch (err) { const msg = (err as Error)?.message ?? ""; this.logger.error(`AI generation error: ${msg.slice(0, 200)}`); + const modelIssue = modelUnavailableMessage(err); const corrupt = /delimiter|expecting|json|parse|unexpected|column \d+/i.test(msg); throw new ServiceUnavailableException({ code: "ERR_AI_GENERATION_FAILED", - message: corrupt - ? "Could not process the AI output — the model returned a corrupted response. Please try again." - : "AI generation failed. Please try again.", + message: modelIssue + ?? (corrupt + ? "Could not process the AI output — the model returned a corrupted response. Please try again." + : "AI generation failed. Please try again."), }); } } @@ -260,7 +262,11 @@ export class AiService { } const msg = (err as Error)?.message ?? ""; this.logger.error(`Instruct stream error: ${msg.slice(0, 200)}`); - yield { type: "error", code: "ERR_AI_GENERATION_FAILED", message: "Could not generate the AI response. Please try again." }; + yield { + type: "error", + code: "ERR_AI_GENERATION_FAILED", + message: modelUnavailableMessage(err) ?? "Could not generate the AI response. Please try again.", + }; } } @@ -580,7 +586,7 @@ export class AiService { }; return; } - // HttpException → response body'i LLM'e geri ver (ReAct self-correct) + // HttpException → feed the response body back to the LLM (ReAct self-correction) const errBody = httpExceptionBody(err); totalToolFailures++; consecutiveFailures++; // Record rejected edge signature → same edge never hits DB/Rules again. @@ -661,7 +667,9 @@ export class AiService { yield { type: "error", code: "ERR_AI_GENERATION_FAILED", - message: "Unexpected error during AI generation. Unconnectable nodes were cleaned up; the connected subgraph was preserved.", + message: + modelUnavailableMessage(err) + ?? "Unexpected error during AI generation. Unconnectable nodes were cleaned up; the connected subgraph was preserved.", }; } } diff --git a/apps/server/src/ai/providers/llm.factory.ts b/apps/server/src/ai/providers/llm.factory.ts index 846d0bb..a2fbea5 100644 --- a/apps/server/src/ai/providers/llm.factory.ts +++ b/apps/server/src/ai/providers/llm.factory.ts @@ -5,12 +5,12 @@ import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { ChatMistralAI } from "@langchain/mistralai"; import { ChatGroq } from "@langchain/groq"; import { ChatOllama } from "@langchain/ollama"; +import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { env } from "../../config/env"; -/** The rest of the codebase treats every chat client as a ChatOpenAI (it only uses the shared - * BaseChatModel surface: invoke / stream / bindTools / .tool_calls). Non-OpenAI providers are - * cast through this alias — every LangChain chat model implements the same interface. */ -export type GenerationChat = ChatOpenAI; +/** The rest of the codebase only uses the shared BaseChatModel surface + * (invoke / stream / bindTools / .tool_calls), so every provider returns this type. */ +export type GenerationChat = BaseChatModel; export type LlmProvider = | "openai" @@ -53,8 +53,8 @@ interface ProviderDef { configured: () => boolean; /** Tool-calling capable — required for the Architect (agent mode). Advisory. */ supportsTools: boolean; - /** Build a chat client (cast to ChatOpenAI — see GenerationChat). */ - build: (opts: ChatOpts) => ChatOpenAI; + /** Build a chat client (any BaseChatModel — see GenerationChat). */ + build: (opts: ChatOpts) => GenerationChat; } /** Provider registry. Adding a provider = one entry here + (if a new SDK) a dependency. @@ -84,11 +84,11 @@ const PROVIDERS: Record = { build: (o) => new ChatAnthropic({ ...COMMON, - model: pickModel(o, "claude-3-5-sonnet-latest"), + model: pickModel(o, "claude-sonnet-4-6"), apiKey: env.ANTHROPIC_API_KEY, maxTokens: 8192, streaming: o.streaming ?? false, - }) as unknown as ChatOpenAI, + }), }, google: { @@ -97,13 +97,13 @@ const PROVIDERS: Record = { supportsTools: true, build: (o) => new ChatGoogleGenerativeAI({ - model: pickModel(o, "gemini-1.5-pro"), + model: pickModel(o, "gemini-3.5-flash"), apiKey: env.GOOGLE_API_KEY, temperature: 0.3, maxOutputTokens: 8192, maxRetries: 1, streaming: o.streaming ?? false, - }) as unknown as ChatOpenAI, + }), }, deepseek: { @@ -132,7 +132,7 @@ const PROVIDERS: Record = { streaming: o.streaming ?? false, configuration: { baseURL: env.DEEPSEEK_BASE_URL }, modelKwargs, - }) as unknown as ChatOpenAI; + }); }, }, @@ -148,7 +148,7 @@ const PROVIDERS: Record = { maxTokens: 8192, maxRetries: 1, streaming: o.streaming ?? false, - }) as unknown as ChatOpenAI, + }), }, groq: { @@ -163,7 +163,7 @@ const PROVIDERS: Record = { maxTokens: 8192, maxRetries: 1, streaming: o.streaming ?? false, - }) as unknown as ChatOpenAI, + }), }, // OpenRouter — an OpenAI-compatible gateway to 300+ models. Model id is "vendor/model". @@ -193,7 +193,7 @@ const PROVIDERS: Record = { baseUrl: env.OLLAMA_BASE_URL, temperature: 0.3, streaming: o.streaming ?? false, - }) as unknown as ChatOpenAI, + }), }, // Bedrock-mantle = OpenAI-compatible Chat Completions endpoint + bearer API key. @@ -251,11 +251,6 @@ export function getGenerationChat(opts: ChatOpts = {}): GenerationChat { return buildFor(env.LLM_GENERATION_PROVIDER, opts); } -/** chat → general dialogue / summary. */ -export function getChatChat(opts: ChatOpts = {}): GenerationChat { - return buildFor(env.LLM_CHAT_PROVIDER, opts); -} - export function isGenerationConfigured(): boolean { return PROVIDERS[env.LLM_GENERATION_PROVIDER].configured(); } @@ -265,3 +260,13 @@ export function providerStatus(provider: LlmProvider): { configured: boolean; en const def = PROVIDERS[provider]; return { configured: def.configured(), envHint: def.envHint }; } + +/** Maps a raw provider error to an actionable message when the model id is the problem + * (deprecated/retired ids, models the key has no access to). Returns null otherwise. */ +export function modelUnavailableMessage(err: unknown): string | null { + const msg = (err as Error)?.message ?? ""; + if (/model.{0,40}(not.{0,10}found|does not exist|unsupported|deprecated|retired|decommissioned)|model_not_found|no access to model/i.test(msg)) { + return "The configured model is not available on your API key — set LLM_MODEL in .env to a model your provider currently offers (see docs/ai-providers.md)."; + } + return null; +} diff --git a/apps/server/src/auth/auth.types.ts b/apps/server/src/auth/auth.types.ts index df62c76..b9f4e71 100644 --- a/apps/server/src/auth/auth.types.ts +++ b/apps/server/src/auth/auth.types.ts @@ -2,8 +2,8 @@ export interface AuthContext { /** Local owner id or API-key owner. */ userId: string; - /** Reserved for future workspace scoping. Always null in OSS edition. */ + /** Reserved for future workspace scoping. Always null in the self-host edition. */ orgId: string | null; - /** Reserved for future workspace roles. Always null in OSS edition. */ + /** Reserved for future workspace roles. Always null in the self-host edition. */ orgRole: string | null; } diff --git a/apps/server/src/codegen/codegen-assembly.spec.ts b/apps/server/src/codegen/codegen-assembly.spec.ts index 393f8d9..8c35b52 100644 --- a/apps/server/src/codegen/codegen-assembly.spec.ts +++ b/apps/server/src/codegen/codegen-assembly.spec.ts @@ -5,16 +5,16 @@ import type { GeneratedFile } from "./types"; /* ──────────────────────────────────────────────────────────────────────── * codegen-assembly.spec.ts — SEAM VALIDATION GATE (fast layer, no npm). * - * Realistic graph (61 node / 82 edge — restaurant app) assembled ONCE - * then EMITTER-INTER seam consistency invariants verified on output - * Single-emitter golden tests CANNOT catch these seam bugs (bugs live + * A realistic graph (61 nodes / 82 edges — restaurant app) is assembled ONCE, + * then INTER-EMITTER seam consistency invariants are verified on the output. + * Single-emitter golden tests CANNOT catch these seam bugs (the bugs live * BETWEEN emitters); this test catches them. * * WHY no tsc (here): two root bugs do NOT appear in tsc-on-skeleton — - * - PK casing ({ Id: id }) `as FindOptionsWhere` cast'iyle gizli (iskelet derlenir), + * - PK casing ({ Id: id }) is hidden by the `as FindOptionsWhere` cast (the skeleton compiles), * - cardinality (single vs array) only fails after FILL (skeleton body throws). - * So locked via STRUCTURAL seam assertions (deterministic, fast). - * Whole-project tsc "compiles out of the box" guarantee is SEPARATE gate + * So they are locked in via STRUCTURAL seam assertions (deterministic, fast). + * The whole-project tsc "compiles out of the box" guarantee is a SEPARATE gate * (codegen-tsc.gate.test.ts + `pnpm test:codegen-gate`). * ──────────────────────────────────────────────────────────────────────── */ @@ -69,10 +69,11 @@ describe("codegen assembly seam (realistic graph)", () => { }); /* ── CARDINALITY SEAM: controller collection return <-> service collection return - * Bug: controller route'tan "koleksiyon" tahmin edip DTO[] basar ama service tekil - * stays -> after fill `return result` (array) won't compile. After fix collection - * operations return DTO[] on both ends. Fixture has RestaurantService.GetAll/ - * Search as collection (graph ReturnType:"array"); both should be RestaurantResponse[]. */ + * Bug: the controller guesses "collection" from the route and emits DTO[], but the + * service stays singular -> after fill `return result` (array) won't compile. After + * the fix, collection operations return DTO[] on both ends. Fixture has + * RestaurantService.GetAll/Search as collection (graph ReturnType:"array"); both + * should be RestaurantResponse[]. */ it("collection service methods return DTO[] (RestaurantService GetAll/Search)", () => { const svc = files.find((f) => f.path.endsWith("restaurant/restaurant.service.ts")); expect(svc).toBeDefined(); @@ -94,35 +95,37 @@ describe("codegen assembly seam (realistic graph)", () => { expect(svc!.content).toMatch(/Promise/); }); - /* ── ENUM DIKISI: entity (varchar) ↔ migration (VARCHAR + CHECK) ────────── - * #56: eskiden entity @Column({type:"enum"}) ama migration TEXT -> tutarsiz. Karar - * varchar+CHECK: hicbir entity native enum kolonu uretmez; migration enum kolonlarini - * VARCHAR + CHECK ile kisitlar (CREATE TYPE yok). Fixture'da enum kolonlari var. */ - it("hicbir entity native enum kolonu (type:\"enum\") uretmez (#56 regresyonu)", () => { + /* ── ENUM SEAM: entity (varchar) ↔ migration (VARCHAR + CHECK) ──────────── + * #56: previously the entity had @Column({type:"enum"}) but the migration used + * TEXT -> inconsistent. The decision is varchar+CHECK: no entity emits a native + * enum column; the migration constrains enum columns with VARCHAR + CHECK (no + * CREATE TYPE). The fixture contains enum columns. */ + it("no entity emits a native enum column (type:\"enum\") (#56 regression)", () => { for (const f of tsFiles(files)) { if (!f.path.includes("/entities/")) continue; expect(f.content, `${f.path} carries native enum column`).not.toContain('type: "enum"'); } }); - it("migration enum kolonlarini VARCHAR + CHECK ile kisitlar (CREATE TYPE yok)", () => { + it("migration constrains enum columns with VARCHAR + CHECK (no CREATE TYPE)", () => { const allSql = files.filter((f) => f.language === "sql").map((f) => f.content).join("\n"); - // Native Postgres enum tipi uretilmez (diyagram evrilince migration kâbusu olmaz). + // No native Postgres enum type is generated (no migration nightmare when the diagram evolves). expect(allSql).not.toContain("CREATE TYPE"); - // Fixture'da enum kolonu oldugundan en az bir CHECK ... IN (...) bulunmali. + // The fixture has an enum column, so at least one CHECK ... IN (...) must be present. expect(allSql).toMatch(/CHECK \("[a-z_]+" IN \('/); }); - /* ── RBAC DIKISI: @Roles ↔ RolesGuard (#39) ────────────────────────────── - * Eskiden @Roles metadata yaziliyordu ama OKUYAN guard yoktu (olu RBAC). Artik - * gercek RolesGuard uretilir (ROLES_KEY'i Reflector ile okur) ve @Roles kullanan - * her controller ayni route'a RolesGuard'i da baglar. Fixture'da roles-li endpoint var. */ - it("RolesGuard uretilir ve @Roles olan her controller'a wire edilir (olu RBAC degil)", () => { + /* ── RBAC SEAM: @Roles ↔ RolesGuard (#39) ──────────────────────────────── + * Previously the @Roles metadata was written but no guard READ it (dead RBAC). + * Now a real RolesGuard is generated (it reads ROLES_KEY via Reflector) and every + * controller using @Roles also binds RolesGuard to the same route. The fixture + * has an endpoint with roles. */ + it("RolesGuard is generated and wired into every controller with @Roles (not dead RBAC)", () => { const guard = files.find((f) => f.path.endsWith("shared/guards/roles.guard.ts")); expect(guard, "roles.guard.ts was not generated").toBeDefined(); expect(guard!.content).toContain("ROLES_KEY"); expect(guard!.content).toContain("Reflector"); - // @Roles kullanan her controller, RolesGuard'i da import edip @UseGuards'a koymali. + // Every controller using @Roles must also import RolesGuard and put it in @UseGuards. let checked = 0; for (const f of tsFiles(files)) { if (!f.path.endsWith(".controller.ts") || !f.content.includes("@Roles(")) continue; @@ -132,21 +135,22 @@ describe("codegen assembly seam (realistic graph)", () => { expect(checked, "no controller using @Roles found in fixture").toBeGreaterThan(0); }); - /* ── CONTRACT-LINT: govde-alan write endpoint'i input DTO'su olmadan ────── - * Emitter graf'ta RequestDTORef yoksa @Body uretmez (dogru); eksik kontrat - * emitter'da uydurulmaz, codegen UYARISI olarak yuzeye cikar (canvas isaretler). - * Fixture'da RequestDTORef'siz PATCH /{id}/status endpoint'leri var. */ - it("contract-lint: govdesiz write endpoint icin codegen warning uretilir (#@Body)", () => { + /* ── CONTRACT-LINT: a body-carrying write endpoint without an input DTO ─── + * When the graph has no RequestDTORef the emitter emits no @Body (correct); the + * missing contract is not invented in the emitter — it surfaces as a codegen + * WARNING (the canvas flags it). The fixture has PATCH /{id}/status endpoints + * without a RequestDTORef. */ + it("contract-lint: emits a codegen warning for a body-less write endpoint (#@Body)", () => { const project = assembleRealisticProject(); const bodyWarnings = project.warnings.filter((w) => /has no request body DTO/.test(w)); expect(bodyWarnings.length, "expected body-less write endpoint warning").toBeGreaterThan(0); }); - /* ── STATE MACHINE DIKISI (L2): gecisli enum -> guard + servis grounding ── - * Fixture'da OrderStatus.Transitions var -> enum dosyasi assertTransition - * guard'i icerir; UpdateStatus'lu OrderService bu guard'i import eder (AI fill'i - * illegal durum gecisini reddetsin diye). */ - it("gecisli enum assert guard'i uretir + status servisi import eder (L2)", () => { + /* ── STATE MACHINE SEAM (L2): enum with transitions -> guard + service grounding ── + * The fixture has OrderStatus.Transitions -> the enum file contains the + * assertTransition guard; the OrderService with UpdateStatus imports that + * guard (so the AI fill rejects illegal state transitions). */ + it("an enum with transitions generates the assert guard + the status service imports it (L2)", () => { const enumFile = files.find((f) => f.path.endsWith("order-status.enum.ts")); expect(enumFile, "order-status.enum.ts not found").toBeDefined(); expect(enumFile!.content).toContain("ORDER_STATUS_TRANSITIONS"); diff --git a/apps/server/src/codegen/codegen-fill.service.ts b/apps/server/src/codegen/codegen-fill.service.ts index e1443c0..8c2053e 100644 --- a/apps/server/src/codegen/codegen-fill.service.ts +++ b/apps/server/src/codegen/codegen-fill.service.ts @@ -9,8 +9,8 @@ import { SurgicalFillRepository } from "./surgical-fill.repository"; import { ensureFillDepsCache } from "./codegen-fill-deps"; import type { CodegenTarget, GeneratedFile } from "./types"; -/** Eszamanli doldurulacak DOSYA sayisi (env ile ayarlanir; ayni dosyanin bolgeleri - * her zaman sirali). */ +/** Number of FILES filled concurrently (configurable via env; regions within the + * same file are always sequential). */ const FILL_PARALLEL = Math.max(1, Number.parseInt(process.env.SOLARCH_FILL_PARALLEL ?? "6", 10) || 6); /** Resolve the @solarch/cli `fill` entry point. It runs as a subprocess so the server's @@ -28,29 +28,31 @@ function resolveCliEntry(): string { } const CLI_ENTRY = resolveCliEntry(); -/** Fill akis olaylari — SSE'ye birebir map'lenir. */ +/** Fill stream events — mapped one-to-one onto SSE. */ export type FillEvent = | { event: "start"; fileCount: number; markerCount: number } | { event: "mode"; verified: boolean; withTests: boolean; reason?: string } | { event: "region"; status: string; nodeId?: string; member: string; file: string; attempts: number; violations?: string[]; error?: string; body?: string } | { event: "phase"; kind: string; round?: number; ok?: boolean; errorCount?: number; file?: string; member?: string; files?: number; skipped?: boolean } - // GOZLEM: fill ajaninin tool eylemi (read/grep/glob/lookup_members/verify_fill). KALICI NOT — - // yalniz canli akar (persistRegion'a girmez). Ozet GUVENLI (kod govdesi / secret deger NONE). + // OBSERVABILITY: a tool action of the fill agent (read/grep/glob/lookup_members/verify_fill). + // NOT persisted — it only streams live (never enters persistRegion). The summary is SAFE + // (no code bodies, no secret values). | { event: "activity"; member: string; file: string; tool: string; summary: string; ok?: boolean; attempt?: number } | { event: "report"; filled: number; violations: number; errors: number; typecheck?: { ok: boolean }; tests?: { ok: boolean; skipped?: boolean } } | { event: "files"; files: GeneratedFile[] } | { event: "error"; message: string; code?: string }; -/** Surgical AI (sunucu-tarafi) — Constructor iskeletinin `@solarch:surgical` - * bolgelerini AI'la doldurur. Akis: assemble (DB'siz) → gecici dizine yaz → - * sicak deps cache'ini node_modules olarak symlink'le → `solarch fill --all - * --parallel N --json` subprocess'i (DOGRULANMIS: tsc dongude, opsiyonel jest) → - * NDJSON ilerleme + faz olaylarini stream et → dolu dosyalari geri oku → temizle. +/** Surgical AI (server-side) — fills the Constructor skeleton's `@solarch:surgical` + * regions with AI. Flow: assemble (no DB) → write to a temp directory → + * symlink the warm deps cache in as node_modules → `solarch fill --all + * --parallel N --json` subprocess (VERIFIED: tsc in the loop, optional jest) → + * stream the NDJSON progress + phase events → read the filled files back → clean up. * - * Dogrulama: deps cache (codegen-fill-deps) kurulabildiyse temp dizine node_modules - * symlink edilir → CLI gercek `tsc` kosar, hatali bolgeleri onarir (parallel). Cache - * yoksa (npm yok / offline) `--skip-verify` TASLAK yoluna dusulur + `mode` olayi - * verified:false der. jest ("derin dogrula") opsiyoneldir (withTests). */ + * Verification: if the deps cache (codegen-fill-deps) could be built, node_modules is + * symlinked into the temp dir → the CLI runs real `tsc` and repairs broken regions + * (in parallel). Without a cache (no npm / offline) the fill refuses with an explicit, + * retryable error (ERR_FILL_UNVERIFIED) — no silent draft fallback. jest + * ("deep verify") is optional (withTests). */ @Injectable() export class CodegenFillService { private readonly logger = new Logger(CodegenFillService.name); @@ -66,7 +68,7 @@ export class CodegenFillService { signal?: AbortSignal, opts?: { withTests?: boolean }, ): AsyncGenerator { - // service.generate proje yoksa NotFoundException atar → controller'a duser. + // service.generate throws NotFoundException when the project is missing → falls through to the controller. const project = await this.codegen.generate(projectId, target); const markerCount = project.files.reduce((s, f) => s + (f.surgicalMarkers ?? 0), 0); if (markerCount === 0) { @@ -84,10 +86,11 @@ export class CodegenFillService { } yield { event: "start", fileCount: project.files.length, markerCount }; - // Dogrulama bagimliliklari: sicak cache'i node_modules olarak symlink'le. - // SESSIZ DRAFT NONE — verified saglanamiyorsa acik, tekrar-denenebilir hata ver - // ("app'te temiz, lokalde tsc hatasi" surprizini engelle). Cache startup'ta warm - // edilir (CodegenDepsWarmupService); bu hata yalniz warm henuz bitmediyse/basarisizsa. + // Verification dependencies: symlink the warm cache in as node_modules. + // NO silent draft fallback — if verified fill cannot be provided, emit an explicit, + // retryable error (prevents the "clean in the app, tsc errors locally" surprise). + // The cache is warmed at startup (CodegenDepsWarmupService); this error only occurs + // when warmup hasn't finished yet or failed. const depsDir = await ensureFillDepsCache(this.logger); let verified = false; let unverifiedReason = "verified-deps cache unavailable (npm/network at startup)"; @@ -126,7 +129,7 @@ export class CodegenFillService { stderr += String(d); }); - // stdout NDJSON — satir satir parse et; dolan bolgeyi ANINDA kalici sakla, sonra yield. + // stdout is NDJSON — parse it line by line; persist a filled region IMMEDIATELY, then yield. const rl = createInterface({ input: child.stdout }); for await (const line of rl) { const trimmed = line.trim(); @@ -135,10 +138,10 @@ export class CodegenFillService { try { ev = JSON.parse(trimmed) as FillEvent; } catch { - continue; // json olmayan gurultu — atla + continue; // non-JSON noise — skip } - // Bolge doldugu an govdeyi bolge-bazinda kalici uygula (re-open dolu gorunur, - // yarida kesilse de elde olan kalir). En iyi caba; hata fill'i bozmaz. + // Persist the body per region the moment it fills (re-open shows it filled, and + // whatever is done survives an interruption). Best effort; a failure doesn't break the fill. if (ev.event === "region" && ev.nodeId) { await this.persistRegion(projectId, ev); } @@ -148,7 +151,7 @@ export class CodegenFillService { signal?.removeEventListener("abort", onAbort); if (code !== 0 && stderr) this.logger.warn(`fill subprocess exit ${code}: ${stderr.slice(0, 400)}`); - // Dolu dosyalari geri oku (tumunu; degismeyenler aynen doner). + // Read the filled files back (all of them; unchanged ones come back as-is). const filled = await Promise.all( project.files.map(async (f) => { try { @@ -160,24 +163,26 @@ export class CodegenFillService { ); yield { event: "files", files: filled }; } finally { - // FIRST node_modules symlink'ini AYRI kaldir → rm asla paylasilan cache'e - // (symlink hedefine) inmesin. (fs.rm symlink'i izlemez ama kasitli garanti.) + // Remove the node_modules symlink SEPARATELY first → rm must never descend into + // the shared cache (the symlink target). (fs.rm doesn't follow symlinks, but this + // makes the guarantee explicit.) await unlink(join(dir, "node_modules")).catch(() => {}); await rm(dir, { recursive: true, force: true }).catch(() => {}); } } - /** Bir "region" olayini DB'ye kalici uygula — DB bolgenin FINAL durumunu yansitsin. + /** Persist a "region" event to the DB — the DB must reflect the region's FINAL state. * - * Bir bolge AYNI fill akisinda once "filled" sonra "violation" emit edilebilir: - * ilk-dolum import'lar cozulmeden tip-denetler (gercek tip hatasi "Cannot find name" - * arkasina saklanir → "filled"); repair fazinda import'lar cozulunce hata gorunur ve - * model cozemezse bolge "violation"a duser. Bu yuzden: - * - "filled" → govdeyi yaz/uzerine yaz (gecerli sonuc). - * - "violation"/"error" → sakli (kirik) govdeyi SIL → bolge stub'a doner (stub DERLENIR, - * derlenmeyen govde KALICI NOT). 3 gundur GetVideo TS2322'sinin saklanma sebebi: - * "filled" kaydediliyor ama sonraki "violation" yok sayiliyordu → kirik govde kaliyordu. - * En iyi caba: persist hatasi fill akisini bozmaz. */ + * A region can emit "filled" first and "violation" later within the SAME fill run: + * the first fill type-checks before imports are resolved (the real type error hides + * behind "Cannot find name" → "filled"); once the repair phase resolves imports the + * error surfaces, and if the model can't fix it the region drops to "violation". Hence: + * - "filled" → write/overwrite the body (valid result). + * - "violation"/"error" → DELETE the stored (broken) body → the region reverts to a stub + * (a stub COMPILES; a non-compiling body must NOT be persisted). This is why the + * GetVideo TS2322 hid for 3 days: "filled" was saved but the later "violation" was + * ignored → the broken body stuck around. + * Best effort: a persist failure doesn't break the fill run. */ private async persistRegion(projectId: string, ev: Extract): Promise { if (!ev.nodeId) return; if (ev.status === "filled" && ev.body) { diff --git a/apps/server/src/codegen/codegen-tsc.gate.test.ts b/apps/server/src/codegen/codegen-tsc.gate.test.ts index 95f3253..d8827e4 100644 --- a/apps/server/src/codegen/codegen-tsc.gate.test.ts +++ b/apps/server/src/codegen/codegen-tsc.gate.test.ts @@ -7,27 +7,29 @@ import { assembleRealisticFixture } from "./__fixtures__/load"; import { ensureFillDepsCache } from "./codegen-fill-deps"; /* ──────────────────────────────────────────────────────────────────────── - * codegen-tsc.gate.test.ts — BUTUN-PROJE TSC GECIDI ("compiles out of the box"). + * codegen-tsc.gate.test.ts — WHOLE-PROJECT TSC GATE ("compiles out of the box"). * - * Gercekci grafi assemble eder → gecici dizine yazar → sicak deps cache'ini - * (ensureFillDepsCache, in-app verified-fill ile AYNI) node_modules olarak symlink'ler - * → uretilen projeye `tsc --noEmit` kosar → 0 hata bekler. Bu, iskeletin GERCEKTEN - * derlendiginin makine-kanitidir (README'nin "compiles out of the box" sozu). + * Assembles the realistic graph → writes it to a temp directory → symlinks the warm + * deps cache (ensureFillDepsCache, the SAME one the in-app verified fill uses) in as + * node_modules → runs `tsc --noEmit` on the generated project → expects 0 errors. + * This is machine proof that the skeleton REALLY compiles (the README's "compiles + * out of the box" promise). * - * AYRI calisir (`*.gate.test.ts`, `*.spec.ts` NOT → default `pnpm test`'e girmez): - * yavas + node_modules gerektirir. `pnpm test:codegen-gate` ile / CI'da kosulur. - * - Cache kurulamazsa (npm yok / offline) ATLAR (gurultulu uyari; CI npm saglamali). - * - Yerel: SOLARCH_FILL_DEPS_CACHE ile hazir bir node_modules'e isaret edilebilir. + * It runs SEPARATELY (`*.gate.test.ts`, not `*.spec.ts` → excluded from the default + * `pnpm test`): slow + needs node_modules. Run via `pnpm test:codegen-gate` / in CI. + * - If the cache cannot be built (no npm / offline) it SKIPS (with a noisy warning; + * CI must provide npm). + * - Locally: SOLARCH_FILL_DEPS_CACHE can point at a ready-made node_modules. * - * NOT: bu gecit ISKELET'i derler (govdeler `throw NOT_IMPLEMENTED`). Cast-ile-gizli - * (PK casing) ve fill-sonrasi (kardinalite) dikis bug'lari burada GORUNMEZ — onlar - * codegen-assembly.spec.ts'teki yapisal seam-assertion'lariyla kilitlenir. Iki gecit - * TOGETHER "verified, not guessed" saglar. + * NOTE: this gate compiles the SKELETON (bodies are `throw NOT_IMPLEMENTED`). + * Cast-hidden (PK casing) and post-fill (cardinality) seam bugs are INVISIBLE here — + * those are locked in by the structural seam assertions in codegen-assembly.spec.ts. + * The two gates TOGETHER provide "verified, not guessed". * ──────────────────────────────────────────────────────────────────────── */ const GATE_TIMEOUT = 600_000; -describe("codegen butun-proje tsc gecidi (gercekci graf)", () => { +describe("codegen whole-project tsc gate (realistic graph)", () => { it( "generated skeleton passes tsc with 0 errors", async (ctx) => { @@ -38,10 +40,10 @@ describe("codegen butun-proje tsc gecidi (gercekci graf)", () => { const msg = "verified-deps cache missing (npm/offline) -> tsc gate could not run. " + "Locally provide ready node_modules via SOLARCH_FILL_DEPS_CACHE."; - // FALSE-GREEN KORUMASI: CI'da skip = sessiz yesil. CI'da deps SAGLANMALI; - // saglanmadiysa gecidi FAIL et (atlama yalniz yerel gelistirmede). + // FALSE-GREEN PROTECTION: skipping in CI = silently green. CI MUST provide + // the deps; if it doesn't, FAIL the gate (skipping is for local dev only). if (process.env.CI) throw new Error(`[tsc-gate] ${msg} gate cannot be skipped in CI.`); - console.warn(`[tsc-gate] ${msg} (yerel: ATLANDI)`); + console.warn(`[tsc-gate] ${msg} (local: SKIPPED)`); ctx.skip(); return; } @@ -53,13 +55,13 @@ describe("codegen butun-proje tsc gecidi (gercekci graf)", () => { await mkdir(dirname(abs), { recursive: true }); await writeFile(abs, f.content); } - // Sicak cache'i node_modules olarak symlink'le (kopya yok, hizli). + // Symlink the warm cache in as node_modules (no copying, fast). await symlink(join(depsDir, "node_modules"), join(dir, "node_modules"), "dir"); const { code, output } = await runTsc(dir); expect(code, `generated skeleton did NOT pass tsc:\n${output}`).toBe(0); } finally { - // FIRST symlink'i ayri kaldir → rm paylasilan cache'e inmesin. + // Remove the symlink separately FIRST → rm must not descend into the shared cache. await unlink(join(dir, "node_modules")).catch(() => {}); await rm(dir, { recursive: true, force: true }).catch(() => {}); } @@ -68,8 +70,8 @@ describe("codegen butun-proje tsc gecidi (gercekci graf)", () => { ); }); -/** Uretilen projede `tsc --noEmit -p tsconfig.json` kosar. tsc'yi node ile dogrudan - * cagirir (.bin shim'ine degil) → platform-bagimsiz. stdout+stderr birlesik doner. */ +/** Runs `tsc --noEmit -p tsconfig.json` on the generated project. Invokes tsc directly + * through node (not the .bin shim) → platform-independent. Returns stdout+stderr combined. */ function runTsc(cwd: string): Promise<{ code: number; output: string }> { return new Promise((resolve) => { const tscEntry = join(cwd, "node_modules", "typescript", "bin", "tsc"); diff --git a/apps/server/src/codegen/codegen.controller.ts b/apps/server/src/codegen/codegen.controller.ts index 7508571..bb423d1 100644 --- a/apps/server/src/codegen/codegen.controller.ts +++ b/apps/server/src/codegen/codegen.controller.ts @@ -112,8 +112,8 @@ export class CodegenController { return ok(await this.service.simpleView(projectId)); } - /** Simple SKETCH — Mermaid for the hand-drawn (Excalidraw-style) Simple view. AI - * (DeepSeek) refines a deterministic baseline into a friendlier non-dev diagram and + /** Simple SKETCH — Mermaid for the hand-drawn (Excalidraw-style) Simple view. The + * configured AI provider refines a deterministic baseline into a friendlier non-dev diagram and * the result is cached per project (LLM runs only when the graph changes); falls back * to the deterministic baseline when AI is unavailable. */ @Get("simple-sketch") diff --git a/apps/server/src/codegen/codegen.service.spec.ts b/apps/server/src/codegen/codegen.service.spec.ts index 3ead39c..2a0d4ed 100644 --- a/apps/server/src/codegen/codegen.service.spec.ts +++ b/apps/server/src/codegen/codegen.service.spec.ts @@ -11,19 +11,19 @@ import type { EdgeKind } from "../edges/schemas/edge.schema"; import type { GeneratedProject } from "./types"; /* ──────────────────────────────────────────────────────────────────────── - * codegen.service.spec.ts — entegrasyon testi. + * codegen.service.spec.ts — integration test. * - * Gercekci 9-node'luk fixture uzerinden TUM montaj zincirini dogrular: + * Verifies the WHOLE assembly chain over a realistic 9-node fixture: * Module(UsersModule) ── ExposedServices: [UsersService] * UsersController ─CALLS─> UsersService ─CALLS─> UserRepository ─WRITES─> users(Table) * + CreateUserDto + UserDto + UserRole(Enum) + UserNotFoundException + User(Model) * - * Assertion'lar: - * - dogru dosyalar uretildi (feature klasoru = "users") - * - Controller->Service DI'i CALLS edge'inden geldi (Controller semasinda ref NONE) - * - Service DI'i = Dependencies UNION CALLS - * - surgical marker sayisi > 0 - * - import'lar cozumlendi (goreli yollar) + * Assertions: + * - the right files were generated (feature folder = "users") + * - Controller->Service DI came from the CALLS edge (no ref in the Controller schema) + * - Service DI = Dependencies UNION CALLS + * - surgical marker count > 0 + * - imports were resolved (relative paths) * - DETERMINISM: same graph twice -> byte-identical JSON * ──────────────────────────────────────────────────────────────────────── */ @@ -31,7 +31,7 @@ const PROJECT_ID = "00000000-0000-4000-8000-000000000000"; const TAB_ID = "11111111-1111-4111-8111-111111111111"; let seq = 0; -/** Deterministik UUID uretici (test ici). */ +/** Deterministic UUID generator (test-only). */ function uid(): string { seq += 1; const h = seq.toString(16).padStart(12, "0"); @@ -66,7 +66,7 @@ function edge(kind: EdgeKind, source: StoredNode, target: StoredNode): StoredEdg }; } -/* ── Fixture insasi ──────────────────────────────────────────────────────── */ +/* ── Fixture construction ────────────────────────────────────────────────── */ function buildFixture(): { nodes: StoredNode[]; edges: StoredEdge[] } { const usersTable = node("Table", { TableName: "users", @@ -84,7 +84,7 @@ function buildFixture(): { nodes: StoredNode[]; edges: StoredEdge[] } { const userRole = node("Enum", { Name: "UserRole", - Description: "User rolu", + Description: "User role", BackingType: "string", Values: [{ Key: "ADMIN" }, { Key: "MEMBER" }], }); @@ -102,7 +102,7 @@ function buildFixture(): { nodes: StoredNode[]; edges: StoredEdge[] } { const createUserDto = node("DTO", { Name: "CreateUserDto", - Description: "User olusturma girdisi", + Description: "User creation input", Fields: [ { Name: "email", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "Email" }] }, { Name: "role", DataType: "UserRole", IsRequired: true, IsArray: false, ValidationRules: [], EnumRef: "UserRole" }, @@ -138,7 +138,7 @@ function buildFixture(): { nodes: StoredNode[]; edges: StoredEdge[] } { const usersService = node("Service", { ServiceName: "UsersService", - Description: "User is mantigi", + Description: "User business logic", IsTransactionScoped: true, Methods: [ { @@ -208,13 +208,13 @@ function buildFixture(): { nodes: StoredNode[]; edges: StoredEdge[] } { ]; const edges = [ - // CRITICAL: Controller->Service yalniz CALLS edge'inden gelir. + // CRITICAL: Controller->Service comes only from the CALLS edge. edge("CALLS", usersController, usersService), - // Service -> Repository (Dependencies + CALLS birlesimi test edilir). + // Service -> Repository (the Dependencies + CALLS union is under test). edge("CALLS", usersService, userRepository), // Repository -> Table (WRITES). edge("WRITES", userRepository, usersTable), - // Module -> Service (USES) — moduleOf icin ek bag. + // Module -> Service (USES) — extra link for moduleOf. edge("USES", usersModule, usersService), // Service -> Exception (THROWS). edge("THROWS", usersService, notFoundExc), @@ -230,7 +230,7 @@ function generate(): GeneratedProject { return service.assemble(graph, "nestjs"); } -describe("CodegenService (orchestrator entegrasyon)", () => { +describe("CodegenService (orchestrator integration)", () => { const project = generate(); const fileByPath = new Map(project.files.map((f) => [f.path, f])); const path = (p: string) => { @@ -239,30 +239,30 @@ describe("CodegenService (orchestrator entegrasyon)", () => { return f; }; - it("dogru cekirdek dosyalari uretildi (users feature klasoru, idiomatik isimler)", () => { + it("generates the right core files (users feature folder, idiomatic names)", () => { const paths = [...fileByPath.keys()]; - // Feature/TS dosyalari montajda "src/" altina toplanir (scaffold + tsconfig - // include ile tek agac). SQL migration'lari KOKTE kalir (derlenmez). - // ARCHITECTURE-FARKINDA: dosya adlari rol son-ekini TEKRARLAMAZ (users.controller.ts, - // user.repository.ts), feature basina TEK module (users.module.ts). + // Feature/TS files are gathered under "src/" at assembly (one tree with the + // scaffold + tsconfig include). SQL migrations stay at the ROOT (not compiled). + // ARCHITECTURE-AWARE: file names do NOT repeat the role suffix (users.controller.ts, + // user.repository.ts), ONE module per feature (users.module.ts). expect(paths).toContain("src/users/users.module.ts"); expect(paths).toContain("src/users/users.controller.ts"); expect(paths).toContain("src/users/users.service.ts"); expect(paths).toContain("src/users/user.repository.ts"); - // Model entity bagli Table ("users") ile ayni feature'da (DI co-location). + // The Model entity sits in the same feature as its bound Table ("users") (DI co-location). expect(paths).toContain("src/users/entities/user.entity.ts"); - // DTO'lar onlari tuketen Controller/Service'in feature'inda (users/dto). + // DTOs live in the feature of the Controller/Service that consumes them (users/dto). expect(paths).toContain("src/users/dto/create-user.dto.ts"); expect(paths).toContain("src/users/dto/user.dto.ts"); - // Exception THROWS eden UsersService ile ayni feature'da (users/exceptions). + // The Exception sits in the same feature as the UsersService that THROWS it (users/exceptions). expect(paths).toContain("src/users/exceptions/user-not-found.exception.ts"); - // Enum (paylasimli kabul edilir) -> common/enums. + // Enum (treated as shared) -> common/enums. expect(paths).toContain("src/common/enums/user-role.enum.ts"); - // TableName "users" fiziksel ad sayilir (cogullanmaz) -> "001_create_users.sql". + // TableName "users" counts as a physical name (not re-pluralized) -> "001_create_users.sql". expect(paths).toContain("migrations/001_create_users.sql"); }); - it("scaffold (proje-genel) dosyalari uretildi", () => { + it("generates the scaffold (project-wide) files", () => { const paths = [...fileByPath.keys()]; expect(paths).toContain("package.json"); expect(paths).toContain("tsconfig.json"); @@ -270,24 +270,24 @@ describe("CodegenService (orchestrator entegrasyon)", () => { expect(paths).toContain("nest-cli.json"); expect(paths).toContain("src/main.ts"); expect(paths).toContain("src/app.module.ts"); - // H3/H1: cekirdek altyapi + global filter. + // H3/H1: core infrastructure + global filter. expect(paths).toContain("src/core/core.module.ts"); expect(paths).toContain("src/shared/filters/all-exceptions.filter.ts"); - // H4: .env.example KOKTE (src/ altinda degil). + // H4: .env.example at the ROOT (not under src/). expect(paths).toContain(".env.example"); expect(paths).not.toContain("src/.env.example"); // H5: TypeORM CLI data-source. expect(paths).toContain("src/data-source.ts"); - // H6: test/CI iskeleti. + // H6: test/CI skeleton. expect(paths).toContain("test/app.e2e-spec.ts"); expect(paths).toContain(".gitignore"); expect(paths).toContain("README.md"); }); - it("H5: SQL migration basina calistirilabilir TypeORM TS migration uretildi", () => { + it("H5: generates a runnable TypeORM TS migration per SQL migration", () => { const paths = [...fileByPath.keys()]; - // users tablosu -> migrations/001_create_users.sql (ham referans) + - // src/migrations/-CreateUsers.ts (TypeORM gelenegi: -.ts). + // users table -> migrations/001_create_users.sql (the raw reference) + + // src/migrations/-CreateUsers.ts (TypeORM convention: -.ts). expect(paths).toContain("migrations/001_create_users.sql"); const tsMig = paths.find((p) => /^src\/migrations\/\d{13}-Create\w+\.ts$/.test(p)); expect(tsMig).toBeDefined(); @@ -297,9 +297,9 @@ describe("CodegenService (orchestrator entegrasyon)", () => { expect(mig).toContain("public async down(queryRunner: QueryRunner)"); expect(mig).toContain('CREATE TABLE "users"'); expect(mig).toContain('DROP TABLE IF EXISTS "users" CASCADE'); - // TypeORM, sinif adinin SON 13 hanesini timestamp olarak parseInt eder - // (MigrationExecutor: name.substr(-13)); salt "001" soneki NaN -> CLI firlatir. - // Sinif adi 13-haneli zaman damgasiyla bitmeli ve parseInt edilebilmeli. + // TypeORM parseInts the LAST 13 digits of the class name as a timestamp + // (MigrationExecutor: name.substr(-13)); a bare "001" suffix is NaN -> the CLI throws. + // The class name must end in a 13-digit timestamp that parseInt can read. const className = mig.match(/export class (\w+) implements MigrationInterface/)?.[1]; expect(className).toBeDefined(); const last13 = className!.slice(-13); @@ -307,130 +307,130 @@ describe("CodegenService (orchestrator entegrasyon)", () => { expect(Number.isNaN(Number.parseInt(last13, 10))).toBe(false); }); - it("DTO'lar uretildi (RequestDTORef/ResponseDTORef cozulebilir)", () => { + it("generates the DTOs (RequestDTORef/ResponseDTORef resolvable)", () => { const paths = [...fileByPath.keys()]; expect(paths.some((p) => p.endsWith("create-user.dto.ts"))).toBe(true); expect(paths.some((p) => p.endsWith("/user.dto.ts"))).toBe(true); }); - it("Controller->Service DI'i CALLS edge'inden geldi (semada ref yok)", () => { + it("Controller->Service DI comes from the CALLS edge (no ref in the schema)", () => { const controller = path("src/users/users.controller.ts").content; - // constructor injection UsersService uzerinden + // constructor injection via UsersService expect(controller).toContain("constructor("); expect(controller).toContain("private readonly usersService: UsersService"); - // UsersService import'u goreli yoldan cozuldu (ayni feature klasoru) + // the UsersService import resolved to a relative path (same feature folder) expect(controller).toMatch(/import \{ UsersService \} from "\.\/users\.service"/); }); - it("Controller route Version oneki + endpoint metotlari + auth guard", () => { + it("controller route carries the Version prefix + endpoint methods + auth guard", () => { const controller = path("src/users/users.controller.ts").content; expect(controller).toContain('@Controller("v1/users")'); expect(controller).toContain("@Post()"); expect(controller).toContain('@Get(":id")'); expect(controller).toContain("@HttpCode(201)"); expect(controller).toContain("@UseGuards(AuthGuard)"); - // RequestDTORef cozuldu -> @Body() dto: CreateUserDto + // RequestDTORef resolved -> @Body() dto: CreateUserDto expect(controller).toContain("@Body() dto: CreateUserDto"); }); - it("Service DI'i = Dependencies UNION CALLS (UserRepository tek kez)", () => { + it("service DI = Dependencies UNION CALLS (UserRepository only once)", () => { const svc = path("src/users/users.service.ts").content; expect(svc).toContain("@Injectable()"); expect(svc).toContain("constructor("); - // Repository hem Dependencies'te hem CALLS edge'inde -> tek injection (dedup) + // The repository appears both in Dependencies and on a CALLS edge -> one injection (deduped) const repoInjections = svc.match(/userRepository: UserRepository/g) ?? []; expect(repoInjections.length).toBe(1); - // import cozuldu (ayni feature) + // import resolved (same feature) expect(svc).toMatch(/from "\.\/user\.repository"/); }); - it("Repository @InjectRepository + entity import cozuldu", () => { + it("repository @InjectRepository + entity import resolved", () => { const repo = path("src/users/user.repository.ts").content; expect(repo).toContain("@Injectable()"); - // EntityReference -> User Model entity import'u (entity ayni feature'da -> ./entities). + // EntityReference -> User Model entity import (the entity is in the same feature -> ./entities). expect(repo).toMatch(/from "\.\/entities\/user\.entity"/); - // CustomQuery -> async imza + surgical marker + // CustomQuery -> async signature + surgical marker expect(repo).toContain("findByEmail"); expect(repo).toContain("@solarch:surgical"); }); - it("Table migration Postgres DDL + ENUM kolon", () => { + it("table migration is Postgres DDL + ENUM column", () => { const sql = path("migrations/001_create_users.sql"); expect(sql.language).toBe("sql"); expect(sql.content).toContain('CREATE TABLE "users"'); - // Entity @Entity adi ile migration tablo adi AYNI (ayrismaz). + // The @Entity name and the migration table name are the SAME (they never diverge). const entity = path("src/users/entities/user.entity.ts").content; expect(entity).toContain('@Entity("users")'); expect(sql.surgicalMarkers).toBe(0); }); - it("Feature module SYNTHESIZED: @Module dekoratoru + DI listeleri (repository kayitli)", () => { + it("feature module SYNTHESIZED: @Module decorator + DI lists (repository registered)", () => { const mod = path("src/users/users.module.ts").content; expect(mod).toContain("@Module("); expect(mod).toContain("controllers: [UsersController],"); - // providers repository'yi de icerir -> DI tam, uygulama BOOT BOOTS. + // providers include the repository too -> DI is complete, the application boots. expect(mod).toContain("providers: [UsersService, UserRepository],"); expect(mod).toContain("TypeOrmModule.forFeature([User])"); expect(mod).toContain("export class UsersModule {}"); }); - it("app.module feature modulunu import eder (ham controller/provider NOT)", () => { + it("app.module imports the feature module (no raw controllers/providers)", () => { const app = path("src/app.module.ts").content; expect(app).toContain('import { UsersModule } from "./users/users.module";'); expect(app).toContain(" UsersModule,"); - // Ham controller/provider app.module'e GIRMEZ. + // Raw controllers/providers never enter app.module. expect(app).not.toContain("controllers:"); expect(app).not.toContain("providers:"); expect(app).not.toContain("UsersController"); }); - it("surgical marker sayisi > 0 (Service/Controller/Repository govdeleri)", () => { + it("surgical marker count > 0 (Service/Controller/Repository bodies)", () => { expect(project.summary.surgicalMarkerCount).toBeGreaterThan(0); const total = project.files.reduce((s, f) => s + f.surgicalMarkers, 0); expect(total).toBe(project.summary.surgicalMarkerCount); }); - it("summary: fileCount/nodeCount dogru, skippedKinds bos (12 tip yok)", () => { + it("summary: fileCount/nodeCount correct, skippedKinds empty (no unsupported kinds)", () => { expect(project.summary.nodeCount).toBe(10); expect(project.summary.fileCount).toBe(project.files.length); - // Fixture'da desteklenmeyen 12 tip NONE -> skippedKinds bos. + // The fixture contains none of the 12 unsupported kinds -> skippedKinds is empty. expect(project.summary.skippedKinds).toEqual({}); }); - it("summary.version = CODEGEN_VERSION = 6 (cikti kendi nesli ile etiketlenir)", () => { + it("summary.version = CODEGEN_VERSION = 6 (output is tagged with its own generation)", () => { expect(project.summary.version).toBe(CODEGEN_VERSION); expect(CODEGEN_VERSION).toBe(6); }); - it("SURGICAL_PLAN.md uretildi (proje KOKUNDE, markdown, Ingilizce prompt)", () => { + it("generates SURGICAL_PLAN.md (at the project ROOT, markdown, English prompt)", () => { const plan = path("SURGICAL_PLAN.md"); expect(plan.language).toBe("markdown"); - // Proje KOKUNDE (src/ altinda NOT). + // At the project ROOT (NOT under src/). expect([...fileByPath.keys()]).toContain("SURGICAL_PLAN.md"); expect([...fileByPath.keys()]).not.toContain("src/SURGICAL_PLAN.md"); - // Iki bolum + kapanis talimati. + // Two sections + a closing instruction. expect(plan.content).toContain("## 1. Codebase introduction"); expect(plan.content).toContain("## 2. Surgical implementation plan"); expect(plan.content).toContain("## Instructions"); - // Codebase tanitimi: NestJS + Solarch. + // Codebase introduction: NestJS + Solarch. expect(plan.content).toContain("NestJS"); expect(plan.content).toContain("Solarch"); - // Feature listesi graph'tan (fixture'da "users" feature'i var). + // The feature list comes from the graph (the fixture has a "users" feature). expect(plan.content).toContain("`users`"); - // Plan, uretilen marker'lari gorur: UsersService.create govdesi listelenir. + // The plan sees the generated markers: the UsersService.create body is listed. expect(plan.content).toContain("src/users/users.service.ts"); expect(plan.content).toContain("Implement:"); - // MD'nin KENDI marker'i NONE (plan metnidir) -> surgicalMarkers 0. + // The MD carries no marker of its OWN (it is plan text) -> surgicalMarkers 0. expect(plan.surgicalMarkers).toBe(0); }); - it("dosyalar path'e gore sirali (determinizm)", () => { + it("files are sorted by path (determinism)", () => { const paths = project.files.map((f) => f.path); const sorted = [...paths].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); expect(paths).toEqual(sorted); }); - it("her dosya tek '\\n' ile biter", () => { + it("every file ends with a single '\\n'", () => { for (const f of project.files) { expect(f.content.endsWith("\n")).toBe(true); expect(f.content.endsWith("\n\n")).toBe(false); @@ -446,10 +446,10 @@ describe("CodegenService (orchestrator entegrasyon)", () => { expect(JSON.stringify(a)).toBe(JSON.stringify(b)); }); - it("SIRA-DEGISMEZLIGI: girdi node/edge sirasi ters cevrilince cikti AYNI", () => { - // Gercek nondeterminizm kaynagi DB'nin SIRASIZ list()'idir; IR her seyi - // yeniden siralar. Bu test, siralamanin TAM (stabil tiebreak'li) oldugunu - // KANITLAR: ayni node'lar ters sirada verildiginde cikti birebir ayni kalmali. + it("ORDER-INVARIANCE: reversing the input node/edge order yields the SAME output", () => { + // The real source of nondeterminism is the DB's UNORDERED list(); the IR + // re-sorts everything. This test PROVES the sort is total (with stable + // tiebreaks): the same nodes given in reverse order must produce byte-identical output. const { nodes, edges } = buildFixture(); const service = new CodegenService(null as never, null as never, null as never, null as never); @@ -461,12 +461,12 @@ describe("CodegenService (orchestrator entegrasyon)", () => { expect(JSON.stringify(reversed.files)).toBe(JSON.stringify(forward.files)); }); - it("SIRA-DEGISMEZLIGI: rastgele karistirilmis girdi -> byte-identical cikti", () => { + it("ORDER-INVARIANCE: shuffled input -> byte-identical output", () => { const { nodes, edges } = buildFixture(); const service = new CodegenService(null as never, null as never, null as never, null as never); const base = service.assemble(buildCodeGraph(nodes, edges), "nestjs"); - // Deterministik (seed'siz ama sabit) bir permutasyon: index'e gore dondur. + // A deterministic (seedless but fixed) permutation: rotate by index. const rotate = (arr: T[], by: number): T[] => { const k = ((by % arr.length) + arr.length) % arr.length; return [...arr.slice(k), ...arr.slice(0, k)]; @@ -480,11 +480,11 @@ describe("CodegenService (orchestrator entegrasyon)", () => { } }); - it("auth guard + roles decorator stub'lari uretildi (controller import'lari cozulur)", () => { - // Controller RequiresAuth=true + RequiredRoles olan endpoint kullanir; - // import edilen stub dosyalar montajda gercekten uretilmeli (TS2307 onlenir). + it("generates the auth guard + roles decorator stubs (controller imports resolve)", () => { + // The controller uses an endpoint with RequiresAuth=true + RequiredRoles; + // the imported stub files must actually be generated at assembly (prevents TS2307). const paths = [...fileByPath.keys()]; - // shared/ standardizasyonu: guard/decorator artik shared/ altinda (common/ degil). + // shared/ standardization: guards/decorators now live under shared/ (not common/). expect(paths).toContain("src/shared/guards/auth.guard.ts"); const guard = path("src/shared/guards/auth.guard.ts").content; expect(guard).toContain("export class AuthGuard"); @@ -492,11 +492,12 @@ describe("CodegenService (orchestrator entegrasyon)", () => { }); }); -describe("CodegenService.generate — secret redaksiyonu (defense-in-depth)", () => { - it("nodes.list redaksiyon yapmasa da codegen sinirinda secret IR'a girmez", async () => { - // Legacy: write-guard oncesi yazilmis secret EnvironmentVariable (duz-metin - // DefaultValue). Repository.list redaksiyon yapmaz; generate() sinirda redakte - // etmeli, boylece koruma yapisaldir (her emitter'in IsSecret kontrolune bagli degil). +describe("CodegenService.generate — secret redaction (defense-in-depth)", () => { + it("keeps secrets out of the IR at the codegen boundary even when nodes.list does not redact", async () => { + // Legacy: a secret EnvironmentVariable written before the write-guard (plain-text + // DefaultValue). Repository.list does not redact; generate() must redact at the + // boundary, so the protection is structural (not dependent on every emitter's + // IsSecret check). const PROJECT = "00000000-0000-4000-8000-00000000abcd"; const secretNode: StoredNode = { id: "aaaaaaaa-aaaa-4aaa-8aaa-000000000abc", @@ -510,7 +511,7 @@ describe("CodegenService.generate — secret redaksiyonu (defense-in-depth)", () version: 1, properties: { Key: "JWT_SECRET", - Description: "imza anahtari", + Description: "signing key", DataType: "String", IsSecret: true, Environment: ["Prod"], @@ -522,13 +523,13 @@ describe("CodegenService.generate — secret redaksiyonu (defense-in-depth)", () let observed: StoredNode[] | null = null; const projects = { exists: async () => true } as never; const nodes = { - list: async () => [secretNode], // RAW (redaksiyonsuz) — repository davranisi. + list: async () => [secretNode], // RAW (unredacted) — repository behavior. } as never; const edges = { list: async () => [] } as never; const surgicalFills = { getAllForProject: async () => [] } as never; const service = new CodegenService(projects, nodes, edges, surgicalFills); - // assemble'i sarmalayip generate'in ona verdigi graph'i yakala. + // Wrap assemble to capture the graph that generate hands it. const origAssemble = service.assemble.bind(service); service.assemble = ((graph, target) => { observed = graph.nodes.map((n) => ({ ...n })); @@ -537,19 +538,19 @@ describe("CodegenService.generate — secret redaksiyonu (defense-in-depth)", () const project = await service.generate(PROJECT, "nestjs"); - // Duz-metin secret HICBIR uretilen dosyada gorunmemeli. + // The plain-text secret must not appear in ANY generated file. for (const f of project.files) { expect(f.content).not.toContain("super-secret-plaintext-value"); } - // IR'a giren node'un DefaultValue'su redakte edilmis olmali (bos). + // The node entering the IR must have its DefaultValue redacted (empty). expect(observed).not.toBeNull(); const envNode = observed!.find((n) => n.type === "EnvironmentVariable"); expect((envNode!.properties as Record).DefaultValue).toBe(""); }); }); -describe("CodegenService — mimari altyapi tam uretim (Cache/Worker artik supported)", () => { - it("Cache + Worker -> GERCEK kod (stub NOT) + skippedKinds bos", () => { +describe("CodegenService — full generation of architectural infrastructure (Cache/Worker now supported)", () => { + it("Cache + Worker -> REAL code (no stubs) + empty skippedKinds", () => { const cache = node("Cache", { CacheName: "SessionCache", Description: "Session cache", @@ -568,32 +569,32 @@ describe("CodegenService — mimari altyapi tam uretim (Cache/Worker artik suppo const service = new CodegenService(null as never, null as never, null as never, null as never); const project = service.assemble(graph, "nestjs"); - // Artik GERCEK kod uretilir; .stub.ts NONE. + // REAL code is generated now; no .stub.ts files. expect(project.files.some((f) => f.path.endsWith(".cache.ts"))).toBe(true); expect(project.files.some((f) => f.path.endsWith(".worker.ts"))).toBe(true); expect(project.files.some((f) => f.path.endsWith(".stub.ts"))).toBe(false); - // Cache/Worker artik supported -> skippedKinds bos. + // Cache/Worker are supported now -> skippedKinds is empty. expect(project.summary.skippedKinds).toEqual({}); - // Worker @Cron handler'i surgical marker tasir. + // The Worker's @Cron handler carries a surgical marker. expect(project.summary.surgicalMarkerCount).toBeGreaterThan(0); - // nodeFiles haritasi: her node kendi dosyasina eslenir. + // nodeFiles map: each node maps to its own file. expect(project.nodeFiles[cache.id]?.some((p) => p.endsWith(".cache.ts"))).toBe(true); expect(project.nodeFiles[worker.id]?.some((p) => p.endsWith(".worker.ts"))).toBe(true); - // H3: root forRoot/register artik CoreModule'de (app.module degil). + // H3: root forRoot/register now lives in CoreModule (not app.module). const core = project.files.find((f) => f.path === "src/core/core.module.ts")!.content; - // Worker -> ScheduleModule.forRoot() (@Cron ateslensin). + // Worker -> ScheduleModule.forRoot() (so @Cron actually fires). expect(core).toContain("ScheduleModule.forRoot()"); - // Cache -> CacheModule app root'a kaydedilir. + // Cache -> CacheModule is registered at the app root. expect(core).toContain("CacheModule.register({ isGlobal: true })"); - // package.json gerekli deps'i aldi (graph-farkinda). + // package.json picked up the required deps (graph-aware). const pkg = project.files.find((f) => f.path === "package.json")!.content; expect(pkg).toContain("@nestjs/cache-manager"); expect(pkg).toContain("@nestjs/schedule"); }); - it("EnvironmentVariable -> .stub.ts URETILMEZ (config; tek temsil .env.example), skippedKinds'e sayilir", () => { + it("EnvironmentVariable -> no .stub.ts (it is config; .env.example is its only representation), counted in skippedKinds", () => { const env = node("EnvironmentVariable", { Key: "DATABASE_URL", Description: "DB baglantisi", @@ -606,23 +607,23 @@ describe("CodegenService — mimari altyapi tam uretim (Cache/Worker artik suppo const service = new CodegenService(null as never, null as never, null as never, null as never); const project = service.assemble(graph, "nestjs"); - // Bir ortam degiskeni kod modulu NOT -> anlamsiz `export class XStub {}` uretilmez. + // An environment variable is NOT a code module -> no meaningless `export class XStub {}`. expect(project.files.some((f) => f.path.includes("environment-variable"))).toBe(false); expect(project.files.some((f) => f.path.endsWith(".stub.ts"))).toBe(false); - // Tek temsili .env.example (H4: KOKTE). + // Its only representation is .env.example (H4: at the ROOT). const envExample = project.files.find((f) => f.path === ".env.example"); expect(envExample?.content).toContain("DATABASE_URL"); - // Kayitsiz kind -> skippedKinds'e sayilir (sessizce dusmez). + // Unregistered kind -> counted in skippedKinds (never dropped silently). expect(project.summary.skippedKinds).toEqual({ EnvironmentVariable: 1 }); }); }); -describe("CodegenService — fault-isolation (M5: bozuk node TUM codegen'i dusurmez)", () => { - it("emitter PATLARSA o node skippedKinds'e sayilir, geri kalan graph uretilir", () => { - // Cache emitter'i (supported) tek bir node'da patlatilir. Beklenen: Cache - // dosyasi URETILMEZ + skippedKinds.Cache=1; ama Worker (saglam) emit edilir - // ve scaffold/feature montaji kesilmez. Try/catch yoksa assemble TUMUYLE - // throw eder ve hicbir dosya cikmazdi. +describe("CodegenService — fault-isolation (M5: a broken node does not take down ALL of codegen)", () => { + it("when an emitter THROWS, that node is counted in skippedKinds and the rest of the graph is generated", () => { + // The (supported) Cache emitter is made to throw on a single node. Expected: no + // Cache file is generated + skippedKinds.Cache=1; but the (healthy) Worker is + // emitted and scaffold/feature assembly is not interrupted. Without the + // try/catch, assemble would throw ENTIRELY and no file would come out. const cache = node("Cache", { CacheName: "SessionCache", Description: "x", @@ -639,8 +640,8 @@ describe("CodegenService — fault-isolation (M5: bozuk node TUM codegen'i dusur const graph = buildCodeGraph([cache, worker], []); const service = new CodegenService(null as never, null as never, null as never, null as never); - // Cache emitter'ini GECICI olarak patlat (gercek "emitter undefined alana - // patlar" senaryosunu simule eder), sonra mutlaka geri yukle. + // Make the Cache emitter throw TEMPORARILY (simulates the real "emitter blows + // up on an undefined field" scenario), then always restore it. const entry = EMITTER_REGISTRY.Cache!; const original = entry.emit; entry.emit = () => { @@ -653,18 +654,18 @@ describe("CodegenService — fault-isolation (M5: bozuk node TUM codegen'i dusur entry.emit = original; } - // Bozuk Cache atlandi: dosya NONE + skippedKinds'e sayildi (sessizce dusmedi). + // The broken Cache was skipped: no file + counted in skippedKinds (not dropped silently). expect(project.files.some((f) => f.path.endsWith(".cache.ts"))).toBe(false); expect(project.summary.skippedKinds).toEqual({ Cache: 1 }); - // Saglam Worker emit edilmeye devam etti (codegen dusmedi). + // The healthy Worker kept being emitted (codegen did not go down). expect(project.files.some((f) => f.path.endsWith(".worker.ts"))).toBe(true); - // Scaffold montaji da kesilmedi. + // Scaffold assembly was not interrupted either. expect(project.files.some((f) => f.path === "src/main.ts")).toBe(true); expect(project.files.some((f) => f.path === "src/app.module.ts")).toBe(true); }); - it("DETERMINISM: ayni bozuk node iki kez -> byte-identical cikti (hangi node patlar sabittir)", () => { + it("DETERMINISM: the same broken node twice -> byte-identical output (which node throws is fixed)", () => { const cache = node("Cache", { CacheName: "C", Description: "x" }); const worker = node("Worker", { WorkerName: "W", Description: "x", Schedule: "0 * * * *", TaskToExecute: "t" }); const service = new CodegenService(null as never, null as never, null as never, null as never); @@ -682,11 +683,12 @@ describe("CodegenService — fault-isolation (M5: bozuk node TUM codegen'i dusur } }); - it("adi WITHOUT (name-property string degil/bos) bozuk node atlanir + sayilir, GARBAGE uretmez", () => { - // Gercek bozuk girdi: ServiceName bir SAYI, Methods bir DIZI NOT. ir.toCodeNode - // name'i ""e zorlar; orchestrator bos-adli node'u atlar. Emitter cagrilmadan - // skippedKinds'e sayilir -> gecersiz "export class { }" + "undefined()" GARBAGE - // CIKMAZ; saglam node uretilmeye devam eder (feature-inference de patlamaz). + it("a nameless broken node (name property not a string / empty) is skipped + counted, emits no GARBAGE", () => { + // Genuinely broken input: ServiceName is a NUMBER, Methods is NOT an array. + // ir.toCodeNode forces the name to ""; the orchestrator skips the empty-named + // node. It is counted in skippedKinds without the emitter being called -> no + // invalid "export class { }" + "undefined()" GARBAGE comes out; the healthy + // node keeps being generated (feature-inference doesn't blow up either). const broken = node("Service", { ServiceName: 12345, Methods: "not-an-array", Dependencies: null }); const good = node("Service", { ServiceName: "GoodService", @@ -697,23 +699,24 @@ describe("CodegenService — fault-isolation (M5: bozuk node TUM codegen'i dusur const service = new CodegenService(null as never, null as never, null as never, null as never); const project = service.assemble(buildCodeGraph([broken, good], []), "nestjs"); - // Bozuk node skippedKinds'e sayildi; hicbir dosya uretmedi. + // The broken node was counted in skippedKinds; it produced no file. expect(project.summary.skippedKinds).toEqual({ Service: 1 }); expect(project.nodeFiles[broken.id]).toBeUndefined(); - // GARBAGE yok: bos sinif adi ya da "undefined()" metodu iceren dosya uretilmemeli. + // No GARBAGE: no file with an empty class name or an "undefined()" method. const tsContent = project.files.filter((f) => f.language === "typescript").map((f) => f.content).join("\n"); expect(tsContent).not.toMatch(/export class\s+\{/); expect(tsContent).not.toContain("undefined(): void"); - // Saglam servis hâlâ uretildi (codegen dusmedi). + // The healthy service was still generated (codegen did not go down). expect(project.files.some((f) => f.path === "src/good/good.service.ts")).toBe(true); }); }); -describe("CodegenService — warnings ciktiya tasinir (M4: dongusel module import)", () => { - it("karsilikli feature CALLS (A<->B): dongu kirilir + project.warnings'e uyari yazilir", () => { - // alpha <-> beta servisleri karsilikli CALLS -> module import dongusu. Orchestrator - // bir geri-kenari forwardRef ile kirar (kenar KORUNUR, lazy emit) ve bunu - // project.warnings'te BILDIRIR; eskiden uyari yalniz graph icinde kalip kaybolurdu. +describe("CodegenService — warnings are carried into the output (M4: circular module import)", () => { + it("mutual feature CALLS (A<->B): the cycle is broken + a warning lands in project.warnings", () => { + // The alpha <-> beta services CALL each other -> a module import cycle. The + // orchestrator breaks one back-edge with forwardRef (the edge is KEPT, emitted + // lazily) and REPORTS it in project.warnings; previously the warning stayed + // inside the graph and got lost. const alphaCtrl = node("Controller", { ControllerName: "AlphaController", Description: "a", BaseRoute: "alpha", Endpoints: [] }); const alphaSvc = node("Service", { ServiceName: "AlphaService", Description: "a", Dependencies: [], Methods: [] }); const betaCtrl = node("Controller", { ControllerName: "BetaController", Description: "b", BaseRoute: "beta", Endpoints: [] }); @@ -722,7 +725,7 @@ describe("CodegenService — warnings ciktiya tasinir (M4: dongusel module impor edge("CALLS", alphaCtrl, alphaSvc), edge("CALLS", betaCtrl, betaSvc), edge("CALLS", alphaSvc, betaSvc), // alpha -> beta - edge("CALLS", betaSvc, alphaSvc), // beta -> alpha (karsilikli) + edge("CALLS", betaSvc, alphaSvc), // beta -> alpha (mutual) ]; const service = new CodegenService(null as never, null as never, null as never, null as never); const project = service.assemble(buildCodeGraph([alphaCtrl, alphaSvc, betaCtrl, betaSvc], edges), "nestjs"); @@ -733,7 +736,7 @@ describe("CodegenService — warnings ciktiya tasinir (M4: dongusel module impor expect(project.warnings[0]).toContain("forwardRef"); }); - it("dongu yoksa project.warnings bos dizidir", () => { + it("without a cycle project.warnings is an empty array", () => { const ctrl = node("Controller", { ControllerName: "SoloController", Description: "x", BaseRoute: "solo", Endpoints: [] }); const svc = node("Service", { ServiceName: "SoloService", Description: "x", Dependencies: [], Methods: [] }); const service = new CodegenService(null as never, null as never, null as never, null as never); @@ -742,15 +745,15 @@ describe("CodegenService — warnings ciktiya tasinir (M4: dongusel module impor }); }); -describe("CodegenService — module wiring EMIT dogrulamasi (Bug 1 + Bug 2 uctan-uca)", () => { - /** Uretilen dosyanin icerigini path-son-eki ile bulur. */ +describe("CodegenService — module wiring EMIT verification (Bug 1 + Bug 2 end-to-end)", () => { + /** Finds a generated file's content by path suffix. */ function fileEndingWith(project: GeneratedProject, suffix: string): GeneratedFile { const f = project.files.find((x) => x.path.endsWith(suffix)); if (!f) throw new Error(`not generated: ${suffix} (existing: ${project.files.map((x) => x.path).join(", ")})`); return f; } - it("Bug 1: 3'lu dongude uretilen module forwardRef(() => XModule) EMIT eder", () => { + it("Bug 1: in a 3-cycle the generated module EMITS forwardRef(() => XModule)", () => { const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); const chatCtrl = node("Controller", { ControllerName: "ChatController", Description: "x", BaseRoute: "chat", Endpoints: [] }); @@ -768,16 +771,16 @@ describe("CodegenService — module wiring EMIT dogrulamasi (Bug 1 + Bug 2 uctan const service = new CodegenService(null as never, null as never, null as never, null as never); const project = service.assemble(buildCodeGraph([authCtrl, authSvc, chatCtrl, chatSvc, msgCtrl, msgSvc], edges), "nestjs"); - // messaging->auth geri-kenari forwardRef olur (to="auth" en kucuk). + // The messaging->auth back-edge becomes forwardRef (to="auth" is the smallest). const msgModule = fileEndingWith(project, "messaging/messaging.module.ts"); expect(msgModule.content).toContain("forwardRef(() => AuthModule)"); expect(msgModule.content).toContain('import { Module, forwardRef } from "@nestjs/common"'); - // Eager kenarlar duz emit edilir (forwardRef NONE). + // Eager edges are emitted plainly (no forwardRef). expect(fileEndingWith(project, "auth/auth.module.ts").content).toContain("imports: [ChatModule]"); expect(fileEndingWith(project, "chat/chat.module.ts").content).toContain("imports: [MessagingModule]"); }); - it("Bug 2: property-dep'li cross-feature Repository → tuketici module sahibi import eder", () => { + it("Bug 2: cross-feature Repository via property-dep → the consuming module imports the owner", () => { const userCtrl = node("Controller", { ControllerName: "UserController", Description: "x", BaseRoute: "users", Endpoints: [] }); const userSvc = node("Service", { ServiceName: "UserService", Description: "x", Dependencies: [], Methods: [] }); const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "users", CustomQueries: [] }); @@ -787,24 +790,24 @@ describe("CodegenService — module wiring EMIT dogrulamasi (Bug 1 + Bug 2 uctan edge("CALLS", userCtrl, userSvc), edge("CALLS", userSvc, userRepo), edge("CALLS", tokenCtrl, tokenSvc), - // tokenSvc -> userRepo CALLS edge'i NONE (yalniz property-dep). + // No tokenSvc -> userRepo CALLS edge (property-dep only). ]; const service = new CodegenService(null as never, null as never, null as never, null as never); const project = service.assemble(buildCodeGraph([userCtrl, userSvc, userRepo, tokenCtrl, tokenSvc], edges), "nestjs"); - // TokenModule UserModule'u import eder; UserModule UserRepository'yi export eder. + // TokenModule imports UserModule; UserModule exports UserRepository. expect(fileEndingWith(project, "token/token.module.ts").content).toContain("UserModule"); expect(fileEndingWith(project, "user/user.module.ts").content).toMatch(/exports:\s*\[[^\]]*UserRepository/); }); }); -describe("CodegenService — Table-only graph BOOT garantisi (mimari-farkinda)", () => { - it("Model'siz Table + cross-feature Service->Repository: sentetik entity + module export -> boot eder", () => { - // image feature ImageGenerationService -CALLS-> UserRepository (auth). - // Tablolar Model'siz (Users/GeneratedImages). Beklenen: - // - Repository + string token NONE; sentetik entity import + Repository. - // - AuthModule UserRepository EXPORT eder; ImageModule AuthModule import eder. - // - app.module yalniz feature modullerini import eder (ham provider degil). +describe("CodegenService — Table-only graph BOOT guarantee (architecture-aware)", () => { + it("Model-less Table + cross-feature Service->Repository: synthetic entity + module export -> boots", () => { + // The image feature's ImageGenerationService -CALLS-> UserRepository (auth). + // The tables have no Model (Users/GeneratedImages). Expected: + // - no Repository + string token; synthetic entity import + Repository. + // - AuthModule EXPORTS UserRepository; ImageModule imports AuthModule. + // - app.module imports only the feature modules (not raw providers). const usersTable = node("Table", { TableName: "Users", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] }); const imagesTable = node("Table", { TableName: "GeneratedImages", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] }); const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "Users", BaseClass: "BaseRepository", CustomQueries: [{ QueryName: "FindById", QueryType: "findOne", Parameters: [{ Name: "id", Type: "UUID" }], ReturnType: "Users" }] }); @@ -831,33 +834,33 @@ describe("CodegenService — Table-only graph BOOT garantisi (mimari-farkinda)", const project = service.assemble(graph, "nestjs"); const byPath = new Map(project.files.map((f) => [f.path, f])); - // Sentetik entity'ler uretildi. + // The synthetic entities were generated. expect(byPath.has("src/auth/entities/user.entity.ts")).toBe(true); expect(byPath.has("src/image/entities/generated-image.entity.ts")).toBe(true); - // UserRepository sentetik entity'ye baglandi (Repository NONE). + // UserRepository was bound to the synthetic entity (no Repository). const userRepoFile = byPath.get("src/auth/user.repository.ts")!.content; expect(userRepoFile).toContain("Repository"); expect(userRepoFile).not.toContain("Repository"); expect(userRepoFile).not.toContain("extends BaseRepository"); expect(userRepoFile).toContain("FindById(id: string)"); // UUID -> string - // AuthModule UserRepository EXPORT eder (cross-feature DI boot eder). + // AuthModule EXPORTS UserRepository (cross-feature DI boots). const authMod = byPath.get("src/auth/auth.module.ts")!.content; expect(authMod).toContain("exports: [UserRepository],"); expect(authMod).toContain("TypeOrmModule.forFeature([User])"); - // ImageModule: AuthModule import + sentetik entity forFeature + Cache provider. + // ImageModule: AuthModule import + synthetic entity forFeature + Cache provider. const imageMod = byPath.get("src/image/image.module.ts")!.content; expect(imageMod).toContain("AuthModule"); expect(imageMod).toContain("TypeOrmModule.forFeature([GeneratedImage])"); - // Cache artik TAM emitter -> gercek provider sinifi (Stub eki NONE). + // Cache is a FULL emitter now -> a real provider class (no Stub suffix). expect(imageMod).toContain("ImageCache"); expect(imageMod).not.toContain("ImageCacheStub"); - // CacheModule.register() feature module imports'a girer (CACHE_MANAGER token). + // CacheModule.register() enters the feature module imports (CACHE_MANAGER token). expect(imageMod).toContain("CacheModule.register()"); - // app.module yalniz feature modullerini import eder. + // app.module imports only the feature modules. const app = byPath.get("src/app.module.ts")!.content; expect(app).toContain("AuthModule,"); expect(app).toContain("ImageModule,"); @@ -866,13 +869,13 @@ describe("CodegenService — Table-only graph BOOT garantisi (mimari-farkinda)", }); describe("CodegenService — orphan-prevention (B2 gateway / B3 common / B4 config)", () => { - it("B2: APIGateway gercek @Controller -> feature module controllers'ina girer (orphan degil)", () => { - // Gateway -CALLS-> UsersService -> gateway, users feature'ina duser; gercek - // @Controller olarak users.module controllers'ina girer (UsersController ile). + it("B2: APIGateway is a real @Controller -> enters the feature module's controllers (not an orphan)", () => { + // Gateway -CALLS-> UsersService -> the gateway lands in the users feature; as a + // real @Controller it enters users.module's controllers (alongside UsersController). const usersSvc = node("Service", { ServiceName: "UsersService", Description: "x", Methods: [], Dependencies: [] }); const usersCtrl = node("Controller", { ControllerName: "UsersController", Description: "x", BaseRoute: "users", Endpoints: [] }); const gateway = node("APIGateway", { - GatewayName: "PublicApiGateway", Description: "giris", Provider: "Kong", + GatewayName: "PublicApiGateway", Description: "login", Provider: "Kong", Routes: [{ Path: "/public/users", TargetRef: "UsersService", Methods: ["GET"], AuthRequired: false }], }); const nodes = [usersSvc, usersCtrl, gateway]; @@ -881,24 +884,24 @@ describe("CodegenService — orphan-prevention (B2 gateway / B3 common / B4 conf const project = new CodegenService(null as never, null as never, null as never, null as never).assemble(graph, "nestjs"); const byPath = new Map(project.files.map((f) => [f.path, f])); - // Gateway dosyasi users feature'inda + gercek @Controller (Injectable NOT). + // The gateway file sits in the users feature + is a real @Controller (NOT Injectable). const gw = byPath.get("src/users/public.gateway.ts"); expect(gw).toBeDefined(); expect(gw!.content).toContain("@Controller()"); expect(gw!.content).not.toContain("@Injectable()"); - // Service enjekte eder (Controller degil -> anti-pattern yok). + // It injects the Service (not the Controller -> no anti-pattern). expect(gw!.content).toContain("private readonly usersService: UsersService,"); expect(gw!.content).not.toContain("UsersController"); - // users.module controllers'ina gateway GIRER (orphan KALMAZ). + // The gateway ENTERS users.module's controllers (it does NOT stay orphaned). const mod = byPath.get("src/users/users.module.ts")!.content; expect(mod).toContain("controllers: [UsersController, PublicApiGateway],"); }); - it("B3: common/'a dusen MessageQueue+EventHandler+Cache -> CommonModule sentezlenir + AppModule import", () => { - // Hicbir feature'a baglanamayan altyapi -> common. CommonModule onlari toplar, - // BullModule.registerQueue + CacheModule.register() wiring'i yapar; AppModule - // import eder -> hicbiri orphan kalmaz. + it("B3: MessageQueue+EventHandler+Cache landing in common/ -> CommonModule synthesized + imported by AppModule", () => { + // Infrastructure that cannot attach to any feature -> common. CommonModule + // collects it, does the BullModule.registerQueue + CacheModule.register() + // wiring; AppModule imports it -> nothing stays orphaned. const queue = node("MessageQueue", { QueueName: "EventsQueue", Description: "x", Type: "Queue", Provider: "RabbitMQ", MessageFormat: "EventDto" }); const handler = node("EventHandler", { HandlerName: "EventsHandler", Description: "x", EventName: "evt", IsAsync: true, QueueRef: "EventsQueue" }); const cache = node("Cache", { CacheName: "SharedCache", Description: "x", KeyPattern: "k:{id}", TTL_Seconds: 60, Engine: "Redis" }); @@ -908,16 +911,16 @@ describe("CodegenService — orphan-prevention (B2 gateway / B3 common / B4 conf const project = new CodegenService(null as never, null as never, null as never, null as never).assemble(graph, "nestjs"); const byPath = new Map(project.files.map((f) => [f.path, f])); - // CommonModule sentezlendi. + // CommonModule was synthesized. const common = byPath.get("src/common/common.module.ts"); expect(common).toBeDefined(); - // BullModule.registerQueue GERCEKTEN cagrilir (sessiz basarisizlik NOT). + // BullModule.registerQueue is ACTUALLY called (no silent failure). expect(common!.content).toContain("BullModule.registerQueue({ name: EVENTS_QUEUE })"); expect(common!.content).toContain("CacheModule.register()"); - // Tum common altyapi provider'lari @Module.providers'a girer (orphan degil). + // All common infra providers enter @Module.providers (not orphaned). expect(common!.content).toContain("providers: [EventsHandler, EventsQueue, SharedCache]"); - // AppModule CommonModule'u import eder. + // AppModule imports CommonModule. const app = byPath.get("src/app.module.ts")!.content; expect(app).toContain('import { CommonModule } from "./common/common.module";'); expect(app).toContain("CommonModule,"); @@ -929,7 +932,7 @@ describe("CodegenService — orphan-prevention (B2 gateway / B3 common / B4 conf const project = new CodegenService(null as never, null as never, null as never, null as never).assemble(graph, "nestjs"); const byPath = new Map(project.files.map((f) => [f.path, f])); - // H3: root forRoot CoreModule'de; app.module ince (yalniz CoreModule + feature). + // H3: root forRoot lives in CoreModule; app.module stays thin (only CoreModule + features). const app = byPath.get("src/app.module.ts")!.content; expect(app).toContain(" CoreModule,"); expect(app).not.toContain("ConfigModule.forRoot"); @@ -939,13 +942,13 @@ describe("CodegenService — orphan-prevention (B2 gateway / B3 common / B4 conf expect(core).toContain("TypeOrmModule.forRootAsync({"); expect(core).toContain('config.getOrThrow("DATABASE_URL")'); - // env.validation.ts (Joi) DAIMA uretilir; DATABASE_URL zorunlu (fail-fast). + // env.validation.ts (Joi) is ALWAYS generated; DATABASE_URL is required (fail-fast). const v = byPath.get("src/config/env.validation.ts"); expect(v).toBeDefined(); expect(v!.content).toContain('import Joi from "joi";'); expect(v!.content).toContain("DATABASE_URL: Joi.string().required(),"); - // package.json @nestjs/config + joi icerir. + // package.json includes @nestjs/config + joi. const pkg = byPath.get("package.json")!.content; expect(pkg).toContain('"@nestjs/config"'); expect(pkg).toContain('"joi"'); @@ -962,7 +965,7 @@ describe("CodegenService — orphan-prevention (B2 gateway / B3 common / B4 conf }); }); -describe("applySurgicalFills — sakli govdeyi NOT_IMPLEMENTED yerine enjekte", () => { +describe("applySurgicalFills — injects the stored body in place of NOT_IMPLEMENTED", () => { const SKELETON: GeneratedFile = { path: "src/users/users.service.ts", language: "typescript", @@ -980,25 +983,25 @@ export class UsersService { `, }; - it("sakli govde varsa throw'u govde + @solarch:filled ile degistirir, marker'i korur, girintiyi tutar", () => { + it("with a stored body, replaces the throw with the body + @solarch:filled, keeps the marker, preserves indentation", () => { const [out] = applySurgicalFills( [SKELETON], [{ nodeId: "n1", member: "getById", body: "const u = await this.repo.findById(id);\nif (!u) throw new NotFoundException();\nreturn u;", filledAt: "2026-06-17T00:00:00Z" }], ); - expect(out.content).toContain("// @solarch:surgical id=n1#getById"); // marker korundu - expect(out.content).toContain("// throws: NotFoundException"); // bilgi yorumu korundu + expect(out.content).toContain("// @solarch:surgical id=n1#getById"); // marker preserved + expect(out.content).toContain("// throws: NotFoundException"); // info comment preserved expect(out.content).toContain("// @solarch:filled by=ai at=2026-06-17T00:00:00Z"); - expect(out.content).toContain(" const u = await this.repo.findById(id);"); // 4-bosluk girinti - expect(out.content).not.toContain("NOT_IMPLEMENTED"); // iskelet throw'u gitti + expect(out.content).toContain(" const u = await this.repo.findById(id);"); // 4-space indent + expect(out.content).not.toContain("NOT_IMPLEMENTED"); // the skeleton throw is gone }); - it("sakli govdesi olmayan bolge iskelet kalir (re-fill secsin) — referans degismez", () => { + it("a region with no stored body stays skeletal (for re-fill to pick up) — reference unchanged", () => { const [out] = applySurgicalFills([SKELETON], [{ nodeId: "other", member: "x", body: "return 1;", filledAt: "t" }]); expect(out.content).toContain("NOT_IMPLEMENTED"); expect(out).toBe(SKELETON); }); - it("surgical icermeyen dosyaya dokunmaz", () => { + it("does not touch a file without surgical markers", () => { const plain: GeneratedFile = { path: "x.ts", language: "typescript", surgicalMarkers: 0, content: "export const x = 1;\n" }; const [out] = applySurgicalFills([plain], [{ nodeId: "n1", member: "getById", body: "return 1;", filledAt: "t" }]); expect(out).toBe(plain); diff --git a/apps/server/src/codegen/codegen.service.ts b/apps/server/src/codegen/codegen.service.ts index c7aa1e5..3e47098 100644 --- a/apps/server/src/codegen/codegen.service.ts +++ b/apps/server/src/codegen/codegen.service.ts @@ -32,17 +32,17 @@ import type { /* ──────────────────────────────────────────────────────────────────────── * codegen.service.ts — orchestrator. * - * Akis: - * 1) Proje var mi? (yoksa 404 ERR_PROJECT_NOT_FOUND) - * 2) Tum node + edge'leri tek projeye gore cek. - * 3) buildCodeGraph -> cozumlenmis CodeGraph + EmitterContext. - * 4) Her node icin REGISTRY'den emitter calistir: - * - kayitli + supported -> dosya(lar) uret. - * - kayitli + !supported -> stub uret + skippedKinds++. - * - kayitsiz -> emitter yok -> skippedKinds++ (sessizce dusmez). - * 5) Scaffold dosyalarini ekle. - * 6) Determinizm: dosyalari path'e gore sirala, cift path'leri (ilk-kazanir) - * tekillestir, summary doldur. + * Flow: + * 1) Does the project exist? (404 ERR_PROJECT_NOT_FOUND if not) + * 2) Fetch all nodes + edges for the one project. + * 3) buildCodeGraph -> resolved CodeGraph + EmitterContext. + * 4) Run the REGISTRY emitter for each node: + * - registered + supported -> emit file(s). + * - registered + !supported -> emit a stub + skippedKinds++. + * - unregistered -> no emitter -> skippedKinds++ (never dropped silently). + * 5) Add the scaffold files. + * 6) Determinism: sort files by path, dedupe duplicate paths (first wins), + * fill in the summary. * ──────────────────────────────────────────────────────────────────────── */ @Injectable() @@ -74,9 +74,9 @@ export class CodegenService { this.surgicalFills.getAllForProject(projectId), ]); - // GUVENLIK (defense-in-depth): nodes.list (repository) redaksiyon YAPMAZ. - // Secret'i IR'a hic sokmamak icin codegen sinirinda redakte et — boylece - // koruma yapisaldir, her emitter'in IsSecret kontrolune bagli degildir. + // SECURITY (defense-in-depth): nodes.list (the repository) does NOT redact. + // Redact at the codegen boundary so secrets never enter the IR — the protection + // is structural instead of depending on every emitter's IsSecret check. const redactedNodes = storedNodes.map((n) => ({ ...n, properties: redactNodeSecrets(n.type, n.properties), @@ -84,21 +84,21 @@ export class CodegenService { const graph = buildCodeGraph(redactedNodes, storedEdges); const project = this.assemble(graph, target); - // Sakli algoritma govdelerini (bolge-bazinda) iskeletteki NOT_IMPLEMENTED yerine - // geri-enjekte et → re-open/regenerate dolu surumu gosterir, re-fill kaldigi yerden. + // Re-inject the stored algorithm bodies (per region) over the skeleton's NOT_IMPLEMENTED + // throws → re-open/regenerate shows the filled version, and re-fill resumes where it left off. if (fills.length > 0) project.files = applySurgicalFills(project.files, fills); return project; } - /** Basit Gorunum (non-dev) projeksiyonu — teknik graf → feature haritasi + - * capability'ler. READ-ONLY, deterministik (Mermaid export'un kardesi); kod - * URETMEZ, AI yok → ucretsiz. Frontend src/features/simple bunu render eder. */ + /** Simple View (non-dev) projection — technical graph → feature map + + * capabilities. READ-ONLY and deterministic (a sibling of the Mermaid export); + * generates NO code, uses no AI → free. The frontend renders it in src/features/simple. */ async simpleView(projectId: string): Promise { if (!(await this.projects.exists(projectId))) { throw new NotFoundException({ code: "ERR_PROJECT_NOT_FOUND", message: `Project '${projectId}' not found.` }); } const [storedNodes, storedEdges] = await Promise.all([this.nodes.list(projectId), this.edges.list(projectId)]); - // Secret degerleri IR'a hic sokma (defense-in-depth; projeksiyon zaten yapi gosterir). + // Never let secret values enter the IR (defense-in-depth; the projection only shows structure anyway). const redactedNodes = storedNodes.map((n) => ({ ...n, properties: redactNodeSecrets(n.type, n.properties) })); return projectSimpleView(buildCodeGraph(redactedNodes, storedEdges)); } @@ -140,7 +140,7 @@ export class CodegenService { } /** Structured Simple-View model (Mermaid-free; ELK-laid-out + rough-rendered on the client). - * A tool-calling agent (DeepSeek v4-flash) refines the PRESENTATION of a deterministic + * A tool-calling agent (the configured provider) refines the PRESENTATION of a deterministic * baseline — friendly names + semantic colors — by calling rename/colorize tools that can * only touch real ids (structure + kind stay graph-true). Cached per project (the agent runs * only when the graph changes); falls back to the deterministic model when AI is off. @@ -233,27 +233,27 @@ export class CodegenService { return { doc: baseline, source: "deterministic", aiConfigured }; } - /** Saf montaj — DB'siz test edilebilir (in-memory CodeGraph al, proje uret). */ + /** Pure assembly — testable without a DB (takes an in-memory CodeGraph, produces a project). */ assemble(graph: CodeGraph, target: CodegenTarget = "nestjs"): GeneratedProject { const ctx: EmitterContext = { graph, target }; const skippedKinds: SkippedKinds = {}; const collected: GeneratedFile[] = []; - // graph.nodes zaten isme gore sirali -> emit sirasi deterministik. - // Node emitter'lar feature dosyalarini proje KOKUNE goreli uretir - // (or. "auth/auth.service.ts"); montaj burada TypeScript feature - // dosyalarina "src/" onekini ekler ki scaffold (src/main.ts, src/app.module.ts) - // + tsconfig "include": ["src/**/*"] ile TEK agac altinda toplansin. SQL - // migration'lari ("migrations/...") KOKTE kalir (derlenmez; siraya tabidir). + // graph.nodes is already sorted by name -> the emit order is deterministic. + // Node emitters produce feature files relative to the project ROOT + // (e.g. "auth/auth.service.ts"); assembly prefixes TypeScript feature files + // with "src/" here so they land in ONE tree together with the scaffold + // (src/main.ts, src/app.module.ts) + tsconfig "include": ["src/**/*"]. SQL + // migrations ("migrations/...") stay at the ROOT (not compiled; order matters). for (const node of graph.nodes) { - // Kapsam-disi (FrontendApp/UIComponent/View): bir backend'de frontend - // bilesenin yeri yok -> DOSYA URETME, yalniz skippedKinds'e say. + // Out of scope (FrontendApp/UIComponent/View): a frontend component has no + // place in a backend -> emit NO file, only count it in skippedKinds. if (graph.isExcluded(node)) { bump(skippedKinds, node.kindOf()); continue; } - // Module node: per-node NOT, FEATURE basina sentezlenir (asagida). - // Feature SEED'i olarak ir.ts'te kullanilir; ayri dosya uretmez. + // Module node: synthesized per FEATURE (below), not per node. + // ir.ts uses it as the feature SEED; it emits no file of its own. if (node.kindOf() === "Module") continue; const entry = EMITTER_REGISTRY[node.kindOf()]; @@ -261,74 +261,77 @@ export class CodegenService { bump(skippedKinds, node.kindOf()); continue; } - // FAULT-ISOLATION (M5): adi WITHOUT node bozuktur (name-property string degil - // ya da bos — ir.toCodeNode "" verir). Gecerli/sema-dogrulanmis her node'un - // adi zorunludur; bos ad = bozuk girdi. Gecerli bir sinif/dosya adi turetilemez - // -> dosya URETME, skippedKinds'e say, siradakine gec (sessizce DUSME). + // FAULT-ISOLATION (M5): a node with no name is broken (the name property is + // not a string, or is empty — ir.toCodeNode yields ""). Every valid, + // schema-validated node must have a name; an empty name means broken input. + // No valid class/file name can be derived -> emit NO file, count it in + // skippedKinds, and move on (never drop it silently). if (node.name.trim().length === 0) { bump(skippedKinds, node.kindOf()); continue; } if (!entry.supported) bump(skippedKinds, node.kindOf()); - // FAULT-ISOLATION (M5): tek bozuk node (or. emitter undefined alana patlar) - // TUM codegen'i dusurmemeli. Bu node'un emit'ini izole et — patlarsa onu - // skippedKinds'e say ve siradakine gec; geri kalan graph uretilmeye devam - // eder. Hatayi yutmak NOT, tek node'u atlamak: deterministik + saf kalir - // (girdi sabitse hangi node'un patladigi da sabittir). + // FAULT-ISOLATION (M5): a single broken node (e.g. an emitter blowing up on an + // undefined field) must not take down ALL of codegen. Isolate this node's emit — + // if it throws, count it in skippedKinds and move on; the rest of the graph + // keeps generating. This is skipping one node, not swallowing the error: it stays + // deterministic + pure (with a fixed input, which node throws is fixed too). let emitted: GeneratedFile[]; try { emitted = entry.emit(node, ctx); } catch (e) { if (process.env.SOLARCH_DEBUG_EMIT) console.error(`EMIT FAIL ${node.kindOf()} "${node.name}": ${(e as Error).message}\n${(e as Error).stack?.split("\n").slice(1, 4).join("\n")}`); - // Bozuk node -> dosya uretme, skippedKinds'e say (sessizce dusmez). - // supported=false zaten yukarida sayildi -> DOUBLE sayma. Yalniz - // supported (gercek emitter) patlarsa burada say. + // Broken node -> emit no file, count it in skippedKinds (never dropped silently). + // supported=false was already counted above -> don't double-count. Only count + // here when a supported (real) emitter throws. if (entry.supported) bump(skippedKinds, node.kindOf()); continue; } for (const f of emitted) { - // node-emitter ciktisi -> dosyayi URETEN node.id ile etiketle (nodeFiles). + // node-emitter output -> tag the file with the node.id that PRODUCED it (nodeFiles). const tagged = { ...f, nodeId: node.id }; collected.push(tagged.language === "typescript" ? { ...tagged, path: `src/${tagged.path}` } : tagged); } } - // ── ENTITY SENTEZI (Table-only graph BOOT garantisi) ──────────────────── - // Model'i WITHOUT ama bir Repository tarafindan referans edilen her Table - // icin TypeORM @Entity sinifi sentezlenir. Boylece @InjectRepository(Entity), - // Repository ve TypeOrmModule.forFeature([Entity]) AYNI sinifa baglanir - // -> NestJS DI bootta repository provider'ini cozebilir, uygulama ACILIR. + // ── ENTITY SYNTHESIS (Table-only graph BOOT guarantee) ────────────────── + // Synthesize a TypeORM @Entity class for every Table that has no Model but is + // referenced by a Repository. That way @InjectRepository(Entity), + // Repository and TypeOrmModule.forFeature([Entity]) bind to the SAME + // class -> NestJS DI can resolve the repository provider at boot, the app starts. for (const table of tablesNeedingSyntheticEntity(graph)) { for (const f of emitSyntheticEntity(table, ctx)) { collected.push({ ...f, path: `src/${f.path}` }); } } - // ── EXCEPTION SENTEZI (bildirilmis-ama-tanimsiz Throws DERLEME garantisi) ── - // Bir Service metodu Throws=[X] bildirir ama X icin Exception node'u yoksa, - // service.emitter X'i surgical marker'a yazar + sentetik dosyadan import eder; - // fill kontrati (checkContract) X'i firlatmaya zorlar. X'in sinifi burada - // uretilmezse `throw new X` import'suz/tanimsiz kalir → TS2304. Sentezle. + // ── EXCEPTION SYNTHESIS (COMPILE guarantee for declared-but-undefined Throws) ── + // When a Service method declares Throws=[X] but there is no Exception node for X, + // service.emitter writes X into the surgical marker + imports it from the synthetic + // file; the fill contract (checkContract) forces the fill to throw X. If X's class + // is not generated here, `throw new X` is left unimported/undefined → TS2304. Synthesize it. for (const exName of undefinedThrownExceptions(graph)) { const f = emitSyntheticException(exName); collected.push({ ...f, path: `src/${f.path}` }); } - // ── FEATURE-MODULE SENTEZI (mimari-farkindalik) ───────────────────────── - // Graph'ta Module node OLMASA bile her cikarilmis feature icin bir - // .module.ts uretilir; app.module bunlari import eder -> DI tam, - // repository'ler kayitli, uygulama BOOT BOOTS. features() zaten slug'a sirali. + // ── FEATURE-MODULE SYNTHESIS (architecture-awareness) ─────────────────── + // Even when the graph has NO Module node, a .module.ts is generated + // for every inferred feature; app.module imports them -> DI is complete, + // repositories are registered, the application boots. features() is already + // sorted by slug. for (const feature of graph.features()) { for (const f of emitFeatureModule(feature, ctx)) { collected.push({ ...f, path: `src/${f.path}` }); } } - // ── COMMON-MODULE SENTEZI (feature-bagsiz altyapi) ────────────────────── - // "common/"a dusen feature-bagsiz altyapi (MessageQueue/EventHandler/Cache/ - // ... ve paylasimli @Controller/APIGateway'ler) bir feature module almaz -> - // BullModule.registerQueue HIC cagrilmaz, provider orphan kalirdi. CommonModule - // bunlari toplar + wiring'ini yapar; AppModule import eder (buildAppModule). + // ── COMMON-MODULE SYNTHESIS (feature-less infrastructure) ─────────────── + // Feature-less infrastructure that lands in "common/" (MessageQueue/EventHandler/ + // Cache/... and shared @Controller/APIGateways) gets no feature module -> + // BullModule.registerQueue would NEVER be called and the providers would stay + // orphaned. CommonModule collects them + does their wiring; AppModule imports + // it (buildAppModule). const commonFeature = graph.commonFeature(); if (commonFeature) { for (const f of emitFeatureModule(commonFeature, ctx)) { @@ -336,52 +339,52 @@ export class CodegenService { } } - // ── SERVICE TEST ISKELETI SENTEZI (H6) ────────────────────────────────── - // Her Service icin yaninda bir .service.spec.ts iskeleti (Test.create - // TestingModule + DI mock'lari). Feature TS dosyalari gibi "src/" altina alinir. + // ── SERVICE TEST SKELETON SYNTHESIS (H6) ──────────────────────────────── + // A .service.spec.ts skeleton next to every Service (Test.create + // TestingModule + DI mocks). Placed under "src/" like the feature TS files. for (const f of emitServiceSpecs(ctx)) { collected.push({ ...f, path: `src/${f.path}` }); } - // Scaffold (node'dan bagimsiz) proje-genel dosyalar (graph-farkinda). - // Bunlar zaten dogru kokte ("src/" TS dosyalari + kok package.json/tsconfig/...). + // Scaffold: project-wide files independent of any node (but graph-aware). + // These already sit at the right root ("src/" TS files + root package.json/tsconfig/...). collected.push(...emitScaffoldProject(ctx)); - // ── MIGRATION RUNNER SENTEZI (H5) ──────────────────────────────────────── - // table/view emitter'lari okunakli `migrations/NNN_create_.sql` uretir ama - // bunlar TypeORM CLI'ca calistirilamaz. Toplanan SQL'leri CALISTIRILABILIR - // `src/migrations/NNN-.ts` (MigrationInterface) siniflarina cevir; - // data-source.ts glob'u bunlara bakar -> `npm run db:migrate` semayi uygular. - // SQL dosyalari NNN'e gore sirali verilir (path sirasi deterministik). + // ── MIGRATION RUNNER SYNTHESIS (H5) ───────────────────────────────────── + // table/view emitters produce readable `migrations/NNN_create_.sql`, but the + // TypeORM CLI cannot run those. Convert the collected SQL into RUNNABLE + // `src/migrations/NNN-.ts` (MigrationInterface) classes; the data-source.ts + // glob picks them up -> `npm run db:migrate` applies the schema. + // The SQL files are passed sorted by NNN (path order is deterministic). const sqlMigrations = collected .filter((f) => f.language === "sql" && /^migrations\/\d+_create_.+\.sql$/.test(f.path)) .map((f) => ({ path: f.path, content: f.content })) .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); collected.push(...emitMigrationRunners(sqlMigrations)); - // ── SURGICAL PLAN SENTEZI (SURGICAL_PLAN.md) ──────────────────────────── - // TUM dosyalar (scaffold + feature + migration dahil) deduped+sorted hâle - // geldikten AFTER uretilir ki plan, uretilen her .ts dosyasindaki - // "@solarch:surgical" marker'larini GOREBILSIN. Sonra dosyaya eklenir ve - // liste yeniden dedupe/sort edilir (SURGICAL_PLAN.md dogru yere otursun). - // ONEMLI: MD'nin KENDI marker'i NONETUR (plan METNIdir, surgicalMarkers:0) - // -> surgicalMarkerCount mantigi bozulmaz. + // ── SURGICAL PLAN SYNTHESIS (SURGICAL_PLAN.md) ────────────────────────── + // Generated AFTER all files (scaffold + feature + migration included) have been + // deduped+sorted, so the plan can SEE the "@solarch:surgical" markers in every + // generated .ts file. It is then appended and the list is deduped/sorted again + // (so SURGICAL_PLAN.md lands in the right place). + // IMPORTANT: the MD carries NO marker of its own (the plan is TEXT, + // surgicalMarkers:0) -> the surgicalMarkerCount logic stays intact. const assembled = dedupeAndSort(collected); const surgicalPlan = emitSurgicalPlan(assembled, graph); const files = dedupeAndSort([...assembled, surgicalPlan]); const surgicalMarkerCount = files.reduce((sum, f) => sum + f.surgicalMarkers, 0); - // node.id -> uretilen dosya yollari. NIHAI (deduped+sorted) dosyalardan kurulur - // -> path'ler montaj sonrasi gercek yollardir. Yalniz nodeId tasiyan dosyalar. + // node.id -> generated file paths. Built from the FINAL (deduped+sorted) files + // -> the paths are the real post-assembly paths. Only files carrying a nodeId. const nodeFiles = buildNodeFiles(files); return { target, files, nodeFiles, - // M4: graph'ta tespit edilen yapisal uyarilar (kirilan dongusel module - // import'lari vb.) + diyagram-ani kontrat denetimi (contract-lint: govde-alan - // write endpoint'i input DTO'su olmadan vb.) ciktiya tasinir — aksi halde - // sessizce kaybolurdu. Ikisi de deterministik + sirali. + // M4: structural warnings detected on the graph (broken circular module + // imports etc.) + the diagram-time contract lint (contract-lint: body-carrying + // write endpoint without an input DTO etc.) are carried into the output — + // otherwise they would be lost silently. Both are deterministic + sorted. warnings: [...graph.warnings(), ...lintContracts(graph)], summary: { version: CODEGEN_VERSION, @@ -397,12 +400,12 @@ export class CodegenService { const SURGICAL_MARKER_RE = /\/\/\s*@solarch:surgical\s+id=([^\s#]+)#(\S+)/; const NOT_IMPLEMENTED_RE = /^(\s*)throw new Error\("NOT_IMPLEMENTED:/; -/** Sakli algoritma govdelerini iskeletteki NOT_IMPLEMENTED satirinin yerine enjekte eder - * (deterministik, string-bazli — ts-morph gerektirmez). Her surgical marker - * (`// @solarch:surgical id=nodeId#member`) icin sakli govde varsa: marker + bilgi - * yorumlarini korur, NOT_IMPLEMENTED throw satirini `// @solarch:filled` imzasi + govde - * (satir girintisi marker blogununkiyle eslenir) ile degistirir. Sakli govdesi olmayan - * bolgeler iskelet kalir → re-fill onlari secer (kaldigi yerden devam). */ +/** Injects stored algorithm bodies in place of the skeleton's NOT_IMPLEMENTED line + * (deterministic, string-based — no ts-morph needed). For each surgical marker + * (`// @solarch:surgical id=nodeId#member`) with a stored body: keeps the marker + + * info comments, and replaces the NOT_IMPLEMENTED throw line with a `// @solarch:filled` + * signature + the body (line indentation matched to the marker block's). Regions with + * no stored body stay skeletal → re-fill picks them up (resumes where it left off). */ export function applySurgicalFills(files: GeneratedFile[], fills: StoredFill[]): GeneratedFile[] { const byKey = new Map(); for (const f of fills) byKey.set(`${f.nodeId}#${f.member}`, f); @@ -419,17 +422,17 @@ export function applySurgicalFills(files: GeneratedFile[], fills: StoredFill[]): out.push(lines[i]!); continue; } - // marker satiri + onu izleyen yorum (// …) satirlarini koru. + // Keep the marker line + the comment (// …) lines that follow it. out.push(lines[i]!); let j = i + 1; while (j < lines.length && /^\s*\/\//.test(lines[j]!)) { out.push(lines[j]!); j++; } - // Simdi lines[j] NOT_IMPLEMENTED throw olmali; degilse (zaten dolu) dokunma. + // lines[j] should now be the NOT_IMPLEMENTED throw; if not (already filled), leave it alone. const thr = NOT_IMPLEMENTED_RE.exec(lines[j] ?? ""); if (!thr) { - i = j - 1; // yorum satirlarini ciktiladik; dongu j'den devam etsin + i = j - 1; // comment lines already emitted; let the loop resume from j continue; } const indent = thr[1] ?? ""; @@ -437,14 +440,15 @@ export function applySurgicalFills(files: GeneratedFile[], fills: StoredFill[]): out.push(""); for (const bl of fill.body.split("\n")) out.push(bl.length > 0 ? `${indent}${bl}` : ""); changed = true; - i = j; // throw satirini atla + i = j; // skip the throw line } return changed ? { ...file, content: out.join("\n") } : file; }); } -/** Nihai dosyalardan node.id -> path[] haritasi kurar. Yalniz nodeId tasiyan - * (node-emitter) dosyalar dahil; anahtarlar + her node'un path listesi sirali. */ +/** Builds the node.id -> path[] map from the final files. Only files carrying a + * nodeId (node-emitter output) are included; the keys and each node's path list + * are sorted. */ function buildNodeFiles(files: GeneratedFile[]): Record { const map = new Map(); for (const f of files) { @@ -464,21 +468,21 @@ function bump(rec: SkippedKinds, key: string): void { rec[key] = (rec[key] ?? 0) + 1; } -/** Path'e gore sirala + cift path'leri tekillestir (ilk-kazanir). */ +/** Sorts by path + dedupes duplicate paths (first wins). */ function dedupeAndSort(files: GeneratedFile[]): GeneratedFile[] { const byPath = new Map(); for (const f of files) if (!byPath.has(f.path)) byPath.set(f.path, f); return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); } -/** Record'u key'e gore sirali yeniden kurar (deterministik JSON ciktisi). */ +/** Rebuilds a record with sorted keys (deterministic JSON output). */ function sortRecord(rec: SkippedKinds): SkippedKinds { const out: SkippedKinds = {}; for (const k of Object.keys(rec).sort()) out[k] = rec[k]; return out; } -// CodeNode tipini disa-bagli tuketicilere kapatma — sadece tip referansi. +// Re-export the CodeNode type for downstream consumers — type-only reference. export type { CodeNode }; /** FNV-1a → short stable key (cache invalidation on graph change). */ @@ -504,12 +508,12 @@ const SKETCH_EXAMPLE = [ " orders -->|Needs| payments", ].join("\n"); -/** Refine a valid deterministic Mermaid into a friendlier, RICHER non-dev one (DeepSeek - * v4-pro). Research-shaped prompt: a full worked example first, then a small fixed shape - * set + labeled arrows, so the output reads like a real data-flow story (not a bare list) - * while staying truthful to the input. */ +/** Refine a valid deterministic Mermaid into a friendlier, RICHER non-dev one (the + * configured provider's agent-tier model). Research-shaped prompt: a full worked example + * first, then a small fixed shape set + labeled arrows, so the output reads like a real + * data-flow story (not a bare list) while staying truthful to the input. */ async function aiRefineMermaid(baseline: string): Promise { - const llm = getGenerationChat({ model: "deepseek-v4-pro" }); + const llm = getGenerationChat({ tier: "agent" }); const sys = "You turn a software system into a friendly Mermaid flowchart for NON-DEVELOPERS. " + "It must read like a real data-flow story a person can follow, not a bare list. " + @@ -542,7 +546,7 @@ async function aiRefineMermaid(baseline: string): Promise { /** SURGICAL update: minimally edit an existing friendly Mermaid to match a new baseline * (add/remove only the changed parts; keep everything else identical → stable layout). */ async function aiPatchMermaid(previous: string, newBaseline: string): Promise { - const llm = getGenerationChat({ model: "deepseek-v4-pro" }); + const llm = getGenerationChat({ tier: "agent" }); const sys = "You SURGICALLY update an existing friendly Mermaid flowchart to match a new structure, " + "changing as LITTLE as possible. Keep EVERY existing node id, label, shape and order " + @@ -577,7 +581,7 @@ const RenameSketchArgs = z.object({ * allowed to color: in practice it washes EVERY group into one hue, which destroys the grouping. * Rename only touches existing ids; structure, kind, color, groups and connections stay graph-true. */ async function aiEnrichSketchModel(model: SimpleSketchModel): Promise { - const llm = getGenerationChat({ toolCalling: true }); // default model = deepseek-v4-flash (tool calling OK) + const llm = getGenerationChat({ toolCalling: true, tier: "instruct" }); // fast tool-calling tier const withTools = llm.bindTools!([ { name: "rename", description: "Rename a part to a short, plain, non-developer name (e.g. 'MessageController' → 'Messages'). Only ids that exist. Returns { ok }.", schema: RenameSketchArgs }, ]); @@ -741,7 +745,7 @@ export function applyDescribeField( * goes through a grounded apply-helper). Structure stays graph-true; the doc is cloned so the * deterministic baseline is never mutated. */ async function aiDocumentizeOpenApi(doc: OpenAPIObject): Promise { - const llm = getGenerationChat({ toolCalling: true }); // default model = deepseek-v4-flash (tool calling OK) + const llm = getGenerationChat({ toolCalling: true, tier: "instruct" }); // fast tool-calling tier const withTools = llm.bindTools!([ { name: "describeApi", description: "Set a Markdown OVERVIEW of the whole API (info.description) — the documentation landing. Call once. Returns { ok }.", schema: DescribeApiArgs }, { name: "describeOperation", description: "Set a summary and/or a rich Markdown description on an EXISTING operation (by operationId). Only listed operationIds. Returns { ok }.", schema: DescribeOperationArgs }, diff --git a/apps/server/src/codegen/contract-lint.spec.ts b/apps/server/src/codegen/contract-lint.spec.ts index e47da10..9140622 100644 --- a/apps/server/src/codegen/contract-lint.spec.ts +++ b/apps/server/src/codegen/contract-lint.spec.ts @@ -3,7 +3,7 @@ import { lintContracts } from "./contract-lint"; import { buildCodeGraph } from "./ir"; import type { StoredNode } from "../nodes/nodes.repository"; -/* ── Fixture yardimcisi ─────────────────────────────────────────────────── */ +/* ── Fixture helper ─────────────────────────────────────────────────────── */ function node(type: StoredNode["type"], id: string, properties: Record): StoredNode { return { id, @@ -34,7 +34,7 @@ const ep = (over: Record) => ({ const CTRL = "c0000000-0000-4000-8000-000000000001"; describe("lintContracts", () => { - it("RequestDTORef'siz write endpoint (POST) -> uyari (controller + metot + route)", () => { + it("write endpoint (POST) without RequestDTORef -> warning (controller + method + route)", () => { const ctrl = node("Controller", CTRL, { ControllerName: "CategoryController", Description: "kategori", @@ -48,13 +48,13 @@ describe("lintContracts", () => { expect(warnings[0]).toMatch(/RequestDTORef|request body|input DTO/i); }); - it("PUT ve PATCH de govde-alan write -> RequestDTORef'siz uyari", () => { + it("PUT and PATCH are body-carrying writes too -> warning without RequestDTORef", () => { const ctrl = node("Controller", CTRL, { ControllerName: "OrderController", Description: "order", BaseRoute: "orders", Endpoints: [ - // PathParam verilir -> route-param kurali tetiklenmez; yalniz RequestDTORef'siz body kurali. + // A PathParam is provided -> the route-param rule doesn't fire; only the missing-RequestDTORef body rule. ep({ HttpMethod: "PUT", Route: ":id", PathParams: [{ Name: "id", Type: "string" }] }), ep({ HttpMethod: "PATCH", Route: ":id/status", PathParams: [{ Name: "id", Type: "string" }] }), ], @@ -62,10 +62,10 @@ describe("lintContracts", () => { expect(lintContracts(buildCodeGraph([ctrl], []))).toHaveLength(2); }); - it("RequestDTORef OLAN write -> uyari yok; GET/DELETE (govdesiz) -> uyari yok", () => { + it("write WITH a RequestDTORef -> no warning; GET/DELETE (body-less) -> no warning", () => { const ctrl = node("Controller", CTRL, { ControllerName: "ProductController", - Description: "urun", + Description: "product", BaseRoute: "products", Endpoints: [ ep({ HttpMethod: "POST", Route: "/", RequestDTORef: "CreateProductDto" }), @@ -73,18 +73,18 @@ describe("lintContracts", () => { ep({ HttpMethod: "DELETE", Route: ":id", PathParams: [{ Name: "id", Type: "string" }] }), ], }); - // CreateProductDto gercek bir DTO node'u (dangling-ref kurali tetiklenmesin). + // CreateProductDto is a real DTO node (so the dangling-ref rule doesn't fire). const dto = node("DTO", "db300000-0000-4000-8000-000000000001", { Name: "CreateProductDto", - Description: "urun girdisi", + Description: "product input", Fields: [{ Name: "name", DataType: "string", IsRequired: true, IsArray: false }], }); expect(lintContracts(buildCodeGraph([ctrl, dto], []))).toHaveLength(0); }); - it("RequiredRoles var ama RequiresAuth yok -> uyari (rol auth olmadan enforce edilemez)", () => { - // RolesGuard request.user.role'e bakar; AuthGuard yoksa request.user set edilmez -> - // RolesGuard her istegi reddeder -> endpoint erisilemez. Kontrat ihlali. + it("RequiredRoles present but RequiresAuth missing -> warning (roles cannot be enforced without auth)", () => { + // RolesGuard looks at request.user.role; without AuthGuard request.user is never set -> + // RolesGuard rejects every request -> the endpoint is unreachable. A contract violation. const ctrl = node("Controller", CTRL, { ControllerName: "AdminController", Description: "yonetim", @@ -97,7 +97,7 @@ describe("lintContracts", () => { expect(warnings.some((w) => /role/i.test(w) && /auth/i.test(w))).toBe(true); }); - it("RequiredRoles + RequiresAuth birlikte -> auth uyarisi NONE", () => { + it("RequiredRoles + RequiresAuth together -> no auth warning", () => { const ctrl = node("Controller", CTRL, { ControllerName: "AdminController", Description: "yonetim", @@ -110,8 +110,8 @@ describe("lintContracts", () => { expect(warnings.some((w) => /role/i.test(w) && /auth/i.test(w))).toBe(false); }); - it("route param'i eslesen PathParam'siz -> uyari (handler okuyamaz)", () => { - // GET /:id ama PathParams bos -> @Param("id") uretilmez, handler id'yi okuyamaz. + it("route param with no matching PathParam -> warning (the handler cannot read it)", () => { + // GET /:id but PathParams is empty -> no @Param("id") is generated, the handler cannot read the id. const dto = node("DTO", "da100000-0000-4000-8000-000000000001", { Name: "OrderDto", Description: "order", @@ -127,7 +127,7 @@ describe("lintContracts", () => { expect(warnings.some((w) => /route parameter/i.test(w) && /\bid\b/.test(w))).toBe(true); }); - it("PathParam route'la eslesince -> route-param uyarisi NONE", () => { + it("PathParam matching the route -> no route-param warning", () => { const dto = node("DTO", "da200000-0000-4000-8000-000000000001", { Name: "OrderDto", Description: "order", @@ -143,22 +143,22 @@ describe("lintContracts", () => { expect(warnings.some((w) => /route parameter/i.test(w))).toBe(false); }); - it("cozulemeyen DTO ref (Request/Response) -> uyari (var olmayan DTO)", () => { + it("unresolvable DTO ref (Request/Response) -> warning (nonexistent DTO)", () => { const ctrl = node("Controller", CTRL, { ControllerName: "ProductController", - Description: "urun", + Description: "product", BaseRoute: "products", Endpoints: [ ep({ HttpMethod: "POST", Route: "/", RequestDTORef: "GhostInput", ResponseDTORef: "GhostOutput" }), ], }); - // GhostInput/GhostOutput DTO node'u NONE -> dangling ref. + // There is no GhostInput/GhostOutput DTO node -> dangling refs. const warnings = lintContracts(buildCodeGraph([ctrl], [])); expect(warnings.some((w) => /GhostInput/.test(w) && /exist|resolve/i.test(w))).toBe(true); expect(warnings.some((w) => /GhostOutput/.test(w))).toBe(true); }); - it("Repository EntityReference cozulemezse -> uyari (Repository fallback)", () => { + it("unresolvable Repository EntityReference -> warning (Repository fallback)", () => { const repo = node("Repository", "dc100000-0000-4000-8000-000000000001", { RepositoryName: "GhostRepository", Description: "baglantisiz", @@ -170,7 +170,7 @@ describe("lintContracts", () => { expect(warnings.some((w) => /Phantom/.test(w) && /resolve|Model or Table|exist/i.test(w))).toBe(true); }); - it("Service Dependency Ref cozulemezse -> uyari (import'suz inject)", () => { + it("unresolvable Service Dependency Ref -> warning (injection without an import)", () => { const svc = node("Service", "dc200000-0000-4000-8000-000000000001", { ServiceName: "OrderService", Description: "order", @@ -182,10 +182,10 @@ describe("lintContracts", () => { expect(warnings.some((w) => /GhostRepo/.test(w) && /resolve|exist/i.test(w))).toBe(true); }); - it("Kural 6: DTO zorunlu alani NULLABLE kolondan besleniyor -> uyari; NOT NULL/optional -> uyari yok", () => { + it("Rule 6: required DTO field fed from a NULLABLE column -> warning; NOT NULL/optional -> no warning", () => { const table = node("Table", "dt100000-0000-4000-8000-000000000001", { TableName: "Videos", - Description: "videolar", + Description: "videos", Columns: [ { Name: "Id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }, { Name: "Title", DataType: "VARCHAR", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }, @@ -196,9 +196,9 @@ describe("lintContracts", () => { Name: "VideoDTO", Description: "video ciktisi", Fields: [ - { Name: "Title", DataType: "string", IsRequired: true, IsArray: false }, // NOT NULL → uyari yok - { Name: "VideoUrl", DataType: "string", IsRequired: true, IsArray: false }, // nullable kolon + zorunlu → UYARI - { Name: "Description", DataType: "string", IsRequired: false, IsArray: false }, // optional → uyari yok + { Name: "Title", DataType: "string", IsRequired: true, IsArray: false }, // NOT NULL → no warning + { Name: "VideoUrl", DataType: "string", IsRequired: true, IsArray: false }, // nullable column + required → WARNING + { Name: "Description", DataType: "string", IsRequired: false, IsArray: false }, // optional → no warning ], }); const warnings = lintContracts(buildCodeGraph([table, dto], [])); @@ -208,17 +208,17 @@ describe("lintContracts", () => { expect(nullWarn[0]).toContain("Videos.VideoUrl"); }); - it("Kural 6: entity-bagli olmayan DTO (eslesen tablo yok) -> nullability uyarisi yok", () => { + it("Rule 6: non-entity-bound DTO (no matching table) -> no nullability warning", () => { const dto = node("DTO", "dd200000-0000-4000-8000-000000000001", { Name: "LoginRequestDTO", - Description: "giris", + Description: "login", Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], }); const warnings = lintContracts(buildCodeGraph([dto], [])); expect(warnings.some((w) => /is required but its source column/.test(w))).toBe(false); }); - it("DETERMINISM: uyarilar sirali + tekrar uretimde ayni", () => { + it("DETERMINISM: warnings are sorted + identical when regenerated", () => { const ctrl = node("Controller", CTRL, { ControllerName: "MixController", Description: "karisik", diff --git a/apps/server/src/codegen/contract-lint.ts b/apps/server/src/codegen/contract-lint.ts index 4537616..4e1555b 100644 --- a/apps/server/src/codegen/contract-lint.ts +++ b/apps/server/src/codegen/contract-lint.ts @@ -1,42 +1,44 @@ import { propsOf, type CodeGraph } from "./ir"; /* ──────────────────────────────────────────────────────────────────────── - * contract-lint.ts — DIYAGRAM-ANI KONTRAT DENETIMI. + * contract-lint.ts — DIAGRAM-TIME CONTRACT LINTING. * - * Graf'in YAPISAL eksiklerini codegen uyarisina cevirir: uretim BASARILI olur ama - * kullaniciya bildirilir (GeneratedProject.warnings -> canvas bunlari isaretler). - * Felsefe: emitter graf ne diyorsa onu uretir; eksik bir kontrati emitter'da - * "uydurmak" yerine burada YUKSEK SESLE yakala (L1 Contract-Compiler'in cekirdegi). + * Turns the graph's STRUCTURAL gaps into codegen warnings: generation SUCCEEDS but + * the user is told (GeneratedProject.warnings -> the canvas flags them). + * Philosophy: emitters generate exactly what the graph says; instead of "inventing" + * a missing contract inside an emitter, catch it LOUDLY here (the core of the + * L1 Contract-Compiler). * - * Simdiki kural: - * - Govde-alan write endpoint'i (POST/PUT/PATCH) bir input DTO'su (RequestDTORef) - * WITHOUT -> @Body uretilemez, istek govdesi sessizce yok sayilir. (surgical-output - * bug'i: category POST / order PATCH'te @Body yoktu, AI placeholder uydurdu.) + * Current rule: + * - A body-carrying write endpoint (POST/PUT/PATCH) without an input DTO + * (RequestDTORef) -> no @Body can be generated, the request body is silently + * ignored. (surgical-output bug: category POST / order PATCH had no @Body, + * the AI invented a placeholder.) * - * SAF + DETERMINISTIC: yalniz graf okumasi, sirali cikti, yan etki yok. + * PURE + DETERMINISTIC: reads the graph only, sorted output, no side effects. * ──────────────────────────────────────────────────────────────────────── */ -/** Bir istek govdesi (body) bekleyebilen HTTP fiilleri. GET/DELETE govdesizdir. */ +/** HTTP verbs that may expect a request body. GET/DELETE are body-less. */ const WRITE_METHODS: ReadonlySet = new Set(["POST", "PUT", "PATCH"]); -/** Graf uzerinde kontrat denetimi kosar; bulunan ihlalleri sirali uyari listesi - * olarak dondurur (ihlal yoksa bos dizi). codegen.service.assemble bunu - * graph.warnings() ile birlestirir. */ +/** Runs the contract lint over the graph; returns the violations found as a sorted + * warning list (an empty array when there are none). codegen.service.assemble + * merges it with graph.warnings(). */ export function lintContracts(graph: CodeGraph): string[] { const warnings: string[] = []; for (const ctrl of graph.allOf("Controller")) { const props = propsOf<"Controller">(ctrl); for (const ep of props.Endpoints ?? []) { - // Kural 1: govde-alan write endpoint'i (POST/PUT/PATCH) input DTO'su olmadan. + // Rule 1: a body-carrying write endpoint (POST/PUT/PATCH) without an input DTO. if (WRITE_METHODS.has(ep.HttpMethod) && !ep.RequestDTORef) { warnings.push( `${ctrl.name}: ${ep.HttpMethod} ${ep.Route} has no request body DTO ` + `(RequestDTORef) — the request body is ignored. Connect an input DTO to this endpoint.`, ); } - // Kural 2: rol gerektiren ama auth gerektirmeyen endpoint. RolesGuard - // request.user.role'e bakar; AuthGuard (authentication) yoksa request.user - // set edilmez -> RolesGuard her istegi reddeder -> endpoint ERISILEMEZ. + // Rule 2: an endpoint that requires roles but not auth. RolesGuard looks at + // request.user.role; without AuthGuard (authentication) request.user is never + // set -> RolesGuard rejects every request -> the endpoint is UNREACHABLE. if ((ep.RequiredRoles?.length ?? 0) > 0 && !ep.RequiresAuth) { warnings.push( `${ctrl.name}: ${ep.HttpMethod} ${ep.Route} requires roles but not authentication ` + @@ -45,8 +47,9 @@ export function lintContracts(graph: CodeGraph): string[] { ); } - // Kural 3: route ":param"'i eslesen PathParam'siz. Emitter @Param("x")'i - // PathParams'tan uretir; route ":x" ama PathParam yoksa handler x'i OKUYAMAZ. + // Rule 3: a route ":param" with no matching PathParam. The emitter generates + // @Param("x") from PathParams; if the route says ":x" but there is no PathParam, + // the handler CANNOT read x. const declaredParams = new Set((ep.PathParams ?? []).map((p) => p.Name)); for (const rp of routeParamNames(ep.Route)) { if (!declaredParams.has(rp)) { @@ -57,8 +60,9 @@ export function lintContracts(graph: CodeGraph): string[] { } } - // Kural 4: DANGLING DTO ref — RequestDTORef/ResponseDTORef bir DTO node'una - // cozulmuyor -> emitter `unknown /* TODO */` uretir; baglanti eksik/yanlis. + // Rule 4: DANGLING DTO ref — RequestDTORef/ResponseDTORef does not resolve to + // a DTO node -> the emitter produces `unknown /* TODO */`; the connection is + // missing or wrong. const dtoRefs: ReadonlyArray = [ ["request", ep.RequestDTORef], ["response", ep.ResponseDTORef], @@ -74,8 +78,9 @@ export function lintContracts(graph: CodeGraph): string[] { } } - // Kural 5: DANGLING entity/dependency ref'leri (kopuk baglantilar). Emitter bunlari - // tolere eder (Repository / import'suz inject) ama graf baglantisi eksik/yanlis. + // Rule 5: DANGLING entity/dependency refs (broken connections). The emitters + // tolerate these (Repository / injection without an import), but the graph + // connection is missing or wrong. for (const repo of graph.allOf("Repository")) { const ref = propsOf<"Repository">(repo).EntityReference; if (ref && !graph.resolveRef(["Model", "Table"], ref)) { @@ -96,12 +101,14 @@ export function lintContracts(graph: CodeGraph): string[] { } } - // Kural 6: NULLABILITY uyumsuzlugu — bir DTO ZORUNLU alani (IsRequired), ayni-isimli entity - // tablosunda NULLABLE bir kolondan (IsNotNull=false) besleniyor. Codegen ikisini de sadik - // uretir (entity `x?: T`, dto `x: T`); fill nullable kaynagi zorunlu hedefe KOPRULEMEK zorunda - // (default/throw) yoksa TS2322. Surgical AI bunu artik kopruler, ama celiskiyi KAYNAGINDA - // (diyagramda) yakala. Eslestirme isim-bazli (VideoDTO → Videos tablosu) → yalniz aday tablo - // VE ayni-isimli kolon varken uyarir (dar, dusuk false-positive). Uyari bloklamaz. + // Rule 6: NULLABILITY mismatch — a REQUIRED DTO field (IsRequired) is fed from a + // NULLABLE column (IsNotNull=false) of the same-named entity table. Codegen emits + // both faithfully (entity `x?: T`, dto `x: T`); the fill then HAS to bridge the + // nullable source into the required target (default/throw) or hit TS2322. The + // surgical AI bridges it now, but catch the contradiction at its SOURCE (the + // diagram). Matching is name-based (VideoDTO → Videos table) → it only warns when + // a candidate table AND a same-named column exist (narrow, low false-positive). + // The warning does not block. for (const dto of graph.allOf("DTO")) { const entityName = dto.name.replace(/(DTO|Dto)$/, ""); if (entityName.length === 0) continue; @@ -124,10 +131,11 @@ export function lintContracts(graph: CodeGraph): string[] { return warnings.sort(); } -/** Bir entity ADI icin (VideoDTO'dan turetilmis "Video") eslesen Table node'unu bulur: - * dogrudan / tekil↔cogul (Video↔Videos, -ies/-y) eslesmesi, buyuk-kucuk harf duyarsiz. - * Aday yoksa null → DTO entity-bagli degil (request/aggregate DTO'su) → lint atlar. - * Donus tipi cikarimla (CodeNode | null) — ir.ts CodeNode'u export etmez. */ +/** Finds the Table node matching an entity NAME ("Video" derived from VideoDTO): + * direct / singular↔plural (Video↔Videos, -ies/-y) matching, case-insensitive. + * No candidate → null → the DTO is not entity-bound (a request/aggregate DTO) → + * the lint skips it. The return type is inferred (CodeNode | null) — ir.ts does + * not export CodeNode. */ function findEntityTable(graph: CodeGraph, entityName: string) { const en = entityName.toLowerCase(); const variants = new Set([en, en + "s", en + "es", en.replace(/y$/, "ies")]); @@ -139,7 +147,7 @@ function findEntityTable(graph: CodeGraph, entityName: string) { return null; } -/** Bir route'taki parametre adlari: ":id" / "{id}" segmentlerinden ad'lari cikarir. */ +/** Parameter names in a route: extracts the names from ":id" / "{id}" segments. */ function routeParamNames(route: string): string[] { return route .split("/") diff --git a/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.spec.ts index 17dd8c9..3682a69 100644 --- a/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/api-gateway.emitter.spec.ts @@ -53,18 +53,18 @@ function ctxFor(nodes: StoredNode[], edges: StoredEdge[] = []): { ctx: EmitterCo return { ctx: { graph, target: "nestjs" } }; } -/* ── id sabitleri ──────────────────────────────────────────────────────── */ +/* ── id constants ──────────────────────────────────────────────────────── */ const GW = "11111111-1111-4111-8111-111111111111"; const USERS_CTRL = "33333333-3333-4333-8333-333333333333"; const AUTH_SVC = "44444444-4444-4444-8444-444444444444"; -/* ── Gateway fixture'lari ──────────────────────────────────────────────── */ +/* ── Gateway fixtures ──────────────────────────────────────────────────── */ function gatewayNode(props: Partial> = {}, id = GW): StoredNode { return node( "APIGateway", { GatewayName: "PublicApiGateway", - Description: "Genel API girisi", + Description: "Public API entry", Provider: "Kong", Routes: [], ...props, @@ -82,33 +82,34 @@ function usersController(id = USERS_CTRL): StoredNode { } describe("emitApiGateway", () => { - it("rol-tekrarsiz .gateway.ts yolu (APIGateway eki duser, base = Public)", () => { - // B2: Gateway gercek bir @Controller -> Controller gibi KENDI feature'ini - // tohumlar (hedef cozulmese bile orphan kalmasin). "PublicApiGateway" en- - // spesifik "APIGateway" ekiyle eslesir -> base "Public" -> feature "public". + it(".gateway.ts path without role repetition (APIGateway suffix dropped, base = Public)", () => { + // B2: The gateway is a real @Controller -> like a Controller it seeds its OWN + // feature (so it never ends up orphaned, even if the target does not resolve). + // "PublicApiGateway" matches the most specific suffix "APIGateway" -> base + // "Public" -> feature "public". const gw = gatewayNode(); const { ctx } = ctxFor([gw]); const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); expect(file.path).toBe("public/public.gateway.ts"); }); - it("@Controller() sinifi (Injectable NOT) + Provider/Description doc-comment", () => { - // B2: Gateway artik GERCEK bir @Controller'dir (orphan @Injectable degil) -> - // feature module'un controllers'ina girer; NestJS routing'i otomatik baglar. + it("@Controller() class (not @Injectable) + Provider/Description doc-comment", () => { + // B2: The gateway is now a REAL @Controller (not an orphan @Injectable) -> + // it goes into the feature module's controllers; NestJS wires routing automatically. const gw = gatewayNode(); const { ctx } = ctxFor([gw]); const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); expect(file.content).toContain("@Controller()"); expect(file.content).not.toContain("@Injectable()"); expect(file.content).toContain("export class PublicApiGateway {"); - expect(file.content).toContain(" * Genel API girisi"); + expect(file.content).toContain(" * Public API entry"); expect(file.content).toContain(" * API Gateway (Provider: Kong)."); expect(file.content).toContain('import { Controller'); expect(file.content).toContain('from "@nestjs/common";'); }); - it("Routes[].TargetRef Service'e cozulur -> DI + goreli import + @Get + delegasyon ipucu", () => { - // B2: Gateway YALNIZ Service enjekte eder (Controller anti-pattern -> DI'a alinmaz). + it("Routes[].TargetRef resolves to a Service -> DI + relative import + @Get + delegation hint", () => { + // B2: The gateway ONLY injects Services (injecting a Controller is an anti-pattern -> excluded from DI). const gw = gatewayNode({ Routes: [ { @@ -120,38 +121,38 @@ describe("emitApiGateway", () => { ], }); const svc = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); - // CALLS edge'i ile gateway, service'in feature'ina (auth) duser. + // Via the CALLS edge the gateway falls into the service's feature (auth). const { ctx } = ctxFor([gw, svc], [edge("CALLS", GW, AUTH_SVC, "55555555-5555-4555-8555-555555555555")]); const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); - // DI: constructor'da AuthService (Service). + // DI: AuthService (Service) in the constructor. expect(file.content).toContain("private readonly authService: AuthService,"); - // goreli import (feature=auth; gateway de auth altinda). + // Relative import (feature=auth; the gateway also lives under auth). expect(file.content).toContain('import { AuthService } from "./auth.service";'); - // HTTP dekoratorlu route metodu + delegasyon ipucu marker'da. + // Route method with HTTP decorator + delegation hint in the marker. expect(file.content).toContain('@Get("users/:id")'); expect(file.content).toContain("async dispatchGetUsersById(): Promise {"); expect(file.content).toContain("// Delegation hint: this.authService.(...)."); }); - it("wildcard route (api/auth/*) -> GECERLI metot adi (`*` identifier'a sizmaz, TS1434/TS1003 onle)", () => { - // Gercek bug: route Path "/api/auth/*" -> metot adi "dispatchGetApiAuth*" uretiliyordu; - // `*` gecersiz identifier -> sozdizimi hatasi (derleme kirilir). Wildcard -> "All". + it("wildcard route (api/auth/*) -> VALID method name (`*` never leaks into the identifier; prevents TS1434/TS1003)", () => { + // Real bug: route Path "/api/auth/*" used to produce the method name "dispatchGetApiAuth*"; + // `*` is an invalid identifier -> syntax error (build breaks). Wildcard -> "All". const gw = gatewayNode({ Routes: [{ Path: "/api/auth/*", TargetRef: "AuthService", Methods: ["GET"], AuthRequired: false }], }); const svc = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); const { ctx } = ctxFor([gw, svc], [edge("CALLS", GW, AUTH_SVC, "55555555-5555-4555-8555-555555555555")]); const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); - // Metot adinda `*` (veya baska identifier-disi karakter) NONE. + // No `*` (or any other non-identifier character) in the method name. expect(file.content).not.toMatch(/async dispatch\w*[^A-Za-z0-9(]/); expect(file.content).toContain("async dispatchGetApiAuthAll(): Promise {"); - // Route argumani `*`'i KORUR (Express wildcard'i runtime'da gecerli). + // The route argument KEEPS the `*` (the Express wildcard is valid at runtime). expect(file.content).toContain('@Get("api/auth/*")'); }); - it("Controller hedefi DI'a ALINMAZ (anti-pattern); metot yine uretilir + marker'da not", () => { - // B2: Bir route Controller'a isaret ederse DI ile enjekte edilmez (controller'a - // HTTP ile gidilir). Constructor bostur; route metodu marker'da not tasir. + it("Controller target is NOT injected via DI (anti-pattern); method is still generated + note in marker", () => { + // B2: If a route points at a Controller it is not injected via DI (controllers + // are reached over HTTP). The constructor stays empty; the route method carries a note in its marker. const gw = gatewayNode({ Routes: [ { Path: "/users/:id", TargetRef: "UsersController", Methods: ["GET"], AuthRequired: false }, @@ -166,7 +167,7 @@ describe("emitApiGateway", () => { expect(file.content).toContain("async dispatchGetUsersById(): Promise {"); }); - it("her route bir surgical-marker'li @-dekoratorlu metot + NOT_IMPLEMENTED govdesi", () => { + it("every route gets a decorated method with a surgical marker + NOT_IMPLEMENTED body", () => { const gw = gatewayNode({ Routes: [ { Path: "/login", TargetRef: "AuthService", Methods: ["POST"], AuthRequired: false }, @@ -182,7 +183,7 @@ describe("emitApiGateway", () => { expect(file.content).toContain("private readonly authService: AuthService,"); }); - it("AuthRequired + RateLimit ipuclari marker aciklamasinda", () => { + it("AuthRequired + RateLimit hints appear in the marker description", () => { const gw = gatewayNode({ Routes: [ { @@ -201,7 +202,7 @@ describe("emitApiGateway", () => { expect(file.content).toContain("// Rate limit: 100 requests / 60s."); }); - it("kayip TargetRef -> THROW yok, DI yok, marker'da TODO ipucu", () => { + it("missing TargetRef -> no throw, no DI, TODO hint in the marker", () => { const gw = gatewayNode({ Routes: [ { Path: "/ghost", TargetRef: "GhostService", Methods: ["GET"], AuthRequired: false }, @@ -215,7 +216,7 @@ describe("emitApiGateway", () => { expect(file.content).toContain("async dispatchGetGhost(): Promise {"); }); - it("ayni dispatch adi -> deterministik tekillestirme (2, 3)", () => { + it("duplicate dispatch names -> deterministic deduplication (2, 3)", () => { const gw = gatewayNode({ Routes: [ { Path: "/users", TargetRef: "AuthService", Methods: ["GET"], AuthRequired: false }, @@ -229,7 +230,7 @@ describe("emitApiGateway", () => { expect(file.content).toContain("async dispatchGetUsers2(): Promise {"); }); - it("DI hedefleri SADECE Service + DEDUP + isme gore sirali (Routes ∪ ROUTES_TO ∪ CALLS)", () => { + it("DI targets are Services ONLY + deduplicated + sorted by name (Routes ∪ ROUTES_TO ∪ CALLS)", () => { const gw = gatewayNode({ Routes: [ { Path: "/a", TargetRef: "BillingService", Methods: ["GET"], AuthRequired: false }, @@ -238,24 +239,24 @@ describe("emitApiGateway", () => { }); const auth = node("Service", { ServiceName: "AuthService", Methods: [] }, AUTH_SVC); const billing = node("Service", { ServiceName: "BillingService", Methods: [] }, "88888888-8888-4888-8888-888888888888"); - // CALLS ayrica AuthService'e -> DEDUP (tek alan kalmali). + // CALLS also targets AuthService -> deduplicated (only one field should remain). const { ctx } = ctxFor( [gw, auth, billing], [edge("CALLS", GW, AUTH_SVC, "77777777-7777-4777-8777-777777777777")], ); const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); - // Isme gore sirali: AuthService once, BillingService sonra. + // Sorted by name: AuthService first, BillingService second. const authIdx = file.content.indexOf("authService: AuthService"); const billingIdx = file.content.indexOf("billingService: BillingService"); expect(authIdx).toBeGreaterThan(-1); expect(billingIdx).toBeGreaterThan(authIdx); - // DEDUP: AuthService constructor'da bir kez. + // Dedup: AuthService appears in the constructor exactly once. expect(file.content.match(/authService: AuthService/g)?.length).toBe(1); }); - it("rol eki adin tamamiysa orijinal ad korunur (Gateway -> gateway/gateway.gateway.ts)", () => { - // B2: Hedefsiz gateway de kendi feature'ini tohumlar (orphan degil). Rol eki - // ("Gateway") adin tamami -> base "gateway" -> feature "gateway". + it("when the role suffix is the whole name, the original name is kept (Gateway -> gateway/gateway.gateway.ts)", () => { + // B2: A gateway without targets also seeds its own feature (not an orphan). The role + // suffix ("Gateway") is the entire name -> base "gateway" -> feature "gateway". const gw = gatewayNode({ GatewayName: "Gateway" }); const { ctx } = ctxFor([gw]); const [file] = emitApiGateway(ctx.graph.byId(GW)!, ctx); diff --git a/apps/server/src/codegen/emitters/nestjs/controller.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/controller.emitter.spec.ts index 8f1a0a9..d12915c 100644 --- a/apps/server/src/codegen/emitters/nestjs/controller.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/controller.emitter.spec.ts @@ -48,7 +48,7 @@ const USER_DTO_ID = "d5555555-5555-4555-8555-555555555555"; const USERS_SERVICE = node("Service", SVC_ID, { ServiceName: "UsersService", - Description: "User is mantigi", + Description: "User business logic", IsTransactionScoped: false, Methods: [{ MethodName: "create", Visibility: "public", Parameters: [], ReturnType: "User", IsAsync: true, Throws: [] }], Dependencies: [], @@ -56,7 +56,7 @@ const USERS_SERVICE = node("Service", SVC_ID, { const CREATE_USER_DTO = node("DTO", CREATE_DTO_ID, { Name: "CreateUserDto", - Description: "Yeni kullanici girdisi", + Description: "New user input", Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], }); @@ -68,7 +68,7 @@ const USER_DTO = node("DTO", USER_DTO_ID, { const USERS_CONTROLLER = node("Controller", CTRL_ID, { ControllerName: "UsersController", - Description: "User HTTP yuzeyi", + Description: "User HTTP surface", BaseRoute: "users", Version: "v1", Endpoints: [ @@ -99,7 +99,7 @@ const USERS_CONTROLLER = node("Controller", CTRL_ID, { }); describe("emitController", () => { - it("tam controller — snapshot", () => { + it("full controller — snapshot", () => { const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, CREATE_USER_DTO, USER_DTO], [ edge("CALLS", CTRL_ID, SVC_ID, "e1111111-1111-4111-8111-111111111111"), ]); @@ -116,7 +116,7 @@ describe("emitController", () => { import { UserDto } from "./dto/user.dto"; import { UsersService } from "./users.service"; - /** User HTTP yuzeyi */ + /** User HTTP surface */ @ApiTags("UsersController") @Controller("v1/users") export class UsersController { @@ -167,13 +167,13 @@ describe("emitController", () => { `); }); - it("govde-alan write endpoint'i RequestDTORef'siz -> genel @Body() body baglanir (fill serbest degisken UYDURMAZ)", () => { - // POST RequestDTORef WITHOUT: eskiden hic body param baglanmaz, surgical fill - // body alanlarini (productId/quantity) serbest degisken sanip TS2304 uretirdi. - // Genel `@Body() body: Record` bagla -> fill body.'den okur. + it("body-taking write endpoint without RequestDTORef -> generic @Body() body is bound (fill does NOT invent free variables)", () => { + // POST without RequestDTORef: previously no body param was bound, so the surgical + // fill treated body fields (productId/quantity) as free variables and produced TS2304. + // Bind a generic `@Body() body: Record` -> the fill reads from body.. const ctrl = node("Controller", CTRL_ID, { ControllerName: "CartController", - Description: "Sepet", + Description: "Cart", BaseRoute: "cart", Endpoints: [ { @@ -184,7 +184,7 @@ describe("emitController", () => { PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], - // RequestDTORef NONE + // No RequestDTORef ResponseDTORef: "UserDto", MiddlewareRefs: [], }, @@ -194,11 +194,11 @@ describe("emitController", () => { const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); expect(file.content).toContain("@Body() body: Record"); expect(file.content).toContain('import { Body, Controller'); - // Fill hint body'nin tipsiz erisilebilir oldugunu soyler. + // The fill hint states that the body is accessible but untyped. expect(file.content).toMatch(/body.*untyped|untyped.*body/i); }); - it("DI servisini CALLS edge'inden cozer ve import eder", () => { + it("resolves the DI service from the CALLS edge and imports it", () => { const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, CREATE_USER_DTO, USER_DTO], [ edge("CALLS", CTRL_ID, SVC_ID, "e1111111-1111-4111-8111-111111111111"), ]); @@ -207,7 +207,7 @@ describe("emitController", () => { expect(file.content).toContain('import { UsersService } from "./users.service";'); }); - it("Version BaseRoute'a onek olur", () => { + it("Version is prefixed onto BaseRoute", () => { const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE], []); const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); expect(file.content).toContain('@Controller("v1/users")'); @@ -216,15 +216,15 @@ describe("emitController", () => { it("RequiresAuth -> @UseGuards(AuthGuard) + stub import", () => { const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, USER_DTO], []); const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); - // USERS_CONTROLLER :id endpoint'i hem auth hem roles tasir -> @UseGuards(AuthGuard, RolesGuard). + // The USERS_CONTROLLER :id endpoint carries both auth and roles -> @UseGuards(AuthGuard, RolesGuard). expect(file.content).toMatch(/@UseGuards\(AuthGuard/); expect(file.content).toContain('import { AuthGuard } from "../shared/guards/auth.guard";'); }); - /* ── RBAC WIRE (#39): @Roles route'una RolesGuard baglanir ─────────────── - * Eskiden @Roles metadata yaziliyordu ama hicbir guard okumuyordu (olu RBAC). - * Artik RequiredRoles olan endpoint @UseGuards'a RolesGuard ekler — Reflector ile - * ROLES_KEY metadata'sini okuyup enforce eden gercek guard (scaffold uretir). */ + /* ── RBAC WIRE (#39): RolesGuard is attached to @Roles routes ───────────── + * Previously @Roles metadata was written but no guard ever read it (dead RBAC). + * Now an endpoint with RequiredRoles adds RolesGuard to @UseGuards — a real guard + * that reads the ROLES_KEY metadata via Reflector and enforces it (scaffold generates it). */ it("RequiredRoles -> @UseGuards(AuthGuard, RolesGuard) + RolesGuard import + @Roles", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "AdminController", @@ -244,7 +244,7 @@ describe("emitController", () => { expect(file.content).toContain('@Roles("admin", "owner")'); }); - it("her govde-gerektiren metotta surgical marker + NOT_IMPLEMENTED var", () => { + it("every body-requiring method has a surgical marker + NOT_IMPLEMENTED", () => { const ctx = ctxFor([USERS_CONTROLLER, USERS_SERVICE, CREATE_USER_DTO, USER_DTO], [ edge("CALLS", CTRL_ID, SVC_ID, "e1111111-1111-4111-8111-111111111111"), ]); @@ -270,7 +270,7 @@ describe("emitController", () => { expect(file.content.endsWith("}\n\n")).toBe(false); }); - it("path/query param tipleri GECERLI TS'e normalize edilir (uuid/int/long -> string/number)", () => { + it("path/query param types are normalized to VALID TS (uuid/int/long -> string/number)", () => { const typed = node("Controller", CTRL_ID, { ControllerName: "ItemsController", Description: "tipli paramlar", @@ -293,7 +293,7 @@ describe("emitController", () => { }); const ctx = ctxFor([typed], []); const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); - // uuid -> string, int -> number, datetime -> Date (ham 'uuid'/'int' GECERSIZ TS idi). + // uuid -> string, int -> number, datetime -> Date (raw 'uuid'/'int' was INVALID TS). expect(file.content).toContain('@Param("id") id: string'); expect(file.content).toContain('@Query("count") count?: number'); expect(file.content).toContain('@Query("since") since?: Date'); @@ -301,8 +301,8 @@ describe("emitController", () => { expect(file.content).not.toContain(": int"); }); - /* ── EDGE-CASE: servissiz controller + kayip DTO ref + bos StatusCodes ── */ - it("CALLS edge yoksa constructor uretilmez; kayip DTO ref TODO birakir, throw etmez", () => { + /* ── EDGE-CASE: controller without a service + missing DTO ref + empty StatusCodes ── */ + it("no CALLS edge -> no constructor is generated; a missing DTO ref leaves a TODO instead of throwing", () => { const lonely = node("Controller", CTRL_ID, { ControllerName: "PingController", Description: "Saglik", @@ -321,28 +321,28 @@ describe("emitController", () => { }, ], }); - const ctx = ctxFor([lonely], []); // hic service/DTO yok + const ctx = ctxFor([lonely], []); // no service/DTO at all expect(() => emitController(ctx.graph.byId(CTRL_ID)!, ctx)).not.toThrow(); const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); expect(file.content).not.toContain("constructor("); expect(file.content).toContain("@Body() dto: unknown"); expect(file.content).toContain("MissingDto"); - // bos StatusCodes -> @HttpCode yok + // empty StatusCodes -> no @HttpCode expect(file.content).not.toContain("@HttpCode"); - // delegasyon ipucu yok (servis yok) + // no delegation hint (there is no service) expect(file.content).not.toContain("Delegation hint"); expect(file.path).toBe("ping/ping.controller.ts"); }); - /* ── Finding #6: ROUTE SIRASI — statik rota'lar ":param" rota'lardan FIRST ── */ - it("statik rota'lar param rota'lardan FIRST deklare edilir (Nest eslesme tuzagi)", () => { + /* ── Finding #6: ROUTE ORDER — static routes come BEFORE ":param" routes ── */ + it("static routes are declared BEFORE param routes (Nest matching pitfall)", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "ProductsController", - Description: "urun API", + Description: "product API", BaseRoute: "products", Endpoints: [ - // Graph sirasi: once :id (param), AFTER categories (statik) -> Nest'te - // "/categories" asla eslesmezdi. Emitter bunu duzeltmeli. + // Graph order: :id (param) first, categories (static) after -> in Nest + // "/categories" would never match. The emitter must fix this. { HttpMethod: "GET", Route: ":id", RequiresAuth: false, RequiredRoles: [], PathParams: [{ Name: "id", Type: "uuid" }], QueryParams: [], StatusCodes: [{ Code: 200 }], @@ -360,11 +360,11 @@ describe("emitController", () => { const byIdIdx = file.content.indexOf('@Get(":id")'); expect(categoriesIdx).toBeGreaterThanOrEqual(0); expect(byIdIdx).toBeGreaterThanOrEqual(0); - // statik "categories" param ":id"'den FIRST cikmali. + // static "categories" must come out BEFORE the ":id" param route. expect(categoriesIdx).toBeLessThan(byIdIdx); }); - it("statik/param siralamasi STABLE — esit ranklarda graph sirasi korunur", () => { + it("static/param ordering is STABLE — graph order is preserved for equal ranks", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "ItemsController", Description: "stable", @@ -380,35 +380,35 @@ describe("emitController", () => { const featured = file.content.indexOf('@Get("featured")'); const popular = file.content.indexOf('@Get("popular")'); const byId = file.content.indexOf('@Get(":id")'); - // iki statik graph sirasinda (featured < popular), ikisi de param'dan once. + // the two static routes keep graph order (featured < popular), both before the param route. expect(featured).toBeLessThan(popular); expect(popular).toBeLessThan(byId); }); - /* ── Finding #7: LIST RETURN — koleksiyon endpoint'i DTO[] doner ── */ - it("GET + path-param NONE -> koleksiyon: ResponseDTORef DTO[] doner", () => { + /* ── Finding #7: LIST RETURN — collection endpoints return DTO[] ── */ + it("GET without path params -> collection: ResponseDTORef returns DTO[]", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "ProductsController", Description: "liste", BaseRoute: "products", Endpoints: [ - // GET /, no path param -> koleksiyon -> ProductDto[]. + // GET /, no path param -> collection -> ProductDto[]. { HttpMethod: "GET", Route: "/", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], ResponseDTORef: "ProductDto", MiddlewareRefs: [] }, - // GET /:id -> tekil kayit -> ProductDto (array NOT). + // GET /:id -> single record -> ProductDto (not an array). { HttpMethod: "GET", Route: ":id", RequiresAuth: false, RequiredRoles: [], PathParams: [{ Name: "id", Type: "string" }], QueryParams: [], StatusCodes: [{ Code: 200 }], ResponseDTORef: "ProductDto", MiddlewareRefs: [] }, ], }); const productDto = node("DTO", "d6666666-6666-4666-8666-666666666666", { - Name: "ProductDto", Description: "urun", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + Name: "ProductDto", Description: "product", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], }); const ctx = ctxFor([ctrl, productDto], []); const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); expect(file.content).toContain("async get(): Promise"); - // getById tekil doner (array NOT). + // getById returns a single record (not an array). expect(file.content).toMatch(/async getById\([\s\S]*?\): Promise \{/); }); - it("GET /me (self/tekil semantik) -> path-param olmasa da SINGLE DTO (array NOT)", () => { + it("GET /me (self/singular semantics) -> SINGLE DTO even without path params (not an array)", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "AccountController", Description: "self", @@ -423,13 +423,13 @@ describe("emitController", () => { expect(file.content).not.toContain("Promise"); }); - it("route list/findAll semantigi -> path-param olsa bile koleksiyon (DTO[])", () => { + it("route with list/findAll semantics -> collection (DTO[]) even with path params", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "OrdersController", - Description: "liste-semantigi", + Description: "list-semantics", BaseRoute: "orders", Endpoints: [ - // GET /:userId/list -> path param var ama son segment "list" -> koleksiyon. + // GET /:userId/list -> has a path param but the last segment is "list" -> collection. { HttpMethod: "GET", Route: ":userId/list", RequiresAuth: false, RequiredRoles: [], PathParams: [{ Name: "userId", Type: "string" }], QueryParams: [], StatusCodes: [], ResponseDTORef: "OrderDto", MiddlewareRefs: [] }, ], }); @@ -441,18 +441,18 @@ describe("emitController", () => { expect(file.content).toContain("Promise"); }); - /* ── TEK-SOURCE KARDINALITE: bildirilmis ReturnsCollection > route sezgisi ── - * Endpoint'te ReturnsCollection bildirilmisse, controller route sekline DAYALI - * tahmini (isCollectionEndpoint) NOT bildirilen alani kullanir. service.emitter - * ile AYNI tek-kaynak: kanvas iki uca da ayni karari set eder -> imzalar garantili - * hizali. */ - it("ReturnsCollection=true: route tekil-sezgili (GET /:id) olsa bile DTO[] doner", () => { + /* ── SINGLE-SOURCE CARDINALITY: declared ReturnsCollection > route heuristic ── + * If ReturnsCollection is declared on the endpoint, the controller uses the declared + * field instead of its route-shape guess (isCollectionEndpoint). Same single source + * as service.emitter: the canvas sets the same decision on both ends -> signatures + * are guaranteed to line up. */ + it("ReturnsCollection=true: returns DTO[] even when the route heuristic says single (GET /:id)", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "ProductsController", Description: "bildirilmis koleksiyon", BaseRoute: "products", Endpoints: [ - // GET /:id -> route sezgisi SINGLE der (path param var); ama bildirilen true. + // GET /:id -> the route heuristic says SINGLE (there is a path param); but the declared value is true. { HttpMethod: "GET", Route: ":id", RequiresAuth: false, RequiredRoles: [], PathParams: [{ Name: "id", Type: "string" }], QueryParams: [], StatusCodes: [{ Code: 200 }], @@ -461,7 +461,7 @@ describe("emitController", () => { ], }); const productDto = node("DTO", "d9999999-9999-4999-8999-999999999999", { - Name: "ProductDto", Description: "urun", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + Name: "ProductDto", Description: "product", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], }); const ctx = ctxFor([ctrl, productDto], []); const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); @@ -469,13 +469,13 @@ describe("emitController", () => { expect(file.content).not.toContain("Promise {"); }); - it("ReturnsCollection=false: route koleksiyon-sezgili (GET /) olsa bile SINGLE doner", () => { + it("ReturnsCollection=false: returns SINGLE even when the route heuristic says collection (GET /)", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "SummaryController", Description: "bildirilmis tekil", BaseRoute: "summary", Endpoints: [ - // GET / (path-param yok) -> route sezgisi COLLECTION der; ama bildirilen false. + // GET / (no path param) -> the route heuristic says COLLECTION; but the declared value is false. { HttpMethod: "GET", Route: "/", RequiresAuth: false, RequiredRoles: [], PathParams: [], QueryParams: [], StatusCodes: [{ Code: 200 }], @@ -484,7 +484,7 @@ describe("emitController", () => { ], }); const productDto = node("DTO", "da999999-9999-4999-8999-999999999999", { - Name: "ProductDto", Description: "urun", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], + Name: "ProductDto", Description: "product", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], }); const ctx = ctxFor([ctrl, productDto], []); const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); @@ -493,7 +493,7 @@ describe("emitController", () => { }); /* ── Finding #8: AUTH/userId + login token ── */ - it("RequiresAuth -> @CurrentUser() user: AuthUser parametresi + import", () => { + it("RequiresAuth -> @CurrentUser() user: AuthUser parameter + import", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "MeController", Description: "kimlik", @@ -506,11 +506,11 @@ describe("emitController", () => { const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); expect(file.content).toContain("@CurrentUser() user: AuthUser"); expect(file.content).toContain('import { type AuthUser, CurrentUser } from "../shared/decorators/current-user.decorator";'); - // userId surgical govdede erisilebilir oldugu marker'da belirtilir. + // The marker notes that userId is accessible inside the surgical body. expect(file.content).toContain("Authenticated user available as 'user' (e.g. user.id)."); }); - it("RequiresAuth=false -> @CurrentUser NONE", () => { + it("RequiresAuth=false -> no @CurrentUser", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "PublicController", Description: "acik", @@ -525,7 +525,7 @@ describe("emitController", () => { expect(file.content).not.toContain("AuthUser"); }); - it("login endpoint (ResponseDTORef yok) -> Promise (void degil)", () => { + it("login endpoint (no ResponseDTORef) -> Promise (not void)", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "AuthController", Description: "auth", @@ -535,7 +535,7 @@ describe("emitController", () => { ], }); const loginDto = node("DTO", "d8888888-8888-4888-8888-888888888888", { - Name: "LoginDto", Description: "giris", Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], + Name: "LoginDto", Description: "login", Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], }); const ctx = ctxFor([ctrl, loginDto], []); const [file] = emitController(ctx.graph.byId(CTRL_ID)!, ctx); @@ -590,7 +590,7 @@ describe("emitController", () => { expect(file.content).not.toContain("@ApiBearerAuth"); }); - it("DETERMINISM: route-sira + auth + array fix sonrasi byte-identical", () => { + it("DETERMINISM: byte-identical after route-order + auth + array fixes", () => { const ctrl = node("Controller", CTRL_ID, { ControllerName: "MixController", Description: "karisik", diff --git a/apps/server/src/codegen/emitters/nestjs/controller.emitter.ts b/apps/server/src/codegen/emitters/nestjs/controller.emitter.ts index 1ad5494..71331c7 100644 --- a/apps/server/src/codegen/emitters/nestjs/controller.emitter.ts +++ b/apps/server/src/codegen/emitters/nestjs/controller.emitter.ts @@ -16,23 +16,24 @@ import { tokensHaveCollectionSemantics } from "../../cardinality"; /* ──────────────────────────────────────────────────────────────────────── * controller.emitter.ts — ControllerNode -> /.controller.ts. * - * @Controller(BaseRoute (+ Version oneki)). DI = ctx.outEdges(id, "CALLS") - * -> Service(ler), constructor injection (private readonly). Her Endpoint -> - * dekoratorlu metot: - * - HTTP fiili -> @Get/@Post/@Put/@Delete/@Patch(Route) - * - ilk StatusCode -> @HttpCode(code) - * - RequiresAuth -> @UseGuards(AuthGuard) (shared/ stub guard importu) - * - RequiredRoles -> @Roles(...) (shared/ stub decorator importu) + * @Controller(BaseRoute (+ Version prefix)). DI = ctx.outEdges(id, "CALLS") + * -> Service(s), constructor injection (private readonly). Each Endpoint -> + * a decorated method: + * - HTTP verb -> @Get/@Post/@Put/@Delete/@Patch(Route) + * - first StatusCode -> @HttpCode(code) + * - RequiresAuth -> @UseGuards(AuthGuard) (shared/ stub guard import) + * - RequiredRoles -> @Roles(...) (shared/ stub decorator import) * - PathParams -> @Param("name") name: Type * - QueryParams -> @Query("name") name: Type - * - RequestDTORef -> @Body() dto: (ref cozulurse import + tip) - * - ResponseDTORef -> Promise (ref cozulurse import + tip) - * Metot adi HttpMethod + Route + path param'lardan DETERMINISTIC turetilir - * (or. GET /users/:id -> getUserById). Govde: surgicalMarker + NOT_IMPLEMENTED; - * delegasyon ipucu (this..) marker aciklamasinda verilir. + * - RequestDTORef -> @Body() dto: (import + type when the ref resolves) + * - ResponseDTORef -> Promise (import + type when the ref resolves) + * The method name is derived DETERMINISTICALLY from HttpMethod + Route + path + * params (e.g. GET /users/:id -> getUserById). Body: surgicalMarker + + * NOT_IMPLEMENTED; a delegation hint (this..) is given in the + * marker description. * - * SAF + DETERMINISTIC: koleksiyonlar sirali, kayip ref tolere edilir (THROW yok), - * import'lar ImportCollector ile, icerik tek "\n" ile biter. + * PURE + DETERMINISTIC: collections are sorted, missing refs are tolerated (no + * THROW), imports go through ImportCollector, content ends with a single "\n". * ──────────────────────────────────────────────────────────────────────── */ type EndpointProps = ReturnType>["Endpoints"][number]; @@ -45,7 +46,7 @@ const HTTP_DECORATOR: Record = { PATCH: "Patch", }; -/** Istek govdesi (body) bekleyebilen HTTP fiilleri (GET/DELETE govdesizdir). */ +/** HTTP verbs that may expect a request body (GET/DELETE are body-less). */ const WRITE_METHODS: ReadonlySet = new Set(["POST", "PUT", "PATCH"]); export const emitController: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { @@ -55,44 +56,44 @@ export const emitController: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[ const imports = new ImportCollector(); - // ── @Controller route: Version onekini SADECE BaseRoute zaten icermiyorsa - // ekle. BaseRoute tam yolu tasiyorsa (or. "api/v1/auth") tekrar onekleme - // -> "v1/api/v1/auth" gibi DOUBLE prefix OLUSMAZ. ── + // ── @Controller route: add the Version prefix ONLY when BaseRoute does not + // already contain it. If BaseRoute carries the full path (e.g. "api/v1/auth"), + // do not prefix again -> no DOUBLE prefix like "v1/api/v1/auth". ── const baseRoute = normalizeRoute(props.BaseRoute); const controllerRoute = computeControllerRoute(props.Version, baseRoute); - // @nestjs/common cekirdek dekoratorleri (kullanilanlar kosullu eklenir). + // @nestjs/common core decorators (the ones used are added conditionally). imports.add("Controller", "@nestjs/common"); - // @nestjs/swagger: sinif @ApiTags ile bir OpenAPI grubu olarak etiketlenir - // (uretilen uygulama kendini Scalar /docs altinda belgeler). + // @nestjs/swagger: the class is tagged as an OpenAPI group via @ApiTags + // (the generated app documents itself under Scalar /docs). imports.add("ApiTags", "@nestjs/swagger"); - // ── DI: CALLS edge'lerinden Service'ler (edge'ler isme gore sirali) ── + // ── DI: Services from the CALLS edges (edges sorted by name) ── const services = collectInjectedServices(node, ctx); for (const svc of services) { imports.add(svc.className, relativeImportPath(thisFile, importPathOf(svc.file))); } - // ── Endpoint metotlari ── - // ROUTE SIRASI (Finding #6): NestJS rota'lari DEKLARASYON SIRASIYLA eslestirir. - // Ayni HTTP fiilinde STATIC rota'lar ("categories") PARAM rota'lardan (":id") - // FIRST gelmeli — yoksa "/categories" hicbir zaman eslesmez (":id" once yakalar). - // sortEndpointsForRouting: statik-segmentli endpoint'ler once, ":param" icerenler - // sonra; esitlikte mevcut sira KORUNUR (stable, deterministik). + // ── Endpoint methods ── + // ROUTE ORDER (Finding #6): NestJS matches routes in DECLARATION ORDER. + // Within the same HTTP verb, STATIC routes ("categories") must come BEFORE + // PARAM routes (":id") — otherwise "/categories" never matches (":id" captures first). + // sortEndpointsForRouting: endpoints with static segments first, those containing + // ":param" after; on ties the existing order is KEPT (stable, deterministic). const orderedEndpoints = sortEndpointsForRouting(props.Endpoints); const methodBlocks: string[] = []; for (const ep of orderedEndpoints) { methodBlocks.push(buildEndpoint(node, ep, services, imports, thisFile, ctx, className)); } - // ── Sinif govdesi ── + // ── Class body ── const lines: string[] = []; if (props.Description) lines.push(`/** ${props.Description} */`); lines.push(`@ApiTags(${JSON.stringify(node.name)})`); lines.push(`@Controller(${JSON.stringify(controllerRoute)})`); lines.push(`export class ${className} {`); - // constructor (yalniz servis varsa) + // constructor (only when there are services) if (services.length > 0) { lines.push(" constructor("); services.forEach((svc, i) => { @@ -122,7 +123,7 @@ export const emitController: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[ return [file]; }; -/* ── DI: CALLS -> Service cozumleme ──────────────────────────────────────── */ +/* ── DI: CALLS -> Service resolution ──────────────────────────────────────── */ interface InjectedService { name: string; className: string; @@ -133,10 +134,10 @@ interface InjectedService { function collectInjectedServices(node: CodeNode, ctx: EmitterContext): InjectedService[] { const seen = new Set(); const out: InjectedService[] = []; - // outEdges zaten kind,source.name,target.name,id'ye gore sirali (deterministik). + // outEdges is already sorted by kind, source.name, target.name, id (deterministic). for (const e of ctx.graph.outEdges(node.id, "CALLS")) { const tgt = ctx.graph.byId(e.targetNodeId); - if (!tgt || tgt.kindOf() !== "Service") continue; // kayip/yanlis ref -> atla + if (!tgt || tgt.kindOf() !== "Service") continue; // missing/wrong ref -> skip if (seen.has(tgt.id)) continue; seen.add(tgt.id); const cls = pascalCase(tgt.name); @@ -150,7 +151,7 @@ function collectInjectedServices(node: CodeNode, ctx: EmitterContext): InjectedS return out; } -/* ── Tek endpoint -> metot bloku ─────────────────────────────────────────── */ +/* ── Single endpoint -> method block ─────────────────────────────────────────── */ function buildEndpoint( node: CodeNode, ep: EndpointProps, @@ -162,13 +163,13 @@ function buildEndpoint( ): string { const decoratorLines: string[] = []; - // HTTP fiili dekoratoru + // HTTP verb decorator const httpDecorator = HTTP_DECORATOR[ep.HttpMethod] ?? "Get"; imports.add(httpDecorator, "@nestjs/common"); const routeArg = methodRouteArg(ep.Route); decoratorLines.push(` @${httpDecorator}(${routeArg})`); - // Ilk StatusCode -> @HttpCode + // First StatusCode -> @HttpCode const firstCode = ep.StatusCodes.length > 0 ? ep.StatusCodes[0].Code : undefined; if (firstCode !== undefined) { imports.add("HttpCode", "@nestjs/common"); @@ -176,10 +177,10 @@ function buildEndpoint( } // RequiresAuth/RequiredRoles -> @UseGuards(AuthGuard[, RolesGuard]). AuthGuard - // (authentication) request.user'i yerlestirir; RolesGuard (authorization, #39) - // @Roles metadata'sini Reflector ile okuyup enforce eder. SIRA onemli: AuthGuard - // FIRST (user'i set eder), RolesGuard AFTER (role'u okur). Eskiden RolesGuard hic - // baglanmiyordu -> @Roles oluydu. + // (authentication) populates request.user; RolesGuard (authorization, #39) reads + // the @Roles metadata via Reflector and enforces it. ORDER matters: AuthGuard + // FIRST (sets the user), RolesGuard AFTER (reads the role). Previously RolesGuard + // was never wired -> @Roles was dead. const guards: string[] = []; if (ep.RequiresAuth) { imports.add("AuthGuard", relativeImportPath(thisFile, "shared/guards/auth.guard")); @@ -194,24 +195,25 @@ function buildEndpoint( decoratorLines.push(` @UseGuards(${guards.join(", ")})`); } - // RequiredRoles -> @Roles(...) (RolesGuard bu ROLES_KEY metadata'sini okur) + // RequiredRoles -> @Roles(...) (RolesGuard reads this ROLES_KEY metadata) if (ep.RequiredRoles.length > 0) { imports.add("Roles", relativeImportPath(thisFile, "shared/decorators/roles.decorator")); const roleArgs = ep.RequiredRoles.map((r) => JSON.stringify(r)).join(", "); decoratorLines.push(` @Roles(${roleArgs})`); } - // ── Parametreler ── + // ── Parameters ── const params: string[] = []; - // PathParams -> @Param("name") name: Type. (Paramsiz endpoint'lerde alan hic - // gelmeyebilir — graf verisi eksik olsa da emitter patlamamali: bos diziye dus.) + // PathParams -> @Param("name") name: Type. (On param-less endpoints the field may + // be absent entirely — the emitter must not blow up on incomplete graph data: + // fall back to an empty array.) for (const p of ep.PathParams ?? []) { imports.add("Param", "@nestjs/common"); params.push(`@Param(${JSON.stringify(p.Name)}) ${safeIdent(p.Name)}: ${tsType(p.Type)}`); } - // QueryParams -> @Query("name") name: Type. (Ayni savunma: alan eksikse bos dizi.) + // QueryParams -> @Query("name") name: Type. (Same defense: empty array when the field is missing.) for (const q of ep.QueryParams ?? []) { imports.add("Query", "@nestjs/common"); const optional = q.Required ? "" : "?"; @@ -229,28 +231,28 @@ function buildEndpoint( imports.add(bodyDtoClass, relativeImportPath(thisFile, importPathOf(filePathFor(dto, ctx.graph)))); params.push(`@Body() dto: ${bodyDtoClass}`); } else { - // Kayip ref: tipsiz body (THROW yok), TODO birak. + // Missing ref: untyped body (no THROW), leave a TODO. params.push(`@Body() dto: unknown /* TODO: DTO '${ep.RequestDTORef}' not found */`); } } else if (WRITE_METHODS.has(ep.HttpMethod)) { - // Govde-alan write endpoint'i (POST/PUT/PATCH) RequestDTORef WITHOUT: tipli DTO yok. - // Eskiden hic body param baglanmazdi -> surgical fill body alanlarini (or. productId, - // quantity) SERBEST VARIABLE sanip `this.svc.x(productId, quantity)` uretip TS2304 - // veriyordu. Genel `@Body() body: Record` bagla: fill gercek (tipsiz) - // body'den okur (`body.productId` -> unknown, derlenir) — uydurma degisken uretmez. - // (Kontrat boslugu contract-lint Rule 1 ile ayrica uyarilir.) + // A body-taking write endpoint (POST/PUT/PATCH) without RequestDTORef: no typed DTO. + // Previously no body param was bound at all -> the surgical fill treated body fields + // (e.g. productId, quantity) as FREE VARIABLES, producing `this.svc.x(productId, quantity)` + // and TS2304. Bind a generic `@Body() body: Record`: the fill reads from + // the real (untyped) body (`body.productId` -> unknown, compiles) — no invented variables. + // (The contract gap is separately flagged by contract-lint Rule 1.) imports.add("Body", "@nestjs/common"); params.push(`@Body() body: Record`); injectsRawBody = true; } - // ── REQUEST BAGLAMI / userId (Finding #8): RequiresAuth olan endpoint'lere - // @CurrentUser() user: AuthUser parametresi ekle. Boylece kimligi dogrulanmis - // kullanicinin id'si (user.id) surgical govdede ERISILEBILIR olur — imzada - // gectigi icin body icinden okunabilir. @CurrentUser, paylasimli bir param - // decorator'dur (shared/decorators/current-user.decorator); request.user'i - // cozer (AuthGuard onu yerlestirir). Param SON sirada gelir (decorator'lu - // @Param/@Query/@Body'den sonra) — okunakli + deterministik. ── + // ── REQUEST CONTEXT / userId (Finding #8): add a @CurrentUser() user: AuthUser + // parameter to endpoints with RequiresAuth. That makes the authenticated + // user's id (user.id) REACHABLE in the surgical body — being in the signature, + // it can be read from inside the body. @CurrentUser is a shared param + // decorator (shared/decorators/current-user.decorator); it resolves + // request.user (AuthGuard places it there). The param comes LAST (after the + // decorated @Param/@Query/@Body) — readable + deterministic. ── let injectsCurrentUser = false; if (ep.RequiresAuth) { injectsCurrentUser = true; @@ -259,15 +261,15 @@ function buildEndpoint( params.push(`@CurrentUser() user: AuthUser`); } - // ── Donus tipi ── - // ResponseDTORef -> Promise. LIST RETURN (Finding #7): koleksiyon - // donduren endpoint (GET + path-param NONE, ya da list/findAll/search/all - // semantigi) tekil DTO degil DTO[] doner. - // AUTH/LOGIN (Finding #8): ResponseDTORef WITHOUT bir login endpoint'i tutarli - // bir token zarfi (AuthResponse) doner — void degil. + // ── Return type ── + // ResponseDTORef -> Promise. LIST RETURN (Finding #7): a collection-returning + // endpoint (GET with no path params, or list/findAll/search/all semantics) + // returns DTO[] instead of a single DTO. + // AUTH/LOGIN (Finding #8): a login endpoint without ResponseDTORef returns a + // consistent token envelope (AuthResponse) — not void. let returnInner = "void"; - // Cozulen yanit DTO'su: @ApiResponse({ type: ... }) calisma-zamani (value) referansi - // icin sinif adi + import yolu burada yakalanir. + // The resolved response DTO: class name + import path are captured here for the + // @ApiResponse({ type: ... }) runtime (value) reference. let responseDtoClass: string | null = null; let responseDtoImport: string | null = null; const collection = isCollectionEndpoint(ep); @@ -284,17 +286,17 @@ function buildEndpoint( returnInner = `unknown /* TODO: DTO '${ep.ResponseDTORef}' not found */`; } } else if (isLoginEndpoint(ep)) { - // Login -> token: tutarli bir kimlik-dogrulama yaniti (accessToken tasir). + // Login -> token: a consistent authentication response (carries accessToken). imports.addType("AuthResponse", relativeImportPath(thisFile, "shared/decorators/current-user.decorator")); returnInner = "AuthResponse"; } const returnType = `Promise<${returnInner}>`; - // ── @nestjs/swagger dekoratorleri (kendini-belgeleyen uretilmis uygulama) ── - // @ApiBearerAuth() RequiresAuth oldugunda; @ApiOperation({ summary }) her - // endpoint'te; her StatusCode icin bir @ApiResponse. Yanit DTO'su <400 kodlarda - // `type:` ile referanslanir (collection ise isArray:true). Aciklama/ornekler - // varsa zenginlestirilmis doc'tan gelir; burada DETERMINISTIC ozet kullanilir. + // ── @nestjs/swagger decorators (self-documenting generated app) ── + // @ApiBearerAuth() when RequiresAuth; @ApiOperation({ summary }) on every + // endpoint; one @ApiResponse per StatusCode. The response DTO is referenced via + // `type:` on <400 codes (isArray:true when it is a collection). Descriptions/ + // examples come from the enriched doc when present; a DETERMINISTIC summary is used here. if (ep.RequiresAuth) { imports.add("ApiBearerAuth", "@nestjs/swagger"); decoratorLines.push(` @ApiBearerAuth()`); @@ -310,8 +312,8 @@ function buildEndpoint( const parts: string[] = [`status: ${sc.Code}`]; if (sc.Description) parts.push(`description: ${JSON.stringify(sc.Description)}`); if (responseDtoClass && responseDtoImport && sc.Code < 400) { - // @ApiResponse({ type: Dto }) DTO'yu calisma-zamani DEGER'i olarak kullanir - // -> type-only import yerine deger importuna yukselt (yoksa derlenmez). + // @ApiResponse({ type: Dto }) uses the DTO as a runtime VALUE + // -> promote the type-only import to a value import (won't compile otherwise). imports.add(responseDtoClass, responseDtoImport); parts.push(`type: ${responseDtoClass}`); if (collection) parts.push(`isArray: true`); @@ -319,10 +321,10 @@ function buildEndpoint( decoratorLines.push(` @ApiResponse({ ${parts.join(", ")} })`); } - // ── Metot adi: HTTP fiili + route + path param ── + // ── Method name: HTTP verb + route + path params ── const methodName = deriveMethodName(ep); - // ── Govde: surgical marker + NOT_IMPLEMENTED ── + // ── Body: surgical marker + NOT_IMPLEMENTED ── const delegate = services.length > 0 ? services[0].field : undefined; const descParts: string[] = []; if (ep.Description) descParts.push(ep.Description); @@ -341,10 +343,11 @@ function buildEndpoint( deps: services.length > 0 ? services.map((s) => s.field) : undefined, }); - // TS: opsiyonel (`name?: T`) parametre, zorunlu parametreden FIRST gelemez (TS1016). - // @Query opsiyonel olabilir ama @CurrentUser/@Param/@Body zorunlu — opsiyonelleri - // sona, goreli sirayi koruyarak tasi (decorator baglamayi bozmaz: konum degil - // decorator deger atar; surgical govde param'lari ada gore okur). + // TS: an optional (`name?: T`) parameter cannot come before a required one (TS1016). + // @Query may be optional but @CurrentUser/@Param/@Body are required — move the + // optionals to the end, preserving relative order (this does not break decorator + // binding: the decorator assigns the value, not the position; the surgical body + // reads params by name). const orderedParams = [ ...params.filter((p) => !/\?:/.test(p)), ...params.filter((p) => /\?:/.test(p)), @@ -360,22 +363,22 @@ function buildEndpoint( return block.join("\n"); } -/* ── Route sirasi / koleksiyon / login sezgileri (DETERMINISTIC) ──────────── */ +/* ── Route order / collection / login heuristics (DETERMINISTIC) ──────────── */ -/** ROUTE SIRASI (Finding #6): NestJS rota'lari @Controller icindeki DEKLARASYON - * sirasiyla eslestirir. ":param" iceren bir rota, kendinden sonra gelen STATIC - * bir rota'yi (ayni fiilde) golgeler — or. @Get(":id") @Get("categories")'ten - * once gelirse "/categories" hicbir zaman calismaz. +/** ROUTE ORDER (Finding #6): NestJS matches routes in their DECLARATION order + * inside @Controller. A route containing ":param" shadows a STATIC route that + * comes after it (on the same verb) — e.g. if @Get(":id") comes before + * @Get("categories"), "/categories" never runs. * - * Bu yuzden endpoint'leri STABLE bicimde yeniden siralariz: ":param"/"{param}" - * segmenti WITHOUT (statik) endpoint'ler FIRST, param icerenler AFTER. Esitlikte - * (ikisi de statik ya da ikisi de param) MEVCUT SIRA korunur (kullanici niyeti + - * determinizm). HTTP fiili karistirilmaz: param-iceren GET, statik POST'tan - * sonraya kayabilir ama bu Nest eslesmesini bozmaz (her fiil kendi icinde sirali - * kalir ve statik-once kurali tum fiiller icin guvenlidir). + * So we re-sort the endpoints in a STABLE way: endpoints WITHOUT a ":param"/ + * "{param}" segment (static) come FIRST, those containing params come AFTER. On + * ties (both static or both parameterized) the EXISTING order is kept (user + * intent + determinism). HTTP verbs are not mixed up: a param-bearing GET may + * shift after a static POST, but that does not break Nest matching (each verb + * stays ordered within itself and the static-first rule is safe for all verbs). * - * Stable sort: her endpoint'i orijinal index'iyle etiketle, anahtar (statik=0, - * param=1) esitse index'le kir. */ + * Stable sort: tag each endpoint with its original index; when the key + * (static=0, param=1) ties, break by index. */ function sortEndpointsForRouting(endpoints: readonly EndpointProps[]): EndpointProps[] { return endpoints .map((ep, index) => ({ ep, index, paramRank: hasRouteParam(ep.Route) ? 1 : 0 })) @@ -383,7 +386,7 @@ function sortEndpointsForRouting(endpoints: readonly EndpointProps[]): EndpointP .map((x) => x.ep); } -/** Bir route en az bir ":param" veya "{param}" segmenti iceriyor mu? */ +/** Does a route contain at least one ":param" or "{param}" segment? */ function hasRouteParam(route: string): boolean { return route .split("/") @@ -391,39 +394,42 @@ function hasRouteParam(route: string): boolean { .some((seg) => seg.startsWith(":") || (seg.startsWith("{") && seg.endsWith("}"))); } -/** LIST RETURN (Finding #7): endpoint bir COLLECTION mu donduruyor? - * Kurallar (deterministik, endpoint SEKLINDEN): - * - Route'un son literal segmenti list/findAll/all/search/findMany semantigi -> koleksiyon - * (path-param olsa bile, or. /:userId/list). - * - GET + hic path-param NONE (ne PathParams ne de route'ta ":param") -> koleksiyon - * (klasik REST liste: GET /products) — ANCAK son literal segment tekil/self - * semantigi (me/current/profile/...) ise SINGLE (GET /me bir kayit doner). - * PathParams olan bir GET (or. /:id) SINGLE kaydi doner -> koleksiyon NOT. */ +/** LIST RETURN (Finding #7): does the endpoint return a COLLECTION? + * Rules (deterministic, from the endpoint's SHAPE): + * - The route's last literal segment has list/findAll/all/search/findMany + * semantics -> collection (even with path params, e.g. /:userId/list). + * - GET with no path params at all (neither PathParams nor ":param" in the + * route) -> collection (classic REST list: GET /products) — UNLESS the last + * literal segment has singular/self semantics (me/current/profile/...), in + * which case it is SINGLE (GET /me returns one record). + * A GET with PathParams (e.g. /:id) returns a SINGLE record -> not a collection. */ function isCollectionEndpoint(ep: EndpointProps): boolean { - // TEK-SOURCE: bildirilmis ReturnsCollection (true/false) route sezgisini OVERRIDES. - // service.emitter ile ayni alan -> controller ve service imzalari garantili hizali. + // SINGLE SOURCE: a declared ReturnsCollection (true/false) OVERRIDES the route + // heuristic. Same field as service.emitter -> controller and service signatures + // are guaranteed to line up. if (typeof ep.ReturnsCollection === "boolean") return ep.ReturnsCollection; if (ep.HttpMethod !== "GET") return false; - // Acik liste-semantigi her zaman koleksiyon (path-param fark etmez). + // Explicit list semantics is always a collection (path params don't matter). if (routeHasListSemantics(ep.Route)) return true; const hasPathParam = (ep.PathParams?.length ?? 0) > 0 || hasRouteParam(ep.Route); if (hasPathParam) return false; - // path-param yok: tekil/self semantigi tasimadikca koleksiyon (REST liste). + // No path params: a collection unless it carries singular/self semantics (REST list). return !routeHasSingularSemantics(ep.Route); } -/** Route'un son STATIC segmenti list/findAll/all/search/findMany gibi bir - * koleksiyon-semantigi tasiyor mu? (camelCase/kebab/snake bolunur.) Kelime kumesi - * cardinality.ts'te TEK SOURCE — service.emitter metot adi icin aynisini kullanir. */ +/** Does the route's last STATIC segment carry collection semantics like + * list/findAll/all/search/findMany? (camelCase/kebab/snake are split.) The word + * set is SINGLE-SOURCED in cardinality.ts — service.emitter uses the same one + * for method names. */ function routeHasListSemantics(route: string): boolean { const last = lastLiteralSegment(route); if (!last) return false; return tokensHaveCollectionSemantics(splitWords(last)); } -/** Route'un son STATIC segmenti SINGLE/self bir kaynak mi? (me/self/current/ - * profile/account/health/status/info/ping...) Bunlar path-param olmasa da - * tek bir kayit doner -> koleksiyon SAYILMAZ. */ +/** Is the route's last STATIC segment a SINGULAR/self resource? (me/self/current/ + * profile/account/health/status/info/ping...) These return a single record even + * without path params -> not counted as a collection. */ function routeHasSingularSemantics(route: string): boolean { const last = lastLiteralSegment(route); if (!last) return false; @@ -434,7 +440,7 @@ function routeHasSingularSemantics(route: string): boolean { return SINGULAR_WORDS.has(joined); } -/** Route'un son STATIC (param WITHOUT) segmenti; yoksa undefined. */ +/** The route's last STATIC (non-param) segment; undefined when there is none. */ function lastLiteralSegment(route: string): string | undefined { const segments = route .split("/") @@ -442,11 +448,12 @@ function lastLiteralSegment(route: string): string | undefined { return segments[segments.length - 1]; } -/** AUTH/LOGIN (Finding #8): ResponseDTORef WITHOUT bir login endpoint'i mi? - * (POST + route literal'lerinden biri "login"/"signin"/"authenticate".) Boyle - * bir endpoint void degil tutarli bir token zarfi (AuthResponse) doner. - * EXPORTED: scaffold.emitter ayni kosulu kullanarak current-user.decorator - * dosyasini (AuthResponse'u tutan) emit edip etmeyecegine karar verir. */ +/** AUTH/LOGIN (Finding #8): is this a login endpoint (checked when it has no + * ResponseDTORef)? (POST + one of the route literals is "login"/"signin"/ + * "authenticate".) Such an endpoint returns a consistent token envelope + * (AuthResponse) instead of void. + * EXPORTED: scaffold.emitter uses the same condition to decide whether to emit + * the current-user.decorator file (which holds AuthResponse). */ export function isLoginEndpoint(ep: EndpointProps): boolean { if (ep.HttpMethod !== "POST") return false; const segments = ep.Route.split("/").filter((s) => s.length > 0 && !s.startsWith(":") && !s.startsWith("{")); @@ -458,10 +465,10 @@ export function isLoginEndpoint(ep: EndpointProps): boolean { return false; } -/* ── Isim/route yardimcilari (DETERMINISTIC) ─────────────────────────────── */ +/* ── Name/route helpers (DETERMINISTIC) ─────────────────────────────── */ -/** Metot adi: GET /users/:id -> getUserById; POST /users -> postUser. - * Fiil + route segmentleri (literal -> Pascal; ":param" -> "By Param"). */ +/** Method name: GET /users/:id -> getUserById; POST /users -> postUser. + * Verb + route segments (literal -> Pascal; ":param" -> "By Param"). */ function deriveMethodName(ep: EndpointProps): string { const verb = ep.HttpMethod.toLowerCase(); const segments = ep.Route.split("/").filter((s) => s.length > 0); @@ -482,8 +489,8 @@ function deriveMethodName(ep: EndpointProps): string { return name.length > 0 ? name : verb; } -/** Route segmentindeki ":id" / "{id}" -> ":id" Nest bicimine normalize eder, - * bas/son "/" temizler. */ +/** Normalizes ":id" / "{id}" route segments to the Nest ":id" form and + * trims leading/trailing "/". */ function normalizeRoute(route: string): string { return route .split("/") @@ -494,42 +501,42 @@ function normalizeRoute(route: string): string { .join("/"); } -/** Iki route parcasini "/" ile birlestirir (boslari eler). */ +/** Joins two route parts with "/" (drops empty ones). */ function joinRoutes(a: string, b: string): string { return [a, b].filter((s) => s.length > 0).join("/"); } -/** @Controller route hesabi (DOUBLE-PREFIX FIX). - * Version yoksa -> baseRoute. Version varsa ve baseRoute o version'i bir PATH - * SEGMENTI olarak zaten iceriyorsa (or. base "api/v1/auth", version "v1") -> - * baseRoute oldugu gibi (tekrar onekleme). Aksi halde version onekle. */ +/** @Controller route computation (DOUBLE-PREFIX FIX). + * No Version -> baseRoute. When there is a Version and baseRoute already contains + * it as a PATH SEGMENT (e.g. base "api/v1/auth", version "v1") -> baseRoute as-is + * (don't prefix again). Otherwise prefix the version. */ function computeControllerRoute(rawVersion: string | undefined, baseRoute: string): string { const version = rawVersion ? normalizeRoute(rawVersion) : ""; if (version.length === 0) return baseRoute; const baseSegments = baseRoute.split("/").filter((s) => s.length > 0); const versionSegments = version.split("/").filter((s) => s.length > 0); - // version'in TUM segmentleri baseRoute'ta zaten varsa -> onekleme. + // If ALL of the version's segments are already in baseRoute -> don't prefix. const alreadyHasVersion = versionSegments.every((v) => baseSegments.includes(v)); if (alreadyHasVersion) return baseRoute; return joinRoutes(version, baseRoute); } -/** Metot dekoratorune giden route argumani. Kok ("/" veya bos) -> argumansiz. */ +/** The route argument passed to the method decorator. Root ("/" or empty) -> no argument. */ function methodRouteArg(route: string): string { const norm = normalizeRoute(route); return norm.length > 0 ? JSON.stringify(norm) : ""; } -/** Param adini gecerli TS tanimlayicisina cevirir (camelCase, deterministik). */ +/** Converts a param name to a valid TS identifier (camelCase, deterministic). */ function safeIdent(raw: string): string { const c = camelCase(raw); if (c.length === 0) return "_param"; return /^[0-9]/.test(c) ? `_${c}` : c; } -/** Path/Query param tipini GECERLI TS'e normalize eder (uuid/int/long/datetime - * vb. -> string/number/Date), model.emitter/dto.emitter ile ayni esleme - * (scalarTsType). Bilinmeyen tip oldugu gibi gecer; bos -> "string". */ +/** Normalizes a path/query param type to VALID TS (uuid/int/long/datetime + * etc. -> string/number/Date), same mapping as model.emitter/dto.emitter + * (scalarTsType). An unknown type passes through as-is; empty -> "string". */ function tsType(raw: string): string { return scalarTsType(raw); } @@ -538,5 +545,5 @@ function cap(w: string): string { return w.length === 0 ? w : w[0].toUpperCase() + w.slice(1).toLowerCase(); } -/* EmitterContext'i import etmeden tip yakalamak icin yerel alias (types.ts'ten). */ +/* Local alias to capture the type without importing EmitterContext (from types.ts). */ type EmitterContext = Parameters[1]; diff --git a/apps/server/src/codegen/emitters/nestjs/dto.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/dto.emitter.spec.ts index cffe066..866ff5f 100644 --- a/apps/server/src/codegen/emitters/nestjs/dto.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/dto.emitter.spec.ts @@ -33,7 +33,7 @@ const DTO_ID = "11111111-1111-4111-8111-111111111111"; const ADDRESS_DTO_ID = "33333333-3333-4333-8333-333333333333"; const ROLE_ENUM_ID = "44444444-4444-4444-8444-444444444444"; -/** Ic ice DTO referansi (CreateUserDto.addresses -> AddressDto). */ +/** Nested DTO reference (CreateUserDto.addresses -> AddressDto). */ const ADDRESS_DTO = storedNode( "DTO", { @@ -46,19 +46,19 @@ const ADDRESS_DTO = storedNode( ADDRESS_DTO_ID, ); -/** Enum referansi (CreateUserDto.role -> UserRole). */ +/** Enum reference (CreateUserDto.role -> UserRole). */ const ROLE_ENUM = storedNode( "Enum", { Name: "UserRole", - Description: "User rolu", + Description: "User role", BackingType: "string", Values: [{ Key: "ADMIN" }, { Key: "MEMBER" }], }, ROLE_ENUM_ID, ); -/** Zengin, gercekci DTO: primitif + dogrulama + opsiyonel + dizi + enum + nested. */ +/** Rich, realistic DTO: primitives + validation + optional + array + enum + nested. */ const CREATE_USER_DTO = storedNode( "DTO", { @@ -109,7 +109,7 @@ const CREATE_USER_DTO = storedNode( ); describe("emitDto", () => { - it("zengin DTO — snapshot", () => { + it("rich DTO — snapshot", () => { const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); expect(file).toMatchInlineSnapshot(` @@ -162,20 +162,20 @@ describe("emitDto", () => { `); }); - it("dosya yolu kebab-case /dto altinda", () => { + it("file path is kebab-case under /dto", () => { const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); - // DTO tek basina (tuketen Controller/Service yok) -> "common" feature; dosya - // adi rol son-ekini ("DTO"/"Dto") TEKRARLAMAZ (create-user.dto.ts). + // A standalone DTO (no consuming Controller/Service) -> "common" feature; the file + // name does NOT repeat the role suffix ("DTO"/"Dto") (create-user.dto.ts). expect(file.path).toBe("common/dto/create-user.dto.ts"); expect(file.path).toMatch(/\/dto\/.+\.dto\.ts$/); expect(file.language).toBe("typescript"); }); - it("class-validator + class-transformer import'lari sirali ve dogru", () => { + it("class-validator + class-transformer imports are ordered and correct", () => { const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); - // Paket import'lari goreli import'lardan once gelir. + // Package imports come before relative imports. expect(file.content).toContain('from "class-validator";'); expect(file.content).toContain('import { Type } from "class-transformer";'); const validatorIdx = file.content.indexOf('from "class-validator"'); @@ -184,7 +184,7 @@ describe("emitDto", () => { expect(enumImportIdx).toBeGreaterThan(validatorIdx); }); - it("EnumRef -> @IsEnum + goreli import; NestedDTORef -> @ValidateNested + @Type + import", () => { + it("EnumRef -> @IsEnum + relative import; NestedDTORef -> @ValidateNested + @Type + import", () => { const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); expect(file.content).toContain("@IsEnum(UserRole)"); @@ -205,7 +205,7 @@ describe("emitDto", () => { expect(file.content).toContain("@IsString({ each: true })"); }); - it("DTO govdesi yok -> surgical marker 0; content ends with single newline", () => { + it("DTOs have no method bodies -> 0 surgical markers; content ends with single newline", () => { const ctx = ctxFor(CREATE_USER_DTO, ADDRESS_DTO, ROLE_ENUM); const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); expect(file.surgicalMarkers).toBe(0); @@ -220,22 +220,22 @@ describe("emitDto", () => { expect(a).toBe(b); }); - it("EDGE-CASE: kayip Enum/NestedDTO ref -> throw NONE, import atlanir, TODO birakilir", () => { - // Sadece DTO eklenir; UserRole enum ve AddressDto graph'ta NONE. + it("EDGE-CASE: missing Enum/NestedDTO ref -> no throw, import is skipped, TODO is left", () => { + // Only the DTO is added; the UserRole enum and AddressDto are absent from the graph. const ctx = ctxFor(CREATE_USER_DTO); const [file] = emitDto(ctx.graph.byId(DTO_ID)!, ctx); - // Tip dekoratoru/tip hâlâ yazilir. + // The type decorator/type is still written. expect(file.content).toContain("@IsEnum(UserRole)"); expect(file.content).toContain("@Type(() => AddressDto)"); - // Cozulemeyen import'lar eklenmez. + // Unresolvable imports are not added. expect(file.content).not.toContain('from "../../common/enums/user-role.enum"'); expect(file.content).not.toContain('from "./address.dto"'); - // TODO isaretleri birakilir. + // TODO markers are left behind. expect(file.content).toContain('// TODO(solarch): Enum ref "UserRole" could not be resolved'); expect(file.content).toContain('// TODO(solarch): NestedDTO ref "AddressDto" could not be resolved'); }); - it("EDGE-CASE: bilinmeyen DataType -> tip oldugu gibi, primitif dekorator eklenmez", () => { + it("EDGE-CASE: unknown DataType -> type is kept as-is, no primitive decorator is added", () => { const weird = storedNode( "DTO", { @@ -254,12 +254,13 @@ describe("emitDto", () => { expect(file.content).not.toContain("@IsNumber"); }); - /* ── SELF-REFERENTIAL DTO (tree/ozyinelemeli — CategoryResponse.children) ── - * Audit #5/#28: agac DTO'su temsil edilemiyordu. NestedDTORef DTO'nun KENDISINE - * isaret ederse (children: CategoryResponse[]), tip+@Type uretilir ama sinif KENDI - * dosyasindan import EDILMEZ (zaten kapsamda; self-import = TS hatasi). Bu, agac/ - * ozyinelemeli DTO'lari mumkun kilar (kardinalite ReturnsCollection ile zaten dizi). */ - it("self-referential nested DTO (children) -> Self[] + @Type, kendini import ETMEZ", () => { + /* ── SELF-REFERENTIAL DTO (tree/recursive — CategoryResponse.children) ── + * Audit #5/#28: tree DTOs could not be represented. If NestedDTORef points at the + * DTO ITSELF (children: CategoryResponse[]), the type + @Type are generated but the + * class is NOT imported from its OWN file (it is already in scope; a self-import is + * a TS error). This makes tree/recursive DTOs possible (cardinality is already an + * array via ReturnsCollection). */ + it("self-referential nested DTO (children) -> Self[] + @Type, does NOT import itself", () => { const cat = storedNode( "DTO", { @@ -274,11 +275,11 @@ describe("emitDto", () => { ); const ctx = ctxFor(cat); const [file] = emitDto(ctx.graph.byId(cat.id)!, ctx); - // Ozyinelemeli alan: tip Self[] + @Type(() => Self) + @ValidateNested. + // Recursive field: type Self[] + @Type(() => Self) + @ValidateNested. expect(file.content).toMatch(/children\??:\s*CategoryResponse\[\]/); expect(file.content).toContain("@Type(() => CategoryResponse)"); expect(file.content).toContain("@ValidateNested"); - // KENDINI import ETMEZ (self-import kirik olurdu). + // Does NOT import itself (a self-import would be broken). expect(file.content).not.toMatch(/import \{[^}]*CategoryResponse[^}]*\} from/); }); diff --git a/apps/server/src/codegen/emitters/nestjs/entity-synthesis.spec.ts b/apps/server/src/codegen/emitters/nestjs/entity-synthesis.spec.ts index a578dde..4bee9f5 100644 --- a/apps/server/src/codegen/emitters/nestjs/entity-synthesis.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/entity-synthesis.spec.ts @@ -13,11 +13,11 @@ import type { NodeKind } from "../../../nodes/schemas"; import type { EdgeKind } from "../../../edges/schemas/edge.schema"; /* ──────────────────────────────────────────────────────────────────────── - * entity-synthesis.spec.ts — Table'dan SENTEZLENEN TypeORM entity. + * entity-synthesis.spec.ts — TypeORM entity SYNTHESIZED from a Table. * - * Model'i WITHOUT ama bir Repository tarafindan referans edilen Table icin - * @Entity sinifi uretilir; @InjectRepository/Repository/forFeature tutarli - * olur ve uygulama BOOT BOOTS (Table-only graph BOOT garantisi). + * For a Table that has no Model but is referenced by a Repository, an + * @Entity class is generated; @InjectRepository/Repository/forFeature stay + * consistent and the app BOOTS (Table-only graph boot guarantee). * ──────────────────────────────────────────────────────────────────────── */ let seq = 0; @@ -73,7 +73,7 @@ const imageRepo = () => }); describe("entity-synthesis", () => { - it("entityClassNameForTable: tablo adi tekil-pascal'a cevrilir", () => { + it("entityClassNameForTable: table name is converted to singular PascalCase", () => { const t = imagesTable(); expect(entityClassNameForTable(buildCodeGraph([t], []).byId(t.id)!)).toBe("GeneratedImage"); const users = node("Table", { TableName: "Users", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }] }); @@ -87,14 +87,14 @@ describe("entity-synthesis", () => { expect(synthEntityFilePath(graph.byId(t.id)!, graph)).toBe("image/entities/generated-image.entity.ts"); }); - it("tablesNeedingSyntheticEntity: yalniz repo-referansli + Model'siz Table'lar", () => { + it("tablesNeedingSyntheticEntity: only repo-referenced Tables without a Model", () => { const t = imagesTable(); const repo = imageRepo(); const graph = buildCodeGraph([t, repo], [edge("WRITES", repo, t)]); expect(tablesNeedingSyntheticEntity(graph).map((x) => x.name)).toEqual(["GeneratedImages"]); }); - it("Model'i OLAN Table icin sentez YAPILMAZ (Model entity uretilir)", () => { + it("a Table that HAS a Model is NOT synthesized (the Model entity is generated instead)", () => { const t = imagesTable(); const model = node("Model", { ClassName: "GeneratedImage", Description: "x", TableRef: "GeneratedImages", Properties: [{ Name: "id", Type: "uuid" }], Methods: [] }); const repo = imageRepo(); @@ -102,19 +102,19 @@ describe("entity-synthesis", () => { expect(tablesNeedingSyntheticEntity(graph)).toEqual([]); }); - it("hicbir repository referans etmeyen Table icin sentez YAPILMAZ", () => { + it("a Table not referenced by any repository is NOT synthesized", () => { const t = imagesTable(); const graph = buildCodeGraph([t], []); expect(tablesNeedingSyntheticEntity(graph)).toEqual([]); }); - it("emitSyntheticEntity: @Entity(fiziksel ad) + PK @PrimaryGeneratedColumn + kolonlar", () => { + it("emitSyntheticEntity: @Entity(physical name) + PK @PrimaryGeneratedColumn + columns", () => { const t = imagesTable(); const repo = imageRepo(); const ctx = ctxFor([t, repo], [edge("WRITES", repo, t)]); const [file] = emitSyntheticEntity(ctx.graph.byId(t.id)!, ctx); expect(file.path).toBe("image/entities/generated-image.entity.ts"); - // @Entity adi migration tablo adiyla AYNI (tableSqlName). + // The @Entity name is the SAME as the migration table name (tableSqlName). expect(file.content).toContain('@Entity("generated_images")'); expect(file.content).toContain("export class GeneratedImage {"); expect(file.content).toContain("@PrimaryGeneratedColumn(\"uuid\")"); @@ -125,7 +125,7 @@ describe("entity-synthesis", () => { expect(file.surgicalMarkers).toBe(0); }); - it("DETERMINISM: ayni table iki kez -> byte-identical", () => { + it("DETERMINISM: same table twice -> byte-identical", () => { const t = imagesTable(); const repo = imageRepo(); const ctx = ctxFor([t, repo], [edge("WRITES", repo, t)]); @@ -134,7 +134,7 @@ describe("entity-synthesis", () => { expect(a).toBe(b); }); - it("FK olmayan table -> iliski dekoratoru URETILMEZ (mevcut akis korunur)", () => { + it("table without FKs -> no relation decorators are generated (existing flow preserved)", () => { const t = imagesTable(); const repo = imageRepo(); const ctx = ctxFor([t, repo], [edge("WRITES", repo, t)]); @@ -144,9 +144,9 @@ describe("entity-synthesis", () => { expect(file.content).not.toContain("@JoinColumn"); }); - describe("ILISKI SENTEZI (M2)", () => { - // users <- posts.author_id (FK). Ikisi de Model'siz + repo-referansli -> - // sentetik entity. posts -> @ManyToOne(User), users -> @OneToMany(Post). + describe("RELATION SYNTHESIS (M2)", () => { + // users <- posts.author_id (FK). Both have no Model + are repo-referenced -> + // synthetic entity. posts -> @ManyToOne(User), users -> @OneToMany(Post). const usersTable = () => node("Table", { TableName: "users", @@ -178,34 +178,34 @@ describe("entity-synthesis", () => { return ctxFor([users, posts, usersRepo, postsRepo], []); } - it("FK sahip tarafi -> @ManyToOne + @JoinColumn(fiziksel FK kolonu), eager:false", () => { + it("FK owning side -> @ManyToOne + @JoinColumn(physical FK column), eager:false", () => { const ctx = relCtx(); const posts = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; const [file] = emitSyntheticEntity(posts, ctx); expect(file.content).toContain("@ManyToOne(() => User, { eager: false })"); expect(file.content).toContain('@JoinColumn({ name: "author_id" })'); expect(file.content).toContain("author!: User;"); - // Sentetik entity import'u + typeorm dekorator import'lari. + // Synthetic entity import + typeorm decorator imports. expect(file.content).toContain("import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from \"typeorm\";"); expect(file.content).toContain("entities/user.entity"); expect(file.surgicalMarkers).toBe(0); }); - it("FK ters tarafi -> @OneToMany(() => Post, (post) => post.author), definite-assignment", () => { + it("FK inverse side -> @OneToMany(() => Post, (post) => post.author), definite-assignment", () => { const ctx = relCtx(); const users = ctx.graph.allOf("Table").find((t) => t.name === "users")!; const [file] = emitSyntheticEntity(users, ctx); // author_id FK ON DELETE CASCADE -> aggregate -> @OneToMany cascade: true. expect(file.content).toContain("@OneToMany(() => Post, (post) => post.author, { cascade: true })"); - // TypeORM iliski property'lerinde dizi initializer'i (= []) YASAKLAR - // (InitializedRelationError -> migration/boot patlar). "!" kullanilir. + // TypeORM FORBIDS array initializers (= []) on relation properties + // (InitializedRelationError -> migration/boot blows up). "!" is used instead. expect(file.content).toContain("posts!: Post[];"); expect(file.content).not.toContain("posts: Post[] = [];"); expect(file.content).toContain("import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from \"typeorm\";"); expect(file.content).toContain("entities/post.entity"); }); - it("nullable FK -> opsiyonel @ManyToOne (nullable: true, '?' alan)", () => { + it("nullable FK -> optional @ManyToOne (nullable: true, '?' field)", () => { const ctx = relCtx(true); const posts = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; const [file] = emitSyntheticEntity(posts, ctx); @@ -213,24 +213,24 @@ describe("entity-synthesis", () => { expect(file.content).toContain("author?: User;"); }); - it("FK KAPANISI: repo-referanssiz ama core tablonun FK hedefi -> entity sentezlenir + iliski URETILIR", () => { - // users hicbir repository tarafindan referans EDILMIYOR; ama core (repo-referansli) - // posts ondan FK ile bagli -> FK kapanisi users'i da sentetik entity'ye ceker - // (sema<->ORM kapsami tam: FK iliskisi @ManyToOne(User) cozulebilir). + it("FK CLOSURE: not repo-referenced but FK target of a core table -> entity is synthesized + relation IS generated", () => { + // users is NOT referenced by any repository; but the core (repo-referenced) + // posts table links to it via FK -> the FK closure pulls users into synthesis too + // (full schema<->ORM coverage: the FK relation @ManyToOne(User) can be resolved). const users = usersTable(); const posts = postsTable(); const postsRepo = repoFor("PostsRepository", "posts"); const ctx = ctxFor([users, posts, postsRepo], []); - // users artik sentez kumesinde. + // users is now in the synthesis set. expect(tablesNeedingSyntheticEntity(ctx.graph).map((t) => t.name).sort()).toEqual(["posts", "users"]); const postsNode = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; const [file] = emitSyntheticEntity(postsNode, ctx); - // Karsi taraf (users) artik sentetik entity -> @ManyToOne(User) uretilir. + // The other side (users) is now a synthetic entity -> @ManyToOne(User) is generated. expect(file.content).toContain("@ManyToOne(() => User, { eager: false })"); expect(file.content).toContain('@JoinColumn({ name: "author_id" })'); }); - it("SAFLIK: hedef tablonun Model'i VAR -> iliski URETILMEZ (Model entity ayri)", () => { + it("PURITY: target table HAS a Model -> relation is NOT generated (the Model entity is separate)", () => { const users = usersTable(); const posts = postsTable(); const userModel = node("Model", { ClassName: "User", Description: "x", TableRef: "users", Properties: [{ Name: "id", Type: "uuid" }], Methods: [] }); @@ -242,7 +242,7 @@ describe("entity-synthesis", () => { expect(file.content).not.toContain("@ManyToOne"); }); - it("composite (cok-kolon) FK -> iliski URETILMEZ (tek-kolon esleme yapilamaz)", () => { + it("composite (multi-column) FK -> relation is NOT generated (no single-column mapping possible)", () => { const parent = node("Table", { TableName: "parents", Description: "x", @@ -271,7 +271,7 @@ describe("entity-synthesis", () => { expect(file.content).not.toContain("@ManyToOne"); }); - it("DETERMINISM: iliskili graph -> byte-identical iki kez", () => { + it("DETERMINISM: graph with relations -> byte-identical twice", () => { const ctx = relCtx(); const posts = ctx.graph.allOf("Table").find((t) => t.name === "posts")!; const a = emitSyntheticEntity(posts, ctx)[0].content; @@ -280,7 +280,7 @@ describe("entity-synthesis", () => { }); }); - describe("TIP MAP (#1): ENUM/JSON + skaler tipler STRICT TS uretir", () => { + describe("TYPE MAP (#1): ENUM/JSON + scalar types produce STRICT TS", () => { const enumNode = () => node("Enum", { Name: "OrderStatus", @@ -288,7 +288,7 @@ describe("entity-synthesis", () => { BackingType: "string", Values: [{ Key: "PENDING" }, { Key: "PAID" }], }); - // VARCHAR/UUID/INT/BIGINT/DECIMAL/FLOAT/BOOLEAN/DATETIME/DATE/JSON/ENUM hepsi. + // VARCHAR/UUID/INT/BIGINT/DECIMAL/FLOAT/BOOLEAN/DATETIME/DATE/JSON/ENUM — all of them. const richTable = () => node("Table", { TableName: "orders", @@ -309,22 +309,23 @@ describe("entity-synthesis", () => { }); const repo = () => node("Repository", { RepositoryName: "OrderRepository", Description: "x", EntityReference: "orders", IsCached: false, CustomQueries: [] }); - it("ENUM kolon -> TS tipi generated enum + @Column({ type:'varchar' }) (#56: native enum NOT, migration CHECK ile)", () => { + it("ENUM column -> TS type is the generated enum + @Column({ type:'varchar' }) (#56: no native enum, migration uses CHECK)", () => { const ctx = ctxFor([richTable(), repo(), enumNode()], []); const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; const [file] = emitSyntheticEntity(orders, ctx); - // ESKI HATA: `status!: ENUM;` (gecersiz TS). + // OLD BUG: `status!: ENUM;` (invalid TS). expect(file.content).not.toContain("ENUM;"); - // TS tipi yine generated enum sinifi; ama @Column VARCHAR -> entity↔migration - // tutarli (migration de VARCHAR + CHECK). native Postgres enum URETILMEZ. + // The TS type is still the generated enum class; but @Column is VARCHAR -> + // entity↔migration stay consistent (the migration is also VARCHAR + CHECK). + // No native Postgres enum is generated. expect(file.content).toMatch(/@Column\(\{ type: "varchar" \}\)\s*\n\s*status!: OrderStatus;/); expect(file.content).toContain('import { OrderStatus } from "../../common/enums/order-status.enum";'); expect(file.content).not.toContain('type: "enum"'); expect(file.content).not.toContain("enum: OrderStatus"); }); - it("EnumRef cozulemez -> guvenli string + @Column({ type:'varchar' }) (throw NONE)", () => { - const ctx = ctxFor([richTable(), repo()], []); // Enum node NONE + it("unresolvable EnumRef -> safe string + @Column({ type:'varchar' }) (no throw)", () => { + const ctx = ctxFor([richTable(), repo()], []); // no Enum node const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; const [file] = emitSyntheticEntity(orders, ctx); expect(file.content).toContain("status!: string;"); @@ -332,7 +333,7 @@ describe("entity-synthesis", () => { expect(file.content).not.toContain("OrderStatus"); }); - it("JSON kolon -> Record + @Column({ type:'jsonb' }) (ESKI HATA: `JSON;`)", () => { + it("JSON column -> Record + @Column({ type:'jsonb' }) (OLD BUG: `JSON;`)", () => { const ctx = ctxFor([richTable(), repo(), enumNode()], []); const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; const [file] = emitSyntheticEntity(orders, ctx); @@ -341,7 +342,7 @@ describe("entity-synthesis", () => { expect(file.content).toContain('@Column({ type: "jsonb", nullable: true })'); }); - it("skaler tipler dogru TS + TypeORM tipine eslenir", () => { + it("scalar types map to the correct TS + TypeORM types", () => { const ctx = ctxFor([richTable(), repo(), enumNode()], []); const orders = ctx.graph.allOf("Table").find((t) => t.name === "orders")!; const [file] = emitSyntheticEntity(orders, ctx); @@ -357,7 +358,7 @@ describe("entity-synthesis", () => { expect(file.content).toContain('@Column({ type: "double precision" })'); expect(file.content).toContain("rate!: number;"); expect(file.content).toContain('@Column({ type: "boolean" })'); - // TS uye adi idiomatik camelCase (is_paid→isPaid); DB kolonu snake_case kalir (SnakeNamingStrategy). + // TS member name is idiomatic camelCase (is_paid→isPaid); the DB column stays snake_case (SnakeNamingStrategy). expect(file.content).toContain("isPaid!: boolean;"); expect(file.content).toContain('@Column({ type: "timestamp" })'); expect(file.content).toContain("createdAt!: Date;"); @@ -366,10 +367,10 @@ describe("entity-synthesis", () => { }); }); - describe("SEMA<->ORM KAPSAMI (#9): join/ara tablolar da sentezlenir", () => { - // orders <- order_items -> products. order_items NE bir repo gosterir NE de - // Model'i var; ama core tablolara (orders/products) FK verir -> FK kapanisi - // onu da sentetik entity'ye ceker (migration var, entity de olur). + describe("SCHEMA<->ORM COVERAGE (#9): join/junction tables are synthesized too", () => { + // orders <- order_items -> products. order_items is NEITHER referenced by a repo + // NOR has a Model; but it has FKs to core tables (orders/products) -> the FK + // closure pulls it into synthesis too (the migration exists, so the entity does as well). const tbl = (name: string, cols: Record[], fks: Record[] = []) => node("Table", { TableName: name, Description: name, Columns: cols, ForeignKeys: fks }); const pkCol = { Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }; @@ -387,16 +388,16 @@ describe("entity-synthesis", () => { { Columns: ["product_id"], ReferencesTable: "products", ReferencesColumns: ["id"], OnDelete: "RESTRICT", OnUpdate: "NO_ACTION" }, ], ); - // Yalniz orders + products bir repo gosterir; order_items GOSTERMEZ. + // Only orders + products are referenced by a repo; order_items is NOT. return ctxFor([orders, products, orderItems, repo("OrderRepository", "orders"), repo("ProductRepository", "products")], []); } - it("order_items (join, repo-referanssiz) entity SENTEZLENIR", () => { + it("order_items (join table, not repo-referenced) entity IS synthesized", () => { const names = tablesNeedingSyntheticEntity(joinCtx().graph).map((t) => t.name).sort(); expect(names).toEqual(["order_items", "orders", "products"]); }); - it("order_items entity -> her FK icin @ManyToOne(core entity) + @JoinColumn", () => { + it("order_items entity -> @ManyToOne(core entity) + @JoinColumn for each FK", () => { const ctx = joinCtx(); const oi = ctx.graph.allOf("Table").find((t) => t.name === "order_items")!; const [file] = emitSyntheticEntity(oi, ctx); @@ -417,11 +418,11 @@ describe("entity-synthesis", () => { expect(file.content).not.toContain("orderItems: OrderItem[] = [];"); }); - it("products entity -> @OneToMany(OrderItem) cascade NONE (order_items->products FK RESTRICT)", () => { + it("products entity -> @OneToMany(OrderItem) without cascade (order_items->products FK RESTRICT)", () => { const ctx = joinCtx(); const products = ctx.graph.allOf("Table").find((t) => t.name === "products")!; const [file] = emitSyntheticEntity(products, ctx); - // order_items->products FK RESTRICT -> bagimsiz iliski -> cascade NONE. + // order_items->products FK RESTRICT -> independent relation -> no cascade. expect(file.content).toContain("@OneToMany(() => OrderItem, (orderItem) => orderItem.product)"); expect(file.content).not.toContain("orderItem.product, { cascade"); }); diff --git a/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.spec.ts index 0d4b814..1636f11 100644 --- a/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/event-handler.emitter.spec.ts @@ -42,7 +42,7 @@ function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; } -/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +/* ── IDs ────────────────────────────────────────────────────────────────── */ const HANDLER = "10000000-0000-4000-8000-000000000001"; const QUEUE = "10000000-0000-4000-8000-000000000002"; const SVC = "10000000-0000-4000-8000-000000000003"; @@ -51,7 +51,7 @@ const CACHE = "10000000-0000-4000-8000-000000000004"; /* ── Node fixtures ──────────────────────────────────────────────────── */ const imageQueue = node("MessageQueue", QUEUE, { QueueName: "ImageJobsQueue", - Description: "Gorsel isleme kuyrugu", + Description: "Image processing queue", Type: "Queue", Provider: "RabbitMQ", MessageFormat: "ImageJobDto", @@ -78,10 +78,10 @@ const imageCache = node("Cache", CACHE, { CacheName: "ImageCache", }); -// Kuyruk-tabanli handler: ImageJobsQueue'yu dinler, ImageService'i cagirir. +// Queue-based handler: listens to ImageJobsQueue, calls ImageService. const queueHandler = node("EventHandler", HANDLER, { HandlerName: "ImageJobEventHandler", - Description: "Gorsel isleme job'unu tuketir", + Description: "Consumes the image processing job", EventName: "image.job.created", IsAsync: true, QueueRef: "ImageJobsQueue", @@ -89,7 +89,7 @@ const queueHandler = node("EventHandler", HANDLER, { DeadLetterQueue: "ImageJobsDLQ", }); -// Olay-tabanli handler: kuyruk NONE, sadece bir olay dinler. +// Event-based handler: no queue, it only listens to an event. const eventHandler = node("EventHandler", HANDLER, { HandlerName: "OrderCreatedEventHandler", Description: "Order olusturuldugunda tetiklenir", @@ -98,7 +98,7 @@ const eventHandler = node("EventHandler", HANDLER, { }); describe("emitEventHandler", () => { - it("kuyruk-tabanli (BullMQ @Processor) — snapshot", () => { + it("queue-based (BullMQ @Processor) — snapshot", () => { const ctx = ctxFrom( [queueHandler, imageQueue, imageService, imageCache], [ @@ -116,7 +116,7 @@ describe("emitEventHandler", () => { import { ImageService } from "../image/image.service"; import { ImageCache } from "./image.cache"; - /** Gorsel isleme job'unu tuketir */ + /** Consumes the image processing job */ /** retry: maxRetries=3, delaySeconds=10 */ /** dead-letter-queue: ImageJobsDLQ */ @Processor("ImageJobsQueue") @@ -130,7 +130,7 @@ describe("emitEventHandler", () => { async process(job: Job): Promise { // @solarch:surgical id=10000000-0000-4000-8000-000000000001#process - // Gorsel isleme job'unu tuketir + // Consumes the image processing job // Triggering queue: ImageJobsQueue. // deps: this.imageCache, this.imageService throw new Error("NOT_IMPLEMENTED: ImageJobEventHandler.process"); @@ -144,7 +144,7 @@ describe("emitEventHandler", () => { `); }); - it("olay-tabanli (@OnEvent) — snapshot", () => { + it("event-based (@OnEvent) — snapshot", () => { const ctx = ctxFrom( [eventHandler, imageService], [edge("e-calls-svc", "CALLS", HANDLER, SVC)], @@ -180,7 +180,7 @@ describe("emitEventHandler", () => { `); }); - it("QueueRef property'si (SUBSCRIBES edge yoksa) da kuyruk-tabanli kola duser", () => { + it("the QueueRef property (without a SUBSCRIBES edge) also takes the queue-based branch", () => { const ctx = ctxFrom([queueHandler, imageQueue], []); const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); expect(file.content).toContain('@Processor("ImageJobsQueue")'); @@ -188,7 +188,7 @@ describe("emitEventHandler", () => { expect(file.content).toContain("import { Processor, WorkerHost } from \"@nestjs/bullmq\";"); }); - it("kuyruk cozulemezse (QueueRef + edge yok) olay-tabanli kola duser", () => { + it("falls back to the event-based branch when the queue cannot be resolved (QueueRef + no edge)", () => { const orphanQueueHandler = node("EventHandler", HANDLER, { HandlerName: "GhostHandler", Description: "Kayip kuyruk referansi", @@ -203,7 +203,7 @@ describe("emitEventHandler", () => { expect(file.content).toContain("@Injectable()"); }); - it("CALLS hedefi handle/process metodunda surgical marker + NOT_IMPLEMENTED", () => { + it("CALLS targets show up in the handle/process method's surgical marker + NOT_IMPLEMENTED", () => { const ctx = ctxFrom([eventHandler, imageService], [edge("e", "CALLS", HANDLER, SVC)]); const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); expect(file.surgicalMarkers).toBe(1); @@ -225,22 +225,22 @@ describe("emitEventHandler", () => { expect(syncFile.content).not.toContain("async handleOrderCreated"); }); - it("dosya yolu filePathFor ile (.handler.ts, rol son-eki tekrarsiz)", () => { - // Handler ImageService'i cagirir -> feature-inference onu "image" feature'ina - // yerlestirir. baseNameOf("OrderCreatedEventHandler") -> "OrderCreated" -> - // dosya kok adi "order-created", rol son-eki ("EventHandler") TEKRARLANMAZ. + it("file path via filePathFor (.handler.ts, role suffix not repeated)", () => { + // The handler calls ImageService -> feature inference places it in the "image" + // feature. baseNameOf("OrderCreatedEventHandler") -> "OrderCreated" -> + // file base name "order-created", the role suffix ("EventHandler") is NOT repeated. const ctx = ctxFrom([eventHandler, imageService], [edge("e", "CALLS", HANDLER, SVC)]); const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); expect(file.path).toBe("image/order-created.handler.ts"); }); - it("dep yoksa constructor uretilmez (bos DI)", () => { + it("no deps -> no constructor is generated (empty DI)", () => { const ctx = ctxFrom([eventHandler], []); const [file] = emitEventHandler(ctx.graph.byId(HANDLER)!, ctx); expect(file.content).not.toContain("constructor("); }); - it("DEDUP: ayni Service'e iki CALLS edge -> tek DI alani", () => { + it("DEDUP: two CALLS edges to the same Service -> single DI field", () => { const ctx = ctxFrom( [eventHandler, imageService], [edge("e1", "CALLS", HANDLER, SVC), edge("e2", "CALLS", HANDLER, SVC)], @@ -269,7 +269,7 @@ describe("emitEventHandler", () => { expect(a).toBe(b); }); - it("edge-case: hic edge/kuyruk yok — throw etmez, minimal @OnEvent handler", () => { + it("edge-case: no edges/queue at all — does not throw, minimal @OnEvent handler", () => { const ctx = ctxFrom([eventHandler], []); let file: { content: string; surgicalMarkers: number } | undefined; expect(() => { diff --git a/apps/server/src/codegen/emitters/nestjs/external-service.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/external-service.emitter.spec.ts index ce3e797..42d4ab4 100644 --- a/apps/server/src/codegen/emitters/nestjs/external-service.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/external-service.emitter.spec.ts @@ -67,7 +67,7 @@ function ctxFor(nodes: StoredNode[], edges: StoredEdge[] = []): { ctx: EmitterCo return { ctx: { graph, target: "nestjs" } }; } -/** Tipik dis servis: Stripe-benzeri Bearer auth + iki endpoint. */ +/** Typical external service: Stripe-like Bearer auth + two endpoints. */ const STRIPE = { ServiceName: "StripeClient", Description: "Stripe odeme API istemcisi", @@ -81,7 +81,7 @@ const STRIPE = { }; describe("emitExternalService", () => { - it("@Injectable HTTP client snapshot (gercek kod, stub degil)", () => { + it("@Injectable HTTP client snapshot (real code, not a stub)", () => { const node = extNode(STRIPE); const { ctx } = ctxFor([node]); const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); @@ -137,7 +137,7 @@ describe("emitExternalService", () => { `); }); - it("sinif adi gercek (pascalCase), Stub eki NONE", () => { + it("class name is the real one (PascalCase), no Stub suffix", () => { const node = extNode(STRIPE); const { ctx } = ctxFor([node]); const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); @@ -146,7 +146,7 @@ describe("emitExternalService", () => { expect(file.content).not.toContain("Stub"); }); - it("HttpService + ConfigService inject edilir, HttpModule importu beklenir", () => { + it("HttpService + ConfigService are injected; HttpModule import is expected", () => { const node = extNode(STRIPE); const { ctx } = ctxFor([node]); const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); @@ -156,17 +156,17 @@ describe("emitExternalService", () => { expect(file.content).toContain("private readonly config: ConfigService,"); }); - it("dosya yolu feature-aware (filePathFor -> /.client.ts)", () => { + it("file path is feature-aware (filePathFor -> /.client.ts)", () => { const node = extNode(STRIPE); const { ctx } = ctxFor([node]); const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); - // baseNameOf("StripeClient") -> "Stripe"; referrer'siz standalone node - // feature-inference'ta "common"a duser (bir Service CALLS etmiyor). + // baseNameOf("StripeClient") -> "Stripe"; a standalone node without referrers + // falls into "common" in feature inference (no Service CALLS it). expect(file.path).toBe("common/stripe.client.ts"); expect(file.path.endsWith(".client.ts")).toBe(true); }); - it("feature atamasi: bir Service CALLS ederse o feature klasorune yazilir", () => { + it("feature assignment: when a Service CALLS it, it is written to that feature folder", () => { const ext = extNode({ ServiceName: "StableDiffusionApi", Description: "Gorsel uretimi", @@ -183,13 +183,13 @@ describe("emitExternalService", () => { }); const { ctx } = ctxFor([ext, svc], [callsEdge(svc.id, ext.id)]); const [file] = emitExternalService(ctx.graph.byId(ext.id)!, ctx); - // baseNameOf("StableDiffusionApi") -> "StableDiffusion"; CALLS eden servis - // "image-generation" feature'inda -> ext de ayni feature'a duser. + // baseNameOf("StableDiffusionApi") -> "StableDiffusion"; the calling service + // is in the "image-generation" feature -> the external service falls into the same feature. expect(file.path).toBe("image-generation/stable-diffusion.client.ts"); expect(file.content).toContain("export class StableDiffusionApi {"); }); - it("Endpoint yoksa tek generic request metodu uretir", () => { + it("no Endpoints -> generates a single generic request method", () => { const node = extNode({ ServiceName: "MailService", Description: "E-posta gonderimi", @@ -204,12 +204,12 @@ describe("emitExternalService", () => { "async request(method: string, path: string, payload?: unknown): Promise {", ); expect(file.content).toContain("NOT_IMPLEMENTED: MailService.request"); - // baseNameOf("MailService") -> "Mail" (Service eki duser); standalone -> - // common feature klasoru. + // baseNameOf("MailService") -> "Mail" (the Service suffix is dropped); standalone -> + // common feature folder. expect(file.path).toBe("common/mail.client.ts"); }); - it("Endpoint metotlari Name'e gore deterministik sirali (Zebra once mi sonra mi)", () => { + it("endpoint methods are deterministically sorted by Name (Alpha before Zebra)", () => { const node = extNode({ ServiceName: "MultiApi", Description: "cok uclu", @@ -232,7 +232,7 @@ describe("emitExternalService", () => { expect(idxMango).toBeLessThan(idxZebra); }); - it("HTTP fiili HttpService metoduna eslenir (this.http.)", () => { + it("the HTTP verb maps to the HttpService method (this.http.)", () => { const node = extNode({ ServiceName: "VerbApi", Description: "fiiller", @@ -250,7 +250,7 @@ describe("emitExternalService", () => { expect(file.content).toContain("this.http.delete"); }); - it("AuthType=None -> authHeaders helper'i URETILMEZ", () => { + it("AuthType=None -> the authHeaders helper is NOT generated", () => { const node = extNode({ ServiceName: "OpenApi", Description: "auth yok", @@ -264,7 +264,7 @@ describe("emitExternalService", () => { expect(file.content).not.toContain("authHeaders"); }); - it("API_Key auth -> ENV binding ile API_KEY, RAW secret koda gomulmez", () => { + it("API_Key auth -> API_KEY via ENV binding, no raw secret embedded in code", () => { const node = extNode({ ServiceName: "KeyedApi", Description: "api key", @@ -277,26 +277,26 @@ describe("emitExternalService", () => { const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); expect(file.content).toContain("private authHeaders(): Record {"); expect(file.content).toContain("KEYED_API_API_KEY"); - // BaseURL bir literal olarak koda gomulmemeli (ENV binding ile okunur). + // The BaseURL must not be embedded as a literal (it is read via ENV binding). expect(file.content).not.toContain("https://k.example.com"); }); - it("BaseURL/Timeout ConfigService env-var binding ile okunur (literal gomulmez)", () => { + it("BaseURL/Timeout are read via ConfigService env-var binding (no literal embedded)", () => { const node = extNode(STRIPE); const { ctx } = ctxFor([node]); const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); expect(file.content).toContain('this.config.get("STRIPE_CLIENT_BASE_URL")'); expect(file.content).toContain('this.config.get("STRIPE_CLIENT_TIMEOUT_SECONDS")'); expect(file.content).not.toContain("https://api.stripe.com"); - // Fallback olarak schema TimeoutSeconds kullanilir. + // The schema's TimeoutSeconds is used as the fallback. expect(file.content).toContain("?? 30) * 1000"); }); - it("surgicalMarkers govde gerektiren her metotta sayilir", () => { + it("surgicalMarkers counts every method that requires a body", () => { const node = extNode(STRIPE); const { ctx } = ctxFor([node]); const [file] = emitExternalService(ctx.graph.byId(node.id)!, ctx); - // 2 endpoint + 1 authHeaders = 3 marker. + // 2 endpoints + 1 authHeaders = 3 markers. expect(file.surgicalMarkers).toBe(3); expect(file.content).toContain("@solarch:surgical"); }); diff --git a/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.spec.ts index e25bb24..c53f2ce 100644 --- a/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/message-queue.emitter.spec.ts @@ -42,7 +42,7 @@ function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; } -/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +/* ── IDs ────────────────────────────────────────────────────────────────── */ const MQ = "30000000-0000-4000-8000-000000000001"; const DTO_JOB = "30000000-0000-4000-8000-000000000002"; const SVC = "30000000-0000-4000-8000-000000000003"; @@ -65,7 +65,7 @@ const imageQueue = node("MessageQueue", MQ, { DeadLetterQueue: "image-dlq", }); -// Kuyrugu kullanan bir Service -> feature inference kuyrugu "image" feature'ina ceker. +// A Service that uses the queue -> feature inference pulls the queue into the "image" feature. const imageService = node("Service", SVC, { ServiceName: "ImageService", Description: "Image business logic", @@ -75,7 +75,7 @@ const imageService = node("Service", SVC, { }); describe("emitMessageQueue", () => { - it("tam producer — snapshot (BullMQ Queue DI, queue sabiti, payload DTO, surgical publish)", () => { + it("full producer — snapshot (BullMQ Queue DI, queue constant, payload DTO, surgical publish)", () => { const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); expect(file).toMatchInlineSnapshot(` @@ -115,7 +115,7 @@ describe("emitMessageQueue", () => { `); }); - it("BullMQ producer iskeleti: @Injectable, @InjectQueue, Queue tip importu", () => { + it("BullMQ producer skeleton: @Injectable, @InjectQueue, Queue type import", () => { const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); expect(file.content).toContain('import { Injectable } from "@nestjs/common";'); @@ -125,33 +125,33 @@ describe("emitMessageQueue", () => { expect(file.content).toContain("export class ImageMessageQueue {"); }); - it("queue adi sabiti @InjectQueue + (Wire) registerQueue icin TEK SOURCE", () => { + it("the queue name constant is the SINGLE SOURCE for @InjectQueue + (Wire) registerQueue", () => { const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); expect(file.content).toContain('export const IMAGE_MESSAGE_QUEUE = "ImageMessageQueue";'); expect(file.content).toContain("@InjectQueue(IMAGE_MESSAGE_QUEUE) private readonly queue: Queue,"); }); - it("publish GERCEK govde tasir (queue.add) + marker + codegen-dolu damgasi (fill sayimi tutarli)", () => { + it("publish carries a REAL body (queue.add) + marker + codegen-filled stamp (fill count stays consistent)", () => { const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); expect(file.content).toContain("async publish(payload: ImageJobDto): Promise {"); expect(file.content).toContain('await this.queue.add("publish", payload);'); expect(file.content).toContain(`// @solarch:surgical id=${MQ}#publish`); expect(file.content).toContain("// deps: this.queue"); - // Govde codegen tarafindan tam uretildi → @solarch:filled by=codegen damgasi. + // The body was fully generated by codegen → @solarch:filled by=codegen stamp. expect(file.content).toContain("// @solarch:filled by=codegen"); - // "Doldurulacak" SAYILMAZ (codegen-dolu) → 0; aksi halde 71 gosterilir 69 doldurulur. + // NOT counted as "to be filled" (codegen-filled) → 0; otherwise 71 would be shown but only 69 filled. expect(file.surgicalMarkers).toBe(0); }); - it("MessageFormat -> DTO import edilir (payload tipi DTO sinifi)", () => { + it("MessageFormat -> the DTO is imported (payload type is the DTO class)", () => { const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); expect(file.content).toMatch(/import type \{ ImageJobDto \} from ".*image-job\.dto"/); }); - it("dosya yolu feature/.queue.ts (rol son-eki TEKRARSIZ)", () => { + it("file path is feature/.queue.ts (role suffix not repeated)", () => { const ctx = ctxFrom([imageQueue, imageJobDto, imageService], [edge("e-mq", "CALLS", SVC, MQ)]); const [file] = emitMessageQueue(ctx.graph.byId(MQ)!, ctx); // baseNameOf: "ImageMessageQueue" -> "Image" -> image.queue.ts. @@ -173,7 +173,7 @@ describe("emitMessageQueue", () => { expect(a).toBe(b); }); - it("'Queue' son-ekli ad da calisir (ImageJobsQueue -> ImageJobs base)", () => { + it("a 'Queue'-suffixed name also works (ImageJobsQueue -> ImageJobs base)", () => { const q = node("MessageQueue", MQ, { QueueName: "ImageJobsQueue", Description: "Jobs", @@ -189,8 +189,8 @@ describe("emitMessageQueue", () => { expect(file.content).toContain("export class ImageJobsQueue {"); }); - /* ── EDGE-CASE: kayip/bos MessageFormat — throw etmez, unknown'a duser ─── */ - it("edge-case: cozulemeyen MessageFormat -> payload unknown, import yok, throw yok", () => { + /* ── EDGE-CASE: missing/empty MessageFormat — does not throw, falls back to unknown ─── */ + it("edge-case: unresolvable MessageFormat -> payload unknown, no import, no throw", () => { const q = node("MessageQueue", MQ, { QueueName: "OrphanQueue", Description: "Yetim kuyruk", @@ -205,12 +205,12 @@ describe("emitMessageQueue", () => { }).not.toThrow(); expect(file!.content).toContain("async publish(payload: unknown): Promise {"); expect(file!.content).not.toContain("import type { GhostDto }"); - // queue.add govdesi yine GERCEK + tek surgical marker. + // The queue.add body is still REAL + a single surgical marker. expect(file!.content).toContain('await this.queue.add("publish", payload);'); - expect(file!.surgicalMarkers).toBe(0); // codegen-dolu (publish tam uretildi) → doldurulacak sayisi 0 + expect(file!.surgicalMarkers).toBe(0); // codegen-filled (publish fully generated) → 0 methods left to fill }); - it("edge-case: MessageFormat hic yok -> payload unknown", () => { + it("edge-case: MessageFormat entirely absent -> payload unknown", () => { const q = node("MessageQueue", MQ, { QueueName: "BareQueue", Description: "Bare", diff --git a/apps/server/src/codegen/emitters/nestjs/middleware.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/middleware.emitter.spec.ts index 0383403..79512f2 100644 --- a/apps/server/src/codegen/emitters/nestjs/middleware.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/middleware.emitter.spec.ts @@ -73,7 +73,7 @@ const AUTH_SERVICE = node("Service", SVC_ID, { }); describe("emitMiddleware", () => { - it("ROUTES_TO ile feature'a dusen tam middleware — snapshot", () => { + it("full middleware assigned to a feature via ROUTES_TO — snapshot", () => { // Middleware -ROUTES_TO-> AuthController -CALLS-> AuthService => feature "auth". const ctx = ctxFor( [AUTH_MIDDLEWARE, AUTH_CONTROLLER, AUTH_SERVICE], @@ -109,7 +109,7 @@ describe("emitMiddleware", () => { `); }); - it("@Injectable() implements NestMiddleware sinifi + use(req,res,next) imzasi", () => { + it("@Injectable() class implementing NestMiddleware + use(req,res,next) signature", () => { const ctx = ctxFor([AUTH_MIDDLEWARE]); const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); expect(file.content).toContain("@Injectable()"); @@ -117,14 +117,14 @@ describe("emitMiddleware", () => { expect(file.content).toContain("use(req: Request, res: Response, next: NextFunction): void {"); }); - it("NestMiddleware + express tipleri import edilir", () => { + it("NestMiddleware + express types are imported", () => { const ctx = ctxFor([AUTH_MIDDLEWARE]); const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); expect(file.content).toContain('import { Injectable, type NestMiddleware } from "@nestjs/common";'); expect(file.content).toContain('import type { NextFunction, Request, Response } from "express";'); }); - it("use() govdesinde surgical marker + NOT_IMPLEMENTED var", () => { + it("the use() body has a surgical marker + NOT_IMPLEMENTED", () => { const ctx = ctxFor([AUTH_MIDDLEWARE]); const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); expect(file.surgicalMarkers).toBe(1); @@ -132,14 +132,14 @@ describe("emitMiddleware", () => { expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: AuthMiddleware.use");'); }); - it("feature yoksa (cross-cutting / baglantisiz) common/ altina iner", () => { - // Hic edge yok -> referrerFeatures bos -> pickFeature null -> "common". + it("without a feature (cross-cutting / unconnected) it lands under common/", () => { + // No edges at all -> referrerFeatures empty -> pickFeature null -> "common". const ctx = ctxFor([AUTH_MIDDLEWARE]); const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); expect(file.path).toBe("common/auth.middleware.ts"); }); - it("filePathFor kullanir: feature'a dustugunde /.middleware.ts", () => { + it("uses filePathFor: /.middleware.ts when assigned to a feature", () => { const ctx = ctxFor( [AUTH_MIDDLEWARE, AUTH_CONTROLLER, AUTH_SERVICE], [ @@ -149,11 +149,11 @@ describe("emitMiddleware", () => { ); const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); expect(file.path).toBe("auth/auth.middleware.ts"); - // Dosya kok adi baseNameOf(AuthMiddleware) = "Auth" -> kebab "auth". + // File base name baseNameOf(AuthMiddleware) = "Auth" -> kebab "auth". expect(file.path).not.toContain("auth-middleware.middleware"); }); - it("ROUTES_TO Controller adi uygulanis ipucu olarak markera girer", () => { + it("the ROUTES_TO Controller name goes into the marker as a wiring hint", () => { const ctx = ctxFor( [AUTH_MIDDLEWARE, AUTH_CONTROLLER, AUTH_SERVICE], [ @@ -167,16 +167,16 @@ describe("emitMiddleware", () => { ); }); - it("Config: yalniz Key'ler markera girer, gizli Value ASLA gomulmez", () => { + it("Config: only the Keys go into the marker, secret Values are NEVER embedded", () => { const ctx = ctxFor([AUTH_MIDDLEWARE]); const [file] = emitMiddleware(ctx.graph.byId(MW_ID)!, ctx); expect(file.content).toContain("// Config keys: tokenHeader, secretEnv."); - // Degerler (authorization / JWT_SECRET) icerige SIZMAMALI. + // The values (authorization / JWT_SECRET) must NOT leak into the content. expect(file.content).not.toContain("authorization"); expect(file.content).not.toContain("JWT_SECRET"); }); - it("Global AppliesTo + Config'siz minimal middleware", () => { + it("minimal middleware with Global AppliesTo + no Config", () => { const minimal = node("Middleware", MW_ID, { MiddlewareName: "LoggingMiddleware", Description: "Logs requests", @@ -189,9 +189,9 @@ describe("emitMiddleware", () => { expect(file.content).toContain("export class LoggingMiddleware implements NestMiddleware {"); expect(file.content).toContain("// Scope: applied to all routes (Global)."); expect(file.content).toContain("// Execution order (ExecutionOrder): 5."); - // MiddlewareType yoksa tur oneki olmadan "middleware use() ..." satiri. + // Without MiddlewareType, the "middleware: implement the use() ..." line has no type prefix. expect(file.content).toContain("// middleware: implement the use() body."); - // Config bos -> "Config keys" satiri yok. + // Empty Config -> no "Config keys" line. expect(file.content).not.toContain("Config keys"); }); @@ -215,8 +215,8 @@ describe("emitMiddleware", () => { expect(a).toBe(b); }); - it("kind adiyla bitmeyen / bos'a dusmeyen ad korunur (rol eki adin TAMAMIYSA)", () => { - // "Middleware" -> baseNameOf adin tamami eki -> orijinal ad korunur. + it("the original name is kept when the role suffix IS the entire name", () => { + // "Middleware" -> in baseNameOf the suffix is the whole name -> the original name is kept. const odd = node("Middleware", MW_ID, { MiddlewareName: "Middleware", Description: "kenar durum", diff --git a/apps/server/src/codegen/emitters/nestjs/model.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/model.emitter.spec.ts index 8167e0b..ed82c10 100644 --- a/apps/server/src/codegen/emitters/nestjs/model.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/model.emitter.spec.ts @@ -48,7 +48,7 @@ const TABLE_ID = "cccccccc-3333-4333-8333-333333333333"; const USER_MODEL = { ClassName: "User", - Description: "Uygulama kullanicisi", + Description: "Application user", TableRef: "users", Properties: [ { Name: "id", Type: "uuid" }, @@ -86,7 +86,7 @@ const POST_MODEL = { }; describe("emitModel", () => { - it("tam model (PK + kolonlar + iliski + method) — snapshot", () => { + it("full model (PK + columns + relation + method) — snapshot", () => { const user = modelNode(USER_MODEL, USER_ID); const post = modelNode(POST_MODEL, POST_ID); const table = tableNode({ TableName: "users", Columns: [] }, TABLE_ID); @@ -96,7 +96,7 @@ describe("emitModel", () => { { "content": "import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; - /** Uygulama kullanicisi */ + /** Application user */ @Entity("users") export class User { @PrimaryGeneratedColumn("uuid") @@ -126,7 +126,7 @@ describe("emitModel", () => { `); }); - it("@Entity adi bagli Table node'unun fiziksel adindan gelir (tekrar cogullanmaz)", () => { + it("the @Entity name comes from the linked Table node's physical name (not pluralized again)", () => { const model = modelNode( { ClassName: "Category", Description: "kategori", TableRef: "categories", Properties: [{ Name: "id", Type: "uuid" }] }, USER_ID, @@ -137,19 +137,19 @@ describe("emitModel", () => { expect(file.content).toContain('@Entity("categories")'); }); - it("@Entity adi, tekil/PascalCase TableName icin table.emitter ile AYNI (ayrismaz)", () => { - // Tekil/PascalCase TableName: eski hata burada ortaya cikardi (entity 'user', - // migration 'users'). Artik ikisi de tableSqlName -> 'user' (birebir ayni). + it("for a singular/PascalCase TableName the @Entity name is the SAME as table.emitter's (no divergence)", () => { + // Singular/PascalCase TableName: the old bug showed up here (entity 'user', + // migration 'users'). Now both use tableSqlName -> 'user' (exactly the same). const table = tableNode( { TableName: "User", - Description: "kullanici", + Description: "user", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true }], }, TABLE_ID, ); const model = modelNode( - { ClassName: "User", Description: "kullanici", TableRef: "User", Properties: [{ Name: "id", Type: "uuid" }] }, + { ClassName: "User", Description: "user", TableRef: "User", Properties: [{ Name: "id", Type: "uuid" }] }, USER_ID, ); const { ctx } = ctxFor([model, table]); @@ -157,30 +157,30 @@ describe("emitModel", () => { const entityFile = emitModel(ctx.graph.byId(USER_ID)!, ctx)[0]; const tableFile = emitTable(ctx.graph.byId(TABLE_ID)!, ctx)[0]; - // table.emitter'in CREATE TABLE adi. + // table.emitter's CREATE TABLE name. const createMatch = tableFile.content.match(/CREATE TABLE "([^"]+)"/); expect(createMatch).not.toBeNull(); const physicalName = createMatch![1]; - expect(physicalName).toBe("user"); // cogullanmaz + expect(physicalName).toBe("user"); // not pluralized - // model.emitter'in @Entity argumani ile BIREBIR ayni. + // EXACTLY the same as model.emitter's @Entity argument. expect(entityFile.content).toContain(`@Entity("${physicalName}")`); }); - it("TableRef yoksa @Entity ClassName'den TURETILIR (pluralizeSnake — acik tablo yok)", () => { + it("without TableRef the @Entity name is DERIVED from ClassName (pluralizeSnake — no explicit table)", () => { const model = modelNode( - { ClassName: "OrderItem", Description: "kalem", Properties: [{ Name: "id", Type: "uuid" }] }, + { ClassName: "OrderItem", Description: "line item", Properties: [{ Name: "id", Type: "uuid" }] }, USER_ID, ); const { ctx } = ctxFor([model]); const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); - // Acik TableName yok -> class adindan tablo adi turetilir ("order_items"), - // bu da boyle bir Table eklendiginde dogal/cogul TableName ile tutarlidir. + // No explicit TableName -> the table name is derived from the class name ("order_items"), + // which stays consistent with the natural/plural TableName if such a Table is added later. expect(file.content).toContain('@Entity("order_items")'); expect(file.path).toBe("order-item/entities/order-item.entity.ts"); }); - it("PK 'id' yoksa ilk property primary key olur (uuid degil)", () => { + it("without an 'id' PK the first property becomes the primary key (not uuid)", () => { const model = modelNode( { ClassName: "Token", Description: "token", Properties: [{ Name: "value", Type: "string" }] }, USER_ID, @@ -191,7 +191,7 @@ describe("emitModel", () => { expect(file.content).toContain("value!: string;"); }); - it("EDGE-CASE: kayip iliski referansi -> TODO satiri, throw NONE, import NONE", () => { + it("EDGE-CASE: missing relation reference -> TODO line, no throw, no import", () => { const model = modelNode( { ClassName: "Comment", @@ -212,27 +212,27 @@ describe("emitModel", () => { expect(() => emitModel(ctx.graph.byId(USER_ID)!, ctx)).not.toThrow(); const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); expect(file.content).toContain("// TODO: relation \"author\""); - // Kayip ref -> import NONE, dekorator satiri NONE (yalniz TODO yorumu). + // Missing ref -> no import, no decorator line (only the TODO comment). expect(file.content).not.toContain("ghost.entity"); expect(file.content).not.toContain("@ManyToOne"); }); - it("TypeORM import sirali; OneToMany inverse-side yoksa TODO'ya duser (decorator/import yok)", () => { + it("TypeORM import is sorted; OneToMany without an inverse side falls back to a TODO (no decorator/import)", () => { const user = modelNode(USER_MODEL, USER_ID); const post = modelNode(POST_MODEL, POST_ID); const { ctx } = ctxFor([user, post]); const [file] = emitModel(ctx.graph.byId(USER_ID)!, ctx); - // OneToMany'de inverseSide ZORUNLU (TS2554); semada InverseSide yok → iliski TODO, - // OneToMany ve iliski entity'si (Post) import EDILMEZ. + // OneToMany REQUIRES an inverseSide (TS2554); the schema has no InverseSide → the relation + // becomes a TODO, and neither OneToMany nor the related entity (Post) is imported. expect(file.content).toMatch(/import \{ Column, Entity, PrimaryGeneratedColumn \} from "typeorm";/); - expect(file.content).not.toContain("@OneToMany"); // decorator emit edilmez (TODO yorumu haric) + expect(file.content).not.toContain("@OneToMany"); // the decorator is not emitted (aside from the TODO comment) expect(file.content).not.toContain("import { Post }"); expect(file.content).toContain('// TODO: relation "posts" (OneToMany -> Post)'); }); - it("OneToMany ters-yon: iliskili Model'de geri-donen @ManyToOne varsa inverse uretilir", () => { - // User.posts (OneToMany -> Post) + Post.author (ManyToOne -> User) → karsilikli. - // Beklenen: @OneToMany(() => Post, (post) => post.author) + Post import + Post[] tipi. + it("OneToMany inverse side: generated when the related Model has a reciprocal @ManyToOne", () => { + // User.posts (OneToMany -> Post) + Post.author (ManyToOne -> User) → reciprocal. + // Expected: @OneToMany(() => Post, (post) => post.author) + Post import + Post[] type. const user = modelNode(USER_MODEL, USER_ID); const postWithAuthor = modelNode( { @@ -252,11 +252,11 @@ describe("emitModel", () => { expect(file.content).toContain("@OneToMany(() => Post, (post) => post.author)"); expect(file.content).toContain("posts!: Post[];"); expect(file.content).toContain("import { Post }"); - expect(file.content).toContain("OneToMany"); // typeorm import'una eklendi + expect(file.content).toContain("OneToMany"); // added to the typeorm import expect(file.content).not.toContain('// TODO: relation "posts"'); }); - it("PascalCase property + .NET Guid → camelCase TS uye + string + @Column uuid", () => { + it("PascalCase property + .NET Guid → camelCase TS member + string + @Column uuid", () => { const id = "aaaa1111-2222-3333-4444-555566667777"; const model = modelNode( { @@ -277,7 +277,7 @@ describe("emitModel", () => { expect(file.content).toContain('type: "uuid"'); // @Column Guid→uuid }); - it("method govdesi surgical marker + NOT_IMPLEMENTED icerir", () => { + it("method bodies contain a surgical marker + NOT_IMPLEMENTED", () => { const user = modelNode(USER_MODEL, USER_ID); const post = modelNode(POST_MODEL, POST_ID); const { ctx } = ctxFor([user, post]); @@ -305,10 +305,10 @@ describe("emitModel", () => { expect(a).toBe(b); }); - /* ── ENUM property -> @Column({ type: "varchar" }) + TS tipi generated enum ─── - * #56: native Postgres enum NOT (migration de VARCHAR + CHECK uretir -> tutarli). - * TS alan tipi yine generated enum sinifi (status!: OrderStatus) ve import edilir. */ - it("ENUM property -> @Column({ type: 'varchar' }), TS tipi generated enum + import", () => { + /* ── ENUM property -> @Column({ type: "varchar" }) + TS type is the generated enum ─── + * #56: no native Postgres enum (the migration also produces VARCHAR + CHECK -> consistent). + * The TS field type is still the generated enum class (status!: OrderStatus) and is imported. */ + it("ENUM property -> @Column({ type: 'varchar' }), TS type is the generated enum + import", () => { const orderStatus: StoredNode = { id: "e1e1e1e1-1111-4111-8111-e1e1e1e1e1e1", type: "Enum", @@ -339,7 +339,7 @@ describe("emitModel", () => { ); const { ctx } = ctxFor([order, orderStatus]); const [file] = emitModel(ctx.graph.byId(order.id)!, ctx); - // @Column VARCHAR (native enum NOT), TS tipi yine generated enum + import. + // @Column is VARCHAR (no native enum); the TS type is still the generated enum + import. expect(file.content).toMatch(/@Column\(\{ type: "varchar" \}\)\s*\n\s*status!: OrderStatus;/); expect(file.content).toContain('import { OrderStatus }'); expect(file.content).not.toContain('type: "enum"'); diff --git a/apps/server/src/codegen/emitters/nestjs/model.emitter.ts b/apps/server/src/codegen/emitters/nestjs/model.emitter.ts index 85e4d66..296f12a 100644 --- a/apps/server/src/codegen/emitters/nestjs/model.emitter.ts +++ b/apps/server/src/codegen/emitters/nestjs/model.emitter.ts @@ -18,23 +18,23 @@ import { columnOrmType, sqlTypeToTs } from "./sql-type-map"; /* ──────────────────────────────────────────────────────────────────────── * model.emitter.ts — ModelNode -> TypeORM entity. * - * Sozlesme (enum.emitter.ts kanonik referansiyla birebir): - * - named `export const emitModel: NodeEmitter`; default export NONE. - * - SAF fonksiyon: (node, ctx) -> GeneratedFile[]. I/O yok, throw yok. - * - Yol her zaman filePathFor(node, ctx.graph) ile. - * - import'lar ImportCollector ile (elle "import ..." YASAK). - * - DETERMINISTIC: Properties/Methods verildigi sirada; ref cozumu ctx uzerinden. - * - surgicalMarkers countSurgicalMarkers(content) ile sayilir. - * - Icerik tek "\n" ile biter. + * Contract (exactly matching the enum.emitter.ts canonical reference): + * - named `export const emitModel: NodeEmitter`; no default export. + * - PURE function: (node, ctx) -> GeneratedFile[]. No I/O, no throws. + * - Path always via filePathFor(node, ctx.graph). + * - Imports via ImportCollector (hand-written "import ..." is forbidden). + * - DETERMINISTIC: Properties/Methods in the given order; ref resolution through ctx. + * - surgicalMarkers counted via countSurgicalMarkers(content). + * - Content ends with a single "\n". * * ModelNode -> /entities/.entity.ts. - * @Entity() — TableRef varsa o Table'in fiziksel adi (tableSqlName, - * table.emitter ile TEK SOURCE), yoksa ClassName'in pluralize snake hali. - * PK: "id" adli property -> @PrimaryGeneratedColumn("uuid"); yoksa ilk property. + * @Entity() — with a TableRef, that Table's physical name (tableSqlName, + * single source with table.emitter), otherwise the ClassName pluralized to snake case. + * PK: the property named "id" -> @PrimaryGeneratedColumn("uuid"); otherwise the first property. * RelationType + RelatedModelRef -> @OneToOne/@OneToMany/@ManyToOne/@ManyToMany * (()=>Related) + import (ctx.resolveRef("Model", RelatedModelRef)). - * Ref cozulemezse iliski satiri atlanir + // TODO yorumu (ASLA throw). - * Methods varsa imza + surgical govde (NOT_IMPLEMENTED). + * When the ref does not resolve, the relation line is skipped + a // TODO comment (NEVER throws). + * When there are Methods: signature + surgical body (NOT_IMPLEMENTED). * ──────────────────────────────────────────────────────────────────────── */ type ModelProps = ReturnType>; @@ -54,13 +54,13 @@ export const emitModel: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => const fromPath = filePathFor(node, ctx.graph); const imports = new ImportCollector(); - // TypeORM cekirdek dekoratorleri — Entity + Column her zaman gerekli. + // TypeORM core decorators — Entity + Column are always needed. imports.add("Column", "typeorm"); imports.add("Entity", "typeorm"); const tableName = resolveTableName(props, node, ctx); - // PK secimi: "id" adli property oncelik; yoksa ilk property. + // PK selection: the property named "id" takes priority; otherwise the first property. const pkProperty = pickPrimaryKey(props.Properties); const lines: string[] = []; @@ -79,7 +79,7 @@ export const emitModel: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => memberBlocks.push(renderMethod(m, className, node)); } - // Uyeler arasinda bir bos satir (deterministik). + // One blank line between members (deterministic). lines.push(memberBlocks.join("\n\n")); lines.push("}"); @@ -95,34 +95,34 @@ export const emitModel: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => return [file]; }; -/** @Entity tablosu adi. TEK SOURCE: tableSqlName — table.emitter'in - * `CREATE TABLE` adiyla BIREBIR ayni (aksi halde entity var olmayan bir tabloya - * baglanir). Deterministik. - * - TableRef cozulurse -> tableSqlName(table.name) (table emitter ile ayni). - * - TableRef var ama cozulmezse -> ham ref'i fiziksel ad say (tableSqlName). - * - TableRef yoksa -> ClassName'den tablo adi TURET (pluralizeSnake): acik - * TableName olmadigi icin "User" -> "users", "OrderItem" -> "order_items". - * Bu, boyle bir Table node'u eklendiginde table.emitter'in uretecegi adla - * tutarlidir (kullanici tabloyu dogal/cogul TableName ile adlandirir). */ +/** The @Entity table name. SINGLE SOURCE: tableSqlName — EXACTLY the same as + * table.emitter's `CREATE TABLE` name (otherwise the entity would bind to a + * nonexistent table). Deterministic. + * - TableRef resolves -> tableSqlName(table.name) (same as the table emitter). + * - TableRef present but unresolvable -> treat the raw ref as the physical name (tableSqlName). + * - No TableRef -> DERIVE the table name from the ClassName (pluralizeSnake): + * with no explicit TableName, "User" -> "users", "OrderItem" -> "order_items". + * This is consistent with the name table.emitter would produce if such a Table + * node were added (users name tables with a natural/plural TableName). */ function resolveTableName(props: ModelProps, node: CodeNode, ctx: Parameters[1]): string { if (props.TableRef) { const table = ctx.graph.resolveRef("Table", props.TableRef); if (table) return tableSqlName(table.name); - // Ref cozulemedi: yine de niyeti koru (ham ref'i fiziksel ad say). + // The ref did not resolve: still honor the intent (treat the raw ref as the physical name). return tableSqlName(props.TableRef); } - // Acik tablo yok -> class adindan tablo adi turet (cogullanir). + // No explicit table -> derive the table name from the class name (pluralized). return pluralizeSnake(node.name); } -/** "id" adli property oncelik; yoksa ilk property (varsa). */ +/** The property named "id" takes priority; otherwise the first property (if any). */ function pickPrimaryKey(properties: ModelProperty[]): ModelProperty | null { const byId = properties.find((p) => p.Name.toLowerCase() === "id"); if (byId) return byId; return properties.length > 0 ? properties[0] : null; } -/** Tek bir property -> dekoratorlu alan (iliski ise @OneToMany... + import). */ +/** A single property -> a decorated field (for relations, @OneToMany... + import). */ function renderProperty( p: ModelProperty, isPrimaryKey: boolean, @@ -132,7 +132,7 @@ function renderProperty( imports: ImportCollector, fromPath: string, ): string | null { - // Iliski property'si. + // Relation property. if (p.RelationType && p.RelatedModelRef) { return renderRelation(p, className, node, ctx, imports, fromPath); } @@ -140,7 +140,7 @@ function renderProperty( const out: string[] = []; if (isPrimaryKey) { imports.add("PrimaryGeneratedColumn", "typeorm"); - // id alani uuid varsayimi; aksi halde siradan PK uretici. + // The id field is assumed uuid; otherwise a plain PK generator. const isUuid = p.Name.toLowerCase() === "id"; out.push(` @PrimaryGeneratedColumn(${isUuid ? '"uuid"' : ""})`); out.push(` ${fieldDeclaration(p, ctx)}`); @@ -152,7 +152,7 @@ function renderProperty( return out.join("\n"); } -/** Iliski dekoratoru + import + alan bildirimi. Ref cozulemezse TODO ile atla. */ +/** Relation decorator + import + field declaration. When the ref does not resolve, skip with a TODO. */ function renderRelation( p: ModelProperty, className: string, @@ -166,15 +166,16 @@ function renderRelation( const related = ctx.graph.resolveRef("Model", p.RelatedModelRef as string); if (!related) { - // Kayip ref: iliskiyi koy, ama type-safe import yok -> TODO + atla. + // Missing ref: note the relation, but no type-safe import -> TODO + skip. return ` // TODO: relation "${tsPropName(p.Name)}" (${p.RelationType} -> ${p.RelatedModelRef}) — reference could not be resolved`; } const relatedClass = pascalCase(related.name); - // TypeORM OneToMany'de inverseSide ZORUNLU (tek-arg @OneToMany TS2554 verir). - // PropertySchema'da InverseSide alani yok → iliskili Model'de BU Model'e geri-donen - // @ManyToOne'i bul ve `(r) => r.` ters-yonunu URET. Karsilikli ManyToOne yoksa - // cikarsanamaz → TODO birak. OneToOne/ManyToMany'de inverseSide opsiyonel → dokunma. + // In TypeORM, OneToMany REQUIRES the inverseSide (a single-arg @OneToMany gives TS2554). + // PropertySchema has no InverseSide field → find the @ManyToOne on the related Model + // pointing back at THIS Model and GENERATE the `(r) => r.` inverse side. Without + // a reciprocal ManyToOne it cannot be inferred → leave a TODO. For OneToOne/ManyToMany + // the inverseSide is optional → leave untouched. if (decorator === "OneToMany") { const inverse = findInverseManyToOne(related, node, ctx); if (!inverse) { @@ -194,7 +195,7 @@ function renderRelation( return out.join("\n"); } imports.add(decorator, "typeorm"); - // Iliski tipini import et (kendi kendine iliski ise import gerekmez). + // Import the relation type (a self-relation needs no import). if (related.id !== node.id) { imports.add(relatedClass, importPathOf(relativeImportPath(fromPath, filePathFor(related, ctx.graph)))); } @@ -203,7 +204,7 @@ function renderRelation( ? `${relatedClass}[]` : relatedClass; const optional = p.IsNullable ? "?" : ""; - // Zorunlu iliski alanlari da definite-assignment "!" alir (strict:true). + // Required relation fields also get the definite-assignment "!" (strict:true). const assertion = optional ? "" : "!"; const out: string[] = []; @@ -212,10 +213,11 @@ function renderRelation( return out.join("\n"); } -/** OneToMany ters-yonu: iliskili Model'in property'lerinde, BU Model'e (owner) - * geri-donen @ManyToOne'i ara. TypeORM `@OneToMany(() => R, r => r.)` - * ister; karsilikli ManyToOne yoksa ters-yon cikarsanamaz → null (cagiran TODO birakir). - * Deterministik: yalniz graf ref cozumu, I/O yok. */ +/** OneToMany inverse side: search the related Model's properties for the + * @ManyToOne pointing back at THIS Model (the owner). TypeORM requires + * `@OneToMany(() => R, r => r.)`; without a reciprocal ManyToOne the + * inverse side cannot be inferred → null (the caller leaves a TODO). + * Deterministic: graph ref resolution only, no I/O. */ function findInverseManyToOne( related: CodeNode, owner: CodeNode, @@ -225,7 +227,7 @@ function findInverseManyToOne( try { relatedProps = propsOf<"Model">(related); } catch { - return null; // related bir Model degilse (sentezlenmis/Table) ters-yon okunamaz + return null; // when related is not a Model (synthesized/Table) the inverse side cannot be read } for (const rp of relatedProps.Properties ?? []) { if (rp.RelationType !== "ManyToOne" || !rp.RelatedModelRef) continue; @@ -235,8 +237,8 @@ function findInverseManyToOne( return null; } -/** @Column({ ... }) secenekleri (deterministik anahtar sirasi). columnOrmType - * (sql-type-map TEK SOURCE) ile entity/Table ile tutarli fiziksel tip. */ +/** @Column({ ... }) options (deterministic key order). columnOrmType + * (single source: sql-type-map) keeps the physical type consistent with the entity/Table. */ function columnOptions( p: ModelProperty, ctx: Parameters[1], @@ -244,10 +246,11 @@ function columnOptions( fromPath: string, ): string { const parts: string[] = []; - // Enum-tipli kolon (#56): Type bir Enum node'una cozulurse @Column VARCHAR olur - // (native Postgres enum NOT) -> migration de VARCHAR + CHECK uretir, TUTARLI. - // cls yine import edilir cunku TS alan tipi (fieldDeclaration) generated enum sinifidir; - // DB-seviyesi deger kisiti migration'daki CHECK constraint'tedir. + // Enum-typed column (#56): when Type resolves to an Enum node, @Column becomes + // VARCHAR (not a native Postgres enum) -> the migration also produces VARCHAR + + // CHECK, CONSISTENT. cls is still imported because the TS field type + // (fieldDeclaration) is the generated enum class; the DB-level value constraint + // lives in the migration's CHECK constraint. const enumNode = ctx.graph.resolveRef("Enum", p.Type); if (enumNode) { const cls = pascalCase(enumNode.name); @@ -260,22 +263,22 @@ function columnOptions( return `{ ${parts.join(", ")} }`; } -/** TypeScript alan bildirimi: "name: Type;" (nullable -> "?"). - * Zorunlu (initializer'siz) alanlar definite-assignment "!" alir; strict:true - * (strictPropertyInitialization) altinda TS2564 vermeden derlenir — TypeORM - * standardi. Opsiyonel "?" alanlar dokunulmaz. */ +/** TypeScript field declaration: "name: Type;" (nullable -> "?"). + * Required (initializer-less) fields get the definite-assignment "!"; compiles + * without TS2564 under strict:true (strictPropertyInitialization) — the TypeORM + * standard. Optional "?" fields are left untouched. */ function fieldDeclaration(p: ModelProperty, ctx: Parameters[1]): string { const optional = p.IsNullable ? "?" : ""; const assertion = optional ? "" : "!"; - // Enum-tipli alan: Type bir Enum node'una cozulurse o sinifi TS tipi yap - // (import @Column adiminda eklendi). Aksi halde serbest string oldugu gibi gecer. + // Enum-typed field: when Type resolves to an Enum node, make that class the TS + // type (the import was added in the @Column step). Otherwise the free-form string passes as-is. const enumNode = ctx.graph.resolveRef("Enum", p.Type); const base = enumNode ? pascalCase(enumNode.name) : sqlTypeToTs(p.Type, false); const tsType = p.IsCollection ? `${base}[]` : base; return `${tsPropName(p.Name)}${optional}${assertion}: ${tsType};`; } -/** Bir method -> imza + surgical govde (NOT_IMPLEMENTED). */ +/** A method -> signature + surgical body (NOT_IMPLEMENTED). */ function renderMethod(m: ModelMethod, className: string, node: CodeNode): string { const visibility = m.Visibility && m.Visibility !== "public" ? `${m.Visibility} ` : ""; const staticKw = m.IsStatic ? "static " : ""; diff --git a/apps/server/src/codegen/emitters/nestjs/module.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/module.emitter.spec.ts index 5fa2143..9f24294 100644 --- a/apps/server/src/codegen/emitters/nestjs/module.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/module.emitter.spec.ts @@ -8,12 +8,12 @@ import type { NodeKind } from "../../../nodes/schemas"; import type { EdgeKind } from "../../../edges/schemas/edge.schema"; /* ──────────────────────────────────────────────────────────────────────── - * module.emitter.spec.ts — FEATURE-MODULE SENTEZI. + * module.emitter.spec.ts — FEATURE-MODULE SYNTHESIS. * - * Yeni API: emitFeatureModule(feature, ctx). Girdi artik ham Module node NOT, - * ir.ts feature-inference'in urettigi bir `Feature` tanimidir. Module node - * OLMASA bile her cikarilmis feature icin bir /.module.ts - * sentezlenir; app.module bunlari import eder -> DI tam, uygulama BOOT BOOTS. + * New API: emitFeatureModule(feature, ctx). The input is no longer a raw Module + * node but a `Feature` definition produced by ir.ts feature inference. Even + * WITHOUT a Module node, a /.module.ts is synthesized for + * every inferred feature; app.module imports them -> DI is complete, the app boots. * ──────────────────────────────────────────────────────────────────────── */ /* ── Fixture helpers ──────────────────────────────────────────────── */ @@ -59,10 +59,10 @@ function featureBySlug(graph: CodeGraph, slug: string): Feature { return f; } -/* ── Gercekci "users" feature fixture'i (Module node NONE -> sentez) ───────── +/* ── Realistic "users" feature fixture (no Module node -> synthesis) ───────── * Controller -CALLS-> Service -CALLS-> Repository -WRITES-> Model(+Table). - * Feature-inference: tek "users" feature; controller/service/repository/entity - * hepsi bu feature'a atanir. */ + * Feature inference: a single "users" feature; controller/service/repository/entity + * are all assigned to this feature. */ function usersFixture() { const userModel = node("Model", { ClassName: "User", @@ -85,7 +85,7 @@ function usersFixture() { }); const usersService = node("Service", { ServiceName: "UsersService", - Description: "User is mantigi", + Description: "User business logic", IsTransactionScoped: false, Methods: [{ MethodName: "findAll", ReturnType: "User[]", IsAsync: true }], Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], @@ -110,7 +110,7 @@ function usersFixture() { } describe("emitFeatureModule", () => { - it("tam users modulu (Module node NONE -> sentez) — snapshot", () => { + it("full users module (no Module node -> synthesis) — snapshot", () => { const fx = usersFixture(); const ctx = ctxFor(fx.nodes, fx.edges); const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); @@ -138,26 +138,26 @@ describe("emitFeatureModule", () => { `); }); - it("dosya yolu /.module.ts (feature basina TEK module)", () => { + it("file path is /.module.ts (ONE module per feature)", () => { const fx = usersFixture(); const ctx = ctxFor(fx.nodes, fx.edges); const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); expect(file.path).toBe("users/users.module.ts"); }); - it("@Module dekoratoru + DI: controllers/providers + TypeOrmModule.forFeature", () => { + it("@Module decorator + DI: controllers/providers + TypeOrmModule.forFeature", () => { const fx = usersFixture(); const ctx = ctxFor(fx.nodes, fx.edges); const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); expect(file.content).toContain("@Module({"); expect(file.content).toContain("controllers: [UsersController],"); - // providers = service'ler + repository'ler (DI tam; repository kayitli). + // providers = services + repositories (DI is complete; the repository is registered). expect(file.content).toContain("providers: [UsersService, UserRepository],"); expect(file.content).toContain("imports: [TypeOrmModule.forFeature([User])],"); expect(file.content).toContain("export class UsersModule {}"); }); - it("import cozumleme: @nestjs/common + @nestjs/typeorm + feature-ici goreli importlar", () => { + it("import resolution: @nestjs/common + @nestjs/typeorm + intra-feature relative imports", () => { const fx = usersFixture(); const ctx = ctxFor(fx.nodes, fx.edges); const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); @@ -166,14 +166,14 @@ describe("emitFeatureModule", () => { expect(file.content).toContain('import { UsersService } from "./users.service";'); expect(file.content).toContain('import { UserRepository } from "./user.repository";'); expect(file.content).toContain('import { User } from "./entities/user.entity";'); - // Paketler once, goreli sonra (import siralamasi). + // Packages first, relative imports after (import ordering). const pkgIdx = file.content.indexOf("@nestjs/common"); const relIdx = file.content.indexOf("./users.service"); expect(pkgIdx).toBeGreaterThanOrEqual(0); expect(pkgIdx).toBeLessThan(relIdx); }); - it("content ends with single newline, surgical marker yok", () => { + it("content ends with single newline, no surgical markers", () => { const fx = usersFixture(); const ctx = ctxFor(fx.nodes, fx.edges); const [file] = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx); @@ -182,7 +182,7 @@ describe("emitFeatureModule", () => { expect(file.surgicalMarkers).toBe(0); }); - it("DETERMINISM: ayni feature iki kez -> byte-identical", () => { + it("DETERMINISM: same feature twice -> byte-identical", () => { const fx = usersFixture(); const ctx = ctxFor(fx.nodes, fx.edges); const a = emitFeatureModule(featureBySlug(ctx.graph, "users"), ctx)[0].content; @@ -190,11 +190,11 @@ describe("emitFeatureModule", () => { expect(a).toBe(b); }); - /* ── CROSS-FEATURE: bir feature baska feature'in service'ini cagirirsa ──── */ - it("cross-feature bagimlilik: dependsOn modulu import + kaynak feature service'i export eder", () => { - // image feature ImageService -CALLS-> AuthService (auth feature). Beklenen: - // - auth modulu AuthService'i EXPORT eder (baska feature kullaniyor). - // - image modulu AuthModule'u IMPORT eder (dependsOn=[auth]). + /* ── CROSS-FEATURE: when one feature calls another feature's service ────── */ + it("cross-feature dependency: the dependsOn module is imported + the source feature exports its service", () => { + // The image feature's ImageService -CALLS-> AuthService (auth feature). Expected: + // - the auth module EXPORTS AuthService (another feature uses it). + // - the image module IMPORTS AuthModule (dependsOn=[auth]). const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "Kimlik uclari", @@ -203,7 +203,7 @@ describe("emitFeatureModule", () => { }); const authSvc = node("Service", { ServiceName: "AuthService", - Description: "Kimlik mantigi", + Description: "Identity logic", Dependencies: [], Methods: [], }); @@ -215,7 +215,7 @@ describe("emitFeatureModule", () => { }); const imageSvc = node("Service", { ServiceName: "ImageService", - Description: "Gorsel mantigi", + Description: "Image logic", Dependencies: [], Methods: [], }); @@ -234,11 +234,11 @@ describe("emitFeatureModule", () => { expect(imageFile.content).toContain('import { AuthModule } from "../auth/auth.module";'); }); - /* ── KARSILIKLI (circular) import: geri-kenar forwardRef ile emit edilir ── */ - it("karsilikli cross-feature: geri-kenar forwardRef(() => X) ile emit edilir (boot circular yok)", () => { - // auth <-> image karsilikli CALLS. ir.ts geri-kenari ((to,from) en kucuk = - // image -> auth) forwardRef ile isaretler -> image modulu AuthModule'u - // forwardRef(() => AuthModule) ile import BOOTS (kenar KORUNUR); auth duz import BOOTS. + /* ── MUTUAL (circular) import: the back-edge is emitted with forwardRef ── */ + it("mutual cross-feature: the back-edge is emitted with forwardRef(() => X) (no circular boot failure)", () => { + // auth <-> image call each other. ir.ts marks the back-edge (smallest (to,from) = + // image -> auth) with forwardRef -> the image module imports AuthModule via + // forwardRef(() => AuthModule) (the edge is PRESERVED); auth uses a plain import. const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); const imageCtrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); @@ -247,31 +247,31 @@ describe("emitFeatureModule", () => { edge("CALLS", authCtrl.id, authSvc.id), edge("CALLS", imageCtrl.id, imageSvc.id), edge("CALLS", imageSvc.id, authSvc.id), // image -> auth - edge("CALLS", authSvc.id, imageSvc.id), // auth -> image (karsilikli) + edge("CALLS", authSvc.id, imageSvc.id), // auth -> image (mutual) ]; const ctx = ctxFor([authCtrl, authSvc, imageCtrl, imageSvc], edges); const authFile = emitFeatureModule(featureBySlug(ctx.graph, "auth"), ctx)[0]; const imageFile = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx)[0]; - // Eager yon (auth -> image) duz import KORUNUR (forwardRef NONE bu yonde). + // The eager direction (auth -> image) KEEPS its plain import (no forwardRef this way). expect(authFile.content).toContain("imports: [ImageModule],"); expect(authFile.content).toContain('import { ImageModule } from "../image/image.module";'); expect(authFile.content).not.toContain("forwardRef"); - // Geri-kenar (image -> auth) forwardRef ile emit edilir; KENAR KORUNUR (provider import'u kaybolmaz). + // The back-edge (image -> auth) is emitted with forwardRef; the EDGE IS PRESERVED (the provider import is not lost). expect(imageFile.content).toContain("imports: [forwardRef(() => AuthModule)],"); expect(imageFile.content).toContain('import { Module, forwardRef } from "@nestjs/common";'); expect(imageFile.content).toContain('import { AuthModule } from "../auth/auth.module";'); - // DI yine saglam: iki servis de export edilir. + // DI stays sound: both services are exported. expect(authFile.content).toContain("exports: [AuthService],"); expect(imageFile.content).toContain("exports: [ImageService],"); }); - /* ── Acik Module node feature'i tohumlarsa Description KORUNUR ──────────── */ - it("acik Module node varsa: feature slug'i tohumlar + Description korunur", () => { + /* ── When an explicit Module node seeds the feature, its Description is KEPT ── */ + it("with an explicit Module node: it seeds the feature slug + the Description is kept", () => { const mod = node("Module", { ModuleName: "AuthModule", - Description: "Kimlik dogrulama modulu", + Description: "Authentication module", StrictBoundaries: true, ExposedServices: ["AuthService"], Dependencies: [], @@ -284,7 +284,7 @@ describe("emitFeatureModule", () => { }); const svc = node("Service", { ServiceName: "AuthService", - Description: "Kimlik mantigi", + Description: "Identity logic", Dependencies: [], Methods: [], }); @@ -293,17 +293,17 @@ describe("emitFeatureModule", () => { const feature = featureBySlug(ctx.graph, "auth"); expect(feature.module?.id).toBe(mod.id); const [file] = emitFeatureModule(feature, ctx); - // Module.Description -> dosya basi yorumu (sentez varsayilan metni NOT). - expect(file.content).toContain("/** Kimlik dogrulama modulu */"); + // Module.Description -> file header comment (not the default synthesis text). + expect(file.content).toContain("/** Authentication module */"); expect(file.content).toContain("export class AuthModule {}"); expect(file.path).toBe("auth/auth.module.ts"); }); - /* ── CROSS-FEATURE Service->Repository: owner modul Repository'yi EXPORT eder ── */ - it("cross-feature Service->Repository: owner modul Repository'yi EXPORT eder, tuketici dependsOn", () => { - // image feature ImageGenerationService -CALLS-> UserRepository (auth feature). - // Beklenen: AuthModule UserRepository'yi EXPORT eder (NestJS'te export edilmeyen - // provider modul-disi gorunmez -> bootta DI hatasi); ImageModule AuthModule import. + /* ── CROSS-FEATURE Service->Repository: the owner module EXPORTS the Repository ── */ + it("cross-feature Service->Repository: the owner module EXPORTS the Repository, the consumer dependsOn it", () => { + // The image feature's ImageGenerationService -CALLS-> UserRepository (auth feature). + // Expected: AuthModule EXPORTS UserRepository (in NestJS an unexported provider is + // invisible outside its module -> DI error at boot); ImageModule imports AuthModule. const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], Methods: [] }); const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "User", CustomQueries: [] }); @@ -318,20 +318,20 @@ describe("emitFeatureModule", () => { ]; const ctx = ctxFor([authCtrl, authSvc, userRepo, userModel, imageCtrl, imageSvc], edges); - // UserRepository hangi feature'a dustu? (firstSourceFeature isimce ilk = auth.) + // Which feature did UserRepository land in? (firstSourceFeature, first by name = auth.) const authFeature = ctx.graph.features().find((f) => f.repositories.some((r) => r.name === "UserRepository")); expect(authFeature?.slug).toBe("auth"); const authFile = emitFeatureModule(featureBySlug(ctx.graph, "auth"), ctx)[0]; - // Repository EXPORT edilir (Service degil sadece -> Repository de aday). + // The Repository is EXPORTED (not just Services -> Repositories are candidates too). expect(authFile.content).toContain("exports: [UserRepository],"); const imageFile = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx)[0]; expect(imageFile.content).toContain("imports: [AuthModule],"); }); - /* ── Enjekte edilen Cache/ExternalService TAM provider'lari module'da ───── */ - it("CACHES_IN/REQUESTS edge'li Service -> Cache/ExternalService TAM provider + module import'lari", () => { + /* ── Injected Cache/ExternalService FULL providers in the module ────────── */ + it("Service with CACHES_IN/REQUESTS edges -> Cache/ExternalService as FULL providers + module imports", () => { const ctrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); const svc = node("Service", { ServiceName: "ImageService", @@ -348,19 +348,19 @@ describe("emitFeatureModule", () => { ]; const ctx = ctxFor([ctrl, svc, cache, ext], edges); const [file] = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx); - // Cache/ExternalService artik TAM emitter -> gercek sinif adi (Stub eki NONE). + // Cache/ExternalService now have FULL emitters -> the real class name (no Stub suffix). expect(file.content).toContain("providers: [ImageService, ImageCache, SdApi],"); expect(file.content).not.toContain("Stub"); expect(file.content).toContain('import { ImageCache } from "./image.cache";'); expect(file.content).toContain('import { SdApi } from "./sd.client";'); - // Module-seviyesi altyapi import'lari: CacheModule + HttpModule + ConfigModule. + // Module-level infrastructure imports: CacheModule + HttpModule + ConfigModule. expect(file.content).toContain("CacheModule.register()"); expect(file.content).toContain("HttpModule"); expect(file.content).toContain("ConfigModule"); }); - /* ── Table-only (Model'siz) feature: sentetik entity forFeature'a girer ──── */ - it("Model'siz Table feature: sentetik entity TypeOrmModule.forFeature'a eklenir", () => { + /* ── Table-only (no Model) feature: the synthetic entity goes into forFeature ── */ + it("Table feature without a Model: the synthetic entity is added to TypeOrmModule.forFeature", () => { const ctrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); const svc = node("Service", { ServiceName: "ImageService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "ImageRepository" }], Methods: [] }); const repo = node("Repository", { RepositoryName: "ImageRepository", Description: "x", EntityReference: "GeneratedImages", CustomQueries: [] }); @@ -368,17 +368,17 @@ describe("emitFeatureModule", () => { const edges = [edge("CALLS", ctrl.id, svc.id), edge("CALLS", svc.id, repo.id), edge("WRITES", repo.id, table.id)]; const ctx = ctxFor([ctrl, svc, repo, table], edges); const [file] = emitFeatureModule(featureBySlug(ctx.graph, "image"), ctx); - // Model NONE ama sentetik GeneratedImage entity forFeature'a girer -> DI tam. + // No Model, but the synthetic GeneratedImage entity goes into forFeature -> DI is complete. expect(file.content).toContain("TypeOrmModule.forFeature([GeneratedImage])"); expect(file.content).toContain('import { GeneratedImage } from "./entities/generated-image.entity";'); expect(file.content).toContain("providers: [ImageService, ImageRepository],"); }); - /* ── #7 cross-feature infra provider TEK module'de provider (singleton) ──── */ - it("cift-inject: PaymentGateway YALNIZ payment module'unde provider; order import eder", () => { - // payment + order IKISI de PaymentGateway (ExternalService) enjekte eder. - // Eski hata: gateway iki module'un de providers'inda -> iki ornek (singleton kirik). - // Beklenen: yalniz PaymentModule provider+export eder; OrderModule PaymentModule import. + /* ── #7 cross-feature infra provider is a provider in ONE module only (singleton) ── */ + it("double-inject: PaymentGateway is a provider ONLY in the payment module; order imports it", () => { + // BOTH payment and order inject PaymentGateway (ExternalService). + // Old bug: the gateway was in both modules' providers -> two instances (broken singleton). + // Expected: only PaymentModule provides+exports it; OrderModule imports PaymentModule. const payCtrl = node("Controller", { ControllerName: "PaymentController", Description: "x", BaseRoute: "payment", Endpoints: [] }); const paySvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); const orderCtrl = node("Controller", { ControllerName: "OrderController", Description: "x", BaseRoute: "order", Endpoints: [] }); @@ -396,26 +396,26 @@ describe("emitFeatureModule", () => { const paymentFile = emitFeatureModule(featureBySlug(ctx.graph, "payment"), ctx)[0]; const orderFile = emitFeatureModule(featureBySlug(ctx.graph, "order"), ctx)[0]; - // Sahip (payment): PaymentGateway providers + exports + import edilen sinif. + // Owner (payment): PaymentGateway in providers + exports + the class import. expect(paymentFile.content).toContain("providers: [PaymentService, PaymentGateway],"); expect(paymentFile.content).toContain("exports: [PaymentService, PaymentGateway],"); expect(paymentFile.content).toContain('import { PaymentGateway } from "./payment-gateway.client";'); - // Sahip-DISI (order): PaymentGateway'i providers'a YAZMAZ, sinifi import ETMEZ. + // The non-owner (order): does NOT put PaymentGateway in providers, does NOT import the class. expect(orderFile.content).not.toContain("PaymentGateway"); expect(orderFile.content).toContain("providers: [OrderService],"); - // PaymentModule'u import eder (gateway + PaymentService oradan gelir). + // It imports PaymentModule (the gateway + PaymentService come from there). expect(orderFile.content).toContain("imports: [PaymentModule],"); expect(orderFile.content).toContain('import { PaymentModule } from "../payment/payment.module";'); - // forwardRef ASLA uretilmez; dongu yok. + // forwardRef is NEVER generated; there is no cycle. expect(paymentFile.content).not.toContain("forwardRef"); expect(orderFile.content).not.toContain("forwardRef"); }); - it("entity yoksa imports alani atlanir (bos @Module alanlari yazilmaz)", () => { - // Controller + Service var ama Model/Table NONE -> TypeOrmModule.forFeature yok, - // cross-feature bagimlilik yok -> imports alani tamamen atlanir. + it("without entities the imports field is omitted (empty @Module fields are not written)", () => { + // There is a Controller + Service but no Model/Table -> no TypeOrmModule.forFeature, + // no cross-feature dependencies -> the imports field is omitted entirely. const ctrl = node("Controller", { ControllerName: "PingController", Description: "Saglik", @@ -424,7 +424,7 @@ describe("emitFeatureModule", () => { }); const svc = node("Service", { ServiceName: "PingService", - Description: "Saglik mantigi", + Description: "Health logic", Dependencies: [], Methods: [], }); diff --git a/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.spec.ts index f716c03..bc4863a 100644 --- a/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.spec.ts @@ -6,7 +6,7 @@ import type { StoredNode } from "../../../nodes/nodes.repository"; import type { StoredEdge } from "../../../edges/edges.repository"; import type { EdgeKind } from "../../../edges/schemas/edge.schema"; -/* ── Fixture helpers (service.emitter.spec ile ayni desen) ──────────── */ +/* ── Fixture helpers (same pattern as service.emitter.spec) ─────────── */ const PROJECT = "00000000-0000-4000-8000-000000000000"; const TAB = "22222222-2222-4222-8222-222222222222"; @@ -42,7 +42,7 @@ function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; } -/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +/* ── IDs ────────────────────────────────────────────────────────────────── */ const ORCH = "10000000-0000-4000-8000-0000000000a1"; const PAYMENT_SVC = "10000000-0000-4000-8000-0000000000a2"; const INVENTORY_SVC = "10000000-0000-4000-8000-0000000000a3"; @@ -74,8 +74,8 @@ const shippingService = node("Service", SHIPPING_SVC, { Methods: [], }); -// Bir Controller'in CALLS ettigi service'ler "checkout" feature'ini tohumlar; -// boylece orchestrator + service'ler ayni feature'a duser (goreli import kisa). +// The services CALLED by a Controller seed the "checkout" feature; +// so the orchestrator + services land in the same feature (short relative imports). const checkoutController = node("Controller", CHECKOUT_CTRL, { ControllerName: "CheckoutController", Description: "Checkout API", @@ -111,8 +111,8 @@ const checkoutOrchestrator = node("Orchestrator", ORCH, { ], }); -/* Controller'in service'leri CALLS etmesi feature atamasi icin: checkout feature. - * Orchestrator da bu service'leri CALLS eder (DI). */ +/* The Controller calling the services drives feature assignment: checkout feature. + * The orchestrator also CALLS these services (DI). */ function fullGraphEdges(): StoredEdge[] { return [ edge("e-c-pay", "CALLS", CHECKOUT_CTRL, PAYMENT_SVC), @@ -125,7 +125,7 @@ function fullGraphEdges(): StoredEdge[] { } describe("emitOrchestrator", () => { - it("tam orchestrator — snapshot (DI, dekorator, execute + adim metotlari, surgical marker)", () => { + it("full orchestrator — snapshot (DI, decorator, execute + step methods, surgical markers)", () => { const ctx = ctxFrom( [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], fullGraphEdges(), @@ -189,28 +189,28 @@ describe("emitOrchestrator", () => { `); }); - it("dosya yolu feature klasoru + rol-tekrarsiz .orchestrator.ts", () => { + it("file path is the feature folder + .orchestrator.ts without role repetition", () => { const ctx = ctxFrom( [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], fullGraphEdges(), ); const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); - // base "Checkout" (Orchestrator eki duser) -> checkout.orchestrator.ts. + // base "Checkout" (the Orchestrator suffix is dropped) -> checkout.orchestrator.ts. expect(file.path).toBe("checkout/checkout.orchestrator.ts"); }); - it("DI = Steps[].ServiceRef ∪ CALLS hedefleri, DEDUP + isme gore sirali", () => { - // Steps'te 3 service ref + ayni service'lere CALLS edge -> her biri TEK alan. + it("DI = Steps[].ServiceRef ∪ CALLS targets, deduplicated + sorted by name", () => { + // 3 service refs in Steps + CALLS edges to the same services -> ONE field each. const ctx = ctxFrom( [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], fullGraphEdges(), ); const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); - // Her service tek kez enjekte edilir. + // Each service is injected exactly once. expect(file.content.split("private readonly inventoryService").length - 1).toBe(1); expect(file.content.split("private readonly paymentService").length - 1).toBe(1); expect(file.content.split("private readonly shippingService").length - 1).toBe(1); - // Isme gore sirali: inventory < payment < shipping. + // Sorted by name: inventory < payment < shipping. const iInv = file.content.indexOf("inventoryService:"); const iPay = file.content.indexOf("paymentService:"); const iShp = file.content.indexOf("shippingService:"); @@ -218,13 +218,13 @@ describe("emitOrchestrator", () => { expect(iPay).toBeLessThan(iShp); }); - it("her adim icin + execute icin surgical marker + NOT_IMPLEMENTED", () => { + it("surgical marker + NOT_IMPLEMENTED for every step and for execute", () => { const ctx = ctxFrom( [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], fullGraphEdges(), ); const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); - // 1 execute + 3 adim = 4 marker. + // 1 execute + 3 steps = 4 markers. expect(file.surgicalMarkers).toBe(4); expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.execute");'); expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.reserveInventory");'); @@ -232,8 +232,8 @@ describe("emitOrchestrator", () => { expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: CheckoutOrchestrator.scheduleShipment");'); }); - it("CALLS edge'i olmadan da Steps[].ServiceRef'ten DI cozer + import uretir", () => { - // Hic CALLS edge yok; DI yalniz Steps[].ServiceRef'ten gelmeli. + it("resolves DI from Steps[].ServiceRef + generates imports even without CALLS edges", () => { + // No CALLS edges at all; DI must come solely from Steps[].ServiceRef. const ctx = ctxFrom( [checkoutOrchestrator, paymentService, inventoryService, shippingService, checkoutController], [ @@ -247,7 +247,7 @@ describe("emitOrchestrator", () => { expect(file.content).toMatch(/import \{ PaymentService \} from ".*payment\.service"/); }); - it("edge-case: kayip ServiceRef — throw etmez, ham isimden sinif adi + import atlanir", () => { + it("edge-case: missing ServiceRef — does not throw, class name from the raw ref + import is skipped", () => { const lonelyOrch = node("Orchestrator", ORCH, { OrchestratorName: "GhostOrchestrator", Description: "Kayip ref'li akis", @@ -266,16 +266,16 @@ describe("emitOrchestrator", () => { expect(() => { file = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx)[0]; }).not.toThrow(); - // Ham ref'ten sinif adi turetilir. + // The class name is derived from the raw ref. expect(file!.content).toContain("private readonly missingService: MissingService,"); - // Cozulemeyen service import EDILMEZ. + // The unresolvable service is NOT imported. expect(file!.content).not.toMatch(/import \{ MissingService \}/); - // execute + 1 adim = 2 marker. + // execute + 1 step = 2 markers. expect(file!.surgicalMarkers).toBe(2); expect(file!.content).toContain('throw new Error("NOT_IMPLEMENTED: GhostOrchestrator.doThing");'); }); - it("edge-case: bos Steps — yine de execute uretir, constructor yok", () => { + it("edge-case: empty Steps — still generates execute, no constructor", () => { const emptyOrch = node("Orchestrator", ORCH, { OrchestratorName: "EmptyOrchestrator", Description: "Adimsiz akis", @@ -284,9 +284,9 @@ describe("emitOrchestrator", () => { }); const ctx = ctxFrom([emptyOrch], []); const [file] = emitOrchestrator(ctx.graph.byId(ORCH)!, ctx); - // DI yok -> constructor yok. + // No DI -> no constructor. expect(file.content).not.toContain("constructor("); - // Yalniz execute() uretilir. + // Only execute() is generated. expect(file.content).toContain("async execute(): Promise {"); expect(file.surgicalMarkers).toBe(1); }); diff --git a/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.ts b/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.ts index 125d8e2..dc5e5df 100644 --- a/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.ts +++ b/apps/server/src/codegen/emitters/nestjs/orchestrator.emitter.ts @@ -14,43 +14,43 @@ import type { OrchestratorNode } from "../../../nodes/schemas/orchestrator.schem /* ──────────────────────────────────────────────────────────────────────── * orchestrator.emitter.ts — OrchestratorNode -> /.orchestrator.ts. * - * Bir Orchestrator, birden cok Service'i bir IS AKISINA (Saga / state machine / - * process manager) baglayan @Injectable() koordinatordur. Kendi is mantigi - * yoktur — KOORDINE eder: enjekte ettigi Service'lerin metotlarini sirali (veya - * telafili) cagirir. + * An Orchestrator is an @Injectable() coordinator that ties multiple Services + * into a WORKFLOW (Saga / state machine / process manager). It has no business + * logic of its own — it COORDINATES: it calls the methods of the Services it + * injects sequentially (or with compensation). * - * DI alanlari (constructor): - * - Steps[].ServiceRef ile adlanan her Service node BIRLESIM - * graph.outEdges(id, "CALLS") hedeflerinden Service olanlar. DEDUP edilir, - * isme gore siralanir, `private readonly : ` olarak - * enjekte edilir. Cozulebilen ref'ler icin import eklenir; cozulemeyen ref'ler - * ham Ref isminden sinif adi turetir (import atlanir → ASLA throw). + * DI fields (constructor): + * - Every Service node named by Steps[].ServiceRef UNION the Service targets + * among graph.outEdges(id, "CALLS"). Deduped, sorted by name, injected as + * `private readonly : `. Imports are added for + * resolvable refs; unresolvable refs derive the class name from the raw Ref + * name (import skipped → NEVER throws). * - * Metotlar: - * - execute(): orchestrator giris noktasi (tum akisi yurutur). Surgical govde; - * deps = enjekte edilen tum Service'ler. - * - Her Step icin bir metot (kebab/camel StepName). Surgical govde; deps = o - * adimi yuruten Service (ServiceRef). Description = "Action" (+ OnFailure / - * CompensationAction notlari). + * Methods: + * - execute(): the orchestrator entry point (runs the whole flow). Surgical + * body; deps = all injected Services. + * - One method per Step (kebab/camel StepName). Surgical body; deps = the + * Service running that step (ServiceRef). Description = "Action" (+ OnFailure / + * CompensationAction notes). * - * SAF + DETERMINISTIC: koleksiyonlar sirali (deps isme, metotlar Step sirasinda), - * import'lar ImportCollector ile, timestamp/random yok, icerik tek "\n" ile biter. + * PURE + DETERMINISTIC: collections sorted (deps by name, methods in Step order), + * imports via ImportCollector, no timestamps/randomness, content ends with a single "\n". * - * NOT: Orchestrator PropsByKind icinde NOT — propsOf<...> KULLANILMAZ. - * properties OrchestratorNode["properties"] olarak tipli okunur (DB Zod-dogrulanmis). + * NOTE: Orchestrator is NOT in PropsByKind — propsOf<...> is NOT used. + * properties is read typed as OrchestratorNode["properties"] (the DB is Zod-validated). * ──────────────────────────────────────────────────────────────────────── */ type OrchestratorProps = OrchestratorNode["properties"]; type OrchestratorStep = OrchestratorProps["Steps"][number]; -/** Cozulmus bir bagimlilik (enjekte edilen Service): DI alani + sinif tipi + - * (varsa) import yolu. */ +/** A resolved dependency (the injected Service): DI field + class type + + * import path (when available). */ interface ResolvedServiceDep { /** constructor / this. */ field: string; - /** enjekte edilen sinif tipi (pascalCase(name)) */ + /** the injected class type (pascalCase(name)) */ className: string; - /** cozulen node'un dosya yolu (import icin); cozulemezse null. */ + /** file path of the resolved node (for the import); null when unresolvable. */ filePath: string | null; } @@ -63,10 +63,10 @@ export const emitOrchestrator: NodeEmitter = (node: CodeNode, ctx): GeneratedFil const imports = new ImportCollector(); imports.add("Injectable", "@nestjs/common"); - // ── DI bagimliliklari: Steps[].ServiceRef ∪ CALLS hedefleri (Service) ────── - // DEDUP (cozulen node.name veya ham ref) + isme gore sirali. Her step'in hangi - // service alanina karsilik geldigini metot govdesinde isaretleyebilmek icin - // ref -> field eslemesini de tutariz. + // ── DI dependencies: Steps[].ServiceRef ∪ CALLS targets (Service) ────── + // Deduped (resolved node.name or raw ref) + sorted by name. We also keep the + // ref -> field mapping so each step's method body can point at the service + // field it corresponds to. const { deps, fieldByRef } = collectServiceDeps(node, props, graph); for (const dep of deps) { if (dep.filePath) { @@ -75,15 +75,15 @@ export const emitOrchestrator: NodeEmitter = (node: CodeNode, ctx): GeneratedFil } const allDepFields = deps.map((d) => `this.${d.field}`); - // ── Metotlar ────────────────────────────────────────────────────────────── - // (1) execute(): tum akisin giris noktasi (deps = tumu). - // (2) Her Step icin bir metot (deps = o adimin service'i). + // ── Methods ────────────────────────────────────────────────────────────── + // (1) execute(): the entry point of the whole flow (deps = all of them). + // (2) One method per Step (deps = that step's service). const methodBlocks: string[] = [renderExecute(node, className, props, allDepFields)]; for (const step of props.Steps ?? []) { methodBlocks.push(renderStep(node, className, step, fieldByRef)); } - // ── Sinif govdesi ─────────────────────────────────────────────────────────── + // ── Class body ─────────────────────────────────────────────────────────── const lines: string[] = []; if (props.Description) lines.push(`/** ${props.Description} */`); lines.push("@Injectable()"); @@ -117,25 +117,25 @@ export const emitOrchestrator: NodeEmitter = (node: CodeNode, ctx): GeneratedFil return [file]; }; -/** Steps[].ServiceRef ∪ CALLS edge hedeflerini (Service) DEDUP edip isme gore - * siralanmis ResolvedServiceDep listesi + ref->field eslemesi dondurur. - * Cozulemeyen ServiceRef'ler ham isimden sinif adi turetir (filePath=null → - * import atlanir). Asla throw etmez. */ +/** Dedupes Steps[].ServiceRef ∪ CALLS edge targets (Service) and returns a + * ResolvedServiceDep list sorted by name + the ref->field mapping. + * Unresolvable ServiceRefs derive the class name from the raw name + * (filePath=null → import skipped). Never throws. */ function collectServiceDeps( node: CodeNode, props: OrchestratorProps, graph: CodeGraph, ): { deps: ResolvedServiceDep[]; fieldByRef: Map } { - // refName (cozulen node.name veya ham ref) -> ResolvedServiceDep (DEDUP). + // refName (resolved node.name or the raw ref) -> ResolvedServiceDep (dedup). const byKey = new Map(); - // Her ham ServiceRef ismini -> DI alan adina esler (step govdesi icin). + // Maps each raw ServiceRef name -> the DI field name (for the step body). const fieldByRef = new Map(); const register = (resolved: CodeNode | null, rawRef: string): string => { const refName = resolved ? resolved.name : rawRef; let entry = byKey.get(refName); if (entry) { - // Mevcut cozulmemis + gelen cozulmus -> yukselt (import kaybini onle). + // Existing unresolved + incoming resolved -> upgrade (prevent import loss). if (entry.filePath === null && resolved) { entry.filePath = filePathFor(resolved, graph); entry.className = pascalCase(resolved.name); @@ -151,17 +151,17 @@ function collectServiceDeps( return entry.field; }; - // (1) Steps[].ServiceRef — her adimi yuruten Service. + // (1) Steps[].ServiceRef — the Service running each step. for (const step of props.Steps ?? []) { const ref = step.ServiceRef; if (!ref) continue; const resolved = graph.resolveRef("Service", ref); const field = register(resolved, ref); - // Ham ServiceRef -> field (step govdesi this. isaretler). + // Raw ServiceRef -> field (the step body points at this.). if (!fieldByRef.has(ref)) fieldByRef.set(ref, field); } - // (2) CALLS edge hedefleri — Service olanlar (Steps'te gecmeyenler de DI'ya girer). + // (2) CALLS edge targets — the Service ones (those absent from Steps also enter DI). for (const e of graph.outEdges(node.id, "CALLS")) { const tgt = graph.byId(e.targetNodeId); if (!tgt || tgt.kindOf() !== "Service") continue; @@ -173,8 +173,8 @@ function collectServiceDeps( return { deps, fieldByRef }; } -/** execute() — orchestrator giris noktasi. Tum akisi (Steps sirasiyla) yurutur. - * deps = enjekte edilen TUM service alanlari. Surgical govde. */ +/** execute() — the orchestrator entry point. Runs the whole flow (in Step order). + * deps = ALL injected service fields. Surgical body. */ function renderExecute( node: CodeNode, className: string, @@ -202,8 +202,9 @@ function renderExecute( return lines.join("\n"); } -/** Tek bir Step'i bir metoda cevirir (imza + surgical govde). Metot adi StepName' - * den camelCase turetilir; deps = o adimi yuruten Service alani (varsa). */ +/** Converts a single Step into a method (signature + surgical body). The method + * name is derived camelCase from the StepName; deps = the Service field running + * that step (if any). */ function renderStep( node: CodeNode, className: string, @@ -213,12 +214,12 @@ function renderStep( const indent = " "; const method = stepMethodName(step.StepName); - // Bu adimi yuruten service alani (ServiceRef -> field). + // The service field running this step (ServiceRef -> field). const depFields: string[] = []; const field = step.ServiceRef ? fieldByRef.get(step.ServiceRef) : undefined; if (field) depFields.push(`this.${field}`); - // Aciklama: Action + OnFailure + (varsa) CompensationAction. + // Description: Action + OnFailure + CompensationAction (when present). const descParts: string[] = []; if (step.Action) descParts.push(step.Action); descParts.push(`onFailure: ${step.OnFailure}`); @@ -239,13 +240,13 @@ function renderStep( return lines.join("\n"); } -/** Bir StepName'i gecerli bir TS metot adina cevirir: camelCase; bossa "step". */ +/** Converts a StepName to a valid TS method name: camelCase; "step" when empty. */ function stepMethodName(stepName: string): string { const camel = camelCase(stepName); return camel.length > 0 ? camel : "step"; } -/** Deterministik string karsilastirmasi. */ +/** Deterministic string comparison. */ function cmp(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } diff --git a/apps/server/src/codegen/emitters/nestjs/repository.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/repository.emitter.spec.ts index 006a041..251375c 100644 --- a/apps/server/src/codegen/emitters/nestjs/repository.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/repository.emitter.spec.ts @@ -5,7 +5,7 @@ import type { EmitterContext } from "../../types"; import type { StoredNode } from "../../../nodes/nodes.repository"; import type { NodeKind } from "../../../nodes/schemas"; -/* ── Fixture helpers (enum.emitter.spec deseni) ───────────────────── */ +/* ── Fixture helpers (enum.emitter.spec pattern) ──────────────────── */ function storedNode( type: NodeKind, properties: Record, @@ -57,7 +57,7 @@ const USER_REPO = storedNode( QueryType: "findOne", Parameters: [{ Name: "email", Type: "string" }], ReturnType: "User | null", - Description: "E-postaya gore kullanici bul", + Description: "Find a user by email", }, { QueryName: "countActive", @@ -71,7 +71,7 @@ const USER_REPO = storedNode( ); describe("emitRepository", () => { - it("Model entity ile tam uretim — snapshot", () => { + it("full generation with a Model entity — snapshot", () => { const { ctx } = ctxFor(USER_REPO, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); expect(file).toMatchInlineSnapshot(` @@ -117,7 +117,7 @@ describe("emitRepository", () => { async findByEmail(email: string): Promise { // @solarch:surgical id=33333333-3333-4333-8333-333333333333#findByEmail - // E-postaya gore kullanici bul + // Find a user by email // GUIDANCE: fetch related data in a SINGLE query via join/relations (leftJoinAndSelect or find({ relations })); avoid N+1 by not relying on lazy access inside a loop. // deps: repo throw new Error("NOT_IMPLEMENTED: UserRepository.findByEmail"); @@ -131,12 +131,12 @@ describe("emitRepository", () => { `); }); - it("dogru import'lar, dekorator, DI ve entity tipi", () => { + it("correct imports, decorator, DI, and entity type", () => { const { ctx } = ctxFor(USER_REPO, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); expect(file.content).toContain(`import { Injectable } from "@nestjs/common";`); expect(file.content).toContain(`import { InjectRepository } from "@nestjs/typeorm";`); - // Repository + FindOptionsWhere (standart CRUD findById where-cast'i icin). + // Repository + FindOptionsWhere (for the standard CRUD findById where-cast). expect(file.content).toContain(`import { FindOptionsWhere, Repository } from "typeorm";`); expect(file.content).toContain(`import { User } from "./entities/user.entity";`); expect(file.content).toContain("@Injectable()"); @@ -145,7 +145,7 @@ describe("emitRepository", () => { expect(file.content).toContain("private readonly repo: Repository,"); }); - it("CustomQuery -> async imza + surgical marker + NOT_IMPLEMENTED", () => { + it("CustomQuery -> async signature + surgical marker + NOT_IMPLEMENTED", () => { const { ctx } = ctxFor(USER_REPO, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); expect(file.content).toContain("async findByEmail(email: string): Promise {"); @@ -154,7 +154,7 @@ describe("emitRepository", () => { expect(file.surgicalMarkers).toBe(2); }); - it("CustomQuery'ler isme gore sirali (countActive < findByEmail)", () => { + it("CustomQueries are sorted by name (countActive < findByEmail)", () => { const { ctx } = ctxFor(USER_REPO, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); const idxCount = file.content.indexOf("async countActive"); @@ -163,7 +163,7 @@ describe("emitRepository", () => { expect(idxFind).toBeGreaterThan(idxCount); }); - it("BaseClass cozulemez serbest ad -> extends URETILMEZ (TS2304 onlenir), TODO birakilir", () => { + it("unresolvable free-form BaseClass -> no extends is generated (prevents TS2304), TODO is left", () => { const repo = storedNode( "Repository", { @@ -188,16 +188,16 @@ describe("emitRepository", () => { ); const { ctx } = ctxFor(repo, model); const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); - // BaseClass cozulebilir bir node NOT (import edilemez) -> `extends` uretmek - // TS2304 'Cannot find name BaseRepository' verirdi. Bunun yerine duz sinif + - // TODO yorumu (gelistirici elle ekler). + // BaseClass is not a resolvable node (cannot be imported) -> generating `extends` + // would produce TS2304 'Cannot find name BaseRepository'. Instead: a plain class + + // a TODO comment (the developer adds it manually). expect(file.content).toContain("export class OrderRepository {"); expect(file.content).not.toContain("extends BaseRepository"); expect(file.content).not.toContain("super();"); expect(file.content).toContain('// TODO: BaseClass "BaseRepository"'); }); - it("dosya yolu feature klasoru + .repository.ts", () => { + it("file path is the feature folder + .repository.ts", () => { const { ctx } = ctxFor(USER_REPO, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); expect(file.path).toBe("user/user.repository.ts"); @@ -218,7 +218,7 @@ describe("emitRepository", () => { expect(a).toBe(b); }); - it("EDGE-CASE: kayip EntityReference -> TODO + entity import NONE, throw NONE", () => { + it("EDGE-CASE: missing EntityReference -> TODO + no entity import, no throw", () => { const orphan = storedNode( "Repository", { @@ -230,14 +230,14 @@ describe("emitRepository", () => { }, "77777777-7777-4777-8777-777777777777", ); - // Model/Table eklenmedi -> resolveRef null. + // No Model/Table added -> resolveRef null. const { ctx } = ctxFor(orphan); const result = emitRepository(ctx.graph.byId(orphan.id)!, ctx); expect(result).toHaveLength(1); const [file] = result; expect(file.content).toContain(`// TODO: EntityReference "Phantom" could not be resolved`); - // Cozulemeyen ref -> import edilebilir sembol yok. DERLENEBILIR kalmak icin - // string token + Repository (TS2304 'Cannot find name Phantom' onlenir). + // Unresolvable ref -> no importable symbol. To stay COMPILABLE: + // string token + Repository (prevents TS2304 'Cannot find name Phantom'). expect(file.content).toContain('@InjectRepository("Phantom")'); expect(file.content).toContain("private readonly repo: Repository,"); expect(file.content).not.toContain("Repository"); @@ -245,7 +245,7 @@ describe("emitRepository", () => { expect(file.surgicalMarkers).toBe(0); }); - it("CustomQuery tip normalizasyonu: UUID -> string, Date korunur (TS2304 onlenir)", () => { + it("CustomQuery type normalization: UUID -> string, Date is preserved (prevents TS2304)", () => { const repo = storedNode( "Repository", { @@ -266,24 +266,24 @@ describe("emitRepository", () => { ); const { ctx } = ctxFor(repo, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); - // UUID -> string, datetime -> Date (scalarTsType); ham 'UUID'/'datetime' - // tanimsiz semboller olurdu -> nest build TS2304 ile kirilirdi. + // UUID -> string, datetime -> Date (scalarTsType); raw 'UUID'/'datetime' + // would be undefined symbols -> nest build would break with TS2304. expect(file.content).toContain("async findSince(id: string, since: Date): Promise"); expect(file.content).not.toContain("UUID"); expect(file.content).not.toContain("datetime"); }); - it("CustomQuery ReturnType entity adi -> import + sinif (User Model cozulur)", () => { + it("CustomQuery ReturnType is an entity name -> import + class (the User Model resolves)", () => { const { ctx } = ctxFor(USER_REPO, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(REPO_ID)!, ctx); - // ReturnType "User | null" -> User Model'i cozulur + entity import edilir. + // ReturnType "User | null" -> the User Model resolves + the entity is imported. expect(file.content).toContain('import { User } from "./entities/user.entity";'); expect(file.content).toContain("Promise"); }); - it("CustomQuery ReturnType View adi -> VALUE import (import type NOT — TS1361 onler)", () => { - // @ViewEntity bir SINIFTIR; govde onu DEGER olarak kullanabilir (repository token, - // QueryBuilder). `import type { ActiveUsersView }` -> TS1361. Value import olmali. + it("CustomQuery ReturnType is a View name -> VALUE import (not `import type` — prevents TS1361)", () => { + // @ViewEntity is a CLASS; the body may use it as a VALUE (repository token, + // QueryBuilder). `import type { ActiveUsersView }` -> TS1361. It must be a value import. const viewRepo = storedNode( "Repository", { @@ -318,19 +318,19 @@ describe("emitRepository", () => { const { ctx } = ctxFor(viewRepo, USER_MODEL, viewNode); const [file] = emitRepository(ctx.graph.byId(viewRepo.id)!, ctx); expect(file.content).toContain("ActiveUsersView"); - // value import (import { ... }) — import type OLMAMALI. + // value import (import { ... }) — must NOT be `import type`. expect(file.content).not.toMatch(/import type\s*\{\s*ActiveUsersView/); expect(file.content).toMatch(/import\s*\{\s*ActiveUsersView\s*\}/); }); - it("EDGE-CASE: bos CustomQueries -> constructor + standart CRUD (surgical NONE)", () => { - // #3: CustomQuery olmasa BILE her repository TAM CRUD tasir (findById/findAll/ - // save/remove) — bunlar GERCEK (deterministik) govdelerdir, surgical NOT. + it("EDGE-CASE: empty CustomQueries -> constructor + standard CRUD (no surgical markers)", () => { + // #3: EVEN without CustomQueries every repository carries FULL CRUD (findById/findAll/ + // save/remove) — these are REAL (deterministic) bodies, not surgical. const bare = storedNode( "Repository", { RepositoryName: "BareRepository", - Description: "Sadece DI", + Description: "DI only", EntityReference: "User", IsCached: false, CustomQueries: [], @@ -340,9 +340,9 @@ describe("emitRepository", () => { const { ctx } = ctxFor(bare, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(bare.id)!, ctx); expect(file.content).toContain("private readonly repo: Repository,"); - // Standart CRUD daima uretilir (GERCEK govde, NOT_IMPLEMENTED yok). + // Standard CRUD is always generated (REAL bodies, no NOT_IMPLEMENTED). expect(file.content).toContain("async findById(id: string): Promise {"); - // findById artik findOne + relations (iliskileri tek sorguda yukler). + // findById is now findOne + relations (loads relations in a single query). expect(file.content).toContain("where: { id: id } as FindOptionsWhere,"); expect(file.content).toContain("relations: this.repo.metadata.relations.map((r) => r.propertyName),"); expect(file.content).toContain("async findAll(): Promise {"); @@ -351,14 +351,14 @@ describe("emitRepository", () => { expect(file.content).toContain("return this.repo.save(entity);"); expect(file.content).toContain("async remove(id: string): Promise {"); expect(file.content).toContain("await this.repo.delete(id);"); - // CRUD gercek govde -> surgical marker / NOT_IMPLEMENTED NONE (CustomQuery yok). + // CRUD has real bodies -> no surgical markers / NOT_IMPLEMENTED (there are no CustomQueries). expect(file.content).not.toContain("NOT_IMPLEMENTED"); expect(file.surgicalMarkers).toBe(0); }); - it("#3 CRUD: kayip EntityReference -> CRUD yine uretilir (any tip + FindOptionsWhere), derlenebilir", () => { - // Kayip entity -> Repository; CRUD GERCEK govdelerle yine uretilir - // (any tip altinda da derlenir). GenericRepository diye cozulmemis ref kalmaz. + it("#3 CRUD: missing EntityReference -> CRUD is still generated (any type + FindOptionsWhere), compilable", () => { + // Missing entity -> Repository; CRUD is still generated with REAL bodies + // (it also compiles under the any type). No unresolved ref like GenericRepository remains. const orphan = storedNode( "Repository", { @@ -382,9 +382,9 @@ describe("emitRepository", () => { expect(file.surgicalMarkers).toBe(0); }); - it("#3 CRUD: CustomQuery ayni isimde ise o CRUD metodu ATLANIR (cift metot yok)", () => { - // User kendi findById/save'ini CustomQuery olarak tanimlarsa CRUD metodu - // ATLANIR (kullanici niyeti kazanir; cift metot derlemeyi kirardi). + it("#3 CRUD: a CustomQuery with the same name SKIPS that CRUD method (no duplicate methods)", () => { + // If the user defines their own findById/save as a CustomQuery, the CRUD method + // is SKIPPED (user intent wins; duplicate methods would break compilation). const repo = storedNode( "Repository", { @@ -401,21 +401,21 @@ describe("emitRepository", () => { ); const { ctx } = ctxFor(repo, USER_MODEL); const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); - // findById ve save CustomQuery (surgical) olarak gelir, CRUD versiyonu atlanir. + // findById and save come in as CustomQueries (surgical), the CRUD versions are skipped. expect(file.content).not.toContain("return this.repo.findOneBy({ id: id }"); expect(file.content).not.toContain("return this.repo.save(entity);"); expect(file.content).toContain(`throw new Error("NOT_IMPLEMENTED: OverrideRepository.findById");`); expect(file.content).toContain(`throw new Error("NOT_IMPLEMENTED: OverrideRepository.save");`); - // Atlanmayan CRUD (findAll/remove) GERCEK govdeyle kalir. + // The non-skipped CRUD (findAll/remove) keeps its REAL bodies. expect(file.content).toContain("return this.repo.find();"); expect(file.content).toContain("await this.repo.delete(id);"); - // findById/save icin tek tanim (cakisma yok). + // A single definition for findById/save (no clash). expect(file.content.match(/async findById/g)?.length).toBe(1); expect(file.content.match(/async save/g)?.length).toBe(1); }); - it("#3 CRUD: Table entity (Model NONE) -> PK tipi kolon DataType'indan cozulur (INT id -> number)", () => { - // PK alani/tipi entity'den cozulur: id INT -> number (findById/remove param tipi). + it("#3 CRUD: Table entity (no Model) -> the PK type resolves from the column DataType (INT id -> number)", () => { + // The PK field/type resolves from the entity: id INT -> number (the findById/remove param type). const table = storedNode( "Table", { @@ -441,18 +441,18 @@ describe("emitRepository", () => { ); const { ctx } = ctxFor(repo, table); const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); - // INT id -> number (UUID olsaydi string'di). + // INT id -> number (a UUID would have been string). expect(file.content).toContain("async findById(id: number): Promise {"); expect(file.content).toContain("async remove(id: number): Promise {"); }); - it("PK kolon adi 'Id' (buyuk) -> findById entity property'siyle HIZALI 'id' kullanir (tek-kaynak)", () => { - // GERCEK BUG: graf PK'yi 'Id' (buyuk I) ile verir (to-be.json'daki tum Table'lar - // boyle). entity-synthesis property'yi tsPropName ile 'id'ye normalize eder, ama - // repository.resolvePrimaryKey ham 'Id'yi kullanirsa -> findById entity'de WITHOUT - // kolona sorgu atar (runtime EntityPropertyNotFoundError; `as FindOptionsWhere` cast - // bunu DERLEMEDE gizler). findById, entity'nin gercek property adiyla (tsPropName) - // HIZALI olmali: { id: id }, { Id: id } NOT. + it("PK column named 'Id' (uppercase) -> findById uses 'id', ALIGNED with the entity property (single source)", () => { + // REAL BUG: the graph provides the PK as 'Id' (uppercase I) (all Tables in to-be.json + // are like this). entity-synthesis normalizes the property to 'id' via tsPropName, but + // if repository.resolvePrimaryKey used the raw 'Id' -> findById would query a column + // that does not exist on the entity (runtime EntityPropertyNotFoundError; the + // `as FindOptionsWhere` cast hides this at COMPILE time). findById must be ALIGNED + // with the entity's real property name (tsPropName): { id: id }, not { Id: id }. const table = storedNode( "Table", { @@ -481,7 +481,7 @@ describe("emitRepository", () => { expect(file.content).not.toContain("{ Id: id }"); }); - it("PK property adi 'Id' (Model) -> findById 'id' kullanir (tek-kaynak, Model yolu)", () => { + it("PK property named 'Id' (Model) -> findById uses 'id' (single source, Model path)", () => { const model = storedNode( "Model", { @@ -509,7 +509,7 @@ describe("emitRepository", () => { expect(file.content).not.toContain("{ Id: id }"); }); - it("EDGE-CASE: EntityReference Table (Model NONE) -> SENTETIK entity import edilir, Repository (boot eder)", () => { + it("EDGE-CASE: EntityReference is a Table (no Model) -> the SYNTHETIC entity is imported, Repository (boots)", () => { const table = storedNode( "Table", { @@ -541,13 +541,13 @@ describe("emitRepository", () => { ); const { ctx } = ctxFor(repo, table); const [file] = emitRepository(ctx.graph.byId(repo.id)!, ctx); - // Model yok -> Table'dan SENTEZLENEN entity (AuditLog). @InjectRepository(Entity) - // /Repository/forFeature hepsi AYNI sinifa baglanir -> uygulama BOOT BOOTS. - // (string token + Repository ARTIK URETILMEZ; bootta DI hatasi verirdi.) + // No Model -> the entity SYNTHESIZED from the Table (AuditLog). @InjectRepository(Entity) + // /Repository/forFeature all bind to the SAME class -> the app boots. + // (string token + Repository is NO LONGER generated; it caused a DI error at boot.) expect(file.content).toContain("@InjectRepository(AuditLog)"); expect(file.content).toContain("private readonly repo: Repository,"); expect(file.content).not.toContain("Repository"); - // Sentetik entity import'u (audit_logs -> tekil "audit-log" entity dosyasi). + // Synthetic entity import (audit_logs -> singular "audit-log" entity file). expect(file.content).toContain("entities/audit-log.entity"); expect(file.content).not.toContain("// TODO: EntityReference"); }); diff --git a/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.spec.ts index 0610786..13894c7 100644 --- a/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.spec.ts @@ -52,7 +52,7 @@ function fileByPath(files: ReturnType, path: string) return f; } -/* ── Gercekci kucuk graph ──────────────────────────────────────────────── */ +/* ── Realistic small graph ─────────────────────────────────────────────── */ function richGraph() { const usersModule = node("Module", { ModuleName: "UsersModule", @@ -63,7 +63,7 @@ function richGraph() { }); const usersService = node("Service", { ServiceName: "UsersService", - Description: "User is mantigi", + Description: "User business logic", IsTransactionScoped: false, Methods: [{ MethodName: "findAll", ReturnType: "User[]" }], Dependencies: [], @@ -74,7 +74,7 @@ function richGraph() { BaseRoute: "users", Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }], }); - // Module'e baglanamayan loose node'lar (moduleOf === null). + // Loose nodes that cannot be attached to a Module (moduleOf === null). const healthController = node("Controller", { ControllerName: "HealthController", Description: "Health check", @@ -88,7 +88,7 @@ function richGraph() { Methods: [{ MethodName: "now", ReturnType: "Date" }], Dependencies: [], }); - // EnvironmentVariable node'lari. + // EnvironmentVariable nodes. const dbUrl = node("EnvironmentVariable", { Key: "DATABASE_URL", Description: "Postgres connection string", @@ -100,7 +100,7 @@ function richGraph() { }); const jwtSecret = node("EnvironmentVariable", { Key: "JWT_SECRET", - Description: "JWT imzalama anahtari", + Description: "JWT signing key", DataType: "String", IsSecret: true, Environment: ["Dev", "Staging", "Prod"], @@ -117,7 +117,7 @@ function richGraph() { }); const edges = [ - // Controller -> Service yalniz CALLS edge'inden gelir (modul bagi buradan). + // Controller -> Service comes only from the CALLS edge (the module link comes from here). edge("CALLS", usersController.id, usersService.id), ]; @@ -127,14 +127,14 @@ function richGraph() { }; } -describe("emitScaffoldProject (graph-farkinda scaffold)", () => { - it("proje dosyalarini uretir (CoreModule + shared filter + data-source + test/CI iskeleti dahil)", () => { +describe("emitScaffoldProject (graph-aware scaffold)", () => { + it("generates the project files (including CoreModule + shared filter + data-source + test/CI skeleton)", () => { const { nodes, edges } = richGraph(); const files = emitScaffoldProject(ctxFor(nodes, edges)); - // richGraph EnvironmentVariable node'lari icerir -> src/config/configuration.ts - // de uretilir (ENV -> tipli config). env.validation.ts (Joi) DAIMA uretilir. + // richGraph contains EnvironmentVariable nodes -> src/config/configuration.ts + // is also generated (ENV -> typed config). env.validation.ts (Joi) is ALWAYS generated. // H1-H6: core.module + shared/filters + data-source + tsconfig.build + jest-e2e - // + .gitignore + test/app.e2e-spec. .env.example KOKTE (H4). + // + .gitignore + test/app.e2e-spec. .env.example is at the ROOT (H4). expect(files.map((f) => f.path).sort()).toEqual( [ ".env.example", @@ -157,36 +157,36 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { ); }); - it("app.module.ts — INCE: CoreModule + feature modulleri (root forRoot/register CoreModule'de)", () => { + it("app.module.ts — THIN: CoreModule + feature modules (root forRoot/register lives in CoreModule)", () => { const { nodes, edges } = richGraph(); const files = emitScaffoldProject(ctxFor(nodes, edges)); const app = fileByPath(files, "src/app.module.ts"); - // Sirali import bloku: paketler once, goreli sonra. + // Sorted import block: packages first, relative imports after. expect(app.content).toContain('import { Module } from "@nestjs/common";'); - // H3: app.module artik CoreModule'u import eder (tum root altyapi orada). + // H3: app.module now imports CoreModule (all root infrastructure lives there). expect(app.content).toContain('import { CoreModule } from "./core/core.module";'); expect(app.content).toContain(" CoreModule,"); - // Her feature -> bir sentezlenmis .module.ts. + // Every feature -> one synthesized .module.ts. expect(app.content).toContain('import { UsersModule } from "./users/users.module";'); expect(app.content).toContain('import { HealthModule } from "./health/health.module";'); expect(app.content).toContain('import { ClockModule } from "./clock/clock.module";'); - // Feature modulleri @Module.imports'a girer (slug'a gore sirali). + // Feature modules go into @Module.imports (sorted by slug). expect(app.content).toContain(" UsersModule,"); expect(app.content).toContain(" HealthModule,"); expect(app.content).toContain(" ClockModule,"); - // H3: app.module ROOT forRoot/register ICERMEZ (hepsi CoreModule'de). + // H3: app.module contains NO root forRoot/register (it all lives in CoreModule). expect(app.content).not.toContain("TypeOrmModule.forRootAsync"); expect(app.content).not.toContain("ConfigModule.forRoot"); - // app.module HAM controller/provider icermez (hepsi feature modullerinde). + // app.module contains no RAW controllers/providers (they all live in feature modules). expect(app.content).not.toContain("controllers:"); expect(app.content).not.toContain("providers:"); expect(app.content).not.toContain("UsersController"); expect(app.content).not.toContain("UsersService"); expect(app.content.endsWith("export class AppModule {}\n")).toBe(true); - // CoreModule TUM root altyapiyi tasir (H1/H2/H3). + // CoreModule carries ALL the root infrastructure (H1/H2/H3). const core = fileByPath(files, "src/core/core.module.ts"); expect(core.content).toContain("ConfigModule.forRoot({ isGlobal: true, load: [configuration], validationSchema })"); expect(core.content).toContain("TypeOrmModule.forRootAsync({"); @@ -195,25 +195,25 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { // #10: snake_case naming strategy on the runtime connection (same as CLI). expect(core.content).toContain('import { SnakeNamingStrategy } from "typeorm-naming-strategies";'); expect(core.content).toContain("namingStrategy: new SnakeNamingStrategy(),"); - // H2: Pino logger CoreModule'de kurulur. + // H2: the Pino logger is set up in CoreModule. expect(core.content).toContain('import { LoggerModule } from "nestjs-pino";'); expect(core.content).toContain("LoggerModule.forRoot({"); - // H1: global exception filter APP_FILTER ile baglanir. + // H1: the global exception filter is bound via APP_FILTER. expect(core.content).toContain('import { APP_FILTER } from "@nestjs/core";'); expect(core.content).toContain("provide: APP_FILTER, useClass: AllExceptionsFilter"); expect(core.content.endsWith("export class CoreModule {}\n")).toBe(true); }); - it("all-exceptions.filter.ts — tutarli zarf + generic 500'de sizinti yok (H1)", () => { + it("all-exceptions.filter.ts — consistent envelope + no leakage on generic 500s (H1)", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); const filter = fileByPath(files, "src/shared/filters/all-exceptions.filter.ts"); expect(filter.content).toContain("@Catch()"); expect(filter.content).toContain("implements ExceptionFilter"); - // Tutarli zarf alanlari. + // Consistent envelope fields. for (const field of ["statusCode", "error", "message", "requestId", "timestamp"]) { expect(filter.content).toContain(field); } - // HttpException status'u korunur; generic -> 500 + jenerik mesaj. + // The HttpException status is preserved; generic errors -> 500 + a generic message. expect(filter.content).toContain("exception instanceof HttpException"); expect(filter.content).toContain("HttpStatus.INTERNAL_SERVER_ERROR"); expect(filter.language).toBe("typescript"); @@ -225,8 +225,8 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { expect(ds.content).toContain("new DataSource({"); expect(ds.content).toContain('migrations: ["dist/migrations/*.js"]'); expect(ds.content).toContain("synchronize: false"); - // M1: data-source SADECE type/url/synchronize tasir — havuz/retry runtime'a - // (CoreModule forRootAsync) ait; CLI DataSource bunlardan OZGUN kalir. + // M1: data-source carries ONLY type/url/synchronize — pool/retry belong to the + // runtime (CoreModule forRootAsync); the CLI DataSource stays free of them. expect(ds.content).not.toContain("extra:"); expect(ds.content).not.toContain("retryAttempts"); // #2: data-source imports dotenv to load .env in the CLI context — dotenv MUST @@ -243,7 +243,7 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { expect(pkg.content).toContain('"typeorm-naming-strategies":'); }); - it("test/CI iskeleti: tsconfig.build + jest config + e2e smoke + .gitignore (H6)", () => { + it("test/CI skeleton: tsconfig.build + jest config + e2e smoke + .gitignore (H6)", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); const gitignore = fileByPath(files, ".gitignore"); for (const ignore of ["node_modules", "dist", ".env"]) { @@ -259,42 +259,42 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { expect(pkg.content).toContain('"@nestjs/testing"'); }); - it("tsconfig.json: TS7-uyumlu — baseUrl NONE (kaldirildi), types acik [node, jest], lib ES2022", () => { + it("tsconfig.json: TS7-compatible — no baseUrl (removed), explicit types [node, jest], lib ES2022", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); const tsconfig = fileByPath(files, "tsconfig.json"); - // baseUrl oluydu (import'lar relative) + TS 7.0 (tsgo) onu reddeder (TS5102) → OLMAMALI. + // baseUrl was dead weight (imports are relative) + TS 7.0 (tsgo) rejects it (TS5102) → it must NOT be present. expect(tsconfig.content).not.toContain("baseUrl"); - // @types global cozumu explicit (baseUrl gidince tsgo otomatik taramayi kaybeder). + // Explicit global @types resolution (without baseUrl, tsgo loses the automatic scan). expect(tsconfig.content).toContain('"types": ["node", "jest"]'); - // DOM-collision fix'i korunur. + // The DOM-collision fix is preserved. expect(tsconfig.content).toContain('"lib": ["ES2022"]'); - // Gecerli JSON kalmali. + // It must remain valid JSON. expect(() => JSON.parse(tsconfig.content)).not.toThrow(); }); - it(".env.example — KOKTE (H4); env node'larindan; secret ASLA gercek deger almaz", () => { + it(".env.example — at the ROOT (H4); built from env nodes; secrets NEVER get real values", () => { const { nodes, edges } = richGraph(); const files = emitScaffoldProject(ctxFor(nodes, edges)); const env = fileByPath(files, ".env.example"); - // DefaultValue olan public degisken degerini alir. + // A public variable with a DefaultValue gets its value. expect(env.content).toContain("DATABASE_URL=postgres://user:password@localhost:5432/app"); expect(env.content).toContain("PORT=3000"); - // Secret placeholder — gercek deger yok. + // Secret placeholder — no real value. expect(env.content).toContain("JWT_SECRET="); - // Aciklama + required/optional meta satiri. + // Description + required/optional meta line. expect(env.content).toContain("# Postgres connection string (Dev/Prod; required)"); expect(env.content).toContain("# HTTP portu (Dev; optional)"); - // M1: DB havuz/timeout/retry ornek degerleri. + // M1: DB pool/timeout/retry example values. expect(env.content).toContain("DB_POOL_MAX=10"); expect(env.content).toContain("DB_CONNECTION_TIMEOUT_MS=10000"); - // L2: CORS + body-limit (CORS bos = kapali). + // L2: CORS + body-limit (empty CORS = disabled). expect(env.content).toContain("CORS_ORIGIN="); expect(env.content).toContain("BODY_LIMIT=1mb"); expect(env.language).toBe("env"); }); - it("README.md — surgical doldurma talimati + uretim notu", () => { + it("README.md — surgical fill instructions + generation note", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); const readme = fileByPath(files, "README.md"); expect(readme.content).toContain("@solarch:surgical"); @@ -325,7 +325,7 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { const readme = fileByPath(files, "README.md"); // filters/ is always present -> always described. expect(readme.content).toContain("filters/all-exceptions.filter.ts"); - // guards/decorators are described as CONDITIONAL ("when ...") — kaydirma-toleransli. + // guards/decorators are described as CONDITIONAL ("when ...") — tolerant to line wrapping. expect(readme.content).toMatch(/generated when an endpoint\s+requires\s+authentication/); expect(readme.content).toMatch(/only when an endpoint\s+declares\s+required roles/); // README mentions the (conditional) current-user decorator (Finding #8). @@ -346,11 +346,11 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { ).toBeDefined(); }); - /* ── RBAC WIRE (#39): usesRoles -> GERCEK RolesGuard emit edilir ────────── - * Eskiden yalniz roles.decorator (metadata yazan) uretiliyordu; onu OKUYAN guard - * yoktu -> @Roles oluydu. Artik roles.guard.ts de uretilir: Reflector ile ROLES_KEY - * metadata'sini okuyup request.user.role'u gerekli rollere gore enforce eder. */ - it("usesRoles -> roles.guard.ts (RolesGuard: Reflector + ROLES_KEY metadata okur)", () => { + /* ── RBAC WIRE (#39): usesRoles -> a REAL RolesGuard is emitted ──────────── + * Previously only roles.decorator (which writes metadata) was generated; no guard + * READ it -> @Roles was dead. Now roles.guard.ts is generated too: it reads the + * ROLES_KEY metadata via Reflector and enforces request.user.role against the required roles. */ + it("usesRoles -> roles.guard.ts (RolesGuard: reads Reflector + ROLES_KEY metadata)", () => { const withRoles = node("Controller", { ControllerName: "SecureController", Description: "secure", @@ -364,19 +364,20 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { expect(guard!.content).toContain("Reflector"); expect(guard!.content).toContain("ROLES_KEY"); expect(guard!.content).toContain("getAllAndOverride"); - // Rol gerekmiyorsa gecer; aksi halde user.role gerekli rollerden biri olmali. - // #58: rol karsilastirmasi CASE-INSENSITIVE (graf "ADMIN" ↔ enum "admin" casing - // uyusmazligina dayanikli; RBAC sessizce kirilmasin). + // If no roles are required it passes; otherwise user.role must be one of the required roles. + // #58: the role comparison is CASE-INSENSITIVE (resilient to the graph "ADMIN" ↔ + // enum "admin" casing mismatch; RBAC must not break silently). expect(guard!.content).toContain("toLowerCase()"); expect(guard!.content).toMatch(/required\.some\(/); }); - /* ── CAPABILITY-LAYER AUTH (#37/#38): AuthGuard GERCEK JWT dogrular ─────── - * Eskiden AuthGuard `return true` placeholder'di (surgical, AI dolduruyor -> sahte - * JWT). Artik deterministik GERCEK guard: Bearer token'i JWT_SECRET ile dogrular, - * request.user'a cozulen claim'leri koyar (@CurrentUser + RolesGuard okur), 401 atar. - * Artik surgical NOT -> fill dokunmaz; auth strateji = JWT (env JWT_SECRET kullanilir). */ - it("AuthGuard GERCEK JWT dogrular (deterministik, surgical degil) + jsonwebtoken dep", () => { + /* ── CAPABILITY-LAYER AUTH (#37/#38): AuthGuard verifies REAL JWTs ───────── + * Previously AuthGuard was a `return true` placeholder (surgical, filled by AI -> fake + * JWT). Now it is a deterministic REAL guard: it verifies the Bearer token with + * JWT_SECRET, puts the decoded claims on request.user (read by @CurrentUser + RolesGuard), + * and throws 401. No longer surgical -> the fill does not touch it; auth strategy = JWT + * (the JWT_SECRET env var is used). */ + it("AuthGuard verifies REAL JWTs (deterministic, not surgical) + jsonwebtoken dep", () => { const withAuth = node("Controller", { ControllerName: "SecureController", Description: "secure", @@ -386,27 +387,27 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { const files = emitScaffoldProject(ctxFor([withAuth], [])); const guard = files.find((f) => f.path === "src/shared/guards/auth.guard.ts"); expect(guard).toBeDefined(); - // Token dogrulama tek-kaynak verifyAccessToken'a delege (auth-token.ts; JWT_SECRET orada). + // Token verification is delegated to the single-source verifyAccessToken (auth-token.ts; JWT_SECRET lives there). expect(guard!.content).toContain("verifyAccessToken"); expect(guard!.content).toContain("../auth/auth-token"); expect(guard!.content).toContain("request.user ="); expect(guard!.content).toContain("UnauthorizedException"); - // Placeholder gitti + artik surgical NOT (fill dokunmaz) + cast yok. - // (Gercek guard DA dogrulamadan AFTER return true yapar — kosulsuz degil.) + // The placeholder is gone + it is no longer surgical (the fill does not touch it) + no cast. + // (The real guard also returns true, but only AFTER verification — not unconditionally.) expect(guard!.content).not.toContain("@solarch:surgical"); expect(guard!.surgicalMarkers).toBe(0); expect(guard!.content).not.toContain("as any"); - // package.json auth deps (yalniz auth kullanilinca). + // package.json auth deps (only when auth is used). const pkg = files.find((f) => f.path === "package.json"); expect(pkg!.content).toContain('"jsonwebtoken"'); expect(pkg!.content).toContain('"@types/jsonwebtoken"'); }); - /* ── AUTH HELPER'LARI: password (bcrypt) + token (tek-kaynak) ──────────── - * Login/Register fill'i icin deterministik primitive'ler: comparePassword/ - * hashPassword (duz-metin karsilastirma yerine) + signAccessToken/verifyAccessToken - * (sahte 'token' yerine; AuthGuard ile TEK SOURCE). usesAuth iken uretilir. */ - it("auth helper'lari uretilir: password.ts (bcrypt) + auth-token.ts (sign/verify)", () => { + /* ── AUTH HELPERS: password (bcrypt) + token (single source) ────────────── + * Deterministic primitives for the Login/Register fill: comparePassword/ + * hashPassword (instead of plain-text comparison) + signAccessToken/verifyAccessToken + * (instead of a fake 'token'; SINGLE SOURCE shared with AuthGuard). Generated when usesAuth. */ + it("auth helpers are generated: password.ts (bcrypt) + auth-token.ts (sign/verify)", () => { const withAuth = node("Controller", { ControllerName: "SecureController", Description: "secure", @@ -424,17 +425,17 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { expect(tok!.content).toContain("export function signAccessToken"); expect(tok!.content).toContain("export function verifyAccessToken"); expect(tok!.content).toContain("JWT_SECRET"); - // AuthGuard artik tek-kaynak verifyAccessToken'i kullanir (inline verify degil). + // AuthGuard now uses the single-source verifyAccessToken (not an inline verify). const guard = files.find((f) => f.path === "src/shared/guards/auth.guard.ts")!; expect(guard.content).toContain("verifyAccessToken"); expect(guard.content).toContain("../auth/auth-token"); - // bcryptjs dep (auth kullanilinca). + // bcryptjs dep (when auth is used). const pkg = files.find((f) => f.path === "package.json")!; expect(pkg.content).toContain('"bcryptjs"'); expect(pkg.content).toContain('"@types/bcryptjs"'); }); - it("auth NONEKEN jsonwebtoken dep EKLENMEZ (kosullu)", () => { + it("without auth the jsonwebtoken dep is NOT added (conditional)", () => { const noAuth = node("Controller", { ControllerName: "PublicController", Description: "public", @@ -445,7 +446,7 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { expect(pkg!.content).not.toContain("jsonwebtoken"); }); - it("package.json — gerekli bagimliliklar pinlenmis", () => { + it("package.json — required dependencies are pinned", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes)); const pkg = fileByPath(files, "package.json"); for (const dep of [ @@ -460,7 +461,7 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { "typeorm", "pg", "reflect-metadata", - // L2: helmet + express (main.ts body-limit/CORS) daima. + // L2: helmet + express (main.ts body-limit/CORS) always. "helmet", "express", // #2 dotenv (data-source CLI) + #10 SnakeNamingStrategy (runtime + CLI). @@ -469,7 +470,7 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { ]) { expect(pkg.content).toContain(`"${dep}"`); } - // L4: paket-yoneticisi pin (README pnpm komutlari ile tutarli; Corepack okur). + // L4: package-manager pin (consistent with the README's pnpm commands; Corepack reads it). expect(pkg.content).toContain('"packageManager": "pnpm@10.0.0"'); expect(pkg.language).toBe("json"); }); @@ -477,16 +478,16 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { it("main.ts — NestFactory (bufferLogs + Pino logger) + ValidationPipe + ConfigService port", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes)); const main = fileByPath(files, "src/main.ts"); - // H2: bufferLogs + Pino logger devralir (fail-fast abortOnError varsayilan). + // H2: bufferLogs + the Pino logger takes over (fail-fast abortOnError is the default). expect(main.content).toContain("NestFactory.create(AppModule, { bufferLogs: true })"); expect(main.content).toContain('import { Logger } from "nestjs-pino";'); expect(main.content).toContain("app.useLogger(app.get(Logger))"); - // #66: whitelist + forbidNonWhitelisted -> bilinmeyen body alanlari 400 ile reddedilir - // (sessizce strip degil); transform -> DTO tip donusumu. + // #66: whitelist + forbidNonWhitelisted -> unknown body fields are rejected with 400 + // (not silently stripped); transform -> DTO type conversion. expect(main.content).toContain("whitelist: true"); expect(main.content).toContain("forbidNonWhitelisted: true"); expect(main.content).toContain("transform: true"); - // Port ConfigService'ten okunur (env dogrulamasindan sonra). + // The port is read from ConfigService (after env validation). expect(main.content).toContain("const config = app.get(ConfigService);"); expect(main.content).toContain('config.get("PORT")'); }); @@ -494,16 +495,16 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { it("main.ts — L1 graceful shutdown + L2 helmet/CORS/body-limit (ConfigService-gated)", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes)); const main = fileByPath(files, "src/main.ts"); - // L1: SIGTERM'de lifecycle hook'lari (TypeORM havuzu temiz kapanir). + // L1: lifecycle hooks on SIGTERM (the TypeORM pool closes cleanly). expect(main.content).toContain("app.enableShutdownHooks();"); - // L2: helmet guvenlik basliklari. + // L2: helmet security headers. expect(main.content).toContain('import helmet from "helmet";'); expect(main.content).toContain("app.use(helmet());"); - // L2: govde siniri (ConfigService-gated, makul default). + // L2: body limit (ConfigService-gated, sensible default). expect(main.content).toContain('import { json, urlencoded } from "express";'); expect(main.content).toContain('config.get("BODY_LIMIT") ?? "1mb"'); expect(main.content).toContain("app.use(json({ limit: bodyLimit }));"); - // L2: CORS yalniz CORS_ORIGIN tanimliysa acilir (yoksa kapali — prod-guvenli). + // L2: CORS is only enabled when CORS_ORIGIN is defined (otherwise off — prod-safe). expect(main.content).toContain('config.get("CORS_ORIGIN")'); expect(main.content).toContain("app.enableCors({"); }); @@ -536,28 +537,28 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { expect(pkg.content).toContain('"@scalar/nestjs-api-reference"'); }); - it("env.validation.ts — Joi semasi DAIMA uretilir (DATABASE_URL required, fail-fast)", () => { + it("env.validation.ts — the Joi schema is ALWAYS generated (DATABASE_URL required, fail-fast)", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes)); const v = fileByPath(files, "src/config/env.validation.ts"); expect(v.content).toContain('import Joi from "joi";'); expect(v.content).toContain("export const validationSchema = Joi.object({"); - // DATABASE_URL daima zorunlu (TypeORM forRootAsync bunu getOrThrow ile okur). + // DATABASE_URL is always required (TypeORM forRootAsync reads it via getOrThrow). expect(v.content).toContain("DATABASE_URL: Joi.string().required(),"); - // DefaultValue olan public degisken default() alir (required ile celismez). + // A public variable with a DefaultValue gets default() (does not conflict with required). expect(v.content).toContain("PORT: Joi.number().default(3000),"); - // Secret + required (default'suz) -> required(). + // Secret + required (no default) -> required(). expect(v.content).toContain("JWT_SECRET: Joi.string().required(),"); - // M1: DB havuz/timeout/retry knob'lari (default'lu opsiyonel sayilar). + // M1: DB pool/timeout/retry knobs (optional numbers with defaults). expect(v.content).toContain("DB_POOL_MAX: Joi.number().default(10),"); expect(v.content).toContain("DB_CONNECTION_TIMEOUT_MS: Joi.number().default(10000),"); expect(v.content).toContain("DB_RETRY_ATTEMPTS: Joi.number().default(10),"); expect(v.content).toContain("DB_RETRY_DELAY_MS: Joi.number().default(3000),"); - // L2: CORS_ORIGIN opsiyonel (bossa kapali), BODY_LIMIT default'lu. + // L2: CORS_ORIGIN is optional (disabled when empty), BODY_LIMIT has a default. expect(v.content).toContain('CORS_ORIGIN: Joi.string().allow("").optional(),'); expect(v.content).toContain('BODY_LIMIT: Joi.string().default("1mb"),'); }); - it("tum icerikler tek satir sonu ile biter", () => { + it("all contents end with a single trailing newline", () => { const files = emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)); for (const f of files) { expect(f.content.endsWith("\n")).toBe(true); @@ -572,7 +573,7 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { expect(a.map((f) => f.content)).toEqual(b.map((f) => f.content)); }); - it("snapshot — app.module.ts tam icerik (INCE; CoreModule + feature'lar)", () => { + it("snapshot — app.module.ts full content (THIN; CoreModule + features)", () => { const { nodes, edges } = richGraph(); const app = fileByPath(emitScaffoldProject(ctxFor(nodes, edges)), "src/app.module.ts"); expect(app.content).toMatchInlineSnapshot(` @@ -595,7 +596,7 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { `); }); - it("snapshot — core.module.ts tam icerik (TUM root altyapi + APP_FILTER + Pino)", () => { + it("snapshot — core.module.ts full content (ALL root infrastructure + APP_FILTER + Pino)", () => { const { nodes, edges } = richGraph(); const core = fileByPath(emitScaffoldProject(ctxFor(nodes, edges)), "src/core/core.module.ts"); expect(core.content).toMatchInlineSnapshot(` @@ -655,41 +656,41 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { `); }); - /* ── Edge-case: bos graph / kayip env ──────────────────────────────── */ - it("EDGE-CASE: bos graph -> sabit iskelet, app.module ince, env varsayilan", () => { + /* ── Edge-case: empty graph / missing env ──────────────────────────── */ + it("EDGE-CASE: empty graph -> fixed skeleton, thin app.module, default env", () => { const files = emitScaffoldProject(ctxFor([], [])); - // 15 dosya: temel iskelet (package/tsconfig x2/nest-cli/jest-e2e/.gitignore/ + // 15 files: the base skeleton (package/tsconfig x2/nest-cli/jest-e2e/.gitignore/ // main/app.module/core.module/filter/env.validation/data-source/e2e/.env.example/ - // README). EnvVar node yok -> configuration.ts NONE; auth/roles stub NONE. + // README). No EnvVar nodes -> no configuration.ts; no auth/roles stubs. expect(files).toHaveLength(15); const app = fileByPath(files, "src/app.module.ts"); - // INCE app.module: yalniz CoreModule; root forRoot CoreModule'de. + // THIN app.module: only CoreModule; root forRoot lives in CoreModule. expect(app.content).toContain(" CoreModule,"); expect(app.content).not.toContain("TypeOrmModule.forRootAsync"); expect(app.content).not.toContain("controllers:"); expect(app.content).not.toContain("providers:"); const core = fileByPath(files, "src/core/core.module.ts"); - // EnvVar yok -> load: [configuration] NONE. + // No EnvVars -> no load: [configuration]. expect(core.content).toContain("ConfigModule.forRoot({ isGlobal: true, validationSchema })"); expect(core.content).toContain("TypeOrmModule.forRootAsync({"); - // env.validation.ts daima var (DATABASE_URL zorunlu). + // env.validation.ts always exists (DATABASE_URL is required). const v = fileByPath(files, "src/config/env.validation.ts"); expect(v.content).toContain("DATABASE_URL: Joi.string().required(),"); const env = fileByPath(files, ".env.example"); - // Env node yok -> makul varsayilanlara duser. + // No env nodes -> falls back to sensible defaults. expect(env.content).toContain("PORT=3000"); expect(env.content).toContain("DATABASE_URL=postgres://user:password@localhost:5432/app"); - // Uretilen hicbir env satiri yok (env node'u yok), ama dosya gecerli. + // No generated env lines (there are no env nodes), but the file is valid. expect(env.content).not.toContain(""); }); - it("EDGE-CASE: Module node'suz tek Controller bile kendi feature modulunu alir (sahipsiz kalmaz)", () => { - // Yalniz bir Controller (hic Module / CALLS edge yok) -> kendi feature'i - // ("ping") + sentezlenmis PingModule; app.module ham controller NOT, - // PingModule'u import eder -> DI tam. + it("EDGE-CASE: even a lone Controller without a Module node gets its own feature module (never orphaned)", () => { + // Just one Controller (no Module / CALLS edges at all) -> its own feature + // ("ping") + a synthesized PingModule; app.module imports PingModule instead + // of the raw controller -> DI is complete. const lone = node("Controller", { ControllerName: "PingController", Description: "ping", @@ -700,24 +701,24 @@ describe("emitScaffoldProject (graph-farkinda scaffold)", () => { const app = fileByPath(files, "src/app.module.ts"); expect(app.content).toContain(" PingModule,"); expect(app.content).toContain('import { PingModule } from "./ping/ping.module";'); - // Ham controller app.module'e GIRMEZ (feature modulune kapsullendi). + // The raw controller does NOT go into app.module (it is encapsulated in the feature module). expect(app.content).not.toContain("controllers:"); expect(app.content).not.toContain("PingController"); }); }); -describe("fillDepsPackageJson (dogrulanmis in-app fill deps SUPERSET)", () => { - it("buildPackageJson'in TUM kosullu deps'lerini + test toolchain'ini icerir", () => { +describe("fillDepsPackageJson (verified in-app fill deps SUPERSET)", () => { + it("contains ALL of buildPackageJson's conditional deps + the test toolchain", () => { const pkg = JSON.parse(fillDepsPackageJson()) as { dependencies: Record; devDependencies: Record; }; - // Kosullu deps (cache/queue/http/schedule/event-emitter/redis) hepsi acik olmali: - // cache node_modules'un uretilen HER import'u cozmesi gerekir. + // The conditional deps (cache/queue/http/schedule/event-emitter/redis) must all be present: + // the cached node_modules has to resolve EVERY generated import. for (const dep of ["@nestjs/cache-manager", "cache-manager", "@keyv/redis", "@nestjs/bullmq", "bullmq", "@nestjs/axios", "axios", "@nestjs/schedule", "@nestjs/event-emitter"]) { - expect(pkg.dependencies[dep], `eksik dep: ${dep}`).toBeDefined(); + expect(pkg.dependencies[dep], `missing dep: ${dep}`).toBeDefined(); } - // Cekirdek runtime + tsc/jest (dogrulama bunlarsiz kosamaz). + // Core runtime + tsc/jest (verification cannot run without these). expect(pkg.dependencies["typeorm"]).toBeDefined(); expect(pkg.dependencies["@nestjs/typeorm"]).toBeDefined(); expect(pkg.devDependencies["typescript"]).toBeDefined(); @@ -725,11 +726,11 @@ describe("fillDepsPackageJson (dogrulanmis in-app fill deps SUPERSET)", () => { expect(pkg.devDependencies["ts-jest"]).toBeDefined(); }); - it("tsgo (native-preview) YALNIZ fill-deps cache'inde — kullanici projesine sizmaz", () => { + it("tsgo (native-preview) lives ONLY in the fill-deps cache — it never leaks into the user project", () => { const fillDeps = JSON.parse(fillDepsPackageJson()) as { devDependencies: Record }; - // Cache: in-app SOLARCH_USE_TSGO=1 gecidi icin tsgo binary'sini bulur. + // Cache: provides the tsgo binary for the in-app SOLARCH_USE_TSGO=1 gate. expect(fillDeps.devDependencies["@typescript/native-preview"]).toBeDefined(); - // Uretilen kullanici projesi (emitScaffoldProject package.json): pre-release arac GIRMEZ. + // The generated user project (emitScaffoldProject package.json): no pre-release tooling gets in. const userPkg = fileByPath(emitScaffoldProject(ctxFor(richGraph().nodes, richGraph().edges)), "package.json"); expect(userPkg.content).not.toContain("native-preview"); }); diff --git a/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.ts b/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.ts index a0cf2da..a09434c 100644 --- a/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.ts +++ b/apps/server/src/codegen/emitters/nestjs/scaffold.emitter.ts @@ -6,46 +6,46 @@ import { countSurgicalMarkers } from "../../surgical"; import { isLoginEndpoint } from "./controller.emitter"; import type { EnvironmentVariableNode } from "../../../nodes/schemas"; -/** EnvironmentVariable, foundation IR'in PropsByKind tablosunda yer almaz; bu - * yuzden propsOf<"EnvironmentVariable"> yoktur. Sema tipiyle dogrudan daraltiriz - * (DB zaten Zod-dogrulanmis — yalniz tip daraltma, calisma zamani donusumu yok). */ +/** EnvironmentVariable is not part of the foundation IR's PropsByKind table, so + * propsOf<"EnvironmentVariable"> does not exist. We narrow directly to the schema + * type (the DB is already Zod-validated — type narrowing only, no runtime conversion). */ type EnvProps = EnvironmentVariableNode["properties"]; const envPropsOf = (node: CodeNode): EnvProps => node.properties as EnvProps; /* ──────────────────────────────────────────────────────────────────────── - * scaffold.emitter.ts — GRAPH-FARKINDA proje-seviyesi iskelet (ScaffoldEmitter). + * scaffold.emitter.ts — GRAPH-AWARE project-level scaffold (ScaffoldEmitter). * - * Cekirdekteki sabit `scaffold.ts` (emitScaffold) bir PLACEHOLDER'dir. Bu emitter - * onun graph-farkinda, uretim-kalitesinde surumudur. Entegrasyon fazi montaj - * noktasinda (codegen.service) emitScaffoldProject cagirir (registry'de yer almaz — - * ScaffoldEmitter node'a bagli degildir). + * The static `scaffold.ts` (emitScaffold) in the core is a PLACEHOLDER. This + * emitter is its graph-aware, production-quality version. The integration-phase + * assembly point (codegen.service) calls emitScaffoldProject (it is not in the + * registry — a ScaffoldEmitter is not tied to a node). * - * Sozlesme: - * - named export, default NONE: `export const emitScaffoldProject: ScaffoldEmitter`. - * - SAF fonksiyon: (ctx) -> GeneratedFile[]. I/O yok, throw yok. - * - Yollar her zaman filePathFor / relativeImportPath ile (hardcode'lar SABIT). - * - import'lar ImportCollector ile sirali (elle "import ..." YASAK). - * - DETERMINISTIC: tum koleksiyonlar isme gore sirali (graph.allOf zaten sirali), - * timestamp/random NONE, sabit version pin'leri. Ayni graph -> byte-identical. - * - Her icerik tek "\n" ile biter. surgicalMarkers countSurgicalMarkers ile. + * Contract: + * - named export, no default: `export const emitScaffoldProject: ScaffoldEmitter`. + * - PURE function: (ctx) -> GeneratedFile[]. No I/O, no throws. + * - Paths always go through filePathFor / relativeImportPath (no hardcoding). + * - Imports are ordered via ImportCollector (hand-written "import ..." is forbidden). + * - DETERMINISTIC: all collections sorted by name (graph.allOf is already sorted), + * no timestamps/randomness, fixed version pins. Same graph -> byte-identical output. + * - Every content ends with a single "\n". surgicalMarkers via countSurgicalMarkers. * - * ARCHITECTURE (modern + optimize NestJS, Encore best-practice): - * package.json, tsconfig.json, tsconfig.build.json, nest-cli.json — sabit - * .gitignore, jest-e2e.json — sabit (H6) + * ARCHITECTURE (modern + optimized NestJS, Encore best practice): + * package.json, tsconfig.json, tsconfig.build.json, nest-cli.json — static + * .gitignore, jest-e2e.json — static (H6) * src/main.ts — NestFactory(bufferLogs) + Pino logger + ValidationPipe - * src/app.module.ts — INCE: yalniz CoreModule + CommonModule + feature module - * src/core/core.module.ts— TUM root forRoot/register (Config/TypeORM/Cache/Bull/ + * src/app.module.ts — THIN: only CoreModule + CommonModule + feature modules + * src/core/core.module.ts— ALL root forRoot/register (Config/TypeORM/Cache/Bull/ * Schedule/EventEmitter/Pino) + APP_FILTER (H1/H2/H3) * src/shared/filters/all-exceptions.filter.ts — global exception filter (H1) - * src/shared/guards/auth.guard.ts — gercek JWT guard (jsonwebtoken) - * src/shared/decorators/roles.decorator.ts — paylasimli stub decorator + * src/shared/guards/auth.guard.ts — real JWT guard (jsonwebtoken) + * src/shared/decorators/roles.decorator.ts — shared stub decorator * src/shared/decorators/current-user.decorator.ts — @CurrentUser + AuthUser/AuthResponse - * src/config/env.validation.ts — Joi fail-fast semasi - * src/config/configuration.ts — (EnvVar varsa) tipli config + * src/config/env.validation.ts — Joi fail-fast schema + * src/config/configuration.ts — typed config (when EnvVars exist) * src/data-source.ts — TypeORM CLI DataSource (H5) - * .env.example (KOKTE) — EnvVar node'lari (H4) + * .env.example (AT THE ROOT) — EnvironmentVariable nodes (H4) * test/app.e2e-spec.ts — smoke e2e (H6) - * README.md — uretim + surgical notlari + * README.md — generation + surgical notes * ──────────────────────────────────────────────────────────────────────── */ export const emitScaffoldProject: ScaffoldEmitter = (ctx: EmitterContext): GeneratedFile[] => { @@ -60,79 +60,79 @@ export const emitScaffoldProject: ScaffoldEmitter = (ctx: EmitterContext): Gener file(".gitignore", GITIGNORE, "markdown"), ts("src/main.ts", MAIN_TS), ts("src/app.module.ts", buildAppModule(ctx)), - // core/core.module.ts — TUM root forRoot/register + APP_FILTER + Pino logger. - // AppModule artik yalniz bunu (+ CommonModule + feature'lar) import eder. + // core/core.module.ts — ALL root forRoot/register + APP_FILTER + Pino logger. + // AppModule now imports only this (+ CommonModule + the feature modules). ts("src/core/core.module.ts", buildCoreModule(infra)), - // shared/filters/all-exceptions.filter.ts — global exception filter (tutarli zarf). + // shared/filters/all-exceptions.filter.ts — global exception filter (consistent envelope). ts("src/shared/filters/all-exceptions.filter.ts", ALL_EXCEPTIONS_FILTER_TS), - // env.validation.ts — Joi semasi. DAIMA uretilir: en az DATABASE_URL zorunlu + - // EnvVar node'larindan turetilen kurallar. Gecersiz/eksik env BOOT'ta firlatir. + // env.validation.ts — Joi schema. ALWAYS generated: at minimum DATABASE_URL is + // required, plus rules derived from EnvVar nodes. An invalid/missing env throws at BOOT. ts("src/config/env.validation.ts", buildEnvValidation(infra)), - // data-source.ts — TypeORM CLI DataSource (migration:run icin). DAIMA uretilir; - // migration yoksa bile derlenebilir (bos migrations dizini glob'u). + // data-source.ts — TypeORM CLI DataSource (for migration:run). ALWAYS generated; + // compiles even without migrations (empty migrations directory glob). ts("src/data-source.ts", buildDataSource()), - // test/app.e2e-spec.ts — smoke e2e: AppModule boot + GET / 404 (Nest 404 zarfi). + // test/app.e2e-spec.ts — smoke e2e: AppModule boot + GET / 404 (Nest 404 envelope). ts("test/app.e2e-spec.ts", APP_E2E_SPEC_TS), file(".env.example", buildEnvExample(ctx, infra), "env"), file("README.md", README_MD, "markdown"), ]; - // ENV -> TIPLI CONFIG: graph'ta EnvironmentVariable node'lari VARSA src/config/ - // configuration.ts uretilir (ConfigModule.forRoot load: [configuration]). + // ENV -> TYPED CONFIG: when the graph has EnvironmentVariable nodes, generate + // src/config/configuration.ts (ConfigModule.forRoot load: [configuration]). if (infra.envNodes.length > 0) { files.push(ts("src/config/configuration.ts", buildConfiguration(infra.envNodes))); } - // controller.emitter, RequiresAuth/RequiredRoles olan endpoint'ler icin - // `shared/guards/auth.guard` ve `shared/decorators/roles.decorator` import eder. - // O dosyalar baska HICBIR yerde uretilmez -> derleme TS2307 verir. Graph'ta en - // az bir kullanan endpoint VARSA stub'larini uret (yollar controller import'lariyla - // ayni: src/shared/...). Kullanilmiyorsa uretme. + // For endpoints with RequiresAuth/RequiredRoles, controller.emitter imports + // `shared/guards/auth.guard` and `shared/decorators/roles.decorator`. Those files + // are generated NOWHERE else -> compilation would fail with TS2307. If the graph + // has at least one endpoint using them, generate the stubs (paths match the + // controller imports: src/shared/...). If unused, do not generate. const usage = scanAuthUsage(ctx); if (usage.usesAuth) { files.push(ts("src/shared/guards/auth.guard.ts", AUTH_GUARD_TS)); - // Auth capability primitive'leri — Login/Register fill'i bunlari KULLANIR - // (duz-metin sifre / sahte token yerine). AuthGuard ile JWT tek-kaynak. + // Auth capability primitives — the Login/Register fill USES these (instead of + // plaintext passwords / fake tokens). Single source of JWT truth with AuthGuard. files.push(ts("src/shared/auth/password.ts", PASSWORD_TS)); files.push(ts("src/shared/auth/auth-token.ts", AUTH_TOKEN_TS)); } if (usage.usesRoles) { files.push(ts("src/shared/decorators/roles.decorator.ts", ROLES_DECORATOR_TS)); - // RBAC WIRE (#39): @Roles metadata'sini OKUYAN gercek guard. roles.decorator - // tek basina olu olurdu; RolesGuard Reflector ile metadata'yi enforce eder. + // RBAC WIRE (#39): the real guard that READS the @Roles metadata. roles.decorator + // alone would be dead; RolesGuard enforces the metadata via Reflector. files.push(ts("src/shared/guards/roles.guard.ts", ROLES_GUARD_TS)); } - // current-user.decorator.ts — @CurrentUser param decorator + AuthUser/AuthResponse - // tipleri. RequiresAuth endpoint'leri (Finding #8) ve login endpoint'leri buradan - // import eder; baska yerde uretilmez -> emit etmezsek TS2307. + // current-user.decorator.ts — @CurrentUser param decorator + the AuthUser/AuthResponse + // types. RequiresAuth endpoints (Finding #8) and login endpoints import from here; + // it is generated nowhere else -> TS2307 if we don't emit it. if (usage.usesCurrentUser) files.push(ts("src/shared/decorators/current-user.decorator.ts", CURRENT_USER_DECORATOR_TS)); return files; }; -/* ── Mimari altyapi taramasi (root registration + dependency kararlari) ──── - * Graph'taki kind'lara gore hangi @nestjs altyapi modullerinin app root'a - * kaydedilecegini (artik CoreModule'de) ve package.json'a hangi deps'in - * eklenecegini belirler. Tek gecis, tek kaynak — core.module + package.json - * AYNI flag'leri okur. */ +/* ── Infrastructure usage scan (root registration + dependency decisions) ──── + * Determines, based on the kinds present in the graph, which @nestjs infrastructure + * modules get registered at the app root (now inside CoreModule) and which deps get + * added to package.json. Single pass, single source — core.module + package.json + * read the SAME flags. */ interface InfraUsage { - /** @nestjs/cache-manager (Cache node varsa). */ + /** @nestjs/cache-manager (when a Cache node exists). */ usesCache: boolean; - /** Cache.Engine === "Redis" olan en az bir Cache var mi? (Redis store dep). */ + /** Is there at least one Cache with Engine === "Redis"? (Redis store dep). */ usesRedisCache: boolean; - /** @nestjs/bullmq + BullModule.forRoot (MessageQueue veya queue-handler varsa). */ + /** @nestjs/bullmq + BullModule.forRoot (when a MessageQueue or queue handler exists). */ usesQueue: boolean; - /** @nestjs/schedule + ScheduleModule.forRoot (Worker varsa). */ + /** @nestjs/schedule + ScheduleModule.forRoot (when a Worker exists). */ usesSchedule: boolean; - /** @nestjs/event-emitter + EventEmitterModule.forRoot (event-tabanli handler varsa). */ + /** @nestjs/event-emitter + EventEmitterModule.forRoot (when an event-based handler exists). */ usesEventEmitter: boolean; - /** @nestjs/axios (ExternalService varsa). */ + /** @nestjs/axios (when an ExternalService exists). */ usesHttp: boolean; - /** Auth kullaniliyor mu (RequiresAuth endpoint) — gercek AuthGuard jsonwebtoken - * ile JWT dogrular → jsonwebtoken + @types/jsonwebtoken dep'i kosullu eklenir. */ + /** Whether auth is used (a RequiresAuth endpoint) — the real AuthGuard verifies JWTs + * via jsonwebtoken → the jsonwebtoken + @types/jsonwebtoken deps are added conditionally. */ usesAuth: boolean; - /** EnvironmentVariable node'lari (tipli config + .env.example). */ + /** EnvironmentVariable nodes (typed config + .env.example). */ envNodes: CodeNode[]; } @@ -145,8 +145,8 @@ function scanInfraUsage(ctx: EmitterContext): InfraUsage { const handlers = graph.allOf("EventHandler"); const envNodes = graph.allOf("EnvironmentVariable"); - // Her EventHandler kuyruk-tabanli (SUBSCRIBES/QueueRef) mi, olay-tabanli - // (@OnEvent) mi? -> BullModule vs EventEmitterModule karari. + // Is each EventHandler queue-based (SUBSCRIBES/QueueRef) or event-based + // (@OnEvent)? -> decides BullModule vs EventEmitterModule. let usesEventEmitter = false; let usesQueueHandler = false; for (const h of handlers) { @@ -171,8 +171,8 @@ function scanInfraUsage(ctx: EmitterContext): InfraUsage { }; } -/** Bir EventHandler kuyruk-tabanli mi? (SUBSCRIBES edge'i veya QueueRef property'si - * ile bir MessageQueue'ya bagli.) event-handler.emitter ile ayni cozum. */ +/** Is an EventHandler queue-based? (Linked to a MessageQueue via a SUBSCRIBES edge + * or a QueueRef property.) Same resolution as event-handler.emitter. */ function handlerIsQueueBased(handler: CodeNode, graph: EmitterContext["graph"]): boolean { for (const e of graph.outEdges(handler.id, "SUBSCRIBES")) { const tgt = graph.byId(e.targetNodeId); @@ -185,15 +185,15 @@ function handlerIsQueueBased(handler: CodeNode, graph: EmitterContext["graph"]): return false; } -/** Graph'taki Controller endpoint'lerinde RequiresAuth / RequiredRoles / - * current-user.decorator kullanimi var mi? (controller.emitter ile AYNI kosullar.) +/** Do the Controller endpoints in the graph use RequiresAuth / RequiredRoles / + * current-user.decorator? (Same conditions as controller.emitter.) * - * usesCurrentUser: controller.emitter `shared/decorators/current-user.decorator` - * dosyasindan import uretiyor mu? Iki yol: - * - RequiresAuth bir endpoint -> @CurrentUser() user: AuthUser parametresi - * - ResponseDTORef WITHOUT login endpoint -> Promise donus - * Her iki durumda da o dosya BASKA hicbir yerde uretilmez -> TS2307. Bu yuzden - * bu kosullardan biri tutuyorsa current-user.decorator.ts emit edilmeli. */ + * usesCurrentUser: does controller.emitter generate an import from + * `shared/decorators/current-user.decorator`? Two paths: + * - an endpoint with RequiresAuth -> @CurrentUser() user: AuthUser parameter + * - a login endpoint without ResponseDTORef -> Promise return + * In both cases that file is generated nowhere else -> TS2307. So when either + * condition holds, current-user.decorator.ts must be emitted. */ function scanAuthUsage( ctx: EmitterContext, ): { usesAuth: boolean; usesRoles: boolean; usesCurrentUser: boolean } { @@ -207,19 +207,19 @@ function scanAuthUsage( usesCurrentUser = true; // @CurrentUser() user: AuthUser } if ((ep.RequiredRoles ?? []).length > 0) usesRoles = true; - // ResponseDTORef WITHOUT login endpoint -> Promise donus. + // Login endpoint without ResponseDTORef -> returns Promise. if (!ep.ResponseDTORef && isLoginEndpoint(ep)) usesCurrentUser = true; } } return { usesAuth, usesRoles, usesCurrentUser }; } -/* ── src/app.module.ts (INCE — yalniz kompozisyon) ───────────────────────── - * H3: app.module artik root forRoot/register ICERMEZ. Yalniz: - * - CoreModule (tum root altyapi + APP_FILTER + Pino — TEK import) - * - CommonModule (varsa; feature-bagsiz altyapi) - * - Module'ler (slug'a sirali) - * Feature listesi slug'a gore sirali (determinizm). +/* ── src/app.module.ts (THIN — composition only) ───────────────────────── + * H3: app.module no longer contains root forRoot/register. Only: + * - CoreModule (all root infrastructure + APP_FILTER + Pino — a SINGLE import) + * - CommonModule (if present; feature-independent infrastructure) + * - Modules (sorted by slug) + * The feature list is sorted by slug (determinism). * ──────────────────────────────────────────────────────────────────────── */ function buildAppModule(ctx: EmitterContext): string { const graph = ctx.graph; @@ -231,15 +231,15 @@ function buildAppModule(ctx: EmitterContext): string { const moduleClassNames: string[] = ["CoreModule"]; - // Tum feature modullerini import et (slug'a sirali) -> imports[]. + // Import every feature module (sorted by slug) -> imports[]. for (const feature of graph.features()) { const className = `${pascalCase(feature.slug)}Module`; const modPath = `src/${feature.slug}/${feature.slug}.module.ts`; imports.add(className, importPathOf(relativeImportPath(appModulePath, modPath))); moduleClassNames.push(className); } - // CommonModule (feature-bagsiz altyapi: kuyruk/handler/cache + paylasimli HTTP - // giris katmani) varsa onu da import et -> orphan provider KALMAZ. + // If a CommonModule exists (feature-independent infrastructure: queues/handlers/ + // caches + the shared HTTP entry layer), import it too -> no orphan providers remain. if (graph.commonFeature()) { imports.add("CommonModule", importPathOf(relativeImportPath(appModulePath, "src/common/common.module.ts"))); moduleClassNames.push("CommonModule"); @@ -258,18 +258,18 @@ function buildAppModule(ctx: EmitterContext): string { return lines.join("\n"); } -/* ── src/core/core.module.ts (TUM ROOT ALTYAPI — H1/H2/H3) ──────────────── - * Uygulama genelinde TEK kez kaydedilen her sey burada toplanir: +/* ── src/core/core.module.ts (ALL ROOT INFRASTRUCTURE — H1/H2/H3) ──────────────── + * Everything registered exactly ONCE across the application is gathered here: * - ConfigModule.forRoot (isGlobal + Joi validationSchema, fail-fast) * - LoggerModule.forRoot (nestjs-pino structured logging — H2) * - TypeOrmModule.forRootAsync (ConfigService -> DATABASE_URL) - * - CacheModule.register (Cache varsa) - * - BullModule.forRoot (Queue varsa) - * - ScheduleModule.forRoot (Worker varsa) - * - EventEmitterModule.forRoot (event-tabanli handler varsa) + * - CacheModule.register (when a Cache exists) + * - BullModule.forRoot (when a Queue exists) + * - ScheduleModule.forRoot (when a Worker exists) + * - EventEmitterModule.forRoot (when an event-based handler exists) * - APP_FILTER -> AllExceptionsFilter (global exception filter — H1) - * @Global NOTDIR: AppModule'de tek import yeter (Nest root altyapi modulleri - * zaten kendi global token'larini —ConfigService/Logger/DataSource— yayar). + * NOT @Global: a single import in AppModule is enough (the Nest root infrastructure + * modules already expose their own global tokens — ConfigService/Logger/DataSource). * ──────────────────────────────────────────────────────────────────────── */ function buildCoreModule(infra: InfraUsage): string { const coreModulePath = "src/core/core.module.ts"; @@ -283,7 +283,7 @@ function buildCoreModule(infra: InfraUsage): string { const rootImportLines: string[] = []; - // ── ConfigModule.forRoot — DAIMA + FAIL-FAST. ───────────────────────────── + // ── ConfigModule.forRoot — ALWAYS + FAIL-FAST. ───────────────────────────── imports.add("ConfigModule", "@nestjs/config"); imports.add("validationSchema", importPathOf(relativeImportPath(coreModulePath, "src/config/env.validation.ts"))); if (infra.envNodes.length > 0) { @@ -293,9 +293,9 @@ function buildCoreModule(infra: InfraUsage): string { rootImportLines.push(" ConfigModule.forRoot({ isGlobal: true, validationSchema }),"); } - // ── LoggerModule.forRoot (nestjs-pino) — yapilandirilmis JSON loglama (H2). ─ - // DI'lanabilir Logger (@nestjs/common veya PinoLogger) her serviste mevcut - // olur; console.log yerine bu kullanilir. Dev'de pino-pretty (NODE_ENV ile). + // ── LoggerModule.forRoot (nestjs-pino) — structured JSON logging (H2). ─ + // An injectable Logger (@nestjs/common or PinoLogger) is available in every + // service; used instead of console.log. pino-pretty in dev (via NODE_ENV). imports.add("LoggerModule", "nestjs-pino"); rootImportLines.push( " LoggerModule.forRoot({", @@ -309,12 +309,12 @@ function buildCoreModule(infra: InfraUsage): string { " }),", ); - // ── TypeOrmModule.forRootAsync(ConfigService) — daima (Postgres). ───────── - // M1: baglanti havuzu + timeout + sinirli retry. ConfigService-gated; ilgili - // env yoksa makul sabit default'lara duser (sonsuz retry'i ONLER). Havuz - // ayarlari `extra` ile pg surucusune gecer (max acik baglanti + baglanti - // kurma timeout'u). retryAttempts/retryDelay TypeOrmModule'un kendi - // yeniden-baglanma davranisini sinirlar. + // ── TypeOrmModule.forRootAsync(ConfigService) — always (Postgres). ───────── + // M1: connection pool + timeout + bounded retry. ConfigService-gated; when the + // relevant env is missing it falls back to sensible fixed defaults (PREVENTS + // infinite retry). Pool settings pass to the pg driver via `extra` (max open + // connections + connection-establishment timeout). retryAttempts/retryDelay + // bound TypeOrmModule's own reconnect behavior. imports.add("ConfigService", "@nestjs/config"); imports.add("TypeOrmModule", "@nestjs/typeorm"); imports.add("SnakeNamingStrategy", "typeorm-naming-strategies"); @@ -341,13 +341,13 @@ function buildCoreModule(infra: InfraUsage): string { " }),", ); - // CacheModule.register({ isGlobal: true }) — CACHE_MANAGER token uygulama geneli. + // CacheModule.register({ isGlobal: true }) — CACHE_MANAGER token app-wide. if (infra.usesCache) { imports.add("CacheModule", "@nestjs/cache-manager"); rootImportLines.push(" CacheModule.register({ isGlobal: true }),"); } - // BullModule.forRoot({ connection }) — Redis baglantisi (Queue varsa). + // BullModule.forRoot({ connection }) — Redis connection (when a Queue exists). if (infra.usesQueue) { imports.add("BullModule", "@nestjs/bullmq"); rootImportLines.push( @@ -360,13 +360,13 @@ function buildCoreModule(infra: InfraUsage): string { ); } - // ScheduleModule.forRoot() — @Cron handler'lari (Worker varsa) ateslensin. + // ScheduleModule.forRoot() — so @Cron handlers fire (when a Worker exists). if (infra.usesSchedule) { imports.add("ScheduleModule", "@nestjs/schedule"); rootImportLines.push(" ScheduleModule.forRoot(),"); } - // EventEmitterModule.forRoot() — @OnEvent handler'lari (event-tabanli) calissin. + // EventEmitterModule.forRoot() — so @OnEvent handlers (event-based) run. if (infra.usesEventEmitter) { imports.add("EventEmitterModule", "@nestjs/event-emitter"); rootImportLines.push(" EventEmitterModule.forRoot(),"); @@ -390,11 +390,11 @@ function buildCoreModule(infra: InfraUsage): string { return lines.join("\n"); } -/* ── .env.example (KOKTE — H4; graph-farkinda) ───────────────────────────── - * EnvironmentVariable node'larindan (isme gore sirali) anahtar=deger satirlari. - * GUVENLIK: IsSecret=true ise deger ASLA yazilmaz -> "" placeholder. - * Proje kokune yazilir (".env" geleneksel olarak kokten okunur); src/.env.example - * NOT. +/* ── .env.example (AT THE ROOT — H4; graph-aware) ───────────────────────────── + * key=value lines from the EnvironmentVariable nodes (sorted by name). + * SECURITY: when IsSecret=true the value is NEVER written -> "" + * placeholder. Written to the project root (".env" is conventionally read from + * the root); NOT src/.env.example. * ──────────────────────────────────────────────────────────────────────── */ function buildEnvExample(ctx: EmitterContext, infra: InfraUsage): string { const envNodes = ctx.graph.allOf("EnvironmentVariable"); @@ -421,9 +421,9 @@ function buildEnvExample(ctx: EmitterContext, infra: InfraUsage): string { } } - // ── Cekirdek calisma-zamani ayarlari (graph'tan bagimsiz; daima). ───────── - // M1: TypeORM havuz/timeout/retry. L2: CORS + govde siniri. Hepsi opsiyonel - // (Joi default'lari var); degerler yorum satirinda varsayilani gosterir. + // ── Core runtime settings (independent of the graph; always). ───────── + // M1: TypeORM pool/timeout/retry. L2: CORS + body limit. All optional + // (Joi defaults exist); the values shown reflect the defaults. lines.push("", "# TypeORM connection pool + timeout + bounded retry (M1)"); lines.push("DB_POOL_MAX=10"); lines.push("DB_CONNECTION_TIMEOUT_MS=10000"); @@ -437,15 +437,15 @@ function buildEnvExample(ctx: EmitterContext, infra: InfraUsage): string { lines.push("# Additive to CORS_ORIGIN; leave empty in prod (does NOT loosen CORS_ORIGIN)."); lines.push("DOCS_CORS_ORIGIN="); - // ── Mimari altyapi env anahtarlari (graph kind'larina gore, deterministik) ── + // ── Infrastructure env keys (based on the graph kinds, deterministic) ── appendInfraEnvKeys(lines, ctx, infra); return lines.join("\n"); } -/** Queue (Redis) + ExternalService env anahtarlarini .env.example'a ekler. - * external-service.emitter ile ayni PREFIX (snakeCase(name).toUpperCase()) ve - * anahtar adlari (_BASE_URL/_TIMEOUT_SECONDS/_AUTH_TOKEN|_API_KEY). */ +/** Appends the Queue (Redis) + ExternalService env keys to .env.example. + * Same PREFIX (snakeCase(name).toUpperCase()) and key names + * (_BASE_URL/_TIMEOUT_SECONDS/_AUTH_TOKEN|_API_KEY) as external-service.emitter. */ function appendInfraEnvKeys(lines: string[], ctx: EmitterContext, infra: InfraUsage): void { if (infra.usesQueue) { lines.push("", "# BullMQ Redis connection (queues)"); @@ -469,7 +469,7 @@ function appendInfraEnvKeys(lines: string[], ctx: EmitterContext, infra: InfraUs } } -/** snakeCase(name).toUpperCase() — external-service.emitter envPrefix ile birebir. */ +/** snakeCase(name).toUpperCase() — exact match with external-service.emitter's envPrefix. */ function snakeUpper(input: string): string { return input .replace(/([a-z0-9])([A-Z])/g, "$1 $2") @@ -480,7 +480,7 @@ function snakeUpper(input: string): string { .join("_"); } -/** Bir env degiskeninin .env.example degeri. Secret ASLA gercek deger almaz. */ +/** The .env.example value of an env variable. A secret NEVER gets a real value. */ function envValueFor(p: EnvProps): string { if (p.IsSecret) { return ""; @@ -498,7 +498,7 @@ function envValueFor(p: EnvProps): string { } } -/* ── GeneratedFile yardimcilari (scaffold.ts ile ayni desen) ──────────────── */ +/* ── GeneratedFile helpers (same pattern as scaffold.ts) ──────────────── */ function file(path: string, content: string, language: GeneratedFile["language"]): GeneratedFile { const normalized = content.endsWith("\n") ? content : `${content}\n`; return { path, content: normalized, language, surgicalMarkers: countSurgicalMarkers(normalized) }; @@ -506,9 +506,9 @@ function file(path: string, content: string, language: GeneratedFile["language"] const ts = (p: string, c: string) => file(p, c, "typescript"); const json = (p: string, c: string) => file(p, c, "json"); -/* ── package.json (GRAPH-FARKINDA dependency secimi) ─────────────────────── - * Cekirdek deps daima; mimari altyapi deps KULLANILAN kind'lara gore eklenir. - * Surum pin'leri SABIT (determinizm); deps anahtarlari sirali. */ +/* ── package.json (GRAPH-AWARE dependency selection) ─────────────────────── + * Core deps always; infrastructure deps are added based on the kinds actually USED. + * Version pins are FIXED (determinism); dep keys are sorted. */ function buildPackageJson(infra: InfraUsage): string { const deps: Record = { "@nestjs/common": "^11.0.0", @@ -528,13 +528,13 @@ function buildPackageJson(infra: InfraUsage): string { // dotenv: data-source.ts (TypeORM CLI) loads .env manually outside NestFactory // (H5). It is imported directly there, so it must be an explicit dependency. dotenv: "^17.0.0", - // express: main.ts json/urlencoded body-limit + CORS icin (L2). Platform - // altinda zaten var; dogrudan import edildigi icin acik dep yapilir. + // express: for the main.ts json/urlencoded body limit + CORS (L2). Already + // present under the platform; made an explicit dep because it is imported directly. express: "^5.0.0", - // helmet: guvenlik HTTP basliklari (main.ts, L2). + // helmet: security HTTP headers (main.ts, L2). helmet: "^8.0.0", joi: "^17.13.0", - // nestjs-pino + pino-http: yapilandirilmis JSON loglama (CoreModule, H2). + // nestjs-pino + pino-http: structured JSON logging (CoreModule, H2). "nestjs-pino": "^4.1.0", pg: "^8.13.1", "pino-http": "^10.0.0", @@ -548,17 +548,17 @@ function buildPackageJson(infra: InfraUsage): string { "typeorm-naming-strategies": "^4.1.0", }; const devDeps: Record = { - // Test/CI iskeleti (H6): jest + ts-jest + @nestjs/testing + supertest. + // Test/CI skeleton (H6): jest + ts-jest + @nestjs/testing + supertest. "@nestjs/cli": "^11.0.0", "@nestjs/testing": "^11.0.0", - // @types/express: main.ts daima express json/urlencoded import eder (L2); - // Middleware emitter'i da Request/Response tiplerini kullanir. + // @types/express: main.ts always imports express json/urlencoded (L2); + // the Middleware emitter also uses the Request/Response types. "@types/express": "^5.0.0", "@types/jest": "^29.5.0", "@types/node": "^22.10.0", "@types/supertest": "^6.0.0", jest: "^29.7.0", - // pino-pretty: dev'de okunakli log (production'da devre disi). + // pino-pretty: readable logs in dev (disabled in production). "pino-pretty": "^11.0.0", supertest: "^7.0.0", "ts-jest": "^29.2.0", @@ -581,8 +581,8 @@ function buildPackageJson(infra: InfraUsage): string { } if (infra.usesSchedule) deps["@nestjs/schedule"] = "^5.0.0"; if (infra.usesEventEmitter) deps["@nestjs/event-emitter"] = "^3.0.0"; - // Auth: gercek AuthGuard Bearer JWT'yi dogrular (jsonwebtoken) + login servisi - // sifreyi bcrypt ile hash'ler/karsilastirir (bcryptjs — saf JS, native derleme yok). + // Auth: the real AuthGuard verifies the Bearer JWT (jsonwebtoken) + the login + // service hashes/compares passwords with bcrypt (bcryptjs — pure JS, no native build). if (infra.usesAuth) { deps["jsonwebtoken"] = "^9.0.0"; deps["bcryptjs"] = "^2.4.3"; @@ -594,22 +594,22 @@ function buildPackageJson(infra: InfraUsage): string { name: "solarch-generated", version: "0.1.0", private: true, - // L4: paket-yoneticisi pin. README pnpm komutlari kullanir; Corepack bu alani - // okuyarak dogru pnpm surumunu etkinlestirir (tutarli kurulum). SABIT surum - // (determinizm); deterministik uretimde lockfile yazilmaz, pin yeterlidir. + // L4: package-manager pin. The README uses pnpm commands; Corepack reads this + // field to activate the right pnpm version (consistent installs). FIXED version + // (determinism); deterministic generation writes no lockfile, the pin suffices. packageManager: "pnpm@10.0.0", scripts: { build: "nest build", start: "node dist/main.js", "start:dev": "nest start --watch", - // H5: TypeORM CLI migration:run, data-source.ts uzerinden. + // H5: TypeORM CLI migration:run, via data-source.ts. "db:migrate": "typeorm migration:run -d dist/data-source.js", "db:migrate:revert": "typeorm migration:revert -d dist/data-source.js", // H6: jest unit + e2e. test: "jest", "test:e2e": "jest --config jest-e2e.json", }, - // H6: jest unit konfigurasyonu (ts-jest, src/ koku, *.spec.ts). + // H6: jest unit configuration (ts-jest, src/ root, *.spec.ts). jest: { moduleFileExtensions: ["js", "json", "ts"], rootDir: "src", @@ -624,11 +624,11 @@ function buildPackageJson(infra: InfraUsage): string { }); } -/** Sunucu-tarafi DOGRULANMIS fill icin node_modules cache'inin kurulacagi KANONIK - * SUPERSET package.json — buildPackageJson'in TUM kosullu deps'leri (cache/queue/ - * http/schedule/event-emitter) acik. Cache bundan kurulur → codegen'in emit - * edebilecegi HER import tsc tarafindan cozulur. Tek kaynak buildPackageJson → - * yeni bir dep eklenince cache de otomatik kapsar (drift yok). */ +/** The CANONICAL SUPERSET package.json the node_modules cache is built from for + * server-side VERIFIED fill — ALL of buildPackageJson's conditional deps (cache/ + * queue/http/schedule/event-emitter) turned on. The cache installs from this → + * EVERY import codegen can emit resolves under tsc. Single source is + * buildPackageJson → when a new dep is added, the cache covers it automatically (no drift). */ export function fillDepsPackageJson(): string { const pkg = JSON.parse( buildPackageJson({ @@ -642,10 +642,10 @@ export function fillDepsPackageJson(): string { envNodes: [], }), ) as { devDependencies?: Record }; - // tsgo (TypeScript 7.0 native): YALNIZ fill-deps cache'ine ekle → in-app DOGRULANMIS fill, - // SOLARCH_USE_TSGO=1 iken ~9x hizli tsc gecidi icin binary'yi bulur. Uretilen KULLANICI - // projelerine GIRMEZ (buildPackageJson'a dokunulmadi) — pre-release arac shipped output'a - // sizmasin. Flag yokken cache'te durur ama kullanilmaz. devDeps sirali tutulur (determinizm). + // tsgo (TypeScript 7.0 native): added ONLY to the fill-deps cache → in-app VERIFIED fill + // finds the binary for the ~9x faster tsc pass when SOLARCH_USE_TSGO=1. It does NOT enter + // generated USER projects (buildPackageJson untouched) — a pre-release tool must not leak + // into shipped output. Without the flag it sits in the cache unused. devDeps stay sorted (determinism). pkg.devDependencies = sortObject({ ...(pkg.devDependencies ?? {}), "@typescript/native-preview": "latest", @@ -653,12 +653,12 @@ export function fillDepsPackageJson(): string { return jsonStringify(pkg); } -/** Deterministik 2-bosluk JSON (anahtar sirasi verildigi gibi korunur). */ +/** Deterministic 2-space JSON (key order preserved as given). */ function jsonStringify(value: unknown): string { return JSON.stringify(value, null, 2); } -/** Bir Record'un anahtarlarini alfabetik siralayip yeniden kurar (deterministik). */ +/** Rebuilds a Record with its keys sorted alphabetically (deterministic). */ function sortObject(rec: Record): Record { const out: Record = {}; for (const k of Object.keys(rec).sort()) out[k] = rec[k]; @@ -666,16 +666,16 @@ function sortObject(rec: Record): Record { } /* ── src/config/env.validation.ts (Joi -> FAIL-FAST) ────────────────────── - * ConfigModule.forRoot({ validationSchema }) ile kullanilan Joi object semasi. - * Gecersiz/eksik bir env BOOT'ta firlatir. + * The Joi object schema used with ConfigModule.forRoot({ validationSchema }). + * An invalid/missing env throws at BOOT. * - * Kurallar: - * - DATABASE_URL: DAIMA Joi.string().required() (TypeORM forRootAsync bunu okur). - * - PORT: Joi.number().default(3000) (main.ts kullanir). - * - EnvironmentVariable node'lari: DataType -> Joi tipi; IsRequired -> required(); - * (secret WITHOUT) DefaultValue -> default(...). Isme gore sirali (determinizm). - * - usesQueue ise REDIS_HOST/REDIS_PORT (BullMQ baglantisi) eklenir. - * Hicbir gercek secret DEGERI gomulmez (yalniz tip/zorunluluk kurallari). ──── */ + * Rules: + * - DATABASE_URL: ALWAYS Joi.string().required() (TypeORM forRootAsync reads it). + * - PORT: Joi.number().default(3000) (used by main.ts). + * - EnvironmentVariable nodes: DataType -> Joi type; IsRequired -> required(); + * (non-secret) DefaultValue -> default(...). Sorted by name (determinism). + * - When usesQueue, REDIS_HOST/REDIS_PORT (BullMQ connection) are added. + * No real secret VALUE is ever embedded (only type/required rules). ──── */ function buildEnvValidation(infra: InfraUsage): string { const imports = new ImportCollector(); imports.addDefault("Joi", "joi"); @@ -683,13 +683,13 @@ function buildEnvValidation(infra: InfraUsage): string { const entries = new Map(); entries.set("DATABASE_URL", "Joi.string().required()"); entries.set("PORT", "Joi.number().default(3000)"); - // M1: TypeORM havuz/timeout/retry ayarlari (CoreModule forRootAsync okur). + // M1: TypeORM pool/timeout/retry settings (read by CoreModule forRootAsync). entries.set("DB_POOL_MAX", "Joi.number().default(10)"); entries.set("DB_CONNECTION_TIMEOUT_MS", "Joi.number().default(10000)"); entries.set("DB_RETRY_ATTEMPTS", "Joi.number().default(10)"); entries.set("DB_RETRY_DELAY_MS", "Joi.number().default(3000)"); - // L2: HTTP guvenligi. CORS_ORIGIN opsiyonel (bossa CORS kapali); BODY_LIMIT - // express body-parser limit dizesi (or. "1mb"). + // L2: HTTP security. CORS_ORIGIN is optional (when empty, CORS is off); + // BODY_LIMIT is the express body-parser limit string (e.g. "1mb"). entries.set("CORS_ORIGIN", "Joi.string().allow(\"\").optional()"); entries.set("BODY_LIMIT", "Joi.string().default(\"1mb\")"); // Self-documenting app: separate dev/docs CORS allowance for the Scalar /docs @@ -723,8 +723,8 @@ function buildEnvValidation(infra: InfraUsage): string { return lines.join("\n"); } -/** Bir EnvVar node'unun Joi kurali (DataType + IsRequired + secret-olmayan - * DefaultValue). Secret deger ASLA gomulmez (yalniz tip/zorunluluk). */ +/** The Joi rule for an EnvVar node (DataType + IsRequired + non-secret + * DefaultValue). A secret value is NEVER embedded (only type/required). */ function joiRuleFor(p: EnvProps): string { let base: string; switch (p.DataType) { @@ -748,8 +748,8 @@ function joiRuleFor(p: EnvProps): string { return base; } -/* ── src/config/configuration.ts (ENV -> TIPLI CONFIG) ───────────────────── - * EnvironmentVariable node'larindan tipli bir config nesnesi uretir. */ +/* ── src/config/configuration.ts (ENV -> TYPED CONFIG) ───────────────────── + * Generates a typed config object from the EnvironmentVariable nodes. */ function buildConfiguration(envNodes: CodeNode[]): string { const lines: string[] = []; lines.push("/**"); @@ -770,7 +770,7 @@ function buildConfiguration(envNodes: CodeNode[]): string { return lines.join("\n"); } -/** Bir env degiskeninin process.env okuma ifadesi (DataType'a gore donusumlu). */ +/** The process.env read expression for an env variable (converted per DataType). */ function envReadExpr(p: EnvProps, key: string): string { const raw = `process.env.${key}`; switch (p.DataType) { @@ -783,7 +783,7 @@ function envReadExpr(p: EnvProps, key: string): string { } } -/** Bir ENV anahtarini ("DATABASE_URL") camelCase alan adina ("databaseUrl"). */ +/** Converts an ENV key ("DATABASE_URL") to a camelCase field name ("databaseUrl"). */ function camelCaseKey(key: string): string { const words = key .split(/[\s\-_./]+/) @@ -798,11 +798,11 @@ function byName(a: CodeNode, b: CodeNode): number { } /* ── src/data-source.ts (TypeORM CLI DataSource — H5) ────────────────────── - * `typeorm migration:run -d dist/data-source.js` bunu yukler. Calisma zamani - * uygulamasi (TypeOrmModule.forRootAsync) ile AYNI baglanti bilgisini paylasir - * (DATABASE_URL); entity'ler glob ile otomatik bulunur. migrations glob'u - * src/migrations/*.ts TS migration siniflarina bakar (orchestrator uretir). - * synchronize:false KORUNUR — sema yalniz migration ile degisir. ──────────── */ + * `typeorm migration:run -d dist/data-source.js` loads this. It shares the SAME + * connection info (DATABASE_URL) as the runtime application (TypeOrmModule. + * forRootAsync); entities are auto-discovered via glob. The migrations glob looks + * at the src/migrations/*.ts TS migration classes (produced by the orchestrator). + * synchronize:false is PRESERVED — the schema changes through migrations only. ──── */ function buildDataSource(): string { return `import "reflect-metadata"; import { config as loadEnv } from "dotenv"; @@ -836,10 +836,11 @@ export default new DataSource({ });`; } -// NOT: baseUrl NONE — uretilen import'lar tamamen relative (oluydu) + TS 7.0 (tsgo) baseUrl'i -// kaldirdi (TS5102). "types" ACIK: baseUrl gidince @types global cozumu explicit olmali -// (node = process/Buffer; jest = .spec.ts global'leri). Diger @types (express/supertest/jwt) -// import edilir → module-scoped, listelenmez. "lib":["ES2022"] DOM-collision fix'i (korunur). +// NOTE: no baseUrl — generated imports are fully relative (it was dead weight) and +// TS 7.0 (tsgo) removed baseUrl (TS5102). "types" is EXPLICIT: with baseUrl gone, global +// @types resolution must be explicit (node = process/Buffer; jest = .spec.ts globals). +// The other @types (express/supertest/jwt) are imported → module-scoped, not listed. +// "lib":["ES2022"] is the DOM-collision fix (kept). const TSCONFIG_JSON = `{ "compilerOptions": { "module": "commonjs", @@ -859,8 +860,8 @@ const TSCONFIG_JSON = `{ "exclude": ["node_modules", "dist"] }`; -/* tsconfig.build.json (H6): nest build bunu kullanir — tsconfig'i extend eder, - * test/spec dosyalarini derlemeden haric tutar (dist temiz kalir). */ +/* tsconfig.build.json (H6): nest build uses this — it extends tsconfig and + * excludes test/spec files from compilation (dist stays clean). */ const TSCONFIG_BUILD_JSON = `{ "extends": "./tsconfig.json", "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"] @@ -875,7 +876,7 @@ const NEST_CLI_JSON = `{ } }`; -/* jest-e2e.json (H6): e2e testleri test/ kokunden, *.e2e-spec.ts ile calistirir. */ +/* jest-e2e.json (H6): runs the e2e tests from the test/ root, via *.e2e-spec.ts. */ const JEST_E2E_JSON = `{ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", @@ -886,7 +887,7 @@ const JEST_E2E_JSON = `{ } }`; -/* .gitignore (H6): node_modules / dist / .env (secret sizintisi onlenir). */ +/* .gitignore (H6): node_modules / dist / .env (prevents secret leakage). */ const GITIGNORE = `# Dependencies node_modules @@ -904,10 +905,10 @@ coverage `; /* ── shared/filters/all-exceptions.filter.ts (GLOBAL EXCEPTION FILTER — H1) ── - * @Catch() ile TUM hatalari yakalar; tutarli JSON zarfi doner: + * Catches ALL errors via @Catch(); returns a consistent JSON envelope: * { statusCode, error, message, requestId, timestamp } - * HttpException -> kendi status'u + mesaji korunur; generic hata -> 500 + - * jenerik mesaj (ic hata DETAYI sizdirilmaz, yalniz sunucu tarafinda loglanir). */ + * HttpException -> its own status + message are preserved; a generic error -> 500 + + * a generic message (internal error DETAIL is never leaked, only logged server-side). */ const ALL_EXCEPTIONS_FILTER_TS = `import { ArgumentsHost, Catch, @@ -982,12 +983,13 @@ export class AllExceptionsFilter implements ExceptionFilter { } }`; -/* ── shared/guards/auth.guard.ts (GERCEK, capability-layer) ───────────────── - * controller.emitter @UseGuards(AuthGuard) urettiginde import edilen dosya. Artik - * PLACEHOLDER/surgical NOT: deterministik GERCEK guard — Bearer JWT'yi JWT_SECRET - * ile dogrular, cozulen claim'leri request.user'a koyar (@CurrentUser + RolesGuard - * okur), her basarisizlikta 401 atar. Auth strateji = JWT; token'i login servisi - * govdesi ayni JWT_SECRET ile imzalar (sub=user id, role=rol). 'as any' KULLANMAZ. */ +/* ── shared/guards/auth.guard.ts (REAL, capability-layer) ───────────────── + * The file imported when controller.emitter generates @UseGuards(AuthGuard). No + * longer a PLACEHOLDER/surgical area: a deterministic REAL guard — it verifies the + * Bearer JWT against JWT_SECRET, puts the decoded claims on request.user (read by + * @CurrentUser + RolesGuard), and throws 401 on any failure. Auth strategy = JWT; + * the login service body signs the token with the same JWT_SECRET (sub=user id, + * role=role). Never uses 'as any'. */ const AUTH_GUARD_TS = `import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; import type { Request } from "express"; import { verifyAccessToken } from "../auth/auth-token"; @@ -1018,10 +1020,11 @@ export class AuthGuard implements CanActivate { } }`; -/* ── shared/auth/auth-token.ts (JWT sign/verify — TEK SOURCE) ──────────────── - * AuthGuard verifyAccessToken ile DOGRULAR; login servisi signAccessToken ile - * IMZALAR — ayni JWT_SECRET + algoritma. Login fill'i sahte 'token' yerine bunu - * cagirir (apiSurface'te gorunur; service.emitter auth servisine import eder). */ +/* ── shared/auth/auth-token.ts (JWT sign/verify — SINGLE SOURCE) ──────────────── + * AuthGuard VERIFIES via verifyAccessToken; the login service SIGNS via + * signAccessToken — same JWT_SECRET + algorithm. The login fill calls this instead + * of a fake 'token' (visible in the apiSurface; service.emitter imports it into + * the auth service). */ const AUTH_TOKEN_TS = `import { sign, verify, type JwtPayload } from "jsonwebtoken"; /** The JWT signing/verification secret (fail fast if not configured). */ @@ -1054,8 +1057,8 @@ export function verifyAccessToken(token: string): JwtPayload { }`; /* ── shared/auth/password.ts (bcrypt hash/compare) ────────────────────────── - * Login/Register fill'i icin: hashPassword (kullanici olustururken passwordHash'e - * yaz) + comparePassword (kimlik dogrularken). Duz-metin karsilastirma yerine. */ + * For the Login/Register fill: hashPassword (write to passwordHash when creating + * a user) + comparePassword (when authenticating). Instead of plaintext comparison. */ const PASSWORD_TS = `import { hash, compare } from "bcryptjs"; /** Cost factor for bcrypt hashing. */ @@ -1072,8 +1075,8 @@ export function comparePassword(plain: string, passwordHash: string): Promise SetMetadata(ROLES_KEY, roles);`; -/* ── shared/guards/roles.guard.ts (GERCEK guard, stub NOT) ──────────────── - * RBAC WIRE (#39): @Roles(...) ile yazilan ROLES_KEY metadata'sini Reflector ile - * OKUR ve enforce eder. Rol gerekmeyen route gecer; gerektiren route'ta - * request.user.role gerekli rollerden biri olmali. request.user'i AuthGuard - * (authentication) yerlestirir; RolesGuard yalniz authorization yapar. Reflector - * NestJS cekirdeginden otomatik enjekte edilir (provider kaydi gerekmez). */ +/* ── shared/guards/roles.guard.ts (a REAL guard, not a stub) ──────────────── + * RBAC WIRE (#39): READS the ROLES_KEY metadata written by @Roles(...) via + * Reflector and enforces it. A route requiring no roles passes; on a route that + * does, request.user.role must be one of the required roles. request.user is + * populated by AuthGuard (authentication); RolesGuard only does authorization. + * Reflector is injected automatically by the NestJS core (no provider registration needed). */ const ROLES_GUARD_TS = `import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { ROLES_KEY } from "../decorators/roles.decorator"; @@ -1115,18 +1118,18 @@ export class RolesGuard implements CanActivate { const request = context.switchToHttp().getRequest<{ user?: { role?: string } }>(); const role = request.user?.role; if (role === undefined) return false; - // CASE-INSENSITIVE rol eslesmesi: graf'taki @Roles("ADMIN") ile enum/token'daki - // "admin" casing'i uyusmasa da RBAC calisir (sessiz kirilma yok). + // CASE-INSENSITIVE role matching: RBAC still works when the graph's + // @Roles("ADMIN") and the enum/token's "admin" casing disagree (no silent breakage). const normalized = role.toLowerCase(); return required.some((r) => r.toLowerCase() === normalized); } }`; /* ── shared/decorators/current-user.decorator.ts (stub) ──────────────────── - * controller.emitter @CurrentUser() user: AuthUser urettiginde (RequiresAuth) - * VE login endpoint Promise dondugunde import edilen dosya. - * Tek dosyada uc export: AuthUser (request.user sekli), AuthResponse (login - * token zarfi), CurrentUser (request.user'i cikaran param decorator). */ + * The file imported when controller.emitter generates @CurrentUser() user: AuthUser + * (RequiresAuth) AND when a login endpoint returns Promise. + * Three exports in one file: AuthUser (the shape of request.user), AuthResponse + * (the login token envelope), CurrentUser (the param decorator extracting request.user). */ const CURRENT_USER_DECORATOR_TS = `import { createParamDecorator, ExecutionContext } from "@nestjs/common"; import type { Request } from "express"; @@ -1227,10 +1230,10 @@ async function bootstrap() { void bootstrap();`; /* ── test/app.e2e-spec.ts (SMOKE E2E — H6) ───────────────────────────────── - * AppModule'u gercekten boot eder + bir HTTP istegi atar. Bilinmeyen bir rota - * 404 doner (Nest varsayilan + AllExceptionsFilter zarfi); bu, uygulamanin DI - * grafiginin tam cozuldugunu ve filter'in baglandigini KANITLAR. Strict altinda - * derlenir. Calistirmak icin DATABASE_URL gerekir (TypeORM forRootAsync). */ + * Actually boots AppModule + fires one HTTP request. An unknown route returns + * 404 (Nest default + the AllExceptionsFilter envelope); this PROVES the app's + * DI graph fully resolves and the filter is wired. Compiles under strict. + * Running it requires DATABASE_URL (TypeORM forRootAsync). */ const APP_E2E_SPEC_TS = `import { INestApplication, ValidationPipe } from "@nestjs/common"; import { Test, TestingModule } from "@nestjs/testing"; import request from "supertest"; diff --git a/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.spec.ts index 6ac20a2..878c661 100644 --- a/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.spec.ts @@ -42,7 +42,7 @@ function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; } -/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +/* ── IDs ────────────────────────────────────────────────────────────────── */ const SVC = "10000000-0000-4000-8000-000000000001"; const REPO = "10000000-0000-4000-8000-000000000002"; const DEP_SVC = "10000000-0000-4000-8000-000000000003"; @@ -109,10 +109,10 @@ const usersService = node("Service", SVC, { const fullNodes = [usersService, usersRepository, paymentService]; describe("emitServiceSpecs", () => { - it("tam davranis iskeleti — snapshot (mock provider'lar + per-metot delegasyon TODO)", () => { + it("full behavior skeleton — snapshot (mock providers + per-method delegation TODO)", () => { const ctx = ctxFrom(fullNodes, []); const files = emitServiceSpecs(ctx); - // Iki Service var (UsersService + PaymentService) -> iki spec. + // There are two Services (UsersService + PaymentService) -> two specs. const usersSpec = files.find((f) => f.path === "users/users.service.spec.ts"); expect(usersSpec).toBeDefined(); expect(usersSpec!.content).toMatchInlineSnapshot(` @@ -184,58 +184,58 @@ describe("emitServiceSpecs", () => { `); }); - it("yalniz PUBLIC metotlar test edilir (private/protected dis API degil)", () => { + it("only PUBLIC methods are tested (private/protected are not external API)", () => { const ctx = ctxFrom(fullNodes, []); const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; expect(usersSpec.content).toContain('describe("createUser", () =>'); expect(usersSpec.content).toContain('describe("validateNow", () =>'); - // private metot icin ne describe ne cagri uretilir. + // Neither a describe nor a call is generated for a private method. expect(usersSpec.content).not.toContain('describe("secretInternal"'); expect(usersSpec.content).not.toContain("secretInternal("); }); - it("davranis iskeleti: her public metot bir it.skip blogu (bayat stub assert'i NONE)", () => { + it("behavior skeleton: one it.skip block per public method (no stale stub asserts)", () => { const ctx = ctxFrom(fullNodes, []); const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; - // Her metot ATLANMIS iskelet (it.skip) -> dolu metot jest'i KIRMAZ. + // Every method is a SKIPPED skeleton (it.skip) -> a filled method does NOT break jest. expect(usersSpec.content).toContain('it.skip("delegates to its dependencies", () => {'); - // act ipucu yorum olarak: async metot -> await; sync -> await yok. + // The act hint is a comment: async method -> await; sync -> no await. expect(usersSpec.content).toContain("// const result = await usersService.createUser(/* input */);"); expect(usersSpec.content).toContain("// const result = usersService.validateNow();"); - // Eski stub-sozlesmesi assert'i KALMADI (metot dolunca bayatlayip fail ederdi). + // The old stub-contract assert is GONE (it would go stale and fail once the method was filled). expect(usersSpec.content).not.toContain("NOT_IMPLEMENTED"); expect(usersSpec.content).not.toContain(".rejects.toThrow"); - // Tek AKTIF assert: DI-resolves smoke'undaki toBeDefined. + // The only ACTIVE assert: toBeDefined in the DI-resolves smoke test. const definedOnly = usersSpec.content.split("toBeDefined").length - 1; expect(definedOnly).toBe(1); }); - it("delegasyon iskeleti: her mock metodu icin arrange + assert ipucu (yorum)", () => { + it("delegation skeleton: arrange + assert hints (as comments) for every mock method", () => { const ctx = ctxFrom(fullNodes, []); const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; - // Arrange ipucu (mockResolvedValue) + assert ipucu (toHaveBeenCalled) yorum olarak. + // Arrange hint (mockResolvedValue) + assert hint (toHaveBeenCalled) as comments. expect(usersSpec.content).toContain("// usersRepository.findByEmail.mockResolvedValue(undefined as never);"); expect(usersSpec.content).toContain("// expect(usersRepository.findByEmail).toHaveBeenCalled();"); expect(usersSpec.content).toContain("// expect(paymentService.charge).toHaveBeenCalled();"); expect(usersSpec.content).toContain("// Behavior skeleton — un-skip"); }); - it("mock provider'lar gercek public metot adlarindan kurulur (Service.Methods / Repository.CustomQueries)", () => { + it("mock providers are built from the real public method names (Service.Methods / Repository.CustomQueries)", () => { const ctx = ctxFrom(fullNodes, []); const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; - // Repository mock'u CustomQueries'ten (countActive, findByEmail), isme sirali. + // The Repository mock comes from CustomQueries (countActive, findByEmail), sorted by name. expect(usersSpec.content).toContain("const usersRepository = { countActive: jest.fn(), findByEmail: jest.fn() };"); - // Service mock'u yalniz PUBLIC metottan (charge; private internalHelper NOT). + // The Service mock only uses PUBLIC methods (charge; not the private internalHelper). expect(usersSpec.content).toContain("const paymentService = { charge: jest.fn() };"); expect(usersSpec.content).not.toContain("internalHelper"); - // useValue, sinif tipine cast'lenir (strict altinda DI tip-uyumu). + // useValue is cast to the class type (DI type compatibility under strict). expect(usersSpec.content).toContain( "{ provide: UsersRepository, useValue: usersRepository as unknown as UsersRepository },", ); }); - it("DI = Dependencies ∪ CALLS hedefleri, DEDUP (ayni repo iki yoldan -> tek mock)", () => { - // Dependencies'te UsersRepository + CALLS edge ile de ayni repo -> tek mock alani. + it("DI = Dependencies ∪ CALLS targets, deduplicated (same repo via two routes -> one mock)", () => { + // UsersRepository in Dependencies + the same repo via a CALLS edge -> a single mock field. const ctx = ctxFrom(fullNodes, [edge("e-dup", "CALLS", SVC, REPO)]); const usersSpec = emitServiceSpecs(ctx).find((f) => f.path === "users/users.service.spec.ts")!; const mockDecls = usersSpec.content.split("const usersRepository = {").length - 1; @@ -244,7 +244,7 @@ describe("emitServiceSpecs", () => { expect(providerLines).toBe(1); }); - it("bagimliliksiz servis: constructor mock yok, providers tek satir, davranis blogu yine uretilir", () => { + it("a service without deps: no constructor mocks, single-line providers, behavior block is still generated", () => { const lonely = node("Service", SVC, { ServiceName: "LonelyService", Description: "No deps", @@ -257,20 +257,20 @@ describe("emitServiceSpecs", () => { const ctx = ctxFrom([lonely], []); const [spec] = emitServiceSpecs(ctx); expect(spec.path).toBe("lonely/lonely.service.spec.ts"); - // Bos DI -> mock blogu ve jest.clearAllMocks NONE; providers tek satir. + // Empty DI -> no mock block and no jest.clearAllMocks; single-line providers. expect(spec.content).not.toContain("Mocked dependencies"); expect(spec.content).not.toContain("jest.clearAllMocks();"); expect(spec.content).toContain("providers: [LonelyService],"); - // Davranis iskeleti yine var (atlanmis it.skip; mock'suz da uretilir). + // The behavior skeleton is still there (skipped it.skip; generated even without mocks). expect(spec.content).toContain('describe("ping", () =>'); expect(spec.content).toContain('it.skip("delegates to its dependencies", () => {'); expect(spec.content).toContain("// const result = lonelyService.ping();"); - // Mock yokken arrange ipucu placeholder'a duser. + // Without mocks the arrange hint falls back to a placeholder. expect(spec.content).toContain(""); }); - it("metotsuz servis: davranis blogu yok ama DI-resolves smoke korunur", () => { - // Sema Methods.min(1) ister ama emitter THROW etmemeli; bos Methods'a dayanikli. + it("a service without methods: no behavior block, but the DI-resolves smoke test is kept", () => { + // The schema requires Methods.min(1) but the emitter must NOT throw; it is resilient to empty Methods. const empty = node("Service", SVC, { ServiceName: "EmptyService", Description: "No methods", @@ -284,7 +284,7 @@ describe("emitServiceSpecs", () => { expect(spec.content).not.toContain("delegates to its dependencies"); }); - it("cozulemeyen bagimlilik ref'i ATLANIR (mock uretilmez -> spec derlenebilir kalir)", () => { + it("an unresolvable dependency ref is SKIPPED (no mock is generated -> the spec stays compilable)", () => { const svc = node("Service", SVC, { ServiceName: "GhostUserService", Description: "Has an unresolvable dep", @@ -296,9 +296,9 @@ describe("emitServiceSpecs", () => { }); const ctx = ctxFrom([svc], []); const [spec] = emitServiceSpecs(ctx); - // Cozulemeyen ref import edilmez/mocklanmaz. + // The unresolvable ref is not imported/mocked. expect(spec.content).not.toContain("MissingRepository"); - // Davranis blogu yine uretilir. + // The behavior block is still generated. expect(spec.content).toContain('describe("run", () =>'); }); @@ -309,7 +309,7 @@ describe("emitServiceSpecs", () => { expect(usersSpec.content.endsWith("});\n\n")).toBe(false); }); - it("test dosyalari surgical marker TASIMAZ ve nodeId tasimaz", () => { + it("test files carry NO surgical markers and no nodeId", () => { const ctx = ctxFrom(fullNodes, []); for (const f of emitServiceSpecs(ctx)) { expect(f.surgicalMarkers).toBe(0); diff --git a/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.ts b/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.ts index 3dee820..bbf9593 100644 --- a/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.ts +++ b/apps/server/src/codegen/emitters/nestjs/service-spec.emitter.ts @@ -11,31 +11,32 @@ import { ImportCollector } from "../../imports"; import { countSurgicalMarkers } from "../../surgical"; /* ──────────────────────────────────────────────────────────────────────── - * service-spec.emitter.ts — Service node basina bir Jest DAVRANIS testi - * ISKELETI uretir (#11): /.service.spec.ts, service dosyasinin - * HEMEN yaninda. + * service-spec.emitter.ts — generates one Jest BEHAVIOR test SKELETON per + * Service node (#11): /.service.spec.ts, RIGHT next to the + * service file. * - * NEDEN davranis iskeleti: eski iskelet yalniz "DI resolves" smoke testi yaziyordu - * ("servis var" der ama "siparis olusturur" demez -> sahte guven). Bu emitter - * graph'taki Methods + Dependencies'ten her PUBLIC metot icin bir davranis - * iskeleti cikarir: - * - Test.createTestingModule, gercek service'i + her bagimliligi MOCK provider - * ile kurar (her mock'un metotlari jest.fn() -> delegasyon assert edilebilir). - * - DI-resolves smoke testi AKTIF kalir (`it(...)` -> gercek regresyon korumasi). - * - Her public metot icin ATLANMIS (`it.skip`) bir davranis iskeleti uretilir: - * arrange/act/assert ipuclari yorum olarak birakilir. ATLANDIGI icin govde stub - * da olsa dolu da olsa jest'i KIRMAZ — eski surum "NOT_IMPLEMENTED throw eder" - * diye assert ediyordu, ama surgical metot dolunca bu assert bayatlayip fail - * ediyordu (kod dogru, test eski stub'i olcuyor). Dogru sozlesmeyi codegen aninda - * bilemeyiz (govde henuz yazilmadi) -> iskelet. Gelistirici un-skip edip gercek - * assert'leri yazar (or. `expect(orderRepository.save).toHaveBeenCalled()`). + * WHY a behavior skeleton: the old skeleton only wrote a "DI resolves" smoke test + * (it says "the service exists" but never "it creates an order" -> false + * confidence). This emitter derives a behavior skeleton for every PUBLIC method + * from the graph's Methods + Dependencies: + * - Test.createTestingModule wires the real service + every dependency as a MOCK + * provider (each mock's methods are jest.fn() -> delegation can be asserted). + * - The DI-resolves smoke test stays ACTIVE (`it(...)` -> real regression protection). + * - For every public method a SKIPPED (`it.skip`) behavior skeleton is generated: + * arrange/act/assert hints are left as comments. Because it is SKIPPED, it does + * not BREAK jest whether the body is a stub or filled — the old version asserted + * "it throws NOT_IMPLEMENTED", but once the surgical method was filled that + * assert went stale and failed (the code was correct, the test measured the old + * stub). We cannot know the right contract at codegen time (the body is not + * written yet) -> a skeleton. The developer un-skips and writes the real asserts + * (e.g. `expect(orderRepository.save).toHaveBeenCalled()`). * - * SAF + DETERMINISTIC: bagimliliklar/metotlar isme gore sirali, import'lar - * ImportCollector ile, icerik tek "\n" ile biter, timestamp/random NONE. Node'a - * bagli NOT (test dosyasi; GeneratedFile.nodeId tasimaz). + * PURE + DETERMINISTIC: dependencies/methods sorted by name, imports via + * ImportCollector, content ends with a single "\n", no timestamps/randomness. + * NOT node-bound (a test file; carries no GeneratedFile.nodeId). * ──────────────────────────────────────────────────────────────────────── */ -/** Service node'larindan davranis test iskeletleri uretir (her servis bir spec). */ +/** Generates behavior test skeletons from the Service nodes (one spec per service). */ export function emitServiceSpecs(ctx: EmitterContext): GeneratedFile[] { const out: GeneratedFile[] = []; for (const svc of ctx.graph.allOf("Service")) { @@ -58,14 +59,14 @@ function buildServiceSpec(node: CodeNode, ctx: EmitterContext): GeneratedFile | imports.add("TestingModule", "@nestjs/testing"); imports.add(className, importPathOf(relativeImportPath(specPath, servicePath))); - // ── DI bagimliliklari -> jest mock provider'lari (gercek DB/Redis gerektirmez) ── - // service.emitter ile ayni kume: Dependencies ∪ CALLS hedefleri (injectable). + // ── DI dependencies -> jest mock providers (no real DB/Redis required) ── + // Same set as service.emitter: Dependencies ∪ CALLS targets (injectable). const deps = collectInjectedDeps(node, ctx); for (const dep of deps) { imports.add(dep.className, importPathOf(relativeImportPath(specPath, dep.filePath))); } - // ── Yalniz PUBLIC metotlar test edilir (private/protected dis API degil) ── + // ── Only PUBLIC methods are tested (private/protected are not external API) ── const methods = [...(propsOf<"Service">(node).Methods ?? [])] .filter((m) => (m.Visibility ?? "public") === "public") .sort((a, b) => cmp(a.MethodName, b.MethodName)); @@ -75,10 +76,10 @@ function buildServiceSpec(node: CodeNode, ctx: EmitterContext): GeneratedFile | lines.push(`describe("${className}", () => {`); lines.push(` let ${instanceName}: ${className};`); - // ── Mock bagimliliklar (jest.fn() metotlariyla — delegasyon assert edilebilir) ── - // Her mock, bagimliligin GERCEK public metot adlarindan (Service.Methods / - // Repository.CustomQueries) kurulur; bilinmeyen yuzeyler bos `{}` ile kalir - // (DI yine cozulur). `as unknown as ` ile useValue tip-uyumlu saglanir. + // ── Mocked dependencies (with jest.fn() methods — delegation can be asserted) ── + // Each mock is built from the dependency's REAL public method names + // (Service.Methods / Repository.CustomQueries); unknown surfaces stay as an + // empty `{}` (DI still resolves). `as unknown as ` keeps useValue type-compatible. if (deps.length > 0) { lines.push(""); lines.push(" // Mocked dependencies — delegated methods are jest.fn() so calls can be asserted."); @@ -108,13 +109,13 @@ function buildServiceSpec(node: CodeNode, ctx: EmitterContext): GeneratedFile | lines.push(` ${instanceName} = moduleRef.get<${className}>(${className});`); lines.push(" });"); - // ── Smoke: DI cozuluyor (regression korumasi; tek basina yeterli NOT) ── + // ── Smoke: DI resolves (regression protection; not sufficient on its own) ── lines.push(""); lines.push(' it("is defined (DI resolves)", () => {'); lines.push(` expect(${instanceName}).toBeDefined();`); lines.push(" });"); - // ── Her public metot icin DAVRANIS iskeleti ────────────────────────────── + // ── A BEHAVIOR skeleton for every public method ────────────────────────────── for (const m of methods) { lines.push(""); lines.push(...renderMethodBehavior(instanceName, m, deps)); @@ -133,16 +134,17 @@ function buildServiceSpec(node: CodeNode, ctx: EmitterContext): GeneratedFile | }; } -/* ── Davranis blogu: bir public metot icin ATLANMIS (it.skip) iskelet ────────── +/* ── Behavior block: a SKIPPED (it.skip) skeleton for one public method ────────── * - * NEDEN it.skip (assert NOT): eski iskelet "metot NOT_IMPLEMENTED throw eder" - * diye assert ediyordu — bu yalniz STUB icin gecerli. Surgical metot DOLUNCA govde - * gercek davranisi yapar (artik NOT_IMPLEMENTED throw etmez) → assert BAYATLAR → - * jest kirilir (kod dogru, test eski hâli olcuyor). Dogru sozlesmeyi codegen aninda - * bilemeyiz (govde henuz yazilmadi), o yuzden bolge bir ISKELET'tir: `it.skip` ile - * atlanir (jest "skipped" der, fail ETMEZ — ne stub ne dolu hâlde) + arrange/act/ - * assert ipuclari yorum olarak birakilir. Gelistirici un-skip edip gercek assert'leri - * yazar. DI-resolves smoke testi (`it(...)`) AKTIF kalir (gercek regresyon korumasi). */ + * WHY it.skip (no assert): the old skeleton asserted "the method throws + * NOT_IMPLEMENTED" — valid only for the STUB. Once the surgical method is FILLED, + * the body performs the real behavior (it no longer throws NOT_IMPLEMENTED) → the + * assert goes STALE → jest breaks (the code is correct, the test measures the old + * state). We cannot know the right contract at codegen time (the body is not + * written yet), so the region is a SKELETON: skipped via `it.skip` (jest reports + * "skipped", never FAILS — neither in stub nor filled state) + arrange/act/assert + * hints are left as comments. The developer un-skips and writes the real asserts. + * The DI-resolves smoke test (`it(...)`) stays ACTIVE (real regression protection). */ function renderMethodBehavior( instanceName: string, method: ServiceMethod, @@ -155,11 +157,11 @@ function renderMethodBehavior( const out: string[] = []; out.push(` describe("${name}", () => {`); - // Atlanan iskelet: govde stub da olsa dolu da olsa fail etmez. Un-skip + gercek assert. + // Skipped skeleton: never fails whether the body is a stub or filled. Un-skip + real asserts. out.push(" // Behavior skeleton — un-skip and replace the comments with real"); out.push(" // arrange/act/assert once you've reviewed the filled method body."); out.push(` it.skip("delegates to its dependencies", () => {`); - // Arrange (yorum): mocklanan bagimliliklarin metotlarini stub'la. + // Arrange (comment): stub the mocked dependencies' methods. out.push(" // Arrange: stub the calls this method should delegate to, e.g."); const arrangeHints = delegationHints(deps); if (arrangeHints.length > 0) { @@ -167,10 +169,10 @@ function renderMethodBehavior( } else { out.push(" // "); } - // Act (yorum): gercek argumanlarla cagir. + // Act (comment): call with real arguments. out.push(" // Act:"); out.push(` // const result = ${awaitKw}${instanceName}.${name}(${args});`); - // Assert (yorum): gercek delegasyon/donus beklentileri. + // Assert (comment): real delegation/return expectations. out.push(" // Assert: replace with real delegation/return assertions, e.g."); const assertHints = assertionHints(deps); if (assertHints.length > 0) { @@ -183,9 +185,9 @@ function renderMethodBehavior( return out; } -/** Arrange iskeleti satirlari: her cozulen bagimliligin her mock metodu icin bir - * `dep.method.mockResolvedValue(... as never);` ipucu (yorum). Determinizm: - * deps + methodNames zaten sirali. */ +/** Arrange skeleton lines: one `dep.method.mockResolvedValue(... as never);` hint + * (as a comment) per mock method of every resolved dependency. Determinism: + * deps + methodNames are already sorted. */ function delegationHints(deps: ResolvedDep[]): string[] { const out: string[] = []; for (const dep of deps) { @@ -196,8 +198,8 @@ function delegationHints(deps: ResolvedDep[]): string[] { return out; } -/** Assert iskeleti satirlari: her mock metodu icin bir - * `expect(dep.method).toHaveBeenCalled();` ipucu (yorum). */ +/** Assert skeleton lines: one `expect(dep.method).toHaveBeenCalled();` hint + * (as a comment) per mock method. */ function assertionHints(deps: ResolvedDep[]): string[] { const out: string[] = []; for (const dep of deps) { @@ -208,31 +210,31 @@ function assertionHints(deps: ResolvedDep[]): string[] { return out; } -/** Bir mock nesne literali uretir: bilinen metot adlari -> jest.fn(); hic metot - * yoksa bos `{}` (DI yine cozulur, yuzey bilinmiyor). */ +/** Produces a mock object literal: known method names -> jest.fn(); with no + * methods, an empty `{}` (DI still resolves, the surface is unknown). */ function renderMockObject(methodNames: string[]): string { if (methodNames.length === 0) return "{}"; return `{ ${methodNames.map((m) => `${m}: jest.fn()`).join(", ")} }`; } interface ResolvedDep { - /** constructor alan adi = camelCase(name) (service.emitter ile ayni). */ + /** constructor field name = camelCase(name) (same as service.emitter). */ field: string; - /** enjekte edilen sinif adi = pascalCase(name). */ + /** injected class name = pascalCase(name). */ className: string; - /** cozulen node'un dosya yolu (import icin). */ + /** file path of the resolved node (for the import). */ filePath: string; - /** mock'lanacak public metot adlari (Service.Methods / Repository.CustomQueries), - * isme gore sirali; bilinmiyorsa bos. */ + /** public method names to mock (Service.Methods / Repository.CustomQueries), + * sorted by name; empty when unknown. */ methodNames: string[]; } -/** Servisin enjekte ettigi (mock'lanacak) provider'lari cozer: property - * Dependencies ∪ CALLS edge hedefleri (Repository/Service/Cache/ExternalService), - * yalniz COZULEBILEN + TAM emitter'li olanlar (sinif adi pascalCase(name)). - * Cozulemeyen/stub ref'ler atlanir (mock uretmeyiz -> spec derlenebilir kalsin). - * Her dep icin public metot adlari (delegasyon mock'u) cikarilir. - * DEDUP + alan adina gore sirali (deterministik; service.emitter ile ayni sira). */ +/** Resolves the providers the service injects (to be mocked): property + * Dependencies ∪ CALLS edge targets (Repository/Service/Cache/ExternalService), + * only those that RESOLVE and have a FULL emitter (class name pascalCase(name)). + * Unresolvable/stub refs are skipped (we produce no mock -> the spec stays compilable). + * For each dep the public method names (delegation mock) are extracted. + * Deduped + sorted by field name (deterministic; same order as service.emitter). */ function collectInjectedDeps(node: CodeNode, ctx: EmitterContext): ResolvedDep[] { const graph = ctx.graph; const byId = new Map(); @@ -261,12 +263,12 @@ function collectInjectedDeps(node: CodeNode, ctx: EmitterContext): ResolvedDep[] return [...byId.values()].sort((a, b) => cmp(a.field, b.field)); } -/** Bir bagimlilik node'unun dis API'sini olusturan public metot adlari (mock'a - * jest.fn() olarak konur). Determinizm: isme gore sirali + DEDUP. +/** The public method names forming a dependency node's external API (placed in + * the mock as jest.fn()). Determinism: sorted by name + deduped. * - Service -> public Methods (Visibility public/default). - * - Repository -> CustomQueries (uretilen repository sinifinin public yuzeyi). - * - Cache/ExternalService -> bilinmeyen yuzey (emitter'a kuplaj yok) -> bos. - * Bossa mock `{}` olur (DI yine cozulur). */ + * - Repository -> CustomQueries (the generated repository class's public surface). + * - Cache/ExternalService -> unknown surface (no coupling to the emitter) -> empty. + * When empty the mock becomes `{}` (DI still resolves). */ function publicMethodNamesOf(dep: CodeNode): string[] { const names = new Set(); if (dep.kindOf() === "Service") { @@ -280,10 +282,10 @@ function publicMethodNamesOf(dep: CodeNode): string[] { return [...names].sort(cmp); } -/** Deterministik string karsilastirmasi (service.emitter ile ayni). */ +/** Deterministic string comparison (same as service.emitter). */ function cmp(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } -/* ── Yerel tip: ServiceMethod (service.schema.ts ile ayni shape) ──────────── */ +/* ── Local type: ServiceMethod (same shape as service.schema.ts) ──────────── */ type ServiceMethod = NonNullable>["Methods"]>[number]; diff --git a/apps/server/src/codegen/emitters/nestjs/service.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/service.emitter.spec.ts index 17de0aa..25385ac 100644 --- a/apps/server/src/codegen/emitters/nestjs/service.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/service.emitter.spec.ts @@ -42,7 +42,7 @@ function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; } -/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +/* ── IDs ────────────────────────────────────────────────────────────────── */ const SVC = "10000000-0000-4000-8000-000000000001"; const REPO = "10000000-0000-4000-8000-000000000002"; const DTO_CREATE = "10000000-0000-4000-8000-000000000003"; @@ -59,7 +59,7 @@ const usersRepository = node("Repository", REPO, { const createUserDto = node("DTO", DTO_CREATE, { Name: "CreateUserDto", - Description: "User olusturma girdisi", + Description: "User creation input", Fields: [{ Name: "email", DataType: "string", IsRequired: true, IsArray: false }], }); @@ -78,12 +78,12 @@ const notFoundException = node("Exception", EXC, { const usersCache = node("Cache", CACHE, { CacheName: "UsersCache", - // Cache semasi v1'de stub; emitter sadece adi/yolu kullanir. + // The Cache schema is a stub in v1; the emitter only uses the name/path. }); const usersService = node("Service", SVC, { ServiceName: "UsersService", - Description: "User is mantigi", + Description: "User business logic", IsTransactionScoped: true, Dependencies: [{ Kind: "Repository", Ref: "UsersRepository" }], Methods: [ @@ -95,7 +95,7 @@ const usersService = node("Service", SVC, { ReturnDtoRef: "UserDto", IsAsync: true, Throws: ["UserNotFoundException"], - Description: "Yeni kullanici olusturur ve UserDto doner.", + Description: "Creates a new user and returns a UserDto.", }, { MethodName: "countActive", @@ -109,7 +109,7 @@ const usersService = node("Service", SVC, { }); describe("emitService", () => { - it("tam servis — snapshot (DI, dekorator, DTO import, surgical marker)", () => { + it("full service — snapshot (DI, decorator, DTO imports, surgical markers)", () => { const ctx = ctxFrom( [usersService, usersRepository, createUserDto, userDto, notFoundException, usersCache], [edge("e-cache", "CALLS", SVC, CACHE)], @@ -124,7 +124,7 @@ describe("emitService", () => { import { UsersCache } from "./users.cache"; import { UsersRepository } from "./users.repository"; - /** User is mantigi */ + /** User business logic */ @Injectable() export class UsersService { constructor( @@ -140,7 +140,7 @@ describe("emitService", () => { async createUser(input: CreateUserDto): Promise { // @solarch:surgical id=10000000-0000-4000-8000-000000000001#createUser - // Yeni kullanici olusturur ve UserDto doner. + // Creates a new user and returns a UserDto. // throws: UserNotFoundException // deps: this.usersCache, this.usersRepository throw new Error("NOT_IMPLEMENTED: UsersService.createUser"); @@ -154,9 +154,9 @@ describe("emitService", () => { `); }); - it("public metot IsAsync:false olsa bile async (await-sync TS1308 onle); private sync KALIR", () => { - // Gercek bug: AuthService.ValidateToken IsAsync:false ama fill await ister -> TS1308. - // Public metot daima async (NestJS idiom); private graf IsAsync'ini korur. + it("public methods are async even with IsAsync:false (prevents await-in-sync TS1308); private stays sync", () => { + // Real bug: AuthService.ValidateToken had IsAsync:false but the fill wants await -> TS1308. + // Public methods are always async (NestJS idiom); private keeps the graph's IsAsync. const svc = node("Service", SVC, { ServiceName: "AuthService", Description: "kimlik", @@ -171,23 +171,23 @@ describe("emitService", () => { const [file] = emitService(ctx.graph.byId(SVC)!, ctx); // public validateToken -> async + Promise<> expect(file.content).toMatch(/async validateToken\([^)]*\): Promise/); - // private hashKey -> sync KALIR (async NOT) + // private hashKey -> STAYS sync (not async) expect(file.content).toMatch(/private hashKey\(raw: string\): string \{/); expect(file.content).not.toMatch(/async hashKey/); }); - it("cozulemeyen DI bagimliligi constructor'a DANGLING tip basmaz (TS2304 + DI boot patlamasini onle)", () => { - // Bir Service, gercek bir node'a cozulmeyen bagimlilik bildirirse (or. Ref="Environment" - // ama oyle bir node yok) eskiden `private readonly environment: Environment` uretiliyordu: - // import yok -> TS2304, ayrica NestJS DI boot'ta "can't resolve" ile patlardi. Cozulemeyen - // dep DI'dan DUSURULMELI (cozulen repo kalir), yerine TODO. Uyari contract-lint Rule 5'te. + it("an unresolvable DI dependency does not put a DANGLING type in the constructor (prevents TS2304 + DI boot crash)", () => { + // If a Service declares a dependency that does not resolve to a real node (e.g. Ref="Environment" + // but no such node exists), we used to generate `private readonly environment: Environment`: + // no import -> TS2304, plus NestJS DI blew up at boot with "can't resolve". The unresolvable + // dep must be DROPPED from DI (the resolved repo stays), with a TODO instead. The warning lives in contract-lint Rule 5. const svc = node("Service", SVC, { ServiceName: "TokenService", Description: "JWT uretir/dogrular", IsTransactionScoped: false, Dependencies: [ - { Kind: "Repository", Ref: "UsersRepository" }, // cozulur - { Kind: "Service", Ref: "Environment" }, // cozulmez (node yok) + { Kind: "Repository", Ref: "UsersRepository" }, // resolves + { Kind: "Service", Ref: "Environment" }, // does not resolve (no such node) ], Methods: [ { MethodName: "generateTokens", Visibility: "public", Parameters: [{ Name: "userId", Type: "UUID", Optional: false }], ReturnType: "TokenPair", IsAsync: false, Throws: [] }, @@ -195,19 +195,19 @@ describe("emitService", () => { }); const ctx = ctxFrom([svc, usersRepository], []); const [file] = emitService(ctx.graph.byId(SVC)!, ctx); - // Dangling tip ve dangling DI alani URETILMEZ. + // No dangling type and no dangling DI field are generated. expect(file.content).not.toMatch(/:\s*Environment\b/); expect(file.content).not.toContain("private readonly environment"); - // Cozulen repo bagimliligi KORUNUR. + // The resolved repo dependency is KEPT. expect(file.content).toContain("private readonly usersRepository: UsersRepository,"); - // Atlanan dep in-file gorunur (TODO). + // The omitted dep is visible in-file (TODO). expect(file.content).toMatch(/TODO:.*Environment.*(resolve|omitted)/i); - // Cozulemeyen serbest donus tipi (TokenPair) merkezi degrade ile Record olur (Fix 1). + // The unresolvable free-form return type (TokenPair) degrades to Record via the central fallback (Fix 1). expect(file.content).toContain("Record"); }); - it("DI = Dependencies ∪ CALLS hedefleri, DEDUP + isme gore sirali", () => { - // Dependencies'te UsersRepository var; CALLS edge ile de ayni repo → tek alan. + it("DI = Dependencies ∪ CALLS targets, deduplicated + sorted by name", () => { + // UsersRepository is in Dependencies; the same repo also arrives via a CALLS edge → one field. const ctx = ctxFrom( [usersService, usersRepository, createUserDto, userDto, notFoundException], [edge("e-dup", "CALLS", SVC, REPO)], @@ -218,26 +218,26 @@ describe("emitService", () => { expect(file.content).toContain("private readonly usersRepository: UsersRepository,"); }); - it("DTO import'lari DEGER import ile gelir (surgical AI runtime kullanir), exception deger import'u ile", () => { + it("DTO imports arrive as VALUE imports (the surgical AI uses them at runtime), exceptions as value imports too", () => { const ctx = ctxFrom( [usersService, usersRepository, createUserDto, userDto, notFoundException], [], ); const [file] = emitService(ctx.graph.byId(SVC)!, ctx); - // DTO'lar DEGER import ile: surgical AI govdede DTO'yu runtime deger olarak - // kullanabilsin (plainToInstance(CreateUserDto, ...)) -> `import type` olsaydi - // derlenmezdi. (DTO'lar UsersService ile ayni "users" feature'inda -> ./dto.) + // DTOs use VALUE imports: so the surgical AI can use the DTO as a runtime value + // in the body (plainToInstance(CreateUserDto, ...)) -> with `import type` it + // would not compile. (The DTOs are in the same "users" feature as UsersService -> ./dto.) expect(file.content).toContain('import { CreateUserDto } from "./dto/create-user.dto";'); expect(file.content).toContain('import { UserDto } from "./dto/user.dto";'); - // type-only NOT -> "import type { ...Dto }" URETILMEMELI. + // Not type-only -> "import type { ...Dto }" must NOT be generated. expect(file.content).not.toContain("import type { CreateUserDto }"); expect(file.content).not.toContain("import type { UserDto }"); - // Exception deger import'u ile (THROWS kaynagi yok -> common/exceptions). + // The exception uses a value import (no THROWS source -> common/exceptions). expect(file.content).toContain('import { UserNotFoundException } from "../common/exceptions/user-not-found.exception";'); expect(file.content).toContain('import { Injectable } from "@nestjs/common";'); }); - it("her govde-gerektiren metot icin surgical marker + NOT_IMPLEMENTED", () => { + it("surgical marker + NOT_IMPLEMENTED for every body-requiring method", () => { const ctx = ctxFrom([usersService, usersRepository, createUserDto, userDto, notFoundException], []); const [file] = emitService(ctx.graph.byId(SVC)!, ctx); expect(file.surgicalMarkers).toBe(2); @@ -245,11 +245,11 @@ describe("emitService", () => { expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: UsersService.countActive");'); }); - it("async metot Promise<> sarar, sync metot sarmaz", () => { + it("async methods wrap in Promise<>, sync methods do not", () => { const ctx = ctxFrom([usersService, usersRepository, createUserDto, userDto, notFoundException], []); const [file] = emitService(ctx.graph.byId(SVC)!, ctx); expect(file.content).toContain("async createUser(input: CreateUserDto): Promise {"); - // Default varsa "?" duser (gecerli TS) → "since: Date = new Date()". + // With a default the "?" is dropped (valid TS) → "since: Date = new Date()". expect(file.content).toContain("private countActive(since: Date = new Date()): number {"); }); @@ -269,41 +269,42 @@ describe("emitService", () => { expect(a).toBe(b); }); - it("DEDUP: cozulemeyen property Dependency, cozulebilen CALLS edge'ini MASKELEMEZ (import korunur)", () => { - // Property Dependency yanlis Kind ile cozulemez (Service diye arar ama node - // Repository'dir) -> ham ref "UsersRepository" filePath=null girer. Ayni ada - // giden CALLS edge gercek Repository'yi cozer. Cozulen KAZANMALI (import kalmali). + it("DEDUP: an unresolvable property Dependency does NOT mask a resolvable CALLS edge (the import is kept)", () => { + // The property Dependency cannot resolve due to a wrong Kind (it looks for a Service + // but the node is a Repository) -> the raw ref "UsersRepository" enters with filePath=null. + // A CALLS edge to the same name resolves the real Repository. The resolved one must WIN (the import stays). const svc = node("Service", SVC, { ServiceName: "OrdersService", - Description: "Order mantigi", + Description: "Order logic", IsTransactionScoped: false, - // Kasitli yanlis Kind -> resolveRef("Service","UsersRepository") = null. + // Deliberately wrong Kind -> resolveRef("Service","UsersRepository") = null. Dependencies: [{ Kind: "Service", Ref: "UsersRepository" }], Methods: [], }); const ctx = ctxFrom([svc, usersRepository], [edge("e-calls", "CALLS", SVC, REPO)]); const [file] = emitService(ctx.graph.byId(SVC)!, ctx); - // Tek alan (dedup), tip dogru ve EN ONEMLISI import uretildi (filePath null kalmadi). + // One field (dedup), the type is correct, and MOST IMPORTANTLY the import was generated (filePath did not stay null). const occurrences = file.content.split("private readonly usersRepository").length - 1; expect(occurrences).toBe(1); expect(file.content).toContain("private readonly usersRepository: UsersRepository,"); - // EN ONEMLISI: import uretildi (cozulen entry kazandi; filePath null kalmadi). + // MOST IMPORTANTLY: the import was generated (the resolved entry won; filePath did not stay null). expect(file.content).toMatch(/import \{ UsersRepository \} from ".*users\.repository"/); }); - /* ── DIZI RETURN KORUMA: ReturnType="XDto[]" + ReturnDtoRef -> "XDto[]" ──── */ - it("dizi donus: ReturnType='XDto[]' + ReturnDtoRef -> Promise (controller ile hizali)", () => { - // Graf zaten dizi donusler icin ReturnType="CartItemDto[]" verir AMA DtoRef de - // doludur. Eskiden DtoRef dolu oldugunda ham Type atilip ciplak "CartItemDto" - // donerdi -> service tekil, controller dizi -> uyumsuz imza. Artik dizi korunur. + /* ── ARRAY RETURN PRESERVATION: ReturnType="XDto[]" + ReturnDtoRef -> "XDto[]" ── */ + it("array return: ReturnType='XDto[]' + ReturnDtoRef -> Promise (aligned with the controller)", () => { + // The graph already gives ReturnType="CartItemDto[]" for array returns BUT the DtoRef + // is also set. Previously, when DtoRef was set, the raw Type was discarded and a bare + // "CartItemDto" was returned -> service singular, controller array -> mismatched + // signatures. Now the array is preserved. const cartItemDto = node("DTO", "10000000-0000-4000-8000-0000000000a1", { Name: "CartItemDto", - Description: "Sepet kalemi ciktisi", + Description: "Cart item output", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], }); const cartService = node("Service", SVC, { ServiceName: "CartService", - Description: "Sepet is mantigi", + Description: "Cart business logic", IsTransactionScoped: false, Dependencies: [], Methods: [ @@ -311,35 +312,35 @@ describe("emitService", () => { MethodName: "getCart", Visibility: "public", Parameters: [{ Name: "userId", Type: "UUID", Optional: false }], - // CRITICAL: ham Type dizi tasir + DtoRef dolu. + // CRITICAL: the raw Type carries the array + the DtoRef is set. ReturnType: "CartItemDto[]", ReturnDtoRef: "CartItemDto", IsAsync: true, Throws: [], - Description: "Usernin sepet kalemlerini doner.", + Description: "Returns the user's cart items.", }, ], }); const ctx = ctxFrom([cartService, cartItemDto], []); const [file] = emitService(ctx.graph.byId(SVC)!, ctx); - // Dizi KORUNDU -> Promise (controller ile ayni imza). + // The array is PRESERVED -> Promise (same signature as the controller). expect(file.content).toContain("async getCart(userId: string): Promise {"); expect(file.content).not.toContain("Promise {"); - // DTO yine DEGER import edilir (sinif adi cozuldu). + // The DTO is still VALUE-imported (the class name resolved). expect(file.content).toContain('import { CartItemDto } from "./dto/cart-item.dto";'); }); - it("dizi donus (DtoRef NONE): ReturnType='XDto[]' zaten korunur (regresyon)", () => { - // ReturnDtoRef bosken yol resolveTypeRef'ten gecer; dizi zaten korunuyordu. - // Bu testin amaci: duzeltme bu yolu BOZMADI (mevcut tekil davranis degismedi). + it("array return (no DtoRef): ReturnType='XDto[]' was already preserved (regression)", () => { + // With an empty ReturnDtoRef the path goes through resolveTypeRef; the array was already preserved. + // The point of this test: the fix did NOT break this path (existing singular behavior unchanged). const productDto = node("DTO", "10000000-0000-4000-8000-0000000000b2", { Name: "ProductDto", - Description: "Urun ciktisi", + Description: "Product output", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], }); const productService = node("Service", SVC, { ServiceName: "ProductService", - Description: "Urun is mantigi", + Description: "Product business logic", IsTransactionScoped: false, Dependencies: [], Methods: [ @@ -348,7 +349,7 @@ describe("emitService", () => { Visibility: "public", Parameters: [], ReturnType: "ProductDto[]", - // ReturnDtoRef NONE -> resolveTypeRef yolu. + // No ReturnDtoRef -> the resolveTypeRef path. IsAsync: true, Throws: [], }, @@ -359,16 +360,16 @@ describe("emitService", () => { expect(file.content).toContain("async list(): Promise {"); }); - it("dizi parametresi: Type='XDto[]' + DtoRef -> dizi korunur (param tarafi tutarli)", () => { - // Parametre tipinde de dizi-koruma tutarli olmali (gorev geregi). + it("array parameter: Type='XDto[]' + DtoRef -> the array is preserved (param side consistent)", () => { + // Array preservation must also be consistent for parameter types (as the task requires). const itemDto = node("DTO", "10000000-0000-4000-8000-0000000000c3", { Name: "ItemDto", - Description: "Kalem girdisi", + Description: "Line item input", Fields: [{ Name: "sku", DataType: "string", IsRequired: true, IsArray: false }], }); const bulkService = node("Service", SVC, { ServiceName: "BulkService", - Description: "Toplu islem mantigi", + Description: "Bulk operation logic", IsTransactionScoped: false, Dependencies: [], Methods: [ @@ -388,22 +389,23 @@ describe("emitService", () => { expect(file.content).not.toContain("addMany(items: ItemDto)"); }); - /* ── TEK-SOURCE KARDINALITE: ReturnsCollection bildirilmis alani ────────── - * Graf SINGLE ReturnType verse bile (or. ListProducts'ta ReturnType='ProductDto', - * ReturnDtoRef='ProductDto'), operasyon bir COLLECTION ise service imzasi DTO[] - * olmali. Aksi halde controller dizi (route sezgisi) ↔ service tekil -> uyumsuz - * imza + surgical govdedeki `return result` (dizi) DERLEME hatasi verir (gercek - * bug: ListProducts/ListOrders, surgical-output 18 tsc hatasi). ReturnsCollection - * kardinalitenin TEK KAYNAGIDIR; emitter onu okur ve tipi DTO[]'e zorlar. */ - it("ReturnsCollection=true: tekil ReturnDtoRef'i Promise'e zorlar (tek-kaynak)", () => { + /* ── SINGLE-SOURCE CARDINALITY: the declared ReturnsCollection field ─────── + * Even when the graph gives a SINGULAR ReturnType (e.g. ListProducts with + * ReturnType='ProductDto', ReturnDtoRef='ProductDto'), if the operation is a + * COLLECTION the service signature must be DTO[]. Otherwise controller array + * (route heuristic) ↔ service singular -> mismatched signatures + the surgical + * body's `return result` (array) is a COMPILE error (real bug: ListProducts/ + * ListOrders, 18 tsc errors in surgical-output). ReturnsCollection is the SINGLE + * SOURCE of cardinality; the emitter reads it and forces the type to DTO[]. */ + it("ReturnsCollection=true: forces a singular ReturnDtoRef to Promise (single source)", () => { const productDto = node("DTO", "10000000-0000-4000-8000-0000000000d4", { Name: "ProductDto", - Description: "Urun ciktisi", + Description: "Product output", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], }); const catalogService = node("Service", SVC, { ServiceName: "CatalogService", - Description: "Katalog is mantigi", + Description: "Catalog business logic", IsTransactionScoped: false, Dependencies: [], Methods: [ @@ -411,12 +413,12 @@ describe("emitService", () => { MethodName: "listProducts", Visibility: "public", Parameters: [], - ReturnType: "ProductDto", // SINGLE ham tip + ReturnType: "ProductDto", // SINGULAR raw type ReturnDtoRef: "ProductDto", - ReturnsCollection: true, // bildirilmis tek-kaynak + ReturnsCollection: true, // declared single source IsAsync: true, Throws: [], - Description: "Urunleri listeler.", + Description: "Lists the products.", }, ], }); @@ -426,20 +428,21 @@ describe("emitService", () => { expect(file.content).not.toContain("Promise {"); }); - /* ── FALLBACK: metot-adi liste-semantigi (bildirilmis alan NONEKEN) ──────── - * Gercek bug: ListProducts/ListOrders graf'ta ReturnsCollection alani WITHOUT + - * tekil ReturnType ile geldi. Bildirilmis alan yoksa emitter, metot adinin liste- - * semantigine (list/all/search/findAll/findMany) bakip koleksiyon cikarir -> DTO[]. - * EXACT-kelime eslesmesi: "listen"/"getAllowance" gibi adlar YANLIS pozitif vermez. */ - it("fallback: liste-semantikli ad (listProducts) tekil ReturnType'i Promise yapar", () => { + /* ── FALLBACK: method-name list semantics (when the declared field is ABSENT) ─ + * Real bug: ListProducts/ListOrders came from the graph WITHOUT a ReturnsCollection + * field + a singular ReturnType. Without the declared field, the emitter inspects the + * method name's list semantics (list/all/search/findAll/findMany) and infers a + * collection -> DTO[]. EXACT-word matching: names like "listen"/"getAllowance" do + * NOT produce false positives. */ + it("fallback: a list-semantic name (listProducts) turns a singular ReturnType into Promise", () => { const productDto = node("DTO", "10000000-0000-4000-8000-0000000000e5", { Name: "ProductDto", - Description: "Urun ciktisi", + Description: "Product output", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], }); const catalogService = node("Service", SVC, { ServiceName: "CatalogService", - Description: "Katalog is mantigi", + Description: "Catalog business logic", IsTransactionScoped: false, Dependencies: [], Methods: [ @@ -447,11 +450,11 @@ describe("emitService", () => { MethodName: "listProducts", Visibility: "public", Parameters: [], - ReturnType: "ProductDto", // SINGLE + ReturnsCollection NONE + ReturnType: "ProductDto", // SINGULAR + no ReturnsCollection ReturnDtoRef: "ProductDto", IsAsync: true, Throws: [], - Description: "Urunleri listeler.", + Description: "Lists the products.", }, ], }); @@ -460,32 +463,33 @@ describe("emitService", () => { expect(file.content).toContain("async listProducts(): Promise {"); }); - /* ── PRECEDENCE: bildirilmis ReturnsCollection=false, ad-sezgisini OVERRIDES ─── - * 'getAllSettings' adi 'all' icerir -> fallback koleksiyon derdi; ama alan acikca - * false. Bildirilmis alan KAZANIR (tekil kalir). Bu, `??` semantigini kilitler: - * `||` kullanilsaydi false dusup ada kayardi (ince regresyon) -> bu test yakalar. */ - it("ReturnsCollection=false ad-sezgisini ezer (bildirilen > tahmin)", () => { + /* ── PRECEDENCE: a declared ReturnsCollection=false OVERRIDES the name heuristic ─ + * The name 'getAllSettings' contains 'all' -> the fallback would say collection; but + * the field is explicitly false. The declared field WINS (stays singular). This locks + * in the `??` semantics: with `||`, false would fall through to the name heuristic + * (a subtle regression) -> this test catches it. */ + it("ReturnsCollection=false overrides the name heuristic (declared > inferred)", () => { const settingsDto = node("DTO", "10000000-0000-4000-8000-0000000000f6", { Name: "SettingsDto", - Description: "Ayar ciktisi", + Description: "Settings output", Fields: [{ Name: "id", DataType: "string", IsRequired: true, IsArray: false }], }); const settingsService = node("Service", SVC, { ServiceName: "SettingsService", - Description: "Ayar is mantigi", + Description: "Settings business logic", IsTransactionScoped: false, Dependencies: [], Methods: [ { - MethodName: "getAllSettings", // 'all' -> ad-sezgisi koleksiyon derdi + MethodName: "getAllSettings", // 'all' -> the name heuristic would say collection Visibility: "public", Parameters: [], ReturnType: "SettingsDto", ReturnDtoRef: "SettingsDto", - ReturnsCollection: false, // ama bildirilmis alan tekil diyor -> kazanir + ReturnsCollection: false, // but the declared field says singular -> it wins IsAsync: true, Throws: [], - Description: "Tum ayarlari tek nesnede doner.", + Description: "Returns all settings in a single object.", }, ], }); @@ -495,12 +499,12 @@ describe("emitService", () => { expect(file.content).not.toContain("Promise"); }); - /* ── AUTH GROUNDING: login/register metodlu servis -> auth helper import ─ - * Login/Register fill'i comparePassword/hashPassword/signAccessToken'i KULLANSIN - * diye (duz-metin sifre / sahte token yerine), bu helper'lar servise import edilir - * -> readDeclaredSurface AI'in apiSurface'ine koyar. noUnusedLocals kapali: kullanmazsa - * zararsiz. Auth-metot adi: login/register/signin/signup/authenticate/... */ - it("auth servisi (Login metodu) -> auth helper'larini import eder (grounding)", () => { + /* ── AUTH GROUNDING: a service with login/register methods -> auth helper imports ─ + * So the Login/Register fill USES comparePassword/hashPassword/signAccessToken + * (instead of plain-text passwords / fake tokens), these helpers are imported into + * the service -> readDeclaredSurface puts them in the AI's apiSurface. noUnusedLocals + * is off: harmless if unused. Auth method names: login/register/signin/signup/authenticate/... */ + it("an auth service (Login method) -> imports the auth helpers (grounding)", () => { const authSvc = node("Service", SVC, { ServiceName: "AuthService", Description: "kimlik dogrulama", @@ -519,17 +523,17 @@ describe("emitService", () => { expect(file.content).toMatch(/from "\.\.\/shared\/auth\/auth-token"/); }); - it("auth WITHOUT servis -> auth helper import ETMEZ", () => { + it("a service without auth -> does NOT import the auth helpers", () => { const ctx = ctxFrom([usersService, usersRepository, createUserDto, userDto, notFoundException], []); const [file] = emitService(ctx.graph.byId(SVC)!, ctx); expect(file.content).not.toContain("shared/auth"); }); - /* ── STATE-MACHINE GROUNDING (L2): status-guncelleyen servis -> assert guard ─ - * Update*Status metodu olan servise, gecis kurali TANIMLI enum'larin - * assertTransition guard'i import edilir -> AI fill'i illegal gecisi - * (pending->delivered atlamasi) reddeder. Gecis kurali NONE enum -> import yok. */ - it("status-guncelleyen servis -> gecisli enum'un assertTransition'ini import eder", () => { + /* ── STATE-MACHINE GROUNDING (L2): a status-updating service -> assert guard ─ + * For a service with an Update*Status method, the assertTransition guard of + * enums that HAVE transition rules is imported -> the AI fill rejects illegal + * transitions (skipping pending->delivered). An enum without transition rules -> no import. */ + it("a status-updating service -> imports the transitioned enum's assertTransition", () => { const orderStatus = node("Enum", "e2e2e2e2-2222-4222-8222-e2e2e2e2e2e2", { Name: "OrderStatus", Description: "Order status", @@ -539,7 +543,7 @@ describe("emitService", () => { }); const orderSvc = node("Service", SVC, { ServiceName: "OrderService", - Description: "siparis is mantigi", + Description: "order business logic", IsTransactionScoped: false, Dependencies: [], Methods: [ @@ -552,13 +556,13 @@ describe("emitService", () => { expect(file.content).toMatch(/from ".*order-status\.enum"/); }); - it("gecis kurali NONE enum -> status servisi guard import ETMEZ", () => { + it("an enum without transition rules -> the status service does NOT import the guard", () => { const plainStatus = node("Enum", "e3e3e3e3-3333-4333-8333-e3e3e3e3e3e3", { Name: "OrderStatus", Description: "durum", BackingType: "string", Values: [{ Key: "PENDING" }, { Key: "CONFIRMED" }], - // Transitions NONE. + // No Transitions. }); const orderSvc = node("Service", SVC, { ServiceName: "OrderService", @@ -572,8 +576,8 @@ describe("emitService", () => { expect(file.content).not.toContain("assertOrderStatusTransition"); }); - /* ── EDGE-CASE: kayip ref + bos koleksiyon — ASLA throw etmez ──────────── */ - it("edge-case: kayip DTO/Exception ref + bos Dependencies — throw etmez, ham tip kullanir", () => { + /* ── EDGE-CASE: missing refs + empty collections — NEVER throws ─────────── */ + it("edge-case: missing DTO/Exception refs + empty Dependencies — does not throw, uses the raw type", () => { const lonelyService = node("Service", SVC, { ServiceName: "LonelyService", Description: "Bagimliliksiz servis", @@ -592,23 +596,23 @@ describe("emitService", () => { }, ], }); - // Hicbir ref'i cozen node yok; yalniz servisin kendisi graph'ta. + // No node resolves any of the refs; only the service itself is in the graph. const ctx = ctxFrom([lonelyService], []); let file: { content: string; surgicalMarkers: number; path: string } | undefined; expect(() => { file = emitService(ctx.graph.byId(SVC)!, ctx)[0]; }).not.toThrow(); - // Controller yok → Service kendi adindan feature turer ("lonely"); dosya adi - // rol son-ekini ("Service") TEKRARLAMAZ. + // No Controller → the Service derives the feature from its own name ("lonely"); the file + // name does NOT repeat the role suffix ("Service"). expect(file!.path).toBe("lonely/lonely.service.ts"); - // Constructor yok (bos DI), parametre tipi ham "string"e dusmus. + // No constructor (empty DI), the parameter type fell back to the raw "string". expect(file!.content).not.toContain("constructor("); - // public metot -> async (NestJS idiom + await-sync guvenlik agi); ham ReturnType Promise'le sarilir. + // public method -> async (NestJS idiom + await-in-sync safety net); the raw ReturnType is wrapped in Promise. expect(file!.content).toContain("async ping(raw: string): Promise {"); - // Kayip ReturnDtoRef -> ham ReturnType (Promise<> icinde). + // Missing ReturnDtoRef -> the raw ReturnType (inside Promise<>). expect(file!.content).toContain("): Promise {"); - // Cozulemeyen exception artik SENTETIK dosyadan import edilir (exception-synthesis - // bildirilmis-ama-tanimsiz Throws'u uretir → fill `throw new Ghost...` derlenir, TS2304 yok). + // The unresolvable exception is now imported from a SYNTHETIC file (exception-synthesis + // generates declared-but-undefined Throws → the fill's `throw new Ghost...` compiles, no TS2304). expect(file!.content).toContain('import { GhostException } from "../common/exceptions/ghost.exception";'); expect(file!.content).toContain("// throws: GhostException"); expect(file!.surgicalMarkers).toBe(1); diff --git a/apps/server/src/codegen/emitters/nestjs/service.emitter.ts b/apps/server/src/codegen/emitters/nestjs/service.emitter.ts index e3c1424..b6e1daf 100644 --- a/apps/server/src/codegen/emitters/nestjs/service.emitter.ts +++ b/apps/server/src/codegen/emitters/nestjs/service.emitter.ts @@ -18,36 +18,36 @@ import type { NodeKind } from "../../../nodes/schemas"; /* ──────────────────────────────────────────────────────────────────────── * service.emitter.ts — ServiceNode -> /.service.ts. * - * @Injectable() bir NestJS servisi uretir: - * - DI alanlari: ServiceNode.Dependencies (Kind+Ref) BIRLESIM - * graph.outEdges(id, "CALLS") hedefleri (Repository/Service/Cache/ - * ExternalService). DEDUP edilir, isme gore siralanir, constructor'a - * `private readonly : ` olarak enjekte edilir. - * Cozulebilen ref'ler icin import eklenir; cozulemeyen ref'ler ham - * Ref isminden sinif adi turetir (import atlanir → ASLA throw). - * - Metotlar: Parameters (DtoRef -> DTO tipi+import; yoksa ham Type; + * Generates an @Injectable() NestJS service: + * - DI fields: ServiceNode.Dependencies (Kind+Ref) UNION the + * graph.outEdges(id, "CALLS") targets (Repository/Service/Cache/ + * ExternalService). Deduped, sorted by name, injected into the constructor + * as `private readonly : `. + * Imports are added for resolvable refs; unresolvable refs derive the class + * name from the raw Ref name (import skipped → NEVER throws). + * - Methods: Parameters (DtoRef -> DTO type+import; otherwise raw Type; * Optional -> "?"; Default), ReturnType (ReturnDtoRef -> DTO; - * IsAsync -> Promise<>). Govde = surgicalMarker (Description, Throws -> - * Exception, erisilebilir bagimliliklar this.) + notImplemented(). + * IsAsync -> Promise<>). Body = surgicalMarker (Description, Throws -> + * Exception, reachable dependencies this.) + notImplemented(). * - * SAF + DETERMINISTIC: koleksiyonlar sirali, import'lar ImportCollector ile, - * timestamp/random yok, icerik tek "\n" ile biter. + * PURE + DETERMINISTIC: collections sorted, imports via ImportCollector, + * no timestamps/randomness, content ends with a single "\n". * ──────────────────────────────────────────────────────────────────────── */ -/** DI ile enjekte edilebilen bagimlilik kind'lari (Dependencies.Kind ⊆ bunlar). */ +/** Dependency kinds injectable via DI (Dependencies.Kind ⊆ these). */ const INJECTABLE_KINDS: NodeKind[] = ["Repository", "Service", "Cache", "ExternalService"]; -/** Bir servisi "auth servisi" sayan kimlik-metodu adlari (onek eslesmesi). Boyle - * bir metot varsa paylasimli auth helper'lari (password/token) import edilir → - * fill grounding'i: Login/Register duz-metin sifre / sahte token yerine bunlari kullanir. */ +/** Identity-method names that mark a service as an "auth service" (prefix match). + * When such a method exists, the shared auth helpers (password/token) are imported → + * fill grounding: Login/Register use them instead of plaintext passwords / fake tokens. */ const AUTH_METHOD_RE = /^(login|register|signup|signin|authenticate|refreshtoken|validatetoken|verifytoken|resetpassword|changepassword|forgotpassword)/i; -/** Tam backend emitter'i OLAN (sinifi `pascalCase(name)` olarak export eden) - * kind'lar. Cache + ExternalService artik tam emitter'a sahip (cache.emitter / - * external-service.emitter) -> gercek sinifi `pascalCase(name)` export ederler - * (Stub eki NONE). DI tipi/import sembolu bununla eslesmek ZORUNDA; ir.ts - * FULL_PROVIDER_KINDS ile birebir tutulmalidir. */ +/** Kinds that HAVE a full backend emitter (exporting their class as + * `pascalCase(name)`). Cache + ExternalService now have full emitters + * (cache.emitter / external-service.emitter) -> they export the real class as + * `pascalCase(name)` (no Stub suffix). The DI type/import symbol MUST match this; + * keep in exact sync with FULL_PROVIDER_KINDS in ir.ts. */ const FULL_EMITTER_KINDS: ReadonlySet = new Set([ "Repository", "Service", @@ -55,23 +55,23 @@ const FULL_EMITTER_KINDS: ReadonlySet = new Set([ "ExternalService", ]); -/** Bir node'un uretilen dosyada export ettigi sinif adini dondurur: tam - * emitter'li kind'lar `pascalCase(name)`; stub'lanan kind'lar `pascalCase(name) - * + "Stub"` (stub.emitter.ts ile TEK SOURCE). resolved=null (kayip ref) -> - * ham ref'in pascal'i (kind bilinmez; mevcut davranis korunur). */ +/** Returns the class name a node exports in its generated file: kinds with a + * full emitter use `pascalCase(name)`; stubbed kinds use `pascalCase(name) + * + "Stub"` (single source with stub.emitter.ts). resolved=null (missing ref) -> + * the raw ref's pascal form (kind unknown; existing behavior preserved). */ function injectedClassName(resolved: CodeNode | null, rawRef: string): string { if (!resolved) return pascalCase(rawRef); const base = pascalCase(resolved.name); return FULL_EMITTER_KINDS.has(resolved.kindOf()) ? base : `${base}Stub`; } -/** Cozulmus bir bagimlilik: DI alani + sinif tipi + (varsa) import yolu. */ +/** A resolved dependency: DI field + class type + import path (when available). */ interface ResolvedDep { - /** constructor'da `this.` */ + /** `this.` in the constructor */ field: string; - /** enjekte edilen sinif tipi */ + /** the injected class type */ className: string; - /** cozulen node'un dosya yolu (import icin); cozulemezse null. */ + /** file path of the resolved node (for the import); null when unresolvable. */ filePath: string | null; } @@ -84,10 +84,10 @@ export const emitService: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] = const imports = new ImportCollector(); imports.add("Injectable", "@nestjs/common"); - // ── DI bagimliliklari: Dependencies ∪ CALLS hedefleri, DEDUP + isme gore sirali ── - // Cozulemeyen dep'ler (filePath===null) DI'dan DUSURULUR: ciplak tipli constructor - // param'i hem TS2304 (import yok) hem NestJS DI boot patlamasi (saglayici yok) verirdi. - // Bunlar contract-lint Rule 5 ile YUKSEK SESLE bildirilir; in-file de TODO birakilir. + // ── DI dependencies: Dependencies ∪ CALLS targets, deduped + sorted by name ── + // Unresolvable deps (filePath===null) are DROPPED from DI: a bare-typed constructor + // param would cause both TS2304 (no import) and a NestJS DI boot crash (no provider). + // These are reported LOUDLY by contract-lint Rule 5; an in-file TODO is also left. const allDeps = collectDependencies(node, graph); const deps = allDeps.filter((d) => d.filePath !== null); const unresolvedDeps = allDeps.filter((d) => d.filePath === null); @@ -95,21 +95,22 @@ export const emitService: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] = imports.add(dep.className, importPathOf(relativeImportPath(filePath, dep.filePath!))); } - // ── AUTH GROUNDING: kimlik-metodu (login/register/...) tasiyan servise paylasimli - // auth helper'larini import et. Bunlar scaffold tarafindan uretilir; import - // edilince readDeclaredSurface AI'in apiSurface'ine koyar → Login duz-metin sifre - // yerine comparePassword, sahte token yerine signAccessToken kullanir. noUnusedLocals - // kapali → kullanilmazsa zararsiz (dead import degil tsc hatasi NOT). ── + // ── AUTH GROUNDING: import the shared auth helpers into a service that carries an + // identity method (login/register/...). These are generated by the scaffold; once + // imported, readDeclaredSurface puts them on the AI's apiSurface → Login uses + // comparePassword instead of plaintext passwords and signAccessToken instead of a + // fake token. noUnusedLocals is off → harmless if unused (a dead import, not a tsc error). ── if (props.Methods.some((m) => AUTH_METHOD_RE.test(m.MethodName))) { imports.add("comparePassword", relativeImportPath(filePath, "shared/auth/password")); imports.add("hashPassword", relativeImportPath(filePath, "shared/auth/password")); imports.add("signAccessToken", relativeImportPath(filePath, "shared/auth/auth-token")); } - // ── STATE-MACHINE GROUNDING (L2): status-guncelleyen metodu (Update*Status) - // olan servise, gecis kurali TANIMLI enum'larin assertTransition guard'ini - // import et -> AI fill'i illegal durum gecisini (pending->delivered) reddeder. - // Yalniz Transitions tasiyan enum'lar (status enum'lari); Color/Size eklenmez. ── + // ── STATE-MACHINE GROUNDING (L2): into a service with a status-updating method + // (Update*Status), import the assertTransition guard of enums that have + // transition rules DEFINED -> the AI fill rejects illegal state transitions + // (pending->delivered). Only enums carrying Transitions (status enums); + // Color/Size are not added. ── if (props.Methods.some((m) => /update\w*status/i.test(m.MethodName))) { for (const en of graph.allOf("Enum")) { if ((propsOf<"Enum">(en).Transitions ?? []).length === 0) continue; @@ -118,23 +119,23 @@ export const emitService: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] = } } - // ── Metotlar ─────────────────────────────────────────────────────────── + // ── Methods ─────────────────────────────────────────────────────────── const methodBlocks: string[] = []; - // Metotlari MethodName'e gore deterministik sirala. + // Sort methods deterministically by MethodName. const methods = [...props.Methods].sort((a, b) => cmp(a.MethodName, b.MethodName)); for (const m of methods) { methodBlocks.push(renderMethod(node, className, m, deps, graph, filePath, imports)); } - // ── Sinif govdesi ──────────────────────────────────────────────────────── + // ── Class body ──────────────────────────────────────────────────────── const lines: string[] = []; - // Anlamli bir aciklama varsa JSDoc bas; tek-harf/bos gurultuyu (ham "s"/"c" - // gibi) atla -> "/** s */" gibi anlamsiz doc uretme. + // Emit a JSDoc when the description is meaningful; skip single-letter/empty + // noise (raw "s"/"c" etc.) -> don't produce meaningless docs like "/** s */". if (isMeaningfulDoc(props.Description)) lines.push(`/** ${props.Description!.trim()} */`); lines.push("@Injectable()"); lines.push(`export class ${className} {`); - // Cozulemeyen bagimliliklar: DI'dan dusuruldu; in-file TODO ile gorunur kil. + // Unresolvable dependencies: dropped from DI; made visible with an in-file TODO. for (const u of unresolvedDeps) { lines.push( ` // TODO: dependency "${u.field}" (${u.className}) could not be resolved — omitted from DI (fix the reference).`, @@ -169,22 +170,22 @@ export const emitService: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] = return [file]; }; -/** Dependencies (Kind+Ref) ∪ CALLS edge hedeflerini DEDUP edip isme gore - * siralanmis ResolvedDep listesi dondurur. Cozulemeyen ref'ler ham isimden - * sinif adi turetir (filePath=null → import atlanir). Asla throw etmez. */ +/** Dedupes Dependencies (Kind+Ref) ∪ CALLS edge targets and returns a + * ResolvedDep list sorted by name. Unresolvable refs derive the class name + * from the raw name (filePath=null → import skipped). Never throws. */ function collectDependencies(node: CodeNode, graph: CodeGraph): ResolvedDep[] { - // refName -> ResolvedDep (DEDUP anahtari: cozulen node.name veya ham ref). + // refName -> ResolvedDep (dedup key: resolved node.name or the raw ref). const byKey = new Map(); const props = propsOf<"Service">(node); - // (1) property Dependencies — Kind ipucu var. + // (1) property Dependencies — has a Kind hint. for (const dep of props.Dependencies ?? []) { const resolved = graph.resolveRef(dep.Kind, dep.Ref); addDep(byKey, resolved, dep.Ref, graph); } - // (2) CALLS edge hedefleri — Repository/Service/Cache/ExternalService. + // (2) CALLS edge targets — Repository/Service/Cache/ExternalService. for (const e of graph.outEdges(node.id, "CALLS")) { const tgt = graph.byId(e.targetNodeId); if (!tgt) continue; @@ -195,11 +196,11 @@ function collectDependencies(node: CodeNode, graph: CodeGraph): ResolvedDep[] { return [...byKey.values()].sort((a, b) => cmp(a.field, b.field)); } -/** Bir bagimliligi (cozulmus node veya ham ref) DEDUP map'ine ekler. - * COZULEN KAZANIR: ayni isimde bir kayit zaten varsa ama o kayit cozulmemisse - * (filePath===null) ve gelen cozulmusse, kaydi YUKSELT (import kaybini onler). - * Orn. cozulemeyen bir property Dependency, ayni node'a giden cozulebilir bir - * CALLS edge'ini maskelemesin — eskiden ilk-kazanir filePath=null birakiyordu. */ +/** Adds a dependency (resolved node or raw ref) to the dedup map. + * RESOLVED WINS: if an entry with the same name already exists but is unresolved + * (filePath===null) and the incoming one is resolved, UPGRADE the entry (prevents + * import loss). E.g. an unresolvable property Dependency must not mask a resolvable + * CALLS edge to the same node — previously first-wins left filePath=null. */ function addDep( byKey: Map, resolved: CodeNode | null, @@ -210,8 +211,8 @@ function addDep( const key = refName; const existing = byKey.get(key); if (existing) { - // Mevcut cozulmemis + gelen cozulmus -> yukselt; aksi halde ilk-kazanir. - // Yukseltirken sinif adini da duzelt (stub kind'i `Stub` olabilir). + // Existing unresolved + incoming resolved -> upgrade; otherwise first-wins. + // When upgrading, fix the class name too (a stub kind may be `Stub`). if (existing.filePath === null && resolved) { existing.filePath = filePathFor(resolved, graph); existing.className = injectedClassName(resolved, rawRef); @@ -219,15 +220,15 @@ function addDep( return; } byKey.set(key, { - // DI alani node adindan (stub eki tasimaz; "usersCache"). + // DI field from the node name (carries no stub suffix; "usersCache"). field: camelCase(refName), - // DI tipi = uretilen sinif adi: tam emitter -> Pascal; stub -> Pascal+"Stub". + // DI type = the generated class name: full emitter -> Pascal; stub -> Pascal+"Stub". className: injectedClassName(resolved, rawRef), filePath: resolved ? filePathFor(resolved, graph) : null, }); } -/** Tek bir ServiceMethod'u (imza + surgical govde) render eder. */ +/** Renders a single ServiceMethod (signature + surgical body). */ function renderMethod( node: CodeNode, className: string, @@ -239,52 +240,55 @@ function renderMethod( ): string { const indent = " "; - // ── Parametreler ───────────────────────────────────────────────────────── + // ── Parameters ───────────────────────────────────────────────────────── const params: string[] = []; for (const p of method.Parameters ?? []) { const typeName = resolveTypeName(p.DtoRef, p.Type, graph, fromFile, imports); const hasDefault = p.Default !== undefined && p.Default !== ""; - // TS: bir parametre HEM "?" HEM "= default" alamaz; default zaten parametreyi - // implicit opsiyonel yapar. Default varsa "?" dusurulur. + // TS: a parameter cannot have BOTH "?" and "= default"; a default already makes + // the parameter implicitly optional. When there is a default, drop the "?". const optional = p.Optional && !hasDefault ? "?" : ""; const def = hasDefault ? ` = ${p.Default}` : ""; params.push(`${p.Name}${optional}: ${typeName}${def}`); } const paramList = params.join(", "); - // ── Donus tipi ─────────────────────────────────────────────────────────── - // TEK-SOURCE KARDINALITE: ReturnsCollection bildirildiyse (true) donus tipini - // DTO[]'e zorla — graf tekil ReturnType verse bile (or. ListProducts: ReturnType - // 'ProductDto' ama operasyon koleksiyon). Boylece service imzasi controller'in - // koleksiyon karariyla HIZALI kalir; aksi halde tekil imza + dizi donduren surgical - // govde derleme hatasi verirdi (gercek bug). Zaten dizi/Array<> tasiyan tip IKI - // KEZ sarilmaz. + // ── Return type ─────────────────────────────────────────────────────────── + // SINGLE-SOURCE CARDINALITY: when ReturnsCollection is declared (true), force + // the return type to DTO[] — even if the graph gives a singular ReturnType + // (e.g. ListProducts: ReturnType 'ProductDto' but the operation is a collection). + // This keeps the service signature ALIGNED with the controller's collection + // decision; otherwise a singular signature + an array-returning surgical body + // would be a compile error (a real bug). A type already carrying []/Array<> is + // not wrapped TWICE. let innerReturn = resolveTypeName(method.ReturnDtoRef, method.ReturnType, graph, fromFile, imports); - // Bildirilmis ReturnsCollection (true/false) KAZANIR; yoksa metot-adi liste- - // semantigi fallback'i (list/all/search/findAll/findMany). Zaten dizi olan tip - // (or. ReturnType 'XDto[]') IKI KEZ sarilmaz. + // A declared ReturnsCollection (true/false) WINS; otherwise fall back to + // method-name list semantics (list/all/search/findAll/findMany). A type that is + // already an array (e.g. ReturnType 'XDto[]') is not wrapped TWICE. const returnsCollection = method.ReturnsCollection ?? tokensHaveCollectionSemantics(splitWords(method.MethodName)); if (returnsCollection && !isArrayType(innerReturn)) { innerReturn = `${innerReturn}[]`; } - // ── ASYNC: PUBLIC service metotlari DAIMA async (NestJS idiom + guvenlik agi). - // Public bir metot neredeyse her zaman I/O yapar (repo/servis cagrisi → await); - // graf IsAsync:false dese bile surgical fill `await` kullaninca sync imza TS1308 - // ile kirilirdi (gercek bug: AuthService.ValidateToken). Public → async (Promise - // sarmali); private metotlar graf IsAsync'ini KORUR (saf yardimci olabilir). + // ── ASYNC: PUBLIC service methods are ALWAYS async (NestJS idiom + safety net). + // A public method almost always does I/O (repo/service call → await); even when + // the graph says IsAsync:false, a sync signature would break with TS1308 once the + // surgical fill used `await` (a real bug: AuthService.ValidateToken). Public → + // async (Promise wrapper); private methods KEEP the graph's IsAsync (they may be + // pure helpers). const isAsync = method.IsAsync || method.Visibility === "public"; const returnType = isAsync ? `Promise<${innerReturn}>` : innerReturn; - // ── Erisilebilir bagimliliklar (this.) ────────────────────────────── + // ── Reachable dependencies (this.) ────────────────────────────── const depFields = deps.map((d) => `this.${d.field}`); - // ── Firlatilabilir Exception'lar — HER ZAMAN import edilir. ──────────────── - // Cozulen Exception node'u → o dosyadan. Cozulmeyen (bildirilmis-ama-tanimsiz - // Throws) → exception-synthesis SENTETIK sinifi uretir; import'u da oradan yap - // (TEK SOURCE synthException*). Aksi halde marker fill'i `throw new X` uretmeye - // zorlar ama X import'suz/tanimsiz kalir → TS2304 (gercek bug: PlaceOrder'in - // CartEmptyException'i). Sentetik dosya assemble'da emitSyntheticException ile basilir. + // ── Throwable Exceptions — ALWAYS imported. ──────────────── + // A resolved Exception node → from its file. An unresolved one (declared-but- + // undefined Throws) → exception-synthesis generates a SYNTHETIC class; import + // from there too (single source: synthException*). Otherwise the marker forces + // the fill to produce `throw new X` while X stays unimported/undefined → TS2304 + // (a real bug: PlaceOrder's CartEmptyException). The synthetic file is written + // during assembly via emitSyntheticException. const throwsNames: string[] = []; for (const exName of method.Throws ?? []) { const exNode = graph.resolveRef("Exception", exName); @@ -313,22 +317,23 @@ function renderMethod( return lines.join("\n"); } -/** Bir parametre/donus tipini cozer: DtoRef varsa DTO sinif adi (+import), - * yoksa ham Type NORMALIZE edilir (resolveTypeRef: UUID->string, User->import+ - * sinif). Cozulemeyen serbest ad oldugu gibi gecer (controller.emitter ile ayni - * tolerans) ama skaler es anlamlilar ve entity/DTO/Enum adlari cozulur -> TS2304 - * onlenir. +/** Resolves a parameter/return type: with a DtoRef, the DTO class name (+import); + * otherwise the raw Type is NORMALIZED (resolveTypeRef: UUID->string, User-> + * import+class). An unresolvable free-form name passes through as-is (same + * tolerance as controller.emitter), but scalar synonyms and entity/DTO/Enum names + * resolve -> TS2304 prevented. * - * DIZI/SARMALAYICI KORUMA: graf zaten dizi donusleri icin ReturnType="XDto[]" - * (or. "CartItemDto[]") verir ama DtoRef de doludur (DTO sinifini isaret eder). - * Eskiden DtoRef dolu oldugunda ham Type TAMAMEN atilir, ciplak "CartItemDto" - * donerdi -> service tekil, controller (resolveTypeRef'ten gectigi icin) dizi -> - * UYUMSUZ imza. Duzeltme: DtoRef SINIF ADINI cozse bile ham Type'taki - * dizi/sarmalayici son-ekini ([], Array<>, <>, | null/undefined ...) KORU. - * Yontem: ham Type icindeki ciplak tanimlayiciyi (DTO adi) cozulmus sinif adina - * yer-degistir, cevreleyen sarmalayiciyi (resolveTypeRef'in korudugu <>[]| vb.) - * oldugu gibi birak. Ham Type sarmalayici icermiyorsa (tekil, or. "unknown" + - * DtoRef) MEVCUT davranis korunur: ciplak DTO sinifi doner. */ + * ARRAY/WRAPPER PRESERVATION: for array returns the graph already gives + * ReturnType="XDto[]" (e.g. "CartItemDto[]") while DtoRef is also set (pointing + * at the DTO class). Previously, when DtoRef was set, the raw Type was discarded + * ENTIRELY and a bare "CartItemDto" came back -> service singular, controller + * (going through resolveTypeRef) array -> MISMATCHED signatures. Fix: even when + * DtoRef resolves the CLASS NAME, PRESERVE the array/wrapper suffix in the raw + * Type ([], Array<>, <>, | null/undefined ...). + * Method: replace the bare identifier (the DTO name) inside the raw Type with the + * resolved class name, leaving the surrounding wrapper (the <>[]| etc. that + * resolveTypeRef preserves) untouched. When the raw Type has no wrapper (singular, + * e.g. "unknown" + DtoRef) the EXISTING behavior is kept: the bare DTO class is returned. */ function resolveTypeName( dtoRef: string | undefined, rawType: string, @@ -340,67 +345,67 @@ function resolveTypeName( const dtoNode = graph.resolveRef("DTO", dtoRef); if (dtoNode) { const dtoClass = pascalCase(dtoNode.name); - // DEGER import'u (type-only NOT): surgical AI govdede DTO'yu runtime - // deger olarak kullanir (plainToInstance(CreateUserDto, ...), validate(...)); - // `import type` olsaydi bu kullanim derlenmezdi. Controller.emitter @Body - // DTO'sunu da DEGER import eder (class-validator runtime) -> tutarli. + // VALUE import (not type-only): the surgical AI uses the DTO as a runtime + // value in the body (plainToInstance(CreateUserDto, ...), validate(...)); + // with `import type` that usage would not compile. Controller.emitter also + // VALUE-imports the @Body DTO (class-validator runtime) -> consistent. imports.add(dtoClass, importPathOf(relativeImportPath(fromFile, filePathFor(dtoNode, graph)))); - // Ham Type bir dizi/sarmalayici tasiyorsa onu KORU (controller ile hizali): + // If the raw Type carries an array/wrapper, PRESERVE it (aligned with the controller): // "CartItemDto[]" -> "CartItemDto[]", "Promise" -> "Promise". - // Tekil ham Type (sarmalayicisiz) -> ciplak DTO sinifi (mevcut davranis). + // A singular raw Type (no wrapper) -> the bare DTO class (existing behavior). return applyTypeWrapper(rawType, dtoClass); } - // Cozulemeyen DtoRef -> ham Type'i normalize et; yoksa ref ismini koru. + // Unresolvable DtoRef -> normalize the raw Type; failing that, keep the ref name. return rawType && rawType !== "" ? resolveTypeRef(rawType, graph, fromFile, imports) : pascalCase(dtoRef); } return resolveTypeRef(rawType, graph, fromFile, imports); } -/** Ham bir tip stringinin SARMALAYICISINI cozulmus sinif adina uygular. +/** Applies a raw type string's WRAPPER to the resolved class name. * - * Ham Type icindeki TEK ciplak tanimlayici parcasini (DTO adi) `resolvedClass` - * ile yer-degistirir; cevredeki sarmalayici sembolleri ([], <>, |, Array, Promise, - * bosluk, null, undefined ...) OLDUGU GIBI korur. Boylece: - * "CartItemDto[]" + UserDto -> "UserDto[]" (DtoRef sinifi, dizi korunur) - * "CartItemDto" + UserDto -> "UserDto" (tekil; mevcut davranis) - * "unknown" / "" + UserDto -> "UserDto" (sarmalayici yok -> ciplak) - * "Promise" + UserDto -> "Promise" (sarmalayici korunur) + * Replaces the SINGLE bare identifier part (the DTO name) inside the raw Type + * with `resolvedClass`; the surrounding wrapper symbols ([], <>, |, Array, + * Promise, whitespace, null, undefined ...) are kept AS-IS. So: + * "CartItemDto[]" + UserDto -> "UserDto[]" (DtoRef class, array preserved) + * "CartItemDto" + UserDto -> "UserDto" (singular; existing behavior) + * "unknown" / "" + UserDto -> "UserDto" (no wrapper -> bare) + * "Promise" + UserDto -> "Promise" (wrapper preserved) * - * Ham Type'ta tam olarak BIR tip-tanimlayicisi (TS anahtar kelimesi olmayan) - * varsa onu resolvedClass ile degistirir. Aksi halde (0 ya da >1 tanimlayici, - * or. union "A | B") sarmalayiciyi guvenle esleyemeyiz -> ciplak resolvedClass'a - * duseriz (mevcut tekil davranis; determinizm + guvenli taraf). */ + * When the raw Type contains exactly ONE type identifier (not a TS keyword), + * it is replaced with resolvedClass. Otherwise (0 or >1 identifiers, e.g. the + * union "A | B") we cannot safely map the wrapper -> fall back to the bare + * resolvedClass (existing singular behavior; determinism + the safe side). */ function applyTypeWrapper(rawType: string, resolvedClass: string): string { const t = (rawType ?? "").trim(); if (t.length === 0) return resolvedClass; - // Tip-tanimlayicisi parcalari (TS sarmalayici anahtar kelimeleri HARIC). + // Type-identifier parts (EXCLUDING the TS wrapper keywords). const ids = (t.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).filter((tok) => !TYPE_WRAPPER_KEYWORDS.has(tok)); - // Tam olarak bir tip-tanimlayicisi yoksa sarmalayiciyi guvenle esleyemeyiz. + // Without exactly one type identifier we cannot safely map the wrapper. if (ids.length !== 1) return resolvedClass; - // O tek tanimlayiciyi resolvedClass ile degistir; sarmalayiciyi koru. + // Replace that single identifier with resolvedClass; preserve the wrapper. return t.replace(/[A-Za-z_][A-Za-z0-9_]*/g, (tok) => TYPE_WRAPPER_KEYWORDS.has(tok) ? tok : resolvedClass, ); } -/** Sarmalayici/yapisal tip anahtar kelimeleri: bunlar bir DTO adi NOTDIR, ham - * Type'ta gorunseler bile yer-degistirme disi tutulur (sarmalayici parcasi). */ +/** Wrapper/structural type keywords: these are NOT a DTO name; even when they + * appear in the raw Type they are excluded from replacement (wrapper parts). */ const TYPE_WRAPPER_KEYWORDS: ReadonlySet = new Set([ "Promise", "Array", "Readonly", "Partial", "null", "undefined", "void", ]); -/** Deterministik string karsilastirmasi. */ +/** Deterministic string comparison. */ function cmp(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } -/** Bir Description'in anlamli bir JSDoc'a degip degmedigi: trim sonrasi >=3 char. - * Tek-harf/bos aciklamalar ("s", "c", " ") JSDoc gurultusu; atlanir. */ +/** Whether a Description is worth a JSDoc: >=3 chars after trimming. + * Single-letter/empty descriptions ("s", "c", " ") are JSDoc noise; skipped. */ function isMeaningfulDoc(desc: string | undefined): boolean { return typeof desc === "string" && desc.trim().length >= 3; } -/* ── Yerel tip: ServiceMethod (service.schema.ts ile ayni shape) ──────────── */ +/* ── Local type: ServiceMethod (same shape as service.schema.ts) ──────────── */ type ServiceProps = PropsByKind["Service"]; type ServiceMethod = ServiceProps["Methods"][number]; diff --git a/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.spec.ts index f1f7440..3e34211 100644 --- a/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.spec.ts @@ -6,12 +6,12 @@ import type { GeneratedFile } from "../../types"; import type { StoredNode } from "../../../nodes/nodes.repository"; /* ──────────────────────────────────────────────────────────────────────── - * surgical-plan.emitter.spec.ts — SURGICAL_PLAN.md dogrulamasi. + * surgical-plan.emitter.spec.ts — SURGICAL_PLAN.md verification. * - * (1) MD iki bolum + kapanis talimati icerir, Ingilizcedir. - * (2) Uretilen .ts dosyalarindaki "@solarch:surgical" marker'lari taranir: - * dosya yolu + imza + throws/deps + "Implement: ..." maddesi listelenir. - * (3) SAF + DETERMINISTIC: ayni girdi -> byte-identical MD. + * (1) The MD contains two sections + closing instructions, in English. + * (2) The "@solarch:surgical" markers in the generated .ts files are scanned: + * file path + signature + throws/deps + an "Implement: ..." item are listed. + * (3) PURE + DETERMINISTIC: same input -> byte-identical MD. * ──────────────────────────────────────────────────────────────────────── */ const PROJECT = "00000000-0000-4000-8000-000000000000"; @@ -32,7 +32,7 @@ function node(type: StoredNode["type"], id: string, properties: Record feature listesi. */ +/** Fixture graph: a single "users" feature (controller + service) -> the feature list. */ function fixtureGraph() { const svc = node("Service", SVC, { ServiceName: "UsersService", Description: "x", Methods: [], Dependencies: [] }); const ctrl = node("Controller", "10000000-0000-4000-8000-000000000002", { @@ -73,28 +73,28 @@ function fixtureGraph() { } describe("emitSurgicalPlan", () => { - it("SURGICAL_PLAN.md uretir (kok yol, markdown, surgicalMarkers 0)", () => { + it("generates SURGICAL_PLAN.md (root path, markdown, surgicalMarkers 0)", () => { const file = emitSurgicalPlan([], fixtureGraph()); expect(file.path).toBe("SURGICAL_PLAN.md"); expect(file.language).toBe("markdown"); - // MD prose marker ADINI anabilir ama bir surgical BODY degildir; emitter - // surgicalMarkers'i 0'a sabitler -> aggregate surgicalMarkerCount bozulmaz. + // The MD prose may mention the marker NAME but is not a surgical BODY; the emitter + // pins surgicalMarkers to 0 -> the aggregate surgicalMarkerCount stays intact. expect(file.surgicalMarkers).toBe(0); expect(file.content.endsWith("\n")).toBe(true); }); - it("iki bolum + kapanis talimati icerir (Ingilizce prompt)", () => { + it("contains two sections + closing instructions (English prompt)", () => { const file = emitSurgicalPlan([], fixtureGraph()); expect(file.content).toContain("# Surgical Implementation Plan"); expect(file.content).toContain("## 1. Codebase introduction"); expect(file.content).toContain("## 2. Surgical implementation plan"); expect(file.content).toContain("## Instructions"); - // Codebase tanitimi: NestJS + Solarch + mimari. + // Codebase introduction: NestJS + Solarch + the architecture. expect(file.content).toContain("NestJS"); expect(file.content).toContain("Solarch"); expect(file.content).toContain("CoreModule"); expect(file.content).toContain("shared/"); - // Kapanis: yalniz isaretli govdeleri doldur, yapiyi degistirme. + // Closing: only fill the marked bodies, do not change the structure. expect(file.content).toContain("Do NOT change any signature"); expect(file.content).toContain("Do NOT edit any other code"); // English only (not Turkish) — pasted to user. @@ -102,13 +102,13 @@ describe("emitSurgicalPlan", () => { expect(file.content).not.toMatch(turkishChars); }); - it("feature listesini graph'tan kurar", () => { + it("builds the feature list from the graph", () => { const file = emitSurgicalPlan([], fixtureGraph()); expect(file.content).toContain("Features (1)"); expect(file.content).toContain("`users`"); }); - it("marker tarar: dosya yolu + imza + Implement + throws + deps listelenir", () => { + it("scans markers: file path + signature + Implement + throws + deps are listed", () => { const files = [ tsFileWithMarker( "src/users/users.service.ts", @@ -124,22 +124,22 @@ describe("emitSurgicalPlan", () => { ]; const file = emitSurgicalPlan(files, fixtureGraph()); - // Dosyaya gore grupli baslik. + // Heading grouped by file. expect(file.content).toContain("### `src/users/users.service.ts`"); - // Imza (marker'in ust satiri) listelenir. + // The signature (the line above the marker) is listed. expect(file.content).toContain("`async create(dto: CreateUserDto): Promise {`"); - // Description -> Implement maddesi. + // Description -> the Implement item. expect(file.content).toContain("Implement: Create a new user and persist it."); // throws + deps. expect(file.content).toContain("Throws: UserNotFoundException"); expect(file.content).toContain("Available dependencies: this.userRepository"); - // Sayac metni (1 govde). + // Counter text (1 body). expect(file.content).toContain("**1** surgical method body"); }); - it("cok-satirli imzayi (controller @Body) tek satira birlestirir", () => { - // NestJS controller: imza @Body() parametresiyle birden multilinea yayilir, - // marker'in hemen ustundeki satir yalniz KAPANISTIR (`): Promise {`). + it("joins a multi-line signature (controller @Body) into a single line", () => { + // NestJS controller: the signature spreads across multiple lines with a @Body() + // parameter; the line right above the marker is only the CLOSING part (`): Promise {`). const content = [ "@Controller()", @@ -158,31 +158,31 @@ describe("emitSurgicalPlan", () => { { path: "src/users/users.controller.ts", content, language: "typescript", surgicalMarkers: 1 }, ]; const file = emitSurgicalPlan(files, fixtureGraph()); - // Imza tam: metot adi + parametreler + donus tipi tek satirda birlesir. + // The signature is complete: method name + parameters + return type merge into one line. expect(file.content).toContain("`async post( @Body() dto: CreateUserDto, ): Promise {`"); - // Sadece kapanis parcasi ("): Promise {") TEK BASINA listelenmemeli. + // The closing fragment alone ("): Promise {") must NOT be listed by itself. expect(file.content).not.toContain("**`): Promise {`**"); }); - it("description NONESA notr Implement ipucu uretir", () => { + it("without a description it generates a neutral Implement hint", () => { const files = [tsFileWithMarker("src/users/users.service.ts", SVC, "list(): User[] {", "list")]; const file = emitSurgicalPlan(files, fixtureGraph()); expect(file.content).toContain("Implement: the body of `list`"); }); - it("birden cok marker -> dosya/uye sirasinda deterministik gruplama", () => { + it("multiple markers -> deterministic grouping in file/member order", () => { const files: GeneratedFile[] = [ tsFileWithMarker("src/users/user.repository.ts", "n2", "findByEmail(email: string): Promise {", "findByEmail"), tsFileWithMarker("src/users/users.service.ts", SVC, "create(): void {", "create"), ]; const file = emitSurgicalPlan(files, fixtureGraph()); - // Iki dosya da bolumlenir; sayim 2. + // Both files get sections; the count is 2. expect(file.content).toContain("### `src/users/user.repository.ts`"); expect(file.content).toContain("### `src/users/users.service.ts`"); expect(file.content).toContain("**2** surgical method bodies"); }); - it("marker yoksa: implement edilecek bir sey olmadigini bildirir", () => { + it("without markers: reports there is nothing to implement", () => { const files = [ { path: "src/main.ts", content: "console.log('x');\n", language: "typescript", surgicalMarkers: 0 } as GeneratedFile, ]; @@ -191,17 +191,17 @@ describe("emitSurgicalPlan", () => { expect(file.content).toContain("**0** surgical method bodies"); }); - it("SQL/JSON/markdown dosyalarini taramaz (yalniz typescript)", () => { + it("does not scan SQL/JSON/markdown files (typescript only)", () => { const files: GeneratedFile[] = [ { path: "migrations/001_create_users.sql", content: "-- @solarch:surgical id=x#y\n", language: "sql", surgicalMarkers: 0 }, { path: "package.json", content: "{}\n", language: "json", surgicalMarkers: 0 }, ]; const file = emitSurgicalPlan(files, fixtureGraph()); - // SQL icindeki sahte marker taranmaz -> "nothing to implement". + // The fake marker inside SQL is not scanned -> "nothing to implement". expect(file.content).toContain("No `@solarch:surgical` markers were found"); }); - it("DETERMINISM: ayni girdi -> byte-identical MD", () => { + it("DETERMINISM: same input -> byte-identical MD", () => { const build = () => emitSurgicalPlan( [ diff --git a/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.ts b/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.ts index 2521809..9d6700c 100644 --- a/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.ts +++ b/apps/server/src/codegen/emitters/nestjs/surgical-plan.emitter.ts @@ -2,36 +2,37 @@ import type { GeneratedFile } from "../../types"; import type { CodeGraph } from "../../ir"; /* ──────────────────────────────────────────────────────────────────────── - * surgical-plan.emitter.ts — SURGICAL_PLAN.md (proje KOKUNDE). + * surgical-plan.emitter.ts — SURGICAL_PLAN.md (at the project ROOT). * - * Constructor (deterministik) yalniz ISKELET uretir; metot govdeleri - * "@solarch:surgical" marker'lariyla isaretli BOS algoritma alanlaridir. Bu - * emitter, MONTAJ AFTERSI tum dosyalari tarayip tek bir INGILIZCE Markdown - * dosyasi uretir: bir AI'a OLDUGU GIBI yapistirilabilen bir PROMPT. + * The Constructor (deterministic) only produces a SKELETON; method bodies are + * EMPTY algorithm areas marked with "@solarch:surgical" markers. This emitter + * scans all files AFTER ASSEMBLY and produces a single ENGLISH Markdown file: + * a PROMPT that can be pasted into an AI AS-IS. * - * Iki bolum: - * (1) CODEBASE INTRODUCTION — bu kod Solarch-uretimi NestJS+TS'tir; mimari - * (feature-based modules + CoreModule + shared/) + stack + proje yapisi - * (feature listesi graph'tan) anlatilir. - * (2) SURGICAL IMPLEMENTATION PLAN — uretilen TUM .ts dosyalarindaki - * "// @solarch:surgical ..." marker'lari taranir; her biri icin dosya - * yolu + uzerindeki metot imzasi + throws/deps + "Implement: ..." maddesi - * (feature/dosyaya gore grupli, deterministik sirali) listelenir. + * Two sections: + * (1) CODEBASE INTRODUCTION — explains that this code is Solarch-generated + * NestJS+TS; the architecture (feature-based modules + CoreModule + + * shared/) + stack + project structure (feature list from the graph). + * (2) SURGICAL IMPLEMENTATION PLAN — scans the "// @solarch:surgical ..." + * markers in ALL generated .ts files; for each one, lists the file path + + * the method signature above it + throws/deps + an "Implement: ..." item + * (grouped by feature/file, deterministically ordered). * - * SAF + DETERMINISTIC: yalniz `files` (path'e sirali verilir) + `graph` okunur; - * timestamp/random yok. Ayni graph -> byte-identical MD. MD'NIN KENDISI marker - * TASIMAZ (surgicalMarkers: 0) — yalniz marker'lari BETIMLEYEN duz metindir. + * PURE + DETERMINISTIC: only `files` (given sorted by path) + `graph` are read; + * no timestamps/randomness. Same graph -> byte-identical MD. The MD ITSELF + * carries no markers (surgicalMarkers: 0) — it is plain text that merely + * DESCRIBES the markers. * ──────────────────────────────────────────────────────────────────────── */ -/** Marker satirindan ayristirilan tek bir surgical alan. */ +/** A single surgical site parsed from a marker line. */ interface SurgicalSite { - /** GeneratedFile.path (montaj sonrasi nihai yol, or. "src/users/users.service.ts"). */ + /** GeneratedFile.path (final post-assembly path, e.g. "src/users/users.service.ts"). */ filePath: string; - /** Marker'daki `id=#` -> member (metot/uye adi). */ + /** `id=#` in the marker -> member (method/member name). */ member: string; - /** Marker'in hemen USTUNDEKI kod satiri (metot imzasi), trim'li. */ + /** The code line IMMEDIATELY ABOVE the marker (the method signature), trimmed. */ signature: string; - /** Marker description satirlari (id/throws/deps DISINDA kalan `// ...` satirlari). */ + /** Marker description lines (the `// ...` lines other than id/throws/deps). */ description: string[]; /** `// throws: A, B` -> ["A", "B"]. */ throws: string[]; @@ -45,8 +46,8 @@ const DEPS_RE = /^\s*\/\/\s*deps:\s*(.+)$/; const COMMENT_RE = /^\s*\/\/\s?(.*)$/; /** - * SURGICAL_PLAN.md uretir. `files` montaj sonrasi TUM dosyalardir (path'e gore - * sirali verilmelidir -> tarama deterministik). `graph` feature listesi icin. + * Generates SURGICAL_PLAN.md. `files` is ALL post-assembly files (must be given + * sorted by path -> deterministic scan). `graph` is for the feature list. */ export function emitSurgicalPlan(files: GeneratedFile[], graph: CodeGraph): GeneratedFile { const sites = collectSurgicalSites(files); @@ -57,9 +58,9 @@ export function emitSurgicalPlan(files: GeneratedFile[], graph: CodeGraph): Gene path: "SURGICAL_PLAN.md", content: body.endsWith("\n") ? body : body + "\n", language: "markdown", - // MD marker ICERMEZ; yalniz marker'lari BETIMLER -> surgicalMarkers sayimi - // bozulmasin diye 0. (countSurgicalMarkers KULLANILMAZ: bu metin - // "@solarch:surgical" alt-dizesini icermez; kasitli.) + // The MD contains no markers; it only DESCRIBES them -> 0 so the + // surgicalMarkers count stays intact. (countSurgicalMarkers is NOT used: + // this text does not contain the "@solarch:surgical" substring; intentional.) surgicalMarkers: 0, }; } @@ -142,14 +143,14 @@ function renderPlan(sites: SurgicalSite[]): string { return lines.join("\n"); } - // Dosyaya gore grupla (sites zaten filePath, sonra member sirasinda gelir). + // Group by file (sites already arrive ordered by filePath, then member). const byFile = new Map(); for (const s of sites) { const arr = byFile.get(s.filePath); if (arr) arr.push(s); else byFile.set(s.filePath, [s]); } - // Dosya yollari deterministik sirada (files path'e sirali verilir; yine de garanti). + // File paths in deterministic order (files come sorted by path; guaranteed anyway). const filePaths = [...byFile.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); for (const filePath of filePaths) { @@ -182,29 +183,29 @@ function renderClosing(): string { return lines.join("\n"); } -/* ── Marker taramasi ───────────────────────────────────────────────────────── */ +/* ── Marker scan ───────────────────────────────────────────────────────── */ /** - * Uretilen TUM .ts dosyalarini satir satir tarayip surgical alanlari cikarir. - * Deterministik: dosyalar verilen sirada (path'e sirali), her dosya icinde - * marker satir sirasinda islenir. + * Scans ALL generated .ts files line by line and extracts the surgical sites. + * Deterministic: files in the given order (sorted by path), markers within each + * file processed in line order. */ function collectSurgicalSites(files: GeneratedFile[]): SurgicalSite[] { const sites: SurgicalSite[] = []; for (const f of files) { - // Yalniz TypeScript kaynak dosyalarini tara (SQL/JSON/MD marker tasimaz). + // Only scan TypeScript source files (SQL/JSON/MD carry no markers). if (f.language !== "typescript") continue; const lines = f.content.split("\n"); for (let i = 0; i < lines.length; i++) { const m = MARKER_RE.exec(lines[i]); if (!m) continue; const member = m[2]; - // Imza: marker'in hemen USTUNDEKI kod satiri(lari). Cok-satirli imzalar - // (or. NestJS controller'da @Body() dekoratorlu parametreler) tek satirda - // KAPANIR (`): Promise {`); o yuzden parantezler dengeleninceye dek - // YUKARI dogru satir topla, sonra tek satira birlestir. + // Signature: the code line(s) IMMEDIATELY ABOVE the marker. Multi-line + // signatures (e.g. @Body()-decorated parameters in a NestJS controller) + // CLOSE on a single line (`): Promise {`); so collect lines UPWARD + // until the parentheses balance, then join into a single line. const signature = reconstructSignature(lines, i); - // Marker'in ALTINDAKI description/throws/deps satirlarini topla. + // Collect the description/throws/deps lines BELOW the marker. const description: string[] = []; const throwsList: string[] = []; const depsList: string[] = []; @@ -227,39 +228,39 @@ function collectSurgicalSites(files: GeneratedFile[]): SurgicalSite[] { if (text.length > 0) description.push(text); continue; } - // Ilk yorum-olmayan satir -> marker blogu bitti. + // First non-comment line -> the marker block has ended. break; } sites.push({ filePath: f.path, member, signature, description, throws: throwsList, deps: depsList }); - i = j - 1; // blogu atla (ic dongu tukettigi satirlari tekrar tarama). + i = j - 1; // skip the block (don't rescan the lines the inner loop consumed). } } return sites; } -/* ── Yardimcilar ───────────────────────────────────────────────────────────── */ +/* ── Helpers ───────────────────────────────────────────────────────────── */ /** - * Marker'in (markerIdx) hemen USTUNDEKI metot imzasini dondurur. Tek satirlik - * imzalar oldugu gibi gelir. NestJS controller imzalari gibi COK satirlilar - * marker'in ustunde KAPANIS satiriyla (`): Promise {`) biter; bu yuzden - * parantezler dengeleninceye dek (ya da en cok birkac satir) YUKARI dogru - * satir toplar ve aralarindaki bosluklari sadelestirerek TEK satira birlestirir. - * Deterministik: yalniz verilen satir dizisine bakar. + * Returns the method signature IMMEDIATELY ABOVE the marker (markerIdx). + * Single-line signatures come back as-is. MULTI-line ones, like NestJS + * controller signatures, end above the marker with a CLOSING line + * (`): Promise {`); so this collects lines UPWARD until the parentheses + * balance (or at most a few lines) and joins them into ONE line, collapsing + * the whitespace in between. Deterministic: looks only at the given line array. */ function reconstructSignature(lines: string[], markerIdx: number): string { if (markerIdx <= 0) return ""; const above = lines[markerIdx - 1].trim(); if (above.length === 0) return ""; - // Ust satir zaten dengeli parantezli bir imza ise (tek-satir), oldugu gibi al. + // If the line above is already a balanced-paren signature (single-line), take it as-is. let open = countChar(above, "("); let close = countChar(above, ")"); if (close <= open) return above; - // Kapanis fazla -> acilis ust satirlarda. Dengeleninceye dek yukari topla. + // More closings than openings -> the opening is on the lines above. Collect upward until balanced. const collected: string[] = [above]; - // Guvenlik siniri: cok uzun bir blokta sonsuza yurume (imzalar kisadir). + // Safety bound: don't walk forever in a very long block (signatures are short). const MAX_LINES = 40; for (let k = markerIdx - 2; k >= 0 && collected.length < MAX_LINES; k--) { const t = lines[k].trim(); @@ -268,7 +269,7 @@ function reconstructSignature(lines: string[], markerIdx: number): string { close += countChar(t, ")"); if (open >= close) break; } - // Tek satira birlestir; coklu bosluklari sadelestir. + // Join into a single line; collapse multiple whitespace. return collected.join(" ").replace(/\s+/g, " ").trim(); } @@ -285,7 +286,7 @@ function splitList(raw: string): string[] { .filter((s) => s.length > 0); } -/** Marker'da description NONESA uretilecek notr Ingilizce ipucu. */ +/** Neutral English hint produced when the marker has no description. */ function defaultImplHint(member: string): string { return `the body of \`${member}\` as required by its signature and the architecture.`; } diff --git a/apps/server/src/codegen/emitters/nestjs/table.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/table.emitter.spec.ts index 1cdfbbc..3f357db 100644 --- a/apps/server/src/codegen/emitters/nestjs/table.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/table.emitter.spec.ts @@ -29,7 +29,7 @@ const USER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const ORDER_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const USER_TABLE = { - // TableName fiziksel ad olarak alinir (cogullanmaz) -> dogal cogul yazilir. + // TableName is taken as the physical name (not pluralized) -> written as its natural plural. TableName: "users", Description: "Application user", Columns: [ @@ -70,7 +70,7 @@ const ORDER_TABLE = { }; describe("emitTable (Table -> Postgres migration SQL)", () => { - it("tam tablo (PK + unique + check + index) — snapshot", () => { + it("full table (PK + unique + check + index) — snapshot", () => { const node = tableNode(USER_TABLE, USER_ID); const { ctx } = ctxFor(node); const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); @@ -97,7 +97,7 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { `); }); - it("FK referans cozumu + migration sirasi: referans edilen tablo once gelir", () => { + it("FK reference resolution + migration order: the referenced table comes first", () => { const user = tableNode(USER_TABLE, USER_ID); const order = tableNode(ORDER_TABLE, ORDER_ID); const { ctx } = ctxFor(user, order); @@ -105,11 +105,11 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { const [userFile] = emitTable(ctx.graph.byId(USER_ID)!, ctx); const [orderFile] = emitTable(ctx.graph.byId(ORDER_ID)!, ctx); - // User'a FK ile bagimli olan Order, topolojide sonra (002) gelir. + // Order, which depends on User via FK, comes later in the topology (002). expect(userFile.path).toBe("migrations/001_create_users.sql"); expect(orderFile.path).toBe("migrations/002_create_orders.sql"); - // FK tum tablodan sonra ALTER TABLE ile, hedef tablo "users" cozulmus. + // The FK comes after the whole table via ALTER TABLE, with the target table "users" resolved. expect(orderFile.content).toContain( 'ALTER TABLE "orders" ADD CONSTRAINT "fk_orders_user_id" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION;', ); @@ -126,7 +126,7 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { const node = tableNode( { TableName: "user_roles", - Description: "User-rol eslesmesi", + Description: "User-role mapping", Columns: [ { Name: "UserId", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, { Name: "RoleId", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, @@ -145,7 +145,7 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { expect(file.content).toContain('CONSTRAINT "uq_user_roles_user_id_role_id" UNIQUE ("user_id", "role_id")'); }); - it("GENERATED kolon + tek-kolon UNIQUE kolon dekoratoru", () => { + it("GENERATED column + single-column UNIQUE column decorator", () => { const node = tableNode( { TableName: "Invoice", @@ -179,7 +179,7 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { expect(file.content).toContain('"gross" DECIMAL(10, 2) GENERATED ALWAYS AS (net + tax) STORED'); }); - it("edge-case: kayip FK ref + bos koleksiyonlar -> throw yok, FK ham isimden turer", () => { + it("edge-case: missing FK ref + empty collections -> no throw, the FK derives from the raw name", () => { const node = tableNode( { TableName: "comments", @@ -188,7 +188,7 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { { Name: "Id", DataType: "INT", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: true }, { Name: "PostId", DataType: "INT", IsPrimaryKey: false, IsNotNull: true, IsUnique: false }, ], - // ForeignKeys/UniqueConstraints/CheckConstraints/Indexes BILEREK eksik (Zod default'suz ham node). + // ForeignKeys/UniqueConstraints/CheckConstraints/Indexes DELIBERATELY missing (raw node without Zod defaults). ForeignKeys: [ { Columns: ["PostId"], ReferencesTable: "posts", ReferencesColumns: ["Id"] }, ], @@ -198,17 +198,17 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { const { ctx } = ctxFor(node); expect(() => emitTable(ctx.graph.byId(node.id)!, ctx)).not.toThrow(); const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); - // Hedef "posts" graph'ta yok -> ham isim fiziksel ad sayilir (cogullanmaz), throw yok. + // The target "posts" is not in the graph -> the raw name counts as the physical name (not pluralized), no throw. expect(file.content).toContain( 'ALTER TABLE "comments" ADD CONSTRAINT "fk_comments_post_id" FOREIGN KEY ("post_id") REFERENCES "posts" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION;', ); - // Bos Index/Unique/Check -> ekstra satir yok. + // Empty Index/Unique/Check -> no extra lines. expect(file.content).not.toContain("CREATE INDEX"); expect(file.content).not.toContain("UNIQUE ("); expect(file.content.endsWith(";\n")).toBe(true); }); - it("dil 'sql', yol migrations/ altinda, content ends with single newline", () => { + it("language is 'sql', path under migrations/, content ends with single newline", () => { const node = tableNode(USER_TABLE, USER_ID); const { ctx } = ctxFor(node); const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); @@ -226,19 +226,20 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { expect(a).toBe(b); }); - it("surgicalMarkers SQL'de 0", () => { + it("surgicalMarkers is 0 for SQL", () => { const node = tableNode(ORDER_TABLE, ORDER_ID); const { ctx } = ctxFor(node); const [file] = emitTable(ctx.graph.byId(node.id)!, ctx); expect(file.surgicalMarkers).toBe(0); }); - /* ── ENUM kolonu -> VARCHAR + CHECK (entity ile tutarli) ───────────────── - * #56: entity @Column({type:"enum"}) ama migration TEXT -> tutarsiz. Karar: - * varchar + CHECK. Migration enum kolonunu VARCHAR yapar ve degerleri CHECK ile - * kisitlar (DB-seviyesi dogrulama); native CREATE TYPE NONE (diyagram evrilince - * migration kâbusu olmasin). Degerler EnumRef -> Enum node Values (Value ?? Key). */ - it("ENUM kolonu -> VARCHAR + CHECK (col IN ...), TEXT/CREATE TYPE degil", () => { + /* ── ENUM column -> VARCHAR + CHECK (consistent with the entity) ────────── + * #56: the entity had @Column({type:"enum"}) but the migration used TEXT -> inconsistent. + * Decision: varchar + CHECK. The migration makes the enum column VARCHAR and constrains + * the values with CHECK (DB-level validation); no native CREATE TYPE (to avoid a + * migration nightmare as the diagram evolves). Values come from EnumRef -> the Enum + * node's Values (Value ?? Key). */ + it("ENUM column -> VARCHAR + CHECK (col IN ...), not TEXT/CREATE TYPE", () => { const enumNode: StoredNode = { id: "e0000000-0000-4000-8000-000000000001", type: "Enum", @@ -277,11 +278,11 @@ describe("emitTable (Table -> Postgres migration SQL)", () => { ); const { ctx } = ctxFor(table, enumNode); const [file] = emitTable(ctx.graph.byId(ORDER_ID)!, ctx); - // Kolon VARCHAR (TEXT/native-enum NOT). + // The column is VARCHAR (not TEXT/native enum). expect(file.content).toMatch(/"status" VARCHAR/); expect(file.content).not.toMatch(/"status" TEXT/); expect(file.content).not.toContain("CREATE TYPE"); - // CHECK constraint enum backing degerleriyle (Value ?? Key). + // The CHECK constraint uses the enum backing values (Value ?? Key). expect(file.content).toMatch( /CHECK \("status" IN \('pending', 'confirmed', 'cancelled'\)\)/, ); diff --git a/apps/server/src/codegen/emitters/nestjs/table.emitter.ts b/apps/server/src/codegen/emitters/nestjs/table.emitter.ts index adb3f39..f4ef2d3 100644 --- a/apps/server/src/codegen/emitters/nestjs/table.emitter.ts +++ b/apps/server/src/codegen/emitters/nestjs/table.emitter.ts @@ -6,30 +6,30 @@ import { countSurgicalMarkers } from "../../surgical"; /* ──────────────────────────────────────────────────────────────────────── * table.emitter.ts — TableNode -> Postgres migration SQL (DDL). * - * Sozlesme (enum.emitter.ts kanonik referansi ile birebir tutarli): - * - default export NONE; named `export const emitTable: NodeEmitter`. - * - SAF fonksiyon: (node, ctx) -> GeneratedFile[]. I/O yok, throw yok. - * - Yol her zaman filePathFor(node, ctx.graph) ile (hardcode YASAK). NNN - * migration sirasi graph.migrationIndexOf uzerinden filePathFor icinde cozulur; - * bu nedenle emitter tum Table setini ctx.graph uzerinden gorur. - * - Icerik DETERMINISTIC: koleksiyonlar verilen sirada, timestamp/random yok. - * - Icerik tek "\n" ile biter. - * - surgicalMarkers countSurgicalMarkers(content) ile sayilir (SQL'de 0). + * Contract (exactly consistent with the enum.emitter.ts canonical reference): + * - no default export; named `export const emitTable: NodeEmitter`. + * - PURE function: (node, ctx) -> GeneratedFile[]. No I/O, no throws. + * - Path always via filePathFor(node, ctx.graph) (hardcoding forbidden). The NNN + * migration order is resolved inside filePathFor via graph.migrationIndexOf; + * that is why the emitter sees the whole Table set through ctx.graph. + * - Content is DETERMINISTIC: collections in the given order, no timestamps/randomness. + * - Content ends with a single "\n". + * - surgicalMarkers counted via countSurgicalMarkers(content) (0 in SQL). * * TableNode -> migrations/NNN_create_.sql: * CREATE TABLE
( - * , - * PRIMARY KEY (...), (Column.IsPrimaryKey veya PrimaryKey.Columns) + * , + * PRIMARY KEY (...), (Column.IsPrimaryKey or PrimaryKey.Columns) * CONSTRAINT UNIQUE (...), (UniqueConstraints) * CONSTRAINT CHECK (...) (CheckConstraints) * ); * CREATE [UNIQUE] INDEX ON
[USING ...] (...) [WHERE ...]; (Indexes) * ALTER TABLE
ADD CONSTRAINT FOREIGN KEY (...) REFERENCES ...; (ForeignKeys) * - * FK'lar TUM tablolardan sonra gelmeli (sira sorunu) — codegen.service migration - * dosyalarini NNN'e gore siralar, FK'lar her tablonun kendi dosyasinin sonunda - * ALTER TABLE ile eklenir; referans edilen tablo migration topolojisinde once - * (daha dusuk NNN) gelir, boylece calistirma sirasinda hedef tablo zaten vardir. + * FKs must come after ALL tables (ordering problem) — codegen.service sorts the + * migration files by NNN, FKs are appended via ALTER TABLE at the end of each + * table's own file; the referenced table comes earlier in the migration topology + * (lower NNN), so the target table already exists at execution time. * ──────────────────────────────────────────────────────────────────────── */ type Column = { @@ -72,8 +72,9 @@ type CheckConstraint = { Name?: string; Expression: string }; export const emitTable: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => { const props = propsOf<"Table">(node); - // TableName fiziksel ad olarak alinir (tekrar cogullanmaz). model.emitter ile - // TEK SOURCE: tableSqlName -> entity @Entity adi bu migration adiyla ayni kalir. + // TableName is taken as the physical name (not pluralized again). SINGLE SOURCE + // with model.emitter: tableSqlName -> the entity's @Entity name stays identical + // to this migration's name. const tableName = tableSqlName(node.name); const columns = (props.Columns ?? []) as Column[]; @@ -84,12 +85,12 @@ export const emitTable: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => const blocks: string[] = []; - // Ust aciklama (deterministik). + // Header comment (deterministic). if (props.Description) { blocks.push(`-- ${props.Description}`); } - // ── CREATE TABLE govdesi ────────────────────────────────────────────── + // ── CREATE TABLE body ────────────────────────────────────────────── const inner: string[] = []; for (const col of columns) { @@ -104,7 +105,7 @@ export const emitTable: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => for (const uc of uniques) { const rawCols = uc.Columns ?? []; const cols = rawCols.map((c) => quoteIdent(snakeCase(c))).join(", "); - if (cols.length === 0) continue; // kayip kolon -> satiri atla + if (cols.length === 0) continue; // missing column -> skip the line const name = uc.Name ?? defaultUniqueName(tableName, rawCols); inner.push(` CONSTRAINT ${quoteIdent(name)} UNIQUE (${cols})`); } @@ -115,10 +116,11 @@ export const emitTable: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => inner.push(` CONSTRAINT ${quoteIdent(name)} CHECK (${cc.Expression.trim()})`); } - // ── ENUM kolonlari icin CHECK constraint (#56: varchar + CHECK) ────────── - // Native CREATE TYPE yerine gecerli degerleri CHECK ile kisitla -> entity'nin - // varchar kolonuyla TUTARLI + DB-seviyesi dogrulama. Degerler EnumRef -> Enum - // node'dan (Value ?? Key). Ref cozulemezse CHECK atlanir (kolon yine VARCHAR). + // ── CHECK constraints for ENUM columns (#56: varchar + CHECK) ────────── + // Instead of a native CREATE TYPE, constrain the valid values with CHECK -> + // CONSISTENT with the entity's varchar column + DB-level validation. Values come + // from EnumRef -> the Enum node (Value ?? Key). When the ref does not resolve, + // the CHECK is skipped (the column is still VARCHAR). for (const col of columns) { const line = enumCheckConstraint(col, tableName, ctx); if (line) inner.push(` ${line}`); @@ -128,13 +130,13 @@ export const emitTable: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => `CREATE TABLE ${quoteIdent(tableName)} (\n` + inner.join(",\n") + `\n);`; blocks.push(createTable); - // ── Indeksler (ayri CREATE INDEX) ───────────────────────────────────── + // ── Indexes (separate CREATE INDEX) ───────────────────────────────────── for (const idx of indexes) { const line = renderIndex(idx, tableName); if (line) blocks.push(line); } - // ── Foreign Key'ler (TUM tablolardan sonra; ALTER TABLE) ────────────── + // ── Foreign keys (after ALL tables; ALTER TABLE) ────────────── for (const fk of foreignKeys) { const line = renderForeignKey(fk, tableName, ctx); if (line) blocks.push(line); @@ -151,11 +153,11 @@ export const emitTable: NodeEmitter = (node: CodeNode, ctx): GeneratedFile[] => return [file]; }; -/* ── Kolon uretimi ──────────────────────────────────────────────────────── */ +/* ── Column rendering ──────────────────────────────────────────────────────── */ function renderColumn(col: Column): string { const parts: string[] = [quoteIdent(snakeCase(col.Name)), sqlType(col)]; - // AUTO_INCREMENT zaten SERIAL/BIGSERIAL'a cevrildi (sqlType icinde); DEFAULT eklemeyiz. + // AUTO_INCREMENT was already converted to SERIAL/BIGSERIAL (inside sqlType); we add no DEFAULT. const isSerial = col.AutoIncrement === true; if (col.IsGenerated === true && col.GeneratedExpression && col.GeneratedExpression.trim().length > 0) { @@ -173,7 +175,7 @@ function renderColumn(col: Column): string { return parts.join(" "); } -/** DataType -> Postgres SQL tipi (Length/Precision/Scale + AutoIncrement). */ +/** DataType -> Postgres SQL type (Length/Precision/Scale + AutoIncrement). */ function sqlType(col: Column): string { const dt = (col.DataType ?? "").toUpperCase(); if (col.AutoIncrement === true) { @@ -208,17 +210,18 @@ function sqlType(col: Column): string { case "JSON": return "JSONB"; case "ENUM": - // ENUM kolonu -> VARCHAR (entity de varchar; tutarlilik #56). Gecerli degerler - // ayrica CHECK constraint ile kisitlanir (enumCheckConstraint, emitTable'da). - // Native CREATE TYPE uretilmez (diyagram evrilince ALTER TYPE kâbusu olmasin). + // ENUM column -> VARCHAR (the entity is varchar too; consistency #56). The valid + // values are additionally constrained by a CHECK constraint (enumCheckConstraint, + // in emitTable). No native CREATE TYPE is generated (avoids the ALTER TYPE + // nightmare as the diagram evolves). return "VARCHAR(255)"; default: return dt.length > 0 ? dt : "TEXT"; } } -/** PK kolonlarini cozer: once PrimaryKey.Columns (composite), yoksa - * Column.IsPrimaryKey olan kolonlar (verilen sirada). snake_case'lenir. */ +/** Resolves the PK columns: PrimaryKey.Columns (composite) first, otherwise the + * columns with Column.IsPrimaryKey (in the given order). snake_cased. */ function resolvePrimaryKey(columns: Column[], composite?: string[]): string[] { if (composite && composite.length > 0) { return composite.map((c) => snakeCase(c)); @@ -226,10 +229,10 @@ function resolvePrimaryKey(columns: Column[], composite?: string[]): string[] { return columns.filter((c) => c.IsPrimaryKey === true).map((c) => snakeCase(c.Name)); } -/* ── Indeks uretimi ─────────────────────────────────────────────────────── */ +/* ── Index rendering ─────────────────────────────────────────────────────── */ function renderIndex(idx: Index, tableName: string): string | null { const cols = (idx.Columns ?? []).map((c) => quoteIdent(snakeCase(c))); - if (cols.length === 0) return null; // kayip kolon -> atla + if (cols.length === 0) return null; // missing column -> skip const unique = idx.IsUnique === true ? "UNIQUE " : ""; const using = indexUsing(idx.Type); const name = quoteIdent(idx.IndexName); @@ -239,7 +242,7 @@ function renderIndex(idx: Index, tableName: string): string | null { return `CREATE ${unique}INDEX ${name} ON ${table}${using} (${cols.join(", ")})${where};`; } -/** Indeks tipi -> Postgres USING ifadesi (BTree varsayilan; atlanir). */ +/** Index type -> Postgres USING clause (BTree is the default; omitted). */ function indexUsing(type?: string): string { switch ((type ?? "BTree").toLowerCase()) { case "hash": @@ -254,7 +257,7 @@ function indexUsing(type?: string): string { } } -/* ── Foreign key uretimi (ALTER TABLE; tum tablolardan sonra) ───────────── */ +/* ── Foreign key rendering (ALTER TABLE; after all tables) ───────────── */ function renderForeignKey( fk: ForeignKey, tableName: string, @@ -262,11 +265,11 @@ function renderForeignKey( ): string | null { const cols = (fk.Columns ?? []).map((c) => quoteIdent(snakeCase(c))); const refCols = (fk.ReferencesColumns ?? []).map((c) => quoteIdent(snakeCase(c))); - if (cols.length === 0 || refCols.length === 0) return null; // eksik kolon -> atla + if (cols.length === 0 || refCols.length === 0) return null; // missing column -> skip - // Hedef tablo node'unu coz; bulunamazsa ham isimden turet (THROW ETMEZ). - // Fiziksel ad tek kaynaktan (tableSqlName) — referans edilen tablonun - // CREATE TABLE adiyla birebir ayni (cogullama NONE). + // Resolve the target table node; when not found, derive from the raw name (does + // NOT throw). The physical name comes from a single source (tableSqlName) — + // exactly the same as the referenced table's CREATE TABLE name (no pluralization). const refNode = ctx.graph.resolveRef("Table", fk.ReferencesTable); const refTable = refNode ? tableSqlName(refNode.name) : tableSqlName(fk.ReferencesTable); @@ -282,7 +285,7 @@ function renderForeignKey( ); } -/** FK_ACTION enum -> SQL ifadesi (SET_NULL -> "SET NULL", NO_ACTION -> "NO ACTION"). */ +/** FK_ACTION enum -> SQL clause (SET_NULL -> "SET NULL", NO_ACTION -> "NO ACTION"). */ function fkAction(action?: string): string { switch ((action ?? "NO_ACTION").toUpperCase()) { case "CASCADE": @@ -297,14 +300,15 @@ function fkAction(action?: string): string { } } -/* ── Deterministik varsayilan constraint adlari ─────────────────────────── */ +/* ── Deterministic default constraint names ─────────────────────────── */ function defaultUniqueName(tableName: string, columns: string[]): string { return `uq_${tableName}_${columns.map((c) => snakeCase(c)).join("_")}`; } -/** ENUM kolonu icin CHECK constraint satiri: degerler EnumRef -> Enum node'dan - * (Value ?? Key, enum.emitter ile AYNI backing). Ref cozulemez/deger yoksa null - * (CHECK uretilmez; kolon yine VARCHAR). Degerler SQL-escape edilir (' -> ''). */ +/** The CHECK constraint line for an ENUM column: values come from EnumRef -> the + * Enum node (Value ?? Key, SAME backing as enum.emitter). When the ref does not + * resolve or there are no values -> null (no CHECK generated; the column is still + * VARCHAR). Values are SQL-escaped (' -> ''). */ function enumCheckConstraint(col: Column, tableName: string, ctx: EmitterContext): string | null { if ((col.DataType ?? "").toUpperCase() !== "ENUM" || !col.EnumRef) return null; const enumNode = ctx.graph.resolveRef("Enum", col.EnumRef); @@ -319,7 +323,7 @@ function enumCheckConstraint(col: Column, tableName: string, ctx: EmitterContext } function defaultCheckName(tableName: string, expression: string): string { - // Ifadeden ciplak kimlik turet: harf/rakam disi -> "_", sikistirilir. + // Derive a bare identifier from the expression: non-alphanumeric -> "_", collapsed. const slug = expression .toLowerCase() .replace(/[^a-z0-9]+/g, "_") @@ -332,8 +336,8 @@ function defaultForeignKeyName(tableName: string, columns: string[]): string { return `fk_${tableName}_${columns.map((c) => snakeCase(c)).join("_")}`; } -/* ── SQL kimlik alintilama (deterministik; her zaman cift tirnak) ────────── */ +/* ── SQL identifier quoting (deterministic; always double quotes) ────────── */ function quoteIdent(ident: string): string { - // Postgres kimligi: gomulu cift tirnak ikilenir. + // Postgres identifier: embedded double quotes are doubled. return `"${ident.replace(/"/g, '""')}"`; } diff --git a/apps/server/src/codegen/emitters/nestjs/view.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/view.emitter.spec.ts index d44a4f3..f3140be 100644 --- a/apps/server/src/codegen/emitters/nestjs/view.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/view.emitter.spec.ts @@ -92,7 +92,7 @@ describe("emitView (View -> Postgres CREATE VIEW migration SQL)", () => { `); }); - it("TS @ViewEntity de emit eder (repository View'i tip olarak import edebilsin)", () => { + it("also emits a TS @ViewEntity (so repositories can import the View as a type)", () => { const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); const { ctx } = ctxFor(node); const files = emitView(ctx.graph.byId(node.id)!, ctx); @@ -107,7 +107,7 @@ describe("emitView (View -> Postgres CREATE VIEW migration SQL)", () => { expect(entity.content).toContain("email!: string;"); // VARCHAR → string }); - it("materialized view + RefreshStrategy yorumu", () => { + it("materialized view + RefreshStrategy comment", () => { const node = viewNode( { ViewName: "RevenueDaily", @@ -127,7 +127,7 @@ describe("emitView (View -> Postgres CREATE VIEW migration SQL)", () => { expect(file.content).toContain("GROUP BY 1;"); }); - it("Materialized=false -> RefreshStrategy verilse bile yorum yazilmaz", () => { + it("Materialized=false -> no comment is written even when RefreshStrategy is provided", () => { const node = viewNode( { ...ACTIVE_USERS_VIEW, @@ -141,7 +141,7 @@ describe("emitView (View -> Postgres CREATE VIEW migration SQL)", () => { expect(file.content).toContain('CREATE VIEW "active_users_view" AS'); }); - it("Definition'daki sondaki ; ve fazladan bosluk tekrarlanmaz", () => { + it("the trailing ; and extra whitespace in the Definition are not duplicated", () => { const node = viewNode( { ViewName: "TrimMe", @@ -155,12 +155,12 @@ describe("emitView (View -> Postgres CREATE VIEW migration SQL)", () => { ); const { ctx } = ctxFor(node); const [file] = emitView(ctx.graph.byId(node.id)!, ctx); - // Tek ";" — cift ";;" yok, bas/son bosluk trimildi. + // A single ";" — no double ";;", leading/trailing whitespace trimmed. expect(file.content).toContain("AS\nSELECT 1;\n"); expect(file.content).not.toContain(";;"); }); - it("CRLF satir sonlari LF'e indirgenir", () => { + it("CRLF line endings are normalized to LF", () => { const node = viewNode( { ViewName: "MultiLine", @@ -178,18 +178,18 @@ describe("emitView (View -> Postgres CREATE VIEW migration SQL)", () => { expect(file.content).toContain("SELECT a\nFROM t\nWHERE a > 0;"); }); - it("migration sirasi: View kaynak Table'larindan AFTER gelir (NNN)", () => { - // users + orders Table'lari + ActiveUsersView (SourceTables: ["users"]). + it("migration order: the View comes AFTER its source Tables (NNN)", () => { + // users + orders Tables + ActiveUsersView (SourceTables: ["users"]). const view = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); const users = tableNode(USERS_TABLE, USERS_ID); const orders = tableNode(ORDERS_TABLE, ORDERS_ID); const { ctx } = ctxFor(view, users, orders); const [file] = emitView(ctx.graph.byId(VIEW_ID)!, ctx); - // 2 Table once (001, 002), View sonra (003) — kaynak Table'lardan sonra. + // 2 Tables first (001, 002), the View after (003) — after the source Tables. expect(file.path).toBe("migrations/003_create_active_users_view.sql"); }); - it("dil 'sql', yol migrations/ altinda, content ends with single newline", () => { + it("language is 'sql', path under migrations/, content ends with single newline", () => { const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); const { ctx } = ctxFor(node); const [file] = emitView(ctx.graph.byId(node.id)!, ctx); @@ -199,14 +199,14 @@ describe("emitView (View -> Postgres CREATE VIEW migration SQL)", () => { expect(file.content.endsWith("\n\n")).toBe(false); }); - it("saf SQL -> surgicalMarkers 0", () => { + it("pure SQL -> surgicalMarkers 0", () => { const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); const { ctx } = ctxFor(node); const [file] = emitView(ctx.graph.byId(node.id)!, ctx); expect(file.surgicalMarkers).toBe(0); }); - it("throw yok + DETERMINISM: same node twice -> byte-identical", () => { + it("no throw + DETERMINISM: same node twice -> byte-identical", () => { const node = viewNode(ACTIVE_USERS_VIEW, VIEW_ID); const { ctx } = ctxFor(node); expect(() => emitView(ctx.graph.byId(node.id)!, ctx)).not.toThrow(); diff --git a/apps/server/src/codegen/emitters/nestjs/worker.emitter.spec.ts b/apps/server/src/codegen/emitters/nestjs/worker.emitter.spec.ts index f3bbaee..ba1831a 100644 --- a/apps/server/src/codegen/emitters/nestjs/worker.emitter.spec.ts +++ b/apps/server/src/codegen/emitters/nestjs/worker.emitter.spec.ts @@ -42,7 +42,7 @@ function ctxFrom(nodes: StoredNode[], edges: StoredEdge[]): EmitterContext { return { graph: buildCodeGraph(nodes, edges), target: "nestjs" }; } -/* ── ID'ler ─────────────────────────────────────────────────────────────── */ +/* ── IDs ────────────────────────────────────────────────────────────────── */ const WORKER = "30000000-0000-4000-8000-000000000001"; const SVC = "30000000-0000-4000-8000-000000000002"; const SVC2 = "30000000-0000-4000-8000-000000000003"; @@ -67,8 +67,8 @@ const thumbnailService = node("Service", SVC, { ], }); -// Worker'i bir feature'a dusurmek icin onu CALLS eden bir Controller-Service -// zinciri kuruyoruz: ThumbnailController -> ThumbnailService -> (feature "thumbnail"). +// To place the worker in a feature we build a Controller-Service chain that +// CALLS it: ThumbnailController -> ThumbnailService -> (feature "thumbnail"). const thumbnailController = node("Controller", CTRL, { ControllerName: "ThumbnailController", Description: "Thumbnail endpoints", @@ -87,7 +87,7 @@ const thumbnailWorker = node("Worker", WORKER, { }); describe("emitWorker", () => { - it("tam worker — snapshot (@Cron, DI Service, surgical handler)", () => { + it("full worker — snapshot (@Cron, DI Service, surgical handler)", () => { const ctx = ctxFrom( [thumbnailWorker, thumbnailService, thumbnailController], [ @@ -125,7 +125,7 @@ describe("emitWorker", () => { `); }); - it("dosya yolu ctx.filePathFor ile feature-aware (.worker.ts, rol son-eki tekrarsiz)", () => { + it("file path is feature-aware via ctx.filePathFor (.worker.ts, role suffix not repeated)", () => { const ctx = ctxFrom( [thumbnailWorker, thumbnailService, thumbnailController], [edge("e-ctrl-svc", "CALLS", CTRL, SVC), edge("e-worker-svc", "CALLS", WORKER, SVC)], @@ -134,20 +134,20 @@ describe("emitWorker", () => { expect(file.path).toBe("thumbnail/thumbnail.worker.ts"); }); - it("@nestjs/schedule Cron + @nestjs/common Injectable import edilir", () => { + it("Cron from @nestjs/schedule + Injectable from @nestjs/common are imported", () => { const ctx = ctxFrom([thumbnailWorker], []); const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); expect(file.content).toContain('import { Injectable } from "@nestjs/common";'); expect(file.content).toContain('import { Cron } from "@nestjs/schedule";'); }); - it("@Cron() cron ifadesini kullanir", () => { + it("@Cron() uses the cron expression", () => { const ctx = ctxFrom([thumbnailWorker], []); const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); expect(file.content).toContain('@Cron("0 3 * * *")'); }); - it("Schedule bossa makul default'a duser (her gece yarisi)", () => { + it("falls back to a sensible default when Schedule is empty (every midnight)", () => { const w = node("Worker", WORKER, { WorkerName: "CleanupWorker", Description: "Temizlik", @@ -162,7 +162,7 @@ describe("emitWorker", () => { expect(file.content).toContain('@Cron("0 0 * * *")'); }); - it("CALLS ettigi Service'i DI eder + handler govdesinde erisilebilir (surgical deps)", () => { + it("injects the Service it CALLS + it is accessible in the handler body (surgical deps)", () => { const ctx = ctxFrom( [thumbnailWorker, thumbnailService, thumbnailController], [edge("e-ctrl-svc", "CALLS", CTRL, SVC), edge("e-worker-svc", "CALLS", WORKER, SVC)], @@ -173,7 +173,7 @@ describe("emitWorker", () => { expect(file.content).toContain("// deps: this.thumbnailService"); }); - it("birden cok Service CALLS -> DEDUP + isme gore sirali DI", () => { + it("multiple Service CALLS -> deduplicated + name-sorted DI", () => { const cleanupService = node("Service", SVC2, { ServiceName: "AuditService", Description: "Denetim", @@ -188,22 +188,22 @@ describe("emitWorker", () => { [ edge("e-ctrl-svc", "CALLS", CTRL, SVC), edge("e-worker-svc", "CALLS", WORKER, SVC), - edge("e-worker-svc-dup", "CALLS", WORKER, SVC), // duplicate -> tek alan + edge("e-worker-svc-dup", "CALLS", WORKER, SVC), // duplicate -> single field edge("e-worker-svc2", "CALLS", WORKER, SVC2), ], ); const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); - // DEDUP: thumbnailService tek kez. + // DEDUP: thumbnailService appears once. const occurrences = file.content.split("private readonly thumbnailService").length - 1; expect(occurrences).toBe(1); - // Isme gore sirali: auditService (a) thumbnailService'ten (t) FIRST. + // Sorted by name: auditService (a) comes BEFORE thumbnailService (t). const auditIdx = file.content.indexOf("private readonly auditService"); const thumbIdx = file.content.indexOf("private readonly thumbnailService"); expect(auditIdx).toBeGreaterThan(-1); expect(auditIdx).toBeLessThan(thumbIdx); }); - it("CALLS hedefi Service degilse DI etmez (yalniz Service enjekte edilir)", () => { + it("does not inject a CALLS target that is not a Service (only Services are injected)", () => { const someCache = node("Cache", CACHE, { CacheName: "ThumbnailCache" }); const ctx = ctxFrom( [thumbnailWorker, someCache], @@ -214,7 +214,7 @@ describe("emitWorker", () => { expect(file.content).not.toContain("Cache"); }); - it("handler icin surgical marker + NOT_IMPLEMENTED govdesi", () => { + it("surgical marker + NOT_IMPLEMENTED body for the handler", () => { const ctx = ctxFrom([thumbnailWorker], []); const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); expect(file.surgicalMarkers).toBe(1); @@ -222,11 +222,11 @@ describe("emitWorker", () => { expect(file.content).toContain('throw new Error("NOT_IMPLEMENTED: ThumbnailWorker.handleThumbnail");'); }); - it("DI yoksa constructor uretilmez", () => { + it("no DI -> no constructor is generated", () => { const ctx = ctxFrom([thumbnailWorker], []); const [file] = emitWorker(ctx.graph.byId(WORKER)!, ctx); expect(file.content).not.toContain("constructor("); - // Handler yine de var. + // The handler is still there. expect(file.content).toContain("async handleThumbnail(): Promise {"); }); @@ -247,21 +247,21 @@ describe("emitWorker", () => { expect(a).toBe(b); }); - it("edge-case: kayip/bicimsiz property + kopuk CALLS — throw etmez", () => { + it("edge-case: missing/malformed properties + dangling CALLS — does not throw", () => { const bareWorker = node("Worker", WORKER, { WorkerName: "BareWorker", - // Description/Schedule/TaskToExecute NONE -> savunmaci okuma bos string. + // No Description/Schedule/TaskToExecute -> defensive reads fall back to empty strings. TimeoutSeconds: 30, RetryPolicy: { MaxRetries: 0 }, IsEnabled: true, }); - // CALLS hedefi graph'ta yok (kopuk edge). + // The CALLS target is not in the graph (dangling edge). const ctx = ctxFrom([bareWorker], [edge("e-dangling", "CALLS", WORKER, SVC)]); let file: { content: string; surgicalMarkers: number; path: string } | undefined; expect(() => { file = emitWorker(ctx.graph.byId(WORKER)!, ctx)[0]; }).not.toThrow(); - // Schedule yok -> default; kopuk CALLS -> DI yok. + // No Schedule -> default; dangling CALLS -> no DI. expect(file!.content).toContain('@Cron("0 0 * * *")'); expect(file!.content).not.toContain("constructor("); expect(file!.content).toContain("async handleBare(): Promise {"); diff --git a/apps/server/src/codegen/import-resolver.service.ts b/apps/server/src/codegen/import-resolver.service.ts index a08bfa7..7d0eb60 100644 --- a/apps/server/src/codegen/import-resolver.service.ts +++ b/apps/server/src/codegen/import-resolver.service.ts @@ -25,15 +25,16 @@ const CLI_ENTRY = resolveCliEntry(); * Surgical AI only writes method BODIES (algorithm) and references types BY NAME; * it CANNOT add imports (only body is written). Imports are the SYSTEM's deterministic job. * - * When codegen.generate re-injects saved bodies into fresh skeleton, only BODY is preserved - * so imports drop -> "Cannot find name" (owned entity/DTO/enum/exception + - * typeorm operator). This service writes generated project to temp dir and runs `solarch fix-imports` - * (AI NONE, tsc NONE — pure ts-morph fixMissingImports + owned preference) to wire imports. + * When codegen.generate re-injects saved bodies into a fresh skeleton, only the BODY is + * preserved, so imports drop -> "Cannot find name" (owned entity/DTO/enum/exception + + * typeorm operators). This service writes the generated project to a temp dir and runs + * `solarch fix-imports` (no AI, no tsc — pure ts-morph fixMissingImports with a preference + * for owned symbols) to wire the imports back up. * - * Why subprocess (not in-memory): backend's ts-morph/ast-core dependency is NONETUR - * (intentional isolation, same as codegen-fill). Also typeorm operators (ILike) need node_modules - * -> warm deps cache is symlinked. Best effort: if cache missing/error, files - * return UNCHANGED (generation never blocked). + * Why a subprocess (not in-memory): the backend intentionally has no ts-morph/ast-core + * dependency (same isolation as codegen-fill). Also typeorm operators (ILike) need + * node_modules, so the warm deps cache is symlinked in. Best effort: if the cache is + * missing or errors, files are returned UNCHANGED (generation is never blocked). * ──────────────────────────────────────────────────────────────────────── */ @Injectable() export class ImportResolverService { diff --git a/apps/server/src/codegen/ir.spec.ts b/apps/server/src/codegen/ir.spec.ts index 3f9ed71..084e45b 100644 --- a/apps/server/src/codegen/ir.spec.ts +++ b/apps/server/src/codegen/ir.spec.ts @@ -39,8 +39,8 @@ function edge(kind: EdgeKind, source: StoredNode, target: StoredNode): StoredEdg }; } -describe("buildCodeGraph — indeksler", () => { - it("byId / byName / allOf / resolveRef cozer", () => { +describe("buildCodeGraph — indexes", () => { + it("resolves byId / byName / allOf / resolveRef", () => { const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); const repo = node("Repository", { RepositoryName: "UsersRepository", Description: "x", EntityReference: "User", CustomQueries: [] }); const g = buildCodeGraph([svc, repo], []); @@ -52,7 +52,7 @@ describe("buildCodeGraph — indeksler", () => { expect(g.resolveRef(["Service", "Repository"], "UsersRepository")?.id).toBe(repo.id); }); - it("kayip ref -> null (THROW ETMEZ)", () => { + it("missing ref -> null (does NOT throw)", () => { const g = buildCodeGraph([], []); expect(g.byId("nope")).toBeNull(); expect(g.byName("Service", "Ghost")).toBeNull(); @@ -62,7 +62,7 @@ describe("buildCodeGraph — indeksler", () => { expect(g.allOf("Table")).toEqual([]); }); - it("outEdges / inEdges kind filtresi", () => { + it("outEdges / inEdges kind filter", () => { const ctrl = node("Controller", { ControllerName: "UsersController", Description: "x", BaseRoute: "/users", Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }] }); const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); const calls = edge("CALLS", ctrl, svc); @@ -73,7 +73,7 @@ describe("buildCodeGraph — indeksler", () => { expect(g.inEdges(svc.id, "CALLS")[0].id).toBe(calls.id); }); - it("propsOf tipli erisim", () => { + it("propsOf typed access", () => { const t = node("Table", { TableName: "users", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); const g = buildCodeGraph([t], []); const props = propsOf<"Table">(g.byId(t.id)!); @@ -82,15 +82,15 @@ describe("buildCodeGraph — indeksler", () => { }); }); -describe("moduleOf heuristigi", () => { - it("Service -> ExposedServices iceren Module", () => { +describe("the moduleOf heuristic", () => { + it("Service -> the Module whose ExposedServices contains it", () => { const mod = node("Module", { ModuleName: "UsersModule", Description: "x", StrictBoundaries: false, ExposedServices: ["UsersService"], Dependencies: [] }); const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); const g = buildCodeGraph([mod, svc], []); expect(g.moduleOf(g.byId(svc.id)!)?.id).toBe(mod.id); }); - it("Controller -> CALLS ettigi Service'in modulu", () => { + it("Controller -> the module of the Service it CALLS", () => { const mod = node("Module", { ModuleName: "UsersModule", Description: "x", StrictBoundaries: false, ExposedServices: ["UsersService"], Dependencies: [] }); const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); const ctrl = node("Controller", { ControllerName: "UsersController", Description: "x", BaseRoute: "/users", Endpoints: [{ HttpMethod: "GET", Route: "/", RequiresAuth: false }] }); @@ -98,38 +98,39 @@ describe("moduleOf heuristigi", () => { expect(g.moduleOf(g.byId(ctrl.id)!)?.id).toBe(mod.id); }); - it("Module bulunamazsa null", () => { + it("null when no Module is found", () => { const svc = node("Service", { ServiceName: "OrphanService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); const g = buildCodeGraph([svc], []); expect(g.moduleOf(g.byId(svc.id)!)).toBeNull(); }); - it("Repository -> acik Service bagi yokken EntityReference'in Model'inin modulune duser", () => { - // Repo'yu Dependencies/CALLS ile baglayan Service NONE; yalniz EntityReference. + it("Repository -> without an explicit Service link, falls to the module of its EntityReference's Model", () => { + // No Service links the repo via Dependencies/CALLS; only the EntityReference. const mod = node("Module", { ModuleName: "UsersModule", Description: "x", StrictBoundaries: false, ExposedServices: ["UsersService"], Dependencies: [] }); const svc = node("Service", { ServiceName: "UsersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); const model = node("Model", { ClassName: "User", Description: "x", TableRef: "users", Properties: [{ Name: "id", Type: "uuid" }], Methods: [] }); const repo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "User", CustomQueries: [] }); - // Model'i module baglamak icin Service uzerinden degil; domain-sharing fallback (c) - // ile UsersService'in modulune (UsersModule) dusmeli ("user" stem == "users" stem). + // The Model isn't tied to a module through a Service; via the domain-sharing + // fallback (c) it should fall to UsersService's module (UsersModule) + // ("user" stem == "users" stem). const g = buildCodeGraph([mod, svc, model, repo], []); expect(g.moduleOf(g.byId(repo.id)!)?.id).toBe(mod.id); }); - it("Repository -> hic bag yokken domain-paylasan Service'in modulune duser", () => { + it("Repository -> with no link at all, falls to the module of the domain-sharing Service", () => { const mod = node("Module", { ModuleName: "OrdersModule", Description: "x", StrictBoundaries: false, ExposedServices: ["OrdersService"], Dependencies: [] }); const svc = node("Service", { ServiceName: "OrdersService", Description: "x", IsTransactionScoped: false, Methods: [{ MethodName: "m", ReturnType: "void" }], Dependencies: [] }); - // EntityReference cozulmez (Model yok) -> yalniz domain stem fallback kalir. + // The EntityReference doesn't resolve (no Model) -> only the domain-stem fallback remains. const repo = node("Repository", { RepositoryName: "OrderRepository", Description: "x", EntityReference: "Order", CustomQueries: [] }); const g = buildCodeGraph([mod, svc, repo], []); expect(g.moduleOf(g.byId(repo.id)!)?.id).toBe(mod.id); }); }); -describe("karsilikli feature import'u (circular module) DETERMINISTIC kirilir", () => { - /** auth <-> image karsilikli CALLS: iki feature birbirini import etmek ister. - * Beklenen: kucuk slug (auth) image'i import etmeye DEVAM eder; buyuk slug - * (image) auth'a dogru geri-kenarini DUSURUR -> boot'ta dongu yok. */ +describe("mutual feature imports (circular modules) are broken DETERMINISTICALLY", () => { + /** auth <-> image mutual CALLS: the two features want to import each other. + * Expected: the smaller slug (auth) KEEPS importing image; the larger slug + * (image) DROPS its back-edge towards auth -> no cycle at boot. */ function mutualFixture() { const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); @@ -139,33 +140,33 @@ describe("karsilikli feature import'u (circular module) DETERMINISTIC kirilir", edge("CALLS", authCtrl, authSvc), edge("CALLS", imageCtrl, imageSvc), edge("CALLS", imageSvc, authSvc), // image -> auth - edge("CALLS", authSvc, imageSvc), // auth -> image (karsilikli) + edge("CALLS", authSvc, imageSvc), // auth -> image (mutual) ]; return buildCodeGraph([authCtrl, authSvc, imageCtrl, imageSvc], edges); } - it("geri-kenar forwardRef ile isaretlenir: kenar KORUNUR, lazy emit edilir", () => { + it("the back-edge is marked with forwardRef: the edge is KEPT, emitted lazily", () => { const g = mutualFixture(); const auth = g.features().find((f) => f.slug === "auth")!; const image = g.features().find((f) => f.slug === "image")!; - // Iki yon de dependsOn'da KALIR (kenar SILINMEZ → provider import'u kaybolmaz); - // (to, from) en kucuk geri-kenar = image->auth → image.forwardRefDeps=["auth"]. + // Both directions STAY in dependsOn (the edge is NOT deleted → the provider import + // is not lost); the smallest (to, from) back-edge = image->auth → image.forwardRefDeps=["auth"]. expect(auth.dependsOn).toContain("image"); expect(image.dependsOn).toContain("auth"); expect(image.forwardRefDeps).toContain("auth"); expect(auth.forwardRefDeps).not.toContain("image"); }); - it("export'lar KORUNUR (DI bozulmaz): iki yon de export eder", () => { + it("exports are PRESERVED (DI stays intact): both directions export", () => { const g = mutualFixture(); const auth = g.features().find((f) => f.slug === "auth")!; const image = g.features().find((f) => f.slug === "image")!; - // Iki servis de cross-feature inject hedefi -> export edilir (import kirilsa da). + // Both services are cross-feature injection targets -> exported (even where the import is broken). expect(auth.exports.map((e) => e.name)).toContain("AuthService"); expect(image.exports.map((e) => e.name)).toContain("ImageService"); }); - it("uyari uretilir (forwardRef ile kirildi)", () => { + it("a warning is produced (broken with forwardRef)", () => { const g = mutualFixture(); expect(g.warnings()).toHaveLength(1); const w = g.warnings()[0]; @@ -174,8 +175,8 @@ describe("karsilikli feature import'u (circular module) DETERMINISTIC kirilir", expect(w).toContain("forwardRef"); }); - it("dongu yoksa uyari yok + dependsOn degismez", () => { - // image -> auth tek yonlu (karsilik yok). + it("no warning without a cycle + dependsOn is unchanged", () => { + // image -> auth is one-way (no reciprocal edge). const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); const imageCtrl = node("Controller", { ControllerName: "ImageController", Description: "x", BaseRoute: "image", Endpoints: [] }); @@ -189,7 +190,7 @@ describe("karsilikli feature import'u (circular module) DETERMINISTIC kirilir", expect(image.dependsOn).toEqual(["auth"]); }); - it("DETERMINISM: warnings + dependsOn iki kez ayni (cache)", () => { + it("DETERMINISM: warnings + dependsOn identical across two calls (cached)", () => { const g = mutualFixture(); expect(g.warnings()).toEqual(g.warnings()); expect(g.features().find((f) => f.slug === "image")!.dependsOn).toEqual( @@ -198,11 +199,11 @@ describe("karsilikli feature import'u (circular module) DETERMINISTIC kirilir", }); }); -describe("N-CYCLE (3'lu+) module import'u DETERMINISTIC kirilir (Bug 1 regresyon)", () => { - /** auth -> chat -> messaging -> auth UCLU dongu (cross-feature CALLS). Eski - * breakCircularImports yalniz IKILI ciftleri tariyordu → hicbir cift mutual - * olmadigi icin dongu KACIYORDU ve NestJS boot'ta UndefinedModuleException - * veriyordu. Tarjan SCC ucluyu yakalar; bir geri-kenar forwardRef olur. */ +describe("N-CYCLE (3+) module imports are broken DETERMINISTICALLY (Bug 1 regression)", () => { + /** auth -> chat -> messaging -> auth is a THREE-WAY cycle (cross-feature CALLS). + * The old breakCircularImports only scanned PAIRS → since no pair was mutual the + * cycle ESCAPED and NestJS threw UndefinedModuleException at boot. Tarjan SCC + * catches the triple; one back-edge becomes forwardRef. */ function threeCycleFixture() { const authCtrl = node("Controller", { ControllerName: "AuthController", Description: "x", BaseRoute: "auth", Endpoints: [] }); const authSvc = node("Service", { ServiceName: "AuthService", Description: "x", Dependencies: [], Methods: [] }); @@ -216,14 +217,14 @@ describe("N-CYCLE (3'lu+) module import'u DETERMINISTIC kirilir (Bug 1 regresyon edge("CALLS", msgCtrl, msgSvc), edge("CALLS", authSvc, chatSvc), // auth -> chat edge("CALLS", chatSvc, msgSvc), // chat -> messaging - edge("CALLS", msgSvc, authSvc), // messaging -> auth (donguyu kapatir) + edge("CALLS", msgSvc, authSvc), // messaging -> auth (closes the cycle) ]; return buildCodeGraph([authCtrl, authSvc, chatCtrl, chatSvc, msgCtrl, msgSvc], edges); } - it("uclu dongu YAKALANIR: tam bir geri-kenar forwardRef olur (eskiden 0 uyari)", () => { + it("the three-way cycle IS CAUGHT: exactly one back-edge becomes forwardRef (previously 0 warnings)", () => { const g = threeCycleFixture(); - // (to, from) en kucuk geri-kenar = messaging->auth (to="auth") → forwardRef. + // The smallest (to, from) back-edge = messaging->auth (to="auth") → forwardRef. const auth = g.features().find((f) => f.slug === "auth")!; const chat = g.features().find((f) => f.slug === "chat")!; const messaging = g.features().find((f) => f.slug === "messaging")!; @@ -232,14 +233,14 @@ describe("N-CYCLE (3'lu+) module import'u DETERMINISTIC kirilir (Bug 1 regresyon expect(chat.forwardRefDeps).toEqual([]); }); - it("kenarlar KORUNUR (provider import'u kaybolmaz): dependsOn degismez", () => { + it("edges are KEPT (no provider import is lost): dependsOn is unchanged", () => { const g = threeCycleFixture(); expect(g.features().find((f) => f.slug === "auth")!.dependsOn).toEqual(["chat"]); expect(g.features().find((f) => f.slug === "chat")!.dependsOn).toEqual(["messaging"]); expect(g.features().find((f) => f.slug === "messaging")!.dependsOn).toEqual(["auth"]); }); - it("tek uyari uretilir (forwardRef ile kirildi)", () => { + it("a single warning is produced (broken with forwardRef)", () => { const g = threeCycleFixture(); expect(g.warnings()).toHaveLength(1); const w = g.warnings()[0]; @@ -248,24 +249,24 @@ describe("N-CYCLE (3'lu+) module import'u DETERMINISTIC kirilir (Bug 1 regresyon expect(w).toContain("forwardRef"); }); - it("kirilan grafik DAG'dir: kalan eager kenarlar arasinda dongu kalmaz", () => { + it("the broken graph is a DAG: no cycle remains among the eager edges", () => { const g = threeCycleFixture(); - // forwardRef kenari cikarinca: auth->chat, chat->messaging = DAG (messaging->auth lazy). + // With the forwardRef edge removed: auth->chat, chat->messaging = DAG (messaging->auth is lazy). const features = g.features(); const eager = new Map(features.map((f) => [f.slug, f.dependsOn.filter((d) => !f.forwardRefDeps.includes(d))])); - expect(eager.get("messaging")).toEqual([]); // tek eager-disi kenar buradaydi + expect(eager.get("messaging")).toEqual([]); // the only non-eager edge was here expect(eager.get("auth")).toEqual(["chat"]); expect(eager.get("chat")).toEqual(["messaging"]); }); }); describe("#4 cross-feature Repository DI (domain co-location)", () => { - /** order feature'i (OrderController->OrderService) ile payment feature'i - * (PaymentService->PaymentRepository). OrderService CROSS-FEATURE olarak - * PaymentRepository'yi de CALLS eder. Beklenen: PaymentRepository PAYMENT - * feature'inda durur (domain-stem "payment" PaymentService ile co-locate), - * order'a KAYMAZ. Boylece PaymentModule onu provider/export eder ve - * PaymentService bootta cozebilir; OrderModule da PaymentModule'u import eder. */ + /** An order feature (OrderController->OrderService) and a payment feature + * (PaymentService->PaymentRepository). OrderService also CALLS + * PaymentRepository CROSS-FEATURE. Expected: PaymentRepository stays in the + * PAYMENT feature (domain stem "payment" co-locates with PaymentService), it + * does NOT drift to order. That way PaymentModule provides/exports it and + * PaymentService can resolve it at boot; OrderModule imports PaymentModule. */ function crossFeatureFixture() { const orderCtrl = node("Controller", { ControllerName: "OrderController", Description: "x", BaseRoute: "orders", Endpoints: [] }); const orderSvc = node("Service", { ServiceName: "OrderService", Description: "x", Dependencies: [], Methods: [] }); @@ -284,74 +285,74 @@ describe("#4 cross-feature Repository DI (domain co-location)", () => { return { g, paymentRepo, orderSvc }; } - it("PaymentRepository PAYMENT feature'inda durur (order'a kaymaz)", () => { + it("PaymentRepository stays in the PAYMENT feature (does not drift to order)", () => { const { g, paymentRepo } = crossFeatureFixture(); - // Domain co-location: PaymentRepository, PaymentService ile ayni feature. + // Domain co-location: PaymentRepository shares a feature with PaymentService. expect(g.featureOf(g.byId(paymentRepo.id)!)).toBe("payment"); }); - it("PaymentModule PaymentRepository'yi provider + export eder (cross-feature inject hedefi)", () => { + it("PaymentModule provides + exports PaymentRepository (a cross-feature injection target)", () => { const { g, paymentRepo } = crossFeatureFixture(); const payment = g.features().find((f) => f.slug === "payment")!; - // Provider: kendi feature'ina ait repository. + // Provider: the repository belongs to its own feature. expect(payment.repositories.map((r) => r.name)).toContain("PaymentRepository"); - // Export: OrderService (order) onu cross-feature CALLS eder -> export ZORUNLU. + // Export: OrderService (order) CALLS it cross-feature -> the export is MANDATORY. expect(payment.exports.map((e) => e.name)).toContain("PaymentRepository"); void paymentRepo; }); - it("OrderModule payment feature'ini import eder (dependsOn payment)", () => { + it("OrderModule imports the payment feature (dependsOn payment)", () => { const { g } = crossFeatureFixture(); const order = g.features().find((f) => f.slug === "order")!; expect(order.dependsOn).toContain("payment"); - // OrderModule PaymentRepository'yi KENDI provider'i olarak tasimaz (payment'in). + // OrderModule does not carry PaymentRepository as its OWN provider (it's payment's). expect(order.repositories.map((r) => r.name)).not.toContain("PaymentRepository"); }); - it("DETERMINISM: feature atamasi iki kez ayni", () => { + it("DETERMINISM: the feature assignment is identical across two calls", () => { const { g, paymentRepo } = crossFeatureFixture(); expect(g.featureOf(g.byId(paymentRepo.id)!)).toBe(g.featureOf(g.byId(paymentRepo.id)!)); }); }); -describe("cross-feature Repository PROPERTY-dep (EDGE NONE) wiring (Bug 2 regresyon)", () => { - /** token feature'i (TokenController->TokenService) UserRepository'yi YALNIZ - * Service.Dependencies property'si ile enjekte eder — graf'ta CALLS EDGE NONE. - * Eski derivasyon property-dep'te Service/Repository'yi ATLIYORDU (yalniz edge - * taraniyordu) → TokenModule, UserModule'u import etmiyor + UserModule export - * etmiyordu → boot'ta "Nest can't resolve UserRepository" (UnknownDependencies). - * Beklenen: token.dependsOn ⊇ user (import) VE user.exports ⊇ UserRepository. */ +describe("cross-feature Repository PROPERTY-dep (no edge) wiring (Bug 2 regression)", () => { + /** The token feature (TokenController->TokenService) injects UserRepository ONLY + * through the Service.Dependencies property — there is NO CALLS edge in the graph. + * The old derivation SKIPPED Service/Repository property-deps (only edges were + * scanned) → TokenModule didn't import UserModule + UserModule didn't export + * → "Nest can't resolve UserRepository" at boot (UnknownDependencies). + * Expected: token.dependsOn ⊇ user (import) AND user.exports ⊇ UserRepository. */ function propDepFixture() { const userCtrl = node("Controller", { ControllerName: "UserController", Description: "x", BaseRoute: "users", Endpoints: [] }); const userSvc = node("Service", { ServiceName: "UserService", Description: "x", Dependencies: [], Methods: [] }); const userRepo = node("Repository", { RepositoryName: "UserRepository", Description: "x", EntityReference: "users", CustomQueries: [] }); const tokenCtrl = node("Controller", { ControllerName: "TokenController", Description: "x", BaseRoute: "tokens", Endpoints: [] }); - // TokenService UserRepository'yi PROPERTY ile enjekte eder — CALLS edge NONE. + // TokenService injects UserRepository via the PROPERTY — no CALLS edge. const tokenSvc = node("Service", { ServiceName: "TokenService", Description: "x", Dependencies: [{ Kind: "Repository", Ref: "UserRepository" }], Methods: [] }); const edges = [ edge("CALLS", userCtrl, userSvc), edge("CALLS", userSvc, userRepo), edge("CALLS", tokenCtrl, tokenSvc), - // DIKKAT: tokenSvc -> userRepo CALLS edge'i BILEREK NONE (yalniz property-dep). + // NOTE: the tokenSvc -> userRepo CALLS edge is DELIBERATELY absent (property-dep only). ]; return buildCodeGraph([userCtrl, userSvc, userRepo, tokenCtrl, tokenSvc], edges); } - it("UserRepository USER feature'inda durur", () => { + it("UserRepository stays in the USER feature", () => { const g = propDepFixture(); const userRepo = g.byName("Repository", "UserRepository")!; expect(g.featureOf(userRepo)).toBe("user"); }); - it("TokenModule UserModule'u import eder (dependsOn ⊇ user) — property-dep'ten", () => { + it("TokenModule imports UserModule (dependsOn ⊇ user) — from the property-dep", () => { const g = propDepFixture(); const token = g.features().find((f) => f.slug === "token")!; expect(token.dependsOn).toContain("user"); - // TokenModule UserRepository'yi KENDI provider'i olarak TASIMAZ (user'in). + // TokenModule does NOT carry UserRepository as its OWN provider (it's user's). expect(token.repositories.map((r) => r.name)).not.toContain("UserRepository"); }); - it("UserModule UserRepository'yi EXPORT eder (cross-feature property-inject hedefi)", () => { + it("UserModule EXPORTS UserRepository (a cross-feature property-injection target)", () => { const g = propDepFixture(); const user = g.features().find((f) => f.slug === "user")!; expect(user.repositories.map((r) => r.name)).toContain("UserRepository"); @@ -359,13 +360,13 @@ describe("cross-feature Repository PROPERTY-dep (EDGE NONE) wiring (Bug 2 regres }); }); -describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => { - /** payment (PaymentService) + order (OrderService) IKISI de PaymentGateway - * (ExternalService) enjekte eder; order ayrica payment'i CALLS eder. Eski - * davranis: PaymentGateway hem payment hem order'in infraProviders'ina girer -> - * iki module'de provider -> iki ornek (singleton kirik). Beklenen: PaymentGateway - * TEK feature'da (payment) provider+export; order onu KENDI provider'i olarak - * TASIMAZ, PaymentModule'u import eder (dependsOn=payment); "common"a dusmez. */ +describe("#7 cross-feature infra provider has ONE OWNER (the singleton is preserved)", () => { + /** payment (PaymentService) + order (OrderService) BOTH inject PaymentGateway + * (ExternalService); order also CALLS payment. Old behavior: PaymentGateway + * entered both payment's and order's infraProviders -> a provider in two modules + * -> two instances (broken singleton). Expected: PaymentGateway is provided + + * exported by ONE feature (payment); order does NOT carry it as its OWN provider, + * it imports PaymentModule (dependsOn=payment); it does not fall to "common". */ function doubleInjectFixture() { const payCtrl = node("Controller", { ControllerName: "PaymentController", Description: "x", BaseRoute: "payment", Endpoints: [] }); const paySvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); @@ -383,7 +384,7 @@ describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => return { g, gw }; } - it("PaymentGateway YALNIZ payment feature'inin provider'i (order'da NONE)", () => { + it("PaymentGateway is a provider of the payment feature ONLY (not of order)", () => { const { g } = doubleInjectFixture(); const payment = g.features().find((f) => f.slug === "payment")!; const order = g.features().find((f) => f.slug === "order")!; @@ -391,7 +392,7 @@ describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => expect(order.infraProviders.map((n) => n.name)).not.toContain("PaymentGateway"); }); - it("sahip feature (payment) PaymentGateway'i EXPORT eder; order import eder (dependsOn)", () => { + it("the owner feature (payment) EXPORTS PaymentGateway; order imports it (dependsOn)", () => { const { g } = doubleInjectFixture(); const payment = g.features().find((f) => f.slug === "payment")!; const order = g.features().find((f) => f.slug === "order")!; @@ -400,28 +401,28 @@ describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => expect(order.dependsOn).toContain("payment"); }); - it("sahip secimi DONGUSUZ: payment secilir (order zaten payment'a bagimli) + uyari yok", () => { + it("the owner choice is CYCLE-FREE: payment is chosen (order already depends on payment) + no warning", () => { const { g, gw } = doubleInjectFixture(); - // order->payment service-call'u zaten var -> in-degree(payment)=1 > order=0 -> payment sahip. + // The order->payment service-call already exists -> in-degree(payment)=1 > order=0 -> payment owns it. expect(g.featureOf(g.byId(gw.id)!)).toBe("payment"); - // Sahip secimi yeni geri-kenar (payment->order) yaratmadi -> dongu kirma uyarisi yok. + // The owner choice created no new back-edge (payment->order) -> no cycle-breaking warning. expect(g.warnings()).toHaveLength(0); }); - it("PaymentGateway 'common'a DUSMEZ (CommonModule onu tekrar yazmaz)", () => { + it("PaymentGateway does NOT fall to 'common' (CommonModule does not re-provide it)", () => { const { g } = doubleInjectFixture(); const common = g.commonFeature(); const commonInfra = common?.infraProviders.map((n) => n.name) ?? []; expect(commonInfra).not.toContain("PaymentGateway"); }); - it("DETERMINISM: sahip atamasi iki kez ayni", () => { + it("DETERMINISM: the owner assignment is identical across two calls", () => { const { g, gw } = doubleInjectFixture(); expect(g.featureOf(g.byId(gw.id)!)).toBe(g.featureOf(g.byId(gw.id)!)); }); - it("simetrik enjekte (injectorlar arasi service-call NONE): isimce ilk slug sahip, dongu yok", () => { - // billing + report IKISI de RateCache enjekte eder, aralarinda cagri NONE. + it("symmetric injection (no service-call between the injectors): the alphabetically first slug owns it, no cycle", () => { + // billing + report BOTH inject RateCache, with no call between them. const billCtrl = node("Controller", { ControllerName: "BillingController", Description: "x", BaseRoute: "billing", Endpoints: [] }); const billSvc = node("Service", { ServiceName: "BillingService", Description: "x", Dependencies: [{ Kind: "Cache", Ref: "RateCache" }], Methods: [] }); const repCtrl = node("Controller", { ControllerName: "ReportController", Description: "x", BaseRoute: "report", Endpoints: [] }); @@ -431,7 +432,7 @@ describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => [billCtrl, billSvc, repCtrl, repSvc, cache], [edge("CALLS", billCtrl, billSvc), edge("CALLS", repCtrl, repSvc), edge("CACHES_IN", billSvc, cache), edge("CACHES_IN", repSvc, cache)], ); - // Esit in-degree (0/0) -> isimce ilk = billing sahip. + // Equal in-degree (0/0) -> alphabetically first = billing owns it. expect(g.featureOf(g.byId(cache.id)!)).toBe("billing"); const billing = g.features().find((f) => f.slug === "billing")!; const report = g.features().find((f) => f.slug === "report")!; @@ -441,8 +442,8 @@ describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => expect(g.warnings()).toHaveLength(0); }); - it("tek feature enjekte ediyorsa SAHIPLIK KURALI DEVREYE GIRMEZ (eski davranis)", () => { - // Yalniz payment PaymentGateway enjekte eder -> dogrudan payment'in infraProviders'i. + it("when a single feature injects, the OWNERSHIP RULE does not kick in (old behavior)", () => { + // Only payment injects PaymentGateway -> directly payment's infraProviders. const payCtrl = node("Controller", { ControllerName: "PaymentController", Description: "x", BaseRoute: "payment", Endpoints: [] }); const paySvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); const gw = node("ExternalService", { ServiceName: "PaymentGateway", Description: "x", BaseURL: "https://pg.example.com", AuthType: "None", TimeoutSeconds: 10, Endpoints: [] }); @@ -452,17 +453,18 @@ describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => expect(g.featureOf(g.byId(gw.id)!)).toBe("payment"); }); - /** REGRESYON (b4f3 GERCEGI): order, PaymentGateway'i YALNIZ property-Dependency - * ile enjekte eder (REQUESTS EDGE NONE); payment ise edge ile enjekte eder. Eski - * computeInfraOwners SADECE inject-edge'lere bakiyordu -> order injector SAYILMAZ, - * injectorSlugs.size=1 -> tek-sahip kurali atlanir -> PaymentGateway hem order hem - * payment module'une provider olur (singleton kirik). Duzeltme: computeInfraOwners - * artik property-Dependency'leri de sayar -> injector=2 -> tek sahip (payment). */ - it("property-Dependency ile enjekte (EDGE NONE) de tek-sahip kuralini tetikler", () => { + /** REGRESSION (the b4f3 real-world case): order injects PaymentGateway ONLY via a + * property-Dependency (no REQUESTS edge); payment injects it via an edge. The old + * computeInfraOwners ONLY looked at inject-edges -> order was not counted as an + * injector, injectorSlugs.size=1 -> the single-owner rule was skipped -> + * PaymentGateway became a provider in both the order and payment modules (broken + * singleton). Fix: computeInfraOwners now counts property-Dependencies too -> + * injectors=2 -> a single owner (payment). */ + it("injection via a property-Dependency (no edge) also triggers the single-owner rule", () => { const payCtrl = node("Controller", { ControllerName: "PaymentController", Description: "x", BaseRoute: "payment", Endpoints: [] }); const paySvc = node("Service", { ServiceName: "PaymentService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); const orderCtrl = node("Controller", { ControllerName: "OrderController", Description: "x", BaseRoute: "order", Endpoints: [] }); - // order: PaymentGateway SADECE property-dep (REQUESTS edge NONE) + order->payment service-call. + // order: PaymentGateway ONLY as a property-dep (no REQUESTS edge) + an order->payment service-call. const orderSvc = node("Service", { ServiceName: "OrderService", Description: "x", Dependencies: [{ Kind: "ExternalService", Ref: "PaymentGateway" }], Methods: [] }); const gw = node("ExternalService", { ServiceName: "PaymentGateway", Description: "x", BaseURL: "https://pg.example.com", AuthType: "None", TimeoutSeconds: 10, Endpoints: [] }); const g = buildCodeGraph( @@ -470,13 +472,13 @@ describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => [ edge("CALLS", payCtrl, paySvc), edge("CALLS", orderCtrl, orderSvc), - edge("REQUESTS", paySvc, gw), // YALNIZ payment'in edge'i var + edge("REQUESTS", paySvc, gw), // ONLY payment has the edge edge("CALLS", orderSvc, paySvc), // order -> payment (service-call) ], ); const payment = g.features().find((f) => f.slug === "payment")!; const order = g.features().find((f) => f.slug === "order")!; - // PaymentGateway TEK feature'da (payment) provider+export; order onu TASIMAZ. + // PaymentGateway is provided+exported by ONE feature (payment); order does NOT carry it. expect(payment.infraProviders.map((n) => n.name)).toContain("PaymentGateway"); expect(order.infraProviders.map((n) => n.name)).not.toContain("PaymentGateway"); expect(payment.exports.map((e) => e.name)).toContain("PaymentGateway"); @@ -486,8 +488,8 @@ describe("#7 cross-feature infra provider TEK SAHIP (singleton korunur)", () => }); }); -describe("migration sirasi (FK topolojisi + isim)", () => { - it("referans verilen tablo once gelir", () => { +describe("migration order (FK topology + name)", () => { + it("the referenced table comes first", () => { const users = node("Table", { TableName: "users", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); const orders = node("Table", { TableName: "orders", Description: "x", Columns: [{ Name: "user_id", DataType: "UUID", IsPrimaryKey: false, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [{ Columns: ["user_id"], ReferencesTable: "users", ReferencesColumns: ["id"], OnDelete: "CASCADE", OnUpdate: "NO_ACTION" }], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); const g = buildCodeGraph([orders, users], []); @@ -496,7 +498,7 @@ describe("migration sirasi (FK topolojisi + isim)", () => { expect(usersIdx).toBeLessThan(ordersIdx); }); - it("FK dongusu patlatmaz (kalanlar isim sirasinda)", () => { + it("an FK cycle does not blow up (the rest stay in name order)", () => { const a = node("Table", { TableName: "a_tbl", Description: "x", Columns: [{ Name: "b_id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [{ Columns: ["b_id"], ReferencesTable: "b_tbl", ReferencesColumns: ["id"], OnDelete: "NO_ACTION", OnUpdate: "NO_ACTION" }], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); const b = node("Table", { TableName: "b_tbl", Description: "x", Columns: [{ Name: "a_id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: false, AutoIncrement: false }], ForeignKeys: [{ Columns: ["a_id"], ReferencesTable: "a_tbl", ReferencesColumns: ["id"], OnDelete: "NO_ACTION", OnUpdate: "NO_ACTION" }], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); const g = buildCodeGraph([a, b], []); @@ -504,16 +506,16 @@ describe("migration sirasi (FK topolojisi + isim)", () => { }); }); -describe("#4 orphan join tablosu entity'si forFeature'a kaydedilir (boot regresyonu)", () => { - /** Gercek e-ticaret deseni: products tablosunu bir Repository gosterir (sentetik - * entity CEKIRDEK), order_items ise hicbir repo gostermez ama products'a FK - * verir (orphan join tablosu). entity-synthesis order_items icin @Entity + - * @ManyToOne(Product) uretir; Product entity'sinde @OneToMany(OrderItem) dogar. - * Eger order_items HICBIR feature'in TypeOrmModule.forFeature'ina girmezse - * TypeORM bootta "Entity metadata for Product#orderItems not found" firlatir. - * Bu test, IR'in order_items'i bir feature'a (FK co-location -> products'in - * feature'i) atadigini ve o feature'in syntheticEntityTables'inda durdugunu - * dogrular -> module.emitter onu forFeature'a kaydeder -> uygulama BOOT BOOTS. */ +describe("#4 an orphan join table's entity is registered in forFeature (boot regression)", () => { + /** A real e-commerce pattern: a Repository points at the products table (synthetic + * entity is the CORE), while order_items is pointed at by no repo but has an FK + * to products (an orphan join table). entity-synthesis generates @Entity + + * @ManyToOne(Product) for order_items; @OneToMany(OrderItem) appears on the + * Product entity. If order_items enters NO feature's TypeOrmModule.forFeature, + * TypeORM throws "Entity metadata for Product#orderItems not found" at boot. + * This test verifies the IR assigns order_items to a feature (FK co-location -> + * products' feature) and that it sits in that feature's syntheticEntityTables -> + * module.emitter registers it in forFeature -> the application boots. */ function joinFixture() { const products = node("Table", { TableName: "products", Description: "x", Columns: [{ Name: "id", DataType: "UUID", IsPrimaryKey: true, IsNotNull: true, IsUnique: true, AutoIncrement: false }], ForeignKeys: [], UniqueConstraints: [], CheckConstraints: [], Indexes: [] }); const productSvc = node("Service", { ServiceName: "ProductService", Description: "x", Dependencies: [], Methods: [] }); @@ -523,25 +525,25 @@ describe("#4 orphan join tablosu entity'si forFeature'a kaydedilir (boot regresy return { g, orderItems, products }; } - it("order_items bir feature'a atanir (kendi orphan slug'inda ASILI KALMAZ)", () => { + it("order_items is assigned to a feature (it does NOT hang in its own orphan slug)", () => { const { g, orderItems } = joinFixture(); const slug = g.featureOf(g.byId(orderItems.id)!); - // FK co-location: products'in feature'i (product). Orphan "order-items"e DUSMEZ. + // FK co-location: products' feature (product). It does NOT fall to an orphan "order-items". expect(slug).toBe("product"); }); - it("atandigi feature'in syntheticEntityTables'inda durur (forFeature kaydi)", () => { + it("it sits in the assigned feature's syntheticEntityTables (forFeature registration)", () => { const { g, orderItems, products } = joinFixture(); const product = g.features().find((f) => f.slug === "product")!; const synthNames = product.syntheticEntityTables.map((t) => t.name); - // Hem cekirdek (products) hem FK-kapanis (order_items) AYNI feature'da -> ikisi de forFeature'a. + // Both the core (products) and the FK closure (order_items) are in the SAME feature -> both go into forFeature. expect(synthNames).toContain("products"); expect(synthNames).toContain("order_items"); void orderItems; void products; }); - it("DETERMINISM: atama iki kez ayni", () => { + it("DETERMINISM: the assignment is identical across two calls", () => { const { g, orderItems } = joinFixture(); expect(g.featureOf(g.byId(orderItems.id)!)).toBe(g.featureOf(g.byId(orderItems.id)!)); }); diff --git a/apps/server/src/codegen/naming.spec.ts b/apps/server/src/codegen/naming.spec.ts index 6d4f51b..830c0d8 100644 --- a/apps/server/src/codegen/naming.spec.ts +++ b/apps/server/src/codegen/naming.spec.ts @@ -15,8 +15,8 @@ import { import { buildCodeGraph } from "./ir"; import { ImportCollector } from "./imports"; -describe("case donusumleri", () => { - it("splitWords karisik girdileri boler", () => { +describe("case conversions", () => { + it("splitWords splits mixed inputs", () => { expect(splitWords("userId")).toEqual(["user", "Id"]); expect(splitWords("UserProfile")).toEqual(["User", "Profile"]); expect(splitWords("user_profile")).toEqual(["user", "profile"]); @@ -48,7 +48,7 @@ describe("case donusumleri", () => { }); describe("pluralizeSnake", () => { - it("temel kurallar", () => { + it("basic rules", () => { expect(pluralizeSnake("User")).toBe("users"); expect(pluralizeSnake("Category")).toBe("categories"); expect(pluralizeSnake("Box")).toBe("boxes"); @@ -56,26 +56,26 @@ describe("pluralizeSnake", () => { expect(pluralizeSnake("Address")).toBe("addresses"); }); - it("unluden sonra -y -> -ys", () => { + it("-y after a vowel -> -ys", () => { expect(pluralizeSnake("Day")).toBe("days"); }); }); -describe("tableSqlName (fiziksel tablo adi — cogullamaz)", () => { - it("acik TableName'i LITERAL kabul eder (tekrar cogullamaz)", () => { - // Eski hata: pluralizeSnake("users")="userses". tableSqlName bunu yapmaz. +describe("tableSqlName (physical table name — does not pluralize)", () => { + it("takes an explicit TableName LITERALLY (does not re-pluralize)", () => { + // The old bug: pluralizeSnake("users")="userses". tableSqlName doesn't do that. expect(tableSqlName("users")).toBe("users"); expect(tableSqlName("orders")).toBe("orders"); expect(tableSqlName("categories")).toBe("categories"); }); - it("yalniz snake_case'ler (tekil/PascalCase oldugu gibi)", () => { + it("only snake_cases (singular/PascalCase pass through)", () => { expect(tableSqlName("User")).toBe("user"); expect(tableSqlName("OrderItem")).toBe("order_item"); }); }); -describe("scalarTsType (sema tipi -> gecerli TS skaleri)", () => { - it("yaygin tipleri normalize eder", () => { +describe("scalarTsType (schema type -> valid TS scalar)", () => { + it("normalizes common types", () => { expect(scalarTsType("uuid")).toBe("string"); expect(scalarTsType("text")).toBe("string"); expect(scalarTsType("int")).toBe("number"); @@ -85,17 +85,17 @@ describe("scalarTsType (sema tipi -> gecerli TS skaleri)", () => { expect(scalarTsType("datetime")).toBe("Date"); expect(scalarTsType("")).toBe("string"); }); - it("bilinmeyen tipi oldugu gibi birakir (ozel sinif/DTO adi)", () => { + it("leaves an unknown type as-is (custom class/DTO name)", () => { expect(scalarTsType("UserDto")).toBe("UserDto"); }); - it("generic SQL ENUM/JSON tiplerini GECERLI TS'e cevirir (bare ENUM/JSON uretmez)", () => { - // EnumRef'siz generic SQL ENUM (or. repository CustomQuery param Type="ENUM") - // -> string. Eskiden bare `ENUM` -> TS2304 (derleme kirik). sql-type-map ile tutarli. + it("converts generic SQL ENUM/JSON types to VALID TS (never emits bare ENUM/JSON)", () => { + // A generic SQL ENUM without an EnumRef (e.g. repository CustomQuery param Type="ENUM") + // -> string. Previously a bare `ENUM` -> TS2304 (broken compile). Consistent with sql-type-map. expect(scalarTsType("ENUM")).toBe("string"); expect(scalarTsType("enum")).toBe("string"); expect(scalarTsType("JSON")).toBe("Record"); expect(scalarTsType("jsonb")).toBe("Record"); - // Ek SQL skaler varyantlari (sql-type-map ile tutarli). + // Additional SQL scalar variants (consistent with sql-type-map). expect(scalarTsType("bigint")).toBe("number"); expect(scalarTsType("smallint")).toBe("number"); expect(scalarTsType("char")).toBe("string"); @@ -104,49 +104,50 @@ describe("scalarTsType (sema tipi -> gecerli TS skaleri)", () => { }); }); -describe("import yollari", () => { - it("importPathOf uzantiyi atar", () => { +describe("import paths", () => { + it("importPathOf drops the extension", () => { expect(importPathOf("users/users.service.ts")).toBe("users/users.service"); }); - it("relativeImportPath ayni klasor", () => { + it("relativeImportPath same folder", () => { expect(relativeImportPath("users/users.controller.ts", "users/users.service.ts")).toBe( "./users.service", ); }); - it("relativeImportPath kardes klasor", () => { + it("relativeImportPath sibling folder", () => { expect(relativeImportPath("users/users.service.ts", "common/enums/role.enum.ts")).toBe( "../common/enums/role.enum", ); }); - it("relativeImportPath alt klasor", () => { + it("relativeImportPath subfolder", () => { expect(relativeImportPath("users/users.service.ts", "users/dto/create-user.dto.ts")).toBe( "./dto/create-user.dto", ); }); }); -describe("resolveTypeRef — cozulemeyen serbest tip GUVENLI degrade olur (TS2304 onle)", () => { - // Bos graf: hicbir node yok -> her PascalCase tip adi COZULEMEZ. Eskiden token - // oldugu gibi gecip `Promise` (TS2304) uretiyordu. Artik acik-uclu - // `Record`'a degrade olur: hem donus (obje insasi) hem tuketim - // (member access -> unknown) derlenir; contract-lint ayrica uyari verir. +describe("resolveTypeRef — an unresolvable free-form type degrades SAFELY (prevent TS2304)", () => { + // An empty graph: no nodes -> every PascalCase type name is UNRESOLVABLE. Previously + // the token passed through as-is and produced `Promise` (TS2304). Now it + // degrades to the open-ended `Record`: both the return (object + // construction) and the consumption (member access -> unknown) compile; + // contract-lint warns separately. const g = buildCodeGraph([], []); const ref = (raw: string) => resolveTypeRef(raw, g, "src/x.ts", new ImportCollector()); - it("ciplak cozulemeyen tip -> Record", () => { + it("a bare unresolvable type -> Record", () => { expect(ref("TokenPair")).toBe("Record"); expect(ref("PaymentResult")).toBe("Record"); }); - it("sarmalayici korunur: Promise, X[]", () => { + it("the wrapper is preserved: Promise, X[]", () => { expect(ref("Promise")).toBe("Promise>"); expect(ref("Cart[]")).toBe("Record[]"); }); - it("skaler ve TS keyword'leri ETKILENMEZ", () => { + it("scalars and TS keywords are UNAFFECTED", () => { expect(ref("UUID")).toBe("string"); expect(ref("Promise")).toBe("Promise"); expect(ref("number")).toBe("number"); diff --git a/apps/server/src/codegen/naming.ts b/apps/server/src/codegen/naming.ts index 1828997..d199fd6 100644 --- a/apps/server/src/codegen/naming.ts +++ b/apps/server/src/codegen/naming.ts @@ -3,22 +3,22 @@ import type { CodeNode, CodeGraph } from "./ir"; import type { ImportCollector } from "./imports"; /* ──────────────────────────────────────────────────────────────────────── - * naming.ts — DETERMINISTIC isim ve yol uretimi. + * naming.ts — DETERMINISTIC name and path generation. * - * Tum emitter'lar isim donusumu ve dosya yolu icin YALNIZ bu modulu kullanir - * (hardcode case donusumu YASAK). Ayni node -> ayni isim -> ayni yol. + * All emitters use ONLY this module for name conversion and file paths + * (hardcoded case conversion is FORBIDDEN). Same node -> same name -> same path. * ──────────────────────────────────────────────────────────────────────── */ -/** Bir tanimlayiciyi kelimelere boler: camelCase, PascalCase, snake_case, - * kebab-case, "bosluklu metin" — hepsini normalize eder. */ +/** Splits an identifier into words: camelCase, PascalCase, snake_case, + * kebab-case, "text with spaces" — normalizes them all. */ export function splitWords(input: string): string[] { return ( input - // camelCase / PascalCase siniri: "userId" -> "user Id" + // camelCase / PascalCase boundary: "userId" -> "user Id" .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - // ardisik buyuk harf + sozcuk: "HTTPServer" -> "HTTP Server" + // consecutive capitals + a word: "HTTPServer" -> "HTTP Server" .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") - // ayraclar + // separators .split(/[\s\-_./]+/) .filter((w) => w.length > 0) ); @@ -38,11 +38,12 @@ export function camelCase(input: string): string { return p.length === 0 ? p : p[0].toLowerCase() + p.slice(1); } -/** Bir property/field adini TS UYE TANIMLAYICISI olarak normalize eder (entity/model - * alani + iliski prop). Diyagram PascalCase yazsa da (Id, CustomerId, Title) idiomatik - * TS camelCase'e cevirir → Surgical AI grounding'i de bu yuzeyi okuyup uyumlu camelCase - * govde uretir. DB kolon ADI NOT — o ham `.Name`'den snakeCase ile ayri turetilir - * (SnakeNamingStrategy member adini ayni snake_case'e indirir, drift olmaz). */ +/** Normalizes a property/field name into a TS MEMBER IDENTIFIER (entity/model + * field + relation prop). Even when the diagram writes PascalCase (Id, CustomerId, + * Title), it converts to idiomatic TS camelCase → the surgical AI grounding also + * reads this surface and produces a matching camelCase body. NOT the DB column + * NAME — that is derived separately from the raw `.Name` via snakeCase + * (SnakeNamingStrategy lowers the member name to the same snake_case, so no drift). */ export const tsPropName = camelCase; /** "UserProfile" -> "user-profile". */ @@ -55,24 +56,25 @@ export function snakeCase(input: string): string { return splitWords(input).map(lower).join("_"); } -/* ── Fiziksel tablo adi — TEK SOURCE ─────────────────────────────────────── - * Bir Table node'unun TableName'i (ve TableRef ile ona baglanan Model'in - * @Entity adi) DAIMA bu fonksiyondan gelir; boylece migration'daki - * `CREATE TABLE` ile entity'nin `@Entity(...)` adi ASLA ayrismaz. +/* ── Physical table name — SINGLE SOURCE ─────────────────────────────────── + * A Table node's TableName (and the @Entity name of the Model bound to it via + * TableRef) ALWAYS comes from this function; that way the migration's + * `CREATE TABLE` and the entity's `@Entity(...)` name NEVER diverge. * - * KARAR: TableName author'in sectigi LITERAL fiziksel tablo adidir — tekrar - * cogullanmaz (yalniz snake_case'lenir). "users" -> "users", "User" -> "user", - * "OrderItem" -> "order_item". (Acik ad yoksa class adindan turetmek icin ayrica - * pluralizeSnake kullanilir — bkz. model.emitter resolveTableName.) */ + * DECISION: TableName is the LITERAL physical table name the author chose — it is + * not re-pluralized (only snake_cased). "users" -> "users", "User" -> "user", + * "OrderItem" -> "order_item". (When there is no explicit name, pluralizeSnake is + * used separately to derive one from the class name — see model.emitter + * resolveTableName.) */ export function tableSqlName(rawTableName: string): string { return snakeCase(rawTableName); } -/* ── Cok basit, deterministik Ingilizce cogullama ────────────────────────── - * Kapsam: yaygin kurallar. AI/sozluk NONE — codegen deterministik kalsin. +/* ── Very simple, deterministic English pluralization ────────────────────── + * Scope: the common rules. No AI, no dictionary — codegen stays deterministic. * "category" -> "categories", "box" -> "boxes", "user" -> "users". - * YALNIZ acik TableName'i WITHOUT bir tabloyu class adindan turetmek icin - * kullanilir (Model.TableRef yoksa). Acik TableName'e ASLA uygulanmaz. */ + * ONLY used to derive a table from the class name when there is no explicit + * TableName (Model.TableRef missing). NEVER applied to an explicit TableName. */ export function pluralizeSnake(input: string): string { const base = snakeCase(input); if (base.length === 0) return base; @@ -84,7 +86,7 @@ export function pluralizeSnake(input: string): string { function pluralizeWord(w: string): string { if (w.length === 0) return w; const endsWith = (s: string) => w.endsWith(s); - // -y -> -ies (unsuzden sonra) + // -y -> -ies (after a consonant) if (endsWith("y") && w.length > 1 && !"aeiou".includes(w[w.length - 2])) { return w.slice(0, -1) + "ies"; } @@ -95,12 +97,13 @@ function pluralizeWord(w: string): string { return w + "s"; } -/* ── Sema tip stringi -> TypeScript skaler tipi ──────────────────────────── - * Semadaki serbest tip string'leri (Param/QueryParam.Type, Model.Property.Type, - * DTO.Field.DataType) yaygin es anlamlilari TS tiplerine normalize eder. Bilinmeyen - * tip OLDUGU GIBI doner (ozel sinif/DTO adi gecisi icin). Uc emitter da (controller/ - * model/dto) ayni eslemeyi paylassin diye buradadir — aksi halde controller - * `id: uuid` gibi GECERSIZ TS uretirdi (uuid bir TS tipi degil). */ +/* ── Schema type string -> TypeScript scalar type ────────────────────────── + * Normalizes the free-form type strings in the schema (Param/QueryParam.Type, + * Model.Property.Type, DTO.Field.DataType) from common synonyms to TS types. An + * unknown type is returned AS-IS (to let custom class/DTO names pass through). + * It lives here so all three emitters (controller/model/dto) share the same + * mapping — otherwise the controller would emit INVALID TS like `id: uuid` + * (uuid is not a TS type). */ export function scalarTsType(raw: string): string { switch ((raw ?? "").trim().toLowerCase()) { case "": @@ -142,14 +145,14 @@ export function scalarTsType(raw: string): string { case "timestamptz": case "time": return "Date"; - // JSON/JSONB: serbest tip string'i olarak verilen JSON -> Record - // (sql-type-map ile tutarli; bare `JSON` GECERSIZ TS uretmesin). + // JSON/JSONB: JSON given as a free-form type string -> Record + // (consistent with sql-type-map; a bare `JSON` must not emit invalid TS). case "json": case "jsonb": return "Record"; - // Parametresiz koleksiyon-ism'i (DataType="Array"/"List", eleman tipi yok) → bare - // `Array` GECERSIZ TS (TS2314: tip argumani ister). Guvenli degradasyon: `unknown` - // (IsCollection ekiyle `unknown[]`). Parametreli `List`/`X[]` resolveTypeRef yolunda. + // A parameterless collection noun (DataType="Array"/"List", no element type) → a bare + // `Array` is INVALID TS (TS2314: wants a type argument). Safe degradation: `unknown` + // (`unknown[]` with the IsCollection flag). Parameterized `List`/`X[]` goes through resolveTypeRef. case "array": case "list": return "unknown"; @@ -158,22 +161,21 @@ export function scalarTsType(raw: string): string { } } -/* ── Serbest tip stringi -> GECERLI TS tipi (scalar + ref cozumu + import) ── - * Repository CustomQuery ve Service metot param/return tipleri semada SERBEST - * string'tir (or. "User", "UUID", "User[]", "Promise"). Ham birakilirsa - * "User"/"UUID" gibi tanimsiz semboller `nest build`'i TS2304 ile kirar. +/* ── Free-form type string -> VALID TS type (scalar + ref resolution + import) ── + * Repository CustomQuery and Service method param/return types are FREE-FORM + * strings in the schema (e.g. "User", "UUID", "User[]", "Promise"). Left + * raw, undefined symbols like "User"/"UUID" break `nest build` with TS2304. * - * resolveTypeRef tek bir tip token'ini: - * 1) scalarTsType ile normalize eder (uuid->string, int->number, date->Date...), - * 2) skaler degilse Model/DTO/Enum node'u olarak cozmeye calisir -> cozulurse - * sinif adini import eder (fromFile'a goreli) ve dondurur, - * 3) hicbiri degilse token'i OLDUGU GIBI birakir (serbest tip; derlemeyi - * kirabilir ama bu zaten kullanicinin verdigi tiptir — controller.emitter - * ile ayni tolerans). + * resolveTypeRef takes a single type token and: + * 1) normalizes it via scalarTsType (uuid->string, int->number, date->Date...), + * 2) if not a scalar, tries to resolve it as a Model/DTO/Enum node -> on success + * imports the class name (relative to fromFile) and returns it, + * 3) otherwise leaves the token AS-IS (a free type; it may break the compile, + * but it is the type the user gave — the same tolerance as controller.emitter). * - * Kompozit tipleri (Array, X[], Promise, X | null, X | undefined) parcalar: - * sarmalayiciyi korur, ICERDEKI tanimlayicilari tek tek cozer. Determinizm: - * yalniz ham string uzerinde regex; node sirasi graph'tan gelir. + * Composite types (Array, X[], Promise, X | null, X | undefined) are split: + * the wrapper is kept, the INNER identifiers are resolved one by one. Determinism: + * regex over the raw string only; node order comes from the graph. * ──────────────────────────────────────────────────────────────────────── */ export function resolveTypeRef( rawType: string, @@ -183,16 +185,16 @@ export function resolveTypeRef( ): string { const raw = (rawType ?? "").trim(); if (raw.length === 0) return "void"; - // LLM-yazimi koleksiyon-ism'i: `List` (Java/Kotlin) gecerli TS degil → - // TS-native `Array` (sozcuk-sinirli, buyuk/kucuk harf duyarsiz; `UserList` - // dokunulmaz). Asagidaki dongu `Array`'i zaten passthrough gecer, icteki X'i cozer. + // An LLM-written collection noun: `List` (Java/Kotlin) is not valid TS → + // TS-native `Array` (word-bounded, case-insensitive; `UserList` is untouched). + // The loop below already passes `Array` through and resolves the inner X. const t = raw.replace(/\bList\s*[]|, bosluk) - // oldugu gibi koru. Bu, Promise, User[], User | null gibi tipleri kapsar. + // Resolve each identifier piece; keep the non-identifier parts (<>[]|, spaces) + // as-is. This covers types like Promise, User[], User | null. return t.replace(/[A-Za-z_][A-Za-z0-9_]*/g, (token) => resolveTypeToken(token, graph, fromFile, imports)); } -/** Bilinen tip-anahtar kelimeleri (cozulmez; oldugu gibi gecer). */ +/** Known type keywords (not resolved; passed through as-is). */ const TS_TYPE_KEYWORDS = new Set([ "Promise", "Array", "Record", "Map", "Set", "Partial", "Readonly", "Pick", "Omit", "string", "number", "boolean", "Date", "void", "any", "unknown", "null", "undefined", @@ -206,54 +208,56 @@ function resolveTypeToken( imports: ImportCollector, ): string { if (TS_TYPE_KEYWORDS.has(token)) return token; - // Skaler es anlamli mi? (uuid/int/date ...) -> TS skaleri. + // A scalar synonym? (uuid/int/date ...) -> the TS scalar. const scalar = scalarTsType(token); if (scalar !== token) return scalar; - // Skaler degil -> bir Model/DTO/Enum node'u olabilir; coz + import et. + // Not a scalar -> it may be a Model/DTO/Enum node; resolve + import. const node = graph.resolveRef(["Model", "DTO", "Enum"], token); if (node) { const cls = pascalCase(node.name); const path = importPathOf(relativeImportPath(fromFile, filePathFor(node, graph))); - // Enum tip-pozisyonunda gorunse de govdede DEGER olarak kullanilir (Status.SUBMITTED, - // Object.values(Enum)) → VALUE import. `import type` olsa TS1361 verir. Model/DTO tip-only kalir. + // Even though an Enum appears in type position, bodies use it as a VALUE + // (Status.SUBMITTED, Object.values(Enum)) → VALUE import. An `import type` + // would give TS1361. Model/DTO stay type-only. if (node.kindOf() === "Enum") imports.add(cls, path); else imports.addType(cls, path); return cls; } - // Bir DB View mi? (repository View dondurur). View migration + TS @ViewEntity uretir; - // tip olarak @ViewEntity sinifini import et (filePathFor(View) migration'dir → viewEntityFilePath). + // A DB View? (a repository can return a View). A View emits a migration + a TS + // @ViewEntity; import the @ViewEntity class as the type (filePathFor(View) is the + // migration → use viewEntityFilePath). const view = graph.resolveRef("View", token); if (view) { const cls = pascalCase(view.name); - // @ViewEntity bir SINIF — govdede deger olarak da kullanilabilir (repository token, - // new) → VALUE import (Enum ile ayni; `import type` TS1361 verir). + // @ViewEntity is a CLASS — it can also be used as a value in bodies (repository + // token, new) → VALUE import (same as Enum; `import type` gives TS1361). imports.add(cls, importPathOf(relativeImportPath(fromFile, viewEntityFilePath(view, graph)))); return cls; } - // Bir Table'dan SENTEZLENEN entity adi mi? (Model yokken servis/repo "User" - // dondurur; sentetik entity sinif adi entityClassNameForTable ile eslesir.) - // Hem ham tablo adi ("Users") hem sentetik sinif adi ("User") eslenir. + // The name of an entity SYNTHESIZED from a Table? (with no Model, a service/repo + // returns "User"; the synthetic entity class name matches entityClassNameForTable.) + // Both the raw table name ("Users") and the synthetic class name ("User") match. const synthTable = resolveSyntheticEntityType(token, graph); if (synthTable) { const cls = entityClassNameForTable(synthTable); imports.addType(cls, importPathOf(relativeImportPath(fromFile, synthEntityFilePath(synthTable, graph)))); return cls; } - // Cozulemeyen serbest ad (hicbir Model/DTO/Enum/View/sentetik-Table node'una - // cozulmedi): bu, graf'in bir KONTRAT BOSLUGUDUR — referans edilen tipin tanimi - // yok. Token'i OLDUGU GIBI birakmak `Promise` gibi TS2304 ile derlemeyi - // KIRARDI. Bunun yerine acik-uclu `Record`'a GUVENLI degrade et: - // · donus pozisyonu: `{ accessToken, ... }` obje literali atanabilir, - // · tuketim: `result.accessToken` -> unknown (index signature) — ikisi de derlenir. - // Bosluk yine de YUKSEK SESLE bildirilir (contract-lint unresolvedTypeRefs uyarisi). + // An unresolvable free-form name (resolved to no Model/DTO/Enum/View/synthetic-Table + // node): this is a CONTRACT GAP in the graph — the referenced type has no definition. + // Leaving the token AS-IS would BREAK the compile with TS2304 (`Promise`). + // Instead, degrade SAFELY to the open-ended `Record`: + // · return position: a `{ accessToken, ... }` object literal is assignable, + // · consumption: `result.accessToken` -> unknown (index signature) — both compile. + // The gap is still reported LOUDLY (the contract-lint unresolvedTypeRefs warning). return "Record"; } -/** Bir tip token'i (or. "User" veya "Users") bir Table'dan SENTEZLENECEK - * entity'ye karsilik geliyor mu? Yalniz (a) bir Repository tarafindan referans - * edilen ve (b) Model'i WITHOUT Table'lar aday — bunlar icin sentetik entity - * dosyasi gercekten uretilir (aksi halde import TS2307 verirdi). Token, tablonun - * ham adi VEYA sentetik sinif adi (singular-pascal) olabilir. */ +/** Does a type token (e.g. "User" or "Users") correspond to an entity that will be + * SYNTHESIZED from a Table? Only Tables that are (a) referenced by a Repository + * and (b) have no Model are candidates — for those a synthetic entity file is + * actually generated (otherwise the import would give TS2307). The token may be + * the table's raw name OR the synthetic class name (singular-pascal). */ function resolveSyntheticEntityType(token: string, graph: CodeGraph): CodeNode | null { const want = pascalCase(token); for (const table of graph.allOf("Table")) { @@ -266,7 +270,7 @@ function resolveSyntheticEntityType(token: string, graph: CodeGraph): CodeNode | return null; } -/** Bir Table, bir Repository.EntityReference ile referans ediliyor mu? */ +/** Is a Table referenced by some Repository.EntityReference? */ function isRepositoryReferenced(table: CodeNode, graph: CodeGraph): boolean { for (const repo of graph.allOf("Repository")) { const ref = (repo.properties as Record).EntityReference; @@ -277,8 +281,9 @@ function isRepositoryReferenced(table: CodeNode, graph: CodeGraph): boolean { return false; } -/** Bu Table'i TableRef ile temsil eden bir Model var mi? (varsa Model entity'si - * uretilir; sentez gereksiz — resolveTypeRef Model'i ayri cozer.) */ +/** Is there a Model representing this Table via TableRef? (if so, the Model's + * entity is generated; no synthesis needed — resolveTypeRef resolves the Model + * separately.) */ function hasBackingModel(table: CodeNode, graph: CodeGraph): boolean { for (const m of graph.allOf("Model")) { const tableRef = (m.properties as Record).TableRef; @@ -290,33 +295,35 @@ function hasBackingModel(table: CodeNode, graph: CodeGraph): boolean { } /* ──────────────────────────────────────────────────────────────────────── - * Feature klasoru + dosya yolu — ARCHITECTURE-FARKINDA. + * Feature folder + file path — ARCHITECTURE-AWARE. * - * Her node bir FEATURE slug'a ("auth", "image", ...) veya "common"a aittir; - * bu atama ir.ts feature-inference tarafindan yapilir ve graph.featureOf(node) - * ile okunur. Dosya yolunun KLASORU feature'dir; DOSYA ADI ise rol son-ekini - * TEKRARLAMAYAN idiomatik isimden (baseNameOf -> kebab) turetilir. + * Every node belongs to a FEATURE slug ("auth", "image", ...) or to "common"; + * that assignment is made by the ir.ts feature-inference and read through + * graph.featureOf(node). The path's FOLDER is the feature; the FILE NAME is + * derived from the idiomatic name that does NOT repeat the role suffix + * (baseNameOf -> kebab). * * AuthController -> src/auth/auth.controller.ts - * UserRepository -> src/user/user.repository.ts (feature'ina gore) + * UserRepository -> src/user/user.repository.ts (by its feature) * AuthResponseDTO -> src/auth/dto/auth-response.dto.ts * ImageGenerationSvc -> src/image/image-generation.service.ts * - * Table migrations/ altindadir — feature'a bagli degildir (degismez). + * Tables live under migrations/ — not tied to a feature (invariant). * ──────────────────────────────────────────────────────────────────────── */ -/** Bir kind icin idiomatik rol son-eki (dosya adinda TEKRARLANMAZ). NestJS - * dosya adi zaten ".controller.ts"/".service.ts" eki tasidigindan sinif - * adindaki "Controller"/"Service"/... son-eki dosya kok adindan dusurulur. */ +/** The idiomatic role suffix for a kind (NOT repeated in the file name). Since a + * NestJS file name already carries the ".controller.ts"/".service.ts" suffix, + * the "Controller"/"Service"/... suffix in the class name is dropped from the + * file's base name. */ const ROLE_SUFFIX_BY_KIND: Partial> = { Controller: ["Controller"], Service: ["Service"], Repository: ["Repository"], Module: ["Module"], Exception: ["Exception", "Error"], - // DTO adlari "...DTO"/"...Dto" eki tasir -> dosya adi bunu tekrarlamaz. + // DTO names carry a "...DTO"/"...Dto" suffix -> the file name doesn't repeat it. DTO: ["DTO", "Dto"], - // ── Mimari altyapi kind'lari (rol eki dosya adinda TEKRARLANMAZ) ────────── + // ── Architectural infrastructure kinds (role suffix NOT repeated in the file name) ── // "ImageResultCache" -> base "ImageResult" -> image-result.cache.ts. Cache: ["Cache"], // "ImageJobsQueue"/"ImageMessageQueue" -> "ImageJobs"/"Image" -> *.queue.ts. @@ -328,7 +335,7 @@ const ROLE_SUFFIX_BY_KIND: Partial> = { // "CheckoutOrchestrator" -> "Checkout" -> checkout.orchestrator.ts. Orchestrator: ["Orchestrator"], // "StableDiffusionApi"/"StripeClient"/"MailService" -> "StableDiffusion"/ - // "Stripe"/"Mail" -> *.client.ts (idiomatik dis servis istemcisi). + // "Stripe"/"Mail" -> *.client.ts (the idiomatic external-service client). ExternalService: ["Client", "Api", "Service"], // "AuthMiddleware" -> "Auth" -> auth.middleware.ts. Middleware: ["Middleware"], @@ -336,11 +343,12 @@ const ROLE_SUFFIX_BY_KIND: Partial> = { APIGateway: ["APIGateway", "Gateway"], }; -/** Bir node'un IDIOMATIK temel adi — rol son-eki ayiklanmis (dosya/feature adi - * turetmek icin). "AuthController"->"Auth", "UserRepository"->"User", +/** A node's IDIOMATIC base name — with the role suffix stripped (for deriving + * file/feature names). "AuthController"->"Auth", "UserRepository"->"User", * "AuthResponseDTO"->"AuthResponse", "ImageGenerationService"->"ImageGeneration". - * Bilinen rol son-eki yoksa ad oldugu gibi doner. Bos ada dusmez (rol son-eki - * adin TAMAMIYSA orijinal ad korunur — "Service" -> "Service"). */ + * Without a known role suffix the name is returned as-is. It never collapses to + * an empty name (when the role suffix IS the whole name, the original is kept — + * "Service" -> "Service"). */ export function baseNameOf(node: CodeNode): string { const name = node.name; const suffixes = ROLE_SUFFIX_BY_KIND[node.kindOf()] ?? []; @@ -352,22 +360,22 @@ export function baseNameOf(node: CodeNode): string { return name; } -/** Node'un feature klasoru (kebab-case). graph.featureOf -> feature slug veya - * "common"; ir.ts feature-inference TEK KAYNAGIDIR. Yol uretiminin yalniz - * okudugu bir degerdir (heuristik burada NOT). */ +/** The node's feature folder (kebab-case). graph.featureOf -> the feature slug or + * "common"; the ir.ts feature-inference is the SINGLE SOURCE. Path generation + * only reads this value (no heuristics here). */ export function featureFolderOf(node: CodeNode, graph: CodeGraph): string { return graph.featureOf(node) || "common"; } -/** Migration sira numarasini 3 haneli sifir-dolgulu dondurur: 1 -> "001". */ +/** Returns the migration sequence number zero-padded to 3 digits: 1 -> "001". */ export function migrationSeq(index: number): string { return String(index + 1).padStart(3, "0"); } -/* ── filePathFor: node -> proje kokune goreli POSIX yolu (bas "/" yok) ────── - * KLASOR = feature (graph.featureOf); DOSYA ADI = baseNameOf (rol son-eki - * TEKRARSIZ). Idiomatik NestJS duzeni: - * Module -> /.module.ts (feature basina TEK module) +/* ── filePathFor: node -> POSIX path relative to the project root (no leading "/") ── + * FOLDER = feature (graph.featureOf); FILE NAME = baseNameOf (role suffix NOT + * repeated). The idiomatic NestJS layout: + * Module -> /.module.ts (ONE module per feature) * Controller -> /.controller.ts (auth.controller.ts) * Service -> /.service.ts * Repository -> /.repository.ts (user.repository.ts) @@ -375,8 +383,8 @@ export function migrationSeq(index: number): string { * DTO -> /dto/.dto.ts (auth-response.dto.ts) * Enum -> /enums/.enum.ts (feature) | common/enums/... (common) * Exception -> /exceptions/.exception.ts | common/exceptions/... (common) - * Table -> migrations/NNN_create_.sql (NNN ir tarafindan verilir) - * View -> migrations/NNN_create_.sql (DB view -> SQL; Table gibi kokte) + * Table -> migrations/NNN_create_.sql (NNN assigned by the ir) + * View -> migrations/NNN_create_.sql (DB view -> SQL; at the root like Table) * Cache -> /.cache.ts * MessageQueue -> /.queue.ts * Worker -> /.worker.ts @@ -385,17 +393,17 @@ export function migrationSeq(index: number): string { * ExternalService -> /.client.ts * Middleware -> /.middleware.ts | common/.middleware.ts * APIGateway -> /.gateway.ts | common/.gateway.ts - * diger stub -> /stubs/..stub.ts (feature koku temiz) + * other stubs -> /stubs/..stub.ts (keeps the feature root clean) * - * Tum dosyalar src/ KOKUNE goredir; src/ onekini scaffold/montaj ekler. - * Table/View icin sira numarasi graph.migrationIndexOf(node) ile cozulur. + * All files are relative to the src/ ROOT; scaffold/assembly adds the src/ prefix. + * For Table/View the sequence number is resolved via graph.migrationIndexOf(node). * ──────────────────────────────────────────────────────────────────────── */ export function filePathFor(node: CodeNode, graph: CodeGraph): string { const feature = featureFolderOf(node, graph); const base = kebabCase(baseNameOf(node)) || kebabCase(node.name) || feature; switch (node.kindOf()) { case "Module": - // Feature basina tek module -> dosya adi feature'in kendisidir. + // One module per feature -> the file name is the feature itself. return `${feature}/${feature}.module.ts`; case "Controller": return `${feature}/${base}.controller.ts`; @@ -408,7 +416,7 @@ export function filePathFor(node: CodeNode, graph: CodeGraph): string { case "DTO": return `${feature}/dto/${base}.dto.ts`; case "Enum": - // Paylasimli enum'lar common/; feature'a ozel olanlar feature altinda. + // Shared enums go to common/; feature-specific ones live under the feature. return feature === "common" ? `common/enums/${base}.enum.ts` : `${feature}/enums/${base}.enum.ts`; @@ -416,10 +424,10 @@ export function filePathFor(node: CodeNode, graph: CodeGraph): string { return feature === "common" ? `common/exceptions/${base}.exception.ts` : `${feature}/exceptions/${base}.exception.ts`; - // Table VE View ikisi de bir SQL migration'dir (CREATE TABLE / CREATE VIEW), - // migrations/ kokunde ve AYNI sira duzeninde (migrationIndexOf View'i kaynak - // Table'larindan sonra yerlestirir). Fiziksel ad tek kaynaktan (tableSqlName; - // tekrar cogullanmaz) — table.emitter / model.emitter ile tutarli. + // Table AND View are both a SQL migration (CREATE TABLE / CREATE VIEW), at the + // migrations/ root and in the SAME ordering scheme (migrationIndexOf places a + // View after its source Tables). The physical name comes from the single source + // (tableSqlName; not re-pluralized) — consistent with table.emitter / model.emitter. case "Table": case "View": { const seq = migrationSeq(graph.migrationIndexOf(node)); @@ -438,25 +446,25 @@ export function filePathFor(node: CodeNode, graph: CodeGraph): string { case "ExternalService": return `${feature}/${base}.client.ts`; case "Middleware": - // Middleware feature'a dusmuyorsa (cross-cutting) common'a iner. + // A middleware that lands in no feature (cross-cutting) goes down to common. return `${feature}/${base}.middleware.ts`; case "APIGateway": - // Gateway feature'a dusmuyorsa common'a iner (filePathFor feature='common'). + // A gateway that lands in no feature goes down to common (filePathFor feature='common'). return `${feature}/${base}.gateway.ts`; default: - // Desteklenmeyen tip -> stub dosyasi. Feature KOKUNE sacilmaz; ayri bir - // `stubs/` alt klasorune toplanir (gercek kod ile karismasin). + // Unsupported kind -> a stub file. Not scattered over the feature ROOT; + // gathered into a separate `stubs/` subfolder (so it doesn't mix with real code). return `${feature}/stubs/${base}.${kebabCase(node.kindOf())}.stub.ts`; } } -/** Bir TS dosyasindan (import yolu icin) uzantisiz POSIX yolu. */ +/** The extensionless POSIX path of a TS file (for import paths). */ export function importPathOf(filePath: string): string { return filePath.replace(/\.tsx?$/, ""); } -/** Iki dosya yolu arasinda goreli import yolu uretir (deterministik, POSIX). - * Orn from="users/users.service.ts" to="common/enums/role.enum.ts" +/** Produces the relative import path between two file paths (deterministic, POSIX). + * E.g. from="users/users.service.ts" to="common/enums/role.enum.ts" * -> "../common/enums/role.enum". */ export function relativeImportPath(fromFile: string, toFile: string): string { const fromDir = fromFile.split("/").slice(0, -1); @@ -470,8 +478,8 @@ export function relativeImportPath(fromFile: string, toFile: string): string { return joined.startsWith(".") ? joined : `./${joined}`; } -/** Bir kind icin "tek basina" sinif adi son eki (Service/Controller vb. zaten - * isimde olabilir; emitter'lar gerekirse kullanir). */ +/** The "standalone" class-name suffix for a kind (Service/Controller etc. may + * already be in the name; emitters use it when needed). */ export const KIND_CLASS_SUFFIX: Partial> = { Controller: "Controller", Service: "Service", @@ -480,20 +488,20 @@ export const KIND_CLASS_SUFFIX: Partial> = { Exception: "Exception", }; -/* ── Table'dan SENTEZLENEN entity isim/yolu — TEK SOURCE ─────────────────── +/* ── Name/path of the entity SYNTHESIZED from a Table — SINGLE SOURCE ────── * entity-synthesis.ts, repository.emitter, module.emitter, naming.resolveTypeRef - * hepsi BU iki fonksiyona dayanir (entity sinif adi/dosya yolu tutarli kalsin). - * Burada (naming.ts) tutulur cunku resolveTypeRef bunlara ihtiyac duyar ve - * naming.ts emitter'lardan import EDEMEZ (dongu). entity-synthesis bunlari - * re-export eder (geriye-uyum). ──────────────────────────────────────────── */ + * all rely on THESE two functions (keeping the entity class name/file path + * consistent). They live here (naming.ts) because resolveTypeRef needs them and + * naming.ts CANNOT import from the emitters (cycle). entity-synthesis re-exports + * them (backwards compatibility). ─────────────────────────────────────────── */ -/** Bir Table node'undan SENTEZLENEN entity sinif adi (tekil-pascal). "Users" - * -> "User", "generated_images" -> "GeneratedImage". */ +/** The class name of the entity SYNTHESIZED from a Table node (singular-pascal). + * "Users" -> "User", "generated_images" -> "GeneratedImage". */ export function entityClassNameForTable(table: CodeNode): string { return pascalCase(singularize(table.name)); } -/** Bir Table node'unun SENTEZLENEN entity dosya yolu: +/** The file path of the entity SYNTHESIZED from a Table node: * /entities/.entity.ts. */ export function synthEntityFilePath(table: CodeNode, graph: CodeGraph): string { const feature = featureFolderOf(table, graph); @@ -501,17 +509,17 @@ export function synthEntityFilePath(table: CodeNode, graph: CodeGraph): string { return `${feature}/entities/${base}.entity.ts`; } -/** View node'unun TS @ViewEntity dosya yolu — migration'dan AYRI (filePathFor(View) - * SQL migration'i verir). Repository bir View'i tip olarak dondurdugunde bu sinif - * import edilir. */ +/** A View node's TS @ViewEntity file path — SEPARATE from the migration + * (filePathFor(View) gives the SQL migration). This class is imported when a + * repository returns a View as a type. */ export function viewEntityFilePath(view: CodeNode, graph: CodeGraph): string { const feature = featureFolderOf(view, graph); const base = kebabCase(view.name) || feature; return `${feature}/entities/${base}.view.ts`; } -/** Cok basit deterministik tekillestirme (sozluk NONE). "users"->"user", - * "categories"->"category", "boxes"->"box". Yalniz son segment tekillestirilir. */ +/** Very simple deterministic singularization (no dictionary). "users"->"user", + * "categories"->"category", "boxes"->"box". Only the last segment is singularized. */ export function singularize(input: string): string { const segments = input.split(/[\s\-_./]+/).filter((w) => w.length > 0); const last = segments.length > 0 ? segments[segments.length - 1] : input; diff --git a/apps/server/src/config/env.ts b/apps/server/src/config/env.ts index 6fb2a77..b279613 100644 --- a/apps/server/src/config/env.ts +++ b/apps/server/src/config/env.ts @@ -10,8 +10,8 @@ const EnvSchema = z.object({ NEO4J_URI: z.string().url(), NEO4J_USER: z.string().min(1), NEO4J_PASSWORD: z.string().min(1), -// Connection pool / timeout — against "connection not available" under load. -// Reasonable for single-box launch; increase if necessary. + // Connection pool / timeout tuning — guards against "connection not available" errors + // under load. Defaults are reasonable for a single-box launch; raise them if needed. NEO4J_MAX_POOL_SIZE: z.coerce.number().int().positive().default(50), NEO4J_CONNECTION_ACQUISITION_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000), NEO4J_CONNECTION_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), @@ -64,21 +64,21 @@ const EnvSchema = z.object({ // Instruct mode (chat, explanation) — fast tier. DEEPSEEK_MODEL_INSTRUCT: z.string().default("deepseek-v4-flash"), -// Agent loop round ceiling (1 round = 1 LLM call; one round can loop MULTIPLE tool calls). -// Production does NOT stop when the ceiling is full: 'paused' event + user "Continue" -// continues where it left off (the agent sees the current graph, does not create it again). 120 = reasonable -// one-time step; great architecture is completed with a few "Go"s. + // Agent loop turn ceiling (1 turn = 1 LLM call; a single turn may run multiple tool calls). + // Hitting the ceiling is not fatal: a 'paused' event is emitted and the user's "Continue" + // resumes where the agent left off (it sees the current graph instead of rebuilding it). + // 120 is a generous budget for one sitting; large architectures finish in a few "Go"s. AI_MAX_TURNS: z.coerce.number().int().positive().default(120), // Codegen fill endpoint rate limit (requests per minute). CODEGEN_FILL_THROTTLE_LIMIT: z.coerce.number().int().positive().default(10), // ── Embeddings (Phase 4 GraphRAG) ── - // bedrock-mantle does not offer embedding models (chat LLMs only) → local default. - // local = @xenova/transformers (ONNX, offline, CPU). bedrock = future embed model via OpenAIEmbeddings. + // bedrock-mantle offers no embedding models (chat LLMs only), so "local" is the default. + // local = @xenova/transformers (ONNX, offline, CPU). bedrock = a future embedding model via OpenAIEmbeddings. EMBED_PROVIDER: z.enum(["local", "bedrock"]).default("local"), - // Multilingual (50+ languages), 384 dim — more accurate cross-locale than - // all-MiniLM-L6-v2 (mainly English). Same size → index unchanged. + // Multilingual (50+ languages), 384 dimensions — more accurate across locales than + // all-MiniLM-L6-v2 (mostly English). Same dimension, so the vector index is unchanged. EMBED_MODEL: z.string().default("Xenova/paraphrase-multilingual-MiniLM-L12-v2"), EMBED_DIM: z.coerce.number().int().positive().default(384), EMBED_TOP_K: z.coerce.number().int().positive().default(3), diff --git a/apps/server/src/edges/dto/update-edge.dto.ts b/apps/server/src/edges/dto/update-edge.dto.ts index 7c59639..079fa71 100644 --- a/apps/server/src/edges/dto/update-edge.dto.ts +++ b/apps/server/src/edges/dto/update-edge.dto.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { createZodDto } from "nestjs-zod"; import { EdgePropertiesSchema } from "../schemas/edge.schema"; -// kind / source / target immutable — verilirse service ERR_EDGE_IMMUTABLE reddeder. +// kind / source / target are immutable — if provided, the service rejects with ERR_EDGE_IMMUTABLE. export const UpdateEdgeSchema = z.object({ properties: EdgePropertiesSchema.optional(), kind: z.string().optional(), diff --git a/apps/server/src/edges/edges.service.ts b/apps/server/src/edges/edges.service.ts index d0af953..2455580 100644 --- a/apps/server/src/edges/edges.service.ts +++ b/apps/server/src/edges/edges.service.ts @@ -137,7 +137,7 @@ export class EdgesService { updatedAt: input.updatedAt ?? now, properties: input.properties, }; - // repo.create idempotent (apoc.merge) + endpoint'leri atomik MATCH eder. + // repo.create is idempotent (apoc.merge) and MATCHes the endpoints atomically. // null -> endpoint(s) deleted between check and create (race). const persisted = await this.repo.create(stored); if (!persisted) { diff --git a/apps/server/src/edges/schemas/edge.schema.spec.ts b/apps/server/src/edges/schemas/edge.schema.spec.ts index 55ff9b3..16f3e7b 100644 --- a/apps/server/src/edges/schemas/edge.schema.spec.ts +++ b/apps/server/src/edges/schemas/edge.schema.spec.ts @@ -13,7 +13,7 @@ const validEdge = { }; describe("EdgeSchema", () => { - it("parses valid edge'i", () => { + it("parses a valid edge", () => { const e = EdgeSchema.parse(validEdge); expect(e.kind).toBe("CALLS"); }); @@ -25,7 +25,7 @@ describe("EdgeSchema", () => { expect(EDGE_KINDS).toContain("ROUTES_TO"); }); - it("Bilinmeyen kind reddeder", () => { + it("rejects unknown kind", () => { expect(() => EdgeSchema.parse({ ...validEdge, kind: "FOO" })).toThrow(); }); @@ -33,7 +33,7 @@ describe("EdgeSchema", () => { expect(() => EdgeSchema.parse({ ...validEdge, sourceNodeId: "abc" })).toThrow(); }); - it("properties.IsAsync zorunlu", () => { + it("requires properties.IsAsync", () => { expect(() => EdgeSchema.parse({ ...validEdge, properties: { Label: "x" } as any, @@ -49,12 +49,12 @@ describe("EdgeSchema", () => { }); describe("EdgePropertiesSchema", () => { - it("Protocol opsiyonel + enum", () => { + it("Protocol is optional and enum-validated", () => { expect(EdgePropertiesSchema.parse({ IsAsync: true, Protocol: "HTTP" }).Protocol).toBe("HTTP"); expect(() => EdgePropertiesSchema.parse({ IsAsync: true, Protocol: "FTP" })).toThrow(); }); - it("RetryCount negatif olamaz", () => { + it("rejects negative RetryCount", () => { expect(() => EdgePropertiesSchema.parse({ IsAsync: true, RetryCount: -1 })).toThrow(); }); }); diff --git a/apps/server/src/embeddings/embeddings.factory.ts b/apps/server/src/embeddings/embeddings.factory.ts index 899c48e..3c8c1cc 100644 --- a/apps/server/src/embeddings/embeddings.factory.ts +++ b/apps/server/src/embeddings/embeddings.factory.ts @@ -1,17 +1,17 @@ import { OpenAIEmbeddings } from "@langchain/openai"; import { env } from "../config/env"; -/** local → always ready (offline ONNX). bedrock → key + base URL required. */ +/** local → always available (offline ONNX). bedrock → requires an API key and a base URL. */ export function embeddingsConfigured(): boolean { if (env.EMBED_PROVIDER === "local") return true; return !!(env.BEDROCK_API_KEY && env.BEDROCK_BASE_URL); } -/** bedrock-mantle OpenAI-compatible /embeddings. It does not currently offer a mantle embed model; - * ileride sunarsa EMBED_PROVIDER=bedrock + EMBED_MODEL ile devreye girer. */ +/** bedrock-mantle OpenAI-compatible /embeddings. Mantle does not offer an embedding model yet; + * once it does, enable this path with EMBED_PROVIDER=bedrock + EMBED_MODEL. */ export function makeBedrockEmbedder(): OpenAIEmbeddings { if (!env.BEDROCK_API_KEY || !env.BEDROCK_BASE_URL) { - throw new Error("BEDROCK_API_KEY ve BEDROCK_BASE_URL gerekli (embeddings=bedrock)."); + throw new Error("BEDROCK_API_KEY and BEDROCK_BASE_URL are required when EMBED_PROVIDER=bedrock."); } return new OpenAIEmbeddings({ model: env.EMBED_MODEL, diff --git a/apps/server/src/embeddings/embeddings.service.spec.ts b/apps/server/src/embeddings/embeddings.service.spec.ts index 04e7305..ffe7047 100644 --- a/apps/server/src/embeddings/embeddings.service.spec.ts +++ b/apps/server/src/embeddings/embeddings.service.spec.ts @@ -2,11 +2,11 @@ import { describe, it, expect } from "vitest"; import { EmbeddingsService } from "./embeddings.service"; describe("EmbeddingsService", () => { - it("local provider'da isConfigured true", () => { + it("isConfigured returns true for the local provider", () => { expect(new EmbeddingsService().isConfigured()).toBe(true); }); -it("embed converts local extractor output to number[]", async () => { + it("embed converts local extractor output to number[]", async () => { const svc = new EmbeddingsService(); (svc as any).extractorPromise = Promise.resolve( async () => ({ data: new Float32Array([0.1, 0.2, 0.3]) }), @@ -16,7 +16,7 @@ it("embed converts local extractor output to number[]", async () => { expect(vec[0]).toBeCloseTo(0.1, 5); }); - it("embedBatch her metni embed eder", async () => { + it("embedBatch embeds every text", async () => { const svc = new EmbeddingsService(); (svc as any).extractorPromise = Promise.resolve( async () => ({ data: new Float32Array([1, 0]) }), diff --git a/apps/server/src/embeddings/embeddings.service.ts b/apps/server/src/embeddings/embeddings.service.ts index 089840c..b2f34f2 100644 --- a/apps/server/src/embeddings/embeddings.service.ts +++ b/apps/server/src/embeddings/embeddings.service.ts @@ -3,7 +3,7 @@ import { env } from "../config/env"; import { embeddingsConfigured, makeBedrockEmbedder } from "./embeddings.factory"; import type { IEmbeddings } from "./embeddings.types"; -/** @xenova/transformers feature-extraction pipeline a callable function. */ +/** Callable shape of the @xenova/transformers feature-extraction pipeline. */ type Extractor = (text: string, opts: { pooling: "mean"; normalize: boolean }) => Promise<{ data: Float32Array }>; @Injectable() @@ -16,11 +16,11 @@ export class EmbeddingsService implements IEmbeddings { return embeddingsConfigured(); } -/** Lazy load the local ONNX model (~1sec on first call, then cache). */ + /** Lazily loads the local ONNX model (~1 s on first call, cached afterwards). */ private localExtractor(): Promise { if (!this.extractorPromise) { this.extractorPromise = (async () => { -// dynamic import: @xenova/transformers ESM/CJS interop is safe in CJS build. + // Dynamic import keeps @xenova/transformers ESM/CJS interop safe in the CJS build. const { pipeline } = await import("@xenova/transformers"); this.logger.log(`Loading local embedder: ${env.EMBED_MODEL}`); return (await pipeline("feature-extraction", env.EMBED_MODEL)) as unknown as Extractor; diff --git a/apps/server/src/embeddings/embeddings.types.ts b/apps/server/src/embeddings/embeddings.types.ts index 8a0d0ba..4d3c971 100644 --- a/apps/server/src/embeddings/embeddings.types.ts +++ b/apps/server/src/embeddings/embeddings.types.ts @@ -1,5 +1,5 @@ -/** DI token + interface — patterns service connects to this abstraction, - * testlerde fake embedder ile override edilir. */ +/** DI token + interface — the patterns service depends on this abstraction; + * tests override it with a fake embedder. */ export const EMBEDDINGS = Symbol("EMBEDDINGS"); export interface IEmbeddings { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 3fde37c..08f484d 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,5 +1,5 @@ import "reflect-metadata"; -// Load .env file FIRST from config/env.ts import (env.ts parses at boot time) +// Load .env before the config/env import below — env.ts parses process.env at module load time. import "dotenv/config"; import { NestFactory } from "@nestjs/core"; import type { NestExpressApplication } from "@nestjs/platform-express"; @@ -52,29 +52,32 @@ Any node→edge→node connection that is not explicitly specified is **forbidde `; async function bootstrap() { -// bodyParser:false → close default 100kb parser; below is single 1mb parser -// we set up (otherwise the two parsers will be chained, the default limit remains valid). -// rawBody:true is preserved (derives from core appOptions.rawBody) → webhook signature works. + // bodyParser: false disables the default 100 KB body parser; we register a single 1 MB + // parser below. (Otherwise both parsers would run and the default limit would still apply.) + // rawBody: true still takes effect — useBodyParser reads it from the app options — so + // webhook signature verification keeps working. const app = await NestFactory.create(AppModule, { rawBody: true, bodyParser: false, }); -// Get real client IP from X-Forwarded-For behind reverse proxy (Caddy/nginx) -// → rate-limit IP fallback works based on the real IP, not the proxy IP. + // Behind a reverse proxy (Caddy/nginx), take the client IP from X-Forwarded-For so the + // rate limiter's IP fallback keys on the real client IP, not the proxy's. app.set("trust proxy", 1); -// Security headers. CSP off: it's a JSON API + Scalar docs (inline -// script/style) page; It breaks the CSP docs UI, JSON is meaningless in responses. + // Security headers. CSP is disabled: this serves a JSON API plus the Scalar docs page + // (which uses inline script/style) — CSP would break the docs UI and adds nothing for + // JSON responses. app.use(helmet({ contentSecurityPolicy: false })); -// Body size limit — prevent memory DoS with unlimited JSON/batch. -// 1mb: chat history (≤50×8000 char) + large graph apply fits comfortably; MB abuse stops. + // Body size limit — prevents memory DoS from unbounded JSON/batch payloads. 1 MB fits + // chat history (≤50×8000 chars) and large graph applies comfortably while blocking + // multi-megabyte abuse. app.useBodyParser("json", { limit: "1mb" }); app.useBodyParser("urlencoded", { limit: "1mb", extended: true }); app.setGlobalPrefix("api/v1"); app.enableCors({ origin: env.CORS_ORIGIN, credentials: true }); -// Report missing env values ​​one by one (which feature does not work and why). + // Warn about each missing env value (naming the feature it disables and why). warnMissingEnv(); -// nestjs-zod global pipe — Gets automatic Zod schema from DTO class, -// Swagger Module also recognizes DTO metadata with this pipe. + // nestjs-zod global pipe — validates DTO classes against their Zod schemas automatically; + // SwaggerModule also picks up DTO metadata through this pipe. app.useGlobalPipes(new ZodPipe()); app.useGlobalFilters( new InternalFilter(), @@ -85,7 +88,7 @@ async function bootstrap() { new SchemaErrorFilter(), ); -// OpenAPI + Scalar dev/test only — do not leak full API surface in production. + // OpenAPI + Scalar docs in dev/test only — don't expose the full API surface in production. if (env.NODE_ENV !== "production") { const config = new DocumentBuilder() .setTitle("Solarch Backend API") @@ -117,8 +120,8 @@ async function bootstrap() { ); } -// Run Nest onModuleDestroy chain in SIGTERM/SIGINT (Neo4j driver.close) + -// Let the HTTP server gracefully shut down. NO manual signal handler required. + // On SIGTERM/SIGINT, run Nest's onModuleDestroy chain (closes the Neo4j driver) and let + // the HTTP server shut down gracefully. No manual signal handlers needed. app.enableShutdownHooks(); // Bind to env.HOST (default 127.0.0.1): on a single box only the local reverse proxy @@ -133,9 +136,9 @@ async function bootstrap() { bootstrap(); -/** Resolves each $ref in the OpenAPI document and replaces it with an inline schema. -* Then add components.schemas so that the Scalar Models panel remains empty. -* we can safely delete it. */ +/** Resolves every $ref in the OpenAPI document, replacing it with an inline copy of the + * schema. Once nothing references components.schemas, the caller can safely empty it, + * which keeps the Scalar Models panel from being populated. */ function inlineAllRefs(doc: any): void { const schemas = doc.components?.schemas ?? {}; const resolve = (ref: string): unknown => { diff --git a/apps/server/src/neo4j/migrations/004_node_version.cypher b/apps/server/src/neo4j/migrations/004_node_version.cypher index dac843c..ba641ee 100644 --- a/apps/server/src/neo4j/migrations/004_node_version.cypher +++ b/apps/server/src/neo4j/migrations/004_node_version.cypher @@ -1,4 +1,4 @@ -// Optimistic concurrency: mevcut tum node'lara baslangic version'u (idempotent). -// NOT: yorum '//' ile (Neo4j inline comment); '--' kullanma — runner '--' ile -// baslayan statement chunk'ini atlar, backfill calismadan gecer. +// Optimistic concurrency: give all existing nodes an initial version (idempotent). +// NOTE: comments must use '//' (Neo4j inline comment); do not use '--' — the runner +// skips statement chunks that start with '--', so the backfill would never run. MATCH (n:Node) WHERE n.version IS NULL SET n.version = 1; diff --git a/apps/server/src/neo4j/migrations/data/002-enrich-faz-b.ts b/apps/server/src/neo4j/migrations/data/002-enrich-faz-b.ts index 2face96..6d79a1e 100644 --- a/apps/server/src/neo4j/migrations/data/002-enrich-faz-b.ts +++ b/apps/server/src/neo4j/migrations/data/002-enrich-faz-b.ts @@ -86,7 +86,7 @@ function enrich(kind: string, p: any): any { }; } if (kind === "MessageQueue") { - return { ...p }; // yeni alanlar opsiyonel + return { ...p }; // new fields are optional } if (kind === "APIGateway") { return { ...p, Routes: p.Routes ?? [] }; diff --git a/apps/server/src/neo4j/migrations/data/005-tabs.ts b/apps/server/src/neo4j/migrations/data/005-tabs.ts index 76082f3..56f18a5 100644 --- a/apps/server/src/neo4j/migrations/data/005-tabs.ts +++ b/apps/server/src/neo4j/migrations/data/005-tabs.ts @@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto"; import { Neo4jService } from "../../neo4j.service"; import { env } from "../../../config/env"; -/** Her projeye default "Ana Mimari" sekmesi + her node'a homeTabId backfill. - * Idempotent — node.position KORUNUR. */ +/** Creates a default "Main Architecture" tab for every project and backfills homeTabId + * on every node. Idempotent — node.position is preserved. */ async function main(): Promise { const svc = new Neo4jService({ uri: env.NEO4J_URI, user: env.NEO4J_USER, password: env.NEO4J_PASSWORD }); await svc.onModuleInit(); @@ -41,7 +41,7 @@ async function main(): Promise { } await svc.onModuleDestroy(); - console.log(`✓ Tabs migration: ${tabs} default sekme, ${backfilled} node homeTabId backfill.`); + console.log(`✓ Tabs migration: ${tabs} default tabs created, ${backfilled} nodes backfilled with homeTabId.`); } main().catch((e) => { diff --git a/apps/server/src/neo4j/neo4j.service.spec.ts b/apps/server/src/neo4j/neo4j.service.spec.ts index 90dcbd4..82afe72 100644 --- a/apps/server/src/neo4j/neo4j.service.spec.ts +++ b/apps/server/src/neo4j/neo4j.service.spec.ts @@ -26,7 +26,7 @@ it("ping works (returns 1)", async () => { expect(result.records[0].get("n")).toBe(1); }); - it("ping() readiness — DB ayaktayken resolve eder", async () => { + it("ping() readiness — resolves while the DB is up", async () => { await expect(service.ping()).resolves.toBeUndefined(); }); diff --git a/apps/server/src/neo4j/neo4j.service.ts b/apps/server/src/neo4j/neo4j.service.ts index 323dd95..365e045 100644 --- a/apps/server/src/neo4j/neo4j.service.ts +++ b/apps/server/src/neo4j/neo4j.service.ts @@ -5,7 +5,7 @@ export interface Neo4jConfig { uri: string; user: string; password: string; - // Connection pool / timeout (opsiyonel — verilmezse makul launch default'u). + // Connection pool / timeout (optional — sensible launch defaults when unset). maxConnectionPoolSize?: number; connectionAcquisitionTimeout?: number; connectionTimeout?: number; diff --git a/apps/server/src/nodes/schemas/api-gateway.schema.spec.ts b/apps/server/src/nodes/schemas/api-gateway.schema.spec.ts index efd4eb9..2515a99 100644 --- a/apps/server/src/nodes/schemas/api-gateway.schema.spec.ts +++ b/apps/server/src/nodes/schemas/api-gateway.schema.spec.ts @@ -45,7 +45,7 @@ describe("APIGatewayNodeSchema (enriched)", () => { expect(() => parse({ ...validProperties, Routes: [{ Path: "/x", TargetRef: "C", Methods: [] }] })).toThrow(); }); - it("Description zorunlu", () => { + it("requires Description", () => { const { Description, ...rest } = validProperties; expect(() => parse(rest)).toThrow(); }); diff --git a/apps/server/src/nodes/schemas/dto.schema.spec.ts b/apps/server/src/nodes/schemas/dto.schema.spec.ts index 51ef91c..25b57ce 100644 --- a/apps/server/src/nodes/schemas/dto.schema.spec.ts +++ b/apps/server/src/nodes/schemas/dto.schema.spec.ts @@ -32,7 +32,7 @@ describe("DTONodeSchema (enriched)", () => { expect(node.properties.Fields[1].ValidationRules).toEqual([]); }); - it("ValidationRule Value (MinLength) kabul eder", () => { + it("accepts ValidationRule Value (MinLength)", () => { const node = parse({ ...validProperties, Fields: [{ Name: "password", DataType: "string", IsRequired: true, IsArray: false, ValidationRules: [{ Rule: "MinLength", Value: "8" }] }], diff --git a/apps/server/src/nodes/schemas/enum.schema.spec.ts b/apps/server/src/nodes/schemas/enum.schema.spec.ts index eea6536..2f62d27 100644 --- a/apps/server/src/nodes/schemas/enum.schema.spec.ts +++ b/apps/server/src/nodes/schemas/enum.schema.spec.ts @@ -28,7 +28,7 @@ describe("EnumNodeSchema (enriched)", () => { expect(node.properties.Values).toHaveLength(3); }); - it("BackingType default string", () => { + it("defaults BackingType to string", () => { const node = parse(validProperties); expect(node.properties.BackingType).toBe("string"); }); @@ -44,7 +44,7 @@ describe("EnumNodeSchema (enriched)", () => { expect(node.properties.Values[1].Description).toBe("Shipped"); }); - it("Description zorunlu", () => { + it("requires Description", () => { const { Description, ...rest } = validProperties; expect(() => parse(rest)).toThrow(); }); diff --git a/apps/server/src/nodes/schemas/message-queue.schema.spec.ts b/apps/server/src/nodes/schemas/message-queue.schema.spec.ts index 8dedf0d..88c845c 100644 --- a/apps/server/src/nodes/schemas/message-queue.schema.spec.ts +++ b/apps/server/src/nodes/schemas/message-queue.schema.spec.ts @@ -21,12 +21,12 @@ const parse = (properties: unknown) => MessageQueueNodeSchema.parse({ ...validBase, type: "MessageQueue", properties }); describe("MessageQueueNodeSchema (enriched)", () => { - it("parses valid MessageQueue'yu", () => { + it("parses a valid MessageQueue", () => { const node = parse(validProperties); expect(node.properties.Provider).toBe("Kafka"); }); - it("teslim garantisi + DLQ + retention kabul eder", () => { + it("accepts delivery guarantee + DLQ + retention", () => { const node = parse({ ...validProperties, DeliveryGuarantee: "exactly-once", @@ -42,11 +42,11 @@ describe("MessageQueueNodeSchema (enriched)", () => { expect(() => parse({ ...validProperties, DeliveryGuarantee: "best-effort" })).toThrow(); }); - it("Bilinmeyen Provider reddeder", () => { + it("rejects unknown Provider", () => { expect(() => parse({ ...validProperties, Provider: "Redis" })).toThrow(); }); - it("Description zorunlu", () => { + it("requires Description", () => { const { Description, ...rest } = validProperties; expect(() => parse(rest)).toThrow(); }); diff --git a/apps/server/src/nodes/schemas/model.schema.spec.ts b/apps/server/src/nodes/schemas/model.schema.spec.ts index cbd5831..36b6272 100644 --- a/apps/server/src/nodes/schemas/model.schema.spec.ts +++ b/apps/server/src/nodes/schemas/model.schema.spec.ts @@ -27,7 +27,7 @@ describe("ModelNodeSchema (enriched)", () => { expect(node.properties.ClassName).toBe("User"); }); - it("Property nullable/collection default false", () => { + it("defaults property IsNullable/IsCollection to false", () => { const node = parse(validProperties); expect(node.properties.Properties[0].IsNullable).toBe(false); expect(node.properties.Properties[0].IsCollection).toBe(false); @@ -74,7 +74,7 @@ describe("ModelNodeSchema (enriched)", () => { expect(m.IsAsync).toBe(true); }); - it("Description zorunlu", () => { + it("requires Description", () => { const { Description, ...rest } = validProperties; expect(() => parse(rest)).toThrow(); }); diff --git a/apps/server/src/nodes/schemas/table.schema.spec.ts b/apps/server/src/nodes/schemas/table.schema.spec.ts index 29f292d..1d3758f 100644 --- a/apps/server/src/nodes/schemas/table.schema.spec.ts +++ b/apps/server/src/nodes/schemas/table.schema.spec.ts @@ -42,7 +42,7 @@ describe("TableNodeSchema (enriched)", () => { expect(node.properties.Indexes).toEqual([]); }); - it("composite PrimaryKey kabul eder", () => { + it("accepts a composite PrimaryKey", () => { const node = parse({ ...validProperties, Columns: [col({ Name: "order_id", IsPrimaryKey: false }), col({ Name: "product_id", IsPrimaryKey: false })], @@ -60,7 +60,7 @@ describe("TableNodeSchema (enriched)", () => { expect(node.properties.ForeignKeys[0].OnUpdate).toBe("NO_ACTION"); }); - it("ForeignKey OnDelete=CASCADE kabul eder", () => { + it("accepts ForeignKey OnDelete=CASCADE", () => { const node = parse({ ...validProperties, ForeignKeys: [{ Columns: ["org_id"], ReferencesTable: "orgs", ReferencesColumns: ["id"], OnDelete: "CASCADE" }], @@ -106,7 +106,7 @@ describe("TableNodeSchema (enriched)", () => { expect(node.properties.Indexes[0].IsUnique).toBe(false); }); - it("CheckConstraint kabul eder", () => { + it("accepts a CheckConstraint", () => { const node = parse({ ...validProperties, CheckConstraints: [{ Name: "age_chk", Expression: "age >= 0" }], diff --git a/apps/server/src/patterns/patterns.repository.ts b/apps/server/src/patterns/patterns.repository.ts index c7be065..bea0d47 100644 --- a/apps/server/src/patterns/patterns.repository.ts +++ b/apps/server/src/patterns/patterns.repository.ts @@ -31,10 +31,10 @@ export class PatternsRepository { ); } -// SECURITY (inter-tenant BOLA): reading surface canonical 'seed' patterns ONLY -//returns. Since promoted (user) patterns are not stamped to the tenant -// does not leak out of any read path (list/getById/search) + to AI prompt - // (search RAG) zehirli pattern enjekte edilemez. + // SECURITY (cross-tenant BOLA): every read path (list/getById/search) is scoped to + // canonical 'seed' patterns only. User-promoted patterns carry no tenant stamp, so + // excluding them here means they can never leak to another tenant — and a poisoned + // pattern can never be injected into AI prompts via search (RAG retrieval). async list(): Promise { const res = await this.neo4j.run( `MATCH (p:Pattern {source: 'seed'}) RETURN p ORDER BY p.createdAt DESC`, @@ -64,7 +64,7 @@ export class PatternsRepository { return res.records.length > 0; } -/** Native vector search: cosine top-K + minScore filter. */ + /** Native vector search: cosine top-K with a minScore filter. */ async search(embedding: number[], k: number, minScore: number): Promise { const res = await this.neo4j.run( `CALL db.index.vector.queryNodes('pattern_embedding', $k, $embedding) diff --git a/apps/server/src/projects/schemas/project.schema.ts b/apps/server/src/projects/schemas/project.schema.ts index 050d504..66b73b8 100644 --- a/apps/server/src/projects/schemas/project.schema.ts +++ b/apps/server/src/projects/schemas/project.schema.ts @@ -10,7 +10,7 @@ export const ProjectSchema = z.object({ status: ProjectStatusSchema, // Ownership / multi-tenancy (ownerId from LocalAuthGuard or API key) ownerId: z.string(), - orgId: z.string().nullable(), // reserved for workspace scoping; null in OSS edition + orgId: z.string().nullable(), // reserved for workspace scoping; null in the self-host edition createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }).strict(); diff --git a/apps/server/test/codegen.e2e-spec.ts b/apps/server/test/codegen.e2e-spec.ts index 9ae36ba..20e48d2 100644 --- a/apps/server/test/codegen.e2e-spec.ts +++ b/apps/server/test/codegen.e2e-spec.ts @@ -196,7 +196,7 @@ describe("Codegen E2E (POST /projects/:id/codegen)", () => { // Verify that core files have been generated (ARCHITECTURE-AWARE feature layout). const paths: string[] = data.files.map((f: { path: string }) => f.path); -// Idiomatic names: role suffix is ​​NOT repeated in filename + feature folder. +// Idiomatic names: role suffix is NOT repeated in filename + feature folder. expect(paths).toContain("src/users/users.controller.ts"); expect(paths).toContain("src/users/users.service.ts"); expect(paths).toContain("src/users/user.repository.ts"); @@ -205,7 +205,7 @@ describe("Codegen E2E (POST /projects/:id/codegen)", () => { expect(paths.some((p) => p.endsWith(".sql"))).toBe(true); expect(paths).toContain("package.json"); expect(paths).toContain("src/app.module.ts"); -// Per-node parent folder NONE (old disjoint structure like "src/users-controller/..." is gone). + // No per-node parent folders (the old disjoint structure like "src/users-controller/..." is gone). expect(paths.some((p) => /\/users-controller\//.test(p))).toBe(false); expect(paths.some((p) => /\/create-user-dto\//.test(p))).toBe(false); diff --git a/apps/server/test/tabs.e2e-spec.ts b/apps/server/test/tabs.e2e-spec.ts index 796698a..09721cf 100644 --- a/apps/server/test/tabs.e2e-spec.ts +++ b/apps/server/test/tabs.e2e-spec.ts @@ -52,7 +52,7 @@ describe("Tabs E2E", () => { await container.stop(); }); -it("When the project is opened, the Main Architecture tab appears", async () => { + it("creates the default Main Architecture tab with a new project", async () => { const p = await request(app.getHttpServer()).post(`${base}/projects`).send({ name: "Tab E2E" }).expect(201); projectId = p.body.data.id; const tabs = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs`).expect(200); @@ -61,7 +61,7 @@ it("When the project is opened, the Main Architecture tab appears", async () => expect(tabs.body.data[0].name).toBe("Main Architecture"); }); -it("node hosts the tab by default, the tab appears owned in the graph", async () => { + it("homes a new node in the default tab and shows it as owned in that tab's graph", async () => { const n = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/nodes`).send({ projectId, position: { x: 10, y: 20 }, type: "Service", properties: { ServiceName: "OrderSvc", Description: "d", IsTransactionScoped: false, Methods: [{ MethodName: "x", ReturnType: "void" }] }, @@ -75,8 +75,8 @@ it("node hosts the tab by default, the tab appears owned in the graph", async () expect(g.body.data.nodes[0].position).toEqual({ x: 10, y: 20 }); }); - it("yeni sekme + node import (referans) round-trip", async () => { -const t = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/tabs`).send({ name: "Order" }).expect(201); + it("round-trips a new tab + node import (reference)", async () => { + const t = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/tabs`).send({ name: "Order" }).expect(201); const tabId = t.body.data.id; await request(app.getHttpServer()).put(`${base}/projects/${projectId}/tabs/${tabId}/references/${nodeId}`).send({ x: 99, y: 88 }).expect(200); const g = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs/${tabId}/graph`).expect(200); @@ -88,20 +88,20 @@ const t = await request(app.getHttpServer()).post(`${base}/projects/${projectId} expect(g2.body.data.nodes).toHaveLength(0); }); - it("node kendi ev sekmesine referans edilemez (400)", async () => { + it("rejects referencing a node into its own home tab (400)", async () => { const tabs = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs`).expect(200); const defId = tabs.body.data.find((t: any) => t.isDefault).id; await request(app.getHttpServer()).put(`${base}/projects/${projectId}/tabs/${defId}/references/${nodeId}`).send({ x: 1, y: 1 }).expect(400); }); - it("default sekme silinemez (400)", async () => { + it("rejects deleting the default tab (400)", async () => { const tabs = await request(app.getHttpServer()).get(`${base}/projects/${projectId}/tabs`).expect(200); const defId = tabs.body.data.find((t: any) => t.isDefault).id; await request(app.getHttpServer()).delete(`${base}/projects/${projectId}/tabs/${defId}`).expect(400); }); -it("when the tab is deleted, the owned node is moved to the Main Architecture, the node is not lost", async () => { -const t = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/tabs`).send({ name: "Temporary" }).expect(201); + it("moves owned nodes to Main Architecture when their tab is deleted, so no node is lost", async () => { + const t = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/tabs`).send({ name: "Temporary" }).expect(201); const tabId = t.body.data.id; const n = await request(app.getHttpServer()).post(`${base}/projects/${projectId}/nodes`).send({ projectId, position: { x: 1, y: 1 }, homeTabId: tabId, type: "Cache", diff --git a/apps/web/README.md b/apps/web/README.md index 536fda9..25cff6d 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,4 +1,4 @@ -# Solarch Web (OSS) +# Solarch Web (self-host) Architecture builder UI for the self-hosted Solarch monorepo. Custom Canvas 2D engine, rule-driven interactions, and a natural-language AI OmniBar — no login screen. diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index ef614d2..211d9d3 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -18,5 +18,18 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + // eslint-plugin-react-hooks v7 (React Compiler rules) + react-refresh surface many + // pre-existing advisories that are not runtime bugs (the app is not React-Compiler-compiled). + // Keep them visible as warnings so CI stays green; clean them up incrementally. + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/refs': 'warn', + 'react-hooks/static-components': 'warn', + 'react-hooks/purity': 'warn', + 'react-hooks/immutability': 'warn', + 'react-hooks/exhaustive-deps': 'warn', + 'react-refresh/only-export-components': 'warn', + '@typescript-eslint/no-unused-vars': 'warn', + }, }, ]) diff --git a/apps/web/package.json b/apps/web/package.json index d713d51..f1b0546 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,9 +28,6 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.100.12", - "@zxcvbn-ts/core": "^3.0.4", - "@zxcvbn-ts/language-common": "^3.0.4", - "@zxcvbn-ts/language-en": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 9154216..f32852c 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -4,7 +4,7 @@ import { API_URL } from "../lib/env"; const BASE_URL = API_URL; -/** Typed openapi-fetch client. OSS local mode: no auth headers (backend injects owner identity). */ +/** Typed openapi-fetch client. Self-host local mode: no auth headers (backend injects owner identity). */ export const api = createClient({ baseUrl: BASE_URL, credentials: "include" }); /** Solarch envelope: { success, data } | { success:false, error }. */ diff --git a/apps/web/src/state/theme.ts b/apps/web/src/state/theme.ts index 2a1ce0f..fbf5a8c 100644 --- a/apps/web/src/state/theme.ts +++ b/apps/web/src/state/theme.ts @@ -9,7 +9,7 @@ * - resolved: theme actually applied — "light" | "dark" (resolved from OS when system). * - setMode: user choice (writes to localStorage + applies). * - cycle: System → Light → Dark cycle for the menu toggle. - * - hydrate: reserved for future per-user sync (OSS uses localStorage only). + * - hydrate: reserved for future per-user sync (self-host uses localStorage only). * - syncSystem: re-resolve when OS preference changes (only when mode==="system"). * * First-paint: the inline script in index.html has already applied the class (no FOUC); on init diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index c2a1f3e..027ede4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -18,9 +18,10 @@ export default defineConfig({ }, server: { // host:true → expose dev server on the LAN (reach http://:5173 from a phone on the same network). - // allowedHosts:true → allow tunnel hostnames (cloudflared/ngrok) in dev. + // Tunnel hostnames (cloudflared/ngrok) are opt-in: VITE_ALLOW_ALL_HOSTS=true. Allowing all + // hosts unconditionally would make the dev server vulnerable to DNS rebinding. host: true, - allowedHosts: true, + ...(process.env.VITE_ALLOW_ALL_HOSTS === "true" ? { allowedHosts: true as const } : {}), proxy: { "/api": { target: "http://127.0.0.1:4000", changeOrigin: true }, }, diff --git a/assets/2.gif b/assets/2.gif deleted file mode 100644 index 4ed965f..0000000 Binary files a/assets/2.gif and /dev/null differ diff --git a/assets/3.gif b/assets/3.gif deleted file mode 100644 index d27b25f..0000000 Binary files a/assets/3.gif and /dev/null differ diff --git a/assets/4.gif b/assets/4.gif deleted file mode 100644 index bb60f4e..0000000 Binary files a/assets/4.gif and /dev/null differ diff --git a/assets/logo-1779724213346.svg b/assets/logo.svg similarity index 100% rename from assets/logo-1779724213346.svg rename to assets/logo.svg diff --git a/deploy/Caddyfile b/deploy/Caddyfile deleted file mode 100644 index 59e2ded..0000000 --- a/deploy/Caddyfile +++ /dev/null @@ -1,17 +0,0 @@ -# Single-origin front door for the self-host bundle. -# Production Docker uses deploy/Caddyfile.base + web-entrypoint.sh (optional Basic Auth). -:3000 { - encode zstd gzip - - # API → backend (the NestJS global prefix is /api/v1, so /api/* covers it). - handle /api/* { - reverse_proxy server:4000 - } - - # SPA static assets + client-side routing fallback. - handle { - root * /srv - try_files {path} /index.html - file_server - } -} diff --git a/deploy/web-entrypoint.sh b/deploy/web-entrypoint.sh index 518c1d2..01e8a73 100755 --- a/deploy/web-entrypoint.sh +++ b/deploy/web-entrypoint.sh @@ -6,8 +6,15 @@ CADDYFILE="/etc/caddy/Caddyfile" BASE="/etc/caddy/Caddyfile.base" AUTH_SNIPPET="/etc/caddy/Caddyfile.auth.snippet" -if [ "${BIND_ADDRESS:-127.0.0.1}" = "0.0.0.0" ] && [ -z "${SOLARCH_BASIC_AUTH_USER:-}" ]; then - echo "WARN: BIND_ADDRESS=0.0.0.0 but SOLARCH_BASIC_AUTH_* is not set — instance is open to the network." >&2 +# Fail closed: a network-exposed instance (0.0.0.0) MUST have COMPLETE Basic Auth. Otherwise the +# app's LocalAuthGuard treats every anonymous request as the owner — i.e. full CRUD to the network. +if [ "${BIND_ADDRESS:-127.0.0.1}" = "0.0.0.0" ]; then + if [ -z "${SOLARCH_BASIC_AUTH_USER:-}" ] || [ -z "${SOLARCH_BASIC_AUTH_HASH:-}" ]; then + echo "FATAL: BIND_ADDRESS=0.0.0.0 (network-exposed) but SOLARCH_BASIC_AUTH_USER/HASH is not fully set." >&2 + echo " Refusing to start an unauthenticated, network-exposed instance." >&2 + echo " Re-run ./install.sh --reconfigure and choose remote exposure, or set BIND_ADDRESS=127.0.0.1." >&2 + exit 1 + fi fi if [ -n "${SOLARCH_BASIC_AUTH_USER:-}" ] && [ -n "${SOLARCH_BASIC_AUTH_HASH:-}" ]; then diff --git a/docker-compose.yml b/docker-compose.yml index 1001bcb..f77cf74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,12 @@ services: neo4j: condition: service_healthy restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:4000/api/v1/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] + interval: 10s + timeout: 5s + retries: 30 + start_period: 40s web: build: @@ -54,7 +60,8 @@ services: SOLARCH_BASIC_AUTH_USER: ${SOLARCH_BASIC_AUTH_USER:-} SOLARCH_BASIC_AUTH_HASH: ${SOLARCH_BASIC_AUTH_HASH:-} depends_on: - - server + server: + condition: service_healthy restart: unless-stopped volumes: diff --git a/docs/README.md b/docs/README.md index 15c702a..8a31d36 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Solarch documentation -English documentation for the **Solarch OSS** self-host edition. +English documentation for the **Solarch** self-host edition. ## Read this first diff --git a/docs/ai-architect.md b/docs/ai-architect.md index 84b9d4f..a29f015 100644 --- a/docs/ai-architect.md +++ b/docs/ai-architect.md @@ -1,7 +1,7 @@ # AI Architect Solarch's AI runs **on your server** with **your API key**. There is no Solarch-hosted model in -the OSS edition. The frontend is provider-agnostic — switching models is a `.env` change. +the self-host edition. The frontend is provider-agnostic — switching models is a `.env` change. See [AI providers](ai-providers.md) for configuration. This document explains *how* AI is used. @@ -62,8 +62,8 @@ drift from enforcement. Agent responses stream to the UI (SSE). Tool results and progress events interleave with text. Instruct mode streams free-form markdown answers. -Rate limits: global throttling plus tighter limits on AI endpoints (`AI_THROTTLE_LIMIT` in -`.env`). See [Self-hosting](self-hosting.md). +Rate limits: global throttling (60 req/min) plus a tighter fixed limit on AI endpoints +(20 req/min). See [Self-hosting](self-hosting.md). ## Failure behavior @@ -73,7 +73,7 @@ Rate limits: global throttling plus tighter limits on AI endpoints (`AI_THROTTLE | Rule rejection | Tool error returned to model; user sees progress, not a hard crash | | Max turns reached | Paused state; user can Continue | -OSS has **no metering** — usage is limited only by your provider quota and hardware. +The self-host edition has **no metering** — usage is limited only by your provider quota and hardware. ## Tips for reliable Agent output diff --git a/docs/ai-providers.md b/docs/ai-providers.md index f6ae449..330def0 100644 --- a/docs/ai-providers.md +++ b/docs/ai-providers.md @@ -19,7 +19,8 @@ If the active provider has no key, the AI endpoints return `503` and the rest of working. **Provider choice is required** — set `LLM_GENERATION_PROVIDER` and `LLM_CHAT_PROVIDER` plus the -matching API key in `.env`. There is no baked-in default in code; `./install.sh` starts with OpenAI. +matching API key in `.env`; the server refuses to start without them. Each provider has a default +model (see the table below) and `LLM_MODEL` overrides it. `./install.sh` starts with OpenAI. How Agent and Instruct use these providers: [AI Architect](ai-architect.md). @@ -31,8 +32,8 @@ provider's key. Optionally set `LLM_MODEL` to override the default model. | Provider id | Env key(s) | Default model | Tool calling | Notes | |---|---|---|---|---| | `openai` | `OPENAI_API_KEY` (`OPENAI_BASE_URL` for Azure) | `gpt-4o` | ✓ | | -| `anthropic` | `ANTHROPIC_API_KEY` | `claude-3-5-sonnet-latest` | ✓ | Claude | -| `google` | `GOOGLE_API_KEY` | `gemini-1.5-pro` | ✓ | Gemini | +| `anthropic` | `ANTHROPIC_API_KEY` | `claude-sonnet-4-6` | ✓ | Claude | +| `google` | `GOOGLE_API_KEY` | `gemini-3.5-flash` | ✓ | Gemini | | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek-v4-pro` / `-flash` | ✓ | Proven tool calling | | `mistral` | `MISTRAL_API_KEY` | `mistral-large-latest` | ✓ | | | `groq` | `GROQ_API_KEY` | `llama-3.3-70b-versatile` | ✓ | Fast inference | @@ -57,4 +58,4 @@ inside Docker, point at the host: `OLLAMA_BASE_URL=http://host.docker.internal:1 ## Usage limits -The OSS edition has **no metering** — AI and code generation are unlimited, bounded only by your LLM provider quota and hardware. +The self-host edition has **no metering** — AI and code generation are unlimited, bounded only by your LLM provider quota and hardware. diff --git a/docs/architecture.md b/docs/architecture.md index f63b430..6091f78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,7 @@ sequenceDiagram **Codegen path:** read graph → IR → emit files → optional CLI fill stream. -## Auth model (OSS) +## Auth model (self-host) No third-party auth or billing. `LocalAuthGuard` assigns `LOCAL_USER_ID` to browser traffic; CLI presents `slk_*` keys. See [CLI & API keys](cli-and-api-keys.md). diff --git a/docs/canvas-and-rules.md b/docs/canvas-and-rules.md index dfb716c..0373d20 100644 --- a/docs/canvas-and-rules.md +++ b/docs/canvas-and-rules.md @@ -85,6 +85,7 @@ Each rejection includes a **suggestion** string the AI Architect uses to self-co |------|---------| | `ERR_COND_001` | Circular dependency on Service → CALLS → Service | | `ERR_COND_002` | Controller call parameter mismatch vs Service RequestDTO | +| `WARN_COND_001` | Empty schema — a Table/DTO/Model has no fields yet (warning, non-blocking) | ## Other common errors diff --git a/docs/cli-and-api-keys.md b/docs/cli-and-api-keys.md index 351f4b5..0401a04 100644 --- a/docs/cli-and-api-keys.md +++ b/docs/cli-and-api-keys.md @@ -1,6 +1,6 @@ # CLI & API keys -The OSS web app has **no login**. Automation (CLI, MCP, scripts) uses **personal API keys** +The self-host web app has **no login**. Automation (CLI, MCP, scripts) uses **personal API keys** prefixed with `slk_`. ## Browser vs CLI identity @@ -16,7 +16,7 @@ Keys are stored as SHA-256 hashes in Neo4j; plaintext is shown **once** at creat 1. Open the app → **Settings**. 2. **Create API key** — copy the `slk_…` value immediately. -3. Limit: 10 keys per local owner (OSS single-user instance). +3. Limit: 10 keys per local owner (single-user self-host instance). Manage keys: `POST/GET/DELETE /api/v1/api-keys` (authenticated as local owner or via existing key). diff --git a/docs/codegen.md b/docs/codegen.md index 24135f4..5e33b46 100644 --- a/docs/codegen.md +++ b/docs/codegen.md @@ -7,7 +7,7 @@ Codegen turns a **validated architecture graph** into a **NestJS project**. Two 2. **Surgical AI fill** — optional LLM pass that fills **method bodies** only, guided by graph metadata and file regions (via `@solarch/cli` subprocess). -The OSS edition has **no usage caps** on codegen or fill. +The self-host edition has **no usage caps** on codegen or fill. ## Opening Code mode diff --git a/docs/deployment.md b/docs/deployment.md index f4fe947..a7979f2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Deployment -Production layout for a **single-box** Solarch OSS install: one machine, one public hostname, +Production layout for a **single-box** Solarch self-host install: one machine, one public hostname, Neo4j + NestJS + static SPA. Docker Compose (`docker compose up`) is enough for many teams. This guide covers a **bare-metal diff --git a/docs/development.md b/docs/development.md index 7b19c19..9f287ca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -81,7 +81,7 @@ Vitest injects minimal `NEO4J_*` and `LLM_*` env for imports — see `vitest.con pnpm build # turbo: server + web production builds ``` -There is no separate typecheck script — `next build` / `nest build` enforce types. +There is no separate typecheck script — `vite build` (web) / `nest build` (server) enforce types. ## OpenAPI client sync diff --git a/docs/getting-started.md b/docs/getting-started.md index 3eece4e..6f1f39a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -43,7 +43,7 @@ See [Self-hosting](self-hosting.md) for every env variable. Navigate to **http://localhost:3000**. -There is **no login screen**. The OSS edition assigns a fixed local owner identity +There is **no login screen**. The self-host edition assigns a fixed local owner identity (`LOCAL_USER_ID`, default `local_owner`). Your browser session owns all projects on this instance. You should land on **Welcome** or an existing project and then the **Canvas**. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 27e02e8..00ccc9d 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -1,6 +1,6 @@ # Self-hosting Solarch -Configuration and security for running Solarch OSS on your own infrastructure. +Configuration and security for running the Solarch self-host edition on your own infrastructure. For install steps and a first-run tour, see [Getting started](getting-started.md). @@ -28,7 +28,9 @@ Open **http://localhost:3000** — fixed local owner identity, no login screen. ## Security / exposure -Solarch OSS has **no in-app login**. Security is network-boundary + optional edge auth. +The self-host edition has **no in-app login**. Security is network-boundary + optional edge auth. + +**Threat model:** the self-host edition assumes a single trusted operator on loopback — there is no per-user auth by design. Exposing beyond localhost requires the fail-closed entrypoint's Basic Auth. Multi-tenant isolation is out of scope. Note: the AI has no direct database access — every mutation goes through the same deterministic rules gate as a human, so a prompt injection can at worst propose an edge the engine rejects. | Profile | `BIND_ADDRESS` | Basic auth | Who can reach it | |---|---|---|---| @@ -79,12 +81,10 @@ Details: [CLI & API keys](cli-and-api-keys.md). | `LOCAL_USER_ID` | `local_owner` | Identity for browser sessions | | `PUBLIC_URL` | `http://localhost:3000` | Public URL (CORS) | | `LLM_MODEL` | provider default | Model override | -| `THROTTLE_BY` | `ip` | Rate limit key: `ip` or `user` | -| `THROTTLE_LIMIT` | `60` | Global requests per minute | -| `THROTTLE_TTL_MS` | `60000` | Rate limit window | -| `AI_THROTTLE_LIMIT` | `20` | AI endpoint requests per minute | | `CODEGEN_FILL_THROTTLE_LIMIT` | `10` | Surgical fill requests per minute | +Global rate limiting (60 req/min) and the tighter AI-endpoint limit (20 req/min) are fixed built-in defaults. + Embeddings (GraphRAG) default to local ONNX — see [AI Architect](ai-architect.md). ## API keys (CLI) diff --git a/install.ps1 b/install.ps1 index a760477..77d01f2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,11 +12,11 @@ $ErrorActionPreference = 'Stop' $InstallVersion = '0.1.0' Set-Location $PSScriptRoot -function Write-Brand([string]$Text) { Write-Host $Text -ForegroundColor '#FD6A09' } +function Write-Brand([string]$Text) { Write-Host $Text -ForegroundColor DarkYellow } function Write-Muted([string]$Text) { Write-Host $Text -ForegroundColor DarkGray } function Write-Ok([string]$Text) { Write-Host " ✓ $Text" -ForegroundColor Green } function Write-Fail([string]$Text) { Write-Host " ✗ $Text" -ForegroundColor Red } -function Write-Warn([string]$Text) { Write-Host " ! $Text" -ForegroundColor '#FD6A09' } +function Write-Warn([string]$Text) { Write-Host " ! $Text" -ForegroundColor DarkYellow } function Show-Banner { $logo = @( @@ -259,8 +259,8 @@ $bedrockUrl = ''; $llmUrl = '' switch ($pick) { '1' { $provider='openai'; $k=Read-Secret 'OPENAI_API_KEY'; $keyLines=@("OPENAI_API_KEY=$k"); $modelDefault='gpt-4o' } - '2' { $provider='anthropic'; $k=Read-Secret 'ANTHROPIC_API_KEY'; $keyLines=@("ANTHROPIC_API_KEY=$k"); $modelDefault='claude-3-5-sonnet-latest' } - '3' { $provider='google'; $k=Read-Secret 'GOOGLE_API_KEY'; $keyLines=@("GOOGLE_API_KEY=$k"); $modelDefault='gemini-1.5-pro' } + '2' { $provider='anthropic'; $k=Read-Secret 'ANTHROPIC_API_KEY'; $keyLines=@("ANTHROPIC_API_KEY=$k"); $modelDefault='claude-sonnet-4-6' } + '3' { $provider='google'; $k=Read-Secret 'GOOGLE_API_KEY'; $keyLines=@("GOOGLE_API_KEY=$k"); $modelDefault='gemini-3.5-flash' } '4' { $provider='deepseek'; $k=Read-Secret 'DEEPSEEK_API_KEY'; $keyLines=@("DEEPSEEK_API_KEY=$k"); $askModel=$false } '5' { $provider='mistral'; $k=Read-Secret 'MISTRAL_API_KEY'; $keyLines=@("MISTRAL_API_KEY=$k"); $modelDefault='mistral-large-latest' } '6' { $provider='groq'; $k=Read-Secret 'GROQ_API_KEY'; $keyLines=@("GROQ_API_KEY=$k"); $modelDefault='llama-3.3-70b-versatile' } @@ -368,12 +368,17 @@ if ($exposure -eq '2') { Write-Ok 'Local only (127.0.0.1:3000).' } +# Compose reads .env for ${...} interpolation, which mangles a literal '$' (bcrypt hashes, +# '$'-containing passwords). Double every '$' for interpolated values; API keys reach the server +# via env_file (read literally) so they are NOT escaped. +function Convert-ComposeEscape([string]$v) { $v -replace '\$', '$$$$' } + $lines = @( '# Generated by install.ps1 — do not commit (gitignored).', 'PUBLIC_URL=http://localhost:3000', 'PORT_PUBLIC=3000', "BIND_ADDRESS=$bindAddress", - "NEO4J_PASSWORD=$neo", + "NEO4J_PASSWORD=$(Convert-ComposeEscape $neo)", 'LOCAL_USER_ID=local_owner', "LLM_GENERATION_PROVIDER=$provider", "LLM_CHAT_PROVIDER=$provider" @@ -381,15 +386,20 @@ $lines = @( if (-not [string]::IsNullOrWhiteSpace($model)) { $lines += "LLM_MODEL=$model" } if ($authUser) { - $lines += "SOLARCH_BASIC_AUTH_USER=$authUser" - $lines += "SOLARCH_BASIC_AUTH_HASH=$authHash" + $lines += "SOLARCH_BASIC_AUTH_USER=$(Convert-ComposeEscape $authUser)" + $lines += "SOLARCH_BASIC_AUTH_HASH=$(Convert-ComposeEscape $authHash)" } else { $lines += '# SOLARCH_BASIC_AUTH_USER=' $lines += '# SOLARCH_BASIC_AUTH_HASH=' } $content = ($lines -join "`n") + "`n" -[System.IO.File]::WriteAllText((Join-Path (Get-Location) '.env'), $content, (New-Object System.Text.UTF8Encoding($false))) +$envPath = Join-Path (Get-Location) '.env' +[System.IO.File]::WriteAllText($envPath, $content, (New-Object System.Text.UTF8Encoding($false))) +# Restrict .env to the current user only (parity with `chmod 600` on Unix). +try { + icacls $envPath /inheritance:r /grant:r "$($env:USERNAME):(R,W)" *> $null +} catch { Write-Warn "Could not restrict .env permissions: $($_.Exception.Message)" } Write-Host '' Write-Ok ".env written (provider: $provider)" @@ -403,7 +413,7 @@ if ($script:OldNeo4jPw -and $neo -ne $script:OldNeo4jPw -and (Test-Neo4jVolume)) Invoke-SolarchCompose down -v 2>$null Write-Ok 'Neo4j volume cleared.' } else { - Write-Warn 'Keeping volume — expect auth errors. Fix: .\scripts\solarch-reset-db.ps1' + Write-Warn 'Keeping volume — expect auth errors. Fix: .\scripts\solarch-compose.ps1 down -v' } } diff --git a/install.sh b/install.sh index 720dc5c..320aaa8 100755 --- a/install.sh +++ b/install.sh @@ -92,8 +92,8 @@ PROVIDER=""; KEY_LINES=""; MODEL_DEFAULT=""; ASK_MODEL=1 case "$pick" in 1) PROVIDER=openai; k=$(read_secret "OPENAI_API_KEY"); KEY_LINES="OPENAI_API_KEY=$k"; MODEL_DEFAULT="gpt-4o" ;; - 2) PROVIDER=anthropic; k=$(read_secret "ANTHROPIC_API_KEY"); KEY_LINES="ANTHROPIC_API_KEY=$k"; MODEL_DEFAULT="claude-3-5-sonnet-latest" ;; - 3) PROVIDER=google; k=$(read_secret "GOOGLE_API_KEY"); KEY_LINES="GOOGLE_API_KEY=$k"; MODEL_DEFAULT="gemini-1.5-pro" ;; + 2) PROVIDER=anthropic; k=$(read_secret "ANTHROPIC_API_KEY"); KEY_LINES="ANTHROPIC_API_KEY=$k"; MODEL_DEFAULT="claude-sonnet-4-6" ;; + 3) PROVIDER=google; k=$(read_secret "GOOGLE_API_KEY"); KEY_LINES="GOOGLE_API_KEY=$k"; MODEL_DEFAULT="gemini-3.5-flash" ;; 4) PROVIDER=deepseek; k=$(read_secret "DEEPSEEK_API_KEY"); KEY_LINES="DEEPSEEK_API_KEY=$k"; ASK_MODEL=0 ;; 5) PROVIDER=mistral; k=$(read_secret "MISTRAL_API_KEY"); KEY_LINES="MISTRAL_API_KEY=$k"; MODEL_DEFAULT="mistral-large-latest" ;; 6) PROVIDER=groq; k=$(read_secret "GROQ_API_KEY"); KEY_LINES="GROQ_API_KEY=$k"; MODEL_DEFAULT="llama-3.3-70b-versatile" ;; @@ -159,7 +159,8 @@ if neo4j_volume_exists && [ -n "$OLD_NEO4J_PW" ]; then read -r -p "$(muted ' Keep existing DB password? [Y/n] ')" keep case "${keep:-Y}" in n|N) - read -r -p "$(muted ' New password (Enter = auto-generate): ')" NEO4J_PW + read -r -s -p "$(muted ' New password (Enter = auto-generate): ')" NEO4J_PW + echo NEO4J_PW=$(ensure_neo4j_password "$NEO4J_PW") ;; *) @@ -168,7 +169,8 @@ if neo4j_volume_exists && [ -n "$OLD_NEO4J_PW" ]; then ;; esac else - read -r -p "$(muted ' Password (Enter = auto-generate): ')" NEO4J_PW + read -r -s -p "$(muted ' Password (Enter = auto-generate): ')" NEO4J_PW + echo NEO4J_PW=$(ensure_neo4j_password "$NEO4J_PW") fi @@ -207,21 +209,25 @@ case "$EXPOSURE" in esac # ── Write .env ──────────────────────────────────────────────────────────────── +# Compose reads .env for ${...} interpolation, which mangles a literal '$' (bcrypt hashes, +# '$'-containing passwords). Double every '$' for values consumed via interpolation so Compose +# restores the literal. API keys reach the server via env_file (read literally) → NOT escaped. +esc() { printf '%s' "$1" | sed 's/[$]/$$/g'; } umask 077 { echo "# Generated by install.sh — do not commit (gitignored)." echo "PUBLIC_URL=http://localhost:3000" echo "PORT_PUBLIC=3000" echo "BIND_ADDRESS=$BIND_ADDRESS" - echo "NEO4J_PASSWORD=$NEO4J_PW" + echo "NEO4J_PASSWORD=$(esc "$NEO4J_PW")" echo "LOCAL_USER_ID=local_owner" echo "LLM_GENERATION_PROVIDER=$PROVIDER" echo "LLM_CHAT_PROVIDER=$PROVIDER" printf '%s\n' "$KEY_LINES" [ -n "$MODEL" ] && echo "LLM_MODEL=$MODEL" if [ -n "$AUTH_USER" ]; then - echo "SOLARCH_BASIC_AUTH_USER=$AUTH_USER" - echo "SOLARCH_BASIC_AUTH_HASH=$AUTH_HASH" + echo "SOLARCH_BASIC_AUTH_USER=$(esc "$AUTH_USER")" + echo "SOLARCH_BASIC_AUTH_HASH=$(esc "$AUTH_HASH")" else echo "# SOLARCH_BASIC_AUTH_USER=" echo "# SOLARCH_BASIC_AUTH_HASH=" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04a4ae3..6dcacaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,15 +186,6 @@ importers: '@tanstack/react-query': specifier: ^5.100.12 version: 5.101.2(react@19.2.7) - '@zxcvbn-ts/core': - specifier: ^3.0.4 - version: 3.0.4 - '@zxcvbn-ts/language-common': - specifier: ^3.0.4 - version: 3.0.4 - '@zxcvbn-ts/language-en': - specifier: ^3.0.2 - version: 3.0.2 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2518,15 +2509,6 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - '@zxcvbn-ts/core@3.0.4': - resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} - - '@zxcvbn-ts/language-common@3.0.4': - resolution: {integrity: sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw==} - - '@zxcvbn-ts/language-en@3.0.2': - resolution: {integrity: sha512-Zp+zL+I6Un2Bj0tRXNs6VUBq3Djt+hwTwUz4dkt2qgsQz47U0/XthZ4ULrT/RxjwJRl5LwiaKOOZeOtmixHnjg==} - abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -3556,10 +3538,6 @@ packages: fast-uri@3.1.3: resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} - fastest-levenshtein@1.0.16: - resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} - engines: {node: '>= 4.9.1'} - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -8114,14 +8092,6 @@ snapshots: '@xtuc/long@4.2.2': {} - '@zxcvbn-ts/core@3.0.4': - dependencies: - fastest-levenshtein: 1.0.16 - - '@zxcvbn-ts/language-common@3.0.4': {} - - '@zxcvbn-ts/language-en@3.0.2': {} - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -9259,8 +9229,6 @@ snapshots: fast-uri@3.1.3: {} - fastest-levenshtein@1.0.16: {} - fastq@1.20.1: dependencies: reusify: 1.1.0 diff --git a/start.ps1 b/start.ps1 index 056cdaf..d42acfa 100644 --- a/start.ps1 +++ b/start.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = 'Stop' Set-Location $PSScriptRoot -function Write-Brand([string]$Text) { Write-Host $Text -ForegroundColor '#FD6A09' } +function Write-Brand([string]$Text) { Write-Host $Text -ForegroundColor DarkYellow } function Write-Muted([string]$Text) { Write-Host $Text -ForegroundColor DarkGray } function Write-Ok([string]$Text) { Write-Host " ✓ $Text" -ForegroundColor Green } function Write-Fail([string]$Text) { Write-Host " ✗ $Text" -ForegroundColor Red }