diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2575a900..7da0e9ae1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,22 +52,9 @@ jobs: - name: Validate test-evidence boundaries and consumers if: matrix.node-version == '20.x' run: | - pnpm -r --if-present --filter @rawsql-ts/test-evidence-core --filter @rawsql-ts/test-evidence-renderer-md --filter @rawsql-ts/shared-binder --filter @rawsql-ts/testkit-core --filter @rawsql-ts/testkit-postgres --filter @rawsql-ts/testkit-sqlite --filter @rawsql-ts/adapter-node-pg --filter @rawsql-ts/ztd-cli run build + pnpm -r --if-present --filter @rawsql-ts/test-evidence-core --filter @rawsql-ts/test-evidence-renderer-md --filter @rawsql-ts/shared-binder --filter @rawsql-ts/testkit-core --filter @rawsql-ts/testkit-postgres --filter @rawsql-ts/testkit-sqlite --filter @rawsql-ts/adapter-node-pg run build pnpm -r --if-present --filter @rawsql-ts/test-evidence-core --filter @rawsql-ts/test-evidence-renderer-md --filter @rawsql-ts/testkit-core --filter @rawsql-ts/testkit-postgres --filter @rawsql-ts/testkit-sqlite --filter @rawsql-ts/adapter-node-pg run test pnpm --filter @rawsql-ts/shared-binder run test:consumer-validation - pnpm --filter @rawsql-ts/ztd-cli run test:consumer-validation - - - name: Build ztd-cli for generated mapper drift - if: matrix.node-version == '20.x' - run: pnpm --filter @rawsql-ts/ztd-cli run build - - - name: Check generated mapper drift - if: matrix.node-version == '20.x' - run: pnpm verify:generated-mapper-drift - - - name: Test generated mapper scaffold and drift commands - if: matrix.node-version == '20.x' - run: pnpm --filter @rawsql-ts/ztd-cli run test:generated-mapper customer-consumer-guard: runs-on: ${{ matrix.os }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b4280aa15..dad0609a4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -54,7 +54,6 @@ jobs: run: | pnpm --filter rawsql-ts run build pnpm --filter @rawsql-ts/testkit-core run build - pnpm --filter @rawsql-ts/ztd-cli run build - name: Verify transfer docs and metadata drift run: pnpm run verify:transfer-docs diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 0cef66642..ba3167e29 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -49,51 +49,6 @@ jobs: - name: Run unit tests (core) run: pnpm --filter "./packages/core" run test - - name: Detect playground-impacting changes - id: playground_changes - shell: bash - run: | - base_sha="${{ github.event.pull_request.base.sha }}" - head_sha="${{ github.event.pull_request.head.sha }}" - changed_files=$(git diff --name-only "$base_sha...$head_sha") - printf '%s\n' "$changed_files" - - if printf '%s\n' "$changed_files" | grep -Eq '^(packages/ztd-cli/|packages/test-evidence-core/|packages/test-evidence-renderer-md/|packages/testkit-core/|playgrounds/ztd-playground/|\.github/workflows/pr-check\.yml$)'; then - echo "run=true" >> "$GITHUB_OUTPUT" - else - echo "run=false" >> "$GITHUB_OUTPUT" - fi - - - name: Detect scaffold-layout changes - id: generated_project_changes - shell: bash - run: | - base_sha="${{ github.event.pull_request.base.sha }}" - head_sha="${{ github.event.pull_request.head.sha }}" - changed_files=$(git diff --name-only "$base_sha...$head_sha") - printf '%s\n' "$changed_files" - - if printf '%s\n' "$changed_files" | grep -Eq '^(package\.json|scripts/verify-generated-project-mode\.mjs|packages/ztd-cli/package\.json|packages/ztd-cli/(README\.md|src/commands/(feature|init)\.ts|templates/|tests/(cliCommands|featureScaffold\.unit|init\.command)\.test\.ts)|docs/guide/(generated-project-verification|ztd-local-source-dogfooding|sql-first-end-to-end-tutorial)\.md|\.github/workflows/pr-check\.yml$)'; then - echo "run=true" >> "$GITHUB_OUTPUT" - else - echo "run=false" >> "$GITHUB_OUTPUT" - fi - - - name: Detect ztd-cli essential-gate changes - id: ztd_cli_gates - shell: bash - run: | - base_sha="${{ github.event.pull_request.base.sha }}" - head_sha="${{ github.event.pull_request.head.sha }}" - changed_files=$(git diff --name-only "$base_sha...$head_sha") - printf '%s\n' "$changed_files" - - if printf '%s\n' "$changed_files" | grep -Eq '^(\.husky/pre-commit|\.github/pull_request_template\.md|\.github/workflows/pr-check\.yml|\.github/workflows/ztd-cli-soft-gates\.yml|scripts/(check-pr-readiness|run-ztd-cli-quality-gates|ztd-cli-quality-gates)\.js|docs/guide/(release-readiness|ztd-cli-quality-gates)\.md|packages/ztd-cli/|docs/guide/ztd-cli-)'; then - echo "run=true" >> "$GITHUB_OUTPUT" - else - echo "run=false" >> "$GITHUB_OUTPUT" - fi - - name: Detect transfer DDL metadata changes id: transfer_ddl_metadata shell: bash @@ -109,53 +64,6 @@ jobs: echo "run=false" >> "$GITHUB_OUTPUT" fi - - name: Build ZTD CLI dependencies - if: steps.playground_changes.outputs.run == 'true' - run: | - pnpm --filter rawsql-ts run build - pnpm --filter @rawsql-ts/test-evidence-core run build - pnpm --filter @rawsql-ts/test-evidence-renderer-md run build - pnpm --filter @rawsql-ts/testkit-core run build - pnpm --filter @rawsql-ts/ztd-cli run build - - - name: Detect deprecated playground - id: playground - if: steps.playground_changes.outputs.run == 'true' - run: | - if [ -d "./playgrounds/ztd-playground" ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - echo "playgrounds/ztd-playground is removed; skipping playground artifact generation." - fi - - # Playground artifact generation stays in PR checks, but only when relevant packages or the workflow changed. - - name: Generate ZTD artifacts (playground) - if: steps.playground_changes.outputs.run == 'true' && steps.playground.outputs.exists == 'true' - working-directory: ./playgrounds/ztd-playground - run: node ../../packages/ztd-cli/dist/index.js ztd-config - - - name: Verify working tree is clean after generation - if: steps.playground_changes.outputs.run == 'true' && steps.playground.outputs.exists == 'true' - run: git diff --exit-code - - - name: Run generated-project verification lane - if: steps.generated_project_changes.outputs.run == 'true' - run: pnpm verify:generated-project-mode - - - name: Build ztd-cli gate dependencies - if: steps.ztd_cli_gates.outputs.run == 'true' - run: | - pnpm --filter rawsql-ts run build - pnpm --filter @rawsql-ts/sql-grep-core run build - pnpm --filter @rawsql-ts/test-evidence-core run build - pnpm --filter @rawsql-ts/test-evidence-renderer-md run build - pnpm --filter @rawsql-ts/testkit-core run build - - - name: Run ztd-cli essential gates - if: steps.ztd_cli_gates.outputs.run == 'true' - run: node scripts/run-ztd-cli-quality-gates.js pr - - name: Check transfer docs and metadata drift if: steps.transfer_ddl_metadata.outputs.run == 'true' run: pnpm verify:transfer-docs diff --git a/.github/workflows/refresh-demo-bundle.yml b/.github/workflows/refresh-demo-bundle.yml index 4d68d98c2..1dd47e8b0 100644 --- a/.github/workflows/refresh-demo-bundle.yml +++ b/.github/workflows/refresh-demo-bundle.yml @@ -39,9 +39,6 @@ jobs: - name: Build testkit-core package run: pnpm --filter @rawsql-ts/testkit-core build - - name: Build ztd-cli package - run: pnpm --filter @rawsql-ts/ztd-cli build - - name: Build rawsql-ts browser output run: pnpm --filter rawsql-ts build:browser diff --git a/.github/workflows/ztd-cli-soft-gates.yml b/.github/workflows/ztd-cli-soft-gates.yml deleted file mode 100644 index 9c5907e06..000000000 --- a/.github/workflows/ztd-cli-soft-gates.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: ztd-cli Soft Gates - -on: - schedule: - - cron: '17 18 * * *' - workflow_dispatch: - -jobs: - soft-gates: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Setup Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run ztd-cli soft gates - run: node scripts/run-ztd-cli-quality-gates.js soft diff --git a/.husky/pre-commit b/.husky/pre-commit index 97927d763..a3de15122 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -16,34 +16,16 @@ node scripts/precommit-enforcement.js # Do not let tests that create temporary Git repositories inherit the hook index. unset GIT_INDEX_FILE -ZTD_ONLY=true -for file in $STAGED_FILES -do - case "$file" in - .github/workflows/*|.husky/pre-commit|packages/ztd-cli/*|docs/guide/ztd-cli-*|scripts/run-ztd-cli-quality-gates.js|scripts/ztd-cli-quality-gates.js|.changeset/*|README.md) - ;; - *) - ZTD_ONLY=false - break - ;; - esac -done - -if [ "$ZTD_ONLY" = true ]; then - echo "[pre-commit] Detected ztd-cli scoped changes." - node scripts/run-ztd-cli-quality-gates.js pre-commit -else - echo "[pre-commit] Running workspace typecheck..." - pnpm typecheck - - echo "[pre-commit] Running workspace tests..." - pnpm test - - echo "[pre-commit] Building workspace..." - pnpm build - - echo "[pre-commit] Running workspace lint..." - pnpm lint -fi +echo "[pre-commit] Running workspace typecheck..." +pnpm typecheck + +echo "[pre-commit] Running workspace tests..." +pnpm test + +echo "[pre-commit] Building workspace..." +pnpm build + +echo "[pre-commit] Running workspace lint..." +pnpm lint echo "[pre-commit] All checks passed." diff --git a/AGENTS.md b/AGENTS.md index 2b0219dc4..d77346f40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,6 @@ Deeper `AGENTS.md` files take precedence when they add stricter or narrower rule - Development has two completion stages: first prove the feature and regression tests, then run a separate finishing review pass before PR handoff. - The finishing review pass must use the available self-review workflow or repo-local review skill, and it must look for cross-mode regressions such as direct command versus PR/worktree command behavior. - The finishing review pass must include a concept boundary review when the change touches package behavior, generated scaffold output, generated runtime code, docs, or PR wording. Read the owning package concept, package scope, technology policy, or Concept Spec when one exists. -- For `@rawsql-ts/ztd-cli`, the concept boundary review must check that the standard generated runtime path remains runtime-free: no dependency on `ztd-cli`, `rawsql-ts`, runtime mapper libraries, runtime validator libraries, SQL JSON result shaping, or hidden business SQL rewriting. Test support and driver adapters may exist only within their stated non-ORM, driver/test roles. - Final PR text and final implementation reports must pass self-review before human review. - Before creating or editing a PR, read `.github/pull_request_template.md` and use `.agents/skills/pr-readiness/SKILL.md` when present. - Before claiming a PR is ready, run the repository PR readiness script locally when `scripts/check-pr-readiness.js` exists, or explicitly state why it could not be run. diff --git a/README.md b/README.md index c7d951367..9a438135d 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,9 @@ A monorepo for **rawsql-ts**: a SQL-first toolkit for parsing, testing, inspecting, and evolving database applications while keeping raw SQL as a first-class asset. -By parsing SQL into abstract syntax trees, rawsql-ts enables type-safe query building, static validation, and transparent result mapping — all while preserving the expressiveness and control of handwritten SQL. AST-based rewriting also powers Zero Table Dependency (ZTD) testing, which transforms application queries to run against in-memory fixtures instead of physical tables, enabling deterministic unit tests without database setup overhead. The repo additionally covers AST-based impact analysis, deterministic test evidence, schema documentation, and `ztd-cli` workflows for inspection and SQL artifact generation. +By parsing SQL into abstract syntax trees, rawsql-ts enables type-safe query building, static validation, fixture-backed testing, and transparent result mapping while preserving the expressiveness and control of handwritten SQL. AST-based rewriting also powers Zero Table Dependency (ZTD) testkits, which transform application queries to run against in-memory fixtures instead of physical tables. -The `ztd init` scaffold now starts from a feature-first layout under `src/features//` and includes `src/features/smoke/` as the removable teaching feature. -Shared feature seams live under `src/features/_shared/`, driver-neutral runtime contracts under `src/libraries/`, driver or sink bindings under `src/adapters//`, shared verification seams under `tests/support/`, and tool-managed assets under `.ztd/`. -`src/catalog` may still exist as internal support, but it is no longer the user-facing standard location. +The former `@rawsql-ts/ztd-cli` package has moved to the Ashiba project as `@ashiba-ts/cli`. Use [mk3008/ashiba](https://github.com/mk3008/ashiba) for SQL-first scaffolding, command-line inspection, optional-condition maintenance, and project lifecycle workflows. > [!Note] > This project is currently in beta. APIs may change until the v1.0 release. @@ -28,20 +26,18 @@ Use this section as the shortest repo-level map. It is intentionally brief: pack | ZTD fixture rewriting and testkits | `@rawsql-ts/testkit-*` | [packages/testkit-core](./packages/testkit-core) | | Test evidence storage and rendering | `@rawsql-ts/test-evidence-*` | [packages/test-evidence-core](./packages/test-evidence-core) | | Schema documentation generation | `@rawsql-ts/ddl-docs-*` | [packages/ddl-docs-cli](./packages/ddl-docs-cli) | -| ZTD project scaffolding and SQL lifecycle tooling | `@rawsql-ts/ztd-cli` | [packages/ztd-cli/README.md](./packages/ztd-cli/README.md) | +| Ashiba CLI workflows | `@ashiba-ts/cli` | [mk3008/ashiba](https://github.com/mk3008/ashiba) | ### Workflow Surfaces -These capabilities are important at the repo level even though they are mostly exposed through `ztd-cli` commands rather than standalone packages. +These workflows are now owned by Ashiba. rawsql-ts keeps the reusable parser, formatter, testkit, binder, SQL grep, and documentation packages that Ashiba can consume. | Workflow | Entry point | Why it matters | |----------|-------------|----------------| -| SQL pipeline planning and dry-run optimization analysis | `ztd query plan`, `ztd perf run --dry-run` | Explains how SQL may be decomposed into stages before execution. | -| SQL impact analysis before schema changes | `ztd query uses` | Supports rename/type-change investigations using AST-based usage analysis. | -| SQL-first optional filter authoring | `ztd query sssql scaffold`, `ztd query sssql refresh` | Keeps optional filters visible in SQL while runtime pruning stays explicit. Runtime no longer injects new filter predicates. | -| SQL debug and recovery for long CTE queries | `ztd query outline`, `ztd query lint`, `ztd query slice`, `ztd query patch apply` | Helps isolate and repair problematic query shapes; `ztd query lint --rules join-direction` adds a FK-aware JOIN readability guard. | -| Explicit-target schema inspection and migration-prep workflow | `ztd ddl diff`, `ztd ddl pull` | Supports safe inspection against explicit target databases and generation of diff / patch SQL artifacts. Applying generated SQL is intentionally out of scope. | -| Machine-readable CLI automation and telemetry | `ztd --output json`, `ztd describe`, telemetry export modes | Supports AI/tooling integration and timing investigation. | +| SQL impact analysis before schema changes | `@rawsql-ts/sql-grep-core` / Ashiba query commands | Supports rename/type-change investigations using AST-based usage analysis. | +| SQL-first optional filter authoring | `rawsql-ts` SSSQL APIs / Ashiba query commands | Keeps optional filters visible in SQL while runtime pruning stays explicit. Runtime no longer injects new filter predicates. | +| Fixture-backed SQL unit testing | `@rawsql-ts/testkit-*` | Runs SQL against deterministic fixtures without a production database dependency. | +| Schema documentation generation | `@rawsql-ts/ddl-docs-*` | Generates reviewable Markdown schema documentation from DDL assets. | ## Packages @@ -96,14 +92,6 @@ The planned rename path is to add a non-breaking alias such as `@rawsql-ts/testk | [@rawsql-ts/ddl-docs-cli](./packages/ddl-docs-cli) | ![npm](https://img.shields.io/npm/v/@rawsql-ts/ddl-docs-cli) | CLI that generates Markdown table definition docs from DDL files. | | [@rawsql-ts/ddl-docs-vitepress](./packages/ddl-docs-vitepress) | ![npm](https://img.shields.io/npm/v/@rawsql-ts/ddl-docs-vitepress) | Scaffold generator for VitePress-based database schema documentation sites. | -### CLI - -| Package | Version | Description | -|---------|---------|-------------| -| [@rawsql-ts/ztd-cli](./packages/ztd-cli) | ![npm](https://img.shields.io/npm/v/@rawsql-ts/ztd-cli) | SQL-first CLI for ZTD workflows, schema inspection, and migration SQL artifact generation. | - -For the machine-readable CLI surface, see [ztd-cli Agent Interface](./docs/guide/ztd-cli-agent-interface.md) and [ztd-cli Describe Schema](./docs/guide/ztd-cli-describe-schema.md). - ## Architecture ```text @@ -117,8 +105,7 @@ rawsql-ts (core) │ └─ @rawsql-ts/testkit-sqlite ├─ @rawsql-ts/ddl-docs-cli │ └─ @rawsql-ts/ddl-docs-vitepress -└─ @rawsql-ts/ztd-cli - └─ uses @rawsql-ts/sql-grep-core for `query uses` +└─ consumed by Ashiba for CLI workflows ``` ## Quick Start @@ -127,12 +114,7 @@ rawsql-ts (core) npm install rawsql-ts ``` -See the [Core Package Documentation](./packages/core/README.md) for usage examples and API reference. For reusable AST-based impact analysis, see [@rawsql-ts/sql-grep-core](./packages/sql-grep-core). For repo-level SQL lifecycle workflows, inspection commands, and ZTD project guidance, see [@rawsql-ts/ztd-cli](./packages/ztd-cli/README.md). Deterministic dogfooding spec: [docs/dogfooding/DOGFOODING.md](./docs/dogfooding/DOGFOODING.md). - -## Tutorials - -- [SQL-first End-to-End Tutorial](./docs/guide/sql-first-end-to-end-tutorial.md) - Walk from DDL to `ztd-config`, `model-gen`, repository wiring, and the first passing smoke test in one focused path. -- [Migration Repair Loop](./docs/dogfooding/ztd-migration-lifecycle.md) - Repair DDL, SQL, DTO, and migration artifacts with AI after the starter flow is green. +See the [Core Package Documentation](./packages/core/README.md) for usage examples and API reference. For reusable AST-based impact analysis, see [@rawsql-ts/sql-grep-core](./packages/sql-grep-core). For CLI scaffolding and SQL lifecycle workflows, use [Ashiba](https://github.com/mk3008/ashiba). ## Intent and Procedure @@ -173,26 +155,6 @@ volumes: Then run `docker compose up -d` and point `ZTD_DB_URL` at that database for the fixture-backed rewrite path. -## CLI Tool Routing Happy Paths - -- SQL pipeline / debug → `ztd query plan ` -- Impact analysis → `ztd query uses ` -- SQL-first optional filters → `ztd query sssql scaffold ` / `ztd query sssql refresh ` -- Schema inspection → `ztd ddl diff --url ` - -For the full routing guide and decision table, see [SQL Tool Happy Paths](./docs/guide/sql-tool-happy-paths.md). - -## Database Boundary at a Glance - -For repo-level workflows, keep this boundary in mind: - -* `.env` is the source of truth for the fixture-backed ZTD runtime inputs, and `ZTD_DB_URL` is the implicit database input used by `ztd-cli` -* `DATABASE_URL` is typically an application/runtime/deployment concern and is not read automatically by `ztd-cli` -* any non-ZTD database target must be supplied explicitly via `--url` or `--db-*` -* migration SQL artifacts may be generated by `ztd-cli`, but apply / deployment execution remains outside its ownership - -This boundary exists for both AI-driven and human-driven workflows. It keeps test, inspection, and deployment concerns from silently collapsing into a single default database model. - ## Online Demo [Try rawsql-ts in your browser](https://mk3008.github.io/rawsql-ts/) diff --git a/artifacts/test-evidence/test-specification.pr.md b/artifacts/test-evidence/test-specification.pr.md deleted file mode 100644 index 4d766d98f..000000000 --- a/artifacts/test-evidence/test-specification.pr.md +++ /dev/null @@ -1,166 +0,0 @@ -# Test Evidence (PR Diff) - -- base: merge-base(main, HEAD) (c8f60f4406390ad953dbf97829de88ab55e7cc40) -- head: HEAD (fd7e3e96429d3c1a068306458c0d1d1ea8c60067) -- base-mode: merge-base -- tests: +8 / ~0 / -0 -- base totals: tests=0 -- head totals: tests=8 - -## sql.active-orders - Active orders SQL semantics - -[File](../../packages/ztd-cli/src/specs/sql/activeOrders.catalog.ts) - -### ADD: baseline - active users with minimum total - -**after** - -input -```json -{ - "active": 1, - "limit": 2, - "minTotal": 20 -} -``` -output -```json -[ - { - "orderId": 10, - "userEmail": "alice@example.com", - "orderTotal": 50 - }, - { - "orderId": 13, - "userEmail": "carol@example.com", - "orderTotal": 35 - } -] -``` - -### ADD: inactive-variant - inactive users return a different result - -**after** - -input -```json -{ - "active": 0, - "limit": 2, - "minTotal": 20 -} -``` -output -```json -[ - { - "orderId": 12, - "userEmail": "bob@example.com", - "orderTotal": 40 - } -] -``` - -## sql.sample - sample sql cases - -[File](../../packages/ztd-cli/src/specs/sql/usersList.catalog.ts) - -### ADD: returns-active-users - returns active users - -**after** - -input -```json -{ - "active": 1 -} -``` -output -```json -[ - { - "id": 1 - } -] -``` - -### ADD: returns-inactive-users-when-active-0 - returns inactive users when active=0 - -**after** - -input -```json -{ - "active": 0 -} -``` -output -```json -[ - { - "id": 2 - } -] -``` - -## unit.alpha - alpha - -[File](../../packages/ztd-cli/tests/specs/testCaseCatalogs.ts) - -### ADD: a - noop - -**after** - -input -```json -1 -``` -output -```json -1 -``` - -## unit.normalize-email - normalizeEmail - -[File](../../packages/ztd-cli/tests/specs/testCaseCatalogs.ts) - -### ADD: keeps-valid-address - retains already-normalized email - -**after** - -input -```json -"alice@example.com" -``` -output -```json -"alice@example.com" -``` - -### ADD: rejects-invalid-input - throws when @ is missing - -**after** - -input -```json -"invalid-email" -``` -output -```json -"Error: invalid email" -``` - -### ADD: trims-and-lowercases - normalizes uppercase + spaces - -**after** - -input -```json -" USER@Example.COM " -``` -output -```json -"user@example.com" -``` - diff --git a/artifacts/test-evidence/test-specification.pr.sample.md b/artifacts/test-evidence/test-specification.pr.sample.md deleted file mode 100644 index 5094aa5b2..000000000 --- a/artifacts/test-evidence/test-specification.pr.sample.md +++ /dev/null @@ -1,146 +0,0 @@ -# Test Evidence (PR Diff) - -- base: sample-base (1111111) -- head: sample-head (2222222) -- base-mode: ref -- tests: +3 / ~1 / -2 -- base totals: tests=4 -- head totals: tests=5 - -## sql.added-catalog - Added SQL Catalog - -[File](../../packages/ztd-cli/src/specs/sql/activeOrders.catalog.ts) - -### ADD: new - new - -**after** - -input -```json -{} -``` -output -```json -[ - { - "id": 200 - } -] -``` - -## sql.removed-catalog - Removed SQL Catalog - -[File](../../packages/ztd-cli/src/specs/sql/usersList.catalog.ts) - -### REMOVE: gone - gone - -**before** - -input -```json -{} -``` -output -```json -[ - { - "id": 100 - } -] -``` - -## sql.users - Users SQL - -[File](../../packages/ztd-cli/src/specs/sql/usersList.catalog.ts) - -### ADD: added-case - newly added - -**after** - -input -```json -{ - "active": 2 -} -``` -output -```json -[ - { - "id": 3 - } -] -``` - -### UPDATE: baseline - returns active users - -**before** - -input -```json -{ - "active": 1 -} -``` -output -```json -[ - { - "id": 1 - } -] -``` - -**after** - -input -```json -{ - "active": 0 -} -``` -output -```json -[ - { - "id": 2 - } -] -``` - -### REMOVE: removed-case - will be removed - -**before** - -input -```json -{ - "active": 9 -} -``` -output -```json -[ - { - "id": 9 - } -] -``` - -## unit.normalize - normalize - -[File](../../packages/ztd-cli/tests/specs/testCaseCatalogs.ts) - -### ADD: fn-added - function case added - -**after** - -input -```json -"B" -``` -output -```json -"b" -``` - diff --git a/benchmarks/drizzle-official-comparison/README.md b/benchmarks/drizzle-official-comparison/README.md index ce38bb37a..4aea40567 100644 --- a/benchmarks/drizzle-official-comparison/README.md +++ b/benchmarks/drizzle-official-comparison/README.md @@ -94,15 +94,6 @@ pnpm exec tsx profiles/mapper-profile.ts The HTTP/k6 helpers are still materialized for compatibility with the official benchmark, but do not run them before Pure ORM has identified a rawsql-ts optimization target. -Generated mapper drift check: - -```sh -pnpm verify:generated-mapper-drift -``` - -Generated row mappers are machine-owned. If a scaffolded generated mapper drifts from its SQL, contract, or boundary source, the check should fail and print the `ztd feature generated-mapper generate ...` command needed to refresh the artifact. -The repository includes `packages/ztd-cli/fixtures/generated-mapper-drift` so the standard drift check has a non-skip target in CI. - ## Latest report: 2026-05-03 ### Summary @@ -196,7 +187,7 @@ Implemented / measured in this pass: - The Pure ORM RFBA runner no longer clones parameter arrays before `pg.Pool.query`, matching the benchmark executor more closely to the RFBA server executor. - Generated mappers for the hot nested DTOs now use direct assignment instead of helper calls plus object spread. This preserves the RFBA scaffold usage model while reducing mapper-only allocation/function-call overhead. - Breakdown phases show parameter construction is not a useful next target, minimized executor invocation is noisy, and generated aggregation shape is the meaningful accepted improvement. -- ztd-cli list generated mappers now emit preallocated-loop direct assignment, so ordinary RFBA scaffold output follows the same direction without asking users to choose a special performance mode. +- Ashiba generated mappers now emit preallocated-loop direct assignment, so ordinary RFBA scaffold output follows the same direction without asking users to choose a special performance mode. Next rawsql-ts optimization candidates: diff --git a/benchmarks/drizzle-official-comparison/src/rfba/features/get-employee-with-recipient/generated/row-mapper.ts b/benchmarks/drizzle-official-comparison/src/rfba/features/get-employee-with-recipient/generated/row-mapper.ts index 0fb67db05..b4ee0bcd5 100644 --- a/benchmarks/drizzle-official-comparison/src/rfba/features/get-employee-with-recipient/generated/row-mapper.ts +++ b/benchmarks/drizzle-official-comparison/src/rfba/features/get-employee-with-recipient/generated/row-mapper.ts @@ -1,4 +1,4 @@ -// @generated by rawsql-ts ztd-cli benchmark scaffold. Do not edit. +// @generated by Ashiba benchmark scaffold. Do not edit. // This file is machine-owned and keeps hot row mapping out of the public boundary. import type { Row } from '../../../../local/sql-contract-mapper'; diff --git a/benchmarks/drizzle-official-comparison/src/rfba/features/get-order-with-details-and-products/generated/row-mapper.ts b/benchmarks/drizzle-official-comparison/src/rfba/features/get-order-with-details-and-products/generated/row-mapper.ts index 7beb84147..fe9acc9fe 100644 --- a/benchmarks/drizzle-official-comparison/src/rfba/features/get-order-with-details-and-products/generated/row-mapper.ts +++ b/benchmarks/drizzle-official-comparison/src/rfba/features/get-order-with-details-and-products/generated/row-mapper.ts @@ -1,4 +1,4 @@ -// @generated by rawsql-ts ztd-cli benchmark scaffold. Do not edit. +// @generated by Ashiba benchmark scaffold. Do not edit. // This file is machine-owned and keeps hot row mapping out of the public boundary. import type { Row } from '../../../../local/sql-contract-mapper'; diff --git a/benchmarks/drizzle-official-comparison/src/rfba/features/get-product-with-supplier/generated/row-mapper.ts b/benchmarks/drizzle-official-comparison/src/rfba/features/get-product-with-supplier/generated/row-mapper.ts index 195f17e24..fc6ae6aa0 100644 --- a/benchmarks/drizzle-official-comparison/src/rfba/features/get-product-with-supplier/generated/row-mapper.ts +++ b/benchmarks/drizzle-official-comparison/src/rfba/features/get-product-with-supplier/generated/row-mapper.ts @@ -1,4 +1,4 @@ -// @generated by rawsql-ts ztd-cli benchmark scaffold. Do not edit. +// @generated by Ashiba benchmark scaffold. Do not edit. // This file is machine-owned and keeps hot row mapping out of the public boundary. import type { Row } from '../../../../local/sql-contract-mapper'; diff --git a/benchmarks/sql-unit-test/README.md b/benchmarks/sql-unit-test/README.md index 355d7529e..6f2f2821d 100644 --- a/benchmarks/sql-unit-test/README.md +++ b/benchmarks/sql-unit-test/README.md @@ -1,33 +1,15 @@ # SQL unit test benchmark helpers -This directory holds the tooling that lets benchmark authors invoke the local `ztd-cli` while working on the SQL unit tests. +This directory holds benchmark fixtures for the rawsql-ts SQL unit-test path. -## Preparing the workspace - -1. Run `pnpm --filter @rawsql-ts/ztd-cli build` at the repository root to keep `packages/ztd-cli/dist/index.js` up to date with the source. -2. Change into this folder and run `pnpm install` so that `node_modules/.bin/ztd` is linked to the local `@rawsql-ts/ztd-cli` package via `link:../../packages/ztd-cli`. - -Because the dependency uses `link:../../packages/ztd-cli`, the command does not pull from the npm registry and always executes the local source tree. - -## Running the local CLI from here - -Use `npx ztd ` or `pnpm run ztd -- ` to execute the `ztd` binary. Both invocations resolve to `node_modules/.bin/ztd`, which runs the locally linked CLI that mirrors `packages/ztd-cli`. - -If you change CLI code, rebuild it via `pnpm --filter @rawsql-ts/ztd-cli build` before rerunning these commands. +Generated files under `tests/generated/` are benchmark inputs. The generator that used to live in this repository as `@rawsql-ts/ztd-cli` has moved to Ashiba, so this workspace no longer links a local `ztd` binary. ## Running the benchmarks -1. Generate the fixtures the benchmark workspace relies on: - -```bash -cd benchmarks/sql-unit-test -npx ztd ztd-config -``` - -2. Return to the repository root and run: +If `tests/generated/ztd-row-map.generated.ts` is present, run from the repository root: ```bash pnpm bench:test ``` -The helper script at `scripts/run-vitest.js` emits a clear skip message unless `tests/generated/ztd-row-map.generated.ts` already exists, so this command stays safe to rerun even when you have not re-generated fixtures. +The helper script at `scripts/run-vitest.js` emits a clear skip message when generated fixtures are missing, so the benchmark command remains safe in fresh checkouts. diff --git a/benchmarks/sql-unit-test/package.json b/benchmarks/sql-unit-test/package.json index 4bf1692d7..a188f279e 100644 --- a/benchmarks/sql-unit-test/package.json +++ b/benchmarks/sql-unit-test/package.json @@ -2,16 +2,14 @@ "name": "@rawsql-ts/sql-unit-test-bench", "version": "0.0.1", "private": true, - "description": "Workspace helper for running the local ztd-cli inside benchmarks/sql-unit-test", + "description": "Workspace helper for running SQL unit-test benchmarks", "scripts": { "test": "node scripts/run-vitest.js", - "ztd": "ztd", "format": "prettier . --write", "lint": "eslint .", "lint:fix": "eslint . --fix" }, "devDependencies": { - "@rawsql-ts/ztd-cli": "link:../../packages/ztd-cli", "eslint": "^9.22.0", "lint-staged": "^16.4.0", "prettier": "^3.7.4", diff --git a/benchmarks/sql-unit-test/scripts/run-vitest.js b/benchmarks/sql-unit-test/scripts/run-vitest.js index f00faee17..a9c221ac3 100644 --- a/benchmarks/sql-unit-test/scripts/run-vitest.js +++ b/benchmarks/sql-unit-test/scripts/run-vitest.js @@ -8,7 +8,7 @@ const generatedPath = join(benchRoot, 'tests', 'generated', 'ztd-row-map.generat if (!existsSync(generatedPath)) { console.log('Skipping benchmark tests because generated fixtures are missing.') console.log( - 'Run `cd benchmarks/sql-unit-test`, then `npx ztd ztd-config`, and after generation rerun `pnpm bench:test`.' + 'Generate the benchmark fixtures with Ashiba or restore tests/generated/ztd-row-map.generated.ts, then rerun `pnpm bench:test`.' ) process.exit(0) } diff --git a/benchmarks/sql-unit-test/tests/support/testkit-client.ts b/benchmarks/sql-unit-test/tests/support/testkit-client.ts index f6b5a4ad9..dd041a106 100644 --- a/benchmarks/sql-unit-test/tests/support/testkit-client.ts +++ b/benchmarks/sql-unit-test/tests/support/testkit-client.ts @@ -1,6 +1,6 @@ // ZTD testkit helper - AUTO GENERATED -// ztd-cli emits this file during project bootstrapping to wire @rawsql-ts/adapter-node-pg adapters. -// Regenerate via npx ztd init (choose overwrite when prompted); avoid manual edits. +// Ashiba emits this file during project bootstrapping to wire @rawsql-ts/adapter-node-pg adapters. +// Regenerate with Ashiba when refreshing benchmark fixtures; avoid manual edits. import { existsSync, promises as fsPromises } from 'node:fs'; import path from 'node:path'; diff --git a/benchmarks/sql-unit-test/ztd/AGENTS.md b/benchmarks/sql-unit-test/ztd/AGENTS.md index f29e81ee4..c27038606 100644 --- a/benchmarks/sql-unit-test/ztd/AGENTS.md +++ b/benchmarks/sql-unit-test/ztd/AGENTS.md @@ -4,7 +4,7 @@ # Policy ## REQUIRED -- `tests/generated/` artifacts MUST be regenerated via `npx ztd ztd-config` when missing or stale. +- `tests/generated/` artifacts MUST be regenerated via `Ashiba fixture generation` when missing or stale. - Files under `ztd/ddl` MUST preserve human-authored structure and statement ordering unless explicitly instructed. - DDL statements MUST remain semicolon-terminated valid PostgreSQL syntax. - Domain spec files MUST keep one executable top-level SELECT block per file. @@ -19,7 +19,7 @@ - Inventing enum values not defined in enum sources. # Mandatory Workflow -- If generated modules are missing or stale, run: `npx ztd ztd-config`. +- If generated modules are missing or stale, run: `Ashiba fixture generation`. # Hygiene - Keep generated artifacts out of commits unless explicitly required by repository policy. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4b9de54fa..e9982809b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -34,7 +34,6 @@ export default defineConfig({ nav: [ { text: 'Guide', link: '/guide/overview' }, { text: 'API', link: '/api/index' }, - { text: 'ztd-cli Docs', link: '/ztd-cli-docs' }, { text: 'Transfer Docs', link: '/transfer-docs' }, { text: 'Playground', link: '/cud-demo/index.html', target: '_blank', rel: 'noopener' }, { text: 'Migration Demo', link: '/migration-demo/index.html', target: '_blank', rel: 'noopener' } @@ -45,7 +44,6 @@ export default defineConfig({ { text: 'Getting Started', link: '/guide/getting-started' }, { text: 'What Is RFBA?', link: '/guide/rfba-overview' }, { text: 'What Is a Concept Spec?', link: '/guide/concept-spec-overview' }, - { text: 'SQL-first End-to-End Tutorial', link: '/guide/sql-first-end-to-end-tutorial' }, { text: 'Execution', items: [ @@ -61,21 +59,8 @@ export default defineConfig({ { text: 'Impact Checks', link: '/guide/query-uses-impact-checks' }, ] }, - { - text: 'Query Lint', - items: [ - { text: 'JOIN Direction', link: '/guide/join-direction-lint-spec' }, - { text: 'SQL Style', link: '/guide/sql-style-lint-spec' }, - ] - }, { text: 'Testkit Concept', link: '/guide/testkit-concept' }, { text: 'ZTD Benchmarking', link: '/guide/ztd-benchmarking' }, - { - text: 'Dogfooding', - items: [ - { text: 'SQL Debug Recovery', link: '/dogfooding/sql-debug-recovery' }, - ] - }, { text: 'SQLite Testkit How-To', link: '/guide/sqlite-testkit-howto' }, { text: 'Conversion Guides', @@ -101,13 +86,6 @@ export default defineConfig({ ] }, ], - '/ztd-cli/': [ - { text: 'ztd-cli Docs', link: '/ztd-cli-docs' }, - { text: 'Package Concept Draft', link: '/ztd-cli/package-concept' }, - { text: 'Testing Policy Draft', link: '/ztd-cli/testing-policy' }, - { text: 'Review Authority Model Draft', link: '/ztd-cli/review-authority-model' }, - { text: 'Technology Policy Draft', link: '/ztd-cli/technology-policy' }, - ], '/api/': apiSidebarWithIndex }, socialLinks: [ diff --git a/docs/benchmarks/rawsql-hasmany-generation-scope.md b/docs/benchmarks/rawsql-hasmany-generation-scope.md index 805a83b40..47a6c4563 100644 --- a/docs/benchmarks/rawsql-hasmany-generation-scope.md +++ b/docs/benchmarks/rawsql-hasmany-generation-scope.md @@ -120,7 +120,7 @@ Fallback should remain compatibility-oriented. The standard scaffold success pat Implemented in this pass: - JSON-compatible generated mapper metadata can describe one `hasMany` relation without a runtime package dependency. -- ztd-cli generated mapper sync can detect one explicit `hasMany` relation from JSON-compatible query metadata. +- Ashiba generated mapper sync can detect one explicit `hasMany` relation from JSON-compatible query metadata. - The first generator entrypoint reads a JSON-compatible `*GeneratedMapperMetadata` constant that can be assigned to queryspec `metadata`; arbitrary inline `metadata` object parsing is intentionally out of scope. Parse failures explain that the metadata object literal must stay JSON-compatible and show the regeneration/check failure before CI can pass. - The generated mapper uses root indexing, SQL row-order preservation, direct assignment, and no object spread in the hot loop. - Missing or unsafe metadata fails generation with a visible reason instead of guessing relations from aliases. diff --git a/docs/benchmarks/rawsql-vs-drizzle-speed-report.md b/docs/benchmarks/rawsql-vs-drizzle-speed-report.md deleted file mode 100644 index 713cff8f5..000000000 --- a/docs/benchmarks/rawsql-vs-drizzle-speed-report.md +++ /dev/null @@ -1,270 +0,0 @@ -# rawsql-ts vs Drizzle Pure ORM Performance Report - -Status: done for local Pure ORM evidence and scaffold drift-check design. Latest report date: 2026-05-03. Branch: `codex/rawsql-drizzle-benchmark`. - -HTTP benchmark results are intentionally excluded from this report. The current goal is to keep the recommended `rawsql-ts RFBA + AOT generated mapper` path fast and maintainable, so the decision loop uses Pure ORM, mapper microbenchmarks, scaffold drift checks, and startup-cost measurements. - -In this report, "Pure ORM" means the benchmark excludes HTTP routing, framework overhead, serialization/network transport, and app-level request handling. - -The main benchmark still includes real PostgreSQL query execution because the intended question is whether the natural `DB query + mapper` path remains fast enough in practical ORM usage. - -The accepted final Pure ORM result is stored under `benchmarks/drizzle-official-comparison/results-pure-orm-20260503-accepted-aot-direct-assignment`. This folder is a clarity alias for the accepted direct-assignment run, not a new measurement. The accepted final result is based on the run where generated direct-assignment aggregation was already included in the measured RFBA AOT mapper path. - -## 1. Generated Mapper Drift Check Design - -`generated/**` is machine-owned. The benchmark and RFBA scaffold direction only works if generated row mappers are treated as reproducible artifacts, not as files that humans or AI agents remember to refresh manually. - -Current drift-check design: - -| requirement | implementation | -|---|---| -| Detect SQL / contract / generated mapper mismatch | `ztd feature generated-mapper check` re-renders the expected mapper from the current scaffold source and compares it with `src/features//queries//generated/row-mapper.ts`. | -| Fail through standard check/test/CI path | `.github/workflows/ci.yml` runs `pnpm verify:generated-mapper-drift` on the Node 20 lane. | -| Show regeneration command on failure | The ztd-cli check reports `ztd feature generated-mapper generate --feature ` with an optional `--query ` scope. | -| Recover without editing `generated/` | `ztd feature generated-mapper generate` refreshes the machine-owned file from source artifacts. | -| Pair refresh command with passive detection | `generate` is the refresh command; `check` and `pnpm verify:generated-mapper-drift` are the passive detection path. | - -The generated file also includes source hashes: - -- `source-boundary-sha256` -- `source-sql-sha256` - -These hashes make drift visible in the generated artifact and help reviewers see which source changed. The authoritative check is still re-rendering and comparing the generated file. - -The repository-level drift script scans for scaffold-shaped `generated/row-mapper.ts` files, finds the owning package with a `ztd` script, and runs the package-local generated-mapper check. It fails if a scaffold generated mapper exists but the owning project cannot be found, because silently skipping checkable generated code would make CI drift detection unreliable. - -This repository now includes `packages/ztd-cli/fixtures/generated-mapper-drift`, a real package fixture with a scaffold-shaped generated mapper. `pnpm verify:generated-mapper-drift` should therefore detect at least one target and should not skip in the normal workspace. - -Skip is allowed only when the checked root contains no scaffold generated mapper artifacts at all. If checkable generated mappers exist but the script cannot locate the owning package, the check fails. - -## 2. Refresh Command + Passive Detection Flow - -The intended user flow is: - -1. User edits hand-owned files: `query.sql`, `queryspec.ts`, contract files, or a thin `boundary.ts`. -2. Standard checks run `pnpm verify:generated-mapper-drift`. -3. If generated output is stale, the check fails and prints the regeneration command. -4. User runs the printed `ztd feature generated-mapper generate ...` command. -5. `generated/**` is recreated deterministically and can be reviewed as a machine-owned diff. - -This keeps generated mapper usage passive and natural. Users do not opt into a special fast mode, and they do not hand-write mapper code. Ordinary scaffold usage is the fast path. - -CI coverage: - -| path | purpose | -|---|---| -| `pnpm verify:generated-mapper-drift` | Passive repository-level drift detection. | -| `pnpm --filter @rawsql-ts/ztd-cli test -- featureScaffold.unit.test.ts cliCommands.test.ts` | Verifies scaffold output shape, generated mapper commands, and command behavior. | -| `pnpm --filter @rawsql-ts/ztd-cli build` | Verifies ztd-cli TypeScript build after generator changes. | - -## 3. hasMany Generation Scope - -The direct-assignment aggregation improvement is accepted, but full `hasMany` / one-to-many generation should be treated as the next feature stage. It should start with a safe, metadata-backed subset rather than a broad inference engine. - -Recommended first supported scope: - -| area | first scope | -|---|---| -| root shape | One root object with a stable parent key. | -| relation shape | One collection relation from flat joined rows. | -| metadata source | Explicit generated query metadata, not alias guessing alone. | -| parent identity | Parent key column or columns must be declared. | -| child presence | Child key/nullability guard must be declared so outer joins can skip absent children. | -| result order | Preserve SQL row order and child order unless metadata says otherwise. | -| mapping style | Generated direct assignment, no object spread in the hot row loop. | -| boundary impact | Keep `boundary.ts` thin; aggregation details stay in query-local generated/internal code. | - -The current generator reads `*GeneratedMapperMetadata` by extracting the object literal and passing it to `JSON.parse`. That means the parsed object literal must be JSON-compatible: quoted keys and string values, no comments, no trailing commas, no spreads, no computed values, and no TypeScript identifiers inside the object. Type annotations or `satisfies` clauses outside the object are fine. Generated composite root keys use typed, length-prefixed segments rather than delimiter-only joins, so delimiter characters inside key values do not collide. - -Out of initial scope: - -- arbitrary deep object graphs -- many-to-many graph materialization -- polymorphic relations -- implicit relation discovery from column prefixes alone -- PostgreSQL JSON aggregation as the standard RFBA mapper path -- user-authored custom mapper code inside `generated/**` - -The feature should preserve the current design goal: scaffolded RFBA code should be fast by default, but the public surface should stay readable. - -## 4. Startup Parse Cost Benchmark - -Pure ORM steady-state benchmarks do not include SQL parsing in the hot path. Startup cost is measured separately to decide whether no-parse/static SQL options are worth designing for queries without dynamic search conditions. - -Command used in the materialized benchmark: - -```sh -pnpm bench:startup-cost -- --runs=20 --folder=results-startup-cost-20260503 -``` - -Artifact: - -- `benchmarks/drizzle-official-comparison/results-startup-cost-20260503/startup-cost-summary.json` - -Measured phases: - -| phase | runs | mean | p50 | p95 | min | max | -|---|---:|---:|---:|---:|---:|---:| -| SQL file load | 20 | 0.5299ms | 0.4854ms | 0.7122ms | 0.4640ms | 0.7446ms | -| rawsql parser import | 20 | 0.5927ms | 0.4354ms | 0.6634ms | 0.4043ms | 3.1543ms | -| SQL parse | 20 | 1.9829ms | 0.9597ms | 3.6801ms | 0.7747ms | 15.8635ms | -| catalog preparation | 20 | 3.1275ms | 2.5951ms | 6.2019ms | 2.0066ms | 6.3999ms | -| generated mapper import | 20 | 1.9104ms | 1.2311ms | 3.7804ms | 0.9332ms | 4.6435ms | - -Interpretation: - -- Startup parser and catalog work is visible, but it is outside the steady-state request/query hot path. -- `catalog-preparation` currently includes the RFBA `loadQueryCatalog` path: parser load, SQL file load, parse, and descriptor creation. -- Dynamic search support may justify the parser at startup, but static/no-dynamic queries now have a separate measurement target for evaluating a future no-parse or precompiled descriptor option. -- Generated mapper import/load is also separate from mapper execution and should not be mixed into steady-state Pure ORM conclusions. - -## 5. RFBA Scaffold Usage Impact - -The performance work keeps RFBA usage natural: - -- Users edit `query.sql`, `queryspec.ts`, contract files, and `boundary.ts`. -- `generated/**` remains machine-owned and reproducible. -- Generated direct-assignment mappers are the standard scaffolded internal implementation, not an advanced performance switch. -- Existing runtime mapper APIs and fallback paths remain compatible. -- `boundary.ts` remains the public surface and should not absorb SQL execution, cardinality, validation, or mapper details. - -The accepted mapper optimization was intentionally placed in generation output and benchmark internals, not in a user-facing fast API. This supports the product message: ordinary RFBA scaffold usage should be fast enough without making users choose between maintainability and performance. - -## 6. Accepted Pure ORM Result - -Benchmark source: - -- Official repository: -- Inspected upstream commit: `2ae27415a69f00b4f0f734ebb0a98e7799008819` -- Local benchmark overlay: `benchmarks/drizzle-official-comparison` - -Accepted artifact: - -- `benchmarks/drizzle-official-comparison/results-pure-orm-20260503-accepted-aot-direct-assignment/pure-orm-summary.json` - -Original measured folder: - -- `benchmarks/drizzle-official-comparison/results-pure-orm-20260503-breakdown-baseline/pure-orm-summary.json` - -The original folder name included `baseline`, but the run already included generated direct-assignment aggregation in the measured RFBA AOT path. The accepted folder was added so readers do not mistake the final accepted evidence for a pre-optimization baseline. - -Command: - -```sh -pnpm bench:pure-orm -- --runs=3 --iterations=2000 --warmup=100 --folder=results-pure-orm-20260503-breakdown-baseline -``` - -Environment: - -| item | value | -|---|---| -| OS | Microsoft Windows 11 Home, OS version `10.0.26200`, build `26200`, WindowsVersion `2009`, x64-based PC | -| CPU | AMD Ryzen 7 7800X3D 8-Core Processor, 8 cores / 16 logical processors | -| CPU cache | L2 `8192 KB`, L3 `98304 KB` | -| memory | 32 GiB class; observed total physical memory `33,378,181,120` to `34,359,738,368` bytes | -| Node.js | `v22.14.0` | -| pnpm | `10.17.0` | -| PostgreSQL | `PostgreSQL 18.3 (Debian 18.3-1.pgdg13+1)` | -| PostgreSQL settings | `max_connections=300`, `shared_buffers=128MB`, `work_mem=4MB` | - -Comparison strategy: - -| concern | handwritten | Drizzle | rawsql-ts RFBA + AOT | -|---|---|---|---| -| SQL authoring | Human-written SQL files. | Relational query API generates SQL. | Human-owned `query.sql` remains the review target. | -| DB execution path | Direct `pg` prepared query execution. | Drizzle prepared relational query execution. | RFBA query boundary over `pg` prepared query execution. | -| Result shaping | Handwritten row-to-result mapper code. | Drizzle relational mapper path. | Generated direct-assignment row mapper. | -| Nested result strategy | JavaScript mapper/aggregation over rows. | ORM-owned relational shaping; generated SQL may move part of shaping into the database depending on the relational query path. | Ordinary row/column SQL plus generated JavaScript mapper/aggregation. | -| Hot path validation | No extra runtime validation in the measured mapper path. | This report does not establish Drizzle's validation strategy; no extra per-row validation layer is added by the benchmark. | No runtime validation in the hot mapper path; correctness is shifted left to queryspec, drift checks, and ZTD-backed DB tests. | -| Review focus | SQL and mapper are both human-owned. | Review focuses on application query code and generated SQL when inspected. | SQL, queryspec, RFBA boundary, generated mapper drift, and DB-backed query tests. | -| Maintainability trade-off | Fast and explicit, but mapper code is manually maintained. | Higher-level query authoring, with ORM-owned SQL and mapping internals. | Human-reviewable SQL with machine-owned, reproducible mapper code. | - -Main result: - -| phase | target | ops/sec avg | p50 avg | p95 avg | p99 avg | -|---|---|---:|---:|---:|---:| -| DB query only | handwritten direct SQL | 1,534.29 | 0.6395ms | 0.7548ms | 0.9477ms | -| DB query only | Drizzle generated SQL reference | 1,582.39 | 0.6130ms | 0.7452ms | 0.9461ms | -| DB query only | rawsql-ts RFBA SQL | 1,565.46 | 0.6212ms | 0.7612ms | 0.9544ms | -| DB query + mapper | handwritten direct SQL | 1,570.23 | 0.6084ms | 0.7663ms | 0.9759ms | -| DB query + mapper | Drizzle JIT mapper | 1,547.65 | 0.6270ms | 0.7655ms | 0.9514ms | -| DB query + mapper | rawsql-ts RFBA + AOT mapper | 1,572.09 | 0.6085ms | 0.7282ms | 0.8971ms | - -Mapper and aggregation diagnostics: - -These rows are diagnostics for rawsql-ts mapper and aggregation work, not an ORM ranking table. - -The Drizzle `mapper-only` path is not reported here as a comparable number. In this benchmark it exercises the prepared Drizzle query path against fixture rows through the capture client, while handwritten and RFBA mapper-only rows call mapper functions directly over row arrays. That is useful as an implementation diagnostic, but the scope is not equivalent enough to claim "Drizzle mapper is slower" from this table. - -| phase | target | ops/sec avg | p50 avg | note | -|---|---|---:|---:|---| -| mapper-only | handwritten | 4,481,308.12 | 0.0002ms | direct mapper function | -| mapper-only | rawsql-ts RFBA AOT mapper | 4,254,124.74 | 0.0001ms | direct generated mapper function | -| aggregation-handwritten | handwritten | 5,035,996.86 | 0.0001ms | rawsql-ts diagnostic | -| aggregation-rfba-current | old helper/spread generated shape | 550,694.42 | 0.0013ms | rawsql-ts diagnostic | -| aggregation-rfba-optimized | direct-assignment generated shape | 4,389,558.53 | 0.0002ms | rawsql-ts diagnostic | -| mapper-only | Drizzle JIT mapper path | not comparable | not comparable | fixture-query diagnostic scope differs from direct mapper function calls | - -Interpretation: - -- rawsql-ts RFBA + AOT is in the same local Pure ORM range as handwritten and Drizzle for the measured cases. -- The accepted direct-assignment aggregation change materially reduces generated aggregation overhead without changing public RFBA usage. -- The later `.then(...)` boundary/executor experiment was rejected because it did not improve `DB query + mapper` and made call-chain diagnostics noisier. - -### Mapping and validation strategy notes - -This report is not a Drizzle evaluation benchmark. -Its purpose is to check whether the recommended rawsql-ts RFBA + AOT generated mapper path can stay close to handwritten and Drizzle in the natural `DB query + mapper` path. - -rawsql-ts intentionally does not use PostgreSQL JSON aggregation as the standard RFBA mapper path. - -The goal is to keep SQL reviewable as ordinary row/column SQL so query behavior, joins, filtering, and cardinality remain visible during review and debugging. - -Response-shape construction is intentionally kept outside the SQL layer. - -ORM relational query paths may choose different result-shaping strategies, including DB-side shaping. -Those are valid engineering trade-offs, but they change maintainability, reviewability, and debugging characteristics. - -rawsql-ts also intentionally avoids runtime validation in the hot mapper path. - -Correctness is shifted left through: - -- queryspec contracts -- generated mapper drift checks -- ZTD-backed SQL unit tests against a real database - -The intended trust boundary is different from arbitrary web input validation. -Database rows are already constrained by schema, SQL contracts, and DB-backed verification before the mapper executes. - -This report does not establish Drizzle's validation strategy. -The benchmark path does not add an extra per-row Zod-style validation layer around Drizzle results. -That is not inherently unsafe; an application can rely on schema definitions plus integration or E2E coverage. -The trade-off is coverage cost: E2E tests can prove the important application paths, but they are usually broader and more expensive than focused DB-backed query tests, so edge-case mapping drift can be easier to miss without additional targeted coverage. - -With that strategy, rawsql-ts RFBA + AOT generated mapper remains in the same local Pure ORM performance range as handwritten mapping. - -The benchmark evidence suggests that maintainable row/column SQL plus generated direct-assignment mappers can stay competitive without PostgreSQL JSON aggregation or runtime validation in the hot mapping path. - -## 7. Remaining Work - -Recommended next work: - -| priority | item | purpose | -|---:|---|---| -| 1 | Add DB-backed fixtures for generated `hasMany` output | Fixture-row behavior is covered; DB-backed ZTD cases can prove the same shape through a query boundary. | -| 2 | Expand startup-cost benchmark cases | Compare static/no-dynamic queries against dynamic-condition-capable catalog loading. | -| 3 | Investigate query-local call-chain cost | Reduce generated/internal indirection only if Pure ORM shows a measurable benefit and `boundary.ts` remains thin. | -| 4 | Keep HTTP benchmark as secondary evidence | Use it only after Pure ORM work to confirm app-level regression risk, not to decide ORM optimization direction. | - -## Artifacts - -- `benchmarks/drizzle-official-comparison/scripts/pure-orm-benchmark.ts` -- `benchmarks/drizzle-official-comparison/scripts/startup-cost-benchmark.ts` -- `benchmarks/drizzle-official-comparison/results-pure-orm-20260503-accepted-aot-direct-assignment/pure-orm-summary.json` -- `benchmarks/drizzle-official-comparison/results-pure-orm-20260503-breakdown-baseline/pure-orm-summary.json` -- `benchmarks/drizzle-official-comparison/results-pure-orm-20260503-chain-after/pure-orm-summary.json` -- `benchmarks/drizzle-official-comparison/results-startup-cost-20260503/startup-cost-summary.json` -- `scripts/check-generated-mapper-drift.mjs` -- `packages/ztd-cli/fixtures/generated-mapper-drift` -- `packages/ztd-cli/src/commands/feature.ts` -- `packages/ztd-cli/tests/featureScaffold.unit.test.ts` diff --git a/docs/dogfooding/DOGFOODING.md b/docs/dogfooding/DOGFOODING.md deleted file mode 100644 index 11bc51fed..000000000 --- a/docs/dogfooding/DOGFOODING.md +++ /dev/null @@ -1,300 +0,0 @@ -# Deterministic ZTD Dogfooding Spec - -This spec defines a deterministic dogfooding harness for `@rawsql-ts/ztd-cli` backend development. -Follow this file exactly and do not substitute ad-hoc scenarios. - -## Related practical lifecycle guides - -- [Migration Lifecycle Dogfooding](./ztd-migration-lifecycle.md) -- [Application Lifecycle Dogfooding](./ztd-application-lifecycle.md) - -## 0) Scope and objective - -- Objective A: Identify where progress still requires AI reasoning. -- Objective B: Identify what can be mechanized (CLI commands, scaffolding, docs, templates, happy-path guidance). -- Objective C: Verify alignment with ZTD principles: - - Development must not require dev-time migrations. - - Required behavior must not require SQL string concatenation. - -## 1) Fixed constraints - -- PostgreSQL version: **18** -- DBMS runtime: **Docker** -- `rawsql-ts` packages: use **local source code**, not npm releases - - This spec validates developer-mode dogfooding before publication. It does not claim to fully reproduce the published npm consumer path. -- Work location: a **git-untracked standalone folder outside any pnpm workspace/monorepo** - - This is the default mode because real-world usage is `@rawsql-ts/ztd-cli` as an npm package in a standalone repo. - - Windows-friendly example: `C:\Users\\tmp\rawsql-ts-dogfood\run-XX\` (must NOT be under the `rawsql-ts` repository tree) -- Local-source mode note: even with `--local-source-root`, the run directory must stay standalone (outside workspace) to keep dependency resolution deterministic and avoid workspace absorption. -- DDL baseline is fixed to Section 2 - -### 1.1 Required environment capture - -Before starting Scenario 1, capture these versions in the report: - -- OS -- Node.js -- pnpm -- Docker Engine -- PostgreSQL image tag used for execution - -### 1.2 Local-source invocation (recommended developer mode) - -Use this canonical local-source invocation form from ``: - -```bash -node "/packages/ztd-cli/dist/index.js" -``` - -- ``: absolute path to the `rawsql-ts` repository root. -- ``: standalone dogfooding run directory (outside any workspace), where commands are executed. -- If `dist/index.js` is missing, build once from ``: - -```bash -pnpm -C "" --filter @rawsql-ts/ztd-cli build -``` - -Note: model-gen (`--probe-mode ztd`) requires `ZTD_DB_URL`. `ztd-cli` does not read `DATABASE_URL` automatically. Also, model-gen is currently SELECT-oriented; INSERT/UPDATE/DELETE SQL may fail with parser errors and is out of scope for model-gen in this dogfooding scenario. - -Note on modes: - -- `Developer mode` means local-source execution from a repo checkout without publishing first. -- `Published package mode` means installing released packages from npm in a standalone repo. -- This spec is intentionally about `Developer mode` so backend dogfooding does not depend on package publication. -- When you need a pre-release check for the npm consumer path, run the separate repository-root workflow in [Published-Package Verification Before Release](../guide/published-package-verification.md). - -## 2) Fixed DDL baseline - -Create `schema.sql` with the exact content below. - -```sql --- schema.sql - -create table product ( - product_id bigserial primary key, - sku text not null unique, - name text not null, - price_yen integer not null check (price_yen >= 0), - created_at timestamptz not null default now() -); - -create table sale ( - sale_id bigserial primary key, - sale_date date not null, - customer_note text null, - created_at timestamptz not null default now() -); - -create table sale_line ( - sale_line_id bigserial primary key, - sale_id bigint not null references sale(sale_id), - product_id bigint not null references product(product_id), - qty integer not null check (qty > 0), - unit_price_yen integer not null check (unit_price_yen >= 0) -); - -create index sale_line_sale_id_idx on sale_line(sale_id); -create index sale_line_product_id_idx on sale_line(product_id); -``` - -## 3) Scenario 1 (New backend) - -Implement sale backend features: - -- Create sale with lines -- List sales (pagination optional) -- Get sale by id (including lines) -- Update sale (customer note + replace lines) -- Delete sale - -### 3.1 Required artifacts - -- SQL assets (DDL + queries) -- Repository or equivalent backend code -- Tests (unit or integration) - -### 3.2 Pass criteria - -- No dev-time migration requirement -- No SQL string concatenation for required behavior -- Artifacts and tests are executable with clear file evidence - -## 4) Scenario 2 (Schema/spec changes) - -Scenario 2 validates survivability under deterministic change. - -### 4.1 Candidate C (fixed) - -Apply all of the following fixed changes. - -1. Add table `payment` -2. Add `payment.sale_id` referencing `sale(sale_id)` -3. Add a new query joining `sale` and `payment` to return sales with payment info - -Append the following DDL: - -```sql -create table payment ( - payment_id bigserial primary key, - sale_id bigint not null references sale(sale_id), - paid_at timestamptz not null, - amount_yen integer not null check (amount_yen >= 0), - method text not null, -- e.g. "cash", "card" - created_at timestamptz not null default now() -); - -create index payment_sale_id_idx on payment(sale_id); -create index payment_paid_at_idx on payment(paid_at); -``` - -Query requirements (fixed): - -- Inputs: `:from_paid_at`, `:to_paid_at` -- Output fields: - - `sale.sale_id`, `sale.sale_date` - - `payment.payment_id`, `payment.paid_at`, `payment.amount_yen`, `payment.method` -- Join: `payment.sale_id = sale.sale_id` (**INNER JOIN**) -- Filter: date range on `payment.paid_at` - -Rule: For Scenario 2 runs, use a fresh Postgres container / fresh database to apply the updated schema, to avoid noisy "relation already exists" output and keep logs comparable. -OPTIONAL (destructive): If you must reuse a DB, reset schema first: `drop schema public cascade; create schema public;`. CAUTION: LOCAL/DISPOSABLE DB ONLY — DO NOT RUN IN PRODUCTION OR ON SHARED DATABASES. - -### 4.2 Pass criteria - -- Still no dev-time migration requirement -- Still no SQL string concatenation -- Generated/spec artifacts and tests updated with clear diff evidence - -## 5) Fixed execution log format (required) - -All runs must include a log in this exact structure: - -```text -[YYYY-MM-DDTHH:MM:SSZ] STEP ACTION "" (UTC recommended) -CMD: - -RESULT: -(exit=) -STDOUT: - -STDERR: - -NOTES: - -``` - -## 6) Fixed report template (required) - -Submit `DOGFOOD_REPORT.md` using this template. - -```markdown -# DOGFOOD REPORT - -## Metadata -- Run date: -- Runner: -- Spec file: -- Repo commit: - -## Environment -- OS: -- Node.js: -- pnpm: -- Docker: -- PostgreSQL image: - -## Files changed -- - -## Scenario 1: New backend -- Result: PASS | PARTIAL | FAIL -- Implemented scope: -- Evidence pointers: LOG STEP , -- Notes: - -## Scenario 2: Schema/spec changes (Candidate C) -- Result: PASS | PARTIAL | FAIL -- Change summary: -- Evidence pointers: LOG STEP , -- Notes: - -## Command and trial metrics -- Total step count: -- Trial/error count: -- Commands that required retries: - -## Frictions - -### Needs CLI automation -- - - Evidence: LOG STEP - -### Feature exists but undiscoverable -- - - Evidence: LOG STEP - -### Forces migration (ZTD violation) -- - - Evidence: LOG STEP - -### Forces SQL string concatenation (security risk) -- - - Evidence: LOG STEP - -## Improvement proposals -- CLI: -- Docs: -- Discoverability: -- Templates/scaffolding: - -## Happy-path draft (shortest successful steps) -1. -2. -3. - -## Open questions -- -``` - -## 7) Deterministic run rules - -- Do not alter scenario definitions during execution. -- If blocked, do not change goals; record the block in LOG and REPORT. -- Distinguish strictly between: - - mechanizable repetition (CLI/docs/template candidates) - - judgment-required work (domain design, naming, contract semantics) - -## 7.1) Lint placeholder handling note - -Note: ztd lint validates SQL via Postgres. When queries contain placeholders ($1, $2, or named params), lint injects default bindings (e.g. null) to avoid unbound-parameter failures (42P02) and to surface SQL-level diagnostics instead. - -## 7.2) OPTIONAL: Running inside a workspace (not recommended) - -- If you run under a directory governed by a parent `pnpm-workspace.yaml`, pnpm may absorb installs into the parent workspace. -- This can introduce unrelated dependency-resolution friction and reduce determinism for dogfooding signals. -- This mode is intentionally not the default because it does not represent typical npm-package users. -## Companion scenarios - -For focused companion flows, use these scenario documents instead of expanding this backend-focused harness inline: - -- [SQL Debug Recovery Dogfooding](./sql-debug-recovery.md) -- [SSSQL Optional-Condition Dogfooding](./sssql-optional-condition.md) -- [Test Documentation Dogfooding](./test-documentation.md) -- [Perf Scale Tuning Dogfooding](./perf-scale-tuning.md) -- [Dynamic Filter Routing Dogfooding](./dynamic-filter-routing.md) - -Those companion scenarios cover broken long-CTE SQL recovery, query graph / query slice / query patch apply usage, truthful optional-condition authoring with SSSQL, direct-vs-decomposed perf evidence loops, scale-sensitive index-vs-pipeline tuning decisions, and the human-readable test documentation export path. -## 8) Recommended run skeleton - -Use this order unless a hard blocker appears. - -1. Prepare an untracked standalone workspace outside any pnpm workspace (for example `C:\Users\\tmp\rawsql-ts-dogfood\run-XX\`). -2. Create Docker PostgreSQL 18 container for runtime checks. -3. Scaffold project with local source linkage. -4. Apply fixed baseline DDL. -5. Implement Scenario 1 assets and tests. -6. Apply Candidate C changes for Scenario 2. -7. Regenerate affected artifacts and tests. -8. Run verification commands. -9. Produce LOG and REPORT files. - diff --git a/docs/dogfooding/perf-scale-tuning.md b/docs/dogfooding/perf-scale-tuning.md deleted file mode 100644 index c9020b814..000000000 --- a/docs/dogfooding/perf-scale-tuning.md +++ /dev/null @@ -1,57 +0,0 @@ -# Perf Scale Tuning Dogfooding - -This scenario preserves the shortest dogfooding loop for volume-sensitive SQL tuning. -The goal is to keep the decision between **index tuning** and **pipeline tuning** explicit, reproducible, and backed by QuerySpec metadata plus local DDL. -The main message is that both branches should tune the workload without breaking the SQL unless the evidence shows that SQL shape itself must change. - -## Use this scenario when - -Use this scenario when a prompt sounds like: - -- "This query will run on tens of thousands of rows." -- "Should we add an index or split this into a pipeline?" -- "The QuerySpec already declares a large scale; prove the next tuning branch." - -## Required inputs - -- QuerySpec `metadata.perf` with at least `expectedScale`, and ideally row expectations. -- `perf/seed.yml` large enough to approximate the intended workload. -- `ztd/ddl/*.sql` containing every physical table and every index you expect the perf sandbox to use. - -## Prompt contract - -A good dogfooding prompt for this scenario should say all of the following explicitly: - -- how large the query is expected to be in production -- whether the concern sounds like scan pressure, join pressure, or repeated intermediate work -- which DDL/index definitions should already exist in the perf sandbox -- whether the repo already has a saved perf run or whether the first task is evidence capture - -If the prompt omits those points, the scenario is not complete yet. The first repair is to gather the missing perf evidence, not to guess the tuning branch. -This keeps the tuning loop on the CLI-evidence path instead of relying on AI guesswork. - -## Happy-path loop - -1. Confirm `metadata.perf` in the QuerySpec. -2. Confirm `ztd perf db reset --dry-run` reports the expected DDL files, table count, and index count. -3. Run `ztd perf run` and inspect: - - `spec_guidance` - - `ddl_inventory` - - `tuning_guidance` -4. If `tuning_guidance.primary_path` is `index`, update DDL and rerun `ztd perf db reset`. -5. If `tuning_guidance.primary_path` is `pipeline`, compare `--strategy direct` and `--strategy decomposed`. -6. Save evidence once the winning branch is stable. - -## Regression surface - -- Test file: `packages/ztd-cli/tests/perfBenchmark.unit.test.ts` -- Test name: `runPerfBenchmark dry-run reports ddl inventory and pipeline-first tuning guidance for scale dogfooding` -- Test file: `packages/ztd-cli/tests/perfSandbox.unit.test.ts` -- Test name: `inspectPerfDdlInventory counts CREATE INDEX statements so perf reset can recreate them` - -## What this scenario protects - -- QuerySpec scale metadata remains connected to perf guidance. -- Maintainers can see whether the sandbox DDL already includes the indexes being discussed. -- Index fixes do not stay as throwaway sandbox changes; they are pushed back into DDL. -- Pipeline tuning stays evidence-driven instead of becoming a generic rewrite reflex. diff --git a/docs/dogfooding/rfba-concept-spec-dogfooding.json b/docs/dogfooding/rfba-concept-spec-dogfooding.json deleted file mode 100644 index f4ed70c2d..000000000 --- a/docs/dogfooding/rfba-concept-spec-dogfooding.json +++ /dev/null @@ -1,323 +0,0 @@ -{ - "schemaVersion": 1, - "purpose": "AI-managed dogfooding ledger for rawsql-ts, ztd-cli, ztd, RFBA, and Concept Spec workflows.", - "humanReadablePrimary": false, - "domains": [ - "rawsql-ts", - "ztd-cli", - "ztd", - "RFBA", - "Concept Spec" - ], - "statusVocabulary": [ - "unresolved", - "closed", - "accepted-intentional", - "follow-up-candidate" - ], - "recordingRule": { - "whenToRecord": [ - "avoidable rework", - "confusing command behavior", - "missing review gate", - "hard-to-diagnose generated artifact drift", - "Concept Spec ambiguity", - "RFBA ambiguity", - "workflow only became correct after human correction" - ], - "defaultStatus": "unresolved", - "closeWhen": [ - "addressed by artifact or workflow change", - "accepted as intentional behavior", - "captured as concrete follow-up candidate" - ], - "reportWhenAsked": [ - "all unresolved observations", - "observations closed since the previous report" - ] - }, - "unresolvedObservations": [ - { - "id": "explicit-control-over-implicit-order", - "domains": [ - "rawsql-ts", - "ztd-cli", - "ztd", - "RFBA", - "Concept Spec" - ], - "summary": "Important constraints must be controlled explicitly instead of depending on implicit expectations such as file-name ordering.", - "signals": [ - "Runtime DDL files were initially named with a zz_ prefix so they would load after master DDL files.", - "The human pointed out that file names should not carry execution-order semantics.", - "The correction introduced an explicit DDL order metadata file and kept file names descriptive.", - "The human clarified the broader development stance: relying on AI inference is the last resort; first prefer mechanisms and mechanical checks that prevent mistakes." - ], - "risk": "If important constraints are hidden in naming, ordering, inference, or convention alone, agents and maintainers may miss the rule, edit it accidentally, or make changes that are hard to review mechanically.", - "expectedPattern": [ - "Treat AI inference as the last resort, not the primary safety mechanism.", - "First ask whether a mechanism, schema, metadata file, test, lint rule, generated check, or explicit control surface can prevent the mistake.", - "Use explicit machine-readable control files or schema fields for important constraints.", - "Keep maintenance cost low when the constraint needs to be edited.", - "Prefer mechanically checkable constraints over implicit expectations." - ], - "possibleFollowUp": "Add or extend checks so DDL order, concept relationships, generated artifact sources, and other important constraints are validated from explicit metadata rather than inferred from names.", - "status": "unresolved" - } - ], - "closedObservations": [ - { - "id": "concept-map-needed", - "domains": [ - "Concept Spec", - "ztd-cli" - ], - "summary": "Concept Spec needs a package-level concept map.", - "signals": [ - "Important terms appeared before their own Concept Specs existed.", - "Defined, planned, embedded, and undefined terms were hard to distinguish mechanically." - ], - "examples": [ - "transfer-execution", - "lineage", - "active-black", - "work-item", - "key-map", - "duplicate-control", - "red-transfer", - "black-row" - ], - "resolution": "Added draft concept-map.md and concept-relationship.json at the transfer concept root.", - "status": "closed" - }, - { - "id": "concept-spec-h2-skeleton", - "domains": [ - "Concept Spec" - ], - "summary": "Concept Spec review benefits from a stable H2 structure.", - "signals": [ - "Dirty Key, Destination, Transfer Setting, and Transfer Request became easier to compare after H2 alignment.", - "Review and AI parsing cost dropped when section order stabilized." - ], - "expectedPattern": [ - "Position", - "Definition", - "Non-responsibilities", - "Responsibilities", - "Invariants", - "Why", - "Usage Notes" - ], - "resolution": "Captured as Concept Spec review guidance and applied to current transfer Concept Specs.", - "status": "closed" - }, - { - "id": "related-terms-not-enough", - "domains": [ - "Concept Spec", - "ztd-cli" - ], - "summary": "Concept relationships should not live only in Related Terms.", - "signals": [ - "Undefined terms were easy to miss.", - "Relationship direction was not mechanically visible.", - "CLI checks could not infer defined versus undefined terms from prose alone." - ], - "resolution": "Use concept-map.md for human review and concept-relationship.json for AI/CLI discovery.", - "status": "closed" - }, - { - "id": "rfba-review-responsibility-not-sql-exposure", - "domains": [ - "RFBA", - "rawsql-ts" - ], - "summary": "RFBA is review responsibility, not SQL exposure.", - "signals": [ - "SQL as a review target could be misread as SQL being the definition of RFBA.", - "Non-SQL review concerns needed clearer placement." - ], - "resolution": "Updated RFBA overview to define RFBA by review-worthy concerns and review responsibility.", - "status": "closed" - }, - { - "id": "ztd-cli-boundary-template", - "domains": [ - "ztd-cli", - "RFBA" - ], - "summary": "ztd-cli scaffold should guide feature boundary shape.", - "signals": [ - "create-transfer-setting initially needed cleanup into input -> workflow -> output.", - "boundary.ts can become a collection of helpers without a visible use-case flow." - ], - "expectedPattern": [ - "parse input", - "execute workflow", - "build output" - ], - "resolution": "Captured as closed dogfooding observation and follow-up candidate for future scaffold improvements.", - "status": "closed" - }, - { - "id": "generated-drift-review-timing", - "domains": [ - "ztd", - "ztd-cli", - "rawsql-ts" - ], - "summary": "Generated artifact drift is useful but must not replace source review.", - "signals": [ - "Generated mapper drift checks caught stale generated files.", - "Human SQL/queryspec review still needed to happen before treating tests as enough evidence." - ], - "resolution": "Captured as workflow guidance: drift checks are mechanical guards, not substitutes for source SQL/queryspec review.", - "status": "closed" - }, - { - "id": "concept-map-reduces-name-negation", - "domains": [ - "Concept Spec", - "RFBA" - ], - "summary": "Once a package-level Concept Map exists, individual Concept Specs should avoid long lists of simple name negations.", - "signals": [ - "Early Concept Specs used Non-responsibilities to say a concept was not several adjacent concepts.", - "After concept-map.md and concept-relationship.json were introduced, those distinctions became globally visible.", - "The remaining value of Non-responsibilities is naming responsibilities that must not be implemented in this concept." - ], - "resolution": "Cleaned transfer Concept Specs to prefer responsibility-boundary wording and updated concept-spec-review skill with a Concept Map aware boundary check.", - "status": "closed" - }, - { - "id": "concept-map-normalizes-related-terms", - "domains": [ - "Concept Spec", - "ztd-cli" - ], - "summary": "Per-spec Related Terms lists duplicate concept-map relationship data once a package-level Concept Map exists.", - "signals": [ - "Related Terms sections created a second maintenance surface for concept relationships.", - "Concept Map and concept-relationship.json can act as the normalized source for relationships and undefined terms." - ], - "resolution": "Removed Related Terms sections from transfer Concept Specs and updated concept-spec-review skill to prefer concept-map normalized relationships.", - "status": "closed" - }, - { - "id": "process-map-separates-use-case-flow", - "domains": [ - "Concept Spec", - "RFBA" - ], - "summary": "Concept Map became too process-oriented once Transfer Execution and Lineage Trace diagrams were added.", - "signals": [ - "Transfer Execution Main and process details proved that concepts could be logically lowered into process flow.", - "Keeping those diagrams in concept-map.md made Concept Map carry use-case process responsibility instead of static concept relationship responsibility.", - "The human clarified that master correlation belongs in Concept Map, while transfer execution and lineage trace belong in process/use-case documents." - ], - "resolution": "Split process diagrams into packages/transfer/docs/processes, added process-map.json, and updated Concept Spec/RFBA guidance plus concept-spec-review skill to distinguish Concept Specs, Concept Maps, Process Maps, and RFBA.", - "status": "closed" - }, - { - "id": "process-map-flow-detail-review-shape", - "domains": [ - "Concept Spec", - "RFBA" - ], - "summary": "Process Map review needs explicit flow/detail structure and conceptual I/O rules.", - "signals": [ - "Transfer Execution became readable only after separating main routine flow from per-process details.", - "Lineage Trace diagrams became understandable only after representing read-only outputs as derived views or key lists.", - "One-input/one-output details were too weak when they did not reveal what concept relationship was being derived.", - "Process Maps should prove conceptual derivability, not prescribe exact SQL, CTE, memory, performance, or transaction implementation." - ], - "resolution": "Updated concept-spec-review skill with Process Map rules for flowchart TD process flows, flowchart LR process details, conceptual input/output, derived views, and implementation-leakage checks.", - "status": "closed" - }, - { - "id": "concept-status-promotion-sync", - "domains": [ - "Concept Spec", - "ztd-cli" - ], - "summary": "Promoting a concept from draft to defined needs cheap, detectable synchronization across the concept folder, concept-map.md, and concept-relationship.json.", - "signals": [ - "active-black moved from planned-specialized to defined.", - "The human-readable map, machine-readable JSON, diagram labels, review request wording, and relationship certainty all needed coordinated edits.", - "A separate draft area would make TODO visibility, link stability, and promotion state harder to inspect mechanically." - ], - "resolution": "Adopted stable concept folders with DRAFT.md for drafts and SPEC.md for defined concepts, documented promotion rules, and updated concept-spec-review skill to detect draft/defined lifecycle drift. Future CLI support remains a follow-up candidate.", - "status": "closed" - } - ], - "followUpCandidates": [ - { - "id": "concept-relationship-status-vocabulary", - "domains": [ - "Concept Spec", - "ztd-cli" - ], - "question": "Should concept-relationship.json support statuses such as planned, candidate, undefined, embedded, and deferred?" - }, - { - "id": "ztd-cli-concept-commands", - "domains": [ - "ztd-cli", - "Concept Spec" - ], - "question": "Should ztd-cli eventually provide concept list, concept check, or feature related-specs commands?" - }, - { - "id": "ztd-cli-concept-rfba-support-boundary", - "domains": [ - "ztd-cli", - "Concept Spec", - "RFBA" - ], - "question": "Should ztd-cli scaffold and reports make Concept Spec references visible while avoiding authoritative concept prose generation?" - }, - { - "id": "concept-check-warning-first", - "domains": [ - "ztd-cli", - "Concept Spec" - ], - "question": "Should concept relationship checks be warning-only at first, except for broken JSON or broken paths?" - }, - { - "id": "rfba-scaffold-input-workflow-output", - "domains": [ - "RFBA", - "ztd-cli" - ], - "question": "Should RFBA scaffold templates include a default input -> workflow -> output boundary pattern?" - }, - { - "id": "concept-spec-review-pre-pr", - "domains": [ - "Concept Spec", - "RFBA" - ], - "question": "Should Concept Spec review become part of a lightweight pre-PR checklist for concept changes?" - }, - { - "id": "transfer-operational-observability", - "domains": [ - "rawsql-ts", - "Concept Spec" - ], - "question": "Transfer engine may be OSS while operational services may be commercial; should Concept Specs preserve enough transfer history context for visualization, analysis, logs, debugging, and secondary use without forcing every detail into core tables?" - }, - { - "id": "ddl-docs-check-validator-modularization", - "domains": [ - "rawsql-ts", - "Concept Spec", - "RFBA" - ], - "question": "Should ddl-docs-cli check.ts be split into schema, table, column, index, relationship, and lifecycle validators so future Concept Spec / DFD / DDL metadata checks can grow without one large validator becoming fragile?" - } - ], - "lastReportedUnresolvedObservationIds": [] -} diff --git a/docs/dogfooding/sql-debug-recovery.md b/docs/dogfooding/sql-debug-recovery.md deleted file mode 100644 index cdd4cdf26..000000000 --- a/docs/dogfooding/sql-debug-recovery.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -title: SQL Debug Recovery Dogfooding ---- - -# SQL Debug Recovery Dogfooding - -This scenario exercises the `ztd-cli` SQL debugging loop on a deliberately broken long CTE query and verifies that the saved evidence is enough for an AI agent to decide the next action without waiting for a human. - -## Goal - -Restore a broken multi-CTE query, isolate the failing stage, patch the SQL safely, and measure whether the repaired direct query or a decomposed execution path is the better next step. - -## When to use this scenario - -Use this scenario when all of the following are true: - -- The SQL file is long enough that a direct manual read is slow. -- One or more CTE stages are broken, suspicious, or too expensive to inspect inline. -- You want the next tuning step to come from evidence rather than intuition. - -## Regression surface - -- Test file: `packages/ztd-cli/tests/sqlDebugDogfooding.cli.test.ts` -- Test name: `sql debug recovery dogfood scenario preserves the shortest command loop artifact` - -This regression surface keeps the command-level recovery path in git so future changes can verify that the saved evidence is still enough for the next action. -## Inputs - -- A SQL file with a long `WITH` chain. -- Optional params in `perf/params.json` or `perf/params.yml`. -- A perf sandbox initialized through `ztd perf init`, `ztd perf db reset`, and `ztd perf seed`. - -## Shortest recovery loop - -1. Inspect the structure with `ztd query outline` and `ztd query graph`. -2. Use `ztd query lint` to find unused CTEs, structural risks, and likely hotspots. -3. Slice the suspicious CTE or final query with `ztd query slice`. -4. Repair the slice locally, then merge it back with `ztd query patch apply --preview`. -5. Re-run `ztd query graph` or `ztd query outline` to confirm the repaired dependency shape. -6. Compare `ztd perf run --strategy direct` with `ztd perf run --strategy decomposed --material ...`. -7. Use `ztd perf report diff` to decide whether rewrite, indexing, or materialization is the next move. - -## Example walkthrough - -### 1. Map the long CTE graph - -```bash -ztd query outline src/sql/reports/customer_health.sql -ztd query graph src/sql/reports/customer_health.sql --format dot -ztd query lint src/sql/reports/customer_health.sql --format json -``` - -What this gives the AI: - -- The full CTE inventory and final-query roots. -- Dependency fan-out and likely pipeline candidates. -- Early warnings such as unused CTEs, duplicate subgraphs, and analysis risks. - -### 2. Isolate the broken stage - -```bash -ztd query slice src/sql/reports/customer_health.sql --cte suspicious_rollup --out tmp/suspicious_rollup.sql -``` - -Repair `tmp/suspicious_rollup.sql`, run it independently, then apply it back: - -```bash -ztd query patch apply src/sql/reports/customer_health.sql \ - --cte suspicious_rollup \ - --from tmp/suspicious_rollup.sql \ - --preview -``` - -If the preview looks correct, write the repaired SQL to a new file or overwrite the original. - -### 3. Compare direct and decomposed execution - -```bash -ztd perf run \ - --query src/sql/reports/customer_health.sql \ - --params perf/params.yml \ - --strategy direct \ - --mode auto \ - --save \ - --label before-decompose - -ztd perf run \ - --query src/sql/reports/customer_health.sql \ - --params perf/params.yml \ - --strategy decomposed \ - --material suspicious_rollup,customer_rollup \ - --mode auto \ - --save \ - --label after-decompose - -ztd perf report diff perf/evidence/run_001 perf/evidence/run_002 -``` - -What this gives the AI: - -- Total run metrics for the entire query path. -- Per-statement metrics and plans for each materialized stage plus the final query. -- The actual SQL that ran, not just the source SQL file. -- Evidence to answer whether the final query improved while total runtime got worse because of materialization overhead. - -## What good evidence looks like - -The evidence is useful when it answers these questions without another human pass: - -- Which CTE stage is broken or structurally risky? -- Which SQL actually ran after params were bound or execution was decomposed? -- Did the direct query get faster, slower, or simply move the cost into a materialize step? -- Is the next action more likely to be an index, a SQL rewrite, or explicit materialization? - -## Optional telemetry path - -Telemetry stays opt-in, but this is one of the best dogfooding paths for it because the loop has clear phases. - -```bash -ztd --telemetry --telemetry-export file --telemetry-file tmp/telemetry/perf-run.jsonl \ - perf run --query src/sql/reports/customer_health.sql --params perf/params.yml --mode auto --dry-run -``` - -Useful spans for this scenario: - -- `perf run` -- `resolve-perf-run-options` -- `execute-perf-benchmark` -- `render-perf-report` - -This lets maintainers verify that the debug loop is discoverable and that machine-facing output stays aligned with the command phases. - -## Why this scenario matters - -This is the shortest realistic path that exercises the current SQL debugging stack together: - -- `query outline` -- `query graph` -- `query lint` -- `query slice` -- `query patch apply` -- `perf run` -- `perf report diff` - -If this scenario is smooth, the core SQL debugging surface is genuinely usable for AI-assisted repair and tuning loops rather than being a set of disconnected commands. diff --git a/docs/dogfooding/telemetry-dogfooding.md b/docs/dogfooding/telemetry-dogfooding.md deleted file mode 100644 index a03258280..000000000 --- a/docs/dogfooding/telemetry-dogfooding.md +++ /dev/null @@ -1,131 +0,0 @@ -# Telemetry Dogfooding Scenarios - -This guide records telemetry-focused dogfooding scenarios that are meant to stay in git as regression surfaces. -The goal is not only to prove that telemetry exists, but to preserve the shortest investigation loops where telemetry is the correct next step. - -## Scenario A: Query Uses impact-analysis timeline - -Use this scenario when a schema-impact scan starts feeling slower or structurally different and you need to know which command phase changed. - -### Why telemetry is the right tool here - -`query uses` already answers the impact question without telemetry. -Telemetry becomes useful only after the structural path is known and you need to confirm phase boundaries such as: - -- option resolution -- spec discovery -- impact aggregation -- output rendering - -### Regression surface - -- Test file: `packages/ztd-cli/tests/commandTelemetry.unit.test.ts` -- Test name: `query uses telemetry dogfood scenario preserves a stable impact-analysis timeline artifact` - -### Expected timeline - -```text -start:query uses column -start:resolve-query-options -start:build-query-usage-report -start:spec-discovery -start:impact-aggregation -start:render-query-usage-output -end:query uses column:ok -``` - -## Scenario B: Model-Gen probe diagnosis timeline - -Use this scenario when `model-gen` fails or slows down and the next question is which phase is responsible. - -### Why telemetry is the right tool here - -`model-gen` has multiple meaningful phases and one explicit probe decision. -Telemetry is useful here because it distinguishes: - -- placeholder scan -- probe connection -- probe query column inspection -- type inference -- generated file rendering -- file emission - -### Regression surface - -- Test file: `packages/ztd-cli/tests/commandTelemetry.unit.test.ts` -- Test name: `model-gen telemetry dogfood scenario preserves the probe diagnosis timeline` - -### Expected timeline - -```text -start:model-gen -start:resolve-model-gen-inputs -decision:model-gen.probe-mode -start:placeholder-scan -start:probe-client-connect -start:probe-query-columns -start:type-inference -start:render-generated-output -start:file-emit -end:model-gen:ok -``` - - -## Scenario C: Perf run benchmark-phase attribution timeline - -Use this scenario when `perf run --dry-run` still returns a structurally correct report, but you need to know whether a regression belongs to option resolution, benchmark execution, or report rendering. - -### Why telemetry is the right tool here - -`perf run --dry-run` already explains the chosen strategy and evidence shape. -Telemetry becomes useful only when the next question is which command phase drifted. - -### Regression surface - -- Test file: `packages/ztd-cli/tests/commandTelemetry.unit.test.ts` -- Test name: `perf run telemetry dogfood scenario preserves the benchmark investigation timeline` - -### Expected timeline - -```text -start:perf run -start:resolve-perf-run-options -start:execute-perf-benchmark -start:render-perf-report -end:perf run:ok -``` - -## Scenario D: Repository telemetry scaffold replacement loop - -Use this scenario when `ztd init --with-sqlclient` claims to scaffold repository telemetry, but you need to prove the generated hook is both safe by default and replaceable by application code. - -### Why telemetry is the right tool here - -This is the shortest dogfooding loop that answers the real repository integration questions: - -- does the scaffold emit structured repository events out of the box? -- is SQL text still suppressed by the default console implementation? -- can application code replace the hook without editing generated internals? - -### Regression surface - -- Test file: `packages/ztd-cli/tests/init.command.test.ts` -- Test name: `repository telemetry scaffold dogfood scenario keeps the default hook replaceable and conservative` - -### Expected assertions - -```text -- default hook emits start/success/error repository events -- default console payload omits sqlText unless explicitly enabled -- custom hook receives the structured events directly -- custom hook bypasses the default console sink -``` -## What this guide is for - -These scenarios are intentionally narrow. -They exist so future changes can answer two regression questions quickly: - -1. Did the telemetry path still expose the same investigation phases? -2. Would a maintainer still know where to look next without ad-hoc debugging? - -If a future telemetry feature cannot improve one of those two answers, it is probably not a dogfooding priority. diff --git a/docs/dogfooding/test-documentation.md b/docs/dogfooding/test-documentation.md deleted file mode 100644 index 017d0d55a..000000000 --- a/docs/dogfooding/test-documentation.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: Test Documentation Dogfooding ---- - -# Test Documentation Dogfooding - -This scenario exercises the `ztd evidence test-doc` path and verifies that the exported Markdown is enough for a maintainer or AI agent to understand what the current ZTD test assets cover without opening the source files first. - -## Goal - -Export one human-readable Markdown document that answers these questions immediately: - -- Which SQL and function catalogs exist? -- What each catalog is meant to protect? -- Which test cases are available for the current happy path? -- Which fixtures and execution style the SQL catalogs expect? - -## When to use this scenario - -Use this scenario when all of the following are true: - -- You already have executable ZTD test assets. -- You need a shareable summary for review, onboarding, or AI handoff. -- Raw JSON evidence is too low-level for the immediate next step. - -## Regression surface - -- Test file: `packages/ztd-cli/tests/testDocumentationDogfooding.cli.test.ts` -- Test name: `test documentation dogfood scenario preserves the shortest export loop artifact` - -This regression surface keeps the shortest export loop in git so future changes can prove that the human-readable documentation path still captures catalog purpose, execution style, fixtures, and case-level expectations. - -## Shortest happy path - -1. Prepare a workspace with either feature-local QuerySpecs, legacy SQL specs, or `tests/specs/index.*` exports. -2. Run `ztd evidence test-doc --out artifacts/test-evidence/test-documentation.md`. -3. Inspect the Markdown for catalog summaries, case lists, fixture notes, and expected results. - -## Example walkthrough - -### 1. Prepare a minimal workspace - -The workspace must include at least one of the following: - -- Option A, feature-side: Feature-local QuerySpec-like files under `src/features/**`, or legacy `src/catalog/specs/*.spec.json`, for SQL catalog metadata. -- Option B, test-side: `tests/specs/index.*` for function and SQL catalog case exports. - -### 2. Export the documentation - -```bash -ztd evidence test-doc --out artifacts/test-evidence/test-documentation.md -``` - -What this gives the AI: - -- One Markdown file instead of separate JSON plus source-file hops. -- Stable catalog headings, purpose text, and case summaries. -- Fixture visibility for SQL catalogs. - -### 3. Review the exported sections - -Good output should include all of the following: - -- top-level summary counts -- one section per catalog -- a case list for each catalog -- input/setup and expected-result blocks per case -- definition links that point back to the source catalog files - -## What good evidence looks like - -The export is useful when it answers these questions without another source-code pass: - -- What does this catalog protect? -- Is this a SQL catalog or a function-unit catalog? -- Which fixtures or setup data does the happy path depend on? -- Which specific case should be extended next when a new behavior is added? - -## Why this scenario matters - -This is the shortest realistic path that exercises the new human-readable documentation export together with the existing deterministic evidence model. - -If this scenario stays smooth, maintainers can hand off current test coverage faster during review, onboarding, and AI-assisted follow-up work. diff --git a/docs/dogfooding/ztd-application-lifecycle.md b/docs/dogfooding/ztd-application-lifecycle.md deleted file mode 100644 index f66751486..000000000 --- a/docs/dogfooding/ztd-application-lifecycle.md +++ /dev/null @@ -1,46 +0,0 @@ -# Application Lifecycle Dogfooding - -This guide records the CRUD dogfooding loop for the starter scaffold. - -The goal is to confirm that an AI agent can work from the generated README and CLI scaffold, follow the starter prompt, and add a new `users` feature without being pushed into shared extraction or migration execution. - -## What to use - -- `ztd init --starter` -- `src/features/smoke` -- `src/features/users` -- `packages/ztd-cli/README.md` -- `packages/ztd-cli/templates/README.md` - -## Prompt used for CRUD - -```text -Add a users feature to this feature-first project. -Start with the generated README and CLI help. -Keep handwritten SQL, specs, and tests inside src/features/users. -Do not apply migrations automatically. -``` - -## What should happen - -1. The agent reads the starter README and uses CLI help when it needs command details. -2. The agent uses `src/features/smoke` as the model. -3. The agent adds `src/features/users`. -4. The agent keeps SQL, spec, and tests feature-local. -5. The next verification command is `vitest`. - -## Evidence to capture - -- the generated project root path -- the exact prompt -- the feature path the agent created -- the commands the agent chose -- whether the agent tried to leave the feature folder -- whether `smoke` was enough as a teaching example - -## Pass criteria - -- the agent can add `users` without asking for extra layout guidance -- the agent keeps handwritten work inside the feature folder -- the next command is a normal test run, not a migration command -- the workflow still feels natural after `smoke` is deleted diff --git a/docs/dogfooding/ztd-cli-spawn-eperm-investigation.md b/docs/dogfooding/ztd-cli-spawn-eperm-investigation.md deleted file mode 100644 index b404d883a..000000000 --- a/docs/dogfooding/ztd-cli-spawn-eperm-investigation.md +++ /dev/null @@ -1,128 +0,0 @@ -# ztd-cli spawn EPERM Investigation - -## Source issue -Issue #685 - -## Why blocker -`pnpm --filter @rawsql-ts/ztd-cli test` is part of the required verification path for the customer-facing Codex bootstrap added in Issue #685. If the test entrypoint cannot start in a reviewer-visible environment, acceptance items 1-3 cannot be promoted to `done` because the PR would otherwise claim verification that did not actually happen. - -## Reproduction -- command: `pnpm --filter @rawsql-ts/ztd-cli test` -- working directory: `` -- environment: - - OS: `Microsoft Windows [Version 10.0.26200.8037]` - - shell: `Windows PowerShell 5.1.26100.7920` - - Node: `v22.16.0` - - pnpm: `10.17.0` - - Vitest: `4.0.7` - - esbuild: `0.25.10` -- full error: - -```text -> @rawsql-ts/ztd-cli@0.22.5 test \packages\ztd-cli -> pnpm --filter @rawsql-ts/adapter-node-pg run build && vitest run --config vitest.config.ts - -failed to load config from \packages\ztd-cli\vitest.config.ts - -Startup Error -Error: spawn EPERM - at ChildProcess.spawn (node:internal/child_process:420:11) - at Object.spawn (node:child_process:753:9) - at ensureServiceIsRunning (\node_modules\.pnpm\esbuild@0.25.10\node_modules\esbuild\lib\main.js:1978:29) - at build (\node_modules\.pnpm\esbuild@0.25.10\node_modules\esbuild\lib\main.js:1876:26) - at bundleConfigFile (file:////node_modules/.pnpm/vite@7.2.1_@types+node@22.18.7_jiti@2.6.1_yaml@2.8.3/node_modules/vite/dist/node/chunks/config.js:36419:23) - at bundleAndLoadConfigFile (file:////node_modules/.pnpm/vite@7.2.1_@types+node@22.18.7_jiti@2.6.1_yaml@2.8.3/node_modules/vite/dist/node/chunks/config.js:36406:24) - at loadConfigFromFile (file:////node_modules/.pnpm/vite@7.2.1_@types+node@22.18.7_jiti@2.6.1_yaml@2.8.3/node_modules/vite/dist/node/chunks/config.js:36375:179) - at resolveConfig (file:////node_modules/.pnpm/vite@7.2.1_@types+node@22.18.7_jiti@2.6.1_yaml@2.8.3/node_modules/vite/dist/node/chunks/config.js:36024:28) - at _createServer (file:////node_modules/.pnpm/vite@7.2.1_@types+node@22.18.7_jiti@2.6.1_yaml@2.8.3/node_modules/vite/dist/node/chunks/config.js:25969:73) - at createServer$2 (file:////node_modules/.pnpm/vite@7.2.1_@types+node@22.18.7_jiti@2.6.1_yaml@2.8.3/node_modules/vite/dist/node/chunks/config.js:25966:9) { - errno: -4048, - code: 'EPERM', - syscall: 'spawn' -} -``` - -- first failing spawn target: - -```text -SPAWN_TARGET=\node_modules\.pnpm\@esbuild+win32-x64@0.25.10\node_modules\@esbuild\win32-x64\esbuild.exe -SPAWN_ARGS=["--service=0.25.10","--ping"] -SPAWN_CWD= -``` - -## Investigation steps -- step 1: - - Reproduced the failure with: - - `pnpm --filter @rawsql-ts/ztd-cli test` - - `pnpm --filter @rawsql-ts/ztd-cli exec vitest` - - `pnpm --filter @rawsql-ts/ztd-cli test -- --run tests/utils/agents.test.ts` - - `pnpm --filter @rawsql-ts/ztd-cli exec vitest run tests/utils/agents.test.ts` - - Every variant failed before any test file was collected, while loading `vitest.config.ts`. -- step 2: - - Compared adjacent commands: - - `pnpm --filter @rawsql-ts/ztd-cli build` -> passed - - `pnpm --filter @rawsql-ts/ztd-cli lint` -> passed - - `pnpm --filter @rawsql-ts/ztd-cli exec vitest run --config vitest.config.ts --configLoader runner` -> still failed with `spawn EPERM` - - The `--configLoader runner` path changed the stack from esbuild service startup to Vite path-resolution internals, but it still failed in `child_process` before tests started. -- step 3: - - Isolated the problem below the repo test layer: - - PowerShell can run `esbuild.exe --version` directly. - - A minimal Node script that only does `child_process.spawn('cmd.exe', ...)` fails with `EPERM`. - - A minimal Node script that only does `child_process.spawn(process.execPath, ['-v'])` fails with `EPERM`. - - A minimal Node script that only does `require('esbuild').build(...)` fails with `EPERM` while spawning `esbuild.exe --service=0.25.10 --ping`. - - Copying `esbuild.exe` to `/tmp-short/esbuild-copy.exe` does not help; Node spawn still fails with `EPERM`. - - Running through `cmd.exe` instead of PowerShell does not help; `pnpm ... exec vitest` still fails the same way. - - A short-path junction `/rawsql-ts-short` did not change the result. - - Testing with a workdir outside the synced workspace root was not possible in this sandbox because command setup failed when the shell workdir moved outside the writable roots. - -## Findings -- confirmed: - - `pnpm --filter @rawsql-ts/ztd-cli test` fails reproducibly in this environment with `spawn EPERM`. - - The failure happens before any Issue #685 test file or snapshot is executed. - - `build` and `lint` pass in the same environment. - - `pnpm --filter @rawsql-ts/ztd-cli exec vitest` fails the same way as the package `test` script. - - `pnpm --filter @rawsql-ts/ztd-cli test -- --run tests/utils/agents.test.ts` still fails before single-test selection matters. - - `--configLoader runner` does not unblock the run; the failure is broader than esbuild config bundling. - - Minimal Node `child_process.spawn()` and `spawnSync()` calls fail even for `cmd.exe` and `node.exe`. - - Direct shell execution of `esbuild.exe` works, so the failure is specifically on Node-launched subprocesses in this environment. -- not confirmed: - - Whether the same Node spawn failure reproduces outside the synced workspace root on this machine. - - Whether CI or another Windows environment without this sandbox reproduces the same behavior. - - Whether Windows Defender / Controlled Folder Access is the exact policy component involved. -- ruled out: - - A problem unique to the new Issue #685 tests or snapshots. - - A problem unique to `pnpm --filter @rawsql-ts/ztd-cli test` script wiring. - - A problem unique to PowerShell vs `cmd.exe`. - - A simple temp-directory-length issue inside the repo; overriding `TEMP` and `TMP` to `tmp-short` did not change the result. -- still unknown: - - The exact host policy or runtime restriction that blocks Node child-process creation in this session. - - Whether the root cause is Codex desktop sandboxing, Windows security policy, or another local execution control layer. - -## Conclusion -B. This PR's code changes are not the primary cause. The blocker is environment-dependent and happens below the repo test layer because Node cannot spawn even `cmd.exe` or `node.exe` from a minimal script in this session. Additional confirmation in CI or another local environment is still required before calling the test path healthy. - -## Impact on acceptance items -- acceptance item 1: - - status: `partial` - - reason: the bootstrap implementation exists and direct CLI checks passed, but the required `pnpm --filter @rawsql-ts/ztd-cli test` path is blocked by environment-level `spawn EPERM`. -- acceptance item 2: - - status: `partial` - - reason: the code path is implemented and repo evidence exists, but the test suite that should verify the opt-in boundary is still blocked. -- acceptance item 3: - - status: `partial` - - reason: collision/customization handling is implemented, but the unit tests that should prove it remain blocked by the same startup failure. - -## What should happen next -- fix inside this PR: not yet. The current evidence does not justify changing the Issue #685 implementation to address `spawn EPERM`. -- split into a separate issue: yes, if CI or another local environment confirms that the PR code is healthy while this environment continues to block Node subprocesses. -- continue with CI or another environment: yes. The next decision point should be a reviewer-checkable run in CI or a second Windows environment that can execute Node child processes normally. - -## Recurrence prevention -- Redact local filesystem paths in reviewer-facing evidence to `` or `` instead of publishing raw host-specific paths. -- Keep a docs assertion that fails when this investigation document contains Windows user-home prefixes or synced-workspace-specific absolute roots. -- Treat local-environment investigation docs as sanitized artifacts: path examples should preserve only the structural information needed for reproduction. - -## Reviewer conclusion -- Local Windows environment reproduces `spawn EPERM` below the Issue #685 change layer. -- Current evidence is insufficient to mark acceptance items 1-3 as done. -- Next decision depends on CI or alternate-environment verification. diff --git a/docs/dogfooding/ztd-migration-lifecycle.md b/docs/dogfooding/ztd-migration-lifecycle.md deleted file mode 100644 index 777c2adb5..000000000 --- a/docs/dogfooding/ztd-migration-lifecycle.md +++ /dev/null @@ -1,88 +0,0 @@ -# Migration Lifecycle Dogfooding - -This guide records the DDL, SQL, and DTO repair loops for the starter scaffold. - -The goal is to confirm that a prompt can point an AI agent at the right files, let the agent repair the breakage, and keep the change local to the `users` feature. - -## Scenario set - -Use one `users` project and try these prompts in order: - -1. DDL change -2. SQL change -3. DTO change -4. migration artifact creation - -These prompts are intended to be copied into a separate AI instance, so the tutorial can verify whether the prompt wording, generated README, and CLI help are sufficient without manual repair in between. - -## Preferred CLI by scenario - -- DDL repair: `npx ztd query uses column users.email --scope-dir src/features/users/persistence --any-schema --view detail` -- SQL repair: `npx ztd model-gen --probe-mode ztd src/features/users/persistence/users.sql --out src/features/users/persistence/users.spec.ts` -- DTO repair: `npx vitest run` -- migration artifact creation: `npx ztd ztd-config`, optionally `npx ztd ddl pull --url ` to inspect the target, then `npx ztd ddl diff --url --out tmp/users.diff.sql` to generate review output plus SQL; if you hand-edit the migration afterward, run `npx ztd ddl risk --file tmp/users.diff.sql` to re-evaluate the final SQL with the same structured risk contract -- tuning: use the separate perf guide, not this starter lifecycle - -`ZTD_DB_URL` is the only implicit database owned by ztd-cli. Use `--url` or a full `--db-*` flag set for any other inspection target. - -## DDL change prompt - -```text -I changed the DDL for users. -Start with the generated README and CLI help. -Run `npx ztd query uses column users.email --scope-dir src/features/users/persistence --any-schema --view detail` to find the affected SQL files before you edit anything. The feature folder is one narrowed scan scope inside the normal project-wide discovery flow. -Fix the tests and feature code that now fail. -Do not apply migrations automatically. -``` - -## SQL change prompt - -```text -I changed the SQL for users. -Start with the generated README and CLI help. -The starter DDL uses the `users` table, so keep the SQL on that table and do not invent a `user` table. -Use `npx ztd model-gen --probe-mode ztd src/features/users/persistence/users.sql --out src/features/users/persistence/users.spec.ts` to refresh the spec, then update the feature-local tests that now fail. In VSA layouts, `model-gen` derives the contract from the SQL file location first, so `--sql-root` is only a compatibility helper for older shared SQL roots. -Keep `ZTD_DB_URL` set in the same shell when you run Vitest. -Do not apply migrations automatically. -``` - -## DTO change prompt - -```text -I changed the DTO shape for users. -Start with the generated README and CLI help. -Update the application and tests that now fail. -Do not apply migrations automatically. -``` - -## Migration prompt - -```text -I changed the DDL for users and need a migration artifact. -Start with the generated README and CLI help. -Run `npx ztd ztd-config`. -Optionally run `npx ztd ddl pull --url ` to inspect the target first. -Run `npx ztd ddl diff --url --out tmp/users.diff.sql` to generate review output plus SQL, read the logical summary first, inspect the structured risks second, and if you hand-edit the SQL run `npx ztd ddl risk --file tmp/users.diff.sql` before you fix the tests that fail. -Do not apply migrations automatically. -``` - -## What to verify - -- the agent starts from the feature folder instead of `src/catalog` -- the agent picks the right file type for the change -- the agent repairs tests before widening the scope -- the agent keeps the fix local to the `users` feature -- the agent uses CLI output to identify the affected files instead of guessing them -- `vitest` passes again after the repair -- the agent can prepare a migration artifact without pretending to deploy it - -## Evidence to capture - -- the generated project root path -- the prompt that was used -- the files the agent changed -- the test command that was run -- whether the agent asked for extra layout help -- whether the scaffold vocabulary matched the prompt vocabulary -- whether the migration prompt stayed explicit about not applying migrations automatically -- whether the final hand-edited SQL was re-evaluated with `ztd ddl risk --file ...` diff --git a/docs/dogfooding/ztd-onboarding-dogfooding.md b/docs/dogfooding/ztd-onboarding-dogfooding.md deleted file mode 100644 index 9d84a93ac..000000000 --- a/docs/dogfooding/ztd-onboarding-dogfooding.md +++ /dev/null @@ -1,87 +0,0 @@ -# ztd Onboarding Dogfooding - -## Source issue -Issue #685 - -## Why it matters -Issue #685 originally changed the customer-facing onboarding path by inserting an AI-control bootstrap into the first-run Codex workflow. The current CLI no longer ships that bootstrap, so reviewers need evidence that the README Quickstart and starter tutorial still form a coherent path from fresh project creation to the first `smoke -> users` development step. - -## What was run -- README Quickstart path in a fresh directory outside the monorepo workspace root. -- Tutorial review against `docs/guide/sql-first-end-to-end-tutorial.md`. -- Published-package path using `npm install -D @rawsql-ts/ztd-cli vitest typescript`. - -## Exact order -1. `npm install -D @rawsql-ts/ztd-cli vitest typescript` -2. `npx ztd init --starter` -3. `.env.example` -> `.env` -4. `docker compose up -d` -5. `npx ztd ztd-config` -6. `npx vitest run` - -## README Quickstart environment -- OS: Windows -- shell: PowerShell -- install location: fresh directory outside `rawsql-ts/` -- npm cache override: project-local cache under `.npm-cache/` -- installed published package: `@rawsql-ts/ztd-cli@0.22.5` - -## README Quickstart step-by-step outcome -- `npm install -D @rawsql-ts/ztd-cli vitest typescript` - - status: `done` - - evidence: install succeeded in the fresh external directory when npm cache was redirected to a project-local folder. - - gap: the same command failed inside `rawsql-ts/tmp/` because the parent workspace leaked `workspace:*` dependencies into the install path, so reviewer judgment should use the external fresh directory result instead. -- `npx ztd init --starter` - - status: `partial` - - evidence: scaffold creation completed and generated the starter project files. - - gap: the published package immediately reported that AI-control guidance was installed for the starter flow and then hit a local `npm install` `spawn EPERM` during dependency sync. This behavior does not match the branch under review, where customer-facing AI-control guidance is removed. -- `.env.example` -> `.env` - - status: `not done` - - evidence: the scaffold generated `compose.yaml`, but no `.env.example` file was present in the published-package run. - - gap: this blocks the exact README copy step in the published-package path. -- `docker compose up -d` - - status: `not done` - - evidence: Docker CLI was present. - - gap: the local Docker path failed with `Access is denied` on the Docker engine pipe, so the DB-backed path remains environment-blocked here. -- `npx ztd ztd-config` - - status: `not done` - - evidence: command resolution worked. - - gap: the published-package scaffold still required `@rawsql-ts/testkit-core` to be installed before `ztd-config` could run, which contradicts the branch README claim that the starter scaffold already includes the required support for a fresh standalone project. -- `npx vitest run` - - status: `not done` - - evidence: `vitest` resolved from the fresh project. - - gap: the local Windows environment still hit `spawn EPERM` during Vitest startup, consistent with the earlier investigation. - -## What succeeded -- The exact README order is conceptually natural: package install -> starter scaffold -> env -> Docker -> generation -> tests. -- The README and starter scaffold are enough to locate `smoke` as the teaching example before the first AI-guided `smoke -> users` step. -- The starter scaffold still communicates that `src/features/smoke` is the teaching example and `src/features/users` is the next real feature. - -## Where the removed bootstrap had helped -- It made the first AI-oriented onboarding step explicit right after scaffold creation. -- It gave a natural place to look before CRUD feature creation. - -## Where the removed bootstrap was redundant or confusing -- The generated README and tutorial already describe the `smoke -> users` progression. -- The extra AI-control files made onboarding depend on text artifacts that were not required for the scaffolded commands. -- Published-package evidence can lag behind branch behavior, so bootstrap-specific claims mixed release-lag evidence with local-environment evidence. - -## Tutorial starting conditions -- The tutorial starts after `ztd init --starter`. -- It is intentionally a scenario guide layered on top of the README first-run path. -- The `smoke -> users` structure remains natural and consistent with the README prompt. - -## Tutorial consistency result -- The tutorial can start immediately after `ztd init --starter` when it is read as the AI-guided starter path. -- The tutorial flow from `src/features/smoke` to `src/features/users` remains coherent. -- The tutorial wording should avoid assuming AI-control files are present. - -## What remains unverified -- The exact README Quickstart cannot yet be treated as a clean published-package proof for this branch because the published `@rawsql-ts/ztd-cli@0.22.5` package does not match the branch onboarding shape. -- The DB-backed path remains blocked in this local environment by Docker access and the previously documented `spawn EPERM` startup issue. -- CI or an alternate environment is still required to close the end-to-end Quickstart and tutorial execution path. - -## Reviewer conclusion -- The onboarding order remains coherent without the AI-control bootstrap. -- The tutorial flow remains coherent, but its wording needs to stay centered on README/help/scaffold behavior instead of AI-control guidance. -- Current evidence is enough to review onboarding shape and wording, but not enough to mark the end-to-end onboarding execution path as fully verified. diff --git a/docs/guide/codex-agents-skill-review.md b/docs/guide/codex-agents-skill-review.md deleted file mode 100644 index 3e1a8dfe3..000000000 --- a/docs/guide/codex-agents-skill-review.md +++ /dev/null @@ -1,135 +0,0 @@ -# Codex Agents And Skills Review - -Date: 2026-05-09 - -## Goal - -Capture which Codex rules should stay in repository policy and which longer workflows should move into dedicated docs or skills. - -## What Recent Work Repeatedly Surfaced - -### Scaffold and published-package drift - -Recent `ztd init` and published-package smoke work keeps reinforcing the same contract across: - -- scaffold code -- scaffold-facing README guidance -- published-package smoke checks - -That alignment is a stable repository rule, so it belongs in `AGENTS.md`. - -### Overwrite-safety expectations - -The published-package smoke explicitly proves that `ztd init` must not overwrite existing DDL unless `--force` is used. - -That expectation is stable repository policy, so it belongs in `AGENTS.md`. - -### Local-source fail-fast guidance - -The local-source guard spends significant effort making dependency and CLI-entry failures explicit and actionable. - -That expectation is stable repository policy, so it belongs in `AGENTS.md`. - -### Long starter and AI onboarding prompts - -Starter flow steps, DB-free versus DB-backed branches, smoke-feature graduation, and prompt-dogfooding procedures are long operational guidance that naturally evolves. - -Those items should stay out of `AGENTS.md` and live in dedicated docs or skills. - -## Proposed AGENTS.md Boundary - -Keep `AGENTS.md` short and policy-oriented: - -1. Keep scaffold code, scaffold-facing docs, and published-package smoke checks aligned when they describe the same workflow. -2. Do not overwrite scaffold-owned or user-authored files without an explicit force path. -3. Fail fast on local-source dogfooding errors and include the next recovery step. -4. Prefer repository-visible verification over narrative confidence. -5. Keep starter walkthroughs and prompt playbooks out of `AGENTS.md`. - -## Proposed New Skills - -### `ztd-published-package-smoke` - -Purpose: -Run and interpret tarball-based scaffold validation, workspace-protocol checks, npm and pnpm consumer smoke, and overwrite-safety checks. - -Expected benefit: -Reduce repeated review comments around contract drift, incomplete release-surface proof, and duplicated helper logic. - -### `ztd-starter-ai-onboarding` - -Purpose: -Handle the starter path after `ztd init --starter`, including DB-free smoke, DB-backed smoke, `.env` setup, first feature scaffold, and smoke-feature cleanup. - -Expected benefit: -Move long evolving onboarding text out of `AGENTS.md` and out of hard-coded prompt fragments. - -### `ztd-prompt-dogfooding` - -Purpose: -Capture prompt review and debugging flows currently represented by `PROMPT_DOGFOOD.md` style guidance. - -Expected benefit: -Separate stable repository rules from prompt-tuning procedures that change more often. - -### `skill-maintenance-audit` - -Purpose: -Review local skill inventory for overlap, overlong `SKILL.md` files, missing forward-test evidence, and reference-vs-core-content drift. - -Expected benefit: -Turn this recurring audit into a repeatable workflow instead of re-deriving the review criteria each time. - -## Skills To Merge Or Reframe - -### `orchestrator` and `triage` - -Recommendation: -Merge them or extract one shared routing rubric. - -Rationale: -Both classify maturity and choose the next skill, so they often restate the same judgment without adding new information. - -### `reporting` and `pr-writing` - -Recommendation: -Keep `reporting` as the canonical evidence skeleton and make `pr-writing` a thin PR renderer. - -Rationale: -Both currently require nearly the same evidence and reviewer-facing fields. - -### `self-review` and `dogfooding` - -Recommendation: -Share one independent-review rubric with separate modes for self-pass versus worker/evaluator separation. - -Rationale: -Both re-check evidence quality, claim overreach, blocker status, and reviewer confidence shape. - -## Skills To Split - -### `skill-creator` - -Recommendation: -Split skill creation from skill maintenance and audit guidance. - -Rationale: -The current skill is large and mixes initialization, editing, validation, forward-testing, and ongoing lineup maintenance. - -## Expected Benefit - -- Keeps `AGENTS.md` stable and reviewable. -- Moves long, changing workflows into purpose-built skills. -- Reduces repeated review comments about prompt drift and duplicated routing logic. -- Makes future Codex onboarding and release-surface checks easier to verify. - -## Verification Basis - -This review was derived from inspection of: - -- `packages/ztd-cli/src/commands/init.ts` -- `packages/ztd-cli/templates/scripts/local-source-guard.mjs` -- `scripts/verify-published-package-mode.mjs` -- the current local Codex skill definitions used in this environment - -No product behavior changed in this review document; it records guidance and workflow recommendations. diff --git a/docs/guide/concept-spec-overview.md b/docs/guide/concept-spec-overview.md index fc2dc4029..a7ebf90ab 100644 --- a/docs/guide/concept-spec-overview.md +++ b/docs/guide/concept-spec-overview.md @@ -169,7 +169,7 @@ Use the right artifact for the job: | code comment | local reason that is easiest to understand near the code | | test / ZTD | executable verification | | RFBA boundary | reviewable implementation surface | -| ztd-cli | scaffold, report, and structural check support | +| Ashiba | scaffold, report, and structural check support | ## Relationship To Issues @@ -412,7 +412,7 @@ The intended layering is: | Package Review Authority Model | define which artifacts are human-owned requirements, AI-led review work, or CLI-owned review views | | Package Technology Policy | define package-level technology constraints and review-trigger exceptions | | RFBA | expose the implementation surfaces humans should review | -| ztd-cli / ZTD / tests | provide scaffold, generated artifacts, drift checks, and executable verification | +| Ashiba / ZTD / tests | provide scaffold, generated artifacts, drift checks, and executable verification | | review-plan | provide deterministic review inputs from changed files, relationship metadata, Package Scope, Test Policy, Authority Model, and Technology Policy | | agent workflow skills | guide planning, TDD, verification, review, branch work, and subagent execution | @@ -893,7 +893,7 @@ Early CLI support should stay structural and mechanical: CLI tools must not reinterpret the spec body, move specs, split specs, merge specs, or reorganize the Concept Spec tree automatically. Those actions require human review. -`ztd-cli` is not the primary author of Concept Specs. +Ashiba is not the primary author of Concept Specs. It should support the relationship between Concept Specs, RFBA boundaries, query artifacts, tests, and generated files without inventing concept content. Useful CLI support should focus on scaffold, discovery, reports, and structural checks. diff --git a/docs/guide/dynamic-filter-routing.md b/docs/guide/dynamic-filter-routing.md index 0378201e8..c01fe558a 100644 --- a/docs/guide/dynamic-filter-routing.md +++ b/docs/guide/dynamic-filter-routing.md @@ -60,7 +60,7 @@ That means these two statements can both be true without contradiction: 2. If the predicate already exists in SQL, bind its required placeholders only. 3. Otherwise, author the missing optional branch in SQL with SSSQL. 4. Keep hardcoded required predicates separate from removable optional branches. -5. Use `ztd query sssql list` to inspect authored branches and `ztd query sssql remove --preview` when cleaning them up. +5. Use `ashiba query optional list` to inspect authored branches and `ashiba query optional remove --preview` when cleaning them up. 6. Add or update the focused unit test that proves the routing choice. ## Related guides diff --git a/docs/guide/execution-scope.md b/docs/guide/execution-scope.md index 6b21cacea..4e55b4648 100644 --- a/docs/guide/execution-scope.md +++ b/docs/guide/execution-scope.md @@ -12,7 +12,7 @@ rawsql-ts and its companion packages form a layered architecture. Each layer has | Concern | Owner | Package | |---------|-------|---------| | SQL parsing and AST transformation | Library | `rawsql-ts` | -| Result row mapping | Generated code | AOT row mapper generated by `ztd-cli` | +| Result row mapping | Generated code | AOT row mapper generated by Ashiba | | Query SQL file loading | Generated code | Generated boundary-local resource loader | | Fixture-backed CTE rewriting | Library | `testkit-core`, `testkit-postgres`, `testkit-sqlite` | | Test connection isolation | Library | `testkit-core` | diff --git a/docs/guide/feature-index.md b/docs/guide/feature-index.md deleted file mode 100644 index 710ed3f68..000000000 --- a/docs/guide/feature-index.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Feature Index ---- - -# Feature Index - -An at-a-glance index of easy-to-miss but important capabilities across the rawsql-ts ecosystem. Each entry tells you **what it is**, **where to find it**, and **when to use it**. - -## CLI (`ztd`) - -| Feature | Command / Location | When to use | -|---------|-------------------|-------------| -| Non-interactive init | `ztd init --yes --workflow demo` | CI/CD pipelines, agent-driven runtime-free scaffolding | -| DDL pull from explicit target | `ztd ddl pull --url ` | Inspect schema state from an existing Postgres database | -| DDL diff | `ztd ddl diff --url ` | Compare local DDL against an explicit target database after changes | -| Watch mode | `ztd ztd-config --watch` | Continuous type regeneration while editing DDL | -| Quiet mode | `ztd ztd-config --quiet` | Suppress next-step hints in scripts | -| SQL linting | `ztd lint ` | Validate SQL files against the schema | -| Observed SQL matching | `ztd query match-observed --sql-file ` | Rank likely source SQL assets when a DB log has no `queryId` | -| Contract check | `ztd check-contract` | Verify catalog contract integrity | -| Test evidence export | `ztd evidence --mode specification` | Generate specification reports from tests | -| Entity generation | `ztd ddl gen-entities` | Create `entities.ts` for ad-hoc schema inspection | - -## Generated Runtime - -| Feature | Location | When to use | -|---------|----------|-------------| -| Thin executor boundary | `FeatureQueryExecutor` in generated projects | Keep production execution free of generator/runtime package dependencies | -| AOT row mapper | `src/features/**/queries/**/generated/row-mapper.ts` | Map query rows to DTOs without a runtime mapper library | -| Generated mapper drift check | `ztd feature generated-mapper check` | Detect SQL/boundary/mapper drift before review | -| Generated mapper refresh | `ztd feature generated-mapper generate` | Refresh machine-owned mapper output after SQL or boundary changes | - -## Templates & Project Structure - -| Feature | Location | When to use | -|---------|----------|-------------| -| SqlClient interface | `src/libraries/sql/sql-client.ts` | Define the app ↔ driver boundary and allow readable `:name` SQL parameters | -| SqlClient adapter (node-postgres) | `src/adapters/pg/sql-client.ts` | Convert node-postgres Client/Pool to `SqlClient` and compile `:name` to `$1` placeholders | -| Repository telemetry contract | `src/libraries/telemetry/repositoryTelemetry.ts` | Keep the shared telemetry seam driver-neutral | -| Repository telemetry console sink | `src/adapters/console/repositoryTelemetry.ts` | Emit safe local logs without widening the shared contract | -| Runtime coercions | generated boundary-local code | Driver-type normalization when a generated boundary needs it | -| QuerySpec files | Feature-local query boundaries under `src/features/**` | Define SQL contracts near the boundary under review | -| Legacy spec files | `src/catalog/specs/` | Maintain fixed catalog contracts in older projects | -| Global test setup | `tests/support/global-setup.ts` | Test-runner initialization hooks | - -## Documentation - -| Guide | Path | When to read | -|-------|------|-------------| -| Happy Path Quickstart | [ztd-cli README](https://github.com/mk3008/rawsql-ts/blob/main/packages/ztd-cli/README.md#happy-path-quickstart) | First-time setup | -| What Is RFBA? | [guide/rfba-overview](./rfba-overview.md) | Understand review-first backend architecture, review responsibilities, and ztd-cli structural vocabulary | -| SQL-first End-to-End Tutorial | [guide/sql-first-end-to-end-tutorial](./sql-first-end-to-end-tutorial.md) | Walk from DDL to the first passing test with one table and one SQL asset | -| After DDL Changes | [ztd-cli README](https://github.com/mk3008/rawsql-ts/blob/main/packages/ztd-cli/README.md#after-ddlschema-changes) | Schema evolution workflow | -| Postgres Pitfalls | [guide/postgres-pitfalls](./postgres-pitfalls.md) | Postgres-specific quirks | -| Spec-Change Scenarios | [guide/spec-change-scenarios](./spec-change-scenarios.md) | Quick reference for common changes | -| Query Uses Overview | [guide/query-uses-overview](./query-uses-overview.md) | Why static analysis beats grep, human vs machine output | -| Query Uses Impact Checks | [guide/query-uses-impact-checks](./query-uses-impact-checks.md) | Full option reference, scenario playbook, troubleshooting | -| Repository Telemetry Setup | [guide/repository-telemetry-setup](./repository-telemetry-setup.md) | Edit the starter telemetry seam, emit safe logs, and follow the `queryId` flow | -| Observed SQL Matching | [guide/observed-sql-matching](./observed-sql-matching.md) | Rank source SQL assets from observed SQL when the stable queryId is missing | -| Observed SQL Investigation | [guide/observed-sql-investigation](./observed-sql-investigation.md) | Run `ztd query match-observed` and inspect ranked candidates | -| SQL Tool Happy Paths | [guide/sql-tool-happy-paths](./sql-tool-happy-paths.md) | Decide whether to start with query plan, perf, query uses, telemetry, or SSSQL | -| Release And Merge Readiness | [guide/release-readiness](./release-readiness.md) | Use the PR readiness contract for baseline exceptions, CLI migration packets, and scaffold proof | -| JOIN Direction Lint Specification | [guide/join-direction-lint-spec](./join-direction-lint-spec.md) | Review the FK-only v1 pattern table, warnings, skips, suppression, and future inference path | -| Perf Tuning Decision Guide | [guide/perf-tuning-decision-guide](./perf-tuning-decision-guide.md) | Decide when QuerySpec scale hints should lead to indexes, pipeline tuning, or both | -| What Is SSSQL? | [guide/sssql-overview](./sssql-overview.md) | Decide whether truthful optional-condition SQL is the right first move | -| SSSQL for Humans | [guide/sssql-for-humans](./sssql-for-humans.md) | Understand why SSSQL exists, where it fits, and how it complements DynamicQueryBuilder | -| Dynamic Filter Routing | [guide/dynamic-filter-routing](./dynamic-filter-routing.md) | Decide whether DynamicQueryBuilder filters or SSSQL optional branches should be the first move | -| ztd-cli SSSQL Authoring | [guide/ztd-cli-sssql-authoring](./ztd-cli-sssql-authoring.md) | Keep optional-condition requests on the SQL-first path while authoring ZTD SQL assets | -| ztd-cli SSSQL Reference | [guide/ztd-cli-sssql-reference](./ztd-cli-sssql-reference.md) | Look up `list`, `scaffold`, `remove`, `refresh`, supported operators, and runtime pruning | -| ztd-cli Agent Interface | [guide/ztd-cli-agent-interface](./ztd-cli-agent-interface.md) | Machine-readable CLI usage for automation and AI agents | -| ztd-cli spawn EPERM investigation | [dogfooding/ztd-cli-spawn-eperm-investigation](../dogfooding/ztd-cli-spawn-eperm-investigation.md) | Review the local Vitest startup blocker before treating Issue #685 acceptance items as done | -| ztd describe schema | [guide/ztd-cli-describe-schema](./ztd-cli-describe-schema.md) | Contract details for `ztd describe` JSON payloads | -| ztd-cli measurement inventory | [guide/ztd-cli-measurement-inventory](./ztd-cli-measurement-inventory.md) | Audit current timing/profiling surfaces before adding OpenTelemetry | -| ztd-cli telemetry policy | [guide/ztd-cli-telemetry-policy](./ztd-cli-telemetry-policy.md) | Event schema, redaction rules, and safe export boundaries for CLI telemetry | -| ztd-cli telemetry export modes | [guide/ztd-cli-telemetry-export-modes](./ztd-cli-telemetry-export-modes.md) | Choose local debug, CI artifact, or OTLP export without changing command behavior | -| ztd-cli telemetry philosophy | [guide/ztd-cli-telemetry-philosophy](./ztd-cli-telemetry-philosophy.md) | Why telemetry exists, why it stays opt-in, and which goals are explicitly out of scope | -| Telemetry Dogfooding Scenarios | [dogfooding/telemetry-dogfooding](../dogfooding/telemetry-dogfooding.md) | Regression-ready telemetry investigation loops for query uses, model-gen, and perf run | -| Perf scale tuning dogfooding | [dogfooding/perf-scale-tuning](../dogfooding/perf-scale-tuning.md) | Confirms QuerySpec perf metadata, DDL indexes, and index-vs-pipeline guidance stay aligned | -| SQL debug recovery dogfooding | [dogfooding/sql-debug-recovery](../dogfooding/sql-debug-recovery.md) | End-to-end loop for broken long-CTE recovery, safe patching, and direct-vs-decomposed perf comparison | -| SSSQL optional-condition dogfooding | [dogfooding/sssql-optional-condition](../dogfooding/sssql-optional-condition.md) | Confirms that optional-filter requests choose truthful SSSQL branches before dynamic SQL assembly | -| Test documentation dogfooding | [dogfooding/test-documentation](../dogfooding/test-documentation.md) | Human-readable export loop for catalog purpose, fixtures, and happy-path test coverage | -| Execution scope | [guide/execution-scope](./execution-scope.md) | Transaction and connection control | -| ZTD Theory | [guide/ztd-theory](./ztd-theory.md) | Conceptual foundation | - diff --git a/docs/guide/finding-registry.example.json b/docs/guide/finding-registry.example.json deleted file mode 100644 index 8f41bd0c3..000000000 --- a/docs/guide/finding-registry.example.json +++ /dev/null @@ -1,92 +0,0 @@ -[ - { - "id": "F-001", - "title": "latest publish cannot be installed by consumer", - "symptom": "npm consumer cannot install the latest published package.", - "source": ["Report", "Codex"], - "failure_surface": "publish", - "category": ["packaging"], - "severity": "blocker", - "detectability": "local", - "recurrence_risk": "high", - "desired_prevention_layer": ["guard", "verification", "release_checklist", "customer_test"], - "candidate_action": "Validate packed tarballs before release and reject workspace protocol leaks.", - "verification_evidence": "pnpm verify:published-package-mode plus npm consumer smoke.", - "status": "verified" - }, - { - "id": "F-002", - "title": "ztd init install failure is misread as scaffold failure", - "symptom": "Dependency install failures were presented as if scaffold creation itself failed.", - "source": ["Report", "Codex"], - "failure_surface": "internal", - "category": ["scaffold"], - "severity": "warning", - "detectability": "local", - "recurrence_risk": "high", - "desired_prevention_layer": ["scaffold", "docs_policy", "verification"], - "candidate_action": "Keep scaffold files and report dependency install failures separately.", - "verification_evidence": "ztd init tests covering install failure, skip-install, and scaffold completion.", - "status": "verified" - }, - { - "id": "F-003", - "title": "repository path bypasses QuerySpec + CatalogExecutor", - "symptom": "Repository code could drift toward direct SQL parsing/formatting instead of the normal QuerySpec path.", - "source": ["Report", "Codex"], - "failure_surface": "internal", - "category": ["runtime"], - "severity": "warning", - "detectability": "workflow", - "recurrence_risk": "high", - "desired_prevention_layer": ["scaffold", "docs_policy", "AI_guard", "verification"], - "candidate_action": "Keep the QuerySpec + CatalogExecutor example runnable and explicit.", - "verification_evidence": "queryspec.example.test.ts and the published consumer smoke path.", - "status": "implemented" - }, - { - "id": "F-004", - "title": "ZTD test path is confused with migration-style integration test", - "symptom": "A PostgreSQL + Docker request could be satisfied with CREATE TABLE / INSERT / DROP TABLE tests instead of fixture-backed rewrite tests.", - "source": ["Report", "Codex"], - "failure_surface": "internal", - "category": ["contract", "policy"], - "severity": "warning", - "detectability": "workflow", - "recurrence_risk": "high", - "desired_prevention_layer": ["docs_policy", "scaffold", "AI_guard", "verification"], - "candidate_action": "Make fixture-backed rewrite the default example and say that execution DB and test strategy are separate concerns.", - "verification_evidence": "Getting Started With AI guidance and smoke test sample.", - "status": "implemented" - }, - { - "id": "F-005", - "title": "template sample does not present the shortest successful path", - "symptom": "Shape-only examples and placeholders do not show a runnable path clearly enough.", - "source": ["Codex"], - "failure_surface": "internal", - "category": ["scaffold", "docs"], - "severity": "warning", - "detectability": "local", - "recurrence_risk": "high", - "desired_prevention_layer": ["scaffold", "docs_policy", "verification"], - "candidate_action": "Keep the queryspec and smoke examples runnable and easy to copy.", - "verification_evidence": "Template docs and generated scaffold tests.", - "status": "evidence_collected" - }, - { - "id": "F-006", - "title": "core runtime export gap induces workaround", - "symptom": "Missing public exports pushed callers toward workaround code.", - "source": ["Codex"], - "failure_surface": "internal", - "category": ["runtime"], - "severity": "advisory", - "detectability": "local", - "recurrence_risk": "medium", - "desired_prevention_layer": ["artifact_contract", "verification"], - "candidate_action": "Expose the runtime API surface that template samples need.", - "verification_evidence": "core export tests and the generated scaffold runtime sample.", - "status": "planned" - } -] diff --git a/docs/guide/finding-registry.md b/docs/guide/finding-registry.md deleted file mode 100644 index 1b8c14434..000000000 --- a/docs/guide/finding-registry.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Finding Registry -outline: deep ---- - -# Finding Registry - -This guide defines the machine-readable shape used to track dogfooding findings before and after remediation. -The goal is to keep the finding itself separate from the fix, the prevention layer, and the evidence. - -## Why this exists - -Dogfooding results are easier to act on when every finding is recorded with the same fields. -That makes it possible to: - -- classify the failure surface -- choose a prevention layer intentionally -- keep the remediation status explicit -- point at evidence instead of relying on memory - -## Registry shape - -Each finding entry should carry these fields: - -- `id` -- `title` -- `symptom` -- `source` -- `failure_surface` -- `category` -- `severity` -- `detectability` -- `recurrence_risk` -- `desired_prevention_layer` -- `candidate_action` -- `verification_evidence` -- `status` - -Recommended status progression: - -- `planned` -- `implemented` -- `evidence_collected` -- `verified` - -## Reading the example - -The example registry at [finding-registry.example.json](./finding-registry.example.json) contains representative findings from the ztd-cli dogfooding work. -It is intentionally small and should be treated as a starting point, not as a complete audit log. - -Validate the example registry with `npx ztd findings validate docs/guide/finding-registry.example.json` when you want a deterministic CI check. - -## How to use it - -When you add a new finding: - -- keep the symptom short and concrete -- keep the candidate action focused on one prevention layer -- record the verification evidence as a command, artifact, or both -- move the status forward only when the evidence exists - -## Notes - -- The registry is meant to be machine-readable. -- The registry should stay stable enough to diff cleanly. -- If a finding does not yet have evidence, keep it in `planned` or `implemented` instead of marking it verified early. diff --git a/docs/guide/generated-project-verification.md b/docs/guide/generated-project-verification.md deleted file mode 100644 index c41ca2059..000000000 --- a/docs/guide/generated-project-verification.md +++ /dev/null @@ -1,80 +0,0 @@ -# Generated-Project Verification Before Merge - -Use this guide when you change the scaffold or layout that `ztd-cli` writes into a fresh project. - -This check is about the project that `ztd init` and `ztd feature scaffold` actually generate, not the packed npm artifact path. It answers a narrower question than the published-package check: - -1. Can a brand-new starter project be created from the current source checkout? -2. Can the generated project immediately scaffold a real feature slice? -3. Can the generated project regenerate config and run its starter tests? - -## What this check proves - -- `ztd init --starter` can create a fresh project from the current source checkout. -- The generated starter project can install dependencies and run its starter smoke tests. -- `ztd feature scaffold --table users --action insert` still produces a runnable feature slice in that project. -- `ztd ztd-config` still works after the scaffolded feature is added. -- The generated project can still run `pnpm test` after the scaffold and config pass. - -## What this check does not prove - -- It does **not** prove the published npm consumer path. -- It does **not** replace [Published-Package Verification Before Release](./published-package-verification.md). -- It does **not** validate every feature shape or database scenario. - -## Canonical command - -Run this from the repository root: - -```bash -pnpm verify:generated-project-mode -``` - -The script will: - -1. Build the workspace packages needed by the local-source scaffold path. -2. Create a fresh starter project under `tmp/generated-project-check/`. -3. Run `ztd init --starter --yes --local-source-root `. -4. Run `ztd feature scaffold --table users --action insert` inside the generated project. -5. Run `ztd ztd-config`. -6. Start a disposable Postgres 18 container on an available local port and wait for it to accept connections. -7. Run `pnpm test`. -8. Stop the disposable Postgres container. -9. Write a machine-readable summary to `tmp/generated-project-check/summary.json`. - -## Target scenarios - -Use this lane when the change touches any of the following: - -- `packages/ztd-cli/src/commands/init.ts` -- `packages/ztd-cli/src/commands/feature.ts` -- `packages/ztd-cli/templates/**` -- `packages/ztd-cli/tests/init.command.test.ts` -- `packages/ztd-cli/tests/featureScaffold.unit.test.ts` -- `packages/ztd-cli/tests/cliCommands.test.ts` -- `packages/ztd-cli/README.md` - -Those files control the generated scaffold and the contract that the generated project must satisfy. - -## How to interpret failures - -- `ztd init` fails. - - Treat this as a scaffold or local-source install regression. -- `feature scaffold` fails. - - Treat this as a scaffold contract or layout regression. -- `ztd ztd-config` fails after scaffold succeeds. - - Treat this as a generated-artifact or DDL/layout regression. -- `pnpm test` fails after the project generated successfully. - - Treat this as a starter-project runtime regression. - -## Recommended policy - -Use this generated-project check before you call a scaffold/layout PR ready for review. -For release work, keep the published-package check in place as well: - -- `Generated-project verification` - - Answers: can the current source checkout still generate a runnable starter project? -- `Published-package verification` - - Answers: does the packed npm consumer path still behave? - -These checks overlap, but they are not interchangeable. diff --git a/docs/guide/join-direction-lint-spec.md b/docs/guide/join-direction-lint-spec.md deleted file mode 100644 index a128b893c..000000000 --- a/docs/guide/join-direction-lint-spec.md +++ /dev/null @@ -1,286 +0,0 @@ ---- -title: JOIN Direction Lint Specification ---- - -# JOIN Direction Lint Specification - -`ztd query lint --rules join-direction` is a conservative readability check for SQL that encourages a stable join direction across a project. - -The goal is not to prove that one query is semantically wrong. The goal is to reduce review noise, AI-generated drift, and cognitive load by making join paths easier to read and compare. - -Before you try the examples on a published CLI, run `npx ztd query lint --help` first and confirm that the help output includes `--rules `. - -- If `--rules` is present, the published package surface is new enough for the examples in this guide. -- If `--rules` is missing or `unknown option '--rules'` appears, you are on an older published `@rawsql-ts/ztd-cli` release and should upgrade before using this guide as-written. - -## Purpose - -This lint looks for inner-join patterns where the query walks **from a parent table down to a child table** even though DDL already defines a clear FK path in the opposite direction. - -`parent -> child` is not a universal anti-pattern. In v1, only `INNER JOIN` in the reverse direction is treated as a warning. `LEFT JOIN` can be a clean parent-first pattern when the query intentionally preserves the parent row set. - -The preferred style in v1 is: - -- start from the child table -- join upward to the parent table -- keep direction consistent within a query when practical - -## Truth source - -v1 uses **FK-only** relation evidence. - -The relation graph is built from explicit DDL metadata only: - -- column-level `REFERENCES` -- table-level `FOREIGN KEY` - -The lint does not infer relation direction from: - -- naming conventions -- join predicates that are not backed by explicit FK evidence -- PK / UNIQUE candidate keys -- application code or runtime behavior - -## v1 scope - -### In scope - -- top-level `SELECT` statements -- normal `JOIN` / `INNER JOIN` -- queries whose join path can be matched to an explicit FK edge -- opt-in execution through `ztd query lint --rules join-direction` - -### Out of scope - -- `LEFT JOIN` and other non-inner join forms -- bridge-table / many-to-many path reasoning -- self-reference-heavy trees -- ambiguous parent candidates -- deeper subquery / CTE / `EXISTS` reasoning -- automatic rewrites or auto-fixes -- default-on rollout - -## Classification table - -| Pattern | v1 outcome | Why | -|---|---|---| -| `child -> parent` inner join | clean | This matches the preferred upward direction. | -| `parent -> child` inner join | warning | The query walks against the preferred FK direction and deserves review attention. | -| `child -> parent -> child` chain | warning if the chain reverses direction in a readable FK path | Direction flips increase cognitive load and review noise. | -| `LEFT JOIN` that keeps the parent row | clean | v1 treats parent-first intent as readable and does not warn. | -| Bridge / many-to-many path | skip | v1 conservatively avoids multi-hop inference that is often intentional. | -| Aggregate or parent-shaped query | skip | The subject is often not the first table in the `FROM` clause, so direction is ambiguous. | -| Explicit suppression comment | skip | The author has stated that the reverse path is intentional. | -| Ambiguous join target / missing FK edge | skip | v1 avoids guessing when relation evidence is incomplete. | - -## Warning cases - -These are the cases the lint is designed to report. - -### 1. Parent -> child inner join - -Example: - -```sql -select * -from public.customers c -join public.orders o on o.customer_id = c.customer_id -``` - -If DDL defines `orders.customer_id references customers.customer_id`, the join is walking from parent to child. The lint reports a warning with `join_type`, `subject_table`, `joined_table`, `child_table`, and `parent_table`. - -### 2. Readability-breaking direction reversal inside a chain - -Example: - -```sql -select * -from public.order_items oi -join public.orders o on o.order_id = oi.order_id -join public.order_items oi2 on oi2.order_id = o.order_id -``` - -The first join may be acceptable, but a direction flip inside the same chain makes the path harder to read. v1 warns only when the FK evidence is clear enough to avoid false positives. - -### 3. Reverse inner join with explicit review attention - -Example: - -```sql -select * -from public.customers c -join public.orders o on o.customer_id = c.customer_id -``` - -This is still a warning in v1, but the intent is more precise than "always non-preferred": the shape is acceptable when review should confirm that the reverse direction is truly intended. - -## Clean cases - -These cases are deliberately treated as clean in v1. - -### LEFT JOIN - -Synthetic fixture: - -- `packages/ztd-cli/tests/fixtures/join-direction/left-join.sql` - -Real repo example: - -- `packages/ztd-cli/tests/utils/taxAllocationScenario.ts` - -Why clean: - -- outer join intent is usually about preserving rows, not join direction style -- many reporting queries intentionally keep the parent or fact table on the left - -This is the clean parent-first pattern in v1 when the query intentionally keeps the parent row set. - -## Skip cases - -These cases are deliberately skipped in v1. - -### Bridge / many-to-many - -Synthetic fixture: - -- `packages/ztd-cli/tests/fixtures/join-direction/bridge.sql` - -Why skip: - -- a bridge table often has two valid parent directions -- without stronger inference, the lint would over-report on normal many-to-many reporting queries - -### Aggregate / parent-shaped query - -Synthetic fixture: - -- `packages/ztd-cli/tests/fixtures/join-direction/aggregate.sql` - -Real repo example: - -- `packages/ztd-cli/tests/utils/taxAllocationScenario.ts` - -Why skip: - -- the apparent `FROM` table is not always the real subject -- aggregates and grouped projections often intentionally pivot around a parent-shaped result - -### No usable join graph - -Real repo example: - -- `packages/ztd-cli/src/specs/sql/usersList.catalog.ts` - -Why skip: - -- the SQL has no meaningful FK-backed join direction to evaluate -- warning here would not help readability - -## Suppression cases - -Use suppression when the reverse direction is intentional and should remain in the query. - -### Supported syntax - -```sql --- ztd-lint-disable join-direction -``` - -Synthetic fixture: - -- `packages/ztd-cli/tests/fixtures/join-direction/suppressed.sql` - -Use suppression when: - -- the query is intentionally written from a reporting or UX shape -- the reverse direction is clearer for the business question -- a local exception is more honest than forcing a rewrite - -## Synthetic and real examples - -### Synthetic fixtures - -- `forward.sql`: child -> parent join, clean -- `reverse.sql`: parent -> child join, warning -- `left-join.sql`: outer join intent, skip -- `bridge.sql`: many-to-many / bridge path, skip -- `aggregate.sql`: aggregate / parent-shaped query, skip -- `suppressed.sql`: explicit suppression, skip - -### Real repo SQL - -- `packages/ztd-cli/src/specs/sql/activeOrders.catalog.ts` - - clean because the observed join path already follows child -> parent direction -- `packages/ztd-cli/src/specs/sql/usersList.catalog.ts` - - skipped because there is no join graph to evaluate -- `packages/ztd-cli/tests/utils/taxAllocationScenario.ts` - - skipped because the query is parent-shaped and uses `LEFT JOIN` - -## Diagnostics shape - -The lint emits structured diagnostics that are available in both text and JSON output. - -Representative JSON shape: - -```json -{ - "type": "join-direction", - "severity": "warning", - "message": "JOIN direction is reversed for public.orders -> public.customers; prefer starting from the child table and joining upward", - "join_type": "join", - "subject_table": "public.customers", - "joined_table": "public.orders", - "child_table": "public.orders", - "parent_table": "public.customers", - "child_columns": ["customer_id"], - "parent_columns": ["customer_id"] -} -``` - -Text output uses the same message: - -```text -WARN join-direction: JOIN direction is reversed for public.orders -> public.customers; prefer starting from the child table and joining upward -``` - -## Heuristic notes - -The v1 heuristics are intentionally conservative: - -- if the join direction cannot be proven, skip it -- if the query is bridge-shaped, skip it -- if the query is outer-join-heavy or aggregate-shaped, skip it -- if the author explicitly suppresses the rule, do not re-litigate the choice - -This is deliberate. The first release is trying to create a reliable guard, not to maximize recall. - -## Future expansion candidates - -### PK / UNIQUE inference - -Future versions may infer parent candidates from: - -- `PRIMARY KEY` -- `UNIQUE` -- additional schema metadata that implies a single authoritative parent row - -### Overlay-based relation modeling - -The current relation graph already carries evidence and confidence fields. That shape can support an overlay model where: - -- FK-backed edges remain confirmed -- inferred edges are attached as a separate layer -- diagnostics can explain whether a relation is confirmed or inferred - -This may be easier to evolve than replacing confirmed FK edges with inferred rows. - -### Deeper query reasoning - -Future work may extend coverage to: - -- nested subqueries -- CTE chains -- `EXISTS` -- more complex parent-shape detection - -Those are intentionally left out of v1 to keep the initial rule stable and low-noise. diff --git a/docs/guide/postgres-pitfalls.md b/docs/guide/postgres-pitfalls.md index f8df2c30c..51a2a1cf5 100644 --- a/docs/guide/postgres-pitfalls.md +++ b/docs/guide/postgres-pitfalls.md @@ -76,9 +76,9 @@ PostgreSQL uses `search_path` to resolve unqualified table names. ZTD's `ztd.con **Mitigation:** - Set `defaultSchema` and `searchPath` in `ztd.config.json` to match your database's `search_path`. -- Use `ztd ztd-config --default-schema --search-path ` to override. +- Use `ashiba config --default-schema --search-path ` to override. ## Further reading - [Mapping vs validation pipeline](../recipes/mapping-vs-validation.md) — how coercions and validators interact -- [Happy Path Quickstart](https://github.com/mk3008/rawsql-ts/blob/main/packages/ztd-cli/README.md#happy-path-quickstart) — end-to-end setup guide +- [Ashiba](https://github.com/mk3008/ashiba) — SQL-first CLI workflows and starter guidance diff --git a/docs/guide/published-package-verification.md b/docs/guide/published-package-verification.md index 63a0f4183..7a89c6653 100644 --- a/docs/guide/published-package-verification.md +++ b/docs/guide/published-package-verification.md @@ -1,35 +1,25 @@ # Published-Package Verification Before Release -Use this guide when you need to validate the published-package happy path **before** pushing packages to npm. +Use this guide when you need to validate rawsql-ts package artifacts before publishing to npm. This is not a perfect substitute for a real registry publish. It is a local verification layer that answers two practical questions: 1. Did `pnpm pack` rewrite workspace protocol dependencies to concrete semver ranges? -2. Does the packed `@rawsql-ts/ztd-cli` tarball still reach a green npm-first standalone smoke run? +2. Do the packed tarballs install and import from a standalone npm consumer project? ## What this check proves -- Release builds complete for the packages needed by the published-package path. +- Release builds complete for publishable rawsql-ts packages. +- Packed tarballs include their declared entrypoints and `dist/` output. - Packed tarballs do not leak `workspace:*`, `workspace:^`, or similar references. -- Phase A proves the packaging / npm primary path gate: - - the tarball installs with `npm install` - - `npx ztd init --yes` completes - - the generated completion message stays on the npm primary path - - follow-up `npm install` still works -- Phase B proves the first test quality gate: - - `npx ztd ztd-config` - - `npx ztd query lint --help` still exposes `--rules` - - `npx ztd query lint --rules join-direction ` parses on the packed CLI path - - `npm run test` - - the generated scaffold reaches a passing first smoke test on the npm-first consumer path - - TypeScript compile checks for both default and Node16 settings as follow-up smoke coverage +- A standalone npm app can install the packed packages. +- The `rawsql-ts` getting-started smoke imports `DynamicQueryBuilder` and `SqlFormatter` from the packed artifact. ## What this check does not prove -- This check does **not** fully emulate npm registry resolution for every unpublished transitive dependency. +- This check does not emulate every npm registry resolution edge case. - A real post-publish smoke check is still required. -- Do not use this to claim that every package combination is already registry-valid. -- When Further Reading docs mention `query lint --rules join-direction`, a real post-publish smoke check should also confirm that `npx ztd query lint --help` exposes `--rules ` on the published package. +- Ashiba CLI scaffold and lifecycle checks are owned by the Ashiba repository, not by this rawsql-ts verification script. ## Canonical command @@ -41,51 +31,24 @@ pnpm verify:published-package-mode The script will: -1. Run `pnpm build:publish` -2. Pack the publishable packages into `tmp/published-package-check/tarballs` -3. Inspect each packed `package.json` for leaked `workspace:` references -4. Create a standalone app under `tmp/published-package-check/packages/npm-primary-path` -5. Run Phase A: `npm install`, `npx ztd init --yes`, completion-message assertions, and follow-up `npm install` -6. Run Phase B: `npx ztd ztd-config`, `npx ztd query lint --help`, `npx ztd query lint --rules join-direction `, `npm run test`, and TypeScript compile checks -7. Write a machine-readable summary to `tmp/published-package-check/summary.json` +1. Run `pnpm build:publish`. +2. Pack publishable packages into `tmp/published-package-check/tarballs`. +3. Inspect each packed `package.json` for leaked `workspace:` references. +4. Install the tarballs into a standalone app under `tmp/published-package-check/packages/packed-install`. +5. Run a rawsql-ts getting-started smoke under `tmp/published-package-check/packages/rawsql-ts-getting-started`. +6. Write a machine-readable summary to `tmp/published-package-check/summary.json`. ## How to interpret failures - Pack inspection fails because a tarball still contains `workspace:` references. - Treat this as a packaging/release bug. -- Phase A fails before `ztd-config`. - - Treat this as a packaging or npm-primary-path regression. -- Phase B fails after the npm-first setup completed. - - Treat this as a first test quality gate regression on the published-package path. - - This now includes command-surface regressions where docs mention `query lint --rules join-direction` but the packed CLI no longer accepts `--rules`. - - The local-source developer path may still be healthy. -- The standalone smoke app passes, but local-source dogfooding fails. - - Treat that as a developer-mode problem, not a packaging problem. - -## First test definition - -For this verification, `first test` means the generated scaffold's minimal smoke test passes after the npm-first setup flow. - -- It is the test path exercised by `npm install -> ztd init -> npx ztd ztd-config -> npm run test`. -- It is **not** a full integration suite. -- It does **not** require every SQL-backed DB test to pass. -- It exists to prove that consumer onboarding reaches visible value, not just successful command execution. +- The standalone install fails. + - Treat this as a published dependency or manifest bug. +- The rawsql-ts getting-started smoke fails. + - Treat this as a package entrypoint or runtime packaging regression. ## Recommended policy -Use both checks before release work is considered healthy: - -- `Local-source developer mode` - - Answers: can we dogfood unreleased changes from source? -- `Published-package verification before release` - - Answers: are the packed artifacts internally consistent enough for release preparation, and does the first generated test pass on the consumer onboarding path? - -## Release checklist - -Treat this document as the canonical pre-release policy for the npm consumer path. - -- `pnpm verify:published-package-mode` is green. -- The consumer path confirms `first test passes`. -- The generated scaffold and the verification path still match the intended onboarding flow. +Use `pnpm verify:published-package-mode` before release work is considered healthy. Only a real npm publish can fully answer the end-user registry path, but this check removes most avoidable surprises before that point. diff --git a/docs/guide/query-uses-impact-checks.md b/docs/guide/query-uses-impact-checks.md index d4cf8f8ee..3b7b70e66 100644 --- a/docs/guide/query-uses-impact-checks.md +++ b/docs/guide/query-uses-impact-checks.md @@ -4,7 +4,7 @@ title: Query Uses Impact Checks # Query Uses Impact Checks -Use `ztd query uses` when you need to answer a schema-change question before editing SQL or repositories: +Use `ashiba query uses` when you need to answer a schema-change question before editing SQL or repositories: - "Does anything reference this table?" - "Which queries still use the old column name?" @@ -12,7 +12,7 @@ Use `ztd query uses` when you need to answer a schema-change question before edi This page covers the `table` and `column` impact checks with examples based on a sample sales project. -Implementation note: the CLI command is provided by `@rawsql-ts/ztd-cli`, while the reusable analysis engine now lives in `@rawsql-ts/sql-grep-core`. +Implementation note: the CLI command is provided by `@ashiba-ts/cli`, while the reusable analysis engine now lives in `@rawsql-ts/sql-grep-core`. The active scan set is **project-wide by default**. `query uses` discovers QuerySpec entries under the current project root and follows each spec's `sqlFile`. Use `--scope-dir` only when you want to narrow the scan to one slice or sub-tree. @@ -21,25 +21,25 @@ The active scan set is **project-wide by default**. `query uses` discovers Query The default view is `impact`, which is the fastest first pass for "used or not, and by which queries?". ```bash -npx ztd query uses table public.sale_items -npx ztd query uses column public.sale_items.quantity +npx ashiba query uses table public.sale_items +npx ashiba query uses column public.sale_items.quantity ``` Use `--view detail` when you need edit-ready evidence: ```bash -npx ztd query uses table public.sale_items --view detail -npx ztd query uses column public.sale_items.quantity --view detail +npx ashiba query uses table public.sale_items --view detail +npx ashiba query uses column public.sale_items.quantity --view detail ``` Use `--exclude-generated` when generated or probe specs would otherwise add noise to rename and type-change investigations: ```bash -npx ztd query uses table public.sale_items --exclude-generated -npx ztd query uses table public.sale_lines --exclude-generated -npx ztd query uses column public.products.title --exclude-generated -npx ztd query uses column public.sale_items.quantity --exclude-generated -npx ztd query uses table public.sale_lines --view detail --exclude-generated +npx ashiba query uses table public.sale_items --exclude-generated +npx ashiba query uses table public.sale_lines --exclude-generated +npx ashiba query uses column public.products.title --exclude-generated +npx ashiba query uses column public.sale_items.quantity --exclude-generated +npx ashiba query uses table public.sale_lines --view detail --exclude-generated ``` `--exclude-generated` only excludes QuerySpec files under `generated` directories. The flag is optional, and the default scan set is unchanged. @@ -51,8 +51,8 @@ npx ztd query uses table public.sale_lines --view detail --exclude-generated Use the default scan first. In the common "new object not referenced yet" case, the answer should be a clean no-hit. ```bash -npx ztd query uses table public.sale_discounts -npx ztd query uses column public.sales.discount_rate +npx ashiba query uses table public.sale_discounts +npx ashiba query uses column public.sales.discount_rate ``` Typical output: @@ -77,10 +77,10 @@ Affected queries: Prefer `--exclude-generated`. These scenarios are more likely to pick up review-only generated specs or probe scaffolds, and excluding them makes the impact list easier to act on. ```bash -npx ztd query uses table public.sale_items --exclude-generated -npx ztd query uses table public.sale_lines --exclude-generated -npx ztd query uses column public.products.title --exclude-generated -npx ztd query uses column public.sale_items.quantity --exclude-generated +npx ashiba query uses table public.sale_items --exclude-generated +npx ashiba query uses table public.sale_lines --exclude-generated +npx ashiba query uses column public.products.title --exclude-generated +npx ashiba query uses column public.sale_items.quantity --exclude-generated ``` Example difference for a rename check: @@ -188,9 +188,9 @@ Primary matches: Run the old name first, then the new name. ```bash -npx ztd query uses table public.sale_items --exclude-generated -npx ztd query uses table public.sale_lines --exclude-generated -npx ztd query uses table public.sale_lines --view detail --exclude-generated +npx ashiba query uses table public.sale_items --exclude-generated +npx ashiba query uses table public.sale_lines --exclude-generated +npx ashiba query uses table public.sale_lines --view detail --exclude-generated ``` Expected pattern: @@ -201,9 +201,9 @@ Expected pattern: ### 2. Rename a column ```bash -npx ztd query uses column public.products.name --exclude-generated -npx ztd query uses column public.products.title --exclude-generated -npx ztd query uses column public.products.title --view detail --exclude-generated +npx ashiba query uses column public.products.name --exclude-generated +npx ashiba query uses column public.products.title --exclude-generated +npx ashiba query uses column public.products.title --view detail --exclude-generated ``` Expected pattern: @@ -214,8 +214,8 @@ Expected pattern: ### 3. Change a column type ```bash -npx ztd query uses column public.sale_items.quantity --exclude-generated -npx ztd query uses column public.sale_items.quantity --view detail --exclude-generated +npx ashiba query uses column public.sale_items.quantity --exclude-generated +npx ashiba query uses column public.sale_items.quantity --view detail --exclude-generated ``` Expected pattern: @@ -232,13 +232,13 @@ Check that your `spec.sqlFile` values still resolve from the spec itself first. If needed, be explicit: ```bash -npx ztd query uses table public.users --sql-root src/sql +npx ashiba query uses table public.users --sql-root src/sql ``` For feature-local layouts, keep the spec and SQL together and let the default resolver work without `--sql-root`: ```bash -npx ztd query uses column users.email --scope-dir src/features/users/persistence --any-schema --view detail +npx ashiba query uses column users.email --scope-dir src/features/users/persistence --any-schema --view detail ``` ### Matches look noisy @@ -246,13 +246,13 @@ npx ztd query uses column users.email --scope-dir src/features/users/persistence Try `--exclude-generated` first. ```bash -npx ztd query uses table public.sale_items --exclude-generated +npx ashiba query uses table public.sale_items --exclude-generated ``` If you still need proof for a specific hit, switch to `--view detail`. ```bash -npx ztd query uses table public.sale_items --view detail --exclude-generated +npx ashiba query uses table public.sale_items --view detail --exclude-generated ``` ## Recommended workflow diff --git a/docs/guide/query-uses-overview.md b/docs/guide/query-uses-overview.md index ec720f6a6..b487c7c7a 100644 --- a/docs/guide/query-uses-overview.md +++ b/docs/guide/query-uses-overview.md @@ -4,13 +4,13 @@ title: Query Uses — Schema Impact Analysis # Query Uses — Schema Impact Analysis -`ztd query uses` is a static analysis command that answers "which SQL queries are affected by this schema change?" without running a database. +`ashiba query uses` is a static analysis command that answers "which SQL queries are affected by this schema change?" without running a database. -The command-line UX is provided by `@rawsql-ts/ztd-cli`, and the reusable analysis engine behind it now lives in `@rawsql-ts/sql-grep-core`. +The command-line UX is provided by `@ashiba-ts/cli`, and the reusable analysis engine behind it now lives in `@rawsql-ts/sql-grep-core`. ## Prerequisites -`ztd query uses` scans the active **QuerySpec set** for the project. By default it discovers JSON or TypeScript specs recursively under the current project root, then follows each spec's `sqlFile` field and parses the referenced SQL for analysis. +`ashiba query uses` scans the active **QuerySpec set** for the project. By default it discovers JSON or TypeScript specs recursively under the current project root, then follows each spec's `sqlFile` field and parses the referenced SQL for analysis. **Common project shapes:** @@ -28,7 +28,7 @@ project-root/ │ └── orders.sql ``` -- **Spec files are required.** Plain `.sql` files without a spec are not scanned. If you have not run `ztd init` yet, start there. +- **Spec files are required.** Plain `.sql` files without a spec are not scanned. If you have not run `ashiba init` yet, start there. - **Project-wide discovery is the default.** QuerySpec files are discovered recursively under the project root unless you narrow the scan with `--scope-dir`. - **Feature-local specs are first-class.** The preferred contract is a spec that keeps `sqlFile` relative to the spec itself, for example `./users.sql`. - **Shared SQL roots still work.** If your project intentionally keeps SQL in one shared tree, you can still use `--sql-root` as a fallback resolver. @@ -42,12 +42,12 @@ A naive `grep "sale_items"` on your SQL files will match table names, but it can - A column used in a `JOIN` condition vs. an unrelated alias - Which specific statements (out of many in a project) actually depend on the target -`ztd query uses` parses each SQL statement into an AST and resolves table/column references with schema awareness. It tells you **how** each query uses the target, not just that the name appears somewhere in the file. +`ashiba query uses` parses each SQL statement into an AST and resolves table/column references with schema awareness. It tells you **how** each query uses the target, not just that the name appears somewhere in the file. | Approach | Finds references | Schema-aware | Shows usage kind | Filters noise | |----------|:---:|:---:|:---:|:---:| | `grep` | Yes | No | No | No | -| `ztd query uses` | Yes | Yes | Yes (join, select, ...) | Yes | +| `ashiba query uses` | Yes | Yes | Yes (join, select, ...) | Yes | ## Two output formats: human and machine @@ -56,7 +56,7 @@ Every command supports `--format text` (default) and `--format json`. **Text** is designed for human review in the terminal: ```bash -npx ztd query uses table public.sale_lines --exclude-generated +npx ashiba query uses table public.sale_lines --exclude-generated ``` ```text @@ -88,7 +88,7 @@ Affected queries: **JSON** is designed for AI agents and CI pipelines. The same structured data is emitted as a single JSON object, making it easy to parse programmatically: ```bash -npx ztd query uses table public.sale_lines --exclude-generated --format json +npx ashiba query uses table public.sale_lines --exclude-generated --format json ``` Use `--out ` to write the result to a file instead of stdout, which is useful for piping into downstream tools or archiving evidence. @@ -104,13 +104,13 @@ The command does **not** scan every `.sql` file in the repository blindly. It sc > "I want to rename `sale_items` to `sale_lines`. What breaks?" ```bash -npx ztd query uses table public.sale_items --exclude-generated +npx ashiba query uses table public.sale_items --exclude-generated ``` If `matches: 2`, you know exactly which 2 queries need updating. After renaming, run the new name to confirm they moved over: ```bash -npx ztd query uses table public.sale_lines --exclude-generated +npx ashiba query uses table public.sale_lines --exclude-generated ``` ### Before a column rename @@ -118,7 +118,7 @@ npx ztd query uses table public.sale_lines --exclude-generated > "I want to rename `products.name` to `products.title`. Which queries reference it?" ```bash -npx ztd query uses column public.products.name --exclude-generated +npx ashiba query uses column public.products.name --exclude-generated ``` ### Before a column type change @@ -126,7 +126,7 @@ npx ztd query uses column public.products.name --exclude-generated > "I'm changing `sale_items.quantity` from `integer` to `numeric`. Who uses it?" ```bash -npx ztd query uses column public.sale_items.quantity --exclude-generated +npx ashiba query uses column public.sale_items.quantity --exclude-generated ``` The command does not judge type compatibility. It tells you every statement that still references the column so you can inspect each one. @@ -136,7 +136,7 @@ The command does not judge type compatibility. It tells you every statement that > "I just added `sale_discounts`. Is anything using it yet?" ```bash -npx ztd query uses table public.sale_discounts +npx ashiba query uses table public.sale_discounts ``` ```text @@ -153,7 +153,7 @@ A clean zero confirms no query depends on it yet. Add `--view detail` to get the exact snippet and file location for each match: ```bash -npx ztd query uses column public.products.title --view detail --exclude-generated +npx ashiba query uses column public.products.title --view detail --exclude-generated ``` ```text diff --git a/docs/guide/repository-telemetry-setup.md b/docs/guide/repository-telemetry-setup.md deleted file mode 100644 index 95d0dbb3c..000000000 --- a/docs/guide/repository-telemetry-setup.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: Repository Telemetry Setup ---- - -# Repository Telemetry Setup - -Use this guide after `ztd init --starter` when you want repository telemetry that helps you debug query behavior without exporting SQL text or bind values by default. - -## What to edit after the starter scaffold - -Start with these files in the generated project: - -- `src/libraries/telemetry/types.ts` -- `src/libraries/telemetry/repositoryTelemetry.ts` -- `src/adapters/console/repositoryTelemetry.ts` - -Then wire telemetry into the repository or service layer that actually runs SQL. - -Typical edit points are repository constructors and query execution helpers under your feature folder, for example: - -- `src/features//application/*.ts` -- `src/features//persistence/*.ts` -- `src/repositories/*.ts` - -## Recommended wiring - -Keep `queryId` stable and treat `repositoryName` and `methodName` as human-readable hints. - -```ts -import { - defaultRepositoryTelemetry, - resolveRepositoryTelemetry, - type RepositoryTelemetry -} from './libraries/telemetry/repositoryTelemetry.js'; - -type RepositoryDeps = { - telemetry?: RepositoryTelemetry; -}; - -export class UsersRepository { - private readonly telemetry: RepositoryTelemetry; - - constructor(deps: RepositoryDeps = {}) { - this.telemetry = resolveRepositoryTelemetry(deps.telemetry ?? defaultRepositoryTelemetry); - } - - async listActiveUsers(): Promise { - this.telemetry.emit({ - kind: 'query.execute.start', - timestamp: new Date().toISOString(), - queryId: 'users.listActive', - repositoryName: 'UsersRepository', - methodName: 'listActiveUsers', - paramsShape: [ - { - name: 'active', - presence: 'present', - kind: 'scalar', - isNull: false, - nullability: 'non-null', - booleanValue: 'true' - } - ], - transformations: { - paging: { enabled: true, hasLimit: true }, - sort: { enabled: true, orderByCount: 1 } - } - }); - } -} -``` - -For a console sink, the generated scaffold already provides `createConsoleRepositoryTelemetry()`. You can pass your own logger later: - -```ts -import { createConsoleRepositoryTelemetry } from './adapters/console/repositoryTelemetry.js'; - -const telemetry = createConsoleRepositoryTelemetry({ - logger: console -}); -``` - -If you use pino or OpenTelemetry, keep the adapter in your application code and forward only the structured event. Do not move SQL text or bind values into the shared starter contract. - -## What the emitted log should look like - -The starter contract keeps the payload intentionally small: - -```json -{ - "kind": "query.execute.success", - "timestamp": "2026-03-27T12:00:00.000Z", - "queryId": "users.listActive", - "repositoryName": "UsersRepository", - "methodName": "listActiveUsers", - "paramsShape": [ - { - "name": "active", - "presence": "present", - "kind": "scalar", - "isNull": false, - "nullability": "non-null", - "booleanValue": "true" - } - ], - "transformations": { - "paging": { "enabled": true, "hasLimit": true }, - "sort": { "enabled": true, "orderByCount": 1 } - }, - "durationMs": 12, - "rowCount": 5 -} -``` - -For error events, the starter contract keeps `errorName` only. If you need more error detail, add it inside a custom sink-specific wrapper instead of widening the shared contract. - -## QueryId present - -When a telemetry event includes `queryId`, the investigation flow is: - -1. Search the emitted `queryId` in your logs. -2. Open the repository or feature code that emits that ID. -3. Compare `paramsShape` and `transformations` with the runtime behavior you observed. -4. Inspect the underlying `.sql` asset if you need to confirm the actual statement. - -This is the fastest path when you already know which repository method was involved. - -## When to stop here - -If the event already tells you which repository method ran, you usually do not need `ztd query match-observed`. -Use `ztd query match-observed` only when you do not have a stable `queryId`. - diff --git a/docs/guide/rfba-overview.md b/docs/guide/rfba-overview.md index 08e2709ad..8d6da5b5a 100644 --- a/docs/guide/rfba-overview.md +++ b/docs/guide/rfba-overview.md @@ -88,9 +88,9 @@ RFBA is compatible with Vertical Slice Architecture. Like VSA, RFBA groups work by feature or use case instead of spreading one use case across technical layers. RFBA adds a review-first focus: inside a feature, expose the artifacts that humans should review most carefully, especially SQL and orchestration, while keeping supporting files close to the review boundary they serve. -## ztd-cli Structural Vocabulary +## Ashiba Structural Vocabulary -`ztd-cli` applies RFBA with three structural terms: +Ashiba applies RFBA with three structural terms: - `root-boundary`: the app-level boundary layer. In rawsql-ts starter layouts, the concrete root-boundaries are `src/features`, `src/adapters`, and `src/libraries`. - `feature-boundary`: a feature-owned boundary under `src/features//`. @@ -116,7 +116,7 @@ Keep feature-specific validation and helpers inside the owning feature boundary. RFBA is not a universal file naming rule. -`boundary.ts` is the default `ztd-cli` feature scaffold convention because it makes generated feature and query entrypoints easy to find. +`boundary.ts` is the default Ashiba feature scaffold convention because it makes generated feature and query entrypoints easy to find. That filename is useful, but it is not the definition of RFBA. Outside feature-scoped scaffold conventions, projects may choose different filenames when that better expresses the local public surface. diff --git a/docs/guide/spec-change-scenarios.md b/docs/guide/spec-change-scenarios.md index 854223a42..a599b8cea 100644 --- a/docs/guide/spec-change-scenarios.md +++ b/docs/guide/spec-change-scenarios.md @@ -10,7 +10,7 @@ Condensed scenarios covering common specification and schema changes, what steps **What changed:** A new `NOT NULL` column added to an existing table's DDL. -**Steps:** `ztd ztd-config` → fix compile errors in fixtures (must include the new column) → update SQL, boundary types, and generated mapper output if needed → re-run tests. +**Steps:** `ashiba check` → fix compile errors in fixtures (must include the new column) → update SQL, boundary types, and generated mapper output if needed → re-run tests. **Takeaway:** NOT NULL columns force fixture updates everywhere the table appears. Plan fixture changes before adding the column. @@ -18,7 +18,7 @@ Condensed scenarios covering common specification and schema changes, what steps **What changed:** Column renamed in DDL (e.g., `name` → `display_name`). -**Steps:** `ztd ztd-config` → update SQL files, boundary types, generated mapper output, validator schemas when present, and fixtures → re-run tests. +**Steps:** `ashiba check` → update SQL files, boundary types, generated mapper output, validator schemas when present, and fixtures → re-run tests. **Takeaway:** TypeScript compile errors are your guide — the generated `TestRowMap` reflects the new name immediately. Fix all references before running tests. @@ -26,7 +26,7 @@ Condensed scenarios covering common specification and schema changes, what steps **What changed:** Column type changed (e.g., `integer` → `bigint`, `text` → `jsonb`). -**Steps:** `ztd ztd-config` → update runtime coercions if driver behavior differs (e.g., bigint returns strings) → update validator schemas → re-run tests. +**Steps:** `ashiba check` → update runtime coercions if driver behavior differs (e.g., bigint returns strings) → update validator schemas → re-run tests. **Takeaway:** Type changes often require runtime coercion updates. Check the [Postgres pitfalls](./postgres-pitfalls.md) page for driver-specific quirks. @@ -34,7 +34,7 @@ Condensed scenarios covering common specification and schema changes, what steps **What changed:** New CREATE TABLE added to DDL. -**Steps:** `ztd ztd-config` → new type appears in `TestRowMap` → write SQL queries, fixtures, and tests → run tests. +**Steps:** `ashiba check` → new type appears in `TestRowMap` → write SQL queries, fixtures, and tests → run tests. **Takeaway:** The simplest scenario — no existing code is affected. Generated types are immediately available. @@ -42,7 +42,7 @@ Condensed scenarios covering common specification and schema changes, what steps **What changed:** Table removed from DDL. -**Steps:** `ztd ztd-config` → type disappears from `TestRowMap` → compile errors show all affected code → remove SQL files, repository methods, fixtures, and tests. +**Steps:** `ashiba check` → type disappears from `TestRowMap` → compile errors show all affected code → remove SQL files, repository methods, fixtures, and tests. **Takeaway:** Compile errors are exhaustive. Fix them all and you're done. @@ -50,7 +50,7 @@ Condensed scenarios covering common specification and schema changes, what steps **What changed:** Constraint or index added to DDL. -**Steps:** `ztd ztd-config` → no type-level changes (indexes/FKs don't affect `TestRowMap`) → test behavior is unchanged. +**Steps:** `ashiba check` → no type-level changes (indexes/FKs don't affect `TestRowMap`) → test behavior is unchanged. **Takeaway:** ZTD doesn't enforce FK constraints in test fixtures — it operates on data, not schema constraints. Runtime tests against a live DB will enforce them. @@ -58,7 +58,7 @@ Condensed scenarios covering common specification and schema changes, what steps **What changed:** `defaultSchema` or `searchPath` updated in `ztd.config.json`. -**Steps:** `ztd ztd-config --default-schema --search-path ` → regenerate types → SQL files may need schema-qualified names → re-run tests. +**Steps:** `ashiba check` → regenerate types → SQL files may need schema-qualified names → re-run tests. **Takeaway:** Schema resolution is a common source of "table not found" errors. Always keep `ztd.config.json` in sync with the database's `search_path`. @@ -68,15 +68,15 @@ Condensed scenarios covering common specification and schema changes, what steps **Steps:** Tests pass (validator still matches old shape) → runtime fails on real data → update validator schema to match new `TestRowMap` shape → re-run tests. -**Takeaway:** Validators are not auto-generated — they must be manually kept in sync with the mapped DTO. Run `ztd check-contract` to catch drift early. +**Takeaway:** Validators are not auto-generated — they must be manually kept in sync with the mapped DTO. Run `ashiba check-contract` to catch drift early. ## 9. Add/remove a column used by a generated mapper **What changed:** Column projected by a generated query boundary was removed or renamed in DDL. -**Steps:** `ztd ztd-config` → update the SQL asset and boundary row type → run `ztd feature generated-mapper generate` → re-run tests. +**Steps:** `ashiba check` → update the SQL asset and boundary row type → run `ashiba feature query refresh` → re-run tests. -**Takeaway:** Generated mappers are machine-owned artifacts. Use generated mapper drift checks and `ztd lint` to catch mismatches early. +**Takeaway:** Generated mappers are machine-owned artifacts. Use generated mapper drift checks and `ashiba lint` to catch mismatches early. ## 10. Switch validator backend (Zod ↔ ArkType) @@ -88,5 +88,5 @@ Condensed scenarios covering common specification and schema changes, what steps ## Further reading -- [After DDL/Schema Changes](https://github.com/mk3008/rawsql-ts/blob/main/packages/ztd-cli/README.md#after-ddlschema-changes) — standard workflow steps +- [Ashiba](https://github.com/mk3008/ashiba) — SQL-first CLI workflows and schema-change guidance - [ZTD Theory](./ztd-theory.md) — conceptual foundation diff --git a/docs/guide/sql-first-end-to-end-tutorial.md b/docs/guide/sql-first-end-to-end-tutorial.md deleted file mode 100644 index b272a86e1..000000000 --- a/docs/guide/sql-first-end-to-end-tutorial.md +++ /dev/null @@ -1,227 +0,0 @@ ---- -title: Starter First-End Tutorial -outline: deep ---- - -# Starter First-End Tutorial - -This tutorial shows the shortest path from `ztd init --starter` to a small `users` feature that can be changed, broken, and repaired with AI help. - -The tutorial uses one starter project, one `smoke` feature, and one `users` feature. The same project is reused for every scenario: - -1. first run -2. CRUD feature creation -3. DDL change -4. SQL change -5. DTO change -6. migration artifact creation - -README gives the first-run copy-paste path. This tutorial gives the scenario-level flow and the preferred CLI for each repair loop. - -## Scenario CLI at a glance - -| Scenario | Primary CLI | Why | -| --- | --- | --- | -| DDL repair | `npx ztd query uses column users.email --scope-dir src/features/users-insert --any-schema --view detail` | Find the impacted feature-local SQL files before editing them | -| SQL repair | `npx ztd model-gen --probe-mode ztd src/features/users-insert/queries/insert-users/insert-users.sql` | Inspect the generated contract on stdout before updating the handwritten query boundary | -| DTO repair | `npx vitest run` after the DTO change | Verify the feature-local runtime and tests after the shape change | -| migration | `npx ztd ztd-config`, optionally `npx ztd ddl pull --url ` to inspect the target, then `npx ztd ddl diff --url --out tmp/users.diff.sql` to prepare review output plus apply SQL | Prepare a manually applied migration without asking ztd-cli to deploy it | -| tuning | `npx ztd query plan ` and the perf guide under `docs/guide/` | Keep perf work in the separate tuning path, not in the starter tutorial | - -`ZTD_DB_URL` is the only implicit database owned by ztd-cli. Use `--url` or a complete `--db-*` flag set for `ddl pull` and `ddl diff` when you want to inspect any other target. - -## 1. Create the starter project - -Run: - -```bash -npx ztd init --starter -``` - -The starter generates: - -- `src/features/smoke` -- `db/ddl/public.sql` -- `compose.yaml` -- Vitest smoke tests - -The smallest DB-backed starter example lives in `src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts`. - It uses `@rawsql-ts/testkit-postgres` and `createPostgresTestkitClient`, so a missing `ZTD_DB_URL`, a stopped Postgres container, or a schema mismatch fails before you build a larger feature. -If you want the fixture-loading details, read `packages/testkit-postgres/README.md` after the starter smoke test. - -## 2. Start Postgres and run the smoke test - -Use the bundled compose file: - -Make sure Docker Desktop or another Docker daemon is already running before you start the compose path, because `docker compose up -d` only launches the stack. - -```bash -cp .env.example .env -# edit ZTD_DB_PORT=5433 if needed -docker compose up -d -npx vitest run -``` - -The starter setup derives `ZTD_DB_URL` from `.env`, so changing `ZTD_DB_PORT` changes both the compose port and the test runtime. - -If port `5432` is already in use, update `ZTD_DB_PORT` in `.env` before you rerun the compose path, for example: - -```bash -cp .env.example .env -# edit ZTD_DB_PORT=5433 -docker compose up -d -npx vitest run -``` - -If you are using PowerShell, the same `.env` file works: - -```powershell -Copy-Item .env.example .env -# edit ZTD_DB_PORT=5433 -docker compose up -d -npx vitest run -``` - -If `docker compose up -d` fails with `all predefined address pools have been fully subnetted`, do not treat it as a port-collision problem. That error means Docker cannot allocate another bridge network, so changing `ZTD_DB_PORT` will not help. Clean up unused Docker networks or widen Docker's address-pool configuration first, then rerun the compose path. - -The smoke test proves the starter wiring is sound before you add real feature work. -It also proves the DB-backed ZTD path is reachable from the starter, not just the DB-free sample path. - -If the project was installed with `pnpm install`, keep using pnpm when you add the node-postgres testkit adapter for the SQL repair loop: - -```bash -pnpm add -D @rawsql-ts/adapter-node-pg -``` - -Avoid mixing `npm install -D` into a pnpm-managed starter project because that can fail before the adapter is added. -`@rawsql-ts/adapter-node-pg` is the current compatible testkit adapter name; it is not the production driver adapter package space. - -## 3. Add the first real feature - -Use `src/features/smoke` as the starter-only teaching example, but scaffold the first real CRUD slice with the CLI: - -```bash -npx ztd feature scaffold --table users --action insert -``` - -That v1 scaffold fixes the initial layout to the RFBA feature-boundary convention: - -- `src/features/users-insert/boundary.ts` -- `src/features/users-insert/tests/users-insert.boundary.test.ts` -- `src/features/users-insert/queries/insert-users/boundary.ts` -- `src/features/users-insert/queries/insert-users/insert-users.sql` -- `src/features/users-insert/queries/insert-users/tests/generated/` -- `src/features/users-insert/queries/insert-users/tests/cases/` -- `src/features/users-insert/README.md` - -In the full RFBA model, `src/features`, `src/adapters`, and `src/libraries` are the concrete `root-boundary` folders. -`src/features/users-insert/` is the `feature-boundary`, and `queries/insert-users/` is one `sub-boundary`. -`queries/` is only the child-boundary container; it does not expose its own public surface. -Within `src/features/*`, `boundary.ts` is the default scaffold entrypoint for feature-boundaries and sub-boundaries. -RFBA is about splitting files by review responsibility: DDL stays the data-structure source of truth, SQL remains the strongest query review boundary, and DTO/mapping/test support stays close to the SQL it serves. -Outside feature-owned boundaries, keep shared feature seams under `src/features/_shared/*`, keep shared verification seams under `tests/support/*`, keep driver-neutral contracts under `src/libraries/*`, keep driver or sink bindings under `src/adapters//*`, and keep `.ztd/*` tool-managed. -Do not count `src/features/_shared/*`, `tests/support/*`, `.ztd/*`, or `db/` as extra root boundaries. -Keep `db/` reserved for DDL, migration, and schema assets; do not place runtime clients or adapters there. - -The feature scaffold creates the boundary files, SQL file, feature-root boundary test, and machine-owned `generated/row-mapper.ts`. After SQL or query-boundary DTO edits, run `npx ztd feature generated-mapper check --feature users-insert` to detect drift; if it fails, run `npx ztd feature generated-mapper generate --feature users-insert --query insert-users` to refresh the machine-owned mapper before continuing. - -After you finish the SQL and DTO edits, run `npx ztd feature tests scaffold --feature users-insert`. -That command refreshes `src/features/users-insert/queries/insert-users/tests/generated/TEST_PLAN.md` and `analysis.json`, refreshes `src/features/users-insert/queries/insert-users/tests/boundary-ztd-types.ts`, and creates the thin `src/features/users-insert/queries/insert-users/tests/insert-users.boundary.ztd.test.ts` Vitest entrypoint only if it is missing. -Persistent case files under `src/features/users-insert/queries/insert-users/tests/cases/` stay human/AI-owned and are not overwritten. - -Treat `tests/support/ztd/` as starter-owned shared support and read-only for feature-specific work. -If `ztd-config` has already run, use `.ztd/generated/ztd-fixture-manifest.generated.ts` as the source for `tableDefinitions` and any fixture-shape hints when you fill the case files. -`beforeDb` and `afterDb` are schema-qualified pure fixture skeletons. -AI-authored cases belong in `src/features/users-insert/queries/insert-users/tests/cases/`, while the fixed app-level runner stays in `tests/support/ztd/harness.ts`. -Keep the feature-root `src/features/users-insert/tests/users-insert.boundary.test.ts` for mock-based boundary tests. -`afterDb` is subset-based per row, rows are treated as an unordered multiset, row order is ignored, and the verifier truncates tables named in `beforeDb` with `restart identity cascade` before seeding. -When the cases are ready, run `npx vitest run src/features/users-insert/queries/insert-users/tests/insert-users.boundary.ztd.test.ts` to execute the ZTD query test. - -## 4. Run the CRUD scenario - -Use this prompt: - -This prompt is meant to be copied into another AI instance so we can observe whether the scaffold, generated README, and CLI guidance are enough on their own. - -```text -Add a users insert feature to this feature-first project. -Start with `npx ztd feature scaffold --table users --action insert`. -Use `root-boundary`, `feature-boundary`, and `sub-boundary` as the ztd-cli structural vocabulary for RFBA. -Treat `src/features`, `src/adapters`, and `src/libraries` as the only concrete root-boundaries in this app. -Keep `boundary.ts`, the query-local `boundary.ts`, and the query-local SQL resource inside `src/features/users-insert`. -Treat RFBA as review-responsibility structure, not as a universal file naming rule. -Treat `queries/` as a child-boundary container rather than a boundary with its own public surface. -Keep shared feature seams under `src/features/_shared/*`, shared verification seams under `tests/support/*`, driver-neutral contracts under `src/libraries/*`, and driver or sink bindings under `src/adapters//*`. -Keep `src/features/_shared/*`, `tests/support/*`, `.ztd/*`, and `db/` in their own roles without counting them as root-boundaries. -Before you edit DTOs or write persistent query cases, run `npx ztd feature tests scaffold --feature users-insert`. That command refreshes `src/features/users-insert/queries/insert-users/tests/generated/TEST_PLAN.md` and `analysis.json`, refreshes `src/features/users-insert/queries/insert-users/tests/boundary-ztd-types.ts`, and creates the thin `src/features/users-insert/queries/insert-users/tests/insert-users.boundary.ztd.test.ts` Vitest entrypoint only if it is missing. Persistent case files under `src/features/users-insert/queries/insert-users/tests/cases/` stay human/AI-owned and are not overwritten. If `ztd-config` has already run, use `.ztd/generated/ztd-fixture-manifest.generated.ts` as the source for `tableDefinitions` and any fixture-shape hints when you fill the case files. The validation cases may stay at the feature boundary, but the success case must execute through the fixed app-level ZTD runner and verify the returned result. Do not put returned columns into the input fixture. Read `TEST_PLAN.md` and `analysis.json` before filling the persistent case files under `src/features/users-insert/queries/insert-users/tests/cases/`. `afterDb` is subset-based per row, rows are treated as an unordered multiset, row order is ignored, and the verifier truncates tables named in `beforeDb` with `restart identity cascade` before seeding. After the cases are ready, run `npx vitest run src/features/users-insert/queries/insert-users/tests/insert-users.boundary.ztd.test.ts` to execute the ZTD feature test. -If the returned id is null, stop and fix the scaffold or DDL instead of weakening the test. -Before writing the success-path assertion, inspect `insert-users.sql` and `boundary.ts`. If the scaffold does not actually return a non-null id, report that mismatch instead of inventing fixture data or schema overrides. -Do not apply migrations automatically. -``` - -Expected result: - -- the agent edits the `users-insert` feature only -- the agent keeps SQL and the feature entrypoint feature-local -- the agent adds tests only after the scaffold exists -- the next command is a normal project test run - -## 5. Run the DDL / SQL / DTO change scenarios - -Use the same `users` project for each scenario: - -- change the DDL and let the agent repair the failures -- change the SQL and let the agent repair the failures -- change the DTO shape and let the agent repair the failures - -Each scenario should end with `vitest` passing again. - -For DDL repair, run `npx ztd query uses column users.email --scope-dir src/features/users-insert --any-schema --view detail` first so the impacted SQL files come from the CLI, not from guesswork. Passing the feature folder as `--scope-dir` is a normal way to narrow the project-wide scan, not a workaround for feature-local layouts. - -For SQL repair, keep the SQL assets under `src/features/users-insert/queries/insert-users/`, keep the query on the starter DDL's `users` table, and rerun `model-gen` against `src/features/users-insert/queries/insert-users/insert-users.sql` directly to inspect the generated contract on stdout before you update the handwritten query boundary. If you want to save that output for reference or gradual migration, write it to a dedicated generated-contract file with `--out` instead of overwriting handwritten runtime files. Do not target `src/features/users-insert/queries/insert-users/boundary.ts` with `--out`, because that file is the runtime boundary that also owns `loadSqlResource` and the execution flow. In VSA layouts, `model-gen` now treats the SQL file location as the primary contract source, so `--sql-root` is only needed for older shared-root layouts. - -For migration work, use an explicit `--url ` with `ddl pull` or `ddl diff` so the target database is never inferred from the starter test database by accident. - -Read the review summary first: - -- the summary tells you what changed logically -- the risks section lists destructive and operational apply-plan risks separately -- even a small summary can still carry destructive risks when the generated apply SQL rebuilds a table -- the generated `.sql` file stays SQL-only so you can review or apply it separately -- the companion `.json` file is for AI/tools that need structured migration metadata -- if you hand-edit the generated migration SQL, run `npx ztd ddl risk --file tmp/users.diff.sql` so the final SQL is re-evaluated with the same structured risk contract -- current `ztd ddl diff` CLI does not expose the lower-level drop-avoidance options from core, so treat drop-related risks as mandatory review points - -Tuning belongs to the separate performance guide and dogfooding set, not to the starter lifecycle in this tutorial. Keep the starter path focused on CRUD, DDL, SQL, DTO, and migration repair loops. - -## 6. Run the migration loop - -When the schema change needs a deployable migration, keep the flow explicit: - -Use a fresh AI prompt for this step so we can confirm the migration guidance works without human patching in the middle. - -1. Edit the DDL in `db/ddl/public.sql` or the relevant schema file. -2. Run `npx ztd ztd-config` to refresh the ZTD-generated artifacts, including `.ztd/generated/ztd-fixture-manifest.generated.ts` for runtime schema metadata (`tableDefinitions` only). -3. Optionally run `npx ztd ddl pull --url ` to inspect the target, then run `npx ztd ddl diff --url --out tmp/users.diff.sql` when you need a migration plan. -4. Read the text summary first, inspect the generated SQL second, and apply the SQL outside `ztd-cli`. -5. Re-run `npx ztd ztd-config` and `npx vitest run` after the migration lands so the generated runtime manifest stays in sync with the schema metadata. - -The fixture contract is intentionally split: - -- generated `tableDefinitions` are the normal runtime path after `ztd-config` -- explicit `tableDefinitions` / `tableRows` are for local tests that want direct fixtures -- `ddl.directories` is the fallback only when no generated manifest exists - -This step belongs in the tutorial because the starter path should show not only how to add a feature, but also how to evolve the schema safely without asking `ztd-cli` to own deployment. - -## 7. What good looks like - -After the starter flow is green, the user should be able to answer these questions without guessing: - -- Where does the next feature live? -- Which files should the agent read first? -- Which command verifies the change? -- Which files stay feature-local? -- How do I prepare a migration without making `ztd-cli` deploy it for me? - -If the answer is unclear, fix the scaffold, generated README, CLI help, or prompt before adding more tutorial content. diff --git a/docs/guide/sql-style-lint-spec.md b/docs/guide/sql-style-lint-spec.md deleted file mode 100644 index 4c6d3f42a..000000000 --- a/docs/guide/sql-style-lint-spec.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: SQL Style Lint Specification ---- - -# SQL Style Lint Specification - -`ztd query lint --rules leading-comma` checks generated or maintained SQL for multiline comma placement without rewriting the file. - -Use this rule when a package wants leading commas in multiline SQL lists and wants the check to be enforced by tooling instead of reviewer memory. - -## Why lint-only - -The style check is intentionally lint-only. SQL formatting can change comment placement, and comment round-tripping must be proven separately before automatic style rewrites become safe for generated SQL workflows. - -The rule validates that the SQL parses through rawsql-ts first, then reports source locations where a comma remains at the end of a continued line. - -## Usage - -```sh -ztd query lint path/to/query.sql --rules leading-comma -``` - -Use comma-separated rules when combining style and structure checks: - -```sh -ztd query lint path/to/query.sql --rules join-direction,leading-comma -``` - -## Reported pattern - -This reports a warning because the comma trails the previous line: - -```sql -select - id, - email -from public.users -``` - -The preferred leading-comma form is clean: - -```sql -select - id - , email -from public.users -``` - -One-line lists are not reported: - -```sql -select id, email from public.users -``` - -## Suppression - -Use a local suppression only when a generated query needs an intentional exception: - -```sql --- ztd-lint-disable leading-comma -select - id, - email -from public.users -``` - -The suppression disables only the `leading-comma` rule for that SQL text. diff --git a/docs/guide/sql-tool-happy-paths.md b/docs/guide/sql-tool-happy-paths.md deleted file mode 100644 index d6fb2f9ef..000000000 --- a/docs/guide/sql-tool-happy-paths.md +++ /dev/null @@ -1,114 +0,0 @@ -# SQL Tool Happy Paths - -This guide maps common SQL investigation questions to the shortest useful `ztd-cli` path. -Use it when the problem is not "how do I use every command?" but "which command should I run first?" - -## Primary routing table - -| Problem shape | Start here | Then | Avoid as the first step | -|---------------|------------|------|--------------------------| -| I need to understand how one SQL asset will split into pipeline stages | `ztd query plan ` | `ztd perf run --dry-run ...` | Telemetry, `query uses` | -| I suspect the optimizer is confused by a predicate or CTE | `ztd query plan ` | `ztd perf run --dry-run ...` and the perf tuning decision guide | `query uses` | -| I need to decide between index tuning and pipeline tuning for a high-volume query | `ztd query plan ` plus QuerySpec `metadata.perf` | `ztd perf db reset --dry-run`, then `ztd perf run ...`, then direct vs decomposed comparison | Telemetry before plan or perf evidence exists | -| I need to confirm where a table or column is used before changing it | `ztd query uses ` | `ztd query lint ` | Telemetry | -| I have an observed SQL statement from logs and need to find the original asset | `ztd query match-observed --sql-file ` | `ztd query uses` if the observed SQL later reveals a stable `queryId` path | `query uses` as the first step | -| I need timing, trace export, or machine-readable execution evidence | Telemetry mode for the command under investigation | The structural command that produced the suspicious result | Starting with telemetry before the SQL shape is known | -| I need to inspect generated SQL or rewritten predicates | `ztd query plan ` plus the focused SQL/debug workflow for the scenario | Integration or DB-backed verification | `query uses` | -| I need to add optional search filters without falling back to SQL concatenation | `ztd query sssql scaffold` / `ztd query sssql refresh` plus truthful branches and `optionalConditionParameters` | Focused pruning verification in unit tests | Telemetry, `query uses` | -| I need to decide whether an optional filter belongs in DynamicQueryBuilder or SSSQL | [Dynamic Filter Routing](./dynamic-filter-routing.md) | Add the focused routing dogfooding test | Guessing from prompt wording alone | - -## Recommended dogfooding loop for SQL pipeline work - -1. Run `ztd query plan ` to inspect the proposed pipeline steps. -2. Run `ztd perf run --dry-run ...` to see materialization and scalar-filter candidates. -3. Reproduce the suspicious case with the smallest focused verification surface. -4. Add or update tests only after the command path tells you which stage is actually wrong. -5. Use telemetry only when the question has become about timing, export, or trace fidelity. - -This order keeps structural debugging ahead of observability debugging. - -## Problem-specific notes - -### `query plan` - -Use `query plan` when you need to answer: - -- Which stages will materialize? -- Which predicates are candidates for scalar filter binding? -- In what order will the stages run? -- Which metadata choice changes the pipeline shape? - -This is the default entry point for SQL pipeline dogfooding. - -### `perf run --dry-run` - -Use `perf run --dry-run` after `query plan` when you need recommendations rather than just structure. -It is the best follow-up when the next question is "is this rewrite likely worth doing?" - -Look for: - -- QuerySpec `spec_guidance` scale expectations -- `ddl_inventory` table/index counts -- `tuning_guidance.primary_path` -- `candidate_ctes` -- `scalar_filter_candidates` -- human-readable text hints such as `consider-scalar-filter-binding` - -### `query uses` - -Use `query uses` for impact analysis, not for pipeline debugging. -Good fits include: - -- column rename preparation -- table split impact audit -- catalog-wide usage inventory before refactors - -If the task is about runtime semantics, plan shape, or optimizer-facing SQL simplification, `query uses` is usually not the first tool. - -### `query match-observed` - -Use `query match-observed` when you only have observed SQL from a DB log, tracing system, or load monitor and you need to guess which `.sql` asset most likely produced it. -It is the structural reverse lookup for cases where `queryId` is missing. - -Look for: - -- projection overlap -- FROM / JOIN graph overlap -- predicate family overlap -- ORDER BY overlap -- LIMIT / OFFSET presence - -This command is intentionally SELECT-first and does not try to prove semantic equivalence. - -### Telemetry - -Telemetry is an opt-in branch after the structural path is known. -Use it when you need: - -- command timing breakdowns -- machine-readable traces for CI or automation -- evidence that the command boundary or export path is wrong - -Telemetry is intentionally not the default happy path for normal SQL dogfooding. -When `queryId` is available, telemetry should be your first stop; when it is not, use `query match-observed` first and telemetry second. - -Saved telemetry regression scenarios live in [Telemetry Dogfooding Scenarios](../dogfooding/telemetry-dogfooding.md). - -Saved SQL debug recovery scenarios live in [SQL Debug Recovery Dogfooding](../dogfooding/sql-debug-recovery.md). - -Saved SSSQL optional-condition scenarios live in [SSSQL Optional-Condition Dogfooding](../dogfooding/sssql-optional-condition.md). - -Saved perf scale tuning scenarios live in [Perf Scale Tuning Dogfooding](../dogfooding/perf-scale-tuning.md). - -## Current saved dogfooding surfaces - -The current routing now has saved regression scenarios for the following previously weak areas: - -| Tool area | Saved scenario | Why it matters | -|-----------|----------------|----------------| -| Telemetry | `query uses`, `model-gen`, and `perf run --dry-run` timelines | Keeps phase attribution stable when the command result is correct but the boundary between phases is not. | -| SQL/debug flow | Long-CTE recovery loop with `query outline`, `query lint`, `query slice`, `query patch apply`, and `perf run` | Preserves the shortest command sequence that is enough to decide the next repair or tuning step. | -| SSSQL authoring | Optional-condition request -> `query sssql scaffold` / `query sssql refresh` -> explicit pruning parameters | Keeps optional-filter requests on the SQL-first path instead of regressing to string-built WHERE assembly. Runtime no longer injects new filter predicates. | -| Perf scale tuning | QuerySpec perf metadata -> DDL/index inventory -> index-vs-pipeline guidance | Keeps high-volume tuning loops explicit about whether the next step is DDL/index work or SQL decomposition. | - -When a tool keeps existing but does not become the natural first step in dogfooding, add a scenario that makes its happy path unavoidable. diff --git a/docs/guide/sssql-for-humans.md b/docs/guide/sssql-for-humans.md index 2e490f7e0..3643137c6 100644 --- a/docs/guide/sssql-for-humans.md +++ b/docs/guide/sssql-for-humans.md @@ -111,5 +111,4 @@ If the answers are yes to 2-4 and no to 1, SSSQL is usually the right tool. - [What Is SSSQL?](./sssql-overview.md) - [Dynamic Filter Routing](./dynamic-filter-routing.md) -- [ztd-cli SSSQL Authoring](./ztd-cli-sssql-authoring.md) -- [SSSQL Optional Branch Pruning MVP](./sssql-optional-branch-pruning.md) \ No newline at end of file +- [SSSQL Optional Branch Pruning MVP](./sssql-optional-branch-pruning.md) diff --git a/docs/guide/sssql-overview.md b/docs/guide/sssql-overview.md index f7af1f815..a505dd4b0 100644 --- a/docs/guide/sssql-overview.md +++ b/docs/guide/sssql-overview.md @@ -79,4 +79,3 @@ Only leave SSSQL when one of these is true: - [SSSQL for Humans](./sssql-for-humans.md) - [SSSQL Optional Branch Pruning MVP](./sssql-optional-branch-pruning.md) - [Querybuilding Recipes](./querybuilding-recipes.md) -- [SQL Tool Happy Paths](./sql-tool-happy-paths.md) diff --git a/docs/guide/ztd-cli-agent-interface.md b/docs/guide/ztd-cli-agent-interface.md deleted file mode 100644 index 6a1b0c347..000000000 --- a/docs/guide/ztd-cli-agent-interface.md +++ /dev/null @@ -1,146 +0,0 @@ -# ztd-cli Agent Interface - -`ztd-cli` supports a machine-readable automation path intended for AI agents and scripted callers. - -## Core Conventions - -- Use `ztd --output json ...` to request a JSON envelope on stdout. -- Expect structured diagnostics on stderr when JSON output is enabled. -- Prefer `--dry-run` before commands that write files. -- Use `--json ` on supported commands when nested option construction is easier than individual flags. -- For `ztd ddl diff`, treat `summary` as the logical diff, treat `risks` as the apply-plan risk list, use the `.json` artifact for automation when needed, and keep the `.sql` output as the apply-target artifact. -- Use `ztd ddl risk --file ` when you need to evaluate a generated or hand-edited migration SQL file directly; it emits the same `risks` contract without regenerating the migration. -- Treat the migration generator and the risk evaluator as separate responsibilities: `ddl diff` builds a migration plus review artifacts, while `ddl risk` evaluates the migration SQL itself after human edits. -- When a request is "add an optional filter" to a SQL asset, prefer SSSQL-style truthful SQL branches before suggesting string-built SQL assembly outside the file. -- The default response pattern for those requests is: update `src/sql/...`, rerun `ztd model-gen --probe-mode ztd` if needed, rerun `ztd lint` plus tests, and only then wire `optionalConditionParameters` for runtime pruning. -- Do not route routine optional predicates toward redundant `LEFT JOIN` scaffolding plus `removeUnusedLeftJoins` cleanup. -- `ztd-cli` implicitly uses only `ZTD_DB_URL` for ZTD-owned workflows. -- Do not assume `DATABASE_URL` is a usable default target for `ztd-cli`; non-ZTD targets must be passed explicitly with `--url` or complete `--db-*` flags. - -- For a scale-sensitive perf request, gather QuerySpec `metadata.perf`, current seed row counts, DDL/index inventory, and whether a captured plan already exists before picking index tuning or pipeline tuning. -- If any of those perf inputs are missing, respond by capturing them with `ztd perf db reset --dry-run` and `ztd perf run` before proposing a fix. - -For SQL authoring guidance around optional predicates, see [ztd-cli SSSQL Authoring](./ztd-cli-sssql-authoring.md). - -## JSON Envelope - -Supported commands emit a JSON object on stdout with this shape: - -```json -{ - "schemaVersion": 1, - "command": "describe command", - "ok": true, - "data": {} -} -``` - -Fields: - -- `schemaVersion`: version of the envelope contract -- `command`: normalized command label -- `ok`: success flag -- `data`: command-specific payload - -## Introspection - -Use `describe` to inspect command capabilities at runtime. - -```bash -ztd describe -ztd describe command init -ztd --output json describe command model-gen -``` - -The detailed form includes: - -- whether the command writes files -- whether `--dry-run` is supported -- whether `--json ` is supported -- whether an output contract can be described separately -- expected stdout/files and exit-code meanings - -The full field contract is documented in [ztd-cli Describe Schema](./ztd-cli-describe-schema.md). - -Examples: - -```bash -ztd ztd-config --json '{"ddlDir":"ztd/ddl","extensions":".sql,.ddl","dryRun":true}' -ztd check contract --json '{"format":"json","strict":true}' -ztd check contract --json '{"format":"json","scopeDir":"src/features/users"}' -ztd query uses column --json '{"target":"public.users.email","format":"json","summaryOnly":true}' -ztd lint --json '{"path":"src/sql/**/*.sql"}' -``` - -`ztd check contract` and `ztd evidence` discover QuerySpec-like assets project-wide by default, including RFBA feature-local query boundaries. Use `--scope-dir` or `scopeDir` only when a review should focus on one feature or subtree. `--specs-dir` remains available for legacy fixed catalog-spec directories. - -`--scope-dir`/`scopeDir` and `--specs-dir` are mutually exclusive. Passing both is a runtime error. Omit both to scan project-wide, pass only `--scope-dir src/features/users` for a feature subtree, or pass only `--specs-dir src/catalog/specs` for a legacy fixed catalog-spec directory. - -## Write Safety - -These commands support `--dry-run`: - -- `ztd init` -- `ztd ztd-config` -- `ztd model-gen` -- `ztd ddl pull` -- `ztd ddl diff` -- `ztd ddl gen-entities` - -Dry-run validates inputs, resolves paths, and computes outputs without writing repo files. - -For SQL-backed scaffolding, `ztd model-gen` now treats feature-local SQL files as the primary contract source. In VSA layouts, omit `--sql-root` unless the project intentionally keeps SQL under a shared compatibility root. - -## Output Controls - -For large reports, prefer these controls: - -- `ztd query uses ... --summary-only` -- `ztd query uses ... --limit ` -- `ztd evidence ... --summary-only` -- `ztd evidence ... --limit ` - -These options keep agent context windows smaller while preserving headline counts in the report summary. - -When output controls are applied, JSON reports include `display` metadata so callers can distinguish truncation from a true zero-result scan. - -`query uses` example: - -```json -{ - "schemaVersion": 2, - "view": "detail", - "summary": { - "matches": 12, - "parseWarnings": 0 - }, - "matches": [], - "warnings": [], - "display": { - "summaryOnly": true, - "totalMatches": 12, - "returnedMatches": 0, - "totalWarnings": 1, - "returnedWarnings": 0, - "truncated": true - } -} -``` - -`evidence` example: - -```json -{ - "schemaVersion": 1, - "mode": "specification", - "summary": { - "sqlCatalogCount": 4, - "testCaseCount": 18 - }, - "display": { - "summaryOnly": false, - "limit": 5, - "truncated": true - } -} -``` diff --git a/docs/guide/ztd-cli-describe-schema.md b/docs/guide/ztd-cli-describe-schema.md deleted file mode 100644 index 6e063277e..000000000 --- a/docs/guide/ztd-cli-describe-schema.md +++ /dev/null @@ -1,93 +0,0 @@ -# ztd-cli Describe Schema - -`ztd describe` is the runtime contract surface for command discovery. - -## Envelope - -When `--output json` is enabled, `describe` uses the standard command envelope: - -```json -{ - "schemaVersion": 1, - "command": "describe", - "ok": true, - "data": {} -} -``` - -## `ztd describe` - -`data` contains the command catalog: - -```json -{ - "schemaVersion": 1, - "commands": [ - { - "name": "model-gen", - "summary": "Probe SQL metadata and generate QuerySpec scaffolding.", - "writesFiles": true, - "supportsDryRun": true, - "supportsJsonPayload": true, - "supportsDescribeOutput": true - } - ] -} -``` - -Fields: - -- `name`: stable command identifier -- `summary`: short human-readable intent -- `writesFiles`: whether the command can mutate the workspace -- `supportsDryRun`: whether `--dry-run` is supported -- `supportsJsonPayload`: whether `--json ` is supported -- `supportsDescribeOutput`: whether the command exposes a secondary output contract - -## `ztd describe command ` - -`data.command` contains the full descriptor: - -```json -{ - "schemaVersion": 1, - "command": { - "name": "ztd-config", - "summary": "Generate TestRowMap and layout metadata from local DDL.", - "writesFiles": true, - "supportsDryRun": true, - "supportsJsonPayload": true, - "output": { - "stdout": "Status or JSON envelope.", - "files": [ - ".ztd/generated/ztd-row-map.generated.ts", - ".ztd/generated/ztd-fixture-manifest.generated.ts", - ".ztd/generated/ztd-layout.generated.ts" - ] - }, - "exitCodes": { - "0": "Generation completed or dry-run plan emitted.", - "1": "Generation failed." - }, - "flags": [ - { - "name": "--dry-run", - "description": "Render and validate generation without writing files." - } - ] - } -} -``` - -Fields: - -- `output.stdout`: stdout contract summary when present -- `output.files`: expected file outputs when the command writes artifacts -- `exitCodes`: deterministic exit-code meanings -- `flags`: supported machine-relevant flags and defaults - -## Stability - -- `schemaVersion` is the contract version for the describe payload itself. -- New fields may be added in a backward-compatible way. -- Existing field names and meanings should not change without a `schemaVersion` bump. diff --git a/docs/guide/ztd-cli-measurement-inventory.md b/docs/guide/ztd-cli-measurement-inventory.md deleted file mode 100644 index 6a073845e..000000000 --- a/docs/guide/ztd-cli-measurement-inventory.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -title: ztd-cli Measurement Inventory -outline: deep ---- - -# ztd-cli Measurement Inventory - -This guide inventories the existing timing, profiling, duration-reporting, and benchmark-specific measurement paths that currently exist around `ztd-cli`. -Its purpose is to make the OpenTelemetry migration explicit before any new instrumentation is added. - -For dogfooding findings and their remediation status, use the companion [Finding Registry](./finding-registry.md). -That registry keeps the failure record, the prevention layer, and the evidence separate from this measurement inventory. - -## Scope and audit rules - -- The audit covers `packages/ztd-cli`, generated helper paths that `ztd-cli` scaffolds, and benchmark/test harnesses that exist specifically to exercise the ZTD flow. -- The audit does not treat generic parser microbenchmarks outside the ZTD workflow as part of the `ztd-cli` instrumentation surface. -- Classification uses four migration actions: - - `replace with OTel` - - `keep as local debug utility` - - `wrap with OTel-backed implementation` - - `remove` - -## Executive summary - -- `packages/ztd-cli/src` currently has no always-on timing or profiling pipeline for production CLI commands. -- The main ad-hoc measurement surface lives in generated or benchmark-only helpers, not in the command handlers themselves. -- The closest overlap with future OpenTelemetry work is the JSON event stream produced by the ZTD testkit helper (`ZTD_SQL_LOG*`, `ZTD_PROFILE*`), because it already models connection/setup/query/teardown phases. -- Benchmark runners also collect rich timing data, but those paths drive deterministic reports and should stay local instead of being turned into primary telemetry sinks. - -## Inventory - -| Mechanism | Location | Current behavior | Audience | Recommended migration | -| --- | --- | --- | --- | --- | -| CLI command output envelopes and diagnostics | `packages/ztd-cli/src/utils/agentCli.ts`, `packages/ztd-cli/src/commands/describe.ts`, `packages/ztd-cli/src/commands/ztdConfigCommand.ts` | Emits structured command status and diagnostics, but not timings. | User-facing automation output | `keep as local debug utility` | -| Scaffold template hook for test clients | `packages/ztd-cli/templates/tests/support/testkit-client.ts` | Placeholder only; no measurement logic is emitted by default. | User-facing scaffold entrypoint | `keep as local debug utility` | -| Generated SQL log and profile events for ZTD-backed tests | `benchmarks/sql-unit-test/tests/support/testkit-client.ts` | Emits `ztd-sql` and `ztd-profile` JSON events via `ZTD_SQL_LOG*` / `ZTD_PROFILE*`, including connection, setup, query, teardown, optional SQL text, params, and fixtures. | Internal debug utility used by tests and local diagnostics | `wrap with OTel-backed implementation` | -| Benchmark-specific ZTD metrics collector | `benchmarks/ztd-bench-vs-raw/tests/support/testkit-client.ts` | Records SQL count, DB time, rewrite time, fixture materialization, cleanup, and optional SQL/profile logs; writes worker-scoped metrics files. | Benchmark-only | `keep as local debug utility` | -| Shared benchmark phase logger | `benchmarks/support/benchmark-logger.ts` | Appends JSONL benchmark events, mirrors to console by level, and stores in-memory phase entries for report generation. | Internal benchmark/report pipeline | `keep as local debug utility` | -| Benchmark DB acquire/release timing | `benchmarks/support/db-client.ts` | Uses `process.hrtime.bigint()` to measure pool acquisition and shutdown timing; emits `acquireClient` and timeout diagnostics. | Internal benchmark/report pipeline | `keep as local debug utility` | -| End-to-end benchmark orchestration and reporting | `benchmarks/ztd-test-benchmark.ts`, `benchmarks/bench-runner/**` | Aggregates run durations, startup/execution splits, concurrency data, session stats, and Markdown/JSON reports. | Benchmark-only and report-only | `keep as local debug utility` | -| Scenario-local stage timers for benchmark scripts | `benchmarks/sql-unit-test/tests/scenarios/customerSummaryScenario.ts`, `benchmarks/sql-unit-test/scripts/customer-summary-benchmark.ts`, `benchmarks/sql-unit-test/scripts/ztd-rewrite-microbench.ts` | Uses `performance.now()` to attribute connection/query/verify/cleanup or parse/convert/stringify stage costs. | Benchmark-only | `keep as local debug utility` | - -## Detailed findings - -### 1. Production CLI commands are not currently timed - -The command implementations under `packages/ztd-cli/src/commands` expose dry-run plans, JSON envelopes, and deterministic diagnostics, but they do not currently measure elapsed time or emit profiling spans. - -Implication for OpenTelemetry: - -- There is no existing command-timing system that must be replaced first. -- New OTel command spans can be introduced without having to unwind an incumbent timing API in the CLI itself. - -Recommendation: - -- Keep the current command envelopes and diagnostics exactly as they are. -- If command-level OTel is added later, treat it as an additive span layer, not as a replacement for stdout/stderr UX contracts. - -### 2. The strongest overlap is the generated ZTD profile stream - -The helper used by the benchmark-backed ZTD test flows already models the lifecycle that OTel would likely represent: - -- `connection` -- `setup` -- `query` -- `teardown` - -It also captures optional attributes that map naturally to span attributes or events: - -- SQL text -- bind params -- fixture names -- execution mode -- query counts and total query time - -Why this overlaps with OTel: - -- The phase model is span-shaped already. -- The current implementation prints JSON to a sink, which is useful locally but duplicates the semantic structure a tracer would carry. -- If OTel is added independently, this path would otherwise emit two parallel representations of the same lifecycle. - -Recommendation: - -- Wrap this surface with an OTel-backed implementation instead of deleting it outright. -- Keep the current JSON sink as a compatibility/debug adapter that can subscribe to span lifecycle events when local troubleshooting needs raw event logs. -- Preserve the existing env-gated opt-in behavior so tests do not become noisy by default. - -### 3. Benchmark metrics should stay benchmark-local - -The benchmark harnesses do more than timing: - -- they produce deterministic files under `tmp/` -- they aggregate worker-level metrics -- they compute report-friendly summaries such as p95 waits, total SQL counts, and startup/execution splits -- they drive Markdown reports that are meant for regression analysis - -Why this should not be replaced by OTel: - -- OTel is better suited for tracing/export than for repository-local benchmark report generation. -- The benchmark reports need stable, replayable file artifacts and explicit aggregation rules. -- Replacing these paths with traces would move core reporting logic into a telemetry backend concern and make local reproduction harder. - -Recommendation: - -- Keep benchmark loggers and metric collectors local. -- If future OTel work wants observability during benchmark runs, emit spans secondarily from these code paths rather than making OTel the source of truth for reports. - -### 4. Placeholder scaffold files are not a telemetry surface yet - -The default scaffolded `packages/ztd-cli/templates/tests/support/testkit-client.ts` is a placeholder that throws until users wire their own adapter. - -Implication: - -- There is no default measurement burden in the scaffold itself. -- OTel work should target the generated/shared helper implementations, not the placeholder. - -Recommendation: - -- Keep the placeholder minimal. -- Avoid adding instrumentation there unless the scaffold starts shipping a real default helper implementation. - -## Overlap and conflict matrix - -| Surface | Potential OTel overlap | Risk if left unchanged | Recommendation | -| --- | --- | --- | --- | -| CLI command envelopes | Low | None; different purpose | Keep | -| `ztd-profile` / `ztd-sql` helper logs | High | Duplicate lifecycle reporting once spans exist | Wrap | -| Benchmark phase logger | Medium | Confusing duplicate timing streams during benchmark runs | Keep local, optional secondary span export only | -| Benchmark metrics files and report builders | Low | Minimal; they are report artifacts, not telemetry transport | Keep | -| Scaffold placeholder | None | None | Keep | - -## Proposed migration sequence - -1. Add OpenTelemetry only to the generated/shared ZTD helper lifecycle first. -2. Map `connection`, `setup`, `query`, and `teardown` to spans or span events with stable attribute names. -3. Preserve the existing env-controlled JSON output as a compatibility adapter for local debugging. -4. Leave benchmark-specific reporters and file outputs unchanged until there is a concrete need for optional trace export during benchmark execution. -5. Re-evaluate whether command-level spans are useful after the helper path is instrumented, because today there is no existing timing contract to migrate there. - -## Recommended ownership boundaries - -- `packages/ztd-cli/src`: command UX, JSON envelopes, dry-run plans, deterministic diagnostics. -- Shared/generated helper path: best place for future runtime/test OpenTelemetry spans. -- `benchmarks/**`: remain the source of truth for reproducible performance reports and micro/macro benchmark metrics. - -## Summary of recommended actions - -| Mechanism class | Action | -| --- | --- | -| Command diagnostics and JSON envelopes | Keep as-is | -| Generated helper lifecycle profiling | Wrap with OTel-backed implementation | -| Benchmark phase/timing/report utilities | Keep local | -| Placeholder scaffold surfaces | Keep minimal, no OTel yet | diff --git a/docs/guide/ztd-cli-quality-gates.md b/docs/guide/ztd-cli-quality-gates.md deleted file mode 100644 index a622073f0..000000000 --- a/docs/guide/ztd-cli-quality-gates.md +++ /dev/null @@ -1,88 +0,0 @@ -# ztd-cli Quality Gates - -`ztd-cli` uses weighted gate timing so local commits stay fast without dropping important coverage. - -## Essential - -Run on every `ztd-cli`-scoped pre-commit and PR check. - -- `typecheck` -- `build` -- `lint` -- scaffold and CLI contract tests -- pre-commit policy tests - -These gates protect user-facing command behavior and generated-project expectations. -Broader CLI integration scenarios still run in the package-level test lanes outside the always-blocking gate. - -## Soft Gate - -Run on a nightly schedule instead of blocking every local commit or PR. - -- `tests/repoGuidance.unit.test.ts` -- `tests/intentProcedure.docs.test.ts` -- `tests/perfBenchmark.unit.test.ts` -- `tests/perfSandbox.unit.test.ts` -- `tests/queryLint.unit.test.ts` - -These lanes stay visible, but they are intentionally not part of the always-blocking gate. - -## Release Readiness - -`release-readiness` is a separate blocking PR check for release-affecting changes. - -It is intended for changes that are more likely to break publishability than ordinary unit-level regressions. -The check name stays stable so it can be configured as a required status check in GitHub branch protection. - -### Trigger Heuristics - -The PR-side gate runs full release-readiness validation when changed files match at least one of these categories: - -- scaffold or generated-project layout paths such as `packages/ztd-cli/templates/` and `packages/ztd-cli/src/commands/init.ts` -- package publish-shape paths such as `packages/*/package.json` and package `CHANGELOG.md` -- publish workflow and publish helper paths under `.github/workflows/`, `.github/actions/setup-publish-runtime/`, and `scripts/verify-published-package-mode.mjs` -- release-note paths under `.changeset/` - -PRs that do not match these heuristics still receive the `release-readiness` check, but it exits successfully without running the heavier publish smoke lane. - -### What The Check Proves - -When the PR is release-affecting, `release-readiness` validates: - -- the release runtime on Node 24 with a blocking npm minimum of `11.5.1` -- published-package smoke through `pnpm verify:published-package-mode` -- packed tarball `dist/` presence -- manifest entrypoint and `exports` consistency -- npm-primary-path scaffold behavior through the packaged CLI path - -Runtime-version mismatches fail fast in setup instead of surfacing as warning-only diagnostics in this gate. -Publish-path failures also fail fast because the packed-package smoke step exits non-zero on missing `dist`, broken entrypoints, or broken packaged CLI flows. - -## PR Readiness Contract - -`PR Check` also enforces a PR-body contract before the heavier package lanes run. - -The contract has three goals: - -- baseline exceptions must be explicit and linked to tracked remediation -- CLI surface changes must carry either a migration packet or an explicit no-migration rationale -- scaffold changes must carry either the standard contract proof set or an explicit no-proof rationale - -The author-facing entry point is `.github/pull_request_template.md`. -The enforcement point is `scripts/check-pr-readiness.js`, which reads the PR body from `GITHUB_EVENT_PATH`. -For mechanical authoring, use `pnpm pr:readiness:prepare ...` to generate a validator-compatible body from the changed-file classification and structured field inputs before opening the PR. - -When scaffold-related files change, the PR body must cover: - -- a non-edit assertion -- fail-fast input-contract proof -- generated-output viability proof - -When CLI-facing files change, the PR body must cover: - -- upgrade note -- deprecation/removal plan or issue -- docs/help/examples alignment -- release/changeset wording - -See [Release And Merge Readiness](./release-readiness.md) for the full field list and rationale. diff --git a/docs/guide/ztd-cli-sssql-authoring.md b/docs/guide/ztd-cli-sssql-authoring.md deleted file mode 100644 index 2e2e1fb8e..000000000 --- a/docs/guide/ztd-cli-sssql-authoring.md +++ /dev/null @@ -1,159 +0,0 @@ ---- -title: ztd-cli SSSQL Authoring -outline: deep ---- - -# ztd-cli SSSQL Authoring - -`ztd-cli` does not execute application SQL by itself, but it is part of the authoring loop that decides what kind of SQL gets saved under `src/sql/`. - -When the request is "add an optional filter" or "make this condition optional", prefer **SSSQL** before falling back to string-built SQL assembly outside the SQL file. - -## What this means in a ZTD project - -In a ZTD project, the authoring loop usually looks like this: - -1. Update the SQL asset under `src/sql/` -2. Regenerate or verify the QuerySpec with `ztd model-gen` -3. Run ZTD tests - -If the change is a routine optional predicate, the SQL asset should stay truthful: - -```sql -select - p.product_id, - p.product_name -from public.products p -where (:brand_name is null or p.brand_name = :brand_name) - and (:category_name is null or exists ( - select 1 - from public.product_categories pc - join public.categories c - on c.category_id = pc.category_id - where pc.product_id = p.product_id - and c.category_name = :category_name - )) -``` - -Then keep the runtime pruning intent explicit in application code: - -```ts -const query = builder.buildQuery(sql, { - optionalConditionParameters: { - brand_name: input.brandName ?? null, - category_name: input.categoryName ?? null, - }, -}); -``` - -When the optional branch is already authored, the CLI can help inspect or undo it: - -```bash -ztd query sssql list src/sql/products/list_products.sql -ztd query sssql remove src/sql/products/list_products.sql --parameter category_name --preview -``` - -If the current rewrite would drop existing SQL comments, the command should fail fast instead of silently writing a damaged file. - -## When to choose SSSQL first - -Reach for SSSQL first when the prompt sounds like: - -- "add an optional filter" -- "make `brand_name` optional" -- "support search-by-category when the value is present" -- "keep one SQL file instead of branching queries in code" - -These requests are usually about preserving a single truthful SQL asset, not about inventing a new SQL-construction layer. - -## What to avoid - -For routine optional predicates, avoid: - -- building `WHERE` fragments with string concatenation -- adding `WHERE 1 = 1` sentinels only to make later concatenation easier -- splitting one readable query into multiple near-duplicate query files -- hiding ordinary optional-filter logic in imperative branching before the SQL is even parsed - -## Where ztd-cli fits - -Use `ztd-cli` to keep the SQL-first loop tight: - -- `ztd model-gen --probe-mode ztd` validates the saved SQL asset against your DDL snapshot -- `ztd lint` checks structural issues in authored SQL files -- ZTD tests keep the spec, fixtures, and DDL snapshot aligned - -`ztd-cli` should help validate the authored SQL, not push the project toward non-SQL-first assembly for ordinary optional filters. - -## End-to-end authoring example - -Use a single SQL asset as the source of truth: - -```sql --- src/sql/products/list_products.sql -select - p.product_id, - p.product_name, - p.brand_name -from public.products p -where (:brand_name is null or p.brand_name = :brand_name) - and (:category_name is null or exists ( - select 1 - from public.product_categories pc - join public.categories c - on c.category_id = pc.category_id - where pc.product_id = p.product_id - and c.category_name = :category_name - )) -order by p.product_name -``` - -Then run the normal ZTD loop: - -```bash -ztd model-gen src/features/products/persistence/list_products.sql \ - --probe-mode ztd \ - --out src/features/products/persistence/list_products.spec.ts -ztd lint src/features/products/persistence/list_products.sql -vitest run -``` - -If the runtime layer uses `rawsql-ts`, keep the pruning contract explicit instead of inventing a second SQL assembly path: - -```ts -const query = builder.buildQuery(sql, { - optionalConditionParameters: { - brand_name: input.brandName ?? null, - category_name: input.categoryName ?? null, - }, -}); -``` - -This is the intended flow for prompts such as "add an optional filter to `src/sql/products/list_products.sql`" or "make category search optional without splitting the SQL file". - -## Agent prompt routing - -When an AI or scripted caller receives requests like these, the first response should stay on the SQL-first path: - -- "Add an optional filter to `src/sql/products/list_products.sql`." -- "Make `brand_name` optional without branching the repository method." -- "Support category filtering only when the value is present." - -A good response shape is: - -1. edit the SQL asset with truthful optional predicates -2. use `ztd query sssql scaffold`, `ztd query sssql list`, or `ztd query sssql remove --preview` when that shortens the authoring loop safely -3. rerun `ztd model-gen --probe-mode ztd` if the contract changed -4. rerun `ztd lint` and tests -5. wire `optionalConditionParameters` only for branch pruning - -Do not steer these requests toward string-built `WHERE` concatenation or redundant `LEFT JOIN` scaffolding plus later cleanup. -Do not suggest runtime optional predicate injection as if `DynamicQueryBuilder` still synthesizes new filter branches from `filter`. - -## Read next - -- [ztd-cli SSSQL Reference](./ztd-cli-sssql-reference.md) -- [What Is SSSQL?](./sssql-overview.md) -- [SSSQL Optional Branch Pruning MVP](./sssql-optional-branch-pruning.md) -- [Querybuilding Recipes](./querybuilding-recipes.md) -- [SSSQL Optional-Condition Dogfooding](../dogfooding/sssql-optional-condition.md) diff --git a/docs/guide/ztd-cli-sssql-reference.md b/docs/guide/ztd-cli-sssql-reference.md deleted file mode 100644 index b043eb41a..000000000 --- a/docs/guide/ztd-cli-sssql-reference.md +++ /dev/null @@ -1,304 +0,0 @@ ---- -title: ztd-cli SSSQL Reference -outline: deep ---- - -# ztd-cli SSSQL Reference - -This page is the single reference for the `ztd query sssql ...` command family and the matching runtime pruning contract in `rawsql-ts`. - -Use it when you need to answer questions like: - -- Which command adds an optional branch? -- How do I inspect authored branches before removing one? -- What is the difference between `scaffold`, `remove`, and `refresh`? -- Which operators and branch kinds are supported? - -For the conceptual introduction, read [What Is SSSQL?](./sssql-overview.md). -For authoring guidance, read [ztd-cli SSSQL Authoring](./ztd-cli-sssql-authoring.md). - -## Command Summary - -| Command | What it does | Typical use | -|---|---|---| -| `ztd query sssql list ` | Inspect supported authored SSSQL branches in a SQL file | Confirm what is present before `remove` or `refresh` | -| `ztd query sssql scaffold ...` | Add one supported optional branch | Add a scalar or `EXISTS` / `NOT EXISTS` optional condition | -| `ztd query sssql remove ...` | Remove one supported authored branch | Undo a scaffold or clean up an old optional condition | -| `ztd query sssql refresh ` | Re-anchor supported branches near the closest source query without changing predicate meaning | Re-run after query edits moved the best insertion point | - -All rewriting commands support `--preview`, which emits a unified diff instead of writing the file. - -## `list` - -Use `list` to inspect the branches the CLI can currently recognize and manage safely. - -```bash -ztd query sssql list src/sql/products/list_products.sql -ztd query sssql list src/sql/products/list_products.sql --format json -``` - -`list` is the safest first step before `remove`, and it is also useful for machine-readable automation. - -Supported branch kinds currently reported by `list`: - -- `scalar` -- `exists` -- `not-exists` -- `expression` - -The output includes branch metadata such as `parameterName`, `kind`, and, when available, operator or target details. - -### Sample text output - -```text -1. parameter: brand_name - kind: scalar - operator: = - target: p.brand_name - sql: (:brand_name is null or "p"."brand_name" = :brand_name) -2. parameter: category_name - kind: exists - sql: (:category_name is null or exists (select 1 from "product_categories" as "pc" where "pc"."product_id" = "p"."product_id" and "pc"."category_name" = :category_name)) -``` - -### Sample JSON output - -```json -{ - "command": "query sssql list", - "ok": true, - "data": { - "file": "src/sql/products/list_products.sql", - "branch_count": 2, - "branches": [ - { - "index": 1, - "parameterName": "brand_name", - "kind": "scalar", - "operator": "=", - "target": "p.brand_name", - "sql": "(:brand_name is null or \"p\".\"brand_name\" = :brand_name)" - }, - { - "index": 2, - "parameterName": "category_name", - "kind": "exists", - "operator": null, - "target": null, - "sql": "(:category_name is null or exists (...))" - } - ] - } -} -``` - -If you only need the recognized parameter names, extract `branches[].parameterName` from the JSON output. - -## `scaffold` - -Use `scaffold` to add one supported optional branch to the closest query scope that owns the target columns. - -### Scalar scaffold - -```bash -ztd query sssql scaffold src/sql/products/list_products.sql \ - --filter p.brand_name \ - --parameter brand_name \ - --operator = -``` - -Supported scalar operators: - -- `=` -- `<>` -- `!=` -- `<` -- `<=` -- `>` -- `>=` -- `like` -- `ilike` - -Notes: - -- `<>` is the normalized SQL form. -- `!=` is accepted as input and normalized to `<>`. -- `ilike` is a PostgreSQL-specific extension, not SQL standard. - -### `EXISTS` / `NOT EXISTS` scaffold - -Use structured scaffold input when the optional condition depends on a table that is not already filtered directly in the outer `FROM` graph. - -```bash -ztd query sssql scaffold src/sql/products/list_products.sql \ - --parameter category_name \ - --kind exists \ - --query-file tmp/category_exists.sql \ - --anchor-column p.product_id -``` - -```bash -ztd query sssql scaffold src/sql/products/list_products.sql \ - --parameter category_name \ - --kind not-exists \ - --query "select 1 from public.product_categories pc where pc.product_id = $c0 and pc.category_name = :category_name" \ - --anchor-column p.product_id -``` - -Structured `EXISTS` / `NOT EXISTS` contract: - -- Pass exactly one subquery via `--query` or `--query-file` -- Use `--kind exists` or `--kind not-exists` -- Provide one or more `--anchor-column` values -- Reference anchor columns inside the subquery as `$c0`, `$c1`, and so on -- Keep the subquery to one statement only - -The CLI rewrites `$c0`, `$c1`, and similar placeholders to the resolved outer column expressions before inserting the branch. - -### Preview mode - -Use `--preview` whenever you want to inspect the diff before writing: - -```bash -ztd query sssql scaffold src/sql/products/list_products.sql \ - --filter p.brand_name \ - --parameter brand_name \ - --operator ilike \ - --preview -``` - -### JSON mode - -Automation can use `--json` instead of many flags: - -```bash -ztd query sssql scaffold src/sql/products/list_products.sql --json '{ - "parameter": "category_name", - "kind": "exists", - "query": "select 1 from public.product_categories pc where pc.product_id = $c0 and pc.category_name = :category_name", - "anchorColumns": ["p.product_id"] -}' -``` - -## `remove` - -Use `remove` to delete one supported optional branch safely. - -```bash -ztd query sssql remove src/sql/products/list_products.sql --parameter category_name -ztd query sssql remove src/sql/products/list_products.sql --parameter category_name --preview -``` - -### Required input - -`remove` requires either: - -- `` and `--parameter ` -- `` and `--all` - -Primary identity is `--parameter`. -When one parameter could match more than one branch, narrow the target with one or more of: - -- `--kind` -- `--operator` -- `--target` - -Example: - -```bash -ztd query sssql remove src/sql/products/list_products.sql \ - --parameter brand_name \ - --kind scalar \ - --operator <> -``` - -### What `remove` can remove - -`remove` removes one branch that the CLI can already recognize through `list`. - -- If `list` shows the branch, `remove` can target it -- If `list` does not show the branch, `remove` does not manage it safely - -`remove` is idempotent. -If the matching branch is already absent, the command becomes a no-op instead of damaging the query. - -### Remove all branches at once - -Use `--all` to remove every recognized SSSQL branch in the query: - -```bash -ztd query sssql remove src/sql/products/list_products.sql --all -ztd query sssql remove src/sql/products/list_products.sql --all --preview -``` - -Rules for `--all`: - -- it removes every branch that `list` can recognize -- it is idempotent -- use it by itself -- do not combine it with `--parameter`, `--kind`, `--operator`, or `--target` - -This keeps bulk removal explicit and avoids accidental over-broad deletes from a partially specified targeted remove command. - -## `refresh` - -Use `refresh` after query edits changed the closest correct query scope for an authored SSSQL branch. - -```bash -ztd query sssql refresh src/sql/products/list_products.sql -ztd query sssql refresh src/sql/products/list_products.sql --preview -``` - -`refresh` does not invent new optional predicates. -It only repositions supported authored branches so they stay attached to the best matching query block after query structure changes. - -`refresh` also supports correlated `EXISTS` / `NOT EXISTS` branches. -When a branch is still attached to an outer query but the anchor column now belongs in an inner query or CTE, `refresh` can move the whole branch and rebase the correlated alias safely. - -Correlated `EXISTS` / `NOT EXISTS` refresh rules: - -- infer one anchor candidate from the correlated outer reference -- fail fast when zero anchor candidates are found -- fail fast when multiple anchor candidates are found -- keep scalar branch behavior unchanged - -## Safety Rules - -These commands are intentionally strict. - -- Repeated scaffold with the same semantic input is idempotent and does not duplicate the branch -- Repeated remove is idempotent and becomes a no-op when the branch is already gone -- Ambiguous remove or unsafe scaffold input fails fast -- `EXISTS` / `NOT EXISTS` scaffold rejects empty SQL, semicolons, multiple statements, and `LATERAL` -- If a rewrite would drop existing SQL comments, the command fails instead of silently writing a damaged file - -The goal is to prefer an explicit error over a quietly corrupted SQL file. - -## Runtime API - -The CLI authors the optional branch into the SQL file. -Runtime behavior is still controlled explicitly by `optionalConditionParameters`. - -```ts -const query = builder.buildQuery(sql, { - optionalConditionParameters: { - brand_name: input.brandName ?? null, - category_name: input.categoryName ?? null, - }, -}); -``` - -Pruning rules: - -- present value: keep the branch -- `null` or `undefined`: prune the optional branch -- omit `optionalConditionParameters`: do not run pruning - -This means the CLI is for authoring, while runtime pruning is still an explicit application-level choice. - -## Choosing The Right Document - -- Concept and tradeoffs: [What Is SSSQL?](./sssql-overview.md) -- SQL-first authoring workflow: [ztd-cli SSSQL Authoring](./ztd-cli-sssql-authoring.md) -- Runtime pruning rules: [SSSQL Optional Branch Pruning MVP](./sssql-optional-branch-pruning.md) -- Dynamic-vs-SSSQL decision: [Dynamic Filter Routing](./dynamic-filter-routing.md) diff --git a/docs/guide/ztd-cli-telemetry-export-modes.md b/docs/guide/ztd-cli-telemetry-export-modes.md deleted file mode 100644 index 3ad1bfd36..000000000 --- a/docs/guide/ztd-cli-telemetry-export-modes.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: ztd-cli Telemetry Export Modes ---- - -# ztd-cli Telemetry Export Modes - -`ztd-cli` keeps telemetry opt-in, but once enabled it can export spans in formats that work for local debugging, CI artifacts, and OTLP-compatible collectors. - -## Modes - -| Mode | Use case | Output | -|------|----------|--------| -| `console` | Default local inspection | JSONL envelopes on `stderr` | -| `debug` | Human-readable local debugging | formatted text lines on `stderr` | -| `file` | CI artifacts or post-run summaries | JSONL file | -| `otlp` | Jaeger / collector inspection | OTLP/HTTP traces | - -## CLI examples - -### Default console export - -```bash -ztd --telemetry query uses table public.users -``` - -### Human-readable local debug output - -```bash -ztd --telemetry --telemetry-export debug query uses table public.users -``` - -### CI-friendly JSONL artifact - -```bash -ztd --telemetry --telemetry-export file --telemetry-file tmp/telemetry/ztd-cli.telemetry.jsonl query uses table public.users -``` - -Archive `tmp/telemetry/ztd-cli.telemetry.jsonl` with the CI system's normal artifact upload step. - -### OTLP/HTTP export for Jaeger or a collector - -```bash -ztd --telemetry --telemetry-export otlp --telemetry-endpoint http://127.0.0.1:4318/v1/traces query uses table public.users -``` - -If `--telemetry-endpoint` is omitted, `ztd-cli` defaults to `http://127.0.0.1:4318/v1/traces`. - -## Environment variables - -| Variable | Purpose | -|----------|---------| -| `ZTD_CLI_TELEMETRY` | Enable telemetry when set to a truthy value | -| `ZTD_CLI_TELEMETRY_EXPORT` | Select `console`, `debug`, `file`, or `otlp` | -| `ZTD_CLI_TELEMETRY_FILE` | Override the JSONL artifact path for `file` mode | -| `ZTD_CLI_TELEMETRY_OTLP_ENDPOINT` | Override the OTLP/HTTP traces endpoint | - -## Design notes - -- Telemetry stays no-op unless explicitly enabled. -- `console` and `debug` keep local inspection simple without requiring a backend. -- `file` mode exists so CI can archive telemetry output without standing up a collector. -- `otlp` mode is intentionally minimal and collector-friendly, so local Jaeger / OpenTelemetry setups can inspect the same spans. -- Redaction and truncation rules from the telemetry policy still apply in every mode. diff --git a/docs/guide/ztd-cli-telemetry-philosophy.md b/docs/guide/ztd-cli-telemetry-philosophy.md deleted file mode 100644 index d5cc1b08b..000000000 --- a/docs/guide/ztd-cli-telemetry-philosophy.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: ztd-cli Telemetry Philosophy ---- - -# ztd-cli Telemetry Philosophy - -`ztd-cli` telemetry exists to help maintainers and advanced users inspect command behavior during investigation, dogfooding, and performance work. It is intentionally **not** part of the default happy path. - -If you are looking for the starter repository telemetry setup and concrete logging flow, read [Repository Telemetry Setup](./repository-telemetry-setup.md). - -## Why telemetry exists - -Telemetry is useful when you need to answer questions like these: - -- Which command phase is slow or unexpectedly failing? -- Which fallback or output-selection path was taken? -- How does a local dogfooding build behave compared with a published package? -- Can we inspect command traces in a collector without adding a mandatory backend dependency? - -That is the scope. `ztd-cli` telemetry is for investigation and optimization, not for everyday usage requirements. - -## When to enable it - -Enable telemetry when you are: - -- Investigating a command failure or performance regression -- Dogfooding new CLI behavior before release -- Capturing CI artifacts for command-level trace review -- Pointing a local collector or Jaeger instance at OTLP output for short-lived inspection -- Verifying SQL recovery and perf loops such as `perf run` plus `perf report diff` during tuning dogfooding - -Leave it off for normal published-package usage, happy-path setup, and standard project scaffolding. - -## Why it is disabled by default - -Telemetry stays opt-in because `ztd-cli` is a published CLI first. - -- The default flow should not require a backend, collector, or trace viewer. -- Normal users should not have to think about exporters just to scaffold or regenerate files. -- Published-package consumers must be able to ignore telemetry completely. -- Optional embedding or exporter integration must not become a hidden runtime requirement. - -This is especially important for an AI-first operating model: structured traces are valuable during investigation, but they should remain an explicit tool rather than ambient background reporting. - -## Non-goals - -Telemetry is **not** intended to become: - -- An always-on production reporting channel -- A mandatory dependency for published-package consumers -- A replacement for normal CLI output, docs, or error messages -- A blanket raw-log export path -- A full logs-and-metrics framework by default - -Logs and metrics are intentionally deferred unless there is a concrete justification that fits the same safety and opt-in posture. - -## Security and privacy caveats - -Telemetry must preserve the same narrow boundary in every export mode. - -- SQL text, bind values, credentials, DSNs, and filesystem dumps must not be exported. -- Large or highly variable payloads should be truncated instead of emitted verbatim. -- Structured spans and decision events are preferred over raw logs because they are easier to sanitize and reason about. -- If a new telemetry signal cannot be made low-cardinality and safe, it should not be added. - -See [ztd-cli telemetry policy](./ztd-cli-telemetry-policy.md) for the concrete schema and redaction rules, and [ztd-cli telemetry export modes](./ztd-cli-telemetry-export-modes.md) for local, CI, and OTLP usage. diff --git a/docs/guide/ztd-cli-telemetry-policy.md b/docs/guide/ztd-cli-telemetry-policy.md deleted file mode 100644 index 063ac67e1..000000000 --- a/docs/guide/ztd-cli-telemetry-policy.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: ztd-cli Telemetry Policy ---- - -# ztd-cli Telemetry Policy - -`ztd-cli` telemetry is intentionally narrow. The sink is useful only when it captures stable decision points and phase boundaries without leaking SQL text, DSNs, credentials, or other high-cardinality payloads. - -## Decision-event schema - -Decision events use a fixed schema. Adding a new event means updating the source schema in `packages/ztd-cli/src/utils/telemetry.ts`, documenting it here, and deciding which low-cardinality attributes are safe to export. - -| Event name | Category | When it fires | Allowed attributes | -|------------|----------|---------------|--------------------| -| `command.selected` | command lifecycle | A CLI command path is selected and root telemetry starts | `command` | -| `command.completed` | command lifecycle | A CLI command path completes successfully | `command` | -| `model-gen.probe-mode` | probe mode selected | `model-gen` decides between `live` and `ztd` probe mode | `probeMode` | -| `watch.invalid-with-dry-run` | invalid option guard | `ztd-config` rejects `--watch` with `--dry-run` | none | -| `command.options.resolved` | fallback activated / config planning | `ztd-config` resolves the option state that affects generation flow | `dryRun`, `watch`, `quiet`, `shouldUpdateConfig`, `jsonPayload` | -| `config.updated` | config mutation | `ztd-config` persists ddl-related config overrides | none | -| `output.json-envelope` | output mode selected | A command emits a machine-readable JSON envelope | none | -| `watch.enabled` | watch activation | `ztd-config` enters watch mode after the first successful generation | none | -| `output.dry-run-diagnostic` | output truncation / guidance selection | Dry-run guidance is emitted instead of writing files | none | -| `output.next-steps-diagnostic` | guidance emission | Interactive next-step guidance is emitted | none | -| `output.quiet-suppressed` | generated output suppressed | Quiet mode suppresses follow-up guidance | none | - -## Reserved event categories - -The current implementation already emits the events above and reserves the following categories for future additions when they become useful enough to keep stable: - -- `fallback activated` -- `generated files excluded` -- `probe mode selected` -- `ambiguous resolution detected` -- `parse recovery applied` -- `output truncation applied` - -Future events in those categories should follow the same rules: low-cardinality names, explicit allowed attributes, and source plus docs updates in the same change. - -## Redaction policy - -The telemetry sink sanitizes span attributes, decision-event attributes, and exception payloads before writing JSON lines to `stderr`. - -### Never export - -- Raw SQL text -- Bind values -- Credentials, secrets, tokens, or auth material -- DSNs / connection URLs -- Full filesystem dumps or other multi-line directory snapshots -- Highly variable large payloads -- Exception stack traces - -### Allowed to export - -- Stable command names -- Boolean toggles such as `dryRun` or `watch` -- Small numeric counters such as `paramCount` or `directoryCount` -- Stable mode names such as `probeMode` -- Project-scoped file paths when they are not secrets and not bulk dumps - -### Sanitization behavior - -- Decision events drop attributes that are not listed in the schema. -- Sensitive keys such as `sqlText`, `databaseUrl`, `bindValues`, or `password` are replaced with `[REDACTED]`. -- Strings that look like SQL text, DSNs, or secret-bearing key-value pairs are replaced with `[REDACTED]` even if the key is generic. -- Large or multi-line strings are replaced with `[TRUNCATED:]` to avoid high-cardinality exports. -- Exceptions keep a sanitized `name` and `message`, but do not export stacks. - -## Testing expectation - -Telemetry changes should ship with tests that capture stderr payloads directly and verify both of these constraints: - -1. Expected decision events and phase spans are still emitted. -2. Sensitive fields are absent or redacted in the exported JSON. diff --git a/docs/guide/ztd-cli-traditional-lane-followup-plan.md b/docs/guide/ztd-cli-traditional-lane-followup-plan.md deleted file mode 100644 index 2488e1ef0..000000000 --- a/docs/guide/ztd-cli-traditional-lane-followup-plan.md +++ /dev/null @@ -1,211 +0,0 @@ -# ztd-cli Traditional Lane Follow-up Plan - -This document defines the follow-up expansion plan for exposing `ztd | traditional` test lanes in `ztd-cli`. -It is intentionally scoped as a design/plan artifact, not an implementation commit. - -## Status - -- Current status: `done` (planning artifact for Issue #767) -- Implementation status: `partial` (CLI lane-aware scaffold is shipped; library adapter wiring remains follow-up) - -## Objective - -Enable `ztd-cli` users to choose a test lane (`ztd` or `traditional`) per query/test intent without moving mode implementation responsibility into CLI. - -## User Value - -- Keep fast contract validation in ZTD lane. -- Use traditional lane for migration/seeding/index/physical-state verification. -- Select the right lane per query or test file based on purpose. -- Make the selected lane traceable from scaffold layout and test evidence. - -## Non-Goal - -- Do not re-implement mode runtime behavior in CLI. -- Do not make this issue a delivery gate for starter acceptance, `mode=ztd` evidence rollout, or SQL trace rollout. - -## Current Baseline (Observed in `ztd-cli`) - -- `feature tests scaffold` currently emits ZTD-only entrypoints and metadata: - - `.boundary.ztd.test.ts` - - `generated/TEST_PLAN.md` with `testKind: ztd` - - `generated/analysis.json` with `testKind: ztd` -- Existing behavior and generated assets are tested as ZTD-first contracts. - -## CLI Surface Proposal - -### Proposed Option - -Add a `--test-kind` selector to `feature tests scaffold`. - -```bash -ztd feature tests scaffold --feature --test-kind ztd -ztd feature tests scaffold --feature --test-kind traditional -``` - -- Allowed values: `ztd | traditional` -- Default: `ztd` (for backward compatibility and onboarding simplicity) -- Keep existing invocations valid when `--test-kind` is omitted. - -### Optional Future Extension - -If multi-lane generation in one run becomes useful: - -```bash -ztd feature tests scaffold --feature --test-kind both -``` - -`both` is explicitly out of initial scope to keep behavior predictable. - -## Coexisting Scaffold Layout - -Two layout candidates were compared. - -### Candidate A: File Suffix (Recommended) - -```text -src/features//queries//tests/ - .boundary.ztd.test.ts - .boundary.traditional.test.ts - cases/ - generated/ - TEST_PLAN.md - TEST_PLAN.traditional.md - analysis.json - analysis.traditional.json -``` - -Benefits: - -- Preserves current directory shape. -- Easy per-file lane selection in editor/CI. -- Lower migration cost from existing ZTD-only scaffold. - -Costs: - -- More files in one directory. -- Requires lane-aware naming discipline. - -### Candidate B: Lane Directories - -```text -src/features//queries//tests/ - ztd/ - .boundary.test.ts - cases/ - generated/ - traditional/ - .boundary.test.ts - cases/ - generated/ -``` - -Benefits: - -- Strong physical separation by lane. -- Clear ownership per lane. - -Costs: - -- Larger scaffold diff footprint. -- Higher risk of duplicated helper paths and import churn. -- Harder backward compatibility with current single-lane structure. - -### Decision - -Start with Candidate A (suffix strategy). Revisit directory strategy only if suffix-based growth becomes unmanageable. - -## Responsibility Boundary (CLI vs Library) - -- Library owns execution mode semantics (`ztd | traditional` runtime behavior). -- CLI owns: - - lane selection UX (`--test-kind`) - - lane-specific scaffold wiring (entrypoints, generated plan/analysis files) - - evidence visibility in scaffold artifacts -- Generated helpers for both lanes must stay thin adapters that call library mode APIs. -- CLI must not re-implement migration/seeding/cleanup internals. - -## Mode Switching Granularity - -Recommended policy: - -- Primary switching unit: test file. -- Practical organization unit: query boundary. -- Optional coordination unit: feature-level conventions (documented, not enforced). - -Rationale: - -- Test-file switching allows mixed intent within one query boundary. -- Query-level default still stays readable in filesystem layout. - -## Traditional Lane Adapter Policy - -- Traditional lane entrypoints must call the same library-facing boundary contract style as ZTD lane. -- Only lane-specific wiring changes are allowed (e.g., runner selection and evidence tags). -- No independent lifecycle stack in generated CLI helpers. - -## Starter UX Decision Points - -Initial recommendation: - -- Starter default remains ZTD-first. -- Mention traditional lane existence in docs/help. -- Do not include full traditional sample by default in starter scaffold. - -Decision criteria for exposing traditional by default later: - -- onboarding complexity delta -- maintenance overhead of dual-lane starter artifacts -- support request frequency for physical-state verification - -## Evidence Contract Alignment - -Lane coexistence should preserve machine-readable evidence alignment. - -- ZTD lane keeps current expectations (`mode=ztd`, `physicalSetupUsed=false`). -- Traditional lane must emit its own lane-consistent evidence contract and clear metadata separation. -- Generated artifacts should be lane-qualified so CI/reporting can summarize by lane without ambiguity. - -## Acceptance Criteria for Follow-up Implementation - -- CLI surface explicitly documents and supports `--test-kind ztd|traditional` with default `ztd`. -- Coexisting scaffold strategy is implemented with explicit trade-off rationale preserved. -- CLI-library responsibility boundary remains intact. -- Traditional generated helper path stays a thin library-mode adapter. -- Mode switching policy at query/test-file level is documented and testable. -- Starter UX decision is documented with explicit criteria. - -## Verification Plan (Prototype/Implementation Phase) - -- Unit tests for argument parsing and defaults (`--test-kind` behavior). -- Snapshot/fixture tests for generated file names and lane-qualified artifacts. -- Regression tests proving omitted `--test-kind` matches current ZTD behavior. -- Contract tests that generated helpers call library mode APIs rather than local lifecycle logic. -- Evidence-schema tests verifying lane-disambiguated output. - -## Scope In - -- CLI surface design for traditional test kind -- Coexisting lane scaffold design -- Query/test-file mode switching policy -- Starter exposure decision framework - -## Scope Out - -- Immediate starter acceptance gate implementation -- Immediate `mode=ztd` evidence rollout changes -- Immediate opt-in SQL trace rollout changes -- Immediate first-pass mode delegation restructuring - -## Risks - -- UX expansion before boundary hardening may reintroduce CLI responsibility creep. -- Coexisting lane files can increase scaffold complexity if naming rules are weak. -- Overexposing traditional lane too early may degrade first-run onboarding. - -## Open Questions - -- Should feature-level defaults be configurable, or only test-file explicit? -- Is suffix naming enough for long-lived repos, or will directory lanes become necessary? -- Where should lane summary live first: CLI output, JSON artifact, or generated docs? -- What is the minimum traditional evidence contract needed for release confidence? diff --git a/docs/guide/ztd-config-top-level-settings.md b/docs/guide/ztd-config-top-level-settings.md deleted file mode 100644 index bce3dfabb..000000000 --- a/docs/guide/ztd-config-top-level-settings.md +++ /dev/null @@ -1,19 +0,0 @@ -# ztd.config.json Top-Level Settings - -`ztd.config.json` keeps schema resolution at the top level: - -```json -{ - "dialect": "postgres", - "ztdRootDir": ".ztd", - "ddlDir": "db/ddl", - "testsDir": ".ztd/tests", - "defaultSchema": "public", - "searchPath": ["public"], - "ddlLint": "strict" -} -``` - -`ddl.defaultSchema` and `ddl.searchPath` are no longer read. - -Use this reference when you want to confirm where the generated config keeps schema resolution and which fields are still honored by `ztd-cli`. diff --git a/docs/guide/ztd-local-source-dogfooding.md b/docs/guide/ztd-local-source-dogfooding.md deleted file mode 100644 index c934dc650..000000000 --- a/docs/guide/ztd-local-source-dogfooding.md +++ /dev/null @@ -1,53 +0,0 @@ -# Local-Source Dogfooding - -Use this guide when you dogfood `ztd-cli` from a throwaway project under `tmp/` while pointing the generated workspace's direct `rawsql-ts` scaffold dependencies at local source instead of published npm packages. - -This guide describes the developer-mode happy path. Use it when you need to validate unpublished changes before release. It is intentionally different from the published-package happy path used by normal npm consumers. - -## Recommended shape - -1. Create the throwaway app under `tmp/` so it stays outside normal git tracking. -2. Scaffold with `ztd init --local-source-root ` so the first install links local-source dependencies instead of waiting for npm publication. -3. Keep your DDL under `db/ddl/*.sql` and prefer `ztd model-gen --probe-mode ztd` during the inner loop. -4. Generated query boundaries should not need local `sql-contract` shims on the runtime-free path. - -Example: - -```bash -mkdir tmp/my-ztd-dogfood && cd tmp/my-ztd-dogfood -npx ztd init --workflow empty --local-source-root ../../.. -pnpm install --ignore-workspace -ztd model-gen src/features/users/queries/list-users/list-users.sql \ - --probe-mode ztd -pnpm typecheck -pnpm test -``` - -The local-source profile rewrites `test` and `typecheck` through a guard script. If the scaffold is still resolving tools from a parent workspace, the guard prints the exact recovery commands instead of failing with a generic module-resolution error. - -Use this mode to answer: `can we dogfood the unreleased CLI from source?` Do not use it to claim that the published npm consumer flow is already healthy, because that must still be validated against released packages. For the pre-release packaging check, use [Published-Package Verification Before Release](./published-package-verification.md). - -## pnpm workspace guard - -If the throwaway project lives under another `pnpm-workspace.yaml` (for example `tmp/` inside this monorepo), plain `pnpm install` can be absorbed by the parent workspace. - -Use: - -```bash -pnpm install --ignore-workspace -``` - -`ztd init` now adds the same flag automatically for its own install step when it detects a parent pnpm workspace. - -## Common failure modes - -- The local-source flow succeeds, but the published-package flow is still blocked. - - Treat that as a release/distribution problem, not as a failure of developer-mode dogfooding. -- `pnpm install` links the app into a parent workspace unexpectedly. - - Re-run with `pnpm install --ignore-workspace`. -- `model-gen --probe-mode ztd` says the DDL directory is missing. - - Run the command from the project root that contains `ztd.config.json`, or pass `--ddl-dir`. -- `model-gen --probe-mode live` succeeds but `--probe-mode ztd` fails. - - Local DDL is not yet the source of truth for that query shape; either update `db/ddl/*.sql` or treat it as a live-schema concern. - - diff --git a/docs/index.md b/docs/index.md index b3303f891..3689b9320 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,9 +14,6 @@ hero: - theme: alt text: Transfer Docs link: /transfer-docs - - theme: alt - text: ztd-cli Docs - link: /ztd-cli-docs - theme: alt text: Open Playground link: /cud-demo/index.html @@ -29,8 +26,8 @@ features: details: 3-8x faster than major SQL libraries while supporting complex PostgreSQL queries. - title: Transform Existing SQL details: Parse raw SQL into AST, apply dynamic filters and structural transformations, then regenerate optimized queries. - - title: Runtime-free ztd-cli Scaffolds - details: Generate SQL-first boundaries with thin executors and AOT row mappers, without runtime mapper or validator libraries in the standard path. + - title: Runtime-free Testkits + details: Validate SQL with fixture-backed testkits while keeping production code free of test-time rewriting dependencies. - title: Browser & Playground Ready details: Run in the browser via CDN and experiment with live formatting and analysis tools. --- diff --git a/docs/public/demo/vendor/rawsql.browser.js b/docs/public/demo/vendor/rawsql.browser.js index a0fc9c1f9..952c12460 100644 --- a/docs/public/demo/vendor/rawsql.browser.js +++ b/docs/public/demo/vendor/rawsql.browser.js @@ -3,14 +3,14 @@ ${caret}`}static skipWhiteSpace(input,position){let length=input.length;for(;pos `),processedLines=[];for(let rawLine of rawLines){let trimmedLine=rawLine.trim(),isSeparatorLine=/^\s*[-=_+*#]+\s*$/.test(rawLine);trimmedLine!==""||isSeparatorLine?processedLines.push(isSeparatorLine?rawLine.trim():trimmedLine):processedLines.push("")}for(;processedLines.length>0&&processedLines[0]==="";)processedLines.shift();for(;processedLines.length>0&&processedLines[processedLines.length-1]==="";)processedLines.pop();return processedLines}static readWhiteSpaceAndComment(input,position){let lines=null,length=input.length;for(;position0&&(lines===null&&(lines=[]),lines.push(...processedLines)),closed=!0;break}position++}if(!closed){let processedLines=this.processBlockCommentContent(input.slice(contentStart));processedLines.length>0&&(lines===null&&(lines=[]),lines.push(...processedLines)),position=length}continue}break}return{position,lines}}static readRegularIdentifier(input,position){let result=this.tryReadRegularIdentifier(input,position);if(!result)throw new Error(`Unexpected character. position: ${position} ${_StringUtils.getDebugPositionInfo(input,position)}`);return result}static tryReadRegularIdentifier(input,position){let start=position,length=input.length;for(;position=this.input.length}canRead(shift=0){return!this.isEndOfInput(shift)}read(expectChar){if(this.isEndOfInput())throw new Error(`Unexpected character. expect: ${expectChar}, actual: EndOfInput, position: ${this.position}`);let char=this.input[this.position];if(char!==expectChar)throw new Error(`Unexpected character. expect: ${expectChar}, actual: ${char}, position: ${this.position}`);return this.position++,char}createLexeme(type,value,comments=null,startPosition,endPosition){let lexeme={type,value:type===128||type===2||type===2048?value.toLowerCase():value,comments};return startPosition!==void 0&&endPosition!==void 0&&(lexeme.position={startPosition,endPosition}),lexeme}createLexemeWithPosition(type,value,startPos,comments=null){return this.createLexeme(type,value,comments,startPos,startPos+value.length)}getDebugPositionInfo(errPosition){return StringUtils.getDebugPositionInfo(this.input,errPosition)}};var KeywordParser=class{constructor(trie5){this.trie=trie5}isEndOfInput(input,position,shift=0){return position+shift>=input.length}canParse(input,position,shift=0){return!this.isEndOfInput(input,position,shift)}parse(input,position){if(this.isEndOfInput(input,position))return null;this.trie.reset();let result=StringUtils.tryReadRegularIdentifier(input,position);if(result===null)return null;let matchResult=this.trie.pushLexeme(result.identifier.toLowerCase());if(matchResult===0)return null;if(matchResult===3)return{keyword:result.identifier,newPosition:result.newPosition};let lexeme=result.identifier,commentResult=StringUtils.readWhiteSpaceAndComment(input,result.newPosition);position=commentResult.position;let collectedComments=commentResult.lines?[...commentResult.lines]:[];if(this.isEndOfInput(input,position))return matchResult===2?{keyword:lexeme,newPosition:position,comments:collectedComments.length>0?collectedComments:void 0}:null;for(;this.canParse(input,position);){let previousMatchResult=matchResult,result2=StringUtils.tryReadRegularIdentifier(input,position);if(result2!==null){if(matchResult=this.trie.pushLexeme(result2.identifier.toLowerCase()),matchResult===0){if(previousMatchResult===2)break;return null}lexeme+=" "+result2.identifier;let nextCommentResult=StringUtils.readWhiteSpaceAndComment(input,result2.newPosition);if(position=nextCommentResult.position,nextCommentResult.lines&&nextCommentResult.lines.length>0&&collectedComments.push(...nextCommentResult.lines),matchResult===3)break}else{if(previousMatchResult===2)break;return null}}return{keyword:lexeme,newPosition:position,comments:collectedComments.length>0?collectedComments:void 0}}};var KeywordTrie=class{constructor(keywords2){this.root=this.createNode();for(let i=0;i0&&(lexeme.positionedComments=[{position:"after",comments:keyword.comments}]),lexeme}if(this.canRead(2)&&this.input[this.position]==="/"&&this.input[this.position+1]==="*"&&this.input[this.position+2]==="+"){this.position+=3;let start=this.position;for(;this.position+1=this.input.length)return!1;let content=this.input.slice(start,pos).trim();return content===""?!1:/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$/.test(content)}readEscapedIdentifier(delimiter){let start=this.position;this.position++;let foundClosing=!1;for(;this.canRead();){if(this.input[this.position]===delimiter){foundClosing=!0;break}this.position++}if(!foundClosing)throw new Error(`Closing delimiter is not found. position: ${start}, delimiter: ${delimiter} ${this.getDebugPositionInfo(start)}`);if(start===this.position)throw new Error(`Closing delimiter is not found. position: ${start}, delimiter: ${delimiter} -${this.getDebugPositionInfo(start)}`);return this.position++,this.input.slice(start+1,this.position-1)}};var trie=new KeywordTrie([["grouping","sets"],["array"]]),keywordParser2=new KeywordParser(trie),FunctionTokenReader=class extends BaseTokenReader{tryRead(previous){if(this.isEndOfInput())return null;let keyword=keywordParser2.parse(this.input,this.position);if(keyword!==null)return this.position=keyword.newPosition,this.createLexeme(2048,keyword.keyword);let result=StringUtils.tryReadRegularIdentifier(this.input,this.position);if(!result)return null;this.position=result.newPosition;var shift=StringUtils.readWhiteSpaceAndComment(this.input,this.position).position-this.position;return this.canRead(shift)&&this.input[this.position+shift]==="("?this.createLexeme(2048,result.identifier):null}};var IdentifierTokenReader=class extends BaseTokenReader{tryRead(previous){if(this.isEndOfInput())return null;let char=this.input[this.position];if(char==="*")return this.position++,this.createLexeme(64,char);let result=StringUtils.readRegularIdentifier(this.input,this.position);return this.position=result.newPosition,this.createLexeme(64,result.identifier)}};function looksLikeSqlServerMoneyLiteral(input,position){let length=input.length;if(position<0||position+1>=length||input[position]!=="$"||!CharLookupTable.isDigit(input[position+1]))return!1;let pos=position+1;for(;pos=length||!CharLookupTable.isDigit(input[groupStart]))break;let groupEnd=groupStart+3;for(let index=groupStart;index=length||!CharLookupTable.isDigit(input[index]))return!1;sawThousandsGroup=!0,pos=groupEnd}if(!sawThousandsGroup)return!1;if(pos=length||!CharLookupTable.isDigit(input[decimalStart]))return!1;let decimalPos=decimalStart;for(;decimalPos=length||input[position]!=="$"||!CharLookupTable.isDigit(input[position+1]))return!1;let pos=position+1;for(;pos=length||!CharLookupTable.isDigit(input[groupStart]))break;let groupEnd=groupStart+3;for(let index=groupStart;index=length||!CharLookupTable.isDigit(input[index]))return!1;sawThousandsGroup=!0,pos=groupEnd}if(!sawThousandsGroup)return!1;if(pos=length||!CharLookupTable.isDigit(input[decimalStart]))return!1;let decimalPos=decimalStart;for(;decimalPos[keyword]),["unbounded"],["normalized"],["nfc","normalized"],["nfd","normalized"],["nfkc","normalized"],["nfkd","normalized"],["nfc"],["nfd"],["nfkc"],["nfkd"]],trie2=new KeywordTrie(keywords),literalKeywordParser=new KeywordParser(trie2),LiteralTokenReader=class extends BaseTokenReader{tryRead(previous){if(this.isEndOfInput())return null;let char=this.input[this.position];if(char==="'"){let value=this.readSingleQuotedString();return this.createLexeme(1,value)}let keyword=this.tryReadKeyword();if(keyword)return keyword;if(char==="."&&this.canRead(1)&&CharLookupTable.isDigit(this.input[this.position+1]))return this.createLexeme(1,this.readDigit());if(CharLookupTable.isDigit(char))return this.createLexeme(1,this.readDigit());if(char==="$"&&this.isDollarQuotedString())return this.createLexeme(1,this.readDollarQuotedString());if(char==="$"&&looksLikeSqlServerMoneyLiteral(this.input,this.position)){this.position++;let numberPart=this.readMoneyDigit();return this.createLexeme(1,"$"+numberPart)}if((char==="+"||char==="-")&&this.determineSignOrOperator(previous)==="sign"){let sign=char;this.position++;let pos=this.position;for(;this.canRead()&&CharLookupTable.isWhitespace(this.input[this.position]);)this.position++;if(this.canRead()&&(CharLookupTable.isDigit(this.input[this.position])||this.input[this.position]==="."&&this.canRead(1)&&CharLookupTable.isDigit(this.input[this.position+1])))return this.createLexeme(1,sign==="-"?sign+this.readDigit():this.readDigit());this.position=pos-1}return null}tryReadKeyword(){let result=literalKeywordParser.parse(this.input,this.position);return result?(this.position=result.newPosition,this.createLexeme(1,result.keyword)):null}determineSignOrOperator(previous){if(previous===null)return"sign";let operatorContextFlags=9545;return(previous.type&operatorContextFlags)!==0?"operator":"sign"}readDigit(){let start=this.position,hasDot=!1,hasExponent=!1;if(this.canRead(1)&&this.input[this.position]==="0"&&"xbo".includes(this.input[this.position+1].toLowerCase())){let prefixType=this.input[this.position+1].toLowerCase();this.position+=2;let isHex=prefixType==="x";for(;this.canRead();){let c=this.input[this.position];if(CharLookupTable.isDigit(c)||isHex&&CharLookupTable.isHexChar(c))this.position++;else break}return this.input.slice(start,this.position)}for(this.input[start]==="."&&(hasDot=!0,this.position++);this.canRead();){let char=this.input[this.position];if(char==="."&&!hasDot)hasDot=!0;else if((char==="e"||char==="E")&&!hasExponent)hasExponent=!0,this.canRead(1)&&(this.input[this.position+1]==="+"||this.input[this.position+1]==="-")&&this.position++;else if(!CharLookupTable.isDigit(char))break;this.position++}if(start===this.position)throw new Error(`Unexpected character. position: ${start} ${this.getDebugPositionInfo(start)}`);return this.input[start]==="."?"0"+this.input.slice(start,this.position):this.input.slice(start,this.position)}readMoneyDigit(){let start=this.position,hasDot=!1;for(;this.canRead();){let char=this.input[this.position];if(char==="."&&!hasDot)hasDot=!0;else if(!(char===","&&!hasDot)){if(!CharLookupTable.isDigit(char))break}this.position++}if(start===this.position)throw new Error(`Unexpected character. position: ${start} ${this.getDebugPositionInfo(start)}`);return this.input.slice(start,this.position)}readSingleQuotedString(){let start=this.position,closed=!1;for(this.read("'");this.canRead();){let char=this.input[this.position];if(this.position++,char==="\\"&&this.canRead(1)){this.position++;continue}else if(char==="'"){if(this.canRead()&&this.input[this.position]==="'"){this.position++;continue}closed=!0;break}}if(closed===!1)throw new Error(`Single quote is not closed. position: ${start} ${this.getDebugPositionInfo(start)}`);return this.input.slice(start,this.position)}isDollarQuotedString(){if(!this.canRead(1))return!1;if(this.input[this.position+1]==="$")return!0;let pos=this.position+1;for(;pos=48&&code<=57||code>=65&&code<=90||code>=97&&code<=122}};var trie3=new KeywordTrie([["and"],["or"],["is"],["is","not"],["is","distinct","from"],["is","not","distinct","from"],["like"],["ilike"],["in"],["exists"],["between"],["not","like"],["not","ilike"],["not","in"],["not","exists"],["not","between"],["escape"],["uescape"],["similar","to"],["not","similar","to"],["similar"],["placing"],["rlike"],["regexp"],["mod"],["xor"],["not"],["both"],["leading"],["trailing"],["both","from"],["leading","from"],["trailing","from"],["year","from"],["month","from"],["day","from"],["hour","from"],["minute","from"],["second","from"],["dow","from"],["doy","from"],["isodow","from"],["quarter","from"],["week","from"],["epoch","from"],["at","time","zone"]]),operatorOrTypeTrie=new KeywordTrie([["date"],["time"],["timestamp"],["timestamptz"],["timetz"],["interval"],["boolean"],["integer"],["bigint"],["smallint"],["numeric"],["decimal"],["real"],["double","precision"],["double","precision"],["character","varying"],["time","without","time","zone"],["time","with","time","zone"],["timestamp","without","time","zone"],["timestamp","with","time","zone"]]),keywordParser3=new KeywordParser(trie3),operatorOrTypeParser=new KeywordParser(operatorOrTypeTrie);var OperatorTokenReader=class extends BaseTokenReader{tryRead(previous){if(this.isEndOfInput())return null;let char=this.input[this.position];if(CharLookupTable.isOperatorSymbol(char)){let start=this.position;for(;this.canRead()&&CharLookupTable.isOperatorSymbol(this.input[this.position])&&(this.position++,!!this.canRead());){let previous2=this.input[this.position-1],next=this.input[this.position];if(previous2==="-"&&next==="-"||previous2==="/"&&next==="*")break}this.position===start&&this.position++;let resut=this.input.slice(start,this.position);return this.createLexeme(2,resut)}let result=operatorOrTypeParser.parse(this.input,this.position);if(result!==null){this.position=result.newPosition;let lexeme=this.createLexeme(8258,result.keyword);return result.comments&&result.comments.length>0&&(lexeme.positionedComments=[...lexeme.positionedComments??[],{position:"after",comments:[...result.comments]}]),lexeme}return result=keywordParser3.parse(this.input,this.position),result!==null?(this.position=result.newPosition,this.createLexeme(2,result.keyword)):null}};var ParameterTokenReader=class extends BaseTokenReader{constructor(input){super(input)}tryRead(previous){if(this.isEndOfInput())return null;if(this.canRead(1)&&this.input[this.position]==="$"&&this.input[this.position+1]==="{"){this.position+=2;let start=this.position;for(;this.canRead()&&this.input[this.position]!=="}";)this.position++;if(this.isEndOfInput())throw new Error(`Unexpected end of input. Expected closing '}' for parameter at position ${start}`);let identifier=this.input.slice(start,this.position);if(identifier.length===0)throw new Error("Empty parameter name is not allowed: found ${} at position "+(start-2));return this.position++,this.createLexeme(256,"${"+identifier+"}")}let char=this.input[this.position];if(CharLookupTable.isNamedParameterPrefix(char)){if(this.canRead(1)&&CharLookupTable.isOperatorSymbol(this.input[this.position+1])||char===":"&&this.isInArraySliceContext()||char==="$"&&this.isDollarQuotedString()||char==="$"&&looksLikeSqlServerMoneyLiteral(this.input,this.position))return null;this.position++;let start=this.position;for(;this.canRead()&&!CharLookupTable.isDelimiter(this.input[this.position]);)this.position++;let identifier=this.input.slice(start,this.position);return this.createLexeme(256,char+identifier)}if(char==="?"){if(this.canRead(1)){let nextChar=this.input[this.position+1];if(nextChar==="|"||nextChar==="&")return null}return previous&&(previous.type&64||previous.type&1)?null:(this.position++,this.createLexeme(256,char))}return null}isInArraySliceContext(){let pos=this.position-1,bracketDepth=0,parenDepth=0;for(;pos>=0;){let char=this.input[pos];if(char==="]")bracketDepth++;else if(char==="["){if(bracketDepth--,bracketDepth<0){if(pos>0){let prevChar=this.input[pos-1];if(/[a-zA-Z0-9_)\]]/.test(prevChar))return!0}if(pos===0)return!1;break}}else char===")"?parenDepth++:char==="("&&parenDepth--;pos--}return!1}isDollarQuotedString(){if(!this.canRead(1))return!1;if(this.input[this.position+1]==="$")return!0;let pos=this.position+1;for(;pos=48&&code<=57||code>=65&&code<=90||code>=97&&code<=122}};var SpecialSymbolTokenReader=class _SpecialSymbolTokenReader extends BaseTokenReader{static{this.SPECIAL_SYMBOL_TOKENS={".":32,",":16,"(":4,")":8,"[":512,"]":1024}}tryRead(previous){if(this.isEndOfInput())return null;let char=this.input[this.position];return char in _SpecialSymbolTokenReader.SPECIAL_SYMBOL_TOKENS?(this.position++,this.createLexeme(_SpecialSymbolTokenReader.SPECIAL_SYMBOL_TOKENS[char],char)):null}};var QUOTE_CHAR_CODE=39,AMPERSAND_CHAR_CODE=38,StringSpecifierTokenReader=class extends BaseTokenReader{tryRead(previous){let start=this.position;if(!this.canRead(1))return null;let firstCode=this.input.charCodeAt(start),secondCode=this.input.charCodeAt(start+1);return secondCode===QUOTE_CHAR_CODE&&this.isSingleLetterStringPrefix(firstCode)?(this.position=start+1,this.createLexeme(4096,this.input[start])):this.canRead(2)&&this.isUnicodePrefix(firstCode)&&secondCode===AMPERSAND_CHAR_CODE&&this.input.charCodeAt(start+2)===QUOTE_CHAR_CODE?(this.position=start+2,this.createLexeme(4096,this.input.slice(start,start+2))):null}isSingleLetterStringPrefix(charCode){return charCode===69||charCode===101||charCode===88||charCode===120||charCode===66||charCode===98}isUnicodePrefix(charCode){return charCode===85||charCode===117}};var TokenReaderManager=class{constructor(input,position=0){this.input=input,this.position=position,this.readers=[]}register(reader){return this.readers.push(reader),this}registerAll(readers){for(let i=0;i0){let existing=lexeme.positionedComments??[];lexeme.positionedComments=[...existing,{position:"after",comments:[...keyword.comments]}]}return lexeme}if(previous===null)return null;let result=StringUtils.tryReadRegularIdentifier(this.input,this.position);return result?(this.position=result.newPosition,previous.type&128&&previous.value==="as"?this.createLexeme(8256,result.identifier):previous.type&2&&previous.value==="::"?this.createLexeme(8192,result.identifier):null):null}};var SELECT_LIST_MODIFIER_VALUES=new Set(["all","distinct","distinct on","top"]),SqlTokenizer=class _SqlTokenizer{constructor(input){this.lineStartPositions=null;this.input=input,this.position=0,this.readerManager=new TokenReaderManager(input).register(new EscapedIdentifierTokenReader(input)).register(new ParameterTokenReader(input)).register(new StringSpecifierTokenReader(input)).register(new LiteralTokenReader(input)).register(new SpecialSymbolTokenReader(input)).register(new CommandTokenReader(input)).register(new OperatorTokenReader(input)).register(new TypeTokenReader(input)).register(new FunctionTokenReader(input)).register(new IdentifierTokenReader(input))}isEndOfInput(shift=0){return this.position+shift>=this.input.length}canRead(shift=0){return!this.isEndOfInput(shift)}isSelectListModifier(lexeme){return SELECT_LIST_MODIFIER_VALUES.has(lexeme.value.toLowerCase())}isMeaningfulSelectItemToken(lexeme){let isSelectableOperator=(lexeme.type&2)!==0&&(lexeme.value==="*"||lexeme.value==="exists");return(lexeme.type&64)!==0||(lexeme.type&1)!==0||isSelectableOperator?!0:(lexeme.type&128)!==0?!this.isSelectListModifier(lexeme):(lexeme.type&16)===0&&(lexeme.type&2)===0}tokenize(options){return options?.preserveFormatting?this.tokenizeWithFormatting():new _SqlTokenizer(this.input).readLexemes()}readLexmes(){return this.readLexemes()}readLexemes(){let segment=this.readNextStatement(0);return segment?segment.lexemes:[]}tokenizeBasic(){let segment=this.readNextStatement(0);return segment?segment.lexemes:[]}readNextStatement(startPosition=0,carryComments=null){let length=this.input.length;if(startPosition>=length)return null;this.position=startPosition;let statementStart=startPosition,pendingLeading=carryComments?[...carryComments]:null,tokenData=[],previous=null;for(;this.canRead();){let prefixComment=this.readComment();if(this.position=prefixComment.position,!this.canRead()){pendingLeading=this.mergeComments(pendingLeading,prefixComment.lines);break}if(this.input[this.position]===";"){pendingLeading=this.mergeComments(pendingLeading,prefixComment.lines);break}let lexeme=this.readerManager.tryRead(this.position,previous);if(lexeme===null)throw new Error(`Unexpected character. actual: ${this.input[this.position]}, position: ${this.position} -${this.getDebugPositionInfo(this.position)}`);let tokenStartPos=this.position,tokenEndPos=this.position=this.readerManager.getMaxPosition(),suffixComment=this.readComment();this.position=suffixComment.position;let prefixComments=this.mergeComments(pendingLeading,prefixComment.lines);pendingLeading=null,tokenData.push({lexeme,startPos:tokenStartPos,endPos:tokenEndPos,prefixComments,suffixComments:suffixComment.lines}),previous=lexeme}let statementEnd=this.position,lexemes=this.hasCommentMetadata(tokenData)?this.buildLexemesFromTokenData(tokenData):this.extractLexemes(tokenData),nextPosition=this.skipPastTerminator(statementEnd);return{lexemes,statementStart,statementEnd,nextPosition,rawText:this.input.slice(statementStart,statementEnd),leadingComments:pendingLeading}}hasCommentMetadata(tokenData){for(let i=0;i0||token.suffixComments&&token.suffixComments.length>0||token.lexeme.comments&&token.lexeme.comments.length>0||token.lexeme.positionedComments&&token.lexeme.positionedComments.length>0)return!0}return!1}extractLexemes(tokenData){let lexemes=new Array(tokenData.length),resolveLineColumn=this.createOrderedLineColumnResolver();for(let i=0;i0){if(lexemeValue==="select"){let suffixComments=current.suffixComments,targetIndex=i+1;for(;targetIndex0&&(hasPositionedComments=!0),lexemes[i]=lexeme}return hasPositionedComments&&this.relocateOrderByComments(lexemes),lexemes}skipPastTerminator(position){let next=position;return next0?!base||base.length===0?[...addition]:[...base,...addition]:base?[...base]:null}relocateOrderByComments(lexemes){for(let i=0;icomment.position==="after"&&comment.comments&&comment.comments.length>0);if(afterComments.length===0)continue;current.positionedComments=current.positionedComments.filter(comment=>comment.position!=="after"),current.positionedComments.length===0&&(current.positionedComments=void 0);let target=lexemes[i+1],beforeComments=afterComments.map(comment=>({position:"before",comments:[...comment.comments]}));target.positionedComments&&target.positionedComments.length>0?target.positionedComments=[...beforeComments,...target.positionedComments]:target.positionedComments=beforeComments}}attachCommentsToLexeme(lexeme,tokenData){let newPositionedComments=[],existingLegacyComments=[],allLegacyComments=[];lexeme.positionedComments&&lexeme.positionedComments.length>0&&newPositionedComments.push(...lexeme.positionedComments),lexeme.comments&&lexeme.comments.length>0&&(existingLegacyComments.push(...lexeme.comments),allLegacyComments.push(...lexeme.comments)),tokenData.prefixComments&&tokenData.prefixComments.length>0&&(allLegacyComments.push(...tokenData.prefixComments),newPositionedComments.push({position:"before",comments:[...tokenData.prefixComments]})),tokenData.suffixComments&&tokenData.suffixComments.length>0&&(allLegacyComments.push(...tokenData.suffixComments),newPositionedComments.push({position:"after",comments:[...tokenData.suffixComments]})),newPositionedComments.length>0?(lexeme.positionedComments=newPositionedComments,lexeme.comments=existingLegacyComments.length>0?existingLegacyComments:null):allLegacyComments.length>0?(lexeme.comments=allLegacyComments,lexeme.positionedComments=void 0):(lexeme.comments=null,lexeme.positionedComments=void 0)}readComment(){let pos=this.position,inputLength=this.input.length;if(pos>=inputLength)return{position:pos,lines:null};let code=this.input.charCodeAt(pos);if(code!==32&&code!==9&&code!==10&&code!==13)if(code===45){if(pos+1>=inputLength||this.input.charCodeAt(pos+1)!==45)return{position:pos,lines:null}}else if(code===47){if(pos+1>=inputLength||this.input.charCodeAt(pos+1)!==42)return{position:pos,lines:null}}else return{position:pos,lines:null};return StringUtils.readWhiteSpaceAndComment(this.input,pos)}getDebugPositionInfo(errPosition){return StringUtils.getDebugPositionInfo(this.input,errPosition)}tokenizeWithFormatting(){let regularLexemes=this.tokenizeBasic();return this.mapToFormattingLexemes(regularLexemes)}mapToFormattingLexemes(regularLexemes){if(regularLexemes.length===0)return[];let lexemePositions=[],searchPos=0;for(let lexeme of regularLexemes){searchPos=this.skipWhitespaceAndComments(searchPos);let lexemeInfo=this.findLexemeAtPosition(lexeme,searchPos);if(lexemeInfo)lexemePositions.push(lexemeInfo),searchPos=lexemeInfo.endPosition;else{let fallbackInfo={startPosition:searchPos,endPosition:searchPos+lexeme.value.length};lexemePositions.push(fallbackInfo),searchPos=fallbackInfo.endPosition}}let formattingLexemes=[];for(let i=0;i=this.input.length)return null;let valuesToTry=[lexeme.value,lexeme.value.toUpperCase(),lexeme.value.toLowerCase()];for(let valueToTry of valuesToTry)if(expectedPos+valueToTry.length<=this.input.length&&this.input.substring(expectedPos,expectedPos+valueToTry.length)===valueToTry&&this.isValidLexemeMatch(valueToTry,expectedPos))return{startPosition:expectedPos,endPosition:expectedPos+valueToTry.length};return null}isValidLexemeMatch(value,position){if(position>0){let charBefore=this.input[position-1];if(this.isAlphanumericUnderscore(charBefore))return!1}let endPosition=position+value.length;if(endPosition=48&&code<=57||code>=65&&code<=90||code>=97&&code<=122||code===95}isWhitespace(char){let code=char.charCodeAt(0);return code===32||code===9||code===10||code===13}extractCommentsFromWhitespace(whitespaceSegment){let inlineComments=[],pos=0;for(;pos0&&inlineComments.push(...lines),pos=result.position,pos===oldPos&&pos++}return inlineComments}skipWhitespaceAndComments(pos){return StringUtils.readWhiteSpaceAndComment(this.input,pos).position}getLineColumnInfo(startPos,endPos){let startInfo=this.getLineColumn(startPos),endInfo=this.getLineColumn(endPos);return{startLine:startInfo.line,startColumn:startInfo.column,endLine:endInfo.line,endColumn:endInfo.column}}getLineColumn(position){let starts=this.ensureLineStartPositions(),low=0,high=starts.length-1;for(;low<=high;){let mid=low+high>>>1;starts[mid]<=position?low=mid+1:high=mid-1}let lineIndex=high>=0?high:0,lineStart=starts[lineIndex];return{line:lineIndex+1,column:position-lineStart+1}}createOrderedLineColumnResolver(){let starts=this.ensureLineStartPositions(),lineIndex=0;return position=>{for(;lineIndex+1pc.position===position);existing?existing.comments.push(...comments):this.positionedComments.push({position,comments:[...comments]})}getPositionedComments(position){if(!this.positionedComments)return[];let positioned=this.positionedComments.find(pc=>pc.position===position);return positioned?positioned.comments:[]}getAllPositionedComments(){if(!this.positionedComments)return[];let result=[],beforeComments=this.getPositionedComments("before");result.push(...beforeComments);let afterComments=this.getPositionedComments("after");return result.push(...afterComments),result}},SqlDialectConfiguration=class{constructor(){this.parameterSymbol=":";this.identifierEscape={start:'"',end:'"'};this.exportComment="none"}};var InsertQuery=class extends SqlComponent{static{this.kind=Symbol("InsertQuery")}constructor(params){super(),this.insertClause=params.insertClause,this.selectQuery=params.selectQuery??null,this.returningClause=params.returning??null}};var InlineQuery=class extends SqlComponent{static{this.kind=Symbol("InlineQuery")}constructor(selectQuery){super(),this.selectQuery=selectQuery}},ValueList=class extends SqlComponent{static{this.kind=Symbol("ValueList")}constructor(values){super(),this.values=values}},ColumnReference=class extends SqlComponent{get namespaces(){return this.qualifiedName.namespaces}get column(){return this.qualifiedName.name instanceof IdentifierString?this.qualifiedName.name:new IdentifierString(this.qualifiedName.name.value)}static{this.kind=Symbol("ColumnReference")}constructor(namespaces,column){super();let col=typeof column=="string"?new IdentifierString(column):column;this.qualifiedName=new QualifiedName(toIdentifierStringArray(namespaces),col)}toString(){return this.qualifiedName.toString()}getNamespace(){return this.qualifiedName.namespaces?this.qualifiedName.namespaces.map(namespace=>namespace.name).join("."):""}},FunctionCall=class extends SqlComponent{static{this.kind=Symbol("FunctionCall")}constructor(namespaces,name,argument,over,withinGroup=null,withOrdinality=!1,internalOrderBy=null,filterCondition=null){super(),this.qualifiedName=new QualifiedName(namespaces,name),this.argument=argument,this.over=over,this.withinGroup=withinGroup,this.withOrdinality=withOrdinality,this.internalOrderBy=internalOrderBy,this.filterCondition=filterCondition}get namespaces(){return this.qualifiedName.namespaces}get name(){return this.qualifiedName.name}},WindowFrameType=(WindowFrameType2=>(WindowFrameType2.Rows="rows",WindowFrameType2.Range="range",WindowFrameType2.Groups="groups",WindowFrameType2))(WindowFrameType||{}),WindowFrameBound=(WindowFrameBound2=>(WindowFrameBound2.UnboundedPreceding="unbounded preceding",WindowFrameBound2.UnboundedFollowing="unbounded following",WindowFrameBound2.CurrentRow="current row",WindowFrameBound2))(WindowFrameBound||{}),WindowFrameBoundStatic=class extends SqlComponent{static{this.kind=Symbol("WindowFrameStaticBound")}constructor(bound){super(),this.bound=bound}},WindowFrameBoundaryValue=class extends SqlComponent{static{this.kind=Symbol("WindowFrameBoundary")}constructor(value,isFollowing){super(),this.value=value,this.isFollowing=isFollowing}},WindowFrameSpec=class extends SqlComponent{static{this.kind=Symbol("WindowFrameSpec")}constructor(frameType,startBound,endBound){super(),this.frameType=frameType,this.startBound=startBound,this.endBound=endBound}},WindowFrameExpression=class extends SqlComponent{static{this.kind=Symbol("WindowFrameExpression")}constructor(partition,order,frameSpec=null){super(),this.partition=partition,this.order=order,this.frameSpec=frameSpec}},UnaryExpression=class extends SqlComponent{static{this.kind=Symbol("UnaryExpression")}constructor(operator,expression){super(),this.operator=new RawString(operator),this.expression=expression}},BinaryExpression=class extends SqlComponent{static{this.kind=Symbol("BinaryExpression")}constructor(left,operator,right){super(),this.left=left,this.operator=new RawString(operator),this.right=right}},LiteralValue=class extends SqlComponent{static{this.kind=Symbol("LiteralExpression")}constructor(value,_deprecated,isStringLiteral){super(),this.value=value,this.isStringLiteral=isStringLiteral}},ParameterExpression=class extends SqlComponent{static{this.kind=Symbol("ParameterExpression")}constructor(name,value=null){super(),this.name=new RawString(name),this.value=value,this.index=null}},SwitchCaseArgument=class extends SqlComponent{static{this.kind=Symbol("SwitchCaseArgument")}constructor(cases,elseValue=null){super(),this.cases=cases,this.elseValue=elseValue}},CaseKeyValuePair=class extends SqlComponent{static{this.kind=Symbol("CaseKeyValuePair")}constructor(key,value){super(),this.key=key,this.value=value}},RawString=class extends SqlComponent{static{this.kind=Symbol("RawString")}constructor(value){super(),this.value=value}},IdentifierString=class extends SqlComponent{static{this.kind=Symbol("IdentifierString")}constructor(alias){super(),this.name=alias}},ParenExpression=class extends SqlComponent{static{this.kind=Symbol("ParenExpression")}constructor(expression){super(),this.expression=expression}},CastExpression=class extends SqlComponent{static{this.kind=Symbol("CastExpression")}constructor(input,castType){super(),this.input=input,this.castType=castType}},CaseExpression=class extends SqlComponent{static{this.kind=Symbol("CaseExpression")}constructor(condition,switchCase){super(),this.condition=condition,this.switchCase=switchCase}},ArrayExpression=class extends SqlComponent{static{this.kind=Symbol("ArrayExpression")}constructor(expression){super(),this.expression=expression}},ArrayQueryExpression=class extends SqlComponent{static{this.kind=Symbol("ArrayQueryExpression")}constructor(query){super(),this.query=query}},BetweenExpression=class extends SqlComponent{static{this.kind=Symbol("BetweenExpression")}constructor(expression,lower,upper,negated){super(),this.expression=expression,this.lower=lower,this.upper=upper,this.negated=negated}},StringSpecifierExpression=class extends SqlComponent{static{this.kind=Symbol("StringSpecifierExpression")}constructor(specifier,value){super(),this.specifier=new RawString(specifier),this.value=new LiteralValue(value)}},TypeValue=class extends SqlComponent{static{this.kind=Symbol("TypeValue")}constructor(namespaces,name,argument=null){super(),this.qualifiedName=new QualifiedName(namespaces,name),this.argument=argument}get namespaces(){return this.qualifiedName.namespaces}get name(){return this.qualifiedName.name}getTypeName(){let nameValue=this.qualifiedName.name instanceof RawString?this.qualifiedName.name.value:this.qualifiedName.name.name;return this.qualifiedName.namespaces&&this.qualifiedName.namespaces.length>0?this.qualifiedName.namespaces.map(ns=>ns.name).join(".")+"."+nameValue:nameValue}},TupleExpression=class extends SqlComponent{static{this.kind=Symbol("TupleExpression")}constructor(values){super(),this.values=values}},ArraySliceExpression=class extends SqlComponent{static{this.kind=Symbol("ArraySliceExpression")}constructor(array,startIndex,endIndex){super(),this.array=array,this.startIndex=startIndex,this.endIndex=endIndex}},ArrayIndexExpression=class extends SqlComponent{static{this.kind=Symbol("ArrayIndexExpression")}constructor(array,index){super(),this.array=array,this.index=index}};function toIdentifierStringArray(input){if(input==null)return null;if(typeof input=="string")return input.trim()===""?null:[new IdentifierString(input)];if(Array.isArray(input)){if(input.length===0)return null;if(typeof input[0]=="string"){let filteredStrings=input.filter(ns=>ns.trim()!=="");return filteredStrings.length===0?null:filteredStrings.map(ns=>new IdentifierString(ns))}else{let filteredIdentifiers=input.filter(ns=>ns.name.trim()!=="");return filteredIdentifiers.length===0?null:filteredIdentifiers}}return null}var QualifiedName=class extends SqlComponent{static{this.kind=Symbol("QualifiedName")}constructor(namespaces,name){super(),this.namespaces=toIdentifierStringArray(namespaces),typeof name=="string"?this.name=new RawString(name):this.name=name}toString(){let nameValue=this.name instanceof RawString?this.name.value:this.name.name;return this.namespaces&&this.namespaces.length>0?this.namespaces.map(ns=>ns.name).join(".")+"."+nameValue:nameValue}};var SelectItem=class extends SqlComponent{static{this.kind=Symbol("SelectItem")}constructor(value,name=null){super(),this.value=value,this.identifier=name?new IdentifierString(name):null}},SelectClause=class extends SqlComponent{static{this.kind=Symbol("SelectClause")}constructor(items,distinct=null,hints=[]){super(),this.items=items,this.distinct=distinct,this.hints=hints}},Distinct=class extends SqlComponent{static{this.kind=Symbol("Distinct")}constructor(){super()}},DistinctOn=class extends SqlComponent{static{this.kind=Symbol("DistinctOn")}constructor(value){super(),this.value=value}},WhereClause=class extends SqlComponent{static{this.kind=Symbol("WhereClause")}constructor(condition){super(),this.condition=condition}},PartitionByClause=class extends SqlComponent{static{this.kind=Symbol("PartitionByClause")}constructor(value){super(),this.value=value}},WindowFrameClause=class extends SqlComponent{static{this.kind=Symbol("WindowFrameClause")}constructor(name,expression){super(),this.name=new IdentifierString(name),this.expression=expression}},WindowsClause=class extends SqlComponent{static{this.kind=Symbol("WindowsClause")}constructor(windows){super(),this.windows=windows}},SortDirection=(SortDirection2=>(SortDirection2.Ascending="asc",SortDirection2.Descending="desc",SortDirection2))(SortDirection||{}),NullsSortDirection=(NullsSortDirection2=>(NullsSortDirection2.First="first",NullsSortDirection2.Last="last",NullsSortDirection2))(NullsSortDirection||{}),OrderByClause=class extends SqlComponent{static{this.kind=Symbol("OrderByClause")}constructor(items){super(),this.order=items}},OrderByItem=class extends SqlComponent{static{this.kind=Symbol("OrderByItem")}constructor(expression,sortDirection,nullsPosition){super(),this.value=expression,this.sortDirection=sortDirection===null?"asc":sortDirection,this.nullsPosition=nullsPosition}},GroupByClause=class extends SqlComponent{static{this.kind=Symbol("GroupByClause")}constructor(expression){super(),this.grouping=expression}},HavingClause=class extends SqlComponent{static{this.kind=Symbol("HavingClause")}constructor(condition){super(),this.condition=condition}},TableSource=class extends SqlComponent{static{this.kind=Symbol("TableSource")}get namespaces(){return this.qualifiedName.namespaces}get table(){return this.qualifiedName.name instanceof IdentifierString?this.qualifiedName.name:new IdentifierString(this.qualifiedName.name.value)}get identifier(){return this.table}constructor(namespaces,table){super();let tbl=typeof table=="string"?new IdentifierString(table):table;this.qualifiedName=new QualifiedName(namespaces,tbl)}getSourceName(){return this.qualifiedName.namespaces&&this.qualifiedName.namespaces.length>0?this.qualifiedName.namespaces.map(namespace=>namespace.name).join(".")+"."+(this.qualifiedName.name instanceof RawString?this.qualifiedName.name.value:this.qualifiedName.name.name):this.qualifiedName.name instanceof RawString?this.qualifiedName.name.value:this.qualifiedName.name.name}},FunctionSource=class extends SqlComponent{static{this.kind=Symbol("FunctionSource")}constructor(name,argument,withOrdinality=!1){if(super(),typeof name=="object"&&name!==null&&"name"in name){let nameObj=name;this.qualifiedName=new QualifiedName(nameObj.namespaces,nameObj.name)}else this.qualifiedName=new QualifiedName(null,name);this.argument=argument,this.withOrdinality=withOrdinality}get namespaces(){return this.qualifiedName.namespaces}get name(){return this.qualifiedName.name}},ParenSource=class extends SqlComponent{static{this.kind=Symbol("ParenSource")}constructor(source){super(),this.source=source}},SubQuerySource=class extends SqlComponent{static{this.kind=Symbol("SubQuerySource")}constructor(query){super(),this.query=query}},SourceExpression=class extends SqlComponent{static{this.kind=Symbol("SourceExpression")}constructor(datasource,aliasExpression){super(),this.datasource=datasource,this.aliasExpression=aliasExpression}getAliasName(){return this.aliasExpression?this.aliasExpression.table.name:this.datasource instanceof TableSource?this.datasource.getSourceName():null}},JoinOnClause=class extends SqlComponent{static{this.kind=Symbol("JoinOnClause")}constructor(condition){super(),this.condition=condition}},JoinUsingClause=class extends SqlComponent{static{this.kind=Symbol("JoinUsingClause")}constructor(condition){super(),this.condition=condition}},JoinClause=class extends SqlComponent{static{this.kind=Symbol("JoinItem")}constructor(joinType,source,condition,lateral){super(),this.joinType=new RawString(joinType),this.source=source,this.condition=condition,this.lateral=lateral}getSourceAliasName(){return this.source.aliasExpression?this.source.aliasExpression.table.name:this.source instanceof TableSource?this.source.table.name:null}},FromClause=class extends SqlComponent{static{this.kind=Symbol("FromClause")}constructor(source,join){super(),this.source=source,this.joins=join}getSourceAliasName(){return this.source.aliasExpression?this.source.aliasExpression.table.name:this.source.datasource instanceof TableSource?this.source.datasource.table.name:null}getSources(){let sources=[this.source];if(this.joins)for(let join of this.joins)sources.push(join.source);return sources}},UsingClause=class extends SqlComponent{static{this.kind=Symbol("UsingClause")}constructor(sources){super(),this.sources=sources}getSources(){return[...this.sources]}},CommonTable=class extends SqlComponent{static{this.kind=Symbol("CommonTable")}constructor(query,aliasExpression,materialized){super(),this.query=query,this.materialized=materialized,typeof aliasExpression=="string"?this.aliasExpression=new SourceAliasExpression(aliasExpression,null):this.aliasExpression=aliasExpression}getSourceAliasName(){return this.aliasExpression.table.name}},WithClause=class extends SqlComponent{constructor(recursive,tables){super();this.trailingComments=null;this.globalComments=null;this.recursive=recursive,this.tables=tables}static{this.kind=Symbol("WithClause")}},LimitClause=class extends SqlComponent{static{this.kind=Symbol("LimitClause")}constructor(limit){super(),this.value=limit}},FetchType=(FetchType2=>(FetchType2.Next="next",FetchType2.First="first",FetchType2))(FetchType||{}),FetchUnit=(FetchUnit2=>(FetchUnit2.RowsOnly="rows only",FetchUnit2.Percent="percent",FetchUnit2.PercentWithTies="percent with ties",FetchUnit2))(FetchUnit||{}),OffsetClause=class extends SqlComponent{static{this.kind=Symbol("OffsetClause")}constructor(value){super(),this.value=value}},FetchClause=class extends SqlComponent{static{this.kind=Symbol("FetchClause")}constructor(expression){super(),this.expression=expression}},FetchExpression=class extends SqlComponent{static{this.kind=Symbol("FetchExpression")}constructor(type,count,unit){super(),this.type=type,this.count=count,this.unit=unit}},LockMode=(LockMode2=>(LockMode2.Update="update",LockMode2.Share="share",LockMode2.KeyShare="key share",LockMode2.NokeyUpdate="no key update",LockMode2))(LockMode||{}),ForClause=class extends SqlComponent{static{this.kind=Symbol("ForClause")}constructor(lockMode){super(),this.lockMode=lockMode}},SourceAliasExpression=class extends SqlComponent{static{this.kind=Symbol("SourceAliasExpression")}constructor(alias,columnAlias){super(),this.table=new IdentifierString(alias),this.columns=columnAlias!==null?columnAlias.map(alias2=>new IdentifierString(alias2)):null}},ReturningClause=class extends SqlComponent{static{this.kind=Symbol("ReturningClause")}constructor(items){super(),this.items=items}get columns(){return this.items.map(item=>item.value instanceof ColumnReference?item.value.column:new IdentifierString(item.identifier?.name??""))}},SetClause=class extends SqlComponent{static{this.kind=Symbol("SetClause")}constructor(items){super(),this.items=items.map(item=>item instanceof SetClauseItem?item:new SetClauseItem(item.column,item.value))}},SetClauseItem=class extends SqlComponent{static{this.kind=Symbol("SetClauseItem")}constructor(column,value){if(super(),typeof column=="object"&&column!==null&&"column"in column){let colObj=column,col=typeof colObj.column=="string"?new IdentifierString(colObj.column):colObj.column;this.qualifiedName=new QualifiedName(colObj.namespaces,col)}else{let col=typeof column=="string"?new IdentifierString(column):column;this.qualifiedName=new QualifiedName(null,col)}this.value=value}get namespaces(){return this.qualifiedName.namespaces}get column(){return this.qualifiedName.name instanceof IdentifierString?this.qualifiedName.name:new IdentifierString(this.qualifiedName.name.value)}getFullName(){return this.qualifiedName.toString()}},UpdateClause=class extends SqlComponent{static{this.kind=Symbol("UpdateClause")}constructor(source){super(),this.source=source}getSourceAliasName(){return this.source.aliasExpression?this.source.aliasExpression.table.name:this.source.datasource instanceof TableSource?this.source.datasource.table.name:null}},DeleteClause=class extends SqlComponent{static{this.kind=Symbol("DeleteClause")}constructor(source){super(),this.source=source}getSourceAliasName(){return this.source.getAliasName()}},InsertClause=class extends SqlComponent{constructor(source,columns){super(),this.source=source,columns&&columns.length>0?this.columns=columns.map(col=>typeof col=="string"?new IdentifierString(col):col):this.columns=null}};var POSTGRESQL_COMMAND_KEYWORDS_ALLOWED_AS_IDENTIFIER=new Set(["groups","rows","range","window","over","following","preceding","within","ordinality","lateral","recursive","materialized","partition"]),FullNameParser=class _FullNameParser{static parseFromLexeme(lexemes,index){let{identifiers,newIndex}=_FullNameParser.parseEscapedOrDotSeparatedIdentifiers(lexemes,index),{namespaces,name}=_FullNameParser.extractNamespacesAndName(identifiers),identifierString=new IdentifierString(name);if(newIndex>index){let lastLexeme=lexemes[newIndex-1];lastLexeme.positionedComments&&lastLexeme.positionedComments.length>0&&(identifierString.positionedComments=[...lastLexeme.positionedComments]),(!identifierString.positionedComments||identifierString.positionedComments.length===0)&&lastLexeme.comments&&lastLexeme.comments.length>0&&(identifierString.comments=[...lastLexeme.comments])}let lastTokenType=0;return newIndex>index&&(lastTokenType=lexemes[newIndex-1].type),{namespaces,name:identifierString,newIndex,lastTokenType}}static parse(str){let lexemes=new SqlTokenizer(str).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex=lexemes.length||!(lexemes[idx].type&64||lexemes[idx].type&128))throw new Error(`Expected identifier after '[' at position ${idx}`);if(identifiers.push(lexemes[idx].value),idx++,idx>=lexemes.length||lexemes[idx].value!=="]")throw new Error(`Expected closing ']' after identifier at position ${idx}`);idx++}else if(lexemes[idx].type&64)identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].type&2048)identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].type&8192)identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].type&128&&POSTGRESQL_COMMAND_KEYWORDS_ALLOWED_AS_IDENTIFIER.has(lexemes[idx].value.toLowerCase()))identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].value==="*"){identifiers.push(lexemes[idx].value),idx++;break}if(idx({position:comment.position,comments:[...comment.comments]})):null,openLegacyComments=openLexeme.comments?[...openLexeme.comments]:null;idx+=1;let result2=SelectQueryParser.parseFromLexeme(lexemes,idx);if(idx=result2.newIndex,idx>=lexemes.length||lexemes[idx].type!==8)throw new Error(`Expected ')' at index ${idx}, but found ${lexemes[idx].value}`);let closingLexeme=lexemes[idx],closingLegacyComments=closingLexeme.comments,closingPositionedComments=closingLexeme.positionedComments;idx++;let value2=new InlineQuery(result2.value);if(openPositionedComments&&openPositionedComments.length>0){let beforeComments=openPositionedComments.filter(comment=>comment.position==="after"&&comment.comments.length>0).map(comment=>({position:"before",comments:[...comment.comments]}));beforeComments.length>0&&(value2.positionedComments=value2.positionedComments?[...beforeComments,...value2.positionedComments]:beforeComments)}else if(openLegacyComments&&openLegacyComments.length>0){let beforeCommentBlock={position:"before",comments:[...openLegacyComments]};value2.positionedComments=value2.positionedComments?[beforeCommentBlock,...value2.positionedComments]:[beforeCommentBlock]}if(closingPositionedComments&&closingPositionedComments.length>0){let afterComments=closingPositionedComments.filter(comment=>comment.position==="after"&&comment.comments.length>0).map(comment=>({position:comment.position,comments:[...comment.comments]}));afterComments.length>0&&(value2.positionedComments=value2.positionedComments?[...value2.positionedComments,...afterComments]:afterComments)}else closingLegacyComments&&closingLegacyComments.length>0&&(value2.comments=closingLegacyComments);return{value:value2,newIndex:idx}}let result=ValueParser.parseArgument(4,8,lexemes,index);idx=result.newIndex;let value=new ParenExpression(result.value),closingIndex=idx-1;if(closingIndex>=0&&closingIndex0){let afterComments=closingLexeme.positionedComments.filter(comment=>comment.position==="after"&&comment.comments.length>0).map(comment=>({position:comment.position,comments:[...comment.comments]}));afterComments.length>0&&(value.positionedComments=value.positionedComments?[...value.positionedComments,...afterComments]:afterComments)}else closingLexeme.comments&&closingLexeme.comments.length>0&&(value.comments=value.comments?value.comments.concat(closingLexeme.comments):[...closingLexeme.comments])}return{value,newIndex:idx}}};var UnaryExpressionParser=class{static parseFromLexeme(lexemes,index){let idx=index;if(idx0?value.positionedComments=operatorLexeme.positionedComments:operatorLexeme.comments&&operatorLexeme.comments.length>0&&(value.comments=operatorLexeme.comments),{value,newIndex:idx}}throw new Error(`Invalid unary expression at index ${index}: ${lexemes[index].value}`)}};var ParameterExpressionParser=class{static parseFromLexeme(lexemes,index){let idx=index,paramName=lexemes[idx].value;paramName.startsWith("${")&¶mName.endsWith("}")?paramName=paramName.slice(2,-1):paramName=paramName.slice(1);let value=new ParameterExpression(paramName);return idx++,{value,newIndex:idx}}};var StringSpecifierExpressionParser=class{static parseFromLexeme(lexemes,index){let idx=index,specifer=lexemes[idx].value;if(idx++,idx>=lexemes.length||lexemes[idx].type!==1)throw new Error(`Expected string literal after string specifier at index ${idx}`);let value=lexemes[idx].value;return idx++,{value:new StringSpecifierExpression(specifer,value),newIndex:idx}}};var CommandExpressionParser=class _CommandExpressionParser{static parseFromLexeme(lexemes,index){let idx=index,current=lexemes[idx];if(current.value==="case"){let caseKeywordComments=current.comments,caseKeywordPositionedComments=current.positionedComments;return idx++,this.parseCaseExpression(lexemes,idx,caseKeywordComments,caseKeywordPositionedComments)}else if(current.value==="case when"){let caseWhenKeywordComments=current.comments,caseWhenKeywordPositionedComments=current.positionedComments;return idx++,this.parseCaseWhenExpression(lexemes,idx,caseWhenKeywordComments,caseWhenKeywordPositionedComments)}return this.parseModifierUnaryExpression(lexemes,idx)}static parseModifierUnaryExpression(lexemes,index){let idx=index;if(idx0?result.positionedComments=caseKeywordPositionedComments:caseKeywordComments&&caseKeywordComments.length>0&&(result.positionedComments=[_CommandExpressionParser.convertLegacyToPositioned(caseKeywordComments,"before")]),{value:result,newIndex:idx}}static parseCaseWhenExpression(lexemes,index,caseWhenKeywordComments,caseWhenKeywordPositionedComments){let idx=index,casewhenResult=this.parseCaseConditionValuePair(lexemes,idx);idx=casewhenResult.newIndex;let caseWhenList=[casewhenResult.value],switchCaseResult=this.parseSwitchCaseArgument(lexemes,idx,caseWhenList);idx=switchCaseResult.newIndex;let result=new CaseExpression(null,switchCaseResult.value);return caseWhenKeywordPositionedComments&&caseWhenKeywordPositionedComments.length>0?result.positionedComments=caseWhenKeywordPositionedComments:caseWhenKeywordComments&&caseWhenKeywordComments.length>0&&(result.positionedComments=[_CommandExpressionParser.convertLegacyToPositioned(caseWhenKeywordComments,"before")]),{value:result,newIndex:idx}}static parseSwitchCaseArgument(lexemes,index,initialWhenThenList){let idx=index,whenThenList=[...initialWhenThenList];idx=this.parseAdditionalWhenClauses(lexemes,idx,whenThenList);let{elseValue,elseComments,newIndex:elseIndex}=this.parseElseClause(lexemes,idx);idx=elseIndex;let{endComments,newIndex:endIndex}=this.parseEndClause(lexemes,idx);if(idx=endIndex,whenThenList.length===0)throw new Error(`The CASE expression requires at least one WHEN clause (index ${idx})`);let switchCaseArg=new SwitchCaseArgument(whenThenList,elseValue);return this.applySwitchCaseComments(switchCaseArg,elseComments,endComments),{value:switchCaseArg,newIndex:idx}}static parseAdditionalWhenClauses(lexemes,index,whenThenList){let idx=index;for(;idx0&&allPositionedComments.push(...elseComments.positioned),elseComments?.legacy&&elseComments.legacy.length>0&&allLegacyComments.push(...elseComments.legacy),endComments?.positioned&&endComments.positioned.length>0&&allPositionedComments.push(...endComments.positioned),endComments?.legacy&&endComments.legacy.length>0&&allLegacyComments.push(...endComments.legacy),allPositionedComments.length>0?switchCaseArg.positionedComments=allPositionedComments:allLegacyComments.length>0&&(switchCaseArg.positionedComments=[_CommandExpressionParser.convertLegacyToPositioned(allLegacyComments,"after")])}static isCommandWithValue(lexeme,value){return(lexeme.type&128)!==0&&lexeme.value===value}static parseCaseConditionValuePair(lexemes,index){let idx=index,condition=ValueParser.parseFromLexeme(lexemes,idx);if(idx=condition.newIndex,idx>=lexemes.length||!(lexemes[idx].type&128)||lexemes[idx].value!=="then")throw new Error(`Expected 'then' after WHEN condition at index ${idx}`);let thenKeywordComments=lexemes[idx].comments,thenKeywordPositionedComments=lexemes[idx].positionedComments;idx++;let value=ValueParser.parseFromLexeme(lexemes,idx);idx=value.newIndex;let keyValuePair=new CaseKeyValuePair(condition.value,value.value);return thenKeywordPositionedComments&&thenKeywordPositionedComments.length>0?keyValuePair.positionedComments=thenKeywordPositionedComments:thenKeywordComments&&thenKeywordComments.length>0&&(keyValuePair.positionedComments=[_CommandExpressionParser.convertLegacyToPositioned(thenKeywordComments,"after")]),{value:keyValuePair,newIndex:idx}}static convertLegacyToPositioned(legacyComments,position="before"){return{position,comments:legacyComments}}};var OrderByClauseParser=class{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex=lexemes.length)return{value,newIndex:idx};let sortDirectionComments=null,sortDirection=null;if(idx0){sortDirectionComments=[];for(let posComment of token.positionedComments)posComment.comments&&posComment.comments.length>0&&sortDirectionComments.push(...posComment.comments)}token.comments&&token.comments.length>0&&(sortDirectionComments||(sortDirectionComments=[]),sortDirectionComments.push(...token.comments))}}let nullsSortDirection=idx>=lexemes.length?null:lexemes[idx].value==="nulls first"?(idx++,"first"):lexemes[idx].value==="nulls last"?(idx++,"last"):null;return sortDirectionComments&&sortDirectionComments.length>0&&(value.comments?value.comments.push(...sortDirectionComments):value.comments=sortDirectionComments),sortDirection===null&&nullsSortDirection===null?{value,newIndex:idx}:{value:new OrderByItem(value,sortDirection,nullsSortDirection),newIndex:idx}}};var PartitionByParser=class{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex=lexemes.length||lexemes[idx].type!==8)throw new Error(`Syntax error at position ${idx}: Missing closing parenthesis ')' for window frame. Each opening parenthesis must have a matching closing parenthesis.`);return idx++,{value:new WindowFrameExpression(partition,order,frameSpec),newIndex:idx}}static isFrameTypeKeyword(value){let lowerValue=value;return lowerValue==="rows"||lowerValue==="range"||lowerValue==="groups"}static parseFrameSpec(lexemes,index){let idx=index,frameTypeStr=lexemes[idx].value,frameType;switch(frameTypeStr){case"rows":frameType="rows";break;case"range":frameType="range";break;case"groups":frameType="groups";break;default:throw new Error(`Syntax error at position ${idx}: Invalid frame type "${lexemes[idx].value}". Expected one of: ROWS, RANGE, GROUPS.`)}if(idx++,idx=lexemes.length||lexemes[idx].value!=="and")throw new Error(`Syntax error at position ${idx}: Expected 'AND' keyword in BETWEEN clause.`);idx++;let endBoundResult=this.parseFrameBoundary(lexemes,idx),endBound=endBoundResult.value;return idx=endBoundResult.newIndex,{value:new WindowFrameSpec(frameType,startBound,endBound),newIndex:idx}}else{let boundaryResult=this.parseFrameBoundary(lexemes,idx),startBound=boundaryResult.value;return idx=boundaryResult.newIndex,{value:new WindowFrameSpec(frameType,startBound,null),newIndex:idx}}}static parseFrameBoundary(lexemes,index){let idx=index;if(idx=lexemes.length)throw new Error("Syntax error: Unexpected end of input after 'OVER' keyword. Expected either a window name or an opening parenthesis '('.");if(lexemes[idx].type&64){let name=lexemes[idx].value;return idx++,{value:new IdentifierString(name),newIndex:idx}}if(lexemes[idx].type&4)return WindowExpressionParser.parseFromLexeme(lexemes,idx);throw new Error(`Syntax error at position ${idx}: Expected a window name or opening parenthesis '(' after OVER keyword, but found "${lexemes[idx].value}".`)}};var ParseError=class _ParseError extends Error{constructor(message,index,context){super(message);this.index=index;this.context=context;this.name="ParseError"}static fromUnparsedLexemes(lexemes,index,messagePrefix){let start=Math.max(0,index-2),end=Math.min(lexemes.length,index+3),context=lexemes.slice(start,end).map((lexeme,idx)=>{let marker=idx+start===index?">":" ",typeName=TokenType[lexeme.type]||lexeme.type;return`${marker} ${idx+start}:${lexeme.value} [${typeName}]`}).join(` +${this.getDebugPositionInfo(this.position)}`);let tokenStartPos=this.position,tokenEndPos=this.position=this.readerManager.getMaxPosition(),suffixComment=this.readComment();this.position=suffixComment.position;let prefixComments=this.mergeComments(pendingLeading,prefixComment.lines);pendingLeading=null,tokenData.push({lexeme,startPos:tokenStartPos,endPos:tokenEndPos,prefixComments,suffixComments:suffixComment.lines}),previous=lexeme}let statementEnd=this.position,lexemes=this.hasCommentMetadata(tokenData)?this.buildLexemesFromTokenData(tokenData):this.extractLexemes(tokenData),nextPosition=this.skipPastTerminator(statementEnd);return{lexemes,statementStart,statementEnd,nextPosition,rawText:this.input.slice(statementStart,statementEnd),leadingComments:pendingLeading}}hasCommentMetadata(tokenData){for(let i=0;i0||token.suffixComments&&token.suffixComments.length>0||token.lexeme.comments&&token.lexeme.comments.length>0||token.lexeme.positionedComments&&token.lexeme.positionedComments.length>0)return!0}return!1}extractLexemes(tokenData){let lexemes=new Array(tokenData.length),resolveLineColumn=this.createOrderedLineColumnResolver();for(let i=0;i0){if(lexemeValue==="select"){let suffixComments=current.suffixComments,targetIndex=i+1;for(;targetIndex0&&(hasPositionedComments=!0),lexemes[i]=lexeme}return hasPositionedComments&&this.relocateOrderByComments(lexemes),lexemes}skipPastTerminator(position){let next=position;return next0?!base||base.length===0?[...addition]:[...base,...addition]:base?[...base]:null}relocateOrderByComments(lexemes){for(let i=0;icomment.position==="after"&&comment.comments&&comment.comments.length>0);if(afterComments.length===0)continue;current.positionedComments=current.positionedComments.filter(comment=>comment.position!=="after"),current.positionedComments.length===0&&(current.positionedComments=void 0);let target=lexemes[i+1],beforeComments=afterComments.map(comment=>({position:"before",comments:[...comment.comments]}));target.positionedComments&&target.positionedComments.length>0?target.positionedComments=[...beforeComments,...target.positionedComments]:target.positionedComments=beforeComments}}attachCommentsToLexeme(lexeme,tokenData){let newPositionedComments=[],existingLegacyComments=[],allLegacyComments=[];lexeme.positionedComments&&lexeme.positionedComments.length>0&&newPositionedComments.push(...lexeme.positionedComments),lexeme.comments&&lexeme.comments.length>0&&(existingLegacyComments.push(...lexeme.comments),allLegacyComments.push(...lexeme.comments)),tokenData.prefixComments&&tokenData.prefixComments.length>0&&(allLegacyComments.push(...tokenData.prefixComments),newPositionedComments.push({position:"before",comments:[...tokenData.prefixComments]})),tokenData.suffixComments&&tokenData.suffixComments.length>0&&(allLegacyComments.push(...tokenData.suffixComments),newPositionedComments.push({position:"after",comments:[...tokenData.suffixComments]})),newPositionedComments.length>0?(lexeme.positionedComments=newPositionedComments,lexeme.comments=existingLegacyComments.length>0?existingLegacyComments:null):allLegacyComments.length>0?(lexeme.comments=allLegacyComments,lexeme.positionedComments=void 0):(lexeme.comments=null,lexeme.positionedComments=void 0)}readComment(){let pos=this.position,inputLength=this.input.length;if(pos>=inputLength)return{position:pos,lines:null};let code=this.input.charCodeAt(pos);if(code!==32&&code!==9&&code!==10&&code!==13)if(code===45){if(pos+1>=inputLength||this.input.charCodeAt(pos+1)!==45)return{position:pos,lines:null}}else if(code===47){if(pos+1>=inputLength||this.input.charCodeAt(pos+1)!==42)return{position:pos,lines:null}}else return{position:pos,lines:null};return StringUtils.readWhiteSpaceAndComment(this.input,pos)}getDebugPositionInfo(errPosition){return StringUtils.getDebugPositionInfo(this.input,errPosition)}tokenizeWithFormatting(){let regularLexemes=this.tokenizeBasic();return this.mapToFormattingLexemes(regularLexemes)}mapToFormattingLexemes(regularLexemes){if(regularLexemes.length===0)return[];let lexemePositions=[],searchPos=0;for(let lexeme of regularLexemes){searchPos=this.skipWhitespaceAndComments(searchPos);let lexemeInfo=this.findLexemeAtPosition(lexeme,searchPos);if(lexemeInfo)lexemePositions.push(lexemeInfo),searchPos=lexemeInfo.endPosition;else{let fallbackInfo={startPosition:searchPos,endPosition:searchPos+lexeme.value.length};lexemePositions.push(fallbackInfo),searchPos=fallbackInfo.endPosition}}let formattingLexemes=[];for(let i=0;i=this.input.length)return null;let valuesToTry=[lexeme.value,lexeme.value.toUpperCase(),lexeme.value.toLowerCase()];for(let valueToTry of valuesToTry)if(expectedPos+valueToTry.length<=this.input.length&&this.input.substring(expectedPos,expectedPos+valueToTry.length)===valueToTry&&this.isValidLexemeMatch(valueToTry,expectedPos))return{startPosition:expectedPos,endPosition:expectedPos+valueToTry.length};return null}isValidLexemeMatch(value,position){if(position>0){let charBefore=this.input[position-1];if(this.isAlphanumericUnderscore(charBefore))return!1}let endPosition=position+value.length;if(endPosition=48&&code<=57||code>=65&&code<=90||code>=97&&code<=122||code===95}isWhitespace(char){let code=char.charCodeAt(0);return code===32||code===9||code===10||code===13}extractCommentsFromWhitespace(whitespaceSegment){let inlineComments=[],pos=0;for(;pos0&&inlineComments.push(...lines),pos=result.position,pos===oldPos&&pos++}return inlineComments}skipWhitespaceAndComments(pos){return StringUtils.readWhiteSpaceAndComment(this.input,pos).position}getLineColumnInfo(startPos,endPos){let startInfo=this.getLineColumn(startPos),endInfo=this.getLineColumn(endPos);return{startLine:startInfo.line,startColumn:startInfo.column,endLine:endInfo.line,endColumn:endInfo.column}}getLineColumn(position){let starts=this.ensureLineStartPositions(),low=0,high=starts.length-1;for(;low<=high;){let mid=low+high>>>1;starts[mid]<=position?low=mid+1:high=mid-1}let lineIndex=high>=0?high:0,lineStart=starts[lineIndex];return{line:lineIndex+1,column:position-lineStart+1}}createOrderedLineColumnResolver(){let starts=this.ensureLineStartPositions(),lineIndex=0;return position=>{for(;lineIndex+1pc.position===position);existing?existing.comments.push(...comments):this.positionedComments.push({position,comments:[...comments]})}getPositionedComments(position){if(!this.positionedComments)return[];let positioned=this.positionedComments.find(pc=>pc.position===position);return positioned?positioned.comments:[]}getAllPositionedComments(){if(!this.positionedComments)return[];let result=[],beforeComments=this.getPositionedComments("before");result.push(...beforeComments);let afterComments=this.getPositionedComments("after");return result.push(...afterComments),result}},SqlDialectConfiguration=class{constructor(){this.parameterSymbol=":";this.identifierEscape={start:'"',end:'"'};this.exportComment="none"}};var InsertQuery=class extends SqlComponent{static{this.kind=Symbol("InsertQuery")}constructor(params){super(),this.insertClause=params.insertClause,this.selectQuery=params.selectQuery??null,this.returningClause=params.returning??null}};var InlineQuery=class extends SqlComponent{static{this.kind=Symbol("InlineQuery")}constructor(selectQuery){super(),this.selectQuery=selectQuery}},ValueList=class extends SqlComponent{static{this.kind=Symbol("ValueList")}constructor(values){super(),this.values=values}},ColumnReference=class extends SqlComponent{get namespaces(){return this.qualifiedName.namespaces}get column(){return this.qualifiedName.name instanceof IdentifierString?this.qualifiedName.name:new IdentifierString(this.qualifiedName.name.value)}static{this.kind=Symbol("ColumnReference")}constructor(namespaces,column){super();let col=typeof column=="string"?new IdentifierString(column):column;this.qualifiedName=new QualifiedName(toIdentifierStringArray(namespaces),col)}toString(){return this.qualifiedName.toString()}getNamespace(){return this.qualifiedName.namespaces?this.qualifiedName.namespaces.map(namespace=>namespace.name).join("."):""}},FunctionCall=class extends SqlComponent{static{this.kind=Symbol("FunctionCall")}constructor(namespaces,name,argument,over,withinGroup=null,withOrdinality=!1,internalOrderBy=null,filterCondition=null){super(),this.qualifiedName=new QualifiedName(namespaces,name),this.argument=argument,this.over=over,this.withinGroup=withinGroup,this.withOrdinality=withOrdinality,this.internalOrderBy=internalOrderBy,this.filterCondition=filterCondition}get namespaces(){return this.qualifiedName.namespaces}get name(){return this.qualifiedName.name}},WindowFrameType=(WindowFrameType2=>(WindowFrameType2.Rows="rows",WindowFrameType2.Range="range",WindowFrameType2.Groups="groups",WindowFrameType2))(WindowFrameType||{}),WindowFrameBound=(WindowFrameBound2=>(WindowFrameBound2.UnboundedPreceding="unbounded preceding",WindowFrameBound2.UnboundedFollowing="unbounded following",WindowFrameBound2.CurrentRow="current row",WindowFrameBound2))(WindowFrameBound||{}),WindowFrameBoundStatic=class extends SqlComponent{static{this.kind=Symbol("WindowFrameStaticBound")}constructor(bound){super(),this.bound=bound}},WindowFrameBoundaryValue=class extends SqlComponent{static{this.kind=Symbol("WindowFrameBoundary")}constructor(value,isFollowing){super(),this.value=value,this.isFollowing=isFollowing}},WindowFrameSpec=class extends SqlComponent{static{this.kind=Symbol("WindowFrameSpec")}constructor(frameType,startBound,endBound){super(),this.frameType=frameType,this.startBound=startBound,this.endBound=endBound}},WindowFrameExpression=class extends SqlComponent{static{this.kind=Symbol("WindowFrameExpression")}constructor(partition,order,frameSpec=null){super(),this.partition=partition,this.order=order,this.frameSpec=frameSpec}},UnaryExpression=class extends SqlComponent{static{this.kind=Symbol("UnaryExpression")}constructor(operator,expression){super(),this.operator=new RawString(operator),this.expression=expression}},BinaryExpression=class extends SqlComponent{static{this.kind=Symbol("BinaryExpression")}constructor(left,operator,right){super(),this.left=left,this.operator=new RawString(operator),this.right=right}},LiteralValue=class extends SqlComponent{static{this.kind=Symbol("LiteralExpression")}constructor(value,_deprecated,isStringLiteral){super(),this.value=value,this.isStringLiteral=isStringLiteral}},ParameterExpression=class extends SqlComponent{static{this.kind=Symbol("ParameterExpression")}constructor(name,value=null){super(),this.name=new RawString(name),this.value=value,this.index=null}},SwitchCaseArgument=class extends SqlComponent{static{this.kind=Symbol("SwitchCaseArgument")}constructor(cases,elseValue=null){super(),this.cases=cases,this.elseValue=elseValue}},CaseKeyValuePair=class extends SqlComponent{static{this.kind=Symbol("CaseKeyValuePair")}constructor(key,value){super(),this.key=key,this.value=value}},RawString=class extends SqlComponent{static{this.kind=Symbol("RawString")}constructor(value){super(),this.value=value}},IdentifierString=class extends SqlComponent{static{this.kind=Symbol("IdentifierString")}constructor(alias){super(),this.name=alias}},ParenExpression=class extends SqlComponent{static{this.kind=Symbol("ParenExpression")}constructor(expression){super(),this.expression=expression}},CastExpression=class extends SqlComponent{static{this.kind=Symbol("CastExpression")}constructor(input,castType){super(),this.input=input,this.castType=castType}},CaseExpression=class extends SqlComponent{static{this.kind=Symbol("CaseExpression")}constructor(condition,switchCase){super(),this.condition=condition,this.switchCase=switchCase}},ArrayExpression=class extends SqlComponent{static{this.kind=Symbol("ArrayExpression")}constructor(expression){super(),this.expression=expression}},ArrayQueryExpression=class extends SqlComponent{static{this.kind=Symbol("ArrayQueryExpression")}constructor(query){super(),this.query=query}},BetweenExpression=class extends SqlComponent{static{this.kind=Symbol("BetweenExpression")}constructor(expression,lower,upper,negated){super(),this.expression=expression,this.lower=lower,this.upper=upper,this.negated=negated}},StringSpecifierExpression=class extends SqlComponent{static{this.kind=Symbol("StringSpecifierExpression")}constructor(specifier,value){super(),this.specifier=new RawString(specifier),this.value=new LiteralValue(value)}},TypeValue=class extends SqlComponent{static{this.kind=Symbol("TypeValue")}constructor(namespaces,name,argument=null){super(),this.qualifiedName=new QualifiedName(namespaces,name),this.argument=argument}get namespaces(){return this.qualifiedName.namespaces}get name(){return this.qualifiedName.name}getTypeName(){let nameValue=this.qualifiedName.name instanceof RawString?this.qualifiedName.name.value:this.qualifiedName.name.name;return this.qualifiedName.namespaces&&this.qualifiedName.namespaces.length>0?this.qualifiedName.namespaces.map(ns=>ns.name).join(".")+"."+nameValue:nameValue}},TupleExpression=class extends SqlComponent{static{this.kind=Symbol("TupleExpression")}constructor(values){super(),this.values=values}},ArraySliceExpression=class extends SqlComponent{static{this.kind=Symbol("ArraySliceExpression")}constructor(array,startIndex,endIndex){super(),this.array=array,this.startIndex=startIndex,this.endIndex=endIndex}},ArrayIndexExpression=class extends SqlComponent{static{this.kind=Symbol("ArrayIndexExpression")}constructor(array,index){super(),this.array=array,this.index=index}};function toIdentifierStringArray(input){if(input==null)return null;if(typeof input=="string")return input.trim()===""?null:[new IdentifierString(input)];if(Array.isArray(input)){if(input.length===0)return null;if(typeof input[0]=="string"){let filteredStrings=input.filter(ns=>ns.trim()!=="");return filteredStrings.length===0?null:filteredStrings.map(ns=>new IdentifierString(ns))}else{let filteredIdentifiers=input.filter(ns=>ns.name.trim()!=="");return filteredIdentifiers.length===0?null:filteredIdentifiers}}return null}var QualifiedName=class extends SqlComponent{static{this.kind=Symbol("QualifiedName")}constructor(namespaces,name){super(),this.namespaces=toIdentifierStringArray(namespaces),typeof name=="string"?this.name=new RawString(name):this.name=name}toString(){let nameValue=this.name instanceof RawString?this.name.value:this.name.name;return this.namespaces&&this.namespaces.length>0?this.namespaces.map(ns=>ns.name).join(".")+"."+nameValue:nameValue}};var SelectItem=class extends SqlComponent{static{this.kind=Symbol("SelectItem")}constructor(value,name=null){super(),this.value=value,this.identifier=name?new IdentifierString(name):null}},SelectClause=class extends SqlComponent{static{this.kind=Symbol("SelectClause")}constructor(items,distinct=null,hints=[]){super(),this.items=items,this.distinct=distinct,this.hints=hints}},Distinct=class extends SqlComponent{static{this.kind=Symbol("Distinct")}constructor(){super()}},DistinctOn=class extends SqlComponent{static{this.kind=Symbol("DistinctOn")}constructor(value){super(),this.value=value}},WhereClause=class extends SqlComponent{static{this.kind=Symbol("WhereClause")}constructor(condition){super(),this.condition=condition}},PartitionByClause=class extends SqlComponent{static{this.kind=Symbol("PartitionByClause")}constructor(value){super(),this.value=value}},WindowFrameClause=class extends SqlComponent{static{this.kind=Symbol("WindowFrameClause")}constructor(name,expression){super(),this.name=new IdentifierString(name),this.expression=expression}},WindowsClause=class extends SqlComponent{static{this.kind=Symbol("WindowsClause")}constructor(windows){super(),this.windows=windows}},SortDirection=(SortDirection2=>(SortDirection2.Ascending="asc",SortDirection2.Descending="desc",SortDirection2))(SortDirection||{}),NullsSortDirection=(NullsSortDirection2=>(NullsSortDirection2.First="first",NullsSortDirection2.Last="last",NullsSortDirection2))(NullsSortDirection||{}),OrderByClause=class extends SqlComponent{static{this.kind=Symbol("OrderByClause")}constructor(items){super(),this.order=items}},OrderByItem=class extends SqlComponent{static{this.kind=Symbol("OrderByItem")}constructor(expression,sortDirection,nullsPosition){super(),this.value=expression,this.sortDirection=sortDirection===null?"asc":sortDirection,this.nullsPosition=nullsPosition}},GroupByClause=class extends SqlComponent{static{this.kind=Symbol("GroupByClause")}constructor(expression){super(),this.grouping=expression}},HavingClause=class extends SqlComponent{static{this.kind=Symbol("HavingClause")}constructor(condition){super(),this.condition=condition}},TableSource=class extends SqlComponent{static{this.kind=Symbol("TableSource")}get namespaces(){return this.qualifiedName.namespaces}get table(){return this.qualifiedName.name instanceof IdentifierString?this.qualifiedName.name:new IdentifierString(this.qualifiedName.name.value)}get identifier(){return this.table}constructor(namespaces,table){super();let tbl=typeof table=="string"?new IdentifierString(table):table;this.qualifiedName=new QualifiedName(namespaces,tbl)}getSourceName(){return this.qualifiedName.namespaces&&this.qualifiedName.namespaces.length>0?this.qualifiedName.namespaces.map(namespace=>namespace.name).join(".")+"."+(this.qualifiedName.name instanceof RawString?this.qualifiedName.name.value:this.qualifiedName.name.name):this.qualifiedName.name instanceof RawString?this.qualifiedName.name.value:this.qualifiedName.name.name}},FunctionSource=class extends SqlComponent{static{this.kind=Symbol("FunctionSource")}constructor(name,argument,withOrdinality=!1){if(super(),typeof name=="object"&&name!==null&&"name"in name){let nameObj=name;this.qualifiedName=new QualifiedName(nameObj.namespaces,nameObj.name)}else this.qualifiedName=new QualifiedName(null,name);this.argument=argument,this.withOrdinality=withOrdinality}get namespaces(){return this.qualifiedName.namespaces}get name(){return this.qualifiedName.name}},ParenSource=class extends SqlComponent{static{this.kind=Symbol("ParenSource")}constructor(source){super(),this.source=source}},SubQuerySource=class extends SqlComponent{static{this.kind=Symbol("SubQuerySource")}constructor(query){super(),this.query=query}},SourceExpression=class extends SqlComponent{static{this.kind=Symbol("SourceExpression")}constructor(datasource,aliasExpression){super(),this.datasource=datasource,this.aliasExpression=aliasExpression}getAliasName(){return this.aliasExpression?this.aliasExpression.table.name:this.datasource instanceof TableSource?this.datasource.getSourceName():null}},JoinOnClause=class extends SqlComponent{static{this.kind=Symbol("JoinOnClause")}constructor(condition){super(),this.condition=condition}},JoinUsingClause=class extends SqlComponent{static{this.kind=Symbol("JoinUsingClause")}constructor(condition){super(),this.condition=condition}},JoinClause=class extends SqlComponent{static{this.kind=Symbol("JoinItem")}constructor(joinType,source,condition,lateral){super(),this.joinType=new RawString(joinType),this.source=source,this.condition=condition,this.lateral=lateral}getSourceAliasName(){return this.source.aliasExpression?this.source.aliasExpression.table.name:this.source instanceof TableSource?this.source.table.name:null}},FromClause=class extends SqlComponent{static{this.kind=Symbol("FromClause")}constructor(source,join){super(),this.source=source,this.joins=join}getSourceAliasName(){return this.source.aliasExpression?this.source.aliasExpression.table.name:this.source.datasource instanceof TableSource?this.source.datasource.table.name:null}getSources(){let sources=[this.source];if(this.joins)for(let join of this.joins)sources.push(join.source);return sources}},UsingClause=class extends SqlComponent{static{this.kind=Symbol("UsingClause")}constructor(sources){super(),this.sources=sources}getSources(){return[...this.sources]}},CommonTable=class extends SqlComponent{static{this.kind=Symbol("CommonTable")}constructor(query,aliasExpression,materialized){super(),this.query=query,this.materialized=materialized,typeof aliasExpression=="string"?this.aliasExpression=new SourceAliasExpression(aliasExpression,null):this.aliasExpression=aliasExpression}getSourceAliasName(){return this.aliasExpression.table.name}},WithClause=class extends SqlComponent{constructor(recursive,tables){super();this.trailingComments=null;this.globalComments=null;this.recursive=recursive,this.tables=tables}static{this.kind=Symbol("WithClause")}},LimitClause=class extends SqlComponent{static{this.kind=Symbol("LimitClause")}constructor(limit){super(),this.value=limit}},FetchType=(FetchType2=>(FetchType2.Next="next",FetchType2.First="first",FetchType2))(FetchType||{}),FetchUnit=(FetchUnit2=>(FetchUnit2.RowsOnly="rows only",FetchUnit2.Percent="percent",FetchUnit2.PercentWithTies="percent with ties",FetchUnit2))(FetchUnit||{}),OffsetClause=class extends SqlComponent{static{this.kind=Symbol("OffsetClause")}constructor(value){super(),this.value=value}},FetchClause=class extends SqlComponent{static{this.kind=Symbol("FetchClause")}constructor(expression){super(),this.expression=expression}},FetchExpression=class extends SqlComponent{static{this.kind=Symbol("FetchExpression")}constructor(type,count,unit){super(),this.type=type,this.count=count,this.unit=unit}},LockMode=(LockMode2=>(LockMode2.Update="update",LockMode2.Share="share",LockMode2.KeyShare="key share",LockMode2.NokeyUpdate="no key update",LockMode2))(LockMode||{}),ForClause=class extends SqlComponent{static{this.kind=Symbol("ForClause")}constructor(lockMode){super(),this.lockMode=lockMode}},SourceAliasExpression=class extends SqlComponent{static{this.kind=Symbol("SourceAliasExpression")}constructor(alias,columnAlias){super(),this.table=new IdentifierString(alias),this.columns=columnAlias!==null?columnAlias.map(alias2=>new IdentifierString(alias2)):null}},ReturningClause=class extends SqlComponent{static{this.kind=Symbol("ReturningClause")}constructor(items){super(),this.items=items}get columns(){return this.items.map(item=>item.value instanceof ColumnReference?item.value.column:new IdentifierString(item.identifier?.name??""))}},SetClause=class extends SqlComponent{static{this.kind=Symbol("SetClause")}constructor(items){super(),this.items=items.map(item=>item instanceof SetClauseItem?item:new SetClauseItem(item.column,item.value))}},SetClauseItem=class extends SqlComponent{static{this.kind=Symbol("SetClauseItem")}constructor(column,value){if(super(),typeof column=="object"&&column!==null&&"column"in column){let colObj=column,col=typeof colObj.column=="string"?new IdentifierString(colObj.column):colObj.column;this.qualifiedName=new QualifiedName(colObj.namespaces,col)}else{let col=typeof column=="string"?new IdentifierString(column):column;this.qualifiedName=new QualifiedName(null,col)}this.value=value}get namespaces(){return this.qualifiedName.namespaces}get column(){return this.qualifiedName.name instanceof IdentifierString?this.qualifiedName.name:new IdentifierString(this.qualifiedName.name.value)}getFullName(){return this.qualifiedName.toString()}},UpdateClause=class extends SqlComponent{static{this.kind=Symbol("UpdateClause")}constructor(source){super(),this.source=source}getSourceAliasName(){return this.source.aliasExpression?this.source.aliasExpression.table.name:this.source.datasource instanceof TableSource?this.source.datasource.table.name:null}},DeleteClause=class extends SqlComponent{static{this.kind=Symbol("DeleteClause")}constructor(source){super(),this.source=source}getSourceAliasName(){return this.source.getAliasName()}},InsertClause=class extends SqlComponent{constructor(source,columns){super(),this.source=source,columns&&columns.length>0?this.columns=columns.map(col=>typeof col=="string"?new IdentifierString(col):col):this.columns=null}};var POSTGRESQL_COMMAND_KEYWORDS_ALLOWED_AS_IDENTIFIER=new Set(["groups","rows","range","window","over","following","preceding","within","ordinality","lateral","recursive","materialized","partition"]),FullNameParser=class _FullNameParser{static parseFromLexeme(lexemes,index){let{identifiers,newIndex}=_FullNameParser.parseEscapedOrDotSeparatedIdentifiers(lexemes,index),{namespaces,name}=_FullNameParser.extractNamespacesAndName(identifiers),identifierString=new IdentifierString(name);if(newIndex>index){let lastLexeme=lexemes[newIndex-1];lastLexeme.positionedComments&&lastLexeme.positionedComments.length>0&&(identifierString.positionedComments=[...lastLexeme.positionedComments]),(!identifierString.positionedComments||identifierString.positionedComments.length===0)&&lastLexeme.comments&&lastLexeme.comments.length>0&&(identifierString.comments=[...lastLexeme.comments])}let lastTokenType=0;return newIndex>index&&(lastTokenType=lexemes[newIndex-1].type),{namespaces,name:identifierString,newIndex,lastTokenType}}static parse(str){let lexemes=new SqlTokenizer(str).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex=lexemes.length||!(lexemes[idx].type&64||lexemes[idx].type&128))throw new Error(`Expected identifier after '[' at position ${idx}`);if(identifiers.push(lexemes[idx].value),idx++,idx>=lexemes.length||lexemes[idx].value!=="]")throw new Error(`Expected closing ']' after identifier at position ${idx}`);idx++}else if(lexemes[idx].type&64)identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].type&2048)identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].type&8192)identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].type&1&&SQL_SPECIAL_VALUE_KEYWORD_SET.has(lexemes[idx].value.toLowerCase()))identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].type&128&&POSTGRESQL_COMMAND_KEYWORDS_ALLOWED_AS_IDENTIFIER.has(lexemes[idx].value.toLowerCase()))identifiers.push(lexemes[idx].value),idx++;else if(lexemes[idx].value==="*"){identifiers.push(lexemes[idx].value),idx++;break}if(idx({position:comment.position,comments:[...comment.comments]})):null,openLegacyComments=openLexeme.comments?[...openLexeme.comments]:null;idx+=1;let result2=SelectQueryParser.parseFromLexeme(lexemes,idx);if(idx=result2.newIndex,idx>=lexemes.length||lexemes[idx].type!==8)throw new Error(`Expected ')' at index ${idx}, but found ${lexemes[idx].value}`);let closingLexeme=lexemes[idx],closingLegacyComments=closingLexeme.comments,closingPositionedComments=closingLexeme.positionedComments;idx++;let value2=new InlineQuery(result2.value);if(openPositionedComments&&openPositionedComments.length>0){let beforeComments=openPositionedComments.filter(comment=>comment.position==="after"&&comment.comments.length>0).map(comment=>({position:"before",comments:[...comment.comments]}));beforeComments.length>0&&(value2.positionedComments=value2.positionedComments?[...beforeComments,...value2.positionedComments]:beforeComments)}else if(openLegacyComments&&openLegacyComments.length>0){let beforeCommentBlock={position:"before",comments:[...openLegacyComments]};value2.positionedComments=value2.positionedComments?[beforeCommentBlock,...value2.positionedComments]:[beforeCommentBlock]}if(closingPositionedComments&&closingPositionedComments.length>0){let afterComments=closingPositionedComments.filter(comment=>comment.position==="after"&&comment.comments.length>0).map(comment=>({position:comment.position,comments:[...comment.comments]}));afterComments.length>0&&(value2.positionedComments=value2.positionedComments?[...value2.positionedComments,...afterComments]:afterComments)}else closingLegacyComments&&closingLegacyComments.length>0&&(value2.comments=closingLegacyComments);return{value:value2,newIndex:idx}}let result=ValueParser.parseArgument(4,8,lexemes,index);idx=result.newIndex;let value=new ParenExpression(result.value),closingIndex=idx-1;if(closingIndex>=0&&closingIndex0){let afterComments=closingLexeme.positionedComments.filter(comment=>comment.position==="after"&&comment.comments.length>0).map(comment=>({position:comment.position,comments:[...comment.comments]}));afterComments.length>0&&(value.positionedComments=value.positionedComments?[...value.positionedComments,...afterComments]:afterComments)}else closingLexeme.comments&&closingLexeme.comments.length>0&&(value.comments=value.comments?value.comments.concat(closingLexeme.comments):[...closingLexeme.comments])}return{value,newIndex:idx}}};var UnaryExpressionParser=class{static parseFromLexeme(lexemes,index){let idx=index;if(idx0?value.positionedComments=operatorLexeme.positionedComments:operatorLexeme.comments&&operatorLexeme.comments.length>0&&(value.comments=operatorLexeme.comments),{value,newIndex:idx}}throw new Error(`Invalid unary expression at index ${index}: ${lexemes[index].value}`)}};var ParameterExpressionParser=class{static parseFromLexeme(lexemes,index){let idx=index,paramName=lexemes[idx].value;paramName.startsWith("${")&¶mName.endsWith("}")?paramName=paramName.slice(2,-1):paramName=paramName.slice(1);let value=new ParameterExpression(paramName);return idx++,{value,newIndex:idx}}};var StringSpecifierExpressionParser=class{static parseFromLexeme(lexemes,index){let idx=index,specifer=lexemes[idx].value;if(idx++,idx>=lexemes.length||lexemes[idx].type!==1)throw new Error(`Expected string literal after string specifier at index ${idx}`);let value=lexemes[idx].value;return idx++,{value:new StringSpecifierExpression(specifer,value),newIndex:idx}}};var CommandExpressionParser=class _CommandExpressionParser{static parseFromLexeme(lexemes,index){let idx=index,current=lexemes[idx];if(current.value==="case"){let caseKeywordComments=current.comments,caseKeywordPositionedComments=current.positionedComments;return idx++,this.parseCaseExpression(lexemes,idx,caseKeywordComments,caseKeywordPositionedComments)}else if(current.value==="case when"){let caseWhenKeywordComments=current.comments,caseWhenKeywordPositionedComments=current.positionedComments;return idx++,this.parseCaseWhenExpression(lexemes,idx,caseWhenKeywordComments,caseWhenKeywordPositionedComments)}return this.parseModifierUnaryExpression(lexemes,idx)}static parseModifierUnaryExpression(lexemes,index){let idx=index;if(idx0?result.positionedComments=caseKeywordPositionedComments:caseKeywordComments&&caseKeywordComments.length>0&&(result.positionedComments=[_CommandExpressionParser.convertLegacyToPositioned(caseKeywordComments,"before")]),{value:result,newIndex:idx}}static parseCaseWhenExpression(lexemes,index,caseWhenKeywordComments,caseWhenKeywordPositionedComments){let idx=index,casewhenResult=this.parseCaseConditionValuePair(lexemes,idx);idx=casewhenResult.newIndex;let caseWhenList=[casewhenResult.value],switchCaseResult=this.parseSwitchCaseArgument(lexemes,idx,caseWhenList);idx=switchCaseResult.newIndex;let result=new CaseExpression(null,switchCaseResult.value);return caseWhenKeywordPositionedComments&&caseWhenKeywordPositionedComments.length>0?result.positionedComments=caseWhenKeywordPositionedComments:caseWhenKeywordComments&&caseWhenKeywordComments.length>0&&(result.positionedComments=[_CommandExpressionParser.convertLegacyToPositioned(caseWhenKeywordComments,"before")]),{value:result,newIndex:idx}}static parseSwitchCaseArgument(lexemes,index,initialWhenThenList){let idx=index,whenThenList=[...initialWhenThenList];idx=this.parseAdditionalWhenClauses(lexemes,idx,whenThenList);let{elseValue,elseComments,newIndex:elseIndex}=this.parseElseClause(lexemes,idx);idx=elseIndex;let{endComments,newIndex:endIndex}=this.parseEndClause(lexemes,idx);if(idx=endIndex,whenThenList.length===0)throw new Error(`The CASE expression requires at least one WHEN clause (index ${idx})`);let switchCaseArg=new SwitchCaseArgument(whenThenList,elseValue);return this.applySwitchCaseComments(switchCaseArg,elseComments,endComments),{value:switchCaseArg,newIndex:idx}}static parseAdditionalWhenClauses(lexemes,index,whenThenList){let idx=index;for(;idx0&&allPositionedComments.push(...elseComments.positioned),elseComments?.legacy&&elseComments.legacy.length>0&&allLegacyComments.push(...elseComments.legacy),endComments?.positioned&&endComments.positioned.length>0&&allPositionedComments.push(...endComments.positioned),endComments?.legacy&&endComments.legacy.length>0&&allLegacyComments.push(...endComments.legacy),allPositionedComments.length>0?switchCaseArg.positionedComments=allPositionedComments:allLegacyComments.length>0&&(switchCaseArg.positionedComments=[_CommandExpressionParser.convertLegacyToPositioned(allLegacyComments,"after")])}static isCommandWithValue(lexeme,value){return(lexeme.type&128)!==0&&lexeme.value===value}static parseCaseConditionValuePair(lexemes,index){let idx=index,condition=ValueParser.parseFromLexeme(lexemes,idx);if(idx=condition.newIndex,idx>=lexemes.length||!(lexemes[idx].type&128)||lexemes[idx].value!=="then")throw new Error(`Expected 'then' after WHEN condition at index ${idx}`);let thenKeywordComments=lexemes[idx].comments,thenKeywordPositionedComments=lexemes[idx].positionedComments;idx++;let value=ValueParser.parseFromLexeme(lexemes,idx);idx=value.newIndex;let keyValuePair=new CaseKeyValuePair(condition.value,value.value);return thenKeywordPositionedComments&&thenKeywordPositionedComments.length>0?keyValuePair.positionedComments=thenKeywordPositionedComments:thenKeywordComments&&thenKeywordComments.length>0&&(keyValuePair.positionedComments=[_CommandExpressionParser.convertLegacyToPositioned(thenKeywordComments,"after")]),{value:keyValuePair,newIndex:idx}}static convertLegacyToPositioned(legacyComments,position="before"){return{position,comments:legacyComments}}};var OrderByClauseParser=class{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex=lexemes.length)return{value,newIndex:idx};let sortDirectionComments=null,sortDirection=null;if(idx0){sortDirectionComments=[];for(let posComment of token.positionedComments)posComment.comments&&posComment.comments.length>0&&sortDirectionComments.push(...posComment.comments)}token.comments&&token.comments.length>0&&(sortDirectionComments||(sortDirectionComments=[]),sortDirectionComments.push(...token.comments))}}let nullsSortDirection=idx>=lexemes.length?null:lexemes[idx].value==="nulls first"?(idx++,"first"):lexemes[idx].value==="nulls last"?(idx++,"last"):null;return sortDirectionComments&&sortDirectionComments.length>0&&(value.comments?value.comments.push(...sortDirectionComments):value.comments=sortDirectionComments),sortDirection===null&&nullsSortDirection===null?{value,newIndex:idx}:{value:new OrderByItem(value,sortDirection,nullsSortDirection),newIndex:idx}}};var PartitionByParser=class{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex=lexemes.length||lexemes[idx].type!==8)throw new Error(`Syntax error at position ${idx}: Missing closing parenthesis ')' for window frame. Each opening parenthesis must have a matching closing parenthesis.`);return idx++,{value:new WindowFrameExpression(partition,order,frameSpec),newIndex:idx}}static isFrameTypeKeyword(value){let lowerValue=value;return lowerValue==="rows"||lowerValue==="range"||lowerValue==="groups"}static parseFrameSpec(lexemes,index){let idx=index,frameTypeStr=lexemes[idx].value,frameType;switch(frameTypeStr){case"rows":frameType="rows";break;case"range":frameType="range";break;case"groups":frameType="groups";break;default:throw new Error(`Syntax error at position ${idx}: Invalid frame type "${lexemes[idx].value}". Expected one of: ROWS, RANGE, GROUPS.`)}if(idx++,idx=lexemes.length||lexemes[idx].value!=="and")throw new Error(`Syntax error at position ${idx}: Expected 'AND' keyword in BETWEEN clause.`);idx++;let endBoundResult=this.parseFrameBoundary(lexemes,idx),endBound=endBoundResult.value;return idx=endBoundResult.newIndex,{value:new WindowFrameSpec(frameType,startBound,endBound),newIndex:idx}}else{let boundaryResult=this.parseFrameBoundary(lexemes,idx),startBound=boundaryResult.value;return idx=boundaryResult.newIndex,{value:new WindowFrameSpec(frameType,startBound,null),newIndex:idx}}}static parseFrameBoundary(lexemes,index){let idx=index;if(idx=lexemes.length)throw new Error("Syntax error: Unexpected end of input after 'OVER' keyword. Expected either a window name or an opening parenthesis '('.");if(lexemes[idx].type&64){let name=lexemes[idx].value;return idx++,{value:new IdentifierString(name),newIndex:idx}}if(lexemes[idx].type&4)return WindowExpressionParser.parseFromLexeme(lexemes,idx);throw new Error(`Syntax error at position ${idx}: Expected a window name or opening parenthesis '(' after OVER keyword, but found "${lexemes[idx].value}".`)}};var ParseError=class _ParseError extends Error{constructor(message,index,context){super(message);this.index=index;this.context=context;this.name="ParseError"}static fromUnparsedLexemes(lexemes,index,messagePrefix){let start=Math.max(0,index-2),end=Math.min(lexemes.length,index+3),context=lexemes.slice(start,end).map((lexeme,idx)=>{let marker=idx+start===index?">":" ",typeName=TokenType[lexeme.type]||lexeme.type;return`${marker} ${idx+start}:${lexeme.value} [${typeName}]`}).join(` `),message=`${messagePrefix} Unparsed lexeme remains at index ${index}: ${lexemes[index].value} Context: -${context}`;return new _ParseError(message,index,context)}};function extractLexemeComments(lexeme){let before=[],after=[];if(!lexeme)return{before,after};if(lexeme.positionedComments&&lexeme.positionedComments.length>0)for(let positioned of lexeme.positionedComments)!positioned.comments||positioned.comments.length===0||(positioned.position==="before"?before.push(...positioned.comments):positioned.position==="after"&&after.push(...positioned.comments));else lexeme.comments&&lexeme.comments.length>0&&before.push(...lexeme.comments);return{before,after}}var FunctionExpressionParser=class{static{this.AGGREGATE_FUNCTIONS_WITH_ORDER_BY=new Set(["string_agg","array_agg","json_agg","jsonb_agg","json_object_agg","jsonb_object_agg","xmlagg"])}static parseArrayExpression(lexemes,index){let idx=index;if(idx+10&&value.addPositionedComments("after",closingComments),{value,newIndex:idx}}else{let value=new FunctionCall(namespaces,name.name,arg.value,null,withinGroup,withOrdinality,internalOrderBy,filterCondition);return closingComments&&closingComments.length>0&&value.addPositionedComments("after",closingComments),{value,newIndex:idx}}}else throw ParseError.fromUnparsedLexemes(lexemes,idx,`Expected opening parenthesis after function name '${name.name}'.`)}static parseKeywordFunction(lexemes,index,keywords2){let idx=index,fullNameResult=FullNameParser.parseFromLexeme(lexemes,idx),namespaces=fullNameResult.namespaces,name=fullNameResult.name;if(idx=fullNameResult.newIndex,idx=lexemes.length||lexemes[idx].value!=="within group")throw new Error(`Expected 'WITHIN GROUP' at index ${idx}`);if(idx++,idx>=lexemes.length||!(lexemes[idx].type&4))throw new Error(`Expected '(' after 'WITHIN GROUP' at index ${idx}`);idx++;let orderByResult=OrderByClauseParser.parseFromLexeme(lexemes,idx);if(idx=orderByResult.newIndex,idx>=lexemes.length||!(lexemes[idx].type&8))throw new Error(`Expected ')' after WITHIN GROUP ORDER BY clause at index ${idx}`);return idx++,{value:orderByResult.value,newIndex:idx}}static parseFilterClause(lexemes,index){let idx=index;if(idx>=lexemes.length||lexemes[idx].value!=="filter")throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected 'FILTER' keyword.");if(idx++,idx>=lexemes.length||!(lexemes[idx].type&4))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected '(' after FILTER.");if(idx++,idx>=lexemes.length||lexemes[idx].value!=="where")throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected 'WHERE' inside FILTER clause.");idx++;let conditionResult=ValueParser.parseFromLexeme(lexemes,idx);if(idx=conditionResult.newIndex,idx>=lexemes.length||!(lexemes[idx].type&8))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected ')' after FILTER predicate.");return idx++,{condition:conditionResult.value,newIndex:idx}}static parseAggregateArguments(lexemes,index){let idx=index,args=[],orderByClause=null;if(idx>=lexemes.length||!(lexemes[idx].type&4))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected opening parenthesis.");if(idx++,idx=lexemes.length||!(lexemes[idx].type&8))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected closing parenthesis.");let closingComments=this.getClosingComments(lexemes[idx]);return idx++,{arguments:args.length===1?args[0]:new ValueList(args),orderByClause,closingComments,newIndex:idx}}static parseArgumentWithComments(lexemes,index){let idx=index;if(idx>=lexemes.length||!(lexemes[idx].type&4))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected opening parenthesis.");let openParenToken=lexemes[idx];idx++;let args=[];if(idx=lexemes.length||!(lexemes[idx].type&8))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected closing parenthesis after wildcard '*'.");let closingComments2=this.getClosingComments(lexemes[idx]);return idx++,{value:wildcard,closingComments:closingComments2,newIndex:idx}}let result=ValueParser.parseFromLexeme(lexemes,idx);if(idx=result.newIndex,openParenToken.positionedComments&&openParenToken.positionedComments.length>0){let afterComments=openParenToken.positionedComments.filter(pc=>pc.position==="after");if(afterComments.length>0){let beforeComments=afterComments.map(pc=>({position:"before",comments:pc.comments}));result.value.positionedComments=[...beforeComments,...result.value.positionedComments||[]],result.value,"qualifiedName"in result.value&&result.value.qualifiedName&&"name"in result.value.qualifiedName&&result.value.qualifiedName.name&&(result.value.qualifiedName.name.positionedComments=null,result.value.qualifiedName.name)}}for(args.push(result.value);idx=lexemes.length||!(lexemes[idx].type&8))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected closing parenthesis.");let closingComments=this.getClosingComments(lexemes[idx]);return idx++,{value:args.length===1?args[0]:new ValueList(args),closingComments,newIndex:idx}}static getClosingComments(lexeme){if(!lexeme)return null;let commentInfo=extractLexemeComments(lexeme);return commentInfo.after.length>0?commentInfo.after:commentInfo.before.length>0?commentInfo.before:null}};var OperatorPrecedence=class{static{this.precedenceMap={or:1,and:2,"=":10,"!=":10,"<>":10,"<":10,"<=":10,">":10,">=":10,like:10,ilike:10,"not like":10,"not ilike":10,"similar to":10,"not similar to":10,in:10,"not in":10,is:10,"is not":10,"->":10,"->>":10,"#>":10,"#>>":10,"@>":10,"<@":10,"?":10,"?|":10,"?&":10,"~":10,"~*":10,"!~":10,"!~*":10,rlike:10,regexp:10,mod:30,xor:2,between:15,"not between":15,"+":20,"-":20,"*":30,"/":30,"%":30,"^":40,"::":50,"unary+":100,"unary-":100,not:100}}static getPrecedence(operator){let precedence=this.precedenceMap[operator.toLowerCase()];return precedence!==void 0?precedence:0}static hasHigherOrEqualPrecedence(operator1,operator2){return this.getPrecedence(operator1)>=this.getPrecedence(operator2)}static isLogicalOperator(operator){let op=operator.toLowerCase();return op==="and"||op==="or"}static isBetweenOperator(operator){let op=operator.toLowerCase();return op==="between"||op==="not between"}static isComparisonOperator(operator){let lowerOp=operator.toLowerCase();return["=","!=","<>","<",">","<=",">=","like","ilike","similar to","in","not in","->","->>","#>","#>>","@>","<@","?","?|","?&","~","~*","!~","!~*","rlike","regexp"].includes(lowerOp)}};var ValueParser=class{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex0&&!left.value.positionedComments?left.value.positionedComments=positionedComments:left.value.comments===null&&comment&&comment.length>0&&(left.value.comments=comment),idx=left.newIndex;let result=left.value,arrayAccessResult=this.parseArrayAccess(lexemes,idx,result);for(result=arrayAccessResult.value,idx=arrayAccessResult.newIndex;idx0&&(binaryExpr.operator.comments=operatorToken.comments),operatorToken.positionedComments&&operatorToken.positionedComments.length>0&&(binaryExpr.operator.positionedComments=operatorToken.positionedComments),result=binaryExpr}return{value:result,newIndex:idx}}static transferPositionedComments(lexeme,value){if(lexeme.positionedComments&&lexeme.positionedComments.length>0){let beforeComments=lexeme.positionedComments.filter(comment=>comment.position==="before"),afterComments=lexeme.positionedComments.filter(comment=>comment.position==="after");if(beforeComments.length>0){let clonedBefore=beforeComments.map(comment=>({position:comment.position,comments:[...comment.comments]}));value.positionedComments=value.positionedComments?[...clonedBefore,...value.positionedComments]:clonedBefore}if(afterComments.length>0){let clonedAfter=afterComments.map(comment=>({position:comment.position,comments:[...comment.comments]}));value.positionedComments=value.positionedComments?[...value.positionedComments,...clonedAfter]:clonedAfter}!beforeComments.length&&!afterComments.length&&!value.positionedComments&&(value.positionedComments=lexeme.positionedComments.map(comment=>({position:comment.position,comments:[...comment.comments]})));return}else value.comments===null&&lexeme.comments&&lexeme.comments.length>0&&(value.comments=lexeme.comments)}static parseItem(lexemes,index){let idx=index;if(idx>=lexemes.length)throw new Error(`Unexpected end of lexemes at index ${index}`);let current=lexemes[idx];if(current.type&64&¤t.type&2&¤t.type&8192){if(idx+1=lexemes.length)return this.transferPositionedComments(current,first.value),first;if(lexemes[first.newIndex].type&1){let literalIndex=first.newIndex,literalLexeme=lexemes[literalIndex],second=LiteralParser.parseFromLexeme(lexemes,literalIndex);this.transferPositionedComments(literalLexeme,second.value);let result=new UnaryExpression(lexemes[idx].value,second.value);return this.transferPositionedComments(current,result),{value:result,newIndex:second.newIndex}}return this.transferPositionedComments(current,first.value),first}else if(current.type&64){let{namespaces,name,newIndex}=FullNameParser.parseFromLexeme(lexemes,idx);if(lexemes[newIndex-1].type&2048){let result=FunctionExpressionParser.parseFromLexeme(lexemes,idx);return this.transferPositionedComments(current,result.value),result}else if(lexemes[newIndex-1].type&8192)if(newIndex0){let beforeComments=openParenToken.positionedComments.filter(pc=>pc.position==="after");beforeComments.length>0&&(wildcard.positionedComments=beforeComments.map(pc=>({position:"before",comments:pc.comments})))}else openParenToken.comments&&openParenToken.comments.length>0&&(wildcard.comments=openParenToken.comments);if(idx++,idx0){let afterComments=openParenToken.positionedComments.filter(pc=>pc.position==="after");if(afterComments.length>0){let beforeComments=afterComments.map(pc=>({position:"before",comments:pc.comments}));result.value.positionedComments?result.value.positionedComments=[...beforeComments,...result.value.positionedComments]:result.value.positionedComments=beforeComments}}else openParenToken.comments&&openParenToken.comments.length>0&&(result.value.comments?result.value.comments=openParenToken.comments.concat(result.value.comments):result.value.comments=openParenToken.comments);for(args.push(result.value);idx=lexemes.length)throw new Error(`Expected array index or slice after '[' at index ${idx-1}`);if(lexemes[idx].type&1024)throw new Error(`Empty array access brackets not supported at index ${idx}`);let startExpr=null,isSlice=!1;if(lexemes[idx].type&2&&lexemes[idx].value===":")isSlice=!0,idx++;else{let colonPrecedence=OperatorPrecedence.getPrecedence(":"),firstResult=this.parseExpressionWithPrecedence(lexemes,idx,colonPrecedence+1);startExpr=firstResult.value,idx=firstResult.newIndex,idx=lexemes.length||!(lexemes[idx].type&1024))throw new Error(`Expected ']' after array slice at index ${idx}`);idx++,result=new ArraySliceExpression(result,startExpr,endExpr)}else{if(!startExpr){let indexResult=this.parseFromLexeme(lexemes,idx);startExpr=indexResult.value,idx=indexResult.newIndex}if(idx>=lexemes.length||!(lexemes[idx].type&1024))throw new Error(`Expected ']' after array index at index ${idx}`);idx++,result=new ArrayIndexExpression(result,startExpr)}}return{value:result,newIndex:idx}}static isSqlServerBracketIdentifier(lexemes,bracketIndex){let idx=bracketIndex+1;if(idx>=lexemes.length)return!1;for(;idx=lexemes.length)return!1;let closingBracketIndex=idx;if(closingBracketIndex+1typeof col=="string"?new IdentifierString(col):col):null,this.values=params.values??null,this.defaultValues=params.defaultValues??!1,this.valuesLeadingComments=params.valuesLeadingComments?[...params.valuesLeadingComments]:null}addValuesLeadingComments(comments){if(!(!comments||comments.length===0)){this.valuesLeadingComments||(this.valuesLeadingComments=[]);for(let comment of comments)this.valuesLeadingComments.includes(comment)||this.valuesLeadingComments.push(comment)}}getValuesLeadingComments(){return this.valuesLeadingComments?[...this.valuesLeadingComments]:[]}},MergeDoNothingAction=class extends MergeAction{static{this.kind=Symbol("MergeDoNothingAction")}},MergeWhenClause=class extends SqlComponent{static{this.kind=Symbol("MergeWhenClause")}constructor(matchType,action,condition,options){super(),this.matchType=matchType,this.action=action,this.condition=condition??null,this.thenLeadingComments=options?.thenLeadingComments?[...options.thenLeadingComments]:null}addThenLeadingComments(comments){if(!(!comments||comments.length===0)){this.thenLeadingComments||(this.thenLeadingComments=[]);for(let comment of comments)this.thenLeadingComments.includes(comment)||this.thenLeadingComments.push(comment)}}getThenLeadingComments(){return this.thenLeadingComments?[...this.thenLeadingComments]:[]}},MergeQuery=class extends SqlComponent{static{this.kind=Symbol("MergeQuery")}constructor(params){super(),this.withClause=params.withClause??null,this.target=params.target,this.source=params.source,this.onCondition=params.onCondition,this.whenClauses=params.whenClauses,this.returningClause=params.returningClause??null}};var CTECollector=class{constructor(){this.commonTables=[];this.visitedNodes=new Set;this.isRootVisit=!0;this.handlers=new Map,this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleSelectQuery(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.handlers.set(ValuesQuery.kind,expr=>this.visitValuesQuery(expr)),this.handlers.set(InsertQuery.kind,expr=>this.visitInsertQuery(expr)),this.handlers.set(UpdateQuery.kind,expr=>this.visitUpdateQuery(expr)),this.handlers.set(DeleteQuery.kind,expr=>this.visitDeleteQuery(expr)),this.handlers.set(MergeQuery.kind,expr=>this.visitMergeQuery(expr)),this.handlers.set(WithClause.kind,expr=>this.visitWithClause(expr)),this.handlers.set(CommonTable.kind,expr=>this.visitCommonTable(expr)),this.handlers.set(SelectItem.kind,expr=>this.visitSelectItem(expr)),this.handlers.set(IdentifierString.kind,expr=>this.visitIdentifierString(expr)),this.handlers.set(RawString.kind,expr=>this.visitRawString(expr)),this.handlers.set(ColumnReference.kind,expr=>this.visitColumnReference(expr)),this.handlers.set(ParameterExpression.kind,expr=>this.visitParameterExpression(expr)),this.handlers.set(LiteralValue.kind,expr=>this.visitLiteralValue(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(TableSource.kind,expr=>this.visitTableSource(expr)),this.handlers.set(FunctionSource.kind,expr=>this.visitFunctionSource(expr)),this.handlers.set(ParenSource.kind,expr=>this.visitParenSource(expr)),this.handlers.set(SubQuerySource.kind,expr=>this.visitSubQuerySource(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(JoinClause.kind,expr=>this.visitJoinClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr)),this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(CaseKeyValuePair.kind,expr=>this.visitCaseKeyValuePair(expr)),this.handlers.set(SwitchCaseArgument.kind,expr=>this.visitSwitchCaseArgument(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(WindowFrameExpression.kind,expr=>this.visitWindowFrameExpression(expr)),this.handlers.set(WindowFrameSpec.kind,expr=>this.visitWindowFrameSpec(expr)),this.handlers.set(TypeValue.kind,expr=>this.visitTypeValue(expr)),this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(StringSpecifierExpression.kind,expr=>this.visitStringSpecifierExpression(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(ForClause.kind,expr=>this.visitForClause(expr)),this.handlers.set(OrderByItem.kind,expr=>this.visitOrderByItem(expr)),this.handlers.set(PartitionByClause.kind,expr=>this.visitPartitionByClause(expr))}getCommonTables(){return this.commonTables}reset(){this.commonTables=[],this.visitedNodes.clear()}collect(query){return this.visit(query),this.getCommonTables()}visit(arg){if(!this.isRootVisit){this.visitNode(arg);return}this.reset(),this.isRootVisit=!1;try{this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(this.visitedNodes.has(arg))return;this.visitedNodes.add(arg);let handler=this.handlers.get(arg.getKind());if(handler){handler(arg);return}let kindSymbol=arg.getKind()?.toString()||"unknown",constructor=arg.constructor?.name||"unknown";throw new Error(`[CTECollector] No handler for ${constructor} with kind ${kindSymbol}.`)}visitSimpleSelectQuery(query){if(query.fromClause&&query.fromClause.accept(this),query.whereClause&&query.whereClause.accept(this),query.groupByClause&&query.groupByClause.accept(this),query.havingClause&&query.havingClause.accept(this),query.orderByClause&&query.orderByClause.accept(this),query.windowClause)for(let win of query.windowClause.windows)win.accept(this);query.limitClause&&query.limitClause.accept(this),query.forClause&&query.forClause.accept(this),query.selectClause.accept(this),query.withClause&&query.withClause.accept(this)}visitBinarySelectQuery(query){query.left.accept(this),query.right.accept(this)}visitValuesQuery(query){for(let tuple of query.tuples)tuple.accept(this)}visitInsertQuery(query){query.selectQuery&&query.selectQuery.accept(this)}visitUpdateQuery(query){query.withClause&&query.withClause.accept(this),query.updateClause.source.accept(this),query.setClause.items.forEach(item=>item.value.accept(this)),query.fromClause&&query.fromClause.accept(this),query.whereClause&&query.whereClause.accept(this),query.returningClause&&this.visitReturningClause(query.returningClause)}visitDeleteQuery(query){query.withClause&&query.withClause.accept(this),query.deleteClause.source.accept(this),query.usingClause&&query.usingClause.sources.forEach(source=>source.accept(this)),query.whereClause&&query.whereClause.accept(this),query.returningClause&&this.visitReturningClause(query.returningClause)}visitMergeQuery(query){query.withClause&&query.withClause.accept(this),query.target.accept(this),query.source.accept(this),query.onCondition.accept(this);for(let clause of query.whenClauses)if(clause.condition&&clause.condition.accept(this),clause.action instanceof MergeUpdateAction)clause.action.setClause.items.forEach(item=>item.value.accept(this)),clause.action.whereClause&&clause.action.whereClause.accept(this);else if(clause.action instanceof MergeDeleteAction)clause.action.whereClause&&clause.action.whereClause.accept(this);else if(clause.action instanceof MergeInsertAction&&clause.action.values)clause.action.values.accept(this);else if(!(clause.action instanceof MergeInsertAction)){let actionName=clause.action?.constructor?.name??"UnknownMergeAction";throw new Error(`[CTECollector] Unsupported MERGE action type: ${actionName}.`)}query.returningClause&&this.visitReturningClause(query.returningClause)}visitReturningClause(clause){for(let item of clause.items)item.accept(this)}visitWithClause(withClause){for(let i=0;ithis.visitSimpleSelectQuery(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.handlers.set(ValuesQuery.kind,expr=>this.visitValuesQuery(expr)),this.handlers.set(InsertQuery.kind,expr=>this.visitInsertQuery(expr)),this.handlers.set(UpdateQuery.kind,expr=>this.visitUpdateQuery(expr)),this.handlers.set(DeleteQuery.kind,expr=>this.visitDeleteQuery(expr)),this.handlers.set(SelectItem.kind,expr=>this.visitSelectItem(expr)),this.handlers.set(IdentifierString.kind,expr=>this.visitIdentifierString(expr)),this.handlers.set(RawString.kind,expr=>this.visitRawString(expr)),this.handlers.set(ColumnReference.kind,expr=>this.visitColumnReference(expr)),this.handlers.set(ParameterExpression.kind,expr=>this.visitParameterExpression(expr)),this.handlers.set(LiteralValue.kind,expr=>this.visitLiteralValue(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(TableSource.kind,expr=>this.visitTableSource(expr)),this.handlers.set(ParenSource.kind,expr=>this.visitParenSource(expr)),this.handlers.set(SubQuerySource.kind,expr=>this.visitSubQuerySource(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(JoinClause.kind,expr=>this.visitJoinClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr)),this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(CaseKeyValuePair.kind,expr=>this.visitCaseKeyValuePair(expr)),this.handlers.set(SwitchCaseArgument.kind,expr=>this.visitSwitchCaseArgument(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(WindowFrameExpression.kind,expr=>this.visitWindowFrameExpression(expr)),this.handlers.set(WindowFrameSpec.kind,expr=>this.visitWindowFrameSpec(expr)),this.handlers.set(TypeValue.kind,expr=>this.visitTypeValue(expr)),this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(ArraySliceExpression.kind,expr=>this.visitArraySliceExpression(expr)),this.handlers.set(ArrayIndexExpression.kind,expr=>this.visitArrayIndexExpression(expr)),this.handlers.set(StringSpecifierExpression.kind,expr=>this.visitStringSpecifierExpression(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(ForClause.kind,expr=>this.visitForClause(expr)),this.handlers.set(OrderByItem.kind,expr=>this.visitOrderByItem(expr)),this.handlers.set(PartitionByClause.kind,expr=>this.visitPartitionByClause(expr))}reset(){this.visitedNodes.clear()}execute(arg){return this.reset(),this.visit(arg)}visit(arg){if(!this.isRootVisit)return this.visitNode(arg);this.reset(),this.isRootVisit=!1;try{return this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(this.visitedNodes.has(arg))return arg;this.visitedNodes.add(arg);let handler=this.handlers.get(arg.getKind());if(handler)return handler(arg);let kindSymbol=arg.getKind()?.toString()||"unknown",constructor=arg.constructor?.name||"unknown";throw new Error(`[CTEDisabler] No handler for ${constructor} with kind ${kindSymbol}.`)}visitSimpleSelectQuery(arg){return arg.withClause&&arg.withClause.tables.forEach(table=>{this.visit(table.query)}),arg.withClause=null,arg.selectClause=this.visit(arg.selectClause),arg.fromClause=arg.fromClause?this.visit(arg.fromClause):null,arg.whereClause=arg.whereClause?this.visit(arg.whereClause):null,arg.groupByClause=arg.groupByClause?this.visit(arg.groupByClause):null,arg.havingClause=arg.havingClause?this.visit(arg.havingClause):null,arg.orderByClause=arg.orderByClause?this.visit(arg.orderByClause):null,arg.windowClause&&(arg.windowClause=new WindowsClause(arg.windowClause.windows.map(w=>this.visit(w)))),arg.limitClause=arg.limitClause?this.visit(arg.limitClause):null,arg.forClause=arg.forClause?this.visit(arg.forClause):null,arg}visitBinarySelectQuery(query){return query.left=this.visit(query.left),query.right=this.visit(query.right),query}visitValuesQuery(query){let newTuples=query.tuples.map(tuple=>this.visit(tuple));return new ValuesQuery(newTuples)}visitInsertQuery(query){return query}visitUpdateQuery(query){return query}visitDeleteQuery(query){return query}visitSelectClause(clause){let newItems=clause.items.map(item=>this.visit(item));return new SelectClause(newItems,clause.distinct)}visitFromClause(clause){let newSource=this.visit(clause.source),newJoins=clause.joins?clause.joins.map(join=>this.visit(join)):null;return new FromClause(newSource,newJoins)}visitSubQuerySource(subQuery){let newQuery=this.visit(subQuery.query);return new SubQuerySource(newQuery)}visitInlineQuery(inlineQuery){let newQuery=this.visit(inlineQuery.selectQuery);return new InlineQuery(newQuery)}visitJoinClause(joinClause){let newSource=this.visit(joinClause.source),newCondition=joinClause.condition?this.visit(joinClause.condition):null;return new JoinClause(joinClause.joinType.value,newSource,newCondition,joinClause.lateral)}visitJoinOnClause(joinOn){let newCondition=this.visit(joinOn.condition);return new JoinOnClause(newCondition)}visitJoinUsingClause(joinUsing){let newCondition=this.visit(joinUsing.condition);return new JoinUsingClause(newCondition)}visitWhereClause(whereClause){let newCondition=this.visit(whereClause.condition);return new WhereClause(newCondition)}visitGroupByClause(clause){let newGrouping=clause.grouping.map(item=>this.visit(item));return new GroupByClause(newGrouping)}visitHavingClause(clause){let newCondition=this.visit(clause.condition);return new HavingClause(newCondition)}visitOrderByClause(clause){let newOrder=clause.order.map(item=>this.visit(item));return new OrderByClause(newOrder)}visitWindowFrameClause(clause){let newExpression=this.visit(clause.expression);return new WindowFrameClause(clause.name.name,newExpression)}visitLimitClause(clause){let newLimit=this.visit(clause.value);return new LimitClause(newLimit)}visitForClause(clause){return new ForClause(clause.lockMode)}visitParenExpression(expr){let newExpression=this.visit(expr.expression);return new ParenExpression(newExpression)}visitBinaryExpression(expr){let newLeft=this.visit(expr.left),newRight=this.visit(expr.right);return new BinaryExpression(newLeft,expr.operator.value,newRight)}visitUnaryExpression(expr){let newExpression=this.visit(expr.expression);return new UnaryExpression(expr.operator.value,newExpression)}visitCaseExpression(expr){let newCondition=expr.condition?this.visit(expr.condition):null,newSwitchCase=this.visit(expr.switchCase);return new CaseExpression(newCondition,newSwitchCase)}visitSwitchCaseArgument(switchCase){let newCases=switchCase.cases.map(caseItem=>this.visit(caseItem)),newElseValue=switchCase.elseValue?this.visit(switchCase.elseValue):null;return new SwitchCaseArgument(newCases,newElseValue)}visitCaseKeyValuePair(pair){let newKey=this.visit(pair.key),newValue=this.visit(pair.value);return new CaseKeyValuePair(newKey,newValue)}visitBetweenExpression(expr){let newExpression=this.visit(expr.expression),newLower=this.visit(expr.lower),newUpper=this.visit(expr.upper);return new BetweenExpression(newExpression,newLower,newUpper,expr.negated)}visitFunctionCall(func){let newArgument=func.argument?this.visit(func.argument):null,newOver=func.over?this.visit(func.over):null;return new FunctionCall(func.namespaces,func.name,newArgument,newOver)}visitArrayExpression(expr){let newExpression=this.visit(expr.expression);return new ArrayExpression(newExpression)}visitArrayQueryExpression(expr){let newQuery=this.visit(expr.query);return new ArrayQueryExpression(newQuery)}visitTupleExpression(expr){let newValues=expr.values.map(value=>this.visit(value));return new TupleExpression(newValues)}visitCastExpression(expr){let newInput=this.visit(expr.input),newCastType=this.visit(expr.castType);return new CastExpression(newInput,newCastType)}visitTypeValue(typeValue){let newArgument=typeValue.argument?this.visit(typeValue.argument):null;return new TypeValue(typeValue.namespaces,typeValue.name,newArgument)}visitSelectItem(item){let newValue=this.visit(item.value);return new SelectItem(newValue,item.identifier?.name)}visitIdentifierString(ident){return ident}visitRawString(raw){return raw}visitColumnReference(column){return column}visitSourceExpression(source){let newSource=this.visit(source.datasource),newAlias=source.aliasExpression;return new SourceExpression(newSource,newAlias)}visitTableSource(source){return source}visitParenSource(source){let newSource=this.visit(source.source);return new ParenSource(newSource)}visitParameterExpression(param){return param}visitWindowFrameExpression(expr){let newPartition=expr.partition?this.visit(expr.partition):null,newOrder=expr.order?this.visit(expr.order):null,newFrameSpec=expr.frameSpec?this.visit(expr.frameSpec):null;return new WindowFrameExpression(newPartition,newOrder,newFrameSpec)}visitWindowFrameSpec(spec){return spec}visitLiteralValue(value){return value}visitOrderByItem(item){let newValue=this.visit(item.value);return new OrderByItem(newValue,item.sortDirection,item.nullsPosition)}visitValueList(valueList){let newValues=valueList.values.map(value=>this.visit(value));return new ValueList(newValues)}visitArraySliceExpression(expr){return expr}visitArrayIndexExpression(expr){return expr}visitStringSpecifierExpression(expr){return expr}visitPartitionByClause(clause){let newValue=this.visit(clause.value);return new PartitionByClause(newValue)}};var TableSourceCollector=class{constructor(selectableOnly=!0,dedupe=!0){this.tableSources=[];this.visitedNodes=new Set;this.tableNameMap=new Map;this.cteNames=new Set;this.isRootVisit=!0;this.selectableOnly=selectableOnly,this.dedupe=dedupe,this.handlers=new Map,this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleSelectQuery(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.handlers.set(ValuesQuery.kind,expr=>this.visitValuesQuery(expr)),this.handlers.set(InsertQuery.kind,expr=>this.visitInsertQuery(expr)),this.handlers.set(UpdateQuery.kind,expr=>this.visitUpdateQuery(expr)),this.handlers.set(DeleteQuery.kind,expr=>this.visitDeleteQuery(expr)),this.handlers.set(MergeQuery.kind,expr=>this.visitMergeQuery(expr)),this.handlers.set(WithClause.kind,expr=>this.visitWithClause(expr)),this.handlers.set(CommonTable.kind,expr=>this.visitCommonTable(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(JoinClause.kind,expr=>this.visitJoinClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(TableSource.kind,expr=>this.visitTableSource(expr)),this.handlers.set(FunctionSource.kind,expr=>this.visitFunctionSource(expr)),this.handlers.set(ParenSource.kind,expr=>this.visitParenSource(expr)),this.handlers.set(SubQuerySource.kind,expr=>this.visitSubQuerySource(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),selectableOnly||(this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(OffsetClause.kind,expr=>this.visitOffsetClause(expr)),this.handlers.set(FetchClause.kind,expr=>this.visitFetchClause(expr)),this.handlers.set(ForClause.kind,expr=>this.visitForClause(expr)),this.handlers.set(OrderByItem.kind,expr=>this.visitOrderByItem(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(SelectItem.kind,expr=>this.visitSelectItem(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(CaseKeyValuePair.kind,expr=>this.visitCaseKeyValuePair(expr)),this.handlers.set(SwitchCaseArgument.kind,expr=>this.visitSwitchCaseArgument(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(StringSpecifierExpression.kind,expr=>this.visitStringSpecifierExpression(expr)))}getTableSources(){return this.tableSources}reset(){this.tableSources=[],this.tableNameMap.clear(),this.visitedNodes.clear(),this.cteNames.clear()}getTableIdentifier(source){return source.qualifiedName.namespaces&&source.qualifiedName.namespaces.length>0?source.qualifiedName.namespaces.map(ns=>ns.name).join(".")+"."+(source.qualifiedName.name instanceof RawString?source.qualifiedName.name.value:source.qualifiedName.name.name):source.qualifiedName.name instanceof RawString?source.qualifiedName.name.value:source.qualifiedName.name.name}collect(query){return this.visit(query),this.getTableSources()}visit(arg){if(!this.isRootVisit){this.visitNode(arg);return}this.reset(),this.isRootVisit=!1;try{this.selectableOnly||this.collectCTEs(arg),this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(this.visitedNodes.has(arg))return;this.visitedNodes.add(arg);let handler=this.handlers.get(arg.getKind());if(handler){handler(arg);return}}collectCTEs(query){let cteCollector=new CTECollector;cteCollector.visit(query);let commonTables=cteCollector.getCommonTables();for(let cte of commonTables)this.cteNames.add(cte.aliasExpression.table.name)}visitSimpleSelectQuery(query){if(query.fromClause&&query.fromClause.accept(this),!this.selectableOnly){if(query.withClause&&query.withClause.accept(this),query.whereClause&&query.whereClause.accept(this),query.groupByClause&&query.groupByClause.accept(this),query.havingClause&&query.havingClause.accept(this),query.orderByClause&&query.orderByClause.accept(this),query.windowClause)for(let win of query.windowClause.windows)win.accept(this);query.limitClause&&query.limitClause.accept(this),query.offsetClause&&query.offsetClause.accept(this),query.fetchClause&&query.fetchClause.accept(this),query.forClause&&query.forClause.accept(this),query.selectClause.accept(this)}}visitBinarySelectQuery(query){query.left.accept(this),query.right.accept(this)}visitValuesQuery(query){if(!this.selectableOnly)for(let tuple of query.tuples)tuple.accept(this)}visitInsertQuery(query){query.insertClause.source.accept(this),query.selectQuery&&query.selectQuery.accept(this),!this.selectableOnly&&query.returningClause&&this.visitReturningClause(query.returningClause)}visitUpdateQuery(query){!this.selectableOnly&&query.withClause&&query.withClause.accept(this),query.updateClause.source.accept(this),this.selectableOnly||query.setClause.items.forEach(item=>item.value.accept(this)),query.fromClause&&query.fromClause.accept(this),!this.selectableOnly&&query.whereClause&&query.whereClause.accept(this),!this.selectableOnly&&query.returningClause&&this.visitReturningClause(query.returningClause)}visitDeleteQuery(query){!this.selectableOnly&&query.withClause&&query.withClause.accept(this),query.deleteClause.source.accept(this),query.usingClause&&query.usingClause.sources.forEach(source=>source.accept(this)),!this.selectableOnly&&query.whereClause&&query.whereClause.accept(this),!this.selectableOnly&&query.returningClause&&this.visitReturningClause(query.returningClause)}visitMergeQuery(query){if(query.withClause&&this.registerCteNames(query.withClause),!this.selectableOnly&&query.withClause&&query.withClause.accept(this),query.target.accept(this),query.source.accept(this),!this.selectableOnly){query.onCondition.accept(this);for(let clause of query.whenClauses)if(clause.condition&&clause.condition.accept(this),clause.action instanceof MergeUpdateAction)clause.action.setClause.items.forEach(item=>item.value.accept(this)),clause.action.whereClause&&clause.action.whereClause.accept(this);else if(clause.action instanceof MergeDeleteAction)clause.action.whereClause&&clause.action.whereClause.accept(this);else if(clause.action instanceof MergeInsertAction&&clause.action.values)clause.action.values.accept(this);else if(!(clause.action instanceof MergeInsertAction)){let actionName=clause.action?.constructor?.name??"UnknownMergeAction";throw new Error(`[TableSourceCollector] Unsupported MERGE action type: ${actionName}.`)}}!this.selectableOnly&&query.returningClause&&this.visitReturningClause(query.returningClause)}visitReturningClause(clause){for(let item of clause.items)item.value.accept(this)}visitWithClause(withClause){if(!this.selectableOnly)for(let table of withClause.tables)table.accept(this)}visitCommonTable(commonTable){this.selectableOnly||commonTable.query.accept(this)}visitFromClause(fromClause){if(fromClause.source.accept(this),fromClause.joins)for(let join of fromClause.joins)join.accept(this)}visitSourceExpression(source){source.datasource.accept(this)}visitTableSource(source){let identifier=this.getTableIdentifier(source);if(!this.isCTETable(source.table.name)){if(this.dedupe){if(this.tableNameMap.has(identifier))return;this.tableNameMap.set(identifier,!0)}this.tableSources.push(source)}}visitFunctionSource(source){source.argument&&this.visitValueComponent(source.argument)}visitValueComponent(value){value.accept(this)}isCTETable(tableName){return this.cteNames.has(tableName)}registerCteNames(withClause){for(let table of withClause.tables)this.cteNames.add(table.aliasExpression.table.name)}visitParenSource(source){source.source.accept(this)}visitSubQuerySource(subQuery){this.selectableOnly||subQuery.query.accept(this)}visitInlineQuery(inlineQuery){this.selectableOnly||inlineQuery.selectQuery.accept(this)}visitJoinClause(joinClause){joinClause.source.accept(this),!this.selectableOnly&&joinClause.condition&&joinClause.condition.accept(this)}visitJoinOnClause(joinOn){this.selectableOnly||joinOn.condition.accept(this)}visitJoinUsingClause(joinUsing){this.selectableOnly||joinUsing.condition.accept(this)}visitWhereClause(whereClause){whereClause.condition.accept(this)}visitGroupByClause(clause){for(let item of clause.grouping)item.accept(this)}visitHavingClause(clause){clause.condition.accept(this)}visitOrderByClause(clause){for(let item of clause.order)item.accept(this)}visitWindowFrameClause(clause){clause.expression.accept(this)}visitLimitClause(clause){clause.value.accept(this)}visitOffsetClause(clause){clause.value.accept(this)}visitFetchClause(clause){clause.expression.accept(this)}visitForClause(_clause){}visitOrderByItem(item){item.value.accept(this)}visitSelectClause(clause){for(let item of clause.items)item.accept(this)}visitSelectItem(item){item.value.accept(this)}visitParenExpression(expr){expr.expression.accept(this)}visitBinaryExpression(expr){expr.left.accept(this),expr.right.accept(this)}visitUnaryExpression(expr){expr.expression.accept(this)}visitCaseExpression(expr){expr.condition&&expr.condition.accept(this),expr.switchCase.accept(this)}visitSwitchCaseArgument(switchCase){for(let caseItem of switchCase.cases)caseItem.accept(this);switchCase.elseValue&&switchCase.elseValue.accept(this)}visitCaseKeyValuePair(pair){pair.key.accept(this),pair.value.accept(this)}visitBetweenExpression(expr){expr.expression.accept(this),expr.lower.accept(this),expr.upper.accept(this)}visitFunctionCall(func){func.argument&&func.argument.accept(this),func.filterCondition&&func.filterCondition.accept(this),func.over&&func.over.accept(this)}visitArrayExpression(expr){expr.expression.accept(this)}visitArrayQueryExpression(expr){expr.query.accept(this)}visitTupleExpression(expr){for(let value of expr.values)value.accept(this)}visitCastExpression(expr){expr.input.accept(this),expr.castType.accept(this)}visitValueList(valueList){for(let value of valueList.values)value.accept(this)}visitStringSpecifierExpression(_expr){}};var HintClause=class extends SqlComponent{static{this.kind=Symbol("HintClause")}constructor(hintContent){super(),this.hintContent=hintContent}getFullHint(){return"/*+ "+this.hintContent+" */"}static isHintClause(value){let trimmed=value.trim();return trimmed.length>=5&&trimmed.substring(0,3)==="/*+"&&trimmed.substring(trimmed.length-2)==="*/"}static extractHintContent(hintClause){let trimmed=hintClause.trim();if(!this.isHintClause(trimmed))throw new Error("Not a valid hint clause: "+hintClause);return trimmed.slice(3,-2).trim()}};var SqlPrintToken=class{constructor(type,text="",containerType=""){this.innerTokens=[];this.type=type,this.text=text,this.containerType=containerType}markAsHeaderComment(){if(this.containerType!=="CommentBlock")throw new Error("Header comment flag must only be applied to CommentBlock containers.");this.isHeaderComment=!0}};var SelectQueryWithClauseHelper=class{static getWithClause(selectQuery){let owner=this.findClauseOwner(selectQuery);return owner?owner.withClause:null}static setWithClause(selectQuery,withClause){let owner=this.findClauseOwner(selectQuery);if(!owner)throw new Error("Cannot attach WITH clause to the provided select query.");owner.withClause=withClause}static detachWithClause(selectQuery){let owner=this.findClauseOwner(selectQuery);if(!owner)return null;let clause=owner.withClause;return owner.withClause=null,clause}static findClauseOwner(selectQuery){if(!selectQuery)return null;if(selectQuery instanceof SimpleSelectQuery||selectQuery instanceof ValuesQuery)return selectQuery;if(selectQuery instanceof BinarySelectQuery)return this.findClauseOwner(selectQuery.left);throw new Error("Unsupported select query type for WITH clause management.")}};var ParameterCollector=class{static collect(node){let result=[];function walk(n){if(!(!n||typeof n!="object")){n.constructor&&n.constructor.kind===ParameterExpression.kind&&result.push(n);for(let key of Object.keys(n)){let v=n[key];Array.isArray(v)?v.forEach(walk):v&&typeof v=="object"&&v.constructor&&v.constructor.kind&&walk(v)}}}return walk(node),result}};var IdentifierDecorator=class{constructor(identifierEscape){this.start=identifierEscape?.start??'"',this.end=identifierEscape?.end??'"'}decorate(text){return text=this.start+text+this.end,text}};var ParameterDecorator=class{constructor(options){this.prefix=options?.prefix??":",this.suffix=options?.suffix??"",this.style=options?.style??"named"}decorate(text,index){let paramText="";return this.style==="anonymous"?paramText=this.prefix:this.style==="indexed"?paramText=this.prefix+index:this.style==="named"&&(paramText=this.prefix+text+this.suffix),text=paramText,text}};var SelectValueCollector=class _SelectValueCollector{constructor(tableColumnResolver=null,initialCommonTables=null){this.selectValues=[];this.visitedNodes=new Set;this.isRootVisit=!0;this.tableColumnResolver=tableColumnResolver??null,this.commonTableCollector=new CTECollector,this.commonTables=[],this.initialCommonTables=initialCommonTables,this.handlers=new Map,this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleSelectQuery(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr))}getValues(){return this.selectValues}reset(){this.selectValues=[],this.visitedNodes.clear(),this.initialCommonTables?this.commonTables=this.initialCommonTables:this.commonTables=[]}collect(arg){this.visit(arg);let items=this.getValues();return this.reset(),items}visit(arg){if(!this.isRootVisit){this.visitNode(arg);return}this.reset(),this.isRootVisit=!1;try{this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(this.visitedNodes.has(arg))return;this.visitedNodes.add(arg);let handler=this.handlers.get(arg.getKind());if(handler){handler(arg);return}}visitSimpleSelectQuery(query){this.commonTables.length===0&&this.initialCommonTables===null&&(this.commonTables=this.commonTableCollector.collect(query)),query.selectClause&&query.selectClause.accept(this);let wildcards=this.selectValues.filter(item=>item.name==="*");if(wildcards.length===0)return;if(this.selectValues.some(item=>item.value instanceof ColumnReference&&item.value.namespaces===null)){query.fromClause&&this.processFromClause(query.fromClause,!0),this.selectValues=this.selectValues.filter(item=>item.name!=="*");return}let wildSourceNames=wildcards.filter(item=>item.value instanceof ColumnReference&&item.value.namespaces).map(item=>item.value.getNamespace());if(query.fromClause){let fromSourceName=query.fromClause.getSourceAliasName();if(fromSourceName&&wildSourceNames.includes(fromSourceName)&&this.processFromClause(query.fromClause,!1),query.fromClause.joins)for(let join of query.fromClause.joins){let joinSourceName=join.getSourceAliasName();joinSourceName&&wildSourceNames.includes(joinSourceName)&&this.processJoinClause(join)}}this.selectValues=this.selectValues.filter(item=>item.name!=="*")}processFromClause(clause,joinCascade){if(clause){let fromSourceName=clause.getSourceAliasName();if(this.processSourceExpression(fromSourceName,clause.source),clause.joins&&joinCascade)for(let join of clause.joins)this.processJoinClause(join)}}processJoinClause(clause){let sourceName=clause.getSourceAliasName();this.processSourceExpression(sourceName,clause.source)}processSourceExpression(sourceName,source){let commonTable=this.commonTables.find(item=>item.aliasExpression.table.name===sourceName);if(commonTable){let innerCommonTables=this.commonTables.filter(item=>item.aliasExpression.table.name!==sourceName);this.collectValuesFromCteQuery(commonTable.query,innerCommonTables).forEach(item=>{this.addSelectValueAsUnique(item.name,new ColumnReference(sourceName?[sourceName]:null,item.name))})}else new _SelectValueCollector(this.tableColumnResolver,this.commonTables).collect(source).forEach(item=>{this.addSelectValueAsUnique(item.name,new ColumnReference(sourceName?[sourceName]:null,item.name))})}visitSelectClause(clause){for(let item of clause.items)this.processSelectItem(item)}processSelectItem(item){if(item.identifier)this.addSelectValueAsUnique(item.identifier.name,item.value);else if(item.value instanceof ColumnReference){let columnName=item.value.column.name;columnName==="*"?this.selectValues.push({name:columnName,value:item.value}):this.addSelectValueAsUnique(columnName,item.value)}}visitSourceExpression(source){if(source.aliasExpression&&source.aliasExpression.columns){let sourceName=source.getAliasName();source.aliasExpression.columns.forEach(column=>{this.addSelectValueAsUnique(column.name,new ColumnReference(sourceName?[sourceName]:null,column.name))});return}else if(source.datasource instanceof TableSource){if(this.tableColumnResolver){let sourceName=source.datasource.getSourceName();this.tableColumnResolver(sourceName).forEach(column=>{this.addSelectValueAsUnique(column,new ColumnReference([sourceName],column))})}return}else if(source.datasource instanceof SubQuerySource){let sourceName=source.getAliasName();new _SelectValueCollector(this.tableColumnResolver,this.commonTables).collect(source.datasource.query).forEach(item=>{this.addSelectValueAsUnique(item.name,new ColumnReference(sourceName?[sourceName]:null,item.name))});return}else if(source.datasource instanceof ParenSource)return this.visit(source.datasource.source)}visitFromClause(clause){clause&&this.processFromClause(clause,!0)}addSelectValueAsUnique(name,value){this.selectValues.some(item=>item.name===name)||this.selectValues.push({name,value})}collectValuesFromCteQuery(query,commonTables){return this.isSelectQuery(query)?new _SelectValueCollector(this.tableColumnResolver,commonTables).collect(query):this.collectValuesFromReturning(query)}collectValuesFromReturning(query){return query instanceof InsertQuery||query instanceof UpdateQuery||query instanceof DeleteQuery||query instanceof MergeQuery?query.returningClause?this.extractValuesFromReturningClause(query.returningClause):[]:[]}extractValuesFromReturningClause(clause){let values=[];for(let item of clause.items){let name=item.identifier?.name??this.extractSelectItemName(item);name&&values.push({name,value:item.value})}return values}extractSelectItemName(item){return item.identifier?item.identifier.name:item.value instanceof ColumnReference?item.value.column.name:null}isSelectQuery(query){return"__selectQueryType"in query&&query.__selectQueryType==="SelectQuery"}};var ReferenceDefinition=class extends SqlComponent{static{this.kind=Symbol("ReferenceDefinition")}constructor(params){super(),this.targetTable=params.targetTable,this.columns=params.columns?[...params.columns]:null,this.matchType=params.matchType??null,this.onDelete=params.onDelete??null,this.onUpdate=params.onUpdate??null,this.deferrable=params.deferrable??null,this.initially=params.initially??null}},ColumnConstraintDefinition=class extends SqlComponent{static{this.kind=Symbol("ColumnConstraintDefinition")}constructor(params){super(),this.kind=params.kind,this.constraintName=params.constraintName,this.defaultValue=params.defaultValue,this.checkExpression=params.checkExpression,this.reference=params.reference,this.rawClause=params.rawClause}},TableConstraintDefinition=class extends SqlComponent{static{this.kind=Symbol("TableConstraintDefinition")}constructor(params){super(),this.kind=params.kind,this.constraintName=params.constraintName,this.columns=params.columns?[...params.columns]:null,this.reference=params.reference,this.checkExpression=params.checkExpression,this.rawClause=params.rawClause,this.deferrable=params.deferrable??null,this.initially=params.initially??null}},TableColumnDefinition=class extends SqlComponent{static{this.kind=Symbol("TableColumnDefinition")}constructor(params){super(),this.name=params.name,this.dataType=params.dataType,this.constraints=params.constraints?[...params.constraints]:[]}},CreateTableQuery=class extends SqlComponent{static{this.kind=Symbol("CreateTableQuery")}constructor(params){super(),this.tableName=new IdentifierString(params.tableName),this.namespaces=params.namespaces?[...params.namespaces]:null,this.isTemporary=params.isTemporary??!1,this.isUnlogged=params.isUnlogged??!1,this.ifNotExists=params.ifNotExists??!1,this.columns=params.columns?[...params.columns]:[],this.tableConstraints=params.tableConstraints?[...params.tableConstraints]:[],this.tableOptions=params.tableOptions??null,this.asSelectQuery=params.asSelectQuery,this.withDataOption=params.withDataOption??null}getSelectQuery(){let selectItems;this.asSelectQuery?selectItems=new SelectValueCollector().collect(this.asSelectQuery).map(val=>new SelectItem(val.value,val.name)):this.columns.length>0?selectItems=this.columns.map(column=>new SelectItem(new ColumnReference(null,column.name),column.name.name)):selectItems=[new SelectItem(new RawString("*"))];let qualifiedName=this.namespaces&&this.namespaces.length>0?[...this.namespaces,this.tableName.name].join("."):this.tableName.name;return new SimpleSelectQuery({selectClause:new SelectClause(selectItems),fromClause:new FromClause(new SourceExpression(new TableSource(null,qualifiedName),null),null)})}getCountQuery(){let qualifiedName=this.namespaces&&this.namespaces.length>0?[...this.namespaces,this.tableName.name].join("."):this.tableName.name;return new SimpleSelectQuery({selectClause:new SelectClause([new SelectItem(new FunctionCall(null,"count",new ColumnReference(null,"*"),null))]),fromClause:new FromClause(new SourceExpression(new TableSource(null,qualifiedName),null),null)})}};function cloneIdentifierWithComments(identifier){let clone=new IdentifierString(identifier.name);return identifier.positionedComments?clone.positionedComments=identifier.positionedComments.map(entry=>({position:entry.position,comments:[...entry.comments]})):identifier.comments&&identifier.comments.length>0&&(clone.comments=[...identifier.comments]),clone}var DropTableStatement=class extends SqlComponent{static{this.kind=Symbol("DropTableStatement")}constructor(params){super(),this.tables=params.tables.map(table=>new QualifiedName(table.namespaces,table.name)),this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},DropIndexStatement=class extends SqlComponent{static{this.kind=Symbol("DropIndexStatement")}constructor(params){super(),this.indexNames=params.indexNames.map(index=>new QualifiedName(index.namespaces,index.name)),this.ifExists=params.ifExists??!1,this.concurrently=params.concurrently??!1,this.behavior=params.behavior??null}},CreateSchemaStatement=class extends SqlComponent{static{this.kind=Symbol("CreateSchemaStatement")}constructor(params){super(),this.schemaName=new QualifiedName(params.schemaName.namespaces,params.schemaName.name),this.ifNotExists=params.ifNotExists??!1,this.authorization=params.authorization?cloneIdentifierWithComments(params.authorization):null}},DropSchemaStatement=class extends SqlComponent{static{this.kind=Symbol("DropSchemaStatement")}constructor(params){super(),this.schemaNames=params.schemaNames.map(schema=>new QualifiedName(schema.namespaces,schema.name)),this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},CommentOnStatement=class extends SqlComponent{static{this.kind=Symbol("CommentOnStatement")}constructor(params){super(),this.targetKind=params.targetKind,this.target=new QualifiedName(params.target.namespaces,params.target.name),this.comment=params.comment}},IndexColumnDefinition=class extends SqlComponent{static{this.kind=Symbol("IndexColumnDefinition")}constructor(params){super(),this.expression=params.expression,this.sortOrder=params.sortOrder??null,this.nullsOrder=params.nullsOrder??null,this.collation=params.collation??null,this.operatorClass=params.operatorClass??null}},CreateIndexStatement=class extends SqlComponent{static{this.kind=Symbol("CreateIndexStatement")}constructor(params){super(),this.unique=params.unique??!1,this.concurrently=params.concurrently??!1,this.ifNotExists=params.ifNotExists??!1,this.indexName=new QualifiedName(params.indexName.namespaces,params.indexName.name),this.tableName=new QualifiedName(params.tableName.namespaces,params.tableName.name),this.usingMethod=params.usingMethod??null,this.columns=params.columns.map(col=>new IndexColumnDefinition({expression:col.expression,sortOrder:col.sortOrder,nullsOrder:col.nullsOrder,collation:col.collation??null,operatorClass:col.operatorClass??null})),this.include=params.include?[...params.include]:null,this.where=params.where,this.withOptions=params.withOptions??null,this.tablespace=params.tablespace??null}},AlterTableAddConstraint=class extends SqlComponent{static{this.kind=Symbol("AlterTableAddConstraint")}constructor(params){super(),this.constraint=params.constraint,this.ifNotExists=params.ifNotExists??!1,this.notValid=params.notValid??!1}},AlterTableDropConstraint=class extends SqlComponent{static{this.kind=Symbol("AlterTableDropConstraint")}constructor(params){super(),this.constraintName=params.constraintName,this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},AlterTableDropColumn=class extends SqlComponent{static{this.kind=Symbol("AlterTableDropColumn")}constructor(params){super(),this.columnName=params.columnName,this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},AlterTableAddColumn=class extends SqlComponent{static{this.kind=Symbol("AlterTableAddColumn")}constructor(params){super(),this.column=params.column,this.ifNotExists=params.ifNotExists??!1}},AlterTableAlterColumnDefault=class extends SqlComponent{static{this.kind=Symbol("AlterTableAlterColumnDefault")}constructor(params){if(super(),this.columnName=params.columnName,this.setDefault=params.setDefault??null,this.dropDefault=params.dropDefault??!1,this.setDefault!==null&&this.dropDefault)throw new Error("[AlterTableAlterColumnDefault] Cannot set and drop a default at the same time.")}},AlterTableStatement=class extends SqlComponent{static{this.kind=Symbol("AlterTableStatement")}constructor(params){super(),this.table=new QualifiedName(params.table.namespaces,params.table.name),this.only=params.only??!1,this.ifExists=params.ifExists??!1,this.actions=params.actions.map(action=>action)}},DropConstraintStatement=class extends SqlComponent{static{this.kind=Symbol("DropConstraintStatement")}constructor(params){super(),this.constraintName=params.constraintName,this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},ExplainOption=class extends SqlComponent{static{this.kind=Symbol("ExplainOption")}constructor(params){super(),this.name=cloneIdentifierWithComments(params.name),this.value=params.value??null}},ExplainStatement=class extends SqlComponent{static{this.kind=Symbol("ExplainStatement")}constructor(params){super(),this.options=params.options?params.options.map(option=>new ExplainOption(option)):null,this.statement=params.statement}},AnalyzeStatement=class extends SqlComponent{static{this.kind=Symbol("AnalyzeStatement")}constructor(params){super(),this.verbose=params?.verbose??!1,this.target=params?.target?new QualifiedName(params.target.namespaces,params.target.name):null,params?.columns?this.columns=params.columns.map(cloneIdentifierWithComments):this.columns=null}},CreateSequenceStatement=class extends SqlComponent{static{this.kind=Symbol("CreateSequenceStatement")}constructor(params){super(),this.sequenceName=new QualifiedName(params.sequenceName.namespaces,params.sequenceName.name),this.ifNotExists=params.ifNotExists??!1,this.clauses=params.clauses?[...params.clauses]:[]}},AlterSequenceStatement=class extends SqlComponent{static{this.kind=Symbol("AlterSequenceStatement")}constructor(params){super(),this.sequenceName=new QualifiedName(params.sequenceName.namespaces,params.sequenceName.name),this.ifExists=params.ifExists??!1,this.clauses=params.clauses?[...params.clauses]:[]}},VacuumStatement=class extends SqlComponent{static{this.kind=Symbol("VacuumStatement")}constructor(params){super(),this.full=params?.full??!1,this.verbose=params?.verbose??!1,this.freeze=params?.freeze??!1,this.analyze=params?.analyze??!1,this.targets=params?.targets?[...params.targets]:[]}},ReindexStatement=class extends SqlComponent{static{this.kind=Symbol("ReindexStatement")}constructor(params){super(),this.concurrently=params?.concurrently??!1,this.targets=params?.targets?[...params.targets]:[],this.targetType=params?.targetType??null}},ClusterStatement=class extends SqlComponent{static{this.kind=Symbol("ClusterStatement")}constructor(params){super(),this.verbose=params?.verbose??!1,this.table=params?.table??null,this.index=params?.index??null}},CheckpointStatement=class extends SqlComponent{static{this.kind=Symbol("CheckpointStatement")}constructor(params){super(),this.options=params?.options?[...params.options]:[]}};var PRESETS={mysql:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous",constraintStyle:"mysql"},postgres:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"$",parameterStyle:"indexed",castStyle:"postgres",constraintStyle:"postgres"},postgresWithNamedParams:{identifierEscape:{start:'"',end:'"'},parameterSymbol:":",parameterStyle:"named",castStyle:"postgres",constraintStyle:"postgres"},sqlserver:{identifierEscape:{start:"[",end:"]"},parameterSymbol:"@",parameterStyle:"named",constraintStyle:"postgres"},sqlite:{identifierEscape:{start:'"',end:'"'},parameterSymbol:":",parameterStyle:"named",constraintStyle:"postgres"},oracle:{identifierEscape:{start:'"',end:'"'},parameterSymbol:":",parameterStyle:"named",constraintStyle:"postgres"},clickhouse:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous",constraintStyle:"postgres"},firebird:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},db2:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},snowflake:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},cloudspanner:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"@",parameterStyle:"named"},duckdb:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},cockroachdb:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"$",parameterStyle:"indexed",castStyle:"postgres"},athena:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},bigquery:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"@",parameterStyle:"named"},hive:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous"},mariadb:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous"},redshift:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"$",parameterStyle:"indexed",castStyle:"postgres"},flinksql:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous"},mongodb:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"}},SqlPrintTokenParser=class _SqlPrintTokenParser{constructor(options){this.handlers=new Map;this.index=1;this.joinConditionContexts=[];options?.preset&&(options={...options.preset,...options}),this.parameterDecorator=new ParameterDecorator({prefix:typeof options?.parameterSymbol=="string"?options.parameterSymbol:options?.parameterSymbol?.start??":",suffix:typeof options?.parameterSymbol=="object"?options.parameterSymbol.end:"",style:options?.parameterStyle??"named"}),this.identifierDecorator=new IdentifierDecorator({start:options?.identifierEscape?.start??'"',end:options?.identifierEscape?.end??'"'}),this.castStyle=options?.castStyle??"standard",this.constraintStyle=options?.constraintStyle??"postgres",this.normalizeJoinConditionOrder=options?.joinConditionOrderByDeclaration??!1,this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(ColumnReference.kind,expr=>this.visitColumnReference(expr)),this.handlers.set(QualifiedName.kind,expr=>this.visitQualifiedName(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(LiteralValue.kind,expr=>this.visitLiteralValue(expr)),this.handlers.set(ParameterExpression.kind,expr=>this.visitParameterExpression(expr)),this.handlers.set(SwitchCaseArgument.kind,expr=>this.visitSwitchCaseArgument(expr)),this.handlers.set(CaseKeyValuePair.kind,expr=>this.visitCaseKeyValuePair(expr)),this.handlers.set(RawString.kind,expr=>this.visitRawString(expr)),this.handlers.set(IdentifierString.kind,expr=>this.visitIdentifierString(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(ArraySliceExpression.kind,expr=>this.visitArraySliceExpression(expr)),this.handlers.set(ArrayIndexExpression.kind,expr=>this.visitArrayIndexExpression(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(StringSpecifierExpression.kind,expr=>this.visitStringSpecifierExpression(expr)),this.handlers.set(TypeValue.kind,expr=>this.visitTypeValue(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),this.handlers.set(WindowFrameExpression.kind,expr=>this.visitWindowFrameExpression(expr)),this.handlers.set(WindowFrameSpec.kind,expr=>this.visitWindowFrameSpec(expr)),this.handlers.set(WindowFrameBoundStatic.kind,expr=>this.visitWindowFrameBoundStatic(expr)),this.handlers.set(WindowFrameBoundaryValue.kind,expr=>this.visitWindowFrameBoundaryValue(expr)),this.handlers.set(PartitionByClause.kind,expr=>this.visitPartitionByClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(OrderByItem.kind,expr=>this.visitOrderByItem(expr)),this.handlers.set(SelectItem.kind,expr=>this.visitSelectItem(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(Distinct.kind,expr=>this.visitDistinct(expr)),this.handlers.set(DistinctOn.kind,expr=>this.visitDistinctOn(expr)),this.handlers.set(HintClause.kind,expr=>this.visitHintClause(expr)),this.handlers.set(TableSource.kind,expr=>this.visitTableSource(expr)),this.handlers.set(FunctionSource.kind,expr=>this.visitFunctionSource(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(SourceAliasExpression.kind,expr=>this.visitSourceAliasExpression(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(JoinClause.kind,expr=>this.visitJoinClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr)),this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(WindowsClause.kind,expr=>this.visitWindowClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(OffsetClause.kind,expr=>this.visitOffsetClause(expr)),this.handlers.set(FetchClause.kind,expr=>this.visitFetchClause(expr)),this.handlers.set(FetchExpression.kind,expr=>this.visitFetchExpression(expr)),this.handlers.set(ForClause.kind,expr=>this.visitForClause(expr)),this.handlers.set(WithClause.kind,expr=>this.visitWithClause(expr)),this.handlers.set(CommonTable.kind,expr=>this.visitCommonTable(expr)),this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleQuery(expr)),this.handlers.set(SubQuerySource.kind,expr=>this.visitSubQuerySource(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.handlers.set(ValuesQuery.kind,expr=>this.visitValuesQuery(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(InsertQuery.kind,expr=>this.visitInsertQuery(expr)),this.handlers.set(InsertClause.kind,expr=>this.visitInsertClause(expr)),this.handlers.set(UpdateQuery.kind,expr=>this.visitUpdateQuery(expr)),this.handlers.set(UpdateClause.kind,expr=>this.visitUpdateClause(expr)),this.handlers.set(DeleteQuery.kind,expr=>this.visitDeleteQuery(expr)),this.handlers.set(DeleteClause.kind,expr=>this.visitDeleteClause(expr)),this.handlers.set(UsingClause.kind,expr=>this.visitUsingClause(expr)),this.handlers.set(SetClause.kind,expr=>this.visitSetClause(expr)),this.handlers.set(SetClauseItem.kind,expr=>this.visitSetClauseItem(expr)),this.handlers.set(ReturningClause.kind,expr=>this.visitReturningClause(expr)),this.handlers.set(CreateTableQuery.kind,expr=>this.visitCreateTableQuery(expr)),this.handlers.set(TableColumnDefinition.kind,expr=>this.visitTableColumnDefinition(expr)),this.handlers.set(ColumnConstraintDefinition.kind,expr=>this.visitColumnConstraintDefinition(expr)),this.handlers.set(TableConstraintDefinition.kind,expr=>this.visitTableConstraintDefinition(expr)),this.handlers.set(ReferenceDefinition.kind,expr=>this.visitReferenceDefinition(expr)),this.handlers.set(CreateIndexStatement.kind,expr=>this.visitCreateIndexStatement(expr)),this.handlers.set(CreateSchemaStatement.kind,expr=>this.visitCreateSchemaStatement(expr)),this.handlers.set(IndexColumnDefinition.kind,expr=>this.visitIndexColumnDefinition(expr)),this.handlers.set(CreateSequenceStatement.kind,expr=>this.visitCreateSequenceStatement(expr)),this.handlers.set(AlterSequenceStatement.kind,expr=>this.visitAlterSequenceStatement(expr)),this.handlers.set(DropTableStatement.kind,expr=>this.visitDropTableStatement(expr)),this.handlers.set(DropIndexStatement.kind,expr=>this.visitDropIndexStatement(expr)),this.handlers.set(DropSchemaStatement.kind,expr=>this.visitDropSchemaStatement(expr)),this.handlers.set(CommentOnStatement.kind,expr=>this.visitCommentOnStatement(expr)),this.handlers.set(AlterTableStatement.kind,expr=>this.visitAlterTableStatement(expr)),this.handlers.set(AlterTableAddConstraint.kind,expr=>this.visitAlterTableAddConstraint(expr)),this.handlers.set(AlterTableDropConstraint.kind,expr=>this.visitAlterTableDropConstraint(expr)),this.handlers.set(AlterTableAddColumn.kind,expr=>this.visitAlterTableAddColumn(expr)),this.handlers.set(AlterTableDropColumn.kind,expr=>this.visitAlterTableDropColumn(expr)),this.handlers.set(AlterTableAlterColumnDefault.kind,expr=>this.visitAlterTableAlterColumnDefault(expr)),this.handlers.set(DropConstraintStatement.kind,expr=>this.visitDropConstraintStatement(expr)),this.handlers.set(ExplainStatement.kind,expr=>this.visitExplainStatement(expr)),this.handlers.set(AnalyzeStatement.kind,expr=>this.visitAnalyzeStatement(expr)),this.handlers.set(MergeQuery.kind,expr=>this.visitMergeQuery(expr)),this.handlers.set(MergeWhenClause.kind,expr=>this.visitMergeWhenClause(expr)),this.handlers.set(MergeUpdateAction.kind,expr=>this.visitMergeUpdateAction(expr)),this.handlers.set(MergeDeleteAction.kind,expr=>this.visitMergeDeleteAction(expr)),this.handlers.set(MergeInsertAction.kind,expr=>this.visitMergeInsertAction(expr)),this.handlers.set(MergeDoNothingAction.kind,expr=>this.visitMergeDoNothingAction(expr))}static{this.SPACE_TOKEN=new SqlPrintToken(10," ")}static{this.COMMA_TOKEN=new SqlPrintToken(3,",")}static{this.ARGUMENT_SPLIT_COMMA_TOKEN=new SqlPrintToken(11,",")}static{this.PAREN_OPEN_TOKEN=new SqlPrintToken(4,"(")}static{this.PAREN_CLOSE_TOKEN=new SqlPrintToken(4,")")}static{this.DOT_TOKEN=new SqlPrintToken(8,".")}static{this._selfHandlingComponentTypes=null}static getSelfHandlingComponentTypes(){return this._selfHandlingComponentTypes||(this._selfHandlingComponentTypes=new Set([SimpleSelectQuery.kind,SelectItem.kind,CaseKeyValuePair.kind,SwitchCaseArgument.kind,ColumnReference.kind,LiteralValue.kind,ParameterExpression.kind,TableSource.kind,SourceAliasExpression.kind,TypeValue.kind,FunctionCall.kind,IdentifierString.kind,QualifiedName.kind])),this._selfHandlingComponentTypes}visitBinarySelectQuery(arg){let token=new SqlPrintToken(0,"");if(arg.positionedComments&&arg.positionedComments.length>0)this.addPositionedCommentsToToken(token,arg),arg.positionedComments=null;else if(arg.headerComments&&arg.headerComments.length>0){if(this.shouldMergeHeaderComments(arg.headerComments)){let mergedHeaderComment=this.createHeaderMultiLineCommentBlock(arg.headerComments);token.innerTokens.push(mergedHeaderComment)}else{let headerCommentBlocks=this.createCommentBlocks(arg.headerComments,!0);token.innerTokens.push(...headerCommentBlocks)}token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN)}return token.innerTokens.push(this.visit(arg.left)),token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,arg.operator.value,"BinarySelectQueryOperator")),token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(this.visit(arg.right)),token}visitCreateSchemaStatement(arg){let keywordParts=["create","schema"];arg.ifNotExists&&keywordParts.push("if not exists");let token=new SqlPrintToken(1,keywordParts.join(" "),"CreateSchemaStatement");return token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(arg.schemaName.accept(this)),arg.authorization&&(token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,"authorization")),token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(arg.authorization.accept(this))),token}static commaSpaceTokens(){return[_SqlPrintTokenParser.COMMA_TOKEN,_SqlPrintTokenParser.SPACE_TOKEN]}static argumentCommaSpaceTokens(){return[_SqlPrintTokenParser.ARGUMENT_SPLIT_COMMA_TOKEN,_SqlPrintTokenParser.SPACE_TOKEN]}visitQualifiedName(arg){let token=new SqlPrintToken(0,"","QualifiedName");if(arg.namespaces)for(let i=0;i0&&token.innerTokens.push(..._SqlPrintTokenParser.commaSpaceTokens()),token.innerTokens.push(this.visit(arg.order[i]));return token}visitOrderByItem(arg){let token=new SqlPrintToken(0,"","OrderByItem");return token.innerTokens.push(this.visit(arg.value)),arg.sortDirection&&arg.sortDirection!=="asc"&&(token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,"desc"))),arg.nullsPosition&&(arg.nullsPosition==="first"?(token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,"nulls first"))):arg.nullsPosition==="last"&&(token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,"nulls last")))),token}parse(arg){this.index=1;let token=this.visit(arg),paramsRaw=ParameterCollector.collect(arg).sort((a,b)=>(a.index??0)-(b.index??0)),style=this.parameterDecorator.style;if(style==="named"){let paramsObj={};for(let p of paramsRaw){let key=p.name.value;if(paramsObj.hasOwnProperty(key)){if(paramsObj[key]!==p.value)throw new Error(`Duplicate parameter name '${key}' with different values detected during query composition.`);continue}paramsObj[key]=p.value}return{token,params:paramsObj}}else if(style==="indexed"){let paramsArr=paramsRaw.map(p=>p.value);return{token,params:paramsArr}}else if(style==="anonymous"){let paramsArr=paramsRaw.map(p=>p.value);return{token,params:paramsArr}}return{token,params:[]}}componentHandlesOwnComments(component){return"handlesOwnComments"in component&&typeof component.handlesOwnComments=="function"?component.handlesOwnComments():_SqlPrintTokenParser.getSelfHandlingComponentTypes().has(component.getKind())}visit(arg){let handler=this.handlers.get(arg.getKind());if(handler){let token=handler(arg);return this.componentHandlesOwnComments(arg)||this.addComponentComments(token,arg),token}throw new Error(`[SqlPrintTokenParser] No handler for kind: ${arg.getKind().toString()}`)}hasPositionedComments(component){return(component.positionedComments?.length??0)>0}hasLegacyComments(component){return(component.comments?.length??0)>0}addComponentComments(token,component){this.hasPositionedComments(component)?this.addPositionedCommentsToToken(token,component):this.hasLegacyComments(component)&&this.addCommentsToToken(token,component.comments)}addPositionedCommentsToToken(token,component){if(!this.hasPositionedComments(component))return;let beforeComments=component.getPositionedComments("before");if(beforeComments.length>0){let commentBlocks=this.createCommentBlocks(beforeComments);for(let i=commentBlocks.length-1;i>=0;i--)token.innerTokens.unshift(commentBlocks[i])}let afterComments=component.getPositionedComments("after");if(afterComments.length>0){let commentBlocks=this.createCommentBlocks(afterComments);for(let commentBlock of commentBlocks)token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(commentBlock)}let componentsWithDuplicationIssues=["CaseExpression","SwitchCaseArgument","CaseKeyValuePair","SelectClause","LiteralValue","IdentifierString","DistinctOn","SourceAliasExpression","SimpleSelectQuery","WhereClause"];token.containerType&&componentsWithDuplicationIssues.includes(token.containerType)&&(component.positionedComments=null)}addCommentsToToken(token,comments){if(!comments?.length)return;let commentBlocks=this.createCommentBlocks(comments);this.insertCommentBlocksWithSpacing(token,commentBlocks)}createInlineCommentSequence(comments){let commentTokens=[];for(let i=0;i0)for(let positioned of lexeme.positionedComments)!positioned.comments||positioned.comments.length===0||(positioned.position==="before"?before.push(...positioned.comments):positioned.position==="after"&&after.push(...positioned.comments));else lexeme.comments&&lexeme.comments.length>0&&before.push(...lexeme.comments);return{before,after}}var FunctionExpressionParser=class{static{this.AGGREGATE_FUNCTIONS_WITH_ORDER_BY=new Set(["string_agg","array_agg","json_agg","jsonb_agg","json_object_agg","jsonb_object_agg","xmlagg"])}static parseArrayExpression(lexemes,index){let idx=index;if(idx+10&&value.addPositionedComments("after",closingComments),{value,newIndex:idx}}else{let value=new FunctionCall(namespaces,name.name,arg.value,null,withinGroup,withOrdinality,internalOrderBy,filterCondition);return closingComments&&closingComments.length>0&&value.addPositionedComments("after",closingComments),{value,newIndex:idx}}}else throw ParseError.fromUnparsedLexemes(lexemes,idx,`Expected opening parenthesis after function name '${name.name}'.`)}static parseKeywordFunction(lexemes,index,keywords2){let idx=index,fullNameResult=FullNameParser.parseFromLexeme(lexemes,idx),namespaces=fullNameResult.namespaces,name=fullNameResult.name;if(idx=fullNameResult.newIndex,idx=lexemes.length||lexemes[idx].value!=="within group")throw new Error(`Expected 'WITHIN GROUP' at index ${idx}`);if(idx++,idx>=lexemes.length||!(lexemes[idx].type&4))throw new Error(`Expected '(' after 'WITHIN GROUP' at index ${idx}`);idx++;let orderByResult=OrderByClauseParser.parseFromLexeme(lexemes,idx);if(idx=orderByResult.newIndex,idx>=lexemes.length||!(lexemes[idx].type&8))throw new Error(`Expected ')' after WITHIN GROUP ORDER BY clause at index ${idx}`);return idx++,{value:orderByResult.value,newIndex:idx}}static parseFilterClause(lexemes,index){let idx=index;if(idx>=lexemes.length||lexemes[idx].value!=="filter")throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected 'FILTER' keyword.");if(idx++,idx>=lexemes.length||!(lexemes[idx].type&4))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected '(' after FILTER.");if(idx++,idx>=lexemes.length||lexemes[idx].value!=="where")throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected 'WHERE' inside FILTER clause.");idx++;let conditionResult=ValueParser.parseFromLexeme(lexemes,idx);if(idx=conditionResult.newIndex,idx>=lexemes.length||!(lexemes[idx].type&8))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected ')' after FILTER predicate.");return idx++,{condition:conditionResult.value,newIndex:idx}}static parseAggregateArguments(lexemes,index){let idx=index,args=[],orderByClause=null;if(idx>=lexemes.length||!(lexemes[idx].type&4))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected opening parenthesis.");if(idx++,idx=lexemes.length||!(lexemes[idx].type&8))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected closing parenthesis.");let closingComments=this.getClosingComments(lexemes[idx]);return idx++,{arguments:args.length===1?args[0]:new ValueList(args),orderByClause,closingComments,newIndex:idx}}static parseArgumentWithComments(lexemes,index){let idx=index;if(idx>=lexemes.length||!(lexemes[idx].type&4))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected opening parenthesis.");let openParenToken=lexemes[idx];idx++;let args=[];if(idx=lexemes.length||!(lexemes[idx].type&8))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected closing parenthesis after wildcard '*'.");let closingComments2=this.getClosingComments(lexemes[idx]);return idx++,{value:wildcard,closingComments:closingComments2,newIndex:idx}}let result=ValueParser.parseFromLexeme(lexemes,idx);if(idx=result.newIndex,openParenToken.positionedComments&&openParenToken.positionedComments.length>0){let afterComments=openParenToken.positionedComments.filter(pc=>pc.position==="after");if(afterComments.length>0){let beforeComments=afterComments.map(pc=>({position:"before",comments:pc.comments}));result.value.positionedComments=[...beforeComments,...result.value.positionedComments||[]],result.value,"qualifiedName"in result.value&&result.value.qualifiedName&&"name"in result.value.qualifiedName&&result.value.qualifiedName.name&&(result.value.qualifiedName.name.positionedComments=null,result.value.qualifiedName.name)}}for(args.push(result.value);idx=lexemes.length||!(lexemes[idx].type&8))throw ParseError.fromUnparsedLexemes(lexemes,idx,"Expected closing parenthesis.");let closingComments=this.getClosingComments(lexemes[idx]);return idx++,{value:args.length===1?args[0]:new ValueList(args),closingComments,newIndex:idx}}static getClosingComments(lexeme){if(!lexeme)return null;let commentInfo=extractLexemeComments(lexeme);return commentInfo.after.length>0?commentInfo.after:commentInfo.before.length>0?commentInfo.before:null}};var OperatorPrecedence=class{static{this.precedenceMap={or:1,and:2,"=":10,"!=":10,"<>":10,"<":10,"<=":10,">":10,">=":10,like:10,ilike:10,"not like":10,"not ilike":10,"similar to":10,"not similar to":10,in:10,"not in":10,is:10,"is not":10,"->":10,"->>":10,"#>":10,"#>>":10,"@>":10,"<@":10,"?":10,"?|":10,"?&":10,"~":10,"~*":10,"!~":10,"!~*":10,rlike:10,regexp:10,mod:30,xor:2,between:15,"not between":15,"+":20,"-":20,"*":30,"/":30,"%":30,"^":40,"::":50,"unary+":100,"unary-":100,not:100}}static getPrecedence(operator){let precedence=this.precedenceMap[operator.toLowerCase()];return precedence!==void 0?precedence:0}static hasHigherOrEqualPrecedence(operator1,operator2){return this.getPrecedence(operator1)>=this.getPrecedence(operator2)}static isLogicalOperator(operator){let op=operator.toLowerCase();return op==="and"||op==="or"}static isBetweenOperator(operator){let op=operator.toLowerCase();return op==="between"||op==="not between"}static isComparisonOperator(operator){let lowerOp=operator.toLowerCase();return["=","!=","<>","<",">","<=",">=","like","ilike","similar to","in","not in","->","->>","#>","#>>","@>","<@","?","?|","?&","~","~*","!~","!~*","rlike","regexp"].includes(lowerOp)}};var ValueParser=class{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex0&&!left.value.positionedComments?left.value.positionedComments=positionedComments:left.value.comments===null&&comment&&comment.length>0&&(left.value.comments=comment),idx=left.newIndex;let result=left.value,arrayAccessResult=this.parseArrayAccess(lexemes,idx,result);for(result=arrayAccessResult.value,idx=arrayAccessResult.newIndex;idx0&&(binaryExpr.operator.comments=operatorToken.comments),operatorToken.positionedComments&&operatorToken.positionedComments.length>0&&(binaryExpr.operator.positionedComments=operatorToken.positionedComments),result=binaryExpr}return{value:result,newIndex:idx}}static transferPositionedComments(lexeme,value){if(lexeme.positionedComments&&lexeme.positionedComments.length>0){let beforeComments=lexeme.positionedComments.filter(comment=>comment.position==="before"),afterComments=lexeme.positionedComments.filter(comment=>comment.position==="after");if(beforeComments.length>0){let clonedBefore=beforeComments.map(comment=>({position:comment.position,comments:[...comment.comments]}));value.positionedComments=value.positionedComments?[...clonedBefore,...value.positionedComments]:clonedBefore}if(afterComments.length>0){let clonedAfter=afterComments.map(comment=>({position:comment.position,comments:[...comment.comments]}));value.positionedComments=value.positionedComments?[...value.positionedComments,...clonedAfter]:clonedAfter}!beforeComments.length&&!afterComments.length&&!value.positionedComments&&(value.positionedComments=lexeme.positionedComments.map(comment=>({position:comment.position,comments:[...comment.comments]})));return}else value.comments===null&&lexeme.comments&&lexeme.comments.length>0&&(value.comments=lexeme.comments)}static parseItem(lexemes,index){let idx=index;if(idx>=lexemes.length)throw new Error(`Unexpected end of lexemes at index ${index}`);let current=lexemes[idx];if(current.type&64&¤t.type&2&¤t.type&8192){if(idx+1=lexemes.length)return this.transferPositionedComments(current,first.value),first;if(lexemes[first.newIndex].type&1){let literalIndex=first.newIndex,literalLexeme=lexemes[literalIndex],second=LiteralParser.parseFromLexeme(lexemes,literalIndex);this.transferPositionedComments(literalLexeme,second.value);let result=new UnaryExpression(lexemes[idx].value,second.value);return this.transferPositionedComments(current,result),{value:result,newIndex:second.newIndex}}return this.transferPositionedComments(current,first.value),first}else if(current.type&64){let{namespaces,name,newIndex}=FullNameParser.parseFromLexeme(lexemes,idx);if(lexemes[newIndex-1].type&2048){let result=FunctionExpressionParser.parseFromLexeme(lexemes,idx);return this.transferPositionedComments(current,result.value),result}else if(lexemes[newIndex-1].type&8192)if(newIndex0){let beforeComments=openParenToken.positionedComments.filter(pc=>pc.position==="after");beforeComments.length>0&&(wildcard.positionedComments=beforeComments.map(pc=>({position:"before",comments:pc.comments})))}else openParenToken.comments&&openParenToken.comments.length>0&&(wildcard.comments=openParenToken.comments);if(idx++,idx0){let afterComments=openParenToken.positionedComments.filter(pc=>pc.position==="after");if(afterComments.length>0){let beforeComments=afterComments.map(pc=>({position:"before",comments:pc.comments}));result.value.positionedComments?result.value.positionedComments=[...beforeComments,...result.value.positionedComments]:result.value.positionedComments=beforeComments}}else openParenToken.comments&&openParenToken.comments.length>0&&(result.value.comments?result.value.comments=openParenToken.comments.concat(result.value.comments):result.value.comments=openParenToken.comments);for(args.push(result.value);idx=lexemes.length)throw new Error(`Expected array index or slice after '[' at index ${idx-1}`);if(lexemes[idx].type&1024)throw new Error(`Empty array access brackets not supported at index ${idx}`);let startExpr=null,isSlice=!1;if(lexemes[idx].type&2&&lexemes[idx].value===":")isSlice=!0,idx++;else{let colonPrecedence=OperatorPrecedence.getPrecedence(":"),firstResult=this.parseExpressionWithPrecedence(lexemes,idx,colonPrecedence+1);startExpr=firstResult.value,idx=firstResult.newIndex,idx=lexemes.length||!(lexemes[idx].type&1024))throw new Error(`Expected ']' after array slice at index ${idx}`);idx++,result=new ArraySliceExpression(result,startExpr,endExpr)}else{if(!startExpr){let indexResult=this.parseFromLexeme(lexemes,idx);startExpr=indexResult.value,idx=indexResult.newIndex}if(idx>=lexemes.length||!(lexemes[idx].type&1024))throw new Error(`Expected ']' after array index at index ${idx}`);idx++,result=new ArrayIndexExpression(result,startExpr)}}return{value:result,newIndex:idx}}static isSqlServerBracketIdentifier(lexemes,bracketIndex){let idx=bracketIndex+1;if(idx>=lexemes.length)return!1;for(;idx=lexemes.length)return!1;let closingBracketIndex=idx;if(closingBracketIndex+1typeof col=="string"?new IdentifierString(col):col):null,this.values=params.values??null,this.defaultValues=params.defaultValues??!1,this.valuesLeadingComments=params.valuesLeadingComments?[...params.valuesLeadingComments]:null}addValuesLeadingComments(comments){if(!(!comments||comments.length===0)){this.valuesLeadingComments||(this.valuesLeadingComments=[]);for(let comment of comments)this.valuesLeadingComments.includes(comment)||this.valuesLeadingComments.push(comment)}}getValuesLeadingComments(){return this.valuesLeadingComments?[...this.valuesLeadingComments]:[]}},MergeDoNothingAction=class extends MergeAction{static{this.kind=Symbol("MergeDoNothingAction")}},MergeWhenClause=class extends SqlComponent{static{this.kind=Symbol("MergeWhenClause")}constructor(matchType,action,condition,options){super(),this.matchType=matchType,this.action=action,this.condition=condition??null,this.thenLeadingComments=options?.thenLeadingComments?[...options.thenLeadingComments]:null}addThenLeadingComments(comments){if(!(!comments||comments.length===0)){this.thenLeadingComments||(this.thenLeadingComments=[]);for(let comment of comments)this.thenLeadingComments.includes(comment)||this.thenLeadingComments.push(comment)}}getThenLeadingComments(){return this.thenLeadingComments?[...this.thenLeadingComments]:[]}},MergeQuery=class extends SqlComponent{static{this.kind=Symbol("MergeQuery")}constructor(params){super(),this.withClause=params.withClause??null,this.target=params.target,this.source=params.source,this.onCondition=params.onCondition,this.whenClauses=params.whenClauses,this.returningClause=params.returningClause??null}};var CTECollector=class{constructor(){this.commonTables=[];this.visitedNodes=new Set;this.isRootVisit=!0;this.handlers=new Map,this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleSelectQuery(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.handlers.set(ValuesQuery.kind,expr=>this.visitValuesQuery(expr)),this.handlers.set(InsertQuery.kind,expr=>this.visitInsertQuery(expr)),this.handlers.set(UpdateQuery.kind,expr=>this.visitUpdateQuery(expr)),this.handlers.set(DeleteQuery.kind,expr=>this.visitDeleteQuery(expr)),this.handlers.set(MergeQuery.kind,expr=>this.visitMergeQuery(expr)),this.handlers.set(WithClause.kind,expr=>this.visitWithClause(expr)),this.handlers.set(CommonTable.kind,expr=>this.visitCommonTable(expr)),this.handlers.set(SelectItem.kind,expr=>this.visitSelectItem(expr)),this.handlers.set(IdentifierString.kind,expr=>this.visitIdentifierString(expr)),this.handlers.set(RawString.kind,expr=>this.visitRawString(expr)),this.handlers.set(ColumnReference.kind,expr=>this.visitColumnReference(expr)),this.handlers.set(ParameterExpression.kind,expr=>this.visitParameterExpression(expr)),this.handlers.set(LiteralValue.kind,expr=>this.visitLiteralValue(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(TableSource.kind,expr=>this.visitTableSource(expr)),this.handlers.set(FunctionSource.kind,expr=>this.visitFunctionSource(expr)),this.handlers.set(ParenSource.kind,expr=>this.visitParenSource(expr)),this.handlers.set(SubQuerySource.kind,expr=>this.visitSubQuerySource(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(JoinClause.kind,expr=>this.visitJoinClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr)),this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(CaseKeyValuePair.kind,expr=>this.visitCaseKeyValuePair(expr)),this.handlers.set(SwitchCaseArgument.kind,expr=>this.visitSwitchCaseArgument(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(WindowFrameExpression.kind,expr=>this.visitWindowFrameExpression(expr)),this.handlers.set(WindowFrameSpec.kind,expr=>this.visitWindowFrameSpec(expr)),this.handlers.set(TypeValue.kind,expr=>this.visitTypeValue(expr)),this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(StringSpecifierExpression.kind,expr=>this.visitStringSpecifierExpression(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(ForClause.kind,expr=>this.visitForClause(expr)),this.handlers.set(OrderByItem.kind,expr=>this.visitOrderByItem(expr)),this.handlers.set(PartitionByClause.kind,expr=>this.visitPartitionByClause(expr))}getCommonTables(){return this.commonTables}reset(){this.commonTables=[],this.visitedNodes.clear()}collect(query){return this.visit(query),this.getCommonTables()}visit(arg){if(!this.isRootVisit){this.visitNode(arg);return}this.reset(),this.isRootVisit=!1;try{this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(this.visitedNodes.has(arg))return;this.visitedNodes.add(arg);let handler=this.handlers.get(arg.getKind());if(handler){handler(arg);return}let kindSymbol=arg.getKind()?.toString()||"unknown",constructor=arg.constructor?.name||"unknown";throw new Error(`[CTECollector] No handler for ${constructor} with kind ${kindSymbol}.`)}visitSimpleSelectQuery(query){if(query.fromClause&&query.fromClause.accept(this),query.whereClause&&query.whereClause.accept(this),query.groupByClause&&query.groupByClause.accept(this),query.havingClause&&query.havingClause.accept(this),query.orderByClause&&query.orderByClause.accept(this),query.windowClause)for(let win of query.windowClause.windows)win.accept(this);query.limitClause&&query.limitClause.accept(this),query.forClause&&query.forClause.accept(this),query.selectClause.accept(this),query.withClause&&query.withClause.accept(this)}visitBinarySelectQuery(query){query.left.accept(this),query.right.accept(this)}visitValuesQuery(query){for(let tuple of query.tuples)tuple.accept(this)}visitInsertQuery(query){query.selectQuery&&query.selectQuery.accept(this)}visitUpdateQuery(query){query.withClause&&query.withClause.accept(this),query.updateClause.source.accept(this),query.setClause.items.forEach(item=>item.value.accept(this)),query.fromClause&&query.fromClause.accept(this),query.whereClause&&query.whereClause.accept(this),query.returningClause&&this.visitReturningClause(query.returningClause)}visitDeleteQuery(query){query.withClause&&query.withClause.accept(this),query.deleteClause.source.accept(this),query.usingClause&&query.usingClause.sources.forEach(source=>source.accept(this)),query.whereClause&&query.whereClause.accept(this),query.returningClause&&this.visitReturningClause(query.returningClause)}visitMergeQuery(query){query.withClause&&query.withClause.accept(this),query.target.accept(this),query.source.accept(this),query.onCondition.accept(this);for(let clause of query.whenClauses)if(clause.condition&&clause.condition.accept(this),clause.action instanceof MergeUpdateAction)clause.action.setClause.items.forEach(item=>item.value.accept(this)),clause.action.whereClause&&clause.action.whereClause.accept(this);else if(clause.action instanceof MergeDeleteAction)clause.action.whereClause&&clause.action.whereClause.accept(this);else if(clause.action instanceof MergeInsertAction&&clause.action.values)clause.action.values.accept(this);else if(!(clause.action instanceof MergeInsertAction)){let actionName=clause.action?.constructor?.name??"UnknownMergeAction";throw new Error(`[CTECollector] Unsupported MERGE action type: ${actionName}.`)}query.returningClause&&this.visitReturningClause(query.returningClause)}visitReturningClause(clause){for(let item of clause.items)item.accept(this)}visitWithClause(withClause){for(let i=0;ithis.visitSimpleSelectQuery(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.handlers.set(ValuesQuery.kind,expr=>this.visitValuesQuery(expr)),this.handlers.set(InsertQuery.kind,expr=>this.visitInsertQuery(expr)),this.handlers.set(UpdateQuery.kind,expr=>this.visitUpdateQuery(expr)),this.handlers.set(DeleteQuery.kind,expr=>this.visitDeleteQuery(expr)),this.handlers.set(SelectItem.kind,expr=>this.visitSelectItem(expr)),this.handlers.set(IdentifierString.kind,expr=>this.visitIdentifierString(expr)),this.handlers.set(RawString.kind,expr=>this.visitRawString(expr)),this.handlers.set(ColumnReference.kind,expr=>this.visitColumnReference(expr)),this.handlers.set(ParameterExpression.kind,expr=>this.visitParameterExpression(expr)),this.handlers.set(LiteralValue.kind,expr=>this.visitLiteralValue(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(TableSource.kind,expr=>this.visitTableSource(expr)),this.handlers.set(ParenSource.kind,expr=>this.visitParenSource(expr)),this.handlers.set(SubQuerySource.kind,expr=>this.visitSubQuerySource(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(JoinClause.kind,expr=>this.visitJoinClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr)),this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(CaseKeyValuePair.kind,expr=>this.visitCaseKeyValuePair(expr)),this.handlers.set(SwitchCaseArgument.kind,expr=>this.visitSwitchCaseArgument(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(WindowFrameExpression.kind,expr=>this.visitWindowFrameExpression(expr)),this.handlers.set(WindowFrameSpec.kind,expr=>this.visitWindowFrameSpec(expr)),this.handlers.set(TypeValue.kind,expr=>this.visitTypeValue(expr)),this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(ArraySliceExpression.kind,expr=>this.visitArraySliceExpression(expr)),this.handlers.set(ArrayIndexExpression.kind,expr=>this.visitArrayIndexExpression(expr)),this.handlers.set(StringSpecifierExpression.kind,expr=>this.visitStringSpecifierExpression(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(ForClause.kind,expr=>this.visitForClause(expr)),this.handlers.set(OrderByItem.kind,expr=>this.visitOrderByItem(expr)),this.handlers.set(PartitionByClause.kind,expr=>this.visitPartitionByClause(expr))}reset(){this.visitedNodes.clear()}execute(arg){return this.reset(),this.visit(arg)}visit(arg){if(!this.isRootVisit)return this.visitNode(arg);this.reset(),this.isRootVisit=!1;try{return this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(this.visitedNodes.has(arg))return arg;this.visitedNodes.add(arg);let handler=this.handlers.get(arg.getKind());if(handler)return handler(arg);let kindSymbol=arg.getKind()?.toString()||"unknown",constructor=arg.constructor?.name||"unknown";throw new Error(`[CTEDisabler] No handler for ${constructor} with kind ${kindSymbol}.`)}visitSimpleSelectQuery(arg){return arg.withClause&&arg.withClause.tables.forEach(table=>{this.visit(table.query)}),arg.withClause=null,arg.selectClause=this.visit(arg.selectClause),arg.fromClause=arg.fromClause?this.visit(arg.fromClause):null,arg.whereClause=arg.whereClause?this.visit(arg.whereClause):null,arg.groupByClause=arg.groupByClause?this.visit(arg.groupByClause):null,arg.havingClause=arg.havingClause?this.visit(arg.havingClause):null,arg.orderByClause=arg.orderByClause?this.visit(arg.orderByClause):null,arg.windowClause&&(arg.windowClause=new WindowsClause(arg.windowClause.windows.map(w=>this.visit(w)))),arg.limitClause=arg.limitClause?this.visit(arg.limitClause):null,arg.forClause=arg.forClause?this.visit(arg.forClause):null,arg}visitBinarySelectQuery(query){return query.left=this.visit(query.left),query.right=this.visit(query.right),query}visitValuesQuery(query){let newTuples=query.tuples.map(tuple=>this.visit(tuple));return new ValuesQuery(newTuples)}visitInsertQuery(query){return query}visitUpdateQuery(query){return query}visitDeleteQuery(query){return query}visitSelectClause(clause){let newItems=clause.items.map(item=>this.visit(item));return new SelectClause(newItems,clause.distinct)}visitFromClause(clause){let newSource=this.visit(clause.source),newJoins=clause.joins?clause.joins.map(join=>this.visit(join)):null;return new FromClause(newSource,newJoins)}visitSubQuerySource(subQuery){let newQuery=this.visit(subQuery.query);return new SubQuerySource(newQuery)}visitInlineQuery(inlineQuery){let newQuery=this.visit(inlineQuery.selectQuery);return new InlineQuery(newQuery)}visitJoinClause(joinClause){let newSource=this.visit(joinClause.source),newCondition=joinClause.condition?this.visit(joinClause.condition):null;return new JoinClause(joinClause.joinType.value,newSource,newCondition,joinClause.lateral)}visitJoinOnClause(joinOn){let newCondition=this.visit(joinOn.condition);return new JoinOnClause(newCondition)}visitJoinUsingClause(joinUsing){let newCondition=this.visit(joinUsing.condition);return new JoinUsingClause(newCondition)}visitWhereClause(whereClause){let newCondition=this.visit(whereClause.condition);return new WhereClause(newCondition)}visitGroupByClause(clause){let newGrouping=clause.grouping.map(item=>this.visit(item));return new GroupByClause(newGrouping)}visitHavingClause(clause){let newCondition=this.visit(clause.condition);return new HavingClause(newCondition)}visitOrderByClause(clause){let newOrder=clause.order.map(item=>this.visit(item));return new OrderByClause(newOrder)}visitWindowFrameClause(clause){let newExpression=this.visit(clause.expression);return new WindowFrameClause(clause.name.name,newExpression)}visitLimitClause(clause){let newLimit=this.visit(clause.value);return new LimitClause(newLimit)}visitForClause(clause){return new ForClause(clause.lockMode)}visitParenExpression(expr){let newExpression=this.visit(expr.expression);return new ParenExpression(newExpression)}visitBinaryExpression(expr){let newLeft=this.visit(expr.left),newRight=this.visit(expr.right);return new BinaryExpression(newLeft,expr.operator.value,newRight)}visitUnaryExpression(expr){let newExpression=this.visit(expr.expression);return new UnaryExpression(expr.operator.value,newExpression)}visitCaseExpression(expr){let newCondition=expr.condition?this.visit(expr.condition):null,newSwitchCase=this.visit(expr.switchCase);return new CaseExpression(newCondition,newSwitchCase)}visitSwitchCaseArgument(switchCase){let newCases=switchCase.cases.map(caseItem=>this.visit(caseItem)),newElseValue=switchCase.elseValue?this.visit(switchCase.elseValue):null;return new SwitchCaseArgument(newCases,newElseValue)}visitCaseKeyValuePair(pair){let newKey=this.visit(pair.key),newValue=this.visit(pair.value);return new CaseKeyValuePair(newKey,newValue)}visitBetweenExpression(expr){let newExpression=this.visit(expr.expression),newLower=this.visit(expr.lower),newUpper=this.visit(expr.upper);return new BetweenExpression(newExpression,newLower,newUpper,expr.negated)}visitFunctionCall(func){let newArgument=func.argument?this.visit(func.argument):null,newOver=func.over?this.visit(func.over):null;return new FunctionCall(func.namespaces,func.name,newArgument,newOver)}visitArrayExpression(expr){let newExpression=this.visit(expr.expression);return new ArrayExpression(newExpression)}visitArrayQueryExpression(expr){let newQuery=this.visit(expr.query);return new ArrayQueryExpression(newQuery)}visitTupleExpression(expr){let newValues=expr.values.map(value=>this.visit(value));return new TupleExpression(newValues)}visitCastExpression(expr){let newInput=this.visit(expr.input),newCastType=this.visit(expr.castType);return new CastExpression(newInput,newCastType)}visitTypeValue(typeValue){let newArgument=typeValue.argument?this.visit(typeValue.argument):null;return new TypeValue(typeValue.namespaces,typeValue.name,newArgument)}visitSelectItem(item){let newValue=this.visit(item.value);return new SelectItem(newValue,item.identifier?.name)}visitIdentifierString(ident){return ident}visitRawString(raw){return raw}visitColumnReference(column){return column}visitSourceExpression(source){let newSource=this.visit(source.datasource),newAlias=source.aliasExpression;return new SourceExpression(newSource,newAlias)}visitTableSource(source){return source}visitParenSource(source){let newSource=this.visit(source.source);return new ParenSource(newSource)}visitParameterExpression(param){return param}visitWindowFrameExpression(expr){let newPartition=expr.partition?this.visit(expr.partition):null,newOrder=expr.order?this.visit(expr.order):null,newFrameSpec=expr.frameSpec?this.visit(expr.frameSpec):null;return new WindowFrameExpression(newPartition,newOrder,newFrameSpec)}visitWindowFrameSpec(spec){return spec}visitLiteralValue(value){return value}visitOrderByItem(item){let newValue=this.visit(item.value);return new OrderByItem(newValue,item.sortDirection,item.nullsPosition)}visitValueList(valueList){let newValues=valueList.values.map(value=>this.visit(value));return new ValueList(newValues)}visitArraySliceExpression(expr){return expr}visitArrayIndexExpression(expr){return expr}visitStringSpecifierExpression(expr){return expr}visitPartitionByClause(clause){let newValue=this.visit(clause.value);return new PartitionByClause(newValue)}};var TableSourceCollector=class{constructor(selectableOnly=!0,dedupe=!0){this.tableSources=[];this.visitedNodes=new Set;this.tableNameMap=new Map;this.cteNames=new Set;this.isRootVisit=!0;this.selectableOnly=selectableOnly,this.dedupe=dedupe,this.handlers=new Map,this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleSelectQuery(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.handlers.set(ValuesQuery.kind,expr=>this.visitValuesQuery(expr)),this.handlers.set(InsertQuery.kind,expr=>this.visitInsertQuery(expr)),this.handlers.set(UpdateQuery.kind,expr=>this.visitUpdateQuery(expr)),this.handlers.set(DeleteQuery.kind,expr=>this.visitDeleteQuery(expr)),this.handlers.set(MergeQuery.kind,expr=>this.visitMergeQuery(expr)),this.handlers.set(WithClause.kind,expr=>this.visitWithClause(expr)),this.handlers.set(CommonTable.kind,expr=>this.visitCommonTable(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(JoinClause.kind,expr=>this.visitJoinClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(TableSource.kind,expr=>this.visitTableSource(expr)),this.handlers.set(FunctionSource.kind,expr=>this.visitFunctionSource(expr)),this.handlers.set(ParenSource.kind,expr=>this.visitParenSource(expr)),this.handlers.set(SubQuerySource.kind,expr=>this.visitSubQuerySource(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),selectableOnly||(this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(OffsetClause.kind,expr=>this.visitOffsetClause(expr)),this.handlers.set(FetchClause.kind,expr=>this.visitFetchClause(expr)),this.handlers.set(ForClause.kind,expr=>this.visitForClause(expr)),this.handlers.set(OrderByItem.kind,expr=>this.visitOrderByItem(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(SelectItem.kind,expr=>this.visitSelectItem(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(CaseKeyValuePair.kind,expr=>this.visitCaseKeyValuePair(expr)),this.handlers.set(SwitchCaseArgument.kind,expr=>this.visitSwitchCaseArgument(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(StringSpecifierExpression.kind,expr=>this.visitStringSpecifierExpression(expr)))}getTableSources(){return this.tableSources}reset(){this.tableSources=[],this.tableNameMap.clear(),this.visitedNodes.clear(),this.cteNames.clear()}getTableIdentifier(source){return source.qualifiedName.namespaces&&source.qualifiedName.namespaces.length>0?source.qualifiedName.namespaces.map(ns=>ns.name).join(".")+"."+(source.qualifiedName.name instanceof RawString?source.qualifiedName.name.value:source.qualifiedName.name.name):source.qualifiedName.name instanceof RawString?source.qualifiedName.name.value:source.qualifiedName.name.name}collect(query){return this.visit(query),this.getTableSources()}visit(arg){if(!this.isRootVisit){this.visitNode(arg);return}this.reset(),this.isRootVisit=!1;try{this.selectableOnly||this.collectCTEs(arg),this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(this.visitedNodes.has(arg))return;this.visitedNodes.add(arg);let handler=this.handlers.get(arg.getKind());if(handler){handler(arg);return}}collectCTEs(query){let cteCollector=new CTECollector;cteCollector.visit(query);let commonTables=cteCollector.getCommonTables();for(let cte of commonTables)this.cteNames.add(cte.aliasExpression.table.name)}visitSimpleSelectQuery(query){if(query.fromClause&&query.fromClause.accept(this),!this.selectableOnly){if(query.withClause&&query.withClause.accept(this),query.whereClause&&query.whereClause.accept(this),query.groupByClause&&query.groupByClause.accept(this),query.havingClause&&query.havingClause.accept(this),query.orderByClause&&query.orderByClause.accept(this),query.windowClause)for(let win of query.windowClause.windows)win.accept(this);query.limitClause&&query.limitClause.accept(this),query.offsetClause&&query.offsetClause.accept(this),query.fetchClause&&query.fetchClause.accept(this),query.forClause&&query.forClause.accept(this),query.selectClause.accept(this)}}visitBinarySelectQuery(query){query.left.accept(this),query.right.accept(this)}visitValuesQuery(query){if(!this.selectableOnly)for(let tuple of query.tuples)tuple.accept(this)}visitInsertQuery(query){query.insertClause.source.accept(this),query.selectQuery&&query.selectQuery.accept(this),!this.selectableOnly&&query.returningClause&&this.visitReturningClause(query.returningClause)}visitUpdateQuery(query){!this.selectableOnly&&query.withClause&&query.withClause.accept(this),query.updateClause.source.accept(this),this.selectableOnly||query.setClause.items.forEach(item=>item.value.accept(this)),query.fromClause&&query.fromClause.accept(this),!this.selectableOnly&&query.whereClause&&query.whereClause.accept(this),!this.selectableOnly&&query.returningClause&&this.visitReturningClause(query.returningClause)}visitDeleteQuery(query){!this.selectableOnly&&query.withClause&&query.withClause.accept(this),query.deleteClause.source.accept(this),query.usingClause&&query.usingClause.sources.forEach(source=>source.accept(this)),!this.selectableOnly&&query.whereClause&&query.whereClause.accept(this),!this.selectableOnly&&query.returningClause&&this.visitReturningClause(query.returningClause)}visitMergeQuery(query){if(query.withClause&&this.registerCteNames(query.withClause),!this.selectableOnly&&query.withClause&&query.withClause.accept(this),query.target.accept(this),query.source.accept(this),!this.selectableOnly){query.onCondition.accept(this);for(let clause of query.whenClauses)if(clause.condition&&clause.condition.accept(this),clause.action instanceof MergeUpdateAction)clause.action.setClause.items.forEach(item=>item.value.accept(this)),clause.action.whereClause&&clause.action.whereClause.accept(this);else if(clause.action instanceof MergeDeleteAction)clause.action.whereClause&&clause.action.whereClause.accept(this);else if(clause.action instanceof MergeInsertAction&&clause.action.values)clause.action.values.accept(this);else if(!(clause.action instanceof MergeInsertAction)){let actionName=clause.action?.constructor?.name??"UnknownMergeAction";throw new Error(`[TableSourceCollector] Unsupported MERGE action type: ${actionName}.`)}}!this.selectableOnly&&query.returningClause&&this.visitReturningClause(query.returningClause)}visitReturningClause(clause){for(let item of clause.items)item.value.accept(this)}visitWithClause(withClause){if(!this.selectableOnly)for(let table of withClause.tables)table.accept(this)}visitCommonTable(commonTable){this.selectableOnly||commonTable.query.accept(this)}visitFromClause(fromClause){if(fromClause.source.accept(this),fromClause.joins)for(let join of fromClause.joins)join.accept(this)}visitSourceExpression(source){source.datasource.accept(this)}visitTableSource(source){let identifier=this.getTableIdentifier(source);if(!this.isCTETable(source.table.name)){if(this.dedupe){if(this.tableNameMap.has(identifier))return;this.tableNameMap.set(identifier,!0)}this.tableSources.push(source)}}visitFunctionSource(source){source.argument&&this.visitValueComponent(source.argument)}visitValueComponent(value){value.accept(this)}isCTETable(tableName){return this.cteNames.has(tableName)}registerCteNames(withClause){for(let table of withClause.tables)this.cteNames.add(table.aliasExpression.table.name)}visitParenSource(source){source.source.accept(this)}visitSubQuerySource(subQuery){this.selectableOnly||subQuery.query.accept(this)}visitInlineQuery(inlineQuery){this.selectableOnly||inlineQuery.selectQuery.accept(this)}visitJoinClause(joinClause){joinClause.source.accept(this),!this.selectableOnly&&joinClause.condition&&joinClause.condition.accept(this)}visitJoinOnClause(joinOn){this.selectableOnly||joinOn.condition.accept(this)}visitJoinUsingClause(joinUsing){this.selectableOnly||joinUsing.condition.accept(this)}visitWhereClause(whereClause){whereClause.condition.accept(this)}visitGroupByClause(clause){for(let item of clause.grouping)item.accept(this)}visitHavingClause(clause){clause.condition.accept(this)}visitOrderByClause(clause){for(let item of clause.order)item.accept(this)}visitWindowFrameClause(clause){clause.expression.accept(this)}visitLimitClause(clause){clause.value.accept(this)}visitOffsetClause(clause){clause.value.accept(this)}visitFetchClause(clause){clause.expression.accept(this)}visitForClause(_clause){}visitOrderByItem(item){item.value.accept(this)}visitSelectClause(clause){for(let item of clause.items)item.accept(this)}visitSelectItem(item){item.value.accept(this)}visitParenExpression(expr){expr.expression.accept(this)}visitBinaryExpression(expr){expr.left.accept(this),expr.right.accept(this)}visitUnaryExpression(expr){expr.expression.accept(this)}visitCaseExpression(expr){expr.condition&&expr.condition.accept(this),expr.switchCase.accept(this)}visitSwitchCaseArgument(switchCase){for(let caseItem of switchCase.cases)caseItem.accept(this);switchCase.elseValue&&switchCase.elseValue.accept(this)}visitCaseKeyValuePair(pair){pair.key.accept(this),pair.value.accept(this)}visitBetweenExpression(expr){expr.expression.accept(this),expr.lower.accept(this),expr.upper.accept(this)}visitFunctionCall(func){func.argument&&func.argument.accept(this),func.filterCondition&&func.filterCondition.accept(this),func.over&&func.over.accept(this)}visitArrayExpression(expr){expr.expression.accept(this)}visitArrayQueryExpression(expr){expr.query.accept(this)}visitTupleExpression(expr){for(let value of expr.values)value.accept(this)}visitCastExpression(expr){expr.input.accept(this),expr.castType.accept(this)}visitValueList(valueList){for(let value of valueList.values)value.accept(this)}visitStringSpecifierExpression(_expr){}};var HintClause=class extends SqlComponent{static{this.kind=Symbol("HintClause")}constructor(hintContent){super(),this.hintContent=hintContent}getFullHint(){return"/*+ "+this.hintContent+" */"}static isHintClause(value){let trimmed=value.trim();return trimmed.length>=5&&trimmed.substring(0,3)==="/*+"&&trimmed.substring(trimmed.length-2)==="*/"}static extractHintContent(hintClause){let trimmed=hintClause.trim();if(!this.isHintClause(trimmed))throw new Error("Not a valid hint clause: "+hintClause);return trimmed.slice(3,-2).trim()}};var SqlPrintToken=class{constructor(type,text="",containerType=""){this.innerTokens=[];this.type=type,this.text=text,this.containerType=containerType}markAsHeaderComment(){if(this.containerType!=="CommentBlock")throw new Error("Header comment flag must only be applied to CommentBlock containers.");this.isHeaderComment=!0}};var SelectQueryWithClauseHelper=class{static getWithClause(selectQuery){let owner=this.findClauseOwner(selectQuery);return owner?owner.withClause:null}static setWithClause(selectQuery,withClause){let owner=this.findClauseOwner(selectQuery);if(!owner)throw new Error("Cannot attach WITH clause to the provided select query.");owner.withClause=withClause}static detachWithClause(selectQuery){let owner=this.findClauseOwner(selectQuery);if(!owner)return null;let clause=owner.withClause;return owner.withClause=null,clause}static findClauseOwner(selectQuery){if(!selectQuery)return null;if(selectQuery instanceof SimpleSelectQuery||selectQuery instanceof ValuesQuery)return selectQuery;if(selectQuery instanceof BinarySelectQuery)return this.findClauseOwner(selectQuery.left);throw new Error("Unsupported select query type for WITH clause management.")}};var ParameterCollector=class{static collect(node){let result=[];function walk(n){if(!(!n||typeof n!="object")){n.constructor&&n.constructor.kind===ParameterExpression.kind&&result.push(n);for(let key of Object.keys(n)){let v=n[key];Array.isArray(v)?v.forEach(walk):v&&typeof v=="object"&&v.constructor&&v.constructor.kind&&walk(v)}}}return walk(node),result}};var IdentifierDecorator=class{constructor(identifierEscape){this.start=identifierEscape?.start??'"',this.end=identifierEscape?.end??'"',this.target=identifierEscape?.target??"all"}decorate(text){return this.target==="minimal"&&this.canRenderBare(text)?text:this.start+this.escapeIdentifierText(text)+this.end}canRenderBare(text){return/^[a-z_][a-z0-9_]*$/.test(text)&&!UNSAFE_BARE_IDENTIFIERS.has(text)&&this.isPlainIdentifierToken(text)}isPlainIdentifierToken(text){let lexemes=new SqlTokenizer(text).readLexmes();return lexemes.length===1&&lexemes[0].type===64&&lexemes[0].value===text}escapeIdentifierText(text){return this.end?text.split(this.end).join(this.end+this.end):text}},UNSAFE_BARE_IDENTIFIERS=new Set(["all","and","any","as","between","by","case","cross","delete","distinct","else","end","except","exists","false","fetch","for","from","full","group","having","in","inner","insert","intersect","into","is","join","left","like","limit","not","null","offset","on","or","order","outer","right","select","set","table","then","true","union","update","using","values","when","where","with",...SQL_SPECIAL_VALUE_KEYWORDS]);var ParameterDecorator=class{constructor(options){this.prefix=options?.prefix??":",this.suffix=options?.suffix??"",this.style=options?.style??"named"}decorate(text,index){let paramText="";return this.style==="anonymous"?paramText=this.prefix:this.style==="indexed"?paramText=this.prefix+index:this.style==="named"&&(paramText=this.prefix+text+this.suffix),text=paramText,text}};var SelectValueCollector=class _SelectValueCollector{constructor(tableColumnResolver=null,initialCommonTables=null){this.selectValues=[];this.visitedNodes=new Set;this.isRootVisit=!0;this.tableColumnResolver=tableColumnResolver??null,this.commonTableCollector=new CTECollector,this.commonTables=[],this.initialCommonTables=initialCommonTables,this.handlers=new Map,this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleSelectQuery(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr))}getValues(){return this.selectValues}reset(){this.selectValues=[],this.visitedNodes.clear(),this.initialCommonTables?this.commonTables=this.initialCommonTables:this.commonTables=[]}collect(arg){this.visit(arg);let items=this.getValues();return this.reset(),items}visit(arg){if(!this.isRootVisit){this.visitNode(arg);return}this.reset(),this.isRootVisit=!1;try{this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(this.visitedNodes.has(arg))return;this.visitedNodes.add(arg);let handler=this.handlers.get(arg.getKind());if(handler){handler(arg);return}}visitSimpleSelectQuery(query){this.commonTables.length===0&&this.initialCommonTables===null&&(this.commonTables=this.commonTableCollector.collect(query)),query.selectClause&&query.selectClause.accept(this);let wildcards=this.selectValues.filter(item=>item.name==="*");if(wildcards.length===0)return;if(this.selectValues.some(item=>item.value instanceof ColumnReference&&item.value.namespaces===null)){query.fromClause&&this.processFromClause(query.fromClause,!0),this.selectValues=this.selectValues.filter(item=>item.name!=="*");return}let wildSourceNames=wildcards.filter(item=>item.value instanceof ColumnReference&&item.value.namespaces).map(item=>item.value.getNamespace());if(query.fromClause){let fromSourceName=query.fromClause.getSourceAliasName();if(fromSourceName&&wildSourceNames.includes(fromSourceName)&&this.processFromClause(query.fromClause,!1),query.fromClause.joins)for(let join of query.fromClause.joins){let joinSourceName=join.getSourceAliasName();joinSourceName&&wildSourceNames.includes(joinSourceName)&&this.processJoinClause(join)}}this.selectValues=this.selectValues.filter(item=>item.name!=="*")}processFromClause(clause,joinCascade){if(clause){let fromSourceName=clause.getSourceAliasName();if(this.processSourceExpression(fromSourceName,clause.source),clause.joins&&joinCascade)for(let join of clause.joins)this.processJoinClause(join)}}processJoinClause(clause){let sourceName=clause.getSourceAliasName();this.processSourceExpression(sourceName,clause.source)}processSourceExpression(sourceName,source){let commonTable=this.commonTables.find(item=>item.aliasExpression.table.name===sourceName);if(commonTable){let innerCommonTables=this.commonTables.filter(item=>item.aliasExpression.table.name!==sourceName);this.collectValuesFromCteQuery(commonTable.query,innerCommonTables).forEach(item=>{this.addSelectValueAsUnique(item.name,new ColumnReference(sourceName?[sourceName]:null,item.name))})}else new _SelectValueCollector(this.tableColumnResolver,this.commonTables).collect(source).forEach(item=>{this.addSelectValueAsUnique(item.name,new ColumnReference(sourceName?[sourceName]:null,item.name))})}visitSelectClause(clause){for(let item of clause.items)this.processSelectItem(item)}processSelectItem(item){if(item.identifier)this.addSelectValueAsUnique(item.identifier.name,item.value);else if(item.value instanceof ColumnReference){let columnName=item.value.column.name;columnName==="*"?this.selectValues.push({name:columnName,value:item.value}):this.addSelectValueAsUnique(columnName,item.value)}}visitSourceExpression(source){if(source.aliasExpression&&source.aliasExpression.columns){let sourceName=source.getAliasName();source.aliasExpression.columns.forEach(column=>{this.addSelectValueAsUnique(column.name,new ColumnReference(sourceName?[sourceName]:null,column.name))});return}else if(source.datasource instanceof TableSource){if(this.tableColumnResolver){let sourceName=source.datasource.getSourceName();this.tableColumnResolver(sourceName).forEach(column=>{this.addSelectValueAsUnique(column,new ColumnReference([sourceName],column))})}return}else if(source.datasource instanceof SubQuerySource){let sourceName=source.getAliasName();new _SelectValueCollector(this.tableColumnResolver,this.commonTables).collect(source.datasource.query).forEach(item=>{this.addSelectValueAsUnique(item.name,new ColumnReference(sourceName?[sourceName]:null,item.name))});return}else if(source.datasource instanceof ParenSource)return this.visit(source.datasource.source)}visitFromClause(clause){clause&&this.processFromClause(clause,!0)}addSelectValueAsUnique(name,value){this.selectValues.some(item=>item.name===name)||this.selectValues.push({name,value})}collectValuesFromCteQuery(query,commonTables){return this.isSelectQuery(query)?new _SelectValueCollector(this.tableColumnResolver,commonTables).collect(query):this.collectValuesFromReturning(query)}collectValuesFromReturning(query){return query instanceof InsertQuery||query instanceof UpdateQuery||query instanceof DeleteQuery||query instanceof MergeQuery?query.returningClause?this.extractValuesFromReturningClause(query.returningClause):[]:[]}extractValuesFromReturningClause(clause){let values=[];for(let item of clause.items){let name=item.identifier?.name??this.extractSelectItemName(item);name&&values.push({name,value:item.value})}return values}extractSelectItemName(item){return item.identifier?item.identifier.name:item.value instanceof ColumnReference?item.value.column.name:null}isSelectQuery(query){return"__selectQueryType"in query&&query.__selectQueryType==="SelectQuery"}};var ReferenceDefinition=class extends SqlComponent{static{this.kind=Symbol("ReferenceDefinition")}constructor(params){super(),this.targetTable=params.targetTable,this.columns=params.columns?[...params.columns]:null,this.matchType=params.matchType??null,this.onDelete=params.onDelete??null,this.onUpdate=params.onUpdate??null,this.deferrable=params.deferrable??null,this.initially=params.initially??null}},ColumnConstraintDefinition=class extends SqlComponent{static{this.kind=Symbol("ColumnConstraintDefinition")}constructor(params){super(),this.kind=params.kind,this.constraintName=params.constraintName,this.defaultValue=params.defaultValue,this.checkExpression=params.checkExpression,this.reference=params.reference,this.rawClause=params.rawClause}},TableConstraintDefinition=class extends SqlComponent{static{this.kind=Symbol("TableConstraintDefinition")}constructor(params){super(),this.kind=params.kind,this.constraintName=params.constraintName,this.columns=params.columns?[...params.columns]:null,this.reference=params.reference,this.checkExpression=params.checkExpression,this.rawClause=params.rawClause,this.deferrable=params.deferrable??null,this.initially=params.initially??null}},TableColumnDefinition=class extends SqlComponent{static{this.kind=Symbol("TableColumnDefinition")}constructor(params){super(),this.name=params.name,this.dataType=params.dataType,this.constraints=params.constraints?[...params.constraints]:[]}},CreateTableQuery=class extends SqlComponent{static{this.kind=Symbol("CreateTableQuery")}constructor(params){super(),this.tableName=new IdentifierString(params.tableName),this.namespaces=params.namespaces?[...params.namespaces]:null,this.isTemporary=params.isTemporary??!1,this.isUnlogged=params.isUnlogged??!1,this.ifNotExists=params.ifNotExists??!1,this.columns=params.columns?[...params.columns]:[],this.tableConstraints=params.tableConstraints?[...params.tableConstraints]:[],this.tableOptions=params.tableOptions??null,this.asSelectQuery=params.asSelectQuery,this.withDataOption=params.withDataOption??null}getSelectQuery(){let selectItems;this.asSelectQuery?selectItems=new SelectValueCollector().collect(this.asSelectQuery).map(val=>new SelectItem(val.value,val.name)):this.columns.length>0?selectItems=this.columns.map(column=>new SelectItem(new ColumnReference(null,column.name),column.name.name)):selectItems=[new SelectItem(new RawString("*"))];let qualifiedName=this.namespaces&&this.namespaces.length>0?[...this.namespaces,this.tableName.name].join("."):this.tableName.name;return new SimpleSelectQuery({selectClause:new SelectClause(selectItems),fromClause:new FromClause(new SourceExpression(new TableSource(null,qualifiedName),null),null)})}getCountQuery(){let qualifiedName=this.namespaces&&this.namespaces.length>0?[...this.namespaces,this.tableName.name].join("."):this.tableName.name;return new SimpleSelectQuery({selectClause:new SelectClause([new SelectItem(new FunctionCall(null,"count",new ColumnReference(null,"*"),null))]),fromClause:new FromClause(new SourceExpression(new TableSource(null,qualifiedName),null),null)})}};function cloneIdentifierWithComments(identifier){let clone=new IdentifierString(identifier.name);return identifier.positionedComments?clone.positionedComments=identifier.positionedComments.map(entry=>({position:entry.position,comments:[...entry.comments]})):identifier.comments&&identifier.comments.length>0&&(clone.comments=[...identifier.comments]),clone}var DropTableStatement=class extends SqlComponent{static{this.kind=Symbol("DropTableStatement")}constructor(params){super(),this.tables=params.tables.map(table=>new QualifiedName(table.namespaces,table.name)),this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},DropIndexStatement=class extends SqlComponent{static{this.kind=Symbol("DropIndexStatement")}constructor(params){super(),this.indexNames=params.indexNames.map(index=>new QualifiedName(index.namespaces,index.name)),this.ifExists=params.ifExists??!1,this.concurrently=params.concurrently??!1,this.behavior=params.behavior??null}},CreateSchemaStatement=class extends SqlComponent{static{this.kind=Symbol("CreateSchemaStatement")}constructor(params){super(),this.schemaName=new QualifiedName(params.schemaName.namespaces,params.schemaName.name),this.ifNotExists=params.ifNotExists??!1,this.authorization=params.authorization?cloneIdentifierWithComments(params.authorization):null}},DropSchemaStatement=class extends SqlComponent{static{this.kind=Symbol("DropSchemaStatement")}constructor(params){super(),this.schemaNames=params.schemaNames.map(schema=>new QualifiedName(schema.namespaces,schema.name)),this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},CommentOnStatement=class extends SqlComponent{static{this.kind=Symbol("CommentOnStatement")}constructor(params){super(),this.targetKind=params.targetKind,this.target=new QualifiedName(params.target.namespaces,params.target.name),this.comment=params.comment}},IndexColumnDefinition=class extends SqlComponent{static{this.kind=Symbol("IndexColumnDefinition")}constructor(params){super(),this.expression=params.expression,this.sortOrder=params.sortOrder??null,this.nullsOrder=params.nullsOrder??null,this.collation=params.collation??null,this.operatorClass=params.operatorClass??null}},CreateIndexStatement=class extends SqlComponent{static{this.kind=Symbol("CreateIndexStatement")}constructor(params){super(),this.unique=params.unique??!1,this.concurrently=params.concurrently??!1,this.ifNotExists=params.ifNotExists??!1,this.indexName=new QualifiedName(params.indexName.namespaces,params.indexName.name),this.tableName=new QualifiedName(params.tableName.namespaces,params.tableName.name),this.usingMethod=params.usingMethod??null,this.columns=params.columns.map(col=>new IndexColumnDefinition({expression:col.expression,sortOrder:col.sortOrder,nullsOrder:col.nullsOrder,collation:col.collation??null,operatorClass:col.operatorClass??null})),this.include=params.include?[...params.include]:null,this.where=params.where,this.withOptions=params.withOptions??null,this.tablespace=params.tablespace??null}},AlterTableAddConstraint=class extends SqlComponent{static{this.kind=Symbol("AlterTableAddConstraint")}constructor(params){super(),this.constraint=params.constraint,this.ifNotExists=params.ifNotExists??!1,this.notValid=params.notValid??!1}},AlterTableDropConstraint=class extends SqlComponent{static{this.kind=Symbol("AlterTableDropConstraint")}constructor(params){super(),this.constraintName=params.constraintName,this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},AlterTableDropColumn=class extends SqlComponent{static{this.kind=Symbol("AlterTableDropColumn")}constructor(params){super(),this.columnName=params.columnName,this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},AlterTableAddColumn=class extends SqlComponent{static{this.kind=Symbol("AlterTableAddColumn")}constructor(params){super(),this.column=params.column,this.ifNotExists=params.ifNotExists??!1}},AlterTableAlterColumnDefault=class extends SqlComponent{static{this.kind=Symbol("AlterTableAlterColumnDefault")}constructor(params){if(super(),this.columnName=params.columnName,this.setDefault=params.setDefault??null,this.dropDefault=params.dropDefault??!1,this.setDefault!==null&&this.dropDefault)throw new Error("[AlterTableAlterColumnDefault] Cannot set and drop a default at the same time.")}},AlterTableStatement=class extends SqlComponent{static{this.kind=Symbol("AlterTableStatement")}constructor(params){super(),this.table=new QualifiedName(params.table.namespaces,params.table.name),this.only=params.only??!1,this.ifExists=params.ifExists??!1,this.actions=params.actions.map(action=>action)}},DropConstraintStatement=class extends SqlComponent{static{this.kind=Symbol("DropConstraintStatement")}constructor(params){super(),this.constraintName=params.constraintName,this.ifExists=params.ifExists??!1,this.behavior=params.behavior??null}},ExplainOption=class extends SqlComponent{static{this.kind=Symbol("ExplainOption")}constructor(params){super(),this.name=cloneIdentifierWithComments(params.name),this.value=params.value??null}},ExplainStatement=class extends SqlComponent{static{this.kind=Symbol("ExplainStatement")}constructor(params){super(),this.options=params.options?params.options.map(option=>new ExplainOption(option)):null,this.statement=params.statement}},AnalyzeStatement=class extends SqlComponent{static{this.kind=Symbol("AnalyzeStatement")}constructor(params){super(),this.verbose=params?.verbose??!1,this.target=params?.target?new QualifiedName(params.target.namespaces,params.target.name):null,params?.columns?this.columns=params.columns.map(cloneIdentifierWithComments):this.columns=null}},CreateSequenceStatement=class extends SqlComponent{static{this.kind=Symbol("CreateSequenceStatement")}constructor(params){super(),this.sequenceName=new QualifiedName(params.sequenceName.namespaces,params.sequenceName.name),this.ifNotExists=params.ifNotExists??!1,this.clauses=params.clauses?[...params.clauses]:[]}},AlterSequenceStatement=class extends SqlComponent{static{this.kind=Symbol("AlterSequenceStatement")}constructor(params){super(),this.sequenceName=new QualifiedName(params.sequenceName.namespaces,params.sequenceName.name),this.ifExists=params.ifExists??!1,this.clauses=params.clauses?[...params.clauses]:[]}},VacuumStatement=class extends SqlComponent{static{this.kind=Symbol("VacuumStatement")}constructor(params){super(),this.full=params?.full??!1,this.verbose=params?.verbose??!1,this.freeze=params?.freeze??!1,this.analyze=params?.analyze??!1,this.targets=params?.targets?[...params.targets]:[]}},ReindexStatement=class extends SqlComponent{static{this.kind=Symbol("ReindexStatement")}constructor(params){super(),this.concurrently=params?.concurrently??!1,this.targets=params?.targets?[...params.targets]:[],this.targetType=params?.targetType??null}},ClusterStatement=class extends SqlComponent{static{this.kind=Symbol("ClusterStatement")}constructor(params){super(),this.verbose=params?.verbose??!1,this.table=params?.table??null,this.index=params?.index??null}},CheckpointStatement=class extends SqlComponent{static{this.kind=Symbol("CheckpointStatement")}constructor(params){super(),this.options=params?.options?[...params.options]:[]}};var PRESETS={mysql:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous",constraintStyle:"mysql"},postgres:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"$",parameterStyle:"indexed",castStyle:"postgres",constraintStyle:"postgres"},postgresWithNamedParams:{identifierEscape:{start:'"',end:'"'},parameterSymbol:":",parameterStyle:"named",castStyle:"postgres",constraintStyle:"postgres"},sqlserver:{identifierEscape:{start:"[",end:"]"},parameterSymbol:"@",parameterStyle:"named",constraintStyle:"postgres"},sqlite:{identifierEscape:{start:'"',end:'"'},parameterSymbol:":",parameterStyle:"named",constraintStyle:"postgres"},oracle:{identifierEscape:{start:'"',end:'"'},parameterSymbol:":",parameterStyle:"named",constraintStyle:"postgres"},clickhouse:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous",constraintStyle:"postgres"},firebird:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},db2:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},snowflake:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},cloudspanner:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"@",parameterStyle:"named"},duckdb:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},cockroachdb:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"$",parameterStyle:"indexed",castStyle:"postgres"},athena:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"},bigquery:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"@",parameterStyle:"named"},hive:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous"},mariadb:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous"},redshift:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"$",parameterStyle:"indexed",castStyle:"postgres"},flinksql:{identifierEscape:{start:"`",end:"`"},parameterSymbol:"?",parameterStyle:"anonymous"},mongodb:{identifierEscape:{start:'"',end:'"'},parameterSymbol:"?",parameterStyle:"anonymous"}},SqlPrintTokenParser=class _SqlPrintTokenParser{constructor(options){this.handlers=new Map;this.index=1;this.joinConditionContexts=[];options?.preset&&(options={...options.preset,...options}),this.parameterDecorator=new ParameterDecorator({prefix:typeof options?.parameterSymbol=="string"?options.parameterSymbol:options?.parameterSymbol?.start??":",suffix:typeof options?.parameterSymbol=="object"?options.parameterSymbol.end:"",style:options?.parameterStyle??"named"}),this.identifierDecorator=new IdentifierDecorator({start:options?.identifierEscape?.start??'"',end:options?.identifierEscape?.end??'"',target:options?.identifierEscape?.target}),this.castStyle=options?.castStyle??"standard",this.constraintStyle=options?.constraintStyle??"postgres",this.normalizeJoinConditionOrder=options?.joinConditionOrderByDeclaration??!1,this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(ColumnReference.kind,expr=>this.visitColumnReference(expr)),this.handlers.set(QualifiedName.kind,expr=>this.visitQualifiedName(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(LiteralValue.kind,expr=>this.visitLiteralValue(expr)),this.handlers.set(ParameterExpression.kind,expr=>this.visitParameterExpression(expr)),this.handlers.set(SwitchCaseArgument.kind,expr=>this.visitSwitchCaseArgument(expr)),this.handlers.set(CaseKeyValuePair.kind,expr=>this.visitCaseKeyValuePair(expr)),this.handlers.set(RawString.kind,expr=>this.visitRawString(expr)),this.handlers.set(IdentifierString.kind,expr=>this.visitIdentifierString(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(ArraySliceExpression.kind,expr=>this.visitArraySliceExpression(expr)),this.handlers.set(ArrayIndexExpression.kind,expr=>this.visitArrayIndexExpression(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(StringSpecifierExpression.kind,expr=>this.visitStringSpecifierExpression(expr)),this.handlers.set(TypeValue.kind,expr=>this.visitTypeValue(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),this.handlers.set(WindowFrameExpression.kind,expr=>this.visitWindowFrameExpression(expr)),this.handlers.set(WindowFrameSpec.kind,expr=>this.visitWindowFrameSpec(expr)),this.handlers.set(WindowFrameBoundStatic.kind,expr=>this.visitWindowFrameBoundStatic(expr)),this.handlers.set(WindowFrameBoundaryValue.kind,expr=>this.visitWindowFrameBoundaryValue(expr)),this.handlers.set(PartitionByClause.kind,expr=>this.visitPartitionByClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(OrderByItem.kind,expr=>this.visitOrderByItem(expr)),this.handlers.set(SelectItem.kind,expr=>this.visitSelectItem(expr)),this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(Distinct.kind,expr=>this.visitDistinct(expr)),this.handlers.set(DistinctOn.kind,expr=>this.visitDistinctOn(expr)),this.handlers.set(HintClause.kind,expr=>this.visitHintClause(expr)),this.handlers.set(TableSource.kind,expr=>this.visitTableSource(expr)),this.handlers.set(FunctionSource.kind,expr=>this.visitFunctionSource(expr)),this.handlers.set(SourceExpression.kind,expr=>this.visitSourceExpression(expr)),this.handlers.set(SourceAliasExpression.kind,expr=>this.visitSourceAliasExpression(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(JoinClause.kind,expr=>this.visitJoinClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr)),this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(WindowsClause.kind,expr=>this.visitWindowClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(OffsetClause.kind,expr=>this.visitOffsetClause(expr)),this.handlers.set(FetchClause.kind,expr=>this.visitFetchClause(expr)),this.handlers.set(FetchExpression.kind,expr=>this.visitFetchExpression(expr)),this.handlers.set(ForClause.kind,expr=>this.visitForClause(expr)),this.handlers.set(WithClause.kind,expr=>this.visitWithClause(expr)),this.handlers.set(CommonTable.kind,expr=>this.visitCommonTable(expr)),this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleQuery(expr)),this.handlers.set(SubQuerySource.kind,expr=>this.visitSubQuerySource(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.handlers.set(ValuesQuery.kind,expr=>this.visitValuesQuery(expr)),this.handlers.set(TupleExpression.kind,expr=>this.visitTupleExpression(expr)),this.handlers.set(InsertQuery.kind,expr=>this.visitInsertQuery(expr)),this.handlers.set(InsertClause.kind,expr=>this.visitInsertClause(expr)),this.handlers.set(UpdateQuery.kind,expr=>this.visitUpdateQuery(expr)),this.handlers.set(UpdateClause.kind,expr=>this.visitUpdateClause(expr)),this.handlers.set(DeleteQuery.kind,expr=>this.visitDeleteQuery(expr)),this.handlers.set(DeleteClause.kind,expr=>this.visitDeleteClause(expr)),this.handlers.set(UsingClause.kind,expr=>this.visitUsingClause(expr)),this.handlers.set(SetClause.kind,expr=>this.visitSetClause(expr)),this.handlers.set(SetClauseItem.kind,expr=>this.visitSetClauseItem(expr)),this.handlers.set(ReturningClause.kind,expr=>this.visitReturningClause(expr)),this.handlers.set(CreateTableQuery.kind,expr=>this.visitCreateTableQuery(expr)),this.handlers.set(TableColumnDefinition.kind,expr=>this.visitTableColumnDefinition(expr)),this.handlers.set(ColumnConstraintDefinition.kind,expr=>this.visitColumnConstraintDefinition(expr)),this.handlers.set(TableConstraintDefinition.kind,expr=>this.visitTableConstraintDefinition(expr)),this.handlers.set(ReferenceDefinition.kind,expr=>this.visitReferenceDefinition(expr)),this.handlers.set(CreateIndexStatement.kind,expr=>this.visitCreateIndexStatement(expr)),this.handlers.set(CreateSchemaStatement.kind,expr=>this.visitCreateSchemaStatement(expr)),this.handlers.set(IndexColumnDefinition.kind,expr=>this.visitIndexColumnDefinition(expr)),this.handlers.set(CreateSequenceStatement.kind,expr=>this.visitCreateSequenceStatement(expr)),this.handlers.set(AlterSequenceStatement.kind,expr=>this.visitAlterSequenceStatement(expr)),this.handlers.set(DropTableStatement.kind,expr=>this.visitDropTableStatement(expr)),this.handlers.set(DropIndexStatement.kind,expr=>this.visitDropIndexStatement(expr)),this.handlers.set(DropSchemaStatement.kind,expr=>this.visitDropSchemaStatement(expr)),this.handlers.set(CommentOnStatement.kind,expr=>this.visitCommentOnStatement(expr)),this.handlers.set(AlterTableStatement.kind,expr=>this.visitAlterTableStatement(expr)),this.handlers.set(AlterTableAddConstraint.kind,expr=>this.visitAlterTableAddConstraint(expr)),this.handlers.set(AlterTableDropConstraint.kind,expr=>this.visitAlterTableDropConstraint(expr)),this.handlers.set(AlterTableAddColumn.kind,expr=>this.visitAlterTableAddColumn(expr)),this.handlers.set(AlterTableDropColumn.kind,expr=>this.visitAlterTableDropColumn(expr)),this.handlers.set(AlterTableAlterColumnDefault.kind,expr=>this.visitAlterTableAlterColumnDefault(expr)),this.handlers.set(DropConstraintStatement.kind,expr=>this.visitDropConstraintStatement(expr)),this.handlers.set(ExplainStatement.kind,expr=>this.visitExplainStatement(expr)),this.handlers.set(AnalyzeStatement.kind,expr=>this.visitAnalyzeStatement(expr)),this.handlers.set(MergeQuery.kind,expr=>this.visitMergeQuery(expr)),this.handlers.set(MergeWhenClause.kind,expr=>this.visitMergeWhenClause(expr)),this.handlers.set(MergeUpdateAction.kind,expr=>this.visitMergeUpdateAction(expr)),this.handlers.set(MergeDeleteAction.kind,expr=>this.visitMergeDeleteAction(expr)),this.handlers.set(MergeInsertAction.kind,expr=>this.visitMergeInsertAction(expr)),this.handlers.set(MergeDoNothingAction.kind,expr=>this.visitMergeDoNothingAction(expr))}static{this.SPACE_TOKEN=new SqlPrintToken(10," ")}static{this.COMMA_TOKEN=new SqlPrintToken(3,",")}static{this.ARGUMENT_SPLIT_COMMA_TOKEN=new SqlPrintToken(11,",")}static{this.PAREN_OPEN_TOKEN=new SqlPrintToken(4,"(")}static{this.PAREN_CLOSE_TOKEN=new SqlPrintToken(4,")")}static{this.DOT_TOKEN=new SqlPrintToken(8,".")}static{this._selfHandlingComponentTypes=null}static getSelfHandlingComponentTypes(){return this._selfHandlingComponentTypes||(this._selfHandlingComponentTypes=new Set([SimpleSelectQuery.kind,SelectItem.kind,CaseKeyValuePair.kind,SwitchCaseArgument.kind,ColumnReference.kind,LiteralValue.kind,ParameterExpression.kind,TableSource.kind,SourceAliasExpression.kind,TypeValue.kind,FunctionCall.kind,IdentifierString.kind,QualifiedName.kind])),this._selfHandlingComponentTypes}visitBinarySelectQuery(arg){let token=new SqlPrintToken(0,"");if(arg.positionedComments&&arg.positionedComments.length>0)this.addPositionedCommentsToToken(token,arg),arg.positionedComments=null;else if(arg.headerComments&&arg.headerComments.length>0){if(this.shouldMergeHeaderComments(arg.headerComments)){let mergedHeaderComment=this.createHeaderMultiLineCommentBlock(arg.headerComments);token.innerTokens.push(mergedHeaderComment)}else{let headerCommentBlocks=this.createCommentBlocks(arg.headerComments,!0);token.innerTokens.push(...headerCommentBlocks)}token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN)}return token.innerTokens.push(this.visit(arg.left)),token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,arg.operator.value,"BinarySelectQueryOperator")),token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(this.visit(arg.right)),token}visitCreateSchemaStatement(arg){let keywordParts=["create","schema"];arg.ifNotExists&&keywordParts.push("if not exists");let token=new SqlPrintToken(1,keywordParts.join(" "),"CreateSchemaStatement");return token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(arg.schemaName.accept(this)),arg.authorization&&(token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,"authorization")),token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(arg.authorization.accept(this))),token}static commaSpaceTokens(){return[_SqlPrintTokenParser.COMMA_TOKEN,_SqlPrintTokenParser.SPACE_TOKEN]}static argumentCommaSpaceTokens(){return[_SqlPrintTokenParser.ARGUMENT_SPLIT_COMMA_TOKEN,_SqlPrintTokenParser.SPACE_TOKEN]}visitQualifiedName(arg){let token=new SqlPrintToken(0,"","QualifiedName");if(arg.namespaces)for(let i=0;i0&&token.innerTokens.push(..._SqlPrintTokenParser.commaSpaceTokens()),token.innerTokens.push(this.visit(arg.order[i]));return token}visitOrderByItem(arg){let token=new SqlPrintToken(0,"","OrderByItem");return token.innerTokens.push(this.visit(arg.value)),arg.sortDirection&&arg.sortDirection!=="asc"&&(token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,"desc"))),arg.nullsPosition&&(arg.nullsPosition==="first"?(token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,"nulls first"))):arg.nullsPosition==="last"&&(token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(new SqlPrintToken(1,"nulls last")))),token}parse(arg){this.index=1;let token=this.visit(arg),paramsRaw=ParameterCollector.collect(arg).sort((a,b)=>(a.index??0)-(b.index??0)),style=this.parameterDecorator.style;if(style==="named"){let paramsObj={};for(let p of paramsRaw){let key=p.name.value;if(paramsObj.hasOwnProperty(key)){if(paramsObj[key]!==p.value)throw new Error(`Duplicate parameter name '${key}' with different values detected during query composition.`);continue}paramsObj[key]=p.value}return{token,params:paramsObj}}else if(style==="indexed"){let paramsArr=paramsRaw.map(p=>p.value);return{token,params:paramsArr}}else if(style==="anonymous"){let paramsArr=paramsRaw.map(p=>p.value);return{token,params:paramsArr}}return{token,params:[]}}componentHandlesOwnComments(component){return"handlesOwnComments"in component&&typeof component.handlesOwnComments=="function"?component.handlesOwnComments():_SqlPrintTokenParser.getSelfHandlingComponentTypes().has(component.getKind())}visit(arg){let handler=this.handlers.get(arg.getKind());if(handler){let token=handler(arg);return this.componentHandlesOwnComments(arg)||this.addComponentComments(token,arg),token}throw new Error(`[SqlPrintTokenParser] No handler for kind: ${arg.getKind().toString()}`)}hasPositionedComments(component){return(component.positionedComments?.length??0)>0}hasLegacyComments(component){return(component.comments?.length??0)>0}addComponentComments(token,component){this.hasPositionedComments(component)?this.addPositionedCommentsToToken(token,component):this.hasLegacyComments(component)&&this.addCommentsToToken(token,component.comments)}addPositionedCommentsToToken(token,component){if(!this.hasPositionedComments(component))return;let beforeComments=component.getPositionedComments("before");if(beforeComments.length>0){let commentBlocks=this.createCommentBlocks(beforeComments);for(let i=commentBlocks.length-1;i>=0;i--)token.innerTokens.unshift(commentBlocks[i])}let afterComments=component.getPositionedComments("after");if(afterComments.length>0){let commentBlocks=this.createCommentBlocks(afterComments);for(let commentBlock of commentBlocks)token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(commentBlock)}let componentsWithDuplicationIssues=["CaseExpression","SwitchCaseArgument","CaseKeyValuePair","SelectClause","LiteralValue","IdentifierString","DistinctOn","SourceAliasExpression","SimpleSelectQuery","WhereClause"];token.containerType&&componentsWithDuplicationIssues.includes(token.containerType)&&(component.positionedComments=null)}addCommentsToToken(token,comments){if(!comments?.length)return;let commentBlocks=this.createCommentBlocks(comments);this.insertCommentBlocksWithSpacing(token,commentBlocks)}createInlineCommentSequence(comments){let commentTokens=[];for(let i=0;i0&&token.innerTokens[token.innerTokens.length-1].type!==10&&token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(...commentBlocks);return}if(token.containerType==="SelectClause"){token.innerTokens.unshift(_SqlPrintTokenParser.SPACE_TOKEN,...commentBlocks);return}if(token.containerType==="IdentifierString"){token.innerTokens.length>0&&token.innerTokens[token.innerTokens.length-1].type!==10&&token.innerTokens.push(_SqlPrintTokenParser.SPACE_TOKEN),token.innerTokens.push(...commentBlocks);return}if(token.innerTokens.unshift(...commentBlocks),this.shouldAddSeparatorSpace(token.containerType)){let separatorSpace=new SqlPrintToken(10," ");token.innerTokens.splice(commentBlocks.length,0,separatorSpace),token.innerTokens.length>commentBlocks.length+1&&token.innerTokens[commentBlocks.length+1].type===10&&token.innerTokens.splice(commentBlocks.length+1,1)}else token.innerTokens.length>commentBlocks.length&&token.innerTokens[commentBlocks.length].type===10&&token.innerTokens.splice(commentBlocks.length,1)}addPositionedCommentsToParenExpression(token,component){if(!component.positionedComments)return;let beforeComments=component.getPositionedComments("before");if(beforeComments.length>0){let commentBlocks=this.createCommentBlocks(beforeComments),insertIndex=1;for(let commentBlock of commentBlocks)token.innerTokens.splice(insertIndex,0,commentBlock),insertIndex++}let afterComments=component.getPositionedComments("after");if(afterComments.length>0){let commentBlocks=this.createCommentBlocks(afterComments),insertIndex=token.innerTokens.length-1+1;for(let commentBlock of commentBlocks)token.innerTokens.splice(insertIndex,0,_SqlPrintTokenParser.SPACE_TOKEN,commentBlock),insertIndex+=2}}shouldAddSeparatorSpace(containerType){return this.isClauseLevelContainer(containerType)}isClauseLevelContainer(containerType){switch(containerType){case"SelectClause":case"FromClause":case"WhereClause":case"GroupByClause":case"HavingClause":case"OrderByClause":case"LimitClause":case"OffsetClause":case"WithClause":case"SimpleSelectQuery":return!0;default:return!1}}formatBlockComment(comment){let hasDelimiters=comment.startsWith("/*")&&comment.endsWith("*/"),rawContent=hasDelimiters?comment.slice(2,-2):comment,lines=this.escapeCommentDelimiters(rawContent).replace(/\r?\n/g,` `).split(` `).map(line=>line.replace(/\s+/g," ").trim()).filter(line=>line.length>0);if(lines.length===0)return"/* */";let isSeparatorLine=lines.length===1&&/^[-=_+*#]+$/.test(lines[0]);return hasDelimiters?isSeparatorLine||lines.length===1?`/* ${lines[0]} */`:`/* @@ -21,13 +21,13 @@ ${lines.map(line=>` ${line}`).join(` `)?text.length-2:text.endsWith(` `)?text.length-1:text.length}cleanupLine(){let workLine=this.getCurrentLine();if(this.isAsciiWhitespaceOnly(workLine.text)&&this.lines.length>1&&(this.commaBreak==="after"||this.commaBreak==="none")){let previousIndex=this.lines.length-2;for(;previousIndex>=0&&this.isAsciiWhitespaceOnly(this.lines[previousIndex].text);)this.lines.splice(previousIndex,1),previousIndex--;if(previousIndex<0)return!1;let previousLine=this.lines[previousIndex];return this.lineHasTrailingComment(previousLine.text)?!1:(this.lines.pop(),!0)}return!1}isAsciiWhitespaceOnly(text){for(let i=0;i0)return this.lines[this.lines.length-1];throw new Error("No tokens to get current line from.")}isCurrentLineEmpty(){if(this.lines.length>0){let currentLine=this.lines[this.lines.length-1];return this.isAsciiWhitespaceOnly(currentLine.text)}return!0}},PrintLine=class{constructor(level,text){this.level=level,this.text=text}};var INDENT_CHAR_MAP={space:" ",tab:" "},NEWLINE_MAP={lf:` `,crlf:`\r -`,cr:"\r"},IDENTIFIER_ESCAPE_MAP={quote:{start:'"',end:'"'},backtick:{start:"`",end:"`"},bracket:{start:"[",end:"]"},none:{start:"",end:""}};function resolveIndentCharOption(option){if(option===void 0)return;let normalized=typeof option=="string"?option.toLowerCase():option;return typeof normalized=="string"&&Object.prototype.hasOwnProperty.call(INDENT_CHAR_MAP,normalized)?INDENT_CHAR_MAP[normalized]:option}function resolveNewlineOption(option){if(option===void 0)return;let normalized=typeof option=="string"?option.toLowerCase():option;return typeof normalized=="string"&&Object.prototype.hasOwnProperty.call(NEWLINE_MAP,normalized)?NEWLINE_MAP[normalized]:option}function resolveIdentifierEscapeOption(option){if(option===void 0)return;if(typeof option=="string"){let normalized=option.toLowerCase();if(!Object.prototype.hasOwnProperty.call(IDENTIFIER_ESCAPE_MAP,normalized))throw new Error(`Unknown identifierEscape option: ${option}`);let mapped=IDENTIFIER_ESCAPE_MAP[normalized];return{start:mapped.start,end:mapped.end}}let start=option.start??"",end=option.end??"";return{start,end}}var OnelineFormattingHelper=class{constructor(options){this.options=options}shouldFormatContainer(token,shouldIndentNested){switch(token.containerType){case"ParenExpression":return this.options.parenthesesOneLine&&!shouldIndentNested;case"BetweenExpression":return this.options.betweenOneLine;case"Values":return this.options.valuesOneLine;case"JoinOnClause":return this.options.joinOneLine;case"CaseExpression":return this.options.caseOneLine;case"InlineQuery":return this.options.subqueryOneLine;default:return!1}}isInsertClauseOneline(parentContainerType){return this.options.insertColumnsOneLine?parentContainerType==="InsertClause"||parentContainerType==="MergeInsertAction":!1}shouldInsertJoinNewline(insideWithClause){return!(insideWithClause&&this.options.withClauseStyle==="full-oneline")}resolveCommaBreak(parentContainerType,commaBreak,cteCommaBreak,valuesCommaBreak){return parentContainerType==="WithClause"?cteCommaBreak:parentContainerType==="AnalyzeStatement"||parentContainerType==="ExplainStatement"?"none":parentContainerType==="Values"?valuesCommaBreak:this.isInsertClauseOneline(parentContainerType)?"none":commaBreak}shouldSkipInsertClauseSpace(parentContainerType,nextToken,currentLineText){if(!(parentContainerType==="InsertClause"||parentContainerType==="MergeInsertAction"))return!1;if(nextToken&&nextToken.type===4&&nextToken.text==="(")return!0;if(!this.options.insertColumnsOneLine)return!1;let lastChar=currentLineText.slice(-1);return lastChar==="("||lastChar===" "||lastChar===""}shouldSkipCommentBlockSpace(parentContainerType,insideWithClause){return parentContainerType==="CommentBlock"&&insideWithClause&&this.options.withClauseStyle==="full-oneline"}formatInsertClauseToken(text,parentContainerType,currentLineText,ensureTrailingSpace){if(!this.isInsertClauseOneline(parentContainerType))return{handled:!1};if(text==="")return{handled:!0};let leadingWhitespace=text.match(/^\s+/)?.[0]??"",trimmed=leadingWhitespace?text.slice(leadingWhitespace.length):text;if(trimmed==="")return{handled:!0};if(leadingWhitespace){let lastChar=currentLineText.slice(-1);lastChar!=="("&&lastChar!==" "&&lastChar!==""&&ensureTrailingSpace()}return{handled:!0,text:trimmed}}};var CREATE_TABLE_SINGLE_PAREN_KEYWORDS=new Set(["unique","check","key","index"]),CREATE_TABLE_MULTI_PAREN_KEYWORDS=new Set(["primary key","foreign key","unique key"]),CREATE_TABLE_PAREN_KEYWORDS_WITH_IDENTIFIER=new Set(["references"]),SqlPrinter=class _SqlPrinter{constructor(options){this.insideWithClause=!1;this.mergeWhenPredicateDepth=0;this.pendingLineCommentBreak=null;this.smartCommentBlockBuilder=null;let resolvedIndentChar=resolveIndentCharOption(options?.indentChar),resolvedNewline=resolveNewlineOption(options?.newline);this.indentChar=resolvedIndentChar??"",this.indentSize=options?.indentSize??0,this.newline=resolvedNewline??" ",this.commaBreak=options?.commaBreak??"none",this.cteCommaBreak=options?.cteCommaBreak??this.commaBreak,this.valuesCommaBreak=options?.valuesCommaBreak??this.commaBreak,this.andBreak=options?.andBreak??"none",this.orBreak=options?.orBreak??"none",this.keywordCase=options?.keywordCase??"none",this.commentExportMode=this.resolveCommentExportMode(options?.exportComment),this.withClauseStyle=options?.withClauseStyle??"standard",this.commentStyle=options?.commentStyle??"block",this.parenthesesOneLine=options?.parenthesesOneLine??!1,this.betweenOneLine=options?.betweenOneLine??!1,this.valuesOneLine=options?.valuesOneLine??!1,this.joinOneLine=options?.joinOneLine??!1,this.caseOneLine=options?.caseOneLine??!1,this.subqueryOneLine=options?.subqueryOneLine??!1,this.indentNestedParentheses=options?.indentNestedParentheses??!1,this.insertColumnsOneLine=options?.insertColumnsOneLine??!1,this.whenOneLine=options?.whenOneLine??!1;let onelineOptions={parenthesesOneLine:this.parenthesesOneLine,betweenOneLine:this.betweenOneLine,valuesOneLine:this.valuesOneLine,joinOneLine:this.joinOneLine,caseOneLine:this.caseOneLine,subqueryOneLine:this.subqueryOneLine,insertColumnsOneLine:this.insertColumnsOneLine,withClauseStyle:this.withClauseStyle};this.onelineHelper=new OnelineFormattingHelper(onelineOptions),this.linePrinter=new LinePrinter(this.indentChar,this.indentSize,this.newline,this.commaBreak),this.indentIncrementContainers=new Set(options?.indentIncrementContainerTypes??["SelectClause","ReturningClause","FromClause","WhereClause","GroupByClause","HavingClause","WindowFrameExpression","PartitionByClause","OrderByClause","WindowClause","LimitClause","OffsetClause","SubQuerySource","BinarySelectQueryOperator","Values","WithClause","SwitchCaseArgument","CaseKeyValuePair","CaseThenValue","ElseClause","CaseElseValue","SimpleSelectQuery","CreateTableDefinition","AlterTableStatement","IndexColumnList","SetClause"])}print(token,level=0){return this.linePrinter=new LinePrinter(this.indentChar,this.indentSize,this.newline,this.commaBreak),this.insideWithClause=!1,this.pendingLineCommentBreak=null,this.smartCommentBlockBuilder=null,this.linePrinter.lines.length>0&&level!==this.linePrinter.lines[0].level&&(this.linePrinter.lines[0].level=level),this.appendToken(token,level,void 0,0,!1,void 0,!1),this.linePrinter.print()}resolveCommentExportMode(option){return option===void 0?"none":option===!0?"full":option===!1?"none":option}rendersInlineComments(){return this.commentExportMode==="full"}shouldRenderComment(token,context){if(context?.forceRender)return this.commentExportMode!=="none";switch(this.commentExportMode){case"full":return!0;case"none":return!1;case"header-only":return token.isHeaderComment===!0;case"top-header-only":return token.isHeaderComment===!0&&!!context?.isTopLevelContainer;default:return!1}}appendToken(token,level,parentContainerType,caseContextDepth=0,indentParentActive=!1,commentContext,previousSiblingWasOpenParen=!1){let wasInsideWithClause=this.insideWithClause;if(token.containerType==="WithClause"&&this.withClauseStyle==="full-oneline"&&(this.insideWithClause=!0),this.shouldSkipToken(token))return;let containerIsTopLevel=parentContainerType===void 0,leadingCommentCount=0,leadingCommentContexts=[];if(token.innerTokens&&token.innerTokens.length>0)for(;leadingCommentCountitem.shouldRender),leadingCommentIndentLevel=hasRenderableLeadingComment?this.getLeadingCommentIndentLevel(parentContainerType,level):null;hasRenderableLeadingComment&&!this.isOnelineMode()&&this.shouldAddNewlineBeforeLeadingComments(parentContainerType)&&this.linePrinter.getCurrentLine().text.trim().length>0&&this.linePrinter.appendNewline(leadingCommentIndentLevel??level);for(let leading of leadingCommentContexts)leading.shouldRender&&this.appendToken(leading.token,leadingCommentIndentLevel??level,token.containerType,caseContextDepth,indentParentActive,leading.context,!1);if(this.smartCommentBlockBuilder&&token.containerType!=="CommentBlock"&&token.type!==12&&this.flushSmartCommentBlockBuilder(),this.pendingLineCommentBreak!==null){this.isOnelineMode()||this.linePrinter.appendNewline(this.pendingLineCommentBreak);let shouldSkipToken=token.type===12;if(this.pendingLineCommentBreak=null,shouldSkipToken)return}let effectiveCommentContext=commentContext??{position:"inline",isTopLevelContainer:containerIsTopLevel};if(token.containerType==="CommentBlock"){if(!this.shouldRenderComment(token,effectiveCommentContext))return;let commentLevel=this.getCommentBaseIndentLevel(level,parentContainerType);this.handleCommentBlockContainer(token,commentLevel,effectiveCommentContext);return}let current=this.linePrinter.getCurrentLine(),nextCaseContextDepth=this.isCaseContext(token.containerType)?caseContextDepth+1:caseContextDepth,shouldIndentNested=this.shouldIndentNestedParentheses(token,previousSiblingWasOpenParen);if(token.type===1)this.handleKeywordToken(token,level,parentContainerType,caseContextDepth);else if(token.type===3)this.handleCommaToken(token,level,parentContainerType);else if(token.type===4)this.handleParenthesisToken(token,level,indentParentActive,parentContainerType);else if(token.type===5&&token.text.toLowerCase()==="and")this.handleAndOperatorToken(token,level,parentContainerType,caseContextDepth);else if(token.type===5&&token.text.toLowerCase()==="or")this.handleOrOperatorToken(token,level,parentContainerType,caseContextDepth);else if(token.containerType==="JoinClause")this.handleJoinClauseToken(token,level);else if(token.type===6){if(this.shouldRenderComment(token,effectiveCommentContext)){let commentLevel=this.getCommentBaseIndentLevel(level,parentContainerType);this.printCommentToken(token.text,commentLevel,parentContainerType)}}else if(token.type===10)this.handleSpaceToken(token,parentContainerType);else if(token.type===12){if(this.whenOneLine&&parentContainerType==="MergeWhenClause")return;let commentLevel=this.getCommentBaseIndentLevel(level,parentContainerType);this.handleCommentNewlineToken(token,commentLevel)}else if(token.containerType==="CommonTable"&&this.withClauseStyle==="cte-oneline"){this.handleCteOnelineToken(token,level);return}else if(this.shouldFormatContainerAsOneline(token,shouldIndentNested)){this.handleOnelineToken(token,level);return}else this.tryAppendInsertClauseTokenText(token.text,parentContainerType)||this.linePrinter.appendText(token.text);if(token.keywordTokens&&token.keywordTokens.length>0)for(let i=0;i0){this.linePrinter.appendText(text);return}this.ensureSpaceBeforeKeyword(),this.linePrinter.appendText(text)}ensureSpaceBeforeKeyword(){let currentLine=this.linePrinter.getCurrentLine();currentLine.text===""||currentLine.text[currentLine.text.length-1]==="("||this.ensureTrailingSpace()}ensureTrailingSpace(){let currentLine=this.linePrinter.getCurrentLine();currentLine.text!==""&&(currentLine.text.endsWith(" ")||(currentLine.text+=" "),currentLine.text=currentLine.text.replace(/\s+$/," "))}tryAppendInsertClauseTokenText(text,parentContainerType){let currentLineText=this.linePrinter.getCurrentLine().text,result=this.onelineHelper.formatInsertClauseToken(text,parentContainerType,currentLineText,()=>this.ensureTrailingSpace());return result.handled?(result.text&&this.linePrinter.appendText(result.text),!0):!1}handleCommaToken(token,level,parentContainerType){let text=token.text,isWithinWithClause=parentContainerType==="WithClause",effectiveCommaBreak=this.onelineHelper.resolveCommaBreak(parentContainerType,this.commaBreak,this.cteCommaBreak,this.valuesCommaBreak);if(parentContainerType==="SetClause"&&(effectiveCommaBreak="before"),this.insideWithClause&&this.withClauseStyle==="full-oneline")this.linePrinter.appendText(text);else if(this.withClauseStyle==="cte-oneline"&&isWithinWithClause)this.linePrinter.appendText(text),this.linePrinter.appendNewline(level);else if(effectiveCommaBreak==="before"){let previousCommaBreak=this.linePrinter.commaBreak;previousCommaBreak!=="before"&&(this.linePrinter.commaBreak="before"),this.insideWithClause&&this.withClauseStyle==="full-oneline"||(this.linePrinter.appendNewline(level),this.newline===" "&&this.linePrinter.trimTrailingWhitespaceFromPreviousLine(),parentContainerType==="InsertClause"&&(this.linePrinter.getCurrentLine().level=level+1)),this.linePrinter.appendText(text),previousCommaBreak!=="before"&&(this.linePrinter.commaBreak=previousCommaBreak)}else if(effectiveCommaBreak==="after"){let previousCommaBreak=this.linePrinter.commaBreak;previousCommaBreak!=="after"&&(this.linePrinter.commaBreak="after"),this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level),this.linePrinter.appendText(text),this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level),previousCommaBreak!=="after"&&(this.linePrinter.commaBreak=previousCommaBreak)}else if(effectiveCommaBreak==="none"){let previousCommaBreak=this.linePrinter.commaBreak;previousCommaBreak!=="none"&&(this.linePrinter.commaBreak="none"),this.linePrinter.appendText(text),this.onelineHelper.isInsertClauseOneline(parentContainerType)&&this.ensureTrailingSpace(),previousCommaBreak!=="none"&&(this.linePrinter.commaBreak=previousCommaBreak)}else this.linePrinter.appendText(text)}handleAndOperatorToken(token,level,parentContainerType,caseContextDepth=0){let text=this.applyKeywordCase(token.text);if(caseContextDepth>0){this.linePrinter.appendText(text);return}if(this.whenOneLine&&(parentContainerType==="MergeWhenClause"||this.mergeWhenPredicateDepth>0)){this.linePrinter.appendText(text);return}this.andBreak==="before"?(this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level),this.linePrinter.appendText(text)):this.andBreak==="after"?(this.linePrinter.appendText(text),this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level)):this.linePrinter.appendText(text)}handleParenthesisToken(token,level,indentParentActive,parentContainerType){if(token.text==="("){if(this.linePrinter.appendText(token.text),(parentContainerType==="InsertClause"||parentContainerType==="MergeInsertAction")&&this.insertColumnsOneLine)return;this.isOnelineMode()||(this.shouldBreakAfterOpeningParen(parentContainerType)?this.linePrinter.appendNewline(level+1):indentParentActive&&!(this.insideWithClause&&this.withClauseStyle==="full-oneline")&&this.linePrinter.appendNewline(level));return}if(token.text===")"&&!this.isOnelineMode()){if(this.shouldBreakBeforeClosingParen(parentContainerType)){this.linePrinter.appendNewline(Math.max(level,0)),this.linePrinter.appendText(token.text);return}if(indentParentActive&&!(this.insideWithClause&&this.withClauseStyle==="full-oneline")){let closingLevel=Math.max(level-1,0);this.linePrinter.appendNewline(closingLevel)}}this.linePrinter.appendText(token.text)}handleOrOperatorToken(token,level,parentContainerType,caseContextDepth=0){let text=this.applyKeywordCase(token.text);if(caseContextDepth>0){this.linePrinter.appendText(text);return}if(this.whenOneLine&&(parentContainerType==="MergeWhenClause"||this.mergeWhenPredicateDepth>0)){this.linePrinter.appendText(text);return}this.orBreak==="before"?(this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level),this.linePrinter.appendText(text)):this.orBreak==="after"?(this.linePrinter.appendText(text),this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level)):this.linePrinter.appendText(text)}shouldIndentNestedParentheses(token,previousSiblingWasOpenParen=!1){return!this.indentNestedParentheses||token.containerType!=="ParenExpression"?!1:previousSiblingWasOpenParen||token.innerTokens.some(child=>this.containsParenExpression(child))}containsParenExpression(token){if(token.containerType==="ParenExpression")return!0;for(let child of token.innerTokens)if(this.containsParenExpression(child))return!0;return!1}handleJoinClauseToken(token,level){let text=this.applyKeywordCase(token.text);this.onelineHelper.shouldInsertJoinNewline(this.insideWithClause)&&this.linePrinter.appendNewline(level),this.linePrinter.appendText(text)}shouldFormatContainerAsOneline(token,shouldIndentNested){return this.onelineHelper.shouldFormatContainer(token,shouldIndentNested)}isInsertClauseOneline(parentContainerType){return this.onelineHelper.isInsertClauseOneline(parentContainerType)}handleSpaceToken(token,parentContainerType,nextToken,previousToken,priorToken){this.smartCommentBlockBuilder&&this.smartCommentBlockBuilder.mode==="line"&&this.flushSmartCommentBlockBuilder();let currentLineText=this.linePrinter.getCurrentLine().text;if(!this.onelineHelper.shouldSkipInsertClauseSpace(parentContainerType,nextToken,currentLineText)){if(this.onelineHelper.shouldSkipCommentBlockSpace(parentContainerType,this.insideWithClause)){let currentLine=this.linePrinter.getCurrentLine();currentLine.text!==""&&!currentLine.text.endsWith(" ")&&this.linePrinter.appendText(" ");return}this.shouldSkipSpaceBeforeParenthesis(parentContainerType,nextToken,previousToken,priorToken)||this.linePrinter.appendText(token.text)}}findPreviousSignificantToken(tokens,index){for(let i=index-1;i>=0;i--){let candidate=tokens[i];if(!(candidate.type===10||candidate.type===12)&&!(candidate.type===6&&!this.rendersInlineComments()))return{token:candidate,index:i}}}shouldSkipSpaceBeforeParenthesis(parentContainerType,nextToken,previousToken,priorToken){return!nextToken||nextToken.type!==4||nextToken.text!=="("||!parentContainerType||!this.isCreateTableSpacingContext(parentContainerType)||!previousToken?!1:!!(this.isCreateTableNameToken(previousToken,parentContainerType)||this.isCreateTableConstraintKeyword(previousToken,parentContainerType)||priorToken&&this.isCreateTableConstraintKeyword(priorToken,parentContainerType)&&this.isIdentifierAttachedToConstraint(previousToken,priorToken,parentContainerType))}shouldAlignCreateTableSelect(containerType,parentContainerType){return containerType==="SimpleSelectQuery"&&parentContainerType==="CreateTableQuery"}isCreateTableSpacingContext(parentContainerType){switch(parentContainerType){case"CreateTableQuery":case"CreateTableDefinition":case"TableConstraintDefinition":case"ColumnConstraintDefinition":case"ReferenceDefinition":return!0;default:return!1}}isCreateTableNameToken(previousToken,parentContainerType){return parentContainerType!=="CreateTableQuery"?!1:previousToken.containerType==="QualifiedName"}isCreateTableConstraintKeyword(token,parentContainerType){if(token.type!==1)return!1;let text=token.text.toLowerCase();return parentContainerType==="ReferenceDefinition"?CREATE_TABLE_PAREN_KEYWORDS_WITH_IDENTIFIER.has(text):!!(CREATE_TABLE_SINGLE_PAREN_KEYWORDS.has(text)||CREATE_TABLE_MULTI_PAREN_KEYWORDS.has(text))}isIdentifierAttachedToConstraint(token,keywordToken,parentContainerType){if(!token)return!1;if(parentContainerType==="ReferenceDefinition")return token.containerType==="QualifiedName"&&CREATE_TABLE_PAREN_KEYWORDS_WITH_IDENTIFIER.has(keywordToken.text.toLowerCase());if(parentContainerType==="TableConstraintDefinition"||parentContainerType==="ColumnConstraintDefinition"){let normalized=keywordToken.text.toLowerCase();if(CREATE_TABLE_SINGLE_PAREN_KEYWORDS.has(normalized)||CREATE_TABLE_MULTI_PAREN_KEYWORDS.has(normalized))return token.containerType==="IdentifierString"}return!1}printCommentToken(text,level,parentContainerType){let trimmed=text.trim();if(trimmed&&!(this.commentStyle==="smart"&&parentContainerType==="CommentBlock"&&this.handleSmartCommentBlockToken(text,trimmed,level)))if(this.commentStyle==="smart"){let normalized=this.normalizeCommentForSmart(trimmed);if(normalized.lines.length>1||normalized.forceBlock){let blockText=this.buildBlockComment(normalized.lines,level);this.linePrinter.appendText(blockText)}else{let content=normalized.lines[0],lineText=content?`-- ${content}`:"--";if(parentContainerType==="CommentBlock")this.linePrinter.appendText(lineText),this.pendingLineCommentBreak=this.resolveCommentIndentLevel(level,parentContainerType);else{this.linePrinter.appendText(lineText);let effectiveLevel=this.resolveCommentIndentLevel(level,parentContainerType);this.linePrinter.appendNewline(effectiveLevel)}}}else{if(trimmed.startsWith("/*")&&trimmed.endsWith("*/"))if(/\r?\n/.test(trimmed)){let newlineReplacement=this.isOnelineMode()?" ":typeof this.newline=="string"?this.newline:` +`,cr:"\r"},IDENTIFIER_ESCAPE_MAP={quote:{start:'"',end:'"'},backtick:{start:"`",end:"`"},bracket:{start:"[",end:"]"},none:{start:"",end:""}};function resolveIndentCharOption(option){if(option===void 0)return;let normalized=typeof option=="string"?option.toLowerCase():option;return typeof normalized=="string"&&Object.prototype.hasOwnProperty.call(INDENT_CHAR_MAP,normalized)?INDENT_CHAR_MAP[normalized]:option}function resolveNewlineOption(option){if(option===void 0)return;let normalized=typeof option=="string"?option.toLowerCase():option;return typeof normalized=="string"&&Object.prototype.hasOwnProperty.call(NEWLINE_MAP,normalized)?NEWLINE_MAP[normalized]:option}function resolveIdentifierEscapeOption(option,target="all"){if(option===void 0)return;if(typeof option=="string"){let normalized=option.toLowerCase();if(!Object.prototype.hasOwnProperty.call(IDENTIFIER_ESCAPE_MAP,normalized))throw new Error(`Unknown identifierEscape option: ${option}`);let mapped=IDENTIFIER_ESCAPE_MAP[normalized];return{start:mapped.start,end:mapped.end,target}}let start=option.start??"",end=option.end??"";return{start,end,target}}var OnelineFormattingHelper=class{constructor(options){this.options=options}shouldFormatContainer(token,shouldIndentNested){switch(token.containerType){case"ParenExpression":return this.options.parenthesesOneLine&&!shouldIndentNested;case"BetweenExpression":return this.options.betweenOneLine;case"Values":return this.options.valuesOneLine;case"JoinOnClause":return this.options.joinOneLine;case"CaseExpression":return this.options.caseOneLine;case"InlineQuery":return this.options.subqueryOneLine;default:return!1}}isInsertClauseOneline(parentContainerType){return this.options.insertColumnsOneLine?parentContainerType==="InsertClause"||parentContainerType==="MergeInsertAction":!1}shouldInsertJoinNewline(insideWithClause){return!(insideWithClause&&this.options.withClauseStyle==="full-oneline")}resolveCommaBreak(parentContainerType,commaBreak,cteCommaBreak,valuesCommaBreak){return parentContainerType==="WithClause"?cteCommaBreak:parentContainerType==="AnalyzeStatement"||parentContainerType==="ExplainStatement"?"none":parentContainerType==="Values"?valuesCommaBreak:this.isInsertClauseOneline(parentContainerType)?"none":commaBreak}shouldSkipInsertClauseSpace(parentContainerType,nextToken,currentLineText){if(!(parentContainerType==="InsertClause"||parentContainerType==="MergeInsertAction"))return!1;if(nextToken&&nextToken.type===4&&nextToken.text==="(")return!0;if(!this.options.insertColumnsOneLine)return!1;let lastChar=currentLineText.slice(-1);return lastChar==="("||lastChar===" "||lastChar===""}shouldSkipCommentBlockSpace(parentContainerType,insideWithClause){return parentContainerType==="CommentBlock"&&insideWithClause&&this.options.withClauseStyle==="full-oneline"}formatInsertClauseToken(text,parentContainerType,currentLineText,ensureTrailingSpace){if(!this.isInsertClauseOneline(parentContainerType))return{handled:!1};if(text==="")return{handled:!0};let leadingWhitespace=text.match(/^\s+/)?.[0]??"",trimmed=leadingWhitespace?text.slice(leadingWhitespace.length):text;if(trimmed==="")return{handled:!0};if(leadingWhitespace){let lastChar=currentLineText.slice(-1);lastChar!=="("&&lastChar!==" "&&lastChar!==""&&ensureTrailingSpace()}return{handled:!0,text:trimmed}}};var CREATE_TABLE_SINGLE_PAREN_KEYWORDS=new Set(["unique","check","key","index"]),CREATE_TABLE_MULTI_PAREN_KEYWORDS=new Set(["primary key","foreign key","unique key"]),CREATE_TABLE_PAREN_KEYWORDS_WITH_IDENTIFIER=new Set(["references"]),SqlPrinter=class _SqlPrinter{constructor(options){this.insideWithClause=!1;this.mergeWhenPredicateDepth=0;this.pendingLineCommentBreak=null;this.smartCommentBlockBuilder=null;let resolvedIndentChar=resolveIndentCharOption(options?.indentChar),resolvedNewline=resolveNewlineOption(options?.newline);this.indentChar=resolvedIndentChar??"",this.indentSize=options?.indentSize??0,this.newline=resolvedNewline??" ",this.commaBreak=options?.commaBreak??"none",this.cteCommaBreak=options?.cteCommaBreak??this.commaBreak,this.valuesCommaBreak=options?.valuesCommaBreak??this.commaBreak,this.andBreak=options?.andBreak??"none",this.orBreak=options?.orBreak??"none",this.keywordCase=options?.keywordCase??"none",this.commentExportMode=this.resolveCommentExportMode(options?.exportComment),this.withClauseStyle=options?.withClauseStyle??"standard",this.commentStyle=options?.commentStyle??"block",this.parenthesesOneLine=options?.parenthesesOneLine??!1,this.betweenOneLine=options?.betweenOneLine??!1,this.valuesOneLine=options?.valuesOneLine??!1,this.joinOneLine=options?.joinOneLine??!1,this.caseOneLine=options?.caseOneLine??!1,this.subqueryOneLine=options?.subqueryOneLine??!1,this.indentNestedParentheses=options?.indentNestedParentheses??!1,this.insertColumnsOneLine=options?.insertColumnsOneLine??!1,this.whenOneLine=options?.whenOneLine??!1;let onelineOptions={parenthesesOneLine:this.parenthesesOneLine,betweenOneLine:this.betweenOneLine,valuesOneLine:this.valuesOneLine,joinOneLine:this.joinOneLine,caseOneLine:this.caseOneLine,subqueryOneLine:this.subqueryOneLine,insertColumnsOneLine:this.insertColumnsOneLine,withClauseStyle:this.withClauseStyle};this.onelineHelper=new OnelineFormattingHelper(onelineOptions),this.linePrinter=new LinePrinter(this.indentChar,this.indentSize,this.newline,this.commaBreak),this.indentIncrementContainers=new Set(options?.indentIncrementContainerTypes??["SelectClause","ReturningClause","FromClause","WhereClause","GroupByClause","HavingClause","WindowFrameExpression","PartitionByClause","OrderByClause","WindowClause","LimitClause","OffsetClause","SubQuerySource","BinarySelectQueryOperator","Values","WithClause","SwitchCaseArgument","CaseKeyValuePair","CaseThenValue","ElseClause","CaseElseValue","SimpleSelectQuery","CreateTableDefinition","AlterTableStatement","IndexColumnList","SetClause"])}print(token,level=0){return this.linePrinter=new LinePrinter(this.indentChar,this.indentSize,this.newline,this.commaBreak),this.insideWithClause=!1,this.pendingLineCommentBreak=null,this.smartCommentBlockBuilder=null,this.linePrinter.lines.length>0&&level!==this.linePrinter.lines[0].level&&(this.linePrinter.lines[0].level=level),this.appendToken(token,level,void 0,0,!1,void 0,!1),this.linePrinter.print()}resolveCommentExportMode(option){return option===void 0?"none":option===!0?"full":option===!1?"none":option}rendersInlineComments(){return this.commentExportMode==="full"}shouldRenderComment(token,context){if(context?.forceRender)return this.commentExportMode!=="none";switch(this.commentExportMode){case"full":return!0;case"none":return!1;case"header-only":return token.isHeaderComment===!0;case"top-header-only":return token.isHeaderComment===!0&&!!context?.isTopLevelContainer;default:return!1}}appendToken(token,level,parentContainerType,caseContextDepth=0,indentParentActive=!1,commentContext,previousSiblingWasOpenParen=!1){let wasInsideWithClause=this.insideWithClause;if(token.containerType==="WithClause"&&this.withClauseStyle==="full-oneline"&&(this.insideWithClause=!0),this.shouldSkipToken(token))return;let containerIsTopLevel=parentContainerType===void 0,leadingCommentCount=0,leadingCommentContexts=[];if(token.innerTokens&&token.innerTokens.length>0)for(;leadingCommentCountitem.shouldRender),leadingCommentIndentLevel=hasRenderableLeadingComment?this.getLeadingCommentIndentLevel(parentContainerType,level):null;hasRenderableLeadingComment&&!this.isOnelineMode()&&this.shouldAddNewlineBeforeLeadingComments(parentContainerType)&&this.linePrinter.getCurrentLine().text.trim().length>0&&this.linePrinter.appendNewline(leadingCommentIndentLevel??level);for(let leading of leadingCommentContexts)leading.shouldRender&&this.appendToken(leading.token,leadingCommentIndentLevel??level,token.containerType,caseContextDepth,indentParentActive,leading.context,!1);if(this.smartCommentBlockBuilder&&token.containerType!=="CommentBlock"&&token.type!==12&&this.flushSmartCommentBlockBuilder(),this.pendingLineCommentBreak!==null){this.isOnelineMode()||this.linePrinter.appendNewline(this.pendingLineCommentBreak);let shouldSkipToken=token.type===12;if(this.pendingLineCommentBreak=null,shouldSkipToken)return}let effectiveCommentContext=commentContext??{position:"inline",isTopLevelContainer:containerIsTopLevel};if(token.containerType==="CommentBlock"){if(!this.shouldRenderComment(token,effectiveCommentContext))return;let commentLevel=this.getCommentBaseIndentLevel(level,parentContainerType);this.handleCommentBlockContainer(token,commentLevel,effectiveCommentContext);return}let current=this.linePrinter.getCurrentLine(),nextCaseContextDepth=this.isCaseContext(token.containerType)?caseContextDepth+1:caseContextDepth,shouldIndentNested=this.shouldIndentNestedParentheses(token,previousSiblingWasOpenParen);if(token.type===1)this.handleKeywordToken(token,level,parentContainerType,caseContextDepth);else if(token.type===3)this.handleCommaToken(token,level,parentContainerType);else if(token.type===4)this.handleParenthesisToken(token,level,indentParentActive,parentContainerType);else if(token.type===5&&token.text.toLowerCase()==="and")this.handleAndOperatorToken(token,level,parentContainerType,caseContextDepth);else if(token.type===5&&token.text.toLowerCase()==="or")this.handleOrOperatorToken(token,level,parentContainerType,caseContextDepth);else if(token.containerType==="JoinClause")this.handleJoinClauseToken(token,level);else if(token.type===6){if(this.shouldRenderComment(token,effectiveCommentContext)){let commentLevel=this.getCommentBaseIndentLevel(level,parentContainerType);this.printCommentToken(token.text,commentLevel,parentContainerType)}}else if(token.type===10)this.handleSpaceToken(token,parentContainerType);else if(token.type===12){if(this.whenOneLine&&parentContainerType==="MergeWhenClause")return;let commentLevel=this.getCommentBaseIndentLevel(level,parentContainerType);this.handleCommentNewlineToken(token,commentLevel)}else if(token.containerType==="CommonTable"&&this.withClauseStyle==="cte-oneline"){this.handleCteOnelineToken(token,level);return}else if(this.shouldFormatContainerAsOneline(token,shouldIndentNested)){this.handleOnelineToken(token,level);return}else this.tryAppendInsertClauseTokenText(token.text,parentContainerType)||this.linePrinter.appendText(token.text);if(token.keywordTokens&&token.keywordTokens.length>0)for(let i=0;i0){this.linePrinter.appendText(text);return}this.ensureSpaceBeforeKeyword(),this.linePrinter.appendText(text)}ensureSpaceBeforeKeyword(){let currentLine=this.linePrinter.getCurrentLine();currentLine.text===""||currentLine.text[currentLine.text.length-1]==="("||this.ensureTrailingSpace()}ensureTrailingSpace(){let currentLine=this.linePrinter.getCurrentLine();currentLine.text!==""&&(currentLine.text.endsWith(" ")||(currentLine.text+=" "),currentLine.text=currentLine.text.replace(/\s+$/," "))}tryAppendInsertClauseTokenText(text,parentContainerType){let currentLineText=this.linePrinter.getCurrentLine().text,result=this.onelineHelper.formatInsertClauseToken(text,parentContainerType,currentLineText,()=>this.ensureTrailingSpace());return result.handled?(result.text&&this.linePrinter.appendText(result.text),!0):!1}handleCommaToken(token,level,parentContainerType){let text=token.text,isWithinWithClause=parentContainerType==="WithClause",effectiveCommaBreak=this.onelineHelper.resolveCommaBreak(parentContainerType,this.commaBreak,this.cteCommaBreak,this.valuesCommaBreak);if(parentContainerType==="SetClause"&&(effectiveCommaBreak="before"),this.insideWithClause&&this.withClauseStyle==="full-oneline")this.linePrinter.appendText(text);else if(this.withClauseStyle==="cte-oneline"&&isWithinWithClause)this.linePrinter.appendText(text),this.linePrinter.appendNewline(level);else if(effectiveCommaBreak==="before"){let previousCommaBreak=this.linePrinter.commaBreak;previousCommaBreak!=="before"&&(this.linePrinter.commaBreak="before"),this.insideWithClause&&this.withClauseStyle==="full-oneline"||(this.linePrinter.appendNewline(level),this.newline===" "&&this.linePrinter.trimTrailingWhitespaceFromPreviousLine(),parentContainerType==="InsertClause"&&(this.linePrinter.getCurrentLine().level=level+1)),this.linePrinter.appendText(text),previousCommaBreak!=="before"&&(this.linePrinter.commaBreak=previousCommaBreak)}else if(effectiveCommaBreak==="after"){let previousCommaBreak=this.linePrinter.commaBreak;previousCommaBreak!=="after"&&(this.linePrinter.commaBreak="after"),this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level),this.linePrinter.appendText(text),this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level),previousCommaBreak!=="after"&&(this.linePrinter.commaBreak=previousCommaBreak)}else if(effectiveCommaBreak==="none"){let previousCommaBreak=this.linePrinter.commaBreak;previousCommaBreak!=="none"&&(this.linePrinter.commaBreak="none"),this.linePrinter.appendText(text),this.onelineHelper.isInsertClauseOneline(parentContainerType)&&this.ensureTrailingSpace(),previousCommaBreak!=="none"&&(this.linePrinter.commaBreak=previousCommaBreak)}else this.linePrinter.appendText(text)}handleAndOperatorToken(token,level,parentContainerType,caseContextDepth=0){let text=this.applyKeywordCase(token.text);if(caseContextDepth>0){this.linePrinter.appendText(text);return}if(this.whenOneLine&&(parentContainerType==="MergeWhenClause"||this.mergeWhenPredicateDepth>0)){this.linePrinter.appendText(text);return}this.andBreak==="before"?(this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level),this.linePrinter.appendText(text)):this.andBreak==="after"?(this.linePrinter.appendText(text),this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level)):this.linePrinter.appendText(text)}handleParenthesisToken(token,level,indentParentActive,parentContainerType){if(token.text==="("){if(this.linePrinter.appendText(token.text),(parentContainerType==="InsertClause"||parentContainerType==="MergeInsertAction")&&this.insertColumnsOneLine)return;this.isOnelineMode()||(this.shouldBreakAfterOpeningParen(parentContainerType)?this.linePrinter.appendNewline(level+1):indentParentActive&&!(this.insideWithClause&&this.withClauseStyle==="full-oneline")&&this.linePrinter.appendNewline(level));return}if(token.text===")"&&!this.isOnelineMode()){if(this.shouldBreakBeforeClosingParen(parentContainerType)){this.linePrinter.appendNewline(Math.max(level,0)),this.linePrinter.appendText(token.text);return}if(indentParentActive&&!(this.insideWithClause&&this.withClauseStyle==="full-oneline")){let closingLevel=Math.max(level-1,0);this.linePrinter.appendNewline(closingLevel)}}this.linePrinter.appendText(token.text)}handleOrOperatorToken(token,level,parentContainerType,caseContextDepth=0){let text=this.applyKeywordCase(token.text);if(caseContextDepth>0){this.linePrinter.appendText(text);return}if(this.whenOneLine&&(parentContainerType==="MergeWhenClause"||this.mergeWhenPredicateDepth>0)){this.linePrinter.appendText(text);return}this.orBreak==="before"?(this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level),this.linePrinter.appendText(text)):this.orBreak==="after"?(this.linePrinter.appendText(text),this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.linePrinter.appendNewline(level)):this.linePrinter.appendText(text)}shouldIndentNestedParentheses(token,previousSiblingWasOpenParen=!1){return!this.indentNestedParentheses||token.containerType!=="ParenExpression"?!1:previousSiblingWasOpenParen||token.innerTokens.some(child=>this.containsParenExpression(child))}containsParenExpression(token){if(token.containerType==="ParenExpression")return!0;for(let child of token.innerTokens)if(this.containsParenExpression(child))return!0;return!1}handleJoinClauseToken(token,level){let text=this.applyKeywordCase(token.text);this.onelineHelper.shouldInsertJoinNewline(this.insideWithClause)&&this.linePrinter.appendNewline(level),this.linePrinter.appendText(text)}shouldFormatContainerAsOneline(token,shouldIndentNested){return this.onelineHelper.shouldFormatContainer(token,shouldIndentNested)}isInsertClauseOneline(parentContainerType){return this.onelineHelper.isInsertClauseOneline(parentContainerType)}handleSpaceToken(token,parentContainerType,nextToken,previousToken,priorToken){this.smartCommentBlockBuilder&&this.smartCommentBlockBuilder.mode==="line"&&this.flushSmartCommentBlockBuilder();let currentLineText=this.linePrinter.getCurrentLine().text;if(!this.onelineHelper.shouldSkipInsertClauseSpace(parentContainerType,nextToken,currentLineText)){if(this.onelineHelper.shouldSkipCommentBlockSpace(parentContainerType,this.insideWithClause)){let currentLine=this.linePrinter.getCurrentLine();currentLine.text!==""&&!currentLine.text.endsWith(" ")&&this.linePrinter.appendText(" ");return}this.shouldSkipSpaceBeforeParenthesis(parentContainerType,nextToken,previousToken,priorToken)||this.linePrinter.appendText(token.text)}}findPreviousSignificantToken(tokens,index){for(let i=index-1;i>=0;i--){let candidate=tokens[i];if(!(candidate.type===10||candidate.type===12)&&!(candidate.type===6&&!this.rendersInlineComments()))return{token:candidate,index:i}}}shouldSkipSpaceBeforeParenthesis(parentContainerType,nextToken,previousToken,priorToken){return!nextToken||nextToken.type!==4||nextToken.text!=="("||!parentContainerType||!this.isCreateTableSpacingContext(parentContainerType)||!previousToken?!1:!!(this.isCreateTableNameToken(previousToken,parentContainerType)||this.isCreateTableConstraintKeyword(previousToken,parentContainerType)||priorToken&&this.isCreateTableConstraintKeyword(priorToken,parentContainerType)&&this.isIdentifierAttachedToConstraint(previousToken,priorToken,parentContainerType))}shouldAlignCreateTableSelect(containerType,parentContainerType){return containerType==="SimpleSelectQuery"&&parentContainerType==="CreateTableQuery"}isCreateTableSpacingContext(parentContainerType){switch(parentContainerType){case"CreateTableQuery":case"CreateTableDefinition":case"TableConstraintDefinition":case"ColumnConstraintDefinition":case"ReferenceDefinition":return!0;default:return!1}}isCreateTableNameToken(previousToken,parentContainerType){return parentContainerType!=="CreateTableQuery"?!1:previousToken.containerType==="QualifiedName"}isCreateTableConstraintKeyword(token,parentContainerType){if(token.type!==1)return!1;let text=token.text.toLowerCase();return parentContainerType==="ReferenceDefinition"?CREATE_TABLE_PAREN_KEYWORDS_WITH_IDENTIFIER.has(text):!!(CREATE_TABLE_SINGLE_PAREN_KEYWORDS.has(text)||CREATE_TABLE_MULTI_PAREN_KEYWORDS.has(text))}isIdentifierAttachedToConstraint(token,keywordToken,parentContainerType){if(!token)return!1;if(parentContainerType==="ReferenceDefinition")return token.containerType==="QualifiedName"&&CREATE_TABLE_PAREN_KEYWORDS_WITH_IDENTIFIER.has(keywordToken.text.toLowerCase());if(parentContainerType==="TableConstraintDefinition"||parentContainerType==="ColumnConstraintDefinition"){let normalized=keywordToken.text.toLowerCase();if(CREATE_TABLE_SINGLE_PAREN_KEYWORDS.has(normalized)||CREATE_TABLE_MULTI_PAREN_KEYWORDS.has(normalized))return token.containerType==="IdentifierString"}return!1}printCommentToken(text,level,parentContainerType){let trimmed=text.trim();if(trimmed&&!(this.commentStyle==="smart"&&parentContainerType==="CommentBlock"&&this.handleSmartCommentBlockToken(text,trimmed,level)))if(this.commentStyle==="smart"){let normalized=this.normalizeCommentForSmart(trimmed);if(normalized.lines.length>1||normalized.forceBlock){let blockText=this.buildBlockComment(normalized.lines,level);this.linePrinter.appendText(blockText)}else{let content=normalized.lines[0],lineText=content?`-- ${content}`:"--";if(parentContainerType==="CommentBlock")this.linePrinter.appendText(lineText),this.pendingLineCommentBreak=this.resolveCommentIndentLevel(level,parentContainerType);else{this.linePrinter.appendText(lineText);let effectiveLevel=this.resolveCommentIndentLevel(level,parentContainerType);this.linePrinter.appendNewline(effectiveLevel)}}}else{if(trimmed.startsWith("/*")&&trimmed.endsWith("*/"))if(/\r?\n/.test(trimmed)){let newlineReplacement=this.isOnelineMode()?" ":typeof this.newline=="string"?this.newline:` `,normalized=trimmed.replace(/\r?\n/g,newlineReplacement);this.linePrinter.appendText(normalized)}else this.linePrinter.appendText(trimmed);else this.linePrinter.appendText(trimmed);if(trimmed.startsWith("--"))if(parentContainerType==="CommentBlock")this.pendingLineCommentBreak=this.resolveCommentIndentLevel(level,parentContainerType);else{let effectiveLevel=this.resolveCommentIndentLevel(level,parentContainerType);this.linePrinter.appendNewline(effectiveLevel)}}}handleSmartCommentBlockToken(raw,trimmed,level){if(!this.smartCommentBlockBuilder){if(trimmed==="/*")return this.smartCommentBlockBuilder={lines:[],level,mode:"block"},!0;let lineContent=this.extractLineCommentContent(trimmed);return lineContent!==null?(this.smartCommentBlockBuilder={lines:[lineContent],level,mode:"line"},!0):!1}if(this.smartCommentBlockBuilder.mode==="block"){if(trimmed==="*/"){let{lines,level:blockLevel}=this.smartCommentBlockBuilder,blockText=this.buildBlockComment(lines,blockLevel);return this.linePrinter.appendText(blockText),this.pendingLineCommentBreak=blockLevel,this.smartCommentBlockBuilder=null,!0}return this.smartCommentBlockBuilder.lines.push(this.normalizeSmartBlockLine(raw)),!0}let content=this.extractLineCommentContent(trimmed);return content!==null?(this.smartCommentBlockBuilder.lines.push(content),!0):(this.flushSmartCommentBlockBuilder(),!1)}handleCommentBlockContainer(token,level,context){if(this.commentStyle!=="smart"){let rawLines=this.extractRawCommentBlockLines(token);if(rawLines.length>0){let normalizedBlocks=rawLines.map(line=>`/* ${line} */`).join(" "),hasTrailingSpace=token.innerTokens?.some(child=>child.type===10&&child.text.includes(" "));this.linePrinter.appendText(hasTrailingSpace?`${normalizedBlocks} `:normalizedBlocks);return}for(let child of token.innerTokens){let childContext={position:context.position,isTopLevelContainer:context.isTopLevelContainer,forceRender:!0};this.appendToken(child,level,token.containerType,0,!1,childContext,!1)}return}let lines=this.collectCommentBlockLines(token);if(lines.length===0&&!this.smartCommentBlockBuilder){this.smartCommentBlockBuilder={lines:[""],level,mode:"line"};return}!this.smartCommentBlockBuilder||this.smartCommentBlockBuilder.mode!=="line"?this.smartCommentBlockBuilder={lines:[...lines],level,mode:"line"}:this.smartCommentBlockBuilder.lines.push(...lines)}normalizeSmartBlockLine(raw){let line=raw.replace(/\s+$/g,"");return line?(line.startsWith(" ")&&(line=line.slice(2)),line.startsWith("* ")?line.slice(2):line==="*"?"":line.startsWith("*")?line.slice(1):line):""}extractLineCommentContent(trimmed){return trimmed.startsWith("--")?trimmed.slice(2).trimStart():trimmed.startsWith("/*")&&trimmed.endsWith("*/")?trimmed.slice(2,-2).trim():null}flushSmartCommentBlockBuilder(){if(!this.smartCommentBlockBuilder)return;let{lines,level,mode}=this.smartCommentBlockBuilder;if(mode==="line"){if(lines.filter(line=>line.trim()!=="").length>1){let blockText=this.buildBlockComment(lines,level);this.linePrinter.appendText(blockText)}else{let content=lines[0]??"",lineText=content?`-- ${content}`:"--";this.linePrinter.appendText(lineText)}this.isOnelineMode()||this.linePrinter.appendNewline(level),this.pendingLineCommentBreak=null}this.smartCommentBlockBuilder=null}collectCommentBlockLines(token){let lines=[],collectingBlock=!1;for(let child of token.innerTokens??[])if(child.type===6){let trimmed=child.text.trim();if(trimmed==="/*"){collectingBlock=!0;continue}if(trimmed==="*/"){collectingBlock=!1;continue}if(collectingBlock){lines.push(this.normalizeSmartBlockLine(child.text));continue}let content=this.extractLineCommentContent(trimmed);content!==null&&lines.push(content)}return lines}extractRawCommentBlockLines(token){let lines=[],collectingBlock=!1;for(let child of token.innerTokens??[])if(child.type===6){let trimmed=child.text.trim();if(trimmed==="/*"){collectingBlock=!0;continue}if(trimmed==="*/"){collectingBlock=!1;continue}if(collectingBlock){trimmed.length>0&&lines.push(trimmed);continue}}return lines}normalizeCommentForSmart(text){let trimmed=text.trim(),source=trimmed,forceBlock=!1;if(trimmed.startsWith("--"))source=trimmed.slice(2);else if(trimmed.startsWith("/*")&&trimmed.endsWith("*/")){let inner=trimmed.slice(2,-2);inner.replace(/\r?\n/g,` `).includes(` `)?(forceBlock=!0,source=inner):(source=inner,source.trim()||(source=trimmed))}let rawSegments=this.escapeCommentDelimiters(source).replace(/\r?\n/g,` `).split(` `),processedLines=[],processedRaw=[];for(let segment of rawSegments){let rawTrimmed=segment.trim(),sanitized=this.sanitizeCommentLine(segment);sanitized.length>0&&(processedLines.push(sanitized),processedRaw.push(rawTrimmed))}let lines=processedLines;if(lines.length===0&&(lines=[""]),!forceBlock&&lines.length===1&&!lines[0]&&trimmed.startsWith("/*")&&trimmed.endsWith("*/")){let escapedFull=this.escapeCommentDelimiters(trimmed);lines=[this.sanitizeCommentLine(escapedFull)]}return!forceBlock&&lines.length>1&&(forceBlock=!0),lines=lines.map((line,index)=>{if(/^[-=_+*#]+$/.test(line)){let normalizedRaw=(processedRaw[index]??line).replace(/\s+/g,"");if(normalizedRaw.length>=line.length)return normalizedRaw}return line}),{lines,forceBlock}}buildBlockComment(lines,level){if(lines.length<=1){let content=lines[0]??"";return content?`/* ${content} */`:"/* */"}let newline=this.newline===" "?` -`:this.newline,currentLevel=this.linePrinter.getCurrentLine()?.level??level,baseIndent=this.getIndentString(currentLevel),innerIndent=baseIndent+" ",body=lines.map(line=>`${innerIndent}${line}`).join(newline),closing=`${baseIndent}*/`;return`/*${newline}${body}${newline}${closing}`}getIndentString(level){return level<=0?"":this.indentSize<=0?" ".repeat(level):(typeof this.indentChar=="string"?this.indentChar:"").repeat(this.indentSize*level)}sanitizeCommentLine(content){let sanitized=content;return sanitized=sanitized.replace(/\u2028|\u2029/g," "),sanitized=sanitized.replace(/\s+/g," ").trim(),sanitized}escapeCommentDelimiters(content){return content.replace(/\/\*/g,"\\/\\*").replace(/\*\//g,"*\\/")}getCommentBaseIndentLevel(level,parentContainerType){if(!parentContainerType)return level;let clauseAlignedLevel=this.getClauseBreakIndentLevel(parentContainerType,level);return Math.max(level,clauseAlignedLevel)}resolveCommentIndentLevel(level,parentContainerType){let baseLevel=this.getCommentBaseIndentLevel(level,parentContainerType),currentLevel=this.linePrinter.getCurrentLine().level??baseLevel;return Math.max(baseLevel,currentLevel)}handleCommentNewlineToken(token,level){if(!this.smartCommentBlockBuilder){if(this.pendingLineCommentBreak!==null){this.linePrinter.appendNewline(this.pendingLineCommentBreak),this.pendingLineCommentBreak=null;return}this.shouldSkipCommentNewline()||this.isOnelineMode()||this.linePrinter.appendNewline(level)}}shouldSkipCommentNewline(){return this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.withClauseStyle==="cte-oneline"}shouldAddNewlineBeforeLeadingComments(parentType){return parentType?parentType==="TupleExpression"?!0:parentType==="InsertClause"||parentType==="MergeInsertAction"?!this.insertColumnsOneLine:parentType==="SetClause"||parentType==="SelectClause"||parentType==="ExplainStatement"||parentType==="ReturningClause":!1}getLeadingCommentIndentLevel(parentType,currentLevel){return parentType==="TupleExpression"||parentType==="InsertClause"||parentType==="MergeInsertAction"||parentType==="SelectClause"||parentType==="ReturningClause"||parentType==="SetClause"?currentLevel+1:currentLevel}isOnelineMode(){return this.newline===" "}handleCteOnelineToken(token,level){let onelineResult=this.createCteOnelinePrinter().print(token,level),cleanedResult=this.cleanDuplicateSpaces(onelineResult);cleanedResult=cleanedResult.replace(/\(\s+/g,"(").replace(/\s+\)/g," )"),this.linePrinter.appendText(cleanedResult.trim())}createCteOnelinePrinter(){return new _SqlPrinter({indentChar:"",indentSize:0,newline:" ",commaBreak:this.commaBreak,cteCommaBreak:this.cteCommaBreak,valuesCommaBreak:this.valuesCommaBreak,andBreak:this.andBreak,orBreak:this.orBreak,keywordCase:this.keywordCase,exportComment:"none",withClauseStyle:"standard",indentNestedParentheses:!1,insertColumnsOneLine:this.insertColumnsOneLine})}handleOnelineToken(token,level){let onelineResult=this.createOnelinePrinter().print(token,level),cleanedResult=this.cleanDuplicateSpaces(onelineResult);this.linePrinter.appendText(cleanedResult)}getClauseBreakIndentLevel(parentType,level){if(!parentType)return level;switch(parentType){case"MergeWhenClause":return level+1;case"MergeUpdateAction":case"MergeDeleteAction":case"MergeInsertAction":return level+1;default:return level}}isMergeActionContainer(token){if(!token)return!1;switch(token.containerType){case"MergeUpdateAction":case"MergeDeleteAction":case"MergeInsertAction":case"MergeDoNothingAction":return!0;default:return!1}}shouldBreakAfterOpeningParen(parentType){return parentType&&(parentType==="InsertClause"||parentType==="MergeInsertAction"||parentType==="ReturningClause")?!this.isInsertClauseOneline(parentType):!1}shouldBreakBeforeClosingParen(parentType){return parentType&&(parentType==="InsertClause"||parentType==="MergeInsertAction")?!this.isInsertClauseOneline(parentType):!1}shouldConvertSpaceToClauseBreak(parentType,nextToken){if(!parentType||!nextToken)return!1;let nextKeyword=nextToken.type===1?nextToken.text.toLowerCase():null,nextContainer=nextToken.containerType;return!!(parentType==="MergeQuery"&&(nextKeyword==="using"||nextContainer==="MergeWhenClause")||parentType==="MergeWhenClause"&&(nextContainer==="MergeUpdateAction"||nextContainer==="MergeDeleteAction"||nextContainer==="MergeInsertAction"||nextContainer==="MergeDoNothingAction")||parentType==="UpdateQuery"&&(nextKeyword==="set"||nextKeyword==="from"||nextKeyword==="where"||nextKeyword==="returning")||parentType==="InsertQuery"&&(nextKeyword==="returning"||nextKeyword&&(nextKeyword.startsWith("select")||nextKeyword.startsWith("values"))||nextContainer==="ValuesQuery"||nextContainer==="SimpleSelectQuery"||nextContainer==="InsertClause")||parentType==="DeleteQuery"&&(nextKeyword==="using"||nextKeyword==="where"||nextKeyword==="returning")||(parentType==="MergeUpdateAction"||parentType==="MergeDeleteAction")&&nextKeyword==="where"||parentType==="MergeInsertAction"&&nextKeyword&&(nextKeyword.startsWith("values")||nextKeyword==="default values"))}createOnelinePrinter(){return new _SqlPrinter({indentChar:"",indentSize:0,newline:" ",commaBreak:"none",cteCommaBreak:this.cteCommaBreak,valuesCommaBreak:"none",andBreak:"none",orBreak:"none",keywordCase:this.keywordCase,exportComment:this.commentExportMode,commentStyle:this.commentStyle,withClauseStyle:"standard",parenthesesOneLine:!1,betweenOneLine:!1,valuesOneLine:!1,joinOneLine:!1,caseOneLine:!1,subqueryOneLine:!1,indentNestedParentheses:!1,insertColumnsOneLine:this.insertColumnsOneLine})}cleanDuplicateSpaces(text){return text.replace(/\s{2,}/g," ")}};var VALID_PRESETS=["mysql","postgres","sqlserver","sqlite"],SqlFormatter=class{constructor(options={}){let presetConfig=options.preset?PRESETS[options.preset]:void 0;if(options.preset&&!presetConfig)throw new Error(`Invalid preset: ${options.preset}`);let resolvedIdentifierEscape=resolveIdentifierEscapeOption(options.identifierEscape??presetConfig?.identifierEscape),parserOptions={...presetConfig,identifierEscape:resolvedIdentifierEscape??presetConfig?.identifierEscape,parameterSymbol:options.parameterSymbol??presetConfig?.parameterSymbol,parameterStyle:options.parameterStyle??presetConfig?.parameterStyle,castStyle:options.castStyle??presetConfig?.castStyle,joinConditionOrderByDeclaration:options.joinConditionOrderByDeclaration},constraintStyle=options.constraintStyle??presetConfig?.constraintStyle??"postgres",parserConfig={...parserOptions,constraintStyle};this.parser=new SqlPrintTokenParser({...parserConfig});let normalizedExportComment=options.exportComment===!0?"full":options.exportComment===!1?"none":options.exportComment,printerOptions={...options,exportComment:normalizedExportComment,parenthesesOneLine:options.parenthesesOneLine,betweenOneLine:options.betweenOneLine,valuesOneLine:options.valuesOneLine,joinOneLine:options.joinOneLine,caseOneLine:options.caseOneLine,subqueryOneLine:options.subqueryOneLine,indentNestedParentheses:options.indentNestedParentheses};this.printer=new SqlPrinter(printerOptions)}format(sql){let{token,params}=this.parser.parse(sql);return{formattedSql:this.printer.print(token),params}}};var Formatter=class{constructor(){this.sqlFormatter=new SqlFormatter({identifierEscape:{start:'"',end:'"'},parameterSymbol:":",parameterStyle:"named"})}format(arg,config=null){return config&&(this.sqlFormatter=new SqlFormatter(config)),this.sqlFormatter.format(arg).formattedSql}formatWithParameters(arg,config=null){config&&(this.sqlFormatter=new SqlFormatter(config));let result=this.sqlFormatter.format(arg);return{sql:result.formattedSql,params:result.params}}visit(arg){return this.format(arg)}};var CTEBuilder=class{constructor(){this.sourceCollector=new TableSourceCollector(!0),this.cteCollector=new CTECollector,this.formatter=new Formatter}build(commonTables){if(commonTables.length===0)return new WithClause(!1,commonTables);let resolvedTables=this.resolveDuplicateNames(commonTables),{tableMap,recursiveCTEs,dependencies}=this.buildDependencyGraph(resolvedTables),sortedTables=this.sortCommonTables(resolvedTables,tableMap,recursiveCTEs,dependencies);return new WithClause(recursiveCTEs.size>0,sortedTables)}resolveDuplicateNames(commonTables){let ctesByName=new Map;for(let table of commonTables){let tableName=table.aliasExpression.table.name;ctesByName.has(tableName)||ctesByName.set(tableName,[]),ctesByName.get(tableName).push(table)}let resolvedTables=[];for(let[name,tables]of Array.from(ctesByName.entries())){if(tables.length===1){resolvedTables.push(tables[0]);continue}let definitions=tables.map(table=>this.formatter.format(table.query));if(new Set(definitions).size===1)resolvedTables.push(tables[0]);else throw new Error(`CTE name conflict detected: '${name}' has multiple different definitions`)}return resolvedTables}buildDependencyGraph(tables){let tableMap=new Map;for(let table of tables)tableMap.set(table.aliasExpression.table.name,table);let recursiveCTEs=new Set,dependencies=new Map,referencedBy=new Map;for(let table of tables){let tableName=table.aliasExpression.table.name,referencedTables=this.sourceCollector.collect(table.query);for(let referencedTable of referencedTables)if(referencedTable.table.name===tableName){recursiveCTEs.add(tableName);break}dependencies.has(tableName)||dependencies.set(tableName,new Set);let referencedCTEs=this.cteCollector.collect(table.query);for(let referencedCTE of referencedCTEs){let referencedName=referencedCTE.aliasExpression.table.name;tableMap.has(referencedName)&&(dependencies.get(tableName).add(referencedName),referencedBy.has(referencedName)||referencedBy.set(referencedName,new Set),referencedBy.get(referencedName).add(tableName))}}return{tableMap,recursiveCTEs,dependencies}}sortCommonTables(tables,tableMap,recursiveCTEs,dependencies){let recursiveResult=[],nonRecursiveResult=[],visited=new Set,visiting=new Set,visit=tableName=>{if(visited.has(tableName))return;if(visiting.has(tableName))throw new Error(`Circular reference detected in CTE: ${tableName}`);visiting.add(tableName);let deps=dependencies.get(tableName)||new Set;for(let dep of Array.from(deps))visit(dep);visiting.delete(tableName),visited.add(tableName),recursiveCTEs.has(tableName)?recursiveResult.push(tableMap.get(tableName)):nonRecursiveResult.push(tableMap.get(tableName))};for(let table of tables){let tableName=table.aliasExpression.table.name;visited.has(tableName)||visit(tableName)}return[...recursiveResult,...nonRecursiveResult]}};var CTEInjector=class{constructor(){this.nameConflictResolver=new CTEBuilder,this.cteCollector=new CTECollector}inject(query,commonTables){if(commonTables.length===0)return query;commonTables.push(...this.cteCollector.collect(query));let resolvedWithCaluse=this.nameConflictResolver.build(commonTables);if(query instanceof SimpleSelectQuery)return this.injectIntoSimpleQuery(query,resolvedWithCaluse);if(query instanceof BinarySelectQuery)return this.injectIntoBinaryQuery(query,resolvedWithCaluse);throw new Error("Unsupported query type")}injectIntoSimpleQuery(query,withClause){if(query.withClause)throw new Error("The query already has a WITH clause. Please remove it before injecting new CTEs.");return query.withClause=withClause,query}injectIntoBinaryQuery(query,withClause){if(query.left instanceof SimpleSelectQuery)return this.injectIntoSimpleQuery(query.left,withClause),query;if(query.left instanceof BinarySelectQuery)return this.injectIntoBinaryQuery(query.left,withClause),query;throw new Error("Unsupported query type for BinarySelectQuery left side")}};var CTENormalizer=class{constructor(){}static normalize(query){let allCommonTables=new CTECollector().collect(query);return allCommonTables.length===0?query:(new CTEDisabler().execute(query),new CTEInjector().inject(query,allCommonTables))}};var DuplicateDetectionMode=(DuplicateDetectionMode2=>(DuplicateDetectionMode2.ColumnNameOnly="columnNameOnly",DuplicateDetectionMode2.FullName="fullName",DuplicateDetectionMode2))(DuplicateDetectionMode||{}),SelectableColumnCollector=class _SelectableColumnCollector{constructor(tableColumnResolver,includeWildCard=!1,duplicateDetection="columnNameOnly",options){this.selectValues=[];this.visitedNodes=new Set;this.uniqueKeys=new Set;this.isRootVisit=!0;this.tableColumnResolver=null;this.commonTables=[];this.initializeProperties(tableColumnResolver,includeWildCard,duplicateDetection,options),this.initializeHandlers()}initializeProperties(tableColumnResolver,includeWildCard,duplicateDetection,options){this.tableColumnResolver=tableColumnResolver??null,this.includeWildCard=includeWildCard,this.commonTableCollector=new CTECollector,this.commonTables=[],this.duplicateDetection=duplicateDetection,this.options=options||{}}initializeHandlers(){this.handlers=new Map,this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleSelectQuery(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.initializeClauseHandlers(),this.initializeValueComponentHandlers()}initializeClauseHandlers(){this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(OffsetClause.kind,expr=>this.offsetClause(expr)),this.handlers.set(FetchClause.kind,expr=>this.visitFetchClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr))}initializeValueComponentHandlers(){this.handlers.set(ColumnReference.kind,expr=>this.visitColumnReference(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(ArraySliceExpression.kind,expr=>this.visitArraySliceExpression(expr)),this.handlers.set(ArrayIndexExpression.kind,expr=>this.visitArrayIndexExpression(expr)),this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(WindowFrameExpression.kind,expr=>this.visitWindowFrameExpression(expr)),this.handlers.set(PartitionByClause.kind,expr=>this.visitPartitionByClause(expr))}getValues(){return this.selectValues}collect(arg){if(!arg)throw new Error("Input argument cannot be null or undefined");this.visit(arg);let items=this.getValues();return this.reset(),items}reset(){this.selectValues=[],this.visitedNodes.clear(),this.uniqueKeys.clear(),this.commonTables=[]}addSelectValueAsUnique(name,value){let key=this.generateUniqueKey(name,value);this.uniqueKeys.has(key)||(this.uniqueKeys.add(key),this.selectValues.push({name,value}))}generateUniqueKey(name,value){if(this.duplicateDetection==="columnNameOnly")return this.normalizeColumnName(name);{let tableName="";value&&typeof value.getNamespace=="function"&&(tableName=value.getNamespace()||"");let fullName=tableName?tableName+"."+name:name;return this.normalizeColumnName(fullName)}}normalizeColumnName(name){if(typeof name!="string")throw new Error("Column name must be a string");return this.options.ignoreCaseAndUnderscore?name.toLowerCase().replace(/_/g,""):name}visit(arg){if(!this.isRootVisit){this.visitNode(arg);return}if(!(arg instanceof SimpleSelectQuery||arg instanceof BinarySelectQuery))throw new Error("Root visit requires a SimpleSelectQuery or BinarySelectQuery.");this.reset(),this.isRootVisit=!1,this.commonTables=this.commonTableCollector.collect(arg);try{this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(!this.visitedNodes.has(arg)){this.visitedNodes.add(arg);try{let handler=this.handlers.get(arg.getKind());handler&&handler(arg)}catch(error){let errorMessage=error instanceof Error?error.message:String(error);throw new Error(`Error processing SQL component of type ${arg.getKind().toString()}: ${errorMessage}`)}}}visitSimpleSelectQuery(query){if(query.selectClause&&query.selectClause.accept(this),query.fromClause&&query.fromClause.accept(this),query.whereClause&&query.whereClause.accept(this),query.groupByClause&&query.groupByClause.accept(this),query.havingClause&&query.havingClause.accept(this),query.windowClause)for(let win of query.windowClause.windows)win.accept(this);query.orderByClause&&query.orderByClause.accept(this),query.limitClause&&query.limitClause.accept(this),query.offsetClause&&query.offsetClause.accept(this),query.fetchClause&&query.fetchClause.accept(this),query.forClause&&query.forClause.accept(this)}visitBinarySelectQuery(query){query.left instanceof SimpleSelectQuery?this.visitSimpleSelectQuery(query.left):query.left instanceof BinarySelectQuery&&this.visitBinarySelectQuery(query.left),query.right instanceof SimpleSelectQuery?this.visitSimpleSelectQuery(query.right):query.right instanceof BinarySelectQuery&&this.visitBinarySelectQuery(query.right)}visitSelectClause(clause){for(let item of clause.items)if(item.identifier)this.addSelectValueAsUnique(item.identifier.name,item.value);else if(item.value instanceof ColumnReference){let columnName=item.value.column.name;columnName!=="*"?this.addSelectValueAsUnique(columnName,item.value):this.includeWildCard&&this.addSelectValueAsUnique(columnName,item.value)}else item.value.accept(this)}visitFromClause(clause){let sourceValues=new SelectValueCollector(this.tableColumnResolver,this.commonTables).collect(clause);for(let item of sourceValues)this.addSelectValueAsUnique(item.name,item.value);if(this.options.upstream&&this.collectUpstreamColumns(clause),clause.joins)for(let join of clause.joins)join.condition&&join.condition.accept(this)}visitWhereClause(clause){clause.condition&&clause.condition.accept(this)}visitGroupByClause(clause){if(clause.grouping)for(let item of clause.grouping)item.accept(this)}visitHavingClause(clause){clause.condition&&clause.condition.accept(this)}visitOrderByClause(clause){if(clause.order)for(let item of clause.order)item.accept(this)}visitWindowFrameClause(clause){clause.expression.accept(this)}visitWindowFrameExpression(expr){expr.partition&&expr.partition.accept(this),expr.order&&expr.order.accept(this),expr.frameSpec&&expr.frameSpec.accept(this)}visitLimitClause(clause){clause.value&&clause.value.accept(this)}offsetClause(clause){clause.value&&clause.value.accept(this)}visitFetchClause(clause){clause.expression&&clause.expression.accept(this)}visitJoinOnClause(joinOnClause){joinOnClause.condition&&joinOnClause.condition.accept(this)}visitJoinUsingClause(joinUsingClause){joinUsingClause.condition&&joinUsingClause.condition.accept(this)}visitColumnReference(columnRef){if(columnRef.column.name!=="*")this.addSelectValueAsUnique(columnRef.column.name,columnRef);else if(this.includeWildCard)this.addSelectValueAsUnique(columnRef.column.name,columnRef);else return}visitBinaryExpression(expr){expr.left&&expr.left.accept(this),expr.right&&expr.right.accept(this)}visitUnaryExpression(expr){expr.expression&&expr.expression.accept(this)}visitFunctionCall(func){func.argument&&func.argument.accept(this),func.over&&func.over.accept(this),func.withinGroup&&func.withinGroup.accept(this),func.internalOrderBy&&func.internalOrderBy.accept(this)}visitInlineQuery(inlineQuery){inlineQuery.selectQuery&&this.visitNode(inlineQuery.selectQuery)}visitParenExpression(expr){expr.expression&&expr.expression.accept(this)}visitCaseExpression(expr){expr.condition&&expr.condition.accept(this),expr.switchCase&&expr.switchCase.accept(this)}visitCastExpression(expr){expr.input&&expr.input.accept(this)}visitBetweenExpression(expr){expr.expression&&expr.expression.accept(this),expr.lower&&expr.lower.accept(this),expr.upper&&expr.upper.accept(this)}visitArrayExpression(expr){expr.expression&&expr.expression.accept(this)}visitArrayQueryExpression(expr){expr.query.accept(this)}visitArraySliceExpression(expr){expr.array&&expr.array.accept(this),expr.startIndex&&expr.startIndex.accept(this),expr.endIndex&&expr.endIndex.accept(this)}visitArrayIndexExpression(expr){expr.array&&expr.array.accept(this),expr.index&&expr.index.accept(this)}visitValueList(expr){if(expr.values&&Array.isArray(expr.values))for(let value of expr.values)value&&value.accept(this)}visitPartitionByClause(clause){clause.value.accept(this)}collectUpstreamColumns(clause){if(this.collectAllAvailableCTEColumns(),this.collectUpstreamColumnsFromSource(clause.source),clause.joins)for(let join of clause.joins)this.collectUpstreamColumnsFromSource(join.source)}collectUpstreamColumnsFromSource(source){if(source.datasource instanceof TableSource){let cteTable=this.findCTEByName(source.datasource.table.name);cteTable?this.collectUpstreamColumnsFromCTE(cteTable):this.collectUpstreamColumnsFromTable(source.datasource)}else source.datasource instanceof SubQuerySource?this.collectUpstreamColumnsFromSubquery(source.datasource):source.datasource instanceof ParenSource&&this.collectUpstreamColumnsFromSource(new SourceExpression(source.datasource.source,null))}collectUpstreamColumnsFromTable(tableSource){if(this.tableColumnResolver){let tableName=tableSource.table.name,columns=this.tableColumnResolver(tableName);for(let columnName of columns){let columnRef=new ColumnReference(tableSource.table.name,columnName);this.addSelectValueAsUnique(columnName,columnRef)}}}collectUpstreamColumnsFromSubquery(subquerySource){if(subquerySource.query instanceof SimpleSelectQuery){let subqueryColumns=new _SelectableColumnCollector(this.tableColumnResolver,this.includeWildCard,this.duplicateDetection,{...this.options,upstream:!0}).collect(subquerySource.query);for(let item of subqueryColumns)this.addSelectValueAsUnique(item.name,item.value)}}collectUpstreamColumnsFromCTE(cteTable){if(cteTable.query instanceof SimpleSelectQuery){let cteColumns=new _SelectableColumnCollector(this.tableColumnResolver,this.includeWildCard,this.duplicateDetection,{...this.options,upstream:!1}).collect(cteTable.query);for(let item of cteColumns)item.name!=="*"&&this.addSelectValueAsUnique(item.name,item.value)}}collectAllAvailableCTEColumns(){for(let cte of this.commonTables)this.collectUpstreamColumnsFromCTE(cte)}findCTEByName(name){return this.commonTables.find(cte=>cte.getSourceAliasName()===name)||null}};var SourceParser=class _SourceParser{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex0?value.positionedComments=name.positionedComments:name.comments&&name.comments.length>0&&(value.comments=name.comments),{value,newIndex}}static parseFunctionSource(lexemes,fullNameResult){let idx=fullNameResult.newIndex,{namespaces,name}=fullNameResult,argument=ValueParser.parseArgument(4,8,lexemes,idx);idx=argument.newIndex;let withOrdinality=!1;idx=lexemes.length)throw new Error(`Syntax error: Unexpected end of input at position ${idx}. Expected a subquery or nested expression after opening parenthesis.`);let keyword=lexemes[idx].value;if(keyword==="select"||keyword==="values"||keyword==="with"){let result=this.parseSubQuerySource(lexemes,idx,openParenToken);if(idx=result.newIndex,idx0){let afterComments=openParenToken.positionedComments.filter(pc=>pc.position==="after");if(afterComments.length>0){let beforeComments=afterComments.map(pc=>({position:"before",comments:pc.comments}));selectQuery.positionedComments?selectQuery.positionedComments=[...beforeComments,...selectQuery.positionedComments]:selectQuery.positionedComments=beforeComments,selectQuery.comments&&(selectQuery.comments=null)}}return{value:new SubQuerySource(selectQuery),newIndex:idx}}};var DuplicateCTEError=class extends Error{constructor(cteName){super(`CTE '${cteName}' already exists in the query`);this.cteName=cteName;this.name="DuplicateCTEError"}},InvalidCTENameError=class extends Error{constructor(cteName,reason){super(`Invalid CTE name '${cteName}': ${reason}`);this.cteName=cteName;this.name="InvalidCTENameError"}},CTENotFoundError=class extends Error{constructor(cteName){super(`CTE '${cteName}' not found in the query`);this.cteName=cteName;this.name="CTENotFoundError"}};var UpstreamSelectQueryFinder=class{constructor(tableColumnResolver,options){this.options=options||{},this.tableColumnResolver=tableColumnResolver,this.columnCollector=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0})}find(query,columnNames){let namesArray=typeof columnNames=="string"?[columnNames]:columnNames,ctes=new CTECollector().collect(query),cteMap=new Map;for(let cte of ctes)cteMap.set(cte.getSourceAliasName(),cte);return this.findUpstream(query,namesArray,cteMap)}handleTableSource(src,columnNames,cteMap){let cte=cteMap.get(src.table.name);if(cte){let nextCteMap=new Map(cteMap);if(nextCteMap.delete(src.table.name),!this.isSelectQuery(cte.query))return null;let result=this.findUpstream(cte.query,columnNames,nextCteMap);return result.length===0?null:result}return null}handleSubQuerySource(src,columnNames,cteMap){let result=this.findUpstream(src.query,columnNames,cteMap);return result.length===0?null:result}processFromClauseBranches(fromClause,columnNames,cteMap){let sources=fromClause.getSources();if(sources.length===0)return null;let allBranchResults=[],allBranchesOk=!0,validBranchCount=0;for(let sourceExpr of sources){let src=sourceExpr.datasource,branchResult=null;if(src instanceof TableSource)branchResult=this.handleTableSource(src,columnNames,cteMap),validBranchCount++;else if(src instanceof SubQuerySource)branchResult=this.handleSubQuerySource(src,columnNames,cteMap),validBranchCount++;else{if(src instanceof ValuesQuery)continue;allBranchesOk=!1;break}if(branchResult===null){allBranchesOk=!1;break}allBranchResults.push(branchResult)}return allBranchesOk&&allBranchResults.length===validBranchCount?allBranchResults.flat():null}findUpstream(query,columnNames,cteMap){if(query instanceof SimpleSelectQuery){let fromClause=query.fromClause;if(fromClause){let branchResult=this.processFromClauseBranches(fromClause,columnNames,cteMap);if(branchResult&&branchResult.length>0)return branchResult}let columns=this.columnCollector.collect(query).map(col=>col.name),cteColumns=this.collectCTEColumns(query,cteMap),allColumns=[...columns,...cteColumns],normalize=s=>this.options.ignoreCaseAndUnderscore?s.toLowerCase().replace(/_/g,""):s;return columnNames.every(name=>allColumns.some(col=>normalize(col)===normalize(name)))?[query]:[]}else if(query instanceof BinarySelectQuery){let left=this.findUpstream(query.left,columnNames,cteMap),right=this.findUpstream(query.right,columnNames,cteMap);return[...left,...right]}return[]}collectCTEColumns(query,cteMap){let cteColumns=[];if(query.withClause)for(let cte of query.withClause.tables){let columns=this.collectColumnsFromCteQuery(cte.query);cteColumns.push(...columns)}return cteColumns}collectColumnsFromCteQuery(query){return this.isSelectQuery(query)?this.collectColumnsFromSelectQuery(query):this.collectColumnsFromReturning(query)}collectColumnsFromSelectQuery(query){if(query instanceof SimpleSelectQuery)try{return this.columnCollector.collect(query).map(col=>col.name)}catch(error){return console.warn("Failed to collect columns from SimpleSelectQuery:",error),[]}else if(query instanceof BinarySelectQuery)return this.collectColumnsFromSelectQuery(query.left);return[]}collectColumnsFromReturning(query){return query instanceof InsertQuery||query instanceof UpdateQuery||query instanceof DeleteQuery||query instanceof MergeQuery?this.extractReturningColumns(query.returningClause):[]}extractReturningColumns(returningClause){if(!returningClause)return[];let columns=[];for(let item of returningClause.items){let name=item.identifier?.name??this.extractColumnName(item);name&&columns.push(name)}return columns}extractColumnName(item){return item.identifier?item.identifier.name:item.value instanceof ColumnReference?item.value.column.name:null}isSelectQuery(query){return"__selectQueryType"in query&&query.__selectQueryType==="SelectQuery"}};var SourceAliasExpressionParser=class{static parseFromLexeme(lexemes,index){let idx=index;if(idx0&&(sourceAlias2.positionedComments=aliasToken.positionedComments),{value:sourceAlias2,newIndex:idx}}let sourceAlias=new SourceAliasExpression(table,null);return aliasToken.positionedComments&&aliasToken.positionedComments.length>0&&(sourceAlias.positionedComments=aliasToken.positionedComments),{value:sourceAlias,newIndex:idx}}throw new Error(`Syntax error at position ${index}: Expected an identifier for table alias but found "${lexemes[index]?.value||"end of input"}".`)}};var SourceExpressionParser=class{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndexcol.name),selectQueries=valuesQuery.tuples.map(tuple=>{if(tuple.values.length!==columnNames.length)throw new Error("Tuple value count does not match column count.");let items=columnNames.map((name,idx)=>new SelectItem(tuple.values[idx],name)),selectClause=new SelectClause(items);return new SimpleSelectQuery({selectClause})}),combined=selectQueries[0];for(let i=1;icol.name),simpleQueries=this.flattenSelectQueries(insertQuery.selectQuery);if(!simpleQueries.length)throw new Error("No SELECT components found to convert.");let tuples=simpleQueries.map(query=>{if(query.fromClause||query.whereClause&&query.whereClause.condition)throw new Error("SELECT queries with FROM or WHERE clauses cannot be converted to VALUES.");let valueMap=new Map;for(let item of query.selectClause.items){let identifier=item.identifier?.name??null;if(!identifier)throw new Error("Each SELECT item must have an alias matching target columns.");valueMap.has(identifier)||valueMap.set(identifier,item.value)}let rowValues=columnNames.map(name=>{let value=valueMap.get(name);if(!value)throw new Error(`Column '${name}' is not provided by the SELECT query.`);return value});return new TupleExpression(rowValues)}),valuesQuery=new ValuesQuery(tuples,columnNames);return SelectQueryWithClauseHelper.setWithClause(valuesQuery,preservedWithClause),new InsertQuery({insertClause:insertQuery.insertClause,selectQuery:valuesQuery,returning:insertQuery.returningClause})}static flattenSelectQueries(selectQuery){if(selectQuery instanceof SimpleSelectQuery)return[selectQuery];if(selectQuery instanceof BinarySelectQuery)return[...this.flattenSelectQueries(selectQuery.left),...this.flattenSelectQueries(selectQuery.right)];throw new Error("Unsupported SelectQuery subtype for conversion.")}};var TextPositionUtils=class{static lineColumnToCharOffset(text,position){if(position.line<1||position.column<1)return-1;let lines=text.split(` +`:this.newline,currentLevel=this.linePrinter.getCurrentLine()?.level??level,baseIndent=this.getIndentString(currentLevel),innerIndent=baseIndent+" ",body=lines.map(line=>`${innerIndent}${line}`).join(newline),closing=`${baseIndent}*/`;return`/*${newline}${body}${newline}${closing}`}getIndentString(level){return level<=0?"":this.indentSize<=0?" ".repeat(level):(typeof this.indentChar=="string"?this.indentChar:"").repeat(this.indentSize*level)}sanitizeCommentLine(content){let sanitized=content;return sanitized=sanitized.replace(/\u2028|\u2029/g," "),sanitized=sanitized.replace(/\s+/g," ").trim(),sanitized}escapeCommentDelimiters(content){return content.replace(/\/\*/g,"\\/\\*").replace(/\*\//g,"*\\/")}getCommentBaseIndentLevel(level,parentContainerType){if(!parentContainerType)return level;let clauseAlignedLevel=this.getClauseBreakIndentLevel(parentContainerType,level);return Math.max(level,clauseAlignedLevel)}resolveCommentIndentLevel(level,parentContainerType){let baseLevel=this.getCommentBaseIndentLevel(level,parentContainerType),currentLevel=this.linePrinter.getCurrentLine().level??baseLevel;return Math.max(baseLevel,currentLevel)}handleCommentNewlineToken(token,level){if(!this.smartCommentBlockBuilder){if(this.pendingLineCommentBreak!==null){this.linePrinter.appendNewline(this.pendingLineCommentBreak),this.pendingLineCommentBreak=null;return}this.shouldSkipCommentNewline()||this.isOnelineMode()||this.linePrinter.appendNewline(level)}}shouldSkipCommentNewline(){return this.insideWithClause&&this.withClauseStyle==="full-oneline"||this.withClauseStyle==="cte-oneline"}shouldAddNewlineBeforeLeadingComments(parentType){return parentType?parentType==="TupleExpression"?!0:parentType==="InsertClause"||parentType==="MergeInsertAction"?!this.insertColumnsOneLine:parentType==="SetClause"||parentType==="SelectClause"||parentType==="ExplainStatement"||parentType==="ReturningClause":!1}getLeadingCommentIndentLevel(parentType,currentLevel){return parentType==="TupleExpression"||parentType==="InsertClause"||parentType==="MergeInsertAction"||parentType==="SelectClause"||parentType==="ReturningClause"||parentType==="SetClause"?currentLevel+1:currentLevel}isOnelineMode(){return this.newline===" "}handleCteOnelineToken(token,level){let onelineResult=this.createCteOnelinePrinter().print(token,level),cleanedResult=this.cleanDuplicateSpaces(onelineResult);cleanedResult=cleanedResult.replace(/\(\s+/g,"(").replace(/\s+\)/g," )"),this.linePrinter.appendText(cleanedResult.trim())}createCteOnelinePrinter(){return new _SqlPrinter({indentChar:"",indentSize:0,newline:" ",commaBreak:this.commaBreak,cteCommaBreak:this.cteCommaBreak,valuesCommaBreak:this.valuesCommaBreak,andBreak:this.andBreak,orBreak:this.orBreak,keywordCase:this.keywordCase,exportComment:"none",withClauseStyle:"standard",indentNestedParentheses:!1,insertColumnsOneLine:this.insertColumnsOneLine})}handleOnelineToken(token,level){let onelineResult=this.createOnelinePrinter().print(token,level),cleanedResult=this.cleanDuplicateSpaces(onelineResult);this.linePrinter.appendText(cleanedResult)}getClauseBreakIndentLevel(parentType,level){if(!parentType)return level;switch(parentType){case"MergeWhenClause":return level+1;case"MergeUpdateAction":case"MergeDeleteAction":case"MergeInsertAction":return level+1;default:return level}}isMergeActionContainer(token){if(!token)return!1;switch(token.containerType){case"MergeUpdateAction":case"MergeDeleteAction":case"MergeInsertAction":case"MergeDoNothingAction":return!0;default:return!1}}shouldBreakAfterOpeningParen(parentType){return parentType&&(parentType==="InsertClause"||parentType==="MergeInsertAction"||parentType==="ReturningClause")?!this.isInsertClauseOneline(parentType):!1}shouldBreakBeforeClosingParen(parentType){return parentType&&(parentType==="InsertClause"||parentType==="MergeInsertAction")?!this.isInsertClauseOneline(parentType):!1}shouldConvertSpaceToClauseBreak(parentType,nextToken){if(!parentType||!nextToken)return!1;let nextKeyword=nextToken.type===1?nextToken.text.toLowerCase():null,nextContainer=nextToken.containerType;return!!(parentType==="MergeQuery"&&(nextKeyword==="using"||nextContainer==="MergeWhenClause")||parentType==="MergeWhenClause"&&(nextContainer==="MergeUpdateAction"||nextContainer==="MergeDeleteAction"||nextContainer==="MergeInsertAction"||nextContainer==="MergeDoNothingAction")||parentType==="UpdateQuery"&&(nextKeyword==="set"||nextKeyword==="from"||nextKeyword==="where"||nextKeyword==="returning")||parentType==="InsertQuery"&&(nextKeyword==="returning"||nextKeyword&&(nextKeyword.startsWith("select")||nextKeyword.startsWith("values"))||nextContainer==="ValuesQuery"||nextContainer==="SimpleSelectQuery"||nextContainer==="InsertClause")||parentType==="DeleteQuery"&&(nextKeyword==="using"||nextKeyword==="where"||nextKeyword==="returning")||(parentType==="MergeUpdateAction"||parentType==="MergeDeleteAction")&&nextKeyword==="where"||parentType==="MergeInsertAction"&&nextKeyword&&(nextKeyword.startsWith("values")||nextKeyword==="default values"))}createOnelinePrinter(){return new _SqlPrinter({indentChar:"",indentSize:0,newline:" ",commaBreak:"none",cteCommaBreak:this.cteCommaBreak,valuesCommaBreak:"none",andBreak:"none",orBreak:"none",keywordCase:this.keywordCase,exportComment:this.commentExportMode,commentStyle:this.commentStyle,withClauseStyle:"standard",parenthesesOneLine:!1,betweenOneLine:!1,valuesOneLine:!1,joinOneLine:!1,caseOneLine:!1,subqueryOneLine:!1,indentNestedParentheses:!1,insertColumnsOneLine:this.insertColumnsOneLine})}cleanDuplicateSpaces(text){return text.replace(/\s{2,}/g," ")}};var VALID_PRESETS=["mysql","postgres","sqlserver","sqlite"],SqlFormatter=class{constructor(options={}){let presetConfig=options.preset?PRESETS[options.preset]:void 0;if(options.preset&&!presetConfig)throw new Error(`Invalid preset: ${options.preset}`);let resolvedIdentifierEscape=resolveIdentifierEscapeOption(options.identifierEscape??presetConfig?.identifierEscape,options.identifierEscapeTarget??"all"),parserOptions={...presetConfig,identifierEscape:resolvedIdentifierEscape??presetConfig?.identifierEscape,parameterSymbol:options.parameterSymbol??presetConfig?.parameterSymbol,parameterStyle:options.parameterStyle??presetConfig?.parameterStyle,castStyle:options.castStyle??presetConfig?.castStyle,joinConditionOrderByDeclaration:options.joinConditionOrderByDeclaration},constraintStyle=options.constraintStyle??presetConfig?.constraintStyle??"postgres",parserConfig={...parserOptions,constraintStyle};this.parser=new SqlPrintTokenParser({...parserConfig});let normalizedExportComment=options.exportComment===!0?"full":options.exportComment===!1?"none":options.exportComment,printerOptions={...options,exportComment:normalizedExportComment,parenthesesOneLine:options.parenthesesOneLine,betweenOneLine:options.betweenOneLine,valuesOneLine:options.valuesOneLine,joinOneLine:options.joinOneLine,caseOneLine:options.caseOneLine,subqueryOneLine:options.subqueryOneLine,indentNestedParentheses:options.indentNestedParentheses};this.printer=new SqlPrinter(printerOptions)}format(sql){let{token,params}=this.parser.parse(sql);return{formattedSql:this.printer.print(token),params}}};var Formatter=class{constructor(){this.sqlFormatter=new SqlFormatter({identifierEscape:{start:'"',end:'"'},parameterSymbol:":",parameterStyle:"named"})}format(arg,config=null){return config&&(this.sqlFormatter=new SqlFormatter(config)),this.sqlFormatter.format(arg).formattedSql}formatWithParameters(arg,config=null){config&&(this.sqlFormatter=new SqlFormatter(config));let result=this.sqlFormatter.format(arg);return{sql:result.formattedSql,params:result.params}}visit(arg){return this.format(arg)}};var CTEBuilder=class{constructor(){this.sourceCollector=new TableSourceCollector(!0),this.cteCollector=new CTECollector,this.formatter=new Formatter}build(commonTables){if(commonTables.length===0)return new WithClause(!1,commonTables);let resolvedTables=this.resolveDuplicateNames(commonTables),{tableMap,recursiveCTEs,dependencies}=this.buildDependencyGraph(resolvedTables),sortedTables=this.sortCommonTables(resolvedTables,tableMap,recursiveCTEs,dependencies);return new WithClause(recursiveCTEs.size>0,sortedTables)}resolveDuplicateNames(commonTables){let ctesByName=new Map;for(let table of commonTables){let tableName=table.aliasExpression.table.name;ctesByName.has(tableName)||ctesByName.set(tableName,[]),ctesByName.get(tableName).push(table)}let resolvedTables=[];for(let[name,tables]of Array.from(ctesByName.entries())){if(tables.length===1){resolvedTables.push(tables[0]);continue}let definitions=tables.map(table=>this.formatter.format(table.query));if(new Set(definitions).size===1)resolvedTables.push(tables[0]);else throw new Error(`CTE name conflict detected: '${name}' has multiple different definitions`)}return resolvedTables}buildDependencyGraph(tables){let tableMap=new Map;for(let table of tables)tableMap.set(table.aliasExpression.table.name,table);let recursiveCTEs=new Set,dependencies=new Map,referencedBy=new Map;for(let table of tables){let tableName=table.aliasExpression.table.name,referencedTables=this.sourceCollector.collect(table.query);for(let referencedTable of referencedTables)if(referencedTable.table.name===tableName){recursiveCTEs.add(tableName);break}dependencies.has(tableName)||dependencies.set(tableName,new Set);let referencedCTEs=this.cteCollector.collect(table.query);for(let referencedCTE of referencedCTEs){let referencedName=referencedCTE.aliasExpression.table.name;tableMap.has(referencedName)&&(dependencies.get(tableName).add(referencedName),referencedBy.has(referencedName)||referencedBy.set(referencedName,new Set),referencedBy.get(referencedName).add(tableName))}}return{tableMap,recursiveCTEs,dependencies}}sortCommonTables(tables,tableMap,recursiveCTEs,dependencies){let recursiveResult=[],nonRecursiveResult=[],visited=new Set,visiting=new Set,visit=tableName=>{if(visited.has(tableName))return;if(visiting.has(tableName))throw new Error(`Circular reference detected in CTE: ${tableName}`);visiting.add(tableName);let deps=dependencies.get(tableName)||new Set;for(let dep of Array.from(deps))visit(dep);visiting.delete(tableName),visited.add(tableName),recursiveCTEs.has(tableName)?recursiveResult.push(tableMap.get(tableName)):nonRecursiveResult.push(tableMap.get(tableName))};for(let table of tables){let tableName=table.aliasExpression.table.name;visited.has(tableName)||visit(tableName)}return[...recursiveResult,...nonRecursiveResult]}};var CTEInjector=class{constructor(){this.nameConflictResolver=new CTEBuilder,this.cteCollector=new CTECollector}inject(query,commonTables){if(commonTables.length===0)return query;commonTables.push(...this.cteCollector.collect(query));let resolvedWithCaluse=this.nameConflictResolver.build(commonTables);if(query instanceof SimpleSelectQuery)return this.injectIntoSimpleQuery(query,resolvedWithCaluse);if(query instanceof BinarySelectQuery)return this.injectIntoBinaryQuery(query,resolvedWithCaluse);throw new Error("Unsupported query type")}injectIntoSimpleQuery(query,withClause){if(query.withClause)throw new Error("The query already has a WITH clause. Please remove it before injecting new CTEs.");return query.withClause=withClause,query}injectIntoBinaryQuery(query,withClause){if(query.left instanceof SimpleSelectQuery)return this.injectIntoSimpleQuery(query.left,withClause),query;if(query.left instanceof BinarySelectQuery)return this.injectIntoBinaryQuery(query.left,withClause),query;throw new Error("Unsupported query type for BinarySelectQuery left side")}};var CTENormalizer=class{constructor(){}static normalize(query){let allCommonTables=new CTECollector().collect(query);return allCommonTables.length===0?query:(new CTEDisabler().execute(query),new CTEInjector().inject(query,allCommonTables))}};var DuplicateDetectionMode=(DuplicateDetectionMode2=>(DuplicateDetectionMode2.ColumnNameOnly="columnNameOnly",DuplicateDetectionMode2.FullName="fullName",DuplicateDetectionMode2))(DuplicateDetectionMode||{}),SelectableColumnCollector=class _SelectableColumnCollector{constructor(tableColumnResolver,includeWildCard=!1,duplicateDetection="columnNameOnly",options){this.selectValues=[];this.visitedNodes=new Set;this.uniqueKeys=new Set;this.isRootVisit=!0;this.tableColumnResolver=null;this.commonTables=[];this.initializeProperties(tableColumnResolver,includeWildCard,duplicateDetection,options),this.initializeHandlers()}initializeProperties(tableColumnResolver,includeWildCard,duplicateDetection,options){this.tableColumnResolver=tableColumnResolver??null,this.includeWildCard=includeWildCard,this.commonTableCollector=new CTECollector,this.commonTables=[],this.duplicateDetection=duplicateDetection,this.options=options||{}}initializeHandlers(){this.handlers=new Map,this.handlers.set(SimpleSelectQuery.kind,expr=>this.visitSimpleSelectQuery(expr)),this.handlers.set(BinarySelectQuery.kind,expr=>this.visitBinarySelectQuery(expr)),this.initializeClauseHandlers(),this.initializeValueComponentHandlers()}initializeClauseHandlers(){this.handlers.set(SelectClause.kind,expr=>this.visitSelectClause(expr)),this.handlers.set(FromClause.kind,expr=>this.visitFromClause(expr)),this.handlers.set(WhereClause.kind,expr=>this.visitWhereClause(expr)),this.handlers.set(GroupByClause.kind,expr=>this.visitGroupByClause(expr)),this.handlers.set(HavingClause.kind,expr=>this.visitHavingClause(expr)),this.handlers.set(OrderByClause.kind,expr=>this.visitOrderByClause(expr)),this.handlers.set(WindowFrameClause.kind,expr=>this.visitWindowFrameClause(expr)),this.handlers.set(LimitClause.kind,expr=>this.visitLimitClause(expr)),this.handlers.set(OffsetClause.kind,expr=>this.offsetClause(expr)),this.handlers.set(FetchClause.kind,expr=>this.visitFetchClause(expr)),this.handlers.set(JoinOnClause.kind,expr=>this.visitJoinOnClause(expr)),this.handlers.set(JoinUsingClause.kind,expr=>this.visitJoinUsingClause(expr))}initializeValueComponentHandlers(){this.handlers.set(ColumnReference.kind,expr=>this.visitColumnReference(expr)),this.handlers.set(BinaryExpression.kind,expr=>this.visitBinaryExpression(expr)),this.handlers.set(UnaryExpression.kind,expr=>this.visitUnaryExpression(expr)),this.handlers.set(FunctionCall.kind,expr=>this.visitFunctionCall(expr)),this.handlers.set(InlineQuery.kind,expr=>this.visitInlineQuery(expr)),this.handlers.set(ParenExpression.kind,expr=>this.visitParenExpression(expr)),this.handlers.set(CaseExpression.kind,expr=>this.visitCaseExpression(expr)),this.handlers.set(CastExpression.kind,expr=>this.visitCastExpression(expr)),this.handlers.set(BetweenExpression.kind,expr=>this.visitBetweenExpression(expr)),this.handlers.set(ArrayExpression.kind,expr=>this.visitArrayExpression(expr)),this.handlers.set(ArrayQueryExpression.kind,expr=>this.visitArrayQueryExpression(expr)),this.handlers.set(ArraySliceExpression.kind,expr=>this.visitArraySliceExpression(expr)),this.handlers.set(ArrayIndexExpression.kind,expr=>this.visitArrayIndexExpression(expr)),this.handlers.set(ValueList.kind,expr=>this.visitValueList(expr)),this.handlers.set(WindowFrameExpression.kind,expr=>this.visitWindowFrameExpression(expr)),this.handlers.set(PartitionByClause.kind,expr=>this.visitPartitionByClause(expr))}getValues(){return this.selectValues}collect(arg){if(!arg)throw new Error("Input argument cannot be null or undefined");this.visit(arg);let items=this.getValues();return this.reset(),items}reset(){this.selectValues=[],this.visitedNodes.clear(),this.uniqueKeys.clear(),this.commonTables=[]}addSelectValueAsUnique(name,value){let key=this.generateUniqueKey(name,value);this.uniqueKeys.has(key)||(this.uniqueKeys.add(key),this.selectValues.push({name,value}))}generateUniqueKey(name,value){if(this.duplicateDetection==="columnNameOnly")return this.normalizeColumnName(name);{let tableName="";value&&typeof value.getNamespace=="function"&&(tableName=value.getNamespace()||"");let fullName=tableName?tableName+"."+name:name;return this.normalizeColumnName(fullName)}}normalizeColumnName(name){if(typeof name!="string")throw new Error("Column name must be a string");return this.options.ignoreCaseAndUnderscore?name.toLowerCase().replace(/_/g,""):name}visit(arg){if(!this.isRootVisit){this.visitNode(arg);return}if(!(arg instanceof SimpleSelectQuery||arg instanceof BinarySelectQuery))throw new Error("Root visit requires a SimpleSelectQuery or BinarySelectQuery.");this.reset(),this.isRootVisit=!1,this.commonTables=this.commonTableCollector.collect(arg);try{this.visitNode(arg)}finally{this.isRootVisit=!0}}visitNode(arg){if(!this.visitedNodes.has(arg)){this.visitedNodes.add(arg);try{let handler=this.handlers.get(arg.getKind());handler&&handler(arg)}catch(error){let errorMessage=error instanceof Error?error.message:String(error);throw new Error(`Error processing SQL component of type ${arg.getKind().toString()}: ${errorMessage}`)}}}visitSimpleSelectQuery(query){if(query.selectClause&&query.selectClause.accept(this),query.fromClause&&query.fromClause.accept(this),query.whereClause&&query.whereClause.accept(this),query.groupByClause&&query.groupByClause.accept(this),query.havingClause&&query.havingClause.accept(this),query.windowClause)for(let win of query.windowClause.windows)win.accept(this);query.orderByClause&&query.orderByClause.accept(this),query.limitClause&&query.limitClause.accept(this),query.offsetClause&&query.offsetClause.accept(this),query.fetchClause&&query.fetchClause.accept(this),query.forClause&&query.forClause.accept(this)}visitBinarySelectQuery(query){query.left instanceof SimpleSelectQuery?this.visitSimpleSelectQuery(query.left):query.left instanceof BinarySelectQuery&&this.visitBinarySelectQuery(query.left),query.right instanceof SimpleSelectQuery?this.visitSimpleSelectQuery(query.right):query.right instanceof BinarySelectQuery&&this.visitBinarySelectQuery(query.right)}visitSelectClause(clause){for(let item of clause.items)if(item.identifier)this.addSelectValueAsUnique(item.identifier.name,item.value);else if(item.value instanceof ColumnReference){let columnName=item.value.column.name;columnName!=="*"?this.addSelectValueAsUnique(columnName,item.value):this.includeWildCard&&this.addSelectValueAsUnique(columnName,item.value)}else item.value.accept(this)}visitFromClause(clause){let sourceValues=new SelectValueCollector(this.tableColumnResolver,this.commonTables).collect(clause);for(let item of sourceValues)this.addSelectValueAsUnique(item.name,item.value);if(this.options.upstream&&this.collectUpstreamColumns(clause),clause.joins)for(let join of clause.joins)join.condition&&join.condition.accept(this)}visitWhereClause(clause){clause.condition&&clause.condition.accept(this)}visitGroupByClause(clause){if(clause.grouping)for(let item of clause.grouping)item.accept(this)}visitHavingClause(clause){clause.condition&&clause.condition.accept(this)}visitOrderByClause(clause){if(clause.order)for(let item of clause.order)item.accept(this)}visitWindowFrameClause(clause){clause.expression.accept(this)}visitWindowFrameExpression(expr){expr.partition&&expr.partition.accept(this),expr.order&&expr.order.accept(this),expr.frameSpec&&expr.frameSpec.accept(this)}visitLimitClause(clause){clause.value&&clause.value.accept(this)}offsetClause(clause){clause.value&&clause.value.accept(this)}visitFetchClause(clause){clause.expression&&clause.expression.accept(this)}visitJoinOnClause(joinOnClause){joinOnClause.condition&&joinOnClause.condition.accept(this)}visitJoinUsingClause(joinUsingClause){joinUsingClause.condition&&joinUsingClause.condition.accept(this)}visitColumnReference(columnRef){if(columnRef.column.name!=="*")this.addSelectValueAsUnique(columnRef.column.name,columnRef);else if(this.includeWildCard)this.addSelectValueAsUnique(columnRef.column.name,columnRef);else return}visitBinaryExpression(expr){expr.left&&expr.left.accept(this),expr.right&&expr.right.accept(this)}visitUnaryExpression(expr){expr.expression&&expr.expression.accept(this)}visitFunctionCall(func){func.argument&&func.argument.accept(this),func.over&&func.over.accept(this),func.withinGroup&&func.withinGroup.accept(this),func.internalOrderBy&&func.internalOrderBy.accept(this)}visitInlineQuery(inlineQuery){inlineQuery.selectQuery&&this.visitNode(inlineQuery.selectQuery)}visitParenExpression(expr){expr.expression&&expr.expression.accept(this)}visitCaseExpression(expr){expr.condition&&expr.condition.accept(this),expr.switchCase&&expr.switchCase.accept(this)}visitCastExpression(expr){expr.input&&expr.input.accept(this)}visitBetweenExpression(expr){expr.expression&&expr.expression.accept(this),expr.lower&&expr.lower.accept(this),expr.upper&&expr.upper.accept(this)}visitArrayExpression(expr){expr.expression&&expr.expression.accept(this)}visitArrayQueryExpression(expr){expr.query.accept(this)}visitArraySliceExpression(expr){expr.array&&expr.array.accept(this),expr.startIndex&&expr.startIndex.accept(this),expr.endIndex&&expr.endIndex.accept(this)}visitArrayIndexExpression(expr){expr.array&&expr.array.accept(this),expr.index&&expr.index.accept(this)}visitValueList(expr){if(expr.values&&Array.isArray(expr.values))for(let value of expr.values)value&&value.accept(this)}visitPartitionByClause(clause){clause.value.accept(this)}collectUpstreamColumns(clause){if(this.collectAllAvailableCTEColumns(),this.collectUpstreamColumnsFromSource(clause.source),clause.joins)for(let join of clause.joins)this.collectUpstreamColumnsFromSource(join.source)}collectUpstreamColumnsFromSource(source){if(source.datasource instanceof TableSource){let cteTable=this.findCTEByName(source.datasource.table.name);cteTable?this.collectUpstreamColumnsFromCTE(cteTable):this.collectUpstreamColumnsFromTable(source.datasource)}else source.datasource instanceof SubQuerySource?this.collectUpstreamColumnsFromSubquery(source.datasource):source.datasource instanceof ParenSource&&this.collectUpstreamColumnsFromSource(new SourceExpression(source.datasource.source,null))}collectUpstreamColumnsFromTable(tableSource){if(this.tableColumnResolver){let tableName=tableSource.table.name,columns=this.tableColumnResolver(tableName);for(let columnName of columns){let columnRef=new ColumnReference(tableSource.table.name,columnName);this.addSelectValueAsUnique(columnName,columnRef)}}}collectUpstreamColumnsFromSubquery(subquerySource){if(subquerySource.query instanceof SimpleSelectQuery){let subqueryColumns=new _SelectableColumnCollector(this.tableColumnResolver,this.includeWildCard,this.duplicateDetection,{...this.options,upstream:!0}).collect(subquerySource.query);for(let item of subqueryColumns)this.addSelectValueAsUnique(item.name,item.value)}}collectUpstreamColumnsFromCTE(cteTable){if(cteTable.query instanceof SimpleSelectQuery){let cteColumns=new _SelectableColumnCollector(this.tableColumnResolver,this.includeWildCard,this.duplicateDetection,{...this.options,upstream:!1}).collect(cteTable.query);for(let item of cteColumns)item.name!=="*"&&this.addSelectValueAsUnique(item.name,item.value)}}collectAllAvailableCTEColumns(){for(let cte of this.commonTables)this.collectUpstreamColumnsFromCTE(cte)}findCTEByName(name){return this.commonTables.find(cte=>cte.getSourceAliasName()===name)||null}};var SourceParser=class _SourceParser{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndex0?value.positionedComments=name.positionedComments:name.comments&&name.comments.length>0&&(value.comments=name.comments),{value,newIndex}}static parseFunctionSource(lexemes,fullNameResult){let idx=fullNameResult.newIndex,{namespaces,name}=fullNameResult,argument=ValueParser.parseArgument(4,8,lexemes,idx);idx=argument.newIndex;let withOrdinality=!1;idx=lexemes.length)throw new Error(`Syntax error: Unexpected end of input at position ${idx}. Expected a subquery or nested expression after opening parenthesis.`);let keyword=lexemes[idx].value;if(keyword==="select"||keyword==="values"||keyword==="with"){let result=this.parseSubQuerySource(lexemes,idx,openParenToken);if(idx=result.newIndex,idx0){let afterComments=openParenToken.positionedComments.filter(pc=>pc.position==="after");if(afterComments.length>0){let beforeComments=afterComments.map(pc=>({position:"before",comments:pc.comments}));selectQuery.positionedComments?selectQuery.positionedComments=[...beforeComments,...selectQuery.positionedComments]:selectQuery.positionedComments=beforeComments,selectQuery.comments&&(selectQuery.comments=null)}}return{value:new SubQuerySource(selectQuery),newIndex:idx}}};var DuplicateCTEError=class extends Error{constructor(cteName){super(`CTE '${cteName}' already exists in the query`);this.cteName=cteName;this.name="DuplicateCTEError"}},InvalidCTENameError=class extends Error{constructor(cteName,reason){super(`Invalid CTE name '${cteName}': ${reason}`);this.cteName=cteName;this.name="InvalidCTENameError"}},CTENotFoundError=class extends Error{constructor(cteName){super(`CTE '${cteName}' not found in the query`);this.cteName=cteName;this.name="CTENotFoundError"}};var UpstreamSelectQueryFinder=class{constructor(tableColumnResolver,options){this.options=options||{},this.tableColumnResolver=tableColumnResolver,this.columnCollector=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0})}find(query,columnNames){let namesArray=typeof columnNames=="string"?[columnNames]:columnNames,ctes=new CTECollector().collect(query),cteMap=new Map;for(let cte of ctes)cteMap.set(cte.getSourceAliasName(),cte);return this.findUpstream(query,namesArray,cteMap)}handleTableSource(src,columnNames,cteMap){let cte=cteMap.get(src.table.name);if(cte){let nextCteMap=new Map(cteMap);if(nextCteMap.delete(src.table.name),!this.isSelectQuery(cte.query))return null;let result=this.findUpstream(cte.query,columnNames,nextCteMap);return result.length===0?null:result}return null}handleSubQuerySource(src,columnNames,cteMap){let result=this.findUpstream(src.query,columnNames,cteMap);return result.length===0?null:result}processFromClauseBranches(fromClause,columnNames,cteMap){let sources=fromClause.getSources();if(sources.length===0)return null;let allBranchResults=[],allBranchesOk=!0,validBranchCount=0;for(let sourceExpr of sources){let src=sourceExpr.datasource,branchResult=null;if(src instanceof TableSource)branchResult=this.handleTableSource(src,columnNames,cteMap),validBranchCount++;else if(src instanceof SubQuerySource)branchResult=this.handleSubQuerySource(src,columnNames,cteMap),validBranchCount++;else{if(src instanceof ValuesQuery)continue;allBranchesOk=!1;break}if(branchResult===null){allBranchesOk=!1;break}allBranchResults.push(branchResult)}return allBranchesOk&&allBranchResults.length===validBranchCount?allBranchResults.flat():null}findUpstream(query,columnNames,cteMap){if(query instanceof SimpleSelectQuery){let fromClause=query.fromClause;if(fromClause){let branchResult=this.processFromClauseBranches(fromClause,columnNames,cteMap);if(branchResult&&branchResult.length>0)return branchResult}let columns=this.columnCollector.collect(query).map(col=>col.name),cteColumns=this.collectCTEColumns(query,cteMap),allColumns=[...columns,...cteColumns],normalize=s=>this.options.ignoreCaseAndUnderscore?s.toLowerCase().replace(/_/g,""):s;return columnNames.every(name=>allColumns.some(col=>normalize(col)===normalize(name)))?[query]:[]}else if(query instanceof BinarySelectQuery){let left=this.findUpstream(query.left,columnNames,cteMap),right=this.findUpstream(query.right,columnNames,cteMap);return[...left,...right]}return[]}collectCTEColumns(query,cteMap){let cteColumns=[];if(query.withClause)for(let cte of query.withClause.tables){let columns=this.collectColumnsFromCteQuery(cte.query);cteColumns.push(...columns)}return cteColumns}collectColumnsFromCteQuery(query){return this.isSelectQuery(query)?this.collectColumnsFromSelectQuery(query):this.collectColumnsFromReturning(query)}collectColumnsFromSelectQuery(query){if(query instanceof SimpleSelectQuery)try{return this.columnCollector.collect(query).map(col=>col.name)}catch(error){return console.warn("Failed to collect columns from SimpleSelectQuery:",error),[]}else if(query instanceof BinarySelectQuery)return this.collectColumnsFromSelectQuery(query.left);return[]}collectColumnsFromReturning(query){return query instanceof InsertQuery||query instanceof UpdateQuery||query instanceof DeleteQuery||query instanceof MergeQuery?this.extractReturningColumns(query.returningClause):[]}extractReturningColumns(returningClause){if(!returningClause)return[];let columns=[];for(let item of returningClause.items){let name=item.identifier?.name??this.extractColumnName(item);name&&columns.push(name)}return columns}extractColumnName(item){return item.identifier?item.identifier.name:item.value instanceof ColumnReference?item.value.column.name:null}isSelectQuery(query){return"__selectQueryType"in query&&query.__selectQueryType==="SelectQuery"}};var SourceAliasExpressionParser=class{static parseFromLexeme(lexemes,index){let idx=index;if(idx0&&(sourceAlias2.positionedComments=aliasToken.positionedComments),{value:sourceAlias2,newIndex:idx}}let sourceAlias=new SourceAliasExpression(table,null);return aliasToken.positionedComments&&aliasToken.positionedComments.length>0&&(sourceAlias.positionedComments=aliasToken.positionedComments),{value:sourceAlias,newIndex:idx}}throw new Error(`Syntax error at position ${index}: Expected an identifier for table alias but found "${lexemes[index]?.value||"end of input"}".`)}};var SourceExpressionParser=class{static parse(query){let lexemes=new SqlTokenizer(query).readLexmes(),result=this.parseFromLexeme(lexemes,0);if(result.newIndexcol.name),selectQueries=valuesQuery.tuples.map(tuple=>{if(tuple.values.length!==columnNames.length)throw new Error("Tuple value count does not match column count.");let items=columnNames.map((name,idx)=>new SelectItem(tuple.values[idx],name)),selectClause=new SelectClause(items);return new SimpleSelectQuery({selectClause})}),combined=selectQueries[0];for(let i=1;icol.name),simpleQueries=this.flattenSelectQueries(insertQuery.selectQuery);if(!simpleQueries.length)throw new Error("No SELECT components found to convert.");let tuples=simpleQueries.map(query=>{if(query.fromClause||query.whereClause&&query.whereClause.condition)throw new Error("SELECT queries with FROM or WHERE clauses cannot be converted to VALUES.");let valueMap=new Map;for(let item of query.selectClause.items){let identifier=item.identifier?.name??null;if(!identifier)throw new Error("Each SELECT item must have an alias matching target columns.");valueMap.has(identifier)||valueMap.set(identifier,item.value)}let rowValues=columnNames.map(name=>{let value=valueMap.get(name);if(!value)throw new Error(`Column '${name}' is not provided by the SELECT query.`);return value});return new TupleExpression(rowValues)}),valuesQuery=new ValuesQuery(tuples,columnNames);return SelectQueryWithClauseHelper.setWithClause(valuesQuery,preservedWithClause),new InsertQuery({insertClause:insertQuery.insertClause,selectQuery:valuesQuery,returning:insertQuery.returningClause})}static flattenSelectQueries(selectQuery){if(selectQuery instanceof SimpleSelectQuery)return[selectQuery];if(selectQuery instanceof BinarySelectQuery)return[...this.flattenSelectQueries(selectQuery.left),...this.flattenSelectQueries(selectQuery.right)];throw new Error("Unsupported SelectQuery subtype for conversion.")}};var TextPositionUtils=class{static lineColumnToCharOffset(text,position){if(position.line<1||position.column<1)return-1;let lines=text.split(` `);if(position.line>lines.length)return-1;let targetLine=lines[position.line-1];if(position.column>targetLine.length+1)return-1;let offset=0;for(let i=0;itext.length)return null;let lines=text.split(` `),currentOffset=0;for(let lineIndex=0;lineIndexlines.length?null:lines[lineNumber-1]}static getLineCount(text){return text.split(` @@ -46,7 +46,7 @@ ${query}`}addRestorationComments(sql,targetNode,warnings){let comments=[];return `),charIndex=0;for(let i=0;i=sql.length)return null;let start=charPosition;for(;start>0&&this.isIdentifierChar(sql.charCodeAt(start-1));)start--;let end=charPosition;for(;end0&&this.isIdentifierChar(sql.charCodeAt(start-1));)start--;let end=charPosition;for(;end=0?sql[beforePosition]:null,afterChar=afterPosition=65&&charCode<=90||charCode>=97&&charCode<=122||charCode===95}isIdentifierChar(charCode){return charCode>=65&&charCode<=90||charCode>=97&&charCode<=122||charCode>=48&&charCode<=57||charCode===95}matchesIdentifierAt(sql,position,identifier){if(position+identifier.length>sql.length)return!1;for(let i=0;i=65&&sqlChar<=90?sqlChar+32:sqlChar,idLower=idChar>=65&&idChar<=90?idChar+32:idChar;if(sqlLower!==idLower)return!1}return!0}hasValidWordBoundaries(beforeChar,afterChar){let isValidBefore=beforeChar===null||!this.isIdentifierChar(beforeChar.charCodeAt(0)),isValidAfter=afterChar===null||!this.isIdentifierChar(afterChar.charCodeAt(0));return isValidBefore&&isValidAfter}countWordOccurrences(sql,identifier){let count=0,position=0,sqlLength=sql.length,idLength=identifier.length;for(;position<=sqlLength-idLength;){if(this.matchesIdentifierAt(sql,position,identifier)){let beforePosition=position-1,afterPosition=position+idLength,beforeChar=beforePosition>=0?sql[beforePosition]:null,afterChar=afterPositioncte.aliasExpression&&cte.aliasExpression.table&&cte.aliasExpression.table.name===name):query instanceof BinarySelectQuery?this.isCTEName(query.left,name)||this.isCTEName(query.right,name):!1}attemptFormattingPreservationRename(sql,position,newName,originalName,renamerType){let standardResult=this.performStandardRename(sql,position,newName,originalName,renamerType);if(!standardResult.success)return{...standardResult,formattingPreserved:!1,formattingMethod:"smart-renamer-only"};let renameMap=new Map([[originalName,newName]]);try{let formattedSql=this.identifierRenamer.renameIdentifiers(sql,renameMap);if(this.validateRenameResult(sql,formattedSql,originalName,newName))return{success:!0,originalSql:sql,newSql:formattedSql,renamerType,originalName,newName,formattingPreserved:!0,formattingMethod:"sql-identifier-renamer"};throw new Error("Validation failed: rename may not have been applied correctly")}catch{return{...standardResult,formattingPreserved:!1,formattingMethod:"smart-renamer-only"}}}performStandardRename(sql,position,newName,originalName,renamerType){try{let newSql;if(renamerType==="cte")newSql=this.cteRenamer.renameCTEAtPosition(sql,position,newName);else if(renamerType==="alias"){let result=this.aliasRenamer.renameAlias(sql,position,newName);if(!result.success)return{success:!1,originalSql:sql,renamerType:"alias",originalName,newName,error:result.conflicts?.join(", ")||"Alias rename failed"};newSql=result.newSql}else return{success:!1,originalSql:sql,renamerType:"unknown",originalName,newName,error:`Cannot determine if '${originalName}' is a CTE name or table alias`};return{success:!0,originalSql:sql,newSql,renamerType,originalName,newName}}catch(error){return{success:!1,originalSql:sql,renamerType,originalName,newName,error:`${renamerType.toUpperCase()} rename failed: ${error instanceof Error?error.message:String(error)}`}}}validateRenameResult(originalSql,newSql,oldName,newName){if(originalSql===newSql||!newSql.includes(newName))return!1;let originalOccurrences=this.countWordOccurrences(originalSql,oldName);return this.countWordOccurrences(newSql,oldName)0)for(let comment of lexeme.inlineComments)comment.trim().length>0&&(result+=` -- ${comment}`);lexeme.followingWhitespace&&(result+=lexeme.followingWhitespace)}return result}analyzeFormatting(lexemes){let totalWhitespace=0,totalComments=0,spaceCount=0,tabCount=0,indentLines=0,totalIndentSize=0;for(let lexeme of lexemes){if(lexeme.followingWhitespace){totalWhitespace+=lexeme.followingWhitespace.length;let lines=lexeme.followingWhitespace.split(` -`);for(let i=1;i0||leadingTabs>0)&&(indentLines++,totalIndentSize+=leadingSpaces+leadingTabs*4,spaceCount+=leadingSpaces,tabCount+=leadingTabs)}}lexeme.inlineComments&&(totalComments+=lexeme.inlineComments.length)}let indentationStyle="none";return spaceCount>0&&tabCount>0?indentationStyle="mixed":spaceCount>0?indentationStyle="spaces":tabCount>0&&(indentationStyle="tabs"),{totalWhitespace,totalComments,indentationStyle,averageIndentSize:indentLines>0?totalIndentSize/indentLines:0}}validateFormattingLexemes(lexemes){let issues=[];for(let i=0;i=lexeme.position.endPosition&&issues.push(`Lexeme ${i} has invalid position range`)}return{isValid:issues.length===0,issues}}};var SelectResultSelectConverter=class{static toSelectQuery(query,options){let fixtureTables=options?.fixtureTables??[];if(fixtureTables.length===0)return query;let sources=new TableSourceCollector(!1).collect(query),referencedTables=new Set;sources.forEach(s=>referencedTables.add(s.getSourceName().toLowerCase()));let neededFixtures=fixtureTables.filter(f=>referencedTables.has(f.tableName.toLowerCase()));if(neededFixtures.length===0)return query;let fixtureCtes=FixtureCteBuilder.buildFixtures(neededFixtures);return query instanceof SimpleSelectQuery&&(query.withClause?query.withClause.tables=[...fixtureCtes,...query.withClause.tables]:query.appendWith(fixtureCtes)),query}};var SimulatedSelectConverter=class{static convert(ast,options){if(ast instanceof InsertQuery)return InsertResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof UpdateQuery)return UpdateResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof DeleteQuery)return DeleteResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof MergeQuery)return MergeResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof SimpleSelectQuery||ast instanceof BinarySelectQuery||ast instanceof ValuesQuery)return SelectResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof CreateTableQuery){if(ast.isTemporary&&ast.asSelectQuery){let processedSelect=SelectResultSelectConverter.toSelectQuery(ast.asSelectQuery,options);return ast.asSelectQuery=processedSelect,ast}return null}return null}};var DDLGeneralizer=class{static generalize(ast){let result=[];for(let component of ast)if(component instanceof CreateTableQuery){let{createTable,alterTables}=this.splitCreateTable(component);result.push(createTable),result.push(...alterTables)}else result.push(component);return result}static splitCreateTable(query){let newColumns=[],alterTables=[],tableQualifiedName=new QualifiedName(query.namespaces||[],query.tableName.name);for(let col of query.columns){let newConstraints=[];for(let constraint of col.constraints)if(["primary-key","unique","references","check"].includes(constraint.kind)){let tableConstraint=this.columnToTableConstraint(col.name,constraint);alterTables.push(new AlterTableStatement({table:tableQualifiedName,actions:[new AlterTableAddConstraint({constraint:tableConstraint})]}))}else newConstraints.push(constraint);newColumns.push(new TableColumnDefinition({name:col.name,dataType:col.dataType,constraints:newConstraints}))}if(query.tableConstraints)for(let constraint of query.tableConstraints)alterTables.push(new AlterTableStatement({table:tableQualifiedName,actions:[new AlterTableAddConstraint({constraint})]}));return{createTable:new CreateTableQuery({tableName:query.tableName.name,namespaces:query.namespaces,columns:newColumns,ifNotExists:query.ifNotExists,isTemporary:query.isTemporary,tableOptions:query.tableOptions,asSelectQuery:query.asSelectQuery,withDataOption:query.withDataOption,tableConstraints:[]}),alterTables}}static columnToTableConstraint(columnName,constraint){let baseParams={constraintName:constraint.constraintName,deferrable:constraint.reference?.deferrable,initially:constraint.reference?.initially};switch(constraint.kind){case"primary-key":return new TableConstraintDefinition({kind:"primary-key",columns:[columnName],...baseParams});case"unique":return new TableConstraintDefinition({kind:"unique",columns:[columnName],...baseParams});case"references":return new TableConstraintDefinition({kind:"foreign-key",columns:[columnName],reference:constraint.reference,...baseParams});case"check":return new TableConstraintDefinition({kind:"check",checkExpression:constraint.checkExpression,...baseParams});default:throw new Error(`Unsupported constraint kind for generalization: ${constraint.kind}`)}}};var DDLDiffGenerator=class{static generateDiff(currentSql,expectedSql,options={}){let currentAst=this.parseAndGeneralize(currentSql),expectedAst=this.parseAndGeneralize(expectedSql),currentSchema=this.buildSchema(currentAst),expectedSchema=this.buildSchema(expectedAst),diffAsts=[];for(let[tableName,expectedTable]of expectedSchema.tables){let currentTable=currentSchema.tables.get(tableName);if(currentTable)this.compareColumns(currentTable,expectedTable,diffAsts,options),this.compareConstraints(currentTable,expectedTable,diffAsts,options),this.compareIndexes(currentTable,expectedTable,diffAsts,options);else{let columns=Array.from(expectedTable.columns.values()).map(c=>c.definition),tableNameStr=expectedTable.qualifiedName.name instanceof RawString?expectedTable.qualifiedName.name.value:expectedTable.qualifiedName.name.name,namespaces=expectedTable.qualifiedName.namespaces?expectedTable.qualifiedName.namespaces.map(ns=>ns.name):null,createTable=new CreateTableQuery({tableName:tableNameStr,namespaces,columns});diffAsts.push(createTable);for(let constraint of expectedTable.constraints)diffAsts.push(new AlterTableStatement({table:expectedTable.qualifiedName,actions:[new AlterTableAddConstraint({constraint:constraint.definition})]}));for(let index of expectedTable.indexes)diffAsts.push(index.definition)}}if(options.dropTables)for(let[tableName,currentTable]of currentSchema.tables)expectedSchema.tables.has(tableName)||diffAsts.push(new DropTableStatement({tables:[currentTable.qualifiedName],ifExists:!1}));let formatter2=new SqlFormatter(options.formatOptions||{keywordCase:"upper"});return diffAsts.map(ast=>formatter2.format(ast).formattedSql+";")}static parseAndGeneralize(sql){let split=MultiQuerySplitter.split(sql),asts=[];for(let q of split.queries)if(!q.isEmpty)try{let ast=SqlParser.parse(q.sql);asts.push(ast)}catch(e){console.warn("Failed to parse SQL for diff:",q.sql,e)}return DDLGeneralizer.generalize(asts)}static buildSchema(asts){let tables=new Map,formatter2=new SqlFormatter({keywordCase:"none"});for(let ast of asts)if(ast instanceof CreateTableQuery){let qName=new QualifiedName(ast.namespaces||[],ast.tableName),key=this.getQualifiedNameKey(qName),tableModel={name:key,qualifiedName:qName,columns:new Map,constraints:[],indexes:[]};for(let col of ast.columns)tableModel.columns.set(col.name.name,{name:col.name.name,definition:col});tables.set(key,tableModel)}else if(ast instanceof AlterTableStatement){let key=this.getQualifiedNameKey(ast.table),tableModel=tables.get(key);if(tableModel)for(let action of ast.actions)if(action instanceof AlterTableAddConstraint){let formatted=formatter2.format(action.constraint).formattedSql;tableModel.constraints.push({name:action.constraint.constraintName?.name,kind:action.constraint.kind,definition:action.constraint,formatted})}else action instanceof AlterTableAddColumn&&tableModel.columns.set(action.column.name.name,{name:action.column.name.name,definition:action.column})}else if(ast instanceof CreateIndexStatement){let key=this.getQualifiedNameKey(ast.tableName),tableModel=tables.get(key);if(tableModel){let formatted=formatter2.format(ast).formattedSql;tableModel.indexes.push({name:ast.indexName.toString(),definition:ast,formatted})}}return{tables}}static compareColumns(current,expected,diffs,options){for(let[name,col]of expected.columns)current.columns.has(name)||diffs.push(new AlterTableStatement({table:expected.qualifiedName,actions:[new AlterTableAddColumn({column:col.definition})]}));if(options.dropColumns)for(let[name,col]of current.columns)expected.columns.has(name)||diffs.push(new AlterTableStatement({table:expected.qualifiedName,actions:[new AlterTableDropColumn({columnName:col.definition.name})]}))}static compareConstraints(current,expected,diffs,options){let formatter2=new SqlFormatter({keywordCase:"none"}),getConstraintSignature=c=>options.checkConstraintNames?c.kind==="primary-key"?c.formatted.replace(/^constraint\s+("[^"]+"|[^\s]+)\s+/i,"").trim():c.name||c.formatted:c.formatted.replace(/^constraint\s+("[^"]+"|[^\s]+)\s+/i,"").trim(),currentSignatures=new Set(current.constraints.map(getConstraintSignature));for(let expectedC of expected.constraints){let sig=getConstraintSignature(expectedC);currentSignatures.has(sig)||diffs.push(new AlterTableStatement({table:expected.qualifiedName,actions:[new AlterTableAddConstraint({constraint:expectedC.definition})]}))}if(options.dropConstraints){let expectedSignatures=new Set(expected.constraints.map(getConstraintSignature));for(let currentC of current.constraints){let sig=getConstraintSignature(currentC);expectedSignatures.has(sig)||(currentC.name?diffs.push(new AlterTableStatement({table:expected.qualifiedName,actions:[new AlterTableDropConstraint({constraintName:new IdentifierString(currentC.name)})]})):console.warn("Cannot drop unnamed constraint:",currentC.formatted))}}}static compareIndexes(current,expected,diffs,options){let getIndexSignature=idx=>{if(options.checkConstraintNames)return idx.name;let def=idx.definition,parts=[];parts.push(def.tableName.toString()),def.unique&&parts.push("UNIQUE"),def.usingMethod&&parts.push(`USING:${def.usingMethod.toString()}`);let columnSigs=def.columns.map(col=>{let expr=col.expression.toString(),sort=col.sortOrder||"",nulls=col.nullsOrder||"";return`${expr}${sort}${nulls}`});return parts.push(`COLS:${columnSigs.join(",")}`),def.include&&def.include.length>0&&parts.push(`INCLUDE:${def.include.map(i=>i.toString()).join(",")}`),def.where&&parts.push(`WHERE:${def.where.toString()}`),parts.join("|")},currentSignatures=new Set(current.indexes.map(getIndexSignature));for(let expectedIdx of expected.indexes){let sig=getIndexSignature(expectedIdx);currentSignatures.has(sig)||diffs.push(expectedIdx.definition)}if(options.checkConstraintNames||options.dropIndexes){let expectedSignatures=new Set(expected.indexes.map(getIndexSignature));for(let currentIdx of current.indexes){let sig=getIndexSignature(currentIdx);expectedSignatures.has(sig)||diffs.push(new DropIndexStatement({indexNames:[currentIdx.definition.indexName],ifExists:!1}))}}}static getQualifiedNameKey(qName){return qName.toString()}};var ParameterDetector=class{static extractParameterNames(query){return ParameterCollector.collect(query).map(p=>p.name.value)}static hasParameter(query,parameterName){return this.extractParameterNames(query).includes(parameterName)}static separateFilters(query,filter){let hardcodedParamNames=this.extractParameterNames(query),hardcodedParams={},dynamicFilters={};for(let[key,value]of Object.entries(filter))hardcodedParamNames.includes(key)?hardcodedParams[key]=value:dynamicFilters[key]=value;return{hardcodedParams,dynamicFilters}}};var FilterableItem=class{constructor(name,type,tableName){this.name=name;this.type=type;this.tableName=tableName}},FilterableItemCollector=class{constructor(tableColumnResolver,options){this.tableColumnResolver=tableColumnResolver,this.options={qualified:!1,upstream:!0,...options}}collect(query){let items=[],columnItems=this.collectColumns(query);items.push(...columnItems);let parameterItems=this.collectParameters(query);return items.push(...parameterItems),this.removeDuplicates(items)}collectColumns(query){let items=[];try{let columns=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:this.options.upstream}).collect(query);for(let column of columns){let tableName,realTableName;if(column.value&&typeof column.value.getNamespace=="function"){let namespace=column.value.getNamespace();namespace&&namespace.trim()!==""&&(tableName=namespace,this.options.qualified&&(realTableName=this.getRealTableName(query,namespace)))}tableName||(tableName=this.inferTableNameFromQuery(query),tableName&&this.options.qualified&&(realTableName=tableName));let columnName=column.name;this.options.qualified&&(realTableName||tableName)&&(columnName=`${realTableName||tableName}.${column.name}`),items.push(new FilterableItem(columnName,"column",tableName))}}catch(error){console.warn("Failed to collect columns with SelectableColumnCollector, using fallback:",error);try{let schemas=new SchemaCollector(this.tableColumnResolver,!0).collect(query);for(let schema of schemas)for(let columnName of schema.columns){let finalColumnName=columnName;this.options.qualified&&(finalColumnName=`${schema.name}.${columnName}`),items.push(new FilterableItem(finalColumnName,"column",schema.name))}}catch(fallbackError){console.warn("Failed to collect columns with both approaches:",error,fallbackError)}}return items}inferTableNameFromQuery(query){if(query instanceof SimpleSelectQuery&&query.fromClause&&query.fromClause.source){let datasource=query.fromClause.source.datasource;if(datasource&&typeof datasource.table=="object"){let table=datasource.table;if(table&&typeof table.name=="string")return table.name}}}getRealTableName(query,aliasOrName){try{let simpleQuery=query.type==="WITH"?query.toSimpleQuery():query;if(simpleQuery instanceof SimpleSelectQuery&&simpleQuery.fromClause){if(simpleQuery.fromClause.source?.datasource){let mainSource=simpleQuery.fromClause.source,realName=this.extractRealTableName(mainSource,aliasOrName);if(realName)return realName}let fromClause=simpleQuery.fromClause;if(fromClause.joinClauses&&Array.isArray(fromClause.joinClauses)){for(let joinClause of fromClause.joinClauses)if(joinClause.source?.datasource){let realName=this.extractRealTableName(joinClause.source,aliasOrName);if(realName)return realName}}}}catch(error){console.warn("Error resolving real table name:",error)}return aliasOrName}extractRealTableName(source,aliasOrName){try{let datasource=source.datasource;if(!datasource)return;let alias=source.alias||source.aliasExpression?.table?.name,realTableName=datasource.table?.name;if(alias===aliasOrName&&realTableName||!alias&&realTableName===aliasOrName)return realTableName}catch{}}collectParameters(query){let items=[];try{let parameterNames=ParameterDetector.extractParameterNames(query);for(let paramName of parameterNames)items.push(new FilterableItem(paramName,"parameter"))}catch(error){console.warn("Failed to collect parameters:",error)}return items}removeDuplicates(items){let seen=new Set,result=[];for(let item of items){let key=`${item.type}:${item.name}:${item.tableName||"none"}`;seen.has(key)||(seen.add(key),result.push(item))}return result.sort((a,b)=>{if(a.type!==b.type)return a.type==="column"?-1:1;if(a.type==="column"){let tableA=a.tableName||"",tableB=b.tableName||"";if(tableA!==tableB)return tableA.localeCompare(tableB)}return a.name.localeCompare(b.name)})}};var SqlSortInjector=class{constructor(tableColumnResolver){this.tableColumnResolver=tableColumnResolver}static removeOrderBy(query){if(typeof query=="string"&&(query=SelectQueryParser.parse(query)),!(query instanceof SimpleSelectQuery))throw new Error("Complex queries are not supported for ORDER BY removal");return new SimpleSelectQuery({withClause:query.withClause,selectClause:query.selectClause,fromClause:query.fromClause,whereClause:query.whereClause,groupByClause:query.groupByClause,havingClause:query.havingClause,orderByClause:null,windowClause:query.windowClause,limitClause:query.limitClause,offsetClause:query.offsetClause,fetchClause:query.fetchClause,forClause:query.forClause})}inject(query,sortConditions){if(typeof query=="string"&&(query=SelectQueryParser.parse(query)),!(query instanceof SimpleSelectQuery))throw new Error("Complex queries are not supported for sorting");let availableColumns=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query);for(let columnName of Object.keys(sortConditions))if(!availableColumns.find(item=>item.name===columnName))throw new Error(`Column or alias '${columnName}' not found in current query`);let newOrderByItems=[];for(let[columnName,condition]of Object.entries(sortConditions)){let columnEntry=availableColumns.find(item=>item.name===columnName);if(!columnEntry)continue;let columnRef=columnEntry.value;this.validateSortCondition(columnName,condition);let sortDirection;condition.desc?sortDirection="desc":sortDirection="asc";let nullsPosition=null;condition.nullsFirst?nullsPosition="first":condition.nullsLast&&(nullsPosition="last");let orderByItem=new OrderByItem(columnRef,sortDirection,nullsPosition);newOrderByItems.push(orderByItem)}let finalOrderByItems=[];query.orderByClause?finalOrderByItems=[...query.orderByClause.order,...newOrderByItems]:finalOrderByItems=newOrderByItems;let newOrderByClause=finalOrderByItems.length>0?new OrderByClause(finalOrderByItems):null;return new SimpleSelectQuery({withClause:query.withClause,selectClause:query.selectClause,fromClause:query.fromClause,whereClause:query.whereClause,groupByClause:query.groupByClause,havingClause:query.havingClause,orderByClause:newOrderByClause,windowClause:query.windowClause,limitClause:query.limitClause,offsetClause:query.offsetClause,fetchClause:query.fetchClause,forClause:query.forClause})}validateSortCondition(columnName,condition){if(condition.asc&&condition.desc)throw new Error(`Conflicting sort directions for column '${columnName}': both asc and desc specified`);if(condition.nullsFirst&&condition.nullsLast)throw new Error(`Conflicting nulls positions for column '${columnName}': both nullsFirst and nullsLast specified`);if(!condition.asc&&!condition.desc&&!condition.nullsFirst&&!condition.nullsLast)throw new Error(`Empty sort condition for column '${columnName}': at least one sort option must be specified`)}};var SqlPaginationInjector=class{inject(query,pagination){if(this.validatePaginationOptions(pagination),typeof query=="string"&&(query=SelectQueryParser.parse(query)),!(query instanceof SimpleSelectQuery))throw new Error("Complex queries are not supported for pagination");if(query.limitClause||query.offsetClause)throw new Error("Query already contains LIMIT or OFFSET clause. Use removePagination() first if you want to override existing pagination.");let offset=(pagination.page-1)*pagination.pageSize,limitClause=new LimitClause(new ParameterExpression("paging_limit",pagination.pageSize)),offsetClause=new OffsetClause(new ParameterExpression("paging_offset",offset));return new SimpleSelectQuery({withClause:query.withClause,selectClause:query.selectClause,fromClause:query.fromClause,whereClause:query.whereClause,groupByClause:query.groupByClause,havingClause:query.havingClause,orderByClause:query.orderByClause,windowClause:query.windowClause,limitClause,offsetClause,fetchClause:query.fetchClause,forClause:query.forClause})}static removePagination(query){if(typeof query=="string"&&(query=SelectQueryParser.parse(query)),!(query instanceof SimpleSelectQuery))throw new Error("Complex queries are not supported for pagination removal");return new SimpleSelectQuery({withClause:query.withClause,selectClause:query.selectClause,fromClause:query.fromClause,whereClause:query.whereClause,groupByClause:query.groupByClause,havingClause:query.havingClause,orderByClause:query.orderByClause,windowClause:query.windowClause,limitClause:null,offsetClause:null,fetchClause:query.fetchClause,forClause:query.forClause})}validatePaginationOptions(pagination){if(!pagination)throw new Error("Pagination options are required");if(typeof pagination.page!="number"||pagination.page<1)throw new Error("Page number must be a positive integer (1 or greater)");if(typeof pagination.pageSize!="number"||pagination.pageSize<1)throw new Error("Page size must be a positive integer (1 or greater)");if(pagination.pageSize>1e3)throw new Error("Page size cannot exceed 1000 items")}};var SqlParameterBinder=class{constructor(options={}){this.options={requireAllParameters:!0,...options}}bind(query,parameterValues){let modifiedQuery=query,existingParams=ParameterDetector.extractParameterNames(modifiedQuery);if(this.options.requireAllParameters){let missingParams=existingParams.filter(paramName=>!(paramName in parameterValues)||parameterValues[paramName]===void 0);if(missingParams.length>0)throw new Error(`Missing values for required parameters: ${missingParams.join(", ")}`)}for(let[paramName,value]of Object.entries(parameterValues))if(existingParams.includes(paramName))try{ParameterHelper.set(modifiedQuery,paramName,value)}catch(error){throw new Error(`Failed to bind parameter '${paramName}': ${error instanceof Error?error.message:"Unknown error"}`)}return modifiedQuery}bindToSimpleQuery(query,parameterValues){return this.bind(query,parameterValues)}};var NAMESPACE_SEPARATOR="|",normalizeIdentifier=input=>{let value=input?.trim()??"";return value===""?"":value.toLowerCase()},normalizeColumnSetKey=columns=>columns.map(column=>normalizeIdentifier(column)).filter(Boolean).sort().join(NAMESPACE_SEPARATOR),buildSchemaMap=schemaInfo=>{let map=new Map;for(let table of schemaInfo){let normalizedName=normalizeIdentifier(table.name);if(!normalizedName)continue;let columnSet=new Set(table.columns.map(normalizeIdentifier).filter(Boolean)),uniqueSetKeys=new Set;for(let uniqueKey of table.uniqueKeys){let normalizedKey=normalizeColumnSetKey(uniqueKey);normalizedKey&&uniqueSetKeys.add(normalizedKey)}columnSet.size===0&&uniqueSetKeys.size===0||map.set(normalizedName,{columnSet,uniqueSetKeys})}return map},collectReferenceMetadata=query=>{let collector=new ColumnReferenceCollector,namespaceCounts=new Map,unqualifiedColumns=new Set;for(let ref of collector.collect(query)){let namespace=normalizeIdentifier(ref.getNamespace());if(namespace)namespaceCounts.set(namespace,(namespaceCounts.get(namespace)??0)+1);else{let column=normalizeIdentifier(ref.column.name);column&&unqualifiedColumns.add(column)}}let joinConditionCounts=new Map;if(query.fromClause?.joins)for(let join of query.fromClause.joins){let counts=new Map;if(join.condition&&join.condition instanceof JoinOnClause){let joinCollector=new ColumnReferenceCollector;for(let ref of joinCollector.collect(join.condition.condition)){let namespace=normalizeIdentifier(ref.getNamespace());namespace&&counts.set(namespace,(counts.get(namespace)??0)+1)}}joinConditionCounts.set(join,counts)}return{namespaceCounts,unqualifiedColumns,joinConditionCounts}},isLeftJoin=join=>join.joinType.value.toLowerCase().includes("left"),getJoinIdentifiers=join=>{let identifiers=new Set,alias=normalizeIdentifier(join.source.getAliasName());if(alias&&identifiers.add(alias),join.source.datasource instanceof TableSource){let rawName=join.source.datasource.getSourceName();rawName&&identifiers.add(normalizeIdentifier(rawName));let shortName=normalizeIdentifier(join.source.datasource.table.name);shortName&&identifiers.add(shortName)}return[...identifiers]},hasExternalReferences=(identifiers,metadata,join)=>{let local=metadata.joinConditionCounts.get(join)??new Map;for(let identifier of identifiers){let total=metadata.namespaceCounts.get(identifier)??0,localCount=local.get(identifier)??0;if(total-localCount>0)return!0}return!1},getJoinColumnInfo=(join,identifiers)=>{if(!(join.condition instanceof JoinOnClause))return null;let expression=join.condition.condition;if(!(expression instanceof BinaryExpression)||expression.operator.value.trim().toLowerCase()!=="=")return null;let resolveColumn=component=>component instanceof ColumnReference?component:null,leftRef=resolveColumn(expression.left),rightRef=resolveColumn(expression.right);if(!leftRef||!rightRef)return null;let normalizedLeftNamespace=normalizeIdentifier(leftRef.getNamespace()),normalizedRightNamespace=normalizeIdentifier(rightRef.getNamespace());return identifiers.has(normalizedLeftNamespace)?normalizeIdentifier(leftRef.column.name):identifiers.has(normalizedRightNamespace)?normalizeIdentifier(rightRef.column.name):null},shouldRemoveJoin=(join,schemaMap,metadata)=>{if(!isLeftJoin(join)||join.lateral||!(join.source.datasource instanceof TableSource))return!1;let candidates=[normalizeIdentifier(join.source.datasource.getSourceName()),normalizeIdentifier(join.source.datasource.table.name)].filter(Boolean),tableInfo;for(let candidate of candidates){let info=schemaMap.get(candidate);if(info){tableInfo=info;break}}if(!tableInfo)return!1;let identifiers=new Set(getJoinIdentifiers(join));if(identifiers.size===0||hasExternalReferences([...identifiers],metadata,join))return!1;let joinColumn=getJoinColumnInfo(join,identifiers);if(!joinColumn||metadata.unqualifiedColumns.has(joinColumn)||tableInfo.columnSet.size>0&&!tableInfo.columnSet.has(joinColumn))return!1;let uniqueKey=normalizeColumnSetKey([joinColumn]);return!!tableInfo.uniqueSetKeys.has(uniqueKey)},optimizeSimpleQuery=(query,schemaMap)=>{if(!query.fromClause?.joins?.length)return!1;let metadata=collectReferenceMetadata(query),retainedJoins=[],removed=!1;for(let join of query.fromClause.joins){if(shouldRemoveJoin(join,schemaMap,metadata)){removed=!0;continue}retainedJoins.push(join)}return query.fromClause.joins=retainedJoins.length>0?retainedJoins:null,removed},traverseSelectQuery=(query,schemaMap)=>{if(query instanceof SimpleSelectQuery)return optimizeSimpleQuery(query,schemaMap);if(query instanceof BinarySelectQuery){let leftChanged=traverseSelectQuery(query.left,schemaMap),rightChanged=traverseSelectQuery(query.right,schemaMap);return leftChanged||rightChanged}return!1},optimizeUnusedLeftJoinsOnce=(query,schemaMap)=>schemaMap.size===0?!1:traverseSelectQuery(query,schemaMap),optimizeUnusedLeftJoins=(query,schemaInfo)=>(optimizeUnusedLeftJoinsOnce(query,buildSchemaMap(schemaInfo)),query),optimizeUnusedLeftJoinsToFixedPoint=(query,schemaInfo)=>{let schemaMap=buildSchemaMap(schemaInfo),changed=!0;for(;changed;)changed=optimizeUnusedLeftJoinsOnce(query,schemaMap);return query},collectTableSourceNames=component=>{let collector=new CTETableReferenceCollector,names=new Set;for(let source of collector.collect(component)){let normalizedName=normalizeIdentifier(source.table.name);normalizedName&&names.add(normalizedName)}return names},isReferencedByOthers=(cteName,mainReferences,cteReferenceMap)=>{if(mainReferences.has(cteName))return!0;for(let[otherName,references]of cteReferenceMap)if(otherName!==cteName&&references.has(cteName))return!0;return!1},optimizeSimpleQueryCtes=query=>{let withClause=query.withClause;if(!withClause||withClause.recursive||withClause.tables.length===0)return!1;let mainReferences=collectTableSourceNames(query),cteReferenceMap=new Map;for(let table of withClause.tables){let normalizedName=normalizeIdentifier(table.aliasExpression.table.name);normalizedName&&cteReferenceMap.set(normalizedName,collectTableSourceNames(table.query))}let removableNames=[];for(let table of withClause.tables){let normalizedName=normalizeIdentifier(table.aliasExpression.table.name);if(!normalizedName)continue;let body=table.query;!(body instanceof SimpleSelectQuery)&&!(body instanceof BinarySelectQuery)||isReferencedByOthers(normalizedName,mainReferences,cteReferenceMap)||removableNames.push(normalizedName)}if(removableNames.length===0)return!1;for(let name of removableNames)query.removeCTE(name);return!0},optimizeCtesInSelectQuery=query=>{if(query instanceof SimpleSelectQuery)return optimizeSimpleQueryCtes(query);if(query instanceof BinarySelectQuery){let leftChanged=optimizeCtesInSelectQuery(query.left),rightChanged=optimizeCtesInSelectQuery(query.right);return leftChanged||rightChanged}return!1},optimizeUnusedCtesOnce=query=>optimizeCtesInSelectQuery(query),optimizeUnusedCtes=query=>(optimizeUnusedCtesOnce(query),query),optimizeUnusedCtesToFixedPoint=query=>{let changed=!0;for(;changed;)changed=optimizeUnusedCtesOnce(query);return query};var isBinaryOperator=(expression,operator)=>expression instanceof BinaryExpression&&expression.operator.value.trim().toLowerCase()===operator,unwrapSingleOuterParen=expression=>{let candidate=expression;for(;candidate instanceof ParenExpression;)candidate=candidate.expression;return candidate},collectTopLevelAndTerms=expression=>{let candidate=unwrapSingleOuterParen(expression);return isBinaryOperator(candidate,"and")?[...collectTopLevelAndTerms(candidate.left),...collectTopLevelAndTerms(candidate.right)]:[expression]},collectTopLevelOrTerms=expression=>{let candidate=unwrapSingleOuterParen(expression);return isBinaryOperator(candidate,"or")?[...collectTopLevelOrTerms(candidate.left),...collectTopLevelOrTerms(candidate.right)]:[expression]},isNullLiteral=expression=>expression instanceof LiteralValue&&expression.value===null||expression instanceof RawString&&expression.value.trim().toLowerCase()==="null",isTrueSentinel=expression=>{let candidate=unwrapSingleOuterParen(expression);return candidate instanceof LiteralValue?candidate.value===!0:isBinaryOperator(candidate,"=")?candidate.left instanceof LiteralValue&&candidate.right instanceof LiteralValue&&candidate.left.value===1&&candidate.right.value===1:!1},getGuardedParameterName=expression=>{let candidate=unwrapSingleOuterParen(expression);return!isBinaryOperator(candidate,"is")||!(candidate.left instanceof ParameterExpression)||!isNullLiteral(candidate.right)?null:candidate.left.name.value},getUniqueParameterNames=expression=>new Set(ParameterCollector.collect(expression).map(parameter=>parameter.name.value)),isSupportedMeaningfulBranch=(expression,parameterName)=>{let candidate=unwrapSingleOuterParen(expression);if(candidate instanceof ParameterExpression)return!1;let parameterNames=getUniqueParameterNames(candidate);return parameterNames.size!==1||!parameterNames.has(parameterName)?!1:!(candidate instanceof LiteralValue||candidate instanceof RawString)},isExplicitPruningTarget=(pruningParameters,parameterName)=>Object.prototype.hasOwnProperty.call(pruningParameters,parameterName),isKnownAbsentTarget=(pruningParameters,parameterName)=>{if(!isExplicitPruningTarget(pruningParameters,parameterName))return!1;let parameterValue=pruningParameters[parameterName];return parameterValue==null},shouldPruneOptionalBranch=(expression,pruningParameters)=>{let branch=getSupportedOptionalConditionBranch(expression);return branch!==null&&isKnownAbsentTarget(pruningParameters,branch.parameterName)},rebuildAndCondition=terms=>{if(terms.length===0)return null;let condition=terms[0];for(let index=1;index{if(!query.whereClause)return!1;let topLevelTerms=collectTopLevelAndTerms(query.whereClause.condition),retainedTerms=[],prunedAnyBranch=!1;for(let term of topLevelTerms){if(shouldPruneOptionalBranch(term,pruningParameters)){prunedAnyBranch=!0;continue}retainedTerms.push(term)}if(!prunedAnyBranch)return!1;let cleanedTerms=retainedTerms.filter(term=>!isTrueSentinel(term)),rebuiltCondition=rebuildAndCondition(cleanedTerms);return query.whereClause=rebuiltCondition?new WhereClause(rebuiltCondition):null,!0},isSelectQueryNode=value=>value instanceof SimpleSelectQuery||value instanceof BinarySelectQuery,traverseNestedSelectQueries=(root,pruningParameters)=>{let changed=!1,visited=new WeakSet,walk=value=>{if(!(!value||typeof value!="object")&&!visited.has(value)){if(visited.add(value),value!==root&&isSelectQueryNode(value)){changed=traverseSelectQuery2(value,pruningParameters)||changed;return}if(Array.isArray(value)){value.forEach(walk);return}for(let child of Object.values(value))walk(child)}};return walk(root),changed},traverseSelectQuery2=(query,pruningParameters)=>{if(query instanceof SimpleSelectQuery){let selfChanged=pruneSimpleQueryWhereClause(query,pruningParameters),nestedChanged=traverseNestedSelectQueries(query,pruningParameters);return selfChanged||nestedChanged}if(query instanceof BinarySelectQuery){let leftChanged=traverseSelectQuery2(query.left,pruningParameters),rightChanged=traverseSelectQuery2(query.right,pruningParameters);return leftChanged||rightChanged}return!1},getSupportedOptionalConditionBranch=expression=>{let orTerms=collectTopLevelOrTerms(expression);if(orTerms.length<2)return null;let guardTerms=orTerms.map(term=>({term,parameterName:getGuardedParameterName(term)})).filter(candidate=>candidate.parameterName!==null);if(guardTerms.length!==1)return null;let[{term:guardTerm,parameterName}]=guardTerms,meaningfulTerms=orTerms.filter(term=>term!==guardTerm);return meaningfulTerms.length===0||!meaningfulTerms.every(term=>isSupportedMeaningfulBranch(term,parameterName))?null:{parameterName,kind:"expression"}},collectSupportedBranchesFromSimpleQuery=(query,branches)=>{if(!query.whereClause)return;let topLevelTerms=collectTopLevelAndTerms(query.whereClause.condition);for(let term of topLevelTerms){let branch=getSupportedOptionalConditionBranch(term);branch&&branches.push({query,parameterName:branch.parameterName,expression:term,kind:branch.kind})}},collectSupportedBranchesFromSelectQuery=(query,branches)=>{if(query instanceof SimpleSelectQuery){collectSupportedBranchesFromSimpleQuery(query,branches),traverseNestedSelectQueriesForCollection(query,branches);return}query instanceof BinarySelectQuery&&(collectSupportedBranchesFromSelectQuery(query.left,branches),collectSupportedBranchesFromSelectQuery(query.right,branches))},traverseNestedSelectQueriesForCollection=(root,branches)=>{let visited=new WeakSet,walk=value=>{if(!(!value||typeof value!="object")&&!visited.has(value)){if(visited.add(value),value!==root&&isSelectQueryNode(value)){collectSupportedBranchesFromSelectQuery(value,branches);return}if(Array.isArray(value)){value.forEach(walk);return}for(let child of Object.values(value))walk(child)}};walk(root)},pruneOptionalConditionBranches=(query,pruningParameters)=>(Object.keys(pruningParameters).length===0||traverseSelectQuery2(query,pruningParameters),query),collectSupportedOptionalConditionBranches=query=>{let branches=[];return collectSupportedBranchesFromSelectQuery(query,branches),branches};var DynamicQueryBuilder=class{constructor(resolverOrOptions){typeof resolverOrOptions=="function"?this.tableColumnResolver=resolverOrOptions:resolverOrOptions&&(this.tableColumnResolver=resolverOrOptions.tableColumnResolver,this.defaultSchemaInfo=resolverOrOptions.schemaInfo)}buildQuery(sqlContent,options={}){let removedOptions=options;if("serialize"in removedOptions||"jsonb"in removedOptions)throw new Error("DynamicQueryBuilder SQL-result JSON shaping has been removed. Keep SQL results as rows and use generated AOT mappers so the executed SQL remains debuggable.");let parsedQuery;try{parsedQuery=SelectQueryParser.parse(sqlContent)}catch(error){throw new Error(`Failed to parse SQL: ${error instanceof Error?error.message:"Unknown error"}`)}let modifiedQuery=parsedQuery;if(options.filter&&Object.keys(options.filter).length>0){let{hardcodedParams,dynamicFilters}=ParameterDetector.separateFilters(modifiedQuery,options.filter);if(Object.keys(hardcodedParams).length>0&&(modifiedQuery=new SqlParameterBinder({requireAllParameters:!1}).bind(modifiedQuery,hardcodedParams)),Object.keys(dynamicFilters).length>0)throw new Error("DynamicQueryBuilder no longer injects runtime filter predicates. Use `ztd query sssql scaffold` to author optional filters, `ztd query sssql refresh` to refresh them, and `optionalConditionParameters` at runtime for pruning only.")}if(options.sort&&Object.keys(options.sort).length>0){let sortInjector=new SqlSortInjector(this.tableColumnResolver),simpleQuery=QueryBuilder.buildSimpleQuery(modifiedQuery);modifiedQuery=sortInjector.inject(simpleQuery,options.sort)}if(options.paging){let{page=1,pageSize}=options.paging;if(pageSize!==void 0){let paginationInjector=new SqlPaginationInjector,paginationOptions={page,pageSize},simpleQuery=QueryBuilder.buildSimpleQuery(modifiedQuery);modifiedQuery=paginationInjector.inject(simpleQuery,paginationOptions)}}modifiedQuery=this.applyColumnFilters(modifiedQuery,options);let optionalConditionParameters=this.resolveOptionalConditionPruningParameters(options);Object.keys(optionalConditionParameters).length>0&&(modifiedQuery=pruneOptionalConditionBranches(modifiedQuery,optionalConditionParameters));let effectiveSchemaInfo=options.schemaInfo??this.defaultSchemaInfo;return options.removeUnusedLeftJoins&&effectiveSchemaInfo?.length&&(modifiedQuery=optimizeUnusedLeftJoinsToFixedPoint(modifiedQuery,effectiveSchemaInfo)),options.removeUnusedCtes&&(modifiedQuery=optimizeUnusedCtesToFixedPoint(modifiedQuery)),modifiedQuery}resolveOptionalConditionPruningParameters(options){if(options.optionalConditionParameters)return options.optionalConditionParameters;if(!options.optionalConditionParameterStates)return{};let legacyParameters={};for(let[parameterName,state]of Object.entries(options.optionalConditionParameterStates))legacyParameters[parameterName]=state==="absent"?null:"__RAWSQL_OPTIONAL_CONDITION_PRESENT__";return legacyParameters}applyColumnFilters(query,options){let hasIncludeFilters=Array.isArray(options.includeColumns)&&options.includeColumns.length>0,hasExcludeFilters=Array.isArray(options.excludeColumns)&&options.excludeColumns.length>0;if(!hasIncludeFilters&&!hasExcludeFilters)return query;if(hasIncludeFilters&&hasExcludeFilters)throw new Error("includeColumns and excludeColumns cannot be used together.");let simpleQuery=QueryBuilder.buildSimpleQuery(query),metadata=simpleQuery.selectClause.items.map(item=>{let name=this.getSelectItemName(item);return{item,normalized:name?this.normalizeColumnIdentifier(name):null}}),availableColumns=new Set(metadata.map(entry=>entry.normalized).filter(name=>name!==null)),includeFilters=hasIncludeFilters?this.normalizeColumnList(options.includeColumns):null,excludeFilters=hasExcludeFilters?this.normalizeColumnList(options.excludeColumns):null,includeSet=includeFilters?new Set(includeFilters.map(entry=>entry.normalized)):null,excludeSet=excludeFilters?new Set(excludeFilters.map(entry=>entry.normalized)):null;if(includeFilters){let missing=includeFilters.filter(entry=>!availableColumns.has(entry.normalized));if(missing.length>0)throw new Error(`Column${missing.length===1?"":"s"} not found in SELECT clause: ${missing.map(entry=>`'${entry.original}'`).join(", ")}.`)}if(excludeFilters){let missing=excludeFilters.filter(entry=>!availableColumns.has(entry.normalized));if(missing.length>0)throw new Error(`Column${missing.length===1?"":"s"} not found in SELECT clause: ${missing.map(entry=>`'${entry.original}'`).join(", ")}.`)}let filteredItems=metadata.filter(entry=>entry.normalized?includeSet?includeSet.has(entry.normalized):excludeSet?!excludeSet.has(entry.normalized):!0:!0).map(entry=>entry.item);if(filteredItems.length===0)throw new Error("Column filtering removed every SELECT item.");return simpleQuery.selectClause.items=filteredItems,simpleQuery}normalizeColumnList(columns){return columns.map(column=>{if(typeof column!="string")throw new Error("Column filters must be strings.");let trimmed=column.trim();if(trimmed==="")throw new Error("Column filters must not be empty.");return{normalized:this.normalizeColumnIdentifier(trimmed),original:trimmed}})}normalizeColumnIdentifier(value){return value.trim().toLowerCase()}getSelectItemName(item){return item.identifier?item.identifier.name:item.value instanceof ColumnReference?item.value.column.name:null}buildFilteredQuery(sqlContent,filter){return this.buildQuery(sqlContent,{filter})}buildSortedQuery(sqlContent,sort){return this.buildQuery(sqlContent,{sort})}buildPaginatedQuery(sqlContent,paging){return this.buildQuery(sqlContent,{paging})}validateSql(sqlContent){try{return SelectQueryParser.parse(sqlContent),!0}catch(error){throw new Error(`Invalid SQL: ${error instanceof Error?error.message:"Unknown error"}`)}}};var SUPPORTED_SCALAR_OPERATORS=new Set(["=","<>","<","<=",">",">=","like","ilike"]),formatter=null,normalizeIdentifier2=value=>value.trim().toLowerCase(),normalizeSql=value=>value.replace(/\s+/g," ").trim().toLowerCase(),normalizeColumnReferenceKey=reference=>`${normalizeIdentifier2(reference.getNamespace())}.${normalizeIdentifier2(reference.column.name)}`,normalizeColumnReferenceText=reference=>{let namespace=reference.getNamespace();return namespace?`${namespace}.${reference.column.name}`:reference.column.name},normalizeScalarOperator=value=>{if(!value)return"=";let normalized=value.trim().toLowerCase();if(normalized==="!=")return"<>";if(SUPPORTED_SCALAR_OPERATORS.has(normalized))return normalized;throw new Error(`Unsupported SSSQL operator '${value}'.`)},isExplicitEqualityScaffoldValue=value=>{if(value==null)return!0;if(Array.isArray(value))return!1;if(typeof value!="object")return!0;let entries=Object.entries(value).filter(([,entry])=>entry!==void 0);return entries.length===1&&entries[0]?.[0]==="="},parseQualifiedFilterName=filterName=>{let segments=filterName.split(".");if(segments.length!==2)return null;let[table,column]=segments.map(segment=>segment.trim());return!table||!column?null:{table,column}},makeParameterName=filterName=>filterName.trim().replace(/\./g,"_").replace(/[^a-zA-Z0-9_]/g,"_"),unwrapParens=expression=>{let candidate=expression;for(;candidate instanceof ParenExpression;)candidate=candidate.expression;return candidate},isBinaryOperator2=(expression,operator)=>expression instanceof BinaryExpression&&expression.operator.value.trim().toLowerCase()===operator,collectTopLevelAndTerms2=expression=>{let candidate=unwrapParens(expression);return isBinaryOperator2(candidate,"and")?[...collectTopLevelAndTerms2(candidate.left),...collectTopLevelAndTerms2(candidate.right)]:[expression]},collectTopLevelOrTerms2=expression=>{let candidate=unwrapParens(expression);return isBinaryOperator2(candidate,"or")?[...collectTopLevelOrTerms2(candidate.left),...collectTopLevelOrTerms2(candidate.right)]:[expression]},getGuardedParameterName2=expression=>{let candidate=unwrapParens(expression);if(!isBinaryOperator2(candidate,"is")||!(candidate.left instanceof ParameterExpression))return null;let right=unwrapParens(candidate.right);return right instanceof LiteralValue&&right.value===null||right instanceof RawString&&right.value.trim().toLowerCase()==="null"?candidate.left.name.value:null},buildOptionalScalarBranch=(column,parameterName,operator)=>{let guard=new BinaryExpression(new ParameterExpression(parameterName),"is",new LiteralValue(null)),predicate=new BinaryExpression(new ColumnReference(column.getNamespace()||null,column.column.name),operator,new ParameterExpression(parameterName));return new ParenExpression(new BinaryExpression(guard,"or",predicate))},buildOptionalExistsBranch=(parameterName,subquery,kind)=>{let guard=new BinaryExpression(new ParameterExpression(parameterName),"is",new LiteralValue(null)),existsExpression=new UnaryExpression("exists",new InlineQuery(subquery)),predicate=kind==="exists"?existsExpression:new UnaryExpression("not",existsExpression);return new ParenExpression(new BinaryExpression(guard,"or",predicate))},rebuildWhereWithoutTerm=(query,termToRemove)=>{if(!query.whereClause)return;let terms=collectTopLevelAndTerms2(query.whereClause.condition).filter(term=>term!==termToRemove);if(terms.length===0){query.whereClause=null;return}let rebuilt=terms[0];for(let index=1;index(formatter??=new SqlFormatter,formatter.format(component).formattedSql),enforceSubqueryConstraints=sql=>{if(!sql.trim())throw new Error("SSSQL EXISTS/NOT EXISTS scaffold query must not be empty.");if(sql.includes(";"))throw new Error("SSSQL EXISTS/NOT EXISTS scaffold query must not contain semicolons or multiple statements.");if(/\blateral\b/i.test(sql))throw new Error("LATERAL is not supported in SSSQL EXISTS/NOT EXISTS scaffold.")},substituteAnchorPlaceholders=(sql,formattedColumns)=>{let usedIndexes=new Set,replaced=sql.replace(/\$c(\d+)/g,(_,indexDigits)=>{let index=Number(indexDigits);if(!Number.isInteger(index))throw new Error(`Invalid placeholder '$c${indexDigits}' in SSSQL scaffold query.`);if(index<0||index>=formattedColumns.length)throw new Error(`Placeholder '$c${index}' references a missing SSSQL scaffold anchor column.`);return usedIndexes.add(index),formattedColumns[index]});if(formattedColumns.length===0)return replaced;for(let index=0;index{let meaningfulTerms=collectTopLevelOrTerms2(expression).filter(term=>getGuardedParameterName2(term)!==parameterName);if(meaningfulTerms.length!==1)return null;let predicate=unwrapParens(meaningfulTerms[0]);if(!(predicate instanceof BinaryExpression))return null;let left=unwrapParens(predicate.left),right=unwrapParens(predicate.right);if(left instanceof ColumnReference&&right instanceof ParameterExpression&&right.name.value===parameterName)try{return{operator:normalizeScalarOperator(predicate.operator.value),target:normalizeColumnReferenceText(left)}}catch{return null}if(right instanceof ColumnReference&&left instanceof ParameterExpression&&left.name.value===parameterName)try{return{operator:normalizeScalarOperator(predicate.operator.value),target:normalizeColumnReferenceText(right)}}catch{return null}return null},hasSelectQuery=value=>typeof value=="object"&&value!==null&&"selectQuery"in value,collectColumnReferencesDeep=value=>{let references=[],visited=new WeakSet,walk=candidate=>{if(!(!candidate||typeof candidate!="object")){if(candidate instanceof ColumnReference){references.push(candidate);return}if(!visited.has(candidate)){if(visited.add(candidate),Array.isArray(candidate)){for(let item of candidate)walk(item);return}for(let child of Object.values(candidate))walk(child)}}};return walk(value),references},getExistsBranchKind=(expression,parameterName)=>{let meaningfulTerms=collectTopLevelOrTerms2(expression).filter(term=>getGuardedParameterName2(term)!==parameterName);if(meaningfulTerms.length!==1)return null;let predicate=unwrapParens(meaningfulTerms[0]),isInlineQueryValue=value=>value instanceof InlineQuery||hasSelectQuery(value);if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="exists")return isInlineQueryValue(unwrapParens(predicate.expression))?"exists":null;if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="not exists")return isInlineQueryValue(unwrapParens(predicate.expression))?"not-exists":null;if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="not"&&unwrapParens(predicate.expression)instanceof UnaryExpression){let nested=unwrapParens(predicate.expression);if(nested.operator.value.trim().toLowerCase()==="exists"&&isInlineQueryValue(unwrapParens(nested.expression)))return"not-exists"}return null},getExistsPredicateDetails=(expression,parameterName)=>{let meaningfulTerms=collectTopLevelOrTerms2(expression).filter(term=>getGuardedParameterName2(term)!==parameterName);if(meaningfulTerms.length!==1)return null;let predicate=unwrapParens(meaningfulTerms[0]),isInlineQueryValue=value=>value instanceof InlineQuery||hasSelectQuery(value);if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="exists"){let candidate=unwrapParens(predicate.expression);return isInlineQueryValue(candidate)?{kind:"exists",subquery:candidate.selectQuery}:null}if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="not exists"){let candidate=unwrapParens(predicate.expression);return isInlineQueryValue(candidate)?{kind:"not-exists",subquery:candidate.selectQuery}:null}if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="not"&&unwrapParens(predicate.expression)instanceof UnaryExpression){let nested=unwrapParens(predicate.expression),candidate=unwrapParens(nested.expression);if(nested.operator.value.trim().toLowerCase()==="exists"&&isInlineQueryValue(candidate))return{kind:"not-exists",subquery:candidate.selectQuery}}return null},getBranchInfo=branch=>{let scalar=getScalarBranchDetails(branch.expression,branch.parameterName);if(scalar)return{parameterName:branch.parameterName,kind:"scalar",operator:scalar.operator,target:scalar.target,query:branch.query,expression:branch.expression,sql:formatSqlComponent(branch.expression)};let existsKind=getExistsBranchKind(branch.expression,branch.parameterName);return existsKind?{parameterName:branch.parameterName,kind:existsKind,query:branch.query,expression:branch.expression,sql:formatSqlComponent(branch.expression)}:{parameterName:branch.parameterName,kind:"expression",query:branch.query,expression:branch.expression,sql:formatSqlComponent(branch.expression)}},SSSQLFilterBuilder=class{constructor(tableColumnResolver){this.tableColumnResolver=tableColumnResolver;this.finder=new UpstreamSelectQueryFinder(this.tableColumnResolver)}list(query){let parsed=this.parseQuery(query);return collectSupportedOptionalConditionBranches(parsed).map(getBranchInfo)}scaffold(query,filters){let parsed=this.parseQuery(query);for(let[filterName,filterValue]of Object.entries(filters)){if(!isExplicitEqualityScaffoldValue(filterValue))throw new Error(`SSSQL scaffold only supports equality filters in v1. Use structured scaffold or refresh for pre-authored branches: '${filterName}'.`);this.scaffoldBranch(parsed,{target:filterName,parameterName:makeParameterName(filterName),operator:"="})}return parsed}scaffoldBranch(query,spec){let parsed=this.parseQuery(query);return spec.kind==="exists"||spec.kind==="not-exists"?(this.scaffoldExistsBranch(parsed,spec),parsed):(this.scaffoldScalarBranch(parsed,spec),parsed)}refresh(query,filters){let parsed=this.parseQuery(query);for(let[filterName,filterValue]of Object.entries(filters)){let parameterName=filterName,target=null,matches=collectSupportedOptionalConditionBranches(parsed).filter(branch=>branch.parameterName===parameterName);if(matches.length===0&&(target=this.resolveTarget(parsed,filterName),parameterName=target.parameterName,matches=collectSupportedOptionalConditionBranches(parsed).filter(branch=>branch.parameterName===parameterName)),matches.length===0){if(target||(target=this.resolveTarget(parsed,filterName),parameterName=target.parameterName),!isExplicitEqualityScaffoldValue(filterValue))throw new Error(`No existing SSSQL branch was found for '${filterName}', and v1 scaffold only supports equality filters.`);this.scaffoldScalarBranch(parsed,{target:filterName,parameterName:target.parameterName,operator:"="});continue}if(matches.length>1)throw new Error(`Multiple SSSQL branches matched parameter ':${parameterName}'. Refresh is ambiguous.`);let[match]=matches;if(!match)continue;let correlatedPlan=this.buildCorrelatedRefreshPlan(parsed,match);if(correlatedPlan){if(correlatedPlan.target.query===match.query)continue;this.rebaseMovedBranchByAlias(match.expression,correlatedPlan.sourceAlias,correlatedPlan.target.column),rebuildWhereWithoutTerm(match.query,match.expression),correlatedPlan.target.query.appendWhere(match.expression);continue}target||(target=this.resolveTarget(parsed,filterName)),match.query!==target.query&&(this.rebaseMovedBranch(match.expression,match.query,target.column),rebuildWhereWithoutTerm(match.query,match.expression),target.query.appendWhere(match.expression))}return parsed}remove(query,spec){let parsed=this.parseQuery(query),matches=this.findMatchingBranchInfos(parsed,spec);if(matches.length===0)return parsed;if(matches.length>1)throw new Error(`Multiple SSSQL branches matched parameter ':${spec.parameterName}'. Remove is ambiguous.`);let[match]=matches;return match&&rebuildWhereWithoutTerm(match.query,match.expression),parsed}removeAll(query){let parsed=this.parseQuery(query),matches=this.list(parsed);for(let match of matches)rebuildWhereWithoutTerm(match.query,match.expression);return parsed}parseQuery(query){return typeof query=="string"?SelectQueryParser.parse(query):query}findMatchingBranchInfos(root,spec){let normalizedOperator=spec.operator?normalizeScalarOperator(spec.operator):void 0,normalizedTarget=spec.target?normalizeIdentifier2(spec.target):void 0;return this.list(root).filter(branch=>!(branch.parameterName!==spec.parameterName||spec.kind&&branch.kind!==spec.kind||normalizedOperator&&branch.operator!==normalizedOperator||normalizedTarget&&(!branch.target||normalizeIdentifier2(branch.target)!==normalizedTarget)))}scaffoldScalarBranch(root,spec){let target=this.resolveTarget(root,spec.target),parameterName=spec.parameterName?.trim()||target.parameterName,operator=normalizeScalarOperator(spec.operator),branch=buildOptionalScalarBranch(target.column,parameterName,operator),branchSql=normalizeSql(formatSqlComponent(branch));this.list(root).find(existing=>existing.query===target.query&&normalizeSql(existing.sql)===branchSql)||target.query.appendWhere(branch)}scaffoldExistsBranch(root,spec){let parameterName=spec.parameterName.trim();if(!parameterName)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold requires parameterName.");if(spec.anchorColumns.length===0)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold requires at least one anchorColumn.");let anchorTargets=spec.anchorColumns.map(anchorColumn=>this.resolveTarget(root,anchorColumn)),targetQueries=[...new Set(anchorTargets.map(target=>target.query))];if(targetQueries.length!==1)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold anchor columns must resolve within one query scope.");let targetQuery=targetQueries[0],formattedColumns=anchorTargets.map(target=>formatSqlComponent(target.column)),substitutedSql=substituteAnchorPlaceholders(spec.query,formattedColumns).trim();enforceSubqueryConstraints(substitutedSql);let subquery=SelectQueryParser.parse(substitutedSql),parameterNames=new Set(ParameterCollector.collect(subquery).map(parameter=>parameter.name.value));if(parameterNames.size!==1||!parameterNames.has(parameterName))throw new Error(`SSSQL ${spec.kind.toUpperCase()} scaffold query must reference only parameter ':${parameterName}'.`);let branch=buildOptionalExistsBranch(parameterName,subquery,spec.kind),branchSql=normalizeSql(formatSqlComponent(branch));this.list(root).find(existing=>existing.query===targetQuery&&normalizeSql(existing.sql)===branchSql)||targetQuery.appendWhere(branch)}resolveTarget(root,filterName){let qualified=parseQualifiedFilterName(filterName),lookupColumn=qualified?.column??filterName.trim(),matches=[...new Set(this.finder.find(root,lookupColumn))].map(query=>this.resolveTargetInQuery(query,filterName,qualified)).filter(target=>target!==null);if(matches.length===0)throw new Error(`Could not resolve SSSQL filter target '${filterName}' in the current query graph.`);if(matches.length>1)throw new Error(`SSSQL filter target '${filterName}' is ambiguous across multiple query scopes.`);return matches[0]}resolveTargetInQuery(query,filterName,qualified){return qualified?this.resolveQualifiedTarget(query,qualified):this.resolveUnqualifiedTarget(query,filterName)}resolveQualifiedTarget(query,filterName){let alias=this.findAliasForTable(query,filterName.table);if(!alias)return null;let matches=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query).filter(entry=>entry.value instanceof ColumnReference).filter(entry=>normalizeColumnReferenceKey(entry.value)===`${normalizeIdentifier2(alias)}.${normalizeIdentifier2(filterName.column)}`);if(matches.length===0)return null;if(matches.length>1)throw new Error(`SSSQL scaffold target '${filterName.table}.${filterName.column}' resolved to multiple columns.`);return{query,column:matches[0].value,parameterName:makeParameterName(`${filterName.table}.${filterName.column}`)}}resolveUnqualifiedTarget(query,filterName){let matches=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query).filter(entry=>entry.value instanceof ColumnReference).filter(entry=>normalizeIdentifier2(entry.name)===normalizeIdentifier2(filterName));if(matches.length===0)return null;if(matches.length>1)throw new Error(`SSSQL scaffold target '${filterName}' is ambiguous. Use a qualified table.column reference.`);return{query,column:matches[0].value,parameterName:makeParameterName(filterName)}}findAliasForTable(query,tableName){let normalizedTable=normalizeIdentifier2(tableName),matchingAliases=(query.fromClause?.getSources()??[]).map(source=>this.resolveAliasForSource(source,normalizedTable)).filter(alias=>alias!==null);if(matchingAliases.length===0)return null;if(matchingAliases.length>1)throw new Error(`SSSQL scaffold target table '${tableName}' is ambiguous in the selected query scope.`);return matchingAliases[0]}resolveAliasForSource(source,normalizedTable){if(!(source.datasource instanceof TableSource))return null;let sourceName=normalizeIdentifier2(source.datasource.getSourceName()),shortName=normalizeIdentifier2(source.datasource.table.name);return sourceName!==normalizedTable&&shortName!==normalizedTable?null:source.getAliasName()??source.datasource.table.name}buildCorrelatedRefreshPlan(root,branch){let details=getExistsPredicateDetails(branch.expression,branch.parameterName);if(!details)return null;let sourceAliases=this.collectSourceAliases(branch.query),candidatesByKey=new Map;for(let reference of new ColumnReferenceCollector().collect(details.subquery)){let namespace=normalizeIdentifier2(reference.getNamespace());if(!namespace||!sourceAliases.has(namespace))continue;let column=normalizeIdentifier2(reference.column.name),key=`${namespace}.${column}`;candidatesByKey.has(key)||candidatesByKey.set(key,{namespace,column})}let candidates=[...candidatesByKey.values()];if(candidates.length===0)throw new Error(`SSSQL refresh could not infer a correlated anchor for ':${branch.parameterName}'.`);if(candidates.length>1){let listed=candidates.map(candidate=>`${candidate.namespace}.${candidate.column}`).join(", ");throw new Error(`SSSQL refresh found multiple correlated anchor candidates for ':${branch.parameterName}' (${listed}).`)}let[anchor]=candidates;if(!anchor)throw new Error(`SSSQL refresh could not infer a correlated anchor for ':${branch.parameterName}'.`);return{target:this.resolveCorrelatedAnchorTarget(root,branch.query,anchor,branch.parameterName),sourceAlias:anchor.namespace}}collectSourceAliases(query){let aliases=new Set;for(let source of query.fromClause?.getSources()??[]){let sourceAlias=this.getSourceAlias(source);sourceAlias&&aliases.add(sourceAlias)}return aliases}resolveCorrelatedAnchorTarget(root,sourceQuery,anchor,parameterName){let sourceExpression=this.findSourceExpressionByAlias(sourceQuery,anchor.namespace,parameterName),upstreamQuery=this.resolveSourceExpressionToUpstreamQuery(root,sourceExpression,parameterName);return upstreamQuery?this.resolveAnchorTargetInQuery(upstreamQuery,anchor,parameterName):{query:sourceQuery,column:new ColumnReference(anchor.namespace,anchor.column),parameterName}}findSourceExpressionByAlias(query,alias,parameterName){let matches=(query.fromClause?.getSources()??[]).filter(source=>this.getSourceAlias(source)===alias);if(matches.length===0)throw new Error(`SSSQL refresh could not resolve correlated alias '${alias}' for ':${parameterName}'.`);if(matches.length>1)throw new Error(`SSSQL refresh found multiple correlated sources for alias '${alias}' and ':${parameterName}'.`);return matches[0]}resolveSourceExpressionToUpstreamQuery(root,source,parameterName){if(source.datasource instanceof SubQuerySource){if(source.datasource.query instanceof SimpleSelectQuery)return source.datasource.query;throw new Error(`SSSQL refresh requires a simple query anchor for ':${parameterName}'.`)}if(!(source.datasource instanceof TableSource))return null;let cteName=normalizeIdentifier2(source.datasource.table.name),cteMatches=new CTECollector().collect(root).filter(cte2=>normalizeIdentifier2(cte2.getSourceAliasName())===cteName);if(cteMatches.length===0)return null;if(cteMatches.length>1)throw new Error(`SSSQL refresh found multiple CTE anchors for ':${parameterName}' (${source.datasource.table.name}).`);let[cte]=cteMatches;if(!cte)return null;let cteQuery=cte.query;if(!(cteQuery instanceof SimpleSelectQuery))throw new Error(`SSSQL refresh requires a simple CTE anchor for ':${parameterName}'.`);return cteQuery}resolveAnchorTargetInQuery(query,anchor,parameterName){let matches=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query).filter(entry=>entry.value instanceof ColumnReference).filter(entry=>normalizeIdentifier2(entry.name)===anchor.column);if(matches.length===0)throw new Error(`SSSQL refresh could not resolve correlated anchor column '${anchor.column}' for ':${parameterName}'.`);if(matches.length>1)throw new Error(`SSSQL refresh found multiple correlated anchor columns '${anchor.column}' for ':${parameterName}'.`);return{query,column:matches[0].value,parameterName}}getSourceAlias(source){let explicitAlias=source.getAliasName();return explicitAlias?normalizeIdentifier2(explicitAlias):source.datasource instanceof TableSource?normalizeIdentifier2(source.datasource.table.name):null}rebaseMovedBranch(expression,sourceQuery,targetColumn){let targetNamespace=targetColumn.qualifiedName.namespaces?targetColumn.qualifiedName.namespaces.map(namespace=>namespace.name):null,targetColumnName=normalizeIdentifier2(targetColumn.column.name),sourceAliases=new Set(collectColumnReferencesDeep(expression).filter(reference=>normalizeIdentifier2(reference.column.name)===targetColumnName).map(reference=>normalizeIdentifier2(reference.getNamespace())).filter(namespace=>namespace.length>0));if(sourceAliases.size===0)return;if(sourceAliases.size>1){let aliases=[...sourceAliases].join(", ");throw new Error(`SSSQL refresh cannot safely rebase '${targetColumn.column.name}' across multiple aliases (${aliases}).`)}let[sourceAlias]=[...sourceAliases];if(new Set((sourceQuery.fromClause?.getSources()??[]).map(source=>source.getAliasName()).filter(alias=>typeof alias=="string").map(alias=>normalizeIdentifier2(alias))).has(sourceAlias))for(let reference of collectColumnReferencesDeep(expression))normalizeIdentifier2(reference.getNamespace())===sourceAlias&&(reference.qualifiedName.namespaces=targetNamespace?.map(namespace=>new IdentifierString(namespace))??null)}rebaseMovedBranchByAlias(expression,sourceAlias,targetColumn){let normalizedSourceAlias=normalizeIdentifier2(sourceAlias);if(!normalizedSourceAlias)return;let targetNamespace=targetColumn.qualifiedName.namespaces?targetColumn.qualifiedName.namespaces.map(namespace=>namespace.name):null;for(let reference of collectColumnReferencesDeep(expression))normalizeIdentifier2(reference.getNamespace())===normalizedSourceAlias&&(reference.qualifiedName.namespaces=targetNamespace?.map(namespace=>new IdentifierString(namespace))??null)}},scaffoldSssqlQuery=(sqlContent,filters)=>({query:new SSSQLFilterBuilder().scaffold(sqlContent,filters)}),refreshSssqlQuery=(sqlContent,filters)=>({query:new SSSQLFilterBuilder().refresh(sqlContent,filters)});var BaseDataFlowNode=class{constructor(id,label,type,shape,details){this.id=id;this.label=label;this.type=type;this.shape=shape;this.details=details}},DataSourceNode=class _DataSourceNode extends BaseDataFlowNode{constructor(id,label,type){super(id,label,type,type==="subquery"?"hexagon":"cylinder");this.annotations=new Set}addAnnotation(annotation){this.annotations.add(annotation)}hasAnnotation(annotation){return this.annotations.has(annotation)}getMermaidRepresentation(){return this.shape==="hexagon"?`${this.id}{{${this.label}}}`:`${this.id}[(${this.label})]`}static createTable(tableName){return new _DataSourceNode(`table_${tableName}`,tableName,"table")}static createCTE(cteName){return new _DataSourceNode(`cte_${cteName}`,`CTE:${cteName}`,"cte")}static createSubquery(alias){return new _DataSourceNode(`subquery_${alias}`,`SubQuery:${alias}`,"subquery")}},ProcessNode=class _ProcessNode extends BaseDataFlowNode{constructor(id,operation,context=""){let nodeId=context?`${context}_${operation.toLowerCase().replace(/\s+/g,"_")}`:operation.toLowerCase().replace(/\s+/g,"_");super(nodeId,operation,"process","hexagon")}getMermaidRepresentation(){return`${this.id}{{${this.label}}}`}static createWhere(context){return new _ProcessNode(`${context}_where`,"WHERE",context)}static createGroupBy(context){return new _ProcessNode(`${context}_group_by`,"GROUP BY",context)}static createHaving(context){return new _ProcessNode(`${context}_having`,"HAVING",context)}static createSelect(context){return new _ProcessNode(`${context}_select`,"SELECT",context)}static createOrderBy(context){return new _ProcessNode(`${context}_order_by`,"ORDER BY",context)}static createLimit(context,hasOffset=!1){let label=hasOffset?"LIMIT/OFFSET":"LIMIT";return new _ProcessNode(`${context}_limit`,label,context)}},OperationNode=class _OperationNode extends BaseDataFlowNode{constructor(id,operation,shape="diamond"){super(id,operation,"operation",shape)}getMermaidRepresentation(){switch(this.shape){case"rounded":return`${this.id}(${this.label})`;case"rectangle":return`${this.id}[${this.label}]`;case"hexagon":return`${this.id}{{${this.label}}}`;case"stadium":return`${this.id}([${this.label}])`;case"diamond":default:return`${this.id}{${this.label}}`}}static createJoin(joinId,joinType){let label,normalizedType=joinType.trim().toLowerCase();return normalizedType==="join"?label="INNER JOIN":normalizedType.endsWith(" join")?label=normalizedType.toUpperCase():label=normalizedType.toUpperCase()+" JOIN",new _OperationNode(`join_${joinId}`,label,"rectangle")}static createUnion(unionId,unionType="UNION ALL"){return new _OperationNode(`${unionType.toLowerCase().replace(/\s+/g,"_")}_${unionId}`,unionType.toUpperCase(),"rectangle")}static createSetOperation(operationId,operation){let normalizedOp=operation.toUpperCase(),id=`${normalizedOp.toLowerCase().replace(/\s+/g,"_")}_${operationId}`;return new _OperationNode(id,normalizedOp,"rectangle")}},OutputNode=class extends BaseDataFlowNode{constructor(context="main"){let label=context==="main"?"Final Result":`${context} Result`;super(`${context}_output`,label,"output","stadium")}getMermaidRepresentation(){return`${this.id}([${this.label}])`}};var DataFlowConnection=class _DataFlowConnection{constructor(from,to,label){this.from=from;this.to=to;this.label=label}getMermaidRepresentation(){let arrow=this.label?` -->|${this.label}| `:" --> ";return`${this.from}${arrow}${this.to}`}static create(from,to,label){return new _DataFlowConnection(from,to,label)}static createWithNullability(from,to,isNullable){let label=isNullable?"NULLABLE":"NOT NULL";return new _DataFlowConnection(from,to,label)}},DataFlowEdgeCollection=class{constructor(){this.edges=[];this.connectionSet=new Set}add(edge){let key=`${edge.from}->${edge.to}`;this.connectionSet.has(key)||(this.edges.push(edge),this.connectionSet.add(key))}addConnection(from,to,label){this.add(DataFlowConnection.create(from,to,label))}addJoinConnection(from,to,isNullable){this.add(DataFlowConnection.createWithNullability(from,to,isNullable))}hasConnection(from,to){return this.connectionSet.has(`${from}->${to}`)}getAll(){return[...this.edges]}getMermaidRepresentation(){return this.edges.map(edge=>edge.getMermaidRepresentation()).join(` +`);for(let i=1;i0||leadingTabs>0)&&(indentLines++,totalIndentSize+=leadingSpaces+leadingTabs*4,spaceCount+=leadingSpaces,tabCount+=leadingTabs)}}lexeme.inlineComments&&(totalComments+=lexeme.inlineComments.length)}let indentationStyle="none";return spaceCount>0&&tabCount>0?indentationStyle="mixed":spaceCount>0?indentationStyle="spaces":tabCount>0&&(indentationStyle="tabs"),{totalWhitespace,totalComments,indentationStyle,averageIndentSize:indentLines>0?totalIndentSize/indentLines:0}}validateFormattingLexemes(lexemes){let issues=[];for(let i=0;i=lexeme.position.endPosition&&issues.push(`Lexeme ${i} has invalid position range`)}return{isValid:issues.length===0,issues}}};var SelectResultSelectConverter=class{static toSelectQuery(query,options){let fixtureTables=options?.fixtureTables??[];if(fixtureTables.length===0)return query;let sources=new TableSourceCollector(!1).collect(query),referencedTables=new Set;sources.forEach(s=>referencedTables.add(s.getSourceName().toLowerCase()));let neededFixtures=fixtureTables.filter(f=>referencedTables.has(f.tableName.toLowerCase()));if(neededFixtures.length===0)return query;let fixtureCtes=FixtureCteBuilder.buildFixtures(neededFixtures);return query instanceof SimpleSelectQuery&&(query.withClause?query.withClause.tables=[...fixtureCtes,...query.withClause.tables]:query.appendWith(fixtureCtes)),query}};var SimulatedSelectConverter=class{static convert(ast,options){if(ast instanceof InsertQuery)return InsertResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof UpdateQuery)return UpdateResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof DeleteQuery)return DeleteResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof MergeQuery)return MergeResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof SimpleSelectQuery||ast instanceof BinarySelectQuery||ast instanceof ValuesQuery)return SelectResultSelectConverter.toSelectQuery(ast,options);if(ast instanceof CreateTableQuery){if(ast.isTemporary&&ast.asSelectQuery){let processedSelect=SelectResultSelectConverter.toSelectQuery(ast.asSelectQuery,options);return ast.asSelectQuery=processedSelect,ast}return null}return null}};var DDLGeneralizer=class{static generalize(ast){let result=[];for(let component of ast)if(component instanceof CreateTableQuery){let{createTable,alterTables}=this.splitCreateTable(component);result.push(createTable),result.push(...alterTables)}else result.push(component);return result}static splitCreateTable(query){let newColumns=[],alterTables=[],tableQualifiedName=new QualifiedName(query.namespaces||[],query.tableName.name);for(let col of query.columns){let newConstraints=[];for(let constraint of col.constraints)if(["primary-key","unique","references","check"].includes(constraint.kind)){let tableConstraint=this.columnToTableConstraint(col.name,constraint);alterTables.push(new AlterTableStatement({table:tableQualifiedName,actions:[new AlterTableAddConstraint({constraint:tableConstraint})]}))}else newConstraints.push(constraint);newColumns.push(new TableColumnDefinition({name:col.name,dataType:col.dataType,constraints:newConstraints}))}if(query.tableConstraints)for(let constraint of query.tableConstraints)alterTables.push(new AlterTableStatement({table:tableQualifiedName,actions:[new AlterTableAddConstraint({constraint})]}));return{createTable:new CreateTableQuery({tableName:query.tableName.name,namespaces:query.namespaces,columns:newColumns,ifNotExists:query.ifNotExists,isTemporary:query.isTemporary,tableOptions:query.tableOptions,asSelectQuery:query.asSelectQuery,withDataOption:query.withDataOption,tableConstraints:[]}),alterTables}}static columnToTableConstraint(columnName,constraint){let baseParams={constraintName:constraint.constraintName,deferrable:constraint.reference?.deferrable,initially:constraint.reference?.initially};switch(constraint.kind){case"primary-key":return new TableConstraintDefinition({kind:"primary-key",columns:[columnName],...baseParams});case"unique":return new TableConstraintDefinition({kind:"unique",columns:[columnName],...baseParams});case"references":return new TableConstraintDefinition({kind:"foreign-key",columns:[columnName],reference:constraint.reference,...baseParams});case"check":return new TableConstraintDefinition({kind:"check",checkExpression:constraint.checkExpression,...baseParams});default:throw new Error(`Unsupported constraint kind for generalization: ${constraint.kind}`)}}};var DDLDiffGenerator=class{static generateDiff(currentSql,expectedSql,options={}){let currentAst=this.parseAndGeneralize(currentSql),expectedAst=this.parseAndGeneralize(expectedSql),currentSchema=this.buildSchema(currentAst),expectedSchema=this.buildSchema(expectedAst),diffAsts=[];for(let[tableName,expectedTable]of expectedSchema.tables){let currentTable=currentSchema.tables.get(tableName);if(currentTable)this.compareColumns(currentTable,expectedTable,diffAsts,options),this.compareConstraints(currentTable,expectedTable,diffAsts,options),this.compareIndexes(currentTable,expectedTable,diffAsts,options);else{let columns=Array.from(expectedTable.columns.values()).map(c=>c.definition),tableNameStr=expectedTable.qualifiedName.name instanceof RawString?expectedTable.qualifiedName.name.value:expectedTable.qualifiedName.name.name,namespaces=expectedTable.qualifiedName.namespaces?expectedTable.qualifiedName.namespaces.map(ns=>ns.name):null,createTable=new CreateTableQuery({tableName:tableNameStr,namespaces,columns});diffAsts.push(createTable);for(let constraint of expectedTable.constraints)diffAsts.push(new AlterTableStatement({table:expectedTable.qualifiedName,actions:[new AlterTableAddConstraint({constraint:constraint.definition})]}));for(let index of expectedTable.indexes)diffAsts.push(index.definition)}}if(options.dropTables)for(let[tableName,currentTable]of currentSchema.tables)expectedSchema.tables.has(tableName)||diffAsts.push(new DropTableStatement({tables:[currentTable.qualifiedName],ifExists:!1}));let formatter2=new SqlFormatter(options.formatOptions||{keywordCase:"upper"});return diffAsts.map(ast=>formatter2.format(ast).formattedSql+";")}static parseAndGeneralize(sql){let split=MultiQuerySplitter.split(sql),asts=[];for(let q of split.queries)if(!q.isEmpty)try{let ast=SqlParser.parse(q.sql);asts.push(ast)}catch(e){console.warn("Failed to parse SQL for diff:",q.sql,e)}return DDLGeneralizer.generalize(asts)}static buildSchema(asts){let tables=new Map,formatter2=new SqlFormatter({keywordCase:"none"});for(let ast of asts)if(ast instanceof CreateTableQuery){let qName=new QualifiedName(ast.namespaces||[],ast.tableName),key=this.getQualifiedNameKey(qName),tableModel={name:key,qualifiedName:qName,columns:new Map,constraints:[],indexes:[]};for(let col of ast.columns)tableModel.columns.set(col.name.name,{name:col.name.name,definition:col});tables.set(key,tableModel)}else if(ast instanceof AlterTableStatement){let key=this.getQualifiedNameKey(ast.table),tableModel=tables.get(key);if(tableModel)for(let action of ast.actions)if(action instanceof AlterTableAddConstraint){let formatted=formatter2.format(action.constraint).formattedSql;tableModel.constraints.push({name:action.constraint.constraintName?.name,kind:action.constraint.kind,definition:action.constraint,formatted})}else action instanceof AlterTableAddColumn&&tableModel.columns.set(action.column.name.name,{name:action.column.name.name,definition:action.column})}else if(ast instanceof CreateIndexStatement){let key=this.getQualifiedNameKey(ast.tableName),tableModel=tables.get(key);if(tableModel){let formatted=formatter2.format(ast).formattedSql;tableModel.indexes.push({name:ast.indexName.toString(),definition:ast,formatted})}}return{tables}}static compareColumns(current,expected,diffs,options){for(let[name,col]of expected.columns)current.columns.has(name)||diffs.push(new AlterTableStatement({table:expected.qualifiedName,actions:[new AlterTableAddColumn({column:col.definition})]}));if(options.dropColumns)for(let[name,col]of current.columns)expected.columns.has(name)||diffs.push(new AlterTableStatement({table:expected.qualifiedName,actions:[new AlterTableDropColumn({columnName:col.definition.name})]}))}static compareConstraints(current,expected,diffs,options){let formatter2=new SqlFormatter({keywordCase:"none"}),getConstraintSignature=c=>options.checkConstraintNames?c.kind==="primary-key"?c.formatted.replace(/^constraint\s+("[^"]+"|[^\s]+)\s+/i,"").trim():c.name||c.formatted:c.formatted.replace(/^constraint\s+("[^"]+"|[^\s]+)\s+/i,"").trim(),currentSignatures=new Set(current.constraints.map(getConstraintSignature));for(let expectedC of expected.constraints){let sig=getConstraintSignature(expectedC);currentSignatures.has(sig)||diffs.push(new AlterTableStatement({table:expected.qualifiedName,actions:[new AlterTableAddConstraint({constraint:expectedC.definition})]}))}if(options.dropConstraints){let expectedSignatures=new Set(expected.constraints.map(getConstraintSignature));for(let currentC of current.constraints){let sig=getConstraintSignature(currentC);expectedSignatures.has(sig)||(currentC.name?diffs.push(new AlterTableStatement({table:expected.qualifiedName,actions:[new AlterTableDropConstraint({constraintName:new IdentifierString(currentC.name)})]})):console.warn("Cannot drop unnamed constraint:",currentC.formatted))}}}static compareIndexes(current,expected,diffs,options){let getIndexSignature=idx=>{if(options.checkConstraintNames)return idx.name;let def=idx.definition,parts=[];parts.push(def.tableName.toString()),def.unique&&parts.push("UNIQUE"),def.usingMethod&&parts.push(`USING:${def.usingMethod.toString()}`);let columnSigs=def.columns.map(col=>{let expr=col.expression.toString(),sort=col.sortOrder||"",nulls=col.nullsOrder||"";return`${expr}${sort}${nulls}`});return parts.push(`COLS:${columnSigs.join(",")}`),def.include&&def.include.length>0&&parts.push(`INCLUDE:${def.include.map(i=>i.toString()).join(",")}`),def.where&&parts.push(`WHERE:${def.where.toString()}`),parts.join("|")},currentSignatures=new Set(current.indexes.map(getIndexSignature));for(let expectedIdx of expected.indexes){let sig=getIndexSignature(expectedIdx);currentSignatures.has(sig)||diffs.push(expectedIdx.definition)}if(options.checkConstraintNames||options.dropIndexes){let expectedSignatures=new Set(expected.indexes.map(getIndexSignature));for(let currentIdx of current.indexes){let sig=getIndexSignature(currentIdx);expectedSignatures.has(sig)||diffs.push(new DropIndexStatement({indexNames:[currentIdx.definition.indexName],ifExists:!1}))}}}static getQualifiedNameKey(qName){return qName.toString()}};var ParameterDetector=class{static extractParameterNames(query){return ParameterCollector.collect(query).map(p=>p.name.value)}static hasParameter(query,parameterName){return this.extractParameterNames(query).includes(parameterName)}static separateFilters(query,filter){let hardcodedParamNames=this.extractParameterNames(query),hardcodedParams={},dynamicFilters={};for(let[key,value]of Object.entries(filter))hardcodedParamNames.includes(key)?hardcodedParams[key]=value:dynamicFilters[key]=value;return{hardcodedParams,dynamicFilters}}};var FilterableItem=class{constructor(name,type,tableName){this.name=name;this.type=type;this.tableName=tableName}},FilterableItemCollector=class{constructor(tableColumnResolver,options){this.tableColumnResolver=tableColumnResolver,this.options={qualified:!1,upstream:!0,...options}}collect(query){let items=[],columnItems=this.collectColumns(query);items.push(...columnItems);let parameterItems=this.collectParameters(query);return items.push(...parameterItems),this.removeDuplicates(items)}collectColumns(query){let items=[];try{let columns=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:this.options.upstream}).collect(query);for(let column of columns){let tableName,realTableName;if(column.value&&typeof column.value.getNamespace=="function"){let namespace=column.value.getNamespace();namespace&&namespace.trim()!==""&&(tableName=namespace,this.options.qualified&&(realTableName=this.getRealTableName(query,namespace)))}tableName||(tableName=this.inferTableNameFromQuery(query),tableName&&this.options.qualified&&(realTableName=tableName));let columnName=column.name;this.options.qualified&&(realTableName||tableName)&&(columnName=`${realTableName||tableName}.${column.name}`),items.push(new FilterableItem(columnName,"column",tableName))}}catch(error){console.warn("Failed to collect columns with SelectableColumnCollector, using fallback:",error);try{let schemas=new SchemaCollector(this.tableColumnResolver,!0).collect(query);for(let schema of schemas)for(let columnName of schema.columns){let finalColumnName=columnName;this.options.qualified&&(finalColumnName=`${schema.name}.${columnName}`),items.push(new FilterableItem(finalColumnName,"column",schema.name))}}catch(fallbackError){console.warn("Failed to collect columns with both approaches:",error,fallbackError)}}return items}inferTableNameFromQuery(query){if(query instanceof SimpleSelectQuery&&query.fromClause&&query.fromClause.source){let datasource=query.fromClause.source.datasource;if(datasource&&typeof datasource.table=="object"){let table=datasource.table;if(table&&typeof table.name=="string")return table.name}}}getRealTableName(query,aliasOrName){try{let simpleQuery=query.type==="WITH"?query.toSimpleQuery():query;if(simpleQuery instanceof SimpleSelectQuery&&simpleQuery.fromClause){if(simpleQuery.fromClause.source?.datasource){let mainSource=simpleQuery.fromClause.source,realName=this.extractRealTableName(mainSource,aliasOrName);if(realName)return realName}let fromClause=simpleQuery.fromClause;if(fromClause.joinClauses&&Array.isArray(fromClause.joinClauses)){for(let joinClause of fromClause.joinClauses)if(joinClause.source?.datasource){let realName=this.extractRealTableName(joinClause.source,aliasOrName);if(realName)return realName}}}}catch(error){console.warn("Error resolving real table name:",error)}return aliasOrName}extractRealTableName(source,aliasOrName){try{let datasource=source.datasource;if(!datasource)return;let alias=source.alias||source.aliasExpression?.table?.name,realTableName=datasource.table?.name;if(alias===aliasOrName&&realTableName||!alias&&realTableName===aliasOrName)return realTableName}catch{}}collectParameters(query){let items=[];try{let parameterNames=ParameterDetector.extractParameterNames(query);for(let paramName of parameterNames)items.push(new FilterableItem(paramName,"parameter"))}catch(error){console.warn("Failed to collect parameters:",error)}return items}removeDuplicates(items){let seen=new Set,result=[];for(let item of items){let key=`${item.type}:${item.name}:${item.tableName||"none"}`;seen.has(key)||(seen.add(key),result.push(item))}return result.sort((a,b)=>{if(a.type!==b.type)return a.type==="column"?-1:1;if(a.type==="column"){let tableA=a.tableName||"",tableB=b.tableName||"";if(tableA!==tableB)return tableA.localeCompare(tableB)}return a.name.localeCompare(b.name)})}};var SqlSortInjector=class{constructor(tableColumnResolver){this.tableColumnResolver=tableColumnResolver}static removeOrderBy(query){if(typeof query=="string"&&(query=SelectQueryParser.parse(query)),!(query instanceof SimpleSelectQuery))throw new Error("Complex queries are not supported for ORDER BY removal");return new SimpleSelectQuery({withClause:query.withClause,selectClause:query.selectClause,fromClause:query.fromClause,whereClause:query.whereClause,groupByClause:query.groupByClause,havingClause:query.havingClause,orderByClause:null,windowClause:query.windowClause,limitClause:query.limitClause,offsetClause:query.offsetClause,fetchClause:query.fetchClause,forClause:query.forClause})}inject(query,sortConditions){if(typeof query=="string"&&(query=SelectQueryParser.parse(query)),!(query instanceof SimpleSelectQuery))throw new Error("Complex queries are not supported for sorting");let availableColumns=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query);for(let columnName of Object.keys(sortConditions))if(!availableColumns.find(item=>item.name===columnName))throw new Error(`Column or alias '${columnName}' not found in current query`);let newOrderByItems=[];for(let[columnName,condition]of Object.entries(sortConditions)){let columnEntry=availableColumns.find(item=>item.name===columnName);if(!columnEntry)continue;let columnRef=columnEntry.value;this.validateSortCondition(columnName,condition);let sortDirection;condition.desc?sortDirection="desc":sortDirection="asc";let nullsPosition=null;condition.nullsFirst?nullsPosition="first":condition.nullsLast&&(nullsPosition="last");let orderByItem=new OrderByItem(columnRef,sortDirection,nullsPosition);newOrderByItems.push(orderByItem)}let finalOrderByItems=[];query.orderByClause?finalOrderByItems=[...query.orderByClause.order,...newOrderByItems]:finalOrderByItems=newOrderByItems;let newOrderByClause=finalOrderByItems.length>0?new OrderByClause(finalOrderByItems):null;return new SimpleSelectQuery({withClause:query.withClause,selectClause:query.selectClause,fromClause:query.fromClause,whereClause:query.whereClause,groupByClause:query.groupByClause,havingClause:query.havingClause,orderByClause:newOrderByClause,windowClause:query.windowClause,limitClause:query.limitClause,offsetClause:query.offsetClause,fetchClause:query.fetchClause,forClause:query.forClause})}validateSortCondition(columnName,condition){if(condition.asc&&condition.desc)throw new Error(`Conflicting sort directions for column '${columnName}': both asc and desc specified`);if(condition.nullsFirst&&condition.nullsLast)throw new Error(`Conflicting nulls positions for column '${columnName}': both nullsFirst and nullsLast specified`);if(!condition.asc&&!condition.desc&&!condition.nullsFirst&&!condition.nullsLast)throw new Error(`Empty sort condition for column '${columnName}': at least one sort option must be specified`)}};var SqlPaginationInjector=class{inject(query,pagination){if(this.validatePaginationOptions(pagination),typeof query=="string"&&(query=SelectQueryParser.parse(query)),!(query instanceof SimpleSelectQuery))throw new Error("Complex queries are not supported for pagination");if(query.limitClause||query.offsetClause)throw new Error("Query already contains LIMIT or OFFSET clause. Use removePagination() first if you want to override existing pagination.");let offset=(pagination.page-1)*pagination.pageSize,limitClause=new LimitClause(new ParameterExpression("paging_limit",pagination.pageSize)),offsetClause=new OffsetClause(new ParameterExpression("paging_offset",offset));return new SimpleSelectQuery({withClause:query.withClause,selectClause:query.selectClause,fromClause:query.fromClause,whereClause:query.whereClause,groupByClause:query.groupByClause,havingClause:query.havingClause,orderByClause:query.orderByClause,windowClause:query.windowClause,limitClause,offsetClause,fetchClause:query.fetchClause,forClause:query.forClause})}static removePagination(query){if(typeof query=="string"&&(query=SelectQueryParser.parse(query)),!(query instanceof SimpleSelectQuery))throw new Error("Complex queries are not supported for pagination removal");return new SimpleSelectQuery({withClause:query.withClause,selectClause:query.selectClause,fromClause:query.fromClause,whereClause:query.whereClause,groupByClause:query.groupByClause,havingClause:query.havingClause,orderByClause:query.orderByClause,windowClause:query.windowClause,limitClause:null,offsetClause:null,fetchClause:query.fetchClause,forClause:query.forClause})}validatePaginationOptions(pagination){if(!pagination)throw new Error("Pagination options are required");if(typeof pagination.page!="number"||pagination.page<1)throw new Error("Page number must be a positive integer (1 or greater)");if(typeof pagination.pageSize!="number"||pagination.pageSize<1)throw new Error("Page size must be a positive integer (1 or greater)");if(pagination.pageSize>1e3)throw new Error("Page size cannot exceed 1000 items")}};var SqlParameterBinder=class{constructor(options={}){this.options={requireAllParameters:!0,...options}}bind(query,parameterValues){let modifiedQuery=query,existingParams=ParameterDetector.extractParameterNames(modifiedQuery);if(this.options.requireAllParameters){let missingParams=existingParams.filter(paramName=>!(paramName in parameterValues)||parameterValues[paramName]===void 0);if(missingParams.length>0)throw new Error(`Missing values for required parameters: ${missingParams.join(", ")}`)}for(let[paramName,value]of Object.entries(parameterValues))if(existingParams.includes(paramName))try{ParameterHelper.set(modifiedQuery,paramName,value)}catch(error){throw new Error(`Failed to bind parameter '${paramName}': ${error instanceof Error?error.message:"Unknown error"}`)}return modifiedQuery}bindToSimpleQuery(query,parameterValues){return this.bind(query,parameterValues)}};var NAMESPACE_SEPARATOR="|",normalizeIdentifier=input=>{let value=input?.trim()??"";return value===""?"":value.toLowerCase()},normalizeColumnSetKey=columns=>columns.map(column=>normalizeIdentifier(column)).filter(Boolean).sort().join(NAMESPACE_SEPARATOR),buildSchemaMap=schemaInfo=>{let map=new Map;for(let table of schemaInfo){let normalizedName=normalizeIdentifier(table.name);if(!normalizedName)continue;let columnSet=new Set(table.columns.map(normalizeIdentifier).filter(Boolean)),uniqueSetKeys=new Set;for(let uniqueKey of table.uniqueKeys){let normalizedKey=normalizeColumnSetKey(uniqueKey);normalizedKey&&uniqueSetKeys.add(normalizedKey)}columnSet.size===0&&uniqueSetKeys.size===0||map.set(normalizedName,{columnSet,uniqueSetKeys})}return map},collectReferenceMetadata=query=>{let collector=new ColumnReferenceCollector,namespaceCounts=new Map,unqualifiedColumns=new Set;for(let ref of collector.collect(query)){let namespace=normalizeIdentifier(ref.getNamespace());if(namespace)namespaceCounts.set(namespace,(namespaceCounts.get(namespace)??0)+1);else{let column=normalizeIdentifier(ref.column.name);column&&unqualifiedColumns.add(column)}}let joinConditionCounts=new Map;if(query.fromClause?.joins)for(let join of query.fromClause.joins){let counts=new Map;if(join.condition&&join.condition instanceof JoinOnClause){let joinCollector=new ColumnReferenceCollector;for(let ref of joinCollector.collect(join.condition.condition)){let namespace=normalizeIdentifier(ref.getNamespace());namespace&&counts.set(namespace,(counts.get(namespace)??0)+1)}}joinConditionCounts.set(join,counts)}return{namespaceCounts,unqualifiedColumns,joinConditionCounts}},isLeftJoin=join=>join.joinType.value.toLowerCase().includes("left"),getJoinIdentifiers=join=>{let identifiers=new Set,alias=normalizeIdentifier(join.source.getAliasName());if(alias&&identifiers.add(alias),join.source.datasource instanceof TableSource){let rawName=join.source.datasource.getSourceName();rawName&&identifiers.add(normalizeIdentifier(rawName));let shortName=normalizeIdentifier(join.source.datasource.table.name);shortName&&identifiers.add(shortName)}return[...identifiers]},hasExternalReferences=(identifiers,metadata,join)=>{let local=metadata.joinConditionCounts.get(join)??new Map;for(let identifier of identifiers){let total=metadata.namespaceCounts.get(identifier)??0,localCount=local.get(identifier)??0;if(total-localCount>0)return!0}return!1},getJoinColumnInfo=(join,identifiers)=>{if(!(join.condition instanceof JoinOnClause))return null;let expression=join.condition.condition;if(!(expression instanceof BinaryExpression)||expression.operator.value.trim().toLowerCase()!=="=")return null;let resolveColumn=component=>component instanceof ColumnReference?component:null,leftRef=resolveColumn(expression.left),rightRef=resolveColumn(expression.right);if(!leftRef||!rightRef)return null;let normalizedLeftNamespace=normalizeIdentifier(leftRef.getNamespace()),normalizedRightNamespace=normalizeIdentifier(rightRef.getNamespace());return identifiers.has(normalizedLeftNamespace)?normalizeIdentifier(leftRef.column.name):identifiers.has(normalizedRightNamespace)?normalizeIdentifier(rightRef.column.name):null},shouldRemoveJoin=(join,schemaMap,metadata)=>{if(!isLeftJoin(join)||join.lateral||!(join.source.datasource instanceof TableSource))return!1;let candidates=[normalizeIdentifier(join.source.datasource.getSourceName()),normalizeIdentifier(join.source.datasource.table.name)].filter(Boolean),tableInfo;for(let candidate of candidates){let info=schemaMap.get(candidate);if(info){tableInfo=info;break}}if(!tableInfo)return!1;let identifiers=new Set(getJoinIdentifiers(join));if(identifiers.size===0||hasExternalReferences([...identifiers],metadata,join))return!1;let joinColumn=getJoinColumnInfo(join,identifiers);if(!joinColumn||metadata.unqualifiedColumns.has(joinColumn)||tableInfo.columnSet.size>0&&!tableInfo.columnSet.has(joinColumn))return!1;let uniqueKey=normalizeColumnSetKey([joinColumn]);return!!tableInfo.uniqueSetKeys.has(uniqueKey)},optimizeSimpleQuery=(query,schemaMap)=>{if(!query.fromClause?.joins?.length)return!1;let metadata=collectReferenceMetadata(query),retainedJoins=[],removed=!1;for(let join of query.fromClause.joins){if(shouldRemoveJoin(join,schemaMap,metadata)){removed=!0;continue}retainedJoins.push(join)}return query.fromClause.joins=retainedJoins.length>0?retainedJoins:null,removed},traverseSelectQuery=(query,schemaMap)=>{if(query instanceof SimpleSelectQuery)return optimizeSimpleQuery(query,schemaMap);if(query instanceof BinarySelectQuery){let leftChanged=traverseSelectQuery(query.left,schemaMap),rightChanged=traverseSelectQuery(query.right,schemaMap);return leftChanged||rightChanged}return!1},optimizeUnusedLeftJoinsOnce=(query,schemaMap)=>schemaMap.size===0?!1:traverseSelectQuery(query,schemaMap),optimizeUnusedLeftJoins=(query,schemaInfo)=>(optimizeUnusedLeftJoinsOnce(query,buildSchemaMap(schemaInfo)),query),optimizeUnusedLeftJoinsToFixedPoint=(query,schemaInfo)=>{let schemaMap=buildSchemaMap(schemaInfo),changed=!0;for(;changed;)changed=optimizeUnusedLeftJoinsOnce(query,schemaMap);return query},collectTableSourceNames=component=>{let collector=new CTETableReferenceCollector,names=new Set;for(let source of collector.collect(component)){let normalizedName=normalizeIdentifier(source.table.name);normalizedName&&names.add(normalizedName)}return names},isReferencedByOthers=(cteName,mainReferences,cteReferenceMap)=>{if(mainReferences.has(cteName))return!0;for(let[otherName,references]of cteReferenceMap)if(otherName!==cteName&&references.has(cteName))return!0;return!1},optimizeSimpleQueryCtes=query=>{let withClause=query.withClause;if(!withClause||withClause.recursive||withClause.tables.length===0)return!1;let mainReferences=collectTableSourceNames(query),cteReferenceMap=new Map;for(let table of withClause.tables){let normalizedName=normalizeIdentifier(table.aliasExpression.table.name);normalizedName&&cteReferenceMap.set(normalizedName,collectTableSourceNames(table.query))}let removableNames=[];for(let table of withClause.tables){let normalizedName=normalizeIdentifier(table.aliasExpression.table.name);if(!normalizedName)continue;let body=table.query;!(body instanceof SimpleSelectQuery)&&!(body instanceof BinarySelectQuery)||isReferencedByOthers(normalizedName,mainReferences,cteReferenceMap)||removableNames.push(normalizedName)}if(removableNames.length===0)return!1;for(let name of removableNames)query.removeCTE(name);return!0},optimizeCtesInSelectQuery=query=>{if(query instanceof SimpleSelectQuery)return optimizeSimpleQueryCtes(query);if(query instanceof BinarySelectQuery){let leftChanged=optimizeCtesInSelectQuery(query.left),rightChanged=optimizeCtesInSelectQuery(query.right);return leftChanged||rightChanged}return!1},optimizeUnusedCtesOnce=query=>optimizeCtesInSelectQuery(query),optimizeUnusedCtes=query=>(optimizeUnusedCtesOnce(query),query),optimizeUnusedCtesToFixedPoint=query=>{let changed=!0;for(;changed;)changed=optimizeUnusedCtesOnce(query);return query};var isBinaryOperator=(expression,operator)=>expression instanceof BinaryExpression&&expression.operator.value.trim().toLowerCase()===operator,unwrapSingleOuterParen=expression=>{let candidate=expression;for(;candidate instanceof ParenExpression;)candidate=candidate.expression;return candidate},collectTopLevelAndTerms=expression=>{let candidate=unwrapSingleOuterParen(expression);return isBinaryOperator(candidate,"and")?[...collectTopLevelAndTerms(candidate.left),...collectTopLevelAndTerms(candidate.right)]:[expression]},collectTopLevelOrTerms=expression=>{let candidate=unwrapSingleOuterParen(expression);return isBinaryOperator(candidate,"or")?[...collectTopLevelOrTerms(candidate.left),...collectTopLevelOrTerms(candidate.right)]:[expression]},isNullLiteral=expression=>expression instanceof LiteralValue&&expression.value===null||expression instanceof RawString&&expression.value.trim().toLowerCase()==="null",isTrueSentinel=expression=>{let candidate=unwrapSingleOuterParen(expression);return candidate instanceof LiteralValue?candidate.value===!0:isBinaryOperator(candidate,"=")?candidate.left instanceof LiteralValue&&candidate.right instanceof LiteralValue&&candidate.left.value===1&&candidate.right.value===1:!1},getGuardedParameterName=expression=>{let candidate=unwrapSingleOuterParen(expression);return!isBinaryOperator(candidate,"is")||!(candidate.left instanceof ParameterExpression)||!isNullLiteral(candidate.right)?null:candidate.left.name.value},getUniqueParameterNames=expression=>new Set(ParameterCollector.collect(expression).map(parameter=>parameter.name.value)),isSupportedMeaningfulBranch=(expression,parameterName)=>{let candidate=unwrapSingleOuterParen(expression);if(candidate instanceof ParameterExpression)return!1;let parameterNames=getUniqueParameterNames(candidate);return parameterNames.size!==1||!parameterNames.has(parameterName)?!1:!(candidate instanceof LiteralValue||candidate instanceof RawString)},isExplicitPruningTarget=(pruningParameters,parameterName)=>Object.prototype.hasOwnProperty.call(pruningParameters,parameterName),isKnownAbsentTarget=(pruningParameters,parameterName)=>{if(!isExplicitPruningTarget(pruningParameters,parameterName))return!1;let parameterValue=pruningParameters[parameterName];return parameterValue==null},shouldPruneOptionalBranch=(expression,pruningParameters)=>{let branch=getSupportedOptionalConditionBranch(expression);return branch!==null&&isKnownAbsentTarget(pruningParameters,branch.parameterName)},rebuildAndCondition=terms=>{if(terms.length===0)return null;let condition=terms[0];for(let index=1;index{if(!query.whereClause)return!1;let topLevelTerms=collectTopLevelAndTerms(query.whereClause.condition),retainedTerms=[],prunedAnyBranch=!1;for(let term of topLevelTerms){if(shouldPruneOptionalBranch(term,pruningParameters)){prunedAnyBranch=!0;continue}retainedTerms.push(term)}if(!prunedAnyBranch)return!1;let cleanedTerms=retainedTerms.filter(term=>!isTrueSentinel(term)),rebuiltCondition=rebuildAndCondition(cleanedTerms);return query.whereClause=rebuiltCondition?new WhereClause(rebuiltCondition):null,!0},isSelectQueryNode=value=>value instanceof SimpleSelectQuery||value instanceof BinarySelectQuery,traverseNestedSelectQueries=(root,pruningParameters)=>{let changed=!1,visited=new WeakSet,walk=value=>{if(!(!value||typeof value!="object")&&!visited.has(value)){if(visited.add(value),value!==root&&isSelectQueryNode(value)){changed=traverseSelectQuery2(value,pruningParameters)||changed;return}if(Array.isArray(value)){value.forEach(walk);return}for(let child of Object.values(value))walk(child)}};return walk(root),changed},traverseSelectQuery2=(query,pruningParameters)=>{if(query instanceof SimpleSelectQuery){let selfChanged=pruneSimpleQueryWhereClause(query,pruningParameters),nestedChanged=traverseNestedSelectQueries(query,pruningParameters);return selfChanged||nestedChanged}if(query instanceof BinarySelectQuery){let leftChanged=traverseSelectQuery2(query.left,pruningParameters),rightChanged=traverseSelectQuery2(query.right,pruningParameters);return leftChanged||rightChanged}return!1},getSupportedOptionalConditionBranch=expression=>{let orTerms=collectTopLevelOrTerms(expression);if(orTerms.length<2)return null;let guardTerms=orTerms.map(term=>({term,parameterName:getGuardedParameterName(term)})).filter(candidate=>candidate.parameterName!==null);if(guardTerms.length!==1)return null;let[{term:guardTerm,parameterName}]=guardTerms,meaningfulTerms=orTerms.filter(term=>term!==guardTerm);return meaningfulTerms.length===0||!meaningfulTerms.every(term=>isSupportedMeaningfulBranch(term,parameterName))?null:{parameterName,kind:"expression"}},collectSupportedBranchesFromSimpleQuery=(query,branches)=>{if(!query.whereClause)return;let topLevelTerms=collectTopLevelAndTerms(query.whereClause.condition);for(let term of topLevelTerms){let branch=getSupportedOptionalConditionBranch(term);branch&&branches.push({query,parameterName:branch.parameterName,expression:term,kind:branch.kind})}},collectSupportedBranchesFromSelectQuery=(query,branches)=>{if(query instanceof SimpleSelectQuery){collectSupportedBranchesFromSimpleQuery(query,branches),traverseNestedSelectQueriesForCollection(query,branches);return}query instanceof BinarySelectQuery&&(collectSupportedBranchesFromSelectQuery(query.left,branches),collectSupportedBranchesFromSelectQuery(query.right,branches))},traverseNestedSelectQueriesForCollection=(root,branches)=>{let visited=new WeakSet,walk=value=>{if(!(!value||typeof value!="object")&&!visited.has(value)){if(visited.add(value),value!==root&&isSelectQueryNode(value)){collectSupportedBranchesFromSelectQuery(value,branches);return}if(Array.isArray(value)){value.forEach(walk);return}for(let child of Object.values(value))walk(child)}};walk(root)},pruneOptionalConditionBranches=(query,pruningParameters)=>(Object.keys(pruningParameters).length===0||traverseSelectQuery2(query,pruningParameters),query),collectSupportedOptionalConditionBranches=query=>{let branches=[];return collectSupportedBranchesFromSelectQuery(query,branches),branches},collectSupportedOptionalConditionBranchSpans=sql=>{let parsed=SelectQueryParser.parse(sql),supportedBranches=collectSupportedOptionalConditionBranches(parsed);if(supportedBranches.length===0)return[];let candidates=collectOptionalConditionSpanCandidates(sql),remainingSupportedCounts=countSupportedBranchesByKey(supportedBranches);assertUnambiguousCandidateCounts(candidates,remainingSupportedCounts);let spans=[];for(let candidate of candidates){let key=getSupportedBranchKey(candidate),remainingCount=remainingSupportedCounts.get(key)??0;remainingCount<=0||(spans.push(candidate),remainingSupportedCounts.set(key,remainingCount-1))}return assertNoMissingSupportedBranches(remainingSupportedCounts),spans},getSupportedBranchKey=branch=>`${branch.kind}:${branch.parameterName}`,countSupportedBranchesByKey=branches=>{let counts=new Map;for(let branch of branches){let key=getSupportedBranchKey(branch);counts.set(key,(counts.get(key)??0)+1)}return counts},assertUnambiguousCandidateCounts=(candidates,supportedCounts)=>{let candidateCounts=countSupportedBranchesByKey(candidates);for(let[key,supportedCount]of supportedCounts){let candidateCount=candidateCounts.get(key)??0;if(candidateCountsupportedCount)throw new Error(`Ambiguous source ranges for supported optional condition branch '${key}'.`)}},assertNoMissingSupportedBranches=supportedCounts=>{let missingKeys=[...supportedCounts.entries()].filter(([,count])=>count>0).map(([key])=>key);if(missingKeys.length>0)throw new Error(`Could not locate source ranges for supported optional condition branches: ${missingKeys.join(", ")}.`)},collectOptionalConditionSpanCandidates=sql=>{let lexemes=new SqlTokenizer(sql).readLexemes(),candidates=[],stack=[];for(let index=0;indexleft.sourceRange.start-right.sourceRange.start)},buildOptionalConditionSpanCandidate=(sql,lexemes,openParenIndex,closeParenIndex)=>{let inside=lexemes.slice(openParenIndex+1,closeParenIndex),orTermRanges=splitTopLevelTermsByKeyword(inside,"or");if(orTermRanges.length<2)return null;let guardTerms=orTermRanges.map(range=>({range,parameterName:getGuardedParameterNameFromLexemes(inside.slice(range.start,range.end))})).filter(candidate=>candidate.parameterName!==null);if(guardTerms.length!==1)return null;let[{range:guardRange,parameterName}]=guardTerms,meaningfulTerms=orTermRanges.filter(range=>range!==guardRange);if(meaningfulTerms.length===0||!meaningfulTerms.every(range=>isSupportedMeaningfulBranchFromLexemes(inside.slice(range.start,range.end),parameterName)))return null;let expandedRange=expandWrappingParenRange(lexemes,openParenIndex,closeParenIndex),sourceStart=requiredPosition(lexemes[expandedRange.openParenIndex]).startPosition,sourceEnd=requiredPosition(lexemes[expandedRange.closeParenIndex]).endPosition,removalRange=getRemovalRange(sql,lexemes,expandedRange.openParenIndex,expandedRange.closeParenIndex);return{parameterName,kind:"expression",sourceRange:{start:sourceStart,end:sourceEnd,text:sql.slice(sourceStart,sourceEnd)},removalRange,openParenIndex:expandedRange.openParenIndex,closeParenIndex:expandedRange.closeParenIndex}},expandWrappingParenRange=(lexemes,openParenIndex,closeParenIndex)=>{let expandedOpenParenIndex=openParenIndex,expandedCloseParenIndex=closeParenIndex;for(;isOpenParen(lexemes[expandedOpenParenIndex-1])&&isCloseParen(lexemes[expandedCloseParenIndex+1]);)expandedOpenParenIndex-=1,expandedCloseParenIndex+=1;return{openParenIndex:expandedOpenParenIndex,closeParenIndex:expandedCloseParenIndex}},splitTopLevelTermsByKeyword=(lexemes,keyword)=>{let ranges=[],depth=0,start=0;for(let index=0;index{let compact=lexemes.filter(lexeme=>!isWrappingParen(lexeme));return compact.length!==3||!isParameter(compact[0])||!isKeyword(compact[1],"is")||!isKeyword(compact[2],"null")?null:normalizeParameterName(compact[0].value)},isSupportedMeaningfulBranchFromLexemes=(lexemes,parameterName)=>{try{let parsed=ValueParser.parseFromLexeme(lexemes,0);return parsed.newIndex!==lexemes.length?!1:isSupportedMeaningfulBranch(parsed.value,parameterName)}catch{return!1}},getRemovalRange=(sql,lexemes,openParenIndex,closeParenIndex)=>{let previous=lexemes[openParenIndex-1],next=lexemes[closeParenIndex+1],start=requiredPosition(lexemes[openParenIndex]).startPosition,end=requiredPosition(lexemes[closeParenIndex]).endPosition;return previous&&isKeyword(previous,"and")?start=requiredPosition(previous).startPosition:next&&isKeyword(next,"and")?end=requiredPosition(next).endPosition:previous&&isKeyword(previous,"where")&&(start=requiredPosition(previous).startPosition),{start,end,text:sql.slice(start,end)}},isParameter=lexeme=>(lexeme.type&256)!==0,isOpenParen=lexeme=>(lexeme.type&4)!==0,isCloseParen=lexeme=>(lexeme.type&8)!==0,isKeyword=(lexeme,keyword)=>lexeme.value.toLowerCase()===keyword,isWrappingParen=lexeme=>isOpenParen(lexeme)||isCloseParen(lexeme),normalizeParameterName=value=>value.startsWith("${")&&value.endsWith("}")?value.slice(2,-1):value.replace(/^[:@$]/,""),requiredPosition=lexeme=>{if(!lexeme.position)throw new Error(`Lexeme '${lexeme.value}' is missing source position metadata.`);return lexeme.position};var DynamicQueryBuilder=class{constructor(resolverOrOptions){typeof resolverOrOptions=="function"?this.tableColumnResolver=resolverOrOptions:resolverOrOptions&&(this.tableColumnResolver=resolverOrOptions.tableColumnResolver,this.defaultSchemaInfo=resolverOrOptions.schemaInfo)}buildQuery(sqlContent,options={}){let removedOptions=options;if("serialize"in removedOptions||"jsonb"in removedOptions)throw new Error("DynamicQueryBuilder SQL-result JSON shaping has been removed. Keep SQL results as rows and use generated AOT mappers so the executed SQL remains debuggable.");let parsedQuery;try{parsedQuery=SelectQueryParser.parse(sqlContent)}catch(error){throw new Error(`Failed to parse SQL: ${error instanceof Error?error.message:"Unknown error"}`)}let modifiedQuery=parsedQuery;if(options.filter&&Object.keys(options.filter).length>0){let{hardcodedParams,dynamicFilters}=ParameterDetector.separateFilters(modifiedQuery,options.filter);if(Object.keys(hardcodedParams).length>0&&(modifiedQuery=new SqlParameterBinder({requireAllParameters:!1}).bind(modifiedQuery,hardcodedParams)),Object.keys(dynamicFilters).length>0)throw new Error("DynamicQueryBuilder no longer injects runtime filter predicates. Use `ashiba query optional add` to author optional filters, `ashiba query optional refresh` to refresh them, and `optionalConditionParameters` at runtime for pruning only.")}if(options.sort&&Object.keys(options.sort).length>0){let sortInjector=new SqlSortInjector(this.tableColumnResolver),simpleQuery=QueryBuilder.buildSimpleQuery(modifiedQuery);modifiedQuery=sortInjector.inject(simpleQuery,options.sort)}if(options.paging){let{page=1,pageSize}=options.paging;if(pageSize!==void 0){let paginationInjector=new SqlPaginationInjector,paginationOptions={page,pageSize},simpleQuery=QueryBuilder.buildSimpleQuery(modifiedQuery);modifiedQuery=paginationInjector.inject(simpleQuery,paginationOptions)}}modifiedQuery=this.applyColumnFilters(modifiedQuery,options);let optionalConditionParameters=this.resolveOptionalConditionPruningParameters(options);Object.keys(optionalConditionParameters).length>0&&(modifiedQuery=pruneOptionalConditionBranches(modifiedQuery,optionalConditionParameters));let effectiveSchemaInfo=options.schemaInfo??this.defaultSchemaInfo;return options.removeUnusedLeftJoins&&effectiveSchemaInfo?.length&&(modifiedQuery=optimizeUnusedLeftJoinsToFixedPoint(modifiedQuery,effectiveSchemaInfo)),options.removeUnusedCtes&&(modifiedQuery=optimizeUnusedCtesToFixedPoint(modifiedQuery)),modifiedQuery}resolveOptionalConditionPruningParameters(options){if(options.optionalConditionParameters)return options.optionalConditionParameters;if(!options.optionalConditionParameterStates)return{};let legacyParameters={};for(let[parameterName,state]of Object.entries(options.optionalConditionParameterStates))legacyParameters[parameterName]=state==="absent"?null:"__RAWSQL_OPTIONAL_CONDITION_PRESENT__";return legacyParameters}applyColumnFilters(query,options){let hasIncludeFilters=Array.isArray(options.includeColumns)&&options.includeColumns.length>0,hasExcludeFilters=Array.isArray(options.excludeColumns)&&options.excludeColumns.length>0;if(!hasIncludeFilters&&!hasExcludeFilters)return query;if(hasIncludeFilters&&hasExcludeFilters)throw new Error("includeColumns and excludeColumns cannot be used together.");let simpleQuery=QueryBuilder.buildSimpleQuery(query),metadata=simpleQuery.selectClause.items.map(item=>{let name=this.getSelectItemName(item);return{item,normalized:name?this.normalizeColumnIdentifier(name):null}}),availableColumns=new Set(metadata.map(entry=>entry.normalized).filter(name=>name!==null)),includeFilters=hasIncludeFilters?this.normalizeColumnList(options.includeColumns):null,excludeFilters=hasExcludeFilters?this.normalizeColumnList(options.excludeColumns):null,includeSet=includeFilters?new Set(includeFilters.map(entry=>entry.normalized)):null,excludeSet=excludeFilters?new Set(excludeFilters.map(entry=>entry.normalized)):null;if(includeFilters){let missing=includeFilters.filter(entry=>!availableColumns.has(entry.normalized));if(missing.length>0)throw new Error(`Column${missing.length===1?"":"s"} not found in SELECT clause: ${missing.map(entry=>`'${entry.original}'`).join(", ")}.`)}if(excludeFilters){let missing=excludeFilters.filter(entry=>!availableColumns.has(entry.normalized));if(missing.length>0)throw new Error(`Column${missing.length===1?"":"s"} not found in SELECT clause: ${missing.map(entry=>`'${entry.original}'`).join(", ")}.`)}let filteredItems=metadata.filter(entry=>entry.normalized?includeSet?includeSet.has(entry.normalized):excludeSet?!excludeSet.has(entry.normalized):!0:!0).map(entry=>entry.item);if(filteredItems.length===0)throw new Error("Column filtering removed every SELECT item.");return simpleQuery.selectClause.items=filteredItems,simpleQuery}normalizeColumnList(columns){return columns.map(column=>{if(typeof column!="string")throw new Error("Column filters must be strings.");let trimmed=column.trim();if(trimmed==="")throw new Error("Column filters must not be empty.");return{normalized:this.normalizeColumnIdentifier(trimmed),original:trimmed}})}normalizeColumnIdentifier(value){return value.trim().toLowerCase()}getSelectItemName(item){return item.identifier?item.identifier.name:item.value instanceof ColumnReference?item.value.column.name:null}buildFilteredQuery(sqlContent,filter){return this.buildQuery(sqlContent,{filter})}buildSortedQuery(sqlContent,sort){return this.buildQuery(sqlContent,{sort})}buildPaginatedQuery(sqlContent,paging){return this.buildQuery(sqlContent,{paging})}validateSql(sqlContent){try{return SelectQueryParser.parse(sqlContent),!0}catch(error){throw new Error(`Invalid SQL: ${error instanceof Error?error.message:"Unknown error"}`)}}};var SUPPORTED_SCALAR_OPERATORS=new Set(["=","<>","<","<=",">",">=","like","ilike"]),formatter=null,normalizeIdentifier2=value=>value.trim().toLowerCase(),normalizeSql=value=>value.replace(/\s+/g," ").trim().toLowerCase(),normalizeRewriteTokenType=lexeme=>(lexeme.type&128)!==0?128:(lexeme.type&64)!==0?64:lexeme.type,normalizeRewriteTokenValue=lexeme=>(lexeme.type&128)!==0?lexeme.value.toLowerCase():lexeme.value,tokenizeForRewritePlan=sql=>new SqlTokenizer(sql).tokenize().map(lexeme=>({type:normalizeRewriteTokenType(lexeme),value:normalizeRewriteTokenValue(lexeme)})),tokenSequencesEqual=(left,right)=>left.length!==right.length?!1:left.every((token,index)=>{let other=right[index];return other!==void 0&&token.type===other.type&&token.value===other.value}),collectCommentFragments=sql=>new SqlTokenizer(sql).tokenize().flatMap(lexeme=>lexeme.positionedComments?lexeme.positionedComments.flatMap(positioned=>positioned.comments):lexeme.comments?[...lexeme.comments]:[]),commentsPreservedInOrder=(before,after)=>{let cursor=0;for(let comment of before){let foundAt=after.indexOf(comment,cursor);if(foundAt<0)return!1;cursor=foundAt+1}return!0},countCommandToken=(tokens,value)=>tokens.filter(token=>token.type===128&&token.value===value).length,applyRewriteEdits=(sql,edits)=>[...edits].sort((left,right)=>right.start-left.start).reduce((current,edit)=>current.slice(0,edit.start)+edit.after+current.slice(edit.end),sql),getStatementEndPosition=sql=>{let end=sql.length;for(;end>0&&/\s/.test(sql[end-1]);)end--;if(end>0&&sql[end-1]===";")for(end--;end>0&&/\s/.test(sql[end-1]);)end--;return end},clauseBoundaryCommands=new Set(["group by","having","order by","limit","offset","fetch","for"]),isClauseBoundary=lexeme=>(lexeme.type&128)!==0&&clauseBoundaryCommands.has(lexeme.value.toLowerCase()),findMinimalWhereInsertPosition=sql=>{let lexemes=new SqlTokenizer(sql).tokenize(),whereIndex=lexemes.findIndex(lexeme=>(lexeme.type&128)!==0&&lexeme.value.toLowerCase()==="where"),statementEnd=getStatementEndPosition(sql);return whereIndex>=0?{position:lexemes.slice(whereIndex+1).find(isClauseBoundary)?.position?.startPosition??statementEnd,hasWhere:!0}:{position:lexemes.find(isClauseBoundary)?.position?.startPosition??statementEnd,hasWhere:!1}},findMatchingParenEnd=(sql,start)=>{let depth=0,quote=null;for(let index=start;index{let spans=[],parameterNeedle=`:${parameterName.toLowerCase()}`,lowerSql=sql.toLowerCase();for(let index=0;index{let prefix=sql.slice(0,start),match=/(\s+)(and|or)(\s*)$/i.exec(prefix);return!match||match.index===void 0?null:{start:match.index,end:start,value:match[2].toLowerCase()}},findBooleanOperatorAfter=(sql,end)=>{let suffix=sql.slice(end),match=/^(\s*)(and|or)(\s+)/i.exec(suffix);return match?{start:end,end:end+match[0].length,value:match[2].toLowerCase()}:null},findWhereBefore=(sql,position)=>{let lexemes=new SqlTokenizer(sql).tokenize(),found=null;for(let lexeme of lexemes){if((lexeme.position?.startPosition??0)>=position)break;(lexeme.type&128)!==0&&lexeme.value.toLowerCase()==="where"&&(found=lexeme)}return found?.position?{start:found.position.startPosition,end:found.position.endPosition}:null},findSourceColumnReferenceText=(sql,reference)=>{let namespace=normalizeIdentifier2(reference.getNamespace()),column=normalizeIdentifier2(reference.column.name),lexemes=new SqlTokenizer(sql).tokenize();for(let index=0;index`${normalizeIdentifier2(reference.getNamespace())}.${normalizeIdentifier2(reference.column.name)}`,normalizeColumnReferenceText=reference=>{let namespace=reference.getNamespace();return namespace?`${namespace}.${reference.column.name}`:reference.column.name},normalizeScalarOperator=value=>{if(!value)return"=";let normalized=value.trim().toLowerCase();if(normalized==="!=")return"<>";if(SUPPORTED_SCALAR_OPERATORS.has(normalized))return normalized;throw new Error(`Unsupported SSSQL operator '${value}'.`)},isExplicitEqualityScaffoldValue=value=>{if(value==null)return!0;if(Array.isArray(value))return!1;if(typeof value!="object")return!0;let entries=Object.entries(value).filter(([,entry])=>entry!==void 0);return entries.length===1&&entries[0]?.[0]==="="},parseQualifiedFilterName=filterName=>{let segments=filterName.split(".");if(segments.length!==2)return null;let[table,column]=segments.map(segment=>segment.trim());return!table||!column?null:{table,column}},makeParameterName=filterName=>filterName.trim().replace(/\./g,"_").replace(/[^a-zA-Z0-9_]/g,"_"),unwrapParens=expression=>{let candidate=expression;for(;candidate instanceof ParenExpression;)candidate=candidate.expression;return candidate},isBinaryOperator2=(expression,operator)=>expression instanceof BinaryExpression&&expression.operator.value.trim().toLowerCase()===operator,collectTopLevelAndTerms2=expression=>{let candidate=unwrapParens(expression);return isBinaryOperator2(candidate,"and")?[...collectTopLevelAndTerms2(candidate.left),...collectTopLevelAndTerms2(candidate.right)]:[expression]},collectTopLevelOrTerms2=expression=>{let candidate=unwrapParens(expression);return isBinaryOperator2(candidate,"or")?[...collectTopLevelOrTerms2(candidate.left),...collectTopLevelOrTerms2(candidate.right)]:[expression]},getGuardedParameterName2=expression=>{let candidate=unwrapParens(expression);if(!isBinaryOperator2(candidate,"is")||!(candidate.left instanceof ParameterExpression))return null;let right=unwrapParens(candidate.right);return right instanceof LiteralValue&&right.value===null||right instanceof RawString&&right.value.trim().toLowerCase()==="null"?candidate.left.name.value:null},buildOptionalScalarBranch=(column,parameterName,operator)=>{let guard=new BinaryExpression(new ParameterExpression(parameterName),"is",new LiteralValue(null)),predicate=new BinaryExpression(new ColumnReference(column.getNamespace()||null,column.column.name),operator,new ParameterExpression(parameterName));return new ParenExpression(new BinaryExpression(guard,"or",predicate))},buildOptionalExistsBranch=(parameterName,subquery,kind)=>{let guard=new BinaryExpression(new ParameterExpression(parameterName),"is",new LiteralValue(null)),existsExpression=new UnaryExpression("exists",new InlineQuery(subquery)),predicate=kind==="exists"?existsExpression:new UnaryExpression("not",existsExpression);return new ParenExpression(new BinaryExpression(guard,"or",predicate))},rebuildWhereWithoutTerm=(query,termToRemove)=>{if(!query.whereClause)return;let terms=collectTopLevelAndTerms2(query.whereClause.condition).filter(term=>term!==termToRemove);if(terms.length===0){query.whereClause=null;return}let rebuilt=terms[0];for(let index=1;index(formatter??=new SqlFormatter,formatter.format(component).formattedSql),enforceSubqueryConstraints=sql=>{if(!sql.trim())throw new Error("SSSQL EXISTS/NOT EXISTS scaffold query must not be empty.");if(sql.includes(";"))throw new Error("SSSQL EXISTS/NOT EXISTS scaffold query must not contain semicolons or multiple statements.");if(/\blateral\b/i.test(sql))throw new Error("LATERAL is not supported in SSSQL EXISTS/NOT EXISTS scaffold.")},substituteAnchorPlaceholders=(sql,formattedColumns)=>{let usedIndexes=new Set,replaced=sql.replace(/\$c(\d+)/g,(_,indexDigits)=>{let index=Number(indexDigits);if(!Number.isInteger(index))throw new Error(`Invalid placeholder '$c${indexDigits}' in SSSQL scaffold query.`);if(index<0||index>=formattedColumns.length)throw new Error(`Placeholder '$c${index}' references a missing SSSQL scaffold anchor column.`);return usedIndexes.add(index),formattedColumns[index]});if(formattedColumns.length===0)return replaced;for(let index=0;index{let meaningfulTerms=collectTopLevelOrTerms2(expression).filter(term=>getGuardedParameterName2(term)!==parameterName);if(meaningfulTerms.length!==1)return null;let predicate=unwrapParens(meaningfulTerms[0]);if(!(predicate instanceof BinaryExpression))return null;let left=unwrapParens(predicate.left),right=unwrapParens(predicate.right);if(left instanceof ColumnReference&&right instanceof ParameterExpression&&right.name.value===parameterName)try{return{operator:normalizeScalarOperator(predicate.operator.value),target:normalizeColumnReferenceText(left)}}catch{return null}if(right instanceof ColumnReference&&left instanceof ParameterExpression&&left.name.value===parameterName)try{return{operator:normalizeScalarOperator(predicate.operator.value),target:normalizeColumnReferenceText(right)}}catch{return null}return null},hasSelectQuery=value=>typeof value=="object"&&value!==null&&"selectQuery"in value,collectColumnReferencesDeep=value=>{let references=[],visited=new WeakSet,walk=candidate=>{if(!(!candidate||typeof candidate!="object")){if(candidate instanceof ColumnReference){references.push(candidate);return}if(!visited.has(candidate)){if(visited.add(candidate),Array.isArray(candidate)){for(let item of candidate)walk(item);return}for(let child of Object.values(candidate))walk(child)}}};return walk(value),references},getExistsBranchKind=(expression,parameterName)=>{let meaningfulTerms=collectTopLevelOrTerms2(expression).filter(term=>getGuardedParameterName2(term)!==parameterName);if(meaningfulTerms.length!==1)return null;let predicate=unwrapParens(meaningfulTerms[0]),isInlineQueryValue=value=>value instanceof InlineQuery||hasSelectQuery(value);if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="exists")return isInlineQueryValue(unwrapParens(predicate.expression))?"exists":null;if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="not exists")return isInlineQueryValue(unwrapParens(predicate.expression))?"not-exists":null;if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="not"&&unwrapParens(predicate.expression)instanceof UnaryExpression){let nested=unwrapParens(predicate.expression);if(nested.operator.value.trim().toLowerCase()==="exists"&&isInlineQueryValue(unwrapParens(nested.expression)))return"not-exists"}return null},getExistsPredicateDetails=(expression,parameterName)=>{let meaningfulTerms=collectTopLevelOrTerms2(expression).filter(term=>getGuardedParameterName2(term)!==parameterName);if(meaningfulTerms.length!==1)return null;let predicate=unwrapParens(meaningfulTerms[0]),isInlineQueryValue=value=>value instanceof InlineQuery||hasSelectQuery(value);if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="exists"){let candidate=unwrapParens(predicate.expression);return isInlineQueryValue(candidate)?{kind:"exists",subquery:candidate.selectQuery}:null}if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="not exists"){let candidate=unwrapParens(predicate.expression);return isInlineQueryValue(candidate)?{kind:"not-exists",subquery:candidate.selectQuery}:null}if(predicate instanceof UnaryExpression&&predicate.operator.value.trim().toLowerCase()==="not"&&unwrapParens(predicate.expression)instanceof UnaryExpression){let nested=unwrapParens(predicate.expression),candidate=unwrapParens(nested.expression);if(nested.operator.value.trim().toLowerCase()==="exists"&&isInlineQueryValue(candidate))return{kind:"not-exists",subquery:candidate.selectQuery}}return null},getBranchInfo=branch=>{let scalar=getScalarBranchDetails(branch.expression,branch.parameterName);if(scalar)return{parameterName:branch.parameterName,kind:"scalar",operator:scalar.operator,target:scalar.target,query:branch.query,expression:branch.expression,sql:formatSqlComponent(branch.expression)};let existsKind=getExistsBranchKind(branch.expression,branch.parameterName);return existsKind?{parameterName:branch.parameterName,kind:existsKind,query:branch.query,expression:branch.expression,sql:formatSqlComponent(branch.expression)}:{parameterName:branch.parameterName,kind:"expression",query:branch.query,expression:branch.expression,sql:formatSqlComponent(branch.expression)}},SSSQLFilterBuilder=class{constructor(tableColumnResolver){this.tableColumnResolver=tableColumnResolver;this.finder=new UpstreamSelectQueryFinder(this.tableColumnResolver)}list(query){let parsed=this.parseQuery(query);return collectSupportedOptionalConditionBranches(parsed).map(getBranchInfo)}planScaffold(query,filters){if(typeof query=="string"){let entries=Object.entries(filters);if(entries.length===1){let[filterName,filterValue]=entries[0];if(isExplicitEqualityScaffoldValue(filterValue))try{return this.planScalarInsert(query,{target:filterName,parameterName:makeParameterName(filterName),operator:"="})}catch{}}}return this.planRewrite(query,parsed=>this.scaffold(parsed,filters))}dryRunScaffold(query,filters){return this.planScaffold(query,filters)}planScaffoldBranch(query,spec){if(typeof query=="string"&&(spec.kind==="exists"||spec.kind==="not-exists"))try{return this.planExistsInsert(query,spec)}catch{}if(typeof query=="string"&&spec.kind!=="exists"&&spec.kind!=="not-exists")try{return this.planScalarInsert(query,spec)}catch{}return this.planRewrite(query,parsed=>this.scaffoldBranch(parsed,spec))}dryRunScaffoldBranch(query,spec){return this.planScaffoldBranch(query,spec)}planRefresh(query,filters){return this.planRewrite(query,parsed=>this.refresh(parsed,filters))}dryRunRefresh(query,filters){return this.planRefresh(query,filters)}planRemove(query,spec){if(typeof query=="string")try{return this.planBranchRemoval(query,spec)}catch{}return this.planRewrite(query,parsed=>this.remove(parsed,spec))}dryRunRemove(query,spec){return this.planRemove(query,spec)}planRemoveAll(query){return this.planRewrite(query,parsed=>this.removeAll(parsed))}dryRunRemoveAll(query){return this.planRemoveAll(query)}scaffold(query,filters){let parsed=this.parseQuery(query);for(let[filterName,filterValue]of Object.entries(filters)){if(!isExplicitEqualityScaffoldValue(filterValue))throw new Error(`SSSQL scaffold only supports equality filters in v1. Use structured scaffold or refresh for pre-authored branches: '${filterName}'.`);this.scaffoldBranch(parsed,{target:filterName,parameterName:makeParameterName(filterName),operator:"="})}return parsed}scaffoldBranch(query,spec){let parsed=this.parseQuery(query);return spec.kind==="exists"||spec.kind==="not-exists"?(this.scaffoldExistsBranch(parsed,spec),parsed):(this.scaffoldScalarBranch(parsed,spec),parsed)}refresh(query,filters){let parsed=this.parseQuery(query);for(let[filterName,filterValue]of Object.entries(filters)){let parameterName=filterName,target=null,matches=collectSupportedOptionalConditionBranches(parsed).filter(branch=>branch.parameterName===parameterName);if(matches.length===0&&(target=this.resolveTarget(parsed,filterName),parameterName=target.parameterName,matches=collectSupportedOptionalConditionBranches(parsed).filter(branch=>branch.parameterName===parameterName)),matches.length===0){if(target||(target=this.resolveTarget(parsed,filterName),parameterName=target.parameterName),!isExplicitEqualityScaffoldValue(filterValue))throw new Error(`No existing SSSQL branch was found for '${filterName}', and v1 scaffold only supports equality filters.`);this.scaffoldScalarBranch(parsed,{target:filterName,parameterName:target.parameterName,operator:"="});continue}if(matches.length>1)throw new Error(`Multiple SSSQL branches matched parameter ':${parameterName}'. Refresh is ambiguous.`);let[match]=matches;if(!match)continue;let correlatedPlan=this.buildCorrelatedRefreshPlan(parsed,match);if(correlatedPlan){if(correlatedPlan.target.query===match.query)continue;this.rebaseMovedBranchByAlias(match.expression,correlatedPlan.sourceAlias,correlatedPlan.target.column),rebuildWhereWithoutTerm(match.query,match.expression),correlatedPlan.target.query.appendWhere(match.expression);continue}target||(target=this.resolveTarget(parsed,filterName)),match.query!==target.query&&(this.rebaseMovedBranch(match.expression,match.query,target.column),rebuildWhereWithoutTerm(match.query,match.expression),target.query.appendWhere(match.expression))}return parsed}remove(query,spec){let parsed=this.parseQuery(query),matches=this.findMatchingBranchInfos(parsed,spec);if(matches.length===0)return parsed;if(matches.length>1)throw new Error(`Multiple SSSQL branches matched parameter ':${spec.parameterName}'. Remove is ambiguous.`);let[match]=matches;return match&&rebuildWhereWithoutTerm(match.query,match.expression),parsed}removeAll(query){let parsed=this.parseQuery(query),matches=this.list(parsed);for(let match of matches)rebuildWhereWithoutTerm(match.query,match.expression);return parsed}parseQuery(query){return typeof query=="string"?SelectQueryParser.parse(query):query}planScalarInsert(sourceSql,spec){let parsed=SelectQueryParser.parse(sourceSql),target=this.resolveTarget(parsed,spec.target);if(target.query!==parsed)return this.planRewrite(sourceSql,query=>this.scaffoldBranch(query,spec));let parameterName=spec.parameterName?.trim()||target.parameterName,operator=normalizeScalarOperator(spec.operator),targetColumnText=findSourceColumnReferenceText(sourceSql,target.column),branchSql=`(:${parameterName} is null or ${targetColumnText} ${operator} :${parameterName})`,normalizedBranch=normalizeSql(branchSql);return this.list(parsed).find(existing=>existing.query===target.query&&normalizeSql(existing.sql)===normalizedBranch)?this.buildPlanFromEdits(sourceSql,[],[],[]):this.buildMinimalInsertPlan(sourceSql,branchSql,{branchKind:"scalar",parameterName,column:targetColumnText})}planExistsInsert(sourceSql,spec){let parameterName=spec.parameterName.trim();if(!parameterName)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold requires parameterName.");if(spec.anchorColumns.length===0)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold requires at least one anchorColumn.");let parsed=SelectQueryParser.parse(sourceSql),anchorTargets=spec.anchorColumns.map(anchorColumn=>this.resolveTarget(parsed,anchorColumn)),targetQueries=[...new Set(anchorTargets.map(target=>target.query))];if(targetQueries.length!==1)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold anchor columns must resolve within one query scope.");let targetQuery=targetQueries[0];if(targetQuery!==parsed)return this.planRewrite(sourceSql,query=>this.scaffoldBranch(query,spec));let sourceColumns=anchorTargets.map(target=>findSourceColumnReferenceText(sourceSql,target.column)),substitutedSql=substituteAnchorPlaceholders(spec.query,sourceColumns).trim();enforceSubqueryConstraints(substitutedSql);let subquery=SelectQueryParser.parse(substitutedSql),parameterNames=new Set(ParameterCollector.collect(subquery).map(parameter=>parameter.name.value));if(parameterNames.size!==1||!parameterNames.has(parameterName))throw new Error(`SSSQL ${spec.kind.toUpperCase()} scaffold query must reference only parameter ':${parameterName}'.`);let branchSql=`(:${parameterName} is null or ${spec.kind==="not-exists"?"not exists":"exists"} (${substitutedSql}))`;return this.list(parsed).find(existing=>existing.query===targetQuery&&normalizeSql(existing.sql)===normalizeSql(branchSql))?this.buildPlanFromEdits(sourceSql,[],[],[]):this.buildMinimalInsertPlan(sourceSql,branchSql,{branchKind:spec.kind,parameterName,column:sourceColumns.join(", ")})}buildMinimalInsertPlan(sourceSql,branchSql,target){let insertPosition=findMinimalWhereInsertPosition(sourceSql),prefix=`${insertPosition.position===0||!/\s/.test(sourceSql[insertPosition.position-1])?" ":""}${insertPosition.hasWhere?"and":"where"} `,suffix=insertPosition.position1)return this.buildPlanFromEdits(sourceSql,[],[],[],[{code:"REWRITE_FAILED",message:"SSSQL remove planning found multiple matching branches.",detail:`Multiple SSSQL branches matched parameter ':${spec.parameterName}'. Remove is ambiguous.`}]);if(matches.length===0)return this.buildPlanFromEdits(sourceSql,[],[],[]);let branchSpans=findOptionalBranchSpans(sourceSql,spec.parameterName);if(branchSpans.length>1)return this.planRewrite(sourceSql,query=>this.remove(query,spec));let span=branchSpans[0];if(!span)return this.planRewrite(sourceSql,query=>this.remove(query,spec));let beforeOperator=findBooleanOperatorBefore(sourceSql,span.start),afterOperator=findBooleanOperatorAfter(sourceSql,span.end),where=findWhereBefore(sourceSql,span.start),start=span.start,end=span.end,changedRegions=[{kind:"target-branch",start:span.start,end:span.end,message:"Removed SSSQL optional branch."}];if(beforeOperator)start=beforeOperator.start,changedRegions.unshift({kind:"boolean-operator",start:beforeOperator.start,end:beforeOperator.end,message:`Removed adjacent ${beforeOperator.value.toUpperCase()} before the SSSQL branch.`});else if(afterOperator)end=afterOperator.end,changedRegions.push({kind:"boolean-operator",start:afterOperator.start,end:afterOperator.end,message:`Removed adjacent ${afterOperator.value.toUpperCase()} after the SSSQL branch.`});else if(where){for(start=where.start;endregion.kind==="target-branch"||region.kind==="where-keyword"||region.kind==="boolean-operator"||region.kind==="parentheses")&&commentsPreserved,planWarnings=[...warnings];if(plannedSql!==void 0&&applyRewriteEdits(sourceSql,edits)!==plannedSql&&(errors=[...errors,{code:"APPLY_PLAN_MISMATCH",message:"Applying SSSQL rewrite plan edits did not reproduce the planned SQL."}]),plannedSql!==void 0)try{SelectQueryParser.parse(plannedSql)}catch(error){errors=[...errors,{code:"PARSE_AFTER_FAILED",message:"The SQL produced by SSSQL rewrite planning could not be parsed.",detail:error instanceof Error?error.message:error}]}return plannedSql!==void 0&&!commentsPreserved&&planWarnings.push({code:"COMMENTS_NOT_PRESERVED",message:"One or more input SQL comments are missing or reordered after the SSSQL rewrite."}),{ok:errors.length===0,requiresFullReformat:!1,edits,sql:plannedSql,safety:{tokenCountBefore:beforeTokens.length,tokenCountAfter:afterTokens.length,tokenSequencePreserved:plannedSql!==void 0?tokenSequencesEqual(beforeTokens,afterTokens):!1,commentsPreserved,changedOnlyTargetBranches,changedRegions},warnings:planWarnings,errors}}planRewrite(query,rewrite){let warnings=[],errors=[],sourceSql=typeof query=="string"?query:formatSqlComponent(query);typeof query!="string"&&warnings.push({code:"SOURCE_SQL_UNAVAILABLE",message:"SSSQL rewrite planning received an AST, so the source SQL had to be formatter-generated before analysis."});let beforeTokens=[],beforeComments=[];try{beforeTokens=tokenizeForRewritePlan(sourceSql),beforeComments=collectCommentFragments(sourceSql)}catch(error){errors.push({code:"TOKENIZE_BEFORE_FAILED",message:"Could not tokenize the input SQL before SSSQL rewrite planning.",detail:error instanceof Error?error.message:error})}let plannedSql,afterTokens=[],afterComments=[];if(errors.length===0)try{let parsed=SelectQueryParser.parse(sourceSql),rewritten=rewrite(parsed);plannedSql=formatSqlComponent(rewritten)}catch(error){errors.push({code:"REWRITE_FAILED",message:"SSSQL rewrite planning could not produce a rewritten query.",detail:error instanceof Error?error.message:error})}if(plannedSql!==void 0){try{SelectQueryParser.parse(plannedSql)}catch(error){errors.push({code:"PARSE_AFTER_FAILED",message:"The SQL produced by SSSQL rewrite planning could not be parsed.",detail:error instanceof Error?error.message:error})}try{afterTokens=tokenizeForRewritePlan(plannedSql),afterComments=collectCommentFragments(plannedSql)}catch(error){errors.push({code:"TOKENIZE_AFTER_FAILED",message:"Could not tokenize the SQL produced by SSSQL rewrite planning.",detail:error instanceof Error?error.message:error})}}let edits=plannedSql!==void 0&&plannedSql!==sourceSql?[{start:0,end:sourceSql.length,before:sourceSql,after:plannedSql,kind:"replace",reason:"Current SSSQL rewrite planning is backed by AST rewrite plus formatter output."}]:[],changedRegions=edits.length>0?[{kind:"formatter-rewrite",start:0,end:sourceSql.length,message:"The conservative SSSQL rewrite plan requires replacing formatter output for the full SQL text."}]:[],requiresFullReformat=edits.length>0,tokenSequencePreserved=plannedSql!==void 0?tokenSequencesEqual(beforeTokens,afterTokens):!1,commentsPreserved=plannedSql!==void 0?commentsPreservedInOrder(beforeComments,afterComments):!1,changedOnlyTargetBranches=edits.length===0;return requiresFullReformat&&warnings.push({code:"FULL_REFORMAT_REQUIRED",message:"The current SSSQL rewrite plan can only represent the change as a full SQL replacement."}),plannedSql!==void 0&&!tokenSequencePreserved&&warnings.push({code:"TOKEN_SEQUENCE_CHANGED",message:"The SQL token sequence changes after the SSSQL rewrite. The conservative planner cannot prove that only target branches changed.",detail:{tokenCountBefore:beforeTokens.length,tokenCountAfter:afterTokens.length}}),plannedSql!==void 0&&!commentsPreserved&&warnings.push({code:"COMMENTS_NOT_PRESERVED",message:"One or more input SQL comments are missing or reordered after the SSSQL rewrite."}),plannedSql!==void 0&&countCommandToken(afterTokens,"as")>countCommandToken(beforeTokens,"as")&&warnings.push({code:"OPTIONAL_ALIAS_AS_ADDED",message:"The rewrite output contains more AS tokens than the input, which may indicate formatter-added aliases."}),plannedSql!==void 0&&!sourceSql.includes('"')&&plannedSql.includes('"')&&warnings.push({code:"IDENTIFIER_QUOTES_ADDED",message:"The rewrite output contains double-quoted identifiers that were not present in the input SQL."}),{ok:errors.length===0,requiresFullReformat,edits,sql:plannedSql,safety:{tokenCountBefore:beforeTokens.length,tokenCountAfter:afterTokens.length,tokenSequencePreserved,commentsPreserved,changedOnlyTargetBranches,changedRegions},warnings,errors}}findMatchingBranchInfos(root,spec){let normalizedOperator=spec.operator?normalizeScalarOperator(spec.operator):void 0,normalizedTarget=spec.target?normalizeIdentifier2(spec.target):void 0;return this.list(root).filter(branch=>!(branch.parameterName!==spec.parameterName||spec.kind&&branch.kind!==spec.kind||normalizedOperator&&branch.operator!==normalizedOperator||normalizedTarget&&(!branch.target||normalizeIdentifier2(branch.target)!==normalizedTarget)))}scaffoldScalarBranch(root,spec){let target=this.resolveTarget(root,spec.target),parameterName=spec.parameterName?.trim()||target.parameterName,operator=normalizeScalarOperator(spec.operator),branch=buildOptionalScalarBranch(target.column,parameterName,operator),branchSql=normalizeSql(formatSqlComponent(branch));this.list(root).find(existing=>existing.query===target.query&&normalizeSql(existing.sql)===branchSql)||target.query.appendWhere(branch)}scaffoldExistsBranch(root,spec){let parameterName=spec.parameterName.trim();if(!parameterName)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold requires parameterName.");if(spec.anchorColumns.length===0)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold requires at least one anchorColumn.");let anchorTargets=spec.anchorColumns.map(anchorColumn=>this.resolveTarget(root,anchorColumn)),targetQueries=[...new Set(anchorTargets.map(target=>target.query))];if(targetQueries.length!==1)throw new Error("SSSQL EXISTS/NOT EXISTS scaffold anchor columns must resolve within one query scope.");let targetQuery=targetQueries[0],formattedColumns=anchorTargets.map(target=>formatSqlComponent(target.column)),substitutedSql=substituteAnchorPlaceholders(spec.query,formattedColumns).trim();enforceSubqueryConstraints(substitutedSql);let subquery=SelectQueryParser.parse(substitutedSql),parameterNames=new Set(ParameterCollector.collect(subquery).map(parameter=>parameter.name.value));if(parameterNames.size!==1||!parameterNames.has(parameterName))throw new Error(`SSSQL ${spec.kind.toUpperCase()} scaffold query must reference only parameter ':${parameterName}'.`);let branch=buildOptionalExistsBranch(parameterName,subquery,spec.kind),branchSql=normalizeSql(formatSqlComponent(branch));this.list(root).find(existing=>existing.query===targetQuery&&normalizeSql(existing.sql)===branchSql)||targetQuery.appendWhere(branch)}resolveTarget(root,filterName){let qualified=parseQualifiedFilterName(filterName),lookupColumn=qualified?.column??filterName.trim(),matches=[...new Set(this.finder.find(root,lookupColumn))].map(query=>this.resolveTargetInQuery(query,filterName,qualified)).filter(target=>target!==null);if(matches.length===0)throw new Error(`Could not resolve SSSQL filter target '${filterName}' in the current query graph.`);if(matches.length>1)throw new Error(`SSSQL filter target '${filterName}' is ambiguous across multiple query scopes.`);return matches[0]}resolveTargetInQuery(query,filterName,qualified){return qualified?this.resolveQualifiedTarget(query,qualified):this.resolveUnqualifiedTarget(query,filterName)}resolveQualifiedTarget(query,filterName){let alias=this.findAliasForTable(query,filterName.table);if(!alias)return null;let matches=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query).filter(entry=>entry.value instanceof ColumnReference).filter(entry=>normalizeColumnReferenceKey(entry.value)===`${normalizeIdentifier2(alias)}.${normalizeIdentifier2(filterName.column)}`);if(matches.length===0)return null;if(matches.length>1)throw new Error(`SSSQL scaffold target '${filterName.table}.${filterName.column}' resolved to multiple columns.`);return{query,column:matches[0].value,parameterName:makeParameterName(`${filterName.table}.${filterName.column}`)}}resolveUnqualifiedTarget(query,filterName){let matches=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query).filter(entry=>entry.value instanceof ColumnReference).filter(entry=>normalizeIdentifier2(entry.name)===normalizeIdentifier2(filterName));if(matches.length===0)return null;if(matches.length>1)throw new Error(`SSSQL scaffold target '${filterName}' is ambiguous. Use a qualified table.column reference.`);return{query,column:matches[0].value,parameterName:makeParameterName(filterName)}}findAliasForTable(query,tableName){let normalizedTable=normalizeIdentifier2(tableName),matchingAliases=(query.fromClause?.getSources()??[]).map(source=>this.resolveAliasForSource(source,normalizedTable)).filter(alias=>alias!==null);if(matchingAliases.length===0)return null;if(matchingAliases.length>1)throw new Error(`SSSQL scaffold target table '${tableName}' is ambiguous in the selected query scope.`);return matchingAliases[0]}resolveAliasForSource(source,normalizedTable){if(!(source.datasource instanceof TableSource))return null;let sourceName=normalizeIdentifier2(source.datasource.getSourceName()),shortName=normalizeIdentifier2(source.datasource.table.name);return sourceName!==normalizedTable&&shortName!==normalizedTable?null:source.getAliasName()??source.datasource.table.name}buildCorrelatedRefreshPlan(root,branch){let details=getExistsPredicateDetails(branch.expression,branch.parameterName);if(!details)return null;let sourceAliases=this.collectSourceAliases(branch.query),candidatesByKey=new Map;for(let reference of new ColumnReferenceCollector().collect(details.subquery)){let namespace=normalizeIdentifier2(reference.getNamespace());if(!namespace||!sourceAliases.has(namespace))continue;let column=normalizeIdentifier2(reference.column.name),key=`${namespace}.${column}`;candidatesByKey.has(key)||candidatesByKey.set(key,{namespace,column})}let candidates=[...candidatesByKey.values()];if(candidates.length===0)throw new Error(`SSSQL refresh could not infer a correlated anchor for ':${branch.parameterName}'.`);if(candidates.length>1){let listed=candidates.map(candidate=>`${candidate.namespace}.${candidate.column}`).join(", ");throw new Error(`SSSQL refresh found multiple correlated anchor candidates for ':${branch.parameterName}' (${listed}).`)}let[anchor]=candidates;if(!anchor)throw new Error(`SSSQL refresh could not infer a correlated anchor for ':${branch.parameterName}'.`);return{target:this.resolveCorrelatedAnchorTarget(root,branch.query,anchor,branch.parameterName),sourceAlias:anchor.namespace}}collectSourceAliases(query){let aliases=new Set;for(let source of query.fromClause?.getSources()??[]){let sourceAlias=this.getSourceAlias(source);sourceAlias&&aliases.add(sourceAlias)}return aliases}resolveCorrelatedAnchorTarget(root,sourceQuery,anchor,parameterName){let sourceExpression=this.findSourceExpressionByAlias(sourceQuery,anchor.namespace,parameterName),upstreamQuery=this.resolveSourceExpressionToUpstreamQuery(root,sourceExpression,parameterName);return upstreamQuery?this.resolveAnchorTargetInQuery(upstreamQuery,anchor,parameterName):{query:sourceQuery,column:new ColumnReference(anchor.namespace,anchor.column),parameterName}}findSourceExpressionByAlias(query,alias,parameterName){let matches=(query.fromClause?.getSources()??[]).filter(source=>this.getSourceAlias(source)===alias);if(matches.length===0)throw new Error(`SSSQL refresh could not resolve correlated alias '${alias}' for ':${parameterName}'.`);if(matches.length>1)throw new Error(`SSSQL refresh found multiple correlated sources for alias '${alias}' and ':${parameterName}'.`);return matches[0]}resolveSourceExpressionToUpstreamQuery(root,source,parameterName){if(source.datasource instanceof SubQuerySource){if(source.datasource.query instanceof SimpleSelectQuery)return source.datasource.query;throw new Error(`SSSQL refresh requires a simple query anchor for ':${parameterName}'.`)}if(!(source.datasource instanceof TableSource))return null;let cteName=normalizeIdentifier2(source.datasource.table.name),cteMatches=new CTECollector().collect(root).filter(cte2=>normalizeIdentifier2(cte2.getSourceAliasName())===cteName);if(cteMatches.length===0)return null;if(cteMatches.length>1)throw new Error(`SSSQL refresh found multiple CTE anchors for ':${parameterName}' (${source.datasource.table.name}).`);let[cte]=cteMatches;if(!cte)return null;let cteQuery=cte.query;if(!(cteQuery instanceof SimpleSelectQuery))throw new Error(`SSSQL refresh requires a simple CTE anchor for ':${parameterName}'.`);return cteQuery}resolveAnchorTargetInQuery(query,anchor,parameterName){let matches=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query).filter(entry=>entry.value instanceof ColumnReference).filter(entry=>normalizeIdentifier2(entry.name)===anchor.column);if(matches.length===0)throw new Error(`SSSQL refresh could not resolve correlated anchor column '${anchor.column}' for ':${parameterName}'.`);if(matches.length>1)throw new Error(`SSSQL refresh found multiple correlated anchor columns '${anchor.column}' for ':${parameterName}'.`);return{query,column:matches[0].value,parameterName}}getSourceAlias(source){let explicitAlias=source.getAliasName();return explicitAlias?normalizeIdentifier2(explicitAlias):source.datasource instanceof TableSource?normalizeIdentifier2(source.datasource.table.name):null}rebaseMovedBranch(expression,sourceQuery,targetColumn){let targetNamespace=targetColumn.qualifiedName.namespaces?targetColumn.qualifiedName.namespaces.map(namespace=>namespace.name):null,targetColumnName=normalizeIdentifier2(targetColumn.column.name),sourceAliases=new Set(collectColumnReferencesDeep(expression).filter(reference=>normalizeIdentifier2(reference.column.name)===targetColumnName).map(reference=>normalizeIdentifier2(reference.getNamespace())).filter(namespace=>namespace.length>0));if(sourceAliases.size===0)return;if(sourceAliases.size>1){let aliases=[...sourceAliases].join(", ");throw new Error(`SSSQL refresh cannot safely rebase '${targetColumn.column.name}' across multiple aliases (${aliases}).`)}let[sourceAlias]=[...sourceAliases];if(new Set((sourceQuery.fromClause?.getSources()??[]).map(source=>source.getAliasName()).filter(alias=>typeof alias=="string").map(alias=>normalizeIdentifier2(alias))).has(sourceAlias))for(let reference of collectColumnReferencesDeep(expression))normalizeIdentifier2(reference.getNamespace())===sourceAlias&&(reference.qualifiedName.namespaces=targetNamespace?.map(namespace=>new IdentifierString(namespace))??null)}rebaseMovedBranchByAlias(expression,sourceAlias,targetColumn){let normalizedSourceAlias=normalizeIdentifier2(sourceAlias);if(!normalizedSourceAlias)return;let targetNamespace=targetColumn.qualifiedName.namespaces?targetColumn.qualifiedName.namespaces.map(namespace=>namespace.name):null;for(let reference of collectColumnReferencesDeep(expression))normalizeIdentifier2(reference.getNamespace())===normalizedSourceAlias&&(reference.qualifiedName.namespaces=targetNamespace?.map(namespace=>new IdentifierString(namespace))??null)}},scaffoldSssqlQuery=(sqlContent,filters)=>({query:new SSSQLFilterBuilder().scaffold(sqlContent,filters)}),refreshSssqlQuery=(sqlContent,filters)=>({query:new SSSQLFilterBuilder().refresh(sqlContent,filters)});var BaseDataFlowNode=class{constructor(id,label,type,shape,details){this.id=id;this.label=label;this.type=type;this.shape=shape;this.details=details}},DataSourceNode=class _DataSourceNode extends BaseDataFlowNode{constructor(id,label,type){super(id,label,type,type==="subquery"?"hexagon":"cylinder");this.annotations=new Set}addAnnotation(annotation){this.annotations.add(annotation)}hasAnnotation(annotation){return this.annotations.has(annotation)}getMermaidRepresentation(){return this.shape==="hexagon"?`${this.id}{{${this.label}}}`:`${this.id}[(${this.label})]`}static createTable(tableName){return new _DataSourceNode(`table_${tableName}`,tableName,"table")}static createCTE(cteName){return new _DataSourceNode(`cte_${cteName}`,`CTE:${cteName}`,"cte")}static createSubquery(alias){return new _DataSourceNode(`subquery_${alias}`,`SubQuery:${alias}`,"subquery")}},ProcessNode=class _ProcessNode extends BaseDataFlowNode{constructor(id,operation,context=""){let nodeId=context?`${context}_${operation.toLowerCase().replace(/\s+/g,"_")}`:operation.toLowerCase().replace(/\s+/g,"_");super(nodeId,operation,"process","hexagon")}getMermaidRepresentation(){return`${this.id}{{${this.label}}}`}static createWhere(context){return new _ProcessNode(`${context}_where`,"WHERE",context)}static createGroupBy(context){return new _ProcessNode(`${context}_group_by`,"GROUP BY",context)}static createHaving(context){return new _ProcessNode(`${context}_having`,"HAVING",context)}static createSelect(context){return new _ProcessNode(`${context}_select`,"SELECT",context)}static createOrderBy(context){return new _ProcessNode(`${context}_order_by`,"ORDER BY",context)}static createLimit(context,hasOffset=!1){let label=hasOffset?"LIMIT/OFFSET":"LIMIT";return new _ProcessNode(`${context}_limit`,label,context)}},OperationNode=class _OperationNode extends BaseDataFlowNode{constructor(id,operation,shape="diamond"){super(id,operation,"operation",shape)}getMermaidRepresentation(){switch(this.shape){case"rounded":return`${this.id}(${this.label})`;case"rectangle":return`${this.id}[${this.label}]`;case"hexagon":return`${this.id}{{${this.label}}}`;case"stadium":return`${this.id}([${this.label}])`;case"diamond":default:return`${this.id}{${this.label}}`}}static createJoin(joinId,joinType){let label,normalizedType=joinType.trim().toLowerCase();return normalizedType==="join"?label="INNER JOIN":normalizedType.endsWith(" join")?label=normalizedType.toUpperCase():label=normalizedType.toUpperCase()+" JOIN",new _OperationNode(`join_${joinId}`,label,"rectangle")}static createUnion(unionId,unionType="UNION ALL"){return new _OperationNode(`${unionType.toLowerCase().replace(/\s+/g,"_")}_${unionId}`,unionType.toUpperCase(),"rectangle")}static createSetOperation(operationId,operation){let normalizedOp=operation.toUpperCase(),id=`${normalizedOp.toLowerCase().replace(/\s+/g,"_")}_${operationId}`;return new _OperationNode(id,normalizedOp,"rectangle")}},OutputNode=class extends BaseDataFlowNode{constructor(context="main"){let label=context==="main"?"Final Result":`${context} Result`;super(`${context}_output`,label,"output","stadium")}getMermaidRepresentation(){return`${this.id}([${this.label}])`}};var DataFlowConnection=class _DataFlowConnection{constructor(from,to,label){this.from=from;this.to=to;this.label=label}getMermaidRepresentation(){let arrow=this.label?` -->|${this.label}| `:" --> ";return`${this.from}${arrow}${this.to}`}static create(from,to,label){return new _DataFlowConnection(from,to,label)}static createWithNullability(from,to,isNullable){let label=isNullable?"NULLABLE":"NOT NULL";return new _DataFlowConnection(from,to,label)}},DataFlowEdgeCollection=class{constructor(){this.edges=[];this.connectionSet=new Set}add(edge){let key=`${edge.from}->${edge.to}`;this.connectionSet.has(key)||(this.edges.push(edge),this.connectionSet.add(key))}addConnection(from,to,label){this.add(DataFlowConnection.create(from,to,label))}addJoinConnection(from,to,isNullable){this.add(DataFlowConnection.createWithNullability(from,to,isNullable))}hasConnection(from,to){return this.connectionSet.has(`${from}->${to}`)}getAll(){return[...this.edges]}getMermaidRepresentation(){return this.edges.map(edge=>edge.getMermaidRepresentation()).join(` `)}};var DataFlowGraph=class{constructor(){this.nodes=new Map;this.edges=new DataFlowEdgeCollection}addNode(node){this.nodes.set(node.id,node)}addEdge(edge){this.edges.add(edge)}addConnection(from,to,label){this.edges.addConnection(from,to,label)}hasNode(nodeId){return this.nodes.has(nodeId)}hasConnection(from,to){return this.edges.hasConnection(from,to)}getNode(nodeId){return this.nodes.get(nodeId)}getAllNodes(){return Array.from(this.nodes.values())}getAllEdges(){return this.edges.getAll()}generateMermaid(direction="TD",title){let mermaid=`flowchart ${direction} `;title&&(mermaid+=` %% ${title} `);let nodeLines=Array.from(this.nodes.values()).map(node=>` ${node.getMermaidRepresentation()}`).join(` @@ -54,4 +54,4 @@ ${query}`}addRestorationComments(sql,targetNode,warnings){let comments=[];return `),this.nodes.size>0&&this.edges.getAll().length>0&&(mermaid+=` `);let edgeRepresentation=this.edges.getMermaidRepresentation();return edgeRepresentation&&(mermaid+=` ${edgeRepresentation} `),mermaid}getOrCreateTable(tableName){let nodeId=`table_${tableName}`,node=this.nodes.get(nodeId);return node||(node=DataSourceNode.createTable(tableName),this.addNode(node)),node}getOrCreateCTE(cteName){let nodeId=`cte_${cteName}`,node=this.nodes.get(nodeId);return node||(node=DataSourceNode.createCTE(cteName),this.addNode(node)),node}getOrCreateSubquery(alias){let nodeId=`subquery_${alias}`,node=this.nodes.get(nodeId);return node||(node=DataSourceNode.createSubquery(alias),this.addNode(node)),node}createProcessNode(type,context){let node=new ProcessNode(context,type);return this.addNode(node),node}createJoinNode(joinId,joinType){let node=OperationNode.createJoin(joinId,joinType);return this.addNode(node),node}createSetOperationNode(operationId,operation){let node=OperationNode.createSetOperation(operationId,operation);return this.addNode(node),node}createOutputNode(context="main"){let node=new OutputNode(context);return this.addNode(node),node}};var DataSourceHandler=class{constructor(graph){this.graph=graph}processSource(sourceExpr,cteNames,queryProcessor){if(sourceExpr.datasource instanceof TableSource)return this.processTableSource(sourceExpr.datasource,cteNames);if(sourceExpr.datasource instanceof SubQuerySource)return this.processSubquerySource(sourceExpr,cteNames,queryProcessor);throw new Error("Unsupported source type")}processTableSource(tableSource,cteNames){let tableName=tableSource.getSourceName();return cteNames.has(tableName)?this.graph.getOrCreateCTE(tableName).id:this.graph.getOrCreateTable(tableName).id}processSubquerySource(sourceExpr,cteNames,queryProcessor){let alias=sourceExpr.aliasExpression?.table.name||"subquery",subqueryNode=this.graph.getOrCreateSubquery(alias),subqueryResultId=queryProcessor(sourceExpr.datasource.query,`subquery_${alias}_internal`,cteNames);return subqueryResultId&&!this.graph.hasConnection(subqueryResultId,subqueryNode.id)&&this.graph.addConnection(subqueryResultId,subqueryNode.id),subqueryNode.id}extractTableNodeIds(fromClause,cteNames){let tableNodeIds=[],sourceExpr=fromClause.source;if(sourceExpr.datasource instanceof TableSource){let tableName=sourceExpr.datasource.getSourceName();if(cteNames.has(tableName)){let cteNode=this.graph.getOrCreateCTE(tableName);tableNodeIds.push(cteNode.id)}else{let tableNode=this.graph.getOrCreateTable(tableName);tableNodeIds.push(tableNode.id)}}if(fromClause.joins&&fromClause.joins.length>0)for(let join of fromClause.joins){let joinSourceExpr=join.source;if(joinSourceExpr.datasource instanceof TableSource){let tableName=joinSourceExpr.datasource.getSourceName();if(cteNames.has(tableName)){let cteNode=this.graph.getOrCreateCTE(tableName);tableNodeIds.push(cteNode.id)}else{let tableNode=this.graph.getOrCreateTable(tableName);tableNodeIds.push(tableNode.id)}}}return tableNodeIds}};var JoinHandler=class{constructor(graph,dataSourceHandler){this.graph=graph;this.dataSourceHandler=dataSourceHandler;this.joinIdCounter=0}resetJoinCounter(){this.joinIdCounter=0}getNextJoinId(){return String(++this.joinIdCounter)}processFromClause(fromClause,cteNames,queryProcessor){let mainSourceId=this.dataSourceHandler.processSource(fromClause.source,cteNames,queryProcessor);return fromClause.joins&&fromClause.joins.length>0?this.processJoins(fromClause.joins,mainSourceId,cteNames,queryProcessor):mainSourceId}processJoins(joins,currentNodeId,cteNames,queryProcessor){let resultNodeId=currentNodeId;for(let join of joins){let joinNodeId=this.dataSourceHandler.processSource(join.source,cteNames,queryProcessor),joinOpId=this.getNextJoinId(),joinNode=this.graph.createJoinNode(joinOpId,join.joinType.value),{leftLabel,rightLabel}=this.getJoinNullabilityLabels(join.joinType.value);resultNodeId&&!this.graph.hasConnection(resultNodeId,joinNode.id)&&this.graph.addConnection(resultNodeId,joinNode.id,leftLabel),joinNodeId&&!this.graph.hasConnection(joinNodeId,joinNode.id)&&this.graph.addConnection(joinNodeId,joinNode.id,rightLabel),resultNodeId=joinNode.id}return resultNodeId}getJoinNullabilityLabels(joinType){switch(joinType.toLowerCase()){case"left join":return{leftLabel:"NOT NULL",rightLabel:"NULLABLE"};case"right join":return{leftLabel:"NULLABLE",rightLabel:"NOT NULL"};case"inner join":case"join":return{leftLabel:"NOT NULL",rightLabel:"NOT NULL"};case"full join":case"full outer join":return{leftLabel:"NULLABLE",rightLabel:"NULLABLE"};case"cross join":return{leftLabel:"NOT NULL",rightLabel:"NOT NULL"};default:return{leftLabel:"",rightLabel:""}}}};var ProcessHandler=class{constructor(graph,dataSourceHandler){this.graph=graph;this.dataSourceHandler=dataSourceHandler}processQueryClauses(query,context,currentNodeId,cteNames,queryProcessor){return currentNodeId}};var CTEHandler=class{constructor(graph){this.graph=graph}processCTEs(withClause,cteNames,queryProcessor){for(let i=0;i0?currentNodeId=this.joinHandler.processFromClause(query.fromClause,cteNames,this.processQuery.bind(this)):currentNodeId=this.dataSourceHandler.processSource(query.fromClause.source,cteNames,this.processQuery.bind(this))),currentNodeId&&(currentNodeId=this.processHandler.processQueryClauses(query,context,currentNodeId,cteNames,this.processQuery.bind(this))),this.handleOutputNode(currentNodeId,context)}processBinaryQuery(query,context,cteNames){let parts=this.flattenBinaryChain(query,query.operator.value);return parts.length>2?this.processMultiPartOperation(parts,query.operator.value,context,cteNames):this.processSimpleBinaryOperation(query,context,cteNames)}processSimpleBinaryOperation(query,context,cteNames){let leftNodeId=this.processQuery(query.left,`${context}_left`,cteNames),rightNodeId=this.processQuery(query.right,`${context}_right`,cteNames),operationId=context==="main"?"main":context.replace(/^cte_/,""),operationNode=this.graph.createSetOperationNode(operationId,query.operator.value);return leftNodeId&&!this.graph.hasConnection(leftNodeId,operationNode.id)&&this.graph.addConnection(leftNodeId,operationNode.id),rightNodeId&&!this.graph.hasConnection(rightNodeId,operationNode.id)&&this.graph.addConnection(rightNodeId,operationNode.id),operationNode.id}processMultiPartOperation(parts,operator,context,cteNames){let partNodes=[],operationId=context==="main"?"main":context.replace(/^cte_/,""),operationNode=this.graph.createSetOperationNode(operationId,operator);for(let i=0;i{q instanceof BinarySelectQuery&&q.operator.value===operator?(collectParts(q.left),collectParts(q.right)):parts.push(q)};return collectParts(query),parts}};var SqlParamInjector=class{constructor(optionsOrResolver,options){typeof optionsOrResolver=="function"?(this.tableColumnResolver=optionsOrResolver,this.options=options||{}):(this.tableColumnResolver=void 0,this.options=optionsOrResolver||{})}inject(query,state){typeof query=="string"&&(query=SelectQueryParser.parse(query));let finder=new UpstreamSelectQueryFinder(this.tableColumnResolver,this.options),collector=new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}),normalize=s=>this.options.ignoreCaseAndUnderscore?s.toLowerCase().replace(/_/g,""):s,allowedOps=["min","max","like","ilike","in","any","=","<",">","!=","<>","<=",">=","or","and","column"],stateValues=Object.values(state);if(stateValues.length>0&&stateValues.every(value=>value===void 0)&&!this.options.allowAllUndefined)throw new Error("All parameters are undefined. This would result in fetching all records. Use allowAllUndefined: true option to explicitly allow this behavior.");let qualifiedParams=[],unqualifiedParams=[];for(let[name,stateValue]of Object.entries(state))stateValue!==void 0&&(this.isQualifiedColumnName(name)?qualifiedParams.push([name,stateValue]):unqualifiedParams.push([name,stateValue]));for(let[name,stateValue]of qualifiedParams)this.processStateParameter(name,stateValue,query,finder,collector,normalize,allowedOps,injectOrConditions,injectAndConditions,injectSimpleCondition,injectComplexConditions,validateOperators);let processedQualifiedColumns=new Set;for(let[qualifiedName,_]of qualifiedParams){let parsed=this.parseQualifiedColumnName(qualifiedName);parsed&&processedQualifiedColumns.add(`${parsed.table.toLowerCase()}.${parsed.column.toLowerCase()}`)}for(let[name,stateValue]of unqualifiedParams)this.processUnqualifiedParameter(name,stateValue,query,finder,collector,normalize,allowedOps,injectOrConditions,injectAndConditions,injectSimpleCondition,injectComplexConditions,validateOperators,processedQualifiedColumns);function injectAndConditions(q,baseName,andConditions,normalize2,availableColumns){for(let i=0;inormalize2(item.name)===normalize2(columnName));if(!entry)throw new Error(`Column '${columnName}' not found in query for AND condition`);let columnRef=entry.value;if("="in andCondition&&andCondition["="]!==void 0){let paramName=`${baseName}_and_${i}_eq`,paramExpr=new ParameterExpression(paramName,andCondition["="]);q.appendWhere(new BinaryExpression(columnRef,"=",paramExpr))}if("min"in andCondition&&andCondition.min!==void 0){let paramName=`${baseName}_and_${i}_min`,paramExpr=new ParameterExpression(paramName,andCondition.min);q.appendWhere(new BinaryExpression(columnRef,">=",paramExpr))}if("max"in andCondition&&andCondition.max!==void 0){let paramName=`${baseName}_and_${i}_max`,paramExpr=new ParameterExpression(paramName,andCondition.max);q.appendWhere(new BinaryExpression(columnRef,"<=",paramExpr))}if("like"in andCondition&&andCondition.like!==void 0){let paramName=`${baseName}_and_${i}_like`,paramExpr=new ParameterExpression(paramName,andCondition.like);q.appendWhere(new BinaryExpression(columnRef,"like",paramExpr))}if("ilike"in andCondition&&andCondition.ilike!==void 0){let paramName=`${baseName}_and_${i}_ilike`,paramExpr=new ParameterExpression(paramName,andCondition.ilike);q.appendWhere(new BinaryExpression(columnRef,"ilike",paramExpr))}if("in"in andCondition&&andCondition.in!==void 0){let prms=andCondition.in.map((v,j)=>new ParameterExpression(`${baseName}_and_${i}_in_${j}`,v));q.appendWhere(new BinaryExpression(columnRef,"in",new ParenExpression(new ValueList(prms))))}if("any"in andCondition&&andCondition.any!==void 0){let paramName=`${baseName}_and_${i}_any`,paramExpr=new ParameterExpression(paramName,andCondition.any);q.appendWhere(new BinaryExpression(columnRef,"=",new FunctionCall(null,"any",paramExpr,null)))}if("<"in andCondition&&andCondition["<"]!==void 0){let paramName=`${baseName}_and_${i}_lt`,paramExpr=new ParameterExpression(paramName,andCondition["<"]);q.appendWhere(new BinaryExpression(columnRef,"<",paramExpr))}if(">"in andCondition&&andCondition[">"]!==void 0){let paramName=`${baseName}_and_${i}_gt`,paramExpr=new ParameterExpression(paramName,andCondition[">"]);q.appendWhere(new BinaryExpression(columnRef,">",paramExpr))}if("!="in andCondition&&andCondition["!="]!==void 0){let paramName=`${baseName}_and_${i}_neq`,paramExpr=new ParameterExpression(paramName,andCondition["!="]);q.appendWhere(new BinaryExpression(columnRef,"!=",paramExpr))}if("<>"in andCondition&&andCondition["<>"]!==void 0){let paramName=`${baseName}_and_${i}_ne`,paramExpr=new ParameterExpression(paramName,andCondition["<>"]);q.appendWhere(new BinaryExpression(columnRef,"<>",paramExpr))}if("<="in andCondition&&andCondition["<="]!==void 0){let paramName=`${baseName}_and_${i}_le`,paramExpr=new ParameterExpression(paramName,andCondition["<="]);q.appendWhere(new BinaryExpression(columnRef,"<=",paramExpr))}if(">="in andCondition&&andCondition[">="]!==void 0){let paramName=`${baseName}_and_${i}_ge`,paramExpr=new ParameterExpression(paramName,andCondition[">="]);q.appendWhere(new BinaryExpression(columnRef,">=",paramExpr))}}}function injectOrConditions(q,baseName,orConditions,normalize2,availableColumns){let orExpressions=[];for(let i=0;inormalize2(item.name)===normalize2(columnName));if(!entry)throw new Error(`Column '${columnName}' not found in query for OR condition`);let columnRef=entry.value,branchConditions=[];if("="in orCondition&&orCondition["="]!==void 0){let paramName=`${baseName}_or_${i}_eq`,paramExpr=new ParameterExpression(paramName,orCondition["="]);branchConditions.push(new BinaryExpression(columnRef,"=",paramExpr))}if("min"in orCondition&&orCondition.min!==void 0){let paramName=`${baseName}_or_${i}_min`,paramExpr=new ParameterExpression(paramName,orCondition.min);branchConditions.push(new BinaryExpression(columnRef,">=",paramExpr))}if("max"in orCondition&&orCondition.max!==void 0){let paramName=`${baseName}_or_${i}_max`,paramExpr=new ParameterExpression(paramName,orCondition.max);branchConditions.push(new BinaryExpression(columnRef,"<=",paramExpr))}if("like"in orCondition&&orCondition.like!==void 0){let paramName=`${baseName}_or_${i}_like`,paramExpr=new ParameterExpression(paramName,orCondition.like);branchConditions.push(new BinaryExpression(columnRef,"like",paramExpr))}if("ilike"in orCondition&&orCondition.ilike!==void 0){let paramName=`${baseName}_or_${i}_ilike`,paramExpr=new ParameterExpression(paramName,orCondition.ilike);branchConditions.push(new BinaryExpression(columnRef,"ilike",paramExpr))}if("in"in orCondition&&orCondition.in!==void 0){let prms=orCondition.in.map((v,j)=>new ParameterExpression(`${baseName}_or_${i}_in_${j}`,v));branchConditions.push(new BinaryExpression(columnRef,"in",new ParenExpression(new ValueList(prms))))}if("any"in orCondition&&orCondition.any!==void 0){let paramName=`${baseName}_or_${i}_any`,paramExpr=new ParameterExpression(paramName,orCondition.any);branchConditions.push(new BinaryExpression(columnRef,"=",new FunctionCall(null,"any",paramExpr,null)))}if("<"in orCondition&&orCondition["<"]!==void 0){let paramName=`${baseName}_or_${i}_lt`,paramExpr=new ParameterExpression(paramName,orCondition["<"]);branchConditions.push(new BinaryExpression(columnRef,"<",paramExpr))}if(">"in orCondition&&orCondition[">"]!==void 0){let paramName=`${baseName}_or_${i}_gt`,paramExpr=new ParameterExpression(paramName,orCondition[">"]);branchConditions.push(new BinaryExpression(columnRef,">",paramExpr))}if("!="in orCondition&&orCondition["!="]!==void 0){let paramName=`${baseName}_or_${i}_neq`,paramExpr=new ParameterExpression(paramName,orCondition["!="]);branchConditions.push(new BinaryExpression(columnRef,"!=",paramExpr))}if("<>"in orCondition&&orCondition["<>"]!==void 0){let paramName=`${baseName}_or_${i}_ne`,paramExpr=new ParameterExpression(paramName,orCondition["<>"]);branchConditions.push(new BinaryExpression(columnRef,"<>",paramExpr))}if("<="in orCondition&&orCondition["<="]!==void 0){let paramName=`${baseName}_or_${i}_le`,paramExpr=new ParameterExpression(paramName,orCondition["<="]);branchConditions.push(new BinaryExpression(columnRef,"<=",paramExpr))}if(">="in orCondition&&orCondition[">="]!==void 0){let paramName=`${baseName}_or_${i}_ge`,paramExpr=new ParameterExpression(paramName,orCondition[">="]);branchConditions.push(new BinaryExpression(columnRef,">=",paramExpr))}if(branchConditions.length>0){let branchExpr=branchConditions[0];for(let j=1;j1?orExpressions.push(new ParenExpression(branchExpr)):orExpressions.push(branchExpr)}}if(orExpressions.length>0){let finalOrExpr=orExpressions[0];for(let i=1;i{if(!allowedOps2.includes(op))throw new Error(`Unsupported operator '${op}' for state key '${name}'`)})}function injectSimpleCondition(q,columnRef,name,stateValue){let paramExpr=new ParameterExpression(name,stateValue);q.appendWhere(new BinaryExpression(columnRef,"=",paramExpr))}function injectComplexConditions(q,columnRef,name,stateValue){let conditions=[];if("="in stateValue){let paramEq=new ParameterExpression(name,stateValue["="]);conditions.push(new BinaryExpression(columnRef,"=",paramEq))}if("min"in stateValue){let paramMin=new ParameterExpression(name+"_min",stateValue.min);conditions.push(new BinaryExpression(columnRef,">=",paramMin))}if("max"in stateValue){let paramMax=new ParameterExpression(name+"_max",stateValue.max);conditions.push(new BinaryExpression(columnRef,"<=",paramMax))}if("like"in stateValue){let paramLike=new ParameterExpression(name+"_like",stateValue.like);conditions.push(new BinaryExpression(columnRef,"like",paramLike))}if("ilike"in stateValue){let paramIlike=new ParameterExpression(name+"_ilike",stateValue.ilike);conditions.push(new BinaryExpression(columnRef,"ilike",paramIlike))}if("in"in stateValue){let prms=stateValue.in.map((v,i)=>new ParameterExpression(`${name}_in_${i}`,v));conditions.push(new BinaryExpression(columnRef,"in",new ParenExpression(new ValueList(prms))))}if("any"in stateValue){let paramAny=new ParameterExpression(name+"_any",stateValue.any);conditions.push(new BinaryExpression(columnRef,"=",new FunctionCall(null,"any",paramAny,null)))}if("<"in stateValue){let paramLT=new ParameterExpression(name+"_lt",stateValue["<"]);conditions.push(new BinaryExpression(columnRef,"<",paramLT))}if(">"in stateValue){let paramGT=new ParameterExpression(name+"_gt",stateValue[">"]);conditions.push(new BinaryExpression(columnRef,">",paramGT))}if("!="in stateValue){let paramNEQ=new ParameterExpression(name+"_neq",stateValue["!="]);conditions.push(new BinaryExpression(columnRef,"!=",paramNEQ))}if("<>"in stateValue){let paramNE=new ParameterExpression(name+"_ne",stateValue["<>"]);conditions.push(new BinaryExpression(columnRef,"<>",paramNE))}if("<="in stateValue){let paramLE=new ParameterExpression(name+"_le",stateValue["<="]);conditions.push(new BinaryExpression(columnRef,"<=",paramLE))}if(">="in stateValue){let paramGE=new ParameterExpression(name+"_ge",stateValue[">="]);conditions.push(new BinaryExpression(columnRef,">=",paramGE))}if(conditions.length===1)q.appendWhere(conditions[0]);else if(conditions.length>1){let combinedExpr=conditions[0];for(let i=1;i0){let targetQuery=this.findTargetQueryForLogicalCondition(finder,query,name,orConditions),allColumns=this.getAllAvailableColumns(targetQuery,collector);injectOrConditions(targetQuery,name,orConditions,normalize,allColumns);return}}if(this.isAndCondition(stateValue)){let andConditions=stateValue.and;if(andConditions&&andConditions.length>0){let targetQuery=this.findTargetQueryForLogicalCondition(finder,query,name,andConditions),allColumns=this.getAllAvailableColumns(targetQuery,collector);injectAndConditions(targetQuery,name,andConditions,normalize,allColumns);return}}if(this.isExplicitColumnMapping(stateValue)){let explicitColumnName=stateValue.column;if(explicitColumnName){let queries=finder.find(query,explicitColumnName);if(queries.length===0)throw new Error(`Explicit column '${explicitColumnName}' not found in query`);for(let q of queries){let entry=this.getAllAvailableColumns(q,collector).find(item=>normalize(item.name)===normalize(explicitColumnName));if(!entry)throw new Error(`Explicit column '${explicitColumnName}' not found in query`);this.isValidatableObject(stateValue)&&validateOperators(stateValue,allowedOps,name),injectComplexConditions(q,entry.value,name,stateValue)}return}}this.processRegularColumnCondition(name,stateValue,query,finder,collector,normalize,allowedOps,injectSimpleCondition,injectComplexConditions,validateOperators)}processUnqualifiedParameter(name,stateValue,query,finder,collector,normalize,allowedOps,injectOrConditions,injectAndConditions,injectSimpleCondition,injectComplexConditions,validateOperators,processedQualifiedColumns){if(this.isOrCondition(stateValue)){let orConditions=stateValue.or;if(orConditions&&orConditions.length>0){let targetQuery=this.findTargetQueryForLogicalCondition(finder,query,name,orConditions),allColumns=this.getAllAvailableColumns(targetQuery,collector);injectOrConditions(targetQuery,name,orConditions,normalize,allColumns);return}}if(this.isAndCondition(stateValue)){let andConditions=stateValue.and;if(andConditions&&andConditions.length>0){let targetQuery=this.findTargetQueryForLogicalCondition(finder,query,name,andConditions),allColumns=this.getAllAvailableColumns(targetQuery,collector);injectAndConditions(targetQuery,name,andConditions,normalize,allColumns);return}}if(this.isExplicitColumnMapping(stateValue)){let explicitColumnName=stateValue.column;if(explicitColumnName){let queries2=finder.find(query,explicitColumnName);if(queries2.length===0)throw new Error(`Explicit column '${explicitColumnName}' not found in query`);for(let q of queries2){let entry=this.getAllAvailableColumns(q,collector).find(item=>normalize(item.name)===normalize(explicitColumnName));if(!entry)throw new Error(`Explicit column '${explicitColumnName}' not found in query`);this.isValidatableObject(stateValue)&&validateOperators(stateValue,allowedOps,name),injectComplexConditions(q,entry.value,name,stateValue)}return}}let queries=finder.find(query,name);if(queries.length===0){if(this.options.ignoreNonExistentColumns)return;throw new Error(`Column '${name}' not found in query`)}for(let q of queries){let allColumns=this.getAllAvailableColumns(q,collector),tableMapping=this.buildTableMapping(q),matchingColumns=allColumns.filter(item=>normalize(item.name)===normalize(name));for(let entry of matchingColumns){let skipColumn=!1;if(entry.value&&typeof entry.value.getNamespace=="function"){let namespace=entry.value.getNamespace();if(namespace){let realTableName=tableMapping.aliasToRealTable.get(namespace.toLowerCase());if(realTableName){let qualifiedKey=`${realTableName.toLowerCase()}.${name.toLowerCase()}`;processedQualifiedColumns.has(qualifiedKey)&&(skipColumn=!0)}}}if(skipColumn)continue;let columnRef=entry.value;this.isValidatableObject(stateValue)&&validateOperators(stateValue,allowedOps,name);let targetColumn=columnRef;if(this.hasColumnMapping(stateValue)){let explicitColumnName=stateValue.column;if(explicitColumnName){let explicitEntry=allColumns.find(item=>normalize(item.name)===normalize(explicitColumnName));explicitEntry&&(targetColumn=explicitEntry.value)}}this.isSimpleValue(stateValue)?injectSimpleCondition(q,targetColumn,name,stateValue):injectComplexConditions(q,targetColumn,name,stateValue)}}}processRegularColumnCondition(name,stateValue,query,finder,collector,normalize,allowedOps,injectSimpleCondition,injectComplexConditions,validateOperators){let searchColumnName=name,targetTableName;if(this.isQualifiedColumnName(name)){let parsed=this.parseQualifiedColumnName(name);parsed&&(searchColumnName=parsed.column,targetTableName=parsed.table)}let queries=finder.find(query,searchColumnName);if(queries.length===0){if(this.options.ignoreNonExistentColumns)return;throw new Error(`Column '${searchColumnName}' not found in query`)}for(let q of queries){let allColumns=this.getAllAvailableColumns(q,collector),entry;if(targetTableName){let tableMapping=this.buildTableMapping(q);if(entry=allColumns.find(item=>{if(!(normalize(item.name)===normalize(searchColumnName)))return!1;if(item.value&&typeof item.value.getNamespace=="function"){let namespace=item.value.getNamespace();if(namespace){let normalizedNamespace=normalize(namespace),normalizedTargetTable=normalize(targetTableName),realTableName=tableMapping.aliasToRealTable.get(normalizedNamespace);if(realTableName&&normalize(realTableName)===normalizedTargetTable)return!0}}return!1}),!entry){if(this.options.ignoreNonExistentColumns)continue;let tableMapping2=this.buildTableMapping(q),hasRealTable=Array.from(tableMapping2.realTableToAlias.keys()).some(realTable=>normalize(realTable)===normalize(targetTableName)),hasAliasTable=Array.from(tableMapping2.aliasToRealTable.keys()).some(alias=>normalize(alias)===normalize(targetTableName));throw!hasRealTable&&!hasAliasTable?new Error(`Column '${name}' (qualified as ${name}) not found in query`):hasAliasTable&&!hasRealTable?new Error(`Column '${name}' not found. Only real table names are allowed in qualified column references (e.g., 'users.name'), not aliases (e.g., 'u.name').`):new Error(`Column '${name}' (qualified as ${name}) not found in query`)}}else if(entry=allColumns.find(item=>normalize(item.name)===normalize(searchColumnName)),!entry)throw new Error(`Column '${searchColumnName}' not found in query`);let columnRef=entry.value;this.isValidatableObject(stateValue)&&validateOperators(stateValue,allowedOps,name);let targetColumn=columnRef;if(this.hasColumnMapping(stateValue)){let explicitColumnName=stateValue.column;if(explicitColumnName){let explicitEntry=allColumns.find(item=>normalize(item.name)===normalize(explicitColumnName));explicitEntry&&(targetColumn=explicitEntry.value)}}let parameterName=this.sanitizeParameterName(name);this.isSimpleValue(stateValue)?injectSimpleCondition(q,targetColumn,parameterName,stateValue):injectComplexConditions(q,targetColumn,parameterName,stateValue)}}findTargetQueryForLogicalCondition(finder,query,baseName,conditions){let referencedColumns=conditions.map(cond=>cond.column||baseName).filter((col,index,arr)=>arr.indexOf(col)===index);for(let colName of referencedColumns){let queries=finder.find(query,colName);if(queries.length>0)return queries[0]}let conditionType=conditions===conditions.or?"OR":"AND";throw new Error(`None of the ${conditionType} condition columns [${referencedColumns.join(", ")}] found in query`)}getAllAvailableColumns(query,collector){let columns=collector.collect(query),cteColumns=this.collectCTEColumns(query);return[...columns,...cteColumns]}collectCTEColumns(query){let cteColumns=[];if(query.withClause)for(let cte of query.withClause.tables)try{let columns=this.collectColumnsFromCteQuery(cte.query);cteColumns.push(...columns)}catch{}return cteColumns}collectColumnsFromCteQuery(query){return this.isSelectQuery(query)?this.collectColumnsFromSelectQuery(query):this.collectColumnsFromReturning(query)}collectColumnsFromSelectQuery(query){return query instanceof SimpleSelectQuery?new SelectableColumnCollector(this.tableColumnResolver,!1,"fullName",{upstream:!0}).collect(query):query instanceof BinarySelectQuery?this.collectColumnsFromSelectQuery(query.left):[]}collectColumnsFromReturning(query){if(query instanceof InsertQuery||query instanceof UpdateQuery||query instanceof DeleteQuery||query instanceof MergeQuery){if(!query.returningClause)return[];let columns=[];for(let item of query.returningClause.items){let columnName=item.identifier?.name??this.extractColumnName(item);columnName&&columns.push({name:columnName,value:item.value})}return columns}return[]}extractColumnName(item){return item.identifier?item.identifier.name:item.value instanceof ColumnReference?item.value.column.name:null}isSelectQuery(query){return"__selectQueryType"in query&&query.__selectQueryType==="SelectQuery"}buildTableMapping(query){let aliasToRealTable=new Map,realTableToAlias=new Map;try{if(query.fromClause&&(this.processSourceForMapping(query.fromClause.source,aliasToRealTable,realTableToAlias),query.fromClause.joins))for(let join of query.fromClause.joins)this.processSourceForMapping(join.source,aliasToRealTable,realTableToAlias);if(query.withClause)for(let cte of query.withClause.tables){let cteAlias=cte.getSourceAliasName();cteAlias&&(aliasToRealTable.set(cteAlias.toLowerCase(),cteAlias),realTableToAlias.set(cteAlias.toLowerCase(),cteAlias))}}catch{}return{aliasToRealTable,realTableToAlias}}processSourceForMapping(source,aliasToRealTable,realTableToAlias){try{if(source.datasource instanceof TableSource){let realTableName=source.datasource.getSourceName(),aliasName=source.aliasExpression?.table?.name||realTableName;realTableName&&aliasName&&(aliasToRealTable.set(aliasName.toLowerCase(),realTableName),realTableToAlias.set(realTableName.toLowerCase(),aliasName),aliasName===realTableName&&aliasToRealTable.set(realTableName.toLowerCase(),realTableName))}}catch{}}};var SchemaManager=class{constructor(schemas){this.schemas=schemas,this.validateSchemas()}validateSchemas(){let tableNames=Object.keys(this.schemas),errors=[];if(Object.entries(this.schemas).forEach(([tableName,table])=>{Object.entries(table.columns).filter(([_,col])=>col.isPrimaryKey).map(([name,_])=>name).length===0&&errors.push(`Table '${tableName}' has no primary key defined`),table.relationships?.forEach(rel=>{tableNames.includes(rel.table)||errors.push(`Table '${tableName}' references unknown table '${rel.table}' in relationship`)})}),errors.length>0)throw new Error(`Schema validation failed:\\n${errors.join("\\n")}`)}getTableColumns(tableName){let table=this.schemas[tableName];return table?Object.keys(table.columns):[]}createTableColumnResolver(){return tableName=>this.getTableColumns(tableName)}getTableNames(){return Object.keys(this.schemas)}getTable(tableName){return this.schemas[tableName]}getPrimaryKey(tableName){let table=this.schemas[tableName];if(!table)return;let primaryKeyEntry=Object.entries(table.columns).find(([_,col])=>col.isPrimaryKey);return primaryKeyEntry?primaryKeyEntry[0]:void 0}getForeignKeys(tableName){let table=this.schemas[tableName];if(!table)return[];let foreignKeys=[];return Object.entries(table.columns).forEach(([columnName,column])=>{column.foreignKey&&foreignKeys.push({column:columnName,referencedTable:column.foreignKey.table,referencedColumn:column.foreignKey.column})}),foreignKeys}};function createSchemaManager(schemas){return new SchemaManager(schemas)}function createTableColumnResolver(schemas){return new SchemaManager(schemas).createTableColumnResolver()}function buildRelationGraphFromCreateTableQueries(queries){let relations=[],byChildTable=new Map,byParentTable=new Map,tableNames=new Set,seen=new Set;for(let query of queries){let childTable=normalizeRelationTableName(buildQualifiedName(query.namespaces,query.tableName.name));tableNames.add(childTable);for(let column of query.columns)for(let constraint of column.constraints){if(constraint.kind!=="references"||!constraint.reference)continue;let edge=createEdge({childTable,parentTable:normalizeRelationTableName(constraint.reference.targetTable.toString()),childColumns:[column.name.name],parentColumns:constraint.reference.columns?.map(item=>item.name)??[],constraintKind:"column-reference",constraintName:constraint.constraintName?.name??null,evidenceKind:"column-reference",confidence:"confirmed"});addEdge(relations,byChildTable,byParentTable,seen,tableNames,edge)}for(let constraint of query.tableConstraints){if(constraint.kind!=="foreign-key"||!constraint.reference)continue;let edge=createEdge({childTable,parentTable:normalizeRelationTableName(constraint.reference.targetTable.toString()),childColumns:constraint.columns?.map(item=>item.name)??[],parentColumns:constraint.reference.columns?.map(item=>item.name)??[],constraintKind:"table-foreign-key",constraintName:constraint.constraintName?.name??null,evidenceKind:"table-foreign-key",confidence:"confirmed"});addEdge(relations,byChildTable,byParentTable,seen,tableNames,edge)}}return{relations,byChildTable,byParentTable,tableNames}}function getOutgoingRelations(graph,childTable){return[...graph.byChildTable.get(normalizeRelationTableName(childTable))??[]]}function getIncomingRelations(graph,parentTable){return[...graph.byParentTable.get(normalizeRelationTableName(parentTable))??[]]}function addEdge(relations,byChildTable,byParentTable,seen,tableNames,edge){tableNames.add(edge.childTable),tableNames.add(edge.parentTable);let signature=[edge.childTable,edge.parentTable,edge.childColumns.join(","),edge.parentColumns.join(","),edge.constraintKind].join("|");seen.has(signature)||(seen.add(signature),relations.push(edge),pushIndexedEdge(byChildTable,edge.childTable,edge),pushIndexedEdge(byParentTable,edge.parentTable,edge))}function pushIndexedEdge(index,key,edge){let bucket=index.get(key)??[];bucket.push(edge),index.set(key,bucket)}function createEdge(params){return{childTable:params.childTable,parentTable:params.parentTable,childColumns:[...params.childColumns],parentColumns:[...params.parentColumns],constraintKind:params.constraintKind,constraintName:params.constraintName,evidenceKind:params.evidenceKind,confidence:params.confidence,isSelfReference:params.childTable===params.parentTable}}function buildQualifiedName(namespaces,name){return[...namespaces??[],name].join(".")}function normalizeRelationTableName(tableName){return normalizeTableName(tableName)}function getQualifiedNameText(value){return normalizeRelationTableName(value.toString())}var KeywordCache=class{static{this.joinSuggestionCache=new Map}static{this.commandSuggestionCache=new Map}static{this.initialized=!1}static initialize(){if(this.initialized)return;let joinPatterns=[["join"],["inner","join"],["cross","join"],["left","join"],["left","outer","join"],["right","join"],["right","outer","join"],["full","join"],["full","outer","join"],["natural","join"],["natural","inner","join"],["natural","left","join"],["natural","left","outer","join"],["natural","right","join"],["natural","right","outer","join"],["natural","full","join"],["natural","full","outer","join"],["lateral","join"],["lateral","inner","join"],["lateral","left","join"],["lateral","left","outer","join"]],suggestionMap=new Map,completePhrases=new Set;joinPatterns.forEach(pattern=>{pattern.length>1&&completePhrases.add(pattern.slice(1).join(" ").toUpperCase())}),joinPatterns.forEach(pattern=>{for(let i=0;i{if(candidatePattern.length>i+1&&candidatePattern[i]===prefix){let completePhrase=candidatePattern.slice(i+1).join(" ").toUpperCase();suggestionMap.get(prefix).add(completePhrase)}})}}),suggestionMap.forEach((suggestions,keyword)=>{this.joinSuggestionCache.set(keyword.toLowerCase(),Array.from(suggestions))}),this.initializeCommandKeywords(),this.initialized=!0}static getJoinSuggestions(keyword){return this.initialize(),this.joinSuggestionCache.get(keyword.toLowerCase())||[]}static isValidJoinKeyword(keyword){return joinkeywordParser.parse(keyword,0)!==null}static getPartialSuggestions(partialKeyword){this.initialize();let partial=partialKeyword.toLowerCase(),suggestions=[];return this.joinSuggestionCache.forEach((values,key)=>{key.startsWith(partial)&&(suggestions.push(key),values.forEach(value=>{suggestions.includes(value)||suggestions.push(value)}))}),suggestions}static getAllJoinKeywords(){this.initialize();let allKeywords=new Set;return this.joinSuggestionCache.forEach((values,key)=>{allKeywords.add(key),values.forEach(value=>allKeywords.add(value))}),Array.from(allKeywords)}static initializeCommandKeywords(){let commandPatterns=this.extractCommandPatternsFromTrie(),suggestionMap=new Map;commandPatterns.forEach(pattern=>{for(let i=0;i{this.commandSuggestionCache.set(keyword.toLowerCase(),Array.from(suggestions))})}static extractCommandPatternsFromTrie(){return[["group","by"],["order","by"],["distinct","on"],["not","materialized"],["row","only"],["rows","only"],["percent","with","ties"],["key","share"],["no","key","update"],["union","all"],["intersect","all"],["except","all"],["partition","by"],["within","group"],["with","ordinality"]]}static getCommandSuggestions(keyword){return this.initialize(),this.commandSuggestionCache.get(keyword.toLowerCase())||[]}static reset(){this.joinSuggestionCache.clear(),this.commandSuggestionCache.clear(),this.initialized=!1}};var CursorContextAnalyzer=class{static{this.patternCache=null}static getKeywordPatterns(){if(this.patternCache!==null)return this.patternCache;let requiresKeywords=new Map,suggestsTables=new Set,suggestsColumns=new Set;return this.extractKeywordPatterns(requiresKeywords,suggestsTables,suggestsColumns),this.patternCache={requiresKeywords,suggestsTables,suggestsColumns},this.patternCache}static extractKeywordPatterns(requiresKeywords,suggestsTables,suggestsColumns){let tableContexts=["from","join"],columnContexts=["select","where","on","having","by"];for(let keyword of tableContexts)this.isKeywordInDictionary(keyword)&&suggestsTables.add(keyword);for(let keyword of columnContexts)this.isKeywordInDictionary(keyword)&&suggestsColumns.add(keyword);this.extractRequiresKeywordPatterns(requiresKeywords)}static isKeywordInDictionary(keyword){return KeywordCache.isValidJoinKeyword(keyword)?!0:["from","join","select","where","on","having","by","group","order"].includes(keyword)}static extractRequiresKeywordPatterns(requiresKeywords){let potentialFirstWords=["inner","left","right","full","cross","natural","outer","group","order"];for(let word of potentialFirstWords){let possibleFollowups=this.findPossibleFollowups(word);possibleFollowups.length>0&&requiresKeywords.set(word,possibleFollowups)}}static findPossibleFollowups(word){let followups=new Set;return KeywordCache.getJoinSuggestions(word.toLowerCase()).forEach(s=>followups.add(s.toUpperCase())),KeywordCache.getCommandSuggestions(word.toLowerCase()).forEach(s=>followups.add(s.toUpperCase())),Array.from(followups)}static requiresSpecificKeywords(tokenValue){let requiredKeywords=this.getKeywordPatterns().requiresKeywords.get(tokenValue);return requiredKeywords?{suggestKeywords:!0,requiredKeywords}:null}static analyzeIntelliSense(sql,cursorPosition){try{let allLexemes=LexemeCursor.getAllLexemesWithPosition(sql),actualTokenIndex=-1,actualCurrentToken;for(let i=0;i=lexeme.position.startPosition&&cursorPosition<=lexeme.position.endPosition){actualCurrentToken=lexeme,actualTokenIndex=i;break}else if(lexeme.position.startPosition>cursorPosition){actualTokenIndex=Math.max(0,i-1),actualCurrentToken=actualTokenIndex>=0?allLexemes[actualTokenIndex]:void 0;break}}}actualTokenIndex===-1&&allLexemes.length>0&&(actualTokenIndex=allLexemes.length-1,actualCurrentToken=allLexemes[actualTokenIndex]);let previousToken=actualTokenIndex>0?allLexemes[actualTokenIndex-1]:void 0;if(this.isAfterDot(sql,cursorPosition,previousToken))return{suggestTables:!1,suggestColumns:!0,suggestKeywords:!1,tableScope:this.findPrecedingIdentifier(sql,cursorPosition,allLexemes),currentToken:actualCurrentToken,previousToken};if(actualCurrentToken){let currentValue=actualCurrentToken.value.toLowerCase(),keywordRequirement=this.requiresSpecificKeywords(currentValue);if(keywordRequirement)return{suggestTables:!1,suggestColumns:!1,...keywordRequirement,currentToken:actualCurrentToken,previousToken}}let tokenValue=actualCurrentToken?.value.toLowerCase(),prevValue=previousToken?.value.toLowerCase();if(tokenValue){let patterns=this.getKeywordPatterns();if(patterns.suggestsTables.has(tokenValue))return{suggestTables:!0,suggestColumns:!1,suggestKeywords:!1,currentToken:actualCurrentToken,previousToken};if(patterns.suggestsColumns.has(tokenValue))return{suggestTables:!1,suggestColumns:!0,suggestKeywords:!1,currentToken:actualCurrentToken,previousToken}}if(prevValue){let patterns=this.getKeywordPatterns(),keywordRequirement=this.requiresSpecificKeywords(prevValue);if(keywordRequirement&&tokenValue!=="join"&&tokenValue!=="outer"&&tokenValue!=="by")return{suggestTables:!1,suggestColumns:!1,...keywordRequirement,currentToken:actualCurrentToken,previousToken};if(patterns.suggestsTables.has(prevValue))return{suggestTables:!0,suggestColumns:!1,suggestKeywords:!1,currentToken:actualCurrentToken,previousToken};if(patterns.suggestsColumns.has(prevValue))return{suggestTables:!1,suggestColumns:!0,suggestKeywords:!1,currentToken:actualCurrentToken,previousToken}}return{suggestTables:!1,suggestColumns:!1,suggestKeywords:!0,currentToken:actualCurrentToken,previousToken}}catch{return{suggestTables:!1,suggestColumns:!1,suggestKeywords:!1}}}static analyzeIntelliSenseAt(sql,position){let charOffset=TextPositionUtils.lineColumnToCharOffset(sql,position);return charOffset===-1?{suggestTables:!1,suggestColumns:!1,suggestKeywords:!1}:this.analyzeIntelliSense(sql,charOffset)}static isAfterDot(sql,cursorPosition,previousToken){if(cursorPosition>0&&sql[cursorPosition-1]==="."||previousToken&&previousToken.value===".")return!0;let pos=cursorPosition-1;for(;pos>=0&&/\s/.test(sql[pos]);)pos--;return pos>=0&&sql[pos]==="."}static findPrecedingIdentifier(sql,cursorPosition,lexemes){if(this.isAfterDot(sql,cursorPosition)){let pos=cursorPosition-1;for(;pos>=0&&/\s/.test(sql[pos]);)pos--;if(pos>=0&&sql[pos]==="."){let identifierEnd=pos;for(;pos>=0&&/\s/.test(sql[pos]);)pos--;for(;pos>=0&&/[a-zA-Z0-9_]/.test(sql[pos]);)pos--;let identifierStart=pos+1;if(identifierStart=0;i--)if(lexemes[i].value==="."&&lexemes[i].position&&lexemes[i].position.startPosition0&&this.isIdentifier(lexemes[i-1]))return lexemes[i-1].value;break}}}static isIdentifier(lexeme){return/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(lexeme.value)}};var ScopeResolver=class{static resolve(sql,cursorPosition){return this.createEmptyScope()}static resolveAt(sql,position){let charOffset=TextPositionUtils.lineColumnToCharOffset(sql,position);return charOffset===-1?this.createEmptyScope():this.resolve(sql,charOffset)}static getColumnsForTable(sql,cursorPosition,tableOrAlias){let scope=this.resolve(sql,cursorPosition),table=scope.availableTables.find(t=>t.name===tableOrAlias||t.alias===tableOrAlias);return table?scope.visibleColumns.filter(col=>col.tableName===table.name||table.alias&&col.tableAlias===table.alias):[]}static analyzeScopeFromQuery(query){let scope={availableTables:[],availableCTEs:[],subqueryLevel:0,visibleColumns:[],currentQuery:query,parentQueries:[]};if(query instanceof SimpleSelectQuery)scope.availableCTEs=this.collectCTEs(query),scope.availableTables=this.collectTablesFromQuery(query),scope.visibleColumns=this.collectVisibleColumns(scope.availableTables,scope.availableCTEs);else if(query instanceof BinarySelectQuery){let leftScope=this.analyzeScopeFromQuery(query.left),rightScope=this.analyzeScopeFromQuery(query.right);scope.availableTables=[...leftScope.availableTables,...rightScope.availableTables],scope.availableCTEs=[...leftScope.availableCTEs,...rightScope.availableCTEs],scope.visibleColumns=[...leftScope.visibleColumns,...rightScope.visibleColumns]}return scope}static collectCTEs(query){let ctes=[];if(query.withClause){let collectedCTEs=new CTECollector().collect(query);for(let cte of collectedCTEs)ctes.push({name:cte.getSourceAliasName(),query:cte.query,columns:this.extractCTEColumns(cte.query),materialized:cte.materialized||!1})}return ctes}static collectTablesFromQuery(query){let tables=[];if(query.fromClause){let fromTables=this.extractTablesFromFromClause(query.fromClause);tables.push(...fromTables)}return tables}static extractTablesFromFromClause(fromClause){let tables=[];if(fromClause.source.datasource instanceof TableSource){let table={name:this.extractTableName(fromClause.source.datasource.qualifiedName),alias:fromClause.source.aliasExpression?.table.name,schema:this.extractSchemaName(fromClause.source.datasource.qualifiedName),fullName:this.getQualifiedNameString(fromClause.source.datasource.qualifiedName),sourceType:"table"};tables.push(table)}else if(fromClause.source.datasource instanceof SubQuerySource){let table={name:fromClause.source.aliasExpression?.table.name||"subquery",alias:fromClause.source.aliasExpression?.table.name,fullName:fromClause.source.aliasExpression?.table.name||"subquery",sourceType:"subquery",originalQuery:fromClause.source.datasource.query};tables.push(table)}if(fromClause.joins)for(let join of fromClause.joins){let joinTables=this.extractTablesFromJoin(join);tables.push(...joinTables)}return tables}static extractTablesFromJoin(join){let tables=[];if(join.source.datasource instanceof TableSource){let table={name:this.extractTableName(join.source.datasource.qualifiedName),alias:join.source.aliasExpression?.table.name,schema:this.extractSchemaName(join.source.datasource.qualifiedName),fullName:this.getQualifiedNameString(join.source.datasource.qualifiedName),sourceType:"table"};tables.push(table)}else if(join.source.datasource instanceof SubQuerySource){let table={name:join.source.aliasExpression?.table.name||"subquery",alias:join.source.aliasExpression?.table.name,fullName:join.source.aliasExpression?.table.name||"subquery",sourceType:"subquery",originalQuery:join.source.datasource.query};tables.push(table)}return tables}static getQualifiedNameString(qualifiedName){return qualifiedName.toString()}static extractTableName(qualifiedName){let parts=this.getQualifiedNameString(qualifiedName).split(".");return parts[parts.length-1]}static extractSchemaName(qualifiedName){let parts=this.getQualifiedNameString(qualifiedName).split(".");return parts.length>1?parts[parts.length-2]:void 0}static extractCTEColumns(query){try{if(this.isSelectQuery(query))return query instanceof SimpleSelectQuery&&query.selectClause?this.extractColumnsFromItems(query.selectClause.items):void 0;if((query instanceof InsertQuery||query instanceof UpdateQuery||query instanceof DeleteQuery||query instanceof MergeQuery)&&query.returningClause)return this.extractColumnsFromItems(query.returningClause.items)}catch{}}static extractColumnsFromItems(items){let columns=[];for(let item of items){if(item.identifier){columns.push(item.identifier.name);continue}let columnName=this.extractColumnNameFromExpression(item.value);columnName&&columns.push(columnName)}return columns.length>0?columns:void 0}static extractColumnNameFromExpression(expression){if(expression instanceof ColumnReference)return expression.column.name;if(expression&&typeof expression=="object"&&"value"in expression)return expression.value}static isSelectQuery(query){return"__selectQueryType"in query&&query.__selectQueryType==="SelectQuery"}static collectVisibleColumns(tables,ctes){let columns=[];for(let cte of ctes)if(cte.columns)for(let columnName of cte.columns)columns.push({name:columnName,tableName:cte.name,fullReference:`${cte.name}.${columnName}`});for(let table of tables)table.sourceType==="table"&&columns.push({name:"*",tableName:table.name,tableAlias:table.alias,fullReference:`${table.alias||table.name}.*`});return columns}static createEmptyScope(){return{availableTables:[],availableCTEs:[],subqueryLevel:0,visibleColumns:[],parentQueries:[]}}};var PositionAwareParser=class{static parseToPosition(sql,cursorPosition,options={}){let charPosition=typeof cursorPosition=="number"?cursorPosition:TextPositionUtils.lineColumnToCharOffset(sql,cursorPosition);if(charPosition===-1)return{success:!1,error:"Invalid cursor position",stoppedAtCursor:!1};try{let normalResult=this.tryNormalParse(sql,charPosition,options);return normalResult.success?normalResult:options.errorRecovery?this.tryErrorRecovery(sql,charPosition,options):normalResult}catch(error){return{success:!1,error:error instanceof Error?error.message:String(error),stoppedAtCursor:!1}}}static parseCurrentQuery(sql,cursorPosition,options={}){let charPosition=typeof cursorPosition=="number"?cursorPosition:TextPositionUtils.lineColumnToCharOffset(sql,cursorPosition);if(charPosition===-1)return{success:!1,error:"Invalid cursor position",stoppedAtCursor:!1};let queryBoundaries=this.findQueryBoundaries(sql),currentQuery=this.findQueryAtPosition(queryBoundaries,charPosition);if(!currentQuery)return{success:!1,error:"No query found at cursor position",stoppedAtCursor:!1};let relativePosition=charPosition-currentQuery.start,querySQL=sql.substring(currentQuery.start,currentQuery.end);return this.parseToPosition(querySQL,relativePosition,options)}static tryNormalParse(sql,cursorPosition,options){if(cursorPosition<0||cursorPosition>sql.length)return{success:!1,error:"Invalid cursor position",stoppedAtCursor:!1};let trimmedSql=sql.trim(),appearsIncomplete=[".",",","SELECT","FROM","WHERE","JOIN","ON","GROUP BY","ORDER BY"].some(pattern=>trimmedSql.toLowerCase().endsWith(pattern.toLowerCase())),analysisResult=SelectQueryParser.analyze(sql);if(!analysisResult.success||appearsIncomplete)return{...analysisResult,success:!1};let allTokens=this.getAllTokens(sql),cursorToken=this.findTokenAtPosition(allTokens,cursorPosition),beforeCursor=this.findTokenBeforePosition(allTokens,cursorPosition);return{...analysisResult,parsedTokens:allTokens,tokenBeforeCursor:beforeCursor,stoppedAtCursor:cursorPositionthis.recoverWithTokenInsertion(sql,cursorPosition,options),()=>this.recoverWithTruncation(sql,cursorPosition,options),()=>this.recoverWithCompletion(sql,cursorPosition,options),()=>this.recoverWithMinimalSQL(sql,cursorPosition,options)];for(let strategy of strategies){if(attempts>=maxAttempts)break;attempts++;try{let result=strategy();if(result.success)return result.recoveryAttempts=attempts,result}catch{continue}}return{success:!1,error:"All error recovery attempts failed",recoveryAttempts:attempts,stoppedAtCursor:!1}}static recoverWithTokenInsertion(sql,cursorPosition,options){if(!options.insertMissingTokens)throw new Error("Token insertion disabled");let fixes=[{pattern:/SELECT\s*$/i,replacement:"SELECT 1 "},{pattern:/FROM\s*$/i,replacement:"FROM dual "},{pattern:/WHERE\s*$/i,replacement:"WHERE 1=1 "},{pattern:/JOIN\s*$/i,replacement:"JOIN dual ON 1=1 "},{pattern:/ON\s*$/i,replacement:"ON 1=1 "},{pattern:/GROUP\s+BY\s*$/i,replacement:"GROUP BY 1 "},{pattern:/ORDER\s+BY\s*$/i,replacement:"ORDER BY 1 "}],fixedSQL=sql;for(let fix of fixes)if(fix.pattern.test(sql)){fixedSQL=sql.replace(fix.pattern,fix.replacement);break}if(fixedSQL===sql)throw new Error("No applicable token insertion found");let result=SelectQueryParser.analyze(fixedSQL),tokens=this.getAllTokens(sql);return{...result,parsedTokens:tokens,tokenBeforeCursor:this.findTokenBeforePosition(tokens,cursorPosition),stoppedAtCursor:!0,recoveryAttempts:1}}static recoverWithTruncation(sql,cursorPosition,options){let truncated=sql.substring(0,cursorPosition),completions=[""," 1"," FROM dual"," WHERE 1=1"];for(let completion of completions)try{let testSQL=truncated+completion,result=SelectQueryParser.analyze(testSQL);if(result.success){let tokens=this.getAllTokens(sql);return{...result,parsedTokens:tokens.filter(t=>t.position&&t.position.startPosition<=cursorPosition),tokenBeforeCursor:this.findTokenBeforePosition(tokens,cursorPosition),stoppedAtCursor:!0,recoveryAttempts:1}}}catch{continue}throw new Error("Truncation recovery failed")}static recoverWithCompletion(sql,cursorPosition,options){let beforeCursor=sql.substring(0,cursorPosition),afterCursor=sql.substring(cursorPosition),completions=[{pattern:/\.\s*$/,completion:"id"},{pattern:/\w+\s*$/,completion:""},{pattern:/,\s*$/,completion:"1"},{pattern:/\(\s*$/,completion:"1)"}];for(let comp of completions)if(comp.pattern.test(beforeCursor)){let testSQL=beforeCursor+comp.completion+afterCursor;try{let result=SelectQueryParser.analyze(testSQL);if(result.success){let tokens=this.getAllTokens(sql);return{...result,parsedTokens:tokens,tokenBeforeCursor:this.findTokenBeforePosition(tokens,cursorPosition),stoppedAtCursor:!0,recoveryAttempts:1}}}catch{continue}}throw new Error("Completion recovery failed")}static recoverWithMinimalSQL(sql,cursorPosition,options){let minimalSQL="SELECT 1 FROM dual WHERE 1=1";try{let result=SelectQueryParser.analyze(minimalSQL),tokens=this.getAllTokens(sql);return{success:!0,query:result.query,parsedTokens:tokens.filter(t=>t.position&&t.position.startPosition<=cursorPosition),tokenBeforeCursor:this.findTokenBeforePosition(tokens,cursorPosition),stoppedAtCursor:!0,partialAST:result.query,recoveryAttempts:1}}catch{throw new Error("Minimal SQL recovery failed")}}static getAllTokens(sql){try{return LexemeCursor.getAllLexemesWithPosition(sql)}catch{return[]}}static findTokenAtPosition(tokens,position){return tokens.find(token=>token.position&&position>=token.position.startPosition&&positionposition>=boundary.start&&position<=boundary.end)}};function parseToPosition(sql,cursorPosition,options={}){return PositionAwareParser.parseToPosition(sql,cursorPosition,options)}function getCursorContext(sql,cursorPosition){return typeof cursorPosition=="number"?CursorContextAnalyzer.analyzeIntelliSense(sql,cursorPosition):CursorContextAnalyzer.analyzeIntelliSenseAt(sql,cursorPosition)}function resolveScope(sql,cursorPosition){return typeof cursorPosition=="number"?ScopeResolver.resolve(sql,cursorPosition):ScopeResolver.resolveAt(sql,cursorPosition)}function splitQueries(sql){return MultiQuerySplitter.split(sql)}function getIntelliSenseInfo(sql,cursorPosition,options={}){let charPos=typeof cursorPosition=="number"?cursorPosition:TextPositionUtils.lineColumnToCharOffset(sql,cursorPosition);if(charPos===-1)return;let activeQuery=splitQueries(sql).getActive(charPos);if(!activeQuery)return;let relativePosition=charPos-activeQuery.start,querySQL=activeQuery.sql,context=getCursorContext(querySQL,relativePosition),scope=resolveScope(querySQL,relativePosition),parseResult=parseToPosition(querySQL,relativePosition,options);return{context,scope,parseResult,currentQuery:querySQL,relativePosition}}function getCompletionSuggestions(sql,cursorPosition){let charPos=typeof cursorPosition=="number"?cursorPosition:TextPositionUtils.lineColumnToCharOffset(sql,cursorPosition);if(charPos===-1)return[];let intelliSenseContext=CursorContextAnalyzer.analyzeIntelliSense(sql,charPos),scope=resolveScope(sql,cursorPosition),suggestions=[];return intelliSenseContext.suggestKeywords&&(intelliSenseContext.requiredKeywords?intelliSenseContext.requiredKeywords.forEach(keyword=>{suggestions.push({type:"keyword",value:keyword,detail:`Required keyword: ${keyword}`})}):getGeneralKeywords(intelliSenseContext).forEach(keyword=>{suggestions.push({type:"keyword",value:keyword.value,detail:keyword.detail})})),intelliSenseContext.suggestTables&&(scope.availableTables.forEach(table=>{suggestions.push({type:"table",value:table.alias||table.name,detail:`Table: ${table.fullName}`,documentation:`Available table${table.alias?` (alias: ${table.alias})`:""}`})}),scope.availableCTEs.forEach(cte=>{suggestions.push({type:"cte",value:cte.name,detail:`CTE: ${cte.name}`,documentation:`Common Table Expression${cte.columns?` with columns: ${cte.columns.join(", ")}`:""}`})})),intelliSenseContext.suggestColumns&&(intelliSenseContext.tableScope?scope.visibleColumns.filter(col=>col.tableName===intelliSenseContext.tableScope||col.tableAlias===intelliSenseContext.tableScope).forEach(col=>{suggestions.push({type:"column",value:col.name,detail:`Column: ${col.fullReference}`,documentation:`Column from ${col.tableName}${col.type?` (${col.type})`:""}`})}):scope.visibleColumns.forEach(col=>{suggestions.push({type:"column",value:col.name==="*"?"*":`${col.tableAlias||col.tableName}.${col.name}`,detail:`Column: ${col.fullReference}`,documentation:`Column from ${col.tableName}`})})),suggestions}function getGeneralKeywords(context){let prevToken=context.previousToken?.value?.toLowerCase(),currentToken=context.currentToken?.value?.toLowerCase();return prevToken==="select"||currentToken==="select"?[{value:"DISTINCT",detail:"Remove duplicate rows"},{value:"COUNT",detail:"Aggregate function"},{value:"SUM",detail:"Aggregate function"},{value:"AVG",detail:"Aggregate function"},{value:"MAX",detail:"Aggregate function"},{value:"MIN",detail:"Aggregate function"}]:prevToken==="from"||currentToken==="from"?[{value:"JOIN",detail:"Inner join tables"},{value:"LEFT JOIN",detail:"Left outer join"},{value:"RIGHT JOIN",detail:"Right outer join"},{value:"FULL JOIN",detail:"Full outer join"},{value:"WHERE",detail:"Filter conditions"},{value:"GROUP BY",detail:"Group results"},{value:"ORDER BY",detail:"Sort results"}]:["where","having","on"].includes(prevToken||"")||["where","having","on"].includes(currentToken||"")?[{value:"AND",detail:"Logical AND operator"},{value:"OR",detail:"Logical OR operator"},{value:"NOT",detail:"Logical NOT operator"},{value:"IN",detail:"Match any value in list"},{value:"LIKE",detail:"Pattern matching"},{value:"BETWEEN",detail:"Range comparison"}]:[{value:"SELECT",detail:"Query data"},{value:"FROM",detail:"Specify table"},{value:"WHERE",detail:"Filter conditions"},{value:"JOIN",detail:"Join tables"},{value:"GROUP BY",detail:"Group results"},{value:"ORDER BY",detail:"Sort results"},{value:"LIMIT",detail:"Limit results"}]}export{AliasRenamer,AlterSequenceStatement,AlterTableAddColumn,AlterTableAddConstraint,AlterTableAlterColumnDefault,AlterTableDropColumn,AlterTableDropConstraint,AlterTableParser,AlterTableStatement,AnalyzeStatement,ArrayExpression,ArrayIndexExpression,ArrayQueryExpression,ArraySliceExpression,BetweenExpression,BinaryExpression,BinarySelectQuery,CTECollector,CTEComposer,CTEDependencyAnalyzer,CTEDisabler,CTENormalizer,CTENotFoundError,CTEQueryDecomposer,CTERegionDetector,CTERenamer,CTETableReferenceCollector,CaseExpression,CaseKeyValuePair,CastExpression,CheckpointStatement,CheckpointStatementParser,ClusterStatement,ClusterStatementParser,ColumnConstraintDefinition,ColumnReference,ColumnReferenceCollector,CommentEditor,CommentOnParser,CommentOnStatement,CommonTable,CreateIndexParser,CreateIndexStatement,CreateSchemaStatement,CreateSequenceStatement,CreateTableParser,CreateTableQuery,CursorContextAnalyzer,DDLDiffGenerator,DDLGeneralizer,DDLToFixtureConverter,DeleteClause,DeleteQuery,DeleteQueryParser,DeleteResultSelectConverter,Distinct,DistinctOn,DropConstraintParser,DropConstraintStatement,DropIndexParser,DropIndexStatement,DropSchemaStatement,DropTableParser,DropTableStatement,DuplicateCTEError,DuplicateDetectionMode,DynamicQueryBuilder,ExplainOption,ExplainStatement,FetchClause,FetchExpression,FetchType,FetchUnit,FilterableItem,FilterableItemCollector,FixtureCteBuilder,ForClause,Formatter,FromClause,FunctionCall,FunctionSource,GroupByClause,HavingClause,IdentifierString,IndexColumnDefinition,InlineQuery,InsertClause,InsertQuery,InsertQueryParser,InsertQuerySelectValuesConverter,InsertResultSelectConverter,InvalidCTENameError,JoinClause,JoinOnClause,JoinUsingClause,LexemeCursor,LimitClause,LiteralValue,LockMode,MergeAction,MergeDeleteAction,MergeDoNothingAction,MergeInsertAction,MergeQuery,MergeQueryParser,MergeResultSelectConverter,MergeUpdateAction,MergeWhenClause,MultiQuerySplitter,MultiQueryUtils,NullsSortDirection,OffsetClause,OrderByClause,OrderByItem,OriginalFormatRestorer,ParameterExpression,ParameterHelper,ParenExpression,ParenSource,PartitionByClause,PositionAwareParser,QualifiedName,QueryBuilder,QueryFlowDiagramGenerator,RawString,ReferenceDefinition,ReindexStatement,ReindexStatementParser,ReturningClause,SSSQLFilterBuilder,SchemaCollector,SchemaManager,ScopeResolver,SelectClause,SelectItem,SelectQueryParser,SelectResultSelectConverter,SelectValueCollector,SelectableColumnCollector,SetClause,SetClauseItem,SimpleSelectQuery,SimulatedSelectConverter,SmartRenamer,SortDirection,SourceAliasExpression,SourceExpression,SqlComponent,SqlDialectConfiguration,SqlFormatter,SqlIdentifierRenamer,SqlPaginationInjector,SqlParamInjector,SqlParameterBinder,SqlParser,SqlSchemaValidator,SqlSortInjector,SqlTokenizer,StringSpecifierExpression,SubQuerySource,SwitchCaseArgument,TableColumnDefinition,TableConstraintDefinition,TableSchema,TableSource,TableSourceCollector,TokenType,TupleExpression,TypeValue,UnaryExpression,UpdateClause,UpdateQuery,UpdateQueryParser,UpdateResultSelectConverter,UpstreamSelectQueryFinder,UsingClause,VALID_PRESETS,VacuumStatement,VacuumStatementParser,ValueList,ValuesQuery,WhereClause,WindowFrameBound,WindowFrameBoundStatic,WindowFrameBoundaryValue,WindowFrameClause,WindowFrameExpression,WindowFrameSpec,WindowFrameType,WindowsClause,WithClause,WithClauseParser,buildRelationGraphFromCreateTableQueries,collectSupportedOptionalConditionBranches,createSchemaManager,createTableColumnResolver,createTableDefinitionFromCreateTableQuery,createTableDefinitionRegistryFromCreateTableQueries,createTableDefinitionRegistryFromSchema,getCompletionSuggestions,getCursorContext,getIncomingRelations,getIntelliSenseInfo,getOutgoingRelations,getQualifiedNameText,normalizeTableName,optimizeUnusedCtes,optimizeUnusedCtesToFixedPoint,optimizeUnusedLeftJoins,optimizeUnusedLeftJoinsToFixedPoint,parseToPosition,pruneOptionalConditionBranches,refreshSssqlQuery,resolveScope,scaffoldSssqlQuery,splitQueries,tableNameVariants}; +`){inComment=!1;continue}!inString&&!inComment&&char===";"&&(boundaries.push({start:currentStart,end:i}),currentStart=i+1)}return currentStartposition>=boundary.start&&position<=boundary.end)}};function parseToPosition(sql,cursorPosition,options={}){return PositionAwareParser.parseToPosition(sql,cursorPosition,options)}function getCursorContext(sql,cursorPosition){return typeof cursorPosition=="number"?CursorContextAnalyzer.analyzeIntelliSense(sql,cursorPosition):CursorContextAnalyzer.analyzeIntelliSenseAt(sql,cursorPosition)}function resolveScope(sql,cursorPosition){return typeof cursorPosition=="number"?ScopeResolver.resolve(sql,cursorPosition):ScopeResolver.resolveAt(sql,cursorPosition)}function splitQueries(sql){return MultiQuerySplitter.split(sql)}function getIntelliSenseInfo(sql,cursorPosition,options={}){let charPos=typeof cursorPosition=="number"?cursorPosition:TextPositionUtils.lineColumnToCharOffset(sql,cursorPosition);if(charPos===-1)return;let activeQuery=splitQueries(sql).getActive(charPos);if(!activeQuery)return;let relativePosition=charPos-activeQuery.start,querySQL=activeQuery.sql,context=getCursorContext(querySQL,relativePosition),scope=resolveScope(querySQL,relativePosition),parseResult=parseToPosition(querySQL,relativePosition,options);return{context,scope,parseResult,currentQuery:querySQL,relativePosition}}function getCompletionSuggestions(sql,cursorPosition){let charPos=typeof cursorPosition=="number"?cursorPosition:TextPositionUtils.lineColumnToCharOffset(sql,cursorPosition);if(charPos===-1)return[];let intelliSenseContext=CursorContextAnalyzer.analyzeIntelliSense(sql,charPos),scope=resolveScope(sql,cursorPosition),suggestions=[];return intelliSenseContext.suggestKeywords&&(intelliSenseContext.requiredKeywords?intelliSenseContext.requiredKeywords.forEach(keyword=>{suggestions.push({type:"keyword",value:keyword,detail:`Required keyword: ${keyword}`})}):getGeneralKeywords(intelliSenseContext).forEach(keyword=>{suggestions.push({type:"keyword",value:keyword.value,detail:keyword.detail})})),intelliSenseContext.suggestTables&&(scope.availableTables.forEach(table=>{suggestions.push({type:"table",value:table.alias||table.name,detail:`Table: ${table.fullName}`,documentation:`Available table${table.alias?` (alias: ${table.alias})`:""}`})}),scope.availableCTEs.forEach(cte=>{suggestions.push({type:"cte",value:cte.name,detail:`CTE: ${cte.name}`,documentation:`Common Table Expression${cte.columns?` with columns: ${cte.columns.join(", ")}`:""}`})})),intelliSenseContext.suggestColumns&&(intelliSenseContext.tableScope?scope.visibleColumns.filter(col=>col.tableName===intelliSenseContext.tableScope||col.tableAlias===intelliSenseContext.tableScope).forEach(col=>{suggestions.push({type:"column",value:col.name,detail:`Column: ${col.fullReference}`,documentation:`Column from ${col.tableName}${col.type?` (${col.type})`:""}`})}):scope.visibleColumns.forEach(col=>{suggestions.push({type:"column",value:col.name==="*"?"*":`${col.tableAlias||col.tableName}.${col.name}`,detail:`Column: ${col.fullReference}`,documentation:`Column from ${col.tableName}`})})),suggestions}function getGeneralKeywords(context){let prevToken=context.previousToken?.value?.toLowerCase(),currentToken=context.currentToken?.value?.toLowerCase();return prevToken==="select"||currentToken==="select"?[{value:"DISTINCT",detail:"Remove duplicate rows"},{value:"COUNT",detail:"Aggregate function"},{value:"SUM",detail:"Aggregate function"},{value:"AVG",detail:"Aggregate function"},{value:"MAX",detail:"Aggregate function"},{value:"MIN",detail:"Aggregate function"}]:prevToken==="from"||currentToken==="from"?[{value:"JOIN",detail:"Inner join tables"},{value:"LEFT JOIN",detail:"Left outer join"},{value:"RIGHT JOIN",detail:"Right outer join"},{value:"FULL JOIN",detail:"Full outer join"},{value:"WHERE",detail:"Filter conditions"},{value:"GROUP BY",detail:"Group results"},{value:"ORDER BY",detail:"Sort results"}]:["where","having","on"].includes(prevToken||"")||["where","having","on"].includes(currentToken||"")?[{value:"AND",detail:"Logical AND operator"},{value:"OR",detail:"Logical OR operator"},{value:"NOT",detail:"Logical NOT operator"},{value:"IN",detail:"Match any value in list"},{value:"LIKE",detail:"Pattern matching"},{value:"BETWEEN",detail:"Range comparison"}]:[{value:"SELECT",detail:"Query data"},{value:"FROM",detail:"Specify table"},{value:"WHERE",detail:"Filter conditions"},{value:"JOIN",detail:"Join tables"},{value:"GROUP BY",detail:"Group results"},{value:"ORDER BY",detail:"Sort results"},{value:"LIMIT",detail:"Limit results"}]}export{AliasRenamer,AlterSequenceStatement,AlterTableAddColumn,AlterTableAddConstraint,AlterTableAlterColumnDefault,AlterTableDropColumn,AlterTableDropConstraint,AlterTableParser,AlterTableStatement,AnalyzeStatement,ArrayExpression,ArrayIndexExpression,ArrayQueryExpression,ArraySliceExpression,BetweenExpression,BinaryExpression,BinarySelectQuery,CTECollector,CTEComposer,CTEDependencyAnalyzer,CTEDisabler,CTENormalizer,CTENotFoundError,CTEQueryDecomposer,CTERegionDetector,CTERenamer,CTETableReferenceCollector,CaseExpression,CaseKeyValuePair,CastExpression,CheckpointStatement,CheckpointStatementParser,ClusterStatement,ClusterStatementParser,ColumnConstraintDefinition,ColumnReference,ColumnReferenceCollector,CommentEditor,CommentOnParser,CommentOnStatement,CommonTable,CreateIndexParser,CreateIndexStatement,CreateSchemaStatement,CreateSequenceStatement,CreateTableParser,CreateTableQuery,CursorContextAnalyzer,DDLDiffGenerator,DDLGeneralizer,DDLToFixtureConverter,DeleteClause,DeleteQuery,DeleteQueryParser,DeleteResultSelectConverter,Distinct,DistinctOn,DropConstraintParser,DropConstraintStatement,DropIndexParser,DropIndexStatement,DropSchemaStatement,DropTableParser,DropTableStatement,DuplicateCTEError,DuplicateDetectionMode,DynamicQueryBuilder,ExplainOption,ExplainStatement,FetchClause,FetchExpression,FetchType,FetchUnit,FilterableItem,FilterableItemCollector,FixtureCteBuilder,ForClause,Formatter,FromClause,FunctionCall,FunctionSource,GroupByClause,HavingClause,IdentifierString,IndexColumnDefinition,InlineQuery,InsertClause,InsertQuery,InsertQueryParser,InsertQuerySelectValuesConverter,InsertResultSelectConverter,InvalidCTENameError,JoinClause,JoinOnClause,JoinUsingClause,LexemeCursor,LimitClause,LiteralValue,LockMode,MergeAction,MergeDeleteAction,MergeDoNothingAction,MergeInsertAction,MergeQuery,MergeQueryParser,MergeResultSelectConverter,MergeUpdateAction,MergeWhenClause,MultiQuerySplitter,MultiQueryUtils,NullsSortDirection,OffsetClause,OrderByClause,OrderByItem,OriginalFormatRestorer,ParameterExpression,ParameterHelper,ParenExpression,ParenSource,PartitionByClause,PositionAwareParser,QualifiedName,QueryBuilder,QueryFlowDiagramGenerator,RawString,ReferenceDefinition,ReindexStatement,ReindexStatementParser,ReturningClause,SSSQLFilterBuilder,SchemaCollector,SchemaManager,ScopeResolver,SelectClause,SelectItem,SelectQueryParser,SelectResultSelectConverter,SelectValueCollector,SelectableColumnCollector,SetClause,SetClauseItem,SimpleSelectQuery,SimulatedSelectConverter,SmartRenamer,SortDirection,SourceAliasExpression,SourceExpression,SqlComponent,SqlDialectConfiguration,SqlFormatter,SqlIdentifierRenamer,SqlPaginationInjector,SqlParamInjector,SqlParameterBinder,SqlParser,SqlSchemaValidator,SqlSortInjector,SqlTokenizer,StringSpecifierExpression,SubQuerySource,SwitchCaseArgument,TableColumnDefinition,TableConstraintDefinition,TableSchema,TableSource,TableSourceCollector,TokenType,TupleExpression,TypeValue,UnaryExpression,UpdateClause,UpdateQuery,UpdateQueryParser,UpdateResultSelectConverter,UpstreamSelectQueryFinder,UsingClause,VALID_PRESETS,VacuumStatement,VacuumStatementParser,ValueList,ValuesQuery,WhereClause,WindowFrameBound,WindowFrameBoundStatic,WindowFrameBoundaryValue,WindowFrameClause,WindowFrameExpression,WindowFrameSpec,WindowFrameType,WindowsClause,WithClause,WithClauseParser,buildRelationGraphFromCreateTableQueries,collectSupportedOptionalConditionBranchSpans,collectSupportedOptionalConditionBranches,createSchemaManager,createTableColumnResolver,createTableDefinitionFromCreateTableQuery,createTableDefinitionRegistryFromCreateTableQueries,createTableDefinitionRegistryFromSchema,getCompletionSuggestions,getCursorContext,getIncomingRelations,getIntelliSenseInfo,getOutgoingRelations,getQualifiedNameText,normalizeTableName,optimizeUnusedCtes,optimizeUnusedCtesToFixedPoint,optimizeUnusedLeftJoins,optimizeUnusedLeftJoinsToFixedPoint,parseToPosition,pruneOptionalConditionBranches,refreshSssqlQuery,resolveScope,scaffoldSssqlQuery,splitQueries,tableNameVariants}; diff --git a/docs/review.md b/docs/review.md index 4bddaadc0..435da0a46 100644 --- a/docs/review.md +++ b/docs/review.md @@ -49,7 +49,7 @@ This section aggregates the package-level review harness inputs used before sema - Mandatory scope rules: `db-centered-transfer`, `human-owned-logical-model`, `generated-docs-not-source` - Mandatory verification policies: `db-backed-contract-verification`, `no-hot-path-runtime-validation` - Mandatory authority rules: `human-owned-requirements`, `ai-owned-review-management`, `cli-owned-review-views` -- Mandatory technology rules: `postgres-primary-db`, `sql-first-ztd-cli`, `no-standard-orm-path`, `cli-front-facing-surface` +- Mandatory technology rules: `postgres-primary-db`, `sql-first-ashiba`, `no-standard-orm-path`, `cli-front-facing-surface` ### Review-plan Diagnostics diff --git a/docs/technology/index.md b/docs/technology/index.md index 0a312a1af..731577cd7 100644 --- a/docs/technology/index.md +++ b/docs/technology/index.md @@ -10,7 +10,7 @@ ## Purpose -`@rawsql-ts/transfer` は、PostgreSQL、SQL-first、ztd-cli / rawsql-ts を標準経路にした転送制御 package である。 +`@rawsql-ts/transfer` は、PostgreSQL、SQL-first、Ashiba / rawsql-ts を標準経路にした転送制御 package である。 コードから現在の実装技術を観測することはできるが、コードだけでは「その技術が意図した制約なのか、偶然の現状なのか」を判定しにくい。 @@ -20,7 +20,7 @@ - Primary database: PostgreSQL - Data access style: SQL-first -- Standard generation / verification path: ztd-cli and rawsql-ts +- Standard generation / verification path: Ashiba and rawsql-ts - Standard transfer implementation path: reviewed SQL, DDL metadata, queryspec contracts, generated mapper checks, and DB-backed tests - Standard front-facing surface: CLI - Web UI is not a standard surface for this package. If a Web surface is needed, treat it as an owning application boundary outside `@rawsql-ts/transfer`. @@ -41,7 +41,7 @@ 例外を採用する場合は、少なくとも次を明示する。 -- なぜ既存の PostgreSQL / SQL-first / ztd-cli / rawsql-ts 経路では不足するのか +- なぜ既存の PostgreSQL / SQL-first / Ashiba / rawsql-ts 経路では不足するのか - 例外が一時的な adapter なのか、package の標準経路を変える scope expansion なのか - Concept Spec、Scope Spec、Test Policy、DDL metadata、generated docs への影響 - 追加で必要になる検証方法 diff --git a/docs/ztd-cli-docs.md b/docs/ztd-cli-docs.md deleted file mode 100644 index 0dbf46732..000000000 --- a/docs/ztd-cli-docs.md +++ /dev/null @@ -1,42 +0,0 @@ -# ztd-cli Docs - -`ztd-cli` Docs are review pages for the target package concept, policies, and staged runtime-free code generation direction of `@rawsql-ts/ztd-cli`. - -The current pages are draft review surfaces. -They describe the intended package direction, not the current implementation state. - -## What Is ztd-cli? - -`ztd-cli` is intended to be a runtime-free code generation CLI for SQL-first backend development. - -It treats DDL as the source of truth for database structure, uses `rawsql-ts` SQL AST analysis at generation time, and generates SQL execution boundaries, DAO-style access code, AOT mappers, and ZTD-backed unit-test scaffolds. - -The standard generated runtime path does not require `ztd-cli`, `rawsql-ts`, runtime mapper libraries, or runtime validator libraries. -Generated SQL should stay ordinary row/column SQL. Response shape should be built by generated AOT mappers rather than hidden SQL JSON aggregation or runtime result-shaping helpers. - -## Standard Runtime Shape - -The standard scaffold is intentionally small at runtime: - -- SQL resources remain reviewable as normal SQL files. -- Query boundaries call thin generated executor helpers. -- Generated `row-mapper.ts` files build response objects through direct assignment. -- Runtime row validation libraries are optional compatibility choices, not the default. -- `@rawsql-ts/sql-contract` and `@rawsql-ts/sql-contract-zod` are not part of the generated application runtime. - -## Review Order - -Use these pages to review the ztd-cli package from durable package concept toward policy and implementation direction. -Start with the package concept, then review testing, authority, and technology policies as they are added. - -## Specification Views - -- [Package Concept Draft](./ztd-cli/package-concept.md) -- [Testing Policy Draft](./ztd-cli/testing-policy.md) -- [Review Authority Model Draft](./ztd-cli/review-authority-model.md) -- [Technology Policy Draft](./ztd-cli/technology-policy.md) - -## Review Views - -- Implementation migration plan: planned -- Runtime dependency inventory: drafted in [Technology Policy Draft](./ztd-cli/technology-policy.md) diff --git a/docs/ztd-cli/package-concept.md b/docs/ztd-cli/package-concept.md deleted file mode 100644 index d0b0b15f4..000000000 --- a/docs/ztd-cli/package-concept.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: ztd-cli Package Concept Draft -outline: deep ---- - -# ztd-cli Package Concept Draft - -
Runtime-free SQL-first code generation CLI for DDL-driven application boundaries, DAO access, AOT mappers, and ZTD-backed tests.
- -
-
-
- id ztd-cli-package-concept - format draft markdown -
- draft -
-
- validation: draft - coverage: partial - open questions present -
-
- meaning: present - responsibilities: present - boundaries: present - invariants: present - rationale: present - evidence: draft only - linked concepts: not registered -
- -
- -This page is a Concept Spec draft for the target direction of `@rawsql-ts/ztd-cli`. -It describes the intended package concept, not the current implementation state. - -## Message - -
-

From DDL to tested SQL.

-

Generate editable feature scaffolds, mappers, unit tests, and SQL impact reports for runtime-free TypeScript apps.

-
- -This message is the package positioning statement. -It is not a complete summary of responsibilities; it is the short promise the package should make before readers inspect the detailed concept boundary. - -## Definition - -`ztd-cli` is a runtime-free code generation CLI for SQL-first backend development. - -It treats DDL as the single source of truth for data structure, uses `rawsql-ts` -SQL AST analysis at generation time, and generates SQL execution boundaries, -DAO-style access code, AOT row mappers, and ZTD-backed unit-test scaffolds. - -The standard generated runtime path does not require `ztd-cli`, `rawsql-ts`, -runtime mapper libraries, or runtime validator libraries. It may use a thin -driver adapter so reviewable SQL files can keep named parameters while the -selected database driver receives its required placeholder format. - -## Responsibilities - -- Use DDL as the source of truth for table, column, constraint, and database type information. -- Use `rawsql-ts` SQL AST analysis during code generation, inspection, scaffolding, and checks. -- Generate reviewable SQL assets and application-side SQL execution boundaries. -- Generate DAO-style access code that calls explicit SQL resources and generated mappers. -- Generate AOT row mappers instead of relying on runtime mapping descriptors. -- Generate ZTD-backed query-boundary tests that exercise SQL execution and mapper behavior. -- Provide generated mapper drift checks so SQL, boundary contracts, and generated mapper artifacts stay aligned. -- Keep SQL files readable by allowing named parameters and delegating driver placeholder conversion to a thin adapter boundary. -- Support PostgreSQL as the current standard database target. - -## Non-Responsibilities - -- `ztd-cli` is not a runtime ORM. -- `ztd-cli` is not a runtime fluent SQL builder. -- `ztd-cli` does not participate in production runtime behavior. -- `ztd-cli` should be needed only as a development-time code generation, inspection, and verification dependency. -- `ztd-cli` affects application code only through generated artifacts and mechanical checks. -- `ztd-cli` is not the standard production runtime for SQL AST rewriting. -- `ztd-cli` does not make runtime DB row validation with Zod, ArkType, or similar validators part of the standard mapper path. -- `ztd-cli` does not use SQL-side JSON shaping or SQL JSON result construction as a generated result-mapping strategy. -- `ztd-cli` does not hide business SQL shape behind runtime SQL rewriting in the standard path. -- `ztd-cli` is not a database driver and does not own connection pooling, transactions, or driver lifecycle. -- Advanced runtime SQL processing, such as pipeline execution, scalar helpers, SSSQL optional-branch pruning, and parameter compression, belongs outside the core standard generated runtime path. - -## Invariants - -- Generated standard runtime code must execute without depending on `ztd-cli` or SQL AST transformation libraries. -- SQL remains a reviewable source artifact; runtime behavior must not depend on hidden SQL shape changes in the standard path. -- Driver adapter behavior must be limited to driver-facing mechanics such as named-parameter compilation and row-result normalization. It must not become ORM behavior, runtime result validation, SQL result shaping, or hidden business SQL rewriting. -- DDL is the source of truth for database structure, but it is not the source of truth for business concepts, process decisions, or feature intent. -- Runtime DB row validation is not required on the hot mapper path when the SQL contract and mapper are covered by generated mapper drift checks and ZTD-backed tests. -- A DAO generated by `ztd-cli` can be treated as type-safe when its SQL contract, generated mapper, and ZTD mapper tests are aligned and passing. -- AOT mapper generation must be reproducible from source artifacts. -- Mapper drift must fail through a mechanical check rather than relying on humans to notice stale generated code. -- Generated code must keep the boundary between generated artifacts and human-owned SQL, query contracts, and test cases visible. - -## Rationale - -Database result rows are a narrower trust boundary than external requests. -The database already enforces strong structure through types, `NOT NULL`, -constraints, and query semantics. - -For the standard `ztd-cli` path, mapper correctness should therefore be shifted -left into DDL review, SQL review, query-boundary contracts, generated mapper -drift checks, and ZTD-backed mapper tests. - -This keeps production runtime code small and direct while preserving a strong -review and verification story. - -## Target Runtime Shape - -The target standard runtime shape is: - -```text -SQL file with :name parameters - + thin driver adapter - + generated DAO - + generated AOT mapper -``` - -The target verification shape is: - -```text -DDL + query contract + generated mapper drift check + ZTD-backed mapper test -``` - -This means the runtime path should be close to direct SQL execution plus direct -assignment into DTOs. - -## Out Of Scope For This Draft - -- Exact package split for advanced runtime SQL processing. -- Migration plan for legacy runtime mapper, catalog, and writer APIs. -- Deprecation schedule for JSON result construction helpers. -- Generated file layout details. -- CLI command names and command-line option design. -- Non-PostgreSQL support policy. - -## Open Questions - -- Which advanced runtime SQL processing features should move into a separate package first? -- Should the separated advanced runtime package be positioned as optional infrastructure or as a compatibility layer? -- Which driver-specific adapter packages should be added after the core driver adapter boundary? -- Which current scaffold outputs must change before the `ztd-cli` concept can move from draft to defined? -- What minimum generated project smoke test proves the runtime-free standard path? -- How should legacy runtime API users be guided through the transition without hiding the new concept boundary? diff --git a/docs/ztd-cli/review-authority-model.md b/docs/ztd-cli/review-authority-model.md deleted file mode 100644 index 5913f7752..000000000 --- a/docs/ztd-cli/review-authority-model.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -title: ztd-cli Review Authority Model Draft -outline: deep ---- - -# ztd-cli Review Authority Model Draft - -
Draft authority model for ztd-cli concept, policy, generated artifacts, AI review, and CLI checks.
- -
-
-
- id ztd-cli-review-authority-model - format draft markdown -
- draft -
-
- validation: draft - coverage: partial - open questions present -
-
- authority classes: present - generated view boundary: present - AI boundary: present - rules index: not registered -
- -
- -This document defines who owns review authority for the target `ztd-cli` direction. -It is not the Concept Spec body and does not replace human approval. - -## Purpose - -The `ztd-cli` direction depends on a clear split between human-owned intent, AI-assisted review, CLI-owned mechanical checks, and generated artifacts. - -This model prevents generated output, AI summaries, or implementation convenience from becoming the source of truth for the package concept or policies. - -## Authority Classes - -### Human-owned Intent - -Humans own durable package direction and acceptance of concept/policy changes. - -Primary examples: - -- Package Concept. -- Testing Policy. -- Review Authority Model. -- Technology Policy. -- Issue goals and explicit human decisions. -- Decisions to deprecate, split, or rename packages. - -AI may draft, compare, ask questions, and propose wording. -Those proposals are review input until accepted by a human. - -### AI-led Review Management - -AI may lead review workflow and consistency checking. - -Primary examples: - -- Finding contradictions between Package Concept, Testing Policy, Technology Policy, and current implementation. -- Separating current-state facts from target-state concept statements. -- Proposing staged migration plans. -- Reporting risks, blockers, and open decisions. - -AI review output is not policy. -It must cite or point to the human-owned source that supports the claim. - -### CLI-owned Mechanical Checks - -CLI checks own deterministic mechanical detection. - -Primary examples: - -- Generated mapper drift checks. -- Generated project smoke checks. -- Structured metadata checks. -- Generated review packets or reports. -- Dependency and runtime-free verification scripts once they exist. - -CLI output is evidence, not final judgment. -When a generated view is wrong, the source artifact or generator should be fixed. - -### Generated Artifacts - -Generated artifacts are machine-owned outputs. - -Primary examples: - -- AOT row mappers. -- Generated test plans. -- Generated analysis files. -- Generated review pages. -- Generated DAO scaffolds when marked as generated. - -Generated artifacts must remain reproducible from source artifacts. -Humans review their meaning through source SQL, DDL, query contracts, policies, tests, and drift checks. - -## Required Review Posture - -Reviews must distinguish: - -- target concept versus current implementation; -- human-approved policy versus AI proposal; -- generated evidence versus source of truth; -- runtime-free standard path versus optional advanced runtime libraries. - -## Open Questions - -- Which ztd-cli policy documents should become structured metadata-backed pages? -- Which CLI checks should be considered merge-blocking once the runtime-free path is implemented? -- Who owns acceptance of advanced runtime package boundaries? diff --git a/docs/ztd-cli/technology-policy.md b/docs/ztd-cli/technology-policy.md deleted file mode 100644 index 7a9c73f71..000000000 --- a/docs/ztd-cli/technology-policy.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -title: ztd-cli Technology Policy Draft -outline: deep ---- - -# ztd-cli Technology Policy Draft - -
Draft technology constraints for ztd-cli as a PostgreSQL-first, runtime-free code generation CLI.
- -
-
-
- id ztd-cli-technology-policy - format draft markdown -
- draft -
-
- validation: draft - coverage: partial - open questions present -
-
- constraints: present - non-standard paths: present - exception policy: present - rules index: not registered -
- -
- -This document is a technology constraint harness for the target `ztd-cli` direction. -It is not the Package Concept itself. - -## Purpose - -`ztd-cli` should be reviewed as a runtime-free code generation CLI. - -Code can show the current implementation, but code alone does not tell reviewers whether a dependency, runtime behavior, or generated shape is an intended constraint or historical accident. -This policy defines the standard technology path and the changes that should trigger review. - -## Standard Technology Constraints - -- Standard database target: PostgreSQL. -- Data access style: SQL-first. -- Structural source of truth: DDL for database structure. -- Code generation analysis tool: `rawsql-ts` SQL AST analysis at generation/check time. -- Standard mapper strategy: generated AOT direct assignment mapper. -- Standard generated runtime path: direct SQL resource execution through a thin driver adapter plus generated DAO and generated mapper. -- Standard verification path: generated mapper drift check plus ZTD-backed query-boundary tests. -- Standard runtime dependency goal: no `ztd-cli`, no `rawsql-ts`, no runtime mapper library, and no runtime validator library. A thin driver adapter dependency is allowed when it only handles driver mechanics. -- Standard package role: development-time code generation, inspection, and verification dependency. - -## Runtime Dependency Inventory - -This policy distinguishes the `ztd-cli` command runtime from the generated application runtime. - -`ztd-cli` itself may depend on SQL analysis, project inspection, evidence rendering, file watching, and command-line tooling because it runs in development and CI. -That does not make those dependencies acceptable in generated application code. - -The standard generated application runtime should exclude: - -- `ztd-cli`; -- `rawsql-ts`; -- `@rawsql-ts/sql-contract`; -- runtime DB row validators such as Zod or ArkType; -- runtime mapper libraries; -- `@rawsql-ts/testkit-*` packages; -- SQL AST rewriting, query planning, or catalog execution helpers. - -The standard generated application runtime may include a thin SQL driver adapter. -That adapter may compile named SQL parameters such as `:customerId` to driver -placeholders such as `$1` or `?`, pass ordered values to the driver, and unwrap -driver row results. It must not provide ORM query construction, SQL AST rewriting, -runtime DB row validation, or result-shaping behavior. - -The generated test and verification path may use development-only dependencies such as PostgreSQL testkit packages, Testcontainers, mapper drift checks, and evidence rendering. -Those dependencies should stay in `devDependencies` or equivalent test-only wiring and should not be required by production DAO execution. - -## Non-Standard Paths - -The following are not part of the standard generated runtime path: - -- Runtime ORM behavior. -- Runtime fluent SQL builder behavior. -- Runtime SQL AST rewriting in ordinary generated DAO execution. -- Runtime DB row validation with Zod, ArkType, or similar validators. -- SQL-side JSON shaping as a generated result-mapping strategy. -- PostgreSQL JSON aggregation (`json_agg`, `jsonb_agg`, `jsonb_build_object`, and related SQL JSON shaping) in generated query output. -- Hidden runtime SQL transformations that make reviewed SQL differ from executed SQL. -- Treating driver placeholder conversion as permission for ORM behavior or business SQL rewriting. -- Pipeline processing, scalar helper libraries, SSSQL optional-branch pruning, and parameter compression inside the core standard runtime path. -- Treating non-PostgreSQL support as already standard. - -## Advanced Runtime Library Boundary - -Advanced SQL runtime processing may still be valuable. - -When needed, it should be treated as explicit optional infrastructure rather than part of the standard generated runtime path. -Compatibility helpers may exist, but they should not define the primary package concept. - -Candidate advanced runtime areas include: - -- SSSQL optional-branch pruning and omitted-parameter compression; -- runtime pipeline execution; -- runtime scalar result helpers when generated static code is insufficient; -- named parameter binding that exceeds the standard driver adapter contract; -- compatibility helpers for legacy dynamic SQL users. - -The first extraction candidate should be SSSQL optional-branch pruning and omitted-parameter compression. -It is a real runtime behavior, it is advanced enough that many generated projects do not need it, and keeping it in the standard path makes the runtime-free claim ambiguous. - -Pipeline execution should be evaluated next because it also represents runtime orchestration rather than generated direct execution. -Scalar result helpers should be treated cautiously: if the behavior can be generated as small static code, it should remain generated rather than become a shared runtime dependency. - -## Package Split Direction - -The recommended split is: - -- `@rawsql-ts/ztd-cli`: development-time generator, analyzer, scaffold, and verifier; -- an optional advanced SQL runtime package: explicit infrastructure for dynamic SQL behavior that cannot be represented as static generated DAO code; -- an optional advanced SQL runtime package: temporary migration support for dynamic SQL behavior that cannot yet be generated statically. -- thin production driver adapter packages named by concrete driver implementation, such as `@rawsql-ts/driver-adapter-core`, `@rawsql-ts/driver-adapter-node-postgres`, `@rawsql-ts/driver-adapter-postgres-js`, or `@rawsql-ts/driver-adapter-mysql2`; -- testkit adapter packages named separately from production driver adapters, such as a future `@rawsql-ts/testkit-adapter-node-postgres`. - -The existing `@rawsql-ts/adapter-node-pg` package is a testkit adapter for connecting node-postgres to `@rawsql-ts/testkit-postgres`. -It should not be treated as the production node-postgres driver adapter. -The non-breaking rename direction is to introduce a `testkit-adapter-*` alias first, keep the existing package for compatibility, and only then mark the legacy name as deprecated in docs or package metadata. - -The optional runtime package should be opt-in and visible. -`ztd-cli` may provide commands that wire it into a generated project, but the default scaffold should not install it or import it. - -Avoid a generic generation parameter that silently changes the standard runtime dependency model. -If a command enables advanced runtime behavior, generated files and docs should make that choice explicit so reviewers can still say whether a project is on the runtime-free path. - -## Deprecation Decisions - -- `@rawsql-ts/sql-contract-zod` is removed and must not appear in workspace packages, generated projects, or standard docs. -- `@rawsql-ts/sql-contract` is removed from the workspace and must not appear in generated projects, standard runtime docs, or package dependency manifests. -- Runtime DB row validation is not part of standard `ztd-cli` scaffold output. -- `createCatalogExecutor` is not part of standard generated query boundaries; new scaffolds should use thin generated executor calls plus generated mappers. -- SQL JSON aggregation and JSON-return shaping are removed from the generated result-mapping path. Standard code should return ordinary rows and build response shape through generated AOT mappers. -- SSSQL optional pruning, pipeline execution, scalar runtime helpers, and named parameter binding that exceeds the driver adapter contract belong in the advanced runtime package instead of the standard scaffold. - -## Exception Policy - -Departing from the standard path is allowed only as an explicit review decision. - -The change should explain: - -- why the runtime-free generated path is insufficient; -- whether the exception is temporary compatibility, an optional advanced runtime package, or a change to the standard concept; -- what additional tests or checks prove the exception; -- whether Package Concept, Testing Policy, Review Authority Model, docs, or generated project smoke tests need updates. - -## Open Questions - -- What should the advanced runtime package be named and scoped to include? -- Which legacy dynamic SQL primitives should move into the advanced runtime package? -- What is the migration path for current generated boundaries that still use runtime validation or catalog execution helpers? -- When, if ever, should non-PostgreSQL support become standard rather than experimental? diff --git a/docs/ztd-cli/testing-policy.md b/docs/ztd-cli/testing-policy.md deleted file mode 100644 index 0d0b11840..000000000 --- a/docs/ztd-cli/testing-policy.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -title: ztd-cli Testing Policy Draft -outline: deep ---- - -# ztd-cli Testing Policy Draft - -
Draft verification policy for the runtime-free ztd-cli generation path.
- -
-
-
- id ztd-cli-testing-policy - format draft markdown -
- draft -
-
- validation: draft - coverage: partial - open questions present -
-
- purpose: present - scope: present - mapper strategy: present - gates: present - rules index: not registered -
- -
- -This document is a package-level verification harness for the target `ztd-cli` concept. -It is not the Concept Spec body. - -## Purpose - -`ztd-cli` verifies its standard generated runtime path by shifting correctness left into source artifacts and generated checks. - -The standard verification strategy is not to add runtime validation to the hot mapper path. -Instead, it combines DDL structure, SQL/query contracts, generated mapper drift checks, and ZTD-backed query-boundary tests. - -## In Scope - -- DDL-derived table, column, constraint, and database type coverage. -- SQL resource review and query-boundary contract coverage. -- Generated AOT mapper drift detection. -- ZTD-backed query-boundary tests that execute SQL and mapper behavior together. -- Generated project smoke tests that prove the standard runtime path does not depend on `ztd-cli`, `rawsql-ts`, runtime mapper libraries, or runtime validator libraries. -- Boundary cases for `null`, database defaults, missing rows, cardinality, scalar values, arrays, JSON columns, and enum-like values when the DDL or query contract exposes them. - -## Out of Scope - -- Treating E2E tests as the only proof for SQL and mapper correctness. -- Adding Zod, ArkType, or similar runtime validation to the standard DB row mapper path. -- Requiring production runtime SQL AST rewriting to prove ordinary generated DAO behavior. -- Treating generated review pages as executable verification. -- Hiding SQL JSON shaping behind runtime behavior as the standard mapper strategy. - -## Required Review Posture - -Tests should protect source intent, not reproduce implementation shape. - -When a test case is derived from a concept, package policy, DDL constraint, SQL contract, or issue requirement, the owning source should be visible enough that reviewers can tell what the test protects. - -Generated tests and generated plans are review aids. -Human-owned or AI-authored persistent test cases remain responsible for the meaningful examples and boundary cases. - -## Mapper Safety Strategy - -DB rows are a narrower trust boundary than external request payloads. - -Mapper safety is provided by: - -- DDL constraints and database types. -- SQL/query contracts. -- Generated mapper drift checks. -- ZTD-backed SQL and mapper tests. - -If a feature adds runtime DB row validation to the standard generated path, the review must explain why these checks are insufficient for that feature. - -## Required Gates - -Before a generated runtime path is treated as type-safe, reviewers should have evidence that: - -- the SQL contract and generated mapper are in sync; -- the generated mapper check passes; -- the ZTD-backed mapper test covers the expected success path; -- relevant edge cases are covered either by ZTD-backed tests, traditional DB tests, or an explicit accepted gap; -- the generated runtime path can execute without `ztd-cli`, `rawsql-ts`, runtime mapper libraries, or runtime validator libraries. - -## Open Questions - -- What is the minimum smoke test that proves runtime-free generated project behavior? -- Which constraint classes require traditional physical DB tests in addition to ZTD tests? -- Should mapper drift checks become a required repository-level gate for all generated projects? -- How should scalar and optional advanced runtime features declare their separate verification requirements? diff --git a/package.json b/package.json index 105dd3075..d83ca39be 100644 --- a/package.json +++ b/package.json @@ -32,16 +32,12 @@ "ztd:bench:testkit-postgres-mode": "ts-node benchmarks/testkit-postgres-mode-benchmark.ts", "bench:testkit-postgres-runtime-metadata": "ts-node benchmarks/testkit-postgres-runtime-metadata-benchmark.ts", "demo:complex-sql": "node packages/core/demo/complex-sql-demo.js", - "playground:gen-config": "pnpm --filter ztd-playground exec ztd ztd-config", - "playground:typecheck": "pnpm --filter ztd-playground typecheck", - "playground:test": "pnpm --filter ztd-playground test", "prepare": "husky", "pr:readiness:prepare": "node scripts/prepare-pr-readiness.js", "guard:branch-session": "node scripts/branch-session-guard.js", "verify:publish-readiness": "node scripts/publish-plan.mjs", "verify:publish-contract": "node scripts/verify-publish-contract.mjs", "verify:runtime-prereqs": "node scripts/verify-runtime-prereqs.mjs", - "verify:generated-mapper-drift": "node scripts/check-generated-mapper-drift.mjs", "docs:dev": "pnpm docs:transfer && vitepress dev docs", "docs:build": "pnpm docs:api && pnpm docs:transfer && vitepress build docs", "docs:preview": "vitepress preview docs", @@ -49,7 +45,6 @@ "docs:transfer": "pnpm --filter @rawsql-ts/ddl-docs-cli run build && node scripts/generate-transfer-docs.mjs", "verify:customer-consumers": "node scripts/verify-published-package-mode.mjs", "verify:published-package-mode": "node scripts/verify-published-package-mode.mjs", - "verify:generated-project-mode": "node scripts/verify-generated-project-mode.mjs", "verify:transfer-ddl-metadata": "pnpm --filter @rawsql-ts/ddl-docs-cli run build && node packages/ddl-docs-cli/dist/src/index.js check --ddl-dir packages/transfer/db/ddl --table-docs packages/transfer/db/ddl/table-docs.json --relationship packages/transfer/db/ddl/relationship.json --order packages/transfer/db/ddl/order.json --concept-relationship packages/transfer/docs/concepts/concept-relationship.json --dfd-relationship packages/transfer/docs/dfd/relationship.json --scope-rules packages/transfer/docs/scope/scope-rules.json --test-rules packages/transfer/docs/testing/test-rules.json --authority-rules packages/transfer/docs/review/authority-rules.json --technology-rules packages/transfer/docs/technology/tech-rules.json --process-dir packages/transfer/docs/processes --default-schema rawsql_transfer", "verify:transfer-docs": "pnpm verify:transfer-ddl-metadata && pnpm docs:transfer && pnpm verify:transfer-ddl-metadata && node scripts/verify-transfer-docs-drift.mjs" }, diff --git a/packages/advanced-runtime/README.md b/packages/advanced-runtime/README.md index af2430a1b..e9e309f9f 100644 --- a/packages/advanced-runtime/README.md +++ b/packages/advanced-runtime/README.md @@ -1,6 +1,6 @@ # @rawsql-ts/advanced-runtime -Opt-in advanced runtime SQL helpers for projects that intentionally need dynamic SQL behavior outside the standard `ztd-cli` runtime-free generated path. +Opt-in advanced runtime SQL helpers for projects that intentionally need dynamic SQL behavior outside the standard Ashiba runtime-free generated path. Initial extraction targets: @@ -10,4 +10,4 @@ Initial extraction targets: - named parameter binding that cannot be resolved during generation - compatibility helpers for legacy runtime users -Standard `ztd-cli` scaffolds should not import this package by default. +Standard Ashiba scaffolds should not import this package by default. diff --git a/packages/core/src/transformers/DynamicQueryBuilder.ts b/packages/core/src/transformers/DynamicQueryBuilder.ts index dfb235900..33b9f395e 100644 --- a/packages/core/src/transformers/DynamicQueryBuilder.ts +++ b/packages/core/src/transformers/DynamicQueryBuilder.ts @@ -247,7 +247,7 @@ export class DynamicQueryBuilder { const hasLegacyDynamicFilters = Object.keys(dynamicFilters).length > 0; if (hasLegacyDynamicFilters) { throw new Error( - "DynamicQueryBuilder no longer injects runtime filter predicates. Use `ztd query sssql scaffold` to author optional filters, `ztd query sssql refresh` to refresh them, and `optionalConditionParameters` at runtime for pruning only." + "DynamicQueryBuilder no longer injects runtime filter predicates. Use `ashiba query optional add` to author optional filters, `ashiba query optional refresh` to refresh them, and `optionalConditionParameters` at runtime for pruning only." ); } } diff --git a/packages/core/tests/transformers/DynamicFilterRoutingDogfooding.test.ts b/packages/core/tests/transformers/DynamicFilterRoutingDogfooding.test.ts index b9a117596..bb1cce665 100644 --- a/packages/core/tests/transformers/DynamicFilterRoutingDogfooding.test.ts +++ b/packages/core/tests/transformers/DynamicFilterRoutingDogfooding.test.ts @@ -19,7 +19,7 @@ describe('Dynamic filter routing dogfooding', () => { filter: { 'profiles.name': 'Alice' } - })).toThrow(/ztd query sssql scaffold/i); + })).toThrow(/ashiba query optional add/i); }); it('dogfood: SSSQL scaffold owns optional filter authoring before runtime pruning', () => { diff --git a/packages/core/tests/transformers/DynamicQueryBuilder.test.ts b/packages/core/tests/transformers/DynamicQueryBuilder.test.ts index 353d057fb..c763b9648 100644 --- a/packages/core/tests/transformers/DynamicQueryBuilder.test.ts +++ b/packages/core/tests/transformers/DynamicQueryBuilder.test.ts @@ -30,12 +30,12 @@ describe('DynamicQueryBuilder', () => { builder.buildQuery('SELECT id, name FROM users WHERE active = true', { filter: { name: 'Alice' } }) - ).toThrow(/ztd query sssql scaffold/i); + ).toThrow(/ashiba query optional add/i); expect(() => builder.buildQuery('SELECT id, name FROM users WHERE active = true', { filter: { name: 'Alice' } }) - ).toThrow(/ztd query sssql refresh/i); + ).toThrow(/ashiba query optional refresh/i); }); it('still binds existing named parameters through the filter option', () => { @@ -74,7 +74,7 @@ describe('DynamicQueryBuilder', () => { it('uses buildFilteredQuery as the same fail-fast path', () => { expect(() => builder.buildFilteredQuery('SELECT id FROM users', { name: 'Alice' }) - ).toThrow(/ztd query sssql scaffold/i); + ).toThrow(/ashiba query optional add/i); }); }); diff --git a/packages/ddl-docs-cli/src/commands/reviewPlan.ts b/packages/ddl-docs-cli/src/commands/reviewPlan.ts index 7ebdfe1ca..01ff9df25 100644 --- a/packages/ddl-docs-cli/src/commands/reviewPlan.ts +++ b/packages/ddl-docs-cli/src/commands/reviewPlan.ts @@ -241,8 +241,8 @@ const MANDATORY_TECHNOLOGY_RULES = [ reason: 'Transfer implementation assumes PostgreSQL-compatible DDL, SQL, and database behavior.', }, { - id: 'sql-first-ztd-cli', - reason: 'Transfer changes should preserve the SQL-first, ztd-cli/rawsql-ts standard implementation path.', + id: 'sql-first-ashiba', + reason: 'Transfer changes should preserve the SQL-first, Ashiba/rawsql-ts standard implementation path.', }, { id: 'no-standard-orm-path', diff --git a/packages/ddl-docs-cli/tests/check.integration.test.ts b/packages/ddl-docs-cli/tests/check.integration.test.ts index f0bf11245..654b6ab76 100644 --- a/packages/ddl-docs-cli/tests/check.integration.test.ts +++ b/packages/ddl-docs-cli/tests/check.integration.test.ts @@ -1865,7 +1865,7 @@ test('review-plan includes package technology policy as mandatory review input', }, technologyRules: [ { id: 'postgres-primary-db', kind: 'database-platform', statement: 'Use PostgreSQL.' }, - { id: 'sql-first-ztd-cli', kind: 'data-access', statement: 'Use SQL-first ztd-cli.' }, + { id: 'sql-first-ashiba', kind: 'data-access', statement: 'Use SQL-first Ashiba.' }, { id: 'no-standard-orm-path', kind: 'data-access-boundary', statement: 'Do not introduce an ORM standard path.' }, { id: 'cli-front-facing-surface', kind: 'front-facing-surface', statement: 'Use CLI as the package front-facing surface.' }, ], @@ -1882,7 +1882,7 @@ test('review-plan includes package technology policy as mandatory review input', expect(plan.mandatoryTechnology?.files).toEqual([technologyPolicyPath, technologyRulesPath]); expect(plan.mandatoryTechnology?.rules.map((entry) => entry.id)).toEqual([ 'postgres-primary-db', - 'sql-first-ztd-cli', + 'sql-first-ashiba', 'no-standard-orm-path', 'cli-front-facing-surface', ]); @@ -1890,7 +1890,7 @@ test('review-plan includes package technology policy as mandatory review input', expect(plan.changedFiles[0]?.packageWideImpact).toBe(true); expect(plan.changedFiles[0]?.requiredReads.technologyRules).toEqual([ 'postgres-primary-db', - 'sql-first-ztd-cli', + 'sql-first-ashiba', 'no-standard-orm-path', 'cli-front-facing-surface', ]); diff --git a/packages/sql-grep-core/README.md b/packages/sql-grep-core/README.md index ad35d1845..52ff29c07 100644 --- a/packages/sql-grep-core/README.md +++ b/packages/sql-grep-core/README.md @@ -3,10 +3,10 @@ ![npm version](https://img.shields.io/npm/v/@rawsql-ts/sql-grep-core) ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) -Low-dependency SQL usage analysis engine extracted from `@rawsql-ts/ztd-cli`. +Low-dependency SQL usage analysis engine extracted from the former `@rawsql-ts/ztd-cli`. -`@rawsql-ts/sql-grep-core` powers `ztd query uses` and exposes the reusable AST-based schema impact analysis primitives behind that command. It scans SQL catalog specs, resolves their SQL files, parses statements with `rawsql-ts`, and reports table or column usage with deterministic machine-readable output. -It also powers `ztd query match-observed`, which ranks candidate `.sql` assets from observed SELECT text when `queryId` is unavailable. +`@rawsql-ts/sql-grep-core` powers reusable AST-based schema impact analysis. It scans SQL catalog specs, resolves their SQL files, parses statements with `rawsql-ts`, and reports table or column usage with deterministic machine-readable output. +Ashiba uses the same core capability for query usage and observed SQL lookup commands. ## What it provides @@ -26,7 +26,7 @@ No CLI framework, watcher, diffing helper, or renderer is required at runtime. ## Typical use case -Use this package when you want the `ztd query uses` engine without taking a dependency on the rest of `ztd-cli`. +Use this package when you want the query usage engine without taking a dependency on a CLI package. ```ts import { buildQueryUsageReport, formatQueryUsageReport } from '@rawsql-ts/sql-grep-core'; @@ -41,11 +41,11 @@ const report = buildQueryUsageReport({ console.log(formatQueryUsageReport(report, 'text')); ``` -## Relationship to ztd-cli +## Relationship to Ashiba -- `@rawsql-ts/ztd-cli` remains the user-facing CLI surface. -- `ztd query uses` now delegates its analysis engine to `@rawsql-ts/sql-grep-core`. -- Telemetry and command-line UX stay in `ztd-cli`; reusable analysis lives here. +- `@ashiba-ts/cli` is the user-facing CLI surface for SQL lifecycle workflows. +- Ashiba query commands can delegate reusable analysis to `@rawsql-ts/sql-grep-core`. +- Telemetry and command-line UX stay in Ashiba; reusable analysis lives here. ## License diff --git a/packages/sql-grep-core/src/query/report.ts b/packages/sql-grep-core/src/query/report.ts index 07d6335ce..95f057cf3 100644 --- a/packages/sql-grep-core/src/query/report.ts +++ b/packages/sql-grep-core/src/query/report.ts @@ -120,7 +120,7 @@ export function buildQueryUsageReport(params: BuildQueryUsageReportParams): Quer ? `No QuerySpec entries found under ${activeScope}. Hint: pass a narrower --scope-dir only when you need to limit the active scan.` : `No QuerySpec entries were discovered under ${activeScope}. -Hint: run "ztd init" or place feature-local specs under your project tree. Use --scope-dir only when you need to narrow the scan.`, +Hint: run "ashiba init" or place feature-local specs under your project tree. Use --scope-dir only when you need to narrow the scan.`, }); } diff --git a/packages/testkit-sqlite/tests/utils/sqlCatalog.ts b/packages/testkit-sqlite/tests/utils/sqlCatalog.ts index 7806b4f2d..8eddb9cfb 100644 --- a/packages/testkit-sqlite/tests/utils/sqlCatalog.ts +++ b/packages/testkit-sqlite/tests/utils/sqlCatalog.ts @@ -1,13 +1,202 @@ -// Temporary shim: keep testkit-sqlite tests decoupled from direct cross-package paths. -// When a shared test utility package is extracted, swap this re-export target. -/** - * Re-export SQL catalog test utilities for testkit-sqlite test suites. - */ -export { - defineSqlCatalog, - runSqlCatalog, - exportSqlCatalogEvidence, - type SqlCatalog, - type SqlCatalogExecutor, - type RunSqlCatalogOptions, -} from '../../../ztd-cli/tests/utils/sqlCatalog'; +import type { TableFixture } from '@rawsql-ts/testkit-core'; +import { expect, it } from 'vitest'; + +/** + * Pure SQL catalog definition type shared by sqlite test suites. + */ +export interface SqlCatalogDefinition, TRow extends Record> { + id: string; + params: { + shape: 'named'; + example: TParams; + }; + output: { + mapping: { + columnMap: Record; + }; + }; + sql: string; +} + +/** + * Single runnable SQL catalog case with optional arranged parameters and expected rows. + */ +export interface SqlCatalogTestCase< + TParams extends Record, + TRow extends Record, +> { + id: string; + title: string; + arrange?: () => TParams; + expected: TRow[]; +} + +/** + * Executable SQL catalog test specification including fixtures and deterministic cases. + */ +export interface SqlCatalogTestSpec< + TParams extends Record, + TRow extends Record, +> { + id: string; + title: string; + description?: string; + definitionPath?: string; + fixtures: TableFixture[]; + catalog: SqlCatalogDefinition; + cases: SqlCatalogTestCase[]; +} + +export type SqlCatalog< + TParams extends Record = Record, + TRow extends Record = Record, +> = SqlCatalogTestSpec; + +/** + * Executes SQL with fixtures and projects engine rows into DTO rows by `columnMap`. + */ +export type SqlCatalogExecutor = ( + sql: string, + params: Record, + fixtures: TableFixture[], + columnMap: Record +) => Promise[]>; + +/** + * Runtime hooks used by `runSqlCatalog`. + */ +export interface RunSqlCatalogOptions { + executor: SqlCatalogExecutor; + onCaseExecuted?: (id: string) => void; +} + +/** + * Define SQL catalog metadata in a pure, reusable shape. + */ +export function defineSqlCatalogDefinition< + TParams extends Record, + TRow extends Record, +>(def: SqlCatalogDefinition): SqlCatalogDefinition { + return def; +} + +/** + * Define deterministic SQL catalog test specs with stable case ordering. + */ +export function defineSqlCatalog< + TParams extends Record, + TRow extends Record, +>(spec: SqlCatalogTestSpec): SqlCatalogTestSpec { + return { + ...spec, + cases: [...spec.cases].sort((a, b) => a.id.localeCompare(b.id)), + }; +} + +/** + * Register each SQL catalog case as an executable vitest test. + */ +export function runSqlCatalog< + TParams extends Record, + TRow extends Record, +>(spec: SqlCatalogTestSpec, opts: RunSqlCatalogOptions): void { + for (const item of spec.cases) { + it(`[${spec.catalog.id}] ${item.id} ${item.title}`, async () => { + const params = item.arrange ? item.arrange() : spec.catalog.params.example; + const actualRows = await opts.executor( + spec.catalog.sql, + params, + spec.fixtures, + spec.catalog.output.mapping.columnMap + ); + + // Keep verification deterministic in the runner, not in spec definitions. + expect(actualRows as TRow[]).toEqual(item.expected); + opts.onCaseExecuted?.(item.id); + }); + } +} + +/** + * Export SQL catalog evidence in a deterministic, pure shape for specification mode. + */ +export function exportSqlCatalogEvidence( + catalogs: Array, Record>> +): { + catalogs: Array<{ + id: string; + title: string; + description?: string; + definitionPath?: string; + params: { shape: 'named'; example: Record }; + output: { mapping: { columnMap: Record } }; + sql: string; + fixtures: Array<{ + tableName: string; + schema?: { columns: Record }; + rowsCount: number; + }>; + cases: Array<{ + id: string; + title: string; + params: Record; + expected: Record[]; + }>; + }>; +} { + return { + catalogs: [...catalogs] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((catalog) => ({ + id: catalog.id, + title: catalog.title, + ...(catalog.description ? { description: catalog.description } : {}), + ...(catalog.definitionPath ? { definitionPath: catalog.definitionPath } : {}), + params: { + shape: 'named', + example: { ...catalog.catalog.params.example }, + }, + output: { + mapping: { + columnMap: Object.fromEntries( + Object.entries(catalog.catalog.output.mapping.columnMap).sort((a, b) => a[0].localeCompare(b[0])) + ), + }, + }, + // Keep SQL as-is so evidence is a lossless projection of primary test inputs. + sql: catalog.catalog.sql, + fixtures: [...catalog.fixtures] + .map((fixture) => ({ + tableName: fixture.tableName, + ...(fixture.schema && fixture.schema.columns + ? { + schema: { + columns: Object.fromEntries( + Object.entries(fixture.schema.columns).sort((a, b) => a[0].localeCompare(b[0])) + ), + }, + } + : {}), + rowsCount: Array.isArray(fixture.rows) ? fixture.rows.length : 0, + })) + .sort((a, b) => a.tableName.localeCompare(b.tableName)), + cases: [...catalog.cases] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((item) => ({ + id: item.id, + title: item.title, + params: buildCaseParams(catalog.catalog.params.example, item.arrange), + expected: item.expected.map((row) => ({ ...row })), + })), + })), + }; +} + +function buildCaseParams( + baseParams: Record, + arrange?: () => Record +): Record { + const arranged = arrange ? arrange() : undefined; + const merged = arranged ? { ...baseParams, ...arranged } : { ...baseParams }; + return Object.fromEntries(Object.entries(merged).sort((a, b) => a[0].localeCompare(b[0]))); +} diff --git a/packages/transfer/docs/technology/TECHNOLOGY_POLICY.md b/packages/transfer/docs/technology/TECHNOLOGY_POLICY.md index abcc9c966..7deafd1c6 100644 --- a/packages/transfer/docs/technology/TECHNOLOGY_POLICY.md +++ b/packages/transfer/docs/technology/TECHNOLOGY_POLICY.md @@ -8,7 +8,7 @@ ## Purpose -`@rawsql-ts/transfer` は、PostgreSQL、SQL-first、ztd-cli / rawsql-ts を標準経路にした転送制御 package である。 +`@rawsql-ts/transfer` は、PostgreSQL、SQL-first、Ashiba / rawsql-ts を標準経路にした転送制御 package である。 コードから現在の実装技術を観測することはできるが、コードだけでは「その技術が意図した制約なのか、偶然の現状なのか」を判定しにくい。 @@ -18,7 +18,7 @@ - Primary database: PostgreSQL - Data access style: SQL-first -- Standard generation / verification path: ztd-cli and rawsql-ts +- Standard generation / verification path: Ashiba and rawsql-ts - Standard transfer implementation path: reviewed SQL, DDL metadata, queryspec contracts, generated mapper checks, and DB-backed tests - Standard front-facing surface: CLI - Web UI is not a standard surface for this package. If a Web surface is needed, treat it as an owning application boundary outside `@rawsql-ts/transfer`. @@ -39,7 +39,7 @@ 例外を採用する場合は、少なくとも次を明示する。 -- なぜ既存の PostgreSQL / SQL-first / ztd-cli / rawsql-ts 経路では不足するのか +- なぜ既存の PostgreSQL / SQL-first / Ashiba / rawsql-ts 経路では不足するのか - 例外が一時的な adapter なのか、package の標準経路を変える scope expansion なのか - Concept Spec、Scope Spec、Test Policy、DDL metadata、generated docs への影響 - 追加で必要になる検証方法 diff --git a/packages/transfer/docs/technology/tech-rules.json b/packages/transfer/docs/technology/tech-rules.json index d19026c6e..7b7a701a9 100644 --- a/packages/transfer/docs/technology/tech-rules.json +++ b/packages/transfer/docs/technology/tech-rules.json @@ -17,13 +17,13 @@ ] }, { - "id": "sql-first-ztd-cli", + "id": "sql-first-ashiba", "kind": "data-access", - "statement": "transfer の標準実装経路は SQL-first、ztd-cli、rawsql-ts である。", + "statement": "transfer の標準実装経路は SQL-first、Ashiba、rawsql-ts である。", "reviewRisk": "implementation-boundary", "probes": [ "この変更はレビュー可能なSQLを標準経路として維持しているか。", - "ztd-cli / rawsql-ts の生成・検証経路を迂回していないか。" + "Ashiba / rawsql-ts の生成・検証経路を迂回していないか。" ] }, { @@ -33,7 +33,7 @@ "reviewRisk": "architecture-boundary", "probes": [ "ORM 導入が adapter-level の例外ではなく標準経路変更になっていないか。", - "ORM を使う場合、なぜ SQL-first / ztd-cli / rawsql-ts では不足するのかが明示されているか。" + "ORM を使う場合、なぜ SQL-first / Ashiba / rawsql-ts では不足するのかが明示されているか。" ] }, { diff --git a/packages/transfer/package.json b/packages/transfer/package.json index de2fa11f7..f54f46cf8 100644 --- a/packages/transfer/package.json +++ b/packages/transfer/package.json @@ -36,8 +36,7 @@ "format": "prettier . --write", "lint": "node ./scripts/check-sql-leading-commas.mjs && eslint src tests --ext .ts", "lint:sql": "node ./scripts/check-sql-leading-commas.mjs", - "lint:fix": "eslint . --fix", - "ztd": "node ./scripts/local-source-guard.mjs ztd" + "lint:fix": "eslint . --fix" }, "lint-staged": { "*.{ts,tsx,js,jsx,json,md,sql}": [ @@ -54,7 +53,6 @@ "devDependencies": { "@rawsql-ts/testkit-core": "workspace:*", "@rawsql-ts/testkit-postgres": "workspace:*", - "@rawsql-ts/ztd-cli": "workspace:*", "@testcontainers/postgresql": "^12.0.1", "@types/node": "22.18.7", "dotenv": "^16.6.1", diff --git a/packages/transfer/scripts/local-source-guard.mjs b/packages/transfer/scripts/local-source-guard.mjs index 8cf0c0adb..5b0b11c44 100644 --- a/packages/transfer/scripts/local-source-guard.mjs +++ b/packages/transfer/scripts/local-source-guard.mjs @@ -6,60 +6,11 @@ import { spawnSync } from 'node:child_process'; const projectRoot = process.cwd(); const command = process.argv[2]; -if (command !== 'test' && command !== 'typecheck' && command !== 'ztd') { - console.error('local-source guard expects "test", "typecheck", or "ztd".'); +if (command !== 'test' && command !== 'typecheck') { + console.error('local-source guard expects "test" or "typecheck".'); process.exit(1); } -if (command === 'ztd') { - const cliEntry = path.resolve(projectRoot, '../ztd-cli/dist/index.js'); - const requestedSubcommand = process.argv.slice(3).join(' ').trim() || '(none)'; - if (!existsSync(cliEntry)) { - console.error( - [ - '[local-source guard] ztd cannot run against the local source checkout yet.', - '', - `What happened:`, - `- Requested subcommand: ${requestedSubcommand}`, - `- Project root: ${normalizePath(projectRoot)}`, - `- The local CLI entry was not found at ${normalizePath(cliEntry)}.`, - '', - 'Next steps:', - '1. Build the local CLI package (for example: pnpm --filter @rawsql-ts/ztd-cli build)', - '2. Confirm this scaffold still points at the intended rawsql-ts monorepo root', - '3. Re-run pnpm ztd ' - ].join('\n') - ); - process.exit(1); - } - - const cliArgs = process.argv.slice(3); - const result = spawnSync(process.execPath, [cliEntry, ...cliArgs], { - cwd: projectRoot, - stdio: 'inherit', - shell: false - }); - - // Surface execution failures explicitly so local-source dogfooding does not - // collapse permission, spawn, and signal problems into the same exit code. - if (result.error) { - console.error('[local-source guard] Failed to launch the local ztd CLI entry.'); - console.error(`- Message: ${result.error.message}`); - if (result.error.stack) { - console.error(result.error.stack); - } - process.exit(1); - } - - if (result.signal) { - console.error('[local-source guard] The local ztd CLI entry was terminated by signal.'); - console.error(`- Signal: ${result.signal}`); - process.exit(1); - } - - process.exit(result.status ?? 1); -} - const workspaceRoot = findAncestorPnpmWorkspaceRoot(projectRoot); const installCommand = workspaceRoot ? 'pnpm install --ignore-workspace' : 'pnpm install'; const rerunCommand = command === 'test' ? 'pnpm test' : 'pnpm typecheck'; diff --git a/packages/transfer/src/adapters/pg/sql-client.ts b/packages/transfer/src/adapters/pg/sql-client.ts index 99e64ab39..222769b86 100644 --- a/packages/transfer/src/adapters/pg/sql-client.ts +++ b/packages/transfer/src/adapters/pg/sql-client.ts @@ -9,7 +9,7 @@ import type { SqlClient } from '#libraries/sql/sql-client.js'; * * Usage: * // This runtime example uses DATABASE_URL for application code. - * // ztd-cli itself does not read DATABASE_URL implicitly. + * // Ashiba itself does not read DATABASE_URL implicitly. * const pool = new Pool({ connectionString: process.env.DATABASE_URL }); * const client = fromPg(pool); * const users = await client.query<{ id: number }>('SELECT id ...', []); diff --git a/packages/transfer/src/features/README.md b/packages/transfer/src/features/README.md index ab852021f..50c8ec311 100644 --- a/packages/transfer/src/features/README.md +++ b/packages/transfer/src/features/README.md @@ -31,7 +31,7 @@ boundary/ Use `src/features/_shared/*` only for feature-facing shared seams such as `FeatureQueryExecutor`. Keep driver-neutral helpers in `src/libraries/*`, driver or sink bindings in `src/adapters//*`, and keep `db/` reserved for DDL, migrations, and schema assets. -Use `ztd feature tests scaffold --feature ` after SQL and DTO edits to refresh `src/features//queries//tests/generated/TEST_PLAN.md` and `analysis.json`, keep the thin `src/features//queries//tests/.boundary.ztd.test.ts` entrypoint in sync, and add persistent cases under `src/features//queries//tests/cases/` with the fixed app-level ZTD runner. +Use `ashiba feature tests scaffold --feature ` after SQL and DTO edits to refresh `src/features//queries//tests/generated/TEST_PLAN.md` and `analysis.json`, keep the thin `src/features//queries//tests/.boundary.ztd.test.ts` entrypoint in sync, and add persistent cases under `src/features//queries//tests/cases/` with the fixed app-level ZTD runner. When you are on the boundary lane, treat it as query-local: `src/features//queries//tests/.boundary.ztd.test.ts`, `src/features//queries//tests/generated/`, and `src/features//queries//tests/cases/` move together, while the feature-root `src/features//tests/.boundary.test.ts` stays on the mock-based lane. ## Import Paths diff --git a/packages/transfer/src/features/create-transfer-setting/queries/insert-transfer-setting-destination-definition/generated/row-mapper.ts b/packages/transfer/src/features/create-transfer-setting/queries/insert-transfer-setting-destination-definition/generated/row-mapper.ts index 1b329ce1a..a8b9870f8 100644 --- a/packages/transfer/src/features/create-transfer-setting/queries/insert-transfer-setting-destination-definition/generated/row-mapper.ts +++ b/packages/transfer/src/features/create-transfer-setting/queries/insert-transfer-setting-destination-definition/generated/row-mapper.ts @@ -1,5 +1,5 @@ -// @generated by rawsql-ts ztd-cli. Do not edit. -// This file is machine-owned and regenerated by `ztd feature generated-mapper generate`. +// @generated by Ashiba. Do not edit. +// This file is machine-owned and regenerated by `ashiba feature generated-mapper generate`. // source-boundary-sha256: a9c9bd9f73232dd92d7100de39c6af82b3813f5d4dbd15fe5c8ab1b72f39976d // source-sql-sha256: 7260ba5b25f12e3c6c5765044459a8c887981ec9440f5a7c1a5bfce74f976637 diff --git a/packages/transfer/src/features/create-transfer-setting/queries/insert-transfer-setting/generated/row-mapper.ts b/packages/transfer/src/features/create-transfer-setting/queries/insert-transfer-setting/generated/row-mapper.ts index eb85dd43e..f6950af6a 100644 --- a/packages/transfer/src/features/create-transfer-setting/queries/insert-transfer-setting/generated/row-mapper.ts +++ b/packages/transfer/src/features/create-transfer-setting/queries/insert-transfer-setting/generated/row-mapper.ts @@ -1,5 +1,5 @@ -// @generated by rawsql-ts ztd-cli. Do not edit. -// This file is machine-owned and regenerated by `ztd feature generated-mapper generate`. +// @generated by Ashiba. Do not edit. +// This file is machine-owned and regenerated by `ashiba feature generated-mapper generate`. // source-boundary-sha256: 9fb53aa25f7ec7bcccbc7ff08c9503a555066591418063bdd4c5615fcfd3eb40 // source-sql-sha256: 8f37ebf759b0084a32ae43d51e9fd8318fe8d733fc91bf1d44f35f48cead9b11 diff --git a/packages/transfer/src/features/create-transfer-setting/queries/resolve-transfer-destination-definitions/generated/row-mapper.ts b/packages/transfer/src/features/create-transfer-setting/queries/resolve-transfer-destination-definitions/generated/row-mapper.ts index 0acb241a6..6414e4944 100644 --- a/packages/transfer/src/features/create-transfer-setting/queries/resolve-transfer-destination-definitions/generated/row-mapper.ts +++ b/packages/transfer/src/features/create-transfer-setting/queries/resolve-transfer-destination-definitions/generated/row-mapper.ts @@ -1,5 +1,5 @@ -// @generated by rawsql-ts ztd-cli. Do not edit. -// This file is machine-owned and regenerated by `ztd feature generated-mapper generate`. +// @generated by Ashiba. Do not edit. +// This file is machine-owned and regenerated by `ashiba feature generated-mapper generate`. // source-boundary-sha256: e8af2ff1e7dfcf8fe3318fca5cee2f626e0a4344eb754c97ff2f7769694fcbf2 // source-sql-sha256: 574d12d2252916d6c20ecb75e2ade17c1e44cbeac3bc88647d7c9c5ec482faea diff --git a/packages/ztd-cli/.npmignore b/packages/ztd-cli/.npmignore deleted file mode 100644 index 7f3bda686..000000000 --- a/packages/ztd-cli/.npmignore +++ /dev/null @@ -1 +0,0 @@ -# Intentionally empty: rely on package.json "files" so the templates/ bundle is shipped as-is. diff --git a/packages/ztd-cli/CHANGELOG.md b/packages/ztd-cli/CHANGELOG.md deleted file mode 100644 index 368b70444..000000000 --- a/packages/ztd-cli/CHANGELOG.md +++ /dev/null @@ -1,655 +0,0 @@ -# @rawsql-ts/ztd-cli - -## 0.27.4 - -### Patch Changes - -- Updated dependencies [[`4698a87`](https://github.com/mk3008/rawsql-ts/commit/4698a87e9a73f8d6b87b0545cb0a740246f7d457)]: - - rawsql-ts@0.23.0 - - @rawsql-ts/adapter-node-pg@0.15.11 - - @rawsql-ts/sql-grep-core@0.1.12 - -## 0.27.3 - -### Patch Changes - -- Updated dependencies [[`95cf764`](https://github.com/mk3008/rawsql-ts/commit/95cf764a6ed70ec158f594f023354bfc9bc81110)]: - - rawsql-ts@0.22.0 - - @rawsql-ts/adapter-node-pg@0.15.10 - - @rawsql-ts/sql-grep-core@0.1.11 - -## 0.27.2 - -### Patch Changes - -- [#848](https://github.com/mk3008/rawsql-ts/pull/848) [`ab62ac5`](https://github.com/mk3008/rawsql-ts/commit/ab62ac5b26cb14a10cc8905fceabcf2590fe4a27) Thanks [@mk3008](https://github.com/mk3008)! - Tighten the starter first-run experience by keeping the generated smoke QuerySpec typecheckable, wrapping aggregate Postgres connection failures with concise recovery steps, and extending publish artifact verification to run starter typecheck, DB-free smoke tests, and ztd-config before release. - -## 0.27.1 - -### Patch Changes - -- [#845](https://github.com/mk3008/rawsql-ts/pull/845) [`da68ad5`](https://github.com/mk3008/rawsql-ts/commit/da68ad52c3622614c53d68ee6a8ddf073ac5917b) Thanks [@mk3008](https://github.com/mk3008)! - Improve starter onboarding failures by replacing npm's default test placeholder during init and generating actionable database setup errors for missing or unreachable starter Postgres connections. - -## 0.27.0 - -### Minor Changes - -- [#807](https://github.com/mk3008/rawsql-ts/pull/807) [`b70e2a1`](https://github.com/mk3008/rawsql-ts/commit/b70e2a10a4adf559dd1226b83aec1ef7402e4530) Thanks [@mk3008](https://github.com/mk3008)! - Make `ztd feature scaffold` generate camelCase feature-boundary DTOs by default while keeping query-boundary params DB-shaped. - - Generated feature boundaries now map camelCase request fields to explicit snake_case query params, map DB-shaped query results back into camelCase responses, and scaffold JSON/JSONB columns as object inputs when they can be represented safely. - -- [#811](https://github.com/mk3008/rawsql-ts/pull/811) [`8b01f35`](https://github.com/mk3008/rawsql-ts/commit/8b01f3574e6b9c3c30e8e66022015f62e77fb4b7) Thanks [@mk3008](https://github.com/mk3008)! - Add generated row mapper support for RFBA scaffolds. - - New RFBA query scaffolds now include a machine-owned `generated/row-mapper.ts` file and use it from the query boundary by default, keeping the public boundary thin while avoiding runtime mapper overhead for standard scaffold output. Generated mapper files include a machine-owned header and are synchronized by `ztd feature generated-mapper generate`; `ztd feature generated-mapper check` fails on drift and prints the regeneration command for CI/test workflows. - - List query generated mappers now emit preallocated-loop direct assignment instead of `rows.map(...)` with a per-row helper, preserving the normal scaffold usage model while keeping the generated mapper hot path closer to handwritten row projection. - - The repository drift check now includes a real scaffold fixture so `pnpm verify:generated-mapper-drift` exercises a non-skip target in CI. If a generated mapper is stale, the check fails and reports the `ztd feature generated-mapper generate ...` command needed to refresh the machine-owned artifact. - - ztd-cli can generate a narrowly scoped one-root/one-collection RFBA row mapper from query boundary metadata. The generated aggregation preserves SQL row order, respects nullable child presence guards, uses direct assignment without object spread in the hot loop, and serializes composite root keys with typed length-prefixed segments so delimiter characters inside key values do not collide. ztd-cli also reports a clear metadata requirement when `*GeneratedMapperMetadata` cannot be parsed. - - `ztd model-gen` now preserves PostgreSQL `$n` placeholders when building live metadata probe SQL after named or positional parameter binding. - -- [#809](https://github.com/mk3008/rawsql-ts/pull/809) [`a7f0078`](https://github.com/mk3008/rawsql-ts/commit/a7f00785405cc65878a3e4bb07942b8a32a4af5a) Thanks [@mk3008](https://github.com/mk3008)! - Add an `--insert-default-policy` option to feature scaffolding so INSERT templates can either copy DDL default expressions into SQL with `explicit-defaults` or omit DB-default columns with `omit-db-defaults`. - -- [#808](https://github.com/mk3008/rawsql-ts/pull/808) [`167a557`](https://github.com/mk3008/rawsql-ts/commit/167a55772977da9b4b0a7f75afca37d972aed779) Thanks [@mk3008](https://github.com/mk3008)! - Export rawsql-ts tokenizer metadata and add an opt-in `ztd query lint --rules leading-comma` SQL style rule that reports multiline trailing commas without rewriting SQL comments. - -- [#786](https://github.com/mk3008/rawsql-ts/pull/786) [`ccc334e`](https://github.com/mk3008/rawsql-ts/commit/ccc334ef02a6d03f1b76f1bdeb5059585c3a111e) Thanks [@mk3008](https://github.com/mk3008)! - Remove customer-facing AI control file distribution from `ztd-cli`. - - This removes the `ztd agents` command group, removes the `ztd init` AI guidance flags, and stops starter/init scaffolds from distributing `AGENTS.md`, `.codex/**`, `CONTEXT.md`, `PROMPT_DOGFOOD.md`, or `.ztd/agents/**` artifacts. - -- [#792](https://github.com/mk3008/rawsql-ts/pull/792) [`77c206c`](https://github.com/mk3008/rawsql-ts/commit/77c206ccd757ab61ae3571a49c9388ee826ac11e) Thanks [@mk3008](https://github.com/mk3008)! - Add `ztd rfba inspect` as a read-only RFBA boundary inspection command. - - The command reports starter root boundaries, feature boundaries, query sub-boundaries, likely public surface files, SQL assets, generated artifacts, local verification files, and structural warnings in text or deterministic JSON output. - -- [#814](https://github.com/mk3008/rawsql-ts/pull/814) [`087b6e8`](https://github.com/mk3008/rawsql-ts/commit/087b6e8882d1044c092f69e0e6cb0a87e6bea53e) Thanks [@mk3008](https://github.com/mk3008)! - Improve RFBA feature scaffolding so `ztd feature scaffold` now generates feature-local `input.ts`, `workflow.ts`, and `output.ts` alongside a thin `boundary.ts`. - - The generated boundary now reads as an `input -> workflow -> output` review flow, while workflow code accepts query ports so tests do not need to infer query identity from SQL text that may be transformed before execution. Multi-query `feature tests scaffold` failures now include the discovered query names and concrete `--query` commands to run. - -- [#797](https://github.com/mk3008/rawsql-ts/pull/797) [`25d26c6`](https://github.com/mk3008/rawsql-ts/commit/25d26c6489ce80bb96c96bbcb77713a58e7a99d5) Thanks [@mk3008](https://github.com/mk3008)! - Add `ztd rfba review-data` to generate deterministic RFBA review packet JSON from Git diffs. The command classifies changed files, maps changes to RFBA boundaries, summarizes supported DDL and SQL changes, groups verification evidence, and writes CI-friendly JSON with review hints and warnings. - -- [#791](https://github.com/mk3008/rawsql-ts/pull/791) [`c6d9f6b`](https://github.com/mk3008/rawsql-ts/commit/c6d9f6b8a3d3ec4e94b0e8ead586432fa07217c4) Thanks [@mk3008](https://github.com/mk3008)! - Align `ztd check contract` and `ztd evidence` with RFBA feature-local QuerySpec discovery. Both commands now scan project QuerySpec-like assets by default, support `--scope-dir` for feature or subtree reviews, and keep `--specs-dir` as the legacy fixed catalog-spec override. - -- [#836](https://github.com/mk3008/rawsql-ts/pull/836) [`8d82bdf`](https://github.com/mk3008/rawsql-ts/commit/8d82bdfb00d3c18c2b188ee17130879f6aabc63b) Thanks [@mk3008](https://github.com/mk3008)! - Add a thin SQL driver adapter core package and update standard ztd-cli scaffolds so SQL files can keep readable named parameters while node-postgres receives positional placeholders at execution time. - -### Patch Changes - -- [#805](https://github.com/mk3008/rawsql-ts/pull/805) [`304252a`](https://github.com/mk3008/rawsql-ts/commit/304252a84a1bd4f38272dc33bb2732a69850182a) Thanks [@mk3008](https://github.com/mk3008)! - Improve generated starter templates for formatting hooks, SQL client adapter contracts, query-boundary test types, config normalization, and traditional queryspec schema rewriting. - -- [#806](https://github.com/mk3008/rawsql-ts/pull/806) [`eb01f7e`](https://github.com/mk3008/rawsql-ts/commit/eb01f7e815b9763399a011d06527cf52c718b7d9) Thanks [@mk3008](https://github.com/mk3008)! - Document the RFBA feature/query test lane split in the canonical guide and generated scaffold guidance. - - The generated guidance now makes feature-boundary tests mock child query boundaries for validation, mapping, and orchestration, while query-boundary tests own SQL behavior through ZTD or another SQL-specific lane. It also clarifies that `src/libraries/` is only for driver-neutral code reusable enough to stand as an external package, not feature-specific validation or helpers. - -- [#836](https://github.com/mk3008/rawsql-ts/pull/836) [`8d82bdf`](https://github.com/mk3008/rawsql-ts/commit/8d82bdfb00d3c18c2b188ee17130879f6aabc63b) Thanks [@mk3008](https://github.com/mk3008)! - Remove the workspace `@rawsql-ts/sql-contract` package from the standard runtime path. - - `ztd-cli` generated query paths now continue toward runtime-free execution with thin executor calls and AOT generated row mappers. `testkit-postgres` now owns its small query-result normalization shape directly, and the node-pg adapter build no longer depends on the removed package. - -- [#782](https://github.com/mk3008/rawsql-ts/pull/782) [`8d2af8f`](https://github.com/mk3008/rawsql-ts/commit/8d2af8f7fa1c9f72c32d19b3fb1319f1c4d27ce6) Thanks [@mk3008](https://github.com/mk3008)! - Rename the documented architecture guidance from BFA to RFBA (Review-First Backend Architecture), add a "What Is RFBA?" guide, and clarify the `root-boundary`, `feature-boundary`, and `sub-boundary` structural vocabulary in the `ztd-cli` docs and generated feature README guidance. - - This update keeps `boundary.ts` as the default scaffold convention inside `src/features/*` while making it clear that RFBA is defined by review responsibility, DDL as the data-structure source of truth, visible SQL review boundaries, public surfaces, dependency direction, and boundary-local verification rather than by one universal filename. - -- [#793](https://github.com/mk3008/rawsql-ts/pull/793) [`eb89983`](https://github.com/mk3008/rawsql-ts/commit/eb89983c5276632e2039c995d6b3bc25aa2648a8) Thanks [@mk3008](https://github.com/mk3008)! - Wire the generated traditional QuerySpec test lane to an active physical runner. - - The scaffold now generates traditional test cases that execute against isolated physical database setup, records mode-specific evidence, supports optional `afterDb` assertions, and keeps the starter setup environment dependency available during package tests. - -- [#790](https://github.com/mk3008/rawsql-ts/pull/790) [`1e2107a`](https://github.com/mk3008/rawsql-ts/commit/1e2107ac7f0254937da5863173658566d53d7a4b) Thanks [@mk3008](https://github.com/mk3008)! - Make `ztd feature tests scaffold` generate query-local ZTD case scaffolds as explicit TODO placeholders. - - The generated ZTD Vitest entrypoint now starts skipped until the case values are filled, and the placeholder case includes CLI-discovered table, input, and output hints so users and AI agents can see what still needs to be completed before enabling the test. - -- [#810](https://github.com/mk3008/rawsql-ts/pull/810) [`e2896a9`](https://github.com/mk3008/rawsql-ts/commit/e2896a903eb80f293b398fa34934b53485be1458) Thanks [@mk3008](https://github.com/mk3008)! - Clarify ZTD constraint coverage in generated query-boundary scaffolds. - - `ztd feature tests scaffold` now records constraint-boundary guidance in generated test plans, analysis JSON, and JSON command output. When DDL for the affected tables contains PostgreSQL constraints that the ZTD fixture/CTE lane does not fully enforce, the scaffold emits TODO guidance that points users to traditional physical DB coverage for DB-enforced failure behavior. - -- Updated dependencies [[`167a557`](https://github.com/mk3008/rawsql-ts/commit/167a55772977da9b4b0a7f75afca37d972aed779), [`21bce06`](https://github.com/mk3008/rawsql-ts/commit/21bce0606888748b9c584c2a597f520f4d25602a), [`8d82bdf`](https://github.com/mk3008/rawsql-ts/commit/8d82bdfb00d3c18c2b188ee17130879f6aabc63b), [`8d82bdf`](https://github.com/mk3008/rawsql-ts/commit/8d82bdfb00d3c18c2b188ee17130879f6aabc63b), [`927dc07`](https://github.com/mk3008/rawsql-ts/commit/927dc07efeb6188f286f030e9585f7651517d0fc)]: - - rawsql-ts@0.21.0 - - @rawsql-ts/adapter-node-pg@0.15.9 - - @rawsql-ts/sql-grep-core@0.1.10 - -## 0.26.0 - -### Minor Changes - -- [#773](https://github.com/mk3008/rawsql-ts/pull/773) [`e9e425f`](https://github.com/mk3008/rawsql-ts/commit/e9e425f77b51402fcca03393305ac36bc99d7576) Thanks [@mk3008](https://github.com/mk3008)! - Improve SSSQL `refresh` so correlated `EXISTS` / `NOT EXISTS` branches can be safely re-anchored after query structure changes. - - `rawsql-ts` now relocates correlated optional branches by inferring a single anchor from outer references, rebases aliases when moving branches across query scopes, and fails fast when anchor inference is missing or ambiguous. - - `@rawsql-ts/ztd-cli` adds regression coverage for correlated `refresh` round-trips and `remove --all` interoperability so SQL-first optional branch maintenance stays deterministic after scaffolding. - -- [#772](https://github.com/mk3008/rawsql-ts/pull/772) [`595f86c`](https://github.com/mk3008/rawsql-ts/commit/595f86c2631f034b01c70533b4a6063e696544c6) Thanks [@mk3008](https://github.com/mk3008)! - Add `--test-kind ztd|traditional` to `ztd feature tests scaffold` and keep `ztd` as the default. - - This update enables side-by-side lane scaffolding for query tests without breaking existing ZTD-only workflows: - - `ztd` lane keeps generating the current files (`.boundary.ztd.test.ts`, `generated/TEST_PLAN.md`, `generated/analysis.json`). - - `traditional` lane generates lane-specific scaffold files (`.boundary.traditional.test.ts`, `boundary-traditional-types.ts`, `generated/TEST_PLAN.traditional.md`, `generated/analysis.traditional.json`, and `cases/basic.traditional.case.ts`). - - The new lane scaffold is intentionally thin and keeps mode execution responsibility in the library layer. - -- [#777](https://github.com/mk3008/rawsql-ts/pull/777) [`9ee5744`](https://github.com/mk3008/rawsql-ts/commit/9ee57445c0dd3a691ff4f227d6f4024960086ab2) Thanks [@mk3008](https://github.com/mk3008)! - Align the starter scaffold and guidance with the canonical directory taxonomy. - - Starter projects now treat `src/features`, `src/adapters`, and `src/libraries` as the app-code roots, keep `db/` for DDL and migration assets, keep shared verification support under `tests/support/*`, and keep `.ztd/*` tool-managed. - - The scaffolded `SqlClient` and telemetry seams now follow that layout, and the generated README, AGENTS guidance, and tutorial docs describe the same ownership model. - -- [#765](https://github.com/mk3008/rawsql-ts/pull/765) [`6a1cb41`](https://github.com/mk3008/rawsql-ts/commit/6a1cb415366f3b8c0650f1caac67d9235ed1a130) Thanks [@mk3008](https://github.com/mk3008)! - Expand SSSQL authoring and inspection across the core library and `ztd-cli`. - - `ztd query sssql` now supports `list`, `remove`, `remove --all`, richer scalar operators, and structured `EXISTS` / `NOT EXISTS` scaffold input with preview-friendly rewrite flows. The CLI also fails fast when a rewrite would drop existing SQL comments. - - `rawsql-ts` now exposes the branch metadata and removal helpers needed to inspect, remove, and bulk-remove recognized SSSQL branches while keeping runtime pruning explicit through `optionalConditionParameters`. - -### Patch Changes - -- [#770](https://github.com/mk3008/rawsql-ts/pull/770) [`5eeb927`](https://github.com/mk3008/rawsql-ts/commit/5eeb927fec2e09e611ca2d764973e95d04c9e154) Thanks [@mk3008](https://github.com/mk3008)! - Make the starter ZTD helper a thin adapter over library mode execution and return machine-checkable run evidence. - - The generated ZTD harness now asserts `mode=ztd` and `physicalSetupUsed=false`, supports opt-in SQL trace output, and is covered by starter acceptance that runs `vitest` before schema setup in generated-project verification. - -- [#780](https://github.com/mk3008/rawsql-ts/pull/780) [`3f9cab6`](https://github.com/mk3008/rawsql-ts/commit/3f9cab6fd9eb782aa1f92bb3c19e83c6bbe4ecb1) Thanks [@mk3008](https://github.com/mk3008)! - Align generated project root aliases across `package.json`, `tsconfig.json`, and `vitest.config.ts` so scaffolded code can use `#features/*`, `#libraries/*`, `#adapters/*`, and `#tests/*` consistently. - - Starter and scaffold templates now use root aliases for imports that cross canonical roots instead of deep relative paths, which makes generated projects easier to move and reorganize without rewriting import depth. - -- Updated dependencies [[`e9e425f`](https://github.com/mk3008/rawsql-ts/commit/e9e425f77b51402fcca03393305ac36bc99d7576), [`6a1cb41`](https://github.com/mk3008/rawsql-ts/commit/6a1cb415366f3b8c0650f1caac67d9235ed1a130)]: - - rawsql-ts@0.20.0 - - @rawsql-ts/adapter-node-pg@0.15.8 - - @rawsql-ts/sql-grep-core@0.1.9 - -## 0.25.0 - -### Minor Changes - -- [#752](https://github.com/mk3008/rawsql-ts/pull/752) [`6bf1fcc`](https://github.com/mk3008/rawsql-ts/commit/6bf1fccfcf3cdce4b74cc42ef3d086c54defb54b) Thanks [@mk3008](https://github.com/mk3008)! - Add `ztd feature query scaffold` for creating child query boundaries under an existing boundary without rewriting the parent boundary. - - Promote `--scope-dir` as the primary `ztd query uses` narrowing flag while keeping `--specs-dir` as a deprecated compatibility alias. - - Support `MERGE ... RETURNING` as a writable CTE output shape in `rawsql-ts` so downstream SELECT and CTE analysis can resolve returned columns consistently across supported DML forms. - -- [#756](https://github.com/mk3008/rawsql-ts/pull/756) [`a4f4b56`](https://github.com/mk3008/rawsql-ts/commit/a4f4b5663b65d2a913158c6916f969f3a05117a6) Thanks [@mk3008](https://github.com/mk3008)! - `ztd query uses` no longer accepts the deprecated `--specs-dir` alias. Use `--scope-dir` when you need to narrow the project-wide QuerySpec scan to one feature or subtree. - -### Patch Changes - -- [#740](https://github.com/mk3008/rawsql-ts/pull/740) [`36f6e6c`](https://github.com/mk3008/rawsql-ts/commit/36f6e6c249385c8d3e4aded063b100f2e2465d61) Thanks [@mk3008](https://github.com/mk3008)! - Stabilize scaffolded shared imports in deep recursive boundary layouts without rewriting every generated import style. - - Generated query-boundary files now use stable shared specifiers for `src/features/_shared/*` and `tests/support/*`, while nearby boundary-local imports stay relative. Starter scaffolds also add the matching package imports, TypeScript paths, and Vitest aliases so deeper boundary splits are less likely to break when code moves into lower child boundaries. - -- Updated dependencies [[`6bf1fcc`](https://github.com/mk3008/rawsql-ts/commit/6bf1fccfcf3cdce4b74cc42ef3d086c54defb54b)]: - - rawsql-ts@0.19.0 - - @rawsql-ts/adapter-node-pg@0.15.7 - - @rawsql-ts/sql-grep-core@0.1.8 - -## 0.24.3 - -### Patch Changes - -- [#724](https://github.com/mk3008/rawsql-ts/pull/724) [`d6b7162`](https://github.com/mk3008/rawsql-ts/commit/d6b71629e6ae2f9000bf2e15153ccea967cebad8) Thanks [@mk3008](https://github.com/mk3008)! - Fix starter scaffolds so the Vitest setup derives `ZTD_TEST_DATABASE_URL` from `ZTD_DB_PORT`, which keeps the DB-backed smoke test aligned with the compose port even if an older localhost:5432 URL is still present. Add a regression test for the generated setup file so the port override stays authoritative. - -- [#724](https://github.com/mk3008/rawsql-ts/pull/724) [`900faa8`](https://github.com/mk3008/rawsql-ts/commit/900faa810588adbd6550d7008bee9e181fa786e0) Thanks [@mk3008](https://github.com/mk3008)! - The starter scaffold now treats `.env` as the single source of truth for database connection settings. `compose.yaml` reads the DB host, port, name, user, and password from `.env`, and the generated Vitest setup derives `ZTD_TEST_DATABASE_URL` from those values. If a preexisting `ZTD_TEST_DATABASE_URL` conflicts with the starter DB settings, `ztd` now fails fast instead of silently choosing one source. - -## 0.24.2 - -### Patch Changes - -- [#722](https://github.com/mk3008/rawsql-ts/pull/722) [`fcc9d19`](https://github.com/mk3008/rawsql-ts/commit/fcc9d1990598483240a64bf0eb92f181c2682ff4) Thanks [@mk3008](https://github.com/mk3008)! - Update the starter smoke scaffold and documentation to use the feature-first layout with query-local ZTD assets under `src/features///tests/`, including the new `smoke` starter structure. - -## 0.24.1 - -### Patch Changes - -- [#715](https://github.com/mk3008/rawsql-ts/pull/715) [`e3eba48`](https://github.com/mk3008/rawsql-ts/commit/e3eba48cca031f04573043ce73f078d3603d8ff0) Thanks [@mk3008](https://github.com/mk3008)! - Fix feature scaffold queryspec generation so CRUD baselines no longer import non-existent `sql-contract` cardinality helpers and instead use locally generated row-count handling. - -## 0.24.0 - -### Minor Changes - -- [#694](https://github.com/mk3008/rawsql-ts/pull/694) [`cc1102f`](https://github.com/mk3008/rawsql-ts/commit/cc1102fdbf19e43eff3a45fc1ffb0afb5218ccc4) Thanks [@mk3008](https://github.com/mk3008)! - Expand `ztd feature scaffold` so the CRUD boundary baseline now supports `--action update` and `--action delete` in addition to `insert`. The generated scaffold keeps the same `entryspec.ts` plus query-local `queryspec.ts` and SQL layout, uses `zod` DTO schemas at both boundaries, creates the empty `tests/` directory, and leaves the two test files for an AI follow-up step. - -- [#692](https://github.com/mk3008/rawsql-ts/pull/692) [`073834c`](https://github.com/mk3008/rawsql-ts/commit/073834cc36edf4df41b4c3571957086f28012688) Thanks [@mk3008](https://github.com/mk3008)! - Simplify `ztd.config.json` by removing the legacy `ddl.defaultSchema` and `ddl.searchPath` mirror. - - `ztd-cli` now reads and writes schema resolution settings only from the top-level `defaultSchema` and `searchPath` fields. Projects that still keep those values under `ddl` must move them to the top level. - -- [#695](https://github.com/mk3008/rawsql-ts/pull/695) [`329f194`](https://github.com/mk3008/rawsql-ts/commit/329f19483e71f9114534406bdea20b6f62b11c4e) Thanks [@mk3008](https://github.com/mk3008)! - Expand `ztd feature scaffold` so the baseline now supports `--action get-by-id` and `--action list` in addition to `insert`, `update`, and `delete`. The generated read scaffolds keep the same feature-local layout, use `queryZeroOrOneRow` for `get-by-id`, and keep default paging plus primary-key ordering inside `list/queryspec.ts` while returning `{ items: [...] }`. - - Generated feature/query specs now use shorter private helper names with responsibility-focused JSDoc, reject unsupported request fields by default, and derive bigint-like ID contracts from the DDL instead of assuming 32-bit numeric IDs. - -- [#693](https://github.com/mk3008/rawsql-ts/pull/693) [`ead64e3`](https://github.com/mk3008/rawsql-ts/commit/ead64e37a05a502f9814e9b6a90ff1190c501221) Thanks [@mk3008](https://github.com/mk3008)! - Add a new `ztd feature scaffold` command for insert feature scaffolds. The command creates the fixed feature layout, writes the placeholder feature entrypoint, SQL file, and README, creates the `tests/` directory, and leaves the two test files for an AI follow-up step. - -- [#696](https://github.com/mk3008/rawsql-ts/pull/696) [`a4263c6`](https://github.com/mk3008/rawsql-ts/commit/a4263c66d5eaa97ddfd406b008af7b78caf057f2) Thanks [@mk3008](https://github.com/mk3008)! - Redefine the default project layout around `src/`, `db/ddl/`, and `.ztd/`. - - `ztd init` and `ztd init --starter` now treat `db/ddl` as the human-owned schema source of truth and `.ztd` as the tool-managed workspace for generated and support files. The legacy root `ztd/` and `tests/` layout is no longer supported, and the CLI now reports explicit migration guidance when that older layout is detected. - - This release also removes `SKILL` scaffold output from the Codex bootstrap path, updates the starter docs and dogfooding prompts to match the current feature scaffold shape, and makes monorepo dogfooding installs keep using the local `@rawsql-ts/ztd-cli` package so the generated project matches the current command surface during verification. - -### Patch Changes - -- [#696](https://github.com/mk3008/rawsql-ts/pull/696) [`686edf2`](https://github.com/mk3008/rawsql-ts/commit/686edf2d23960d8108b4c01777364980183664fe) Thanks [@mk3008](https://github.com/mk3008)! - Fix two dogfooded workflow gaps in the current starter/tutorial path. - - `ztd query uses` now discovers scaffolded feature-local `queryspec.ts` files that load SQL through `loadSqlResource(...)`, so DDL repair and usage search work against the generated VSA layout instead of reporting that no QuerySpec entries were found. - - `ztd model-gen --probe-mode ztd` now handles starter-style `INSERT ... RETURNING` scaffolds more reliably by deriving RETURNING column types from the loaded DDL metadata when direct probing cannot resolve them, and it also reads starter `.env` settings to find the ZTD-owned test database without requiring a manually exported runtime connection variable. - -- [#691](https://github.com/mk3008/rawsql-ts/pull/691) [`774601c`](https://github.com/mk3008/rawsql-ts/commit/774601c7b482e922665ba7ec075f255530720815) Thanks [@mk3008](https://github.com/mk3008)! - Strengthen the starter guidance and repository troubleshooting notes so SQL-backed QuerySpecs are treated as ZTD-backed tests, and SQL shadowing failures are diagnosed before considering schema changes. - -- Updated dependencies [[`686edf2`](https://github.com/mk3008/rawsql-ts/commit/686edf2d23960d8108b4c01777364980183664fe)]: - - @rawsql-ts/sql-grep-core@0.1.7 - -## 0.23.0 - -### Minor Changes - -- Clarify the managed workspace layout and bootstrap ownership for new projects. DDL now defaults to `db/ddl`, repo-managed generated/support files live under `.ztd/`, the removed root `ztd/` and `tests/` layouts now fail with explicit migration guidance, and `ztd agents init` no longer scaffolds or ships `SKILL` assets. - -- [#679](https://github.com/mk3008/rawsql-ts/pull/679) [`be9b689`](https://github.com/mk3008/rawsql-ts/commit/be9b6893ff42f783f9cb52f1b8cd9cdc6c120e23) Thanks [@mk3008](https://github.com/mk3008)! - Add SSSQL scaffold and refresh commands, and change `DynamicQueryBuilder` so legacy runtime filter predicates fail fast instead of being injected at runtime. Runtime optional-condition pruning, sort, and paging remain supported. - -### Patch Changes - -- [#682](https://github.com/mk3008/rawsql-ts/pull/682) [`07eb7fd`](https://github.com/mk3008/rawsql-ts/commit/07eb7fdda0b932f3f6bc13d58767e57927d6707e) Thanks [@mk3008](https://github.com/mk3008)! - The starter README now stays focused on entry points, while the repository telemetry setup and observed SQL investigation flows are documented in separate guides. - - You can now follow step-by-step instructions for editing the generated telemetry scaffold, emitting safe structured logs, reviewing queryId-based incidents, and running `ztd query match-observed` when `queryId` is missing. - -- [#687](https://github.com/mk3008/rawsql-ts/pull/687) [`937bb1c`](https://github.com/mk3008/rawsql-ts/commit/937bb1c42484ae4dda72cfac787734d35f485502) Thanks [@mk3008](https://github.com/mk3008)! - `ztd agents init` now installs an opt-in customer-facing Codex bootstrap with visible `AGENTS.md`, `.codex`, and `.agents` guidance. The command surface, templates, status reporting, and docs were updated so reviewers can verify the managed set and the current local `spawn EPERM` blocker separately. - -- [#669](https://github.com/mk3008/rawsql-ts/pull/669) [`0d61ffe`](https://github.com/mk3008/rawsql-ts/commit/0d61ffe7a464133d8d8b6720bcdd43aea432fceb) Thanks [@mk3008](https://github.com/mk3008)! - The starter quickstart now uses a `.env`-based setup flow, includes `.env.example` and `.gitignore`, and loads the starter runtime connection consistently in Vitest. The generated README and tutorial were updated to keep the database port and test runtime aligned. - -- [#673](https://github.com/mk3008/rawsql-ts/pull/673) [`7f4035a`](https://github.com/mk3008/rawsql-ts/commit/7f4035a3caeba7f0b15247957bb0d360beef1296) Thanks [@mk3008](https://github.com/mk3008)! - Improve the starter smoke path so it points to `@rawsql-ts/testkit-postgres` and `createPostgresTestkitClient`, and clarify the generated testkit guidance in the starter docs. - -- [#672](https://github.com/mk3008/rawsql-ts/pull/672) [`68b385e`](https://github.com/mk3008/rawsql-ts/commit/68b385e0407b8a610078ea4c07ee0c602e6910ed) Thanks [@mk3008](https://github.com/mk3008)! - `ztd-config` now reuses shared DDL analysis for linting and table metadata generation, and skips no-op config writes so telemetry matches actual persistence. - -- Updated dependencies [[`07eb7fd`](https://github.com/mk3008/rawsql-ts/commit/07eb7fdda0b932f3f6bc13d58767e57927d6707e), [`be9b689`](https://github.com/mk3008/rawsql-ts/commit/be9b6893ff42f783f9cb52f1b8cd9cdc6c120e23)]: - - @rawsql-ts/sql-grep-core@0.1.6 - - rawsql-ts@0.18.0 - - @rawsql-ts/adapter-node-pg@0.15.6 - -## 0.22.5 - -### Patch Changes - -- [#660](https://github.com/mk3008/rawsql-ts/pull/660) [`2bc9b36`](https://github.com/mk3008/rawsql-ts/commit/2bc9b369918e395ef7fd4eb7fad30b9f42869a00) Thanks [@mk3008](https://github.com/mk3008)! - Improve the starter quickstart and smoke scaffold so the Docker daemon prerequisite is easier to spot, and align the starter `smoke` QuerySpec with the feature-local SQL file path generated by `ztd init`. - -## 0.22.4 - -### Patch Changes - -- [#653](https://github.com/mk3008/rawsql-ts/pull/653) [`4540a22`](https://github.com/mk3008/rawsql-ts/commit/4540a22a57c600cbd4f4dbe2fe160cd8da1fb12e) Thanks [@mk3008](https://github.com/mk3008)! - Improve `query uses` for feature-local VSA projects by discovering QuerySpec files from the project tree, preferring spec-relative SQL resolution, and clarifying the VSA-first impact-analysis contract in docs and CLI help. - -- [#652](https://github.com/mk3008/rawsql-ts/pull/652) [`303a549`](https://github.com/mk3008/rawsql-ts/commit/303a549ce713d4bd53cfa5d30ec1c2515cf9ea06) Thanks [@mk3008](https://github.com/mk3008)! - Republish `@rawsql-ts/ztd-cli` so the published CLI surface for `ztd query lint --rules join-direction` stays aligned with the current Further Reading docs. - - Clarify the public help and guide text so users can confirm that their installed CLI exposes `--rules` before trying the join-direction examples. - -- Updated dependencies [[`4540a22`](https://github.com/mk3008/rawsql-ts/commit/4540a22a57c600cbd4f4dbe2fe160cd8da1fb12e)]: - - @rawsql-ts/sql-grep-core@0.1.5 - -## 0.22.3 - -### Patch Changes - -- [#647](https://github.com/mk3008/rawsql-ts/pull/647) [`bbdae2c`](https://github.com/mk3008/rawsql-ts/commit/bbdae2cadcbd8668fb5f823168c4f1b5eff8a02f) Thanks [@mk3008](https://github.com/mk3008)! - Improve `ztd ddl diff` reviewability by emitting review-first text/json summaries alongside a SQL artifact, and update the migration docs to explain how to inspect and apply the generated files. - -- [#646](https://github.com/mk3008/rawsql-ts/pull/646) [`fe400bc`](https://github.com/mk3008/rawsql-ts/commit/fe400bcdbdb1e71de85b6f65a15de46738480730) Thanks [@mk3008](https://github.com/mk3008)! - Add a published-package verification gate that checks `ztd query lint --help` still exposes `--rules` and that `ztd query lint --rules join-direction ` runs on the packed CLI path. This keeps the release contract aligned with the Further Reading docs for the join-direction lint command surface. - -- [#649](https://github.com/mk3008/rawsql-ts/pull/649) [`6991c7e`](https://github.com/mk3008/rawsql-ts/commit/6991c7e018f10bb2fc5d3d64ba3641aa7ef0219e) Thanks [@mk3008](https://github.com/mk3008)! - Extract the DDL diff risk analyzer into reusable plan-based and SQL-based evaluators so generated and hand-edited migration SQL can be assessed through the same structured risk contract. - -## 0.22.2 - -### Patch Changes - -- Republish `@rawsql-ts/sql-contract` with its runtime `dist/` artifacts so standalone consumers can run `ztd model-gen` in the starter tutorial without a missing module error. - - Refresh the starter README generated by `@rawsql-ts/ztd-cli` so standalone users can recover from a busy `5432` port and keep using `pnpm add -D` when the project was initialized with pnpm. - -## 0.22.1 - -### Patch Changes - -- [#628](https://github.com/mk3008/rawsql-ts/pull/628) [`b579253`](https://github.com/mk3008/rawsql-ts/commit/b5792534c0f01934274c7db980fbe651c58fda4a) Thanks [@mk3008](https://github.com/mk3008)! - Fix the init scaffold so `@rawsql-ts/testkit-core` is installed automatically and `npx ztd ztd-config` works in a fresh standalone project. - -- Updated dependencies [[`5d15113`](https://github.com/mk3008/rawsql-ts/commit/5d151130b492b0bfbb787a1410ceb1eeee0683e6)]: - - @rawsql-ts/sql-grep-core@0.1.4 - - @rawsql-ts/test-evidence-renderer-md@0.3.2 - -## 0.22.0 - -### Minor Changes - -- [#626](https://github.com/mk3008/rawsql-ts/pull/626) [`25fdcd3`](https://github.com/mk3008/rawsql-ts/commit/25fdcd321a239cfeb77d4a9b4fcaaff2f479d88a) Thanks [@mk3008](https://github.com/mk3008)! - Refresh the ztd-cli starter workflow and README so the feature-first starter scaffold, AI prompt, tutorial, and dogfooding guidance line up with the new first-run experience. - -### Patch Changes - -- [#621](https://github.com/mk3008/rawsql-ts/pull/621) [`ecd69d2`](https://github.com/mk3008/rawsql-ts/commit/ecd69d267ae959a65a92fc61b646d098e90ced74) Thanks [@mk3008](https://github.com/mk3008)! - Keep the generated QuerySpec sample aligned with the published consumer smoke path and add a contract guard for uncovered SQL assets so the scaffolded repository example validates raw rows correctly. - -- [#625](https://github.com/mk3008/rawsql-ts/pull/625) [`9a4aab3`](https://github.com/mk3008/rawsql-ts/commit/9a4aab3e59310d60c65794d373f071f7c3016ed7) Thanks [@mk3008](https://github.com/mk3008)! - Add `ztd findings validate` so machine-readable finding registries can be checked deterministically in CI or locally. - -- [#624](https://github.com/mk3008/rawsql-ts/pull/624) [`c6495af`](https://github.com/mk3008/rawsql-ts/commit/c6495afa7c2f18c25ebf33f31f434482ef44f453) Thanks [@mk3008](https://github.com/mk3008)! - Add a small PostgreSQL 18 Docker helper to the Getting Started with AI guidance so users can bootstrap a local ZTD test database more easily. - -- [#623](https://github.com/mk3008/rawsql-ts/pull/623) [`eedf9db`](https://github.com/mk3008/rawsql-ts/commit/eedf9db9bac9d4200d73bd67eb6dc9885b13873b) Thanks [@mk3008](https://github.com/mk3008)! - Add a machine-readable finding registry example, validation helper, and docs link so dogfooding findings can carry evidence and status consistently. - -## 0.21.0 - -### Minor Changes - -- [#616](https://github.com/mk3008/rawsql-ts/pull/616) [`33b300c`](https://github.com/mk3008/rawsql-ts/commit/33b300c147c909296f5a29f547a12210ed612170) Thanks [@mk3008](https://github.com/mk3008)! - Remove the scaffold's `tables/` and `views/` folders and update the docs, AGENTS guidance, and tests so `1 SQL file / 1 QuerySpec / 1 repository entrypoint / 1 DTO` is the only query-unit storage rule. - -### Patch Changes - -- [#610](https://github.com/mk3008/rawsql-ts/pull/610) [`41b6729`](https://github.com/mk3008/rawsql-ts/commit/41b672995f4ffd3d825aaef03697d818e20e2fd8) Thanks [@mk3008](https://github.com/mk3008)! - Clarify repo policy interpretation so `MUST` and `REQUIRED` mean completion criteria, and add a regression test for the canonical policy mirror. - -- [#613](https://github.com/mk3008/rawsql-ts/pull/613) [`99535b1`](https://github.com/mk3008/rawsql-ts/commit/99535b16c00423756c32e37f9d63982cfaede5ed) Thanks [@mk3008](https://github.com/mk3008)! - Clarify repository intent and procedure so source assets and downstream artifacts are read as causality, not just rules. - -## 0.20.3 - -### Patch Changes - -- [#589](https://github.com/mk3008/rawsql-ts/pull/589) [`70b928d`](https://github.com/mk3008/rawsql-ts/commit/70b928d81096165e66ff6578baa78354f39db4b2) Thanks [@mk3008](https://github.com/mk3008)! - Fix npm consumer compatibility for `ztd-cli` by removing the hard `pnpm-workspace.yaml` runtime assumption, requiring `--force` for scaffold overwrites, and emitting Node16/NodeNext-friendly `.js` template imports. - - Keep `@rawsql-ts/sql-contract-zod` publishable with a prepack build step while documenting that new projects should prefer `@rawsql-ts/sql-contract` with `zod`. - -## 0.20.2 - -### Patch Changes - -- Updated dependencies [[`214bb0a`](https://github.com/mk3008/rawsql-ts/commit/214bb0a8d6ceffb193e78d7531d78d6d2182b34a)]: - - @rawsql-ts/sql-grep-core@0.1.3 - -## 0.20.1 - -### Patch Changes - -- [#571](https://github.com/mk3008/rawsql-ts/pull/571) [`6fd0afa`](https://github.com/mk3008/rawsql-ts/commit/6fd0afa9b3faef0e41ba6a56e3d40fc507a9172a) Thanks [@mk3008](https://github.com/mk3008)! - Fix published package manifests so npm consumers do not receive `workspace:` dependency ranges when installing `@rawsql-ts/ztd-cli` and its internal runtime dependencies. - -- Updated dependencies [[`6fd0afa`](https://github.com/mk3008/rawsql-ts/commit/6fd0afa9b3faef0e41ba6a56e3d40fc507a9172a)]: - - @rawsql-ts/sql-grep-core@0.1.2 - - @rawsql-ts/test-evidence-renderer-md@0.3.1 - -## 0.20.0 - -### Minor Changes - -- [#566](https://github.com/mk3008/rawsql-ts/pull/566) [`90c9eb2`](https://github.com/mk3008/rawsql-ts/commit/90c9eb24de83580211fe0bb45d6bf4c53c2f9efb) Thanks [@mk3008](https://github.com/mk3008)! - `ztd init` now keeps the default scaffold focused on consumer-facing project files. - - AI guidance artifacts such as `CONTEXT.md`, `PROMPT_DOGFOOD.md`, and `.ztd/agents/*` are no longer generated by default. If you want those files in the scaffold, pass `--with-ai-guidance`. - - Local-source developer mode also keeps generated consumer code on normal `@rawsql-ts/sql-contract` package imports instead of emitting local shim files into the project tree. - -## 0.19.0 - -### Minor Changes - -- [#552](https://github.com/mk3008/rawsql-ts/pull/552) [`953c569`](https://github.com/mk3008/rawsql-ts/commit/953c5699ee8cd6125335fc4443e24891d1a7fae1) Thanks [@mk3008](https://github.com/mk3008)! - Add ztd evidence test-doc to export human-readable Markdown test documentation from deterministic ZTD test assets. - -### Patch Changes - -- [#551](https://github.com/mk3008/rawsql-ts/pull/551) [`bf369a8`](https://github.com/mk3008/rawsql-ts/commit/bf369a8cf5c873d0820221285209b70ea87f164a) Thanks [@mk3008](https://github.com/mk3008)! - Add QuerySpec perf scale metadata and surface spec-driven perf guidance in ztd-cli benchmark reports. - -- Updated dependencies [[`b56a3fa`](https://github.com/mk3008/rawsql-ts/commit/b56a3fa82763c4120f73b2cec9f295c55c951609), [`953c569`](https://github.com/mk3008/rawsql-ts/commit/953c5699ee8cd6125335fc4443e24891d1a7fae1)]: - - rawsql-ts@0.17.0 - - @rawsql-ts/test-evidence-renderer-md@0.3.0 - - @rawsql-ts/adapter-node-pg@0.15.4 - - @rawsql-ts/sql-grep-core@0.1.1 - -## 0.18.0 - -### Minor Changes - -- [#451](https://github.com/mk3008/rawsql-ts/pull/451) [`40c4d82`](https://github.com/mk3008/rawsql-ts/commit/40c4d8259b8808a25dc77b61fa3fd324856a80b8) Thanks [@mk3008](https://github.com/mk3008)! - Keep deterministic test-evidence semantic transforms in @rawsql-ts/test-evidence-core and add a pure buildSpecificationModel intermediate model API with schemaVersion validation and typed deterministic errors. - - Add @rawsql-ts/test-evidence-renderer-md for markdown projection only, then update @rawsql-ts/ztd-cli to consume core semantics and renderer projections via explicit boundaries. - -- [#505](https://github.com/mk3008/rawsql-ts/pull/505) [`8127f60`](https://github.com/mk3008/rawsql-ts/commit/8127f60b55cb0f1d2691a01fffdaf6d0feeb5ef3) Thanks [@mk3008](https://github.com/mk3008)! - Add agent-first CLI improvements to `ztd-cli`, including a global `--output json` mode, a new `describe` command, dry-run support for write-capable commands, JSON payload input paths, and stricter input hardening for file paths and identifiers. - -- [#505](https://github.com/mk3008/rawsql-ts/pull/505) [`4fb22e3`](https://github.com/mk3008/rawsql-ts/commit/4fb22e3262f7e00bfddc65ba82b2ff1a2e3e0e86) Thanks [@mk3008](https://github.com/mk3008)! - Change `ztd init` to write managed internal agent guidance under `.ztd/agents/` by default, add `ztd agents install` and `ztd agents status`, and stop auto-creating visible `AGENTS.md` files unless explicitly installed. - -- [#446](https://github.com/mk3008/rawsql-ts/pull/446) [`06cd070`](https://github.com/mk3008/rawsql-ts/commit/06cd07084a50884d54613561ba760b3a10e37284) Thanks [@mk3008](https://github.com/mk3008)! - Add a new `ztd evidence --mode specification` command that exports deterministic JSON and Markdown artifacts derived from SQL catalog specs and test case definitions. - -- [#487](https://github.com/mk3008/rawsql-ts/pull/487) [`9e5db08`](https://github.com/mk3008/rawsql-ts/commit/9e5db08fd0a7fe7a43f980d6684b879550712f0f) Thanks [@mk3008](https://github.com/mk3008)! - Add `impact` and `detail` views to `ztd query uses`, improve clause-aware detail locations, and show a friendly hint when no catalog specs are discoverable. - -- [#488](https://github.com/mk3008/rawsql-ts/pull/488) [`8ecca4b`](https://github.com/mk3008/rawsql-ts/commit/8ecca4b349331b7c4a8f2a5a40f760531cf413d9) Thanks [@mk3008](https://github.com/mk3008)! - Add a local-source dogfooding mode to `ztd init` via `--local-source-root`. - - The new mode links `@rawsql-ts/sql-contract` from a local monorepo path, emits `src/local/sql-contract.ts`, and switches the scaffold runtime coercion import to the local shim so a fresh project under `tmp/` can reach `pnpm install`, `pnpm typecheck`, and the template smoke tests without published rawsql-ts packages. - -- [#486](https://github.com/mk3008/rawsql-ts/pull/486) [`4c905f7`](https://github.com/mk3008/rawsql-ts/commit/4c905f7f345682c3f4bd6a514a244232e02d07e4) Thanks [@mk3008](https://github.com/mk3008)! - Add `ztd model-gen` to generate names-first QuerySpec scaffolds from live PostgreSQL metadata. - -- [#487](https://github.com/mk3008/rawsql-ts/pull/487) [`9cd9068`](https://github.com/mk3008/rawsql-ts/commit/9cd90682ce65d9e0f04730f18795013bfa7c5d2f) Thanks [@mk3008](https://github.com/mk3008)! - Add strict-first `ztd query uses` commands for table and column impact investigation with deterministic output, explicit uncertainty labels, shared SQL catalog discovery, and stable statement fingerprints. - -### Patch Changes - -- [#488](https://github.com/mk3008/rawsql-ts/pull/488) [`9d9b8aa`](https://github.com/mk3008/rawsql-ts/commit/9d9b8aae1e36b1ac4a08dcad59a9eba05315ac6b) Thanks [@mk3008](https://github.com/mk3008)! - Default `ztd query uses` to the impact view in docs/help and add `--exclude-generated` to skip `src/catalog/specs/generated` during impact scans when those specs are review-only noise. - -- [#506](https://github.com/mk3008/rawsql-ts/pull/506) [`1cb9aef`](https://github.com/mk3008/rawsql-ts/commit/1cb9aef7402d00f19a8bebe416f845b9efd36a88) Thanks [@mk3008](https://github.com/mk3008)! - Clarify published vs local-source dogfooding, ensure fresh `ztd init` installs scaffold dependencies, inline scaffold timestamp coercions so generated smoke tests run against the published sql-contract package, make `serial8` DDL mapping generate stable numeric types, align release verification with `pnpm pack/publish` so workspace dependencies are rewritten consistently, and add a repository-root published-package smoke check that packs internal tarballs and reuses them via local overrides before release. Publish `@rawsql-ts/shared-binder`, `@rawsql-ts/test-evidence-core`, and `@rawsql-ts/test-evidence-renderer-md` so released packages can resolve their runtime evidence and binder dependencies. - -- [#451](https://github.com/mk3008/rawsql-ts/pull/451) [`77ba0be`](https://github.com/mk3008/rawsql-ts/commit/77ba0be9506a547f2ac397c82ac69957b76c8fa9) Thanks [@mk3008](https://github.com/mk3008)! - Fix markdown definition/file links to be resolved relative to each markdown output location in local path mode. - - When GitHub Actions metadata is not present, renderer links now compute relative paths from the markdown directory to the source definition path, preventing broken links in generated artifacts. - -- [#451](https://github.com/mk3008/rawsql-ts/pull/451) [`ea03f55`](https://github.com/mk3008/rawsql-ts/commit/ea03f55a6dad7e61f737da453256bde64454442e) Thanks [@mk3008](https://github.com/mk3008)! - Refine unit test specification markdown for review readability with flattened headings, strict two-axis tags, consistent focus phrasing, and catalog/case-level refs. Enforce throws error block rendering and deterministic metadata ordering. - -- [#460](https://github.com/mk3008/rawsql-ts/pull/460) [`8b7535c`](https://github.com/mk3008/rawsql-ts/commit/8b7535c61a9dd11c239116387df40986371a48c9) Thanks [@mk3008](https://github.com/mk3008)! - Fix the ztd-cli build pipeline so it builds the test-evidence workspace dependencies before compiling the CLI package. - -- [#474](https://github.com/mk3008/rawsql-ts/pull/474) [`606c99a`](https://github.com/mk3008/rawsql-ts/commit/606c99a49fb384197afb3d8a00511c1737a0dea6) Thanks [@mk3008](https://github.com/mk3008)! - Onboarding & discoverability improvements (Epic #463): - - Add copy-pasteable "Happy Path Quickstart" to README - - Document DDL/schema change workflow with common patterns - - Add `--workflow` and `--validator` flags for non-interactive `ztd init --yes` - - Improve `ztd --help` with "Getting started" and "Common workflow" guidance - - Print next-step hints after `ztd-config` (suppress with `--quiet`) - - Include `fromPg()` SqlClient conversion helper in scaffolded templates - - Add docs for rowMapping/coerce vs validator pipeline order - - Add Postgres pitfalls guide, spec-change scenarios digest, and feature index - -- [#488](https://github.com/mk3008/rawsql-ts/pull/488) [`3d27115`](https://github.com/mk3008/rawsql-ts/commit/3d27115d8497c3c8046c5d1e2b8acc363c1d6a7d) Thanks [@mk3008](https://github.com/mk3008)! - Teach `ztd query uses` to resolve existing `spec.sqlFile` values against a project SQL root such as `src/sql` before falling back to legacy spec-relative lookup, and improve unresolved-file guidance. - -- [#488](https://github.com/mk3008/rawsql-ts/pull/488) [`fd9d1b2`](https://github.com/mk3008/rawsql-ts/commit/fd9d1b25c152ce039ff4ab20f8163fb889d3c5a2) Thanks [@mk3008](https://github.com/mk3008)! - Improve ZTD-first dogfooding and scaffold feedback. - - make `ztd init` detect parent pnpm workspaces and use `--ignore-workspace` for its own install step - - add local-source dogfooding guidance for nested workspaces and generated import overrides - - let `ztd model-gen` generate local-friendly `sql-contract` imports via `--import-style` and `--import-from` - - strengthen the default scaffold smoke tests and placeholder diagnostics so wiring mistakes fail with clearer messages - -- [#487](https://github.com/mk3008/rawsql-ts/pull/487) [`a9d2129`](https://github.com/mk3008/rawsql-ts/commit/a9d21291c7734683f9a5cb07a4d8abac6a880345) Thanks [@mk3008](https://github.com/mk3008)! - Improve `ztd query uses` stability by keeping usage-kind summaries deterministic, bounding statement-location cache growth, and isolating spec-load failures so one bad spec file does not abort the full report. - -- [#488](https://github.com/mk3008/rawsql-ts/pull/488) [`89f2fd5`](https://github.com/mk3008/rawsql-ts/commit/89f2fd5fd27576a514309024a2bf7b4d25b6ec20) Thanks [@mk3008](https://github.com/mk3008)! - Align `ztd query uses table --view detail` snippets and locations to the matched table reference token so impact scans point at the actual table node instead of a nearby clause token. - -- [#442](https://github.com/mk3008/rawsql-ts/pull/442) [`7f62025`](https://github.com/mk3008/rawsql-ts/commit/7f62025f9f97449375a3d549d1cc13cb210cc319) Thanks [@mk3008](https://github.com/mk3008)! - Add deterministic `ztd check contract` validation with stable exit semantics, root-level AST wildcard safety checks, and CLI/unit coverage for contract diagnostics. - -- Updated dependencies [[`40c4d82`](https://github.com/mk3008/rawsql-ts/commit/40c4d8259b8808a25dc77b61fa3fd324856a80b8), [`1cb9aef`](https://github.com/mk3008/rawsql-ts/commit/1cb9aef7402d00f19a8bebe416f845b9efd36a88), [`77ba0be`](https://github.com/mk3008/rawsql-ts/commit/77ba0be9506a547f2ac397c82ac69957b76c8fa9), [`ea03f55`](https://github.com/mk3008/rawsql-ts/commit/ea03f55a6dad7e61f737da453256bde64454442e), [`8acdf88`](https://github.com/mk3008/rawsql-ts/commit/8acdf88ebc743d1ce1ed3c85c9b085c6b8456afc), [`e960404`](https://github.com/mk3008/rawsql-ts/commit/e96040413ce357c0c86fe87f886b9d8cce6cb44e)]: - - @rawsql-ts/test-evidence-core@0.2.0 - - @rawsql-ts/test-evidence-renderer-md@0.2.0 - - @rawsql-ts/adapter-node-pg@0.15.3 - - rawsql-ts@0.16.1 - -## 0.17.0 - -### Minor Changes - -- [#433](https://github.com/mk3008/rawsql-ts/pull/433) [`36fd789`](https://github.com/mk3008/rawsql-ts/commit/36fd7898926abf318873350ec3aeb5a28a60e021) Thanks [@mk3008](https://github.com/mk3008)! - Adopt SQL-first scaffolding with named-parameter SQL layout in the ZTD template, and compile named parameters to indexed placeholders in the pg adapter. - -- [#433](https://github.com/mk3008/rawsql-ts/pull/433) [`83e870a`](https://github.com/mk3008/rawsql-ts/commit/83e870aa945b75cdf894c8a620309e6d54dba178) Thanks [@mk3008](https://github.com/mk3008)! - Redesign `ztd init` to produce a deterministic minimal scaffold with per-folder AGENTS.md guidance and option-specific DDL seeding only (pg_dump, empty, or demo DDL). - -- [#433](https://github.com/mk3008/rawsql-ts/pull/433) [`bf588fd`](https://github.com/mk3008/rawsql-ts/commit/bf588fd73e4fd728b193dd795e449729e6b554b5) Thanks [@mk3008](https://github.com/mk3008)! - Add the new default ZTD scaffold layout with view SQL under "src/sql/views", job SQL under "src/sql/jobs", and repositories split between "src/repositories/views" and "src/repositories/tables". The init command now supports "--yes" to overwrite existing scaffold files without prompts for non-interactive runs. - -### Patch Changes - -- [#433](https://github.com/mk3008/rawsql-ts/pull/433) [`06ec7ea`](https://github.com/mk3008/rawsql-ts/commit/06ec7ea2c54b9561ff74cbbd6c13d8cc7ef6f9dc) Thanks [@mk3008](https://github.com/mk3008)! - Add a new public `timestampFromDriver(value, fieldName?)` helper in `@rawsql-ts/sql-contract` for fail-fast `Date | string` normalization of driver-returned timestamps. - - Update `@rawsql-ts/ztd-cli` templates to normalize runtime timestamp fields through the shared sql-contract helper (via runtime coercion wiring), add strict guardrails against local timestamp re-implementation, and expand scaffold smoke validation tests for valid and invalid timestamp strings. - -- [#435](https://github.com/mk3008/rawsql-ts/pull/435) [`345a4a1`](https://github.com/mk3008/rawsql-ts/commit/345a4a1ad0354e975f47200f0f222696fa67a326) Thanks [@mk3008](https://github.com/mk3008)! - Fix optional adapter resolution during SQL lint execution so workspace and test environments no longer require prebuilt dist artifacts. - -- Updated dependencies [[`36fd789`](https://github.com/mk3008/rawsql-ts/commit/36fd7898926abf318873350ec3aeb5a28a60e021)]: - - @rawsql-ts/adapter-node-pg@0.15.2 - -## 0.16.0 - -### Minor Changes - -- [#411](https://github.com/mk3008/rawsql-ts/pull/411) [`84ec3a0`](https://github.com/mk3008/rawsql-ts/commit/84ec3a0c5f3e16463c1eee532fc9570bf1bcff93) Thanks [@mk3008](https://github.com/mk3008)! - Document that the CLI templates treat db/ddl as the only authoritative source, keep optional references purely informational, and ship the mapper/writer sample with its supporting tests. - -### Patch Changes - -- Updated dependencies [[`1ad78c5`](https://github.com/mk3008/rawsql-ts/commit/1ad78c5430b2ac24e0fb8fe6fb6ecf913e9b9e54), [`857a3c3`](https://github.com/mk3008/rawsql-ts/commit/857a3c3f21e32610024aa51f636841f9ff9e4ce4), [`84ec3a0`](https://github.com/mk3008/rawsql-ts/commit/84ec3a0c5f3e16463c1eee532fc9570bf1bcff93), [`2361f3c`](https://github.com/mk3008/rawsql-ts/commit/2361f3cbdf7589984bbbe7779ffb5d8129ff3804), [`9c05224`](https://github.com/mk3008/rawsql-ts/commit/9c052243a8005b8882e88f50b7d469ac7c55b24e), [`fc7a80e`](https://github.com/mk3008/rawsql-ts/commit/fc7a80e237850dc3c5f06dd7c8ad5472af1e3dc8), [`e38df03`](https://github.com/mk3008/rawsql-ts/commit/e38df035cc8301b24a4fdfaab9d1cbbaa9d95c0a), [`f957e21`](https://github.com/mk3008/rawsql-ts/commit/f957e219ab5f1f27df2bc771fc25032ccf35f226), [`ba24150`](https://github.com/mk3008/rawsql-ts/commit/ba24150112a08ae5e80fc43533f7c5d47d8e3a81)]: - - rawsql-ts@0.16.0 - - @rawsql-ts/testkit-postgres@0.15.1 - - @rawsql-ts/adapter-node-pg@0.15.1 - - @rawsql-ts/testkit-core@0.15.1 - -## 0.15.0 - -### Patch Changes - -- [#387](https://github.com/mk3008/rawsql-ts/pull/387) [`95525f7`](https://github.com/mk3008/rawsql-ts/commit/95525f72f37576f0ef4e78bf77f8681644311f82) Thanks [@mk3008](https://github.com/mk3008)! - - ztd init now writes the tests/AGENTS.md guidance next to the generated tests layout so the CLI includes the latest testing rules without manual steps. - - Expanded the AGENTS templates to spell out the required validation and testing expectations for general and tests directories. - -- [#391](https://github.com/mk3008/rawsql-ts/pull/391) [`4e14a23`](https://github.com/mk3008/rawsql-ts/commit/4e14a23b405c1ba729229330baf725d09837aca2) Thanks [@mk3008](https://github.com/mk3008)! - Add deterministic ztd lint integration coverage and relax parser/default-value assertions. - -- Updated dependencies [[`8fc296a`](https://github.com/mk3008/rawsql-ts/commit/8fc296a24f1dc8190c3561bc265f5b32d537eab3), [`ee41f6d`](https://github.com/mk3008/rawsql-ts/commit/ee41f6d270c8174f0c6128ece3f3abd55a726f3d), [`45c55bd`](https://github.com/mk3008/rawsql-ts/commit/45c55bd58f0e1b969ce7bcc6cc35d53d2248ebdd), [`efc6e3f`](https://github.com/mk3008/rawsql-ts/commit/efc6e3fd2c1a9dec3bc54ed446101ed53191fea3)]: - - @rawsql-ts/testkit-core@0.15.0 - - rawsql-ts@0.15.0 - - @rawsql-ts/testkit-postgres@0.15.0 - - @rawsql-ts/adapter-node-pg@0.15.0 - -## 0.14.4 - -### Patch Changes - -- [#380](https://github.com/mk3008/rawsql-ts/pull/380) [`89c5f0d`](https://github.com/mk3008/rawsql-ts/commit/89c5f0d1bac9d7801401e145ee096c811d9ac077) Thanks [@mk3008](https://github.com/mk3008)! - Improve Windows package manager spawning during `ztd init` so pnpm `.cmd` shims resolve reliably. - -- Updated dependencies []: - - rawsql-ts@0.14.4 - - @rawsql-ts/testkit-core@0.14.4 - -## 0.14.3 - -### Patch Changes - -- [#376](https://github.com/mk3008/rawsql-ts/pull/376) [`5a11604`](https://github.com/mk3008/rawsql-ts/commit/5a11604a6f2fd166156762c621874f35d3e26c46) Thanks [@mk3008](https://github.com/mk3008)! - Clarify ZTD template guidance for defaults, test rules, and protected directories. - -- Updated dependencies [[`1cfcc2a`](https://github.com/mk3008/rawsql-ts/commit/1cfcc2ab7502b9f01f4ba53d8e8540b8ca40e3d7)]: - - rawsql-ts@0.14.3 - - @rawsql-ts/testkit-core@0.14.3 - -## 0.14.2 - -### Patch Changes - -- [#374](https://github.com/mk3008/rawsql-ts/pull/374) [`9201657`](https://github.com/mk3008/rawsql-ts/commit/920165791046546db3b3e0f5fe25313ea332e66c) Thanks [@mk3008](https://github.com/mk3008)! - Improve Windows package manager resolution during `ztd init` dependency installation. - -- Updated dependencies []: - - rawsql-ts@0.14.2 - - @rawsql-ts/testkit-core@0.14.2 - -## 0.14.1 - -### Patch Changes - -- Updated dependencies [[`0746dce`](https://github.com/mk3008/rawsql-ts/commit/0746dceb58ae2270feab896d1f1a2caf64ec338f)]: - - @rawsql-ts/testkit-core@0.14.1 - - rawsql-ts@0.14.1 - -## 0.14.0 - -### Minor Changes - -- [#364](https://github.com/mk3008/rawsql-ts/pull/364) [`18e8ef2`](https://github.com/mk3008/rawsql-ts/commit/18e8ef20ed1c2147e15f807eb91c0f61eb5481ae) Thanks [@mk3008](https://github.com/mk3008)! - Add DDL integrity linting with configurable strict/warn/off handling across fixture loading and ztd-config generation. - -- [#324](https://github.com/mk3008/rawsql-ts/pull/324) [`04bd81e`](https://github.com/mk3008/rawsql-ts/commit/04bd81e8a32aec0eb0b601599b157612dd342f77) Thanks [@mk3008](https://github.com/mk3008)! - Add optional SqlClient scaffold for tutorials - - `ztd init --with-sqlclient` now generates a minimal `sql-client.ts`, providing a small SQL client boundary for tutorial and onboarding use cases. - - The scaffold is optional and intended mainly for tutorials. - - Projects with an existing database layer do not need this flag. - - Existing `sql-client.ts` files are never overwritten. - - Templates and documentation were updated to explain when this option is appropriate. - Tests were added to verify file generation and non-overwrite behavior. - -- [`e9720a5`](https://github.com/mk3008/rawsql-ts/commit/e9720a56da28120eb4cac2c1c2586d7d737a8c7c) Thanks [@mk3008](https://github.com/mk3008)! - Add optional ZTD test profiling logs for connection, setup, query timing, and teardown. - -### Patch Changes - -- [#332](https://github.com/mk3008/rawsql-ts/pull/332) [`0ee677c`](https://github.com/mk3008/rawsql-ts/commit/0ee677cad032b36bff4834603efdfda037b2b743) Thanks [@mk3008](https://github.com/mk3008)! - ## Benchmark comparison refresh - - Traditional and ZTD now execute the same repository implementation, but Traditional still runs migration/seed/cleanup around each call while ZTD rewrites that query into fixtures. - - The benchmark outputs now surface the total SQL count and DB execution time for both workflows, along with the rewrite and fixture timing that explains why ZTD issues fewer statements. - -- [#318](https://github.com/mk3008/rawsql-ts/pull/318) [`f5ea0f8`](https://github.com/mk3008/rawsql-ts/commit/f5ea0f85727d99c281f4719c9f6c1445636f6d93) Thanks [@mk3008](https://github.com/mk3008)! - Add SQL rewrite logging to generated pg-testkit client - - Generated `.ztd/support/testkit-client.ts` can emit structured logs showing the SQL before and after pg-testkit rewrites it. - - Logging can be enabled via `ZTD_SQL_LOG` and can optionally include parameters via `ZTD_SQL_LOG_PARAMS`. - - Logging is resilient to non-JSON primitives (e.g. `bigint`) and circular references, so enabling it won't crash a test run. - -- [#334](https://github.com/mk3008/rawsql-ts/pull/334) [`d039a5e`](https://github.com/mk3008/rawsql-ts/commit/d039a5e756eeda0dac6bcac757a016476153ced2) Thanks [@mk3008](https://github.com/mk3008)! - `ztd init --with-app-interface` now appends the application interface guidance to `AGENTS.md` without touching the rest of the ZTD layout. - -- [#347](https://github.com/mk3008/rawsql-ts/pull/347) [`734d5dd`](https://github.com/mk3008/rawsql-ts/commit/734d5dd8caeac60b9b22cd4379ca92c6fc965910) Thanks [@mk3008](https://github.com/mk3008)! - Safe cleanup for traditional clients - - Traditional execution mode now always closes the Postgres client even when cleanup statements throw, so we avoid leaking connections after tests finish. - - Any cleanup or client close error is recorded and rethrown after profiling finishes so users still observe the true failure. - -- [#335](https://github.com/mk3008/rawsql-ts/pull/335) [`08acabf`](https://github.com/mk3008/rawsql-ts/commit/08acabfb2a319c09a00bd058cbe0af769837a422) Thanks [@mk3008](https://github.com/mk3008)! - `ztd init` now always creates `ztd/domain-specs/` and `ztd/enums/` directories so the new layout exposes those anchors whether they already exist or not. - -- [#332](https://github.com/mk3008/rawsql-ts/pull/332) [`0ee677c`](https://github.com/mk3008/rawsql-ts/commit/0ee677cad032b36bff4834603efdfda037b2b743) Thanks [@mk3008](https://github.com/mk3008)! - ## Benchmark concurrency diagnostics - - Traditional parallel in-process runs now report 95th percentile waiting, migration, and cleanup durations so the Markdown report surfaces where the parallel workflow spends its time. - - ZTD in-process runs capture waiting p95 plus the peak number of PostgreSQL sessions for the largest measured suite, and the report now exposes them in a dedicated “ZTD Concurrency Diagnostics” section. - - The documentation points to the new Vitest smoke test (`benchmarks/ztd-bench/tests/diagnostics/traditional-parallelism.test.ts`) so you can rerun the validation quickly before launching the full benchmark. - -- [#322](https://github.com/mk3008/rawsql-ts/pull/322) [`d8d9508`](https://github.com/mk3008/rawsql-ts/commit/d8d95081d2feb67e9ce7fcc991e7991b44752782) Thanks [@mk3008](https://github.com/mk3008)! - Avoid unrelated changes in AI-assisted workflows - - Generated AGENTS.md now explicitly instructs AI assistants to avoid unrelated changes (format-only diffs, refactors, dependency upgrades, file renames, or regenerating artifacts) unless explicitly requested. - -- [#354](https://github.com/mk3008/rawsql-ts/pull/354) [`daaa94b`](https://github.com/mk3008/rawsql-ts/commit/daaa94b4143d4c1555eb92cf39c4a1ebd2a829c2) Thanks [@mk3008](https://github.com/mk3008)! - Add template guidance that recommends reusing a shared SqlClient per worker process and avoiding cross-worker sharing to prevent unnecessary reconnects. - -- [#356](https://github.com/mk3008/rawsql-ts/pull/356) [`261e95e`](https://github.com/mk3008/rawsql-ts/commit/261e95e6e13a426a3472bef25920f79c9d590774) Thanks [@mk3008](https://github.com/mk3008)! - Clarify AGENTS guidance for repository and test responsibilities so teams can follow consistent patterns when adding SQL or tests. - -- [#332](https://github.com/mk3008/rawsql-ts/pull/332) [`0ee677c`](https://github.com/mk3008/rawsql-ts/commit/0ee677cad032b36bff4834603efdfda037b2b743) Thanks [@mk3008](https://github.com/mk3008)! - ## AST stringify microbenchmark - - Added a standalone TypeScript script that parses the existing repository SQL to AST and measures the stringify step (`SqlFormatter.format`) in μs/ ns loops. - - Documented how to run the script with `pnpm ts-node benchmarks/ztd-bench/stringify-only-benchmark.ts` and how to tune warmup/iteration counts. - -- [#347](https://github.com/mk3008/rawsql-ts/pull/347) [`1b8c5b6`](https://github.com/mk3008/rawsql-ts/commit/1b8c5b6463bd84e86a2542368f8da32107982f6b) Thanks [@mk3008](https://github.com/mk3008)! - Document and test the new traditional execution mode for the CLI template testkit helper so Postgres schemas, fixtures, and cleanup strategies are exercised along with the existing ZTD workflow. - -- [#332](https://github.com/mk3008/rawsql-ts/pull/332) [`0ee677c`](https://github.com/mk3008/rawsql-ts/commit/0ee677cad032b36bff4834603efdfda037b2b743) Thanks [@mk3008](https://github.com/mk3008)! - ## Traditional parallelism validation - - Traditional parallel benchmarks now validate that they can open the requested number of concurrent PostgreSQL sessions and fail when a misconfiguration prevents concurrency. - - Worker-scoped benchmark clients require explicit `workerId`s so each parallel worker keeps its own session and cannot be serialized by token reuse. - - A new Vitest smoke test simulates a concurrent `pg_sleep` workload and guards future runs against regressions before the full benchmark executes. - -- [#365](https://github.com/mk3008/rawsql-ts/pull/365) [`2255852`](https://github.com/mk3008/rawsql-ts/commit/2255852b37cacfda0cd326623d6ccb7f40120330) Thanks [@mk3008](https://github.com/mk3008)! - Reliable DDL watch updates on Windows - - ztd-config --watch now detects DDL edits under configured directories on Windows. - - Generated test row maps stay in sync without manual reruns. - -- [#319](https://github.com/mk3008/rawsql-ts/pull/319) [`f644c8b`](https://github.com/mk3008/rawsql-ts/commit/f644c8bbd33b7537024b43fafeabbe3705fbc40a) Thanks [@mk3008](https://github.com/mk3008)! - Make `ztd init` produce a self-consistent scaffold by installing the devDependencies referenced by the generated templates. - - Postgres remains the default, so `@rawsql-ts/testkit-postgres` (and optionally `@rawsql-ts/adapter-node-pg`) are automatically added when they are not already declared. - -- Updated dependencies [[`e3c97e4`](https://github.com/mk3008/rawsql-ts/commit/e3c97e44ce38e12a21a2a777ea504fd142738037), [`f73ed38`](https://github.com/mk3008/rawsql-ts/commit/f73ed380e888477789efbf27417d8d3451093218), [`18e8ef2`](https://github.com/mk3008/rawsql-ts/commit/18e8ef20ed1c2147e15f807eb91c0f61eb5481ae), [`963a1d1`](https://github.com/mk3008/rawsql-ts/commit/963a1d141612b981a344858fe9b1a2888a28f049), [`07735e5`](https://github.com/mk3008/rawsql-ts/commit/07735e5937fe7d78cffab9d47c213d78fcf24a0c), [`7dde2ab`](https://github.com/mk3008/rawsql-ts/commit/7dde2ab139c9029eb4b87e521bc91cb881695791), [`e8c7eed`](https://github.com/mk3008/rawsql-ts/commit/e8c7eedc454ee11205c5a117d7bf70a2dfdcc4f5), [`88a48d6`](https://github.com/mk3008/rawsql-ts/commit/88a48d63598f941aead4143c0ffeb05792e0af4e), [`e8f025a`](https://github.com/mk3008/rawsql-ts/commit/e8f025afc95004966d0a5f89f5d167bc77ffbeec), [`440133a`](https://github.com/mk3008/rawsql-ts/commit/440133ac48043af3da66cdfa73842a24c5142d84), [`7ac3280`](https://github.com/mk3008/rawsql-ts/commit/7ac328069c5458abd68a5ae78e8b791984a23b57)]: - - rawsql-ts@0.14.0 - - @rawsql-ts/testkit-core@0.14.0 - -## 0.13.3 - -### Patch Changes - -- [#304](https://github.com/mk3008/rawsql-ts/pull/304) [`e213234`](https://github.com/mk3008/rawsql-ts/commit/e213234f7bbbc709750ba40f798e2ffe7ee3d539) Thanks [@mk3008](https://github.com/mk3008)! - fix(ztd-cli): include templates in npm package and make init template resolution robust - -- Updated dependencies []: - - rawsql-ts@0.13.3 - - @rawsql-ts/testkit-core@0.13.3 - -## 0.13.2 - -### Patch Changes - -- [#294](https://github.com/mk3008/rawsql-ts/pull/294) [`4e09e65`](https://github.com/mk3008/rawsql-ts/commit/4e09e65c6826c0116807f094f0793d4e96f1825f) Thanks [@mk3008](https://github.com/mk3008)! - Ensure published packages always include built `dist/` artifacts by building during the `prepack` lifecycle (and in the publish workflow). This fixes cases where `npx ztd init` fails with `MODULE_NOT_FOUND` due to missing compiled entrypoints. - -- [#294](https://github.com/mk3008/rawsql-ts/pull/294) [`4e09e65`](https://github.com/mk3008/rawsql-ts/commit/4e09e65c6826c0116807f094f0793d4e96f1825f) Thanks [@mk3008](https://github.com/mk3008)! - Ensure published packages always include built dist artifacts. - -- Updated dependencies [[`4e09e65`](https://github.com/mk3008/rawsql-ts/commit/4e09e65c6826c0116807f094f0793d4e96f1825f), [`4e09e65`](https://github.com/mk3008/rawsql-ts/commit/4e09e65c6826c0116807f094f0793d4e96f1825f)]: - - rawsql-ts@0.13.2 - - @rawsql-ts/testkit-core@0.13.2 - -## 0.13.1 - -### Patch Changes - -- [`b01df7d`](https://github.com/mk3008/rawsql-ts/commit/b01df7dca83023e768c119162c8c5f39e39b74be) Thanks [@mk3008](https://github.com/mk3008)! - Patch release to address dependency security advisories by updating Prisma tooling and ESLint, and pinning patched transitive versions via pnpm overrides. - -- Updated dependencies [[`b01df7d`](https://github.com/mk3008/rawsql-ts/commit/b01df7dca83023e768c119162c8c5f39e39b74be)]: - - rawsql-ts@0.13.1 - - @rawsql-ts/testkit-core@0.13.1 - -## 0.13.0 - -### Minor Changes - -- Update ztd-cli and perform internal refactors and fixes across the workspace. - -### Patch Changes - -- Updated dependencies []: - - rawsql-ts@0.13.0 - - @rawsql-ts/testkit-core@0.13.0 diff --git a/packages/ztd-cli/README.md b/packages/ztd-cli/README.md deleted file mode 100644 index 48ab949f6..000000000 --- a/packages/ztd-cli/README.md +++ /dev/null @@ -1,329 +0,0 @@ -# @rawsql-ts/ztd-cli - -![npm version](https://img.shields.io/npm/v/@rawsql-ts/ztd-cli) -![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg) - -`ztd-cli` is a SQL-first CLI for feature-first RFBA (Review-First Backend Architecture) application development. - -RFBA is a backend architecture for making AI-assisted work reviewable by humans. -It splits files by review responsibility, keeps dependency direction and public surfaces visible, treats DDL as the data-structure source of truth, and exposes SQL as a strong review boundary. - -## Highlights - -- DDL is the source of truth, and `pg_dump` output can be used to bootstrap it. -- SQL lives as files, co-located with each feature. -- Development starts from SQL changes, then moves through tests and repair loops. -- ZTD-format SQL tests are the standard, and SQL tuning has a dedicated path. -- Migration artifacts are generated for review, not applied automatically. -- No extra DSL is required. -- VSA-style feature-local SQL layouts are supported. -- RFBA keeps review-heavy SQL and orchestration visible while letting DTOs, mapping, and tests use scaffolded structure. - -## Quickstart - -Run these in order. - -```bash -npm install -D @rawsql-ts/ztd-cli vitest typescript -npx ztd init --starter -# starter scaffold generates compose.yaml, starter DDL, config, and test stubs -cp .env.example .env -# edit ZTD_DB_PORT=5433 if needed -npx ztd ztd-config -docker compose up -d -npx vitest run -``` - -## RFBA Architecture - -RFBA is architecture and structure theory, not a filename rule. -`ztd-cli` implements RFBA with three structural layers: - -```text -root-boundary/ - feature-boundary/ - sub-boundary/ -``` - -- `root-boundary` is the app-level boundary layer. -- In rawsql-ts, the concrete root boundaries are only `src/features`, `src/adapters`, and `src/libraries`. -- `feature-boundary` is a feature-owned boundary under `src/features//`. -- `sub-boundary` is an optional child boundary inside one feature when review responsibility, allowed dependencies, public surface, or verification scope changes. - -For feature-owned work, the default scaffold convention is: - -```text -src/features// - boundary.ts - queries/ - / - boundary.ts - tests/ - tests/ -``` - -- A `feature-boundary` owns that feature's SQL, QuerySpec, orchestration entrypoint, and feature-local verification. -- A query sub-boundary is the feature-local query unit: it keeps the SQL, row/result mapping contract, execution contract, and query-local verification together. -- `queries/` is a child-boundary container and does not expose its own public surface. -- The actual child query public surface lives in `queries//boundary.ts`. -- Inside `src/features/*`, `boundary.ts` is the default scaffold entrypoint for feature-boundaries and sub-boundaries. -- `boundary.ts` is a feature-scoped convention for discoverability and scaffold compatibility, not the definition of RFBA itself. -- Cross-boundary calls should go through the target boundary's public surface instead of reaching into private helpers. - -The starter and feature scaffolds apply that convention under `src/features//...`, so the feature-local public surface stays easy to discover. - -### Inspect RFBA Boundaries - -Reviewers and agents can inspect the current RFBA boundary map before editing: - -```bash -npx ztd rfba inspect -npx ztd rfba inspect --format json -``` - -The command is read-only. It reports concrete starter root boundaries, feature-boundaries, query sub-boundaries, likely public surface files, SQL assets, generated artifacts, local verification files, and structural warnings. -The JSON output is deterministic and omits timestamps so it can be consumed by agents and review checks. - -### Generate RFBA Review Data - -Use `rfba review-data` in merge PR checks when an agent or reviewer needs RFBA-aware review packet data instead of raw diff text: - -```bash -npx ztd rfba review-data --base origin/main --head HEAD --out .ztd/review/rfba-review-data.json -``` - -The output is deterministic JSON and is suitable for a CI artifact. It classifies changed files, maps changes to RFBA boundaries, summarizes supported DDL and SQL changes, groups verification evidence, and emits warnings for review gaps that need human or AI attention. -The command does not write the final PR review narrative and does not judge business correctness. - -Example AI prompt for PR summary generation: - -```text -Read .ztd/review/rfba-review-data.json and write an RFBA Review Summary for the PR. -Do not repeat raw git diff. -Summarize the meaning of DDL, SQL, boundary, adapter, and verification changes. -Separate confirmed facts from review questions. -Use warnings as high-priority review notes. -Do not claim business correctness when the JSON only provides structural evidence. -``` - -Important repo areas outside the concrete root-boundary list: - -- Keep shared feature seams under `src/features/_shared/*`. -- Keep driver-neutral contracts under `src/libraries/*`; `src/libraries` itself is one concrete root-boundary. -- Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package; keep feature-specific validation and helpers inside the owning feature. -- Keep driver- or sink-specific bindings under `src/adapters//*`; `src/adapters` itself is one concrete root-boundary. -- Keep shared verification seams under `tests/support/*`. -- Keep tool-managed assets under `.ztd/*`. -- Do not count `src/features/_shared/*`, `tests/support/*`, `.ztd/*`, or `db/` as extra root boundaries. - -Adapter boundary rule: - -- If `` is one concrete technology, treat `src/adapters//` as the adapter boundary, for example `src/adapters/pg/`. -- If `` is a family or plural container such as `aws` or `cloud`, treat `src/adapters//` as a parent and create child boundaries such as `src/adapters/aws/s3/` and `src/adapters/aws/lambda/`. - -Reserve `db/` for DDL, migration, and schema assets only; do not place runtime clients or adapters there. -Code outside a feature may still use `boundary.ts` when it helps locally, but it is not a required filename outside feature-boundaries and sub-boundaries. - -PowerShell: - -```powershell -npm install -D @rawsql-ts/ztd-cli vitest typescript -npx ztd init --starter -# starter scaffold generates compose.yaml, starter DDL, config, and test stubs -Copy-Item .env.example .env -# edit ZTD_DB_PORT=5433 if needed -npx ztd ztd-config -docker compose up -d -npx vitest run -``` - -### Feature Test Debugging - -### Port Already In Use - -If port `5432` is already in use, change `ZTD_DB_PORT` in `.env` and then verify recovery with: - -```bash -docker compose up -d -npx vitest run -``` - -### Docker Network Pool Exhausted - -If `docker compose up -d` fails with `all predefined address pools have been fully subnetted`, this is not a `ZTD_DB_PORT` collision. - -- The failure is happening before the container binds its port. -- Changing `ZTD_DB_PORT` will not fix it. -- Typical recovery is Docker-side cleanup such as removing unused networks, pruning Docker state, or widening Docker's `default-address-pools` setting. - -### ZTD Runtime Debugging - -- If an AI-authored ZTD test fails, do not assume the prompt or case file is the only problem; check whether `ztd-cli` or `rawsql-ts` changed the manifest or rewrite path. -- If you see `user_id: null`, compare the direct database `INSERT ... RETURNING ...` result with the ZTD result and inspect `.ztd/generated/ztd-fixture-manifest.generated.ts` first. -- If a local-source workspace is meant to reflect a source change, verify that it resolves `rawsql-ts` from the local source tree rather than a registry copy. -- Check ZTD evidence first (`mode=ztd`, `physicalSetupUsed=false`) before assuming fixture data is wrong. -- Enable SQL trace only when needed with `ZTD_SQL_TRACE=1` (optional `ZTD_SQL_TRACE_DIR`). - -## Create the Users Insert Feature - -Use this after Quickstart. - -The DDL is in `db/ddl/public.sql`. - -Run this first: - -```bash -npx ztd feature scaffold --table users --action insert -``` - -Scaffold the `users-insert` feature with co-located SQL, boundaries, and a thin tests entrypoint. -Starter-owned shared support lives under `tests/support/ztd/`; `.ztd/` remains the tool-managed workspace for generated metadata and support files. - -When an existing boundary needs one more child query boundary, add it without regenerating the parent boundary: - -```bash -npx ztd feature query scaffold --feature users-insert --query-name insert-user-audit --table user_audit --action insert -``` - -If the boundary is deeper in a VSA-style folder tree, point at the exact boundary folder instead: - -```bash -npx ztd feature query scaffold --boundary-dir src/features/orders/write/sales-insert --query-name insert-sales-detail --table sales_detail --action insert -``` - -Choose exactly one target selector: - -- Prefer `--feature` for a feature-root boundary. -- Use `--boundary-dir` for a deeper existing boundary folder. -- Omit both only when the current working directory is already the target boundary. -- The additive scaffold creates `queries//boundary.ts` plus `queries//.sql`. -- It creates `queries/` when it is missing. -- It does not edit the parent `boundary.ts`, including in `--dry-run`. -- Parent orchestration, transaction decisions, and response shaping stay human/AI-owned. - -After you finish the SQL and DTO edits, run `npx ztd feature tests scaffold --feature `. -That command refreshes `src/features//queries//tests/generated/TEST_PLAN.md` and `analysis.json`, refreshes `src/features//queries//tests/boundary-ztd-types.ts`, and creates the thin Vitest entrypoint `src/features//queries//tests/.boundary.ztd.test.ts` only if it is missing. -Persistent case files under `src/features//queries//tests/cases/` are human/AI-owned and are not overwritten. -ZTD here means query-boundary-local cases that execute through the fixed app-level harness against the real database engine, not a mocked executor. -If `ztd-config` has already run, use `.ztd/generated/ztd-fixture-manifest.generated.ts` as the source for `tableDefinitions` and any fixture-shape hints the case needs. -`beforeDb` is a schema-qualified pure fixture skeleton. -Use validation-only cases for boundary checks and DB-backed cases for the success path. -Keep the feature-root `src/features//tests/.boundary.test.ts` for mock-based boundary tests. -Feature-boundary tests mock child query boundaries and verify feature validation, mapping, and orchestration. -Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane. -Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries. -The ZTD verifier returns machine-checkable evidence (`mode`, `rewriteApplied`, `physicalSetupUsed`) per case. -`afterDb` assertions are intentionally excluded from this ZTD lane; use a traditional DB-state lane when you need post-state assertions. - -`ztd feature tests scaffold --test-kind traditional` creates an active `.boundary.traditional.test.ts` entrypoint that calls the shared mode runner. -Traditional cases keep the same `beforeDb`, `input`, and `output` shape and can add optional `afterDb` assertions. -The traditional runner physically prepares DDL and fixture rows, then reports `mode=traditional` and `physicalSetupUsed=true`. -After the cases are filled, run `npx vitest run src/features//queries//tests/.boundary.ztd.test.ts` to execute the ZTD query test. - -## Import Paths - -As boundary depth grows, avoid making every import depth-sensitive by default. - -- The goal is boundary-change safety, not a blanket root-alias migration. -- Keep local, nearby references relative when they move with the same boundary. -- When an import crosses canonical roots, use the matching root alias so the target root stays explicit: `#features/*`, `#libraries/*`, `#adapters/*`, and `#tests/*`. -- Feature-to-feature imports should still go through the target boundary's `boundary.ts` entrypoint rather than deep private files. -- Root aliases are for cross-root references and shared seams, not a reason to rewrite every same-root local import to one style. - -## Troubleshooting - -- If a DB-backed ZTD case returns `user_id: null`, check the fixture manifest and rewrite path before weakening the case. -- Compare the direct database `INSERT ... RETURNING ...` result with the ZTD result so you can separate a DB issue from a manifest or rewrite issue. -- If the workspace is meant to reflect a source change, verify it resolves `rawsql-ts` from the local source tree instead of a registry copy. -- When debugging rewrite behavior, use `ZTD_SQL_TRACE=1` to emit per-case trace JSON without adding always-on log noise. - -```text -Write ZTD-format cases for the query boundary. -Keep the persistent case files in `src/features//queries//tests/cases/`. -Use `src/features//queries//tests/generated/TEST_PLAN.md` and `analysis.json` as the source of truth. -Do not put returned columns into the input fixture; only assert them after the DB-backed case returns. -The validation cases may stay at the feature boundary, but the success case must run through the fixed app-level ZTD runner and verify the returned result. -If the returned result is `null`, stop and fix the scaffold or DDL instead of weakening the case. -Before writing the success-path assertion, inspect the current SQL and query boundary. If the scaffold does not actually return the expected result shape, report that mismatch instead of inventing fixture data or schema overrides. -Do not apply migrations automatically. -``` - -Finish by running: - -```bash -npx vitest run -``` - -If you want a deeper walkthrough, keep that in the linked guides instead of expanding this README. - -## Command Index - -This section is a reader-facing index of the main `ztd-cli` entry points. -It is not the exhaustive command reference for every subcommand and flag. -Use `ztd describe` for machine-readable discovery, and follow the linked guides when one command family has a deeper workflow. - -| Command | Purpose | -|---|---| -| `ztd init --starter` | Scaffold the starter project with smoke, DDL, compose, and local Postgres wiring. | -| `ztd feature scaffold --table --action ` | Scaffold a feature-local CRUD/SELECT slice with SQL, `boundary.ts` entrypoints, README, and a thin tests entrypoint. | -| `ztd feature query scaffold --query-name --table
--action ` | Add one child query boundary under an existing boundary folder without rewriting the parent boundary. Use exactly one of `--feature` or `--boundary-dir`, or omit both only when the current working directory is already the target boundary. | -| `ztd feature tests scaffold --feature ` | Refresh `src/features//queries//tests/generated/TEST_PLAN.md`, `analysis.json`, and `src/features//queries//tests/boundary-ztd-types.ts`; create the thin `src/features//queries//tests/.boundary.ztd.test.ts` Vitest entrypoint when missing; keep `src/features//queries//tests/cases/` as human/AI-owned persistent cases. | -| `ztd ztd-config` | Regenerate `TestRowMap` and runtime fixture metadata from DDL without Docker. | -| `ztd lint` | Lint SQL against a temporary Postgres. | -| `ztd model-gen` | Generate query-boundary scaffolding from SQL assets. | -| `ztd query uses` | Find impacted SQL before changing a table or column. | -| `ztd query match-observed` | Rank likely source SQL assets from observed SELECT text. | -| `ztd query sssql list` / `scaffold` / `remove` / `refresh` | Inspect, author, undo, and re-anchor SQL-first optional filter branches. See [ztd-cli SSSQL Reference](../../docs/guide/ztd-cli-sssql-reference.md). | -| `ztd ddl pull` / `ztd ddl diff` | Inspect a target and prepare migration SQL. | -| `ztd rfba inspect` / `review-data` | Inspect RFBA boundaries and generate deterministic merge PR review packet JSON. | -| `ztd perf init` / `ztd perf run` | Run the tuning loop for index or pipeline investigation. | -| `ztd describe` | Inspect commands in machine-readable form. | - -## Glossary - -| Term | Meaning | -|---|---| -| ZTD | [Zero Table Dependency](../../docs/guide/ztd-theory.md) - test against a real database engine without creating or mutating application tables. | -| DDL | SQL schema files that act as the source of truth for type generation. | -| TestRowMap | Generated TypeScript types that describe row shape from local DDL. | -| QuerySpec | Contract object that ties a SQL asset file to parameter and output types. | -| SSSQL | [SQL-first optional-filter authoring style](../../docs/guide/sssql-overview.md) that keeps the query truthful and lets the runtime prune only what it must. | - -## Further Reading - -### User Guides - -- [SQL-first End-to-End Tutorial](../../docs/guide/sql-first-end-to-end-tutorial.md) - starter flow, repair loops, and scenario-specific CLI guidance -- [What Is RFBA?](../../docs/guide/rfba-overview.md) - review-first backend architecture, review responsibilities, and ztd-cli structural vocabulary -- [SQL Tool Happy Paths](../../docs/guide/sql-tool-happy-paths.md) - choose between query plan, perf, query uses, and telemetry -- [Dynamic Filter Routing](../../docs/guide/dynamic-filter-routing.md) - decide between DynamicQueryBuilder filters and SSSQL branches -- [ztd.config.json Top-Level Settings](../../docs/guide/ztd-config-top-level-settings.md) - where schema resolution lives and how to read the generated config -- [Perf Tuning Decision Guide](../../docs/guide/perf-tuning-decision-guide.md) - index tuning vs pipeline tuning -- [JOIN Direction Lint Specification](../../docs/guide/join-direction-lint-spec.md) - readable FK-aware JOIN guidance -- [Repository Telemetry Setup](../../docs/guide/repository-telemetry-setup.md) - how to edit the scaffold, emit logs, and investigate with `queryId` -- [Observed SQL Investigation](../../docs/guide/observed-sql-investigation.md) - how to use `ztd query match-observed` when `queryId` is missing -- [ztd-cli Agent Interface](../../docs/guide/ztd-cli-agent-interface.md) - machine-readable command surface -- [Traditional Lane Follow-up Plan](../../docs/guide/ztd-cli-traditional-lane-followup-plan.md) - design plan for exposing `--test-kind ztd|traditional` and coexisting lane scaffolds - -### Advanced User Guides - -- [Published-Package Verification Before Release](../../docs/guide/published-package-verification.md) - pack and smoke-test the published-package path -- [Release And Merge Readiness](../../docs/guide/release-readiness.md) - PR-body contract for baseline exceptions, CLI migration packets, and scaffold proof -- [What Is SSSQL?](../../docs/guide/sssql-overview.md) - the shortest intro to truthful optional-filter SQL -- [SSSQL for Humans](../../docs/guide/sssql-for-humans.md) - why SSSQL exists and where it fits in the toolchain -- [ztd-cli SSSQL Reference](../../docs/guide/ztd-cli-sssql-reference.md) - one-page command and runtime reference for `ztd query sssql` -- [ztd-cli Telemetry Philosophy](../../docs/guide/ztd-cli-telemetry-philosophy.md) - when to enable telemetry and why it stays opt-in -- [ztd-cli Telemetry Policy](../../docs/guide/ztd-cli-telemetry-policy.md) - which event fields are allowed and how redaction works -- [ztd-cli Telemetry Export Modes](../../docs/guide/ztd-cli-telemetry-export-modes.md) - how to send telemetry to console, debug, file, or OTLP -- [Observed SQL Matching](../../docs/guide/observed-sql-matching.md) - reverse lookup for missing `queryId`, with best-effort ranking and skip/warning reporting -- [Multiple DB Clients in One Workflow](../../docs/guide/multiple-db-clients-in-one-workflow.md) - separate DB contexts, one workflow, and side-by-side `SqlClient` bindings - -### Developer Guides - -- [Local-Source Development](../../docs/guide/ztd-local-source-dogfooding.md) - unpublished local checkout workflow -- [ztd-cli spawn EPERM Investigation](../../docs/dogfooding/ztd-cli-spawn-eperm-investigation.md) - reviewer-checkable root-cause investigation for the local Vitest startup blocker -- [ztd Onboarding Verification](../../docs/dogfooding/ztd-onboarding-dogfooding.md) - reviewer-checkable README Quickstart and tutorial verification for the customer-facing onboarding path - -## License - -MIT diff --git a/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.sql-active-orders.md b/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.sql-active-orders.md deleted file mode 100644 index 562f01e8d..000000000 --- a/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.sql-active-orders.md +++ /dev/null @@ -1,58 +0,0 @@ -# sql.active-orders - -- index: [Unit Test Index](./test-specification.index.md) -- title: Active orders SQL semantics -- definition: [src/specs/sql/activeOrders.catalog.ts](../../src/specs/sql/activeOrders.catalog.ts) -- tests: 2 -- fixtures: orders, users - -## Test Cases - -### baseline - active users with minimum total -- expected: success -#### input -```json -{ - "active": 1, - "limit": 2, - "minTotal": 20 -} -``` -#### output -```json -[ - { - "orderId": 10, - "orderTotal": 50, - "userEmail": "alice@example.com" - }, - { - "orderId": 13, - "orderTotal": 35, - "userEmail": "carol@example.com" - } -] -``` - -### inactive-variant - inactive users return a different result -- expected: success -#### input -```json -{ - "active": 0, - "limit": 2, - "minTotal": 20 -} -``` -#### output -```json -[ - { - "orderId": 12, - "orderTotal": 40, - "userEmail": "bob@example.com" - } -] -``` - - diff --git a/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.sql-sample.md b/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.sql-sample.md deleted file mode 100644 index 4cf0ad508..000000000 --- a/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.sql-sample.md +++ /dev/null @@ -1,45 +0,0 @@ -# sql.sample - -- index: [Unit Test Index](./test-specification.index.md) -- title: sample sql cases -- definition: [src/specs/sql/usersList.catalog.ts](../../src/specs/sql/usersList.catalog.ts) -- tests: 2 -- fixtures: users - -## Test Cases - -### returns-active-users - returns active users -- expected: success -#### input -```json -{ - "active": 1 -} -``` -#### output -```json -[ - { - "id": 1 - } -] -``` - -### returns-inactive-users-when-active-0 - returns inactive users when active=0 -- expected: success -#### input -```json -{ - "active": 0 -} -``` -#### output -```json -[ - { - "id": 2 - } -] -``` - - diff --git a/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.unit-alpha.md b/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.unit-alpha.md deleted file mode 100644 index 25117b4d7..000000000 --- a/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.unit-alpha.md +++ /dev/null @@ -1,23 +0,0 @@ -# unit.alpha - -- index: [Unit Test Index](./test-specification.index.md) -- title: alpha -- definition: [tests/specs/testCaseCatalogs.ts](../../tests/specs/testCaseCatalogs.ts) -- tests: 1 - -## Test Cases - -### a - noop -- expected: success -- tags: happy-path -- focus: Baseline smoke case for runner and evidence plumbing. -#### input -```json -1 -``` -#### output -```json -1 -``` - - diff --git a/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.unit-normalize-email.md b/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.unit-normalize-email.md deleted file mode 100644 index fc0ccabff..000000000 --- a/packages/ztd-cli/artifacts/test-evidence/test-specification.catalog.unit-normalize-email.md +++ /dev/null @@ -1,100 +0,0 @@ -# unit.normalize-email Test Cases - -- schemaVersion: 1 -- index: [Unit Test Index](./test-specification.index.md) -- title: normalizeEmail -- definition: [tests/specs/testCaseCatalogs.ts](../../tests/specs/testCaseCatalogs.ts) -- description: Executable, inference-free specification for internal normalization behavior. -- refs: - - [Issue #448](https://github.com/mk3008/rawsql-ts/issues/448) -- tests: 6 - -## accepts-minimal-domain - accepts shortest practical domain form -- expected: success -- tags: [validation, bva] -- focus: Ensures minimal local and domain segments are accepted. -### input -```json -"a@b.c" -``` -### output -```json -"a@b.c" -``` - -## keeps-plus-alias - preserves plus alias while normalizing case -- expected: success -- tags: [normalization, bva] -- focus: Ensures alias characters are preserved during normalization. -### input -```json -" USER+tag@Example.COM " -``` -### output -```json -"user+tag@example.com" -``` - -## keeps-valid-address - retains already-normalized email -- expected: success -- tags: [normalization, idempotence] -- focus: Ensures already normalized input remains unchanged. -### input -```json -"alice@example.com" -``` -### output -```json -"alice@example.com" -``` - -## rejects-invalid-input - throws when @ is missing -- expected: throws -- tags: [validation, ep] -- focus: Rejects input without @ before producing normalized output. -- refs: - - [Issue #777](https://github.com/mk3008/rawsql-ts/issues/777) -### input -```json -"invalid-email" -``` -### error -```json -{ - "name": "Error", - "message": "invalid email", - "match": "contains" -} -``` - -## throws-empty-after-trim - throws when trimmed input is empty -- expected: throws -- tags: [validation, bva] -- focus: Rejects whitespace-only input after trimming. -### input -```json -" " -``` -### error -```json -{ - "name": "Error", - "message": "invalid email", - "match": "contains" -} -``` - -## trims-and-lowercases - normalizes uppercase + spaces -- expected: success -- tags: [normalization, ep] -- focus: Ensures trimming and lowercasing run before return. -### input -```json -" USER@Example.COM " -``` -### output -```json -"user@example.com" -``` - - diff --git a/packages/ztd-cli/artifacts/test-evidence/test-specification.index.md b/packages/ztd-cli/artifacts/test-evidence/test-specification.index.md deleted file mode 100644 index 53bb5ceb4..000000000 --- a/packages/ztd-cli/artifacts/test-evidence/test-specification.index.md +++ /dev/null @@ -1,10 +0,0 @@ -# Unit Test Index - -- catalogs: 1 - -## Catalog Files - -- [unit.normalize-email](./test-specification.catalog.unit-normalize-email.md) - - title: normalizeEmail - - tests: 6 - diff --git a/packages/ztd-cli/artifacts/test-evidence/test-specification.pr.json b/packages/ztd-cli/artifacts/test-evidence/test-specification.pr.json deleted file mode 100644 index 6e76e90c4..000000000 --- a/packages/ztd-cli/artifacts/test-evidence/test-specification.pr.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "version": 1, - "base": { - "ref": "main", - "sha": "c8f60f4406390ad953dbf97829de88ab55e7cc40" - }, - "head": { - "ref": "HEAD", - "sha": "fd7e3e96429d3c1a068306458c0d1d1ea8c60067" - }, - "baseMode": "merge-base", - "totals": { - "base": { - "catalogs": 0, - "tests": 0 - }, - "head": { - "catalogs": 4, - "tests": 8 - } - }, - "summary": { - "catalogs": { - "added": 4, - "removed": 0, - "updated": 0 - }, - "cases": { - "added": 8, - "removed": 0, - "updated": 0 - } - }, - "catalogs": { - "added": [ - { - "catalogAfter": { - "kind": "sql", - "catalogId": "sql.active-orders", - "title": "Active orders SQL semantics", - "definition": "src/specs/sql/activeOrders.catalog.ts", - "fixtures": [ - "orders", - "users" - ], - "cases": [ - { - "id": "baseline", - "title": "active users with minimum total", - "input": { - "active": 1, - "limit": 2, - "minTotal": 20 - }, - "output": [ - { - "orderId": 10, - "userEmail": "alice@example.com", - "orderTotal": 50 - }, - { - "orderId": 13, - "userEmail": "carol@example.com", - "orderTotal": 35 - } - ] - }, - { - "id": "inactive-variant", - "title": "inactive users return a different result", - "input": { - "active": 0, - "limit": 2, - "minTotal": 20 - }, - "output": [ - { - "orderId": 12, - "userEmail": "bob@example.com", - "orderTotal": 40 - } - ] - } - ] - } - }, - { - "catalogAfter": { - "kind": "sql", - "catalogId": "sql.sample", - "title": "sample sql cases", - "definition": "src/specs/sql/usersList.catalog.ts", - "fixtures": [ - "users" - ], - "cases": [ - { - "id": "returns-active-users", - "title": "returns active users", - "input": { - "active": 1 - }, - "output": [ - { - "id": 1 - } - ] - }, - { - "id": "returns-inactive-users-when-active-0", - "title": "returns inactive users when active=0", - "input": { - "active": 0 - }, - "output": [ - { - "id": 2 - } - ] - } - ] - } - }, - { - "catalogAfter": { - "kind": "function", - "catalogId": "unit.alpha", - "title": "alpha", - "definition": "tests/specs/testCaseCatalogs.ts", - "cases": [ - { - "id": "a", - "title": "noop", - "input": 1, - "output": 1 - } - ] - } - }, - { - "catalogAfter": { - "kind": "function", - "catalogId": "unit.normalize-email", - "title": "normalizeEmail", - "definition": "tests/specs/testCaseCatalogs.ts", - "cases": [ - { - "id": "keeps-valid-address", - "title": "retains already-normalized email", - "input": "alice@example.com", - "output": "alice@example.com" - }, - { - "id": "rejects-invalid-input", - "title": "throws when @ is missing", - "input": "invalid-email", - "output": "Error: invalid email" - }, - { - "id": "trims-and-lowercases", - "title": "normalizes uppercase + spaces", - "input": " USER@Example.COM ", - "output": "user@example.com" - } - ] - } - } - ], - "removed": [], - "updated": [] - } -} diff --git a/packages/ztd-cli/artifacts/test-evidence/test-specification.preview.json b/packages/ztd-cli/artifacts/test-evidence/test-specification.preview.json deleted file mode 100644 index 50f6935fb..000000000 --- a/packages/ztd-cli/artifacts/test-evidence/test-specification.preview.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "schemaVersion": 1, - "mode": "specification", - "summary": { - "sqlCatalogCount": 0, - "sqlCaseCatalogCount": 2, - "testCaseCount": 4, - "specFilesScanned": 0, - "testFilesScanned": 1 - }, - "sqlCatalogs": [], - "sqlCaseCatalogs": [ - { - "id": "sql.active-orders", - "title": "Active orders SQL semantics", - "definitionPath": "src/specs/sql/activeOrders.catalog.ts", - "params": { - "shape": "named", - "example": { - "active": 1, - "minTotal": 20, - "limit": 2 - } - }, - "output": { - "mapping": { - "columnMap": { - "orderId": "order_id", - "orderTotal": "order_total", - "userEmail": "user_email" - } - } - }, - "sql": "\n SELECT\n o.id AS order_id,\n u.email AS user_email,\n o.total AS order_total\n FROM orders o\n INNER JOIN users u ON u.id = o.user_id\n WHERE u.active = @active\n AND o.total >= @minTotal\n ORDER BY o.total DESC\n LIMIT @limit\n ", - "fixtures": [ - { - "tableName": "orders", - "schema": { - "columns": { - "id": "INTEGER", - "total": "INTEGER", - "user_id": "INTEGER" - } - }, - "rowsCount": 4 - }, - { - "tableName": "users", - "schema": { - "columns": { - "active": "INTEGER", - "email": "TEXT", - "id": "INTEGER" - } - }, - "rowsCount": 3 - } - ], - "cases": [ - { - "id": "baseline", - "title": "active users with minimum total", - "params": { - "active": 1, - "limit": 2, - "minTotal": 20 - }, - "expected": [ - { - "orderId": 10, - "userEmail": "alice@example.com", - "orderTotal": 50 - }, - { - "orderId": 13, - "userEmail": "carol@example.com", - "orderTotal": 35 - } - ] - }, - { - "id": "inactive-variant", - "title": "inactive users return a different result", - "params": { - "active": 0, - "limit": 2, - "minTotal": 20 - }, - "expected": [ - { - "orderId": 12, - "userEmail": "bob@example.com", - "orderTotal": 40 - } - ] - } - ] - }, - { - "id": "sql.sample", - "title": "sample sql cases", - "definitionPath": "src/specs/sql/usersList.catalog.ts", - "params": { - "shape": "named", - "example": { - "active": 1 - } - }, - "output": { - "mapping": { - "columnMap": { - "id": "id" - } - } - }, - "sql": "select id from users where active = :active", - "fixtures": [ - { - "tableName": "users", - "schema": { - "columns": { - "active": "INTEGER", - "id": "INTEGER" - } - }, - "rowsCount": 2 - } - ], - "cases": [ - { - "id": "returns-active-users", - "title": "returns active users", - "params": { - "active": 1 - }, - "expected": [ - { - "id": 1 - } - ] - }, - { - "id": "returns-inactive-users-when-active-0", - "title": "returns inactive users when active=0", - "params": { - "active": 0 - }, - "expected": [ - { - "id": 2 - } - ] - } - ] - } - ], - "testCaseCatalogs": [ - { - "id": "unit.alpha", - "title": "alpha", - "definitionPath": "tests/specs/testCaseCatalogs.ts", - "cases": [ - { - "id": "a", - "title": "noop", - "input": 1, - "output": 1 - } - ] - }, - { - "id": "unit.normalize-email", - "title": "normalizeEmail", - "description": "Executable, inference-free specification for internal normalization behavior.", - "definitionPath": "tests/specs/testCaseCatalogs.ts", - "cases": [ - { - "id": "keeps-valid-address", - "title": "retains already-normalized email", - "input": "alice@example.com", - "output": "alice@example.com" - }, - { - "id": "rejects-invalid-input", - "title": "throws when @ is missing", - "input": "invalid-email", - "output": "Error: invalid email" - }, - { - "id": "trims-and-lowercases", - "title": "normalizes uppercase + spaces", - "input": " USER@Example.COM ", - "output": "user@example.com" - } - ] - } - ], - "testCases": [ - { - "kind": "test-case", - "id": "unit.alpha.a", - "catalogId": "unit.alpha", - "caseId": "a", - "filePath": "tests/specs/index", - "title": "noop" - }, - { - "kind": "test-case", - "id": "unit.normalize-email.keeps-valid-address", - "catalogId": "unit.normalize-email", - "caseId": "keeps-valid-address", - "filePath": "tests/specs/index", - "title": "retains already-normalized email" - }, - { - "kind": "test-case", - "id": "unit.normalize-email.rejects-invalid-input", - "catalogId": "unit.normalize-email", - "caseId": "rejects-invalid-input", - "filePath": "tests/specs/index", - "title": "throws when @ is missing" - }, - { - "kind": "test-case", - "id": "unit.normalize-email.trims-and-lowercases", - "catalogId": "unit.normalize-email", - "caseId": "trims-and-lowercases", - "filePath": "tests/specs/index", - "title": "normalizes uppercase + spaces" - } - ] -} diff --git a/packages/ztd-cli/fixtures/generated-mapper-drift/package.json b/packages/ztd-cli/fixtures/generated-mapper-drift/package.json deleted file mode 100644 index 6d672da26..000000000 --- a/packages/ztd-cli/fixtures/generated-mapper-drift/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@rawsql-ts/fixture-generated-mapper-drift", - "private": true, - "type": "module", - "scripts": { - "ztd": "node ../../dist/index.js" - } -} diff --git a/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/boundary.ts b/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/boundary.ts deleted file mode 100644 index 864d4932b..000000000 --- a/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/boundary.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from 'zod'; -import { mapListRowsToResult } from './generated/row-mapper.js'; - -const RowSchema = z.object({ - id: z.string(), - email: z.string(), -}).strict(); - -const QueryResultSchema = z.object({ - items: z.array(RowSchema), -}).strict(); - -export type ListQueryResult = z.infer; -export type ListRow = z.infer; - -export async function executeListQuerySpec(rows: ListRow[]): Promise { - return mapListRowsToResult(rows); -} diff --git a/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/generated/row-mapper.ts b/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/generated/row-mapper.ts deleted file mode 100644 index 07c8413ed..000000000 --- a/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/generated/row-mapper.ts +++ /dev/null @@ -1,18 +0,0 @@ -// @generated by rawsql-ts ztd-cli. Do not edit. -// This file is machine-owned and regenerated by `ztd feature generated-mapper generate`. -// source-boundary-sha256: f5a514edf686b785d10b74699d3de1079a088ef1f5460e9ab73b5697db207708 -// source-sql-sha256: 6578873c4ab5aa03d7de955b7247a0c394dfa0fb293403aeb519b9a66f8f667a - -import type { ListQueryResult, ListRow } from '../boundary.js'; - -export function mapListRowsToResult(rows: ListRow[]): ListQueryResult { - const items = new Array(rows.length); - for (let index = 0; index < rows.length; index += 1) { - const row = rows[index]; - items[index] = { - "id": row["id"], - "email": row["email"], - }; - } - return { items }; -} diff --git a/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/list.sql b/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/list.sql deleted file mode 100644 index e5f4a47ac..000000000 --- a/packages/ztd-cli/fixtures/generated-mapper-drift/src/features/users-list/queries/list/list.sql +++ /dev/null @@ -1,5 +0,0 @@ -select - id, - email -from users -order by id asc; diff --git a/packages/ztd-cli/package.json b/packages/ztd-cli/package.json deleted file mode 100644 index 6ae2bb411..000000000 --- a/packages/ztd-cli/package.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "@rawsql-ts/ztd-cli", - "version": "0.27.4", - "description": "DB-agnostic scaffolding and DDL helpers for Zero Table Dependency projects", - "main": "dist/index.js", - "bin": { - "ztd": "dist/index.js" - }, - "scripts": { - "prepack": "node -e \"const fs=require('fs');const cp=require('child_process');const npm=process.platform==='win32'?'npm.cmd':'npm';if(!fs.existsSync('dist/index.js')){process.exit(cp.spawnSync(npm,['run','build'],{stdio:'inherit'}).status??1)}\"", - "build": "pnpm --filter rawsql-ts run build && pnpm --filter @rawsql-ts/sql-grep-core run build && pnpm --filter @rawsql-ts/test-evidence-core run build && pnpm --filter @rawsql-ts/test-evidence-renderer-md run build && pnpm --filter @rawsql-ts/testkit-core run build && tsc -p tsconfig.json", - "test": "pnpm --filter @rawsql-ts/adapter-node-pg run build && vitest run --config vitest.config.ts", - "test:essential": "pnpm --filter @rawsql-ts/adapter-node-pg run build && vitest run --config vitest.config.ts tests/checkContract.cli-exit.test.ts tests/checkContract.cli.test.ts tests/checkContract.unit.test.ts tests/featureScaffold.unit.test.ts tests/featureTestsScaffold.unit.test.ts tests/gitignoreTemplate.pack.test.ts tests/init.command.test.ts tests/options.unit.test.ts tests/prReadiness.unit.test.ts tests/precommitEnforcement.unit.test.ts tests/qualityGates.unit.test.ts tests/releaseReadiness.unit.test.ts", - "test:generated-mapper": "pnpm --filter @rawsql-ts/adapter-node-pg run build && vitest run --config vitest.config.ts tests/featureScaffold.unit.test.ts tests/cliCommands.test.ts", - "test:soft": "vitest run --config vitest.config.ts tests/repoGuidance.unit.test.ts tests/intentProcedure.docs.test.ts tests/perfBenchmark.unit.test.ts tests/perfSandbox.unit.test.ts tests/queryLint.unit.test.ts", - "lint": "eslint src --ext .ts", - "release": "npm run lint && npm run test && npm run build && node -e \"require('fs').mkdirSync('../../tmp', { recursive: true })\" && pnpm pack --out ../../tmp/rawsql-ts-ztd-cli.tgz && pnpm publish --access public", - "test:consumer-validation": "vitest run --config vitest.config.ts tests/testEvidence.unit.test.ts tests/testEvidence.cli.test.ts tests/sqlCatalog.evidence.test.ts tests/testCaseCatalog.evidence.test.ts" - }, - "keywords": [ - "rawsql-ts", - "ztd", - "cli", - "ddl", - "fixtures" - ], - "author": "msugiura", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/mk3008/rawsql-ts.git", - "directory": "packages/ztd-cli" - }, - "publishConfig": { - "access": "public" - }, - "engines": { - "node": ">=20" - }, - "dependencies": { - "@rawsql-ts/driver-adapter-core": "^0.2.0", - "@rawsql-ts/sql-grep-core": "^0.1.12", - "@rawsql-ts/test-evidence-core": "^0.2.0", - "@rawsql-ts/test-evidence-renderer-md": "^0.3.2", - "chokidar": "^5.0.0", - "commander": "^12.0.0", - "diff": "^8.0.3", - "fast-glob": "^3.3.3", - "rawsql-ts": "^0.23.0", - "yaml": "^2.8.3" - }, - "peerDependencies": { - "@rawsql-ts/adapter-node-pg": "^0.15.11" - }, - "peerDependenciesMeta": { - "@rawsql-ts/adapter-node-pg": { - "optional": true - } - }, - "devDependencies": { - "@rawsql-ts/adapter-node-pg": "^0.15.11", - "@rawsql-ts/testkit-core": "^0.17.2", - "@rawsql-ts/testkit-postgres": "^0.16.2", - "@testcontainers/postgresql": "^12.0.1", - "@types/diff": "^5.0.1", - "@types/node": "^22.13.10", - "dotenv": "^16.4.7", - "pg": "^8.11.1", - "testcontainers": "^12.0.1", - "typescript": "^5.8.2", - "vitest": "^4.1.8" - }, - "files": [ - "dist", - "templates", - "README.md" - ] -} diff --git a/packages/ztd-cli/src/commands/checkContract.ts b/packages/ztd-cli/src/commands/checkContract.ts deleted file mode 100644 index a918632a4..000000000 --- a/packages/ztd-cli/src/commands/checkContract.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { BinarySelectQuery, ColumnReference, DeleteQuery, MultiQuerySplitter, SimpleSelectQuery, SqlParser, UpdateQuery } from 'rawsql-ts'; -import { - discoverProjectSqlCatalogSpecFiles, - isPlainObject, - loadSqlCatalogSpecsFromFile, - walkSqlCatalogSpecFiles, - type LoadedSqlCatalogSpec -} from '../utils/sqlCatalogDiscovery'; -import { getAgentOutputFormat, parseJsonPayload } from '../utils/agentCli'; - -export type CheckFormat = 'human' | 'json'; -export type ViolationSeverity = 'error' | 'warning'; - -/** A single deterministic contract-check violation/warning item. */ -export interface ContractViolation { - rule: - | 'duplicate-spec-id' - | 'unresolved-sql-file' - | 'params-shape-mismatch' - | 'mapping-invalid-entry' - | 'mapping-duplicate-entry' - | 'uncovered-sql-file' - | 'safety-select-star' - | 'safety-missing-where' - | 'sql-parse-error'; - severity: ViolationSeverity; - specId: string; - filePath: string; - message: string; -} - -export interface CheckContractResult { - ok: boolean; - violations: ContractViolation[]; - filesChecked: number; - specsChecked: number; -} - -/** - * Resolve command exit code for contract checks. - * @param args.result Completed check result when execution succeeded. - * @param args.error Error thrown while running checks. - * @returns 0 when result is ok, 1 when violations exist or non-runtime errors occur, 2 for runtime/config errors. - */ -export function resolveCheckContractExitCode(args: { - result?: CheckContractResult; - error?: unknown; -}): 0 | 1 | 2 { - if (args.error) { - return args.error instanceof CheckContractRuntimeError ? 2 : 1; - } - if (!args.result) { - return 2; - } - return args.result.ok ? 0 : 1; -} - -/** Runtime/configuration error for contract check command (maps to exit code 2). */ -export class CheckContractRuntimeError extends Error { - readonly exitCode = 2; -} - -interface CheckCommandOptions { - format?: string; - out?: string; - strict?: boolean; - scopeDir?: string; - specsDir?: string; - json?: string; -} - -/** Register `ztd check contract` command on the CLI root program. */ -export function registerCheckContractCommand(program: Command): void { - const check = program.command('check').description('Contract validation workflows'); - - check - .command('contract') - .description('Check SQL contract specs deterministically') - .option('--format ', 'Output format (human|json)') - .option('--out ', 'Write output to file') - .option('--strict', 'Treat safety warnings as violations') - .option('--scope-dir ', 'Limit QuerySpec discovery to one feature, boundary, or subtree') - .option('--specs-dir ', 'Legacy override for a fixed SQL catalog specs directory') - .option('--json ', 'Pass command options as a JSON object') - .action(async (options: CheckCommandOptions) => { - try { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const format = resolveCheckOutputFormat(merged.format); - const result = runCheckContract({ - strict: normalizeBooleanOption(merged.strict), - rootDir: process.env.ZTD_PROJECT_ROOT, - scopeDir: normalizeStringOption(merged.scopeDir), - specsDir: normalizeStringOption(merged.specsDir) - }); - const text = formatOutput(result, format); - const outPath = normalizeStringOption(merged.out); - if (outPath) { - const absolute = path.resolve(process.cwd(), outPath); - mkdirSync(path.dirname(absolute), { recursive: true }); - writeFileSync(absolute, text, 'utf8'); - } else { - const writer = result.ok ? console.log : console.error; - writer(text); - } - process.exitCode = resolveCheckContractExitCode({ result }); - } catch (error) { - process.exitCode = resolveCheckContractExitCode({ error }); - console.error(error instanceof Error ? error.message : String(error)); - } - }); -} - -function normalizeFormat(format: string): CheckFormat { - const normalized = format.trim().toLowerCase(); - if (normalized === 'human') { - return 'human'; - } - if (normalized === 'json') { - return 'json'; - } - throw new CheckContractRuntimeError(`Unsupported format: ${format}`); -} - -function resolveCheckOutputFormat(value: unknown): CheckFormat { - const explicitFormat = normalizeStringOption(value); - if (explicitFormat) { - return normalizeFormat(explicitFormat); - } - - // Map the global CLI output mode onto the contract command's local format names. - return getAgentOutputFormat() === 'json' ? 'json' : 'human'; -} - -function normalizeStringOption(value: unknown): string | undefined { - if (value === undefined || value === null || value === '') { - return undefined; - } - if (typeof value !== 'string') { - throw new CheckContractRuntimeError(`Expected a string option but received ${typeof value}.`); - } - return value; -} - -function normalizeBooleanOption(value: unknown): boolean { - if (value === undefined) { - return false; - } - if (typeof value !== 'boolean') { - throw new CheckContractRuntimeError(`Expected a boolean option but received ${typeof value}.`); - } - return value; -} - -/** - * Run deterministic contract checks for catalog specs under a project root. - * @param options.strict Treat safety checks as errors when true, warnings otherwise. - * @param options.rootDir Optional project root override. - * @param options.scopeDir Optional feature/boundary subtree override (relative to rootDir). - * @param options.specsDir Optional legacy specs directory override (relative to rootDir). - */ -export function runCheckContract(options: { - strict: boolean; - rootDir?: string; - scopeDir?: string; - specsDir?: string; -}): CheckContractResult { - const root = path.resolve(options.rootDir ?? process.cwd()); - const specFiles = resolveCheckContractSpecFiles(root, options); - const loadedSpecs = specFiles.flatMap((filePath) => - loadSqlCatalogSpecsFromFile(filePath, (message) => new CheckContractRuntimeError(message)) - ); - const violations: ContractViolation[] = []; - const sqlDir = options.scopeDir - ? resolveDirectoryOption(root, options.scopeDir, 'Scope directory') - : path.resolve(root, 'src', 'sql'); - const sqlFiles = existsSync(sqlDir) ? walkSqlFiles(sqlDir) : []; - - const coveredSqlFiles = new Set(); - for (const loaded of loadedSpecs) { - if (typeof loaded.spec.sqlFile !== 'string' || loaded.spec.sqlFile.trim().length === 0) { - continue; - } - coveredSqlFiles.add(path.resolve(path.dirname(loaded.filePath), loaded.spec.sqlFile)); - } - - const uncoveredSeverity: ViolationSeverity = options.strict ? 'error' : 'warning'; - for (const sqlFile of sqlFiles) { - if (coveredSqlFiles.has(sqlFile)) { - continue; - } - violations.push({ - rule: 'uncovered-sql-file', - severity: uncoveredSeverity, - specId: path.relative(root, sqlFile).replace(/\\/g, '/'), - filePath: sqlFile, - message: - 'SQL asset is not covered by any QuerySpec. Start from tests/queryspec.example.test.ts or run ztd model-gen to scaffold the first spec.' - }); - } - - const duplicateMap = new Map(); - for (const loaded of loadedSpecs) { - const id = typeof loaded.spec.id === 'string' ? loaded.spec.id.trim() : ''; - if (!id) { - continue; - } - const list = duplicateMap.get(id) ?? []; - list.push(loaded); - duplicateMap.set(id, list); - } - for (const [id, entries] of Array.from(duplicateMap.entries()).sort((a, b) => a[0].localeCompare(b[0]))) { - if (entries.length < 2) { - continue; - } - for (const entry of entries.sort((a, b) => a.filePath.localeCompare(b.filePath))) { - violations.push({ - rule: 'duplicate-spec-id', - severity: 'error', - specId: id, - filePath: entry.filePath, - message: `Duplicate spec.id "${id}" detected.` - }); - } - } - - for (const loaded of loadedSpecs.sort((a, b) => { - const idA = typeof a.spec.id === 'string' ? a.spec.id : ''; - const idB = typeof b.spec.id === 'string' ? b.spec.id : ''; - return idA.localeCompare(idB) || a.filePath.localeCompare(b.filePath); - })) { - const specId = typeof loaded.spec.id === 'string' && loaded.spec.id.trim().length > 0 - ? loaded.spec.id.trim() - : ''; - - if (typeof loaded.spec.sqlFile !== 'string' || loaded.spec.sqlFile.trim().length === 0) { - violations.push({ - rule: 'unresolved-sql-file', - severity: 'error', - specId, - filePath: loaded.filePath, - message: 'spec.sqlFile must be a non-empty string.' - }); - } else { - const sqlPath = path.resolve(path.dirname(loaded.filePath), loaded.spec.sqlFile); - if (!existsSync(sqlPath)) { - violations.push({ - rule: 'unresolved-sql-file', - severity: 'error', - specId, - filePath: loaded.filePath, - message: `SQL file does not exist: ${loaded.spec.sqlFile}` - }); - } else { - applySafetyChecks(sqlPath, specId, loaded.filePath, options.strict, violations); - } - } - - const shape = loaded.spec.params?.shape; - const example = loaded.spec.params?.example; - if (shape === 'positional' && !Array.isArray(example)) { - violations.push({ - rule: 'params-shape-mismatch', - severity: 'error', - specId, - filePath: loaded.filePath, - message: 'params.shape="positional" requires params.example to be an array.' - }); - } else if (shape === 'named' && !isPlainObject(example)) { - violations.push({ - rule: 'params-shape-mismatch', - severity: 'error', - specId, - filePath: loaded.filePath, - message: 'params.shape="named" requires params.example to be an object.' - }); - } - - const mapping = loaded.spec.output?.mapping; - if (mapping) { - validateMapping(specId, loaded.filePath, mapping, violations); - } - } - - const sorted = violations.sort((a, b) => - a.rule.localeCompare(b.rule) || - a.specId.localeCompare(b.specId) || - a.filePath.localeCompare(b.filePath) || - a.message.localeCompare(b.message) - ); - - return { - ok: sorted.length === 0 || sorted.every((item) => item.severity === 'warning'), - violations: sorted, - filesChecked: specFiles.length, - specsChecked: loadedSpecs.length - }; -} - -function resolveCheckContractSpecFiles( - root: string, - options: { scopeDir?: string; specsDir?: string } -): string[] { - if (options.scopeDir && options.specsDir) { - throw new CheckContractRuntimeError('Use either --scope-dir or --specs-dir, not both.'); - } - - if (options.specsDir) { - const specsDir = resolveDirectoryOption(root, options.specsDir, 'Spec directory'); - return walkSqlCatalogSpecFiles(specsDir, { excludeTestFiles: true }); - } - - if (options.scopeDir) { - const scopeDir = resolveDirectoryOption(root, options.scopeDir, 'Scope directory'); - return discoverProjectSqlCatalogSpecFiles(scopeDir, { excludeTestFiles: true }); - } - - return discoverProjectSqlCatalogSpecFiles(root, { excludeTestFiles: true }); -} - -function resolveDirectoryOption(root: string, value: string, label: string): string { - const resolved = path.resolve(root, value); - const relative = path.relative(root, resolved); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new CheckContractRuntimeError(`${label} must be inside the project root: ${resolved}`); - } - if (!existsSync(resolved)) { - throw new CheckContractRuntimeError(`${label} not found: ${resolved}`); - } - if (!statSync(resolved).isDirectory()) { - throw new CheckContractRuntimeError(`${label} is not a directory: ${resolved}`); - } - return resolved; -} - -function applySafetyChecks( - sqlPath: string, - specId: string, - specFilePath: string, - strict: boolean, - violations: ContractViolation[] -): void { - const sql = readFileSync(sqlPath, 'utf8'); - const chunks = MultiQuerySplitter.split(sql).queries.filter((item) => !item.isEmpty); - for (const chunk of chunks) { - const statement = chunk.sql.trim(); - if (!statement) { - continue; - } - let parsed: unknown; - try { - parsed = SqlParser.parse(statement); - } catch (error) { - violations.push({ - rule: 'sql-parse-error', - severity: 'warning', - specId, - filePath: specFilePath, - message: `SQL parse failed in safety check: ${error instanceof Error ? error.message : String(error)}` - }); - continue; - } - - const severity: ViolationSeverity = strict ? 'error' : 'warning'; - - if (parsed instanceof UpdateQuery || parsed instanceof DeleteQuery) { - if (!parsed.whereClause) { - violations.push({ - rule: 'safety-missing-where', - severity, - specId, - filePath: specFilePath, - message: 'UPDATE/DELETE statement without WHERE detected.' - }); - } - continue; - } - - if (hasRootLevelSelectWildcard(parsed)) { - violations.push({ - rule: 'safety-select-star', - severity, - specId, - filePath: specFilePath, - message: 'SELECT * detected at root query level.' - }); - } - } -} - -function hasRootLevelSelectWildcard(parsed: unknown): boolean { - if (parsed instanceof SimpleSelectQuery) { - return parsed.selectClause.items.some((item) => isWildcardSelectItem(item.value)); - } - - if (parsed instanceof BinarySelectQuery) { - return hasRootLevelSelectWildcard(parsed.left) || hasRootLevelSelectWildcard(parsed.right); - } - - return false; -} - -function isWildcardSelectItem(value: unknown): boolean { - return value instanceof ColumnReference && value.column.name === '*'; -} - -function validateMapping( - specId: string, - filePath: string, - mapping: { prefix?: unknown; columnMap?: unknown }, - violations: ContractViolation[] -): void { - const hasPrefix = typeof mapping.prefix === 'string' && mapping.prefix.trim().length > 0; - if (!hasPrefix && mapping.columnMap === undefined) { - violations.push({ - rule: 'mapping-invalid-entry', - severity: 'error', - specId, - filePath, - message: 'output.mapping must provide prefix or columnMap.' - }); - return; - } - - if (mapping.columnMap === undefined) { - return; - } - - if (!isPlainObject(mapping.columnMap)) { - violations.push({ - rule: 'mapping-invalid-entry', - severity: 'error', - specId, - filePath, - message: 'output.mapping.columnMap must be an object when provided.' - }); - return; - } - - const seenColumns = new Map(); - for (const key of Object.keys(mapping.columnMap).sort()) { - const value = mapping.columnMap[key]; - if (!key.trim()) { - violations.push({ - rule: 'mapping-invalid-entry', - severity: 'error', - specId, - filePath, - message: 'output.mapping.columnMap keys must be non-empty strings.' - }); - continue; - } - if (typeof value !== 'string' || value.trim().length === 0) { - violations.push({ - rule: 'mapping-invalid-entry', - severity: 'error', - specId, - filePath, - message: `output.mapping.columnMap["${key}"] must be a non-empty string.` - }); - continue; - } - const normalized = value.trim(); - const prev = seenColumns.get(normalized); - if (prev) { - violations.push({ - rule: 'mapping-duplicate-entry', - severity: 'error', - specId, - filePath, - message: `Duplicate mapped column "${normalized}" for keys "${prev}" and "${key}".` - }); - continue; - } - seenColumns.set(normalized, key); - } -} - -function walkSqlFiles(rootDir: string): string[] { - const files: string[] = []; - const stack = [rootDir]; - while (stack.length > 0) { - const current = stack.pop()!; - const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(absolute); - continue; - } - if (!entry.isFile()) { - continue; - } - if (path.extname(entry.name).toLowerCase() !== '.sql') { - continue; - } - files.push(absolute); - } - } - return files.sort((a, b) => a.localeCompare(b)); -} - -/** Format check results into human text or deterministic JSON text. */ -export function formatOutput(result: CheckContractResult, format: CheckFormat): string { - if (format === 'json') { - return `${JSON.stringify(result, null, 2)}\n`; - } - - if (result.violations.length === 0) { - return `contract check passed (${result.specsChecked} specs in ${result.filesChecked} files)`; - } - - const lines: string[] = []; - lines.push(`contract check found ${result.violations.length} violation(s)`); - for (const item of result.violations) { - lines.push(`- [${item.severity}] ${item.rule} ${item.specId} @ ${item.filePath}`); - lines.push(` ${item.message}`); - } - return lines.join('\n'); -} diff --git a/packages/ztd-cli/src/commands/connectionOptions.ts b/packages/ztd-cli/src/commands/connectionOptions.ts deleted file mode 100644 index 0a2657367..000000000 --- a/packages/ztd-cli/src/commands/connectionOptions.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - resolveExplicitTargetConnection, - resolveZtdOwnedTestConnection -} from '../utils/dbConnection'; -import type { DbConnectionFlags, ResolvedDatabaseConnection } from '../utils/dbConnection'; - -export interface ConnectionCliOptions { - url?: string; - dbHost?: string; - dbPort?: string; - dbUser?: string; - dbPassword?: string; - dbName?: string; -} - -export function resolveExplicitCliConnection(options: ConnectionCliOptions): ResolvedDatabaseConnection { - return resolveExplicitTargetConnection(buildFlagSet(options), options.url); -} - -export function resolveZtdOwnedCliConnection(): ResolvedDatabaseConnection { - return resolveZtdOwnedTestConnection(); -} - -export function buildFlagSet(options: ConnectionCliOptions): DbConnectionFlags { - return { - host: options.dbHost, - port: options.dbPort, - user: options.dbUser, - password: options.dbPassword, - database: options.dbName - }; -} diff --git a/packages/ztd-cli/src/commands/ddl.ts b/packages/ztd-cli/src/commands/ddl.ts deleted file mode 100644 index 582f102cc..000000000 --- a/packages/ztd-cli/src/commands/ddl.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { runDiffSchema } from './diff'; -import { analyzeMigrationSqlRisks } from './ddlRiskEvaluator'; -import { runGenerateEntities } from './genEntities'; -import { runPullSchema } from './pull'; -import { resolveExplicitCliConnection, type ConnectionCliOptions } from './connectionOptions'; -import { - collectDirectories, - collectValues, - normalizeDirectoryList, - parseExtensions, - resolveExtensions, - DEFAULT_DDL_DIRECTORY, - DEFAULT_EXTENSIONS -} from './options'; -import { isJsonOutput, parseJsonPayload, writeCommandEnvelope } from '../utils/agentCli'; -import { validateProjectPath, validateResourceIdentifier } from '../utils/agentSafety'; - -interface PullCommandOptions extends ConnectionCliOptions { - pgDumpPath?: string; - pgDumpShell?: boolean; - out?: string; - schema?: string[]; - table?: string[]; - dryRun?: boolean; - json?: string; -} - -interface DiffCommandOptions extends ConnectionCliOptions { - pgDumpPath?: string; - pgDumpShell?: boolean; - ddlDir?: string[]; - extensions?: string[]; - out?: string; - dryRun?: boolean; - json?: string; -} - -interface RiskCommandOptions { - file?: string; - json?: string; -} - -/** - * Registers all DDL-related commands (`pull`, `gen-entities`, `diff`) on the top-level CLI program. - * @param program - The Commander program instance to extend. - */ -export function registerDdlCommands(program: Command): void { - const ddl = program.command('ddl').description('DDL-focused workflows'); - - ddl - .command('pull') - .description('Inspect schema state from an explicit target database and normalize the pulled DDL') - .option('--url ', 'Explicit target database URL for inspection workflows (preferred over --db-*)') - .option('--out ', 'Destination directory for the pulled DDL', DEFAULT_DDL_DIRECTORY) - .option('--db-host ', 'Explicit target database host when --url is not used') - .option('--db-port ', 'Explicit target database port (defaults to 5432)') - .option('--db-user ', 'Explicit target database user') - .option('--db-password ', 'Explicit target database password') - .option('--db-name ', 'Explicit target database name') - .option('--pg-dump-path ', 'Custom pg_dump executable path') - .option('--pg-dump-shell', 'Run the pg_dump path through a shell so wrapper commands like "docker exec pg_dump" can be used') - .option('--schema ', 'Schema name to include (repeatable)', collectValues, []) - .option('--table
', 'Table spec (schema.table) to include (repeatable)', collectValues, []) - .option('--dry-run', 'Validate pull inputs and normalize the dump without writing files') - .option('--json ', 'Pass pull options as a JSON object') - .action(async (options: PullCommandOptions) => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const connection = resolveExplicitCliConnection(merged); - const result = await runPullSchema({ - url: connection.url, - out: validateProjectPath(String(merged.out ?? DEFAULT_DDL_DIRECTORY), '--out'), - pgDumpPath: merged.pgDumpPath, - pgDumpShell: Boolean(merged.pgDumpShell), - schemas: (merged.schema ?? []).map((value) => validateResourceIdentifier(String(value), '--schema')), - tables: (merged.table ?? []).map((value) => validateResourceIdentifier(String(value), '--table')), - connectionContext: connection.context, - dryRun: Boolean(merged.dryRun) - }); - if (isJsonOutput()) { - writeCommandEnvelope('ddl pull', { - schemaVersion: 1, - dryRun: result.dryRun, - outDir: result.outDir, - files: result.files.map((file) => ({ schema: file.schema, path: file.filePath, bytes: file.contents.length })) - }); - } - }); - - ddl - .command('gen-entities') - .description('Generate optional entities.ts helpers from the DDL snapshot') - .option('--ddl-dir ', 'DDL directory to scan (repeatable)', collectDirectories, []) - .option('--extensions ', 'Comma-separated extensions to include', parseExtensions, DEFAULT_EXTENSIONS) - .option('--out ', 'Destination TypeScript file', path.join('src', 'entities.ts')) - .option('--dry-run', 'Render entities without writing the destination file') - .option('--json ', 'Pass generation options as a JSON object') - .action(async (options) => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const directories = normalizeDirectoryList(merged.ddlDir as string[], DEFAULT_DDL_DIRECTORY); - const extensions = resolveExtensions(merged.extensions as string[], DEFAULT_EXTENSIONS); - const result = await runGenerateEntities({ - directories, - extensions, - out: validateProjectPath(String(merged.out ?? path.join('src', 'entities.ts')), '--out'), - dryRun: Boolean(merged.dryRun) - }); - if (isJsonOutput()) { - writeCommandEnvelope('ddl gen-entities', { - schemaVersion: 1, - dryRun: result.dryRun, - outFile: result.outFile, - tables: result.tables.map((table) => table.name), - bytes: result.rendered.length - }); - } - }); - - ddl - .command('diff') - .description('Compare local DDL against an explicit target database and emit logical summary plus structured apply-plan risks alongside pure SQL artifacts') - .option('--ddl-dir ', 'DDL directory to scan (repeatable)', collectDirectories, []) - .option('--extensions ', 'Comma-separated extensions to include', parseExtensions, DEFAULT_EXTENSIONS) - .option('--url ', 'Explicit target database URL for inspection workflows (preferred over --db-*)') - .option('--out ', 'Output path for the generated SQL artifact; companion .txt/.json review files are written alongside it') - .option('--db-host ', 'Explicit target database host when --url is not used') - .option('--db-port ', 'Explicit target database port (defaults to 5432)') - .option('--db-user ', 'Explicit target database user') - .option('--db-password ', 'Explicit target database password') - .option('--db-name ', 'Explicit target database name') - .option('--pg-dump-path ', 'Custom pg_dump executable path') - .option('--pg-dump-shell', 'Run the pg_dump path through a shell so wrapper commands like "docker exec pg_dump" can be used') - .option('--dry-run', 'Compute the logical summary and structured risks without writing the SQL/.txt/.json artifacts') - .option('--json ', 'Pass diff options as a JSON object') - .action(async (options: DiffCommandOptions) => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const outPath = resolveRequiredProjectPath(merged.out, '--out'); - const directories = normalizeDirectoryList(merged.ddlDir ?? [], DEFAULT_DDL_DIRECTORY); - const extensions = resolveExtensions(merged.extensions, DEFAULT_EXTENSIONS); - const connection = resolveExplicitCliConnection(merged); - const result = await runDiffSchema({ - directories, - extensions, - url: connection.url, - out: outPath, - pgDumpPath: merged.pgDumpPath, - pgDumpShell: Boolean(merged.pgDumpShell), - connectionContext: connection.context, - dryRun: Boolean(merged.dryRun) - }); - if (isJsonOutput()) { - writeCommandEnvelope('ddl diff', { - schemaVersion: 1, - dryRun: result.dryRun, - outFile: result.outFile, - hasChanges: result.hasChanges, - applyPlan: result.applyPlan, - artifacts: result.artifacts, - summary: result.summary, - risks: result.risks, - sqlBytes: result.sql.length - }); - return; - } - - process.stdout.write(result.text); - }); - - ddl - .command('risk') - .description('Analyze a generated or hand-edited migration SQL file and emit the shared structured risk contract') - .option('--file ', 'Migration SQL file to analyze with the shared structured risk contract') - .option('--json ', 'Pass risk options as a JSON object') - .action(async (options: RiskCommandOptions) => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const filePath = resolveRequiredProjectPath(merged.file, '--file'); - const sql = readFileSync(filePath, 'utf8'); - const risks = analyzeMigrationSqlRisks(sql); - - if (isJsonOutput()) { - writeCommandEnvelope('ddl risk', { - schemaVersion: 1, - file: filePath, - risks - }); - return; - } - - const lines = ['Migration SQL risks', `- file: ${filePath}`, '', 'Destructive risks']; - lines.push(...formatRiskLines(risks.destructiveRisks)); - lines.push('', 'Operational risks'); - lines.push(...formatRiskLines(risks.operationalRisks)); - process.stdout.write(`${lines.join('\n')}\n`); - }); -} - -export { collectDirectories, parseExtensions, DEFAULT_EXTENSIONS, DEFAULT_DDL_DIRECTORY } from './options'; - -function resolveRequiredProjectPath(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`${label} is required (either via ${label} or --json {"${label.slice(2)}":"..."}).`); - } - - return validateProjectPath(value, label); -} - -function formatRiskLines( - risks: Array<{ kind: string; target?: string; from?: string; to?: string; guidance?: string[] }> -): string[] { - if (risks.length === 0) { - return ['- none']; - } - - const lines: string[] = []; - for (const risk of risks) { - if ('from' in risk && risk.from && 'to' in risk && risk.to) { - lines.push(`- ${risk.kind}: ${risk.from} -> ${risk.to}`); - } else { - lines.push(`- ${risk.kind}: ${String(risk.target ?? 'unknown')}`); - } - - if (risk.guidance && risk.guidance.length > 0) { - lines.push(` guidance: ${risk.guidance.join(', ')}`); - } - } - - return lines; -} diff --git a/packages/ztd-cli/src/commands/ddlDiffContracts.ts b/packages/ztd-cli/src/commands/ddlDiffContracts.ts deleted file mode 100644 index aeb367379..000000000 --- a/packages/ztd-cli/src/commands/ddlDiffContracts.ts +++ /dev/null @@ -1,86 +0,0 @@ -export type DdlDiffChangeKind = - | 'create_table' - | 'drop_table' - | 'add_column' - | 'drop_column' - | 'alter_type' - | 'alter_nullability' - | 'table_rebuild' - | 'schema_change'; - -export interface DdlDiffSummaryEntry { - schema: string; - table: string; - changeKind: DdlDiffChangeKind; - details: Record; -} - -export type RiskGuidanceKind = - | 'review_if_required' - | 'avoid_if_possible' - | 'cli_option_not_exposed'; - -export type DestructiveRiskKind = - | 'drop_table' - | 'drop_column' - | 'cascade_drop' - | 'alter_type' - | 'rename_candidate' - | 'nullability_tighten' - | 'semantic_constraint_change'; - -export type OperationalRiskKind = - | 'table_rebuild' - | 'index_rebuild' - | 'full_table_copy'; - -export interface DestructiveRisk { - kind: DestructiveRiskKind; - target?: string; - from?: string; - to?: string; - avoidable?: boolean; - guidance?: RiskGuidanceKind[]; -} - -export interface OperationalRisk { - kind: OperationalRiskKind; - target: string; -} - -export interface DdlDiffRisks { - destructiveRisks: DestructiveRisk[]; - operationalRisks: OperationalRisk[]; -} - -export interface DdlDiffArtifacts { - sql: string; - text: string; - json: string; -} - -export type ApplyPlanOperationKind = - | 'emit_schema_statement' - | 'drop_table_cascade' - | 'create_table' - | 'recreate_table' - | 'reapply_statement' - | 'drop_column_effect' - | 'alter_type_effect' - | 'nullability_tighten_effect' - | 'rename_candidate_effect' - | 'semantic_constraint_change_effect' - | 'index_rebuild_effect'; - -export interface ApplyPlanOperation { - kind: ApplyPlanOperationKind; - target?: string; - from?: string; - to?: string; - sql?: string; - statementKind?: 'index' | 'other'; -} - -export interface DdlApplyPlan { - operations: ApplyPlanOperation[]; -} diff --git a/packages/ztd-cli/src/commands/ddlRiskEvaluator.ts b/packages/ztd-cli/src/commands/ddlRiskEvaluator.ts deleted file mode 100644 index 7597307c9..000000000 --- a/packages/ztd-cli/src/commands/ddlRiskEvaluator.ts +++ /dev/null @@ -1,314 +0,0 @@ -import type { - ApplyPlanOperation, - DdlApplyPlan, - DdlDiffRisks, - DdlDiffSummaryEntry, - DestructiveRisk, - OperationalRisk, -} from './ddlDiffContracts'; - -export function analyzeMigrationPlanRisks(plan: DdlApplyPlan, summary: DdlDiffSummaryEntry[] = []): DdlDiffRisks { - const destructiveRisks: DestructiveRisk[] = []; - const operationalRisks: OperationalRisk[] = []; - const summaryByTable = groupSummaryByTable(summary); - const rebuiltTables = new Set( - plan.operations - .filter((operation) => operation.kind === 'recreate_table') - .map((operation) => operation.target) - .filter((target): target is string => Boolean(target)) - ); - - for (const operation of plan.operations) { - switch (operation.kind) { - case 'drop_table_cascade': - if (operation.target) { - destructiveRisks.push(createGuidedRisk('drop_table', operation.target)); - destructiveRisks.push(createGuidedRisk('cascade_drop', operation.target)); - } - break; - case 'drop_column_effect': - if (operation.target) { - destructiveRisks.push(createGuidedRisk('drop_column', operation.target)); - } - break; - case 'alter_type_effect': - if (operation.target) { - destructiveRisks.push(createDestructiveRisk('alter_type', operation.target)); - } - break; - case 'nullability_tighten_effect': - if (operation.target) { - destructiveRisks.push(createDestructiveRisk('nullability_tighten', operation.target)); - } - break; - case 'rename_candidate_effect': - destructiveRisks.push(createDestructiveRisk('rename_candidate', undefined, operation.from, operation.to)); - break; - case 'semantic_constraint_change_effect': - if (operation.target) { - destructiveRisks.push(createDestructiveRisk('semantic_constraint_change', operation.target)); - } - break; - case 'recreate_table': - if (operation.target) { - operationalRisks.push({ kind: 'table_rebuild', target: operation.target }); - operationalRisks.push({ kind: 'full_table_copy', target: operation.target }); - } - break; - case 'index_rebuild_effect': - if (operation.target) { - operationalRisks.push({ kind: 'index_rebuild', target: operation.target }); - } - break; - } - } - - // Preserve summary-aware rename and typed column signals that are not recoverable from plan operations alone. - for (const [tableKey, entries] of summaryByTable.entries()) { - for (const candidate of findRenameCandidates(entries)) { - destructiveRisks.push(createDestructiveRisk('rename_candidate', undefined, candidate.from, candidate.to)); - } - - if (!rebuiltTables.has(tableKey)) { - continue; - } - - for (const entry of entries.filter((item) => item.changeKind === 'alter_type')) { - destructiveRisks.push(createDestructiveRisk('alter_type', `${tableKey}.${String(entry.details.column)}`)); - } - } - - return { - destructiveRisks: dedupeDestructiveRisks(destructiveRisks), - operationalRisks: dedupeOperationalRisks(operationalRisks) - }; -} - -export function analyzeMigrationSqlRisks(sql: string): DdlDiffRisks { - const destructiveRisks: DestructiveRisk[] = []; - const operationalRisks: OperationalRisk[] = []; - const normalized = sql.replace(/\r\n/g, '\n'); - const statements = normalized - .split(/;\s*/) - .map((statement) => statement.trim()) - .filter((statement) => statement.length > 0); - - const droppedTables = new Set(); - const createdTables = new Set(); - const rebuiltTables = new Set(); - const createTablesWithConstraints = new Set(); - const alteredConstraintTables = new Set(); - - for (const statement of statements) { - const dropTableMatch = statement.match(/^drop\s+table\s+(?:if\s+exists\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)(?:\s+cascade)?$/i); - if (dropTableMatch) { - const table = normalizeQualifiedTarget(dropTableMatch[1]); - destructiveRisks.push(createGuidedRisk('drop_table', table)); - if (/\bcascade\b/i.test(statement)) { - destructiveRisks.push(createGuidedRisk('cascade_drop', table)); - } - droppedTables.add(table); - continue; - } - - const createTableMatch = statement.match(/^create\s+table\s+(?:if\s+not\s+exists\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)/i); - if (createTableMatch) { - const table = normalizeQualifiedTarget(createTableMatch[1]); - createdTables.add(table); - - // Rebuilt CREATE TABLE statements can reintroduce or tighten constraints without explicit ALTER CONSTRAINT steps. - if (hasConstraintLikeClause(statement)) { - createTablesWithConstraints.add(table); - } - continue; - } - - const dropColumnMatch = statement.match(/^alter\s+table\s+(?:only\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)\s+drop\s+column\s+(?:if\s+exists\s+)?("?[\w$]+"?)/i); - if (dropColumnMatch) { - destructiveRisks.push(createGuidedRisk('drop_column', `${normalizeQualifiedTarget(dropColumnMatch[1])}.${normalizeIdentifier(dropColumnMatch[2])}`)); - continue; - } - - const alterTypeMatch = statement.match(/^alter\s+table\s+(?:only\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)\s+alter\s+column\s+("?[\w$]+"?)\s+type\b/i); - if (alterTypeMatch) { - destructiveRisks.push(createDestructiveRisk('alter_type', `${normalizeQualifiedTarget(alterTypeMatch[1])}.${normalizeIdentifier(alterTypeMatch[2])}`)); - continue; - } - - const setNotNullMatch = statement.match(/^alter\s+table\s+(?:only\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)\s+alter\s+column\s+("?[\w$]+"?)\s+set\s+not\s+null\b/i); - if (setNotNullMatch) { - destructiveRisks.push(createDestructiveRisk('nullability_tighten', `${normalizeQualifiedTarget(setNotNullMatch[1])}.${normalizeIdentifier(setNotNullMatch[2])}`)); - continue; - } - - const addConstraintMatch = statement.match(/^alter\s+table\s+(?:only\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)\s+add\s+constraint\s+("?[\w$]+"?)/i); - if (addConstraintMatch) { - const table = normalizeQualifiedTarget(addConstraintMatch[1]); - alteredConstraintTables.add(table); - destructiveRisks.push(createDestructiveRisk('semantic_constraint_change', table)); - continue; - } - - const dropConstraintMatch = statement.match(/^alter\s+table\s+(?:only\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)\s+drop\s+constraint\s+(?:if\s+exists\s+)?("?[\w$]+"?)/i); - if (dropConstraintMatch) { - const table = normalizeQualifiedTarget(dropConstraintMatch[1]); - alteredConstraintTables.add(table); - destructiveRisks.push(createDestructiveRisk('semantic_constraint_change', table)); - continue; - } - - const createIndexMatch = statement.match(/^create\s+(?:unique\s+)?index\s+(?:if\s+not\s+exists\s+)?("?[\w$]+"?).*?\bon\s+((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)/i); - if (createIndexMatch) { - const indexName = normalizeIdentifier(createIndexMatch[1]); - const tableTarget = normalizeQualifiedTarget(createIndexMatch[2]); - if (droppedTables.has(tableTarget)) { - operationalRisks.push({ kind: 'index_rebuild', target: indexName }); - } - } - } - - for (const table of droppedTables) { - if (createdTables.has(table)) { - rebuiltTables.add(table); - } - } - - for (const table of rebuiltTables) { - operationalRisks.push({ kind: 'table_rebuild', target: table }); - operationalRisks.push({ kind: 'full_table_copy', target: table }); - - if (createTablesWithConstraints.has(table) || alteredConstraintTables.has(table)) { - destructiveRisks.push(createDestructiveRisk('semantic_constraint_change', table)); - } - } - - return { - destructiveRisks: dedupeDestructiveRisks(destructiveRisks), - operationalRisks: dedupeOperationalRisks(operationalRisks) - }; -} - -function createGuidedRisk(kind: 'drop_table' | 'drop_column' | 'cascade_drop', target: string): DestructiveRisk { - return { - kind, - target, - avoidable: true, - guidance: ['review_if_required', 'avoid_if_possible', 'cli_option_not_exposed'] - }; -} - -function createDestructiveRisk( - kind: Exclude, - target?: string, - from?: string, - to?: string -): DestructiveRisk { - return { - kind, - target, - from, - to, - guidance: ['review_if_required'] - }; -} - -function dedupeDestructiveRisks(risks: DestructiveRisk[]): DestructiveRisk[] { - const seen = new Map(); - for (const risk of risks) { - const key = JSON.stringify({ - kind: risk.kind, - target: risk.target ?? '', - from: risk.from ?? '', - to: risk.to ?? '' - }); - if (!seen.has(key)) { - seen.set(key, risk); - } - } - - return [...seen.values()].sort((left, right) => { - const leftKey = `${left.kind}:${left.target ?? left.from ?? ''}:${left.to ?? ''}`; - const rightKey = `${right.kind}:${right.target ?? right.from ?? ''}:${right.to ?? ''}`; - return leftKey.localeCompare(rightKey); - }); -} - -function dedupeOperationalRisks(risks: OperationalRisk[]): OperationalRisk[] { - const seen = new Map(); - for (const risk of risks) { - const key = `${risk.kind}:${risk.target}`; - if (!seen.has(key)) { - seen.set(key, risk); - } - } - - return [...seen.values()].sort((left, right) => { - const leftKey = `${left.kind}:${left.target}`; - const rightKey = `${right.kind}:${right.target}`; - return leftKey.localeCompare(rightKey); - }); -} - -function groupSummaryByTable(summary: DdlDiffSummaryEntry[]): Map { - const grouped = new Map(); - for (const entry of summary) { - const key = `${entry.schema}.${entry.table}`; - const bucket = grouped.get(key) ?? []; - bucket.push(entry); - grouped.set(key, bucket); - } - return grouped; -} - -function findRenameCandidates(entries: DdlDiffSummaryEntry[]): Array<{ from: string; to: string }> { - const addedColumns = entries.filter((entry) => entry.changeKind === 'add_column'); - const droppedColumns = entries.filter((entry) => entry.changeKind === 'drop_column'); - const candidates: Array<{ from: string; to: string }> = []; - - for (const dropped of droppedColumns) { - const matched = addedColumns.find((entry) => normalizeSql(String(entry.details.type)) === normalizeSql(String(dropped.details.type))); - if (!matched) { - continue; - } - - const tableKey = `${dropped.schema}.${dropped.table}`; - candidates.push({ - from: `${tableKey}.${String(dropped.details.column)}`, - to: `${tableKey}.${String(matched.details.column)}` - }); - } - - return candidates; -} - -function normalizeQualifiedTarget(value: string): string { - const cleaned = value.trim(); - const segments = cleaned.split('.'); - if (segments.length === 1) { - return `public.${normalizeIdentifier(segments[0])}`; - } - - return `${normalizeIdentifier(segments[0])}.${normalizeIdentifier(segments[1])}`; -} - -function normalizeIdentifier(value: string): string { - return value.replace(/^"/, '').replace(/"$/, ''); -} - -function normalizeSql(value: string): string { - return value.trim().replace(/\s+/g, ' ').toLowerCase(); -} - -function hasConstraintLikeClause(statement: string): boolean { - const bodyStart = statement.indexOf('('); - const bodyEnd = statement.lastIndexOf(')'); - if (bodyStart === -1 || bodyEnd <= bodyStart) { - return false; - } - - const body = statement.slice(bodyStart + 1, bodyEnd); - return /\bconstraint\b|\bprimary\s+key\b|\bforeign\s+key\b|\breferences\b|\bcheck\b|\bunique\b/i.test(body); -} - -// Re-exporting the shape keeps future SQL re-evaluation entrypoints on the same contract. -export type { DdlDiffRisks, DdlApplyPlan, DdlDiffSummaryEntry } from './ddlDiffContracts'; diff --git a/packages/ztd-cli/src/commands/describe.ts b/packages/ztd-cli/src/commands/describe.ts deleted file mode 100644 index be7f67b72..000000000 --- a/packages/ztd-cli/src/commands/describe.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { Command } from 'commander'; -import { isJsonOutput, writeCommandEnvelope } from '../utils/agentCli'; - -interface CommandFlagDescriptor { - name: string; - description: string; - defaultValue?: string | boolean; -} - -interface CommandDescriptor { - name: string; - summary: string; - writesFiles: boolean; - supportsDryRun: boolean; - supportsJsonPayload: boolean; - supportsDescribeOutput?: boolean; - output?: { - stdout?: string; - files?: string[]; - }; - exitCodes: Record; - flags: CommandFlagDescriptor[]; -} - -const COMMANDS: CommandDescriptor[] = [ - { - name: 'init', - summary: 'Scaffold a ZTD project with templates, config, and optional demo assets.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Human summary in text mode, JSON envelope in global json mode.', - files: ['ztd.config.json', 'db/ddl/*.sql', '.ztd/generated/*'] - }, - exitCodes: { - '0': 'Scaffold completed or dry-run plan emitted.', - '1': 'Validation or filesystem error.' - }, - flags: [ - { name: '--dry-run', description: 'Validate inputs and emit the planned scaffold without writing files.' }, - { name: '--json', description: 'Pass init options as a JSON object.' }, - { name: '--yes', description: 'Accept defaults and overwrite existing files.' } - ] - }, - { - name: 'feature scaffold', - summary: 'Scaffold a feature-local CRUD boundary skeleton from schema metadata.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: false, - output: { - stdout: 'Human scaffold summary in text mode, JSON envelope in global json mode.', - files: [ - 'src/features//', - 'src/features//boundary.ts', - 'src/features//queries//', - 'src/features//queries//boundary.ts', - 'src/features//queries//.sql', - 'src/features//queries//generated/row-mapper.ts', - 'src/features//tests/', - 'src/features//tests/.boundary.test.ts', - 'src/features//README.md', - 'src/features/_shared/featureQueryExecutor.ts on first scaffold run', - 'src/features/_shared/loadSqlResource.ts on first scaffold run' - ] - }, - exitCodes: { - '0': 'Scaffold completed or dry-run plan emitted.', - '1': 'Validation, metadata resolution, or filesystem error.' - }, - flags: [ - { name: '--table
', description: 'Target table name for the scaffold.' }, - { name: '--action ', description: 'Action template to scaffold. v1 supports insert, update, delete, get-by-id, and list.' }, - { name: '--feature-name ', description: 'Override the derived resource-action feature name.' }, - { name: '--insert-default-policy ', description: 'INSERT default-column policy. Use explicit-defaults to copy DDL defaults into SQL, or omit-db-defaults to let the database assign DB-default columns.' }, - { name: '--dry-run', description: 'Validate inputs and emit the planned scaffold without writing files.' }, - { name: '--force', description: 'Overwrite scaffold-owned feature files when they already exist.' } - ] - }, - { - name: 'feature generated-mapper generate', - summary: 'Regenerate machine-owned RFBA query row mappers from query boundary contracts.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: false, - output: { - stdout: 'Human sync summary in text mode, JSON envelope in global json mode.', - files: ['src/features//queries//generated/row-mapper.ts'] - }, - exitCodes: { - '0': 'Generated mapper files were synchronized or dry-run plan emitted.', - '1': 'Feature/query contract resolution or filesystem error.' - }, - flags: [ - { name: '--feature ', description: 'Feature name under src/features/.' }, - { name: '--query ', description: 'Limit regeneration to one query under queries/.' }, - { name: '--dry-run', description: 'Check planned generated mapper updates without writing files.' } - ] - }, - { - name: 'feature generated-mapper check', - summary: 'Fail when machine-owned RFBA row mappers drift from query boundary contracts.', - writesFiles: false, - supportsDryRun: false, - supportsJsonPayload: false, - output: { - stdout: 'Human success summary in text mode, JSON envelope in global json mode.', - files: [] - }, - exitCodes: { - '0': 'Generated mapper files match their query boundary contracts.', - '1': 'Generated mapper drift or feature/query contract resolution error.' - }, - flags: [ - { name: '--feature ', description: 'Feature name under src/features/.' }, - { name: '--query ', description: 'Limit drift detection to one query under queries/.' } - ] - }, - { - name: 'feature query scaffold', - summary: 'Add one additive child query boundary under an existing boundary without rewriting the parent boundary.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: false, - output: { - stdout: 'Human additive scaffold summary in text mode, JSON envelope in global json mode.', - files: [ - '/queries//', - '/queries//boundary.ts', - '/queries//.sql', - '/queries//generated/row-mapper.ts', - 'src/features/_shared/featureQueryExecutor.ts on first scaffold run', - 'src/features/_shared/loadSqlResource.ts on first scaffold run' - ] - }, - exitCodes: { - '0': 'Scaffold completed or dry-run plan emitted.', - '1': 'Validation, metadata resolution, or filesystem error.' - }, - flags: [ - { name: '--table
', description: 'Target table name for the new query boundary.' }, - { name: '--action ', description: 'Query action template to scaffold. v1 supports insert, update, delete, get-by-id, and list.' }, - { name: '--query-name ', description: 'Name of the child query boundary to create under queries/.' }, - { name: '--feature ', description: 'Resolve the target boundary as src/features/.' }, - { name: '--boundary-dir ', description: 'Resolve the target boundary from an explicit existing boundary folder. Use either --feature or --boundary-dir, or omit both when the current working directory is already the target boundary.' }, - { name: '--insert-default-policy ', description: 'INSERT default-column policy. Use explicit-defaults to copy DDL defaults into SQL, or omit-db-defaults to let the database assign DB-default columns.' }, - { name: '--dry-run', description: 'Validate inputs and emit the planned additive scaffold without writing files.' } - ] - }, - { - name: 'ztd-config', - summary: 'Generate TestRowMap, runtime fixture metadata, and layout metadata from local DDL.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Status or JSON envelope.', - files: [ - '.ztd/generated/ztd-row-map.generated.ts', - '.ztd/generated/ztd-fixture-manifest.generated.ts', - '.ztd/generated/ztd-layout.generated.ts' - ] - }, - exitCodes: { - '0': 'Generation completed or dry-run plan emitted.', - '1': 'Generation failed.' - }, - flags: [ - { name: '--dry-run', description: 'Render and validate generation without writing files.' }, - { name: '--json', description: 'Pass ztd-config options as a JSON object.' }, - { name: '--watch', description: 'Watch DDL files and regenerate on change.', defaultValue: false } - ] - }, - { - name: 'model-gen', - summary: 'Probe SQL metadata and generate QuerySpec scaffolding from feature-local or shared SQL assets.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: true, - supportsDescribeOutput: true, - output: { - stdout: 'Generated TypeScript when --out is omitted; JSON envelope in global json mode.', - files: ['Specified --out file when present'] - }, - exitCodes: { - '0': 'Generation completed, dry-run plan emitted, or output contract described.', - '1': 'Validation or probing failed.' - }, - flags: [ - { name: '--dry-run', description: 'Validate probing and show the planned output file without writing it.' }, - { name: '--json', description: 'Pass model-gen options as a JSON object.' }, - { name: '--describe-output', description: 'Print the generated artifact contract instead of probing.' }, - { name: '--sql-root', description: 'Compatibility helper for shared SQL roots; feature-local SQL resolves naturally without it.' } - ] - }, - { - name: 'ddl pull', - summary: 'Pull PostgreSQL schema DDL via pg_dump and normalize it into per-schema files.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Status or JSON envelope.', - files: ['/.sql'] - }, - exitCodes: { - '0': 'Pull completed or dry-run plan emitted.', - '1': 'pg_dump or normalization failed.' - }, - flags: [ - { name: '--dry-run', description: 'Run pg_dump and normalization without writing schema files.' }, - { name: '--json', description: 'Pass pull options as a JSON object.' } - ] - }, - { - name: 'ddl diff', - summary: 'Compare local DDL with a live database and emit logical summary, structured risks, and a pure SQL artifact.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Human logical summary plus structured risks in text mode, JSON envelope in global json mode.', - files: ['Specified --out SQL file plus companion .txt and .json review artifacts with summary/risks'] - }, - exitCodes: { - '0': 'Diff completed or dry-run review emitted.', - '1': 'Local discovery or pg_dump failed.' - }, - flags: [ - { name: '--dry-run', description: 'Compute the logical summary and structured risks without writing the SQL or review artifacts.' }, - { name: '--json', description: 'Pass diff options as a JSON object.' } - ] - }, - { - name: 'ddl risk', - summary: 'Analyze a generated or hand-edited migration SQL file and emit the same structured risk contract used by ddl diff.', - writesFiles: false, - supportsDryRun: false, - supportsJsonPayload: true, - output: { - stdout: 'Human structured risk report in text mode, JSON envelope in global json mode.' - }, - exitCodes: { - '0': 'Risk report emitted.', - '1': 'Validation or file loading failed.' - }, - flags: [ - { name: '--file', description: 'Migration SQL file to analyze.' }, - { name: '--json', description: 'Pass risk options as a JSON object.' } - ] - }, - { - name: 'ddl gen-entities', - summary: 'Generate helper interfaces from DDL metadata.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Status or JSON envelope.', - files: ['Specified --out entities file'] - }, - exitCodes: { - '0': 'Generation completed or dry-run plan emitted.', - '1': 'DDL parsing failed.' - }, - flags: [ - { name: '--dry-run', description: 'Render entities without writing the output file.' }, - { name: '--json', description: 'Pass generation options as a JSON object.' } - ] - }, - { - name: 'check contract', - summary: 'Validate project QuerySpec-backed SQL contracts and emit deterministic findings.', - writesFiles: true, - supportsDryRun: false, - supportsJsonPayload: true, - output: { - stdout: 'Human report or deterministic JSON report.' - }, - exitCodes: { - '0': 'No violations.', - '1': 'Violations detected.', - '2': 'Runtime or config error.' - }, - flags: [ - { name: '--format json', description: 'Emit the report as deterministic JSON.' }, - { name: '--scope-dir', description: 'Limit QuerySpec discovery to one feature, boundary, or subtree.' }, - { name: '--specs-dir', description: 'Legacy fixed catalog specs directory override.' }, - { name: '--json', description: 'Pass check options as a JSON object.' } - ] - }, - { - name: 'perf init', - summary: 'Scaffold the opt-in perf sandbox configuration and Docker assets.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Human scaffold summary or JSON envelope.', - files: ['perf/sandbox.json', 'perf/seed.yml', 'perf/params.yml', 'perf/docker-compose.yml', 'perf/README.md', 'perf/.gitignore'] - }, - exitCodes: { - '0': 'Scaffold completed or dry-run plan emitted.', - '1': 'Validation or filesystem error.' - }, - flags: [ - { name: '--dry-run', description: 'Emit the planned perf sandbox scaffold without writing files.' }, - { name: '--json', description: 'Pass perf init options as a JSON object.' } - ] - }, - { - name: 'perf db reset', - summary: 'Recreate the perf sandbox schema from local DDL.', - writesFiles: false, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Human reset summary or JSON envelope.' - }, - exitCodes: { - '0': 'Reset completed or dry-run plan emitted.', - '1': 'Docker, connection, or DDL replay failed.' - }, - flags: [ - { name: '--dry-run', description: 'Emit the DDL replay plan without touching Docker or PostgreSQL.' }, - { name: '--json', description: 'Pass perf db reset options as a JSON object.' } - ] - }, - { - name: 'perf seed', - summary: 'Generate deterministic synthetic data from perf/seed.yml.', - writesFiles: false, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Human seed summary or JSON envelope.' - }, - exitCodes: { - '0': 'Seed completed or dry-run plan emitted.', - '1': 'Connection, DDL parsing, or insert generation failed.' - }, - flags: [ - { name: '--dry-run', description: 'Emit the seed plan without touching PostgreSQL.' }, - { name: '--json', description: 'Pass perf seed options as a JSON object.' } - ] - }, - { - name: 'perf run', - summary: 'Benchmark a SQL query and emit evidence for AI-driven tuning loops.', - writesFiles: true, - supportsDryRun: true, - supportsJsonPayload: true, - output: { - stdout: 'Human benchmark summary or JSON envelope.', - files: ['perf/evidence/run_xxx/* when --save is enabled'] - }, - exitCodes: { - '0': 'Benchmark completed or dry-run plan emitted.', - '1': 'Validation, connection, or execution failed.' - }, - flags: [ - { name: '--query', description: 'SQL file to benchmark inside the perf sandbox.' }, - { name: '--params', description: 'JSON or YAML file with named or positional parameters.' }, - { name: '--strategy', description: 'Execution strategy (direct|decomposed).', defaultValue: 'direct' }, - { name: '--material', description: 'Comma-separated CTEs to materialize when using decomposed execution.' }, - { name: '--mode', description: 'Benchmark mode (auto|latency|completion).', defaultValue: 'auto' }, - { name: '--repeat', description: 'Measured repetitions for latency mode.', defaultValue: '10' }, - { name: '--warmup', description: 'Warmup repetitions for latency mode.', defaultValue: '3' }, - { name: '--classify-threshold-seconds', description: 'Threshold for auto mode classification.', defaultValue: '60' }, - { name: '--timeout-minutes', description: 'Timeout for measured runs.', defaultValue: '5' }, - { name: '--save', description: 'Persist benchmark evidence under perf/evidence/run_xxx.' }, - { name: '--dry-run', description: 'Resolve benchmark mode and evidence shape without touching PostgreSQL.' }, - { name: '--label', description: 'Attach a short label to the saved run directory.' }, - { name: '--json', description: 'Pass perf run options as a JSON object.' } - ] - }, - { - name: 'perf report diff', - summary: 'Compare two saved perf benchmark runs and report the primary delta.', - writesFiles: false, - supportsDryRun: false, - supportsJsonPayload: true, - output: { - stdout: 'Human diff summary or JSON envelope.' - }, - exitCodes: { - '0': 'Diff report emitted.', - '1': 'Evidence loading or validation failed.' - }, - flags: [ - { name: '--format', description: 'Output format (text|json).', defaultValue: 'text' }, - { name: '--json', description: 'Pass perf report diff options as a JSON object.' } - ] - }, { - name: 'query uses', - summary: 'Inspect catalog SQL usage of tables or columns.', - writesFiles: true, - supportsDryRun: false, - supportsJsonPayload: true, - output: { - stdout: 'Text report or versioned JSON report with optional display metadata.' - }, - exitCodes: { - '0': 'Report emitted.', - '1': 'Validation failed.' - }, - flags: [ - { name: '--format json', description: 'Emit a versioned JSON usage report.' }, - { name: '--json', description: 'Pass target and options as a JSON object.' }, - { name: '--summary-only', description: 'Emit only summary counts with display metadata.' }, - { name: '--limit', description: 'Truncate matches and warnings while preserving summary totals.' } - ] - }, - { - name: 'query match-observed', - summary: 'Rank likely source SQL assets for an observed SELECT statement.', - writesFiles: true, - supportsDryRun: false, - supportsJsonPayload: false, - output: { - stdout: 'Text ranking report or JSON report for observed SQL candidates.', - files: ['Specified --out file when present'] - }, - exitCodes: { - '0': 'Report emitted.', - '1': 'Validation failed or no candidate SELECT assets were found.' - }, - flags: [ - { name: '--sql', description: 'Observed SQL text to rank.' }, - { name: '--sql-file', description: 'Read the observed SQL text from a file.' }, - { name: '--format json', description: 'Emit a machine-readable JSON ranking report.' }, - { name: '--out', description: 'Write output to a file.' } - ] - }, - { - name: 'lint', - summary: 'Lint SQL files with fixture-backed validation.', - writesFiles: false, - supportsDryRun: false, - supportsJsonPayload: true, - output: { - stdout: 'Silent on success in text mode, JSON envelope in global json mode.' - }, - exitCodes: { - '0': 'Lint completed without failures.', - '1': 'Lint failures or runtime error.' - }, - flags: [ - { name: '--json', description: 'Pass lint options as a JSON object.' } - ] - }, - { - name: 'rfba inspect', - summary: 'Inspect RFBA root, feature, and query sub-boundaries without writing files.', - writesFiles: false, - supportsDryRun: false, - supportsJsonPayload: true, - output: { - stdout: 'Text boundary map or deterministic JSON report.' - }, - exitCodes: { - '0': 'Boundary report emitted.', - '1': 'Validation or filesystem error.' - }, - flags: [ - { name: '--format ', description: 'Output format (text|json).' }, - { name: '--root ', description: 'Project root to inspect.' }, - { name: '--json', description: 'Pass inspect options as a JSON object.' } - ] - }, - { - name: 'evidence', - summary: 'Generate deterministic specification evidence artifacts from project QuerySpec and test assets.', - writesFiles: true, - supportsDryRun: false, - supportsJsonPayload: true, - output: { - files: ['/test-specification.json', '/test-specification.md'] - }, - exitCodes: { - '0': 'Evidence generated.', - '1': 'Generation failed.', - '2': 'Runtime or config error.' - }, - flags: [ - { name: '--scope-dir', description: 'Limit QuerySpec discovery to one feature, boundary, or subtree.' }, - { name: '--specs-dir', description: 'Legacy fixed catalog specs directory override.' }, - { name: '--json', description: 'Pass evidence options as a JSON object.' } - ] - } -]; - -export function getDescribeCommandDescriptors(): readonly CommandDescriptor[] { - return COMMANDS; -} - -export function registerDescribeCommand(program: Command): void { - const describe = program.command('describe').description('Describe ztd-cli commands and output contracts'); - - describe.action(() => { - const payload = { - schemaVersion: 1, - commands: COMMANDS.map((command) => ({ - name: command.name, - summary: command.summary, - writesFiles: command.writesFiles, - supportsDryRun: command.supportsDryRun, - supportsJsonPayload: command.supportsJsonPayload, - supportsDescribeOutput: command.supportsDescribeOutput ?? false - })) - }; - - if (isJsonOutput()) { - writeCommandEnvelope('describe', payload); - return; - } - - const lines = ['Available command descriptions:']; - for (const command of COMMANDS) { - lines.push(`- ${command.name}: ${command.summary}`); - } - process.stdout.write(`${lines.join('\n')}\n`); - }); - - describe - .command('command ') - .description('Describe one command in detail') - .action((name: string) => { - const descriptor = COMMANDS.find((command) => command.name === name.trim()); - if (!descriptor) { - throw new Error(`Unknown command description: ${name}`); - } - - if (isJsonOutput()) { - writeCommandEnvelope('describe command', { - schemaVersion: 1, - command: descriptor - }); - return; - } - - const lines = [ - `${descriptor.name}`, - descriptor.summary, - `writesFiles: ${descriptor.writesFiles}`, - `supportsDryRun: ${descriptor.supportsDryRun}`, - `supportsJsonPayload: ${descriptor.supportsJsonPayload}` - ]; - if (descriptor.supportsDescribeOutput) { - lines.push('supportsDescribeOutput: true'); - } - lines.push('flags:'); - for (const flag of descriptor.flags) { - lines.push(`- ${flag.name}: ${flag.description}`); - } - lines.push('exitCodes:'); - for (const [code, message] of Object.entries(descriptor.exitCodes)) { - lines.push(`- ${code}: ${message}`); - } - process.stdout.write(`${lines.join('\n')}\n`); - }); -} diff --git a/packages/ztd-cli/src/commands/diff.ts b/packages/ztd-cli/src/commands/diff.ts deleted file mode 100644 index af70b4d9f..000000000 --- a/packages/ztd-cli/src/commands/diff.ts +++ /dev/null @@ -1,858 +0,0 @@ -import { writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { collectSqlFiles } from '../utils/collectSqlFiles'; -import { formatConnectionTarget } from '../utils/connectionSummary'; -import type { DbConnectionContext } from '../utils/dbConnection'; -import { ensureDirectory } from '../utils/fs'; -import { runPgDump } from '../utils/pgDump'; -import { withSpanSync } from '../utils/telemetry'; -import { analyzeMigrationPlanRisks } from './ddlRiskEvaluator'; -import type { - ApplyPlanOperation, - DdlApplyPlan, - DdlDiffArtifacts, - DdlDiffRisks, - DdlDiffSummaryEntry, - DdlDiffChangeKind, - DestructiveRisk, - OperationalRisk, -} from './ddlDiffContracts'; - -export interface DiffSchemaOptions { - directories: string[]; - extensions: string[]; - url: string; - out: string; - pgDumpPath?: string; - pgDumpShell?: boolean; - connectionContext?: DbConnectionContext; - dryRun?: boolean; -} - -export interface DiffSchemaResult { - outFile: string; - sql: string; - text: string; - json: string; - dryRun: boolean; - hasChanges: boolean; - summary: DdlDiffSummaryEntry[]; - applyPlan: DdlApplyPlan; - risks: DdlDiffRisks; - artifacts: DdlDiffArtifacts; -} - -interface TableDefinition { - key: string; - schema: string; - table: string; - statement: string; - normalizedStatement: string; - columns: Map; -} - -interface ParsedColumn { - name: string; - type: string; - nullable: boolean; -} - -interface SupplementalStatement { - sql: string; - kind: 'index' | 'other'; - name?: string; -} - -interface ParsedSchemaModel { - tableDefinitions: Map; - createSchemaStatements: string[]; - supplementalStatementsByTable: Map; -} - -export const DDL_DIFF_SPAN_NAMES = { - collectLocalDdl: 'collect-local-ddl', - pullRemoteDdl: 'pull-remote-ddl', - computeDiffPlan: 'compute-diff-plan', - emitDiffPlan: 'emit-diff-plan', -} as const; - -export function runDiffSchema(options: DiffSchemaOptions): DiffSchemaResult { - const localSources = withSpanSync(DDL_DIFF_SPAN_NAMES.collectLocalDdl, () => { - const discovered = collectSqlFiles(options.directories, options.extensions); - if (discovered.length === 0) { - throw new Error(`No SQL files were discovered under ${options.directories.join(', ')}`); - } - return discovered; - }, { - directoryCount: options.directories.length, - extensionCount: options.extensions.length, - }); - - // Concatenate the local DDL files in a stable order for deterministic outputs. - const localSql = localSources.map((source) => source.sql).join('\n\n'); - const remoteSql = withSpanSync(DDL_DIFF_SPAN_NAMES.pullRemoteDdl, () => { - return runPgDump({ - url: options.url, - pgDumpPath: options.pgDumpPath, - pgDumpShell: options.pgDumpShell, - connectionContext: options.connectionContext - }); - }); - - const plan = withSpanSync(DDL_DIFF_SPAN_NAMES.computeDiffPlan, () => { - const databaseTarget = formatConnectionTarget(options.connectionContext) || 'target: unknown'; - const localModel = parseSchemaModel(localSql); - const remoteModel = parseSchemaModel(remoteSql); - const summary = buildSummary(localModel, remoteModel); - const applyPlan = buildApplyPlan(localSql, localModel, remoteModel, summary); - const risks = analyzeMigrationPlanRisks(applyPlan, summary); - const hasChanges = summary.length > 0; - const artifacts = deriveArtifactPaths(options.out); - const sql = hasChanges ? renderApplySql(localSql, applyPlan) : '-- No schema differences detected.\n'; - const text = buildTextSummary({ - summary, - risks, - sqlArtifactPath: artifacts.sql, - databaseTarget, - hasChanges - }); - const json = JSON.stringify({ - kind: 'ddl-diff', - generatedAt: new Date().toISOString(), - target: { - connection: databaseTarget - }, - summary, - applyPlan, - risks, - hasChanges, - artifacts - }, null, 2); - - return { - hasChanges, - artifacts, - sql, - text, - json, - summary, - applyPlan, - risks - }; - }, { - localFileCount: localSources.length, - }); - - if (!options.dryRun) { - withSpanSync(DDL_DIFF_SPAN_NAMES.emitDiffPlan, () => { - ensureDirectory(path.dirname(plan.artifacts.sql)); - writeFileSync(plan.artifacts.sql, plan.sql, 'utf8'); - writeFileSync(plan.artifacts.text, plan.text, 'utf8'); - writeFileSync(plan.artifacts.json, `${plan.json}\n`, 'utf8'); - console.error(`DDL diff SQL written to ${plan.artifacts.sql}`); - console.error(`DDL diff review text written to ${plan.artifacts.text}`); - console.error(`DDL diff review JSON written to ${plan.artifacts.json}`); - }, { - outFile: plan.artifacts.sql, - }); - } - - return { - outFile: plan.artifacts.sql, - sql: plan.sql, - text: plan.text, - json: plan.json, - dryRun: Boolean(options.dryRun), - hasChanges: plan.hasChanges, - summary: plan.summary, - applyPlan: plan.applyPlan, - risks: plan.risks, - artifacts: plan.artifacts - }; -} - -function deriveArtifactPaths(outFile: string): DdlDiffArtifacts { - if (outFile.endsWith('.sql')) { - return { - sql: outFile, - text: outFile.slice(0, -4) + '.txt', - json: outFile.slice(0, -4) + '.json' - }; - } - - return { - sql: outFile, - text: `${outFile}.txt`, - json: `${outFile}.json` - }; -} - -function buildTextSummary(options: { - summary: DdlDiffSummaryEntry[]; - risks: DdlDiffRisks; - sqlArtifactPath: string; - databaseTarget: string; - hasChanges: boolean; -}): string { - const lines = ['Migration summary', `- target: ${options.databaseTarget}`]; - - if (!options.hasChanges) { - lines.push('- no schema differences detected'); - } else { - for (const entry of options.summary) { - lines.push(`- ${entry.schema}.${entry.table}: ${formatSummaryEntry(entry)}`); - } - } - - lines.push('', 'Destructive risks'); - lines.push(...formatRiskLines(options.risks.destructiveRisks)); - - lines.push('', 'Operational risks'); - lines.push(...formatRiskLines(options.risks.operationalRisks)); - - lines.push('', 'Generated SQL', `- ${options.sqlArtifactPath}`); - return `${lines.join('\n')}\n`; -} - -function formatRiskLines(risks: Array): string[] { - if (risks.length === 0) { - return ['- none']; - } - - const lines: string[] = []; - for (const risk of risks) { - if ('from' in risk && risk.from && risk.to) { - lines.push(`- ${risk.kind}: ${risk.from} -> ${risk.to}`); - } else { - lines.push(`- ${risk.kind}: ${String(risk.target ?? 'unknown')}`); - } - - if ('guidance' in risk && risk.guidance && risk.guidance.length > 0) { - lines.push(` guidance: ${risk.guidance.join(', ')}`); - } - } - return lines; -} - -function groupSummaryByTable(summary: DdlDiffSummaryEntry[]): Map { - const grouped = new Map(); - for (const entry of summary) { - const key = `${entry.schema}.${entry.table}`; - const bucket = grouped.get(key) ?? []; - bucket.push(entry); - grouped.set(key, bucket); - } - return grouped; -} - -function formatSummaryEntry(entry: DdlDiffSummaryEntry): string { - switch (entry.changeKind) { - case 'create_table': - return 'create table'; - case 'drop_table': - return 'drop table'; - case 'add_column': - return `add column ${String(entry.details.column)} ${String(entry.details.type)}${entry.details.nullable ? ' null' : ' not null'}`; - case 'drop_column': - return `drop column ${String(entry.details.column)}`; - case 'alter_type': - return `alter column ${String(entry.details.column)} type ${String(entry.details.from)} -> ${String(entry.details.to)}`; - case 'alter_nullability': - return `alter column ${String(entry.details.column)} nullability ${String(entry.details.from)} -> ${String(entry.details.to)}`; - case 'table_rebuild': - return 'table definition changed'; - case 'schema_change': - return String(entry.details.message ?? 'schema-level change'); - } -} - -function buildApplyPlan( - localSql: string, - localModel: ParsedSchemaModel, - remoteModel: ParsedSchemaModel, - summary: DdlDiffSummaryEntry[] -): DdlApplyPlan { - const operations: ApplyPlanOperation[] = []; - const summaryByTable = groupSummaryByTable(summary); - const remoteSchemas = collectKnownSchemas(remoteModel); - - // Create schemas first, but skip schemas the remote snapshot already knows about. - for (const statement of localModel.createSchemaStatements) { - const schemaName = extractCreatedSchemaName(statement); - if (schemaName && remoteSchemas.has(schemaName)) { - continue; - } - operations.push({ - kind: 'emit_schema_statement', - sql: statement.trim().replace(/;?$/, ';') - }); - } - - // Decide which remote tables must be removed or rebuilt before replaying local DDL. - for (const [key, remoteTable] of remoteModel.tableDefinitions.entries()) { - const localTable = localModel.tableDefinitions.get(key); - if (!localTable) { - operations.push({ - kind: 'drop_table_cascade', - target: key, - sql: `DROP TABLE IF EXISTS ${quoteQualifiedName(remoteTable.schema, remoteTable.table)} CASCADE;` - }); - continue; - } - - if (localTable.normalizedStatement !== remoteTable.normalizedStatement) { - operations.push({ - kind: 'drop_table_cascade', - target: key, - sql: `DROP TABLE IF EXISTS ${quoteQualifiedName(localTable.schema, localTable.table)} CASCADE;` - }); - operations.push({ - kind: 'recreate_table', - target: key - }); - - const tableEntries = summaryByTable.get(key) ?? []; - for (const entry of tableEntries) { - const columnTarget = entry.details.column ? `${key}.${String(entry.details.column)}` : key; - if (entry.changeKind === 'drop_column') { - operations.push({ kind: 'drop_column_effect', target: columnTarget }); - } else if (entry.changeKind === 'alter_type') { - operations.push({ kind: 'alter_type_effect', target: columnTarget }); - } else if (entry.changeKind === 'alter_nullability' && entry.details.from === 'nullable' && entry.details.to === 'not-null') { - operations.push({ kind: 'nullability_tighten_effect', target: columnTarget }); - } - } - - for (const candidate of findRenameCandidates(tableEntries)) { - operations.push({ - kind: 'rename_candidate_effect', - from: candidate.from, - to: candidate.to - }); - } - - if (hasConstraintChange(localTable.statement, remoteTable.statement)) { - operations.push({ - kind: 'semantic_constraint_change_effect', - target: key - }); - } - } - } - - // Replay local table definitions and supplemental statements after destructive operations. - for (const [key, localTable] of localModel.tableDefinitions.entries()) { - const remoteTable = remoteModel.tableDefinitions.get(key); - const recreated = operations.some((operation) => operation.kind === 'recreate_table' && operation.target === key); - if (!remoteTable || recreated) { - operations.push({ - kind: 'create_table', - target: key, - sql: localTable.statement.trim().replace(/;?$/, ';') - }); - - const supplemental = localModel.supplementalStatementsByTable.get(key) ?? []; - for (const statement of supplemental) { - operations.push({ - kind: 'reapply_statement', - target: statement.name ?? key, - sql: statement.sql.trim().replace(/;?$/, ';'), - statementKind: statement.kind - }); - if (statement.kind === 'index') { - operations.push({ - kind: 'index_rebuild_effect', - target: statement.name ?? key - }); - } - } - continue; - } - - // Emit supplemental statements that are missing from the remote snapshot without forcing a table rebuild. - const supplementalToApply = diffSupplementalStatements( - localModel.supplementalStatementsByTable.get(key) ?? [], - remoteModel.supplementalStatementsByTable.get(key) ?? [] - ); - for (const statement of supplementalToApply) { - operations.push({ - kind: 'reapply_statement', - target: statement.name ?? key, - sql: statement.sql.trim().replace(/;?$/, ';'), - statementKind: statement.kind - }); - if (statement.kind === 'index') { - operations.push({ - kind: 'index_rebuild_effect', - target: statement.name ?? key - }); - } - } - } - - // Fall back to the local snapshot when no table-level parsing succeeded so SQL output stays valid. - if (operations.length === 0 && localSql.trim().length > 0) { - return { - operations: [ - { - kind: 'create_table', - target: 'local_snapshot', - sql: `${localSql.trim()}\n` - } - ] - }; - } - - return { operations }; -} - -function renderApplySql(localSql: string, applyPlan: DdlApplyPlan): string { - const sqlStatements = applyPlan.operations - .map((operation) => operation.sql?.trim()) - .filter((statement): statement is string => Boolean(statement && statement.length > 0)); - - const rendered = sqlStatements.join('\n\n'); - if (rendered.length > 0) { - return `${rendered}\n`; - } - return `${localSql.trim()}\n`; -} - -function findRenameCandidates(entries: DdlDiffSummaryEntry[]): Array<{ from: string; to: string }> { - const addedColumns = entries.filter((entry) => entry.changeKind === 'add_column'); - const droppedColumns = entries.filter((entry) => entry.changeKind === 'drop_column'); - const candidates: Array<{ from: string; to: string }> = []; - - for (const dropped of droppedColumns) { - const matched = addedColumns.find((entry) => normalizeSql(String(entry.details.type)) === normalizeSql(String(dropped.details.type))); - if (!matched) { - continue; - } - - const tableKey = `${dropped.schema}.${dropped.table}`; - candidates.push({ - from: `${tableKey}.${String(dropped.details.column)}`, - to: `${tableKey}.${String(matched.details.column)}` - }); - } - - return candidates; -} - -function parseSchemaModel(sql: string): ParsedSchemaModel { - const statements = splitSqlStatements(sql); - const tableDefinitions = new Map(); - const createSchemaStatements: string[] = []; - const supplementalStatementsByTable = new Map(); - - for (const statement of statements) { - const trimmed = statement.trim(); - if (trimmed.length === 0) { - continue; - } - - const tableDefinition = parseCreateTable(trimmed); - if (tableDefinition) { - tableDefinitions.set(tableDefinition.key, tableDefinition); - continue; - } - - if (/^create\s+schema\b/i.test(trimmed)) { - createSchemaStatements.push(trimmed); - continue; - } - - const referencedTable = extractReferencedTable(trimmed); - if (referencedTable) { - const bucket = supplementalStatementsByTable.get(referencedTable) ?? []; - bucket.push({ - sql: trimmed, - kind: isCreateIndexStatement(trimmed) ? 'index' : 'other', - name: extractIndexName(trimmed) - }); - supplementalStatementsByTable.set(referencedTable, bucket); - } - } - - return { - tableDefinitions, - createSchemaStatements, - supplementalStatementsByTable - }; -} - -function splitSqlStatements(sql: string): string[] { - return sql - .split(/;\s*(?:\r?\n|$)/) - .map((statement) => statement.trim()) - .filter((statement) => statement.length > 0); -} - -function parseCreateTable(statement: string): TableDefinition | undefined { - const match = statement.match(/^create\s+table\s+(?:if\s+not\s+exists\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)/i); - if (!match) { - return undefined; - } - - const [schema, table] = splitQualifiedName(match[1]); - const columns = parseColumns(statement); - const key = `${schema}.${table}`; - - return { - key, - schema, - table, - statement, - normalizedStatement: normalizeSql(statement), - columns - }; -} - -function parseColumns(statement: string): Map { - const columns = new Map(); - const start = statement.indexOf('('); - const end = statement.lastIndexOf(')'); - if (start < 0 || end <= start) { - return columns; - } - - const body = statement.slice(start + 1, end); - for (const rawLine of splitTopLevelCommaSeparated(body)) { - const line = rawLine.trim().replace(/\s+/g, ' '); - if (line.length === 0 || /^(constraint|primary key|foreign key|unique|check)\b/i.test(line)) { - continue; - } - const columnMatch = line.match(/^("?[\w$]+"?)\s+(.+)$/); - if (!columnMatch) { - continue; - } - - const name = normalizeIdentifier(columnMatch[1]); - const remainder = columnMatch[2]; - const nullable = !/\bnot null\b/i.test(remainder); - const type = remainder - .replace(/\bnot null\b/ig, '') - .replace(/\bnull\b/ig, '') - .replace(/\bdefault\b[\s\S]*$/i, '') - .trim() - .replace(/\s+/g, ' '); - - columns.set(name, { - name, - type, - nullable - }); - } - - return columns; -} - -function extractReferencedTable(statement: string): string | undefined { - const createIndexMatch = statement.match(/\bon\s+((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)/i); - if (createIndexMatch) { - const [schema, table] = splitQualifiedName(createIndexMatch[1]); - return `${schema}.${table}`; - } - - const alterTableMatch = statement.match(/^alter\s+table\s+(?:only\s+)?((?:"[^"]+"|[a-zA-Z_][\w$]*)(?:\.(?:"[^"]+"|[a-zA-Z_][\w$]*))?)/i); - if (alterTableMatch) { - const [schema, table] = splitQualifiedName(alterTableMatch[1]); - return `${schema}.${table}`; - } - - return undefined; -} - -function isCreateIndexStatement(statement: string): boolean { - return /^create\s+(?:unique\s+)?index\b/i.test(statement); -} - -function extractIndexName(statement: string): string | undefined { - const match = statement.match(/^create\s+(?:unique\s+)?index\s+(?:if\s+not\s+exists\s+)?("?[\w$]+"?)/i); - if (!match) { - return undefined; - } - return normalizeIdentifier(match[1]); -} - -function buildSummary(localModel: ParsedSchemaModel, remoteModel: ParsedSchemaModel): DdlDiffSummaryEntry[] { - const entries: DdlDiffSummaryEntry[] = []; - const remoteSchemas = collectKnownSchemas(remoteModel); - - // Surface schema-level changes so schema-only diffs are not treated as no-ops. - for (const statement of localModel.createSchemaStatements) { - const schemaName = extractCreatedSchemaName(statement); - if (!schemaName || remoteSchemas.has(schemaName)) { - continue; - } - entries.push({ - schema: schemaName, - table: '(schema)', - changeKind: 'schema_change', - details: { - message: `create schema ${schemaName}` - } - }); - } - - for (const [key, localTable] of localModel.tableDefinitions.entries()) { - const remoteTable = remoteModel.tableDefinitions.get(key); - if (!remoteTable) { - entries.push({ - schema: localTable.schema, - table: localTable.table, - changeKind: 'create_table', - details: {} - }); - continue; - } - - entries.push(...buildTableChangeSummary(localTable, remoteTable)); - - // Record supplemental-only changes for stable review output and hasChanges detection. - const supplementalChanges = diffSupplementalStatements( - localModel.supplementalStatementsByTable.get(key) ?? [], - remoteModel.supplementalStatementsByTable.get(key) ?? [] - ); - for (const statement of supplementalChanges) { - entries.push({ - schema: localTable.schema, - table: localTable.table, - changeKind: 'schema_change', - details: { - message: `apply ${statement.kind} ${statement.name ?? key}` - } - }); - } - } - - for (const [key, remoteTable] of remoteModel.tableDefinitions.entries()) { - if (!localModel.tableDefinitions.has(key)) { - entries.push({ - schema: remoteTable.schema, - table: remoteTable.table, - changeKind: 'drop_table', - details: {} - }); - } - } - - return sortSummaryEntries(entries); -} - -function buildTableChangeSummary(localTable: TableDefinition, remoteTable: TableDefinition): DdlDiffSummaryEntry[] { - const entries: DdlDiffSummaryEntry[] = []; - const key = `${localTable.schema}.${localTable.table}`; - - for (const [columnName, localColumn] of localTable.columns.entries()) { - const remoteColumn = remoteTable.columns.get(columnName); - if (!remoteColumn) { - entries.push({ - schema: localTable.schema, - table: localTable.table, - changeKind: 'add_column', - details: { - column: localColumn.name, - type: localColumn.type, - nullable: localColumn.nullable - } - }); - continue; - } - - if (normalizeSql(localColumn.type) !== normalizeSql(remoteColumn.type)) { - entries.push({ - schema: localTable.schema, - table: localTable.table, - changeKind: 'alter_type', - details: { - column: localColumn.name, - from: remoteColumn.type, - to: localColumn.type - } - }); - } - - if (localColumn.nullable !== remoteColumn.nullable) { - entries.push({ - schema: localTable.schema, - table: localTable.table, - changeKind: 'alter_nullability', - details: { - column: localColumn.name, - from: remoteColumn.nullable ? 'nullable' : 'not-null', - to: localColumn.nullable ? 'nullable' : 'not-null' - } - }); - } - } - - for (const [columnName, remoteColumn] of remoteTable.columns.entries()) { - if (!localTable.columns.has(columnName)) { - entries.push({ - schema: localTable.schema, - table: localTable.table, - changeKind: 'drop_column', - details: { - column: remoteColumn.name, - type: remoteColumn.type - } - }); - } - } - - if (entries.length === 0 && localTable.normalizedStatement !== remoteTable.normalizedStatement) { - entries.push({ - schema: localTable.schema, - table: localTable.table, - changeKind: 'table_rebuild', - details: { - message: `${key} changed outside the parsed column set` - } - }); - } - - return entries; -} - -function hasConstraintChange(localStatement: string, remoteStatement: string): boolean { - const localConstraints = extractConstraintLines(localStatement); - const remoteConstraints = extractConstraintLines(remoteStatement); - if (localConstraints.length === 0 && remoteConstraints.length === 0) { - return false; - } - - return normalizeSql(localConstraints.join(' ')) !== normalizeSql(remoteConstraints.join(' ')); -} - -function extractConstraintLines(statement: string): string[] { - const start = statement.indexOf('('); - const end = statement.lastIndexOf(')'); - if (start < 0 || end <= start) { - return []; - } - - return splitTopLevelCommaSeparated( - statement.slice(start + 1, end) - ) - .map((line) => line.trim()) - .filter((line) => /^(constraint|primary key|foreign key|unique|check)\b/i.test(line)); -} - -function splitTopLevelCommaSeparated(body: string): string[] { - const segments: string[] = []; - let current = ''; - let quote: '"' | "'" | undefined; - let parenDepth = 0; - - for (let index = 0; index < body.length; index += 1) { - const character = body[index]; - const next = body[index + 1]; - - if (quote) { - current += character; - if (character === quote && next === quote) { - current += next; - index += 1; - continue; - } - if (character === quote && body[index - 1] !== '\\') { - quote = undefined; - } - continue; - } - - if (character === '\'' || character === '"') { - quote = character; - current += character; - continue; - } - - if (character === '(') { - parenDepth += 1; - current += character; - continue; - } - - if (character === ')' && parenDepth > 0) { - parenDepth -= 1; - current += character; - continue; - } - - if (character === ',' && parenDepth === 0) { - segments.push(current); - current = ''; - continue; - } - - current += character; - } - - if (current.length > 0) { - segments.push(current); - } - - return segments; -} - -function collectKnownSchemas(model: ParsedSchemaModel): Set { - const schemas = new Set(); - for (const statement of model.createSchemaStatements) { - const schemaName = extractCreatedSchemaName(statement); - if (schemaName) { - schemas.add(schemaName); - } - } - - for (const table of model.tableDefinitions.values()) { - schemas.add(table.schema); - } - - return schemas; -} - -function extractCreatedSchemaName(statement: string): string | undefined { - const match = statement.match(/^create\s+schema\s+(?:if\s+not\s+exists\s+)?("?[\w$]+"?)/i); - if (!match) { - return undefined; - } - return normalizeIdentifier(match[1]); -} - -function diffSupplementalStatements( - localStatements: SupplementalStatement[], - remoteStatements: SupplementalStatement[] -): SupplementalStatement[] { - const remoteSql = new Set(remoteStatements.map((statement) => normalizeSql(statement.sql))); - return localStatements.filter((statement) => !remoteSql.has(normalizeSql(statement.sql))); -} - -function sortSummaryEntries(entries: DdlDiffSummaryEntry[]): DdlDiffSummaryEntry[] { - return [...entries].sort((left, right) => { - const leftKey = `${left.schema}.${left.table}.${left.changeKind}.${String(left.details.column ?? '')}`; - const rightKey = `${right.schema}.${right.table}.${right.changeKind}.${String(right.details.column ?? '')}`; - return leftKey.localeCompare(rightKey); - }); -} - -function splitQualifiedName(value: string): [string, string] { - const segments = value.split('.'); - if (segments.length === 1) { - return ['public', normalizeIdentifier(segments[0])]; - } - - return [normalizeIdentifier(segments[0]), normalizeIdentifier(segments[1])]; -} - -function normalizeIdentifier(value: string): string { - return value.replace(/^"/, '').replace(/"$/, ''); -} - -function normalizeSql(value: string): string { - return value.trim().replace(/\s+/g, ' ').toLowerCase(); -} - -function quoteQualifiedName(schema: string, table: string): string { - return `"${schema}"."${table}"`; -} diff --git a/packages/ztd-cli/src/commands/feature.ts b/packages/ztd-cli/src/commands/feature.ts deleted file mode 100644 index 20ecbfb8b..000000000 --- a/packages/ztd-cli/src/commands/feature.ts +++ /dev/null @@ -1,3379 +0,0 @@ -import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; -import { createHash } from 'node:crypto'; -import path from 'node:path'; -import { Command } from 'commander'; -import { - CreateTableQuery, - MultiQuerySplitter, - SqlFormatter, - SqlParser, - type ColumnConstraintDefinition, - type TableColumnDefinition -} from 'rawsql-ts'; -import { emitDiagnostic, isJsonOutput, writeCommandEnvelope } from '../utils/agentCli'; -import { ensureDirectory } from '../utils/fs'; -import { collectSqlFiles, type SqlSource } from '../utils/collectSqlFiles'; -import { inspectImportAliasSupport } from '../utils/importAliasSupport'; -import { loadZtdProjectConfig, resolveGeneratedDir } from '../utils/ztdProjectConfig'; -import { registerFeatureTestsScaffoldCommand } from './featureTests'; - -const FEATURE_ACTIONS = ['insert', 'update', 'delete', 'get-by-id', 'list'] as const; -type FeatureAction = (typeof FEATURE_ACTIONS)[number]; -const INSERT_DEFAULT_POLICIES = ['explicit-defaults', 'omit-db-defaults'] as const; -type InsertDefaultPolicy = (typeof INSERT_DEFAULT_POLICIES)[number]; -const DEFAULT_INSERT_DEFAULT_POLICY: InsertDefaultPolicy = 'explicit-defaults'; -const DEFAULT_PAGE_SIZE = 50; -const FEATURE_SHARED_EXECUTOR_IMPORT_PATH = '#features/_shared/featureQueryExecutor.js'; -const FEATURE_SHARED_LOAD_SQL_RESOURCE_IMPORT_PATH = '#features/_shared/loadSqlResource.js'; -const FIXED_LAYOUT_DESCRIPTION = [ - 'src/features//', - ' boundary.ts', - ' tests/', - ' .boundary.test.ts', - ' queries/', - ' /', - ' boundary.ts', - ' .sql', - ' generated/', - ' row-mapper.ts', - ' tests/', - ' .boundary.ztd.test.ts', - ' boundary-ztd-types.ts', - ' generated/', - ' TEST_PLAN.md', - ' analysis.json', - ' cases/', - ' README.md' -].join('\n'); - -type FeatureCommandOptions = { - table?: string; - action?: string; - featureName?: string; - insertDefaultPolicy?: string; - dryRun?: boolean; - force?: boolean; - rootDir?: string; -}; - -type ExistingBoundaryQueryCommandOptions = { - table?: string; - action?: string; - queryName?: string; - feature?: string; - boundaryDir?: string; - insertDefaultPolicy?: string; - dryRun?: boolean; - rootDir?: string; - workingDir?: string; -}; - -type GeneratedMapperCommandOptions = { - feature?: string; - query?: string; - rootDir?: string; - dryRun?: boolean; -}; - -type FeatureScaffoldSourceName = 'generated-metadata' | 'ddl'; - -interface GeneratedMetadataAssessment { - source: 'generated-metadata'; - supported: boolean; - reasons: string[]; - checkedFiles: string[]; -} - -interface ScaffoldColumnMetadata { - name: string; - typeName?: string; - isNotNull: boolean; - defaultValue: string | null; - hasGeneratedIdentity: boolean; -} - -interface DdlTableMetadata { - canonicalName: string; - schemaName: string; - tableName: string; - columns: ScaffoldColumnMetadata[]; - primaryKeyColumns: string[]; -} - -interface FeatureScaffoldInput { - source: FeatureScaffoldSourceName; - table: DdlTableMetadata; -} - -interface FeatureScaffoldPaths { - featureDir: string; - queryDir: string; - testsDir: string; - entryBoundaryTestFile: string; - entrySpecFile: string; - inputFile: string; - workflowFile: string; - outputFile: string; - querySpecFile: string; - querySqlFile: string; - queryGeneratedDir: string; - queryGeneratedRowMapperFile: string; - readmeFile: string; - sharedDir: string; - featureQueryExecutorFile: string; - loadSqlResourceFile: string; -} - -interface ExistingBoundaryQueryScaffoldPaths { - boundaryDir: string; - queriesDir: string; - queryDir: string; - querySpecFile: string; - querySqlFile: string; - queryGeneratedDir: string; - queryGeneratedRowMapperFile: string; - entrySpecFile: string; - sharedDir: string; - featureQueryExecutorFile: string; - loadSqlResourceFile: string; - createsQueriesDir: boolean; -} - -interface FeatureScaffoldResult { - featureName: string; - queryName: string; - action: FeatureAction; - table: string; - primaryKeyColumn: string; - source: FeatureScaffoldSourceName; - insertDefaultPolicy: InsertDefaultPolicy; - dryRun: boolean; - outputs: Array<{ path: string; written: boolean; kind: 'directory' | 'file' }>; -} - -interface ExistingBoundaryQueryScaffoldResult { - boundaryPath: string; - resolutionSource: 'feature' | 'boundary-dir' | 'cwd'; - queryName: string; - action: FeatureAction; - table: string; - primaryKeyColumn: string; - source: FeatureScaffoldSourceName; - insertDefaultPolicy: InsertDefaultPolicy; - dryRun: boolean; - outputs: Array<{ path: string; written: boolean; kind: 'directory' | 'file' }>; -} - -interface GeneratedMapperSyncResult { - featureName: string; - queryNames: string[]; - dryRun: boolean; - outputs: Array<{ path: string; written: boolean; changed: boolean; kind: 'file' }>; -} - -interface GeneratedMapperCheckResult { - featureName: string; - queryNames: string[]; - ok: boolean; - checked: Array<{ path: string; changed: boolean; kind: 'file' }>; -} - -type GeneratedMapperMode = 'single' | 'optional' | 'list' | 'hasMany'; - -interface GeneratedHasManySideMetadata { - key: string[]; - columns: Record; -} - -interface GeneratedHasManyCollectionMetadata extends GeneratedHasManySideMetadata { - property: string; - presence: string[]; -} - -interface GeneratedHasManyRelationMetadata { - kind: 'hasMany'; - root: GeneratedHasManySideMetadata; - collection: GeneratedHasManyCollectionMetadata; -} - -interface GeneratedMapperSpec { - featureName: string; - queryName: string; - queryPascalName: string; - mode: GeneratedMapperMode; - fieldNames: string[]; - hasMany?: GeneratedHasManyRelationMetadata; - boundaryHash: string; - sqlHash: string; - boundaryFile: string; - generatedFile: string; -} - -export function registerFeatureCommand(program: Command): void { - const feature = program.command('feature').description('Scaffold feature-local files from schema metadata'); - registerFeatureTestsScaffoldCommand(feature); - const featureQuery = feature - .command('query') - .description('Add a child query boundary under an existing boundary folder'); - const generatedMapper = feature - .command('generated-mapper') - .description('Synchronize machine-owned RFBA generated row mappers'); - - feature - .command('scaffold') - .description('Scaffold a feature-local CRUD or SELECT boundary skeleton from schema metadata') - .requiredOption('--table
', 'Target table name') - .requiredOption('--action ', 'Feature action template to scaffold (v1 supports insert, update, delete, get-by-id, and list)') - .option('--feature-name ', 'Override the derived feature name') - .option('--insert-default-policy ', 'INSERT default-column policy: explicit-defaults or omit-db-defaults', DEFAULT_INSERT_DEFAULT_POLICY) - .option('--dry-run', 'Validate inputs and emit the planned scaffold without writing files', false) - .option('--force', 'Overwrite scaffold-owned feature files when they already exist', false) - .action(async (options: FeatureCommandOptions) => { - const result = await runFeatureScaffoldCommand(options); - if (isJsonOutput()) { - writeCommandEnvelope('feature scaffold', result); - return; - } - - const lines = [ - `Feature scaffold ${result.dryRun ? 'plan' : 'completed'}: ${result.featureName}`, - `Action: ${result.action}`, - `Table: ${result.table}`, - `Primary key: ${result.primaryKeyColumn}`, - `Source: ${result.source}`, - `Insert default policy: ${result.insertDefaultPolicy}`, - '', - 'Created by CLI:', - ...result.outputs.map((output) => `- ${output.path}`), - '', - 'Reserved for AI follow-up (not created by the CLI):', - `- Run \`ztd feature tests scaffold --feature ${result.featureName}\` after you finish SQL and DTO edits.`, - `- That command will refresh src/features/${result.featureName}/queries/${result.queryName}/tests/generated/TEST_PLAN.md and analysis.json, while AI-authored cases stay in src/features/${result.featureName}/queries/${result.queryName}/tests/cases/.` - ]; - process.stdout.write(`${lines.join('\n')}\n`); - }); - - featureQuery - .command('scaffold') - .description('Scaffold one additive query boundary under an existing boundary folder without rewriting the parent boundary') - .requiredOption('--table
', 'Target table name for the new query boundary') - .requiredOption('--action ', 'Query action template to scaffold (v1 supports insert, update, delete, get-by-id, and list)') - .requiredOption('--query-name ', 'Name of the query boundary to add under queries/') - .option('--feature ', 'Resolve the target boundary as src/features/') - .option('--boundary-dir ', 'Explicit existing boundary folder path; defaults to the current working directory when omitted') - .option('--insert-default-policy ', 'INSERT default-column policy: explicit-defaults or omit-db-defaults', DEFAULT_INSERT_DEFAULT_POLICY) - .option('--dry-run', 'Validate inputs and emit the planned scaffold without writing files', false) - .action(async (options: ExistingBoundaryQueryCommandOptions) => { - const result = await runExistingBoundaryQueryScaffoldCommand(options); - if (isJsonOutput()) { - writeCommandEnvelope('feature query scaffold', result); - return; - } - - const lines = [ - `Existing-boundary query scaffold ${result.dryRun ? 'plan' : 'completed'}: ${result.queryName}`, - `Boundary: ${result.boundaryPath}`, - `Resolved by: ${result.resolutionSource}`, - `Action: ${result.action}`, - `Table: ${result.table}`, - `Primary key: ${result.primaryKeyColumn}`, - `Source: ${result.source}`, - `Insert default policy: ${result.insertDefaultPolicy}`, - '', - 'Created by CLI:', - ...result.outputs.map((output) => `- ${output.path}`), - '', - 'Reserved for AI/human follow-up (not done by the CLI):', - '- Wire the new query boundary into the parent boundary explicitly.', - '- Decide orchestration, transaction boundaries, and response shaping at the parent boundary.' - ]; - process.stdout.write(`${lines.join('\n')}\n`); - }); - - generatedMapper - .command('generate') - .description('Regenerate machine-owned query row mappers from query boundary contracts') - .requiredOption('--feature ', 'Feature name under src/features/') - .option('--query ', 'Limit regeneration to one query under queries/') - .option('--dry-run', 'Check the planned generated mapper updates without writing files', false) - .action(async (options: GeneratedMapperCommandOptions) => { - const result = await runFeatureGeneratedMapperGenerateCommand(options); - if (isJsonOutput()) { - writeCommandEnvelope('feature generated-mapper generate', result); - return; - } - - const lines = [ - `Generated mapper ${result.dryRun ? 'plan' : 'sync'}: ${result.featureName}`, - '', - ...result.outputs.map((output) => `- ${output.path}${output.changed ? ' (changed)' : ' (unchanged)'}`) - ]; - process.stdout.write(`${lines.join('\n')}\n`); - }); - - generatedMapper - .command('check') - .description('Fail when machine-owned query row mappers drift from query boundary contracts') - .requiredOption('--feature ', 'Feature name under src/features/') - .option('--query ', 'Limit drift detection to one query under queries/') - .action(async (options: GeneratedMapperCommandOptions) => { - const result = await runFeatureGeneratedMapperCheckCommand(options); - if (isJsonOutput()) { - writeCommandEnvelope('feature generated-mapper check', result); - return; - } - - process.stdout.write(`Generated mapper check passed: ${result.featureName}\n`); - }); -} - -export async function runFeatureScaffoldCommand(options: FeatureCommandOptions): Promise { - const rootDir = options.rootDir ?? process.cwd(); - const action = normalizeFeatureAction(options.action); - const insertDefaultPolicy = normalizeInsertDefaultPolicy(options.insertDefaultPolicy); - const config = loadZtdProjectConfig(rootDir); - const featureName = normalizeFeatureName( - options.featureName ?? deriveFeatureName(options.table ?? '', action) - ); - const queryName = deriveQueryName(options.table ?? '', action); - - const generatedMetadataAssessment = assessGeneratedMetadataCapability(rootDir); - const input = resolveFeatureScaffoldInput({ - projectRoot: rootDir, - table: options.table ?? '', - config, - generatedMetadataAssessment - }); - const primaryKeyColumn = resolvePrimaryKeyColumn(input.table); - const paths = buildFeatureScaffoldPaths(rootDir, featureName, queryName); - const contents = renderFeatureScaffoldFiles({ - rootDir, - featureName, - queryName, - action, - table: input.table, - primaryKeyColumn, - insertDefaultPolicy, - }); - assertFeatureWriteSafety(paths, options.force === true); - const sharedOutputs = buildSharedOutputs(rootDir, paths, !options.dryRun); - - const outputs: FeatureScaffoldResult['outputs'] = [ - ...sharedOutputs, - { path: toProjectRelativePath(rootDir, paths.featureDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, paths.testsDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, paths.queryDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, paths.queryGeneratedDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, paths.entryBoundaryTestFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.entrySpecFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.inputFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.workflowFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.outputFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.querySpecFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.querySqlFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.queryGeneratedRowMapperFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.readmeFile), written: !options.dryRun, kind: 'file' }, - ]; - - if (options.dryRun) { - return { - featureName, - queryName, - action, - table: input.table.canonicalName, - primaryKeyColumn, - source: input.source, - insertDefaultPolicy, - dryRun: true, - outputs - }; - } - - ensureDirectory(paths.sharedDir); - ensureDirectory(paths.featureDir); - ensureDirectory(paths.testsDir); - ensureDirectory(paths.queryDir); - ensureDirectory(paths.queryGeneratedDir); - writeFileIfMissing(paths.featureQueryExecutorFile, contents.featureQueryExecutorFile); - writeFileIfMissing(paths.loadSqlResourceFile, contents.loadSqlResourceFile); - writeFileIfMissing(paths.entryBoundaryTestFile, contents.entrySpecTestFile); - writeFeatureFile(paths.entrySpecFile, contents.entrySpecFile, options.force === true); - writeFeatureFile(paths.inputFile, contents.inputFile, options.force === true); - writeFeatureFile(paths.workflowFile, contents.workflowFile, options.force === true); - writeFeatureFile(paths.outputFile, contents.outputFile, options.force === true); - writeFeatureFile(paths.querySpecFile, contents.querySpecFile, options.force === true); - writeFeatureFile(paths.querySqlFile, contents.querySqlFile, options.force === true); - writeGeneratedFile(paths.queryGeneratedRowMapperFile, contents.queryGeneratedRowMapperFile); - writeFeatureFile(paths.readmeFile, contents.readmeFile, options.force === true); - - emitDiagnostic({ - code: 'feature-scaffold.ai-follow-up', - message: `CLI created src/features/${featureName}/tests/ only for the feature-boundary lane. Run feature tests scaffold after SQL and DTO edits to refresh query-local generated analysis and keep AI-authored cases under src/features/${featureName}/queries/${queryName}/tests/cases/.` - }); - - return { - featureName, - queryName, - action, - table: input.table.canonicalName, - primaryKeyColumn, - source: input.source, - insertDefaultPolicy, - dryRun: false, - outputs - }; -} - -export async function runExistingBoundaryQueryScaffoldCommand( - options: ExistingBoundaryQueryCommandOptions -): Promise { - const rootDir = options.rootDir ?? process.cwd(); - const action = normalizeFeatureAction(options.action); - const insertDefaultPolicy = normalizeInsertDefaultPolicy(options.insertDefaultPolicy); - const queryName = normalizeChildQueryName(options.queryName); - const config = loadZtdProjectConfig(rootDir); - const generatedMetadataAssessment = assessGeneratedMetadataCapability(rootDir); - const input = resolveFeatureScaffoldInput({ - projectRoot: rootDir, - table: options.table ?? '', - config, - generatedMetadataAssessment - }); - const primaryKeyColumn = resolvePrimaryKeyColumn(input.table); - const resolvedBoundary = resolveExistingBoundaryFolder(rootDir, options); - assertExistingBoundaryFolderContract(rootDir, resolvedBoundary.boundaryDir); - const paths = buildExistingBoundaryQueryScaffoldPaths(rootDir, resolvedBoundary.boundaryDir, queryName); - assertExistingBoundaryQueryWriteSafety(paths); - const contents = renderExistingBoundaryQueryScaffoldFiles({ - rootDir, - boundaryDir: resolvedBoundary.boundaryDir, - boundaryRelativeDir: resolvedBoundary.boundaryPath, - queryName, - action, - table: input.table, - primaryKeyColumn, - insertDefaultPolicy - }); - - const outputs: ExistingBoundaryQueryScaffoldResult['outputs'] = [ - ...buildSharedOutputs(rootDir, paths, !options.dryRun), - ...(paths.createsQueriesDir - ? [{ path: toProjectRelativePath(rootDir, paths.queriesDir), written: !options.dryRun, kind: 'directory' as const }] - : []), - { path: toProjectRelativePath(rootDir, paths.queryDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, paths.queryGeneratedDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, paths.querySpecFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.querySqlFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, paths.queryGeneratedRowMapperFile), written: !options.dryRun, kind: 'file' }, - ]; - - if (options.dryRun) { - return { - boundaryPath: resolvedBoundary.boundaryPath, - resolutionSource: resolvedBoundary.resolutionSource, - queryName, - action, - table: input.table.canonicalName, - primaryKeyColumn, - source: input.source, - insertDefaultPolicy, - dryRun: true, - outputs - }; - } - - ensureDirectory(paths.sharedDir); - ensureDirectory(paths.queriesDir); - ensureDirectory(paths.queryDir); - ensureDirectory(paths.queryGeneratedDir); - writeFileIfMissing(paths.featureQueryExecutorFile, contents.featureQueryExecutorFile); - writeFileIfMissing(paths.loadSqlResourceFile, contents.loadSqlResourceFile); - writeFileSync(paths.querySpecFile, contents.querySpecFile, 'utf8'); - writeFileSync(paths.querySqlFile, contents.querySqlFile, 'utf8'); - writeGeneratedFile(paths.queryGeneratedRowMapperFile, contents.queryGeneratedRowMapperFile); - - emitDiagnostic({ - code: 'feature-query-scaffold.parent-follow-up', - message: `CLI added ${resolvedBoundary.boundaryPath}/queries/${queryName}, but it did not modify ${resolvedBoundary.boundaryPath}/boundary.ts. Wire orchestration explicitly in the parent boundary.` - }); - - return { - boundaryPath: resolvedBoundary.boundaryPath, - resolutionSource: resolvedBoundary.resolutionSource, - queryName, - action, - table: input.table.canonicalName, - primaryKeyColumn, - source: input.source, - insertDefaultPolicy, - dryRun: false, - outputs - }; -} - -export async function runFeatureGeneratedMapperGenerateCommand( - options: GeneratedMapperCommandOptions -): Promise { - const rootDir = options.rootDir ?? process.cwd(); - const featureName = normalizeFeatureName(options.feature ?? ''); - const specs = collectGeneratedMapperSpecs(rootDir, featureName, options.query); - const outputs: GeneratedMapperSyncResult['outputs'] = []; - - for (const spec of specs) { - const expected = renderGeneratedRowMapperFileFromSpec(spec); - const previous = existsSync(spec.generatedFile) ? readFileSync(spec.generatedFile, 'utf8') : ''; - const changed = previous !== expected; - outputs.push({ - path: toProjectRelativePath(rootDir, spec.generatedFile), - written: !options.dryRun, - changed, - kind: 'file' - }); - if (!options.dryRun) { - ensureDirectory(path.dirname(spec.generatedFile)); - writeGeneratedFile(spec.generatedFile, expected); - } - } - - return { - featureName, - queryNames: specs.map((spec) => spec.queryName), - dryRun: options.dryRun === true, - outputs - }; -} - -export async function runFeatureGeneratedMapperCheckCommand( - options: GeneratedMapperCommandOptions -): Promise { - const rootDir = options.rootDir ?? process.cwd(); - const featureName = normalizeFeatureName(options.feature ?? ''); - const specs = collectGeneratedMapperSpecs(rootDir, featureName, options.query); - const checked: GeneratedMapperCheckResult['checked'] = []; - const driftedSpecs: GeneratedMapperSpec[] = []; - - for (const spec of specs) { - const expected = renderGeneratedRowMapperFileFromSpec(spec); - const previous = existsSync(spec.generatedFile) ? readFileSync(spec.generatedFile, 'utf8') : ''; - const changed = previous !== expected; - checked.push({ - path: toProjectRelativePath(rootDir, spec.generatedFile), - changed, - kind: 'file' - }); - if (changed) { - driftedSpecs.push(spec); - } - } - - if (driftedSpecs.length > 0) { - const driftList = driftedSpecs - .map((spec) => `- ${toProjectRelativePath(rootDir, spec.generatedFile)}`) - .join('\n'); - const querySuffix = driftedSpecs.length === 1 ? ` --query ${driftedSpecs[0].queryName}` : ''; - throw new Error( - [ - `Generated row mapper drift detected for feature ${featureName}.`, - driftList, - `Run \`ztd feature generated-mapper generate --feature ${featureName}${querySuffix}\` to refresh machine-owned generated files.` - ].join('\n') - ); - } - - return { - featureName, - queryNames: specs.map((spec) => spec.queryName), - ok: true, - checked - }; -} - -export function deriveFeatureName(tableName: string, action: string): string { - const resourceSegment = toFeatureResourceSegment(tableName); - return `${resourceSegment}-${action.trim().toLowerCase()}`; -} - -export function normalizeFeatureAction(action: string | undefined): FeatureAction { - const normalized = (action ?? '').trim().toLowerCase(); - if (FEATURE_ACTIONS.includes(normalized as FeatureAction)) { - return normalized as FeatureAction; - } - throw new Error(`Unsupported --action value: ${action}. v1 supports only insert, update, delete, get-by-id, and list.`); -} - -export function normalizeInsertDefaultPolicy(policy: string | undefined): InsertDefaultPolicy { - const normalized = (policy ?? DEFAULT_INSERT_DEFAULT_POLICY).trim().toLowerCase(); - if (INSERT_DEFAULT_POLICIES.includes(normalized as InsertDefaultPolicy)) { - return normalized as InsertDefaultPolicy; - } - throw new Error(`Unsupported --insert-default-policy value: ${policy}. Supported policies are explicit-defaults and omit-db-defaults.`); -} - -export function normalizeFeatureName(value: string): string { - const normalized = value.trim().toLowerCase(); - if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(normalized)) { - throw new Error( - 'Feature name must use resource-action kebab-case, start with a letter, and look like users-insert.' - ); - } - return normalized; -} - -export function normalizeChildQueryName(value: string | undefined): string { - const normalized = (value ?? '').trim().toLowerCase(); - if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(normalized)) { - throw new Error('Query name must use kebab-case, start with a letter, and look like insert-sales-detail.'); - } - return normalized; -} - -export function assessGeneratedMetadataCapability(projectRoot: string): GeneratedMetadataAssessment { - const config = loadZtdProjectConfig(projectRoot); - const generatedManifestPath = path.join(projectRoot, resolveGeneratedDir(config), 'ztd-fixture-manifest.generated.ts'); - const reasons: string[] = []; - if (!existsSync(generatedManifestPath)) { - reasons.push(`${normalizeCliPath(path.relative(projectRoot, generatedManifestPath))} is missing.`); - return { - source: 'generated-metadata', - supported: false, - reasons, - checkedFiles: [normalizeCliPath(generatedManifestPath)] - }; - } - - const manifestSource = readFileSync(generatedManifestPath, 'utf8'); - if (!manifestSource.includes('tableDefinitions')) { - reasons.push('generated manifest does not expose tableDefinitions.'); - } - if (!manifestSource.includes('typeName')) { - reasons.push('generated manifest does not expose typeName metadata.'); - } - if (!manifestSource.includes('defaultValue')) { - reasons.push('generated manifest does not expose defaultValue metadata.'); - } - reasons.push('generated manifest does not expose explicit primary key identity.'); - reasons.push('generated manifest does not expose composite primary key structure.'); - reasons.push('generated manifest does not expose generated/identity semantics as a scaffold contract.'); - - return { - source: 'generated-metadata', - supported: false, - reasons, - checkedFiles: [normalizeCliPath(generatedManifestPath)] - }; -} - -export function resolveFeatureScaffoldInput(params: { - projectRoot: string; - table: string; - config: ReturnType; - generatedMetadataAssessment: GeneratedMetadataAssessment; -}): FeatureScaffoldInput { - const { generatedMetadataAssessment } = params; - if (generatedMetadataAssessment.supported) { - throw new Error('Generated metadata runtime input is not implemented yet.'); - } - - const table = loadTableMetadataFromDdl({ - projectRoot: params.projectRoot, - rawTableName: params.table, - defaultSchema: params.config.defaultSchema, - searchPath: params.config.searchPath, - ddlDir: params.config.ddlDir - }); - - return { - source: 'ddl', - table - }; -} - -export function resolvePrimaryKeyColumn(table: DdlTableMetadata): string { - if (table.primaryKeyColumns.length === 0) { - throw new Error(`Table ${table.canonicalName} must declare exactly one primary key column in v1.`); - } - if (table.primaryKeyColumns.length > 1) { - throw new Error(`Composite primary keys are not supported in v1: ${table.canonicalName}.`); - } - return table.primaryKeyColumns[0]; -} - -function loadTableMetadataFromDdl(params: { - projectRoot: string; - rawTableName: string; - defaultSchema: string; - searchPath: string[]; - ddlDir: string; -}): DdlTableMetadata { - const ddlRoot = path.resolve(params.projectRoot, params.ddlDir); - const sources = collectSqlFiles([ddlRoot], ['.sql']); - if (sources.length === 0) { - throw new Error(`No SQL files were discovered under ${params.ddlDir}.`); - } - - const tables = collectDdlTableMetadata(sources, params.defaultSchema); - const target = resolveRequestedTable(params.rawTableName, tables, params.defaultSchema, params.searchPath); - if (!target) { - throw new Error(`Table not found for scaffold: ${params.rawTableName}.`); - } - return target; -} - -function collectDdlTableMetadata(sources: SqlSource[], defaultSchema: string): DdlTableMetadata[] { - const tables = new Map(); - for (const source of sources) { - const queries = MultiQuerySplitter.split(source.sql).queries; - for (const query of queries) { - if (query.isEmpty) { - continue; - } - const parsed = tryParseCreateTable(query.sql); - if (!parsed) { - continue; - } - const table = buildDdlTableMetadata(parsed, defaultSchema); - tables.set(table.canonicalName, table); - } - } - return [...tables.values()].sort((a, b) => a.canonicalName.localeCompare(b.canonicalName)); -} - -function tryParseCreateTable(sql: string): CreateTableQuery | null { - try { - const parsed = SqlParser.parse(sql); - return parsed instanceof CreateTableQuery ? parsed : null; - } catch { - return null; - } -} - -function buildDdlTableMetadata(query: CreateTableQuery, defaultSchema: string): DdlTableMetadata { - const schemaName = query.namespaces?.[query.namespaces.length - 1] ?? defaultSchema; - const tableName = query.tableName.name; - const columnPrimaryKeys = query.columns - .filter((column) => hasColumnPrimaryKey(column.constraints)) - .map((column) => column.name.name); - const tablePrimaryKey = query.tableConstraints - .find((constraint) => constraint.kind === 'primary-key') - ?.columns - ?.map((column) => column.name) ?? []; - const primaryKeyColumns = dedupeStrings([...columnPrimaryKeys, ...tablePrimaryKey]); - - return { - canonicalName: `${schemaName}.${tableName}`, - schemaName, - tableName, - primaryKeyColumns, - columns: query.columns.map((column) => ({ - name: column.name.name, - typeName: extractTypeName(column), - isNotNull: column.constraints.some((constraint) => constraint.kind === 'not-null' || constraint.kind === 'primary-key'), - defaultValue: extractDefaultValue(column.constraints), - hasGeneratedIdentity: column.constraints.some((constraint) => - constraint.kind === 'generated-always-identity' || constraint.kind === 'generated-by-default-identity' - ) - })) - }; -} - -function hasColumnPrimaryKey(constraints: ColumnConstraintDefinition[]): boolean { - return constraints.some((constraint) => constraint.kind === 'primary-key'); -} - -function extractTypeName(column: TableColumnDefinition): string | undefined { - const dataType = column.dataType; - if (!dataType) { - return undefined; - } - if ('getTypeName' in dataType && typeof dataType.getTypeName === 'function') { - return dataType.getTypeName(); - } - if ('value' in dataType && typeof dataType.value === 'string') { - return dataType.value; - } - return undefined; -} - -function extractDefaultValue(constraints: ColumnConstraintDefinition[]): string | null { - const defaultConstraint = constraints.find((constraint) => constraint.kind === 'default'); - if (!defaultConstraint || defaultConstraint.defaultValue == null) { - return null; - } - const value = defaultConstraint.defaultValue; - if (typeof value === 'string') { - return value; - } - if ( - typeof value === 'number' || - typeof value === 'boolean' || - typeof value === 'bigint' - ) { - return String(value); - } - if ( - typeof value === 'object' && - value !== null && - 'toSql' in value && - typeof value.toSql === 'function' - ) { - return value.toSql(); - } - try { - const formatter = new SqlFormatter({ keywordCase: 'none' }); - const { formattedSql } = formatter.format(value); - return formattedSql; - } catch (cause) { - throw new Error( - `Failed to render a scaffoldable default expression: ${cause instanceof Error ? cause.message : String(cause)}` - ); - } -} - -function resolveRequestedTable( - rawTableName: string, - tables: DdlTableMetadata[], - defaultSchema: string, - searchPath: string[] -): DdlTableMetadata | undefined { - const normalized = rawTableName.trim().toLowerCase(); - if (normalized.includes('.')) { - const canonicalMatch = tables.find((table) => table.canonicalName.toLowerCase() === normalized); - if (canonicalMatch) { - return canonicalMatch; - } - } - - const candidates = tables.filter((table) => table.tableName.toLowerCase() === normalized); - if (candidates.length === 0) { - return undefined; - } - - const orderedSearchPath = searchPath.map((entry) => entry.toLowerCase()); - for (const schemaName of orderedSearchPath) { - const match = candidates.find((table) => table.schemaName.toLowerCase() === schemaName); - if (match) { - return match; - } - } - - if (candidates.length === 1) { - return candidates[0]; - } - throw new Error(`Table name is ambiguous: ${rawTableName}. Use a schema-qualified table name.`); -} - -function buildFeatureScaffoldPaths(rootDir: string, featureName: string, queryName: string): FeatureScaffoldPaths { - const featureDir = path.join(rootDir, 'src', 'features', featureName); - const sharedDir = path.join(rootDir, 'src', 'features', '_shared'); - return { - featureDir, - queryDir: path.join(featureDir, 'queries', queryName), - testsDir: path.join(featureDir, 'tests'), - entryBoundaryTestFile: path.join(featureDir, 'tests', `${featureName}.boundary.test.ts`), - entrySpecFile: path.join(featureDir, 'boundary.ts'), - inputFile: path.join(featureDir, 'input.ts'), - workflowFile: path.join(featureDir, 'workflow.ts'), - outputFile: path.join(featureDir, 'output.ts'), - querySpecFile: path.join(featureDir, 'queries', queryName, 'boundary.ts'), - querySqlFile: path.join(featureDir, 'queries', queryName, `${queryName}.sql`), - queryGeneratedDir: path.join(featureDir, 'queries', queryName, 'generated'), - queryGeneratedRowMapperFile: path.join(featureDir, 'queries', queryName, 'generated', 'row-mapper.ts'), - readmeFile: path.join(featureDir, 'README.md'), - sharedDir, - featureQueryExecutorFile: path.join(sharedDir, 'featureQueryExecutor.ts'), - loadSqlResourceFile: path.join(sharedDir, 'loadSqlResource.ts') - }; -} - -function buildExistingBoundaryQueryScaffoldPaths( - rootDir: string, - boundaryDir: string, - queryName: string -): ExistingBoundaryQueryScaffoldPaths { - const queriesDir = path.join(boundaryDir, 'queries'); - const queryDir = path.join(queriesDir, queryName); - const sharedDir = path.join(rootDir, 'src', 'features', '_shared'); - return { - boundaryDir, - queriesDir, - queryDir, - querySpecFile: path.join(queryDir, 'boundary.ts'), - querySqlFile: path.join(queryDir, `${queryName}.sql`), - queryGeneratedDir: path.join(queryDir, 'generated'), - queryGeneratedRowMapperFile: path.join(queryDir, 'generated', 'row-mapper.ts'), - entrySpecFile: path.join(boundaryDir, 'boundary.ts'), - sharedDir, - featureQueryExecutorFile: path.join(sharedDir, 'featureQueryExecutor.ts'), - loadSqlResourceFile: path.join(sharedDir, 'loadSqlResource.ts'), - createsQueriesDir: !existsSync(queriesDir) - }; -} - -function renderFeatureScaffoldFiles(params: { - rootDir: string; - featureName: string; - queryName: string; - action: FeatureAction; - table: DdlTableMetadata; - primaryKeyColumn: string; - insertDefaultPolicy: InsertDefaultPolicy; -}): { - entrySpecFile: string; - inputFile: string; - workflowFile: string; - outputFile: string; - entrySpecTestFile: string; - querySpecFile: string; - querySqlFile: string; - queryGeneratedRowMapperFile: string; - readmeFile: string; - featureQueryExecutorFile: string; - loadSqlResourceFile: string; -} { - const sharedImports = resolveFeatureSharedImportPaths( - params.rootDir, - path.join(params.rootDir, 'src', 'features', params.featureName, 'queries', params.queryName), - 'Feature scaffold' - ); - const pascalName = toPascalCase(params.featureName); - const entryCamelName = toCamelCase(params.featureName); - const queryPascalName = toPascalCase(params.queryName); - const queryCamelName = toCamelCase(params.queryName); - const actionPlan = buildActionPlan(params.action, params.table, params.primaryKeyColumn, params.insertDefaultPolicy); - const requestFields = actionPlan.requestColumns.map((column) => toRenderField(column, { boundary: 'feature' })); - const responseFields = actionPlan.resultColumns.map((column) => toRenderField(column, { boundary: 'feature' })); - const queryRequestFields = actionPlan.requestColumns.map((column) => toRenderField(column, { boundary: 'query' })); - const queryResponseFields = actionPlan.resultColumns.map((column) => toRenderField(column, { boundary: 'query' })); - const sqlFile = renderActionSql(actionPlan, params.table.canonicalName, params.primaryKeyColumn); - const sharedSupportFiles = renderFeatureSharedSupportFiles(); - const entrySpecFile = renderEntrySpecFile({ - action: params.action, - featureName: params.featureName, - pascalName, - entryCamelName, - queryName: params.queryName, - queryPascalName, - queryCamelName, - requestFields, - responseFields, - insertDefaultPolicy: params.insertDefaultPolicy - }); - const inputFile = renderFeatureInputFile({ - pascalName, - requestFields - }); - const workflowFile = renderFeatureWorkflowFile({ - action: params.action, - featureName: params.featureName, - pascalName, - queryName: params.queryName, - queryPascalName, - requestFields, - insertDefaultPolicy: params.insertDefaultPolicy - }); - const outputFile = renderFeatureOutputFile({ - action: params.action, - pascalName, - queryName: params.queryName, - queryPascalName, - responseFields - }); - const querySpecFile = renderQuerySpecFile({ - action: params.action, - queryName: params.queryName, - boundaryRelativeDir: normalizeCliPath(path.join('src', 'features', params.featureName)), - queryPascalName, - queryCamelName, - requestFields: queryRequestFields, - responseFields: queryResponseFields, - sharedExecutorImportPath: sharedImports.executorImportPath, - sharedLoadSqlResourceImportPath: sharedImports.loadSqlResourceImportPath, - insertDefaultPolicy: params.insertDefaultPolicy - }); - const queryGeneratedRowMapperFile = renderGeneratedRowMapperFile({ - action: params.action, - queryPascalName, - responseFields: queryResponseFields, - boundarySource: querySpecFile, - sqlSource: sqlFile - }); - const readmeFile = renderReadmeFile({ - action: params.action, - featureName: params.featureName, - queryName: params.queryName, - tableName: params.table.canonicalName, - primaryKeyColumn: params.primaryKeyColumn, - generatedColumns: params.table.columns - .filter((column) => isGeneratedInsertColumn(column, params.primaryKeyColumn)) - .map((column) => column.name), - queryColumns: actionPlan.queryColumns.map((column) => column.name), - parameterColumns: actionPlan.requestColumns.map((column) => column.name), - defaultExpressionColumns: actionPlan.queryColumns.filter((column) => column.source === 'ddl-default').map((column) => column.name), - omittedDefaultColumns: actionPlan.queryColumns.filter((column) => column.source === 'omitted-db-default').map((column) => column.name), - insertDefaultPolicy: params.insertDefaultPolicy - }); - - return { - entrySpecFile, - inputFile, - workflowFile, - outputFile, - entrySpecTestFile: renderEntrySpecTestFile({ - featureName: params.featureName, - queryName: params.queryName, - pascalName, - hasRequestFields: requestFields.length > 0 - }), - querySpecFile, - querySqlFile: sqlFile, - queryGeneratedRowMapperFile, - readmeFile, - featureQueryExecutorFile: sharedSupportFiles.featureQueryExecutorFile, - loadSqlResourceFile: sharedSupportFiles.loadSqlResourceFile - }; -} - -type RenderField = { - name: string; - sourceName: string; - typeScriptType: string; - parserKind: 'string' | 'number' | 'boolean' | 'jsonObject'; - nullable: boolean; - sourceType: string; -}; - -type QueryColumn = { - name: string; - expression: string; - source: 'param' | 'ddl-default' | 'omitted-db-default' | 'selected'; -}; - -type ActionPlan = { - action: FeatureAction; - requestColumns: ScaffoldColumnMetadata[]; - resultColumns: ScaffoldColumnMetadata[]; - queryColumns: QueryColumn[]; - writeColumns: QueryColumn[]; - whereColumns: QueryColumn[]; -}; - -function deriveQueryName(tableName: string, action: FeatureAction): string { - if (action === 'get-by-id' || action === 'list') { - return action; - } - return `${action}-${toFeatureResourceSegment(tableName)}`; -} - -function resolveExistingBoundaryFolder( - rootDir: string, - options: ExistingBoundaryQueryCommandOptions -): { - boundaryDir: string; - boundaryPath: string; - resolutionSource: 'feature' | 'boundary-dir' | 'cwd'; -} { - if (options.feature && options.boundaryDir) { - throw new Error('Use either --feature or --boundary-dir, not both.'); - } - - if (options.feature) { - const featureName = normalizeFeatureName(options.feature); - const boundaryDir = path.join(rootDir, 'src', 'features', featureName); - return { - boundaryDir, - boundaryPath: toProjectRelativePath(rootDir, boundaryDir), - resolutionSource: 'feature' - }; - } - - if (options.boundaryDir) { - const boundaryDir = path.resolve(rootDir, options.boundaryDir); - return { - boundaryDir, - boundaryPath: toProjectRelativePath(rootDir, boundaryDir), - resolutionSource: 'boundary-dir' - }; - } - - const boundaryDir = options.workingDir ?? process.cwd(); - return { - boundaryDir, - boundaryPath: toProjectRelativePath(rootDir, boundaryDir), - resolutionSource: 'cwd' - }; -} - -function collectGeneratedMapperSpecs(rootDir: string, featureName: string, queryName?: string): GeneratedMapperSpec[] { - const featureDir = path.join(rootDir, 'src', 'features', featureName); - const queriesDir = path.join(featureDir, 'queries'); - if (!existsSync(queriesDir) || !statSync(queriesDir).isDirectory()) { - throw new Error(`Feature queries directory not found: ${toProjectRelativePath(rootDir, queriesDir)}.`); - } - - const queryNames = queryName === undefined - ? readdirSync(queriesDir) - .filter((entry) => { - const candidate = path.join(queriesDir, entry); - return statSync(candidate).isDirectory() && existsSync(path.join(candidate, 'boundary.ts')); - }) - .sort() - : [normalizeChildQueryName(queryName)]; - - if (queryNames.length === 0) { - throw new Error(`No query boundary files were found under ${toProjectRelativePath(rootDir, queriesDir)}.`); - } - - return queryNames.map((query) => readGeneratedMapperSpec(rootDir, featureName, query)); -} - -function readGeneratedMapperSpec(rootDir: string, featureName: string, queryName: string): GeneratedMapperSpec { - const queryDir = path.join(rootDir, 'src', 'features', featureName, 'queries', queryName); - const boundaryFile = path.join(queryDir, 'boundary.ts'); - const querySqlFile = path.join(queryDir, `${queryName}.sql`); - const generatedFile = path.join(queryDir, 'generated', 'row-mapper.ts'); - if (!existsSync(boundaryFile) || !statSync(boundaryFile).isFile()) { - throw new Error(`Query boundary not found: ${toProjectRelativePath(rootDir, boundaryFile)}.`); - } - if (!existsSync(querySqlFile) || !statSync(querySqlFile).isFile()) { - throw new Error(`Query SQL not found: ${toProjectRelativePath(rootDir, querySqlFile)}.`); - } - - const boundarySource = readFileSync(boundaryFile, 'utf8'); - const sqlSource = readFileSync(querySqlFile, 'utf8'); - const queryPascalName = extractQueryPascalName(boundarySource, rootDir, boundaryFile); - const fieldNames = extractRowSchemaFieldNames(boundarySource, rootDir, boundaryFile); - const hasMany = extractGeneratedHasManyMetadata(boundarySource, fieldNames, rootDir, boundaryFile); - if (fieldNames.length === 0) { - throw new Error( - `Cannot generate row mapper for ${toProjectRelativePath(rootDir, boundaryFile)}: RowSchema has no fields.` - ); - } - - return { - featureName, - queryName, - queryPascalName, - mode: hasMany ? 'hasMany' : detectGeneratedMapperMode(boundarySource), - fieldNames, - hasMany, - boundaryHash: hashGeneratedMapperSource(boundarySource), - sqlHash: hashGeneratedMapperSource(sqlSource), - boundaryFile, - generatedFile - }; -} - -function hashGeneratedMapperSource(source: string): string { - return createHash('sha256').update(source.replace(/\r\n/g, '\n')).digest('hex'); -} - -function extractQueryPascalName(source: string, rootDir: string, boundaryFile: string): string { - const match = - /export type ([A-Za-z][A-Za-z0-9]*)Row = z\.infer;/.exec(source) ?? - /export interface ([A-Za-z][A-Za-z0-9]*)Row\s*\{/.exec(source); - if (!match) { - throw new Error( - `Cannot generate row mapper for ${toProjectRelativePath(rootDir, boundaryFile)}: exported Row type was not found.` - ); - } - return match[1]; -} - -function extractRowSchemaFieldNames(source: string, rootDir: string, boundaryFile: string): string[] { - const startToken = 'const RowSchema = z.object({'; - const start = source.indexOf(startToken); - if (start === -1) { - return extractRowInterfaceFieldNames(source, rootDir, boundaryFile); - } - const bodyStart = start + startToken.length; - const end = source.indexOf('\n})', bodyStart); - if (end === -1) { - throw new Error( - `Cannot generate row mapper for ${toProjectRelativePath(rootDir, boundaryFile)}: RowSchema end was not found.` - ); - } - - const fields: string[] = []; - for (const line of source.slice(bodyStart, end).split(/\r?\n/)) { - const match = /^\s*(?:(['"])(.*?)\1|([A-Za-z_$][A-Za-z0-9_$]*))\s*:/.exec(line); - if (!match) { - continue; - } - fields.push(match[2] ?? match[3]); - } - return fields; -} - -function extractRowInterfaceFieldNames(source: string, rootDir: string, boundaryFile: string): string[] { - const match = /export interface [A-Za-z][A-Za-z0-9]*Row\s*\{([\s\S]*?)\n\}/.exec(source); - if (!match) { - throw new Error( - `Cannot generate row mapper for ${toProjectRelativePath(rootDir, boundaryFile)}: row shape was not found.` - ); - } - - const fields: string[] = []; - for (const line of match[1].split(/\r?\n/)) { - const field = /^\s*(?:(['"])(.*?)\1|([A-Za-z_$][A-Za-z0-9_$]*))\??\s*:/.exec(line); - if (!field) { - continue; - } - fields.push(field[2] ?? field[3]); - } - return fields; -} - -function detectGeneratedMapperMode(source: string): GeneratedMapperMode { - if (source.includes('RowsToResult')) { - return 'list'; - } - if (source.includes('const QueryResultSchema = RowSchema.nullable();') || /export type [A-Za-z][A-Za-z0-9]*QueryResult = [A-Za-z][A-Za-z0-9]*Row \| null;/.test(source)) { - return 'optional'; - } - return 'single'; -} - -function extractGeneratedHasManyMetadata( - source: string, - fieldNames: string[], - rootDir: string, - boundaryFile: string -): GeneratedHasManyRelationMetadata | undefined { - const metadata = extractGeneratedMapperMetadata(source, rootDir, boundaryFile); - const hasMany = metadata?.relations?.hasMany; - if (hasMany === undefined) { - return undefined; - } - if (!Array.isArray(hasMany) || hasMany.length !== 1) { - throw new Error( - `Cannot generate hasMany row mapper for ${toProjectRelativePath(rootDir, boundaryFile)}: expected exactly one metadata.relations.hasMany entry.` - ); - } - const relation = hasMany[0] as GeneratedHasManyRelationMetadata; - validateGeneratedHasManyMetadata(relation, new Set(fieldNames), rootDir, boundaryFile); - return relation; -} - -function extractGeneratedMapperMetadata( - source: string, - rootDir: string, - boundaryFile: string -): { relations?: { hasMany?: unknown[] } } | undefined { - const namedMetadata = /(?:export\s+)?const\s+[A-Za-z0-9_$]*GeneratedMapperMetadata\s*=/.exec(source); - if (!namedMetadata) { - return undefined; - } - const objectStart = source.indexOf('{', namedMetadata.index); - if (objectStart === -1) { - throw new Error( - `Cannot parse generated mapper metadata for ${toProjectRelativePath(rootDir, boundaryFile)}: expected a JSON-compatible object literal after the *GeneratedMapperMetadata assignment.` - ); - } - const objectEnd = findMatchingBrace(source, objectStart); - if (objectEnd === -1) { - throw new Error( - `Cannot parse generated mapper metadata for ${toProjectRelativePath(rootDir, boundaryFile)}: metadata object was not closed.` - ); - } - const objectText = source.slice(objectStart, objectEnd + 1); - try { - return JSON.parse(objectText) as { relations?: { hasMany?: unknown[] } }; - } catch (cause) { - throw new Error( - `Cannot parse generated mapper metadata for ${toProjectRelativePath(rootDir, boundaryFile)}: metadata object literal must be JSON-compatible. Quote object keys and string values; do not use TypeScript identifiers, comments, spreads, computed values, or trailing commas inside the object literal. ${cause instanceof Error ? cause.message : String(cause)}` - ); - } -} - -function findMatchingBrace(source: string, startIndex: number): number { - let depth = 0; - let quote: '"' | "'" | '`' | undefined; - let escaped = false; - for (let index = startIndex; index < source.length; index += 1) { - const char = source[index]; - if (quote) { - if (escaped) { - escaped = false; - continue; - } - if (char === '\\') { - escaped = true; - continue; - } - if (char === quote) { - quote = undefined; - } - continue; - } - if (char === '"' || char === "'" || char === '`') { - quote = char; - continue; - } - if (char === '{') { - depth += 1; - continue; - } - if (char === '}') { - depth -= 1; - if (depth === 0) { - return index; - } - } - } - return -1; -} - -function validateGeneratedHasManyMetadata( - relation: GeneratedHasManyRelationMetadata, - fieldNames: Set, - rootDir: string, - boundaryFile: string -): void { - const context = toProjectRelativePath(rootDir, boundaryFile); - if (relation?.kind !== 'hasMany') { - throw new Error(`Cannot generate hasMany row mapper for ${context}: relation kind must be "hasMany".`); - } - assertNonEmptyStringArray(relation.root?.key, 'root.key', context); - assertNonEmptyStringArray(relation.collection?.key, 'collection.key', context); - assertNonEmptyStringArray(relation.collection?.presence, 'collection.presence', context); - assertIdentifier(relation.collection?.property, 'collection.property', context); - assertColumnMap(relation.root?.columns, 'root.columns', fieldNames, context); - assertColumnMap(relation.collection?.columns, 'collection.columns', fieldNames, context); - for (const column of [ - ...relation.root.key, - ...relation.collection.key, - ...relation.collection.presence, - ]) { - if (!fieldNames.has(column)) { - throw new Error(`Cannot generate hasMany row mapper for ${context}: metadata column "${column}" is not declared in RowSchema.`); - } - } -} - -function assertNonEmptyStringArray(value: unknown, label: string, context: string): asserts value is string[] { - if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== 'string' || entry.length === 0)) { - throw new Error(`Cannot generate hasMany row mapper for ${context}: metadata.${label} must be a non-empty string array.`); - } -} - -function assertIdentifier(value: unknown, label: string, context: string): asserts value is string { - if (typeof value !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value)) { - throw new Error(`Cannot generate hasMany row mapper for ${context}: metadata.${label} must be an identifier string.`); - } -} - -function assertColumnMap( - value: unknown, - label: string, - fieldNames: Set, - context: string -): asserts value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) { - throw new Error(`Cannot generate hasMany row mapper for ${context}: metadata.${label} must be a non-empty object.`); - } - for (const [property, column] of Object.entries(value)) { - assertIdentifier(property, `${label} property`, context); - if (typeof column !== 'string' || !fieldNames.has(column)) { - throw new Error(`Cannot generate hasMany row mapper for ${context}: metadata.${label}.${property} column "${String(column)}" is not declared in RowSchema.`); - } - } -} - -function renderExistingBoundaryQueryScaffoldFiles(params: { - rootDir: string; - boundaryDir: string; - boundaryRelativeDir: string; - queryName: string; - action: FeatureAction; - table: DdlTableMetadata; - primaryKeyColumn: string; - insertDefaultPolicy: InsertDefaultPolicy; -}): { - querySpecFile: string; - querySqlFile: string; - queryGeneratedRowMapperFile: string; - featureQueryExecutorFile: string; - loadSqlResourceFile: string; -} { - const sharedImports = resolveFeatureSharedImportPaths( - params.rootDir, - path.join(params.boundaryDir, 'queries', params.queryName), - 'Feature query scaffold' - ); - const queryPascalName = toPascalCase(params.queryName); - const queryCamelName = toCamelCase(params.queryName); - const actionPlan = buildActionPlan(params.action, params.table, params.primaryKeyColumn, params.insertDefaultPolicy); - const requestFields = actionPlan.requestColumns.map((column) => toRenderField(column, { boundary: 'query' })); - const responseFields = actionPlan.resultColumns.map((column) => toRenderField(column, { boundary: 'query' })); - const sharedSupportFiles = renderFeatureSharedSupportFiles(); - const querySpecFile = renderQuerySpecFile({ - action: params.action, - queryName: params.queryName, - boundaryRelativeDir: params.boundaryRelativeDir, - queryPascalName, - queryCamelName, - requestFields, - responseFields, - sharedExecutorImportPath: sharedImports.executorImportPath, - sharedLoadSqlResourceImportPath: sharedImports.loadSqlResourceImportPath, - insertDefaultPolicy: params.insertDefaultPolicy - }); - const querySqlFile = renderActionSql(actionPlan, params.table.canonicalName, params.primaryKeyColumn); - - return { - querySpecFile, - queryGeneratedRowMapperFile: renderGeneratedRowMapperFile({ - action: params.action, - queryPascalName, - responseFields, - boundarySource: querySpecFile, - sqlSource: querySqlFile - }), - querySqlFile, - featureQueryExecutorFile: sharedSupportFiles.featureQueryExecutorFile, - loadSqlResourceFile: sharedSupportFiles.loadSqlResourceFile - }; -} - -function assertExistingBoundaryFolderContract(rootDir: string, boundaryDir: string): void { - const relativeBoundary = toProjectRelativePath(rootDir, boundaryDir); - if (relativeBoundary.startsWith('..')) { - throw new Error(`Boundary folder must stay inside the project root: ${boundaryDir}.`); - } - if (!existsSync(boundaryDir)) { - throw new Error(`Existing boundary folder not found: ${relativeBoundary}.`); - } - if (!statSync(boundaryDir).isDirectory()) { - throw new Error(`Boundary target must be a directory: ${relativeBoundary}.`); - } - - const entrySpecFile = path.join(boundaryDir, 'boundary.ts'); - if (!existsSync(entrySpecFile)) { - throw new Error(`Boundary folder must contain boundary.ts: ${relativeBoundary}.`); - } - if (!statSync(entrySpecFile).isFile()) { - throw new Error(`Boundary entrypoint must be a file: ${normalizeCliPath(path.join(relativeBoundary, 'boundary.ts'))}.`); - } - - const queriesDir = path.join(boundaryDir, 'queries'); - if (existsSync(queriesDir) && !statSync(queriesDir).isDirectory()) { - throw new Error(`Expected queries/ to be a directory under ${relativeBoundary}.`); - } -} - -function assertExistingBoundaryQueryWriteSafety(paths: ExistingBoundaryQueryScaffoldPaths): void { - if (existsSync(paths.queryDir)) { - throw new Error( - `Query boundary already exists: ${normalizeCliPath(path.join(path.basename(paths.boundaryDir), 'queries', path.basename(paths.queryDir)))}.` - ); - } -} - -function resolveFeatureSharedImportPaths( - rootDir: string, - queryDir: string, - commandLabel: string -): { - executorImportPath: string; - loadSqlResourceImportPath: string; -} { - const importAliasSupport = inspectImportAliasSupport(rootDir, { - packageImportKey: '#features/*.js', - tsconfigPathKey: '#features/*', - vitestAliasPrefix: '#features' - }); - if (importAliasSupport === 'partial') { - emitDiagnostic({ - code: 'feature-scaffold.partial-alias-fallback', - severity: 'warning', - message: `${commandLabel} found partial #features alias configuration. Falling back to relative imports for generated files. Configure package.json#imports, tsconfig.json compilerOptions.paths, and vitest.config.ts resolve.alias together to enable stable #features imports.` - }); - } else if (importAliasSupport === 'supported') { - return { - executorImportPath: FEATURE_SHARED_EXECUTOR_IMPORT_PATH, - loadSqlResourceImportPath: FEATURE_SHARED_LOAD_SQL_RESOURCE_IMPORT_PATH - }; - } - - return { - executorImportPath: normalizeCliPath( - path.relative(queryDir, path.join(rootDir, 'src', 'features', '_shared', 'featureQueryExecutor.js')) - ), - loadSqlResourceImportPath: normalizeCliPath( - path.relative(queryDir, path.join(rootDir, 'src', 'features', '_shared', 'loadSqlResource.js')) - ) - }; -} - -function renderFeatureSharedSupportFiles(): { - featureQueryExecutorFile: string; - loadSqlResourceFile: string; -} { - return { - featureQueryExecutorFile: [ - '// Shared runtime contract for scaffolded features.', - '// Inject your DB execution implementation at this seam from the application runtime.', - 'export interface FeatureQueryExecutor {', - ' query(sql: string, params: Record): Promise;', - '}', - '' - ].join('\n'), - loadSqlResourceFile: [ - "import { readFileSync } from 'node:fs';", - "import path from 'node:path';", - '', - 'export function loadSqlResource(currentDir: string, relativePath: string): string {', - " return readFileSync(path.join(currentDir, relativePath), 'utf8');", - '}', - '' - ].join('\n') - }; -} - -function buildActionPlan( - action: FeatureAction, - table: DdlTableMetadata, - primaryKeyColumn: string, - insertDefaultPolicy: InsertDefaultPolicy -): ActionPlan { - if (action === 'insert') { - const queryColumns = selectInsertSqlColumns(table, primaryKeyColumn, insertDefaultPolicy); - const writeColumns = queryColumns.filter((column) => column.source !== 'omitted-db-default'); - return { - action, - requestColumns: table.columns - .filter((column) => !isGeneratedInsertColumn(column, primaryKeyColumn) && column.defaultValue == null), - resultColumns: [requireColumn(table, primaryKeyColumn)], - queryColumns, - writeColumns, - whereColumns: [] - }; - } - - if (action === 'update') { - const primaryKey = requireColumn(table, primaryKeyColumn); - const mutableColumns = table.columns.filter((column) => !isGeneratedInsertColumn(column, primaryKeyColumn) && column.name !== primaryKeyColumn); - if (mutableColumns.length === 0) { - throw new Error(`Update scaffold requires at least one mutable non-primary-key column: ${table.canonicalName}.`); - } - const whereColumns = [{ name: primaryKey.name, expression: `:${primaryKey.name}`, source: 'param' as const }]; - const writeColumns = mutableColumns.map((column) => ({ name: column.name, expression: `:${column.name}`, source: 'param' as const })); - return { - action, - requestColumns: [primaryKey, ...mutableColumns], - resultColumns: [primaryKey], - queryColumns: [...whereColumns, ...writeColumns], - writeColumns, - whereColumns - }; - } - - const primaryKey = requireColumn(table, primaryKeyColumn); - const whereColumns = [{ name: primaryKey.name, expression: `:${primaryKey.name}`, source: 'param' as const }]; - if (action === 'delete') { - return { - action, - requestColumns: [primaryKey], - resultColumns: [primaryKey], - queryColumns: whereColumns, - writeColumns: [], - whereColumns - }; - } - - if (action === 'get-by-id') { - return { - action, - requestColumns: [primaryKey], - resultColumns: [...table.columns], - queryColumns: [ - ...whereColumns, - ...table.columns.map((column) => ({ - name: column.name, - expression: quoteSqlIdentifier(column.name), - source: 'selected' as const - })) - ], - writeColumns: [], - whereColumns - }; - } - - return { - action, - requestColumns: [], - resultColumns: [...table.columns], - queryColumns: table.columns.map((column) => ({ - name: column.name, - expression: quoteSqlIdentifier(column.name), - source: 'selected' as const - })), - writeColumns: [], - whereColumns: [] - }; -} - -function selectInsertSqlColumns( - table: DdlTableMetadata, - primaryKeyColumn: string, - insertDefaultPolicy: InsertDefaultPolicy -): QueryColumn[] { - return table.columns - .filter((column) => !isGeneratedInsertColumn(column, primaryKeyColumn)) - .map((column) => ({ - name: column.name, - expression: column.defaultValue ?? `:${column.name}`, - source: column.defaultValue == null - ? 'param' - : insertDefaultPolicy === 'omit-db-defaults' - ? 'omitted-db-default' - : 'ddl-default' - })); -} - -function requireColumn(table: DdlTableMetadata, columnName: string): ScaffoldColumnMetadata { - const column = table.columns.find((candidate) => candidate.name === columnName); - if (!column) { - throw new Error(`Column ${columnName} was not found in ${table.canonicalName}.`); - } - return column; -} - -function isGeneratedInsertColumn(column: ScaffoldColumnMetadata, primaryKeyColumn: string): boolean { - if (column.hasGeneratedIdentity) { - return true; - } - if (column.name !== primaryKeyColumn) { - return false; - } - const normalizedType = (column.typeName ?? '').trim().toLowerCase(); - if ( - normalizedType === 'serial' - || normalizedType === 'serial2' - || normalizedType === 'serial4' - || normalizedType === 'serial8' - || normalizedType === 'bigserial' - || normalizedType === 'smallserial' - ) { - return true; - } - return /^nextval\s*\(/i.test(column.defaultValue ?? ''); -} - -function renderActionSql(plan: ActionPlan, tableName: string, primaryKeyColumn: string): string { - const quotedTableName = quoteQualifiedIdentifier(tableName); - const quotedPrimaryKeyColumn = quoteSqlIdentifier(primaryKeyColumn); - if (plan.action === 'insert') { - const policyReviewComment = '-- TODO: Review INSERT default-column policy before using this scaffold in production.'; - if (plan.writeColumns.length === 0) { - return [ - policyReviewComment, - `insert into ${quotedTableName}`, - 'default values', - `returning ${quotedPrimaryKeyColumn};`, - '' - ].join('\n'); - } - - return [ - policyReviewComment, - `insert into ${quotedTableName} (`, - plan.writeColumns.map((column) => ` ${quoteSqlIdentifier(column.name)}`).join(',\n'), - ') values (', - plan.writeColumns.map((column) => ` ${column.expression}`).join(',\n'), - `) returning ${quotedPrimaryKeyColumn};`, - '' - ].join('\n'); - } - - if (plan.action === 'update') { - return [ - `update ${quotedTableName}`, - 'set', - plan.writeColumns.map((column) => ` ${quoteSqlIdentifier(column.name)} = ${column.expression}`).join(',\n'), - 'where', - plan.whereColumns.map((column, index) => ` ${quoteSqlIdentifier(column.name)} = ${column.expression}${index < plan.whereColumns.length - 1 ? ' and' : ''}`).join('\n'), - `returning ${quotedPrimaryKeyColumn};`, - '' - ].join('\n'); - } - - if (plan.action === 'get-by-id') { - return [ - 'select', - plan.resultColumns.map((column) => ` ${quoteSqlIdentifier(column.name)}`).join(',\n'), - `from ${quotedTableName}`, - 'where', - plan.whereColumns.map((column, index) => ` ${quoteSqlIdentifier(column.name)} = ${column.expression}${index < plan.whereColumns.length - 1 ? ' and' : ''}`).join('\n'), - '' - ].join('\n'); - } - - if (plan.action === 'list') { - return [ - 'select', - plan.resultColumns.map((column) => ` ${quoteSqlIdentifier(column.name)}`).join(',\n'), - `from ${quotedTableName}`, - 'order by', - ` ${quotedPrimaryKeyColumn} asc`, - 'limit :limit;', - '' - ].join('\n'); - } - - return [ - `delete from ${quotedTableName}`, - 'where', - plan.whereColumns.map((column, index) => ` ${quoteSqlIdentifier(column.name)} = ${column.expression}${index < plan.whereColumns.length - 1 ? ' and' : ''}`).join('\n'), - `returning ${quotedPrimaryKeyColumn};`, - '' - ].join('\n'); -} - -function quoteQualifiedIdentifier(value: string): string { - return value.split('.').map((segment) => quoteSqlIdentifier(segment)).join('.'); -} - -function quoteSqlIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"`; -} - -function toRenderField(column: ScaffoldColumnMetadata, options: { boundary: 'feature' | 'query' }): RenderField { - const fieldName = options.boundary === 'feature' ? toCamelCase(column.name) : column.name; - const typeName = (column.typeName ?? '').trim().toLowerCase(); - if (typeName === 'json' || typeName === 'jsonb') { - return { - name: fieldName, - sourceName: column.name, - typeScriptType: column.isNotNull ? 'Record' : 'Record | null', - parserKind: 'jsonObject', - nullable: !column.isNotNull, - sourceType: column.typeName ?? 'jsonb' - }; - } - if (isBigIntLikeType(typeName)) { - return { - name: fieldName, - sourceName: column.name, - typeScriptType: column.isNotNull ? 'string' : 'string | null', - parserKind: 'string', - nullable: !column.isNotNull, - sourceType: column.typeName ?? 'bigint' - }; - } - if (isStringEncodedNumericType(typeName)) { - return { - name: fieldName, - sourceName: column.name, - typeScriptType: column.isNotNull ? 'string' : 'string | null', - parserKind: 'string', - nullable: !column.isNotNull, - sourceType: column.typeName ?? 'numeric' - }; - } - if (isNumberType(typeName)) { - return { - name: fieldName, - sourceName: column.name, - typeScriptType: column.isNotNull ? 'number' : 'number | null', - parserKind: 'number', - nullable: !column.isNotNull, - sourceType: column.typeName ?? 'numeric' - }; - } - if (typeName === 'boolean' || typeName === 'bool') { - return { - name: fieldName, - sourceName: column.name, - typeScriptType: column.isNotNull ? 'boolean' : 'boolean | null', - parserKind: 'boolean', - nullable: !column.isNotNull, - sourceType: column.typeName ?? 'boolean' - }; - } - return { - name: fieldName, - sourceName: column.name, - typeScriptType: column.isNotNull ? 'string' : 'string | null', - parserKind: 'string', - nullable: !column.isNotNull, - sourceType: column.typeName ?? 'text' - }; -} - -function isBigIntLikeType(typeName: string): boolean { - return [ - 'bigserial', - 'serial8', - 'int8', - 'bigint' - ].includes(typeName); -} - -function isNumberType(typeName: string): boolean { - return [ - 'serial', - 'serial2', - 'serial4', - 'smallserial', - 'int', - 'int2', - 'int4', - 'integer', - 'smallint', - 'real', - 'float', - 'float4', - 'float8', - 'double precision' - ].includes(typeName); -} - -function isStringEncodedNumericType(typeName: string): boolean { - return [ - 'numeric', - 'decimal' - ].includes(typeName); -} - -function renderEntrySpecFile(params: { - action: FeatureAction; - featureName: string; - pascalName: string; - entryCamelName: string; - queryName: string; - queryPascalName: string; - queryCamelName: string; - requestFields: RenderField[]; - responseFields: RenderField[]; - insertDefaultPolicy: InsertDefaultPolicy; -}): string { - return renderFeatureBoundaryFile(params); -} - -function renderFeatureInputFile(params: { - pascalName: string; - requestFields: RenderField[]; -}): string { - const normalizeLines = params.requestFields.length === 0 - ? [' return request;'] - : [ - ' return {', - ...params.requestFields.map((field) => { - if (field.parserKind === 'string') { - return ` ${field.name}: request.${field.name}${field.nullable ? " === null ? null : request." + field.name + ".trim()" : '.trim()'},`; - } - return ` ${field.name}: request.${field.name},`; - }), - ' };' - ]; - const rejectLines = params.requestFields - .filter((field) => field.parserKind === 'string') - .flatMap((field) => { - const valueRef = `request.${field.name}`; - if (field.nullable) { - return [ - ` if (${valueRef} !== null && ${valueRef}.length === 0) {`, - ` throw new Error('${params.pascalName}Request.${field.name} must not be empty after trim().');`, - ' }' - ]; - } - return [ - ` if (${valueRef}.length === 0) {`, - ` throw new Error('${params.pascalName}Request.${field.name} must not be empty after trim().');`, - ' }' - ]; - }); - - return [ - renderObjectType(`${params.pascalName}Request`, params.requestFields, { - exported: true, - property: 'name' - }), - '', - '/** Parses the raw feature request at the feature boundary. */', - ...renderStrictObjectParser({ - functionName: 'parseRawRequest', - typeName: `${params.pascalName}Request`, - fields: params.requestFields, - label: `${params.pascalName}Request`, - property: 'name' - }), - '', - '/** Normalizes the parsed feature request for downstream feature logic. */', - `function normalizeRequest(request: ${params.pascalName}Request): ${params.pascalName}Request {`, - ...normalizeLines, - '}', - '', - '/** Rejects feature requests that violate feature-level rules. */', - `function rejectRequest(request: ${params.pascalName}Request): void {`, - ...(rejectLines.length > 0 - ? rejectLines - : [' // Add feature-level reject rules here when follow-up requirements appear.']), - '}', - '', - `export function parseRequest(raw: unknown): ${params.pascalName}Request {`, - ' const request = normalizeRequest(parseRawRequest(raw));', - ' rejectRequest(request);', - ' return request;', - '}', - '', - ...renderStrictObjectParserSupport(), - '' - ].join('\n'); -} - -function renderFeatureBoundaryFile(params: { - featureName: string; -}): string { - return [ - "import type { FeatureQueryExecutor } from '../_shared/featureQueryExecutor.js';", - "import * as input from './input.js';", - "import * as output from './output.js';", - "import * as workflow from './workflow.js';", - '', - "export type { " + toPascalCase(params.featureName) + "Request } from './input.js';", - "export type { " + toPascalCase(params.featureName) + "Response } from './output.js';", - '', - '/**', - ` * Executes the ${params.featureName} feature boundary.`, - ' *', - ' * Review order:', - ' * 1. parse input', - ' * 2. execute workflow', - ' * 3. build output', - ' */', - 'export async function execute(', - ' executor: FeatureQueryExecutor,', - ' rawRequest: unknown', - `): Promise {`, - ' const request = input.parseRequest(rawRequest);', - ' const created = await workflow.execute(executor, request);', - ' return output.buildResult(created);', - '}', - '' - ].join('\n'); -} - -function renderFeatureWorkflowFile(params: { - action: FeatureAction; - featureName: string; - pascalName: string; - queryName: string; - queryPascalName: string; - requestFields: RenderField[]; - insertDefaultPolicy: InsertDefaultPolicy; -}): string { - const queryParamsRequestName = params.requestFields.length === 0 ? '_request' : 'request'; - return [ - "import type { FeatureQueryExecutor } from '../_shared/featureQueryExecutor.js';", - `import type { ${params.pascalName}Request } from './input.js';`, - `import {`, - ` execute${params.queryPascalName}QuerySpec,`, - ` type ${params.queryPascalName}QueryParams,`, - ` type ${params.queryPascalName}QueryResult`, - `} from './queries/${params.queryName}/boundary.js';`, - '', - `export type ${params.pascalName}WorkflowResult = ${params.queryPascalName}QueryResult;`, - '', - `export type ${params.pascalName}Queries = {`, - ` execute${params.queryPascalName}: (`, - ' executor: FeatureQueryExecutor,', - ` params: ${params.queryPascalName}QueryParams`, - ` ) => Promise<${params.queryPascalName}QueryResult>;`, - '};', - '', - `const defaultQueries: ${params.pascalName}Queries = {`, - ` execute${params.queryPascalName}: execute${params.queryPascalName}QuerySpec`, - '};', - '', - ...renderWorkflowDesignComments(params.action, params.insertDefaultPolicy), - `export async function execute(`, - ' executor: FeatureQueryExecutor,', - ` request: ${params.pascalName}Request,`, - ` queries: ${params.pascalName}Queries = defaultQueries`, - `): Promise<${params.pascalName}WorkflowResult> {`, - ` return queries.execute${params.queryPascalName}(executor, toQueryParams(request));`, - '}', - '', - '/** Maps the feature request into query params for the query spec. */', - `function toQueryParams(${queryParamsRequestName}: ${params.pascalName}Request): ${params.queryPascalName}QueryParams {`, - ...renderTypedReturnObject(params.requestFields, `${params.queryPascalName}QueryParams`), - '}', - '' - ].join('\n'); -} - -function renderWorkflowDesignComments(action: FeatureAction, insertDefaultPolicy: InsertDefaultPolicy): string[] { - const lines = [ - '/**', - ' * Runs the feature workflow after input parsing.', - ' *', - ' * Query functions are injected for workflow tests so tests do not infer query identity', - ' * from SQL text that may be transformed by rewrite or pipeline processing.' - ]; - if (action === 'insert') { - lines.push( - insertDefaultPolicy === 'explicit-defaults' - ? ' * DDL-backed defaults are intentionally visible in the SQL resource for review.' - : ' * DB-default columns are intentionally omitted so the database assigns them.' - ); - } - lines.push(' */'); - return lines; -} - -function renderFeatureOutputFile(params: { - action: FeatureAction; - pascalName: string; - queryName: string; - queryPascalName: string; - responseFields: RenderField[]; -}): string { - return [ - `import type { ${params.queryPascalName}QueryResult } from './queries/${params.queryName}/boundary.js';`, - '', - ...renderFeatureResponseType(params.pascalName, params.action, params.responseFields), - '', - 'export function buildResult(result: ' + params.queryPascalName + 'QueryResult): ' + params.pascalName + 'Response {', - ...renderFeatureOutputBuildLines(params.action, params.responseFields), - '}', - '' - ].join('\n'); -} - -function renderFeatureResponseType(pascalName: string, action: FeatureAction, fields: RenderField[]): string[] { - const objectLines = renderTypeObjectLines(fields, ' '); - if (action === 'get-by-id') { - return [ - `export type ${pascalName}Response = {`, - ...objectLines, - '} | null;' - ]; - } - if (action === 'list') { - return [ - `export type ${pascalName}Response = {`, - ' items: Array<{', - ...renderTypeObjectLines(fields, ' '), - ' }>;', - '};' - ]; - } - return [ - `export type ${pascalName}Response = {`, - ...objectLines, - '};' - ]; -} - -function renderTypeObjectLines(fields: RenderField[], indent: string): string[] { - if (fields.length === 0) { - return []; - } - return fields.map((field) => `${indent}${field.name}: ${field.typeScriptType};`); -} - -function renderFeatureOutputBuildLines(action: FeatureAction, fields: RenderField[]): string[] { - if (action === 'get-by-id') { - return [ - ' if (result === null) {', - ' return null;', - ' }', - ...renderResultObjectLines('result', fields, ' ') - ]; - } - if (action === 'list') { - return [ - ' return {', - ' items: result.items.map((item) => ({', - ...fields.map((field) => ` ${field.name}: item.${field.sourceName},`), - ' }))', - ' };' - ]; - } - return renderResultObjectLines('result', fields, ' '); -} - -function renderResultObjectLines(sourceName: string, fields: RenderField[], indent: string): string[] { - if (fields.length === 0) { - return [`${indent}return {};`]; - } - return [ - `${indent}return {`, - ...fields.map((field) => `${indent} ${field.name}: ${sourceName}.${field.sourceName},`), - `${indent}};` - ]; -} - -function renderEntrySpecTestFile(params: { - featureName: string; - queryName: string; - pascalName: string; - hasRequestFields: boolean; -}): string { - const entrypointImportPath = '../boundary.js'; - const sharedExecutorImportPath = '../../_shared/featureQueryExecutor.js'; - - if (!params.hasRequestFields) { - return [ - "import { test } from 'vitest';", - '', - `test.todo('cover feature boundary behavior for ${params.featureName}/${params.queryName}');`, - `test.todo('cover normalization and response mapping for ${params.pascalName} boundary');`, - '', - '// AI follow-up note:', - `// Keep the real assertions in this file if the feature boundary needs more than mock-based boundary checks.`, - `// The query-boundary contract lives in queries/${params.queryName}/tests/${params.queryName}.boundary.ztd.test.ts.`, - '' - ].join('\n'); - } - - return [ - "import { expect, test } from 'vitest';", - '', - `import { execute } from '${entrypointImportPath}';`, - `import type { FeatureQueryExecutor } from '${sharedExecutorImportPath}';`, - '', - 'function createGuardedExecutor(): FeatureQueryExecutor {', - ' return {', - ' async query() {', - ` throw new Error('Feature boundary tests stay mock-based for ${params.featureName}; keep DB-backed execution in the boundary lane.');`, - ' }', - ' };', - '}', - '', - `test('rejects invalid feature input at the feature boundary for ${params.featureName}/${params.queryName}', async () => {`, - ` await expect(execute(createGuardedExecutor(), {})).rejects.toThrow();`, - '});', - '', - `test.todo('cover normalization and response mapping for ${params.pascalName} boundary');`, - '', - '// AI follow-up note:', - `// Keep the real assertions in this file if the feature boundary needs more than mock-based boundary checks.`, - `// The query-boundary contract lives in queries/${params.queryName}/tests/${params.queryName}.boundary.ztd.test.ts.`, - '' - ].join('\n'); -} - -function renderQuerySpecFile(params: { - action: FeatureAction; - queryName: string; - boundaryRelativeDir: string; - queryPascalName: string; - queryCamelName: string; - requestFields: RenderField[]; - responseFields: RenderField[]; - sharedExecutorImportPath: string; - sharedLoadSqlResourceImportPath: string; - insertDefaultPolicy: InsertDefaultPolicy; -}): string { - if (params.action === 'get-by-id') { - return renderGetByIdQuerySpecFile(params); - } - if (params.action === 'list') { - return renderListQuerySpecFile(params); - } - - return [ - "import { dirname } from 'node:path';", - "import { fileURLToPath } from 'node:url';", - '', - `import type { FeatureQueryExecutor } from '${params.sharedExecutorImportPath}';`, - `import { loadSqlResource } from '${params.sharedLoadSqlResourceImportPath}';`, - `import { map${params.queryPascalName}RowToResult } from './generated/row-mapper.js';`, - '', - 'const __dirname = dirname(fileURLToPath(import.meta.url));', - `const ${params.queryCamelName}SqlResource = loadSqlResource(__dirname, '${params.queryName}.sql');`, - '', - ...renderQuerySpecBoundaryComments(params.action, params.insertDefaultPolicy), - renderObjectType(`${params.queryPascalName}QueryParams`, params.requestFields, { - exported: true, - property: 'name' - }), - '', - renderObjectType(`${params.queryPascalName}QueryResult`, [params.responseFields[0]], { - exported: true, - property: 'name' - }), - '', - renderTypeInterface(`${params.queryPascalName}Row`, [params.responseFields[0]], true), - '', - '/** Parses raw query params at the query boundary. */', - ...renderStrictObjectParser({ - functionName: 'parseQueryParams', - typeName: `${params.queryPascalName}QueryParams`, - fields: params.requestFields, - label: `${params.queryPascalName}QueryParams`, - property: 'name' - }), - '', - '/** Loads the single row for the write baseline. */', - `async function loadSingleRow(executor: FeatureQueryExecutor, sql: string, params: Record): Promise<${params.queryPascalName}Row> {`, - ` const rows = await executor.query<${params.queryPascalName}Row>(sql, params);`, - ' if (rows.length !== 1) {', - ` throw new Error('${params.queryPascalName}QuerySpec expected exactly one row.');`, - ' }', - ' return rows[0];', - '}', - '', - '/** Executes the query boundary flow for this query spec. */', - `export async function execute${params.queryPascalName}QuerySpec(`, - ` executor: FeatureQueryExecutor,`, - ` rawParams: unknown`, - `): Promise<${params.queryPascalName}QueryResult> {`, - ' const params = parseQueryParams(rawParams);', - ` const row = await loadSingleRow(executor, ${params.queryCamelName}SqlResource, params);`, - ` return map${params.queryPascalName}RowToResult(row);`, - '}', - '', - ...renderStrictObjectParserSupport(), - '' - ].join('\n'); -} - -function renderReadmeFile(params: { - action: FeatureAction; - featureName: string; - queryName: string; - tableName: string; - primaryKeyColumn: string; - generatedColumns: string[]; - queryColumns: string[]; - parameterColumns: string[]; - defaultExpressionColumns: string[]; - omittedDefaultColumns: string[]; - insertDefaultPolicy: InsertDefaultPolicy; -}): string { - const generatedColumnsLine = params.action === 'insert' - ? params.generatedColumns.length > 0 - ? `- Generated / identity / sequence-backed columns excluded at scaffold time: ${params.generatedColumns.map((name) => `\`${name}\``).join(', ')}.` - : '- No generated / identity / sequence-backed columns were detected for exclusion in this scaffold.' - : params.action === 'get-by-id' || params.action === 'list' - ? '- Generated / identity handling is unchanged in this read scaffold because the baseline only reads the selected row shape.' - : '- Generated / identity handling remains explicit in this write scaffold; no extra generated-column behavior is inferred here.'; - const queryColumnsLine = params.queryColumns.length > 0 - ? `- Initial ${params.action} query columns: ${params.queryColumns.map((name) => `\`${name}\``).join(', ')}.` - : `- Initial ${params.action} query does not require caller-visible write columns.`; - const parameterColumnsLine = params.parameterColumns.length > 0 - ? `- Caller-supplied request/query params: ${params.parameterColumns.map((name) => `\`${name}\``).join(', ')}.` - : '- The baseline keeps caller-supplied request/query params empty until the use case requires explicit inputs.'; - const defaultExpressionColumnsLine = params.action === 'insert' && params.defaultExpressionColumns.length > 0 - ? `- DDL-backed default expressions written directly into SQL: ${params.defaultExpressionColumns.map((name) => `\`${name}\``).join(', ')}.` - : params.action === 'insert' - ? params.insertDefaultPolicy === 'omit-db-defaults' && params.omittedDefaultColumns.length > 0 - ? `- DB-default columns omitted from INSERT so the database assigns them: ${params.omittedDefaultColumns.map((name) => `\`${name}\``).join(', ')}.` - : '- No general insert columns used DDL-backed default expressions in this scaffold.' - : params.action === 'get-by-id' || params.action === 'list' - ? '- Read baselines do not infer additional filter or default-expression policy beyond the explicit SQL and spec contract.' - : '- Write baselines do not infer additional default-expression or policy behavior beyond the explicit SQL and spec contract.'; - const insertDefaultPolicyLines = params.action === 'insert' - ? [ - `- INSERT default-column policy: \`${params.insertDefaultPolicy}\`.`, - params.insertDefaultPolicy === 'explicit-defaults' - ? '- `explicit-defaults` copies DDL default expressions into SQL so reviewers can see the exact assigned value in the generated query.' - : '- `omit-db-defaults` leaves DB-default columns out of the INSERT column list so the database assigns them at execution time.', - '- TODO: Review this default-column policy before treating the scaffold as business-safe; choose `explicit-defaults` when the SQL must show the assignment, and `omit-db-defaults` when the database default is the intended runtime behavior.' - ] - : []; - - return [ - `# ${params.featureName}`, - '', - '## Purpose', - '', - `Scaffold a minimal ${params.action} feature skeleton for \`${params.tableName}\` with explicit feature, DB, and transport boundaries.`, - '', - '## Fixed feature layout contract', - '', - '```text', - FIXED_LAYOUT_DESCRIPTION, - '```', - '', - '## CLI-created files', - '', - '- `boundary.ts`', - '- `input.ts`', - '- `workflow.ts`', - '- `output.ts`', - `- \`tests/${params.featureName}.boundary.test.ts\``, - `- \`queries/${params.queryName}/boundary.ts\``, - `- \`queries/${params.queryName}/${params.queryName}.sql\``, - `- \`queries/${params.queryName}/generated/row-mapper.ts\``, - '- `README.md`', - '', - '## Shared helper files created by the CLI when missing', - '', - '- `src/features/_shared/featureQueryExecutor.ts`', - '- `src/features/_shared/loadSqlResource.ts`', - '- Thin generated query execution helpers with no standard runtime catalog dependency', - '', - '## CLI-owned generated files', - '', - `- \`queries/${params.queryName}/generated/row-mapper.ts\``, - `- \`generated/*\` is CLI-owned, machine-owned, and refreshable. If deleted or drifted, run \`ztd feature generated-mapper generate --feature ${params.featureName} --query ${params.queryName}\` to recreate it.`, - `- CI/test should run \`ztd feature generated-mapper check --feature ${params.featureName}\` so contract drift fails with a regeneration command instead of depending on voluntary cleanup.`, - '', - '## Created by `feature tests scaffold` after SQL and DTO edits', - '', - `- \`queries/${params.queryName}/tests/boundary-ztd-types.ts\``, - `- \`queries/${params.queryName}/tests/generated/TEST_PLAN.md\``, - `- \`queries/${params.queryName}/tests/generated/analysis.json\``, - `- \`queries/${params.queryName}/tests/${params.queryName}.boundary.ztd.test.ts\``, - '', - '## Human/AI-owned persistent files', - '', - `- persistent case files under \`queries/${params.queryName}/tests/cases/\``, - `- cases/* is human/AI-owned and kept.`, - `- \`queries/${params.queryName}/tests/${params.queryName}.boundary.ztd.test.ts\` is a thin Vitest entrypoint and is kept.`, - '', - '## RFBA review responsibilities', - '', - '- RFBA splits files by review responsibility; this scaffold keeps review-heavy SQL visible and keeps DTO/mapping/test support close to the SQL it serves.', - '- `boundary.ts` is the default feature-boundary public surface and should read as `input -> workflow -> output`.', - '- `input.ts` owns raw request parsing, normalization, and feature-level input rejection.', - '- `workflow.ts` owns the feature use-case flow and query orchestration. It accepts query ports so workflow tests do not identify queries by SQL text.', - '- `output.ts` owns lightweight public response assembly from generated query result types.', - ...renderReadmeEntryspecNotes(params.action, params.parameterColumns), - '- Feature-local `boundary.ts` exports `execute`; package roots can alias it as a feature-specific public API name.', - `- \`queries/${params.queryName}/\` is the query unit: SQL, generated row/result mapping, execution contract, and query-local tests move together for review.`, - `- \`queries/${params.queryName}/boundary.ts\` is the default query-boundary public surface for query params, row shape, query result shape, and SQL execution contract.`, - `- \`queries/${params.queryName}/boundary.ts\` keeps public flow thin while generated row mapping stays under \`queries/${params.queryName}/generated/\`.`, - `- \`queries/${params.queryName}/boundary.ts\` and \`queries/${params.queryName}/${params.queryName}.sql\` stay co-located as one boundary/SQL pair.`, - `- \`tests/${params.featureName}.boundary.test.ts\` is the thin Vitest entrypoint for the feature boundary lane.`, - '- Feature-boundary and workflow tests should mock query ports rather than classify child queries by SQL text; advanced runtime SQL transformations must be explicit opt-in infrastructure.', - '- Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane.', - '- Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries.', - '- Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package; keep feature-specific validation and helpers inside the owning feature.', - `- \`queries/${params.queryName}/tests/${params.queryName}.boundary.ztd.test.ts\` is the thin Vitest entrypoint for the ZTD query lane.`, - generatedColumnsLine, - queryColumnsLine, - parameterColumnsLine, - defaultExpressionColumnsLine, - ...insertDefaultPolicyLines, - ...renderReadmeOperationNotes(params.action, params.primaryKeyColumn, params.insertDefaultPolicy), - '', - '## Follow-up query growth', - '', - `- Keep this baseline as one workflow and one primary query by default; add another sibling query directory under \`queries/\` only if a follow-up intentionally expands the feature.`, - '- If a follow-up adds another query directory, keep each query directory self-contained around its public entrypoint and SQL resource.', - '- Add transport-specific adapters later only when a concrete transport contract exists.', - '', - '## Shared helper note', - '', - '- `src/features/_shared/featureQueryExecutor.ts` is the shared runtime contract for DB execution injection.', - '- Cardinality checks should stay as thin generated query-local helpers in the standard scaffold.', - '- Treat `exactly-one`, `zero-or-one`, `many`, and `scalar` as the long-term cardinality contract family for future CRUD and SELECT expansion.', - '', - '## Follow-up customization points', - '', - '- Narrow field types and validation rules once the transport contract is known.', - '- Replace any scaffolded DDL-backed default expression if the feature needs a different explicit SQL assignment.', - ...renderReadmeFollowUpNotes(params.action), - `- After the SQL and DTO edits settle, run \`ztd feature tests scaffold --feature ${params.featureName}\` to refresh the CLI-owned generated files, keep the thin Vitest entrypoint in place, and then keep the persistent case files as human/AI-owned query-local assets.`, - '' - ].join('\n'); -} - -function renderReadmeEntryspecNotes(action: FeatureAction, parameterColumns: string[]): string[] { - if (action === 'get-by-id') { - return [ - '- `input.ts` uses local scaffolded request parsing and keeps the get-by-id baseline focused on key-only request parsing.', - '- `input.ts` rejects unsupported request fields instead of silently ignoring them in the baseline scaffold.', - '- The get-by-id baseline keeps not-found handling explicit and non-throwing so follow-up work can decide whether to keep nullable output or move to an exactly-one contract.' - ]; - } - if (action === 'list') { - return [ - '- `input.ts` uses local scaffolded request parsing, keeps the baseline request minimal, and `output.ts` returns a `{ items: [...] }` response contract.', - '- `input.ts` rejects unsupported request fields instead of silently ignoring them in the baseline scaffold.', - '- `input.ts` does not expose explicit paging inputs in the baseline scaffold; follow-up work can add them once the use case is known.' - ]; - } - const hasStringLikeInput = parameterColumns.length > 0; - if (action === 'delete') { - return [ - '- `input.ts` uses local scaffolded request parsing and keeps the delete baseline focused on key-only request parsing.', - '- The delete baseline does not assume string normalization; add transport-specific parsing or policy checks later only when the feature actually needs them.' - ]; - } - - if (hasStringLikeInput) { - return [ - '- `input.ts` uses local scaffolded request parsing, and the scaffold includes `trim()` plus empty-string rejection examples for current string inputs.' - ]; - } - - return [ - '- `input.ts` uses local scaffolded request parsing and leaves string normalization examples for follow-up when string fields appear.' - ]; -} - -function renderEntrySpecBoundaryComments( - action: FeatureAction, - insertDefaultPolicy: InsertDefaultPolicy = DEFAULT_INSERT_DEFAULT_POLICY -): string[] { - if (action === 'get-by-id') { - return [ - '// The get-by-id baseline accepts only the primary-key request input.', - '// Keep not-found handling explicit here instead of promoting it to an exception contract by default.' - ]; - } - if (action === 'list') { - return [ - '// The list baseline keeps the request contract intentionally minimal.', - '// Paging and stable ordering stay inside queries//boundary.ts so the feature boundary remains transport-focused.' - ]; - } - if (action === 'insert') { - return [ - '// Only non-default insert columns remain in the initial feature request.', - insertDefaultPolicy === 'explicit-defaults' - ? '// DDL-backed default expressions are written into the SQL resource explicitly.' - : '// DB-default insert columns are omitted from the SQL resource so the database assigns them.' - ]; - } - if (action === 'update') { - return [ - '// The initial update request carries the primary key plus every mechanically mutable candidate column.', - '// Treat control or audit fields as follow-up review points; remove or pin them when the feature contract becomes more specific.' - ]; - } - return [ - '// The initial delete request carries only the primary-key predicate.', - '// Add richer policy or authorization checks here as follow-up requirements appear.' - ]; -} - -function renderQuerySpecBoundaryComments( - action: FeatureAction, - insertDefaultPolicy: InsertDefaultPolicy = DEFAULT_INSERT_DEFAULT_POLICY -): string[] { - if (action === 'get-by-id') { - return [ - '// Query params own only the primary-key predicate for the get-by-id baseline.', - '// The baseline query returns zero or one row and leaves not-found handling non-throwing.' - ]; - } - if (action === 'list') { - return [ - '// queries//boundary.ts owns the list baseline paging and primary-key ordering contract.', - '// Keep the request contract narrow here; explicit paging inputs can be added later when the use case is known.' - ]; - } - if (action === 'insert') { - return [ - '// Query params own only the DB-boundary values that still need caller-supplied input.', - insertDefaultPolicy === 'explicit-defaults' - ? '// DDL-backed defaults are reflected directly in the SQL resource.' - : '// DB-default columns are omitted from the INSERT column list.' - ]; - } - if (action === 'update') { - return [ - '// Query params own the primary-key predicate plus every caller-supplied write value.', - '// The baseline update query returns the primary key after exactly one-row execution.' - ]; - } - return [ - '// Query params own only the primary-key predicate for the delete baseline.', - '// The baseline delete query returns the primary key after exactly one-row execution.' - ]; -} - -function renderReadmeOperationNotes( - action: FeatureAction, - primaryKeyColumn: string, - insertDefaultPolicy: InsertDefaultPolicy -): string[] { - if (action === 'get-by-id') { - return [ - `- The baseline get-by-id query uses \`${primaryKeyColumn}\` as the predicate and selects the scaffolded row shape explicitly.`, - '- The baseline allows not found instead of treating it as an exception.', - '- Generated request and response contracts follow the DDL-derived column types for this feature; the scaffold does not assume that every ID is a 32-bit integer.', - '- If the feature later needs a strict existence guarantee, this scaffold can be tightened to a strict one-row contract as a follow-up decision.' - ]; - } - if (action === 'list') { - return [ - `- The baseline list query applies stable primary-key ordering by \`${primaryKeyColumn}\` and keeps paging enabled by default.`, - `- \`DEFAULT_PAGE_SIZE\` is set to \`${DEFAULT_PAGE_SIZE}\` in queries//boundary.ts so the default can be changed without widening the request contract first.`, - '- Generated request and response contracts follow the DDL-derived column types for this feature; the scaffold does not assume that every ID is a 32-bit integer.', - '- The baseline response is `{ items: [...] }` so paging metadata and other list-level fields can be added later without breaking the response shape.' - ]; - } - if (action === 'insert') { - return [ - insertDefaultPolicy === 'explicit-defaults' - ? '- SQL omits only generated / identity / sequence-backed primary keys. Every other insert column stays explicit in the scaffold SQL.' - : '- SQL omits generated / identity / sequence-backed primary keys and DB-default columns selected by the scaffold policy.', - insertDefaultPolicy === 'explicit-defaults' - ? '- When DDL declares a column default, the scaffold writes that default expression into SQL explicitly instead of relying on an implicit database default at runtime.' - : '- When DDL declares a column default, the scaffold omits that column so the database default applies at runtime.', - `- The insert result returns the primary key only: \`${primaryKeyColumn}\`.` - ]; - } - if (action === 'update') { - return [ - `- The baseline update query uses \`${primaryKeyColumn}\` as the predicate and updates every non-generated, non-primary-key column explicitly.`, - '- This baseline is mechanical, not a mutable-policy guarantee. Control or audit columns such as `created_at`, `updated_at`, and similar fields are representative follow-up candidates to remove, pin, or otherwise specialize.', - `- The update result returns the primary key only: \`${primaryKeyColumn}\`.` - ]; - } - return [ - `- The baseline delete query uses \`${primaryKeyColumn}\` as the predicate and performs a primary-key-only delete.`, - `- The delete result returns the primary key only: \`${primaryKeyColumn}\`.` - ]; -} - -function renderReadmeFollowUpNotes(action: FeatureAction): string[] { - if (action === 'get-by-id') { - return [ - '- Switch to a strict one-row contract later only if the feature decides that missing rows must fail instead of returning a nullable result.' - ]; - } - if (action === 'list') { - return [ - '- Add explicit paging inputs, filter fields, or richer ordering only after the list use case is known.', - '- Keep catalog-based paging and ordering inside `queries//boundary.ts` as the feature grows.' - ]; - } - if (action === 'update') { - return [ - '- Revisit the mutable field set before treating the scaffold as business-safe. Control and audit columns are expected follow-up review points for update features.' - ]; - } - - if (action === 'delete') { - return [ - '- Add richer delete policy, authorization checks, or soft-delete behavior later if the feature contract requires them.' - ]; - } - - return []; -} - -function renderGetByIdEntrySpecFile(params: { - action: FeatureAction; - featureName: string; - pascalName: string; - entryCamelName: string; - queryName: string; - queryPascalName: string; - queryCamelName: string; - requestFields: RenderField[]; - responseFields: RenderField[]; -}): string { - return [ - "import type { FeatureQueryExecutor } from '../_shared/featureQueryExecutor.js';", - '', - `import {`, - ` execute${params.queryPascalName}QuerySpec,`, - ` type ${params.queryPascalName}QueryParams,`, - ` type ${params.queryPascalName}QueryResult`, - `} from './queries/${params.queryName}/boundary.js';`, - '', - ...renderEntrySpecBoundaryComments(params.action), - '', - renderObjectType(`${params.pascalName}Request`, params.requestFields, { - exported: true, - property: 'name' - }), - '', - `export type ${params.pascalName}Response = {`, - ...renderTypeObjectLines(params.responseFields, ' '), - '} | null;', - '', - '/** Parses the raw feature request at the feature boundary. */', - ...renderStrictObjectParser({ - functionName: 'parseRequest', - typeName: `${params.pascalName}Request`, - fields: params.requestFields, - label: `${params.pascalName}Request`, - property: 'name' - }), - '', - '/** Normalizes the parsed feature request for downstream feature logic. */', - `function normalizeRequest(request: ${params.pascalName}Request): ${params.pascalName}Request {`, - ' return {', - ...params.requestFields.map((field) => field.parserKind === 'string' - ? ` ${field.name}: request.${field.name}.trim(),` - : ` ${field.name}: request.${field.name},`), - ' };', - '}', - '', - '/** Rejects feature requests that violate feature-level rules. */', - `function rejectRequest(request: ${params.pascalName}Request): void {`, - ...params.requestFields - .filter((field) => field.parserKind === 'string') - .flatMap((field) => [ - ` if (request.${field.name}.length === 0) {`, - ` throw new Error('${params.pascalName}Request.${field.name} must not be empty after trim().');`, - ' }' - ]), - ...(params.requestFields.some((field) => field.parserKind === 'string') - ? [] - : [' // Add feature-level reject rules here when follow-up requirements appear.']), - '}', - '', - '/** Maps the feature request into query params for the query spec. */', - `function toQueryParams(request: ${params.pascalName}Request): ${params.queryPascalName}QueryParams {`, - ...renderTypedReturnObject(params.requestFields, `${params.queryPascalName}QueryParams`), - '}', - '', - '/** Maps the query result into the feature response contract. */', - `function fromQueryResult(result: ${params.queryPascalName}QueryResult): ${params.pascalName}Response {`, - ' if (result === null) {', - ' return null;', - ' }', - ' // TODO: Review domain-specific response naming before exposing this feature boundary publicly.', - ...renderPlainObjectFromSource('result', params.responseFields), - '}', - '', - '/** Executes the feature boundary flow for this feature. */', - `export async function execute${params.pascalName}EntrySpec(`, - ' executor: FeatureQueryExecutor,', - ' rawRequest: unknown', - `): Promise<${params.pascalName}Response> {`, - ' const request = normalizeRequest(parseRequest(rawRequest));', - ' rejectRequest(request);', - ` const result = await execute${params.queryPascalName}QuerySpec(executor, toQueryParams(request));`, - ' return fromQueryResult(result);', - '}', - '', - ...renderStrictObjectParserSupport(), - '' - ].join('\n'); -} - -function renderListEntrySpecFile(params: { - action: FeatureAction; - featureName: string; - pascalName: string; - entryCamelName: string; - queryName: string; - queryPascalName: string; - queryCamelName: string; - requestFields: RenderField[]; - responseFields: RenderField[]; -}): string { - return [ - "import type { FeatureQueryExecutor } from '../_shared/featureQueryExecutor.js';", - '', - `import {`, - ` execute${params.queryPascalName}QuerySpec,`, - ` type ${params.queryPascalName}QueryParams,`, - ` type ${params.queryPascalName}QueryResult`, - `} from './queries/${params.queryName}/boundary.js';`, - '', - ...renderEntrySpecBoundaryComments(params.action), - '', - renderObjectType(`${params.pascalName}Request`, params.requestFields, { - exported: true, - property: 'name' - }), - '', - `export type ${params.pascalName}Response = {`, - ' items: Array<{', - ...renderTypeObjectLines(params.responseFields, ' '), - ' }>;', - '};', - '', - '/** Parses the raw feature request at the feature boundary. */', - ...renderStrictObjectParser({ - functionName: 'parseRequest', - typeName: `${params.pascalName}Request`, - fields: params.requestFields, - label: `${params.pascalName}Request`, - property: 'name' - }), - '', - '/** Normalizes the parsed feature request for downstream feature logic. */', - `function normalizeRequest(request: ${params.pascalName}Request): ${params.pascalName}Request {`, - ' return request;', - '}', - '', - '/** Rejects feature requests that violate feature-level rules. */', - `function rejectRequest(_request: ${params.pascalName}Request): void {`, - ' // Add feature-level reject rules here when follow-up requirements appear.', - '}', - '', - '/** Maps the feature request into query params for the query spec. */', - `function toQueryParams(_request: ${params.pascalName}Request): ${params.queryPascalName}QueryParams {`, - ` return {} as ${params.queryPascalName}QueryParams;`, - '}', - '', - '/** Maps the query result into the feature response contract. */', - `function fromQueryResult(result: ${params.queryPascalName}QueryResult): ${params.pascalName}Response {`, - ' // TODO: Review domain-specific response naming before exposing this feature boundary publicly.', - ' return {', - ' items: result.items.map((item) => ({', - ...params.responseFields.map((field) => ` ${field.name}: item.${field.sourceName},`), - ' })),', - ' };', - '}', - '', - '/** Executes the feature boundary flow for this feature. */', - `export async function execute${params.pascalName}EntrySpec(`, - ' executor: FeatureQueryExecutor,', - ' rawRequest: unknown', - `): Promise<${params.pascalName}Response> {`, - ' const request = normalizeRequest(parseRequest(rawRequest));', - ' rejectRequest(request);', - ` const result = await execute${params.queryPascalName}QuerySpec(executor, toQueryParams(request));`, - ' return fromQueryResult(result);', - '}', - '', - ...renderStrictObjectParserSupport(), - '' - ].join('\n'); -} - -function renderGetByIdQuerySpecFile(params: { - action: FeatureAction; - queryName: string; - boundaryRelativeDir: string; - queryPascalName: string; - queryCamelName: string; - requestFields: RenderField[]; - responseFields: RenderField[]; - sharedExecutorImportPath: string; - sharedLoadSqlResourceImportPath: string; -}): string { - return [ - "import { dirname } from 'node:path';", - "import { fileURLToPath } from 'node:url';", - '', - `import type { FeatureQueryExecutor } from '${params.sharedExecutorImportPath}';`, - `import { loadSqlResource } from '${params.sharedLoadSqlResourceImportPath}';`, - `import { map${params.queryPascalName}RowToResult } from './generated/row-mapper.js';`, - '', - 'const __dirname = dirname(fileURLToPath(import.meta.url));', - `const ${params.queryCamelName}SqlResource = loadSqlResource(__dirname, '${params.queryName}.sql');`, - '', - ...renderQuerySpecBoundaryComments(params.action), - renderObjectType(`${params.queryPascalName}QueryParams`, params.requestFields, { - exported: true, - property: 'name' - }), - '', - renderTypeInterface(`${params.queryPascalName}Row`, params.responseFields, true), - '', - `export type ${params.queryPascalName}QueryResult = ${params.queryPascalName}Row | null;`, - '', - '/** Parses raw query params at the query boundary. */', - ...renderStrictObjectParser({ - functionName: 'parseQueryParams', - typeName: `${params.queryPascalName}QueryParams`, - fields: params.requestFields, - label: `${params.queryPascalName}QueryParams`, - property: 'name' - }), - '', - '/** Loads the optional row for the get-by-id baseline. */', - `async function loadOptionalRow(executor: FeatureQueryExecutor, sql: string, params: Record): Promise<${params.queryPascalName}Row | undefined> {`, - ` const rows = await executor.query<${params.queryPascalName}Row>(sql, params);`, - ' if (rows.length === 0) {', - ' return undefined;', - ' }', - ' if (rows.length > 1) {', - ` throw new Error('${params.queryPascalName}QuerySpec expected at most one row.');`, - ' }', - ' return rows[0];', - '}', - '', - '/** Executes the query boundary flow for this query spec. */', - `export async function execute${params.queryPascalName}QuerySpec(`, - ' executor: FeatureQueryExecutor,', - ' rawParams: unknown', - `): Promise<${params.queryPascalName}QueryResult> {`, - ' const params = parseQueryParams(rawParams);', - ` const row = await loadOptionalRow(executor, ${params.queryCamelName}SqlResource, params);`, - ` return map${params.queryPascalName}RowToResult(row);`, - '}', - '', - ...renderStrictObjectParserSupport(), - '' - ].join('\n'); -} - -function renderListQuerySpecFile(params: { - action: FeatureAction; - queryName: string; - boundaryRelativeDir: string; - queryPascalName: string; - queryCamelName: string; - requestFields: RenderField[]; - responseFields: RenderField[]; - sharedExecutorImportPath: string; - sharedLoadSqlResourceImportPath: string; -}): string { - return [ - "import { dirname } from 'node:path';", - "import { fileURLToPath } from 'node:url';", - '', - `import type { FeatureQueryExecutor } from '${params.sharedExecutorImportPath}';`, - `import { loadSqlResource } from '${params.sharedLoadSqlResourceImportPath}';`, - `import { map${params.queryPascalName}RowsToResult } from './generated/row-mapper.js';`, - '', - `const DEFAULT_PAGE_SIZE = ${DEFAULT_PAGE_SIZE};`, - 'const __dirname = dirname(fileURLToPath(import.meta.url));', - `const ${params.queryCamelName}SqlResource = loadSqlResource(__dirname, '${params.queryName}.sql');`, - '', - ...renderQuerySpecBoundaryComments(params.action), - renderObjectType(`${params.queryPascalName}QueryParams`, params.requestFields, { - exported: true, - property: 'name' - }), - '', - renderTypeInterface(`${params.queryPascalName}Row`, params.responseFields, true), - '', - `export interface ${params.queryPascalName}QueryResult {`, - ` items: ${params.queryPascalName}Row[];`, - '}', - '', - `type ${params.queryPascalName}CatalogQueryParams = ${params.queryPascalName}QueryParams & {`, - ' limit: number;', - '};', - '', - '/** Parses raw query params at the query boundary. */', - ...renderStrictObjectParser({ - functionName: 'parseQueryParams', - typeName: `${params.queryPascalName}QueryParams`, - fields: params.requestFields, - label: `${params.queryPascalName}QueryParams`, - property: 'name' - }), - '', - '/** Maps the feature request into query params for the query spec. */', - `function toQueryParams(params: ${params.queryPascalName}QueryParams): ${params.queryPascalName}CatalogQueryParams {`, - ' return {', - ' ...params,', - ' limit: DEFAULT_PAGE_SIZE,', - ' };', - '}', - '', - '/** Executes the query boundary flow for this query spec. */', - `export async function execute${params.queryPascalName}QuerySpec(`, - ' executor: FeatureQueryExecutor,', - ' rawParams: unknown', - `): Promise<${params.queryPascalName}QueryResult> {`, - ' const params = parseQueryParams(rawParams);', - ` const rows = await executor.query<${params.queryPascalName}Row>(${params.queryCamelName}SqlResource, toQueryParams(params));`, - ` return map${params.queryPascalName}RowsToResult(rows);`, - '}', - '', - ...renderStrictObjectParserSupport(), - '' - ].join('\n'); -} - -function renderGeneratedRowMapperFile(params: { - action: FeatureAction; - queryPascalName: string; - responseFields: RenderField[]; - boundarySource: string; - sqlSource: string; -}): string { - if (params.responseFields.length === 0) { - throw new Error(`Cannot generate row mapper for ${params.queryPascalName}: no result fields were available.`); - } - return renderGeneratedRowMapperFileFromSpec({ - queryPascalName: params.queryPascalName, - mode: params.action === 'list' ? 'list' : params.action === 'get-by-id' ? 'optional' : 'single', - fieldNames: params.responseFields.map((field) => field.name), - hasMany: undefined, - boundaryHash: hashGeneratedMapperSource(params.boundarySource), - sqlHash: hashGeneratedMapperSource(params.sqlSource) - }); -} - -function renderGeneratedRowMapperFileFromSpec(params: { - queryPascalName: string; - mode: GeneratedMapperMode; - fieldNames: string[]; - hasMany?: GeneratedHasManyRelationMetadata; - boundaryHash: string; - sqlHash: string; -}): string { - if (params.fieldNames.length === 0) { - throw new Error(`Cannot generate row mapper for ${params.queryPascalName}: no result fields were available.`); - } - const header = [ - '// @generated by rawsql-ts ztd-cli. Do not edit.', - '// This file is machine-owned and regenerated by `ztd feature generated-mapper generate`.', - `// source-boundary-sha256: ${params.boundaryHash}`, - `// source-sql-sha256: ${params.sqlHash}`, - '' - ]; - const importLine = `import type { ${params.queryPascalName}QueryResult, ${params.queryPascalName}Row } from '../boundary.js';`; - if (params.mode === 'hasMany') { - if (!params.hasMany) { - throw new Error(`Cannot generate hasMany row mapper for ${params.queryPascalName}: hasMany metadata was not provided.`); - } - return renderGeneratedHasManyRowMapperFile({ - header, - importLine, - queryPascalName: params.queryPascalName, - relation: params.hasMany - }); - } - - if (params.mode === 'list') { - return [ - ...header, - importLine, - '', - `export function map${params.queryPascalName}RowsToResult(rows: ${params.queryPascalName}Row[]): ${params.queryPascalName}QueryResult {`, - ` const items = new Array<${params.queryPascalName}QueryResult['items'][number]>(rows.length);`, - ' for (let index = 0; index < rows.length; index += 1) {', - ' const row = rows[index];', - ...renderGeneratedMapperAssignment('items[index]', 'row', params.fieldNames), - ' }', - ' return { items };', - '}', - '' - ].join('\n'); - } - - const rowType = params.mode === 'optional' - ? `${params.queryPascalName}Row | undefined` - : `${params.queryPascalName}Row`; - const nullGuard = params.mode === 'optional' - ? [ - ' if (row === undefined) {', - ' return null;', - ' }' - ] - : []; - - return [ - ...header, - importLine, - '', - `export function map${params.queryPascalName}RowToResult(row: ${rowType}): ${params.queryPascalName}QueryResult {`, - ...nullGuard, - ...renderGeneratedMapperReturnObject('row', params.fieldNames, `${params.queryPascalName}QueryResult`), - '}', - '' - ].join('\n'); -} - -function renderGeneratedHasManyRowMapperFile(params: { - header: string[]; - importLine: string; - queryPascalName: string; - relation: GeneratedHasManyRelationMetadata; -}): string { - const collectionProperty = params.relation.collection.property; - return [ - ...params.header, - params.importLine, - '', - 'function serializeGeneratedKey(values: readonly unknown[]): string {', - ' return values', - ' .map((value) => {', - ' const text = String(value);', - ' return `${typeof value}:${text.length}:${text}`;', - ' })', - " .join('');", - '}', - '', - `export function map${params.queryPascalName}RowsToResult(rows: ${params.queryPascalName}Row[]): ${params.queryPascalName}QueryResult {`, - ` const items: ${params.queryPascalName}QueryResult['items'] = [];`, - ` const rootIndex = new Map();`, - ' for (let index = 0; index < rows.length; index += 1) {', - ' const row = rows[index];', - ` const rootKey = serializeGeneratedKey([${params.relation.root.key.map((column) => `row[${JSON.stringify(column)}]`).join(', ')}]);`, - ' let root = rootIndex.get(rootKey);', - ' if (root === undefined) {', - ' root = {', - ...renderGeneratedHasManyObjectProperties(' ', 'row', params.relation.root.columns), - ` ${collectionProperty}: [],`, - ' };', - ' rootIndex.set(rootKey, root);', - ' items.push(root);', - ' }', - ` if (${params.relation.collection.presence.map((column) => `row[${JSON.stringify(column)}] !== null && row[${JSON.stringify(column)}] !== undefined`).join(' && ')}) {`, - ` root.${collectionProperty}.push({`, - ...renderGeneratedHasManyObjectProperties(' ', 'row', params.relation.collection.columns), - ' });', - ' }', - ' }', - ' return { items };', - '}', - '' - ].join('\n'); -} - -function renderGeneratedHasManyObjectProperties( - indent: string, - sourceName: string, - columns: Record -): string[] { - return Object.entries(columns) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([property, column]) => `${indent}${property}: ${sourceName}[${JSON.stringify(column)}],`); -} - -function renderGeneratedMapperReturnObject(sourceName: string, fieldNames: string[], typeName: string): string[] { - if (fieldNames.length === 0) { - return [` return {} as ${typeName};`]; - } - return [ - ' return {', - ...fieldNames.map((fieldName) => ` ${JSON.stringify(fieldName)}: ${sourceName}[${JSON.stringify(fieldName)}],`), - ' };' - ]; -} - -function renderGeneratedMapperAssignment(targetName: string, sourceName: string, fieldNames: string[]): string[] { - if (fieldNames.length === 0) { - return [` ${targetName} = {};`]; - } - return [ - ` ${targetName} = {`, - ...fieldNames.map((fieldName) => ` ${JSON.stringify(fieldName)}: ${sourceName}[${JSON.stringify(fieldName)}],`), - ' };' - ]; -} - -function renderExampleValue(field: RenderField): string { - if (field.nullable) { - return 'null'; - } - if (field.parserKind === 'jsonObject') { - return "{ example: 'value' }"; - } - if (field.parserKind === 'number') { - return '1'; - } - if (field.parserKind === 'boolean') { - return 'true'; - } - return `'example_${field.name}'`; -} - -function renderZodObjectSchema( - name: string, - fields: RenderField[], - options: { trimStrings: boolean; rejectEmptyStrings: boolean; exported: boolean; strict?: boolean } -): string { - const lines = [`${options.exported ? 'export ' : ''}const ${name} = z.object({`]; - for (const field of fields) { - lines.push(` ${field.name}: ${renderZodField(field, options)},`); - } - lines.push(`})${options.strict ? '.strict()' : ''};`); - return lines.join('\n'); -} - -function renderZodField( - field: RenderField, - options: { trimStrings: boolean; rejectEmptyStrings: boolean; exported: boolean } -): string { - let base = ''; - if (field.parserKind === 'number') { - base = 'z.number().finite()'; - } else if (field.parserKind === 'boolean') { - base = 'z.boolean()'; - } else if (field.parserKind === 'jsonObject') { - base = 'z.record(z.string(), z.unknown())'; - } else { - base = 'z.string()'; - if (options.trimStrings) { - base += '.trim()'; - } - if (options.rejectEmptyStrings) { - base += `.min(1, '${field.name} must not be empty.')`; - } - } - if (field.nullable) { - base += '.nullable()'; - } - return base; -} - -function renderTypeInterface(name: string, fields: RenderField[], exported: boolean): string { - const lines = [`${exported ? 'export ' : ''}interface ${name} {`]; - for (const field of fields) { - lines.push(` ${field.sourceName}: ${field.typeScriptType};`); - } - lines.push('}'); - return lines.join('\n'); -} - -function renderObjectType( - name: string, - fields: RenderField[], - options: { exported: boolean; property: 'name' | 'sourceName' } -): string { - if (fields.length === 0) { - return `${options.exported ? 'export ' : ''}type ${name} = {};`; - } - const lines = [`${options.exported ? 'export ' : ''}interface ${name} {`]; - for (const field of fields) { - lines.push(` ${field[options.property]}: ${field.typeScriptType};`); - } - lines.push('}'); - return lines.join('\n'); -} - -function renderStrictObjectParser(params: { - functionName: string; - typeName: string; - fields: RenderField[]; - label: string; - property: 'name' | 'sourceName'; -}): string[] { - const allowedKeys = `[${params.fields.map((field) => JSON.stringify(field[params.property])).join(', ')}]`; - if (params.fields.length === 0) { - return [ - `function ${params.functionName}(raw: unknown): ${params.typeName} {`, - ` parseStrictObject(raw, '${params.label}', ${allowedKeys});`, - ` return {} as ${params.typeName};`, - '}' - ]; - } - - return [ - `function ${params.functionName}(raw: unknown): ${params.typeName} {`, - ` const record = parseStrictObject(raw, '${params.label}', ${allowedKeys});`, - ' return {', - ...params.fields.map((field) => ` ${field[params.property]}: ${renderReadFieldExpression(field, 'record', params.property, params.label)},`), - ' };', - '}' - ]; -} - -function renderReadFieldExpression( - field: RenderField, - recordName: string, - property: 'name' | 'sourceName', - label: string -): string { - const key = field[property]; - const value = `${recordName}[${JSON.stringify(key)}]`; - const fieldLabel = `${label}.${key}`; - if (field.parserKind === 'number') { - return field.nullable - ? `readNullableNumber(${value}, '${fieldLabel}')` - : `readNumber(${value}, '${fieldLabel}')`; - } - if (field.parserKind === 'boolean') { - return field.nullable - ? `readNullableBoolean(${value}, '${fieldLabel}')` - : `readBoolean(${value}, '${fieldLabel}')`; - } - if (field.parserKind === 'jsonObject') { - return field.nullable - ? `readNullableJsonObject(${value}, '${fieldLabel}')` - : `readJsonObject(${value}, '${fieldLabel}')`; - } - return field.nullable - ? `readNullableString(${value}, '${fieldLabel}')` - : `readString(${value}, '${fieldLabel}')`; -} - -function renderStrictObjectParserSupport(): string[] { - return [ - 'type UnknownRecord = Record;', - '', - 'function parseStrictObject(raw: unknown, label: string, allowedKeys: readonly string[]): UnknownRecord {', - " if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {", - " throw new Error(`${label} must be an object.`);", - ' }', - ' const record = raw as UnknownRecord;', - ' for (const key of Object.keys(record)) {', - ' if (!allowedKeys.includes(key)) {', - " throw new Error(`${label}.${key} is not supported by this scaffolded boundary.`);", - ' }', - ' }', - ' return record;', - '}', - '', - 'function readString(value: unknown, label: string): string {', - " if (typeof value !== 'string') {", - " throw new Error(`${label} must be a string.`);", - ' }', - ' return value;', - '}', - '', - 'function readNullableString(value: unknown, label: string): string | null {', - ' return value === null ? null : readString(value, label);', - '}', - '', - 'function readNumber(value: unknown, label: string): number {', - " if (typeof value !== 'number' || !Number.isFinite(value)) {", - " throw new Error(`${label} must be a finite number.`);", - ' }', - ' return value;', - '}', - '', - 'function readNullableNumber(value: unknown, label: string): number | null {', - ' return value === null ? null : readNumber(value, label);', - '}', - '', - 'function readBoolean(value: unknown, label: string): boolean {', - " if (typeof value !== 'boolean') {", - " throw new Error(`${label} must be a boolean.`);", - ' }', - ' return value;', - '}', - '', - 'function readNullableBoolean(value: unknown, label: string): boolean | null {', - ' return value === null ? null : readBoolean(value, label);', - '}', - '', - 'function readJsonObject(value: unknown, label: string): Record {', - " if (value === null || typeof value !== 'object' || Array.isArray(value)) {", - " throw new Error(`${label} must be an object.`);", - ' }', - ' return value as Record;', - '}', - '', - 'function readNullableJsonObject(value: unknown, label: string): Record | null {', - ' return value === null ? null : readJsonObject(value, label);', - '}' - ]; -} - -function renderTypedReturnObject(fields: RenderField[], typeName: string): string[] { - if (fields.length === 0) { - return [` return {} as ${typeName};`]; - } - return [ - ' return {', - ...fields.map((field) => ` ${field.sourceName}: request.${field.name},`), - ' };' - ]; -} - -function renderParsedObjectFromSource(sourceName: string, fields: RenderField[], schemaName: string): string[] { - if (fields.length === 0) { - return [` return ${schemaName}.parse({});`]; - } - return [ - ` return ${schemaName}.parse({`, - ...fields.map((field) => ` ${field.name}: ${sourceName}.${field.sourceName},`), - ' });' - ]; -} - -function renderPlainObjectFromSource(sourceName: string, fields: RenderField[]): string[] { - if (fields.length === 0) { - return [' return {};']; - } - return [ - ' return {', - ...fields.map((field) => ` ${field.name}: ${sourceName}.${field.sourceName},`), - ' };' - ]; -} - -function toPascalCase(value: string): string { - return value - .split(/[^a-zA-Z0-9]+/) - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(''); -} - -function toCamelCase(value: string): string { - const pascal = toPascalCase(value); - return pascal.charAt(0).toLowerCase() + pascal.slice(1); -} - -function normalizeCliPath(filePath: string): string { - return filePath.replace(/\\/g, '/'); -} - -function toProjectRelativePath(rootDir: string, filePath: string): string { - return normalizeCliPath(path.relative(rootDir, filePath)); -} - -function toFeatureResourceSegment(tableName: string): string { - const rawResource = tableName.trim().split('.').pop() ?? ''; - return rawResource - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') - .replace(/[_\s]+/g, '-') - .replace(/[^a-zA-Z0-9-]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-+|-+$/g, '') - .toLowerCase(); -} - -function buildSharedOutputs( - rootDir: string, - paths: Pick, - written: boolean -): FeatureScaffoldResult['outputs'] { - const outputs: FeatureScaffoldResult['outputs'] = []; - if (!existsSync(paths.sharedDir)) { - outputs.push({ - path: toProjectRelativePath(rootDir, paths.sharedDir), - written, - kind: 'directory' - }); - } - if (!existsSync(paths.featureQueryExecutorFile)) { - outputs.push({ - path: toProjectRelativePath(rootDir, paths.featureQueryExecutorFile), - written, - kind: 'file' - }); - } - if (!existsSync(paths.loadSqlResourceFile)) { - outputs.push({ - path: toProjectRelativePath(rootDir, paths.loadSqlResourceFile), - written, - kind: 'file' - }); - } - return outputs; -} - -function writeFileIfMissing(filePath: string, contents: string): void { - if (existsSync(filePath)) { - return; - } - writeFileSync(filePath, contents, 'utf8'); -} - -function writeFeatureFile(filePath: string, contents: string, force: boolean): void { - if (existsSync(filePath) && !force) { - return; - } - writeFileSync(filePath, contents, 'utf8'); -} - -function writeGeneratedFile(filePath: string, contents: string): void { - writeFileSync(filePath, contents, 'utf8'); -} - -function assertFeatureWriteSafety(paths: FeatureScaffoldPaths, force: boolean): void { - if (force) { - return; - } - - const existingPaths = [ - paths.entrySpecFile, - paths.inputFile, - paths.workflowFile, - paths.outputFile, - paths.querySpecFile, - paths.querySqlFile, - paths.readmeFile - ].filter((candidate) => existsSync(candidate)); - if (existingPaths.length === 0) { - return; - } - - const relativePaths = existingPaths.map((candidate) => normalizeCliPath(path.relative(paths.featureDir, candidate))); - throw new Error( - `Feature scaffold would overwrite existing files for ${path.basename(paths.featureDir)}: ${relativePaths.join(', ')}. Re-run with --force to overwrite scaffold-owned files.` - ); -} - -function dedupeStrings(values: string[]): string[] { - return [...new Set(values)]; -} diff --git a/packages/ztd-cli/src/commands/featureTests.ts b/packages/ztd-cli/src/commands/featureTests.ts deleted file mode 100644 index af92b5add..000000000 --- a/packages/ztd-cli/src/commands/featureTests.ts +++ /dev/null @@ -1,1164 +0,0 @@ -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { - CreateTableQuery, - MultiQuerySplitter, - SqlParser, - type ColumnConstraintDefinition, - type TableConstraintDefinition -} from 'rawsql-ts'; - -import { emitDiagnostic, isJsonOutput, writeCommandEnvelope } from '../utils/agentCli'; -import { ensureDirectory } from '../utils/fs'; -import { inspectImportAliasSupport } from '../utils/importAliasSupport'; - -const TESTS_SUPPORT_HARNESS_IMPORT_PATH = '#tests/support/ztd/harness.js'; -const TESTS_SUPPORT_CASE_TYPES_IMPORT_PATH = '#tests/support/ztd/case-types.js'; -const FEATURE_TEST_KINDS = ['ztd', 'traditional'] as const; -type FeatureTestKind = (typeof FEATURE_TEST_KINDS)[number]; - -type FeatureTestsCommandOptions = { - feature?: string; - query?: string; - testKind?: string; - dryRun?: boolean; - force?: boolean; - rootDir?: string; -}; - -interface FeatureTestsScaffoldResult { - featureName: string; - queryName: string; - testKind: FeatureTestKind; - dryRun: boolean; - unsupportedConstraintGuidance: UnsupportedConstraintGuidance[]; - outputs: Array<{ path: string; written: boolean; kind: 'directory' | 'file' }>; -} - -interface UnsupportedConstraintGuidance { - table: string; - constraint: 'unique' | 'check' | 'not-null' | 'foreign-key' | 'exclusion'; - sourcePath: string; - recommendation: string; - todo: string; -} - -interface QueryLayout { - featureName: string; - queryName: string; - queryDir: string; - testsDir: string; - generatedDir: string; - casesDir: string; - entrypointFile: string; - queryTypesFile: string; - planFile: string; - analysisFile: string; - basicCaseFile: string; - querySpecFile: string; - querySqlFile: string; -} - -interface FeatureTestAnalysis { - schemaVersion: 1; - featureId: string; - testKind: FeatureTestKind; - fixtureCandidateTables: string[]; - writesTables: string[]; - validationScenarioHints: string[]; - dbScenarioHints: string[]; - constraintCoverageNotes: string[]; - unsupportedConstraintGuidance: UnsupportedConstraintGuidance[]; - resultCardinality: 'one' | 'many'; -} - -interface TestPlanDetails extends FeatureTestAnalysis { - queryInputFields: string[]; - queryOutputFields: string[]; - queryFixtureRowFields: string[]; - querySpecSourcePath: string; - entrySpecPath: string; - querySpecPath: string; - sqlPath: string; - vitestEntrypointPath: string; - generatedDirPath: string; - casesDirPath: string; - analysisPath: string; - fixedVerifierPath: string; -} - -export function registerFeatureTestsScaffoldCommand(featureCommand: Command): void { - const tests = featureCommand - .command('tests') - .description('Refresh query-boundary generated analysis, refresh the generated type file, and create the thin Vitest entrypoint when it is missing'); - - tests - .command('scaffold') - .description('Refresh query-boundary generated analysis, refresh the generated type file, and keep persistent case files untouched') - .requiredOption('--feature ', 'Target feature name') - .option('--query ', 'Target query directory when the feature has more than one query') - .option('--test-kind ', 'Scaffold lane kind (ztd or traditional)', 'ztd') - .option('--dry-run', 'Validate inputs and emit the planned scaffold without writing files', false) - .option('--force', 'Overwrite scaffold-owned generated files when they already exist', false) - .action(async (options: FeatureTestsCommandOptions) => { - const result = await runFeatureTestsScaffoldCommand(options); - if (isJsonOutput()) { - writeCommandEnvelope('feature tests scaffold', result); - return; - } - - const lines = [ - `Feature tests scaffold ${result.dryRun ? 'plan' : 'completed'}: ${result.featureName}`, - `Query: ${result.queryName}`, - `Test kind: ${result.testKind}`, - '', - 'Created by CLI:', - ...result.outputs.map((output) => `- ${output.path}`), - '', - 'CLI-owned generated files:', - `- src/features/${result.featureName}/queries/${result.queryName}/tests/${result.queryName}.boundary.${result.testKind}.test.ts (created only when missing)`, - `- src/features/${result.featureName}/queries/${result.queryName}/tests/boundary-${result.testKind}-types.ts`, - `- src/features/${result.featureName}/queries/${result.queryName}/tests/generated/${result.testKind === 'ztd' ? 'TEST_PLAN.md' : 'TEST_PLAN.traditional.md'}`, - `- src/features/${result.featureName}/queries/${result.queryName}/tests/generated/${result.testKind === 'ztd' ? 'analysis.json' : 'analysis.traditional.json'}`, - '', - 'AI-authored files:', - `- src/features/${result.featureName}/queries/${result.queryName}/tests/cases/ (TODO-based cases; fill them before enabling the generated test)` - ]; - process.stdout.write(`${lines.join('\n')}\n`); - }); -} - -export async function runFeatureTestsScaffoldCommand(options: FeatureTestsCommandOptions): Promise { - const rootDir = options.rootDir ?? process.cwd(); - const testKind = normalizeFeatureTestKind(options.testKind); - const featureName = normalizeFeatureName(options.feature ?? ''); - const featureDir = path.join(rootDir, 'src', 'features', featureName); - if (!existsSync(featureDir)) { - throw new Error(`Feature not found for tests scaffold: ${featureName}. Run feature scaffold first.`); - } - - const queryLayout = resolveQueryLayout(featureDir, featureName, options.query, testKind); - assertSharedZtdTestSupport(rootDir); - assertGeneratedWriteSafety([queryLayout.planFile, queryLayout.analysisFile], options.force === true); - - const planDetails = buildTestPlanDetails({ - rootDir, - featureDir, - queryLayout, - testKind - }); - - const files = renderFeatureTestScaffoldFiles({ - rootDir, - featureName, - queryName: queryLayout.queryName, - planDetails, - testKind - }); - - const outputs: FeatureTestsScaffoldResult['outputs'] = [ - { path: toProjectRelativePath(rootDir, queryLayout.testsDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, queryLayout.generatedDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, queryLayout.casesDir), written: !options.dryRun, kind: 'directory' }, - { path: toProjectRelativePath(rootDir, queryLayout.entrypointFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, queryLayout.basicCaseFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, queryLayout.queryTypesFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, queryLayout.planFile), written: !options.dryRun, kind: 'file' }, - { path: toProjectRelativePath(rootDir, queryLayout.analysisFile), written: !options.dryRun, kind: 'file' } - ]; - - if (options.dryRun) { - return { - featureName, - queryName: queryLayout.queryName, - testKind, - dryRun: true, - unsupportedConstraintGuidance: planDetails.unsupportedConstraintGuidance, - outputs - }; - } - - ensureDirectory(queryLayout.testsDir); - ensureDirectory(queryLayout.generatedDir); - ensureDirectory(queryLayout.casesDir); - - // The Vitest entrypoint and the initial case file are created once and then - // treated as persistent query-owned assets. `--force` refreshes the CLI-owned - // generated snapshot files and the query-local type alias file below. - writeFileIfMissing(queryLayout.entrypointFile, files.vitestEntrypointFile); - writeFileIfMissing(queryLayout.basicCaseFile, files.basicCaseFile); - writeFeatureFile(queryLayout.queryTypesFile, files.queryTypesFile, options.force === true); - writeFeatureFile(queryLayout.planFile, files.testPlanFile, options.force === true); - writeFeatureFile(queryLayout.analysisFile, files.analysisFile, options.force === true); - - emitDiagnostic({ - code: 'feature-tests-scaffold.ai-follow-up', - message: testKind === 'ztd' - ? `CLI refreshed generated analysis under src/features/${featureName}/queries/${queryLayout.queryName}/tests/generated/ for test-kind=${testKind}, refreshed boundary-${testKind}-types.ts, created the skipped Vitest entrypoint only if it was missing, and left TODO-based AI-authored cases under src/features/${featureName}/queries/${queryLayout.queryName}/tests/cases/ untouched. Fill the case values, then enable the generated test.` - : `CLI refreshed generated analysis under src/features/${featureName}/queries/${queryLayout.queryName}/tests/generated/ for test-kind=${testKind}, refreshed boundary-${testKind}-types.ts, created the active Vitest entrypoint only if it was missing, and left TODO-based AI-authored cases under src/features/${featureName}/queries/${queryLayout.queryName}/tests/cases/ untouched. Fill the case values, then run the traditional lane against a disposable database.` - }); - - return { - featureName, - queryName: queryLayout.queryName, - testKind, - dryRun: false, - unsupportedConstraintGuidance: planDetails.unsupportedConstraintGuidance, - outputs - }; -} - -function assertSharedZtdTestSupport(rootDir: string): void { - const requiredSupportFiles = [ - path.join(rootDir, 'tests', 'support', 'ztd', 'harness.ts'), - path.join(rootDir, 'tests', 'support', 'ztd', 'case-types.ts') - ]; - - const missingFiles = requiredSupportFiles.filter((filePath) => !existsSync(filePath)); - if (missingFiles.length === 0) { - return; - } - - const missingList = missingFiles - .map((filePath) => toProjectRelativePath(rootDir, filePath)) - .join(', '); - - throw new Error( - `feature tests scaffold requires starter-owned shared ZTD support under tests/support/ztd. Missing: ${missingList}. Run \`ztd init --starter\` for a fresh starter project, or add the shared support files before scaffolding query-boundary tests.` - ); -} - -function renderFeatureTestScaffoldFiles(params: { - rootDir: string; - featureName: string; - queryName: string; - planDetails: TestPlanDetails; - testKind: FeatureTestKind; -}): { - testPlanFile: string; - analysisFile: string; - vitestEntrypointFile: string; - basicCaseFile: string; - queryTypesFile: string; -} { - const importAliasSupport = inspectImportAliasSupport(params.rootDir, { - packageImportKey: '#tests/*.js', - tsconfigPathKey: '#tests/*', - vitestAliasPrefix: '#tests' - }); - if (importAliasSupport === 'partial') { - throw new Error( - 'Feature tests scaffold found partial #tests alias configuration. Configure package.json#imports, tsconfig.json compilerOptions.paths, and vitest.config.ts resolve.alias together, or remove the partial alias setup.' - ); - } - const useStableTestSupportImports = importAliasSupport === 'supported'; - const isZtdLane = params.testKind === 'ztd'; - const fixtureCandidateTablesLine = params.planDetails.fixtureCandidateTables.length > 0 - ? params.planDetails.fixtureCandidateTables.map((field) => `- ${field}`).join('\n') - : '- TODO: inspect the scaffolded SQL and DDL for fixture candidate tables.'; - const writesTablesLine = params.planDetails.writesTables.length > 0 - ? params.planDetails.writesTables.map((field) => `- ${field}`).join('\n') - : '- TODO: inspect the scaffolded SQL for write targets.'; - const validationHintsLine = params.planDetails.validationScenarioHints.length > 0 - ? params.planDetails.validationScenarioHints.map((field) => `- ${field}`).join('\n') - : '- TODO: inspect the scaffolded boundary.ts for feature-boundary hints.'; - const dbHintsLine = params.planDetails.dbScenarioHints.length > 0 - ? params.planDetails.dbScenarioHints.map((field) => `- ${field}`).join('\n') - : '- TODO: inspect the scaffolded query boundary and SQL for DB-backed hints.'; - const constraintCoverageLine = params.planDetails.constraintCoverageNotes.map((note) => `- ${note}`).join('\n'); - const unsupportedConstraintLine = params.planDetails.unsupportedConstraintGuidance.length > 0 - ? params.planDetails.unsupportedConstraintGuidance - .map((guidance) => `- TODO: ${guidance.todo}`) - .join('\n') - : '- No unsupported constraint follow-up was detected from the current DDL/table hints.'; - const caseReadinessLine = isZtdLane - ? '- Generated ZTD cases are intentionally placeholders. Fill `beforeDb`, `input`, and `output`, then change the generated Vitest entrypoint from `test.skip` to `test`.' - : '- Generated traditional cases are intentionally placeholders. Fill `beforeDb`, `input`, `output`, and optional `afterDb`, then run the active traditional Vitest entrypoint against a disposable database.'; - - const testPlanFile = [ - `# ${params.featureName} / ${params.queryName} boundary test plan`, - '', - 'This file snapshots the current scaffold contract before AI completes the TODO-based case files.', - '', - '## Contract Snapshot', - '', - `- schemaVersion: ${params.planDetails.schemaVersion}`, - `- featureId: ${params.planDetails.featureId}`, - `- testKind: ${params.planDetails.testKind}`, - `- resultCardinality: ${params.planDetails.resultCardinality}`, - `- fixedVerifier: ${params.planDetails.fixedVerifierPath}`, - `- vitestEntrypoint: ${params.planDetails.vitestEntrypointPath}`, - `- generatedDir: ${params.planDetails.generatedDirPath}`, - `- casesDir: ${params.planDetails.casesDirPath}`, - `- analysisJson: ${params.planDetails.analysisPath}`, - '', - '## Source Files', - '', - `- ${params.planDetails.entrySpecPath}`, - `- ${params.planDetails.querySpecPath}`, - `- ${params.planDetails.sqlPath}`, - `- ${params.planDetails.vitestEntrypointPath}`, - '', - '## Fixture Candidate Tables', - '', - fixtureCandidateTablesLine, - '', - '## Write Tables', - '', - writesTablesLine, - '', - '## Validation Scenario Hints', - '', - validationHintsLine, - '', - '## DB Scenario Hints', - '', - dbHintsLine, - '', - '## Constraint Coverage Boundary', - '', - constraintCoverageLine, - '', - '## Unsupported Constraint Follow-up', - '', - unsupportedConstraintLine, - '', - '## Case Readiness', - '', - caseReadinessLine, - '', - '## After DB Semantics', - '', - ...(isZtdLane - ? [ - '- ZTD queryspec execution is fixture-rewrite based and does not perform physical DB setup.', - '- `mode=ztd`, `physicalSetupUsed=false`, and `rewriteApplied` are returned as machine-checkable evidence.', - '- This ZTD lane does not expose `afterDb`; use output assertions or a traditional DB-state lane for post-state checks.', - '- Set `ZTD_SQL_TRACE=1` to emit per-case SQL trace JSON; optionally set `ZTD_SQL_TRACE_DIR` to override the output directory.' - ] - : [ - '- Traditional queryspec execution physically prepares DDL and fixture rows before running the query boundary.', - '- `mode=traditional`, `physicalSetupUsed=true`, and `rewriteApplied=false` are returned as machine-checkable evidence.', - '- Optional `afterDb` assertions can be modeled in this lane (for example migration/index/physical-state effects).', - '- Set `ZTD_SQL_TRACE=1` to emit per-case SQL trace JSON; optionally set `ZTD_SQL_TRACE_DIR` to override the output directory.' - ]), - '', - '## Ownership', - '', - `- Generated files live under ${params.planDetails.generatedDirPath}.`, - `- AI-authored TODO case files live under ${params.planDetails.casesDirPath}.`, - '- Do not edit generated files by hand unless you are intentionally repairing them with --force.', - '' - ].join('\n'); - - const analysisFile = `${JSON.stringify( - { - schemaVersion: params.planDetails.schemaVersion, - featureId: params.planDetails.featureId, - testKind: params.planDetails.testKind, - fixtureCandidateTables: params.planDetails.fixtureCandidateTables, - writesTables: params.planDetails.writesTables, - validationScenarioHints: params.planDetails.validationScenarioHints, - dbScenarioHints: params.planDetails.dbScenarioHints, - constraintCoverageNotes: params.planDetails.constraintCoverageNotes, - unsupportedConstraintGuidance: params.planDetails.unsupportedConstraintGuidance, - resultCardinality: params.planDetails.resultCardinality - }, - null, - 2 - )}\n`; - - const querySpecImportPath = '../boundary.js'; - const harnessImportPath = useStableTestSupportImports - ? TESTS_SUPPORT_HARNESS_IMPORT_PATH - : '../../../../../../tests/support/ztd/harness.js'; - const casesImportPath = isZtdLane ? './cases/basic.case.js' : './cases/basic.traditional.case.js'; - const executorName = readExportedFunctionName(params.planDetails.querySpecSourcePath, 'execute', 'QuerySpec'); - const queryTypePrefix = toPascalCase(params.queryName); - const queryCaseTypeName = isZtdLane ? `${queryTypePrefix}QueryBoundaryZtdCase` : `${queryTypePrefix}QueryBoundaryTraditionalCase`; - const queryTypesImportPath = isZtdLane ? './boundary-ztd-types.js' : './boundary-traditional-types.js'; - const queryBoundaryTypesImport = `import type { ${queryTypePrefix}QueryParams, ${queryTypePrefix}QueryResult } from '../boundary.js';`; - const beforeDbTypeLiteral = buildQueryFixtureTypeLiteral( - params.planDetails.fixtureCandidateTables, - buildQueryFixtureRowTypeLiteral(params.planDetails.queryFixtureRowFields) - ); - const beforeDbValueLiteral = buildQueryFixtureValueLiteral(params.planDetails.fixtureCandidateTables); - const beforeDbTodoLines = renderTodoCommentLines( - 'TODO: Fill fixture rows for the tables the CLI could identify. Remove rows that are not needed for this case.', - params.planDetails.fixtureCandidateTables - ); - const inputTodoLines = renderTodoCommentLines( - 'TODO: Replace the placeholder input with concrete query parameters before enabling the generated test.', - params.planDetails.queryInputFields - ); - const outputTodoLines = renderTodoCommentLines( - 'TODO: Replace the placeholder output with the exact result expected from the query boundary.', - params.planDetails.queryOutputFields - ); - const unsupportedConstraintTodoLines = isZtdLane - ? params.planDetails.unsupportedConstraintGuidance.map((guidance) => ` // TODO: ${guidance.todo}`) - : []; - const vitestEntrypointFile = isZtdLane - ? [ - `import { expect, test } from 'vitest';`, - '', - `import { runQuerySpecZtdCases } from '${harnessImportPath}';`, - `import { ${executorName} } from '${querySpecImportPath}';`, - `import cases from '${casesImportPath}';`, - `import type { ${queryCaseTypeName} } from '${queryTypesImportPath}';`, - '', - `test.skip('${params.featureName}/${params.queryName} boundary ZTD case scaffold placeholder', async () => {`, - ' // TODO: Fill tests/cases/basic.case.ts, then change this to test(...).', - ' expect(cases.length).toBeGreaterThan(0);', - ` const evidence = await runQuerySpecZtdCases(cases, ${executorName});`, - " expect(evidence.every((entry) => entry.mode === 'ztd')).toBe(true);", - ' expect(evidence.every((entry) => entry.physicalSetupUsed === false)).toBe(true);', - '});', - '' - ].join('\n') - : [ - `import { expect, test } from 'vitest';`, - '', - `import { runQuerySpecTraditionalCases } from '${harnessImportPath}';`, - `import { ${executorName} } from '${querySpecImportPath}';`, - `import cases from '${casesImportPath}';`, - `import type { ${queryCaseTypeName} } from '${queryTypesImportPath}';`, - '', - `test('${params.featureName}/${params.queryName} boundary traditional cases run through physical DB setup', async () => {`, - ' expect(cases.length).toBeGreaterThan(0);', - ` const evidence = await runQuerySpecTraditionalCases(cases, ${executorName});`, - " expect(evidence.every((entry) => entry.mode === 'traditional')).toBe(true);", - ' expect(evidence.every((entry) => entry.physicalSetupUsed === true)).toBe(true);', - '});', - '' - ].join('\n'); - - const basicCaseFile = [ - `import type { ${queryTypePrefix}BeforeDb, ${queryTypePrefix}Input, ${queryTypePrefix}Output, ${queryCaseTypeName} } from '${isZtdLane ? '../boundary-ztd-types.js' : '../boundary-traditional-types.js'}';`, - '', - `const cases: readonly ${queryCaseTypeName}[] = [`, - ' {', - " name: 'basic-success',", - ...beforeDbTodoLines, - ` beforeDb: ${beforeDbValueLiteral} as ${queryTypePrefix}BeforeDb,`, - ...inputTodoLines, - ` input: {} as ${queryTypePrefix}Input,`, - ...outputTodoLines, - ` output: {} as ${queryTypePrefix}Output,`, - ...unsupportedConstraintTodoLines, - ...(isZtdLane ? [] : [ - ' // TODO: Add afterDb when this case must assert physical post-state table rows.', - ' // afterDb: async (db) => {', - ' // await db.table(...).toContainRows(...);', - ' // },' - ]), - ' }', - '];', - '', - 'export default cases;', - '' - ].join('\n'); - - const queryTypesFile = isZtdLane - ? [ - `import type { QuerySpecZtdCase } from '${useStableTestSupportImports ? TESTS_SUPPORT_CASE_TYPES_IMPORT_PATH : '../../../../../../tests/support/ztd/case-types.js'}';`, - queryBoundaryTypesImport, - '', - `export type ${queryTypePrefix}BeforeDb = ${beforeDbTypeLiteral};`, - `export type ${queryTypePrefix}Input = ${queryTypePrefix}QueryParams;`, - `export type ${queryTypePrefix}Output = ${queryTypePrefix}QueryResult;`, - '', - `export type ${queryCaseTypeName} = QuerySpecZtdCase<`, - ` ${queryTypePrefix}BeforeDb,`, - ` ${queryTypePrefix}Input,`, - ` ${queryTypePrefix}Output`, - '>;', - '' - ].join('\n') - : [ - `import type { QuerySpecTraditionalCase } from '${useStableTestSupportImports ? TESTS_SUPPORT_CASE_TYPES_IMPORT_PATH : '../../../../../../tests/support/ztd/case-types.js'}';`, - queryBoundaryTypesImport, - '', - `export type ${queryTypePrefix}BeforeDb = ${beforeDbTypeLiteral};`, - `export type ${queryTypePrefix}Input = ${queryTypePrefix}QueryParams;`, - `export type ${queryTypePrefix}Output = ${queryTypePrefix}QueryResult;`, - '', - `export type ${queryCaseTypeName} = QuerySpecTraditionalCase<`, - ` ${queryTypePrefix}BeforeDb,`, - ` ${queryTypePrefix}Input,`, - ` ${queryTypePrefix}Output`, - '>;', - '' - ].join('\n'); - - return { - testPlanFile, - analysisFile, - vitestEntrypointFile, - basicCaseFile, - queryTypesFile - }; -} - -function buildTestPlanDetails(params: { - rootDir: string; - featureDir: string; - queryLayout: QueryLayout; - testKind: FeatureTestKind; -}): TestPlanDetails { - const entrySpecFile = path.join(params.featureDir, 'boundary.ts'); - const entrySpecSource = readFileSync(entrySpecFile, 'utf8'); - const querySpecSource = readFileSync(params.queryLayout.querySpecFile, 'utf8'); - const sqlSource = readFileSync(params.queryLayout.querySqlFile, 'utf8'); - const requestFields = extractSchemaFields(entrySpecSource, 'RequestSchema'); - const queryInputFields = extractSchemaFields(querySpecSource, 'QueryParamsSchema'); - const queryOutputFields = extractSchemaFields(querySpecSource, 'QueryResultSchema'); - const sqlInsertColumns = extractSqlInsertColumns(sqlSource); - const sqlReturningColumns = extractSqlReturningColumns(sqlSource); - const fixtureCandidateTables = dedupeStrings([ - ...extractSqlTableReferences(sqlSource), - ...extractSqlWriteTables(sqlSource) - ]); - const writesTables = extractSqlWriteTables(sqlSource); - const resultCardinality = querySpecSource.includes('items: z.array(') || params.queryLayout.queryName === 'list' ? 'many' : 'one'; - const resolvedInputFields = queryInputFields.length > 0 ? queryInputFields : requestFields; - const resolvedOutputFields = queryOutputFields.length > 0 ? queryOutputFields : sqlReturningColumns; - const unsupportedConstraintGuidance = buildUnsupportedConstraintGuidance({ - rootDir: params.rootDir, - testKind: params.testKind, - fixtureCandidateTables, - writesTables - }); - - return { - schemaVersion: 1, - featureId: path.basename(params.featureDir), - testKind: params.testKind, - fixtureCandidateTables, - writesTables, - validationScenarioHints: buildValidationScenarioHints(requestFields, params.queryLayout.queryName), - dbScenarioHints: buildDbScenarioHints(params.testKind, writesTables, params.queryLayout.queryName, fixtureCandidateTables), - constraintCoverageNotes: buildConstraintCoverageNotes(params.testKind), - unsupportedConstraintGuidance, - resultCardinality, - queryInputFields: resolvedInputFields, - queryOutputFields: resolvedOutputFields, - queryFixtureRowFields: dedupeStrings([...sqlInsertColumns, ...sqlReturningColumns, ...resolvedOutputFields]), - entrySpecPath: toProjectRelativePath(params.rootDir, entrySpecFile), - querySpecSourcePath: params.queryLayout.querySpecFile, - querySpecPath: toProjectRelativePath(params.rootDir, params.queryLayout.querySpecFile), - sqlPath: toProjectRelativePath(params.rootDir, params.queryLayout.querySqlFile), - vitestEntrypointPath: toProjectRelativePath(params.rootDir, params.queryLayout.entrypointFile), - generatedDirPath: toProjectRelativePath(params.rootDir, params.queryLayout.generatedDir), - casesDirPath: toProjectRelativePath(params.rootDir, params.queryLayout.casesDir), - analysisPath: toProjectRelativePath(params.rootDir, params.queryLayout.analysisFile), - fixedVerifierPath: params.testKind === 'ztd' - ? 'tests/support/ztd/harness.ts' - : 'tests/support/ztd/harness.ts#runQuerySpecTraditionalCases' - }; -} - -function buildValidationScenarioHints(requestFields: string[], queryName: string): string[] { - const hints = [ - 'Keep feature-boundary validation separate from query-boundary DB-backed execution.', - 'Validation failures belong in the feature-root mock test lane.' - ]; - - if (requestFields.length > 0) { - hints.push(`Required request fields in feature boundary: ${requestFields.map((field) => `\`${field}\``).join(', ')}.`); - } else { - hints.push(`No required request fields were extracted for ${queryName}; keep the feature-boundary test focused on normalization and boundary rules.`); - } - - return hints; -} - -function buildConstraintCoverageNotes(testKind: FeatureTestKind): string[] { - if (testKind === 'ztd') { - return [ - 'ZTD currently verifies rewritten SQL input/output, fixture table/column shape, evidence fields, and required INSERT column presence for NOT NULL columns without defaults when table definitions are available.', - 'Explicit NULL values for NOT NULL columns and simple UNIQUE checks are feasible ZTD preflight candidates, but they are not enforced by the current fixture/CTE rewrite lane.', - 'Use a traditional physical DB lane for DB-enforced fail-fast behavior today, especially CHECK, foreign key, exclusion, deferrable, partial/expression UNIQUE, collation-sensitive, or full PostgreSQL constraint semantics.' - ]; - } - - return [ - 'Traditional execution is the lane for PostgreSQL-enforced constraint failures because it runs against physical DB setup.', - 'Use this lane for constraint failure cases that are not covered by ZTD preflight, including `unique`, `check`, `not null`, foreign key, exclusion, and similar DB-enforced fail-fast cases.', - 'Keep the ZTD lane focused on rewritten SQL input/output, fixture shape, and evidence fields.' - ]; -} - -function buildUnsupportedConstraintGuidance(params: { - rootDir: string; - testKind: FeatureTestKind; - fixtureCandidateTables: string[]; - writesTables: string[]; -}): UnsupportedConstraintGuidance[] { - if (params.testKind !== 'ztd') { - return []; - } - - const candidateTables = new Set( - dedupeStrings([...params.fixtureCandidateTables, ...params.writesTables]) - .flatMap((table) => buildTableLookupKeys(table, readDefaultSchema(params.rootDir))) - ); - if (candidateTables.size === 0) { - return []; - } - - const ddlSources = collectDdlSqlSources(params.rootDir); - const guidance: UnsupportedConstraintGuidance[] = []; - - const defaultSchema = readDefaultSchema(params.rootDir); - for (const source of ddlSources) { - for (const table of collectCreateTableConstraintInfo(source.sql, defaultSchema)) { - const tableKeys = buildTableLookupKeys(table.name, readDefaultSchema(params.rootDir)); - if (!tableKeys.some((key) => candidateTables.has(key))) { - continue; - } - - for (const constraint of table.constraints) { - guidance.push({ - table: table.normalizedName, - constraint, - sourcePath: source.path, - recommendation: buildConstraintRecommendation(constraint), - todo: buildConstraintTodo(table.normalizedName, constraint) - }); - } - } - } - - const seen = new Set(); - return guidance.filter((entry) => { - const key = `${entry.table}:${entry.constraint}:${entry.sourcePath}`; - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); -} - -function buildConstraintRecommendation(constraint: UnsupportedConstraintGuidance['constraint']): string { - switch (constraint) { - case 'not-null': - return 'ZTD catches missing required INSERT columns, but explicit NULL or parameter-NULL failures need a traditional physical DB lane today.'; - case 'unique': - return 'Simple UNIQUE preflight is feasible, but current ZTD fixture/CTE execution does not enforce UNIQUE violations.'; - case 'check': - return 'CHECK expression semantics are PostgreSQL-enforced and should be covered by a traditional physical DB lane.'; - case 'foreign-key': - return 'Foreign key existence and timing semantics are PostgreSQL-enforced and should be covered by a traditional physical DB lane.'; - case 'exclusion': - return 'Exclusion constraints rely on PostgreSQL operator/index semantics and should be covered by a traditional physical DB lane.'; - } -} - -function buildConstraintTodo(table: string, constraint: UnsupportedConstraintGuidance['constraint']): string { - const label = constraint.toUpperCase().replace('-', ' '); - return `${table} has ${label} constraint coverage that is not fully enforced by the ZTD lane; add or run a traditional physical DB case for DB-enforced failure behavior.`; -} - -function collectDdlSqlSources(rootDir: string): Array<{ path: string; sql: string }> { - const ddlDir = path.join(rootDir, readDdlDir(rootDir)); - if (!existsSync(ddlDir)) { - return []; - } - - const sources: Array<{ path: string; sql: string }> = []; - collectSqlSourcesRecursive(rootDir, ddlDir, sources); - return sources.sort((a, b) => a.path.localeCompare(b.path)); -} - -function collectSqlSourcesRecursive(rootDir: string, directory: string, accumulator: Array<{ path: string; sql: string }>): void { - const entries = readdirSync(directory, { withFileTypes: true }); - for (const entry of entries) { - const resolved = path.join(directory, entry.name); - if (entry.isDirectory()) { - collectSqlSourcesRecursive(rootDir, resolved, accumulator); - continue; - } - if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.sql') { - continue; - } - const sql = readFileSync(resolved, 'utf8'); - if (!sql.trim()) { - continue; - } - accumulator.push({ - path: toProjectRelativePath(rootDir, resolved), - sql - }); - } -} - -function collectCreateTableConstraintInfo( - sql: string, - defaultSchema: string -): Array<{ name: string; normalizedName: string; constraints: UnsupportedConstraintGuidance['constraint'][] }> { - const tables: Array<{ name: string; normalizedName: string; constraints: UnsupportedConstraintGuidance['constraint'][] }> = []; - const batch = MultiQuerySplitter.split(sql); - - for (const query of batch.queries) { - if (query.isEmpty) { - continue; - } - const parsed = tryParseCreateTable(query.sql); - if (!parsed) { - continue; - } - const schemaName = parsed.namespaces?.[parsed.namespaces.length - 1] ?? defaultSchema; - const tableName = parsed.tableName.name; - const normalizedName = `${schemaName}.${tableName}`.toLowerCase(); - tables.push({ - name: normalizedName, - normalizedName, - constraints: detectConstraintKinds(parsed, query.sql) - }); - } - - return tables; -} - -function tryParseCreateTable(sql: string): CreateTableQuery | null { - try { - const parsed = SqlParser.parse(sql); - return parsed instanceof CreateTableQuery ? parsed : null; - } catch { - return null; - } -} - -function detectConstraintKinds(query: CreateTableQuery, sql: string): UnsupportedConstraintGuidance['constraint'][] { - const constraints: UnsupportedConstraintGuidance['constraint'][] = []; - const columnConstraints = query.columns.flatMap((column) => column.constraints); - const tableConstraints = query.tableConstraints; - - if (columnConstraints.some((constraint) => constraint.kind === 'not-null')) { - constraints.push('not-null'); - } - if ( - columnConstraints.some((constraint) => constraint.kind === 'unique') || - tableConstraints.some((constraint) => constraint.kind === 'unique') - ) { - constraints.push('unique'); - } - if ( - columnConstraints.some((constraint) => isColumnCheckConstraint(constraint)) || - tableConstraints.some((constraint) => isTableCheckConstraint(constraint)) - ) { - constraints.push('check'); - } - if ( - columnConstraints.some((constraint) => constraint.kind === 'references') || - tableConstraints.some((constraint) => constraint.kind === 'foreign-key') - ) { - constraints.push('foreign-key'); - } - // rawsql-ts does not expose a structured CREATE TABLE exclusion constraint node yet (#802). - // This intentionally noisy fallback can false-positive on identifiers, comments, or string literals; - // acceptable here because it only emits advisory traditional-lane TODO guidance until parser support exists. - if (/\bexclude\b|\bexclusion\b/i.test(sql)) { - constraints.push('exclusion'); - } - return constraints; -} - -function isColumnCheckConstraint(constraint: ColumnConstraintDefinition): boolean { - return constraint.kind === 'check'; -} - -function isTableCheckConstraint(constraint: TableConstraintDefinition): boolean { - return constraint.kind === 'check'; -} - -function buildTableLookupKeys(tableName: string, defaultSchema: string): string[] { - const normalized = normalizeSqlIdentifierPath(tableName); - if (normalized.includes('.')) { - const [, table] = normalized.split('.'); - return [normalized, table]; - } - return [normalized, `${defaultSchema}.${normalized}`]; -} - -function normalizeSqlIdentifierPath(value: string): string { - return value - .split('.') - .map((part) => part.trim().replace(/^"|"$/g, '').toLowerCase()) - .join('.'); -} - -function readDdlDir(rootDir: string): string { - const configPath = path.join(rootDir, 'ztd.config.json'); - if (!existsSync(configPath)) { - return 'db/ddl'; - } - try { - const raw = JSON.parse(readFileSync(configPath, 'utf8')) as { ddlDir?: unknown }; - return typeof raw.ddlDir === 'string' && raw.ddlDir.length > 0 ? raw.ddlDir : 'db/ddl'; - } catch { - return 'db/ddl'; - } -} - -function readDefaultSchema(rootDir: string): string { - const configPath = path.join(rootDir, 'ztd.config.json'); - if (!existsSync(configPath)) { - return 'public'; - } - try { - const raw = JSON.parse(readFileSync(configPath, 'utf8')) as { defaultSchema?: unknown }; - return typeof raw.defaultSchema === 'string' && raw.defaultSchema.length > 0 ? raw.defaultSchema.toLowerCase() : 'public'; - } catch { - return 'public'; - } -} - -function buildDbScenarioHints( - testKind: FeatureTestKind, - writesTables: string[], - queryName: string, - fixtureCandidateTables: string[] -): string[] { - const hints = testKind === 'ztd' - ? [ - 'Use the fixed app-level harness and query-local cases to keep the ZTD path thin.', - 'Keep db/input/output visible in the case file so the AI can fill the query contract without re-deriving the scaffold.' - ] - : [ - 'Use the shared mode-switching harness and run the traditional lane against physical DB setup.', - 'Keep db/input/output/afterDb visible in the case file so physical-state verification intent stays explicit.' - ]; - - if (writesTables.length > 0) { - hints.push(`Write tables for ${queryName}: ${writesTables.map((table) => `\`${table}\``).join(', ')}.`); - hints.push( - testKind === 'ztd' - ? 'Switch to a traditional DB-state lane when you need post-execution table assertions.' - : 'Add post-execution table assertions in this lane when physical-state verification is required.' - ); - } else if (fixtureCandidateTables.length > 0) { - hints.push(`Read tables for ${queryName}: ${fixtureCandidateTables.map((table) => `\`${table}\``).join(', ')}.`); - hints.push('DB-backed cases should seed the minimum fixture rows needed to make the query result shape obvious.'); - } else { - hints.push(`No table references were discovered for ${queryName}; inspect SQL and query boundary manually before filling the case.`); - } - - return hints; -} - -type FixtureTreeNode = { - children: Map; -}; - -function buildFixtureTree(tableNames: string[]): FixtureTreeNode { - const root: FixtureTreeNode = { children: new Map() }; - - for (const tableName of dedupeStrings(tableNames)) { - const segments = tableName - .split('.') - .map((segment) => segment.trim()) - .filter(Boolean); - - let current = root; - for (const segment of segments) { - if (!current.children.has(segment)) { - current.children.set(segment, { children: new Map() }); - } - current = current.children.get(segment)!; - } - } - - return root; -} - -function renderFixtureTree(node: FixtureTreeNode, leaf: string, separator: ',' | ';'): string { - if (node.children.size === 0) { - return leaf; - } - - const entries = [...node.children.entries()].map(([segment, child]) => `${segment}: ${renderFixtureTree(child, leaf, separator)}`); - return `{ ${entries.join(` ${separator} `)} }`; -} - -function buildQueryFixtureTypeLiteral(tableNames: string[], rowTypeLiteral: string): string { - const normalized = dedupeStrings(tableNames); - if (normalized.length === 0) { - return 'Record'; - } - return renderFixtureTree(buildFixtureTree(normalized), `readonly ${rowTypeLiteral}[]`, ';'); -} - -function buildQueryFixtureRowTypeLiteral(fieldNames: string[]): string { - const normalized = dedupeStrings(fieldNames); - if (normalized.length === 0) { - return 'Record'; - } - - return `{ ${normalized.map((field) => `${field}?: unknown`).join('; ')} }`; -} - -function renderTodoCommentLines(message: string, hints: string[]): string[] { - const lines = [` // ${message}`]; - const normalized = dedupeStrings(hints); - if (normalized.length === 0) { - lines.push(' // CLI hints: none discovered; inspect boundary.ts, SQL, DDL, and TEST_PLAN.md.'); - return lines; - } - - lines.push(` // CLI hints: ${normalized.join(', ')}.`); - return lines; -} - -function buildQueryFixtureValueLiteral(tableNames: string[]): string { - const normalized = dedupeStrings(tableNames); - if (normalized.length === 0) { - return '{}'; - } - return renderFixtureTree(buildFixtureTree(normalized), '[]', ','); -} - -function extractSqlTableReferences(sqlSource: string): string[] { - const tablePatterns = [ - /\binsert\s+into\s+([^\s(,;]+)/ig, - /\bupdate\s+([^\s(,;]+)/ig, - /\bdelete\s+from\s+([^\s(,;]+)/ig, - /\bfrom\s+([^\s(,;]+)/ig, - /\bjoin\s+([^\s(,;]+)/ig - ]; - const tables = new Set(); - - for (const pattern of tablePatterns) { - for (const match of sqlSource.matchAll(pattern)) { - const tableName = match[1]; - if (tableName) { - tables.add(normalizeSqlTableName(tableName)); - } - } - } - - return [...tables]; -} - -function extractSqlWriteTables(sqlSource: string): string[] { - const writePatterns = [ - /\binsert\s+into\s+([^\s(,;]+)/ig, - /\bupdate\s+([^\s(,;]+)/ig, - /\bdelete\s+from\s+([^\s(,;]+)/ig - ]; - const tables = new Set(); - - for (const pattern of writePatterns) { - for (const match of sqlSource.matchAll(pattern)) { - const tableName = match[1]; - if (tableName) { - tables.add(normalizeSqlTableName(tableName)); - } - } - } - - return [...tables]; -} - -function extractSqlInsertColumns(sqlSource: string): string[] { - const match = sqlSource.match(/\binsert\s+into\s+[^\s(,;]+(?:\s*\(\s*([^)]+?)\s*\))?/i); - if (!match || !match[1]) { - return []; - } - - return splitSqlIdentifiers(match[1]); -} - -function extractSqlReturningColumns(sqlSource: string): string[] { - const match = sqlSource.match(/\breturning\s+([^;]+)\s*;?\s*$/i); - if (!match || !match[1]) { - return []; - } - - return splitSqlIdentifiers(match[1]); -} - -function splitSqlIdentifiers(value: string): string[] { - return value - .split(',') - .map((segment) => segment.trim()) - .filter(Boolean) - .map((segment) => segment.replace(/^["'`]|["'`]$/g, '')) - .map((segment) => segment.replace(/\s+as\s+.+$/i, '')) - .map((segment) => segment.replace(/\(.+\)$/, '')) - .map((segment) => segment.trim()) - .filter(Boolean); -} - -function extractSchemaFields(source: string, schemaName: string): string[] { - const match = source.match(new RegExp(`const\\s+${schemaName}\\s*=\\s*z\\.object\\(\\{([\\s\\S]*?)\\}\\)\\.strict\\(\\);`)); - if (!match) { - return []; - } - - return match[1] - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => line.replace(/,$/, '')) - .map((line) => line.match(/^([a-zA-Z0-9_]+):/)?.[1]) - .filter((value): value is string => Boolean(value)); -} - -function normalizeSqlTableName(value: string): string { - return value - .trim() - .replace(/;$/, '') - .split('.') - .map((segment) => segment.replace(/^["'`]|["'`]$/g, '').toLowerCase()) - .join('.'); -} - -function resolveQueryLayout( - featureDir: string, - featureName: string, - selectedQueryName: string | undefined, - testKind: FeatureTestKind -): QueryLayout { - const queriesRoot = path.join(featureDir, 'queries'); - if (!existsSync(queriesRoot)) { - throw new Error(`No queries directory was discovered under ${featureDir}. Run feature scaffold first.`); - } - - const queryDirectories = readdirSync(queriesRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .filter((entry) => existsSync(path.join(queriesRoot, entry, 'boundary.ts'))) - .sort((a, b) => a.localeCompare(b)); - - if (selectedQueryName) { - if (!queryDirectories.includes(selectedQueryName)) { - throw new Error(`Query directory not found for tests scaffold: ${selectedQueryName}.`); - } - return buildQueryLayout(featureDir, featureName, selectedQueryName, testKind); - } - - if (queryDirectories.length === 0) { - throw new Error(`No boundary.ts file was discovered under ${queriesRoot}. Run feature scaffold first.`); - } - - if (queryDirectories.length > 1) { - const queryList = queryDirectories.map((queryName) => `- ${queryName}`).join('\n'); - const commandList = queryDirectories - .map((queryName) => ` ztd feature tests scaffold --feature ${featureName} --query ${queryName} --test-kind ${testKind}`) - .join('\n'); - throw new Error([ - `Multiple query directories were discovered under ${featureDir}.`, - '', - 'Choose the query to refresh with --query :', - queryList, - '', - 'Suggested commands:', - commandList - ].join('\n')); - } - - return buildQueryLayout(featureDir, featureName, queryDirectories[0], testKind); -} - -function buildQueryLayout(featureDir: string, featureName: string, queryName: string, testKind: FeatureTestKind): QueryLayout { - const queryDir = path.join(featureDir, 'queries', queryName); - const testsDir = path.join(queryDir, 'tests'); - const generatedDir = path.join(testsDir, 'generated'); - const casesDir = path.join(testsDir, 'cases'); - const isZtdLane = testKind === 'ztd'; - return { - featureName, - queryName, - queryDir, - testsDir, - generatedDir, - casesDir, - entrypointFile: path.join(testsDir, `${queryName}.boundary.${testKind}.test.ts`), - queryTypesFile: path.join(testsDir, isZtdLane ? 'boundary-ztd-types.ts' : 'boundary-traditional-types.ts'), - planFile: path.join(generatedDir, isZtdLane ? 'TEST_PLAN.md' : 'TEST_PLAN.traditional.md'), - analysisFile: path.join(generatedDir, isZtdLane ? 'analysis.json' : 'analysis.traditional.json'), - basicCaseFile: path.join(casesDir, isZtdLane ? 'basic.case.ts' : 'basic.traditional.case.ts'), - querySpecFile: path.join(queryDir, 'boundary.ts'), - querySqlFile: path.join(queryDir, `${queryName}.sql`) - }; -} - -function normalizeFeatureTestKind(value: string | undefined): FeatureTestKind { - const normalized = (value ?? 'ztd').trim().toLowerCase(); - if (FEATURE_TEST_KINDS.includes(normalized as FeatureTestKind)) { - return normalized as FeatureTestKind; - } - - throw new Error(`Feature test kind supports only ${FEATURE_TEST_KINDS.join(', ')}.`); -} - -function normalizeFeatureName(value: string): string { - const normalized = value.trim().toLowerCase(); - if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(normalized)) { - throw new Error('Feature name must use resource-action kebab-case, start with a letter, and look like users-insert.'); - } - return normalized; -} - -function readExportedFunctionName(filePath: string, prefix: string, suffix: string): string { - const contents = readFileSync(filePath, 'utf8'); - const match = contents.match(new RegExp(`export\\s+(?:async\\s+)?function\\s+(${prefix}[A-Za-z0-9]+${suffix})`)); - if (match) { - return match[1]; - } - const baseName = path.basename(filePath, path.extname(filePath)); - return `${prefix}${toPascalCase(baseName)}${suffix}`; -} - -function toPascalCase(value: string): string { - return value - .split(/[^a-zA-Z0-9]+/) - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(''); -} - -function toProjectRelativePath(fromPath: string, toPath: string): string { - return normalizeCliPath(path.relative(fromPath, toPath)); -} - -function normalizeCliPath(filePath: string): string { - return filePath.replace(/\\/g, '/'); -} - -function assertGeneratedWriteSafety(paths: string[], force: boolean): void { - if (force) { - return; - } - - const existingPaths = paths.filter((candidate) => existsSync(candidate)); - if (existingPaths.length === 0) { - return; - } - - throw new Error( - `Refusing to overwrite query-boundary generated feature test scaffold files without --force: ${existingPaths.map(normalizeCliPath).join(', ')}` - ); -} - -function writeFeatureFile(filePath: string, contents: string, force: boolean): void { - if (existsSync(filePath) && !force) { - return; - } - writeFileSync(filePath, contents, 'utf8'); -} - -function writeFileIfMissing(filePath: string, contents: string): void { - if (existsSync(filePath)) { - return; - } - writeFileSync(filePath, contents, 'utf8'); -} - -function dedupeStrings(values: string[]): string[] { - return [...new Set(values)]; -} diff --git a/packages/ztd-cli/src/commands/findings.ts b/packages/ztd-cli/src/commands/findings.ts deleted file mode 100644 index 60eb255a5..000000000 --- a/packages/ztd-cli/src/commands/findings.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { getAgentOutputFormat, parseJsonPayload } from '../utils/agentCli'; -import { validateFindingRegistry, type FindingRegistryIssue } from '../utils/findingRegistry'; - -export type FindingRegistryValidationFormat = 'human' | 'json'; - -export interface FindingRegistryValidationResult { - ok: boolean; - registryPath: string; - entriesChecked: number; - issues: FindingRegistryIssue[]; -} - -interface FindingsCommandOptions { - format?: string; - out?: string; - json?: string; -} - -/** Runtime/configuration error for finding registry validation (maps to exit code 2). */ -export class FindingRegistryValidationRuntimeError extends Error { - readonly exitCode = 2; -} - -/** Register finding-registry validation commands on the CLI root. */ -export function registerFindingRegistryCommand(program: Command): void { - const findings = program.command('findings').description('Validate machine-readable finding registry files'); - - findings - .command('validate ') - .description('Validate a machine-readable finding registry JSON file') - .option('--format ', 'Output format (human|json)') - .option('--out ', 'Write output to file') - .option('--json ', 'Pass command options as a JSON object') - .action(async (registryFile: string, options: FindingsCommandOptions) => { - try { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const format = resolveValidationFormat(merged.format); - const result = runValidateFindingRegistry(registryFile); - const text = formatFindingRegistryValidationResult(result, format); - const outPath = normalizeStringOption(merged.out); - if (outPath) { - mkdirSync(path.dirname(path.resolve(process.cwd(), outPath)), { recursive: true }); - writeFileSync(path.resolve(process.cwd(), outPath), text, 'utf8'); - } else { - const writer = result.ok ? console.log : console.error; - writer(text); - } - process.exitCode = result.ok ? 0 : 1; - } catch (error) { - process.exitCode = error instanceof FindingRegistryValidationRuntimeError ? 2 : 1; - console.error(error instanceof Error ? error.message : String(error)); - } - }); -} - -export function runValidateFindingRegistry(registryFile: string, rootDir = process.cwd()): FindingRegistryValidationResult { - const absolute = path.resolve(rootDir, registryFile); - if (!existsSync(absolute)) { - throw new FindingRegistryValidationRuntimeError(`Finding registry not found: ${absolute}`); - } - - let parsed: unknown; - try { - parsed = JSON.parse(readFileSync(absolute, 'utf8')); - } catch (error) { - throw new FindingRegistryValidationRuntimeError( - `Failed to parse finding registry JSON at ${absolute}: ${error instanceof Error ? error.message : String(error)}` - ); - } - - const issues = validateFindingRegistry(parsed); - return { - ok: issues.length === 0, - registryPath: absolute, - entriesChecked: Array.isArray(parsed) ? parsed.length : 0, - issues - }; -} - -export function formatFindingRegistryValidationResult( - result: FindingRegistryValidationResult, - format: FindingRegistryValidationFormat -): string { - if (format === 'json') { - return `${JSON.stringify(result, null, 2)}\n`; - } - - const lines = result.ok - ? [`Finding registry is valid: ${result.entriesChecked} entries checked.`] - : [ - `Finding registry has ${result.issues.length} issue(s) across ${result.entriesChecked} entries.`, - ...result.issues.map((issue) => formatIssue(issue)) - ]; - return `${lines.join('\n')}\n`; -} - -function formatIssue(issue: FindingRegistryIssue): string { - const location = issue.index >= 0 ? `entry ${issue.index}` : 'registry'; - return `- ${location} / ${issue.field}: ${issue.message}`; -} - -function resolveValidationFormat(value: unknown): FindingRegistryValidationFormat { - const explicit = normalizeStringOption(value); - if (explicit) { - const normalized = explicit.trim().toLowerCase(); - if (normalized === 'human' || normalized === 'json') { - return normalized; - } - throw new FindingRegistryValidationRuntimeError(`Unsupported format: ${explicit}`); - } - - return getAgentOutputFormat() === 'json' ? 'json' : 'human'; -} - -function normalizeStringOption(value: unknown): string | undefined { - if (value === undefined || value === null || value === '') { - return undefined; - } - if (typeof value !== 'string') { - throw new FindingRegistryValidationRuntimeError(`Expected a string option but received ${typeof value}.`); - } - return value; -} diff --git a/packages/ztd-cli/src/commands/genEntities.ts b/packages/ztd-cli/src/commands/genEntities.ts deleted file mode 100644 index c1f0e12e9..000000000 --- a/packages/ztd-cli/src/commands/genEntities.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { collectSqlFiles } from '../utils/collectSqlFiles'; -import { ensureDirectory } from '../utils/fs'; -import { mapSqlTypeToTs } from '../utils/typeMapper'; -import { snapshotTableMetadata, type TableMetadata } from './ztdConfig'; - -export interface GenerateEntitiesOptions { - directories: string[]; - extensions: string[]; - out: string; - dryRun?: boolean; -} - -export interface GenerateEntitiesResult { - outFile: string; - rendered: string; - tables: TableMetadata[]; - dryRun: boolean; -} - -export function runGenerateEntities(options: GenerateEntitiesOptions): GenerateEntitiesResult { - const sources = collectSqlFiles(options.directories, options.extensions); - if (sources.length === 0) { - throw new Error(`No SQL files were discovered under ${options.directories.join(', ')}`); - } - - const tables = snapshotTableMetadata(sources); - if (tables.length === 0) { - throw new Error('The provided DDL sources did not contain any CREATE TABLE statements.'); - } - - const output = renderEntitiesFile(tables); - if (!options.dryRun) { - ensureDirectory(path.dirname(options.out)); - writeFileSync(options.out, output, 'utf8'); - console.log(`Generated ${tables.length} schema helpers at ${options.out}`); - } - return { - outFile: options.out, - rendered: output, - tables, - dryRun: Boolean(options.dryRun) - }; -} - -function renderEntitiesFile(tables: TableMetadata[]): string { - // The header reminds maintainers that this file is a secondary reference next to .ztd/generated/ztd-row-map.generated.ts. - const header = [ - '// ENTITY HELPERS - AUTO GENERATED', - '// Complementary reference for tooling. TestRowMap in .ztd/generated/ztd-row-map.generated.ts remains authoritative.', - '' - ].join('\n'); - - // Emit an interface per table to keep column metadata available for optional helpers. - const definitions = tables - .map((table) => { - const entityName = table.testRowInterfaceName.replace(/TestRow$/, 'Entity'); - const fields = table.columns - .map((column) => { - const baseType = mapSqlTypeToTs(column.typeName, `${table.name}.${column.name}`); - const tsType = column.isNullable ? `${baseType} | null` : baseType; - return ` ${column.name}: ${tsType};`; - }) - .join('\n'); - return `export interface ${entityName} {\n${fields}\n}`; - }) - .join('\n\n'); - - return `${header}${definitions}\n`; -} diff --git a/packages/ztd-cli/src/commands/init.ts b/packages/ztd-cli/src/commands/init.ts deleted file mode 100644 index cc3a7fb36..000000000 --- a/packages/ztd-cli/src/commands/init.ts +++ /dev/null @@ -1,2899 +0,0 @@ -import { Command } from 'commander'; -import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import readline from 'node:readline/promises'; - -import { ensureDirectory } from '../utils/fs'; -import { - DEFAULT_ZTD_CONFIG, - resolveSupportDir, - writeZtdProjectConfig -} from '../utils/ztdProjectConfig'; -import { runGenerateZtdConfig, type ZtdConfigGenerationOptions } from './ztdConfig'; -import { runPullSchema, type PullSchemaOptions } from './pull'; -import { isJsonOutput, parseJsonPayload, writeCommandEnvelope } from '../utils/agentCli'; -import { rejectControlChars, rejectEncodedTraversal } from '../utils/agentSafety'; - -type PackageManager = 'pnpm' | 'npm' | 'yarn'; -type PackageInstallKind = 'devDependencies' | 'install'; -interface PnpmWorkspaceGuard { - workspaceRoot: string | null; - shouldIgnoreWorkspace: boolean; -} - -interface NpmWorkspaceGuard { - workspaceRoot: string | null; - shouldDisableWorkspaces: boolean; -} - -/** - * Prompt interface for interactive input during `ztd init`. - */ -export interface Prompter { - selectChoice(question: string, choices: string[]): Promise; - promptInput(question: string, example?: string): Promise; - promptInputWithDefault(question: string, defaultValue: string, example?: string): Promise; - confirm(question: string): Promise; - close(): void; -} - -/** - * Create a readline-backed prompter that reads from stdin/stdout. - */ -export function createConsolePrompter(): Prompter { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: process.stdout.isTTY - }); - - async function requestLine(question: string): Promise { - return (await rl.question(question)).trim(); - } - - async function requestLineWithDefault( - question: string, - defaultValue: string, - example?: string - ): Promise { - const prompt = `${question}${example ? ` (${example})` : ''} [default: ${defaultValue}]: `; - const answer = await requestLine(prompt); - return answer.length > 0 ? answer : defaultValue; - } - - return { - async selectChoice(question: string, choices: string[]): Promise { - while (true) { - console.log(question); - for (let i = 0; i < choices.length; i += 1) { - console.log(` ${i + 1}. ${choices[i]}`); - } - - const answer = await requestLine('Select an option: '); - const selected = Number(answer); - if (Number.isFinite(selected) && selected >= 1 && selected <= choices.length) { - return selected - 1; - } - console.log('Please choose a valid option number.'); - } - }, - - async promptInput(question: string, example?: string): Promise { - while (true) { - const answer = await requestLine(`${question}${example ? ` (${example})` : ''}: `); - if (answer.length > 0) { - return answer; - } - console.log('This value cannot be empty.'); - } - }, - - async promptInputWithDefault(question: string, defaultValue: string, example?: string): Promise { - return requestLineWithDefault(question, defaultValue, example); - }, - - async confirm(question: string): Promise { - while (true) { - const answer = (await requestLine(`${question} (y/N): `)).toLowerCase(); - if (answer === '') { - return false; - } - if (answer === 'y' || answer === 'yes') { - return true; - } - if (answer === 'n' || answer === 'no') { - return false; - } - console.log('Please respond with y(es) or n(o).'); - } - }, - - close(): void { - rl.close(); - } - }; -} - -type FileKey = - | 'schema' - | 'config' - | 'starterCompose' - | 'envExample' - | 'smokeSpec' - | 'localSourceGuardScript' - | 'smokeValidationTest' - | 'smokeEntrySpecTest' - | 'smokeQuerySpecTest' - | 'smokeQuerySpec' - | 'smokeQuerySql' - | 'smokeQueryTestsQuerySpecZtdTypes' - | 'smokeQueryTestsBasicCase' - | 'smokeQueryTestsGeneratedAnalysis' - | 'smokeQueryTestsGeneratedTestPlan' - | 'testsSupportZtdReadme' - | 'testsSupportZtdCaseTypes' - | 'testsSupportZtdVerifier' - | 'testsSupportZtdHarness' - | 'setupEnv' - | 'starterPostgresTestkit' - | 'infrastructureReadme' - | 'adaptersReadme' - | 'telemetryTypes' - | 'telemetryRepository' - | 'telemetryConsoleRepository' - | 'featureRootReadme' - | 'smokeReadme' - | 'smokeTestsReadme' - | 'globalSetup' - | 'vitestConfig' - | 'tsconfig' - | 'ztdDocsReadme' - | 'sqlReadme' - | 'readme' - | 'featureQueryExecutor' - | 'loadSqlResource' - | 'sqlClient' - | 'sqlClientAdapters' - | 'gitignore' - | 'editorconfig' - | 'prettierignore' - | 'prettier' - | 'package'; - -/** - * Summarizes how an individual file was created during initialization. - */ -export interface FileSummary { - relativePath: string; - outcome: 'created' | 'overwritten' | 'unchanged'; -} - -/** - * Result payload for `ztd init` describing outputs and next steps. - */ -export interface InitResult { - summary: string; - files: FileSummary[]; -} - -/** - * Dependency overrides used to orchestrate the init flow and IO side effects. - */ -export interface ZtdConfigWriterDependencies { - ensureDirectory: (directory: string) => void; - writeFile: (filePath: string, contents: string) => void; - fileExists: (filePath: string) => boolean; - runPullSchema: (options: PullSchemaOptions) => Promise | unknown; - runGenerateZtdConfig: (options: ZtdConfigGenerationOptions) => Promise | unknown; - checkPgDump: () => boolean; - log: (message: string) => void; - installPackages: (options: { - rootDir: string; - kind: PackageInstallKind; - packages: string[]; - packageManager: PackageManager; - }) => Promise | void; -} - -/** - * Options for configuring the `ztd init` command execution. - */ -export interface InitCommandOptions { - rootDir?: string; - dependencies?: Partial; - appShape?: InitAppShape; - starter?: boolean; - postgresImage?: string; - skipInstall?: boolean; - forceOverwrite?: boolean; - nonInteractive?: boolean; - workflow?: InitWorkflow; - validator?: ValidatorBackend; - localSourceRoot?: string; - dryRun?: boolean; -} - -type ValidatorBackend = 'none' | 'zod' | 'arktype'; -type InitDependencyProfile = 'registry' | 'local-source'; -type InitAppShape = 'default' | 'webapi'; - -interface OptionalFeatures { - validator: ValidatorBackend; -} - -interface InitScaffoldProfile { - dependencyProfile: InitDependencyProfile; - localSourceRoot: string | null; -} - -interface InitScaffoldLayout { - readmeTemplate: string; - featureQueryExecutorPath: string; - featureQueryExecutorTemplate: string; - loadSqlResourcePath: string; - loadSqlResourceTemplate: string; - sqlClientTemplate: string; - sqlClientAdaptersTemplate: string; - envExamplePath: string; - envExampleTemplate: string; - featureReadmePath: string; - featureReadmeTemplate: string; - smokeReadmePath: string; - smokeReadmeTemplate: string; - smokeTestsReadmePath: string; - smokeTestsReadmeTemplate: string; - smokeSpecPath: string; - smokeSpecTemplate: string; - smokeValidationTestPath: string; - smokeValidationTestTemplate: string; - smokeEntrySpecTestPath: string; - smokeEntrySpecTestTemplate: string; - smokeQuerySpecTestPath: string; - smokeQuerySpecTestTemplate: string; - smokeQuerySpecPath: string; - smokeQuerySpecTemplate: string; - smokeQuerySqlPath: string; - smokeQuerySqlTemplate: string; - smokeQueryTestsQuerySpecZtdTypesPath: string; - smokeQueryTestsQuerySpecZtdTypesTemplate: string; - smokeQueryTestsBasicCasePath: string; - smokeQueryTestsBasicCaseTemplate: string; - smokeQueryTestsGeneratedAnalysisPath: string; - smokeQueryTestsGeneratedAnalysisTemplate: string; - smokeQueryTestsGeneratedTestPlanPath: string; - smokeQueryTestsGeneratedTestPlanTemplate: string; - testsSupportZtdReadmePath: string; - testsSupportZtdReadmeTemplate: string; - testsSupportZtdCaseTypesPath: string; - testsSupportZtdCaseTypesTemplate: string; - testsSupportZtdVerifierPath: string; - testsSupportZtdVerifierTemplate: string; - testsSupportZtdHarnessPath: string; - testsSupportZtdHarnessTemplate: string; - setupEnvPath: string; - setupEnvTemplate: string; - starterPostgresTestkitPath: string; - starterPostgresTestkitTemplate: string; - infrastructureReadmePath: string; - infrastructureReadmeTemplate: string; - telemetryTypesPath: string; - telemetryTypesTemplate: string; - telemetryRepositoryPath: string; - telemetryRepositoryTemplate: string; - telemetryConsoleRepositoryPath: string; - telemetryConsoleRepositoryTemplate: string; - sqlClientPath: string; - sqlClientAdaptersPath: string; -} - -const STACK_DEV_DEPENDENCIES: Record = { - '@rawsql-ts/testkit-core': '^0.16.1', - '@rawsql-ts/ztd-cli': resolveCurrentCliVersion(), -}; -const STACK_RUNTIME_DEPENDENCIES: Record = { - '@rawsql-ts/driver-adapter-core': '^0.2.0' -}; -const STARTER_DEV_DEPENDENCIES: Record = { - pg: '^8.13.1', - '@types/pg': '^8.15.6', - '@rawsql-ts/testkit-postgres': '^0.15.4' -}; -const LOCAL_SOURCE_STACK_PACKAGE_DIRS: Record = { - '@rawsql-ts/testkit-core': path.join('packages', 'testkit-core'), - '@rawsql-ts/ztd-cli': path.join('packages', 'ztd-cli') -}; -const LOCAL_SOURCE_RUNTIME_PACKAGE_DIRS: Record = { - '@rawsql-ts/driver-adapter-core': path.join('packages', 'drivers', 'driver-adapter-core') -}; -const LOCAL_SOURCE_STACK_PACKAGE_DIRS_STARTER: Record = { - '@rawsql-ts/testkit-postgres': path.join('packages', 'testkit-postgres') -}; -const ZOD_DEPENDENCY: Record = { - zod: '^4.3.6' -}; -const ARKTYPE_DEPENDENCY: Record = { - arktype: '2.2.0' -}; - -function resolveCurrentCliVersion(): string { - const candidates = [ - path.resolve(__dirname, '..', '..', 'package.json'), - path.resolve(__dirname, '..', '..', '..', 'package.json'), - ]; - - for (const candidate of candidates) { - try { - const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as { version?: string }; - if (typeof parsed.version === 'string' && parsed.version.length > 0) { - return `^${parsed.version}`; - } - } catch { - continue; - } - } - - return '^0.23.0'; -} - -async function gatherOptionalFeatures( - prompter: Prompter, - _dependencies: ZtdConfigWriterDependencies, - validatorOverride?: ValidatorBackend -): Promise { - if (validatorOverride) { - return { - validator: validatorOverride - }; - } - const validatorChoice = await prompter.selectChoice( - 'Standard ztd-cli scaffold is runtime-free. Install an optional runtime validator compatibility backend?', - ['None (runtime-free standard)', 'Zod (compatibility)', 'ArkType (compatibility)'] - ); - const validator: ValidatorBackend = validatorChoice === 0 ? 'none' : validatorChoice === 1 ? 'zod' : 'arktype'; - return { - validator - }; -} - -type InitWorkflow = 'pg_dump' | 'empty' | 'demo'; - -const README_TEMPLATE = 'README.md'; -const DEFAULT_POSTGRES_IMAGE = 'postgres:18'; -const STARTER_COMPOSE_FILE = 'compose.yaml'; -const ENV_EXAMPLE_TEMPLATE = '.env.example'; -const FEATURE_ROOT_README_TEMPLATE = 'src/features/README.md'; -const FEATURE_SMOKE_README_TEMPLATE = 'src/features/smoke/README.md'; -const FEATURE_SMOKE_TESTS_README_TEMPLATE = 'src/features/smoke/tests/README.md'; -const FEATURE_SMOKE_SPEC_TEMPLATE = 'src/features/smoke/boundary.ts'; -const FEATURE_SMOKE_VALIDATION_TEST_TEMPLATE = 'src/features/smoke/tests/smoke.validation.test.ts'; -const FEATURE_SMOKE_ENTRYSPEC_TEST_TEMPLATE = 'src/features/smoke/tests/smoke.boundary.test.ts'; -const FEATURE_SMOKE_QUERYSPEC_TEST_TEMPLATE = 'src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts'; -const FEATURE_SMOKE_QUERY_SPEC_TEMPLATE = 'src/features/smoke/queries/smoke/boundary.ts'; -const FEATURE_SMOKE_QUERY_SQL_TEMPLATE = 'src/features/smoke/queries/smoke/smoke.sql'; -const FEATURE_SHARED_FEATURE_QUERY_EXECUTOR_TEMPLATE = 'src/features/_shared/featureQueryExecutor.ts'; -const FEATURE_SHARED_LOAD_SQL_RESOURCE_TEMPLATE = 'src/features/_shared/loadSqlResource.ts'; -const FEATURE_SMOKE_QUERY_TESTS_QUERYSPEC_ZTD_TYPES_TEMPLATE = 'src/features/smoke/queries/smoke/tests/boundary-ztd-types.ts'; -const FEATURE_SMOKE_QUERY_TESTS_BASIC_CASE_TEMPLATE = 'src/features/smoke/queries/smoke/tests/cases/basic.case.ts'; -const FEATURE_SMOKE_QUERY_TESTS_GENERATED_ANALYSIS_TEMPLATE = 'src/features/smoke/queries/smoke/tests/generated/analysis.json'; -const FEATURE_SMOKE_QUERY_TESTS_GENERATED_TEST_PLAN_TEMPLATE = 'src/features/smoke/queries/smoke/tests/generated/TEST_PLAN.md'; -const TESTS_SUPPORT_ZTD_README_TEMPLATE = 'tests/support/ztd/README.md'; -const TESTS_SUPPORT_ZTD_CASE_TYPES_TEMPLATE = 'tests/support/ztd/case-types.ts'; -const TESTS_SUPPORT_ZTD_VERIFIER_TEMPLATE = 'tests/support/ztd/verifier.ts'; -const TESTS_SUPPORT_ZTD_HARNESS_TEMPLATE = 'tests/support/ztd/harness.ts'; -const LOCAL_SOURCE_GUARD_TEMPLATE = 'scripts/local-source-guard.mjs'; -const GLOBAL_SETUP_TEMPLATE = 'tests/support/global-setup.ts'; -const SETUP_ENV_TEMPLATE = 'tests/support/setup-env.ts'; -const VITEST_CONFIG_TEMPLATE = 'vitest.config.ts'; -const TSCONFIG_TEMPLATE = 'tsconfig.json'; -const SQL_CLIENT_TEMPLATE = 'src/libraries/sql/sql-client.ts'; -const SQL_CLIENT_ADAPTERS_TEMPLATE = 'src/adapters/pg/sql-client.ts'; -const INFRASTRUCTURE_README_TEMPLATE = 'src/libraries/README.md'; -const ADAPTERS_README_TEMPLATE = 'src/adapters/README.md'; -const TELEMETRY_TYPES_TEMPLATE = 'src/libraries/telemetry/types.ts'; -const TELEMETRY_REPOSITORY_TEMPLATE = 'src/libraries/telemetry/repositoryTelemetry.ts'; -const TELEMETRY_CONSOLE_REPOSITORY_TEMPLATE = 'src/adapters/console/repositoryTelemetry.ts'; -const SQL_README_TEMPLATE = 'src/libraries/sql/README.md'; -const JOBS_README_TEMPLATE = 'src/jobs/README.md'; -const ZTD_README_TEMPLATE = 'ztd/README.md'; -const ZTD_DDL_DEMO_TEMPLATE = 'db/ddl/demo.sql'; -const STARTER_DB_READY_NOTE = - 'Wait until Postgres is ready before the DB-backed smoke path; if the container just started, rerun the QuerySpec smoke test once.'; - -const EMPTY_SCHEMA_COMMENT = (schemaName: string): string => - [ - `-- DDL for schema "${schemaName}".`, - '-- Add CREATE TABLE statements here.', - '' - ].join('\n'); - -const STARTER_SCHEMA_TEMPLATE = (schemaName: string): string => - [ - `-- Starter DDL for schema "${schemaName}".`, - '-- The starter flow begins with a single users table so the first feature stays obvious.', - '', - 'create table users (', - ' user_id bigint generated by default as identity primary key,', - ' email text not null unique,', - ' display_name text not null,', - ' is_active boolean not null default true,', - ' created_at timestamptz not null default current_timestamp', - ');', - '', - 'comment on table users is', - " 'Starter user directory for the first CRUD feature.';", - '', - 'comment on column users.user_id is', - " 'Primary key for a user.';", - 'comment on column users.email is', - " 'Email address for the user.';", - 'comment on column users.display_name is', - " 'Human-readable display name for the user.';", - 'comment on column users.is_active is', - " 'Whether the user is active.';", - 'comment on column users.created_at is', - " 'Timestamp when the user row was created.';", - '' - ].join('\n'); - -const STARTER_README_APPENDIX = (postgresImage: string, ztdCommand: string): string => - [ - '## Starter Flow', - '', - '1. Start by reading `src/features/smoke/` as the starter-only sample feature.', - '2. Run the DB-free smoke tests first with `npx vitest run src/features/smoke/tests/smoke.boundary.test.ts src/features/smoke/tests/smoke.validation.test.ts`.', - '3. Copy `.env.example` to `.env` and update `ZTD_DB_PORT` if 5432 is already in use.', - '4. Start Postgres with `docker compose up -d` when you are ready for the DB-backed smoke path.', - `5. ${STARTER_DB_READY_NOTE}`, - `6. The bundled compose file uses \`${postgresImage}\`, and the generated Vitest setup derives \`ZTD_DB_URL\` from \`ZTD_DB_PORT\`.`, - `7. Run \`${ztdCommand} ztd-config\` to regenerate the runtime fixture manifest, DDL-derived test rows, and layout metadata.`, - '8. Read `tests/support/ztd/harness.ts` and `src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts` to see the DB-backed starter smoke path through the shared query-boundary harness and starter DB wiring.', - '9. Run `npx vitest run` to exercise the DB-free and DB-backed smoke tests with the values from `.env`.', - `10. Run \`${ztdCommand} feature scaffold --table users --action insert\` to create the first fixed feature shell.`, - `11. After you finish SQL and DTO edits, run \`${ztdCommand} feature tests scaffold --feature users-insert\` to create TODO-based test scaffolds, then let AI complete them.`, - '' - ].join('\n'); - -const STARTER_COMPOSE_TEMPLATE = (postgresImage: string): string => - [ - '# Starter Postgres environment for ZTD tests.', - '# Start it with: docker compose up -d', - '# Copy .env.example to .env and update ZTD_DB_PORT if 5432 is already in use.', - '# docker compose reads .env from the project root for variable substitution.', - '', - 'services:', - ' postgres:', - ` image: ${postgresImage}`, - ' environment:', - ' POSTGRES_DB: ztd', - ' POSTGRES_PASSWORD: ztd', - ' POSTGRES_USER: ztd', - ' ports:', - ' - "${ZTD_DB_PORT:-5432}:5432"', - '' - ].join('\n'); - -const DEMO_SCHEMA_TEMPLATE = (_schemaName: string): string => { - return loadTemplate(ZTD_DDL_DEMO_TEMPLATE); -}; - -function buildStarterReadmeContents(rootDir: string, scaffoldProfile: InitScaffoldProfile, postgresImage: string): string { - const base = loadTemplate(README_TEMPLATE).replace(/\r\n/g, '\n').trimEnd(); - return `${base}\n\n${STARTER_README_APPENDIX(postgresImage, resolveInitZtdCommand(rootDir, scaffoldProfile))}`; -} - -function resolveInitZtdCommand(rootDir: string, scaffoldProfile: InitScaffoldProfile): string { - const packageManager = detectPackageManager(rootDir, defaultPackageManagerForScaffold(scaffoldProfile)); - if (scaffoldProfile.dependencyProfile === 'local-source') { - return packageManager === 'npm' ? 'npm run ztd --' : `${packageManager} ztd`; - } - return packageManager === 'npm' ? 'npx ztd' : packageManager === 'yarn' ? 'yarn exec ztd' : 'pnpm exec ztd'; -} - -function resolveInitScaffoldLayout(rootDir: string, _appShape: InitAppShape): InitScaffoldLayout { - const supportDir = resolveSupportDir(DEFAULT_ZTD_CONFIG); - return { - readmeTemplate: README_TEMPLATE, - featureQueryExecutorPath: path.join(rootDir, 'src', 'features', '_shared', 'featureQueryExecutor.ts'), - featureQueryExecutorTemplate: FEATURE_SHARED_FEATURE_QUERY_EXECUTOR_TEMPLATE, - loadSqlResourcePath: path.join(rootDir, 'src', 'features', '_shared', 'loadSqlResource.ts'), - loadSqlResourceTemplate: FEATURE_SHARED_LOAD_SQL_RESOURCE_TEMPLATE, - sqlClientTemplate: SQL_CLIENT_TEMPLATE, - sqlClientAdaptersTemplate: SQL_CLIENT_ADAPTERS_TEMPLATE, - envExamplePath: path.join(rootDir, '.env.example'), - envExampleTemplate: ENV_EXAMPLE_TEMPLATE, - featureReadmePath: path.join(rootDir, 'src', 'features', 'README.md'), - featureReadmeTemplate: FEATURE_ROOT_README_TEMPLATE, - smokeReadmePath: path.join(rootDir, 'src', 'features', 'smoke', 'README.md'), - smokeReadmeTemplate: FEATURE_SMOKE_README_TEMPLATE, - smokeTestsReadmePath: path.join(rootDir, 'src', 'features', 'smoke', 'tests', 'README.md'), - smokeTestsReadmeTemplate: FEATURE_SMOKE_TESTS_README_TEMPLATE, - smokeSpecPath: path.join(rootDir, 'src', 'features', 'smoke', 'boundary.ts'), - smokeSpecTemplate: FEATURE_SMOKE_SPEC_TEMPLATE, - smokeValidationTestPath: path.join(rootDir, 'src', 'features', 'smoke', 'tests', 'smoke.validation.test.ts'), - smokeValidationTestTemplate: FEATURE_SMOKE_VALIDATION_TEST_TEMPLATE, - smokeEntrySpecTestPath: path.join(rootDir, 'src', 'features', 'smoke', 'tests', 'smoke.boundary.test.ts'), - smokeEntrySpecTestTemplate: FEATURE_SMOKE_ENTRYSPEC_TEST_TEMPLATE, - smokeQuerySpecTestPath: path.join(rootDir, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'smoke.boundary.ztd.test.ts'), - smokeQuerySpecTestTemplate: FEATURE_SMOKE_QUERYSPEC_TEST_TEMPLATE, - smokeQuerySpecPath: path.join(rootDir, 'src', 'features', 'smoke', 'queries', 'smoke', 'boundary.ts'), - smokeQuerySpecTemplate: FEATURE_SMOKE_QUERY_SPEC_TEMPLATE, - smokeQuerySqlPath: path.join(rootDir, 'src', 'features', 'smoke', 'queries', 'smoke', 'smoke.sql'), - smokeQuerySqlTemplate: FEATURE_SMOKE_QUERY_SQL_TEMPLATE, - smokeQueryTestsQuerySpecZtdTypesPath: path.join(rootDir, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'boundary-ztd-types.ts'), - smokeQueryTestsQuerySpecZtdTypesTemplate: FEATURE_SMOKE_QUERY_TESTS_QUERYSPEC_ZTD_TYPES_TEMPLATE, - smokeQueryTestsBasicCasePath: path.join(rootDir, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'cases', 'basic.case.ts'), - smokeQueryTestsBasicCaseTemplate: FEATURE_SMOKE_QUERY_TESTS_BASIC_CASE_TEMPLATE, - smokeQueryTestsGeneratedAnalysisPath: path.join(rootDir, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated', 'analysis.json'), - smokeQueryTestsGeneratedAnalysisTemplate: FEATURE_SMOKE_QUERY_TESTS_GENERATED_ANALYSIS_TEMPLATE, - smokeQueryTestsGeneratedTestPlanPath: path.join(rootDir, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated', 'TEST_PLAN.md'), - smokeQueryTestsGeneratedTestPlanTemplate: FEATURE_SMOKE_QUERY_TESTS_GENERATED_TEST_PLAN_TEMPLATE, - testsSupportZtdReadmePath: path.join(rootDir, 'tests', 'support', 'ztd', 'README.md'), - testsSupportZtdReadmeTemplate: TESTS_SUPPORT_ZTD_README_TEMPLATE, - testsSupportZtdCaseTypesPath: path.join(rootDir, 'tests', 'support', 'ztd', 'case-types.ts'), - testsSupportZtdCaseTypesTemplate: TESTS_SUPPORT_ZTD_CASE_TYPES_TEMPLATE, - testsSupportZtdVerifierPath: path.join(rootDir, 'tests', 'support', 'ztd', 'verifier.ts'), - testsSupportZtdVerifierTemplate: TESTS_SUPPORT_ZTD_VERIFIER_TEMPLATE, - testsSupportZtdHarnessPath: path.join(rootDir, 'tests', 'support', 'ztd', 'harness.ts'), - testsSupportZtdHarnessTemplate: TESTS_SUPPORT_ZTD_HARNESS_TEMPLATE, - setupEnvPath: path.join(rootDir, supportDir, 'setup-env.ts'), - setupEnvTemplate: SETUP_ENV_TEMPLATE, - starterPostgresTestkitPath: path.join(rootDir, supportDir, 'postgres-testkit.ts'), - starterPostgresTestkitTemplate: 'tests/support/postgres-testkit.ts', - infrastructureReadmePath: path.join(rootDir, 'src', 'libraries', 'README.md'), - infrastructureReadmeTemplate: INFRASTRUCTURE_README_TEMPLATE, - telemetryTypesPath: path.join(rootDir, 'src', 'libraries', 'telemetry', 'types.ts'), - telemetryTypesTemplate: TELEMETRY_TYPES_TEMPLATE, - telemetryRepositoryPath: path.join(rootDir, 'src', 'libraries', 'telemetry', 'repositoryTelemetry.ts'), - telemetryRepositoryTemplate: TELEMETRY_REPOSITORY_TEMPLATE, - telemetryConsoleRepositoryPath: path.join(rootDir, 'src', 'adapters', 'console', 'repositoryTelemetry.ts'), - telemetryConsoleRepositoryTemplate: TELEMETRY_CONSOLE_REPOSITORY_TEMPLATE, - sqlClientPath: path.join(rootDir, 'src', 'libraries', 'sql', 'sql-client.ts'), - sqlClientAdaptersPath: path.join(rootDir, 'src', 'adapters', 'pg', 'sql-client.ts') - }; -} - -function resolveTemplateDirectory(): string { - const candidates = [ - // Prefer the installed package layout: /dist/commands → /templates. - path.resolve(__dirname, '..', '..', '..', 'templates'), - // Support legacy layouts that copied templates into dist/. - path.resolve(__dirname, '..', '..', 'templates'), - // Support running tests directly from the monorepo source tree. - path.resolve(process.cwd(), 'packages', 'ztd-cli', 'templates') - ]; - - // Pick the first directory that contains the expected template entrypoint. - for (const candidate of candidates) { - if (existsSync(path.join(candidate, README_TEMPLATE))) { - return candidate; - } - } - - return candidates[0]; -} - -// Resolve templates from a shipped directory so `ztd init` works after `npm install`. -const TEMPLATE_DIRECTORY = resolveTemplateDirectory(); - -const DEFAULT_DEPENDENCIES: ZtdConfigWriterDependencies = { - ensureDirectory, - writeFile: (filePath, contents) => writeFileSync(filePath, contents, 'utf8'), - fileExists: (filePath) => existsSync(filePath), - runPullSchema, - runGenerateZtdConfig, - checkPgDump: () => { - const executable = process.env.PG_DUMP_PATH ?? 'pg_dump'; - const result = spawnSync(executable, ['--version'], { stdio: 'ignore' }); - return result.status === 0 && !result.error; - }, - log: (message: string) => { - console.log(message); - }, - installPackages: ({ rootDir, kind, packages, packageManager }) => { - // Use the Windows shim executables so spawnSync finds the package manager in PATH. - const executable = resolvePackageManagerExecutable(packageManager); - const shellExecutable = resolvePackageManagerShellExecutable(executable, packageManager); - const args = buildPackageManagerArgs(kind, packageManager, packages, rootDir); - if (args.length === 0) { - return; - } - - const isWin32 = process.platform === 'win32'; - // Prefer shell execution for .cmd/.bat on Windows to avoid a guaranteed failure. - const preferShell = isWin32 && /\.(cmd|bat)$/i.test(executable); - const baseSpawnOptions = { - cwd: rootDir, - stdio: 'inherit' as const, - shell: false - }; - const shellSpawnOptions = { - ...baseSpawnOptions, - shell: true - }; - - let result = spawnSync(preferShell ? shellExecutable : executable, args, preferShell ? shellSpawnOptions : baseSpawnOptions); - if (result.error && isWin32 && !preferShell) { - // Retry with cmd.exe only on Windows so .cmd shims resolve reliably. - result = spawnSync(shellExecutable, args, shellSpawnOptions); - } - if ((result.error || result.status !== 0) && executable !== packageManager) { - // Retry with the bare command name in case a resolved path is rejected. - result = spawnSync(packageManager, args, baseSpawnOptions); - if (result.error && isWin32) { - // Final fallback to shell when the bare command still fails on Windows. - result = spawnSync(packageManager, args, shellSpawnOptions); - } - } - if (result.error || result.status !== 0) { - const base = `Failed to run ${packageManager} ${args.join(' ')}`; - const reason = result.error - ? `: ${result.error.message}` - : ` (exit code: ${result.status ?? 'unknown'}, signal: ${result.signal ?? 'none'})`; - throw new Error(`${base}${reason}`); - } - } -}; - -/** - * Run the interactive `ztd init` workflow and return the resulting summary. - */ -export async function runInitCommand(prompter: Prompter, options?: InitCommandOptions): Promise { - const rootDir = options?.rootDir ?? process.cwd(); - const dependencies: ZtdConfigWriterDependencies = { - ...DEFAULT_DEPENDENCIES, - ...(options?.dependencies ?? {}) - }; - const overwritePolicy = { - force: options?.forceOverwrite ?? false, - nonInteractive: options?.nonInteractive ?? false - }; - - // Determine workflow: use explicit flag, non-interactive default, starter preset, or prompt. - let workflow: InitWorkflow; - let starter = options?.starter === true; - if (options?.workflow) { - workflow = options.workflow; - } else if (starter) { - workflow = 'demo'; - } else if (overwritePolicy.nonInteractive) { - workflow = 'demo'; - } else { - const workflowChoice = await prompter.selectChoice( - 'How do you want to start your database workflow?', - [ - 'Starter (recommended): compose, smoke tests, and sample DDL', - 'Pull schema from Postgres (pg_dump)', - 'Create empty scaffold (I will write DDL)', - 'Create scaffold with demo DDL (no app code)' - ] - ); - starter = workflowChoice === 0; - workflow = workflowChoice === 0 ? 'demo' : workflowChoice === 1 ? 'pg_dump' : workflowChoice === 2 ? 'empty' : 'demo'; - } - - if (overwritePolicy.nonInteractive && workflow === 'pg_dump') { - throw new Error( - 'Non-interactive mode does not support the pg_dump workflow (requires connection string prompt).' - ); - } - - const schemaName = normalizeSchemaName(DEFAULT_ZTD_CONFIG.defaultSchema); - const schemaFileName = `${sanitizeSchemaFileName(schemaName)}.sql`; - const appShape: InitAppShape = options?.appShape ?? 'default'; - const postgresImage = options?.postgresImage?.trim() || DEFAULT_POSTGRES_IMAGE; - const scaffoldLayout = resolveInitScaffoldLayout(rootDir, appShape); - const supportDir = resolveSupportDir(DEFAULT_ZTD_CONFIG); - - const absolutePaths: Record = { - schema: path.join(rootDir, DEFAULT_ZTD_CONFIG.ddlDir, schemaFileName), - config: path.join(rootDir, 'ztd.config.json'), - starterCompose: path.join(rootDir, STARTER_COMPOSE_FILE), - envExample: path.join(rootDir, '.env.example'), - setupEnv: path.join(rootDir, supportDir, 'setup-env.ts'), - featureQueryExecutor: path.join(rootDir, 'src', 'features', '_shared', 'featureQueryExecutor.ts'), - loadSqlResource: path.join(rootDir, 'src', 'features', '_shared', 'loadSqlResource.ts'), - localSourceGuardScript: path.join(rootDir, 'scripts', 'local-source-guard.mjs'), - featureRootReadme: scaffoldLayout.featureReadmePath, - smokeReadme: scaffoldLayout.smokeReadmePath, - smokeTestsReadme: scaffoldLayout.smokeTestsReadmePath, - smokeSpec: scaffoldLayout.smokeSpecPath, - smokeValidationTest: scaffoldLayout.smokeValidationTestPath, - smokeEntrySpecTest: scaffoldLayout.smokeEntrySpecTestPath, - smokeQuerySpecTest: scaffoldLayout.smokeQuerySpecTestPath, - smokeQuerySpec: scaffoldLayout.smokeQuerySpecPath, - smokeQuerySql: scaffoldLayout.smokeQuerySqlPath, - smokeQueryTestsQuerySpecZtdTypes: scaffoldLayout.smokeQueryTestsQuerySpecZtdTypesPath, - smokeQueryTestsBasicCase: scaffoldLayout.smokeQueryTestsBasicCasePath, - smokeQueryTestsGeneratedAnalysis: scaffoldLayout.smokeQueryTestsGeneratedAnalysisPath, - smokeQueryTestsGeneratedTestPlan: scaffoldLayout.smokeQueryTestsGeneratedTestPlanPath, - testsSupportZtdReadme: scaffoldLayout.testsSupportZtdReadmePath, - testsSupportZtdCaseTypes: scaffoldLayout.testsSupportZtdCaseTypesPath, - testsSupportZtdVerifier: scaffoldLayout.testsSupportZtdVerifierPath, - testsSupportZtdHarness: scaffoldLayout.testsSupportZtdHarnessPath, - infrastructureReadme: scaffoldLayout.infrastructureReadmePath, - adaptersReadme: path.join(rootDir, 'src', 'adapters', 'README.md'), - telemetryTypes: scaffoldLayout.telemetryTypesPath, - telemetryRepository: scaffoldLayout.telemetryRepositoryPath, - telemetryConsoleRepository: scaffoldLayout.telemetryConsoleRepositoryPath, - starterPostgresTestkit: scaffoldLayout.starterPostgresTestkitPath, - readme: path.join(rootDir, 'README.md'), - sqlReadme: path.join(rootDir, 'src', 'libraries', 'sql', 'README.md'), - sqlClient: scaffoldLayout.sqlClientPath, - sqlClientAdapters: scaffoldLayout.sqlClientAdaptersPath, - globalSetup: path.join(rootDir, supportDir, 'global-setup.ts'), - vitestConfig: path.join(rootDir, 'vitest.config.ts'), - tsconfig: path.join(rootDir, 'tsconfig.json'), - ztdDocsReadme: path.join(rootDir, 'db', 'README.md'), - gitignore: path.join(rootDir, '.gitignore'), - editorconfig: path.join(rootDir, '.editorconfig'), - prettierignore: path.join(rootDir, '.prettierignore'), - prettier: path.join(rootDir, '.prettierrc'), - package: path.join(rootDir, 'package.json') - }; - - const relativePath = (key: FileKey): string => - path.relative(rootDir, absolutePaths[key]).replace(/\\/g, '/') || absolutePaths[key]; - - const summaries: Partial> = {}; - const scaffoldProfile = resolveInitScaffoldProfile(rootDir, options?.localSourceRoot, starter); - - // Ask how the user prefers to populate the initial schema. - if (workflow === 'pg_dump') { - // Database-first path: pull the schema before writing any DDL files. - if (!dependencies.checkPgDump()) { - throw new Error('Unable to find pg_dump. Install Postgres or set PG_DUMP_PATH before running ztd init.'); - } - const connectionString = await prompter.promptInput( - 'Enter the Postgres connection string for your database', - 'postgres://user:pass@host:5432/db' - ); - - const schemaSummary = await writeFileWithConsent( - absolutePaths.schema, - relativePath('schema'), - dependencies, - prompter, - overwritePolicy, - async () => { - dependencies.ensureDirectory(path.dirname(absolutePaths.schema)); - await dependencies.runPullSchema({ - url: connectionString, - out: path.dirname(absolutePaths.schema), - schemas: [schemaName] - }); - } - ); - - summaries.schema = schemaSummary; - } else if (workflow === 'empty') { - // Manual path: seed the DDL directory with a starter schema so ztd-config can run. - const schemaSummary = await writeFileWithConsent( - absolutePaths.schema, - relativePath('schema'), - dependencies, - prompter, - overwritePolicy, - async () => { - dependencies.ensureDirectory(path.dirname(absolutePaths.schema)); - dependencies.writeFile(absolutePaths.schema, EMPTY_SCHEMA_COMMENT(schemaName)); - } - ); - - summaries.schema = schemaSummary; - } else { - const schemaSummary = await writeFileWithConsent( - absolutePaths.schema, - relativePath('schema'), - dependencies, - prompter, - overwritePolicy, - async () => { - dependencies.ensureDirectory(path.dirname(absolutePaths.schema)); - dependencies.writeFile( - absolutePaths.schema, - starter ? STARTER_SCHEMA_TEMPLATE(schemaName) : DEMO_SCHEMA_TEMPLATE(schemaName) - ); - } - ); - - summaries.schema = schemaSummary; - } - - // Seed the ztd.config.json defaults so downstream tooling knows where ddl/tests live. - const configSummary = await writeFileWithConsent( - absolutePaths.config, - relativePath('config'), - dependencies, - prompter, - overwritePolicy, - () => { - writeZtdProjectConfig(rootDir, { - ztdRootDir: '.ztd', - defaultSchema: schemaName, - searchPath: [schemaName] - }); - } - ); - summaries.config = configSummary; - - const validatorOverride = options?.validator ?? (starter || overwritePolicy.nonInteractive ? 'none' : undefined); - const optionalFeatures = await gatherOptionalFeatures( - prompter, - dependencies, - validatorOverride - ); - - // Emit supporting documentation that describes the workflow for contributors. - const readmeSummary = await writeDocFile( - absolutePaths.readme, - relativePath('readme'), - starter ? buildStarterReadmeContents(rootDir, scaffoldProfile, postgresImage) : loadTemplate(README_TEMPLATE), - dependencies, - prompter, - overwritePolicy - ); - if (readmeSummary) { - summaries.readme = readmeSummary; - } - - if (starter) { - const composeSummary = await writeDocFile( - absolutePaths.starterCompose, - relativePath('starterCompose'), - STARTER_COMPOSE_TEMPLATE(postgresImage), - dependencies, - prompter, - overwritePolicy - ); - summaries.starterCompose = composeSummary; - } - - const featureRootReadmeSummary = await writeTemplateFile( - rootDir, - absolutePaths.featureRootReadme, - relativePath('featureRootReadme'), - scaffoldLayout.featureReadmeTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (featureRootReadmeSummary) { - summaries.featureRootReadme = featureRootReadmeSummary; - } - - if (starter) { - const smokeReadmeSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeReadme, - relativePath('smokeReadme'), - scaffoldLayout.smokeReadmeTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeReadmeSummary) { - summaries.smokeReadme = smokeReadmeSummary; - } - - const smokeTestsReadmeSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeTestsReadme, - relativePath('smokeTestsReadme'), - scaffoldLayout.smokeTestsReadmeTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeTestsReadmeSummary) { - summaries.smokeTestsReadme = smokeTestsReadmeSummary; - } - - const smokeSpecSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeSpec, - relativePath('smokeSpec'), - scaffoldLayout.smokeSpecTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeSpecSummary) { - summaries.smokeSpec = smokeSpecSummary; - } - - const smokeValidationTestSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeValidationTest, - relativePath('smokeValidationTest'), - scaffoldLayout.smokeValidationTestTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeValidationTestSummary) { - summaries.smokeValidationTest = smokeValidationTestSummary; - } - - const smokeEntrySpecTestSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeEntrySpecTest, - relativePath('smokeEntrySpecTest'), - scaffoldLayout.smokeEntrySpecTestTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeEntrySpecTestSummary) { - summaries.smokeEntrySpecTest = smokeEntrySpecTestSummary; - } - - const smokeQuerySpecTestSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeQuerySpecTest, - relativePath('smokeQuerySpecTest'), - scaffoldLayout.smokeQuerySpecTestTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeQuerySpecTestSummary) { - summaries.smokeQuerySpecTest = smokeQuerySpecTestSummary; - } - - const smokeQuerySpecSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeQuerySpec, - relativePath('smokeQuerySpec'), - scaffoldLayout.smokeQuerySpecTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeQuerySpecSummary) { - summaries.smokeQuerySpec = smokeQuerySpecSummary; - } - - const smokeQuerySqlSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeQuerySql, - relativePath('smokeQuerySql'), - scaffoldLayout.smokeQuerySqlTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeQuerySqlSummary) { - summaries.smokeQuerySql = smokeQuerySqlSummary; - } - - const smokeQueryTestsQuerySpecZtdTypesSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeQueryTestsQuerySpecZtdTypes, - relativePath('smokeQueryTestsQuerySpecZtdTypes'), - scaffoldLayout.smokeQueryTestsQuerySpecZtdTypesTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeQueryTestsQuerySpecZtdTypesSummary) { - summaries.smokeQueryTestsQuerySpecZtdTypes = smokeQueryTestsQuerySpecZtdTypesSummary; - } - - const smokeQueryTestsBasicCaseSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeQueryTestsBasicCase, - relativePath('smokeQueryTestsBasicCase'), - scaffoldLayout.smokeQueryTestsBasicCaseTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeQueryTestsBasicCaseSummary) { - summaries.smokeQueryTestsBasicCase = smokeQueryTestsBasicCaseSummary; - } - - const smokeQueryTestsGeneratedAnalysisSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeQueryTestsGeneratedAnalysis, - relativePath('smokeQueryTestsGeneratedAnalysis'), - scaffoldLayout.smokeQueryTestsGeneratedAnalysisTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeQueryTestsGeneratedAnalysisSummary) { - summaries.smokeQueryTestsGeneratedAnalysis = smokeQueryTestsGeneratedAnalysisSummary; - } - - const smokeQueryTestsGeneratedTestPlanSummary = await writeTemplateFile( - rootDir, - absolutePaths.smokeQueryTestsGeneratedTestPlan, - relativePath('smokeQueryTestsGeneratedTestPlan'), - scaffoldLayout.smokeQueryTestsGeneratedTestPlanTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (smokeQueryTestsGeneratedTestPlanSummary) { - summaries.smokeQueryTestsGeneratedTestPlan = smokeQueryTestsGeneratedTestPlanSummary; - } - - const featureQueryExecutorSummary = await writeTemplateFile( - rootDir, - absolutePaths.featureQueryExecutor, - relativePath('featureQueryExecutor'), - scaffoldLayout.featureQueryExecutorTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (featureQueryExecutorSummary) { - summaries.featureQueryExecutor = featureQueryExecutorSummary; - } - - const loadSqlResourceSummary = await writeTemplateFile( - rootDir, - absolutePaths.loadSqlResource, - relativePath('loadSqlResource'), - scaffoldLayout.loadSqlResourceTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (loadSqlResourceSummary) { - summaries.loadSqlResource = loadSqlResourceSummary; - } - - const testsSupportZtdReadmeSummary = await writeTemplateFile( - rootDir, - absolutePaths.testsSupportZtdReadme, - relativePath('testsSupportZtdReadme'), - scaffoldLayout.testsSupportZtdReadmeTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (testsSupportZtdReadmeSummary) { - summaries.testsSupportZtdReadme = testsSupportZtdReadmeSummary; - } - - const testsSupportZtdCaseTypesSummary = await writeTemplateFile( - rootDir, - absolutePaths.testsSupportZtdCaseTypes, - relativePath('testsSupportZtdCaseTypes'), - scaffoldLayout.testsSupportZtdCaseTypesTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (testsSupportZtdCaseTypesSummary) { - summaries.testsSupportZtdCaseTypes = testsSupportZtdCaseTypesSummary; - } - - const testsSupportZtdVerifierSummary = await writeTemplateFile( - rootDir, - absolutePaths.testsSupportZtdVerifier, - relativePath('testsSupportZtdVerifier'), - scaffoldLayout.testsSupportZtdVerifierTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (testsSupportZtdVerifierSummary) { - summaries.testsSupportZtdVerifier = testsSupportZtdVerifierSummary; - } - - const testsSupportZtdHarnessSummary = await writeTemplateFile( - rootDir, - absolutePaths.testsSupportZtdHarness, - relativePath('testsSupportZtdHarness'), - scaffoldLayout.testsSupportZtdHarnessTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (testsSupportZtdHarnessSummary) { - summaries.testsSupportZtdHarness = testsSupportZtdHarnessSummary; - } - - const infrastructureReadmeSummary = await writeTemplateFile( - rootDir, - absolutePaths.infrastructureReadme, - relativePath('infrastructureReadme'), - scaffoldLayout.infrastructureReadmeTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (infrastructureReadmeSummary) { - summaries.infrastructureReadme = infrastructureReadmeSummary; - } - - const sqlReadmeSummary = await writeTemplateFile( - rootDir, - absolutePaths.sqlReadme, - relativePath('sqlReadme'), - SQL_README_TEMPLATE, - dependencies, - prompter, - overwritePolicy - ); - if (sqlReadmeSummary) { - summaries.sqlReadme = sqlReadmeSummary; - } - - const adaptersReadmeSummary = await writeTemplateFile( - rootDir, - absolutePaths.adaptersReadme, - relativePath('adaptersReadme'), - ADAPTERS_README_TEMPLATE, - dependencies, - prompter, - overwritePolicy - ); - if (adaptersReadmeSummary) { - summaries.adaptersReadme = adaptersReadmeSummary; - } - - const telemetryTypesSummary = await writeTemplateFile( - rootDir, - absolutePaths.telemetryTypes, - relativePath('telemetryTypes'), - scaffoldLayout.telemetryTypesTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (telemetryTypesSummary) { - summaries.telemetryTypes = telemetryTypesSummary; - } - - const telemetryRepositorySummary = await writeTemplateFile( - rootDir, - absolutePaths.telemetryRepository, - relativePath('telemetryRepository'), - scaffoldLayout.telemetryRepositoryTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (telemetryRepositorySummary) { - summaries.telemetryRepository = telemetryRepositorySummary; - } - - const telemetryConsoleRepositorySummary = await writeTemplateFile( - rootDir, - absolutePaths.telemetryConsoleRepository, - relativePath('telemetryConsoleRepository'), - scaffoldLayout.telemetryConsoleRepositoryTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (telemetryConsoleRepositorySummary) { - summaries.telemetryConsoleRepository = telemetryConsoleRepositorySummary; - } - - const starterPostgresTestkitSummary = await writeTemplateFile( - rootDir, - absolutePaths.starterPostgresTestkit, - relativePath('starterPostgresTestkit'), - scaffoldLayout.starterPostgresTestkitTemplate, - dependencies, - prompter, - overwritePolicy - ); - if (starterPostgresTestkitSummary) { - summaries.starterPostgresTestkit = starterPostgresTestkitSummary; - } - } - - if (scaffoldProfile.dependencyProfile === 'local-source') { - const localSourceGuardSummary = await writeDocFile( - absolutePaths.localSourceGuardScript, - relativePath('localSourceGuardScript'), - buildLocalSourceGuardContents(absolutePaths.localSourceGuardScript, scaffoldProfile), - dependencies, - prompter, - overwritePolicy - ); - if (localSourceGuardSummary) { - summaries.localSourceGuardScript = localSourceGuardSummary; - } - } - - - const sqlClientSummary = writeOptionalTemplateFile( - absolutePaths.sqlClient, - relativePath('sqlClient'), - scaffoldLayout.sqlClientTemplate, - dependencies - ); - if (sqlClientSummary) { - summaries.sqlClient = sqlClientSummary; - - // Only scaffold the pg adapter alongside the SqlClient interface. - const sqlClientAdaptersSummary = writeOptionalTemplateFile( - absolutePaths.sqlClientAdapters, - relativePath('sqlClientAdapters'), - scaffoldLayout.sqlClientAdaptersTemplate, - dependencies - ); - if (sqlClientAdaptersSummary) { - summaries.sqlClientAdapters = sqlClientAdaptersSummary; - } - } - - const envExampleSummary = await writeTemplateFile( - rootDir, - absolutePaths.envExample, - relativePath('envExample'), - ENV_EXAMPLE_TEMPLATE, - dependencies, - prompter, - overwritePolicy - ); - if (envExampleSummary) { - summaries.envExample = envExampleSummary; - } - - const globalSetupSummary = await writeTemplateFile( - rootDir, - absolutePaths.globalSetup, - relativePath('globalSetup'), - GLOBAL_SETUP_TEMPLATE, - dependencies, - prompter, - overwritePolicy - ); - if (globalSetupSummary) { - summaries.globalSetup = globalSetupSummary; - } - - const setupEnvSummary = await writeTemplateFile( - rootDir, - absolutePaths.setupEnv, - relativePath('setupEnv'), - SETUP_ENV_TEMPLATE, - dependencies, - prompter, - overwritePolicy - ); - if (setupEnvSummary) { - summaries.setupEnv = setupEnvSummary; - } - - const vitestConfigSummary = await writeTemplateFile( - rootDir, - absolutePaths.vitestConfig, - relativePath('vitestConfig'), - VITEST_CONFIG_TEMPLATE, - dependencies, - prompter, - overwritePolicy - ); - if (vitestConfigSummary) { - summaries.vitestConfig = vitestConfigSummary; - } - - const tsconfigSummary = await writeTemplateFile( - rootDir, - absolutePaths.tsconfig, - relativePath('tsconfig'), - TSCONFIG_TEMPLATE, - dependencies, - prompter, - overwritePolicy - ); - if (tsconfigSummary) { - summaries.tsconfig = tsconfigSummary; - } - - const editorconfigSummary = copyTemplateFileIfMissing( - rootDir, - relativePath('editorconfig'), - '.editorconfig', - dependencies - ); - if (editorconfigSummary) { - summaries.editorconfig = editorconfigSummary; - } - - const prettierSummary = copyTemplateFileIfMissing( - rootDir, - relativePath('prettier'), - '.prettierrc', - dependencies - ); - if (prettierSummary) { - summaries.prettier = prettierSummary; - } - - const gitignoreSummary = copyTemplateFileIfMissing( - rootDir, - relativePath('gitignore'), - // npm pack can omit dotfile templates from the published bundle, so keep a non-dotfile fallback. - ['.gitignore', 'gitignore.template'], - dependencies - ); - if (gitignoreSummary) { - summaries.gitignore = gitignoreSummary; - } - const gitignoreEnvSummary = ensureGitignoreEnvEntries(rootDir, dependencies); - if (gitignoreEnvSummary) { - summaries.gitignore = gitignoreEnvSummary; - } - - const prettierignoreSummary = copyTemplateFileIfMissing( - rootDir, - relativePath('prettierignore'), - '.prettierignore', - dependencies - ); - if (prettierignoreSummary) { - summaries.prettierignore = prettierignoreSummary; - } - - const packageSummary = ensurePackageJsonFormatting( - rootDir, - relativePath('package'), - dependencies, - optionalFeatures, - scaffoldProfile, - starter - ); - if (packageSummary) { - summaries.package = packageSummary; - } - - const installNote = await ensureTemplateDependenciesInstalled( - rootDir, - absolutePaths, - summaries, - dependencies, - scaffoldProfile, - options?.skipInstall === true - ); - const shouldIncludeManualInstallStep = - options?.skipInstall === true || installNote?.startsWith('Dependency install failed') === true; - - const nextSteps = buildNextSteps( - normalizeRelative(rootDir, absolutePaths.schema), - workflow, - rootDir, - scaffoldProfile, - shouldIncludeManualInstallStep, - starter, - postgresImage - ); - const summaryLines = buildSummaryLines( - summaries as Record, - optionalFeatures, - nextSteps, - scaffoldProfile, - starter, - postgresImage, - installNote - ); - summaryLines.forEach(dependencies.log); - - return { - summary: summaryLines.join('\n'), - files: Object.values(summaries) as FileSummary[] - }; -} - -function resolvePackageManagerExecutable(packageManager: PackageManager): string { - const override = process.env.ZTD_PACKAGE_MANAGER_PATH; - if (override) { - return override; - } - - if (process.platform !== 'win32') { - return packageManager; - } - - // Prefer .cmd shims first on Windows so they take precedence over extension-less files. - const cmdFallbacks: Record = { - npm: 'npm.cmd', - pnpm: 'pnpm.cmd', - yarn: 'yarn.cmd' - }; - const cmdResolved = resolveExecutableInPath(cmdFallbacks[packageManager]); - if (cmdResolved) { - return cmdResolved; - } - - // Fall back to the extension-less name if no cmd shim is found. - const resolved = resolveExecutableInPath(packageManager); - if (resolved) { - return resolved; - } - - return cmdFallbacks[packageManager] ?? packageManager; -} - -export function resolvePackageManagerShellExecutable( - executable: string, - packageManager: PackageManager, - platform: NodeJS.Platform = process.platform -): string { - if (platform !== 'win32') { - return executable; - } - - // `shell: true` delegates through cmd.exe, which splits unquoted absolute paths with spaces. - // Use the shim basename so the shell resolves it from PATH without truncating at `C:\Program`. - if (/\.(cmd|bat)$/i.test(executable) && path.win32.isAbsolute(executable)) { - return path.win32.basename(executable); - } - - return executable || packageManager; -} - -function resolveExecutableInPath(executable: string): string | null { - const pathValue = process.env.PATH ?? process.env.Path ?? ''; - if (!pathValue) { - return null; - } - - const pathEntries = pathValue - .split(path.delimiter) - .map((entry) => { - const trimmed = entry.trim(); - if (process.platform !== 'win32') { - return trimmed; - } - if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { - return trimmed.slice(1, -1); - } - return trimmed; - }) - .filter(Boolean); - const hasExtension = path.extname(executable).length > 0; - const extensions = - process.platform === 'win32' - ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM') - .split(';') - .map((ext) => ext.trim()) - .filter(Boolean) - : ['']; - - // Check each PATH entry with PATHEXT so we can resolve shim executables. - for (const entry of pathEntries) { - if (hasExtension) { - const candidate = path.join(entry, executable); - if (existsSync(candidate)) { - return candidate; - } - continue; - } - - for (const ext of extensions) { - const candidate = path.join(entry, `${executable}${ext}`); - if (existsSync(candidate)) { - return candidate; - } - } - } - - return null; -} - -export function findAncestorPnpmWorkspaceRoot(rootDir: string): string | null { - let cursor = path.resolve(rootDir); - - while (true) { - const parentDir = path.dirname(cursor); - if (parentDir === cursor) { - return null; - } - cursor = parentDir; - - if (existsSync(path.join(cursor, 'pnpm-workspace.yaml'))) { - return cursor; - } - } -} - -export function resolvePnpmWorkspaceGuard( - rootDir: string, - packageManager: PackageManager -): PnpmWorkspaceGuard { - if (packageManager !== 'pnpm') { - return { workspaceRoot: null, shouldIgnoreWorkspace: false }; - } - - const workspaceRoot = findAncestorPnpmWorkspaceRoot(rootDir); - return { - workspaceRoot, - shouldIgnoreWorkspace: workspaceRoot !== null, - }; -} - -function resolveNpmWorkspaceGuard(rootDir: string, packageManager: PackageManager): NpmWorkspaceGuard { - if (packageManager !== 'npm') { - return { workspaceRoot: null, shouldDisableWorkspaces: false }; - } - - const workspaceRoot = findAncestorWorkspaceRoot(rootDir); - return { - workspaceRoot, - shouldDisableWorkspaces: workspaceRoot !== null, - }; -} - -function findAncestorWorkspaceRoot(rootDir: string): string | null { - let cursor = path.resolve(rootDir); - - while (true) { - const parentDir = path.dirname(cursor); - if (parentDir === cursor) { - return null; - } - cursor = parentDir; - - const packagePath = path.join(cursor, 'package.json'); - if (!existsSync(packagePath)) { - continue; - } - - try { - const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as Record; - if (parsed.workspaces !== undefined) { - return cursor; - } - } catch { - continue; - } - } -} - -export interface InitInstallStrategy { - installCommand: string; - workspaceGuard: PnpmWorkspaceGuard; - shouldDeferAutoInstall: boolean; -} - -function buildManualInstallCommand( - kind: PackageInstallKind, - packageManager: PackageManager, - packages: string[], - rootDir: string -): string { - return [packageManager, ...buildPackageManagerArgs(kind, packageManager, packages, rootDir)].join(' '); -} - -export function resolveInitInstallStrategy( - rootDir: string, - packageManager: PackageManager, - environment?: { platform?: NodeJS.Platform; npmCommand?: string } -): InitInstallStrategy { - const workspaceGuard = resolvePnpmWorkspaceGuard(rootDir, packageManager); - const npmWorkspaceGuard = resolveNpmWorkspaceGuard(rootDir, packageManager); - const installCommand = - packageManager === 'pnpm' && workspaceGuard.shouldIgnoreWorkspace - ? 'pnpm install --ignore-workspace' - : packageManager === 'npm' && npmWorkspaceGuard.shouldDisableWorkspaces - ? 'npm install --workspaces=false' - : `${packageManager} install`; - const platform = environment?.platform ?? process.platform; - // npm_command is provided by npm/pnpm for lifecycle and exec invocations, which we use as a fallback in real CLI runs. - const npmCommand = environment?.npmCommand ?? process.env.npm_command; - - return { - installCommand, - workspaceGuard, - shouldDeferAutoInstall: platform === 'win32' && packageManager === 'pnpm' && npmCommand === 'exec' - }; -} - -export function buildPackageManagerArgs( - kind: PackageInstallKind, - packageManager: PackageManager, - packages: string[], - rootDir?: string -): string[] { - const pnpmWorkspaceGuard = - rootDir !== undefined ? resolvePnpmWorkspaceGuard(rootDir, packageManager) : { shouldIgnoreWorkspace: false }; - const npmWorkspaceGuard = - rootDir !== undefined - ? resolveNpmWorkspaceGuard(rootDir, packageManager) - : { workspaceRoot: null, shouldDisableWorkspaces: false }; - - if (kind === 'install') { - if (packageManager === 'pnpm' && pnpmWorkspaceGuard.shouldIgnoreWorkspace) { - return ['install', '--ignore-workspace']; - } - if (packageManager === 'npm' && npmWorkspaceGuard.shouldDisableWorkspaces) { - return ['install', '--workspaces=false']; - } - return ['install']; - } - - if (packages.length === 0) { - return []; - } - - if (packageManager === 'npm') { - return npmWorkspaceGuard.shouldDisableWorkspaces - ? ['install', '-D', ...packages, '--workspaces=false'] - : ['install', '-D', ...packages]; - } - - return packageManager === 'pnpm' && pnpmWorkspaceGuard.shouldIgnoreWorkspace - ? ['add', '-D', ...packages, '--ignore-workspace'] - : ['add', '-D', ...packages]; -} - -function defaultPackageManagerForScaffold(scaffoldProfile?: InitScaffoldProfile): PackageManager { - return scaffoldProfile?.dependencyProfile === 'local-source' ? 'pnpm' : 'npm'; -} - -function detectPackageManager(rootDir: string, fallbackPackageManager: PackageManager = 'npm'): PackageManager { - // Prefer lockfiles to avoid guessing when multiple package managers are installed. - if (existsSync(path.join(rootDir, 'pnpm-lock.yaml'))) { - return 'pnpm'; - } - if (existsSync(path.join(rootDir, 'yarn.lock'))) { - return 'yarn'; - } - if (existsSync(path.join(rootDir, 'package-lock.json'))) { - return 'npm'; - } - - // Default external standalone consumers to npm unless the scaffold explicitly targets local-source dogfooding. - return fallbackPackageManager; -} - -function extractPackageName(specifier: string): string | null { - if ( - specifier.startsWith('.') || - specifier.startsWith('/') || - specifier.startsWith('node:') || - specifier.startsWith('#') - ) { - return null; - } - - if (specifier.startsWith('@')) { - const [scope, name] = specifier.split('/'); - if (!scope || !name) { - return null; - } - return `${scope}/${name}`; - } - - const [name] = specifier.split('/'); - return name || null; -} - -function listReferencedPackagesFromSource(source: string): string[] { - const packages = new Set(); - const patterns = [ - // Capture ESM imports and re-exports, including `import type`, but only - // when the statement itself starts with import/export so SQL string - // literals like `from "user"` do not get misidentified as packages. - /^\s*(?:import|export)\b[\s\S]*?\bfrom\s+['"]([^'"]+)['"]/gm, - // Capture dynamic imports. - /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, - // Capture CommonJS requires. - /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g - ]; - - for (const pattern of patterns) { - for (const match of source.matchAll(pattern)) { - const specifier = match[1]; - if (!specifier) { - continue; - } - - const packageName = extractPackageName(specifier); - if (!packageName) { - continue; - } - packages.add(packageName); - } - } - - return [...packages]; -} - -function listDeclaredPackages(rootDir: string): Set { - const packagePath = path.join(rootDir, 'package.json'); - if (!existsSync(packagePath)) { - return new Set(); - } - - const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as Record; - const keys = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const; - const declared = new Set(); - - for (const key of keys) { - const record = parsed[key]; - if (!record || typeof record !== 'object') { - continue; - } - - for (const name of Object.keys(record as Record)) { - declared.add(name); - } - } - - return declared; -} - -function listTemplateReferencedPackages( - absolutePaths: Record, - summaries: Partial> -): string[] { - const packages = new Set(); - const touchedKeys = Object.entries(summaries) - .filter((entry): entry is [FileKey, FileSummary] => Boolean(entry[1])) - .filter(([, summary]) => summary.outcome === 'created' || summary.outcome === 'overwritten') - .map(([key]) => key); - - for (const key of touchedKeys) { - const filePath = absolutePaths[key]; - if (!filePath.endsWith('.ts') && !filePath.endsWith('.tsx') && !filePath.endsWith('.js')) { - continue; - } - if (!existsSync(filePath)) { - continue; - } - - // Parse template output after it is written so the detected packages match the emitted scaffold exactly. - const contents = readFileSync(filePath, 'utf8'); - listReferencedPackagesFromSource(contents).forEach((name) => packages.add(name)); - } - - return [...packages].sort(); -} - -async function ensureTemplateDependenciesInstalled( - rootDir: string, - absolutePaths: Record, - summaries: Partial>, - dependencies: ZtdConfigWriterDependencies, - scaffoldProfile: InitScaffoldProfile, - skipInstall: boolean -): Promise { - const packageManager = detectPackageManager(rootDir, defaultPackageManagerForScaffold(scaffoldProfile)); - const packageJsonPath = path.join(rootDir, 'package.json'); - if (!dependencies.fileExists(packageJsonPath)) { - const note = `Skipping dependency installation because package.json is missing. Next: run ${packageManager} init, install dependencies, then run npx ztd ztd-config.`; - dependencies.log(note); - return note; - } - - const installStrategy = resolveInitInstallStrategy(rootDir, packageManager); - if (installStrategy.workspaceGuard.shouldIgnoreWorkspace) { - dependencies.log( - `Detected parent pnpm workspace at ${installStrategy.workspaceGuard.workspaceRoot}. Running pnpm with --ignore-workspace so this initialized project keeps isolated installs.` - ); - } - if (skipInstall) { - const note = `Skipping dependency installation because --skip-install was set. Next: run ${installStrategy.installCommand} manually before executing ztd-config or tests.`; - dependencies.log(note); - return note; - } - const referencedPackages = listTemplateReferencedPackages(absolutePaths, summaries); - const declaredPackages = listDeclaredPackages(rootDir); - - // Install only packages that are not declared yet to avoid unintentionally bumping pinned versions. - const missingPackages = referencedPackages.filter((name) => !declaredPackages.has(name)); - if (missingPackages.length > 0) { - if (installStrategy.shouldDeferAutoInstall) { - const manualAddCommand = buildManualInstallCommand('devDependencies', packageManager, missingPackages, rootDir); - const note = `Skipping automatic ${manualAddCommand} because Windows pnpm exec can break the current ztd process after package.json changes. Next: run ${manualAddCommand} manually.`; - dependencies.log(note); - return note; - } - - dependencies.log(`Installing devDependencies referenced by templates (${packageManager}): ${missingPackages.join(', ')}`); - try { - await dependencies.installPackages({ - rootDir, - kind: 'devDependencies', - packages: missingPackages, - packageManager - }); - return null; - } catch (error) { - const note = `Dependency install failed, but the scaffold was created: ${extractErrorMessage(error)}`; - dependencies.log(note); - return note; - } - } - // Avoid mutating the current pnpm exec shim on Windows while it is still executing this command. - if (summaries.package?.outcome === 'created' || summaries.package?.outcome === 'overwritten') { - if (installStrategy.shouldDeferAutoInstall) { - const note = `Skipping automatic ${installStrategy.installCommand} because Windows pnpm exec can break the current ztd process after package.json changes. Next: run ${installStrategy.installCommand} manually.`; - dependencies.log(note); - return note; - } - - dependencies.log(`Running ${installStrategy.installCommand} to sync dependencies.`); - try { - await dependencies.installPackages({ rootDir, kind: 'install', packages: [], packageManager }); - return null; - } catch (error) { - const note = `Dependency install failed, but the scaffold was created: ${extractErrorMessage(error)}`; - dependencies.log(note); - return note; - } - } - - return null; -} - -function resolveTemplatePath(templateNames: string[]): string | null { - for (const templateName of templateNames) { - const templatePath = path.join(TEMPLATE_DIRECTORY, templateName); - if (existsSync(templatePath)) { - return templatePath; - } - } - - return null; -} - -function copyTemplateFileIfMissing( - rootDir: string, - relative: string, - templateName: string | string[], - dependencies: ZtdConfigWriterDependencies -): FileSummary | null { - const templatePath = resolveTemplatePath( - Array.isArray(templateName) ? templateName : [templateName] - ); - // Skip copying when the CLI package does not include the requested template. - if (!templatePath) { - return null; - } - - const targetPath = path.join(rootDir, relative); - // Avoid overwriting a file that the project already maintains. - if (dependencies.fileExists(targetPath)) { - return null; - } - - dependencies.ensureDirectory(path.dirname(targetPath)); - // Emit the template content so the generated project gets the same formatting defaults. - dependencies.writeFile(targetPath, readFileSync(templatePath, 'utf8')); - return { relativePath: relative, outcome: 'created' }; -} - -function ensureGitignoreEnvEntries( - rootDir: string, - dependencies: ZtdConfigWriterDependencies -): FileSummary | null { - const absolutePath = path.join(rootDir, '.gitignore'); - if (!dependencies.fileExists(absolutePath)) { - return null; - } - - const current = readFileSync(absolutePath, 'utf8').replace(/\r\n/g, '\n'); - const requiredEntries = ['.env', '.env.*', '!.env.example']; - const preservedLines = current - .split('\n') - .filter((line) => !requiredEntries.includes(line)); - - while (preservedLines.length > 0 && preservedLines[preservedLines.length - 1].trim() === '') { - preservedLines.pop(); - } - - const normalizedLines = - preservedLines.length > 0 - ? [...preservedLines, '', ...requiredEntries] - : [...requiredEntries]; - const updated = `${normalizedLines.join('\n')}\n`; - - if (updated === current) { - return null; - } - - dependencies.writeFile(absolutePath, updated); - return { relativePath: '.gitignore', outcome: 'overwritten' }; -} - -function writeOptionalTemplateFile( - absolutePath: string, - relative: string, - templateName: string, - dependencies: ZtdConfigWriterDependencies -): FileSummary | null { - const templatePath = path.join(TEMPLATE_DIRECTORY, templateName); - // Skip when the template is missing from the installed package. - if (!existsSync(templatePath)) { - return null; - } - - if (dependencies.fileExists(absolutePath)) { - // Preserve existing files for opt-in scaffolds without prompting. - dependencies.log(`Skipping ${relative} because the file already exists.`); - return { relativePath: relative, outcome: 'unchanged' }; - } - - dependencies.ensureDirectory(path.dirname(absolutePath)); - dependencies.writeFile(absolutePath, readFileSync(templatePath, 'utf8')); - return { relativePath: relative, outcome: 'created' }; -} - -function ensurePackageJsonFormatting( - rootDir: string, - relative: string, - dependencies: ZtdConfigWriterDependencies, - optionalFeatures: OptionalFeatures, - scaffoldProfile: InitScaffoldProfile, - starter: boolean -): FileSummary | null { - const packageManager = detectPackageManager(rootDir, defaultPackageManagerForScaffold(scaffoldProfile)); - const packagePath = path.join(rootDir, 'package.json'); - const packageExists = dependencies.fileExists(packagePath); - const parsed = packageExists - ? (JSON.parse(readFileSync(packagePath, 'utf8')) as Record) - : { - name: inferPackageName(rootDir), - version: '0.0.0', - private: true - }; - let changed = false; - - if (!('type' in parsed)) { - parsed.type = 'module'; - changed = true; - } - - const importsField = (parsed.imports as Record | undefined) ?? {}; - const requiredImports: Record> = { - '#features/*.js': { - types: './src/features/*.ts', - default: './dist/features/*.js' - }, - '#libraries/*.js': { - types: './src/libraries/*.ts', - default: './dist/libraries/*.js' - }, - '#adapters/*.js': { - types: './src/adapters/*.ts', - default: './dist/adapters/*.js' - }, - '#tests/*.js': { - types: './tests/*.ts', - default: './tests/*.ts' - } - }; - for (const [key, value] of Object.entries(requiredImports)) { - if (JSON.stringify(importsField[key]) === JSON.stringify(value)) { - continue; - } - importsField[key] = value; - changed = true; - } - if (Object.keys(importsField).length > 0) { - parsed.imports = importsField; - } - - const scripts = (parsed.scripts as Record | undefined) ?? {}; - const requiredScripts: Record = { - test: 'vitest run --passWithNoTests', - typecheck: 'tsc --noEmit', - format: 'prettier . --write', - lint: 'eslint .', - 'lint:fix': 'eslint . --fix' - }; - if (scaffoldProfile.dependencyProfile === 'local-source') { - // Route test and typecheck through the local-source guard so parent workspaces cannot silently hijack execution. - requiredScripts.test = 'node ./scripts/local-source-guard.mjs test --passWithNoTests'; - requiredScripts.typecheck = 'node ./scripts/local-source-guard.mjs typecheck'; - requiredScripts.ztd = 'node ./scripts/local-source-guard.mjs ztd'; - } - - // Ensure the canonical formatting and lint scripts exist without overwriting custom commands. - for (const [name, value] of Object.entries(requiredScripts)) { - if (name in scripts && !shouldReplaceScaffoldScript(name, scripts[name])) { - continue; - } - scripts[name] = value; - changed = true; - } - - if (changed) { - parsed.scripts = scripts; - } - - // Provide lint-staged wiring for the formatting pipeline when no configuration is present. - if (!('lint-staged' in parsed)) { - parsed['lint-staged'] = { - '*.{ts,tsx,js,jsx,json,md,sql}': ['prettier --write'] - }; - changed = true; - } - - // Wire simple-git-hooks only if the user has not already customized it. - if (!('simple-git-hooks' in parsed)) { - parsed['simple-git-hooks'] = { - 'pre-commit': resolveLintStagedCommand(packageManager) - }; - changed = true; - } - - const dependenciesField = (parsed.dependencies as Record | undefined) ?? {}; - const devDependencies = (parsed.devDependencies as Record | undefined) ?? {}; - const formattingDeps: Record = { - eslint: '^9.22.0', - 'lint-staged': '^16.2.7', - 'prettier': '^3.7.4', - 'prettier-plugin-sql': '^0.19.2', - 'simple-git-hooks': '^2.13.1' - }; - const testingDeps: Record = { - dotenv: '^16.6.1', - vitest: '^4.1.8', - typescript: '^5.8.2', - '@types/node': '^22.13.10' - }; - // Add the formatting toolchain dependencies that back the scripts and hooks. - for (const [dep, version] of Object.entries(formattingDeps)) { - if (dep in devDependencies) { - continue; - } - devDependencies[dep] = version; - changed = true; - } - - const stackDependencies: Record = - scaffoldProfile.dependencyProfile === 'local-source' - ? buildLocalSourceStackDependencies(rootDir, scaffoldProfile, starter) - : { - ...STACK_DEV_DEPENDENCIES - }; - const runtimeDependencies: Record = - scaffoldProfile.dependencyProfile === 'local-source' - ? buildLocalSourceRuntimeDependencies(rootDir, scaffoldProfile) - : { - ...STACK_RUNTIME_DEPENDENCIES - }; - if (starter) { - for (const [dependencyName, version] of Object.entries(STARTER_DEV_DEPENDENCIES)) { - if (!(dependencyName in stackDependencies)) { - stackDependencies[dependencyName] = version; - } - } - } - if (optionalFeatures.validator === 'zod') { - Object.assign(stackDependencies, ZOD_DEPENDENCY); - } else if (optionalFeatures.validator === 'arktype') { - Object.assign(stackDependencies, ARKTYPE_DEPENDENCY); - } - - // Ensure test and typecheck toolchain dependencies are present for a runnable scaffold. - for (const [dep, version] of Object.entries(testingDeps)) { - if (dep in devDependencies) { - continue; - } - devDependencies[dep] = version; - changed = true; - } - - for (const [dep, version] of Object.entries(stackDependencies)) { - if (dep in devDependencies) { - continue; - } - devDependencies[dep] = version; - changed = true; - } - - for (const [dep, version] of Object.entries(runtimeDependencies)) { - if (dep in dependenciesField) { - continue; - } - dependenciesField[dep] = version; - changed = true; - } - - if (!changed) { - return null; - } - - parsed.dependencies = dependenciesField; - parsed.devDependencies = devDependencies; - dependencies.ensureDirectory(path.dirname(packagePath)); - // Persist the updated manifest so the new scripts and tools are available immediately. - dependencies.writeFile(packagePath, `${JSON.stringify(parsed, null, 2)}\n`); - return { relativePath: relative, outcome: packageExists ? 'overwritten' : 'created' }; -} - -function shouldReplaceScaffoldScript(name: string, currentValue: string | undefined): boolean { - if (name !== 'test' || currentValue === undefined) { - return false; - } - - const normalized = currentValue.trim().replace(/\s+/g, ' '); - return ( - normalized === 'echo "Error: no test specified" && exit 1' || - normalized === "echo 'Error: no test specified' && exit 1" - ); -} - -function resolveLintStagedCommand(packageManager: PackageManager): string { - if (packageManager === 'pnpm') { - return 'pnpm lint-staged'; - } - if (packageManager === 'yarn') { - return 'yarn lint-staged'; - } - return 'npx lint-staged'; -} - -function inferPackageName(rootDir: string): string { - const baseName = path.basename(rootDir).toLowerCase(); - const normalized = baseName.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); - if (normalized.length > 0) { - return normalized; - } - return 'ztd-project'; -} - -async function writeFileWithConsent( - absolutePath: string, - relative: string, - dependencies: ZtdConfigWriterDependencies, - prompter: Prompter, - overwritePolicy: OverwritePolicy, - writer: () => Promise | void -): Promise { - const { existed, write } = await confirmOverwriteIfExists( - absolutePath, - relative, - dependencies, - prompter, - overwritePolicy - ); - if (!write) { - return { relativePath: relative, outcome: 'unchanged' }; - } - await writer(); - return { relativePath: relative, outcome: existed ? 'overwritten' : 'created' }; -} - -interface OverwriteCheck { - existed: boolean; - write: boolean; -} - -interface OverwritePolicy { - force: boolean; - nonInteractive: boolean; -} - -async function confirmOverwriteIfExists( - absolutePath: string, - relative: string, - dependencies: ZtdConfigWriterDependencies, - prompter: Prompter, - overwritePolicy: OverwritePolicy -): Promise { - const existed = dependencies.fileExists(absolutePath); - if (!existed) { - return { existed: false, write: true }; - } - if (overwritePolicy.force) { - return { existed: true, write: true }; - } - if (overwritePolicy.nonInteractive) { - throw new Error( - `File ${relative} already exists. Re-run with --force to overwrite or remove the file before running ztd init.` - ); - } - const overwrite = await prompter.confirm(`File ${relative} already exists. Overwrite?`); - if (!overwrite) { - return { existed: true, write: false }; - } - return { existed: true, write: true }; -} - -async function writeDocFile( - absolutePath: string, - relative: string, - contents: string, - dependencies: ZtdConfigWriterDependencies, - prompter: Prompter, - overwritePolicy: OverwritePolicy -): Promise { - const summary = await writeFileWithConsent( - absolutePath, - relative, - dependencies, - prompter, - overwritePolicy, - () => { - dependencies.ensureDirectory(path.dirname(absolutePath)); - dependencies.writeFile(absolutePath, contents); - } - ); - return summary; -} - -async function writeTemplateFile( - rootDir: string, - absolutePath: string, - relative: string, - templateName: string, - dependencies: ZtdConfigWriterDependencies, - prompter: Prompter, - overwritePolicy: OverwritePolicy, - allowFallback?: boolean -): Promise { - const templateTarget = resolveTemplateTarget(rootDir, absolutePath, relative, dependencies, allowFallback); - if (!templateTarget) { - return null; - } - - // Load shared documentation templates so every new project gets the same guidance. - const contents = loadTemplate(templateName); - return writeDocFile( - templateTarget.absolutePath, - templateTarget.relativePath, - contents, - dependencies, - prompter, - overwritePolicy - ); -} - -interface TemplateTarget { - absolutePath: string; - relativePath: string; -} - -function resolveTemplateTarget( - rootDir: string, - absolutePath: string, - relative: string, - dependencies: ZtdConfigWriterDependencies, - allowFallback?: boolean -): TemplateTarget | null { - if (!dependencies.fileExists(absolutePath)) { - return { absolutePath, relativePath: relative }; - } - - if (!allowFallback || !isRootMarkdown(relative)) { - dependencies.log(`Skipping template ${relative} because the target file already exists.`); - return null; - } - - // When the preferred destination already exists, try emitting a sibling with a "_ztd" suffix. - const parsed = path.parse(absolutePath); - const fallbackAbsolute = path.join(parsed.dir, `${parsed.name}_ztd${parsed.ext}`); - if (dependencies.fileExists(fallbackAbsolute)) { - const existingRelative = normalizeRelative(rootDir, fallbackAbsolute); - dependencies.log( - `Skipping template ${relative} because both ${relative} and ${existingRelative} already exist.` - ); - return null; - } - - const fallbackRelative = normalizeRelative(rootDir, fallbackAbsolute); - dependencies.log(`Existing ${relative} preserved; writing template as ${fallbackRelative}.`); - return { absolutePath: fallbackAbsolute, relativePath: fallbackRelative }; -} - -function normalizeRelative(rootDir: string, absolutePath: string): string { - // Normalize the path relative to the project root so summaries use forward slashes. - const relative = normalizeCliPath(path.relative(rootDir, absolutePath)); - return relative || absolutePath; -} - -function normalizeCliPath(filePath: string): string { - return filePath.replace(/\\/g, '/'); -} - -/** - * Normalizes a schema identifier into the canonical lowercase form used by ztd-cli file naming. - * Empty input falls back to the configured default schema. - */ -export function normalizeSchemaName(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return DEFAULT_ZTD_CONFIG.defaultSchema; - } - return trimmed.replace(/^"|"$/g, '').toLowerCase(); -} - -/** - * Sanitizes a normalized schema identifier so it can be used as a filesystem-safe file stem. - * Returns `schema` when all characters are stripped by sanitization. - */ -export function sanitizeSchemaFileName(schemaName: string): string { - const sanitized = schemaName.replace(/[^a-z0-9_-]/g, '_').replace(/^_+|_+$/g, ''); - return sanitized || 'schema'; -} - -function isRootMarkdown(relative: string): boolean { - return relative.toLowerCase().endsWith('.md') && !relative.includes('/'); -} - -function loadTemplate(templateName: string): string { - const templatePath = path.join(TEMPLATE_DIRECTORY, templateName); - // Fail fast if the template bundle was shipped without the requested file. - if (!existsSync(templatePath)) { - throw new Error(`Missing template file: ${templateName}`); - } - return readFileSync(templatePath, 'utf8'); -} - -function buildLocalSourceGuardContents( - absolutePath: string, - scaffoldProfile: InitScaffoldProfile -): string { - const template = loadTemplate(LOCAL_SOURCE_GUARD_TEMPLATE); - if (scaffoldProfile.dependencyProfile !== 'local-source' || !scaffoldProfile.localSourceRoot) { - return template.replace('__LOCAL_SOURCE_ZTD_CLI__', './packages/ztd-cli/dist/index.js'); - } - - const cliEntry = path.join(scaffoldProfile.localSourceRoot, 'packages', 'ztd-cli', 'dist', 'index.js'); - const projectRoot = path.dirname(path.dirname(absolutePath)); - const relativeCliEntry = normalizeCliPath(path.relative(projectRoot, cliEntry)); - const cliImportPath = - relativeCliEntry.startsWith('.') || relativeCliEntry.startsWith('/') ? relativeCliEntry : `./${relativeCliEntry}`; - return template.replace('__LOCAL_SOURCE_ZTD_CLI__', cliImportPath); -} - -function buildNextSteps( - schemaRelativePath: string, - workflow: InitWorkflow, - rootDir: string, - scaffoldProfile: InitScaffoldProfile, - includeInstallStep: boolean, - starter: boolean, - postgresImage: string -): { nextSteps: string[]; fallbackSteps: string[] } { - const packageManager = detectPackageManager(rootDir, defaultPackageManagerForScaffold(scaffoldProfile)); - const installStrategy = resolveInitInstallStrategy(rootDir, packageManager); - const installCommand = installStrategy.installCommand; - const runScriptCommand = (script: 'typecheck' | 'test'): string => - packageManager === 'npm' ? `npm run ${script}` : `${packageManager} ${script}`; - const ztdCommand = resolveInitZtdCommand(rootDir, scaffoldProfile); - const firstStep = - workflow === 'pg_dump' - ? `Review the dumped DDL in ${schemaRelativePath} and adjust it before generating downstream artifacts` - : `If the schema file is empty, edit ${schemaRelativePath} before generating downstream artifacts`; - const sqlStep = 'Create your first real feature under src/features// and keep SQL, boundary entrypoints, and tests feature-local'; - const aiGuidanceStep = 'Open README.md and follow the Getting Started With AI section before writing repository code'; - const generationSteps = [ - `Run ${ztdCommand} ztd-config to regenerate DDL-derived test rows and layout metadata`, - `Run ${ztdCommand} model-gen --probe-mode ztd to inspect the generated contract before you update the handwritten query boundary` - ]; - const wiringStep = 'Keep the first slice small and local before extracting shared helpers'; - const firstTestStep = `Run tests (${runScriptCommand('test')} or npx vitest run) to keep the generated scaffold green before adding more features`; - const sampleTestStep = - 'Keep repo-level helpers under `.ztd/support/`, and keep customer-owned tests inside `src/features/*/tests`'; - const fallbackSteps = [ - `If ${ztdCommand} ztd-config fails, keep editing ${schemaRelativePath} and the feature-local SQL file first, then rerun generation after the DDL is ready`, - `If ${ztdCommand} model-gen fails, keep the SQL file and rerun it after ${ztdCommand} ztd-config succeeds; the ztd probe path does not need DATABASE_URL`, - 'If you do not have .env yet, keep the work DB-free until the connection is ready' - ]; - - if (starter) { - const starterNextSteps = [ - 'Inspect src/features/smoke/ and treat it as a starter-only sample feature that can be deleted later', - `Run tests (${runScriptCommand('test')} or npx vitest run src/features/smoke/tests/smoke.boundary.test.ts src/features/smoke/tests/smoke.validation.test.ts) to confirm the DB-free smoke path is green`, - 'Read src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts to see the DB-backed query-boundary path that also checks connectivity', - 'Run docker compose up -d to start the bundled Postgres container before the DB-backed smoke path', - STARTER_DB_READY_NOTE, - `The bundled compose file uses ${postgresImage}; copy .env.example to .env and keep ZTD_DB_PORT aligned before running src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts`, - 'Expect src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts to fail until .env is present or the DB is running; that failure is part of the starter guidance', - ...generationSteps, - `Start your first real CRUD slice with \`${ztdCommand} feature scaffold --table users --action insert\` after the smoke sample makes sense`, - `Then run \`${ztdCommand} feature tests scaffold --feature users-insert\` after you finish SQL and DTO edits, and let AI complete the TODO-based tests.`, - 'Delete src/features/smoke/ once you no longer need the starter sample' - ]; - const starterFallbackSteps = [ - `If ${ztdCommand} ztd-config fails, keep editing ${schemaRelativePath} and src/features/smoke/queries/smoke/smoke.sql first, then rerun generation after the DDL is ready`, - `If ${ztdCommand} model-gen fails, keep src/features/smoke/queries/smoke/smoke.sql and rerun it after ${ztdCommand} ztd-config succeeds; the ztd probe path does not need DATABASE_URL`, - 'If the DB-backed smoke path fails before .env is configured, finish the DB-free smoke path first and come back after the database is ready' - ]; - return { - nextSteps: starterNextSteps.map((step, index) => ` ${index + 1}. ${step}`), - fallbackSteps: starterFallbackSteps.map((step) => ` - ${step}`) - }; - } - - if (scaffoldProfile.dependencyProfile === 'local-source') { - const localSourceSteps = [ - `Run ${installCommand}`, - firstStep, - sqlStep, - ...generationSteps, - wiringStep, - sampleTestStep, - firstTestStep, - `Run ${runScriptCommand('typecheck')} before you start wiring handwritten repository code` - ]; - return { - nextSteps: localSourceSteps.map((step, index) => ` ${index + 1}. ${step}`), - fallbackSteps: fallbackSteps.map((step) => ` - ${step}`) - }; - } - - const nextSteps = [firstStep, sqlStep]; - if (includeInstallStep || installStrategy.shouldDeferAutoInstall) { - nextSteps.push(`Run ${installCommand}`); - } - nextSteps.push(aiGuidanceStep, ...generationSteps, wiringStep, sampleTestStep, firstTestStep); - // Avoid repeating the same install hint when the deferred-install path already emitted it explicitly. - if (installStrategy.workspaceGuard.shouldIgnoreWorkspace && !installStrategy.shouldDeferAutoInstall) { - fallbackSteps.push('This project is nested under a parent pnpm workspace; use pnpm install --ignore-workspace for manual installs.'); - } - return { - nextSteps: nextSteps.map((step, index) => ` ${index + 1}. ${step}`), - fallbackSteps: fallbackSteps.map((step) => ` - ${step}`) - }; -} - -function resolveLocalSourceStackPackageDirs(starter: boolean): Record { - return starter - ? { ...LOCAL_SOURCE_RUNTIME_PACKAGE_DIRS, ...LOCAL_SOURCE_STACK_PACKAGE_DIRS, ...LOCAL_SOURCE_STACK_PACKAGE_DIRS_STARTER } - : { ...LOCAL_SOURCE_RUNTIME_PACKAGE_DIRS, ...LOCAL_SOURCE_STACK_PACKAGE_DIRS }; -} - -function resolveInitScaffoldProfile(rootDir: string, localSourceRoot?: string, starter = false): InitScaffoldProfile { - const resolvedRoot = localSourceRoot - ? path.resolve(rootDir, localSourceRoot) - : detectImplicitLocalSourceRoot(starter); - - if (!resolvedRoot) { - return { dependencyProfile: 'registry', localSourceRoot: null }; - } - - // Validate every direct rawsql-ts scaffold dependency up front so local-source installs - // cannot silently mix local packages with registry packages. - for (const packageDir of Object.values(resolveLocalSourceStackPackageDirs(starter))) { - const packageJsonPath = path.join(resolvedRoot, packageDir, 'package.json'); - if (!existsSync(packageJsonPath)) { - throw new Error( - `The local-source root does not contain ${normalizeCliPath(path.join(packageDir, 'package.json'))}: ${normalizeCliPath(resolvedRoot)}` - ); - } - } - - return { - dependencyProfile: 'local-source', - localSourceRoot: resolvedRoot - }; -} - -function detectImplicitLocalSourceRoot(starter = false): string | null { - const packageRoot = path.resolve(__dirname, '..', '..'); - const workspaceRoot = findAncestorPnpmWorkspaceRoot(packageRoot); - if (!workspaceRoot) { - return null; - } - - for (const packageDir of Object.values(resolveLocalSourceStackPackageDirs(starter))) { - if (!existsSync(path.join(workspaceRoot, packageDir, 'package.json'))) { - return null; - } - } - - return workspaceRoot; -} - -function buildLocalSourceStackDependencies( - rootDir: string, - scaffoldProfile: InitScaffoldProfile, - starter = false -): Record { - if (scaffoldProfile.dependencyProfile !== 'local-source' || !scaffoldProfile.localSourceRoot) { - return { - ...STACK_DEV_DEPENDENCIES - }; - } - - return { - ...STACK_DEV_DEPENDENCIES, - ...Object.fromEntries( - Object.entries(starter - ? { ...LOCAL_SOURCE_STACK_PACKAGE_DIRS, ...LOCAL_SOURCE_STACK_PACKAGE_DIRS_STARTER } - : LOCAL_SOURCE_STACK_PACKAGE_DIRS - ).map(([packageName, packageDir]) => [ - packageName, - toFileDependencySpecifier(rootDir, path.join(scaffoldProfile.localSourceRoot!, packageDir)) - ]) - ) - }; -} - -function buildLocalSourceRuntimeDependencies( - rootDir: string, - scaffoldProfile: InitScaffoldProfile -): Record { - if (scaffoldProfile.dependencyProfile !== 'local-source' || !scaffoldProfile.localSourceRoot) { - return { - ...STACK_RUNTIME_DEPENDENCIES - }; - } - - return { - ...STACK_RUNTIME_DEPENDENCIES, - ...Object.fromEntries( - Object.entries(LOCAL_SOURCE_RUNTIME_PACKAGE_DIRS).map(([packageName, packageDir]) => [ - packageName, - toFileDependencySpecifier(rootDir, path.join(scaffoldProfile.localSourceRoot!, packageDir)) - ]) - ) - }; -} - -function toFileDependencySpecifier(rootDir: string, targetDir: string): string { - const relativeTarget = normalizeCliPath(path.relative(rootDir, targetDir)); - const withDotPrefix = - relativeTarget.startsWith('.') || relativeTarget.startsWith('/') ? relativeTarget : `./${relativeTarget}`; - return `file:${withDotPrefix}`; -} - -function buildSummaryLines( - summaries: Record, - optionalFeatures: OptionalFeatures, - nextSteps: { nextSteps: string[]; fallbackSteps: string[] }, - scaffoldProfile: InitScaffoldProfile, - starter: boolean, - postgresImage: string, - installNote?: string | null -): string[] { - const orderedKeys: FileKey[] = [ - 'schema', - 'config', - 'starterCompose', - 'envExample', - 'readme', - 'ztdDocsReadme', - 'sqlReadme', - 'featureRootReadme', - 'smokeReadme', - 'smokeTestsReadme', - 'smokeSpec', - 'localSourceGuardScript', - 'smokeValidationTest', - 'smokeEntrySpecTest', - 'smokeQuerySpecTest', - 'smokeQuerySpec', - 'smokeQuerySql', - 'smokeQueryTestsQuerySpecZtdTypes', - 'smokeQueryTestsBasicCase', - 'smokeQueryTestsGeneratedAnalysis', - 'smokeQueryTestsGeneratedTestPlan', - 'testsSupportZtdReadme', - 'testsSupportZtdCaseTypes', - 'testsSupportZtdVerifier', - 'testsSupportZtdHarness', - 'setupEnv', - 'starterPostgresTestkit', - 'infrastructureReadme', - 'adaptersReadme', - 'telemetryTypes', - 'telemetryRepository', - 'telemetryConsoleRepository', - 'featureQueryExecutor', - 'loadSqlResource', - 'sqlClient', - 'sqlClientAdapters', - 'globalSetup', - 'vitestConfig', - 'tsconfig', - 'gitignore', - 'editorconfig', - 'prettierignore', - 'prettier', - 'package' - ]; - const lines = ['ZTD project initialized.', '', 'Created:']; - - for (const key of orderedKeys) { - const summary = summaries[key]; - const note = - summary?.outcome === 'created' - ? '' - : summary?.outcome === 'overwritten' - ? ' (overwritten existing file)' - : ' (existing file preserved)'; - if (summary) { - lines.push(` - ${summary.relativePath}${note}`); - } - } - - lines.push('', 'Runtime configuration:'); - const stackLine = - scaffoldProfile.dependencyProfile === 'local-source' - ? ' - Runtime-free query execution scaffold with local-source development tooling links only' - : ' - Runtime-free query execution scaffold; no @rawsql-ts/sql-contract runtime dependency is installed by default'; - lines.push(stackLine); - if (optionalFeatures.validator === 'none') { - lines.push(' - Runtime row validator: none (standard)'); - } else { - const validatorLabel = - optionalFeatures.validator === 'zod' - ? 'Zod (zod, compatibility only; see docs/recipes/validation-zod.md)' - : 'ArkType (arktype, compatibility only; see docs/recipes/validation-arktype.md)'; - lines.push(` - Runtime validator compatibility backend: ${validatorLabel}`); - } - if (starter) { - lines.push('', 'Starter flow:'); - lines.push(` - Bundled Postgres compose image: ${postgresImage}`); - lines.push(' - Run docker compose up -d before the DB-backed smoke test so the starter DB path is ready.'); - lines.push( - ' - The starter smoke test uses tests/support/ztd/harness.ts, the query-local smoke spec under src/features/smoke/queries/smoke/tests/, and the starter DB wiring under .ztd/support/ to fail fast on setup problems.' - ); - } - if (installNote) { - lines.push('', 'Dependency installation:'); - lines.push(` - ${installNote}`); - } - lines.push('', 'Next steps:', ...nextSteps.nextSteps); - if (nextSteps.fallbackSteps.length > 0) { - lines.push('', 'If this fails:', ...nextSteps.fallbackSteps); - } - return lines; -} - -function extractErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - return String(error); -} - -const VALID_WORKFLOWS: readonly InitWorkflow[] = ['pg_dump', 'empty', 'demo'] as const; -const VALID_VALIDATORS: readonly ValidatorBackend[] = ['none', 'zod', 'arktype'] as const; -const VALID_APP_SHAPES: readonly InitAppShape[] = ['default', 'webapi'] as const; - -export interface InitDryRunPlan { - schemaVersion: 1; - workflow: InitWorkflow; - validator: ValidatorBackend; - starter: boolean; - postgresImage: string; - dryRun: true; - files: string[]; -} - -function pushRelativePlanFile(files: string[], rootDir: string, filePath: string): void { - files.push(normalizeCliPath(path.relative(rootDir, filePath))); -} - -function buildInitPlanFiles( - rootDir: string, - scaffoldLayout: InitScaffoldLayout, - schemaFileName: string, - options: { - starter: boolean; - withAiGuidance?: boolean; - withDogfooding?: boolean; - withAppInterface?: boolean; - } -): string[] { - const files = [ - 'ztd.config.json', - path.join(DEFAULT_ZTD_CONFIG.ddlDir, schemaFileName), - path.join(resolveSupportDir(DEFAULT_ZTD_CONFIG), 'global-setup.ts'), - 'vitest.config.ts', - 'tsconfig.json', - ]; - - pushRelativePlanFile(files, rootDir, scaffoldLayout.featureReadmePath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.setupEnvPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.envExamplePath); - files.push('README.md'); - - if (options.starter) { - files.push(path.join('src', 'libraries', 'sql', 'README.md')); - files.push(path.join('src', 'adapters', 'README.md')); - pushRelativePlanFile(files, rootDir, path.join(rootDir, STARTER_COMPOSE_FILE)); - pushRelativePlanFile(files, rootDir, scaffoldLayout.featureQueryExecutorPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.loadSqlResourcePath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeReadmePath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeTestsReadmePath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeSpecPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeEntrySpecTestPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeValidationTestPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeQuerySpecTestPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeQuerySpecPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeQuerySqlPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeQueryTestsQuerySpecZtdTypesPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeQueryTestsBasicCasePath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeQueryTestsGeneratedAnalysisPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.smokeQueryTestsGeneratedTestPlanPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.testsSupportZtdReadmePath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.testsSupportZtdCaseTypesPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.testsSupportZtdVerifierPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.testsSupportZtdHarnessPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.infrastructureReadmePath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.telemetryTypesPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.telemetryRepositoryPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.telemetryConsoleRepositoryPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.starterPostgresTestkitPath); - } - - if (options.withAiGuidance) { - files.push('.ztd/agents/manifest.json'); - files.push('.ztd/agents/root.md'); - files.push('.ztd/agents/src.md'); - files.push('.ztd/agents/src-features.md'); - files.push('.ztd/agents/tests.md'); - files.push('.ztd/agents/db.md'); - files.push('CONTEXT.md'); - } - - if (options.withDogfooding) { - files.push('PROMPT_DOGFOOD.md'); - } - - pushRelativePlanFile(files, rootDir, scaffoldLayout.sqlClientPath); - pushRelativePlanFile(files, rootDir, scaffoldLayout.sqlClientAdaptersPath); - - if (options.withAppInterface) { - files.push('AGENTS.md'); - } - - return files; -} - -export function buildInitDryRunPlan(rootDir: string, options: { - appShape: InitAppShape; - starter?: boolean; - postgresImage?: string; - withAiGuidance?: boolean; - withDogfooding?: boolean; - withAppInterface?: boolean; - workflow: InitWorkflow; - validator: ValidatorBackend; - localSourceRoot?: string; -}): InitDryRunPlan { - const schemaName = normalizeSchemaName(DEFAULT_ZTD_CONFIG.defaultSchema); - const schemaFileName = `${sanitizeSchemaFileName(schemaName)}.sql`; - const scaffoldLayout = resolveInitScaffoldLayout(rootDir, options.appShape); - const starter = options.starter === true; - const postgresImage = options.postgresImage?.trim() || DEFAULT_POSTGRES_IMAGE; - const files = buildInitPlanFiles(rootDir, scaffoldLayout, schemaFileName, { - starter, - withAiGuidance: options.withAiGuidance, - withDogfooding: options.withDogfooding, - withAppInterface: options.withAppInterface - }); - if (options.localSourceRoot) { - resolveInitScaffoldProfile(rootDir, options.localSourceRoot, starter); - } - - return { - schemaVersion: 1, - workflow: options.workflow, - validator: options.validator, - starter, - postgresImage, - dryRun: true, - files: files.map((file) => normalizeCliPath(file)) - }; -} - -function validateJsonBooleanFlag(value: unknown, flagName: string): value is boolean | undefined { - if (value === undefined || typeof value === 'boolean') { - return true; - } - - console.error(`Invalid --${flagName} value in --json payload. Expected a boolean.`); - process.exit(1); -} - -export function registerInitCommand(program: Command): void { - program - .command('init') - .description('Automate project setup for Zero Table Dependency workflows') - .option('--app-shape ', 'Deprecated compatibility option; the vertical scaffold ignores it') - .option('--starter', 'Generate the recommended starter flow with compose, smoke tests, and sample DDL') - .option('--postgres-image ', 'Postgres image/tag to use in the starter compose file (default: postgres:18)') - .option('--skip-install', 'Create the scaffold without installing dependencies') - .option('--yes', 'Accept defaults without interactive prompts') - .option('--force', 'Allow ztd init to overwrite files it owns') - .option('--workflow ', 'Schema workflow: pg_dump, empty, or demo (default: demo)') - .option('--validator ', 'Optional compatibility validator backend: none, zod, or arktype (default: none)') - .option('--dry-run', 'Validate init options and emit the planned scaffold without writing files') - .option('--json ', 'Pass init options as a JSON object') - .option( - '--local-source-root ', - 'Link @rawsql-ts dependencies to a local monorepo root for dogfooding instead of published npm packages' - ) - .action(async (options: { - appShape?: string; - starter?: boolean; - postgresImage?: string; - skipInstall?: boolean; - yes?: boolean; - force?: boolean; - workflow?: string; - validator?: string; - localSourceRoot?: string; - dryRun?: boolean; - json?: string; - }) => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - if (typeof merged.localSourceRoot === 'string') { - merged.localSourceRoot = rejectEncodedTraversal(rejectControlChars(merged.localSourceRoot, '--local-source-root'), '--local-source-root'); - } - // Reject stringly-typed booleans from --json so unsupported payloads cannot silently enable features. - validateJsonBooleanFlag(merged.yes, 'yes'); - validateJsonBooleanFlag(merged.dryRun, 'dry-run'); - validateJsonBooleanFlag(merged.force, 'force'); - validateJsonBooleanFlag(merged.starter, 'starter'); - validateJsonBooleanFlag(merged.skipInstall, 'skip-install'); - if (merged.postgresImage !== undefined && typeof merged.postgresImage !== 'string') { - console.error('Invalid --postgres-image value in --json payload. Expected a string.'); - process.exit(1); - } - // Validate --workflow value if provided. - if (merged.workflow && !VALID_WORKFLOWS.includes(merged.workflow as InitWorkflow)) { - console.error(`Invalid --workflow value: "${merged.workflow}". Must be one of: ${VALID_WORKFLOWS.join(', ')}`); - process.exit(1); - } - // Validate --validator value if provided. - if (merged.validator && !VALID_VALIDATORS.includes(merged.validator as ValidatorBackend)) { - console.error(`Invalid --validator value: "${merged.validator}". Must be one of: ${VALID_VALIDATORS.join(', ')}`); - process.exit(1); - } - if (merged.appShape && !VALID_APP_SHAPES.includes(merged.appShape as InitAppShape)) { - console.error(`Invalid --app-shape value: "${merged.appShape}". Must be one of: ${VALID_APP_SHAPES.join(', ')}`); - process.exit(1); - } - - const isNonInteractive = merged.yes === true || !process.stdin.isTTY || merged.dryRun === true; - - // When --yes is used, apply defaults for unspecified flags. - const workflow = (merged.workflow as InitWorkflow | undefined) ?? (isNonInteractive ? 'demo' : undefined); - const validator = (merged.validator as ValidatorBackend | undefined) ?? (isNonInteractive ? 'none' : undefined); - const appShape = (merged.appShape as InitAppShape | undefined) ?? 'default'; - const starter = merged.starter === true; - const postgresImage = (merged.postgresImage as string | undefined) ?? DEFAULT_POSTGRES_IMAGE; - - if (isNonInteractive && workflow === 'pg_dump') { - console.error('Non-interactive mode does not support the pg_dump workflow (requires connection string prompt).'); - process.exit(1); - } - - if (merged.dryRun === true) { - const plan = buildInitDryRunPlan(process.cwd(), { - appShape, - starter, - postgresImage, - workflow: workflow ?? 'demo', - validator: validator ?? 'none', - localSourceRoot: merged.localSourceRoot - }); - if (isJsonOutput()) { - writeCommandEnvelope('init', plan); - } else { - process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); - } - return; - } - - const prompter = createConsolePrompter(); - try { - await runInitCommand(prompter, { - appShape, - starter, - postgresImage, - skipInstall: merged.skipInstall === true, - forceOverwrite: merged.force === true, - nonInteractive: isNonInteractive, - workflow, - validator, - localSourceRoot: merged.localSourceRoot - }); - } finally { - prompter.close(); - } - }); -} diff --git a/packages/ztd-cli/src/commands/lint.ts b/packages/ztd-cli/src/commands/lint.ts deleted file mode 100644 index 474c02b9b..000000000 --- a/packages/ztd-cli/src/commands/lint.ts +++ /dev/null @@ -1,683 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { Command } from 'commander'; -import { MultiQuerySplitter, SqlParser } from 'rawsql-ts'; -import type { TableDefinitionModel } from 'rawsql-ts'; -import type { DdlLintMode, TableRowsFixture } from '@rawsql-ts/testkit-core'; -import { - ensureAdapterNodePgModule, - ensurePgModule, - ensurePostgresContainerModule, - ensureTestkitCoreModule, - type AdapterNodePgModule, - type PgClientLike, - type TestkitCoreModule -} from '../utils/optionalDependencies'; -import { loadZtdProjectConfig } from '../utils/ztdProjectConfig'; -import { - resolveSqlFiles, - extractEnumLabels, - buildLintFixtureRow -} from '../utils/sqlLintHelpers'; -import { isJsonOutput, parseJsonPayload, writeCommandResultEnvelope } from '../utils/agentCli'; - -/** Categorizes the type of failure encountered when validating SQL statements. */ -export type LintFailureKind = 'parser' | 'transform' | 'db'; - -/** Captures diagnostic details contributed by PostgreSQL or the SQL rewriter. */ -export interface LintFailureDetails { - code?: string; - position?: number; - detail?: string; - hint?: string; -} - -/** Describes a single linting failure observed while processing a SQL file. */ -export interface LintFailure { - kind: LintFailureKind; - filePath: string; - statement?: string; - message: string; - location: { line: number; column: number } | null; - rewritten?: string | null; - details?: LintFailureDetails; -} - -/** Configuration for a lint run, including file discovery and schema fixtures. */ -export interface RunSqlLintOptions { - sqlFiles: string[]; - ddlDirectories: string[]; - defaultSchema: string; - searchPath: string[]; - ddlLint: DdlLintMode; - client: PgClientLike; -} - -/** Outcome summary for a lint run. */ -export interface RunSqlLintResult { - failures: LintFailure[]; - filesChecked: number; -} - -export interface LintCommandEnvelopeData { - schemaVersion: 1; - filesChecked: number; - failures: LintFailure[]; - error?: string; -} - -interface LintModuleConstructors { - DdlLintError: TestkitCoreModule['DdlLintError']; - TableNameResolver: TestkitCoreModule['TableNameResolver']; - DdlFixtureLoader: TestkitCoreModule['DdlFixtureLoader']; - MissingFixtureError: TestkitCoreModule['MissingFixtureError']; - SchemaValidationError: TestkitCoreModule['SchemaValidationError']; - QueryRewriteError: TestkitCoreModule['QueryRewriteError']; -} - -type TransformError = - | InstanceType - | InstanceType - | InstanceType; - -interface LintCommandOptions { - json?: string; - path?: string; -} - -/** - * Validate every SQL file against the configured DDL fixtures by replaying each - * statement through the adapter-provided testkit client. - * @param options Configuration values that describe which files and schemas to lint. - * @returns A summary of the failures observed and how many files were processed. - */ -export async function runSqlLint(options: RunSqlLintOptions): Promise { - const { sqlFiles, ddlDirectories, defaultSchema, searchPath, ddlLint, client } = options; - const testkitCore = await ensureTestkitCoreModule(); - const adapter = await ensureAdapterNodePgModule(); - const constructors: LintModuleConstructors = { - TableNameResolver: testkitCore.TableNameResolver, - DdlFixtureLoader: testkitCore.DdlFixtureLoader, - DdlLintError: testkitCore.DdlLintError, - MissingFixtureError: testkitCore.MissingFixtureError, - SchemaValidationError: testkitCore.SchemaValidationError, - QueryRewriteError: testkitCore.QueryRewriteError - }; - - let tableDefinitions: TableDefinitionModel[]; - try { - const resolver = new constructors.TableNameResolver({ defaultSchema, searchPath }); - const loader = new constructors.DdlFixtureLoader({ - directories: ddlDirectories, - tableNameResolver: resolver, - ddlLint - }); - const fixtures = loader.getFixtures(); - tableDefinitions = fixtures.map((fixture) => fixture.tableDefinition); - } catch (error) { - return { - failures: [buildTransformFailureFromLoaderError(error, ddlDirectories, constructors)], - filesChecked: sqlFiles.length - }; - } - - const enumLabels = extractEnumLabels(ddlDirectories); - const tableRows: TableRowsFixture[] = tableDefinitions.map((definition) => ({ - tableName: definition.name, - rows: [buildLintFixtureRow(definition, enumLabels)] - })); - - let rewrittenStatement: string | null = null; - const testkit = adapter.createPgTestkitClient({ - connectionFactory: async () => client, - tableDefinitions, - tableRows, - defaultSchema, - searchPath, - onExecute: (sql: string) => { - rewrittenStatement = sql; - } - }); - - const failures: LintFailure[] = []; - try { - for (const filePath of sqlFiles) { - const contents = readFileSafe(filePath); - await lintFile( - filePath, - contents, - testkit, - failures, - () => { - rewrittenStatement = null; - }, - () => rewrittenStatement, - constructors - ); - } - } finally { - await testkit.close(); - } - - return { - failures, - filesChecked: sqlFiles.length - }; -} - -/** - * Register the `ztd lint` CLI command that validates raw SQL with a temporary Postgres instance. - * @param program CLI root command that receives the lint subcommand. - */ -export function registerLintCommand(program: Command): void { - program - .command('lint [path]') - .description('Lint SQL files for syntax and analysis correctness via ZTD') - .option('--json ', 'Pass lint options as a JSON object') - .action(async (pattern: string | undefined, options: LintCommandOptions) => { - try { - const resolved = resolveLintCommandInput(pattern, options); - await runLintCommand(resolved.path); - } catch (error) { - if (isJsonOutput()) { - writeCommandResultEnvelope('lint', false, buildLintCommandFailureData(error)); - } - throw error; - } - }); -} - -async function runLintCommand(pattern: string): Promise { - const config = loadZtdProjectConfig(); - const projectRoot = process.cwd(); - const ddlRoot = path.resolve(projectRoot, config.ddlDir); - const sqlFiles = resolveSqlFiles(pattern); - - const databaseUrl = process.env.ZTD_DB_URL?.trim(); - const connectionUrl = databaseUrl && databaseUrl.length > 0 ? databaseUrl : null; - - if (!connectionUrl) { - assertDockerReadyForLint(); - } - - const pgModule = await ensurePgModule(); - const { Client: PgClient } = pgModule; - let client: InstanceType | null = null; - let container: { getConnectionUri(): string; stop(): Promise } | null = null; - - try { - let resolvedConnectionUrl = connectionUrl; - if (!resolvedConnectionUrl) { - const containerModule = await ensurePostgresContainerModule().catch((error) => { - const baseMessage = error instanceof Error ? error.message : String(error); - throw new Error(`${baseMessage} Or set ZTD_DB_URL to reuse an existing Postgres connection.`); - }); - const { PostgreSqlContainer } = containerModule; - const started = await new PostgreSqlContainer(process.env.ZTD_TEST_DB_IMAGE ?? 'postgres:16-alpine') - .withDatabase('ztdlint') - .withUsername('ztd') - .withPassword('ztd') - .start() - .catch((error) => { - throw buildLintContainerStartError(error); - }); - container = started; - resolvedConnectionUrl = started.getConnectionUri(); - } - - client = new PgClient({ - connectionString: resolvedConnectionUrl!, - connectionTimeoutMillis: resolveDbConnectTimeoutMs() - }); - await client.connect().catch((error) => { - throw buildLintConnectionError(error, Boolean(connectionUrl)); - }); - - const result = await runSqlLint({ - sqlFiles, - ddlDirectories: [ddlRoot], - defaultSchema: config.defaultSchema, - searchPath: config.searchPath, - ddlLint: config.ddlLint, - client - }); - - if (result.failures.length > 0) { - if (isJsonOutput()) { - writeCommandResultEnvelope('lint', false, { - schemaVersion: 1, - filesChecked: result.filesChecked, - failures: result.failures - }); - } - reportFailures(result.failures); - process.exitCode = 1; - throw new Error('ztd lint failed'); - } - - if (isJsonOutput()) { - writeCommandResultEnvelope('lint', true, { - schemaVersion: 1, - filesChecked: result.filesChecked, - failures: [] - }); - } - } finally { - await client?.end().catch(() => undefined); - await container?.stop(); - } -} - -export function resolveLintCommandInput(pattern: string | undefined, options: LintCommandOptions): { path: string } { - const merged = options.json - ? { path: pattern, ...options, ...parseJsonPayload>(options.json, '--json') } - : { path: pattern, ...options }; - if (typeof merged.path !== 'string' || merged.path.trim().length === 0) { - throw new Error('A lint path must be provided either as a positional argument or via --json {"path":"..."}'); - } - return { path: merged.path }; -} - -export function buildLintCommandFailureData(error: unknown): LintCommandEnvelopeData { - return { - schemaVersion: 1, - filesChecked: 0, - failures: [], - error: error instanceof Error ? error.message : String(error) - }; -} - -function resolveDbConnectTimeoutMs(): number { - const raw = process.env.ZTD_DB_CONNECT_TIMEOUT_MS?.trim(); - if (!raw) { - return 3000; - } - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - return 3000; - } - return Math.floor(parsed); -} - -function assertDockerReadyForLint(): void { - // Run a quick Docker CLI probe so daemon-off states fail before lint does heavier setup. - const probe = spawnSync('docker', ['info', '--format', '{{json .ServerVersion}}'], { - encoding: 'utf8', - timeout: 3000 - }); - - if (probe.error || probe.status !== 0) { - const stderr = (probe.stderr ?? '').trim(); - const detail = stderr.length > 0 ? ` (${stderr})` : ''; - throw new Error( - `Docker is not reachable. Start Docker Desktop/service before running ztd lint without ZTD_DB_URL.${detail}` - ); - } -} - -export function buildLintContainerStartError(error: unknown): Error { - const message = error instanceof Error ? error.message : String(error); - if (message.toLowerCase().includes('container runtime strategy') || message.toLowerCase().includes('docker')) { - return new Error( - `${message} Start Docker Desktop/service, or set ZTD_DB_URL to use an existing Postgres.` - ); - } - return new Error(message); -} - -export function buildLintConnectionError(error: unknown, usingExternalConnection: boolean): Error { - const message = error instanceof Error ? error.message : String(error); - const guidance = usingExternalConnection - ? 'Check ZTD_DB_URL and verify the ZTD-owned test database is reachable.' - : 'Check Docker Desktop/service and retry, or set ZTD_DB_URL to skip container startup.'; - return new Error(`Failed to connect to PostgreSQL for ztd lint. ${guidance} (${message})`); -} -function readFileSafe(filePath: string): string { - return readFileSync(filePath, 'utf8'); -} - -async function lintFile( - filePath: string, - contents: string, - testkit: ReturnType, - failures: LintFailure[], - resetRewritten: () => void, - getRewritten: () => string | null, - constructors: LintModuleConstructors -): Promise { - let split; - try { - split = MultiQuerySplitter.split(contents); - } catch (error) { - failures.push(buildParserFailure(filePath, contents, error)); - return; - } - - // Validate every statement that survives the splitter to catch syntax and semantic issues. - for (const chunk of split.queries) { - if (chunk.isEmpty) { - continue; - } - const statement = chunk.sql.trim(); - if (!statement) { - continue; - } - try { - SqlParser.parse(statement); - } catch (parseError) { - failures.push(buildParserFailure(filePath, statement, parseError)); - continue; - } - try { - resetRewritten(); - await executeValidationStatement(testkit, statement); - } catch (error) { - failures.push( - buildStatementFailure( - filePath, - statement, - contents, - error, - getRewritten(), - constructors - ) - ); - } - } -} - -async function executeValidationStatement( - testkit: ReturnType, - statement: string -): Promise { - const bindings = buildLintDefaultBindings(statement); - // Execute the rewritten statement so fixtures are applied before Postgres validation. - await testkit.query(statement, bindings); -} - -/** - * Detects the highest positional placeholder index used in SQL text. - * @param sql SQL text that may contain PostgreSQL-style placeholders such as $1. - * @returns The maximum placeholder index, or 0 when no positional placeholders exist. - */ -export function detectMaxPositionalParamIndex(sql: string): number { - const positionalPattern = /\$([1-9]\d*)/g; - let max = 0; - let match: RegExpExecArray | null; - while ((match = positionalPattern.exec(sql)) !== null) { - const index = Number(match[1]); - if (Number.isFinite(index) && index > max) { - max = index; - } - } - return max; -} - -function detectNamedParams(sql: string): string[] { - const namedPattern = /(^|[^:]):([A-Za-z_][A-Za-z0-9_]*)/g; - const names = new Set(); - let match: RegExpExecArray | null; - while ((match = namedPattern.exec(sql)) !== null) { - names.add(match[2]); - } - return Array.from(names); -} - -/** - * Builds deterministic placeholder bindings for lint execution. - * Named placeholders receive a keyed object and positional placeholders receive an indexed array. - * @param sql Statement text to inspect for placeholders. - * @returns Bind values or undefined when the statement has no placeholders. - */ -export function buildLintDefaultBindings( - sql: string -): unknown[] | Record | undefined { - const namedParams = detectNamedParams(sql); - if (namedParams.length > 0) { - return Object.fromEntries(namedParams.map((name) => [name, null])); - } - - const maxPositionalIndex = detectMaxPositionalParamIndex(sql); - if (maxPositionalIndex > 0) { - return Array.from({ length: maxPositionalIndex }, () => null); - } - return undefined; -} - -/** - * Build a parser failure record that surfaces rawsql-ts errors from runSqlLint. - * @param filePath The SQL file that triggered the parser issue. - * @param contents Statement contents to display alongside the error. - * @param error The parser error produced by rawsql-ts. - * @returns A failure record that keeps the command output consistent. - */ -export function buildParserFailure( - filePath: string, - contents: string, - error: unknown -): LintFailure { - const message = - `RawSQL parser: ${error instanceof Error ? error.message : 'Unknown parser error'}`; - return buildLintFailure({ - kind: 'parser', - filePath, - statement: contents, - message, - location: null - }); -} - -function buildStatementFailure( - filePath: string, - statement: string, - contents: string, - error: unknown, - rewritten: string | null, - constructors: LintModuleConstructors -): LintFailure { - if (isTransformError(error, constructors)) { - return buildTransformFailureFromStatement(filePath, statement, error, rewritten, constructors); - } - return buildDbFailure(filePath, statement, contents, error, rewritten); -} - -function buildDbFailure( - filePath: string, - statement: string, - contents: string, - error: unknown, - rewritten: string | null -): LintFailure { - const pgError = error as Record; - const position = - typeof pgError.position === 'string' - ? Number(pgError.position) - 1 - : typeof pgError.position === 'number' - ? pgError.position - : undefined; - const location = - position !== undefined ? findLineColumn(contents, position) : null; - const details: LintFailureDetails = {}; - if (typeof pgError.code === 'string') { - details.code = pgError.code; - } - if (typeof pgError.position === 'string' && !Number.isNaN(Number(pgError.position))) { - details.position = Number(pgError.position); - } else if (typeof pgError.position === 'number') { - details.position = pgError.position; - } - if (typeof pgError.detail === 'string') { - details.detail = pgError.detail; - } - if (typeof pgError.hint === 'string') { - details.hint = pgError.hint; - } - return buildLintFailure({ - kind: 'db', - filePath, - statement, - message: (pgError.message as string) ?? 'Unknown error', - location, - rewritten, - details: Object.keys(details).length > 0 ? details : undefined - }); -} - -function buildTransformFailureFromStatement( - filePath: string, - statement: string, - error: TransformError, - rewritten: string | null, - constructors: LintModuleConstructors -): LintFailure { - const message = `ZTD transform: ${error.message}`; - const details = getTransformDetails(error, constructors); - return buildLintFailure({ - kind: 'transform', - filePath, - statement, - message, - location: null, - rewritten, - details - }); -} - -function buildTransformFailureFromLoaderError( - error: unknown, - ddlDirectories: string[], - constructors: LintModuleConstructors -): LintFailure { - if ( - error instanceof constructors.DdlLintError && - error.diagnostics.length > 0 - ) { - const diag = error.diagnostics[0]; - const filePath = resolveDdlFailurePath(diag.source, ddlDirectories); - return buildLintFailure({ - kind: 'transform', - filePath, - message: `ZTD transform: ${diag.message}`, - location: null, - details: { code: diag.code } - }); - } - const filePath = resolveDdlFailurePath(undefined, ddlDirectories); - return buildLintFailure({ - kind: 'transform', - filePath, - message: `ZTD transform: ${error instanceof Error ? error.message : 'Unknown transform error'}`, - location: null - }); -} - -function resolveDdlFailurePath( - source: string | undefined, - directories: string[] -): string { - if (source) { - return path.resolve(process.cwd(), source); - } - if (directories.length) { - return path.resolve(directories[0]); - } - return process.cwd(); -} - -function getTransformDetails( - error: TransformError, - constructors: LintModuleConstructors -): LintFailureDetails | undefined { - if (error instanceof constructors.MissingFixtureError) { - return { code: 'missing-fixture' }; - } - if (error instanceof constructors.SchemaValidationError) { - return { code: 'schema-validation' }; - } - if (error instanceof constructors.QueryRewriteError) { - return { code: 'query-rewrite' }; - } - return undefined; -} - -function isTransformError( - error: unknown, - constructors: LintModuleConstructors -): error is TransformError { - return ( - error instanceof constructors.MissingFixtureError || - error instanceof constructors.SchemaValidationError || - error instanceof constructors.QueryRewriteError - ); -} - -function buildLintFailure(params: { - kind: LintFailureKind; - filePath: string; - statement?: string; - message: string; - location: { line: number; column: number } | null; - rewritten?: string | null; - details?: LintFailureDetails; -}): LintFailure { - return { - kind: params.kind, - filePath: params.filePath, - statement: params.statement, - message: params.message, - location: params.location ?? null, - rewritten: params.rewritten ?? null, - details: params.details - }; -} - -function findLineColumn( - contents: string, - position: number -): { line: number; column: number } { - const lines = contents.split(/\r?\n/); - let offset = 0; - for (let i = 0; i < lines.length; i += 1) { - const lineLength = lines[i].length + 1; - if (position < offset + lineLength) { - return { - line: i + 1, - column: position - offset + 1 - }; - } - offset += lineLength; - } - return { - line: lines.length, - column: lines[lines.length - 1].length + 1 - }; -} - -function reportFailures(failures: LintFailure[]): void { - for (const failure of failures) { - console.error(`\n[${failure.filePath}] (${failure.kind}) ${failure.message}`); - if (failure.location) { - console.error( - `at line ${failure.location.line} column ${failure.location.column}` - ); - } - if (failure.details?.position !== undefined) { - console.error(`position: ${failure.details.position}`); - } - if (failure.details?.code) { - console.error(`code: ${failure.details.code}`); - } - if (failure.details?.detail) { - console.error(`detail: ${failure.details.detail}`); - } - if (failure.details?.hint) { - console.error(`hint: ${failure.details.hint}`); - } - if (failure.rewritten) { - console.error('rewritten SQL:'); - console.error(failure.rewritten); - } - } -} - diff --git a/packages/ztd-cli/src/commands/modelGen.ts b/packages/ztd-cli/src/commands/modelGen.ts deleted file mode 100644 index 49b4ff468..000000000 --- a/packages/ztd-cli/src/commands/modelGen.ts +++ /dev/null @@ -1,896 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { - ensureAdapterNodePgModule, - ensurePgModule, - ensureTestkitCoreModule, - type PgClientLike, - type PgTestkitClientLike, -} from '../utils/optionalDependencies'; -import { buildProbeSql, mapDeclaredPgTypeToTs, probeQueryColumns, type ProbedColumn } from '../utils/modelProbe'; -import { bindModelGenNamedSql } from '../utils/modelGenBinder'; -import { - deriveModelGenNames, - normalizeGeneratedSqlFile, - renderModelGenFile, - toModelPropertyName, - type ModelGenFormat -} from '../utils/modelGenRender'; -import { ModelGenSqlScanError, scanModelGenSql, type PlaceholderMode, type SqlScanResult } from '../utils/modelGenScanner'; -import { - discoverProjectSqlCatalogSpecFiles, - loadSqlCatalogSpecsFromFile, -} from '../utils/sqlCatalogDiscovery'; -import { - resolveExplicitCliConnection, - resolveZtdOwnedCliConnection, - type ConnectionCliOptions -} from './connectionOptions'; -import { loadZtdProjectConfig } from '../utils/ztdProjectConfig'; -import { isJsonOutput, parseJsonPayload, writeCommandEnvelope } from '../utils/agentCli'; -import { validateProjectPath } from '../utils/agentSafety'; -import { emitDecisionEvent, withSpan, withSpanSync } from '../utils/telemetry'; - -interface ModelGenCommandOptions extends ConnectionCliOptions { - out?: string; - format?: ModelGenFormat; - sqlRoot?: string; - allowPositional?: boolean; - debugProbe?: boolean; - probeMode?: ModelGenProbeMode; - ddlDir?: string; - dryRun?: boolean; - describeOutput?: boolean; - json?: string; -} - -type ModelGenProbeMode = 'live' | 'ztd'; - -interface ModelGenZtdProbeOptions { - ddlDirectories: string[]; - defaultSchema: string; - searchPath: string[]; -} - -interface ModelGenZtdFixtureState { - tableDefinitions: unknown[]; - tableRows: Array<{ - tableName: string; - rows: Array>; - }>; -} - -interface ResolvedModelGenInputs { - derivedNames: { - interfaceName: string; - mappingName: string; - specName: string; - specId: string; - }; - format: ModelGenFormat; - probeMode: ModelGenProbeMode; - relativeSqlFile: string; - sqlFile: string; -} - -export const MODEL_GEN_SPAN_NAMES = { - resolveInputs: 'resolve-model-gen-inputs', - placeholderScan: 'placeholder-scan', - probeClientConnect: 'probe-client-connect', - probeQueryColumns: 'probe-query-columns', - typeInference: 'type-inference', - renderOutput: 'render-generated-output', - fileEmit: 'file-emit', -} as const; - -interface ModelGenZtdProbeInput { - ddlDir?: string; - rootDir?: string; -} - -export async function runModelGen(sqlFilePath: string, options: ModelGenCommandOptions): Promise { - const resolved = withSpanSync(MODEL_GEN_SPAN_NAMES.resolveInputs, () => { - return resolveModelGenInputs(sqlFilePath, options); - }, { - format: options.format ?? 'spec', - hasOut: Boolean(options.out), - }); - - emitDecisionEvent('model-gen.probe-mode', { - probeMode: resolved.probeMode, - }); - - const placeholderPlan = withSpanSync(MODEL_GEN_SPAN_NAMES.placeholderScan, () => { - const sqlSource = readFileSync(resolved.sqlFile, 'utf8'); - const scan = scanOrThrow(sqlSource, resolved.sqlFile, Boolean(options.allowPositional)); - const bound = bindProbeSql(sqlSource, scan, Boolean(options.allowPositional)); - - if (options.debugProbe) { - printProbeDebug( - resolved.sqlFile, - scan.mode, - bound.boundSql, - bound.orderedParamNames, - Boolean(options.allowPositional), - resolved.probeMode, - options.ddlDir - ); - } - - return { - bound, - scan, - }; - }, { - allowPositional: Boolean(options.allowPositional), - }); - - const probeClient = await withSpan(MODEL_GEN_SPAN_NAMES.probeClientConnect, async () => { - const connection = resolveCliConnectionWithProbeGuidance(options, resolved.probeMode); - return createProbeClient(resolved.probeMode, connection.url, options); - }, { - probeMode: resolved.probeMode, - }); - - try { - const probedColumns = await withSpan(MODEL_GEN_SPAN_NAMES.probeQueryColumns, async () => { - try { - return await probeQueryColumns( - probeClient.queryable, - placeholderPlan.bound.boundSql, - placeholderPlan.bound.orderedParamNames.map(() => null), - { direct: resolved.probeMode === 'ztd' } - ); - } catch (error) { - const fallbackColumns = await tryInferZtdReturningColumnsFromDdl({ - error, - probeMode: resolved.probeMode, - rootDir: process.cwd(), - ddlDir: options.ddlDir, - boundSql: placeholderPlan.bound.boundSql, - }); - if (fallbackColumns) { - return fallbackColumns; - } - throw error; - } - }, { - paramCount: placeholderPlan.bound.orderedParamNames.length, - probeMode: resolved.probeMode, - }); - - const columns = withSpanSync(MODEL_GEN_SPAN_NAMES.typeInference, () => { - const inferredColumns = probedColumns.map((column) => ({ - columnName: column.columnName, - propertyName: toModelPropertyName(column.columnName), - tsType: column.tsType - })); - assertUniqueProperties(inferredColumns.map((column) => column.propertyName)); - return inferredColumns; - }, { - columnCount: probedColumns.length, - }); - - const rendered = withSpanSync(MODEL_GEN_SPAN_NAMES.renderOutput, () => { - return renderModelGenFile({ - command: buildCommandText(sqlFilePath, options), - format: resolved.format, - sqlFile: resolved.relativeSqlFile, - specId: resolved.derivedNames.specId, - interfaceName: resolved.derivedNames.interfaceName, - mappingName: resolved.derivedNames.mappingName, - specName: resolved.derivedNames.specName, - placeholderMode: placeholderPlan.scan.mode, - allowPositional: Boolean(options.allowPositional), - orderedParamNames: placeholderPlan.bound.orderedParamNames, - columns - }); - }, { - format: resolved.format, - }); - - if (options.out && !options.dryRun) { - const outFile = options.out; - withSpanSync(MODEL_GEN_SPAN_NAMES.fileEmit, () => { - const absoluteOut = validateProjectPath(outFile, '--out'); - mkdirSync(path.dirname(absoluteOut), { recursive: true }); - writeFileSync(absoluteOut, rendered, 'utf8'); - }, { - outFile: normalizeCliPath(outFile), - }); - } - - return rendered; - } finally { - await probeClient.close(); - } -} - -export function registerModelGenCommand(program: Command): void { - program - .command('model-gen ') - .description('Generate QuerySpec output scaffolds from feature-local or shared SQL assets using ZTD-backed inspection or explicit target inspection metadata') - .option('--out ', 'Write the generated scaffold to a TypeScript file') - .option('--format ', 'Output format (spec, row-mapping, interface)', 'spec') - .option('--sql-root ', 'Compatibility helper for shared SQL roots; feature-local SQL resolves naturally without it') - .option('--allow-positional', 'Allow legacy positional placeholders ($1, $2, ...) for this run') - .option('--probe-mode ', 'Inspection source: live or ztd (default: live for backward compatibility; prefer ztd for the fast loop)', 'live') - .option('--ddl-dir ', 'DDL directory override for --probe-mode ztd (default: ztd.config.json ddlDir)') - .option('--debug-probe', 'Print the bound inspection SQL and ordered parameter names to stderr before inspection') - .option('--dry-run', 'Validate inspection and render output metadata without writing the generated file') - .option('--describe-output', 'Describe the generated artifact contract and exit') - .option('--json ', 'Pass model-gen options as a JSON object') - .option('--url ', 'Explicit target database URL for live inspection (preferred over --db-*)') - .option('--db-host ', 'Explicit target database host when --url is not used') - .option('--db-port ', 'Explicit target database port (defaults to 5432)') - .option('--db-user ', 'Explicit target database user') - .option('--db-password ', 'Explicit target database password') - .option('--db-name ', 'Explicit target database name') - .addHelpText( - 'after', - ` -Notes: - - In VSA layouts, pass the feature-local SQL file directly and keep the generated spec next to it. - - model-gen derives sqlFile/spec id from the SQL file location by default. - - Use --sql-root only when the project intentionally keeps SQL under a shared compatibility root. -` - ) - .action(async (sqlFile: string, options: ModelGenCommandOptions) => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - if (merged.describeOutput) { - const payload = { - schemaVersion: 1, - command: 'model-gen', - fileRules: { - supportsFeatureLocalSql: true, - // This array documents the recommended VSA mental model exposed to users. - // resolveRenderedSqlFileReference may still check explicitSqlRoot first - // for shared-layout compatibility before falling back to these defaults. - sqlResolutionConceptualOrder: [ - 'spec-relative-from-out', - 'project-relative', - 'explicit-sql-root', - 'legacy-src-sql' - ], - explicitSqlRootIsCompatibilityHelper: true, - detectsStableSpecIdCollisions: true - }, - outputs: { - spec: 'TypeScript QuerySpec scaffold', - 'row-mapping': 'TypeScript row mapping object', - interface: 'TypeScript row interface' - }, - writeBehavior: merged.out - ? { writesTo: validateProjectPath(String(merged.out), '--out'), dryRun: Boolean(merged.dryRun) } - : { writesTo: null, dryRun: Boolean(merged.dryRun) } - }; - if (isJsonOutput()) { - writeCommandEnvelope('model-gen describe-output', payload); - } else { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } - return; - } - - const rendered = await runModelGen(validateProjectPath(sqlFile, ''), { - ...merged, - ddlDir: merged.ddlDir ? validateProjectPath(String(merged.ddlDir), '--ddl-dir') : undefined, - out: merged.out ? validateProjectPath(String(merged.out), '--out') : undefined - }); - if (isJsonOutput()) { - writeCommandEnvelope('model-gen', { - schemaVersion: 1, - dryRun: Boolean(merged.dryRun), - outFile: merged.out ? validateProjectPath(String(merged.out), '--out') : null, - bytes: rendered.length, - format: merged.format ?? 'spec' - }); - return; - } - if (!merged.out) { - process.stdout.write(rendered); - } - }); -} - - -export function resolveCliConnectionWithProbeGuidance( - options: ModelGenCommandOptions, - probeMode: ModelGenProbeMode -) { - try { - if (probeMode === 'ztd') { - return resolveZtdOwnedCliConnection(); - } - return resolveExplicitCliConnection(options); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('ZTD_DB_URL is required') && probeMode === 'ztd') { - throw new Error( - [ - message, - 'model-gen --probe-mode ztd still needs a reachable PostgreSQL connection for ZTD-owned inspection.', - 'Start Docker/service and provide ZTD_DB_URL, then rerun.' - ].join('\n') - ); - } - throw error; - } -} - -function resolveDbConnectTimeoutMs(): number { - const raw = process.env.ZTD_DB_CONNECT_TIMEOUT_MS?.trim(); - if (!raw) { - return 3000; - } - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - return 3000; - } - return Math.floor(parsed); -} - -export function buildModelGenConnectionFailure(error: unknown, probeMode: ModelGenProbeMode): Error { - const message = error instanceof Error ? error.message : String(error); - const modeHint = - probeMode === 'ztd' - ? 'Ensure ZTD_DB_URL points to a reachable PostgreSQL instance before ZTD-owned inspection.' - : 'Ensure --url or a complete --db-* flag set points to a reachable PostgreSQL instance for explicit target inspection.'; - return new Error(`Failed to connect to PostgreSQL for model-gen. ${modeHint} (${message})`); -} -function normalizeFormat(format?: string): ModelGenFormat { - const normalized = (format ?? 'spec').trim().toLowerCase(); - if (normalized === 'spec' || normalized === 'row-mapping' || normalized === 'interface') { - return normalized; - } - throw new Error(`Unsupported format "${format}". Use one of: spec, row-mapping, interface.`); -} - -function normalizeProbeMode(value?: string): ModelGenProbeMode { - const normalized = (value ?? 'live').trim().toLowerCase(); - if (normalized === 'live' || normalized === 'ztd') { - return normalized; - } - throw new Error(`Unsupported probe mode "${value}". Use one of: live, ztd.`); -} - -function normalizeRealPath(targetPath: string): string { - const absolute = path.resolve(process.cwd(), targetPath); - if (!existsSync(absolute)) { - throw new Error(`File or directory does not exist: ${targetPath}`); - } - return realpathSync(absolute); -} - -function assertWithinExplicitSqlRoot(sqlRoot: string, sqlFile: string): void { - const relative = path.relative(sqlRoot, sqlFile); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new Error([ - `The SQL file is outside the configured sql root: ${sqlFile}.`, - 'model-gen only uses --sql-root as a compatibility helper for shared SQL layouts.', - 'For feature-local SQL, omit --sql-root and let model-gen derive the contract from the file location.', - `Move the file under ${sqlRoot} or remove --sql-root for feature-local discovery.` - ].join('\n')); - } -} - -export function resolveModelGenInputs( - sqlFilePath: string, - options: Pick & { rootDir?: string } -): ResolvedModelGenInputs { - const rootDir = options.rootDir ?? process.cwd(); - const format = normalizeFormat(options.format); - const sqlFile = normalizeRealPath(path.isAbsolute(sqlFilePath) ? sqlFilePath : path.resolve(rootDir, sqlFilePath)); - const explicitSqlRoot = options.sqlRoot - ? normalizeRealPath(path.isAbsolute(options.sqlRoot) ? options.sqlRoot : path.resolve(rootDir, options.sqlRoot)) - : undefined; - if (explicitSqlRoot) { - assertWithinExplicitSqlRoot(explicitSqlRoot, sqlFile); - } - - // Prefer spec-relative sqlFile values when we know where the generated spec - // will live so VSA slices naturally emit ./query.sql contracts. - const relativeSqlFile = resolveRenderedSqlFileReference({ - rootDir, - sqlFile, - outFile: options.out, - explicitSqlRoot, - }); - - // Preserve stable names across VSA and shared-root layouts by deriving the - // identity from the SQL path inside the project instead of a fixed src/sql root. - const derivedNames = deriveModelGenNames( - resolveModelGenIdentityPath({ - rootDir, - sqlFile, - explicitSqlRoot, - }) - ); - ensureSpecIdAvailable(rootDir, derivedNames.specId, sqlFile); - - return { - derivedNames, - format, - probeMode: normalizeProbeMode(options.probeMode), - relativeSqlFile, - sqlFile, - }; -} - -function resolveRenderedSqlFileReference(params: { - rootDir: string; - sqlFile: string; - outFile?: string; - explicitSqlRoot?: string; -}): string { - if (params.explicitSqlRoot) { - return normalizeGeneratedSqlFile(path.relative(params.explicitSqlRoot, params.sqlFile)); - } - - if (params.outFile) { - const outAbsolute = path.isAbsolute(params.outFile) - ? params.outFile - : path.resolve(params.rootDir, params.outFile); - return normalizeRelativeSpecPath(path.relative(path.dirname(outAbsolute), params.sqlFile)); - } - - return normalizeGeneratedSqlFile(resolveModelGenIdentityPath(params)); -} - -function resolveModelGenIdentityPath(params: { - rootDir: string; - sqlFile: string; - explicitSqlRoot?: string; -}): string { - if (params.explicitSqlRoot) { - return normalizeGeneratedSqlFile(path.relative(params.explicitSqlRoot, params.sqlFile)); - } - - const projectRelative = normalizeGeneratedSqlFile(path.relative(params.rootDir, params.sqlFile)); - if (projectRelative.startsWith('src/features/')) { - return projectRelative.slice('src/'.length); - } - if (projectRelative.startsWith('src/sql/')) { - return projectRelative.slice('src/sql/'.length); - } - if (projectRelative.startsWith('src/')) { - return projectRelative.slice('src/'.length); - } - return projectRelative; -} - -function normalizeRelativeSpecPath(relativePath: string): string { - const normalized = normalizeGeneratedSqlFile(relativePath); - if (normalized.startsWith('./') || normalized.startsWith('../')) { - return normalized; - } - return `./${normalized}`; -} - -function scanOrThrow(sqlSource: string, sqlFile: string, allowPositional: boolean) { - try { - const scan = scanModelGenSql(sqlSource); - if (scan.mode === 'positional' && !allowPositional) { - throw new Error([ - `Detected positional placeholders ($1, $2, ...) in ${sqlFile}.`, - 'SQL asset files in this workflow must use named parameters (:name) by policy.', - 'Rewrite the SQL to :name placeholders, or rerun ztd model-gen with --allow-positional for legacy SQL.' - ].join('\n')); - } - return scan; - } catch (error) { - if (error instanceof ModelGenSqlScanError) { - const detail = - error.token.startsWith(':') - ? 'model-gen currently supports named parameters matching [A-Za-z_][A-Za-z0-9_]*.' - : 'model-gen currently supports names-first SQL assets using :name only.'; - throw new Error([ - `${error.message.replace(/\.$/u, '')} in ${sqlFile}.`, - detail, - 'Rename the parameter to a supported :name form and rerun ztd model-gen.' - ].join('\n')); - } - throw error; - } -} - -export function bindProbeSql(sqlSource: string, scan: SqlScanResult, allowPositional: boolean): { - boundSql: string; - orderedParamNames: string[]; -} { - if (scan.mode === 'named') { - return bindModelGenNamedSql(sqlSource); - } - if (scan.mode === 'positional') { - if (!allowPositional) { - throw new Error('Positional placeholders are not allowed without --allow-positional.'); - } - // Preserve the highest positional slot so sparse placeholders still receive - // a params array that matches PostgreSQL's indexed placeholder contract. - const maxPlaceholderIndex = scan.positionalTokens.reduce((max, token) => { - const numericIndex = Number(token.token.slice(1)); - return Number.isFinite(numericIndex) ? Math.max(max, numericIndex) : max; - }, 0); - const orderedParamNames = Array.from({ length: maxPlaceholderIndex }, (_, index) => `$${index + 1}`); - return { boundSql: sqlSource, orderedParamNames }; - } - return { boundSql: sqlSource, orderedParamNames: [] }; -} - -interface ProbeClientHandle { - queryable: PgClientLike | PgTestkitClientLike; - close(): Promise; -} - -async function createProbeClient( - probeMode: ModelGenProbeMode, - connectionUrl: string, - options: ModelGenCommandOptions -): Promise { - const pgModule = await ensurePgModule(); - const pgClient = new pgModule.Client({ - connectionString: connectionUrl, - connectionTimeoutMillis: resolveDbConnectTimeoutMs() - }); - await pgClient.connect().catch((error) => { - throw buildModelGenConnectionFailure(error, probeMode); - }); - - if (probeMode === 'live') { - return { - queryable: pgClient, - close: async () => { - await pgClient.end(); - } - }; - } - - try { - const adapterModule = await ensureAdapterNodePgModule(); - const ztdProbeOptions = resolveModelGenZtdProbeOptions(options); - const ztdFixtureState = await loadModelGenZtdFixtureState(ztdProbeOptions); - const testkitClient = adapterModule.createPgTestkitClient({ - connectionFactory: () => pgClient, - tableDefinitions: ztdFixtureState.tableDefinitions, - tableRows: ztdFixtureState.tableRows, - defaultSchema: ztdProbeOptions.defaultSchema, - searchPath: ztdProbeOptions.searchPath - }); - - return { - queryable: testkitClient, - close: async () => { - const results = await Promise.allSettled([ - testkitClient.close(), - pgClient.end(), - ]); - const failure = results.find((result) => result.status === 'rejected'); - if (failure?.status === 'rejected') { - throw failure.reason; - } - } - }; - } catch (error) { - await pgClient.end(); - throw error; - } -} - -export function resolveModelGenZtdProbeOptions( - options: ModelGenZtdProbeInput -): ModelGenZtdProbeOptions { - const rootDir = options.rootDir ?? process.cwd(); - const config = loadZtdProjectConfig(rootDir); - const configuredDir = options.ddlDir ?? config.ddlDir; - const absoluteDir = path.resolve(rootDir, configuredDir); - if (!existsSync(absoluteDir)) { - throw new Error([ - `The DDL directory for --probe-mode ztd was not found: ${configuredDir}.`, - 'model-gen ztd mode needs DDL metadata so it can rewrite the probe query without physical tables.', - 'Create the directory, update ztd.config.json ddlDir, or pass --ddl-dir explicitly.' - ].join('\n')); - } - return { - ddlDirectories: [absoluteDir], - defaultSchema: config.defaultSchema, - searchPath: config.searchPath - }; -} - -export async function loadModelGenZtdFixtureState( - options: ModelGenZtdProbeOptions -): Promise { - const testkitCore = await ensureTestkitCoreModule(); - - // Reuse the shared schema resolver so unqualified references follow the same searchPath precedence as runtime ZTD rewrites. - const tableNameResolver = new testkitCore.TableNameResolver({ - defaultSchema: options.defaultSchema, - searchPath: options.searchPath, - }); - const loader = new testkitCore.DdlFixtureLoader({ - directories: options.ddlDirectories, - tableNameResolver, - }); - const ddlFixtures = loader.getFixtures(); - - return { - tableDefinitions: ddlFixtures.map((fixture) => fixture.tableDefinition), - tableRows: ddlFixtures.map((fixture) => ({ - tableName: fixture.tableDefinition.name, - rows: fixture.rows ?? [], - })), - }; -} - -function printProbeDebug( - sqlFile: string, - mode: PlaceholderMode, - boundSql: string, - orderedParamNames: string[], - allowPositional: boolean, - probeMode: ModelGenProbeMode, - ddlDir?: string -): void { - const lines = [ - '[model-gen] inspection debug', - `sqlFile: ${normalizeCliPath(sqlFile)}`, - `placeholderMode: ${mode}`, - `allowPositional: ${allowPositional}`, - `probeMode: ${probeMode}`, - `orderedParamNames: ${JSON.stringify(orderedParamNames)}`, - 'boundSql:', - boundSql, - `inspectionSql: ${buildProbeSql(boundSql)}` - ]; - if (probeMode === 'ztd') { - const ztdOptions = resolveModelGenZtdProbeOptions({ ddlDir }); - lines.push(`ddlDir: ${normalizeCliPath(ddlDir ?? loadZtdProjectConfig().ddlDir)}`); - lines.push(`defaultSchema: ${ztdOptions.defaultSchema}`); - lines.push(`searchPath: ${JSON.stringify(ztdOptions.searchPath)}`); - } - process.stderr.write(`${lines.join('\n')}\n`); -} - -function buildCommandText(sqlFilePath: string, options: ModelGenCommandOptions): string { - const segments = ['ztd model-gen', normalizeCliPath(sqlFilePath)]; - if (options.out) { - segments.push(`--out ${normalizeCliPath(options.out)}`); - } - if (options.format && options.format !== 'spec') { - segments.push(`--format ${options.format}`); - } - if (options.sqlRoot && options.sqlRoot !== path.join('src', 'sql')) { - segments.push(`--sql-root ${normalizeCliPath(options.sqlRoot)}`); - } - if (options.allowPositional) { - segments.push('--allow-positional'); - } - if (options.probeMode && options.probeMode !== 'live') { - segments.push(`--probe-mode ${options.probeMode}`); - } - if (options.ddlDir) { - segments.push(`--ddl-dir ${normalizeCliPath(options.ddlDir)}`); - } - return segments.join(' '); -} - -export function normalizeCliPath(filePath: string): string { - return filePath.replace(/\\/g, '/'); -} - -async function tryInferZtdReturningColumnsFromDdl(params: { - error: unknown; - probeMode: ModelGenProbeMode; - rootDir?: string; - ddlDir?: string; - boundSql: string; -}): Promise { - if (params.probeMode !== 'ztd') { - return null; - } - - const message = params.error instanceof Error ? params.error.message : String(params.error); - if (!/cannot be resolved for RETURNING output/i.test(message)) { - return null; - } - - const ztdOptions = resolveModelGenZtdProbeOptions({ - rootDir: params.rootDir, - ddlDir: params.ddlDir, - }); - const fixtureState = await loadModelGenZtdFixtureState(ztdOptions); - return inferReturningColumnsFromTableDefinitions( - params.boundSql, - fixtureState.tableDefinitions, - ztdOptions.defaultSchema, - ztdOptions.searchPath - ); -} - -export function inferReturningColumnsFromTableDefinitions( - sql: string, - tableDefinitions: unknown[], - defaultSchema: string, - searchPath: string[] -): ProbedColumn[] | null { - const normalizedSql = sql.trim().replace(/(?:;\s*)+$/u, ''); - const tableMatch = normalizedSql.match(/^\s*(?:insert\s+into|update|delete\s+from)\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|[A-Za-z_][\w$]*))?)/iu); - const returningMatch = normalizedSql.match(/\breturning\b([\s\S]+)$/iu); - if (!tableMatch || !returningMatch) { - return null; - } - - const tableIdentifier = parseTableReference(tableMatch[1]); - if (!tableIdentifier) { - return null; - } - - const tableDefinition = resolveTableDefinition(tableDefinitions, tableIdentifier, defaultSchema, searchPath); - if (!tableDefinition) { - return null; - } - - const returningColumns = splitReturningColumns(returningMatch[1]); - if (returningColumns.length === 0) { - return null; - } - - const resolved = returningColumns.map((columnName) => { - const column = tableDefinition.columns.find((candidate) => candidate.name.toLowerCase() === columnName.toLowerCase()); - if (!column) { - throw new Error(`Column '${columnName}' cannot be resolved for RETURNING output.`); - } - const typeName = typeof column.typeName === 'string' ? column.typeName : 'unknown'; - return { - columnName: column.name, - typeName, - tsType: mapDeclaredPgTypeToTs(typeName), - }; - }); - - return resolved; -} - -function splitReturningColumns(source: string): string[] { - return source - .split(',') - .map((segment) => segment.trim()) - .map((segment) => { - const identifier = parseColumnReference(segment); - return identifier ? identifier.column : null; - }) - .filter((value): value is string => typeof value === 'string' && value.length > 0); -} - -function parseColumnReference(source: string): { schema?: string; table?: string; column: string } | null { - const parts = source - .split('.') - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) - .map((segment) => { - if (segment.startsWith('"') && segment.endsWith('"')) { - return segment.slice(1, -1); - } - return /^[A-Za-z_][\w$]*$/u.test(segment) ? segment : null; - }); - - if (parts.some((part) => part === null)) { - return null; - } - - if (parts.length === 1) { - return { column: parts[0]! }; - } - if (parts.length === 2) { - return { table: parts[0]!, column: parts[1]! }; - } - if (parts.length === 3) { - return { schema: parts[0]!, table: parts[1]!, column: parts[2]! }; - } - return null; -} - -function parseTableReference(source: string): { schema?: string; table: string } | null { - const parts = source - .split('.') - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) - .map((segment) => { - if (segment.startsWith('"') && segment.endsWith('"')) { - return segment.slice(1, -1); - } - return /^[A-Za-z_][\w$]*$/u.test(segment) ? segment : null; - }); - - if (parts.some((part) => part === null)) { - return null; - } - - if (parts.length === 1) { - return { table: parts[0]! }; - } - if (parts.length === 2) { - return { schema: parts[0]!, table: parts[1]! }; - } - return null; -} - -function resolveTableDefinition( - tableDefinitions: unknown[], - identifier: { schema?: string; table: string }, - defaultSchema: string, - searchPath: string[] -): { name: string; columns: Array<{ name: string; typeName?: string }> } | null { - const definitions = tableDefinitions - .filter((definition): definition is { name: string; columns: Array<{ name: string; typeName?: string }> } => - typeof (definition as { name?: unknown }).name === 'string' && - Array.isArray((definition as { columns?: unknown }).columns) - ); - - const requestedTable = identifier.table; - const requestedSchema = identifier.schema; - if (requestedSchema) { - return ( - definitions.find((definition) => definition.name.toLowerCase() === `${requestedSchema}.${requestedTable}`.toLowerCase()) ?? - definitions.find((definition) => definition.name.toLowerCase() === requestedTable.toLowerCase()) ?? - null - ); - } - - const candidates = definitions.filter((definition) => definition.name.split('.').pop()?.toLowerCase() === requestedTable.toLowerCase()); - for (const schemaName of [defaultSchema, ...searchPath]) { - const match = candidates.find((definition) => definition.name.toLowerCase() === `${schemaName}.${requestedTable}`.toLowerCase()); - if (match) { - return match; - } - } - - return candidates[0] ?? null; -} - -function ensureSpecIdAvailable(projectRoot: string, specId: string, sourceSqlFile: string): void { - const specFiles = discoverProjectSqlCatalogSpecFiles(projectRoot, { excludeTestFiles: true }); - for (const specFile of specFiles) { - const loadedSpecs = loadSqlCatalogSpecsFromFile(specFile, (message) => new Error(message)); - if (loadedSpecs.length > 0) { - for (const entry of loadedSpecs) { - if (entry.spec.id !== specId) { - continue; - } - throw new Error([ - `Generated spec id "${specId}" conflicts with an existing spec in ${entry.filePath}.`, - 'model-gen keeps spec ids stable and does not auto-rename collisions.', - `Rename the SQL file or adjust the --sql-root layout and rerun ztd model-gen for ${sourceSqlFile}.` - ].join('\n')); - } - continue; - } - - // Preserve the old collision guard for partial/manual spec stubs that may - // define an id before they become full QuerySpec entries with sqlFile. - const source = readFileSync(specFile, 'utf8'); - const matches = source.matchAll(/id\s*:\s*['"`]([^'"`]+)['"`]/g); - for (const match of matches) { - if (match[1] !== specId) { - continue; - } - throw new Error([ - `Generated spec id "${specId}" conflicts with an existing spec in ${specFile}.`, - 'model-gen keeps spec ids stable and does not auto-rename collisions.', - `Rename the SQL file or adjust the --sql-root layout and rerun ztd model-gen for ${sourceSqlFile}.` - ].join('\n')); - } - } -} - -function assertUniqueProperties(properties: string[]): void { - const seen = new Set(); - for (const property of properties) { - if (seen.has(property)) { - throw new Error(`Duplicate generated property name "${property}" detected. Rename the SQL column aliases before rerunning model-gen.`); - } - seen.add(property); - } -} - diff --git a/packages/ztd-cli/src/commands/options.ts b/packages/ztd-cli/src/commands/options.ts deleted file mode 100644 index c78301b23..000000000 --- a/packages/ztd-cli/src/commands/options.ts +++ /dev/null @@ -1,46 +0,0 @@ -export const DEFAULT_EXTENSIONS = ['.sql']; -export const DEFAULT_DDL_DIRECTORY = 'db/ddl'; -export const DEFAULT_TESTS_DIRECTORY = '.ztd/tests'; -const EXTENSION_TOKEN_PATTERN = /^[A-Za-z0-9_]+$/; - -export function collectDirectories(value: string, previous: string[]): string[] { - return [...previous, value]; -} - -export function collectValues(value: string, previous: string[]): string[] { - return [...previous, value]; -} - -export function normalizeDirectoryList(userDirectories: string[] | undefined, fallback: string): string[] { - const candidates = (userDirectories ?? []).map((entry) => entry.trim()).filter(Boolean); - // Fall back to the configured default directory when no explicit paths are provided. - const directories = candidates.length ? candidates : [fallback]; - return Array.from(new Set(directories)); -} - -export function parseExtensions(value: string | string[]): string[] { - const rawEntries = Array.isArray(value) ? value : value.split(','); - - // Clean and normalize each candidate before deduplicating to ensure stable output. - const cleanedTokens = rawEntries - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0 && entry !== '.') - .map((entry) => (entry.startsWith('.') ? entry.slice(1) : entry)) - .filter((token) => token.length > 0 && EXTENSION_TOKEN_PATTERN.test(token)) - .map((token) => `.${token.toLowerCase()}`); - - return Array.from(new Set(cleanedTokens)).sort(); -} - -export function resolveExtensions(input: string[] | undefined, fallback: string[]): string[] { - // Use user-specified extensions when present; otherwise rely on the default list. - const normalized = input?.length ? input : fallback; - return Array.from(new Set(normalized)); -} - -export function parseCsvList(value: string): string[] { - return value - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} diff --git a/packages/ztd-cli/src/commands/perf.ts b/packages/ztd-cli/src/commands/perf.ts deleted file mode 100644 index 73218eace..000000000 --- a/packages/ztd-cli/src/commands/perf.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { Command } from 'commander'; -import path from 'node:path'; -import { - CreateTableQuery, - MultiQuerySplitter, - SqlParser, - createTableDefinitionFromCreateTableQuery -} from 'rawsql-ts'; -import { - applyPerfInitPlan, - buildInsertStatementsForTable, - buildPerfInitPlan, - inspectPerfDdlInventory, - loadPerfSeedConfig, - resetPerfSandbox, - seedPerfSandbox -} from '../perf/sandbox'; -import { - PERF_BENCHMARK_DEFAULTS, - diffPerfBenchmarkReports, - formatPerfBenchmarkReport, - formatPerfDiffReport, - runPerfBenchmark, - type PerfBenchmarkFormat, - type PerfBenchmarkMode -} from '../perf/benchmark'; -import { isJsonOutput, parseJsonPayload, writeCommandEnvelope } from '../utils/agentCli'; -import { collectSqlFiles } from '../utils/collectSqlFiles'; -import { withSpan, withSpanSync } from '../utils/telemetry'; -import { loadZtdProjectConfig } from '../utils/ztdProjectConfig'; - -interface PerfInitOptions { - dryRun?: boolean; - json?: string; -} - -interface PerfSeedOptions { - dryRun?: boolean; - json?: string; -} - -interface PerfResetOptions { - dryRun?: boolean; - json?: string; -} - -interface PerfRunOptions { - query?: string; - params?: string; - strategy?: string; - material?: string; - mode?: string; - repeat?: string; - warmup?: string; - classifyThresholdSeconds?: string; - timeoutMinutes?: string; - save?: boolean; - dryRun?: boolean; - label?: string; - json?: string; -} - -interface PerfReportDiffOptions { - format?: string; - json?: string; -} - -export const PERF_COMMAND_SPANS = { - resolveRunOptions: 'resolve-perf-run-options', - executeBenchmark: 'execute-perf-benchmark', - renderBenchmark: 'render-perf-report', - loadDiff: 'load-perf-report-diff', - renderDiff: 'render-perf-diff-output', -} as const; - -export function registerPerfCommands(program: Command): void { - const perf = program.command('perf').description('Opt-in perf sandbox workflows for reproducible SQL experiments'); - perf.addHelpText( - 'after', - ` -Examples: - $ ztd perf init - $ ztd perf db reset --dry-run - $ ztd perf seed --dry-run - $ ztd perf run --query src/sql/report.sql --dry-run - $ ztd perf report diff perf/evidence/run_001 perf/evidence/run_002 -` - ); - - perf - .command('init') - .description('Scaffold the perf sandbox configuration and Docker assets') - .option('--dry-run', 'Emit the perf scaffold plan without writing files') - .option('--json ', 'Pass perf init options as a JSON object') - .action((options: PerfInitOptions) => { - runPerfInitCommand(options); - }); - - const db = perf.command('db').description('Manage the perf sandbox database'); - db - .command('reset') - .description('Recreate the perf sandbox schema from local DDL') - .option('--dry-run', 'Emit the reset plan without touching Docker or PostgreSQL') - .option('--json ', 'Pass perf db reset options as a JSON object') - .action(async (options: PerfResetOptions) => { - await runPerfDbResetCommand(options); - }); - - perf - .command('seed') - .description('Generate deterministic synthetic data from perf/seed.yml') - .option('--dry-run', 'Emit the seed plan without touching PostgreSQL') - .option('--json ', 'Pass perf seed options as a JSON object') - .action(async (options: PerfSeedOptions) => { - await runPerfSeedCommand(options); - }); - - perf - .command('run') - .description('Benchmark one SQL query and capture evidence for AI-driven tuning loops') - .option('--query ', 'SQL file to benchmark inside the perf sandbox') - .option('--params ', 'JSON or YAML file with query parameters (object for named placeholders, array for positional)') - .option('--strategy ', 'Execution strategy (direct|decomposed)', 'direct') - .option('--material ', 'Comma-separated CTEs to materialize when --strategy decomposed is used') - .option('--mode ', 'Benchmark mode (auto|latency|completion)', 'auto') - .option('--repeat ', `Measured repetitions for latency mode (default: ${PERF_BENCHMARK_DEFAULTS.repeat})`) - .option('--warmup ', `Warmup repetitions for latency mode (default: ${PERF_BENCHMARK_DEFAULTS.warmup})`) - .option('--classify-threshold-seconds ', `Threshold for auto mode classification (default: ${PERF_BENCHMARK_DEFAULTS.classifyThresholdSeconds})`) - .option('--timeout-minutes ', `Timeout for measured runs (default: ${PERF_BENCHMARK_DEFAULTS.timeoutMinutes})`) - .option('--save', 'Persist benchmark evidence under perf/evidence/run_xxx') - .option('--dry-run', 'Resolve mode, params, and evidence shape without touching PostgreSQL') - .option('--label ', 'Attach a short label to the saved run directory') - .option('--json ', 'Pass perf run options as a JSON object') - .action(async (options: PerfRunOptions) => { - await runPerfRunCommand(options); - }); - - const report = perf.command('report').description('Compare saved perf benchmark evidence'); - report - .command('diff ') - .description('Compare two saved perf benchmark runs and highlight the primary metric delta') - .option('--format ', 'Output format (text|json)', 'text') - .option('--json ', 'Pass perf report diff options as a JSON object') - .action((baselineDir: string, candidateDir: string, options: PerfReportDiffOptions) => { - runPerfReportDiffCommand(baselineDir, candidateDir, options); - }); -} - -function runPerfInitCommand(options: PerfInitOptions): void { - const merged = resolvePerfOptions(options); - const plan = buildPerfInitPlan(process.cwd()); - - if (merged.dryRun) { - emitPerfResult('perf init', { - dryRun: true, - files: plan.files.map((file) => path.relative(process.cwd(), file.path).replace(/\\/g, '/')) - }); - return; - } - - const written = applyPerfInitPlan(plan).map((file) => path.relative(process.cwd(), file).replace(/\\/g, '/')); - emitPerfResult('perf init', { - dryRun: false, - files: written - }, [`Perf sandbox initialized.`, ...written.map((file) => `- ${file}`)]); -} - -async function runPerfDbResetCommand(options: PerfResetOptions): Promise { - const merged = resolvePerfOptions(options); - const ddlInventory = inspectPerfDdlInventory(process.cwd(), { requireExistingDdlDir: true }); - - if (merged.dryRun) { - emitPerfResult('perf db reset', { - dryRun: true, - ddl_files: ddlInventory.files, - ddl_file_count: ddlInventory.files.length, - ddl_statement_count: ddlInventory.ddlStatementCount, - table_count: ddlInventory.tableCount, - index_count: ddlInventory.indexCount, - index_names: ddlInventory.indexNames - }); - return; - } - - const result = await resetPerfSandbox(process.cwd()); - const displayConnectionUrl = toDisplayConnectionUrl(result.connectionUrl); - const appliedInventory = inspectPerfDdlInventory(process.cwd(), { requireExistingDdlDir: true }); - emitPerfResult('perf db reset', { - dryRun: false, - connection_url: displayConnectionUrl, - used_docker: result.usedDocker, - ddl_files: result.appliedFiles, - ddl_statement_count: result.ddlStatements, - table_count: appliedInventory.tableCount, - index_count: appliedInventory.indexCount, - index_names: appliedInventory.indexNames - }, [ - `Perf sandbox reset complete.`, - `Connection: ${displayConnectionUrl}`, - `DDL files: ${result.appliedFiles.length}`, - `DDL statements: ${result.ddlStatements}`, - `Tables: ${appliedInventory.tableCount}`, - `Indexes: ${appliedInventory.indexCount}` - ]); -} - -async function runPerfSeedCommand(options: PerfSeedOptions): Promise { - const merged = resolvePerfOptions(options); - - if (merged.dryRun) { - const plan = buildPerfSeedDryRunPlan(process.cwd()); - emitPerfResult('perf seed', { - dryRun: true, - seed: plan.seed, - tables: plan.tables - }); - return; - } - - const result = await seedPerfSandbox(process.cwd()); - const displayConnectionUrl = toDisplayConnectionUrl(result.connectionUrl); - emitPerfResult('perf seed', { - dryRun: false, - connection_url: displayConnectionUrl, - used_docker: result.usedDocker, - seed: result.seed, - inserted_rows: result.insertedRows - }, [ - `Perf seed complete.`, - `Connection: ${displayConnectionUrl}`, - `Seed: ${result.seed}`, - ...Object.entries(result.insertedRows).map(([tableName, rows]) => `- ${tableName}: ${rows} rows`) - ]); -} - -async function runPerfRunCommand(options: PerfRunOptions): Promise { - const resolved = withSpanSync(PERF_COMMAND_SPANS.resolveRunOptions, () => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const queryFile = normalizeRequiredStringOption(merged.query, '--query'); - - return { - rootDir: process.cwd(), - queryFile, - paramsFile: normalizeOptionalStringOption(merged.params), - strategy: normalizePerfStrategy(normalizeOptionalStringOption(merged.strategy) ?? 'direct'), - material: normalizeCsvListOption(merged.material), - mode: normalizeBenchmarkMode(normalizeOptionalStringOption(merged.mode) ?? 'auto'), - repeat: normalizePositiveIntegerOption(merged.repeat, '--repeat', PERF_BENCHMARK_DEFAULTS.repeat), - warmup: normalizeNonNegativeIntegerOption(merged.warmup, '--warmup', PERF_BENCHMARK_DEFAULTS.warmup), - classifyThresholdSeconds: normalizePositiveIntegerOption( - merged.classifyThresholdSeconds, - '--classify-threshold-seconds', - PERF_BENCHMARK_DEFAULTS.classifyThresholdSeconds - ), - timeoutMinutes: normalizePositiveIntegerOption(merged.timeoutMinutes, '--timeout-minutes', PERF_BENCHMARK_DEFAULTS.timeoutMinutes), - save: normalizeBooleanOption(merged.save), - dryRun: normalizeBooleanOption(merged.dryRun), - label: normalizeOptionalStringOption(merged.label) - }; - }, { - jsonPayload: Boolean(options.json), - }); - - const report = await withSpan(PERF_COMMAND_SPANS.executeBenchmark, () => { - return runPerfBenchmark(resolved); - }, { - strategy: resolved.strategy, - requestedMode: resolved.mode, - save: resolved.save, - dryRun: resolved.dryRun, - }); - - withSpanSync(PERF_COMMAND_SPANS.renderBenchmark, () => { - emitPerfReport('perf run', report); - }, { - selectedMode: report.selected_mode, - strategy: report.strategy, - saved: report.saved, - dryRun: report.dry_run, - }); -} - -function runPerfReportDiffCommand(baselineDir: string, candidateDir: string, options: PerfReportDiffOptions): void { - const resolved = withSpanSync(PERF_COMMAND_SPANS.loadDiff, () => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const format = normalizePerfFormat(normalizeOptionalStringOption(merged.format)); - const report = diffPerfBenchmarkReports(path.resolve(process.cwd(), baselineDir), path.resolve(process.cwd(), candidateDir)); - - return { format, report }; - }, { - jsonPayload: Boolean(options.json), - }); - - if (isJsonOutput()) { - withSpanSync(PERF_COMMAND_SPANS.renderDiff, () => { - writeCommandEnvelope('perf report diff', resolved.report); - }, { - format: resolved.format, - }); - return; - } - - withSpanSync(PERF_COMMAND_SPANS.renderDiff, () => { - process.stdout.write(formatPerfDiffReport(resolved.report, resolved.format)); - }, { - format: resolved.format, - }); -} -function buildPerfSeedDryRunPlan(rootDir: string): { seed: number; tables: Record } { - const config = loadZtdProjectConfig(rootDir); - const seedConfig = loadPerfSeedConfig(rootDir); - const ddlSources = collectSqlFiles([path.resolve(rootDir, config.ddlDir)], ['.sql']); - const definitions = ddlSources.flatMap((source) => { - const split = MultiQuerySplitter.split(source.sql); - return split.queries.flatMap((chunk: { sql: string }) => { - const sql = chunk.sql.trim(); - if (!sql) { - return []; - } - const parsed = SqlParser.parse(sql); - if (!(parsed instanceof CreateTableQuery)) { - return []; - } - return [createTableDefinitionFromCreateTableQuery(parsed)]; - }); - }); - - const tables = Object.fromEntries( - Object.entries(seedConfig.tables).map(([tableName, tableConfig]) => { - const definition = definitions.find((candidate) => candidate.name === tableName || candidate.name === `${config.defaultSchema}.${tableName}`); - if (!definition) { - throw new Error(`No table definition found for perf seed table: ${tableName}`); - } - return [definition.name, buildInsertStatementsForTable(definition, tableConfig.rows, seedConfig).length]; - }) - ); - - return { seed: seedConfig.seed, tables }; -} - -function toDisplayConnectionUrl(connectionUrl: string): string { - try { - const parsed = new URL(connectionUrl); - return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; - } catch { - return '[unavailable]'; - } -} - -function emitPerfResult(command: string, data: Record, textLines?: string[]): void { - if (isJsonOutput()) { - writeCommandEnvelope(command, data); - return; - } - - if (textLines) { - process.stdout.write(`${textLines.join('\n')}\n`); - return; - } - - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); -} - -function emitPerfReport(command: 'perf run', report: Awaited>): void { - if (isJsonOutput()) { - writeCommandEnvelope(command, report); - return; - } - - process.stdout.write(formatPerfBenchmarkReport(report, 'text')); -} - -function resolvePerfOptions(options: T): { dryRun: boolean } { - const merged = options.json - ? { ...options, ...parseJsonPayload>(options.json, '--json') } - : options; - - if (merged.dryRun !== undefined && typeof merged.dryRun !== 'boolean') { - throw new Error('Expected --dry-run to resolve to a boolean.'); - } - - return { - dryRun: Boolean(merged.dryRun) - }; -} - -function normalizeOptionalStringOption(value: unknown): string | undefined { - if (value === undefined || value === null || value === '') { - return undefined; - } - if (typeof value !== 'string') { - throw new Error(`Expected a string option but received ${typeof value}.`); - } - return value; -} - -function normalizeRequiredStringOption(value: unknown, label: string): string { - const normalized = normalizeOptionalStringOption(value); - if (!normalized) { - throw new Error(`${label} is required.`); - } - return normalized; -} - -function normalizeBooleanOption(value: unknown): boolean { - if (value === undefined) { - return false; - } - if (typeof value !== 'boolean') { - throw new Error(`Expected a boolean option but received ${typeof value}.`); - } - return value; -} - -function normalizePositiveIntegerOption(value: unknown, label: string, fallback: number): number { - if (value === undefined || value === null || value === '') { - return fallback; - } - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`${label} must be a positive integer.`); - } - return parsed; -} - -function normalizeNonNegativeIntegerOption(value: unknown, label: string, fallback: number): number { - if (value === undefined || value === null || value === '') { - return fallback; - } - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error(`${label} must be a non-negative integer.`); - } - return parsed; -} - -function normalizeCsvListOption(value: unknown): string[] { - if (Array.isArray(value)) { - return value.map((entry) => { - if (typeof entry !== 'string') { - throw new Error(`Expected material entries to be strings but received ${typeof entry}.`); - } - return entry.trim(); - }).filter(Boolean); - } - - const normalized = normalizeOptionalStringOption(value); - if (!normalized) { - return []; - } - return normalized.split(',').map((entry) => entry.trim()).filter(Boolean); -} - -function normalizePerfStrategy(value: string): 'direct' | 'decomposed' { - const normalized = value.trim().toLowerCase(); - if (normalized === 'direct' || normalized === 'decomposed') { - return normalized; - } - throw new Error(`Unsupported perf execution strategy: ${value}`); -} - -function normalizeBenchmarkMode(value: string): PerfBenchmarkMode { - const normalized = value.trim().toLowerCase(); - if (normalized === 'auto' || normalized === 'latency' || normalized === 'completion') { - return normalized; - } - throw new Error(`Unsupported perf benchmark mode: ${value}`); -} - -function normalizePerfFormat(value: string | undefined): PerfBenchmarkFormat { - const normalized = (value ?? 'text').trim().toLowerCase(); - if (normalized === 'text' || normalized === 'json') { - return normalized; - } - throw new Error(`Unsupported format: ${value}`); -} - diff --git a/packages/ztd-cli/src/commands/pull.ts b/packages/ztd-cli/src/commands/pull.ts deleted file mode 100644 index 62db5c259..000000000 --- a/packages/ztd-cli/src/commands/pull.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { existsSync, writeFileSync, rmSync } from 'node:fs'; -import path from 'node:path'; -import { runPgDump } from '../utils/pgDump'; -import { ensureDirectory } from '../utils/fs'; -import { normalizePulledSchema, NormalizedStatement } from '../utils/normalizePulledSchema'; -import type { DbConnectionContext } from '../utils/dbConnection'; - -export interface PullSchemaOptions { - url: string; - out: string; - pgDumpPath?: string; - pgDumpShell?: boolean; - schemas?: string[]; - tables?: string[]; - connectionContext?: DbConnectionContext; - dryRun?: boolean; -} - -interface TableSpecifier { - schema: string; - original: string; -} - -export interface PullSchemaResult { - outDir: string; - files: Array<{ - schema: string; - filePath: string; - contents: string; - }>; - dryRun: boolean; -} - -export function runPullSchema(options: PullSchemaOptions): PullSchemaResult { - // Canonicalize CLI filters before invoking pg_dump so later steps can rely on consistent casing. - const schemaFilters = (options.schemas ?? []).map((value) => normalizeSchemaName(value)); - const tableFilters = (options.tables ?? []).map((value) => parseTableSpecifier(value)); - // Build the schema set that should survive the normalization pass. - const allowedSchemas = buildAllowedSchemas(schemaFilters, tableFilters); - - // Invoke pg_dump once with the normalized filters so only the desired objects are exported. - const ddlSql = runPgDump({ - url: options.url, - pgDumpPath: options.pgDumpPath, - pgDumpShell: options.pgDumpShell, - extraArgs: buildPgDumpArguments(schemaFilters, tableFilters), - connectionContext: options.connectionContext - }); - - // Normalize and bucket the pg_dump output while respecting the requested schema set. - const normalizedMap = normalizePulledSchema(ddlSql, { - allowedSchemas: allowedSchemas.size ? allowedSchemas : undefined - }); - if (normalizedMap.size === 0) { - // Help callers realize their filters may have excluded everything from the dump. - const filterHints: string[] = []; - if (schemaFilters.length) { - filterHints.push(`--schema ${schemaFilters.join(', ')}`); - } - if (tableFilters.length) { - filterHints.push(`--table ${tableFilters.map((table) => table.original).join(', ')}`); - } - const hint = filterHints.length ? ` Filters applied: ${filterHints.join('; ')}.` : ''; - throw new Error(`The dump did not contain any supported DDL statements.${hint} Verify the schema/table filters match your database.`); - } - - const outDir = path.resolve(options.out); - const files = Array.from(normalizedMap.entries()) - .sort(([schemaA], [schemaB]) => schemaA.localeCompare(schemaB)) - .map(([schema, statements]) => ({ - schema, - filePath: path.join(outDir, `${sanitizeSchemaFileName(schema)}.sql`), - contents: buildSchemaFile(statements) - })); - - if (!options.dryRun) { - ensureDirectory(outDir); - // Remove the legacy schema.sql snapshot only when it is still present. - const legacySchemaFile = path.join(outDir, 'schema.sql'); - if (existsSync(legacySchemaFile)) { - rmSync(legacySchemaFile, { force: true }); - } - const schemasDir = path.join(outDir, 'schemas'); - if (existsSync(schemasDir)) { - rmSync(schemasDir, { recursive: true, force: true }); - } - - // Persist each schema snapshot directly under the DDL directory for easier discovery. - for (const file of files) { - writeFileSync(file.filePath, file.contents, 'utf8'); - console.log(`Wrote normalized schema for ${file.schema} at ${file.filePath}`); - } - } - - return { outDir, files, dryRun: Boolean(options.dryRun) }; -} - -function buildPgDumpArguments(schemaFilters: string[], tableFilters: TableSpecifier[]): string[] { - // Allow callers to target specific schemas or tables when invoking pg_dump. - const args: string[] = []; - for (const schema of schemaFilters) { - args.push('--schema', schema); - } - for (const table of tableFilters) { - args.push('--table', table.original); - } - return args; -} - -function buildAllowedSchemas(schemaFilters: string[], tableFilters: TableSpecifier[]): Set { - // Combine the schemas referenced via filters so the normalizer can limit output. - const combined = new Set(schemaFilters); - for (const table of tableFilters) { - combined.add(table.schema); - } - return combined; -} - -function buildSchemaFile(statements: NormalizedStatement[]): string { - // Join the sorted statements with blank lines so the file stays readable. - const body = statements.map((statement) => statement.sql).join('\n\n'); - return `${body}\n`; -} - -function sanitizeSchemaFileName(schema: string): string { - const sanitized = schema.replace(/[^a-z0-9_-]/g, '_').replace(/^_+|_+$/g, ''); - // Fall back to a safe default when all characters were stripped away. - return sanitized || 'schema'; -} - -function normalizeSchemaName(value: string): string { - // Trim, unquote, and lowercase schema names to keep filter comparison predictable. - return value.trim().replace(/^"|"$/g, '').toLowerCase(); -} - -function parseTableSpecifier(value: string): TableSpecifier { - const trimmed = value.trim(); - const qualifiedPattern = /^\s*(?:"([^"]+)"|([^".\s]+))\.(?:"([^"]+)"|([^".\s]+))\s*$/; - const match = trimmed.match(qualifiedPattern); - // Resolve the schema portion, falling back to public when the specifier is unqualified. - const schema = match ? match[1] ?? match[2] ?? 'public' : 'public'; - return { - schema: normalizeSchemaName(schema), - original: trimmed - }; -} diff --git a/packages/ztd-cli/src/commands/query.ts b/packages/ztd-cli/src/commands/query.ts deleted file mode 100644 index edc70a61f..000000000 --- a/packages/ztd-cli/src/commands/query.ts +++ /dev/null @@ -1,1063 +0,0 @@ -import { Command, Option } from 'commander'; -import { createTwoFilesPatch } from 'diff'; -import { applyQueryOutputControls, formatQueryUsageReport } from '../query/format'; -import { applyQueryPatch } from '../query/patch'; -import { buildQueryLintReport, formatQueryLintReport, type QueryLintFormat, type QueryLintRule } from '../query/lint'; -import { - buildQueryPipelinePlan, - formatQueryPipelinePlan, - type QueryPipelinePlanFormat -} from '../query/planner'; -import { buildQueryUsageReport, writeQueryUsageOutput } from '../query/report'; -import { - buildObservedSqlMatchReport, - formatObservedSqlMatchReport -} from '@rawsql-ts/sql-grep-core'; -import { buildQuerySliceReport } from '../query/slice'; -import { - buildQueryStructureReport, - formatQueryStructureReport, - type QueryStructureFormat -} from '../query/structure'; -import { getAgentOutputFormat, isJsonOutput, parseJsonPayload, writeCommandEnvelope } from '../utils/agentCli'; -import { withSpanSync } from '../utils/telemetry'; -import { readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { - collectSupportedOptionalConditionBranches, - SelectQueryParser, - SSSQLFilterBuilder, - type SssqlBranchInfo, - type SssqlBranchKind, - type SssqlRemoveSpec, - type SssqlScaffoldSpec, - type SssqlScaffoldFilters, - SqlFormatter -} from 'rawsql-ts'; - -interface QueryUsesOptions { - format?: string; - scopeDir?: string; - sqlRoot?: string; - excludeGenerated?: boolean; - out?: string; - view?: string; - anySchema?: boolean; - anyTable?: boolean; - summaryOnly?: boolean; - limit?: string; - json?: string; - target?: string; -} - -interface QueryStructureOptions { - format?: string; - out?: string; -} - -interface QueryPlanOptions { - format?: string; - out?: string; - material?: unknown; - scalarFilterColumns?: unknown; - scalarFilterColumn?: unknown; - json?: string; -} -interface QuerySliceOptions { - cte?: string; - final?: boolean; - out?: string; - limit?: string; -} - -interface QueryPatchApplyOptions { - cte?: string; - from?: string; - out?: string; - preview?: boolean; -} - -interface QuerySssqlScaffoldOptions { - format?: string; - out?: string; - json?: string; - preview?: boolean; - filter?: unknown; - filters?: Record; - parameter?: string; - operator?: string; - kind?: string; - query?: string; - queryFile?: string; - anchorColumns?: unknown; - anchorColumn?: unknown; -} - -interface QuerySssqlRefreshOptions { - format?: string; - out?: string; - json?: string; - preview?: boolean; -} - -interface QuerySssqlListOptions { - format?: string; - out?: string; - json?: string; -} - -interface QuerySssqlRemoveOptions { - format?: string; - out?: string; - json?: string; - preview?: boolean; - all?: boolean; - parameter?: string; - kind?: string; - operator?: string; - target?: string; -} - -interface QueryMatchObservedOptions { - format?: string; - out?: string; - sql?: string; - sqlFile?: string; -} - -interface QueryLintOptions { - format?: string; - out?: string; - rules?: string; -} - -export const QUERY_USES_COMMAND_SPANS = { - resolveOptions: 'resolve-query-options', - renderOutput: 'render-query-usage-output', -} as const; - -const QUERY_SCOPE_DIR_HELP = 'Limit discovery to one boundary or QuerySpec subtree instead of scanning the whole project'; - -/** - * Register strict-first impact investigation commands on the CLI root. - */ -export function registerQueryCommands(program: Command): void { - const query = program.command('query').description('Impact investigation for project QuerySpec-backed SQL assets'); - query.addHelpText( - 'after', - ` -Examples: - $ ztd query uses table public.users - $ ztd query uses column public.users.email - $ ztd query uses column public.users.email --view detail - $ ztd query uses column users.email --any-schema --format json - $ ztd query uses column email --any-schema --any-table --format json - $ ztd query outline large_query.sql - $ ztd query graph large_query.sql --format dot - $ ztd query slice large_query.sql --cte purchase_summary - $ ztd query plan large_query.sql --material base_cte --scalar-filter-column sale_date --format json - $ ztd query patch apply large_query.sql --cte purchase_summary --from edited_slice.sql --preview - $ ztd query lint large_query.sql --format json - $ ztd query match-observed --sql-file observed.sql --format json - -Notes: - - Strict mode is the default. Relaxed modes are explicit opt-in only. - - Impact is the default view. Use --view detail for edit-ready locations/snippets. - - Impact representatives may omit select snippets; use --view detail for edit-ready SELECT occurrences. - - Project-wide discovery is the default. query uses scans QuerySpec entries discovered under the current project root. - - Use --scope-dir only to narrow the active scan to one boundary or sub-tree. - - Use --sql-root only when specs intentionally point into a shared SQL root instead of staying feature-local. - - Use --exclude-generated to skip QuerySpec files under generated directories when those files are review-only noise. - - Static column analysis is inherently uncertain and labels ambiguity via confidence/notes. - - exprHints: best-effort only. Absence of exprHints does not imply the feature is not present. - - statement_fingerprint is stable across formatting/comment changes under the current normalization contract. -` - ); - - // Keep outline/graph/slice/plan aligned with the existing query surface from main. - // Issue #518 intentionally limits telemetry instrumentation in this file to query uses - // so conflict resolution does not narrow the established query command surface. - const uses = query.command('uses').description('Find where catalog SQL uses a table or column target'); - - uses - .command('table [target]') - .description('Find statements that use a table target') - .option('--format ', 'Output format (text|json)', 'text') - .option('--view ', 'Investigation view (impact|detail)', 'impact') - .addOption(new Option('--scope-dir ', QUERY_SCOPE_DIR_HELP)) - .option('--sql-root ', 'Optional fallback root for shared sqlFile layouts when specs are not feature-local') - .option('--exclude-generated', 'Exclude QuerySpec files under generated directories from scan targets') - .option('--out ', 'Write output to file') - .option('--summary-only', 'Emit summary counts without per-match details') - .option('--limit ', 'Limit returned matches and warnings in the output') - .option('--json ', 'Pass command options as a JSON object') - .option('--any-schema', 'Allow
lookup across schemas') - .option('--any-table', 'Unsupported for table usage') - .action((target: string | undefined, options: QueryUsesOptions) => { - runQueryUsesCommand('table', target, options); - }); - - uses - .command('column [target]') - .description('Find statements that use a column target') - .option('--format ', 'Output format (text|json)', 'text') - .option('--view ', 'Investigation view (impact|detail)', 'impact') - .addOption(new Option('--scope-dir ', QUERY_SCOPE_DIR_HELP)) - .option('--sql-root ', 'Optional fallback root for shared sqlFile layouts when specs are not feature-local') - .option('--exclude-generated', 'Exclude QuerySpec files under generated directories from scan targets') - .option('--out ', 'Write output to file') - .option('--summary-only', 'Emit summary counts without per-match details') - .option('--limit ', 'Limit returned matches and warnings in the output') - .option('--json ', 'Pass command options as a JSON object') - .option('--any-schema', 'Allow or lookup across schemas') - .option('--any-table', 'Allow lookup across tables (requires --any-schema)') - .addHelpText( - 'after', - ` -Notes: - - Impact is the default view. Use --view detail if you need edit-ready locations/snippets. - - Impact representatives may omit select snippets; use --view detail for edit-ready SELECT occurrences. - - Project-wide discovery is the default. Use --scope-dir only when you want to narrow the active scan. - - Feature-local spec-relative sqlFile values are the preferred contract. Use --sql-root only for shared-root fallback. - - exprHints: best-effort only. Absence of exprHints does not imply the feature is not present. -` - ) - .action((target: string | undefined, options: QueryUsesOptions) => { - runQueryUsesCommand('column', target, options); - }); - - query - .command('outline ') - .description('Summarize query structure, CTE dependencies, and base table usage') - .option('--format ', 'Output format (text|json)', 'text') - .option('--out ', 'Write output to file') - .action((sqlFile: string, options: QueryStructureOptions) => { - runQueryStructureCommand(sqlFile, options, false, 'ztd query outline'); - }); - - query - .command('graph ') - .description('Emit the query dependency graph in text, JSON, or DOT form') - .option('--format ', 'Output format (text|json|dot)', 'text') - .option('--out ', 'Write output to file') - .action((sqlFile: string, options: QueryStructureOptions) => { - runQueryStructureCommand(sqlFile, options, true, 'ztd query graph'); - }); - - query - .command('slice ') - .description('Generate a minimal executable SQL slice for a target CTE or the final query') - .option('--cte ', 'Slice a specific CTE into a standalone debug query') - .option('--final', 'Slice the final query while removing unused CTEs') - .option('--limit ', 'Add LIMIT to the emitted debug query when supported') - .option('--out ', 'Write output to file') - .action((sqlFile: string, options: QuerySliceOptions) => { - runQuerySliceCommand(sqlFile, options); - }); - - query - .command('plan ') - .description('Emit deterministic execution steps from CTE metadata') - .option('--format ', 'Output format (text|json)', 'text') - .option('--material ', 'Comma-separated CTE names to materialize') - .option('--scalar-filter-column ', 'Comma-separated column names to bind from WHERE scalar filters') - .option('--json ', 'Pass command options as a JSON object') - .option('--out ', 'Write output to file') - .action((sqlFile: string, options: QueryPlanOptions) => { - runQueryPlanCommand(sqlFile, options); - }); - - query - .command('lint ') - .description('Report structural maintainability and analysis-safety issues in a SQL query') - .option('--format ', 'Output format (text|json)', 'text') - .option('--rules ', 'Comma-separated lint rules to enable (for example: join-direction,leading-comma)') - .option('--out ', 'Write output to file') - .addHelpText( - 'after', - ` -Notes: - - If your installed CLI does not list --rules in this help output, upgrade to a newer published ztd-cli release before trying join-direction examples from Further Reading. - - Use --rules join-direction to enable the FK-aware JOIN direction readability check. - - Use --rules leading-comma to enforce leading commas in multiline SQL lists without rewriting comments. - - Suppress a specific query with "-- ztd-lint-disable join-direction" when the reverse path is intentional. - - Suppress leading-comma style checks with "-- ztd-lint-disable leading-comma" when a generated query needs a local exception. - - These rules are opt-in in v1. join-direction focuses on top-level inner joins with explicit FK evidence. -` - ) - .action((sqlFile: string, options: QueryLintOptions) => { - runQueryLintCommand(sqlFile, options); - }); - - query - .command('match-observed') - .description('Rank candidate SQL assets for an observed SELECT statement') - .option('--sql ', 'Observed SQL text to rank') - .option('--sql-file ', 'Read the observed SQL text from a file') - .option('--format ', 'Output format (text|json)', 'text') - .option('--out ', 'Write output to file') - .action((options: QueryMatchObservedOptions) => { - runQueryMatchObservedCommand(options); - }); - - const sssql = query.command('sssql').description('Generate and refresh SQL-first optional filter scaffolds'); - - sssql - .command('list ') - .description('List supported SSSQL optional branches discovered in the query') - .option('--format ', 'Output format (text|json)', 'text') - .option('--json ', 'Pass command options as a JSON object') - .option('--out ', 'Write output to file') - .action((sqlFile: string, options: QuerySssqlListOptions) => { - runQuerySssqlListCommand(sqlFile, options); - }); - - sssql - .command('scaffold ') - .description('Generate SSSQL optional filter scaffolds near the closest source query') - .option('--format ', 'Output format (text|json)', 'text') - .option('--json ', 'Pass command options as a JSON object') - .option('--filter ', 'Target column for scalar scaffold, or primary anchor column for EXISTS/NOT EXISTS') - .option('--parameter ', 'Explicit parameter name for structured SSSQL scaffold') - .option('--operator ', 'Scalar operator (=, <>, !=, <, <=, >, >=, like, ilike)') - .option('--kind ', 'Structured branch kind (scalar|exists|not-exists)') - .option('--query ', 'Subquery SQL for EXISTS/NOT EXISTS scaffold') - .option('--query-file ', 'Read subquery SQL for EXISTS/NOT EXISTS scaffold from a file') - .option('--anchor-column ', 'Comma-separated anchor columns used by $c0, $c1 placeholders') - .option('--preview', 'Emit a unified diff without writing files') - .option('--out ', 'Write output to file') - .action((sqlFile: string, options: QuerySssqlScaffoldOptions) => { - runQuerySssqlScaffoldCommand(sqlFile, options); - }); - - sssql - .command('refresh ') - .description('Refresh existing SSSQL optional filter scaffolds without changing predicate meaning') - .option('--format ', 'Output format (text|json)', 'text') - .option('--json ', 'Pass command options as a JSON object') - .option('--preview', 'Emit a unified diff without writing files') - .option('--out ', 'Write output to file') - .action((sqlFile: string, options: QuerySssqlRefreshOptions) => { - runQuerySssqlRefreshCommand(sqlFile, options); - }); - - sssql - .command('remove ') - .description('Remove one supported SSSQL optional filter branch safely') - .option('--format ', 'Output format (text|json)', 'text') - .option('--json ', 'Pass command options as a JSON object') - .option('--all', 'Remove all recognized SSSQL branches in the query') - .option('--parameter ', 'Parameter name that identifies the target branch') - .option('--kind ', 'Optional branch kind filter (scalar|exists|not-exists|expression)') - .option('--operator ', 'Optional scalar operator filter when removing scalar branches') - .option('--target ', 'Optional target column filter when removing scalar branches') - .option('--preview', 'Emit a unified diff without writing files') - .option('--out ', 'Write output to file') - .action((sqlFile: string, options: QuerySssqlRemoveOptions) => { - runQuerySssqlRemoveCommand(sqlFile, options); - }); - - query - .command('patch') - .description('Apply AI-edited SQL fragments back onto the original query safely') - .command('apply ') - .description('Replace one CTE in the original SQL with the matching definition from an edited SQL file') - .option('--cte ', 'Target CTE name to replace in the original query') - .option('--from ', 'Edited SQL file that contains the replacement CTE definition') - .option('--preview', 'Emit a unified diff without writing files') - .option('--out ', 'Write the patched SQL to a new file instead of overwriting the original') - .action((sqlFile: string, options: QueryPatchApplyOptions) => { - runQueryPatchApplyCommand(sqlFile, options); - }); -} - -function runQueryUsesCommand(kind: 'table' | 'column', target: string | undefined, options: QueryUsesOptions): void { - const resolved = withSpanSync(QUERY_USES_COMMAND_SPANS.resolveOptions, () => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - - if ('specsDir' in merged) { - throw new Error('Use scopeDir / --scope-dir instead of the removed specsDir / --specs-dir option.'); - } - - const format = normalizeFormat(normalizeStringOption(merged.format) ?? getAgentOutputFormat()); - const view = normalizeView(normalizeStringOption(merged.view) ?? 'impact'); - const resolvedTarget = normalizeStringOption(merged.target) ?? target; - if (!resolvedTarget) { - throw new Error('A target must be provided either as a positional argument or via --json {"target": "..."}'); - } - - return { - merged, - format, - view, - resolvedTarget, - }; - }, { - kind, - targetProvided: Boolean(target), - jsonPayload: Boolean(options.json), - }); - - const scopeDir = normalizeStringOption(resolved.merged.scopeDir); - - const report = buildQueryUsageReport({ - kind, - rawTarget: resolved.resolvedTarget, - rootDir: process.env.ZTD_PROJECT_ROOT, - specsDir: scopeDir, - sqlRoot: normalizeStringOption(resolved.merged.sqlRoot), - excludeGenerated: normalizeBooleanOption(resolved.merged.excludeGenerated), - view: resolved.view, - anySchema: normalizeBooleanOption(resolved.merged.anySchema), - anyTable: normalizeBooleanOption(resolved.merged.anyTable) - }); - - withSpanSync(QUERY_USES_COMMAND_SPANS.renderOutput, () => { - const limitedReport = applyQueryOutputControls(report, { - summaryOnly: normalizeBooleanOption(resolved.merged.summaryOnly), - limit: normalizeLimit(resolved.merged.limit) - }); - const contents = formatQueryUsageReport(limitedReport, resolved.format); - const outPath = normalizeStringOption(resolved.merged.out); - if (outPath) { - writeQueryUsageOutput(outPath, contents); - return; - } - console.log(contents.trimEnd()); - }, { - format: resolved.format, - writesFile: Boolean(resolved.merged.out), - }); -} - -function runQueryStructureCommand( - sqlFile: string, - options: QueryStructureOptions, - allowDot: boolean, - commandName: string -): void { - const format = normalizeStructureFormat(options.format ?? 'text', allowDot); - const report = buildQueryStructureReport(sqlFile, commandName); - const contents = formatQueryStructureReport(report, format); - if (options.out) { - writeQueryUsageOutput(options.out, contents); - return; - } - console.log(contents.trimEnd()); -} - -function runQuerySliceCommand(sqlFile: string, options: QuerySliceOptions): void { - const report = buildQuerySliceReport(sqlFile, { - cte: normalizeStringOption(options.cte), - final: normalizeBooleanOption(options.final), - limit: normalizeLimit(options.limit) - }); - if (options.out) { - writeQueryUsageOutput(options.out, report.sql); - return; - } - console.log(report.sql.trimEnd()); -} - -function runQueryPlanCommand(sqlFile: string, options: QueryPlanOptions): void { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const scalarFilterOption = merged.scalarFilterColumns ?? merged.scalarFilterColumn; - if (scalarFilterOption === undefined && (merged as Record).scalarMaterial !== undefined) { - throw new Error('Use scalarFilterColumns / --scalar-filter-column instead of scalarMaterial / --scalar-material.'); - } - - const format = normalizePlanFormat(normalizeStringOption(merged.format) ?? getAgentOutputFormat()); - const plan = buildQueryPipelinePlan(sqlFile, { - material: normalizeListOption(merged.material, '--material'), - scalarFilterColumns: normalizeListOption(scalarFilterOption, '--scalar-filter-column') - }); - const contents = formatQueryPipelinePlan(plan, format); - const outPath = normalizeStringOption(merged.out); - if (outPath) { - writeQueryUsageOutput(outPath, contents); - return; - } - console.log(contents.trimEnd()); -} - -function runQueryLintCommand(sqlFile: string, options: QueryLintOptions): void { - const format = normalizeLintFormat(normalizeStringOption(options.format) ?? getAgentOutputFormat()); - const report = buildQueryLintReport(sqlFile, { - projectRoot: process.env.ZTD_PROJECT_ROOT ?? process.cwd(), - rules: normalizeRuleList(options.rules) - }); - const contents = formatQueryLintReport(report, format); - if (options.out) { - writeQueryUsageOutput(options.out, contents); - return; - } - console.log(contents.trimEnd()); -} - -function runQueryMatchObservedCommand(options: QueryMatchObservedOptions): void { - const format = normalizeFormat(normalizeStringOption(options.format) ?? getAgentOutputFormat()); - const observedSql = resolveObservedSqlInput(options); - const report = buildObservedSqlMatchReport({ - observedSql, - rootDir: process.env.ZTD_PROJECT_ROOT ?? process.cwd() - }); - const contents = formatObservedSqlMatchReport(report, format); - - if (options.out) { - writeQueryUsageOutput(options.out, contents); - } else if (format === 'json') { - process.stdout.write(contents); - } else { - console.log(contents.trimEnd()); - } - - if (report.matches.length === 0) { - console.error('No candidate SELECT assets were found for the observed SQL.'); - process.exitCode = 1; - } -} - -function normalizeRuleList(value: unknown): QueryLintRule[] | undefined { - if (value === undefined || value === null || value === '') { - return undefined; - } - - const rawValues = Array.isArray(value) ? value : [value]; - const rules = new Set(); - for (const rawValue of rawValues) { - if (typeof rawValue !== 'string') { - throw new Error(`Expected lint rules to be a string or string[] but received ${typeof rawValue}.`); - } - - // Accept comma-separated CLI input and JSON arrays with one item per entry. - for (const item of rawValue.split(',')) { - const normalized = item.trim().toLowerCase(); - if (!normalized) { - continue; - } - if (!isSupportedQueryLintRule(normalized)) { - throw new Error(`Unsupported lint rule: ${item}. Supported rules: join-direction, leading-comma`); - } - rules.add(normalized); - } - } - - return rules.size > 0 ? [...rules] : undefined; -} - -function isSupportedQueryLintRule(value: string): value is QueryLintRule { - return value === 'join-direction' || value === 'leading-comma'; -} - -function runQueryPatchApplyCommand(sqlFile: string, options: QueryPatchApplyOptions): void { - const cte = normalizeRequiredStringOption(options.cte, '--cte'); - const from = normalizeRequiredStringOption(options.from, '--from'); - const report = applyQueryPatch(sqlFile, { - cte, - from, - out: normalizeStringOption(options.out), - preview: normalizeBooleanOption(options.preview) - }); - - if (isJsonOutput()) { - writeCommandEnvelope('query patch apply', { - file: report.file, - edited_file: report.edited_file, - target_cte: report.target_cte, - preview: report.preview, - changed: report.changed, - written: report.written, - output_file: report.output_file, - diff: report.diff, - updated_sql: report.updated_sql - }); - return; - } - - if (report.preview) { - process.stdout.write(report.diff); - if (!report.diff.endsWith('\n')) { - process.stdout.write('\n'); - } - return; - } - - process.stdout.write([ - `Patched CTE: ${report.target_cte}`, - `Edited SQL: ${report.edited_file}`, - `Output file: ${report.output_file}`, - `Changed: ${report.changed ? 'yes' : 'no'}` - ].join('\n')); - process.stdout.write('\n'); -} - -function runQuerySssqlListCommand(sqlFile: string, options: QuerySssqlListOptions): void { - const resolved = options.json - ? { ...options, ...parseJsonPayload>(options.json, '--json') } - : options; - const sql = readFileSync(sqlFile, 'utf8'); - const branches = new SSSQLFilterBuilder().list(sql); - const format = normalizeFormat(normalizeStringOption(resolved.format) ?? getAgentOutputFormat()); - const outputFile = normalizeStringOption(resolved.out); - const contents = format === 'json' - ? JSON.stringify({ - command: 'query sssql list', - ok: true, - data: { - file: sqlFile, - branch_count: branches.length, - branches: branches.map((branch, index) => serializeSssqlBranch(branch, index)) - } - }) - : formatSssqlBranchList(branches); - - if (outputFile) { - writeQueryUsageOutput(outputFile, contents); - if (format !== 'json') { - return; - } - } - - if (format === 'json') { - process.stdout.write(outputFile ? `${contents}\n` : contents); - return; - } - - process.stdout.write(contents.endsWith('\n') ? contents : `${contents}\n`); -} - -function runQuerySssqlScaffoldCommand(sqlFile: string, options: QuerySssqlScaffoldOptions): void { - const resolved = options.json - ? { ...options, ...parseJsonPayload>(options.json, '--json') } - : options; - const filters = normalizeSssqlFilters(resolved.filters ?? (isLegacyFilterObject(resolved.filter) ? resolved.filter : undefined)); - const spec = buildStructuredSssqlScaffoldSpec(resolved); - const report = applySssqlRewrite(sqlFile, { - commandName: 'query sssql scaffold', - out: normalizeStringOption(resolved.out), - preview: normalizeBooleanOption(resolved.preview), - transform: (sql) => { - const builder = new SSSQLFilterBuilder(); - if (spec) { - return builder.scaffoldBranch(sql, spec); - } - return builder.scaffold(sql, filters); - } - }); - - emitSssqlRewriteReport(report, normalizeFormat(normalizeStringOption(resolved.format) ?? getAgentOutputFormat())); -} - -function runQuerySssqlRefreshCommand(sqlFile: string, options: QuerySssqlRefreshOptions): void { - const resolved = options.json - ? { ...options, ...parseJsonPayload>(options.json, '--json') } - : options; - const report = applySssqlRewrite(sqlFile, { - commandName: 'query sssql refresh', - out: normalizeStringOption(resolved.out), - preview: normalizeBooleanOption(resolved.preview), - transform: (sql) => { - const parsed = SelectQueryParser.parse(sql); - const existingBranches = collectSupportedOptionalConditionBranches(parsed); - const filters = Object.fromEntries(existingBranches.map((branch) => [branch.parameterName, null])); - return new SSSQLFilterBuilder().refresh(parsed, filters); - } - }); - - emitSssqlRewriteReport(report, normalizeFormat(normalizeStringOption(resolved.format) ?? getAgentOutputFormat())); -} - -function runQuerySssqlRemoveCommand(sqlFile: string, options: QuerySssqlRemoveOptions): void { - const resolved = options.json - ? { ...options, ...parseJsonPayload>(options.json, '--json') } - : options; - const report = applySssqlRewrite(sqlFile, { - commandName: 'query sssql remove', - out: normalizeStringOption(resolved.out), - preview: normalizeBooleanOption(resolved.preview), - transform: (sql) => { - const builder = new SSSQLFilterBuilder(); - if (normalizeBooleanOption(resolved.all)) { - assertNoTargetedRemoveOptions(resolved); - return builder.removeAll(sql); - } - - const spec = normalizeSssqlRemoveSpec(resolved); - return builder.remove(sql, spec); - } - }); - - emitSssqlRewriteReport(report, normalizeFormat(normalizeStringOption(resolved.format) ?? getAgentOutputFormat())); -} - -function normalizeLimit(value: unknown): number | undefined { - if (value === undefined) { - return undefined; - } - if (typeof value !== 'string' && typeof value !== 'number') { - throw new Error(`Unsupported limit type: ${typeof value}. Use a positive integer.`); - } - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`Unsupported limit: ${value}. Use a positive integer.`); - } - return parsed; -} - -function normalizeStringOption(value: unknown): string | undefined { - if (value === undefined || value === null || value === '') { - return undefined; - } - if (typeof value !== 'string') { - throw new Error(`Expected a string option but received ${typeof value}.`); - } - return value; -} - -function normalizeRequiredStringOption(value: unknown, label: string): string { - const normalized = normalizeStringOption(value); - if (!normalized) { - throw new Error(`${label} is required.`); - } - return normalized; -} - -function normalizeBooleanOption(value: unknown): boolean { - if (value === undefined) { - return false; - } - if (typeof value !== 'boolean') { - throw new Error(`Expected a boolean option but received ${typeof value}.`); - } - return value; -} - -function normalizeListOption(value: unknown, label: string): string[] | undefined { - if (value === undefined || value === null || value === '') { - return undefined; - } - - const rawValues = Array.isArray(value) ? value : [value]; - const normalized: string[] = []; - - for (const rawValue of rawValues) { - if (typeof rawValue !== 'string') { - throw new Error(`Expected ${label} to be a string or string[] but received ${typeof rawValue}.`); - } - - // Accept comma-separated CLI input and JSON arrays with one item per entry. - for (const item of rawValue.split(',')) { - const trimmed = item.trim(); - if (trimmed.length > 0) { - normalized.push(trimmed); - } - } - } - - return normalized.length > 0 ? normalized : undefined; -} - -function normalizeSssqlFilters(value: unknown): SssqlScaffoldFilters { - if (value === undefined || value === null) { - return {}; - } - - if (typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`Expected SSSQL filters to be a JSON object but received ${typeof value}.`); - } - - const filters = value as Record; - const normalized: SssqlScaffoldFilters = {}; - - for (const [key, rawValue] of Object.entries(filters)) { - normalized[key] = rawValue as SssqlScaffoldFilters[string]; - } - - return normalized; -} - -function isLegacyFilterObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function buildStructuredSssqlScaffoldSpec(value: QuerySssqlScaffoldOptions): SssqlScaffoldSpec | undefined { - const explicitFilter = normalizeStringOption(value.filter); - const explicitKind = normalizeStringOption(value.kind)?.trim().toLowerCase(); - const explicitQuery = normalizeStringOption(value.query); - const explicitQueryFile = normalizeStringOption(value.queryFile); - const operator = normalizeStringOption(value.operator); - const parameter = normalizeStringOption(value.parameter); - const anchorColumns = normalizeListOption(value.anchorColumns ?? value.anchorColumn, '--anchor-column'); - - const hasStructuredInputs = Boolean(explicitFilter || explicitKind || explicitQuery || explicitQueryFile || operator || parameter || anchorColumns?.length); - if (!hasStructuredInputs) { - return undefined; - } - - const target = explicitFilter; - const query = resolveSssqlSubqueryInput(explicitQuery, explicitQueryFile); - - if (explicitKind === 'exists' || explicitKind === 'not-exists' || query) { - return { - kind: normalizeSssqlExistsKind(explicitKind), - parameterName: normalizeRequiredStringOption(parameter, '--parameter'), - query: normalizeRequiredStringOption(query, '--query or --query-file'), - anchorColumns: anchorColumns ?? [normalizeRequiredStringOption(target, '--filter')] - }; - } - - return { - target: normalizeRequiredStringOption(target, '--filter'), - parameterName: parameter, - operator: operator as SssqlScaffoldSpec extends infer _ ? string : never - } as SssqlScaffoldSpec; -} - -function normalizeSssqlExistsKind(value: string | undefined): 'exists' | 'not-exists' { - if (!value || value === 'exists') { - return 'exists'; - } - if (value === 'not-exists') { - return 'not-exists'; - } - throw new Error(`Unsupported SSSQL branch kind: ${value}. Use exists or not-exists.`); -} - -function normalizeSssqlRemoveSpec(value: QuerySssqlRemoveOptions): SssqlRemoveSpec { - const kindValue = normalizeStringOption(value.kind)?.trim().toLowerCase(); - return { - parameterName: normalizeRequiredStringOption(value.parameter, '--parameter'), - kind: kindValue ? normalizeSssqlBranchKind(kindValue) : undefined, - operator: normalizeStringOption(value.operator) as SssqlRemoveSpec['operator'], - target: normalizeStringOption(value.target) - }; -} - -function assertNoTargetedRemoveOptions(value: QuerySssqlRemoveOptions): void { - if ( - normalizeStringOption(value.parameter) || - normalizeStringOption(value.kind) || - normalizeStringOption(value.operator) || - normalizeStringOption(value.target) - ) { - throw new Error('Use --all by itself. Do not combine it with --parameter, --kind, --operator, or --target.'); - } -} - -function normalizeSssqlBranchKind(value: string): SssqlBranchKind { - if (value === 'scalar' || value === 'exists' || value === 'not-exists' || value === 'expression') { - return value; - } - throw new Error(`Unsupported SSSQL branch kind: ${value}.`); -} - -function resolveSssqlSubqueryInput(sqlText: string | undefined, sqlFile: string | undefined): string | undefined { - if (sqlText && sqlFile) { - throw new Error('Use either --query or --query-file, not both.'); - } - - if (sqlText) { - return sqlText; - } - - if (sqlFile) { - return readFileSync(sqlFile, 'utf8'); - } - - return undefined; -} - -function serializeSssqlBranch(branch: SssqlBranchInfo, index: number): Record { - return { - index: index + 1, - parameterName: branch.parameterName, - kind: branch.kind, - operator: branch.operator ?? null, - target: branch.target ?? null, - sql: branch.sql - }; -} - -function formatSssqlBranchList(branches: SssqlBranchInfo[]): string { - if (branches.length === 0) { - return 'No supported SSSQL branches found.\n'; - } - - const lines: string[] = []; - branches.forEach((branch, index) => { - lines.push(`${index + 1}. parameter: ${branch.parameterName}`); - lines.push(` kind: ${branch.kind}`); - if (branch.operator) { - lines.push(` operator: ${branch.operator}`); - } - if (branch.target) { - lines.push(` target: ${branch.target}`); - } - lines.push(` sql: ${branch.sql}`); - }); - - return `${lines.join('\n')}\n`; -} - -interface SssqlRewriteReport { - commandName: string; - file: string; - output_file: string | null; - preview: boolean; - changed: boolean; - written: boolean; - sql: string; - diff: string; -} - -function applySssqlRewrite( - sqlFile: string, - options: { - commandName: string; - out?: string; - preview?: boolean; - transform: (sql: string) => unknown; - } -): SssqlRewriteReport { - const absoluteInputPath = path.resolve(sqlFile); - const originalSql = readFileSync(absoluteInputPath, 'utf8'); - const transformed = options.transform(originalSql); - const formatted = new SqlFormatter().format(transformed as never).formattedSql; - const updatedSql = `${formatted}\n`; - assertNoCommentLoss(originalSql, updatedSql, options.commandName); - - SelectQueryParser.parse(updatedSql); - - const preview = Boolean(options.preview); - const outputFile = path.resolve(options.out ?? absoluteInputPath); - const changed = normalizeLineEndings(originalSql) !== normalizeLineEndings(updatedSql); - const diff = createTwoFilesPatch( - normalizePath(absoluteInputPath), - normalizePath(outputFile), - normalizeLineEndings(originalSql), - normalizeLineEndings(updatedSql), - '', - '', - { context: 3 } - ); - - if (!preview) { - writeFileSync(outputFile, updatedSql, 'utf8'); - } - - return { - commandName: options.commandName, - file: absoluteInputPath, - output_file: outputFile, - preview, - changed, - written: !preview, - sql: formatted, - diff - }; -} - -function emitSssqlRewriteReport(report: SssqlRewriteReport, format: 'text' | 'json'): void { - if (format === 'json') { - writeCommandEnvelope(report.commandName, { - file: report.file, - output_file: report.output_file, - preview: report.preview, - changed: report.changed, - written: report.written, - sql: report.sql, - diff: report.diff - }); - return; - } - - if (report.preview) { - process.stdout.write(report.diff.endsWith('\n') ? report.diff : `${report.diff}\n`); - return; - } - - return; -} - -function normalizeLineEndings(value: string): string { - return value.replace(/\r\n/g, '\n'); -} - -function normalizePath(value: string): string { - return value.split(path.sep).join('/'); -} - -function assertNoCommentLoss(before: string, after: string, commandName: string): void { - const beforeComments = extractSqlCommentFragments(before); - if (beforeComments.length === 0) { - return; - } - - const normalizedAfter = normalizeLineEndings(after); - const missing = beforeComments.filter(comment => !normalizedAfter.includes(comment)); - if (missing.length > 0) { - throw new Error(`${commandName} would drop SQL comments during rewrite. Remove or relocate the comments before applying this command.`); - } -} - -function extractSqlCommentFragments(sql: string): string[] { - const normalized = normalizeLineEndings(sql); - const matches = normalized.match(/--.*$/gm) ?? []; - const blockMatches = normalized.match(/\/\*[\s\S]*?\*\//g) ?? []; - return [...matches, ...blockMatches].map(comment => comment.trim()).filter(Boolean); -} - -function resolveObservedSqlInput(options: QueryMatchObservedOptions): string { - const sqlText = normalizeStringOption(options.sql); - const sqlFile = normalizeStringOption(options.sqlFile); - - if (sqlText && sqlFile) { - throw new Error('Use either --sql or --sql-file, not both.'); - } - - if (sqlText) { - return sqlText; - } - - if (sqlFile) { - return readFileSync(sqlFile, 'utf8'); - } - - throw new Error('Provide observed SQL with --sql or --sql-file.'); -} - -function normalizeFormat(format: string): 'text' | 'json' { - const normalized = format.trim().toLowerCase(); - if (normalized === 'text' || normalized === 'json') { - return normalized; - } - throw new Error(`Unsupported format: ${format}`); -} - -function normalizePlanFormat(format: string): QueryPipelinePlanFormat { - return normalizeFormat(format); -} - -function normalizeLintFormat(format: string): QueryLintFormat { - return normalizeFormat(format); -} - -function normalizeStructureFormat(format: string, allowDot: boolean): QueryStructureFormat { - const normalized = format.trim().toLowerCase(); - if (normalized === 'text' || normalized === 'json') { - return normalized; - } - if (allowDot && normalized === 'dot') { - return normalized; - } - throw new Error(`Unsupported format: ${format}`); -} - -function normalizeView(view: string): 'impact' | 'detail' { - const normalized = view.trim().toLowerCase(); - if (normalized === 'impact' || normalized === 'detail') { - return normalized; - } - throw new Error(`Unsupported view: ${view}`); -} - diff --git a/packages/ztd-cli/src/commands/rfba.ts b/packages/ztd-cli/src/commands/rfba.ts deleted file mode 100644 index 2ab58e6c9..000000000 --- a/packages/ztd-cli/src/commands/rfba.ts +++ /dev/null @@ -1,508 +0,0 @@ -import { existsSync, readdirSync, statSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { getAgentOutputFormat, parseJsonPayload, writeCommandEnvelope } from '../utils/agentCli'; -import { runRfbaReviewData } from './rfbaReviewData'; - -export type RfbaBoundaryKind = 'root-boundary' | 'feature-boundary' | 'child-boundary-container' | 'sub-boundary'; -export type RfbaInspectFormat = 'text' | 'json'; - -export interface RfbaBoundaryReviewQuestions { - responsibilityBoundary: string; - dependencyFlow: string; - publicSurface: string; - verification: string; -} - -export interface RfbaBoundaryAssets { - publicSurfaceFiles: string[]; - sqlAssets: string[]; - generatedArtifacts: string[]; - localVerificationFiles: string[]; -} - -export interface RfbaBoundaryReport extends RfbaBoundaryAssets { - id: string; - kind: RfbaBoundaryKind; - name: string; - path: string; - parentBoundaryId: string | null; - childBoundaryIds: string[]; - publicBoundary: boolean; - reviewQuestions: RfbaBoundaryReviewQuestions; - notes: string[]; - warnings: string[]; -} - -export interface RfbaInspectionReport { - schemaVersion: 1; - projectRoot: string; - expectedRootBoundaryPaths: string[]; - summary: { - rootBoundaries: number; - featureBoundaries: number; - childBoundaryContainers: number; - subBoundaries: number; - warnings: number; - }; - boundaries: RfbaBoundaryReport[]; -} - -interface RfbaInspectOptions { - format?: string; - root?: string; - json?: string; -} - -interface RfbaReviewDataOptions { - base?: string; - head?: string; - out?: string; - format?: string; - scope?: string; - includeRawDiff?: boolean; - root?: string; - json?: string; -} - -interface BoundaryDraft { - id: string; - kind: RfbaBoundaryKind; - name: string; - absolutePath: string; - relativePath: string; - parentBoundaryId: string | null; - childBoundaryIds: string[]; - publicBoundary: boolean; - reviewQuestions: RfbaBoundaryReviewQuestions; - notes: string[]; - warnings: string[]; - excludeChildPaths: string[]; -} - -const STARTER_ROOT_BOUNDARIES = [ - { name: 'features', relativePath: 'src/features' }, - { name: 'adapters', relativePath: 'src/adapters' }, - { name: 'libraries', relativePath: 'src/libraries' }, -] as const; - -const PUBLIC_SURFACE_FILENAMES = new Set(['boundary.ts', 'index.ts', 'index.js', 'README.md']); -const VERIFICATION_FILE_PATTERNS = [ - /\.test\.[cm]?[jt]sx?$/i, - /\.spec\.[cm]?[jt]sx?$/i, - /\.case\.[cm]?[jt]sx?$/i, - /TEST_PLAN\.md$/i, -]; - -export function registerRfbaCommand(program: Command): void { - const rfba = program.command('rfba').description('Review-first boundary inspection for RFBA projects'); - - rfba - .command('inspect') - .description('Inspect RFBA root, feature, and query sub-boundaries without writing files') - .option('--format ', 'Output format (text|json)') - .option('--root ', 'Project root to inspect', '.') - .option('--json ', 'Pass command options as a JSON object') - .action((options: RfbaInspectOptions) => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const root = normalizeStringOption(merged.root) ?? '.'; - const format = resolveRfbaInspectFormat(merged.format); - const report = inspectRfbaBoundaries(path.resolve(process.cwd(), root)); - - if (getAgentOutputFormat() === 'json' && !merged.format) { - writeCommandEnvelope('rfba inspect', report); - return; - } - - process.stdout.write(formatRfbaInspectionReport(report, format)); - }); - - rfba - .command('review-data') - .description('Generate deterministic RFBA review packet data from a Git diff') - .option('--base ', 'Compare base ref', 'origin/main') - .option('--head ', 'Compare head ref', 'HEAD') - .option('--out ', 'Write JSON output to file') - .option('--format ', 'Output format (json)', 'json') - .option('--scope ', 'Limit review data collection to a path') - .option('--include-raw-diff', 'Include raw file diff snippets', false) - .option('--root ', 'Project root to inspect and compare', '.') - .option('--json ', 'Pass command options as a JSON object') - .action((options: RfbaReviewDataOptions) => { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const root = normalizeStringOption(merged.root) ?? '.'; - const projectRoot = path.resolve(process.cwd(), root); - const report = runRfbaReviewData({ - base: normalizeStringOption(merged.base), - head: normalizeStringOption(merged.head), - out: normalizeStringOption(merged.out), - format: normalizeStringOption(merged.format), - scope: normalizeStringOption(merged.scope), - includeRawDiff: Boolean(merged.includeRawDiff), - projectRoot, - inspectReport: inspectRfbaBoundaries(projectRoot), - }); - - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - }); -} - -export function inspectRfbaBoundaries(projectRoot: string): RfbaInspectionReport { - const absoluteRoot = path.resolve(projectRoot); - const drafts: BoundaryDraft[] = []; - - for (const rootBoundary of STARTER_ROOT_BOUNDARIES) { - const absolutePath = path.join(absoluteRoot, ...rootBoundary.relativePath.split('/')); - if (!isDirectory(absolutePath)) { - continue; - } - - drafts.push(createBoundaryDraft({ - kind: 'root-boundary', - name: rootBoundary.name, - absolutePath, - relativePath: rootBoundary.relativePath, - parentBoundaryId: null, - publicBoundary: true, - reviewQuestions: rootBoundaryReviewQuestions(rootBoundary.relativePath), - notes: [`Concrete starter root boundary: ${rootBoundary.relativePath}.`], - })); - } - - const featuresRoot = path.join(absoluteRoot, 'src', 'features'); - if (isDirectory(featuresRoot)) { - const featureRootDraft = drafts.find((draft) => draft.relativePath === 'src/features'); - for (const featureName of listChildDirectoryNames(featuresRoot)) { - if (featureName.startsWith('_')) { - continue; - } - - const featurePath = path.join(featuresRoot, featureName); - const featureRelativePath = toPosixPath(path.relative(absoluteRoot, featurePath)); - const featureDraft = createBoundaryDraft({ - kind: 'feature-boundary', - name: featureName, - absolutePath: featurePath, - relativePath: featureRelativePath, - parentBoundaryId: featureRootDraft?.id ?? null, - publicBoundary: true, - reviewQuestions: featureBoundaryReviewQuestions(featureRelativePath), - notes: ['Feature-owned boundary for orchestration, SQL ownership, and feature-local verification.'], - }); - if (!existsSync(path.join(featurePath, 'boundary.ts'))) { - featureDraft.warnings.push('Feature boundary does not expose the starter public surface file boundary.ts.'); - } - drafts.push(featureDraft); - if (featureRootDraft) { - featureRootDraft.childBoundaryIds.push(featureDraft.id); - featureRootDraft.excludeChildPaths.push(featurePath); - } - - const queriesPath = path.join(featurePath, 'queries'); - if (!isDirectory(queriesPath)) { - continue; - } - - const queriesRelativePath = toPosixPath(path.relative(absoluteRoot, queriesPath)); - const queriesDraft = createBoundaryDraft({ - kind: 'child-boundary-container', - name: 'queries', - absolutePath: queriesPath, - relativePath: queriesRelativePath, - parentBoundaryId: featureDraft.id, - publicBoundary: false, - reviewQuestions: queriesContainerReviewQuestions(queriesRelativePath), - notes: ['Container for query sub-boundaries; not a public RFBA boundary by itself.'], - }); - const directFiles = listDirectFiles(queriesPath); - if (directFiles.length > 0) { - queriesDraft.warnings.push('queries/ contains direct files; query assets should usually live under queries// sub-boundaries.'); - } - drafts.push(queriesDraft); - featureDraft.childBoundaryIds.push(queriesDraft.id); - featureDraft.excludeChildPaths.push(queriesPath); - - for (const queryName of listChildDirectoryNames(queriesPath)) { - const queryPath = path.join(queriesPath, queryName); - const queryRelativePath = toPosixPath(path.relative(absoluteRoot, queryPath)); - const queryDraft = createBoundaryDraft({ - kind: 'sub-boundary', - name: queryName, - absolutePath: queryPath, - relativePath: queryRelativePath, - parentBoundaryId: queriesDraft.id, - publicBoundary: true, - reviewQuestions: querySubBoundaryReviewQuestions(queryRelativePath), - notes: ['Feature-local query sub-boundary for SQL, generated evidence, and query-local verification.'], - }); - if (!existsSync(path.join(queryPath, 'boundary.ts'))) { - queryDraft.warnings.push('Query sub-boundary does not expose boundary.ts.'); - } - if (collectOwnedFiles(queryPath, []).filter((file) => file.endsWith('.sql')).length === 0) { - queryDraft.warnings.push('Query sub-boundary does not contain a SQL asset.'); - } - drafts.push(queryDraft); - queriesDraft.childBoundaryIds.push(queryDraft.id); - queriesDraft.excludeChildPaths.push(queryPath); - } - } - } - - const boundaries = drafts - .sort((left, right) => left.relativePath.localeCompare(right.relativePath)) - .map((draft) => { - const assets = classifyBoundaryAssets(absoluteRoot, draft.absolutePath, draft.excludeChildPaths); - return { - id: draft.id, - kind: draft.kind, - name: draft.name, - path: draft.relativePath, - parentBoundaryId: draft.parentBoundaryId, - childBoundaryIds: draft.childBoundaryIds.sort(), - publicBoundary: draft.publicBoundary, - reviewQuestions: draft.reviewQuestions, - ...assets, - notes: draft.notes.sort(), - warnings: draft.warnings.sort(), - }; - }); - - const warningCount = boundaries.reduce((sum, boundary) => sum + boundary.warnings.length, 0); - - return { - schemaVersion: 1, - projectRoot: absoluteRoot, - expectedRootBoundaryPaths: STARTER_ROOT_BOUNDARIES.map((boundary) => boundary.relativePath), - summary: { - rootBoundaries: boundaries.filter((boundary) => boundary.kind === 'root-boundary').length, - featureBoundaries: boundaries.filter((boundary) => boundary.kind === 'feature-boundary').length, - childBoundaryContainers: boundaries.filter((boundary) => boundary.kind === 'child-boundary-container').length, - subBoundaries: boundaries.filter((boundary) => boundary.kind === 'sub-boundary').length, - warnings: warningCount, - }, - boundaries, - }; -} - -export function formatRfbaInspectionReport(report: RfbaInspectionReport, format: RfbaInspectFormat): string { - if (format === 'json') { - return `${JSON.stringify(report, null, 2)}\n`; - } - - const lines = [ - 'RFBA boundary inspection', - `Project root: ${report.projectRoot}`, - `Expected starter root boundaries: ${report.expectedRootBoundaryPaths.join(', ')}`, - `Root boundaries: ${report.summary.rootBoundaries}`, - `Feature boundaries: ${report.summary.featureBoundaries}`, - `Query containers: ${report.summary.childBoundaryContainers}`, - `Query sub-boundaries: ${report.summary.subBoundaries}`, - `Warnings: ${report.summary.warnings}`, - '', - 'Boundaries:', - ]; - - for (const boundary of report.boundaries) { - lines.push(`- ${boundary.path} [${boundary.kind}]${boundary.publicBoundary ? '' : ' (not public boundary)'}`); - lines.push(` responsibility: ${boundary.reviewQuestions.responsibilityBoundary}`); - lines.push(` dependency flow: ${boundary.reviewQuestions.dependencyFlow}`); - lines.push(` public surface: ${formatAssetList(boundary.publicSurfaceFiles)}`); - lines.push(` sql assets: ${formatAssetList(boundary.sqlAssets)}`); - lines.push(` generated artifacts: ${formatAssetList(boundary.generatedArtifacts)}`); - lines.push(` verification: ${formatAssetList(boundary.localVerificationFiles)}`); - for (const warning of boundary.warnings) { - lines.push(` warning: ${warning}`); - } - } - - return `${lines.join('\n')}\n`; -} - -function createBoundaryDraft(input: { - kind: RfbaBoundaryKind; - name: string; - absolutePath: string; - relativePath: string; - parentBoundaryId: string | null; - publicBoundary: boolean; - reviewQuestions: RfbaBoundaryReviewQuestions; - notes: string[]; -}): BoundaryDraft { - return { - id: `${input.kind}:${input.relativePath}`, - kind: input.kind, - name: input.name, - absolutePath: input.absolutePath, - relativePath: input.relativePath, - parentBoundaryId: input.parentBoundaryId, - childBoundaryIds: [], - publicBoundary: input.publicBoundary, - reviewQuestions: input.reviewQuestions, - notes: input.notes, - warnings: [], - excludeChildPaths: [], - }; -} - -function classifyBoundaryAssets(projectRoot: string, boundaryPath: string, excludeChildPaths: string[]): RfbaBoundaryAssets { - const files = collectOwnedFiles(boundaryPath, excludeChildPaths); - const publicSurfaceFiles: string[] = []; - const sqlAssets: string[] = []; - const generatedArtifacts: string[] = []; - const localVerificationFiles: string[] = []; - - for (const file of files) { - const relativePath = toPosixPath(path.relative(projectRoot, file)); - const basename = path.basename(file); - if (PUBLIC_SURFACE_FILENAMES.has(basename)) { - if (basename !== 'README.md' || path.dirname(file) === boundaryPath) { - publicSurfaceFiles.push(relativePath); - } - } - if (basename.toLowerCase().endsWith('.sql')) { - sqlAssets.push(relativePath); - } - if (isGeneratedArtifact(relativePath, basename)) { - generatedArtifacts.push(relativePath); - } - if (isVerificationFile(relativePath, basename)) { - localVerificationFiles.push(relativePath); - } - } - - return { - publicSurfaceFiles: sortUnique(publicSurfaceFiles), - sqlAssets: sortUnique(sqlAssets), - generatedArtifacts: sortUnique(generatedArtifacts), - localVerificationFiles: sortUnique(localVerificationFiles), - }; -} - -function collectOwnedFiles(root: string, excludedRoots: string[]): string[] { - if (!isDirectory(root)) { - return []; - } - - const normalizedExcludedRoots = excludedRoots.map((excludedRoot) => path.resolve(excludedRoot)); - const results: string[] = []; - const visit = (current: string) => { - const resolvedCurrent = path.resolve(current); - if (normalizedExcludedRoots.some((excludedRoot) => resolvedCurrent === excludedRoot || resolvedCurrent.startsWith(`${excludedRoot}${path.sep}`))) { - return; - } - - for (const entry of readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) { - const entryPath = path.join(current, entry.name); - if (entry.isDirectory()) { - visit(entryPath); - } else if (entry.isFile()) { - results.push(entryPath); - } - } - }; - - visit(root); - return results.sort((left, right) => left.localeCompare(right)); -} - -function listChildDirectoryNames(root: string): string[] { - return readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort((left, right) => left.localeCompare(right)); -} - -function listDirectFiles(root: string): string[] { - return readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isFile()) - .map((entry) => entry.name) - .sort((left, right) => left.localeCompare(right)); -} - -function isDirectory(value: string): boolean { - try { - return statSync(value).isDirectory(); - } catch { - return false; - } -} - -function isGeneratedArtifact(relativePath: string, basename: string): boolean { - return relativePath.split('/').includes('generated') || basename.includes('.generated.') || basename === 'analysis.json'; -} - -function isVerificationFile(relativePath: string, basename: string): boolean { - return relativePath.split('/').includes('tests') || VERIFICATION_FILE_PATTERNS.some((pattern) => pattern.test(basename)); -} - -function resolveRfbaInspectFormat(value: unknown): RfbaInspectFormat { - const explicit = normalizeStringOption(value); - if (!explicit) { - return getAgentOutputFormat(); - } - - const normalized = explicit.trim().toLowerCase(); - if (normalized === 'text' || normalized === 'json') { - return normalized; - } - - throw new Error(`Unsupported RFBA inspection format: ${explicit}`); -} - -function normalizeStringOption(value: unknown): string | undefined { - if (value === undefined || value === null || value === '') { - return undefined; - } - if (typeof value !== 'string') { - throw new Error(`Expected a string option but received ${typeof value}.`); - } - return value; -} - -function sortUnique(values: string[]): string[] { - return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right)); -} - -function toPosixPath(value: string): string { - return value.split(path.sep).join('/'); -} - -function formatAssetList(values: string[]): string { - return values.length === 0 ? '(none)' : values.join(', '); -} - -function rootBoundaryReviewQuestions(relativePath: string): RfbaBoundaryReviewQuestions { - return { - responsibilityBoundary: `${relativePath} is a starter root boundary for a top-level application responsibility area.`, - dependencyFlow: 'Root boundaries should depend inward on stable contracts and avoid depending on sibling implementation details.', - publicSurface: 'Review README.md, index files, and boundary files that define how this root area is entered.', - verification: 'Review tests and generated evidence inside this root area before changing its contracts.', - }; -} - -function featureBoundaryReviewQuestions(relativePath: string): RfbaBoundaryReviewQuestions { - return { - responsibilityBoundary: `${relativePath} owns one feature boundary and its feature-local orchestration.`, - dependencyFlow: 'Feature code may call its query sub-boundaries and stable shared helpers; external callers should enter through the feature public surface.', - publicSurface: 'boundary.ts and README.md are the likely reviewer entry points for the feature.', - verification: 'Feature-local tests under tests/ verify orchestration and boundary behavior.', - }; -} - -function queriesContainerReviewQuestions(relativePath: string): RfbaBoundaryReviewQuestions { - return { - responsibilityBoundary: `${relativePath} groups query sub-boundaries for one feature.`, - dependencyFlow: 'The container should not be treated as a callable public surface; dependencies flow into concrete queries// sub-boundaries.', - publicSurface: 'No public boundary is expected directly on queries/.', - verification: 'Verification belongs to each concrete query sub-boundary.', - }; -} - -function querySubBoundaryReviewQuestions(relativePath: string): RfbaBoundaryReviewQuestions { - return { - responsibilityBoundary: `${relativePath} owns one query-level SQL boundary.`, - dependencyFlow: 'The parent feature boundary calls into this query sub-boundary; query code should keep SQL, mapping, and generated evidence local.', - publicSurface: 'boundary.ts is the likely callable query surface; SQL files are review assets, not external entry points.', - verification: 'Query-local tests, cases, generated analysis, and TEST_PLAN.md belong with this sub-boundary.', - }; -} diff --git a/packages/ztd-cli/src/commands/rfbaReviewData.ts b/packages/ztd-cli/src/commands/rfbaReviewData.ts deleted file mode 100644 index 33f039dde..000000000 --- a/packages/ztd-cli/src/commands/rfbaReviewData.ts +++ /dev/null @@ -1,1525 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { SqlParser } from 'rawsql-ts'; -import { ensureDirectory } from '../utils/fs'; -import type { RfbaBoundaryKind, RfbaInspectionReport } from './rfba'; - -export type RfbaReviewDataFormat = 'json'; -export type GitChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied'; -export type RfbaChangedFileKind = - | 'ddl' - | 'feature-boundary' - | 'query-boundary' - | 'query-sql' - | 'query-case' - | 'generated-evidence' - | 'query-verification' - | 'feature-verification' - | 'adapter' - | 'library' - | 'test-support' - | 'tool-managed' - | 'unknown'; -export type RfbaReviewWeight = 'high' | 'medium' | 'low'; - -export interface GitNameStatusEntry { - status: GitChangeStatus; - path: string; - oldPath?: string; - score?: number; -} - -export interface RfbaChangedFile { - path: string; - oldPath?: string; - status: GitChangeStatus; - kind: RfbaChangedFileKind; - oldKind?: RfbaChangedFileKind; - boundary: string | null; - oldBoundary?: string | null; - parentFeatureBoundary: string | null; - oldParentFeatureBoundary?: string | null; - reviewWeight: RfbaReviewWeight; - oldReviewWeight?: RfbaReviewWeight; - rawDiff?: string; -} - -export interface RfbaReviewHint { - target: string; - priority: RfbaReviewWeight; - hint: string; -} - -export interface RfbaReviewWarning { - code: string; - message: string; - path?: string; - paths?: string[]; - boundary?: string; -} - -export interface RfbaChangedBoundary { - boundary: string; - kind: RfbaBoundaryKind | 'unknown'; - parentBoundary: string | null; - changedFiles: string[]; - reviewWeight: RfbaReviewWeight; -} - -export interface DdlColumnView { - name: string; - type: string | null; - notNull: boolean; - default: string | null; - primaryKey: boolean; - unique: boolean; -} - -export interface DdlTableView { - table: string; - columnsAfter: DdlColumnView[]; -} - -export interface DdlChangeDetail { - kind: - | 'add-table' - | 'drop-table' - | 'add-column' - | 'drop-column' - | 'modify-column-type' - | 'modify-column-nullability' - | 'modify-column-default' - | 'add-primary-key' - | 'drop-primary-key' - | 'add-unique' - | 'drop-unique' - | 'add-index' - | 'drop-index'; - column?: string; - index?: string; - before: unknown; - after: unknown; - explanationSql: string; - reviewHints: string[]; -} - -export interface DdlChangeItem { - path: string; - objectKind: 'table' | 'index'; - object: string; - explanationSqlPurpose: 'human-readable explanation only; not an auto-apply migration'; - changes: DdlChangeDetail[]; - tableViewAfter?: DdlTableView; -} - -export interface SqlChangeItem { - path: string; - boundary: string | null; - statementKindBefore: string | null; - statementKindAfter: string | null; - readTablesBefore: string[]; - readTablesAfter: string[]; - writeTablesBefore: string[]; - writeTablesAfter: string[]; - returningColumnsBefore: string[]; - returningColumnsAfter: string[]; - selectedColumnsBefore: string[]; - selectedColumnsAfter: string[]; - whereColumnsBefore: string[]; - whereColumnsAfter: string[]; - joinTablesBefore: string[]; - joinTablesAfter: string[]; - reviewHints: string[]; -} - -export interface BoundaryChangeItem { - path: string; - boundary: string | null; - kind: RfbaChangedFileKind; - exportedNamesBefore: string[]; - exportedNamesAfter: string[]; - zodSchemaNamesBefore: string[]; - zodSchemaNamesAfter: string[]; - addedExports: string[]; - removedExports: string[]; - reviewWeight: RfbaReviewWeight; - reviewHints: string[]; -} - -export interface VerificationSummaryItem { - boundary: string; - changedCases: string[]; - changedGeneratedEvidence: string[]; - changedEntrypoints: string[]; - changedFeatureTests: string[]; - changedTestSupport: string[]; - missingLikelyEvidence: string[]; -} - -export interface RfbaReviewDataReport { - schemaVersion: 1; - command: 'rfba review-data'; - base: string; - head: string; - summary: { - changedFiles: number; - changedBoundaries: number; - ddlChanges: number; - sqlChanges: number; - boundaryChanges: number; - adapterChanges: number; - verificationChanges: number; - generatedChanges: number; - warnings: number; - }; - changedFiles: RfbaChangedFile[]; - changedBoundaries: RfbaChangedBoundary[]; - ddlChanges: DdlChangeItem[]; - sqlChanges: SqlChangeItem[]; - boundaryChanges: BoundaryChangeItem[]; - adapterChanges: RfbaChangedFile[]; - verificationChanges: VerificationSummaryItem[]; - generatedChanges: RfbaChangedFile[]; - reviewHints: RfbaReviewHint[]; - warnings: RfbaReviewWarning[]; -} - -export interface RfbaReviewDataOptions { - base?: string; - head?: string; - out?: string; - format?: string; - scope?: string; - includeRawDiff?: boolean; - projectRoot?: string; - inspectReport: RfbaInspectionReport; -} - -interface GitClient { - nameStatus(base: string, head: string): string; - show(ref: string, filePath: string): string | null; - diff(base: string, head: string, filePath: string): string | null; -} - -interface DdlTableModel { - name: string; - columns: Map; - primaryKeyColumns: string[]; - uniqueColumns: string[][]; -} - -interface DdlIndexModel { - name: string; - table: string; - unique: boolean; - columns: string[]; -} - -interface SqlSummary { - statementKind: string | null; - readTables: string[]; - writeTables: string[]; - returningColumns: string[]; - selectedColumns: string[]; - whereColumns: string[]; - joinTables: string[]; - parseWarning: boolean; -} - -const EXPLANATION_SQL_PURPOSE = 'human-readable explanation only; not an auto-apply migration' as const; - -export function runRfbaReviewData(options: RfbaReviewDataOptions): RfbaReviewDataReport { - const projectRoot = path.resolve(options.projectRoot ?? process.cwd()); - const base = normalizeOption(options.base) ?? 'origin/main'; - const head = normalizeOption(options.head) ?? 'HEAD'; - const format = resolveRfbaReviewDataFormat(options.format); - const scope = normalizeScope(options.scope); - const git = createGitClient(projectRoot); - const report = buildRfbaReviewData({ - base, - head, - scope, - includeRawDiff: Boolean(options.includeRawDiff), - inspectReport: options.inspectReport, - git, - }); - - if (format !== 'json') { - throw new Error(`Unsupported RFBA review-data format: ${options.format}`); - } - - if (options.out) { - const outPath = path.resolve(projectRoot, options.out); - ensureDirectory(path.dirname(outPath)); - writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); - } - - return report; -} - -export function buildRfbaReviewData(params: { - base: string; - head: string; - scope?: string; - includeRawDiff: boolean; - inspectReport: RfbaInspectionReport; - git: GitClient; -}): RfbaReviewDataReport { - const entries = parseGitNameStatus(params.git.nameStatus(params.base, params.head)) - .filter((entry) => !params.scope || isInScope(entry.path, params.scope) || (entry.oldPath ? isInScope(entry.oldPath, params.scope) : false)); - const warnings: RfbaReviewWarning[] = []; - const changedFiles = entries.map((entry) => { - const file = classifyRfbaChangedFile(entry); - if (params.includeRawDiff) { - file.rawDiff = params.git.diff(params.base, params.head, file.path) ?? undefined; - } - return file; - }); - - const changedBoundaries = buildChangedBoundarySummary(changedFiles, params.inspectReport); - const ddlChanges = buildDdlChanges(changedFiles, params.base, params.head, params.git, warnings); - const sqlChanges = buildSqlChanges(changedFiles, params.base, params.head, params.git, warnings); - const boundaryChanges = buildBoundaryChanges(changedFiles, params.base, params.head, params.git); - const verificationChanges = buildVerificationSummary(changedFiles); - const generatedChanges = changedFiles.filter((file) => file.kind === 'generated-evidence'); - const adapterChanges = changedFiles.filter((file) => file.kind === 'adapter'); - const reviewHints = buildReviewHints(changedFiles, ddlChanges, sqlChanges, boundaryChanges); - - addVerificationWarnings(changedFiles, verificationChanges, warnings); - addGeneratedOnlyWarnings(changedFiles, warnings); - warnings.sort(compareWarnings); - - return { - schemaVersion: 1, - command: 'rfba review-data', - base: params.base, - head: params.head, - summary: { - changedFiles: changedFiles.length, - changedBoundaries: changedBoundaries.length, - ddlChanges: ddlChanges.reduce((sum, item) => sum + item.changes.length, 0), - sqlChanges: sqlChanges.length, - boundaryChanges: boundaryChanges.length, - adapterChanges: adapterChanges.length, - verificationChanges: verificationChanges.length, - generatedChanges: generatedChanges.length, - warnings: warnings.length, - }, - changedFiles: changedFiles.sort(compareByPath), - changedBoundaries, - ddlChanges, - sqlChanges, - boundaryChanges, - adapterChanges: adapterChanges.sort(compareByPath), - verificationChanges, - generatedChanges: generatedChanges.sort(compareByPath), - reviewHints: reviewHints.sort((left, right) => left.target.localeCompare(right.target) || left.hint.localeCompare(right.hint)), - warnings, - }; -} - -export function parseGitNameStatus(output: string): GitNameStatusEntry[] { - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - const parts = line.split('\t'); - const rawStatus = parts[0] ?? ''; - const statusCode = rawStatus.charAt(0); - const score = rawStatus.length > 1 ? Number(rawStatus.slice(1)) : undefined; - if (statusCode === 'R' || statusCode === 'C') { - if (parts.length < 3) { - throw new Error(`Invalid git name-status rename/copy entry: ${line}`); - } - return { - status: statusCode === 'R' ? 'renamed' : 'copied', - oldPath: normalizePath(parts[1]), - path: normalizePath(parts[2]), - score: Number.isFinite(score) ? score : undefined, - }; - } - const pathValue = parts[1] ?? parts[0]?.slice(1).trim(); - if (!pathValue) { - throw new Error(`Invalid git name-status entry: ${line}`); - } - switch (statusCode) { - case 'A': - return { status: 'added', path: normalizePath(pathValue) }; - case 'M': - return { status: 'modified', path: normalizePath(pathValue) }; - case 'D': - return { status: 'deleted', path: normalizePath(pathValue) }; - default: - return { status: 'modified', path: normalizePath(pathValue) }; - } - }); -} - -export function classifyRfbaChangedFile(entry: GitNameStatusEntry): RfbaChangedFile { - const pathValue = normalizePath(entry.path); - const kind = classifyKind(pathValue); - const oldPath = entry.oldPath ? normalizePath(entry.oldPath) : undefined; - const oldKind = oldPath ? classifyKind(oldPath) : undefined; - return { - path: pathValue, - oldPath, - status: entry.status, - kind, - oldKind, - boundary: deriveBoundary(pathValue, kind), - oldBoundary: oldPath && oldKind ? deriveBoundary(oldPath, oldKind) : undefined, - parentFeatureBoundary: deriveParentFeatureBoundary(pathValue), - oldParentFeatureBoundary: oldPath ? deriveParentFeatureBoundary(oldPath) : undefined, - reviewWeight: reviewWeightForKind(kind), - oldReviewWeight: oldKind ? reviewWeightForKind(oldKind) : undefined, - }; -} - -export function buildChangedBoundarySummary( - changedFiles: RfbaChangedFile[], - inspectReport: RfbaInspectionReport -): RfbaChangedBoundary[] { - const boundaryByPath = new Map(inspectReport.boundaries.map((boundary) => [boundary.path, boundary])); - const parentByPath = new Map(inspectReport.boundaries.map((boundary) => { - const parentPath = boundary.parentBoundaryId - ? inspectReport.boundaries.find((candidate) => candidate.id === boundary.parentBoundaryId)?.path ?? null - : null; - return [boundary.path, parentPath] as const; - })); - const grouped = new Map(); - - for (const file of changedFiles) { - for (const boundaryRef of changedBoundaryRefs(file)) { - const list = grouped.get(boundaryRef.boundary) ?? []; - list.push({ - path: boundaryRef.path, - parentFeatureBoundary: boundaryRef.parentFeatureBoundary, - reviewWeight: boundaryRef.reviewWeight, - }); - grouped.set(boundaryRef.boundary, list); - } - } - - return Array.from(grouped.entries()) - .map(([boundaryPath, refs]): RfbaChangedBoundary => { - const boundary = boundaryByPath.get(boundaryPath); - return { - boundary: boundaryPath, - kind: boundary?.kind ?? 'unknown', - parentBoundary: parentByPath.get(boundaryPath) ?? refs.find((ref) => ref.parentFeatureBoundary)?.parentFeatureBoundary ?? null, - changedFiles: sortUnique(refs.map((ref) => ref.path)), - reviewWeight: maxReviewWeight(refs.map((ref) => ref.reviewWeight)), - }; - }) - .sort((left, right) => left.boundary.localeCompare(right.boundary)); -} - -export function diffDdlTables(beforeSql: string | null, afterSql: string | null, filePath = 'db/ddl/schema.sql'): DdlChangeItem[] { - const before = parseDdlTables(beforeSql ?? ''); - const after = parseDdlTables(afterSql ?? ''); - const beforeIndexes = parseDdlIndexes(beforeSql ?? ''); - const afterIndexes = parseDdlIndexes(afterSql ?? ''); - const items: DdlChangeItem[] = []; - const tableNames = sortUnique([...before.keys(), ...after.keys()]); - - for (const tableName of tableNames) { - const beforeTable = before.get(tableName); - const afterTable = after.get(tableName); - const changes: DdlChangeDetail[] = []; - if (!beforeTable && afterTable) { - changes.push({ - kind: 'add-table', - before: null, - after: tableView(afterTable), - explanationSql: `CREATE TABLE ${tableName} (...);`, - reviewHints: ['Review the new table ownership, constraints, and migration rollout semantics.'], - }); - } else if (beforeTable && !afterTable) { - changes.push({ - kind: 'drop-table', - before: tableView(beforeTable), - after: null, - explanationSql: `DROP TABLE ${tableName};`, - reviewHints: ['Confirm whether dropping this table is intended and whether data migration is required.'], - }); - } else if (beforeTable && afterTable) { - changes.push(...diffColumns(tableName, beforeTable, afterTable)); - changes.push(...diffPrimaryKey(tableName, beforeTable, afterTable)); - changes.push(...diffUnique(tableName, beforeTable, afterTable)); - } - - if (changes.length > 0) { - items.push({ - path: filePath, - objectKind: 'table', - object: tableName, - explanationSqlPurpose: EXPLANATION_SQL_PURPOSE, - changes, - tableViewAfter: afterTable ? tableView(afterTable) : undefined, - }); - } - } - - items.push(...diffDdlIndexes(beforeIndexes, afterIndexes, filePath)); - - return items.sort((left, right) => left.object.localeCompare(right.object)); -} - -export function summarizeSqlChange(beforeSql: string | null, afterSql: string | null, filePath = 'query.sql', boundary: string | null = null): SqlChangeItem { - const before = summarizeSql(beforeSql ?? ''); - const after = summarizeSql(afterSql ?? ''); - const reviewHints: string[] = []; - if (before.statementKind !== after.statementKind) { - reviewHints.push('Confirm whether the query boundary contract changed with the SQL statement kind.'); - } - if (before.returningColumns.join('\0') !== after.returningColumns.join('\0')) { - reviewHints.push('Confirm whether the returned result shape change is reflected in the query boundary.'); - reviewHints.push('Confirm whether query-local cases assert the returned columns.'); - } - if (before.writeTables.join('\0') !== after.writeTables.join('\0')) { - reviewHints.push('Confirm write table changes against DDL ownership and transaction semantics.'); - } - - return { - path: filePath, - boundary, - statementKindBefore: before.statementKind, - statementKindAfter: after.statementKind, - readTablesBefore: before.readTables, - readTablesAfter: after.readTables, - writeTablesBefore: before.writeTables, - writeTablesAfter: after.writeTables, - returningColumnsBefore: before.returningColumns, - returningColumnsAfter: after.returningColumns, - selectedColumnsBefore: before.selectedColumns, - selectedColumnsAfter: after.selectedColumns, - whereColumnsBefore: before.whereColumns, - whereColumnsAfter: after.whereColumns, - joinTablesBefore: before.joinTables, - joinTablesAfter: after.joinTables, - reviewHints: sortUnique(reviewHints), - }; -} - -export function buildVerificationSummary(changedFiles: RfbaChangedFile[]): VerificationSummaryItem[] { - const grouped = new Map(); - const ensure = (boundary: string) => { - const existing = grouped.get(boundary); - if (existing) { - return existing; - } - const created: VerificationSummaryItem = { - boundary, - changedCases: [], - changedGeneratedEvidence: [], - changedEntrypoints: [], - changedFeatureTests: [], - changedTestSupport: [], - missingLikelyEvidence: [], - }; - grouped.set(boundary, created); - return created; - }; - - for (const file of changedFiles) { - for (const ref of changedFileKindRefs(file)) { - const boundary = ref.boundary ?? ref.parentFeatureBoundary ?? 'global'; - if (!isVerificationKind(ref.kind)) { - continue; - } - const item = ensure(boundary); - if (ref.kind === 'query-case') { - item.changedCases.push(ref.path); - } else if (ref.kind === 'generated-evidence') { - item.changedGeneratedEvidence.push(ref.path); - } else if (ref.kind === 'feature-verification') { - item.changedFeatureTests.push(ref.path); - } else if (ref.kind === 'test-support') { - item.changedTestSupport.push(ref.path); - } else { - item.changedEntrypoints.push(ref.path); - } - } - } - - const sqlBoundaries = sortUnique(changedFiles - .flatMap((file) => changedFileKindRefs(file)) - .filter((ref) => ref.kind === 'query-sql' && ref.boundary) - .map((ref) => ref.boundary as string)); - for (const boundary of sqlBoundaries) { - const item = ensure(boundary); - if (item.changedCases.length === 0 && item.changedGeneratedEvidence.length === 0) { - item.missingLikelyEvidence.push('SQL changed but no query-local cases or generated evidence changed.'); - } - } - - return Array.from(grouped.values()) - .map((item) => ({ - ...item, - changedCases: sortUnique(item.changedCases), - changedGeneratedEvidence: sortUnique(item.changedGeneratedEvidence), - changedEntrypoints: sortUnique(item.changedEntrypoints), - changedFeatureTests: sortUnique(item.changedFeatureTests), - changedTestSupport: sortUnique(item.changedTestSupport), - missingLikelyEvidence: sortUnique(item.missingLikelyEvidence), - })) - .sort((left, right) => left.boundary.localeCompare(right.boundary)); -} - -function createGitClient(projectRoot: string): GitClient { - return { - nameStatus(base, head) { - return runGit(projectRoot, ['diff', '--name-status', `${base}...${head}`]); - }, - show(ref, filePath) { - const result = runGitMaybe(projectRoot, ['show', `${ref}:${filePath}`]); - return result.status === 0 ? result.stdout : null; - }, - diff(base, head, filePath) { - const result = runGitMaybe(projectRoot, ['diff', `${base}...${head}`, '--', filePath]); - return result.status === 0 ? result.stdout : null; - }, - }; -} - -function runGit(cwd: string, args: string[]): string { - const result = runGitMaybe(cwd, args); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error(result.stderr.trim() || result.stdout.trim() || `git ${args.join(' ')} failed`); - } - return result.stdout; -} - -function runGitMaybe(cwd: string, args: string[]): { status: number | null; stdout: string; stderr: string; error?: Error } { - const result = spawnSync('git', args, { - cwd, - encoding: 'utf8', - env: createIsolatedGitEnv(), - }); - return { - status: result.status, - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - error: result.error, - }; -} - -function createIsolatedGitEnv(): NodeJS.ProcessEnv { - const env = { ...process.env }; - delete env.GIT_DIR; - delete env.GIT_WORK_TREE; - delete env.GIT_INDEX_FILE; - delete env.GIT_PREFIX; - return env; -} - -function buildDdlChanges( - changedFiles: RfbaChangedFile[], - base: string, - head: string, - git: GitClient, - warnings: RfbaReviewWarning[] -): DdlChangeItem[] { - const result: DdlChangeItem[] = []; - for (const file of changedFiles.filter((candidate) => candidate.kind === 'ddl')) { - const before = file.status === 'added' ? null : git.show(base, file.oldPath ?? file.path); - const after = file.status === 'deleted' ? null : git.show(head, file.path); - const changes = diffDdlTables(before, after, file.path); - if ((before || after) && changes.length === 0 && containsSupportedDdl(before ?? after ?? '')) { - warnings.push({ - code: 'ddl-analysis.partial', - path: file.path, - message: 'DDL analysis produced no supported structural changes. AI should inspect the DDL file directly.', - }); - } - if ((before || after) && !containsSupportedDdl(before ?? '') && !containsSupportedDdl(after ?? '')) { - warnings.push({ - code: 'ddl-analysis.partial', - path: file.path, - message: 'DDL analysis only supports CREATE TABLE and CREATE INDEX statements in the MVP. AI should inspect this DDL file directly.', - }); - } - result.push(...changes); - } - return result.sort((left, right) => left.path.localeCompare(right.path) || left.object.localeCompare(right.object)); -} - -function buildSqlChanges( - changedFiles: RfbaChangedFile[], - base: string, - head: string, - git: GitClient, - warnings: RfbaReviewWarning[] -): SqlChangeItem[] { - const result: SqlChangeItem[] = []; - for (const file of changedFiles.filter((candidate) => candidate.kind === 'query-sql')) { - const before = file.status === 'added' ? null : git.show(base, file.oldPath ?? file.path); - const after = file.status === 'deleted' ? null : git.show(head, file.path); - const beforeSummary = summarizeSql(before ?? ''); - const afterSummary = summarizeSql(after ?? ''); - if ((before && beforeSummary.parseWarning) || (after && afterSummary.parseWarning)) { - warnings.push({ - code: 'sql-analysis.partial', - path: file.path, - message: 'SQL analysis was partial. AI should inspect the SQL file directly.', - }); - } - result.push(summarizeSqlChange(before, after, file.path, file.boundary)); - } - return result.sort((left, right) => left.path.localeCompare(right.path)); -} - -function buildBoundaryChanges( - changedFiles: RfbaChangedFile[], - base: string, - head: string, - git: GitClient -): BoundaryChangeItem[] { - return changedFiles - .filter((file) => file.kind === 'feature-boundary' || file.kind === 'query-boundary') - .map((file) => { - const before = file.status === 'added' ? '' : git.show(base, file.oldPath ?? file.path) ?? ''; - const after = file.status === 'deleted' ? '' : git.show(head, file.path) ?? ''; - const beforeExports = extractExportedNames(before); - const afterExports = extractExportedNames(after); - return { - path: file.path, - boundary: file.boundary, - kind: file.kind, - exportedNamesBefore: beforeExports, - exportedNamesAfter: afterExports, - zodSchemaNamesBefore: extractZodSchemaNames(before), - zodSchemaNamesAfter: extractZodSchemaNames(after), - addedExports: afterExports.filter((name) => !beforeExports.includes(name)).sort(), - removedExports: beforeExports.filter((name) => !afterExports.includes(name)).sort(), - reviewWeight: file.reviewWeight, - reviewHints: [ - 'Inspect public request and response shape changes manually.', - 'Confirm orchestration still calls query boundaries through public surfaces.', - ], - }; - }) - .sort((left, right) => left.path.localeCompare(right.path)); -} - -function buildReviewHints( - changedFiles: RfbaChangedFile[], - ddlChanges: DdlChangeItem[], - sqlChanges: SqlChangeItem[], - boundaryChanges: BoundaryChangeItem[] -): RfbaReviewHint[] { - const hints: RfbaReviewHint[] = []; - for (const file of changedFiles) { - if (file.kind === 'ddl') { - hints.push({ - target: file.path, - priority: 'high', - hint: 'DDL changed. Human should review table, column, constraint, and migration risk semantics.', - }); - } else if (file.kind === 'query-sql') { - hints.push({ - target: file.path, - priority: 'high', - hint: 'SQL changed. Human should review query behavior, table usage, and returned shape.', - }); - } else if (file.kind === 'generated-evidence') { - hints.push({ - target: file.path, - priority: 'low', - hint: 'Generated evidence changed. Confirm the source SQL, DDL, or cases explain the generated update.', - }); - } - } - for (const item of ddlChanges) { - for (const change of item.changes) { - hints.push(...change.reviewHints.map((hint) => ({ target: item.object, priority: 'high' as const, hint }))); - } - } - for (const item of sqlChanges) { - hints.push(...item.reviewHints.map((hint) => ({ target: item.path, priority: 'high' as const, hint }))); - } - for (const item of boundaryChanges) { - hints.push(...item.reviewHints.map((hint) => ({ target: item.path, priority: item.reviewWeight, hint }))); - } - return uniqueHints(hints); -} - -function addVerificationWarnings( - changedFiles: RfbaChangedFile[], - verificationChanges: VerificationSummaryItem[], - warnings: RfbaReviewWarning[] -): void { - const changedDdl = changedFiles.some((file) => hasChangedFileKind(file, 'ddl')); - if (changedDdl && !changedFiles.some((file) => hasAnyChangedFileKind(file, ['query-case', 'generated-evidence', 'feature-verification', 'query-verification']))) { - warnings.push({ - code: 'verification.possibly-missing', - message: 'DDL changed but no obvious RFBA verification or generated evidence changed.', - }); - } - for (const item of verificationChanges) { - for (const missing of item.missingLikelyEvidence) { - warnings.push({ - code: 'verification.possibly-missing', - boundary: item.boundary, - message: missing, - }); - } - } -} - -function addGeneratedOnlyWarnings( - changedFiles: RfbaChangedFile[], - warnings: RfbaReviewWarning[] -): void { - const generatedPaths = sortUnique(changedFiles - .flatMap((file) => changedFileKindRefs(file)) - .filter((ref) => ref.kind === 'generated-evidence') - .map((ref) => ref.path)); - if (generatedPaths.length === 0) { - return; - } - const sourceKinds: RfbaChangedFileKind[] = ['ddl', 'query-sql', 'feature-boundary', 'query-boundary', 'query-case']; - const hasSourceChange = changedFiles.some((file) => hasAnyChangedFileKind(file, sourceKinds)); - if (!hasSourceChange) { - warnings.push({ - code: 'generated-without-source', - paths: generatedPaths, - message: 'Generated evidence changed without an obvious DDL, SQL, boundary, or case change.', - }); - } -} - -function changedBoundaryRefs(file: RfbaChangedFile): { - boundary: string; - path: string; - parentFeatureBoundary: string | null; - reviewWeight: RfbaReviewWeight; -}[] { - const refs: { - boundary: string; - path: string; - parentFeatureBoundary: string | null; - reviewWeight: RfbaReviewWeight; - }[] = []; - if (file.boundary) { - refs.push({ - boundary: file.boundary, - path: file.path, - parentFeatureBoundary: file.parentFeatureBoundary, - reviewWeight: file.reviewWeight, - }); - } - if (file.oldPath && file.oldBoundary) { - refs.push({ - boundary: file.oldBoundary, - path: file.oldPath, - parentFeatureBoundary: file.oldParentFeatureBoundary ?? null, - reviewWeight: file.oldReviewWeight ?? file.reviewWeight, - }); - } - return refs; -} - -function changedFileKindRefs(file: RfbaChangedFile): { - path: string; - kind: RfbaChangedFileKind; - boundary: string | null; - parentFeatureBoundary: string | null; -}[] { - const refs = [{ - path: file.path, - kind: file.kind, - boundary: file.boundary, - parentFeatureBoundary: file.parentFeatureBoundary, - }]; - if (file.oldPath && file.oldKind) { - refs.push({ - path: file.oldPath, - kind: file.oldKind, - boundary: file.oldBoundary ?? null, - parentFeatureBoundary: file.oldParentFeatureBoundary ?? null, - }); - } - return refs; -} - -function hasChangedFileKind(file: RfbaChangedFile, kind: RfbaChangedFileKind): boolean { - return file.kind === kind || file.oldKind === kind; -} - -function hasAnyChangedFileKind(file: RfbaChangedFile, kinds: RfbaChangedFileKind[]): boolean { - return kinds.some((kind) => hasChangedFileKind(file, kind)); -} - -function getFeaturePathInfo(parts: string[]): { startIndex: number; endIndex: number } | null { - const srcIndex = parts.indexOf('src'); - if (srcIndex < 0 || parts[srcIndex + 1] !== 'features') { - return null; - } - const startIndex = srcIndex + 2; - const stopIndex = findFirstSegmentIndex(parts, startIndex, ['queries', 'tests', 'generated-evidence', 'boundary.ts']); - const endIndex = (stopIndex < 0 ? parts.length : stopIndex) - 1; - if (endIndex < startIndex) { - return null; - } - const featureSegments = parts.slice(startIndex, endIndex + 1).filter((segment) => !segment.startsWith('_')); - if (featureSegments.length === 0) { - return null; - } - return { startIndex, endIndex }; -} - -function getQueryPathInfo(parts: string[], featureStartIndex: number): { queriesIndex: number; queryNameIndex: number } | null { - const queriesIndex = parts.indexOf('queries', featureStartIndex); - const queryNameIndex = queriesIndex + 1; - if (queriesIndex < 0 || !parts[queryNameIndex]) { - return null; - } - return { queriesIndex, queryNameIndex }; -} - -function buildFeatureBoundaryPath(parts: string[], featureInfo: { startIndex: number; endIndex: number }): string { - const featureSegments = parts - .slice(featureInfo.startIndex, featureInfo.endIndex + 1) - .filter((segment) => !segment.startsWith('_')); - return [...parts.slice(0, featureInfo.startIndex), ...featureSegments].join('/'); -} - -function findFirstSegmentIndex(parts: string[], startIndex: number, segments: string[]): number { - const indices = segments - .map((segment) => parts.indexOf(segment, startIndex)) - .filter((index) => index >= 0); - return indices.length > 0 ? Math.min(...indices) : -1; -} - -function classifyKind(filePath: string): RfbaChangedFileKind { - const parts = filePath.split('/'); - const featureInfo = getFeaturePathInfo(parts); - const queryInfo = featureInfo ? getQueryPathInfo(parts, featureInfo.startIndex) : null; - if (/^db\/ddl\/.+\.sql$/i.test(filePath)) { - return 'ddl'; - } - if (queryInfo) { - const afterQuery = parts.slice(queryInfo.queryNameIndex + 1); - const testsIndex = afterQuery.indexOf('tests'); - if (testsIndex >= 0) { - const testsKind = afterQuery[testsIndex + 1]; - if (testsKind === 'cases') { - return 'query-case'; - } - if (testsKind === 'generated') { - return 'generated-evidence'; - } - return 'query-verification'; - } - if (afterQuery.length === 1 && afterQuery[0] === 'boundary.ts') { - return 'query-boundary'; - } - if (/\.sql$/i.test(parts[parts.length - 1] ?? '')) { - return 'query-sql'; - } - } - if (featureInfo && parts[parts.length - 1] === 'boundary.ts') { - return 'feature-boundary'; - } - if (featureInfo && parts.indexOf('tests', featureInfo.startIndex) >= 0) { - return 'feature-verification'; - } - if (/^src\/adapters\//i.test(filePath)) { - return 'adapter'; - } - if (/^src\/libraries\//i.test(filePath)) { - return 'library'; - } - if (/^tests\/support\//i.test(filePath)) { - return 'test-support'; - } - if (/^\.ztd\//i.test(filePath)) { - return 'tool-managed'; - } - return 'unknown'; -} - -function deriveBoundary(filePath: string, kind: RfbaChangedFileKind): string | null { - const parts = filePath.split('/'); - const featureInfo = getFeaturePathInfo(parts); - const queryInfo = featureInfo ? getQueryPathInfo(parts, featureInfo.startIndex) : null; - if (kind === 'feature-boundary' || kind === 'feature-verification') { - return featureInfo ? buildFeatureBoundaryPath(parts, featureInfo) : null; - } - if (kind === 'query-boundary' || kind === 'query-sql' || kind === 'query-case' || kind === 'query-verification' || kind === 'generated-evidence') { - return queryInfo ? parts.slice(0, queryInfo.queryNameIndex + 1).join('/') : null; - } - if (kind === 'adapter') { - return parts.length >= 3 ? parts.slice(0, 3).join('/') : 'src/adapters'; - } - if (kind === 'library') { - return parts.length >= 3 ? parts.slice(0, 3).join('/') : 'src/libraries'; - } - return null; -} - -function deriveParentFeatureBoundary(filePath: string): string | null { - const parts = filePath.split('/'); - const featureInfo = getFeaturePathInfo(parts); - return featureInfo ? buildFeatureBoundaryPath(parts, featureInfo) : null; -} - -function reviewWeightForKind(kind: RfbaChangedFileKind): RfbaReviewWeight { - switch (kind) { - case 'ddl': - case 'query-sql': - case 'feature-boundary': - case 'query-boundary': - case 'adapter': - return 'high'; - case 'generated-evidence': - case 'tool-managed': - return 'low'; - default: - return 'medium'; - } -} - -function parseDdlTables(sql: string): Map { - const result = new Map(); - for (const match of sql.matchAll(/create\s+(?:unlogged\s+|temporary\s+|temp\s+)?table\s+(?:if\s+not\s+exists\s+)?([^\s(]+)\s*\(([\s\S]*?)\)\s*;/gi)) { - const tableName = normalizeIdentifier(match[1]); - const body = match[2] ?? ''; - const columns = new Map(); - const primaryKeyColumns: string[] = []; - const uniqueColumns: string[][] = []; - for (const part of splitTopLevelComma(body)) { - const trimmed = part.trim(); - if (!trimmed) { - continue; - } - const tablePrimary = trimmed.match(/^(?:constraint\s+\S+\s+)?primary\s+key\s*\(([^)]+)\)/i); - if (tablePrimary) { - primaryKeyColumns.push(...splitIdentifierList(tablePrimary[1])); - continue; - } - const tableUnique = trimmed.match(/^(?:constraint\s+\S+\s+)?unique\s*\(([^)]+)\)/i); - if (tableUnique) { - uniqueColumns.push(splitIdentifierList(tableUnique[1])); - continue; - } - if (/^(constraint|foreign|check|exclude)\b/i.test(trimmed)) { - continue; - } - const column = parseDdlColumn(trimmed); - if (column) { - columns.set(column.name, column); - if (column.primaryKey) { - primaryKeyColumns.push(column.name); - } - if (column.unique) { - uniqueColumns.push([column.name]); - } - } - } - for (const column of columns.values()) { - column.primaryKey = primaryKeyColumns.includes(column.name); - column.unique = uniqueColumns.some((group) => group.length === 1 && group[0] === column.name); - if (column.primaryKey) { - column.notNull = true; - } - } - result.set(tableName, { name: tableName, columns, primaryKeyColumns: sortUnique(primaryKeyColumns), uniqueColumns }); - } - return result; -} - -function parseDdlIndexes(sql: string): Map { - const result = new Map(); - for (const match of sql.matchAll(/create\s+(unique\s+)?index\s+(?:concurrently\s+)?(?:if\s+not\s+exists\s+)?([^\s]+)\s+on\s+([^\s(]+)\s*\(([^)]+)\)\s*;/gi)) { - const name = normalizeIdentifier(match[2]); - result.set(name, { - name, - table: normalizeTableRef(match[3]), - unique: Boolean(match[1]), - columns: splitIdentifierList(match[4]), - }); - } - return result; -} - -function parseDdlColumn(definition: string): DdlColumnView | null { - const match = definition.match(/^("[^"]+"|[a-zA-Z_][\w$]*)\s+(.+)$/); - if (!match) { - return null; - } - const name = normalizeIdentifier(match[1]); - const rest = match[2].trim(); - const keywordIndex = findFirstConstraintKeywordIndex(rest); - const type = (keywordIndex === -1 ? rest : rest.slice(0, keywordIndex)).trim() || null; - const constraintText = keywordIndex === -1 ? '' : rest.slice(keywordIndex); - return { - name, - type, - notNull: /\bnot\s+null\b/i.test(constraintText), - default: extractDefaultValue(constraintText), - primaryKey: /\bprimary\s+key\b/i.test(constraintText), - unique: /\bunique\b/i.test(constraintText), - }; -} - -function diffColumns(tableName: string, before: DdlTableModel, after: DdlTableModel): DdlChangeDetail[] { - const changes: DdlChangeDetail[] = []; - const columnNames = sortUnique([...before.columns.keys(), ...after.columns.keys()]); - for (const columnName of columnNames) { - const beforeColumn = before.columns.get(columnName); - const afterColumn = after.columns.get(columnName); - if (!beforeColumn && afterColumn) { - changes.push({ - kind: 'add-column', - column: columnName, - before: null, - after: afterColumn, - explanationSql: `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${afterColumn.type ?? ''}${afterColumn.notNull ? ' NOT NULL' : ''}${afterColumn.default ? ` DEFAULT ${afterColumn.default}` : ''};`, - reviewHints: [ - 'Confirm whether the default value is correct for business semantics.', - 'Confirm whether this column should have an enum or check constraint.', - ], - }); - } else if (beforeColumn && !afterColumn) { - changes.push({ - kind: 'drop-column', - column: columnName, - before: beforeColumn, - after: null, - explanationSql: `ALTER TABLE ${tableName} DROP COLUMN ${columnName};`, - reviewHints: ['Confirm whether dropping this column is intended and whether existing data must be migrated.'], - }); - } else if (beforeColumn && afterColumn) { - if ((beforeColumn.type ?? '').toLowerCase() !== (afterColumn.type ?? '').toLowerCase()) { - changes.push({ - kind: 'modify-column-type', - column: columnName, - before: beforeColumn.type, - after: afterColumn.type, - explanationSql: `ALTER TABLE ${tableName} ALTER COLUMN ${columnName} TYPE ${afterColumn.type ?? 'unknown'};`, - reviewHints: ['Confirm type conversion safety and whether a USING clause is required.'], - }); - } - if (beforeColumn.notNull !== afterColumn.notNull) { - changes.push({ - kind: 'modify-column-nullability', - column: columnName, - before: { notNull: beforeColumn.notNull }, - after: { notNull: afterColumn.notNull }, - explanationSql: `ALTER TABLE ${tableName} ALTER COLUMN ${columnName} ${afterColumn.notNull ? 'SET NOT NULL' : 'DROP NOT NULL'};`, - reviewHints: ['Confirm nullability changes against existing rows and request validation.'], - }); - } - if ((beforeColumn.default ?? '') !== (afterColumn.default ?? '')) { - changes.push({ - kind: 'modify-column-default', - column: columnName, - before: beforeColumn.default, - after: afterColumn.default, - explanationSql: afterColumn.default - ? `ALTER TABLE ${tableName} ALTER COLUMN ${columnName} SET DEFAULT ${afterColumn.default};` - : `ALTER TABLE ${tableName} ALTER COLUMN ${columnName} DROP DEFAULT;`, - reviewHints: ['Confirm whether the default value is correct for business semantics.'], - }); - } - } - } - return changes; -} - -function diffPrimaryKey(tableName: string, before: DdlTableModel, after: DdlTableModel): DdlChangeDetail[] { - const beforeKey = before.primaryKeyColumns.join(','); - const afterKey = after.primaryKeyColumns.join(','); - if (beforeKey === afterKey) { - return []; - } - return [{ - kind: beforeKey ? 'drop-primary-key' : 'add-primary-key', - before: before.primaryKeyColumns, - after: after.primaryKeyColumns, - explanationSql: afterKey - ? `ALTER TABLE ${tableName} ADD PRIMARY KEY (${after.primaryKeyColumns.join(', ')});` - : `ALTER TABLE ${tableName} DROP CONSTRAINT ;`, - reviewHints: ['Confirm primary key changes against references and application identity semantics.'], - }]; -} - -function diffUnique(tableName: string, before: DdlTableModel, after: DdlTableModel): DdlChangeDetail[] { - const beforeKeys = before.uniqueColumns.map((columns) => columns.join(',')).sort(); - const afterKeys = after.uniqueColumns.map((columns) => columns.join(',')).sort(); - const changes: DdlChangeDetail[] = []; - for (const key of beforeKeys.filter((candidate) => !afterKeys.includes(candidate))) { - changes.push({ - kind: 'drop-unique', - before: key.split(','), - after: null, - explanationSql: `ALTER TABLE ${tableName} DROP CONSTRAINT ;`, - reviewHints: ['Confirm whether removing uniqueness is intended for business semantics.'], - }); - } - for (const key of afterKeys.filter((candidate) => !beforeKeys.includes(candidate))) { - changes.push({ - kind: 'add-unique', - before: null, - after: key.split(','), - explanationSql: `ALTER TABLE ${tableName} ADD UNIQUE (${key.split(',').join(', ')});`, - reviewHints: ['Confirm whether existing rows satisfy the new uniqueness rule.'], - }); - } - return changes; -} - -function diffDdlIndexes( - before: Map, - after: Map, - filePath: string -): DdlChangeItem[] { - const items: DdlChangeItem[] = []; - const indexNames = sortUnique([...before.keys(), ...after.keys()]); - for (const indexName of indexNames) { - const beforeIndex = before.get(indexName); - const afterIndex = after.get(indexName); - if (!beforeIndex && afterIndex) { - items.push({ - path: filePath, - objectKind: 'index', - object: indexName, - explanationSqlPurpose: EXPLANATION_SQL_PURPOSE, - changes: [{ - kind: 'add-index', - index: indexName, - before: null, - after: afterIndex, - explanationSql: `CREATE ${afterIndex.unique ? 'UNIQUE ' : ''}INDEX ${indexName} ON ${afterIndex.table} (${afterIndex.columns.join(', ')});`, - reviewHints: ['Confirm whether the new index supports the changed query path and whether build cost is acceptable.'], - }], - }); - } else if (beforeIndex && !afterIndex) { - items.push({ - path: filePath, - objectKind: 'index', - object: indexName, - explanationSqlPurpose: EXPLANATION_SQL_PURPOSE, - changes: [{ - kind: 'drop-index', - index: indexName, - before: beforeIndex, - after: null, - explanationSql: `DROP INDEX ${indexName};`, - reviewHints: ['Confirm whether removing this index is safe for existing read paths.'], - }], - }); - } - } - return items; -} - -function summarizeSql(sql: string): SqlSummary { - const stripped = stripSqlComments(sql).trim(); - if (!stripped) { - return emptySqlSummary(); - } - let parseWarning = false; - try { - SqlParser.parse(stripped); - } catch { - parseWarning = true; - } - const statementKind = stripped.match(/^(?:with\b[\s\S]+?\)\s*)?(select|insert|update|delete|merge)\b/i)?.[1]?.toLowerCase() ?? null; - return { - statementKind, - readTables: extractReadTables(stripped, statementKind), - writeTables: extractWriteTables(stripped, statementKind), - returningColumns: extractReturningColumns(stripped), - selectedColumns: extractSelectedColumns(stripped, statementKind), - whereColumns: extractWhereColumns(stripped), - joinTables: extractJoinTables(stripped), - parseWarning, - }; -} - -function emptySqlSummary(): SqlSummary { - return { - statementKind: null, - readTables: [], - writeTables: [], - returningColumns: [], - selectedColumns: [], - whereColumns: [], - joinTables: [], - parseWarning: false, - }; -} - -function extractReadTables(sql: string, statementKind: string | null): string[] { - const tables: string[] = []; - for (const match of sql.matchAll(/\bfrom\s+("[^"]+"|[a-zA-Z_][\w$]*(?:\s*\.\s*"?[\w$]+"?)?)/gi)) { - tables.push(normalizeTableRef(match[1])); - } - for (const match of sql.matchAll(/\bjoin\s+("[^"]+"|[a-zA-Z_][\w$]*(?:\s*\.\s*"?[\w$]+"?)?)/gi)) { - tables.push(normalizeTableRef(match[1])); - } - if (statementKind === 'delete') { - const writeTables = extractWriteTables(sql, statementKind); - return sortUnique(tables.filter((table) => !writeTables.includes(table))); - } - return sortUnique(tables); -} - -function extractWriteTables(sql: string, statementKind: string | null): string[] { - const patterns: RegExp[] = []; - if (statementKind === 'insert') { - patterns.push(/\binsert\s+into\s+("[^"]+"|[a-zA-Z_][\w$]*(?:\s*\.\s*"?[\w$]+"?)?)/i); - } else if (statementKind === 'update') { - patterns.push(/\bupdate\s+("[^"]+"|[a-zA-Z_][\w$]*(?:\s*\.\s*"?[\w$]+"?)?)/i); - } else if (statementKind === 'delete') { - patterns.push(/\bdelete\s+from\s+("[^"]+"|[a-zA-Z_][\w$]*(?:\s*\.\s*"?[\w$]+"?)?)/i); - } - return sortUnique(patterns.map((pattern) => sql.match(pattern)?.[1]).filter((value): value is string => Boolean(value)).map(normalizeTableRef)); -} - -function extractReturningColumns(sql: string): string[] { - const returning = sql.match(/\breturning\s+([\s\S]+?)(?:;|$)/i)?.[1]; - if (!returning) { - return []; - } - return splitTopLevelComma(returning).map(cleanSqlExpressionName).filter(Boolean).sort(); -} - -function extractSelectedColumns(sql: string, statementKind: string | null): string[] { - if (statementKind !== 'select') { - return []; - } - const selected = sql.match(/\bselect\s+([\s\S]+?)\s+\bfrom\b/i)?.[1]; - if (!selected) { - return []; - } - return splitTopLevelComma(selected).map(cleanSqlExpressionName).filter(Boolean).sort(); -} - -function extractWhereColumns(sql: string): string[] { - const where = sql.match(/\bwhere\s+([\s\S]+?)(?:\bgroup\s+by\b|\border\s+by\b|\blimit\b|\breturning\b|;|$)/i)?.[1]; - if (!where) { - return []; - } - return sortUnique(Array.from(where.matchAll(/(?|!=|<|>|<=|>=|\bis\b|\bin\b|\blike\b)/gi)) - .map((match) => normalizeIdentifier(match[1])) - .filter((name) => !SQL_KEYWORDS.has(name.toLowerCase()))); -} - -function extractJoinTables(sql: string): string[] { - return sortUnique(Array.from(sql.matchAll(/\bjoin\s+("[^"]+"|[a-zA-Z_][\w$]*(?:\s*\.\s*"?[\w$]+"?)?)/gi)) - .map((match) => normalizeTableRef(match[1]))); -} - -function extractExportedNames(source: string): string[] { - const names = new Set(); - for (const match of source.matchAll(/\bexport\s+(?:async\s+)?(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g)) { - names.add(match[1]); - } - for (const match of source.matchAll(/\bexport\s*\{([^}]+)\}/g)) { - for (const part of match[1].split(',')) { - const name = part.trim().split(/\s+as\s+/i)[1] ?? part.trim().split(/\s+as\s+/i)[0]; - if (/^[A-Za-z_$][\w$]*$/.test(name)) { - names.add(name); - } - } - } - return Array.from(names).sort(); -} - -function extractZodSchemaNames(source: string): string[] { - const names = new Set(); - for (const match of source.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*(?:Schema|Input|Output|Request|Response))\s*=\s*z\./g)) { - names.add(match[1]); - } - return Array.from(names).sort(); -} - -function splitTopLevelComma(value: string): string[] { - const parts: string[] = []; - let current = ''; - let depth = 0; - let quote: string | null = null; - for (let index = 0; index < value.length; index += 1) { - const char = value[index]; - const previous = value[index - 1]; - if (quote) { - current += char; - if (char === quote && previous !== '\\') { - quote = null; - } - continue; - } - if (char === '\'' || char === '"') { - quote = char; - current += char; - continue; - } - if (char === '(') { - depth += 1; - } else if (char === ')' && depth > 0) { - depth -= 1; - } - if (char === ',' && depth === 0) { - parts.push(current); - current = ''; - } else { - current += char; - } - } - if (current.trim()) { - parts.push(current); - } - return parts; -} - -function splitIdentifierList(value: string): string[] { - return splitTopLevelComma(value).map((part) => normalizeIdentifier(part.trim())).filter(Boolean).sort(); -} - -function findFirstConstraintKeywordIndex(value: string): number { - const match = value.search(/\s(?:constraint|not\s+null|null|default|primary\s+key|unique|references|check|generated)\b/i); - return match === -1 ? -1 : match + 1; -} - -function extractDefaultValue(value: string): string | null { - const match = value.match(/\bdefault\s+(.+?)(?:\s+(?:constraint|not\s+null|null|primary\s+key|unique|references|check|generated)\b|$)/i); - return match?.[1]?.trim() ?? null; -} - -function tableView(table: DdlTableModel): DdlTableView { - return { - table: table.name, - columnsAfter: Array.from(table.columns.values()).sort((left, right) => left.name.localeCompare(right.name)), - }; -} - -function containsCreateTable(sql: string): boolean { - return /\bcreate\s+(?:unlogged\s+|temporary\s+|temp\s+)?table\b/i.test(sql); -} - -function containsSupportedDdl(sql: string): boolean { - return containsCreateTable(sql) || /\bcreate\s+(?:unique\s+)?index\b/i.test(sql); -} - -function cleanSqlExpressionName(value: string): string { - const trimmed = value.trim(); - const alias = trimmed.match(/\bas\s+("[^"]+"|[a-zA-Z_][\w$]*)$/i)?.[1] - ?? trimmed.match(/\s+("[^"]+"|[a-zA-Z_][\w$]*)$/)?.[1]; - if (alias && !/[()*/+-]/.test(alias)) { - return normalizeIdentifier(alias); - } - const column = trimmed.match(/(?:^|\.)("[^"]+"|[a-zA-Z_][\w$]*)$/)?.[1]; - return column ? normalizeIdentifier(column) : trimmed.replace(/\s+/g, ' '); -} - -function normalizeTableRef(value: string): string { - return value.split('.').map((part) => normalizeIdentifier(part.trim())).join('.'); -} - -function normalizeIdentifier(value: string): string { - const trimmed = value.trim(); - if (trimmed.startsWith('"') && trimmed.endsWith('"')) { - return trimmed.slice(1, -1).replace(/""/g, '"'); - } - return trimmed.replace(/"/g, ''); -} - -function stripSqlComments(value: string): string { - return value.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); -} - -function normalizePath(value: string): string { - return value.replace(/\\/g, '/').replace(/^\.\//, ''); -} - -function normalizeScope(value: string | undefined): string | undefined { - const normalized = normalizeOption(value); - return normalized ? normalizePath(normalized).replace(/\/$/, '') : undefined; -} - -function normalizeOption(value: string | undefined): string | undefined { - if (value === undefined || value === '') { - return undefined; - } - return value; -} - -function resolveRfbaReviewDataFormat(value: string | undefined): RfbaReviewDataFormat { - const normalized = (value ?? 'json').trim().toLowerCase(); - if (normalized === 'json') { - return 'json'; - } - throw new Error(`Unsupported RFBA review-data format: ${value}`); -} - -function isInScope(filePath: string, scope: string): boolean { - return filePath === scope || filePath.startsWith(`${scope}/`); -} - -function isVerificationKind(kind: RfbaChangedFileKind): boolean { - return kind === 'query-case' - || kind === 'generated-evidence' - || kind === 'query-verification' - || kind === 'feature-verification' - || kind === 'test-support'; -} - -function maxReviewWeight(values: RfbaReviewWeight[]): RfbaReviewWeight { - if (values.includes('high')) { - return 'high'; - } - if (values.includes('medium')) { - return 'medium'; - } - return 'low'; -} - -function sortUnique(values: string[]): string[] { - return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right)); -} - -function compareByPath(left: { path: string }, right: { path: string }): number { - return left.path.localeCompare(right.path); -} - -function compareWarnings(left: RfbaReviewWarning, right: RfbaReviewWarning): number { - return left.code.localeCompare(right.code) - || (left.path ?? left.boundary ?? left.paths?.join(',') ?? '').localeCompare(right.path ?? right.boundary ?? right.paths?.join(',') ?? '') - || left.message.localeCompare(right.message); -} - -function uniqueHints(hints: RfbaReviewHint[]): RfbaReviewHint[] { - const seen = new Set(); - const result: RfbaReviewHint[] = []; - for (const hint of hints) { - const key = `${hint.target}\0${hint.priority}\0${hint.hint}`; - if (!seen.has(key)) { - seen.add(key); - result.push(hint); - } - } - return result; -} - -const SQL_KEYWORDS = new Set([ - 'and', - 'or', - 'not', - 'null', - 'true', - 'false', - 'is', - 'in', - 'like', - 'between', - 'exists', -]); diff --git a/packages/ztd-cli/src/commands/testEvidence.ts b/packages/ztd-cli/src/commands/testEvidence.ts deleted file mode 100644 index 1292c7f93..000000000 --- a/packages/ztd-cli/src/commands/testEvidence.ts +++ /dev/null @@ -1,1767 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { Command } from 'commander'; -import { - buildSpecificationModel, - buildDiffJson, - stableStringify as coreStableStringify, - type DiffCase as CorePrDiffCase, - type DiffCatalog as CorePrDiffCatalog, - type DiffJson as CoreTestSpecificationPrDiff, - type PreviewJson as TestEvidencePreviewJson -} from '@rawsql-ts/test-evidence-core'; -import { - renderDiffMarkdown, - renderSpecificationMarkdown, - renderTestDocumentationMarkdown, - type DefinitionLinkOptions, - type RemovedDetailLevel -} from '@rawsql-ts/test-evidence-renderer-md'; -import { - discoverProjectSqlCatalogSpecFiles, - isPlainObject, - loadSqlCatalogSpecsFromFile, - walkSqlCatalogSpecFiles, - type LoadedSqlCatalogSpec -} from '../utils/sqlCatalogDiscovery'; -import { parseJsonPayload } from '../utils/agentCli'; - -/** - * Supported evidence generation modes for `ztd evidence`. - */ -export type TestEvidenceMode = 'specification'; - -/** - * Output formats accepted by `ztd evidence`. - */ -export type TestEvidenceFormat = 'json' | 'markdown' | 'both'; -type TestEvidenceErrorCode = 'NO_SPECS_FOUND'; - -/** - * Evidence row summarizing one SQL catalog spec file. - */ -export interface SqlCatalogSpecEvidence { - kind: 'sql-catalog'; - id: string; - specFile: string; - sqlFile: string | null; - sqlFileResolved: boolean; - paramsShape: 'named' | 'positional' | 'unknown'; - hasOutputMapping: boolean; -} - -/** - * Flattened executable test-case evidence row. - */ -export interface TestCaseEvidence { - kind: 'test-case'; - id: string; - catalogId: string; - caseId: string; - filePath: string; - title: string; - description?: string; -} - -/** - * Top-level deterministic evidence document for specification mode. - */ -export interface TestSpecificationEvidence { - schemaVersion: 1; - mode: TestEvidenceMode; - summary: { - sqlCatalogCount: number; - sqlCaseCatalogCount: number; - testCaseCount: number; - specFilesScanned: number; - testFilesScanned: number; - }; - sqlCatalogs: SqlCatalogSpecEvidence[]; - sqlCaseCatalogs: SqlCaseCatalogEvidence[]; - testCaseCatalogs: TestCaseCatalogEvidence[]; - testCases: TestCaseEvidence[]; - display?: { - summaryOnly: boolean; - limit?: number; - truncated: boolean; - }; -} - -/** - * Normalized case payload used for deterministic PR diff calculation. - */ -export type PrDiffCase = CorePrDiffCase; - -/** - * Normalized catalog payload used for deterministic PR diff calculation. - */ -export type PrDiffCatalog = CorePrDiffCatalog; - -/** - * Deterministic diff document for PR-focused test evidence output. - */ -export type TestSpecificationPrDiff = CoreTestSpecificationPrDiff; - -/** - * Deterministic evidence representation of an executable function test catalog. - */ -export interface TestCaseCatalogEvidence { - id: string; - title: string; - description?: string; - definitionPath?: string; - refs?: Array<{ - label: string; - url: string; - }>; - cases: Array<{ - id: string; - title: string; - description?: string; - input: unknown; - expected: 'success' | 'throws' | 'errorResult'; - output?: unknown; - error?: { - name: string; - message: string; - match: 'equals' | 'contains'; - }; - tags?: string[]; - focus?: string; - refs?: Array<{ - label: string; - url: string; - }>; - }>; -} - -/** - * Deterministic evidence representation of an executable SQL case catalog. - */ -export interface SqlCaseCatalogEvidence { - id: string; - title: string; - description?: string; - definitionPath?: string; - params: { shape: 'named'; example: Record }; - output: { mapping: { columnMap: Record } }; - sql: string; - fixtures: Array<{ - tableName: string; - schema?: { columns: Record }; - rowsCount: number; - }>; - cases: Array<{ - id: string; - title: string; - params: Record; - expected: unknown[]; - }>; -} - -interface TestEvidenceCommandOptions { - mode: string; - format: string; - outDir: string; - scopeDir?: string; - specsDir?: string; - testsDir?: string; - specModule?: string; - json?: string; - summaryOnly?: boolean; - limit?: string; -} - -interface TestEvidencePrCommandOptions { - base?: string; - head?: string; - baseMode?: string; - allowEmptyBase?: boolean; - removedDetail?: string; - outDir?: string; - scopeDir?: string; - specsDir?: string; - testsDir?: string; - specModule?: string; - json?: string; - summaryOnly?: boolean; - limit?: string; -} - -interface TestDocCommandOptions { - out?: string; - scopeDir?: string; - specsDir?: string; - testsDir?: string; - specModule?: string; - json?: string; -} - -interface TestCaseCatalogDocumentLike { - catalogs?: unknown; -} - -interface TestCaseCatalogLike { - id?: unknown; - title?: unknown; - cases?: unknown; -} - -interface TestCaseLike { - id?: unknown; - title?: unknown; - description?: unknown; -} - -interface EvidenceModuleLike { - testCaseCatalogs?: unknown; - sqlCatalogCases?: unknown; -} - -/** - * Runtime/configuration error for test evidence command (maps to exit code 2). - */ -export class TestEvidenceRuntimeError extends Error { - readonly exitCode = 2; - readonly code?: TestEvidenceErrorCode; - - constructor(message: string, options?: { code?: TestEvidenceErrorCode }) { - super(message); - this.name = 'TestEvidenceRuntimeError'; - this.code = options?.code; - } -} - -/** - * Resolve command exit code for test evidence generation. - * @param args.result Completed generation result when execution succeeded. - * @param args.error Error thrown while generating evidence. - * @returns 0 when generation succeeded, 2 for runtime/configuration errors, 1 for other failures. - */ -export function resolveTestEvidenceExitCode(args: { - result?: TestSpecificationEvidence; - error?: unknown; -}): 0 | 1 | 2 { - if (args.error) { - return args.error instanceof TestEvidenceRuntimeError ? 2 : 1; - } - if (!args.result) { - return 2; - } - return 0; -} - -/** - * Register `ztd evidence` command on the CLI root program. - */ -export function registerTestEvidenceCommand(program: Command): void { - const evidenceCommand = program - .command('evidence') - .alias('test-evidence') - .description('Generate deterministic test specification evidence artifacts') - .option('--mode ', 'Evidence mode (specification)', 'specification') - .option('--format ', 'Output format (json|markdown|both)', 'both') - .option('--out-dir ', 'Output directory', '.ztd/test-evidence') - .option('--scope-dir ', 'Limit QuerySpec discovery to one feature, boundary, or subtree') - .option('--specs-dir ', 'Legacy override for a fixed SQL catalog specs directory') - .option('--tests-dir ', 'Override tests directory (default: tests)') - .option('--spec-module ', 'Explicit evidence module path (default: tests/specs/index)') - .option('--json ', 'Pass evidence options as a JSON object') - .option('--summary-only', 'Write only summary counts without catalog or case detail payloads') - .option('--limit ', 'Limit returned catalogs and cases in generated artifacts') - .action((options: TestEvidenceCommandOptions) => { - try { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const mode = normalizeMode(String(merged.mode)); - const format = normalizeFormat(String(merged.format)); - const report = applyEvidenceOutputControls( - runTestEvidenceSpecification({ - mode, - rootDir: process.env.ZTD_PROJECT_ROOT, - scopeDir: merged.scopeDir as string | undefined, - specsDir: merged.specsDir as string | undefined, - testsDir: merged.testsDir as string | undefined, - specModule: merged.specModule as string | undefined - }), - { - summaryOnly: Boolean(merged.summaryOnly), - limit: normalizeEvidenceLimit(merged.limit as string | undefined) - } - ); - const sourceRootDir = path.resolve(process.env.ZTD_PROJECT_ROOT ?? process.cwd()); - writeArtifacts({ - report, - format, - outDir: path.resolve(process.cwd(), String(merged.outDir)), - sourceRootDir - }); - process.exitCode = resolveTestEvidenceExitCode({ result: report }); - } catch (error) { - process.exitCode = resolveTestEvidenceExitCode({ error }); - console.error(error instanceof Error ? error.message : String(error)); - } - }); - evidenceCommand - .command('test-doc') - .description('Generate human-readable Markdown test documentation from ZTD test assets') - .option('--out ', 'Output markdown path', '.ztd/test-evidence/test-documentation.md') - .option('--scope-dir ', 'Limit QuerySpec discovery to one feature, boundary, or subtree') - .option('--specs-dir ', 'Legacy override for a fixed SQL catalog specs directory') - .option('--tests-dir ', 'Override tests directory (default: tests)') - .option('--spec-module ', 'Explicit evidence module path (default: tests/specs/index)') - .option('--json ', 'Pass test-doc options as a JSON object') - .action((options: TestDocCommandOptions) => { - try { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const report = runTestEvidenceSpecification({ - mode: 'specification', - rootDir: process.env.ZTD_PROJECT_ROOT, - scopeDir: merged.scopeDir as string | undefined, - specsDir: merged.specsDir as string | undefined, - testsDir: merged.testsDir as string | undefined, - specModule: merged.specModule as string | undefined - }); - const sourceRootDir = path.resolve(process.env.ZTD_PROJECT_ROOT ?? process.cwd()); - writeTestDocumentationArtifact({ - report, - outPath: path.resolve(process.cwd(), String(merged.out ?? '.ztd/test-evidence/test-documentation.md')), - sourceRootDir - }); - process.exitCode = resolveTestEvidenceExitCode({ result: report }); - } catch (error) { - process.exitCode = resolveTestEvidenceExitCode({ error }); - console.error(error instanceof Error ? error.message : String(error)); - } - }); - - evidenceCommand - .command('pr') - .description('Generate PR diff evidence from base/head specification JSON projections') - .option('--base ', 'Base git ref', 'main') - .option('--head ', 'Head git ref', 'HEAD') - .option('--base-mode ', 'Base resolution mode (merge-base|ref)', 'merge-base') - .option('--allow-empty-base', 'Allow empty base evidence when head has catalogs') - .option('--removed-detail ', 'Removed case detail level (none|input|full)', 'input') - .option('--out-dir ', 'Output directory', 'artifacts/test-evidence') - .option('--scope-dir ', 'Limit QuerySpec discovery to one feature, boundary, or subtree') - .option('--specs-dir ', 'Legacy override for a fixed SQL catalog specs directory') - .option('--tests-dir ', 'Override tests directory (default: tests)') - .option('--spec-module ', 'Explicit evidence module path (default: tests/specs/index)') - .option('--json ', 'Pass PR evidence options as a JSON object') - .option('--summary-only', 'Write PR evidence with summary-only base/head projections') - .option('--limit ', 'Limit returned catalogs and cases in base/head projections') - .action((options: TestEvidencePrCommandOptions) => { - try { - const merged = options.json ? { ...options, ...parseJsonPayload>(options.json, '--json') } : options; - const output = runTestEvidencePr({ - baseRef: String(merged.base ?? 'main'), - headRef: String(merged.head ?? 'HEAD'), - baseMode: normalizeBaseMode(String(merged.baseMode ?? 'merge-base')), - allowEmptyBase: Boolean(merged.allowEmptyBase), - removedDetail: normalizeRemovedDetail(String(merged.removedDetail ?? 'input')), - outDir: String(merged.outDir ?? 'artifacts/test-evidence'), - rootDir: process.env.ZTD_PROJECT_ROOT, - scopeDir: merged.scopeDir as string | undefined, - specsDir: merged.specsDir as string | undefined, - testsDir: merged.testsDir as string | undefined, - specModule: merged.specModule as string | undefined, - summaryOnly: Boolean(merged.summaryOnly), - limit: normalizeEvidenceLimit(merged.limit as string | undefined) - }); - process.exitCode = resolveTestEvidenceExitCode({ result: output.headReport }); - } catch (error) { - process.exitCode = resolveTestEvidenceExitCode({ error }); - console.error(error instanceof Error ? error.message : String(error)); - } - }); -} - -function normalizeMode(mode: string): TestEvidenceMode { - const normalized = mode.trim().toLowerCase(); - if (normalized !== 'specification') { - throw new TestEvidenceRuntimeError(`Unsupported mode: ${mode}`); - } - return 'specification'; -} - -function normalizeFormat(format: string): TestEvidenceFormat { - const normalized = format.trim().toLowerCase(); - if (normalized === 'json' || normalized === 'markdown' || normalized === 'both') { - return normalized; - } - throw new TestEvidenceRuntimeError(`Unsupported format: ${format}`); -} - -function normalizeBaseMode(mode: string): 'merge-base' | 'ref' { - const normalized = mode.trim().toLowerCase(); - if (normalized === 'merge-base' || normalized === 'ref') { - return normalized; - } - throw new TestEvidenceRuntimeError(`Unsupported base-mode: ${mode}`); -} - -function normalizeRemovedDetail(level: string): RemovedDetailLevel { - const normalized = level.trim().toLowerCase(); - if (normalized === 'none' || normalized === 'input' || normalized === 'full') { - return normalized; - } - throw new TestEvidenceRuntimeError(`Unsupported removed-detail: ${level}`); -} - -/** - * Build deterministic specification evidence from SQL catalog specs and test-case catalog exports. - */ -export function runTestEvidenceSpecification(options: { - mode: TestEvidenceMode; - rootDir?: string; - scopeDir?: string; - specsDir?: string; - testsDir?: string; - specModule?: string; -}): TestSpecificationEvidence { - const root = path.resolve(options.rootDir ?? process.cwd()); - const testsDir = options.testsDir ? path.resolve(root, options.testsDir) : path.resolve(root, 'tests'); - - const sqlSpecFiles = resolveTestEvidenceSpecFiles(root, options); - const evidenceModule = loadEvidenceModule(root, testsDir, options.specModule); - const testCaseCatalogFiles = existsSync(testsDir) ? walkFiles(testsDir, isTestCaseCatalogFile) : []; - const legacyTestCases = evidenceModule ? [] : testCaseCatalogFiles.flatMap((filePath) => loadTestCaseCatalogEvidence(root, filePath)); - - const testCaseCatalogs = evidenceModule - ? readTestCaseCatalogsFromModule(evidenceModule) - .map((catalog) => ({ - ...catalog, - cases: [...catalog.cases].sort((a, b) => a.id.localeCompare(b.id)) - })) - .sort((a, b) => a.id.localeCompare(b.id)) - : []; - const testCasesFromModule = flattenTestCaseCatalogs(testCaseCatalogs); - const sqlCaseCatalogsFromModule = evidenceModule ? readSqlCaseCatalogsFromModule(evidenceModule) : []; - if (sqlSpecFiles.length === 0 && testCasesFromModule.length === 0 && legacyTestCases.length === 0 && sqlCaseCatalogsFromModule.length === 0) { - throw new TestEvidenceRuntimeError( - `No catalog specs or test-case evidence exports were found. Checked scope=${describeSpecDiscoveryScope(root, options)}, testsDir=${testsDir}`, - { code: 'NO_SPECS_FOUND' } - ); - } - - const sqlCatalogs = sqlSpecFiles - .flatMap((filePath) => - loadSqlCatalogSpecsFromFile(filePath, (message) => new TestEvidenceRuntimeError(message)) - ) - .map((loaded) => toSqlEvidence(root, loaded)) - .sort((a, b) => a.id.localeCompare(b.id) || a.specFile.localeCompare(b.specFile)); - - const testCases = [...testCasesFromModule, ...legacyTestCases] - .sort((a, b) => a.id.localeCompare(b.id) || a.filePath.localeCompare(b.filePath)); - const sqlCaseCatalogs = sqlCaseCatalogsFromModule - .sort((a, b) => a.id.localeCompare(b.id)); - - return { - schemaVersion: 1, - mode: options.mode, - summary: { - sqlCatalogCount: sqlCatalogs.length, - sqlCaseCatalogCount: sqlCaseCatalogs.length, - testCaseCount: testCases.length, - specFilesScanned: sqlSpecFiles.length, - testFilesScanned: evidenceModule ? 1 : testCaseCatalogFiles.length - }, - sqlCatalogs, - sqlCaseCatalogs, - testCaseCatalogs, - testCases - }; -} - -function resolveTestEvidenceSpecFiles( - root: string, - options: { scopeDir?: string; specsDir?: string } -): string[] { - if (options.scopeDir && options.specsDir) { - throw new TestEvidenceRuntimeError('Use either --scope-dir or --specs-dir, not both.'); - } - - if (options.specsDir) { - const specsDir = resolveDirectoryOption(root, options.specsDir, 'Spec directory'); - return walkSqlCatalogSpecFiles(specsDir); - } - - if (options.scopeDir) { - const scopeDir = resolveDirectoryOption(root, options.scopeDir, 'Scope directory'); - return discoverProjectSqlCatalogSpecFiles(scopeDir); - } - - return discoverProjectSqlCatalogSpecFiles(root); -} - -function resolveDirectoryOption(root: string, value: string, label: string): string { - const resolved = path.resolve(root, value); - const relative = path.relative(root, resolved); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new TestEvidenceRuntimeError(`${label} must be inside the project root: ${resolved}`); - } - if (!existsSync(resolved)) { - throw new TestEvidenceRuntimeError(`${label} not found: ${resolved}`); - } - if (!statSync(resolved).isDirectory()) { - throw new TestEvidenceRuntimeError(`${label} is not a directory: ${resolved}`); - } - return resolved; -} - -function describeSpecDiscoveryScope(root: string, options: { scopeDir?: string; specsDir?: string }): string { - if (options.scopeDir) { - return path.resolve(root, options.scopeDir); - } - if (options.specsDir) { - return path.resolve(root, options.specsDir); - } - return root; -} - -/** - * Render deterministic JSON or Markdown output text. - */ -export function formatTestEvidenceOutput( - report: TestSpecificationEvidence, - format: Exclude, - context?: { markdownPath?: string; sourceRootDir?: string } -): string { - if (format === 'json') { - return `${JSON.stringify(report, null, 2)}\n`; - } - - if (report.display?.summaryOnly) { - const lines = [ - '# Test Specification Summary', - '', - `- schemaVersion: ${report.schemaVersion}`, - `- mode: ${report.mode}`, - `- sqlCatalogCount: ${report.summary.sqlCatalogCount}`, - `- sqlCaseCatalogCount: ${report.summary.sqlCaseCatalogCount}`, - `- testCaseCount: ${report.summary.testCaseCount}`, - `- specFilesScanned: ${report.summary.specFilesScanned}`, - `- testFilesScanned: ${report.summary.testFilesScanned}`, - `- truncated: ${report.display.truncated}`, - report.display.limit !== undefined ? `- limit: ${report.display.limit}` : '' - ].filter(Boolean); - return `${lines.join('\n')}\n`; - } - - const model = buildSpecificationModel(report as TestEvidencePreviewJson); - return `${renderSpecificationMarkdown(model, { - definitionLinks: resolveDefinitionLinkOptions(context) - })}\n`; -} - -/** - * Stable stringify that sorts object keys recursively for deterministic fingerprinting. - */ -export function stableStringify(value: unknown): string { - return coreStableStringify(value); -} - -/** - * Build deterministic PR diff JSON from base/head specification reports. - */ -export function buildTestEvidencePrDiff(args: { - base: { ref: string; sha: string; report: TestSpecificationEvidence }; - head: { ref: string; sha: string; report: TestSpecificationEvidence }; - baseMode: 'merge-base' | 'ref'; -}): TestSpecificationPrDiff { - return buildDiffJson({ - base: { - ref: args.base.ref, - sha: args.base.sha, - previewJson: args.base.report as TestEvidencePreviewJson - }, - head: { - ref: args.head.ref, - sha: args.head.sha, - previewJson: args.head.report as TestEvidencePreviewJson - }, - baseMode: args.baseMode - }) as TestSpecificationPrDiff; -} - -/** - * Render PR-focused markdown from diff JSON. - */ -export function formatTestEvidencePrMarkdown( - diff: TestSpecificationPrDiff, - options?: { removedDetail?: RemovedDetailLevel; markdownPath?: string; sourceRootDir?: string } -): string { - return renderDiffMarkdown(diff, { - definitionLinks: resolveDefinitionLinkOptions({ - markdownPath: options?.markdownPath, - sourceRootDir: options?.sourceRootDir - }) - }); -} - -function resolveDefinitionLinkOptions(context?: { markdownPath?: string; sourceRootDir?: string }): DefinitionLinkOptions { - const serverUrl = process.env.GITHUB_SERVER_URL?.trim(); - const repository = process.env.GITHUB_REPOSITORY?.trim(); - const ref = process.env.GITHUB_SHA?.trim(); - if (serverUrl && repository && ref) { - return { - mode: 'github', - github: { - serverUrl, - repository, - ref - } - }; - } - if (context?.markdownPath && context?.sourceRootDir) { - return { - mode: 'path', - path: { - markdownDir: path.dirname(context.markdownPath), - sourceRootDir: context.sourceRootDir - } - }; - } - return { mode: 'path' }; -} - -/** - * Generate base/head reports, compute PR diff JSON, and write PR artifacts. - */ -export function runTestEvidencePr(options: { - baseRef: string; - headRef: string; - baseMode: 'merge-base' | 'ref'; - allowEmptyBase?: boolean; - removedDetail?: RemovedDetailLevel; - outDir: string; - rootDir?: string; - scopeDir?: string; - specsDir?: string; - testsDir?: string; - specModule?: string; - summaryOnly?: boolean; - limit?: number; -}): { - baseReport: TestSpecificationEvidence; - headReport: TestSpecificationEvidence; - diff: TestSpecificationPrDiff; -} { - const root = path.resolve(options.rootDir ?? process.cwd()); - const tempRoot = path.resolve(root, '.tmp', 'test-evidence-worktree'); - mkdirSync(tempRoot, { recursive: true }); - const createdWorktrees: string[] = []; - const resolvedHeadSha = resolveGitSha(root, options.headRef); - const resolvedBaseSha = - options.baseMode === 'merge-base' - ? resolveGitMergeBase(root, options.baseRef, options.headRef) - : resolveGitSha(root, options.baseRef); - const currentHeadSha = resolveGitSha(root, 'HEAD'); - const normalizedScopeDir = normalizeDiscoveryDirForWorktree(root, options.scopeDir, 'Scope directory'); - const normalizedSpecsDir = normalizeDiscoveryDirForWorktree(root, options.specsDir, 'Spec directory'); - - try { - const headMaterialized = materializeEvidenceForRef({ - repoRoot: root, - ref: options.headRef, - resolvedSha: resolvedHeadSha, - allowCurrentWorkspace: resolvedHeadSha === currentHeadSha, - tempRoot, - createdWorktrees, - specsDir: normalizedSpecsDir, - testsDir: options.testsDir, - specModule: options.specModule, - scopeDir: normalizedScopeDir, - summaryOnly: options.summaryOnly, - limit: options.limit - }); - const baseMaterialized = materializeEvidenceForRef({ - repoRoot: root, - ref: options.baseRef, - resolvedSha: resolvedBaseSha, - allowCurrentWorkspace: resolvedBaseSha === currentHeadSha, - tempRoot, - createdWorktrees, - specsDir: normalizedSpecsDir, - testsDir: options.testsDir, - specModule: options.specModule, - scopeDir: normalizedScopeDir, - summaryOnly: options.summaryOnly, - limit: options.limit - }); - console.log(`base preview: ${baseMaterialized.previewJsonPath}`); - console.log(`head preview: ${headMaterialized.previewJsonPath}`); - - const diff = buildTestEvidencePrDiff({ - base: { ref: options.baseRef, sha: baseMaterialized.sha, report: baseMaterialized.report }, - head: { ref: options.headRef, sha: headMaterialized.sha, report: headMaterialized.report }, - baseMode: options.baseMode - }); - if (!options.allowEmptyBase && diff.totals.base.catalogs === 0 && diff.totals.head.catalogs > 0) { - throw new TestEvidenceRuntimeError( - 'Base test evidence is empty.\nThis likely indicates preview generation failed at base ref.\nIf this is intentional, re-run with --allow-empty-base.' - ); - } - - const outDir = path.resolve(root, options.outDir); - mkdirSync(outDir, { recursive: true }); - const diffJsonPath = path.join(outDir, 'test-specification.pr.json'); - const diffMdPath = path.join(outDir, 'test-specification.pr.md'); - writeFileSync(diffJsonPath, `${JSON.stringify(diff, null, 2)}\n`, 'utf8'); - writeFileSync( - diffMdPath, - formatTestEvidencePrMarkdown(diff, { - removedDetail: options.removedDetail, - markdownPath: diffMdPath, - sourceRootDir: root - }), - 'utf8' - ); - console.log(`wrote: ${diffJsonPath}`); - console.log(`wrote: ${diffMdPath}`); - - return { - baseReport: baseMaterialized.report, - headReport: headMaterialized.report, - diff - }; - } finally { - cleanupWorktrees(root, createdWorktrees); - } -} - -function normalizeDiscoveryDirForWorktree(root: string, value: string | undefined, label: string): string | undefined { - if (!value) { - return undefined; - } - const resolved = resolveDirectoryOption(root, value, label); - return path.relative(root, resolved) || '.'; -} - -function writeArtifacts(args: { - report: TestSpecificationEvidence; - format: TestEvidenceFormat; - outDir: string; - sourceRootDir: string; -}): void { - mkdirSync(args.outDir, { recursive: true }); - const writtenFiles: string[] = []; - - if (args.format === 'json' || args.format === 'both') { - const jsonPath = path.join(args.outDir, 'test-specification.json'); - writeFileSync(jsonPath, formatTestEvidenceOutput(args.report, 'json'), 'utf8'); - writtenFiles.push(jsonPath); - } - - if (args.format === 'markdown' || args.format === 'both') { - const markdownPaths = args.report.display?.summaryOnly - ? writeSpecificationSummaryMarkdown(args.report, args.outDir) - : writeSpecificationMarkdownArtifacts(args.report, args.outDir, args.sourceRootDir); - writtenFiles.push(...markdownPaths); - } - - for (const filePath of writtenFiles.sort((a, b) => a.localeCompare(b))) { - console.log(`wrote: ${filePath}`); - } -} - -function writeSpecificationSummaryMarkdown(report: TestSpecificationEvidence, outDir: string): string[] { - const summaryPath = path.join(outDir, 'test-specification.summary.md'); - writeFileSync(summaryPath, formatTestEvidenceOutput(report, 'markdown'), 'utf8'); - return [summaryPath]; -} - -function writeSpecificationMarkdownArtifacts( - report: TestSpecificationEvidence, - outDir: string, - sourceRootDir: string - ): string[] { - const indexFileName = 'test-specification.index.md'; - const model = buildSpecificationModel(report as TestEvidencePreviewJson); - const catalogs = [...model.catalogs].sort((a, b) => a.catalogId.localeCompare(b.catalogId)); - const written: string[] = []; - const catalogRows: Array<{ - fileName: string; - catalogId: string; - title: string; - tests: number; - }> = []; - - for (const catalog of catalogs) { - const catalogSlug = toSpecificationSlug(catalog.catalogId); - const catalogFileName = `test-specification.catalog.${catalogSlug}.md`; - const catalogPath = path.join(outDir, catalogFileName); - const catalogDefinitionLinks = resolveDefinitionLinkOptions({ markdownPath: catalogPath, sourceRootDir }); - const catalogLines: string[] = []; - catalogLines.push(`# ${catalog.catalogId} Test Cases`); - catalogLines.push(''); - catalogLines.push(`- schemaVersion: ${model.schemaVersion}`); - catalogLines.push(`- index: [Unit Test Index](./${indexFileName})`); - catalogLines.push(`- title: ${catalog.title}`); - const definitionPath = - catalog.definition ?? - findCatalogDefinitionPath(report, catalog.catalogId); - catalogLines.push(`- definition: ${formatDefinitionLinkMarkdown(definitionPath, catalogDefinitionLinks)}`); - if (catalog.description) { - catalogLines.push(`- description: ${catalog.description}`); - } - if (Array.isArray(catalog.refs) && catalog.refs.length > 0) { - catalogLines.push('- refs:'); - for (const ref of catalog.refs) { - catalogLines.push(` - [${ref.label}](${ref.url})`); - } - } - catalogLines.push(`- tests: ${catalog.cases.length}`); - if (catalog.kind === 'sql') { - catalogLines.push(`- fixtures: ${(catalog.fixtures ?? []).join(', ') || '(none)'}`); - } - catalogLines.push(''); - - const sortedCases = [...catalog.cases].sort((a, b) => a.id.localeCompare(b.id)); - for (const testCase of sortedCases) { - catalogLines.push(`## ${testCase.id} - ${testCase.title}`); - catalogLines.push(`- expected: ${testCase.expected}`); - catalogLines.push(`- tags: ${formatCaseTags(testCase.tags)}`); - catalogLines.push(`- focus: ${formatCaseFocus(testCase.focus)}`); - if (Array.isArray(testCase.refs) && testCase.refs.length > 0) { - catalogLines.push('- refs:'); - for (const ref of testCase.refs) { - catalogLines.push(` - [${ref.label}](${ref.url})`); - } - } - catalogLines.push('### input'); - catalogLines.push('```json'); - catalogLines.push(stringifyStablePretty(testCase.input)); - catalogLines.push('```'); - if (testCase.expected === 'throws') { - catalogLines.push('### error'); - catalogLines.push('```json'); - catalogLines.push(stringifyErrorPretty(testCase.error)); - catalogLines.push('```'); - } else { - catalogLines.push('### output'); - catalogLines.push('```json'); - catalogLines.push(stringifyStablePretty(testCase.output)); - catalogLines.push('```'); - } - catalogLines.push(''); - } - - catalogLines.push(''); - writeFileSync(catalogPath, `${catalogLines.join('\n')}\n`, 'utf8'); - written.push(catalogPath); - catalogRows.push({ - fileName: catalogFileName, - catalogId: catalog.catalogId, - title: catalog.title, - tests: catalog.cases.length - }); - } - - const indexPath = path.join(outDir, indexFileName); - const indexLines: string[] = []; - indexLines.push('# Unit Test Index'); - indexLines.push(''); - indexLines.push(`- catalogs: ${catalogRows.length}`); - indexLines.push(''); - indexLines.push('## Catalog Files'); - indexLines.push(''); - for (const row of catalogRows.sort((a, b) => a.fileName.localeCompare(b.fileName))) { - indexLines.push(`- [${row.catalogId}](./${row.fileName})`); - indexLines.push(` - title: ${row.title}`); - indexLines.push(` - tests: ${row.tests}`); - } - indexLines.push(''); - writeFileSync(indexPath, `${indexLines.join('\n')}\n`, 'utf8'); - written.push(indexPath); - - return written; -} - -export function applyEvidenceOutputControls( - report: TestSpecificationEvidence, - options: { summaryOnly?: boolean; limit?: number } -): TestSpecificationEvidence { - const summaryOnly = Boolean(options.summaryOnly); - const limit = options.limit; - - if (summaryOnly) { - return { - ...report, - sqlCatalogs: [], - sqlCaseCatalogs: [], - testCaseCatalogs: [], - testCases: [], - display: { - summaryOnly: true, - limit, - truncated: - report.sqlCatalogs.length > 0 || - report.sqlCaseCatalogs.length > 0 || - report.testCaseCatalogs.length > 0 || - report.testCases.length > 0 - } - }; - } - - if (limit === undefined) { - return report; - } - - const limited = { - ...report, - sqlCatalogs: report.sqlCatalogs.slice(0, limit), - sqlCaseCatalogs: report.sqlCaseCatalogs.slice(0, limit).map((catalog) => ({ - ...catalog, - cases: catalog.cases.slice(0, limit) - })), - testCaseCatalogs: report.testCaseCatalogs.slice(0, limit).map((catalog) => ({ - ...catalog, - cases: catalog.cases.slice(0, limit) - })), - testCases: report.testCases.slice(0, limit), - display: { - summaryOnly: false, - limit, - truncated: - report.sqlCatalogs.length > limit || - report.sqlCaseCatalogs.length > limit || - report.testCaseCatalogs.length > limit || - report.testCases.length > limit || - report.sqlCaseCatalogs.some((catalog) => catalog.cases.length > limit) || - report.testCaseCatalogs.some((catalog) => catalog.cases.length > limit) - } - }; - return limited; -} - -function normalizeEvidenceLimit(value?: string): number | undefined { - if (value === undefined) { - return undefined; - } - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new TestEvidenceRuntimeError(`Unsupported limit: ${value}. Use a positive integer.`); - } - return parsed; -} - -function findCatalogDefinitionPath(report: TestSpecificationEvidence, catalogId: string): string | undefined { - const functionCatalog = report.testCaseCatalogs.find((catalog) => catalog.id === catalogId); - if (functionCatalog?.definitionPath) { - return functionCatalog.definitionPath; - } - const sqlCatalog = report.sqlCaseCatalogs.find((catalog) => catalog.id === catalogId); - if (sqlCatalog?.definitionPath) { - return sqlCatalog.definitionPath; - } - return undefined; -} - -function toSpecificationSlug(definition: string): string { - if (definition === '(unknown)') { - return 'unknown'; - } - const normalized = definition - .replace(/\\/g, '/') - .replace(/^\//, '') - .replace(/[^a-zA-Z0-9/_-]+/g, '-') - .replace(/\/+/g, '__') - .replace(/-+/g, '-') - .replace(/^[-_]+|[-_]+$/g, ''); - return normalized || 'unknown'; -} - -function formatDefinitionLinkMarkdown( - definitionPath: string | undefined, - options?: DefinitionLinkOptions -): string { - if (!definitionPath) { - return '(unknown)'; - } - const normalizedPath = definitionPath.replace(/\\/g, '/').replace(/^\/+/, ''); - if (!normalizedPath) { - return '(unknown)'; - } - if (options?.mode === 'github' && options.github) { - const serverUrl = options.github.serverUrl.replace(/\/+$/, ''); - const repository = options.github.repository.trim(); - const ref = options.github.ref.trim(); - if (serverUrl && repository && ref) { - const encodedPath = normalizedPath.split('/').map((part) => encodeURIComponent(part)).join('/'); - const url = `${serverUrl}/${repository}/blob/${encodeURIComponent(ref)}/${encodedPath}`; - return `[${normalizedPath}](${url})`; - } - } - if (options?.mode === 'path' && options.path) { - const absoluteTarget = path.resolve(options.path.sourceRootDir, normalizedPath); - const absoluteMarkdownDir = path.resolve(options.path.markdownDir); - const relativePath = path.relative(absoluteMarkdownDir, absoluteTarget).replace(/\\/g, '/'); - return `[${normalizedPath}](${relativePath || normalizedPath})`; - } - return `[${normalizedPath}](${normalizedPath})`; -} - -function materializeEvidenceForRef(args: { - repoRoot: string; - ref: string; - resolvedSha: string; - allowCurrentWorkspace: boolean; - tempRoot: string; - createdWorktrees: string[]; - scopeDir?: string; - specsDir?: string; - testsDir?: string; - specModule?: string; - summaryOnly?: boolean; - limit?: number; -}): { sha: string; report: TestSpecificationEvidence; previewJsonPath: string } { - const toReport = (rootDir: string): TestSpecificationEvidence => { - try { - return applyEvidenceOutputControls( - runTestEvidenceSpecification({ - mode: 'specification', - rootDir, - scopeDir: args.scopeDir, - specsDir: args.specsDir, - testsDir: args.testsDir, - specModule: args.specModule - }), - { - summaryOnly: args.summaryOnly, - limit: args.limit - } - ); - } catch (error) { - if ( - error instanceof TestEvidenceRuntimeError && - error.code === 'NO_SPECS_FOUND' - ) { - return createEmptySpecificationEvidence(); - } - throw error; - } - }; - - if (args.allowCurrentWorkspace) { - const report = toReport(args.repoRoot); - return { - sha: args.resolvedSha, - report, - previewJsonPath: writePreviewSnapshot(args.repoRoot, report) - }; - } - - const worktreeDir = mkdtempSync(path.join(args.tempRoot, 'wt-')); - args.createdWorktrees.push(worktreeDir); - runGitCommand(args.repoRoot, ['worktree', 'add', '--detach', worktreeDir, args.resolvedSha]); - const report = toReport(worktreeDir); - return { - sha: args.resolvedSha, - report, - previewJsonPath: writePreviewSnapshot(worktreeDir, report) - }; -} - -function createEmptySpecificationEvidence(): TestSpecificationEvidence { - return { - schemaVersion: 1, - mode: 'specification', - summary: { - sqlCatalogCount: 0, - sqlCaseCatalogCount: 0, - testCaseCount: 0, - specFilesScanned: 0, - testFilesScanned: 0 - }, - sqlCatalogs: [], - sqlCaseCatalogs: [], - testCaseCatalogs: [], - testCases: [] - }; -} - -function writePreviewSnapshot(rootDir: string, report: TestSpecificationEvidence): string { - const previewDir = path.resolve(rootDir, 'artifacts', 'test-evidence'); - mkdirSync(previewDir, { recursive: true }); - const previewPath = path.join(previewDir, 'test-specification.preview.json'); - writeFileSync(previewPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); - return previewPath; -} - -function cleanupWorktrees(repoRoot: string, worktreeDirs: string[]): void { - for (const worktreeDir of [...worktreeDirs].reverse()) { - try { - runGitCommand(repoRoot, ['worktree', 'remove', '--force', worktreeDir]); - } catch { - rmSync(worktreeDir, { recursive: true, force: true }); - } - } -} - -function runGitCommand(repoRoot: string, args: string[]): string { - try { - return execFileSync('git', args, { - cwd: repoRoot, - env: withoutGitLocalEnv(process.env), - stdio: ['ignore', 'pipe', 'pipe'], - encoding: 'utf8' - }).trim(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new TestEvidenceRuntimeError(`Git command failed: git ${args.join(' ')} (${message})`); - } -} - -function withoutGitLocalEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const next = { ...env }; - for (const key of [ - 'GIT_ALTERNATE_OBJECT_DIRECTORIES', - 'GIT_CONFIG', - 'GIT_CONFIG_PARAMETERS', - 'GIT_CONFIG_COUNT', - 'GIT_OBJECT_DIRECTORY', - 'GIT_DIR', - 'GIT_WORK_TREE', - 'GIT_IMPLICIT_WORK_TREE', - 'GIT_GRAFT_FILE', - 'GIT_INDEX_FILE', - 'GIT_NO_REPLACE_OBJECTS', - 'GIT_REPLACE_REF_BASE', - 'GIT_PREFIX', - 'GIT_SHALLOW_FILE', - 'GIT_COMMON_DIR' - ]) { - delete next[key]; - } - return next; -} - -function resolveGitSha(repoRoot: string, ref: string): string { - return runGitCommand(repoRoot, ['rev-parse', ref]); -} - -function resolveGitMergeBase(repoRoot: string, baseRef: string, headRef: string): string { - return runGitCommand(repoRoot, ['merge-base', baseRef, headRef]); -} - -function toSqlEvidence( - rootDir: string, - loaded: LoadedSqlCatalogSpec -): SqlCatalogSpecEvidence { - const id = - typeof loaded.spec.id === 'string' && loaded.spec.id.trim().length > 0 - ? loaded.spec.id.trim() - : ``; - const sqlFile = typeof loaded.spec.sqlFile === 'string' && loaded.spec.sqlFile.trim().length > 0 - ? loaded.spec.sqlFile.trim() - : null; - const paramsShape = loaded.spec.params?.shape === 'named' || loaded.spec.params?.shape === 'positional' - ? loaded.spec.params.shape - : 'unknown'; - const resolvedSqlPath = sqlFile ? path.resolve(path.dirname(loaded.filePath), sqlFile) : null; - const specFile = normalizePath(path.relative(rootDir, loaded.filePath)); - return { - kind: 'sql-catalog', - id, - specFile, - sqlFile, - sqlFileResolved: Boolean(resolvedSqlPath && existsSync(resolvedSqlPath)), - paramsShape, - hasOutputMapping: loaded.spec.output?.mapping !== undefined - }; -} - -function loadEvidenceModule(rootDir: string, testsDir: string, specModule?: string): EvidenceModuleLike | undefined { - const moduleCandidates = resolveEvidenceModuleCandidates(rootDir, testsDir, specModule); - const target = moduleCandidates.find((candidate) => existsSync(candidate)); - if (!target) { - return undefined; - } - try { - const requireFn = createRequire(__filename); - const loaded = requireFn(target) as Record; - const normalized = loaded?.default && isPlainObject(loaded.default) - ? loaded.default as Record - : loaded; - return normalized as EvidenceModuleLike; - } catch (error) { - throw new TestEvidenceRuntimeError( - `Failed to load evidence module ${target}: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -function resolveEvidenceModuleCandidates(rootDir: string, testsDir: string, specModule?: string): string[] { - if (specModule) { - const absolute = path.resolve(rootDir, specModule); - return [ - absolute, - `${absolute}.js`, - `${absolute}.cjs`, - `${absolute}.ts` - ]; - } - - const base = path.resolve(testsDir, 'specs', 'index'); - return [ - `${base}.js`, - `${base}.cjs`, - `${base}.ts` - ]; -} - -function readTestCaseCatalogsFromModule(moduleValue: EvidenceModuleLike): Array<{ - id: string; - title: string; - description?: string; - definitionPath?: string; - refs?: Array<{ - label: string; - url: string; - }>; - cases: Array<{ - id: string; - title: string; - description?: string; - input: unknown; - expected: 'success' | 'throws' | 'errorResult'; - output?: unknown; - error?: { - name: string; - message: string; - match: 'equals' | 'contains'; - }; - tags?: string[]; - focus?: string; - refs?: Array<{ - label: string; - url: string; - }>; - }>; -}> { - if (!Array.isArray(moduleValue.testCaseCatalogs)) { - return []; - } - - return moduleValue.testCaseCatalogs.map((catalog, index) => { - if (!isPlainObject(catalog)) { - throw new TestEvidenceRuntimeError(`testCaseCatalogs[${index}] must be an object in evidence module.`); - } - const id = typeof catalog.id === 'string' ? catalog.id.trim() : ''; - const title = typeof catalog.title === 'string' ? catalog.title.trim() : ''; - const cases = Array.isArray(catalog.cases) ? catalog.cases : []; - if (!id || !title) { - throw new TestEvidenceRuntimeError(`testCaseCatalogs[${index}] requires non-empty id/title.`); - } - const normalizedCases = cases.map((item, caseIndex) => { - if (!isPlainObject(item)) { - throw new TestEvidenceRuntimeError(`testCaseCatalogs[${index}].cases[${caseIndex}] must be an object.`); - } - const caseId = typeof item.id === 'string' ? item.id.trim() : ''; - const caseTitle = typeof item.title === 'string' ? item.title.trim() : ''; - if (!caseId || !caseTitle) { - throw new TestEvidenceRuntimeError(`testCaseCatalogs[${index}].cases[${caseIndex}] requires non-empty id/title.`); - } - const description = typeof item.description === 'string' && item.description.trim().length > 0 - ? item.description.trim() - : undefined; - const hasDirectInput = Object.prototype.hasOwnProperty.call(item, 'input'); - const hasDirectOutput = Object.prototype.hasOwnProperty.call(item, 'output'); - const hasDirectExpected = Object.prototype.hasOwnProperty.call(item, 'expected'); - const hasDirectError = Object.prototype.hasOwnProperty.call(item, 'error'); - const hasDirectTags = Object.prototype.hasOwnProperty.call(item, 'tags'); - const hasDirectFocus = Object.prototype.hasOwnProperty.call(item, 'focus'); - const hasDirectRefs = Object.prototype.hasOwnProperty.call(item, 'refs'); - const evidenceValue = Object.prototype.hasOwnProperty.call(item, 'evidence') ? item.evidence : undefined; - const hasEvidence = isPlainObject(evidenceValue); - - // Accept both normalized evidence shape and raw test catalog shape (`evidence.*`). - const input = hasDirectInput - ? item.input - : hasEvidence && Object.prototype.hasOwnProperty.call(evidenceValue, 'input') - ? evidenceValue.input - : undefined; - const output = hasDirectOutput - ? item.output - : hasEvidence && Object.prototype.hasOwnProperty.call(evidenceValue, 'output') - ? evidenceValue.output - : undefined; - const expectedRaw = hasDirectExpected - ? item.expected - : hasEvidence && Object.prototype.hasOwnProperty.call(evidenceValue, 'expected') - ? evidenceValue.expected - : undefined; - const expected: 'success' | 'throws' | 'errorResult' | undefined = - expectedRaw === 'success' || expectedRaw === 'throws' || expectedRaw === 'errorResult' - ? expectedRaw - : output === undefined - ? undefined - : 'success'; - const errorRaw = hasDirectError - ? item.error - : hasEvidence && Object.prototype.hasOwnProperty.call(evidenceValue, 'error') - ? evidenceValue.error - : undefined; - const tagsRaw = hasDirectTags - ? item.tags - : hasEvidence && Object.prototype.hasOwnProperty.call(evidenceValue, 'tags') - ? evidenceValue.tags - : undefined; - const focusRaw = hasDirectFocus - ? item.focus - : hasEvidence && Object.prototype.hasOwnProperty.call(evidenceValue, 'focus') - ? evidenceValue.focus - : undefined; - const refsRaw = hasDirectRefs - ? item.refs - : hasEvidence && Object.prototype.hasOwnProperty.call(evidenceValue, 'refs') - ? evidenceValue.refs - : undefined; - if (input === undefined || expected === undefined) { - throw new TestEvidenceRuntimeError( - `testCaseCatalogs[${index}].cases[${caseIndex}] requires input/expected evidence fields.` - ); - } - if (expected !== 'throws' && output === undefined) { - throw new TestEvidenceRuntimeError( - `testCaseCatalogs[${index}].cases[${caseIndex}] requires output when expected is not "throws".` - ); - } - if (expected === 'throws') { - if (!isPlainObject(errorRaw)) { - throw new TestEvidenceRuntimeError( - `testCaseCatalogs[${index}].cases[${caseIndex}] requires error when expected is "throws".` - ); - } - if ( - typeof errorRaw.name !== 'string' || - typeof errorRaw.message !== 'string' || - (errorRaw.match !== 'equals' && errorRaw.match !== 'contains') - ) { - throw new TestEvidenceRuntimeError( - `testCaseCatalogs[${index}].cases[${caseIndex}].error must define name/message/match.` - ); - } - } - const normalizedTags = - Array.isArray(tagsRaw) && tagsRaw.every((tag) => typeof tag === 'string' && tag.trim().length > 0) - ? normalizeCaseTags(tagsRaw.map((tag) => tag.trim())) - : undefined; - const normalizedFocus = typeof focusRaw === 'string' && focusRaw.trim().length > 0 - ? focusRaw.trim() - : undefined; - const normalizedRefs = normalizeCatalogRefs(refsRaw); - if (!normalizedTags || normalizedTags.length !== 2) { - throw new TestEvidenceRuntimeError( - `testCaseCatalogs[${index}].cases[${caseIndex}] requires tags with exactly 2 axes: [intent, technique].` - ); - } - if (!normalizedFocus) { - throw new TestEvidenceRuntimeError( - `testCaseCatalogs[${index}].cases[${caseIndex}] requires focus sentence.` - ); - } - return { - id: caseId, - title: caseTitle, - ...(description ? { description } : {}), - input, - expected, - ...(expected === 'throws' - ? { - error: { - name: (errorRaw as { name: string }).name, - message: (errorRaw as { message: string }).message, - match: (errorRaw as { match: 'equals' | 'contains' }).match - } - } - : { output }), - tags: normalizedTags, - focus: normalizedFocus, - ...(normalizedRefs.length > 0 ? { refs: normalizedRefs } : {}) - }; - }); - const description = typeof catalog.description === 'string' && catalog.description.trim().length > 0 - ? catalog.description.trim() - : undefined; - const definitionPath = typeof catalog.definitionPath === 'string' && catalog.definitionPath.trim().length > 0 - ? catalog.definitionPath.trim() - : undefined; - const refs = normalizeCatalogRefs(catalog.refs); - return { - id, - title, - ...(description ? { description } : {}), - ...(definitionPath ? { definitionPath } : {}), - ...(refs.length > 0 ? { refs } : {}), - cases: normalizedCases - }; - }); -} - -function flattenTestCaseCatalogs(catalogs: Array<{ - id: string; - title: string; - description?: string; - definitionPath?: string; - cases: Array<{ - id: string; - title: string; - description?: string; - input?: unknown; - output?: unknown; - }>; -}>): TestCaseEvidence[] { - const rows: TestCaseEvidence[] = []; - for (const catalog of catalogs) { - for (const testCase of catalog.cases) { - rows.push({ - kind: 'test-case', - id: `${catalog.id}.${testCase.id}`, - catalogId: catalog.id, - caseId: testCase.id, - filePath: 'tests/specs/index', - title: testCase.title, - ...(testCase.description ? { description: testCase.description } : {}) - }); - } - } - return rows; -} - -function readSqlCaseCatalogsFromModule(moduleValue: EvidenceModuleLike): SqlCaseCatalogEvidence[] { - if (!Array.isArray(moduleValue.sqlCatalogCases)) { - return []; - } - return moduleValue.sqlCatalogCases.map((catalog, index) => normalizeSqlCaseCatalog(catalog, index)); -} - -function normalizeSqlCaseCatalog(catalog: unknown, index: number): SqlCaseCatalogEvidence { - if (!isPlainObject(catalog)) { - throw new TestEvidenceRuntimeError(`sqlCatalogCases[${index}] must be an object in evidence module.`); - } - const id = typeof catalog.id === 'string' ? catalog.id.trim() : ''; - const title = typeof catalog.title === 'string' ? catalog.title.trim() : ''; - if (!id || !title) { - throw new TestEvidenceRuntimeError(`sqlCatalogCases[${index}] requires non-empty id/title.`); - } - const details = isPlainObject(catalog.catalog) ? catalog.catalog : {}; - const params = isPlainObject(details.params) ? details.params : {}; - const output = isPlainObject(details.output) ? details.output : {}; - const mapping = isPlainObject(output.mapping) ? output.mapping : {}; - const columnMapRaw = isPlainObject(mapping.columnMap) ? mapping.columnMap : {}; - const columnMap = Object.fromEntries( - Object.entries(columnMapRaw) - .filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string') - .sort((a, b) => a[0].localeCompare(b[0])) - ); - const sql = typeof details.sql === 'string' ? details.sql : ''; - const example = isPlainObject(params.example) ? { ...params.example } : {}; - const fixturesRaw = Array.isArray(catalog.fixtures) ? catalog.fixtures : []; - const fixtures = fixturesRaw - .filter((item) => isPlainObject(item) && typeof item.tableName === 'string') - .map((item) => ({ - tableName: item.tableName as string, - ...(isPlainObject(item.schema) && isPlainObject(item.schema.columns) - ? { - schema: { - columns: Object.fromEntries( - Object.entries(item.schema.columns) - .filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string') - .sort((a, b) => a[0].localeCompare(b[0])) - ) - } - } - : {}), - rowsCount: Array.isArray(item.rows) ? item.rows.length : typeof item.rowsCount === 'number' ? item.rowsCount : 0 - })) - .sort((a, b) => a.tableName.localeCompare(b.tableName)); - const casesRaw = Array.isArray(catalog.cases) ? catalog.cases : []; - const baseParams = isPlainObject(params.example) ? { ...params.example } : {}; - const cases = casesRaw - .filter((item) => isPlainObject(item) && typeof item.id === 'string' && typeof item.title === 'string') - .map((item, caseIndex) => { - const arrangedParams = resolveCaseArrangeParams(item, index, caseIndex); - const mergedParams = arrangedParams ? { ...baseParams, ...arrangedParams } : { ...baseParams }; - const expected = Array.isArray(item.expected) ? [...item.expected] : []; - return { - id: item.id as string, - title: item.title as string, - params: Object.fromEntries( - Object.entries(mergedParams) - .filter((entry): entry is [string, unknown] => typeof entry[0] === 'string') - .sort((a, b) => a[0].localeCompare(b[0])) - ), - expected - }; - }) - .sort((a, b) => a.id.localeCompare(b.id)); - - const nestedDefinitionPath = typeof details.definitionPath === 'string' && details.definitionPath.trim().length > 0 - ? details.definitionPath.trim() - : undefined; - const description = typeof catalog.description === 'string' && catalog.description.trim().length > 0 - ? catalog.description.trim() - : undefined; - const definitionPath = typeof catalog.definitionPath === 'string' && catalog.definitionPath.trim().length > 0 - ? catalog.definitionPath.trim() - : nestedDefinitionPath; - return { - id, - title, - ...(description ? { description } : {}), - ...(definitionPath ? { definitionPath } : {}), - params: { - shape: 'named', - example - }, - output: { - mapping: { - columnMap - } - }, - // Keep SQL text unchanged so evidence remains a direct projection of test source. - sql, - fixtures, - cases - }; -} - -function resolveCaseArrangeParams( - testCase: Record, - catalogIndex: number, - caseIndex: number -): Record | undefined { - const arrange = testCase.arrange; - if (arrange === undefined) { - return undefined; - } - if (typeof arrange !== 'function') { - throw new TestEvidenceRuntimeError( - `sqlCatalogCases[${catalogIndex}].cases[${caseIndex}].arrange must be a function when provided.` - ); - } - const arranged = (arrange as () => unknown)(); - if (!isPlainObject(arranged)) { - throw new TestEvidenceRuntimeError( - `sqlCatalogCases[${catalogIndex}].cases[${caseIndex}].arrange must return an object.` - ); - } - return arranged; -} - -function loadTestCaseCatalogEvidence(rootDir: string, filePath: string): TestCaseEvidence[] { - let parsed: unknown; - try { - parsed = JSON.parse(readFileSync(filePath, 'utf8')); - } catch (error) { - throw new TestEvidenceRuntimeError( - `Failed to parse test-case catalog file ${filePath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - - if (!isPlainObject(parsed)) { - throw new TestEvidenceRuntimeError(`Test-case catalog file must contain an object: ${filePath}`); - } - - const document = parsed as TestCaseCatalogDocumentLike; - if (!Array.isArray(document.catalogs)) { - throw new TestEvidenceRuntimeError(`Test-case catalog file must define a catalogs array: ${filePath}`); - } - - const relative = normalizePath(path.relative(rootDir, filePath)); - const rows: TestCaseEvidence[] = []; - const catalogs = document.catalogs as TestCaseCatalogLike[]; - for (const catalog of catalogs) { - const catalogId = typeof catalog.id === 'string' ? catalog.id.trim() : ''; - if (!catalogId) { - throw new TestEvidenceRuntimeError(`Test-case catalog id must be a non-empty string: ${filePath}`); - } - if (!Array.isArray(catalog.cases)) { - throw new TestEvidenceRuntimeError(`Test-case catalog "${catalogId}" must define a cases array: ${filePath}`); - } - - const cases = catalog.cases as TestCaseLike[]; - for (const testCase of cases) { - const caseId = typeof testCase.id === 'string' ? testCase.id.trim() : ''; - const title = typeof testCase.title === 'string' ? testCase.title.trim() : ''; - if (!caseId || !title) { - throw new TestEvidenceRuntimeError( - `Test-case catalog "${catalogId}" requires each case to define non-empty id/title: ${filePath}` - ); - } - const description = typeof testCase.description === 'string' && testCase.description.trim().length > 0 - ? testCase.description.trim() - : undefined; - rows.push({ - kind: 'test-case', - id: `${catalogId}.${caseId}`, - catalogId, - caseId, - filePath: relative, - title, - ...(description ? { description } : {}) - }); - } - } - - return rows; -} - -function walkFiles(rootDir: string, predicate: (absolutePath: string) => boolean): string[] { - const stack = [rootDir]; - const files: string[] = []; - while (stack.length > 0) { - const current = stack.pop()!; - const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(absolute); - continue; - } - if (entry.isFile() && predicate(absolute)) { - files.push(absolute); - } - } - } - return files.sort((a, b) => a.localeCompare(b)); -} - -function isTestCaseCatalogFile(filePath: string): boolean { - const lowered = filePath.toLowerCase(); - return lowered.endsWith('.test-case-catalog.json'); -} - -function normalizePath(input: string): string { - return input.split(path.sep).join('/'); -} - -function normalizeCatalogRefs(value: unknown): Array<{ label: string; url: string }> { - if (!Array.isArray(value)) { - return []; - } - return value - .filter((item): item is Record => isPlainObject(item)) - .map((item) => ({ - label: typeof item.label === 'string' ? item.label.trim() : '', - url: typeof item.url === 'string' ? item.url.trim() : '' - })) - .filter((item) => item.label.length > 0 && item.url.length > 0) - .sort((a, b) => a.label.localeCompare(b.label) || a.url.localeCompare(b.url)); -} - -const TAG_NORMALIZATION_MAP: Record = { - boundary: 'bva' -}; - -const INTENT_TAGS = ['normalization', 'validation', 'authorization', 'invariant'] as const; -const TECHNIQUE_TAGS = ['ep', 'bva', 'idempotence', 'state'] as const; -const INTENT_TAG_SET = new Set(INTENT_TAGS); -const TECHNIQUE_TAG_SET = new Set(TECHNIQUE_TAGS); - -function normalizeCaseTags(tags: string[]): string[] { - const normalized = new Set(); - for (const rawTag of tags) { - const lowered = rawTag.trim().toLowerCase(); - const mapped = TAG_NORMALIZATION_MAP[lowered] ?? lowered; - if (INTENT_TAG_SET.has(mapped) || TECHNIQUE_TAG_SET.has(mapped)) { - normalized.add(mapped); - } - } - const intent = INTENT_TAGS.find((tag) => normalized.has(tag)); - const technique = TECHNIQUE_TAGS.find((tag) => normalized.has(tag)); - const result: string[] = []; - if (intent) { - result.push(intent); - } - if (technique) { - result.push(technique); - } - return result; -} - -function formatCaseTags(tags: string[] | undefined): string { - const normalized = Array.isArray(tags) ? normalizeCaseTags(tags) : []; - return `[${normalized.join(', ')}]`; -} - -function formatCaseFocus(focus: string | undefined): string { - if (typeof focus === 'string' && focus.trim().length > 0) { - return focus.trim(); - } - return '(not specified)'; -} - -function stringifyStablePretty(value: unknown): string { - return JSON.stringify(sortDeep(value), null, 2) ?? 'null'; -} - -function stringifyErrorPretty(value: unknown): string { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return 'null'; - } - const source = value as Record; - return JSON.stringify( - { - name: source.name, - message: source.message, - match: source.match - }, - null, - 2 - ) ?? 'null'; -} - -function sortDeep(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => sortDeep(item)); - } - if (value !== null && typeof value === 'object') { - const entries = Object.entries(value as Record) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, nested]) => [key, sortDeep(nested)] as const); - return Object.fromEntries(entries); - } - return value; -} - - - - - - - - -/** - * Render deterministic Markdown focused on human-readable test intent. - */ -export function formatTestDocumentationOutput( - report: TestSpecificationEvidence, - context?: { markdownPath?: string; sourceRootDir?: string } -): string { - const model = buildSpecificationModel(report as TestEvidencePreviewJson); - return `${renderTestDocumentationMarkdown(model, { - definitionLinks: resolveDefinitionLinkOptions(context) - })}\n`; -} - -function writeTestDocumentationArtifact(args: { - report: TestSpecificationEvidence; - outPath: string; - sourceRootDir: string; -}): void { - mkdirSync(path.dirname(args.outPath), { recursive: true }); - writeFileSync( - args.outPath, - formatTestDocumentationOutput(args.report, { - markdownPath: args.outPath, - sourceRootDir: args.sourceRootDir - }), - 'utf8' - ); - console.log(`wrote: ${args.outPath}`); -} diff --git a/packages/ztd-cli/src/commands/ztdConfig.ts b/packages/ztd-cli/src/commands/ztdConfig.ts deleted file mode 100644 index 0d31f34e1..000000000 --- a/packages/ztd-cli/src/commands/ztdConfig.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { - createTableDefinitionFromCreateTableQuery, - CreateTableQuery, - MultiQuerySplitter, - SqlFormatter, - SqlParser, - type ValueComponent -} from 'rawsql-ts'; -import type { DdlLintMode, TableNameResolver } from '@rawsql-ts/testkit-core'; -import { ensureTestkitCoreModule } from '../utils/optionalDependencies'; -import { collectSqlFiles, SqlSource } from '../utils/collectSqlFiles'; -import { mapSqlTypeToTs } from '../utils/typeMapper'; -import { ensureDirectory } from '../utils/fs'; - -export interface ZtdConfigGenerationOptions { - directories: string[]; - extensions: string[]; - out: string; - defaultSchema?: string; - searchPath?: string[]; - ddlLint?: DdlLintMode; - dryRun?: boolean; -} - -export interface ColumnMetadata { - name: string; - typeName?: string; - isNullable: boolean; - required: boolean; - defaultValue: string | null; - isNotNull: boolean; -} - -export interface TableMetadata { - name: string; - testRowInterfaceName: string; - columns: ColumnMetadata[]; -} - -export interface ZtdConfigGenerationResult { - tables: TableMetadata[]; - rendered: string; - manifestRendered: string; - outFile: string; - manifestOutFile: string; - dryRun: boolean; -} - -export async function runGenerateZtdConfig(options: ZtdConfigGenerationOptions): Promise { - const testkitCore = await ensureTestkitCoreModule(); - const { - TableNameResolver, - DdlLintError, - analyzeDdlSources, - applyDdlLintMode, - formatDdlLintDiagnostics, - normalizeDdlLintMode - } = testkitCore; - - const sources = collectSqlFiles(options.directories, options.extensions); - if (sources.length === 0) { - throw new Error(`No SQL files were discovered under ${options.directories.join(', ')}`); - } - - // Build a resolver that honors the configured schema/search path so the generated rows stay canonical. - const resolver = new TableNameResolver({ - defaultSchema: options.defaultSchema, - searchPath: options.searchPath - }); - - // Validate the DDL sources up front so fixture metadata is generated from a consistent schema. - const lintMode = normalizeDdlLintMode(options.ddlLint); - const analysis = analyzeDdlSources(sources, { tableNameResolver: resolver }); - if (lintMode !== 'off') { - const diagnostics = analysis.diagnostics; - if (diagnostics.length > 0) { - const adjusted = applyDdlLintMode(diagnostics, lintMode); - if (lintMode === 'strict') { - throw new DdlLintError(adjusted); - } - console.warn(formatDdlLintDiagnostics(adjusted)); - } - } - - const tables = buildTableMetadataFromCreateStatements( - analysis.createStatements.map((entry) => entry.ast), - resolver - ); - if (tables.length === 0) { - throw new Error('The provided DDL sources did not contain any CREATE TABLE statements.'); - } - - const output = renderZtdConfigFile(tables); - const manifestOutFile = path.join(path.dirname(options.out), 'ztd-fixture-manifest.generated.ts'); - const manifestOutput = renderZtdFixtureManifestFile(tables); - if (!options.dryRun) { - ensureDirectory(path.dirname(options.out)); - writeFileSync(options.out, output, 'utf8'); - writeFileSync(manifestOutFile, manifestOutput, 'utf8'); - console.log(`Generated ${tables.length} ZTD test rows at ${options.out} and ${manifestOutFile}`); - } - return { - tables, - rendered: output, - manifestRendered: manifestOutput, - outFile: options.out, - manifestOutFile, - dryRun: Boolean(options.dryRun) - }; -} - -export function snapshotTableMetadata(sources: SqlSource[], resolver?: TableNameResolver): TableMetadata[] { - const createStatements = collectCreateTableStatements(sources); - return buildTableMetadataFromCreateStatements(createStatements, resolver); -} - -function collectCreateTableStatements(sources: SqlSource[]): CreateTableQuery[] { - const createStatements: CreateTableQuery[] = []; - for (const source of sources) { - // Split multi-statement files so each CREATE TABLE can be processed independently. - const batch = MultiQuerySplitter.split(source.sql); - - for (const query of batch.queries) { - if (query.isEmpty) { - continue; - } - - try { - const parsed = SqlParser.parse(query.sql); - if (parsed instanceof CreateTableQuery) { - createStatements.push(parsed); - } - } catch (_error) { - continue; - } - } - } - - return createStatements; -} - -function buildTableMetadataFromCreateStatements( - createStatements: CreateTableQuery[], - resolver?: TableNameResolver -): TableMetadata[] { - const registry = new Map(); - // Track tables by their SQL name so each definition is emitted only once. - for (const ast of createStatements) { - const definition = createTableDefinitionFromCreateTableQuery(ast); - - // Normalize table names so the generated config mirrors resolver expectations. - const canonicalName = resolver?.resolve(definition.name) ?? definition.name; - - if (registry.has(canonicalName)) { - continue; - } - - // Reuse the runtime-ready table definition model so the generated metadata can feed testkit directly. - const columns = definition.columns.map((column) => { - return { - name: column.name, - typeName: column.typeName, - isNullable: !column.isNotNull, - required: Boolean(column.required), - defaultValue: formatDefaultValue(column.defaultValue), - isNotNull: Boolean(column.isNotNull) - }; - }); - - registry.set(canonicalName, { - name: canonicalName, - testRowInterfaceName: buildTestRowInterfaceName(canonicalName), - columns - }); - } - - return Array.from(registry.values()).sort((a, b) => a.name.localeCompare(b.name)); -} - -function formatDefaultValue(value: string | ValueComponent | null | undefined): string | null { - if (value == null) { - return null; - } - - if (typeof value === 'string') { - return value; - } - - try { - // Render DDL defaults as SQL text so the generated manifest stays readable and deterministic. - const formatter = new SqlFormatter({ keywordCase: 'none' }); - const { formattedSql } = formatter.format(value); - return formattedSql; - } catch (_error) { - return String(value); - } -} - -function buildTestRowInterfaceName(tableName: string): string { - // Preserve schema and table segments when Pascal-casing to keep names unique. - const namespaceParts = tableName.split('.'); - const pascalize = (segment: string): string => - segment - .split(/[^A-Za-z0-9]+/) - .filter(Boolean) - .map((token) => { - const lower = token.toLowerCase(); - return lower.charAt(0).toUpperCase() + lower.slice(1); - }) - .join(''); - - const pascalSegments = namespaceParts.map((segment) => pascalize(segment)).filter(Boolean); - const combined = pascalSegments.join(''); - const normalized = combined.replace(/^[0-9]+/, ''); - // Guarantee the interface name starts with a letter or underscore. - const prefix = /^[A-Za-z_]/.test(normalized.charAt(0) ?? '') ? normalized : `_${normalized}`; - return `${prefix || 'Table'}TestRow`; -} - -export function renderZtdConfigFile(tables: TableMetadata[]): string { - const header = [ - '// GENERATED FILE. DO NOT EDIT.', - '// ZTD TEST ROW MAP', - '// This file is synchronized with DDL using ztd-config.', - '', - 'type ColumnDefinitions = Record;', - '', - 'export interface TableSchemaDefinition {', - ' columns: ColumnDefinitions;', - '}', - '', - 'export type FixtureRow = Record;', - '', - 'export interface TableFixture = FixtureRow> {', - ' tableName: string;', - ' rows: RowShape[];', - ' schema: TableSchemaDefinition;', - '}', - '' - ].join('\n'); - - const entries = tables - .map((table) => ` '${table.name}': ${table.testRowInterfaceName};`) - .join('\n'); - - // Build each table interface while preserving the column order from the DDL. - const definitions = tables - .map((table) => { - const fields = table.columns - .map((column) => { - const baseType = mapSqlTypeToTs(column.typeName, `${table.name}.${column.name}`); - const tsType = column.isNullable ? `${baseType} | null` : baseType; - return ` ${column.name}: ${tsType};`; - }) - .join('\n'); - return `export interface ${table.testRowInterfaceName} extends FixtureRow {\n${fields}\n}`; - }) - .join('\n\n'); - - // Assemble a schema map that mirrors TestRowMap so tests can reuse canonical affinity metadata. - const schemaEntries = tables - .map((table) => { - const columnDefinitions = table.columns - .map((column) => { - const typeLiteral = JSON.stringify(column.typeName ?? ''); - return ` ${column.name}: ${typeLiteral},`; - }) - .join('\n'); - return ` '${table.name}': {\n columns: {\n${columnDefinitions}\n }\n },`; - }) - .join('\n'); - - const footer = [ - '', - 'export type TestRow = TestRowMap[K];', - 'export type ZtdRowShapes = TestRowMap;', - 'export type ZtdTableName = keyof TestRowMap;', - '', - 'export type ZtdTableSchemas = Record;', - '', - 'export const tableSchemas: ZtdTableSchemas = {', - `${schemaEntries}`, - '};', - '', - 'export function tableSchema(tableName: K): TableSchemaDefinition {', - ' return tableSchemas[tableName];', - '}', - '', - 'export type ZtdTableFixture = TableFixture & {', - ' tableName: K;', - ' rows: ZtdRowShapes[K][];', - ' schema: TableSchemaDefinition;', - '};', - '', - 'export interface ZtdConfig {', - ' tables: ZtdTableName[];', - '}', - '', - 'export function tableFixture(', - ' tableName: K,', - ' rows: ZtdRowShapes[K][],', - ' schema?: TableSchemaDefinition', - '): TableFixture {', - ' return { tableName, rows, schema: schema ?? tableSchemas[tableName] };', - '}', - '', - 'export function tableFixtureWithSchema(', - ' tableName: K,', - ' rows: ZtdRowShapes[K][]', - '): ZtdTableFixture {', - ' // Always pair fixture rows with the canonical schema generated from DDL.', - ' return { tableName, rows, schema: tableSchemas[tableName] };', - '}', - '' - ].join('\n'); - - return `${header}export interface TestRowMap {\n${entries}\n}\n\n${definitions}\n${footer}`; -} - -export function renderZtdFixtureManifestFile(tables: TableMetadata[]): string { - const tableDefinitions = tables - .map((table) => { - const columns = table.columns - .map((column) => { - const parts = [ - ` name: ${JSON.stringify(column.name)},`, - ]; - if (column.typeName !== undefined) { - parts.push(` typeName: ${JSON.stringify(column.typeName)},`); - } - parts.push(` required: ${column.required},`); - // The snapshot phase already normalizes DDL defaults, so the renderer only serializes the stable value. - parts.push(` defaultValue: ${JSON.stringify(column.defaultValue)},`); - parts.push(` isNotNull: ${column.isNotNull},`); - return [ - ' {', - ...parts, - ' },' - ].join('\n'); - }) - .join('\n'); - return ` {\n name: ${JSON.stringify(table.name)},\n columns: [\n${columns}\n ]\n },`; - }) - .join('\n'); - - return [ - '// GENERATED FILE. DO NOT EDIT.', - '// ZTD GENERATED FIXTURE MANIFEST', - '// This file is synchronized with DDL using ztd-config.', - '', - "import type { TableDefinitionModel } from 'rawsql-ts';", - '', - 'export interface GeneratedFixtureManifest {', - ' tableDefinitions: TableDefinitionModel[];', - '}', - '', - 'export const tableDefinitions: TableDefinitionModel[] = [', - tableDefinitions, - '];', - '', - 'export const generatedFixtureManifest: GeneratedFixtureManifest = {', - ' tableDefinitions,', - '};', - '' - ].join('\n'); -} diff --git a/packages/ztd-cli/src/commands/ztdConfigCommand.ts b/packages/ztd-cli/src/commands/ztdConfigCommand.ts deleted file mode 100644 index 8155b2049..000000000 --- a/packages/ztd-cli/src/commands/ztdConfigCommand.ts +++ /dev/null @@ -1,335 +0,0 @@ -import chokidar from 'chokidar'; -import { writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { - collectDirectories, - parseExtensions, - normalizeDirectoryList, - resolveExtensions, - DEFAULT_DDL_DIRECTORY, - DEFAULT_EXTENSIONS, - parseCsvList -} from './options'; -import { - loadZtdProjectConfig, - resolveGeneratedDir, - writeZtdProjectConfig, - type ZtdProjectConfig -} from '../utils/ztdProjectConfig'; -import { runGenerateZtdConfig, type ZtdConfigGenerationOptions } from './ztdConfig'; -import { ensureDirectory } from '../utils/fs'; -import { emitDiagnostic, isJsonOutput, parseJsonPayload, writeCommandEnvelope } from '../utils/agentCli'; -import { validateProjectPath, validateResourceIdentifier } from '../utils/agentSafety'; -import { emitDecisionEvent, withSpan } from '../utils/telemetry'; - -const WATCH_DEBOUNCE_MS = 150; - -type ZtdConfigCommandOptions = { - ddlDir: string[]; - extensions: string[]; - out?: string; - defaultSchema?: string; - searchPath?: string[]; - watch: boolean; - quiet: boolean; - dryRun: boolean; - json?: string; -}; - -function normalizeZtdConfigCommandOptions(options: Record): ZtdConfigCommandOptions { - const ddlDir = typeof options.ddlDir === 'string' - ? collectDirectories(options.ddlDir, []) - : Array.isArray(options.ddlDir) - ? options.ddlDir.filter((entry): entry is string => typeof entry === 'string') - : []; - const extensions = typeof options.extensions === 'string' - ? parseExtensions(options.extensions) - : Array.isArray(options.extensions) - ? options.extensions.filter((entry): entry is string => typeof entry === 'string') - : DEFAULT_EXTENSIONS; - const searchPath = typeof options.searchPath === 'string' - ? parseCsvList(options.searchPath) - : Array.isArray(options.searchPath) - ? options.searchPath.filter((entry): entry is string => typeof entry === 'string') - : undefined; - - return { - ddlDir, - extensions, - out: typeof options.out === 'string' ? options.out : undefined, - defaultSchema: typeof options.defaultSchema === 'string' ? options.defaultSchema : undefined, - searchPath, - watch: Boolean(options.watch), - quiet: Boolean(options.quiet), - dryRun: Boolean(options.dryRun), - json: typeof options.json === 'string' ? options.json : undefined, - }; -} - -function renderZtdLayoutGeneratedFile(config: ZtdProjectConfig): string { - const ztdRootDir = config.ztdRootDir?.replace(/\\/g, '/') ?? resolveGeneratedDir(config).replace(/\/generated$/, ''); - const ddlDir = config.ddlDir.replace(/\\/g, '/'); - - return [ - '// GENERATED FILE. DO NOT EDIT.', - '', - 'export default {', - ` ztdRootDir: ${JSON.stringify(ztdRootDir)},`, - ` ddlDir: ${JSON.stringify(ddlDir)},`, - '};', - '' - ].join('\n'); -} - -function writeZtdLayoutFile(layoutFilePath: string, config: ZtdProjectConfig): void { - // Ensure the generated folder exists before emitting the layout snapshot. - ensureDirectory(path.dirname(layoutFilePath)); - writeFileSync(layoutFilePath, renderZtdLayoutGeneratedFile(config), 'utf8'); -} - -/** - * Registers the `ztd-config` CLI command, which generates the canonical ZTD TestRowMap from DDL sources. - */ -export function registerZtdConfigCommand(program: Command): void { - program - .command('ztd-config') - .description('Generate the canonical ZTD TestRowMap from DDL sources') - .option('--ddl-dir ', 'DDL directory to scan (repeatable)', collectDirectories, []) - .option('--extensions ', 'Comma-separated extensions to include', parseExtensions, DEFAULT_EXTENSIONS) - .option('--out ', 'Destination TypeScript file for generated config') - .option('--default-schema ', 'Override defaultSchema stored in ztd.config.json') - .option('--search-path ', 'Comma-separated schema search path entries', parseCsvList) - .option('--watch', 'Watch DDL files and regenerate when schema changes', false) - .option('--quiet', 'Suppress next-step hints after generation', false) - .option('--dry-run', 'Validate inputs and render outputs without writing files', false) - .option('--json ', 'Pass command options as a JSON object') - .action(async (options: Record) => { - const commandState = await withSpan('resolve-command-state', async () => { - const merged = options.json ? resolveZtdConfigCommandOptions(options) : normalizeZtdConfigCommandOptions(options); - if (merged.watch && merged.dryRun) { - emitDecisionEvent('watch.invalid-with-dry-run'); - throw new Error('--watch cannot be combined with --dry-run.'); - } - - const projectConfig = loadZtdProjectConfig(); - const directories = normalizeDirectoryList(merged.ddlDir, projectConfig.ddlDir ?? DEFAULT_DDL_DIRECTORY); - const extensions = resolveExtensions(merged.extensions, DEFAULT_EXTENSIONS); - const defaultOut = path.join(resolveGeneratedDir(projectConfig), 'ztd-row-map.generated.ts'); - const output = merged.out ?? defaultOut; - const layoutOut = path.join(path.dirname(output), 'ztd-layout.generated.ts'); - const manifestOut = path.join(path.dirname(output), 'ztd-fixture-manifest.generated.ts'); - - let nextDefaultSchema = projectConfig.defaultSchema; - let nextSearchPath = projectConfig.searchPath; - let shouldUpdateConfig = false; - - if (merged.defaultSchema) { - nextDefaultSchema = validateResourceIdentifier(merged.defaultSchema, '--default-schema'); - shouldUpdateConfig = true; - } - - if (merged.searchPath && merged.searchPath.length > 0) { - nextSearchPath = merged.searchPath.map((entry) => validateResourceIdentifier(entry, '--search-path')); - shouldUpdateConfig = true; - } - - const validatedOutput = validateProjectPath(output, '--out'); - const validatedLayoutOut = validateProjectPath(layoutOut, 'generated layout output'); - const validatedManifestOut = validateProjectPath(manifestOut, 'generated runtime manifest output'); - const generationOptions: ZtdConfigGenerationOptions = { - directories, - extensions, - out: validatedOutput, - defaultSchema: nextDefaultSchema, - searchPath: nextSearchPath, - ddlLint: projectConfig.ddlLint, - dryRun: merged.dryRun - }; - const layoutConfig: ZtdProjectConfig = { - ...projectConfig, - defaultSchema: nextDefaultSchema, - searchPath: nextSearchPath - }; - - emitDecisionEvent('command.options.resolved', { - dryRun: merged.dryRun, - watch: merged.watch, - quiet: merged.quiet, - shouldUpdateConfig, - jsonPayload: Boolean(options.json), - }); - - return { - merged, - projectConfig, - shouldUpdateConfig, - nextDefaultSchema, - nextSearchPath, - validatedOutput, - validatedLayoutOut, - validatedManifestOut, - generationOptions, - layoutConfig, - }; - }, { - command: 'ztd-config', - }); - - let configUpdated = false; - if (commandState.shouldUpdateConfig && !commandState.merged.dryRun) { - await withSpan('persist-project-config', async () => { - configUpdated = writeZtdProjectConfig( - process.cwd(), - { - defaultSchema: commandState.nextDefaultSchema, - searchPath: commandState.nextSearchPath - }, - commandState.projectConfig - ); - if (configUpdated) { - emitDiagnostic({ code: 'ztd-config.config-updated', message: 'ztd.config.json schema settings updated.' }); - emitDecisionEvent('config.updated'); - } - }); - } - - const generation = await withSpan('generate-ztd-config', async () => { - return await runGenerateZtdConfig(commandState.generationOptions); - }, { - dryRun: commandState.merged.dryRun, - directoryCount: commandState.generationOptions.directories.length, - }); - - if (!commandState.merged.dryRun) { - await withSpan('write-layout-file', async () => { - writeZtdLayoutFile(commandState.validatedLayoutOut, commandState.layoutConfig); - }); - } - - await withSpan('emit-command-output', async () => { - if (isJsonOutput()) { - writeCommandEnvelope('ztd-config', { - schemaVersion: 1, - dryRun: commandState.merged.dryRun, - configUpdated, - outputs: [ - { path: commandState.validatedOutput, bytes: generation.rendered.length, written: !commandState.merged.dryRun }, - { path: commandState.validatedManifestOut, bytes: generation.manifestRendered.length, written: !commandState.merged.dryRun }, - { path: commandState.validatedLayoutOut, written: !commandState.merged.dryRun } - ], - tables: generation.tables.map((table) => ({ name: table.name, columns: table.columns.length })) - }); - emitDecisionEvent('output.json-envelope'); - } - - if (commandState.merged.watch) { - console.log(`[watch] Initial generation complete: ${commandState.generationOptions.out}`); - emitDecisionEvent('watch.enabled'); - await watchZtdConfig( - commandState.generationOptions, - commandState.validatedLayoutOut, - commandState.layoutConfig - ); - } else if (!commandState.merged.quiet) { - if (commandState.merged.dryRun) { - emitDiagnostic({ - code: 'ztd-config.dry-run', - message: `Dry-run validated generation for ${commandState.validatedOutput}, ${commandState.validatedManifestOut}, and ${commandState.validatedLayoutOut}.` - }); - emitDecisionEvent('output.dry-run-diagnostic'); - } else { - emitDiagnostic({ code: 'ztd-config.next-steps', message: 'Next: run vitest, ztd lint, and ztd check-contract.' }); - emitDecisionEvent('output.next-steps-diagnostic'); - } - } else { - emitDecisionEvent('output.quiet-suppressed'); - } - }); - }); -} - -export function resolveZtdConfigCommandOptions(options: Record): ZtdConfigCommandOptions { - const payload = parseJsonPayload>(String(options.json), '--json'); - return normalizeZtdConfigCommandOptions({ ...options, ...payload }); -} - -async function watchZtdConfig( - options: ZtdConfigGenerationOptions, - layoutOut: string, - layoutConfig: ZtdProjectConfig -): Promise { - const cwd = process.cwd(); - const extensionSet = new Set(options.extensions.map((extension) => extension.toLowerCase())); - // Watch directories directly to avoid platform-specific glob quirks. - const watchRoots = options.directories.map((dir) => path.resolve(dir)); - - // Only the configured output file (`options.out`) is overwritten while watching DDL changes. - const watcher = chokidar.watch(watchRoots, { - ignoreInitial: true, - awaitWriteFinish: { stabilityThreshold: 80, pollInterval: 20 } - }); - - console.log('[watch] Watching DDL files for changes (Ctrl+C to stop)...'); - - let debounceTimer: NodeJS.Timeout | null = null; - let scheduledPath: string | null = null; - - const scheduleReload = (changedPath: string): void => { - scheduledPath = changedPath; - if (debounceTimer) { - clearTimeout(debounceTimer); - } - debounceTimer = setTimeout(() => { - debounceTimer = null; - void executeReload(scheduledPath); - scheduledPath = null; - }, WATCH_DEBOUNCE_MS); - }; - - const scheduleReloadIfDdl = (changedPath: string): void => { - // Filter to DDL extensions so unrelated file changes do not trigger regeneration. - const extension = path.extname(changedPath).toLowerCase(); - if (!extensionSet.has(extension)) { - return; - } - scheduleReload(changedPath); - }; - - const executeReload = async (changedPath: string | null): Promise => { - const relativePath = changedPath ? path.relative(cwd, changedPath) : 'unknown'; - console.log(`[watch] DDL changed: ${relativePath}`); - try { - const generation = await runGenerateZtdConfig(options); - writeZtdLayoutFile(layoutOut, layoutConfig); - console.log(`[watch] Updated: ${options.out}`); - } catch (error) { - console.error( - `[watch] Failed to regenerate ${options.out}: ${error instanceof Error ? error.message : String(error)}` - ); - } - }; - - watcher.on('add', scheduleReloadIfDdl); - watcher.on('change', scheduleReloadIfDdl); - watcher.on('unlink', scheduleReloadIfDdl); - - await new Promise((resolve) => { - const stop = async (): Promise => { - console.log('[watch] Shutting down ztd-config watcher...'); - watcher.off('add', scheduleReloadIfDdl); - watcher.off('change', scheduleReloadIfDdl); - watcher.off('unlink', scheduleReloadIfDdl); - if (debounceTimer) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - await watcher.close(); - process.off('SIGINT', stop); - resolve(); - }; - process.once('SIGINT', () => { - void stop(); - }); - }); -} diff --git a/packages/ztd-cli/src/index.ts b/packages/ztd-cli/src/index.ts deleted file mode 100644 index d88eb5e0a..000000000 --- a/packages/ztd-cli/src/index.ts +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env node - -import { Command } from 'commander'; -import { CheckContractRuntimeError, registerCheckContractCommand } from './commands/checkContract'; -import { registerDescribeCommand } from './commands/describe'; -import { registerDdlCommands } from './commands/ddl'; -import { registerFeatureCommand } from './commands/feature'; -import { registerFindingRegistryCommand } from './commands/findings'; -import { registerInitCommand } from './commands/init'; -import { registerLintCommand } from './commands/lint'; -import { registerModelGenCommand } from './commands/modelGen'; -import { registerPerfCommands } from './commands/perf'; -import { registerQueryCommands } from './commands/query'; -import { registerRfbaCommand } from './commands/rfba'; -import { TestEvidenceRuntimeError, registerTestEvidenceCommand } from './commands/testEvidence'; -import { registerZtdConfigCommand } from './commands/ztdConfigCommand'; -import { setAgentOutputFormat } from './utils/agentCli'; -import { - beginCommandSpan, - configureTelemetry, - emitDecisionEvent, - finishCommandSpan, - flushTelemetry, - recordException, -} from './utils/telemetry'; - -function getCommandPath(command: Command): string { - const segments: string[] = []; - let current: Command | null = command; - - while (current) { - const name = current.name(); - if (name && name !== 'ztd') { - segments.unshift(name); - } - current = current.parent ?? null; - } - - return segments.join(' '); -} - -export function buildProgram(): Command { - const program = new Command(); - program.name('ztd').description('Zero Table Dependency scaffolding and DDL helpers'); - program.option('--output ', 'Global output format (text|json)', 'text'); - program.option('--telemetry', 'Enable internal telemetry events'); - program.option('--telemetry-export ', 'Telemetry export mode (console|debug|file|otlp)'); - program.option('--telemetry-file ', 'Write JSONL telemetry to a file when telemetry export mode is file'); - program.option('--telemetry-endpoint ', 'OTLP/HTTP traces endpoint when telemetry export mode is otlp'); - program.hook('preAction', (_rootCommand: Command, actionCommand: Command) => { - const options = actionCommand.optsWithGlobals() as { - output?: string; - telemetry?: boolean; - telemetryExport?: string; - telemetryFile?: string; - telemetryEndpoint?: string; - }; - setAgentOutputFormat(options.output); - - // Preserve env-based opt-in when CLI flags were not provided explicitly. - const telemetryOptionSource = actionCommand.getOptionValueSource('telemetry'); - const telemetryExportSource = actionCommand.getOptionValueSource('telemetryExport'); - const telemetryFileSource = actionCommand.getOptionValueSource('telemetryFile'); - const telemetryEndpointSource = actionCommand.getOptionValueSource('telemetryEndpoint'); - - configureTelemetry({ - enabled: telemetryOptionSource === 'default' ? undefined : options.telemetry, - exportMode: telemetryExportSource === 'default' ? undefined : options.telemetryExport, - filePath: telemetryFileSource === 'default' ? undefined : options.telemetryFile, - otlpEndpoint: telemetryEndpointSource === 'default' ? undefined : options.telemetryEndpoint, - }); - - const commandPath = getCommandPath(actionCommand); - beginCommandSpan(commandPath, { - outputFormat: options.output ?? 'text', - telemetryEnabled: telemetryOptionSource === 'default' - ? undefined - : Boolean(options.telemetry), - }); - emitDecisionEvent('command.selected', { command: commandPath }); - }); - program.hook('postAction', (_rootCommand: Command, actionCommand: Command) => { - emitDecisionEvent('command.completed', { command: getCommandPath(actionCommand) }); - finishCommandSpan('ok'); - }); - - registerInitCommand(program); - registerFeatureCommand(program); - registerLintCommand(program); - registerModelGenCommand(program); - registerPerfCommands(program); - registerQueryCommands(program); - registerCheckContractCommand(program); - registerFindingRegistryCommand(program); - registerTestEvidenceCommand(program); - registerZtdConfigCommand(program); - registerDdlCommands(program); - registerRfbaCommand(program); - registerDescribeCommand(program); - - program.addHelpText('after', ` -Getting started: - $ ztd init Create a new ZTD project (interactive) - $ ztd init --yes Create a new ZTD project (non-interactive, demo + Zod defaults) - $ ztd init --yes --force Allow non-interactive overwrite of scaffold-owned files - $ ztd feature scaffold --table users --action insert --dry-run - $ ztd feature generated-mapper check --feature users-insert - $ ztd feature query scaffold --feature users-insert --query-name insert-user-audit --table user_audit --action insert --dry-run - $ ztd feature tests scaffold --feature users-insert - $ ztd ztd-config Generate TestRowMap types from DDL - $ ztd findings validate docs/guide/finding-registry.example.json - $ ztd lint Lint SQL files against the schema - $ ztd perf init Scaffold the opt-in perf sandbox - $ ztd perf run --query src/sql/report.sql --dry-run - $ ztd --telemetry --telemetry-export file --telemetry-file tmp/telemetry/perf-run.jsonl perf run --query src/sql/report.sql --dry-run - $ ztd query uses table public.users - $ ztd query uses column public.users.email --format json - $ ztd rfba inspect --format json - $ ztd --telemetry --telemetry-export debug query uses table public.users - -Common workflow: - 1. ztd init Scaffold the project - 2. ztd ztd-config Generate test types from DDL - 3. vitest run Run tests - -After schema changes: - 1. Edit db/ddl/*.sql (or inspect an explicit target with ztd ddl pull --url ) - 2. ztd ztd-config Regenerate types - 3. vitest run Verify tests still pass - -Documentation: https://github.com/mk3008/rawsql-ts`); - - return program; -} - -export async function main(argv: string[] = process.argv): Promise { - const program = buildProgram(); - await program.parseAsync(argv); - await flushTelemetry(); -} - -async function handleFatalError(error: unknown): Promise { - // Keep a terminal root exception alongside child span failures so exporters can correlate - // the failing phase with the overall command outcome without inferring it from child spans. - recordException(error, { scope: 'command-root' }); - finishCommandSpan('error'); - await flushTelemetry(); - console.error(error instanceof Error ? error.message : error); - if (error instanceof CheckContractRuntimeError || error instanceof TestEvidenceRuntimeError) { - process.exit(2); - } - process.exit(1); -} - -if (require.main === module) { - void main().catch(handleFatalError); -} diff --git a/packages/ztd-cli/src/perf/benchmark.ts b/packages/ztd-cli/src/perf/benchmark.ts deleted file mode 100644 index 6cf0f74c1..000000000 --- a/packages/ztd-cli/src/perf/benchmark.ts +++ /dev/null @@ -1,2789 +0,0 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { parse as parseYaml } from 'yaml'; -import { ensurePgModule } from '../utils/optionalDependencies'; -import { bindModelGenNamedSql } from '../utils/modelGenBinder'; -import { scanModelGenSql, type PlaceholderMode } from '../utils/modelGenScanner'; -import { buildQueryStructureReport } from '../query/structure'; -import { findScalarFilterCandidates } from '../query/scalarFilterAnalysis'; -import { executeQueryPipeline, type QueryPipelineSession, type QueryPipelineSessionFactory } from '../query/execute'; -import { buildQueryPipelinePlan, type QueryPipelineMetadata, type QueryPipelineStep } from '../query/planner'; -import { ensurePerfConnection, inspectPerfDdlInventory, loadPerfSandboxConfig, loadPerfSeedConfig, type PerfDdlInventory, type PerfSeedConfig } from './sandbox'; - -export type PerfBenchmarkMode = 'auto' | 'latency' | 'completion'; -export type PerfSelectedBenchmarkMode = 'latency' | 'completion'; -export type PerfBenchmarkFormat = 'text' | 'json'; -export type PerfExecutionStrategy = 'direct' | 'decomposed'; -export type PerfStatementRole = 'materialize' | 'scalar-filter-bind' | 'final-query'; -export type PerfRecommendedActionName = - | 'consider-pipeline-materialization' - | 'review-index-coverage' - | 'inspect-join-strategy' - | 'stabilize-completion-run' - | 'capture-perf-evidence' - | 'increase-perf-fixture-scale' - | 'consider-scalar-filter-binding'; - -export interface PerfRunOptions { - rootDir: string; - queryFile: string; - paramsFile?: string; - strategy: PerfExecutionStrategy; - material: string[]; - mode: PerfBenchmarkMode; - repeat: number; - warmup: number; - classifyThresholdSeconds: number; - timeoutMinutes: number; - save: boolean; - dryRun: boolean; - label?: string; -} - -export interface PerfStatementReport { - seq: number; - role: PerfStatementRole; - target?: string; - sql: string; - bindings: unknown[] | Record | undefined; - resolved_sql_preview?: string; - row_count?: number; - elapsed_ms?: number; - timed_out?: boolean; - plan_summary?: PerfPlanSummary | null; - sql_file?: string; - resolved_sql_preview_file?: string; - plan_file?: string; -} - -export interface PerfPlanSummary { - node_type?: string; - join_type?: string; - total_cost?: number; - plan_rows?: number; - actual_rows?: number; - actual_total_time?: number; -} - -export interface PerfStrategyMetadata { - materialized_ctes: string[]; - scalar_filter_columns: string[]; - planned_steps: Array<{ - step: number; - kind: QueryPipelineStep['kind']; - target: string; - depends_on: string[]; - }>; -} - -export interface PerfPipelineCandidate { - name: string; - downstream_references: number; - reasons: string[]; -} - -export interface PerfPipelineAnalysis { - query_type: string; - cte_count: number; - should_consider_pipeline: boolean; - candidate_ctes: PerfPipelineCandidate[]; - scalar_filter_candidates: string[]; - notes: string[]; -} - -export interface PerfRecommendedAction { - action: PerfRecommendedActionName; - priority: 'high' | 'medium'; - rationale: string; -} - -export interface PerfClassificationProbe { - elapsed_ms: number; - timed_out: boolean; - row_count?: number; - reused_as_warmup?: boolean; - reused_as_measured_run?: boolean; -} - -export interface PerfPlanDelta { - statement_id: string; - baseline_plan: string; - candidate_plan: string; - changed: boolean; -} - -export interface PerfStatementDelta { - statement_id: string; - role: PerfStatementRole; - baseline_elapsed_ms?: number; - candidate_elapsed_ms?: number; - elapsed_delta_ms?: number; - baseline_row_count?: number; - candidate_row_count?: number; - baseline_timed_out?: boolean; - candidate_timed_out?: boolean; -} - -interface PerfPlanFacts { - observations: string[]; - statement_summary: string; - hasCapturedPlan: boolean; - hasSequentialScan: boolean; - hasJoin: boolean; -} - -export type PerfExpectedScale = 'tiny' | 'small' | 'medium' | 'large' | 'batch'; -export type PerfReviewPolicy = 'none' | 'recommended' | 'strongly-recommended'; -export type PerfEvidenceStatus = 'captured' | 'missing' | 'not-required'; -export type PerfFixtureRowsStatus = 'sufficient' | 'undersized' | 'unknown'; - -export interface PerfQuerySpecGuidance { - spec_id: string; - spec_file: string; - expected_scale?: PerfExpectedScale; - expected_input_rows?: number; - expected_output_rows?: number; - review_policy: PerfReviewPolicy; - evidence_status: PerfEvidenceStatus; - fixture_rows_available?: number; - fixture_rows_status: PerfFixtureRowsStatus; -} - -export interface PerfDdlInventorySummary { - ddl_files: number; - ddl_statement_count: number; - table_count: number; - index_count: number; - index_names: string[]; -} - -export type PerfTuningPrimaryPath = 'index' | 'pipeline' | 'capture-plan'; - -export interface PerfTuningBranchGuidance { - recommended: boolean; - rationale: string[]; - next_steps: string[]; -} - -export interface PerfTuningGuidance { - primary_path: PerfTuningPrimaryPath; - requires_captured_plan: boolean; - index_branch: PerfTuningBranchGuidance; - pipeline_branch: PerfTuningBranchGuidance; -} - -export interface PerfTuningSummary { - headline: string; - evidence: string[]; - next_step: string; -} - -interface DiscoveredPerfQuerySpecMetadata { - specId: string; - specFile: string; - expectedScale?: PerfExpectedScale; - expectedInputRows?: number; - expectedOutputRows?: number; -} - -export interface PerfBenchmarkReport { - schema_version: 1; - command: 'perf run'; - run_id?: string; - label?: string; - query_file: string; - query_type: 'SELECT'; - params_file?: string; - params_shape: PlaceholderMode; - ordered_param_names: string[]; - source_sql_file: string; - source_sql: string; - bound_sql: string; - bindings: unknown[] | Record | undefined; - strategy: PerfExecutionStrategy; - strategy_metadata?: PerfStrategyMetadata; - requested_mode: PerfBenchmarkMode; - selected_mode: PerfSelectedBenchmarkMode; - selection_reason: string; - classify_threshold_ms: number; - timeout_ms: number; - database_version?: string; - dry_run: boolean; - saved: boolean; - evidence_dir?: string; - classification_probe?: PerfClassificationProbe; - total_elapsed_ms?: number; - latency_metrics?: { - measured_runs: number; - warmup_runs: number; - min_ms: number; - max_ms: number; - avg_ms: number; - median_ms: number; - p95_ms: number; - }; - completion_metrics?: { - completed: boolean; - timed_out: boolean; - wall_time_ms: number; - }; - executed_statements: PerfStatementReport[]; - plan_summary?: PerfPlanSummary | null; - plan_observations: string[]; - recommended_actions: PerfRecommendedAction[]; - pipeline_analysis: PerfPipelineAnalysis; - spec_guidance?: PerfQuerySpecGuidance; - ddl_inventory?: PerfDdlInventorySummary; - tuning_guidance?: PerfTuningGuidance; - tuning_summary?: PerfTuningSummary; - seed?: Pick; -} - -export interface PerfDiffReport { - schema_version: 1; - command: 'perf report diff'; - baseline_run_id?: string; - candidate_run_id?: string; - baseline_mode: PerfSelectedBenchmarkMode; - candidate_mode: PerfSelectedBenchmarkMode; - baseline_strategy: PerfExecutionStrategy; - candidate_strategy: PerfExecutionStrategy; - primary_metric: { - name: 'p95_ms' | 'wall_time_ms' | 'total_elapsed_ms'; - baseline: number; - candidate: number; - improvement_percent: number; - }; - mode_changed: boolean; - strategy_changed: boolean; - statements_delta: number; - statement_deltas: PerfStatementDelta[]; - plan_deltas: PerfPlanDelta[]; - notes: string[]; -} - -interface PreparedBenchmarkQuery { - absolutePath: string; - sourceSql: string; - boundSql: string; - queryType: 'SELECT'; - paramsShape: PlaceholderMode; - orderedParamNames: string[]; - bindings: unknown[] | Record | undefined; - runtimeBindings: unknown[] | Record | undefined; -} - -interface StatementExecutionTrace { - role: PerfStatementRole; - target: string; - sql: string; - bindings: unknown[] | Record | undefined; - resolvedSqlPreview?: string; - elapsedMs: number; - rowCount?: number; - timedOut: boolean; - planJson?: unknown | null; -} - -interface BenchmarkExecutionResult { - elapsedMs: number; - rowCount?: number; - timedOut: boolean; - statements: StatementExecutionTrace[]; - finalPlanJson?: unknown | null; - strategyMetadata?: PerfStrategyMetadata; -} - -interface PerfSelectionResult { - selectedMode: PerfSelectedBenchmarkMode; - reason: string; - probe?: BenchmarkExecutionResult; -} - -const DEFAULT_REPEAT = 10; -const DEFAULT_WARMUP = 3; -const DEFAULT_CLASSIFY_THRESHOLD_SECONDS = 60; -const DEFAULT_TIMEOUT_MINUTES = 5; - -function assertValidPerfRunOptions(options: PerfRunOptions): void { - const issues: string[] = []; - - if (!Number.isInteger(options.repeat) || options.repeat <= 0) { - issues.push('repeat must be a positive integer'); - } - if (!Number.isInteger(options.warmup) || options.warmup < 0) { - issues.push('warmup must be a non-negative integer'); - } - if (!Number.isFinite(options.timeoutMinutes) || options.timeoutMinutes <= 0) { - issues.push('timeoutMinutes must be greater than 0'); - } - if (!Number.isFinite(options.classifyThresholdSeconds) || options.classifyThresholdSeconds <= 0) { - issues.push('classifyThresholdSeconds must be greater than 0'); - } - - if (issues.length > 0) { - throw new Error('invalid perf options: ' + issues.join('; ')); - } -} - -const PERF_REVIEW_POLICY_BY_SCALE: Record = { - tiny: 'none', - small: 'none', - medium: 'recommended', - large: 'strongly-recommended', - batch: 'strongly-recommended' -}; - -const PERF_REVIEW_POLICY_SEVERITY: Record = { - none: 0, - recommended: 1, - 'strongly-recommended': 2 -}; - -const PERF_SPEC_DISCOVERY_ROOTS = [ - path.join('src', 'catalog', 'specs'), - path.join('src', 'specs'), - 'specs' -]; - -function buildPerfQuerySpecGuidance( - rootDir: string, - queryFile: string, - seedConfig: PerfSeedConfig, - saveEvidence: boolean, - dryRun: boolean -): PerfQuerySpecGuidance | undefined { - const discovered = discoverPerfQuerySpecMetadata(rootDir, queryFile); - if (!discovered) { - return undefined; - } - - const reviewPolicy = toPerfReviewPolicy(discovered.expectedScale, discovered.expectedInputRows); - const relationsUsed = buildQueryStructureReport(queryFile, 'ztd perf run').referenced_tables; - const fixtureRowsAvailable = countPerfSeedRows(seedConfig, relationsUsed); - const fixtureRowsStatus = toPerfFixtureRowsStatus(fixtureRowsAvailable, discovered.expectedInputRows); - const evidenceStatus = saveEvidence && !dryRun - ? 'captured' - : reviewPolicy === 'strongly-recommended' ? 'missing' : 'not-required'; - - return { - spec_id: discovered.specId, - spec_file: discovered.specFile, - expected_scale: discovered.expectedScale, - expected_input_rows: discovered.expectedInputRows, - expected_output_rows: discovered.expectedOutputRows, - review_policy: reviewPolicy, - evidence_status: evidenceStatus, - fixture_rows_available: fixtureRowsAvailable, - fixture_rows_status: fixtureRowsStatus - }; -} - -function toPerfReviewPolicy( - expectedScale: PerfExpectedScale | undefined, - expectedInputRows: number | undefined -): PerfReviewPolicy { - const scalePolicy = expectedScale ? PERF_REVIEW_POLICY_BY_SCALE[expectedScale] : 'none'; - const rowsPolicy = expectedInputRows === undefined - ? 'none' - : expectedInputRows >= 100_000 - ? 'strongly-recommended' - : expectedInputRows >= 10_000 - ? 'recommended' - : 'none'; - - return PERF_REVIEW_POLICY_SEVERITY[scalePolicy] >= PERF_REVIEW_POLICY_SEVERITY[rowsPolicy] - ? scalePolicy - : rowsPolicy; -} - -function toPerfFixtureRowsStatus( - fixtureRowsAvailable: number | undefined, - expectedInputRows: number | undefined -): PerfFixtureRowsStatus { - if (expectedInputRows === undefined || fixtureRowsAvailable === undefined) { - return 'unknown'; - } - return fixtureRowsAvailable >= expectedInputRows ? 'sufficient' : 'undersized'; -} - -function countPerfSeedRows( - seedConfig: PerfSeedConfig, - relationsUsed?: readonly string[] -): number | undefined { - const normalizedRelations = normalizePerfRelationNames(relationsUsed); - if (normalizedRelations.size === 0) { - return undefined; - } - - let matched = false; - let rows = 0; - for (const [tableName, tableSeed] of Object.entries(seedConfig.tables)) { - if (!matchesPerfSeedRelation(tableName, normalizedRelations)) { - continue; - } - matched = true; - rows += tableSeed.rows; - } - - return matched ? rows : undefined; -} - -function discoverPerfQuerySpecMetadata( - rootDir: string, - queryFile: string -): DiscoveredPerfQuerySpecMetadata | undefined { - const queryCandidates = buildPerfQuerySpecSqlCandidates(rootDir, queryFile); - const matches: DiscoveredPerfQuerySpecMetadata[] = []; - for (const relativeRoot of PERF_SPEC_DISCOVERY_ROOTS) { - const absoluteRoot = path.resolve(rootDir, relativeRoot); - if (!existsSync(absoluteRoot)) { - continue; - } - - for (const filePath of walkPerfSpecFiles(absoluteRoot)) { - matches.push(...loadPerfQuerySpecMetadataFromFile(filePath, queryCandidates)); - } - } - - if (matches.length <= 1) { - return matches[0]; - } - - // Fail loudly when multiple specs claim the same SQL file so perf guidance stays deterministic. - throw new Error(`Multiple QuerySpecs matched ${path.resolve(queryFile)}: ${matches.map((match) => `${match.specId} (${match.specFile})`).join(', ')}`); -} - -function buildPerfQuerySpecSqlCandidates(rootDir: string, queryFile: string): Set { - const absoluteQueryFile = path.resolve(queryFile); - const candidates = [ - path.relative(rootDir, absoluteQueryFile), - path.relative(path.resolve(rootDir, 'src', 'sql'), absoluteQueryFile), - path.relative(path.resolve(rootDir, 'sql'), absoluteQueryFile) - ].map((value) => normalizePerfSpecPath(value)) - .filter((value) => value.length > 0 && !value.startsWith('..')); - - return new Set(candidates); -} - -function normalizePerfSpecPath(value: string): string { - return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, ''); -} - -function walkPerfSpecFiles(rootDir: string): string[] { - const files: string[] = []; - const stack = [rootDir]; - while (stack.length > 0) { - const current = stack.pop()!; - const entries = readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(absolute); - continue; - } - if (!entry.isFile()) { - continue; - } - if (/\.(?:json|[cm]?[jt]s)$/iu.test(entry.name) && !/\.test\./iu.test(entry.name)) { - files.push(absolute); - } - } - } - return files.sort((left, right) => left.localeCompare(right)); -} - -function loadPerfQuerySpecMetadataFromFile( - filePath: string, - queryCandidates: Set -): DiscoveredPerfQuerySpecMetadata[] { - if (path.extname(filePath).toLowerCase() === '.json') { - return loadPerfQuerySpecMetadataFromJson(filePath, queryCandidates); - } - - const source = readFileSync(filePath, 'utf8'); - const discovered: DiscoveredPerfQuerySpecMetadata[] = []; - for (const block of extractPerfQuerySpecBlocks(source)) { - const parsed = parsePerfQuerySpecBlock(block, filePath, queryCandidates); - if (parsed) { - discovered.push(parsed); - } - } - - return discovered; -} - -function loadPerfQuerySpecMetadataFromJson( - filePath: string, - queryCandidates: Set -): DiscoveredPerfQuerySpecMetadata[] { - const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; - if (Array.isArray(parsed)) { - const matches: DiscoveredPerfQuerySpecMetadata[] = []; - for (const entry of parsed) { - const discovered = parsePerfQuerySpecObject(entry, filePath, queryCandidates); - if (discovered) { - matches.push(discovered); - } - } - return matches; - } - if (typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { specs?: unknown[] }).specs)) { - const matches: DiscoveredPerfQuerySpecMetadata[] = []; - for (const entry of (parsed as { specs: unknown[] }).specs) { - const discovered = parsePerfQuerySpecObject(entry, filePath, queryCandidates); - if (discovered) { - matches.push(discovered); - } - } - return matches; - } - const discovered = parsePerfQuerySpecObject(parsed, filePath, queryCandidates); - return discovered ? [discovered] : []; -} - -function parsePerfQuerySpecObject( - value: unknown, - filePath: string, - queryCandidates: Set -): DiscoveredPerfQuerySpecMetadata | undefined { - if (typeof value !== 'object' || value === null) { - return undefined; - } - const record = value as Record; - const sqlFile = typeof record.sqlFile === 'string' ? record.sqlFile : undefined; - if (!sqlFile || !matchesPerfQuerySpecSqlFile(queryCandidates, sqlFile)) { - return undefined; - } - const metadata = typeof record.metadata === 'object' && record.metadata !== null - ? record.metadata as Record - : undefined; - const perf = metadata && typeof metadata.perf === 'object' && metadata.perf !== null - ? metadata.perf as Record - : undefined; - const expectedScale = normalizePerfExpectedScale(perf?.expectedScale ?? perf?.expected_scale); - const expectedInputRows = normalizePerfMetadataNumber(perf?.expectedInputRows ?? perf?.expected_input_rows); - const expectedOutputRows = normalizePerfMetadataNumber(perf?.expectedOutputRows ?? perf?.expected_output_rows); - if (!expectedScale && expectedInputRows === undefined && expectedOutputRows === undefined) { - return undefined; - } - return { - specId: typeof record.id === 'string' ? record.id : normalizePerfSpecPath(sqlFile), - specFile: filePath, - expectedScale, - expectedInputRows, - expectedOutputRows - }; -} - -function extractPerfQuerySpecBlocks(source: string): string[] { - const blocks: string[] = []; - const seen = new Set(); - const sqlFileRegex = /sqlFile\s*:\s*['"`][^'"`\n]+['"`]/g; - - for (const match of source.matchAll(sqlFileRegex)) { - if (typeof match.index !== 'number') { - continue; - } - const start = source.lastIndexOf('{', match.index); - if (start < 0) { - continue; - } - let depth = 0; - let end = -1; - for (let index = start; index < source.length; index += 1) { - const char = source[index]; - if (char === '{') { - depth += 1; - } else if (char === '}') { - depth -= 1; - if (depth === 0) { - end = index; - break; - } - } - } - if (end < 0) { - continue; - } - const block = source.slice(start, end + 1); - if (!seen.has(block)) { - seen.add(block); - blocks.push(block); - } - } - - return blocks; -} - -function parsePerfQuerySpecBlock( - block: string, - filePath: string, - queryCandidates: Set -): DiscoveredPerfQuerySpecMetadata | undefined { - const sqlFile = block.match(/sqlFile\s*:\s*['"`]([^'"`\n]+)['"`]/u)?.[1]; - if (!sqlFile || !matchesPerfQuerySpecSqlFile(queryCandidates, sqlFile)) { - return undefined; - } - const expectedScale = parsePerfExpectedScale(block); - const expectedInputRows = parsePerfMetadataNumber(block, ['expectedInputRows', 'expected_input_rows']); - const expectedOutputRows = parsePerfMetadataNumber(block, ['expectedOutputRows', 'expected_output_rows']); - if (!expectedScale && expectedInputRows === undefined && expectedOutputRows === undefined) { - return undefined; - } - const specId = block.match(/id\s*:\s*['"`]([^'"`\n]+)['"`]/u)?.[1] ?? normalizePerfSpecPath(sqlFile); - return { - specId, - specFile: filePath, - expectedScale, - expectedInputRows, - expectedOutputRows - }; -} - -function matchesPerfQuerySpecSqlFile(queryCandidates: Set, sqlFile: string): boolean { - const normalizedSpecSqlFile = normalizePerfSpecPath(sqlFile); - return queryCandidates.has(normalizedSpecSqlFile); -} - -function parsePerfExpectedScale(block: string): PerfExpectedScale | undefined { - const match = block.match(/expected(?:Scale|_scale)\s*:\s*['"`](tiny|small|medium|large|batch)['"`]/u); - return normalizePerfExpectedScale(match?.[1]); -} - -function parsePerfMetadataNumber(block: string, keys: string[]): number | undefined { - for (const key of keys) { - const escapedKey = key.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); - const match = block.match(new RegExp(`${escapedKey}\\s*:\\s*(\\d+(?:_\\d+)*(?:\\.\\d+)?)`, 'u')); - const parsed = normalizePerfMetadataNumber(match?.[1]); - if (parsed !== undefined) { - return parsed; - } - } - return undefined; -} - -function normalizePerfExpectedScale(value: unknown): PerfExpectedScale | undefined { - return value === 'tiny' || value === 'small' || value === 'medium' || value === 'large' || value === 'batch' - ? value - : undefined; -} - -function normalizePerfMetadataNumber(value: unknown): number | undefined { - if (typeof value === 'number') { - return Number.isFinite(value) && value >= 0 ? value : undefined; - } - if (typeof value === 'string') { - const parsed = Number(value.replace(/_/g, '')); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; - } - return undefined; -} - -function normalizePerfRelationNames(relationsUsed?: readonly string[]): Set { - const normalized = new Set(); - for (const relation of relationsUsed ?? []) { - const relationName = normalizePerfRelationName(relation); - if (!relationName) { - continue; - } - normalized.add(relationName); - - const unqualified = relationName.split('.').at(-1); - if (unqualified) { - normalized.add(unqualified); - } - } - return normalized; -} - -function matchesPerfSeedRelation(tableName: string, relationsUsed: Set): boolean { - const normalizedTableName = normalizePerfRelationName(tableName); - if (!normalizedTableName) { - return false; - } - - if (relationsUsed.has(normalizedTableName)) { - return true; - } - - const unqualified = normalizedTableName.split('.').at(-1); - return Boolean(unqualified && relationsUsed.has(unqualified)); -} - -function normalizePerfRelationName(value: string): string { - return value.trim().replace(/^"+|"+$/g, '').toLowerCase(); -} - -/** - * Execute or plan a perf benchmark against the sandbox using either direct or decomposed execution. - */ -export async function runPerfBenchmark(options: PerfRunOptions): Promise { - const strategy = options.strategy ?? 'direct'; - const material = options.material ?? []; - assertValidPerfRunOptions({ ...options, strategy, material }); - - const prepared = prepareBenchmarkQuery(options.rootDir, options.queryFile, options.paramsFile); - const pipelineAnalysis = buildPerfPipelineAnalysis(prepared.absolutePath); - const strategyMetadata = buildRequestedStrategyMetadata(prepared.absolutePath, strategy, material); - const classifyThresholdMs = options.classifyThresholdSeconds * 1000; - const timeoutMs = options.timeoutMinutes * 60 * 1000; - const seedConfig = loadPerfSeedConfig(options.rootDir); - const specGuidance = buildPerfQuerySpecGuidance(options.rootDir, prepared.absolutePath, seedConfig, options.save, options.dryRun); - const ddlInventory = inspectPerfDdlInventory(options.rootDir); - const ddlInventorySummary = summarizePerfDdlInventory(ddlInventory); - const databaseVersion = options.dryRun ? undefined : await fetchPerfDatabaseVersion(options.rootDir); - - const selection: PerfSelectionResult = options.dryRun - ? { - selectedMode: options.mode === 'auto' ? 'completion' : options.mode, - reason: options.mode === 'auto' - ? 'dry-run skips live auto classification; the real run will pick latency or completion after a thresholded probe' - : 'mode forced by user' - } - : options.mode === 'auto' - ? await classifyPerfBenchmarkMode(options.rootDir, prepared, strategy, material, classifyThresholdMs) - : { - selectedMode: options.mode, - reason: 'mode forced by user' - }; - - if (options.dryRun) { - const dryRunPlanFacts: PerfPlanFacts = { - observations: [], - statement_summary: '(no plan captured)', - hasCapturedPlan: false, - hasSequentialScan: false, - hasJoin: false - }; - const tuningGuidance = buildPerfTuningGuidance(pipelineAnalysis, dryRunPlanFacts, specGuidance); - return { - schema_version: 1, - command: 'perf run', - query_file: prepared.absolutePath, - query_type: prepared.queryType, - params_file: options.paramsFile ? path.resolve(options.rootDir, options.paramsFile) : undefined, - params_shape: prepared.paramsShape, - ordered_param_names: prepared.orderedParamNames, - source_sql_file: prepared.absolutePath, - source_sql: prepared.sourceSql, - bound_sql: prepared.boundSql, - bindings: prepared.bindings, - strategy: strategy, - strategy_metadata: strategyMetadata, - requested_mode: options.mode, - selected_mode: selection.selectedMode, - selection_reason: selection.reason, - classify_threshold_ms: classifyThresholdMs, - timeout_ms: timeoutMs, - database_version: databaseVersion, - dry_run: true, - saved: false, - classification_probe: selection.probe ? toPerfClassificationProbe(selection.probe) : undefined, - executed_statements: buildDryRunStatements(prepared, strategy, strategyMetadata), - plan_observations: [], - recommended_actions: buildPerfRecommendedActions(selection.selectedMode, true, pipelineAnalysis, dryRunPlanFacts, specGuidance), - pipeline_analysis: pipelineAnalysis, - spec_guidance: specGuidance, - ddl_inventory: ddlInventorySummary, - tuning_guidance: tuningGuidance, - tuning_summary: buildPerfTuningSummary(tuningGuidance), - seed: { seed: seedConfig.seed } - }; - } - - - const latencyRuns: number[] = []; - let representativeExecution: BenchmarkExecutionResult | undefined; - let classificationProbe = selection.probe ? toPerfClassificationProbe(selection.probe) : undefined; - - if (selection.selectedMode === 'latency') { - let remainingWarmups = options.warmup; - let remainingMeasuredRuns = options.repeat; - - // Reuse the auto-classification probe so the benchmark does not hide an extra live execution. - if (selection.probe) { - if (selection.probe.timedOut) { - throw new Error('Latency benchmark classification probe timed out unexpectedly. Re-run with --mode completion or a larger timeout.'); - } - if (remainingWarmups > 0) { - remainingWarmups -= 1; - classificationProbe = { - ...toPerfClassificationProbe(selection.probe), - reused_as_warmup: true - }; - } else if (remainingMeasuredRuns > 0) { - remainingMeasuredRuns -= 1; - latencyRuns.push(selection.probe.elapsedMs); - representativeExecution = selection.probe; - classificationProbe = { - ...toPerfClassificationProbe(selection.probe), - reused_as_measured_run: true - }; - } - } - - for (let index = 0; index < remainingWarmups; index += 1) { - const warmupExecution = await executePerfBenchmarkOnce(options.rootDir, prepared, strategy, material, timeoutMs, false); - if (warmupExecution.timedOut) { - throw new Error('Latency benchmark timed out during warmup. Re-run with --mode completion or a larger timeout.'); - } - } - - for (let index = 0; index < remainingMeasuredRuns; index += 1) { - const execution = await executePerfBenchmarkOnce( - options.rootDir, - prepared, - strategy, - material, - timeoutMs, - !representativeExecution - ); - if (execution.timedOut) { - throw new Error('Latency benchmark timed out during a measured run. Re-run with --mode completion or a larger timeout.'); - } - latencyRuns.push(execution.elapsedMs); - if (!representativeExecution) { - representativeExecution = execution; - } - } - } else { - if (selection.probe && !selection.probe.timedOut) { - representativeExecution = selection.probe; - classificationProbe = { - ...toPerfClassificationProbe(selection.probe), - reused_as_measured_run: true - }; - } else { - representativeExecution = await executePerfBenchmarkOnce(options.rootDir, prepared, strategy, material, timeoutMs, true); - } - } - - if (!representativeExecution) { - throw new Error('Perf benchmark did not produce a representative execution.'); - } - - const executedStatements = toPerfStatementReports(representativeExecution.statements); - const planFacts = buildPerfPlanFactsFromStatements(representativeExecution.statements); - const tuningGuidance = buildPerfTuningGuidance(pipelineAnalysis, planFacts, specGuidance); - const report: PerfBenchmarkReport = { - schema_version: 1, - command: 'perf run', - query_file: prepared.absolutePath, - query_type: prepared.queryType, - params_file: options.paramsFile ? path.resolve(options.rootDir, options.paramsFile) : undefined, - params_shape: prepared.paramsShape, - ordered_param_names: prepared.orderedParamNames, - source_sql_file: prepared.absolutePath, - source_sql: prepared.sourceSql, - bound_sql: prepared.boundSql, - bindings: prepared.bindings, - strategy: strategy, - strategy_metadata: representativeExecution.strategyMetadata ?? strategyMetadata, - requested_mode: options.mode, - selected_mode: selection.selectedMode, - selection_reason: selection.reason, - classify_threshold_ms: classifyThresholdMs, - timeout_ms: timeoutMs, - database_version: databaseVersion, - dry_run: false, - saved: false, - classification_probe: classificationProbe, - total_elapsed_ms: selection.selectedMode === 'latency' - ? latencyRuns.reduce((sum, value) => sum + value, 0) - : representativeExecution.elapsedMs, - latency_metrics: selection.selectedMode === 'latency' - ? buildLatencyMetrics(latencyRuns, options.warmup) - : undefined, - completion_metrics: selection.selectedMode === 'completion' - ? { - completed: !representativeExecution.timedOut, - timed_out: representativeExecution.timedOut, - wall_time_ms: representativeExecution.elapsedMs - } - : undefined, - executed_statements: executedStatements, - plan_summary: summarizePlanJson(representativeExecution.finalPlanJson), - plan_observations: planFacts.observations, - recommended_actions: buildPerfRecommendedActions( - selection.selectedMode, - !representativeExecution.timedOut, - pipelineAnalysis, - planFacts, - specGuidance - ), - pipeline_analysis: pipelineAnalysis, - spec_guidance: specGuidance, - ddl_inventory: ddlInventorySummary, - tuning_guidance: tuningGuidance, - tuning_summary: buildPerfTuningSummary(tuningGuidance), - seed: { seed: seedConfig.seed } - }; - - if (options.save) { - const persisted = savePerfBenchmarkEvidence(options.rootDir, report, representativeExecution.statements); - report.run_id = persisted.runId; - report.evidence_dir = persisted.evidenceDir; - report.saved = true; - report.executed_statements = report.executed_statements.map((statement, index) => ({ - ...statement, - sql_file: persisted.sqlFiles[index] || undefined, - resolved_sql_preview_file: persisted.resolvedSqlPreviewFiles[index] || undefined, - plan_file: persisted.planFiles[index] || undefined - })); - } - - return report; -} -function buildRequestedStrategyMetadata( - sqlFile: string, - strategy: PerfExecutionStrategy, - material: string[] -): PerfStrategyMetadata | undefined { - if (strategy !== 'decomposed') { - return undefined; - } - - const plan = buildQueryPipelinePlan(sqlFile, { material }); - return toPerfStrategyMetadata(plan); -} - -function buildDryRunStatements( - prepared: PreparedBenchmarkQuery, - strategy: PerfExecutionStrategy, - strategyMetadata: PerfStrategyMetadata | undefined -): PerfStatementReport[] { - if (strategy !== 'decomposed' || !strategyMetadata) { - return [ - { - seq: 1, - role: 'final-query', - target: 'FINAL_QUERY', - sql: prepared.boundSql, - bindings: prepared.bindings, - resolved_sql_preview: renderResolvedSqlPreview(prepared.boundSql, prepared.bindings) - } - ]; - } - - return strategyMetadata.planned_steps.map((step) => ({ - seq: step.step, - role: mapPipelineStepKindToRole(step.kind), - target: step.target, - sql: step.kind === 'materialize' - ? `create temp table "${step.target.replace(/"/g, '""')}" as -- resolved at runtime` - : prepared.boundSql, - bindings: prepared.bindings, - resolved_sql_preview: step.kind === 'materialize' - ? `materialize ${step.target} from ${path.basename(prepared.absolutePath)}` - : renderResolvedSqlPreview(prepared.boundSql, prepared.bindings) - })); -} - -async function executePerfBenchmarkOnce( - rootDir: string, - prepared: PreparedBenchmarkQuery, - strategy: PerfExecutionStrategy, - material: string[], - timeoutMs: number, - capturePlans: boolean -): Promise { - return strategy === 'decomposed' - ? executeDecomposedBenchmarkOnce(rootDir, prepared, material, timeoutMs, capturePlans) - : executeDirectBenchmarkDetailed(rootDir, prepared, timeoutMs, capturePlans); -} - -function preparePgStatementExecution( - sql: string, - params?: unknown[] | Record -): { sql: string; bindings: unknown[] | undefined; resolvedSqlPreview?: string } { - if (!params) { - return { sql, bindings: undefined, resolvedSqlPreview: undefined }; - } - - if (Array.isArray(params)) { - return { - sql, - bindings: params, - resolvedSqlPreview: renderResolvedSqlPreview(sql, params) - }; - } - - const scan = scanModelGenSql(sql); - if (scan.mode !== 'named') { - return { sql, bindings: undefined, resolvedSqlPreview: undefined }; - } - - const bound = bindModelGenNamedSql(sql); - const bindings = bound.orderedParamNames.map((name) => { - if (!(name in params)) { - throw new Error(`Missing named pipeline param: ${name}`); - } - return params[name]; - }); - - return { - sql: bound.boundSql, - bindings, - resolvedSqlPreview: renderResolvedSqlPreview(bound.boundSql, bindings) - }; -} -function buildExplainTargetSql(sql: string): string { - const match = /^create\s+temp\s+table\s+.+?\s+as\s+([\s\S]+)$/i.exec(sql.trim()); - return match?.[1]?.trim() || sql; -} - - -async function executeDirectBenchmarkDetailed( - rootDir: string, - prepared: PreparedBenchmarkQuery, - timeoutMs: number, - capturePlans: boolean -): Promise { - const pg = await ensurePgModule(); - const sandboxConfig = loadPerfSandboxConfig(rootDir); - const resolvedConnection = await ensurePerfConnection(rootDir, sandboxConfig); - const client = new pg.Client({ - connectionString: resolvedConnection.connectionUrl, - connectionTimeoutMillis: 3000 - }); - - let statementTrace: StatementExecutionTrace; - let finalPlanJson: unknown | null = null; - try { - await client.connect(); - await client.query(`SET statement_timeout = ${Math.max(1, Math.trunc(timeoutMs))}`); - - // Measure only live statement execution so connection/auth and plan capture do not pollute SQL latency. - const startedAt = nowMs(); - const result = await client.query(prepared.boundSql, prepared.bindings as unknown[] | undefined); - const elapsedMs = nowMs() - startedAt; - - if (capturePlans) { - finalPlanJson = await capturePlanWithConnectedClient( - client, - prepared.boundSql, - Array.isArray(prepared.bindings) ? prepared.bindings : undefined, - true, - timeoutMs - ); - } - - statementTrace = { - role: 'final-query', - target: 'FINAL_QUERY', - sql: prepared.boundSql, - bindings: prepared.bindings, - resolvedSqlPreview: renderResolvedSqlPreview(prepared.boundSql, prepared.bindings), - elapsedMs, - rowCount: extractRowCount(result as { rowCount?: number; rows?: unknown[] }), - timedOut: false, - planJson: finalPlanJson - }; - } catch (error) { - if (!isQueryTimeout(error)) { - throw error; - } - statementTrace = { - role: 'final-query', - target: 'FINAL_QUERY', - sql: prepared.boundSql, - bindings: prepared.bindings, - resolvedSqlPreview: renderResolvedSqlPreview(prepared.boundSql, prepared.bindings), - elapsedMs: timeoutMs, - timedOut: true - }; - } finally { - await client.end().catch(() => undefined); - } - - return { - elapsedMs: statementTrace.elapsedMs, - rowCount: statementTrace.rowCount, - timedOut: statementTrace.timedOut, - statements: [statementTrace], - finalPlanJson - }; -} - -async function executeDecomposedBenchmarkOnce( - rootDir: string, - prepared: PreparedBenchmarkQuery, - material: string[], - timeoutMs: number, - capturePlans: boolean -): Promise { - const pg = await ensurePgModule(); - const sandboxConfig = loadPerfSandboxConfig(rootDir); - const resolvedConnection = await ensurePerfConnection(rootDir, sandboxConfig); - const plan = buildQueryPipelinePlan(prepared.absolutePath, { material }); - const rawStatements: Array<{ - sql: string; - bindings: unknown[] | Record | undefined; - resolvedSqlPreview?: string; - elapsedMs: number; - rowCount?: number; - timedOut: boolean; - planJson?: unknown | null; - }> = []; - - const client = new pg.Client({ - connectionString: resolvedConnection.connectionUrl, - connectionTimeoutMillis: 3000 - }); - let totalElapsedMs = 0; - const sessionFactory: QueryPipelineSessionFactory = { - openSession: async (): Promise => ({ - query: async (sql: string, params?: unknown[] | Record) => { - const execution = preparePgStatementExecution(sql, params); - if (shouldSkipPerfStatementCapture(sql)) { - return client.query(execution.sql, execution.bindings) as Promise<{ rows: Record[]; rowCount?: number }>; - } - - const remainingMs = Math.max(1, Math.trunc(timeoutMs - totalElapsedMs)); - await client.query(`SET statement_timeout = ${remainingMs}`); - - // Track total elapsed time across decomposed statements while keeping per-statement timings separate. - const startedAt = nowMs(); - try { - const result = await client.query(execution.sql, execution.bindings); - const elapsedMs = nowMs() - startedAt; - totalElapsedMs += elapsedMs; - const planJson = capturePlans - ? await capturePlanWithConnectedClient( - client, - buildExplainTargetSql(execution.sql), - execution.bindings, - false, - remainingMs - ) - : null; - rawStatements.push({ - sql: execution.sql, - bindings: execution.bindings, - resolvedSqlPreview: execution.resolvedSqlPreview, - elapsedMs, - rowCount: extractRowCount(result as { rowCount?: number; rows?: unknown[] }), - timedOut: false, - planJson - }); - return result as { rows: Record[]; rowCount?: number }; - } catch (error) { - if (!isQueryTimeout(error)) { - throw error; - } - const elapsedMs = Math.min(timeoutMs, Math.max(remainingMs, nowMs() - startedAt)); - totalElapsedMs += elapsedMs; - rawStatements.push({ - sql: execution.sql, - bindings: execution.bindings, - resolvedSqlPreview: execution.resolvedSqlPreview, - elapsedMs, - timedOut: true - }); - throw error; - } - }, - end: async () => undefined - }) - }; - - try { - await client.connect(); - const pipelineResult = await executeQueryPipeline(sessionFactory, { - sqlFile: prepared.absolutePath, - metadata: { material }, - params: prepared.runtimeBindings - }); - const statements = mapPipelineStatements(rawStatements, toPerfPlannedSteps(pipelineResult.steps)); - const finalStatement = statements.find((statement) => statement.role === 'final-query'); - return { - elapsedMs: totalElapsedMs, - rowCount: pipelineResult.final.rowCount, - timedOut: false, - statements, - finalPlanJson: finalStatement?.planJson ?? null, - strategyMetadata: toPerfStrategyMetadata(plan) - }; - } catch (error) { - if (!isQueryTimeout(error)) { - throw error; - } - const statements = mapPipelineStatements(rawStatements, toPerfPlannedSteps(plan.steps.slice(0, rawStatements.length))); - const finalStatement = [...statements].reverse().find((statement) => statement.role === 'final-query'); - return { - elapsedMs: totalElapsedMs, - rowCount: finalStatement?.rowCount, - timedOut: true, - statements, - finalPlanJson: finalStatement?.planJson ?? null, - strategyMetadata: toPerfStrategyMetadata(plan) - }; - } finally { - await client.end().catch(() => undefined); - } -} - -async function capturePlanWithConnectedClient( - client: { query: (sql: string, params?: unknown[]) => Promise<{ rows?: Record[] }> }, - sql: string, - params: unknown[] | undefined, - analyze: boolean, - timeoutMs: number -): Promise { - const explainPrefix = analyze - ? 'EXPLAIN (ANALYZE TRUE, BUFFERS TRUE, FORMAT JSON) ' - : 'EXPLAIN (FORMAT JSON) '; - - try { - await client.query(`SET statement_timeout = ${Math.max(1, Math.trunc(timeoutMs))}`); - const result = await client.query(`${explainPrefix}${sql}`, params as unknown[] | undefined); - const firstRow = (result.rows?.[0] ?? {}) as Record; - return firstRow['QUERY PLAN'] ?? firstRow['query plan'] ?? null; - } catch (error) { - if (isQueryTimeout(error)) { - return null; - } - throw error; - } -} - -export function toPerfPlannedSteps( - steps: Array<{ kind: PerfStatementRole | QueryPipelineStep['kind']; target: string }> -): Array<{ kind: PerfStatementRole; target: string }> { - return steps - // scalar-filter-bind is emitted by execution tracing, not QueryPipelinePlan metadata. - .filter((step) => step.kind !== 'scalar-filter-bind') - .map((step) => ({ - kind: mapPipelineStepKindToRole(step.kind as QueryPipelineStep['kind']), - target: step.target - })); -} - -export function mapPipelineStatements( - statements: Array<{ - sql: string; - bindings: unknown[] | Record | undefined; - resolvedSqlPreview?: string; - elapsedMs: number; - rowCount?: number; - timedOut: boolean; - planJson?: unknown | null; - }>, - plannedSteps?: Array<{ kind: PerfStatementRole; target: string }> -): StatementExecutionTrace[] { - return statements.map((statement, index) => ({ - role: plannedSteps?.[index]?.kind ?? (index === statements.length - 1 ? 'final-query' : 'materialize'), - target: plannedSteps?.[index]?.target ?? (index === statements.length - 1 ? 'FINAL_QUERY' : `stage_${index + 1}`), - sql: statement.sql, - bindings: statement.bindings, - resolvedSqlPreview: statement.resolvedSqlPreview, - elapsedMs: statement.elapsedMs, - rowCount: statement.rowCount, - timedOut: statement.timedOut, - planJson: statement.planJson ?? null - })); -} - -function mapPipelineStepKindToRole(kind: QueryPipelineStep['kind']): PerfStatementRole { - return kind === 'materialize' ? 'materialize' : 'final-query'; -} - -function shouldSkipPerfStatementCapture(sql: string): boolean { - return /^\s*drop\s+table\s+if\s+exists\b/i.test(sql); -} - -function toPerfStrategyMetadata(plan: ReturnType): PerfStrategyMetadata { - return { - materialized_ctes: [...plan.metadata.material], - scalar_filter_columns: [...plan.metadata.scalarFilterColumns], - planned_steps: plan.steps.map((step) => ({ - step: step.step, - kind: step.kind, - target: step.target, - depends_on: [...step.depends_on] - })) - }; -} - -function toPerfStatementReports(statements: StatementExecutionTrace[]): PerfStatementReport[] { - return statements.map((statement, index) => ({ - seq: index + 1, - role: statement.role, - target: statement.target, - sql: statement.sql, - bindings: statement.bindings, - resolved_sql_preview: statement.resolvedSqlPreview, - row_count: statement.rowCount, - elapsed_ms: statement.elapsedMs, - timed_out: statement.timedOut, - plan_summary: summarizePlanJson(statement.planJson) - })); -} - -function buildPerfPlanFactsFromStatements(statements: StatementExecutionTrace[]): PerfPlanFacts { - const observations: string[] = []; - const summaries: string[] = []; - let hasCapturedPlan = false; - let hasSequentialScan = false; - let hasJoin = false; - - for (const statement of statements) { - const facts = buildPerfPlanFacts(statement.planJson ?? null); - const prefix = `${statement.role}(${statement.target})`; - summaries.push(`${prefix}: ${facts.statement_summary}`); - observations.push(...facts.observations.map((observation) => `${prefix}: ${observation}`)); - hasCapturedPlan = hasCapturedPlan || facts.hasCapturedPlan; - hasSequentialScan = hasSequentialScan || facts.hasSequentialScan; - hasJoin = hasJoin || facts.hasJoin; - } - - return { - observations: Array.from(new Set(observations)), - statement_summary: summaries.join(' | ') || '(no plan captured)', - hasCapturedPlan, - hasSequentialScan, - hasJoin - }; -} -/** - * Compare two saved benchmark evidence directories for AI-friendly tuning decisions. - */ -export function diffPerfBenchmarkReports(baselineDir: string, candidateDir: string): PerfDiffReport { - const baseline = loadPerfBenchmarkReport(baselineDir); - const candidate = loadPerfBenchmarkReport(candidateDir); - const notes: string[] = []; - const modeChanged = baseline.selected_mode !== candidate.selected_mode; - const strategyChanged = baseline.strategy !== candidate.strategy; - const statementsDelta = candidate.executed_statements.length - baseline.executed_statements.length; - const statementDeltas = buildPerfStatementDeltas(baseline, candidate); - const planDeltas = buildPerfPlanDeltas(baseline, candidate); - - let metricName: 'p95_ms' | 'wall_time_ms' | 'total_elapsed_ms' = 'total_elapsed_ms'; - let baselineMetric = baseline.total_elapsed_ms ?? 0; - let candidateMetric = candidate.total_elapsed_ms ?? 0; - - if (baseline.selected_mode === 'latency' && candidate.selected_mode === 'latency') { - metricName = 'p95_ms'; - baselineMetric = baseline.latency_metrics?.p95_ms ?? baseline.total_elapsed_ms ?? 0; - candidateMetric = candidate.latency_metrics?.p95_ms ?? candidate.total_elapsed_ms ?? 0; - notes.push('Compared latency-mode p95 because both runs are repeat benchmarks.'); - } else if (baseline.selected_mode === 'completion' && candidate.selected_mode === 'completion') { - metricName = 'wall_time_ms'; - baselineMetric = baseline.completion_metrics?.wall_time_ms ?? baseline.total_elapsed_ms ?? 0; - candidateMetric = candidate.completion_metrics?.wall_time_ms ?? candidate.total_elapsed_ms ?? 0; - notes.push('Compared completion wall time because both runs are long-running benchmarks.'); - } else { - notes.push('Modes differ, so diff falls back to total elapsed time instead of p95.'); - } - - if (modeChanged) { - notes.push(`Mode changed from ${baseline.selected_mode} to ${candidate.selected_mode}.`); - } - if (strategyChanged) { - notes.push(`Strategy changed from ${baseline.strategy} to ${candidate.strategy}.`); - } - if (baseline.pipeline_analysis.should_consider_pipeline || candidate.pipeline_analysis.should_consider_pipeline) { - notes.push('Pipeline candidacy is present in at least one run; inspect candidate_ctes before rewriting SQL.'); - } - if ((baseline.pipeline_analysis.scalar_filter_candidates?.length ?? 0) > 0 || (candidate.pipeline_analysis.scalar_filter_candidates?.length ?? 0) > 0) { - notes.push('Scalar filter binding candidates are present; inspect scalar_filter_candidates before keeping optimizer-sensitive subqueries inline.'); - } - if (baseline.database_version && candidate.database_version && baseline.database_version !== candidate.database_version) { - notes.push(`Database version changed from ${baseline.database_version} to ${candidate.database_version}.`); - } - const candidateActions = candidate.recommended_actions ?? []; - if (candidateActions.length > 0) { - notes.push('Candidate recommended actions: ' + candidateActions.map((action) => action.action).join(', ')); - } - - return { - schema_version: 1, - command: 'perf report diff', - baseline_run_id: baseline.run_id, - candidate_run_id: candidate.run_id, - baseline_mode: baseline.selected_mode, - candidate_mode: candidate.selected_mode, - baseline_strategy: baseline.strategy, - candidate_strategy: candidate.strategy, - primary_metric: { - name: metricName, - baseline: baselineMetric, - candidate: candidateMetric, - improvement_percent: calculateImprovementPercent(baselineMetric, candidateMetric) - }, - mode_changed: modeChanged, - strategy_changed: strategyChanged, - statements_delta: statementsDelta, - statement_deltas: statementDeltas, - plan_deltas: planDeltas, - notes - }; -} -/** - * Render a benchmark report in either text or JSON for humans and agents. - */ -export function formatPerfBenchmarkReport(report: PerfBenchmarkReport, format: PerfBenchmarkFormat): string { - if (format === 'json') { - return `${JSON.stringify(report, null, 2)}\n`; - } - - const lines = [ - `Query: ${report.query_file}`, - `Mode: ${report.selected_mode} (requested: ${report.requested_mode})`, - `Selection: ${report.selection_reason}`, - `Strategy: ${report.strategy}`, - `Timeout: ${Math.round(report.timeout_ms / 1000)}s`, - `Statements: ${report.executed_statements.length}`, - ]; - - if (report.latency_metrics) { - lines.push(`Measured runs: ${report.latency_metrics.measured_runs}`); - lines.push(`avg: ${report.latency_metrics.avg_ms.toFixed(2)} ms`); - lines.push(`median: ${report.latency_metrics.median_ms.toFixed(2)} ms`); - lines.push(`p95: ${report.latency_metrics.p95_ms.toFixed(2)} ms`); - } - - if (report.completion_metrics) { - lines.push(`completed: ${report.completion_metrics.completed ? 'yes' : 'no'}`); - lines.push(`timed_out: ${report.completion_metrics.timed_out ? 'yes' : 'no'}`); - lines.push(`wall_time: ${report.completion_metrics.wall_time_ms.toFixed(2)} ms`); - } - - if (report.classification_probe) { - const probeSuffix = report.classification_probe.timed_out ? ' (timed out)' : ''; - lines.push(`Classification probe: ${report.classification_probe.elapsed_ms.toFixed(2)} ms${probeSuffix}`); - } - - - if (report.tuning_summary) { - lines.push(`Decision summary: ${report.tuning_summary.headline}`); - for (const evidence of report.tuning_summary.evidence) { - lines.push(` evidence: ${evidence}`); - } - lines.push(` next: ${report.tuning_summary.next_step}`); - } - lines.push(''); - if (report.spec_guidance) { - lines.push(''); - lines.push('Query spec guidance:'); - lines.push(` spec_id: ${report.spec_guidance.spec_id}`); - lines.push(` spec_file: ${report.spec_guidance.spec_file}`); - lines.push(` expected_scale: ${report.spec_guidance.expected_scale ?? '(unspecified)'}`); - lines.push(` review_policy: ${report.spec_guidance.review_policy}`); - lines.push(` evidence_status: ${report.spec_guidance.evidence_status}`); - lines.push(` fixture_rows_available: ${report.spec_guidance.fixture_rows_available ?? '(unknown)'}`); - lines.push(` fixture_rows_status: ${report.spec_guidance.fixture_rows_status}`); - if (report.spec_guidance.expected_input_rows !== undefined) { - lines.push(` expected_input_rows: ${report.spec_guidance.expected_input_rows}`); - } - if (report.spec_guidance.expected_output_rows !== undefined) { - lines.push(` expected_output_rows: ${report.spec_guidance.expected_output_rows}`); - } - lines.push(''); - } - if (report.ddl_inventory) { - lines.push('DDL inventory:'); - lines.push(` ddl_files: ${report.ddl_inventory.ddl_files}`); - lines.push(` ddl_statement_count: ${report.ddl_inventory.ddl_statement_count}`); - lines.push(` table_count: ${report.ddl_inventory.table_count}`); - lines.push(` index_count: ${report.ddl_inventory.index_count}`); - lines.push(` index_names: ${report.ddl_inventory.index_names.length > 0 ? report.ddl_inventory.index_names.join(', ') : '(none)'}`); - lines.push(''); - } - if (report.tuning_guidance) { - lines.push('Tuning guidance:'); - lines.push(` primary_path: ${report.tuning_guidance.primary_path}`); - lines.push(` requires_captured_plan: ${report.tuning_guidance.requires_captured_plan ? 'yes' : 'no'}`); - lines.push(` index_branch: ${report.tuning_guidance.index_branch.recommended ? 'recommended' : 'secondary'}`); - for (const rationale of report.tuning_guidance.index_branch.rationale) { - lines.push(` rationale: ${rationale}`); - } - for (const step of report.tuning_guidance.index_branch.next_steps) { - lines.push(` next: ${step}`); - } - lines.push(` pipeline_branch: ${report.tuning_guidance.pipeline_branch.recommended ? 'recommended' : 'secondary'}`); - for (const rationale of report.tuning_guidance.pipeline_branch.rationale) { - lines.push(` rationale: ${rationale}`); - } - for (const step of report.tuning_guidance.pipeline_branch.next_steps) { - lines.push(` next: ${step}`); - } - lines.push(''); - } - lines.push('Executed statements:'); - for (const statement of report.executed_statements) { - const statementLabel = statement.target ? `${statement.seq}. ${statement.role} (${statement.target})` : `${statement.seq}. ${statement.role}`; - lines.push(statementLabel); - lines.push(` elapsed_ms: ${statement.elapsed_ms !== undefined ? statement.elapsed_ms.toFixed(2) : '(n/a)'}`); - lines.push(` row_count: ${statement.row_count ?? '(n/a)'}`); - if (statement.resolved_sql_preview) { - lines.push(` resolved_sql_preview: ${truncateSingleLine(statement.resolved_sql_preview, 120)}`); - } - lines.push(` sql: ${truncateSingleLine(statement.sql, 120)}`); - if (statement.sql_file) { - lines.push(` sql_file: ${statement.sql_file}`); - } - if (statement.resolved_sql_preview_file) { - lines.push(` resolved_sql_preview_file: ${statement.resolved_sql_preview_file}`); - } - if (statement.plan_file) { - lines.push(` plan_file: ${statement.plan_file}`); - } - } - - lines.push(''); - lines.push('Pipeline analysis:'); - lines.push(` should_consider_pipeline: ${report.pipeline_analysis.should_consider_pipeline ? 'yes' : 'no'}`); - if (report.pipeline_analysis.candidate_ctes.length === 0) { - lines.push(' candidate_ctes: (none)'); - } else { - for (const candidate of report.pipeline_analysis.candidate_ctes) { - lines.push(` - ${candidate.name}: downstream references=${candidate.downstream_references}`); - } - } - lines.push(` scalar_filter_candidates: ${report.pipeline_analysis.scalar_filter_candidates.length > 0 ? report.pipeline_analysis.scalar_filter_candidates.join(', ') : '(none)'}`); - - if (report.strategy_metadata) { - lines.push(''); - lines.push('Strategy metadata:'); - lines.push(` materialized_ctes: ${report.strategy_metadata.materialized_ctes.length > 0 ? report.strategy_metadata.materialized_ctes.join(', ') : '(none)'}`); - lines.push(` scalar_filter_columns: ${report.strategy_metadata.scalar_filter_columns.length > 0 ? report.strategy_metadata.scalar_filter_columns.join(', ') : '(none)'}`); - for (const step of report.strategy_metadata.planned_steps) { - lines.push(` - step ${step.step}: ${step.kind} ${step.target} <= ${step.depends_on.length > 0 ? step.depends_on.join(', ') : '(root)'}`); - } - } - - if (report.plan_observations.length > 0) { - lines.push(''); - lines.push('Plan observations:'); - for (const observation of report.plan_observations) { - lines.push(`- ${observation}`); - } - } - - if (report.recommended_actions.length > 0) { - lines.push(''); - lines.push('Recommended actions:'); - for (const action of report.recommended_actions) { - lines.push(`- [${action.priority}] ${action.action}: ${action.rationale}`); - } - } - - if (report.evidence_dir) { - lines.push(''); - lines.push(`Evidence: ${report.evidence_dir}`); - } - - return `${lines.join('\n')}\n`; -} - -export function formatPerfDiffReport(report: PerfDiffReport, format: PerfBenchmarkFormat): string { - if (format === 'json') { - return `${JSON.stringify(report, null, 2)}\n`; - } - - const statementDeltas = report.statement_deltas ?? []; - - const lines = [ - `Baseline mode: ${report.baseline_mode}`, - `Candidate mode: ${report.candidate_mode}`, - `Baseline strategy: ${report.baseline_strategy}`, - `Candidate strategy: ${report.candidate_strategy}`, - `Primary metric: ${report.primary_metric.name}`, - `Baseline: ${report.primary_metric.baseline.toFixed(2)}`, - `Candidate: ${report.primary_metric.candidate.toFixed(2)}`, - `Improvement: ${report.primary_metric.improvement_percent.toFixed(2)}%`, - `Statements delta: ${report.statements_delta}`, - ]; - - if (statementDeltas.some((delta) => delta.elapsed_delta_ms !== undefined || delta.baseline_timed_out !== delta.candidate_timed_out)) { - lines.push(''); - lines.push('Statement deltas:'); - for (const delta of statementDeltas) { - const elapsed = delta.elapsed_delta_ms !== undefined - ? `${delta.elapsed_delta_ms >= 0 ? '+' : ''}${delta.elapsed_delta_ms.toFixed(2)} ms` - : '(n/a)'; - lines.push(`- ${delta.statement_id}: baseline=${delta.baseline_elapsed_ms ?? '(n/a)'} candidate=${delta.candidate_elapsed_ms ?? '(n/a)'} delta=${elapsed}`); - } - } - - if (report.plan_deltas.some((delta) => delta.changed)) { - lines.push(''); - lines.push('Plan deltas:'); - for (const delta of report.plan_deltas.filter((entry) => entry.changed)) { - lines.push(`- ${delta.statement_id}: ${delta.baseline_plan} -> ${delta.candidate_plan}`); - } - } - - if (report.notes.length > 0) { - lines.push(''); - lines.push('Notes:'); - for (const note of report.notes) { - lines.push(`- ${note}`); - } - } - - return `${lines.join('\n')}\n`; -} - -export function buildPerfPipelineAnalysis(sqlFile: string): PerfPipelineAnalysis { - const structure = buildQueryStructureReport(sqlFile, 'ztd perf run'); - const referenceCounts = new Map(structure.ctes.map((cte) => [cte.name, 0])); - for (const cte of structure.ctes) { - for (const dependency of cte.depends_on) { - referenceCounts.set(dependency, (referenceCounts.get(dependency) ?? 0) + 1); - } - } - for (const root of normalizeFinalQueryRoots(structure.final_query)) { - referenceCounts.set(root, (referenceCounts.get(root) ?? 0) + 1); - } - - const scalarFilterCandidates = findScalarFilterCandidates(sqlFile); - - const candidateCtes = structure.ctes - .map((cte) => { - const downstreamReferences = referenceCounts.get(cte.name) ?? 0; - const reasons: string[] = []; - if (downstreamReferences >= 2) { - reasons.push('referenced by multiple downstream consumers'); - } - if (!cte.unused && cte.depends_on.length >= 2) { - reasons.push('merges multiple upstream dependencies'); - } - return { - name: cte.name, - downstream_references: downstreamReferences, - reasons - }; - }) - .filter((candidate) => candidate.reasons.length > 0); - - const notes = [ - 'Pipeline candidacy is heuristic in this MVP and does not yet benchmark SqlSpec runtime materialization directly.' - ]; - if (scalarFilterCandidates.length > 0) { - notes.push(`Optimizer-sensitive scalar predicates detected on columns: ${scalarFilterCandidates.join(', ')}`); - } - if (structure.unused_ctes.length > 0) { - notes.push(`Unused CTEs detected: ${structure.unused_ctes.join(', ')}`); - } - - return { - query_type: structure.query_type, - cte_count: structure.cte_count, - should_consider_pipeline: candidateCtes.length > 0 || scalarFilterCandidates.length > 0, - candidate_ctes: candidateCtes, - scalar_filter_candidates: scalarFilterCandidates, - notes - }; -} - -/** - * Load a saved benchmark report from summary.json. - */ -export function summarizePerfDdlInventory(inventory: PerfDdlInventory): PerfDdlInventorySummary { - return { - ddl_files: inventory.files.length, - ddl_statement_count: inventory.ddlStatementCount, - table_count: inventory.tableCount, - index_count: inventory.indexCount, - index_names: [...inventory.indexNames] - }; -} - -export function buildPerfTuningGuidance( - pipelineAnalysis: PerfPipelineAnalysis, - planFacts: PerfPlanFacts, - specGuidance?: PerfQuerySpecGuidance -): PerfTuningGuidance { - const indexRationale: string[] = []; - const indexNextSteps = [ - 'Capture or review EXPLAIN (ANALYZE, BUFFERS) before changing the physical design.', - 'Append CREATE INDEX statements to db/ddl/*.sql instead of making ad-hoc sandbox-only changes.', - 'Run `ztd perf db reset` so the perf sandbox recreates both tables and indexes from local DDL.' - ]; - const pipelineRationale: string[] = []; - const pipelineNextSteps = [ - 'Review candidate_ctes and scalar_filter_candidates before rewriting the query.', - 'Use PIPELINE decomposition when the same intermediate result is reused or scalar filters are optimizer-sensitive.', - 'After SQL changes, rerun `ztd perf db reset` and `ztd perf seed` so the benchmark uses the intended physical schema.' - ]; - - const hasPipelineSignals = pipelineAnalysis.should_consider_pipeline - || pipelineAnalysis.candidate_ctes.length > 0 - || pipelineAnalysis.scalar_filter_candidates.length > 0; - const hasPlanSignals = planFacts.hasSequentialScan || planFacts.hasJoin; - - if (pipelineAnalysis.candidate_ctes.length > 0) { - pipelineRationale.push('Reusable intermediate stages detected: ' + pipelineAnalysis.candidate_ctes.map((candidate) => candidate.name).join(', ') + '.'); - } - if (pipelineAnalysis.scalar_filter_candidates.length > 0) { - pipelineRationale.push('Optimizer-sensitive scalar predicates detected: ' + pipelineAnalysis.scalar_filter_candidates.join(', ') + '.'); - } - if (planFacts.hasSequentialScan) { - indexRationale.push('Captured plan shows a sequential scan, so index coverage is the first physical-design branch to review.'); - } - if (planFacts.hasJoin) { - indexRationale.push('Captured plan shows join work, so supporting indexes or join-order changes may matter.'); - } - if (specGuidance?.review_policy === 'strongly-recommended') { - indexRationale.push('QuerySpec marks this query as performance-sensitive, so preserve physical design changes in local DDL before benchmarking.'); - } - - let primaryPath: PerfTuningPrimaryPath = 'capture-plan'; - if (hasPipelineSignals) { - primaryPath = 'pipeline'; - } - if (hasPlanSignals && !hasPipelineSignals) { - primaryPath = 'index'; - } - - if (!planFacts.hasCapturedPlan && indexRationale.length === 0) { - indexRationale.push('No captured plan is available yet, so confirm whether scans or joins are the real bottleneck before adding indexes.'); - } - if (!planFacts.hasCapturedPlan && pipelineRationale.length === 0) { - pipelineRationale.push('No pipeline-specific signal is available yet, so start by capturing a representative plan and row counts.'); - } - - return { - primary_path: primaryPath, - requires_captured_plan: !planFacts.hasCapturedPlan, - index_branch: { - recommended: primaryPath === 'index', - rationale: indexRationale, - next_steps: indexNextSteps - }, - pipeline_branch: { - recommended: primaryPath === 'pipeline', - rationale: pipelineRationale, - next_steps: pipelineNextSteps - } - }; -} -export function buildPerfTuningSummary(guidance: PerfTuningGuidance): PerfTuningSummary { - if (guidance.primary_path === 'index') { - return { - headline: 'Start with index tuning.', - evidence: guidance.index_branch.rationale.slice(0, 2), - next_step: guidance.index_branch.next_steps[0] ?? 'Review the captured plan before changing indexes.' - }; - } - - if (guidance.primary_path === 'pipeline') { - return { - headline: 'Start with pipeline tuning.', - evidence: guidance.pipeline_branch.rationale.slice(0, 2), - next_step: guidance.pipeline_branch.next_steps[0] ?? 'Review candidate_ctes before rewriting the query.' - }; - } - - return guidance.requires_captured_plan - ? { - headline: 'Capture a representative plan before choosing index or pipeline work.', - evidence: [ - 'No captured plan signal is available yet.', - ...guidance.index_branch.rationale.slice(0, 1) - ], - next_step: guidance.index_branch.next_steps[0] ?? 'Capture EXPLAIN (ANALYZE, BUFFERS) output first.' - } - : { - headline: 'A representative plan is already available; compare index and pipeline evidence next.', - evidence: guidance.index_branch.rationale.length > 0 - ? guidance.index_branch.rationale.slice(0, 2) - : ['A captured plan exists, but it does not yet isolate scans, joins, or pipeline hotspots.'], - next_step: guidance.index_branch.next_steps[0] ?? 'Review the captured plan before changing indexes.' - }; -} - -function buildPerfRecommendedActions( - selectedMode: PerfSelectedBenchmarkMode, - completed: boolean, - pipelineAnalysis: PerfPipelineAnalysis, - planFacts: PerfPlanFacts, - specGuidance?: PerfQuerySpecGuidance -): PerfRecommendedAction[] { - const actions: PerfRecommendedAction[] = []; - - if (selectedMode === 'completion' && !completed) { - actions.push({ - action: 'stabilize-completion-run', - priority: 'high', - rationale: 'The benchmark timed out in completion mode, so the next loop should focus on finishing the query before comparing latency percentiles.' - }); - } - if (pipelineAnalysis.candidate_ctes.length > 0) { - actions.push({ - action: 'consider-pipeline-materialization', - priority: 'medium', - rationale: `Pipeline candidates detected: ${pipelineAnalysis.candidate_ctes.map((candidate) => candidate.name).join(', ')}.` - }); - } - if (pipelineAnalysis.scalar_filter_candidates.length > 0) { - actions.push({ - action: 'consider-scalar-filter-binding', - priority: 'medium', - rationale: `Scalar filter candidates detected: ${pipelineAnalysis.scalar_filter_candidates.join(', ')}.` - }); - } - if (planFacts.hasSequentialScan) { - actions.push({ - action: 'review-index-coverage', - priority: 'medium', - rationale: 'The captured plan includes a sequential scan, so index coverage is a likely tuning branch.' - }); - } - if (planFacts.hasJoin) { - actions.push({ - action: 'inspect-join-strategy', - priority: 'medium', - rationale: 'The captured plan includes a join operator, so rewriting join shape or supporting it with indexes may help.' - }); - } - if (specGuidance?.evidence_status === 'missing') { - actions.push({ - action: 'capture-perf-evidence', - priority: 'high', - rationale: `QuerySpec guidance marks this query as ${specGuidance.expected_scale ?? 'performance-sensitive'}, so save benchmark evidence for maintenance review.` - }); - } - if (specGuidance?.fixture_rows_status === 'undersized' && specGuidance.expected_input_rows !== undefined) { - actions.push({ - action: 'increase-perf-fixture-scale', - priority: specGuidance.review_policy === 'strongly-recommended' ? 'high' : 'medium', - rationale: `perf/seed.yml currently provisions ${specGuidance.fixture_rows_available} rows, below the expected input scale of ${specGuidance.expected_input_rows}.` - }); - } - - return uniqueRecommendedActions(actions); -} - -function uniqueRecommendedActions(actions: PerfRecommendedAction[]): PerfRecommendedAction[] { - const deduped = new Map(); - for (const action of actions) { - deduped.set(action.action, action); - } - return Array.from(deduped.values()); -} - -function buildPerfPlanFacts(planJson: unknown): PerfPlanFacts { - const observations: string[] = []; - const statementSummaryParts: string[] = []; - const hasCapturedPlan = planJson !== null && planJson !== undefined; - let hasSequentialScan = false; - let hasJoin = false; - - walkPlanNodes(planJson, (node) => { - const nodeType = normalizeString(node['Node Type']); - const relationName = normalizeString(node['Relation Name']); - const joinType = normalizeString(node['Join Type']); - const cteName = normalizeString(node['CTE Name']); - const filter = normalizeString(node.Filter ?? node['Index Cond']); - - if (!nodeType) { - return; - } - - statementSummaryParts.push(joinType ? `${joinType} ${nodeType}` : nodeType); - - if (nodeType === 'Seq Scan' && relationName) { - hasSequentialScan = true; - observations.push( - filter - ? `Seq Scan on ${relationName} with filter ${truncateSingleLine(filter, 90)}` - : `Seq Scan on ${relationName}` - ); - } - if (nodeType === 'Nested Loop' || nodeType.includes('Join') || Boolean(joinType)) { - hasJoin = true; - } - if (joinType) { - observations.push(`${joinType} ${nodeType} present in the captured plan`); - } - if (nodeType === 'CTE Scan' && cteName) { - observations.push(`CTE Scan reads ${cteName}`); - } - }); - - return { - observations: Array.from(new Set(observations)), - statement_summary: Array.from(new Set(statementSummaryParts)).join(' -> ') || '(no plan captured)', - hasCapturedPlan, - hasSequentialScan, - hasJoin - }; -} - -function buildPerfPlanDeltas(baseline: PerfBenchmarkReport, candidate: PerfBenchmarkReport): PerfPlanDelta[] { - const keys = collectPerfStatementDiffKeys(baseline.executed_statements, candidate.executed_statements); - const baselineLookup = buildPerfStatementDiffLookup(baseline.executed_statements); - const candidateLookup = buildPerfStatementDiffLookup(candidate.executed_statements); - const deltas: PerfPlanDelta[] = []; - - for (const key of keys) { - const baselineStatement = baselineLookup.get(key); - const candidateStatement = candidateLookup.get(key); - const statementId = formatPlanDeltaStatementId(candidateStatement ?? baselineStatement, key); - const baselinePlan = summarizeStatementPlan(baselineStatement, baseline.plan_observations, true); - const candidatePlan = summarizeStatementPlan(candidateStatement, candidate.plan_observations, true); - deltas.push({ - statement_id: statementId, - baseline_plan: baselinePlan, - candidate_plan: candidatePlan, - changed: baselinePlan !== candidatePlan - }); - } - - return deltas; -} - -function buildPerfStatementDeltas(baseline: PerfBenchmarkReport, candidate: PerfBenchmarkReport): PerfStatementDelta[] { - const keys = collectPerfStatementDiffKeys(baseline.executed_statements, candidate.executed_statements); - const baselineLookup = buildPerfStatementDiffLookup(baseline.executed_statements); - const candidateLookup = buildPerfStatementDiffLookup(candidate.executed_statements); - const deltas: PerfStatementDelta[] = []; - - for (const key of keys) { - const baselineStatement = baselineLookup.get(key); - const candidateStatement = candidateLookup.get(key); - const statement = candidateStatement ?? baselineStatement; - deltas.push({ - statement_id: formatPlanDeltaStatementId(statement, key), - role: statement?.role ?? 'final-query', - baseline_elapsed_ms: baselineStatement?.elapsed_ms, - candidate_elapsed_ms: candidateStatement?.elapsed_ms, - elapsed_delta_ms: - baselineStatement?.elapsed_ms !== undefined && candidateStatement?.elapsed_ms !== undefined - ? candidateStatement.elapsed_ms - baselineStatement.elapsed_ms - : undefined, - baseline_row_count: baselineStatement?.row_count, - candidate_row_count: candidateStatement?.row_count, - baseline_timed_out: baselineStatement?.timed_out, - candidate_timed_out: candidateStatement?.timed_out - }); - } - - return deltas; -} - -function buildPerfStatementDiffLookup(statements: PerfStatementReport[]): Map { - return new Map(buildPerfStatementDiffEntries(statements).map((entry) => [entry.key, entry.statement])); -} - -function collectPerfStatementDiffKeys( - baselineStatements: PerfStatementReport[], - candidateStatements: PerfStatementReport[] -): string[] { - const keys: string[] = []; - const seen = new Set(); - for (const entry of [...buildPerfStatementDiffEntries(baselineStatements), ...buildPerfStatementDiffEntries(candidateStatements)]) { - if (seen.has(entry.key)) { - continue; - } - seen.add(entry.key); - keys.push(entry.key); - } - return keys; -} - -function buildPerfStatementDiffEntries( - statements: PerfStatementReport[] -): Array<{ key: string; statement: PerfStatementReport }> { - const counts = new Map(); - return statements.map((statement) => { - const baseKey = statement.target ? `${statement.role}:${statement.target}` : `${statement.role}:statement`; - const nextCount = (counts.get(baseKey) ?? 0) + 1; - counts.set(baseKey, nextCount); - return { - key: nextCount === 1 ? baseKey : `${baseKey}#${nextCount}`, - statement - }; - }); -} - -function formatPlanDeltaStatementId(statement: PerfStatementReport | undefined, fallbackKey: string): string { - if (!statement) { - return fallbackKey; - } - return statement.target ? `${statement.seq}:${statement.role}:${statement.target}` : `${statement.seq}:${statement.role}`; -} - -function summarizeStatementPlan( - statement: PerfStatementReport | undefined, - planObservations: string[], - includeObservations: boolean -): string { - if (!statement) { - return '(missing statement)'; - } - - const parts: string[] = []; - const summary = statement.plan_summary; - if (summary?.join_type && summary.node_type) { - parts.push(`${summary.join_type} ${summary.node_type}`); - } else if (summary?.node_type) { - parts.push(summary.node_type); - } - if (includeObservations && planObservations.length > 0) { - const prefix = `${statement.role}(${statement.target ?? 'statement'})`; - const relevantObservations = planObservations.filter((observation) => observation.startsWith(prefix)); - if (relevantObservations.length > 0) { - parts.push(relevantObservations.join(' | ')); - } - } - return parts.join(' :: ') || '(no plan captured)'; -} - -function walkPlanNodes(planJson: unknown, visit: (node: Record) => void): void { - if (!Array.isArray(planJson)) { - return; - } - for (const entry of planJson) { - if (typeof entry !== 'object' || entry === null) { - continue; - } - const plan = (entry as Record).Plan; - if (typeof plan === 'object' && plan !== null) { - walkSinglePlanNode(plan as Record, visit); - } - } -} - -function walkSinglePlanNode(node: Record, visit: (node: Record) => void): void { - visit(node); - const plans = node.Plans; - if (!Array.isArray(plans)) { - return; - } - for (const child of plans) { - if (typeof child === 'object' && child !== null) { - walkSinglePlanNode(child as Record, visit); - } - } -} - -function renderResolvedSqlPreview( - sql: string, - bindings: unknown[] | Record | undefined -): string | undefined { - if (!Array.isArray(bindings) || bindings.length === 0) { - return undefined; - } - - return sql.replace(/\$(\d+)/g, (token, rawIndex) => { - const binding = bindings[Number(rawIndex) - 1]; - return binding === undefined ? token : renderSqlLiteral(binding); - }); -} - -function renderSqlLiteral(value: unknown): string { - if (value === null) { - return 'null'; - } - if (typeof value === 'number' || typeof value === 'bigint') { - return String(value); - } - if (typeof value === 'boolean') { - return value ? 'true' : 'false'; - } - if (value instanceof Date) { - return `'${value.toISOString()}'`; - } - return `'${String(value).replace(/'/g, "''")}'`; -} -export function loadPerfBenchmarkReport(evidenceDir: string): PerfBenchmarkReport { - const summaryFile = path.join(path.resolve(evidenceDir), 'summary.json'); - const parsed = JSON.parse(readFileSync(summaryFile, 'utf8')) as unknown; - return validatePerfBenchmarkReport(summaryFile, parsed); -} - -export const PERF_BENCHMARK_DEFAULTS = { - repeat: DEFAULT_REPEAT, - warmup: DEFAULT_WARMUP, - classifyThresholdSeconds: DEFAULT_CLASSIFY_THRESHOLD_SECONDS, - timeoutMinutes: DEFAULT_TIMEOUT_MINUTES -} as const; - -function prepareBenchmarkQuery(rootDir: string, queryFile: string, paramsFile?: string): PreparedBenchmarkQuery { - const absolutePath = path.resolve(rootDir, queryFile); - const sourceSql = readFileSync(absolutePath, 'utf8'); - const structure = buildQueryStructureReport(absolutePath, 'ztd perf run'); - if (structure.query_type !== 'SELECT') { - throw new Error('ztd perf run currently supports SELECT queries only.'); - } - - const scan = scanModelGenSql(sourceSql); - const rawBindings = paramsFile ? loadPerfBindings(rootDir, paramsFile) : undefined; - - if (scan.mode === 'named') { - if (!rawBindings || typeof rawBindings !== 'object' || Array.isArray(rawBindings)) { - throw new Error('Named SQL placeholders require an object in --params.'); - } - const namedBindings = rawBindings as Record; - const bound = bindModelGenNamedSql(sourceSql); - const orderedValues = bound.orderedParamNames.map((name) => { - if (!(name in namedBindings)) { - throw new Error(`Missing named benchmark param: ${name}`); - } - return namedBindings[name]; - }); - return { - absolutePath, - sourceSql, - boundSql: bound.boundSql, - queryType: 'SELECT', - paramsShape: scan.mode, - orderedParamNames: bound.orderedParamNames, - bindings: orderedValues, - runtimeBindings: namedBindings - }; - } - if (scan.mode === 'positional') { - if (!Array.isArray(rawBindings)) { - throw new Error('Positional SQL placeholders require an array in --params.'); - } - - const orderedParamNames = scan.positionalTokens.map((token) => token.token); - const highestRequiredIndex = orderedParamNames.reduce((max, token) => { - const parsed = Number(token.slice(1)); - return Number.isInteger(parsed) ? Math.max(max, parsed) : max; - }, 0); - if (rawBindings.length < highestRequiredIndex) { - throw new Error(`Positional SQL placeholders require at least ${highestRequiredIndex} parameters for $${highestRequiredIndex}.`); - } - - return { - absolutePath, - sourceSql, - boundSql: sourceSql, - queryType: 'SELECT', - paramsShape: scan.mode, - orderedParamNames, - bindings: rawBindings, - runtimeBindings: rawBindings - }; - } - - if (rawBindings !== undefined) { - throw new Error('This SQL file has no placeholders, so --params is not needed.'); - } - - return { - absolutePath, - sourceSql, - boundSql: sourceSql, - queryType: 'SELECT', - paramsShape: 'none', - orderedParamNames: [], - bindings: undefined, - runtimeBindings: undefined - }; -} - -function loadPerfBindings(rootDir: string, paramsFile: string): unknown { - const absolutePath = path.resolve(rootDir, paramsFile); - const rawContents = readFileSync(absolutePath, 'utf8'); - - try { - if (path.extname(absolutePath).toLowerCase() === '.json') { - return JSON.parse(rawContents); - } - - const parsed = parseYaml(rawContents); - if (isPerfParamsEnvelope(parsed)) { - return parsed.params; - } - return parsed ?? {}; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse perf params file ${absolutePath}: ${message}`); - } -} - -function isPerfParamsEnvelope(value: unknown): value is { params: unknown } { - return typeof value === 'object' && value !== null && !Array.isArray(value) && 'params' in value; -} - -async function fetchPerfDatabaseVersion(rootDir: string): Promise { - const pg = await ensurePgModule(); - const sandboxConfig = loadPerfSandboxConfig(rootDir); - const resolvedConnection = await ensurePerfConnection(rootDir, sandboxConfig); - const client = new pg.Client({ - connectionString: resolvedConnection.connectionUrl, - connectionTimeoutMillis: 3000 - }); - - try { - await client.connect(); - const result = await client.query<{ server_version?: string }>('SHOW server_version'); - return result.rows?.[0]?.server_version; - } finally { - await client.end().catch(() => undefined); - } -} - -async function classifyPerfBenchmarkMode( - rootDir: string, - prepared: PreparedBenchmarkQuery, - strategy: PerfExecutionStrategy, - material: string[], - classifyThresholdMs: number -): Promise { - const probe = await executePerfBenchmarkOnce(rootDir, prepared, strategy, material, classifyThresholdMs, true); - if (probe.timedOut || probe.elapsedMs >= classifyThresholdMs) { - return { - selectedMode: 'completion', - reason: `classification probe exceeded ${classifyThresholdMs} ms`, - probe - }; - } - - return { - selectedMode: 'latency', - reason: `classification probe completed within ${classifyThresholdMs} ms`, - probe - }; -} - -function buildLatencyMetrics(runs: number[], warmupRuns: number): PerfBenchmarkReport['latency_metrics'] { - const sorted = [...runs].sort((left, right) => left - right); - return { - measured_runs: runs.length, - warmup_runs: warmupRuns, - min_ms: sorted[0] ?? 0, - max_ms: sorted[sorted.length - 1] ?? 0, - avg_ms: runs.reduce((sum, value) => sum + value, 0) / Math.max(runs.length, 1), - median_ms: percentile(sorted, 0.5), - p95_ms: percentile(sorted, 0.95) - }; -} - -function percentile(sorted: number[], ratio: number): number { - if (sorted.length === 0) { - return 0; - } - const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1)); - return sorted[index] ?? 0; -} - -function summarizePlanJson(planJson: unknown): PerfPlanSummary | null { - const topLevel = Array.isArray(planJson) ? planJson[0] : null; - if (!topLevel || typeof topLevel !== 'object') { - return null; - } - - const record = topLevel as Record; - const plan = (record.Plan ?? null) as Record | null; - if (!plan) { - return null; - } - - return { - node_type: normalizeString(plan['Node Type']), - join_type: normalizeString(plan['Join Type']), - total_cost: normalizeNumber(plan['Total Cost']), - plan_rows: normalizeNumber(plan['Plan Rows']), - actual_rows: normalizeNumber(plan['Actual Rows']), - actual_total_time: normalizeNumber(plan['Actual Total Time']) - }; -} - -function savePerfBenchmarkEvidence( - rootDir: string, - report: PerfBenchmarkReport, - statements: StatementExecutionTrace[] -): { runId: string; evidenceDir: string; planFiles: string[]; sqlFiles: string[]; resolvedSqlPreviewFiles: string[] } { - const evidenceRoot = path.join(rootDir, 'perf', 'evidence'); - mkdirSync(evidenceRoot, { recursive: true }); - const reserved = reservePerfEvidenceDir(evidenceRoot, report.label); - const plansDir = path.join(reserved.evidenceDir, 'plans'); - const sqlDir = path.join(reserved.evidenceDir, 'executed-sql'); - mkdirSync(plansDir, { recursive: true }); - mkdirSync(sqlDir, { recursive: true }); - - copyFileSync(report.source_sql_file, path.join(reserved.evidenceDir, 'source.sql')); - if (report.params_file && existsSync(report.params_file)) { - copyFileSync(report.params_file, path.join(reserved.evidenceDir, path.basename(report.params_file))); - } - - const planFiles: string[] = []; - const sqlFiles: string[] = []; - const resolvedSqlPreviewFiles: string[] = []; - for (const [index, statement] of report.executed_statements.entries()) { - const trace = statements[index]; - const targetSuffix = statement.target ? `-${sanitizeLabel(statement.target)}` : ''; - const baseName = `${String(statement.seq).padStart(3, '0')}-${statement.role}${targetSuffix}`; - const sqlFileName = `${baseName}.bound.sql`; - const relativeSqlPath = path.join('executed-sql', sqlFileName).replace(/\\/g, '/'); - writeFileSync(path.join(sqlDir, sqlFileName), `${statement.sql.trimEnd()}\n`, 'utf8'); - sqlFiles.push(relativeSqlPath); - - if (statement.resolved_sql_preview) { - const resolvedFileName = `${baseName}.resolved-preview.sql`; - const relativeResolvedPath = path.join('executed-sql', resolvedFileName).replace(/\\/g, '/'); - writeFileSync(path.join(sqlDir, resolvedFileName), `${statement.resolved_sql_preview.trimEnd()}\n`, 'utf8'); - resolvedSqlPreviewFiles.push(relativeResolvedPath); - } else { - resolvedSqlPreviewFiles.push(''); - } - - if (trace?.planJson !== undefined && trace.planJson !== null) { - const planFileName = `${baseName}.plan.json`; - const relativePlanPath = path.join('plans', planFileName).replace(/\\/g, '/'); - writeFileSync(path.join(plansDir, planFileName), `${JSON.stringify(trace.planJson, null, 2)}\n`, 'utf8'); - planFiles.push(relativePlanPath); - } else { - planFiles.push(''); - } - } - - const summary: PerfBenchmarkReport = { - ...report, - run_id: reserved.runId, - evidence_dir: reserved.evidenceDir, - saved: true, - executed_statements: report.executed_statements.map((statement, index) => ({ - ...statement, - sql_file: sqlFiles[index] || undefined, - resolved_sql_preview_file: resolvedSqlPreviewFiles[index] || undefined, - plan_file: planFiles[index] || undefined - })) - }; - writeFileSync(path.join(reserved.evidenceDir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); - writeFileSync(path.join(reserved.evidenceDir, 'executed-statements.json'), `${JSON.stringify(summary.executed_statements, null, 2)}\n`, 'utf8'); - - return { - runId: reserved.runId, - evidenceDir: reserved.evidenceDir, - planFiles, - sqlFiles, - resolvedSqlPreviewFiles - }; -} - -function reservePerfEvidenceDir(evidenceRoot: string, label: string | undefined): { runId: string; evidenceDir: string } { - const suffix = label ? `_${sanitizeLabel(label)}` : ''; - let nextRun = readHighestPerfRunIndex(evidenceRoot) + 1; - - // Reserve the run directory atomically so concurrent perf runs cannot collide. - for (let attempts = 0; attempts < 1024; attempts += 1) { - const runId = `run_${String(nextRun).padStart(3, '0')}${suffix}`; - const evidenceDir = path.join(evidenceRoot, runId); - try { - mkdirSync(evidenceDir); - return { runId, evidenceDir }; - } catch (error) { - if (isAlreadyExistsError(error)) { - nextRun += 1; - continue; - } - throw error; - } - } - - throw new Error('Unable to allocate a perf evidence directory after repeated collisions.'); -} - -function readHighestPerfRunIndex(evidenceRoot: string): number { - const existing = readdirSync(evidenceRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); - return existing.reduce((max, name) => { - const match = /^run_(\d+)/.exec(name); - return match ? Math.max(max, Number(match[1])) : max; - }, 0); -} - -function sanitizeLabel(label: string): string { - return label.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); -} - -function isAlreadyExistsError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST'; -} - -function toPerfClassificationProbe(result: BenchmarkExecutionResult): PerfClassificationProbe { - return { - elapsed_ms: result.elapsedMs, - timed_out: result.timedOut, - row_count: result.rowCount - }; -} - -function validatePerfBenchmarkReport(summaryFile: string, value: unknown): PerfBenchmarkReport { - if (!isPerfBenchmarkReport(value)) { - throw new Error(`Invalid perf benchmark summary: ${summaryFile}`); - } - return value; -} - -function isPerfBenchmarkReport(value: unknown): value is PerfBenchmarkReport { - if (typeof value !== 'object' || value === null) { - return false; - } - const report = value as Record; - if (report.schema_version !== 1 || report.command !== 'perf run') { - return false; - } - if (report.query_type !== 'SELECT' || !isPerfExecutionStrategy(report.strategy)) { - return false; - } - if (!isPerfSelectedMode(report.selected_mode) || !isPerfRequestedMode(report.requested_mode)) { - return false; - } - if (!isStringArray(report.ordered_param_names) || !isPerfStatementReportArray(report.executed_statements)) { - return false; - } - if (!isStringArray(report.plan_observations) || !isPerfRecommendedActionArray(report.recommended_actions)) { - return false; - } - if (!isPerfPipelineAnalysis(report.pipeline_analysis) - || !isOptionalPerfClassificationProbe(report.classification_probe) - || !isOptionalPerfQuerySpecGuidance(report.spec_guidance) - || !isOptionalPerfDdlInventorySummary(report.ddl_inventory) - || !isOptionalPerfTuningGuidance(report.tuning_guidance) - || !isOptionalPerfTuningSummary(report.tuning_summary)) { - return false; - } - return typeof report.query_file === 'string' - && typeof report.source_sql_file === 'string' - && typeof report.source_sql === 'string' - && typeof report.bound_sql === 'string' - && typeof report.selection_reason === 'string' - && typeof report.classify_threshold_ms === 'number' - && typeof report.timeout_ms === 'number' - && typeof report.dry_run === 'boolean' - && typeof report.saved === 'boolean' - && isOptionalString(report.params_file) - && isOptionalString(report.run_id) - && isOptionalString(report.label) - && isOptionalString(report.evidence_dir) - && isOptionalString(report.database_version) - && isOptionalNumber(report.total_elapsed_ms) - && isOptionalPerfPlanSummary(report.plan_summary) - && isOptionalLatencyMetrics(report.latency_metrics) - && isOptionalCompletionMetrics(report.completion_metrics) - && isOptionalPerfStrategyMetadata(report.strategy_metadata); -} - -function isOptionalPerfQuerySpecGuidance(value: unknown): value is PerfQuerySpecGuidance | undefined { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null) { - return false; - } - const guidance = value as Record; - return typeof guidance.spec_id === 'string' - && typeof guidance.spec_file === 'string' - && isOptionalPerfExpectedScale(guidance.expected_scale) - && isOptionalNumber(guidance.expected_input_rows) - && isOptionalNumber(guidance.expected_output_rows) - && isPerfReviewPolicy(guidance.review_policy) - && isPerfEvidenceStatus(guidance.evidence_status) - && isOptionalNumber(guidance.fixture_rows_available) - && isPerfFixtureRowsStatus(guidance.fixture_rows_status); -} - -function isOptionalPerfDdlInventorySummary(value: unknown): value is PerfDdlInventorySummary | undefined { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null) { - return false; - } - const inventory = value as Record; - return typeof inventory.ddl_files === 'number' - && typeof inventory.ddl_statement_count === 'number' - && typeof inventory.table_count === 'number' - && typeof inventory.index_count === 'number' - && isStringArray(inventory.index_names); -} - -function isOptionalPerfTuningGuidance(value: unknown): value is PerfTuningGuidance | undefined { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null) { - return false; - } - const guidance = value as Record; - return isPerfTuningPrimaryPath(guidance.primary_path) - && typeof guidance.requires_captured_plan === 'boolean' - && isPerfTuningBranchGuidance(guidance.index_branch) - && isPerfTuningBranchGuidance(guidance.pipeline_branch); -} - -function isPerfTuningPrimaryPath(value: unknown): value is PerfTuningPrimaryPath { - return value === 'index' || value === 'pipeline' || value === 'capture-plan'; -} - -function isOptionalPerfTuningSummary(value: unknown): value is PerfTuningSummary | undefined { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null) { - return false; - } - const summary = value as Record; - return typeof summary.headline === 'string' - && isStringArray(summary.evidence) - && typeof summary.next_step === 'string'; -} - -function isPerfTuningBranchGuidance(value: unknown): value is PerfTuningBranchGuidance { - if (typeof value !== 'object' || value === null) { - return false; - } - const guidance = value as Record; - return typeof guidance.recommended === 'boolean' - && isStringArray(guidance.rationale) - && isStringArray(guidance.next_steps); -} -function isOptionalPerfExpectedScale(value: unknown): value is PerfExpectedScale | undefined { - return value === undefined || value === 'tiny' || value === 'small' || value === 'medium' || value === 'large' || value === 'batch'; -} - -function isPerfReviewPolicy(value: unknown): value is PerfReviewPolicy { - return value === 'none' || value === 'recommended' || value === 'strongly-recommended'; -} - -function isPerfEvidenceStatus(value: unknown): value is PerfEvidenceStatus { - return value === 'captured' || value === 'missing' || value === 'not-required'; -} - -function isPerfFixtureRowsStatus(value: unknown): value is PerfFixtureRowsStatus { - return value === 'sufficient' || value === 'undersized' || value === 'unknown'; -} - -function isPerfExecutionStrategy(value: unknown): value is PerfExecutionStrategy { - return value === 'direct' || value === 'decomposed'; -} - -function isPerfSelectedMode(value: unknown): value is PerfSelectedBenchmarkMode { - return value === 'latency' || value === 'completion'; -} - -function isPerfRequestedMode(value: unknown): value is PerfBenchmarkMode { - return value === 'auto' || value === 'latency' || value === 'completion'; -} - -function isPerfPipelineAnalysis(value: unknown): value is PerfPipelineAnalysis { - if (typeof value !== 'object' || value === null) { - return false; - } - const analysis = value as Record; - // Older saved summaries may not include scalar_filter_candidates. - // Treat the field as optional so perf diff remains backward-compatible. - return typeof analysis.query_type === 'string' - && typeof analysis.cte_count === 'number' - && typeof analysis.should_consider_pipeline === 'boolean' - && isPerfPipelineCandidateArray(analysis.candidate_ctes) - && (analysis.scalar_filter_candidates === undefined || isStringArray(analysis.scalar_filter_candidates)) - && isStringArray(analysis.notes); -} - -function isPerfPipelineCandidateArray(value: unknown): value is PerfPipelineCandidate[] { - return Array.isArray(value) && value.every((candidate) => { - if (typeof candidate !== 'object' || candidate === null) { - return false; - } - const record = candidate as Record; - return typeof record.name === 'string' - && typeof record.downstream_references === 'number' - && isStringArray(record.reasons); - }); -} - -function isPerfStatementReportArray(value: unknown): value is PerfStatementReport[] { - return Array.isArray(value) && value.every((statement) => isPerfStatementReport(statement)); -} - -function isPerfStatementReport(value: unknown): value is PerfStatementReport { - if (typeof value !== 'object' || value === null) { - return false; - } - const statement = value as Record; - return typeof statement.seq === 'number' - && isPerfStatementRole(statement.role) - && typeof statement.sql === 'string' - && isOptionalString(statement.target) - && isOptionalBindings(statement.bindings) - && isOptionalString(statement.resolved_sql_preview) - && isOptionalNumber(statement.row_count) - && isOptionalNumber(statement.elapsed_ms) - && isOptionalBoolean(statement.timed_out) - && isOptionalPerfPlanSummary(statement.plan_summary) - && isOptionalString(statement.sql_file) - && isOptionalString(statement.resolved_sql_preview_file) - && isOptionalString(statement.plan_file); -} - -function isPerfStatementRole(value: unknown): value is PerfStatementRole { - return value === 'materialize' || value === 'scalar-filter-bind' || value === 'final-query'; -} - -function isPerfRecommendedActionArray(value: unknown): value is PerfRecommendedAction[] { - return Array.isArray(value) && value.every((action) => isPerfRecommendedAction(action)); -} - -function isPerfRecommendedAction(value: unknown): value is PerfRecommendedAction { - if (typeof value !== 'object' || value === null) { - return false; - } - const action = value as Record; - return typeof action.action === 'string' - && (action.priority === 'high' || action.priority === 'medium') - && typeof action.rationale === 'string'; -} - -function isOptionalPerfClassificationProbe(value: unknown): value is PerfClassificationProbe | undefined { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null) { - return false; - } - const probe = value as Record; - return typeof probe.elapsed_ms === 'number' - && typeof probe.timed_out === 'boolean' - && isOptionalNumber(probe.row_count) - && isOptionalBoolean(probe.reused_as_warmup) - && isOptionalBoolean(probe.reused_as_measured_run); -} - -function isOptionalPerfPlanSummary(value: unknown): value is PerfPlanSummary | null | undefined { - if (value === undefined || value === null) { - return true; - } - if (typeof value !== 'object') { - return false; - } - const summary = value as Record; - return isOptionalString(summary.node_type) - && isOptionalString(summary.join_type) - && isOptionalNumber(summary.total_cost) - && isOptionalNumber(summary.plan_rows) - && isOptionalNumber(summary.actual_rows) - && isOptionalNumber(summary.actual_total_time); -} - -function isOptionalPerfStrategyMetadata(value: unknown): value is PerfStrategyMetadata | undefined { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null) { - return false; - } - const metadata = value as Record; - return isStringArray(metadata.materialized_ctes) - && isStringArray(metadata.scalar_filter_columns) - && Array.isArray(metadata.planned_steps) - && metadata.planned_steps.every((step) => { - if (typeof step !== 'object' || step === null) { - return false; - } - const record = step as Record; - return typeof record.step === 'number' - && (record.kind === 'materialize' || record.kind === 'final-query') - && typeof record.target === 'string' - && isStringArray(record.depends_on); - }); -} - -function isOptionalLatencyMetrics(value: unknown): boolean { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null) { - return false; - } - const metrics = value as Record; - return typeof metrics.measured_runs === 'number' - && typeof metrics.warmup_runs === 'number' - && typeof metrics.min_ms === 'number' - && typeof metrics.max_ms === 'number' - && typeof metrics.avg_ms === 'number' - && typeof metrics.median_ms === 'number' - && typeof metrics.p95_ms === 'number'; -} - -function isOptionalCompletionMetrics(value: unknown): boolean { - if (value === undefined) { - return true; - } - if (typeof value !== 'object' || value === null) { - return false; - } - const metrics = value as Record; - return typeof metrics.completed === 'boolean' - && typeof metrics.timed_out === 'boolean' - && typeof metrics.wall_time_ms === 'number'; -} - -function isOptionalBindings(value: unknown): boolean { - return value === undefined || Array.isArray(value) || (typeof value === 'object' && value !== null); -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); -} - -function isOptionalString(value: unknown): value is string | undefined { - return value === undefined || typeof value === 'string'; -} - -function isOptionalNumber(value: unknown): value is number | undefined { - return value === undefined || typeof value === 'number'; -} - -function isOptionalBoolean(value: unknown): value is boolean | undefined { - return value === undefined || typeof value === 'boolean'; -} -function normalizeFinalQueryRoots(finalQuery: string | string[] | null | undefined): string[] { - if (Array.isArray(finalQuery)) { - return finalQuery.map((value) => value.trim()).filter(Boolean); - } - if (typeof finalQuery === 'string') { - return finalQuery.split(',').map((value) => value.trim()).filter(Boolean); - } - return []; -} - -function calculateImprovementPercent(baseline: number, candidate: number): number { - if (baseline === 0) { - return 0; - } - return ((baseline - candidate) / baseline) * 100; -} - -function extractRowCount(result: { rowCount?: number; rows?: unknown[] }): number | undefined { - if (typeof result.rowCount === 'number') { - return result.rowCount; - } - return Array.isArray(result.rows) ? result.rows.length : undefined; -} - -function isQueryTimeout(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === '57014'; -} - -function nowMs(): number { - return Number(process.hrtime.bigint()) / 1_000_000; -} - -function normalizeNumber(value: unknown): number | undefined { - return typeof value === 'number' ? value : undefined; -} - -function normalizeString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function truncateSingleLine(value: string, limit: number): string { - const normalized = value.replace(/\s+/g, ' ').trim(); - if (normalized.length <= limit) { - return normalized; - } - return `${normalized.slice(0, limit - 3)}...`; -} diff --git a/packages/ztd-cli/src/perf/sandbox.ts b/packages/ztd-cli/src/perf/sandbox.ts deleted file mode 100644 index ea202518a..000000000 --- a/packages/ztd-cli/src/perf/sandbox.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { - CreateIndexStatement, - CreateTableQuery, - MultiQuerySplitter, - SqlParser, - createTableDefinitionFromCreateTableQuery, - type TableDefinitionModel -} from 'rawsql-ts'; -import { collectSqlFiles } from '../utils/collectSqlFiles'; -import { ensurePgModule } from '../utils/optionalDependencies'; -import { loadZtdProjectConfig } from '../utils/ztdProjectConfig'; - -export interface PerfSandboxConfig { - dockerImage: string; - containerName: string; - database: string; - username: string; - password: string; - port: number; - seed: number; -} - -export interface PerfTableSeedConfig { - rows: number; -} - -export interface PerfColumnSeedConfig { - values?: string[]; - skew?: number; -} - -export interface PerfSeedConfig { - seed: number; - tables: Record; - columns: Record; -} - -export interface PerfInitPlan { - rootDir: string; - files: Array<{ path: string; contents: string }>; -} - -export interface PerfDdlStatement { - file: string; - sql: string; - kind: 'table' | 'index' | 'other'; -} - -export interface PerfDdlInventory { - files: string[]; - statements: PerfDdlStatement[]; - ddlStatementCount: number; - tableCount: number; - indexCount: number; - indexNames: string[]; -} - -export interface PerfResetResult { - connectionUrl: string; - appliedFiles: string[]; - ddlStatements: number; - usedDocker: boolean; -} - -export interface PerfSeedResult { - connectionUrl: string; - insertedRows: Record; - seed: number; - usedDocker: boolean; -} - -export function resolvePerfExternalDatabaseUrl(env: NodeJS.ProcessEnv = process.env): string | null { - const explicitUrl = (env.ZTD_DB_URL ?? '').trim(); - return explicitUrl || null; -} - -const DEFAULT_PERF_SANDBOX: PerfSandboxConfig = { - dockerImage: 'postgres:16-alpine', - containerName: 'ztd-perf-sandbox', - database: 'ztd_perf', - username: 'ztd_perf', - password: 'ztd_perf', - port: 55432, - seed: 496 -}; - -const PERF_DIRECTORY = 'perf'; -const PERF_SANDBOX_CONFIG = 'sandbox.json'; -const PERF_SEED_CONFIG = 'seed.yml'; -const PERF_PARAMS_CONFIG = 'params.yml'; -const PERF_DOCKER_COMPOSE = 'docker-compose.yml'; -const PERF_README = 'README.md'; -const PERF_GITIGNORE = '.gitignore'; - -export function buildPerfInitPlan(rootDir: string): PerfInitPlan { - const perfDir = path.join(rootDir, PERF_DIRECTORY); - const sandbox = DEFAULT_PERF_SANDBOX; - return { - rootDir, - files: [ - { - path: path.join(perfDir, PERF_SANDBOX_CONFIG), - contents: `${JSON.stringify(sandbox, null, 2)}\n` - }, - { - path: path.join(perfDir, PERF_SEED_CONFIG), - contents: buildDefaultSeedYaml(sandbox.seed) - }, - { - path: path.join(perfDir, PERF_PARAMS_CONFIG), - contents: ['# Default benchmark parameter presets for future perf runs.', 'params: {}', ''].join('\n') - }, - { - path: path.join(perfDir, PERF_DOCKER_COMPOSE), - contents: buildDockerComposeYaml(sandbox) - }, - { - path: path.join(perfDir, PERF_README), - contents: buildPerfReadme() - }, - { - path: path.join(perfDir, PERF_GITIGNORE), - contents: ['evidence/', '.tmp/', ''].join('\n') - } - ] - }; -} - -export function applyPerfInitPlan(plan: PerfInitPlan): string[] { - const written: string[] = []; - for (const file of plan.files) { - mkdirSync(path.dirname(file.path), { recursive: true }); - writeFileSync(file.path, file.contents, 'utf8'); - written.push(file.path); - } - return written; -} - -export function inspectPerfDdlInventory(rootDir: string, options: { requireExistingDdlDir?: boolean } = {}): PerfDdlInventory { - const config = loadZtdProjectConfig(rootDir); - const ddlRoot = path.resolve(rootDir, config.ddlDir); - if (!existsSync(ddlRoot)) { - if (options.requireExistingDdlDir) { - throw new Error(`Perf DDL directory does not exist: ${ddlRoot}`); - } - return { - files: [], - statements: [], - ddlStatementCount: 0, - tableCount: 0, - indexCount: 0, - indexNames: [] - }; - } - const ddlSources = collectSqlFiles([ddlRoot], ['.sql']); - const statements: PerfDdlStatement[] = []; - const indexNames: string[] = []; - let tableCount = 0; - let indexCount = 0; - - for (const source of ddlSources) { - const split = MultiQuerySplitter.split(source.sql); - for (const chunk of split.queries) { - const sql = chunk.sql.trim(); - if (!sql) { - continue; - } - - const parsed = parseOptionalDdlStatement(sql); - if (!parsed) { - continue; - } - let kind: PerfDdlStatement['kind'] = 'other'; - if (parsed instanceof CreateTableQuery) { - kind = 'table'; - tableCount += 1; - } else if (parsed instanceof CreateIndexStatement) { - kind = 'index'; - indexCount += 1; - indexNames.push(String(parsed.indexName)); - } - - statements.push({ - file: source.path, - sql, - kind - }); - } - } - - return { - files: ddlSources.map((source) => source.path), - statements, - ddlStatementCount: statements.length, - tableCount, - indexCount, - indexNames - }; -} - -function parseOptionalDdlStatement(sql: string): ReturnType | undefined { - try { - return SqlParser.parse(sql); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - // SqlParser currently throws plain Error without stable codes/classes; keep - // this narrow message check until it exposes typed no-statement failures. - if (message.includes('[SqlParser] No SQL statements found in input.')) { - return undefined; - } - throw error; - } -} - -export async function resetPerfSandbox(rootDir: string): Promise { - const sandboxConfig = loadPerfSandboxConfig(rootDir); - const resolvedConnection = await ensurePerfConnection(rootDir, sandboxConfig); - const ddlInventory = inspectPerfDdlInventory(rootDir, { requireExistingDdlDir: true }); - - const pg = await ensurePgModule(); - const client = new pg.Client({ connectionString: resolvedConnection.connectionUrl, connectionTimeoutMillis: 3000 }); - - try { - await client.connect(); - await client.query('BEGIN'); - - try { - await client.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;'); - - for (const statement of ddlInventory.statements) { - await client.query(statement.sql); - } - - await client.query('COMMIT'); - } catch (error) { - await client.query('ROLLBACK').catch(() => undefined); - throw error; - } - - return { - connectionUrl: resolvedConnection.connectionUrl, - appliedFiles: ddlInventory.files, - ddlStatements: ddlInventory.ddlStatementCount, - usedDocker: resolvedConnection.usedDocker - }; - } finally { - await client.end().catch(() => undefined); - } -} - -export async function seedPerfSandbox(rootDir: string): Promise { - const config = loadZtdProjectConfig(rootDir); - const sandboxConfig = loadPerfSandboxConfig(rootDir); - const seedConfig = loadPerfSeedConfig(rootDir); - const resolvedConnection = await ensurePerfConnection(rootDir, sandboxConfig); - const definitions = loadTableDefinitions(rootDir, config.ddlDir); - - const pg = await ensurePgModule(); - const client = new pg.Client({ connectionString: resolvedConnection.connectionUrl, connectionTimeoutMillis: 3000 }); - const insertedRows: Record = {}; - - try { - await client.connect(); - - for (const [tableName, tableSeed] of Object.entries(seedConfig.tables)) { - const definition = resolveTableDefinition(definitions, tableName, config.defaultSchema); - if (!definition) { - throw new Error(`No table definition found for perf seed table: ${tableName}`); - } - - const statements = buildInsertStatementsForTable(definition, tableSeed.rows, seedConfig); - for (const statement of statements) { - await client.query(statement.sql, statement.values); - } - insertedRows[definition.name] = statements.length; - } - - return { - connectionUrl: resolvedConnection.connectionUrl, - insertedRows, - seed: seedConfig.seed, - usedDocker: resolvedConnection.usedDocker - }; - } finally { - await client.end().catch(() => undefined); - } -} - -export function parsePerfSeedYaml(contents: string): PerfSeedConfig { - const lines = contents.replace(/\r\n/g, '\n').split('\n'); - const config: PerfSeedConfig = { seed: DEFAULT_PERF_SANDBOX.seed, tables: {}, columns: {} }; - let section: 'tables' | 'columns' | null = null; - let currentKey: string | null = null; - - for (const rawLine of lines) { - const line = rawLine.replace(/#.*$/, ''); - if (!line.trim()) { - continue; - } - - if (!line.startsWith(' ')) { - const [key, rawValue] = line.split(':', 2); - const normalizedKey = key.trim(); - section = normalizedKey === 'tables' ? 'tables' : normalizedKey === 'columns' ? 'columns' : null; - currentKey = null; - if (normalizedKey === 'seed') { - config.seed = Number((rawValue ?? '').trim()) || DEFAULT_PERF_SANDBOX.seed; - } - continue; - } - - if (line.startsWith(' ') && !line.startsWith(' ')) { - currentKey = line.trim().replace(/:$/, ''); - if (section === 'tables') { - config.tables[currentKey] = { rows: 0 }; - } - if (section === 'columns') { - config.columns[currentKey] = {}; - } - continue; - } - - if (!currentKey || !section) { - continue; - } - - const trimmed = line.trim(); - const [property, rawValue] = trimmed.split(':', 2); - const value = (rawValue ?? '').trim(); - - if (section === 'tables' && property === 'rows') { - config.tables[currentKey].rows = Number(value); - continue; - } - - if (section === 'columns') { - if (property === 'skew') { - config.columns[currentKey].skew = Number(value); - continue; - } - if (property === 'values') { - config.columns[currentKey].values = parseInlineYamlArray(value); - } - } - } - - return config; -} - -export function buildInsertStatementsForTable( - definition: TableDefinitionModel, - rowCount: number, - seedConfig: PerfSeedConfig -): Array<{ sql: string; values: unknown[] }> { - const statements: Array<{ sql: string; values: unknown[] }> = []; - const rng = createDeterministicRng(seedConfig.seed + hashString(definition.name)); - const insertableColumns = definition.columns.filter((column) => !column.defaultValue); - - for (let index = 0; index < rowCount; index += 1) { - if (insertableColumns.length === 0) { - statements.push({ - sql: `INSERT INTO ${quoteQualifiedName(definition.name)} DEFAULT VALUES;`, - values: [] - }); - continue; - } - - const values = insertableColumns.map((column) => - buildSyntheticValue(definition.name, column.name, column.typeName, index, rng, seedConfig, column.isNotNull ?? false) - ); - const placeholders = values.map((_, valueIndex) => `$${valueIndex + 1}`).join(', '); - const columnList = insertableColumns.map((column) => `"${column.name}"`).join(', '); - statements.push({ - sql: `INSERT INTO ${quoteQualifiedName(definition.name)} (${columnList}) VALUES (${placeholders});`, - values - }); - } - - return statements; -} - -export function loadPerfSandboxConfig(rootDir: string): PerfSandboxConfig { - const filePath = path.join(rootDir, PERF_DIRECTORY, PERF_SANDBOX_CONFIG); - if (!existsSync(filePath)) { - return DEFAULT_PERF_SANDBOX; - } - return { - ...DEFAULT_PERF_SANDBOX, - ...JSON.parse(readFileSync(filePath, 'utf8')) - } as PerfSandboxConfig; -} - -export function loadPerfSeedConfig(rootDir: string): PerfSeedConfig { - const filePath = path.join(rootDir, PERF_DIRECTORY, PERF_SEED_CONFIG); - if (!existsSync(filePath)) { - return parsePerfSeedYaml(buildDefaultSeedYaml(DEFAULT_PERF_SANDBOX.seed)); - } - return parsePerfSeedYaml(readFileSync(filePath, 'utf8')); -} - -function buildDefaultSeedYaml(seed: number): string { - return [ - '# Deterministic row counts for the perf sandbox.', - `seed: ${seed}`, - 'tables:', - ' users:', - ' rows: 10000', - ' orders:', - ' rows: 50000', - 'columns:', - ' public.users.status:', - ' values: [active, inactive]', - ' skew: 0.85', - '' - ].join('\n'); -} - -function buildDockerComposeYaml(config: PerfSandboxConfig): string { - return [ - 'services:', - ' perf-db:', - ` image: ${config.dockerImage}`, - ` container_name: ${config.containerName}`, - ' restart: unless-stopped', - ' environment:', - ` POSTGRES_DB: ${config.database}`, - ` POSTGRES_USER: ${config.username}`, - ` POSTGRES_PASSWORD: ${config.password}`, - ' ports:', - ` - "${config.port}:5432"`, - ' healthcheck:', - ' test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]', - ' interval: 3s', - ' timeout: 3s', - ' retries: 20', - '' - ].join('\n'); -} - -function buildPerfReadme(): string { - return [ - '# Perf Sandbox', - '', - 'This directory hosts the opt-in performance sandbox used by `ztd perf` commands.', - '', - 'Suggested workflow:', - '1. `ztd perf init`', - '2. `ztd perf db reset`', - '3. `ztd perf seed`', - '', - 'The reset step replays local `db/ddl/*.sql`, including physical tables and indexes.', - '', - 'The sandbox is intentionally separated from default ZTD workflows.', - '' - ].join('\n'); -} - -function parseInlineYamlArray(value: string): string[] { - const trimmed = value.trim(); - if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) { - return []; - } - return trimmed - .slice(1, -1) - .split(',') - .map((item) => item.trim()) - .filter((item) => item.length > 0); -} - -function loadTableDefinitions(rootDir: string, ddlDir: string): TableDefinitionModel[] { - const ddlSources = collectSqlFiles([path.resolve(rootDir, ddlDir)], ['.sql']); - const definitions: TableDefinitionModel[] = []; - - for (const source of ddlSources) { - const split = MultiQuerySplitter.split(source.sql); - for (const chunk of split.queries) { - const sql = chunk.sql.trim(); - if (!sql) { - continue; - } - - const parsed = parseOptionalDdlStatement(sql); - if (!parsed) { - continue; - } - if (!(parsed instanceof CreateTableQuery)) { - continue; - } - definitions.push(createTableDefinitionFromCreateTableQuery(parsed)); - } - } - - return definitions; -} - -function resolveTableDefinition( - definitions: TableDefinitionModel[], - requestedName: string, - defaultSchema: string -): TableDefinitionModel | undefined { - const normalized = requestedName.includes('.') ? requestedName : `${defaultSchema}.${requestedName}`; - return definitions.find((definition) => definition.name === normalized || definition.name === requestedName); -} - -export async function ensurePerfConnection(rootDir: string, config: PerfSandboxConfig): Promise<{ connectionUrl: string; usedDocker: boolean }> { - const externalUrl = resolvePerfExternalDatabaseUrl(); - if (externalUrl) { - return { connectionUrl: externalUrl, usedDocker: false }; - } - - const ignoredDefaultDatabaseUrl = (process.env.DATABASE_URL ?? '').trim(); - const composeFile = path.join(rootDir, PERF_DIRECTORY, PERF_DOCKER_COMPOSE); - if (!existsSync(composeFile)) { - if (ignoredDefaultDatabaseUrl) { - throw new Error('Perf sandbox ignores DATABASE_URL for ZTD-owned workflows. Set ZTD_DB_URL explicitly or run `ztd perf init` first.'); - } - throw new Error('Perf sandbox is not initialized. Run `ztd perf init` first.'); - } - - assertDockerReadyForPerf(); - runDockerCompose(rootDir, composeFile, ['up', '-d']); - const connectionUrl = buildSandboxConnectionUrl(config); - await waitForDatabase(connectionUrl); - return { connectionUrl, usedDocker: true }; -} - -function buildSandboxConnectionUrl(config: PerfSandboxConfig): string { - return `postgres://${config.username}:${config.password}@127.0.0.1:${config.port}/${config.database}`; -} - -function assertDockerReadyForPerf(): void { - const probe = spawnSync('docker', ['info', '--format', '{{json .ServerVersion}}'], { encoding: 'utf8', timeout: 3000 }); - if (probe.error || probe.status !== 0) { - const detail = (probe.stderr ?? '').trim(); - throw new Error(`Docker is not reachable for ztd perf.${detail ? ` (${detail})` : ''}`); - } -} - -function runDockerCompose(rootDir: string, composeFile: string, args: string[]): void { - const result = spawnSync('docker', ['compose', '-f', composeFile, ...args], { cwd: rootDir, encoding: 'utf8', timeout: 30000 }); - if (result.error || result.status !== 0) { - throw new Error(`Docker compose failed for ztd perf: ${(result.stderr || result.stdout || result.error?.message || 'unknown error').trim()}`); - } -} - -async function waitForDatabase(connectionUrl: string): Promise { - const pg = await ensurePgModule(); - const startedAt = Date.now(); - - while (Date.now() - startedAt < 30000) { - const client = new pg.Client({ connectionString: connectionUrl, connectionTimeoutMillis: 2000 }); - try { - await client.connect(); - await client.end(); - return; - } catch { - await client.end().catch(() => undefined); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - - throw new Error('Timed out waiting for the perf sandbox database to become ready.'); -} - -function buildSyntheticValue( - tableName: string, - columnName: string, - typeName: string | undefined, - index: number, - rng: () => number, - seedConfig: PerfSeedConfig, - isNotNull: boolean -): unknown { - const override = seedConfig.columns[`${tableName}.${columnName}`] ?? seedConfig.columns[columnName]; - if (override?.values && override.values.length > 0) { - const skew = typeof override.skew === 'number' && override.skew > 0 && override.skew < 1 ? override.skew : undefined; - if (skew && override.values.length > 1 && rng() < skew) { - return override.values[0]; - } - return override.values[Math.floor(rng() * override.values.length)] ?? override.values[0]; - } - - if (!isNotNull && index % 7 === 0) { - return null; - } - - const normalizedType = (typeName ?? 'text').toLowerCase(); - if (normalizedType.includes('int') || normalizedType.includes('serial')) { - return index + 1; - } - if (normalizedType.includes('numeric') || normalizedType.includes('decimal')) { - return Number(((index + 1) * 1.11).toFixed(2)); - } - if (normalizedType.includes('bool')) { - return index % 2 === 0; - } - if (normalizedType.includes('date') && !normalizedType.includes('time')) { - return `2024-01-${String((index % 28) + 1).padStart(2, '0')}`; - } - if (normalizedType.includes('time')) { - return `2024-01-01T${String(index % 24).padStart(2, '0')}:00:00.000Z`; - } - if (normalizedType.includes('uuid')) { - const suffix = String(index + 1).padStart(12, '0'); - return `00000000-0000-4000-8000-${suffix}`; - } - - return `${tableName.replace(/\./g, '_')}_${columnName}_${index + 1}`; -} - -function createDeterministicRng(seed: number): () => number { - let state = seed >>> 0; - return () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 0x100000000; - }; -} - -function hashString(value: string): number { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; - } - return Math.abs(hash); -} - -function quoteQualifiedName(name: string): string { - return name.split('.').map((segment) => `"${segment}"`).join('.'); -} diff --git a/packages/ztd-cli/src/query/analysis.ts b/packages/ztd-cli/src/query/analysis.ts deleted file mode 100644 index 4b210c813..000000000 --- a/packages/ztd-cli/src/query/analysis.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - BinarySelectQuery, - CTECollector, - CTEDependencyAnalyzer, - CTETableReferenceCollector, - DeleteQuery, - InsertQuery, - SimpleSelectQuery, - SqlParser, - TableSource, - UpdateQuery, - ValuesQuery -} from 'rawsql-ts'; -import { FromClause, SourceExpression } from 'rawsql-ts'; - -export type SupportedStatement = SimpleSelectQuery | BinarySelectQuery | ValuesQuery | InsertQuery | UpdateQuery | DeleteQuery; - -export interface QueryAnalysis { - statement: SupportedStatement; - ctes: ReturnType; - cteNames: string[]; - dependencyMap: Map; - rootDependencies: string[]; -} - -export function assertSupportedStatement(parsed: ReturnType, commandName: string): SupportedStatement { - if ( - parsed instanceof SimpleSelectQuery || - parsed instanceof BinarySelectQuery || - parsed instanceof ValuesQuery || - parsed instanceof InsertQuery || - parsed instanceof UpdateQuery || - parsed instanceof DeleteQuery - ) { - return parsed; - } - - throw new Error(`${commandName} supports SELECT/INSERT/UPDATE/DELETE statements only.`); -} - -export function analyzeStatement(statement: SupportedStatement): QueryAnalysis { - const cteCollector = new CTECollector(); - const ctes = cteCollector.collect(statement); - const cteNames = ctes.map((cte) => cte.aliasExpression.table.name); - const dependencyMap = buildDependencyMap(statement, ctes); - const rootDependencies = collectRootDependencies(statement, cteNames); - - return { - statement, - ctes, - cteNames, - dependencyMap, - rootDependencies - }; -} - -export function detectQueryType(statement: SupportedStatement): 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' { - if (statement instanceof InsertQuery) { - return 'INSERT'; - } - if (statement instanceof UpdateQuery) { - return 'UPDATE'; - } - if (statement instanceof DeleteQuery) { - return 'DELETE'; - } - return 'SELECT'; -} - -export function buildDependencyMap( - statement: SupportedStatement, - ctes: ReturnType -): Map { - const cteNames = ctes.map((cte) => cte.aliasExpression.table.name); - - if (statement instanceof SimpleSelectQuery) { - const analyzer = new CTEDependencyAnalyzer(); - analyzer.analyzeDependencies(statement); - return new Map( - cteNames.map((name) => [name, analyzer.getDependencies(name).filter((dependency) => cteNames.includes(dependency))]) - ); - } - - const collector = new CTETableReferenceCollector(); - const cteNameSet = new Set(cteNames); - return new Map( - ctes.map((cte) => { - const references = collectCteQueryReferenceNames(cte.query, collector); - const dependencies = Array.from( - new Set(references.filter((reference) => cteNameSet.has(reference) && reference !== cte.aliasExpression.table.name)) - ); - return [cte.aliasExpression.table.name, dependencies]; - }) - ); -} - -function collectCteQueryReferenceNames( - query: ReturnType[number]['query'], - collector: CTETableReferenceCollector -): string[] { - if (query instanceof InsertQuery) { - return query.selectQuery ? collectNamesFromComponents(collector, [query.selectQuery]) : []; - } - - if (query instanceof UpdateQuery) { - return collectNamesFromComponents(collector, [ - query.updateClause.source, - query.fromClause, - query.whereClause - ]); - } - - if (query instanceof DeleteQuery) { - return collectNamesFromComponents(collector, [ - query.deleteClause.source, - query.usingClause, - query.whereClause - ]); - } - - return collector.collect(query).map((source) => source.table.name); -} - -function collectNamesFromComponents( - collector: CTETableReferenceCollector, - components: Array[0] | null | undefined> -): string[] { - return uniquePreservingOrder( - components.flatMap((component) => component ? collector.collect(component).map((source) => source.table.name) : []) - ); -} - -export function collectRootDependencies(statement: SupportedStatement, cteNames: string[]): string[] { - const cteNameSet = new Set(cteNames); - - if (isSelectStatement(statement)) { - return collectReferencedCteNames(cteNameSet, statement); - } - - if (statement instanceof InsertQuery) { - return statement.selectQuery - ? collectReferencedCteNames(cteNameSet, assertSelectStatement(statement.selectQuery)) - : []; - } - - if (statement instanceof UpdateQuery) { - return collectReferencedCteNames(cteNameSet, statement.updateClause.source, statement.fromClause, statement.whereClause); - } - - return collectReferencedCteNames(cteNameSet, statement.deleteClause.source, statement.usingClause, statement.whereClause); -} - -export function collectReachableCtes( - rootDependencies: string[], - dependencyMap: Map, - stopSet: ReadonlySet = new Set() -): Set { - const visited = new Set(); - const queue = [...rootDependencies]; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current || visited.has(current)) { - continue; - } - - visited.add(current); - - // Treat already-satisfied CTEs as traversal stops so downstream slices stay minimal. - if (stopSet.has(current)) { - continue; - } - - for (const dependency of dependencyMap.get(current) ?? []) { - if (!visited.has(dependency)) { - queue.push(dependency); - } - } - } - - return visited; -} - -export function collectDependencyClosure( - targetName: string, - dependencyMap: Map, - stopSet: ReadonlySet = new Set() -): string[] { - const ordered: string[] = []; - const visiting = new Set(); - const visited = new Set(); - - function visit(name: string): void { - if (visited.has(name) || visiting.has(name)) { - return; - } - - visiting.add(name); - - // Preserve the stop node itself, but do not recurse into its upstream dependencies. - if (!stopSet.has(name)) { - for (const dependency of dependencyMap.get(name) ?? []) { - visit(dependency); - } - } - - visiting.delete(name); - visited.add(name); - ordered.push(name); - } - - visit(targetName); - return ordered; -} - -export function collectDirectSources(statement: SupportedStatement): SourceExpression[] { - if (isSelectStatement(statement)) { - return collectSelectSources(statement); - } - if (statement instanceof InsertQuery) { - return statement.selectQuery - ? collectDirectSources(assertSelectStatement(statement.selectQuery)) - : [statement.insertClause.source]; - } - if (statement instanceof UpdateQuery) { - return [statement.updateClause.source, ...collectSourcesFromFromClause(statement.fromClause)]; - } - return [statement.deleteClause.source, ...(statement.usingClause?.getSources() ?? [])]; -} - -export function isSelectStatement(statement: SupportedStatement): statement is SimpleSelectQuery | BinarySelectQuery | ValuesQuery { - return statement instanceof SimpleSelectQuery || statement instanceof BinarySelectQuery || statement instanceof ValuesQuery; -} - -function collectReferencedCteNames(cteNameSet: Set, ...components: Array[0] | null | undefined>): string[] { - const collector = new CTETableReferenceCollector(); - const names = components.flatMap((component) => { - if (!component) { - return []; - } - - return collector.collect(component).map((source) => source.table.name); - }); - - return uniquePreservingOrder(names.filter((name) => cteNameSet.has(name))); -} - -export function uniquePreservingOrder(values: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const value of values) { - if (seen.has(value)) { - continue; - } - seen.add(value); - result.push(value); - } - return result; -} - -function collectSelectSources(statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery): SourceExpression[] { - if (statement instanceof BinarySelectQuery) { - return [ - ...collectSelectSources(assertSelectStatement(statement.left)), - ...collectSelectSources(assertSelectStatement(statement.right)) - ]; - } - if (statement instanceof ValuesQuery) { - return []; - } - return collectSourcesFromFromClause(statement.fromClause); -} - -function assertSelectStatement(statement: unknown): SimpleSelectQuery | BinarySelectQuery | ValuesQuery { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof BinarySelectQuery || - statement instanceof ValuesQuery - ) { - return statement; - } - - throw new Error('Expected a SELECT-compatible statement.'); -} - -function collectSourcesFromFromClause(fromClause: FromClause | null | undefined): SourceExpression[] { - if (!fromClause) { - return []; - } - return fromClause.getSources(); -} diff --git a/packages/ztd-cli/src/query/analyzeColumnUsage.ts b/packages/ztd-cli/src/query/analyzeColumnUsage.ts deleted file mode 100644 index 7eb6f3c83..000000000 --- a/packages/ztd-cli/src/query/analyzeColumnUsage.ts +++ /dev/null @@ -1,588 +0,0 @@ -import { - ArrayExpression, - ArrayIndexExpression, - ArrayQueryExpression, - ArraySliceExpression, - BetweenExpression, - BinaryExpression, - CaseExpression, - CaseKeyValuePair, - CastExpression, - ColumnReference, - DeleteQuery, - FunctionCall, - IdentifierString, - InlineQuery, - InsertQuery, - JoinOnClause, - JoinUsingClause, - OrderByItem, - ParenExpression, - ParenSource, - RawString, - SelectItem, - SimpleSelectQuery, - SqlParser, - StringSpecifierExpression, - SubQuerySource, - SwitchCaseArgument, - TableSource, - TupleExpression, - UnaryExpression, - UpdateQuery, - ValueList, - type FromClause, - type JoinClause, - type SourceExpression, - type ValueComponent -} from 'rawsql-ts'; -import { locateUsageText } from './location'; -import type { CatalogStatement } from '../utils/sqlCatalogStatements'; -import type { - QueryUsageAnalyzerResult, - QueryUsageClauseAnchor, - QueryUsageConfidence, - QueryUsageMatchDetail, - QueryUsageMode, - QueryUsageTarget -} from './types'; - -interface ScopeState { - targetTablePresent: boolean; - aliases: Set; -} - -interface ColumnOccurrence { - usageKind: string; - searchTerms: string[]; - confidence: QueryUsageConfidence; - notes: string[]; - exprHints: string[]; - clauseAnchor: QueryUsageClauseAnchor; -} - -/** - * Analyze column usage for a single statement using AST traversal with explicit uncertainty labeling. - */ -export function analyzeColumnUsage(params: { - statement: CatalogStatement; - target: QueryUsageTarget; - mode: QueryUsageMode; -}): QueryUsageAnalyzerResult { - let parsed: unknown; - try { - parsed = SqlParser.parse(params.statement.statementText); - } catch (error) { - return { - matches: [], - warnings: [ - { - catalog_id: params.statement.catalogId, - query_id: params.statement.queryId, - sql_file: params.statement.sqlFile, - code: 'parse-failed', - message: error instanceof Error ? error.message : String(error) - } - ] - }; - } - - const occurrences = collectColumnOccurrences(parsed, params.target, params.mode); - return { - matches: occurrences.map((occurrence) => toColumnMatch(params.statement, occurrence)), - warnings: [] - }; -} - -function collectColumnOccurrences( - parsed: unknown, - target: QueryUsageTarget, - mode: QueryUsageMode, - context: { inSubquery?: boolean; inCte?: boolean } = {} -): ColumnOccurrence[] { - if (parsed instanceof SimpleSelectQuery) { - const matches: ColumnOccurrence[] = []; - if (parsed.withClause) { - for (const table of parsed.withClause.tables) { - matches.push(...collectColumnOccurrences(table.query, target, mode, { inCte: true })); - } - } - - const scope = buildScope(parsed.fromClause ?? undefined, target, mode); - if (parsed.fromClause) { - matches.push(...collectNestedSourceQueryMatches(parsed.fromClause.source, target, mode)); - } - for (const item of parsed.selectClause.items) { - matches.push(...collectSelectItemMatches(item, target, mode, scope, context)); - } - if (parsed.whereClause) { - matches.push(...collectExpressionMatches(parsed.whereClause.condition, target, mode, scope, context, 'where', [])); - } - if (parsed.groupByClause) { - for (const group of parsed.groupByClause.grouping) { - matches.push(...collectExpressionMatches(group, target, mode, scope, context, 'group-by', [])); - } - } - if (parsed.havingClause) { - matches.push(...collectExpressionMatches(parsed.havingClause.condition, target, mode, scope, context, 'having', [])); - } - if (parsed.orderByClause) { - for (const order of parsed.orderByClause.order) { - matches.push(...collectExpressionMatches(order instanceof OrderByItem ? order.value : order, target, mode, scope, context, 'order-by', [])); - } - } - if (parsed.fromClause?.joins) { - for (const join of parsed.fromClause.joins) { - matches.push(...collectNestedSourceQueryMatches(join.source, target, mode)); - matches.push(...collectJoinMatches(join, target, mode, scope, context)); - } - } - return matches; - } - - if (parsed instanceof UpdateQuery) { - const matches: ColumnOccurrence[] = []; - if (parsed.withClause) { - for (const table of parsed.withClause.tables) { - matches.push(...collectColumnOccurrences(table.query, target, mode, { inCte: true })); - } - } - - const scope = buildScope(parsed.fromClause ?? undefined, target, mode, parsed.updateClause.source); - for (const item of parsed.setClause.items) { - if (matchesColumnName(item.column.name, target)) { - matches.push(buildExplicitOccurrence(item.column.name, mode, 'update-set', [], context)); - } - matches.push(...collectExpressionMatches(item.value, target, mode, scope, context, 'update-set', [])); - } - if (parsed.whereClause) { - matches.push(...collectExpressionMatches(parsed.whereClause.condition, target, mode, scope, context, 'where', [])); - } - if (parsed.returningClause) { - for (const item of parsed.returningClause.items) { - matches.push(...collectSelectItemMatches(item, target, mode, scope, context, 'returning')); - } - } - if (parsed.fromClause?.joins) { - for (const join of parsed.fromClause.joins) { - matches.push(...collectJoinMatches(join, target, mode, scope, context)); - } - } - return matches; - } - - if (parsed instanceof DeleteQuery) { - const matches: ColumnOccurrence[] = []; - if (parsed.withClause) { - for (const table of parsed.withClause.tables) { - matches.push(...collectColumnOccurrences(table.query, target, mode, { inCte: true })); - } - } - - const scope = buildScope(undefined, target, mode, parsed.deleteClause.source); - if (parsed.whereClause) { - matches.push(...collectExpressionMatches(parsed.whereClause.condition, target, mode, scope, context, 'where', [])); - } - if (parsed.returningClause) { - for (const item of parsed.returningClause.items) { - matches.push(...collectSelectItemMatches(item, target, mode, scope, context, 'returning')); - } - } - return matches; - } - - if (parsed instanceof InsertQuery) { - const scope = buildScope(undefined, target, mode, parsed.insertClause.source); - const matches: ColumnOccurrence[] = []; - if (parsed.insertClause.columns) { - for (const column of parsed.insertClause.columns) { - if (matchesColumnName(column.name, target)) { - matches.push(buildExplicitOccurrence(column.name, mode, 'insert-column', [], context)); - } - } - } - if (parsed.selectQuery) { - matches.push(...collectColumnOccurrences(parsed.selectQuery, target, mode, { inSubquery: true })); - } - if (parsed.returningClause) { - for (const item of parsed.returningClause.items) { - matches.push(...collectSelectItemMatches(item, target, mode, scope, context, 'returning')); - } - } - return matches; - } - - return []; -} - -function buildScope( - fromClause: FromClause | undefined, - target: QueryUsageTarget, - mode: QueryUsageMode, - primarySource?: SourceExpression -): ScopeState { - const scope: ScopeState = { - targetTablePresent: mode === 'any-schema-any-table', - aliases: new Set() - }; - - if (primarySource) { - collectScopeFromSource(primarySource, target, mode, scope); - } - if (fromClause) { - collectScopeFromSource(fromClause.source, target, mode, scope); - if (fromClause.joins) { - for (const join of fromClause.joins) { - collectScopeFromSource(join.source, target, mode, scope); - } - } - } - return scope; -} - -function collectScopeFromSource(source: SourceExpression, target: QueryUsageTarget, mode: QueryUsageMode, scope: ScopeState): void { - if (!(source.datasource instanceof TableSource)) { - return; - } - - const schema = source.datasource.namespaces?.map((value) => value.name).join('.') || undefined; - const table = source.datasource.table.name; - const matches = - mode === 'exact' - ? target.schema !== undefined && target.table !== undefined && `${schema}.${table}`.toLowerCase() === `${target.schema}.${target.table}`.toLowerCase() - : mode === 'any-schema' - ? target.table !== undefined && table.toLowerCase() === target.table.toLowerCase() - : true; - if (!matches) { - return; - } - - scope.targetTablePresent = true; - if (source.aliasExpression) { - scope.aliases.add(source.aliasExpression.table.name.toLowerCase()); - } - scope.aliases.add(table.toLowerCase()); - if (schema) { - scope.aliases.add(`${schema}.${table}`.toLowerCase()); - } -} - -function collectSelectItemMatches( - item: SelectItem, - target: QueryUsageTarget, - mode: QueryUsageMode, - scope: ScopeState, - context: { inSubquery?: boolean; inCte?: boolean }, - rootUsageKind = 'select' -): ColumnOccurrence[] { - return collectExpressionMatches(item.value, target, mode, scope, context, rootUsageKind, ['projection']); -} - -function collectJoinMatches( - join: JoinClause, - target: QueryUsageTarget, - mode: QueryUsageMode, - scope: ScopeState, - context: { inSubquery?: boolean; inCte?: boolean } -): ColumnOccurrence[] { - if (!join.condition) { - return []; - } - if (join.condition instanceof JoinOnClause) { - return collectExpressionMatches(join.condition.condition, target, mode, scope, context, 'join-on', []); - } - if (join.condition instanceof JoinUsingClause) { - return collectExpressionMatches(join.condition.condition, target, mode, scope, context, 'join-using', []); - } - return []; -} - -function collectNestedSourceQueryMatches( - source: SourceExpression, - target: QueryUsageTarget, - mode: QueryUsageMode -): ColumnOccurrence[] { - let current: SourceExpression['datasource'] = source.datasource; - while (current instanceof ParenSource) { - current = current.source; - } - if (current instanceof SubQuerySource) { - return collectColumnOccurrences(current.query, target, mode, { inSubquery: true }); - } - return []; -} - -function collectExpressionMatches( - value: ValueComponent, - target: QueryUsageTarget, - mode: QueryUsageMode, - scope: ScopeState, - context: { inSubquery?: boolean; inCte?: boolean }, - rootUsageKind: string, - exprHints: string[] -): ColumnOccurrence[] { - if (value instanceof ColumnReference) { - return collectColumnReferenceMatch(value, target, mode, scope, context, rootUsageKind, exprHints); - } - if (value instanceof BinaryExpression) { - return [ - ...collectExpressionMatches(value.left, target, mode, scope, context, rootUsageKind, [...exprHints, 'comparison']), - ...collectExpressionMatches(value.right, target, mode, scope, context, rootUsageKind, [...exprHints, 'comparison']) - ]; - } - if (value instanceof UnaryExpression) { - return collectExpressionMatches(value.expression, target, mode, scope, context, rootUsageKind, exprHints); - } - if (value instanceof FunctionCall) { - const nestedHints = [...exprHints, 'function']; - const functionName = value.name instanceof IdentifierString ? value.name.name : value.name.value; - if (['count', 'sum', 'avg', 'min', 'max'].includes(functionName.toLowerCase())) { - nestedHints.push('aggregate'); - } - const matches: ColumnOccurrence[] = []; - if (value.argument) { - matches.push(...collectExpressionMatches(value.argument, target, mode, scope, context, rootUsageKind, nestedHints)); - } - if (value.filterCondition) { - matches.push(...collectExpressionMatches(value.filterCondition, target, mode, scope, context, rootUsageKind, nestedHints)); - } - if (value.internalOrderBy) { - for (const order of value.internalOrderBy.order) { - matches.push(...collectExpressionMatches(order instanceof OrderByItem ? order.value : order, target, mode, scope, context, rootUsageKind, nestedHints)); - } - } - return matches; - } - if (value instanceof CastExpression) { - return collectExpressionMatches(value.input, target, mode, scope, context, rootUsageKind, [...exprHints, 'cast']); - } - if (value instanceof ParenExpression) { - return collectExpressionMatches(value.expression, target, mode, scope, context, rootUsageKind, exprHints); - } - if (value instanceof InlineQuery) { - return collectColumnOccurrences(value.selectQuery, target, mode, { inSubquery: true }); - } - if (value instanceof ArrayQueryExpression) { - return collectColumnOccurrences(value.query, target, mode, { inSubquery: true }); - } - if (value instanceof ArrayExpression) { - return collectExpressionMatches(value.expression, target, mode, scope, context, rootUsageKind, exprHints); - } - if (value instanceof ArraySliceExpression) { - return [ - ...collectExpressionMatches(value.array, target, mode, scope, context, rootUsageKind, exprHints), - ...(value.startIndex ? collectExpressionMatches(value.startIndex, target, mode, scope, context, rootUsageKind, exprHints) : []), - ...(value.endIndex ? collectExpressionMatches(value.endIndex, target, mode, scope, context, rootUsageKind, exprHints) : []) - ]; - } - if (value instanceof ArrayIndexExpression) { - return [ - ...collectExpressionMatches(value.array, target, mode, scope, context, rootUsageKind, exprHints), - ...collectExpressionMatches(value.index, target, mode, scope, context, rootUsageKind, exprHints) - ]; - } - if (value instanceof ValueList) { - return value.values.flatMap((entry) => collectExpressionMatches(entry, target, mode, scope, context, rootUsageKind, exprHints)); - } - if (value instanceof BetweenExpression) { - return [ - ...collectExpressionMatches(value.expression, target, mode, scope, context, rootUsageKind, [...exprHints, 'comparison']), - ...collectExpressionMatches(value.lower, target, mode, scope, context, rootUsageKind, [...exprHints, 'comparison']), - ...collectExpressionMatches(value.upper, target, mode, scope, context, rootUsageKind, [...exprHints, 'comparison']) - ]; - } - if (value instanceof CaseExpression) { - return [ - ...(value.condition ? collectExpressionMatches(value.condition, target, mode, scope, context, rootUsageKind, exprHints) : []), - ...collectExpressionMatches(value.switchCase, target, mode, scope, context, rootUsageKind, exprHints) - ]; - } - if (value instanceof SwitchCaseArgument) { - return [ - ...value.cases.flatMap((item) => collectExpressionMatches(item, target, mode, scope, context, rootUsageKind, exprHints)), - ...(value.elseValue ? collectExpressionMatches(value.elseValue, target, mode, scope, context, rootUsageKind, exprHints) : []) - ]; - } - if (value instanceof CaseKeyValuePair) { - return [ - ...collectExpressionMatches(value.key, target, mode, scope, context, rootUsageKind, exprHints), - ...collectExpressionMatches(value.value, target, mode, scope, context, rootUsageKind, exprHints) - ]; - } - if (value instanceof TupleExpression) { - return value.values.flatMap((entry) => collectExpressionMatches(entry, target, mode, scope, context, rootUsageKind, exprHints)); - } - if (value instanceof StringSpecifierExpression || value instanceof IdentifierString || value instanceof RawString) { - return []; - } - return []; -} - -function collectColumnReferenceMatch( - value: ColumnReference, - target: QueryUsageTarget, - mode: QueryUsageMode, - scope: ScopeState, - context: { inSubquery?: boolean; inCte?: boolean }, - rootUsageKind: string, - exprHints: string[] -): ColumnOccurrence[] { - const usageKind = context.inCte ? 'cte' : context.inSubquery ? 'subquery' : rootUsageKind; - const notes = new Set(); - const hints = new Set(exprHints); - let confidence: QueryUsageConfidence = mode === 'exact' ? 'high' : 'low'; - let searchTerms: string[] = []; - - const namespace = value.namespaces?.map((entry) => entry.name).join('.').toLowerCase(); - const columnName = value.column.name; - const wildcard = columnName === '*'; - - if (wildcard) { - hints.add('wildcard'); - notes.add('wildcard-select'); - confidence = 'low'; - if (!scope.targetTablePresent) { - return []; - } - searchTerms = [namespace ? `${namespace}.*` : '*']; - } else { - if (!matchesColumnName(columnName, target)) { - return []; - } - - if (usageKind === 'join-using') { - notes.add('join-using-column'); - confidence = 'low'; - } - - if (!namespace) { - notes.add('unqualified-column'); - confidence = 'low'; - if (!scope.targetTablePresent && mode !== 'any-schema-any-table') { - return []; - } - searchTerms = [columnName]; - } else { - const matchesNamespace = - mode === 'any-schema-any-table' || - scope.aliases.has(namespace) || - (target.table ? namespace === target.table.toLowerCase() : false) || - (target.schema && target.table ? namespace === `${target.schema}.${target.table}`.toLowerCase() : false); - if (!matchesNamespace) { - return []; - } - searchTerms = [`${namespace}.${columnName}`, columnName]; - } - } - - if (context.inSubquery && rootUsageKind === 'select') { - notes.add('subquery-projection'); - } - if (context.inCte && rootUsageKind === 'select') { - notes.add('cte-projection'); - } - if (mode !== 'exact') { - notes.add('relaxed-match-any-schema'); - if (mode === 'any-schema-any-table') { - notes.add('relaxed-match-any-table'); - } - confidence = 'low'; - } - - return [{ - usageKind, - searchTerms, - confidence, - notes: Array.from(notes), - exprHints: Array.from(hints), - clauseAnchor: resolveClauseAnchor(usageKind) - }]; -} - -function buildExplicitOccurrence( - columnName: string, - mode: QueryUsageMode, - usageKind: string, - exprHints: string[], - context: { inSubquery?: boolean; inCte?: boolean } -): ColumnOccurrence { - const notes = new Set(); - if (mode !== 'exact') { - notes.add('relaxed-match-any-schema'); - if (mode === 'any-schema-any-table') { - notes.add('relaxed-match-any-table'); - } - } - return { - usageKind: context.inCte ? 'cte' : context.inSubquery ? 'subquery' : usageKind, - searchTerms: [columnName], - confidence: notes.size > 0 ? 'low' : 'high', - notes: Array.from(notes), - exprHints, - clauseAnchor: resolveClauseAnchor(context.inCte ? 'cte' : context.inSubquery ? 'subquery' : usageKind) - }; -} - -function matchesColumnName(columnName: string, target: QueryUsageTarget): boolean { - return target.column !== undefined && columnName.toLowerCase() === target.column.toLowerCase(); -} - -function toColumnMatch(statement: CatalogStatement, occurrence: ColumnOccurrence): QueryUsageMatchDetail { - const located = locateUsageText({ - statementText: statement.statementText, - statementStartOffsetInFile: statement.statementStartOffsetInFile, - candidates: occurrence.searchTerms, - clauseAnchor: occurrence.clauseAnchor - }); - const notes = [...occurrence.notes]; - let confidence = occurrence.confidence; - - if (located.ambiguous && !notes.includes('ambiguous-multiple-occurrences')) { - notes.push('ambiguous-multiple-occurrences'); - confidence = 'low'; - } - - return { - kind: 'detail', - catalog_id: statement.catalogId, - query_id: statement.queryId, - statement_fingerprint: statement.statementFingerprint, - sql_file: statement.sqlFile, - usage_kind: occurrence.usageKind, - exprHints: occurrence.exprHints.length > 0 ? occurrence.exprHints.sort() : undefined, - location: located.location, - snippet: located.snippet, - confidence, - notes: notes.sort(), - source: 'ast' - }; -} - -function resolveClauseAnchor(usageKind: string): QueryUsageClauseAnchor { - switch (usageKind) { - case 'where': - return { kind: usageKind, tokens: ['WHERE'] }; - case 'order-by': - return { kind: usageKind, tokens: ['ORDER', 'BY'] }; - case 'group-by': - return { kind: usageKind, tokens: ['GROUP', 'BY'] }; - case 'having': - return { kind: usageKind, tokens: ['HAVING'] }; - case 'returning': - return { kind: usageKind, tokens: ['RETURNING'] }; - case 'update-set': - return { kind: usageKind, tokens: ['SET'] }; - case 'join-on': - return { kind: usageKind, tokens: ['ON'] }; - case 'join-using': - return { kind: usageKind, tokens: ['USING'] }; - case 'insert-column': - return { kind: usageKind, tokens: ['INSERT', 'INTO'] }; - case 'select': - case 'subquery': - case 'cte': - case 'unknown': - default: - return { kind: usageKind, tokens: ['SELECT'] }; - } -} diff --git a/packages/ztd-cli/src/query/analyzeTableUsage.ts b/packages/ztd-cli/src/query/analyzeTableUsage.ts deleted file mode 100644 index 3efc4987a..000000000 --- a/packages/ztd-cli/src/query/analyzeTableUsage.ts +++ /dev/null @@ -1,417 +0,0 @@ -import { - ArrayExpression, - ArrayIndexExpression, - ArrayQueryExpression, - ArraySliceExpression, - BetweenExpression, - BinaryExpression, - CaseExpression, - CaseKeyValuePair, - CastExpression, - DeleteQuery, - FunctionCall, - IdentifierString, - InlineQuery, - InsertQuery, - OrderByItem, - ParenExpression, - ParenSource, - RawString, - SimpleSelectQuery, - SourceExpression, - SqlParser, - StringSpecifierExpression, - SubQuerySource, - SwitchCaseArgument, - TupleExpression, - UnaryExpression, - UpdateQuery, - ValueList, - type ValueComponent -} from 'rawsql-ts'; -import type { FromClause, JoinClause, TableSource, UsingClause } from 'rawsql-ts'; -import { TableSource as TableSourceModel } from 'rawsql-ts'; -import { locateUsageText } from './location'; -import type { CatalogStatement } from '../utils/sqlCatalogStatements'; -import type { - QueryUsageAnalyzerResult, - QueryUsageClauseAnchor, - QueryUsageConfidence, - QueryUsageMatchDetail, - QueryUsageMode, - QueryUsageTarget -} from './types'; - -interface TableOccurrence { - usageKind: string; - searchTerms: string[]; - confidence: QueryUsageConfidence; - notes: string[]; - clauseAnchor: QueryUsageClauseAnchor; - strongClauseMatch: boolean; -} - -/** - * Analyze table usage for a single statement using AST-first traversal. - */ -export function analyzeTableUsage(params: { - statement: CatalogStatement; - target: QueryUsageTarget; - mode: QueryUsageMode; -}): QueryUsageAnalyzerResult { - let parsed: unknown; - try { - parsed = SqlParser.parse(params.statement.statementText); - } catch (error) { - return { - matches: [], - warnings: [ - { - catalog_id: params.statement.catalogId, - query_id: params.statement.queryId, - sql_file: params.statement.sqlFile, - code: 'parse-failed', - message: error instanceof Error ? error.message : String(error) - } - ] - }; - } - - const occurrences = collectTableOccurrences(parsed, params.target, params.mode); - return { - matches: occurrences.map((occurrence) => toTableMatch(params.statement, occurrence)), - warnings: [] - }; -} - -function collectTableOccurrences( - parsed: unknown, - target: QueryUsageTarget, - mode: QueryUsageMode, - context: { inSubquery?: boolean; inCte?: boolean } = {} -): TableOccurrence[] { - if (parsed instanceof SimpleSelectQuery) { - const matches: TableOccurrence[] = []; - if (parsed.withClause) { - for (const table of parsed.withClause.tables) { - matches.push(...collectTableOccurrences(table.query, target, mode, { inCte: true })); - } - } - if (parsed.fromClause) { - matches.push(...collectFromClauseOccurrences(parsed.fromClause, target, mode, context)); - } - if (parsed.whereClause) { - matches.push(...collectExpressionQueryOccurrences(parsed.whereClause.condition, target, mode, context)); - } - if (parsed.havingClause) { - matches.push(...collectExpressionQueryOccurrences(parsed.havingClause.condition, target, mode, context)); - } - if (parsed.groupByClause) { - for (const group of parsed.groupByClause.grouping) { - matches.push(...collectExpressionQueryOccurrences(group, target, mode, context)); - } - } - if (parsed.orderByClause) { - for (const order of parsed.orderByClause.order) { - matches.push(...collectExpressionQueryOccurrences(order instanceof OrderByItem ? order.value : order, target, mode, context)); - } - } - return matches; - } - if (parsed instanceof UpdateQuery) { - const matches: TableOccurrence[] = []; - if (parsed.withClause) { - for (const table of parsed.withClause.tables) { - matches.push(...collectTableOccurrences(table.query, target, mode, { inCte: true })); - } - } - matches.push(...collectSourceExpressionOccurrences(parsed.updateClause.source, target, mode, context, 'update-target')); - if (parsed.fromClause) { - matches.push(...collectFromClauseOccurrences(parsed.fromClause, target, mode, context)); - } - if (parsed.whereClause) { - matches.push(...collectExpressionQueryOccurrences(parsed.whereClause.condition, target, mode, context)); - } - for (const item of parsed.setClause.items) { - matches.push(...collectExpressionQueryOccurrences(item.value, target, mode, context)); - } - return matches; - } - if (parsed instanceof DeleteQuery) { - const matches: TableOccurrence[] = []; - if (parsed.withClause) { - for (const table of parsed.withClause.tables) { - matches.push(...collectTableOccurrences(table.query, target, mode, { inCte: true })); - } - } - matches.push(...collectSourceExpressionOccurrences(parsed.deleteClause.source, target, mode, context, 'delete-target')); - if (parsed.usingClause) { - matches.push(...collectUsingOccurrences(parsed.usingClause, target, mode, context)); - } - if (parsed.whereClause) { - matches.push(...collectExpressionQueryOccurrences(parsed.whereClause.condition, target, mode, context)); - } - return matches; - } - if (parsed instanceof InsertQuery) { - const matches: TableOccurrence[] = []; - matches.push(...collectSourceExpressionOccurrences(parsed.insertClause.source, target, mode, context, 'insert-target')); - if (parsed.selectQuery) { - matches.push(...collectTableOccurrences(parsed.selectQuery, target, mode, { inSubquery: true })); - } - return matches; - } - return []; -} - -function collectFromClauseOccurrences( - fromClause: FromClause, - target: QueryUsageTarget, - mode: QueryUsageMode, - context: { inSubquery?: boolean; inCte?: boolean } -): TableOccurrence[] { - const usageKind = context.inCte ? 'cte-body-from' : context.inSubquery ? 'subquery-from' : 'from'; - const matches = collectSourceExpressionOccurrences(fromClause.source, target, mode, context, usageKind); - if (fromClause.joins) { - for (const join of fromClause.joins) { - matches.push(...collectJoinOccurrences(join, target, mode, context)); - } - } - return matches; -} - -function collectJoinOccurrences( - join: JoinClause, - target: QueryUsageTarget, - mode: QueryUsageMode, - context: { inSubquery?: boolean; inCte?: boolean } -): TableOccurrence[] { - const usageKind = context.inCte ? 'cte-body-from' : context.inSubquery ? 'subquery-from' : 'join'; - return collectSourceExpressionOccurrences(join.source, target, mode, context, usageKind); -} - -function collectUsingOccurrences( - usingClause: UsingClause, - target: QueryUsageTarget, - mode: QueryUsageMode, - context: { inSubquery?: boolean; inCte?: boolean } -): TableOccurrence[] { - return usingClause.getSources().flatMap((source) => - collectSourceExpressionOccurrences(source, target, mode, context, 'using') - ); -} - -function collectExpressionQueryOccurrences( - value: ValueComponent, - target: QueryUsageTarget, - mode: QueryUsageMode, - context: { inSubquery?: boolean; inCte?: boolean } -): TableOccurrence[] { - if (value instanceof InlineQuery) { - return collectTableOccurrences(value.selectQuery, target, mode, { ...context, inSubquery: true }); - } - if (value instanceof ArrayQueryExpression) { - return collectTableOccurrences(value.query, target, mode, { ...context, inSubquery: true }); - } - if (value instanceof BinaryExpression) { - return [ - ...collectExpressionQueryOccurrences(value.left, target, mode, context), - ...collectExpressionQueryOccurrences(value.right, target, mode, context) - ]; - } - if (value instanceof UnaryExpression) { - return collectExpressionQueryOccurrences(value.expression, target, mode, context); - } - if (value instanceof FunctionCall) { - const matches: TableOccurrence[] = []; - if (value.argument) { - matches.push(...collectExpressionQueryOccurrences(value.argument, target, mode, context)); - } - if (value.filterCondition) { - matches.push(...collectExpressionQueryOccurrences(value.filterCondition, target, mode, context)); - } - if (value.internalOrderBy) { - for (const order of value.internalOrderBy.order) { - matches.push(...collectExpressionQueryOccurrences(order instanceof OrderByItem ? order.value : order, target, mode, context)); - } - } - return matches; - } - if (value instanceof CastExpression) { - return collectExpressionQueryOccurrences(value.input, target, mode, context); - } - if (value instanceof ParenExpression) { - return collectExpressionQueryOccurrences(value.expression, target, mode, context); - } - if (value instanceof ArrayExpression) { - return collectExpressionQueryOccurrences(value.expression, target, mode, context); - } - if (value instanceof ArraySliceExpression) { - return [ - ...collectExpressionQueryOccurrences(value.array, target, mode, context), - ...(value.startIndex ? collectExpressionQueryOccurrences(value.startIndex, target, mode, context) : []), - ...(value.endIndex ? collectExpressionQueryOccurrences(value.endIndex, target, mode, context) : []) - ]; - } - if (value instanceof ArrayIndexExpression) { - return [ - ...collectExpressionQueryOccurrences(value.array, target, mode, context), - ...collectExpressionQueryOccurrences(value.index, target, mode, context) - ]; - } - if (value instanceof ValueList) { - return value.values.flatMap((entry) => collectExpressionQueryOccurrences(entry, target, mode, context)); - } - if (value instanceof BetweenExpression) { - return [ - ...collectExpressionQueryOccurrences(value.expression, target, mode, context), - ...collectExpressionQueryOccurrences(value.lower, target, mode, context), - ...collectExpressionQueryOccurrences(value.upper, target, mode, context) - ]; - } - if (value instanceof CaseExpression) { - return [ - ...(value.condition ? collectExpressionQueryOccurrences(value.condition, target, mode, context) : []), - ...collectExpressionQueryOccurrences(value.switchCase, target, mode, context) - ]; - } - if (value instanceof SwitchCaseArgument) { - return [ - ...value.cases.flatMap((item) => collectExpressionQueryOccurrences(item, target, mode, context)), - ...(value.elseValue ? collectExpressionQueryOccurrences(value.elseValue, target, mode, context) : []) - ]; - } - if (value instanceof CaseKeyValuePair) { - return [ - ...collectExpressionQueryOccurrences(value.key, target, mode, context), - ...collectExpressionQueryOccurrences(value.value, target, mode, context) - ]; - } - if (value instanceof TupleExpression) { - return value.values.flatMap((entry) => collectExpressionQueryOccurrences(entry, target, mode, context)); - } - if (value instanceof StringSpecifierExpression || value instanceof IdentifierString || value instanceof RawString) { - return []; - } - return []; -} - -function collectSourceExpressionOccurrences( - source: SourceExpression, - target: QueryUsageTarget, - mode: QueryUsageMode, - context: { inSubquery?: boolean; inCte?: boolean }, - usageKind: string -): TableOccurrence[] { - if (source.datasource instanceof TableSourceModel) { - const qualified = getQualifiedTable(source.datasource); - if (matchesTargetTable(qualified, target, mode)) { - return [{ - usageKind, - searchTerms: buildTableSearchTerms({ - ...target, - table: qualified.table, - schema: qualified.schema - }, mode), - confidence: mode === 'exact' ? 'high' : 'low', - notes: mode === 'exact' ? [] : ['relaxed-match-any-schema'], - clauseAnchor: resolveClauseAnchor(usageKind), - strongClauseMatch: mode === 'exact' - }]; - } - return []; - } - if (source.datasource instanceof SubQuerySource) { - return collectTableOccurrences(source.datasource.query, target, mode, { ...context, inSubquery: true }); - } - if (source.datasource instanceof ParenSource) { - return collectSourceExpressionOccurrences(new SourceExpression(source.datasource.source, source.aliasExpression), target, mode, context, usageKind); - } - return []; -} - -function getQualifiedTable(source: TableSource): { schema?: string; table: string; full: string } { - const schema = source.namespaces?.map((value) => value.name).join('.') || undefined; - const table = source.table.name; - return { - schema, - table, - full: schema ? `${schema}.${table}` : table - }; -} - -function matchesTargetTable( - table: { schema?: string; table: string; full: string }, - target: QueryUsageTarget, - mode: QueryUsageMode -): boolean { - if (mode === 'exact') { - return target.schema !== undefined && target.table !== undefined && table.full.toLowerCase() === `${target.schema}.${target.table}`.toLowerCase(); - } - return target.table !== undefined && table.table.toLowerCase() === target.table.toLowerCase(); -} - -function buildTableSearchTerms(target: QueryUsageTarget, mode: QueryUsageMode): string[] { - if (mode === 'exact') { - return target.schema && target.table ? [`${target.schema}.${target.table}`] : []; - } - if (mode === 'any-schema') { - return target.table ? [target.table] : []; - } - return target.table ? [target.table] : []; -} - -function toTableMatch(statement: CatalogStatement, occurrence: TableOccurrence): QueryUsageMatchDetail { - const located = locateUsageText({ - statementText: statement.statementText, - statementStartOffsetInFile: statement.statementStartOffsetInFile, - candidates: occurrence.searchTerms, - clauseAnchor: occurrence.clauseAnchor, - snippetMode: 'line' - }); - const notes = [...occurrence.notes]; - let confidence = occurrence.confidence; - - if (located.ambiguous && !notes.includes('ambiguous-multiple-occurrences')) { - notes.push('ambiguous-multiple-occurrences'); - confidence = 'low'; - } - if (occurrence.strongClauseMatch && !located.ambiguous) { - confidence = 'high'; - } - - return { - kind: 'detail', - catalog_id: statement.catalogId, - query_id: statement.queryId, - statement_fingerprint: statement.statementFingerprint, - sql_file: statement.sqlFile, - usage_kind: occurrence.usageKind, - location: located.location, - snippet: located.snippet, - confidence, - notes: notes.sort(), - source: 'ast' - }; -} - -function resolveClauseAnchor(usageKind: string): QueryUsageClauseAnchor { - switch (usageKind) { - case 'from': - case 'subquery-from': - case 'cte-body-from': - return { kind: usageKind, tokens: ['FROM'] }; - case 'join': - return { kind: usageKind, tokens: ['JOIN'] }; - case 'insert-target': - return { kind: usageKind, tokens: ['INSERT', 'INTO'] }; - case 'update-target': - return { kind: usageKind, tokens: ['UPDATE'] }; - case 'delete-target': - return { kind: usageKind, tokens: ['DELETE', 'FROM'] }; - case 'using': - return { kind: usageKind, tokens: ['USING'] }; - default: - return { kind: usageKind, tokens: ['FROM'] }; - } -} diff --git a/packages/ztd-cli/src/query/execute.ts b/packages/ztd-cli/src/query/execute.ts deleted file mode 100644 index b7732182b..000000000 --- a/packages/ztd-cli/src/query/execute.ts +++ /dev/null @@ -1,1147 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { - BinaryExpression, - BinarySelectQuery, - ColumnReference, - CommonTable, - DeleteQuery, - FromClause, - InlineQuery, - InsertQuery, - LimitClause, - LiteralValue, - ParameterExpression, - ParenExpression, - RawString, - IdentifierString, - SelectClause, - SelectItem, - SimpleSelectQuery, - SourceAliasExpression, - SourceExpression, - SqlFormatter, - SqlParser, - SubQuerySource, - TableSource, - UpdateQuery, - ValuesQuery, - WithClause -} from 'rawsql-ts'; -import { - analyzeStatement, - assertSupportedStatement, - collectDependencyClosure, - collectReachableCtes, - type SupportedStatement -} from './analysis'; -import { buildQueryPipelinePlan, type QueryPipelineMetadata, type QueryPipelinePlan } from './planner'; - -export type PipelineRow = Record; - -export type PipelineQueryResult = - | PipelineRow[] - | { - rows: PipelineRow[]; - rowCount?: number; - }; - -export interface QueryPipelineSession { - query(sql: string, params?: unknown[] | Record): Promise; - release?(): void | Promise; - end?(): void | Promise; -} - -export interface QueryPipelineSessionFactory { - openSession(): Promise; -} - -export interface ExecuteQueryPipelineOptions { - sqlFile: string; - metadata?: QueryPipelineMetadata; - params?: unknown[] | Record; -} - -export interface QueryPipelineExecutionStepResult { - kind: 'materialize' | 'materialize-returning' | 'scalar-filter-bind' | 'final-query'; - target: string; - sql: string; - params?: unknown[] | Record; - rowCount?: number; -} - -export interface QueryPipelineExecutionResult { - plan: QueryPipelinePlan; - final: { - rows: PipelineRow[]; - rowCount?: number; - sql: string; - params?: unknown[] | Record; - }; - steps: QueryPipelineExecutionStepResult[]; -} - -interface PipelineStageQuery { - sql: string; - params?: unknown[] | Record; - scalarSteps: QueryPipelineExecutionStepResult[]; -} - -interface PreparedPipelineSource { - sql: string; -} - -interface BuildPipelineStageQueryOptions { - cte?: string; - final?: boolean; - runtimeParams?: unknown[] | Record; - materializedCtes: string[]; - limit?: number; - scalarFilterColumns: string[]; -} - -interface ScalarBindingContext { - runtimeParams?: unknown[] | Record; - scalarFilterColumns: Set; - materializedCtes: Set; - sourceSql: string; - recursive: boolean; - nextOrdinal: number; -} - -interface ScalarBindingCandidate { - columnName: string; - inlineQuery: InlineQuery; - replace: (expression: ParameterExpression) => void; -} - -interface ScalarExecutionResult { - paramName: string; - value: unknown; - step: QueryPipelineExecutionStepResult; -} - -const SUPPORTED_COMPARISON_OPERATORS = new Set(['=', '!=', '<>', '>', '>=', '<', '<=']); - -/** - * Execute a decomposed query pipeline inside one DB session and reuse prior stage outputs. - */ -export async function executeQueryPipeline( - sessionFactory: QueryPipelineSessionFactory, - options: ExecuteQueryPipelineOptions -): Promise { - const source = preparePipelineSource(options.sqlFile); - const plan = buildQueryPipelinePlan(options.sqlFile, options.metadata); - const session = await sessionFactory.openSession(); - const materializedCtes: string[] = []; - const createdTempTables: string[] = []; - const steps: QueryPipelineExecutionStepResult[] = []; - let executionError: unknown; - - try { - for (const step of plan.steps) { - if (step.kind === 'materialize' || step.kind === 'materialize-returning') { - const stage = await buildPipelineStageQuery(source, session, { - cte: step.target, - runtimeParams: options.params, - materializedCtes, - scalarFilterColumns: plan.metadata.scalarFilterColumns - }); - steps.push(...stage.scalarSteps); - - const sql = `create temp table ${quoteIdentifier(step.target)} as ${stage.sql.trim()}`; - const stageParams = normalizeParamsForSql(sql, stage.params, options.params); - const result = normalizePipelineQueryResult(await session.query(sql, stageParams)); - materializedCtes.push(step.target); - createdTempTables.push(step.target); - steps.push({ - kind: step.kind, - target: step.target, - sql, - params: stageParams, - rowCount: result.rowCount - }); - continue; - } - - const finalStage = await buildPipelineStageQuery(source, session, { - final: true, - runtimeParams: options.params, - materializedCtes, - scalarFilterColumns: plan.metadata.scalarFilterColumns - }); - steps.push(...finalStage.scalarSteps); - - const finalParams = normalizeParamsForSql(finalStage.sql, finalStage.params, options.params); - const result = normalizePipelineQueryResult(await session.query(finalStage.sql, finalParams)); - steps.push({ - kind: step.kind, - target: step.target, - sql: finalStage.sql, - params: finalParams, - rowCount: result.rowCount - }); - return { - plan, - final: { - rows: result.rows, - rowCount: result.rowCount, - sql: finalStage.sql, - params: finalParams - }, - steps - }; - } - - throw new Error('Query pipeline plan did not include a final-query step.'); - } catch (error) { - executionError = error; - throw error; - } finally { - await cleanupPipelineSession(session, createdTempTables, executionError); - } -} - -async function buildPipelineStageQuery( - source: PreparedPipelineSource, - session: QueryPipelineSession, - options: BuildPipelineStageQueryOptions -): Promise { - validateStageOptions(options); - return options.cte - ? buildTargetStageQuery(source, session, options) - : buildFinalStageQuery(source, session, options); -} - -async function buildTargetStageQuery( - source: PreparedPipelineSource, - session: QueryPipelineSession, - options: BuildPipelineStageQueryOptions -): Promise { - const parsed = assertSupportedStatement(SqlParser.parse(source.sql), 'executeQueryPipeline'); - const analysis = analyzeStatement(parsed); - const targetName = options.cte as string; - const materializedStopSet = new Set(options.materializedCtes.filter((name) => name !== targetName)); - const includedNames = collectDependencyClosure(targetName, analysis.dependencyMap, materializedStopSet); - if (!includedNames.includes(targetName)) { - throw new Error(`CTE not found in query: ${targetName}`); - } - - const includedCtes = buildStageCtes(analysis.ctes, includedNames, targetName, options.materializedCtes); - const targetCte = includedCtes.find((cte) => cte.aliasExpression.table.name === targetName); - if (!targetCte) { - throw new Error(`CTE not found in query: ${targetName}`); - } - - const dependencyCtes = includedCtes.filter((cte) => cte.aliasExpression.table.name !== targetName); - const bindingContext = createScalarBindingContext( - options, - includedNames, - source.sql, - getWithClause(parsed)?.recursive ?? false - ); - const scalarSteps = await bindScalarFilterPredicatesInCtes(includedCtes, bindingContext, session); - const formatter = createPipelineFormatter(options.runtimeParams); - const isReturningCte = isReturningDmlQuery(targetCte.query); - const withCtes = isReturningCte ? [...dependencyCtes, targetCte] : dependencyCtes; - const targetQuery = isReturningCte ? buildSelectFromTargetQuery(targetName, options.limit) : assertSelectQuery(targetCte.query); - const withComponent = withCtes.length > 0 ? new WithClause(getWithClause(parsed)?.recursive ?? false, withCtes) : null; - const withResult = withComponent - ? formatter.format(withComponent) - : { formattedSql: '', params: emptyFormatterParams(options.runtimeParams) }; - - let mainResult: { formattedSql: string; params: unknown[] | Record }; - if (isReturningCte) { - mainResult = formatter.format(targetQuery); - } else if (options.limit !== undefined) { - if (targetQuery instanceof SimpleSelectQuery) { - targetQuery.limitClause = new LimitClause(new LiteralValue(options.limit)); - mainResult = formatter.format(targetQuery); - } else { - mainResult = formatter.format(buildWrappedLimitQuery(targetQuery, options.limit)); - } - } else { - mainResult = formatter.format(targetQuery); - } - - const mergedParams = mergeFormatterParams([withResult.params, mainResult.params], options.runtimeParams); - const sql = withResult.formattedSql ? `${withResult.formattedSql} ${mainResult.formattedSql}` : mainResult.formattedSql; - - return { - sql: `${sql}\n`, - params: normalizeParamsForSql(sql, mergedParams, options.runtimeParams), - scalarSteps - }; - -} - -async function buildFinalStageQuery( - source: PreparedPipelineSource, - session: QueryPipelineSession, - options: BuildPipelineStageQueryOptions -): Promise { - const parsed = assertSupportedStatement(SqlParser.parse(source.sql), 'executeQueryPipeline'); - const analysis = analyzeStatement(parsed); - const stopSet = new Set(options.materializedCtes); - const includedNames = [...collectReachableCtes(analysis.rootDependencies, analysis.dependencyMap, stopSet)]; - const includedCtes = buildStageCtes(analysis.ctes, includedNames, null, options.materializedCtes); - const bindingContext = createScalarBindingContext( - options, - includedNames, - source.sql, - getWithClause(parsed)?.recursive ?? false - ); - - applyMinimalWithClause(parsed, includedCtes, getWithClause(parsed)?.recursive ?? false); - const scalarSteps = [ - ...(await bindScalarFilterPredicatesInCtes(includedCtes, bindingContext, session)), - ...(await bindScalarFilterPredicates(parsed, bindingContext, session)) - ]; - const formatter = createPipelineFormatter(options.runtimeParams); - - if (options.limit !== undefined) { - if (parsed instanceof SimpleSelectQuery) { - parsed.limitClause = new LimitClause(new LiteralValue(options.limit)); - } else if (parsed instanceof ValuesQuery || parsed instanceof BinarySelectQuery) { - const wrapped = buildWrappedLimitQuery(parsed, options.limit); - const { formattedSql, params } = formatter.format(wrapped); - return { - sql: `${formattedSql}\n`, - params: normalizeParamsForSql(formattedSql, mergeFormatterParams(params, options.runtimeParams), options.runtimeParams), - scalarSteps - }; - } else { - throw new Error('--limit is only supported for SELECT final slices or --cte slices.'); - } - } - - const { formattedSql, params } = formatter.format(parsed); - return { - sql: `${formattedSql}\n`, - params: normalizeParamsForSql(formattedSql, mergeFormatterParams(params, options.runtimeParams), options.runtimeParams), - scalarSteps - }; -} - -function createScalarBindingContext( - options: BuildPipelineStageQueryOptions, - includedNames: string[], - sourceSql: string, - recursive: boolean -): ScalarBindingContext { - return { - runtimeParams: options.runtimeParams, - scalarFilterColumns: new Set(options.scalarFilterColumns), - materializedCtes: new Set(options.materializedCtes), - sourceSql, - recursive, - nextOrdinal: 1 - }; -} - -function buildStageCtes( - ctes: CommonTable[], - includedNames: string[], - currentTarget: string | null, - materializedCtes: string[] -): CommonTable[] { - const includedSet = new Set(includedNames); - const materializedSet = new Set(materializedCtes); - - return ctes.filter((cte) => { - const name = cte.aliasExpression.table.name; - if (!includedSet.has(name)) { - return false; - } - - return name === currentTarget || !materializedSet.has(name); - }); -} - -async function bindScalarFilterPredicatesInCtes( - ctes: CommonTable[], - context: ScalarBindingContext, - session: QueryPipelineSession -): Promise { - const steps: QueryPipelineExecutionStepResult[] = []; - for (const cte of ctes) { - if (!isSelectQuery(cte.query)) { - continue; - } - steps.push(...await bindScalarFilterPredicates(cte.query, context, session)); - } - return steps; -} -async function bindScalarFilterPredicates( - statement: SupportedStatement | SimpleSelectQuery, - context: ScalarBindingContext, - session: QueryPipelineSession -): Promise { - if (context.scalarFilterColumns.size === 0) { - return []; - } - - if (statement instanceof SimpleSelectQuery) { - return bindScalarFilterPredicatesInSelect(statement, context, session); - } - - if (statement instanceof BinarySelectQuery) { - const steps: QueryPipelineExecutionStepResult[] = []; - steps.push(...await bindScalarFilterPredicatesInSelectBranch(assertSelectQuery(statement.left), context, session)); - steps.push(...await bindScalarFilterPredicatesInSelectBranch(assertSelectQuery(statement.right), context, session)); - return steps; - } - - if (statement instanceof ValuesQuery) { - return []; - } - - if (statement instanceof InsertQuery) { - return statement.selectQuery - ? bindScalarFilterPredicatesInSelectBranch(assertSelectQuery(statement.selectQuery), context, session) - : []; - } - - if (statement instanceof UpdateQuery || statement instanceof DeleteQuery) { - return []; - } - - return []; -} - -async function bindScalarFilterPredicatesInSelectBranch( - statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery, - context: ScalarBindingContext, - session: QueryPipelineSession -): Promise { - if (statement instanceof SimpleSelectQuery) { - return bindScalarFilterPredicatesInSelect(statement, context, session); - } - if (statement instanceof BinarySelectQuery) { - const steps: QueryPipelineExecutionStepResult[] = []; - steps.push(...await bindScalarFilterPredicatesInSelectBranch(assertSelectQuery(statement.left), context, session)); - steps.push(...await bindScalarFilterPredicatesInSelectBranch(assertSelectQuery(statement.right), context, session)); - return steps; - } - return []; -} - -async function bindScalarFilterPredicatesInSelect( - selectQuery: SimpleSelectQuery, - context: ScalarBindingContext, - session: QueryPipelineSession -): Promise { - if (!selectQuery.whereClause) { - return []; - } - - return rewritePredicateExpression(selectQuery.whereClause.condition, context, session); -} - -async function rewritePredicateExpression( - expression: unknown, - context: ScalarBindingContext, - session: QueryPipelineSession -): Promise { - if (!(expression instanceof BinaryExpression)) { - return []; - } - - const binaryExpression = expression; - const candidate = findScalarBindingCandidate(binaryExpression, context.scalarFilterColumns); - if (candidate) { - const execution = await executeScalarFilterBinding(candidate, context, session); - if (execution) { - candidate.replace(new ParameterExpression(execution.paramName, execution.value as never)); - return [execution.step]; - } - } - - const steps: QueryPipelineExecutionStepResult[] = []; - steps.push(...await rewritePredicateExpression(binaryExpression.left, context, session)); - steps.push(...await rewritePredicateExpression(binaryExpression.right, context, session)); - return steps; -} -function findScalarBindingCandidate( - expression: BinaryExpression, - scalarFilterColumns: ReadonlySet -): ScalarBindingCandidate | null { - const operator = extractOperator(expression.operator); - if (!SUPPORTED_COMPARISON_OPERATORS.has(operator)) { - return null; - } - - const leftMatches = containsTargetColumn(expression.left, scalarFilterColumns); - const rightMatches = containsTargetColumn(expression.right, scalarFilterColumns); - const leftInline = unwrapInlineQuery(expression.left); - const rightInline = unwrapInlineQuery(expression.right); - - if (leftMatches && rightInline) { - return { - columnName: leftMatches, - inlineQuery: rightInline, - replace: (parameter) => { - expression.right = parameter; - } - }; - } - - if (rightMatches && leftInline) { - return { - columnName: rightMatches, - inlineQuery: leftInline, - replace: (parameter) => { - expression.left = parameter; - } - }; - } - - return null; -} - -async function executeScalarFilterBinding( - candidate: ScalarBindingCandidate, - context: ScalarBindingContext, - session: QueryPipelineSession -): Promise { - const selectQuery = candidate.inlineQuery.selectQuery; - if (!(selectQuery instanceof SimpleSelectQuery)) { - throw new Error(`Scalar filter binding for column "${candidate.columnName}" requires a simple SELECT subquery.`); - } - - if (isCorrelatedScalarSubquery(selectQuery)) { - return null; - } - - assertSingleColumnScalarSelect(selectQuery, candidate.columnName); - const paramName = `__scalar_filter_${candidate.columnName}_${context.nextOrdinal}`; - const { sql, params: queryParams } = buildScalarBindingQuery(selectQuery, context); - const scalarParams = normalizeParamsForSql(sql, queryParams, context.runtimeParams); - const result = normalizePipelineQueryResult(await session.query(sql, scalarParams)); - const value = extractScalarFilterValue(result.rows, candidate.columnName); - const step: QueryPipelineExecutionStepResult = { - kind: 'scalar-filter-bind', - target: candidate.columnName, - sql, - params: scalarParams, - rowCount: result.rowCount - }; - - context.nextOrdinal += 1; - return { paramName, value, step }; -} -function buildScalarBindingQuery( - selectQuery: SimpleSelectQuery, - context: ScalarBindingContext -): { sql: string; params?: unknown[] | Record } { - const sourceStatement = assertSupportedStatement(SqlParser.parse(context.sourceSql), 'executeQueryPipeline'); - const sourceAnalysis = analyzeStatement(sourceStatement); - const scalarRoots = collectScalarQueryCteRoots(selectQuery, sourceAnalysis.ctes, context.materializedCtes); - const dependencyNames = scalarRoots.length > 0 - ? [...collectReachableCtes(scalarRoots, sourceAnalysis.dependencyMap, context.materializedCtes)] - : []; - const scalarCtes = buildStageCtes(sourceAnalysis.ctes, dependencyNames, null, [...context.materializedCtes]); - const formatter = createPipelineFormatter(context.runtimeParams); - const withComponent = scalarCtes.length > 0 ? new WithClause(context.recursive, scalarCtes) : null; - const withResult = withComponent - ? formatter.format(withComponent) - : { formattedSql: '', params: emptyFormatterParams(context.runtimeParams) }; - const mainResult = formatter.format(selectQuery); - const mergedParams = mergeFormatterParams([withResult.params, mainResult.params], context.runtimeParams); - const sql = withResult.formattedSql ? `${withResult.formattedSql} ${mainResult.formattedSql}` : mainResult.formattedSql; - - return { - sql: `${sql} -`, - params: normalizeParamsForSql(sql, mergedParams, context.runtimeParams) - }; -} - -function collectScalarQueryCteRoots( - selectQuery: SimpleSelectQuery, - ctes: CommonTable[], - materializedCtes: ReadonlySet -): string[] { - const cteNames = new Set(ctes.map((cte) => cte.aliasExpression.table.name)); - const roots = new Set(); - - walkAst(selectQuery, (current) => { - if (!(current instanceof TableSource)) { - return; - } - - const sourceName = extractQualifiedNameLeaf(current.qualifiedName.name); - if (cteNames.has(sourceName) && !materializedCtes.has(sourceName)) { - roots.add(sourceName); - } - }); - - return [...roots]; -} -function assertSingleColumnScalarSelect(selectQuery: SimpleSelectQuery, columnName: string): void { - if (selectQuery.selectClause.items.length !== 1) { - throw new Error(`Scalar filter binding for column "${columnName}" requires a subquery that statically exposes exactly one column.`); - } - - const [item] = selectQuery.selectClause.items; - if (item?.value instanceof RawString && item.value.value.trim() === '*') { - throw new Error(`Scalar filter binding for column "${columnName}" requires a subquery that statically exposes exactly one column.`); - } -} - -function extractScalarFilterValue(rows: PipelineRow[], columnName: string): unknown { - if (rows.length !== 1) { - throw new Error(`Scalar filter binding for column "${columnName}" must return exactly one row.`); - } - - const row = rows[0] ?? {}; - const columns = Object.keys(row); - if (columns.length !== 1) { - throw new Error(`Scalar filter binding for column "${columnName}" must return exactly one column.`); - } - - return row[columns[0]]; -} - -function isCorrelatedScalarSubquery(selectQuery: SimpleSelectQuery): boolean { - const localNames = collectLocalRelationNames(selectQuery); - const columnReferences = collectColumnReferences(selectQuery); - - return columnReferences.some((reference) => { - const qualifierParts = reference.qualifiedName.namespaces?.map((namespace) => namespace.name) ?? []; - if (qualifierParts.length === 0) { - return false; - } - - const qualifier = qualifierParts[qualifierParts.length - 1]; - return !localNames.has(qualifier); - }); -} - -function collectLocalRelationNames(selectQuery: SimpleSelectQuery): Set { - const localNames = new Set(); - const sources = selectQuery.fromClause?.getSources() ?? []; - - for (const source of sources) { - const aliasName = source.aliasExpression?.table?.name; - if (aliasName) { - localNames.add(aliasName); - } - - if (source.datasource instanceof TableSource) { - localNames.add(extractQualifiedNameLeaf(source.datasource.qualifiedName.name)); - } - } - - return localNames; -} - -function collectColumnReferences(node: unknown): ColumnReference[] { - const matches: ColumnReference[] = []; - walkAst(node, (current) => { - if (current instanceof ColumnReference) { - matches.push(current); - } - }); - return matches; -} - -function containsTargetColumn(node: unknown, scalarFilterColumns: ReadonlySet): string | null { - let matched: string | null = null; - walkAst(node, (current) => { - if (matched || !(current instanceof ColumnReference)) { - return; - } - - const columnName = extractQualifiedNameLeaf(current.qualifiedName.name); - if (scalarFilterColumns.has(columnName)) { - matched = columnName; - } - }); - return matched; -} - -function walkAst(node: unknown, visit: (current: unknown) => void): void { - if (!node || typeof node !== 'object') { - return; - } - - visit(node); - - for (const value of Object.values(node as Record)) { - if (!value) { - continue; - } - - if (Array.isArray(value)) { - for (const item of value) { - walkAst(item, visit); - } - continue; - } - - if (typeof value === 'object') { - walkAst(value, visit); - } - } -} - -function unwrapInlineQuery(expression: unknown): InlineQuery | null { - if (expression instanceof InlineQuery) { - return expression; - } - - if (expression instanceof ParenExpression) { - return unwrapInlineQuery(expression.expression); - } - - return null; -} - -function extractOperator(operator: RawString | unknown): string { - if (operator instanceof RawString) { - return operator.value.trim(); - } - return ''; -} - -function extractQualifiedNameLeaf(name: RawString | IdentifierString): string { - return name instanceof RawString ? name.value : name.name; -} - -function preparePipelineSource(sqlFile: string): PreparedPipelineSource { - const sql = readFileSync(path.resolve(sqlFile), 'utf8'); - - return { - sql - }; -} - -function createPipelineFormatter(runtimeParams: unknown[] | Record | undefined): SqlFormatter { - if (runtimeParams && !Array.isArray(runtimeParams)) { - return new SqlFormatter({ - identifierEscape: { start: '"', end: '"' }, - parameterStyle: 'named', - parameterSymbol: ':' - }); - } - - return new SqlFormatter({ preset: 'postgres' }); -} - -function emptyFormatterParams(runtimeParams: unknown[] | Record | undefined): unknown[] | Record { - return Array.isArray(runtimeParams) ? [] : {}; -} - -function mergeFormatterParams( - formatterParams: unknown[] | Record | Array>, - runtimeParams: unknown[] | Record | undefined -): unknown[] | Record | undefined { - const parts = Array.isArray(formatterParams) && formatterParams.some((part) => Array.isArray(part) || isPlainObject(part)) - ? (formatterParams as Array>) - : [formatterParams as unknown[] | Record]; - - if ((runtimeParams && !Array.isArray(runtimeParams)) || parts.some((part) => isPlainObject(part))) { - const merged: Record = isPlainObject(runtimeParams) ? { ...runtimeParams } : {}; - - // Preserve original runtime params while layering formatter-generated named values on top. - for (const part of parts) { - if (!isPlainObject(part)) { - continue; - } - - for (const [key, value] of Object.entries(part)) { - if (value === undefined || (value === null && key in merged)) { - continue; - } - merged[key] = value; - } - } - - return Object.keys(merged).length > 0 ? merged : undefined; - } - - const merged: unknown[] = Array.isArray(runtimeParams) ? [...runtimeParams] : []; - for (const part of parts) { - if (!Array.isArray(part)) { - continue; - } - - // Formatter arrays reuse positional indexes, so keep runtime slots in place and only fill missing/generated entries. - part.forEach((value, index) => { - if ((value === undefined || value === null) && index < merged.length) { - return; - } - merged[index] = value; - }); - } - - return merged.length > 0 ? merged : undefined; -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function normalizeParamsForSql( - sql: string, - params: unknown[] | Record | undefined, - runtimeParams: unknown[] | Record | undefined -): unknown[] | Record | undefined { - if (!Array.isArray(params)) { - return params; - } - - const maxSlot = findHighestPositionalPlaceholder(sql); - if (maxSlot === 0) { - return undefined; - } - - const finalized = params.slice(0, maxSlot).map((value, index) => { - if ((value === undefined || value === null) && Array.isArray(runtimeParams) && index < runtimeParams.length) { - return runtimeParams[index]; - } - return value; - }); - - return finalized; -} - -function findHighestPositionalPlaceholder(sql: string): number { - let highest = 0; - let index = 0; - let dollarQuoteTag: string | null = null; - - while (index < sql.length) { - const current = sql[index]; - const next = sql[index + 1]; - - if (dollarQuoteTag) { - if (sql.startsWith(dollarQuoteTag, index)) { - index += dollarQuoteTag.length; - dollarQuoteTag = null; - continue; - } - - index += 1; - continue; - } - - if (current === "'") { - index = skipQuotedString(sql, index, "'"); - continue; - } - - if (current === '"') { - index = skipQuotedString(sql, index, '"'); - continue; - } - - if (current === '-' && next === '-') { - index = skipLineComment(sql, index + 2); - continue; - } - - if (current === '/' && next === '*') { - index = skipBlockComment(sql, index + 2); - continue; - } - - const dollarTag = readDollarQuoteTag(sql, index); - if (dollarTag) { - dollarQuoteTag = dollarTag; - index += dollarTag.length; - continue; - } - - if (current === '$' && isAsciiDigit(next)) { - let end = index + 1; - while (end < sql.length && isAsciiDigit(sql[end])) { - end += 1; - } - - highest = Math.max(highest, Number(sql.slice(index + 1, end))); - index = end; - continue; - } - - index += 1; - } - - return highest; -} - -function skipQuotedString(sql: string, start: number, quote: '\'' | '"'): number { - let index = start + 1; - while (index < sql.length) { - if (sql[index] === quote) { - if (sql[index + 1] === quote) { - index += 2; - continue; - } - - return index + 1; - } - - index += 1; - } - - return index; -} - -function skipLineComment(sql: string, start: number): number { - let index = start; - while (index < sql.length && sql[index] !== '\n') { - index += 1; - } - return index; -} - -function skipBlockComment(sql: string, start: number): number { - let index = start; - while (index < sql.length - 1) { - if (sql[index] === '*' && sql[index + 1] === '/') { - return index + 2; - } - - index += 1; - } - - return sql.length; -} - -function readDollarQuoteTag(sql: string, start: number): string | null { - if (sql[start] !== '$') { - return null; - } - - let index = start + 1; - while (index < sql.length && sql[index] !== '$') { - const current = sql[index]; - if (!isDollarQuoteTagChar(current)) { - return null; - } - - index += 1; - } - - if (sql[index] !== '$') { - return null; - } - - return sql.slice(start, index + 1); -} - -function isDollarQuoteTagChar(char: string | undefined): boolean { - return char !== undefined && /[A-Za-z0-9_]/.test(char); -} - -function isAsciiDigit(value: string | undefined): value is string { - return value !== undefined && value >= '0' && value <= '9'; -} -function normalizePipelineQueryResult(result: PipelineQueryResult): { rows: PipelineRow[]; rowCount?: number } { - if (Array.isArray(result)) { - return { rows: result }; - } - - return { - rows: result.rows, - rowCount: result.rowCount - }; -} - -async function cleanupPipelineSession( - session: QueryPipelineSession, - createdTempTables: string[], - executionError: unknown -): Promise { - let cleanupError: unknown; - - // Keep dropping later tables even if one cleanup statement fails. - for (const tableName of [...createdTempTables].reverse()) { - try { - await session.query(`drop table if exists ${quoteIdentifier(tableName)}`); - } catch (error) { - if (!cleanupError) { - cleanupError = error; - } - } - } - - try { - await closePipelineSession(session); - } catch (error) { - if (!executionError && !cleanupError) { - throw error; - } - } - - if (!executionError && cleanupError) { - throw cleanupError; - } -} - -async function closePipelineSession(session: QueryPipelineSession): Promise { - if (typeof session.release === 'function') { - await session.release(); - return; - } - - if (typeof session.end === 'function') { - await session.end(); - } -} - -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -function validateStageOptions(options: BuildPipelineStageQueryOptions): void { - const hasTarget = typeof options.cte === 'string' && options.cte.trim() !== ''; - const hasFinal = options.final === true; - - if (hasTarget === hasFinal) { - throw new Error('Specify exactly one stage target or final query mode.'); - } -} - -function buildSelectFromTargetQuery(targetName: string, limit: number | undefined): SimpleSelectQuery { - return new SimpleSelectQuery({ - selectClause: new SelectClause([new SelectItem(new RawString('*'))]), - fromClause: new FromClause(new SourceExpression(new TableSource(null, targetName), null), null), - limitClause: limit === undefined ? null : new LimitClause(new LiteralValue(limit)) - }); -} - -function buildWrappedLimitQuery(statement: BinarySelectQuery | ValuesQuery, limit: number): SimpleSelectQuery { - return new SimpleSelectQuery({ - selectClause: new SelectClause([new SelectItem(new RawString('*'))]), - fromClause: new FromClause( - new SourceExpression(new SubQuerySource(statement), new SourceAliasExpression('final_slice', null)), - null - ), - limitClause: new LimitClause(new LiteralValue(limit)) - }); -} - -function applyMinimalWithClause(statement: SupportedStatement, ctes: CommonTable[], recursive: boolean): void { - const nextWithClause = ctes.length > 0 ? new WithClause(recursive, ctes) : null; - - if ( - statement instanceof SimpleSelectQuery || - statement instanceof ValuesQuery || - statement instanceof UpdateQuery || - statement instanceof DeleteQuery - ) { - statement.withClause = nextWithClause; - return; - } - - if (statement instanceof InsertQuery) { - if (!statement.selectQuery) { - return; - } - - // INSERT ... SELECT stores the CTEs on the nested selectQuery, not on InsertQuery itself. - applyMinimalWithClauseToSelect(assertSelectQuery(statement.selectQuery), nextWithClause); - return; - } - - applyMinimalWithClauseToSelect(statement, nextWithClause); -} - -function applyMinimalWithClauseToSelect( - statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery, - withClause: WithClause | null -): void { - if (statement instanceof SimpleSelectQuery || statement instanceof ValuesQuery) { - statement.withClause = withClause; - return; - } - - let current: BinarySelectQuery | SimpleSelectQuery | ValuesQuery = statement; - while (current instanceof BinarySelectQuery) { - if (current.left instanceof BinarySelectQuery) { - current = current.left; - continue; - } - - if (current.left instanceof SimpleSelectQuery || current.left instanceof ValuesQuery) { - current.left.withClause = withClause; - return; - } - - break; - } - - throw new Error('Unable to apply rewritten WITH clause to the final query.'); -} - -function getWithClause(statement: SupportedStatement): WithClause | null { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof ValuesQuery || - statement instanceof UpdateQuery || - statement instanceof DeleteQuery - ) { - return statement.withClause ?? null; - } - - if (statement instanceof InsertQuery) { - return statement.selectQuery ? getSelectWithClause(assertSelectQuery(statement.selectQuery)) : null; - } - - return getSelectWithClause(statement); -} - -function assertSelectQuery(statement: unknown): SimpleSelectQuery | BinarySelectQuery | ValuesQuery { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof BinarySelectQuery || - statement instanceof ValuesQuery - ) { - return statement; - } - - throw new Error('Expected a SELECT-compatible statement for query execution.'); -} - -function isSelectQuery(statement: unknown): statement is SimpleSelectQuery | BinarySelectQuery | ValuesQuery { - return ( - statement instanceof SimpleSelectQuery || - statement instanceof BinarySelectQuery || - statement instanceof ValuesQuery - ); -} - -function isReturningDmlQuery(statement: unknown): statement is InsertQuery | UpdateQuery | DeleteQuery { - if (statement instanceof InsertQuery || statement instanceof UpdateQuery || statement instanceof DeleteQuery) { - if (!statement.returningClause) { - throw new Error('DML CTE materialization requires a RETURNING clause.'); - } - return true; - } - return false; -} - -function getSelectWithClause(statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery): WithClause | null { - if (statement instanceof SimpleSelectQuery || statement instanceof ValuesQuery) { - return statement.withClause ?? null; - } - - let current: BinarySelectQuery | SimpleSelectQuery | ValuesQuery = statement; - while (current instanceof BinarySelectQuery) { - if (current.left instanceof BinarySelectQuery) { - current = current.left; - continue; - } - - if (current.left instanceof SimpleSelectQuery || current.left instanceof ValuesQuery) { - return current.left.withClause ?? null; - } - - break; - } - - return null; -} diff --git a/packages/ztd-cli/src/query/format.ts b/packages/ztd-cli/src/query/format.ts deleted file mode 100644 index 3d4e0aa9d..000000000 --- a/packages/ztd-cli/src/query/format.ts +++ /dev/null @@ -1 +0,0 @@ -export { applyQueryOutputControls, formatQueryUsageReport, sortQueryUsageMatches, sortQueryUsageWarnings } from '@rawsql-ts/sql-grep-core'; diff --git a/packages/ztd-cli/src/query/lint.ts b/packages/ztd-cli/src/query/lint.ts deleted file mode 100644 index 73b38a47d..000000000 --- a/packages/ztd-cli/src/query/lint.ts +++ /dev/null @@ -1,1034 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { - BinarySelectQuery, - CommonTable, - CTETableReferenceCollector, - DeleteQuery, - BinaryExpression, - ColumnReference, - CreateTableQuery, - FromClause, - HavingClause, - IdentifierString, - InsertQuery, - JoinClause, - JoinOnClause, - JoinUsingClause, - FunctionCall, - ParenExpression, - SimpleSelectQuery, - SourceExpression, - TableSource, - SqlFormatter, - SqlParser, - SqlTokenizer, - MultiQuerySplitter, - UpdateQuery, - ValuesQuery, - WhereClause, - ValueList, - normalizeTableName, - type RelationGraph, - type ValueComponent -} from 'rawsql-ts'; -import { - analyzeStatement, - assertSupportedStatement, - collectReachableCtes, - detectQueryType, - type SupportedStatement -} from './analysis'; -import { loadZtdProjectConfig } from '../utils/ztdProjectConfig'; -import { collectSqlFiles } from '../utils/collectSqlFiles'; -import { buildRelationGraphFromCreateTableQueries, getOutgoingRelations } from 'rawsql-ts'; - -export type QueryLintFormat = 'text' | 'json'; -export type QueryLintSeverity = 'error' | 'warning' | 'info'; -export type QueryLintRule = 'join-direction' | 'leading-comma'; -export type QueryLintIssueType = - | 'unused-cte' - | 'duplicate-join-block' - | 'duplicate-filter-predicate' - | 'dependency-cycle' - | 'analysis-risk' - | 'large-cte' - | 'join-direction' - | 'leading-comma'; - -export interface QueryLintIssue { - type: QueryLintIssueType; - severity: QueryLintSeverity; - message: string; - cte?: string; - cycle?: string[]; - fragment?: string; - occurrences?: string[]; - line_count?: number; - line?: number; - column?: number; - risk_pattern?: string; - join_type?: string; - subject_table?: string; - joined_table?: string; - child_table?: string; - parent_table?: string; - child_columns?: string[]; - parent_columns?: string[]; -} - -export interface QueryLintReport { - file: string; - query_type: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE'; - cte_count: number; - issue_count: number; - issues: QueryLintIssue[]; -} - -export interface QueryLintBuildOptions { - projectRoot?: string; - rules?: QueryLintRule[]; -} - -const LARGE_CTE_LINE_THRESHOLD = 40; -const SEVERITY_ORDER: Record = { - error: 0, - warning: 1, - info: 2 -}; - -const ANALYSIS_RISK_PATTERNS: Array<{ pattern: RegExp; riskPattern: string; message: string }> = [ - { - pattern: /\$\{[^}]+\}/, - riskPattern: 'template-interpolation', - message: 'template interpolation detected; static analysis may not reflect the executed SQL' - }, - { - pattern: /\{\{[^}]+\}\}/, - riskPattern: 'mustache-template', - message: 'template placeholders detected; static analysis may not reflect the executed SQL' - }, - { - pattern: /\bexecute\b/i, - riskPattern: 'execute-dynamic-sql', - message: 'EXECUTE detected; dynamic SQL prevents stable structural analysis' - }, - { - pattern: /\bformat\s*\(/i, - riskPattern: 'format-sql-construction', - message: 'format(...) detected; SQL string construction may hide runtime dependencies' - }, - { - pattern: /'[^']*'\s*\|\||\|\|\s*'[^']*'/, - riskPattern: 'string-concatenation', - message: 'string concatenation detected; SQL construction may not be mechanically analyzable' - } -]; - -interface PatternOccurrence { - scope: string; - normalized: string; - preview: string; -} - -/** - * Build a structural maintainability lint report for one SQL file. - */ -export function buildQueryLintReport(sqlFile: string, options: QueryLintBuildOptions = {}): QueryLintReport { - const absolutePath = path.resolve(sqlFile); - const sql = readFileSync(absolutePath, 'utf8'); - const statement = assertSupportedStatement(SqlParser.parse(sql), 'ztd query lint'); - const analysis = analyzeStatement(statement); - const formatter = new SqlFormatter(); - const issues: QueryLintIssue[] = []; - const enabledRules = new Set(options.rules ?? []); - - const usedCtes = collectReachableCtes(analysis.rootDependencies, analysis.dependencyMap); - for (const cteName of analysis.cteNames.filter((name) => !usedCtes.has(name)).sort()) { - issues.push({ - type: 'unused-cte', - severity: 'warning', - cte: cteName, - message: `${cteName} is defined but never used` - }); - } - - const allowedRecursiveSelfReferences = collectAllowedRecursiveSelfReferences(statement, analysis.ctes); - for (const cycle of detectDependencyCycles(analysis.dependencyMap, allowedRecursiveSelfReferences)) { - issues.push({ - type: 'dependency-cycle', - severity: 'error', - cycle, - message: `invalid dependency cycle detected (${cycle.join(' -> ')})` - }); - } - - for (const duplicate of findDuplicatePatterns(statement, analysis.ctes, formatter)) { - issues.push(duplicate); - } - - for (const cte of analysis.ctes) { - const formattedSql = formatter.format(cte.query).formattedSql; - - // The formatter can collapse wide statements into a handful of lines, - // so approximate line pressure from total SQL length as a fallback signal. - const lineCount = formattedSql.split(/\r?\n/).length; - const estimatedLineCount = Math.max(lineCount, Math.ceil(formattedSql.length / 50)); - if (estimatedLineCount > LARGE_CTE_LINE_THRESHOLD) { - issues.push({ - type: 'large-cte', - severity: 'info', - cte: cte.aliasExpression.table.name, - line_count: estimatedLineCount, - message: `${cte.aliasExpression.table.name} contains approximately ${estimatedLineCount} lines of SQL` - }); - } - } - - for (const risk of detectAnalysisRiskPatterns(sql)) { - issues.push(risk); - } - - if (enabledRules.has('join-direction')) { - const relationGraph = loadJoinDirectionRelationGraph(options.projectRoot); - if (relationGraph) { - issues.push(...buildJoinDirectionIssues(statement, analysis.ctes, relationGraph, sql)); - } - } - - if (enabledRules.has('leading-comma') && !hasLintSuppression(sql, 'leading-comma')) { - issues.push(...buildLeadingCommaIssues(sql)); - } - - const sortedIssues = issues.sort((left, right) => - SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity] - || left.type.localeCompare(right.type) - || (left.line ?? 0) - (right.line ?? 0) - || (left.column ?? 0) - (right.column ?? 0) - || left.message.localeCompare(right.message) - ); - - return { - file: absolutePath, - query_type: detectQueryType(statement), - cte_count: analysis.ctes.length, - issue_count: sortedIssues.length, - issues: sortedIssues - }; -} - -/** - * Render the query lint report in the requested output format. - */ -export function formatQueryLintReport(report: QueryLintReport, format: QueryLintFormat): string { - if (format === 'json') { - return `${JSON.stringify(report, null, 2)}\n`; - } - - if (report.issues.length === 0) { - return 'No query lint issues detected.\n'; - } - - const lines = report.issues.map((issue) => `${formatSeverity(issue.severity)} ${issue.type}: ${issue.message}`); - return `${lines.join('\n')}\n`; -} - -function formatSeverity(severity: QueryLintSeverity): string { - switch (severity) { - case 'error': - return 'ERROR'; - case 'warning': - return 'WARN'; - case 'info': - default: - return 'INFO'; - } -} - -function detectDependencyCycles( - dependencyMap: Map, - allowedRecursiveSelfReferences: Set -): string[][] { - const cycles = new Map(); - const visiting = new Set(); - const visited = new Set(); - const stack: string[] = []; - - function visit(name: string): void { - if (visited.has(name)) { - return; - } - - visiting.add(name); - stack.push(name); - - for (const dependency of dependencyMap.get(name) ?? []) { - if (!visiting.has(dependency)) { - visit(dependency); - continue; - } - - const startIndex = stack.indexOf(dependency); - if (startIndex === -1) { - continue; - } - const cycle = [...stack.slice(startIndex), dependency]; - const canonical = canonicalizeCycle(cycle); - if (isAllowedRecursiveCycle(canonical, allowedRecursiveSelfReferences)) { - continue; - } - cycles.set(canonical.join(' -> '), canonical); - } - - stack.pop(); - visiting.delete(name); - visited.add(name); - } - - for (const name of Array.from(dependencyMap.keys()).sort()) { - visit(name); - } - - return Array.from(cycles.values()).sort((left, right) => left.join(' -> ').localeCompare(right.join(' -> '))); -} - -function collectAllowedRecursiveSelfReferences( - statement: SupportedStatement, - ctes: CommonTable[] -): Set { - const withClause = getStatementWithClause(statement); - if (!withClause?.recursive) { - return new Set(); - } - - const collector = new CTETableReferenceCollector(); - return new Set( - ctes - .filter((cte) => collector.collect(cte.query).some((source) => source.table.name === cte.aliasExpression.table.name)) - .map((cte) => cte.aliasExpression.table.name) - ); -} - -function isAllowedRecursiveCycle(cycle: string[], allowedRecursiveSelfReferences: Set): boolean { - const nodes = cycle.slice(0, -1); - return nodes.length === 1 - && cycle[0] === cycle[cycle.length - 1] - && allowedRecursiveSelfReferences.has(nodes[0]); -} - -function getStatementWithClause(statement: SupportedStatement): { recursive: boolean } | null { - if (statement instanceof InsertQuery) { - return statement.selectQuery ? getSelectStatementWithClause(assertSelectStatement(statement.selectQuery)) : null; - } - - if (statement instanceof BinarySelectQuery) { - return null; - } - - return getQueryWithClause(statement); -} - -function getSelectStatementWithClause(statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery): { recursive: boolean } | null { - if (statement instanceof BinarySelectQuery) { - return null; - } - - return getQueryWithClause(statement); -} - -function getQueryWithClause(statement: SimpleSelectQuery | ValuesQuery | UpdateQuery | DeleteQuery): { recursive: boolean } | null { - const candidate = statement as { withClause?: { recursive: boolean } | null }; - return candidate.withClause ?? null; -} - -function canonicalizeCycle(cycle: string[]): string[] { - const nodes = cycle.slice(0, -1); - if (nodes.length === 0) { - return cycle; - } - - const rotations = nodes.map((_, index) => { - const rotated = [...nodes.slice(index), ...nodes.slice(0, index)]; - return [...rotated, rotated[0]]; - }); - - return rotations.sort((left, right) => left.join(' -> ').localeCompare(right.join(' -> ')))[0]; -} - -function findDuplicatePatterns( - statement: SupportedStatement, - ctes: CommonTable[], - formatter: SqlFormatter -): QueryLintIssue[] { - const joinOccurrences: PatternOccurrence[] = []; - const predicateOccurrences: PatternOccurrence[] = []; - - for (const cte of ctes) { - const supportedQuery = toSupportedStatement(cte.query); - if (!supportedQuery) { - continue; - } - - collectPatternsFromStatement( - supportedQuery, - cte.aliasExpression.table.name, - formatter, - joinOccurrences, - predicateOccurrences - ); - } - collectPatternsFromStatement(statement, 'FINAL_QUERY', formatter, joinOccurrences, predicateOccurrences); - - return [ - ...buildDuplicateIssues('duplicate-join-block', 'warning', 'repeated join logic detected', joinOccurrences), - ...buildDuplicateIssues('duplicate-filter-predicate', 'warning', 'repeated filter predicate detected', predicateOccurrences) - ]; -} - -function collectPatternsFromStatement( - statement: SupportedStatement | SimpleSelectQuery | BinarySelectQuery | ValuesQuery, - scope: string, - formatter: SqlFormatter, - joinOccurrences: PatternOccurrence[], - predicateOccurrences: PatternOccurrence[] -): void { - if (statement instanceof InsertQuery) { - if (statement.selectQuery) { - collectPatternsFromStatement(assertSelectStatement(statement.selectQuery), scope, formatter, joinOccurrences, predicateOccurrences); - } - return; - } - - if (statement instanceof UpdateQuery) { - collectPatternsFromFromClause(statement.fromClause, scope, formatter, joinOccurrences); - collectPredicateOccurrence(statement.whereClause, scope, formatter, predicateOccurrences); - return; - } - - if (statement instanceof DeleteQuery) { - collectPredicateOccurrence(statement.whereClause, scope, formatter, predicateOccurrences); - return; - } - - if (statement instanceof BinarySelectQuery) { - collectPatternsFromStatement(assertSelectStatement(statement.left), `${scope}.left`, formatter, joinOccurrences, predicateOccurrences); - collectPatternsFromStatement(assertSelectStatement(statement.right), `${scope}.right`, formatter, joinOccurrences, predicateOccurrences); - return; - } - - if (statement instanceof ValuesQuery) { - return; - } - - collectPatternsFromFromClause(statement.fromClause, scope, formatter, joinOccurrences); - collectPredicateOccurrence(statement.whereClause, scope, formatter, predicateOccurrences); - collectPredicateOccurrence(statement.havingClause, scope, formatter, predicateOccurrences); -} - -function collectPatternsFromFromClause( - fromClause: FromClause | null | undefined, - scope: string, - formatter: SqlFormatter, - joinOccurrences: PatternOccurrence[] -): void { - if (!fromClause?.joins) { - return; - } - - for (const join of fromClause.joins) { - const preview = formatter.format(join).formattedSql; - joinOccurrences.push({ - scope, - preview, - normalized: normalizeSqlFragment(preview) - }); - if (join.condition instanceof JoinOnClause) { - // Keep the join block only once; duplicate predicate detection focuses on WHERE/HAVING for now. - continue; - } - } -} - -function collectPredicateOccurrence( - clause: WhereClause | HavingClause | null | undefined, - scope: string, - formatter: SqlFormatter, - predicateOccurrences: PatternOccurrence[] -): void { - if (!clause) { - return; - } - - const preview = formatter.format(clause.condition as ValueComponent).formattedSql; - predicateOccurrences.push({ - scope, - preview, - normalized: normalizeSqlFragment(preview) - }); -} - -function buildDuplicateIssues( - type: Extract, - severity: QueryLintSeverity, - messagePrefix: string, - occurrences: PatternOccurrence[] -): QueryLintIssue[] { - const groups = new Map(); - for (const occurrence of occurrences) { - const list = groups.get(occurrence.normalized) ?? []; - list.push(occurrence); - groups.set(occurrence.normalized, list); - } - - return Array.from(groups.values()) - .filter((group) => new Set(group.map((item) => item.scope)).size > 1) - .sort((left, right) => left[0].normalized.localeCompare(right[0].normalized)) - .map((group) => ({ - type, - severity, - fragment: group[0].preview, - occurrences: Array.from(new Set(group.map((item) => item.scope))).sort(), - message: `${messagePrefix} across ${Array.from(new Set(group.map((item) => item.scope))).sort().join(', ')}` - })); -} - -function detectAnalysisRiskPatterns(sql: string): QueryLintIssue[] { - return ANALYSIS_RISK_PATTERNS - .filter(({ pattern }) => pattern.test(sql)) - .map(({ riskPattern, message }) => ({ - type: 'analysis-risk' as const, - severity: 'warning' as const, - risk_pattern: riskPattern, - message - })); -} - -function loadJoinDirectionRelationGraph(projectRoot?: string): RelationGraph | null { - const resolvedRoot = path.resolve(projectRoot ?? process.env.ZTD_PROJECT_ROOT ?? process.cwd()); - let config; - try { - config = loadZtdProjectConfig(resolvedRoot); - } catch { - return null; - } - - const ddlRoot = path.resolve(resolvedRoot, config.ddlDir); - let ddlSources; - try { - ddlSources = collectSqlFiles([ddlRoot], ['.sql']); - } catch { - return null; - } - - const createTableQueries: CreateTableQuery[] = []; - for (const source of ddlSources) { - let split; - try { - split = MultiQuerySplitter.split(source.sql); - } catch { - continue; - } - - for (const chunk of split.queries) { - if (chunk.isEmpty) { - continue; - } - - try { - const parsed = SqlParser.parse(chunk.sql); - if (parsed instanceof CreateTableQuery) { - createTableQueries.push(parsed); - } - } catch { - continue; - } - } - } - - if (createTableQueries.length === 0) { - return null; - } - - return buildRelationGraphFromCreateTableQueries(createTableQueries); -} - -function buildJoinDirectionIssues( - statement: SupportedStatement, - ctes: CommonTable[], - relationGraph: RelationGraph, - sql: string -): QueryLintIssue[] { - if (hasLintSuppression(sql, 'join-direction')) { - return []; - } - - const issues: QueryLintIssue[] = []; - for (const target of collectJoinDirectionTargets(statement, ctes)) { - issues.push(...inspectJoinDirectionQuery(target.query, target.scope, relationGraph)); - } - return issues; -} - -function collectJoinDirectionTargets( - statement: SupportedStatement, - ctes: CommonTable[] -): Array<{ query: SimpleSelectQuery; scope: string }> { - const targets: Array<{ query: SimpleSelectQuery; scope: string }> = []; - - for (const cte of ctes) { - if (cte.query instanceof SimpleSelectQuery) { - targets.push({ query: cte.query, scope: cte.aliasExpression.table.name }); - } - } - - if (statement instanceof SimpleSelectQuery) { - targets.push({ query: statement, scope: 'FINAL_QUERY' }); - } else if (statement instanceof InsertQuery && statement.selectQuery instanceof SimpleSelectQuery) { - targets.push({ query: statement.selectQuery, scope: 'FINAL_QUERY' }); - } - - return targets; -} - -function inspectJoinDirectionQuery( - query: SimpleSelectQuery, - scope: string, - relationGraph: RelationGraph -): QueryLintIssue[] { - if (shouldSkipJoinDirectionQuery(query)) { - return []; - } - - const fromClause = query.fromClause; - if (!fromClause?.joins || fromClause.joins.length === 0) { - return []; - } - - const rootSource = resolveSourceTable(fromClause.source); - if (!rootSource) { - return []; - } - - const issues: QueryLintIssue[] = []; - let currentSource = rootSource; - - for (const join of fromClause.joins) { - const joinSource = resolveSourceTable(join.source); - if (!joinSource || join.lateral || !isInspectableInnerJoin(join.joinType.value)) { - // Non-inner joins stay clean in v1 because preserving the parent row - // is often the intended readable pattern. - break; - } - - if (isBridgeTableCandidate(relationGraph, currentSource.tableName) || isBridgeTableCandidate(relationGraph, joinSource.tableName)) { - // Bridge / many-to-many paths are intentionally skipped in v1 to avoid - // over-reporting on relationship tables that legitimately sit in the middle. - return []; - } - - const comparison = extractJoinComparison(join.condition, currentSource, joinSource); - if (!comparison) { - break; - } - - const forwardMatch = findMatchingRelation( - getOutgoingRelations(relationGraph, currentSource.tableName), - joinSource.tableName, - comparison - ); - if (forwardMatch) { - currentSource = joinSource; - continue; - } - - const reverseMatch = findMatchingRelation( - getOutgoingRelations(relationGraph, joinSource.tableName), - currentSource.tableName, - comparison - ); - if (reverseMatch) { - issues.push( - buildJoinDirectionIssue(scope, join.joinType.value, currentSource.tableName, joinSource.tableName, reverseMatch) - ); - currentSource = joinSource; - continue; - } - - if ( - hasAmbiguousRelation(relationGraph, currentSource.tableName, joinSource.tableName) || - hasAmbiguousRelation(relationGraph, joinSource.tableName, currentSource.tableName) - ) { - break; - } - break; - } - - return issues; -} - -function shouldSkipJoinDirectionQuery(query: SimpleSelectQuery): boolean { - if (query.groupByClause || query.havingClause) { - return true; - } - - if (query.selectClause.items.some((item) => containsAggregateFunction(item.value))) { - return true; - } - - return containsNamedFunction(query.whereClause?.condition, new Set(['exists'])); -} - -function containsAggregateFunction(value: ValueComponent | null | undefined): boolean { - return containsNamedFunction( - value, - new Set(['count', 'sum', 'avg', 'min', 'max', 'string_agg', 'array_agg', 'json_agg', 'jsonb_agg', 'bool_and', 'bool_or']) - ); -} - -function containsNamedFunction(value: ValueComponent | null | undefined, names: ReadonlySet): boolean { - if (!value) { - return false; - } - - if (value instanceof FunctionCall) { - const functionName = value.name instanceof IdentifierString ? value.name.name : value.name.value; - if (names.has(functionName.toLowerCase())) { - return true; - } - return containsNamedFunction(value.argument, names) || - containsNamedFunction(value.filterCondition, names); - } - - if (value instanceof BinaryExpression) { - return containsNamedFunction(value.left, names) || containsNamedFunction(value.right, names); - } - - if (value instanceof ParenExpression) { - return containsNamedFunction(value.expression, names); - } - - if (value instanceof ValueList) { - return value.values.some((entry) => containsNamedFunction(entry, names)); - } - - return false; -} - -function resolveSourceTable(source: SourceExpression): ResolvedSourceTable | null { - if (!(source.datasource instanceof TableSource)) { - return null; - } - - const tableName = normalizeTableName(source.datasource.getSourceName()); - const alias = normalizeTableName(source.getAliasName() ?? source.datasource.getSourceName()); - const ownerNames = new Set([tableName, alias]); - return { tableName, alias, ownerNames }; -} - -interface JoinComparison { - currentColumns: string[]; - joinedColumns: string[]; -} - -function extractJoinComparison( - condition: JoinClause['condition'], - currentSource: ResolvedSourceTable, - joinedSource: ResolvedSourceTable -): JoinComparison | null { - if (condition instanceof JoinUsingClause) { - return extractUsingComparison(condition.condition); - } - - if (condition instanceof JoinOnClause) { - return extractOnComparison(condition.condition, currentSource, joinedSource); - } - - return null; -} - -function extractUsingComparison( - value: ValueComponent -): JoinComparison | null { - if (!(value instanceof ValueList)) { - return null; - } - - const columns: string[] = []; - for (const entry of value.values) { - if (!(entry instanceof ColumnReference) && !(entry instanceof IdentifierString)) { - return null; - } - const name = entry instanceof ColumnReference ? entry.column.name : entry.name; - if (!name) { - return null; - } - columns.push(name); - } - - return { - currentColumns: columns, - joinedColumns: columns - }; -} - -function extractOnComparison( - value: ValueComponent, - currentSource: ResolvedSourceTable, - joinedSource: ResolvedSourceTable -): JoinComparison | null { - const pairs = collectOnComparisonPairs(value, currentSource, joinedSource); - if (!pairs || pairs.length === 0) { - return null; - } - - return { - currentColumns: pairs.map((pair) => pair.currentColumn), - joinedColumns: pairs.map((pair) => pair.joinedColumn) - }; -} - -function collectOnComparisonPairs( - value: ValueComponent, - currentSource: ResolvedSourceTable, - joinedSource: ResolvedSourceTable -): Array<{ currentColumn: string; joinedColumn: string }> | null { - if (value instanceof ParenExpression) { - return collectOnComparisonPairs(value.expression, currentSource, joinedSource); - } - - if (value instanceof BinaryExpression) { - const operator = value.operator.value.toLowerCase(); - if (operator === 'and') { - const left = collectOnComparisonPairs(value.left, currentSource, joinedSource); - const right = collectOnComparisonPairs(value.right, currentSource, joinedSource); - if (!left || !right) { - return null; - } - return [...left, ...right]; - } - - if (operator !== '=') { - return null; - } - - const pair = resolveEqualityPair(value.left, value.right, currentSource, joinedSource); - if (pair) { - return [pair]; - } - - const reversed = resolveEqualityPair(value.right, value.left, currentSource, joinedSource); - if (reversed) { - return [{ currentColumn: reversed.joinedColumn, joinedColumn: reversed.currentColumn }]; - } - } - - return null; -} - -function resolveEqualityPair( - left: ValueComponent, - right: ValueComponent, - currentSource: ResolvedSourceTable, - joinedSource: ResolvedSourceTable -): { currentColumn: string; joinedColumn: string } | null { - const leftRef = extractColumnReference(left); - const rightRef = extractColumnReference(right); - if (!leftRef || !rightRef) { - return null; - } - - const leftOwner = normalizeTableName(leftRef.owner); - const rightOwner = normalizeTableName(rightRef.owner); - if (!isOwnerMatch(leftOwner, currentSource) || !isOwnerMatch(rightOwner, joinedSource)) { - return null; - } - - return { - currentColumn: leftRef.column, - joinedColumn: rightRef.column - }; -} - -function extractColumnReference(value: ValueComponent): { owner: string; column: string } | null { - if (!(value instanceof ColumnReference)) { - return null; - } - - const owner = value.namespaces?.map((namespace) => namespace.name).join('.'); - if (!owner) { - return null; - } - - const column = value.column.name; - if (!column) { - return null; - } - - return { - owner, - column - }; -} - -interface ResolvedSourceTable { - tableName: string; - alias: string; - ownerNames: Set; -} - -function isOwnerMatch(owner: string, source: ResolvedSourceTable): boolean { - return source.ownerNames.has(normalizeTableName(owner)); -} - -function findMatchingRelation( - candidates: ReturnType, - expectedParentTable: string, - comparison: JoinComparison -): ReturnType[number] | null { - const matches = candidates.filter((candidate) => candidate.parentTable === expectedParentTable); - if (matches.length === 0) { - return null; - } - - const matched = matches.find((candidate) => relationMatchesComparison(candidate, comparison)); - if (!matched) { - return null; - } - - return matched; -} - -function relationMatchesComparison(candidate: ReturnType[number], comparison: JoinComparison): boolean { - if (candidate.childColumns.length === 0 || candidate.parentColumns.length === 0) { - return true; - } - - return sameNormalizedColumns(candidate.childColumns, comparison.currentColumns) && - sameNormalizedColumns(candidate.parentColumns, comparison.joinedColumns); -} - -function sameNormalizedColumns(left: string[], right: string[]): boolean { - if (left.length !== right.length) { - return false; - } - - const normalize = (values: string[]) => [...values].map((value) => value.toLowerCase()).sort(); - const leftNormalized = normalize(left); - const rightNormalized = normalize(right); - return leftNormalized.every((value, index) => value === rightNormalized[index]); -} - -function hasAmbiguousRelation( - relationGraph: RelationGraph, - leftTable: string, - rightTable: string -): boolean { - const forward = getOutgoingRelations(relationGraph, leftTable).filter((edge) => edge.parentTable === rightTable); - const reverse = getOutgoingRelations(relationGraph, rightTable).filter((edge) => edge.parentTable === leftTable); - return forward.length > 1 || reverse.length > 1; -} - -function isBridgeTableCandidate(relationGraph: RelationGraph, tableName: string): boolean { - const outgoing = getOutgoingRelations(relationGraph, tableName); - const distinctParents = new Set(outgoing.map((edge) => edge.parentTable)); - return distinctParents.size >= 2; -} - -function buildJoinDirectionIssue( - scope: string, - joinType: string, - subjectTable: string, - joinedTable: string, - relation: ReturnType[number] -): QueryLintIssue { - return { - type: 'join-direction', - severity: 'warning', - cte: scope === 'FINAL_QUERY' ? undefined : scope, - join_type: joinType, - subject_table: subjectTable, - joined_table: joinedTable, - child_table: relation.childTable, - parent_table: relation.parentTable, - child_columns: relation.childColumns, - parent_columns: relation.parentColumns, - message: `JOIN direction is reversed for ${relation.childTable} -> ${relation.parentTable}; prefer starting from the child table and joining upward` - }; -} - -function isInspectableInnerJoin(joinType: string): boolean { - const normalized = joinType.trim().toLowerCase(); - return normalized === 'join' || normalized === 'inner join'; -} - -function buildLeadingCommaIssues(sql: string): QueryLintIssue[] { - const issues: QueryLintIssue[] = []; - - const lexemes = new SqlTokenizer(sql).tokenize(); - for (let index = 0; index < lexemes.length; index++) { - const comma = lexemes[index]; - if (comma.value !== ',') { - continue; - } - - const next = lexemes[index + 1]; - const commaPosition = comma.position; - const nextPosition = next?.position; - if ( - commaPosition?.startLine !== undefined && - commaPosition.startColumn !== undefined && - nextPosition?.startLine !== undefined && - nextPosition.startColumn !== undefined && - nextPosition.startLine > commaPosition.startLine - ) { - issues.push({ - type: 'leading-comma', - severity: 'warning', - line: commaPosition.startLine, - column: commaPosition.startColumn, - message: `comma should lead the continued line at ${nextPosition.startLine}:${nextPosition.startColumn} instead of trailing line ${commaPosition.startLine}` - }); - } - } - - return issues; -} - -function hasLintSuppression(sql: string, rule: QueryLintRule): boolean { - return new RegExp(`ztd-lint-disable\\s+${rule}`, 'i').test(sql); -} - -function normalizeSqlFragment(sql: string): string { - return sql - .replace(/\s+/g, ' ') - .trim() - .toLowerCase(); -} - -function assertSelectStatement(statement: unknown): SimpleSelectQuery | BinarySelectQuery | ValuesQuery { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof BinarySelectQuery || - statement instanceof ValuesQuery - ) { - return statement; - } - - throw new Error('Expected a SELECT-compatible statement while linting query patterns.'); -} - -function toSupportedStatement(statement: unknown): SupportedStatement | null { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof BinarySelectQuery || - statement instanceof ValuesQuery || - statement instanceof InsertQuery || - statement instanceof UpdateQuery || - statement instanceof DeleteQuery - ) { - return statement; - } - - return null; -} - diff --git a/packages/ztd-cli/src/query/location.ts b/packages/ztd-cli/src/query/location.ts deleted file mode 100644 index 9a8bb9dc9..000000000 --- a/packages/ztd-cli/src/query/location.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { QueryUsageClauseAnchor, QueryUsageLocation } from './types'; - -const MAX_SNIPPET_LENGTH = 200; -const MAX_STATEMENT_CACHE_SIZE = 256; - -interface LocatedText { - location: QueryUsageLocation | null; - snippet: string; - ambiguous: boolean; -} - -interface OccurrenceRange { - start: number; - end: number; -} - -interface ClauseMarker { - start: number; - end: number; - keyword: string; -} - -interface StatementLocationCache { - clauseMarkers: ClauseMarker[]; -} - -const statementCache = new Map(); - -export function clearStatementCache(): void { - statementCache.clear(); -} - -/** - * Locate the best matching occurrence in a statement and project it to file-relative coordinates. - */ -export function locateUsageText(params: { - statementText: string; - statementStartOffsetInFile: number; - candidates: string[]; - clauseAnchor?: QueryUsageClauseAnchor; - snippetMode?: 'clause' | 'line'; -}): LocatedText { - const cache = getStatementCache(params.statementText); - const occurrences = collectOccurrences(params.statementText, params.candidates); - if (occurrences.length === 0) { - return { - location: null, - snippet: params.statementText.trim(), - ambiguous: false - }; - } - - const clauseWindow = params.clauseAnchor - ? findClauseWindow(cache, params.clauseAnchor) - : null; - - const selected = selectOccurrence(occurrences, clauseWindow); - if (!selected) { - return { - location: null, - snippet: extractSnippet(params.statementText, 0, Math.min(params.statementText.length, MAX_SNIPPET_LENGTH)), - ambiguous: occurrences.length > 1 - }; - } - - const snippet = params.snippetMode === 'line' - ? extractLineSnippet(params.statementText, selected.start, selected.end) - : extractClauseSnippet(params.statementText, selected.start, selected.end, clauseWindow); - - return { - location: buildLocation(params.statementText, params.statementStartOffsetInFile, selected.start, selected.end), - snippet, - ambiguous: selected.ambiguous - }; -} - -function getStatementCache(statementText: string): StatementLocationCache { - const cached = statementCache.get(statementText); - if (cached) { - // Refresh cache recency on access so older statements are evicted first. - statementCache.delete(statementText); - statementCache.set(statementText, cached); - return cached; - } - - const clauseMarkers = Array.from(statementText.matchAll(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|RETURNING|SET|JOIN|ON|USING|FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b/gi)) - .map((match) => ({ - start: match.index ?? 0, - end: (match.index ?? 0) + match[0].length, - keyword: match[0].toUpperCase() - })) - .sort((left, right) => left.start - right.start || left.end - right.end); - - const value = { clauseMarkers }; - setStatementCache(statementText, value); - return value; -} - -function setStatementCache(statementText: string, value: StatementLocationCache): void { - if (statementCache.has(statementText)) { - statementCache.delete(statementText); - } - statementCache.set(statementText, value); - - // Keep the cache bounded for long-running CLI processes and large batch scans. - while (statementCache.size > MAX_STATEMENT_CACHE_SIZE) { - const oldestKey = statementCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - statementCache.delete(oldestKey); - } -} - -function selectOccurrence( - occurrences: OccurrenceRange[], - clauseWindow: { anchorStart: number; start: number; end: number } | null -): (OccurrenceRange & { ambiguous: boolean }) | null { - if (!clauseWindow) { - return occurrences.length > 0 - ? { - ...occurrences[0], - ambiguous: occurrences.length > 1 - } - : null; - } - - const inWindow = occurrences.filter((occurrence) => - occurrence.start >= clauseWindow.start && - occurrence.start < clauseWindow.end - ); - const candidates = inWindow.length > 0 - ? inWindow - : occurrences.filter((occurrence) => occurrence.start >= clauseWindow.start); - const selectedPool = candidates.length > 0 ? candidates : occurrences; - if (selectedPool.length === 0) { - return null; - } - - const selected = [...selectedPool].sort((left, right) => - Math.abs(left.start - clauseWindow.start) - Math.abs(right.start - clauseWindow.start) || - left.start - right.start || - (right.end - right.start) - (left.end - left.start) - )[0]; - - return { - ...selected, - ambiguous: selectedPool.length > 1 - }; -} - -function findClauseWindow( - cache: StatementLocationCache, - clauseAnchor: QueryUsageClauseAnchor -): { anchorStart: number; start: number; end: number } | null { - const anchorPattern = clauseAnchor.tokens.join(' ').toUpperCase(); - const anchor = cache.clauseMarkers.find((marker) => marker.keyword === anchorPattern); - if (!anchor) { - return null; - } - - const nextClause = cache.clauseMarkers.find((marker) => marker.start > anchor.end); - return { - anchorStart: anchor.start, - start: anchor.end, - end: nextClause?.start ?? Number.MAX_SAFE_INTEGER - }; -} - -function collectOccurrences(statementText: string, candidates: string[]): OccurrenceRange[] { - const occurrences: OccurrenceRange[] = []; - for (const candidate of Array.from(new Set(candidates.filter(Boolean)))) { - const pattern = buildCandidatePattern(candidate); - for (const match of statementText.matchAll(pattern)) { - const start = match.index ?? -1; - if (start < 0) { - continue; - } - occurrences.push({ - start, - end: start + match[0].length - }); - } - } - return dedupeOccurrences(occurrences); -} - -function buildCandidatePattern(candidate: string): RegExp { - const parts = candidate.split('.'); - const pattern = parts - .map((part) => `(?:"${escapeRegex(part)}"|${escapeRegex(part)})`) - .join('\\s*\\.\\s*'); - return new RegExp(`(? - left.start - right.start || - (right.end - right.start) - (left.end - left.start) || - left.end - right.end - ); - const deduped: OccurrenceRange[] = []; - for (const occurrence of sorted) { - const duplicate = deduped.find((entry) => - (entry.start === occurrence.start && entry.end === occurrence.end) || - (occurrence.start >= entry.start && occurrence.end <= entry.end) - ); - if (!duplicate) { - deduped.push(occurrence); - } - } - return deduped.sort((left, right) => - left.start - right.start || - (right.end - right.start) - (left.end - left.start) - ); -} - -function clampSnippetEnd(selectedEnd: number, clauseEnd: number): number { - return Math.min(Math.max(selectedEnd, 0) + MAX_SNIPPET_LENGTH, clauseEnd); -} - -function extractClauseSnippet( - statementText: string, - selectedStart: number, - selectedEnd: number, - clauseWindow: { anchorStart: number; start: number; end: number } | null -): string { - const snippetStart = clauseWindow ? clauseWindow.anchorStart : selectedStart; - const snippetEnd = clampSnippetEnd(selectedEnd, clauseWindow?.end ?? statementText.length); - return extractSnippet(statementText, snippetStart, snippetEnd); -} - -function extractLineSnippet(statementText: string, start: number, end: number): string { - // Keep table snippets anchored to the concrete identifier line so the reason for the match is obvious. - const lineStart = statementText.lastIndexOf('\n', Math.max(0, start - 1)) + 1; - const lineEndIndex = statementText.indexOf('\n', end); - const lineEnd = lineEndIndex >= 0 ? lineEndIndex : statementText.length; - return extractSnippet(statementText, lineStart, lineEnd); -} - -function buildLocation( - statementText: string, - statementStartOffsetInFile: number, - statementOffsetStart: number, - statementOffsetEnd: number -): QueryUsageLocation { - const start = offsetToLineColumn(statementText, statementOffsetStart); - const end = offsetToLineColumn(statementText, statementOffsetEnd); - return { - startLine: start.line, - startColumn: start.column, - endLine: end.line, - endColumn: end.column, - fileOffsetStart: statementStartOffsetInFile + statementOffsetStart, - fileOffsetEnd: statementStartOffsetInFile + statementOffsetEnd, - statementOffsetStart, - statementOffsetEnd - }; -} - -function extractSnippet(statementText: string, start: number, end: number): string { - return statementText.slice(Math.max(0, start), Math.min(end, statementText.length)).trim(); -} - -function offsetToLineColumn(text: string, offset: number): { line: number; column: number } { - const safeOffset = Math.max(0, Math.min(offset, text.length)); - const lines = text.slice(0, safeOffset).split('\n'); - return { - line: lines.length, - column: lines[lines.length - 1].length + 1 - }; -} - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/packages/ztd-cli/src/query/patch.ts b/packages/ztd-cli/src/query/patch.ts deleted file mode 100644 index b82b7835b..000000000 --- a/packages/ztd-cli/src/query/patch.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { createTwoFilesPatch } from 'diff'; -import { - BinarySelectQuery, - CommonTable, - DeleteQuery, - InsertQuery, - SimpleSelectQuery, - SqlFormatter, - SqlParser, - UpdateQuery, - ValuesQuery, - WithClause -} from 'rawsql-ts'; -import { ensureDirectory } from '../utils/fs'; -import { assertSupportedStatement, type SupportedStatement } from './analysis'; - -export interface QueryPatchApplyOptions { - cte: string; - from: string; - out?: string; - preview?: boolean; -} - -export interface QueryPatchApplyReport { - file: string; - edited_file: string; - target_cte: string; - preview: boolean; - changed: boolean; - written: boolean; - output_file: string; - updated_sql: string; - diff: string; -} - -/** - * Replace one CTE in the original SQL with the matching edited CTE definition. - */ -export function applyQueryPatch(sqlFile: string, options: QueryPatchApplyOptions): QueryPatchApplyReport { - const targetCte = normalizeTargetCte(options.cte); - const absoluteOriginalPath = path.resolve(sqlFile); - const absoluteEditedPath = path.resolve(options.from); - const outputFile = path.resolve(options.out ?? absoluteOriginalPath); - const originalSql = readFileSync(absoluteOriginalPath, 'utf8'); - const editedSql = readFileSync(absoluteEditedPath, 'utf8'); - - const originalStatement = assertSupportedStatement(SqlParser.parse(originalSql), 'ztd query patch apply'); - const originalWithClause = requireWithClause(originalStatement, absoluteOriginalPath); - const replacement = extractReplacementCte(editedSql, targetCte, absoluteEditedPath); - const targetIndex = findExactlyOneCteIndex(originalWithClause, targetCte, absoluteOriginalPath); - - // Replace only the requested CTE while preserving the original WITH clause order. - originalWithClause.tables.splice(targetIndex, 1, replacement); - - const formatter = new SqlFormatter(); - const updatedSql = `${formatter.format(originalStatement).formattedSql}\n`; - - // Re-parse the emitted SQL so syntax errors fail before any file write happens. - assertSupportedStatement(SqlParser.parse(updatedSql), 'ztd query patch apply'); - - const diff = createPatch( - absoluteOriginalPath, - outputFile, - originalSql, - updatedSql - ); - const changed = normalizeLineEndings(originalSql) !== normalizeLineEndings(updatedSql); - const preview = Boolean(options.preview); - - if (!preview) { - ensureDirectory(path.dirname(outputFile)); - writeFileSync(outputFile, updatedSql, 'utf8'); - } - - return { - file: absoluteOriginalPath, - edited_file: absoluteEditedPath, - target_cte: targetCte, - preview, - changed, - written: !preview, - output_file: outputFile, - updated_sql: updatedSql, - diff - }; -} - -function normalizeTargetCte(value: string): string { - const normalized = value.trim(); - if (!normalized) { - throw new Error('ztd query patch apply requires --cte .'); - } - return normalized; -} - -function extractReplacementCte(sql: string, targetCte: string, sourceFile: string): CommonTable { - try { - const parsed = assertSupportedStatement(SqlParser.parse(sql), 'ztd query patch apply'); - return extractReplacementCteFromStatement(parsed, targetCte, sourceFile); - } catch (statementError) { - try { - const snippet = sql.trim().replace(/;\s*$/, ''); - const parsed = assertSupportedStatement(SqlParser.parse(`with ${snippet} select 1`), 'ztd query patch apply'); - return extractReplacementCteFromStatement(parsed, targetCte, sourceFile); - } catch { - throw statementError; - } - } -} - -function extractReplacementCteFromStatement(statement: SupportedStatement, targetCte: string, sourceFile: string): CommonTable { - const withClause = getWithClause(statement); - if (!withClause) { - throw new Error(`Edited SQL must include the target CTE "${targetCte}" in a WITH clause.`); - } - - const targetIndex = findExactlyOneCteIndex(withClause, targetCte, sourceFile); - return withClause.tables[targetIndex]; -} - -function requireWithClause(statement: SupportedStatement, sourceFile: string): WithClause { - const withClause = getWithClause(statement); - if (!withClause) { - throw new Error(`SQL file does not contain a WITH clause: ${sourceFile}`); - } - return withClause; -} - -function findExactlyOneCteIndex(withClause: WithClause, targetCte: string, sourceFile: string): number { - const matches = withClause.tables - .map((cte: CommonTable, index: number) => ({ cte, index })) - .filter((entry) => entry.cte.aliasExpression.table.name.toLowerCase() === targetCte.toLowerCase()); - - if (matches.length === 0) { - throw new Error(`CTE "${targetCte}" was not found in ${sourceFile}.`); - } - if (matches.length > 1) { - throw new Error(`CTE "${targetCte}" appears multiple times in ${sourceFile}; patch apply requires a unique target.`); - } - - return matches[0].index; -} - -function createPatch(originalFile: string, outputFile: string, before: string, after: string): string { - return createTwoFilesPatch( - normalizePath(originalFile), - normalizePath(outputFile), - normalizeLineEndings(before), - normalizeLineEndings(after), - '', - '', - { context: 3 } - ); -} - -function normalizeLineEndings(value: string): string { - return value.replace(/\r\n/g, '\n'); -} - -function normalizePath(value: string): string { - return value.split(path.sep).join('/'); -} - -function getWithClause(statement: SupportedStatement): WithClause | null { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof ValuesQuery || - statement instanceof UpdateQuery || - statement instanceof DeleteQuery - ) { - return statement.withClause ?? null; - } - - if (statement instanceof InsertQuery) { - return statement.selectQuery ? getSelectWithClause(assertSelectStatement(statement.selectQuery)) : null; - } - - return getSelectWithClause(statement); -} - -function getSelectWithClause(statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery): WithClause | null { - if (statement instanceof SimpleSelectQuery || statement instanceof ValuesQuery) { - return statement.withClause ?? null; - } - - let current: BinarySelectQuery | SimpleSelectQuery | ValuesQuery = statement; - while (current instanceof BinarySelectQuery) { - if (current.left instanceof BinarySelectQuery) { - current = current.left; - continue; - } - - if (current.left instanceof SimpleSelectQuery || current.left instanceof ValuesQuery) { - return current.left.withClause ?? null; - } - - break; - } - - return null; -} - -function assertSelectStatement(statement: unknown): SimpleSelectQuery | BinarySelectQuery | ValuesQuery { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof BinarySelectQuery || - statement instanceof ValuesQuery - ) { - return statement; - } - - throw new Error('Expected a SELECT-compatible statement.'); -} - diff --git a/packages/ztd-cli/src/query/planner.ts b/packages/ztd-cli/src/query/planner.ts deleted file mode 100644 index f7481972b..000000000 --- a/packages/ztd-cli/src/query/planner.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { DeleteQuery, InsertQuery, SqlParser, UpdateQuery } from 'rawsql-ts'; -import { analyzeStatement, assertSupportedStatement } from './analysis'; -import { buildQueryStructureReport, type QueryStructureReport } from './structure'; - -export type QueryPipelinePlanFormat = 'text' | 'json'; -export type QueryPipelineStepKind = 'materialize' | 'materialize-returning' | 'final-query'; - -export interface QueryPipelineMetadata { - material?: string[]; - scalarFilterColumns?: string[]; -} - -export interface QueryPipelineStep { - step: number; - kind: QueryPipelineStepKind; - target: string; - depends_on: string[]; -} - -export interface QueryPipelinePlan { - file: string; - query_type: QueryStructureReport['query_type']; - final_query: string | null; - metadata: { - material: string[]; - scalarFilterColumns: string[]; - }; - steps: QueryPipelineStep[]; -} - -/** - * Build a deterministic execution plan from query structure and runtime metadata. - */ -export function buildQueryPipelinePlan( - sqlFile: string, - metadata: QueryPipelineMetadata = {} -): QueryPipelinePlan { - const report = buildQueryStructureReport(sqlFile); - const material = uniquePreservingOrder(metadata.material ?? []); - const scalarFilterColumns = uniquePreservingOrder(metadata.scalarFilterColumns ?? []); - const cteNameSet = new Set(report.ctes.map((cte) => cte.name)); - - validateKnownCtes(material, cteNameSet, 'material'); - - const orderedCtes = topologicallySortCtes(report); - const plannedCtes = orderedCtes.filter((name) => material.includes(name)); - const cteMap = new Map(report.ctes.map((cte) => [cte.name, cte])); - const returningCteNames = collectReturningCteNames(sqlFile); - - const steps: QueryPipelineStep[] = plannedCtes.map((name, index) => ({ - step: index + 1, - kind: returningCteNames.has(name) ? 'materialize-returning' : 'materialize', - target: name, - depends_on: [...(cteMap.get(name)?.depends_on ?? [])] - })); - - steps.push({ - step: steps.length + 1, - kind: 'final-query', - target: 'FINAL_QUERY', - depends_on: resolveFinalQueryDependencies(report, cteNameSet) - }); - - return { - file: report.file, - query_type: report.query_type, - final_query: report.final_query, - metadata: { - material, - scalarFilterColumns - }, - steps - }; -} - -/** - * Render the pipeline plan for machine or human consumption. - */ -export function formatQueryPipelinePlan(plan: QueryPipelinePlan, format: QueryPipelinePlanFormat): string { - if (format === 'json') { - return `${JSON.stringify(plan, null, 2)}\n`; - } - return `${formatQueryPipelineText(plan)}\n`; -} - -function formatQueryPipelineText(plan: QueryPipelinePlan): string { - const lines = [ - `Query type: ${plan.query_type}`, - `Material CTEs: ${plan.metadata.material.length > 0 ? plan.metadata.material.join(', ') : '(none)'}`, - `Scalar filter columns: ${plan.metadata.scalarFilterColumns.length > 0 ? plan.metadata.scalarFilterColumns.join(', ') : '(none)'}`, - '', - 'Planned steps:' - ]; - - for (const step of plan.steps) { - lines.push(`${step.step}. ${describeStep(step)}`); - lines.push(` depends_on: ${step.depends_on.length > 0 ? step.depends_on.join(', ') : '(none)'}`); - } - - return lines.join('\n'); -} - -function describeStep(step: QueryPipelineStep): string { - switch (step.kind) { - case 'materialize': - return `materialize ${step.target}`; - case 'materialize-returning': - return `materialize returning ${step.target}`; - case 'final-query': - default: - return 'run final query'; - } -} - -function collectReturningCteNames(sqlFile: string): Set { - const absolutePath = path.resolve(sqlFile); - const sql = readFileSync(absolutePath, 'utf8'); - const statement = assertSupportedStatement(SqlParser.parse(sql), 'ztd query plan'); - const analysis = analyzeStatement(statement); - const names = new Set(); - - for (const cte of analysis.ctes) { - const query = cte.query; - if ( - (query instanceof InsertQuery || query instanceof UpdateQuery || query instanceof DeleteQuery) && - query.returningClause - ) { - names.add(cte.aliasExpression.table.name); - } - } - - return names; -} - -function validateKnownCtes(names: string[], cteNameSet: Set, label: 'material'): void { - for (const name of names) { - if (!cteNameSet.has(name)) { - throw new Error(`Unknown ${label} CTE: ${name}`); - } - } -} - -function resolveFinalQueryDependencies(report: QueryStructureReport, cteNameSet: Set): string[] { - if (!report.final_query) { - return []; - } - - return report.final_query - .split(',') - .map((value) => value.trim()) - .filter((value) => value.length > 0 && cteNameSet.has(value)); -} - -function topologicallySortCtes(report: QueryStructureReport): string[] { - const cteMap = new Map(report.ctes.map((cte) => [cte.name, cte])); - const cteOrder = new Map(report.ctes.map((cte, index) => [cte.name, index])); - const visited = new Set(); - const visiting = new Set(); - const ordered: string[] = []; - - const visit = (name: string) => { - if (visited.has(name)) { - return; - } - if (visiting.has(name)) { - throw new Error(`Circular CTE dependency detected while planning: ${name}`); - } - - visiting.add(name); - const dependencies = [...(cteMap.get(name)?.depends_on ?? [])].sort( - (left, right) => (cteOrder.get(left) ?? Number.MAX_SAFE_INTEGER) - (cteOrder.get(right) ?? Number.MAX_SAFE_INTEGER) - ); - - // Visit dependencies first so every emitted step is ready to execute. - for (const dependency of dependencies) { - visit(dependency); - } - - visiting.delete(name); - visited.add(name); - ordered.push(name); - }; - - for (const cte of report.ctes) { - visit(cte.name); - } - - return ordered; -} - -function uniquePreservingOrder(values: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - - for (const value of values) { - if (seen.has(value)) { - continue; - } - seen.add(value); - result.push(value); - } - - return result; -} diff --git a/packages/ztd-cli/src/query/report.ts b/packages/ztd-cli/src/query/report.ts deleted file mode 100644 index cb3080f39..000000000 --- a/packages/ztd-cli/src/query/report.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - buildQueryUsageReport as buildCoreQueryUsageReport, - QUERY_USES_REPORT_SPANS, - writeQueryUsageOutput, -} from '@rawsql-ts/sql-grep-core'; -import type { BuildQueryUsageReportParams } from '@rawsql-ts/sql-grep-core'; -import { withSpanSync } from '../utils/telemetry'; -import type { TelemetryAttributes } from '../utils/telemetry'; - -export { QUERY_USES_REPORT_SPANS, writeQueryUsageOutput }; - -const runQueryUsageSpan: NonNullable = (name, fn, attrs) => - withSpanSync(name, fn, attrs as TelemetryAttributes); - -/** - * Build a deterministic impact or detail investigation report from catalog specs. - */ -export function buildQueryUsageReport(params: Omit) { - return buildCoreQueryUsageReport({ - ...params, - withSpanSync: runQueryUsageSpan, - }); -} diff --git a/packages/ztd-cli/src/query/scalarFilterAnalysis.ts b/packages/ztd-cli/src/query/scalarFilterAnalysis.ts deleted file mode 100644 index 23a794078..000000000 --- a/packages/ztd-cli/src/query/scalarFilterAnalysis.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { - BinaryExpression, - BinarySelectQuery, - ColumnReference, - CommonTable, - DeleteQuery, - IdentifierString, - InlineQuery, - ParenExpression, - InsertQuery, - RawString, - SimpleSelectQuery, - SqlParser, - TableSource, - UpdateQuery, - ValuesQuery, -} from 'rawsql-ts'; -import { assertSupportedStatement, type SupportedStatement } from './analysis'; - -const SUPPORTED_COMPARISON_OPERATORS = new Set(['=', '!=', '<>', '>', '>=', '<', '<=']); - -/** - * Detect non-correlated WHERE scalar subqueries that are good scalar-filter binding candidates. - */ -export function findScalarFilterCandidates(sqlFile: string): string[] { - const statement = assertSupportedStatement(SqlParser.parse(readFileSync(sqlFile, 'utf8')), 'findScalarFilterCandidates'); - return findScalarFilterCandidatesInStatement(statement); -} - -export function findScalarFilterCandidatesInStatement(statement: SupportedStatement): string[] { - const candidates: string[] = []; - collectScalarFilterCandidatesFromStatement(statement, candidates); - return uniquePreservingOrder(candidates); -} - -function collectScalarFilterCandidatesFromStatement(statement: SupportedStatement, candidates: string[]): void { - if (statement instanceof SimpleSelectQuery) { - collectScalarFilterCandidatesFromSimpleSelect(statement, candidates); - return; - } - - if (statement instanceof BinarySelectQuery) { - collectScalarFilterCandidatesFromSelectNode(statement, candidates); - return; - } - - if (statement instanceof ValuesQuery) { - return; - } - - if (statement instanceof InsertQuery) { - if (statement.selectQuery) { - collectScalarFilterCandidatesFromSelectNode(assertSelectQuery(statement.selectQuery), candidates); - } - return; - } - - if (statement instanceof UpdateQuery || statement instanceof DeleteQuery) { - return; - } -} - -function collectScalarFilterCandidatesFromSelectNode( - statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery, - candidates: string[] -): void { - if (statement instanceof SimpleSelectQuery) { - collectScalarFilterCandidatesFromSimpleSelect(statement, candidates); - return; - } - - if (statement instanceof BinarySelectQuery) { - collectScalarFilterCandidatesFromSelectNode(assertSelectQuery(statement.left), candidates); - collectScalarFilterCandidatesFromSelectNode(assertSelectQuery(statement.right), candidates); - } -} - -function collectScalarFilterCandidatesFromSimpleSelect(statement: SimpleSelectQuery, candidates: string[]): void { - for (const cte of getWithClauseTables(statement.withClause)) { - collectScalarFilterCandidatesFromCte(cte, candidates); - } - - if (!statement.whereClause) { - return; - } - - collectScalarFilterCandidatesFromExpression(statement.whereClause.condition, candidates); -} - -function collectScalarFilterCandidatesFromCte(cte: CommonTable, candidates: string[]): void { - collectScalarFilterCandidatesFromSelectNode(assertSelectQuery(cte.query), candidates); -} - -function collectScalarFilterCandidatesFromExpression(expression: unknown, candidates: string[]): void { - if (!(expression instanceof BinaryExpression)) { - return; - } - - const operator = extractOperator(expression.operator); - if (!SUPPORTED_COMPARISON_OPERATORS.has(operator)) { - collectScalarFilterCandidatesFromExpression(expression.left, candidates); - collectScalarFilterCandidatesFromExpression(expression.right, candidates); - return; - } - - const leftInline = unwrapInlineQuery(expression.left); - const rightInline = unwrapInlineQuery(expression.right); - - if (leftInline && isEligibleScalarSubquery(leftInline)) { - candidates.push(...collectColumnNames(expression.right)); - } - - if (rightInline && isEligibleScalarSubquery(rightInline)) { - candidates.push(...collectColumnNames(expression.left)); - } - - collectScalarFilterCandidatesFromExpression(expression.left, candidates); - collectScalarFilterCandidatesFromExpression(expression.right, candidates); -} - -function isEligibleScalarSubquery(inlineQuery: InlineQuery): boolean { - const selectQuery = inlineQuery.selectQuery; - if (!(selectQuery instanceof SimpleSelectQuery)) { - return false; - } - - if (!hasExactlyOneProjectedColumn(selectQuery)) { - return false; - } - - return !isCorrelatedScalarSubquery(selectQuery); -} - -function hasExactlyOneProjectedColumn(selectQuery: SimpleSelectQuery): boolean { - if (selectQuery.selectClause.items.length !== 1) { - return false; - } - - const [item] = selectQuery.selectClause.items; - return !(item?.value instanceof RawString && item.value.value.trim() === '*'); -} - -function isCorrelatedScalarSubquery(selectQuery: SimpleSelectQuery): boolean { - const localNames = collectLocalRelationNames(selectQuery); - const columnReferences = collectColumnReferences(selectQuery); - - return columnReferences.some((reference) => { - const qualifierParts = reference.qualifiedName.namespaces?.map((namespace) => namespace.name) ?? []; - if (qualifierParts.length === 0) { - return false; - } - - const qualifier = qualifierParts[qualifierParts.length - 1]; - return !localNames.has(qualifier); - }); -} - -function collectLocalRelationNames(selectQuery: SimpleSelectQuery): Set { - const localNames = new Set(); - const sources = selectQuery.fromClause?.getSources() ?? []; - - for (const source of sources) { - const aliasName = source.aliasExpression?.table?.name; - if (aliasName) { - localNames.add(aliasName); - } - - if (source.datasource instanceof TableSource) { - localNames.add(extractQualifiedNameLeaf(source.datasource.qualifiedName.name)); - } - } - - return localNames; -} - -function collectColumnNames(node: unknown): string[] { - const columnNames: string[] = []; - walkAst(node, (current) => { - if (!(current instanceof ColumnReference)) { - return; - } - - columnNames.push(extractQualifiedNameLeaf(current.qualifiedName.name)); - }); - return uniquePreservingOrder(columnNames); -} - -function collectColumnReferences(node: unknown): ColumnReference[] { - const matches: ColumnReference[] = []; - walkAst(node, (current) => { - if (current instanceof ColumnReference) { - matches.push(current); - } - }); - return matches; -} - -function walkAst(node: unknown, visit: (current: unknown) => void): void { - if (!node || typeof node !== 'object') { - return; - } - - visit(node); - - for (const value of Object.values(node as Record)) { - if (!value) { - continue; - } - - if (Array.isArray(value)) { - for (const item of value) { - walkAst(item, visit); - } - continue; - } - - if (typeof value === 'object') { - walkAst(value, visit); - } - } -} - -function unwrapInlineQuery(expression: unknown): InlineQuery | null { - if (expression instanceof InlineQuery) { - return expression; - } - - if (expression instanceof ParenExpression) { - return unwrapInlineQuery(expression.expression); - } - - return null; -} - -function extractOperator(operator: RawString | unknown): string { - if (operator instanceof RawString) { - return operator.value.trim(); - } - return ''; -} - -function extractQualifiedNameLeaf(name: RawString | IdentifierString): string { - return name instanceof RawString ? name.value : name.name; -} - -function assertSelectQuery(statement: unknown): SimpleSelectQuery | BinarySelectQuery | ValuesQuery { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof BinarySelectQuery || - statement instanceof ValuesQuery - ) { - return statement; - } - - throw new Error('Expected a SELECT-compatible statement for scalar filter analysis.'); -} - -function getWithClauseTables(withClause: SimpleSelectQuery['withClause']): CommonTable[] { - return withClause ? ((withClause as unknown as { tables?: CommonTable[]; commonTables?: CommonTable[] }).tables ?? (withClause as unknown as { commonTables?: CommonTable[] }).commonTables ?? []) : []; -} - -function uniquePreservingOrder(values: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - - for (const value of values) { - if (seen.has(value)) { - continue; - } - seen.add(value); - result.push(value); - } - - return result; -} - - - - - - - - diff --git a/packages/ztd-cli/src/query/slice.ts b/packages/ztd-cli/src/query/slice.ts deleted file mode 100644 index deb3939f2..000000000 --- a/packages/ztd-cli/src/query/slice.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { - BinarySelectQuery, - CommonTable, - DeleteQuery, - FromClause, - InsertQuery, - LimitClause, - LiteralValue, - RawString, - SelectClause, - SelectItem, - SimpleSelectQuery, - SourceAliasExpression, - SourceExpression, - SqlFormatter, - SqlParser, - SubQuerySource, - TableSource, - UpdateQuery, - ValuesQuery, - WithClause -} from 'rawsql-ts'; -import { - analyzeStatement, - assertSupportedStatement, - collectDependencyClosure, - collectReachableCtes, - type SupportedStatement -} from './analysis'; - -export interface QuerySliceOptions { - cte?: string; - final?: boolean; - limit?: number; - excludeCtes?: string[]; -} - -export interface QuerySliceReport { - file: string; - mode: 'cte' | 'final'; - target: string; - included_ctes: string[]; - sql: string; -} - -/** - * Build a minimal executable SQL slice for either a target CTE or the final query. - */ -export function buildQuerySliceReport(sqlFile: string, options: QuerySliceOptions): QuerySliceReport { - validateSliceOptions(options); - - const absolutePath = path.resolve(sqlFile); - const sql = readFileSync(absolutePath, 'utf8'); - const statement = assertSupportedStatement(SqlParser.parse(sql), 'ztd query slice'); - const analysis = analyzeStatement(statement); - const excludedSet = new Set(options.excludeCtes ?? []); - - if (analysis.ctes.length === 0) { - throw new Error('ztd query slice requires a query with at least one CTE.'); - } - - if (options.cte) { - return buildTargetSliceReport( - absolutePath, - statement, - analysis.ctes, - analysis.dependencyMap, - options.cte, - options.limit, - excludedSet - ); - } - - return buildFinalSliceReport(absolutePath, sql, options.limit, excludedSet); -} - -function buildTargetSliceReport( - absolutePath: string, - statement: SupportedStatement, - ctes: CommonTable[], - dependencyMap: Map, - targetName: string, - limit: number | undefined, - excludedSet: Set -): QuerySliceReport { - const stopSet = new Set([...excludedSet].filter((name) => name !== targetName)); - const includedNames = collectDependencyClosure(targetName, dependencyMap, stopSet).filter( - (name) => name === targetName || !excludedSet.has(name) - ); - if (!includedNames.includes(targetName)) { - throw new Error(`CTE not found in query: ${targetName}`); - } - - const includedCtes = filterCtesByOrder(ctes, includedNames); - if (!includedCtes.some((cte) => cte.aliasExpression.table.name === targetName)) { - throw new Error(`CTE not found in query: ${targetName}`); - } - - const formatter = new SqlFormatter(); - const sliceQuery = buildSelectFromTargetQuery(targetName, limit); - const sql = composeSliceSql( - getWithClause(statement)?.recursive ?? false, - includedCtes, - formatter.format(sliceQuery).formattedSql, - formatter - ); - - return { - file: absolutePath, - mode: 'cte', - target: targetName, - included_ctes: includedCtes.map((cte) => cte.aliasExpression.table.name), - sql: `${sql} -` - }; -} - -function buildFinalSliceReport( - absolutePath: string, - sql: string, - limit: number | undefined, - excludedSet: Set -): QuerySliceReport { - const parsed = assertSupportedStatement(SqlParser.parse(sql), 'ztd query slice'); - const analysis = analyzeStatement(parsed); - const includedSet = collectReachableCtes(analysis.rootDependencies, analysis.dependencyMap, excludedSet); - - const includedCtes = filterCtesByOrder( - analysis.ctes, - [...includedSet].filter((name) => !excludedSet.has(name)) - ); - - // Retain only the transitive closure needed by the final statement. - applyMinimalWithClause(parsed, includedCtes); - - // Preserve the original final statement and only inject a LIMIT when the final statement is SELECT-compatible. - if (limit !== undefined) { - if (parsed instanceof SimpleSelectQuery) { - parsed.limitClause = new LimitClause(new LiteralValue(limit)); - } else if (parsed instanceof ValuesQuery || parsed instanceof BinarySelectQuery) { - const formatter = new SqlFormatter(); - const wrapped = buildWrappedLimitQuery(parsed, limit); - return { - file: absolutePath, - mode: 'final', - target: 'FINAL_QUERY', - included_ctes: includedCtes.map((cte) => cte.aliasExpression.table.name), - sql: `${formatter.format(wrapped).formattedSql} -` - }; - } else { - throw new Error('--limit is only supported for SELECT final slices or --cte slices.'); - } - } - - const formatter = new SqlFormatter(); - return { - file: absolutePath, - mode: 'final', - target: 'FINAL_QUERY', - included_ctes: includedCtes.map((cte) => cte.aliasExpression.table.name), - sql: `${formatter.format(parsed).formattedSql} -` - }; -} - -function validateSliceOptions(options: QuerySliceOptions): void { - const hasTarget = typeof options.cte === 'string' && options.cte.trim() !== ''; - const hasFinal = options.final === true; - - if (hasTarget === hasFinal) { - throw new Error('Specify exactly one of --cte or --final.'); - } -} - -function filterCtesByOrder(ctes: CommonTable[], includedNames: string[]): CommonTable[] { - const includedSet = new Set(includedNames); - return ctes.filter((cte) => includedSet.has(cte.aliasExpression.table.name)); -} - -function composeSliceSql(recursive: boolean, ctes: CommonTable[], mainQuery: string, formatter: SqlFormatter): string { - if (ctes.length === 0) { - return mainQuery; - } - - // Format the WithClause directly so CommonTable metadata such as materialization hints - // and alias column lists survive the slice output intact. - const withClause = formatter.format(new WithClause(recursive, ctes)).formattedSql; - return `${withClause} ${mainQuery}`; -} - -function buildSelectFromTargetQuery(targetName: string, limit: number | undefined): SimpleSelectQuery { - return new SimpleSelectQuery({ - selectClause: new SelectClause([new SelectItem(new RawString('*'))]), - fromClause: new FromClause(new SourceExpression(new TableSource(null, targetName), null), null), - limitClause: limit === undefined ? null : new LimitClause(new LiteralValue(limit)) - }); -} - -function buildWrappedLimitQuery(statement: BinarySelectQuery | ValuesQuery, limit: number): SimpleSelectQuery { - return new SimpleSelectQuery({ - selectClause: new SelectClause([new SelectItem(new RawString('*'))]), - fromClause: new FromClause( - new SourceExpression(new SubQuerySource(statement), new SourceAliasExpression('final_slice', null)), - null - ), - limitClause: new LimitClause(new LiteralValue(limit)) - }); -} - -function applyMinimalWithClause(statement: SupportedStatement, ctes: CommonTable[]): void { - const existingWithClause = getWithClause(statement); - const nextWithClause = ctes.length > 0 ? new WithClause(existingWithClause?.recursive ?? false, ctes) : null; - - if ( - statement instanceof SimpleSelectQuery || - statement instanceof ValuesQuery || - statement instanceof UpdateQuery || - statement instanceof DeleteQuery - ) { - statement.withClause = nextWithClause; - return; - } - - if (statement instanceof InsertQuery) { - if (!statement.selectQuery) { - return; - } - - // INSERT ... SELECT stores the CTEs on the nested selectQuery, not on InsertQuery itself. - applyMinimalWithClauseToSelect(assertSelectQuery(statement.selectQuery), nextWithClause); - return; - } - - applyMinimalWithClauseToSelect(statement, nextWithClause); -} - -function applyMinimalWithClauseToSelect( - statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery, - withClause: WithClause | null -): void { - if (statement instanceof SimpleSelectQuery || statement instanceof ValuesQuery) { - statement.withClause = withClause; - return; - } - - let current: BinarySelectQuery | SimpleSelectQuery | ValuesQuery = statement; - while (current instanceof BinarySelectQuery) { - if (current.left instanceof BinarySelectQuery) { - current = current.left; - continue; - } - - if (current.left instanceof SimpleSelectQuery || current.left instanceof ValuesQuery) { - current.left.withClause = withClause; - return; - } - - break; - } - - throw new Error('Unable to apply sliced WITH clause to the final query.'); -} - -function getWithClause(statement: SupportedStatement): WithClause | null { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof ValuesQuery || - statement instanceof UpdateQuery || - statement instanceof DeleteQuery - ) { - return statement.withClause ?? null; - } - - if (statement instanceof InsertQuery) { - return statement.selectQuery ? getSelectWithClause(assertSelectQuery(statement.selectQuery)) : null; - } - - return getSelectWithClause(statement); -} - -function assertSelectQuery(statement: unknown): SimpleSelectQuery | BinarySelectQuery | ValuesQuery { - if ( - statement instanceof SimpleSelectQuery || - statement instanceof BinarySelectQuery || - statement instanceof ValuesQuery - ) { - return statement; - } - - throw new Error('Expected a SELECT-compatible statement for query slicing.'); -} - -function getSelectWithClause(statement: SimpleSelectQuery | BinarySelectQuery | ValuesQuery): WithClause | null { - if (statement instanceof SimpleSelectQuery || statement instanceof ValuesQuery) { - return statement.withClause ?? null; - } - - let current: BinarySelectQuery | SimpleSelectQuery | ValuesQuery = statement; - while (current instanceof BinarySelectQuery) { - if (current.left instanceof BinarySelectQuery) { - current = current.left; - continue; - } - - if (current.left instanceof SimpleSelectQuery || current.left instanceof ValuesQuery) { - return current.left.withClause ?? null; - } - - break; - } - - return null; -} diff --git a/packages/ztd-cli/src/query/structure.ts b/packages/ztd-cli/src/query/structure.ts deleted file mode 100644 index 87ce8af7c..000000000 --- a/packages/ztd-cli/src/query/structure.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { SqlParser, TableSourceCollector, normalizeTableName } from 'rawsql-ts'; -import { TableSource } from 'rawsql-ts'; -import { - analyzeStatement, - assertSupportedStatement, - collectDirectSources, - collectReachableCtes, - detectQueryType, - type SupportedStatement, - uniquePreservingOrder -} from './analysis'; - -export type QueryStructureFormat = 'text' | 'json' | 'dot'; - -export interface QueryStructureNode { - name: string; - depends_on: string[]; - used_by_final_query: boolean; - unused: boolean; -} - -export interface QueryStructureReport { - query_type: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE'; - file: string; - cte_count: number; - ctes: QueryStructureNode[]; - final_query: string | null; - referenced_tables: string[]; - unused_ctes: string[]; -} - -/** - * Parse a SQL file and summarize its CTE graph and referenced base tables. - */ -export function buildQueryStructureReport(sqlFile: string, commandName: string = 'ztd query outline'): QueryStructureReport { - const absolutePath = path.resolve(sqlFile); - const sql = readFileSync(absolutePath, 'utf8'); - const parsed = SqlParser.parse(sql); - const statement = assertSupportedStatement(parsed, commandName); - const analysis = analyzeStatement(statement); - const usedCtes = collectReachableCtes(analysis.rootDependencies, analysis.dependencyMap); - const unusedCtes = analysis.cteNames.filter((name) => !usedCtes.has(name)).sort(); - const referencedTables = Array.from( - new Set(new TableSourceCollector(false).collect(statement).map((source) => normalizeCollectedTableName(source))) - ).sort(); - - return { - query_type: detectQueryType(statement), - file: absolutePath, - cte_count: analysis.ctes.length, - ctes: analysis.cteNames.map((name) => ({ - name, - depends_on: [...(analysis.dependencyMap.get(name) ?? [])].sort(), - used_by_final_query: usedCtes.has(name), - unused: !usedCtes.has(name) - })), - final_query: resolveFinalQuery(statement, analysis.cteNames, analysis.rootDependencies), - referenced_tables: referencedTables, - unused_ctes: unusedCtes - }; -} - -/** - * Render the query structure report in the requested output format. - */ -export function formatQueryStructureReport(report: QueryStructureReport, format: QueryStructureFormat): string { - switch (format) { - case 'json': - return `${JSON.stringify(report, null, 2)}\n`; - case 'dot': - return `${formatQueryStructureDot(report)}\n`; - case 'text': - default: - return `${formatQueryStructureText(report)}\n`; - } -} - -function formatQueryStructureText(report: QueryStructureReport): string { - const lines = [ - `Query type: ${report.query_type}`, - `CTE count: ${report.cte_count}`, - '', - 'CTEs:' - ]; - - if (report.ctes.length === 0) { - lines.push('(none)'); - } else { - report.ctes.forEach((cte, index) => { - const suffix = cte.unused ? ' [unused]' : ''; - lines.push(`${index + 1}. ${cte.name}${suffix}`); - lines.push(` depends_on: ${cte.depends_on.length > 0 ? cte.depends_on.join(', ') : '(none)'}`); - }); - } - - lines.push('', 'Final query target:', report.final_query ?? '(none)'); - lines.push('', 'Referenced tables:'); - if (report.referenced_tables.length === 0) { - lines.push('(none)'); - } else { - lines.push(...report.referenced_tables); - } - - lines.push('', 'Unused CTEs:'); - if (report.unused_ctes.length === 0) { - lines.push('(none)'); - } else { - lines.push(...report.unused_ctes); - } - - return lines.join('\n'); -} - -function formatQueryStructureDot(report: QueryStructureReport): string { - const lines = ['digraph query_structure {', ' rankdir=LR;', ' "FINAL_QUERY" [shape=box];']; - const directRoots = report.final_query ? report.final_query.split(', ').filter(Boolean) : []; - - for (const cte of report.ctes) { - const attributes = cte.unused ? ' [style=dashed]' : ''; - lines.push(` "${cte.name}"${attributes};`); - } - - for (const cte of report.ctes) { - for (const dependency of cte.depends_on) { - lines.push(` "${cte.name}" -> "${dependency}";`); - } - if (directRoots.includes(cte.name)) { - lines.push(` "FINAL_QUERY" -> "${cte.name}";`); - } - } - - lines.push('}'); - return lines.join('\n'); -} - -function resolveFinalQuery(statement: SupportedStatement, cteNames: string[], rootDependencies: string[]): string | null { - if (rootDependencies.length > 0) { - return rootDependencies.join(', '); - } - - const cteNameSet = new Set(cteNames); - const directSources = uniquePreservingOrder( - collectDirectSources(statement) - .map((source) => source.datasource) - .filter((source): source is TableSource => source instanceof TableSource) - .map((source) => normalizeFinalSourceName(source, cteNameSet)) - ); - - if (directSources.length === 0) { - return null; - } - - return directSources.join(', '); -} - -function normalizeCollectedTableName(source: TableSource): string { - const namespaces = source.qualifiedName.namespaces?.map((namespace) => namespace.name) ?? []; - return normalizeTableName([...namespaces, source.table.name].join('.')); -} - -function normalizeFinalSourceName(source: TableSource, cteNameSet: Set): string { - if (cteNameSet.has(source.table.name)) { - return source.table.name; - } - return normalizeCollectedTableName(source); -} diff --git a/packages/ztd-cli/src/query/targets.ts b/packages/ztd-cli/src/query/targets.ts deleted file mode 100644 index 032a88c44..000000000 --- a/packages/ztd-cli/src/query/targets.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parseQueryTarget } from '@rawsql-ts/sql-grep-core'; -export type { ParsedQueryTarget } from '@rawsql-ts/sql-grep-core'; diff --git a/packages/ztd-cli/src/query/types.ts b/packages/ztd-cli/src/query/types.ts deleted file mode 100644 index ad2f0ca6a..000000000 --- a/packages/ztd-cli/src/query/types.ts +++ /dev/null @@ -1,107 +0,0 @@ -export type QueryUsageMode = 'exact' | 'any-schema' | 'any-schema-any-table'; -export type QueryUsageView = 'impact' | 'detail'; -export type QueryUsageConfidence = 'high' | 'medium' | 'low'; -export type QueryUsageSource = 'ast' | 'fallback'; -export type QueryUsageTargetKind = 'table' | 'column'; - -export interface QueryUsageTarget { - kind: QueryUsageTargetKind; - raw: string; - schema?: string; - table?: string; - column?: string; -} - -export interface QueryUsageLocation { - startLine: number; - startColumn: number; - endLine: number; - endColumn: number; - fileOffsetStart: number; - fileOffsetEnd: number; - statementOffsetStart?: number; - statementOffsetEnd?: number; -} - -export interface QueryUsageRepresentative { - usage_kind: string; - location: QueryUsageLocation | null; - snippet: string; - exprHints?: string[]; - confidence: QueryUsageConfidence; - notes: string[]; -} - -export interface QueryUsageMatchDetail { - kind: 'detail'; - catalog_id: string; - query_id: string; - statement_fingerprint: string; - sql_file: string; - usage_kind: string; - exprHints?: string[]; - location: QueryUsageLocation | null; - snippet: string; - confidence: QueryUsageConfidence; - notes: string[]; - source: QueryUsageSource; -} - -export interface QueryUsageMatchImpact { - kind: 'impact'; - catalog_id: string; - query_id: string; - statement_fingerprint: string; - sql_file: string; - usageKindCounts: Record; - confidence: QueryUsageConfidence; - notes: string[]; - source: QueryUsageSource; - representatives?: QueryUsageRepresentative[]; -} - -export type QueryUsageMatch = QueryUsageMatchDetail | QueryUsageMatchImpact; - -export interface QueryUsageWarning { - catalog_id?: string; - query_id?: string; - sql_file?: string; - code: string; - message: string; -} - -export interface QueryUsageReport { - schemaVersion: 2; - mode: QueryUsageMode; - view: QueryUsageView; - target: QueryUsageTarget; - summary: { - catalogsScanned: number; - statementsScanned: number; - matches: number; - fallbackMatches: number; - unresolvedSqlFiles: number; - parseWarnings: number; - }; - matches: QueryUsageMatch[]; - warnings: QueryUsageWarning[]; - display?: { - summaryOnly: boolean; - limit?: number; - totalMatches: number; - returnedMatches: number; - totalWarnings: number; - returnedWarnings: number; - truncated: boolean; - }; -} - -export interface QueryUsageAnalyzerResult { - matches: QueryUsageMatchDetail[]; - warnings: QueryUsageWarning[]; -} - -export interface QueryUsageClauseAnchor { - kind: string; - tokens: string[]; -} diff --git a/packages/ztd-cli/src/specs/sql/activeOrders.catalog.ts b/packages/ztd-cli/src/specs/sql/activeOrders.catalog.ts deleted file mode 100644 index d1556e992..000000000 --- a/packages/ztd-cli/src/specs/sql/activeOrders.catalog.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { defineSqlCatalogDefinition } from '../sqlCatalogDefinition'; - -/** - * SQL definition for listing active user orders in descending total order. - */ -export const activeOrdersCatalog = defineSqlCatalogDefinition< - { active: number; minTotal: number; limit: number }, - { orderId: number; userEmail: string; orderTotal: number } ->({ - id: 'orders.active-users.list', - params: { - shape: 'named', - example: { active: 1, minTotal: 20, limit: 2 }, - }, - output: { - mapping: { - columnMap: { - orderId: 'order_id', - userEmail: 'user_email', - orderTotal: 'order_total', - }, - }, - }, - sql: ` - SELECT - o.id AS order_id, - u.email AS user_email, - o.total AS order_total - FROM orders o - INNER JOIN users u ON u.id = o.user_id - WHERE u.active = @active - AND o.total >= @minTotal - ORDER BY o.total DESC - LIMIT @limit - `, -}); diff --git a/packages/ztd-cli/src/specs/sql/usersList.catalog.ts b/packages/ztd-cli/src/specs/sql/usersList.catalog.ts deleted file mode 100644 index ad39d8afc..000000000 --- a/packages/ztd-cli/src/specs/sql/usersList.catalog.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineSqlCatalogDefinition } from '../sqlCatalogDefinition'; - -/** - * Reference SQL catalog definition used by tests and examples. - */ -export const usersListCatalog = defineSqlCatalogDefinition<{ active: number }, { id: number }>({ - id: 'users.list', - params: { shape: 'named', example: { active: 1 } }, - output: { mapping: { columnMap: { id: 'id' } } }, - sql: 'select id from users where active = :active', -}); diff --git a/packages/ztd-cli/src/specs/sqlCatalogDefinition.ts b/packages/ztd-cli/src/specs/sqlCatalogDefinition.ts deleted file mode 100644 index 8207f3d73..000000000 --- a/packages/ztd-cli/src/specs/sqlCatalogDefinition.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Canonical SQL catalog metadata shape shared by spec-layer and test-layer code. - */ -export interface SqlCatalogDefinition, TRow extends Record> { - id: string; - params: { - shape: 'named'; - example: TParams; - }; - output: { - mapping: { - columnMap: Record; - }; - }; - sql: string; -} - -/** - * Define a SQL catalog metadata object without adding test-runtime behavior. - */ -export function defineSqlCatalogDefinition< - TParams extends Record, - TRow extends Record, ->(def: SqlCatalogDefinition): SqlCatalogDefinition { - return def; -} diff --git a/packages/ztd-cli/src/utils/agentCli.ts b/packages/ztd-cli/src/utils/agentCli.ts deleted file mode 100644 index 0f9f19cba..000000000 --- a/packages/ztd-cli/src/utils/agentCli.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -export type AgentOutputFormat = 'text' | 'json'; -export type DiagnosticSeverity = 'info' | 'warning' | 'error'; - -const OUTPUT_FORMAT_ENV = 'ZTD_CLI_OUTPUT_FORMAT'; -const DEFAULT_SCHEMA_VERSION = 1; - -export interface DiagnosticEvent { - code: string; - message: string; - severity?: DiagnosticSeverity; - details?: Record; -} - -export interface CommandEnvelope { - schemaVersion: 1; - command: string; - ok: boolean; - data: T; -} - -export function setAgentOutputFormat(format: string | undefined): void { - const normalized = normalizeOutputFormat(format); - process.env[OUTPUT_FORMAT_ENV] = normalized; -} - -export function getAgentOutputFormat(): AgentOutputFormat { - return normalizeOutputFormat(process.env[OUTPUT_FORMAT_ENV]); -} - -export function isJsonOutput(): boolean { - return getAgentOutputFormat() === 'json'; -} - -export function normalizeOutputFormat(format: string | undefined): AgentOutputFormat { - const normalized = (format ?? 'text').trim().toLowerCase(); - if (normalized === 'text' || normalized === 'json') { - return normalized; - } - throw new Error(`Unsupported output format: ${format}`); -} - -export function emitDiagnostic(event: DiagnosticEvent): void { - const severity = event.severity ?? 'info'; - if (isJsonOutput()) { - const payload = { - schemaVersion: DEFAULT_SCHEMA_VERSION, - type: 'diagnostic', - severity, - code: event.code, - message: event.message, - details: event.details ?? {} - }; - process.stderr.write(`${JSON.stringify(payload)}\n`); - return; - } - - const prefix = severity === 'error' ? '[error]' : severity === 'warning' ? '[warn]' : '[info]'; - process.stderr.write(`${prefix} ${event.message}\n`); -} - -export function writeCommandEnvelope(command: string, data: T): void { - writeCommandResultEnvelope(command, true, data); -} - -export function writeCommandResultEnvelope(command: string, ok: boolean, data: T): void { - const payload: CommandEnvelope = { - schemaVersion: DEFAULT_SCHEMA_VERSION, - command, - ok, - data - }; - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); -} - -export function parseJsonPayload>(value: string, label: string): T { - const raw = value.startsWith('@') - ? readFileSync(path.resolve(process.cwd(), value.slice(1)), 'utf8') - : value; - try { - const parsed = JSON.parse(raw); - if (!isPlainRecord(parsed)) { - throw new Error(`${label} must decode to a JSON object.`); - } - return parsed as T; - } catch (error) { - throw new Error(`Failed to parse ${label}: ${error instanceof Error ? error.message : String(error)}`); - } -} - -export function isPlainRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/packages/ztd-cli/src/utils/agentSafety.ts b/packages/ztd-cli/src/utils/agentSafety.ts deleted file mode 100644 index efd3e1c41..000000000 --- a/packages/ztd-cli/src/utils/agentSafety.ts +++ /dev/null @@ -1,45 +0,0 @@ -import path from 'node:path'; - -const CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/u; -const ENCODED_SEPARATOR_PATTERN = /%2e|%2f|%5c/i; - -export function rejectControlChars(value: string, label: string): string { - if (CONTROL_CHAR_PATTERN.test(value)) { - throw new Error(`${label} contains control characters and was rejected.`); - } - return value; -} - -export function rejectEncodedTraversal(value: string, label: string): string { - if (ENCODED_SEPARATOR_PATTERN.test(value)) { - throw new Error(`${label} contains encoded path traversal or separators and was rejected.`); - } - return value; -} - -export function validateResourceIdentifier(value: string, label: string): string { - const normalized = rejectEncodedTraversal(rejectControlChars(value.trim(), label), label); - if (normalized.includes('?') || normalized.includes('#')) { - throw new Error(`${label} must not include query or fragment characters.`); - } - if (normalized.includes('%')) { - throw new Error(`${label} must not include percent-encoded segments.`); - } - if (!normalized) { - throw new Error(`${label} must not be empty.`); - } - return normalized; -} - -export function validateProjectPath(targetPath: string, label: string, rootDir: string = process.cwd()): string { - const normalizedInput = rejectEncodedTraversal(rejectControlChars(targetPath.trim(), label), label); - if (!normalizedInput) { - throw new Error(`${label} must not be empty.`); - } - const absolute = path.resolve(rootDir, normalizedInput); - const relative = path.relative(rootDir, absolute); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new Error(`${label} must stay inside the current project root.`); - } - return absolute; -} diff --git a/packages/ztd-cli/src/utils/collectSqlFiles.ts b/packages/ztd-cli/src/utils/collectSqlFiles.ts deleted file mode 100644 index 05081ca6c..000000000 --- a/packages/ztd-cli/src/utils/collectSqlFiles.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import path from 'node:path'; - -export interface SqlSource { - path: string; - sql: string; -} - -/** - * Scans the supplied directories (recursively) for SQL files matching the configured extensions and aggregates their contents. - * @returns Sorted list of `SqlSource` records containing workspace-relative paths and SQL text for each discovered file. - */ -export function collectSqlFiles(directories: string[], extensions: string[]): SqlSource[] { - // Guard against caller forgetting to supply directories; defaults should be applied upstream. - if (directories.length === 0) { - throw new Error('DDL directories list is empty; caller must provide default paths.'); - } - - // parseExtensions already normalizes casing, but defensively re-normalize to avoid relying solely on the caller. - const normalized = extensions.map((extension) => extension.toLowerCase()); - const extensionSet = new Set(normalized); - const sources: SqlSource[] = []; - - for (const directory of directories) { - // Resolve each configured path so the file order is deterministic. - const resolvedDirectory = path.resolve(directory); - if (!existsSync(resolvedDirectory)) { - throw new Error(`DDL directory not found: ${resolvedDirectory}`); - } - - scanDirectory(resolvedDirectory, extensionSet, sources); - } - - return sources.sort((a, b) => a.path.localeCompare(b.path)); -} - -function scanDirectory(directory: string, extensions: Set, accumulator: SqlSource[]): void { - const entries = readdirSync(directory, { withFileTypes: true }); - - for (const entry of entries) { - const resolved = path.join(directory, entry.name); - - if (entry.isDirectory()) { - // Recursively descend into subdirectories before collecting files. - scanDirectory(resolved, extensions, accumulator); - continue; - } - - if (!entry.isFile()) { - continue; - } - - const extension = path.extname(entry.name).toLowerCase(); - if (!extensions.has(extension)) { - continue; - } - - const sql = readFileSync(resolved, 'utf8'); - // Empty SQL files are intentionally skipped; comment-only fixtures need a different handling path. - if (!sql.trim()) { - continue; - } - - // Store a workspace-relative path so outputs do not leak machine-specific absolute paths. - const relativePath = path.relative(process.cwd(), resolved).replace(/\\/g, '/'); - - accumulator.push({ path: relativePath, sql }); - } -} diff --git a/packages/ztd-cli/src/utils/connectionSummary.ts b/packages/ztd-cli/src/utils/connectionSummary.ts deleted file mode 100644 index a0266e59d..000000000 --- a/packages/ztd-cli/src/utils/connectionSummary.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { DbConnectionContext } from './dbConnection'; - -export function formatConnectionTarget(context?: DbConnectionContext): string { - if (!context) { - return ''; - } - - // Collect metadata that identifies the destination without exposing secrets. - const parts = [`source=${context.source}`]; - if (context.host) { - parts.push(`host=${context.host}`); - } - if (context.port) { - parts.push(`port=${context.port}`); - } - if (context.database) { - parts.push(`db=${context.database}`); - } - if (context.user) { - parts.push(`user=${context.user}`); - } - - return `target: ${parts.join(', ')}`; -} - -export function describeConnectionContext(context?: DbConnectionContext): string { - const formatted = formatConnectionTarget(context); - return formatted ? ` (${formatted})` : ''; -} diff --git a/packages/ztd-cli/src/utils/dbConnection.ts b/packages/ztd-cli/src/utils/dbConnection.ts deleted file mode 100644 index a80b62817..000000000 --- a/packages/ztd-cli/src/utils/dbConnection.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import path from 'node:path'; - -export interface DbConnectionFlags { - host?: string; - port?: string; - user?: string; - password?: string; - database?: string; -} - -export type DbConnectionSource = 'ztd-test-env' | 'explicit-url' | 'explicit-flags'; - -export interface DbConnectionContext { - source: DbConnectionSource; - host?: string; - port?: number; - user?: string; - database?: string; -} - -export interface ResolvedDatabaseConnection { - url: string; - context: DbConnectionContext; -} - -const DEFAULT_PORT = 5432; -const ZTD_DB_URL_ENV = 'ZTD_DB_URL'; -const ZTD_DB_PORT_ENV = 'ZTD_DB_PORT'; - -/** - * Resolves the single implicit database owned by ztd-cli. - * @returns The managed ZTD test database connection plus sanitized context metadata. - */ -export function resolveZtdOwnedTestConnection(rootDir: string = process.cwd()): ResolvedDatabaseConnection { - const envUrl = resolveManagedDatabaseUrlFromEnvironment(rootDir); - if (!envUrl) { - throw new Error( - `${ZTD_DB_URL_ENV} is required for this ZTD-owned workflow. ztd-cli does not read DATABASE_URL implicitly.` - ); - } - - return { - url: envUrl, - context: { - source: 'ztd-test-env', - ...parseUrlContext(envUrl) - } - }; -} - -function resolveManagedDatabaseUrlFromEnvironment(rootDir: string): string { - const directEnvUrl = (process.env[ZTD_DB_URL_ENV] ?? '').trim(); - if (directEnvUrl) { - return directEnvUrl; - } - - const envFileValues = loadDotEnvValues(rootDir); - const envFileUrl = envFileValues.get(ZTD_DB_URL_ENV)?.trim(); - if (envFileUrl) { - return envFileUrl; - } - - const port = envFileValues.get(ZTD_DB_PORT_ENV)?.trim(); - if (port) { - return `postgres://ztd:ztd@localhost:${port}/ztd`; - } - - return ''; -} - -function loadDotEnvValues(rootDir: string): Map { - const envPath = path.join(rootDir, '.env'); - if (!existsSync(envPath)) { - return new Map(); - } - - const parsed = new Map(); - const source = readFileSync(envPath, 'utf8'); - for (const line of source.split(/\r?\n/u)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - - const equalsIndex = trimmed.indexOf('='); - if (equalsIndex <= 0) { - continue; - } - - const key = trimmed.slice(0, equalsIndex).trim(); - const value = trimmed.slice(equalsIndex + 1).trim(); - if (key.length === 0) { - continue; - } - parsed.set(key, stripOptionalQuotes(value)); - } - - return parsed; -} - -function stripOptionalQuotes(value: string): string { - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - return value.slice(1, -1); - } - return value; -} - -/** - * Resolves an explicit non-ZTD target connection. - * @param flags - Explicit target fields supplied by the caller. - * @param explicitUrl - Fully-qualified explicit target URL. - * @returns The target connection plus sanitized context metadata. - */ -export function resolveExplicitTargetConnection( - flags: DbConnectionFlags, - explicitUrl?: string -): ResolvedDatabaseConnection { - const trimmedExplicitUrl = explicitUrl?.trim(); - if (trimmedExplicitUrl) { - // Explicit URLs always win so callers can override any partially supplied field set. - return { - url: trimmedExplicitUrl, - context: { - source: 'explicit-url', - ...parseUrlContext(trimmedExplicitUrl) - } - }; - } - - if (hasExplicitFlags(flags)) { - return resolveFromFlags(flags); - } - - throw new Error( - 'This command does not use implicit database settings. Pass --url or --db-* explicitly.' - ); -} - -/** Determines whether the caller provided any explicit target flag. */ -function hasExplicitFlags(flags: DbConnectionFlags): boolean { - return Boolean( - flags.host || - flags.port || - flags.user || - flags.password || - flags.database - ); -} - -/** - * Builds a complete explicit target connection when flag-based overrides are present. - * @param flags - Explicit connection fields supplied by the caller. - * @returns The canonical PostgreSQL URL plus metadata about the explicit target. - */ -function resolveFromFlags(flags: DbConnectionFlags): ResolvedDatabaseConnection { - const missing = []; - if (!flags.host) { - missing.push('--db-host'); - } - if (!flags.user) { - missing.push('--db-user'); - } - if (!flags.database) { - missing.push('--db-name'); - } - - // Fail partial flag combinations so callers do not accidentally inspect the wrong target. - if (missing.length) { - throw new Error( - `Incomplete explicit target database flags. Missing ${missing.join(', ')}. Provide all required fields or use --url.` - ); - } - - const port = normalizePort(flags.port); - const numericPort = port ?? DEFAULT_PORT; - const url = buildConnectionUrl({ - host: flags.host!, - port: numericPort, - user: flags.user!, - password: flags.password, - database: flags.database! - }); - - return { - url, - context: { - source: 'explicit-flags', - host: flags.host!, - port: numericPort, - user: flags.user!, - database: flags.database! - } - }; -} - -/** - * Normalizes a port string/number into a valid TCP port or undefined when absent. - * @param port - User-supplied port string or number. - * @returns The validated numeric port or undefined when the input is missing. - */ -function normalizePort(port?: string | number): number | undefined { - if (port === undefined) { - return undefined; - } - const parsed = typeof port === 'number' ? port : Number(port); - if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { - throw new Error('The port must be a positive integer between 1 and 65535.'); - } - return parsed; -} - -/** - * Builds a canonical PostgreSQL connection URL from parsed segments. - * @param host - The database host address. - * @param port - The numeric TCP port to target. - * @param user - Username to embed in the URL. - * @param password - Optional password component. - * @param database - Database name applied to the pathname. - * @returns Fully-qualified PostgreSQL URL. - */ -function buildConnectionUrl({ - host, - port, - user, - password, - database -}: { - host: string; - port: number; - user: string; - password?: string; - database: string; -}): string { - const url = new URL('postgresql://'); - url.hostname = host; - url.port = port.toString(); - url.username = user; - if (password) { - url.password = password; - } - url.pathname = `/${database}`; - return url.toString(); -} - -/** - * Extracts host/user/database metadata from a PostgreSQL-style URL. - * @param urlValue - Connection string provided by the caller. - * @returns Partial context describing host, port, user, and database. - */ -function parseUrlContext(urlValue: string): Partial { - try { - const parsed = new URL(urlValue); - const database = parsed.pathname ? parsed.pathname.replace(/^\//, '') : undefined; - return { - host: parsed.hostname || undefined, - port: parsed.port ? Number(parsed.port) : undefined, - user: parsed.username || undefined, - database: database && database.length ? database : undefined - }; - } catch { - return {}; - } -} diff --git a/packages/ztd-cli/src/utils/findingRegistry.ts b/packages/ztd-cli/src/utils/findingRegistry.ts deleted file mode 100644 index b11be40d3..000000000 --- a/packages/ztd-cli/src/utils/findingRegistry.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { isPlainObject } from './sqlCatalogDiscovery'; - -export type FindingSourceLabel = 'Report' | 'Codex' | 'FC'; -export type FindingFailureSurface = 'internal' | 'ci' | 'publish' | 'customer'; -export type FindingSeverity = 'blocker' | 'warning' | 'advisory'; -export type FindingDetectability = 'local' | 'workflow' | 'ci_only'; -export type FindingRecurrenceRisk = 'low' | 'medium' | 'high'; -export type FindingStatus = 'planned' | 'implemented' | 'evidence_collected' | 'verified'; - -export interface FindingRegistryEntry { - id: string; - title: string; - symptom: string; - source: FindingSourceLabel[]; - failure_surface: FindingFailureSurface; - category: string[]; - severity: FindingSeverity; - detectability: FindingDetectability; - recurrence_risk: FindingRecurrenceRisk; - desired_prevention_layer: string[]; - candidate_action: string; - verification_evidence: string; - status: FindingStatus; -} - -export interface FindingRegistryIssue { - index: number; - field: string; - message: string; -} - -const allowedSources = new Set(['Report', 'Codex', 'FC']); -const allowedFailureSurfaces = new Set(['internal', 'ci', 'publish', 'customer']); -const allowedSeverities = new Set(['blocker', 'warning', 'advisory']); -const allowedDetectability = new Set(['local', 'workflow', 'ci_only']); -const allowedRecurrenceRisk = new Set(['low', 'medium', 'high']); -const allowedStatuses = new Set(['planned', 'implemented', 'evidence_collected', 'verified']); - -/** - * Validate a machine-readable finding registry payload. - * The validator stays small on purpose so docs, CI, and scripts can share the same contract. - */ -export function validateFindingRegistry(value: unknown): FindingRegistryIssue[] { - const issues: FindingRegistryIssue[] = []; - if (!Array.isArray(value)) { - return [{ index: -1, field: 'root', message: 'Finding registry must be an array.' }]; - } - - value.forEach((entry, index) => validateEntry(entry, index, issues)); - return issues; -} - -function validateEntry(entry: unknown, index: number, issues: FindingRegistryIssue[]): void { - if (!isPlainObject(entry)) { - issues.push({ index, field: 'root', message: 'Each finding must be a plain object.' }); - return; - } - - requireString(entry, index, issues, 'id'); - requireString(entry, index, issues, 'title'); - requireString(entry, index, issues, 'symptom'); - requireString(entry, index, issues, 'candidate_action'); - requireString(entry, index, issues, 'verification_evidence'); - requireStringArray(entry, index, issues, 'source', allowedSources); - requireStringArray(entry, index, issues, 'category'); - requireStringArray(entry, index, issues, 'desired_prevention_layer'); - requireEnum(entry, index, issues, 'failure_surface', allowedFailureSurfaces); - requireEnum(entry, index, issues, 'severity', allowedSeverities); - requireEnum(entry, index, issues, 'detectability', allowedDetectability); - requireEnum(entry, index, issues, 'recurrence_risk', allowedRecurrenceRisk); - requireEnum(entry, index, issues, 'status', allowedStatuses); -} - -function requireString(entry: Record, index: number, issues: FindingRegistryIssue[], field: string): void { - const value = entry[field]; - if (typeof value !== 'string' || value.trim().length === 0) { - issues.push({ index, field, message: `${field} must be a non-empty string.` }); - } -} - -function requireStringArray( - entry: Record, - index: number, - issues: FindingRegistryIssue[], - field: string, - allowed?: Set -): void { - const value = entry[field]; - if (!Array.isArray(value) || value.length === 0) { - issues.push({ index, field, message: `${field} must be a non-empty string array.` }); - return; - } - - for (const item of value) { - if (typeof item !== 'string' || item.trim().length === 0) { - issues.push({ index, field, message: `${field} entries must be non-empty strings.` }); - return; - } - if (allowed && !allowed.has(item as TAllowed)) { - issues.push({ index, field, message: `${field} entry "${item}" is not supported.` }); - return; - } - } -} - -function requireEnum( - entry: Record, - index: number, - issues: FindingRegistryIssue[], - field: string, - allowed: Set -): void { - const value = entry[field]; - if (typeof value !== 'string' || !allowed.has(value as TAllowed)) { - issues.push({ index, field, message: `${field} must be one of: ${Array.from(allowed).join(', ')}.` }); - } -} diff --git a/packages/ztd-cli/src/utils/fs.ts b/packages/ztd-cli/src/utils/fs.ts deleted file mode 100644 index b6080dd64..000000000 --- a/packages/ztd-cli/src/utils/fs.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { existsSync, mkdirSync } from 'node:fs'; - -export function ensureDirectory(directory: string): void { - if (!directory || existsSync(directory)) { - return; - } - - // Create the destination hierarchy so calling code can emit files safely. - mkdirSync(directory, { recursive: true }); -} diff --git a/packages/ztd-cli/src/utils/importAliasSupport.ts b/packages/ztd-cli/src/utils/importAliasSupport.ts deleted file mode 100644 index 55199d7d4..000000000 --- a/packages/ztd-cli/src/utils/importAliasSupport.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import path from 'node:path'; - -type JsonRecord = Record; - -export type ImportAliasSupportStatus = 'supported' | 'absent' | 'partial'; - -function readJsonRecordIfExists(filePath: string): JsonRecord | null { - if (!existsSync(filePath)) { - return null; - } - - try { - return JSON.parse(readFileSync(filePath, 'utf8')) as JsonRecord; - } catch { - return null; - } -} - -function hasPackageImport(rootDir: string, importKey: string): boolean { - const packageJson = readJsonRecordIfExists(path.join(rootDir, 'package.json')); - if (!packageJson) { - return false; - } - - const imports = packageJson.imports; - return typeof imports === 'object' && imports !== null && importKey in (imports as JsonRecord); -} - -function hasTsconfigPath(rootDir: string, pathKey: string): boolean { - const tsconfig = readJsonRecordIfExists(path.join(rootDir, 'tsconfig.json')); - if (!tsconfig) { - return false; - } - - const compilerOptions = tsconfig.compilerOptions; - if (!compilerOptions || typeof compilerOptions !== 'object') { - return false; - } - - const paths = (compilerOptions as JsonRecord).paths; - return typeof paths === 'object' && paths !== null && pathKey in (paths as JsonRecord); -} - -function hasVitestAlias(rootDir: string, aliasPrefix: string): boolean { - const configPath = path.join(rootDir, 'vitest.config.ts'); - if (!existsSync(configPath)) { - return false; - } - - const contents = readFileSync(configPath, 'utf8'); - return contents.includes(`'${aliasPrefix}'`) || contents.includes(`"${aliasPrefix}"`); -} - -export function inspectImportAliasSupport(rootDir: string, options: { - packageImportKey: string; - tsconfigPathKey: string; - vitestAliasPrefix: string; -}): ImportAliasSupportStatus { - const checks = [ - hasPackageImport(rootDir, options.packageImportKey), - hasTsconfigPath(rootDir, options.tsconfigPathKey), - hasVitestAlias(rootDir, options.vitestAliasPrefix) - ]; - - if (checks.every(Boolean)) { - return 'supported'; - } - if (checks.every((value) => !value)) { - return 'absent'; - } - return 'partial'; -} diff --git a/packages/ztd-cli/src/utils/modelGenBinder.ts b/packages/ztd-cli/src/utils/modelGenBinder.ts deleted file mode 100644 index aef30ad08..000000000 --- a/packages/ztd-cli/src/utils/modelGenBinder.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { scanModelGenSql } from './modelGenScanner'; - -export interface BoundModelGenSql { - boundSql: string; - orderedParamNames: string[]; -} - -/** - * Converts `:name` placeholders to indexed PostgreSQL placeholders while preserving string/comment boundaries. - */ -export function bindModelGenNamedSql(sql: string): BoundModelGenSql { - const scan = scanModelGenSql(sql); - if (scan.mode !== 'named') { - throw new Error('bindModelGenNamedSql expected named SQL placeholders.'); - } - - const orderedParamNames: string[] = []; - const slotByName = new Map(); - let cursor = 0; - let boundSql = ''; - - for (const token of scan.namedTokens) { - boundSql += sql.slice(cursor, token.start); - let slot = slotByName.get(token.name); - if (!slot) { - orderedParamNames.push(token.name); - slot = orderedParamNames.length; - slotByName.set(token.name, slot); - } - boundSql += `$${slot}`; - cursor = token.end; - } - - boundSql += sql.slice(cursor); - return { boundSql, orderedParamNames }; -} diff --git a/packages/ztd-cli/src/utils/modelGenRender.ts b/packages/ztd-cli/src/utils/modelGenRender.ts deleted file mode 100644 index cc805e82c..000000000 --- a/packages/ztd-cli/src/utils/modelGenRender.ts +++ /dev/null @@ -1,184 +0,0 @@ -import path from 'node:path'; - -export type ModelGenFormat = 'interface' | 'row-mapping' | 'spec'; - -export interface ModelGenColumn { - columnName: string; - propertyName: string; - tsType: string; -} - -export interface RenderModelGenInput { - command: string; - format: ModelGenFormat; - sqlFile: string; - specId: string; - interfaceName: string; - mappingName: string; - specName: string; - placeholderMode: 'named' | 'positional' | 'none'; - allowPositional: boolean; - orderedParamNames: string[]; - columns: ModelGenColumn[]; -} - -export function renderModelGenFile(input: RenderModelGenInput): string { - const parts: string[] = []; - parts.push(`// Generated by: ${input.command}`); - parts.push('// DO NOT COMMIT without review. Adjust nullability, cardinality, normalization, and example values as needed.'); - parts.push('// Review mapping.key and rerun this command after SQL or schema changes.'); - if (input.allowPositional && input.placeholderMode === 'positional') { - parts.push('// Legacy warning: this scaffold was generated from positional placeholders. Prefer rewriting the SQL asset to named parameters (:name).'); - } else { - parts.push('// names-first reminder: SQL asset files should use named parameters (:name).'); - } - parts.push(''); - - parts.push(renderInterface(input.interfaceName, input.columns)); - - if (input.format === 'row-mapping' || input.format === 'spec') { - parts.push(''); - parts.push(renderMapping(input.mappingName, input.interfaceName, input.columns)); - } - - if (input.format === 'spec') { - parts.push(''); - parts.push(renderSpec(input)); - } - - return `${parts.join('\n')}\n`; -} - -export function toModelPropertyName(columnName: string): string { - const normalized = columnName - .trim() - .replace(/[^A-Za-z0-9]+/g, ' ') - .split(' ') - .filter(Boolean) - .map((segment, index) => { - const lower = segment.toLowerCase(); - if (index === 0) { - return lower; - } - return lower.charAt(0).toUpperCase() + lower.slice(1); - }) - .join(''); - const prefixed = /^[A-Za-z_]/.test(normalized.charAt(0) ?? '') ? normalized : `_${normalized}`; - if (!prefixed) { - throw new Error(`Failed to derive a TypeScript property name from column "${columnName}".`); - } - return prefixed; -} - -export function deriveModelGenNames(relativeSqlFile: string): { - interfaceName: string; - mappingName: string; - specName: string; - specId: string; -} { - const withoutExtension = relativeSqlFile.replace(/\.[^.]+$/u, ''); - const normalizedPath = withoutExtension.replace(/\\/g, '/'); - const segments = normalizedPath.split('/').filter(Boolean); - const lastSegment = segments[segments.length - 1] ?? 'query'; - const interfaceName = `${toPascalCase(lastSegment)}Row`; - const baseCamel = toCamelCase(lastSegment); - const specId = - segments.length === 1 - ? baseCamel - : `${segments.slice(0, -1).join('.')}.${baseCamel}`; - return { - interfaceName, - mappingName: `${baseCamel}Mapping`, - specName: `${baseCamel}Spec`, - specId - }; -} - -export function normalizeGeneratedSqlFile(relativeSqlFile: string): string { - return relativeSqlFile.split(path.sep).join('/').replace(/\\/g, '/'); -} - -function renderInterface(interfaceName: string, columns: ModelGenColumn[]): string { - const fields = columns.map((column) => ` ${column.propertyName}: ${column.tsType};`).join('\n'); - return `export interface ${interfaceName} {\n${fields}\n}`; -} - -function renderMapping(mappingName: string, interfaceName: string, columns: ModelGenColumn[]): string { - const keyName = columns[0]?.propertyName ?? 'id'; - const mapEntries = columns - .map((column) => ` ${column.propertyName}: ${renderTsStringLiteral(column.columnName)},`) - .join('\n'); - return `export const ${mappingName} = {\n name: ${renderTsStringLiteral(interfaceName.replace(/Row$/u, ''))},\n key: ${renderTsStringLiteral(keyName)},\n columnMap: {\n${mapEntries}\n },\n} as const;`; -} - -function renderSpec(input: RenderModelGenInput): string { - const paramsType = renderParamsType(input.placeholderMode, input.orderedParamNames); - const paramsExample = renderParamsExample(input.placeholderMode, input.orderedParamNames); - const outputExample = input.columns - .map((column) => ` ${column.propertyName}: ${renderExampleValue(column.tsType)},`) - .join('\n'); - return `export type ${input.specName.charAt(0).toUpperCase()}${input.specName.slice(1)}Params = ${paramsType};\n\nexport const ${input.specName} = {\n id: ${renderTsStringLiteral(input.specId)},\n sqlFile: ${renderTsStringLiteral(input.sqlFile)},\n params: { shape: ${renderTsStringLiteral(input.placeholderMode === 'none' ? 'positional' : input.placeholderMode)}, example: ${paramsExample} },\n output: {\n mapping: ${input.mappingName},\n example: {\n${outputExample}\n } satisfies ${input.interfaceName},\n },\n} as const;`; -} - -function renderParamsType(mode: 'named' | 'positional' | 'none', orderedParamNames: string[]): string { - if (mode === 'named') { - return `{ ${orderedParamNames.map((name) => `${name}: unknown`).join('; ')} }`; - } - if (mode === 'positional') { - return 'unknown[]'; - } - return '[]'; -} - -function renderParamsExample(mode: 'named' | 'positional' | 'none', orderedParamNames: string[]): string { - if (mode === 'named') { - const properties = orderedParamNames.map((name) => `${name}: null`).join(', '); - return `{ ${properties} }`; - } - if (mode === 'positional') { - return `[${orderedParamNames.map(() => 'null').join(', ')}]`; - } - return '[]'; -} - -function renderExampleValue(tsType: string): string { - if (tsType.endsWith('[]')) { - return '[]'; - } - if (tsType === 'number') { - return '0'; - } - if (tsType === 'string') { - return "''"; - } - if (tsType === 'boolean') { - return 'false'; - } - if (tsType === 'Uint8Array') { - return 'new Uint8Array()'; - } - if (tsType === 'any') { - return 'null'; - } - return 'null as unknown'; -} - -function renderTsStringLiteral(value: string): string { - return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`; -} - -function toPascalCase(value: string): string { - return value - .split(/[^A-Za-z0-9]+/u) - .filter(Boolean) - .map((segment) => { - const lower = segment.toLowerCase(); - return lower.charAt(0).toUpperCase() + lower.slice(1); - }) - .join('') || 'Query'; -} - -function toCamelCase(value: string): string { - const pascal = toPascalCase(value); - return pascal.charAt(0).toLowerCase() + pascal.slice(1); -} diff --git a/packages/ztd-cli/src/utils/modelGenScanner.ts b/packages/ztd-cli/src/utils/modelGenScanner.ts deleted file mode 100644 index d14f0b9e5..000000000 --- a/packages/ztd-cli/src/utils/modelGenScanner.ts +++ /dev/null @@ -1,221 +0,0 @@ -export type PlaceholderMode = 'none' | 'named' | 'positional'; - -export interface SqlScanNamedToken { - start: number; - end: number; - name: string; -} - -export interface SqlScanResult { - mode: PlaceholderMode; - namedTokens: SqlScanNamedToken[]; - positionalTokens: Array<{ start: number; end: number; token: string }>; -} - -const IDENTIFIER_START_PATTERN = /[A-Za-z_]/; -const IDENTIFIER_PART_PATTERN = /[A-Za-z0-9_]/; - -export class ModelGenSqlScanError extends Error { - readonly token: string; - - constructor(message: string, token: string) { - super(message); - this.name = 'ModelGenSqlScanError'; - this.token = token; - } -} - -/** - * Scans SQL text for supported/unsupported placeholder styles without fully parsing SQL grammar. - * The scanner guarantees that it will not inspect inside string literals, quoted identifiers, or comments. - */ -export function scanModelGenSql(sql: string): SqlScanResult { - const namedTokens: SqlScanNamedToken[] = []; - const positionalTokens: Array<{ start: number; end: number; token: string }> = []; - let index = 0; - - while (index < sql.length) { - const current = sql[index]; - const next = sql[index + 1] ?? ''; - - if (current === '\'') { - index = skipSingleQuotedString(sql, index); - continue; - } - if (current === '"') { - index = skipDoubleQuotedIdentifier(sql, index); - continue; - } - if (current === '-' && next === '-') { - index = skipLineComment(sql, index); - continue; - } - if (current === '/' && next === '*') { - index = skipBlockComment(sql, index); - continue; - } - if (current === '$') { - const dollarQuote = readDollarQuoteDelimiter(sql, index); - if (dollarQuote) { - index = skipDollarQuotedString(sql, index, dollarQuote); - continue; - } - - if (/[0-9]/.test(next)) { - const end = consumeDigits(sql, index + 1); - positionalTokens.push({ start: index, end, token: sql.slice(index, end) }); - index = end; - continue; - } - - if (next === '{') { - throw new ModelGenSqlScanError('Detected unsupported placeholder syntax "${name}".', consumeUnsupportedToken(sql, index)); - } - } - if (current === '?') { - throw new ModelGenSqlScanError('Detected unsupported placeholder syntax "?".', '?'); - } - if (current === '@' && IDENTIFIER_START_PATTERN.test(next)) { - throw new ModelGenSqlScanError('Detected unsupported placeholder syntax "@name".', consumeUnsupportedToken(sql, index)); - } - if (current === ':') { - // PostgreSQL casts use `::`, which must not be treated as a named placeholder. - if (next === ':') { - index += 2; - continue; - } - if (IDENTIFIER_START_PATTERN.test(next)) { - const end = consumeIdentifier(sql, index + 1); - if ((sql[end] ?? '') === '-') { - throw new ModelGenSqlScanError('Detected unsupported named parameter syntax.', consumeUnsupportedToken(sql, index)); - } - namedTokens.push({ - start: index, - end, - name: sql.slice(index + 1, end) - }); - index = end; - continue; - } - if (/[0-9]/.test(next)) { - throw new ModelGenSqlScanError('Detected unsupported placeholder syntax ":1".', consumeUnsupportedToken(sql, index)); - } - if (next && !isStructuralDelimiter(next)) { - throw new ModelGenSqlScanError('Detected unsupported named parameter syntax.', consumeUnsupportedToken(sql, index)); - } - } - - index += 1; - } - - if (namedTokens.length > 0 && positionalTokens.length > 0) { - throw new ModelGenSqlScanError('Detected mixed named and positional placeholder styles.', 'mixed'); - } - - return { - mode: - namedTokens.length > 0 - ? 'named' - : positionalTokens.length > 0 - ? 'positional' - : 'none', - namedTokens, - positionalTokens - }; -} - -function skipSingleQuotedString(sql: string, start: number): number { - let index = start + 1; - while (index < sql.length) { - if (sql[index] === '\'' && sql[index + 1] === '\'') { - index += 2; - continue; - } - if (sql[index] === '\'') { - return index + 1; - } - index += 1; - } - return sql.length; -} - -function skipDoubleQuotedIdentifier(sql: string, start: number): number { - let index = start + 1; - while (index < sql.length) { - if (sql[index] === '"' && sql[index + 1] === '"') { - index += 2; - continue; - } - if (sql[index] === '"') { - return index + 1; - } - index += 1; - } - return sql.length; -} - -function skipLineComment(sql: string, start: number): number { - let index = start + 2; - while (index < sql.length && sql[index] !== '\n') { - index += 1; - } - return index; -} - -function skipBlockComment(sql: string, start: number): number { - let index = start + 2; - while (index < sql.length) { - if (sql[index] === '*' && sql[index + 1] === '/') { - return index + 2; - } - index += 1; - } - return sql.length; -} - -function readDollarQuoteDelimiter(sql: string, start: number): string | null { - let index = start + 1; - while (index < sql.length && /[A-Za-z0-9_]/.test(sql[index] ?? '')) { - index += 1; - } - if (sql[index] !== '$') { - return null; - } - return sql.slice(start, index + 1); -} - -function skipDollarQuotedString(sql: string, start: number, delimiter: string): number { - const end = sql.indexOf(delimiter, start + delimiter.length); - if (end < 0) { - return sql.length; - } - return end + delimiter.length; -} - -function consumeDigits(sql: string, start: number): number { - let index = start; - while (index < sql.length && /[0-9]/.test(sql[index] ?? '')) { - index += 1; - } - return index; -} - -function consumeIdentifier(sql: string, start: number): number { - let index = start; - while (index < sql.length && IDENTIFIER_PART_PATTERN.test(sql[index] ?? '')) { - index += 1; - } - return index; -} - -function consumeUnsupportedToken(sql: string, start: number): string { - let index = start + 1; - while (index < sql.length && !isStructuralDelimiter(sql[index] ?? '')) { - index += 1; - } - return sql.slice(start, index); -} - -function isStructuralDelimiter(value: string): boolean { - return /\s|[,;()<>+=*/%|&!~[\]{}]/.test(value); -} diff --git a/packages/ztd-cli/src/utils/modelProbe.ts b/packages/ztd-cli/src/utils/modelProbe.ts deleted file mode 100644 index a7ee08238..000000000 --- a/packages/ztd-cli/src/utils/modelProbe.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { SimulatedSelectConverter, SqlFormatter, SqlParser } from 'rawsql-ts'; - -interface PgMetadataQueryClientLike { - query(statement: string, values?: unknown[] | Record): Promise<{ - rows?: T[]; - fields?: unknown[]; - }>; -} - -export interface ProbedColumn { - columnName: string; - typeName: string; - tsType: string; -} - -interface ProbeField { - name: string; - dataTypeID: number; -} - -interface PgTypeRow { - oid: number; - typname: string; - typtype: string; - typelem: number; - typbasetype: number; -} - -const directTypeMap = new Map([ - ['smallint', 'number'], - ['integer', 'number'], - ['int', 'number'], - ['bigint', 'string'], - ['int2', 'number'], - ['int4', 'number'], - ['int8', 'string'], - ['float4', 'number'], - ['float8', 'number'], - ['numeric', 'string'], - ['decimal', 'string'], - ['bool', 'boolean'], - ['text', 'string'], - ['varchar', 'string'], - ['bpchar', 'string'], - ['char', 'string'], - ['uuid', 'string'], - ['citext', 'string'], - ['name', 'string'], - ['date', 'string'], - ['timestamp', 'string'], - ['timestamptz', 'string'], - ['time', 'string'], - ['timetz', 'string'], - ['json', 'any'], - ['jsonb', 'any'], - ['bytea', 'Uint8Array'] -]); - -export function mapDeclaredPgTypeToTs(typeName: string | undefined): string { - if (!typeName) { - return 'unknown'; - } - const normalized = typeName.trim().toLowerCase(); - return directTypeMap.get(normalized) ?? 'unknown'; -} - -export async function probeQueryColumns( - client: PgMetadataQueryClientLike, - boundSql: string, - params: unknown[], - options?: { direct?: boolean } -): Promise { - const probeSql = options?.direct ? normalizeProbeSource(boundSql) : buildProbeSql(boundSql); - const result = await client.query(probeSql, params); - const fields = normalizeFields(result.fields); - if (fields.length === 0) { - throw new Error('The probe query returned no column metadata.'); - } - - const typeRows = await loadPgTypes(client, fields.map((field) => field.dataTypeID)); - return fields.map((field) => { - const typeName = resolvePgTypeName(field.dataTypeID, typeRows); - return { - columnName: field.name, - typeName, - tsType: mapPgTypeToTs(field.dataTypeID, typeRows) - }; - }); -} - -export function buildProbeSql(boundSql: string): string { - const normalizedSql = normalizeProbeSource(boundSql); - if (!normalizedSql) { - throw new Error('The SQL probe source is empty.'); - } - - const hasPgPositionalPlaceholder = containsPgPositionalPlaceholder(normalizedSql); - if (hasPgPositionalPlaceholder && /^\s*(select|with)\b/iu.test(normalizedSql)) { - return `SELECT * FROM (${normalizedSql}) AS _ztd_type_probe LIMIT 0`; - } - - const returningProbeSql = buildReturningProbeSelect(normalizedSql); - if (returningProbeSql) { - return `SELECT * FROM (${returningProbeSql}) AS _ztd_type_probe LIMIT 0`; - } - - try { - const parserSql = hasPgPositionalPlaceholder - ? normalizedSql.replace(/\$(\d+)\b/gu, ':$1') - : normalizedSql; - const ast = SqlParser.parse(parserSql); - const simulatedSelect = SimulatedSelectConverter.convert(ast, { - missingFixtureStrategy: 'passthrough' - }); - if (simulatedSelect) { - const formatter = new SqlFormatter({ keywordCase: 'none' }); - const { formattedSql } = formatter.format(simulatedSelect); - let simulatedSql = formattedSql.trim().replace(/(?:;\s*)+$/u, ''); - if (hasPgPositionalPlaceholder) { - simulatedSql = simulatedSql.replace(/(^|[^:]):(\d+)\b/gu, '$1$$$2'); - } - if (simulatedSql) { - return `SELECT * FROM (${simulatedSql}) AS _ztd_type_probe LIMIT 0`; - } - } - } catch { - // Fall back to the legacy direct wrapper so probe behavior remains tolerant - // for callers that already provide SELECT-compatible SQL. - } - - return `SELECT * FROM (${normalizedSql}) AS _ztd_type_probe LIMIT 0`; -} - -function buildReturningProbeSelect(sql: string): string | undefined { - const match = sql.match( - /^\s*(?:insert\s+into|update|delete\s+from)\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)[\s\S]*?\breturning\b([\s\S]+)$/iu - ); - if (!match || !match[1] || !match[2]) { - return undefined; - } - const table = match[1].replace(/\s*\.\s*/gu, '.'); - const returning = match[2].trim().replace(/(?:;\s*)+$/u, ''); - if (!returning) { - return undefined; - } - return `SELECT ${returning} FROM ${table} LIMIT 0`; -} - -function containsPgPositionalPlaceholder(sql: string): boolean { - return /\$\d+/u.test(sql); -} - -function normalizeProbeSource(boundSql: string): string { - return boundSql.trim().replace(/(?:;\s*)+$/u, ''); -} - -function normalizeFields(fields: unknown): ProbeField[] { - if (!Array.isArray(fields)) { - return []; - } - return fields - .filter((field): field is ProbeField => - typeof field === 'object' && - field !== null && - typeof (field as { name?: unknown }).name === 'string' && - typeof (field as { dataTypeID?: unknown }).dataTypeID === 'number' - ); -} - -async function loadPgTypes(client: PgMetadataQueryClientLike, initialOids: number[]): Promise> { - const rows = new Map(); - const pending = new Set(initialOids.filter((oid) => oid > 0)); - - while (pending.size > 0) { - const batch = Array.from(pending); - pending.clear(); - const result = await client.query( - ` - SELECT oid, typname, typtype, typelem, typbasetype - FROM pg_type - WHERE oid = ANY($1::oid[]) - `, - [batch] - ); - for (const row of result.rows ?? []) { - rows.set(row.oid, row); - if (row.typelem && row.typelem > 0 && !rows.has(row.typelem)) { - pending.add(row.typelem); - } - if (row.typbasetype && row.typbasetype > 0 && !rows.has(row.typbasetype)) { - pending.add(row.typbasetype); - } - } - } - - return rows; -} - -function resolvePgTypeName(oid: number, rows: Map): string { - const row = rows.get(oid); - if (!row) { - return 'unknown'; - } - if (row.typtype === 'd' && row.typbasetype > 0) { - return resolvePgTypeName(row.typbasetype, rows); - } - if (row.typelem > 0 && row.typname.startsWith('_')) { - return `${resolvePgTypeName(row.typelem, rows)}[]`; - } - if (row.typtype === 'e') { - return row.typname; - } - return row.typname; -} - -function mapPgTypeToTs(oid: number, rows: Map): string { - const row = rows.get(oid); - if (!row) { - return 'unknown'; - } - if (row.typtype === 'd' && row.typbasetype > 0) { - return mapPgTypeToTs(row.typbasetype, rows); - } - if (row.typelem > 0 && row.typname.startsWith('_')) { - return `${mapPgTypeToTs(row.typelem, rows)}[]`; - } - if (row.typtype === 'e') { - return 'string'; - } - return mapDeclaredPgTypeToTs(row.typname); -} diff --git a/packages/ztd-cli/src/utils/normalizePulledSchema.ts b/packages/ztd-cli/src/utils/normalizePulledSchema.ts deleted file mode 100644 index 7404d0fbb..000000000 --- a/packages/ztd-cli/src/utils/normalizePulledSchema.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - IdentifierString, - MultiQuerySplitter, - RawString, - SqlComponent, - SqlFormatter, - SqlParser, - QualifiedName -} from 'rawsql-ts'; - -export type StatementGroup = 'createSchema' | 'createTable' | 'view' | 'alterTable' | 'sequence' | 'index'; - -export interface NormalizedStatement { - schema: string; - objectName: string; - group: StatementGroup; - sql: string; -} - -interface NormalizationOptions { - allowedSchemas?: Set; -} - -const SqlFormatterOptions = { - keywordCase: 'lower', - indentSize: 2, - indentChar: ' ', - newline: '\n', - commaBreak: 'after', - exportComment: 'none' -} as const; - -const ddlFormatter = new SqlFormatter(SqlFormatterOptions); - -const groupOrder: Record = { - createSchema: 0, - createTable: 1, - view: 2, - alterTable: 3, - sequence: 4, - index: 5 -}; - -export function normalizePulledSchema(rawSql: string, options: NormalizationOptions = {}): Map { - const cleanedSql = stripPgDumpNoise(rawSql); - const queries = MultiQuerySplitter.split(cleanedSql).queries; - const schemaMap = new Map(); - - // Iterate over each statement to normalize, bucket by schema, and honor any active filters. - for (const query of queries) { - const statementText = query.sql.trim(); - if (!statementText || shouldSkipStatement(statementText)) { - continue; - } - - const normalized = processStatement(statementText); - if (!normalized) { - continue; - } - - if (options.allowedSchemas && options.allowedSchemas.size > 0 && !options.allowedSchemas.has(normalized.schema)) { - continue; - } - - const bucket = schemaMap.get(normalized.schema) ?? []; - bucket.push(normalized); - schemaMap.set(normalized.schema, bucket); - } - // Ensure every included schema has a CREATE SCHEMA statement even if pg_dump omitted it. - for (const [schema, statements] of schemaMap) { - if (statements.some((entry) => entry.group === 'createSchema')) { - continue; - } - statements.push({ - schema, - objectName: schema, - group: 'createSchema', - sql: finalizeRawStatement(`create schema ${schema}`) - }); - } - - // Ensure the statements for each schema are returned in a deterministic order. - for (const statements of schemaMap.values()) { - statements.sort((a, b) => { - const primary = groupOrder[a.group] - groupOrder[b.group]; - if (primary !== 0) { - return primary; - } - return a.objectName.localeCompare(b.objectName); - }); - } - - return schemaMap; -} - -function stripPgDumpNoise(rawSql: string): string { - // Remove pg_dump meta commands and standalone comments so the parser only sees SQL statements. - const normalizedNewlines = rawSql.replace(/\r\n/g, '\n'); - return normalizedNewlines - .split('\n') - .filter((line) => { - const trimmed = line.trim(); - if (!trimmed) { - return true; - } - if (trimmed.startsWith('\\') || trimmed.startsWith('--')) { - return false; - } - return true; - }) - .join('\n'); -} - -function shouldSkipStatement(statement: string): boolean { - // Strip handles comments and meta commands earlier, so skip only runtime statements we still want to ignore. - const lower = statement.toLowerCase(); - if (lower.startsWith('set ') || lower.startsWith('select ')) { - return true; - } - if (lower.startsWith('comment ') || lower.startsWith('drop ')) { - return true; - } - if (lower.startsWith('create extension') || lower.startsWith('create type')) { - return true; - } - return false; -} - -function processStatement(statement: string): NormalizedStatement | null { - const lower = statement.toLowerCase(); - - // Route statements by their leading keywords to dedicated handlers. - if (lower.startsWith('create schema')) { - return buildCreateSchemaStatement(statement); - } - - if (lower.startsWith('create view') || lower.startsWith('create or replace view')) { - return buildViewStatement(statement); - } - - if (lower.startsWith('create table') || lower.startsWith('create temporary table')) { - return buildAstStatement(statement, 'createTable', (ast) => ast.tableName); - } - - if (lower.startsWith('alter table')) { - return buildAstStatement(statement, 'alterTable', (ast) => ast.table); - } - - if (lower.startsWith('create index') || lower.startsWith('create unique index')) { - return buildAstStatement(statement, 'index', (ast) => ast.tableName); - } - - if (lower.startsWith('create sequence')) { - return buildAstStatement(statement, 'sequence', (ast) => ast.sequenceName); - } - - if (lower.startsWith('alter sequence')) { - return buildAstStatement(statement, 'sequence', (ast) => ast.sequenceName); - } - - return null; -} - -function buildCreateSchemaStatement(statement: string): NormalizedStatement | null { - // SqlParser does not yet support CREATE SCHEMA statements, so fall back to regex parsing. - const match = statement.match(/create\s+schema\s+(?:if\s+not\s+exists\s+)?(.+?)(?:\s|;|$)/i); - if (!match) { - return null; - } - const schemaName = normalizeIdentifier(match[1].trim()); - return { - schema: schemaName, - objectName: schemaName, - group: 'createSchema', - sql: finalizeRawStatement(statement) - }; -} - -function buildViewStatement(statement: string): NormalizedStatement | null { - // SqlParser is not able to parse CREATE VIEW statements yet, so use a regex fallback. - const match = statement.match(/create\s+(?:or\s+replace\s+)?view\s+(.+?)\s+as\b/i); - if (!match) { - return null; - } - const namePart = match[1].trim().split(/\s*\(/)[0].trim(); - const { schema, object } = splitQualifiedIdentifier(namePart); - return { - schema, - objectName: object, - group: 'view', - sql: finalizeRawStatement(statement) - }; -} - -function buildAstStatement( - statement: string, - group: StatementGroup, - qualifier: (ast: any) => QualifiedName -): NormalizedStatement | null { - try { - // Parse the statement with rawsql-ts and convert it back to formatted SQL. - const ast = SqlParser.parse(statement); - const qualifierName = qualifier(ast); - const { schema, object } = extractQualifiedInfo(qualifierName); - return { - group, - schema, - objectName: object, - sql: formatAstStatement(ast) - }; - } catch { - return null; - } -} - -function formatAstStatement(component: SqlComponent): string { - const { formattedSql } = ddlFormatter.format(component); - const trimmed = formattedSql.trim(); - return trimmed.endsWith(';') ? trimmed : `${trimmed};`; -} - -function finalizeRawStatement(statement: string): string { - const trimmed = statement.trim(); - return trimmed.endsWith(';') ? trimmed : `${trimmed};`; -} - -function extractQualifiedInfo(name: QualifiedName): { schema: string; object: string } { - // Convert the qualified components into lowercase, unquoted tokens for deterministic grouping. - const schemaValue = - name.namespaces && name.namespaces.length > 0 ? name.namespaces[name.namespaces.length - 1] : null; - const schema = schemaValue ? normalizeIdentifier(schemaValue) : 'public'; - const object = normalizeIdentifier(name.name); - return { schema, object }; -} - -function normalizeIdentifier(value: IdentifierString | RawString | string): string { - const raw = typeof value === 'string' ? value : value instanceof RawString ? value.value : value.name; - return raw.replace(/^"|"$/g, '').toLowerCase(); -} - -function splitQualifiedIdentifier(input: string): { schema: string; object: string } { - const pattern = /^\s*(?:"([^"]+)"|([^".\s]+))(?:\.(?:"([^"]+)"|([^".\s]+)))?\s*$/; - const match = input.match(pattern); - let schema = 'public'; - let object = input; - - if (match) { - const firstPart = match[1] ?? match[2]; - const secondPart = match[3] ?? match[4]; - if (secondPart) { - schema = firstPart ?? 'public'; - object = secondPart; - } else if (firstPart) { - object = firstPart; - } - } - - return { - schema: normalizeIdentifier(schema), - object: normalizeIdentifier(object) - }; -} diff --git a/packages/ztd-cli/src/utils/optionalDependencies.ts b/packages/ztd-cli/src/utils/optionalDependencies.ts deleted file mode 100644 index 82748db60..000000000 --- a/packages/ztd-cli/src/utils/optionalDependencies.ts +++ /dev/null @@ -1,200 +0,0 @@ -import path from 'node:path'; -import { existsSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const moduleCache = new Map>(); -const currentDirPath = resolveCurrentDirPath(); -const packageRoot = findNearestPackageRoot(currentDirPath); -const repositoryRoot = findWorkspaceRoot(currentDirPath); - -async function loadOptionalModule( - cacheKey: string, - loader: () => Promise, - description: string, - installHint: string -): Promise { - if (moduleCache.has(cacheKey)) { - return moduleCache.get(cacheKey) as Promise; - } - - const moduleLoader = loader() - .catch((error) => { - moduleCache.delete(cacheKey); - const installNote = installHint ? ` Install it via \`${installHint}\`.` : ''; - const original = error instanceof Error ? ` (${error.message})` : ''; - throw new Error(`${description}${installNote}${original}`); - }); - - moduleCache.set(cacheKey, moduleLoader); - return moduleLoader; -} - -export function buildConsumerInstallHint(...packages: string[]): string { - return `npm install --save-dev ${packages.join(' ')}`; -} - -export type TestkitCoreModule = typeof import('@rawsql-ts/testkit-core'); - -export interface PgTestkitClientLike { - query(statement: string, values?: unknown[] | Record): Promise<{ - rows?: T[]; - fields?: unknown[]; - }>; - close(): Promise; -} - -export interface AdapterNodePgModule { - createPgTestkitClient(options: Record): PgTestkitClientLike; -} - -export interface PgClientLike { - connect(): Promise; - query(statement: string, values?: unknown[]): Promise<{ - rows?: T[]; - fields?: unknown[]; - }>; - end(): Promise; -} - -export interface PgModule { - Client: new (options: { connectionString: string; connectionTimeoutMillis?: number }) => PgClientLike; -} - -export interface PostgresContainerLike { - getConnectionUri(): string; - stop(): Promise; -} - -export interface PostgresContainerBuilderLike { - withDatabase(database: string): PostgresContainerBuilderLike; - withUsername(username: string): PostgresContainerBuilderLike; - withPassword(password: string): PostgresContainerBuilderLike; - start(): Promise; -} - -export interface PostgresContainerModule { - PostgreSqlContainer: new (image: string) => PostgresContainerBuilderLike; -} - -export function clearOptionalDependencyCache(): void { - moduleCache.clear(); -} - -function resolveCurrentDirPath(): string { - // Evaluate import.meta.url at runtime so this module can still be compiled in CJS-oriented ts-node flows. - const importMetaUrl = tryGetImportMetaUrl(); - if (importMetaUrl) { - return path.dirname(fileURLToPath(importMetaUrl)); - } - if (typeof __dirname === 'string') { - return __dirname; - } - throw new Error('Failed to resolve current module directory for optional dependency loading.'); -} - -function tryGetImportMetaUrl(): string | undefined { - try { - return Function('return import.meta.url')() as string; - } catch { - return undefined; - } -} - -export function findNearestPackageRoot(startDir: string): string { - let cursor = startDir; - while (true) { - const hasPackageJson = existsSync(path.join(cursor, 'package.json')); - if (hasPackageJson) { - return cursor; - } - - const parentDir = path.dirname(cursor); - if (parentDir === cursor) { - throw new Error( - 'Failed to locate the package root while resolving optional dependencies.' - ); - } - cursor = parentDir; - } -} - -export function findWorkspaceRoot(startDir: string): string | null { - let cursor = startDir; - while (true) { - const hasWorkspaceFile = existsSync(path.join(cursor, 'pnpm-workspace.yaml')); - const hasPackageJson = existsSync(path.join(cursor, 'package.json')); - if (hasWorkspaceFile && hasPackageJson) { - return cursor; - } - - const parentDir = path.dirname(cursor); - if (parentDir === cursor) { - return null; - } - cursor = parentDir; - } -} - -function requireFromPackageRoot(specifier: string): T { - const require = createRequire(path.join(packageRoot, 'package.json')); - return require(specifier) as T; -} - -async function loadAdapterNodePgModule(): Promise { - try { - return requireFromPackageRoot('@rawsql-ts/adapter-node-pg'); - } catch (error) { - if (!repositoryRoot) { - throw error; - } - - // Workspace tests can run before adapter build output exists, so use source entrypoint. - const workspaceAdapterSrc = path.join( - repositoryRoot, - 'packages/adapters/adapter-node-pg/src/index.ts' - ); - try { - return (await import(pathToFileURL(workspaceAdapterSrc).href)) as AdapterNodePgModule; - } catch { - throw error; - } - } -} - -export async function ensureTestkitCoreModule(): Promise { - return loadOptionalModule( - '@rawsql-ts/testkit-core', - () => import('@rawsql-ts/testkit-core'), - 'This command requires @rawsql-ts/testkit-core so fixtures and schema metadata are available.', - buildConsumerInstallHint('@rawsql-ts/testkit-core') - ); -} - -export async function ensureAdapterNodePgModule(): Promise { - return loadOptionalModule( - '@rawsql-ts/adapter-node-pg', - loadAdapterNodePgModule, - 'A database adapter (for example @rawsql-ts/adapter-node-pg) is required to execute the rewritten SQL.', - buildConsumerInstallHint('@rawsql-ts/adapter-node-pg') - ); -} - -export async function ensurePgModule(): Promise { - return loadOptionalModule( - 'pg', - () => import('pg'), - 'The SQL lint command needs a PostgreSQL driver such as pg.', - buildConsumerInstallHint('pg') - ); -} - -export async function ensurePostgresContainerModule(): Promise { - return loadOptionalModule( - '@testcontainers/postgresql', - () => import('@testcontainers/postgresql'), - 'ztd lint wants to spin up a disposable Postgres container via @testcontainers/postgresql.', - buildConsumerInstallHint('@testcontainers/postgresql') - ); -} - diff --git a/packages/ztd-cli/src/utils/pgDump.ts b/packages/ztd-cli/src/utils/pgDump.ts deleted file mode 100644 index 78e04caac..000000000 --- a/packages/ztd-cli/src/utils/pgDump.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import os from 'node:os'; -import type { DbConnectionContext } from './dbConnection'; -import { describeConnectionContext } from './connectionSummary'; - -export interface PgDumpOptions { - url: string; - pgDumpPath?: string; - pgDumpShell?: boolean; - extraArgs?: string[]; - connectionContext?: DbConnectionContext; -} - -/** - * Wraps `pg_dump` in schema-only mode for the given connection URL and returns the captured DDL output. - */ -export function runPgDump(options: PgDumpOptions): string { - const executable = options.pgDumpPath ?? process.env.PG_DUMP_PATH ?? 'pg_dump'; - const useShell = Boolean(options.pgDumpShell); - const args = [ - '--schema-only', - '--no-owner', - '--no-privileges', - ...(options.extraArgs ?? []), - '--dbname', - options.url - ]; - - const result = spawnSync(executable, args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - // Allow wrapper scripts or compound commands such as "docker exec pg_dump". - shell: useShell - }); - - const connectionNote = describeConnectionContext(options.connectionContext); - const extraArgsNote = options.extraArgs?.length ? ` (extra args: ${options.extraArgs.join(' ')})` : ''; - - if (result.error) { - const windowsHint = - os.platform() === 'win32' - ? ' On Windows, ensure "C:\\Program Files\\PostgreSQL\\\\bin" is on PATH, or pass --pg-dump-shell with a wrapper command such as "docker exec pg_dump".' - : ''; - - if (isExecutableMissing(result.error, result.stderr?.toString())) { - throw new Error( - `pg_dump executable not found (${executable}). Install PostgreSQL or pass --pg-dump-path to point at the binary.${windowsHint}` - ); - } - - throw new Error( - `Failed to launch pg_dump (${executable})${connectionNote}: ${result.error.message ?? 'Unknown error'}${extraArgsNote}${useShell ? ' (shell mode enabled)' : ''}` - ); - } - - if (result.status !== 0 || !result.stdout) { - const stderr = result.stderr ? result.stderr.toString().trim() : 'Unknown error'; - throw new Error(`pg_dump reported an error${connectionNote}: ${stderr}${extraArgsNote}`); - } - - return result.stdout; -} - -function isExecutableMissing(error: NodeJS.ErrnoException, stderr?: string): boolean { - if (error.code === 'ENOENT') { - return true; - } - - if (!stderr) { - return false; - } - - const normalized = stderr.toLowerCase(); - return normalized.includes('not recognized') || normalized.includes('command not found'); -} diff --git a/packages/ztd-cli/src/utils/queryFingerprint.ts b/packages/ztd-cli/src/utils/queryFingerprint.ts deleted file mode 100644 index 732448a59..000000000 --- a/packages/ztd-cli/src/utils/queryFingerprint.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createHash } from 'node:crypto'; - -/** - * Fixed fingerprint length used in machine-facing outputs. - */ -export const QUERY_FINGERPRINT_LENGTH = 12; - -/** - * Normalize SQL text for stable statement fingerprinting. - * - * Fingerprint normalization is a compatibility contract for Issue #478. - */ -export function normalizeQueryFingerprintSource(sql: string): string { - const withoutLineComments = sql - .split('\n') - .map((line) => { - const commentStart = line.indexOf('--'); - return commentStart >= 0 ? line.slice(0, commentStart) : line; - }) - .join('\n'); - - const withoutBlockComments = withoutLineComments.replace(/\/\*[\s\S]*?\*\//g, ' '); - return withoutBlockComments.replace(/\s+/g, ' ').trim(); -} - -/** - * Build a stable short fingerprint for a SQL statement. - */ -export function createQueryFingerprint(sql: string): string { - const normalized = normalizeQueryFingerprintSource(sql); - return createHash('sha1').update(normalized).digest('hex').slice(0, QUERY_FINGERPRINT_LENGTH); -} diff --git a/packages/ztd-cli/src/utils/sqlCatalogDiscovery.ts b/packages/ztd-cli/src/utils/sqlCatalogDiscovery.ts deleted file mode 100644 index 6477e3cbb..000000000 --- a/packages/ztd-cli/src/utils/sqlCatalogDiscovery.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { readFileSync, readdirSync } from 'node:fs'; -import path from 'node:path'; - -const DEFAULT_DISCOVERY_IGNORED_DIRS = new Set([ - '.git', - 'node_modules', - 'dist', - 'build', - 'coverage', - 'tmp', - 'test', - 'tests', - '__tests__', - '.turbo', - '.next', - '.nuxt', - 'out', -]); - -/** - * Minimal SQL catalog spec shape shared across command-layer discovery flows. - */ -export interface SqlCatalogSpecLike { - id?: unknown; - sqlFile?: unknown; - params?: { - shape?: unknown; - example?: unknown; - }; - output?: { - mapping?: { - prefix?: unknown; - columnMap?: unknown; - } | unknown; - }; -} - -/** - * Discovered SQL catalog spec paired with its source file path. - */ -export interface LoadedSqlCatalogSpec { - filePath: string; - spec: SqlCatalogSpecLike; -} - -interface DiscoveryErrorFactory { - (message: string): Error; -} - -/** - * Walk spec-like files under the provided root using deterministic ordering. - */ -export function walkSqlCatalogSpecFiles( - rootDir: string, - options?: { excludeTestFiles?: boolean; excludeGenerated?: boolean } -): string[] { - return collectSqlCatalogSpecFiles(rootDir, rootDir, options); -} - -/** - * Discover QuerySpec-like files from the project root without assuming one fixed specs directory. - */ -export function discoverProjectSqlCatalogSpecFiles( - projectRoot: string, - options?: { excludeTestFiles?: boolean; excludeGenerated?: boolean } -): string[] { - return collectSqlCatalogSpecFiles(projectRoot, projectRoot, options, true); -} - -function collectSqlCatalogSpecFiles( - walkRoot: string, - generatedScopeRoot: string, - options?: { excludeTestFiles?: boolean; excludeGenerated?: boolean }, - useProjectWideIgnores = false -): string[] { - const files: string[] = []; - const stack = [walkRoot]; - while (stack.length > 0) { - const current = stack.pop()!; - const entries = readdirSync(current, { withFileTypes: true }) - .sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - const absolute = path.join(current, entry.name); - if (entry.isDirectory()) { - if (useProjectWideIgnores && shouldIgnoreDiscoveryDirectory(entry.name)) { - continue; - } - stack.push(absolute); - continue; - } - - if (!entry.isFile()) { - continue; - } - - const lowered = entry.name.toLowerCase(); - const isSpecLike = - lowered.endsWith('.json') || - lowered.endsWith('.ts') || - lowered.endsWith('.js') || - lowered.endsWith('.mts') || - lowered.endsWith('.cts'); - if (!isSpecLike) { - continue; - } - if (options?.excludeTestFiles && lowered.includes('.test.')) { - continue; - } - if (options?.excludeGenerated && isGeneratedSpecPath(absolute, generatedScopeRoot)) { - continue; - } - files.push(absolute); - } - } - return files.sort((a, b) => a.localeCompare(b)); -} - -/** - * Load SQL catalog specs from a single file while preserving current lightweight parsing behavior. - */ -export function loadSqlCatalogSpecsFromFile( - filePath: string, - createError: DiscoveryErrorFactory -): LoadedSqlCatalogSpec[] { - const ext = path.extname(filePath).toLowerCase(); - if (ext === '.json') { - const source = readFileSync(filePath, 'utf8'); - if (!looksLikeSpecFile(filePath) && !source.includes('"sqlFile"') && !source.includes('sqlFile')) { - return []; - } - - let parsed: unknown; - try { - parsed = JSON.parse(source); - } catch (error) { - throw createError( - `Failed to parse spec file ${filePath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - - if (Array.isArray(parsed)) { - return parsed - .filter(isSpecLikeRecord) - .map((spec) => ({ spec: spec as SqlCatalogSpecLike, filePath })); - } - if (isPlainObject(parsed) && Array.isArray((parsed as Record).specs)) { - const specs = (parsed as { specs: unknown[] }).specs; - return specs - .filter(isSpecLikeRecord) - .map((spec) => ({ spec: spec as SqlCatalogSpecLike, filePath })); - } - if (isSpecLikeRecord(parsed)) { - return [{ spec: parsed as SqlCatalogSpecLike, filePath }]; - } - return []; - } - - const source = readFileSync(filePath, 'utf8'); - if (!looksLikeSpecFile(filePath) && !source.includes('sqlFile')) { - const fallbackSpec = extractFeatureLocalQuerySpec(source); - return fallbackSpec ? [{ spec: fallbackSpec, filePath }] : []; - } - const blocks = extractTsJsSpecBlocks(source); - if (blocks.length === 0) { - const fallbackSpec = extractFeatureLocalQuerySpec(source); - return fallbackSpec ? [{ spec: fallbackSpec, filePath }] : []; - } - return blocks.map((block) => { - const id = block.match(/id\s*:\s*['"`]([^'"`]+)['"`]/)?.[1]; - const sqlFile = block.match(/sqlFile\s*:\s*['"`]([^'"`]+)['"`]/)?.[1]; - const shape = block.match(/shape\s*:\s*['"`](positional|named)['"`]/)?.[1]; - const exampleIsArray = /example\s*:\s*\[/.test(block); - const exampleIsObject = /example\s*:\s*\{/.test(block); - - const columnMapBlock = block.match(/columnMap\s*:\s*\{([\s\S]*?)\}/)?.[1] ?? ''; - const columnMap: Record = {}; - for (const match of Array.from(columnMapBlock.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*['"`]([^'"`]+)['"`]/g))) { - columnMap[match[1]] = match[2]; - } - const prefix = block.match(/prefix\s*:\s*['"`]([^'"`]*)['"`]/)?.[1]; - const mapping = { - ...(typeof prefix === 'string' ? { prefix } : {}), - ...(Object.keys(columnMap).length > 0 ? { columnMap } : {}) - }; - - return { - spec: { - id, - sqlFile, - params: { - shape, - example: exampleIsArray ? [] : exampleIsObject ? {} : undefined - }, - output: Object.keys(mapping).length > 0 ? { mapping } : undefined - } as SqlCatalogSpecLike, - filePath - }; - }); -} - -function extractFeatureLocalQuerySpec(source: string): SqlCatalogSpecLike | null { - const sqlResourceMatch = source.match(/loadSqlResource\s*\(\s*__dirname\s*,\s*['"`]([^'"`]+\.sql)['"`]\s*\)/); - if (!sqlResourceMatch) { - return null; - } - - const label = source.match(/label\s*:\s*['"`]([^'"`]+)['"`]/)?.[1]; - return { - id: label, - sqlFile: `./${sqlResourceMatch[1]}` - }; -} - -/** - * Lightweight TS/JS object-literal extraction used by current command implementations. - */ -export function extractTsJsSpecBlocks(source: string): string[] { - const blocks: string[] = []; - const seen = new Set(); - const idRegex = /id\s*:\s*['"`][^'"`]+['"`]/g; - - for (const match of Array.from(source.matchAll(idRegex))) { - if (typeof match.index !== 'number') { - continue; - } - - const start = source.lastIndexOf('{', match.index); - if (start < 0) { - continue; - } - - let depth = 0; - let end = -1; - for (let i = start; i < source.length; i += 1) { - const ch = source[i]; - if (ch === '{') { - depth += 1; - } else if (ch === '}') { - depth -= 1; - if (depth === 0) { - end = i; - break; - } - } - } - - if (end < 0) { - continue; - } - - const block = source.slice(start, end + 1); - if (!/sqlFile\s*:\s*['"`][^'"`]+['"`]/.test(block)) { - continue; - } - - if (!seen.has(block)) { - seen.add(block); - blocks.push(block); - } - } - - return blocks; -} - -export function isPlainObject(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return false; - } - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} - -function isGeneratedSpecPath(filePath: string, specsDir: string): boolean { - const normalizedFilePath = path.resolve(filePath); - const relativePath = path.relative(path.resolve(specsDir), normalizedFilePath); - if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { - return false; - } - - const segments = relativePath.split(path.sep); - return segments.some((segment) => segment.trim().toLowerCase() === 'generated'); -} - -function shouldIgnoreDiscoveryDirectory(dirName: string): boolean { - return DEFAULT_DISCOVERY_IGNORED_DIRS.has(dirName.toLowerCase()); -} - -function isSpecLikeRecord(value: unknown): value is Record { - return isPlainObject(value) && typeof value.sqlFile === 'string' && value.sqlFile.trim().length > 0; -} - -function looksLikeSpecFile(filePath: string): boolean { - const normalized = path.basename(filePath).toLowerCase(); - return normalized.includes('.spec.') || normalized.includes('.generated.'); -} diff --git a/packages/ztd-cli/src/utils/sqlCatalogStatements.ts b/packages/ztd-cli/src/utils/sqlCatalogStatements.ts deleted file mode 100644 index 9a59c1bc4..000000000 --- a/packages/ztd-cli/src/utils/sqlCatalogStatements.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { MultiQuerySplitter } from 'rawsql-ts'; -import { createQueryFingerprint } from './queryFingerprint'; - -/** - * Statement inventory entry derived from a catalog SQL file. - */ -export interface CatalogStatement { - catalogId: string; - queryId: string; - statementFingerprint: string; - sqlFile: string; - statementIndex: number; - statementText: string; - statementStartOffsetInFile: number; -} - -/** - * Split a catalog SQL file into deterministic statement inventory rows. - */ -export function buildCatalogStatements(params: { - catalogId: string; - sqlFile: string; - sqlText: string; -}): CatalogStatement[] { - const split = MultiQuerySplitter.split(params.sqlText); - return split.queries - .filter((query) => !query.isEmpty) - .map((query, index) => ({ - catalogId: params.catalogId, - queryId: `${params.catalogId}:${index + 1}`, - statementFingerprint: createQueryFingerprint(query.sql), - sqlFile: params.sqlFile, - statementIndex: index + 1, - statementText: query.sql, - statementStartOffsetInFile: query.start - })); -} diff --git a/packages/ztd-cli/src/utils/sqlLintHelpers.ts b/packages/ztd-cli/src/utils/sqlLintHelpers.ts deleted file mode 100644 index 22985a05d..000000000 --- a/packages/ztd-cli/src/utils/sqlLintHelpers.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { existsSync, lstatSync } from 'node:fs'; -import path from 'node:path'; -import fg from 'fast-glob'; -import { DEFAULT_EXTENSIONS } from '../commands/options'; -import { collectSqlFiles } from './collectSqlFiles'; -import type { TableDefinitionModel } from 'rawsql-ts'; -import type { FixtureRow } from '@rawsql-ts/testkit-core'; - -/** - * Resolve a CLI argument into an absolute list of `.sql` files. - */ -export function resolveSqlFiles(pattern: string): string[] { - const absolutePattern = path.resolve(pattern); - if (existsSync(absolutePattern)) { - const stats = lstatSync(absolutePattern); - if (stats.isFile()) { - return [toPosixPattern(absolutePattern)]; - } - if (stats.isDirectory()) { - const matches = fg.sync(toPosixPattern(path.join(absolutePattern, '**', '*.sql')), { - absolute: true, - onlyFiles: true - }); - if (matches.length === 0) { - throw new Error(`No SQL files were found under ${absolutePattern}`); - } - return matches.map(toPosixPattern).sort(); - } - } - - const globMatches = fg.sync(toPosixPattern(pattern), { - absolute: true, - onlyFiles: true - }); - if (globMatches.length === 0) { - throw new Error(`No SQL files matched ${pattern}`); - } - return globMatches.map(toPosixPattern).sort(); -} - -/** - * Scan the configured DDL directories for CREATE TYPE ... AS ENUM definitions. - */ -export function extractEnumLabels( - directories: string[], - extensions: string[] = DEFAULT_EXTENSIONS -): Map { - const enums = new Map(); - for (const directory of directories) { - if (!existsSync(directory)) { - continue; - } - const files = collectSqlFiles([directory], extensions); - for (const { sql } of files) { - for (const { name, labels } of parseEnumDefinitions(sql)) { - if (!labels.length) { - continue; - } - const normalized = normalizeQualifiedName(name); - if (!normalized) { - continue; - } - if (!enums.has(normalized)) { - enums.set(normalized, labels); - } - } - } - } - return enums; -} - -/** - * Build a fixture row for linting using minimal values derived from the column types. - */ -export function buildLintFixtureRow( - definition: TableDefinitionModel, - enumLabels: Map -): FixtureRow { - const row: FixtureRow = {}; - for (const column of definition.columns) { - row[column.name] = column.required - ? inferDefaultValue(column.typeName, enumLabels) - : null; - } - return row; -} - -/** - * Infer a default value for a required column to keep Postgres happy. - */ -export function inferDefaultValue( - typeName: string | undefined, - enumLabels: Map -): unknown { - if (!typeName) { - return ''; - } - const normalized = normalizeTypeName(typeName); - const enumMatch = enumLabels.get(normalized.key); - if (enumMatch && enumMatch.length > 0) { - return enumMatch[0]; - } - if (normalized.isArray) { - return '{}'; - } - if ( - normalized.base.startsWith('int') || - normalized.base.includes('serial') || - normalized.base === 'numeric' || - normalized.base === 'decimal' || - normalized.base === 'real' || - normalized.base === 'double precision' - ) { - return 0; - } - if (normalized.base === 'boolean' || normalized.base === 'bool') { - return false; - } - if (normalized.base === 'uuid') { - return '00000000-0000-0000-0000-000000000000'; - } - if ( - normalized.base.startsWith('character') || - normalized.base.startsWith('varchar') || - normalized.base === 'text' || - normalized.base === 'citext' || - normalized.base === 'name' - ) { - return ''; - } - if (normalized.base === 'date') { - return '1970-01-01'; - } - if (normalized.base.startsWith('timestamp')) { - return '1970-01-01 00:00:00'; - } - if (normalized.base.startsWith('time')) { - return '00:00:00'; - } - if (normalized.base === 'json' || normalized.base === 'jsonb') { - return '{}'; - } - return ''; -} - -interface NormalizedTypeName { - base: string; - key: string; - isArray: boolean; -} - -function normalizeTypeName(typeName: string): NormalizedTypeName { - const segments = splitQualifiedName(typeName); - if (segments.length === 0) { - return { base: '', key: '', isArray: false }; - } - const cleaned = segments.map((token) => - compactIdentifier(token).toLowerCase() - ); - let isArray = false; - let lastSegment = cleaned[cleaned.length - 1]; - if (lastSegment.endsWith('[]')) { - isArray = true; - lastSegment = lastSegment.slice(0, -2).trim(); - } - const base = lastSegment.replace(/\(.*\)$/, '').trim(); - cleaned[cleaned.length - 1] = base; - return { - base, - key: cleaned.join('.'), - isArray - }; -} - -function normalizeQualifiedName(value: string): string | null { - const segments = splitQualifiedName(value); - if (segments.length === 0) { - return null; - } - const cleaned = segments.map((segment) => - compactIdentifier(segment).toLowerCase() - ); - return cleaned.join('.'); -} - -function splitQualifiedName(value: string): string[] { - const parts: string[] = []; - let buffer = ''; - let inQuotes = false; - for (let i = 0; i < value.length; i += 1) { - const char = value[i]; - if (char === '"') { - buffer += char; - if (inQuotes && value[i + 1] === '"') { - buffer += '"'; - i += 1; - continue; - } - inQuotes = !inQuotes; - continue; - } - if (char === '.' && !inQuotes) { - if (buffer.length > 0) { - parts.push(buffer); - } - buffer = ''; - continue; - } - buffer += char; - } - if (buffer.length > 0) { - parts.push(buffer); - } - return parts - .map((segment) => segment.trim()) - .filter(Boolean); -} - -function compactIdentifier(value: string): string { - const trimmed = value.trim(); - if (trimmed.startsWith('"') && trimmed.endsWith('"')) { - return trimmed - .slice(1, -1) - .replace(/""/g, '"'); - } - return trimmed.replace(/\s+/g, ' '); -} - -function parseEnumDefinitions( - sql: string -): Array<{ name: string; labels: string[] }> { - const results: Array<{ name: string; labels: string[] }> = []; - const regex = /create\s+type\s+(.+?)\s+as\s+enum\s*\(/gsi; - let match: RegExpExecArray | null; - while ((match = regex.exec(sql))) { - const rawName = match[1].trim(); - const openParenIndex = regex.lastIndex - 1; - if (openParenIndex < 0 || sql[openParenIndex] !== '(') { - continue; - } - const { labels, end } = parseEnumLabelList(sql, openParenIndex); - results.push({ name: rawName, labels }); - regex.lastIndex = end; - } - return results; -} - -function parseEnumLabelList( - sql: string, - startIndex: number -): { labels: string[]; end: number } { - const labels: string[] = []; - let idx = startIndex + 1; - while (idx < sql.length) { - idx = skipWhitespace(sql, idx); - if (sql[idx] === ')') { - idx += 1; - break; - } - if (sql[idx] === ',') { - idx += 1; - continue; - } - if (sql[idx] !== "'") { - idx += 1; - continue; - } - const { value, newIndex } = parseStringLiteral(sql, idx); - labels.push(value); - idx = newIndex; - } - return { labels, end: idx }; -} - -function parseStringLiteral( - sql: string, - index: number -): { value: string; newIndex: number } { - let idx = index + 1; - let value = ''; - while (idx < sql.length) { - const char = sql[idx]; - if (char === "'") { - if (sql[idx + 1] === "'") { - value += "'"; - idx += 2; - continue; - } - idx += 1; - break; - } - value += char; - idx += 1; - } - return { value, newIndex: idx }; -} - -function skipWhitespace(sql: string, index: number): number { - while (index < sql.length && /\s/.test(sql[index])) { - index += 1; - } - return index; -} - -function toPosixPattern(value: string): string { - return value.replace(/\\/g, '/'); -} diff --git a/packages/ztd-cli/src/utils/telemetry.ts b/packages/ztd-cli/src/utils/telemetry.ts deleted file mode 100644 index 794cf9e8b..000000000 --- a/packages/ztd-cli/src/utils/telemetry.ts +++ /dev/null @@ -1,706 +0,0 @@ -import { appendFileSync, mkdirSync } from 'node:fs'; -import { dirname, resolve as resolvePath } from 'node:path'; -import { performance } from 'node:perf_hooks'; -import { randomBytes } from 'node:crypto'; - -export type TelemetryAttributeValue = string | number | boolean | null; -export type TelemetryAttributes = Record; -export type TelemetryStatus = 'ok' | 'error'; -export type TelemetryExportMode = 'console' | 'debug' | 'file' | 'otlp'; - -const TELEMETRY_ENABLED_ENV = 'ZTD_CLI_TELEMETRY'; -const TELEMETRY_EXPORT_ENV = 'ZTD_CLI_TELEMETRY_EXPORT'; -const TELEMETRY_FILE_ENV = 'ZTD_CLI_TELEMETRY_FILE'; -const TELEMETRY_OTLP_ENDPOINT_ENV = 'ZTD_CLI_TELEMETRY_OTLP_ENDPOINT'; -const DEFAULT_SCHEMA_VERSION = 1; -const DEFAULT_OTLP_HTTP_ENDPOINT = 'http://127.0.0.1:4318/v1/traces'; -const DEFAULT_FILE_EXPORT_PATH = 'tmp/telemetry/ztd-cli.telemetry.jsonl'; -const MAX_ATTRIBUTE_STRING_LENGTH = 160; -const REDACTED_VALUE = '[REDACTED]'; -const OTLP_STATUS_OK = 1; -const OTLP_STATUS_ERROR = 2; - -export const TELEMETRY_DECISION_EVENT_SCHEMA = { - 'command.selected': { - summary: 'A command path was selected and root command telemetry started.', - allowedAttributes: ['command'], - }, - 'command.completed': { - summary: 'A command path completed successfully.', - allowedAttributes: ['command'], - }, - 'model-gen.probe-mode': { - summary: 'model-gen resolved whether probing should use live PostgreSQL or ZTD fixtures.', - allowedAttributes: ['probeMode'], - }, - 'watch.invalid-with-dry-run': { - summary: 'ztd-config rejected an invalid watch plus dry-run option combination.', - allowedAttributes: [], - }, - 'command.options.resolved': { - summary: 'ztd-config resolved high-level option state that influences generation flow.', - allowedAttributes: ['dryRun', 'watch', 'quiet', 'shouldUpdateConfig', 'jsonPayload'], - }, - 'config.updated': { - summary: 'ztd-config persisted ddl-related project configuration changes.', - allowedAttributes: [], - }, - 'output.json-envelope': { - summary: 'The command emitted a machine-readable JSON envelope.', - allowedAttributes: [], - }, - 'watch.enabled': { - summary: 'ztd-config switched into watch mode after the initial generation.', - allowedAttributes: [], - }, - 'output.dry-run-diagnostic': { - summary: 'The command emitted dry-run follow-up guidance instead of writing files.', - allowedAttributes: [], - }, - 'output.next-steps-diagnostic': { - summary: 'The command emitted next-step guidance for interactive use.', - allowedAttributes: [], - }, - 'output.quiet-suppressed': { - summary: 'The command intentionally suppressed follow-up guidance because quiet mode is active.', - allowedAttributes: [], - }, -} as const; - -export type TelemetryDecisionEventName = keyof typeof TELEMETRY_DECISION_EVENT_SCHEMA; - -interface TelemetrySpan { - id: string; - end(status: TelemetryStatus): void; - recordException(error: unknown, attributes?: TelemetryAttributes): void; -} - -interface TelemetrySink { - startSpan(name: string, parentSpanId?: string, attributes?: TelemetryAttributes): TelemetrySpan; - emitDecisionEvent(name: TelemetryDecisionEventName, spanId?: string, attributes?: TelemetryAttributes): void; - emitException(error: unknown, spanId?: string, attributes?: TelemetryAttributes): void; - flush(): Promise; -} - -interface TelemetryEnvelopeBase { - schemaVersion: number; - type: 'telemetry'; - timestamp: string; -} - -type TelemetryEnvelope = TelemetryEnvelopeBase & Record; - -interface OtlpSpanEventRecord { - timeUnixNano: string; - name: string; - attributes: Array>; -} - -interface ActiveOtlpSpanRecord { - traceId: string; - spanId: string; - parentSpanId?: string; - name: string; - startTimeUnixNano: string; - attributes: Array>; - events: OtlpSpanEventRecord[]; -} - -class NoopTelemetrySpan implements TelemetrySpan { - id = 'noop'; - - end(): void { - return; - } - - recordException(): void { - return; - } -} - -class NoopTelemetrySink implements TelemetrySink { - startSpan(): TelemetrySpan { - return new NoopTelemetrySpan(); - } - - emitDecisionEvent(): void { - return; - } - - emitException(): void { - return; - } - - async flush(): Promise { - return; - } -} - -class JsonLinesTelemetrySink implements TelemetrySink { - private nextSpanId = 1; - - constructor(private readonly writeLine: (line: string) => void) {} - - startSpan(name: string, parentSpanId?: string, attributes?: TelemetryAttributes): TelemetrySpan { - const spanId = `span-${this.nextSpanId++}`; - const startedAt = performance.now(); - - this.write({ - kind: 'span-start', - spanId, - parentSpanId, - spanName: name, - attributes: sanitizeAttributes(attributes), - }); - - return { - id: spanId, - end: (status: TelemetryStatus) => { - this.write({ - kind: 'span-end', - spanId, - parentSpanId, - spanName: name, - status, - durationMs: roundDuration(performance.now() - startedAt), - }); - }, - recordException: (error: unknown, exceptionAttributes?: TelemetryAttributes) => { - this.emitException(error, spanId, exceptionAttributes); - }, - }; - } - - emitDecisionEvent(name: TelemetryDecisionEventName, spanId?: string, attributes?: TelemetryAttributes): void { - const schema = TELEMETRY_DECISION_EVENT_SCHEMA[name]; - this.write({ - kind: 'decision', - eventName: name, - spanId, - attributes: sanitizeAttributes(attributes, schema.allowedAttributes), - }); - } - - emitException(error: unknown, spanId?: string, attributes?: TelemetryAttributes): void { - this.write({ - kind: 'exception', - spanId, - error: normalizeError(error), - attributes: sanitizeAttributes(attributes), - }); - } - - async flush(): Promise { - return; - } - - private write(payload: Record): void { - this.writeLine(JSON.stringify(buildTelemetryEnvelope(payload))); - } -} - -class DebugTelemetrySink implements TelemetrySink { - private nextSpanId = 1; - - startSpan(name: string, parentSpanId?: string, attributes?: TelemetryAttributes): TelemetrySpan { - const spanId = `span-${this.nextSpanId++}`; - const startedAt = performance.now(); - this.write('span-start', name, spanId, parentSpanId, sanitizeAttributes(attributes)); - - return { - id: spanId, - end: (status: TelemetryStatus) => { - this.write('span-end', name, spanId, parentSpanId, { - status, - durationMs: roundDuration(performance.now() - startedAt), - }); - }, - recordException: (error: unknown, exceptionAttributes?: TelemetryAttributes) => { - this.emitException(error, spanId, exceptionAttributes); - }, - }; - } - - emitDecisionEvent(name: TelemetryDecisionEventName, spanId?: string, attributes?: TelemetryAttributes): void { - const schema = TELEMETRY_DECISION_EVENT_SCHEMA[name]; - this.write('decision', name, spanId, undefined, sanitizeAttributes(attributes, schema.allowedAttributes)); - } - - emitException(error: unknown, spanId?: string, attributes?: TelemetryAttributes): void { - this.write('exception', normalizeError(error).message as string, spanId, undefined, sanitizeAttributes(attributes)); - } - - async flush(): Promise { - return; - } - - private write(kind: string, label: string, spanId?: string, parentSpanId?: string, data: Record = {}): void { - const summary = JSON.stringify(data); - const suffix = summary === '{}' ? '' : ` ${summary}`; - const parent = parentSpanId ? ` parent=${parentSpanId}` : ''; - process.stderr.write(`[telemetry] ${kind} ${label} span=${spanId ?? 'none'}${parent}${suffix}\n`); - } -} - -class OtlpHttpTelemetrySink implements TelemetrySink { - private readonly activeSpans = new Map(); - private readonly pendingExports = new Set>(); - - constructor(private readonly endpoint: string) {} - - startSpan(name: string, parentSpanId?: string, attributes?: TelemetryAttributes): TelemetrySpan { - const parentSpan = parentSpanId ? this.activeSpans.get(parentSpanId) : undefined; - const spanId = randomBytes(8).toString('hex'); - const spanRecord: ActiveOtlpSpanRecord = { - traceId: parentSpan?.traceId ?? randomBytes(16).toString('hex'), - spanId, - parentSpanId, - name, - startTimeUnixNano: currentTimeUnixNano(), - attributes: toOtlpAttributes(sanitizeAttributes(attributes)), - events: [], - }; - this.activeSpans.set(spanId, spanRecord); - - return { - id: spanId, - end: (status: TelemetryStatus) => { - this.endSpan(spanId, status); - }, - recordException: (error: unknown, exceptionAttributes?: TelemetryAttributes) => { - this.emitException(error, spanId, exceptionAttributes); - }, - }; - } - - emitDecisionEvent(name: TelemetryDecisionEventName, spanId?: string, attributes?: TelemetryAttributes): void { - const spanRecord = spanId ? this.activeSpans.get(spanId) : undefined; - if (!spanRecord) { - return; - } - - const schema = TELEMETRY_DECISION_EVENT_SCHEMA[name]; - spanRecord.events.push({ - timeUnixNano: currentTimeUnixNano(), - name, - attributes: toOtlpAttributes(sanitizeAttributes(attributes, schema.allowedAttributes)), - }); - } - - emitException(error: unknown, spanId?: string, attributes?: TelemetryAttributes): void { - const spanRecord = spanId ? this.activeSpans.get(spanId) : undefined; - if (!spanRecord) { - return; - } - - const normalized = normalizeError(error); - spanRecord.events.push({ - timeUnixNano: currentTimeUnixNano(), - name: 'exception', - attributes: [ - ...toOtlpAttributes({ - 'exception.type': String(normalized.name ?? 'UnknownError'), - 'exception.message': String(normalized.message ?? ''), - }), - ...toOtlpAttributes(sanitizeAttributes(attributes)), - ], - }); - } - - async flush(): Promise { - await Promise.allSettled([...this.pendingExports]); - } - - private endSpan(spanId: string, status: TelemetryStatus): void { - const spanRecord = this.activeSpans.get(spanId); - if (!spanRecord) { - return; - } - this.activeSpans.delete(spanId); - - const payload = { - resourceSpans: [ - { - resource: { - attributes: toOtlpAttributes({ - 'service.name': 'ztd-cli', - 'telemetry.export.mode': 'otlp', - }), - }, - scopeSpans: [ - { - scope: { - name: '@rawsql-ts/ztd-cli', - }, - spans: [ - { - traceId: spanRecord.traceId, - spanId: spanRecord.spanId, - parentSpanId: spanRecord.parentSpanId, - name: spanRecord.name, - kind: 1, - startTimeUnixNano: spanRecord.startTimeUnixNano, - endTimeUnixNano: currentTimeUnixNano(), - attributes: spanRecord.attributes, - events: spanRecord.events, - status: { - code: status === 'ok' ? OTLP_STATUS_OK : OTLP_STATUS_ERROR, - }, - }, - ], - }, - ], - }, - ], - }; - - const exportPromise = fetch(this.endpoint, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify(payload), - }) - .then(() => undefined) - .catch(() => undefined) - .finally(() => { - this.pendingExports.delete(exportPromise); - }); - - this.pendingExports.add(exportPromise); - } -} - -const NOOP_SINK = new NoopTelemetrySink(); - -let telemetrySink: TelemetrySink = NOOP_SINK; -let telemetryEnabled = false; -let telemetryExportMode: TelemetryExportMode = 'console'; -const spanStack: TelemetrySpan[] = []; - -export function resolveTelemetryEnabled(explicit?: boolean | string | undefined): boolean { - if (typeof explicit === 'boolean') { - return explicit; - } - - if (typeof explicit === 'string') { - return isTruthy(explicit); - } - - return isTruthy(process.env[TELEMETRY_ENABLED_ENV]); -} - -export function resolveTelemetryExportMode(explicit?: TelemetryExportMode | string | undefined): TelemetryExportMode { - return normalizeTelemetryExportMode(explicit ?? process.env[TELEMETRY_EXPORT_ENV]); -} - -export function resolveTelemetryFilePath(explicit?: string | undefined): string { - return explicit ?? process.env[TELEMETRY_FILE_ENV] ?? DEFAULT_FILE_EXPORT_PATH; -} - -export function resolveTelemetryOtlpEndpoint(explicit?: string | undefined): string { - return explicit ?? process.env[TELEMETRY_OTLP_ENDPOINT_ENV] ?? DEFAULT_OTLP_HTTP_ENDPOINT; -} - -export function setTelemetryEnabled(enabled: boolean): void { - process.env[TELEMETRY_ENABLED_ENV] = enabled ? '1' : '0'; -} - -export function configureTelemetry(options: { - enabled?: boolean | string; - exportMode?: TelemetryExportMode | string; - filePath?: string; - otlpEndpoint?: string; -} = {}): void { - telemetryEnabled = resolveTelemetryEnabled(options.enabled); - telemetryExportMode = resolveTelemetryExportMode(options.exportMode); - - if (!telemetryEnabled) { - telemetrySink = NOOP_SINK; - spanStack.length = 0; - return; - } - - telemetrySink = createTelemetrySink({ - exportMode: telemetryExportMode, - filePath: resolveTelemetryFilePath(options.filePath), - otlpEndpoint: resolveTelemetryOtlpEndpoint(options.otlpEndpoint), - }); - spanStack.length = 0; -} - -export function isTelemetryEnabled(): boolean { - return telemetryEnabled; -} - -export function getTelemetryExportMode(): TelemetryExportMode { - return telemetryExportMode; -} - -export async function flushTelemetry(): Promise { - await telemetrySink.flush(); -} - -export function beginCommandSpan(commandName: string, attributes: TelemetryAttributes = {}): void { - if (!telemetryEnabled) { - return; - } - - spanStack.length = 0; - const rootSpan = telemetrySink.startSpan(commandName, undefined, { - ...attributes, - scope: 'command-root', - }); - spanStack.push(rootSpan); -} - -export function finishCommandSpan(status: TelemetryStatus = 'ok'): void { - if (!telemetryEnabled) { - return; - } - - while (spanStack.length > 1) { - spanStack.pop()?.end(status); - } - - spanStack.pop()?.end(status); -} - -export async function withSpan(name: string, fn: () => Promise | T, attributes: TelemetryAttributes = {}): Promise { - if (!telemetryEnabled) { - return await fn(); - } - - const parentSpanId = getCurrentSpan()?.id; - const span = telemetrySink.startSpan(name, parentSpanId, attributes); - spanStack.push(span); - - try { - const result = await fn(); - span.end('ok'); - return result; - } catch (error) { - span.recordException(error); - span.end('error'); - throw error; - } finally { - removeSpan(span.id); - } -} - -export function withSpanSync(name: string, fn: () => T, attributes: TelemetryAttributes = {}): T { - if (!telemetryEnabled) { - return fn(); - } - - const parentSpanId = getCurrentSpan()?.id; - const span = telemetrySink.startSpan(name, parentSpanId, attributes); - spanStack.push(span); - - try { - const result = fn(); - span.end('ok'); - return result; - } catch (error) { - span.recordException(error); - span.end('error'); - throw error; - } finally { - removeSpan(span.id); - } -} - -export function emitDecisionEvent(name: TelemetryDecisionEventName, attributes: TelemetryAttributes = {}): void { - if (!telemetryEnabled) { - return; - } - - telemetrySink.emitDecisionEvent(name, getCurrentSpan()?.id, attributes); -} - -export function recordException(error: unknown, attributes: TelemetryAttributes = {}): void { - if (!telemetryEnabled) { - return; - } - - const currentSpan = getCurrentSpan(); - if (currentSpan) { - currentSpan.recordException(error, attributes); - return; - } - - telemetrySink.emitException(error, undefined, attributes); -} - -function createTelemetrySink(options: { - exportMode: TelemetryExportMode; - filePath: string; - otlpEndpoint: string; -}): TelemetrySink { - switch (options.exportMode) { - case 'console': - return new JsonLinesTelemetrySink((line) => { - process.stderr.write(`${line}\n`); - }); - case 'debug': - return new DebugTelemetrySink(); - case 'file': { - const absoluteFile = resolvePath(process.cwd(), options.filePath); - mkdirSync(dirname(absoluteFile), { recursive: true }); - return new JsonLinesTelemetrySink((line) => { - appendFileSync(absoluteFile, `${line}\n`, 'utf8'); - }); - } - case 'otlp': - return new OtlpHttpTelemetrySink(options.otlpEndpoint); - default: - return NOOP_SINK; - } -} - -function buildTelemetryEnvelope(payload: Record): TelemetryEnvelope { - return { - schemaVersion: DEFAULT_SCHEMA_VERSION, - type: 'telemetry', - timestamp: new Date().toISOString(), - ...payload, - }; -} - -function getCurrentSpan(): TelemetrySpan | undefined { - return spanStack[spanStack.length - 1]; -} - -function removeSpan(spanId: string): void { - const index = spanStack.findIndex((span) => span.id === spanId); - if (index >= 0) { - spanStack.splice(index, 1); - } -} - -function isTruthy(value: string | undefined): boolean { - if (!value) { - return false; - } - - const normalized = value.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; -} - -function normalizeTelemetryExportMode(value: string | undefined): TelemetryExportMode { - switch ((value ?? 'console').trim().toLowerCase()) { - case 'console': - case 'debug': - case 'file': - case 'otlp': - return (value ?? 'console').trim().toLowerCase() as TelemetryExportMode; - default: - return 'console'; - } -} - -function sanitizeAttributes( - attributes: TelemetryAttributes = {}, - allowedKeys?: readonly string[], -): Record { - const sanitized: Record = {}; - for (const [key, value] of Object.entries(attributes)) { - if (value === undefined) { - continue; - } - if (allowedKeys && !allowedKeys.includes(key)) { - continue; - } - - sanitized[key] = sanitizeAttributeValue(key, value); - } - return sanitized; -} - -function normalizeError(error: unknown): Record { - if (error instanceof Error) { - return { - name: error.name, - message: sanitizeStringValue('message', error.message), - }; - } - - return { - name: 'UnknownError', - message: sanitizeStringValue('message', String(error)), - }; -} - -function sanitizeAttributeValue(key: string, value: TelemetryAttributeValue): TelemetryAttributeValue { - if (value === null || typeof value === 'number' || typeof value === 'boolean') { - return isSensitiveAttributeKey(key) ? REDACTED_VALUE : value; - } - - return sanitizeStringValue(key, value); -} - -// Telemetry safety policy intentionally distinguishes secrets from bulky but benign data. DSN detection covers both URL-style and libpq-style forms, while oversized or multiline values are truncated instead of redacted so exporters keep the boundary without leaking payload bodies. -function sanitizeStringValue(key: string, value: string): string { - if (isSensitiveAttributeKey(key) || looksSensitive(value)) { - return REDACTED_VALUE; - } - - if (value.includes('\n') || value.length > MAX_ATTRIBUTE_STRING_LENGTH) { - return `[TRUNCATED:${value.length}]`; - } - - return value; -} - -function isSensitiveAttributeKey(key: string): boolean { - return [ - /(?:^|\.)(?:sql|sqlText|query|queryText|statement)$/iu, - /(?:dsn|databaseUrl|connectionUrl|url|uri)$/iu, - /(?:password|secret|credential|authorization|api(?:_|-)?key|access(?:_|-)?key|(?:auth|bearer|access|refresh|session)?token)$/iu, - /(?:bindValue|bindValues|paramValue|paramValues)$/iu, - /(?:stack|dump)$/iu, - ].some((pattern) => pattern.test(key)); -} - -function looksSensitive(value: string): boolean { - const normalized = value.trim(); - if (normalized.length === 0) { - return false; - } - - return [ - /\b(?:postgres(?:ql)?|mysql|mssql|redis):\/\/\S+/iu, - /\b(?:host|hostaddr|port|dbname|db|user|password|passfile|service|sslmode|sslcert|sslkey|sslrootcert|target_session_attrs)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s]+)/iu, - /\b(?:password|pwd|secret|token|api(?:key|_key)|access(?:key|_key)|authorization)\s*[=:]\s*\S+/iu, - /\b(?:bearer|basic)\s+[A-Za-z0-9._~+\/=:-]{8,}\b/iu, - /\b(?:select|insert|update|delete|create|alter|drop|with)\b[\s\S]{0,120}\b(?:from|into|table|view|where|values|set)\b/iu, - ].some((pattern) => pattern.test(normalized)); -} - -function toOtlpAttributes(attributes: Record): Array> { - return Object.entries(attributes).map(([key, value]) => ({ - key, - value: toOtlpAnyValue(value), - })); -} - -function toOtlpAnyValue(value: TelemetryAttributeValue): Record { - if (value === null) { - return { stringValue: 'null' }; - } - if (typeof value === 'boolean') { - return { boolValue: value }; - } - if (typeof value === 'number') { - return Number.isInteger(value) ? { intValue: value } : { doubleValue: value }; - } - return { stringValue: value }; -} - -function currentTimeUnixNano(): string { - return (BigInt(Date.now()) * BigInt(1000000)).toString(); -} - -function roundDuration(value: number): number { - return Math.round(value * 1000) / 1000; -} diff --git a/packages/ztd-cli/src/utils/typeMapper.ts b/packages/ztd-cli/src/utils/typeMapper.ts deleted file mode 100644 index 058d9da42..000000000 --- a/packages/ztd-cli/src/utils/typeMapper.ts +++ /dev/null @@ -1,96 +0,0 @@ -const numericTypes = new Set([ - 'int', - 'integer', - 'smallint', - 'bigint', - 'real', - 'double precision', - 'float', - 'serial', - 'bigserial', - 'smallserial', - 'serial2', - 'serial4', - 'serial8' -]); - -const arbitraryPrecisionTypes = new Set(['decimal', 'numeric']); - -const stringTypes = new Set(['text', 'varchar', 'char', 'character varying', 'character', 'uuid', 'citext']); - -const dateTypes = new Set(['date', 'timestamp', 'timestamp without time zone', 'timestamp with time zone', 'time', 'time without time zone', 'time with time zone', 'timestamptz']); - -const shouldLogUnknownSqlTypes = process.env.RAWSQL_DDL_SILENT !== '1'; - -function warnUnknownSqlType(typeName: string | undefined, context?: string): void { - // Do nothing when logging is explicitly silenced in CI or tooling. - if (!shouldLogUnknownSqlTypes) { - return; - } - - // Keep developers aware when the generator sees an unmapped SQL type. - const subject = context ?? 'column'; - console.warn( - `[ztd ddl] Unknown SQL type for ${subject}: ${typeName ?? 'undefined'}. Defaulting to unknown.` - ); -} - -/** - * Maps PostgreSQL data types (basic numeric, string, temporal, JSON/binary types) to serializable TypeScript types, falling back to `unknown` when unmatched. - */ -export function mapSqlTypeToTs(typeName?: string, context?: string): string { - // Log when the AST omitted a type so callers can track down why type inference failed. - if (!typeName) { - warnUnknownSqlType(typeName, context); - return 'unknown'; - } - - // Drop trailing precision/length metadata (varchar(255), numeric(10,2), etc.) because TS cannot capture it. - const normalized = typeName.split('(')[0].trim().toLowerCase(); - - if (numericTypes.has(normalized)) { - return 'number'; - } - - if (arbitraryPrecisionTypes.has(normalized)) { - // Postgres decimal/numeric are arbitrary precision, so string preserves exactness when floating-point would lose it. - return 'string'; - } - - // Treat all date/time families as strings to keep the generated rows serializable. - if (normalized.includes('time') || dateTypes.has(normalized)) { - return 'string'; - } - - if (stringTypes.has(normalized) || normalized.includes('text')) { - return 'string'; - } - - if (normalized === 'boolean' || normalized === 'bool') { - return 'boolean'; - } - - if (normalized === 'json' || normalized === 'jsonb') { - // Use any so generated APIs stay friendly for JSON-heavy applications; narrowing can still be applied later. - return 'any'; - } - - if (normalized === 'bytea') { - // Prefer Uint8Array to avoid Node.js-only Buffer while still representing binary payloads. - return 'Uint8Array'; - } - - if (normalized === 'inet' || normalized === 'cidr' || normalized === 'macaddr') { - return 'string'; - } - - if (normalized.startsWith('interval')) { - // Interval strings cover day/hour/second variants, so keep them as text. - return 'string'; - } - - // Alert when an unfamiliar SQL type flows through so we can extend the mapper later. - warnUnknownSqlType(typeName, context); - return 'unknown'; -} - diff --git a/packages/ztd-cli/src/utils/ztdProjectConfig.ts b/packages/ztd-cli/src/utils/ztdProjectConfig.ts deleted file mode 100644 index 68fb09d63..000000000 --- a/packages/ztd-cli/src/utils/ztdProjectConfig.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import type { DdlLintMode } from '@rawsql-ts/testkit-core'; - -export interface ZtdConnectionConfig { - url?: string; - host?: string; - port?: number; - user?: string; - password?: string; - database?: string; -} - -export interface ZtdProjectConfig { - ztdRootDir?: string; - dialect: string; - ddlDir: string; - testsDir: string; - defaultSchema: string; - searchPath: string[]; - /** Controls DDL integrity validation during config generation and tests. */ - ddlLint: DdlLintMode; - /** @deprecated Legacy field. ztd-cli no longer uses config-based DB connections implicitly. */ - connection?: ZtdConnectionConfig; -} - -const CONFIG_NAME = 'ztd.config.json'; -export const DEFAULT_ZTD_ROOT_DIR = '.ztd'; -export const DEFAULT_ZTD_GENERATED_DIR = `${DEFAULT_ZTD_ROOT_DIR}/generated`; -export const DEFAULT_ZTD_SUPPORT_DIR = `${DEFAULT_ZTD_ROOT_DIR}/support`; -export const DEFAULT_TEST_DISCOVERY_DIR = `${DEFAULT_ZTD_ROOT_DIR}/tests`; - -let hasWarnedLegacyConnectionConfig = false; - -export const DEFAULT_ZTD_CONFIG: ZtdProjectConfig = { - ztdRootDir: DEFAULT_ZTD_ROOT_DIR, - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: DEFAULT_TEST_DISCOVERY_DIR, - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' -}; - -/** - * Resolves the path to the project's ztd.config.json from a provided root. - * @param rootDir - Directory to start searching from (defaults to current working directory). - * @returns Absolute path to ztd.config.json. - */ -export function resolveZtdConfigPath(rootDir: string = process.cwd()): string { - return path.join(rootDir, CONFIG_NAME); -} - -/** - * Loads the project configuration, merging against defaults when the file is missing or partially provided. - * @param rootDir - Directory containing the ztd.config.json file. - * @returns A fully-resolved ZtdProjectConfig instance. - */ -export function loadZtdProjectConfig(rootDir: string = process.cwd()): ZtdProjectConfig { - const filePath = resolveZtdConfigPath(rootDir); - if (!existsSync(filePath)) { - assertSupportedProjectLayout(rootDir, DEFAULT_ZTD_CONFIG); - return DEFAULT_ZTD_CONFIG; - } - - try { - // Merge on top of defaults so partial configs remain valid. - const raw = JSON.parse(readFileSync(filePath, 'utf8')); - const legacySchemaConfig = detectLegacySchemaConfig(raw); - if (legacySchemaConfig) { - throw new Error( - `${filePath} uses removed legacy ddl.defaultSchema / ddl.searchPath settings. Move them to top-level defaultSchema and searchPath.` - ); - } - const rawConnection = typeof raw.connection === 'object' && raw.connection !== null ? raw.connection : undefined; - const rawLintMode = typeof raw.ddlLint === 'string' ? raw.ddlLint.trim().toLowerCase() : undefined; - const resolvedDefaultSchema = resolveSchemaName( - typeof raw.defaultSchema === 'string' ? raw.defaultSchema : undefined, - DEFAULT_ZTD_CONFIG.defaultSchema - ); - const resolvedSearchPath = resolveSearchPath( - raw.searchPath, - undefined, - resolvedDefaultSchema - ); - const normalizedConnection = normalizeConnectionConfig(rawConnection); - if (normalizedConnection) { - emitLegacyConnectionConfigWarning(filePath); - } - - const resolvedConfig: ZtdProjectConfig = { - ztdRootDir: typeof raw.ztdRootDir === 'string' && raw.ztdRootDir.length ? raw.ztdRootDir : undefined, - dialect: typeof raw.dialect === 'string' ? raw.dialect : DEFAULT_ZTD_CONFIG.dialect, - ddlDir: typeof raw.ddlDir === 'string' && raw.ddlDir.length ? raw.ddlDir : DEFAULT_ZTD_CONFIG.ddlDir, - testsDir: - typeof raw.testsDir === 'string' && raw.testsDir.length ? raw.testsDir : DEFAULT_ZTD_CONFIG.testsDir, - defaultSchema: resolvedDefaultSchema, - searchPath: resolvedSearchPath, - ddlLint: isDdlLintMode(rawLintMode) ? rawLintMode : DEFAULT_ZTD_CONFIG.ddlLint, - connection: normalizedConnection - }; - assertSupportedProjectLayout(rootDir, resolvedConfig); - return resolvedConfig; - } catch (error) { - throw new Error(`${CONFIG_NAME} is malformed: ${error instanceof Error ? error.message : String(error)}`); - } -} - -export function resolveZtdRootDir(config: ZtdProjectConfig): string { - return normalizeProjectPath(config.ztdRootDir || DEFAULT_ZTD_CONFIG.ztdRootDir || DEFAULT_ZTD_ROOT_DIR); -} - -export function resolveGeneratedDir(config: ZtdProjectConfig): string { - return `${resolveZtdRootDir(config)}/generated`; -} - -export function resolveSupportDir(config: ZtdProjectConfig): string { - return `${resolveZtdRootDir(config)}/support`; -} - -function normalizeProjectPath(filePath: string): string { - return filePath.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, ''); -} - -function assertSupportedProjectLayout(rootDir: string, config: ZtdProjectConfig): void { - const normalizedDdlDir = normalizeProjectPath(config.ddlDir); - const normalizedTestsDir = normalizeProjectPath(config.testsDir); - const normalizedZtdRootDir = resolveZtdRootDir(config); - const legacySignals: string[] = []; - - if (normalizedDdlDir === 'ztd/ddl') { - legacySignals.push('ztd.config.json uses the removed ddlDir value "ztd/ddl".'); - } - if (normalizedTestsDir === 'tests') { - legacySignals.push('ztd.config.json uses the removed testsDir value "tests".'); - } - if (normalizedZtdRootDir !== DEFAULT_ZTD_ROOT_DIR) { - legacySignals.push(`ztd.config.json uses the unsupported ztdRootDir "${normalizedZtdRootDir}".`); - } - - const knownLegacyPaths = ['ztd/ddl', 'tests/generated', 'tests/queryspec.example.test.ts']; - for (const relativePath of knownLegacyPaths) { - if (existsSync(path.join(rootDir, relativePath))) { - legacySignals.push(`Legacy layout detected at ${relativePath}.`); - } - } - - const legacySupportDir = path.join(rootDir, 'tests/support'); - if (existsSync(legacySupportDir) && !existsSync(path.join(legacySupportDir, 'ztd'))) { - legacySignals.push('Legacy layout detected at tests/support.'); - } - - if (legacySignals.length === 0) { - return; - } - - throw new Error( - [ - 'This project uses the removed pre-.ztd layout.', - ...legacySignals.map((signal) => `- ${signal}`), - 'Migration steps:', - '- Move DDL from ztd/ddl to db/ddl.', - '- Move repo-level generated files to .ztd/generated.', - '- Move repo-level support files to .ztd/support.', - '- Update ztd.config.json so ztdRootDir=".ztd" and ddlDir="db/ddl".', - '- Re-run `ztd ztd-config` after the move.', - '- Remove stale legacy scaffold files under ztd/ and tests/.' - ].join('\n') - ); -} - -function isDdlLintMode(value?: string): value is DdlLintMode { - return value === 'strict' || value === 'warn' || value === 'off'; -} - -function resolveSchemaName(primary?: string, fallback?: string, defaultValue = DEFAULT_ZTD_CONFIG.defaultSchema): string { - const candidate = [primary, fallback].find((value): value is string => typeof value === 'string' && value.length > 0); - return candidate ?? defaultValue; -} - -function resolveSearchPath( - primary: unknown, - fallback: unknown, - defaultSchema: string -): string[] { - const primaryPath = normalizeSchemaList(primary); - if (primaryPath.length > 0) { - return primaryPath; - } - - const fallbackPath = normalizeSchemaList(fallback); - if (fallbackPath.length > 0) { - return fallbackPath; - } - - return [defaultSchema]; -} - -function normalizeSchemaList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - - return value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); -} - -function detectLegacySchemaConfig(raw: unknown): boolean { - if (typeof raw !== 'object' || raw === null) { - return false; - } - - const rawRecord = raw as Record; - return Object.prototype.hasOwnProperty.call(rawRecord, 'ddl') && typeof rawRecord.ddl === 'object' && rawRecord.ddl !== null; -} - -/** - * Persists the provided overrides on top of the existing project configuration. - * @param rootDir - Directory that hosts ztd.config.json. - * @param overrides - Partial configuration values to merge. - */ -export function writeZtdProjectConfig( - rootDir: string, - overrides: Partial = {}, - baseConfig: ZtdProjectConfig = loadZtdProjectConfig(rootDir) -): boolean { - const finalConfig = mergeProjectConfig(baseConfig, overrides); - const existingPath = resolveZtdConfigPath(rootDir); - const existingConfigPresent = existsSync(existingPath); - const baseSerialized = `${JSON.stringify(baseConfig, null, 2)}\n`; - const finalSerialized = `${JSON.stringify(finalConfig, null, 2)}\n`; - if (existingConfigPresent && baseSerialized === finalSerialized) { - return false; - } - - const resolvedConnection = mergeConnectionConfig(baseConfig.connection, overrides.connection); - if (resolvedConnection) { - finalConfig.connection = resolvedConnection; - } else { - delete finalConfig.connection; - } - - writeFileSync(existingPath, `${JSON.stringify(finalConfig, null, 2)}\n`, 'utf8'); - return true; -} - -/** - * Normalizes a raw connection object into the typed connection configuration. - * @param rawConnection - Value read from ztd.config.json that may describe a connection. - * @returns A typed connection config or undefined when the input is invalid. - */ -function normalizeConnectionConfig(rawConnection: unknown): ZtdConnectionConfig | undefined { - if (typeof rawConnection !== 'object' || rawConnection === null) { - return undefined; - } - const rawRecord = rawConnection as Record; - const connection: ZtdConnectionConfig = {}; - const url = typeof rawRecord.url === 'string' ? rawRecord.url.trim() : undefined; - if (url) { - connection.url = url; - } - - const host = typeof rawRecord.host === 'string' ? rawRecord.host.trim() : undefined; - if (host) { - connection.host = host; - } - - const user = typeof rawRecord.user === 'string' ? rawRecord.user.trim() : undefined; - if (user) { - connection.user = user; - } - - const password = - typeof rawRecord.password === 'string' && rawRecord.password.length > 0 ? rawRecord.password : undefined; - if (password) { - connection.password = password; - } - - const database = typeof rawRecord.database === 'string' ? rawRecord.database.trim() : undefined; - if (database) { - connection.database = database; - } - - const portValue = rawRecord.port; - const port = typeof portValue === 'number' - ? portValue - : typeof portValue === 'string' - ? Number(portValue) - : undefined; - if (port && Number.isInteger(port) && port > 0) { - connection.port = port; - } - - if (Object.keys(connection).length === 0) { - return undefined; - } - - return connection; -} - -/** - * Merges two connection config objects, preferring values from the overrides. - * @param base - Existing connection configuration. - * @param overrides - Incoming override values from CLI or other sources. - * @returns Combined configuration or undefined when nothing is specified. - */ -function mergeConnectionConfig( - base?: ZtdConnectionConfig, - overrides?: ZtdConnectionConfig -): ZtdConnectionConfig | undefined { - const merged: ZtdConnectionConfig = { - ...(base ?? {}), - ...(overrides ?? {}) - }; - - if (Object.keys(merged).length === 0) { - return undefined; - } - - return merged; -} - -function mergeProjectConfig( - baseConfig: ZtdProjectConfig, - overrides: Partial -): ZtdProjectConfig { - const defaultSchema = - typeof overrides.defaultSchema === 'string' && overrides.defaultSchema.length > 0 - ? overrides.defaultSchema - : baseConfig.defaultSchema; - const searchPath = - normalizeSchemaList(overrides.searchPath).length > 0 - ? normalizeSchemaList(overrides.searchPath) - : baseConfig.searchPath; - return { - ...baseConfig, - ...overrides, - defaultSchema, - searchPath - }; -} - -function emitLegacyConnectionConfigWarning(filePath: string): void { - if (hasWarnedLegacyConnectionConfig) { - return; - } - - hasWarnedLegacyConnectionConfig = true; - process.emitWarning( - `Legacy connection settings were found in ${filePath}. ztd-cli no longer uses ztd.config.json.connection for implicit DB resolution. Use ZTD_DB_URL for ZTD-owned workflows and pass --url or --db-* explicitly for non-ZTD targets.`, - { - code: 'ZTD_LEGACY_CONNECTION_CONFIG', - detail: 'The connection field remains readable for compatibility, but it is deprecated.' - } - ); -} diff --git a/packages/ztd-cli/templates/.editorconfig b/packages/ztd-cli/templates/.editorconfig deleted file mode 100644 index 9ad70588f..000000000 --- a/packages/ztd-cli/templates/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_style = space -indent_size = 2 -tab_width = 2 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.md] -trim_trailing_whitespace = false - -[*.sql] -indent_size = 2 diff --git a/packages/ztd-cli/templates/.env.example b/packages/ztd-cli/templates/.env.example deleted file mode 100644 index 230282bf2..000000000 --- a/packages/ztd-cli/templates/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# Copy this file to .env and adjust ZTD_DB_PORT if 5432 is already in use. -# The generated Vitest setup derives ZTD_DB_URL from ZTD_DB_PORT. -ZTD_DB_PORT=5432 diff --git a/packages/ztd-cli/templates/.prettierignore b/packages/ztd-cli/templates/.prettierignore deleted file mode 100644 index 5967a8e57..000000000 --- a/packages/ztd-cli/templates/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -.ztd/generated/** - diff --git a/packages/ztd-cli/templates/.prettierrc b/packages/ztd-cli/templates/.prettierrc deleted file mode 100644 index 8439227e0..000000000 --- a/packages/ztd-cli/templates/.prettierrc +++ /dev/null @@ -1,24 +0,0 @@ -{ - "semi": true, - "singleQuote": true, - "trailingComma": "all", - "tabWidth": 2, - "printWidth": 100, - "bracketSpacing": true, - "arrowParens": "always", - "plugins": ["prettier-plugin-sql"], - "overrides": [ - { - "files": "**/*.sql", - "options": { - "parser": "sql" - } - }, - { - "files": "**/*.md", - "options": { - "parser": "markdown" - } - } - ] -} diff --git a/packages/ztd-cli/templates/DESIGN.md b/packages/ztd-cli/templates/DESIGN.md deleted file mode 100644 index 88ad947a0..000000000 --- a/packages/ztd-cli/templates/DESIGN.md +++ /dev/null @@ -1,17 +0,0 @@ -# ZTD Template Design Notes - -## Role and Boundaries -- Defines template-level design intent for ownership and runtime/test split. -- Clarifies where human-owned contracts stop and implementation wiring begins. - -## Non-Goals -- Replacing directory-local AGENTS contracts. -- Embedding operational command playbooks. - -## Ownership Model -- Human-owned directories preserve domain contracts and SQL intent. -- AI-assisted directories implement runtime wiring and verification logic. - -## Runtime Split -- `src/` contains runtime assets. -- `tests/` and `ztd/` contain verification and generation inputs. diff --git a/packages/ztd-cli/templates/DEV_NOTES.md b/packages/ztd-cli/templates/DEV_NOTES.md deleted file mode 100644 index 02e93d3f4..000000000 --- a/packages/ztd-cli/templates/DEV_NOTES.md +++ /dev/null @@ -1,7 +0,0 @@ -# ZTD Template Dev Notes - -## Commands -- `pnpm test` in initialized template projects. - -## Troubleshooting -- Template initialization must include runnable test configuration and at least one executable test. diff --git a/packages/ztd-cli/templates/README.md b/packages/ztd-cli/templates/README.md deleted file mode 100644 index 76eef6036..000000000 --- a/packages/ztd-cli/templates/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# Zero Table Dependency Project - -This scaffold starts from `ztd init`. - -This generated project is either: - -- standalone customer-facing output that resolves published packages, or -- local-source workspace output that resolves `rawsql-ts` packages through `file:` dependencies back to a monorepo checkout - -Check `package.json` to see which mode you are in. If you see `file:` dependencies that point back to a monorepo checkout, this is a local-source workspace and the monorepo checkout is the source of truth. - -The project is feature-first by default: - -- keep SQL, boundaries, and tests close to each feature -- use `src/features`, `src/adapters`, and `src/libraries` as the app-code roots -- keep `db/` for DDL, migration, and schema assets only -- use thin generated query execution helpers for the standard runtime-free path -- keep feature-root boundary tests under `src/features//tests/` -- keep CLI-owned generated assets under `src/features//queries//tests/generated` -- keep human/AI-owned persistent cases under `src/features//queries//tests/cases` -- keep the thin query-boundary entrypoint next to them under `src/features//queries//tests/` -- keep starter-owned shared support under `tests/support/ztd/` -- keep tool-managed fixture metadata under `.ztd/generated/` -- `ztd.config.json` controls generated metadata and runtime defaults while the feature-local tests stay next to the feature they cover - -When you add SQL-backed tests, copy `.env.example` to `.env` and adjust `ZTD_DB_PORT` if needed before running the DB-backed suites. - -If `docker compose up -d` fails with `all predefined address pools have been fully subnetted`, treat that as a Docker network-pool problem rather than a `ZTD_DB_PORT` collision. In that case, recover Docker networking first; changing `ZTD_DB_PORT` alone will not fix it. - -```bash -npx vitest run src/features/**/*.test.ts -``` - -The generated runtime manifest is the preferred input for `@rawsql-ts/testkit-postgres`; raw DDL directories remain a fallback for legacy layouts. The generated contract itself is schema metadata only (`tableDefinitions`), so test rows stay explicit. -The starter keeps `ztdRootDir`, `ddlDir`, `defaultSchema`, and `searchPath` in `ztd.config.json`. The helper reads the project defaults from one place instead of repeating them in every DB-backed test. - -`src/features//tests/` is where feature-root boundary tests live. Query-local ZTD assets live under `src/features//queries//tests/{generated,cases}` with the thin entrypoint beside them. Starter-owned shared support lives at `tests/support/ztd/`, while `.ztd/` is the tool-managed workspace for generated metadata and support files. Keep `FeatureQueryExecutor` in `src/features/_shared/`, keep the driver-neutral `SqlClient` contract in `src/libraries/sql/sql-client.ts`, and put driver or sink bindings under `src/adapters//`. -Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package; keep feature-specific validation and helpers inside the owning feature. - -When an import crosses one of the canonical roots, use the root alias instead of a depth-sensitive relative path: - -- `#features/*` for `src/features/*` -- `#libraries/*` for `src/libraries/*` -- `#adapters/*` for `src/adapters/*` -- `#tests/*` for `tests/*` - -Keep local same-root references relative when they move with the same boundary. Use the alias when the import crosses a root boundary or points at shared support. - -## Getting Started with AI - -Use this short prompt: -Choose `ztd init` or `ztd init --starter` based on whether you want the removable starter sample. - -```text -I want to build a feature-first application with @rawsql-ts/ztd-cli. -Treat the project structure as Architecture as a Framework. -Every boundary folder exposes only `boundary.ts`, and sub-boundaries repeat the same rule. -Keep handwritten SQL, query boundaries, repository code, and tests inside `src/features/`. -Treat the query boundary contract and its ZTD-backed test as one completion unit; do not stop at a property-only check. -Keep feature-boundary tests mock-based in `src/features//tests/.boundary.test.ts`. -Feature-boundary tests mock child query boundaries and verify feature validation, mapping, and orchestration. -Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane. -Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries. -Keep shared feature seams in `src/features/_shared/*`, shared verification seams in `tests/support/*`, and tool-managed files in `.ztd/*`. -Keep the driver-neutral `SqlClient` contract in `src/libraries/sql/sql-client.ts`. -Put driver or sink bindings under `src/adapters//` instead of under `db/`. -Make sure the query-boundary result executes through the DB-backed ZTD path and checks mapping and validation, not just property values. -Do not put returned columns into the input fixture; assert them only after the DB-backed result returns. -If the returned result is `null`, stop and fix the scaffold or DDL instead of weakening the success-path schema or seeding fake rows. -Before writing the success-path assertion, inspect the current SQL and query boundary. If the scaffold does not actually return the expected result shape, report that mismatch instead of inventing fixture data or schema overrides. -After the SQL and DTO edits settle, refresh the query-local test scaffold: - -- Run `ztd feature tests scaffold --feature ` to refresh `src/features//queries//tests/generated/TEST_PLAN.md`, `analysis.json`, and `generated/*`. -- Keep `src/features//queries//tests/cases/` as human/AI-owned persistent cases around the fixed app-level ZTD runner. -- Keep the thin `src/features//queries//tests/.boundary..test.ts` Vitest entrypoint. -- Use `--test-kind traditional` when the same query-boundary case shape needs physical DDL setup, fixture seeding, or optional `afterDb` post-state checks. -- If `ztd-config` has already run, use `.ztd/generated/ztd-fixture-manifest.generated.ts` as the source for `tableDefinitions` and fixture-shape hints. -- Treat `beforeDb` as a pure fixture skeleton with schema-qualified table keys. -- Treat the ZTD lane as rewritten SQL input/output coverage. It validates fixture table/column shape, evidence fields, and required `INSERT` column presence for `NOT NULL` columns without defaults when table definitions are available. -- Explicit `NULL` values for `NOT NULL` columns and simple `UNIQUE` checks are feasible ZTD preflight candidates, but they are not enforced by the current fixture/CTE rewrite lane. -- Use `--test-kind traditional` for DB-enforced fail-fast behavior today, especially `CHECK`, foreign key, exclusion, deferrable, partial/expression `UNIQUE`, collation-sensitive, or full PostgreSQL constraint semantics. -- Check machine-readable evidence (`mode`, `rewriteApplied`, `physicalSetupUsed`): ZTD evidence should show `mode=ztd` and `physicalSetupUsed=false`; traditional evidence should show `mode=traditional` and `physicalSetupUsed=true`. -- Enable SQL trace only when needed with `ZTD_SQL_TRACE=1` and optional `ZTD_SQL_TRACE_DIR`. -- When the cases are ready, run the generated `.boundary..test.ts` entrypoint with `npx vitest run`. - -## Troubleshooting - -- If a DB-backed ZTD case returns `user_id: null`, inspect the fixture manifest and rewrite path before weakening the case. -- Compare the direct database `INSERT ... RETURNING ...` result with the ZTD result to tell whether the problem is the DB, the manifest, or the rewrite path. -- If the workspace is meant to reflect a source change, verify it resolves `rawsql-ts` from the local source tree instead of a registry copy. -- Check the returned evidence in the ZTD entrypoint (`mode=ztd`, `physicalSetupUsed=false`) before debugging fixtures. -- Use the traditional entrypoint (`mode=traditional`, `physicalSetupUsed=true`) for DB-side effects and post-state assertions. -- Use the traditional entrypoint for constraint failures that are not covered by ZTD preflight; `CHECK`, foreign key, exclusion, deferrable, partial/expression `UNIQUE`, collation-sensitive, and full PostgreSQL constraint semantics belong there. -- If an AI-authored ZTD test fails, do not assume the prompt or case file is the only problem; `ztd-cli` or `rawsql-ts` can still be the source of the bug. -- A `user_id: null` symptom usually points at fixture manifest, metadata, or rewrite path trouble rather than the DB engine itself. -- When a local-source workspace should reflect a source change, verify the local `rawsql-ts` checkout is being resolved instead of a registry copy. -- Enable `ZTD_SQL_TRACE=1` only when investigating rewrite issues so normal logs stay quiet. -Do not apply migrations automatically. -``` - -The feature-first path is successful when: - -- `users` is the next feature to add -- SQL, boundary entrypoints, and tests stay feature-local -- the same vocabulary appears in the README, CLI help, and tutorial docs - -If you need the deeper change scenarios, consult the source-repository guides when you are working from the monorepo checkout; this generated workspace may not contain `docs/`. diff --git a/packages/ztd-cli/templates/db/ddl/demo.sql b/packages/ztd-cli/templates/db/ddl/demo.sql deleted file mode 100644 index 172e43a2e..000000000 --- a/packages/ztd-cli/templates/db/ddl/demo.sql +++ /dev/null @@ -1,17 +0,0 @@ -create table users ( - user_id bigint generated by default as identity primary key, - email text not null unique, - display_name text not null, - is_active boolean not null default true, - created_at timestamptz not null default current_timestamp -); - -create index idx_users_is_active - on users (is_active, user_id); - -comment on table users is 'Starter users table for the first feature.'; -comment on column users.user_id is 'Identity primary key for the user row.'; -comment on column users.email is 'Unique email address for the user.'; -comment on column users.display_name is 'Human-readable display name for the user.'; -comment on column users.is_active is 'Whether the user is active.'; -comment on column users.created_at is 'Creation timestamp for the user row.'; diff --git a/packages/ztd-cli/templates/gitignore.template b/packages/ztd-cli/templates/gitignore.template deleted file mode 100644 index 555ada50a..000000000 --- a/packages/ztd-cli/templates/gitignore.template +++ /dev/null @@ -1,10 +0,0 @@ -node_modules/ -dist/ -coverage/ -*.log - -.env -.env.* -!.env.example - -.ztd/generated/** diff --git a/packages/ztd-cli/templates/scripts/local-source-guard.mjs b/packages/ztd-cli/templates/scripts/local-source-guard.mjs deleted file mode 100644 index d63a4d544..000000000 --- a/packages/ztd-cli/templates/scripts/local-source-guard.mjs +++ /dev/null @@ -1,190 +0,0 @@ -import { createRequire } from 'node:module'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const projectRoot = process.cwd(); -const command = process.argv[2]; - -if (command !== 'test' && command !== 'typecheck' && command !== 'ztd') { - console.error('local-source guard expects "test", "typecheck", or "ztd".'); - process.exit(1); -} - -if (command === 'ztd') { - const cliEntry = path.resolve(projectRoot, '__LOCAL_SOURCE_ZTD_CLI__'); - const requestedSubcommand = process.argv.slice(3).join(' ').trim() || '(none)'; - if (!existsSync(cliEntry)) { - console.error( - [ - '[local-source guard] ztd cannot run against the local source checkout yet.', - '', - `What happened:`, - `- Requested subcommand: ${requestedSubcommand}`, - `- Project root: ${normalizePath(projectRoot)}`, - `- The local CLI entry was not found at ${normalizePath(cliEntry)}.`, - '', - 'Next steps:', - '1. Build the local CLI package (for example: pnpm --filter @rawsql-ts/ztd-cli build)', - '2. Confirm this scaffold still points at the intended rawsql-ts monorepo root', - '3. Re-run pnpm ztd ' - ].join('\n') - ); - process.exit(1); - } - - const cliArgs = process.argv.slice(3); - const result = spawnSync(process.execPath, [cliEntry, ...cliArgs], { - cwd: projectRoot, - stdio: 'inherit', - shell: false - }); - - // Surface execution failures explicitly so local-source dogfooding does not - // collapse permission, spawn, and signal problems into the same exit code. - if (result.error) { - console.error('[local-source guard] Failed to launch the local ztd CLI entry.'); - console.error(`- Message: ${result.error.message}`); - if (result.error.stack) { - console.error(result.error.stack); - } - process.exit(1); - } - - if (result.signal) { - console.error('[local-source guard] The local ztd CLI entry was terminated by signal.'); - console.error(`- Signal: ${result.signal}`); - process.exit(1); - } - - process.exit(result.status ?? 1); -} - -const workspaceRoot = findAncestorPnpmWorkspaceRoot(projectRoot); -const installCommand = workspaceRoot ? 'pnpm install --ignore-workspace' : 'pnpm install'; -const rerunCommand = command === 'test' ? 'pnpm test' : 'pnpm typecheck'; -const fallbackCommand = command === 'test' ? 'npx vitest run' : 'npx tsc --noEmit'; -const binaryName = process.platform === 'win32' - ? command === 'test' - ? 'vitest.cmd' - : 'tsc.cmd' - : command === 'test' - ? 'vitest' - : 'tsc'; -const forwardedArgs = process.argv.slice(3); -const binaryArgs = command === 'test' ? ['run', ...forwardedArgs] : ['--noEmit', ...forwardedArgs]; -const binaryPath = path.join(projectRoot, 'node_modules', '.bin', binaryName); -const packageChecks = command === 'test' - ? ['vitest/package.json'] - : ['typescript/package.json']; - -const resolutionIssues = inspectResolution(packageChecks); -if (!existsSync(binaryPath) || resolutionIssues.length > 0) { - printGuidance({ - command, - installCommand, - rerunCommand, - fallbackCommand, - workspaceRoot, - binaryPath, - resolutionIssues - }); - process.exit(1); -} - -const result = spawnSync(binaryPath, binaryArgs, { - cwd: projectRoot, - stdio: 'inherit', - shell: process.platform === 'win32' -}); -process.exit(result.status ?? 1); - -function inspectResolution(specifiers) { - const projectRequire = createRequire(path.join(projectRoot, 'package.json')); - const issues = []; - - for (const specifier of specifiers) { - try { - const resolvedPath = specifier.endsWith('/package.json') - ? resolveInstalledPackageManifest(specifier) - : projectRequire.resolve(specifier); - if (!isInsideProject(resolvedPath)) { - issues.push(`${specifier} resolved outside this scaffold: ${normalizePath(resolvedPath)}`); - } - } catch { - issues.push(`${specifier} is not installed in this scaffold`); - } - } - - return issues; -} - -function resolveInstalledPackageManifest(specifier) { - const manifestRelativePath = path.join('node_modules', ...specifier.split('/')); - const manifestPath = path.join(projectRoot, manifestRelativePath); - if (!existsSync(manifestPath)) { - throw new Error(`${specifier} manifest is missing`); - } - return manifestPath; -} - -function isInsideProject(filePath) { - const relativePath = path.relative(projectRoot, filePath); - return relativePath.length === 0 || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); -} - -function normalizePath(filePath) { - return filePath.replace(/\\/g, '/'); -} - -function findAncestorPnpmWorkspaceRoot(rootDir) { - let cursor = path.resolve(rootDir); - while (true) { - const parentDir = path.dirname(cursor); - if (parentDir === cursor) { - return null; - } - cursor = parentDir; - if (existsSync(path.join(cursor, 'pnpm-workspace.yaml'))) { - return cursor; - } - } -} - -function printGuidance({ - command, - installCommand, - rerunCommand, - fallbackCommand, - workspaceRoot, - binaryPath, - resolutionIssues -}) { - const commandLabel = command === 'test' ? 'pnpm test' : 'pnpm typecheck'; - const lines = [ - `[local-source guard] ${commandLabel} cannot run against this scaffold yet.`, - '', - 'What happened:', - `- The local binary was not found at ${normalizePath(binaryPath)} or required packages resolved outside this project.`, - ]; - - if (workspaceRoot) { - lines.push(`- This project sits under pnpm workspace ${normalizePath(workspaceRoot)}, so pnpm may still be using the parent workspace context.`); - } - - if (resolutionIssues.length > 0) { - lines.push('- Resolution details:'); - for (const issue of resolutionIssues) { - lines.push(` - ${issue}`); - } - } - - lines.push( - '', - 'Next steps:', - `1. Run ${installCommand}`, - `2. Re-run ${rerunCommand}`, - `3. If pnpm still looks absorbed by the parent workspace, try ${fallbackCommand}` - ); - console.error(lines.join('\n')); -} diff --git a/packages/ztd-cli/templates/src/adapters/README.md b/packages/ztd-cli/templates/src/adapters/README.md deleted file mode 100644 index d5f6c0897..000000000 --- a/packages/ztd-cli/templates/src/adapters/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Adapters - -Technology-specific bindings live here. - -- Put driver- or sink-specific code under `src/adapters//`. -- Keep each `` folder singular until it needs child boundaries. -- Do not place runtime clients or adapters under `db/`; reserve `db/` for DDL, migrations, and schema assets. diff --git a/packages/ztd-cli/templates/src/adapters/console/repositoryTelemetry.ts b/packages/ztd-cli/templates/src/adapters/console/repositoryTelemetry.ts deleted file mode 100644 index a0d8dd8c9..000000000 --- a/packages/ztd-cli/templates/src/adapters/console/repositoryTelemetry.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { - RepositoryTelemetry, - RepositoryTelemetryConsoleOptions, - RepositoryTelemetryEvent, -} from '#libraries/telemetry/types.js'; - -/** - * Create a conservative console-backed telemetry hook for repositories. - * - * The emitted payload stays on the safe side of the boundary: it includes the - * runtime contract metadata, but never SQL text or bind values. - */ -export function createConsoleRepositoryTelemetry( - options: RepositoryTelemetryConsoleOptions = {}, -): RepositoryTelemetry { - const logger = options.logger ?? console; - - return { - emit(event: RepositoryTelemetryEvent): void { - const payload = serializeEvent(event); - if (event.kind === 'query.execute.error') { - logger.error('[repository-telemetry]', payload); - return; - } - - logger.info('[repository-telemetry]', payload); - }, - }; -} - -function serializeEvent( - event: RepositoryTelemetryEvent, -): Record { - const payload: Record = { - kind: event.kind, - timestamp: event.timestamp, - queryId: event.queryId, - repositoryName: event.repositoryName, - methodName: event.methodName, - paramsShape: event.paramsShape, - transformations: event.transformations - }; - - if (event.kind !== 'query.execute.start') { - payload.durationMs = event.durationMs; - } - if (event.kind === 'query.execute.success' && event.rowCount !== undefined) { - payload.rowCount = event.rowCount; - } - if (event.kind === 'query.execute.error') { - payload.errorName = event.errorName; - } - - return payload; -} diff --git a/packages/ztd-cli/templates/src/adapters/pg/sql-client.ts b/packages/ztd-cli/templates/src/adapters/pg/sql-client.ts deleted file mode 100644 index 1b3def5f5..000000000 --- a/packages/ztd-cli/templates/src/adapters/pg/sql-client.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createRowsOnlySqlClient } from '@rawsql-ts/driver-adapter-core'; -import type { SqlClient } from '#libraries/sql/sql-client.js'; - -/** - * Adapt a node-postgres `pg`-style queryable (Client or Pool) into a SqlClient. - * - * SQL resources can keep `:name` parameters for readability. The adapter compiles - * them to node-postgres `$1`, `$2`, ... placeholders immediately before execution. - * - * Usage: - * // This runtime example uses DATABASE_URL for application code. - * // ztd-cli itself does not read DATABASE_URL implicitly. - * const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - * const client = fromPg(pool); - * const users = await client.query<{ id: number }>('SELECT id ...', []); - */ -export function fromPg( - queryable: { - query(text: string, values?: readonly unknown[]): Promise<{ rows: Record[] }>; - } -): SqlClient { - return createRowsOnlySqlClient(queryable, { placeholderStyle: 'pg-indexed' }); -} diff --git a/packages/ztd-cli/templates/src/application/README.md b/packages/ztd-cli/templates/src/application/README.md deleted file mode 100644 index 110c8ec32..000000000 --- a/packages/ztd-cli/templates/src/application/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Application - -Use cases and orchestration live here. - -- Coordinate domain behavior and repository ports here. -- Avoid direct ownership of SQL, DDL, and ZTD configuration in this layer. -- When you need a repository-test example, start from `tests/queryspec.example.test.ts` and keep the application layer free of SQL. diff --git a/packages/ztd-cli/templates/src/catalog/runtime/_coercions.ts b/packages/ztd-cli/templates/src/catalog/runtime/_coercions.ts deleted file mode 100644 index 741c34f6f..000000000 --- a/packages/ztd-cli/templates/src/catalog/runtime/_coercions.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Runtime coercions run BEFORE validator schemas. -// See docs/recipes/mapping-vs-validation.md for pipeline details. -export function normalizeTimestamp(value: unknown, fieldName?: string): Date { - const fieldLabel = fieldName?.trim() ? ` for "${fieldName}"` : ''; - - // Preserve valid Date instances while rejecting invalid dates eagerly. - if (value instanceof Date) { - if (Number.isNaN(value.getTime())) { - throw new Error(`Invalid Date value${fieldLabel}.`); - } - return value; - } - - // Parse driver-returned timestamp strings after trimming transport whitespace. - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - throw new Error(`Expected a non-empty timestamp string${fieldLabel}.`); - } - - const timestamp = Date.parse(trimmed); - if (Number.isNaN(timestamp)) { - throw new Error(`Invalid timestamp string${fieldLabel}: "${value}".`); - } - return new Date(timestamp); - } - - const actualType = value === null ? 'null' : typeof value; - throw new Error(`Expected Date or timestamp string${fieldLabel}, received ${actualType}.`); -} diff --git a/packages/ztd-cli/templates/src/catalog/runtime/_smoke.runtime.ts b/packages/ztd-cli/templates/src/catalog/runtime/_smoke.runtime.ts deleted file mode 100644 index 23e234c91..000000000 --- a/packages/ztd-cli/templates/src/catalog/runtime/_smoke.runtime.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { parseSmokeOutput, type SmokeOutput } from '../specs/_smoke.spec.js'; -import { normalizeTimestamp } from './_coercions.js'; - -/** - * Validate runtime output against the catalog smoke invariant. - * - * The reusable QuerySpec-first sample lives in `tests/queryspec.example.test.ts`. - */ -export function ensureSmokeOutput(value: unknown): SmokeOutput { - // Normalize driver-dependent timestamp representations before contract validation. - if (isRecord(value) && 'createdAt' in value) { - return parseSmokeOutput({ - ...value, - createdAt: normalizeTimestamp(value.createdAt, 'createdAt') - }); - } - - return parseSmokeOutput(value); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} diff --git a/packages/ztd-cli/templates/src/catalog/specs/_smoke.spec.arktype.ts b/packages/ztd-cli/templates/src/catalog/specs/_smoke.spec.arktype.ts deleted file mode 100644 index 3f4ce8e94..000000000 --- a/packages/ztd-cli/templates/src/catalog/specs/_smoke.spec.arktype.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { type } from 'arktype'; - -/** - * Validator invariant contract used to prove the minimal onboarding gate. - * - * The reusable QuerySpec-first example lives in `tests/queryspec.example.test.ts`. - * - * This file is intentionally minimal and domain-agnostic. - */ -export const smokeOutputSchema = type({ - id: 'number.integer', - createdAt: 'Date' -}); - -export type SmokeOutput = ReturnType; - -/** - * Parse and validate an unknown runtime payload. - */ -export function parseSmokeOutput(value: unknown): SmokeOutput { - smokeOutputSchema.assert(value); - return value as SmokeOutput; -} diff --git a/packages/ztd-cli/templates/src/catalog/specs/_smoke.spec.zod.ts b/packages/ztd-cli/templates/src/catalog/specs/_smoke.spec.zod.ts deleted file mode 100644 index 79cc2e0a3..000000000 --- a/packages/ztd-cli/templates/src/catalog/specs/_smoke.spec.zod.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from 'zod'; - -/** - * Validator invariant contract used to prove the minimal onboarding gate. - * - * The reusable QuerySpec-first example lives in `tests/queryspec.example.test.ts`. - * - * This file is intentionally minimal and domain-agnostic. - */ -export const smokeOutputSchema = z.object({ - id: z.number().int(), - createdAt: z.date() -}); - -export type SmokeOutput = z.infer; - -/** - * Parse and validate an unknown runtime payload. - */ -export function parseSmokeOutput(value: unknown): SmokeOutput { - return smokeOutputSchema.parse(value); -} diff --git a/packages/ztd-cli/templates/src/db/sql-client-adapters.ts b/packages/ztd-cli/templates/src/db/sql-client-adapters.ts deleted file mode 100644 index 9e6a8a523..000000000 --- a/packages/ztd-cli/templates/src/db/sql-client-adapters.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createRowsOnlySqlClient } from '@rawsql-ts/driver-adapter-core'; -import type { SqlClient } from './sql-client.js'; - -/** - * Adapt a node-postgres `pg`-style queryable (Client or Pool) into a SqlClient. - * - * SQL resources can keep `:name` parameters for readability. The adapter compiles - * them to node-postgres `$1`, `$2`, ... placeholders immediately before execution. - * - * Usage: - * const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - * const client = fromPg(pool); - * const users = await client.query<{ id: number }>('SELECT id ...', []); - */ -export function fromPg( - queryable: { - query(text: string, values?: readonly unknown[]): Promise<{ rows: Record[] }>; - } -): SqlClient { - return createRowsOnlySqlClient(queryable, { placeholderStyle: 'pg-indexed' }); -} diff --git a/packages/ztd-cli/templates/src/db/sql-client.ts b/packages/ztd-cli/templates/src/db/sql-client.ts deleted file mode 100644 index 09486cf45..000000000 --- a/packages/ztd-cli/templates/src/db/sql-client.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type { - SqlClient, - SqlNamedParams, - SqlQueryParameters, - SqlQueryRows, -} from '@rawsql-ts/driver-adapter-core'; - -/** - * Minimal SQL client interface required by the repository layer. - * - * - Production: adapt this interface to your preferred driver (node-postgres, mysql2, etc.) and normalize the results to `T[]`. - * - SQL files may keep `:name` parameters for readability; driver adapters compile them to the placeholder style required by the driver. - * - Tests: replace the implementation with a mock, a fixture helper, or an adapter that follows this contract. - * - * Connection strategy note: - * - Prefer one live client per DB context or worker process for better performance. - * - Multiple clients can coexist in the same workflow as long as each one owns its own lifecycle. - * - Do not share a live client across parallel workers without proper synchronization. - */ diff --git a/packages/ztd-cli/templates/src/domain/README.md b/packages/ztd-cli/templates/src/domain/README.md deleted file mode 100644 index 6a2aa5a65..000000000 --- a/packages/ztd-cli/templates/src/domain/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Domain - -Domain types, invariants, and business rules live here. - -- Do not import SQL files, QuerySpecs, or ZTD-generated artifacts into this layer. -- Prefer ports or plain interfaces for dependencies on application or infrastructure code. -- If you need a persistence example, follow `tests/queryspec.example.test.ts` instead of adding SQL here. diff --git a/packages/ztd-cli/templates/src/features/README.md b/packages/ztd-cli/templates/src/features/README.md deleted file mode 100644 index 493a2a6d3..000000000 --- a/packages/ztd-cli/templates/src/features/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Feature-First Layout - -This scaffold organizes application work under `src/features//`. - -## Architecture as a Framework - -The feature layout treats architecture as a framework contract, not a naming convention: - -```text -boundary/ - boundary.ts - child-boundary/ - tests/ -``` - -- A folder is a boundary. -- `boundary.ts` is that boundary's public surface. -- Child boundaries are child folders that repeat the same rule. -- `tests/` is the verification group owned by that boundary. -- Cross-boundary tests should go through `boundary.ts`, not internal helper files. - -## Default shape - -- `boundary.ts`: the single feature boundary public surface for request parsing, normalization, and response shaping -- `queries//boundary.ts`: the single query boundary public surface for DB-facing SQL execution and row/result mapping -- `tests`: the feature-local verification group, including a thin `tests/.boundary.test.ts` Vitest entrypoint for the mock-based lane -- `queries//tests`: the query-local verification group, including a thin `queries//tests/.boundary.ztd.test.ts` Vitest entrypoint for the ZTD lane -- add more child boundaries as child folders when one boundary grows; each child repeats the same `boundary.ts` plus `tests/` rule - -`ztd.config.json` owns the tool-managed workspace under `.ztd/generated/` and `.ztd/tests/` support files. Feature-authored boundary tests stay under `src/features//tests/`, while query-local ZTD assets stay under `src/features//queries//tests/{generated,cases}`. -Use `src/features/_shared/*` only for feature-facing shared seams such as `FeatureQueryExecutor`. -Keep driver-neutral helpers in `src/libraries/*`, driver or sink bindings in `src/adapters//*`, and keep `db/` reserved for DDL, migrations, and schema assets. -Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package; keep feature-specific validation and helpers inside the owning feature. - -Feature-boundary tests mock child query boundaries and verify feature validation, mapping, and orchestration. -Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane. -Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries. - -Use `ztd feature tests scaffold --feature ` after SQL and DTO edits to refresh `src/features//queries//tests/generated/TEST_PLAN.md` and `analysis.json`, keep the thin `src/features//queries//tests/.boundary.ztd.test.ts` entrypoint in sync, and add persistent cases under `src/features//queries//tests/cases/` with the fixed app-level ZTD runner. -When you are on the boundary lane, treat it as query-local: `src/features//queries//tests/.boundary.ztd.test.ts`, `src/features//queries//tests/generated/`, and `src/features//queries//tests/cases/` move together, while the feature-root `src/features//tests/.boundary.test.ts` stays on the mock-based lane. - -## Import Paths - -Prefer stability at recursive boundary seams over one blanket import style. - -- Keep local, nearby references relative when they naturally move with the same boundary. -- Stabilize only shared references that are likely to break when a boundary is split and moved deeper, such as `src/features/_shared/*` or `tests/support/*`. -- One workable tactic is package `imports` such as `#features/*` and `#tests/*`, or an equivalent alias that works in both TypeScript and runtime resolution. -- Minimum rule: do not let deep relative imports become the public boundary contract. -- When a boundary depends on another boundary, make the dependency obvious by importing its compiled ESM entrypoint with `.js` specifiers, such as `./boundary.js` or `../boundary.js`, rather than walking through internal files. -- Pragmatic exception: designated shared seams such as `src/features/_shared/*` and `tests/support/*` may use stabilized root-level aliases because those files are shared support seams, not another boundary's private implementation. - -## Sample feature - -If you enabled the starter flow, `smoke` is the removable teaching feature. -Copy its shape for the first real feature, then delete it once the project has a real slice of its own. -In the starter flow, `smoke` also shows the DB-backed path through `@rawsql-ts/testkit-postgres` and the preferred named-parameter SQL style through its feature-local SQL sample. - diff --git a/packages/ztd-cli/templates/src/features/_shared/featureQueryExecutor.ts b/packages/ztd-cli/templates/src/features/_shared/featureQueryExecutor.ts deleted file mode 100644 index 4d1113eaf..000000000 --- a/packages/ztd-cli/templates/src/features/_shared/featureQueryExecutor.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Shared runtime contract for scaffolded features. -// Inject your DB execution implementation at this seam from the application runtime. -export interface FeatureQueryExecutor { - query(sql: string, params: Record): Promise; -} diff --git a/packages/ztd-cli/templates/src/features/_shared/loadSqlResource.ts b/packages/ztd-cli/templates/src/features/_shared/loadSqlResource.ts deleted file mode 100644 index d9915af2c..000000000 --- a/packages/ztd-cli/templates/src/features/_shared/loadSqlResource.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -export function loadSqlResource(currentDir: string, relativePath: string): string { - return readFileSync(path.join(currentDir, relativePath), 'utf8'); -} diff --git a/packages/ztd-cli/templates/src/features/smoke/README.md b/packages/ztd-cli/templates/src/features/smoke/README.md deleted file mode 100644 index 727d03b16..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Smoke Feature - -`smoke` is the starter-only sample feature in the scaffold. -It lives at `src/features/smoke` and is safe to delete once the first real feature exists. - -This feature intentionally contains three narrow paths: - -- a DB-free sample function with feature-local unit tests -- a DB-backed smoke test that uses `createStarterPostgresTestkitClient` from `.ztd/support/postgres-testkit.ts` on top of `@rawsql-ts/testkit-postgres` and checks `ZTD_DB_URL` connectivity -- a minimal named-parameter SQL example that uses `:user_id` - -Use it as a pattern for the next real feature, then remove the whole folder when the starter sample is no longer useful. If you add another DB-backed feature, reuse the same thin starter helper and keep the new fixtures near the new test. diff --git a/packages/ztd-cli/templates/src/features/smoke/boundary.ts b/packages/ztd-cli/templates/src/features/smoke/boundary.ts deleted file mode 100644 index 21910b664..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/boundary.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { FeatureQueryExecutor } from '../_shared/featureQueryExecutor.js'; -import { - executeSmokeQuerySpec, - type SmokeQueryParams, - type SmokeQueryResult -} from './queries/smoke/boundary.js'; - -export interface SmokeRequest { - user_id: number; -} - -export interface SmokeResponse { - user_id: number; - email: string; -} - -function parseRequest(raw: unknown): SmokeRequest { - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - throw new Error('SmokeRequest must be an object.'); - } - const value = (raw as Record).user_id; - if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { - throw new Error('SmokeRequest.user_id must be a positive integer.'); - } - return { user_id: value }; -} - -function toQueryParams(request: SmokeRequest): SmokeQueryParams { - return { - user_id: request.user_id - }; -} - -function fromQueryResult(result: SmokeQueryResult): SmokeResponse { - return { - user_id: result.user_id, - email: result.email - }; -} - -export async function executeSmokeEntrySpec( - executor: FeatureQueryExecutor, - rawRequest: unknown -): Promise { - const request = parseRequest(rawRequest); - const result = await executeSmokeQuerySpec(executor, toQueryParams(request)); - return fromQueryResult(result); -} diff --git a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/boundary.ts b/packages/ztd-cli/templates/src/features/smoke/queries/smoke/boundary.ts deleted file mode 100644 index 5c37104f8..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/boundary.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import type { FeatureQueryExecutor } from '#features/_shared/featureQueryExecutor.js'; -import { loadSqlResource } from '#features/_shared/loadSqlResource.js'; - -const smokeSqlResource = loadSqlResource(dirname(fileURLToPath(import.meta.url)), 'smoke.sql'); - -export interface SmokeQueryParams extends Record { - user_id: number; -} - -export interface SmokeQueryResult { - user_id: number; - email: string; -} - -type SmokeRow = { - user_id: number | string; - email: string; -}; - -function parseQueryParams(raw: unknown): SmokeQueryParams { - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - throw new Error('SmokeQueryParams must be an object.'); - } - const value = (raw as Record).user_id; - if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { - throw new Error('SmokeQueryParams.user_id must be a positive integer.'); - } - return { user_id: value }; -} - -function parseRow(raw: unknown): SmokeRow { - return raw as SmokeRow; -} - -function mapRowToResult(row: SmokeRow): SmokeQueryResult { - return { - user_id: Number(row.user_id), - email: row.email - }; -} - -async function loadSingleRow( - executor: FeatureQueryExecutor, - sql: string, - params: Record -): Promise { - const rows = await executor.query>(sql, params); - if (rows.length !== 1) { - throw new Error('SmokeQuerySpec expected exactly one row.'); - } - return parseRow(rows[0]); -} - -export async function executeSmokeQuerySpec( - executor: FeatureQueryExecutor, - rawParams: unknown -): Promise { - const params = parseQueryParams(rawParams); - const row = await loadSingleRow(executor, smokeSqlResource, params); - return mapRowToResult(row); -} diff --git a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/smoke.sql b/packages/ztd-cli/templates/src/features/smoke/queries/smoke/smoke.sql deleted file mode 100644 index d104999fb..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/smoke.sql +++ /dev/null @@ -1,5 +0,0 @@ -select - user_id, - email -from users -where user_id = :user_id::integer; diff --git a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/boundary-ztd-types.ts b/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/boundary-ztd-types.ts deleted file mode 100644 index cb9f31c25..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/boundary-ztd-types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { QuerySpecZtdCase } from '#tests/support/ztd/case-types.js'; -import type { SmokeQueryParams, SmokeQueryResult } from '../boundary.js'; - -export type SmokeBeforeDb = { - public: { - users: readonly { - user_id?: unknown; - email?: unknown; - display_name?: unknown; - is_active?: unknown; - created_at?: unknown; - }[]; - }; -}; - -export type SmokeInput = SmokeQueryParams; -export type SmokeOutput = SmokeQueryResult; - -export type SmokeQueryBoundaryZtdCase = QuerySpecZtdCase; diff --git a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/cases/basic.case.ts b/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/cases/basic.case.ts deleted file mode 100644 index 5fc9f2c2b..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/cases/basic.case.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SmokeQueryBoundaryZtdCase } from '../boundary-ztd-types.js'; - -const cases: readonly SmokeQueryBoundaryZtdCase[] = [ - { - name: 'selects the starter users row by id', - beforeDb: { - public: { - users: [ - { - user_id: 1, - email: 'alice@example.com', - display_name: 'Alice', - is_active: true, - created_at: '2024-01-01T00:00:00.000Z' - } - ] - } - }, - input: { user_id: 1 }, - output: { user_id: 1, email: 'alice@example.com' } - } -]; - -export default cases; diff --git a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/TEST_PLAN.md b/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/TEST_PLAN.md deleted file mode 100644 index c2c20a0e2..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/TEST_PLAN.md +++ /dev/null @@ -1,55 +0,0 @@ -# smoke / smoke boundary test plan - -This file snapshots the current scaffold contract before AI adds case files. - -## Contract Snapshot - -- schemaVersion: 1 -- featureId: smoke -- testKind: ztd -- resultCardinality: one -- fixedVerifier: tests/support/ztd/harness.ts -- vitestEntrypoint: src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts -- generatedDir: src/features/smoke/queries/smoke/tests/generated -- casesDir: src/features/smoke/queries/smoke/tests/cases -- analysisJson: src/features/smoke/queries/smoke/tests/generated/analysis.json - -## Source Files - -- src/features/smoke/boundary.ts -- src/features/smoke/queries/smoke/boundary.ts -- src/features/smoke/queries/smoke/smoke.sql -- src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts - -## Fixture Candidate Tables - -- public.users - -## Validation Scenario Hints - -- Keep feature-boundary validation separate from the DB-backed execution boundary. -- Validation failures belong in the feature-root mock test lane. -- Required request fields in feature boundary: `user_id`. - -## DB Scenario Hints - -- Use the fixed app-level harness and query-local cases to keep the ZTD path thin. -- Keep db/input/output visible in the case file so the AI can fill the query contract without re-deriving the scaffold. -- Read from `public.users` by `user_id` so the smoke query proves connectivity and schema wiring. - -## Constraint Coverage Boundary - -- ZTD currently verifies rewritten SQL input/output, fixture table/column shape, evidence fields, and required INSERT column presence for NOT NULL columns without defaults when table definitions are available. -- Explicit NULL values for NOT NULL columns and simple UNIQUE checks are feasible ZTD preflight candidates, but they are not enforced by the current fixture/CTE rewrite lane. -- Use a traditional physical DB lane for DB-enforced fail-fast behavior today, especially CHECK, foreign key, exclusion, deferrable, partial/expression UNIQUE, collation-sensitive, or full PostgreSQL constraint semantics. - -## Unsupported Constraint Follow-up - -- TODO: public.users has NOT NULL constraint coverage that is not fully enforced by the ZTD lane; add or run a traditional physical DB case for DB-enforced failure behavior. -- TODO: public.users has UNIQUE constraint coverage that is not fully enforced by the ZTD lane; add or run a traditional physical DB case for DB-enforced failure behavior. - -## Ownership - -- Generated files live under src/features/smoke/queries/smoke/tests/generated. -- AI-authored case files live under src/features/smoke/queries/smoke/tests/cases. -- Do not edit generated files by hand unless you are intentionally repairing them with --force. diff --git a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/analysis.json b/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/analysis.json deleted file mode 100644 index a9d4d9c0d..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/analysis.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "schemaVersion": 1, - "featureId": "smoke", - "testKind": "ztd", - "fixtureCandidateTables": [ - "public.users" - ], - "validationScenarioHints": [ - "Keep spec validation separate from the DB-backed execution boundary.", - "Validation failures belong in the feature-root mock test lane.", - "Required request fields in spec: `user_id`." - ], - "dbScenarioHints": [ - "Use the fixed app-level harness and query-local cases to keep the ZTD path thin.", - "Keep db/input/output visible in the case file so the AI can fill the query contract without re-deriving the scaffold.", - "Read from `public.users` by `user_id` so the smoke query proves connectivity and schema wiring." - ], - "constraintCoverageNotes": [ - "ZTD currently verifies rewritten SQL input/output, fixture table/column shape, evidence fields, and required INSERT column presence for NOT NULL columns without defaults when table definitions are available.", - "Explicit NULL values for NOT NULL columns and simple UNIQUE checks are feasible ZTD preflight candidates, but they are not enforced by the current fixture/CTE rewrite lane.", - "Use a traditional physical DB lane for DB-enforced fail-fast behavior today, especially CHECK, foreign key, exclusion, deferrable, partial/expression UNIQUE, collation-sensitive, or full PostgreSQL constraint semantics." - ], - "unsupportedConstraintGuidance": [ - { - "table": "public.users", - "constraint": "not-null", - "sourcePath": "db/ddl/demo.sql", - "recommendation": "ZTD catches missing required INSERT columns, but explicit NULL or parameter-NULL failures need a traditional physical DB lane today.", - "todo": "public.users has NOT NULL constraint coverage that is not fully enforced by the ZTD lane; add or run a traditional physical DB case for DB-enforced failure behavior." - }, - { - "table": "public.users", - "constraint": "unique", - "sourcePath": "db/ddl/demo.sql", - "recommendation": "Simple UNIQUE preflight is feasible, but current ZTD fixture/CTE execution does not enforce UNIQUE violations.", - "todo": "public.users has UNIQUE constraint coverage that is not fully enforced by the ZTD lane; add or run a traditional physical DB case for DB-enforced failure behavior." - } - ], - "resultCardinality": "one" -} diff --git a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts b/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts deleted file mode 100644 index 3b3ee4190..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from 'vitest'; - -import { runQuerySpecZtdCases } from '#tests/support/ztd/harness.js'; -import { executeSmokeQuerySpec } from '../boundary.js'; -import cases from './cases/basic.case.js'; - -test('smoke/smoke boundary ZTD cases run through the fixed app-level harness', async () => { - expect(cases.length).toBeGreaterThan(0); - const evidence = await runQuerySpecZtdCases(cases, executeSmokeQuerySpec); - expect(evidence.every((entry) => entry.mode === 'ztd')).toBe(true); - expect(evidence.every((entry) => entry.physicalSetupUsed === false)).toBe(true); -}); diff --git a/packages/ztd-cli/templates/src/features/smoke/tests/README.md b/packages/ztd-cli/templates/src/features/smoke/tests/README.md deleted file mode 100644 index cf761c4b0..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/tests/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Smoke Tests - -This folder contains the feature-local verification group for the removable `smoke` sample. - -- `smoke.boundary.test.ts` and `smoke.validation.test.ts` stay DB-free and exercise the feature boundary without touching the database. -- `src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts` uses the fixed app-level ZTD harness from `tests/support/ztd/harness.ts`, requires `ZTD_DB_URL`, and proves the starter DB-backed path. -- The fixed app-level ZTD runner lives in `tests/support/ztd/harness.ts`; query-local cases should live in `tests/cases/` and call into that runner. -- Real feature scaffolds also add a thin `.boundary.ztd.test.ts` Vitest entrypoint next to the query-local tests, plus a feature-root `.boundary.test.ts` for the mock-based lane. -- The starter setup loads `.env` through `tests/support/setup-env.ts` and derives `ZTD_DB_URL` from `ZTD_DB_PORT`. -- The starter defaults for `ztdRootDir`, `ddlDir`, `defaultSchema`, and `searchPath` live in `ztd.config.json`; the helper reads them so follow-up DB-backed tests can reuse the same setup. -- Expect the DB-backed path to fail until the starter database is configured. - diff --git a/packages/ztd-cli/templates/src/features/smoke/tests/smoke.boundary.test.ts b/packages/ztd-cli/templates/src/features/smoke/tests/smoke.boundary.test.ts deleted file mode 100644 index 474ba55a3..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/tests/smoke.boundary.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from 'vitest'; - -import type { FeatureQueryExecutor } from '../../_shared/featureQueryExecutor.js'; -import { executeSmokeEntrySpec } from '../boundary.js'; - -function createGuardedExecutor(): FeatureQueryExecutor { - return { - async query() { - throw new Error('Feature boundary tests stay mock-based for smoke; keep DB-backed execution in the query lane.'); - } - }; -} - -function createMockExecutor(rows: readonly Record[]): FeatureQueryExecutor { - return { - async query() { - return [...rows] as T[]; - } - }; -} - -test('maps the starter smoke request through the feature boundary', async () => { - await expect( - executeSmokeEntrySpec(createMockExecutor([{ user_id: 1, email: 'alice@example.com' }]), { - user_id: 1 - }) - ).resolves.toEqual({ user_id: 1, email: 'alice@example.com' }); -}); - -test('rejects invalid feature input at the feature boundary for smoke', async () => { - await expect(executeSmokeEntrySpec(createGuardedExecutor(), {})).rejects.toThrow( - /user_id|required|invalid/i - ); -}); diff --git a/packages/ztd-cli/templates/src/features/smoke/tests/smoke.validation.test.ts b/packages/ztd-cli/templates/src/features/smoke/tests/smoke.validation.test.ts deleted file mode 100644 index 0fbbf622a..000000000 --- a/packages/ztd-cli/templates/src/features/smoke/tests/smoke.validation.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, test } from 'vitest'; - -import type { FeatureQueryExecutor } from '../../_shared/featureQueryExecutor.js'; -import { executeSmokeEntrySpec } from '../boundary.js'; - -function createGuardedExecutor(): FeatureQueryExecutor { - return { - async query() { - throw new Error('Validation should reject before the query lane runs.'); - } - }; -} - -test('rejects zero user_id values at the feature boundary', async () => { - await expect(executeSmokeEntrySpec(createGuardedExecutor(), { user_id: 0 })).rejects.toThrow( - /user_id|positive|invalid/i - ); -}); - -test('rejects non-object requests at the feature boundary', async () => { - await expect(executeSmokeEntrySpec(createGuardedExecutor(), null)).rejects.toThrow( - /object|user_id|invalid/i - ); -}); diff --git a/packages/ztd-cli/templates/src/infrastructure/README.md b/packages/ztd-cli/templates/src/infrastructure/README.md deleted file mode 100644 index 5769e4bc9..000000000 --- a/packages/ztd-cli/templates/src/infrastructure/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Infrastructure - -Infrastructure adapters live here. - -- Database, telemetry, and persistence integration belong here. -- Keep domain and application contracts stable while swapping implementations. -- The default repository telemetry seam is no-op, so you can leave it wired but silent until you opt in to a sink. -- Use `queryId` as the stable lookup key and treat `repositoryName` and `methodName` as human-readable hints. -- Use `tests/queryspec.example.test.ts` as the first repository-oriented sample when you add a persistence seam. diff --git a/packages/ztd-cli/templates/src/infrastructure/db/sql-client-adapters.ts b/packages/ztd-cli/templates/src/infrastructure/db/sql-client-adapters.ts deleted file mode 100644 index b8ac606e1..000000000 --- a/packages/ztd-cli/templates/src/infrastructure/db/sql-client-adapters.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createRowsOnlySqlClient } from '@rawsql-ts/driver-adapter-core'; -import type { SqlClient } from './sql-client.js'; - -/** - * Adapt a node-postgres `pg`-style queryable (Client or Pool) into a SqlClient. - * - * SQL resources can keep `:name` parameters for readability. The adapter compiles - * them to node-postgres `$1`, `$2`, ... placeholders immediately before execution. - * - * Usage: - * // This runtime example uses DATABASE_URL for application code. - * // ztd-cli itself does not read DATABASE_URL implicitly. - * const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - * const client = fromPg(pool); - * const users = await client.query<{ id: number }>('SELECT id ...', []); - */ -export function fromPg( - queryable: { - query(text: string, values?: readonly unknown[]): Promise<{ rows: Record[] }>; - } -): SqlClient { - return createRowsOnlySqlClient(queryable, { placeholderStyle: 'pg-indexed' }); -} diff --git a/packages/ztd-cli/templates/src/infrastructure/db/sql-client.ts b/packages/ztd-cli/templates/src/infrastructure/db/sql-client.ts deleted file mode 100644 index 0116e5468..000000000 --- a/packages/ztd-cli/templates/src/infrastructure/db/sql-client.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type { - SqlClient, - SqlNamedParams, - SqlQueryParameters, - SqlQueryRows, -} from '@rawsql-ts/driver-adapter-core'; - -/** - * Minimal SQL client interface required by the persistence layer. - * - * - Production: adapt this interface to your preferred driver (node-postgres, mysql2, etc.) and normalize the results to `T[]`. - * - SQL files may keep `:name` parameters for readability; driver adapters compile them to the placeholder style required by the driver. - * - Tests: replace the implementation with a mock, a fixture helper, or an adapter that follows this contract. - * - * Connection strategy note: - * - Prefer one live client per DB context or worker process for better performance. - * - Multiple clients can coexist in the same workflow as long as each one owns its own lifecycle. - * - Do not share a live client across parallel workers without proper synchronization. - */ diff --git a/packages/ztd-cli/templates/src/infrastructure/persistence/README.md b/packages/ztd-cli/templates/src/infrastructure/persistence/README.md deleted file mode 100644 index 78395a404..000000000 --- a/packages/ztd-cli/templates/src/infrastructure/persistence/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Persistence Infrastructure - -This is the primary ZTD-aware layer. - -- Store SQL assets in `src/sql`. -- Place QuerySpecs in `src/catalog/specs`. -- Maintain runtime mapping helpers in `src/catalog/runtime`. -- Keep DDL in `db/ddl`. -- Start the first repository test from `tests/queryspec.example.test.ts` so the SQL, QuerySpec, and ZTD rewrite sample stay aligned. diff --git a/packages/ztd-cli/templates/src/infrastructure/telemetry/consoleRepositoryTelemetry.ts b/packages/ztd-cli/templates/src/infrastructure/telemetry/consoleRepositoryTelemetry.ts deleted file mode 100644 index 65f6af883..000000000 --- a/packages/ztd-cli/templates/src/infrastructure/telemetry/consoleRepositoryTelemetry.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { - RepositoryTelemetry, - RepositoryTelemetryConsoleOptions, - RepositoryTelemetryEvent, -} from './types.js'; - -/** - * Create a conservative console-backed telemetry hook for repositories. - * - * The emitted payload stays on the safe side of the boundary: it includes the - * runtime contract metadata, but never SQL text or bind values. - */ -export function createConsoleRepositoryTelemetry( - options: RepositoryTelemetryConsoleOptions = {}, -): RepositoryTelemetry { - const logger = options.logger ?? console; - - return { - emit(event: RepositoryTelemetryEvent): void { - const payload = serializeEvent(event); - if (event.kind === 'query.execute.error') { - logger.error('[repository-telemetry]', payload); - return; - } - - logger.info('[repository-telemetry]', payload); - }, - }; -} - -function serializeEvent( - event: RepositoryTelemetryEvent, -): Record { - const payload: Record = { - kind: event.kind, - timestamp: event.timestamp, - queryId: event.queryId, - repositoryName: event.repositoryName, - methodName: event.methodName, - paramsShape: event.paramsShape, - transformations: event.transformations - }; - - if (event.kind !== 'query.execute.start') { - payload.durationMs = event.durationMs; - } - if (event.kind === 'query.execute.success' && event.rowCount !== undefined) { - payload.rowCount = event.rowCount; - } - if (event.kind === 'query.execute.error') { - payload.errorName = event.errorName; - } - - return payload; -} diff --git a/packages/ztd-cli/templates/src/infrastructure/telemetry/repositoryTelemetry.ts b/packages/ztd-cli/templates/src/infrastructure/telemetry/repositoryTelemetry.ts deleted file mode 100644 index d20954dcd..000000000 --- a/packages/ztd-cli/templates/src/infrastructure/telemetry/repositoryTelemetry.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { RepositoryTelemetry } from './types.js'; - -export type { - RepositoryTelemetry, - RepositoryTelemetryBooleanValue, - RepositoryTelemetryConsoleOptions, - RepositoryTelemetryArrayLength, - RepositoryTelemetryContext, - RepositoryTelemetryEvent, - RepositoryTelemetryEventKind, - RepositoryTelemetryNullability, - RepositoryTelemetryOptionalPredicatePruning, - RepositoryTelemetryPagingTransformation, - RepositoryTelemetryParameterKind, - RepositoryTelemetryParameterShape, - RepositoryTelemetryPresence, - RepositoryTelemetryPipelineTransformation, - RepositoryTelemetrySortTransformation, - RepositoryTelemetryTransformations, -} from './types.js'; - -export { createConsoleRepositoryTelemetry } from './consoleRepositoryTelemetry.js'; - -/** - * Create a repository telemetry hook that intentionally does nothing. - * - * The starter scaffold keeps this as the default so applications opt in to - * console, pino, OpenTelemetry, or custom sinks explicitly. - */ -export function createNoopRepositoryTelemetry(): RepositoryTelemetry { - return { - emit(): void { - return; - } - }; -} - -export const defaultRepositoryTelemetry = createNoopRepositoryTelemetry(); - -/** - * Resolve the repository telemetry hook that application code wants to use. - * - * Repository constructors can accept an optional telemetry dependency and call - * this helper so the default no-op hook works without extra setup. - */ -export function resolveRepositoryTelemetry( - telemetry?: RepositoryTelemetry, -): RepositoryTelemetry { - return telemetry ?? defaultRepositoryTelemetry; -} diff --git a/packages/ztd-cli/templates/src/infrastructure/telemetry/types.ts b/packages/ztd-cli/templates/src/infrastructure/telemetry/types.ts deleted file mode 100644 index b4aa57820..000000000 --- a/packages/ztd-cli/templates/src/infrastructure/telemetry/types.ts +++ /dev/null @@ -1,164 +0,0 @@ -export type RepositoryTelemetryEventKind = - | 'query.execute.start' - | 'query.execute.success' - | 'query.execute.error'; - -export type RepositoryTelemetryPresence = 'present' | 'absent'; -export type RepositoryTelemetryParameterKind = 'scalar' | 'array' | 'object' | 'unknown'; -export type RepositoryTelemetryNullability = 'null' | 'non-null' | 'mixed' | 'unknown'; -export type RepositoryTelemetryArrayLength = 'empty' | 'single' | 'few' | 'many' | 'unknown'; -export type RepositoryTelemetryBooleanValue = 'true' | 'false'; - -type RepositoryTelemetryScalarParameterShape = { - name: string; - presence: 'present'; - kind: 'scalar'; - isNull: false; - nullability: 'non-null'; - isEmptyString?: boolean; - booleanValue?: RepositoryTelemetryBooleanValue; - arrayLength?: never; - isEmptyArray?: never; - operator?: string; -}; - -type RepositoryTelemetryArrayParameterShape = - | { - name: string; - presence: 'present'; - kind: 'array'; - isNull: false; - nullability: Exclude; - arrayLength: RepositoryTelemetryArrayLength; - isEmptyArray: boolean; - isEmptyString?: never; - booleanValue?: never; - operator?: string; - } - | { - name: string; - presence: 'present'; - kind: 'array'; - isNull: true; - nullability: 'null'; - arrayLength?: never; - isEmptyArray?: never; - isEmptyString?: never; - booleanValue?: never; - operator?: string; - }; - -type RepositoryTelemetryNullParameterShape = { - name: string; - presence: 'present'; - kind: 'scalar' | 'object' | 'unknown'; - isNull: true; - nullability: 'null'; - arrayLength?: never; - isEmptyArray?: never; - isEmptyString?: never; - booleanValue?: never; - operator?: string; -}; - -type RepositoryTelemetryUnknownParameterShape = - | { - name: string; - presence: 'absent'; - kind: 'unknown'; - isNull: false; - nullability: 'unknown'; - arrayLength?: never; - isEmptyArray?: never; - isEmptyString?: never; - booleanValue?: never; - operator?: string; - } - | { - name: string; - presence: 'present'; - kind: 'object' | 'unknown'; - isNull: false; - nullability: Exclude; - arrayLength?: never; - isEmptyArray?: never; - isEmptyString?: never; - booleanValue?: never; - operator?: string; - }; - -export type RepositoryTelemetryParameterShape = - | RepositoryTelemetryScalarParameterShape - | RepositoryTelemetryArrayParameterShape - | RepositoryTelemetryNullParameterShape - | RepositoryTelemetryUnknownParameterShape; - -export interface RepositoryTelemetryOptionalPredicatePruning { - enabled: boolean; - prunedPredicateCount?: number; -} - -export interface RepositoryTelemetryPagingTransformation { - enabled: boolean; - hasLimit?: boolean; - hasOffset?: boolean; -} - -export interface RepositoryTelemetrySortTransformation { - enabled: boolean; - orderByCount?: number; -} - -export interface RepositoryTelemetryPipelineTransformation { - enabled: boolean; - stageCount?: number; -} - -export interface RepositoryTelemetryTransformations { - optionalPredicatePruning?: RepositoryTelemetryOptionalPredicatePruning; - paging?: RepositoryTelemetryPagingTransformation; - sort?: RepositoryTelemetrySortTransformation; - pipelineDecomposition?: RepositoryTelemetryPipelineTransformation; -} - -export interface RepositoryTelemetryContext { - queryId: string; - repositoryName: string; - methodName: string; - paramsShape: RepositoryTelemetryParameterShape[]; - transformations: RepositoryTelemetryTransformations; -} - -interface RepositoryTelemetryEventBase extends RepositoryTelemetryContext { - kind: RepositoryTelemetryEventKind; - timestamp: string; -} - -export interface RepositoryQueryExecuteStartEvent extends RepositoryTelemetryEventBase { - kind: 'query.execute.start'; -} - -export interface RepositoryQueryExecuteSuccessEvent extends RepositoryTelemetryEventBase { - kind: 'query.execute.success'; - durationMs: number; - rowCount?: number; -} - -export interface RepositoryQueryExecuteErrorEvent extends RepositoryTelemetryEventBase { - kind: 'query.execute.error'; - durationMs: number; - errorName: string; -} - -export type RepositoryTelemetryEvent = - | RepositoryQueryExecuteStartEvent - | RepositoryQueryExecuteSuccessEvent - | RepositoryQueryExecuteErrorEvent; - -export interface RepositoryTelemetry { - emit(event: RepositoryTelemetryEvent): void | Promise; -} - -export interface RepositoryTelemetryConsoleOptions { - logger?: Pick; -} diff --git a/packages/ztd-cli/templates/src/jobs/README.md b/packages/ztd-cli/templates/src/jobs/README.md deleted file mode 100644 index 0b15a416d..000000000 --- a/packages/ztd-cli/templates/src/jobs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Jobs - -Job runners that orchestrate repository or SQL workflows. \ No newline at end of file diff --git a/packages/ztd-cli/templates/src/libraries/README.md b/packages/ztd-cli/templates/src/libraries/README.md deleted file mode 100644 index c7691ec83..000000000 --- a/packages/ztd-cli/templates/src/libraries/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Libraries - -Shared runtime contracts and reusable helpers live here. - -- Keep driver-neutral contracts under `src/libraries/sql/` and `src/libraries/telemetry/`. -- Prefer `src/features//` first when a helper is still owned by one feature. -- Move code here only when it is driver-neutral and reusable enough to stand as an external package. -- Do not move feature-specific validation, mapping, or orchestration helpers here; keep them inside the owning feature boundary. diff --git a/packages/ztd-cli/templates/src/libraries/sql/README.md b/packages/ztd-cli/templates/src/libraries/sql/README.md deleted file mode 100644 index 855fe1e4a..000000000 --- a/packages/ztd-cli/templates/src/libraries/sql/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# SQL Library - -Keep driver-neutral SQL contracts here. - -- `sql-client.ts` defines the app-to-driver contract. -- Driver bindings belong under `src/adapters//`, not under `db/`. -- Keep handwritten `.sql` assets inside the feature that owns the workflow. diff --git a/packages/ztd-cli/templates/src/libraries/sql/sql-client.ts b/packages/ztd-cli/templates/src/libraries/sql/sql-client.ts deleted file mode 100644 index c401c0a9c..000000000 --- a/packages/ztd-cli/templates/src/libraries/sql/sql-client.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type { - SqlClient, - SqlNamedParams, - SqlQueryParameters, - SqlQueryRows, -} from '@rawsql-ts/driver-adapter-core'; - -/** - * Minimal SQL client contract shared by the app and adapter boundaries. - * - * - Production: adapt this contract to your preferred driver (node-postgres, mysql2, etc.) and normalize the results to `T[]`. - * - SQL files may keep `:name` parameters for readability; driver adapters compile them to the placeholder style required by the driver. - * - Tests: replace the implementation with a mock, a fixture helper, or an adapter that follows this contract. - * - * Connection strategy note: - * - Prefer one live client per DB context or worker process for better performance. - * - Multiple clients can coexist in the same workflow as long as each one owns its own lifecycle. - * - Do not share a live client across parallel workers without proper synchronization. - */ diff --git a/packages/ztd-cli/templates/src/libraries/telemetry/repositoryTelemetry.ts b/packages/ztd-cli/templates/src/libraries/telemetry/repositoryTelemetry.ts deleted file mode 100644 index 1cfb1b257..000000000 --- a/packages/ztd-cli/templates/src/libraries/telemetry/repositoryTelemetry.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { RepositoryTelemetry } from './types.js'; - -export type { - RepositoryTelemetry, - RepositoryTelemetryBooleanValue, - RepositoryTelemetryConsoleOptions, - RepositoryTelemetryArrayLength, - RepositoryTelemetryContext, - RepositoryTelemetryEvent, - RepositoryTelemetryEventKind, - RepositoryTelemetryNullability, - RepositoryTelemetryOptionalPredicatePruning, - RepositoryTelemetryPagingTransformation, - RepositoryTelemetryParameterKind, - RepositoryTelemetryParameterShape, - RepositoryTelemetryPresence, - RepositoryTelemetryPipelineTransformation, - RepositoryTelemetrySortTransformation, - RepositoryTelemetryTransformations, -} from './types.js'; - -/** - * Create a repository telemetry hook that intentionally does nothing. - * - * The starter scaffold keeps this as the default so applications opt in to - * console, pino, OpenTelemetry, or custom sinks explicitly. - */ -export function createNoopRepositoryTelemetry(): RepositoryTelemetry { - return { - emit(): void { - return; - } - }; -} - -export const defaultRepositoryTelemetry = createNoopRepositoryTelemetry(); - -/** - * Resolve the repository telemetry hook that application code wants to use. - * - * Repository constructors can accept an optional telemetry dependency and call - * this helper so the default no-op hook works without extra setup. - */ -export function resolveRepositoryTelemetry( - telemetry?: RepositoryTelemetry, -): RepositoryTelemetry { - return telemetry ?? defaultRepositoryTelemetry; -} diff --git a/packages/ztd-cli/templates/src/libraries/telemetry/types.ts b/packages/ztd-cli/templates/src/libraries/telemetry/types.ts deleted file mode 100644 index b4aa57820..000000000 --- a/packages/ztd-cli/templates/src/libraries/telemetry/types.ts +++ /dev/null @@ -1,164 +0,0 @@ -export type RepositoryTelemetryEventKind = - | 'query.execute.start' - | 'query.execute.success' - | 'query.execute.error'; - -export type RepositoryTelemetryPresence = 'present' | 'absent'; -export type RepositoryTelemetryParameterKind = 'scalar' | 'array' | 'object' | 'unknown'; -export type RepositoryTelemetryNullability = 'null' | 'non-null' | 'mixed' | 'unknown'; -export type RepositoryTelemetryArrayLength = 'empty' | 'single' | 'few' | 'many' | 'unknown'; -export type RepositoryTelemetryBooleanValue = 'true' | 'false'; - -type RepositoryTelemetryScalarParameterShape = { - name: string; - presence: 'present'; - kind: 'scalar'; - isNull: false; - nullability: 'non-null'; - isEmptyString?: boolean; - booleanValue?: RepositoryTelemetryBooleanValue; - arrayLength?: never; - isEmptyArray?: never; - operator?: string; -}; - -type RepositoryTelemetryArrayParameterShape = - | { - name: string; - presence: 'present'; - kind: 'array'; - isNull: false; - nullability: Exclude; - arrayLength: RepositoryTelemetryArrayLength; - isEmptyArray: boolean; - isEmptyString?: never; - booleanValue?: never; - operator?: string; - } - | { - name: string; - presence: 'present'; - kind: 'array'; - isNull: true; - nullability: 'null'; - arrayLength?: never; - isEmptyArray?: never; - isEmptyString?: never; - booleanValue?: never; - operator?: string; - }; - -type RepositoryTelemetryNullParameterShape = { - name: string; - presence: 'present'; - kind: 'scalar' | 'object' | 'unknown'; - isNull: true; - nullability: 'null'; - arrayLength?: never; - isEmptyArray?: never; - isEmptyString?: never; - booleanValue?: never; - operator?: string; -}; - -type RepositoryTelemetryUnknownParameterShape = - | { - name: string; - presence: 'absent'; - kind: 'unknown'; - isNull: false; - nullability: 'unknown'; - arrayLength?: never; - isEmptyArray?: never; - isEmptyString?: never; - booleanValue?: never; - operator?: string; - } - | { - name: string; - presence: 'present'; - kind: 'object' | 'unknown'; - isNull: false; - nullability: Exclude; - arrayLength?: never; - isEmptyArray?: never; - isEmptyString?: never; - booleanValue?: never; - operator?: string; - }; - -export type RepositoryTelemetryParameterShape = - | RepositoryTelemetryScalarParameterShape - | RepositoryTelemetryArrayParameterShape - | RepositoryTelemetryNullParameterShape - | RepositoryTelemetryUnknownParameterShape; - -export interface RepositoryTelemetryOptionalPredicatePruning { - enabled: boolean; - prunedPredicateCount?: number; -} - -export interface RepositoryTelemetryPagingTransformation { - enabled: boolean; - hasLimit?: boolean; - hasOffset?: boolean; -} - -export interface RepositoryTelemetrySortTransformation { - enabled: boolean; - orderByCount?: number; -} - -export interface RepositoryTelemetryPipelineTransformation { - enabled: boolean; - stageCount?: number; -} - -export interface RepositoryTelemetryTransformations { - optionalPredicatePruning?: RepositoryTelemetryOptionalPredicatePruning; - paging?: RepositoryTelemetryPagingTransformation; - sort?: RepositoryTelemetrySortTransformation; - pipelineDecomposition?: RepositoryTelemetryPipelineTransformation; -} - -export interface RepositoryTelemetryContext { - queryId: string; - repositoryName: string; - methodName: string; - paramsShape: RepositoryTelemetryParameterShape[]; - transformations: RepositoryTelemetryTransformations; -} - -interface RepositoryTelemetryEventBase extends RepositoryTelemetryContext { - kind: RepositoryTelemetryEventKind; - timestamp: string; -} - -export interface RepositoryQueryExecuteStartEvent extends RepositoryTelemetryEventBase { - kind: 'query.execute.start'; -} - -export interface RepositoryQueryExecuteSuccessEvent extends RepositoryTelemetryEventBase { - kind: 'query.execute.success'; - durationMs: number; - rowCount?: number; -} - -export interface RepositoryQueryExecuteErrorEvent extends RepositoryTelemetryEventBase { - kind: 'query.execute.error'; - durationMs: number; - errorName: string; -} - -export type RepositoryTelemetryEvent = - | RepositoryQueryExecuteStartEvent - | RepositoryQueryExecuteSuccessEvent - | RepositoryQueryExecuteErrorEvent; - -export interface RepositoryTelemetry { - emit(event: RepositoryTelemetryEvent): void | Promise; -} - -export interface RepositoryTelemetryConsoleOptions { - logger?: Pick; -} diff --git a/packages/ztd-cli/templates/src/presentation/http/README.md b/packages/ztd-cli/templates/src/presentation/http/README.md deleted file mode 100644 index 6497361a0..000000000 --- a/packages/ztd-cli/templates/src/presentation/http/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# HTTP Presentation - -HTTP handlers, controllers, and transport mapping live here. - -- Keep framework-specific code here. -- Delegate use-case orchestration to `src/application`. -- Keep persistence examples out of this layer and use `tests/queryspec.example.test.ts` when you need a repository-oriented sample. diff --git a/packages/ztd-cli/templates/src/sql/README.md b/packages/ztd-cli/templates/src/sql/README.md deleted file mode 100644 index e6b417d2a..000000000 --- a/packages/ztd-cli/templates/src/sql/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# SQL Files - -Store SQL statements by table or domain in subfolders. - -- Use named parameters (for example `:id`). -- Keep SQL in files; repositories should load them. -- When you turn the first SQL asset into a QuerySpec, mirror the repository-test shape shown in `tests/queryspec.example.test.ts`. diff --git a/packages/ztd-cli/templates/tests/queryspec.example.test.ts b/packages/ztd-cli/templates/tests/queryspec.example.test.ts deleted file mode 100644 index eba65ff68..000000000 --- a/packages/ztd-cli/templates/tests/queryspec.example.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { expect, test } from 'vitest'; - -type QuerySpec = { - id: string; - sqlFile: string; - params: { - shape: 'positional'; - example: TParams; - }; - output: { - mapping: { - name: string; - key: string; - columnMap: Record; - }; - validate?: (value: unknown) => TRow; - example: TRow; - }; - notes: string; -}; - -type ThinQueryExecutorOptions = { - loader: { - load(sqlFile: string): Promise; - }; - executor: (sql: string, params: readonly unknown[]) => Promise[]>; -}; - -function rowMapping(mapping: { name: string; key: string; columnMap: Record }) { - return mapping; -} - -function parseBoolean(value: unknown): boolean { - if (typeof value === 'boolean') { - return value; - } - - if (typeof value === 'number') { - if (value === 1) { - return true; - } - - if (value === 0) { - return false; - } - } - - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - - if (normalized === 'true' || normalized === '1') { - return true; - } - - if (normalized === 'false' || normalized === '0') { - return false; - } - } - - throw new Error('Invalid users.list-active is_active value'); -} - -function createThinQueryExecutor({ loader, executor }: ThinQueryExecutorOptions) { - return { - async list(spec: QuerySpec, params: TParams) { - const sql = await loader.load(spec.sqlFile); - const rows = await executor(sql, params); - return rows.map((value) => (spec.output.validate ? spec.output.validate(value) : (value as TRow))); - } - }; -} - -type UserSummaryRow = { - userId: string; - email: string; - displayName: string; - isActive: boolean; -}; - -const listActiveUsersSpec: QuerySpec<[boolean], UserSummaryRow> = { - id: 'users.list-active', - sqlFile: 'src/sql/users/list-active-users.sql', - params: { - shape: 'positional', - example: [true] as [boolean] - }, - output: { - mapping: rowMapping({ - name: 'UserSummary', - key: 'userId', - columnMap: { - userId: 'user_id', - email: 'email', - displayName: 'display_name', - isActive: 'is_active' - } - }), - validate: (value) => { - const row = value as { - user_id: unknown; - email: unknown; - display_name: unknown; - is_active: unknown; - }; - return { - userId: String(row.user_id), - email: String(row.email), - displayName: String(row.display_name), - isActive: parseBoolean(row.is_active) - }; - }, - example: { - userId: 'user-1', - email: 'alice@example.com', - displayName: 'Alice', - isActive: true - } - }, - notes: 'Use this as the sample when you add the first users QuerySpec.' -}; - -test('queryspec example keeps users SQL, rowMapping, and thin executor aligned', async () => { - const loadedSql: string[] = []; - const executedSql: Array<{ sql: string; params: readonly unknown[] }> = []; - - const executor = createThinQueryExecutor({ - loader: { - async load(sqlFile: string) { - loadedSql.push(sqlFile); - return 'select user_id, email, display_name, is_active from users where is_active = :is_active order by user_id'; - } - }, - executor: async (sql, params) => { - executedSql.push({ sql, params }); - return [ - { - user_id: 'user-1', - email: 'alice@example.com', - display_name: 'Alice', - is_active: true - } - ]; - } - }); - - const rows = await executor.list(listActiveUsersSpec, [true]); - - expect(loadedSql).toEqual(['src/sql/users/list-active-users.sql']); - expect(executedSql).toEqual([ - { - sql: 'select user_id, email, display_name, is_active from users where is_active = :is_active order by user_id', - params: [true] - } - ]); - expect(rows).toEqual([ - { - userId: 'user-1', - email: 'alice@example.com', - displayName: 'Alice', - isActive: true - } - ]); - expect( - listActiveUsersSpec.output.validate?.({ - user_id: 'user-1', - email: 'alice@example.com', - display_name: 'Alice', - is_active: true - }) - ).toEqual(listActiveUsersSpec.output.example); - expect( - listActiveUsersSpec.output.validate?.({ - user_id: 'user-1', - email: 'alice@example.com', - display_name: 'Alice', - is_active: 'false' - }) - ).toEqual({ - userId: 'user-1', - email: 'alice@example.com', - displayName: 'Alice', - isActive: false - }); - expect(() => - listActiveUsersSpec.output.validate?.({ - user_id: 'user-1', - email: 'alice@example.com', - display_name: 'Alice', - is_active: 'maybe' - }) - ).toThrow('Invalid users.list-active is_active value'); -}); diff --git a/packages/ztd-cli/templates/tests/support/global-setup.ts b/packages/ztd-cli/templates/tests/support/global-setup.ts deleted file mode 100644 index 2799861d7..000000000 --- a/packages/ztd-cli/templates/tests/support/global-setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Vitest global setup. - * - * Environment loading happens in .ztd/support/setup-env.ts. - * Keep this hook available for teams that later add SQL-backed integration setup. - */ -export default async function globalSetup() { - return () => undefined; -} diff --git a/packages/ztd-cli/templates/tests/support/postgres-testkit.ts b/packages/ztd-cli/templates/tests/support/postgres-testkit.ts deleted file mode 100644 index a51fb3291..000000000 --- a/packages/ztd-cli/templates/tests/support/postgres-testkit.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import path from 'node:path'; - -import { - createPostgresTestkitClient, - type CreatePostgresTestkitClientOptions, - type PostgresTestkitClient -} from '@rawsql-ts/testkit-postgres'; -import type { DdlFixtureLoaderOptions } from '@rawsql-ts/testkit-core'; -import { Pool } from 'pg'; - -interface StarterProjectConfigFile { - ztdRootDir?: string; - ddlDir?: string; - defaultSchema?: string; - searchPath?: string[]; -} - -export interface StarterPostgresDefaults { - projectRootDir: string; - ztdRootDir: string; - defaultSchema: string; - searchPath: string[]; - ddlDirectories: string[]; -} - -export interface StarterPostgresTestkitOptions = Record> - extends Pick, 'tableDefinitions' | 'tableRows' | 'ddl' | 'onExecute'> { - rootDir?: string; - connectionString?: string; - defaultSchema?: string; - searchPath?: string[]; -} - -function normalizeSearchPath(searchPath: unknown): string[] { - if (!Array.isArray(searchPath)) { - return []; - } - - return searchPath - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} - -function loadStarterProjectConfig(rootDir: string = process.cwd()): StarterProjectConfigFile { - const configPath = path.join(rootDir, 'ztd.config.json'); - if (!existsSync(configPath)) { - return {}; - } - - try { - return JSON.parse(readFileSync(configPath, 'utf8')) as StarterProjectConfigFile; - } catch (error) { - if (isMissingConfigFileError(error)) { - return {}; - } - throw error; - } -} - -export function loadStarterPostgresDefaults(rootDir: string = process.cwd()): StarterPostgresDefaults { - const projectConfig = loadStarterProjectConfig(rootDir); - const resolvedProjectRootDir = path.resolve(rootDir); - const resolvedZtdRootDir = path.resolve(rootDir, projectConfig.ztdRootDir ?? '.ztd'); - const configuredDefaultSchema = - typeof projectConfig.defaultSchema === 'string' ? projectConfig.defaultSchema.trim() : ''; - const defaultSchema = configuredDefaultSchema.length > 0 ? configuredDefaultSchema : 'public'; - const searchPath = normalizeSearchPath(projectConfig.searchPath); - const resolvedDdlDir = path.resolve( - resolvedProjectRootDir, - typeof projectConfig.ddlDir === 'string' && projectConfig.ddlDir.trim().length > 0 - ? projectConfig.ddlDir - : 'db/ddl' - ); - - return { - projectRootDir: resolvedProjectRootDir, - ztdRootDir: resolvedZtdRootDir, - defaultSchema, - searchPath: searchPath.length > 0 ? searchPath : [defaultSchema], - ddlDirectories: existsSync(resolvedDdlDir) ? [resolvedDdlDir] : [] - }; -} - -/** - * Create a reusable starter Postgres testkit client for DB-backed smoke tests. - * - * Call this helper once per DB context so a workflow can hold multiple clients at the same time. - * - * The helper keeps the setup defaults in one place, but leaves table definitions - * and rows next to the individual test so the sample stays readable. - */ -export function createStarterPostgresTestkitClient = Record>( - options: StarterPostgresTestkitOptions -): PostgresTestkitClient { - const connectionString = options.connectionString ?? process.env.ZTD_DB_URL; - if (!connectionString) { - throw new Error(buildStarterPostgresSetupMessage()); - } - - const defaults = loadStarterPostgresDefaults(options.rootDir); - const pool = new Pool({ connectionString }); - - return createPostgresTestkitClient({ - queryExecutor: async (sql, params) => { - try { - const result = await pool.query(sql, params as unknown[]); - return { - rows: result.rows, - rowCount: result.rowCount ?? undefined - }; - } catch (error) { - throw wrapStarterPostgresFailureIfHelpful(error, connectionString); - } - }, - defaultSchema: options.defaultSchema ?? defaults.defaultSchema, - searchPath: options.searchPath ?? defaults.searchPath, - tableDefinitions: options.tableDefinitions, - tableRows: options.tableRows, - ddl: options.ddl ?? resolveStarterDdlOptions(defaults.ddlDirectories), - onExecute: options.onExecute, - // Let the client own pool shutdown so test cleanup stays a single close() call. - disposeExecutor: async () => { - await pool.end(); - } - }); -} - -function buildStarterPostgresSetupMessage(): string { - return [ - 'ZTD_DB_URL is not set before creating a starter Postgres testkit client.', - '', - 'Next steps:', - '1. Copy `.env.example` to `.env`.', - '2. Set `ZTD_DB_PORT=5432`, or choose another free host port.', - '3. Start the starter Postgres database with `docker compose up -d`.', - '4. Rerun `npx vitest run`.', - '', - 'The generated Vitest setup derives `ZTD_DB_URL` from `ZTD_DB_PORT`.', - 'If Docker reports `all predefined address pools have been fully subnetted`, fix Docker networking first; changing `ZTD_DB_PORT` alone will not recover that error.' - ].join('\n'); -} - -function wrapStarterPostgresFailureIfHelpful(error: unknown, connectionString: string): unknown { - if (!isStarterPostgresConnectionFailure(error)) { - return error; - } - - const originalMessage = error instanceof Error ? error.message : String(error); - const wrapped = new Error( - [ - 'The starter Postgres database was not reachable while running a starter Postgres testkit query.', - '', - `Connection target: ${describeConnectionTarget(connectionString)}`, - `Original error: ${originalMessage}`, - '', - 'Next steps:', - '1. Start the bundled database with `docker compose up -d`.', - '2. If port 5432 is already in use, set another `ZTD_DB_PORT` in `.env` and rerun `docker compose up -d`.', - '3. Wait until Postgres is ready, then rerun `npx vitest run`.', - '', - 'If Docker reports `all predefined address pools have been fully subnetted`, fix Docker networking first; changing `ZTD_DB_PORT` alone will not recover that error.' - ].join('\n') - ); - (wrapped as Error & { cause?: unknown }).cause = error; - return wrapped; -} - -function isStarterPostgresConnectionFailure(error: unknown): boolean { - if (typeof error !== 'object' || error === null) { - return false; - } - - const code = 'code' in error ? String((error as { code?: unknown }).code) : ''; - if (['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', '28P01', '3D000'].includes(code)) { - return true; - } - - const message = error instanceof Error ? error.message : String(error); - return /connection terminated|connection timeout|password authentication failed|getaddrinfo|connect econnrefused/i.test(message); -} - -function describeConnectionTarget(connectionString: string): string { - try { - const url = new URL(connectionString); - return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}/${url.pathname.replace(/^\/+/, '')}`; - } catch { - return 'configured ZTD_DB_URL'; - } -} - -function resolveStarterDdlOptions(ddlDirectories: string[]): DdlFixtureLoaderOptions | undefined { - if (ddlDirectories.length === 0) { - return undefined; - } - - return { - directories: ddlDirectories - }; -} - -function isMissingConfigFileError(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'ENOENT'; -} diff --git a/packages/ztd-cli/templates/tests/support/setup-env.ts b/packages/ztd-cli/templates/tests/support/setup-env.ts deleted file mode 100644 index 24b5ad3ea..000000000 --- a/packages/ztd-cli/templates/tests/support/setup-env.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { config } from 'dotenv'; - -config(); - -if (!process.env.ZTD_DB_URL) { - const port = process.env.ZTD_DB_PORT?.trim() || '5432'; - process.env.ZTD_DB_URL = `postgres://ztd:ztd@localhost:${port}/ztd`; -} diff --git a/packages/ztd-cli/templates/tests/support/testkit-client.webapi.ts b/packages/ztd-cli/templates/tests/support/testkit-client.webapi.ts deleted file mode 100644 index e58d3dd4c..000000000 --- a/packages/ztd-cli/templates/tests/support/testkit-client.webapi.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { SqlClient } from '#libraries/sql/sql-client.js'; - -export type TestkitClient = SqlClient & { - close(): Promise; -}; - -export interface TableFixture = Record> { - tableName: string; - rows: RowShape[]; - schema?: { columns: Record }; -} - -type FixtureRow = Record; - -function normalizeIdentifier(value: string): string { - return value.trim().replace(/^"+|"+$/g, '').toLowerCase(); -} - -function resolveTableRows(fixtures: TableFixture[], tableName: string): FixtureRow[] { - const requested = normalizeIdentifier(tableName); - const fixture = fixtures.find((entry) => { - const candidate = normalizeIdentifier(entry.tableName); - return candidate === requested || candidate.endsWith(`.${requested}`); - }); - - return (fixture?.rows ?? []) as FixtureRow[]; -} - -function parseSelectQuery(sql: string): { columns: string[]; tableName: string; whereColumn?: string; whereValue?: string } { - const normalized = sql.trim().replace(/;$/, ''); - const match = normalized.match( - /^select\s+(?[\s\S]+?)\s+from\s+(?
"[^"]+"|[a-zA-Z0-9_.]+)(?:\s+where\s+(?"[^"]+"|[a-zA-Z0-9_]+)\s*=\s*(?\$\d+|'.*?'|".*?"|[a-zA-Z0-9_.-]+))?$/i - ); - - if (!match?.groups) { - throw new Error(`Unsupported testkit query: ${sql}`); - } - - const columns = match.groups.columns.split(',').map((column) => normalizeIdentifier(column)); - const tableName = normalizeIdentifier(match.groups.table); - const whereColumn = match.groups.whereColumn ? normalizeIdentifier(match.groups.whereColumn) : undefined; - const whereValue = match.groups.whereValue?.trim(); - - return { columns, tableName, whereColumn, whereValue }; -} - -function resolveWhereValue(whereValue: string | undefined, values?: readonly unknown[] | Record): unknown { - if (!whereValue) { - return undefined; - } - - if (whereValue.startsWith('$')) { - const index = Number(whereValue.slice(1)); - if (!Number.isFinite(index) || index < 1) { - throw new Error(`Unsupported parameter reference: ${whereValue}`); - } - - if (!Array.isArray(values)) { - throw new Error('Positional parameters are required for fixture-backed queries.'); - } - - return values[index - 1]; - } - - if (whereValue.startsWith("'") && whereValue.endsWith("'")) { - return whereValue.slice(1, -1).replace(/''/g, "'"); - } - - if (whereValue.startsWith('"') && whereValue.endsWith('"')) { - return whereValue.slice(1, -1); - } - - return whereValue; -} - -function projectRows(rows: FixtureRow[], columns: string[]): FixtureRow[] { - return rows.map((row) => { - const projected: FixtureRow = {}; - for (const column of columns) { - projected[column] = row[column]; - } - return projected; - }); -} - -function filterRows(rows: FixtureRow[], whereColumn: string | undefined, whereValue: unknown): FixtureRow[] { - if (!whereColumn) { - return rows; - } - - return rows.filter((row) => row[whereColumn] === whereValue); -} - -export function tableFixture>( - tableName: string, - rows: RowShape[], - schema?: { columns: Record } -): TableFixture { - return { tableName, rows, schema }; -} - -/** - * Create a reusable fixture-backed SqlClient for ZTD tests. - * - * The scaffold keeps the first test path self-contained so the sample stays runnable - * even when external fixture packages are unavailable. - */ -export async function createTestkitClient(fixtures: TableFixture[] = []): Promise { - return { - async query = Record>( - text: string, - values?: readonly unknown[] | Record - ): Promise { - if (fixtures.length === 0) { - throw new Error('Provide tableFixture() rows before executing fixture-backed tests.'); - } - - const parsed = parseSelectQuery(text); - const rows = resolveTableRows(fixtures, parsed.tableName); - const whereValue = resolveWhereValue(parsed.whereValue, values); - const matchingRows = filterRows(rows, parsed.whereColumn, whereValue); - return projectRows(matchingRows, parsed.columns) as T[]; - }, - async close() { - return; - } - }; -} diff --git a/packages/ztd-cli/templates/tests/support/ztd/README.md b/packages/ztd-cli/templates/tests/support/ztd/README.md deleted file mode 100644 index b842f13cf..000000000 --- a/packages/ztd-cli/templates/tests/support/ztd/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Query-Boundary Test Support - -This folder holds the starter-owned shared support for query-boundary ZTD and traditional cases. - -- `harness.ts` exposes the fixed app-level runners that query-local cases call. -- `verifier.ts` adapts ZTD cases to fixture rewriting and traditional cases to physical DDL + fixture setup. -- `case-types.ts` defines the small v1 case shapes. - -Query-local AI work should live in `src/features//queries//tests/cases/`. -Generated analysis belongs in `src/features//queries//tests/generated/`. -The Vitest entrypoint `src/features//queries//tests/.boundary..test.ts` should stay thin and only adapt the cases to the fixed runner. -`beforeDb` is a pure fixture skeleton with schema-qualified table keys. -The query-boundary ZTD case type carries `beforeDb`, `input`, and `output`. -The traditional case type uses the same core shape and may add `afterDb` for post-state assertions. -The verifier returns machine-checkable evidence (`mode`, `rewriteApplied`, `physicalSetupUsed`) for each case. -ZTD currently verifies rewritten SQL input/output, fixture shape, and required `INSERT` column presence for `NOT NULL` columns without defaults when table definitions are available. -Explicit `NULL` values for `NOT NULL` columns and simple `UNIQUE` checks are feasible ZTD preflight candidates, but they are not enforced by the current fixture/CTE rewrite lane. -Use the traditional physical DB lane for DB-enforced fail-fast behavior today, especially `CHECK`, foreign key, exclusion, deferrable, partial/expression `UNIQUE`, collation-sensitive, or full PostgreSQL constraint semantics. -Traditional evidence should report `mode=traditional` and `physicalSetupUsed=true`. -Enable SQL trace only when needed with `ZTD_SQL_TRACE=1` (optional `ZTD_SQL_TRACE_DIR`). -Do not use `--force` to overwrite persistent case files. diff --git a/packages/ztd-cli/templates/tests/support/ztd/case-types.ts b/packages/ztd-cli/templates/tests/support/ztd/case-types.ts deleted file mode 100644 index 664590dfe..000000000 --- a/packages/ztd-cli/templates/tests/support/ztd/case-types.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface QuerySpecCase< - BeforeDb extends Record = Record, - Input = unknown, - Output = unknown -> { - name: string; - beforeDb: BeforeDb; - input: Input; - output: Output; - afterDb?: BeforeDb; -} - -export type QuerySpecZtdCase< - BeforeDb extends Record = Record, - Input = unknown, - Output = unknown -> = Omit, 'afterDb'>; - -export type QuerySpecTraditionalCase< - BeforeDb extends Record = Record, - Input = unknown, - Output = unknown -> = QuerySpecCase; - -export type ZtdCase< - BeforeDb extends Record = Record, - Input = unknown, - Output = unknown -> = QuerySpecZtdCase; diff --git a/packages/ztd-cli/templates/tests/support/ztd/harness.ts b/packages/ztd-cli/templates/tests/support/ztd/harness.ts deleted file mode 100644 index fdf118cf2..000000000 --- a/packages/ztd-cli/templates/tests/support/ztd/harness.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { QuerySpecTraditionalCase, QuerySpecZtdCase } from './case-types.js'; -import { - verifyQuerySpecTraditionalCase, - verifyQuerySpecZtdCase, - type QuerySpecExecutionEvidence -} from './verifier.js'; - -type QuerySpecExecutor, Input, Output> = ( - client: QuerySpecExecutorClient, - input: Input -) => Promise; - -export type QuerySpecExecutorClient> = { - query(sql: string, params: Record): Promise; -}; - -export interface QuerySpecRunnerOptions { - mode?: 'ztd' | 'traditional'; -} - -/** - * Fixed runner for query-boundary ZTD cases. - * - * Keep the app-level harness stable and let query-local case files evolve. - */ -export async function runQuerySpecZtdCases, Input, Output>( - cases: readonly QuerySpecZtdCase[], - execute: QuerySpecExecutor -): Promise { - const evidence: QuerySpecExecutionEvidence[] = []; - for (const querySpecCase of cases) { - evidence.push(await verifyQuerySpecZtdCase(querySpecCase, execute)); - } - return evidence; -} - -/** - * Supported mode-switching runner for query-boundary cases. - * - * `ztd` uses fixture rewriting. `traditional` physically prepares DDL and - * fixture rows before executing the same query boundary shape. - */ -export async function runQuerySpecCases, Input, Output>( - cases: readonly (QuerySpecZtdCase | QuerySpecTraditionalCase)[], - execute: QuerySpecExecutor, - options: QuerySpecRunnerOptions = {} -): Promise { - if ((options.mode ?? 'ztd') === 'traditional') { - return runQuerySpecTraditionalCases( - cases as readonly QuerySpecTraditionalCase[], - execute - ); - } - - return runQuerySpecZtdCases(cases as readonly QuerySpecZtdCase[], execute); -} - -export async function runQuerySpecTraditionalCases, Input, Output>( - cases: readonly QuerySpecTraditionalCase[], - execute: QuerySpecExecutor -): Promise { - const evidence: QuerySpecExecutionEvidence[] = []; - for (const querySpecCase of cases) { - evidence.push(await verifyQuerySpecTraditionalCase(querySpecCase, execute)); - } - return evidence; -} - -/** - * Backward-compatible alias for older templates while the repo migrates to the queryspec-specific vocabulary. - */ -export const runZtdCases = runQuerySpecZtdCases; - -export type { QuerySpecCase, QuerySpecTraditionalCase, QuerySpecZtdCase } from './case-types.js'; -export type QuerySpecHarnessClient> = QuerySpecExecutorClient; -export type { QuerySpecExecutionEvidence } from './verifier.js'; diff --git a/packages/ztd-cli/templates/tests/support/ztd/verifier.ts b/packages/ztd-cli/templates/tests/support/ztd/verifier.ts deleted file mode 100644 index c0e3705d7..000000000 --- a/packages/ztd-cli/templates/tests/support/ztd/verifier.ts +++ /dev/null @@ -1,927 +0,0 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect } from 'vitest'; -import { Pool } from 'pg'; -import type { PoolClient } from 'pg'; - -import type { PostgresTestkitClient } from '@rawsql-ts/testkit-postgres'; -import type { QuerySpecTraditionalCase, QuerySpecZtdCase } from './case-types.js'; - -type QuerySpecExecutorClient = { - query(sql: string, params: Record): Promise; -}; - -type QuerySpecExecutor = ( - client: QuerySpecExecutorClient, - input: Input -) => Promise; - -type FixtureTree = Record; -type FixtureRow = Record; -type FixtureTableRows = Array<{ tableName: string; rows: FixtureRow[] }>; - -export type QuerySpecExecutionMode = 'ztd'; -export type QuerySpecSupportedExecutionMode = 'ztd' | 'traditional'; - -export interface QuerySpecExecutionEvidence { - mode: QuerySpecSupportedExecutionMode; - rewriteApplied: boolean; - physicalSetupUsed: boolean; - executedQueryCount: number; - traceFilePath?: string; -} - -interface PhysicalQuerySpecExecutorClient extends QuerySpecExecutorClient { - close(): Promise; - assertAfterDb(afterDb: FixtureTree): Promise; -} - -interface QueryExecutionTrace { - index: number; - originalSql: string; - boundSql: string; - boundParams: unknown[]; - executedSql?: string; - executedParams?: unknown[]; - fixturesApplied?: string[]; - rewriteApplied: boolean; -} - -interface StarterProjectConfigFile { - ztdRootDir?: string; - ddlDir?: string; - defaultSchema?: string; - searchPath?: string[]; -} - -interface StarterProjectDefaults { - projectRootDir: string; - ztdRootDir: string; - defaultSchema: string; - searchPath: string[]; - ddlDirectories: string[]; -} - -export async function verifyQuerySpecZtdCase( - querySpecCase: QuerySpecZtdCase, - execute: QuerySpecExecutor -): Promise { - const connectionString = process.env.ZTD_DB_URL; - if (!connectionString) { - throw new Error(buildStarterDbSetupMessage('query-boundary ZTD cases')); - } - - const tableRows = flattenFixtureTableRows(querySpecCase.beforeDb).map((tableFixture) => ({ - tableName: tableFixture.tableName, - rows: tableFixture.rows - })); - - const trace: QueryExecutionTrace[] = []; - const defaults = loadStarterDefaults(process.cwd()); - let pool: Pool | undefined; - let testkitClient: PostgresTestkitClient | undefined; - let failure: unknown; - - try { - pool = new Pool({ connectionString }); - const { createPostgresTestkitClient } = await import('@rawsql-ts/testkit-postgres'); - testkitClient = createPostgresTestkitClient({ - queryExecutor: async (sql, params) => { - const result = await pool!.query(sql, params as unknown[]); - return { - rows: result.rows, - rowCount: result.rowCount ?? undefined - }; - }, - defaultSchema: defaults.defaultSchema, - searchPath: defaults.searchPath, - tableRows, - ddl: defaults.ddlDirectories.length > 0 ? { directories: defaults.ddlDirectories } : undefined, - onExecute: (sql, params, fixtures) => { - const latestTrace = trace[trace.length - 1]; - if (!latestTrace) { - return; - } - - latestTrace.executedSql = sql; - latestTrace.executedParams = params; - latestTrace.fixturesApplied = fixtures; - latestTrace.rewriteApplied = - normalizeSql(latestTrace.boundSql) !== normalizeSql(sql) || (fixtures?.length ?? 0) > 0; - } - }); - - const result = await execute(createQuerySpecExecutor(testkitClient, trace), querySpecCase.input); - expect(result).toEqual(querySpecCase.output); - if (trace.length === 0) { - throw new Error( - `ZTD verifier did not execute any SQL for case "${querySpecCase.name}". Check the query boundary and fixture setup before accepting the case.` - ); - } - } catch (error) { - failure = error; - } finally { - if (testkitClient) { - await testkitClient.close(); - } - if (pool) { - await pool.end(); - } - } - - const evidence: QuerySpecExecutionEvidence = { - mode: 'ztd', - rewriteApplied: trace.some((entry) => entry.rewriteApplied), - physicalSetupUsed: false, - executedQueryCount: trace.length - }; - - const traceFilePath = writeTraceFileIfEnabled(querySpecCase.name, trace, evidence, failure); - if (traceFilePath) { - evidence.traceFilePath = traceFilePath; - } - - if (failure) { - throw wrapStarterDbFailureIfHelpful(failure, 'query-boundary ZTD cases', connectionString); - } - - return evidence; -} - -export async function verifyQuerySpecTraditionalCase( - querySpecCase: QuerySpecTraditionalCase, - execute: QuerySpecExecutor -): Promise { - const connectionString = process.env.ZTD_DB_URL; - if (!connectionString) { - throw new Error(buildStarterDbSetupMessage('query-boundary traditional cases')); - } - - const trace: QueryExecutionTrace[] = []; - const defaults = loadStarterDefaults(process.cwd()); - const pool = new Pool({ connectionString }); - let client: PhysicalQuerySpecExecutorClient | undefined; - let failure: unknown; - - try { - client = await createPhysicalQuerySpecExecutor(pool, defaults, querySpecCase.beforeDb, trace); - const result = await execute(client, querySpecCase.input); - expect(result).toEqual(querySpecCase.output); - if (querySpecCase.afterDb) { - await client.assertAfterDb(querySpecCase.afterDb); - } - if (trace.length === 0) { - throw new Error( - `Traditional verifier did not execute any SQL for case "${querySpecCase.name}". Check the query boundary and fixture setup before accepting the case.` - ); - } - } catch (error) { - failure = error; - } finally { - if (client) { - await client.close(); - } else { - await pool.end(); - } - } - - const evidence: QuerySpecExecutionEvidence = { - mode: 'traditional', - rewriteApplied: false, - physicalSetupUsed: true, - executedQueryCount: trace.length - }; - - const traceFilePath = writeTraceFileIfEnabled(querySpecCase.name, trace, evidence, failure); - if (traceFilePath) { - evidence.traceFilePath = traceFilePath; - } - - if (failure) { - throw wrapStarterDbFailureIfHelpful(failure, 'query-boundary traditional cases', connectionString); - } - - return evidence; -} - -function buildStarterDbSetupMessage(context: string): string { - return [ - `ZTD_DB_URL is not set before running ${context}.`, - '', - 'Next steps:', - '1. Copy `.env.example` to `.env`.', - '2. Set `ZTD_DB_PORT=5432`, or choose another free host port.', - '3. Start the starter Postgres database with `docker compose up -d`.', - '4. Rerun `npx vitest run`.', - '', - 'The generated Vitest setup derives `ZTD_DB_URL` from `ZTD_DB_PORT`.', - 'If Docker reports `all predefined address pools have been fully subnetted`, fix Docker networking first; changing `ZTD_DB_PORT` alone will not recover that error.' - ].join('\n'); -} - -function wrapStarterDbFailureIfHelpful(error: unknown, context: string, connectionString: string): unknown { - if (!isStarterDbConnectionFailure(error)) { - return error; - } - - const originalMessage = describeStarterDbOriginalError(error); - const wrapped = new Error( - [ - `The starter Postgres database was not reachable while running ${context}.`, - '', - `Connection target: ${describeConnectionTarget(connectionString)}`, - `Original error: ${originalMessage}`, - '', - 'Next steps:', - '1. Start the bundled database with `docker compose up -d`.', - '2. If the configured host port is already in use, set another `ZTD_DB_PORT` in `.env` and rerun `docker compose up -d`.', - '3. Wait until Postgres is ready, then rerun `npx vitest run`.', - '', - 'If Docker reports `all predefined address pools have been fully subnetted`, fix Docker networking first; changing `ZTD_DB_PORT` alone will not recover that error.' - ].join('\n') - ); - return wrapped; -} - -function describeStarterDbOriginalError(error: unknown): string { - if ( - typeof error === 'object' && - error !== null && - 'errors' in error && - Array.isArray((error as { errors?: unknown }).errors) - ) { - const messages = (error as { errors: unknown[] }).errors - .map((innerError) => (innerError instanceof Error ? innerError.message : String(innerError))) - .filter((message) => message.trim().length > 0); - - if (messages.length > 0) { - return messages.join('; '); - } - } - - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - return String(error); -} - -function isStarterDbConnectionFailure(error: unknown): boolean { - if (typeof error !== 'object' || error === null) { - return false; - } - - if ( - 'errors' in error && - Array.isArray((error as { errors?: unknown }).errors) && - (error as { errors: unknown[] }).errors.some((innerError) => isStarterDbConnectionFailure(innerError)) - ) { - return true; - } - - if ('cause' in error && isStarterDbConnectionFailure((error as { cause?: unknown }).cause)) { - return true; - } - - const code = 'code' in error ? String((error as { code?: unknown }).code) : ''; - if (['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', '28P01', '3D000'].includes(code)) { - return true; - } - - const message = error instanceof Error ? error.message : String(error); - return /connection terminated|connection timeout|password authentication failed|getaddrinfo|connect econnrefused/i.test(message); -} - -function describeConnectionTarget(connectionString: string): string { - try { - const url = new URL(connectionString); - return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ''}/${url.pathname.replace(/^\/+/, '')}`; - } catch { - return 'configured ZTD_DB_URL'; - } -} - -function flattenFixtureTableRows( - fixture: FixtureTree, - pathSegments: string[] = [] -): FixtureTableRows { - const tableRows: FixtureTableRows = []; - - for (const [key, value] of Object.entries(fixture)) { - const nextPathSegments = [...pathSegments, key]; - if (Array.isArray(value)) { - tableRows.push({ - tableName: nextPathSegments.join('.'), - rows: value.map((row) => assertRecordRow(row, nextPathSegments.join('.'))) - }); - continue; - } - - if (isPlainRecord(value)) { - tableRows.push(...flattenFixtureTableRows(value, nextPathSegments)); - continue; - } - - throw new Error( - `Query-boundary fixture entry ${nextPathSegments.join('.')} must be an object or an array of rows.` - ); - } - - return tableRows; -} - -function assertRecordRow(value: unknown, tableName: string): Record { - if (isPlainRecord(value)) { - return value; - } - - throw new Error(`Query-boundary fixture rows for ${tableName} must be objects.`); -} - -function isPlainRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function createQuerySpecExecutor( - testkitClient: PostgresTestkitClient, - trace: QueryExecutionTrace[] -): QuerySpecExecutorClient { - return { - async query(sql: string, params: Record): Promise { - const bound = bindNamedParams(sql, params); - trace.push({ - index: trace.length + 1, - originalSql: sql, - boundSql: bound.boundSql, - boundParams: bound.boundValues, - rewriteApplied: false - }); - - const result = await testkitClient.query(bound.boundSql, bound.boundValues); - return result.rows as T[]; - } - }; -} - -async function createPhysicalQuerySpecExecutor( - pool: Pool, - defaults: StarterProjectDefaults, - beforeDb: FixtureTree, - trace: QueryExecutionTrace[] -): Promise { - const client = await pool.connect(); - const schemaName = createPhysicalSchemaName(); - let closed = false; - - try { - await client.query(`CREATE SCHEMA ${quoteIdentifier(schemaName)}`); - await client.query(`SET search_path TO ${buildPhysicalSearchPath(defaults.searchPath, schemaName)}`); - await applySqlFiles(client, defaults.ddlDirectories, defaults.defaultSchema, schemaName); - await seedFixtureRows(client, flattenFixtureTableRows(beforeDb), defaults.defaultSchema, schemaName); - } catch (error) { - try { - await dropPhysicalSchema(client, schemaName); - } finally { - client.release(); - await pool.end(); - } - throw error; - } - - return { - async query(sql: string, params: Record): Promise { - const bound = bindNamedParams(sql, params); - const executedSql = rewriteSchemaQualifiedSql(bound.boundSql, defaults.defaultSchema, schemaName); - trace.push({ - index: trace.length + 1, - originalSql: sql, - boundSql: bound.boundSql, - boundParams: bound.boundValues, - executedSql, - executedParams: bound.boundValues, - rewriteApplied: false - }); - const result = await client.query(executedSql, bound.boundValues); - return result.rows as T[]; - }, - async assertAfterDb(afterDb: FixtureTree): Promise { - const expectedTables = flattenFixtureTableRows(afterDb); - for (const tableFixture of expectedTables) { - const tableName = toPhysicalTableName(tableFixture.tableName, defaults.defaultSchema, schemaName); - const rows = await client.query(`SELECT * FROM ${tableName}`); - if (tableFixture.rows.length === 0) { - expect(rows.rows).toEqual([]); - continue; - } - expect(rows.rows).toEqual( - expect.arrayContaining(tableFixture.rows.map((row) => expect.objectContaining(row))) - ); - } - }, - async close(): Promise { - if (closed) { - return; - } - closed = true; - try { - await dropPhysicalSchema(client, schemaName); - } finally { - client.release(); - await pool.end(); - } - } - }; -} - -async function applySqlFiles( - client: PoolClient, - ddlDirectories: string[], - defaultSchema: string, - schemaName: string -): Promise { - for (const ddlDirectory of ddlDirectories) { - for (const fileName of readdirSync(ddlDirectory).filter((entry) => entry.endsWith('.sql')).sort()) { - const sql = readFileSync(path.join(ddlDirectory, fileName), 'utf8').trim(); - if (sql.length === 0) { - continue; - } - await client.query(rewriteSchemaQualifiedSql(sql, defaultSchema, schemaName)); - } - } -} - -async function seedFixtureRows( - client: PoolClient, - tableFixtures: FixtureTableRows, - defaultSchema: string, - schemaName: string -): Promise { - for (const tableFixture of tableFixtures) { - for (const row of tableFixture.rows) { - const columns = Object.keys(row); - if (columns.length === 0) { - continue; - } - const tableName = toPhysicalTableName(tableFixture.tableName, defaultSchema, schemaName); - const columnList = columns.map(quoteIdentifier).join(', '); - const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); - const values = columns.map((column) => row[column]); - await client.query(`INSERT INTO ${tableName} (${columnList}) VALUES (${placeholders})`, values); - } - } -} - -async function dropPhysicalSchema(client: PoolClient, schemaName: string): Promise { - await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`); -} - -function createPhysicalSchemaName(): string { - const random = Math.random().toString(36).slice(2, 10); - return `ztd_traditional_${Date.now()}_${process.pid}_${random}`; -} - -function toPhysicalTableName(tableName: string, defaultSchema: string, schemaName: string): string { - const segments = tableName.split('.').map((segment) => segment.trim()).filter(Boolean); - const tableSegment = segments.at(-1); - if (!tableSegment) { - throw new Error(`Invalid fixture table name: ${tableName}`); - } - return `${quoteIdentifier(schemaName)}.${quoteIdentifier(tableSegment)}`; -} - -function rewriteSchemaQualifiedSql(sql: string, defaultSchema: string, schemaName: string): string { - let rewritten = ''; - let index = 0; - - while (index < sql.length) { - const current = sql[index]; - const next = sql[index + 1] ?? ''; - - if (current === '\'') { - const end = skipSingleQuotedString(sql, index); - rewritten += sql.slice(index, end); - index = end; - continue; - } - if (current === '-' && next === '-') { - const end = skipLineComment(sql, index); - rewritten += sql.slice(index, end); - index = end; - continue; - } - if (current === '/' && next === '*') { - const end = skipBlockComment(sql, index); - rewritten += sql.slice(index, end); - index = end; - continue; - } - if (current === '$') { - const dollarQuote = readDollarQuoteDelimiter(sql, index); - if (dollarQuote) { - const end = skipDollarQuotedString(sql, index, dollarQuote); - rewritten += sql.slice(index, end); - index = end; - continue; - } - } - - const qualifier = readDefaultSchemaQualifier(sql, index, defaultSchema); - if (qualifier) { - rewritten += `${quoteIdentifier(schemaName)}.`; - index = qualifier.end; - continue; - } - - rewritten += current; - index += 1; - } - - return rewritten; -} - -function quoteIdentifier(value: string): string { - return `"${value.replace(/"/g, '""')}"`; -} - -function buildPhysicalSearchPath(searchPath: string[], schemaName: string): string { - const schemas = [schemaName, ...searchPath.filter((entry) => entry !== schemaName)]; - return schemas.map(formatSearchPathEntry).join(', '); -} - -function formatSearchPathEntry(entry: string): string { - const normalized = entry.toLowerCase(); - if (entry === '$user' || /^pg_temp($|_)/.test(normalized)) { - return entry; - } - return quoteIdentifier(entry); -} - -function readDefaultSchemaQualifier( - sql: string, - start: number, - defaultSchema: string -): { end: number } | null { - const previous = sql[start - 1] ?? ''; - if (previous === '.' || /[A-Za-z0-9_"]/.test(previous)) { - return null; - } - - if (sql[start] === '"') { - const quoted = readQuotedIdentifier(sql, start); - if (!quoted || quoted.value !== defaultSchema || sql[quoted.end] !== '.') { - return null; - } - return { end: quoted.end + 1 }; - } - - if (!/[A-Za-z_]/.test(sql[start] ?? '')) { - return null; - } - - const end = consumeIdentifier(sql, start); - if (sql.slice(start, end) !== defaultSchema || sql[end] !== '.') { - return null; - } - - return { end: end + 1 }; -} - -function readQuotedIdentifier(sql: string, start: number): { end: number; value: string } | null { - let index = start + 1; - let value = ''; - while (index < sql.length) { - if (sql[index] === '"' && sql[index + 1] === '"') { - value += '"'; - index += 2; - continue; - } - if (sql[index] === '"') { - return { end: index + 1, value }; - } - value += sql[index]; - index += 1; - } - return null; -} - -function loadStarterDefaults(rootDir: string): StarterProjectDefaults { - const config = loadStarterProjectConfig(rootDir); - const configuredDefaultSchema = - typeof config.defaultSchema === 'string' ? config.defaultSchema.trim() : ''; - const defaultSchema = configuredDefaultSchema.length > 0 ? configuredDefaultSchema : 'public'; - const searchPath = normalizeSearchPath(config.searchPath); - const projectRootDir = path.resolve(rootDir); - const ztdRootDir = path.resolve(rootDir, config.ztdRootDir ?? '.ztd'); - const ddlDirectory = path.resolve( - projectRootDir, - typeof config.ddlDir === 'string' && config.ddlDir.trim().length > 0 ? config.ddlDir : 'db/ddl' - ); - - return { - projectRootDir, - ztdRootDir, - defaultSchema, - searchPath: searchPath.length > 0 ? searchPath : [defaultSchema], - ddlDirectories: existsSync(ddlDirectory) ? [ddlDirectory] : [] - }; -} - -function loadStarterProjectConfig(rootDir: string): StarterProjectConfigFile { - const configPath = path.join(rootDir, 'ztd.config.json'); - if (!existsSync(configPath)) { - return {}; - } - - try { - return JSON.parse(readFileSync(configPath, 'utf8')) as StarterProjectConfigFile; - } catch (error) { - if ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code?: string }).code === 'ENOENT' - ) { - return {}; - } - throw error; - } -} - -function normalizeSearchPath(searchPath: unknown): string[] { - if (!Array.isArray(searchPath)) { - return []; - } - - return searchPath - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} - -function writeTraceFileIfEnabled( - caseName: string, - trace: QueryExecutionTrace[], - evidence: QuerySpecExecutionEvidence, - failure?: unknown -): string | undefined { - if (!isTraceEnabled()) { - return undefined; - } - - const traceDir = resolveTraceDir(); - mkdirSync(traceDir, { recursive: true }); - - const fileName = `${createSafeFileSegment(caseName)}-${Date.now()}-${process.pid}.json`; - const traceFilePath = path.join(traceDir, fileName); - - writeFileSync( - traceFilePath, - `${JSON.stringify( - { - caseName, - evidence, - failure: serializeTraceFailure(failure), - trace - }, - null, - 2 - )}\n`, - 'utf8' - ); - - return traceFilePath; -} - -function serializeTraceFailure(failure: unknown): Record | undefined { - if (failure === undefined) { - return undefined; - } - - if (failure instanceof Error) { - return { - name: failure.name, - message: failure.message, - stack: failure.stack - }; - } - - return { - name: 'Error', - message: String(failure) - }; -} - -function isTraceEnabled(): boolean { - const value = process.env.ZTD_SQL_TRACE; - if (!value) { - return false; - } - - const normalized = value.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; -} - -function resolveTraceDir(): string { - const configuredDir = process.env.ZTD_SQL_TRACE_DIR; - if (configuredDir && configuredDir.trim().length > 0) { - return path.resolve(process.cwd(), configuredDir); - } - - return path.join(process.cwd(), '.ztd', 'tmp', 'sql-trace'); -} - -function createSafeFileSegment(value: string): string { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalized.length > 0 ? normalized : 'queryspec-case'; -} - -function normalizeSql(sql: string): string { - return sql.replace(/\s+/g, ' ').trim(); -} - -interface BoundNamedSql { - boundSql: string; - boundValues: unknown[]; -} - -function bindNamedParams(sql: string, params: Record): BoundNamedSql { - const scan = scanNamedParams(sql); - if (scan.mode !== 'named') { - return { - boundSql: sql, - boundValues: [] - }; - } - - const orderedValues: unknown[] = []; - const slotByName = new Map(); - let cursor = 0; - let boundSql = ''; - - for (const token of scan.namedTokens) { - boundSql += sql.slice(cursor, token.start); - let slot = slotByName.get(token.name); - if (!slot) { - orderedValues.push(resolveNamedParam(params, token.name)); - slot = orderedValues.length; - slotByName.set(token.name, slot); - } - boundSql += `$${slot}`; - cursor = token.end; - } - - boundSql += sql.slice(cursor); - return { - boundSql, - boundValues: orderedValues - }; -} - -function resolveNamedParam(params: Record, name: string): unknown { - if (!(name in params)) { - throw new Error(`Missing named query param: ${name}`); - } - return params[name]; -} - -type PlaceholderMode = 'none' | 'named' | 'positional'; - -interface NamedToken { - start: number; - end: number; - name: string; -} - -function scanNamedParams(sql: string): { mode: PlaceholderMode; namedTokens: NamedToken[] } { - const namedTokens: NamedToken[] = []; - let index = 0; - - while (index < sql.length) { - const current = sql[index]; - const next = sql[index + 1] ?? ''; - - if (current === '\'') { - index = skipSingleQuotedString(sql, index); - continue; - } - if (current === '"') { - index = skipDoubleQuotedIdentifier(sql, index); - continue; - } - if (current === '-' && next === '-') { - index = skipLineComment(sql, index); - continue; - } - if (current === '/' && next === '*') { - index = skipBlockComment(sql, index); - continue; - } - if (current === '$') { - const dollarQuote = readDollarQuoteDelimiter(sql, index); - if (dollarQuote) { - index = skipDollarQuotedString(sql, index, dollarQuote); - continue; - } - } - if (current === ':') { - if (next === ':') { - index += 2; - continue; - } - if (/[A-Za-z_]/.test(next)) { - const end = consumeIdentifier(sql, index + 1); - namedTokens.push({ - start: index, - end, - name: sql.slice(index + 1, end) - }); - index = end; - continue; - } - } - - index += 1; - } - - return { - mode: namedTokens.length > 0 ? 'named' : 'none', - namedTokens - }; -} - -function skipSingleQuotedString(sql: string, start: number): number { - let index = start + 1; - while (index < sql.length) { - if (sql[index] === '\'' && sql[index + 1] === '\'') { - index += 2; - continue; - } - if (sql[index] === '\'') { - return index + 1; - } - index += 1; - } - return sql.length; -} - -function skipDoubleQuotedIdentifier(sql: string, start: number): number { - let index = start + 1; - while (index < sql.length) { - if (sql[index] === '"' && sql[index + 1] === '"') { - index += 2; - continue; - } - if (sql[index] === '"') { - return index + 1; - } - index += 1; - } - return sql.length; -} - -function skipLineComment(sql: string, start: number): number { - let index = start + 2; - while (index < sql.length && sql[index] !== '\n') { - index += 1; - } - return index; -} - -function skipBlockComment(sql: string, start: number): number { - let index = start + 2; - while (index < sql.length) { - if (sql[index] === '*' && sql[index + 1] === '/') { - return index + 2; - } - index += 1; - } - return sql.length; -} - -function readDollarQuoteDelimiter(sql: string, start: number): string | null { - let index = start + 1; - while (index < sql.length && /[A-Za-z0-9_]/.test(sql[index] ?? '')) { - index += 1; - } - if (sql[index] === '$') { - return sql.slice(start, index + 1); - } - return null; -} - -function skipDollarQuotedString(sql: string, start: number, delimiter: string): number { - const closeIndex = sql.indexOf(delimiter, start + delimiter.length); - return closeIndex >= 0 ? closeIndex + delimiter.length : sql.length; -} - -function consumeIdentifier(sql: string, start: number): number { - let index = start; - while (index < sql.length && /[A-Za-z0-9_]/.test(sql[index] ?? '')) { - index += 1; - } - return index; -} diff --git a/packages/ztd-cli/templates/tests/ztd-layout.generated.ts b/packages/ztd-cli/templates/tests/ztd-layout.generated.ts deleted file mode 100644 index f50b14d1c..000000000 --- a/packages/ztd-cli/templates/tests/ztd-layout.generated.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED FILE. DO NOT EDIT. - -export default { - ztdRootDir: '.ztd', - ddlDir: 'db/ddl', -}; diff --git a/packages/ztd-cli/templates/tsconfig.json b/packages/ztd-cli/templates/tsconfig.json deleted file mode 100644 index 9805ada04..000000000 --- a/packages/ztd-cli/templates/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "baseUrl": ".", - "paths": { - "#features/*": ["src/features/*"], - "#libraries/*": ["src/libraries/*"], - "#adapters/*": ["src/adapters/*"], - "#tests/*": ["tests/*"] - }, - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/.tsbuildinfo" - }, - "include": ["src/**/*.ts", "tests/**/*.ts"] -} diff --git a/packages/ztd-cli/templates/vitest.config.ts b/packages/ztd-cli/templates/vitest.config.ts deleted file mode 100644 index 2740a04f8..000000000 --- a/packages/ztd-cli/templates/vitest.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - resolve: { - alias: { - '#features': fileURLToPath(new URL('./src/features', import.meta.url)), - '#libraries': fileURLToPath(new URL('./src/libraries', import.meta.url)), - '#adapters': fileURLToPath(new URL('./src/adapters', import.meta.url)), - '#tests': fileURLToPath(new URL('./tests', import.meta.url)), - }, - }, - test: { - include: ['tests/**/*.test.ts', 'src/features/**/*.test.ts'], - environment: 'node', - globals: true, - setupFiles: ['.ztd/support/setup-env.ts'], - globalSetup: ['.ztd/support/global-setup.ts'], - // Starting a Postgres testcontainer can exceed the default 5s timeout on some machines. - testTimeout: 60_000, - hookTimeout: 60_000, - }, -}); diff --git a/packages/ztd-cli/templates/ztd/README.md b/packages/ztd-cli/templates/ztd/README.md deleted file mode 100644 index 0d1f38832..000000000 --- a/packages/ztd-cli/templates/ztd/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# ztd/ - -This directory stores ZTD schema artifacts. - -- `db/ddl/.sql` is the source of truth for schema. diff --git a/packages/ztd-cli/templates/ztd/ddl/demo.sql b/packages/ztd-cli/templates/ztd/ddl/demo.sql deleted file mode 100644 index 172e43a2e..000000000 --- a/packages/ztd-cli/templates/ztd/ddl/demo.sql +++ /dev/null @@ -1,17 +0,0 @@ -create table users ( - user_id bigint generated by default as identity primary key, - email text not null unique, - display_name text not null, - is_active boolean not null default true, - created_at timestamptz not null default current_timestamp -); - -create index idx_users_is_active - on users (is_active, user_id); - -comment on table users is 'Starter users table for the first feature.'; -comment on column users.user_id is 'Identity primary key for the user row.'; -comment on column users.email is 'Unique email address for the user.'; -comment on column users.display_name is 'Human-readable display name for the user.'; -comment on column users.is_active is 'Whether the user is active.'; -comment on column users.created_at is 'Creation timestamp for the user row.'; diff --git a/packages/ztd-cli/tests/__snapshots__/cliCommands.test.ts.snap b/packages/ztd-cli/tests/__snapshots__/cliCommands.test.ts.snap deleted file mode 100644 index f46efb069..000000000 --- a/packages/ztd-cli/tests/__snapshots__/cliCommands.test.ts.snap +++ /dev/null @@ -1,91 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ztd-config CLI produces the expected ztd-row-map.generated.ts snapshot 1`] = ` -"// GENERATED FILE. DO NOT EDIT. -// ZTD TEST ROW MAP -// This file is synchronized with DDL using ztd-config. - -type ColumnDefinitions = Record; - -export interface TableSchemaDefinition { - columns: ColumnDefinitions; -} - -export type FixtureRow = Record; - -export interface TableFixture = FixtureRow> { - tableName: string; - rows: RowShape[]; - schema: TableSchemaDefinition; -} -export interface TestRowMap { - 'public.sessions': PublicSessionsTestRow; - 'public.users': PublicUsersTestRow; -} - -export interface PublicSessionsTestRow extends FixtureRow { - id: number; - user_id: number; - expires_at: string; -} - -export interface PublicUsersTestRow extends FixtureRow { - id: number; - email: string; - score: string | null; -} - -export type TestRow = TestRowMap[K]; -export type ZtdRowShapes = TestRowMap; -export type ZtdTableName = keyof TestRowMap; - -export type ZtdTableSchemas = Record; - -export const tableSchemas: ZtdTableSchemas = { - 'public.sessions': { - columns: { - id: "bigint", - user_id: "int", - expires_at: "timestamptz", - } - }, - 'public.users': { - columns: { - id: "serial", - email: "text", - score: "numeric", - } - }, -}; - -export function tableSchema(tableName: K): TableSchemaDefinition { - return tableSchemas[tableName]; -} - -export type ZtdTableFixture = TableFixture & { - tableName: K; - rows: ZtdRowShapes[K][]; - schema: TableSchemaDefinition; -}; - -export interface ZtdConfig { - tables: ZtdTableName[]; -} - -export function tableFixture( - tableName: K, - rows: ZtdRowShapes[K][], - schema?: TableSchemaDefinition -): TableFixture { - return { tableName, rows, schema: schema ?? tableSchemas[tableName] }; -} - -export function tableFixtureWithSchema( - tableName: K, - rows: ZtdRowShapes[K][] -): ZtdTableFixture { - // Always pair fixture rows with the canonical schema generated from DDL. - return { tableName, rows, schema: tableSchemas[tableName] }; -} -" -`; diff --git a/packages/ztd-cli/tests/__snapshots__/describe.cli.test.ts.snap b/packages/ztd-cli/tests/__snapshots__/describe.cli.test.ts.snap deleted file mode 100644 index 63b3ba11e..000000000 --- a/packages/ztd-cli/tests/__snapshots__/describe.cli.test.ts.snap +++ /dev/null @@ -1,1210 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`describe command emits JSON envelope when global output is json 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Generation completed, dry-run plan emitted, or output contract described.", - "1": "Validation or probing failed.", - }, - "flags": [ - { - "description": "Validate probing and show the planned output file without writing it.", - "name": "--dry-run", - }, - { - "description": "Pass model-gen options as a JSON object.", - "name": "--json", - }, - { - "description": "Print the generated artifact contract instead of probing.", - "name": "--describe-output", - }, - { - "description": "Compatibility helper for shared SQL roots; feature-local SQL resolves naturally without it.", - "name": "--sql-root", - }, - ], - "name": "model-gen", - "output": { - "files": [ - "Specified --out file when present", - ], - "stdout": "Generated TypeScript when --out is omitted; JSON envelope in global json mode.", - }, - "summary": "Probe SQL metadata and generate QuerySpec scaffolding from feature-local or shared SQL assets.", - "supportsDescribeOutput": true, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > check contract 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "No violations.", - "1": "Violations detected.", - "2": "Runtime or config error.", - }, - "flags": [ - { - "description": "Emit the report as deterministic JSON.", - "name": "--format json", - }, - { - "description": "Limit QuerySpec discovery to one feature, boundary, or subtree.", - "name": "--scope-dir", - }, - { - "description": "Legacy fixed catalog specs directory override.", - "name": "--specs-dir", - }, - { - "description": "Pass check options as a JSON object.", - "name": "--json", - }, - ], - "name": "check contract", - "output": { - "stdout": "Human report or deterministic JSON report.", - }, - "summary": "Validate project QuerySpec-backed SQL contracts and emit deterministic findings.", - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > ddl diff 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Diff completed or dry-run review emitted.", - "1": "Local discovery or pg_dump failed.", - }, - "flags": [ - { - "description": "Compute the logical summary and structured risks without writing the SQL or review artifacts.", - "name": "--dry-run", - }, - { - "description": "Pass diff options as a JSON object.", - "name": "--json", - }, - ], - "name": "ddl diff", - "output": { - "files": [ - "Specified --out SQL file plus companion .txt and .json review artifacts with summary/risks", - ], - "stdout": "Human logical summary plus structured risks in text mode, JSON envelope in global json mode.", - }, - "summary": "Compare local DDL with a live database and emit logical summary, structured risks, and a pure SQL artifact.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > ddl gen-entities 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Generation completed or dry-run plan emitted.", - "1": "DDL parsing failed.", - }, - "flags": [ - { - "description": "Render entities without writing the output file.", - "name": "--dry-run", - }, - { - "description": "Pass generation options as a JSON object.", - "name": "--json", - }, - ], - "name": "ddl gen-entities", - "output": { - "files": [ - "Specified --out entities file", - ], - "stdout": "Status or JSON envelope.", - }, - "summary": "Generate helper interfaces from DDL metadata.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > ddl pull 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Pull completed or dry-run plan emitted.", - "1": "pg_dump or normalization failed.", - }, - "flags": [ - { - "description": "Run pg_dump and normalization without writing schema files.", - "name": "--dry-run", - }, - { - "description": "Pass pull options as a JSON object.", - "name": "--json", - }, - ], - "name": "ddl pull", - "output": { - "files": [ - "/.sql", - ], - "stdout": "Status or JSON envelope.", - }, - "summary": "Pull PostgreSQL schema DDL via pg_dump and normalize it into per-schema files.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > ddl risk 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Risk report emitted.", - "1": "Validation or file loading failed.", - }, - "flags": [ - { - "description": "Migration SQL file to analyze.", - "name": "--file", - }, - { - "description": "Pass risk options as a JSON object.", - "name": "--json", - }, - ], - "name": "ddl risk", - "output": { - "stdout": "Human structured risk report in text mode, JSON envelope in global json mode.", - }, - "summary": "Analyze a generated or hand-edited migration SQL file and emit the same structured risk contract used by ddl diff.", - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": false, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > describe-root 1`] = ` -{ - "command": "describe", - "data": { - "commands": [ - { - "name": "init", - "summary": "Scaffold a ZTD project with templates, config, and optional demo assets.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "feature scaffold", - "summary": "Scaffold a feature-local CRUD boundary skeleton from schema metadata.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": false, - "writesFiles": true, - }, - { - "name": "feature generated-mapper generate", - "summary": "Regenerate machine-owned RFBA query row mappers from query boundary contracts.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": false, - "writesFiles": true, - }, - { - "name": "feature generated-mapper check", - "summary": "Fail when machine-owned RFBA row mappers drift from query boundary contracts.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": false, - "writesFiles": false, - }, - { - "name": "feature query scaffold", - "summary": "Add one additive child query boundary under an existing boundary without rewriting the parent boundary.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": false, - "writesFiles": true, - }, - { - "name": "ztd-config", - "summary": "Generate TestRowMap, runtime fixture metadata, and layout metadata from local DDL.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "model-gen", - "summary": "Probe SQL metadata and generate QuerySpec scaffolding from feature-local or shared SQL assets.", - "supportsDescribeOutput": true, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "ddl pull", - "summary": "Pull PostgreSQL schema DDL via pg_dump and normalize it into per-schema files.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "ddl diff", - "summary": "Compare local DDL with a live database and emit logical summary, structured risks, and a pure SQL artifact.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "ddl risk", - "summary": "Analyze a generated or hand-edited migration SQL file and emit the same structured risk contract used by ddl diff.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": false, - }, - { - "name": "ddl gen-entities", - "summary": "Generate helper interfaces from DDL metadata.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "check contract", - "summary": "Validate project QuerySpec-backed SQL contracts and emit deterministic findings.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "perf init", - "summary": "Scaffold the opt-in perf sandbox configuration and Docker assets.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "perf db reset", - "summary": "Recreate the perf sandbox schema from local DDL.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": false, - }, - { - "name": "perf seed", - "summary": "Generate deterministic synthetic data from perf/seed.yml.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": false, - }, - { - "name": "perf run", - "summary": "Benchmark a SQL query and emit evidence for AI-driven tuning loops.", - "supportsDescribeOutput": false, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "perf report diff", - "summary": "Compare two saved perf benchmark runs and report the primary delta.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": false, - }, - { - "name": "query uses", - "summary": "Inspect catalog SQL usage of tables or columns.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": true, - }, - { - "name": "query match-observed", - "summary": "Rank likely source SQL assets for an observed SELECT statement.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": false, - "writesFiles": true, - }, - { - "name": "lint", - "summary": "Lint SQL files with fixture-backed validation.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": false, - }, - { - "name": "rfba inspect", - "summary": "Inspect RFBA root, feature, and query sub-boundaries without writing files.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": false, - }, - { - "name": "evidence", - "summary": "Generate deterministic specification evidence artifacts from project QuerySpec and test assets.", - "supportsDescribeOutput": false, - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": true, - }, - ], - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > evidence 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Evidence generated.", - "1": "Generation failed.", - "2": "Runtime or config error.", - }, - "flags": [ - { - "description": "Limit QuerySpec discovery to one feature, boundary, or subtree.", - "name": "--scope-dir", - }, - { - "description": "Legacy fixed catalog specs directory override.", - "name": "--specs-dir", - }, - { - "description": "Pass evidence options as a JSON object.", - "name": "--json", - }, - ], - "name": "evidence", - "output": { - "files": [ - "/test-specification.json", - "/test-specification.md", - ], - }, - "summary": "Generate deterministic specification evidence artifacts from project QuerySpec and test assets.", - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > feature generated-mapper check 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Generated mapper files match their query boundary contracts.", - "1": "Generated mapper drift or feature/query contract resolution error.", - }, - "flags": [ - { - "description": "Feature name under src/features/.", - "name": "--feature ", - }, - { - "description": "Limit drift detection to one query under queries/.", - "name": "--query ", - }, - ], - "name": "feature generated-mapper check", - "output": { - "files": [], - "stdout": "Human success summary in text mode, JSON envelope in global json mode.", - }, - "summary": "Fail when machine-owned RFBA row mappers drift from query boundary contracts.", - "supportsDryRun": false, - "supportsJsonPayload": false, - "writesFiles": false, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > feature generated-mapper generate 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Generated mapper files were synchronized or dry-run plan emitted.", - "1": "Feature/query contract resolution or filesystem error.", - }, - "flags": [ - { - "description": "Feature name under src/features/.", - "name": "--feature ", - }, - { - "description": "Limit regeneration to one query under queries/.", - "name": "--query ", - }, - { - "description": "Check planned generated mapper updates without writing files.", - "name": "--dry-run", - }, - ], - "name": "feature generated-mapper generate", - "output": { - "files": [ - "src/features//queries//generated/row-mapper.ts", - ], - "stdout": "Human sync summary in text mode, JSON envelope in global json mode.", - }, - "summary": "Regenerate machine-owned RFBA query row mappers from query boundary contracts.", - "supportsDryRun": true, - "supportsJsonPayload": false, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > feature query scaffold 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Scaffold completed or dry-run plan emitted.", - "1": "Validation, metadata resolution, or filesystem error.", - }, - "flags": [ - { - "description": "Target table name for the new query boundary.", - "name": "--table
", - }, - { - "description": "Query action template to scaffold. v1 supports insert, update, delete, get-by-id, and list.", - "name": "--action ", - }, - { - "description": "Name of the child query boundary to create under queries/.", - "name": "--query-name ", - }, - { - "description": "Resolve the target boundary as src/features/.", - "name": "--feature ", - }, - { - "description": "Resolve the target boundary from an explicit existing boundary folder. Use either --feature or --boundary-dir, or omit both when the current working directory is already the target boundary.", - "name": "--boundary-dir ", - }, - { - "description": "INSERT default-column policy. Use explicit-defaults to copy DDL defaults into SQL, or omit-db-defaults to let the database assign DB-default columns.", - "name": "--insert-default-policy ", - }, - { - "description": "Validate inputs and emit the planned additive scaffold without writing files.", - "name": "--dry-run", - }, - ], - "name": "feature query scaffold", - "output": { - "files": [ - "/queries//", - "/queries//boundary.ts", - "/queries//.sql", - "/queries//generated/row-mapper.ts", - "src/features/_shared/featureQueryExecutor.ts on first scaffold run", - "src/features/_shared/loadSqlResource.ts on first scaffold run", - ], - "stdout": "Human additive scaffold summary in text mode, JSON envelope in global json mode.", - }, - "summary": "Add one additive child query boundary under an existing boundary without rewriting the parent boundary.", - "supportsDryRun": true, - "supportsJsonPayload": false, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > feature scaffold 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Scaffold completed or dry-run plan emitted.", - "1": "Validation, metadata resolution, or filesystem error.", - }, - "flags": [ - { - "description": "Target table name for the scaffold.", - "name": "--table
", - }, - { - "description": "Action template to scaffold. v1 supports insert, update, delete, get-by-id, and list.", - "name": "--action ", - }, - { - "description": "Override the derived resource-action feature name.", - "name": "--feature-name ", - }, - { - "description": "INSERT default-column policy. Use explicit-defaults to copy DDL defaults into SQL, or omit-db-defaults to let the database assign DB-default columns.", - "name": "--insert-default-policy ", - }, - { - "description": "Validate inputs and emit the planned scaffold without writing files.", - "name": "--dry-run", - }, - { - "description": "Overwrite scaffold-owned feature files when they already exist.", - "name": "--force", - }, - ], - "name": "feature scaffold", - "output": { - "files": [ - "src/features//", - "src/features//boundary.ts", - "src/features//queries//", - "src/features//queries//boundary.ts", - "src/features//queries//.sql", - "src/features//queries//generated/row-mapper.ts", - "src/features//tests/", - "src/features//tests/.boundary.test.ts", - "src/features//README.md", - "src/features/_shared/featureQueryExecutor.ts on first scaffold run", - "src/features/_shared/loadSqlResource.ts on first scaffold run", - ], - "stdout": "Human scaffold summary in text mode, JSON envelope in global json mode.", - }, - "summary": "Scaffold a feature-local CRUD boundary skeleton from schema metadata.", - "supportsDryRun": true, - "supportsJsonPayload": false, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > init 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Scaffold completed or dry-run plan emitted.", - "1": "Validation or filesystem error.", - }, - "flags": [ - { - "description": "Validate inputs and emit the planned scaffold without writing files.", - "name": "--dry-run", - }, - { - "description": "Pass init options as a JSON object.", - "name": "--json", - }, - { - "description": "Accept defaults and overwrite existing files.", - "name": "--yes", - }, - ], - "name": "init", - "output": { - "files": [ - "ztd.config.json", - "db/ddl/*.sql", - ".ztd/generated/*", - ], - "stdout": "Human summary in text mode, JSON envelope in global json mode.", - }, - "summary": "Scaffold a ZTD project with templates, config, and optional demo assets.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > lint 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Lint completed without failures.", - "1": "Lint failures or runtime error.", - }, - "flags": [ - { - "description": "Pass lint options as a JSON object.", - "name": "--json", - }, - ], - "name": "lint", - "output": { - "stdout": "Silent on success in text mode, JSON envelope in global json mode.", - }, - "summary": "Lint SQL files with fixture-backed validation.", - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": false, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > model-gen 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Generation completed, dry-run plan emitted, or output contract described.", - "1": "Validation or probing failed.", - }, - "flags": [ - { - "description": "Validate probing and show the planned output file without writing it.", - "name": "--dry-run", - }, - { - "description": "Pass model-gen options as a JSON object.", - "name": "--json", - }, - { - "description": "Print the generated artifact contract instead of probing.", - "name": "--describe-output", - }, - { - "description": "Compatibility helper for shared SQL roots; feature-local SQL resolves naturally without it.", - "name": "--sql-root", - }, - ], - "name": "model-gen", - "output": { - "files": [ - "Specified --out file when present", - ], - "stdout": "Generated TypeScript when --out is omitted; JSON envelope in global json mode.", - }, - "summary": "Probe SQL metadata and generate QuerySpec scaffolding from feature-local or shared SQL assets.", - "supportsDescribeOutput": true, - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > perf db reset 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Reset completed or dry-run plan emitted.", - "1": "Docker, connection, or DDL replay failed.", - }, - "flags": [ - { - "description": "Emit the DDL replay plan without touching Docker or PostgreSQL.", - "name": "--dry-run", - }, - { - "description": "Pass perf db reset options as a JSON object.", - "name": "--json", - }, - ], - "name": "perf db reset", - "output": { - "stdout": "Human reset summary or JSON envelope.", - }, - "summary": "Recreate the perf sandbox schema from local DDL.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": false, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > perf init 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Scaffold completed or dry-run plan emitted.", - "1": "Validation or filesystem error.", - }, - "flags": [ - { - "description": "Emit the planned perf sandbox scaffold without writing files.", - "name": "--dry-run", - }, - { - "description": "Pass perf init options as a JSON object.", - "name": "--json", - }, - ], - "name": "perf init", - "output": { - "files": [ - "perf/sandbox.json", - "perf/seed.yml", - "perf/params.yml", - "perf/docker-compose.yml", - "perf/README.md", - "perf/.gitignore", - ], - "stdout": "Human scaffold summary or JSON envelope.", - }, - "summary": "Scaffold the opt-in perf sandbox configuration and Docker assets.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > perf report diff 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Diff report emitted.", - "1": "Evidence loading or validation failed.", - }, - "flags": [ - { - "defaultValue": "text", - "description": "Output format (text|json).", - "name": "--format", - }, - { - "description": "Pass perf report diff options as a JSON object.", - "name": "--json", - }, - ], - "name": "perf report diff", - "output": { - "stdout": "Human diff summary or JSON envelope.", - }, - "summary": "Compare two saved perf benchmark runs and report the primary delta.", - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": false, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > perf run 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Benchmark completed or dry-run plan emitted.", - "1": "Validation, connection, or execution failed.", - }, - "flags": [ - { - "description": "SQL file to benchmark inside the perf sandbox.", - "name": "--query", - }, - { - "description": "JSON or YAML file with named or positional parameters.", - "name": "--params", - }, - { - "defaultValue": "direct", - "description": "Execution strategy (direct|decomposed).", - "name": "--strategy", - }, - { - "description": "Comma-separated CTEs to materialize when using decomposed execution.", - "name": "--material", - }, - { - "defaultValue": "auto", - "description": "Benchmark mode (auto|latency|completion).", - "name": "--mode", - }, - { - "defaultValue": "10", - "description": "Measured repetitions for latency mode.", - "name": "--repeat", - }, - { - "defaultValue": "3", - "description": "Warmup repetitions for latency mode.", - "name": "--warmup", - }, - { - "defaultValue": "60", - "description": "Threshold for auto mode classification.", - "name": "--classify-threshold-seconds", - }, - { - "defaultValue": "5", - "description": "Timeout for measured runs.", - "name": "--timeout-minutes", - }, - { - "description": "Persist benchmark evidence under perf/evidence/run_xxx.", - "name": "--save", - }, - { - "description": "Resolve benchmark mode and evidence shape without touching PostgreSQL.", - "name": "--dry-run", - }, - { - "description": "Attach a short label to the saved run directory.", - "name": "--label", - }, - { - "description": "Pass perf run options as a JSON object.", - "name": "--json", - }, - ], - "name": "perf run", - "output": { - "files": [ - "perf/evidence/run_xxx/* when --save is enabled", - ], - "stdout": "Human benchmark summary or JSON envelope.", - }, - "summary": "Benchmark a SQL query and emit evidence for AI-driven tuning loops.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > perf seed 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Seed completed or dry-run plan emitted.", - "1": "Connection, DDL parsing, or insert generation failed.", - }, - "flags": [ - { - "description": "Emit the seed plan without touching PostgreSQL.", - "name": "--dry-run", - }, - { - "description": "Pass perf seed options as a JSON object.", - "name": "--json", - }, - ], - "name": "perf seed", - "output": { - "stdout": "Human seed summary or JSON envelope.", - }, - "summary": "Generate deterministic synthetic data from perf/seed.yml.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": false, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > query match-observed 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Report emitted.", - "1": "Validation failed or no candidate SELECT assets were found.", - }, - "flags": [ - { - "description": "Observed SQL text to rank.", - "name": "--sql", - }, - { - "description": "Read the observed SQL text from a file.", - "name": "--sql-file", - }, - { - "description": "Emit a machine-readable JSON ranking report.", - "name": "--format json", - }, - { - "description": "Write output to a file.", - "name": "--out", - }, - ], - "name": "query match-observed", - "output": { - "files": [ - "Specified --out file when present", - ], - "stdout": "Text ranking report or JSON report for observed SQL candidates.", - }, - "summary": "Rank likely source SQL assets for an observed SELECT statement.", - "supportsDryRun": false, - "supportsJsonPayload": false, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > query uses 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Report emitted.", - "1": "Validation failed.", - }, - "flags": [ - { - "description": "Emit a versioned JSON usage report.", - "name": "--format json", - }, - { - "description": "Pass target and options as a JSON object.", - "name": "--json", - }, - { - "description": "Emit only summary counts with display metadata.", - "name": "--summary-only", - }, - { - "description": "Truncate matches and warnings while preserving summary totals.", - "name": "--limit", - }, - ], - "name": "query uses", - "output": { - "stdout": "Text report or versioned JSON report with optional display metadata.", - }, - "summary": "Inspect catalog SQL usage of tables or columns.", - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > rfba inspect 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Boundary report emitted.", - "1": "Validation or filesystem error.", - }, - "flags": [ - { - "description": "Output format (text|json).", - "name": "--format ", - }, - { - "description": "Project root to inspect.", - "name": "--root ", - }, - { - "description": "Pass inspect options as a JSON object.", - "name": "--json", - }, - ], - "name": "rfba inspect", - "output": { - "stdout": "Text boundary map or deterministic JSON report.", - }, - "summary": "Inspect RFBA root, feature, and query sub-boundaries without writing files.", - "supportsDryRun": false, - "supportsJsonPayload": true, - "writesFiles": false, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; - -exports[`describe command snapshots the top-level command catalog and every detailed descriptor > ztd-config 1`] = ` -{ - "command": "describe command", - "data": { - "command": { - "exitCodes": { - "0": "Generation completed or dry-run plan emitted.", - "1": "Generation failed.", - }, - "flags": [ - { - "description": "Render and validate generation without writing files.", - "name": "--dry-run", - }, - { - "description": "Pass ztd-config options as a JSON object.", - "name": "--json", - }, - { - "defaultValue": false, - "description": "Watch DDL files and regenerate on change.", - "name": "--watch", - }, - ], - "name": "ztd-config", - "output": { - "files": [ - ".ztd/generated/ztd-row-map.generated.ts", - ".ztd/generated/ztd-fixture-manifest.generated.ts", - ".ztd/generated/ztd-layout.generated.ts", - ], - "stdout": "Status or JSON envelope.", - }, - "summary": "Generate TestRowMap, runtime fixture metadata, and layout metadata from local DDL.", - "supportsDryRun": true, - "supportsJsonPayload": true, - "writesFiles": true, - }, - "schemaVersion": 1, - }, - "ok": true, - "schemaVersion": 1, -} -`; diff --git a/packages/ztd-cli/tests/__snapshots__/ztdConfig.unit.test.ts.snap b/packages/ztd-cli/tests/__snapshots__/ztdConfig.unit.test.ts.snap deleted file mode 100644 index 3a77338d6..000000000 --- a/packages/ztd-cli/tests/__snapshots__/ztdConfig.unit.test.ts.snap +++ /dev/null @@ -1,243 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`generates ZTD row map from CREATE TABLE statements 1`] = ` -"// GENERATED FILE. DO NOT EDIT. -// ZTD TEST ROW MAP -// This file is synchronized with DDL using ztd-config. - -type ColumnDefinitions = Record; - -export interface TableSchemaDefinition { - columns: ColumnDefinitions; -} - -export type FixtureRow = Record; - -export interface TableFixture = FixtureRow> { - tableName: string; - rows: RowShape[]; - schema: TableSchemaDefinition; -} -export interface TestRowMap { - 'public.profiles': PublicProfilesTestRow; - 'public.users': PublicUsersTestRow; -} - -export interface PublicProfilesTestRow extends FixtureRow { - user_id: number; - bio: string | null; - rating: string | null; -} - -export interface PublicUsersTestRow extends FixtureRow { - id: number; - email: string; - created_at: string; -} - -export type TestRow = TestRowMap[K]; -export type ZtdRowShapes = TestRowMap; -export type ZtdTableName = keyof TestRowMap; - -export type ZtdTableSchemas = Record; - -export const tableSchemas: ZtdTableSchemas = { - 'public.profiles': { - columns: { - user_id: "bigint", - bio: "text", - rating: "numeric", - } - }, - 'public.users': { - columns: { - id: "serial", - email: "text", - created_at: "timestamptz", - } - }, -}; - -export function tableSchema(tableName: K): TableSchemaDefinition { - return tableSchemas[tableName]; -} - -export type ZtdTableFixture = TableFixture & { - tableName: K; - rows: ZtdRowShapes[K][]; - schema: TableSchemaDefinition; -}; - -export interface ZtdConfig { - tables: ZtdTableName[]; -} - -export function tableFixture( - tableName: K, - rows: ZtdRowShapes[K][], - schema?: TableSchemaDefinition -): TableFixture { - return { tableName, rows, schema: schema ?? tableSchemas[tableName] }; -} - -export function tableFixtureWithSchema( - tableName: K, - rows: ZtdRowShapes[K][] -): ZtdTableFixture { - // Always pair fixture rows with the canonical schema generated from DDL. - return { tableName, rows, schema: tableSchemas[tableName] }; -} -" -`; - -exports[`generates a runtime fixture manifest alongside the row map 1`] = ` -"// GENERATED FILE. DO NOT EDIT. -// ZTD GENERATED FIXTURE MANIFEST -// This file is synchronized with DDL using ztd-config. - -import type { TableDefinitionModel } from 'rawsql-ts'; - -export interface GeneratedFixtureManifest { - tableDefinitions: TableDefinitionModel[]; -} - -export const tableDefinitions: TableDefinitionModel[] = [ - { - name: "public.users", - columns: [ - { - name: "id", - typeName: "serial", - required: true, - defaultValue: null, - isNotNull: true, - }, - { - name: "email", - typeName: "text", - required: true, - defaultValue: null, - isNotNull: true, - }, - { - name: "created_at", - typeName: "timestamptz", - required: false, - defaultValue: "now()", - isNotNull: true, - }, - ] - }, -]; - -export const generatedFixtureManifest: GeneratedFixtureManifest = { - tableDefinitions, -}; -" -`; - -exports[`handles multiple sources with composite keys and cross-schema references 1`] = ` -"// GENERATED FILE. DO NOT EDIT. -// ZTD TEST ROW MAP -// This file is synchronized with DDL using ztd-config. - -type ColumnDefinitions = Record; - -export interface TableSchemaDefinition { - columns: ColumnDefinitions; -} - -export type FixtureRow = Record; - -export interface TableFixture = FixtureRow> { - tableName: string; - rows: RowShape[]; - schema: TableSchemaDefinition; -} -export interface TestRowMap { - 'analytics.weekly_stats': AnalyticsWeeklyStatsTestRow; - 'public.users': PublicUsersTestRow; - 'sales.orders': SalesOrdersTestRow; -} - -export interface AnalyticsWeeklyStatsTestRow extends FixtureRow { - stats_id: number; - order_id: number; - order_line: number; - sales_amount: string; -} - -export interface PublicUsersTestRow extends FixtureRow { - id: number; - name: string; -} - -export interface SalesOrdersTestRow extends FixtureRow { - order_id: number; - line: number; - customer_id: number; - total: string; -} - -export type TestRow = TestRowMap[K]; -export type ZtdRowShapes = TestRowMap; -export type ZtdTableName = keyof TestRowMap; - -export type ZtdTableSchemas = Record; - -export const tableSchemas: ZtdTableSchemas = { - 'analytics.weekly_stats': { - columns: { - stats_id: "serial", - order_id: "bigint", - order_line: "bigint", - sales_amount: "numeric", - } - }, - 'public.users': { - columns: { - id: "serial", - name: "text", - } - }, - 'sales.orders': { - columns: { - order_id: "bigint", - line: "bigint", - customer_id: "bigint", - total: "numeric", - } - }, -}; - -export function tableSchema(tableName: K): TableSchemaDefinition { - return tableSchemas[tableName]; -} - -export type ZtdTableFixture = TableFixture & { - tableName: K; - rows: ZtdRowShapes[K][]; - schema: TableSchemaDefinition; -}; - -export interface ZtdConfig { - tables: ZtdTableName[]; -} - -export function tableFixture( - tableName: K, - rows: ZtdRowShapes[K][], - schema?: TableSchemaDefinition -): TableFixture { - return { tableName, rows, schema: schema ?? tableSchemas[tableName] }; -} - -export function tableFixtureWithSchema( - tableName: K, - rows: ZtdRowShapes[K][] -): ZtdTableFixture { - // Always pair fixture rows with the canonical schema generated from DDL. - return { tableName, rows, schema: tableSchemas[tableName] }; -} -" -`; diff --git a/packages/ztd-cli/tests/agentSafety.unit.test.ts b/packages/ztd-cli/tests/agentSafety.unit.test.ts deleted file mode 100644 index 287c00fb9..000000000 --- a/packages/ztd-cli/tests/agentSafety.unit.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { validateProjectPath, validateResourceIdentifier } from '../src/utils/agentSafety'; - -test('validateProjectPath resolves a valid relative path inside the project root', () => { - expect(validateProjectPath('src/file.ts', '--out', '/repo/project')).toBe(path.resolve('/repo/project', 'src/file.ts')); -}); - -test('validateProjectPath rejects traversal outside project root', () => { - expect(() => validateProjectPath('../outside.txt', '--out', 'C:/repo/project')).toThrow(/inside the current project root/); -}); - -test('validateResourceIdentifier trims and preserves valid identifiers', () => { - expect(validateResourceIdentifier('public.users', '--table')).toBe('public.users'); - expect(validateResourceIdentifier(' my_schema ', '--schema')).toBe('my_schema'); -}); - -test('validateResourceIdentifier rejects query fragments and encoded traversal', () => { - expect(() => validateResourceIdentifier('users?fields=id', '--table')).toThrow(/query or fragment/); - expect(() => validateResourceIdentifier('%2e%2e/secret', '--table')).toThrow(/encoded path traversal/); -}); - - -test('validateResourceIdentifier rejects control characters', () => { - expect(() => validateResourceIdentifier(`user${String.fromCharCode(0)}name`, '--table')).toThrow(/control characters/); -}); diff --git a/packages/ztd-cli/tests/agentsPolicy.unit.test.ts b/packages/ztd-cli/tests/agentsPolicy.unit.test.ts deleted file mode 100644 index 91578274c..000000000 --- a/packages/ztd-cli/tests/agentsPolicy.unit.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = resolve(__dirname, '../../..'); -const rootAgentsPath = resolve(repoRoot, 'AGENTS.md'); -const visibleMirrorPath = resolve(repoRoot, '.agent/AGENTS.md'); - -function readPolicy(filePath: string): string { - return readFileSync(filePath, 'utf8'); -} - -const SHARED_POLICY_ASSERTIONS = [ - 'All assistant-user conversation in this repository must be in Japanese.', - 'Reports MUST use an itemized structure with `acceptance item`, `status`, `evidence`, and `gap`.', - 'Final PR text and final implementation reports MUST keep those fields visible per acceptance item.', - 'Global summary sections MUST NOT replace per-item status, evidence, or gap.', - 'GitHub-facing reports MUST NOT use local filesystem links such as `/C:/...`; use repo-relative references or plain text.', - 'If a GitHub-facing report contains a local filesystem path, final form is incomplete.', - 'Reports MUST distinguish `tests were updated` from `tests passed`.', - 'If execution is blocked or not run, the affected item MUST remain `partial` or `not done`.', - 'Plans MUST state the `Source issue` and `Why it matters`.', - 'Reports MUST state the `Source request` or `Source issue` and `Why it matters` before item-level status.', - 'Reports MUST state `What changed` before file inventory or file lists.', - 'Final PR text and final implementation reports MUST pass two-cycle self-review before human review.', - 'Review findings MUST be triaged as `blocker`, `follow-up`, or `nit`.', - 'Reports MUST distinguish `Repository evidence` from `Supplementary evidence` when both appear.', - 'PR reports MUST treat `Repository evidence` as the primary basis for acceptance judgment.', - 'Reports MUST end with `What the human should decide next`.', - '`What changed` MUST describe user-facing or reviewer-facing meaning before implementation detail or file names.', - '`Verification basis` MUST state what observation was treated as sufficient to call the shape or item satisfied.', - '`What the human should decide next` SHOULD be phrased as a narrow choice whenever possible.', -]; - -function assertPolicyContains(contents: string, assertions: string[]): void { - for (const phrase of assertions) { - if (phrase === 'Reports MUST use an itemized structure with `acceptance item`, `status`, `evidence`, and `gap`.') { - expect(contents).toContain('Reports MUST use an itemized structure with'); - expect(contents).toContain('`acceptance item`'); - expect(contents).toContain('`status`'); - expect(contents).toContain('`evidence`'); - expect(contents).toContain('`gap`'); - continue; - } - - expect(contents).toContain(phrase); - } -} - -test('root AGENTS.md defines global guardrails and routing', () => { - const contents = readPolicy(rootAgentsPath); - - expect(contents).toContain('# Repository Scope'); - expect(contents).toContain('This file defines repository-wide rules for rawsql-ts developers.'); - expect(contents).toContain('Deeper `AGENTS.md` files take precedence when they add stricter or narrower rules without weakening completion criteria.'); - expect(contents).toContain('## Global Guardrails'); - expect(contents).toContain('Keep generated artifacts, fixtures, and derived docs aligned with their source assets.'); - expect(contents).toContain('Do not weaken completion criteria or skip required verification.'); - expect(contents).toContain('Do not mix customer-facing guidance into this repository policy.'); - expect(contents).toContain('## Guidance Routing'); - expect(contents).toContain('Root `AGENTS.md` defines repository-wide policy only; detailed output formats and workflows belong to subagent or skill guidance.'); - expect(contents).toContain('Reports must distinguish `done`, `partial`, and `not done`.'); - expect(contents).toContain('Reports must distinguish `tests were updated` from `tests passed`.'); - expect(contents).toContain('Supplementary evidence alone must not justify a strong `done` claim.'); - expect(contents).toContain('.codex/agents/'); - expect(contents).toContain('.agents/skills/'); -}); - -test('.agent/AGENTS.md mirrors the routing and guardrail policy', () => { - const contents = readPolicy(visibleMirrorPath); - - expect(contents).toContain('Visible Policy Mirror'); - expect(contents).toContain('`MUST` and `REQUIRED` define completion criteria.'); - expect(contents).toContain('repository root policy remains canonical'); - expect(contents).toContain('Reports MUST make `Verification basis`, `Guarantee limits`, and `Outstanding gaps` visible when needed.'); - expect(contents).toContain('Consistency review MUST check literal drift, mirror / test / policy mismatch, required field coverage, GitHub-safe references, per-item final form, and `tests were updated` versus `tests passed` wording.'); - expect(contents).toContain('`Supplementary evidence` alone MUST NOT justify a strong `done` claim'); - assertPolicyContains(contents, SHARED_POLICY_ASSERTIONS); - expect(contents).toContain('.codex/agents/planning.md'); - expect(contents).toContain('.codex/agents/review.md'); - expect(contents).toContain('attainment-reporting/SKILL.md'); - expect(contents).toContain('self-review/SKILL.md'); -}); - -test('policy precedence is described without weakening completion criteria', () => { - const rootContents = readPolicy(rootAgentsPath); - const mirrorContents = readPolicy(visibleMirrorPath); - - expect(rootContents).toContain('Deeper `AGENTS.md` files take precedence when they add stricter or narrower rules without weakening completion criteria.'); - expect(mirrorContents).toContain('deeper files may only narrow scope without weakening completion criteria'); -}); diff --git a/packages/ztd-cli/tests/branchSessionGuard.unit.test.ts b/packages/ztd-cli/tests/branchSessionGuard.unit.test.ts deleted file mode 100644 index 85efdb4ef..000000000 --- a/packages/ztd-cli/tests/branchSessionGuard.unit.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, expect, test } from 'vitest'; - -const { - SESSION_GIT_PATH, - checkBranchExpectation, - clearExpectedBranch, - parseCliArgs, - recordExpectedBranch, - resolveSessionFilePath, -} = require('../../../scripts/branch-session-guard.js') as { - SESSION_GIT_PATH: string; - checkBranchExpectation( - cwd?: string, - helpers?: { - runGit?: (args: string[], cwd?: string) => string; - sessionFilePath?: string; - currentBranch?: string; - expectedBranch?: string | null; - readFile?: (filePath: string, encoding: string) => string; - exists?: (filePath: string) => boolean; - }, - ): { ok: boolean; code: string; message: string; sessionFilePath: string }; - clearExpectedBranch( - cwd?: string, - helpers?: { - runGit?: (args: string[], cwd?: string) => string; - sessionFilePath?: string; - exists?: (filePath: string) => boolean; - unlink?: (filePath: string) => void; - }, - ): string; - parseCliArgs(argv: string[]): { command: string; options: Record }; - recordExpectedBranch( - expectedBranch: string, - cwd?: string, - helpers?: { - runGit?: (args: string[], cwd?: string) => string; - sessionFilePath?: string; - currentBranch?: string; - }, - ): { expectedBranch: string; sessionFilePath: string }; - resolveSessionFilePath(cwd?: string, runGit?: (args: string[], cwd?: string) => string): string; -}; - -const tempDirs: string[] = []; - -afterEach(() => { - while (tempDirs.length > 0) { - const directory = tempDirs.pop(); - if (directory) { - rmSync(directory, { recursive: true, force: true }); - } - } -}); - -function createTempDir(): string { - const directory = mkdtempSync(path.join(os.tmpdir(), 'branch-session-guard-')); - tempDirs.push(directory); - return directory; -} - -function gitPathRunner(gitPath: string, currentBranch = 'codex/749-branch-session-guard') { - return (args: string[]) => { - if (args[0] === 'rev-parse') { - return gitPath; - } - if (args[0] === 'branch') { - return currentBranch; - } - throw new Error(`Unexpected git args: ${args.join(' ')}`); - }; -} - -test('parseCliArgs keeps branch arguments explicit', () => { - expect(parseCliArgs(['expect', '--branch', 'codex/749-branch-session-guard'])).toEqual({ - command: 'expect', - options: { branch: 'codex/749-branch-session-guard' }, - }); -}); - -test('resolveSessionFilePath uses git-owned metadata path', () => { - const cwd = createTempDir(); - const resolved = resolveSessionFilePath(cwd, gitPathRunner('.git/worktrees/redo/rawsql/expected-branch')); - - expect(resolved).toBe(path.resolve(cwd, '.git/worktrees/redo/rawsql/expected-branch')); - expect(SESSION_GIT_PATH).toBe('rawsql/expected-branch'); -}); - -test('recordExpectedBranch writes the expected branch to the session file', () => { - const cwd = createTempDir(); - const sessionFilePath = path.join(cwd, '.git', 'rawsql', 'expected-branch'); - - const result = recordExpectedBranch('codex/749-branch-session-guard', cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch'), - sessionFilePath, - currentBranch: 'codex/749-branch-session-guard', - }); - - expect(result.expectedBranch).toBe('codex/749-branch-session-guard'); - expect(readFileSync(sessionFilePath, 'utf8')).toBe('codex/749-branch-session-guard\n'); -}); - -test('checkBranchExpectation fails when no expected branch is recorded', () => { - const cwd = createTempDir(); - const sessionFilePath = path.join(cwd, '.git', 'rawsql', 'expected-branch'); - - const result = checkBranchExpectation(cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch'), - sessionFilePath, - currentBranch: 'codex/749-branch-session-guard', - }); - - expect(result.ok).toBe(false); - expect(result.code).toBe('missing-expected-branch'); - expect(result.message).toContain('No expected branch is recorded'); -}); - -test('checkBranchExpectation passes when current and expected branches match', () => { - const cwd = createTempDir(); - const sessionFilePath = path.join(cwd, '.git', 'rawsql', 'expected-branch'); - - recordExpectedBranch('codex/749-branch-session-guard', cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch'), - sessionFilePath, - currentBranch: 'codex/749-branch-session-guard', - }); - - const result = checkBranchExpectation(cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch'), - sessionFilePath, - currentBranch: 'codex/749-branch-session-guard', - }); - - expect(result.ok).toBe(true); - expect(result.code).toBe('ok'); -}); - -test('checkBranchExpectation fails on mismatched branch', () => { - const cwd = createTempDir(); - const sessionFilePath = path.join(cwd, '.git', 'rawsql', 'expected-branch'); - - recordExpectedBranch('codex/749-branch-session-guard', cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch'), - sessionFilePath, - currentBranch: 'codex/749-branch-session-guard', - }); - - const result = checkBranchExpectation(cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch', 'codex/another-task'), - sessionFilePath, - currentBranch: 'codex/another-task', - }); - - expect(result.ok).toBe(false); - expect(result.code).toBe('branch-mismatch'); - expect(result.message).toContain('Expected branch: codex/749-branch-session-guard'); - expect(result.message).toContain('Current branch: codex/another-task'); -}); - -test('checkBranchExpectation fails on detached HEAD', () => { - const cwd = createTempDir(); - const sessionFilePath = path.join(cwd, '.git', 'rawsql', 'expected-branch'); - - const result = checkBranchExpectation(cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch', ''), - sessionFilePath, - currentBranch: '', - expectedBranch: 'codex/749-branch-session-guard', - }); - - expect(result.ok).toBe(false); - expect(result.code).toBe('detached-head'); -}); - -test('clearExpectedBranch removes the session file', () => { - const cwd = createTempDir(); - const sessionFilePath = path.join(cwd, '.git', 'rawsql', 'expected-branch'); - - recordExpectedBranch('codex/749-branch-session-guard', cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch'), - sessionFilePath, - currentBranch: 'codex/749-branch-session-guard', - }); - expect(existsSync(sessionFilePath)).toBe(true); - - clearExpectedBranch(cwd, { - runGit: gitPathRunner('.git/rawsql/expected-branch'), - sessionFilePath, - }); - - expect(existsSync(sessionFilePath)).toBe(false); -}); - -test('pre-push checks branch session before retro gate', () => { - const prePushHook = readFileSync(path.resolve(__dirname, '../../../.husky/pre-push'), 'utf8'); - - const branchIndex = prePushHook.indexOf('node scripts/branch-session-guard.js check'); - const retroIndex = prePushHook.indexOf('node scripts/check-retro-clean.js'); - - expect(branchIndex).toBeGreaterThanOrEqual(0); - expect(retroIndex).toBeGreaterThan(branchIndex); -}); diff --git a/packages/ztd-cli/tests/checkContract.cli-exit.test.ts b/packages/ztd-cli/tests/checkContract.cli-exit.test.ts deleted file mode 100644 index 1ff63fd02..000000000 --- a/packages/ztd-cli/tests/checkContract.cli-exit.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from 'vitest'; -import { CheckContractRuntimeError, resolveCheckContractExitCode } from '../src/commands/checkContract'; - -test('check contract exit code is 0 when checks pass', () => { - const code = resolveCheckContractExitCode({ - result: { ok: true, violations: [], filesChecked: 1, specsChecked: 1 } - }); - expect(code).toBe(0); -}); - -test('check contract exit code is 1 when violations exist', () => { - const code = resolveCheckContractExitCode({ - result: { - ok: false, - filesChecked: 1, - specsChecked: 1, - violations: [ - { - rule: 'unresolved-sql-file', - severity: 'error', - specId: 'bad', - filePath: '/tmp/spec.json', - message: 'missing' - } - ] - } - }); - expect(code).toBe(1); -}); - -test('check contract exit code is 2 for runtime/config errors', () => { - const code = resolveCheckContractExitCode({ error: new CheckContractRuntimeError('bad config') }); - expect(code).toBe(2); -}); diff --git a/packages/ztd-cli/tests/checkContract.cli.test.ts b/packages/ztd-cli/tests/checkContract.cli.test.ts deleted file mode 100644 index f11854b3e..000000000 --- a/packages/ztd-cli/tests/checkContract.cli.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { Command } from 'commander'; -import { afterEach, expect, test } from 'vitest'; -import { registerCheckContractCommand } from '../src/commands/checkContract'; - -const originalProjectRoot = process.env.ZTD_PROJECT_ROOT; -const originalExitCode = process.exitCode; - -afterEach(() => { - process.env.ZTD_PROJECT_ROOT = originalProjectRoot; - process.exitCode = originalExitCode; -}); - -function createWorkspace(prefix: string): string { - const root = mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - mkdirSync(path.join(root, 'src', 'catalog', 'specs'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'sql'), { recursive: true }); - return root; -} - -function createProgram(capture: { stdout: string[]; stderr: string[] }): Command { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str) - }); - registerCheckContractCommand(program); - return program; -} - -test('CLI: check contract help documents RFBA scope discovery and legacy specsDir', async () => { - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - - await expect(program.parseAsync(['check', 'contract', '--help'], { from: 'user' })).rejects.toMatchObject({ - code: 'commander.helpDisplayed' - }); - - const help = capture.stdout.join(''); - expect(help).toContain('--scope-dir '); - expect(help).toContain('Limit QuerySpec discovery to one feature, boundary, or'); - expect(help).toContain('subtree'); - expect(help).toContain('--specs-dir '); - expect(help).toContain('Legacy override for a fixed SQL catalog specs directory'); -}); - -test('CLI: check contract writes json output and sets exitCode=0 on success', async () => { - const workspace = createWorkspace('check-contract-cli-pass'); - writeFileSync( - path.join(workspace, 'src', 'catalog', 'specs', 'ok.spec.json'), - JSON.stringify( - { - id: 'users.list', - sqlFile: '../../sql/users.list.sql', - params: { shape: 'named', example: { status: 'active' } }, - output: { mapping: { columnMap: { userId: 'user_id' } } } - }, - null, - 2 - ), - 'utf8' - ); - writeFileSync(path.join(workspace, 'src', 'sql', 'users.list.sql'), 'select user_id from users where status = :status', 'utf8'); - - process.env.ZTD_PROJECT_ROOT = workspace; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - - const outPath = path.join(workspace, 'artifacts', 'contract-check.json'); - await program.parseAsync(['check', 'contract', '--format', 'json', '--out', outPath], { from: 'user' }); - - expect(process.exitCode).toBe(0); - const output = JSON.parse(readFileSync(outPath, 'utf8')); - expect(output).toMatchObject({ ok: true, filesChecked: 1, specsChecked: 1, violations: [] }); - expect(capture.stdout.join('')).toBe(''); - expect(capture.stderr.join('')).toBe(''); -}); - -test('CLI: check contract prints violations and sets exitCode=1', async () => { - const workspace = createWorkspace('check-contract-cli-violations'); - writeFileSync( - path.join(workspace, 'src', 'catalog', 'specs', 'bad.spec.json'), - JSON.stringify( - { - id: 'users.bad', - sqlFile: '../../sql/missing.sql', - params: { shape: 'named', example: [] } - }, - null, - 2 - ), - 'utf8' - ); - - process.env.ZTD_PROJECT_ROOT = workspace; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const outPath = path.join(workspace, 'artifacts', 'contract-check.json'); - - await program.parseAsync(['check', 'contract', '--format', 'json', '--out', outPath], { from: 'user' }); - - expect(process.exitCode).toBe(1); - const parsed = JSON.parse(readFileSync(outPath, 'utf8')); - expect(parsed.violations.some((v: { rule: string }) => v.rule === 'unresolved-sql-file')).toBe(true); - expect(parsed.violations.some((v: { rule: string }) => v.rule === 'params-shape-mismatch')).toBe(true); -}); - -test('CLI: check contract flags uncovered SQL assets in strict mode', async () => { - const workspace = createWorkspace('check-contract-cli-uncovered-sql'); - writeFileSync(path.join(workspace, 'src', 'sql', 'orphan.sql'), 'select 1', 'utf8'); - - process.env.ZTD_PROJECT_ROOT = workspace; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const outPath = path.join(workspace, 'artifacts', 'contract-check.json'); - - await program.parseAsync(['check', 'contract', '--format', 'json', '--strict', '--out', outPath], { from: 'user' }); - - expect(process.exitCode).toBe(1); - const parsed = JSON.parse(readFileSync(outPath, 'utf8')); - expect(parsed.ok).toBe(false); - expect(parsed.violations.some((v: { rule: string }) => v.rule === 'uncovered-sql-file')).toBe(true); -}); - -test('CLI: check contract sets exitCode=2 for runtime/config errors', async () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), 'check-contract-cli-error-')); - process.env.ZTD_PROJECT_ROOT = workspace; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - - await program.parseAsync(['check', 'contract', '--scope-dir', 'missing-feature'], { from: 'user' }); - - expect(process.exitCode).toBe(2); -}); - -test('CLI: check contract accepts --json payload options', async () => { - const workspace = createWorkspace('check-contract-cli-json'); - writeFileSync( - path.join(workspace, 'src', 'catalog', 'specs', 'ok.spec.json'), - JSON.stringify( - { - id: 'users.list', - sqlFile: '../../sql/users.list.sql', - params: { shape: 'named', example: { status: 'active' } } - }, - null, - 2 - ), - 'utf8' - ); - writeFileSync(path.join(workspace, 'src', 'sql', 'users.list.sql'), 'select user_id from users where status = :status', 'utf8'); - - process.env.ZTD_PROJECT_ROOT = workspace; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const outPath = path.join(workspace, 'artifacts', 'contract-check.json'); - - await program.parseAsync( - ['check', 'contract', '--json', JSON.stringify({ format: 'json', out: outPath, strict: true })], - { from: 'user' } - ); - - expect(process.exitCode).toBe(0); - const output = JSON.parse(readFileSync(outPath, 'utf8')); - expect(output).toMatchObject({ ok: true, filesChecked: 1, specsChecked: 1, violations: [] }); - expect(capture.stdout.join('')).toBe(''); - expect(capture.stderr.join('')).toBe(''); -}); - -test('CLI: check contract accepts scopeDir from --json payload', async () => { - const workspace = createWorkspace('check-contract-cli-scope-json'); - const queryDir = path.join(workspace, 'src', 'features', 'users', 'queries', 'list-users'); - mkdirSync(queryDir, { recursive: true }); - writeFileSync(path.join(queryDir, 'list-users.sql'), 'SELECT id FROM users WHERE active = :active', 'utf8'); - writeFileSync( - path.join(queryDir, 'queryspec.ts'), - [ - "const listUsersSql = loadSqlResource(__dirname, 'list-users.sql');", - "export const listUsersQuerySpec = { label: 'features.users.list-users', sql: listUsersSql };", - '' - ].join('\n'), - 'utf8' - ); - - process.env.ZTD_PROJECT_ROOT = workspace; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const outPath = path.join(workspace, 'artifacts', 'contract-check.json'); - - await program.parseAsync( - [ - 'check', - 'contract', - '--json', - JSON.stringify({ - format: 'json', - out: outPath, - scopeDir: path.join('src', 'features', 'users') - }) - ], - { from: 'user' } - ); - - expect(process.exitCode).toBe(0); - const output = JSON.parse(readFileSync(outPath, 'utf8')); - expect(output).toMatchObject({ ok: true, filesChecked: 1, specsChecked: 1, violations: [] }); -}); diff --git a/packages/ztd-cli/tests/checkContract.unit.test.ts b/packages/ztd-cli/tests/checkContract.unit.test.ts deleted file mode 100644 index 5f583d7bc..000000000 --- a/packages/ztd-cli/tests/checkContract.unit.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { describe, expect, test } from 'vitest'; -import { formatOutput, runCheckContract } from '../src/commands/checkContract'; - -function createWorkspace(): string { - const dir = mkdtempSync(path.join(os.tmpdir(), 'ztd-check-contract-')); - mkdirSync(path.join(dir, 'src', 'catalog', 'specs'), { recursive: true }); - mkdirSync(path.join(dir, 'src', 'sql'), { recursive: true }); - return dir; -} - -function writeFeatureLocalQuerySpec(root: string, featureName: string, sql: string): void { - const queryDir = path.join(root, 'src', 'features', featureName, 'queries', 'list-users'); - mkdirSync(queryDir, { recursive: true }); - writeFileSync(path.join(queryDir, 'list-users.sql'), sql, 'utf8'); - writeFileSync( - path.join(queryDir, 'queryspec.ts'), - [ - "import { loadSqlResource } from '../../../_shared/loadSqlResource';", - '', - "const listUsersSql = loadSqlResource(__dirname, 'list-users.sql');", - '', - 'export const listUsersQuerySpec = {', - ` label: 'features.${featureName}.list-users',`, - ' sql: listUsersSql', - '};', - '' - ].join('\n'), - 'utf8' - ); -} - -describe('runCheckContract', () => { - test('detects duplicate spec ids', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'SELECT 1', 'utf8'); - writeFileSync(path.join(root, 'src', 'catalog', 'specs', 'a.json'), JSON.stringify({ id: 'same', sqlFile: '../../sql/a.sql', params: { shape: 'positional', example: [] } }), 'utf8'); - writeFileSync(path.join(root, 'src', 'catalog', 'specs', 'b.json'), JSON.stringify({ id: 'same', sqlFile: '../../sql/a.sql', params: { shape: 'positional', example: [] } }), 'utf8'); - - const result = runCheckContract({ strict: true, rootDir: root }); - expect(result.violations.some((v) => v.rule === 'duplicate-spec-id')).toBe(true); - }); - - test('detects unresolved sqlFile', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'catalog', 'specs', 'a.json'), JSON.stringify({ id: 'a', sqlFile: '../../sql/missing.sql', params: { shape: 'positional', example: [] } }), 'utf8'); - - const result = runCheckContract({ strict: true, rootDir: root }); - expect(result.violations).toEqual(expect.arrayContaining([expect.objectContaining({ rule: 'unresolved-sql-file', specId: 'a' })])); - }); - - test('discovers feature-local QuerySpec assets project-wide by default', () => { - const root = createWorkspace(); - writeFeatureLocalQuerySpec(root, 'users', 'SELECT id FROM users WHERE active = :active'); - - const result = runCheckContract({ strict: true, rootDir: root }); - - expect(result).toMatchObject({ - ok: true, - filesChecked: 1, - specsChecked: 1 - }); - expect(result.violations).toEqual([]); - }); - - test('limits project discovery with scopeDir and preserves legacy specsDir', () => { - const root = createWorkspace(); - writeFeatureLocalQuerySpec(root, 'users', 'SELECT id FROM users WHERE active = :active'); - writeFeatureLocalQuerySpec(root, 'orders', 'SELECT * FROM orders'); - writeFileSync(path.join(root, 'src', 'sql', 'legacy.sql'), 'SELECT 1', 'utf8'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'legacy.json'), - JSON.stringify({ id: 'legacy', sqlFile: '../../sql/legacy.sql', params: { shape: 'positional', example: [] } }), - 'utf8' - ); - - const scoped = runCheckContract({ strict: true, rootDir: root, scopeDir: path.join('src', 'features', 'users') }); - expect(scoped).toMatchObject({ filesChecked: 1, specsChecked: 1 }); - expect(scoped.violations.some((v) => v.specId === 'features.orders.list-users')).toBe(false); - - const legacy = runCheckContract({ strict: true, rootDir: root, specsDir: path.join('src', 'catalog', 'specs') }); - expect(legacy).toMatchObject({ filesChecked: 1, specsChecked: 1 }); - expect(legacy.violations.some((v) => v.specId === 'features.users.list-users')).toBe(false); - }); - - test('limits uncovered SQL detection to scopeDir', () => { - const root = createWorkspace(); - writeFeatureLocalQuerySpec(root, 'users', 'SELECT id FROM users WHERE active = :active'); - writeFileSync(path.join(root, 'src', 'sql', 'outside-scope.sql'), 'SELECT 1', 'utf8'); - - const result = runCheckContract({ - strict: true, - rootDir: root, - scopeDir: path.join('src', 'features', 'users') - }); - - expect(result.ok).toBe(true); - expect(result.violations).toEqual([]); - }); - - test('rejects non-directory scopeDir and specsDir options', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'features-file'), 'not a directory', 'utf8'); - writeFileSync(path.join(root, 'src', 'catalog', 'specs-file'), 'not a directory', 'utf8'); - - expect(() => - runCheckContract({ strict: true, rootDir: root, scopeDir: path.join('src', 'features-file') }) - ).toThrow(/Scope directory is not a directory/); - expect(() => - runCheckContract({ strict: true, rootDir: root, specsDir: path.join('src', 'catalog', 'specs-file') }) - ).toThrow(/Spec directory is not a directory/); - }); - - test('detects params shape mismatches', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'SELECT 1', 'utf8'); - writeFileSync(path.join(root, 'src', 'catalog', 'specs', 'a.json'), JSON.stringify({ id: 'named-bad', sqlFile: '../../sql/a.sql', params: { shape: 'named', example: [] } }), 'utf8'); - writeFileSync(path.join(root, 'src', 'catalog', 'specs', 'b.json'), JSON.stringify({ id: 'pos-bad', sqlFile: '../../sql/a.sql', params: { shape: 'positional', example: { id: 1 } } }), 'utf8'); - - const result = runCheckContract({ strict: true, rootDir: root }); - expect(result.violations.filter((v) => v.rule === 'params-shape-mismatch')).toHaveLength(2); - }); - - test('detects mapping duplicate/invalid entries', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'SELECT 1', 'utf8'); - writeFileSync(path.join(root, 'src', 'catalog', 'specs', 'a.json'), JSON.stringify({ id: 'map', sqlFile: '../../sql/a.sql', params: { shape: 'positional', example: [] }, output: { mapping: { columnMap: { id: 'user_id', other: 'user_id', bad: '' } } } }), 'utf8'); - - const result = runCheckContract({ strict: true, rootDir: root }); - expect(result.violations.some((v) => v.rule === 'mapping-duplicate-entry')).toBe(true); - expect(result.violations.some((v) => v.rule === 'mapping-invalid-entry')).toBe(true); - }); - - test('warns when SQL assets are not covered by any QuerySpec', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'sql', 'orphan.sql'), 'SELECT 1', 'utf8'); - - const result = runCheckContract({ strict: false, rootDir: root }); - expect(result.ok).toBe(true); - expect(result.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - rule: 'uncovered-sql-file', - severity: 'warning' - }) - ]) - ); - }); - - test('safety checks are warnings by default and errors with strict', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'sql', 'unsafe.sql'), 'SELECT * FROM users; UPDATE users SET name = $1;', 'utf8'); - writeFileSync(path.join(root, 'src', 'catalog', 'specs', 'a.json'), JSON.stringify({ id: 'unsafe', sqlFile: '../../sql/unsafe.sql', params: { shape: 'positional', example: [] } }), 'utf8'); - - const warnResult = runCheckContract({ strict: false, rootDir: root }); - expect(warnResult.ok).toBe(true); - expect(warnResult.violations.some((v) => v.rule === 'safety-select-star' && v.severity === 'warning')).toBe(true); - - const strictResult = runCheckContract({ strict: true, rootDir: root }); - expect(strictResult.ok).toBe(false); - expect(strictResult.violations.some((v) => v.rule === 'safety-missing-where' && v.severity === 'error')).toBe(true); - }); - - test('does not flag wildcard inside EXISTS subquery when root select list is explicit', () => { - const root = createWorkspace(); - writeFileSync( - path.join(root, 'src', 'sql', 'exists.sql'), - 'SELECT id FROM users WHERE EXISTS (SELECT * FROM orders WHERE orders.user_id = users.id);', - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'exists.json'), - JSON.stringify({ id: 'exists-safe', sqlFile: '../../sql/exists.sql', params: { shape: 'positional', example: [] } }), - 'utf8' - ); - - const result = runCheckContract({ strict: true, rootDir: root }); - expect(result.violations.some((v) => v.rule === 'safety-select-star')).toBe(false); - }); - - test('records sql parse warning for invalid SQL safety-check phase', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'sql', 'invalid.sql'), 'SELECT FROM', 'utf8'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'invalid.json'), - JSON.stringify({ id: 'invalid.sql', sqlFile: '../../sql/invalid.sql', params: { shape: 'positional', example: [] } }), - 'utf8' - ); - - const result = runCheckContract({ strict: true, rootDir: root }); - expect(result.violations.some((v) => v.rule === 'sql-parse-error' && v.severity === 'warning')).toBe(true); - }); - - test('ts/js extractor includes mapping.prefix so mapping validation still runs', () => { - const root = createWorkspace(); - writeFileSync(path.join(root, 'src', 'sql', 'prefix.sql'), 'SELECT 1', 'utf8'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'prefix.ts'), - "export const bad = { id: 'prefix.bad', sqlFile: '../../sql/prefix.sql', params: { shape: 'positional', example: [] }, output: { mapping: { prefix: '' } } };", - 'utf8' - ); - - const result = runCheckContract({ strict: true, rootDir: root }); - expect(result.violations.some((v) => v.rule === 'mapping-invalid-entry')).toBe(true); - }); - - test('formatOutput emits deterministic json', () => { - const formatted = formatOutput({ ok: false, filesChecked: 1, specsChecked: 1, violations: [{ rule: 'duplicate-spec-id', severity: 'error', specId: 'a', filePath: '/tmp/a.json', message: 'dup' }] }, 'json'); - expect(formatted).toContain('"rule": "duplicate-spec-id"'); - }); -}); diff --git a/packages/ztd-cli/tests/cliCommands.test.ts b/packages/ztd-cli/tests/cliCommands.test.ts deleted file mode 100644 index c43b52e13..000000000 --- a/packages/ztd-cli/tests/cliCommands.test.ts +++ /dev/null @@ -1,3879 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Client } from 'pg'; -import { expect, test } from 'vitest'; -import { TAX_ALLOCATION_QUERY } from './utils/taxAllocationScenario'; - -const nodeExecutable = process.execPath; -const packageManagerExecutable = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const cliRoot = path.resolve(__dirname, '..'); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const cliEntry = path.join(cliRoot, 'dist', 'index.js'); -const generatedMapperDriftScript = path.join(repoRoot, 'scripts', 'check-generated-mapper-drift.mjs'); -let cliBuildPrepared = false; -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - // Ensure the shared tmp directory exists before deriving per-test folders. - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -const pgDumpCommand = process.env.PG_DUMP_BIN ?? 'pg_dump'; - -function ensureBuiltCli(): void { - if (cliBuildPrepared) { - return; - } - - // Run the built CLI in tests so dogfooding follows the packaged command path and avoids ts-node path drift. - const buildResult = spawnSync(packageManagerExecutable, ['--filter', '@rawsql-ts/ztd-cli', 'build'], { - cwd: repoRoot, - env: { - ...process.env, - NODE_ENV: 'test', - }, - encoding: 'utf8', - shell: process.platform === 'win32', - }); - - if (buildResult.error) { - throw buildResult.error; - } - if (buildResult.status !== 0) { - throw new Error(buildResult.stderr || buildResult.stdout || 'Failed to build ztd-cli before running CLI tests.'); - } - - cliBuildPrepared = true; -} - -function commandExists(command: string): boolean { - // Run --version to confirm the binary is callable before enabling DB tests. - const result = spawnSync(command, ['--version'], { stdio: 'ignore' }); - return result.status === 0 && !result.error; -} - -function buildCliEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { - return { - // Copy the current environment so per-test mutations (for example ZTD_DB_URL) propagate to the CLI. - ...process.env, - NODE_ENV: 'test', - ...overrides, - }; -} - -function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}, cwd: string = repoRoot): SpawnSyncReturns { - ensureBuiltCli(); - - // Invoke the built CLI so the command path matches real dogfooding usage and stays stable across TypeScript upgrades. - return spawnSync(nodeExecutable, [cliEntry, ...args], { - cwd, - env: buildCliEnv(envOverrides), - encoding: 'utf8', - - }); -} - -function readNormalizedFile(filePath: string): string { - const contents = readFileSync(filePath, 'utf8'); - // Normalize line endings so snapshots are stable across platforms. - return contents.replace(/\r\n/g, '\n'); -} - -function normalizeSchemaDump(contents: string): string { - // Normalize casing and strip quotes so pg_dump identifier quoting stays stable across versions. - return contents.toLowerCase().replace(/"/g, ''); -} - -function assertCliSuccess(result: SpawnSyncReturns, label?: string) { - expect(result.error).toBeUndefined(); - const context = label ? `${label}: ` : ''; - expect(result.status, `${context}${result.stderr || result.stdout}`).toBe(0); -} - -function assertCliFailure(result: SpawnSyncReturns, label?: string) { - expect(result.error).toBeUndefined(); - const context = label ? `${label}: ` : ''; - expect(result.status, `${context}${result.stderr || result.stdout}`).not.toBe(0); -} - -function createSqlWorkspace(prefix: string, sqlRelativePath: string = path.join('src', 'sql', 'query.sql')): { - rootDir: string; - sqlRoot: string; - sqlFile: string; -} { - const rootDir = createTempDir(prefix); - const sqlFile = path.join(rootDir, sqlRelativePath); - const sqlRoot = path.join(rootDir, 'src', 'sql'); - mkdirSync(path.dirname(sqlFile), { recursive: true }); - return { rootDir, sqlRoot, sqlFile }; -} - -function createGeneratedMapperDriftWorkspace(prefix: string): { rootDir: string; packageDir: string } { - const rootDir = createTempDir(prefix); - const packageDir = path.join(rootDir, 'fixture'); - const queryDir = path.join(packageDir, 'src', 'features', 'users-list', 'queries', 'list'); - const relativeCliEntry = path.relative(packageDir, cliEntry).replace(/\\/g, '/'); - mkdirSync(path.join(queryDir, 'generated'), { recursive: true }); - writeFileSync( - path.join(packageDir, 'package.json'), - `${JSON.stringify({ - name: 'generated-mapper-drift-fixture', - private: true, - type: 'module', - scripts: { - ztd: `node ${relativeCliEntry}` - } - }, null, 2)}\n`, - 'utf8' - ); - writeFileSync( - path.join(queryDir, 'boundary.ts'), - [ - "import { z } from 'zod';", - "import { mapListRowsToResult } from './generated/row-mapper.js';", - '', - 'const RowSchema = z.object({', - ' id: z.string(),', - ' email: z.string(),', - '}).strict();', - '', - 'const QueryResultSchema = z.object({', - ' items: z.array(RowSchema),', - '}).strict();', - '', - 'export type ListQueryResult = z.infer;', - 'export type ListRow = z.infer;', - '', - 'export async function executeListQuerySpec(rows: ListRow[]): Promise {', - ' return mapListRowsToResult(rows);', - '}', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(queryDir, 'list.sql'), - [ - 'select', - ' id,', - ' email', - 'from users', - 'order by id asc;', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync(path.join(queryDir, 'generated', 'row-mapper.ts'), '// stale generated mapper\n', 'utf8'); - return { rootDir, packageDir }; -} - -async function resetPublicSchema(client: Client) { - // Recreate the public schema so pg_dump sees a predictable set of objects. - await client.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;'); -} - -async function seedProductsTable(client: Client) { - // Create a simple products table that the CLI pull command should capture. - await client.query(` - CREATE TABLE public.products ( - id serial PRIMARY KEY, - name text NOT NULL, - price decimal - ); - `); -} - -test( - 'ztd-config CLI produces the expected ztd-row-map.generated.ts snapshot', - () => { - const ddlDir = createTempDir('cli-gen-ddl'); - writeFileSync( - path.join(ddlDir, 'tables.sql'), - ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - email text NOT NULL, - score numeric - ); - - CREATE TABLE public.sessions ( - id bigint PRIMARY KEY, - user_id int NOT NULL REFERENCES public.users(id), - expires_at timestamptz NOT NULL - ); - `, - 'utf8' - ); - - const outDir = createTempDir('cli-gen-out'); - const outputFile = path.join(outDir, 'ztd-row-map.generated.ts'); - const layoutFile = path.join(outDir, 'ztd-layout.generated.ts'); - const manifestFile = path.join(outDir, 'ztd-fixture-manifest.generated.ts'); - - const result = runCli(['ztd-config', '--ddl-dir', ddlDir, '--extensions', '.sql', '--out', outputFile]); - assertCliSuccess(result, 'ztd-config'); - - const content = readNormalizedFile(outputFile); - expect(content).toMatchSnapshot(); - const layoutContent = readNormalizedFile(layoutFile); - expect(layoutContent).toBe(`// GENERATED FILE. DO NOT EDIT. - -export default { - ztdRootDir: ".ztd", - ddlDir: "db/ddl", -}; -`); - const manifestContent = readNormalizedFile(manifestFile); - expect(manifestContent).toContain("import type { TableDefinitionModel } from 'rawsql-ts';"); - expect(manifestContent).toContain('export const generatedFixtureManifest'); - expect(manifestContent).not.toContain('tableRows:'); - }, - 60000, -); - -test( - 'ztd-config CLI accepts --json payload and emits a JSON envelope in global json mode', - () => { - const ddlDir = createTempDir('cli-gen-ddl-json'); - writeFileSync( - path.join(ddlDir, 'tables.sql'), - ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - email text NOT NULL - ); - `, - 'utf8' - ); - - const outDir = createTempDir('cli-gen-out-json'); - const outputFile = path.join(outDir, 'ztd-row-map.generated.ts'); - const manifestFile = path.join(outDir, 'ztd-fixture-manifest.generated.ts'); - const result = runCli([ - '--output', - 'json', - 'ztd-config', - '--json', - JSON.stringify({ - ddlDir, - extensions: '.sql', - out: outputFile, - dryRun: true - }) - ]); - - assertCliSuccess(result, 'ztd-config json'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'ztd-config', - ok: true, - data: { - dryRun: true, - outputs: expect.arrayContaining([ - expect.objectContaining({ path: outputFile, written: false }), - expect.objectContaining({ - path: manifestFile, - written: false - }) - ]) - } - }); - expect(existsSync(outputFile)).toBe(false); - expect(existsSync(manifestFile)).toBe(false); - }, - 60000, -); - -test( - 'top-level help exposes model-gen as a first-class command', - () => { - const result = runCli(['--help']); - assertCliSuccess(result, '--help'); - expect(result.stdout).toContain('Getting started'); - expect(result.stdout).toContain('model-gen [options] '); - expect(result.stdout).toContain('feature'); - expect(result.stdout).toContain('feature generated-mapper check --feature users-insert'); - expect(result.stdout).toContain('feature query scaffold --feature users-insert'); - }, - 60000, -); - -test( - 'feature scaffold help exposes the CRUD boundary scaffold contract', - () => { - const result = runCli(['feature', 'scaffold', '--help']); - assertCliSuccess(result, 'feature scaffold --help'); - expect(result.stdout).toContain('--table
'); - expect(result.stdout).toContain('--action '); - expect(result.stdout).toContain('--feature-name '); - expect(result.stdout).toContain('--insert-default-policy '); - expect(result.stdout).toContain('--dry-run'); - expect(result.stdout).toContain('--force'); - expect(result.stdout).toMatch(/insert,\s+update,\s+delete,\s+get-by-id,\s+and\s+list/); - }, - 60000, -); - -test( - 'feature tests scaffold help exposes the TODO-based test scaffold contract', - () => { - const result = runCli(['feature', 'tests', 'scaffold', '--help']); - assertCliSuccess(result, 'feature tests scaffold --help'); - expect(result.stdout).toContain('--feature '); - expect(result.stdout).toContain('--query '); - expect(result.stdout).toContain('--test-kind '); - expect(result.stdout).toContain('--dry-run'); - expect(result.stdout).toContain('--force'); - expect(result.stdout).toContain('Refresh query-boundary generated analysis'); - expect(result.stdout).toContain('keep persistent case files untouched'); - }, - 60000, -); - -test( - 'feature generated-mapper check help exposes drift detection', - () => { - const result = runCli(['feature', 'generated-mapper', 'check', '--help']); - assertCliSuccess(result, 'feature generated-mapper check --help'); - expect(result.stdout).toContain('--feature '); - expect(result.stdout).toContain('--query '); - expect(result.stdout).toContain('drift'); - }, - 60000, -); - -test( - 'describe command reports feature generated-mapper check metadata in global json mode', - () => { - const result = runCli(['--output', 'json', 'describe', 'command', 'feature generated-mapper check']); - - assertCliSuccess(result, 'describe feature generated-mapper check'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'describe command', - ok: true, - data: { - command: { - name: 'feature generated-mapper check', - writesFiles: false, - supportsDryRun: false - } - } - }); - const flags = parsed.data.command.flags.map((flag: { name: string }) => flag.name); - expect(flags).toEqual(expect.arrayContaining(['--feature ', '--query '])); - }, - 60000, -); - -test( - 'feature generated-mapper generate help exposes machine-owned refresh', - () => { - const result = runCli(['feature', 'generated-mapper', 'generate', '--help']); - assertCliSuccess(result, 'feature generated-mapper generate --help'); - expect(result.stdout).toContain('--feature '); - expect(result.stdout).toContain('--query '); - expect(result.stdout).toContain('--dry-run'); - expect(result.stdout).toContain('Regenerate machine-owned'); - }, - 60000, -); - -test( - 'describe command reports feature generated-mapper generate metadata in global json mode', - () => { - const result = runCli(['--output', 'json', 'describe', 'command', 'feature generated-mapper generate']); - - assertCliSuccess(result, 'describe feature generated-mapper generate'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'describe command', - ok: true, - data: { - command: { - name: 'feature generated-mapper generate', - writesFiles: true, - supportsDryRun: true, - summary: 'Regenerate machine-owned RFBA query row mappers from query boundary contracts.' - } - } - }); - const flags = parsed.data.command.flags.map((flag: { name: string }) => flag.name); - expect(flags).toEqual(expect.arrayContaining(['--feature ', '--query ', '--dry-run'])); - }, - 60000, -); - -test( - 'repository generated mapper drift check fails on stale mapper and passes after regeneration', - () => { - ensureBuiltCli(); - const { rootDir, packageDir } = createGeneratedMapperDriftWorkspace('generated-mapper-drift-script'); - const failed = spawnSync(nodeExecutable, [generatedMapperDriftScript], { - cwd: repoRoot, - env: buildCliEnv({ - GENERATED_MAPPER_DRIFT_ROOT: rootDir - }), - encoding: 'utf8' - }); - - assertCliFailure(failed, 'generated mapper drift script stale fixture'); - expect(failed.stdout.replace(/\\/g, '/')).toContain('[generated-mapper-drift] checking fixture/src/features/users-list'); - expect(failed.stderr).toContain('ztd feature generated-mapper generate --feature users-list --query list'); - - const regenerate = runCli(['feature', 'generated-mapper', 'generate', '--feature', 'users-list'], {}, packageDir); - assertCliSuccess(regenerate, 'feature generated-mapper generate fixture'); - - const passed = spawnSync(nodeExecutable, [generatedMapperDriftScript], { - cwd: repoRoot, - env: buildCliEnv({ - GENERATED_MAPPER_DRIFT_ROOT: rootDir - }), - encoding: 'utf8' - }); - - assertCliSuccess(passed, 'generated mapper drift script regenerated fixture'); - expect(passed.stdout.replace(/\\/g, '/')).toContain('[generated-mapper-drift] checking fixture/src/features/users-list'); - expect(passed.stdout).toContain('Generated mapper check passed: users-list'); - }, - 60000, -); - -test( - 'repository generated mapper drift check fails when a generated mapper has no owning ztd project', - () => { - const rootDir = createTempDir('generated-mapper-drift-no-owner'); - const generatedDir = path.join(rootDir, 'src', 'features', 'users-list', 'queries', 'list', 'generated'); - mkdirSync(generatedDir, { recursive: true }); - writeFileSync(path.join(generatedDir, 'row-mapper.ts'), '// generated mapper without owner\n', 'utf8'); - - const result = spawnSync(nodeExecutable, [generatedMapperDriftScript], { - cwd: repoRoot, - env: buildCliEnv({ - GENERATED_MAPPER_DRIFT_ROOT: rootDir - }), - encoding: 'utf8' - }); - - assertCliFailure(result, 'generated mapper drift script missing owning project'); - expect(result.stderr).toContain('no parent package.json with a ztd script was found'); - expect(result.stderr).toContain('Generated row mappers are machine-owned and must be passively checked'); - }, - 60000, -); - -test( - 'feature query scaffold help exposes the additive child-boundary scaffold contract', - () => { - const result = runCli(['feature', 'query', 'scaffold', '--help']); - assertCliSuccess(result, 'feature query scaffold --help'); - expect(result.stdout).toContain('--table
'); - expect(result.stdout).toContain('--action '); - expect(result.stdout).toContain('--query-name '); - expect(result.stdout).toContain('--feature '); - expect(result.stdout).toContain('--boundary-dir '); - expect(result.stdout).toContain('--insert-default-policy '); - expect(result.stdout).toContain('Scaffold one additive query boundary'); - expect(result.stdout).toContain('rewriting the parent boundary'); - }, - 60000, -); - -test( - 'feature scaffold dry-run emits JSON and reserves test files for AI follow-up', - () => { - const workspace = createTempDir('feature-scaffold-dry-run'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null,', - ' created_at timestamptz not null default now()', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - '--output', - 'json', - 'feature', - 'scaffold', - '--table', - 'users', - '--action', - 'insert', - '--dry-run' - ], {}, workspace); - - assertCliSuccess(result, 'feature scaffold dry-run json'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'feature scaffold', - ok: true, - data: { - featureName: 'users-insert', - action: 'insert', - table: 'public.users', - primaryKeyColumn: 'id', - source: 'ddl', - insertDefaultPolicy: 'explicit-defaults', - dryRun: true - } - }); - const plannedPaths = parsed.data.outputs.map((entry: { path: string }) => entry.path); - expect(plannedPaths).toEqual(expect.arrayContaining([ - 'src/features/_shared', - 'src/features/_shared/featureQueryExecutor.ts', - 'src/features/_shared/loadSqlResource.ts', - 'src/features/users-insert', - 'src/features/users-insert/boundary.ts', - 'src/features/users-insert/queries/insert-users', - 'src/features/users-insert/queries/insert-users/boundary.ts', - 'src/features/users-insert/queries/insert-users/insert-users.sql', - 'src/features/users-insert/tests', - 'src/features/users-insert/README.md' - ])); - expect(plannedPaths.some((entry: string) => entry.endsWith('.boundary.ztd.test.ts'))).toBe(false); - expect(plannedPaths.some((entry: string) => entry.endsWith('.boundary.test.ts'))).toBe(true); - }, - 60000, -); - -test( - 'feature query scaffold dry-run emits JSON and keeps parent orchestration as follow-up work', - () => { - const workspace = createTempDir('feature-query-scaffold-dry-run'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'sales-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(featureDir, { recursive: true }); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing parent boundary\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - '--output', - 'json', - 'feature', - 'query', - 'scaffold', - '--feature', - 'sales-insert', - '--query-name', - 'insert-sales-detail', - '--table', - 'sales_detail', - '--action', - 'insert', - '--dry-run' - ], {}, workspace); - - assertCliSuccess(result, 'feature query scaffold dry-run json'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'feature query scaffold', - ok: true, - data: { - boundaryPath: 'src/features/sales-insert', - resolutionSource: 'feature', - queryName: 'insert-sales-detail', - action: 'insert', - table: 'public.sales_detail', - primaryKeyColumn: 'id', - source: 'ddl', - insertDefaultPolicy: 'explicit-defaults', - dryRun: true - } - }); - const plannedPaths = parsed.data.outputs.map((entry: { path: string }) => entry.path); - expect(plannedPaths).toEqual(expect.arrayContaining([ - 'src/features/sales-insert/queries', - 'src/features/sales-insert/queries/insert-sales-detail', - 'src/features/sales-insert/queries/insert-sales-detail/boundary.ts', - 'src/features/sales-insert/queries/insert-sales-detail/insert-sales-detail.sql' - ])); - expect(plannedPaths).not.toContain('src/features/sales-insert/boundary.ts'); - }, - 60000, -); - -test( - 'feature query scaffold fails fast when query-name is missing', - () => { - const result = runCli(['feature', 'query', 'scaffold', '--table', 'sales_detail', '--action', 'insert']); - - assertCliFailure(result, 'feature query scaffold missing query-name'); - expect(result.stderr || result.stdout).toContain('required option'); - expect(result.stderr || result.stdout).toContain('--query-name '); - }, - 60000, -); - -test( - 'feature query scaffold fails fast when table is missing', - () => { - const result = runCli(['feature', 'query', 'scaffold', '--query-name', 'insert-sales-detail', '--action', 'insert']); - - assertCliFailure(result, 'feature query scaffold missing table'); - expect(result.stderr || result.stdout).toContain('required option'); - expect(result.stderr || result.stdout).toContain('--table
'); - }, - 60000, -); - -test( - 'feature query scaffold can omit DB-default columns from child INSERT SQL', - () => { - const workspace = createTempDir('feature-query-scaffold-omit-db-defaults'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'events-write'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(featureDir, { recursive: true }); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing parent boundary\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'events.sql'), - [ - 'create table public.events (', - ' id serial8 primary key,', - ' title text not null,', - ' created_at timestamptz not null default current_timestamp', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'query', - 'scaffold', - '--feature', - 'events-write', - '--query-name', - 'insert-events', - '--table', - 'events', - '--action', - 'insert', - '--insert-default-policy', - 'omit-db-defaults' - ], {}, workspace); - - assertCliSuccess(result, 'feature query scaffold omit db defaults'); - const sql = readNormalizedFile(path.join(workspace, 'src', 'features', 'events-write', 'queries', 'insert-events', 'insert-events.sql')); - expect(sql).toContain('"title"'); - expect(sql).not.toContain('"created_at"'); - expect(sql).not.toContain('current_timestamp'); - expect(sql).toContain('TODO: Review INSERT default-column policy'); - }, - 60000, -); - -test( - 'feature scaffold writes the boundary baseline without creating query-local test files', - () => { - const workspace = createTempDir('feature-scaffold-write'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null,', - ' created_at timestamptz not null default now()', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'users', - '--action', - 'insert' - ], {}, workspace); - - assertCliSuccess(result, 'feature scaffold write'); - expect(existsSync(path.join(workspace, 'src', 'features', '_shared'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', '_shared', 'loadSqlResource.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', '_shared', 'queryOneExact.ts'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', '_shared', 'featureQueryExecutor.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'tests'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'boundary.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'insert-users.sql'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'adapter-cli.ts'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'adapter-api.ts'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'adapter-lambda.ts'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'README.md'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'tests', 'insert-users.boundary.ztd.test.ts'))).toBe(false); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'insert-users.sql'))).toContain('returning "id";'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'insert-users.sql'))).toContain('TODO: Review INSERT default-column policy'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'boundary.ts'))).toContain( - 'export async function execute(' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'boundary.ts'))).toContain( - "import type { FeatureQueryExecutor } from '../_shared/featureQueryExecutor.js';" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'input.ts'))).toContain( - 'export interface UsersInsertRequest {' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'input.ts'))).toContain( - 'email: string;' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'input.ts'))).toContain( - "email: readString(record[\"email\"], 'UsersInsertRequest.email')" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'input.ts'))).toContain( - 'function parseRequest' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'workflow.ts'))).toContain( - 'function toQueryParams' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'boundary.ts'))).not.toContain( - 'QueryParamsSchema' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'boundary.ts'))).not.toContain( - 'InsertUsersQueryExecutor' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'boundary.ts'))).not.toContain( - 'export type UsersInsertEntryExecutor' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'workflow.ts'))).not.toContain( - 'created_at: request.created_at' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).toContain( - "const insertUsersSqlResource = loadSqlResource(__dirname, 'insert-users.sql');" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).toContain( - 'export interface InsertUsersQueryParams {' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).toContain( - "email: readString(record[\"email\"], 'InsertUsersQueryParams.email')" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).toContain( - "import type { FeatureQueryExecutor } from '../../../_shared/featureQueryExecutor.js';" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).not.toContain( - 'queryExactlyOneRow' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).toContain( - 'loadSingleRow' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).not.toContain( - 'export const insertUsersQueryParamsSchema' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).not.toContain( - 'export interface InsertUsersQueryContract' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).not.toContain( - "id: z.string().min(1, 'id must not be empty.')" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'))).not.toContain( - 'created_at: z.string().min(1' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'insert-users.sql'))).not.toContain( - ':id' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'insert-users.sql'))).not.toContain( - ':created_at' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'insert-users.sql'))).toContain( - 'now()' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', '_shared', 'featureQueryExecutor.ts'))).toContain( - 'export interface FeatureQueryExecutor {' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'README.md'))).toContain( - '`boundary.ts` is the default feature-boundary public surface' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'README.md'))).toContain( - 'When DDL declares a column default, the scaffold writes that default expression into SQL explicitly' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'README.md'))).toContain( - 'INSERT default-column policy: `explicit-defaults`' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'README.md'))).toContain( - 'TODO: Review this default-column policy' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'README.md'))).toContain( - 'Cardinality checks should stay as thin generated query-local helpers' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-insert', 'README.md'))).toContain( - 'Keep this baseline as one workflow and one primary query by default' - ); - }, - 60000, -); - -test( - 'feature scaffold preserves exact DDL defaults in explicit-defaults mode', - () => { - const workspace = createTempDir('feature-scaffold-explicit-defaults'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'events.sql'), - [ - 'create table public.events (', - ' id serial8 primary key,', - ' title text not null,', - ' created_at timestamptz not null default current_timestamp', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'events', - '--action', - 'insert', - '--insert-default-policy', - 'explicit-defaults' - ], {}, workspace); - - assertCliSuccess(result, 'feature scaffold explicit defaults'); - const sql = readNormalizedFile(path.join(workspace, 'src', 'features', 'events-insert', 'queries', 'insert-events', 'insert-events.sql')); - expect(sql).toContain('current_timestamp'); - expect(sql).not.toContain('now()'); - expect(sql).toContain('"created_at"'); - expect(sql).toContain('TODO: Review INSERT default-column policy'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'events-insert', 'README.md'))).toContain( - 'DDL-backed default expressions written directly into SQL: `created_at`.' - ); - }, - 60000, -); - -test( - 'feature scaffold can omit DB-default columns from INSERT', - () => { - const workspace = createTempDir('feature-scaffold-omit-db-defaults'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'events.sql'), - [ - 'create table public.events (', - ' id serial8 primary key,', - ' title text not null,', - ' created_at timestamptz not null default current_timestamp', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'events', - '--action', - 'insert', - '--insert-default-policy', - 'omit-db-defaults' - ], {}, workspace); - - assertCliSuccess(result, 'feature scaffold omit db defaults'); - const sql = readNormalizedFile(path.join(workspace, 'src', 'features', 'events-insert', 'queries', 'insert-events', 'insert-events.sql')); - const entryBoundary = readNormalizedFile(path.join(workspace, 'src', 'features', 'events-insert', 'boundary.ts')); - const queryBoundary = readNormalizedFile(path.join(workspace, 'src', 'features', 'events-insert', 'queries', 'insert-events', 'boundary.ts')); - const readme = readNormalizedFile(path.join(workspace, 'src', 'features', 'events-insert', 'README.md')); - - expect(sql).toContain('"title"'); - expect(sql).not.toContain('"created_at"'); - expect(sql).not.toContain('current_timestamp'); - expect(sql).toContain('TODO: Review INSERT default-column policy'); - expect(entryBoundary).not.toContain('created_at: request.created_at'); - expect(queryBoundary).not.toContain('created_at: z.string().min(1'); - expect(readme).toContain('INSERT default-column policy: `omit-db-defaults`'); - expect(readme).toContain('DB-default columns omitted from INSERT so the database assigns them: `created_at`.'); - expect(readme).toContain('TODO: Review this default-column policy'); - }, - 60000, -); - -test( - 'feature scaffold rejects composite primary keys in v1', - () => { - const workspace = createTempDir('feature-scaffold-composite-pk'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'orders.sql'), - [ - 'create table public.orders (', - ' order_id bigint not null,', - ' line_no bigint not null,', - ' primary key (order_id, line_no)', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'orders', - '--action', - 'insert' - ], {}, workspace); - - assertCliFailure(result, 'feature scaffold composite pk'); - expect(result.stderr || result.stdout).toContain('Composite primary keys are not supported in v1'); - }, - 60000, -); - -test( - 'feature scaffold writes the update boundary baseline', - () => { - const workspace = createTempDir('feature-scaffold-update-cli'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null,', - ' display_name text', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'users', - '--action', - 'update' - ], {}, workspace); - - assertCliSuccess(result, 'feature scaffold update write'); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-update', 'boundary.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-update', 'queries', 'update-users', 'boundary.ts'))).toBe(true); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-update', 'queries', 'update-users', 'update-users.sql'))).toContain('update "public"."users"'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-update', 'queries', 'update-users', 'update-users.sql'))).toContain('"id" = :id'); - }, - 60000, -); - -test( - 'feature scaffold writes the delete boundary baseline', - () => { - const workspace = createTempDir('feature-scaffold-delete-cli'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'users', - '--action', - 'delete' - ], {}, workspace); - - assertCliSuccess(result, 'feature scaffold delete write'); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-delete', 'boundary.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-delete', 'queries', 'delete-users', 'boundary.ts'))).toBe(true); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-delete', 'queries', 'delete-users', 'delete-users.sql'))).toContain('delete from "public"."users"'); - }, - 60000, -); - -test( - 'feature scaffold writes the get-by-id boundary baseline', - () => { - const workspace = createTempDir('feature-scaffold-get-by-id-cli'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'users', - '--action', - 'get-by-id' - ], {}, workspace); - - assertCliSuccess(result, 'feature scaffold get-by-id write'); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-get-by-id', 'boundary.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-get-by-id', 'input.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-get-by-id', 'workflow.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-get-by-id', 'output.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-get-by-id', 'queries', 'get-by-id', 'boundary.ts'))).toBe(true); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'boundary.ts'))).toContain('export async function execute('); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'input.ts'))).toContain('export interface UsersGetByIdRequest {'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'input.ts'))).toContain('function parseRequest'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'workflow.ts'))).toContain('function toQueryParams'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'output.ts'))).toContain('id: result.id'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'queries', 'get-by-id', 'boundary.ts'))).toContain('loadOptionalRow'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'queries', 'get-by-id', 'boundary.ts'))).not.toContain('queryZeroOrOneRow'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'queries', 'get-by-id', 'boundary.ts'))).toContain('export interface GetByIdQueryParams {'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-get-by-id', 'queries', 'get-by-id', 'boundary.ts'))).toContain('export interface GetByIdRow {'); - }, - 60000, -); - -test( - 'feature scaffold writes the list boundary baseline', - () => { - const workspace = createTempDir('feature-scaffold-list-cli'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'users', - '--action', - 'list' - ], {}, workspace); - - assertCliSuccess(result, 'feature scaffold list write'); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-list', 'boundary.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-list', 'input.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-list', 'workflow.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-list', 'output.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-list', 'queries', 'list', 'boundary.ts'))).toBe(true); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'boundary.ts'))).toContain('export async function execute('); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'input.ts'))).toContain('export type UsersListRequest = {};'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'input.ts'))).toContain('function parseRequest'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'workflow.ts'))).toContain('function toQueryParams'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'output.ts'))).toContain('items: result.items.map'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'queries', 'list', 'boundary.ts'))).not.toContain('createCatalogExecutor'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'queries', 'list', 'boundary.ts'))).toContain('export type ListQueryParams = {};'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'queries', 'list', 'boundary.ts'))).toContain('export interface ListRow {'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'users-list', 'queries', 'list', 'list.sql'))).toContain('limit :limit;'); - }, - 60000, -); - -test( - 'feature scaffold preserves existing files unless --force is provided', - () => { - const workspace = createTempDir('feature-scaffold-existing-file'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(path.join(featureDir, 'queries', 'insert-users'), { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing\n', 'utf8'); - - const result = runCli([ - 'feature', - 'scaffold', - '--table', - 'users', - '--action', - 'insert' - ], {}, workspace); - - assertCliFailure(result, 'feature scaffold existing file'); - expect(result.stderr || result.stdout).toContain('Re-run with --force'); - }, - 60000, -); - -test( - 'query help exposes the new sssql scaffold and refresh commands', - () => { - const result = runCli(['query', 'sssql', '--help']); - assertCliSuccess(result, 'query sssql --help'); - expect(result.stdout).toContain('list [options] '); - expect(result.stdout).toContain('scaffold [options] '); - expect(result.stdout).toContain('refresh [options] '); - expect(result.stdout).toContain('remove [options] '); - }, - 60000, -); - -test( - 'query sssql scaffold emits JSON output and writes the formatted SQL file', - () => { - const workspace = createSqlWorkspace('query-sssql-scaffold', path.join('src', 'sql', 'users.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT u.id, u.status - FROM users u - WHERE u.active = true - `, - 'utf8' - ); - - const outFile = path.join(workspace.rootDir, 'users.sssql.sql'); - const result = runCli( - [ - 'query', - 'sssql', - 'scaffold', - workspace.sqlFile, - '--format', - 'json', - '--json', - JSON.stringify({ - filters: { status: 'premium' } - }), - '--out', - outFile - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query sssql scaffold'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'query sssql scaffold', - ok: true, - data: { - file: workspace.sqlFile, - output_file: outFile, - written: true - } - }); - - const contents = readFileSync(outFile, 'utf8').replace(/\r\n/g, '\n').trim().toLowerCase(); - expect(contents).toContain('(:status is null or "u"."status" = :status)'); - }, - 60000, -); - -test( - 'query sssql refresh rewrites existing optional branches without changing their meaning', - () => { - const workspace = createSqlWorkspace('query-sssql-refresh', path.join('src', 'sql', 'users.sql')); - writeFileSync( - workspace.sqlFile, - ` - WITH user_data AS ( - SELECT u.id, u.status - FROM users u - ) - SELECT ud.id - FROM user_data ud - WHERE (:status IS NULL OR ud.status = :status) - `, - 'utf8' - ); - - const outFile = path.join(workspace.rootDir, 'users.refreshed.sql'); - const result = runCli( - [ - 'query', - 'sssql', - 'refresh', - workspace.sqlFile, - '--out', - outFile - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query sssql refresh'); - expect(result.stdout.trim()).toBe(''); - - const contents = readFileSync(outFile, 'utf8').replace(/\r\n/g, '\n').toLowerCase(); - expect(contents).toContain('with "user_data" as (select "u"."id", "u"."status" from "users" as "u" where (:status is null or "u"."status" = :status))'); - expect(contents).not.toContain('ud"."status = :status'); - }, - 60000, -); - -test( - 'query sssql scaffold overwrites the input file by default on non-preview runs', - () => { - const workspace = createSqlWorkspace('query-sssql-scaffold-overwrite', path.join('src', 'sql', 'users.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT u.id, u.status - FROM users u - `, - 'utf8' - ); - - const result = runCli( - [ - 'query', - 'sssql', - 'scaffold', - workspace.sqlFile, - '--format', - 'json', - '--json', - JSON.stringify({ - filters: { status: 'premium' } - }) - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query sssql scaffold overwrite'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'query sssql scaffold', - ok: true, - data: { - file: workspace.sqlFile, - output_file: workspace.sqlFile, - written: true - } - }); - - const contents = readNormalizedFile(workspace.sqlFile).toLowerCase(); - expect(contents).toContain('(:status is null or "u"."status" = :status)'); - }, - 60000, -); - -test( - 'query sssql refresh accepts a JSON payload for machine-readable automation', - () => { - const workspace = createSqlWorkspace('query-sssql-refresh-json', path.join('src', 'sql', 'users.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT u.id, u.status - FROM users u - WHERE (:status IS NULL OR u.status = :status) - `, - 'utf8' - ); - - const outFile = path.join(workspace.rootDir, 'users.refreshed.sql'); - const result = runCli( - [ - 'query', - 'sssql', - 'refresh', - workspace.sqlFile, - '--format', - 'json', - '--json', - JSON.stringify({ - out: outFile - }) - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query sssql refresh json'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'query sssql refresh', - ok: true, - data: { - file: workspace.sqlFile, - output_file: outFile, - written: true - } - }); - - const contents = readFileSync(outFile, 'utf8').replace(/\r\n/g, '\n').trim().toLowerCase(); - expect(contents).toContain('(:status is null or "u"."status" = :status)'); - }, - 60000, -); - -test( - 'query sssql scaffold -> refresh -> remove --all works for exists/not-exists branches', - () => { - const workspace = createSqlWorkspace('query-sssql-roundtrip-exists', path.join('src', 'sql', 'products.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT p.product_id, p.product_name - FROM products p - `, - 'utf8' - ); - - const authoredFile = path.join(workspace.rootDir, 'products.authored.sql'); - const scaffoldExists = runCli( - [ - 'query', - 'sssql', - 'scaffold', - workspace.sqlFile, - '--kind', - 'exists', - '--parameter', - 'category_name', - '--filter', - 'products.product_id', - '--query', - 'select 1 from product_categories pc where pc.product_id = $c0 and pc.category_name = :category_name', - '--out', - authoredFile - ], - {}, - workspace.rootDir - ); - assertCliSuccess(scaffoldExists, 'query sssql scaffold exists round-trip'); - - const scaffoldNotExists = runCli( - [ - 'query', - 'sssql', - 'scaffold', - authoredFile, - '--kind', - 'not-exists', - '--parameter', - 'archived_name', - '--filter', - 'products.product_id', - '--query', - 'select 1 from archived_products ap where ap.product_id = $c0 and ap.product_name = :archived_name' - ], - {}, - workspace.rootDir - ); - assertCliSuccess(scaffoldNotExists, 'query sssql scaffold not-exists round-trip'); - - const refreshedFile = path.join(workspace.rootDir, 'products.refreshed.sql'); - const refresh = runCli( - ['query', 'sssql', 'refresh', authoredFile, '--out', refreshedFile], - {}, - workspace.rootDir - ); - assertCliSuccess(refresh, 'query sssql refresh round-trip'); - - const refreshed = readNormalizedFile(refreshedFile).toLowerCase(); - expect(refreshed).toContain(':category_name is null or exists'); - expect(refreshed).toContain(':archived_name is null or not exists'); - - const removedFile = path.join(workspace.rootDir, 'products.removed-all.sql'); - const removeAll = runCli( - ['query', 'sssql', 'remove', refreshedFile, '--all', '--out', removedFile], - {}, - workspace.rootDir - ); - assertCliSuccess(removeAll, 'query sssql remove --all round-trip'); - - const removed = readNormalizedFile(removedFile).toLowerCase(); - expect(removed).not.toContain(':category_name'); - expect(removed).not.toContain(':archived_name'); - expect(removed).not.toContain('exists'); - }, - 60000, -); - -test( - 'query sssql refresh relocates correlated exists once and stays idempotent on rerun', - () => { - const workspace = createSqlWorkspace('query-sssql-refresh-correlated', path.join('src', 'sql', 'products.sql')); - writeFileSync( - workspace.sqlFile, - ` - WITH base_products AS ( - SELECT p.product_id, p.product_name - FROM products p - ) - SELECT bp.product_id - FROM base_products bp - WHERE ( - :category_name IS NULL - OR EXISTS ( - SELECT 1 - FROM product_categories pc - WHERE pc.product_id = bp.product_id - AND pc.category_name = :category_name - ) - ) - `, - 'utf8' - ); - - const onceFile = path.join(workspace.rootDir, 'products.refreshed.once.sql'); - const first = runCli( - ['query', 'sssql', 'refresh', workspace.sqlFile, '--out', onceFile], - {}, - workspace.rootDir - ); - assertCliSuccess(first, 'query sssql refresh correlated first'); - - const once = readNormalizedFile(onceFile).toLowerCase(); - expect(once).toContain('with "base_products" as (select "p"."product_id", "p"."product_name" from "products" as "p" where (:category_name is null or exists'); - expect(once).toContain('"pc"."product_id" = "p"."product_id"'); - expect(once).not.toContain('from "base_products" as "bp" where (:category_name is null or exists'); - - const twiceFile = path.join(workspace.rootDir, 'products.refreshed.twice.sql'); - const second = runCli( - ['query', 'sssql', 'refresh', onceFile, '--format', 'json', '--out', twiceFile], - {}, - workspace.rootDir - ); - assertCliSuccess(second, 'query sssql refresh correlated second'); - const payload = JSON.parse(second.stdout); - expect(payload).toMatchObject({ - command: 'query sssql refresh', - ok: true, - data: { - changed: false, - written: true - } - }); - - expect(readNormalizedFile(twiceFile)).toBe(readNormalizedFile(onceFile)); - }, - 60000, -); - -test( - 'query sssql list reports discovered branches in json mode', - () => { - const workspace = createSqlWorkspace('query-sssql-list', path.join('src', 'sql', 'users.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT u.id, u.status - FROM users u - WHERE (:status IS NULL OR u.status = :status) - `, - 'utf8' - ); - - const result = runCli( - ['query', 'sssql', 'list', workspace.sqlFile, '--format', 'json'], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query sssql list'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'query sssql list', - ok: true, - data: { - file: workspace.sqlFile, - branch_count: 1, - branches: [ - expect.objectContaining({ - parameterName: 'status', - kind: 'scalar', - operator: '=', - }) - ] - } - }); - }, - 60000, -); - -test( - 'query sssql scaffold supports structured operator input and preview diff', - () => { - const workspace = createSqlWorkspace('query-sssql-operator-preview', path.join('src', 'sql', 'products.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT p.product_id, p.product_name - FROM products p - `, - 'utf8' - ); - - const result = runCli( - [ - 'query', - 'sssql', - 'scaffold', - workspace.sqlFile, - '--filter', - 'products.product_name', - '--parameter', - 'product_name', - '--operator', - 'ilike', - '--preview' - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query sssql scaffold preview'); - expect(result.stdout.toLowerCase()).toContain('---'); - expect(result.stdout.toLowerCase()).toContain('+++'); - expect(result.stdout.toLowerCase()).toContain('(:product_name is null or "p"."product_name" ilike :product_name)'); - expect(readNormalizedFile(workspace.sqlFile).toLowerCase()).not.toContain('ilike'); - }, - 60000, -); - -test( - 'query sssql scaffold supports structured exists authoring', - () => { - const workspace = createSqlWorkspace('query-sssql-exists', path.join('src', 'sql', 'products.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT p.product_id, p.product_name - FROM products p - `, - 'utf8' - ); - - const subqueryFile = path.join(workspace.rootDir, 'category-subquery.sql'); - writeFileSync( - subqueryFile, - ` - SELECT 1 - FROM product_categories pc - WHERE pc.product_id = $c0 - AND pc.category_name = :category_name - `, - 'utf8' - ); - - const outFile = path.join(workspace.rootDir, 'products.sssql.sql'); - const result = runCli( - [ - 'query', - 'sssql', - 'scaffold', - workspace.sqlFile, - '--format', - 'json', - '--filter', - 'products.product_id', - '--parameter', - 'category_name', - '--kind', - 'exists', - '--query-file', - subqueryFile, - '--out', - outFile - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query sssql scaffold exists'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'query sssql scaffold', - ok: true, - data: { - file: workspace.sqlFile, - output_file: outFile, - written: true - } - }); - - const contents = readNormalizedFile(outFile).toLowerCase(); - expect(contents).toContain(':category_name is null or exists'); - expect(contents).toContain('"pc"."product_id" = "p"."product_id"'); - }, - 60000, -); - -test( - 'query sssql remove removes one branch safely and remains idempotent', - () => { - const workspace = createSqlWorkspace('query-sssql-remove', path.join('src', 'sql', 'users.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT u.id, u.status - FROM users u - WHERE (:status IS NULL OR u.status = :status) - `, - 'utf8' - ); - - const removedFile = path.join(workspace.rootDir, 'users.removed.sql'); - const first = runCli( - [ - 'query', - 'sssql', - 'remove', - workspace.sqlFile, - '--parameter', - 'status', - '--out', - removedFile - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(first, 'query sssql remove'); - expect(readNormalizedFile(removedFile).toLowerCase()).not.toContain(':status'); - - const secondOut = path.join(workspace.rootDir, 'users.removed-twice.sql'); - const second = runCli( - [ - 'query', - 'sssql', - 'remove', - removedFile, - '--parameter', - 'status', - '--format', - 'json', - '--out', - secondOut - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(second, 'query sssql remove idempotent'); - const parsed = JSON.parse(second.stdout); - expect(parsed).toMatchObject({ - command: 'query sssql remove', - ok: true, - data: { - changed: false, - written: true - } - }); - expect(readNormalizedFile(secondOut)).toBe(readNormalizedFile(removedFile)); - }, - 60000, -); - -test( - 'query sssql remove --all removes every recognized branch in the query', - () => { - const workspace = createSqlWorkspace('query-sssql-remove-all', path.join('src', 'sql', 'products.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT p.product_id, p.product_name - FROM products p - WHERE (:product_name IS NULL OR p.product_name ILIKE :product_name) - AND ( - :category_name IS NULL - OR EXISTS ( - SELECT 1 - FROM product_categories pc - WHERE pc.product_id = p.product_id - AND pc.category_name = :category_name - ) - ) - `, - 'utf8' - ); - - const removedFile = path.join(workspace.rootDir, 'products.removed.sql'); - const result = runCli( - [ - 'query', - 'sssql', - 'remove', - workspace.sqlFile, - '--all', - '--format', - 'json', - '--out', - removedFile - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query sssql remove --all'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'query sssql remove', - ok: true, - data: { - changed: true, - written: true - } - }); - - const contents = readNormalizedFile(removedFile).toLowerCase(); - expect(contents).not.toContain(':product_name'); - expect(contents).not.toContain(':category_name'); - expect(contents).not.toContain('exists'); - }, - 60000, -); - -test( - 'query sssql remove --all rejects targeted remove flags', - () => { - const workspace = createSqlWorkspace('query-sssql-remove-all-invalid', path.join('src', 'sql', 'users.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT u.id, u.status - FROM users u - WHERE (:status IS NULL OR u.status = :status) - `, - 'utf8' - ); - - const result = runCli( - [ - 'query', - 'sssql', - 'remove', - workspace.sqlFile, - '--all', - '--parameter', - 'status' - ], - {}, - workspace.rootDir - ); - - assertCliFailure(result, 'query sssql remove --all invalid'); - expect(result.stderr || result.stdout).toContain('Use --all by itself'); - }, - 60000, -); - -test( - 'query sssql scaffold fails fast when rewrite would drop SQL comments', - () => { - const workspace = createSqlWorkspace('query-sssql-comment-guard', path.join('src', 'sql', 'users.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT - u.id, -- keep me - u.status - FROM users u - `, - 'utf8' - ); - - const result = runCli( - [ - 'query', - 'sssql', - 'scaffold', - workspace.sqlFile, - '--filter', - 'users.status', - '--parameter', - 'status', - '--operator', - '=' - ], - {}, - workspace.rootDir - ); - - assertCliFailure(result, 'query sssql scaffold comment guard'); - expect(result.stderr || result.stdout).toContain('would drop SQL comments'); - }, - 60000, -); - -test( - 'query match-observed ranks the likely source asset for observed SELECT SQL', - () => { - const workspace = createSqlWorkspace('query-match-observed', path.join('src', 'sql', 'users', 'list.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT account.user_id, account.email - FROM public.users account - WHERE (:active IS NULL OR account.active = :active) - ORDER BY account.created_at DESC - LIMIT :limit - `, - 'utf8' - ); - mkdirSync(path.join(workspace.rootDir, 'src', 'sql', 'products'), { recursive: true }); - writeFileSync( - path.join(workspace.rootDir, 'src', 'sql', 'products', 'list.sql'), - ` - SELECT product.product_id, product.name - FROM public.products product - WHERE product.active = true - ORDER BY product.created_at DESC - `, - 'utf8' - ); - const result = runCli( - [ - 'query', - 'match-observed', - '--sql', - ` - SELECT u.user_id, u.email - FROM public.users u - WHERE u.active = true - ORDER BY u.created_at DESC - LIMIT 25 - `, - '--format', - 'json' - ], - { ZTD_PROJECT_ROOT: workspace.rootDir }, - workspace.rootDir - ); - - assertCliSuccess(result, 'query match-observed'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - schemaVersion: 1, - observedQueries: 1 - }); - expect(parsed.matches.length).toBeGreaterThan(0); - expect(parsed.matches[0]).toMatchObject({ - sql_file: 'src/sql/users/list.sql', - section_scores: expect.objectContaining({ - projection: expect.any(Number), - source: expect.any(Number), - where: expect.any(Number), - order: expect.any(Number), - paging: expect.any(Number) - }) - }); - expect(parsed.matches[0].reasons.join(' ')).toContain('projection matches exactly'); - expect(Array.isArray(parsed.matches[0].differences)).toBe(true); - }, - 60000, -); - -test( - 'query match-observed exits non-zero when no candidate SELECT assets are found', - () => { - const workspaceRoot = createTempDir('query-match-observed-empty'); - const observedDir = createTempDir('query-match-observed-observed'); - const sqlFile = path.join(observedDir, 'observed.sql'); - writeFileSync(sqlFile, 'SELECT 1', 'utf8'); - - const result = runCli( - ['query', 'match-observed', '--sql-file', sqlFile, '--format', 'text'], - { ZTD_PROJECT_ROOT: workspaceRoot }, - workspaceRoot - ); - - assertCliFailure(result, 'query match-observed no candidates'); - expect(result.stderr).toContain('No candidate SELECT assets were found for the observed SQL.'); - }, - 60000, -); - -test( - 'describe command returns machine-readable metadata with global json output', - () => { - const result = runCli(['--output', 'json', 'describe', 'command', 'model-gen']); - assertCliSuccess(result, 'describe command'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - schemaVersion: 1, - command: 'describe command', - ok: true - }); - }, - 60000, -); - -test( - 'check contract honors global json output without a local --format override', - () => { - const workspace = createTempDir('check-contract-global-json'); - mkdirSync(path.join(workspace, 'src', 'catalog', 'specs'), { recursive: true }); - mkdirSync(path.join(workspace, 'src', 'sql'), { recursive: true }); - writeFileSync( - path.join(workspace, 'src', 'catalog', 'specs', 'ok.spec.json'), - JSON.stringify({ - id: 'users.list', - sqlFile: '../../sql/users.list.sql', - params: { shape: 'named', example: { status: 'active' } } - }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(workspace, 'src', 'sql', 'users.list.sql'), - 'select user_id from users where status = :status', - 'utf8' - ); - - const result = runCli(['--output', 'json', 'check', 'contract'], { ZTD_PROJECT_ROOT: workspace }, workspace); - - assertCliSuccess(result, 'check contract global json'); - const parsed = JSON.parse(result.stdout); - expect(parsed.ok).toBe(true); - expect(parsed.violations).toEqual([]); - }, - 60000, -); - -test( - 'lint CLI accepts --json payload and emits a JSON envelope in global json mode', - () => { - const workspace = createSqlWorkspace('lint-json-cli'); - writeFileSync(workspace.sqlFile, 'select 1 as value', 'utf8'); - writeFileSync( - path.join(workspace.rootDir, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - mkdirSync(path.join(workspace.rootDir, 'db', 'ddl'), { recursive: true }); - writeFileSync( - path.join(workspace.rootDir, 'db', 'ddl', 'public.sql'), - 'CREATE TABLE public.users (id integer PRIMARY KEY);', - 'utf8' - ); - - const result = runCli( - ['--output', 'json', 'lint', '--json', JSON.stringify({ path: workspace.sqlFile })], - { - ZTD_DB_URL: 'postgres://127.0.0.1:1/invalid', - DATABASE_URL: '' - }, - workspace.rootDir - ); - - assertCliFailure(result, 'lint json'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'lint', - ok: false, - data: { - filesChecked: 0, - error: expect.stringContaining('Failed to connect to PostgreSQL for ztd lint.') - } - }); - expect(result.stderr).toContain('Failed to connect to PostgreSQL for ztd lint.'); - }, - 60000, -); - -test('init rejects non-boolean dryRun in --json payload', { timeout: 60_000 }, () => { - const workspace = createTempDir('init-json-dryrun-boolean-validation'); - const result = runCli([ - 'init', - '--json', - JSON.stringify({ - workflow: 'demo', - validator: 'zod', - dryRun: 'false' - }) - ], {}, workspace); - - assertCliFailure(result, 'init json dryRun boolean validation'); - expect(result.stderr).toContain('Invalid --dry-run value in --json payload. Expected a boolean.'); -}); - -test('init rejects non-boolean force in --json payload', { timeout: 60_000 }, () => { - const workspace = createTempDir('init-json-boolean-validation'); - const result = runCli([ - 'init', - '--dry-run', - '--json', - JSON.stringify({ - workflow: 'demo', - validator: 'zod', - force: 'false' - }) - ], {}, workspace); - - assertCliFailure(result, 'init json force boolean validation'); - expect(result.stderr).toContain('Invalid --force value in --json payload. Expected a boolean.'); -}); - -test('init CLI does not scaffold AI control files', { timeout: 60_000 }, () => { - const workspace = createTempDir('init-default-no-ai-guidance'); - const result = runCli(['init', '--yes', '--workflow', 'empty', '--validator', 'zod'], {}, workspace); - - assertCliSuccess(result, 'init without ai control files'); - expect(result.stdout).not.toContain('Internal guidance is managed under .ztd/agents/.'); - expect(existsSync(path.join(workspace, '.ztd', 'agents', 'manifest.json'))).toBe(false); - expect(existsSync(path.join(workspace, '.ztd', 'agents', 'root.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'CONTEXT.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'PROMPT_DOGFOOD.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'AGENTS_ztd.md'))).toBe(false); - expect(existsSync(path.join(workspace, '.codex', 'config.toml'))).toBe(false); -}, -60000, -); - -test('top-level help exposes perf init as an opt-in sandbox workflow', () => { - const result = runCli(['--help']); - - assertCliSuccess(result, '--help perf'); - expect(result.stdout).toContain('perf init'); - expect(result.stdout).toContain('opt-in perf sandbox'); -}); - -test('describe command reports perf init metadata in global json mode', () => { - const result = runCli(['--output', 'json', 'describe', 'command', 'perf init']); - - assertCliSuccess(result, 'describe perf init'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'describe command', - ok: true, - data: { - command: { - name: 'perf init', - supportsDryRun: true, - supportsJsonPayload: true, - writesFiles: true - } - } - }); -}); - -test('describe command reports feature query scaffold metadata in global json mode', () => { - const result = runCli(['--output', 'json', 'describe', 'command', 'feature query scaffold']); - - assertCliSuccess(result, 'describe feature query scaffold'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'describe command', - ok: true, - data: { - command: { - name: 'feature query scaffold', - supportsDryRun: true, - writesFiles: true - } - } - }); - expect(parsed.data.command.flags).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: '--feature ' }), - expect.objectContaining({ name: '--boundary-dir ' }), - expect.objectContaining({ name: '--dry-run' }) - ])); -}); - -test('perf init dry-run emits the planned sandbox scaffold in global json mode', () => { - const workspace = createTempDir('perf-init-dry-run'); - const result = runCli(['--output', 'json', 'perf', 'init', '--dry-run'], {}, workspace); - - assertCliSuccess(result, 'perf init dry-run'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'perf init', - ok: true, - data: { - dryRun: true, - files: expect.arrayContaining([ - 'perf/sandbox.json', - 'perf/seed.yml', - 'perf/docker-compose.yml' - ]) - } - }); - expect(existsSync(path.join(workspace, 'perf', 'sandbox.json'))).toBe(false); -}); - -test('perf init writes the sandbox scaffold files', () => { - const workspace = createTempDir('perf-init-write'); - const result = runCli(['perf', 'init'], {}, workspace); - - assertCliSuccess(result, 'perf init'); - expect(result.stdout).toContain('Perf sandbox initialized.'); - expect(existsSync(path.join(workspace, 'perf', 'sandbox.json'))).toBe(true); - expect(readNormalizedFile(path.join(workspace, 'perf', 'seed.yml'))).toContain('seed: 496'); - expect(readNormalizedFile(path.join(workspace, 'perf', 'docker-compose.yml'))).toContain('perf-db'); -}); - -test('perf db reset dry-run lists DDL files without touching Docker', () => { - const workspace = createTempDir('perf-reset-dry-run'); - mkdirSync(path.join(workspace, 'db', 'ddl'), { recursive: true }); - writeFileSync( - path.join(workspace, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ztdRootDir: '.ztd', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - writeFileSync(path.join(workspace, 'db', 'ddl', 'public.sql'), ['create table public.users (id integer primary key);', 'create index users_id_idx on public.users(id);', ''].join('\n'), 'utf8'); - - const result = runCli(['--output', 'json', 'perf', 'db', 'reset', '--dry-run'], {}, workspace); - - assertCliSuccess(result, 'perf db reset dry-run'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data).toMatchObject({ - dryRun: true, - ddl_file_count: 1, - ddl_statement_count: 2, - table_count: 1, - index_count: 1, - index_names: ['users_id_idx'], - ddl_files: ['db/ddl/public.sql'] - }); -}); - - - -test('perf db reset dry-run fails fast when the configured DDL directory is missing', () => { - const workspace = createTempDir('perf-reset-missing-ddl'); - writeFileSync( - path.join(workspace, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - - const result = runCli(['perf', 'db', 'reset', '--dry-run'], {}, workspace); - - assertCliFailure(result, 'perf db reset dry-run missing ddl'); - expect(result.stderr).toContain('Perf DDL directory does not exist:'); -}); -test('perf seed output redacts connection credentials in global json mode', () => { - const workspace = createTempDir('perf-seed-redact'); - mkdirSync(path.join(workspace, 'db', 'ddl'), { recursive: true }); - mkdirSync(path.join(workspace, 'perf'), { recursive: true }); - writeFileSync( - path.join(workspace, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - writeFileSync(path.join(workspace, 'db', 'ddl', 'public.sql'), ['create table public.users (id integer primary key);', 'create index users_id_idx on public.users(id);', ''].join('\n'), 'utf8'); - writeFileSync(path.join(workspace, 'perf', 'seed.yml'), [ - 'seed: 999', - 'tables:', - ' users:', - ' rows: 1', - 'columns:', - '' - ].join('\n'), 'utf8'); - - const result = runCli( - ['--output', 'json', 'perf', 'seed'], - { ZTD_DB_URL: 'postgres://perf_user:perf_pass@127.0.0.1:1/ztd_perf' }, - workspace - ); - - assertCliFailure(result, 'perf seed redaction'); - expect(result.stderr).not.toContain('perf_pass'); -}); -test('perf db reset refuses implicit DATABASE_URL without explicit ZTD test opt-in', () => { - const workspace = createTempDir('perf-reset-safety'); - mkdirSync(path.join(workspace, 'db', 'ddl'), { recursive: true }); - writeFileSync( - path.join(workspace, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - writeFileSync(path.join(workspace, 'db', 'ddl', 'public.sql'), ['create table public.users (id integer primary key);', 'create index users_id_idx on public.users(id);', ''].join('\n'), 'utf8'); - - const result = runCli( - ['perf', 'db', 'reset'], - { DATABASE_URL: 'postgres://app.example/db', ZTD_DB_URL: '' }, - workspace - ); - - assertCliFailure(result, 'perf db reset safety'); - expect(result.stderr).toContain('Perf sandbox ignores DATABASE_URL'); - expect(result.stderr).toContain('ZTD_DB_URL'); -}); - -test('perf seed dry-run rejects unknown tables from perf seed config', () => { - const workspace = createTempDir('perf-seed-invalid-table'); - mkdirSync(path.join(workspace, 'db', 'ddl'), { recursive: true }); - mkdirSync(path.join(workspace, 'perf'), { recursive: true }); - writeFileSync( - path.join(workspace, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - writeFileSync(path.join(workspace, 'db', 'ddl', 'public.sql'), ['create table public.users (id integer primary key);', 'create index users_id_idx on public.users(id);', ''].join('\n'), 'utf8'); - writeFileSync(path.join(workspace, 'perf', 'seed.yml'), [ - 'seed: 999', - 'tables:', - ' missing_table:', - ' rows: 3', - 'columns:', - '' - ].join('\n'), 'utf8'); - - const result = runCli(['perf', 'seed', '--dry-run'], {}, workspace); - - assertCliFailure(result, 'perf seed dry-run invalid table'); - expect(result.stderr).toContain('No table definition found for perf seed table: missing_table'); -}); -test('perf seed dry-run reports deterministic row counts from perf seed config', () => { - const workspace = createTempDir('perf-seed-dry-run'); - mkdirSync(path.join(workspace, 'db', 'ddl'), { recursive: true }); - mkdirSync(path.join(workspace, 'perf'), { recursive: true }); - writeFileSync( - path.join(workspace, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - writeFileSync(path.join(workspace, 'db', 'ddl', 'public.sql'), [ - 'create table public.users (', - ' id integer primary key,', - ' status text not null,', - ' score numeric', - ');' - ].join('\n'), 'utf8'); - writeFileSync(path.join(workspace, 'perf', 'seed.yml'), [ - 'seed: 999', - 'tables:', - ' users:', - ' rows: 3', - 'columns:', - ' public.users.status:', - ' values: [active, inactive]', - ' skew: 0.8', - '' - ].join('\n'), 'utf8'); - - const result = runCli(['--output', 'json', 'perf', 'seed', '--dry-run'], {}, workspace); - - assertCliSuccess(result, 'perf seed dry-run'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data).toMatchObject({ - dryRun: true, - seed: 999, - tables: { - 'public.users': 3 - } - }); -}); -test('query outline summarizes CTE dependencies and unused CTEs', () => { - const workspace = createSqlWorkspace('query-outline', path.join('src', 'sql', 'reports', 'ranked_users.sql')); - writeFileSync( - workspace.sqlFile, - ` - with users_base as ( - select id, region_id from public.users - ), - filtered_users as ( - select id from users_base where region_id in ( - select id from public.regions where active = true - ) - ), - purchase_summary as ( - select o.user_id, count(*) as order_count - from public.orders o - join filtered_users fu on fu.id = o.user_id - group by o.user_id - ), - ranked_users as ( - select ps.user_id, ps.order_count - from purchase_summary ps - ), - unused_cte as ( - select * from public.audit_log - ) - select * from ranked_users - `, - 'utf8' - ); - - const result = runCli(['query', 'outline', workspace.sqlFile], {}, workspace.rootDir); - assertCliSuccess(result, 'query outline'); - expect(result.stdout).toContain('Query type: SELECT'); - expect(result.stdout).toContain('CTE count: 5'); - expect(result.stdout).toContain('4. ranked_users'); - expect(result.stdout).toContain('5. unused_cte [unused]'); - expect(result.stdout).toContain('depends_on: purchase_summary'); - expect(result.stdout).toContain('Final query target:'); - expect(result.stdout).toContain('ranked_users'); - expect(result.stdout).toContain('public.audit_log'); - expect(result.stdout).toContain('Unused CTEs:'); - expect(result.stdout).toContain('unused_cte'); -}); - -test('query graph defaults to text output', () => { - const workspace = createSqlWorkspace('query-graph-text', path.join('src', 'sql', 'graph.sql')); - writeFileSync( - workspace.sqlFile, - ` - with base_data as ( - select id from public.users - ), - final_data as ( - select id from base_data - ) - select * from final_data - `, - 'utf8' - ); - - const result = runCli(['query', 'graph', workspace.sqlFile], {}, workspace.rootDir); - assertCliSuccess(result, 'query graph text'); - expect(result.stdout).toContain('Query type: SELECT'); - expect(result.stdout).toContain('Final query target:'); - expect(result.stdout).toContain('final_data'); -}); - -test('query graph emits machine-readable JSON when requested', () => { - const workspace = createSqlWorkspace('query-graph-json', path.join('src', 'sql', 'graph.sql')); - writeFileSync( - workspace.sqlFile, - ` - with base_data as ( - select id from public.users - ), - filtered_data as ( - select id from base_data - ), - unused_data as ( - select id from public.audit_log - ) - select * from filtered_data - `, - 'utf8' - ); - - const result = runCli(['query', 'graph', workspace.sqlFile, '--format', 'json'], {}, workspace.rootDir); - assertCliSuccess(result, 'query graph json'); - const payload = JSON.parse(result.stdout); - expect(payload.query_type).toBe('SELECT'); - expect(payload.final_query).toBe('filtered_data'); - expect(payload.unused_ctes).toEqual(['unused_data']); - expect(payload.ctes).toEqual([ - { - name: 'base_data', - depends_on: [], - used_by_final_query: true, - unused: false - }, - { - name: 'filtered_data', - depends_on: ['base_data'], - used_by_final_query: true, - unused: false - }, - { - name: 'unused_data', - depends_on: [], - used_by_final_query: false, - unused: true - } - ]); -}); - -test('query graph preserves multiple direct roots in final_query', () => { - const workspace = createSqlWorkspace('query-graph-multi-root', path.join('src', 'sql', 'graph.sql')); - writeFileSync( - workspace.sqlFile, - ` - with left_data as ( - select id from public.users - ), - right_data as ( - select id from public.orders - ) - select ld.id, rd.id - from left_data ld - join right_data rd on rd.id = ld.id - `, - 'utf8' - ); - - const result = runCli(['query', 'graph', workspace.sqlFile, '--format', 'json'], {}, workspace.rootDir); - assertCliSuccess(result, 'query graph multi-root'); - const payload = JSON.parse(result.stdout); - expect(payload.final_query).toBe('left_data, right_data'); -}); - - -test('query plan emits deterministic text steps from material and scalar filter metadata', () => { - const workspace = createSqlWorkspace('query-plan-text', path.join('src', 'sql', 'plan.sql')); - writeFileSync( - workspace.sqlFile, - [ - 'with base_users as (', - ' select id, region_id from public.users', - '),', - 'filtered_users as (', - ' select id from base_users where region_id is not null', - '),', - 'ranked_users as (', - ' select id from filtered_users', - ')', - 'select * from ranked_users', - 'where sale_date > (', - ' select p.closed_year_month from public.parameters p', - ')' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'query', - 'plan', - workspace.sqlFile, - '--material', - 'ranked_users,filtered_users', - '--scalar-filter-column', - 'sale_date' - ], {}, workspace.rootDir); - assertCliSuccess(result, 'query plan text'); - expect(result.stdout).toContain('Query type: SELECT'); - expect(result.stdout).toContain('Material CTEs: ranked_users, filtered_users'); - expect(result.stdout).toContain('Scalar filter columns: sale_date'); - expect(result.stdout).toContain('1. materialize filtered_users'); - expect(result.stdout).toContain('2. materialize ranked_users'); - expect(result.stdout).toContain('3. run final query'); -}); - -test('query plan accepts JSON metadata and emits machine-readable JSON', () => { - const workspace = createSqlWorkspace('query-plan-json', path.join('src', 'sql', 'plan.sql')); - writeFileSync( - workspace.sqlFile, - [ - 'with base_users as (', - ' select id from public.users', - '),', - 'filtered_users as (', - ' select id from base_users', - ')', - 'select * from filtered_users' - ].join('\n'), - 'utf8' - ); - - const result = runCli([ - 'query', - 'plan', - workspace.sqlFile, - '--json', - JSON.stringify({ - format: 'json', - material: ['filtered_users'], - scalarFilterColumns: ['sale_date'] - }) - ], {}, workspace.rootDir); - assertCliSuccess(result, 'query plan json'); - const payload = JSON.parse(result.stdout); - expect(payload.query_type).toBe('SELECT'); - expect(payload.metadata).toEqual({ - material: ['filtered_users'], - scalarFilterColumns: ['sale_date'] - }); - expect(payload.steps).toEqual([ - { - step: 1, - kind: 'materialize', - target: 'filtered_users', - depends_on: ['base_users'] - }, - { - step: 2, - kind: 'final-query', - target: 'FINAL_QUERY', - depends_on: ['filtered_users'] - } - ]); -}); -test('query plan exposes the tax allocation dogfood pipeline in text mode', () => { - const workspace = createSqlWorkspace('query-plan-tax-allocation', path.join('src', 'sql', 'reports', 'tax_allocation.sql')); - writeFileSync(workspace.sqlFile, TAX_ALLOCATION_QUERY, 'utf8'); - - const result = runCli([ - 'query', - 'plan', - workspace.sqlFile, - '--material', - 'input_lines,floored_allocations,ranked_allocations', - '--scalar-filter-column', - 'allocation_rank' - ], {}, workspace.rootDir); - - assertCliSuccess(result, 'query plan tax allocation'); - expect(result.stdout).toContain('Material CTEs: input_lines, floored_allocations, ranked_allocations'); - expect(result.stdout).toContain('Scalar filter columns: allocation_rank'); - expect(result.stdout).toContain('1. materialize input_lines'); - expect(result.stdout).toContain('2. materialize floored_allocations'); - expect(result.stdout).toContain('3. materialize ranked_allocations'); - expect(result.stdout).toContain('4. run final query'); -}); - -test('query outline supports DML statements with CTE analysis', () => { - const workspace = createSqlWorkspace('query-outline-insert', path.join('src', 'sql', 'insert_report.sql')); - writeFileSync( - workspace.sqlFile, - ` - with source_rows as ( - select id from public.users - ), - unused_rows as ( - select id from public.audit_log - ) - insert into public.user_report (user_id) - select id from source_rows - `, - 'utf8' - ); - - const result = runCli(['query', 'outline', workspace.sqlFile], {}, workspace.rootDir); - assertCliSuccess(result, 'query outline insert'); - expect(result.stdout).toContain('Query type: INSERT'); - expect(result.stdout).toContain('CTE count: 2'); - expect(result.stdout).toContain('1. source_rows'); - expect(result.stdout).toContain('2. unused_rows [unused]'); - expect(result.stdout).toContain('Final query target:'); - expect(result.stdout).toContain('source_rows'); - expect(result.stdout).toContain('public.audit_log'); - expect(result.stdout).toContain('unused_rows'); -}); - -test('model-gen rejects positional placeholders by default and recommends named params', () => { - const workspace = createSqlWorkspace('model-gen-positional-error'); - writeFileSync(workspace.sqlFile, 'select * from users where id = $1', 'utf8'); - - const result = runCli(['model-gen', workspace.sqlFile, '--sql-root', workspace.sqlRoot], {}, workspace.rootDir); - assertCliFailure(result, 'model-gen positional'); - expect(result.stderr).toContain('Detected positional placeholders ($1, $2, ...)'); - expect(result.stderr).toContain('must use named parameters (:name) by policy'); - expect(result.stderr).toContain('--allow-positional'); -}); - -test('model-gen describe-output emits contract metadata without probing', () => { - const workspace = createSqlWorkspace('model-gen-describe-output'); - writeFileSync(workspace.sqlFile, 'select 1 as value', 'utf8'); - - const result = runCli( - ['--output', 'json', 'model-gen', workspace.sqlFile, '--sql-root', workspace.sqlRoot, '--describe-output'], - {}, - workspace.rootDir - ); - assertCliSuccess(result, 'model-gen describe-output'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data).toMatchObject({ - command: 'model-gen', - fileRules: expect.objectContaining({ - supportsFeatureLocalSql: true, - sqlResolutionConceptualOrder: [ - 'spec-relative-from-out', - 'project-relative', - 'explicit-sql-root', - 'legacy-src-sql' - ], - explicitSqlRootIsCompatibilityHelper: true, - }), - outputs: { - spec: 'TypeScript QuerySpec scaffold' - } - }); -}); - -test('model-gen derives feature-local contracts without --sql-root in VSA layouts', () => { - const workspace = createSqlWorkspace( - 'model-gen-vsa-describe-output', - path.join('src', 'features', 'users', 'persistence', 'users.sql') - ); - writeFileSync(workspace.sqlFile, 'select 1 as value', 'utf8'); - - const outFile = path.join(workspace.rootDir, 'src', 'features', 'users', 'persistence', 'users.spec.ts'); - const result = runCli( - ['--output', 'json', 'model-gen', workspace.sqlFile, '--out', outFile, '--describe-output'], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'model-gen vsa describe-output'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data.writeBehavior).toMatchObject({ - writesTo: outFile, - }); -}); - -test('model-gen rejects sql files outside the configured sql root', () => { - const workspace = createSqlWorkspace('model-gen-root-error'); - const externalSql = path.join(workspace.rootDir, 'outside.sql'); - writeFileSync(externalSql, 'select 1 as value', 'utf8'); - - const result = runCli(['model-gen', externalSql, '--sql-root', workspace.sqlRoot], {}, workspace.rootDir); - assertCliFailure(result, 'model-gen root'); - expect(result.stderr).toContain('outside the configured sql root'); - expect(result.stderr).toContain('--sql-root'); -}); - -test('model-gen rejects spec id collisions before probing the database', () => { - const workspace = createSqlWorkspace('model-gen-spec-id-collision'); - writeFileSync(workspace.sqlFile, 'select 1 as value', 'utf8'); - const specsDir = path.join(workspace.rootDir, 'src', 'catalog', 'specs'); - mkdirSync(specsDir, { recursive: true }); - const collisionFile = path.join(specsDir, 'query.spec.ts'); - writeFileSync(collisionFile, "export const existing = { id: 'query' };\n", 'utf8'); - - const result = runCli(['model-gen', workspace.sqlFile, '--sql-root', workspace.sqlRoot], {}, workspace.rootDir); - assertCliFailure(result, 'model-gen collision'); - expect(result.stderr).toContain('conflicts with an existing spec'); - expect(result.stderr).toContain('does not auto-rename collisions'); -}); - -const hasPgDump = commandExists(pgDumpCommand); -const hasConnection = Boolean(process.env.TEST_PG_URI); -const shouldRunDbTests = hasPgDump && hasConnection; -const skipReasons: string[] = []; -if (!hasPgDump) { - skipReasons.push(`${pgDumpCommand} is missing from PATH`); -} -if (!hasConnection) { - skipReasons.push('TEST_PG_URI is not set'); -} -if (!shouldRunDbTests) { - console.warn(`Skipping DB-dependent CLI tests: ${skipReasons.join('; ')}.`); -} -const pullTest = shouldRunDbTests ? test.sequential : test.skip; - -pullTest('pull CLI emits schema from Postgres via pg_dump', async () => { - // TEST_PG_URI must point to a disposable Postgres instance because the test drops public schema. - const connectionString = process.env.TEST_PG_URI!; - const client = new Client({ connectionString }); - await client.connect(); - - try { - await resetPublicSchema(client); - await seedProductsTable(client); - - const outDir = createTempDir('cli-pull'); - const result = runCli(['ddl', 'pull', '--url', connectionString, '--out', outDir], { DATABASE_URL: 'postgres://ignored.example/app' }); - assertCliSuccess(result, 'ddl pull'); - const schemaFile = path.join(outDir, 'public.sql'); - expect(existsSync(schemaFile)).toBe(true); - const schema = readNormalizedFile(schemaFile); - expect(existsSync(path.join(outDir, 'schemas'))).toBe(false); - const normalizedSchema = normalizeSchemaDump(schema); - expect(normalizedSchema).toContain('create schema public;'); - expect(normalizedSchema).toContain('create table public.products'); - // Ensure pg_dump SET statements are removed without blocking ALTER ... SET DEFAULT. - expect(normalizedSchema).not.toMatch(/(^|\n)set\s+/); - expect(existsSync(path.join(outDir, 'schema.sql'))).toBe(false); - } finally { - await resetPublicSchema(client); - await client.end(); - } -}, 60_000); - -test('ddl pull ignores DATABASE_URL and requires an explicit target', () => { - const outDir = createTempDir('cli-pull-no-implicit-target'); - const result = runCli(['ddl', 'pull', '--out', outDir], { - DATABASE_URL: 'postgres://app.example/db', - ZTD_DB_URL: 'postgres://ztd.example/db' - }); - - assertCliFailure(result, 'ddl pull explicit target requirement'); - expect(result.stderr).toContain('This command does not use implicit database settings'); -}); - -test('ddl pull rejects partial explicit target flags', () => { - const outDir = createTempDir('cli-pull-partial-flags'); - const result = runCli(['ddl', 'pull', '--out', outDir, '--db-host', '127.0.0.1', '--db-user', 'postgres']); - - assertCliFailure(result, 'ddl pull partial flags'); - expect(result.stderr).toContain('Incomplete explicit target database flags'); -}); - -test('ddl diff help explains review-first output and companion artifacts', () => { - const result = runCli(['ddl', 'diff', '--help']); - - assertCliSuccess(result, 'ddl diff help'); - expect(result.stdout).toContain('emit logical summary'); - expect(result.stdout).toContain('structured apply-plan risks alongside pure SQL artifacts'); - expect(result.stdout).toContain('Output path for the generated SQL artifact;'); - expect(result.stdout).toContain('companion .txt/.json review files are written'); - expect(result.stdout).toContain('Compute the logical summary and structured risks'); - expect(result.stdout).toContain('without writing the SQL/.txt/.json artifacts'); - expect(result.stdout).toContain('SQL/.txt/.json artifacts'); -}); - -test('query lint help exposes the published join-direction command surface', () => { - const result = runCli(['query', 'lint', '--help']); - - assertCliSuccess(result, 'query lint help'); - expect(result.stdout).toContain('--rules '); - expect(result.stdout).toContain('join-direction'); - expect(result.stdout).toContain('upgrade to a newer published ztd-cli release'); -}); - -test('ddl risk help explains post-hoc evaluation for hand-edited migration SQL', () => { - const result = runCli(['ddl', 'risk', '--help']); - - assertCliSuccess(result, 'ddl risk help'); - expect(result.stdout).toContain('hand-edited migration SQL file'); - expect(result.stdout).toContain('emit the shared'); - expect(result.stdout).toContain('structured risk contract'); - expect(result.stdout).toContain('--file '); -}); - -test('describe command reports ddl diff review artifacts in global json mode', () => { - const result = runCli(['--output', 'json', 'describe', 'command', 'ddl diff']); - - assertCliSuccess(result, 'describe ddl diff'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'describe command', - ok: true, - data: { - command: { - name: 'ddl diff', - supportsDryRun: true, - supportsJsonPayload: true, - writesFiles: true, - output: { - files: ['Specified --out SQL file plus companion .txt and .json review artifacts with summary/risks'] - } - } - } - }); -}); - -test('describe command reports ddl risk metadata in global json mode', () => { - const result = runCli(['--output', 'json', 'describe', 'command', 'ddl risk']); - - assertCliSuccess(result, 'describe ddl risk'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'describe command', - ok: true, - data: { - command: { - name: 'ddl risk', - supportsDryRun: false, - supportsJsonPayload: true, - writesFiles: false, - flags: expect.arrayContaining([ - expect.objectContaining({ name: '--file' }), - expect.objectContaining({ name: '--json' }) - ]) - } - } - }); -}); - -test('ddl risk evaluates a hand-edited migration SQL file through the public CLI', () => { - const workspace = createTempDir('ddl-risk-cli'); - const sqlFile = path.join(workspace, 'hand-edited.sql'); - writeFileSync( - sqlFile, - [ - 'DROP TABLE IF EXISTS public.users CASCADE;', - 'CREATE TABLE public.users (id integer primary key, display_name text not null);', - 'CREATE INDEX idx_users_display_name ON public.users(display_name);', - '' - ].join('\n'), - 'utf8' - ); - - const result = runCli(['--output', 'json', 'ddl', 'risk', '--file', sqlFile], {}, workspace); - - assertCliSuccess(result, 'ddl risk'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'ddl risk', - ok: true, - data: { - file: sqlFile, - risks: { - destructiveRisks: expect.arrayContaining([ - expect.objectContaining({ kind: 'drop_table', target: 'public.users', avoidable: true }), - expect.objectContaining({ kind: 'cascade_drop', target: 'public.users', avoidable: true }) - ]), - operationalRisks: expect.arrayContaining([ - expect.objectContaining({ kind: 'table_rebuild', target: 'public.users' }), - expect.objectContaining({ kind: 'index_rebuild', target: 'idx_users_display_name' }) - ]) - } - } - }); -}); - -test('ddl risk accepts --json payload for the migration file path', () => { - const workspace = createTempDir('ddl-risk-cli-json'); - const sqlFile = path.join(workspace, 'hand-edited.sql'); - writeFileSync( - sqlFile, - 'DROP TABLE public.users CASCADE; CREATE TABLE public.users (id serial PRIMARY KEY, CONSTRAINT users_pkey PRIMARY KEY (id));', - 'utf8' - ); - - const result = runCli([ - '--output', - 'json', - 'ddl', - 'risk', - '--json', - JSON.stringify({ file: sqlFile }) - ], {}, workspace); - - assertCliSuccess(result, 'ddl risk json'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'ddl risk', - ok: true, - data: { - file: sqlFile, - risks: { - destructiveRisks: expect.arrayContaining([ - expect.objectContaining({ kind: 'drop_table', target: 'public.users' }), - expect.objectContaining({ kind: 'semantic_constraint_change', target: 'public.users' }) - ]), - operationalRisks: expect.arrayContaining([ - expect.objectContaining({ kind: 'table_rebuild', target: 'public.users' }) - ]) - } - } - }); -}); - -pullTest('pull CLI dry-run validates dump without writing files', async () => { - const connectionString = process.env.TEST_PG_URI!; - const client = new Client({ connectionString }); - await client.connect(); - - try { - await resetPublicSchema(client); - await seedProductsTable(client); - - const outDir = createTempDir('cli-pull-dry-run'); - const result = runCli(['--output', 'json', 'ddl', 'pull', '--url', connectionString, '--out', outDir, '--dry-run'], { DATABASE_URL: 'postgres://ignored.example/app' }); - assertCliSuccess(result, 'ddl pull dry-run'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data).toMatchObject({ - dryRun: true, - files: [expect.objectContaining({ schema: 'public' })] - }); - for (const file of parsed.data.files as Array<{ path: string }>) { - expect(existsSync(file.path)).toBe(false); - } - } finally { - await resetPublicSchema(client); - await client.end(); - } -}, 60_000); - -pullTest('model-gen emits a names-first spec scaffold from live Postgres metadata', async () => { - const connectionString = process.env.TEST_PG_URI!; - const client = new Client({ connectionString }); - await client.connect(); - - try { - await resetPublicSchema(client); - await client.query(` - CREATE TABLE public.products ( - id serial PRIMARY KEY, - name text NOT NULL, - price numeric NOT NULL - ); - `); - - const workspace = createSqlWorkspace('model-gen-named', path.join('src', 'sql', 'sales', 'get_sales_header.sql')); - writeFileSync( - workspace.sqlFile, - ` - select - p.id as product_id, - p.name as product_name, - p.price as list_price - from public.products p - where p.id = :product_id - `, - 'utf8' - ); - const outFile = path.join(workspace.rootDir, 'product.spec.ts'); - const result = runCli( - ['model-gen', workspace.sqlFile, '--sql-root', workspace.sqlRoot, '--url', connectionString, '--out', outFile, '--debug-probe'], - { DATABASE_URL: 'postgres://ignored.example/app' }, - workspace.rootDir - ); - - assertCliSuccess(result, 'model-gen named'); - const content = readNormalizedFile(outFile); - expect(content).toContain('export interface GetSalesHeaderRow'); - expect(content).toContain("productId: 'product_id'"); - expect(content).toContain("listPrice: 'list_price'"); - expect(content).toContain("params: { shape: 'named', example: { product_id: null } }"); - expect(result.stderr).toContain('[model-gen] inspection debug'); - expect(result.stderr).toContain('orderedParamNames: ["product_id"]'); - expect(result.stderr).toContain('inspectionSql: SELECT * FROM ('); - expect(result.stdout).toBe(''); - } finally { - await resetPublicSchema(client); - await client.end(); - } -}, 60_000); - -pullTest('model-gen emits a spec scaffold from ZTD DDL metadata without physical tables', async () => { - const connectionString = process.env.TEST_PG_URI!; - const client = new Client({ connectionString }); - await client.connect(); - - try { - await resetPublicSchema(client); - const existsBefore = await client.query<{ name: string | null }>( - "select to_regclass('public.products') as name" - ); - expect(existsBefore.rows[0]?.name).toBeNull(); - - const workspace = createSqlWorkspace('model-gen-ztd', path.join('src', 'sql', 'sales', 'get_sales_header.sql')); - const ddlDir = path.join(workspace.rootDir, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'public.sql'), - ` - CREATE TABLE public.products ( - id serial PRIMARY KEY, - name text NOT NULL, - price numeric NOT NULL - ); - `, - 'utf8' - ); - writeFileSync( - path.join(workspace.rootDir, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - writeFileSync( - workspace.sqlFile, - ` - select - p.id as product_id, - p.name as product_name, - p.price as list_price - from public.products p - where p.id = :product_id - `, - 'utf8' - ); - const outFile = path.join(workspace.rootDir, 'product-ztd.spec.ts'); - const result = runCli( - ['model-gen', workspace.sqlFile, '--sql-root', workspace.sqlRoot, '--probe-mode', 'ztd', '--out', outFile, '--debug-probe'], - { ZTD_DB_URL: connectionString, DATABASE_URL: 'postgres://ignored.example/app' }, - workspace.rootDir - ); - - assertCliSuccess(result, 'model-gen ztd'); - const content = readNormalizedFile(outFile); - expect(content).toContain('export interface GetSalesHeaderRow'); - expect(content).toContain("productId: 'product_id'"); - expect(content).toContain("params: { shape: 'named', example: { product_id: null } }"); - expect(result.stderr).toContain('probeMode: ztd'); - expect(result.stderr).toContain('ddlDir: db/ddl'); - - const existsAfter = await client.query<{ name: string | null }>( - "select to_regclass('public.products') as name" - ); - expect(existsAfter.rows[0]?.name).toBeNull(); - } finally { - await resetPublicSchema(client); - await client.end(); - } -}, 60_000); - -pullTest('model-gen ztd resolves unqualified table names through defaultSchema/searchPath', async () => { - const connectionString = process.env.TEST_PG_URI!; - const client = new Client({ connectionString }); - await client.connect(); - - try { - await resetPublicSchema(client); - - const workspace = createSqlWorkspace('model-gen-ztd-unqualified', path.join('src', 'sql', 'users', 'list_users.sql')); - const ddlDir = path.join(workspace.rootDir, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'public.sql'), - ` - CREATE TABLE public.users ( - user_id integer PRIMARY KEY, - email text NOT NULL - ); - `, - 'utf8' - ); - writeFileSync( - path.join(workspace.rootDir, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - writeFileSync( - workspace.sqlFile, - ` - select - user_id, - email - from users - where user_id = :user_id - `, - 'utf8' - ); - const outFile = path.join(workspace.rootDir, 'users-ztd.spec.ts'); - const result = runCli( - ['model-gen', workspace.sqlFile, '--sql-root', workspace.sqlRoot, '--probe-mode', 'ztd', '--out', outFile, '--debug-probe'], - { ZTD_DB_URL: connectionString, DATABASE_URL: 'postgres://ignored.example/app' }, - workspace.rootDir - ); - - assertCliSuccess(result, 'model-gen ztd unqualified'); - const content = readNormalizedFile(outFile); - expect(content).toContain('export interface ListUsersRow'); - expect(content).toContain("userId: 'user_id'"); - expect(content).toContain("email: 'email'"); - expect(result.stderr).toContain('searchPath: ["public"]'); - } finally { - await resetPublicSchema(client); - await client.end(); - } -}, 60_000); - -pullTest('model-gen ztd honors searchPath precedence for unqualified table names', async () => { - const connectionString = process.env.TEST_PG_URI!; - const client = new Client({ connectionString }); - await client.connect(); - - try { - await resetPublicSchema(client); - - const workspace = createSqlWorkspace('model-gen-ztd-search-path', path.join('src', 'sql', 'users', 'current_users.sql')); - const ddlDir = path.join(workspace.rootDir, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'schemas.sql'), - ` - CREATE SCHEMA app; - - CREATE TABLE app.users ( - account_id integer PRIMARY KEY, - handle text NOT NULL - ); - - CREATE TABLE public.users ( - user_id integer PRIMARY KEY, - email text NOT NULL - ); - `, - 'utf8' - ); - writeFileSync( - path.join(workspace.rootDir, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'app', - searchPath: ['app', 'public'], - ddlLint: 'strict' - }, null, 2), - 'utf8' - ); - writeFileSync( - workspace.sqlFile, - ` - select - account_id, - handle - from users - where account_id = :account_id - `, - 'utf8' - ); - const outFile = path.join(workspace.rootDir, 'current-users-ztd.spec.ts'); - const result = runCli( - ['model-gen', workspace.sqlFile, '--sql-root', workspace.sqlRoot, '--probe-mode', 'ztd', '--out', outFile, '--debug-probe'], - { ZTD_DB_URL: connectionString, DATABASE_URL: 'postgres://ignored.example/app' }, - workspace.rootDir - ); - - assertCliSuccess(result, 'model-gen ztd search-path'); - const content = readNormalizedFile(outFile); - expect(content).toContain("accountId: 'account_id'"); - expect(content).toContain("handle: 'handle'"); - expect(content).toContain('export interface CurrentUsersRow'); - expect(result.stderr).toContain('defaultSchema: app'); - expect(result.stderr).toContain('searchPath: ["app","public"]'); - } finally { - await resetPublicSchema(client); - await client.end(); - } -}, 60_000); - -pullTest('model-gen allows legacy positional placeholders only behind --allow-positional', async () => { - const connectionString = process.env.TEST_PG_URI!; - const client = new Client({ connectionString }); - await client.connect(); - - try { - await resetPublicSchema(client); - await client.query(` - CREATE TABLE public.products ( - id serial PRIMARY KEY, - name text NOT NULL - ); - `); - - const workspace = createSqlWorkspace('model-gen-positional'); - writeFileSync( - workspace.sqlFile, - ` - select - p.id as product_id, - p.name as product_name - from public.products p - where p.id = $1 - `, - 'utf8' - ); - const outFile = path.join(workspace.rootDir, 'product-positional.spec.ts'); - const result = runCli( - ['model-gen', workspace.sqlFile, '--sql-root', workspace.sqlRoot, '--url', connectionString, '--allow-positional', '--out', outFile], - { DATABASE_URL: 'postgres://ignored.example/app' }, - workspace.rootDir - ); - - assertCliSuccess(result, 'model-gen positional'); - const content = readNormalizedFile(outFile); - expect(content).toContain('Legacy warning'); - expect(content).toContain("params: { shape: 'positional', example: [null] }"); - } finally { - await resetPublicSchema(client); - await client.end(); - } -}, 60_000); - -test('query patch apply previews a targeted CTE replacement without writing', () => { - const workspace = createSqlWorkspace('query-patch-preview', path.join('src', 'sql', 'reports', 'sales.sql')); - const editedFile = path.join(workspace.rootDir, 'edited.sql'); - writeFileSync( - workspace.sqlFile, - ` - with users_base as ( - select id, region_id from public.users - ), - purchase_summary as ( - select id from users_base - ) - select * from purchase_summary - `, - 'utf8' - ); - writeFileSync( - editedFile, - ` - with users_base as ( - select id, region_id from public.users - ), - purchase_summary as ( - select id from users_base where region_id = 1 - ) - select * from purchase_summary - `, - 'utf8' - ); - const before = readNormalizedFile(workspace.sqlFile); - - const result = runCli( - ['query', 'patch', 'apply', workspace.sqlFile, '--cte', 'purchase_summary', '--from', editedFile, '--preview'], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query patch preview'); - expect(result.stdout).toContain('--- '); - expect(result.stdout).toContain('+++ '); - expect(result.stdout).toContain('"region_id" = 1'); - expect(readNormalizedFile(workspace.sqlFile)).toBe(before); -}); - -test('query patch apply writes the patched SQL to --out and supports global json output', () => { - const workspace = createSqlWorkspace('query-patch-json', path.join('src', 'sql', 'reports', 'sales.sql')); - const editedFile = path.join(workspace.rootDir, 'edited.sql'); - const outputFile = path.join(workspace.rootDir, 'patched.sql'); - writeFileSync( - workspace.sqlFile, - ` - with purchase_summary as ( - select id from public.users - ) - select * from purchase_summary - `, - 'utf8' - ); - writeFileSync( - editedFile, - 'purchase_summary (user_id) as materialized (select id as user_id from public.users)', - 'utf8' - ); - - const result = runCli( - ['--output', 'json', 'query', 'patch', 'apply', workspace.sqlFile, '--cte', 'purchase_summary', '--from', editedFile, '--out', outputFile], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'query patch json'); - const payload = JSON.parse(result.stdout); - expect(payload).toMatchObject({ - command: 'query patch apply', - ok: true, - data: { - file: workspace.sqlFile, - edited_file: editedFile, - target_cte: 'purchase_summary', - written: true, - output_file: outputFile, - changed: true - } - }); - const patched = readNormalizedFile(outputFile); - expect(patched).toContain('"purchase_summary"("user_id") as materialized'); - expect(patched).toContain('from "public"."users"'); -}); - - -test('query lint reports structural maintainability issues in text mode', () => { - const workspace = createSqlWorkspace('query-lint-text', path.join('src', 'sql', 'reports', 'maintainability.sql')); - writeFileSync( - workspace.sqlFile, - ` - with base_users as ( - select u.id - from public.users u - join public.regions r on r.id = u.id - ), - duplicate_users as ( - select u.id - from public.users u - join public.regions r on r.id = u.id - ), - unused_stage as ( - select id from public.audit_log - ) - select format('select %s from users', id) - from duplicate_users - `, - 'utf8' - ); - - const result = runCli(['query', 'lint', workspace.sqlFile], {}, workspace.rootDir); - assertCliSuccess(result, 'query lint text'); - expect(result.stdout).toContain('WARN unused-cte: unused_stage is defined but never used'); - expect(result.stdout).toContain('WARN duplicate-join-block:'); - expect(result.stdout).toContain('WARN analysis-risk:'); -}); - -test('query lint emits machine-readable JSON when requested', () => { - const workspace = createSqlWorkspace('query-lint-json', path.join('src', 'sql', 'reports', 'cycle.sql')); - writeFileSync( - workspace.sqlFile, - ` - with a as ( - select * from b - ), - b as ( - select * from a - ) - select * from a - `, - 'utf8' - ); - - const result = runCli(['query', 'lint', workspace.sqlFile, '--format', 'json'], {}, workspace.rootDir); - assertCliSuccess(result, 'query lint json'); - const payload = JSON.parse(result.stdout); - expect(payload.query_type).toBe('SELECT'); - expect(payload.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'dependency-cycle', - severity: 'error', - cycle: ['a', 'b', 'a'] - }) - ])); -}); - - - - - - - - - - -test('top-level help exposes perf run for benchmark loops', () => { - const result = runCli(['--help']); - - assertCliSuccess(result, '--help perf run'); - expect(result.stdout).toContain('perf run --query src/sql/report.sql --dry-run'); -}); - -test('describe command reports perf run metadata in global json mode', () => { - const result = runCli(['--output', 'json', 'describe', 'command', 'perf run']); - - assertCliSuccess(result, 'describe perf run'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'describe command', - ok: true, - data: { - command: { - name: 'perf run', - supportsDryRun: true, - supportsJsonPayload: true, - writesFiles: true, - flags: expect.arrayContaining([ - expect.objectContaining({ name: '--repeat' }), - expect.objectContaining({ name: '--warmup' }), - expect.objectContaining({ name: '--classify-threshold-seconds' }), - expect.objectContaining({ name: '--timeout-minutes' }), - expect.objectContaining({ name: '--label' }), - expect.objectContaining({ - name: '--params', - description: 'JSON or YAML file with named or positional parameters.' - }) - ]) - } - } - }); -}); - -test('perf run dry-run emits benchmark evidence metadata in global json mode', () => { - const workspace = createSqlWorkspace('perf-run-dry-run', path.join('src', 'sql', 'reports', 'sales.sql')); - const paramsFile = path.join(workspace.rootDir, 'perf', 'params.yml'); - mkdirSync(path.dirname(paramsFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - ` - select * - from public.sales - where region_id = :region_id - `, - 'utf8' - ); - writeFileSync(paramsFile, ['params:', ' region_id: 99', ''].join('\n'), 'utf8'); - - const result = runCli( - ['--output', 'json', 'perf', 'run', '--query', workspace.sqlFile, '--params', paramsFile, '--mode', 'latency', '--dry-run'], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'perf run dry-run'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'perf run', - ok: true, - data: { - dry_run: true, - requested_mode: 'latency', - selected_mode: 'latency', - params_shape: 'named', - ordered_param_names: ['region_id'], - executed_statements: [ - expect.objectContaining({ - role: 'final-query', - sql: expect.stringContaining('$1') - }) - ] - } - }); -}); - -test('perf run dry-run highlights tax allocation pipeline recommendations in global json mode', () => { - const workspace = createSqlWorkspace('perf-run-tax-allocation', path.join('src', 'sql', 'reports', 'tax_allocation.sql')); - const paramsFile = path.join(workspace.rootDir, 'perf', 'params.json'); - mkdirSync(path.dirname(paramsFile), { recursive: true }); - writeFileSync(workspace.sqlFile, TAX_ALLOCATION_QUERY, 'utf8'); - writeFileSync(paramsFile, JSON.stringify([2], null, 2), 'utf8'); - - const result = runCli( - ['--output', 'json', 'perf', 'run', '--query', workspace.sqlFile, '--params', paramsFile, '--mode', 'latency', '--dry-run'], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'perf run tax allocation'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data).toMatchObject({ - dry_run: true, - params_shape: 'positional', - pipeline_analysis: expect.objectContaining({ - should_consider_pipeline: true, - scalar_filter_candidates: ['allocation_rank'] - }) - }); - expect(parsed.data.recommended_actions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ action: 'consider-pipeline-materialization' }), - expect.objectContaining({ action: 'consider-scalar-filter-binding' }) - ]) - ); -}); - -test('perf run accepts --json payload for query resolution in global json mode', () => { - const workspace = createSqlWorkspace('perf-run-json-payload', path.join('src', 'sql', 'reports', 'sales.sql')); - const paramsFile = path.join(workspace.rootDir, 'perf', 'params.yml'); - mkdirSync(path.dirname(paramsFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - [ - 'select *', - 'from public.sales', - 'where region_id = :region_id' - ].join('\n'), - 'utf8' - ); - writeFileSync(paramsFile, ['params:', ' region_id: 77', ''].join('\n'), 'utf8'); - - const result = runCli( - [ - '--output', - 'json', - 'perf', - 'run', - '--json', - JSON.stringify({ - query: workspace.sqlFile, - params: paramsFile, - mode: 'latency', - dryRun: true - }) - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'perf run json payload'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data).toMatchObject({ - dry_run: true, - requested_mode: 'latency', - selected_mode: 'latency', - ordered_param_names: ['region_id'], - bindings: [77] - }); -}); - -test('perf report diff emits machine-readable JSON from saved evidence summaries', () => { - const workspace = createTempDir('perf-report-diff'); - const baselineDir = path.join(workspace, 'perf', 'evidence', 'run_001'); - const candidateDir = path.join(workspace, 'perf', 'evidence', 'run_002'); - mkdirSync(baselineDir, { recursive: true }); - mkdirSync(candidateDir, { recursive: true }); - - writeFileSync( - path.join(baselineDir, 'summary.json'), - JSON.stringify({ - schema_version: 1, - command: 'perf run', - run_id: 'run_001', - query_file: 'baseline.sql', - query_type: 'SELECT', - params_shape: 'none', - ordered_param_names: [], - source_sql_file: 'baseline.sql', - source_sql: 'select 1', - bound_sql: 'select 1', - strategy: 'direct', - requested_mode: 'latency', - selected_mode: 'latency', - selection_reason: 'forced', - classify_threshold_ms: 60000, - timeout_ms: 300000, - database_version: '16.2', - dry_run: false, - saved: true, - total_elapsed_ms: 300, - latency_metrics: { - measured_runs: 3, - warmup_runs: 1, - min_ms: 90, - max_ms: 120, - avg_ms: 100, - median_ms: 95, - p95_ms: 120 - }, - executed_statements: [{ seq: 1, role: 'final-query', sql: 'select 1', plan_summary: { node_type: 'Seq Scan' } }], - plan_observations: ['Seq Scan on public.users'], - recommended_actions: [], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - notes: [] - } - }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(candidateDir, 'summary.json'), - JSON.stringify({ - schema_version: 1, - command: 'perf run', - run_id: 'run_002', - query_file: 'candidate.sql', - query_type: 'SELECT', - params_shape: 'none', - ordered_param_names: [], - source_sql_file: 'candidate.sql', - source_sql: 'select 1', - bound_sql: 'select 1', - strategy: 'direct', - requested_mode: 'latency', - selected_mode: 'latency', - selection_reason: 'forced', - classify_threshold_ms: 60000, - timeout_ms: 300000, - database_version: '16.3', - dry_run: false, - saved: true, - total_elapsed_ms: 240, - latency_metrics: { - measured_runs: 3, - warmup_runs: 1, - min_ms: 70, - max_ms: 90, - avg_ms: 80, - median_ms: 80, - p95_ms: 90 - }, - executed_statements: [{ seq: 1, role: 'final-query', sql: 'select 1', plan_summary: { node_type: 'Nested Loop', join_type: 'Inner' } }], - plan_observations: ['Inner Nested Loop present in the captured plan'], - recommended_actions: [], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - notes: [] - } - }, null, 2), - 'utf8' - ); - - const result = runCli(['--output', 'json', 'perf', 'report', 'diff', baselineDir, candidateDir], {}, workspace); - - assertCliSuccess(result, 'perf report diff'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - command: 'perf report diff', - ok: true, - data: { - primary_metric: { - name: 'p95_ms', - baseline: 120, - candidate: 90 - }, - plan_deltas: [ - expect.objectContaining({ - statement_id: '1:final-query' - }) - ] - } - }); -}); - - -test('perf run dry-run exposes decomposed multi-statement strategy metadata in global json mode', () => { - const workspace = createSqlWorkspace('perf-run-decomposed', path.join('src', 'sql', 'reports', 'sales.sql')); - writeFileSync( - workspace.sqlFile, - ` - with base_sales as ( - select id, region_id from public.sales - ), - filtered_sales as ( - select id from base_sales where region_id = 99 - ), - final_sales as ( - select id from filtered_sales - ) - select * from final_sales - `, - 'utf8' - ); - - const result = runCli( - [ - '--output', - 'json', - 'perf', - 'run', - '--query', - workspace.sqlFile, - '--strategy', - 'decomposed', - '--material', - 'base_sales,filtered_sales', - '--mode', - 'latency', - '--dry-run' - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'perf run decomposed dry-run'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data).toMatchObject({ - strategy: 'decomposed', - strategy_metadata: { - materialized_ctes: ['base_sales', 'filtered_sales'], - planned_steps: [ - expect.objectContaining({ step: 1, kind: 'materialize', target: 'base_sales' }), - expect.objectContaining({ step: 2, kind: 'materialize', target: 'filtered_sales' }), - expect.objectContaining({ step: 3, kind: 'final-query', target: 'FINAL_QUERY' }), - ] - }, - executed_statements: [ - expect.objectContaining({ seq: 1, role: 'materialize', target: 'base_sales' }), - expect.objectContaining({ seq: 2, role: 'materialize', target: 'filtered_sales' }), - expect.objectContaining({ seq: 3, role: 'final-query', target: 'FINAL_QUERY' }), - ] - }); -}); - -test('perf run accepts material arrays in --json payloads', () => { - const workspace = createSqlWorkspace('perf-run-json-material-array', path.join('src', 'sql', 'reports', 'sales.sql')); - writeFileSync( - workspace.sqlFile, - ` - with base_sales as ( - select id, region_id from public.sales - ), - filtered_sales as ( - select id from base_sales where region_id = 99 - ) - select * from filtered_sales - `, - 'utf8' - ); - - const result = runCli( - [ - '--output', - 'json', - 'perf', - 'run', - '--json', - JSON.stringify({ - query: workspace.sqlFile, - strategy: 'decomposed', - material: ['base_sales'], - mode: 'latency', - dryRun: true - }) - ], - {}, - workspace.rootDir - ); - - assertCliSuccess(result, 'perf run json material array'); - const parsed = JSON.parse(result.stdout); - expect(parsed.data).toMatchObject({ - strategy: 'decomposed', - strategy_metadata: { - materialized_ctes: ['base_sales'] - }, - executed_statements: [ - expect.objectContaining({ seq: 1, role: 'materialize', target: 'base_sales' }), - expect.objectContaining({ seq: 2, role: 'final-query', target: 'FINAL_QUERY' }) - ] - }); -}); - -test('query match-observed ranks the likely source asset for observed SELECT SQL', () => { - const workspace = createSqlWorkspace('query-match-observed', path.join('src', 'sql', 'users', 'list.sql')); - writeFileSync( - workspace.sqlFile, - ` - SELECT account.user_id, account.email - FROM public.users account - WHERE (:active IS NULL OR account.active = :active) - ORDER BY account.created_at DESC - LIMIT :limit - `, - 'utf8' - ); - mkdirSync(path.dirname(path.join(workspace.rootDir, 'src', 'sql', 'products', 'list.sql')), { recursive: true }); - writeFileSync( - path.join(workspace.rootDir, 'src', 'sql', 'products', 'list.sql'), - ` - SELECT product.product_id, product.name - FROM public.products product - WHERE product.active = true - ORDER BY product.created_at DESC - `, - 'utf8' - ); - mkdirSync(path.dirname(path.join(workspace.rootDir, 'src', 'sql', 'users', 'list-with-join.sql')), { recursive: true }); - writeFileSync( - path.join(workspace.rootDir, 'src', 'sql', 'users', 'list-with-join.sql'), - ` - SELECT account.user_id, account.email - FROM public.users account - JOIN public.orders ord ON ord.user_id = account.user_id - WHERE account.active = true - `, - 'utf8' - ); - - const result = runCli( - [ - 'query', - 'match-observed', - '--sql', - ` - SELECT u.user_id, u.email - FROM public.users u - WHERE u.active = true - ORDER BY u.created_at DESC - LIMIT 25 - `, - '--format', - 'json' - ], - { ZTD_PROJECT_ROOT: workspace.rootDir }, - workspace.rootDir - ); - - assertCliSuccess(result, 'query match-observed'); - const parsed = JSON.parse(result.stdout); - expect(parsed).toMatchObject({ - schemaVersion: 1, - observedQueries: 1, - }); - expect(parsed.matches[0]).toMatchObject({ - sql_file: 'src/sql/users/list.sql', - section_scores: expect.objectContaining({ - projection: expect.any(Number), - source: expect.any(Number), - where: expect.any(Number), - order: expect.any(Number), - paging: expect.any(Number) - }) - }); - expect(parsed.matches[0].reasons.length).toBeGreaterThan(0); -}, 60000); diff --git a/packages/ztd-cli/tests/commandTelemetry.unit.test.ts b/packages/ztd-cli/tests/commandTelemetry.unit.test.ts deleted file mode 100644 index 71841b3b1..000000000 --- a/packages/ztd-cli/tests/commandTelemetry.unit.test.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; - -vi.mock('../src/utils/modelProbe', () => ({ - buildProbeSql: vi.fn((sql: string) => `SELECT * FROM (${sql}) AS _ztd_type_probe LIMIT 0`), - probeQueryColumns: vi.fn(async () => [ - { columnName: 'sales_id', typeName: 'int4', tsType: 'number' }, - { columnName: 'created_at', typeName: 'timestamptz', tsType: 'string' }, - ]), -})); - -vi.mock('../src/utils/optionalDependencies', () => ({ - ensureAdapterNodePgModule: vi.fn(), - ensurePgModule: vi.fn(async () => ({ - Client: class MockPgClient { - async connect(): Promise { - return; - } - - async end(): Promise { - return; - } - }, - })), - ensureTestkitCoreModule: vi.fn(), -})); - -vi.mock('../src/utils/pgDump', () => ({ - runPgDump: vi.fn(() => 'CREATE TABLE public.accounts (id bigint PRIMARY KEY);'), -})); - -import { buildProgram } from '../src/index'; -import { configureTelemetry } from '../src/utils/telemetry'; - -const originalProjectRoot = process.env.ZTD_PROJECT_ROOT; -const originalTelemetry = process.env.ZTD_CLI_TELEMETRY; -const originalTelemetryExport = process.env.ZTD_CLI_TELEMETRY_EXPORT; -const originalTelemetryFile = process.env.ZTD_CLI_TELEMETRY_FILE; - -function createWorkspace(prefix: string): string { - const tmpRoot = path.join(process.cwd(), 'tmp'); - mkdirSync(tmpRoot, { recursive: true }); - return mkdtempSync(path.join(tmpRoot, prefix + '-')); -} - -function captureTelemetry(): { lines: string[]; restore: () => void } { - const lines: string[] = []; - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - lines.push(String(chunk)); - return true; - }) as typeof process.stderr.write); - - return { - lines, - restore: () => stderrSpy.mockRestore(), - }; -} - -function parseTelemetryPayloads(lines: string[]): Array> { - return lines - .join('') - .split(/\r?\n/) - .filter((line) => line.includes('"type":"telemetry"')) - .map((line) => JSON.parse(line) as Record); -} - -function summarizeTelemetryTimeline(payloads: Array>, rootSpanName: string): string[] { - // Keep only the events that matter for dogfooding regressions so phase routing stays stable. - return payloads.flatMap((payload) => { - const kind = typeof payload.kind === 'string' ? payload.kind : null; - const spanName = typeof payload.spanName === 'string' ? payload.spanName : null; - const eventName = typeof payload.eventName === 'string' ? payload.eventName : null; - const status = typeof payload.status === 'string' ? payload.status : null; - - if (kind === 'span-start' && spanName) { - return [`start:${spanName}`]; - } - if (kind === 'decision' && eventName && !eventName.startsWith('command.')) { - return [`decision:${eventName}`]; - } - if (kind === 'span-end' && spanName === rootSpanName && status) { - return [`end:${spanName}:${status}`]; - } - return []; - }); -} - -afterEach(() => { - if (originalProjectRoot === undefined) { - delete process.env.ZTD_PROJECT_ROOT; - } else { - process.env.ZTD_PROJECT_ROOT = originalProjectRoot; - } - - if (originalTelemetry === undefined) { - delete process.env.ZTD_CLI_TELEMETRY; - } else { - process.env.ZTD_CLI_TELEMETRY = originalTelemetry; - } - - if (originalTelemetryExport === undefined) { - delete process.env.ZTD_CLI_TELEMETRY_EXPORT; - } else { - process.env.ZTD_CLI_TELEMETRY_EXPORT = originalTelemetryExport; - } - - if (originalTelemetryFile === undefined) { - delete process.env.ZTD_CLI_TELEMETRY_FILE; - } else { - process.env.ZTD_CLI_TELEMETRY_FILE = originalTelemetryFile; - } - - configureTelemetry({ enabled: false }); - vi.restoreAllMocks(); -}); - -test('query uses emits stable phase spans through the real CLI path', async () => { - const workspace = createWorkspace('query-telemetry'); - const specsDir = path.join(workspace, 'src', 'catalog', 'specs'); - const sqlDir = path.join(workspace, 'src', 'sql'); - mkdirSync(specsDir, { recursive: true }); - mkdirSync(sqlDir, { recursive: true }); - - writeFileSync( - path.join(specsDir, 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8', - ); - writeFileSync(path.join(sqlDir, 'users.sql'), 'SELECT email FROM public.users;', 'utf8'); - - process.env.ZTD_PROJECT_ROOT = workspace; - process.env.ZTD_CLI_TELEMETRY = '1'; - - const telemetry = captureTelemetry(); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync(['node', 'ztd', 'query', 'uses', 'table', 'public.users', '--format', 'json'], { from: 'node' }); - - telemetry.restore(); - logSpy.mockRestore(); - - const payloads = parseTelemetryPayloads(telemetry.lines); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'query uses table' }), - expect.objectContaining({ kind: 'span-start', spanName: 'resolve-query-options' }), - expect.objectContaining({ kind: 'span-start', spanName: 'build-query-usage-report' }), - expect.objectContaining({ kind: 'span-start', spanName: 'spec-discovery' }), - expect.objectContaining({ kind: 'span-start', spanName: 'impact-aggregation' }), - expect.objectContaining({ kind: 'span-start', spanName: 'render-query-usage-output' }), - expect.objectContaining({ kind: 'span-end', spanName: 'query uses table', status: 'ok' }), - ]), - ); -}); - -test('query uses can export telemetry to a CI-friendly artifact file through the real CLI path', async () => { - const workspace = createWorkspace('query-telemetry-file'); - const specsDir = path.join(workspace, 'src', 'catalog', 'specs'); - const sqlDir = path.join(workspace, 'src', 'sql'); - const telemetryFile = path.join(workspace, 'artifacts', 'query-uses.telemetry.jsonl'); - mkdirSync(specsDir, { recursive: true }); - mkdirSync(sqlDir, { recursive: true }); - - writeFileSync( - path.join(specsDir, 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8', - ); - writeFileSync(path.join(sqlDir, 'users.sql'), 'SELECT email FROM public.users;', 'utf8'); - - process.env.ZTD_PROJECT_ROOT = workspace; - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - [ - 'node', - 'ztd', - '--telemetry', - '--telemetry-export', - 'file', - '--telemetry-file', - telemetryFile, - 'query', - 'uses', - 'table', - 'public.users', - '--format', - 'json', - ], - { from: 'node' }, - ); - - logSpy.mockRestore(); - - expect(existsSync(telemetryFile)).toBe(true); - const payloads = readFileSync(telemetryFile, 'utf8') - .trim() - .split(/\r?\n/) - .map((line) => JSON.parse(line)); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'query uses table' }), - expect.objectContaining({ kind: 'span-end', spanName: 'query uses table', status: 'ok' }), - ]), - ); -}); - -test('query uses telemetry dogfood scenario preserves a stable impact-analysis timeline artifact', async () => { - const workspace = createWorkspace('query-telemetry-dogfood'); - const specsDir = path.join(workspace, 'src', 'catalog', 'specs'); - const sqlDir = path.join(workspace, 'src', 'sql'); - const telemetryFile = path.join(workspace, 'artifacts', 'query-uses.timeline.jsonl'); - mkdirSync(specsDir, { recursive: true }); - mkdirSync(sqlDir, { recursive: true }); - - writeFileSync( - path.join(specsDir, 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8', - ); - writeFileSync(path.join(sqlDir, 'users.sql'), 'SELECT email FROM public.users WHERE email IS NOT NULL;', 'utf8'); - - process.env.ZTD_PROJECT_ROOT = workspace; - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - [ - 'node', - 'ztd', - '--telemetry', - '--telemetry-export', - 'file', - '--telemetry-file', - telemetryFile, - 'query', - 'uses', - 'column', - 'public.users.email', - '--format', - 'json', - '--exclude-generated', - ], - { from: 'node' }, - ); - - logSpy.mockRestore(); - - const payloads = readFileSync(telemetryFile, 'utf8') - .trim() - .split(/\r?\n/) - .map((line) => JSON.parse(line) as Record); - expect(summarizeTelemetryTimeline(payloads, 'query uses column')).toEqual([ - 'start:query uses column', - 'start:resolve-query-options', - 'start:build-query-usage-report', - 'start:spec-discovery', - 'start:impact-aggregation', - 'start:render-query-usage-output', - 'end:query uses column:ok' - ]); -}); -test('model-gen emits phase spans and probe decision events through the real CLI path', async () => { - const workspace = createWorkspace('model-gen-telemetry'); - const sqlDir = path.join(workspace, 'src', 'sql', 'sales'); - const outDir = path.join(workspace, 'src', 'catalog', 'specs', 'generated'); - mkdirSync(sqlDir, { recursive: true }); - mkdirSync(outDir, { recursive: true }); - - const sqlFile = path.join(sqlDir, 'get_sales.sql'); - const outFile = path.join(outDir, 'getSales.generated.ts'); - writeFileSync(sqlFile, 'select :sales_id::int4 as sales_id, now() as created_at;', 'utf8'); - - const relativeSqlFile = path.relative(process.cwd(), sqlFile); - const relativeSqlRoot = path.relative(process.cwd(), path.join(workspace, 'src', 'sql')); - const relativeOutFile = path.relative(process.cwd(), outFile); - - const telemetry = captureTelemetry(); - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - void chunk; - return true; - }) as typeof process.stdout.write); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - [ - 'node', - 'ztd', - '--telemetry', - 'model-gen', - relativeSqlFile, - '--sql-root', - relativeSqlRoot, - '--out', - relativeOutFile, - '--url', - 'postgres://demo:secret@localhost:5432/app', - ], - { from: 'node' }, - ); - - telemetry.restore(); - stdoutSpy.mockRestore(); - - expect(existsSync(outFile)).toBe(true); - - const payloads = parseTelemetryPayloads(telemetry.lines); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'model-gen' }), - expect.objectContaining({ kind: 'span-start', spanName: 'resolve-model-gen-inputs' }), - expect.objectContaining({ kind: 'decision', eventName: 'model-gen.probe-mode' }), - expect.objectContaining({ kind: 'span-start', spanName: 'placeholder-scan' }), - expect.objectContaining({ kind: 'span-start', spanName: 'probe-client-connect' }), - expect.objectContaining({ kind: 'span-start', spanName: 'probe-query-columns' }), - expect.objectContaining({ kind: 'span-start', spanName: 'type-inference' }), - expect.objectContaining({ kind: 'span-start', spanName: 'render-generated-output' }), - expect.objectContaining({ kind: 'span-start', spanName: 'file-emit' }), - expect.objectContaining({ kind: 'span-end', spanName: 'model-gen', status: 'ok' }), - ]), - ); -}); - -test('model-gen telemetry dogfood scenario preserves the probe diagnosis timeline', async () => { - const workspace = createWorkspace('model-gen-telemetry-dogfood'); - const sqlDir = path.join(workspace, 'src', 'sql', 'sales'); - const outDir = path.join(workspace, 'src', 'catalog', 'specs', 'generated'); - mkdirSync(sqlDir, { recursive: true }); - mkdirSync(outDir, { recursive: true }); - - const sqlFile = path.join(sqlDir, 'get_sales.sql'); - const outFile = path.join(outDir, 'getSales.generated.ts'); - writeFileSync(sqlFile, 'select :sales_id::int4 as sales_id, now() as created_at;', 'utf8'); - - const relativeSqlFile = path.relative(process.cwd(), sqlFile); - const relativeSqlRoot = path.relative(process.cwd(), path.join(workspace, 'src', 'sql')); - const relativeOutFile = path.relative(process.cwd(), outFile); - - const telemetry = captureTelemetry(); - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - void chunk; - return true; - }) as typeof process.stdout.write); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - [ - 'node', - 'ztd', - '--telemetry', - 'model-gen', - relativeSqlFile, - '--sql-root', - relativeSqlRoot, - '--out', - relativeOutFile, - '--url', - 'postgres://demo:secret@localhost:5432/app', - ], - { from: 'node' }, - ); - - telemetry.restore(); - stdoutSpy.mockRestore(); - - const payloads = parseTelemetryPayloads(telemetry.lines); - expect(summarizeTelemetryTimeline(payloads, 'model-gen')).toEqual([ - 'start:model-gen', - 'start:resolve-model-gen-inputs', - 'decision:model-gen.probe-mode', - 'start:placeholder-scan', - 'start:probe-client-connect', - 'start:probe-query-columns', - 'start:type-inference', - 'start:render-generated-output', - 'start:file-emit', - 'end:model-gen:ok' - ]); -}); -test('ddl diff emits stable phase spans through the real CLI path', async () => { - const workspace = createWorkspace('ddl-diff-telemetry'); - const ddlDir = path.join(workspace, 'ztd', 'ddl'); - const outFile = path.join(workspace, 'tmp', 'plan.diff'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(path.dirname(outFile), { recursive: true }); - writeFileSync(path.join(ddlDir, 'public.sql'), 'CREATE TABLE public.users (id integer PRIMARY KEY);', 'utf8'); - - const relativeDdlDir = path.relative(process.cwd(), ddlDir); - const relativeOutFile = path.relative(process.cwd(), outFile); - - const telemetry = captureTelemetry(); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - ['node', 'ztd', '--telemetry', 'ddl', 'diff', '--ddl-dir', relativeDdlDir, '--out', relativeOutFile, '--url', 'postgres://demo:secret@localhost:5432/app'], - { from: 'node' }, - ); - - telemetry.restore(); - logSpy.mockRestore(); - - const payloads = parseTelemetryPayloads(telemetry.lines); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'ddl diff' }), - expect.objectContaining({ kind: 'span-start', spanName: 'collect-local-ddl' }), - expect.objectContaining({ kind: 'span-start', spanName: 'pull-remote-ddl' }), - expect.objectContaining({ kind: 'span-start', spanName: 'compute-diff-plan' }), - expect.objectContaining({ kind: 'span-start', spanName: 'emit-diff-plan' }), - expect.objectContaining({ kind: 'span-end', spanName: 'ddl diff', status: 'ok' }), - ]), - ); -}); - -test('perf run emits benchmark phase spans through the real CLI path', async () => { - const workspace = createWorkspace('perf-run-telemetry'); - const sqlDir = path.join(workspace, 'src', 'sql'); - const perfDir = path.join(workspace, 'perf'); - mkdirSync(sqlDir, { recursive: true }); - mkdirSync(perfDir, { recursive: true }); - - const sqlFile = path.join(sqlDir, 'sales.sql'); - const paramsFile = path.join(perfDir, 'params.yml'); - writeFileSync( - sqlFile, - [ - 'select *', - 'from public.sales', - 'where region_id = :region_id' - ].join('\n'), - 'utf8', - ); - writeFileSync(paramsFile, ['params:', ' region_id: 77', ''].join('\n'), 'utf8'); - - const telemetry = captureTelemetry(); - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - void chunk; - return true; - }) as typeof process.stdout.write); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - [ - 'node', - 'ztd', - '--telemetry', - 'perf', - 'run', - '--query', - sqlFile, - '--params', - paramsFile, - '--mode', - 'latency', - '--dry-run', - ], - { from: 'node' }, - ); - - telemetry.restore(); - stdoutSpy.mockRestore(); - - const payloads = parseTelemetryPayloads(telemetry.lines); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'perf run' }), - expect.objectContaining({ kind: 'span-start', spanName: 'resolve-perf-run-options' }), - expect.objectContaining({ kind: 'span-start', spanName: 'execute-perf-benchmark' }), - expect.objectContaining({ kind: 'span-start', spanName: 'render-perf-report' }), - expect.objectContaining({ kind: 'span-end', spanName: 'perf run', status: 'ok' }), - ]), - ); -}); - -test('perf run telemetry dogfood scenario preserves the benchmark investigation timeline', async () => { - const workspace = createWorkspace('perf-run-telemetry-dogfood'); - const sqlDir = path.join(workspace, 'src', 'sql'); - const perfDir = path.join(workspace, 'perf'); - const telemetryFile = path.join(workspace, 'artifacts', 'perf-run.timeline.jsonl'); - mkdirSync(sqlDir, { recursive: true }); - mkdirSync(perfDir, { recursive: true }); - - const sqlFile = path.join(sqlDir, 'sales.sql'); - const paramsFile = path.join(perfDir, 'params.yml'); - writeFileSync( - sqlFile, - [ - 'with base_sales as (', - ' select id, region_id', - ' from public.sales', - ')', - 'select *', - 'from base_sales', - 'where region_id = :region_id' - ].join('\n'), - 'utf8', - ); - writeFileSync(paramsFile, ['params:', ' region_id: 77', ''].join('\n'), 'utf8'); - - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - void chunk; - return true; - }) as typeof process.stdout.write); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - [ - 'node', - 'ztd', - '--telemetry', - '--telemetry-export', - 'file', - '--telemetry-file', - telemetryFile, - 'perf', - 'run', - '--query', - sqlFile, - '--params', - paramsFile, - '--mode', - 'latency', - '--dry-run', - ], - { from: 'node' }, - ); - - stdoutSpy.mockRestore(); - - const payloads = readFileSync(telemetryFile, 'utf8') - .trim() - .split(/\r?\n/) - .map((line) => JSON.parse(line) as Record); - expect(summarizeTelemetryTimeline(payloads, 'perf run')).toEqual([ - 'start:perf run', - 'start:resolve-perf-run-options', - 'start:execute-perf-benchmark', - 'start:render-perf-report', - 'end:perf run:ok' - ]); -}); diff --git a/packages/ztd-cli/tests/dbConnection.unit.test.ts b/packages/ztd-cli/tests/dbConnection.unit.test.ts deleted file mode 100644 index 081fa3989..000000000 --- a/packages/ztd-cli/tests/dbConnection.unit.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { - resolveExplicitTargetConnection, - resolveZtdOwnedTestConnection -} from '../src/utils/dbConnection'; - -function withEnv( - values: Partial>, - fn: () => T -): T { - const previousDatabaseUrl = process.env.DATABASE_URL; - const previousZtdDbUrl = process.env.ZTD_DB_URL; - const previousZtdDbPort = process.env.ZTD_DB_PORT; - try { - for (const [key, value] of Object.entries(values) as Array<['DATABASE_URL' | 'ZTD_DB_URL' | 'ZTD_DB_PORT', string | undefined]>) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - return fn(); - } finally { - if (previousDatabaseUrl === undefined) { - delete process.env.DATABASE_URL; - } else { - process.env.DATABASE_URL = previousDatabaseUrl; - } - - if (previousZtdDbUrl === undefined) { - delete process.env.ZTD_DB_URL; - } else { - process.env.ZTD_DB_URL = previousZtdDbUrl; - } - - if (previousZtdDbPort === undefined) { - delete process.env.ZTD_DB_PORT; - } else { - process.env.ZTD_DB_PORT = previousZtdDbPort; - } - } -} - -test('ZTD-owned connection uses only ZTD_DB_URL', () => { - const connection = withEnv( - { - ZTD_DB_URL: 'postgres://ztd_user:secret@test-host:5439/ztd_db', - DATABASE_URL: 'postgres://app_user:secret@app-host:5432/app_db' - }, - () => resolveZtdOwnedTestConnection() - ); - - expect(connection.context.source).toBe('ztd-test-env'); - expect(connection.context.host).toBe('test-host'); - expect(connection.context.port).toBe(5439); - expect(connection.context.user).toBe('ztd_user'); - expect(connection.context.database).toBe('ztd_db'); -}); - -test('--url takes precedence over --db-* for explicit target connections', () => { - const connection = resolveExplicitTargetConnection( - { - host: 'flags.example', - port: '6543', - user: 'flag-user', - password: 'p@ss', - database: 'flag_db' - }, - 'postgres://cli-user:secret@cli-host:3421/cli-db' - ); - - expect(connection.context.source).toBe('explicit-url'); - expect(connection.context.host).toBe('cli-host'); - expect(connection.context.port).toBe(3421); - expect(connection.context.user).toBe('cli-user'); - expect(connection.context.database).toBe('cli-db'); - expect(connection.url).toBe('postgres://cli-user:secret@cli-host:3421/cli-db'); -}); - -test('explicit --db-* flags are used when --url is absent', () => { - const connection = resolveExplicitTargetConnection({ - host: 'flags.example', - port: '6543', - user: 'flag-user', - password: 'p@ss', - database: 'flag_db' - }); - - expect(connection.context.source).toBe('explicit-flags'); - expect(connection.context.host).toBe('flags.example'); - expect(connection.context.port).toBe(6543); - expect(connection.context.database).toBe('flag_db'); -}); - -test('explicit target resolution ignores DATABASE_URL and ZTD_DB_URL', () => { - const connection = withEnv( - { - DATABASE_URL: 'postgres://app_user:secret@app-host:5432/app_db', - ZTD_DB_URL: 'postgres://ztd_user:secret@test-host:5439/ztd_db' - }, - () => - resolveExplicitTargetConnection( - { - host: 'flags.example', - port: '6543', - user: 'flag-user', - password: 'p@ss', - database: 'flag_db' - } - ) - ); - - expect(connection.context.host).toBe('flags.example'); - expect(connection.context.source).toBe('explicit-flags'); -}); - -test('partial explicit --db-* flags fail with a clear error', () => { - expect(() => - resolveExplicitTargetConnection({ - host: 'flags.example', - user: 'flag-user' - }) - ).toThrow(/Incomplete explicit target database flags/); -}); - -test('missing ZTD-owned connection reports actionable error', () => { - withEnv({ DATABASE_URL: 'postgres://app_user:secret@app-host:5432/app_db', ZTD_DB_URL: undefined }, () => { - expect(() => resolveZtdOwnedTestConnection()).toThrow(/ZTD_DB_URL is required/); - }); -}); - -test('ZTD-owned connection derives the starter URL from .env ZTD_DB_PORT when the direct env var is absent', () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), 'db-connection-dotenv-')); - writeFileSync(path.join(workspace, '.env'), 'ZTD_DB_PORT=5544\n', 'utf8'); - const connection = withEnv( - { - DATABASE_URL: 'postgres://app_user:secret@app-host:5432/app_db', - ZTD_DB_URL: undefined, - ZTD_DB_PORT: undefined, - }, - () => resolveZtdOwnedTestConnection(workspace) - ); - - expect(connection.url).toBe('postgres://ztd:ztd@localhost:5544/ztd'); - expect(connection.context).toMatchObject({ - source: 'ztd-test-env', - host: 'localhost', - port: 5544, - user: 'ztd', - database: 'ztd' - }); -}); - -test('missing explicit target info reports actionable error', () => { - expect(() => resolveExplicitTargetConnection({})).toThrow( - /This command does not use implicit database settings/ - ); -}); diff --git a/packages/ztd-cli/tests/describe.cli.test.ts b/packages/ztd-cli/tests/describe.cli.test.ts deleted file mode 100644 index 3d600b3e4..000000000 --- a/packages/ztd-cli/tests/describe.cli.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Command } from 'commander'; -import { afterEach, expect, test, vi } from 'vitest'; -import { getDescribeCommandDescriptors, registerDescribeCommand } from '../src/commands/describe'; -import { setAgentOutputFormat } from '../src/utils/agentCli'; - -const originalFormat = process.env.ZTD_CLI_OUTPUT_FORMAT; - -afterEach(() => { - process.env.ZTD_CLI_OUTPUT_FORMAT = originalFormat; -}); - -function createProgram(capture: { stdout: string[]; stderr: string[] }): Command { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str) - }); - registerDescribeCommand(program); - return program; -} - -test('describe command emits JSON envelope when global output is json', async () => { - setAgentOutputFormat('json'); - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - capture.stdout.push(String(chunk)); - return true; - }) as typeof process.stdout.write); - - await program.parseAsync(['describe', 'command', 'model-gen'], { from: 'user' }); - writeSpy.mockRestore(); - - const parsed = JSON.parse(capture.stdout.join('')); - expect(parsed).toMatchSnapshot(); - expect(capture.stderr).toEqual([]); -}); - -test('describe metadata documents RFBA scope discovery options for contract and evidence commands', () => { - const descriptors = getDescribeCommandDescriptors(); - const checkContract = descriptors.find((descriptor) => descriptor.name === 'check contract'); - const evidence = descriptors.find((descriptor) => descriptor.name === 'evidence'); - - expect(checkContract?.flags).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: '--scope-dir' }), - expect.objectContaining({ name: '--specs-dir', description: expect.stringContaining('Legacy') }) - ])); - expect(checkContract?.summary).toContain('QuerySpec-backed'); - expect(evidence?.flags).toEqual(expect.arrayContaining([ - expect.objectContaining({ name: '--scope-dir' }), - expect.objectContaining({ name: '--specs-dir', description: expect.stringContaining('Legacy') }) - ])); - expect(evidence?.summary).toContain('project QuerySpec'); -}); - -test('describe command snapshots the top-level command catalog and every detailed descriptor', async () => { - setAgentOutputFormat('json'); - const rootCapture = { stdout: [] as string[], stderr: [] as string[] }; - const rootProgram = createProgram(rootCapture); - const rootWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - rootCapture.stdout.push(String(chunk)); - return true; - }) as typeof process.stdout.write); - - await rootProgram.parseAsync(['describe'], { from: 'user' }); - rootWriteSpy.mockRestore(); - - expect(JSON.parse(rootCapture.stdout.join(''))).toMatchSnapshot('describe-root'); - - for (const descriptor of getDescribeCommandDescriptors()) { - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - capture.stdout.push(String(chunk)); - return true; - }) as typeof process.stdout.write); - - await program.parseAsync(['describe', 'command', descriptor.name], { from: 'user' }); - writeSpy.mockRestore(); - - expect(JSON.parse(capture.stdout.join(''))).toMatchSnapshot(descriptor.name); - expect(capture.stderr).toEqual([]); - } -}); diff --git a/packages/ztd-cli/tests/devNotes.docs.test.ts b/packages/ztd-cli/tests/devNotes.docs.test.ts deleted file mode 100644 index 962ab07ea..000000000 --- a/packages/ztd-cli/tests/devNotes.docs.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function readNormalizedFile(relativePath: string): string { - const filePath = path.join(repoRoot, relativePath); - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); -} - -test('DEV_NOTES.md documents the SQL shadowing troubleshooting order', () => { - const devNotes = readNormalizedFile('DEV_NOTES.md'); - - expect(devNotes).toContain('For SQL-backed test failures, first confirm whether the SQL is shadowing the intended path or accidentally touching a physical table directly.'); - expect(devNotes).toContain('If shadowing is wrong, check in this order: DDL and fixture sync, fixture selection or specification, repository bug or rewriter bug.'); - expect(devNotes).toContain('Do not use DDL execution as a repair path for ZTD validation failures.'); - expect(devNotes).toContain('If the database is reachable, treat relation or missing-table errors as a shadowing, fixture, or repository problem before considering schema changes.'); -}); - -test('DEV_NOTES.md documents branch session guard setup and limits', () => { - const devNotes = readNormalizedFile('DEV_NOTES.md'); - - expect(devNotes).toContain('After switching to the intended branch for this local worktree, record it with `pnpm guard:branch-session expect-current`.'); - expect(devNotes).toContain('`pre-push` blocks when no expected branch is recorded'); - expect(devNotes).toContain('The guard proves only that this local worktree is still on the branch declared for the session;'); -}); diff --git a/packages/ztd-cli/tests/diff.unit.test.ts b/packages/ztd-cli/tests/diff.unit.test.ts deleted file mode 100644 index 8965fd440..000000000 --- a/packages/ztd-cli/tests/diff.unit.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test, vi } from 'vitest'; -import * as pgDumpUtil from '../src/utils/pgDump'; -import { runDiffSchema } from '../src/commands/diff'; -import { analyzeMigrationPlanRisks, analyzeMigrationSqlRisks } from '../src/commands/ddlRiskEvaluator'; - -const repoRoot = path.resolve(__dirname, '../../..'); -const tempRoot = path.join(repoRoot, 'tmp'); - -function readNormalizedFile(filePath: string): string { - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); -} - -function createTempDir(prefix: string): string { - if (!existsSync(tempRoot)) { - mkdirSync(tempRoot, { recursive: true }); - } - return mkdtempSync(path.join(tempRoot, `${prefix}-`)); -} - -test('diff schema writes pure SQL plus companion review artifacts', () => { - const ddlDir = path.join(createTempDir('cli-diff'), 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - email text NOT NULL, - last_login_at timestamptz - ); - `, - 'utf8' - ); - - const outputFile = path.join(createTempDir('cli-diff-output'), 'users.diff.sql'); - const remoteSql = ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - email text NOT NULL - ); - `; - - // Replace pg_dump with a stable payload so the generated diff stays deterministic. - const spy = vi.spyOn(pgDumpUtil, 'runPgDump').mockReturnValue(remoteSql); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z')); - try { - const result = runDiffSchema({ - directories: [ddlDir], - extensions: ['.sql'], - url: 'postgres://test:secret@cli-host:5432/diff-db', - out: outputFile, - connectionContext: { - source: 'flags', - host: 'cli-host', - port: 5432, - user: 'diff-user', - database: 'diff-db' - } - }); - - expect(spy).toHaveBeenCalled(); - expect(result.outFile).toBe(outputFile); - expect(result.hasChanges).toBe(true); - expect(result.summary).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - schema: 'public', - table: 'users', - changeKind: 'add_column', - details: expect.objectContaining({ - column: 'last_login_at', - type: 'timestamptz', - nullable: true - }) - }) - ]) - ); - expect(result.applyPlan.operations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'drop_table_cascade', - target: 'public.users' - }), - expect.objectContaining({ - kind: 'recreate_table', - target: 'public.users' - }) - ]) - ); - expect(result.risks.destructiveRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'drop_table', - target: 'public.users', - avoidable: true, - guidance: expect.arrayContaining(['review_if_required', 'cli_option_not_exposed']) - }), - expect.objectContaining({ - kind: 'cascade_drop', - target: 'public.users' - }) - ]) - ); - expect(result.risks.operationalRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'table_rebuild', - target: 'public.users' - }), - expect.objectContaining({ - kind: 'full_table_copy', - target: 'public.users' - }) - ]) - ); - } finally { - vi.useRealTimers(); - spy.mockRestore(); - } - - const sqlContents = readNormalizedFile(outputFile); - const textContents = readNormalizedFile(outputFile.replace(/\.sql$/, '.txt')); - const jsonContents = JSON.parse(readNormalizedFile(outputFile.replace(/\.sql$/, '.json'))); - - expect(sqlContents).toContain('DROP TABLE IF EXISTS "public"."users" CASCADE;'); - expect(sqlContents).toContain('CREATE TABLE public.users'); - expect(sqlContents).not.toContain('-- ztd ddl diff plan'); - expect(sqlContents).not.toContain('--- local'); - - expect(textContents).toContain('Migration summary'); - expect(textContents).toContain('public.users: add column last_login_at timestamptz null'); - expect(textContents).toContain('Destructive risks'); - expect(textContents).toContain('drop_table: public.users'); - expect(textContents).toContain('guidance: review_if_required, avoid_if_possible, cli_option_not_exposed'); - expect(textContents).toContain('Operational risks'); - expect(textContents).toContain('table_rebuild: public.users'); - expect(textContents).toContain(outputFile); - - expect(jsonContents).toMatchObject({ - kind: 'ddl-diff', - hasChanges: true, - artifacts: { - sql: outputFile - }, - risks: { - destructiveRisks: expect.arrayContaining([ - expect.objectContaining({ - kind: 'drop_table', - target: 'public.users' - }) - ]), - operationalRisks: expect.arrayContaining([ - expect.objectContaining({ - kind: 'table_rebuild', - target: 'public.users' - }) - ]) - }, - summary: expect.arrayContaining([ - expect.objectContaining({ - schema: 'public', - table: 'users', - changeKind: 'add_column' - }) - ]) - }); -}); - -test('diff schema dry-run returns review data without writing artifacts', () => { - const ddlDir = path.join(createTempDir('cli-diff-dry-run'), 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - ` - CREATE TABLE public.users ( - id serial PRIMARY KEY - ); - `, - 'utf8' - ); - - const outputFile = path.join(createTempDir('cli-diff-dry-run-output'), 'users.diff.sql'); - const spy = vi.spyOn(pgDumpUtil, 'runPgDump').mockReturnValue(` - CREATE TABLE public.users ( - id serial PRIMARY KEY - ); - `); - - try { - const result = runDiffSchema({ - directories: [ddlDir], - extensions: ['.sql'], - url: 'postgres://test:secret@cli-host:5432/diff-db', - out: outputFile, - dryRun: true - }); - - expect(result.dryRun).toBe(true); - expect(result.hasChanges).toBe(false); - expect(result.text).toContain('no schema differences detected'); - expect(result.text).toContain('Destructive risks\n- none'); - expect(result.text).toContain('Operational risks\n- none'); - expect(result.risks.destructiveRisks).toEqual([]); - expect(result.risks.operationalRisks).toEqual([]); - expect(existsSync(outputFile)).toBe(false); - expect(existsSync(outputFile.replace(/\.sql$/, '.txt'))).toBe(false); - expect(existsSync(outputFile.replace(/\.sql$/, '.json'))).toBe(false); - } finally { - spy.mockRestore(); - } -}); - -test('diff schema reports column and index rebuild risks from the apply plan', () => { - const ddlDir = path.join(createTempDir('cli-diff-risk-matrix'), 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - display_name text NOT NULL, - nickname text NOT NULL - ); - - CREATE INDEX idx_users_display_name ON public.users(display_name); - `, - 'utf8' - ); - - const outputFile = path.join(createTempDir('cli-diff-risk-matrix-output'), 'users.diff.sql'); - const remoteSql = ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - name text, - nickname integer - ); - - CREATE INDEX idx_users_display_name ON public.users(name); - `; - - const spy = vi.spyOn(pgDumpUtil, 'runPgDump').mockReturnValue(remoteSql); - try { - const result = runDiffSchema({ - directories: [ddlDir], - extensions: ['.sql'], - url: 'postgres://test:secret@cli-host:5432/diff-db', - out: outputFile - }); - - expect(result.risks.destructiveRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'drop_column', target: 'public.users.name' }), - expect.objectContaining({ kind: 'alter_type', target: 'public.users.nickname' }), - expect.objectContaining({ kind: 'nullability_tighten', target: 'public.users.nickname' }), - expect.objectContaining({ - kind: 'rename_candidate', - from: 'public.users.name', - to: 'public.users.display_name' - }) - ]) - ); - expect(result.risks.operationalRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'index_rebuild', target: 'idx_users_display_name' }) - ]) - ); - } finally { - spy.mockRestore(); - } -}); - -test('diff schema reports supplemental-only index changes without table rebuild risks', () => { - const ddlDir = path.join(createTempDir('cli-diff-supplemental-only'), 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - display_name text NOT NULL - ); - - CREATE INDEX idx_users_display_name ON public.users(display_name); - `, - 'utf8' - ); - - const outputFile = path.join(createTempDir('cli-diff-supplemental-only-output'), 'users.diff.sql'); - const remoteSql = ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - display_name text NOT NULL - ); - `; - - const spy = vi.spyOn(pgDumpUtil, 'runPgDump').mockReturnValue(remoteSql); - try { - const result = runDiffSchema({ - directories: [ddlDir], - extensions: ['.sql'], - url: 'postgres://test:secret@cli-host:5432/diff-db', - out: outputFile - }); - - expect(result.hasChanges).toBe(true); - expect(result.summary).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - schema: 'public', - table: 'users', - changeKind: 'schema_change', - details: expect.objectContaining({ - message: 'apply index idx_users_display_name' - }) - }) - ]) - ); - expect(result.applyPlan.operations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'reapply_statement', - target: 'idx_users_display_name' - }), - expect.objectContaining({ - kind: 'index_rebuild_effect', - target: 'idx_users_display_name' - }) - ]) - ); - expect(result.applyPlan.operations).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'recreate_table', - target: 'public.users' - }) - ]) - ); - expect(result.risks.destructiveRisks).toEqual([]); - expect(result.risks.operationalRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'index_rebuild', target: 'idx_users_display_name' }) - ]) - ); - } finally { - spy.mockRestore(); - } -}); - -test('diff schema does not misparse commas inside column types or constraints', () => { - const ddlDir = path.join(createTempDir('cli-diff-top-level-comma'), 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'orders.sql'), - ` - CREATE TABLE public.orders ( - id serial PRIMARY KEY, - amount numeric(10,2) NOT NULL, - status text NOT NULL, - CONSTRAINT orders_status_check CHECK (status IN ('new', 'paid')) - ); - `, - 'utf8' - ); - - const outputFile = path.join(createTempDir('cli-diff-top-level-comma-output'), 'orders.diff.sql'); - const remoteSql = ` - CREATE TABLE public.orders ( - id serial PRIMARY KEY, - amount numeric(10,2) NOT NULL, - status text NOT NULL, - CONSTRAINT orders_status_check CHECK (status IN ('new', 'paid')) - ); - `; - - const spy = vi.spyOn(pgDumpUtil, 'runPgDump').mockReturnValue(remoteSql); - try { - const result = runDiffSchema({ - directories: [ddlDir], - extensions: ['.sql'], - url: 'postgres://test:secret@cli-host:5432/diff-db', - out: outputFile, - dryRun: true - }); - - expect(result.hasChanges).toBe(false); - expect(result.summary).toEqual([]); - expect(result.risks.destructiveRisks).toEqual([]); - expect(result.risks.operationalRisks).toEqual([]); - } finally { - spy.mockRestore(); - } -}); - -test('plan-based evaluator preserves ddl diff structured risks independently from rendering', () => { - const plan = { - operations: [ - { kind: 'drop_table_cascade', target: 'public.users' }, - { kind: 'recreate_table', target: 'public.users' }, - { kind: 'drop_column_effect', target: 'public.users.legacy_name' }, - { kind: 'index_rebuild_effect', target: 'idx_users_display_name' } - ] - } as const; - - const summary = [ - { - schema: 'public', - table: 'users', - changeKind: 'drop_column' as const, - details: { column: 'legacy_name', type: 'text' } - } - ]; - - const risks = analyzeMigrationPlanRisks(plan, summary); - expect(risks.destructiveRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'drop_table', - target: 'public.users', - avoidable: true, - guidance: expect.arrayContaining(['cli_option_not_exposed']) - }), - expect.objectContaining({ - kind: 'drop_column', - target: 'public.users.legacy_name' - }) - ]) - ); - expect(risks.operationalRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'table_rebuild', target: 'public.users' }), - expect.objectContaining({ kind: 'index_rebuild', target: 'idx_users_display_name' }) - ]) - ); -}); - -test('sql-based evaluator can re-evaluate hand-edited migration SQL', () => { - const handEditedSql = ` - DROP TABLE IF EXISTS public.users CASCADE; - CREATE TABLE public.users ( - id serial PRIMARY KEY, - display_name text NOT NULL - ); - ALTER TABLE public.orders ALTER COLUMN total_amount TYPE numeric(12,2); - ALTER TABLE public.orders ADD CONSTRAINT orders_total_amount_positive CHECK (total_amount > 0); - ALTER TABLE public.client DROP COLUMN client_name; - CREATE INDEX idx_users_display_name ON public.users(display_name); - `; - - const risks = analyzeMigrationSqlRisks(handEditedSql); - expect(risks.destructiveRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'drop_table', target: 'public.users' }), - expect.objectContaining({ kind: 'cascade_drop', target: 'public.users' }), - expect.objectContaining({ kind: 'alter_type', target: 'public.orders.total_amount' }), - expect.objectContaining({ kind: 'drop_column', target: 'public.client.client_name' }), - expect.objectContaining({ kind: 'semantic_constraint_change', target: 'public.orders' }) - ]) - ); - expect(risks.operationalRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'table_rebuild', target: 'public.users' }), - expect.objectContaining({ kind: 'full_table_copy', target: 'public.users' }), - expect.objectContaining({ kind: 'index_rebuild', target: 'idx_users_display_name' }) - ]) - ); -}); - -test('sql-based evaluator supports same-line statements and rebuilt table constraints', () => { - const risks = analyzeMigrationSqlRisks( - 'DROP TABLE public.users CASCADE; CREATE TABLE public.users (id serial PRIMARY KEY, email text NOT NULL, CONSTRAINT users_email_unique UNIQUE (email));' - ); - - expect(risks.destructiveRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'drop_table', target: 'public.users' }), - expect.objectContaining({ kind: 'cascade_drop', target: 'public.users' }), - expect.objectContaining({ kind: 'semantic_constraint_change', target: 'public.users' }) - ]) - ); - expect(risks.operationalRisks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'table_rebuild', target: 'public.users' }), - expect.objectContaining({ kind: 'full_table_copy', target: 'public.users' }) - ]) - ); -}); diff --git a/packages/ztd-cli/tests/directoryFinding.docs.test.ts b/packages/ztd-cli/tests/directoryFinding.docs.test.ts deleted file mode 100644 index 27940c64e..000000000 --- a/packages/ztd-cli/tests/directoryFinding.docs.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function readNormalizedFile(relativePath: string): string { - const filePath = path.join(repoRoot, relativePath); - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); -} - -test('readmes promote the feature-first layout without tables/views taxonomy', () => { - const rootReadme = readNormalizedFile('README.md'); - const packageReadme = readNormalizedFile('packages/ztd-cli/README.md'); - const scaffoldReadme = readNormalizedFile('packages/ztd-cli/templates/README.md'); - const featuresReadme = readNormalizedFile('packages/ztd-cli/templates/src/features/README.md'); - const smokeReadme = readNormalizedFile('packages/ztd-cli/templates/src/features/smoke/README.md'); - - for (const doc of [rootReadme, packageReadme, scaffoldReadme, featuresReadme, smokeReadme]) { - expect(doc).toContain('src/features'); - expect(doc).not.toContain('tables/views'); - } - - expect(rootReadme).toContain('feature-first'); - expect(packageReadme).toContain('feature-first'); - expect(scaffoldReadme).toContain('feature-first'); - expect(featuresReadme).toContain('smoke'); - expect(smokeReadme).toContain('starter-only sample feature'); - expect(smokeReadme).toContain('three narrow paths'); - expect(smokeReadme).toContain('DB-backed smoke test'); - expect(smokeReadme).toContain('createStarterPostgresTestkitClient'); - expect(rootReadme).toContain('Migration Repair Loop'); - expect(packageReadme).toContain('Quickstart'); - expect(packageReadme).toContain('Create the Users Insert Feature'); - expect(packageReadme).toContain('Highlights'); - expect(packageReadme).toContain('Command Index'); - expect(packageReadme).toContain('Glossary'); - expect(packageReadme).toContain('Further Reading'); - expect(packageReadme).toContain('RFBA (Review-First Backend Architecture)'); - expect(packageReadme).toContain('RFBA is a backend architecture for making AI-assisted work reviewable by humans.'); - expect(packageReadme).toContain('It splits files by review responsibility'); - expect(packageReadme).toContain('ZTD here means query-boundary-local cases that execute through the fixed app-level harness against the real database engine, not a mocked executor.'); - expect(packageReadme).toContain('Use validation-only cases for boundary checks and DB-backed cases for the success path.'); - expect(packageReadme).toContain('Keep the feature-root `src/features//tests/.boundary.test.ts` for mock-based boundary tests.'); - expect(packageReadme).toContain('Feature-boundary tests mock child query boundaries and verify feature validation, mapping, and orchestration.'); - expect(packageReadme).toContain('Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane.'); - expect(packageReadme).toContain('Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries.'); - expect(packageReadme).toContain('Starter-owned shared support lives under `tests/support/ztd/`; `.ztd/` remains the tool-managed workspace for generated metadata and support files.'); - expect(packageReadme).toContain('`root-boundary` is the app-level boundary layer.'); - expect(packageReadme).toContain('the concrete root boundaries are only `src/features`, `src/adapters`, and `src/libraries`'); - expect(packageReadme).toContain('`queries/` is a child-boundary container and does not expose its own public surface.'); - expect(packageReadme).toContain('A query sub-boundary is the feature-local query unit'); - expect(packageReadme).toContain('`boundary.ts` is a feature-scoped convention for discoverability and scaffold compatibility'); - expect(packageReadme).toContain('Do not count `src/features/_shared/*`, `tests/support/*`, `.ztd/*`, or `db/` as extra root boundaries.'); - expect(packageReadme).toContain('src/adapters/'); - expect(packageReadme).toContain('src/adapters/pg/'); - expect(packageReadme).toContain('src/adapters/aws/s3/'); - expect(packageReadme).toContain('src/libraries/*'); - expect(packageReadme).toContain('Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package'); - expect(packageReadme).toContain('After you finish the SQL and DTO edits'); - expect(packageReadme).toContain('feature tests scaffold --feature '); - expect(packageReadme).toContain('tests/generated/TEST_PLAN.md'); - expect(scaffoldReadme).toContain('src/features`, `src/adapters`, and `src/libraries` as the app-code roots'); - expect(scaffoldReadme).toContain('Make sure the query-boundary result executes through the DB-backed ZTD path and checks mapping and validation, not just property values.'); - expect(scaffoldReadme).toContain('Feature-boundary tests mock child query boundaries and verify feature validation, mapping, and orchestration.'); - expect(scaffoldReadme).toContain('Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane.'); - expect(scaffoldReadme).toContain('Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries.'); - expect(featuresReadme).toContain('Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package'); - expect(readNormalizedFile('packages/ztd-cli/templates/src/libraries/README.md')).toContain('Do not move feature-specific validation, mapping, or orchestration helpers here'); - expect(scaffoldReadme).toContain('simple `UNIQUE` checks are feasible ZTD preflight candidates'); - expect(readNormalizedFile('packages/ztd-cli/templates/tests/support/ztd/README.md')).toContain( - 'Use the traditional physical DB lane for DB-enforced fail-fast behavior today' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/TEST_PLAN.md')).toContain( - 'Constraint Coverage Boundary' - ); - expect(readNormalizedFile('docs/guide/sql-first-end-to-end-tutorial.md')).toContain('Scenario CLI at a glance'); - expect(readNormalizedFile('docs/dogfooding/ztd-migration-lifecycle.md')).toContain('Preferred CLI by scenario'); - expect(packageReadme).toContain('## Further Reading'); - expect(readNormalizedFile('docs/guide/perf-tuning-decision-guide.md')).toContain( - 'tuning stays evidence-driven and does not require breaking the SQL shape first' - ); - expect(readNormalizedFile('docs/dogfooding/perf-scale-tuning.md')).toContain( - 'without breaking the SQL unless the evidence shows that SQL shape itself must change' - ); -}); - -test('feature README and scaffold files center the sample feature and recursive boundary folders', () => { - const files = [ - 'packages/ztd-cli/templates/src/features/README.md', - 'packages/ztd-cli/templates/src/features/smoke/README.md', - 'packages/ztd-cli/templates/src/features/smoke/boundary.ts', - 'packages/ztd-cli/templates/src/features/smoke/tests/smoke.boundary.test.ts' - ]; - - for (const file of files) { - const contents = readNormalizedFile(file); - expect(contents).toContain('feature'); - expect(contents.toLowerCase()).toContain('smoke'); - expect(contents).not.toContain('tables/views'); - } - - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/README.md').toLowerCase()).toContain('feature-first'); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/README.md')).toContain('boundary.ts'); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/smoke/boundary.ts')).toContain( - 'executeSmokeEntrySpec' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/smoke/queries/smoke/boundary.ts')).toContain( - 'executeSmokeQuerySpec' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/smoke/queries/smoke/smoke.sql')).toContain( - 'where user_id = :user_id::integer' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/smoke/tests/smoke.boundary.test.ts')).toContain( - 'executeSmokeEntrySpec' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts')).toContain( - 'runQuerySpecZtdCases' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/_shared/featureQueryExecutor.ts')).toContain( - 'FeatureQueryExecutor' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/features/_shared/loadSqlResource.ts')).toContain( - 'loadSqlResource' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/libraries/sql/sql-client.ts')).toContain('SqlClient'); - expect(readNormalizedFile('packages/ztd-cli/templates/src/libraries/sql/sql-client.ts')).toContain( - '@rawsql-ts/driver-adapter-core' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/adapters/pg/sql-client.ts')).toContain('fromPg'); - expect(readNormalizedFile('packages/ztd-cli/templates/src/adapters/pg/sql-client.ts')).toContain( - 'createRowsOnlySqlClient' - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/adapters/pg/sql-client.ts')).toContain( - "from '#libraries/sql/sql-client.js'" - ); - expect(readNormalizedFile('packages/ztd-cli/templates/src/adapters/console/repositoryTelemetry.ts')).toContain( - "from '#libraries/telemetry/types.js'" - ); - expect(readNormalizedFile('packages/ztd-cli/templates/tests/support/testkit-client.webapi.ts')).toContain( - "from '#libraries/sql/sql-client.js'" - ); -}); - -test('feature-first scaffold files exist in the template bundle', () => { - const requiredPaths = [ - 'packages/ztd-cli/templates/src/features/README.md', - 'packages/ztd-cli/templates/src/features/smoke/README.md', - 'packages/ztd-cli/templates/src/features/smoke/boundary.ts', - 'packages/ztd-cli/templates/src/features/smoke/tests/smoke.boundary.test.ts', - 'packages/ztd-cli/templates/src/features/smoke/queries/smoke/boundary.ts', - 'packages/ztd-cli/templates/src/features/smoke/queries/smoke/smoke.sql', - 'packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts', - 'packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/boundary-ztd-types.ts', - 'packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/cases/basic.case.ts', - 'packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/TEST_PLAN.md', - 'packages/ztd-cli/templates/src/features/smoke/queries/smoke/tests/generated/analysis.json', - 'packages/ztd-cli/templates/src/features/_shared/featureQueryExecutor.ts', - 'packages/ztd-cli/templates/src/features/_shared/loadSqlResource.ts', - 'packages/ztd-cli/templates/src/libraries/README.md', - 'packages/ztd-cli/templates/src/libraries/sql/README.md', - 'packages/ztd-cli/templates/src/libraries/sql/sql-client.ts', - 'packages/ztd-cli/templates/src/libraries/telemetry/repositoryTelemetry.ts', - 'packages/ztd-cli/templates/src/adapters/README.md', - 'packages/ztd-cli/templates/src/adapters/pg/sql-client.ts', - 'packages/ztd-cli/templates/src/adapters/console/repositoryTelemetry.ts' - ]; - - for (const requiredPath of requiredPaths) { - expect(existsSync(path.join(repoRoot, requiredPath))).toBe(true); - } - - const removedPaths = [ - 'packages/ztd-cli/templates/AGENTS.md', - 'packages/ztd-cli/templates/CONTEXT.md', - 'packages/ztd-cli/templates/PROMPT_DOGFOOD.md', - 'packages/ztd-cli/templates/.codex/config.toml', - 'packages/ztd-cli/templates/.codex/agents/planning.md', - 'packages/ztd-cli/templates/.codex/agents/troubleshooting.md', - 'packages/ztd-cli/templates/.codex/agents/next-steps.md', - 'packages/ztd-cli/templates/.agents/skills/quickstart/SKILL.md', - 'packages/ztd-cli/templates/.agents/skills/troubleshooting/SKILL.md', - 'packages/ztd-cli/templates/.agents/skills/next-steps/SKILL.md' - ]; - - for (const removedPath of removedPaths) { - expect(existsSync(path.join(repoRoot, removedPath))).toBe(false); - } -}); diff --git a/packages/ztd-cli/tests/featureScaffold.unit.test.ts b/packages/ztd-cli/tests/featureScaffold.unit.test.ts deleted file mode 100644 index 1453d592e..000000000 --- a/packages/ztd-cli/tests/featureScaffold.unit.test.ts +++ /dev/null @@ -1,1864 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { expect, test } from 'vitest'; -import { - assessGeneratedMetadataCapability, - deriveFeatureName, - normalizeChildQueryName, - normalizeFeatureAction, - normalizeFeatureName, - normalizeInsertDefaultPolicy, - resolveFeatureScaffoldInput, - resolvePrimaryKeyColumn, - runExistingBoundaryQueryScaffoldCommand, - runFeatureGeneratedMapperCheckCommand, - runFeatureGeneratedMapperGenerateCommand, - runFeatureScaffoldCommand -} from '../src/commands/feature'; -import { DEFAULT_ZTD_CONFIG } from '../src/utils/ztdProjectConfig'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function seedStableFeatureAliases(workspace: string): void { - writeFileSync( - path.join(workspace, 'package.json'), - `${JSON.stringify({ - name: 'feature-scaffold-test', - private: true, - type: 'module', - imports: { - '#features/*.js': { - types: './src/features/*.ts', - default: './dist/features/*.js' - } - } - }, null, 2)}\n`, - 'utf8' - ); - writeFileSync( - path.join(workspace, 'tsconfig.json'), - `${JSON.stringify({ - compilerOptions: { - baseUrl: '.', - paths: { - '#features/*': ['src/features/*'] - } - } - }, null, 2)}\n`, - 'utf8' - ); - writeFileSync( - path.join(workspace, 'vitest.config.ts'), - [ - "import { defineConfig } from 'vitest/config';", - '', - 'export default defineConfig({', - ' resolve: {', - " alias: { '#features': '/virtual/src/features' }", - ' }', - '});', - '' - ].join('\n'), - 'utf8' - ); -} - -test('deriveFeatureName defaults to resource-action form', () => { - expect(deriveFeatureName('users', 'insert')).toBe('users-insert'); - expect(deriveFeatureName('users', 'update')).toBe('users-update'); - expect(deriveFeatureName('users', 'delete')).toBe('users-delete'); - expect(deriveFeatureName('users', 'get-by-id')).toBe('users-get-by-id'); - expect(deriveFeatureName('users', 'list')).toBe('users-list'); - expect(deriveFeatureName('public.users', 'insert')).toBe('users-insert'); - expect(deriveFeatureName('crm.UserProfiles', 'insert')).toBe('user-profiles-insert'); - expect(deriveFeatureName('public.user_profiles', 'insert')).toBe('user-profiles-insert'); - expect(deriveFeatureName('public.user profiles', 'insert')).toBe('user-profiles-insert'); -}); - -test('normalizeFeatureAction accepts the supported CRUD scaffold actions', () => { - expect(normalizeFeatureAction('insert')).toBe('insert'); - expect(normalizeFeatureAction('update')).toBe('update'); - expect(normalizeFeatureAction('delete')).toBe('delete'); - expect(normalizeFeatureAction('get-by-id')).toBe('get-by-id'); - expect(normalizeFeatureAction('list')).toBe('list'); - expect(() => normalizeFeatureAction('read')).toThrow(/supports only insert, update, delete, get-by-id, and list/i); -}); - -test('normalizeInsertDefaultPolicy accepts only documented INSERT default policies', () => { - expect(normalizeInsertDefaultPolicy(undefined)).toBe('explicit-defaults'); - expect(normalizeInsertDefaultPolicy('explicit-defaults')).toBe('explicit-defaults'); - expect(normalizeInsertDefaultPolicy('omit-db-defaults')).toBe('omit-db-defaults'); - expect(() => normalizeInsertDefaultPolicy('implicit')).toThrow(/explicit-defaults and omit-db-defaults/i); -}); - -test('normalizeFeatureName enforces resource-action kebab-case', () => { - expect(normalizeFeatureName('users-insert')).toBe('users-insert'); - expect(() => normalizeFeatureName('users')).toThrow(/resource-action/i); - expect(() => normalizeFeatureName('3users-insert')).toThrow(/start with a letter/i); -}); - -test('normalizeChildQueryName enforces kebab-case child-boundary names', () => { - expect(normalizeChildQueryName('insert-sales-detail')).toBe('insert-sales-detail'); - expect(() => normalizeChildQueryName('insert_sales_detail')).toThrow(/kebab-case/i); - expect(() => normalizeChildQueryName('3-insert-sales-detail')).toThrow(/start with a letter/i); -}); - -test('generated metadata assessment reports missing PK contract even when manifest exists', () => { - const workspace = createTempDir('feature-scaffold-manifest'); - const generatedDir = path.join(workspace, '.ztd', 'generated'); - mkdirSync(generatedDir, { recursive: true }); - writeFileSync( - path.join(generatedDir, 'ztd-fixture-manifest.generated.ts'), - [ - 'export const tableDefinitions = [', - ' {', - ' name: "public.users",', - ' columns: [{ name: "id", typeName: "serial", defaultValue: null, isNotNull: true }]', - ' }', - '];' - ].join('\n'), - 'utf8' - ); - - const assessment = assessGeneratedMetadataCapability(workspace); - expect(assessment.supported).toBe(false); - expect(assessment.reasons.join('\n')).toMatch(/primary key identity/i); - expect(assessment.reasons.join('\n')).toMatch(/composite primary key/i); -}); - -test('resolveFeatureScaffoldInput falls back to ddl metadata and resolves schema-qualified names', () => { - const workspace = createTempDir('feature-scaffold-ddl'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null,', - ' created_at timestamptz not null default now()', - ');' - ].join('\n'), - 'utf8' - ); - - const input = resolveFeatureScaffoldInput({ - projectRoot: workspace, - table: 'users', - config: DEFAULT_ZTD_CONFIG, - generatedMetadataAssessment: { - source: 'generated-metadata', - supported: false, - reasons: ['pk metadata missing'], - checkedFiles: [] - } - }); - - expect(input.source).toBe('ddl'); - expect(input.table.canonicalName).toBe('public.users'); - expect(input.table.columns.map((column) => column.name)).toEqual(['id', 'email', 'created_at']); -}); - -test('resolveFeatureScaffoldInput honors searchPath order when schemas share a table name', () => { - const workspace = createTempDir('feature-scaffold-search-path'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'tables.sql'), - [ - 'create table public.users (', - ' id serial primary key', - ');', - '', - 'create table app.users (', - ' id serial primary key', - ');' - ].join('\n'), - 'utf8' - ); - - const input = resolveFeatureScaffoldInput({ - projectRoot: workspace, - table: 'users', - config: { - ...DEFAULT_ZTD_CONFIG, - searchPath: ['app', 'public'] - }, - generatedMetadataAssessment: { - source: 'generated-metadata', - supported: false, - reasons: ['pk metadata missing'], - checkedFiles: [] - } - }); - - expect(input.table.canonicalName).toBe('app.users'); -}); - -test('resolvePrimaryKeyColumn rejects missing and composite primary keys', () => { - expect(() => - resolvePrimaryKeyColumn({ - canonicalName: 'public.users', - schemaName: 'public', - tableName: 'users', - primaryKeyColumns: [], - columns: [] - }) - ).toThrow(/exactly one primary key/i); - - expect(() => - resolvePrimaryKeyColumn({ - canonicalName: 'public.users', - schemaName: 'public', - tableName: 'users', - primaryKeyColumns: ['tenant_id', 'id'], - columns: [] - }) - ).toThrow(/composite primary keys/i); -}); - -test('runExistingBoundaryQueryScaffoldCommand dry-run plans an additive query under an existing feature boundary', async () => { - const workspace = createTempDir('feature-query-scaffold-dry-run'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'sales-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(featureDir, { recursive: true }); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing parent boundary\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - const result = await runExistingBoundaryQueryScaffoldCommand({ - feature: 'sales-insert', - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - dryRun: true, - rootDir: workspace - }); - - expect(result.boundaryPath).toBe('src/features/sales-insert'); - expect(result.resolutionSource).toBe('feature'); - const plannedPaths = result.outputs.map((output) => output.path); - expect(plannedPaths).toEqual(expect.arrayContaining([ - 'src/features/sales-insert/queries', - 'src/features/sales-insert/queries/insert-sales-detail', - 'src/features/sales-insert/queries/insert-sales-detail/generated', - 'src/features/sales-insert/queries/insert-sales-detail/boundary.ts', - 'src/features/sales-insert/queries/insert-sales-detail/insert-sales-detail.sql', - 'src/features/sales-insert/queries/insert-sales-detail/generated/row-mapper.ts' - ])); - expect(plannedPaths).not.toContain('src/features/sales-insert/boundary.ts'); -}); - -test('runExistingBoundaryQueryScaffoldCommand writes a child query boundary without touching the parent boundary', async () => { - const workspace = createTempDir('feature-query-scaffold-write'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'sales-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(featureDir, { recursive: true }); - const parentBoundary = path.join(featureDir, 'boundary.ts'); - writeFileSync(parentBoundary, '// existing parent boundary\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - await runExistingBoundaryQueryScaffoldCommand({ - boundaryDir: path.join('src', 'features', 'sales-insert'), - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - rootDir: workspace - }); - - expect(readFileSync(parentBoundary, 'utf8')).toBe('// existing parent boundary\n'); - expect(existsSync(path.join(featureDir, 'queries', 'insert-sales-detail', 'boundary.ts'))).toBe(true); - expect(existsSync(path.join(featureDir, 'queries', 'insert-sales-detail', 'insert-sales-detail.sql'))).toBe(true); - expect(existsSync(path.join(featureDir, 'queries', 'insert-sales-detail', 'generated', 'row-mapper.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', '_shared', 'featureQueryExecutor.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', '_shared', 'loadSqlResource.ts'))).toBe(true); - expect(existsSync(path.join(featureDir, 'README.md'))).toBe(false); -}); - -test('runExistingBoundaryQueryScaffoldCommand renders dynamic shared imports for nested boundaries', async () => { - const workspace = createTempDir('feature-query-scaffold-nested'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const boundaryDir = path.join(workspace, 'src', 'features', 'orders', 'write', 'sales-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(boundaryDir, { recursive: true }); - writeFileSync(path.join(boundaryDir, 'boundary.ts'), '// nested parent boundary\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - await runExistingBoundaryQueryScaffoldCommand({ - boundaryDir: path.join('src', 'features', 'orders', 'write', 'sales-insert'), - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - rootDir: workspace - }); - - const querySpecFile = readFileSync( - path.join(boundaryDir, 'queries', 'insert-sales-detail', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).toContain("import type { FeatureQueryExecutor } from '../../../../../_shared/featureQueryExecutor.js';"); - expect(querySpecFile).toContain("import { loadSqlResource } from '../../../../../_shared/loadSqlResource.js';"); -}); - -test('runExistingBoundaryQueryScaffoldCommand fails fast when the parent boundary contract is invalid', async () => { - const workspace = createTempDir('feature-query-scaffold-invalid-boundary'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const boundaryDir = path.join(workspace, 'src', 'features', 'sales-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(boundaryDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - await expect( - runExistingBoundaryQueryScaffoldCommand({ - boundaryDir: path.join('src', 'features', 'sales-insert'), - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - rootDir: workspace - }) - ).rejects.toThrow(/must contain boundary\.ts/i); -}); - -test('runExistingBoundaryQueryScaffoldCommand fails fast when the target boundary does not exist', async () => { - const workspace = createTempDir('feature-query-scaffold-missing-boundary'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - await expect( - runExistingBoundaryQueryScaffoldCommand({ - boundaryDir: path.join('src', 'features', 'sales-insert'), - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - rootDir: workspace - }) - ).rejects.toThrow(/Existing boundary folder not found/i); -}); - -test('runExistingBoundaryQueryScaffoldCommand fails fast when the target boundary is not a directory', async () => { - const workspace = createTempDir('feature-query-scaffold-file-boundary'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const boundaryFile = path.join(workspace, 'src', 'features', 'sales-insert'); - mkdirSync(path.dirname(boundaryFile), { recursive: true }); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync(boundaryFile, 'not a directory\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - await expect( - runExistingBoundaryQueryScaffoldCommand({ - boundaryDir: path.join('src', 'features', 'sales-insert'), - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - rootDir: workspace - }) - ).rejects.toThrow(/Boundary target must be a directory/i); -}); - -test('runExistingBoundaryQueryScaffoldCommand fails fast when queries is not a directory', async () => { - const workspace = createTempDir('feature-query-scaffold-invalid-queries'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'sales-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(featureDir, { recursive: true }); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing parent boundary\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - writeFileSync(path.join(featureDir, 'queries'), 'not a directory\n', 'utf8'); - await expect( - runExistingBoundaryQueryScaffoldCommand({ - boundaryDir: path.join('src', 'features', 'sales-insert'), - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - rootDir: workspace - }) - ).rejects.toThrow(/queries\/ to be a directory/i); -}); - -test('runExistingBoundaryQueryScaffoldCommand fails fast when the target query already exists', async () => { - const workspace = createTempDir('feature-query-scaffold-existing-query'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'sales-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(path.join(featureDir, 'queries', 'insert-sales-detail'), { recursive: true }); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing parent boundary\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - await expect( - runExistingBoundaryQueryScaffoldCommand({ - boundaryDir: path.join('src', 'features', 'sales-insert'), - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - rootDir: workspace - }) - ).rejects.toThrow(/already exists/i); -}); - -test('runExistingBoundaryQueryScaffoldCommand rejects feature and boundaryDir together', async () => { - const workspace = createTempDir('feature-query-scaffold-exclusive-flags'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'sales-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(featureDir, { recursive: true }); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing parent boundary\n', 'utf8'); - writeFileSync( - path.join(ddlDir, 'sales_detail.sql'), - [ - 'create table public.sales_detail (', - ' id serial primary key,', - ' sales_id integer not null,', - ' amount numeric not null', - ');' - ].join('\n'), - 'utf8' - ); - - await expect( - runExistingBoundaryQueryScaffoldCommand({ - feature: 'sales-insert', - boundaryDir: path.join('src', 'features', 'sales-insert'), - table: 'sales_detail', - action: 'insert', - queryName: 'insert-sales-detail', - rootDir: workspace - }) - ).rejects.toThrow(/feature.*boundary-dir|boundary-dir.*feature|not both/i); -}); - -test('runFeatureScaffoldCommand dry-run creates the new insert layout without test files', async () => { - const workspace = createTempDir('feature-scaffold-dry-run'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null,', - ' created_at timestamptz not null default now()', - ');' - ].join('\n'), - 'utf8' - ); - - const result = await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - dryRun: true, - rootDir: workspace - }); - - expect(result.featureName).toBe('users-insert'); - expect(result.primaryKeyColumn).toBe('id'); - expect(result.source).toBe('ddl'); - expect(result.outputs.map((output) => output.path)).toEqual(expect.arrayContaining([ - 'src/features/_shared', - 'src/features/_shared/featureQueryExecutor.ts', - 'src/features/_shared/loadSqlResource.ts', - 'src/features/users-insert', - 'src/features/users-insert/tests', - 'src/features/users-insert/tests/users-insert.boundary.test.ts', - 'src/features/users-insert/boundary.ts', - 'src/features/users-insert/input.ts', - 'src/features/users-insert/workflow.ts', - 'src/features/users-insert/output.ts', - 'src/features/users-insert/queries/insert-users', - 'src/features/users-insert/queries/insert-users/generated', - 'src/features/users-insert/queries/insert-users/boundary.ts', - 'src/features/users-insert/queries/insert-users/insert-users.sql', - 'src/features/users-insert/queries/insert-users/generated/row-mapper.ts', - 'src/features/users-insert/README.md' - ])); - expect(result.outputs.some((output) => output.path.endsWith('.boundary.ztd.test.ts'))).toBe(false); - expect(result.outputs.some((output) => output.path.endsWith('.boundary.test.ts'))).toBe(true); -}); - -test('runFeatureScaffoldCommand writes the boundary baseline and excludes generated PK columns', async () => { - const workspace = createTempDir('feature-scaffold-write-contract'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null,', - ' created_at timestamptz not null default now()', - ');' - ].join('\n'), - 'utf8' - ); - - const result = await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }); - - expect(result.dryRun).toBe(false); - - const entrySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'boundary.ts'), - 'utf8' - ); - expect(entrySpecFile).toContain("import type { FeatureQueryExecutor } from '../_shared/featureQueryExecutor.js';"); - expect(entrySpecFile).toContain("import * as input from './input.js';"); - expect(entrySpecFile).toContain("import * as workflow from './workflow.js';"); - expect(entrySpecFile).toContain("import * as output from './output.js';"); - expect(entrySpecFile).toContain('Review order:'); - expect(entrySpecFile).toContain('export async function execute('); - expect(entrySpecFile).toContain('const request = input.parseRequest(rawRequest);'); - expect(entrySpecFile).toContain('const created = await workflow.execute(executor, request);'); - expect(entrySpecFile).toContain('return output.buildResult(created);'); - expect(entrySpecFile).not.toContain('QueryParamsSchema'); - expect(entrySpecFile).not.toContain('RequestSchema'); - - const inputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'input.ts'), - 'utf8' - ); - expect(inputFile).not.toContain("import { z } from 'zod';"); - expect(inputFile).toContain('export interface UsersInsertRequest {'); - expect(inputFile).toContain('email: string;'); - expect(inputFile).not.toContain('RequestSchema'); - expect(inputFile).toContain('/** Parses the raw feature request at the feature boundary. */'); - expect(inputFile).toContain('function parseRawRequest'); - expect(inputFile).toContain("email: readString(record[\"email\"], 'UsersInsertRequest.email')"); - expect(inputFile).toContain('/** Normalizes the parsed feature request for downstream feature logic. */'); - expect(inputFile).toContain('function normalizeRequest'); - expect(inputFile).toContain('email: request.email.trim()'); - expect(inputFile).toContain("throw new Error('UsersInsertRequest.email must not be empty after trim().');"); - expect(inputFile).toContain('export function parseRequest'); - expect(inputFile).not.toContain('QueryParamsSchema'); - expect(inputFile).not.toContain('ResponseSchema'); - expect(inputFile).not.toContain('id: string;'); - - const workflowFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'workflow.ts'), - 'utf8' - ); - expect(workflowFile).toContain('export type UsersInsertQueries = {'); - expect(workflowFile).toContain('executeInsertUsers: executeInsertUsersQuerySpec'); - expect(workflowFile).toContain('Query functions are injected for workflow tests'); - expect(workflowFile).toContain('SQL text that may be transformed by rewrite or pipeline processing'); - expect(workflowFile).toContain('/** Maps the feature request into query params for the query spec. */'); - expect(workflowFile).toContain('function toQueryParams'); - expect(workflowFile).toContain('return {'); - expect(workflowFile).toContain('email: request.email,'); - expect(workflowFile).not.toContain('id: request.id,'); - expect(workflowFile).not.toContain('created_at: request.created_at,'); - - const outputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'output.ts'), - 'utf8' - ); - expect(outputFile).toContain("import type { InsertUsersQueryResult } from './queries/insert-users/boundary.js';"); - expect(outputFile).toContain('export type UsersInsertResponse = {'); - expect(outputFile).toContain('export function buildResult'); - expect(outputFile).toContain('id: result.id,'); - expect(outputFile).not.toContain("import { z } from 'zod';"); - expect(outputFile).not.toContain('ResponseSchema'); - expect(entrySpecFile).not.toContain('UsersInsertRequest.id'); - expect(entrySpecFile).not.toContain('id: z.string()'); - expect(entrySpecFile).not.toContain('created_at: request.created_at.trim()'); - expect(entrySpecFile).not.toContain('UsersInsertRequest.created_at'); - expect(entrySpecFile).not.toContain('ScalarKind'); - expect(entrySpecFile).not.toContain('parseBySpecs'); - expect(entrySpecFile).not.toContain('expectObject'); - expect(entrySpecFile).not.toContain('matchesKind'); - - const entrySpecTestFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'tests', 'users-insert.boundary.test.ts'), - 'utf8' - ); - expect(entrySpecTestFile).toContain("import { expect, test } from 'vitest';"); - expect(entrySpecTestFile).toContain("import { execute } from '../boundary.js';"); - expect(entrySpecTestFile).toContain("import type { FeatureQueryExecutor } from '../../_shared/featureQueryExecutor.js';"); - expect(entrySpecTestFile).toContain('function createGuardedExecutor(): FeatureQueryExecutor'); - expect(entrySpecTestFile).toContain("test('rejects invalid feature input at the feature boundary for users-insert/insert-users', async () => {"); - expect(entrySpecTestFile).toContain('await expect(execute(createGuardedExecutor(), {})).rejects.toThrow();'); - expect(entrySpecTestFile).toContain("test.todo('cover normalization and response mapping for UsersInsert boundary');"); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).not.toContain("import { z } from 'zod';"); - expect(querySpecFile).toContain("import type { FeatureQueryExecutor } from '../../../_shared/featureQueryExecutor.js';"); - expect(querySpecFile).toContain("const insertUsersSqlResource = loadSqlResource(__dirname, 'insert-users.sql');"); - expect(querySpecFile).toContain("import { mapInsertUsersRowToResult } from './generated/row-mapper.js';"); - expect(querySpecFile).toContain('export interface InsertUsersQueryParams {'); - expect(querySpecFile).toContain('email: string;'); - expect(querySpecFile).toContain("email: readString(record[\"email\"], 'InsertUsersQueryParams.email')"); - expect(querySpecFile).not.toContain('id: string;'); - expect(querySpecFile).not.toContain('created_at: string;'); - expect(querySpecFile).toContain('export interface InsertUsersRow {'); - expect(querySpecFile).toContain('export interface InsertUsersQueryResult {'); - expect(querySpecFile).toContain('/** Parses raw query params at the query boundary. */'); - expect(querySpecFile).toContain('function parseQueryParams'); - expect(querySpecFile).not.toContain('/** Parses a raw DB row at the query boundary. */'); - expect(querySpecFile).not.toContain('function parseRow'); - expect(querySpecFile).not.toContain('function mapRowToResult'); - expect(querySpecFile).toContain('/** Executes the query boundary flow for this query spec. */'); - expect(querySpecFile).toContain('export async function executeInsertUsersQuerySpec'); - expect(querySpecFile).toContain('loadSingleRow'); - expect(querySpecFile).toContain('return mapInsertUsersRowToResult(row);'); - expect(querySpecFile).toContain('executor.query(sql, params)'); - expect(querySpecFile).not.toContain('export interface InsertUsersQueryContract'); - expect(querySpecFile).not.toContain('export const insertUsersQueryContract'); - expect(querySpecFile).not.toContain('export function parseInsertUsersQueryParams'); - expect(querySpecFile).not.toContain('export function parseInsertUsersRow'); - expect(querySpecFile).not.toContain('ScalarKind'); - expect(querySpecFile).not.toContain('parseBySpecs'); - - const generatedRowMapperFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'generated', 'row-mapper.ts'), - 'utf8' - ); - expect(generatedRowMapperFile).toContain('@generated by rawsql-ts ztd-cli'); - expect(generatedRowMapperFile).toContain('machine-owned'); - expect(generatedRowMapperFile).toContain('source-boundary-sha256:'); - expect(generatedRowMapperFile).toContain('source-sql-sha256:'); - expect(generatedRowMapperFile).toContain("import type { InsertUsersQueryResult, InsertUsersRow } from '../boundary.js';"); - expect(generatedRowMapperFile).not.toContain('rowMapperFallbackReason'); - expect(generatedRowMapperFile).toContain('export function mapInsertUsersRowToResult(row: InsertUsersRow): InsertUsersQueryResult'); - expect(generatedRowMapperFile).toContain('"id": row["id"],'); - await expect( - runFeatureGeneratedMapperCheckCommand({ - feature: 'users-insert', - query: 'insert-users', - rootDir: workspace - }) - ).resolves.toMatchObject({ ok: true }); - - const sqlFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'insert-users.sql'), - 'utf8' - ); - expect(sqlFile).toContain('insert into "public"."users" ('); - expect(sqlFile).toContain(':email'); - expect(sqlFile).toContain('now()'); - expect(sqlFile).not.toContain(':id'); - expect(sqlFile).not.toContain(':created_at'); - expect(sqlFile).toContain('returning "id";'); - - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'adapter-cli.ts'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'adapter-api.ts'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'users-insert', 'adapter-lambda.ts'))).toBe(false); - - const featureQueryExecutorFile = readFileSync( - path.join(workspace, 'src', 'features', '_shared', 'featureQueryExecutor.ts'), - 'utf8' - ); - expect(featureQueryExecutorFile).toContain('export interface FeatureQueryExecutor {'); - expect(featureQueryExecutorFile).toContain('Inject your DB execution implementation at this seam'); - - const readmeFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'README.md'), - 'utf8' - ); - expect(readmeFile).toContain('## RFBA review responsibilities'); - expect(readmeFile).toContain('RFBA splits files by review responsibility'); - expect(readmeFile).toContain('`boundary.ts` is the default feature-boundary public surface and should read as `input -> workflow -> output`.'); - expect(readmeFile).toContain('`input.ts` owns raw request parsing, normalization, and feature-level input rejection.'); - expect(readmeFile).toContain('`workflow.ts` owns the feature use-case flow and query orchestration.'); - expect(readmeFile).toContain('Feature-boundary and workflow tests should mock query ports rather than classify child queries by SQL text'); - expect(readmeFile).toContain('Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane.'); - expect(readmeFile).toContain('Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries.'); - expect(readmeFile).toContain('Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package'); - expect(readmeFile).toContain('uses local scaffolded request parsing'); - expect(readmeFile).toContain('Feature-local `boundary.ts` exports `execute`'); - expect(readmeFile).toContain('queries/insert-users/boundary.ts'); - expect(readmeFile).toContain('queries/insert-users/` is the query unit'); - expect(readmeFile).toContain('## CLI-owned generated files'); - expect(readmeFile).toContain('queries/insert-users/generated/row-mapper.ts'); - expect(readmeFile).toContain('ztd feature generated-mapper generate --feature users-insert --query insert-users'); - expect(readmeFile).toContain('ztd feature generated-mapper check --feature users-insert'); - expect(readmeFile).toContain('## Created by `feature tests scaffold` after SQL and DTO edits'); - expect(readmeFile).toContain('queries/insert-users/tests/boundary-ztd-types.ts'); - expect(readmeFile).toContain('insert-users/tests/generated/TEST_PLAN.md'); - expect(readmeFile).toContain('insert-users/tests/generated/analysis.json'); - expect(readmeFile).toContain('## Human/AI-owned persistent files'); - expect(readmeFile).not.toContain('## AI-created files'); - expect(readmeFile).toContain('Generated / identity / sequence-backed columns excluded at scaffold time: `id`.'); - expect(readmeFile).toContain('Initial insert query columns: `email`, `created_at`.'); - expect(readmeFile).toContain('Caller-supplied request/query params: `email`.'); - expect(readmeFile).toContain('DDL-backed default expressions written directly into SQL: `created_at`.'); - expect(readmeFile).toContain('When DDL declares a column default, the scaffold writes that default expression into SQL explicitly'); - expect(readmeFile).toContain('featureQueryExecutor.ts` is the shared runtime contract for DB execution injection'); - expect(readmeFile).toContain('Thin generated query execution helpers with no standard runtime catalog dependency'); - expect(readmeFile).toContain('Keep this baseline as one workflow and one primary query by default'); -}); - -test('runFeatureScaffoldCommand emits camelCase feature DTOs while keeping query params DB-shaped', async () => { - const workspace = createTempDir('feature-scaffold-camel-dto'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'account_events.sql'), - [ - 'create table public.account_events (', - ' id serial8 primary key,', - ' account_id integer not null,', - ' display_name text,', - ' event_payload jsonb not null,', - ' created_at timestamptz not null default now()', - ');' - ].join('\n'), - 'utf8' - ); - - await runFeatureScaffoldCommand({ - table: 'account_events', - action: 'insert', - rootDir: workspace - }); - - const inputFile = readFileSync( - path.join(workspace, 'src', 'features', 'account-events-insert', 'input.ts'), - 'utf8' - ); - expect(inputFile).toContain('accountId: number;'); - expect(inputFile).toContain('displayName: string | null;'); - expect(inputFile).toContain('eventPayload: Record;'); - expect(inputFile).toContain("accountId: readNumber(record[\"accountId\"], 'AccountEventsInsertRequest.accountId')"); - expect(inputFile).toContain("displayName: readNullableString(record[\"displayName\"], 'AccountEventsInsertRequest.displayName')"); - expect(inputFile).toContain("eventPayload: readJsonObject(record[\"eventPayload\"], 'AccountEventsInsertRequest.eventPayload')"); - expect(inputFile).not.toContain('account_id: number;'); - expect(inputFile).not.toContain('display_name: string | null;'); - - const workflowFile = readFileSync( - path.join(workspace, 'src', 'features', 'account-events-insert', 'workflow.ts'), - 'utf8' - ); - expect(workflowFile).toContain('account_id: request.accountId,'); - expect(workflowFile).toContain('display_name: request.displayName,'); - expect(workflowFile).toContain('event_payload: request.eventPayload,'); - - const outputFile = readFileSync( - path.join(workspace, 'src', 'features', 'account-events-insert', 'output.ts'), - 'utf8' - ); - expect(outputFile).toContain('id: result.id,'); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'account-events-insert', 'queries', 'insert-account-events', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).toContain('account_id: number;'); - expect(querySpecFile).toContain('display_name: string | null;'); - expect(querySpecFile).toContain('event_payload: Record;'); - expect(querySpecFile).toContain("account_id: readNumber(record[\"account_id\"], 'InsertAccountEventsQueryParams.account_id')"); - expect(querySpecFile).toContain("display_name: readNullableString(record[\"display_name\"], 'InsertAccountEventsQueryParams.display_name')"); - expect(querySpecFile).toContain("event_payload: readJsonObject(record[\"event_payload\"], 'InsertAccountEventsQueryParams.event_payload')"); - expect(querySpecFile).not.toContain('accountId: number;'); - expect(querySpecFile).not.toContain('displayName: string | null;'); - - const sqlFile = readFileSync( - path.join(workspace, 'src', 'features', 'account-events-insert', 'queries', 'insert-account-events', 'insert-account-events.sql'), - 'utf8' - ); - expect(sqlFile).toContain(':account_id'); - expect(sqlFile).toContain(':display_name'); - expect(sqlFile).toContain(':event_payload'); - expect(sqlFile).not.toContain(':accountId'); -}); - -test('runFeatureScaffoldCommand uses stable shared imports when the workspace supports #features', async () => { - const workspace = createTempDir('feature-scaffold-stable-imports'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - seedStableFeatureAliases(workspace); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id integer generated always as identity primary key,', - ' email text not null,', - ' created_at timestamptz not null default now()', - ');', - '' - ].join('\n'), - 'utf8' - ); - - await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).toContain("import type { FeatureQueryExecutor } from '#features/_shared/featureQueryExecutor.js';"); - expect(querySpecFile).toContain("import { loadSqlResource } from '#features/_shared/loadSqlResource.js';"); -}); - -test('runFeatureScaffoldCommand falls back to relative imports when #features alias support is partial', async () => { - const workspace = createTempDir('feature-scaffold-partial-import-alias'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(workspace, 'package.json'), - `${JSON.stringify({ - name: 'feature-scaffold-test', - private: true, - type: 'module', - imports: { - '#features/*.js': { - types: './src/features/*.ts', - default: './dist/features/*.js' - } - } - }, null, 2)}\n`, - 'utf8' - ); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - - await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).toContain("import type { FeatureQueryExecutor } from '../../../_shared/featureQueryExecutor.js';"); - expect(querySpecFile).toContain("import { loadSqlResource } from '../../../_shared/loadSqlResource.js';"); -}); - -test('runFeatureScaffoldCommand uses default values when every insert column is DB-generated', async () => { - const workspace = createTempDir('feature-scaffold-default-values'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key', - ');' - ].join('\n'), - 'utf8' - ); - - await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }); - - const sqlFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'insert-users.sql'), - 'utf8' - ); - expect(sqlFile).toContain('insert into "public"."users"'); - expect(sqlFile).toContain('default values'); - - const inputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'input.ts'), - 'utf8' - ); - expect(inputFile).toContain('export type UsersInsertRequest = {};'); - - const workflowFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'workflow.ts'), - 'utf8' - ); - expect(workflowFile).toContain('return {} as InsertUsersQueryParams;'); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-insert', 'queries', 'insert-users', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).toContain('export type InsertUsersQueryParams = {};'); -}); - -test('runFeatureScaffoldCommand renders primitive defaults directly into insert SQL', async () => { - const workspace = createTempDir('feature-scaffold-primitive-defaults'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'flags.sql'), - [ - 'create table public.flags (', - ' id serial primary key,', - ' enabled boolean not null default false,', - ' priority integer not null default 0,', - ' name text not null', - ');' - ].join('\n'), - 'utf8' - ); - - await runFeatureScaffoldCommand({ - table: 'flags', - action: 'insert', - rootDir: workspace - }); - - const sqlFile = readFileSync( - path.join(workspace, 'src', 'features', 'flags-insert', 'queries', 'insert-flags', 'insert-flags.sql'), - 'utf8' - ); - expect(sqlFile).toContain('false'); - expect(sqlFile).toContain('0'); - expect(sqlFile).toContain(':name'); -}); - -test('runFeatureScaffoldCommand writes the update baseline with pk predicate and explicit set list', async () => { - const workspace = createTempDir('feature-scaffold-update-write'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null,', - ' display_name text,', - ' created_at timestamptz not null default now()', - ');' - ].join('\n'), - 'utf8' - ); - - const result = await runFeatureScaffoldCommand({ - table: 'users', - action: 'update', - rootDir: workspace - }); - - expect(result.featureName).toBe('users-update'); - const inputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-update', 'input.ts'), - 'utf8' - ); - expect(inputFile).toContain('export interface UsersUpdateRequest'); - expect(inputFile).toContain('id: number;'); - expect(inputFile).toContain('email: string;'); - expect(inputFile).toContain('displayName: string | null;'); - expect(inputFile).toContain('createdAt: string;'); - - const workflowFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-update', 'workflow.ts'), - 'utf8' - ); - expect(workflowFile).toContain('display_name: request.displayName,'); - expect(workflowFile).toContain('created_at: request.createdAt,'); - - const outputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-update', 'output.ts'), - 'utf8' - ); - expect(outputFile).toContain('export type UsersUpdateResponse = {'); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-update', 'queries', 'update-users', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).toContain('export interface UpdateUsersQueryParams'); - expect(querySpecFile).toContain('loadSingleRow'); - expect(querySpecFile).not.toContain('queryExactlyOneRow'); - - const sqlFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-update', 'queries', 'update-users', 'update-users.sql'), - 'utf8' - ); - expect(sqlFile).toContain('update "public"."users"'); - expect(sqlFile).toContain('"email" = :email'); - expect(sqlFile).toContain('"display_name" = :display_name'); - expect(sqlFile).toContain('"created_at" = :created_at'); - expect(sqlFile).toContain('where'); - expect(sqlFile).toContain('"id" = :id'); - expect(sqlFile).toContain('returning "id";'); - expect(sqlFile).not.toContain('"id" = :id,\n'); - expect(sqlFile).not.toContain('where\n "email" = :email'); - expect(sqlFile).not.toContain('where\n "display_name" = :display_name'); - expect(sqlFile).not.toContain('where\n "created_at" = :created_at'); - - const updateReadmeFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-update', 'README.md'), - 'utf8' - ); - expect(updateReadmeFile).toContain('mechanical, not a mutable-policy guarantee'); - expect(updateReadmeFile).toContain('`created_at`, `updated_at`, and similar fields are representative follow-up candidates'); - expect(updateReadmeFile).toContain('Generated / identity handling remains explicit in this write scaffold'); - expect(updateReadmeFile).toContain('Write baselines do not infer additional default-expression or policy behavior'); -}); - -test('runFeatureScaffoldCommand writes the delete baseline with key-only predicate', async () => { - const workspace = createTempDir('feature-scaffold-delete-write'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - - const result = await runFeatureScaffoldCommand({ - table: 'users', - action: 'delete', - rootDir: workspace - }); - - expect(result.featureName).toBe('users-delete'); - const inputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-delete', 'input.ts'), - 'utf8' - ); - expect(inputFile).toContain('export interface UsersDeleteRequest'); - expect(inputFile).toContain('id: number;'); - expect(inputFile).not.toContain('email:'); - - const sqlFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-delete', 'queries', 'delete-users', 'delete-users.sql'), - 'utf8' - ); - expect(sqlFile).toContain('delete from "public"."users"'); - expect(sqlFile).toContain('"id" = :id'); - expect(sqlFile).toContain('returning "id";'); - expect(sqlFile).not.toContain('"email" = :email'); - - const deleteReadmeFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-delete', 'README.md'), - 'utf8' - ); - expect(deleteReadmeFile).toContain('does not assume string normalization'); - expect(deleteReadmeFile).not.toContain('trim()` plus empty-string rejection examples for string inputs'); - expect(deleteReadmeFile).toContain('Generated / identity handling remains explicit in this write scaffold'); - expect(deleteReadmeFile).toContain('Write baselines do not infer additional default-expression or policy behavior'); -}); - -test('runFeatureScaffoldCommand writes the get-by-id baseline with zero-or-one contract', async () => { - const workspace = createTempDir('feature-scaffold-get-by-id-write'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null,', - ' display_name text', - ');' - ].join('\n'), - 'utf8' - ); - - const result = await runFeatureScaffoldCommand({ - table: 'users', - action: 'get-by-id', - rootDir: workspace - }); - - expect(result.featureName).toBe('users-get-by-id'); - const inputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-get-by-id', 'input.ts'), - 'utf8' - ); - expect(inputFile).toContain('export interface UsersGetByIdRequest {'); - expect(inputFile).toContain('function parseRequest'); - expect(inputFile).toContain('function normalizeRequest'); - expect(inputFile).toContain('function rejectRequest'); - expect(inputFile).toContain('id: string;'); - expect(inputFile).toContain("id: readString(record[\"id\"], 'UsersGetByIdRequest.id')"); - expect(inputFile).not.toContain('QueryParamsSchema'); - - const workflowFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-get-by-id', 'workflow.ts'), - 'utf8' - ); - expect(workflowFile).toContain('function toQueryParams'); - expect(workflowFile).toContain('Maps the feature request into query params for the query spec.'); - - const outputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-get-by-id', 'output.ts'), - 'utf8' - ); - expect(outputFile).toContain('export type UsersGetByIdResponse = {'); - expect(outputFile).toContain('} | null;'); - expect(outputFile).toContain('if (result === null)'); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-get-by-id', 'queries', 'get-by-id', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).not.toContain('queryZeroOrOneRow'); - expect(querySpecFile).toContain('export interface GetByIdQueryParams {'); - expect(querySpecFile).toContain('export interface GetByIdRow {'); - expect(querySpecFile).toContain('export type GetByIdQueryResult = GetByIdRow | null;'); - expect(querySpecFile).toContain("import { mapGetByIdRowToResult } from './generated/row-mapper.js';"); - expect(querySpecFile).toContain('function parseQueryParams'); - expect(querySpecFile).not.toContain('function parseRow'); - expect(querySpecFile).not.toContain('function mapRowToResult'); - expect(querySpecFile).toContain('loadOptionalRow'); - expect(querySpecFile).toContain('executor.query(sql, params)'); - expect(querySpecFile).toContain('return mapGetByIdRowToResult(row);'); - - const generatedRowMapperFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-get-by-id', 'queries', 'get-by-id', 'generated', 'row-mapper.ts'), - 'utf8' - ); - expect(generatedRowMapperFile).toContain('export function mapGetByIdRowToResult(row: GetByIdRow | undefined): GetByIdQueryResult'); - expect(generatedRowMapperFile).toContain('return null;'); - expect(generatedRowMapperFile).toContain('"display_name": row["display_name"],'); - - const sqlFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-get-by-id', 'queries', 'get-by-id', 'get-by-id.sql'), - 'utf8' - ); - expect(sqlFile).toContain('select'); - expect(sqlFile).toContain('from "public"."users"'); - expect(sqlFile).toContain('"id" = :id'); - - const readmeFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-get-by-id', 'README.md'), - 'utf8' - ); - expect(readmeFile).toContain('The baseline allows not found instead of treating it as an exception.'); - expect(readmeFile).toContain('does not assume that every ID is a 32-bit integer'); - expect(readmeFile).toContain('rejects unsupported request fields instead of silently ignoring them'); - expect(readmeFile).toContain('tightened to a strict one-row contract'); -}); - -test('runFeatureScaffoldCommand writes the list baseline with generated paging and items response', async () => { - const workspace = createTempDir('feature-scaffold-list-write'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial8 primary key,', - ' email text not null,', - ' is_active boolean not null default true', - ');' - ].join('\n'), - 'utf8' - ); - - const result = await runFeatureScaffoldCommand({ - table: 'users', - action: 'list', - rootDir: workspace - }); - - expect(result.featureName).toBe('users-list'); - const inputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-list', 'input.ts'), - 'utf8' - ); - expect(inputFile).toContain('export type UsersListRequest = {};'); - expect(inputFile).toContain('function parseRequest'); - expect(inputFile).toContain('function normalizeRequest'); - expect(inputFile).toContain('function rejectRequest'); - - const workflowFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-list', 'workflow.ts'), - 'utf8' - ); - expect(workflowFile).toContain('Maps the feature request into query params for the query spec.'); - expect(workflowFile).toContain('function toQueryParams'); - expect(workflowFile).toContain('return {} as ListQueryParams;'); - - const outputFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-list', 'output.ts'), - 'utf8' - ); - expect(outputFile).toContain('export type UsersListResponse = {'); - expect(outputFile).toContain('items: Array<{'); - expect(outputFile).toContain('id: string;'); - expect(outputFile).toContain('items: result.items.map((item) => ({'); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-list', 'queries', 'list', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).not.toContain("import { createCatalogExecutor, type QuerySpec } from '@rawsql-ts/sql-contract';"); - expect(querySpecFile).toContain('export type ListQueryParams = {};'); - expect(querySpecFile).toContain('export interface ListRow {'); - expect(querySpecFile).toContain('const DEFAULT_PAGE_SIZE = 50;'); - expect(querySpecFile).not.toContain('allowNamedParamsWithoutBinder: true'); - expect(querySpecFile).toContain('items: ListRow[];'); - expect(querySpecFile).toContain('limit: DEFAULT_PAGE_SIZE'); - expect(querySpecFile).toContain('function parseQueryParams'); - expect(querySpecFile).not.toContain('function parseRow'); - expect(querySpecFile).toContain('function toQueryParams'); - expect(querySpecFile).toContain("import { mapListRowsToResult } from './generated/row-mapper.js';"); - expect(querySpecFile).not.toContain('function mapRowsToResult'); - expect(querySpecFile).toContain('return mapListRowsToResult(rows);'); - - const generatedRowMapperFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-list', 'queries', 'list', 'generated', 'row-mapper.ts'), - 'utf8' - ); - expect(generatedRowMapperFile).toContain('export function mapListRowsToResult(rows: ListRow[]): ListQueryResult'); - expect(generatedRowMapperFile).toContain("const items = new Array(rows.length);"); - expect(generatedRowMapperFile).toContain('for (let index = 0; index < rows.length; index += 1)'); - expect(generatedRowMapperFile).not.toContain('rows.map('); - expect(generatedRowMapperFile).toContain('"is_active": row["is_active"],'); - - const sqlFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-list', 'queries', 'list', 'list.sql'), - 'utf8' - ); - expect(sqlFile).toContain('order by'); - expect(sqlFile).toContain('"id" asc'); - expect(sqlFile).toContain('limit :limit;'); - - const readmeFile = readFileSync( - path.join(workspace, 'src', 'features', 'users-list', 'README.md'), - 'utf8' - ); - expect(readmeFile).toContain('filter fields'); - expect(readmeFile).toContain('paging enabled by default'); - expect(readmeFile).toContain('`DEFAULT_PAGE_SIZE` is set to `50`'); - expect(readmeFile).toContain('stable primary-key ordering'); - expect(readmeFile).toContain('does not assume that every ID is a 32-bit integer'); - expect(readmeFile).toContain('rejects unsupported request fields instead of silently ignoring them'); - expect(readmeFile).toContain('The baseline response is `{ items: [...] }`'); -}); - -test('runFeatureScaffoldCommand keeps numeric and decimal read contracts string-based', async () => { - const workspace = createTempDir('feature-scaffold-list-numeric-write'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'products.sql'), - [ - 'create table public.products (', - ' id serial8 primary key,', - ' price decimal not null,', - ' score numeric', - ');' - ].join('\n'), - 'utf8' - ); - - const result = await runFeatureScaffoldCommand({ - table: 'products', - action: 'list', - rootDir: workspace - }); - - expect(result.featureName).toBe('products-list'); - const outputFile = readFileSync( - path.join(workspace, 'src', 'features', 'products-list', 'output.ts'), - 'utf8' - ); - expect(outputFile).toContain('price: string;'); - expect(outputFile).toContain('score: string | null;'); - expect(outputFile).not.toContain('price: number;'); - expect(outputFile).not.toContain('score: number | null;'); - - const querySpecFile = readFileSync( - path.join(workspace, 'src', 'features', 'products-list', 'queries', 'list', 'boundary.ts'), - 'utf8' - ); - expect(querySpecFile).toContain('price: string;'); - expect(querySpecFile).toContain('score: string | null;'); - expect(querySpecFile).not.toContain('price: z.number().finite()'); - expect(querySpecFile).not.toContain('score: z.number().finite()'); -}); - -test('runFeatureScaffoldCommand preserves existing feature files unless force is set', async () => { - const workspace = createTempDir('feature-scaffold-collision'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(path.join(featureDir, 'queries', 'insert-users'), { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing file\n', 'utf8'); - writeFileSync(path.join(featureDir, 'queries', 'insert-users', 'boundary.ts'), '// existing query boundary\n', 'utf8'); - writeFileSync(path.join(featureDir, 'queries', 'insert-users', 'insert-users.sql'), '-- existing sql\n', 'utf8'); - - await expect( - runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }) - ).rejects.toThrow(/overwrite existing files/i); - expect(readFileSync(path.join(featureDir, 'queries', 'insert-users', 'boundary.ts'), 'utf8')).toBe('// existing query boundary\n'); - expect(readFileSync(path.join(featureDir, 'queries', 'insert-users', 'insert-users.sql'), 'utf8')).toBe('-- existing sql\n'); -}); - -test('runFeatureScaffoldCommand overwrites scaffold-owned feature files with --force', async () => { - const workspace = createTempDir('feature-scaffold-force'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - mkdirSync(ddlDir, { recursive: true }); - mkdirSync(path.join(featureDir, 'queries', 'insert-users'), { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - writeFileSync(path.join(featureDir, 'boundary.ts'), '// existing boundary\n', 'utf8'); - writeFileSync(path.join(featureDir, 'queries', 'insert-users', 'boundary.ts'), '// existing query boundary\n', 'utf8'); - writeFileSync(path.join(featureDir, 'queries', 'insert-users', 'insert-users.sql'), '-- existing sql\n', 'utf8'); - writeFileSync(path.join(featureDir, 'README.md'), '# existing readme\n', 'utf8'); - mkdirSync(path.join(featureDir, 'queries', 'insert-users', 'generated'), { recursive: true }); - writeFileSync( - path.join(featureDir, 'queries', 'insert-users', 'generated', 'row-mapper.ts'), - '// stale generated mapper\n', - 'utf8' - ); - - const result = await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - force: true, - rootDir: workspace - }); - - expect(result.dryRun).toBe(false); - expect(readFileSync(path.join(featureDir, 'boundary.ts'), 'utf8')).toContain( - 'export async function execute(' - ); - expect(readFileSync(path.join(featureDir, 'input.ts'), 'utf8')).toContain('export function parseRequest'); - expect(readFileSync(path.join(featureDir, 'workflow.ts'), 'utf8')).toContain('export async function execute('); - expect(readFileSync(path.join(featureDir, 'output.ts'), 'utf8')).toContain('export function buildResult'); - expect(readFileSync(path.join(featureDir, 'queries', 'insert-users', 'boundary.ts'), 'utf8')).not.toContain('// existing query boundary'); - expect(readFileSync(path.join(featureDir, 'queries', 'insert-users', 'insert-users.sql'), 'utf8')).not.toContain('-- existing sql'); - expect(readFileSync(path.join(featureDir, 'queries', 'insert-users', 'generated', 'row-mapper.ts'), 'utf8')).toContain( - 'export function mapInsertUsersRowToResult(row: InsertUsersRow): InsertUsersQueryResult' - ); -}); - -test('generated mapper check fails on drift and reports the regeneration command', async () => { - const workspace = createTempDir('feature-generated-mapper-check'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const generatedMapperFile = path.join( - workspace, - 'src', - 'features', - 'users-insert', - 'queries', - 'insert-users', - 'generated', - 'row-mapper.ts' - ); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }); - writeFileSync(generatedMapperFile, '// stale generated mapper\n', 'utf8'); - - await expect( - runFeatureGeneratedMapperCheckCommand({ - feature: 'users-insert', - query: 'insert-users', - rootDir: workspace - }) - ).rejects.toThrow(/ztd feature generated-mapper generate --feature users-insert --query insert-users/); -}); - -test('generated mapper check fails when query SQL changes without regenerating the mapper', async () => { - const workspace = createTempDir('feature-generated-mapper-sql-drift'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const querySqlFile = path.join( - workspace, - 'src', - 'features', - 'users-insert', - 'queries', - 'insert-users', - 'insert-users.sql' - ); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }); - writeFileSync(querySqlFile, `${readFileSync(querySqlFile, 'utf8')}\n-- reviewer edit\n`, 'utf8'); - - await expect( - runFeatureGeneratedMapperCheckCommand({ - feature: 'users-insert', - query: 'insert-users', - rootDir: workspace - }) - ).rejects.toThrow(/ztd feature generated-mapper generate --feature users-insert --query insert-users/); -}); - -test('generated mapper check fails when the query boundary contract changes without regenerating the mapper', async () => { - const workspace = createTempDir('feature-generated-mapper-boundary-drift'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const queryBoundaryFile = path.join( - workspace, - 'src', - 'features', - 'users-insert', - 'queries', - 'insert-users', - 'boundary.ts' - ); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }); - writeFileSync(queryBoundaryFile, `${readFileSync(queryBoundaryFile, 'utf8')}\n// reviewer contract edit\n`, 'utf8'); - - await expect( - runFeatureGeneratedMapperCheckCommand({ - feature: 'users-insert', - query: 'insert-users', - rootDir: workspace - }) - ).rejects.toThrow(/ztd feature generated-mapper generate --feature users-insert --query insert-users/); -}); - -test('generated mapper generate force-syncs machine-owned files from the boundary contract', async () => { - const workspace = createTempDir('feature-generated-mapper-generate'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const generatedMapperFile = path.join( - workspace, - 'src', - 'features', - 'users-insert', - 'queries', - 'insert-users', - 'generated', - 'row-mapper.ts' - ); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'users.sql'), - [ - 'create table public.users (', - ' id serial primary key,', - ' email text not null', - ');' - ].join('\n'), - 'utf8' - ); - await runFeatureScaffoldCommand({ - table: 'users', - action: 'insert', - rootDir: workspace - }); - writeFileSync(generatedMapperFile, '// stale generated mapper\n', 'utf8'); - - const result = await runFeatureGeneratedMapperGenerateCommand({ - feature: 'users-insert', - query: 'insert-users', - rootDir: workspace - }); - - expect(result.outputs).toEqual([ - expect.objectContaining({ - path: 'src/features/users-insert/queries/insert-users/generated/row-mapper.ts', - written: true, - changed: true - }) - ]); - expect(readFileSync(generatedMapperFile, 'utf8')).toContain( - 'export function mapInsertUsersRowToResult(row: InsertUsersRow): InsertUsersQueryResult' - ); - await expect( - runFeatureGeneratedMapperCheckCommand({ - feature: 'users-insert', - query: 'insert-users', - rootDir: workspace - }) - ).resolves.toMatchObject({ ok: true }); -}); - -test('generated mapper generate supports explicit one-to-many metadata with direct assignment', async () => { - const workspace = createTempDir('feature-generated-mapper-hasmany'); - const queryDir = path.join(workspace, 'src', 'features', 'orders-list', 'queries', 'list'); - const generatedMapperFile = path.join(queryDir, 'generated', 'row-mapper.ts'); - mkdirSync(path.join(queryDir, 'generated'), { recursive: true }); - writeFileSync( - path.join(queryDir, 'boundary.ts'), - [ - "import { z } from 'zod';", - "import { mapListRowsToResult } from './generated/row-mapper.js';", - '', - 'const RowSchema = z.object({', - ' order_id: z.string(),', - ' order_date: z.string(),', - ' detail_id: z.string().nullable(),', - ' product_name: z.string().nullable(),', - '}).strict();', - '', - 'const DetailSchema = z.object({', - ' id: z.string(),', - ' productName: z.string().nullable(),', - '}).strict();', - '', - 'const OrderSchema = z.object({', - ' id: z.string(),', - ' orderDate: z.string(),', - ' details: z.array(DetailSchema),', - '}).strict();', - '', - 'const QueryResultSchema = z.object({', - ' items: z.array(OrderSchema),', - '}).strict();', - '', - 'export type ListQueryResult = z.infer;', - 'export type ListRow = z.infer;', - '', - 'export const ListGeneratedMapperMetadata = {', - ' "relations": {', - ' "hasMany": [', - ' {', - ' "kind": "hasMany",', - ' "root": {', - ' "key": ["order_id"],', - ' "columns": {', - ' "id": "order_id",', - ' "orderDate": "order_date"', - ' }', - ' },', - ' "collection": {', - ' "property": "details",', - ' "key": ["detail_id"],', - ' "presence": ["detail_id"],', - ' "columns": {', - ' "id": "detail_id",', - ' "productName": "product_name"', - ' }', - ' }', - ' }', - ' ]', - ' }', - '} as const;', - '', - 'export async function executeListQuerySpec(rows: ListRow[]): Promise {', - ' return mapListRowsToResult(rows);', - '}', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(queryDir, 'list.sql'), - [ - 'select', - ' order_id,', - ' order_date,', - ' detail_id,', - ' product_name', - 'from order_rows', - 'order by order_id asc, detail_id asc;', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync(generatedMapperFile, '// stale mapper\n', 'utf8'); - - await runFeatureGeneratedMapperGenerateCommand({ - feature: 'orders-list', - query: 'list', - rootDir: workspace - }); - - const generated = readFileSync(generatedMapperFile, 'utf8'); - expect(generated).toContain('const rootIndex = new Map();'); - expect(generated).toContain('const rootKey = serializeGeneratedKey([row["order_id"]]);'); - expect(generated).toContain('const text = String(value);'); - expect(generated).toContain('return `${typeof value}:${text.length}:${text}`;'); - expect(generated).toContain('details: [],'); - expect(generated).toContain('root.details.push({'); - expect(generated).toContain('if (row["detail_id"] !== null && row["detail_id"] !== undefined)'); - expect(generated).toContain('id: row["order_id"],'); - expect(generated).toContain('productName: row["product_name"],'); - expect(generated).not.toContain('...row'); - expect(generated).not.toContain(".join('|')"); - - const module = await import(`${pathToFileURL(generatedMapperFile).href}?behavior=${Date.now()}`); - const result = module.mapListRowsToResult([ - { - order_id: 'order-1', - order_date: '2026-05-03', - detail_id: 'detail-1', - product_name: 'Keyboard', - }, - { - order_id: 'order-1', - order_date: '2026-05-03', - detail_id: 'detail-2', - product_name: 'Mouse', - }, - { - order_id: 'order-2', - order_date: '2026-05-04', - detail_id: null, - product_name: null, - }, - ]); - expect(result).toEqual({ - items: [ - { - id: 'order-1', - orderDate: '2026-05-03', - details: [ - { id: 'detail-1', productName: 'Keyboard' }, - { id: 'detail-2', productName: 'Mouse' }, - ], - }, - { - id: 'order-2', - orderDate: '2026-05-04', - details: [], - }, - ], - }); -}); - -test('generated mapper generate explains that hasMany metadata must be JSON-compatible', async () => { - const workspace = createTempDir('feature-generated-mapper-hasmany-non-json'); - const queryDir = path.join(workspace, 'src', 'features', 'orders-list', 'queries', 'list'); - mkdirSync(path.join(queryDir, 'generated'), { recursive: true }); - writeFileSync( - path.join(queryDir, 'boundary.ts'), - [ - "import { z } from 'zod';", - '', - 'const RowSchema = z.object({', - ' order_id: z.string(),', - ' detail_id: z.string().nullable(),', - '}).strict();', - '', - 'const QueryResultSchema = z.object({', - ' items: z.array(z.object({ id: z.string(), details: z.array(z.object({ id: z.string() })) })),', - '}).strict();', - '', - 'export type ListQueryResult = z.infer;', - 'export type ListRow = z.infer;', - '', - 'export const ListGeneratedMapperMetadata = {', - ' relations: {', - ' hasMany: []', - ' }', - '} as const;', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync(path.join(queryDir, 'list.sql'), 'select order_id, detail_id from order_rows;\n', 'utf8'); - - await expect( - runFeatureGeneratedMapperGenerateCommand({ - feature: 'orders-list', - query: 'list', - rootDir: workspace - }) - ).rejects.toThrow(/metadata object literal must be JSON-compatible.*Quote object keys and string values/s); -}); - -test('generated mapper generate fails when one-to-many metadata references a missing row column', async () => { - const workspace = createTempDir('feature-generated-mapper-hasmany-invalid'); - const queryDir = path.join(workspace, 'src', 'features', 'orders-list', 'queries', 'list'); - mkdirSync(path.join(queryDir, 'generated'), { recursive: true }); - writeFileSync( - path.join(queryDir, 'boundary.ts'), - [ - "import { z } from 'zod';", - '', - 'const RowSchema = z.object({', - ' order_id: z.string(),', - ' detail_id: z.string().nullable(),', - '}).strict();', - '', - 'const QueryResultSchema = z.object({', - ' items: z.array(z.object({ id: z.string(), details: z.array(z.object({ id: z.string() })) })),', - '}).strict();', - '', - 'export type ListQueryResult = z.infer;', - 'export type ListRow = z.infer;', - '', - 'export const ListGeneratedMapperMetadata = {', - ' "relations": {', - ' "hasMany": [', - ' {', - ' "kind": "hasMany",', - ' "root": { "key": ["order_id"], "columns": { "id": "order_id" } },', - ' "collection": {', - ' "property": "details",', - ' "key": ["missing_detail_id"],', - ' "presence": ["detail_id"],', - ' "columns": { "id": "detail_id" }', - ' }', - ' }', - ' ]', - ' }', - '} as const;', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync(path.join(queryDir, 'list.sql'), 'select order_id, detail_id from order_rows;\n', 'utf8'); - - await expect( - runFeatureGeneratedMapperGenerateCommand({ - feature: 'orders-list', - query: 'list', - rootDir: workspace - }) - ).rejects.toThrow(/metadata column "missing_detail_id" is not declared in RowSchema/); -}); diff --git a/packages/ztd-cli/tests/featureTestsScaffold.unit.test.ts b/packages/ztd-cli/tests/featureTestsScaffold.unit.test.ts deleted file mode 100644 index 33eab648d..000000000 --- a/packages/ztd-cli/tests/featureTestsScaffold.unit.test.ts +++ /dev/null @@ -1,602 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -import { runFeatureTestsScaffoldCommand } from '../src/commands/featureTests'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function seedSharedZtdSupport(workspace: string): void { - const supportDir = path.join(workspace, 'tests', 'support', 'ztd'); - mkdirSync(supportDir, { recursive: true }); - writeFileSync( - path.join(supportDir, 'harness.ts'), - 'export async function runQuerySpecZtdCases() {}\nexport async function runQuerySpecTraditionalCases() {}\n', - 'utf8' - ); - writeFileSync( - path.join(supportDir, 'case-types.ts'), - 'export type QuerySpecZtdCase = { beforeDb: A; input: B; output: C };\nexport type QuerySpecTraditionalCase = { beforeDb: A; input: B; output: C; afterDb?: A };\n', - 'utf8' - ); -} - -function seedStableTestAliases(workspace: string): void { - writeFileSync( - path.join(workspace, 'package.json'), - `${JSON.stringify({ - name: 'feature-tests-scaffold-test', - private: true, - type: 'module', - imports: { - '#tests/*.js': { - types: './tests/*.ts', - default: './tests/*.ts' - } - } - }, null, 2)}\n`, - 'utf8' - ); - writeFileSync( - path.join(workspace, 'tsconfig.json'), - `${JSON.stringify({ - compilerOptions: { - baseUrl: '.', - paths: { - '#tests/*': ['tests/*'] - } - } - }, null, 2)}\n`, - 'utf8' - ); - writeFileSync( - path.join(workspace, 'vitest.config.ts'), - [ - "import { defineConfig } from 'vitest/config';", - '', - 'export default defineConfig({', - ' resolve: {', - " alias: { '#tests': '/virtual/tests' }", - ' }', - '});', - '' - ].join('\n'), - 'utf8' - ); -} - -function seedQueryBoundaryWithTypedContracts(queryDir: string): void { - writeFileSync( - path.join(queryDir, 'boundary.ts'), - [ - 'export type InsertUsersQueryParams = Record;', - 'export type InsertUsersQueryResult = Record;', - 'export async function executeInsertUsersBoundary() { return {}; }', - '' - ].join('\n'), - 'utf8' - ); -} - -test('runFeatureTestsScaffoldCommand writes query-local ZTD scaffolds from the current feature files', async () => { - const workspace = createTempDir('feature-tests-scaffold'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - const queryDir = path.join(featureDir, 'queries', 'insert-users'); - mkdirSync(queryDir, { recursive: true }); - mkdirSync(path.join(workspace, 'db', 'ddl'), { recursive: true }); - seedSharedZtdSupport(workspace); - - writeFileSync( - path.join(featureDir, 'boundary.ts'), - [ - "import { z } from 'zod';", - '', - 'const RequestSchema = z.object({', - ' email: z.string()', - '}).strict();', - '', - 'export async function executeUsersInsertBoundary() {', - ' return RequestSchema.parse({ email: "alice@example.com" });', - '}' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(queryDir, 'boundary.ts'), - [ - 'export type InsertUsersQueryParams = { email: string };', - 'export type InsertUsersQueryResult = { user_id: string };', - '', - "export async function executeInsertUsersBoundary() {", - ' return { user_id: "placeholder" };', - '}' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(queryDir, 'insert-users.sql'), - 'insert into public.users (email) values (:email) returning user_id;', - 'utf8' - ); - writeFileSync( - path.join(workspace, 'db', 'ddl', 'public.sql'), - [ - 'create table public.users (', - ' user_id uuid primary key,', - ' email text not null unique,', - ' status text not null check (status in (\'active\', \'disabled\')),', - ' account_id uuid references public.accounts(account_id)', - ');', - '' - ].join('\n'), - 'utf8' - ); - - const result = await runFeatureTestsScaffoldCommand({ - feature: 'users-insert', - rootDir: workspace - }); - - expect(result).toMatchObject({ - featureName: 'users-insert', - queryName: 'insert-users', - testKind: 'ztd', - dryRun: false, - unsupportedConstraintGuidance: expect.arrayContaining([ - expect.objectContaining({ - table: 'public.users', - constraint: 'unique', - sourcePath: 'db/ddl/public.sql' - }), - expect.objectContaining({ - table: 'public.users', - constraint: 'not-null', - sourcePath: 'db/ddl/public.sql' - }), - expect.objectContaining({ - table: 'public.users', - constraint: 'check', - sourcePath: 'db/ddl/public.sql' - }), - expect.objectContaining({ - table: 'public.users', - constraint: 'foreign-key', - sourcePath: 'db/ddl/public.sql' - }) - ]) - }); - expect(result.outputs.map((output) => output.path)).toEqual( - expect.arrayContaining([ - 'src/features/users-insert/queries/insert-users/tests', - 'src/features/users-insert/queries/insert-users/tests/generated', - 'src/features/users-insert/queries/insert-users/tests/cases', - 'src/features/users-insert/queries/insert-users/tests/insert-users.boundary.ztd.test.ts', - 'src/features/users-insert/queries/insert-users/tests/cases/basic.case.ts', - 'src/features/users-insert/queries/insert-users/tests/generated/TEST_PLAN.md', - 'src/features/users-insert/queries/insert-users/tests/generated/analysis.json' - ]) - ); - - const vitestEntrypointFile = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'insert-users.boundary.ztd.test.ts'), - 'utf8' - ); - expect(vitestEntrypointFile).toContain("import { expect, test } from 'vitest';"); - expect(vitestEntrypointFile).toContain("import { runQuerySpecZtdCases } from '../../../../../../tests/support/ztd/harness.js';"); - expect(vitestEntrypointFile).toContain("import { executeBoundaryQuerySpec } from '../boundary.js';"); - expect(vitestEntrypointFile).toContain("import cases from './cases/basic.case.js';"); - expect(vitestEntrypointFile).toContain("import type { InsertUsersQueryBoundaryZtdCase } from './boundary-ztd-types.js';"); - expect(vitestEntrypointFile).toContain("test.skip('users-insert/insert-users boundary ZTD case scaffold placeholder'"); - expect(vitestEntrypointFile).toContain('TODO: Fill tests/cases/basic.case.ts, then change this to test(...).'); - expect(vitestEntrypointFile).not.toContain("test('users-insert/insert-users boundary ZTD cases run through the fixed app-level harness'"); - expect(vitestEntrypointFile).toContain('expect(cases.length).toBeGreaterThan(0);'); - expect(vitestEntrypointFile).toContain('const evidence = await runQuerySpecZtdCases(cases, executeBoundaryQuerySpec);'); - expect(vitestEntrypointFile).toContain("expect(evidence.every((entry) => entry.mode === 'ztd')).toBe(true);"); - expect(vitestEntrypointFile).toContain('expect(evidence.every((entry) => entry.physicalSetupUsed === false)).toBe(true);'); - - const basicCaseFile = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'cases', 'basic.case.ts'), - 'utf8' - ); - expect(basicCaseFile).toContain('TODO: Fill fixture rows for the tables the CLI could identify.'); - expect(basicCaseFile).toContain('CLI hints: public.users.'); - expect(basicCaseFile).toContain('beforeDb: { public: { users: [] } } as InsertUsersBeforeDb,'); - expect(basicCaseFile).toContain('TODO: Replace the placeholder input with concrete query parameters before enabling the generated test.'); - expect(basicCaseFile).toContain('CLI hints: email.'); - expect(basicCaseFile).toContain('input: {} as InsertUsersInput,'); - expect(basicCaseFile).toContain('TODO: Replace the placeholder output with the exact result expected from the query boundary.'); - expect(basicCaseFile).toContain('CLI hints: user_id.'); - expect(basicCaseFile).toContain('output: {} as InsertUsersOutput,'); - expect(basicCaseFile).toContain('public.users has UNIQUE constraint coverage that is not fully enforced by the ZTD lane'); - expect(basicCaseFile).toContain('public.users has CHECK constraint coverage that is not fully enforced by the ZTD lane'); - - const queryTypesFile = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'boundary-ztd-types.ts'), - 'utf8' - ); - expect(queryTypesFile).toContain('export type InsertUsersBeforeDb = { public: { users: readonly { email?: unknown; user_id?: unknown }[] } };'); - expect(queryTypesFile).toContain("import type { QuerySpecZtdCase } from '../../../../../../tests/support/ztd/case-types.js';"); - expect(queryTypesFile).toContain("import type { InsertUsersQueryParams, InsertUsersQueryResult } from '../boundary.js';"); - expect(queryTypesFile).toContain('export type InsertUsersInput = InsertUsersQueryParams;'); - expect(queryTypesFile).toContain('export type InsertUsersOutput = InsertUsersQueryResult;'); - expect(queryTypesFile).not.toContain('AfterDb'); - expect(queryTypesFile).not.toContain('InsertUsersBeforeDb = Record'); - - const testPlanFile = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'generated', 'TEST_PLAN.md'), - 'utf8' - ); - expect(testPlanFile).toContain('# users-insert / insert-users boundary test plan'); - expect(testPlanFile).toContain('schemaVersion: 1'); - expect(testPlanFile).toContain('featureId: users-insert'); - expect(testPlanFile).toContain('testKind: ztd'); - expect(testPlanFile).toContain('resultCardinality: one'); - expect(testPlanFile).toContain('fixedVerifier: tests/support/ztd/harness.ts'); - expect(testPlanFile).toContain('vitestEntrypoint: src/features/users-insert/queries/insert-users/tests/insert-users.boundary.ztd.test.ts'); - expect(testPlanFile).toContain('generatedDir: src/features/users-insert/queries/insert-users/tests/generated'); - expect(testPlanFile).toContain('casesDir: src/features/users-insert/queries/insert-users/tests/cases'); - expect(testPlanFile).toContain('analysisJson: src/features/users-insert/queries/insert-users/tests/generated/analysis.json'); - expect(testPlanFile).toContain('src/features/users-insert/boundary.ts'); - expect(testPlanFile).toContain('src/features/users-insert/queries/insert-users/boundary.ts'); - expect(testPlanFile).toContain('src/features/users-insert/queries/insert-users/insert-users.sql'); - expect(testPlanFile).toContain('Fixture Candidate Tables'); - expect(testPlanFile).toContain('- public.users'); - expect(testPlanFile).toContain('Write Tables'); - expect(testPlanFile).toContain('Validation Scenario Hints'); - expect(testPlanFile).toContain('DB Scenario Hints'); - expect(testPlanFile).toContain('Constraint Coverage Boundary'); - expect(testPlanFile).toContain('required INSERT column presence for NOT NULL columns without defaults'); - expect(testPlanFile).toContain('simple UNIQUE checks are feasible ZTD preflight candidates'); - expect(testPlanFile).toContain('Use a traditional physical DB lane for DB-enforced fail-fast behavior today'); - expect(testPlanFile).toContain('Unsupported Constraint Follow-up'); - expect(testPlanFile).toContain('TODO: public.users has UNIQUE constraint coverage that is not fully enforced by the ZTD lane'); - expect(testPlanFile).toContain('Case Readiness'); - expect(testPlanFile).toContain('Generated ZTD cases are intentionally placeholders.'); - expect(testPlanFile).toContain('change the generated Vitest entrypoint from `test.skip` to `test`'); - expect(testPlanFile).toContain('After DB Semantics'); - expect(testPlanFile).toContain('machine-checkable evidence'); - expect(testPlanFile).toContain('physicalSetupUsed=false'); - expect(testPlanFile).toContain('This ZTD lane does not expose `afterDb`'); - expect(testPlanFile).toContain('ZTD_SQL_TRACE=1'); - - const analysisFile = JSON.parse( - readFileSync(path.join(featureDir, 'queries', 'insert-users', 'tests', 'generated', 'analysis.json'), 'utf8') - ) as { - schemaVersion: number; - featureId: string; - testKind: string; - fixtureCandidateTables: string[]; - writesTables: string[]; - validationScenarioHints: string[]; - dbScenarioHints: string[]; - constraintCoverageNotes: string[]; - unsupportedConstraintGuidance: Array<{ table: string; constraint: string; sourcePath: string; recommendation: string; todo: string }>; - resultCardinality: string; - }; - - expect(analysisFile).toMatchObject({ - schemaVersion: 1, - featureId: 'users-insert', - testKind: 'ztd', - fixtureCandidateTables: ['public.users'], - writesTables: ['public.users'], - validationScenarioHints: expect.arrayContaining([ - 'Keep feature-boundary validation separate from query-boundary DB-backed execution.', - 'Validation failures belong in the feature-root mock test lane.' - ]), - dbScenarioHints: expect.arrayContaining([ - 'Use the fixed app-level harness and query-local cases to keep the ZTD path thin.', - 'Keep db/input/output visible in the case file so the AI can fill the query contract without re-deriving the scaffold.' - ]), - constraintCoverageNotes: expect.arrayContaining([ - 'ZTD currently verifies rewritten SQL input/output, fixture table/column shape, evidence fields, and required INSERT column presence for NOT NULL columns without defaults when table definitions are available.', - 'Explicit NULL values for NOT NULL columns and simple UNIQUE checks are feasible ZTD preflight candidates, but they are not enforced by the current fixture/CTE rewrite lane.', - 'Use a traditional physical DB lane for DB-enforced fail-fast behavior today, especially CHECK, foreign key, exclusion, deferrable, partial/expression UNIQUE, collation-sensitive, or full PostgreSQL constraint semantics.' - ]), - unsupportedConstraintGuidance: expect.arrayContaining([ - expect.objectContaining({ - table: 'public.users', - constraint: 'unique', - recommendation: expect.stringContaining('current ZTD fixture/CTE execution does not enforce UNIQUE violations') - }), - expect.objectContaining({ - table: 'public.users', - constraint: 'foreign-key', - todo: expect.stringContaining('traditional physical DB case') - }) - ]), - resultCardinality: 'one' - }); -}); - -test('runFeatureTestsScaffoldCommand refreshes generated analysis without overwriting persistent cases', async () => { - const workspace = createTempDir('feature-tests-cases'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - const queryDir = path.join(featureDir, 'queries', 'insert-users'); - const testsDir = path.join(queryDir, 'tests'); - const generatedDir = path.join(testsDir, 'generated'); - const casesDir = path.join(testsDir, 'cases'); - mkdirSync(queryDir, { recursive: true }); - mkdirSync(casesDir, { recursive: true }); - mkdirSync(generatedDir, { recursive: true }); - seedSharedZtdSupport(workspace); - - writeFileSync( - path.join(featureDir, 'boundary.ts'), - [ - "import { z } from 'zod';", - '', - 'const RequestSchema = z.object({', - ' email: z.string()', - '}).strict();', - '', - 'export async function executeUsersInsertBoundary() {', - ' return RequestSchema.parse({ email: "alice@example.com" });', - '}' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(queryDir, 'boundary.ts'), - [ - "export async function executeInsertUsersBoundary() {", - ' return { user_id: "placeholder" };', - '}' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(queryDir, 'insert-users.sql'), - 'insert into public.users (email) values (:email) returning user_id;', - 'utf8' - ); - - const caseFile = path.join(casesDir, 'basic.case.ts'); - writeFileSync(caseFile, "export const marker = 'keep-me';\n", 'utf8'); - const entrypointFile = path.join(testsDir, 'insert-users.boundary.ztd.test.ts'); - writeFileSync(entrypointFile, "export const entrypointMarker = 'keep-me';\n", 'utf8'); - const queryTypesFile = path.join(testsDir, 'boundary-ztd-types.ts'); - writeFileSync(queryTypesFile, "export const queryTypesMarker = 'refresh-me';\n", 'utf8'); - - await runFeatureTestsScaffoldCommand({ - feature: 'users-insert', - rootDir: workspace, - force: true - }); - - expect(readFileSync(caseFile, 'utf8')).toBe("export const marker = 'keep-me';\n"); - expect(readFileSync(entrypointFile, 'utf8')).toBe("export const entrypointMarker = 'keep-me';\n"); - expect(readFileSync(queryTypesFile, 'utf8')).not.toBe("export const queryTypesMarker = 'refresh-me';\n"); - expect(readFileSync(path.join(generatedDir, 'analysis.json'), 'utf8')).toContain('"schemaVersion": 1'); -}); - -test('runFeatureTestsScaffoldCommand uses stable shared test imports when the workspace supports #tests', async () => { - const workspace = createTempDir('feature-tests-stable-imports'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - const queryDir = path.join(featureDir, 'queries', 'insert-users'); - mkdirSync(queryDir, { recursive: true }); - seedSharedZtdSupport(workspace); - seedStableTestAliases(workspace); - - writeFileSync(path.join(featureDir, 'boundary.ts'), 'export const RequestSchema = null;\n', 'utf8'); - seedQueryBoundaryWithTypedContracts(queryDir); - writeFileSync(path.join(queryDir, 'insert-users.sql'), 'select 1;', 'utf8'); - - await runFeatureTestsScaffoldCommand({ - feature: 'users-insert', - rootDir: workspace - }); - - const vitestEntrypointFile = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'insert-users.boundary.ztd.test.ts'), - 'utf8' - ); - expect(vitestEntrypointFile).toContain("import { runQuerySpecZtdCases } from '#tests/support/ztd/harness.js';"); - - const queryTypesFile = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'boundary-ztd-types.ts'), - 'utf8' - ); - expect(queryTypesFile).toContain("import type { QuerySpecZtdCase } from '#tests/support/ztd/case-types.js';"); - expect(queryTypesFile).toContain("import type { InsertUsersQueryParams, InsertUsersQueryResult } from '../boundary.js';"); -}); - -test('runFeatureTestsScaffoldCommand fails fast when #tests alias support is partial', async () => { - const workspace = createTempDir('feature-tests-partial-import-alias'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - const queryDir = path.join(featureDir, 'queries', 'insert-users'); - mkdirSync(queryDir, { recursive: true }); - seedSharedZtdSupport(workspace); - writeFileSync( - path.join(workspace, 'package.json'), - `${JSON.stringify({ - name: 'feature-tests-scaffold-test', - private: true, - type: 'module', - imports: { - '#tests/*.js': { - types: './tests/*.ts', - default: './tests/*.ts' - } - } - }, null, 2)}\n`, - 'utf8' - ); - - writeFileSync(path.join(featureDir, 'boundary.ts'), 'export const RequestSchema = null;\n', 'utf8'); - seedQueryBoundaryWithTypedContracts(queryDir); - writeFileSync(path.join(queryDir, 'insert-users.sql'), 'select 1;', 'utf8'); - - await expect( - runFeatureTestsScaffoldCommand({ - feature: 'users-insert', - rootDir: workspace - }) - ).rejects.toThrow(/partial #tests alias configuration/i); -}); - -test('runFeatureTestsScaffoldCommand fails fast when starter-owned ZTD support is missing', async () => { - const workspace = createTempDir('feature-tests-missing-support'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - const queryDir = path.join(featureDir, 'queries', 'insert-users'); - mkdirSync(queryDir, { recursive: true }); - - writeFileSync(path.join(featureDir, 'boundary.ts'), 'export const RequestSchema = null;\n', 'utf8'); - writeFileSync(path.join(queryDir, 'boundary.ts'), 'export async function executeInsertUsersBoundary() { return {}; }\n', 'utf8'); - writeFileSync(path.join(queryDir, 'insert-users.sql'), 'select 1;', 'utf8'); - - await expect( - runFeatureTestsScaffoldCommand({ - feature: 'users-insert', - rootDir: workspace - }) - ).rejects.toThrow(/tests\/support\/ztd/); -}); - -test('runFeatureTestsScaffoldCommand writes traditional lane scaffolds without overwriting ztd lane artifacts', async () => { - const workspace = createTempDir('feature-tests-traditional-scaffold'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - const queryDir = path.join(featureDir, 'queries', 'insert-users'); - mkdirSync(queryDir, { recursive: true }); - seedSharedZtdSupport(workspace); - - writeFileSync(path.join(featureDir, 'boundary.ts'), 'export const RequestSchema = null;\n', 'utf8'); - writeFileSync(path.join(queryDir, 'boundary.ts'), 'export async function executeInsertUsersBoundary() { return {}; }\n', 'utf8'); - writeFileSync(path.join(queryDir, 'insert-users.sql'), 'insert into public.users (email) values (:email) returning user_id;', 'utf8'); - - await runFeatureTestsScaffoldCommand({ - feature: 'users-insert', - rootDir: workspace - }); - - const result = await runFeatureTestsScaffoldCommand({ - feature: 'users-insert', - rootDir: workspace, - testKind: 'traditional' - }); - - expect(result).toMatchObject({ - featureName: 'users-insert', - queryName: 'insert-users', - testKind: 'traditional', - dryRun: false - }); - expect(result.outputs.map((output) => output.path)).toEqual( - expect.arrayContaining([ - 'src/features/users-insert/queries/insert-users/tests/insert-users.boundary.traditional.test.ts', - 'src/features/users-insert/queries/insert-users/tests/boundary-traditional-types.ts', - 'src/features/users-insert/queries/insert-users/tests/cases/basic.traditional.case.ts', - 'src/features/users-insert/queries/insert-users/tests/generated/TEST_PLAN.traditional.md', - 'src/features/users-insert/queries/insert-users/tests/generated/analysis.traditional.json' - ]) - ); - - const traditionalEntrypoint = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'insert-users.boundary.traditional.test.ts'), - 'utf8' - ); - expect(traditionalEntrypoint).toContain("import { runQuerySpecTraditionalCases } from '../../../../../../tests/support/ztd/harness.js';"); - expect(traditionalEntrypoint).toContain("test('users-insert/insert-users boundary traditional cases run through physical DB setup'"); - expect(traditionalEntrypoint).toContain('const evidence = await runQuerySpecTraditionalCases(cases, executeBoundaryQuerySpec);'); - expect(traditionalEntrypoint).toContain("expect(evidence.every((entry) => entry.mode === 'traditional')).toBe(true);"); - expect(traditionalEntrypoint).toContain('expect(evidence.every((entry) => entry.physicalSetupUsed === true)).toBe(true);'); - expect(traditionalEntrypoint).not.toContain('test.skip'); - - const traditionalTypes = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'boundary-traditional-types.ts'), - 'utf8' - ); - expect(traditionalTypes).toContain("import type { QuerySpecTraditionalCase } from '../../../../../../tests/support/ztd/case-types.js';"); - expect(traditionalTypes).toContain("import type { InsertUsersQueryParams, InsertUsersQueryResult } from '../boundary.js';"); - expect(traditionalTypes).toContain('export type InsertUsersInput = InsertUsersQueryParams;'); - expect(traditionalTypes).toContain('export type InsertUsersOutput = InsertUsersQueryResult;'); - expect(traditionalTypes).toContain('export type InsertUsersQueryBoundaryTraditionalCase = QuerySpecTraditionalCase<'); - - const traditionalCaseFile = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'cases', 'basic.traditional.case.ts'), - 'utf8' - ); - expect(traditionalCaseFile).toContain('afterDb'); - - const traditionalAnalysis = JSON.parse( - readFileSync(path.join(featureDir, 'queries', 'insert-users', 'tests', 'generated', 'analysis.traditional.json'), 'utf8') - ) as { testKind: string; dbScenarioHints: string[] }; - expect(traditionalAnalysis.testKind).toBe('traditional'); - expect(traditionalAnalysis.dbScenarioHints.join('\n')).toContain('shared mode-switching harness'); - expect(traditionalAnalysis.dbScenarioHints.join('\n')).not.toContain('fixed app-level harness'); - expect((traditionalAnalysis as { constraintCoverageNotes: string[] }).constraintCoverageNotes.join('\n')).toContain( - 'Traditional execution is the lane for PostgreSQL-enforced constraint failures' - ); - - const traditionalPlan = readFileSync( - path.join(featureDir, 'queries', 'insert-users', 'tests', 'generated', 'TEST_PLAN.traditional.md'), - 'utf8' - ); - expect(traditionalPlan).toContain('fixedVerifier: tests/support/ztd/harness.ts#runQuerySpecTraditionalCases'); - expect(traditionalPlan).toContain('physically prepares DDL and fixture rows'); - expect(traditionalPlan).toContain('physicalSetupUsed=true'); - expect(traditionalPlan).toContain('Optional `afterDb` assertions'); - expect(traditionalPlan).toContain('PostgreSQL-enforced constraint failures'); - expect(traditionalPlan).toContain('constraint failure cases that are not covered by ZTD preflight'); - - expect( - existsSync(path.join(featureDir, 'queries', 'insert-users', 'tests', 'insert-users.boundary.ztd.test.ts')) - ).toBe(true); - expect( - existsSync(path.join(featureDir, 'queries', 'insert-users', 'tests', 'generated', 'TEST_PLAN.md')) - ).toBe(true); - expect( - existsSync(path.join(featureDir, 'queries', 'insert-users', 'tests', 'generated', 'analysis.json')) - ).toBe(true); -}); - -test('runFeatureTestsScaffoldCommand rejects unsupported test-kind values', async () => { - const workspace = createTempDir('feature-tests-invalid-kind'); - const featureDir = path.join(workspace, 'src', 'features', 'users-insert'); - const queryDir = path.join(featureDir, 'queries', 'insert-users'); - mkdirSync(queryDir, { recursive: true }); - seedSharedZtdSupport(workspace); - - writeFileSync(path.join(featureDir, 'boundary.ts'), 'export const RequestSchema = null;\n', 'utf8'); - writeFileSync(path.join(queryDir, 'boundary.ts'), 'export async function executeInsertUsersBoundary() { return {}; }\n', 'utf8'); - writeFileSync(path.join(queryDir, 'insert-users.sql'), 'select 1;', 'utf8'); - - await expect( - runFeatureTestsScaffoldCommand({ - feature: 'users-insert', - rootDir: workspace, - testKind: 'legacy' - }) - ).rejects.toThrow(/supports only ztd, traditional/i); -}); - -test('runFeatureTestsScaffoldCommand explains how to refresh multi-query features', async () => { - const workspace = createTempDir('feature-tests-multi-query-guidance'); - const featureDir = path.join(workspace, 'src', 'features', 'users-sync'); - const insertQueryDir = path.join(featureDir, 'queries', 'insert-users'); - const resolveQueryDir = path.join(featureDir, 'queries', 'resolve-users'); - mkdirSync(insertQueryDir, { recursive: true }); - mkdirSync(resolveQueryDir, { recursive: true }); - seedSharedZtdSupport(workspace); - - writeFileSync(path.join(featureDir, 'boundary.ts'), 'export const RequestSchema = null;\n', 'utf8'); - seedQueryBoundaryWithTypedContracts(insertQueryDir); - writeFileSync(path.join(insertQueryDir, 'insert-users.sql'), 'insert into public.users (email) values (:email) returning user_id;', 'utf8'); - writeFileSync(path.join(resolveQueryDir, 'boundary.ts'), 'export async function executeResolveUsersBoundary() { return {}; }\n', 'utf8'); - writeFileSync(path.join(resolveQueryDir, 'resolve-users.sql'), 'select user_id from public.users;', 'utf8'); - - await expect( - runFeatureTestsScaffoldCommand({ - feature: 'users-sync', - rootDir: workspace - }) - ).rejects.toThrow(/Choose the query to refresh with --query :[\s\S]*insert-users[\s\S]*resolve-users[\s\S]*Suggested commands:[\s\S]*--query insert-users[\s\S]*--query resolve-users/); -}); diff --git a/packages/ztd-cli/tests/findingRegistry.docs.test.ts b/packages/ztd-cli/tests/findingRegistry.docs.test.ts deleted file mode 100644 index 30889d27d..000000000 --- a/packages/ztd-cli/tests/findingRegistry.docs.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function readDoc(relativePath: string): string { - return readFileSync(path.join(repoRoot, relativePath), 'utf8').replace(/\r\n/g, '\n'); -} - -test('finding registry docs point to the example registry and lifecycle states', () => { - const guide = readDoc('docs/guide/finding-registry.md'); - const inventory = readDoc('docs/guide/ztd-cli-measurement-inventory.md'); - - expect(guide).toContain('finding-registry.example.json'); - expect(guide).toContain('planned'); - expect(guide).toContain('verified'); - expect(inventory).toContain('Finding Registry'); - expect(inventory).toContain('finding-registry.md'); -}); diff --git a/packages/ztd-cli/tests/findingRegistry.unit.test.ts b/packages/ztd-cli/tests/findingRegistry.unit.test.ts deleted file mode 100644 index cbb72b8cf..000000000 --- a/packages/ztd-cli/tests/findingRegistry.unit.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { describe, expect, test } from 'vitest'; -import { validateFindingRegistry } from '../src/utils/findingRegistry'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function readJson(relativePath: string): unknown { - return JSON.parse(readFileSync(path.join(repoRoot, relativePath), 'utf8')); -} - -describe('validateFindingRegistry', () => { - test('accepts the example registry', () => { - const registry = readJson('docs/guide/finding-registry.example.json'); - expect(validateFindingRegistry(registry)).toEqual([]); - }); - - test('flags malformed registry entries', () => { - const issues = validateFindingRegistry([ - { - id: 'F-999', - title: 'broken entry', - symptom: 'missing evidence and invalid status', - source: ['Report'], - failure_surface: 'internal', - category: ['docs'], - severity: 'warning', - detectability: 'local', - recurrence_risk: 'low', - desired_prevention_layer: ['docs_policy'], - candidate_action: 'add evidence', - verification_evidence: 'none', - status: 'done' - } - ]); - - expect(issues.some((issue) => issue.field === 'status')).toBe(true); - }); -}); diff --git a/packages/ztd-cli/tests/findings.cli.test.ts b/packages/ztd-cli/tests/findings.cli.test.ts deleted file mode 100644 index 9c7bfa6a2..000000000 --- a/packages/ztd-cli/tests/findings.cli.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { Command } from 'commander'; -import { afterEach, expect, test } from 'vitest'; -import { registerFindingRegistryCommand } from '../src/commands/findings'; - -const originalExitCode = process.exitCode; - -afterEach(() => { - process.exitCode = originalExitCode; -}); - -function createWorkspace(prefix: string): string { - return mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); -} - -function createProgram(capture: { stdout: string[]; stderr: string[] }): Command { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str) - }); - registerFindingRegistryCommand(program); - return program; -} - -test('CLI: findings validate writes json output and sets exitCode=0 on success', async () => { - const workspace = createWorkspace('findings-cli-pass'); - const registryPath = path.join(workspace, 'finding-registry.json'); - const outPath = path.join(workspace, 'artifacts', 'findings.json'); - mkdirSync(path.dirname(outPath), { recursive: true }); - writeFileSync( - registryPath, - JSON.stringify([ - { - id: 'F-001', - title: 'example', - symptom: 'example symptom', - source: ['Report'], - failure_surface: 'internal', - category: ['docs'], - severity: 'warning', - detectability: 'local', - recurrence_risk: 'low', - desired_prevention_layer: ['docs_policy'], - candidate_action: 'add guidance', - verification_evidence: 'docs test', - status: 'planned' - } - ]), - 'utf8' - ); - - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - await program.parseAsync(['findings', 'validate', registryPath, '--format', 'json', '--out', outPath], { from: 'user' }); - - expect(process.exitCode).toBe(0); - const parsed = JSON.parse(readFileSync(outPath, 'utf8')); - expect(parsed).toMatchObject({ ok: true, entriesChecked: 1, issues: [] }); - expect(capture.stdout.join('')).toBe(''); - expect(capture.stderr.join('')).toBe(''); -}); - -test('CLI: findings validate sets exitCode=1 for invalid registry entries', async () => { - const workspace = createWorkspace('findings-cli-invalid'); - const registryPath = path.join(workspace, 'finding-registry.json'); - const outPath = path.join(workspace, 'artifacts', 'findings.json'); - mkdirSync(path.dirname(outPath), { recursive: true }); - writeFileSync( - registryPath, - JSON.stringify([ - { - id: '', - title: 'broken', - symptom: 'missing fields', - source: ['Report'], - failure_surface: 'internal', - category: ['docs'], - severity: 'warning', - detectability: 'local', - recurrence_risk: 'low', - desired_prevention_layer: ['docs_policy'], - candidate_action: 'fix it', - verification_evidence: 'none', - status: 'done' - } - ]), - 'utf8' - ); - - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - await program.parseAsync(['findings', 'validate', registryPath, '--format', 'json', '--out', outPath], { from: 'user' }); - - expect(process.exitCode).toBe(1); - const parsed = JSON.parse(readFileSync(outPath, 'utf8')); - expect(parsed.ok).toBe(false); - expect(parsed.issues.some((issue: { field: string }) => issue.field === 'id')).toBe(true); - expect(capture.stdout.join('')).toBe(''); - expect(capture.stderr.join('')).toBe(''); -}); diff --git a/packages/ztd-cli/tests/findings.unit.test.ts b/packages/ztd-cli/tests/findings.unit.test.ts deleted file mode 100644 index 80ec85f07..000000000 --- a/packages/ztd-cli/tests/findings.unit.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { describe, expect, test } from 'vitest'; -import { formatFindingRegistryValidationResult, runValidateFindingRegistry } from '../src/commands/findings'; - -function createWorkspace(): string { - return mkdtempSync(path.join(os.tmpdir(), 'ztd-findings-')); -} - -describe('runValidateFindingRegistry', () => { - test('accepts the example registry', () => { - const root = createWorkspace(); - const registryPath = path.join(root, 'finding-registry.example.json'); - writeFileSync( - registryPath, - JSON.stringify([ - { - id: 'F-001', - title: 'example', - symptom: 'example symptom', - source: ['Report'], - failure_surface: 'internal', - category: ['docs'], - severity: 'warning', - detectability: 'local', - recurrence_risk: 'low', - desired_prevention_layer: ['docs_policy'], - candidate_action: 'add guidance', - verification_evidence: 'docs test', - status: 'planned' - } - ]), - 'utf8' - ); - - const result = runValidateFindingRegistry(registryPath, root); - expect(result.ok).toBe(true); - expect(result.entriesChecked).toBe(1); - expect(result.issues).toHaveLength(0); - }); - - test('flags malformed registry entries', () => { - const root = createWorkspace(); - const registryPath = path.join(root, 'finding-registry.bad.json'); - writeFileSync( - registryPath, - JSON.stringify([ - { - id: '', - title: 'broken', - symptom: 'missing fields', - source: ['Report'], - failure_surface: 'internal', - category: ['docs'], - severity: 'warning', - detectability: 'local', - recurrence_risk: 'low', - desired_prevention_layer: ['docs_policy'], - candidate_action: 'fix it', - verification_evidence: 'none', - status: 'done' - } - ]), - 'utf8' - ); - - const result = runValidateFindingRegistry(registryPath, root); - expect(result.ok).toBe(false); - expect(result.issues.some((issue) => issue.field === 'id')).toBe(true); - expect(result.issues.some((issue) => issue.field === 'status')).toBe(true); - }); - - test('formats a human summary', () => { - const text = formatFindingRegistryValidationResult( - { - ok: false, - registryPath: '/tmp/findings.json', - entriesChecked: 1, - issues: [{ index: 0, field: 'status', message: 'status must be one of: planned, implemented, evidence_collected, verified.' }] - }, - 'human' - ); - - expect(text).toContain('Finding registry has 1 issue'); - expect(text).toContain('entry 0 / status'); - }); -}); diff --git a/packages/ztd-cli/tests/fixtures/join-direction/aggregate.sql b/packages/ztd-cli/tests/fixtures/join-direction/aggregate.sql deleted file mode 100644 index b79bd8d9a..000000000 --- a/packages/ztd-cli/tests/fixtures/join-direction/aggregate.sql +++ /dev/null @@ -1,7 +0,0 @@ -select - c.customer_id, - count(o.order_id) as order_count -from public.customers c -left join public.orders o - on o.customer_id = c.customer_id -group by c.customer_id; diff --git a/packages/ztd-cli/tests/fixtures/join-direction/bridge.sql b/packages/ztd-cli/tests/fixtures/join-direction/bridge.sql deleted file mode 100644 index 9782c3079..000000000 --- a/packages/ztd-cli/tests/fixtures/join-direction/bridge.sql +++ /dev/null @@ -1,8 +0,0 @@ -select - s.sale_id, - t.tag_name -from public.sales s -join public.sale_item_tags sit - on sit.sale_id = s.sale_id -join public.tags t - on t.tag_id = sit.tag_id; diff --git a/packages/ztd-cli/tests/fixtures/join-direction/forward.sql b/packages/ztd-cli/tests/fixtures/join-direction/forward.sql deleted file mode 100644 index 392fcdc55..000000000 --- a/packages/ztd-cli/tests/fixtures/join-direction/forward.sql +++ /dev/null @@ -1,9 +0,0 @@ -select - oi.order_item_id, - o.order_id, - c.customer_id -from public.order_items oi -join public.orders o - on o.order_id = oi.order_id -join public.customers c - on c.customer_id = o.customer_id; diff --git a/packages/ztd-cli/tests/fixtures/join-direction/left-join.sql b/packages/ztd-cli/tests/fixtures/join-direction/left-join.sql deleted file mode 100644 index 17c135c6d..000000000 --- a/packages/ztd-cli/tests/fixtures/join-direction/left-join.sql +++ /dev/null @@ -1,6 +0,0 @@ -select - c.customer_id, - o.order_id -from public.customers c -left join public.orders o - on o.customer_id = c.customer_id; diff --git a/packages/ztd-cli/tests/fixtures/join-direction/reverse.sql b/packages/ztd-cli/tests/fixtures/join-direction/reverse.sql deleted file mode 100644 index 0ad9757f4..000000000 --- a/packages/ztd-cli/tests/fixtures/join-direction/reverse.sql +++ /dev/null @@ -1,6 +0,0 @@ -select - c.customer_id, - o.order_id -from public.customers c -join public.orders o - on o.customer_id = c.customer_id; diff --git a/packages/ztd-cli/tests/fixtures/join-direction/suppressed.sql b/packages/ztd-cli/tests/fixtures/join-direction/suppressed.sql deleted file mode 100644 index 7d02490b3..000000000 --- a/packages/ztd-cli/tests/fixtures/join-direction/suppressed.sql +++ /dev/null @@ -1,7 +0,0 @@ --- ztd-lint-disable join-direction -select - c.customer_id, - o.order_id -from public.customers c -join public.orders o - on o.customer_id = c.customer_id; diff --git a/packages/ztd-cli/tests/furtherReading.docs.test.ts b/packages/ztd-cli/tests/furtherReading.docs.test.ts deleted file mode 100644 index 83bd23935..000000000 --- a/packages/ztd-cli/tests/furtherReading.docs.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function readNormalizedFile(relativePath: string): string { - const filePath = path.join(repoRoot, relativePath); - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); -} - -function expectInOrder(haystack: string, needles: string[]): void { - let cursor = 0; - for (const needle of needles) { - const index = haystack.indexOf(needle, cursor); - expect(index, `Expected to find "${needle}" after offset ${cursor}`).toBeGreaterThanOrEqual(0); - cursor = index + needle.length; - } -} - -test('package README links every Further Reading guide from the public index', () => { - const readme = readNormalizedFile('packages/ztd-cli/README.md'); - - expectInOrder(readme, [ - '## Further Reading', - '[SQL-first End-to-End Tutorial](../../docs/guide/sql-first-end-to-end-tutorial.md)', - '[What Is RFBA?](../../docs/guide/rfba-overview.md)', - '[SQL Tool Happy Paths](../../docs/guide/sql-tool-happy-paths.md)', - '[Perf Tuning Decision Guide](../../docs/guide/perf-tuning-decision-guide.md)', - '[JOIN Direction Lint Specification](../../docs/guide/join-direction-lint-spec.md)', - '[ztd-cli Telemetry Philosophy](../../docs/guide/ztd-cli-telemetry-philosophy.md)', - '[Local-Source Development](../../docs/guide/ztd-local-source-dogfooding.md)', - '[ztd-cli spawn EPERM Investigation](../../docs/dogfooding/ztd-cli-spawn-eperm-investigation.md)', - '[ztd Onboarding Verification](../../docs/dogfooding/ztd-onboarding-dogfooding.md)' - ]); -}); - -test('Further Reading docs stay aligned with the current standalone and CLI behavior', () => { - const expectations: Array<{ docPath: string; phrases: string[] }> = [ - { - docPath: 'docs/guide/rfba-overview.md', - phrases: [ - 'RFBA means **Review-First Backend Architecture**.', - 'The goal is to make AI-produced work reviewable by humans.', - 'RFBA splits backend code by review-worthy concerns, not by technical layers.', - 'SQL is an important example, not the definition of RFBA.', - '## File Splitting Rule', - 'Expose the artifacts humans should review directly; keep mechanical wiring and generated code close to the boundary they serve.', - 'RFBA is intentionally scoped to backend work, especially database applications.', - 'RFBA treats DDL as the source of truth for data structure', - 'raw SQL is a natural review boundary', - '`root-boundary`: the app-level boundary layer.', - '`feature-boundary`: a feature-owned boundary under `src/features//`.', - '`sub-boundary`: an optional child boundary inside a feature', - 'Feature-boundary tests are mock-based by default.', - 'Query-boundary tests own SQL behavior.', - 'Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries.', - '`src/libraries/` is for driver-neutral code reusable enough to stand as an external package.', - 'RFBA is not a universal file naming rule.', - '`boundary.ts` is the default `ztd-cli` feature scaffold convention' - ] - }, - { - docPath: 'docs/guide/sql-first-end-to-end-tutorial.md', - phrases: [ - 'This tutorial shows the shortest path from `ztd init --starter` to a small `users` feature', - 'The smallest DB-backed starter example lives in `src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts`.', - '`@rawsql-ts/testkit-postgres` and `createPostgresTestkitClient`', - 'Docker Desktop or another Docker daemon is already running', - 'cp .env.example .env', - '# edit ZTD_DB_PORT=5433', - 'docker compose up -d', - 'The starter setup derives `ZTD_DB_URL` from `.env`', - 'If port `5432` is already in use, update `ZTD_DB_PORT` in `.env` before you rerun the compose path, for example:', - 'Copy-Item .env.example .env', - 'npx ztd query uses column users.email --scope-dir src/features/users-insert --any-schema --view detail', - 'Passing the feature folder as `--scope-dir` is a normal way to narrow the project-wide scan, not a workaround for feature-local layouts.', - 'npx ztd model-gen --probe-mode ztd src/features/users-insert/queries/insert-users/insert-users.sql', - 'Do not target `src/features/users-insert/queries/insert-users/boundary.ts` with `--out`, because that file is the runtime boundary that also owns `loadSqlResource` and the execution flow.', - 'model-gen` now treats the SQL file location as the primary contract source', - 'Read the review summary first:', - '- the risks section lists destructive and operational apply-plan risks separately', - 'npx ztd ddl risk --file tmp/users.diff.sql', - 'current `ztd ddl diff` CLI does not expose the lower-level drop-avoidance options from core', - 'npx vitest run', - 'generated `tableDefinitions` are the normal runtime path after `ztd-config`', - 'explicit `tableDefinitions` / `tableRows` are for local tests that want direct fixtures', - '`ddl.directories` is the fallback only when no generated manifest exists' - ] - }, - { - docPath: 'packages/testkit-postgres/README.md', - phrases: [ - 'createPostgresTestkitClient', - 'defaultSchema', - 'searchPath', - 'public.users', - 'Generated fixture manifests', - 'ddl.directories' - ] - }, - { - docPath: 'docs/dogfooding/ztd-migration-lifecycle.md', - phrases: [ - 'The goal is to confirm that a prompt can point an AI agent at the right files', - 'migration artifact creation', - 'tmp/users.diff.sql', - 'npx ztd ddl risk --file tmp/users.diff.sql', - 'npx ztd model-gen --probe-mode ztd src/features/users/persistence/users.sql --out src/features/users/persistence/users.spec.ts', - '`ZTD_DB_URL` is the only implicit database owned by ztd-cli.', - 'Use `--url` or a full `--db-*` flag set for any other inspection target.', - 'The feature folder is one narrowed scan scope inside the normal project-wide discovery flow.', - 'inspect the structured risks second', - 'Do not apply migrations automatically.' - ] - }, - { - docPath: 'docs/guide/query-uses-impact-checks.md', - phrases: [ - 'The active scan set is **project-wide by default**.', - 'Use `--scope-dir` only when you want to narrow the scan to one slice or sub-tree.', - 'prefers feature-local spec-relative paths, then tries project-relative paths', - 'npx ztd query uses column users.email --scope-dir src/features/users/persistence --any-schema --view detail' - ] - }, - { - docPath: 'docs/guide/perf-tuning-decision-guide.md', - phrases: [ - 'tuning stays evidence-driven and does not require breaking the SQL shape first', - 'ztd query plan ', - 'ztd perf db reset --dry-run', - 'ztd perf run --dry-run', - 'direct vs decomposed' - ] - }, - { - docPath: 'docs/guide/sql-tool-happy-paths.md', - phrases: [ - 'Use it when the problem is not "how do I use every command?" but "which command should I run first?"', - '`ztd query plan `', - '`ztd perf run --dry-run ...`', - '`ztd query uses `', - 'Telemetry is an opt-in branch after the structural path is known.' - ] - }, - { - docPath: 'docs/guide/ztd-cli-agent-interface.md', - phrases: [ - 'Use `ztd --output json ...` to request a JSON envelope on stdout.', - 'Prefer `--dry-run` before commands that write files.', - 'Use `--json ` on supported commands when nested option construction is easier than individual flags.', - 'treat `summary` as the logical diff, treat `risks` as the apply-plan risk list', - 'Use `ztd ddl risk --file ` when you need to evaluate a generated or hand-edited migration SQL file directly', - '`ztd model-gen` now treats feature-local SQL files as the primary contract source', - 'ZTD_DB_URL', - 'Do not assume `DATABASE_URL` is a usable default target' - ] - }, - { - docPath: 'docs/guide/feature-index.md', - phrases: [ - 'SQL-first End-to-End Tutorial', - 'Non-interactive init' - ] - }, - { - docPath: 'docs/dogfooding/ztd-application-lifecycle.md', - phrases: [ - 'confirm that an AI agent can work from the generated README and CLI scaffold', - '`ztd init --starter`', - 'Do not apply migrations automatically.' - ] - }, - { - docPath: 'packages/ztd-cli/README.md', - phrases: [ - '`root-boundary` is the app-level boundary layer.', - 'the concrete root boundaries are only `src/features`, `src/adapters`, and `src/libraries`', - '`queries/` is a child-boundary container and does not expose its own public surface.', - '`boundary.ts` is a feature-scoped convention for discoverability and scaffold compatibility', - 'Do not count `src/features/_shared/*`, `tests/support/*`, `.ztd/*`, or `db/` as extra root boundaries.', - 'If an AI-authored ZTD test fails, do not assume the prompt or case file is the only problem; check whether `ztd-cli` or `rawsql-ts` changed the manifest or rewrite path.', - 'If you see `user_id: null`, compare the direct database `INSERT ... RETURNING ...` result with the ZTD result and inspect `.ztd/generated/ztd-fixture-manifest.generated.ts` first.', - 'If a local-source workspace is meant to reflect a source change, verify that it resolves `rawsql-ts` from the local source tree rather than a registry copy.', - 'npx ztd feature query scaffold --feature users-insert --query-name insert-user-audit --table user_audit --action insert', - 'does not edit the parent `boundary.ts`', - 'After you finish the SQL and DTO edits, run `npx ztd feature tests scaffold --feature `.', - 'creates the thin Vitest entrypoint `src/features//queries//tests/.boundary.ztd.test.ts` only if it is missing.', - 'Persistent case files under `src/features//queries//tests/cases/` are human/AI-owned and are not overwritten.', - 'Feature-boundary tests mock child query boundaries and verify feature validation, mapping, and orchestration.', - 'Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane.', - 'Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries.', - 'Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package', - 'Do not apply migrations automatically.' - ] - }, - { - docPath: 'docs/dogfooding/ztd-cli-spawn-eperm-investigation.md', - phrases: [ - '## Source issue', - '## Why blocker', - '## Reproduction', - '## Investigation steps', - '## Findings', - '## Conclusion', - '## Impact on acceptance items', - '## What should happen next', - '## Recurrence prevention', - '## Reviewer conclusion', - 'pnpm --filter @rawsql-ts/ztd-cli test', - 'pnpm --filter @rawsql-ts/ztd-cli exec vitest', - 'pnpm --filter @rawsql-ts/ztd-cli test', - 'build` -> passed', - 'lint` -> passed', - 'child_process.spawn(', - 'B. This PR\'s code changes are not the primary cause.', - 'Local Windows environment reproduces `spawn EPERM` below the Issue #685 change layer.', - 'Current evidence is insufficient to mark acceptance items 1-3 as done.', - 'Next decision depends on CI or alternate-environment verification.' - ] - }, - { - docPath: 'docs/dogfooding/ztd-onboarding-dogfooding.md', - phrases: [ - '## What was run', - '## Exact order', - '## README Quickstart step-by-step outcome', - '## Where the removed bootstrap had helped', - '## Where the removed bootstrap was redundant or confusing', - '## What remains unverified', - 'npm install -D @rawsql-ts/ztd-cli vitest typescript', - 'npx ztd init --starter', - '.env.example', - 'docker compose up -d', - 'npx ztd ztd-config', - 'npx vitest run', - 'README Quickstart path in a fresh directory outside the monorepo workspace root.', - 'The onboarding order remains coherent without the AI-control bootstrap' - ] - }, - { - docPath: 'docs/dogfooding/perf-scale-tuning.md', - phrases: [ - 'The goal is to keep the decision between **index tuning** and **pipeline tuning** explicit, reproducible, and backed by QuerySpec metadata plus local DDL.', - 'QuerySpec `metadata.perf`', - '`perf/seed.yml`', - '`ztd perf db reset --dry-run`', - '`ztd perf run`', - 'compare `--strategy direct` and `--strategy decomposed`' - ] - }, - { - docPath: 'docs/guide/published-package-verification.md', - phrases: [ - 'not a perfect substitute for a real registry publish', - 'Run this from the repository root:', - '`pnpm verify:published-package-mode`', - 'Packed tarballs do not leak `workspace:*`', - 'The standalone smoke app passes, but local-source dogfooding fails.', - 'A real post-publish smoke check is still required.' - ] - }, - { - docPath: 'docs/guide/ztd-local-source-dogfooding.md', - phrases: [ - 'throwaway project under `tmp/`', - '`ztd init --local-source-root `', - 'ztd model-gen src/features/users/queries/list-users/list-users.sql \\', - '`pnpm install --ignore-workspace`', - 'Do not use it to claim that the published npm consumer flow is already healthy', - 'Use this mode to answer: `can we dogfood the unreleased CLI from source?`' - ] - }, - { - docPath: 'docs/guide/ztd-cli-telemetry-philosophy.md', - phrases: [ - 'intentionally **not** part of the default happy path', - 'Published-package consumers must be able to ignore telemetry completely.', - 'Enable telemetry when you are:', - 'Leave it off for normal published-package usage, happy-path setup, and standard project scaffolding.' - ] - }, - { - docPath: 'docs/guide/join-direction-lint-spec.md', - phrases: [ - '`ztd query lint --rules join-direction`', - '`npx ztd query lint --help`', - '`--rules `', - '`unknown option \'--rules\'`', - '`parent -> child` is not a universal anti-pattern.', - 'v1 uses **FK-only** relation evidence.', - '`LEFT JOIN` can be a clean parent-first pattern when the query intentionally preserves the parent row set.', - 'skip' - ] - } - ]; - - for (const { docPath, phrases } of expectations) { - const doc = readNormalizedFile(docPath); - for (const phrase of phrases) { - expect(doc, `${docPath} should contain ${phrase}`).toContain(phrase); - } - } -}); - -test('spawn EPERM investigation doc stays sanitized for reviewer-facing publication', () => { - const doc = readNormalizedFile('docs/dogfooding/ztd-cli-spawn-eperm-investigation.md'); - - expect(doc).toContain(''); - expect(doc).toContain(''); - expect(doc).not.toMatch(/C:\\Users\\/); - expect(doc).not.toMatch(/OneDrive\\/); -}); - -test('quickstart and tutorial spell out the common 5432 collision fallback', () => { - const packageReadme = readNormalizedFile('packages/ztd-cli/README.md'); - const scaffoldReadme = readNormalizedFile('packages/ztd-cli/templates/README.md'); - const tutorial = readNormalizedFile('docs/guide/sql-first-end-to-end-tutorial.md'); - - expect(packageReadme).toContain('.env.example'); - expect(packageReadme).toContain('ZTD_DB_PORT'); - expect(packageReadme).toContain('If port `5432` is already in use, change `ZTD_DB_PORT` in `.env` and then verify recovery with:'); - expect(packageReadme).toContain('cp .env.example .env'); - expect(packageReadme).toContain('docker compose up -d'); - expect(packageReadme).toContain('npx vitest run'); - expect(packageReadme).toContain('all predefined address pools have been fully subnetted'); - expect(packageReadme).toContain('Changing `ZTD_DB_PORT` will not fix it.'); - expect(scaffoldReadme).toContain('When you add SQL-backed tests, copy `.env.example` to `.env` and adjust `ZTD_DB_PORT` if needed before running the DB-backed suites.'); - expect(scaffoldReadme).toContain('Starter-owned shared support lives at `tests/support/ztd/`, while `.ztd/` is the tool-managed workspace for generated metadata and support files.'); - expect(scaffoldReadme).toContain('all predefined address pools have been fully subnetted'); - expect(tutorial).toContain('If port `5432` is already in use, update `ZTD_DB_PORT` in `.env` before you rerun the compose path, for example:'); - expect(tutorial).toContain('cp .env.example .env'); - expect(tutorial).toContain('Copy-Item .env.example .env'); - expect(tutorial).toContain('all predefined address pools have been fully subnetted'); - expect(tutorial).toContain('changing `ZTD_DB_PORT` will not help'); - expect(packageReadme).not.toContain('A folder is a boundary.'); - expect(packageReadme).not.toContain('Every boundary folder exposes only `boundary.ts`'); - expect(packageReadme).toContain('RFBA (Review-First Backend Architecture)'); - expect(packageReadme).toContain('RFBA is architecture and structure theory, not a filename rule.'); - expect(tutorial).toContain('RFBA is about splitting files by review responsibility'); - expect(readNormalizedFile('docs/.vitepress/config.mts')).toContain("{ text: 'What Is RFBA?', link: '/guide/rfba-overview' }"); - expect(readNormalizedFile('docs/guide/feature-index.md')).toContain('[guide/rfba-overview](./rfba-overview.md)'); -}); diff --git a/packages/ztd-cli/tests/gitignoreTemplate.pack.test.ts b/packages/ztd-cli/tests/gitignoreTemplate.pack.test.ts deleted file mode 100644 index aa9f3974b..000000000 --- a/packages/ztd-cli/tests/gitignoreTemplate.pack.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -test('ztd-cli pack output ships the gitignore template needed by init', { timeout: 60_000 }, () => { - const output = - process.platform === 'win32' - ? execFileSync('cmd.exe', ['/c', 'npm', 'pack', '--dry-run', '--json', '--ignore-scripts'], { - cwd: path.join(repoRoot, 'packages', 'ztd-cli'), - encoding: 'utf8' - }) - : execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { - cwd: path.join(repoRoot, 'packages', 'ztd-cli'), - encoding: 'utf8' - }); - const manifest = JSON.parse(output) as Array<{ - files: Array<{ path: string }>; - }>; - const packedFiles = new Set(manifest.flatMap((entry) => entry.files.map((file) => file.path))); - - expect(packedFiles.has('templates/gitignore.template')).toBe(true); - expect(packedFiles.has('templates/.gitignore')).toBe(false); -}); diff --git a/packages/ztd-cli/tests/init.command.test.ts b/packages/ztd-cli/tests/init.command.test.ts deleted file mode 100644 index c93289f2e..000000000 --- a/packages/ztd-cli/tests/init.command.test.ts +++ /dev/null @@ -1,718 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -import { - buildInitDryRunPlan, - runInitCommand, - buildPackageManagerArgs, - findAncestorPnpmWorkspaceRoot, - resolveInitInstallStrategy, - resolvePackageManagerShellExecutable, - resolvePnpmWorkspaceGuard, - type ZtdConfigWriterDependencies, - type Prompter -} from '../src/commands/init'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function readNormalizedFile(filePath: string): string { - const contents = readFileSync(filePath, 'utf8'); - return contents.replace(/\r\n/g, '\n'); -} - -class TestPrompter implements Prompter { - private index = 0; - constructor(private readonly responses: string[]) {} - - private nextResponse(): string { - if (this.index >= this.responses.length) { - throw new Error('Not enough responses supplied to TestPrompter'); - } - return this.responses[this.index++]; - } - - async selectChoice(_question: string, _choices: string[]): Promise { - const value = this.nextResponse(); - const selected = Number(value); - if (!Number.isFinite(selected)) { - throw new Error(`Invalid choice "${value}" supplied to TestPrompter.`); - } - if (selected < 1 || selected > _choices.length) { - throw new Error(`Choice "${value}" is outside the valid range.`); - } - return selected - 1; - } - - async promptInput(_question: string, _example?: string): Promise { - return this.nextResponse(); - } - - async promptInputWithDefault(_question: string, defaultValue: string, _example?: string): Promise { - const value = this.nextResponse(); - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : defaultValue; - } - - async confirm(_question: string): Promise { - const answer = this.nextResponse().trim().toLowerCase(); - return answer === 'y' || answer === 'yes'; - } - - close(): void { - // No resources to clean up in the mock prompter. - } -} - -test('init bootstraps a feature-first scaffold', { timeout: 60_000 }, async () => { - const workspace = createTempDir('cli-init-feature-first'); - const prompter = new TestPrompter([]); - - const result = await runInitCommand(prompter, { - rootDir: workspace, - nonInteractive: true, - forceOverwrite: true, - workflow: 'empty', - validator: 'zod' - }); - - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('This scaffold starts from `ztd init`.'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('This generated project is either:'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('local-source workspace output'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('If you see `file:` dependencies that point back to a monorepo checkout'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('keep starter-owned shared support under `tests/support/ztd/`'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('keep tool-managed fixture metadata under `.ztd/generated/`'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('src/features`, `src/adapters`, and `src/libraries` as the app-code roots'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('Feature-boundary tests mock child query boundaries and verify feature validation, mapping, and orchestration.'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('Query-boundary tests own SQL behavior through ZTD or another SQL-specific lane.'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('Integration tests are opt-in and should be named as integration tests when they intentionally cross multiple live boundaries.'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('Use `src/libraries/` only for driver-neutral code reusable enough to stand as an external package'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('this generated workspace may not contain `docs/`'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('`ztd.config.json` controls generated metadata and runtime defaults while the feature-local tests stay next to the feature they cover'); - expect(existsSync(path.join(workspace, 'src', 'features', 'README.md'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'libraries', 'sql', 'sql-client.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'adapters', 'pg', 'sql-client.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke'))).toBe(false); - expect(readNormalizedFile(path.join(workspace, 'vitest.config.ts'))).toContain( - "src/features/**/*.test.ts" - ); - expect(readNormalizedFile(path.join(workspace, '.prettierrc'))).toContain('"files": "**/*.sql"'); - expect(readNormalizedFile(path.join(workspace, '.prettierrc'))).toContain('"files": "**/*.md"'); - expect(existsSync(path.join(workspace, 'src', 'domain'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'application'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'presentation'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'infrastructure'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'jobs'))).toBe(false); - expect(existsSync(path.join(workspace, 'compose.yaml'))).toBe(false); - expect(existsSync(path.join(workspace, '.env.example'))).toBe(true); - expect(existsSync(path.join(workspace, '.gitignore'))).toBe(true); - const gitignore = readNormalizedFile(path.join(workspace, '.gitignore')); - expect(gitignore).toMatch(/^\.env$/m); - expect(gitignore).toMatch(/^\.env\.\*$/m); - expect(gitignore).toMatch(/^!\.env\.example$/m); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('copy `.env.example` to `.env` and adjust `ZTD_DB_PORT` if needed before running the DB-backed suites'); - expect(readNormalizedFile(path.join(workspace, 'vitest.config.ts'))).toContain('setupFiles'); - expect(readNormalizedFile(path.join(workspace, 'vitest.config.ts'))).toContain( - ".ztd/support/setup-env.ts" - ); - expect(readNormalizedFile(path.join(workspace, 'vitest.config.ts'))).toContain("'#features'"); - expect(readNormalizedFile(path.join(workspace, 'vitest.config.ts'))).toContain("'#libraries'"); - expect(readNormalizedFile(path.join(workspace, 'vitest.config.ts'))).toContain("'#adapters'"); - expect(readNormalizedFile(path.join(workspace, 'vitest.config.ts'))).toContain("'#tests'"); - expect(readNormalizedFile(path.join(workspace, 'tsconfig.json'))).toContain('"#libraries/*"'); - expect(readNormalizedFile(path.join(workspace, 'tsconfig.json'))).toContain('"#adapters/*"'); - expect(readNormalizedFile(path.join(workspace, 'src', 'adapters', 'pg', 'sql-client.ts'))).toContain( - "from '#libraries/sql/sql-client.js'" - ); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'setup-env.ts'))).toContain( - 'ZTD_DB_PORT' - ); - expect(readNormalizedFile(path.join(workspace, '.env.example'))).toContain('ZTD_DB_PORT=5432'); - const packageJson = JSON.parse(readNormalizedFile(path.join(workspace, 'package.json'))) as { - type?: string; - scripts?: Record; - dependencies: Record; - devDependencies: Record; - imports?: Record; - 'lint-staged'?: Record; - 'simple-git-hooks'?: Record; - }; - expect(packageJson.scripts?.test).toContain('--passWithNoTests'); - expect(packageJson.scripts?.test).not.toContain('no test specified'); - expect(packageJson['lint-staged']?.['*.{ts,tsx,js,jsx,json,md,sql}']).toEqual(['prettier --write']); - expect(packageJson['simple-git-hooks']?.['pre-commit']).toBe('pnpm lint-staged'); - expect(packageJson.devDependencies).toHaveProperty('dotenv'); - expect(packageJson.devDependencies.vitest).toBe('^4.1.8'); - expect(packageJson.devDependencies).not.toHaveProperty('@rawsql-ts/sql-contract'); - expect(packageJson.dependencies).toHaveProperty('@rawsql-ts/driver-adapter-core'); - expect(packageJson.dependencies).not.toHaveProperty('@rawsql-ts/ztd-cli'); - expect(packageJson.dependencies).not.toHaveProperty('rawsql-ts'); - expect(packageJson.dependencies).not.toHaveProperty('@rawsql-ts/testkit-core'); - expect(packageJson.dependencies).not.toHaveProperty('@rawsql-ts/testkit-postgres'); - expect(packageJson.devDependencies).not.toHaveProperty('@rawsql-ts/driver-adapter-core'); - expect(packageJson.devDependencies).toHaveProperty('@rawsql-ts/testkit-core'); - expect(packageJson.imports?.['#features/*.js']).toEqual({ - types: './src/features/*.ts', - default: './dist/features/*.js' - }); - expect(packageJson.imports?.['#libraries/*.js']).toEqual({ - types: './src/libraries/*.ts', - default: './dist/libraries/*.js' - }); - expect(packageJson.imports?.['#adapters/*.js']).toEqual({ - types: './src/adapters/*.ts', - default: './dist/adapters/*.js' - }); - expect(packageJson.imports?.['#tests/*.js']).toEqual({ - types: './tests/*.ts', - default: './tests/*.ts' - }); - expect(result.summary).not.toContain('src/features/smoke/tests/smoke.test.ts'); - expect(result.summary).toContain('src/features/README.md'); - expect(result.summary).toContain('.env.example'); -}); - -test('init starter bootstraps compose, starter DDL, and smoke tests without visible AGENTS', { timeout: 60_000 }, async () => { - const workspace = createTempDir('cli-init-starter'); - const prompter = new TestPrompter([]); - - const result = await runInitCommand(prompter, { - rootDir: workspace, - nonInteractive: true, - forceOverwrite: true, - workflow: 'demo', - validator: 'zod', - starter: true, - postgresImage: 'postgres:17' - }); - - const ddlFiles = readdirSync(path.join(workspace, 'db', 'ddl')).filter((entry) => entry.endsWith('.sql')); - expect(existsSync(path.join(workspace, 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'AGENTS_ztd.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'db', 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, '.codex', 'config.toml'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'compose.yaml'))).toBe(true); - expect(existsSync(path.join(workspace, '.env.example'))).toBe(true); - expect(existsSync(path.join(workspace, '.gitignore'))).toBe(true); - const starterGitignore = readNormalizedFile(path.join(workspace, '.gitignore')); - expect(starterGitignore).toMatch(/^\.env$/m); - expect(starterGitignore).toMatch(/^\.env\.\*$/m); - expect(starterGitignore).toMatch(/^!\.env\.example$/m); - expect(readNormalizedFile(path.join(workspace, 'compose.yaml'))).toContain('image: postgres:17'); - expect(readNormalizedFile(path.join(workspace, 'compose.yaml'))).toContain('ZTD_DB_PORT'); - expect(readNormalizedFile(path.join(workspace, 'compose.yaml'))).toContain( - '${ZTD_DB_PORT:-5432}:5432' - ); - expect(readNormalizedFile(path.join(workspace, '.env.example'))).toContain('ZTD_DB_PORT=5432'); - expect(ddlFiles.length).toBeGreaterThan(0); - expect( - readNormalizedFile(path.join(workspace, 'db', 'ddl', ddlFiles[0])) - ).toContain('create table users'); - expect( - readNormalizedFile(path.join(workspace, 'db', 'ddl', ddlFiles[0])) - ).toContain('Starter user directory for the first CRUD feature'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('Starter Flow'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('starter-only sample feature'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('Copy `.env.example` to `.env` and update `ZTD_DB_PORT` if 5432 is already in use.'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('ZTD_DB_PORT'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain( - 'derives `ZTD_DB_URL` from `ZTD_DB_PORT`' - ); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('npx vitest run src/features/smoke/tests/smoke.boundary.test.ts src/features/smoke/tests/smoke.validation.test.ts'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('pnpm ztd feature scaffold --table users --action insert'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('pnpm ztd ztd-config'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('@rawsql-ts/testkit-postgres'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('fixed app-level ZTD runner'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'README.md'))).toContain('starter-only sample feature'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'tests', 'README.md'))).toContain('src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'tests', 'README.md'))).toContain('setup-env.ts'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'tests', 'README.md'))).toContain('tests/support/ztd/harness.ts'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'tests', 'README.md'))).toContain('ZTD_DB_PORT'); - expect(existsSync(path.join(workspace, 'src', 'features', '_shared', 'featureQueryExecutor.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', '_shared', 'loadSqlResource.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'boundary.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'smoke.sql'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'boundary-ztd-types.ts'))).toBe(true); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'boundary-ztd-types.ts'))).toContain( - "from '#tests/support/ztd/case-types.js'" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'boundary-ztd-types.ts'))).toContain( - "import type { SmokeQueryParams, SmokeQueryResult } from '../boundary.js';" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'boundary-ztd-types.ts'))).toContain( - 'export type SmokeInput = SmokeQueryParams;' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'boundary-ztd-types.ts'))).toContain( - 'export type SmokeOutput = SmokeQueryResult;' - ); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'cases', 'basic.case.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated', 'TEST_PLAN.md'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated', 'analysis.json'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'application'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'domain'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'persistence'))).toBe(false); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'smoke.sql'))).toContain('where user_id = :user_id::integer'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'boundary.ts'))).toContain('executeSmokeQuerySpec'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'boundary.ts'))).toContain('loadSqlResource'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'boundary.ts'))).toContain( - 'export interface SmokeQueryParams extends Record' - ); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'setup-env.ts'))).toContain('ZTD_DB_PORT'); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'setup-env.ts'))).toContain('ZTD_DB_URL'); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'postgres-testkit.ts'))).toContain('createPostgresTestkitClient'); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'postgres-testkit.ts'))).toContain('loadStarterPostgresDefaults'); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'postgres-testkit.ts'))).toContain('ZTD_DB_URL'); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'postgres-testkit.ts'))).toContain('throw new Error'); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'postgres-testkit.ts'))).toContain('Copy `.env.example` to `.env`'); - expect(readNormalizedFile(path.join(workspace, '.ztd', 'support', 'postgres-testkit.ts'))).toContain('docker compose up -d'); - expect(readNormalizedFile(path.join(workspace, 'tests', 'support', 'ztd', 'verifier.ts'))).toContain('Copy `.env.example` to `.env`'); - expect(readNormalizedFile(path.join(workspace, 'tests', 'support', 'ztd', 'verifier.ts'))).toContain('all predefined address pools have been fully subnetted'); - expect(readNormalizedFile(path.join(workspace, 'ztd.config.json'))).toContain('"ztdRootDir": ".ztd"'); - expect(readNormalizedFile(path.join(workspace, 'ztd.config.json'))).toContain('"defaultSchema": "public"'); - expect(readNormalizedFile(path.join(workspace, 'ztd.config.json'))).toContain('"searchPath": ['); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'tests', 'smoke.test.ts'))).toBe(false); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'tests', 'smoke.boundary.test.ts'))).toContain('executeSmokeEntrySpec'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'tests', 'smoke.validation.test.ts'))).toContain('Validation should reject before the query lane runs.'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'smoke.boundary.ztd.test.ts'))).toContain('runQuerySpecZtdCases'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'smoke.boundary.ztd.test.ts'))).toContain("entry.mode === 'ztd'"); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'smoke.boundary.ztd.test.ts'))).toContain('entry.physicalSetupUsed === false'); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'smoke.boundary.ztd.test.ts'))).toContain( - "from '#tests/support/ztd/harness.js'" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated', 'TEST_PLAN.md'))).toContain( - 'Constraint Coverage Boundary' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated', 'TEST_PLAN.md'))).toContain( - 'required INSERT column presence for NOT NULL columns without defaults' - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated', 'analysis.json'))).toContain( - '"constraintCoverageNotes"' - ); - expect(readNormalizedFile(path.join(workspace, 'tests', 'support', 'ztd', 'README.md'))).toContain( - 'simple `UNIQUE` checks are feasible ZTD preflight candidates' - ); - expect(existsSync(path.join(workspace, 'src', 'libraries', 'README.md'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'libraries', 'sql', 'README.md'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'libraries', 'telemetry', 'types.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'libraries', 'telemetry', 'repositoryTelemetry.ts'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'adapters', 'README.md'))).toBe(true); - expect(existsSync(path.join(workspace, 'src', 'adapters', 'console', 'repositoryTelemetry.ts'))).toBe(true); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'README.md'))).toContain('Shared runtime contracts and reusable helpers live here.'); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'README.md'))).toContain('Do not move feature-specific validation, mapping, or orchestration helpers here'); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'sql', 'README.md'))).toContain('Keep driver-neutral SQL contracts here.'); - expect(readNormalizedFile(path.join(workspace, 'src', 'adapters', 'README.md'))).toContain('Technology-specific bindings live here.'); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'telemetry', 'repositoryTelemetry.ts'))).toContain('createNoopRepositoryTelemetry'); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'telemetry', 'repositoryTelemetry.ts'))).toContain('defaultRepositoryTelemetry'); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'telemetry', 'repositoryTelemetry.ts'))).not.toContain('sqlText'); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'telemetry', 'types.ts'))).toContain('paramsShape'); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'telemetry', 'types.ts'))).toContain('transformations'); - expect(readNormalizedFile(path.join(workspace, 'src', 'libraries', 'telemetry', 'types.ts'))).not.toContain('parameterValues'); - expect(readNormalizedFile(path.join(workspace, 'src', 'adapters', 'pg', 'sql-client.ts'))).toContain( - "from '#libraries/sql/sql-client.js'" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'adapters', 'console', 'repositoryTelemetry.ts'))).toContain( - "from '#libraries/telemetry/types.js'" - ); - expect(readNormalizedFile(path.join(workspace, 'src', 'adapters', 'console', 'repositoryTelemetry.ts'))).toContain('queryId'); - expect(readNormalizedFile(path.join(workspace, 'src', 'adapters', 'console', 'repositoryTelemetry.ts'))).not.toContain('sqlText'); - const packageJson = JSON.parse(readNormalizedFile(path.join(workspace, 'package.json'))) as { - type?: string; - scripts?: Record; - dependencies: Record; - devDependencies: Record; - imports?: Record; - }; - expect(packageJson.scripts?.test).toContain('--passWithNoTests'); - expect(packageJson.scripts?.test).not.toContain('no test specified'); - expect(packageJson.devDependencies).toHaveProperty('dotenv'); - expect(packageJson.devDependencies.vitest).toBe('^4.1.8'); - expect(packageJson.devDependencies).not.toHaveProperty('@rawsql-ts/sql-contract'); - expect(packageJson.dependencies).toHaveProperty('@rawsql-ts/driver-adapter-core'); - expect(packageJson.dependencies).not.toHaveProperty('@rawsql-ts/ztd-cli'); - expect(packageJson.dependencies).not.toHaveProperty('rawsql-ts'); - expect(packageJson.dependencies).not.toHaveProperty('@rawsql-ts/testkit-core'); - expect(packageJson.dependencies).not.toHaveProperty('@rawsql-ts/testkit-postgres'); - expect(packageJson.devDependencies).not.toHaveProperty('@rawsql-ts/driver-adapter-core'); - expect(packageJson.devDependencies).toHaveProperty('@rawsql-ts/testkit-core'); - expect(packageJson.devDependencies).toHaveProperty('@rawsql-ts/testkit-postgres'); - expect(packageJson.devDependencies).toHaveProperty('pg'); - expect(packageJson.devDependencies).toHaveProperty('@types/pg'); - expect(packageJson.imports?.['#features/*.js']).toEqual({ - types: './src/features/*.ts', - default: './dist/features/*.js' - }); - expect(packageJson.imports?.['#libraries/*.js']).toEqual({ - types: './src/libraries/*.ts', - default: './dist/libraries/*.js' - }); - expect(packageJson.imports?.['#adapters/*.js']).toEqual({ - types: './src/adapters/*.ts', - default: './dist/adapters/*.js' - }); - expect(packageJson.imports?.['#tests/*.js']).toEqual({ - types: './tests/*.ts', - default: './tests/*.ts' - }); - expect(existsSync(path.join(workspace, '.ztd', 'support', 'testkit-client.ts'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'smoke.boundary.ztd.test.ts'))).toBe(true); - expect(result.summary).toContain('compose.yaml'); - expect(result.summary).toContain('.env.example'); - expect(result.summary).toContain('.ztd/support/setup-env.ts'); - expect(result.summary).toContain('.ztd/support/postgres-testkit.ts'); - expect(result.summary).toContain('src/features/smoke/tests/smoke.boundary.test.ts'); - expect(result.summary).toContain('src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts'); - expect(result.summary).toContain('starter-only sample feature'); - expect(result.summary).not.toContain('ztd agents init'); - expect(result.summary).toContain('Delete src/features/smoke/'); -}); - -test('init dry-run plan matches starter outputs without AGENTS files', () => { - const workspace = createTempDir('cli-init-dry-run-plan'); - const plan = buildInitDryRunPlan(workspace, { - appShape: 'default', - starter: true, - postgresImage: 'postgres:17', - workflow: 'demo', - validator: 'zod', - localSourceRoot: null - }); - - expect(plan.dryRun).toBe(true); - expect(plan.files).toEqual(expect.arrayContaining([ - 'compose.yaml', - 'src/features/_shared/featureQueryExecutor.ts', - 'src/features/_shared/loadSqlResource.ts', - 'src/features/smoke/boundary.ts', - 'src/features/smoke/tests/smoke.boundary.test.ts', - 'src/libraries/sql/README.md', - 'src/adapters/README.md', - 'tests/support/ztd/README.md', - 'tests/support/ztd/case-types.ts', - 'tests/support/ztd/verifier.ts', - 'tests/support/ztd/harness.ts', - 'src/libraries/telemetry/types.ts', - 'src/adapters/console/repositoryTelemetry.ts', - '.ztd/support/postgres-testkit.ts' - ])); - expect(plan.files.filter((file) => file === 'src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts')).toHaveLength(1); - expect(plan.files).not.toContain('.gitignore'); - expect(plan.files).not.toContain('.ztd/generated/ztd-row-map.generated.ts'); - expect(plan.files).not.toContain('.ztd/generated/ztd-layout.generated.ts'); - expect(plan.files).not.toContain('.ztd/generated/ztd-fixture-manifest.generated.ts'); - expect(plan.files).not.toContain('src/features/smoke/application/README.md'); - expect(plan.files).not.toContain('src/features/smoke/domain/README.md'); - expect(plan.files).not.toContain('src/features/smoke/persistence/README.md'); - expect(plan.files).not.toContain('src/features/smoke/domain/smoke-policy.ts'); - expect(plan.files).not.toContain('src/features/smoke/application/smoke-workflow.ts'); - expect(plan.files).not.toContain('src/features/smoke/persistence/smoke.sql'); - expect(plan.files).not.toContain('src/features/smoke/persistence/boundary.ts'); - expect(plan.files).not.toContain('AGENTS.md'); - expect(plan.files).not.toContain('db/AGENTS.md'); - expect(plan.files).not.toContain('db/ddl/AGENTS.md'); - expect(plan.files).not.toContain('src/AGENTS.md'); - expect(plan.files).not.toContain('src/features/AGENTS.md'); -}); - -test('init dry-run plan for non-starter init excludes starter-only readmes', () => { - const workspace = createTempDir('cli-init-dry-run-plan-default'); - const plan = buildInitDryRunPlan(workspace, { - appShape: 'default', - starter: false, - workflow: 'empty', - validator: 'zod', - localSourceRoot: null - }); - - expect(plan.dryRun).toBe(true); - expect(plan.files).toEqual(expect.arrayContaining([ - 'src/libraries/sql/sql-client.ts', - 'src/adapters/pg/sql-client.ts' - ])); - expect(plan.files).not.toContain('src/libraries/README.md'); - expect(plan.files).not.toContain('src/libraries/sql/README.md'); - expect(plan.files).not.toContain('src/adapters/README.md'); - expect(plan.files).not.toContain('compose.yaml'); - expect(plan.files).not.toContain('src/features/smoke/README.md'); -}); - -test('init dry-run plan with app interface keeps scaffold files and adds AGENTS once', () => { - const workspace = createTempDir('cli-init-dry-run-plan-app-interface'); - const plan = buildInitDryRunPlan(workspace, { - appShape: 'default', - starter: false, - workflow: 'empty', - validator: 'zod', - localSourceRoot: null, - withAppInterface: true - }); - - expect(plan.dryRun).toBe(true); - expect(plan.files).toEqual(expect.arrayContaining([ - 'AGENTS.md', - 'src/libraries/sql/sql-client.ts', - 'src/adapters/pg/sql-client.ts' - ])); - expect(plan.files.filter((file) => file === 'AGENTS.md')).toHaveLength(1); -}); - -test('init derives generated lint-staged hook command from the package manager lockfile', async () => { - const workspace = createTempDir('cli-init-npm-hook'); - const prompter = new TestPrompter([]); - writeFileSync(path.join(workspace, 'package-lock.json'), '{}\n', 'utf8'); - - await runInitCommand(prompter, { - rootDir: workspace, - nonInteractive: true, - forceOverwrite: true, - workflow: 'empty', - validator: 'zod', - skipInstall: true - }); - - const packageJson = JSON.parse(readNormalizedFile(path.join(workspace, 'package.json'))) as { - 'simple-git-hooks'?: Record; - }; - expect(packageJson['simple-git-hooks']?.['pre-commit']).toBe('npx lint-staged'); -}); - -test('init replaces the default npm test placeholder with the runnable scaffold test script', async () => { - const workspace = createTempDir('cli-init-npm-test-placeholder'); - const prompter = new TestPrompter([]); - writeFileSync( - path.join(workspace, 'package.json'), - `${JSON.stringify( - { - name: 'npm-placeholder-starter', - version: '1.0.0', - scripts: { - test: 'echo "Error: no test specified" && exit 1', - custom: 'node custom.js' - } - }, - null, - 2 - )}\n`, - 'utf8' - ); - - await runInitCommand(prompter, { - rootDir: workspace, - nonInteractive: true, - forceOverwrite: true, - workflow: 'empty', - validator: 'zod', - skipInstall: true - }); - - const packageJson = JSON.parse(readNormalizedFile(path.join(workspace, 'package.json'))) as { - scripts?: Record; - }; - expect(packageJson.scripts?.test).toContain('--passWithNoTests'); - expect(packageJson.scripts?.test).not.toContain('no test specified'); - expect(packageJson.scripts?.custom).toBe('node custom.js'); -}); - -test('default scaffold omits AI control files', async () => { - const workspace = createTempDir('cli-init-then-bootstrap'); - const prompter = new TestPrompter([]); - - await runInitCommand(prompter, { - rootDir: workspace, - nonInteractive: true, - forceOverwrite: true, - workflow: 'empty', - validator: 'zod' - }); - - expect(existsSync(path.join(workspace, '.codex', 'config.toml'))).toBe(false); - expect(existsSync(path.join(workspace, 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'AGENTS_ztd.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'src', 'features', 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'db', 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'db', 'ddl', 'AGENTS.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'CONTEXT.md'))).toBe(false); - expect(existsSync(path.join(workspace, 'PROMPT_DOGFOOD.md'))).toBe(false); - expect(existsSync(path.join(workspace, '.ztd', 'agents', 'manifest.json'))).toBe(false); -}); - -test('init local-source mode links rawsql-ts dependencies from the monorepo without exposing local-source shims to consumer code', async () => { - const workspace = createTempDir('cli-init-local-source'); - const prompter = new TestPrompter([]); - - const result = await runInitCommand(prompter, { - rootDir: workspace, - forceOverwrite: true, - nonInteractive: true, - workflow: 'empty', - validator: 'zod', - localSourceRoot: repoRoot - }); - - const packageJson = JSON.parse(readNormalizedFile(path.join(workspace, 'package.json'))) as { - dependencies: Record; - devDependencies: Record; - imports?: Record; - }; - const localSourceGuardPath = path.join(workspace, 'scripts', 'local-source-guard.mjs'); - - expect(result.summary).toContain('Run pnpm ztd ztd-config'); - expect(result.summary).not.toContain('src/features/smoke/tests/smoke.test.ts'); - expect(existsSync(localSourceGuardPath)).toBe(true); - expect(packageJson.devDependencies['@rawsql-ts/sql-contract']).toBeUndefined(); - expect(packageJson.dependencies['@rawsql-ts/driver-adapter-core']).toBe( - `file:${path.relative(workspace, path.join(repoRoot, 'packages', 'drivers', 'driver-adapter-core')).replace(/\\/g, '/')}` - ); - expect(packageJson.devDependencies['@rawsql-ts/driver-adapter-core']).toBeUndefined(); - expect(packageJson.devDependencies['@rawsql-ts/testkit-core']).toBe( - `file:${path.relative(workspace, path.join(repoRoot, 'packages', 'testkit-core')).replace(/\\/g, '/')}` - ); - expect(packageJson.devDependencies['@rawsql-ts/testkit-postgres']).toBeUndefined(); - expect(packageJson.devDependencies['@rawsql-ts/ztd-cli']).toBe( - `file:${path.relative(workspace, path.join(repoRoot, 'packages', 'ztd-cli')).replace(/\\/g, '/')}` - ); - expect(packageJson.type).toBe('module'); - expect(packageJson.imports?.['#features/*.js']?.default).toBe('./dist/features/*.js'); - expect(packageJson.imports?.['#libraries/*.js']).toEqual({ - types: './src/libraries/*.ts', - default: './dist/libraries/*.js' - }); - expect(packageJson.imports?.['#adapters/*.js']).toEqual({ - types: './src/adapters/*.ts', - default: './dist/adapters/*.js' - }); - expect(packageJson.imports?.['#tests/*.js']).toEqual({ - types: './tests/*.ts', - default: './tests/*.ts' - }); -}); - -test('init starter local-source mode keeps starter rawsql-ts packages on file dependencies', async () => { - const workspace = createTempDir('cli-init-starter-local-source'); - const prompter = new TestPrompter([]); - - const result = await runInitCommand(prompter, { - rootDir: workspace, - starter: true, - forceOverwrite: true, - nonInteractive: true, - workflow: 'demo', - validator: 'zod', - localSourceRoot: repoRoot - }); - - const packageJson = JSON.parse(readNormalizedFile(path.join(workspace, 'package.json'))) as { - type?: string; - dependencies: Record; - devDependencies: Record; - imports?: Record; - }; - - expect(packageJson.devDependencies['@rawsql-ts/sql-contract']).toBeUndefined(); - expect(packageJson.dependencies['@rawsql-ts/driver-adapter-core']).toBe( - `file:${path.relative(workspace, path.join(repoRoot, 'packages', 'drivers', 'driver-adapter-core')).replace(/\\/g, '/')}` - ); - expect(packageJson.devDependencies['@rawsql-ts/driver-adapter-core']).toBeUndefined(); - expect(packageJson.devDependencies['@rawsql-ts/testkit-core']).toBe( - `file:${path.relative(workspace, path.join(repoRoot, 'packages', 'testkit-core')).replace(/\\/g, '/')}` - ); - expect(packageJson.devDependencies['@rawsql-ts/testkit-postgres']).toBe( - `file:${path.relative(workspace, path.join(repoRoot, 'packages', 'testkit-postgres')).replace(/\\/g, '/')}` - ); - expect(packageJson.devDependencies['@rawsql-ts/ztd-cli']).toBe( - `file:${path.relative(workspace, path.join(repoRoot, 'packages', 'ztd-cli')).replace(/\\/g, '/')}` - ); - expect(packageJson.type).toBe('module'); - expect(packageJson.imports?.['#features/*.js']?.default).toBe('./dist/features/*.js'); - expect(packageJson.imports?.['#libraries/*.js']).toEqual({ - types: './src/libraries/*.ts', - default: './dist/libraries/*.js' - }); - expect(packageJson.imports?.['#adapters/*.js']).toEqual({ - types: './src/adapters/*.ts', - default: './dist/adapters/*.js' - }); - expect(packageJson.imports?.['#tests/*.js']).toEqual({ - types: './tests/*.ts', - default: './tests/*.ts' - }); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('pnpm ztd ztd-config'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('pnpm ztd feature scaffold --table users --action insert'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).toContain('pnpm ztd feature tests scaffold --feature users-insert'); - expect(readNormalizedFile(path.join(workspace, 'README.md'))).not.toContain('pnpm exec ztd ztd-config'); - expect(result.summary).toContain('Run pnpm ztd ztd-config'); - expect(result.summary).toContain('`pnpm ztd feature scaffold --table users --action insert`'); - expect(result.summary).toContain('`pnpm ztd feature tests scaffold --feature users-insert`'); -}); - -test('pnpm nested under a parent workspace uses --ignore-workspace for manual installs', () => { - const workspace = createTempDir('cli-init-workspace-guard'); - - expect(findAncestorPnpmWorkspaceRoot(workspace)).toBe(repoRoot); - expect(resolvePnpmWorkspaceGuard(workspace, 'pnpm')).toEqual({ - workspaceRoot: repoRoot, - shouldIgnoreWorkspace: true - }); - expect(buildPackageManagerArgs('install', 'pnpm', [], workspace)).toEqual([ - 'install', - '--ignore-workspace' - ]); - expect(buildPackageManagerArgs('devDependencies', 'pnpm', ['vitest'], workspace)).toEqual([ - 'add', - '-D', - 'vitest', - '--ignore-workspace' - ]); -}); - -test('resolveInitInstallStrategy keeps manual add commands workspace-safe for Windows pnpm exec', () => { - const workspace = path.join(repoRoot, 'tmp', 'init-install-strategy'); - - expect( - resolveInitInstallStrategy(workspace, 'pnpm', { - platform: 'win32', - npmCommand: 'exec' - }).shouldDeferAutoInstall - ).toBe(true); - - expect(buildPackageManagerArgs('devDependencies', 'pnpm', ['vitest', 'typescript'], workspace)).toEqual([ - 'add', - '-D', - 'vitest', - 'typescript', - '--ignore-workspace' - ]); -}); - -test('resolvePackageManagerShellExecutable strips Windows absolute shim paths before shell execution', () => { - expect( - resolvePackageManagerShellExecutable('C:\\Program Files\\nodejs\\npm.cmd', 'npm', 'win32') - ).toBe('npm.cmd'); - expect( - resolvePackageManagerShellExecutable('C:\\Program Files\\nodejs\\pnpm.cmd', 'pnpm', 'win32') - ).toBe('pnpm.cmd'); - expect(resolvePackageManagerShellExecutable('/usr/local/bin/npm', 'npm', 'linux')).toBe('/usr/local/bin/npm'); -}); - -test('TestPrompter.confirm maps yes and no variants', async () => { - const prompter = new TestPrompter(['y', 'yes', 'n']); - expect(await prompter.confirm('still there?')).toBe(true); - expect(await prompter.confirm('again?')).toBe(true); - expect(await prompter.confirm('final?')).toBe(false); -}); - -test('TestPrompter.selectChoice rejects invalid inputs', async () => { - await expect(new TestPrompter(['foo']).selectChoice('option?', ['a', 'b'])).rejects.toThrow('Invalid choice'); - await expect(new TestPrompter(['99']).selectChoice('option?', ['only'])).rejects.toThrow('outside the valid range'); -}); diff --git a/packages/ztd-cli/tests/intentProcedure.docs.test.ts b/packages/ztd-cli/tests/intentProcedure.docs.test.ts deleted file mode 100644 index bd2a3162a..000000000 --- a/packages/ztd-cli/tests/intentProcedure.docs.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function readNormalizedFile(relativePath: string): string { - const filePath = path.join(repoRoot, relativePath); - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); -} - -function expectInOrder(haystack: string, needles: string[]): void { - let cursor = 0; - for (const needle of needles) { - const index = haystack.indexOf(needle, cursor); - expect(index, `Expected to find "${needle}" after offset ${cursor}`).toBeGreaterThanOrEqual(0); - cursor = index + needle.length; - } -} - -test('root policy and mirror describe intent and procedure as causality', () => { - const rootAgents = readNormalizedFile('AGENTS.md'); - const mirrorAgents = readNormalizedFile('.agent/AGENTS.md'); - - expect(rootAgents).toContain('# Repository Scope'); - expect(rootAgents).toContain('Use the repo-local guidance under `.codex/agents/` and `.agents/skills/` for planning, verification, review, and reporting details.'); - expect(rootAgents).toContain('Keep assistant-user conversation in Japanese in this repository.'); - expect(rootAgents).toContain('Plans must state the source issue or request, acceptance items, verification methods, and explicit out-of-scope items when scope is limited.'); - - expect(mirrorAgents).toContain('# Visible Policy Mirror'); - expect(mirrorAgents).toContain('Use `.codex/agents/planning.md`, `.codex/agents/verification.md`, `.codex/agents/review.md`, and `.codex/agents/reporting.md` for developer workflow support.'); - expect(mirrorAgents).toContain('Use `.agents/skills/acceptance-planning/SKILL.md`, `.agents/skills/self-review/SKILL.md`, `.agents/skills/package-spec-review/SKILL.md`, and `.agents/skills/attainment-reporting/SKILL.md` for repeatable planning, package spec review, review, and reporting workflows.'); -}); - -test('README exposes the high-level intent and procedure entry point', () => { - const readme = readNormalizedFile('README.md'); - - expectInOrder(readme, [ - '## Tutorials', - 'SQL-first End-to-End Tutorial', - '## Intent and Procedure', - 'Use this repo by treating DDL and SQL as source assets, and generated specs, repositories, and tests as downstream artifacts that must stay in sync.', - 'Procedure: `DDL -> SQL -> generate -> wire -> test`.', - 'For a step-by-step example, see the SQL-first tutorial above.', - ]); -}); diff --git a/packages/ztd-cli/tests/lintUtils.test.ts b/packages/ztd-cli/tests/lintUtils.test.ts deleted file mode 100644 index f8b613166..000000000 --- a/packages/ztd-cli/tests/lintUtils.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { - resolveSqlFiles, - extractEnumLabels, - inferDefaultValue -} from '../src/utils/sqlLintHelpers'; -import { - buildLintCommandFailureData, - buildLintConnectionError, - buildLintContainerStartError, - buildLintDefaultBindings, - buildParserFailure, - detectMaxPositionalParamIndex, - resolveLintCommandInput, -} from '../src/commands/lint'; - -function createTempDir(prefix: string): string { - return mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); -} - -const toPosixPath = (value: string): string => value.replace(/\\/g, '/'); - -test('resolveSqlFiles resolves files, directories, and glob patterns', () => { - const workspace = createTempDir('ztd-lint'); - const topFile = path.join(workspace, 'top.sql'); - mkdirSync(path.join(workspace, 'nested'), { recursive: true }); - const nestedFile = path.join(workspace, 'nested', 'detail.sql'); - writeFileSync(topFile, 'select 1'); - writeFileSync(nestedFile, 'select 2'); - - const directoryMatch = resolveSqlFiles(workspace); - expect(directoryMatch).toEqual( - expect.arrayContaining([toPosixPath(topFile), toPosixPath(nestedFile)]) - ); - - const globMatch = resolveSqlFiles(path.join(workspace, '**', '*.sql')); - expect(globMatch).toEqual( - expect.arrayContaining([toPosixPath(nestedFile), toPosixPath(topFile)]) - ); - - const fileMatch = resolveSqlFiles(topFile); - expect(fileMatch).toEqual([toPosixPath(topFile)]); -}); - -test('extractEnumLabels returns normalized enum keys', () => { - const workspace = createTempDir('ztd-lint-enum'); - const ddlDir = path.join(workspace, 'ztd', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'status.sql'), - ` - CREATE TYPE public.status AS ENUM ( - 'pending', - 'done' - ); - ` - ); - - const enums = extractEnumLabels([ddlDir]); - expect(enums.has('public.status')).toBe(true); - expect(enums.get('public.status')).toEqual(['pending', 'done']); -}); - -test('inferDefaultValue returns type-specific fixtures', () => { - const enumMap = new Map([ - ['public.status', ['pending', 'done']] - ]); - expect(inferDefaultValue('integer', enumMap)).toBe(0); - expect(inferDefaultValue('boolean', enumMap)).toBe(false); - expect(inferDefaultValue('public.status', enumMap)).toBe('pending'); - expect(inferDefaultValue('character varying', enumMap)).toBe(''); - expect(inferDefaultValue('timestamp with time zone', enumMap)).toBe('1970-01-01 00:00:00'); - expect(inferDefaultValue('uuid', enumMap)).toBe('00000000-0000-0000-0000-000000000000'); - const numericArrayFixture = inferDefaultValue('numeric[]', enumMap); - expect(typeof numericArrayFixture).toBe('string'); - expect(numericArrayFixture).toContain('{'); - expect(numericArrayFixture).toContain('}'); -}); - -test('buildParserFailure marks parser kind with parse keyword', () => { - const error = new Error('unexpected token'); - const failure = buildParserFailure('test.sql', 'select 1', error); - expect(failure.kind).toBe('parser'); - expect(failure.message.toLowerCase()).toContain('parse'); - expect(failure.details?.code).toBeUndefined(); -}); - -test('buildLintContainerStartError appends Docker guidance for runtime failures', () => { - const error = buildLintContainerStartError(new Error('Could not find a working container runtime strategy')); - expect(error.message).toContain('Start Docker Desktop/service'); - expect(error.message).toContain('ZTD_DB_URL'); -}); - -test('buildLintConnectionError explains external connection recovery', () => { - const error = buildLintConnectionError(new Error('ECONNREFUSED'), true); - expect(error.message).toContain('ZTD_DB_URL'); - expect(error.message).toContain('ECONNREFUSED'); -}); - -test('buildLintConnectionError explains Docker recovery when no external connection is set', () => { - const error = buildLintConnectionError(new Error('timeout'), false); - expect(error.message).toContain('Docker Desktop/service'); - expect(error.message).toContain('ZTD_DB_URL'); -}); - -test('buildLintCommandFailureData returns a stable JSON envelope payload for command failures', () => { - expect(buildLintCommandFailureData(new Error('lint exploded'))).toEqual({ - schemaVersion: 1, - filesChecked: 0, - failures: [], - error: 'lint exploded' - }); -}); - - -test('detectMaxPositionalParamIndex returns the highest positional slot', () => { - expect(detectMaxPositionalParamIndex('select * from t where a = $1 and b = $3')).toBe(3); - expect(detectMaxPositionalParamIndex('select 1')).toBe(0); -}); - -test('buildLintDefaultBindings creates null-filled arrays for positional placeholders', () => { - expect(buildLintDefaultBindings('select * from t where a = $1 and b = $3')).toEqual([null, null, null]); -}); - -test('buildLintDefaultBindings creates name-keyed null objects for named placeholders', () => { - expect(buildLintDefaultBindings('select * from t where a = :id and b = :status')).toEqual({ - id: null, - status: null, - }); -}); - -test('resolveLintCommandInput accepts a path from --json payload', () => { - expect( - resolveLintCommandInput(undefined, { json: JSON.stringify({ path: 'src/sql/**/*.sql' }) }) - ).toEqual({ path: 'src/sql/**/*.sql' }); -}); - -test('resolveLintCommandInput rejects missing path across positional and json inputs', () => { - expect(() => resolveLintCommandInput(undefined, {})).toThrow(/must be provided/); -}); diff --git a/packages/ztd-cli/tests/modelGen.unit.test.ts b/packages/ztd-cli/tests/modelGen.unit.test.ts deleted file mode 100644 index d690c70c1..000000000 --- a/packages/ztd-cli/tests/modelGen.unit.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -import { expect, test } from 'vitest'; -import { - bindProbeSql, - loadModelGenZtdFixtureState, - normalizeCliPath, - resolveModelGenInputs, - resolveModelGenZtdProbeOptions, - resolveCliConnectionWithProbeGuidance, - buildModelGenConnectionFailure, - inferReturningColumnsFromTableDefinitions, -} from '../src/commands/modelGen'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { bindModelGenNamedSql } from '../src/utils/modelGenBinder'; -import { buildProbeSql, probeQueryColumns } from '../src/utils/modelProbe'; -import { deriveModelGenNames, normalizeGeneratedSqlFile, renderModelGenFile, toModelPropertyName } from '../src/utils/modelGenRender'; -import { ModelGenSqlScanError, scanModelGenSql } from '../src/utils/modelGenScanner'; - -test('scanModelGenSql detects named parameters while skipping strings and comments', () => { - const sql = ` - select ':ignored', "still:ignored" - from users - where id = :id - -- and email = :ignored_comment - /* and name = :ignored_block */ - and status = :status - `; - - const result = scanModelGenSql(sql); - expect(result.mode).toBe('named'); - expect(result.namedTokens.map((token) => token.name)).toEqual(['id', 'status']); -}); - -test('scanModelGenSql rejects unsupported placeholder syntaxes', () => { - expect(() => scanModelGenSql('select * from users where id = :user-id')).toThrow(ModelGenSqlScanError); - expect(() => scanModelGenSql('select * from users where id = :名前')).toThrow(ModelGenSqlScanError); - expect(() => scanModelGenSql('select * from users where id = @userId')).toThrow(ModelGenSqlScanError); - expect(() => scanModelGenSql('select * from users where id = ${userId}')).toThrow(ModelGenSqlScanError); - expect(() => scanModelGenSql('select * from users where id = ?')).toThrow(ModelGenSqlScanError); -}); - -test('scanModelGenSql rejects mixed named and positional placeholders', () => { - expect(() => scanModelGenSql('select * from users where id = :id and status = $1')).toThrow(ModelGenSqlScanError); -}); - -test('scanModelGenSql ignores PostgreSQL casts for named placeholders', () => { - const result = scanModelGenSql('select :status::text as status'); - expect(result.mode).toBe('named'); - expect(result.namedTokens.map((token) => token.name)).toEqual(['status']); - expect(result.positionalTokens).toEqual([]); -}); - -test('scanModelGenSql ignores PostgreSQL casts for positional placeholders', () => { - const result = scanModelGenSql('select $1::uuid as user_id'); - expect(result.mode).toBe('positional'); - expect(result.namedTokens).toEqual([]); - expect(result.positionalTokens.map((token) => token.token)).toEqual(['$1']); -}); - -test('bindModelGenNamedSql reuses the same slot for repeated names in SQL order', () => { - const bound = bindModelGenNamedSql('select * from demo where a = :id or b = :id and c = :name'); - expect(bound.orderedParamNames).toEqual(['id', 'name']); - expect(bound.boundSql).toContain('a = $1 or b = $1 and c = $2'); -}); - -test('bindProbeSql derives positional params from scanner tokens and preserves sparse indexes', () => { - const sql = "select '$99' as ignored, value from demo where a = $2 or b = $2"; - const bound = bindProbeSql(sql, scanModelGenSql(sql), true); - expect(bound.boundSql).toBe(sql); - expect(bound.orderedParamNames).toEqual(['$1', '$2']); -}); - -test('toModelPropertyName converts SQL columns to camelCase names', () => { - expect(toModelPropertyName('sales_id')).toBe('salesId'); - expect(toModelPropertyName('customer-name')).toBe('customerName'); - expect(toModelPropertyName('1st_column')).toBe('_1stColumn'); -}); - -test('deriveModelGenNames builds stable generated identifiers from sql-root relative paths', () => { - expect(deriveModelGenNames('sales/get_sales_header.sql')).toEqual({ - interfaceName: 'GetSalesHeaderRow', - mappingName: 'getSalesHeaderMapping', - specName: 'getSalesHeaderSpec', - specId: 'sales.getSalesHeader' - }); -}); - -test('resolveModelGenInputs derives VSA feature-local ids and spec-relative sqlFile values without --sql-root', () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), 'model-gen-vsa-inputs-')); - const sqlDir = path.join(workspace, 'src', 'features', 'users', 'persistence'); - mkdirSync(sqlDir, { recursive: true }); - const sqlFile = path.join(sqlDir, 'users.sql'); - const outFile = path.join(sqlDir, 'users.spec.ts'); - writeFileSync(sqlFile, 'select 1 as value', 'utf8'); - - expect( - resolveModelGenInputs(sqlFile, { - rootDir: workspace, - out: path.relative(workspace, outFile), - }) - ).toMatchObject({ - relativeSqlFile: './users.sql', - derivedNames: { - specId: 'features.users.persistence.users', - specName: 'usersSpec', - }, - }); -}); - -test('resolveModelGenInputs keeps shared-root ids stable when --sql-root is used as a compatibility helper', () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), 'model-gen-shared-root-')); - const sqlDir = path.join(workspace, 'src', 'sql', 'sales'); - mkdirSync(sqlDir, { recursive: true }); - const sqlFile = path.join(sqlDir, 'get_sales_header.sql'); - writeFileSync(sqlFile, 'select 1 as value', 'utf8'); - - expect( - resolveModelGenInputs(sqlFile, { - rootDir: workspace, - sqlRoot: path.join('src', 'sql'), - }) - ).toMatchObject({ - relativeSqlFile: 'sales/get_sales_header.sql', - derivedNames: { - specId: 'sales.getSalesHeader', - specName: 'getSalesHeaderSpec', - }, - }); -}); - -test('normalizeGeneratedSqlFile always uses forward slashes', () => { - expect(normalizeGeneratedSqlFile('sales\\get_sales_header.sql')).toBe('sales/get_sales_header.sql'); -}); - -test('renderModelGenFile emits names-first spec scaffolds', () => { - const output = renderModelGenFile({ - command: 'ztd model-gen src/sql/sales/get_sales_header.sql', - format: 'spec', - sqlFile: 'sales/get_sales_header.sql', - specId: 'sales.getSalesHeader', - interfaceName: 'GetSalesHeaderRow', - mappingName: 'getSalesHeaderMapping', - specName: 'getSalesHeaderSpec', - placeholderMode: 'named', - allowPositional: false, - orderedParamNames: ['sales_id'], - columns: [ - { columnName: 'sales_id', propertyName: 'salesId', tsType: 'number' }, - { columnName: 'created_at', propertyName: 'createdAt', tsType: 'string' } - ] - }); - - expect(output).toContain('// names-first reminder'); - expect(output).not.toContain('@rawsql-ts/sql-contract'); - expect(output).not.toContain('rowMapping'); - expect(output).not.toContain('QuerySpec'); - expect(output).toContain("params: { shape: 'named', example: { sales_id: null } }"); - expect(output).toContain("salesId: 'sales_id'"); - expect(output).toContain("createdAt: 'created_at'"); -}); - -test('renderModelGenFile escapes single quotes and backslashes in generated string literals', () => { - const output = renderModelGenFile({ - command: 'ztd model-gen src/sql/demo.sql', - format: 'spec', - sqlFile: "sales\\owner's_report.sql", - specId: "sales.owner'sReport", - interfaceName: 'OwnerReportRow', - mappingName: 'ownerReportMapping', - specName: 'ownerReportSpec', - placeholderMode: 'named', - allowPositional: false, - orderedParamNames: ['owner_id'], - columns: [{ columnName: "owner's\\name", propertyName: 'ownerName', tsType: 'string' }] - }); - - expect(output).toContain("id: 'sales.owner\\'sReport'"); - expect(output).toContain("sqlFile: 'sales\\\\owner\\'s_report.sql'"); - expect(output).toContain("ownerName: 'owner\\'s\\\\name'"); -}); - -test('renderModelGenFile marks positional scaffolds as legacy when explicitly allowed', () => { - const output = renderModelGenFile({ - command: 'ztd model-gen legacy.sql --allow-positional', - format: 'spec', - sqlFile: 'legacy.sql', - specId: 'legacy', - interfaceName: 'LegacyRow', - mappingName: 'legacyMapping', - specName: 'legacySpec', - placeholderMode: 'positional', - allowPositional: true, - orderedParamNames: ['$1', '$2'], - columns: [{ columnName: 'value', propertyName: 'value', tsType: 'string' }] - }); - - expect(output).toContain('Legacy warning'); - expect(output).toContain("params: { shape: 'positional', example: [null, null] }"); -}); - -test('renderModelGenFile emits row mapping metadata without runtime helper imports', () => { - const output = renderModelGenFile({ - command: 'ztd model-gen src/sql/demo.sql --format row-mapping', - format: 'row-mapping', - sqlFile: 'demo.sql', - specId: 'demo', - interfaceName: 'DemoRow', - mappingName: 'demoMapping', - specName: 'demoSpec', - placeholderMode: 'none', - allowPositional: false, - orderedParamNames: [], - columns: [{ columnName: 'value', propertyName: 'value', tsType: 'string' }] - }); - - expect(output).toContain('export const demoMapping = {'); - expect(output).toContain("value: 'value'"); - expect(output).not.toContain('rowMapping'); - expect(output).not.toContain('@rawsql-ts/sql-contract'); -}); - -test('normalizeCliPath converts windows-style paths to slash-separated paths', () => { - expect(normalizeCliPath('src\\sql\\sales\\get_sales_header.sql')).toBe('src/sql/sales/get_sales_header.sql'); - expect(normalizeCliPath('src\\catalog\\specs\\get-sales-header.spec.ts')).toBe('src/catalog/specs/get-sales-header.spec.ts'); -}); - -test('buildProbeSql trims trailing semicolons before wrapping the probe query', () => { - const probeSql = buildProbeSql('select 1 as value; \n'); - expect(probeSql).toContain('SELECT * FROM ('); - expect(probeSql).toContain('AS _ztd_type_probe LIMIT 0'); - expect(probeSql.toLowerCase()).toContain('select 1 as'); - expect(probeSql.toLowerCase()).toContain('value'); -}); - -test('buildProbeSql preserves PostgreSQL positional placeholders after binding', () => { - const probeSql = buildProbeSql('select * from public.products where id = $1'); - expect(probeSql).toContain('where id = $1'); - expect(probeSql).not.toContain(':1'); -}); - -test('buildProbeSql converts positional insert-returning statements into probeable select SQL', () => { - const probeSql = buildProbeSql(` - insert into public.users (email, display_name) - values ($1, $2) - returning user_id - `); - - expect(probeSql).toContain('SELECT * FROM ('); - expect(probeSql.toLowerCase()).toContain('select'); - expect(probeSql.toLowerCase()).toContain('from public.users'); - expect(probeSql.toLowerCase()).toContain('user_id'); - expect(probeSql.toLowerCase()).not.toContain('insert into'); - expect(probeSql).not.toContain(':1'); - expect(probeSql).not.toContain(':2'); -}); - -test('buildProbeSql converts insert-returning statements into probeable select SQL', () => { - const probeSql = buildProbeSql(` - insert into public.users (email, display_name) - values (:email, :display_name) - returning user_id - `); - - expect(probeSql).toContain('SELECT * FROM ('); - expect(probeSql.toLowerCase()).toContain('select'); - expect(probeSql.toLowerCase()).toContain('from public.users'); - expect(probeSql.toLowerCase()).toContain('user_id'); - expect(probeSql.toLowerCase()).not.toContain('insert into'); - expect(probeSql.toLowerCase()).not.toContain('expected'); -}); - -test('probeQueryColumns maps int8 metadata to string to match pg driver defaults', async () => { - let queryCall = 0; - const client = { - async query(sql: string): Promise<{ fields?: unknown; rows?: T[] }> { - queryCall += 1; - if (queryCall === 1) { - expect(sql).toContain('SELECT * FROM ('); - expect(sql.toLowerCase()).toContain('select count(*) as'); - expect(sql.toLowerCase()).toContain('total'); - return { - fields: [{ name: 'total', dataTypeID: 20 }], - rows: [] - }; - } - expect(sql).toContain('FROM pg_type'); - return { - rows: [{ oid: 20, typname: 'int8', typtype: 'b', typelem: 0, typbasetype: 0 } as T] - }; - } - }; - - await expect(probeQueryColumns(client, 'select count(*) as total', [])).resolves.toEqual([ - { columnName: 'total', typeName: 'int8', tsType: 'string' } - ]); -}); - -test('probeQueryColumns can probe direct SQL without wrapping when the caller opts in', async () => { - let queryCall = 0; - const client = { - async query(sql: string): Promise<{ fields?: unknown; rows?: T[] }> { - queryCall += 1; - if (queryCall === 1) { - expect(sql.toLowerCase()).toContain('insert into public.users'); - expect(sql).not.toContain('SELECT * FROM ('); - return { - fields: [{ name: 'user_id', dataTypeID: 20 }], - rows: [] - }; - } - - return { - rows: [{ oid: 20, typname: 'int8', typtype: 'b', typelem: 0, typbasetype: 0 } as T] - }; - } - }; - - await expect( - probeQueryColumns( - client, - 'insert into public.users (email) values ($1) returning user_id', - [null], - { direct: true } - ) - ).resolves.toEqual([ - { columnName: 'user_id', typeName: 'int8', tsType: 'string' } - ]); -}); - -test('inferReturningColumnsFromTableDefinitions resolves schema-qualified inserts against unqualified ZTD table definitions', () => { - expect( - inferReturningColumnsFromTableDefinitions( - 'insert into "public"."users" (email) values (:email) returning "user_id";', - [ - { - name: 'users', - columns: [ - { name: 'user_id', typeName: 'bigint' }, - { name: 'email', typeName: 'text' }, - ], - }, - ], - 'public', - ['public'] - ) - ).toEqual([ - { columnName: 'user_id', typeName: 'bigint', tsType: 'string' } - ]); -}); - -test('resolveModelGenZtdProbeOptions preserves defaultSchema and searchPath from ztd.config.json', () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), 'model-gen-ztd-config-')); - mkdirSync(path.join(workspace, 'schema'), { recursive: true }); - writeFileSync( - path.join(workspace, 'ztd.config.json'), - JSON.stringify({ - dialect: 'postgres', - ddlDir: 'schema', - ztdRootDir: '.ztd', - defaultSchema: 'app', - searchPath: ['app', 'public'], - ddlLint: 'strict' - }), - 'utf8' - ); - - expect(resolveModelGenZtdProbeOptions({ rootDir: workspace })).toEqual({ - ddlDirectories: [path.join(workspace, 'schema')], - defaultSchema: 'app', - searchPath: ['app', 'public'] - }); -}); - -test('loadModelGenZtdFixtureState creates empty fixtures for DDL-only tables', async () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), 'model-gen-ztd-fixtures-')); - const ddlDir = path.join(workspace, 'schema'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'public.sql'), - ` - CREATE TABLE public.users ( - user_id integer PRIMARY KEY, - email text NOT NULL - ); - `, - 'utf8' - ); - - const fixtureState = await loadModelGenZtdFixtureState({ - ddlDirectories: [ddlDir], - defaultSchema: 'public', - searchPath: ['public'], - }); - - expect(fixtureState.tableDefinitions).toHaveLength(1); - expect(fixtureState.tableRows).toEqual([ - { - tableName: 'public.users', - rows: [], - }, - ]); -}); - -test('loadModelGenZtdFixtureState preserves searchPath precedence so unqualified references resolve to the first matching schema', async () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), 'model-gen-ztd-search-path-')); - const ddlDir = path.join(workspace, 'schema'); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(ddlDir, 'schemas.sql'), - ` - CREATE SCHEMA app; - - CREATE TABLE app.users ( - account_id integer PRIMARY KEY - ); - - CREATE TABLE public.users ( - user_id integer PRIMARY KEY - ); - `, - 'utf8' - ); - - const fixtureState = await loadModelGenZtdFixtureState({ - ddlDirectories: [ddlDir], - defaultSchema: 'app', - searchPath: ['app', 'public'], - }); - - // DdlFixtureLoader keeps both schema-qualified fixtures, and the first entry matches the searchPath - // priority that unqualified references (for example `from users`) will follow during ZTD probing. - expect(fixtureState.tableDefinitions.map((table) => (table as { name: string }).name)).toEqual([ - 'app.users', - 'public.users', - ]); - expect(fixtureState.tableRows.map((fixture) => fixture.tableName)).toEqual([ - 'app.users', - 'public.users', - ]); - expect(fixtureState.tableRows).toEqual([ - { - tableName: 'app.users', - rows: [], - }, - { - tableName: 'public.users', - rows: [], - }, - ]); -}); - - -test('resolveCliConnectionWithProbeGuidance explains ztd probe DB requirement when connection is missing', () => { - const previous = process.env.ZTD_DB_URL; - try { - delete process.env.ZTD_DB_URL; - expect(() => resolveCliConnectionWithProbeGuidance({}, 'ztd')).toThrow(/ZTD_DB_URL/); - } finally { - if (previous === undefined) { - delete process.env.ZTD_DB_URL; - } else { - process.env.ZTD_DB_URL = previous; - } - } -}); - -test('buildModelGenConnectionFailure includes mode-specific guidance', () => { - const ztdError = buildModelGenConnectionFailure(new Error('ECONNREFUSED'), 'ztd'); - expect(ztdError.message).toContain('before ZTD-owned inspection'); - expect(ztdError.message).toContain('ECONNREFUSED'); - - const liveError = buildModelGenConnectionFailure(new Error('timeout'), 'live'); - expect(liveError.message).toContain('Failed to connect to PostgreSQL for model-gen'); - expect(liveError.message).toContain('timeout'); -}); diff --git a/packages/ztd-cli/tests/optionalDependencies.unit.test.ts b/packages/ztd-cli/tests/optionalDependencies.unit.test.ts deleted file mode 100644 index 003de0ce3..000000000 --- a/packages/ztd-cli/tests/optionalDependencies.unit.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -import { buildConsumerInstallHint, findNearestPackageRoot, findWorkspaceRoot } from '../src/utils/optionalDependencies'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - mkdirSync(tmpRoot, { recursive: true }); - return path.join(tmpRoot, `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`); -} - -test('findNearestPackageRoot resolves a consumer package without requiring pnpm-workspace.yaml', () => { - const workspace = createTempDir('optional-deps-consumer'); - const packageDir = path.join(workspace, 'node_modules', '@rawsql-ts', 'ztd-cli'); - const nestedDir = path.join(packageDir, 'dist', 'utils'); - - mkdirSync(nestedDir, { recursive: true }); - writeFileSync(path.join(workspace, 'package.json'), JSON.stringify({ name: 'consumer-app' }, null, 2), 'utf8'); - writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({ name: '@rawsql-ts/ztd-cli' }, null, 2), 'utf8'); - - expect(findNearestPackageRoot(nestedDir)).toBe(packageDir); - expect(findWorkspaceRoot(nestedDir)).toBe(repoRoot); -}); - -test('findWorkspaceRoot still detects the rawsql-ts monorepo root when present', () => { - const nestedDir = path.join(repoRoot, 'packages', 'ztd-cli', 'src', 'utils'); - - expect(findWorkspaceRoot(nestedDir)).toBe(repoRoot); -}); - -test('buildConsumerInstallHint uses npm as the external standalone primary path', () => { - expect(buildConsumerInstallHint('@rawsql-ts/testkit-core')).toBe('npm install --save-dev @rawsql-ts/testkit-core'); - expect(buildConsumerInstallHint('pg', '@testcontainers/postgresql')).toBe( - 'npm install --save-dev pg @testcontainers/postgresql' - ); -}); diff --git a/packages/ztd-cli/tests/options.unit.test.ts b/packages/ztd-cli/tests/options.unit.test.ts deleted file mode 100644 index 50472a2f9..000000000 --- a/packages/ztd-cli/tests/options.unit.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, test } from 'vitest'; -import { - normalizeDirectoryList, - parseExtensions, - resolveExtensions, - DEFAULT_DDL_DIRECTORY, - DEFAULT_EXTENSIONS -} from '../src/commands/options'; - -test('normalizeDirectoryList respects user directories and falls back when absent', () => { - const provided = ['db/ddl', 'schema']; - const normalized = normalizeDirectoryList(provided, DEFAULT_DDL_DIRECTORY); - expect(normalized).toEqual(['db/ddl', 'schema']); - - const fallback = normalizeDirectoryList([], DEFAULT_DDL_DIRECTORY); - expect(fallback).toEqual([DEFAULT_DDL_DIRECTORY]); -}); - -test('normalizeDirectoryList removes duplicates', () => { - const normalized = normalizeDirectoryList(['db/ddl', 'db/ddl', 'schema'], DEFAULT_DDL_DIRECTORY); - expect(normalized).toEqual(['db/ddl', 'schema']); -}); - -test('parseExtensions normalizes CLI extension arguments and ignores invalid tokens', () => { - const list = parseExtensions('SQL, .ddl ,json , ,TXT, ???'); - expect(list).toEqual(['.ddl', '.json', '.sql', '.txt']); -}); - -test('parseExtensions accepts array inputs and deduplicates case-insensitively', () => { - const list = parseExtensions(['SQL', '.Sql', 'json']); - expect(list).toEqual(['.json', '.sql']); -}); - -test('resolveExtensions falls back to defaults when none provided', () => { - expect(resolveExtensions(undefined, DEFAULT_EXTENSIONS)).toEqual(DEFAULT_EXTENSIONS); -}); - -test('resolveExtensions respects provided extensions when available', () => { - const extensions = ['.sql', '.ddl']; - expect(resolveExtensions(extensions, DEFAULT_EXTENSIONS)).toEqual(['.sql', '.ddl']); -}); diff --git a/packages/ztd-cli/tests/perfBenchmark.unit.test.ts b/packages/ztd-cli/tests/perfBenchmark.unit.test.ts deleted file mode 100644 index 264200a7e..000000000 --- a/packages/ztd-cli/tests/perfBenchmark.unit.test.ts +++ /dev/null @@ -1,1459 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { - buildPerfPipelineAnalysis, - buildPerfTuningGuidance, - buildPerfTuningSummary, - diffPerfBenchmarkReports, - formatPerfBenchmarkReport, - formatPerfDiffReport, - loadPerfBenchmarkReport, - mapPipelineStatements, - runPerfBenchmark, - summarizePerfDdlInventory, - toPerfPlannedSteps, - type PerfBenchmarkReport -} from '../src/perf/benchmark'; -import { TAX_ALLOCATION_QUERY } from './utils/taxAllocationScenario'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function createSqlWorkspace(prefix: string, sqlRelativePath: string = path.join('src', 'sql', 'query.sql')): { - rootDir: string; - sqlFile: string; -} { - const rootDir = createTempDir(prefix); - const sqlFile = path.join(rootDir, sqlRelativePath); - mkdirSync(path.dirname(sqlFile), { recursive: true }); - return { rootDir, sqlFile }; -} - -function makePerfReport(overrides: Partial = {}): PerfBenchmarkReport { - return { - schema_version: 1, - command: 'perf run', - run_id: 'run_001', - query_file: 'candidate.sql', - query_type: 'SELECT', - params_shape: 'none', - ordered_param_names: [], - source_sql_file: 'candidate.sql', - source_sql: 'select 1', - bound_sql: 'select 1', - bindings: undefined, - strategy: 'direct', - requested_mode: 'completion', - selected_mode: 'completion', - selection_reason: 'forced', - classify_threshold_ms: 60000, - timeout_ms: 300000, - dry_run: false, - saved: true, - total_elapsed_ms: 500, - completion_metrics: { - completed: true, - timed_out: false, - wall_time_ms: 500 - }, - executed_statements: [], - plan_observations: [], - recommended_actions: [], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - }, - ...overrides - }; -} - -test('runPerfBenchmark dry-run binds named YAML params and surfaces pipeline analysis', async () => { - const workspace = createSqlWorkspace('perf-benchmark-dry-run', path.join('src', 'sql', 'reports', 'sales.sql')); - const paramsFile = path.join(workspace.rootDir, 'perf', 'params.yml'); - mkdirSync(path.dirname(paramsFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - ` - with scoped_sales as ( - select id, region_id - from public.sales - where region_id = :region_id - ), - final_sales as ( - select id from scoped_sales - ) - select * from final_sales - `, - 'utf8' - ); - writeFileSync(paramsFile, ['# named params for perf runs', 'params:', ' region_id: 10', ''].join('\n'), 'utf8'); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - paramsFile, - mode: 'latency', - repeat: 5, - warmup: 1, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.dry_run).toBe(true); - expect(report.params_shape).toBe('named'); - expect(report.ordered_param_names).toEqual(['region_id']); - expect(report.bindings).toEqual([10]); - expect(report.executed_statements).toEqual([ - expect.objectContaining({ - seq: 1, - role: 'final-query', - sql: expect.stringContaining('$1') - }) - ]); - expect(report.executed_statements[0]?.resolved_sql_preview).toContain('region_id = 10'); - expect(report.plan_observations).toEqual([]); - expect(report.recommended_actions).toEqual([]); - expect(report.pipeline_analysis.should_consider_pipeline).toBe(false); - expect(report.params_file).toBe(path.resolve(paramsFile)); - expect(report.strategy).toBe('direct'); - expect(report.strategy_metadata).toBeUndefined(); - - const text = formatPerfBenchmarkReport(report, 'text'); - expect(text).toContain('Mode: latency'); - expect(text).toContain('Executed statements:'); - expect(text).toContain('resolved_sql_preview:'); -}); - -test('buildPerfPipelineAnalysis flags reusable fan-out CTEs as pipeline candidates', () => { - const workspace = createSqlWorkspace('perf-pipeline-analysis', path.join('src', 'sql', 'reports', 'pipeline_candidate.sql')); - writeFileSync( - workspace.sqlFile, - ` - with base_sales as ( - select id, region_id, closed_month from public.sales - ), - regional_sales as ( - select id, region_id from base_sales - ), - month_sales as ( - select id, closed_month from base_sales - ), - final_sales as ( - select rs.id - from regional_sales rs - join month_sales ms on ms.id = rs.id - ) - select * from final_sales - `, - 'utf8' - ); - - const analysis = buildPerfPipelineAnalysis(workspace.sqlFile); - - expect(analysis.should_consider_pipeline).toBe(true); - expect(analysis.candidate_ctes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: 'base_sales', - downstream_references: 2, - reasons: expect.arrayContaining(['referenced by multiple downstream consumers']) - }) - ]) - ); -}); - - -test('buildPerfPipelineAnalysis surfaces tax allocation scalar filter candidates for dogfooding', () => { - const workspace = createSqlWorkspace('perf-tax-allocation-analysis', path.join('src', 'sql', 'reports', 'tax_allocation.sql')); - writeFileSync(workspace.sqlFile, TAX_ALLOCATION_QUERY, 'utf8'); - - const analysis = buildPerfPipelineAnalysis(workspace.sqlFile); - - expect(analysis.should_consider_pipeline).toBe(true); - expect(analysis.candidate_ctes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'input_lines' }), - expect.objectContaining({ name: 'floored_allocations' }), - expect.objectContaining({ name: 'ranked_allocations' }) - ]) - ); - expect(analysis.scalar_filter_candidates).toEqual(['allocation_rank']); - expect(analysis.notes).toContain('Optimizer-sensitive scalar predicates detected on columns: allocation_rank'); - - const report: PerfBenchmarkReport = { - schema_version: 1, - command: 'perf run', - query_file: workspace.sqlFile, - query_type: 'SELECT', - params_shape: 'none', - ordered_param_names: [], - source_sql_file: workspace.sqlFile, - source_sql: TAX_ALLOCATION_QUERY, - bound_sql: TAX_ALLOCATION_QUERY, - bindings: undefined, - strategy: 'direct', - requested_mode: 'latency', - selected_mode: 'latency', - selection_reason: 'forced', - classify_threshold_ms: 60000, - timeout_ms: 300000, - dry_run: true, - saved: false, - executed_statements: [], - plan_observations: [], - recommended_actions: [ - { - action: 'consider-pipeline-materialization', - priority: 'medium', - rationale: 'Pipeline candidates detected: input_lines, floored_allocations, ranked_allocations.' - }, - { - action: 'consider-scalar-filter-binding', - priority: 'medium', - rationale: 'Scalar filter candidates detected: allocation_rank.' - } - ], - pipeline_analysis: analysis, - }; - - const text = formatPerfBenchmarkReport(report, 'text'); - expect(text).toContain('scalar_filter_candidates: allocation_rank'); - expect(text).toContain('consider-scalar-filter-binding'); - expect(text).toContain('consider-pipeline-materialization'); - expect(text).toContain('allocation_rank'); -}); -test('diffPerfBenchmarkReports compares saved latency runs by p95', () => { - const workspace = createTempDir('perf-benchmark-diff'); - const baselineDir = path.join(workspace, 'run_001'); - const candidateDir = path.join(workspace, 'run_002'); - mkdirSync(baselineDir, { recursive: true }); - mkdirSync(candidateDir, { recursive: true }); - - const baseline = makePerfReport({ - run_id: 'run_001', - query_file: 'baseline.sql', - source_sql_file: 'baseline.sql', - selected_mode: 'latency', - requested_mode: 'latency', - total_elapsed_ms: 360, - latency_metrics: { - measured_runs: 3, - warmup_runs: 1, - min_ms: 100, - max_ms: 120, - avg_ms: 110, - median_ms: 110, - p95_ms: 120 - }, - executed_statements: [{ seq: 1, role: 'final-query', sql: 'select 1', bindings: undefined, elapsed_ms: 110, plan_summary: { node_type: 'Seq Scan' } }], - plan_observations: ['Seq Scan on public.users'], - recommended_actions: [], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - }, - database_version: '16.2' - }); - - const candidate = makePerfReport({ - ...baseline, - run_id: 'run_002', - database_version: '16.3', - total_elapsed_ms: 240, - latency_metrics: { - measured_runs: 3, - warmup_runs: 1, - min_ms: 70, - max_ms: 90, - avg_ms: 80, - median_ms: 80, - p95_ms: 90 - }, - executed_statements: [{ seq: 1, role: 'final-query', sql: 'select 1', bindings: undefined, elapsed_ms: 80, plan_summary: { node_type: 'Nested Loop', join_type: 'Inner' } }], - plan_observations: ['Inner Nested Loop present in the captured plan'] - }); - - writeFileSync(path.join(baselineDir, 'summary.json'), JSON.stringify(baseline, null, 2), 'utf8'); - writeFileSync(path.join(candidateDir, 'summary.json'), JSON.stringify(candidate, null, 2), 'utf8'); - - const diff = diffPerfBenchmarkReports(baselineDir, candidateDir); - - expect(diff.primary_metric.name).toBe('p95_ms'); - expect(diff.primary_metric.baseline).toBe(120); - expect(diff.primary_metric.candidate).toBe(90); - expect(diff.primary_metric.improvement_percent).toBeCloseTo(25, 3); - expect(diff.plan_deltas).toEqual([ - expect.objectContaining({ statement_id: '1:final-query', changed: true }) - ]); - expect(diff.notes).toContain('Database version changed from 16.2 to 16.3.'); -}); - -test('runPerfBenchmark rejects invalid perf options before touching the sandbox', async () => { - await expect(runPerfBenchmark({ - rootDir: repoRoot, - queryFile: 'missing.sql', - mode: 'latency', - repeat: 0, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - })).rejects.toThrow('invalid perf options: repeat must be a positive integer'); -}); - -test('runPerfBenchmark wraps YAML parse failures with the absolute params path', async () => { - const workspace = createSqlWorkspace('perf-benchmark-yaml-invalid', path.join('src', 'sql', 'reports', 'broken.yml.sql')); - const paramsFile = path.join(workspace.rootDir, 'perf', 'params.yml'); - mkdirSync(path.dirname(paramsFile), { recursive: true }); - writeFileSync(workspace.sqlFile, 'select * from public.sales where status = :status', 'utf8'); - writeFileSync(paramsFile, ['params: [unterminated', ''].join('\n'), 'utf8'); - await expect(runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - paramsFile, - mode: 'latency', - repeat: 1, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - })).rejects.toThrow(`Failed to parse perf params file ${path.resolve(paramsFile)}`); -}); -test('runPerfBenchmark rejects positional params that do not cover the highest placeholder index', async () => { - const workspace = createSqlWorkspace('perf-benchmark-positional-arity', path.join('src', 'sql', 'reports', 'positional.sql')); - const paramsFile = path.join(workspace.rootDir, 'perf', 'params.json'); - mkdirSync(path.dirname(paramsFile), { recursive: true }); - writeFileSync(workspace.sqlFile, 'select * from public.sales where region_id = $3', 'utf8'); - writeFileSync(paramsFile, JSON.stringify([10, 20], null, 2), 'utf8'); - - await expect(runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - paramsFile, - mode: 'latency', - repeat: 1, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - })).rejects.toThrow('Positional SQL placeholders require at least 3 parameters for $3.'); -}); -test('runPerfBenchmark dry-run preserves quoted YAML string params', async () => { - const workspace = createSqlWorkspace('perf-benchmark-yaml-quoted', path.join('src', 'sql', 'reports', 'status.sql')); - const paramsFile = path.join(workspace.rootDir, 'perf', 'params.yml'); - mkdirSync(path.dirname(paramsFile), { recursive: true }); - writeFileSync(workspace.sqlFile, 'select * from public.sales where status = :status', 'utf8'); - writeFileSync(paramsFile, ['params:', ' status: "value # still data"', ''].join('\n'), 'utf8'); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - paramsFile, - mode: 'latency', - repeat: 1, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.bindings).toEqual(['value # still data']); -}); - -test('runPerfBenchmark dry-run in auto mode defers live classification without touching PostgreSQL', async () => { - const workspace = createSqlWorkspace('perf-benchmark-dry-run-auto', path.join('src', 'sql', 'reports', 'auto.sql')); - writeFileSync( - workspace.sqlFile, - 'select * from public.sales where region_id = 1', - 'utf8' - ); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - mode: 'auto', - repeat: 5, - warmup: 1, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.selected_mode).toBe('completion'); - expect(report.selection_reason).toContain('dry-run skips live auto classification'); -}); - - -test('loadPerfBenchmarkReport rejects malformed summary payloads', () => { - const workspace = createTempDir('perf-benchmark-invalid-summary'); - writeFileSync( - path.join(workspace, 'summary.json'), - JSON.stringify({ - schema_version: 1, - command: 'perf run', - query_file: 'broken.sql', - query_type: 'SELECT', - ordered_param_names: [], - source_sql_file: 'broken.sql', - source_sql: 'select 1', - bound_sql: 'select 1', - strategy: 'direct', - requested_mode: 'latency', - selected_mode: 'latency', - selection_reason: 'forced', - classify_threshold_ms: 60000, - timeout_ms: 300000, - dry_run: false, - saved: true, - executed_statements: [{}], - plan_observations: ['ok'], - recommended_actions: [], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - notes: [] - } - }, null, 2), - 'utf8' - ); - - expect(() => loadPerfBenchmarkReport(workspace)).toThrow(`Invalid perf benchmark summary: ${path.join(workspace, 'summary.json')}`); -}); -test('formatPerfDiffReport surfaces structural plan deltas for AI review', () => { - const diff = { - schema_version: 1, - command: 'perf report diff' as const, - baseline_mode: 'latency' as const, - candidate_mode: 'latency' as const, - baseline_strategy: 'direct' as const, - candidate_strategy: 'direct' as const, - primary_metric: { - name: 'p95_ms' as const, - baseline: 120, - candidate: 90, - improvement_percent: 25 - }, - mode_changed: false, - statements_delta: 0, - plan_deltas: [ - { - statement_id: '1:final-query', - baseline_plan: 'Seq Scan', - candidate_plan: 'Inner Nested Loop', - changed: true - } - ], - notes: ['Compared latency-mode p95 because both runs are repeat benchmarks.'] - }; - const text = formatPerfDiffReport(diff, 'text'); - expect(text).toContain('Plan deltas:'); - expect(text).toContain('1:final-query: Seq Scan -> Inner Nested Loop'); - const json = formatPerfDiffReport(diff, 'json'); - expect(JSON.parse(json).plan_deltas).toEqual(diff.plan_deltas); -}); -test('formatPerfBenchmarkReport surfaces recommended actions for AI follow-up', () => { - const report: PerfBenchmarkReport = { - schema_version: 1, - command: 'perf run', - query_file: 'candidate.sql', - query_type: 'SELECT', - params_shape: 'none', - ordered_param_names: [], - source_sql_file: 'candidate.sql', - source_sql: 'select * from users', - bound_sql: 'select * from users', - bindings: undefined, - strategy: 'direct', - requested_mode: 'completion', - selected_mode: 'completion', - selection_reason: 'classification probe exceeded 60000 ms', - classify_threshold_ms: 60000, - timeout_ms: 300000, - dry_run: false, - saved: false, - total_elapsed_ms: 300000, - completion_metrics: { - completed: false, - timed_out: true, - wall_time_ms: 300000 - }, - classification_probe: { - elapsed_ms: 60000, - timed_out: true - }, - executed_statements: [ - { - seq: 1, - role: 'final-query', - sql: 'select * from users', - bindings: undefined, - elapsed_ms: 300000, - timed_out: true, - sql_file: 'executed-sql/001-final-query.bound.sql', - resolved_sql_preview_file: 'executed-sql/001-final-query.resolved-preview.sql' - } - ], - plan_summary: { - node_type: 'Seq Scan' - }, - plan_observations: ['Seq Scan on users'], - recommended_actions: [ - { - action: 'stabilize-completion-run', - priority: 'high', - rationale: 'timeout first' - }, - { - action: 'review-index-coverage', - priority: 'medium', - rationale: 'seq scan present' - } - ], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - } - }; - - const text = formatPerfBenchmarkReport(report, 'text'); - expect(text).toContain('Classification probe: 60000.00 ms (timed out)'); - expect(text).toContain('Recommended actions:'); - expect(text).toContain('[high] stabilize-completion-run: timeout first'); - expect(text).toContain('Plan observations:'); - expect(text).toContain('Seq Scan on users'); - expect(text).toContain('sql_file: executed-sql/001-final-query.bound.sql'); - expect(text).toContain('resolved_sql_preview_file: executed-sql/001-final-query.resolved-preview.sql'); -}); - -test('formatPerfBenchmarkReport recommends join review for nested loop plans', () => { - const report: PerfBenchmarkReport = { - schema_version: 1, - command: 'perf run', - query_file: 'nested-loop.sql', - query_type: 'SELECT', - params_shape: 'none', - ordered_param_names: [], - source_sql_file: 'nested-loop.sql', - source_sql: 'select 1', - bound_sql: 'select 1', - bindings: undefined, - strategy: 'direct', - requested_mode: 'latency', - selected_mode: 'latency', - selection_reason: 'forced', - classify_threshold_ms: 60000, - timeout_ms: 300000, - dry_run: false, - saved: false, - total_elapsed_ms: 10, - latency_metrics: { - measured_runs: 3, - warmup_runs: 1, - min_ms: 3, - max_ms: 4, - avg_ms: 3.5, - median_ms: 3.5, - p95_ms: 4 - }, - executed_statements: [ - { - seq: 1, - role: 'final-query', - sql: 'select 1', - bindings: undefined, - elapsed_ms: 4, - plan_summary: { node_type: 'Nested Loop', join_type: 'Inner' } - } - ], - plan_summary: { - node_type: 'Nested Loop', - join_type: 'Inner' - }, - plan_observations: ['Inner Nested Loop present in the captured plan'], - recommended_actions: [ - { - action: 'inspect-join-strategy', - priority: 'medium', - rationale: 'The captured plan includes a join operator, so rewriting join shape or supporting it with indexes may help.' - } - ], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - } - }; - const text = formatPerfBenchmarkReport(report, 'text'); - expect(text).toContain('inspect-join-strategy'); - expect(text).toContain('Inner Nested Loop present in the captured plan'); -}); - - - - -test('runPerfBenchmark dry-run exposes decomposed multi-statement evidence and strategy metadata', async () => { - const workspace = createSqlWorkspace('perf-benchmark-decomposed', path.join('src', 'sql', 'reports', 'decomposed.sql')); - writeFileSync( - workspace.sqlFile, - ` - with base_sales as ( - select id, region_id from public.sales - ), - filtered_sales as ( - select id from base_sales where region_id = 10 - ), - final_sales as ( - select id from filtered_sales - ) - select * from final_sales - `, - 'utf8' - ); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - strategy: 'decomposed', - material: ['base_sales', 'filtered_sales'], - mode: 'latency', - repeat: 2, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.strategy).toBe('decomposed'); - expect(report.strategy_metadata).toMatchObject({ - materialized_ctes: ['base_sales', 'filtered_sales'], - planned_steps: [ - expect.objectContaining({ step: 1, kind: 'materialize', target: 'base_sales' }), - expect.objectContaining({ step: 2, kind: 'materialize', target: 'filtered_sales' }), - expect.objectContaining({ step: 3, kind: 'final-query', target: 'FINAL_QUERY' }), - ] - }); - expect(report.executed_statements).toEqual([ - expect.objectContaining({ seq: 1, role: 'materialize', target: 'base_sales' }), - expect.objectContaining({ seq: 2, role: 'materialize', target: 'filtered_sales' }), - expect.objectContaining({ seq: 3, role: 'final-query', target: 'FINAL_QUERY' }), - ]); -}); - -test('loadPerfBenchmarkReport accepts decomposed summaries with strategy metadata', () => { - const workspace = createTempDir('perf-benchmark-decomposed-summary'); - const summary = makePerfReport({ - run_id: 'run_100', - strategy: 'decomposed', - strategy_metadata: { - materialized_ctes: ['base_sales'], - scalar_filter_columns: [], - planned_steps: [ - { step: 1, kind: 'materialize', target: 'base_sales', depends_on: [] }, - { step: 2, kind: 'final-query', target: 'FINAL_QUERY', depends_on: ['base_sales'] }, - ] - }, - total_elapsed_ms: 400, - completion_metrics: { - completed: true, - timed_out: false, - wall_time_ms: 400 - }, - executed_statements: [ - { seq: 1, role: 'materialize', target: 'base_sales', sql: 'create temp table "base_sales" as select 1', bindings: undefined, elapsed_ms: 120 }, - { seq: 2, role: 'final-query', target: 'FINAL_QUERY', sql: 'select * from "base_sales"', bindings: undefined, elapsed_ms: 280 }, - ], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 1, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - } - }); - - writeFileSync(path.join(workspace, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8'); - const loaded = loadPerfBenchmarkReport(workspace); - expect(loaded.strategy).toBe('decomposed'); - expect(loaded.strategy_metadata?.planned_steps).toHaveLength(2); - expect(loaded.executed_statements[0]).toMatchObject({ role: 'materialize', target: 'base_sales' }); -}); - -test('loadPerfBenchmarkReport accepts legacy summaries without scalar filter candidates', () => { - const workspace = createTempDir('perf-benchmark-legacy-summary'); - const summary = makePerfReport({ - run_id: 'run_legacy', - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 1, - should_consider_pipeline: true, - candidate_ctes: [ - { - name: 'base_sales', - downstream_references: 2, - reasons: ['referenced by multiple downstream consumers'] - } - ], - notes: ['legacy summary fixture'] - } as PerfBenchmarkReport['pipeline_analysis'] - }); - - writeFileSync(path.join(workspace, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8'); - - const loaded = loadPerfBenchmarkReport(workspace); - - expect(loaded.run_id).toBe('run_legacy'); - expect(loaded.pipeline_analysis.candidate_ctes).toHaveLength(1); - expect(loaded.pipeline_analysis.notes).toEqual(['legacy summary fixture']); -}); - -test('diffPerfBenchmarkReports emits statement deltas for decomposed multi-statement runs', () => { - const workspace = createTempDir('perf-benchmark-decomposed-diff'); - const baselineDir = path.join(workspace, 'run_001'); - const candidateDir = path.join(workspace, 'run_002'); - mkdirSync(baselineDir, { recursive: true }); - mkdirSync(candidateDir, { recursive: true }); - - const baseline = makePerfReport({ - run_id: 'run_001', - query_file: 'baseline.sql', - source_sql_file: 'baseline.sql', - strategy: 'decomposed', - strategy_metadata: { - materialized_ctes: ['base_sales'], - scalar_filter_columns: [], - planned_steps: [ - { step: 1, kind: 'materialize', target: 'base_sales', depends_on: [] }, - { step: 2, kind: 'final-query', target: 'FINAL_QUERY', depends_on: ['base_sales'] }, - ] - }, - total_elapsed_ms: 500, - completion_metrics: { completed: true, timed_out: false, wall_time_ms: 500 }, - executed_statements: [ - { seq: 1, role: 'materialize', target: 'base_sales', sql: 'create temp table "base_sales" as select 1', bindings: undefined, elapsed_ms: 220 }, - { seq: 2, role: 'final-query', target: 'FINAL_QUERY', sql: 'select * from "base_sales"', bindings: undefined, elapsed_ms: 280, plan_summary: { node_type: 'Seq Scan' } }, - ], - plan_observations: ['Seq Scan on base_sales'], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 1, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - } - }); - - const candidate = { - ...baseline, - run_id: 'run_002', - total_elapsed_ms: 410, - completion_metrics: { completed: true, timed_out: false, wall_time_ms: 410 }, - executed_statements: [ - { seq: 1, role: 'materialize', target: 'base_sales', sql: 'create temp table "base_sales" as select 1', bindings: undefined, elapsed_ms: 150 }, - { seq: 2, role: 'final-query', target: 'FINAL_QUERY', sql: 'select * from "base_sales"', bindings: undefined, elapsed_ms: 260, plan_summary: { node_type: 'Index Scan' } }, - ], - plan_observations: ['Index Scan on base_sales'], - }; - - writeFileSync(path.join(baselineDir, 'summary.json'), JSON.stringify(baseline, null, 2), 'utf8'); - writeFileSync(path.join(candidateDir, 'summary.json'), JSON.stringify(candidate, null, 2), 'utf8'); - - const diff = diffPerfBenchmarkReports(baselineDir, candidateDir); - expect(diff.statement_deltas).toEqual(expect.arrayContaining([ - expect.objectContaining({ statement_id: '1:materialize:base_sales', elapsed_delta_ms: -70 }), - expect.objectContaining({ statement_id: '2:final-query:FINAL_QUERY', elapsed_delta_ms: -20 }), - ])); - const text = formatPerfDiffReport(diff, 'text'); - expect(text).toContain('Statement deltas:'); - expect(text).toContain('1:materialize:base_sales'); -}); - -test('diffPerfBenchmarkReports aligns final-query deltas across direct and decomposed runs', () => { - const workspace = createTempDir('perf-benchmark-strategy-diff'); - const baselineDir = path.join(workspace, 'run_001'); - const candidateDir = path.join(workspace, 'run_002'); - mkdirSync(baselineDir, { recursive: true }); - mkdirSync(candidateDir, { recursive: true }); - - const baseline = makePerfReport({ - run_id: 'run_001', - query_file: 'baseline.sql', - source_sql_file: 'baseline.sql', - strategy: 'direct', - total_elapsed_ms: 500, - completion_metrics: { completed: true, timed_out: false, wall_time_ms: 500 }, - executed_statements: [ - { seq: 1, role: 'final-query', target: 'FINAL_QUERY', sql: 'select * from base_sales', bindings: undefined, elapsed_ms: 500, plan_summary: { node_type: 'Nested Loop' } }, - ], - plan_observations: ['Nested Loop on base_sales'], - pipeline_analysis: { - query_type: 'SELECT', - cte_count: 1, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - } - }); - - const candidate = makePerfReport({ - ...baseline, - run_id: 'run_002', - strategy: 'decomposed', - strategy_metadata: { - materialized_ctes: ['base_sales'], - scalar_filter_columns: [], - planned_steps: [ - { step: 1, kind: 'materialize', target: 'base_sales', depends_on: [] }, - { step: 2, kind: 'final-query', target: 'FINAL_QUERY', depends_on: ['base_sales'] }, - ] - }, - total_elapsed_ms: 410, - completion_metrics: { completed: true, timed_out: false, wall_time_ms: 410 }, - executed_statements: [ - { seq: 1, role: 'materialize', target: 'base_sales', sql: 'create temp table "base_sales" as select 1', bindings: undefined, elapsed_ms: 150, plan_summary: { node_type: 'Seq Scan' } }, - { seq: 2, role: 'final-query', target: 'FINAL_QUERY', sql: 'select * from "base_sales"', bindings: undefined, elapsed_ms: 260, plan_summary: { node_type: 'Index Scan' } }, - ], - plan_observations: ['Seq Scan on base_sales', 'Index Scan on base_sales'], - }); - - writeFileSync(path.join(baselineDir, 'summary.json'), JSON.stringify(baseline, null, 2), 'utf8'); - writeFileSync(path.join(candidateDir, 'summary.json'), JSON.stringify(candidate, null, 2), 'utf8'); - - const diff = diffPerfBenchmarkReports(baselineDir, candidateDir); - expect(diff.statement_deltas).toEqual(expect.arrayContaining([ - expect.objectContaining({ statement_id: '2:final-query:FINAL_QUERY', baseline_elapsed_ms: 500, candidate_elapsed_ms: 260, elapsed_delta_ms: -240 }), - expect.objectContaining({ statement_id: '1:materialize:base_sales', baseline_elapsed_ms: undefined, candidate_elapsed_ms: 150 }), - ])); - expect(diff.plan_deltas).toEqual(expect.arrayContaining([ - expect.objectContaining({ statement_id: '2:final-query:FINAL_QUERY', changed: true }), - expect.objectContaining({ statement_id: '1:materialize:base_sales', baseline_plan: '(missing statement)' }), - ])); -}); - - -test('mapPipelineStatements keeps materialize roles for captured timeout steps before the final query', () => { - const mapped = mapPipelineStatements( - [ - { - sql: 'create temp table "base_sales" as select 1', - bindings: undefined, - elapsedMs: 300000, - timedOut: true, - } - ], - toPerfPlannedSteps([ - { kind: 'materialize', target: 'base_sales' } - ]) - ); - - expect(mapped).toEqual([ - expect.objectContaining({ - role: 'materialize', - target: 'base_sales', - timedOut: true, - }) - ]); -}); - -test('toPerfPlannedSteps drops scalar-filter pseudo-steps before mapping executed statements', () => { - const mapped = mapPipelineStatements( - [ - { - sql: 'create temp table "base_sales" as select 1', - bindings: undefined, - elapsedMs: 20, - timedOut: false, - }, - { - sql: 'select * from "base_sales" where id = $1', - bindings: [1], - elapsedMs: 30, - timedOut: false, - } - ], - toPerfPlannedSteps([ - { kind: 'scalar-filter-bind', target: 'SCALAR_FILTER' }, - { kind: 'materialize', target: 'base_sales' }, - { kind: 'final-query', target: 'FINAL_QUERY' } - ]) - ); - - expect(mapped).toEqual([ - expect.objectContaining({ role: 'materialize', target: 'base_sales' }), - expect.objectContaining({ role: 'final-query', target: 'FINAL_QUERY' }) - ]); -}); - -test('runPerfBenchmark dry-run discovers QuerySpec perf guidance from sql-root relative sqlFile', async () => { - const workspace = createSqlWorkspace('perf-benchmark-query-spec', path.join('src', 'sql', 'reports', 'sales.sql')); - const specFile = path.join(workspace.rootDir, 'src', 'catalog', 'specs', 'sales.spec.ts'); - mkdirSync(path.dirname(specFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - ` - select id - from public.sales - `, - 'utf8' - ); - writeFileSync( - specFile, - ` - export const salesSpec = { - id: 'reports.sales', - sqlFile: 'reports/sales.sql', - params: { shape: 'named', example: {} }, - output: { example: { id: 1 } }, - metadata: { - perf: { - expectedScale: 'large', - expectedInputRows: 50000, - expectedOutputRows: 200 - } - } - }; - `, - 'utf8' - ); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - strategy: 'direct', - material: [], - mode: 'completion', - repeat: 3, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.spec_guidance).toMatchObject({ - spec_id: 'reports.sales', - expected_scale: 'large', - expected_input_rows: 50000, - expected_output_rows: 200, - review_policy: 'strongly-recommended', - evidence_status: 'missing', - }); - expect(report.recommended_actions).toEqual(expect.arrayContaining([ - expect.objectContaining({ action: 'capture-perf-evidence', priority: 'high' }) - ])); - - const text = formatPerfBenchmarkReport(report, 'text'); - expect(text).toContain('Query spec guidance:'); - expect(text).toContain('expected_scale: large'); - expect(text).toContain('evidence_status: missing'); -}); - -test('runPerfBenchmark dry-run warns when perf seed rows undershoot QuerySpec expected input rows', async () => { - const workspace = createSqlWorkspace('perf-benchmark-query-spec-seed', path.join('src', 'sql', 'reports', 'orders.sql')); - const specFile = path.join(workspace.rootDir, 'src', 'catalog', 'specs', 'orders.spec.ts'); - const seedFile = path.join(workspace.rootDir, 'perf', 'seed.yml'); - mkdirSync(path.dirname(specFile), { recursive: true }); - mkdirSync(path.dirname(seedFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - ` - select id - from public.orders - `, - 'utf8' - ); - writeFileSync( - specFile, - ` - export const ordersSpec = { - id: 'reports.orders', - sqlFile: 'src/sql/reports/orders.sql', - params: { shape: 'named', example: {} }, - output: { example: { id: 1 } }, - metadata: { - perf: { - expectedScale: 'medium', - expectedInputRows: 1000 - } - } - }; - `, - 'utf8' - ); - writeFileSync( - seedFile, - [ - 'seed: 123', - 'tables:', - ' orders:', - ' rows: 12', - 'columns: {}', - '' - ].join('\n'), - 'utf8' - ); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - strategy: 'direct', - material: [], - mode: 'completion', - repeat: 3, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.spec_guidance).toMatchObject({ - spec_id: 'reports.orders', - expected_scale: 'medium', - expected_input_rows: 1000, - review_policy: 'recommended', - fixture_rows_available: 12, - fixture_rows_status: 'undersized', - }); - expect(report.recommended_actions).toEqual(expect.arrayContaining([ - expect.objectContaining({ action: 'increase-perf-fixture-scale' }) - ])); -}); - -test('runPerfBenchmark dry-run with save still requests missing perf evidence', async () => { - const workspace = createSqlWorkspace('perf-benchmark-query-spec-dry-save', path.join('src', 'sql', 'reports', 'sales.sql')); - const specFile = path.join(workspace.rootDir, 'src', 'catalog', 'specs', 'sales.spec.ts'); - mkdirSync(path.dirname(specFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - ` - select id - from public.sales - `, - 'utf8' - ); - writeFileSync( - specFile, - ` - export const salesSpec = { - id: 'reports.sales', - sqlFile: 'reports/sales.sql', - params: { shape: 'named', example: {} }, - output: { example: { id: 1 } }, - metadata: { - perf: { - expectedScale: 'medium', - expectedInputRows: 100000 - } - } - }; - `, - 'utf8' - ); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - strategy: 'direct', - material: [], - mode: 'completion', - repeat: 3, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: true, - dryRun: true, - }); - - expect(report.saved).toBe(false); - expect(report.spec_guidance).toMatchObject({ - expected_scale: 'medium', - expected_input_rows: 100000, - review_policy: 'strongly-recommended', - evidence_status: 'missing', - }); - expect(report.recommended_actions).toEqual(expect.arrayContaining([ - expect.objectContaining({ action: 'capture-perf-evidence', priority: 'high' }) - ])); -}); - -test('runPerfBenchmark dry-run ignores unrelated perf seed tables when checking fixture scale', async () => { - const workspace = createSqlWorkspace('perf-benchmark-query-spec-related-seed', path.join('src', 'sql', 'reports', 'orders.sql')); - const specFile = path.join(workspace.rootDir, 'src', 'catalog', 'specs', 'orders.spec.ts'); - const seedFile = path.join(workspace.rootDir, 'perf', 'seed.yml'); - mkdirSync(path.dirname(specFile), { recursive: true }); - mkdirSync(path.dirname(seedFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - ` - select id - from public.orders - `, - 'utf8' - ); - writeFileSync( - specFile, - ` - export const ordersSpec = { - id: 'reports.orders', - sqlFile: 'reports/orders.sql', - params: { shape: 'named', example: {} }, - output: { example: { id: 1 } }, - metadata: { - perf: { - expectedScale: 'medium', - expectedInputRows: 1000 - } - } - }; - `, - 'utf8' - ); - writeFileSync( - seedFile, - [ - 'seed: 123', - 'tables:', - ' orders:', - ' rows: 12', - ' users:', - ' rows: 5000', - 'columns: {}', - '' - ].join('\n'), - 'utf8' - ); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - strategy: 'direct', - material: [], - mode: 'completion', - repeat: 3, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.spec_guidance).toMatchObject({ - fixture_rows_available: 12, - fixture_rows_status: 'undersized', - }); -}); - -test('runPerfBenchmark dry-run does not match QuerySpec guidance by basename only', async () => { - const workspace = createSqlWorkspace('perf-benchmark-query-spec-basename', path.join('src', 'sql', 'admin', 'sales.sql')); - const specFile = path.join(workspace.rootDir, 'src', 'catalog', 'specs', 'sales.spec.ts'); - mkdirSync(path.dirname(specFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - ` - select id - from public.sales - `, - 'utf8' - ); - writeFileSync( - specFile, - ` - export const salesSpec = { - id: 'reports.sales', - sqlFile: 'reports/sales.sql', - params: { shape: 'named', example: {} }, - output: { example: { id: 1 } }, - metadata: { - perf: { - expectedScale: 'large' - } - } - }; - `, - 'utf8' - ); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - strategy: 'direct', - material: [], - mode: 'completion', - repeat: 3, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.spec_guidance).toBeUndefined(); -}); - -test('runPerfBenchmark dry-run rejects ambiguous QuerySpec perf guidance matches', async () => { - const workspace = createSqlWorkspace('perf-benchmark-query-spec-ambiguous', path.join('src', 'sql', 'reports', 'sales.sql')); - const firstSpecFile = path.join(workspace.rootDir, 'src', 'catalog', 'specs', 'sales-a.spec.ts'); - const secondSpecFile = path.join(workspace.rootDir, 'src', 'catalog', 'specs', 'sales-b.spec.ts'); - mkdirSync(path.dirname(firstSpecFile), { recursive: true }); - writeFileSync( - workspace.sqlFile, - ` - select id - from public.sales - `, - 'utf8' - ); - writeFileSync( - firstSpecFile, - ` - export const salesSpecA = { - id: 'reports.sales.a', - sqlFile: 'reports/sales.sql', - params: { shape: 'named', example: {} }, - output: { example: { id: 1 } }, - metadata: { - perf: { - expectedScale: 'medium' - } - } - }; - `, - 'utf8' - ); - writeFileSync( - secondSpecFile, - ` - export const salesSpecB = { - id: 'reports.sales.b', - sqlFile: 'src/sql/reports/sales.sql', - params: { shape: 'named', example: {} }, - output: { example: { id: 1 } }, - metadata: { - perf: { - expectedScale: 'large' - } - } - }; - `, - 'utf8' - ); - - await expect(runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - strategy: 'direct', - material: [], - mode: 'completion', - repeat: 3, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - })).rejects.toThrow(/Multiple QuerySpecs matched/); -}); - -test('runPerfBenchmark dry-run reports ddl inventory and pipeline-first tuning guidance for scale dogfooding', async () => { - const workspace = createSqlWorkspace('perf-benchmark-scale-dogfood', path.join('src', 'sql', 'reports', 'sales_pipeline.sql')); - const specFile = path.join(workspace.rootDir, 'src', 'catalog', 'specs', 'sales-pipeline.spec.ts'); - const ddlFile = path.join(workspace.rootDir, 'db', 'ddl', 'public.sql'); - mkdirSync(path.dirname(specFile), { recursive: true }); - mkdirSync(path.dirname(ddlFile), { recursive: true }); - writeFileSync(path.join(workspace.rootDir, 'ztd.config.json'), JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), 'utf8'); - writeFileSync(ddlFile, [ - 'create table public.sales (id integer primary key, region_id integer not null, closed_month date not null);', - 'create index sales_region_closed_month_idx on public.sales(region_id, closed_month);', - '' - ].join('\n'), 'utf8'); - writeFileSync(workspace.sqlFile, [ - 'with base_sales as (', - ' select id, region_id, closed_month', - ' from public.sales', - '),', - 'regional_sales as (', - ' select id, region_id from base_sales', - '),', - 'month_sales as (', - ' select id, closed_month from base_sales', - ')', - 'select rs.id', - 'from regional_sales rs', - 'join month_sales ms on ms.id = rs.id', - '' - ].join('\n'), 'utf8'); - writeFileSync(specFile, [ - 'export const salesPipelineSpec = {', - " id: 'reports.sales-pipeline',", - " sqlFile: 'reports/sales_pipeline.sql',", - " params: { shape: 'named', example: {} },", - " output: { example: { id: 1 } },", - ' metadata: {', - ' perf: {', - " expectedScale: 'large',", - ' expectedInputRows: 50000,', - ' expectedOutputRows: 500', - ' }', - ' }', - '};', - '' - ].join('\n'), 'utf8'); - - const report = await runPerfBenchmark({ - rootDir: workspace.rootDir, - queryFile: workspace.sqlFile, - strategy: 'direct', - material: [], - mode: 'completion', - repeat: 1, - warmup: 0, - classifyThresholdSeconds: 60, - timeoutMinutes: 5, - save: false, - dryRun: true, - }); - - expect(report.ddl_inventory).toMatchObject({ - ddl_files: 1, - ddl_statement_count: 2, - table_count: 1, - index_count: 1, - index_names: ['sales_region_closed_month_idx'] - }); - expect(report.tuning_guidance).toMatchObject({ - primary_path: 'pipeline', - requires_captured_plan: true, - pipeline_branch: expect.objectContaining({ recommended: true }), - index_branch: expect.objectContaining({ recommended: false }) - }); - expect(report.tuning_summary).toMatchObject({ - headline: 'Start with pipeline tuning.' - }); - - const textReport = formatPerfBenchmarkReport(report, 'text'); - const jsonReport = JSON.parse(formatPerfBenchmarkReport(report, 'json')) as PerfBenchmarkReport; - expect(textReport).toContain('Decision summary: Start with pipeline tuning.'); - expect(textReport).toContain('DDL inventory:'); - expect(textReport).toContain('index_count: 1'); - expect(textReport).toContain('Tuning guidance:'); - expect(jsonReport.tuning_summary?.headline).toBe('Start with pipeline tuning.'); - expect(textReport).toContain('primary_path: pipeline'); - expect(textReport).toContain('Run `ztd perf db reset` so the perf sandbox recreates both tables and indexes from local DDL.'); -}); - -test('buildPerfTuningGuidance prefers index remediation when the captured plan shows a sequential scan without pipeline signals', () => { - const guidance = buildPerfTuningGuidance({ - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - }, { - observations: ['Seq Scan on public.sales'], - statement_summary: 'Seq Scan', - hasCapturedPlan: true, - hasSequentialScan: true, - hasJoin: false - }, { - spec_id: 'reports.sales', - spec_file: 'src/catalog/specs/sales.spec.ts', - expected_scale: 'large', - review_policy: 'strongly-recommended', - evidence_status: 'missing', - fixture_rows_status: 'unknown' - }); - - expect(guidance.primary_path).toBe('index'); - expect(guidance.index_branch.recommended).toBe(true); - expect(guidance.pipeline_branch.recommended).toBe(false); - expect(guidance.index_branch.next_steps).toContain('Append CREATE INDEX statements to db/ddl/*.sql instead of making ad-hoc sandbox-only changes.'); - expect(buildPerfTuningSummary(guidance)).toMatchObject({ - headline: 'Start with index tuning.' - }); -}); -test('buildPerfTuningGuidance does not require another capture when a non-signal plan already exists', () => { - const guidance = buildPerfTuningGuidance({ - query_type: 'SELECT', - cte_count: 0, - should_consider_pipeline: false, - candidate_ctes: [], - scalar_filter_candidates: [], - notes: [] - }, { - observations: [], - statement_summary: 'Index Only Scan on public.sales', - hasCapturedPlan: true, - hasSequentialScan: false, - hasJoin: false - }); - - expect(guidance.primary_path).toBe('capture-plan'); - expect(guidance.requires_captured_plan).toBe(false); - expect(buildPerfTuningSummary(guidance)).toMatchObject({ - headline: 'A representative plan is already available; compare index and pipeline evidence next.', - evidence: ['A captured plan exists, but it does not yet isolate scans, joins, or pipeline hotspots.'], - next_step: 'Capture or review EXPLAIN (ANALYZE, BUFFERS) before changing the physical design.' - }); -}); - -test('summarizePerfDdlInventory keeps index counts visible in saved perf guidance', () => { - const summary = summarizePerfDdlInventory({ - files: ['db/ddl/public.sql'], - statements: [], - ddlStatementCount: 4, - tableCount: 2, - indexCount: 2, - indexNames: ['sales_region_idx', 'sales_closed_month_idx'] - }); - - expect(summary).toEqual({ - ddl_files: 1, - ddl_statement_count: 4, - table_count: 2, - index_count: 2, - index_names: ['sales_region_idx', 'sales_closed_month_idx'] - }); -}); diff --git a/packages/ztd-cli/tests/perfSandbox.unit.test.ts b/packages/ztd-cli/tests/perfSandbox.unit.test.ts deleted file mode 100644 index 0dbc1cfc0..000000000 --- a/packages/ztd-cli/tests/perfSandbox.unit.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { - buildInsertStatementsForTable, - buildPerfInitPlan, - inspectPerfDdlInventory, - parsePerfSeedYaml, - resolvePerfExternalDatabaseUrl, - type PerfSeedConfig -} from '../src/perf/sandbox'; -import type { TableDefinitionModel } from 'rawsql-ts'; - -const usersDefinition: TableDefinitionModel = { - name: 'public.users', - columns: [ - { name: 'id', typeName: 'integer', isNotNull: true, required: true }, - { name: 'status', typeName: 'text', isNotNull: true }, - { name: 'score', typeName: 'numeric', isNotNull: false } - ] -}; - -const auditDefinition: TableDefinitionModel = { - name: 'public.audit_events', - columns: [ - { name: 'id', typeName: 'integer', isNotNull: true, required: true, defaultValue: 'generated by default as identity' } - ] -}; - -test('buildPerfInitPlan scaffolds the expected perf sandbox files', () => { - const plan = buildPerfInitPlan('C:/workspace/example'); - const relativeFiles = plan.files.map((file) => file.path.replace(/\\/g, '/')).map((filePath) => filePath.slice(filePath.indexOf('/perf/'))).map((filePath) => filePath.slice(1)).sort(); - - expect(relativeFiles).toEqual([ - 'perf/.gitignore', - 'perf/README.md', - 'perf/docker-compose.yml', - 'perf/params.yml', - 'perf/sandbox.json', - 'perf/seed.yml' - ]); -}); - -test('parsePerfSeedYaml reads deterministic row counts and column overrides', () => { - const config = parsePerfSeedYaml([ - 'seed: 123', - 'tables:', - ' users:', - ' rows: 25', - 'columns:', - ' public.users.status:', - ' values: [active, inactive]', - ' skew: 0.9', - '' - ].join('\n')); - - expect(config).toEqual({ - seed: 123, - tables: { - users: { rows: 25 } - }, - columns: { - 'public.users.status': { - values: ['active', 'inactive'], - skew: 0.9 - } - } - }); -}); - -test('buildInsertStatementsForTable stays deterministic for a fixed seed config', () => { - const seedConfig: PerfSeedConfig = { - seed: 496, - tables: { users: { rows: 2 } }, - columns: { - 'public.users.status': { - values: ['active', 'inactive'], - skew: 0.75 - } - } - }; - - const firstRun = buildInsertStatementsForTable(usersDefinition, 2, seedConfig); - const secondRun = buildInsertStatementsForTable(usersDefinition, 2, seedConfig); - - expect(firstRun).toEqual(secondRun); - expect(firstRun[0]?.sql).toContain('INSERT INTO "public"."users"'); - expect(firstRun[0]?.values[1]).toBe('active'); - expect(firstRun[1]?.values[0]).toBe(2); -}); - -test('buildInsertStatementsForTable uses DEFAULT VALUES when every column has a default', () => { - const seedConfig: PerfSeedConfig = { - seed: 496, - tables: { audit_events: { rows: 2 } }, - columns: {} - }; - - const statements = buildInsertStatementsForTable(auditDefinition, 2, seedConfig); - - expect(statements).toEqual([ - { sql: 'INSERT INTO "public"."audit_events" DEFAULT VALUES;', values: [] }, - { sql: 'INSERT INTO "public"."audit_events" DEFAULT VALUES;', values: [] } - ]); -}); - -test('resolvePerfExternalDatabaseUrl only honors the explicit perf variable', () => { - expect(resolvePerfExternalDatabaseUrl({ - ZTD_DB_URL: 'postgres://perf.example/db', - DATABASE_URL: 'postgres://app.example/db' - })).toBe('postgres://perf.example/db'); - - expect(resolvePerfExternalDatabaseUrl({ - DATABASE_URL: 'postgres://app.example/db' - })).toBeNull(); -}); - -test('inspectPerfDdlInventory counts CREATE INDEX statements so perf reset can recreate them', () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'perf-ddl-')); - const ddlDir = path.join(rootDir, 'db', 'ddl'); - if (!existsSync(ddlDir)) { - mkdirSync(ddlDir, { recursive: true }); - } - - writeFileSync(path.join(rootDir, 'ztd.config.json'), JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), 'utf8'); - writeFileSync(path.join(ddlDir, 'public.sql'), [ - 'create table public.users (id integer primary key, email text not null);', - 'create index users_email_idx on public.users(email);', - '' - ].join('\n'), 'utf8'); - - const inventory = inspectPerfDdlInventory(rootDir); - - expect(inventory.ddlStatementCount).toBe(2); - expect(inventory.tableCount).toBe(1); - expect(inventory.indexCount).toBe(1); - expect(inventory.indexNames).toEqual(['users_email_idx']); - expect(inventory.statements.map((statement) => statement.kind)).toEqual(['table', 'index']); -}); - -test('inspectPerfDdlInventory ignores comment-only DDL placeholders without parser failures', () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'perf-ddl-comments-')); - const ddlDir = path.join(rootDir, 'db', 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - - writeFileSync(path.join(rootDir, 'ztd.config.json'), JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), 'utf8'); - writeFileSync(path.join(ddlDir, 'public.sql'), [ - '-- DDL for schema "public".', - '-- Add CREATE TABLE statements here.', - '' - ].join('\n'), 'utf8'); - - const inventory = inspectPerfDdlInventory(rootDir); - - expect(inventory.files).toHaveLength(1); - expect(inventory.ddlStatementCount).toBe(0); - expect(inventory.statements).toEqual([]); -}); - -test('inspectPerfDdlInventory fails fast when the configured DDL directory does not exist', () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'perf-ddl-missing-')); - - writeFileSync(path.join(rootDir, 'ztd.config.json'), JSON.stringify({ - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, null, 2), 'utf8'); - - expect(() => inspectPerfDdlInventory(rootDir, { requireExistingDdlDir: true })).toThrow(/Perf DDL directory does not exist:/); -}); diff --git a/packages/ztd-cli/tests/pgDump.unit.test.ts b/packages/ztd-cli/tests/pgDump.unit.test.ts deleted file mode 100644 index 08cadf8a0..000000000 --- a/packages/ztd-cli/tests/pgDump.unit.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; - -const spawnSyncMock = vi.hoisted(() => vi.fn()); - -vi.mock('node:child_process', () => ({ - spawnSync: spawnSyncMock, -})); - -afterEach(() => { - spawnSyncMock.mockReset(); -}); - -describe('runPgDump', () => { - test('runs pg_dump through a shell when pgDumpShell is enabled', async () => { - spawnSyncMock.mockReturnValue({ - status: 0, - stdout: 'CREATE TABLE public.accounts (id bigint primary key);', - stderr: '', - }); - - const { runPgDump } = await import('../src/utils/pgDump'); - const result = runPgDump({ - url: 'postgres://postgres:postgres@localhost:5432/taskdogfood', - pgDumpPath: 'docker exec ztd-webapi-lifecycle-pg pg_dump', - pgDumpShell: true, - }); - - expect(result).toContain('CREATE TABLE public.accounts'); - expect(spawnSyncMock).toHaveBeenCalledWith( - 'docker exec ztd-webapi-lifecycle-pg pg_dump', - ['--schema-only', '--no-owner', '--no-privileges', '--dbname', 'postgres://postgres:postgres@localhost:5432/taskdogfood'], - expect.objectContaining({ - encoding: 'utf8', - shell: true, - }), - ); - }); - - test('includes shell-mode context when the pg_dump command fails to launch', async () => { - spawnSyncMock.mockReturnValue({ - error: Object.assign(new Error('EINVAL'), { code: 'EINVAL' }), - status: null, - stdout: '', - stderr: '', - }); - - const { runPgDump } = await import('../src/utils/pgDump'); - - expect(() => - runPgDump({ - url: 'postgres://postgres:postgres@localhost:5432/taskdogfood', - pgDumpPath: 'docker exec broken-container pg_dump', - pgDumpShell: true, - }), - ).toThrow('shell mode enabled'); - }); -}); diff --git a/packages/ztd-cli/tests/postgresTestkitHelper.unit.test.ts b/packages/ztd-cli/tests/postgresTestkitHelper.unit.test.ts deleted file mode 100644 index e84c420b7..000000000 --- a/packages/ztd-cli/tests/postgresTestkitHelper.unit.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; - -import { - createStarterPostgresTestkitClient, - loadStarterPostgresDefaults -} from '../templates/tests/support/postgres-testkit'; - -const tempDirs: string[] = []; - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - vi.restoreAllMocks(); -}); - -test('loadStarterPostgresDefaults reads top-level starter defaults and falls back to public', () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-starter-defaults-')); - tempDirs.push(rootDir); - - writeFileSync( - path.join(rootDir, 'ztd.config.json'), - JSON.stringify( - { - ztdRootDir: '.', - defaultSchema: ' app ', - searchPath: [' app ', '', ' public '] - }, - null, - 2 - ), - 'utf8' - ); - - expect(loadStarterPostgresDefaults(rootDir)).toEqual({ - projectRootDir: rootDir, - ztdRootDir: rootDir, - defaultSchema: 'app', - searchPath: ['app', 'public'], - ddlDirectories: [] - }); -}); - -test('loadStarterPostgresDefaults throws when ztd.config.json is malformed', () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-starter-config-error-')); - tempDirs.push(rootDir); - - writeFileSync(path.join(rootDir, 'ztd.config.json'), '{ "defaultSchema": ', 'utf8'); - - expect(() => loadStarterPostgresDefaults(rootDir)).toThrow(/Unexpected end of JSON input|malformed/); -}); - -test('createStarterPostgresTestkitClient requires an explicit connectionString or ZTD_DB_URL', () => { - const previous = process.env.ZTD_DB_URL; - delete process.env.ZTD_DB_URL; - - try { - expect(() => - createStarterPostgresTestkitClient({ - tableDefinitions: [], - tableRows: [] - }) - ).toThrow(/Copy `.env\.example` to `.env`/); - } finally { - if (previous === undefined) { - delete process.env.ZTD_DB_URL; - } else { - process.env.ZTD_DB_URL = previous; - } - } -}); diff --git a/packages/ztd-cli/tests/prReadiness.unit.test.ts b/packages/ztd-cli/tests/prReadiness.unit.test.ts deleted file mode 100644 index 2d6a26c7f..000000000 --- a/packages/ztd-cli/tests/prReadiness.unit.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const { - classifyPullRequestContext, - classifyPrReadiness, - validatePrReadiness, -} = require('../../../scripts/check-pr-readiness.js') as { - classifyPullRequestContext(eventPath: string): { - isReleasePr: boolean; - headRef: string; - title: string; - authorLogin: string; - }; - classifyPrReadiness(changedFiles: string[]): { - changedFiles: string[]; - requiresCliMigrationPacket: boolean; - requiresScaffoldContractProof: boolean; - cliMatchedFiles: string[]; - scaffoldMatchedFiles: string[]; - }; - validatePrReadiness(input: { - body: string; - classification: { - changedFiles: string[]; - requiresCliMigrationPacket: boolean; - requiresScaffoldContractProof: boolean; - cliMatchedFiles: string[]; - scaffoldMatchedFiles: string[]; - }; - pullRequestContext?: { - isReleasePr: boolean; - headRef: string; - title: string; - authorLogin: string; - }; - }): { - ok: boolean; - errors: string[]; - }; -}; -const { - buildPreparedPrReadiness, - parseArgs, -} = require('../../../scripts/prepare-pr-readiness.js') as { - buildPreparedPrReadiness(input: { - baseSha?: string | null; - headSha?: string | null; - changedFiles: string[]; - summaryLines?: string[]; - verificationLines?: string[]; - baselineMode?: 'no-exception' | 'exception'; - trackingIssue?: string; - scopedChecks?: string[]; - baselineRationale?: string; - selfReviewWorkflow?: string; - selfReviewResult?: string; - conceptReviewWorkflow?: string; - conceptReviewResult?: string; - cliMode?: 'no-packet' | 'packet' | null; - cliNoMigrationRationale?: string; - upgradeNote?: string; - deprecationPlan?: string; - docsUpdated?: string; - releaseWording?: string; - scaffoldMode?: 'no-proof' | 'proof' | null; - noProofRationale?: string; - nonEditAssertion?: string; - failFastProof?: string; - generatedOutputProof?: string; - }): { - classification: { - changedFiles: string[]; - requiresCliMigrationPacket: boolean; - requiresScaffoldContractProof: boolean; - cliMatchedFiles: string[]; - scaffoldMatchedFiles: string[]; - }; - body: string; - }; - parseArgs(argv: string[]): Record; -}; - -function createBaseBody(): string { - return [ - '## Summary', - '', - '- add process guardrails', - '', - '## Verification', - '', - '- pnpm --filter @rawsql-ts/ztd-cli test:essential', - '', - '## Merge Readiness', - '', - '- [x] No baseline exception requested.', - '- [ ] Baseline exception requested and linked below.', - '', - 'Tracking issue:', - 'Scoped checks run:', - 'Why full baseline is not required:', - '', - '## Self Review', - '', - 'Self-review workflow: self-review skill two-cycle pass completed.', - 'Self-review result: no unresolved blockers.', - 'Concept-review workflow: concept boundary review checked the owning package concept.', - 'Concept-review result: no concept or package-boundary violations remain.', - '', - '## CLI Surface Migration', - '', - '- [ ] No migration packet required for this CLI change.', - '- [ ] CLI/user-facing surface change and migration packet completed.', - '', - 'No-migration rationale:', - 'Upgrade note:', - 'Deprecation/removal plan or issue:', - 'Docs/help/examples updated:', - 'Release/changeset wording:', - '', - '## Scaffold Contract Proof', - '', - '- [ ] No scaffold contract proof required for this PR.', - '- [ ] Scaffold contract proof completed.', - '', - 'No-proof rationale:', - 'Non-edit assertion:', - 'Fail-fast input-contract proof:', - 'Generated-output viability proof:', - '', - ].join('\n'); -} - -test('pr-readiness classifies CLI and scaffold changes independently', () => { - const classification = classifyPrReadiness([ - 'packages/ztd-cli/src/commands/query.ts', - 'packages/ztd-cli/templates/src/features/smoke/boundary.ts', - 'README.md', - ]); - - expect(classification.requiresCliMigrationPacket).toBe(true); - expect(classification.requiresScaffoldContractProof).toBe(true); - expect(classification.cliMatchedFiles).toEqual(['packages/ztd-cli/src/commands/query.ts']); - expect(classification.scaffoldMatchedFiles).toEqual(['packages/ztd-cli/templates/src/features/smoke/boundary.ts']); -}); - -test('pr-readiness accepts a tracked baseline exception plus CLI migration packet', () => { - const body = [ - '## Summary', - '', - '- rename a public query uses flag', - '', - '## Verification', - '', - '- pnpm --filter @rawsql-ts/ztd-cli test:essential', - '', - '## Merge Readiness', - '', - '- [ ] No baseline exception requested.', - '- [x] Baseline exception requested and linked below.', - '', - 'Tracking issue: #735', - 'Scoped checks run: pnpm --filter @rawsql-ts/ztd-cli test:essential, pnpm verify:generated-project-mode', - 'Why full baseline is not required: the unrelated baseline failure is tracked separately and this PR only needs the scoped lanes above.', - '', - '## Self Review', - '', - 'Self-review workflow: self-review skill two-cycle pass completed.', - 'Self-review result: no unresolved blockers.', - 'Concept-review workflow: concept boundary review checked the owning package concept.', - 'Concept-review result: no concept or package-boundary violations remain.', - '', - '## CLI Surface Migration', - '', - '- [ ] No migration packet required for this CLI change.', - '- [x] CLI/user-facing surface change and migration packet completed.', - '', - 'No-migration rationale:', - 'Upgrade note: `ztd query uses` no longer accepts `--specs-dir`; use `--scope-dir` in examples and shell snippets.', - 'Deprecation/removal plan or issue: issue #746 removed the deprecated alias from `query uses`.', - 'Docs/help/examples updated: query help text, tutorial examples, and guide examples were updated together.', - 'Release/changeset wording: call out the alias removal and the required `--scope-dir` replacement in one user-facing note.', - '', - '## Scaffold Contract Proof', - '', - '- [x] No scaffold contract proof required for this PR.', - '- [ ] Scaffold contract proof completed.', - '', - 'No-proof rationale: this change only touches the query uses CLI surface.', - 'Non-edit assertion:', - 'Fail-fast input-contract proof:', - 'Generated-output viability proof:', - '', - ].join('\n'); - - const validation = validatePrReadiness({ - body, - classification: classifyPrReadiness(['packages/ztd-cli/src/commands/query.ts']), - }); - - expect(validation.ok).toBe(true); - expect(validation.errors).toEqual([]); -}); - -test('pr-readiness rejects CLI changes without a migration packet or rationale', () => { - const validation = validatePrReadiness({ - body: createBaseBody(), - classification: classifyPrReadiness(['packages/ztd-cli/src/commands/query.ts']), - }); - - expect(validation.ok).toBe(false); - expect(validation.errors).toEqual(expect.arrayContaining([ - 'Select exactly one CLI Surface Migration checkbox.', - ])); -}); - -test('pr-readiness rejects PR bodies without self-review evidence', () => { - const body = createBaseBody() - .replace('## Self Review\n\nSelf-review workflow: self-review skill two-cycle pass completed.\nSelf-review result: no unresolved blockers.\nConcept-review workflow: concept boundary review checked the owning package concept.\nConcept-review result: no concept or package-boundary violations remain.\n\n', ''); - - const validation = validatePrReadiness({ - body, - classification: classifyPrReadiness(['packages/core/src/index.ts']), - }); - - expect(validation.ok).toBe(false); - expect(validation.errors).toEqual(expect.arrayContaining([ - 'Missing "## Self Review" section from the PR body.', - 'Self Review must name the self-review workflow or skill that was run. is required: "Self-review workflow:" must be filled in.', - 'Self Review must state whether blockers remain. is required: "Self-review result:" must be filled in.', - 'Self Review must name the concept review workflow, package concept, Concept Spec, or explicit no-concept-impact rationale that was checked. is required: "Concept-review workflow:" must be filled in.', - 'Self Review must state whether concept or package-boundary violations remain. is required: "Concept-review result:" must be filled in.', - ])); -}); - -test('pr-readiness rejects PR bodies without concept review evidence', () => { - const body = createBaseBody() - .replace('Concept-review workflow: concept boundary review checked the owning package concept.\n', '') - .replace('Concept-review result: no concept or package-boundary violations remain.\n', ''); - - const validation = validatePrReadiness({ - body, - classification: classifyPrReadiness(['packages/core/src/index.ts']), - }); - - expect(validation.ok).toBe(false); - expect(validation.errors).toEqual(expect.arrayContaining([ - 'Self Review must name the concept review workflow, package concept, Concept Spec, or explicit no-concept-impact rationale that was checked. is required: "Concept-review workflow:" must be filled in.', - 'Self Review must state whether concept or package-boundary violations remain. is required: "Concept-review result:" must be filled in.', - ])); -}); - -test('pr-readiness rejects scaffold changes without the three proof classes', () => { - const body = [ - '## Summary', - '', - '- harden scaffold tests', - '', - '## Verification', - '', - '- pnpm --filter @rawsql-ts/ztd-cli test:essential', - '', - '## Merge Readiness', - '', - '- [x] No baseline exception requested.', - '- [ ] Baseline exception requested and linked below.', - '', - 'Tracking issue:', - 'Scoped checks run:', - 'Why full baseline is not required:', - '', - '## Self Review', - '', - 'Self-review workflow: self-review skill two-cycle pass completed.', - 'Self-review result: no unresolved blockers.', - 'Concept-review workflow: concept boundary review checked the owning package concept.', - 'Concept-review result: no concept or package-boundary violations remain.', - '', - '## CLI Surface Migration', - '', - '- [x] No migration packet required for this CLI change.', - '- [ ] CLI/user-facing surface change and migration packet completed.', - '', - 'No-migration rationale: this PR only changes scaffold guardrails.', - 'Upgrade note:', - 'Deprecation/removal plan or issue:', - 'Docs/help/examples updated:', - 'Release/changeset wording:', - '', - '## Scaffold Contract Proof', - '', - '- [ ] No scaffold contract proof required for this PR.', - '- [x] Scaffold contract proof completed.', - '', - 'No-proof rationale:', - 'Non-edit assertion: parent boundary.ts remains untouched while child query boundaries are added.', - 'Fail-fast input-contract proof:', - 'Generated-output viability proof: generated output still includes the required shared imports.', - '', - ].join('\n'); - - const validation = validatePrReadiness({ - body, - classification: classifyPrReadiness(['packages/ztd-cli/templates/src/features/smoke/boundary.ts']), - }); - - expect(validation.ok).toBe(false); - expect(validation.errors.some((error) => error.includes('Scaffold contract proof must include a fail-fast input-contract proof.'))).toBe(true); -}); - -test('pr-readiness skips the human-authored body contract for release PRs', () => { - const validation = validatePrReadiness({ - body: 'This PR was opened by the Changesets release GitHub action.', - classification: classifyPrReadiness([ - 'packages/ztd-cli/package.json', - 'packages/core/CHANGELOG.md', - ]), - pullRequestContext: { - isReleasePr: true, - headRef: 'changeset-release/main', - title: 'chore(release): version packages', - authorLogin: 'github-actions[bot]', - }, - }); - - expect(validation.ok).toBe(true); - expect(validation.errors).toEqual([]); -}); - -test('pr-readiness classifies a changeset release branch as a release PR', () => { - const rootDir = mkdtempSync(path.join(os.tmpdir(), 'pr-readiness-event-')); - const eventPath = path.join(rootDir, 'event.json'); - writeFileSync(eventPath, JSON.stringify({ - pull_request: { - head: { ref: 'changeset-release/main' }, - title: 'chore(release): version packages', - user: { login: 'github-actions[bot]' }, - }, - }), 'utf8'); - - expect(classifyPullRequestContext(eventPath)).toEqual({ - isReleasePr: true, - headRef: 'changeset-release/main', - title: 'chore(release): version packages', - authorLogin: 'github-actions[bot]', - }); -}); - -test('pr-readiness preparation renders a validator-compatible CLI packet body', () => { - const prepared = buildPreparedPrReadiness({ - changedFiles: ['packages/ztd-cli/src/commands/query.ts'], - summaryLines: ['align query flag migration guidance'], - verificationLines: ['pnpm --filter @rawsql-ts/ztd-cli test -- prReadiness.unit.test.ts'], - baselineMode: 'no-exception', - cliMode: 'packet', - upgradeNote: 'Replace `--specs-dir` with `--scope-dir` in command examples.', - deprecationPlan: 'Issue #746 tracks the deprecated alias removal.', - docsUpdated: 'CLI help output and guide examples were updated together.', - releaseWording: 'Call out the required flag rename in the release note.', - }); - - expect(prepared.body).toContain('Tracking issue: not needed; no baseline exception requested.'); - expect(prepared.body).toContain('Concept-review result: no unresolved concept or package-boundary violations.'); - expect(prepared.body).toContain('Upgrade note: Replace `--specs-dir` with `--scope-dir` in command examples.'); - - const validation = validatePrReadiness({ - body: prepared.body, - classification: prepared.classification, - }); - - expect(validation.ok).toBe(true); - expect(validation.errors).toEqual([]); -}); - -test('pr-readiness preparation renders scaffold proof fields on the same line as labels', () => { - const prepared = buildPreparedPrReadiness({ - changedFiles: ['packages/ztd-cli/templates/src/features/smoke/boundary.ts'], - summaryLines: ['mechanize scaffold proof authoring'], - verificationLines: ['pnpm --filter @rawsql-ts/ztd-cli test -- prReadiness.unit.test.ts'], - baselineMode: 'no-exception', - scaffoldMode: 'proof', - nonEditAssertion: 'The parent feature boundary remains untouched while the generated child import shape is asserted separately.', - failFastProof: 'The prepared body requires the explicit proof field before validation succeeds.', - generatedOutputProof: 'Generated project verification confirms the scaffolded output stays viable.', - }); - - expect(prepared.body).toContain('Non-edit assertion: The parent feature boundary remains untouched while the generated child import shape is asserted separately.'); - expect(prepared.body).toContain('Fail-fast input-contract proof: The prepared body requires the explicit proof field before validation succeeds.'); - expect(prepared.body).not.toContain('Non-edit assertion:\n'); - - const validation = validatePrReadiness({ - body: prepared.body, - classification: prepared.classification, - }); - - expect(validation.ok).toBe(true); - expect(validation.errors).toEqual([]); -}); - -test('pr-readiness preparation fails fast when classified CLI changes omit a mode selection', () => { - expect(() => buildPreparedPrReadiness({ - changedFiles: ['packages/ztd-cli/src/commands/query.ts'], - summaryLines: ['omit CLI mode to prove fail-fast guidance'], - verificationLines: ['pnpm --filter @rawsql-ts/ztd-cli test -- prReadiness.unit.test.ts'], - baselineMode: 'no-exception', - })).toThrow('CLI-facing changes require --cli-mode set to "no-packet" or "packet".'); -}); - -test('pr-readiness preparation fails fast when classified scaffold changes omit a mode selection', () => { - expect(() => buildPreparedPrReadiness({ - changedFiles: ['packages/ztd-cli/templates/src/features/smoke/boundary.ts'], - summaryLines: ['omit scaffold mode to prove fail-fast guidance'], - verificationLines: ['pnpm --filter @rawsql-ts/ztd-cli test -- prReadiness.unit.test.ts'], - baselineMode: 'no-exception', - })).toThrow(/--scaffold-mode/); -}); - -test('pr-readiness parseArgs fails fast when --changed-file has no operand', () => { - expect(() => parseArgs(['--changed-file', '--summary-line', 'body'])) - .toThrow('--changed-file requires a non-empty value.'); -}); - -test('pr-readiness parseArgs fails fast when --summary-line has a blank operand', () => { - expect(() => parseArgs(['--summary-line', ' '])) - .toThrow('--summary-line requires a non-empty value.'); -}); diff --git a/packages/ztd-cli/tests/precommitEnforcement.unit.test.ts b/packages/ztd-cli/tests/precommitEnforcement.unit.test.ts deleted file mode 100644 index 7bb13b5a0..000000000 --- a/packages/ztd-cli/tests/precommitEnforcement.unit.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { expect, test } from 'vitest'; - -const { - collectPolicyViolations, - isPerfEvidenceFile, - isPerfSensitiveSourceFile, - isLocalTaskLedgerFile, - isQuerySpecFile, - isRepositorySourceFile, - isTestFile, -} = require('../../../scripts/precommit-enforcement.js') as { - collectPolicyViolations( - stagedFiles: string[], - options?: { readFile?: (filePath: string) => string }, - ): string[]; - isPerfEvidenceFile(filePath: string): boolean; - isPerfSensitiveSourceFile(filePath: string): boolean; - isLocalTaskLedgerFile(filePath: string): boolean; - isQuerySpecFile(filePath: string): boolean; - isRepositorySourceFile(filePath: string): boolean; - isTestFile(filePath: string): boolean; -}; - -test('query spec changes require a staged test file', () => { - const violations = collectPolicyViolations([ - 'packages/ztd-cli/src/catalog/specs/orderSummary.spec.ts', - ]); - - expect(violations).toEqual([ - expect.stringContaining('QuerySpec changes require tests in the same commit.'), - ]); -}); - -test('repository changes require telemetry hook markers', () => { - const violations = collectPolicyViolations( - ['packages/demo/src/repositories/views/ordersRepository.ts'], - { - readFile: () => 'export class OrdersRepository {}', - }, - ); - - expect(violations).toEqual([ - expect.stringContaining('Repository source changes must include a telemetry hook seam.'), - ]); -}); - -test('perf-sensitive changes require staged perf evidence', () => { - const violations = collectPolicyViolations([ - 'packages/ztd-cli/src/perf/benchmark.ts', - ]); - - expect(violations).toEqual([ - expect.stringContaining('Perf-sensitive changes require perf evidence in the same commit.'), - ]); -}); - -test('paired tests, telemetry markers, and perf evidence satisfy the policy', () => { - const violations = collectPolicyViolations( - [ - 'packages/demo/src/catalog/specs/orderSummary.spec.ts', - 'packages/demo/tests/orderSummary.test.ts', - 'packages/demo/src/repositories/views/ordersRepository.ts', - 'benchmarks/parser-phase-benchmark.ts', - 'docs/dogfooding/telemetry-dogfooding.md', - ], - { - readFile: () => - "import { resolveRepositoryTelemetry, type RepositoryTelemetry } from '../telemetry/repositoryTelemetry';", - }, - ); - - expect(violations).toEqual([]); -}); - -test('tmp task ledgers are rejected even when staged explicitly', () => { - const violations = collectPolicyViolations([ - 'tmp/PLAN.md', - ]); - - expect(violations).toEqual([ - expect.stringContaining('Local task ledgers under tmp/ must not be committed.'), - ]); -}); - -test('path classifiers stay aligned with the enforced policy', () => { - expect(isTestFile('packages/ztd-cli/tests/init.command.test.ts')).toBe(true); - expect(isQuerySpecFile('packages/demo/src/catalog/specs/orderSummary.spec.ts')).toBe(true); - expect(isRepositorySourceFile('packages/demo/src/repositories/views/ordersRepository.ts')).toBe(true); - expect(isPerfSensitiveSourceFile('benchmarks/parser-phase-benchmark.ts')).toBe(true); - expect(isPerfEvidenceFile('docs/dogfooding/telemetry-dogfooding.md')).toBe(true); - expect(isLocalTaskLedgerFile('tmp/PLAN.md')).toBe(true); - expect(isLocalTaskLedgerFile('tmp/sub/PLAN.md')).toBe(true); - expect(isLocalTaskLedgerFile('prefix/tmp/PLAN.md')).toBe(true); - expect(isLocalTaskLedgerFile('prefix/tmp/sub/PLAN.md')).toBe(true); - expect(isLocalTaskLedgerFile('not_tmp/PLAN.md')).toBe(false); -}); diff --git a/packages/ztd-cli/tests/publishWorkflow.unit.test.ts b/packages/ztd-cli/tests/publishWorkflow.unit.test.ts deleted file mode 100644 index fc466b414..000000000 --- a/packages/ztd-cli/tests/publishWorkflow.unit.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const publishWorkflowPath = path.resolve(__dirname, '../../../.github/workflows/publish.yml'); -const publishWorkflow = fs.readFileSync(publishWorkflowPath, 'utf8'); -const prCheckWorkflowPath = path.resolve(__dirname, '../../../.github/workflows/pr-check.yml'); -const prCheckWorkflow = fs.readFileSync(prCheckWorkflowPath, 'utf8'); -const releasePrWorkflowPath = path.resolve(__dirname, '../../../.github/workflows/release-pr.yml'); -const releasePrWorkflow = fs.readFileSync(releasePrWorkflowPath, 'utf8'); -const publishedPackageModePath = path.resolve(__dirname, '../../../scripts/verify-published-package-mode.mjs'); -const publishedPackageModeScript = fs.readFileSync(publishedPackageModePath, 'utf8'); -const publishPlanPath = path.resolve(__dirname, '../../../scripts/publish-plan.mjs'); -const publishPlanScript = fs.readFileSync(publishPlanPath, 'utf8'); -const generatedProjectModePath = path.resolve(__dirname, '../../../scripts/verify-generated-project-mode.mjs'); -const generatedProjectModeScript = fs.readFileSync(generatedProjectModePath, 'utf8'); -const ztdCliPackageJsonPath = path.resolve(__dirname, '../package.json'); -const ztdCliPackageJson = JSON.parse(fs.readFileSync(ztdCliPackageJsonPath, 'utf8')) as { - dependencies?: Record; - devDependencies?: Record; -}; - -test('publish workflow verifies built artifacts before actual publish', () => { - expect(publishWorkflow).toContain('verify_publish_artifacts:'); - expect(publishWorkflow).toContain('Run publish artifact verification'); - expect(publishWorkflow).toContain('--publish-manifest "${{ steps.artifacts_contract.outputs.publish_manifest_path }}"'); -}); - -test('actual_publish depends on publish artifact verification', () => { - expect(publishWorkflow).toContain('needs: [verify_publish_readiness, build_publish_artifacts, verify_publish_artifacts]'); -}); - -test('proof mode skips the main-branch requirement and actual publish', () => { - expect(publishWorkflow).toContain("if: ${{ inputs.verification_mode != 'proof' }}"); - expect(publishWorkflow).toContain("if: ${{ needs.verify_publish_readiness.outputs.should_publish == 'true' && inputs.verification_mode != 'proof' }}"); - expect(publishWorkflow).toContain('create-publish-proof-plan.mjs'); -}); - -test('release PR workflow requires the changesets PAT instead of silently falling back to GITHUB_TOKEN', () => { - expect(releasePrWorkflow).toContain('name: Require release PR token'); - expect(releasePrWorkflow).toContain('Release PR requires the CHANGESETS_TOKEN secret.'); - expect(releasePrWorkflow).toContain('GITHUB_TOKEN: ${{ secrets.CHANGESETS_TOKEN }}'); - expect(releasePrWorkflow).not.toContain('secrets.CHANGESETS_TOKEN || secrets.GITHUB_TOKEN'); -}); - -test('pr check reruns when the PR body is edited or the draft is marked ready for review', () => { - expect(prCheckWorkflow).toContain('types: [opened, reopened, synchronize, edited, ready_for_review]'); -}); - -test('standalone pnpm proof apps use the installed ztd bin helper instead of pnpm exec', () => { - expect(publishedPackageModeScript).toContain('function getInstalledBinPath(directory, binName) {'); - expect(publishedPackageModeScript).toContain('function runInstalledZtdCli(directory, args) {'); - expect(publishedPackageModeScript).toContain('pnpm-workspace.yaml'); - expect(publishedPackageModeScript).toContain('const result = runInstalledZtdCli(directory, ["init", "--dry-run", "--yes", ...args]);'); - - const starterSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyPnpmStarterPath(packages) {'), - publishedPackageModeScript.indexOf('function verifyPnpmAdapterInstall(packages) {'), - ); - expect(starterSection).toContain('runInstalledZtdCli(appDir,'); - expect(starterSection).not.toContain('"exec",\n "ztd"'); - expect(starterSection).toContain('"tsc", "--noEmit", "-p", "tsconfig.json"'); - expect(starterSection).toContain('"src/features/smoke/tests/smoke.boundary.test.ts"'); - expect(starterSection).toContain('"src/features/smoke/tests/smoke.validation.test.ts"'); - expect(starterSection).toContain('runInstalledZtdCli(appDir, ["ztd-config"])'); - - const adapterSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyPnpmAdapterInstall(packages) {'), - publishedPackageModeScript.indexOf('function verifyPnpmTutorialModelGen(packages) {'), - ); - expect(adapterSection).toContain('runInstalledZtdCli(appDir,'); - expect(adapterSection).not.toContain('"exec"'); - - const tutorialSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyPnpmTutorialModelGen(packages) {'), - publishedPackageModeScript.indexOf('function verifyOverwriteSafety(packages) {'), - ); - expect(tutorialSection).toContain('runInstalledZtdCli(appDir,'); - expect(tutorialSection).not.toContain('"exec",'); -}); - -test('published-package mode includes the rawsql-ts getting-started smoke path', () => { - expect(publishedPackageModeScript).toContain('function verifyCoreGettingStarted(packages) {'); - expect(publishedPackageModeScript).toContain("name: \"rawsql-ts-getting-started-check\""); - expect(publishedPackageModeScript).toContain("await import('rawsql-ts')"); - expect(publishedPackageModeScript).toContain('const builder = new DynamicQueryBuilder();'); - expect(publishedPackageModeScript).toContain('const formatter = new SqlFormatter();'); -}); - -test('published-package mode only runs the rawsql-ts getting-started smoke when its tarball is present', () => { - const coreSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyCoreGettingStarted(packages) {'), - publishedPackageModeScript.indexOf('function verifyNpmPrimaryPath(packages) {'), - ); - - expect(coreSection).toContain('const tarballDependencies = createTarballDependencyMap(packages);'); - expect(coreSection).toContain('if (!hasTarballDependency(tarballDependencies, "rawsql-ts")) {'); - expect(coreSection).toContain('return null;'); - expect(coreSection).toContain('"rawsql-ts": tarballDependencies["rawsql-ts"],'); -}); - -test('published-package mode expects local-source guard scripts from npm consumer scaffolds', () => { - const npmSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyNpmPrimaryPath(packages) {'), - publishedPackageModeScript.indexOf('function verifyNpmConsumerSmoke(phaseAResult) {'), - ); - - expect(npmSection).toContain('node ./scripts/local-source-guard.mjs test --passWithNoTests'); - expect(npmSection).toContain('node ./scripts/local-source-guard.mjs typecheck'); - expect(npmSection).toContain('node ./scripts/local-source-guard.mjs ztd'); -}); - -test('overwrite safety uses the installed ztd bin so npm does not consume --force', () => { - const overwriteSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyOverwriteSafety(packages) {'), - publishedPackageModeScript.indexOf('function main() {'), - ); - - expect(overwriteSection).toContain('runInstalledZtdCli(appDir, ["init", "--yes", "--workflow", "demo"])'); - expect(overwriteSection).toContain('runInstalledZtdCli(appDir, ["init", "--yes", "--force", "--workflow", "demo"])'); - expect(overwriteSection).not.toContain('"exec"'); -}); - -test('standalone pnpm proof apps pin tarball overrides at the workspace root', () => { - expect(publishedPackageModeScript).toContain('function syncStandaloneWorkspacePackageJson(overrides) {'); - expect(publishedPackageModeScript).toContain('writePackageJson(standalonePackageRoot, {'); - expect(publishedPackageModeScript).toContain('syncStandaloneWorkspacePackageJson(tarballDependencies);'); -}); - -test('generated-project mode skips the initial install and reapplies local-source overrides before pnpm install', () => { - expect(generatedProjectModeScript).toContain('"--skip-install"'); - expect(generatedProjectModeScript).toContain('function applyLocalSourceOverrides(appDir) {'); - expect(generatedProjectModeScript).toContain('runIn(generatedProjectRoot, PNPM, ["install", "--ignore-workspace", "--no-frozen-lockfile"])'); - expect(generatedProjectModeScript).toContain('runIn(generatedProjectRoot, process.execPath, ['); - expect(generatedProjectModeScript).toContain('path.join(generatedProjectRoot, "scripts", "local-source-guard.mjs")'); - expect(generatedProjectModeScript).toContain('"ztd"'); - expect(generatedProjectModeScript).toContain('shell: false'); -}); - -test('packed tarball install smoke only runs commands for tarballs included in the publish manifest', () => { - expect(publishedPackageModeScript).toContain('function hasTarballDependency(tarballDependencies, packageName) {'); - expect(publishedPackageModeScript).toContain('if (hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) {'); - expect(publishedPackageModeScript).toContain('const smokeImportTargets = ['); - expect(publishedPackageModeScript).toContain('"@rawsql-ts/testkit-core",'); - expect(publishedPackageModeScript).not.toContain('"@rawsql-ts/sql-contract-zod",'); - expect(publishedPackageModeScript).toContain('.filter((packageName) => hasTarballDependency(tarballDependencies, packageName));'); - expect(publishedPackageModeScript).toContain('if (smokeImportTargets.length > 0) {'); -}); - -test('published-package smoke rebinds scaffold runtime dependencies to packed tarballs', () => { - expect(publishedPackageModeScript).toContain('function createPublishedDependencyRangeMap(packages) {'); - expect(publishedPackageModeScript).toContain('dependencyName === "rawsql-ts" || dependencyName.startsWith("@rawsql-ts/")'); - expect(publishedPackageModeScript).toContain('for (const sectionName of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"])'); - expect(publishedPackageModeScript).toContain('section[dependencyName] = tarballDependencies[dependencyName];'); - expect(publishedPackageModeScript).toContain('currentRange.startsWith("file:")'); - expect(publishedPackageModeScript).toContain('section[dependencyName] = publishedDependencyRanges.get(dependencyName);'); - expect(publishedPackageModeScript).toContain('fs.rmSync(path.join(directory, "package-lock.json"), { force: true });'); - expect(publishedPackageModeScript).toContain('fs.rmSync(path.join(directory, "node_modules"), { force: true, recursive: true });'); - expect(publishedPackageModeScript).toContain('restorePublishedDependencyRanges(appDir, packages);'); -}); - -test('published-package mode only runs ztd CLI smoke paths when its tarball is present', () => { - const npmSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyNpmPrimaryPath(packages) {'), - publishedPackageModeScript.indexOf('function verifyNpmConsumerSmoke(phaseAResult) {'), - ); - expect(npmSection).toContain('if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) {'); - expect(npmSection).toContain('return null;'); - expect(npmSection).toContain('runInstalledZtdCli(appDir, ["init", "--yes", "--workflow", "demo"])'); - expect(npmSection).not.toContain('NPM, ["exec", "--", "ztd"'); - - const smokeSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyNpmConsumerSmoke(phaseAResult) {'), - publishedPackageModeScript.indexOf('function verifyPnpmStarterPath(packages) {'), - ); - expect(smokeSection).toContain('if (phaseAResult == null) {'); - expect(smokeSection).toContain('return null;'); - expect(smokeSection).toContain('runInstalledZtdCli(appDir, ["ztd-config"])'); - expect(smokeSection).toContain('runInstalledZtdCli(appDir, ['); - expect(smokeSection).not.toContain('NPM, ["exec", "--", "ztd"'); - - const starterSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyPnpmStarterPath(packages) {'), - publishedPackageModeScript.indexOf('function verifyPnpmAdapterInstall(packages) {'), - ); - expect(starterSection).toContain('if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) {'); - expect(starterSection).toContain('return null;'); - - const adapterSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyPnpmAdapterInstall(packages) {'), - publishedPackageModeScript.indexOf('function verifyPnpmTutorialModelGen(packages) {'), - ); - expect(adapterSection).toContain('if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) {'); - expect(adapterSection).toContain('return null;'); - - const tutorialSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyPnpmTutorialModelGen(packages) {'), - publishedPackageModeScript.indexOf('function verifyOverwriteSafety(packages) {'), - ); - expect(tutorialSection).toContain('if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) {'); - expect(tutorialSection).toContain('return null;'); - - const overwriteSection = publishedPackageModeScript.slice( - publishedPackageModeScript.indexOf('function verifyOverwriteSafety(packages) {'), - publishedPackageModeScript.indexOf('function main() {'), - ); - expect(overwriteSection).toContain('if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) {'); - expect(overwriteSection).toContain('return null;'); -}); - -test('publish plan can include unpublished workspace dependencies required by published scaffolds', () => { - expect(ztdCliPackageJson.dependencies).toHaveProperty('@rawsql-ts/driver-adapter-core'); - expect(ztdCliPackageJson.devDependencies).not.toHaveProperty('@rawsql-ts/driver-adapter-core'); - expect(publishPlanScript).toContain('for (let index = 0; index < publishCandidates.length; index += 1)'); - expect(publishPlanScript).toContain('for (const dependencyName of candidate.workspaceDependencies)'); - expect(publishPlanScript).toContain('addPublishCandidate(dependencyPkg);'); -}); diff --git a/packages/ztd-cli/tests/qualityGates.unit.test.ts b/packages/ztd-cli/tests/qualityGates.unit.test.ts deleted file mode 100644 index d96466322..000000000 --- a/packages/ztd-cli/tests/qualityGates.unit.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, test } from 'vitest'; - -const { - ESSENTIAL_TEST_FILES, - SOFT_GATE_TEST_FILES, -} = require('../../../scripts/ztd-cli-quality-gates.js') as { - ESSENTIAL_TEST_FILES: string[]; - SOFT_GATE_TEST_FILES: string[]; -}; - -test('essential suite stays focused on scaffold and CLI contract coverage', () => { - expect(ESSENTIAL_TEST_FILES).toEqual( - expect.arrayContaining([ - 'tests/checkContract.cli.test.ts', - 'tests/featureScaffold.unit.test.ts', - 'tests/init.command.test.ts', - 'tests/precommitEnforcement.unit.test.ts', - ]), - ); - expect(ESSENTIAL_TEST_FILES).not.toEqual( - expect.arrayContaining([ - 'tests/repoGuidance.unit.test.ts', - 'tests/intentProcedure.docs.test.ts', - 'tests/perfBenchmark.unit.test.ts', - 'tests/perfSandbox.unit.test.ts', - 'tests/queryLint.unit.test.ts', - ]), - ); -}); - -test('soft-gate suite keeps docs, perf, and query lint coverage visible', () => { - expect(SOFT_GATE_TEST_FILES).toEqual(expect.arrayContaining([ - 'tests/repoGuidance.unit.test.ts', - 'tests/intentProcedure.docs.test.ts', - 'tests/perfBenchmark.unit.test.ts', - 'tests/perfSandbox.unit.test.ts', - 'tests/queryLint.unit.test.ts', - ])); -}); diff --git a/packages/ztd-cli/tests/queryExecute.unit.test.ts b/packages/ztd-cli/tests/queryExecute.unit.test.ts deleted file mode 100644 index 34a1711a7..000000000 --- a/packages/ztd-cli/tests/queryExecute.unit.test.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test, vi } from 'vitest'; -import { executeQueryPipeline } from '../src/query/execute'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function createSqlWorkspace(prefix: string, sqlRelativePath: string = path.join('src', 'sql', 'query.sql')): { - rootDir: string; - sqlFile: string; -} { - const rootDir = createTempDir(prefix); - const sqlFile = path.join(rootDir, sqlRelativePath); - mkdirSync(path.dirname(sqlFile), { recursive: true }); - return { rootDir, sqlFile }; -} - -function writeScalarPredicateSql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - 'select s.*', - 'from sales s', - 'where s.sale_date > (', - ' select p.closed_year_month', - ' from parameters p', - ')' - ].join('\n'), - 'utf8' - ); -} - -function writeCorrelatedScalarPredicateSql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - 'select s.*', - 'from sales s', - 'where s.sale_date > (', - ' select p.closed_year_month', - ' from parameters p', - ' where p.tenant_id = s.tenant_id', - ')' - ].join('\n'), - 'utf8' - ); -} - -function writeNestedScalarDependencySql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - 'with cutoff_source as (', - ' select p.closed_year_month', - ' from parameters p', - ')', - 'select s.*', - 'from sales s', - 'where s.sale_date > (', - ' select p.closed_year_month', - ' from parameters p', - ' where exists (', - ' select 1', - ' from cutoff_source cs', - ' where cs.closed_year_month = p.closed_year_month', - ' )', - ')' - ].join('\n'), - 'utf8' - ); -} - -function writeLiteralPlaceholderSql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - "select '$1 is literal' as note" - ].join('\n'), - 'utf8' - ); -} -function writeMaterialChainSql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - 'with base_sales as (', - ' select id, sale_date, region_id from sales', - '),', - 'filtered_sales as (', - ' select id, sale_date from base_sales where region_id is not null', - '),', - 'ranked_sales as (', - ' select id, sale_date from filtered_sales', - ')', - 'select * from ranked_sales' - ].join('\n'), - 'utf8' - ); -} - -function writeMixedPipelineSql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - 'with scoped_sales as (', - ' select s.id, s.sale_date, s.region_id', - ' from sales s', - ' where s.region_id is not null', - '),', - 'ranked_sales as (', - ' select ss.id, ss.sale_date', - ' from scoped_sales ss', - ' where ss.sale_date > (', - ' select p.closed_year_month', - ' from parameters p', - ' )', - ')', - 'select * from ranked_sales' - ].join('\n'), - 'utf8' - ); -} - -function writeReturningCtePipelineSql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - 'with source_rows as (', - ' select id from pending_events', - '),', - 'inserted_rows as (', - ' insert into audit_log (id)', - ' select id from source_rows', - ' returning id', - '),', - 'fanout_rows as (', - ' select id from inserted_rows', - ')', - 'select * from fanout_rows' - ].join('\n'), - 'utf8' - ); -} - -function writeDmlCteWithoutReturningSql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - 'with inserted_rows as (', - ' insert into audit_log (id)', - ' select id from pending_events', - ')', - 'select 1' - ].join('\n'), - 'utf8' - ); -} - -function writeInvalidStaticScalarSql(sqlFile: string): void { - writeFileSync( - sqlFile, - [ - 'select s.*', - 'from sales s', - 'where s.sale_date > (', - ' select p.closed_year_month, p.closed_year', - ' from parameters p', - ')' - ].join('\n'), - 'utf8' - ); -} - -function normalizeSql(sql: string): string { - return sql.replace(/\s+/g, ' ').trim().toLowerCase(); -} - -test('executeQueryPipeline rewrites optimizer-sensitive scalar predicates into bind parameters', async () => { - const workspace = createSqlWorkspace('query-pipeline-scalar-bind'); - writeScalarPredicateSql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [{ closed_year_month: '2024-12-01' }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - const result = await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - scalarFilterColumns: ['sale_date'] - } - } - ); - - expect(openSession).toHaveBeenCalledTimes(1); - expect(result.steps.map((step) => step.kind)).toEqual(['scalar-filter-bind', 'final-query']); - expect(result.final.rows).toEqual([{ id: 1 }]); - - const scalarSql = normalizeSql(query.mock.calls[0]?.[0] as string); - const finalSql = normalizeSql(query.mock.calls[1]?.[0] as string); - - expect(scalarSql).toContain('select "p"."closed_year_month" from "parameters" as "p"'); - expect(finalSql).toMatch(/where "s"\."sale_date" > \$1/); - expect(finalSql).not.toContain('select "p"."closed_year_month"'); - expect(query.mock.calls[1]?.[1]).toEqual(['2024-12-01']); - expect(release).toHaveBeenCalledTimes(1); -}); - -test('executeQueryPipeline leaves correlated scalar predicates unchanged', async () => { - const workspace = createSqlWorkspace('query-pipeline-correlated'); - writeCorrelatedScalarPredicateSql(workspace.sqlFile); - - const query = vi.fn().mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - const result = await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - scalarFilterColumns: ['sale_date'] - } - } - ); - - expect(result.steps.map((step) => step.kind)).toEqual(['final-query']); - expect(query).toHaveBeenCalledTimes(1); - const finalSql = normalizeSql(query.mock.calls[0]?.[0] as string); - expect(finalSql).toContain('where "s"."sale_date" > (select "p"."closed_year_month" from "parameters" as "p" where "p"."tenant_id" = "s"."tenant_id")'); - expect(release).toHaveBeenCalledTimes(1); -}); - - -test('executeQueryPipeline includes nested scalar-subquery cte dependencies in the bind step', async () => { - const workspace = createSqlWorkspace('query-pipeline-nested-scalar-dependency'); - writeNestedScalarDependencySql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [{ closed_year_month: '2024-12-01' }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - scalarFilterColumns: ['sale_date'] - } - } - ); - - const scalarSql = normalizeSql(query.mock.calls[0]?.[0] as string); - expect(scalarSql).toContain('with "cutoff_source" as'); - expect(scalarSql).toContain('from "cutoff_source" as "cs"'); - expect(release).toHaveBeenCalledTimes(1); -}); -test('executeQueryPipeline runs multi-stage materialized pipelines without recomputing prior CTEs', async () => { - const workspace = createSqlWorkspace('query-pipeline-material-chain'); - writeMaterialChainSql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockResolvedValueOnce({ rows: [{ id: 1, sale_date: '2024-12-15' }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - const result = await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - material: ['filtered_sales', 'ranked_sales'] - } - } - ); - - expect(result.steps.map((step) => step.kind)).toEqual(['materialize', 'materialize', 'final-query']); - const stage1Sql = normalizeSql(query.mock.calls[0]?.[0] as string); - const stage2Sql = normalizeSql(query.mock.calls[1]?.[0] as string); - const finalSql = normalizeSql(query.mock.calls[2]?.[0] as string); - - expect(stage1Sql).toContain('create temp table "filtered_sales" as with'); - expect(stage2Sql).toContain('create temp table "ranked_sales" as select'); - expect(stage2Sql).not.toContain('filtered_sales as ('); - expect(stage2Sql).toContain('from "filtered_sales"'); - expect(finalSql).not.toContain('filtered_sales as ('); - expect(finalSql).not.toContain('ranked_sales as ('); - expect(finalSql).toContain('from "ranked_sales"'); - expect(normalizeSql(query.mock.calls[3]?.[0] as string)).toBe('drop table if exists "ranked_sales"'); - expect(normalizeSql(query.mock.calls[4]?.[0] as string)).toBe('drop table if exists "filtered_sales"'); - expect(release).toHaveBeenCalledTimes(1); -}); - -test('executeQueryPipeline materializes root ctes without self-shadowing the temp table name', async () => { - const workspace = createSqlWorkspace('query-pipeline-root-material'); - writeMaterialChainSql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockResolvedValueOnce({ rows: [{ id: 1, sale_date: '2024-12-15' }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - const result = await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - material: ['base_sales'] - } - } - ); - - expect(result.steps.map((step) => step.kind)).toEqual(['materialize', 'final-query']); - const stageSql = normalizeSql(query.mock.calls[0]?.[0] as string); - const finalSql = normalizeSql(query.mock.calls[1]?.[0] as string); - - expect(stageSql).toContain('create temp table "base_sales" as select "id", "sale_date", "region_id" from "sales"'); - expect(stageSql).not.toContain('with base_sales as ('); - expect(finalSql).toContain('from "base_sales"'); - expect(normalizeSql(query.mock.calls[2]?.[0] as string)).toBe('drop table if exists "base_sales"'); - expect(release).toHaveBeenCalledTimes(1); -}); - -test('executeQueryPipeline materializes RETURNING CTE output before downstream fanout', async () => { - const workspace = createSqlWorkspace('query-pipeline-returning-cte'); - writeReturningCtePipelineSql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - const result = await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - material: ['inserted_rows'] - } - } - ); - - expect(result.steps.map((step) => step.kind)).toEqual(['materialize-returning', 'final-query']); - const materializeSql = normalizeSql(query.mock.calls[0]?.[0] as string); - const finalSql = normalizeSql(query.mock.calls[1]?.[0] as string); - - expect(materializeSql).toContain('create temp table "inserted_rows" as with'); - expect(materializeSql).toContain('"inserted_rows" as (insert into "audit_log"("id") select "id" from "source_rows" returning "id")'); - expect(materializeSql).toContain('select * from "inserted_rows"'); - expect(finalSql).not.toContain('insert into "audit_log"'); - expect(finalSql).toContain('from "inserted_rows"'); - expect(normalizeSql(query.mock.calls[2]?.[0] as string)).toBe('drop table if exists "inserted_rows"'); - expect(release).toHaveBeenCalledTimes(1); -}); - -test('executeQueryPipeline rejects DML CTE materialization without RETURNING', async () => { - const workspace = createSqlWorkspace('query-pipeline-dml-no-returning'); - writeDmlCteWithoutReturningSql(workspace.sqlFile); - - const query = vi.fn(); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - await expect(() => - executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - material: ['inserted_rows'] - } - } - ) - ).rejects.toThrow('DML CTE materialization requires a RETURNING clause.'); - - expect(query).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledTimes(1); -}); - -test('executeQueryPipeline mixes temp-table reuse with scalar predicate binding', async () => { - const workspace = createSqlWorkspace('query-pipeline-mixed'); - writeMixedPipelineSql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockResolvedValueOnce({ rows: [{ closed_year_month: '2024-12-01' }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ id: 1, sale_date: '2024-12-15' }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - const result = await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - material: ['scoped_sales'], - scalarFilterColumns: ['sale_date'] - } - } - ); - - expect(result.steps.map((step) => step.kind)).toEqual(['materialize', 'scalar-filter-bind', 'final-query']); - const finalSql = normalizeSql(query.mock.calls[2]?.[0] as string); - expect(finalSql).toContain('from "scoped_sales" as "ss"'); - expect(finalSql).toMatch(/where "ss"\."sale_date" > \$1/); - expect(finalSql).not.toContain('select "p"."closed_year_month"'); - expect(query.mock.calls[2]?.[1]).toEqual(['2024-12-01']); - expect(normalizeSql(query.mock.calls[3]?.[0] as string)).toBe('drop table if exists "scoped_sales"'); - expect(release).toHaveBeenCalledTimes(1); -}); - -test('executeQueryPipeline cleans up temp tables when a materialize step fails', async () => { - const workspace = createSqlWorkspace('query-pipeline-material-failure'); - writeMaterialChainSql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockRejectedValueOnce(new Error('material boom')) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - await expect(() => - executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - material: ['filtered_sales', 'ranked_sales'] - } - } - ) - ).rejects.toThrow('material boom'); - - expect(normalizeSql(query.mock.calls[2]?.[0] as string)).toBe('drop table if exists "filtered_sales"'); - expect(release).toHaveBeenCalledTimes(1); -}); - -test('executeQueryPipeline cleans up temp tables when the final query fails', async () => { - const workspace = createSqlWorkspace('query-pipeline-final-failure'); - writeMaterialChainSql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockRejectedValueOnce(new Error('final boom')) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - await expect(() => - executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - material: ['filtered_sales', 'ranked_sales'] - } - } - ) - ).rejects.toThrow('final boom'); - - expect(normalizeSql(query.mock.calls[3]?.[0] as string)).toBe('drop table if exists "ranked_sales"'); - expect(normalizeSql(query.mock.calls[4]?.[0] as string)).toBe('drop table if exists "filtered_sales"'); - expect(release).toHaveBeenCalledTimes(1); -}); - -test('executeQueryPipeline falls back to session.end when release is unavailable', async () => { - const workspace = createSqlWorkspace('query-pipeline-end-fallback'); - writeMaterialChainSql(workspace.sqlFile); - - const query = vi.fn() - .mockResolvedValueOnce({ rows: [], rowCount: 2 }) - .mockResolvedValueOnce({ rows: [{ id: 1, sale_date: '2024-12-15' }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }); - const end = vi.fn(); - const openSession = vi.fn(async () => ({ query, end })); - - await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - material: ['filtered_sales'] - } - } - ); - - expect(end).toHaveBeenCalledTimes(1); -}); - -test.each([ - { - label: '0 rows', - sqlFactory: writeScalarPredicateSql, - results: [{ rows: [], rowCount: 0 }], - message: 'Scalar filter binding for column "sale_date" must return exactly one row.' - }, - { - label: '2 rows', - sqlFactory: writeScalarPredicateSql, - results: [{ rows: [{ closed_year_month: '2024-12-01' }, { closed_year_month: '2024-12-02' }], rowCount: 2 }], - message: 'Scalar filter binding for column "sale_date" must return exactly one row.' - }, - { - label: '2 columns', - sqlFactory: writeInvalidStaticScalarSql, - results: [], - message: 'Scalar filter binding for column "sale_date" requires a subquery that statically exposes exactly one column.' - } -])('executeQueryPipeline rejects invalid scalar filter bindings: $label', async ({ sqlFactory, results, message }) => { - const workspace = createSqlWorkspace('query-pipeline-invalid-scalar'); - sqlFactory(workspace.sqlFile); - - const query = vi.fn(); - for (const result of results) { - query.mockResolvedValueOnce(result); - } - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - await expect(() => - executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - metadata: { - scalarFilterColumns: ['sale_date'] - } - } - ) - ).rejects.toThrow(message); - - expect(release).toHaveBeenCalledTimes(1); -}); -test('executeQueryPipeline does not infer bind params from literal $n text', async () => { - const workspace = createSqlWorkspace('query-pipeline-literal-placeholder'); - writeLiteralPlaceholderSql(workspace.sqlFile); - - const query = vi.fn().mockResolvedValueOnce({ rows: [{ note: '$1 is literal' }], rowCount: 1 }); - const release = vi.fn(); - const openSession = vi.fn(async () => ({ query, release })); - - const result = await executeQueryPipeline( - { openSession }, - { - sqlFile: workspace.sqlFile, - params: [123] - } - ); - - expect(result.steps.map((step) => step.kind)).toEqual(['final-query']); - expect(query).toHaveBeenCalledWith(expect.any(String), undefined); - expect(release).toHaveBeenCalledTimes(1); -}); diff --git a/packages/ztd-cli/tests/queryFingerprint.unit.test.ts b/packages/ztd-cli/tests/queryFingerprint.unit.test.ts deleted file mode 100644 index 0d43b4700..000000000 --- a/packages/ztd-cli/tests/queryFingerprint.unit.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from 'vitest'; -import { buildCatalogStatements } from '../src/utils/sqlCatalogStatements'; -import { createQueryFingerprint, normalizeQueryFingerprintSource } from '../src/utils/queryFingerprint'; - -test('normalizeQueryFingerprintSource uses stable normalization rules', () => { - expect(normalizeQueryFingerprintSource(` - -- comment - SELECT id - FROM users /* block */ - WHERE active = 1 - `)).toMatchInlineSnapshot(`"SELECT id FROM users WHERE active = 1"`); -}); - -test('query fingerprint collapses equivalent whitespace and comment variants', () => { - const a = createQueryFingerprint('SELECT id FROM users WHERE active = 1'); - const b = createQueryFingerprint(` - -- leading - SELECT id - FROM users - /* inline */ - WHERE active = 1 - `); - expect(a).toBe(b); -}); - -test('query fingerprint distinguishes different statements', () => { - expect(createQueryFingerprint('SELECT id FROM users')).not.toBe( - createQueryFingerprint('SELECT email FROM users') - ); -}); - -test('buildCatalogStatements uses 1-based indexes, file-relative offsets, and stable fingerprints', () => { - const sql = [ - '-- comment', - 'SELECT id FROM users;', - '', - 'SELECT email FROM users WHERE active = 1;' - ].join('\n'); - - const statements = buildCatalogStatements({ - catalogId: 'catalog.users', - sqlFile: 'src/sql/users.sql', - sqlText: sql - }); - - expect(statements.map((statement) => ({ - queryId: statement.queryId, - index: statement.statementIndex, - offset: statement.statementStartOffsetInFile, - fingerprint: statement.statementFingerprint, - statementText: statement.statementText - }))).toEqual([ - { - queryId: 'catalog.users:1', - index: 1, - offset: 0, - fingerprint: '6cb80ffe674e', - statementText: '-- comment\nSELECT id FROM users' - }, - { - queryId: 'catalog.users:2', - index: 2, - offset: 32, - fingerprint: '802a04ff843f', - statementText: 'SELECT email FROM users WHERE active = 1' - } - ]); -}); diff --git a/packages/ztd-cli/tests/queryLint.unit.test.ts b/packages/ztd-cli/tests/queryLint.unit.test.ts deleted file mode 100644 index 14b5b2256..000000000 --- a/packages/ztd-cli/tests/queryLint.unit.test.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { afterEach, expect, test, vi } from 'vitest'; -import { activeOrdersCatalog } from '../src/specs/sql/activeOrders.catalog'; -import { usersListCatalog } from '../src/specs/sql/usersList.catalog'; -import { registerQueryCommands } from '../src/commands/query'; -import { buildQueryLintReport, formatQueryLintReport } from '../src/query/lint'; -import { TAX_ALLOCATION_QUERY } from './utils/taxAllocationScenario'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); -const originalProjectRoot = process.env.ZTD_PROJECT_ROOT; - -afterEach(() => { - process.env.ZTD_PROJECT_ROOT = originalProjectRoot; -}); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function createSqlWorkspace(prefix: string, sqlRelativePath: string = path.join('src', 'sql', 'query.sql')): { - rootDir: string; - sqlFile: string; -} { - const rootDir = createTempDir(prefix); - const sqlFile = path.join(rootDir, sqlRelativePath); - mkdirSync(path.dirname(sqlFile), { recursive: true }); - return { rootDir, sqlFile }; -} - -function createJoinDirectionWorkspace(prefix: string): { - rootDir: string; - sqlFile: string; - ddlDir: string; -} { - const rootDir = createTempDir(prefix); - const ddlDir = path.join(rootDir, 'db', 'ddl'); - const sqlFile = path.join(rootDir, 'src', 'sql', 'query.sql'); - mkdirSync(path.dirname(sqlFile), { recursive: true }); - mkdirSync(ddlDir, { recursive: true }); - writeFileSync( - path.join(rootDir, 'ztd.config.json'), - JSON.stringify({ - ddlDir: 'db/ddl', - defaultSchema: 'public', - searchPath: ['public'] - }, null, 2), - 'utf8' - ); - return { rootDir, sqlFile, ddlDir }; -} - -function writeJoinDirectionUsersOrdersSchema(ddlDir: string): void { - writeFileSync( - path.join(ddlDir, 'schema.sql'), - ` - CREATE TABLE public.users ( - id integer PRIMARY KEY, - email text NOT NULL, - active integer NOT NULL - ); - - CREATE TABLE public.orders ( - id integer PRIMARY KEY, - user_id integer NOT NULL REFERENCES public.users(id), - total integer NOT NULL - ); - `, - 'utf8' - ); -} - -function writeJoinDirectionInvoiceSchema(ddlDir: string): void { - writeFileSync( - path.join(ddlDir, 'schema.sql'), - ` - CREATE TABLE public.invoice_lines ( - invoice_id integer NOT NULL, - id integer PRIMARY KEY, - amount_cents integer NOT NULL, - tax_rate_basis_points integer NOT NULL - ); - `, - 'utf8' - ); -} - -function writeJoinDirectionSchema(ddlDir: string): void { - writeFileSync( - path.join(ddlDir, 'schema.sql'), - ` - CREATE TABLE public.customers ( - customer_id integer PRIMARY KEY - ); - - CREATE TABLE public.orders ( - order_id integer PRIMARY KEY, - customer_id integer NOT NULL REFERENCES public.customers(customer_id) - ); - - CREATE TABLE public.order_items ( - order_item_id integer PRIMARY KEY, - order_id integer NOT NULL REFERENCES public.orders(order_id) - ); - - CREATE TABLE public.sales ( - sale_id integer PRIMARY KEY - ); - - CREATE TABLE public.tags ( - tag_id integer PRIMARY KEY - ); - - CREATE TABLE public.sale_item_tags ( - sale_id integer NOT NULL REFERENCES public.sales(sale_id), - tag_id integer NOT NULL REFERENCES public.tags(tag_id) - ); - `, - 'utf8' - ); -} - -function readJoinDirectionFixture(name: string): string { - return readFileSync(path.join(__dirname, 'fixtures', 'join-direction', name), 'utf8'); -} - -function createQueryLintProgram(capture: { stdout: string[]; stderr: string[] }): Command { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str) - }); - registerQueryCommands(program); - return program; -} - -test('buildQueryLintReport detects structural maintainability issues', () => { - const workspace = createSqlWorkspace('query-lint-report', path.join('src', 'sql', 'reports', 'maintainability.sql')); - const oversizedProjection = Array.from({ length: 120 }, (_, index) => ` id + ${index} as value_${index}`).join(',\n'); - writeFileSync( - workspace.sqlFile, - ` - with base_users as ( - select u.id, u.region_id - from public.users u - join public.regions r on r.id = u.region_id - where u.active = true - ), - filtered_users as ( - select u.id, u.region_id - from public.users u - join public.regions r on r.id = u.region_id - where u.active = true - ), - oversized_stage as ( - select -${oversizedProjection} - from filtered_users - ), - unused_stage as ( - select * from public.audit_log - ) - select format('select %s from users', id) - from oversized_stage - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile); - - expect(report).toMatchObject({ - file: workspace.sqlFile, - query_type: 'SELECT', - cte_count: 4 - }); - expect(report.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ type: 'unused-cte', cte: 'base_users', severity: 'warning' }), - expect.objectContaining({ type: 'unused-cte', cte: 'unused_stage', severity: 'warning' }), - expect.objectContaining({ type: 'duplicate-join-block', severity: 'warning' }), - expect.objectContaining({ type: 'duplicate-filter-predicate', severity: 'warning' }), - expect.objectContaining({ type: 'large-cte', cte: 'oversized_stage', severity: 'info' }), - expect.objectContaining({ type: 'analysis-risk', risk_pattern: 'format-sql-construction', severity: 'warning' }) - ])); -}); - -test('buildQueryLintReport does not flag legal recursive CTEs as dependency cycles', () => { - const workspace = createSqlWorkspace('query-lint-recursive'); - writeFileSync( - workspace.sqlFile, - ` - with recursive walk as ( - select id, parent_id - from public.nodes - where parent_id is null - union all - select n.id, n.parent_id - from public.nodes n - join walk w on w.id = n.parent_id - ) - select * from walk - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile); - - expect(report.issues.filter((issue) => issue.type === 'dependency-cycle')).toEqual([]); -}); - -test('buildQueryLintReport detects dependency cycles as errors', () => { - const workspace = createSqlWorkspace('query-lint-cycle'); - writeFileSync( - workspace.sqlFile, - ` - with a as ( - select * from b - ), - b as ( - select * from a - ) - select * from a - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile); - - expect(report.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'dependency-cycle', - severity: 'error', - cycle: ['a', 'b', 'a'], - message: 'invalid dependency cycle detected (a -> b -> a)' - }) - ])); -}); - -test('formatQueryLintReport renders json for agents and compact text for humans', () => { - const workspace = createSqlWorkspace('query-lint-format'); - writeFileSync( - workspace.sqlFile, - ` - with unused_stage as ( - select id from public.users - ) - select 1 - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile); - const jsonOutput = formatQueryLintReport(report, 'json'); - expect(JSON.parse(jsonOutput)).toEqual(report); - - const textOutput = formatQueryLintReport(report, 'text'); - expect(textOutput).toContain('WARN unused-cte: unused_stage is defined but never used'); -}); - -test('buildQueryLintReport warns on multiline trailing commas when leading-comma is enabled', () => { - const workspace = createSqlWorkspace('query-lint-leading-comma-trailing'); - writeFileSync( - workspace.sqlFile, - ` - select - id, - email - from public.users - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile, { - rules: ['leading-comma'] - }); - - expect(report.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'leading-comma', - severity: 'warning', - line: expect.any(Number), - column: expect.any(Number) - }) - ])); -}); - -test('buildQueryLintReport keeps multiline leading commas clean', () => { - const workspace = createSqlWorkspace('query-lint-leading-comma-clean'); - writeFileSync( - workspace.sqlFile, - ` - select - id - , email - from public.users - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile, { - rules: ['leading-comma'] - }); - - expect(report.issues.filter((issue) => issue.type === 'leading-comma')).toEqual([]); -}); - -test('buildQueryLintReport keeps one-line comma lists clean', () => { - const workspace = createSqlWorkspace('query-lint-leading-comma-oneline'); - writeFileSync(workspace.sqlFile, 'select id, email from public.users', 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - rules: ['leading-comma'] - }); - - expect(report.issues.filter((issue) => issue.type === 'leading-comma')).toEqual([]); -}); - -test('buildQueryLintReport ignores commas inside dollar-quoted SQL literals', () => { - const workspace = createSqlWorkspace('query-lint-leading-comma-dollar-quote'); - writeFileSync( - workspace.sqlFile, - ` - select - $$first, - second$$ as body - , id - from public.users - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile, { - rules: ['leading-comma'] - }); - - expect(report.issues.filter((issue) => issue.type === 'leading-comma')).toEqual([]); -}); - -test('buildQueryLintReport suppresses leading-comma when explicitly disabled in SQL text', () => { - const workspace = createSqlWorkspace('query-lint-leading-comma-suppressed'); - writeFileSync( - workspace.sqlFile, - ` - -- ztd-lint-disable leading-comma - select - id, - email - from public.users - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile, { - rules: ['leading-comma'] - }); - - expect(report.issues.filter((issue) => issue.type === 'leading-comma')).toEqual([]); -}); - -test('formatQueryLintReport exposes leading-comma line and column in json mode', () => { - const workspace = createSqlWorkspace('query-lint-leading-comma-json'); - writeFileSync( - workspace.sqlFile, - ` - select - id, - email - from public.users - `, - 'utf8' - ); - - const report = buildQueryLintReport(workspace.sqlFile, { - rules: ['leading-comma'] - }); - const payload = JSON.parse(formatQueryLintReport(report, 'json')); - - expect(payload.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'leading-comma', - severity: 'warning', - line: expect.any(Number), - column: expect.any(Number) - }) - ])); -}); - -test('buildQueryLintReport keeps the forward join-direction dogfood query clean', () => { - const workspace = createJoinDirectionWorkspace('query-lint-join-direction'); - writeJoinDirectionSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, readJoinDirectionFixture('forward.sql'), 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues.filter((issue) => issue.type === 'join-direction')).toEqual([]); -}); - -test('buildQueryLintReport warns on the reverse inner join dogfood query', () => { - const workspace = createJoinDirectionWorkspace('query-lint-join-direction-reversed'); - writeJoinDirectionSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, readJoinDirectionFixture('reverse.sql'), 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'join-direction', - severity: 'warning', - subject_table: 'public.customers', - joined_table: 'public.orders', - child_table: 'public.orders', - parent_table: 'public.customers' - }) - ]) - ); -}); - -test('buildQueryLintReport suppresses join-direction when explicitly disabled in SQL text', () => { - const workspace = createJoinDirectionWorkspace('query-lint-join-direction-suppressed'); - writeJoinDirectionSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, readJoinDirectionFixture('suppressed.sql'), 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues.filter((issue) => issue.type === 'join-direction')).toEqual([]); -}); - -test('buildQueryLintReport keeps left-join parent-first intent clean', () => { - const workspace = createJoinDirectionWorkspace('query-lint-join-direction-left'); - writeJoinDirectionSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, readJoinDirectionFixture('left-join.sql'), 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues.filter((issue) => issue.type === 'join-direction')).toEqual([]); -}); - -test('buildQueryLintReport skips bridge-table dogfood queries because many-to-many paths are intentionally exempt in v1', () => { - const workspace = createJoinDirectionWorkspace('query-lint-join-direction-bridge'); - writeJoinDirectionSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, readJoinDirectionFixture('bridge.sql'), 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues.filter((issue) => issue.type === 'join-direction')).toEqual([]); -}); - -test('buildQueryLintReport skips aggregate dogfood queries because the parent-shaped summary is intentionally ambiguous', () => { - const workspace = createJoinDirectionWorkspace('query-lint-join-direction-aggregate'); - writeJoinDirectionSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, readJoinDirectionFixture('aggregate.sql'), 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues.filter((issue) => issue.type === 'join-direction')).toEqual([]); -}); - -test('query lint command enables join-direction through --rules', async () => { - const workspace = createJoinDirectionWorkspace('query-lint-join-direction-cli'); - writeJoinDirectionSchema(workspace.ddlDir); - writeFileSync( - workspace.sqlFile, - ` - select - c.customer_id, - o.order_id - from public.customers c - join public.orders o - on o.customer_id = c.customer_id - `, - 'utf8' - ); - - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createQueryLintProgram(capture); - const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { - capture.stdout.push(String(value ?? '')); - }); - - process.env.ZTD_PROJECT_ROOT = workspace.rootDir; - try { - await program.parseAsync(['query', 'lint', workspace.sqlFile, '--rules', 'join-direction'], { from: 'user' }); - } finally { - logSpy.mockRestore(); - } - - expect(capture.stderr).toEqual([]); - expect(capture.stdout.join('')).toContain('WARN join-direction: JOIN direction is reversed for public.orders -> public.customers'); -}); - -test('query lint command emits join-direction diagnostics in json mode', async () => { - const workspace = createJoinDirectionWorkspace('query-lint-join-direction-cli-json'); - writeJoinDirectionSchema(workspace.ddlDir); - writeFileSync( - workspace.sqlFile, - ` - select - c.customer_id, - o.order_id - from public.customers c - join public.orders o - on o.customer_id = c.customer_id - `, - 'utf8' - ); - - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createQueryLintProgram(capture); - const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { - capture.stdout.push(String(value ?? '')); - }); - - process.env.ZTD_PROJECT_ROOT = workspace.rootDir; - try { - await program.parseAsync(['query', 'lint', workspace.sqlFile, '--rules', 'join-direction', '--format', 'json'], { from: 'user' }); - } finally { - logSpy.mockRestore(); - } - - expect(capture.stderr).toEqual([]); - const payload = JSON.parse(capture.stdout.join('')); - expect(payload.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'join-direction', - severity: 'warning', - parent_table: 'public.customers', - child_table: 'public.orders' - }) - ])); -}); - -test('query lint command enables leading-comma through --rules', async () => { - const workspace = createSqlWorkspace('query-lint-leading-comma-cli'); - writeFileSync( - workspace.sqlFile, - ` - select - id, - email - from public.users - `, - 'utf8' - ); - - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createQueryLintProgram(capture); - const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { - capture.stdout.push(String(value ?? '')); - }); - - try { - await program.parseAsync(['query', 'lint', workspace.sqlFile, '--rules', 'leading-comma'], { from: 'user' }); - } finally { - logSpy.mockRestore(); - } - - expect(capture.stderr).toEqual([]); - expect(capture.stdout.join('')).toContain('WARN leading-comma: comma should lead the continued line'); -}); - -test('activeOrdersCatalog.sql stays clean because it already follows child-to-parent join direction', () => { - const workspace = createJoinDirectionWorkspace('query-lint-active-orders-repo-sql'); - writeJoinDirectionUsersOrdersSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, activeOrdersCatalog.sql, 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues.filter((issue) => issue.type === 'join-direction')).toEqual([]); -}); - -test('usersListCatalog.sql is skipped because it has no join graph to evaluate', () => { - const workspace = createJoinDirectionWorkspace('query-lint-users-list-repo-sql'); - writeJoinDirectionUsersOrdersSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, usersListCatalog.sql, 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues.filter((issue) => issue.type === 'join-direction')).toEqual([]); -}); - -test('tax allocation repo SQL is skipped because the parent-shaped aggregate and LEFT JOIN make the direction ambiguous by design', () => { - const workspace = createJoinDirectionWorkspace('query-lint-tax-allocation-repo-sql'); - writeJoinDirectionInvoiceSchema(workspace.ddlDir); - writeFileSync(workspace.sqlFile, TAX_ALLOCATION_QUERY, 'utf8'); - - const report = buildQueryLintReport(workspace.sqlFile, { - projectRoot: workspace.rootDir, - rules: ['join-direction'] - }); - - expect(report.issues.filter((issue) => issue.type === 'join-direction')).toEqual([]); -}); diff --git a/packages/ztd-cli/tests/queryPatch.unit.test.ts b/packages/ztd-cli/tests/queryPatch.unit.test.ts deleted file mode 100644 index 45827c21d..000000000 --- a/packages/ztd-cli/tests/queryPatch.unit.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { applyQueryPatch } from '../src/query/patch'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function createSqlFile(rootDir: string, name: string, sql: string): string { - const filePath = path.join(rootDir, name); - writeFileSync(filePath, sql, 'utf8'); - return filePath; -} - -function readNormalizedFile(filePath: string): string { - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); -} - -test('applyQueryPatch replaces only the targeted CTE while preserving metadata from the edited SQL', () => { - const workspace = createTempDir('query-patch-apply'); - const originalFile = createSqlFile( - workspace, - 'original.sql', - ` - with users_base as ( - select id, region_id from public.users - ), - purchase_summary as ( - select id from users_base - ), - untouched_cte as ( - select * from public.audit_log - ) - select * from purchase_summary - ` - ); - const editedFile = createSqlFile( - workspace, - 'edited.sql', - ` - with users_base as ( - select id, region_id from public.users - ), - purchase_summary (user_id) as materialized ( - select id as user_id - from users_base - where region_id = 1 - ) - select * from purchase_summary - ` - ); - const outputFile = path.join(workspace, 'patched.sql'); - - const report = applyQueryPatch(originalFile, { - cte: 'purchase_summary', - from: editedFile, - out: outputFile - }); - - expect(report.changed).toBe(true); - expect(report.written).toBe(true); - expect(report.output_file).toBe(outputFile); - const patched = readNormalizedFile(outputFile); - expect(patched).toContain('"purchase_summary"("user_id") as materialized'); - expect(patched).toContain('where "region_id" = 1'); - expect(patched).toContain('"untouched_cte" as'); - expect(patched).not.toContain('select "id" from "users_base"'); -}); - -test('applyQueryPatch preview emits a unified diff without overwriting the original SQL file', () => { - const workspace = createTempDir('query-patch-preview'); - const originalFile = createSqlFile( - workspace, - 'original.sql', - ` - with target_cte as ( - select id from public.users - ) - select * from target_cte - ` - ); - const editedFile = createSqlFile( - workspace, - 'edited.sql', - 'target_cte as (select id from public.users where active = true)' - ); - const before = readNormalizedFile(originalFile); - - const report = applyQueryPatch(originalFile, { - cte: 'target_cte', - from: editedFile, - preview: true - }); - - expect(report.preview).toBe(true); - expect(report.written).toBe(false); - expect(report.diff).toContain('--- '); - expect(report.diff).toContain('+++ '); - expect(report.diff).toContain('"active" = true'); - expect(readNormalizedFile(originalFile)).toBe(before); -}); - -test('applyQueryPatch fails when the edited SQL does not contain the requested CTE', () => { - const workspace = createTempDir('query-patch-missing-target'); - const originalFile = createSqlFile( - workspace, - 'original.sql', - ` - with target_cte as ( - select id from public.users - ) - select * from target_cte - ` - ); - const editedFile = createSqlFile( - workspace, - 'edited.sql', - ` - with other_cte as ( - select id from public.users - ) - select * from other_cte - ` - ); - - expect(() => applyQueryPatch(originalFile, { - cte: 'target_cte', - from: editedFile - })).toThrow(`CTE "target_cte" was not found in ${editedFile}.`); -}); - - -test('applyQueryPatch matches the requested CTE name case-insensitively', () => { - const workspace = createTempDir('query-patch-case-insensitive'); - const originalFile = createSqlFile( - workspace, - 'original.sql', - ` - with purchase_summary as ( - select id from public.users - ) - select * from purchase_summary - ` - ); - const editedFile = createSqlFile( - workspace, - 'edited.sql', - 'purchase_summary as (select id from public.users where active = true)' - ); - - const report = applyQueryPatch(originalFile, { - cte: 'PURCHASE_SUMMARY', - from: editedFile, - preview: true - }); - - expect(report.preview).toBe(true); - expect(report.diff).toContain('"active" = true'); -}); - -test('applyQueryPatch fails when the original SQL does not contain the requested CTE', () => { - const workspace = createTempDir('query-patch-missing-original'); - const originalFile = createSqlFile( - workspace, - 'original.sql', - ` - with other_cte as ( - select id from public.users - ) - select * from other_cte - ` - ); - const editedFile = createSqlFile( - workspace, - 'edited.sql', - 'target_cte as (select id from public.users)' - ); - - expect(() => applyQueryPatch(originalFile, { - cte: 'target_cte', - from: editedFile - })).toThrow(`CTE "target_cte" was not found in ${originalFile}.`); -}); - -test('applyQueryPatch fails when the original SQL contains duplicate target CTE names', () => { - const workspace = createTempDir('query-patch-duplicate-original'); - const originalFile = createSqlFile( - workspace, - 'original.sql', - ` - with target_cte as ( - select id from public.users - ), - target_cte as ( - select id from public.orders - ) - select * from target_cte - ` - ); - const editedFile = createSqlFile( - workspace, - 'edited.sql', - 'target_cte as (select id from public.users where active = true)' - ); - - expect(() => applyQueryPatch(originalFile, { - cte: 'target_cte', - from: editedFile - })).toThrow(`CTE "target_cte" appears multiple times in ${originalFile}; patch apply requires a unique target.`); -}); - -test('applyQueryPatch fails when the edited SQL contains duplicate target CTE names', () => { - const workspace = createTempDir('query-patch-duplicate-edited'); - const originalFile = createSqlFile( - workspace, - 'original.sql', - ` - with target_cte as ( - select id from public.users - ) - select * from target_cte - ` - ); - const editedFile = createSqlFile( - workspace, - 'edited.sql', - ` - with target_cte as ( - select id from public.users - ), - target_cte as ( - select id from public.orders - ) - select * from target_cte - ` - ); - - expect(() => applyQueryPatch(originalFile, { - cte: 'target_cte', - from: editedFile - })).toThrow(`CTE "target_cte" appears multiple times in ${editedFile}; patch apply requires a unique target.`); -}); - -test('applyQueryPatch preserves the surrounding DML statement while replacing the target CTE', () => { - const workspace = createTempDir('query-patch-dml'); - const originalFile = createSqlFile( - workspace, - 'original.sql', - ` - with source_rows as ( - select id from public.users - ), - audit_rows as ( - select id from public.audit_log - ) - insert into public.user_report (user_id) - select id from source_rows - ` - ); - const editedFile = createSqlFile( - workspace, - 'edited.sql', - 'source_rows as (select id from public.users where active = true)' - ); - const outputFile = path.join(workspace, 'patched.sql'); - - const report = applyQueryPatch(originalFile, { - cte: 'source_rows', - from: editedFile, - out: outputFile - }); - - expect(report.written).toBe(true); - const patched = readNormalizedFile(outputFile); - expect(patched).toContain('insert into'); - expect(patched).toContain('"user_report"'); - expect(patched).toContain('"active" = true'); - expect(patched).toContain('"audit_rows" as'); -}); diff --git a/packages/ztd-cli/tests/queryPlanner.unit.test.ts b/packages/ztd-cli/tests/queryPlanner.unit.test.ts deleted file mode 100644 index 19ae28565..000000000 --- a/packages/ztd-cli/tests/queryPlanner.unit.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { buildQueryPipelinePlan, formatQueryPipelinePlan } from '../src/query/planner'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function createSqlWorkspace(prefix: string, sqlRelativePath: string = path.join('src', 'sql', 'query.sql')): { - rootDir: string; - sqlFile: string; -} { - const rootDir = createTempDir(prefix); - const sqlFile = path.join(rootDir, sqlRelativePath); - mkdirSync(path.dirname(sqlFile), { recursive: true }); - return { rootDir, sqlFile }; -} - -test('buildQueryPipelinePlan emits deterministic ordered steps from metadata', () => { - const workspace = createSqlWorkspace('query-pipeline-plan', path.join('src', 'sql', 'reports', 'pipeline.sql')); - writeFileSync( - workspace.sqlFile, - ` - with base_users as ( - select id, region_id - from public.users - ), - filtered_users as ( - select id - from base_users - where region_id is not null - ), - ranked_users as ( - select id - from filtered_users - ) - select * - from ranked_users - where sale_date > ( - select p.closed_year_month - from public.parameters p - ) - `, - 'utf8' - ); - - const plan = buildQueryPipelinePlan(workspace.sqlFile, { - material: ['ranked_users', 'filtered_users'], - scalarFilterColumns: ['sale_date'] - }); - - expect(plan).toMatchObject({ - file: workspace.sqlFile, - query_type: 'SELECT', - final_query: 'ranked_users', - metadata: { - material: ['ranked_users', 'filtered_users'], - scalarFilterColumns: ['sale_date'] - } - }); - expect(plan.steps).toEqual([ - { - step: 1, - kind: 'materialize', - target: 'filtered_users', - depends_on: ['base_users'] - }, - { - step: 2, - kind: 'materialize', - target: 'ranked_users', - depends_on: ['filtered_users'] - }, - { - step: 3, - kind: 'final-query', - target: 'FINAL_QUERY', - depends_on: ['ranked_users'] - } - ]); -}); - -test('buildQueryPipelinePlan marks RETURNING CTE materialization steps explicitly', () => { - const workspace = createSqlWorkspace('query-pipeline-returning-plan'); - writeFileSync( - workspace.sqlFile, - ` - with source_rows as ( - select id - from pending_events - ), - inserted_rows as ( - insert into audit_log (id) - select id - from source_rows - returning id - ) - select * - from inserted_rows - `, - 'utf8' - ); - - const plan = buildQueryPipelinePlan(workspace.sqlFile, { - material: ['inserted_rows'] - }); - - expect(plan.steps).toEqual([ - { - step: 1, - kind: 'materialize-returning', - target: 'inserted_rows', - depends_on: ['source_rows'] - }, - { - step: 2, - kind: 'final-query', - target: 'FINAL_QUERY', - depends_on: ['inserted_rows'] - } - ]); - - expect(formatQueryPipelinePlan(plan, 'text')).toContain('1. materialize returning inserted_rows'); -}); - -test('buildQueryPipelinePlan keeps non-returning DML CTEs as normal materialize steps for execution fail-fast', () => { - const workspace = createSqlWorkspace('query-pipeline-dml-no-returning-plan'); - writeFileSync( - workspace.sqlFile, - ` - with inserted_rows as ( - insert into audit_log (id) - select id - from pending_events - ) - select 1 - `, - 'utf8' - ); - - const plan = buildQueryPipelinePlan(workspace.sqlFile, { - material: ['inserted_rows'] - }); - - expect(plan.steps[0]).toMatchObject({ - kind: 'materialize', - target: 'inserted_rows' - }); -}); - -test('formatQueryPipelinePlan renders json for agents and text for humans', () => { - const workspace = createSqlWorkspace('query-pipeline-format'); - writeFileSync( - workspace.sqlFile, - ` - with base_data as ( - select id - from public.users - ), - final_data as ( - select id - from base_data - ) - select * - from final_data - `, - 'utf8' - ); - - const plan = buildQueryPipelinePlan(workspace.sqlFile, { - material: ['final_data'], - scalarFilterColumns: ['sale_date'] - }); - - const jsonOutput = formatQueryPipelinePlan(plan, 'json'); - expect(JSON.parse(jsonOutput)).toEqual(plan); - - const textOutput = formatQueryPipelinePlan(plan, 'text'); - expect(textOutput).toContain('Query type: SELECT'); - expect(textOutput).toContain('Material CTEs: final_data'); - expect(textOutput).toContain('Scalar filter columns: sale_date'); - expect(textOutput).toContain('1. materialize final_data'); - expect(textOutput).toContain('2. run final query'); -}); - -test('buildQueryPipelinePlan rejects metadata that references unknown CTE names', () => { - const workspace = createSqlWorkspace('query-pipeline-invalid'); - writeFileSync( - workspace.sqlFile, - ` - with base_data as ( - select id - from public.users - ) - select * - from base_data - `, - 'utf8' - ); - - expect(() => - buildQueryPipelinePlan(workspace.sqlFile, { - material: ['missing_cte'] - }) - ).toThrow('Unknown material CTE: missing_cte'); -}); diff --git a/packages/ztd-cli/tests/querySlice.unit.test.ts b/packages/ztd-cli/tests/querySlice.unit.test.ts deleted file mode 100644 index 2ac3a210a..000000000 --- a/packages/ztd-cli/tests/querySlice.unit.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { buildQuerySliceReport } from '../src/query/slice'; - -function createSqlFile(prefix: string, sql: string): string { - const workspace = mkdtempSync(path.join(tmpdir(), `${prefix}-`)); - const sqlFile = path.join(workspace, 'query.sql'); - writeFileSync(sqlFile, sql, 'utf8'); - return sqlFile; -} - -test('buildQuerySliceReport emits the minimal dependency chain for a target CTE', () => { - const sqlFile = createSqlFile( - 'query-slice-cte', - ` - with users_base as ( - select id, region_id from public.users - ), - filtered_users as ( - select id from users_base where region_id = 1 - ), - purchase_summary as ( - select fu.id, count(*) as order_count - from filtered_users fu - join public.orders o on o.user_id = fu.id - group by fu.id - ), - final_projection as ( - select * from purchase_summary - ), - unused_cte as ( - select * from public.audit_log - ) - select * from final_projection - ` - ); - - const result = buildQuerySliceReport(sqlFile, { cte: 'purchase_summary' }); - - expect(result.mode).toBe('cte'); - expect(result.included_ctes).toEqual(['users_base', 'filtered_users', 'purchase_summary']); - expect(result.sql).toContain('"users_base" as'); - expect(result.sql).toContain('"filtered_users" as'); - expect(result.sql).toContain('"purchase_summary" as'); - expect(result.sql).toContain('from "purchase_summary"'); - expect(result.sql).not.toContain('final_projection'); - expect(result.sql).not.toContain('unused_cte'); -}); - -test('buildQuerySliceReport preserves CommonTable formatting metadata', () => { - const sqlFile = createSqlFile( - 'query-slice-metadata', - ` - with named_rows (user_id) as materialized ( - select id from public.users - ), - final_rows as ( - select user_id from named_rows - ) - select * from final_rows - ` - ); - - const result = buildQuerySliceReport(sqlFile, { cte: 'named_rows' }); - - expect(result.sql).toContain('"named_rows"("user_id")'); - expect(result.sql).toContain('materialized'); -}); - -test('buildQuerySliceReport preserves the minimized final query', () => { - const sqlFile = createSqlFile( - 'query-slice-final', - ` - with base_data as ( - select id, status from public.users - ), - filtered_data as ( - select id from base_data where status = 'active' - ), - unused_data as ( - select id from public.audit_log - ) - select id from filtered_data order by id - ` - ); - - const result = buildQuerySliceReport(sqlFile, { final: true }); - - expect(result.mode).toBe('final'); - expect(result.included_ctes).toEqual(['base_data', 'filtered_data']); - expect(result.sql).toContain('"base_data" as'); - expect(result.sql).toContain('"filtered_data" as'); - expect(result.sql).toContain('from "filtered_data"'); - expect(result.sql).not.toContain('unused_data'); -}); - -test('buildQuerySliceReport treats excluded CTEs as traversal stops for final slices', () => { - const sqlFile = createSqlFile( - 'query-slice-final-excluded-stops', - ` - with upstream_seed as ( - select id from public.users - ), - materialized_stage as ( - select id from upstream_seed - ), - final_rows as ( - select id from materialized_stage - ) - select * from final_rows - ` - ); - - const result = buildQuerySliceReport(sqlFile, { final: true, excludeCtes: ['materialized_stage'] }); - - expect(result.included_ctes).toEqual(['final_rows']); - expect(result.sql).not.toContain('upstream_seed'); - expect(result.sql).not.toContain('materialized_stage as ('); -}); - -test('buildQuerySliceReport applies LIMIT to target CTE slices', () => { - const sqlFile = createSqlFile( - 'query-slice-limit', - ` - with base_data as ( - select id from public.users - ), - target_data as ( - select id from base_data - ) - select * from target_data - ` - ); - - const result = buildQuerySliceReport(sqlFile, { cte: 'target_data', limit: 25 }); - - expect(result.included_ctes).toEqual(['base_data', 'target_data']); - expect(result.sql).toContain('from "target_data"'); - expect(result.sql).toContain('limit 25'); -}); - -test('buildQuerySliceReport supports DML final slices while removing unused CTEs', () => { - const sqlFile = createSqlFile( - 'query-slice-insert-final', - ` - with source_rows as ( - select id from public.users - ), - audit_rows as ( - select id from public.audit_log - ) - insert into public.user_report (user_id) - select id from source_rows - ` - ); - - const result = buildQuerySliceReport(sqlFile, { final: true }); - - expect(result.mode).toBe('final'); - expect(result.included_ctes).toEqual(['source_rows']); - expect(result.sql).toContain('"source_rows" as'); - expect(result.sql).toContain('insert into'); - expect(result.sql).toContain('"user_report"'); - expect(result.sql).not.toContain('audit_rows'); -}); - -test('buildQuerySliceReport preserves CTEs referenced from DML predicate subqueries', () => { - const sqlFile = createSqlFile( - 'query-slice-update-exists', - ` - with target_rows as ( - select id from public.users where active = true - ), - audit_rows as ( - select id from public.audit_log - ) - update public.users u - set status = 'verified' - where exists ( - select 1 from target_rows tr where tr.id = u.id - ) - ` - ); - - const result = buildQuerySliceReport(sqlFile, { final: true }); - - expect(result.mode).toBe('final'); - expect(result.included_ctes).toEqual(['target_rows']); - expect(result.sql).toContain('"target_rows" as'); - expect(result.sql).toContain('update'); - expect(result.sql).toContain('exists'); - expect(result.sql).not.toContain('audit_rows'); -}); diff --git a/packages/ztd-cli/tests/queryStructure.unit.test.ts b/packages/ztd-cli/tests/queryStructure.unit.test.ts deleted file mode 100644 index 93b9fbb70..000000000 --- a/packages/ztd-cli/tests/queryStructure.unit.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { buildQueryStructureReport, formatQueryStructureReport } from '../src/query/structure'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function createSqlWorkspace(prefix: string, sqlRelativePath: string = path.join('src', 'sql', 'query.sql')): { - rootDir: string; - sqlFile: string; -} { - const rootDir = createTempDir(prefix); - const sqlFile = path.join(rootDir, sqlRelativePath); - mkdirSync(path.dirname(sqlFile), { recursive: true }); - return { rootDir, sqlFile }; -} - -test('buildQueryStructureReport extracts CTE dependency relationships without executing SQL', () => { - const workspace = createSqlWorkspace('query-structure-report', path.join('src', 'sql', 'reports', 'dependency_graph.sql')); - writeFileSync( - workspace.sqlFile, - ` - with regional_users as ( - select u.id, u.region_id - from public.users u - ), - active_regions as ( - select id - from public.regions - where active = true - ), - filtered_users as ( - select ru.id - from regional_users ru - join active_regions ar on ar.id = ru.region_id - ), - purchase_summary as ( - select o.user_id, count(*) as order_count - from public.orders o - join filtered_users fu on fu.id = o.user_id - group by o.user_id - ), - orphaned_audit as ( - select * from public.audit_log - ) - select * - from purchase_summary - `, - 'utf8' - ); - - const report = buildQueryStructureReport(workspace.sqlFile); - - expect(report).toMatchObject({ - query_type: 'SELECT', - file: workspace.sqlFile, - cte_count: 5, - final_query: 'purchase_summary', - unused_ctes: ['orphaned_audit'] - }); - expect(report.ctes).toEqual([ - { - name: 'regional_users', - depends_on: [], - used_by_final_query: true, - unused: false - }, - { - name: 'active_regions', - depends_on: [], - used_by_final_query: true, - unused: false - }, - { - name: 'filtered_users', - depends_on: ['active_regions', 'regional_users'], - used_by_final_query: true, - unused: false - }, - { - name: 'purchase_summary', - depends_on: ['filtered_users'], - used_by_final_query: true, - unused: false - }, - { - name: 'orphaned_audit', - depends_on: [], - used_by_final_query: false, - unused: true - } - ]); - expect(report.referenced_tables).toEqual([ - 'public.audit_log', - 'public.orders', - 'public.regions', - 'public.users' - ]); -}); - -test('formatQueryStructureReport renders json for agents and text for humans', () => { - const workspace = createSqlWorkspace('query-structure-format'); - writeFileSync( - workspace.sqlFile, - ` - with base_data as ( - select id - from public.users - ), - final_data as ( - select id - from base_data - ) - select * - from final_data - `, - 'utf8' - ); - - const report = buildQueryStructureReport(workspace.sqlFile); - - // Keep the contract explicit for machine consumers that need stable fields. - const jsonOutput = formatQueryStructureReport(report, 'json'); - expect(JSON.parse(jsonOutput)).toEqual(report); - - // Keep the text rendering readable when a developer inspects the graph manually. - const textOutput = formatQueryStructureReport(report, 'text'); - expect(textOutput).toContain('Query type: SELECT'); - expect(textOutput).toContain('CTE count: 2'); - expect(textOutput).toContain('1. base_data'); - expect(textOutput).toContain('2. final_data'); - expect(textOutput).toContain('depends_on: base_data'); - expect(textOutput).toContain('Final query target:'); - expect(textOutput).toContain('final_data'); - expect(textOutput).toContain('Referenced tables:'); - expect(textOutput).toContain('public.users'); -}); - -test('buildQueryStructureReport reports the caller command name on unsupported input', () => { - const workspace = createSqlWorkspace('query-graph-unsupported'); - writeFileSync(workspace.sqlFile, 'create table public.users (id integer primary key)', 'utf8'); - - expect(() => buildQueryStructureReport(workspace.sqlFile, 'ztd query graph')).toThrow( - 'ztd query graph supports SELECT/INSERT/UPDATE/DELETE statements only.' - ); -}); diff --git a/packages/ztd-cli/tests/queryTaxAllocation.ztd.test.ts b/packages/ztd-cli/tests/queryTaxAllocation.ztd.test.ts deleted file mode 100644 index 0a70926c7..000000000 --- a/packages/ztd-cli/tests/queryTaxAllocation.ztd.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { execSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; -import { Client } from 'pg'; -import { executeQueryPipeline } from '../src/query/execute'; -import { buildQueryPipelinePlan } from '../src/query/planner'; -import { - TAX_ALLOCATION_CASES, - TAX_ALLOCATION_FIXTURE_ROWS, - TAX_ALLOCATION_METADATA, - TAX_ALLOCATION_QUERY, -} from './utils/taxAllocationScenario'; - -const containerRuntimeAvailable = (() => { - try { - execSync('docker info', { stdio: 'ignore', timeout: 10000 }); - return true; - } catch { - return false; - } -})(); - -const ztdDescribe = containerRuntimeAvailable ? describe : describe.skip; -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function normalizeRows(rows: Array>): Array<{ id: number; amount_cents: number; allocated_tax_cents: number }> { - return rows.map((row) => ({ - id: Number(row.id), - amount_cents: Number(row.amount_cents), - allocated_tax_cents: Number(row.allocated_tax_cents), - })); -} - -ztdDescribe('tax allocation dogfood scenario', () => { - let container: StartedPostgreSqlContainer | null = null; - let sqlFile = ''; - - beforeAll(async () => { - container = await new PostgreSqlContainer('postgres:18-alpine').start(); - const workspace = createTempDir('tax-allocation-dogfood'); - sqlFile = path.join(workspace, 'src', 'sql', 'tax_allocation.sql'); - mkdirSync(path.dirname(sqlFile), { recursive: true }); - writeFileSync(sqlFile, TAX_ALLOCATION_QUERY, 'utf8'); - - const client = await createConnectedClient(container); - try { - await client.query(` - create table public.invoice_lines ( - invoice_id integer not null, - id integer primary key, - amount_cents integer not null, - tax_rate_basis_points integer not null - ) - `); - - for (const row of TAX_ALLOCATION_FIXTURE_ROWS) { - await client.query( - `insert into public.invoice_lines (invoice_id, id, amount_cents, tax_rate_basis_points) values ($1, $2, $3, $4)`, - [row.invoice_id, row.id, row.amount_cents, row.tax_rate_basis_points] - ); - } - } finally { - await client.end(); - } - }, 120000); - - afterAll(async () => { - if (container) { - await container.stop(); - } - }); - - test.each(TAX_ALLOCATION_CASES)('pipeline preserves tax allocation result: $label', async ({ invoiceId, expectedRows }) => { - const directRows = await runDirectAllocation(invoiceId, sqlFile, container); - const pipelineResult = await runPipelineAllocation(invoiceId, sqlFile, container); - - expect(directRows).toEqual(expectedRows); - expect(pipelineResult.finalRows).toEqual(expectedRows); - expect(pipelineResult.openSessionCount).toBe(1); - expect(pipelineResult.steps.map((step) => step.kind)).toEqual([ - 'materialize', - 'materialize', - 'materialize', - 'scalar-filter-bind', - 'final-query', - ]); - }, 120000); - - test('tax allocation dogfood picks natural split points and rewrites the remainder filter', async () => { - const plan = buildQueryPipelinePlan(sqlFile, TAX_ALLOCATION_METADATA); - expect(plan.steps).toEqual([ - { step: 1, kind: 'materialize', target: 'input_lines', depends_on: [] }, - { step: 2, kind: 'materialize', target: 'floored_allocations', depends_on: ['raw_tax_basis'] }, - { step: 3, kind: 'materialize', target: 'ranked_allocations', depends_on: ['floored_allocations'] }, - { step: 4, kind: 'final-query', target: 'FINAL_QUERY', depends_on: ['final_allocations'] }, - ]); - - const pipelineResult = await runPipelineAllocation(2, sqlFile, container); - const finalSql = normalizeSql( - [...pipelineResult.history].reverse().find((entry) => !entry.sql.startsWith('drop table if exists'))?.sql ?? '' - ); - const flooredStageSql = normalizeSql(pipelineResult.history.find((entry) => entry.sql.includes('from "input_lines"'))?.sql ?? ''); - const rankedStageSql = normalizeSql(pipelineResult.history.find((entry) => entry.sql.includes('from "floored_allocations"'))?.sql ?? ''); - - expect(flooredStageSql).toContain('from "input_lines"'); - expect(rankedStageSql).toContain('from "floored_allocations"'); - expect(finalSql).toContain('from "ranked_allocations" as "ranked"'); - expect(finalSql).toMatch(/where "allocation_rank" <= \$1/); - expect(finalSql).not.toContain('from "floored_allocations"'); - expect(finalSql).not.toContain('from "input_lines"'); - }, 120000); -}); - -async function runDirectAllocation( - invoiceId: number, - sqlFile: string, - container: StartedPostgreSqlContainer | null -): Promise> { - const client = await createConnectedClient(container); - try { - const sql = readFileSync(sqlFile, 'utf8').trimEnd(); - const result = await client.query(sql, [invoiceId]); - return normalizeRows(result.rows as Array>); - } finally { - await client.end(); - } -} - -async function runPipelineAllocation( - invoiceId: number, - sqlFile: string, - container: StartedPostgreSqlContainer | null -): Promise<{ - finalRows: Array<{ id: number; amount_cents: number; allocated_tax_cents: number }>; - openSessionCount: number; - steps: Awaited>['steps']; - history: Array<{ sql: string; params?: unknown[] | Record }>; -}> { - const history: Array<{ sql: string; params?: unknown[] | Record }> = []; - let openSessionCount = 0; - - const result = await executeQueryPipeline( - { - openSession: async () => { - openSessionCount += 1; - const client = await createConnectedClient(container); - return { - query: async (sql: string, params?: unknown[] | Record) => { - history.push({ sql, params }); - const result = await client.query(sql, params as unknown[] | undefined); - return { - rows: result.rows as Array>, - rowCount: result.rowCount, - }; - }, - end: async () => { - await client.end(); - }, - }; - }, - }, - { - sqlFile, - metadata: TAX_ALLOCATION_METADATA, - params: [invoiceId], - } - ); - - return { - finalRows: normalizeRows(result.final.rows), - openSessionCount, - steps: result.steps, - history, - }; -} - -async function createConnectedClient(container: StartedPostgreSqlContainer | null): Promise { - if (!container) { - throw new Error('Postgres container is not initialized for tax allocation dogfood tests.'); - } - - const client = new Client({ connectionString: container.getConnectionUri() }); - await client.connect(); - return client; -} - -function normalizeSql(sql: string): string { - return sql.replace(/\s+/g, ' ').trim().toLowerCase(); -} - diff --git a/packages/ztd-cli/tests/queryUses.unit.test.ts b/packages/ztd-cli/tests/queryUses.unit.test.ts deleted file mode 100644 index a60c0ff0e..000000000 --- a/packages/ztd-cli/tests/queryUses.unit.test.ts +++ /dev/null @@ -1,1239 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { Command } from 'commander'; -import { afterEach, expect, test, vi } from 'vitest'; -import { registerQueryCommands } from '../src/commands/query'; -import { applyQueryOutputControls, formatQueryUsageReport } from '../src/query/format'; -import { buildQueryUsageReport } from '../src/query/report'; -import { parseQueryTarget } from '../src/query/targets'; - -function createWorkspace(prefix: string): string { - const root = mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - mkdirSync(path.join(root, 'src', 'catalog', 'specs'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'sql'), { recursive: true }); - return root; -} - -const originalProjectRoot = process.env.ZTD_PROJECT_ROOT; - -afterEach(() => { - process.env.ZTD_PROJECT_ROOT = originalProjectRoot; -}); - -function createProgram(capture: { stdout: string[]; stderr: string[] }): Command { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str) - }); - registerQueryCommands(program); - return program; -} - - -test('query commands keep outline and graph subcommands registered', () => { - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const queryCommand = program.commands.find((command) => command.name() === 'query'); - - expect(queryCommand?.commands.map((command) => command.name())).toEqual( - expect.arrayContaining(['uses', 'outline', 'graph', 'plan']) - ); -}); -test('parseQueryTarget enforces strict defaults and explicit relaxed modes', () => { - expect(() => parseQueryTarget({ kind: 'table', raw: 'users' })).toThrow(/schema\.table/); - expect(() => parseQueryTarget({ kind: 'table', raw: 'public.users', anySchema: true, anyTable: true })).toThrow(/not supported for table usage/); - expect(() => parseQueryTarget({ kind: 'column', raw: 'users.email' })).toThrow(/schema\.table\.column/); - expect(() => parseQueryTarget({ kind: 'column', raw: 'email', anyTable: true })).toThrow(/requires --any-schema/); - - expect(parseQueryTarget({ kind: 'column', raw: 'public.users.email' })).toEqual({ - mode: 'exact', - target: { kind: 'column', raw: 'public.users.email', schema: 'public', table: 'users', column: 'email' } - }); - expect(parseQueryTarget({ kind: 'column', raw: 'users.email', anySchema: true })).toEqual({ - mode: 'any-schema', - target: { kind: 'column', raw: 'users.email', table: 'users', column: 'email' } - }); - expect(parseQueryTarget({ kind: 'column', raw: 'email', anySchema: true, anyTable: true })).toEqual({ - mode: 'any-schema-any-table', - target: { kind: 'column', raw: 'email', column: 'email' } - }); -}); - -test('impact view aggregates table usage by statement', () => { - const root = createWorkspace('query-uses-table-impact'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - [ - 'SELECT u.email FROM public.users u;', - 'SELECT o.id FROM public.orders o JOIN public.users u ON u.id = o.user_id;', - 'UPDATE public.users SET email = $1 WHERE id = $2;', - 'DELETE FROM public.users WHERE id = $1;', - 'INSERT INTO public.users (email) VALUES ($1);', - 'SELECT * FROM (SELECT id FROM public.users) nested;', - 'WITH active_users AS (SELECT id FROM public.users) SELECT id FROM active_users;' - ].join('\n'), - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.users', - rootDir: root - }); - - expect(report.schemaVersion).toBe(2); - expect(report.view).toBe('impact'); - expect(report.summary).toMatchObject({ - catalogsScanned: 1, - statementsScanned: 7, - matches: 7, - fallbackMatches: 0, - parseWarnings: 0 - }); - expect(report.matches.every((match) => match.kind === 'impact')).toBe(true); - expect(report.matches.map((match) => ({ - queryId: match.query_id, - usageKinds: match.kind === 'impact' ? match.usageKindCounts : {}, - confidence: match.confidence, - notes: match.notes - }))).toEqual([ - { queryId: 'catalog.users:1', usageKinds: { from: 1 }, confidence: 'high', notes: [] }, - { queryId: 'catalog.users:2', usageKinds: { join: 1 }, confidence: 'high', notes: [] }, - { queryId: 'catalog.users:3', usageKinds: { 'update-target': 1 }, confidence: 'high', notes: [] }, - { queryId: 'catalog.users:4', usageKinds: { 'delete-target': 1 }, confidence: 'high', notes: [] }, - { queryId: 'catalog.users:5', usageKinds: { 'insert-target': 1 }, confidence: 'high', notes: [] }, - { queryId: 'catalog.users:6', usageKinds: { 'subquery-from': 1 }, confidence: 'high', notes: [] }, - { queryId: 'catalog.users:7', usageKinds: { 'cte-body-from': 1 }, confidence: 'high', notes: [] } - ]); - expect(formatQueryUsageReport(report, 'json')).toContain('"view": "impact"'); - expect(formatQueryUsageReport(report, 'text')).toContain('Affected queries:'); -}); - -test('project-wide discovery finds feature-local specs across multiple slices without sql-root flags', () => { - const root = createWorkspace('query-uses-vsa-project-wide'); - mkdirSync(path.join(root, 'src', 'features', 'users', 'persistence'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'features', 'orders', 'persistence'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'features', 'users', 'persistence', 'users.spec.ts'), - [ - 'export const usersSpec = {', - " id: 'features.users.persistence.users',", - " sqlFile: './users.sql',", - " params: { shape: 'named', example: { id: 1 } }", - '};' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'orders', 'persistence', 'orders.spec.ts'), - [ - 'export const ordersSpec = {', - " id: 'features.orders.persistence.orders',", - " sqlFile: './orders.sql',", - " params: { shape: 'named', example: { id: 1 } }", - '};' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'users', 'persistence', 'users.sql'), - 'SELECT u.email FROM public.users u;', - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'orders', 'persistence', 'orders.sql'), - 'SELECT o.id FROM public.orders o JOIN public.users u ON u.id = o.user_id;', - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.users', - rootDir: root, - }); - - expect(report.summary).toMatchObject({ - catalogsScanned: 2, - statementsScanned: 2, - matches: 2, - unresolvedSqlFiles: 0, - }); - expect(report.matches).toEqual([ - expect.objectContaining({ - kind: 'impact', - catalog_id: 'features.orders.persistence.orders', - sql_file: 'src/features/orders/persistence/orders.sql', - usageKindCounts: { join: 1 }, - }), - expect.objectContaining({ - kind: 'impact', - catalog_id: 'features.users.persistence.users', - sql_file: 'src/features/users/persistence/users.sql', - usageKindCounts: { from: 1 }, - }), - ]); -}); - -test('query usage report discovers scaffolded feature-local queryspec files that use loadSqlResource', () => { - const root = createWorkspace('query-uses-scaffolded-feature-local'); - mkdirSync(path.join(root, 'src', 'features', 'users-insert', 'insert-users'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'features', 'users-insert', 'insert-users', 'queryspec.ts'), - [ - "import { loadSqlResource } from '../../_shared/loadSqlResource';", - '', - "const insertUsersSqlResource = loadSqlResource(__dirname, 'insert-users.sql');", - '', - 'export async function executeInsertUsersQuerySpec() {', - ' return insertUsersSqlResource;', - '}', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'users-insert', 'insert-users', 'insert-users.sql'), - 'insert into public.users (email) values (:email) returning user_id;', - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'users.email', - rootDir: root, - specsDir: 'src/features/users-insert', - anySchema: true, - view: 'detail' - }); - - expect(report.summary).toMatchObject({ - catalogsScanned: 1, - statementsScanned: 1, - matches: 1, - unresolvedSqlFiles: 0 - }); - expect(report.warnings).toEqual([]); - expect(report.matches).toEqual([ - expect.objectContaining({ - kind: 'detail', - sql_file: 'src/features/users-insert/insert-users/insert-users.sql' - }) - ]); -}); - -test('query uses command accepts --scope-dir for boundary-first narrowing', async () => { - const root = createWorkspace('query-uses-scope-dir-command'); - mkdirSync(path.join(root, 'src', 'features', 'users-insert', 'insert-users'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'features', 'users-insert', 'insert-users', 'queryspec.ts'), - [ - "import { loadSqlResource } from '../../_shared/loadSqlResource';", - '', - "const insertUsersSqlResource = loadSqlResource(__dirname, 'insert-users.sql');", - '', - 'export async function executeInsertUsersQuerySpec() {', - ' return insertUsersSqlResource;', - '}', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'users-insert', 'insert-users', 'insert-users.sql'), - 'insert into public.users (email) values (:email) returning user_id;', - 'utf8' - ); - process.env.ZTD_PROJECT_ROOT = root; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { - capture.stdout.push(String(value ?? '')); - }); - - await program.parseAsync( - [ - 'query', - 'uses', - 'column', - 'users.email', - '--scope-dir', - 'src/features/users-insert', - '--any-schema', - '--view', - 'detail', - '--format', - 'json' - ], - { from: 'user' } - ); - logSpy.mockRestore(); - - const parsed = JSON.parse(capture.stdout.join('')); - expect(parsed.summary).toMatchObject({ - catalogsScanned: 1, - matches: 1, - }); - expect(parsed.matches).toEqual([ - expect.objectContaining({ - kind: 'detail', - sql_file: 'src/features/users-insert/insert-users/insert-users.sql', - }), - ]); - expect(capture.stderr).toEqual([]); -}); - -test('impact view resolves sql-root-relative sqlFile values before the legacy spec-relative fallback', () => { - const root = createWorkspace('query-uses-sql-root-relative'); - mkdirSync(path.join(root, 'src', 'sql', 'sales'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'sales.spec.ts'), - [ - 'export const salesSpec = {', - " id: 'sales.byId',", - " sqlFile: 'sales/get-sale-by-id.sql',", - " params: { shape: 'named', example: { sale_id: 'sale-001' } }", - '};' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'sales', 'get-sale-by-id.sql'), - 'SELECT p.name FROM public.sales s LEFT JOIN public.products p ON p.id = s.sale_id;', - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.products', - rootDir: root - }); - - expect(report.summary).toMatchObject({ - catalogsScanned: 1, - statementsScanned: 1, - matches: 1, - unresolvedSqlFiles: 0 - }); - expect(report.matches).toEqual([ - expect.objectContaining({ - kind: 'impact', - catalog_id: 'sales.byId', - query_id: 'sales.byId:1', - sql_file: 'src/sql/sales/get-sale-by-id.sql', - usageKindCounts: { join: 1 } - }) - ]); - expect(report.warnings).toEqual([]); - expect(formatQueryUsageReport(report, 'text')).toContain('unresolved sql files: 0'); -}); - -test('query usage report defaults to impact view unless detail is requested explicitly', () => { - const root = createWorkspace('query-uses-default-impact'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'users.sql'), 'SELECT email FROM public.users;', 'utf8'); - - const defaultReport = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users.email', - rootDir: root - }); - const explicitImpactReport = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users.email', - rootDir: root, - view: 'impact' - }); - const detailReport = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users.email', - rootDir: root, - view: 'detail' - }); - - expect(defaultReport.view).toBe('impact'); - expect(defaultReport.matches.every((match) => match.kind === 'impact')).toBe(true); - expect(explicitImpactReport).toEqual(defaultReport); - expect(detailReport.view).toBe('detail'); - expect(detailReport.matches.every((match) => match.kind === 'detail')).toBe(true); -}); - -test('query usage output controls can emit summary-only and limited reports', () => { - const root = createWorkspace('query-uses-output-controls'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - [ - 'SELECT email FROM public.users;', - 'SELECT email FROM public.users WHERE email IS NOT NULL;' - ].join('\n'), - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users.email', - rootDir: root, - view: 'detail' - }); - const summaryOnly = applyQueryOutputControls(report, { summaryOnly: true }); - const limited = applyQueryOutputControls(report, { limit: 1 }); - - expect(summaryOnly.matches).toEqual([]); - expect(summaryOnly.display).toMatchObject({ - summaryOnly: true, - totalMatches: report.matches.length, - returnedMatches: 0, - truncated: true - }); - expect(formatQueryUsageReport(summaryOnly, 'text')).toContain('summary only: true'); - - expect(limited.matches).toHaveLength(1); - expect(limited.display).toMatchObject({ - summaryOnly: false, - limit: 1, - returnedMatches: 1, - truncated: true - }); -}); - -test('query usage report can exclude generated specs while keeping the default scan set unchanged', () => { - const root = createWorkspace('query-uses-exclude-generated'); - mkdirSync(path.join(root, 'src', 'catalog', 'specs', 'generated'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'sql', 'sales'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'sales.spec.ts'), - [ - 'export const salesSpec = {', - " id: 'sales.byId',", - " sqlFile: 'sales/get-sale-by-id.sql',", - " params: { shape: 'named', example: { sale_id: 'sale-001' } }", - '};' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'generated', 'sales.generated.spec.ts'), - [ - 'export const generatedSalesSpec = {', - " id: 'sales.generatedById',", - " sqlFile: 'sales/get-sale-by-id.sql',", - " params: { shape: 'named', example: { sale_id: 'sale-001' } }", - '};' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'sales', 'get-sale-by-id.sql'), - 'SELECT p.name FROM public.sales s LEFT JOIN public.products p ON p.id = s.sale_id;', - 'utf8' - ); - - const includedReport = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.products', - rootDir: root - }); - const excludedReport = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.products', - rootDir: root, - excludeGenerated: true - }); - - expect(includedReport.summary).toMatchObject({ - catalogsScanned: 2, - matches: 2, - unresolvedSqlFiles: 0 - }); - expect(includedReport.matches.map((match) => match.catalog_id)).toEqual([ - 'sales.byId', - 'sales.generatedById' - ]); - expect(excludedReport.summary).toMatchObject({ - catalogsScanned: 1, - matches: 1, - unresolvedSqlFiles: 0 - }); - expect(excludedReport.matches.map((match) => match.catalog_id)).toEqual(['sales.byId']); -}); - -test('query uses command accepts --json payload options and target', async () => { - const root = createWorkspace('query-uses-json-command'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - [ - 'SELECT email FROM public.users;', - 'SELECT email FROM public.users WHERE email IS NOT NULL;' - ].join('\n'), - 'utf8' - ); - process.env.ZTD_PROJECT_ROOT = root; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { - capture.stdout.push(String(value ?? '')); - }); - - await program.parseAsync( - [ - 'query', - 'uses', - 'column', - '--json', - JSON.stringify({ - target: 'public.users.email', - format: 'json', - view: 'detail', - summaryOnly: true - }) - ], - { from: 'user' } - ); - logSpy.mockRestore(); - - const parsed = JSON.parse(capture.stdout.join('')); - expect(parsed).toMatchObject({ - schemaVersion: 2, - view: 'detail', - target: { - kind: 'column', - raw: 'public.users.email' - }, - display: { - summaryOnly: true, - totalMatches: 3, - returnedMatches: 0, - truncated: true - } - }); - expect(parsed.matches).toEqual([]); - expect(capture.stderr).toEqual([]); -}); - -test('query uses command rejects removed --specs-dir alias as an unknown option', async () => { - const root = createWorkspace('query-uses-deprecated-specs-dir'); - mkdirSync(path.join(root, 'src', 'features', 'users', 'persistence'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'features', 'users', 'persistence', 'queryspec.ts'), - [ - "import { loadSqlResource } from '../../_shared/loadSqlResource';", - '', - "const usersSqlResource = loadSqlResource(__dirname, 'users.sql');", - '', - 'export async function executeUsersQuerySpec() {', - ' return usersSqlResource;', - '}', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'users', 'persistence', 'users.sql'), - 'select email from public.users;', - 'utf8' - ); - process.env.ZTD_PROJECT_ROOT = root; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - await expect( - program.parseAsync( - [ - 'query', - 'uses', - 'column', - 'users.email', - '--specs-dir', - 'src/features/users/persistence', - '--any-schema', - '--format', - 'json' - ], - { from: 'user' } - ) - ).rejects.toMatchObject({ - code: 'commander.unknownOption' - }); - expect(capture.stdout).toEqual([]); - expect(capture.stderr.join('')).toContain("error: unknown option '--specs-dir'"); -}); - -test('query uses command rejects removed specsDir JSON payload alias', async () => { - const root = createWorkspace('query-uses-json-deprecated-specs-dir'); - mkdirSync(path.join(root, 'src', 'features', 'users', 'persistence'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'features', 'users', 'persistence', 'queryspec.ts'), - [ - "import { loadSqlResource } from '../../_shared/loadSqlResource';", - '', - "const usersSqlResource = loadSqlResource(__dirname, 'users.sql');", - '', - 'export async function executeUsersQuerySpec() {', - ' return usersSqlResource;', - '}', - '' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'users', 'persistence', 'users.sql'), - 'select email from public.users;', - 'utf8' - ); - process.env.ZTD_PROJECT_ROOT = root; - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createProgram(capture); - - await expect( - program.parseAsync( - [ - 'query', - 'uses', - 'column', - '--json', - JSON.stringify({ - target: 'users.email', - specsDir: 'src/features/users/persistence', - anySchema: true, - format: 'json' - }) - ], - { from: 'user' } - ) - ).rejects.toThrow(/scopeDir|--scope-dir|specsDir|--specs-dir/); - expect(capture.stdout).toEqual([]); -}); - -test('query usage report excludes generated specs under a custom specsDir', () => { - const root = createWorkspace('query-uses-exclude-generated-custom-specs'); - const specsDir = path.join(root, 'custom', 'specs'); - mkdirSync(path.join(specsDir, 'generated'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'sql', 'sales'), { recursive: true }); - writeFileSync( - path.join(specsDir, 'sales.spec.ts'), - [ - 'export const salesSpec = {', - " id: 'sales.byId',", - " sqlFile: 'sales/get-sale-by-id.sql',", - " params: { shape: 'named', example: { sale_id: 'sale-001' } }", - '};' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(specsDir, 'generated', 'sales.generated.spec.ts'), - [ - 'export const generatedSalesSpec = {', - " id: 'sales.generatedById',", - " sqlFile: 'sales/get-sale-by-id.sql',", - " params: { shape: 'named', example: { sale_id: 'sale-001' } }", - '};' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'sales', 'get-sale-by-id.sql'), - 'SELECT p.name FROM public.sales s LEFT JOIN public.products p ON p.id = s.sale_id;', - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.products', - rootDir: root, - specsDir: 'custom/specs', - excludeGenerated: true - }); - - expect(report.summary).toMatchObject({ - catalogsScanned: 1, - matches: 1, - unresolvedSqlFiles: 0 - }); - expect(report.matches.map((match) => match.catalog_id)).toEqual(['sales.byId']); -}); - -test('table impact ignores RETURNING-only statements when the target table is never referenced', () => { - const root = createWorkspace('query-uses-table-ignore-returning'); - mkdirSync(path.join(root, 'src', 'sql', 'sales'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'sales.spec.ts'), - [ - 'export const salesSpec = {', - " id: 'sales.mutations',", - " sqlFile: 'sales/mutations.sql',", - " params: { shape: 'named', example: { sale_id: 'sale-001' } }", - '};' - ].join('\n'), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'sales', 'mutations.sql'), - [ - 'insert into public.sales (id) values (:sale_id) returning id;', - 'update public.sales set id = :sale_id where id = :sale_id returning id;', - 'delete from public.sales where id = :sale_id returning id;' - ].join('\n'), - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.sale_discounts', - rootDir: root - }); - - expect(report.summary).toMatchObject({ - statementsScanned: 3, - matches: 0, - unresolvedSqlFiles: 0 - }); - expect(report.matches).toEqual([]); -}); - -test('impact view keeps high confidence for exact table matches with quoted identifiers', () => { - const root = createWorkspace('query-uses-table-quoted'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - 'SELECT * FROM "public"."users";\nSELECT * FROM public . users;', - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.users', - rootDir: root - }); - - expect(report.matches).toEqual([ - expect.objectContaining({ - kind: 'impact', - query_id: 'catalog.users:1', - confidence: 'high', - notes: [], - usageKindCounts: { from: 1 } - }), - expect.objectContaining({ - kind: 'impact', - query_id: 'catalog.users:2', - confidence: 'high', - notes: [], - usageKindCounts: { from: 1 } - }) - ]); -}); - -test('detail view keeps per-occurrence rows and fixes clause-aware column locations', () => { - const root = createWorkspace('query-uses-column-detail'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - [ - 'SELECT u.email FROM public.users u;', - 'SELECT id FROM public.users WHERE email = $1 ORDER BY email;', - 'UPDATE public.users SET email = $1 RETURNING email;', - 'INSERT INTO public.users (email) VALUES ($1);' - ].join('\n'), - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users.email', - rootDir: root, - view: 'detail' - }); - - expect(report.view).toBe('detail'); - expect(report.matches.every((match) => match.kind === 'detail')).toBe(true); - - const orderBy = report.matches.find((match) => match.kind === 'detail' && match.usage_kind === 'order-by'); - const where = report.matches.find((match) => match.kind === 'detail' && match.usage_kind === 'where'); - const returning = report.matches.find((match) => match.kind === 'detail' && match.usage_kind === 'returning'); - const updateSet = report.matches.find((match) => match.kind === 'detail' && match.usage_kind === 'update-set'); - - expect(where?.snippet).toBe('WHERE email = $1'); - expect(orderBy?.snippet).toBe('ORDER BY email'); - expect(where?.location?.fileOffsetStart).toBeLessThan(orderBy?.location?.fileOffsetStart ?? 0); - expect(returning?.snippet).toBe('RETURNING email'); - expect(updateSet?.snippet).toBe('SET email = $1'); - expect((returning?.location?.fileOffsetStart ?? 0)).toBeGreaterThan(updateSet?.location?.fileOffsetStart ?? 0); - expect(formatQueryUsageReport(report, 'text')).toContain('Primary matches:'); -}); - -test('detail view unwraps nested paren sources when collecting subquery column usage', () => { - const root = createWorkspace('query-uses-column-nested-paren-source'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - 'SELECT nested.email FROM ((SELECT email FROM public.users) ) nested;', - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users.email', - rootDir: root, - view: 'detail' - }); - - expect(report.matches).toEqual(expect.arrayContaining([ - expect.objectContaining({ - kind: 'detail', - usage_kind: 'subquery' - }) - ])); -}); - -test('impact view aggregates confidence and notes for relaxed column investigation', () => { - const root = createWorkspace('query-uses-relaxed-impact'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'users.sql'), 'SELECT email FROM public.users ORDER BY email;', 'utf8'); - - const report = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'email', - rootDir: root, - anySchema: true, - anyTable: true - }); - - expect(report.mode).toBe('any-schema-any-table'); - expect(report.target).toEqual({ - kind: 'column', - raw: 'email', - column: 'email' - }); - const match = report.matches[0]; - expect(match?.kind).toBe('impact'); - if (match?.kind === 'impact') { - expect(match.confidence).toBe('low'); - expect(match.notes).toEqual(expect.arrayContaining([ - 'relaxed-match-any-schema', - 'relaxed-match-any-table', - 'statement-has-unqualified-column' - ])); - expect(match.usageKindCounts).toEqual({ 'order-by': 1, select: 1 }); - expect(match.representatives?.map((representative) => representative.usage_kind)).toEqual(['order-by']); - } -}); - -test('impact view keeps select counts but omits select representatives', () => { - const root = createWorkspace('query-uses-impact-select-representatives'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - 'SELECT email FROM public.users WHERE email = $1 ORDER BY email;', - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users.email', - rootDir: root - }); - - const match = report.matches[0]; - expect(match?.kind).toBe('impact'); - if (match?.kind === 'impact') { - expect(match.usageKindCounts).toEqual({ 'order-by': 1, select: 1, where: 1 }); - expect(match.representatives?.map((representative) => representative.usage_kind)).toEqual(['order-by', 'where']); - } -}); - -test('detail view emits parse warnings, fallback rows, and no-catalog guidance deterministically', () => { - const root = createWorkspace('query-uses-warnings'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'a.spec.json'), - JSON.stringify({ id: 'catalog.a', sqlFile: '../../sql/a.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'b.spec.json'), - JSON.stringify({ id: 'catalog.b', sqlFile: '../../sql/missing.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'UPDATE public.users SET email =', 'utf8'); - - const tableReport = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.users', - rootDir: root, - view: 'detail' - }); - - expect(tableReport.summary).toMatchObject({ - parseWarnings: 1, - unresolvedSqlFiles: 1, - fallbackMatches: 1 - }); - expect(tableReport.matches).toEqual(expect.arrayContaining([ - expect.objectContaining({ - kind: 'detail', - source: 'fallback', - usage_kind: 'update-target', - confidence: 'low', - notes: expect.arrayContaining(['parser-fallback']) - }) - ])); - expect(tableReport.warnings).toEqual([ - { - catalog_id: 'catalog.a', - code: 'parse-failed', - message: expect.any(String), - query_id: 'catalog.a:1', - sql_file: 'src/sql/a.sql' - }, - { - catalog_id: 'catalog.b', - code: 'unresolved-sql-file', - message: expect.stringContaining('SQL file does not exist: ../../sql/missing.sql'), - sql_file: 'src/sql/missing.sql' - } - ]); - expect(tableReport.warnings[1]?.message).toContain('Tried spec-relative path: src/sql/missing.sql'); - expect(tableReport.warnings[1]?.message).toContain( - 'Tried project-relative path: ../../sql/missing.sql' - ); - expect(tableReport.warnings[1]?.message).toContain('prefer feature-local spec-relative sqlFile values'); - expect(formatQueryUsageReport(tableReport, 'text')).toContain('Fallback-derived matches:'); - - const emptyRoot = createWorkspace('query-uses-empty'); - const emptyReport = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.users', - rootDir: emptyRoot - }); - expect(emptyReport.summary.matches).toBe(0); - expect(emptyReport.warnings).toEqual([ - { - code: 'no-catalog-specs-found', - message: expect.stringContaining('No QuerySpec entries were discovered under .') - } - ]); - expect(formatQueryUsageReport(emptyReport, 'text')).toContain('No QuerySpec entries were discovered under .'); - expect(formatQueryUsageReport(emptyReport, 'text')).toContain('Use --scope-dir only when you need to narrow the scan.'); -}); - -test('table impact finds tables referenced from EXISTS subqueries through their FROM nodes', () => { - const root = createWorkspace('query-uses-table-exists-subquery'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'sales.spec.json'), - JSON.stringify({ id: 'catalog.sales', sqlFile: '../../sql/sales.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'sales.sql'), - [ - 'SELECT s.id', - 'FROM public.sales s', - 'WHERE EXISTS (', - ' SELECT 1', - ' FROM public.sale_items si', - ' WHERE si.sale_id = s.id', - ');' - ].join('\n'), - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.sale_items', - rootDir: root, - view: 'detail' - }); - - expect(report.summary).toMatchObject({ - matches: 1, - unresolvedSqlFiles: 0 - }); - expect(report.matches).toEqual([ - expect.objectContaining({ - kind: 'detail', - catalog_id: 'catalog.sales', - usage_kind: 'subquery-from', - sql_file: 'src/sql/sales.sql' - }) - ]); -}); - -test('table detail anchors EXISTS subquery matches to the nested table token', () => { - const root = createWorkspace('query-uses-table-detail-exists-anchor'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'sales.spec.json'), - JSON.stringify({ id: 'catalog.sales', sqlFile: '../../sql/sales.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - const sql = [ - 'SELECT s.id', - 'FROM public.sales s', - 'WHERE EXISTS (', - ' SELECT 1', - ' FROM public.sale_items si', - ' WHERE si.sale_id = s.id', - ');' - ].join('\n'); - writeFileSync(path.join(root, 'src', 'sql', 'sales.sql'), sql, 'utf8'); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.sale_items', - rootDir: root, - view: 'detail' - }); - - expect(report.matches).toHaveLength(1); - const match = report.matches[0]; - expect(match?.kind).toBe('detail'); - if (match?.kind === 'detail') { - const expectedOffset = sql.indexOf('public.sale_items'); - expect(expectedOffset).toBeGreaterThanOrEqual(0); - expect(match.location?.statementOffsetStart).toBe(expectedOffset); - expect(match.location?.statementOffsetEnd).toBe(expectedOffset + 'public.sale_items'.length); - expect(match.snippet).toContain('public.sale_items'); - expect(match.snippet).toBe('FROM public.sale_items si'); - } -}); - -test('impact view still resolves legacy spec-relative sqlFile values for backward compatibility', () => { - const root = createWorkspace('query-uses-spec-relative-fallback'); - mkdirSync(path.join(root, 'spec-assets'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../../spec-assets/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'spec-assets', 'users.sql'), 'SELECT * FROM public.users;', 'utf8'); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.users', - rootDir: root - }); - - expect(report.summary).toMatchObject({ - matches: 1, - unresolvedSqlFiles: 0 - }); - expect(report.matches).toEqual([ - expect.objectContaining({ - kind: 'impact', - catalog_id: 'catalog.users', - sql_file: 'spec-assets/users.sql', - usageKindCounts: { from: 1 } - }) - ]); -}); - -test('table detail anchors join matches to the table token and keeps the table line in the snippet', () => { - const root = createWorkspace('query-uses-table-detail-join-anchor'); - mkdirSync(path.join(root, 'src', 'sql', 'sales'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'sales.spec.ts'), - [ - 'export const salesSpec = {', - " id: 'sales.byId',", - " sqlFile: 'sales/get-sale-by-id.sql',", - " params: { shape: 'named', example: { sale_id: 'sale-001' } }", - '};' - ].join('\n'), - 'utf8' - ); - const sql = [ - 'select', - ' s.id as sale_id,', - ' string_agg(p.name, \', \' order by si.line_no) as product_names', - 'from public.sales as s', - 'left join public.sale_items as si', - ' on si.sale_id = s.id', - 'left join public.products as p', - ' on p.id = si.product_id' - ].join('\n'); - writeFileSync(path.join(root, 'src', 'sql', 'sales', 'get-sale-by-id.sql'), sql, 'utf8'); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.products', - rootDir: root, - view: 'detail' - }); - - expect(report.matches).toHaveLength(1); - const match = report.matches[0]; - expect(match?.kind).toBe('detail'); - if (match?.kind === 'detail') { - const expectedOffset = sql.indexOf('public.products'); - expect(expectedOffset).toBeGreaterThanOrEqual(0); - expect(match.location?.statementOffsetStart).toBe(expectedOffset); - expect(match.location?.statementOffsetEnd).toBe(expectedOffset + 'public.products'.length); - expect(match.snippet).toContain('public.products'); - expect(match.snippet).toBe('left join public.products as p'); - } -}); - -test('query usage report isolates spec load failures per file', () => { - const root = createWorkspace('query-uses-spec-load-failure'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'broken.spec.json'), - '{', - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'users.sql'), 'SELECT * FROM public.users;', 'utf8'); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.users', - rootDir: root - }); - - expect(report.summary.matches).toBe(1); - expect(report.warnings).toEqual(expect.arrayContaining([ - expect.objectContaining({ - code: 'spec-load-failed', - sql_file: 'src/catalog/specs/broken.spec.json' - }) - ])); -}); - -test('column usage ignores qualified wildcards from non-target relations', () => { - const root = createWorkspace('query-uses-column-qualified-wildcard'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - 'SELECT orders.* FROM public.users users JOIN public.orders orders ON orders.user_id = users.id;', - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users.email', - rootDir: root, - view: 'detail' - }); - - expect(report.matches).toEqual([]); - expect(report.summary.matches).toBe(0); -}); - -test('table usage finds scalar subqueries referenced from SELECT projections', () => { - const root = createWorkspace('query-uses-table-select-subquery'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'sales.spec.json'), - JSON.stringify({ id: 'catalog.sales', sqlFile: '../../sql/sales.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'sales.sql'), - [ - 'SELECT (', - ' SELECT count(*)', - ' FROM public.sale_items si', - ' WHERE si.sale_id = s.id', - ') AS item_count', - 'FROM public.sales s;' - ].join('\n'), - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'table', - rawTarget: 'public.sale_items', - rootDir: root, - view: 'detail' - }); - - expect(report.matches).toEqual([ - expect.objectContaining({ - kind: 'detail', - usage_kind: 'subquery-from', - catalog_id: 'catalog.sales' - }) - ]); -}); - -test('column usage traverses DELETE USING subqueries', () => { - const root = createWorkspace('query-uses-column-delete-using-subquery'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'catalog.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'sql', 'users.sql'), - [ - 'DELETE FROM public.users u', - 'USING (', - ' SELECT id', - ' FROM public.users_archive', - ' WHERE email IS NOT NULL', - ') archived', - 'WHERE archived.id = u.id;' - ].join('\n'), - 'utf8' - ); - - const report = buildQueryUsageReport({ - kind: 'column', - rawTarget: 'public.users_archive.email', - rootDir: root, - view: 'detail' - }); - - expect(report.matches).toEqual([ - expect.objectContaining({ - kind: 'detail', - usage_kind: 'subquery', - catalog_id: 'catalog.users' - }) - ]); -}); diff --git a/packages/ztd-cli/tests/releaseReadiness.unit.test.ts b/packages/ztd-cli/tests/releaseReadiness.unit.test.ts deleted file mode 100644 index c2d682718..000000000 --- a/packages/ztd-cli/tests/releaseReadiness.unit.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const { - classifyReleaseReadiness, - evaluateChangesetGuardrail, - listPendingChangesetFiles, - readPullRequestContext, - readPullRequestLabels, -} = require('../../../scripts/release-readiness.js') as { - classifyReleaseReadiness( - changedFiles: string[], - ): { - releaseAffecting: boolean; - changedFiles: string[]; - matchedFiles: Array<{ filePath: string; kinds: string[] }>; - matchedKinds: string[]; - }; - evaluateChangesetGuardrail(params: { - releaseAffecting: boolean; - changesetFiles: string[]; - labelNames: string[]; - }): { - guardrailRequired: boolean; - guardrailPassed: boolean; - hasChangeset: boolean; - hasNoReleaseLabel: boolean; - }; - listPendingChangesetFiles(rootDir: string): string[]; - readPullRequestContext(eventPath: string): { - isReleasePr: boolean; - headRef: string; - title: string; - authorLogin: string; - labelNames: string[]; - }; - readPullRequestLabels(eventPath: string): string[]; -}; - -test('release-readiness matches package surface, publish workflow, and release-note paths', () => { - const classification = classifyReleaseReadiness([ - 'packages/ztd-cli/src/commands/query.ts', - '.github/workflows/publish.yml', - '.changeset/release-readiness.md', - ]); - - expect(classification.releaseAffecting).toBe(true); - expect(classification.matchedKinds).toEqual([ - 'package-surface', - 'publish-workflow', - 'release-notes', - ]); -}); - -test('release-readiness treats package source and package READMEs as release-affecting', () => { - const classification = classifyReleaseReadiness([ - 'packages/core/src/transformers/SelectValueCollector.ts', - 'packages/ztd-cli/README.md', - 'docs/guide/getting-started.md', - ]); - - expect(classification.releaseAffecting).toBe(true); - expect(classification.matchedKinds).toEqual(['package-surface']); - expect(classification.matchedFiles).toEqual([ - { - filePath: 'packages/core/src/transformers/SelectValueCollector.ts', - kinds: ['package-surface'], - }, - { - filePath: 'packages/ztd-cli/README.md', - kinds: ['package-surface'], - }, - { - filePath: 'docs/guide/getting-started.md', - kinds: ['package-surface'], - }, - ]); -}); - -test('release-readiness matches package manifest changes as publish-shape changes', () => { - const classification = classifyReleaseReadiness([ - 'packages/ztd-cli/package.json', - ]); - - expect(classification.releaseAffecting).toBe(true); - expect(classification.matchedFiles).toEqual([ - { - filePath: 'packages/ztd-cli/package.json', - kinds: ['package-surface'], - }, - ]); -}); - -test('release-readiness matches nested package manifests as publish-shape changes', () => { - const classification = classifyReleaseReadiness([ - 'packages/adapters/adapter-node-pg/package.json', - 'packages/adapters/adapter-node-pg/CHANGELOG.md', - ]); - - expect(classification.releaseAffecting).toBe(true); - expect(classification.matchedKinds).toEqual(['package-surface']); - expect(classification.matchedFiles).toEqual([ - { - filePath: 'packages/adapters/adapter-node-pg/package.json', - kinds: ['package-surface'], - }, - { - filePath: 'packages/adapters/adapter-node-pg/CHANGELOG.md', - kinds: ['package-surface'], - }, - ]); -}); - -test('release-readiness treats publish helper changes as release-affecting', () => { - const classification = classifyReleaseReadiness([ - 'scripts/build-publish-artifacts.mjs', - 'scripts/create-publish-proof-plan.mjs', - 'scripts/verify-published-package-mode.mjs', - ]); - - expect(classification.releaseAffecting).toBe(true); - expect(classification.matchedKinds).toEqual(['publish-workflow']); -}); - -test('release-readiness ignores ordinary package tests and docs outside the checklist', () => { - const classification = classifyReleaseReadiness([ - 'packages/ztd-cli/tests/queryLint.unit.test.ts', - 'docs/guide/overview.md', - ]); - - expect(classification.releaseAffecting).toBe(false); - expect(classification.matchedKinds).toEqual([]); - expect(classification.matchedFiles).toEqual([]); -}); - -test('listPendingChangesetFiles ignores README-like markdown files', () => { - const rootDir = mkdtempSync(path.join(os.tmpdir(), 'release-readiness-changesets-')); - const changesetDir = path.join(rootDir, '.changeset'); - mkdirSync(changesetDir, { recursive: true }); - writeFileSync(path.join(changesetDir, 'README.md'), '# notes\n', 'utf8'); - writeFileSync(path.join(changesetDir, 'olive-wolves-jump.md'), '---\n---\n', 'utf8'); - writeFileSync(path.join(changesetDir, '.hidden.md'), 'ignored\n', 'utf8'); - writeFileSync(path.join(changesetDir, 'config.json'), '{}\n', 'utf8'); - - expect(listPendingChangesetFiles(rootDir)).toEqual([ - '.changeset/olive-wolves-jump.md', - ]); -}); - -test('readPullRequestLabels returns sorted label names from a pull_request payload', () => { - const rootDir = mkdtempSync(path.join(os.tmpdir(), 'release-readiness-labels-')); - const eventPath = path.join(rootDir, 'event.json'); - writeFileSync(eventPath, JSON.stringify({ - pull_request: { - labels: [ - { name: 'z-release' }, - { name: 'no-release' }, - { name: 'A-label' }, - ], - }, - }), 'utf8'); - - expect(readPullRequestLabels(eventPath)).toEqual([ - 'A-label', - 'no-release', - 'z-release', - ]); -}); - -test('readPullRequestContext detects release PR metadata from the event payload', () => { - const rootDir = mkdtempSync(path.join(os.tmpdir(), 'release-readiness-context-')); - const eventPath = path.join(rootDir, 'event.json'); - writeFileSync(eventPath, JSON.stringify({ - pull_request: { - head: { ref: 'changeset-release/main' }, - title: 'chore(release): version packages', - user: { login: 'github-actions[bot]' }, - labels: [ - { name: 'z-release' }, - { name: 'A-label' }, - ], - }, - }), 'utf8'); - - expect(readPullRequestContext(eventPath)).toEqual({ - isReleasePr: true, - headRef: 'changeset-release/main', - title: 'chore(release): version packages', - authorLogin: 'github-actions[bot]', - labelNames: [ - 'A-label', - 'z-release', - ], - }); -}); - -test('changeset guardrail fails release-affecting PRs without a changeset or no-release label', () => { - expect( - evaluateChangesetGuardrail({ - releaseAffecting: true, - changesetFiles: [], - labelNames: [], - }), - ).toEqual({ - guardrailRequired: true, - guardrailPassed: false, - hasChangeset: false, - hasNoReleaseLabel: false, - isReleasePr: false, - }); -}); - -test('changeset guardrail passes when a release-affecting PR includes a changeset', () => { - expect( - evaluateChangesetGuardrail({ - releaseAffecting: true, - changesetFiles: ['.changeset/example.md'], - labelNames: [], - }), - ).toEqual({ - guardrailRequired: true, - guardrailPassed: true, - hasChangeset: true, - hasNoReleaseLabel: false, - isReleasePr: false, - }); -}); - -test('changeset guardrail passes when a release-affecting PR carries the no-release label', () => { - expect( - evaluateChangesetGuardrail({ - releaseAffecting: true, - changesetFiles: [], - labelNames: ['no-release'], - }), - ).toEqual({ - guardrailRequired: true, - guardrailPassed: true, - hasChangeset: false, - hasNoReleaseLabel: true, - isReleasePr: false, - }); -}); - -test('changeset guardrail skips the pending changeset requirement for release PRs', () => { - expect( - evaluateChangesetGuardrail({ - releaseAffecting: true, - changesetFiles: [], - labelNames: [], - isReleasePr: true, - }), - ).toEqual({ - guardrailRequired: false, - guardrailPassed: true, - hasChangeset: false, - hasNoReleaseLabel: false, - isReleasePr: true, - }); -}); diff --git a/packages/ztd-cli/tests/repoGuidance.unit.test.ts b/packages/ztd-cli/tests/repoGuidance.unit.test.ts deleted file mode 100644 index 202051e8d..000000000 --- a/packages/ztd-cli/tests/repoGuidance.unit.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = resolve(__dirname, '../../..'); - -function readText(relativePath: string): string { - return readFileSync(resolve(repoRoot, relativePath), 'utf8'); -} - -test('repo-local Codex guidance files exist and point at developer workflows', () => { - expect(existsSync(resolve(repoRoot, '.codex', 'config.toml'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.codex', 'agents', 'planning.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.codex', 'agents', 'verification.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.codex', 'agents', 'review.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.codex', 'agents', 'reporting.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.agents', 'skills', 'acceptance-planning', 'SKILL.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.agents', 'skills', 'self-review', 'SKILL.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.agents', 'skills', 'package-spec-review', 'SKILL.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.agents', 'skills', 'attainment-reporting', 'SKILL.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.agents', 'skills', 'structured-metadata-migration-review', 'SKILL.md'))).toBe(true); - expect(existsSync(resolve(repoRoot, '.agents', 'skills', 'broad-generated-diff-review-packet', 'SKILL.md'))).toBe(true); -}); - -test('planning guidance covers acceptance items and verification methods', () => { - const planningSkill = readText('.agents/skills/acceptance-planning/SKILL.md'); - const planningAgent = readText('.codex/agents/planning.md'); - const verificationAgent = readText('.codex/agents/verification.md'); - - expect(planningSkill).toContain('Source issue'); - expect(planningSkill).toContain('Why it matters'); - expect(planningSkill).toContain('Acceptance items'); - expect(planningSkill).toContain('Decision points'); - expect(planningSkill).toContain('Verification methods'); - expect(planningAgent).toContain('State the source issue or request and why it matters.'); - expect(planningAgent).toContain('Source issue'); - expect(planningAgent).toContain('Why it matters'); - expect(planningAgent).toContain('Decision points, when relevant'); - expect(planningAgent).toContain('Define explicit acceptance items.'); - expect(planningAgent).toContain('Attach a concrete verification method to each acceptance item.'); - expect(verificationAgent).toContain('State verification basis when the evidence needs interpretation.'); - expect(verificationAgent).toContain('Confirm whether the planned verification methods were actually satisfied; do not silently replace them.'); -}); - -test('reporting guidance covers reviewer-facing and operator-facing reporting shape', () => { - const reportingSkill = readText('.agents/skills/attainment-reporting/SKILL.md'); - const reviewSkill = readText('.agents/skills/self-review/SKILL.md'); - const packageSpecSkill = readText('.agents/skills/package-spec-review/SKILL.md'); - const structuredMetadataSkill = readText('.agents/skills/structured-metadata-migration-review/SKILL.md'); - const broadGeneratedDiffSkill = readText('.agents/skills/broad-generated-diff-review-packet/SKILL.md'); - const reportingAgent = readText('.codex/agents/reporting.md'); - const reviewAgent = readText('.codex/agents/review.md'); - const rootAgents = readText('AGENTS.md'); - const mirrorAgents = readText('.agent/AGENTS.md'); - - expect(reportingSkill).toContain('Source request or source issue'); - expect(reportingSkill).toContain('Why it matters'); - expect(reportingSkill).toContain('What changed'); - expect(reportingSkill).toContain('Decision points'); - expect(reportingSkill).toContain('Verification basis'); - expect(reportingSkill).toContain('Guarantee limits'); - expect(reportingSkill).toContain('Outstanding gaps'); - expect(reportingSkill).toContain('What the human should decide next'); - expect(reportingSkill).toContain('describe the meaning of the change before naming files'); - expect(reportingSkill).toContain('what observation was treated as sufficient'); - expect(reportingSkill).toContain('prefer a narrow accept-or-defer style choice'); - expect(reportingSkill).toContain('The final report form MUST include every acceptance item as `acceptance item`, `status`, `evidence`, and `gap`.'); - expect(reportingSkill).toContain('The final PR text and normal work report MUST show those per-item fields directly'); - expect(reportingSkill).toContain('do not use local filesystem links such as `/C:/...`'); - expect(reportingSkill).toContain('treat the final form as incomplete'); - expect(reportingSkill).toContain('Distinguish `tests were updated` from `tests passed`.'); - expect(reportingSkill).toContain('keep the affected item `partial` or `not done`'); - expect(reportingSkill).toContain('pass consistency review and human acceptance review'); - expect(reportingSkill).toContain('Review findings MUST be triaged as `blocker`, `follow-up`, or `nit`.'); - expect(reportingSkill).toContain('`Repository evidence` MUST be the primary evidence class for acceptance judgment.'); - expect(reportingSkill).toContain('`Supplementary evidence` means local logs, external observations'); - expect(reportingSkill).toContain('`Supplementary evidence` alone MUST NOT justify a strong `done` claim'); - expect(reportingSkill).toContain('Mapping each acceptance item to `done`, `partial`, or `not done`.'); - expect(reportingAgent).toContain('The report is a decision document, not a work log.'); - expect(reportingAgent).toContain('Source issue or request'); - expect(reportingAgent).toContain('Why it matters'); - expect(reportingAgent).toContain('What changed'); - expect(reportingAgent).not.toContain('Decision points'); - expect(reportingAgent).toContain('Verification basis'); - expect(reportingAgent).toContain('Guarantee limits'); - expect(reportingAgent).toContain('Outstanding gaps'); - expect(reportingAgent).toContain('What the human should decide next'); - expect(reportingAgent).toContain('explain the meaning of the change before listing touched files'); - expect(reportingAgent).toContain('What the human should decide next`, phrased as a narrow choice whenever possible.'); - expect(reportingAgent).not.toContain('what observation was treated as enough'); - expect(reportingAgent).toContain('phrased as a narrow choice whenever possible.'); - expect(reportingAgent).not.toContain('The final PR text must leave those fields visible per item'); - expect(reportingAgent).not.toContain('The same per-item final form is required for normal Codex work reports'); - expect(reportingAgent).not.toContain('do not emit local filesystem links such as `/C:/...`'); - expect(reportingAgent).not.toContain('the final form is incomplete and must be corrected'); - expect(reportingAgent).toContain('Keep `tests were updated`, `tests passed`, and `execution remains partial` separate'); - expect(reportingAgent).not.toContain('pass consistency review and human acceptance review'); - expect(reportingAgent).not.toContain('Review findings must be triaged as `blocker`, `follow-up`, or `nit`.'); - expect(reportingAgent).toContain('`Repository evidence` is the primary basis for acceptance judgment in PR-facing text.'); - expect(reportingAgent).toContain('`Supplementary evidence` must be labeled as supplementary and must not be presented as equivalent to repository evidence.'); - expect(reportingAgent).toContain('keep it `partial` or narrow the claim with explicit guarantee limits'); - expect(reportingAgent).not.toContain('Map each plan-time acceptance item to `done`, `partial`, or `not done`.'); - expect(reviewSkill).toContain('consistency review'); - expect(reviewSkill).toContain('human acceptance review'); - expect(reviewSkill).toContain('`blocker`, `follow-up`, or `nit`'); - expect(reviewSkill).toContain('Run both review cycles before claiming readiness for human review.'); - expect(packageSpecSkill).toContain('Package Review Authority Model'); - expect(packageSpecSkill).toContain('Package Technology Policy'); - expect(packageSpecSkill).toContain('technology-policy exception warnings'); - expect(packageSpecSkill).toContain('Web UI as a transfer package front-facing surface'); - expect(structuredMetadataSkill).toContain('schemaVersion'); - expect(structuredMetadataSkill).toContain('canonical enum'); - expect(structuredMetadataSkill).toContain('real structured parser'); - expect(structuredMetadataSkill).toContain('Evidence and display-label'); - expect(broadGeneratedDiffSkill).toContain('Review Packet Shape'); - expect(broadGeneratedDiffSkill).toContain('review-tool limits'); - expect(broadGeneratedDiffSkill).toContain('source-to-generated traceability'); - expect(reviewAgent).toContain('Review Cycle 1: Consistency Review'); - expect(reviewAgent).toContain('Review Cycle 2: Human Acceptance Review'); - expect(reviewAgent).toContain('.agents/skills/package-spec-review/SKILL.md'); - expect(reviewAgent).toContain('.agents/skills/structured-metadata-migration-review/SKILL.md'); - expect(reviewAgent).toContain('.agents/skills/broad-generated-diff-review-packet/SKILL.md'); - expect(reviewAgent).toContain('Triage Rules'); - expect(reviewAgent).toContain('Unsupported `done` claims based mainly on supplementary evidence are blockers.'); - expect(reviewAgent).toContain('If a blocker remains, the result is not ready for human review.'); - expect(rootAgents).toContain('Keep assistant-user conversation in Japanese in this repository.'); - expect(rootAgents).toContain('Keep scaffold code, scaffold-facing docs, and published-package smoke checks aligned when they describe the same workflow.'); - expect(rootAgents).toContain('.agents/skills/package-spec-review/SKILL.md'); - expect(rootAgents).toContain('.agents/skills/structured-metadata-migration-review/SKILL.md'); - expect(rootAgents).toContain('.agents/skills/broad-generated-diff-review-packet/SKILL.md'); - expect(rootAgents).toContain('Keep structured metadata sources, schema files, implementation allowlists, fixtures, and generated review views aligned'); - expect(rootAgents).toContain('Broad generated docs or API diffs should preserve source-to-generated traceability'); - expect(rootAgents).toContain('Do not turn `AGENTS.md` into the storage location for starter walkthroughs, AI onboarding prompts, dogfooding playbooks, or investigation scripts; keep those in dedicated docs or skills.'); - expect(rootAgents).toContain('For local-source dogfooding or scaffold developer-mode flows, fail fast when dependencies or CLI entrypoints are missing and make the next recovery step explicit.'); - expect(rootAgents).toContain('Do not overwrite scaffold-owned or user-authored files without an explicit force path; failed initialization must not leave partial overwrites behind.'); - expect(rootAgents).toContain('Final user-facing progress and completion reports should use explicit sections rather than long narrative-only blocks when multiple concerns are being reported.'); - expect(rootAgents).toContain('Final PR text and final implementation reports must pass self-review before human review.'); - expect(rootAgents).not.toContain('Review findings MUST be triaged as `blocker`, `follow-up`, or `nit`.'); - expect(rootAgents).not.toContain('Reports MUST distinguish `Repository evidence` from `Supplementary evidence` when both appear.'); - expect(rootAgents).not.toContain('PR reports MUST treat `Repository evidence` as the primary basis for acceptance judgment.'); - expect(rootAgents).toContain('Supplementary evidence alone must not justify a strong `done` claim.'); - expect(mirrorAgents).toContain('All assistant-user conversation in this repository must be in Japanese.'); - expect(mirrorAgents).toContain('Keep scaffold code, scaffold-facing docs, and published-package smoke checks aligned when they describe the same workflow.'); - expect(mirrorAgents).toContain('`AGENTS.md` MUST stay policy-oriented; starter walkthroughs, AI onboarding prompts, dogfooding playbooks, and investigation scripts belong in dedicated docs or skills.'); - expect(mirrorAgents).toContain('structured-metadata-migration-review/SKILL.md'); - expect(mirrorAgents).toContain('broad-generated-diff-review-packet/SKILL.md'); - expect(mirrorAgents).toContain('Structured metadata sources, schema files, implementation allowlists, fixtures, and generated review views MUST remain aligned'); - expect(mirrorAgents).toContain('Broad generated docs or API diffs SHOULD preserve source-to-generated traceability'); - expect(mirrorAgents).toContain('Local-source dogfooding and scaffold developer-mode flows MUST fail fast when dependencies or CLI entrypoints are missing, and the next recovery step MUST be explicit.'); - expect(mirrorAgents).toContain('Scaffold-owned or user-authored files MUST NOT be overwritten without an explicit force path, and failed initialization MUST NOT leave partial overwrites behind.'); - expect(mirrorAgents).toContain('Reports MUST use an itemized structure with `acceptance item`, `status`, `evidence`, and `gap`.'); - expect(mirrorAgents).toContain('Final PR text and final implementation reports MUST pass two-cycle self-review before human review.'); - expect(mirrorAgents).toContain('Review findings MUST be triaged as `blocker`, `follow-up`, or `nit`.'); - expect(mirrorAgents).toContain('Reports MUST distinguish `Repository evidence` from `Supplementary evidence` when both appear.'); - expect(mirrorAgents).toContain('PR reports MUST treat `Repository evidence` as the primary basis for acceptance judgment.'); - expect(mirrorAgents).toContain('`Supplementary evidence` alone MUST NOT justify a strong `done` claim'); -}); - -test('reporting guidance fixes the decision-oriented order', () => { - const reportingSkill = readText('.agents/skills/attainment-reporting/SKILL.md'); - const sourceIndex = reportingSkill.indexOf('Source request or source issue'); - const whyIndex = reportingSkill.indexOf('Why it matters'); - const changedIndex = reportingSkill.indexOf('What changed'); - const verificationIndex = reportingSkill.indexOf('Verification basis'); - const limitsIndex = reportingSkill.indexOf('Guarantee limits'); - const gapsIndex = reportingSkill.indexOf('Outstanding gaps'); - const nextDecisionIndex = reportingSkill.indexOf('What the human should decide next'); - - expect(sourceIndex).toBeGreaterThanOrEqual(0); - expect(whyIndex).toBeGreaterThan(sourceIndex); - expect(changedIndex).toBeGreaterThan(whyIndex); - expect(verificationIndex).toBeGreaterThan(changedIndex); - expect(limitsIndex).toBeGreaterThan(verificationIndex); - expect(gapsIndex).toBeGreaterThan(limitsIndex); - expect(nextDecisionIndex).toBeGreaterThan(gapsIndex); -}); - -test('.codex/config.toml routes developer workflows to repo-local guidance', () => { - const config = readText('.codex/config.toml'); - - expect(config).toContain('developer_only = true'); - expect(config).toContain('preferred_workflows = ["planning", "verification", "review", "reporting"]'); - expect(config).not.toContain('required_reporting_fields = ["acceptance_items", "verification_methods", "repository_evidence", "supplementary_evidence", "review_triage", "attainment_status"]'); - expect(config).toContain('planning = ".codex/agents/planning.md"'); - expect(config).toContain('verification = ".codex/agents/verification.md"'); - expect(config).toContain('review = ".codex/agents/review.md"'); - expect(config).toContain('reporting = ".codex/agents/reporting.md"'); -}); diff --git a/packages/ztd-cli/tests/rfba.unit.test.ts b/packages/ztd-cli/tests/rfba.unit.test.ts deleted file mode 100644 index 82f7de9e3..000000000 --- a/packages/ztd-cli/tests/rfba.unit.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { afterEach, expect, test } from 'vitest'; -import { - formatRfbaInspectionReport, - inspectRfbaBoundaries, - registerRfbaCommand, -} from '../src/commands/rfba'; -import { setAgentOutputFormat } from '../src/utils/agentCli'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); -const originalFormat = process.env.ZTD_CLI_OUTPUT_FORMAT; - -afterEach(() => { - process.env.ZTD_CLI_OUTPUT_FORMAT = originalFormat; -}); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function writeWorkspaceFile(root: string, relativePath: string, contents = ''): void { - const filePath = path.join(root, ...relativePath.split('/')); - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, contents, 'utf8'); -} - -test('RFBA inspection discovers starter root, feature, query container, and query sub-boundaries', () => { - const workspace = createTempDir('rfba-starter'); - writeWorkspaceFile(workspace, 'src/features/README.md', '# Features\n'); - writeWorkspaceFile(workspace, 'src/adapters/README.md', '# Adapters\n'); - writeWorkspaceFile(workspace, 'src/libraries/README.md', '# Libraries\n'); - writeWorkspaceFile(workspace, 'src/features/_shared/loadSqlResource.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/README.md', '# Orders list\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/tests/orders-list.boundary.test.ts', 'test.todo("feature");\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/queries/list-orders/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/queries/list-orders/list-orders.sql', 'select 1;\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/queries/list-orders/tests/list-orders.boundary.ztd.test.ts', 'test.todo("query");\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/queries/list-orders/tests/generated/analysis.json', '{}\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/queries/list-orders/tests/generated/TEST_PLAN.md', '# Plan\n'); - - const report = inspectRfbaBoundaries(workspace); - - expect(report.schemaVersion).toBe(1); - expect(report.expectedRootBoundaryPaths).toEqual(['src/features', 'src/adapters', 'src/libraries']); - expect(report.summary).toMatchObject({ - rootBoundaries: 3, - featureBoundaries: 1, - childBoundaryContainers: 1, - subBoundaries: 1, - warnings: 0, - }); - expect(report.boundaries.map((boundary) => boundary.path)).toEqual([ - 'src/adapters', - 'src/features', - 'src/features/orders-list', - 'src/features/orders-list/queries', - 'src/features/orders-list/queries/list-orders', - 'src/libraries', - ]); - - const queriesContainer = report.boundaries.find((boundary) => boundary.path === 'src/features/orders-list/queries'); - expect(queriesContainer).toMatchObject({ - kind: 'child-boundary-container', - publicBoundary: false, - publicSurfaceFiles: [], - }); - - const featureBoundary = report.boundaries.find((boundary) => boundary.path === 'src/features/orders-list'); - expect(featureBoundary).toMatchObject({ - kind: 'feature-boundary', - publicSurfaceFiles: ['src/features/orders-list/boundary.ts', 'src/features/orders-list/README.md'], - localVerificationFiles: ['src/features/orders-list/tests/orders-list.boundary.test.ts'], - }); - expect(featureBoundary?.sqlAssets).toEqual([]); - expect(featureBoundary?.generatedArtifacts).toEqual([]); - - const queryBoundary = report.boundaries.find((boundary) => boundary.path === 'src/features/orders-list/queries/list-orders'); - expect(queryBoundary).toMatchObject({ - kind: 'sub-boundary', - publicSurfaceFiles: ['src/features/orders-list/queries/list-orders/boundary.ts'], - sqlAssets: ['src/features/orders-list/queries/list-orders/list-orders.sql'], - generatedArtifacts: [ - 'src/features/orders-list/queries/list-orders/tests/generated/analysis.json', - 'src/features/orders-list/queries/list-orders/tests/generated/TEST_PLAN.md', - ], - localVerificationFiles: [ - 'src/features/orders-list/queries/list-orders/tests/generated/analysis.json', - 'src/features/orders-list/queries/list-orders/tests/generated/TEST_PLAN.md', - 'src/features/orders-list/queries/list-orders/tests/list-orders.boundary.ztd.test.ts', - ], - }); -}); - -test('RFBA inspection reports malformed boundary evidence without failing the read-only command', () => { - const workspace = createTempDir('rfba-malformed'); - writeWorkspaceFile(workspace, 'src/features/billing-export/README.md', '# Billing\n'); - writeWorkspaceFile(workspace, 'src/features/billing-export/queries/orphan.sql', 'select 1;\n'); - mkdirSync(path.join(workspace, 'src', 'features', 'billing-export', 'queries', 'missing-contract'), { recursive: true }); - - const report = inspectRfbaBoundaries(workspace); - - expect(report.summary).toMatchObject({ - rootBoundaries: 1, - featureBoundaries: 1, - childBoundaryContainers: 1, - subBoundaries: 1, - warnings: 4, - }); - expect(report.boundaries.find((boundary) => boundary.path === 'src/features/billing-export')?.warnings).toEqual([ - 'Feature boundary does not expose the starter public surface file boundary.ts.', - ]); - expect(report.boundaries.find((boundary) => boundary.path === 'src/features/billing-export/queries')?.warnings).toEqual([ - 'queries/ contains direct files; query assets should usually live under queries// sub-boundaries.', - ]); - expect(report.boundaries.find((boundary) => boundary.path === 'src/features/billing-export/queries/missing-contract')?.warnings).toEqual([ - 'Query sub-boundary does not contain a SQL asset.', - 'Query sub-boundary does not expose boundary.ts.', - ]); -}); - -test('RFBA JSON output is deterministic and text output identifies queries as a non-public container', () => { - const workspace = createTempDir('rfba-json'); - writeWorkspaceFile(workspace, 'src/features/orders-list/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/queries/list-orders/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/orders-list/queries/list-orders/list-orders.sql', 'select 1;\n'); - - const first = formatRfbaInspectionReport(inspectRfbaBoundaries(workspace), 'json'); - const second = formatRfbaInspectionReport(inspectRfbaBoundaries(workspace), 'json'); - expect(second).toBe(first); - - const text = formatRfbaInspectionReport(inspectRfbaBoundaries(workspace), 'text'); - expect(text).toContain('src/features/orders-list/queries [child-boundary-container] (not public boundary)'); - expect(text).toContain('src/features/orders-list/queries/list-orders/list-orders.sql'); -}); - -test('rfba inspect help exposes the read-only inspection surface', async () => { - setAgentOutputFormat('text'); - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str), - }); - registerRfbaCommand(program); - - await expect(program.parseAsync(['rfba', 'inspect', '--help'], { from: 'user' })).rejects.toMatchObject({ - code: 'commander.helpDisplayed', - }); - const stdout = capture.stdout.join(''); - expect(stdout).toContain('Inspect RFBA root, feature, and query sub-boundaries without writing files'); - expect(stdout).toContain('--format '); - expect(stdout).toContain('--root '); - expect(stdout).toContain('--json '); -}); diff --git a/packages/ztd-cli/tests/rfbaReviewData.unit.test.ts b/packages/ztd-cli/tests/rfbaReviewData.unit.test.ts deleted file mode 100644 index a009f2af3..000000000 --- a/packages/ztd-cli/tests/rfbaReviewData.unit.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { Command } from 'commander'; -import { expect, test } from 'vitest'; -import { inspectRfbaBoundaries, registerRfbaCommand } from '../src/commands/rfba'; -import { - buildChangedBoundarySummary, - buildVerificationSummary, - classifyRfbaChangedFile, - diffDdlTables, - parseGitNameStatus, - summarizeSqlChange, -} from '../src/commands/rfbaReviewData'; -import { setAgentOutputFormat } from '../src/utils/agentCli'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const tmpRoot = path.join(repoRoot, 'tmp'); - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function writeWorkspaceFile(root: string, relativePath: string, contents = ''): void { - const filePath = path.join(root, ...relativePath.split('/')); - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, contents, 'utf8'); -} - -function runGit(cwd: string, args: string[]): void { - const env = { ...process.env }; - delete env.GIT_DIR; - delete env.GIT_WORK_TREE; - delete env.GIT_INDEX_FILE; - delete env.GIT_PREFIX; - - const result = spawnSync('git', args, { - cwd, - encoding: 'utf8', - env, - }); - expect(result.status, result.stderr || result.stdout).toBe(0); -} - -test('parseGitNameStatus supports added, modified, deleted, renamed, and copied entries', () => { - expect(parseGitNameStatus([ - 'A\tsrc/features/users/boundary.ts', - 'M\tdb/ddl/public.sql', - 'D\tsrc/features/users/tests/users.test.ts', - 'R091\told.sql\tnew.sql', - 'C100\ta.sql\tb.sql', - ].join('\n'))).toEqual([ - { status: 'added', path: 'src/features/users/boundary.ts' }, - { status: 'modified', path: 'db/ddl/public.sql' }, - { status: 'deleted', path: 'src/features/users/tests/users.test.ts' }, - { status: 'renamed', oldPath: 'old.sql', path: 'new.sql', score: 91 }, - { status: 'copied', oldPath: 'a.sql', path: 'b.sql', score: 100 }, - ]); -}); - -test('classifyRfbaChangedFile maps RFBA review kinds and weights deterministically', () => { - expect(classifyRfbaChangedFile({ status: 'modified', path: 'db/ddl/public.sql' })).toMatchObject({ - kind: 'ddl', - boundary: null, - reviewWeight: 'high', - }); - expect(classifyRfbaChangedFile({ status: 'modified', path: 'src/features/users-insert/queries/insert-users/insert-users.sql' })).toMatchObject({ - kind: 'query-sql', - boundary: 'src/features/users-insert/queries/insert-users', - parentFeatureBoundary: 'src/features/users-insert', - reviewWeight: 'high', - }); - expect(classifyRfbaChangedFile({ status: 'modified', path: 'src/features/users-insert/queries/insert-users/tests/generated/analysis.json' })).toMatchObject({ - kind: 'generated-evidence', - reviewWeight: 'low', - }); - expect(classifyRfbaChangedFile({ status: 'modified', path: 'docs/notes.md' })).toMatchObject({ - kind: 'unknown', - reviewWeight: 'medium', - }); -}); - -test('classifyRfbaChangedFile preserves old-side RFBA classification for renames', () => { - expect(classifyRfbaChangedFile({ - status: 'renamed', - oldPath: 'src/features/accounts/queries/list-accounts/list-accounts.sql', - path: 'src/features/customers/queries/list-customers/list-customers.sql', - })).toMatchObject({ - kind: 'query-sql', - oldKind: 'query-sql', - boundary: 'src/features/customers/queries/list-customers', - oldBoundary: 'src/features/accounts/queries/list-accounts', - parentFeatureBoundary: 'src/features/customers', - oldParentFeatureBoundary: 'src/features/accounts', - reviewWeight: 'high', - oldReviewWeight: 'high', - }); -}); - -test('classifyRfbaChangedFile supports nested feature query and test layouts', () => { - expect(classifyRfbaChangedFile({ - status: 'modified', - path: 'src/features/billing/accounts/queries/read-balance/read-balance.sql', - })).toMatchObject({ - kind: 'query-sql', - boundary: 'src/features/billing/accounts/queries/read-balance', - parentFeatureBoundary: 'src/features/billing/accounts', - }); - expect(classifyRfbaChangedFile({ - status: 'modified', - path: 'src/features/billing/accounts/queries/read-balance/tests/cases/basic.case.ts', - })).toMatchObject({ - kind: 'query-case', - boundary: 'src/features/billing/accounts/queries/read-balance', - parentFeatureBoundary: 'src/features/billing/accounts', - }); - expect(classifyRfbaChangedFile({ - status: 'modified', - path: 'src/features/billing/accounts/tests/read-balance.test.ts', - })).toMatchObject({ - kind: 'feature-verification', - boundary: 'src/features/billing/accounts', - parentFeatureBoundary: 'src/features/billing/accounts', - }); -}); - -test('buildChangedBoundarySummary reuses RFBA inspection boundaries', () => { - const workspace = createTempDir('rfba-review-boundaries'); - writeWorkspaceFile(workspace, 'src/features/users-insert/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/users-insert/queries/insert-users/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/users-insert/queries/insert-users/insert-users.sql', 'select 1;\n'); - const changedFiles = [ - classifyRfbaChangedFile({ status: 'modified', path: 'src/features/users-insert/queries/insert-users/insert-users.sql' }), - classifyRfbaChangedFile({ status: 'modified', path: 'src/features/users-insert/queries/insert-users/boundary.ts' }), - ]; - - expect(buildChangedBoundarySummary(changedFiles, inspectRfbaBoundaries(workspace))).toEqual([ - { - boundary: 'src/features/users-insert/queries/insert-users', - kind: 'sub-boundary', - parentBoundary: 'src/features/users-insert/queries', - changedFiles: [ - 'src/features/users-insert/queries/insert-users/boundary.ts', - 'src/features/users-insert/queries/insert-users/insert-users.sql', - ], - reviewWeight: 'high', - }, - ]); -}); - -test('buildChangedBoundarySummary includes both old and new boundaries for renamed files', () => { - const workspace = createTempDir('rfba-review-rename-boundaries'); - writeWorkspaceFile(workspace, 'src/features/accounts/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/accounts/queries/list-accounts/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/customers/boundary.ts', 'export {};\n'); - writeWorkspaceFile(workspace, 'src/features/customers/queries/list-customers/boundary.ts', 'export {};\n'); - const changedFiles = [ - classifyRfbaChangedFile({ - status: 'renamed', - oldPath: 'src/features/accounts/queries/list-accounts/list-accounts.sql', - path: 'src/features/customers/queries/list-customers/list-customers.sql', - }), - ]; - - expect(buildChangedBoundarySummary(changedFiles, inspectRfbaBoundaries(workspace))).toEqual([ - expect.objectContaining({ - boundary: 'src/features/accounts/queries/list-accounts', - changedFiles: ['src/features/accounts/queries/list-accounts/list-accounts.sql'], - }), - expect.objectContaining({ - boundary: 'src/features/customers/queries/list-customers', - changedFiles: ['src/features/customers/queries/list-customers/list-customers.sql'], - }), - ]); -}); - -test('diffDdlTables reports add column, type, nullability, default, and explanation-only SQL', () => { - const before = ` - CREATE TABLE public.users ( - id integer PRIMARY KEY, - email text NOT NULL - ); - `; - const after = ` - CREATE TABLE public.users ( - id integer PRIMARY KEY, - email varchar(320), - status text NOT NULL DEFAULT 'active' - ); - `; - - const changes = diffDdlTables(before, after, 'db/ddl/public.sql'); - - expect(changes).toHaveLength(1); - expect(changes[0]).toMatchObject({ - object: 'public.users', - explanationSqlPurpose: 'human-readable explanation only; not an auto-apply migration', - }); - expect(changes[0].changes.map((change) => change.kind)).toEqual([ - 'modify-column-type', - 'modify-column-nullability', - 'add-column', - ]); - expect(changes[0].tableViewAfter?.columnsAfter.map((column) => column.name)).toEqual(['email', 'id', 'status']); -}); - -test('diffDdlTables reports add-index and drop-index changes', () => { - const before = ` - CREATE TABLE public.users ( - id integer PRIMARY KEY, - email text NOT NULL - ); - CREATE INDEX users_email_idx ON public.users (email); - `; - const after = ` - CREATE TABLE public.users ( - id integer PRIMARY KEY, - email text NOT NULL - ); - CREATE UNIQUE INDEX users_email_unique_idx ON public.users (email); - `; - - expect(diffDdlTables(before, after, 'db/ddl/public.sql')).toEqual(expect.arrayContaining([ - expect.objectContaining({ - objectKind: 'index', - object: 'users_email_idx', - changes: [expect.objectContaining({ kind: 'drop-index' })], - }), - expect.objectContaining({ - objectKind: 'index', - object: 'users_email_unique_idx', - changes: [expect.objectContaining({ kind: 'add-index' })], - }), - ])); -}); - -test('summarizeSqlChange reports INSERT RETURNING and table usage changes', () => { - const before = `INSERT INTO public.users (email) VALUES ($1) RETURNING id, email;`; - const after = `INSERT INTO public.users (email, status) VALUES ($1, $2) RETURNING id, email, status;`; - - expect(summarizeSqlChange(before, after, 'src/features/users-insert/queries/insert-users/insert-users.sql', 'src/features/users-insert/queries/insert-users')).toMatchObject({ - statementKindBefore: 'insert', - statementKindAfter: 'insert', - writeTablesBefore: ['public.users'], - writeTablesAfter: ['public.users'], - returningColumnsBefore: ['email', 'id'], - returningColumnsAfter: ['email', 'id', 'status'], - reviewHints: expect.arrayContaining([ - 'Confirm whether the returned result shape change is reflected in the query boundary.', - 'Confirm whether query-local cases assert the returned columns.', - ]), - }); - - expect(summarizeSqlChange( - `SELECT id, email FROM public.users WHERE email = $1;`, - `SELECT id, email FROM public.users JOIN public.accounts ON accounts.user_id = users.id WHERE email = $1;` - )).toMatchObject({ - readTablesAfter: ['public.accounts', 'public.users'], - joinTablesAfter: ['public.accounts'], - selectedColumnsAfter: ['email', 'id'], - whereColumnsAfter: ['email'], - }); - - expect(summarizeSqlChange( - `UPDATE public.users SET email = $1 WHERE id = $2 RETURNING id;`, - `UPDATE public.user_profiles SET email = $1 WHERE id = $2 RETURNING id;` - )).toMatchObject({ - writeTablesBefore: ['public.users'], - writeTablesAfter: ['public.user_profiles'], - }); -}); - -test('buildVerificationSummary groups query evidence and flags likely missing evidence', () => { - const files = [ - classifyRfbaChangedFile({ status: 'modified', path: 'src/features/users-insert/queries/insert-users/insert-users.sql' }), - classifyRfbaChangedFile({ status: 'modified', path: 'src/features/users-insert/queries/insert-users/tests/generated/analysis.json' }), - classifyRfbaChangedFile({ status: 'modified', path: 'src/features/users-insert/queries/insert-users/tests/cases/basic.case.ts' }), - classifyRfbaChangedFile({ status: 'modified', path: 'tests/support/ztd/runner.ts' }), - ]; - - expect(buildVerificationSummary(files)).toEqual([ - { - boundary: 'global', - changedCases: [], - changedGeneratedEvidence: [], - changedEntrypoints: [], - changedFeatureTests: [], - changedTestSupport: ['tests/support/ztd/runner.ts'], - missingLikelyEvidence: [], - }, - { - boundary: 'src/features/users-insert/queries/insert-users', - changedCases: ['src/features/users-insert/queries/insert-users/tests/cases/basic.case.ts'], - changedGeneratedEvidence: ['src/features/users-insert/queries/insert-users/tests/generated/analysis.json'], - changedEntrypoints: [], - changedFeatureTests: [], - changedTestSupport: [], - missingLikelyEvidence: [], - }, - ]); - - expect(buildVerificationSummary([ - classifyRfbaChangedFile({ status: 'modified', path: 'src/features/users-insert/queries/insert-users/insert-users.sql' }), - ])[0].missingLikelyEvidence).toEqual(['SQL changed but no query-local cases or generated evidence changed.']); -}); - -test('buildVerificationSummary includes old query boundaries for renamed SQL files', () => { - expect(buildVerificationSummary([ - classifyRfbaChangedFile({ - status: 'renamed', - oldPath: 'src/features/accounts/queries/list-accounts/list-accounts.sql', - path: 'src/features/customers/queries/list-customers/list-customers.sql', - }), - ])).toEqual([ - expect.objectContaining({ - boundary: 'src/features/accounts/queries/list-accounts', - missingLikelyEvidence: ['SQL changed but no query-local cases or generated evidence changed.'], - }), - expect.objectContaining({ - boundary: 'src/features/customers/queries/list-customers', - missingLikelyEvidence: ['SQL changed but no query-local cases or generated evidence changed.'], - }), - ]); -}); - -test('rfba review-data command emits stdout JSON and writes --out in a Git workspace', async () => { - setAgentOutputFormat('text'); - const workspace = createTempDir('rfba-review-cli'); - runGit(workspace, ['init']); - runGit(workspace, ['config', 'user.email', 'test@example.com']); - runGit(workspace, ['config', 'user.name', 'Test User']); - writeWorkspaceFile(workspace, 'db/ddl/public.sql', ` - CREATE TABLE public.users ( - id integer PRIMARY KEY, - email text NOT NULL - ); - `); - writeWorkspaceFile(workspace, 'src/features/users-insert/boundary.ts', 'export const createUser = () => undefined;\n'); - writeWorkspaceFile(workspace, 'src/features/users-insert/queries/insert-users/boundary.ts', 'export const insertUsers = () => undefined;\n'); - writeWorkspaceFile(workspace, 'src/features/users-insert/queries/insert-users/insert-users.sql', 'INSERT INTO public.users (email) VALUES ($1) RETURNING id, email;\n'); - runGit(workspace, ['add', '.']); - runGit(workspace, ['commit', '-m', 'base']); - runGit(workspace, ['branch', 'base']); - - writeWorkspaceFile(workspace, 'db/ddl/public.sql', ` - CREATE TABLE public.users ( - id integer PRIMARY KEY, - email text NOT NULL, - status text NOT NULL DEFAULT 'active' - ); - `); - writeWorkspaceFile(workspace, 'src/features/users-insert/queries/insert-users/insert-users.sql', 'INSERT INTO public.users (email, status) VALUES ($1, $2) RETURNING id, email, status;\n'); - runGit(workspace, ['add', '.']); - runGit(workspace, ['commit', '-m', 'head']); - - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str), - }); - registerRfbaCommand(program); - const outFile = '.ztd/review/rfba-review-data.json'; - - const originalWrite = process.stdout.write; - process.stdout.write = ((chunk: string | Uint8Array) => { - capture.stdout.push(String(chunk)); - return true; - }) as typeof process.stdout.write; - try { - await program.parseAsync(['rfba', 'review-data', '--root', workspace, '--base', 'base', '--head', 'HEAD', '--out', outFile], { from: 'user' }); - } finally { - process.stdout.write = originalWrite; - } - - const stdoutJson = JSON.parse(capture.stdout.join('')); - const fileJson = JSON.parse(readFileSync(path.join(workspace, outFile), 'utf8')); - expect(stdoutJson).toEqual(fileJson); - expect(stdoutJson).toMatchObject({ - schemaVersion: 1, - command: 'rfba review-data', - base: 'base', - head: 'HEAD', - summary: { - changedFiles: 2, - changedBoundaries: 1, - ddlChanges: 1, - sqlChanges: 1, - }, - }); - expect(stdoutJson.changedFiles.map((file: { kind: string }) => file.kind).sort()).toEqual(['ddl', 'query-sql']); - expect(stdoutJson.warnings).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'verification.possibly-missing' }), - ])); -}); - -test('rfba review-data help exposes review packet options', async () => { - setAgentOutputFormat('text'); - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str), - }); - registerRfbaCommand(program); - - await expect(program.parseAsync(['rfba', 'review-data', '--help'], { from: 'user' })).rejects.toMatchObject({ - code: 'commander.helpDisplayed', - }); - const stdout = capture.stdout.join(''); - expect(stdout).toContain('Generate deterministic RFBA review packet data from a Git diff'); - expect(stdout).toContain('--base '); - expect(stdout).toContain('--head '); - expect(stdout).toContain('--out '); - expect(stdout).toContain('--scope '); - expect(stdout).toContain('--include-raw-diff'); -}); diff --git a/packages/ztd-cli/tests/setupEnv.unit.test.ts b/packages/ztd-cli/tests/setupEnv.unit.test.ts deleted file mode 100644 index c5decf933..000000000 --- a/packages/ztd-cli/tests/setupEnv.unit.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; - -const DB_ENV_KEYS = ['ZTD_DB_HOST', 'ZTD_DB_PORT', 'ZTD_DB_NAME', 'ZTD_DB_USER', 'ZTD_DB_PASS'] as const; -const tempDirs: string[] = []; -const originalEnv = Object.fromEntries( - [...DB_ENV_KEYS, 'ZTD_DB_URL'].map((key) => [key, process.env[key]]) -) as Record<(typeof DB_ENV_KEYS)[number] | 'ZTD_DB_URL', string | undefined>; - -function restoreEnv(key: keyof typeof originalEnv, value: string | undefined): void { - if (value === undefined) { - delete process.env[key]; - return; - } - - process.env[key] = value; -} - -function writeStarterEnv(rootDir: string, overrides: Partial> = {}): void { - const envLines = DB_ENV_KEYS.map((key) => `${key}=${overrides[key] ?? defaultValueFor(key)}`); - writeFileSync(path.join(rootDir, '.env'), `${envLines.join('\n')}\n`, 'utf8'); -} - -function defaultValueFor(key: (typeof DB_ENV_KEYS)[number]): string { - switch (key) { - case 'ZTD_DB_HOST': - return '127.0.0.1'; - case 'ZTD_DB_PORT': - return '5433'; - case 'ZTD_DB_NAME': - return 'ztd'; - case 'ZTD_DB_USER': - return 'ztd'; - case 'ZTD_DB_PASS': - return 'ztd'; - } -} - -afterEach(() => { - for (const [key, value] of Object.entries(originalEnv) as Array<[keyof typeof originalEnv, string | undefined]>) { - restoreEnv(key, value); - } - vi.resetModules(); - vi.restoreAllMocks(); - - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('setup-env derives ZTD_DB_URL from the starter DB env vars', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-setup-env-derived-')); - tempDirs.push(rootDir); - writeStarterEnv(rootDir); - - vi.spyOn(process, 'cwd').mockReturnValue(rootDir); - for (const key of [...DB_ENV_KEYS, 'ZTD_DB_URL'] as const) { - delete process.env[key]; - } - - await import('../templates/tests/support/setup-env'); - - expect(process.env.ZTD_DB_URL).toBe('postgres://ztd:ztd@localhost:5433/ztd'); -}); - -test('setup-env preserves an existing ZTD_DB_URL', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-setup-env-existing-')); - tempDirs.push(rootDir); - writeStarterEnv(rootDir, { ZTD_DB_PORT: '5434' }); - - vi.spyOn(process, 'cwd').mockReturnValue(rootDir); - for (const key of [...DB_ENV_KEYS, 'ZTD_DB_URL'] as const) { - delete process.env[key]; - } - process.env.ZTD_DB_URL = 'postgres://example:example@127.0.0.1:6000/example'; - - await import('../templates/tests/support/setup-env'); - - expect(process.env.ZTD_DB_URL).toBe('postgres://example:example@127.0.0.1:6000/example'); -}); - -test('setup-env falls back to the default port when ZTD_DB_PORT is missing', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-setup-env-default-port-')); - tempDirs.push(rootDir); - writeStarterEnv(rootDir); - writeFileSync( - path.join(rootDir, '.env'), - [ - 'ZTD_DB_HOST=127.0.0.1', - 'ZTD_DB_NAME=ztd', - 'ZTD_DB_USER=ztd', - 'ZTD_DB_PASS=ztd' - ].join('\n'), - 'utf8' - ); - - vi.spyOn(process, 'cwd').mockReturnValue(rootDir); - for (const key of [...DB_ENV_KEYS, 'ZTD_DB_URL'] as const) { - delete process.env[key]; - } - - await import('../templates/tests/support/setup-env'); - - expect(process.env.ZTD_DB_URL).toBe('postgres://ztd:ztd@localhost:5432/ztd'); -}); diff --git a/packages/ztd-cli/tests/specs.entrypoint.test.ts b/packages/ztd-cli/tests/specs.entrypoint.test.ts deleted file mode 100644 index 42a8fa5f4..000000000 --- a/packages/ztd-cli/tests/specs.entrypoint.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { sqlCatalogCases, testCaseCatalogs } from './specs'; - -describe('specs entrypoint', () => { - it('exports at least one test-case catalog and one SQL catalog spec', () => { - expect(Array.isArray(testCaseCatalogs)).toBe(true); - expect(Array.isArray(sqlCatalogCases)).toBe(true); - expect(testCaseCatalogs.length).toBeGreaterThan(0); - expect(sqlCatalogCases.length).toBeGreaterThan(0); - }); - - it('keeps spec modules free from vitest blocks by convention', () => { - const specsRoot = path.resolve(__dirname, 'specs'); - const sourceFiles = [ - path.join(specsRoot, 'index.ts'), - path.join(specsRoot, 'testCaseCatalogs.ts'), - path.join(specsRoot, 'sql', 'activeOrders.ts'), - ]; - for (const filePath of sourceFiles) { - const source = readFileSync(filePath, 'utf8'); - expect(source).not.toMatch(/\b(?:describe|it|test)\s*\(/); - } - }); - - it('removes legacy SQL catalog alias names from ztd-cli files', () => { - const packageRoot = path.resolve(__dirname, '..'); - const legacyDefineName = ['define', 'SqlCatalog', 'Cases'].join(''); - const legacyRunName = ['run', 'SqlCatalog', 'Cases'].join(''); - const sourceFiles = collectFiles(path.join(packageRoot, 'src')).concat( - collectFiles(path.join(packageRoot, 'tests')) - ); - - for (const filePath of sourceFiles) { - const source = readFileSync(filePath, 'utf8'); - expect(source.includes(legacyDefineName), filePath).toBe(false); - expect(source.includes(legacyRunName), filePath).toBe(false); - } - }); -}); - -function collectFiles(rootDir: string): string[] { - let rootStat; - try { - rootStat = statSync(rootDir); - } catch { - return []; - } - if (!rootStat.isDirectory()) { - return []; - } - const entries = readdirSync(rootDir, { withFileTypes: true }); - const files: string[] = []; - for (const entry of entries) { - const entryPath = path.join(rootDir, entry.name); - if (entry.isDirectory()) { - files.push(...collectFiles(entryPath)); - continue; - } - if (entry.isFile() && /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/i.test(entry.name)) { - files.push(entryPath); - } - } - return files; -} diff --git a/packages/ztd-cli/tests/specs/README.md b/packages/ztd-cli/tests/specs/README.md deleted file mode 100644 index 6b2cf9cf7..000000000 --- a/packages/ztd-cli/tests/specs/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Catalog Spec Pattern - -This folder is the explicit specification source for test evidence projection. - -- `tests/specs/testCaseCatalogs.ts`: Test Case Catalog specs. -- `tests/specs/sql/*.ts`: SQL Catalog case specs. -- `tests/specs/index.ts`: explicit export barrel consumed by evidence mode. - -Rules: - -- Spec files export catalog objects only. -- Do not declare `describe`/`it` inside spec files. -- Runner tests execute specs. -- Evidence tests validate deterministic projections. -- Evidence discovery is explicit (`tests/specs/index`), never glob/source parsing. -- Canonical SQL catalog API names: - - `defineSqlCatalog(...)` - - `runSqlCatalog(...)` - - `exportSqlCatalogEvidence(...)` - -Evidence fixed points: - -- SQL evidence fixture summary includes only: - - `tableName` - - `schema.columns` - - `rowsCount` -- Evidence must be deterministic: - - sorted IDs and stable key ordering - - no timestamps - - no environment-specific fields -- Full fixture rows are not included by default; add them only via explicit, stable requirements. diff --git a/packages/ztd-cli/tests/specs/index.ts b/packages/ztd-cli/tests/specs/index.ts deleted file mode 100644 index 4e22170cf..000000000 --- a/packages/ztd-cli/tests/specs/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { alphaCatalog, emailCatalog } from './testCaseCatalogs'; -import { activeOrdersSqlCases } from './sql/activeOrders'; -import { sampleSqlCatalog } from './sql/sample'; - -/** - * Aggregated executable test-case catalogs. - */ -export const testCaseCatalogs = [emailCatalog, alphaCatalog]; - -/** - * Aggregated SQL catalog test specifications. - */ -export const sqlCatalogCases = [activeOrdersSqlCases, sampleSqlCatalog]; - -/** - * Re-exported sample SQL catalog for direct imports from this entrypoint. - */ -export { sampleSqlCatalog } from './sql/sample'; diff --git a/packages/ztd-cli/tests/specs/sql/activeOrders.ts b/packages/ztd-cli/tests/specs/sql/activeOrders.ts deleted file mode 100644 index a8ebed4e5..000000000 --- a/packages/ztd-cli/tests/specs/sql/activeOrders.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { TableFixture } from '@rawsql-ts/testkit-core'; -import { activeOrdersCatalog } from '../../../src/specs/sql/activeOrders.catalog'; -import { defineSqlCatalog } from '../../utils/sqlCatalog'; - -const usersFixture: TableFixture = { - tableName: 'users', - rows: [ - { id: 1, email: 'alice@example.com', active: 1 }, - { id: 2, email: 'bob@example.com', active: 0 }, - { id: 3, email: 'carol@example.com', active: 1 }, - ], - schema: { - columns: { - id: 'INTEGER', - email: 'TEXT', - active: 'INTEGER', - }, - }, -}; - -const ordersFixture: TableFixture = { - tableName: 'orders', - rows: [ - { id: 10, user_id: 1, total: 50 }, - { id: 11, user_id: 1, total: 20 }, - { id: 12, user_id: 2, total: 40 }, - { id: 13, user_id: 3, total: 35 }, - ], - schema: { - columns: { - id: 'INTEGER', - user_id: 'INTEGER', - total: 'INTEGER', - }, - }, -}; - -/** - * SQL catalog spec that verifies active-order selection semantics. - */ -export const activeOrdersSqlCases = defineSqlCatalog({ - id: 'sql.active-orders', - title: 'Active orders SQL semantics', - definitionPath: 'src/specs/sql/activeOrders.catalog.ts', - fixtures: [usersFixture, ordersFixture], - catalog: activeOrdersCatalog, - cases: [ - { - id: 'baseline', - title: 'active users with minimum total', - expected: [ - { orderId: 10, userEmail: 'alice@example.com', orderTotal: 50 }, - { orderId: 13, userEmail: 'carol@example.com', orderTotal: 35 }, - ], - }, - { - id: 'inactive-variant', - title: 'inactive users return a different result', - arrange: () => ({ active: 0, minTotal: 20, limit: 2 }), - expected: [{ orderId: 12, userEmail: 'bob@example.com', orderTotal: 40 }], - }, - ], -}); diff --git a/packages/ztd-cli/tests/specs/sql/sample.ts b/packages/ztd-cli/tests/specs/sql/sample.ts deleted file mode 100644 index ca5976a4e..000000000 --- a/packages/ztd-cli/tests/specs/sql/sample.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { usersListCatalog } from '../../../src/specs/sql/usersList.catalog'; -import { defineSqlCatalog } from '../../utils/sqlCatalog'; - -/** - * Minimal SQL catalog spec used as a reference for data-only test cases. - */ -export const sampleSqlCatalog = defineSqlCatalog({ - id: 'sql.sample', - title: 'sample sql cases', - definitionPath: 'src/specs/sql/usersList.catalog.ts', - fixtures: [ - { - tableName: 'users', - rows: [ - { id: 1, active: 1 }, - { id: 2, active: 0 }, - ], - schema: { columns: { id: 'INTEGER', active: 'INTEGER' } }, - }, - ], - catalog: usersListCatalog, - cases: [ - { - id: 'returns-inactive-users-when-active-0', - title: 'returns inactive users when active=0', - arrange: () => ({ active: 0 }), - expected: [{ id: 2 }], - }, - { - id: 'returns-active-users', - title: 'returns active users', - expected: [{ id: 1 }], - }, - ], -}); diff --git a/packages/ztd-cli/tests/specs/testCaseCatalogs.ts b/packages/ztd-cli/tests/specs/testCaseCatalogs.ts deleted file mode 100644 index 509fdaf1f..000000000 --- a/packages/ztd-cli/tests/specs/testCaseCatalogs.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { defineTestCaseCatalog } from '../utils/testCaseCatalog'; - -function normalizeEmail(input: string): string { - const trimmed = input.trim().toLowerCase(); - if (!trimmed.includes('@')) { - throw new Error('invalid email'); - } - return trimmed; -} - -/** - * Executable catalog covering email normalization behavior. - */ -export const emailCatalog = defineTestCaseCatalog({ - id: 'unit.normalize-email', - title: 'normalizeEmail', - description: 'Executable, inference-free specification for internal normalization behavior.', - definitionPath: 'tests/specs/testCaseCatalogs.ts', - refs: [ - { label: 'Issue #448', url: 'https://github.com/mk3008/rawsql-ts/issues/448' } - ], - cases: [ - { - id: 'rejects-invalid-input', - title: 'throws when @ is missing', - arrange: () => 'invalid-email', - act: (value) => () => normalizeEmail(value), - evidence: { - input: 'invalid-email', - expected: 'throws', - error: { - name: 'Error', - message: 'invalid email', - match: 'contains', - }, - tags: ['validation', 'ep'], - focus: 'Rejects input without @ before producing normalized output.', - refs: [ - { label: 'Issue #448', url: 'https://github.com/mk3008/rawsql-ts/issues/448' } - ], - }, - assert: (invoke) => { - try { - invoke(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('invalid email')) { - return; - } - throw new Error(`Expected invalid email error, got: ${message}`); - } - throw new Error('Expected normalizeEmail to throw for invalid input.'); - }, - }, - { - id: 'trims-and-lowercases', - title: 'normalizes uppercase + spaces', - arrange: () => ' USER@Example.COM ', - act: (value) => () => normalizeEmail(value), - evidence: { - input: ' USER@Example.COM ', - expected: 'success', - output: 'user@example.com', - tags: ['normalization', 'ep'], - focus: 'Ensures trimming and lowercasing run before return.', - }, - assert: (invoke) => { - const actual = invoke(); - if (actual !== 'user@example.com') { - throw new Error(`Expected user@example.com but got ${String(actual)}.`); - } - }, - }, - { - id: 'keeps-valid-address', - title: 'retains already-normalized email', - arrange: () => 'alice@example.com', - act: (value) => () => normalizeEmail(value), - evidence: { - input: 'alice@example.com', - expected: 'success', - output: 'alice@example.com', - tags: ['normalization', 'idempotence'], - focus: 'Ensures already normalized input remains unchanged.', - }, - assert: (invoke) => { - const actual = invoke(); - if (actual !== 'alice@example.com') { - throw new Error(`Expected alice@example.com but got ${String(actual)}.`); - } - }, - }, - { - id: 'accepts-minimal-domain', - title: 'accepts shortest practical domain form', - arrange: () => 'a@b.c', - act: (value) => () => normalizeEmail(value), - evidence: { - input: 'a@b.c', - expected: 'success', - output: 'a@b.c', - tags: ['validation', 'bva'], - focus: 'Ensures minimal local and domain segments are accepted.', - }, - assert: (invoke) => { - const actual = invoke(); - if (actual !== 'a@b.c') { - throw new Error(`Expected a@b.c but got ${String(actual)}.`); - } - }, - }, - { - id: 'keeps-plus-alias', - title: 'preserves plus alias while normalizing case', - arrange: () => ' USER+tag@Example.COM ', - act: (value) => () => normalizeEmail(value), - evidence: { - input: ' USER+tag@Example.COM ', - expected: 'success', - output: 'user+tag@example.com', - tags: ['boundary', 'normalization'], - focus: 'Ensures alias characters are preserved during normalization.', - }, - assert: (invoke) => { - const actual = invoke(); - if (actual !== 'user+tag@example.com') { - throw new Error(`Expected user+tag@example.com but got ${String(actual)}.`); - } - }, - }, - { - id: 'throws-empty-after-trim', - title: 'throws when trimmed input is empty', - arrange: () => ' ', - act: (value) => () => normalizeEmail(value), - evidence: { - input: ' ', - expected: 'throws', - error: { - name: 'Error', - message: 'invalid email', - match: 'contains', - }, - tags: ['validation', 'bva'], - focus: 'Rejects whitespace-only input after trimming.', - }, - assert: (invoke) => { - try { - invoke(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('invalid email')) { - return; - } - throw new Error(`Expected invalid email error, got: ${message}`); - } - throw new Error('Expected normalizeEmail to throw for whitespace input.'); - }, - }, - ], -}); - -/** - * Tiny smoke catalog used to validate generic runner behavior. - */ -export const alphaCatalog = defineTestCaseCatalog({ - id: 'unit.alpha', - title: 'alpha', - definitionPath: 'tests/specs/testCaseCatalogs.ts', - cases: [ - { - id: 'a', - title: 'noop', - arrange: () => 1, - act: (value) => () => value, - evidence: { - input: 1, - expected: 'success', - output: 1, - tags: ['invariant', 'state'], - focus: 'Ensures baseline runner and evidence plumbing remain stable.', - }, - assert: (invoke) => { - const actual = invoke(); - if (actual !== 1) { - throw new Error(`Expected 1 but got ${String(actual)}.`); - } - }, - }, - ], -}); diff --git a/packages/ztd-cli/tests/sqlCatalog.evidence.test.ts b/packages/ztd-cli/tests/sqlCatalog.evidence.test.ts deleted file mode 100644 index 88f8ee5aa..000000000 --- a/packages/ztd-cli/tests/sqlCatalog.evidence.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { exportSqlCatalogEvidence } from './utils/sqlCatalog'; -import { activeOrdersSqlCases } from './specs/sql/activeOrders'; - -describe('sql catalog evidence', () => { - it('is deterministic with stable sorting and case-level params', () => { - const unsorted = [activeOrdersSqlCases]; - const exported1 = exportSqlCatalogEvidence([...unsorted]); - const exported2 = exportSqlCatalogEvidence([...unsorted]); - - expect(exported1).toEqual(exported2); - expect(exported1.catalogs.map((item) => item.id)).toEqual(['sql.active-orders']); - expect(exported1.catalogs[0]?.cases.map((item) => item.id)).toEqual([ - 'baseline', - 'inactive-variant', - ]); - expect(exported1.catalogs[0]?.cases).toEqual([ - { - id: 'baseline', - title: 'active users with minimum total', - params: { active: 1, limit: 2, minTotal: 20 }, - expected: [ - { orderId: 10, userEmail: 'alice@example.com', orderTotal: 50 }, - { orderId: 13, userEmail: 'carol@example.com', orderTotal: 35 }, - ], - }, - { - id: 'inactive-variant', - title: 'inactive users return a different result', - params: { active: 0, limit: 2, minTotal: 20 }, - expected: [{ orderId: 12, userEmail: 'bob@example.com', orderTotal: 40 }], - }, - ]); - expect(exported1.catalogs[0]?.sql).toBe(activeOrdersSqlCases.catalog.sql); - expect(exported1.catalogs[0]?.fixtures).toEqual([ - { rowsCount: 4, tableName: 'orders', schema: { columns: { id: 'INTEGER', total: 'INTEGER', user_id: 'INTEGER' } } }, - { rowsCount: 3, tableName: 'users', schema: { columns: { active: 'INTEGER', email: 'TEXT', id: 'INTEGER' } } }, - ]); - }); -}); diff --git a/packages/ztd-cli/tests/sqlCatalog.mock.runner.test.ts b/packages/ztd-cli/tests/sqlCatalog.mock.runner.test.ts deleted file mode 100644 index 33ce939b3..000000000 --- a/packages/ztd-cli/tests/sqlCatalog.mock.runner.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { afterAll, describe, expect } from 'vitest'; -import { runSqlCatalog } from './utils/sqlCatalog'; -import { sampleSqlCatalog } from './specs/sql/sample'; - -describe('sql catalog runner (mock)', () => { - const executedCaseIds = new Set(); - - runSqlCatalog(sampleSqlCatalog, { - executor: createMockSampleExecutor(), - onCaseExecuted: (id) => executedCaseIds.add(id), - }); - - afterAll(() => { - expect([...executedCaseIds].sort()).toEqual([ - 'returns-active-users', - 'returns-inactive-users-when-active-0', - ]); - }); -}); - -function createMockSampleExecutor() { - return async (_sql: string, params: Record) => { - return params.active === 0 ? [{ id: 2 }] : [{ id: 1 }]; - }; -} diff --git a/packages/ztd-cli/tests/sqlCatalog.ztd.runner.test.ts b/packages/ztd-cli/tests/sqlCatalog.ztd.runner.test.ts deleted file mode 100644 index 842698705..000000000 --- a/packages/ztd-cli/tests/sqlCatalog.ztd.runner.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { execSync } from 'node:child_process'; -import { afterAll, beforeAll, describe, expect } from 'vitest'; -import type { TableFixture } from '@rawsql-ts/testkit-core'; -import { createPgTestkitClient } from '@rawsql-ts/adapter-node-pg'; -import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql'; -import { Client } from 'pg'; -import type { TableDefinitionModel } from 'rawsql-ts'; -import { runSqlCatalog, type SqlCatalogExecutor } from './utils/sqlCatalog'; -import { sampleSqlCatalog } from './specs/sql/sample'; - -const containerRuntimeAvailable = (() => { - try { - execSync('docker info', { stdio: 'ignore', timeout: 10000 }); - return true; - } catch { - return false; - } -})(); - -const ztdDescribe = containerRuntimeAvailable ? describe : describe.skip; - -ztdDescribe('sql catalog runner (ztd)', () => { - let container: StartedPostgreSqlContainer | null = null; - const executedCaseIds = new Set(); - const fixturesAppliedHistory: string[][] = []; - const rewrittenSqlHistory: string[] = []; - - beforeAll(async () => { - // Use a disposable real Postgres instance so this test reflects ZTD execution semantics. - container = await new PostgreSqlContainer('postgres:18-alpine').start(); - }, 120000); - - runSqlCatalog(sampleSqlCatalog, { - executor: createZtdSampleExecutor({ - onExecute: (sql, _params, fixtures) => { - rewrittenSqlHistory.push(sql); - fixturesAppliedHistory.push([...(fixtures ?? [])]); - }, - getConnectionString: () => requireConnectionString(container), - }), - onCaseExecuted: (id) => executedCaseIds.add(id), - }); - - afterAll(async () => { - if (container) { - await container.stop(); - } - expect([...executedCaseIds].sort()).toEqual([ - 'returns-active-users', - 'returns-inactive-users-when-active-0', - ]); - expect(fixturesAppliedHistory).toEqual([['users'], ['users']]); - expect(rewrittenSqlHistory).toHaveLength(2); - }); -}); - -interface ZtdSampleExecutorOptions { - onExecute?: (sql: string, params?: unknown[], fixtures?: string[]) => void; - getConnectionString: () => string; -} - -function createZtdSampleExecutor(options: ZtdSampleExecutorOptions): SqlCatalogExecutor { - return async ( - sql: string, - params: Record, - fixtures: TableFixture[], - columnMap: Record - ): Promise[]> => { - const driver = createPgTestkitClient({ - connectionFactory: async () => { - const connection = new Client({ connectionString: options.getConnectionString() }); - await connection.connect(); - return connection; - }, - tableDefinitions: toTableDefinitions(fixtures), - tableRows: fixtures.map((fixture) => ({ tableName: fixture.tableName, rows: fixture.rows })), - onExecute: options.onExecute, - }); - try { - const result = await driver.query(sql, params); - return result.rows.map((row) => projectRow(row as Record, columnMap)); - } finally { - await driver.close(); - } - }; -} - -function toTableDefinitions(fixtures: TableFixture[]): TableDefinitionModel[] { - return fixtures.map((fixture) => { - const columns = getFixtureColumns(fixture).map(([name, typeName]) => ({ - name, - typeName, - })); - return { - name: fixture.tableName, - columns, - }; - }); -} - -function getFixtureColumns(fixture: TableFixture): Array<[string, string]> { - if (fixture.schema && 'columns' in fixture.schema && fixture.schema.columns) { - return Object.entries(fixture.schema.columns).map(([name, type]) => [name, String(type)]); - } - - // Fall back to row keys when schema metadata is omitted in the fixture. - const firstRow = fixture.rows[0]; - if (!firstRow || typeof firstRow !== 'object') { - return []; - } - return Object.keys(firstRow).map((name) => [name, 'text']); -} - -function requireConnectionString(container: StartedPostgreSqlContainer | null): string { - if (!container) { - throw new Error('Postgres container is not initialized for sql catalog ZTD runner test.'); - } - return container.getConnectionUri(); -} - -function projectRow( - row: Record, - columnMap: Record -): Record { - const dto: Record = {}; - for (const [dtoKey, columnName] of Object.entries(columnMap)) { - dto[dtoKey] = row[columnName]; - } - return dto; -} diff --git a/packages/ztd-cli/tests/sqlCatalogDiscovery.unit.test.ts b/packages/ztd-cli/tests/sqlCatalogDiscovery.unit.test.ts deleted file mode 100644 index af5ea15dc..000000000 --- a/packages/ztd-cli/tests/sqlCatalogDiscovery.unit.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { runCheckContract } from '../src/commands/checkContract'; -import { runTestEvidenceSpecification } from '../src/commands/testEvidence'; -import { - discoverProjectSqlCatalogSpecFiles, - loadSqlCatalogSpecsFromFile, - walkSqlCatalogSpecFiles -} from '../src/utils/sqlCatalogDiscovery'; - -function createWorkspace(prefix: string): string { - const root = mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - mkdirSync(path.join(root, 'src', 'catalog', 'specs'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'sql'), { recursive: true }); - mkdirSync(path.join(root, 'tests', 'specs'), { recursive: true }); - return root; -} - -test('sql catalog discovery keeps deterministic ordering and excludes .test. files when requested', () => { - const root = createWorkspace('sql-catalog-discovery'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'b.spec.json'), - JSON.stringify({ id: 'catalog.b', sqlFile: '../../sql/b.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'a.spec.ts'), - "export const a = { id: 'catalog.a', sqlFile: '../../sql/a.sql', params: { shape: 'positional', example: [] }, output: { mapping: { prefix: '' } } };", - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'ignored.test.ts'), - "export const ignored = { id: 'catalog.ignored', sqlFile: '../../sql/ignored.sql', params: { shape: 'named' } };", - 'utf8' - ); - - const allFiles = walkSqlCatalogSpecFiles(path.join(root, 'src', 'catalog', 'specs')); - const checkFiles = walkSqlCatalogSpecFiles(path.join(root, 'src', 'catalog', 'specs'), { excludeTestFiles: true }); - - expect(allFiles.map((filePath) => path.basename(filePath))).toEqual(['a.spec.ts', 'b.spec.json', 'ignored.test.ts']); - expect(checkFiles.map((filePath) => path.basename(filePath))).toEqual(['a.spec.ts', 'b.spec.json']); -}); - -test('sql catalog discovery finds feature-local specs under src/features', () => { - const root = createWorkspace('sql-catalog-features'); - mkdirSync(path.join(root, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'features', 'smoke', 'queries', 'smoke', 'spec.ts'), - "export const smoke = { id: 'features.smoke.queries.smoke.smoke', sqlFile: './smoke.sql', params: { shape: 'named', example: { user_id: 1 } } };", - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'smoke', 'queries', 'smoke', 'tests', 'generated', 'smoke.generated.ts'), - "export const generated = { id: 'features.smoke.queries.smoke.generated', sqlFile: './generated.sql', params: { shape: 'named' } };", - 'utf8' - ); - - const allFiles = walkSqlCatalogSpecFiles(path.join(root, 'src', 'features')); - const checkFiles = walkSqlCatalogSpecFiles(path.join(root, 'src', 'features'), { - excludeTestFiles: true, - excludeGenerated: true - }); - - expect(allFiles.map((filePath) => path.basename(filePath))).toEqual([ - 'spec.ts', - 'smoke.generated.ts' - ]); - expect(checkFiles.map((filePath) => path.basename(filePath))).toEqual(['spec.ts']); -}); - -test('project-wide spec discovery finds feature-local QuerySpecs without a fixed catalog root', () => { - const root = createWorkspace('sql-catalog-project-wide'); - mkdirSync(path.join(root, 'src', 'features', 'users', 'persistence'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'features', 'orders', 'persistence'), { recursive: true }); - mkdirSync(path.join(root, 'tests', 'fixtures'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'features', 'users', 'persistence', 'users.spec.ts'), - "export const usersSpec = { id: 'features.users.persistence.users', sqlFile: './users.sql', params: { shape: 'named', example: { id: 1 } } };", - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'features', 'orders', 'persistence', 'orders.spec.ts'), - "export const ordersSpec = { id: 'features.orders.persistence.orders', sqlFile: './orders.sql', params: { shape: 'named', example: { id: 1 } } };", - 'utf8' - ); - writeFileSync( - path.join(root, 'tests', 'fixtures', 'fake.spec.ts'), - "export const fake = { id: 'tests.fake', sqlFile: './fake.sql', params: { shape: 'named' } };", - 'utf8' - ); - - const discovered = discoverProjectSqlCatalogSpecFiles(root, { excludeTestFiles: true }); - - expect(discovered.map((filePath) => path.relative(root, filePath).replace(/\\/g, '/'))).toEqual([ - 'src/features/orders/persistence/orders.spec.ts', - 'src/features/users/persistence/users.spec.ts' - ]); -}); - -test('sql catalog discovery preserves spec ids, ordering, sqlFile, and minimal extracted fields', () => { - const root = createWorkspace('sql-catalog-minimal'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'a.spec.ts'), - "export const a = { id: 'catalog.a', sqlFile: '../../sql/a.sql', params: { shape: 'named', example: { active: 1 } }, output: { mapping: { columnMap: { id: 'user_id' } } } };", - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'b.spec.json'), - JSON.stringify({ id: 'catalog.b', sqlFile: '../../sql/b.sql', params: { shape: 'positional', example: [] } }, null, 2), - 'utf8' - ); - - const files = walkSqlCatalogSpecFiles(path.join(root, 'src', 'catalog', 'specs'), { excludeTestFiles: true }); - const loaded = files.flatMap((filePath) => loadSqlCatalogSpecsFromFile(filePath, (message) => new Error(message))); - - expect(loaded.map((entry) => ({ - id: entry.spec.id, - file: path.basename(entry.filePath), - sqlFile: entry.spec.sqlFile, - shape: entry.spec.params?.shape, - hasMapping: entry.spec.output?.mapping !== undefined - }))).toEqual([ - { - id: 'catalog.a', - file: 'a.spec.ts', - sqlFile: '../../sql/a.sql', - shape: 'named', - hasMapping: true - }, - { - id: 'catalog.b', - file: 'b.spec.json', - sqlFile: '../../sql/b.sql', - shape: 'positional', - hasMapping: false - } - ]); -}); - -test('sql catalog discovery extracts feature-local queryspec files that use loadSqlResource', () => { - const root = createWorkspace('sql-catalog-feature-queryspec'); - mkdirSync(path.join(root, 'src', 'features', 'users-insert', 'insert-users'), { recursive: true }); - writeFileSync( - path.join(root, 'src', 'features', 'users-insert', 'insert-users', 'queryspec.ts'), - [ - "import { loadSqlResource } from '../../_shared/loadSqlResource';", - '', - "const insertUsersSqlResource = loadSqlResource(__dirname, 'insert-users.sql');", - '', - 'export async function executeInsertUsersQuerySpec() {', - ' return insertUsersSqlResource;', - '}', - '' - ].join('\n'), - 'utf8' - ); - - const loaded = loadSqlCatalogSpecsFromFile( - path.join(root, 'src', 'features', 'users-insert', 'insert-users', 'queryspec.ts'), - (message) => new Error(message) - ); - - expect(loaded).toEqual([ - expect.objectContaining({ - spec: expect.objectContaining({ - sqlFile: './insert-users.sql' - }) - }) - ]); -}); - -test('shared discovery keeps runCheckContract behavior unchanged for ts/json specs', () => { - const root = createWorkspace('sql-catalog-check-contract'); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'SELECT 1', 'utf8'); - writeFileSync(path.join(root, 'src', 'sql', 'b.sql'), 'SELECT * FROM users', 'utf8'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'a.spec.ts'), - "export const a = { id: 'catalog.a', sqlFile: '../../sql/a.sql', params: { shape: 'named', example: [] } };", - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'b.spec.json'), - JSON.stringify({ id: 'catalog.b', sqlFile: '../../sql/b.sql', params: { shape: 'positional', example: [] } }, null, 2), - 'utf8' - ); - - const result = runCheckContract({ strict: true, rootDir: root }); - expect(result).toMatchObject({ - filesChecked: 2, - specsChecked: 2 - }); - expect(result.violations).toEqual([ - expect.objectContaining({ - rule: 'params-shape-mismatch', - specId: 'catalog.a' - }), - expect.objectContaining({ - rule: 'safety-select-star', - specId: 'catalog.b' - }) - ]); -}); - -test('shared discovery keeps runTestEvidenceSpecification behavior unchanged for mixed spec files', () => { - const root = createWorkspace('sql-catalog-evidence'); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'select 1', 'utf8'); - writeFileSync(path.join(root, 'src', 'sql', 'b.sql'), 'select 2', 'utf8'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'b.spec.json'), - JSON.stringify({ id: 'catalog.b', sqlFile: '../../sql/b.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'a.spec.ts'), - "export const a = { id: 'catalog.a', sqlFile: '../../sql/a.sql', params: { shape: 'positional', example: [] }, output: { mapping: { prefix: 'user_' } } };", - 'utf8' - ); - writeFileSync( - path.join(root, 'tests', 'specs', 'index.cjs'), - [ - 'module.exports = {', - ' testCaseCatalogs: [],', - ' sqlCatalogCases: []', - '};', - '' - ].join('\n'), - 'utf8' - ); - - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - expect(report.summary).toMatchObject({ - sqlCatalogCount: 2, - specFilesScanned: 2 - }); - expect(report.sqlCatalogs).toEqual([ - expect.objectContaining({ id: 'catalog.a', specFile: 'src/catalog/specs/a.spec.ts', paramsShape: 'positional', hasOutputMapping: true }), - expect.objectContaining({ id: 'catalog.b', specFile: 'src/catalog/specs/b.spec.json', paramsShape: 'named', hasOutputMapping: false }) - ]); -}); diff --git a/packages/ztd-cli/tests/sqlClientAdapterTemplate.unit.test.ts b/packages/ztd-cli/tests/sqlClientAdapterTemplate.unit.test.ts deleted file mode 100644 index 6449cda03..000000000 --- a/packages/ztd-cli/tests/sqlClientAdapterTemplate.unit.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, test, vi } from 'vitest'; - -import { fromPg } from '../templates/src/adapters/pg/sql-client'; - -test('fromPg unwraps QueryResult.rows from the underlying pg queryable', async () => { - const queryable = { - query: vi.fn().mockResolvedValue({ rows: [{ id: 1 }, { id: 2 }] }) - }; - - const client = fromPg(queryable); - const rows = await client.query<{ id: number }>('select id from users where team_id = $1', [7]); - - expect(rows).toEqual([{ id: 1 }, { id: 2 }]); - expect(queryable.query).toHaveBeenCalledWith('select id from users where team_id = $1', [7]); -}); - -test('fromPg compiles named parameters to pg placeholders', async () => { - const queryable = { - query: vi.fn().mockResolvedValue({ rows: [{ id: 7 }] }) - }; - - const client = fromPg(queryable); - const rows = await client.query<{ id: number }>('select id from users where team_id = :teamId', { - teamId: 7 - }); - - expect(rows).toEqual([{ id: 7 }]); - expect(queryable.query).toHaveBeenCalledWith('select id from users where team_id = $1', [7]); -}); - -test('fromPg forwards queries without values to the underlying pg queryable', async () => { - const queryable = { - query: vi.fn().mockResolvedValue({ rows: [{ ok: true }] }) - }; - - const client = fromPg(queryable); - const rows = await client.query<{ ok: boolean }>('select true as ok'); - - expect(rows).toEqual([{ ok: true }]); - expect(queryable.query).toHaveBeenCalledWith('select true as ok', undefined); -}); diff --git a/packages/ztd-cli/tests/sqlDebugDogfooding.cli.test.ts b/packages/ztd-cli/tests/sqlDebugDogfooding.cli.test.ts deleted file mode 100644 index 37e2caf4e..000000000 --- a/packages/ztd-cli/tests/sqlDebugDogfooding.cli.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { - SQL_DEBUG_RECOVERY_PARAMS, - SQL_DEBUG_RECOVERY_PATCH, - SQL_DEBUG_RECOVERY_QUERY, -} from './utils/sqlDebugRecoveryScenario'; - -const nodeExecutable = process.execPath; -const packageManagerExecutable = process.env.npm_execpath ?? (process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'); -const cliRoot = path.resolve(__dirname, '..'); -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const cliEntry = path.join(cliRoot, 'dist', 'index.js'); -const tmpRoot = path.join(repoRoot, 'tmp'); -let cliBuildPrepared = false; - -function createTempDir(prefix: string): string { - if (!existsSync(tmpRoot)) { - mkdirSync(tmpRoot, { recursive: true }); - } - return mkdtempSync(path.join(tmpRoot, `${prefix}-`)); -} - -function ensureBuiltCli(): void { - if (cliBuildPrepared) { - return; - } - - const buildCommand = process.platform === 'win32' - ? 'cmd.exe' - : packageManagerExecutable.endsWith('.js') || packageManagerExecutable.endsWith('.cjs') - ? nodeExecutable - : packageManagerExecutable; - const buildArgs = process.platform === 'win32' - ? ['/d', '/s', '/c', 'pnpm --filter @rawsql-ts/ztd-cli build'] - : buildCommand === nodeExecutable - ? [packageManagerExecutable, '--filter', '@rawsql-ts/ztd-cli', 'build'] - : ['--filter', '@rawsql-ts/ztd-cli', 'build']; - - const buildResult = spawnSync(buildCommand, buildArgs, { - cwd: repoRoot, - env: { - ...process.env, - NODE_ENV: 'test', - }, - encoding: 'utf8', - }); - - if (buildResult.error) { - throw buildResult.error; - } - if (buildResult.status !== 0) { - throw new Error(buildResult.stderr || buildResult.stdout || 'Failed to build ztd-cli before SQL debug dogfooding.'); - } - - cliBuildPrepared = true; -} - -function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}, cwd: string = repoRoot): SpawnSyncReturns { - ensureBuiltCli(); - - return spawnSync(nodeExecutable, [cliEntry, ...args], { - cwd, - env: { - ...process.env, - NODE_ENV: 'test', - ...envOverrides, - }, - encoding: 'utf8', - }); -} - -function assertCliSuccess(result: SpawnSyncReturns, label: string): void { - expect(result.error).toBeUndefined(); - expect(result.status, `${label}: ${result.stderr || result.stdout}`).toBe(0); -} - -test('sql debug recovery dogfood scenario preserves the shortest command loop artifact', () => { - const workspace = createTempDir('sql-debug-recovery'); - const sqlFile = path.join(workspace, 'src', 'sql', 'reports', 'customer_health.sql'); - const paramsFile = path.join(workspace, 'perf', 'params.json'); - const sliceFile = path.join(workspace, 'tmp', 'suspicious_rollup.sql'); - const editedFile = path.join(workspace, 'tmp', 'suspicious_rollup.edited.sql'); - - mkdirSync(path.dirname(sqlFile), { recursive: true }); - mkdirSync(path.dirname(paramsFile), { recursive: true }); - mkdirSync(path.dirname(sliceFile), { recursive: true }); - writeFileSync(sqlFile, SQL_DEBUG_RECOVERY_QUERY, 'utf8'); - writeFileSync(paramsFile, JSON.stringify(SQL_DEBUG_RECOVERY_PARAMS, null, 2), 'utf8'); - writeFileSync(editedFile, SQL_DEBUG_RECOVERY_PATCH, 'utf8'); - - const outlineResult = runCli(['query', 'outline', sqlFile], {}, workspace); - assertCliSuccess(outlineResult, 'query outline recovery'); - expect(outlineResult.stdout).toContain('suspicious_rollup'); - expect(outlineResult.stdout).toContain('unused_debug_cte [unused]'); - expect(outlineResult.stdout).toContain('Final query target:'); - - const lintResult = runCli(['query', 'lint', sqlFile, '--format', 'json'], {}, workspace); - assertCliSuccess(lintResult, 'query lint recovery'); - const lintParsed = JSON.parse(lintResult.stdout); - expect(lintParsed).toMatchObject({ - issue_count: 1, - issues: [ - expect.objectContaining({ - type: 'unused-cte', - cte: 'unused_debug_cte', - }), - ], - }); - - const sliceResult = runCli(['query', 'slice', sqlFile, '--cte', 'suspicious_rollup', '--out', sliceFile], {}, workspace); - assertCliSuccess(sliceResult, 'query slice recovery'); - const slicedSql = readFileSync(sliceFile, 'utf8'); - expect(slicedSql).toContain('from "customer_rollup"'); - expect(slicedSql).not.toContain('unused_debug_cte'); - - const patchPreviewResult = runCli( - ['query', 'patch', 'apply', sqlFile, '--cte', 'suspicious_rollup', '--from', editedFile, '--preview'], - {}, - workspace, - ); - assertCliSuccess(patchPreviewResult, 'query patch recovery'); - expect(patchPreviewResult.stdout).toContain('Index:'); - expect(patchPreviewResult.stdout).toContain('dense_rank()'); - - const directResult = runCli( - ['--output', 'json', 'perf', 'run', '--query', sqlFile, '--params', paramsFile, '--mode', 'latency', '--dry-run'], - {}, - workspace, - ); - assertCliSuccess(directResult, 'perf direct recovery'); - const directParsed = JSON.parse(directResult.stdout); - expect(directParsed.data).toMatchObject({ - strategy: 'direct', - pipeline_analysis: expect.objectContaining({ - should_consider_pipeline: true, - candidate_ctes: [expect.objectContaining({ name: 'customer_rollup' })], - }), - executed_statements: [expect.objectContaining({ seq: 1, role: 'final-query', target: 'FINAL_QUERY' })], - }); - expect(directParsed.data.executed_statements[0].resolved_sql_preview).toContain('where rollup_rank <= 5'); - - const decomposedResult = runCli( - [ - '--output', - 'json', - 'perf', - 'run', - '--query', - sqlFile, - '--params', - paramsFile, - '--strategy', - 'decomposed', - '--material', - 'customer_rollup', - '--mode', - 'latency', - '--dry-run', - ], - {}, - workspace, - ); - assertCliSuccess(decomposedResult, 'perf decomposed recovery'); - const decomposedParsed = JSON.parse(decomposedResult.stdout); - expect(decomposedParsed.data).toMatchObject({ - strategy: 'decomposed', - strategy_metadata: { - materialized_ctes: ['customer_rollup'], - planned_steps: [ - expect.objectContaining({ step: 1, kind: 'materialize', target: 'customer_rollup' }), - expect.objectContaining({ step: 2, kind: 'final-query', target: 'FINAL_QUERY' }), - ], - }, - executed_statements: [ - expect.objectContaining({ seq: 1, role: 'materialize', target: 'customer_rollup' }), - expect.objectContaining({ seq: 2, role: 'final-query', target: 'FINAL_QUERY' }), - ], - }); -}, 60_000); diff --git a/packages/ztd-cli/tests/sqlFirstTutorial.docs.test.ts b/packages/ztd-cli/tests/sqlFirstTutorial.docs.test.ts deleted file mode 100644 index 95eb67d2c..000000000 --- a/packages/ztd-cli/tests/sqlFirstTutorial.docs.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function readNormalizedFile(relativePath: string): string { - const filePath = path.join(repoRoot, relativePath); - return readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); -} - -function expectInOrder(haystack: string, needles: string[]): void { - let cursor = 0; - for (const needle of needles) { - const index = haystack.indexOf(needle, cursor); - expect(index, `Expected to find "${needle}" after offset ${cursor}`).toBeGreaterThanOrEqual(0); - cursor = index + needle.length; - } -} - -test('root README links to the SQL-first end-to-end tutorial', () => { - const readme = readNormalizedFile('README.md'); - - expect(readme).toContain('SQL-first End-to-End Tutorial'); - expect(readme).toContain('./docs/guide/sql-first-end-to-end-tutorial.md'); -}); - -test('the tutorial preserves the shortest DDL to first test path', () => { - const tutorial = readNormalizedFile('docs/guide/sql-first-end-to-end-tutorial.md'); - - expectInOrder(tutorial, [ - 'This tutorial shows the shortest path from `ztd init --starter` to a small `users` feature that can be changed, broken, and repaired with AI help.', - 'The tutorial uses one starter project, one `smoke` feature, and one `users` feature.', - 'DDL repair | `npx ztd query uses column users.email --scope-dir src/features/users-insert --any-schema --view detail`', - 'SQL repair | `npx ztd model-gen --probe-mode ztd src/features/users-insert/queries/insert-users/insert-users.sql`', - 'DTO repair | `npx vitest run` after the DTO change', - 'migration | `npx ztd ztd-config`, optionally `npx ztd ddl pull --url ` to inspect the target, then `npx ztd ddl diff --url --out tmp/users.diff.sql` to prepare review output plus apply SQL', - 'tuning | `npx ztd query plan ` and the perf guide under `docs/guide/`', - 'npx ztd init --starter', - 'src/features/smoke', - 'db/ddl/public.sql', - 'The smallest DB-backed starter example lives in `src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts`.', - 'It uses `@rawsql-ts/testkit-postgres` and `createPostgresTestkitClient`', - 'Docker Desktop or another Docker daemon is already running', - 'cp .env.example .env', - '# edit ZTD_DB_PORT=5433 if needed', - 'docker compose up -d', - 'npx vitest run', - 'The starter setup derives `ZTD_DB_URL` from `.env`', - 'If port `5432` is already in use, update `ZTD_DB_PORT` in `.env` before you rerun the compose path, for example:', - 'Copy-Item .env.example .env', - '# edit ZTD_DB_PORT=5433', - 'docker compose up -d', - 'npx vitest run', - 'Use `src/features/smoke` as the starter-only teaching example, but scaffold the first real CRUD slice with the CLI:', - 'npx ztd feature scaffold --table users --action insert', - 'src/features/users-insert/boundary.ts', - 'src/features/users-insert/tests/users-insert.boundary.test.ts', - 'src/features/users-insert/queries/insert-users/boundary.ts', - 'src/features/users-insert/queries/insert-users/insert-users.sql', - 'src/features/users-insert/queries/insert-users/tests/', - ]); - - expect(tutorial).toContain('queries/insert-users/tests/insert-users.boundary.ztd.test.ts'); - expect(tutorial).toContain('queries/insert-users/tests/generated/'); - expect(tutorial).toContain('queries/insert-users/tests/cases/'); - expect(tutorial).toContain('persistent case files'); - expect(tutorial).toContain('Add a users insert feature to this feature-first project.'); - expect(tutorial).not.toContain('AGENTS.md'); - expect(tutorial).not.toContain('.codex/agents'); - expect(tutorial).not.toContain('.ztd/agents'); - expect(tutorial).toContain('Start with `npx ztd feature scaffold --table users --action insert`.'); - expect(tutorial).toContain('`src/features`, `src/adapters`, and `src/libraries` are the concrete `root-boundary` folders.'); - expect(tutorial).toContain('`src/features/users-insert/` is the `feature-boundary`, and `queries/insert-users/` is one `sub-boundary`.'); - expect(tutorial).toContain('`queries/` is only the child-boundary container; it does not expose its own public surface.'); - expect(tutorial).toContain('Keep `boundary.ts`, the query-local `boundary.ts`, and the query-local SQL resource inside `src/features/users-insert`.'); - expect(tutorial).toContain('In the full RFBA model, `src/features`, `src/adapters`, and `src/libraries` are the concrete `root-boundary` folders.'); - expect(tutorial).toContain('RFBA is about splitting files by review responsibility'); - expect(tutorial).toContain('Use `root-boundary`, `feature-boundary`, and `sub-boundary` as the ztd-cli structural vocabulary for RFBA.'); - expect(tutorial).toContain('Treat RFBA as review-responsibility structure, not as a universal file naming rule.'); - expect(tutorial).toContain('Keep shared feature seams under `src/features/_shared/*`, shared verification seams under `tests/support/*`, driver-neutral contracts under `src/libraries/*`, and driver or sink bindings under `src/adapters//*`.'); - expect(tutorial).toContain('The feature scaffold creates the boundary files, SQL file, feature-root boundary test, and machine-owned `generated/row-mapper.ts`.'); - expect(tutorial).toContain('npx ztd feature generated-mapper check --feature users-insert'); - expect(tutorial).toContain('npx ztd feature generated-mapper generate --feature users-insert --query insert-users'); - expect(tutorial).toContain('That command refreshes `src/features/users-insert/queries/insert-users/tests/generated/TEST_PLAN.md` and `analysis.json`, refreshes `src/features/users-insert/queries/insert-users/tests/boundary-ztd-types.ts`, and creates the thin `src/features/users-insert/queries/insert-users/tests/insert-users.boundary.ztd.test.ts` Vitest entrypoint only if it is missing.'); - expect(tutorial).toContain('Persistent case files under `src/features/users-insert/queries/insert-users/tests/cases/` stay human/AI-owned and are not overwritten.'); - expect(tutorial).toContain('The validation cases may stay at the feature boundary, but the success case must execute through the fixed app-level ZTD runner and verify the returned result.'); - expect(tutorial).toContain('Do not put returned columns into the input fixture.'); - expect(tutorial).toContain('If the returned id is null, stop and fix the scaffold or DDL instead of weakening the test.'); - expect(tutorial).toContain('Before writing the success-path assertion, inspect `insert-users.sql` and `boundary.ts`. If the scaffold does not actually return a non-null id, report that mismatch instead of inventing fixture data or schema overrides.'); - expect(tutorial).toContain('When the cases are ready, run `npx vitest run src/features/users-insert/queries/insert-users/tests/insert-users.boundary.ztd.test.ts` to execute the ZTD query test.'); - expect(tutorial).toContain('npx ztd query uses column users.email --scope-dir src/features/users-insert --any-schema --view detail'); - expect(tutorial).toContain('Passing the feature folder as `--scope-dir` is a normal way to narrow the project-wide scan, not a workaround for feature-local layouts.'); - expect(tutorial).toContain('For SQL repair, keep the SQL assets under `src/features/users-insert/queries/insert-users/`, keep the query on the starter DDL\'s `users` table, and rerun `model-gen` against `src/features/users-insert/queries/insert-users/insert-users.sql` directly to inspect the generated contract on stdout before you update the handwritten query boundary.'); - expect(tutorial).toContain('If you want to save that output for reference or gradual migration, write it to a dedicated generated-contract file with `--out` instead of overwriting handwritten runtime files.'); - expect(tutorial).toContain('Do not target `src/features/users-insert/queries/insert-users/boundary.ts` with `--out`, because that file is the runtime boundary that also owns `loadSqlResource` and the execution flow.'); - expect(tutorial).toContain('In VSA layouts, `model-gen` now treats the SQL file location as the primary contract source, so `--sql-root` is only needed for older shared-root layouts.'); - expect(tutorial).toContain('npx ztd ztd-config'); - expect(tutorial).toContain('npx ztd ddl diff'); - - expect(tutorial).toContain('npx ztd ddl risk --file tmp/users.diff.sql'); - expect(tutorial).toContain('generated `tableDefinitions` are the normal runtime path after `ztd-config`'); - expect(tutorial).toContain('explicit `tableDefinitions` / `tableRows` are for local tests that want direct fixtures'); - expect(tutorial).toContain('`ddl.directories` is the fallback only when no generated manifest exists'); - expect(tutorial).not.toContain('Every boundary folder exposes only `boundary.ts`'); - expect(tutorial).not.toContain('A folder is a boundary'); - expect(tutorial).toContain('the agent edits the `users-insert` feature only'); -}); - -test('guide navigation and feature index surface the tutorial', () => { - const sidebar = readNormalizedFile('docs/.vitepress/config.mts'); - const featureIndex = readNormalizedFile('docs/guide/feature-index.md'); - - expect(sidebar).toContain('/guide/sql-first-end-to-end-tutorial'); - expect(featureIndex).toContain('SQL-first End-to-End Tutorial'); - expect(featureIndex).toContain('./sql-first-end-to-end-tutorial.md'); -}); diff --git a/packages/ztd-cli/tests/telemetry.unit.test.ts b/packages/ztd-cli/tests/telemetry.unit.test.ts deleted file mode 100644 index 388852a07..000000000 --- a/packages/ztd-cli/tests/telemetry.unit.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; -import { - beginCommandSpan, - configureTelemetry, - emitDecisionEvent, - finishCommandSpan, - flushTelemetry, - getTelemetryExportMode, - recordException, - resolveTelemetryExportMode, - setTelemetryEnabled, - withSpan, - withSpanSync, -} from '../src/utils/telemetry'; - -const originalTelemetry = process.env.ZTD_CLI_TELEMETRY; -const originalTelemetryExport = process.env.ZTD_CLI_TELEMETRY_EXPORT; -const originalTelemetryFile = process.env.ZTD_CLI_TELEMETRY_FILE; -const originalTelemetryEndpoint = process.env.ZTD_CLI_TELEMETRY_OTLP_ENDPOINT; - -function createWorkspace(prefix: string): string { - const tmpRoot = path.join(process.cwd(), 'tmp'); - mkdirSync(tmpRoot, { recursive: true }); - return mkdtempSync(path.join(tmpRoot, prefix + '-')); -} - -afterEach(async () => { - if (originalTelemetry === undefined) { - delete process.env.ZTD_CLI_TELEMETRY; - } else { - process.env.ZTD_CLI_TELEMETRY = originalTelemetry; - } - - if (originalTelemetryExport === undefined) { - delete process.env.ZTD_CLI_TELEMETRY_EXPORT; - } else { - process.env.ZTD_CLI_TELEMETRY_EXPORT = originalTelemetryExport; - } - - if (originalTelemetryFile === undefined) { - delete process.env.ZTD_CLI_TELEMETRY_FILE; - } else { - process.env.ZTD_CLI_TELEMETRY_FILE = originalTelemetryFile; - } - - if (originalTelemetryEndpoint === undefined) { - delete process.env.ZTD_CLI_TELEMETRY_OTLP_ENDPOINT; - } else { - process.env.ZTD_CLI_TELEMETRY_OTLP_ENDPOINT = originalTelemetryEndpoint; - } - - configureTelemetry({ enabled: false }); - await flushTelemetry(); - vi.restoreAllMocks(); -}); - -test('telemetry is a no-op by default', async () => { - const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - void chunk; - return true; - }) as typeof process.stderr.write); - - configureTelemetry({ enabled: false }); - beginCommandSpan('ztd-config'); - emitDecisionEvent('command.selected', { command: 'ztd-config' }); - await withSpan('phase', async () => undefined); - recordException(new Error('ignored')); - finishCommandSpan('ok'); - await flushTelemetry(); - - expect(writeSpy).not.toHaveBeenCalled(); -}); - -test('telemetry defaults to console export when enabled without an explicit export mode', async () => { - const lines: string[] = []; - vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - lines.push(String(chunk).trim()); - return true; - }) as typeof process.stderr.write); - - setTelemetryEnabled(true); - configureTelemetry(); - expect(getTelemetryExportMode()).toBe('console'); - - beginCommandSpan('ztd-config', { outputFormat: 'json' }); - emitDecisionEvent('command.selected', { command: 'ztd-config' }); - await expect(withSpan('generate', async () => { - emitDecisionEvent('output.json-envelope'); - throw new Error('boom'); - })).rejects.toThrow('boom'); - recordException(new Error('root failure'), { scope: 'command-root' }); - finishCommandSpan('error'); - await flushTelemetry(); - - const payloads = lines.filter(Boolean).map((line) => JSON.parse(line)); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: 'telemetry', kind: 'span-start', spanName: 'ztd-config' }), - expect.objectContaining({ type: 'telemetry', kind: 'span-start', spanName: 'generate' }), - expect.objectContaining({ type: 'telemetry', kind: 'decision', eventName: 'output.json-envelope' }), - expect.objectContaining({ type: 'telemetry', kind: 'exception', spanId: expect.any(String) }), - expect.objectContaining({ type: 'telemetry', kind: 'span-end', spanName: 'ztd-config', status: 'error' }), - ]), - ); -}); - -test('debug export emits human-readable telemetry lines for local inspection', async () => { - const lines: string[] = []; - vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - lines.push(String(chunk)); - return true; - }) as typeof process.stderr.write); - - configureTelemetry({ enabled: true, exportMode: 'debug' }); - beginCommandSpan('query uses table'); - emitDecisionEvent('command.selected', { command: 'query uses table' }); - finishCommandSpan('ok'); - await flushTelemetry(); - - const serialized = lines.join(''); - expect(serialized).toContain('[telemetry] span-start query uses table'); - expect(serialized).toContain('[telemetry] decision command.selected'); - expect(serialized).toContain('[telemetry] span-end query uses table'); - expect(() => JSON.parse(serialized)).toThrow(); -}); - -test('file export writes JSONL telemetry that CI can archive', async () => { - const workspace = createWorkspace('telemetry-file'); - const filePath = path.join(workspace, 'artifacts', 'telemetry.jsonl'); - - configureTelemetry({ enabled: true, exportMode: 'file', filePath }); - beginCommandSpan('ddl diff'); - await withSpan('collect-local-ddl', async () => undefined, { localFileCount: 2 }); - finishCommandSpan('ok'); - await flushTelemetry(); - - expect(existsSync(filePath)).toBe(true); - const payloads = readFileSync(filePath, 'utf8') - .trim() - .split(/\r?\n/) - .map((line) => JSON.parse(line)); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'ddl diff' }), - expect.objectContaining({ kind: 'span-start', spanName: 'collect-local-ddl' }), - expect.objectContaining({ kind: 'span-end', spanName: 'ddl diff', status: 'ok' }), - ]), - ); -}); - -test('otlp export posts completed spans to an OTLP HTTP endpoint', async () => { - const fetchSpy = vi.fn(async () => ({ ok: true })); - vi.stubGlobal('fetch', fetchSpy); - - configureTelemetry({ enabled: true, exportMode: 'otlp', otlpEndpoint: 'http://127.0.0.1:4318/v1/traces' }); - beginCommandSpan('model-gen'); - emitDecisionEvent('model-gen.probe-mode', { probeMode: 'live' }); - await withSpan('probe-client-connect', async () => undefined, { probeMode: 'live' }); - finishCommandSpan('ok'); - await flushTelemetry(); - - expect(fetchSpy).toHaveBeenCalled(); - const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('http://127.0.0.1:4318/v1/traces'); - const body = JSON.parse(String(init.body)); - expect(body.resourceSpans[0].scopeSpans[0].spans[0]).toEqual( - expect.objectContaining({ - name: 'probe-client-connect', - traceId: expect.any(String), - spanId: expect.any(String), - parentSpanId: expect.any(String), - }), - ); - expect(body.resourceSpans[0].scopeSpans[0].spans[0].events).toEqual([]); -}); - -test('decision events keep only schema-approved attributes and redact sensitive payloads', async () => { - const lines: string[] = []; - vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - lines.push(String(chunk).trim()); - return true; - }) as typeof process.stderr.write); - - const dsn = 'postgres://demo:secret@localhost:5432/app'; - const libpqDsn = 'host=localhost port=5432 dbname=app user=demo password=secret'; - const sql = 'SELECT email FROM public.users WHERE password = :password'; - const filesystemDump = Array.from({ length: 20 }, (_, index) => `C:/tmp/file-${index}.sql`).join('\n'); - - setTelemetryEnabled(true); - configureTelemetry(); - beginCommandSpan('model-gen', { - connectionUrl: dsn, - scope: 'command-root', - }); - emitDecisionEvent('model-gen.probe-mode', { - probeMode: 'live', - sqlText: sql, - connectionUrl: dsn, - }); - await withSpan('probe-client-connect', async () => undefined, { - databaseUrl: dsn, - sqlText: sql, - outFile: 'src/catalog/specs/generated/getSales.generated.ts', - }); - recordException(new Error(`Probe failed for ${libpqDsn} while running ${sql}`), { - bindValues: '[1,"secret"]', - filesystemDump, - }); - finishCommandSpan('error'); - await flushTelemetry(); - - const serialized = lines.join('\n'); - expect(serialized).not.toContain(dsn); - expect(serialized).not.toContain(libpqDsn); - expect(serialized).not.toContain(sql); - expect(serialized).not.toContain('secret'); - expect(serialized).not.toContain(filesystemDump); - - const payloads = lines.filter(Boolean).map((line) => JSON.parse(line)); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'decision', - eventName: 'model-gen.probe-mode', - attributes: { probeMode: 'live' }, - }), - expect.objectContaining({ - kind: 'span-start', - spanName: 'probe-client-connect', - attributes: { - databaseUrl: '[REDACTED]', - sqlText: '[REDACTED]', - outFile: 'src/catalog/specs/generated/getSales.generated.ts', - }, - }), - expect.objectContaining({ - kind: 'exception', - error: { - name: 'Error', - message: '[REDACTED]', - }, - attributes: { - bindValues: '[REDACTED]', - filesystemDump: '[REDACTED]', - }, - }), - ]), - ); -}); - -test('authorization-style secrets are redacted by key and value detectors', async () => { - const lines: string[] = []; - vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - lines.push(String(chunk).trim()); - return true; - }) as typeof process.stderr.write); - - const apiKey = 'demoApiKey12345'; - const accessKey = 'demoAccessKey67890'; - const authorization = 'Bearer demo-token-abcdef12'; - - setTelemetryEnabled(true); - configureTelemetry(); - beginCommandSpan('model-gen', { - apiKey, - scope: 'command-root', - }); - await withSpan('probe-client-connect', async () => undefined, { - authorization, - accessKey, - note: authorization, - }); - recordException(new Error(`authorization=${authorization}`), { - apiKey, - }); - finishCommandSpan('error'); - await flushTelemetry(); - - const serialized = lines.join('\n'); - expect(serialized).not.toContain(apiKey); - expect(serialized).not.toContain(accessKey); - expect(serialized).not.toContain(authorization); - - const payloads = lines.filter(Boolean).map((line) => JSON.parse(line)); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'span-start', - spanName: 'model-gen', - attributes: { - apiKey: '[REDACTED]', - scope: 'command-root', - }, - }), - expect.objectContaining({ - kind: 'span-start', - spanName: 'probe-client-connect', - attributes: { - authorization: '[REDACTED]', - accessKey: '[REDACTED]', - note: '[REDACTED]', - }, - }), - expect.objectContaining({ - kind: 'exception', - error: { - name: 'Error', - message: '[REDACTED]', - }, - attributes: { - apiKey: '[REDACTED]', - }, - }), - ]), - ); -}); - -test('benign oversized and multiline attributes use truncation without redaction', async () => { - const lines: string[] = []; - vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - lines.push(String(chunk).trim()); - return true; - }) as typeof process.stderr.write); - - const multilineNote = ['phase summary', 'line two', 'line three'].join('\n'); - const longNote = 'safe-output-'.repeat(20); - - setTelemetryEnabled(true); - configureTelemetry(); - beginCommandSpan('query uses table'); - await withSpan('render-query-usage-output', async () => 'ok', { - summary: multilineNote, - preview: longNote, - }); - finishCommandSpan('ok'); - await flushTelemetry(); - - const payloads = lines.filter(Boolean).map((line) => JSON.parse(line)); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'span-start', - spanName: 'render-query-usage-output', - attributes: { - summary: `[TRUNCATED:${multilineNote.length}]`, - preview: `[TRUNCATED:${longNote.length}]`, - }, - }), - ]), - ); - - const serialized = lines.join('\n'); - expect(serialized).not.toContain(multilineNote); - expect(serialized).not.toContain(longNote); - expect(serialized).not.toContain('[REDACTED]'); -}); - -test('withSpanSync emits synchronous child span lifecycle when enabled', async () => { - const lines: string[] = []; - vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - lines.push(String(chunk).trim()); - return true; - }) as typeof process.stderr.write); - - setTelemetryEnabled(true); - configureTelemetry(); - beginCommandSpan('ddl diff'); - const value = withSpanSync('compute-diff-plan', () => 'ok', { localFileCount: 2 }); - finishCommandSpan('ok'); - await flushTelemetry(); - - expect(value).toBe('ok'); - const payloads = lines.filter(Boolean).map((line) => JSON.parse(line)); - expect(payloads).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'compute-diff-plan', attributes: { localFileCount: 2 } }), - expect.objectContaining({ kind: 'span-end', spanName: 'compute-diff-plan', status: 'ok' }), - ]), - ); -}); - -test('telemetry export mode falls back to env configuration', () => { - process.env.ZTD_CLI_TELEMETRY_EXPORT = 'debug'; - expect(resolveTelemetryExportMode(undefined)).toBe('debug'); -}); diff --git a/packages/ztd-cli/tests/testCaseCatalog.evidence.test.ts b/packages/ztd-cli/tests/testCaseCatalog.evidence.test.ts deleted file mode 100644 index 0b13225b5..000000000 --- a/packages/ztd-cli/tests/testCaseCatalog.evidence.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { exportTestCaseCatalogEvidence } from './utils/testCaseCatalog'; -import { alphaCatalog, emailCatalog } from './specs/testCaseCatalogs'; - -describe('test case catalog evidence', () => { - it('is deterministic and sorted by catalog/case id', () => { - const unsortedCatalogs = [emailCatalog, alphaCatalog]; - const exported1 = exportTestCaseCatalogEvidence([...unsortedCatalogs]); - const exported2 = exportTestCaseCatalogEvidence([...unsortedCatalogs]); - - expect(exported1).toEqual(exported2); - expect(exported1.catalogs.map((catalog) => catalog.id)).toEqual([ - 'unit.alpha', - 'unit.normalize-email', - ]); - const normalize = exported1.catalogs.find( - (catalog) => catalog.id === 'unit.normalize-email' - ); - expect(normalize).toBeDefined(); - expect(normalize!.cases.map((item) => item.id)).toEqual([ - 'accepts-minimal-domain', - 'keeps-plus-alias', - 'keeps-valid-address', - 'rejects-invalid-input', - 'throws-empty-after-trim', - 'trims-and-lowercases', - ]); - expect(normalize!.cases.find((item) => item.id === 'rejects-invalid-input')).toMatchObject({ - expected: 'throws', - error: { name: 'Error', message: 'invalid email', match: 'contains' } - }); - }); -}); diff --git a/packages/ztd-cli/tests/testCaseCatalog.runner.test.ts b/packages/ztd-cli/tests/testCaseCatalog.runner.test.ts deleted file mode 100644 index 34c49ba2f..000000000 --- a/packages/ztd-cli/tests/testCaseCatalog.runner.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { afterAll, describe, expect } from 'vitest'; -import { runTestCaseCatalog } from './utils/testCaseCatalog'; -import { emailCatalog } from './specs/testCaseCatalogs'; - -describe('test case catalog runner', () => { - const executedCaseIds = new Set(); - - runTestCaseCatalog(emailCatalog, { - onCaseExecuted: (id) => executedCaseIds.add(id), - }); - - afterAll(() => { - expect([...executedCaseIds].sort()).toEqual([ - 'accepts-minimal-domain', - 'keeps-plus-alias', - 'keeps-valid-address', - 'rejects-invalid-input', - 'throws-empty-after-trim', - 'trims-and-lowercases', - ]); - }); -}); diff --git a/packages/ztd-cli/tests/testDocumentationDogfooding.cli.test.ts b/packages/ztd-cli/tests/testDocumentationDogfooding.cli.test.ts deleted file mode 100644 index 0c28a6386..000000000 --- a/packages/ztd-cli/tests/testDocumentationDogfooding.cli.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { Command } from 'commander'; -import { afterEach, expect, test } from 'vitest'; -import { registerTestEvidenceCommand } from '../src/commands/testEvidence'; - -const originalProjectRoot = process.env.ZTD_PROJECT_ROOT; -const originalExitCode = process.exitCode; - -afterEach(() => { - process.env.ZTD_PROJECT_ROOT = originalProjectRoot; - process.exitCode = originalExitCode; -}); - -function createWorkspace(prefix: string): string { - const root = mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - mkdirSync(path.join(root, 'src', 'catalog', 'specs'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'sql'), { recursive: true }); - mkdirSync(path.join(root, 'tests', 'specs'), { recursive: true }); - return root; -} - -function writeDogfoodAssets(root: string): void { - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'orders.spec.json'), - JSON.stringify( - { - id: 'orders.active-users.list', - sqlFile: '../../sql/orders.active-users.list.sql', - params: { shape: 'named', example: { active: 1, limit: 2 } }, - }, - null, - 2, - ), - 'utf8', - ); - writeFileSync( - path.join(root, 'src', 'sql', 'orders.active-users.list.sql'), - 'select order_id from orders where active = @active order by order_id limit @limit', - 'utf8', - ); - - const specModule = [ - 'module.exports = {', - 'testCaseCatalogs: [', - ' {', - " id: 'unit.sales',", - " title: 'sales service',", - " definitionPath: 'tests/specs/sales.catalog.ts',", - " refs: [{ label: 'Issue #552', url: 'https://github.com/mk3008/rawsql-ts/pull/552' }],", - ' cases: [', - " { id: 'happy-path', title: 'happy path', input: { customerId: 1 }, expected: 'success', output: [{ saleId: 10 }], tags: ['normalization', 'ep'], focus: 'Confirms the exported happy-path coverage for the current sales service.' }", - ' ]', - ' }', - '],', - 'sqlCatalogCases: [', - ' {', - " id: 'sql.active-orders',", - " title: 'active orders',", - " definitionPath: 'src/specs/sql/activeOrders.catalog.ts',", - ' fixtures: [', - " { tableName: 'orders', rows: [{ order_id: 10, active: 1 }], schema: { columns: { order_id: 'INTEGER', active: 'INTEGER' } } }", - ' ],', - ' catalog: {', - " id: 'orders.active-users.list',", - " params: { shape: 'named', example: { active: 1, limit: 2 } },", - " output: { mapping: { columnMap: { orderId: 'order_id' } } },", - " sql: 'select order_id from orders where active = @active order by order_id limit @limit'", - ' },', - ' cases: [', - " { id: 'baseline', title: 'baseline', expected: [{ orderId: 10 }], refs: [{ label: 'Catalog note', url: 'https://example.invalid/catalog-note' }] }", - ' ]', - ' }', - ']', - '};', - '', - ].join('\n'); - - writeFileSync(path.join(root, 'tests', 'specs', 'index.cjs'), specModule, 'utf8'); -} - -function createProgram(): Command { - const program = new Command(); - program.exitOverride(); - registerTestEvidenceCommand(program); - return program; -} - -test('test documentation dogfood scenario preserves the shortest export loop artifact', async () => { - const root = createWorkspace('evidence-test-doc-dogfood'); - writeDogfoodAssets(root); - - process.env.ZTD_PROJECT_ROOT = root; - const outFile = path.join(root, 'artifacts', 'test-evidence', 'test-documentation.md'); - const program = createProgram(); - - // Use the real CLI command path so the saved dogfood artifact matches maintainer usage. - await program.parseAsync(['evidence', 'test-doc', '--out', outFile], { from: 'user' }); - - expect(process.exitCode).toBe(0); - const markdown = readFileSync(outFile, 'utf8'); - - // Assert the exact sections that make the exported document actionable without opening source files. - expect(markdown).toContain('# ZTD Test Documentation'); - expect(markdown).toContain('- catalogs: 2'); - expect(markdown).toContain('## sql.active-orders - active orders'); - expect(markdown).toContain('- targetType: sql-catalog'); - expect(markdown).toContain('- fixtures: orders'); - expect(markdown).toContain('### baseline - baseline'); - expect(markdown).toContain('#### Input / Setup'); - expect(markdown).toContain('#### Expected Result'); - expect(markdown).toContain('## unit.sales - sales service'); - expect(markdown).toContain('- purpose: Confirms the exported happy-path coverage for the current sales service.'); - expect(markdown).toContain('- coverage: normal; tags=[normalization, ep]'); - expect(markdown).toContain('[tests/specs/sales.catalog.ts]'); - expect(markdown).toContain('[src/specs/sql/activeOrders.catalog.ts]'); -}); diff --git a/packages/ztd-cli/tests/testEvidence.cli.test.ts b/packages/ztd-cli/tests/testEvidence.cli.test.ts deleted file mode 100644 index 16ecbf6a5..000000000 --- a/packages/ztd-cli/tests/testEvidence.cli.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { Command } from 'commander'; -import { afterEach, expect, test } from 'vitest'; -import { - registerTestEvidenceCommand, - resolveTestEvidenceExitCode, - TestEvidenceRuntimeError -} from '../src/commands/testEvidence'; - -const originalProjectRoot = process.env.ZTD_PROJECT_ROOT; -const originalExitCode = process.exitCode; - -afterEach(() => { - process.env.ZTD_PROJECT_ROOT = originalProjectRoot; - process.exitCode = originalExitCode; -}); - -function createWorkspace(prefix: string): string { - const root = mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - mkdirSync(path.join(root, 'src', 'catalog', 'specs'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'sql'), { recursive: true }); - mkdirSync(path.join(root, 'tests', 'specs'), { recursive: true }); - return root; -} - -function writeSpecModule(root: string): void { - const source = [ - 'module.exports = {', - 'testCaseCatalogs: [', - ' {', - " id: 'unit.users',", - " title: 'users',", - " definitionPath: 'tests/specs/users.catalog.ts',", - " cases: [{ id: 'lists-users', title: 'lists users', input: { active: 1 }, expected: 'success', output: [{ id: 1 }], tags: ['normalization', 'ep'], focus: 'Ensures user listing behavior remains deterministic.' }]", - ' }', - '],', - 'sqlCatalogCases: [', - ' {', - " id: 'sql.active-orders',", - " title: 'active orders',", - " definitionPath: 'src/specs/sql/activeOrders.catalog.ts',", - ' fixtures: [', - " { tableName: 'users', rows: [{ id: 1 }], schema: { columns: { id: 'INTEGER' } } }", - ' ],', - ' catalog: {', - " id: 'orders.active-users.list',", - " params: { shape: 'named', example: { active: 1, minTotal: 20, limit: 2 } },", - " output: { mapping: { columnMap: { orderId: 'order_id' } } },", - " sql: 'select order_id from orders where active = @active'", - ' },', - " cases: [", - " { id: 'baseline', title: 'baseline', expected: [{ orderId: 10 }] },", - " { id: 'inactive', title: 'inactive', arrange: () => ({ active: 0 }), expected: [{ orderId: 12 }] }", - " ]", - ' }', - ']', - '};', - '' - ].join('\n'); - writeFileSync(path.join(root, 'tests', 'specs', 'index.cjs'), source, 'utf8'); -} - -function createProgram(): Command { - const program = new Command(); - program.exitOverride(); - registerTestEvidenceCommand(program); - return program; -} - -function createCapturingProgram(capture: { stdout: string[]; stderr: string[] }): Command { - const program = new Command(); - program.exitOverride(); - program.configureOutput({ - writeOut: (str) => capture.stdout.push(str), - writeErr: (str) => capture.stderr.push(str) - }); - registerTestEvidenceCommand(program); - return program; -} - -function escapeRegex(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function expectDefinitionLinkPathOrGithub(markdown: string, definitionPath: string): void { - const escapedPath = escapeRegex(definitionPath); - const pattern = new RegExp( - `definition: \\[${escapedPath}\\]\\((?:\\.\\./${escapedPath}|https://github\\.com/[^\\s)]+/blob/[^\\s)]+/${escapedPath})\\)` - ); - expect(markdown).toMatch(pattern); -} - -test('CLI: evidence writes json and markdown artifacts', async () => { - const root = createWorkspace('evidence-cli-pass'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'orders.active-users.list', sqlFile: '../../sql/orders.active-users.list.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'orders.active-users.list.sql'), 'select user_id from users', 'utf8'); - writeSpecModule(root); - - process.env.ZTD_PROJECT_ROOT = root; - const outDir = path.join(root, 'artifacts'); - const program = createProgram(); - await program.parseAsync(['evidence', '--mode', 'specification', '--format', 'both', '--out-dir', outDir], { from: 'user' }); - - expect(process.exitCode).toBe(0); - const parsedJson = JSON.parse(readFileSync(path.join(outDir, 'test-specification.json'), 'utf8')); - expect(parsedJson.summary).toMatchObject({ sqlCatalogCount: 1, sqlCaseCatalogCount: 1, testCaseCount: 1 }); - expect(parsedJson.testCases[0]).toMatchObject({ id: 'unit.users.lists-users', filePath: 'tests/specs/index' }); - expect(parsedJson.sqlCaseCatalogs[0]).toMatchObject({ - id: 'sql.active-orders', - cases: [ - { id: 'baseline', params: { active: 1, limit: 2, minTotal: 20 }, expected: [{ orderId: 10 }] }, - { id: 'inactive', params: { active: 0, limit: 2, minTotal: 20 }, expected: [{ orderId: 12 }] } - ] - }); - expect(parsedJson.testCaseCatalogs[0]).toMatchObject({ - id: 'unit.users', - definitionPath: 'tests/specs/users.catalog.ts', - cases: [{ - id: 'lists-users', - title: 'lists users', - input: { active: 1 }, - expected: 'success', - output: [{ id: 1 }], - tags: ['normalization', 'ep'], - focus: 'Ensures user listing behavior remains deterministic.' - }] - }); - const markdownFiles = readdirSync(outDir) - .filter((name) => name.startsWith('test-specification.') && name.endsWith('.md')) - .sort(); - expect(markdownFiles).toEqual([ - 'test-specification.catalog.sql-active-orders.md', - 'test-specification.catalog.unit-users.md', - 'test-specification.index.md', - ]); - const markdown = markdownFiles - .map((name) => readFileSync(path.join(outDir, name), 'utf8')) - .join('\n'); - const indexMarkdown = readFileSync(path.join(outDir, 'test-specification.index.md'), 'utf8'); - const usersCatalogMarkdown = readFileSync(path.join(outDir, 'test-specification.catalog.unit-users.md'), 'utf8'); - const sqlCatalogMarkdown = readFileSync(path.join(outDir, 'test-specification.catalog.sql-active-orders.md'), 'utf8'); - expect(indexMarkdown).toContain('# Unit Test Index'); - expect(indexMarkdown).toContain('- catalogs: 2'); - expect(indexMarkdown).toContain('[unit.users](./test-specification.catalog.unit-users.md)'); - expect(indexMarkdown).toContain('[sql.active-orders](./test-specification.catalog.sql-active-orders.md)'); - expect(indexMarkdown).toContain(' - title: users'); - expect(indexMarkdown).toContain(' - title: active orders'); - expect(indexMarkdown).not.toContain('## Test Case Files'); - expect(usersCatalogMarkdown).toContain('- index: [Unit Test Index](./test-specification.index.md)'); - expect(sqlCatalogMarkdown).toContain('- index: [Unit Test Index](./test-specification.index.md)'); - expect(markdown).toContain('# unit.users Test Cases'); - expect(markdown).toContain('# sql.active-orders Test Cases'); - expect(markdown).toContain('- title: users'); - expect(markdown).toContain('- title: active orders'); - expect(markdown).toContain('- catalogs: 2'); - expect(markdown).toContain('- tests: 1'); - expect(markdown).toContain('- tests: 2'); - expect(markdown).toContain('## lists-users - lists users'); - expectDefinitionLinkPathOrGithub(markdown, 'tests/specs/users.catalog.ts'); - expectDefinitionLinkPathOrGithub(markdown, 'src/specs/sql/activeOrders.catalog.ts'); - expect(markdown).toContain('## baseline - baseline'); - expect(markdown).toContain('## inactive - inactive'); - expect(markdown).not.toContain('\n---\n'); - expect(markdown).toContain('### input'); - expect(markdown).toContain('### output'); - expect(markdown).not.toContain('## SQL Unit Tests'); - expect(markdown).not.toContain('## Function Unit Tests'); - expect(markdown).toContain('"active": 1'); - expect(markdown).toContain('```json'); - expect(markdown).not.toContain('select order_id from orders where active = @active'); -}); - -test('CLI: evidence help documents scopeDir and legacy specsDir across evidence commands', async () => { - for (const args of [ - ['evidence', '--help'], - ['evidence', 'test-doc', '--help'], - ['evidence', 'pr', '--help'] - ]) { - const capture = { stdout: [] as string[], stderr: [] as string[] }; - const program = createCapturingProgram(capture); - - await expect(program.parseAsync(args, { from: 'user' })).rejects.toMatchObject({ - code: 'commander.helpDisplayed' - }); - - const help = capture.stdout.join(''); - expect(help).toContain('--scope-dir '); - expect(help).toContain('Limit QuerySpec discovery to one feature, boundary'); - expect(help).toContain('subtree'); - expect(help).toContain('--specs-dir '); - expect(help).toContain('Legacy override for a fixed SQL catalog specs'); - expect(help).toContain('directory'); - } -}); - -test('CLI: evidence accepts scopeDir from --json payload', async () => { - const root = createWorkspace('evidence-cli-scope-json'); - const queryDir = path.join(root, 'src', 'features', 'users', 'queries', 'list-users'); - mkdirSync(queryDir, { recursive: true }); - writeFileSync(path.join(queryDir, 'list-users.sql'), 'SELECT id FROM users WHERE active = :active', 'utf8'); - writeFileSync( - path.join(queryDir, 'queryspec.ts'), - [ - "const listUsersSql = loadSqlResource(__dirname, 'list-users.sql');", - "export const listUsersQuerySpec = { label: 'features.users.list-users', sql: listUsersSql };", - '' - ].join('\n'), - 'utf8' - ); - - process.env.ZTD_PROJECT_ROOT = root; - const outDir = path.join(root, 'artifacts-scope'); - const program = createProgram(); - await program.parseAsync( - [ - 'evidence', - '--json', - JSON.stringify({ - mode: 'specification', - format: 'json', - outDir, - scopeDir: path.join('src', 'features', 'users') - }) - ], - { from: 'user' } - ); - - expect(process.exitCode).toBe(0); - const parsedJson = JSON.parse(readFileSync(path.join(outDir, 'test-specification.json'), 'utf8')); - expect(parsedJson.summary).toMatchObject({ sqlCatalogCount: 1, specFilesScanned: 1 }); - expect(parsedJson.sqlCatalogs[0]).toMatchObject({ - id: 'features.users.list-users', - specFile: 'src/features/users/queries/list-users/queryspec.ts' - }); -}); - -test('CLI: evidence summary-only writes compact artifacts', async () => { - const root = createWorkspace('evidence-cli-summary'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'orders.active-users.list', sqlFile: '../../sql/orders.active-users.list.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'orders.active-users.list.sql'), 'select user_id from users', 'utf8'); - writeSpecModule(root); - - process.env.ZTD_PROJECT_ROOT = root; - const outDir = path.join(root, 'artifacts-summary'); - const program = createProgram(); - await program.parseAsync(['evidence', '--mode', 'specification', '--format', 'both', '--out-dir', outDir, '--summary-only'], { from: 'user' }); - - expect(process.exitCode).toBe(0); - const parsedJson = JSON.parse(readFileSync(path.join(outDir, 'test-specification.json'), 'utf8')); - expect(parsedJson).toMatchObject({ - display: { summaryOnly: true, truncated: true }, - sqlCatalogs: [], - testCases: [] - }); - const markdownFiles = readdirSync(outDir) - .filter((name) => name.startsWith('test-specification.') && name.endsWith('.md')) - .sort(); - expect(markdownFiles).toEqual(['test-specification.summary.md']); - const markdown = readFileSync(path.join(outDir, 'test-specification.summary.md'), 'utf8'); - expect(markdown).toContain('# Test Specification Summary'); -}); - -test('CLI: evidence sets exitCode=2 for unsupported mode', async () => { - const root = createWorkspace('evidence-cli-mode'); - process.env.ZTD_PROJECT_ROOT = root; - const program = createProgram(); - - await program.parseAsync(['evidence', '--mode', 'report'], { from: 'user' }); - expect(process.exitCode).toBe(2); -}); - -test('resolveTestEvidenceExitCode maps success and runtime failures', () => { - expect(resolveTestEvidenceExitCode({ result: { - schemaVersion: 1, - mode: 'specification', - summary: { sqlCatalogCount: 0, sqlCaseCatalogCount: 0, testCaseCount: 0, specFilesScanned: 0, testFilesScanned: 0 }, - sqlCatalogs: [], - sqlCaseCatalogs: [], - testCaseCatalogs: [], - testCases: [] - } })).toBe(0); - expect(resolveTestEvidenceExitCode({ error: new Error('x') })).toBe(1); - expect(resolveTestEvidenceExitCode({ error: new TestEvidenceRuntimeError('bad config') })).toBe(2); -}); - - -test('CLI: evidence test-doc writes a human-readable markdown artifact', async () => { - const root = createWorkspace('evidence-cli-test-doc'); - writeSpecModule(root); - - process.env.ZTD_PROJECT_ROOT = root; - const outFile = path.join(root, 'artifacts', 'test-documentation.md'); - const program = createProgram(); - await program.parseAsync(['evidence', 'test-doc', '--out', outFile], { from: 'user' }); - - expect(process.exitCode).toBe(0); - const markdown = readFileSync(outFile, 'utf8'); - expect(markdown).toContain('# ZTD Test Documentation'); - expect(markdown).toContain('## unit.users - users'); - expect(markdown).toContain('- purpose: Ensures user listing behavior remains deterministic.'); - expect(markdown).toContain('## sql.active-orders - active orders'); - expect(markdown).toContain('- targetType: sql-catalog'); - expect(markdown).toContain('- execution: Execute the SQL catalog with the documented parameters against fixtures: users.'); - expectDefinitionLinkPathOrGithub(markdown, 'tests/specs/users.catalog.ts'); - expectDefinitionLinkPathOrGithub(markdown, 'src/specs/sql/activeOrders.catalog.ts'); -}); - diff --git a/packages/ztd-cli/tests/testEvidence.pr.unit.test.ts b/packages/ztd-cli/tests/testEvidence.pr.unit.test.ts deleted file mode 100644 index 4cf61aceb..000000000 --- a/packages/ztd-cli/tests/testEvidence.pr.unit.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { expect, test } from 'vitest'; -import { - buildTestEvidencePrDiff, - formatTestEvidencePrMarkdown, - stableStringify, - type TestSpecificationEvidence -} from '../src/commands/testEvidence'; - -function escapeRegex(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function expectFileLinkPathOrGithub(markdown: string, definitionPath: string): void { - const escapedPath = escapeRegex(definitionPath); - const pattern = new RegExp( - `\\[File\\]\\((?:${escapedPath}|https://github\\.com/[^\\s)]+/blob/[^\\s)]+/${escapedPath})\\)` - ); - expect(markdown).toMatch(pattern); -} - -function createReport(args: { - sqlCatalogs?: Array<{ - id: string; - title: string; - definitionPath?: string; - fixtures?: string[]; - cases: Array<{ id: string; title: string; input: Record; output: unknown[] }>; - }>; - functionCatalogs?: Array<{ - id: string; - title: string; - definitionPath?: string; - cases: Array<{ - id: string; - title: string; - input: unknown; - expected?: 'success' | 'throws' | 'errorResult'; - output?: unknown; - error?: { name: string; message: string; match: 'equals' | 'contains' }; - }>; - }>; -}): TestSpecificationEvidence { - const sqlCatalogs = args.sqlCatalogs ?? []; - const functionCatalogs = args.functionCatalogs ?? []; - const testCaseCount = - sqlCatalogs.reduce((count, catalog) => count + catalog.cases.length, 0) + - functionCatalogs.reduce((count, catalog) => count + catalog.cases.length, 0); - return { - schemaVersion: 1, - mode: 'specification', - summary: { - sqlCatalogCount: 0, - sqlCaseCatalogCount: sqlCatalogs.length, - testCaseCount, - specFilesScanned: 0, - testFilesScanned: 0 - }, - sqlCatalogs: [], - sqlCaseCatalogs: sqlCatalogs.map((catalog) => ({ - id: catalog.id, - title: catalog.title, - ...(catalog.definitionPath ? { definitionPath: catalog.definitionPath } : {}), - params: { shape: 'named', example: {} }, - output: { mapping: { columnMap: {} } }, - sql: 'select 1', - fixtures: (catalog.fixtures ?? []).map((tableName) => ({ tableName, rowsCount: 0 })), - cases: catalog.cases.map((testCase) => ({ - id: testCase.id, - title: testCase.title, - params: testCase.input, - expected: testCase.output - })) - })), - testCaseCatalogs: functionCatalogs.map((catalog) => ({ - id: catalog.id, - title: catalog.title, - ...(catalog.definitionPath ? { definitionPath: catalog.definitionPath } : {}), - cases: catalog.cases.map((testCase) => ({ - id: testCase.id, - title: testCase.title, - input: testCase.input, - expected: testCase.expected ?? 'success', - ...(testCase.expected === 'throws' ? { error: testCase.error } : { output: testCase.output }) - })) - })), - testCases: [] - }; -} - -test('stableStringify keeps deterministic key ordering', () => { - expect(stableStringify({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}'); -}); - -test('fixtures ordering difference does not create updates', () => { - const base = createReport({ - sqlCatalogs: [ - { - id: 'sql.users', - title: 'users', - definitionPath: 'src/specs/sql/users.ts', - fixtures: ['users', 'orders'], - cases: [{ id: 'baseline', title: 'baseline', input: { active: 1 }, output: [{ id: 1 }] }] - } - ] - }); - const head = createReport({ - sqlCatalogs: [ - { - id: 'sql.users', - title: 'users', - definitionPath: 'src/specs/sql/users.ts', - fixtures: ['orders', 'users'], - cases: [{ id: 'baseline', title: 'baseline', input: { active: 1 }, output: [{ id: 1 }] }] - } - ] - }); - - const diff = buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'a', report: base }, - head: { ref: 'HEAD', sha: 'b', report: head }, - baseMode: 'merge-base' - }); - expect(diff.summary.catalogs).toEqual({ added: 0, removed: 0, updated: 0 }); - expect(diff.summary.cases).toEqual({ added: 0, removed: 0, updated: 0 }); - expect(diff.baseMode).toBe('merge-base'); - expect(diff.totals.base).toEqual({ catalogs: 1, tests: 1 }); - expect(diff.totals.head).toEqual({ catalogs: 1, tests: 1 }); -}); - -test('input/output change is reported as case updated', () => { - const base = createReport({ - functionCatalogs: [ - { - id: 'unit.normalize', - title: 'normalize', - cases: [{ id: 'trim', title: 'trim', input: ' A ', output: 'a' }] - } - ] - }); - const head = createReport({ - functionCatalogs: [ - { - id: 'unit.normalize', - title: 'normalize', - cases: [{ id: 'trim', title: 'trim', input: ' A ', output: 'aa' }] - } - ] - }); - - const diff = buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'a', report: base }, - head: { ref: 'HEAD', sha: 'b', report: head }, - baseMode: 'ref' - }); - expect(diff.summary.catalogs.updated).toBe(1); - expect(diff.summary.cases.updated).toBe(1); - expect(diff.catalogs.updated[0]?.cases.updated[0]?.before.output).toBe('a'); - expect(diff.catalogs.updated[0]?.cases.updated[0]?.after.output).toBe('aa'); -}); - -test('catalog and case add/remove are classified correctly', () => { - const base = createReport({ - sqlCatalogs: [ - { - id: 'sql.base', - title: 'base', - cases: [{ id: 'a', title: 'a', input: {}, output: [] }] - } - ], - functionCatalogs: [ - { - id: 'unit.only-base', - title: 'base-fn', - cases: [{ id: 'x', title: 'x', input: 1, output: 1 }] - } - ] - }); - const head = createReport({ - sqlCatalogs: [ - { - id: 'sql.base', - title: 'base', - cases: [{ id: 'b', title: 'b', input: {}, output: [] }] - }, - { - id: 'sql.new', - title: 'new', - cases: [{ id: 'n', title: 'n', input: {}, output: [] }] - } - ] - }); - - const diff = buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'a', report: base }, - head: { ref: 'HEAD', sha: 'b', report: head }, - baseMode: 'ref' - }); - expect(diff.summary.catalogs).toEqual({ added: 1, removed: 1, updated: 1 }); - expect(diff.summary.cases).toEqual({ added: 2, removed: 2, updated: 0 }); -}); - -test('markdown header shows merge-base expression when baseMode=merge-base', () => { - const diff = buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'aaa', report: createReport({}) }, - head: { ref: 'HEAD', sha: 'bbb', report: createReport({}) }, - baseMode: 'merge-base' - }); - const markdown = formatTestEvidencePrMarkdown(diff); - expect(markdown).toContain('- base: merge-base(main, HEAD) (aaa)'); - expect(markdown).toContain('- head: HEAD (bbb)'); -}); - -test('test-centric markdown groups changed cases under a single catalog heading', () => { - const base = createReport({ - sqlCatalogs: [ - { - id: 'sql.users', - title: 'users', - definitionPath: 'src/specs/sql/users.ts', - fixtures: ['users'], - cases: [ - { id: 'baseline', title: 'baseline', input: { active: 1 }, output: [{ id: 1 }] }, - { id: 'removed-case', title: 'removed', input: { active: 9 }, output: [{ id: 9 }] } - ] - } - ] - }); - const head = createReport({ - sqlCatalogs: [ - { - id: 'sql.users', - title: 'users', - definitionPath: 'src/specs/sql/users.ts', - fixtures: ['users'], - cases: [ - { id: 'baseline', title: 'baseline', input: { active: 0 }, output: [{ id: 2 }] }, - { id: 'added-case', title: 'added', input: { active: 2 }, output: [{ id: 3 }] } - ] - } - ] - }); - const diff = buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'a', report: base }, - head: { ref: 'HEAD', sha: 'b', report: head }, - baseMode: 'ref' - }); - const markdown = formatTestEvidencePrMarkdown(diff); - expect(markdown.match(/## sql\.users - users/g)?.length).toBe(1); - expectFileLinkPathOrGithub(markdown, 'src/specs/sql/users.ts'); - expect(markdown).toContain('### ADD: added-case - added'); - expect(markdown).toContain('**after**'); - expect(markdown).toContain('### REMOVE: removed-case - removed'); - expect(markdown).toContain('**before**'); - expect(markdown).toContain('### UPDATE: baseline - baseline'); -}); - -test('PR markdown uses GitHub HTTPS file links when CI metadata exists', () => { - const base = createReport({ - sqlCatalogs: [ - { - id: 'sql.users', - title: 'users', - definitionPath: 'src/specs/sql/users.ts', - cases: [{ id: 'baseline', title: 'baseline', input: { active: 1 }, output: [{ id: 1 }] }] - } - ] - }); - const head = createReport({ - sqlCatalogs: [ - { - id: 'sql.users', - title: 'users', - definitionPath: 'src/specs/sql/users.ts', - cases: [{ id: 'baseline', title: 'baseline', input: { active: 0 }, output: [{ id: 2 }] }] - } - ] - }); - const diff = buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'a', report: base }, - head: { ref: 'HEAD', sha: 'b', report: head }, - baseMode: 'ref' - }); - - const originalServer = process.env.GITHUB_SERVER_URL; - const originalRepo = process.env.GITHUB_REPOSITORY; - const originalSha = process.env.GITHUB_SHA; - try { - process.env.GITHUB_SERVER_URL = 'https://github.com'; - process.env.GITHUB_REPOSITORY = 'mk3008/rawsql-ts'; - process.env.GITHUB_SHA = 'abc123'; - const markdown = formatTestEvidencePrMarkdown(diff); - expect(markdown).toContain('[File](https://github.com/mk3008/rawsql-ts/blob/abc123/src/specs/sql/users.ts)'); - } finally { - process.env.GITHUB_SERVER_URL = originalServer; - process.env.GITHUB_REPOSITORY = originalRepo; - process.env.GITHUB_SHA = originalSha; - } -}); - -test('removed cases always render before blocks in test-centric markdown', () => { - const base = createReport({ - sqlCatalogs: [ - { - id: 'sql.users', - title: 'users', - cases: [{ id: 'removed-case', title: 'removed', input: { active: 9 }, output: [{ id: 9 }] }] - } - ] - }); - const head = createReport({}); - const diff = buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'a', report: base }, - head: { ref: 'HEAD', sha: 'b', report: head }, - baseMode: 'ref' - }); - - const markdown = formatTestEvidencePrMarkdown(diff, { removedDetail: 'none' }); - expect(markdown).toContain('### REMOVE: removed-case - removed'); - expect(markdown).toContain('**before**'); - expect(markdown).toContain('input'); - expect(markdown).toContain('output'); - expect(markdown).not.toContain('**after**'); -}); - -test('diff json uses schemaVersion', () => { - const diff = buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'aaa', report: createReport({}) }, - head: { ref: 'HEAD', sha: 'bbb', report: createReport({}) }, - baseMode: 'merge-base' - }); - - expect(diff).toMatchObject({ schemaVersion: 1 }); -}); - -test('unsupported preview schemaVersion is rejected deterministically', () => { - const base = { - ...createReport({}), - schemaVersion: 2 - } as unknown as TestSpecificationEvidence; - const head = createReport({}); - - expect(() => - buildTestEvidencePrDiff({ - base: { ref: 'main', sha: 'aaa', report: base }, - head: { ref: 'HEAD', sha: 'bbb', report: head }, - baseMode: 'merge-base' - }) - ).toThrow(/schemaVersion|unsupported/i); -}); - diff --git a/packages/ztd-cli/tests/testEvidence.unit.test.ts b/packages/ztd-cli/tests/testEvidence.unit.test.ts deleted file mode 100644 index 8b25d43a1..000000000 --- a/packages/ztd-cli/tests/testEvidence.unit.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import os from 'node:os'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import { - applyEvidenceOutputControls, - formatTestDocumentationOutput, - formatTestEvidenceOutput, - runTestEvidencePr, - runTestEvidenceSpecification, - TestEvidenceRuntimeError -} from '../src/commands/testEvidence'; - -function createWorkspace(prefix: string): string { - const root = mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - mkdirSync(path.join(root, 'src', 'catalog', 'specs'), { recursive: true }); - mkdirSync(path.join(root, 'src', 'sql'), { recursive: true }); - mkdirSync(path.join(root, 'tests', 'specs'), { recursive: true }); - return root; -} - -function writeFeatureLocalQuerySpec(root: string, featureName: string): void { - const queryDir = path.join(root, 'src', 'features', featureName, 'queries', 'list-users'); - mkdirSync(queryDir, { recursive: true }); - writeFileSync(path.join(queryDir, 'list-users.sql'), 'SELECT id FROM users WHERE active = :active', 'utf8'); - writeFileSync( - path.join(queryDir, 'queryspec.ts'), - [ - "import { loadSqlResource } from '../../../_shared/loadSqlResource';", - '', - "const listUsersSql = loadSqlResource(__dirname, 'list-users.sql');", - '', - 'export const listUsersQuerySpec = {', - ` label: 'features.${featureName}.list-users',`, - ' sql: listUsersSql', - '};', - '' - ].join('\n'), - 'utf8' - ); -} - -function writeSpecModule(root: string, options?: { testCaseIds?: string[]; includeSqlCase?: boolean }): void { - const testCaseIds = options?.testCaseIds ?? ['returns-active-users', 'skipped-case']; - const sqlCaseBlock = options?.includeSqlCase === false - ? 'sqlCatalogCases: [],' - : [ - 'sqlCatalogCases: [', - ' {', - " id: 'sql.active-orders',", - " title: 'active orders',", - " definitionPath: 'src/specs/sql/activeOrders.catalog.ts',", - ' fixtures: [', - " { tableName: 'users', rows: [{ id: 1 }], schema: { columns: { id: 'INTEGER' } } }", - ' ],', - ' catalog: {', - " id: 'orders.active-users.list',", - " params: { shape: 'named', example: { active: 1, minTotal: 20, limit: 2 } },", - " output: { mapping: { columnMap: { orderId: 'order_id' } } },", - " sql: 'select order_id from orders where active = @active'", - ' },', - ' cases: [', - " { id: 'baseline', title: 'baseline', expected: [{ orderId: 10 }] },", - " { id: 'inactive', title: 'inactive', arrange: () => ({ active: 0 }), expected: [{ orderId: 12 }] }", - ' ]', - ' }', - '],' - ].join('\n'); - - const source = [ - 'module.exports = {', - 'testCaseCatalogs: [', - ' {', - " id: 'unit.users',", - " title: 'User behavior',", - " definitionPath: 'tests/specs/users.catalog.ts',", - ' cases: [', - ...testCaseIds.map((id) => ` { id: '${id}', title: '${id.replace(/-/g, ' ')}', input: '${id}', expected: 'success', output: '${id}-ok', tags: ['invariant', 'state'], focus: 'Ensures ${id.replace(/-/g, ' ')} behavior remains stable.' },`), - ' ]', - ' }', - '],', - sqlCaseBlock, - '};', - '' - ].join('\n'); - - writeFileSync(path.join(root, 'tests', 'specs', 'index.cjs'), source, 'utf8'); -} - -function withoutGitHubEnv(fn: () => T): T { - const keys = ['GITHUB_SERVER_URL', 'GITHUB_REPOSITORY', 'GITHUB_SHA'] as const; - const originals = keys.map((key) => process.env[key]); - try { - keys.forEach((key) => delete process.env[key]); - return fn(); - } finally { - keys.forEach((key, index) => { - const original = originals[index]; - if (original === undefined) { - delete process.env[key]; - } else { - process.env[key] = original; - } - }); - } -} - -function git(root: string, args: string[]): string { - const { GIT_DIR: _gitDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env; - return execFileSync('git', args, { - cwd: root, - env, - stdio: ['ignore', 'pipe', 'pipe'], - encoding: 'utf8' - }).trim(); -} - -function initGitRepository(root: string): void { - git(root, ['init']); - git(root, ['config', 'user.email', 'test@example.com']); - git(root, ['config', 'user.name', 'Test User']); -} - -test('runTestEvidenceSpecification extracts SQL catalogs, SQL case catalogs, and test-case catalogs deterministically', () => { - const root = createWorkspace('evidence-spec'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify( - { - id: 'orders.active-users.list', - sqlFile: '../../sql/orders.active-users.list.sql', - params: { shape: 'named' }, - output: { mapping: { columnMap: { userId: 'user_id' } } } - }, - null, - 2 - ), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'orders.active-users.list.sql'), 'select user_id from users', 'utf8'); - writeSpecModule(root); - - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - expect(report.summary).toEqual({ - sqlCatalogCount: 1, - sqlCaseCatalogCount: 1, - testCaseCount: 2, - specFilesScanned: 1, - testFilesScanned: 1 - }); - expect(report.sqlCatalogs[0]).toMatchObject({ - id: 'orders.active-users.list', - paramsShape: 'named', - sqlFileResolved: true, - specFile: 'src/catalog/specs/users.spec.json', - hasOutputMapping: true - }); - expect(report.testCases.map((item) => item.id)).toEqual([ - 'unit.users.returns-active-users', - 'unit.users.skipped-case' - ]); - expect(report.sqlCaseCatalogs[0]).toMatchObject({ - id: 'sql.active-orders', - definitionPath: 'src/specs/sql/activeOrders.catalog.ts', - cases: [ - { id: 'baseline', params: { active: 1, limit: 2, minTotal: 20 }, expected: [{ orderId: 10 }] }, - { id: 'inactive', params: { active: 0, limit: 2, minTotal: 20 }, expected: [{ orderId: 12 }] }, - ] - }); -}); - -test('runTestEvidenceSpecification throws when neither specs nor evidence module exports exist', () => { - const root = mkdtempSync(path.join(os.tmpdir(), 'evidence-empty-')); - expect(() => runTestEvidenceSpecification({ mode: 'specification', rootDir: root })).toThrowError(TestEvidenceRuntimeError); -}); - -test('runTestEvidenceSpecification discovers feature-local QuerySpecs project-wide by default', () => { - const root = createWorkspace('evidence-feature-local'); - writeFeatureLocalQuerySpec(root, 'users'); - - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - - expect(report.summary).toMatchObject({ - sqlCatalogCount: 1, - specFilesScanned: 1 - }); - expect(report.sqlCatalogs).toEqual([ - expect.objectContaining({ - id: 'features.users.list-users', - specFile: 'src/features/users/queries/list-users/queryspec.ts', - sqlFile: './list-users.sql', - sqlFileResolved: true - }) - ]); -}); - -test('runTestEvidenceSpecification supports scopeDir and legacy specsDir discovery', () => { - const root = createWorkspace('evidence-scope-dir'); - writeFeatureLocalQuerySpec(root, 'users'); - writeFeatureLocalQuerySpec(root, 'orders'); - writeFileSync(path.join(root, 'src', 'sql', 'legacy.sql'), 'select 1', 'utf8'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'legacy.spec.json'), - JSON.stringify({ id: 'legacy', sqlFile: '../../sql/legacy.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - - const scoped = runTestEvidenceSpecification({ - mode: 'specification', - rootDir: root, - scopeDir: path.join('src', 'features', 'users') - }); - expect(scoped.sqlCatalogs.map((item) => item.id)).toEqual(['features.users.list-users']); - - const legacy = runTestEvidenceSpecification({ - mode: 'specification', - rootDir: root, - specsDir: path.join('src', 'catalog', 'specs') - }); - expect(legacy.sqlCatalogs.map((item) => item.id)).toEqual(['legacy']); -}); - -test('runTestEvidenceSpecification rejects ambiguous scopeDir and specsDir discovery', () => { - const root = createWorkspace('evidence-conflicting-discovery'); - - expect(() => - runTestEvidenceSpecification({ - mode: 'specification', - rootDir: root, - scopeDir: path.join('src', 'features', 'users'), - specsDir: path.join('src', 'catalog', 'specs') - }) - ).toThrowError(TestEvidenceRuntimeError); -}); - -test('runTestEvidenceSpecification rejects non-directory or external discovery roots', () => { - const root = createWorkspace('evidence-invalid-discovery'); - const outsideRoot = mkdtempSync(path.join(os.tmpdir(), 'evidence-outside-')); - writeFileSync(path.join(root, 'src', 'features-file'), 'not a directory', 'utf8'); - - expect(() => - runTestEvidenceSpecification({ - mode: 'specification', - rootDir: root, - scopeDir: path.join('src', 'features-file') - }) - ).toThrow(/Scope directory is not a directory/); - expect(() => - runTestEvidenceSpecification({ - mode: 'specification', - rootDir: root, - specsDir: outsideRoot - }) - ).toThrow(/Spec directory must be inside the project root/); -}); - -test('runTestEvidencePr normalizes absolute discovery roots before materializing worktrees', () => { - const root = createWorkspace('evidence-pr-absolute-specs'); - initGitRepository(root); - writeFeatureLocalQuerySpec(root, 'users'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'users.spec.json'), - JSON.stringify({ id: 'legacy.users', sqlFile: '../../sql/users.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'users.sql'), 'select id from users', 'utf8'); - writeSpecModule(root, { testCaseIds: ['works'], includeSqlCase: false }); - git(root, ['add', '.']); - git(root, ['commit', '-m', 'base']); - const baseSha = git(root, ['rev-parse', 'HEAD']); - - writeFileSync(path.join(root, 'src', 'sql', 'users.sql'), 'select id, name from users', 'utf8'); - git(root, ['add', '.']); - git(root, ['commit', '-m', 'head']); - const headSha = git(root, ['rev-parse', 'HEAD']); - - const originalGitIndexFile = process.env.GIT_INDEX_FILE; - process.env.GIT_INDEX_FILE = path.join(root, 'missing-parent-hook-index'); - try { - const result = runTestEvidencePr({ - baseRef: baseSha, - headRef: headSha, - baseMode: 'ref', - outDir: 'artifacts/test-evidence', - rootDir: root, - specsDir: path.join(root, 'src', 'catalog', 'specs'), - summaryOnly: true - }); - - expect(result.baseReport.summary.sqlCatalogCount).toBe(1); - expect(result.headReport.summary.sqlCatalogCount).toBe(1); - - const scopedResult = runTestEvidencePr({ - baseRef: baseSha, - headRef: headSha, - baseMode: 'ref', - outDir: 'artifacts/test-evidence-scoped', - rootDir: root, - scopeDir: path.join(root, 'src', 'features', 'users'), - summaryOnly: true - }); - - expect(scopedResult.baseReport.summary.sqlCatalogCount).toBe(1); - expect(scopedResult.headReport.summary.sqlCatalogCount).toBe(1); - } finally { - if (originalGitIndexFile === undefined) { - delete process.env.GIT_INDEX_FILE; - } else { - process.env.GIT_INDEX_FILE = originalGitIndexFile; - } - } -}); - -test('formatTestEvidenceOutput emits deterministic markdown and json text', () => { - const root = createWorkspace('evidence-format'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'a.spec.json'), - JSON.stringify({ id: 'a', sqlFile: '../../sql/a.sql', params: { shape: 'positional' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'select 1', 'utf8'); - writeSpecModule(root, { testCaseIds: ['works'], includeSqlCase: false }); - - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - const { markdown, json } = withoutGitHubEnv(() => ({ - markdown: formatTestEvidenceOutput(report, 'markdown'), - json: formatTestEvidenceOutput(report, 'json'), - })); - - expect(markdown).toContain('# unit.users Test Cases'); - expect(markdown).toContain('- tests: 1'); - expect(markdown).toContain("definition: [tests/specs/users.catalog.ts](tests/specs/users.catalog.ts)"); - expect(markdown).toContain('## works - works'); - expect(markdown).not.toContain('\n---\n'); - expect(markdown).toContain('### input'); - expect(markdown).toContain('### output'); - expect(markdown).not.toContain('## SQL Unit Tests'); - expect(markdown).not.toContain('## Function Unit Tests'); - expect(markdown).toContain('"works"'); - expect(markdown).toContain('"works-ok"'); - expect(markdown).not.toContain('SELECT'); - expect(JSON.parse(json)).toMatchObject({ - schemaVersion: 1, - mode: 'specification', - summary: { sqlCatalogCount: 1, sqlCaseCatalogCount: 0, testCaseCount: 1 } - }); - const parsed = JSON.parse(readFileSync(path.join(root, 'src', 'catalog', 'specs', 'a.spec.json'), 'utf8')); - expect(parsed.id).toBe('a'); -}); - -test('applyEvidenceOutputControls can limit payloads and emit summary-only markdown', () => { - const root = createWorkspace('evidence-output-controls'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'a.spec.json'), - JSON.stringify({ id: 'a', sqlFile: '../../sql/a.sql', params: { shape: 'positional' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'select 1', 'utf8'); - writeSpecModule(root, { testCaseIds: ['works', 'works-again'], includeSqlCase: true }); - - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - const limited = applyEvidenceOutputControls(report, { limit: 1 }); - const summaryOnly = applyEvidenceOutputControls(report, { summaryOnly: true, limit: 1 }); - - expect(limited.sqlCatalogs).toHaveLength(1); - expect(limited.testCases).toHaveLength(1); - expect(limited.display).toMatchObject({ limit: 1, summaryOnly: false, truncated: true }); - - expect(summaryOnly.sqlCatalogs).toEqual([]); - expect(summaryOnly.testCases).toEqual([]); - expect(summaryOnly.display).toMatchObject({ summaryOnly: true, truncated: true }); - expect(formatTestEvidenceOutput(summaryOnly, 'markdown')).toContain('# Test Specification Summary'); -}); - -test('formatTestEvidenceOutput uses GitHub HTTPS links when CI metadata exists', () => { - const root = createWorkspace('evidence-github-links'); - writeSpecModule(root, { testCaseIds: ['works'], includeSqlCase: false }); - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - - const originalServer = process.env.GITHUB_SERVER_URL; - const originalRepo = process.env.GITHUB_REPOSITORY; - const originalSha = process.env.GITHUB_SHA; - try { - process.env.GITHUB_SERVER_URL = 'https://github.com'; - process.env.GITHUB_REPOSITORY = 'mk3008/rawsql-ts'; - process.env.GITHUB_SHA = 'abc123'; - const markdown = formatTestEvidenceOutput(report, 'markdown'); - expect(markdown).toContain( - '[tests/specs/users.catalog.ts](https://github.com/mk3008/rawsql-ts/blob/abc123/tests/specs/users.catalog.ts)' - ); - } finally { - process.env.GITHUB_SERVER_URL = originalServer; - process.env.GITHUB_REPOSITORY = originalRepo; - process.env.GITHUB_SHA = originalSha; - } -}); - -test('runTestEvidenceSpecification keeps deterministic ordering, normalized paths, and environment-free output', () => { - const root = createWorkspace('evidence-determinism'); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'b.spec.json'), - JSON.stringify({ id: 'catalog.b', sqlFile: '../../sql/b.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync( - path.join(root, 'src', 'catalog', 'specs', 'a.spec.json'), - JSON.stringify({ id: 'catalog.a', sqlFile: '../../sql/a.sql', params: { shape: 'named' } }, null, 2), - 'utf8' - ); - writeFileSync(path.join(root, 'src', 'sql', 'a.sql'), 'select 1', 'utf8'); - writeFileSync(path.join(root, 'src', 'sql', 'b.sql'), 'select 2', 'utf8'); - writeSpecModule(root, { testCaseIds: ['b', 'a'], includeSqlCase: false }); - - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - expect(report.sqlCatalogs.map((item) => item.id)).toEqual(['catalog.a', 'catalog.b']); - expect(report.testCases.map((item) => item.id)).toEqual(['unit.users.a', 'unit.users.b']); - expect(report.testCaseCatalogs.map((item) => item.id)).toEqual(['unit.users']); - expect(report.testCaseCatalogs[0]?.definitionPath).toBe('tests/specs/users.catalog.ts'); - expect(report.testCaseCatalogs[0]?.cases[0]).toMatchObject({ - id: 'a', - input: 'a', - output: 'a-ok', - tags: ['invariant', 'state'], - focus: 'Ensures a behavior remains stable.' - }); - expect(report.sqlCatalogs.every((item) => !item.specFile.includes('\\'))).toBe(true); - expect(JSON.stringify(report)).not.toContain(root); - expect(JSON.stringify(report)).not.toMatch(/\d{4}-\d{2}-\d{2}T/); -}); - -test('runTestEvidenceSpecification normalizes tags vocabulary and keeps catalog refs', () => { - const root = createWorkspace('evidence-tags-refs'); - writeFileSync( - path.join(root, 'tests', 'specs', 'index.cjs'), - [ - 'module.exports = {', - 'testCaseCatalogs: [', - ' {', - " id: 'unit.tags',", - " title: 'tags',", - " refs: [{ label: 'Issue #448', url: 'https://github.com/mk3008/rawsql-ts/issues/448' }],", - ' cases: [', - " { id: 'c1', title: 'c1', input: 'x', expected: 'success', output: 'x', tags: ['happy-path', 'validation', 'bva', 'ep'], focus: 'Ensures tags are normalized into two axes.' }", - ' ]', - ' }', - '],', - 'sqlCatalogCases: []', - '};', - '' - ].join('\n'), - 'utf8' - ); - - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - expect(report.testCaseCatalogs[0]?.refs).toEqual([ - { label: 'Issue #448', url: 'https://github.com/mk3008/rawsql-ts/issues/448' } - ]); - expect(report.testCaseCatalogs[0]?.cases[0]?.tags).toEqual(['validation', 'ep']); - expect(report.testCaseCatalogs[0]?.cases[0]?.focus).toBe('Ensures tags are normalized into two axes.'); -}); - - -test('formatTestDocumentationOutput emits human-readable test intent sections', () => { - const root = createWorkspace('evidence-test-doc'); - writeSpecModule(root, { testCaseIds: ['works'], includeSqlCase: true }); - - const report = runTestEvidenceSpecification({ mode: 'specification', rootDir: root }); - const markdown = withoutGitHubEnv(() => formatTestDocumentationOutput(report)); - - expect(markdown).toContain('# ZTD Test Documentation'); - expect(markdown).toContain('## sql.active-orders - active orders'); - expect(markdown).toContain('- targetType: sql-catalog'); - expect(markdown).toContain('- execution: Execute the SQL catalog with the documented parameters against fixtures: users.'); - expect(markdown).toContain('- expectedSummary: Returns 1 row(s).'); - expect(markdown).toContain('## unit.users - User behavior'); - expect(markdown).toContain('- targetType: function-unit'); - expect(markdown).toContain('- purpose: Ensures works behavior remains stable.'); - expect(markdown).toContain('- coverage: normal; tags=[invariant, state]'); - expect(markdown).toContain('### Test Case List'); - expect(markdown).toContain('#### Input / Setup'); - expect(markdown).toContain('#### Expected Result'); - expect(markdown).toContain('#### Notes / Assumptions'); -}); - diff --git a/packages/ztd-cli/tests/typeMapper.unit.test.ts b/packages/ztd-cli/tests/typeMapper.unit.test.ts deleted file mode 100644 index 3eb712a16..000000000 --- a/packages/ztd-cli/tests/typeMapper.unit.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { expect, test } from 'vitest'; - -import { mapSqlTypeToTs } from '../src/utils/typeMapper'; - -test('mapSqlTypeToTs treats serial aliases as numbers', () => { - expect(mapSqlTypeToTs('smallserial')).toBe('number'); - expect(mapSqlTypeToTs('serial2')).toBe('number'); - expect(mapSqlTypeToTs('serial4')).toBe('number'); - expect(mapSqlTypeToTs('serial8')).toBe('number'); -}); diff --git a/packages/ztd-cli/tests/utils/normalize.ts b/packages/ztd-cli/tests/utils/normalize.ts deleted file mode 100644 index c3d8396bd..000000000 --- a/packages/ztd-cli/tests/utils/normalize.ts +++ /dev/null @@ -1 +0,0 @@ -export const normalizeLineEndings = (text: string) => text.replace(/\r\n/g, '\n'); diff --git a/packages/ztd-cli/tests/utils/normalizePulledSchema.test.ts b/packages/ztd-cli/tests/utils/normalizePulledSchema.test.ts deleted file mode 100644 index a2e351fc9..000000000 --- a/packages/ztd-cli/tests/utils/normalizePulledSchema.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test } from 'vitest'; -import { normalizePulledSchema } from '../../src/utils/normalizePulledSchema'; - -test('normalizePulledSchema extracts relevant statements per schema', () => { - const sampleDump = ` - -- PostgreSQL database dump - SET statement_timeout = 0; - SET lock_timeout = 0; - \\connect test - \\restrict - \\unrestrict - CREATE SCHEMA public; - CREATE TABLE public.products ( - id serial PRIMARY KEY, - name text NOT NULL - ); - CREATE VIEW public.product_view AS - SELECT id, name FROM public.products; - ALTER TABLE public.products ADD CONSTRAINT products_name_unique UNIQUE (name); - ALTER TABLE public.products ALTER COLUMN name SET DEFAULT 'unknown'; - CREATE SEQUENCE public.products_id_seq; - ALTER SEQUENCE public.products_id_seq OWNED BY public.products.id; - CREATE INDEX idx_products_name ON public.products (name); - SELECT pg_catalog.setval('public.products_id_seq', 1, false); - DROP TABLE IF EXISTS ignored; - `; - - const normalized = normalizePulledSchema(sampleDump); - expect(normalized.size).toBe(1); - const statements = normalized.get('public'); - expect(statements).toBeTruthy(); - const groups = statements?.map((entry) => entry.group) ?? []; - expect(groups).toEqual([ - 'createSchema', - 'createTable', - 'view', - 'alterTable', - 'alterTable', - 'sequence', - 'sequence', - 'index' - ]); - - const content = (statements ?? []).map((entry) => entry.sql.toLowerCase()).join('\n'); - expect(content).toContain('create schema public;'); - expect(content).toContain('create table "public"."products"'); - expect(content).toContain('create view public.product_view'); - expect(content).toContain('create index'); - expect(content).not.toContain('drop table'); -}); - -test('normalizePulledSchema ignores pg_dump header comments before statements', () => { - const headerDump = ` - -- PostgreSQL database dump - -- Name: products; Type: TABLE; Schema: public; Owner: postgres - -- Description: Table for items - CREATE TABLE public.products ( - id serial PRIMARY KEY - ); - `; - - const normalized = normalizePulledSchema(headerDump); - const statements = normalized.get('public') ?? []; - expect(statements.some((entry) => entry.group === 'createTable')).toBe(true); -}); diff --git a/packages/ztd-cli/tests/utils/sqlCatalog.ts b/packages/ztd-cli/tests/utils/sqlCatalog.ts deleted file mode 100644 index a28d939c8..000000000 --- a/packages/ztd-cli/tests/utils/sqlCatalog.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { TableFixture } from '@rawsql-ts/testkit-core'; -import { expect, it } from 'vitest'; -import { - defineSqlCatalogDefinition as defineSqlCatalogDefinitionFn, - type SqlCatalogDefinition, -} from '../../src/specs/sqlCatalogDefinition'; - -/** - * Pure SQL catalog definition type shared with src-level specs. - */ -export type { SqlCatalogDefinition }; - -/** - * Single runnable SQL catalog case with optional arranged parameters and expected rows. - */ -export interface SqlCatalogTestCase< - TParams extends Record, - TRow extends Record, -> { - id: string; - title: string; - arrange?: () => TParams; - expected: TRow[]; -} - -/** - * Executable SQL catalog test specification including fixtures and deterministic cases. - */ -export interface SqlCatalogTestSpec< - TParams extends Record, - TRow extends Record, -> { - id: string; - title: string; - description?: string; - definitionPath?: string; - fixtures: TableFixture[]; - catalog: SqlCatalogDefinition; - cases: SqlCatalogTestCase[]; -} - -export type SqlCatalog< - TParams extends Record = Record, - TRow extends Record = Record, -> = SqlCatalogTestSpec; - -/** - * Executes SQL with fixtures and projects engine rows into DTO rows by `columnMap`. - */ -export type SqlCatalogExecutor = ( - sql: string, - params: Record, - fixtures: TableFixture[], - columnMap: Record -) => Promise[]>; - -/** - * Runtime hooks used by `runSqlCatalog`. - */ -export interface RunSqlCatalogOptions { - executor: SqlCatalogExecutor; - onCaseExecuted?: (id: string) => void; -} - -/** - * Define SQL catalog metadata in a pure, reusable shape. - */ -export const defineSqlCatalogDefinition = defineSqlCatalogDefinitionFn; - -/** - * Define deterministic SQL catalog test specs with stable case ordering. - */ -export function defineSqlCatalog< - TParams extends Record, - TRow extends Record, ->(spec: SqlCatalogTestSpec): SqlCatalogTestSpec { - return { - ...spec, - cases: [...spec.cases].sort((a, b) => a.id.localeCompare(b.id)), - }; -} - -/** - * Register each SQL catalog case as an executable vitest test. - */ -export function runSqlCatalog< - TParams extends Record, - TRow extends Record, ->(spec: SqlCatalogTestSpec, opts: RunSqlCatalogOptions): void { - for (const item of spec.cases) { - it(`[${spec.catalog.id}] ${item.id} ${item.title}`, async () => { - const params = item.arrange ? item.arrange() : spec.catalog.params.example; - const actualRows = await opts.executor( - spec.catalog.sql, - params, - spec.fixtures, - spec.catalog.output.mapping.columnMap - ); - - // Keep verification deterministic in the runner, not in spec definitions. - expect(actualRows as TRow[]).toEqual(item.expected); - opts.onCaseExecuted?.(item.id); - }); - } -} - -/** - * Export SQL catalog evidence in a deterministic, pure shape for specification mode. - */ -export function exportSqlCatalogEvidence( - catalogs: Array, Record>> -): { - catalogs: Array<{ - id: string; - title: string; - description?: string; - definitionPath?: string; - params: { shape: 'named'; example: Record }; - output: { mapping: { columnMap: Record } }; - sql: string; - fixtures: Array<{ - tableName: string; - schema?: { columns: Record }; - rowsCount: number; - }>; - cases: Array<{ - id: string; - title: string; - params: Record; - expected: Record[]; - }>; - }>; -} { - return { - catalogs: [...catalogs] - .sort((a, b) => a.id.localeCompare(b.id)) - .map((catalog) => ({ - id: catalog.id, - title: catalog.title, - ...(catalog.description ? { description: catalog.description } : {}), - ...(catalog.definitionPath ? { definitionPath: catalog.definitionPath } : {}), - params: { - shape: 'named', - example: { ...catalog.catalog.params.example }, - }, - output: { - mapping: { - columnMap: Object.fromEntries( - Object.entries(catalog.catalog.output.mapping.columnMap).sort((a, b) => a[0].localeCompare(b[0])) - ), - }, - }, - // Keep SQL as-is so evidence is a lossless projection of primary test inputs. - sql: catalog.catalog.sql, - fixtures: [...catalog.fixtures] - .map((fixture) => ({ - tableName: fixture.tableName, - ...(fixture.schema && fixture.schema.columns - ? { - schema: { - columns: Object.fromEntries( - Object.entries(fixture.schema.columns).sort((a, b) => a[0].localeCompare(b[0])) - ), - }, - } - : {}), - rowsCount: Array.isArray(fixture.rows) ? fixture.rows.length : 0, - })) - .sort((a, b) => a.tableName.localeCompare(b.tableName)), - cases: [...catalog.cases] - .sort((a, b) => a.id.localeCompare(b.id)) - .map((item) => ({ - id: item.id, - title: item.title, - params: buildCaseParams(catalog.catalog.params.example, item.arrange), - expected: item.expected.map((row) => ({ ...row })), - })), - })), - }; -} - -function buildCaseParams( - baseParams: Record, - arrange?: () => Record -): Record { - const arranged = arrange ? arrange() : undefined; - const merged = arranged ? { ...baseParams, ...arranged } : { ...baseParams }; - return Object.fromEntries(Object.entries(merged).sort((a, b) => a[0].localeCompare(b[0]))); -} diff --git a/packages/ztd-cli/tests/utils/sqlDebugRecoveryScenario.ts b/packages/ztd-cli/tests/utils/sqlDebugRecoveryScenario.ts deleted file mode 100644 index 282db329e..000000000 --- a/packages/ztd-cli/tests/utils/sqlDebugRecoveryScenario.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const SQL_DEBUG_RECOVERY_QUERY = `with source_orders as ( - select order_id, customer_id, amount, region_id, order_date - from public.orders -), -filtered_orders as ( - select * - from source_orders - where region_id = :region_id -), -customer_rollup as ( - select customer_id, sum(amount) as total_amount - from filtered_orders - group by customer_id -), -suspicious_rollup as ( - select customer_id, total_amount, - row_number() over (order by total_amount desc, customer_id) as rollup_rank - from customer_rollup -), -unused_debug_cte as ( - select customer_id - from customer_rollup - where total_amount > 0 -) -select customer_id, total_amount -from suspicious_rollup -where rollup_rank <= :top_n -`; - -export const SQL_DEBUG_RECOVERY_PATCH = `suspicious_rollup as ( - select customer_id, total_amount, - dense_rank() over (order by total_amount desc) as rollup_rank - from customer_rollup -) -`; - -export const SQL_DEBUG_RECOVERY_PARAMS = { - region_id: 9, - top_n: 5, -}; diff --git a/packages/ztd-cli/tests/utils/taxAllocationScenario.ts b/packages/ztd-cli/tests/utils/taxAllocationScenario.ts deleted file mode 100644 index 3874045f4..000000000 --- a/packages/ztd-cli/tests/utils/taxAllocationScenario.ts +++ /dev/null @@ -1,148 +0,0 @@ -export interface TaxAllocationCase { - invoiceId: number; - label: string; - expectedRows: Array<{ id: number; amount_cents: number; allocated_tax_cents: number }>; -} - -export const TAX_ALLOCATION_QUERY = ` -with input_lines as ( - select - l.id, - l.amount_cents, - l.tax_rate_basis_points - from public.invoice_lines l - where l.invoice_id = $1 -), -raw_tax_basis as ( - select - id, - amount_cents, - tax_rate_basis_points, - amount_cents::numeric * tax_rate_basis_points::numeric / 10000 as raw_tax_cents - from input_lines -), -floored_allocations as ( - select - id, - amount_cents, - floor(raw_tax_cents)::int as floored_tax_cents, - raw_tax_cents - floor(raw_tax_cents) as discarded_fraction - from raw_tax_basis -), -expected_total_tax as ( - select - round(sum(amount_cents::numeric * tax_rate_basis_points::numeric / 10000))::int as expected_tax_cents - from input_lines -), -ranked_allocations as ( - select - id, - amount_cents, - floored_tax_cents, - discarded_fraction, - row_number() over ( - order by discarded_fraction desc, id asc - ) as allocation_rank - from floored_allocations -), -bonus_rows as ( - select id - from ranked_allocations - where allocation_rank <= ( - select greatest( - (select round(sum(amount_cents::numeric * tax_rate_basis_points::numeric / 10000))::int from input_lines) - - coalesce(sum(floored_tax_cents), 0)::int, - 0 - ) - from floored_allocations - ) -), -final_allocations as ( - select - ranked.id, - ranked.amount_cents, - ranked.floored_tax_cents + case when bonus.id is null then 0 else 1 end as allocated_tax_cents - from ranked_allocations ranked - left join bonus_rows bonus on bonus.id = ranked.id -) -select id, amount_cents, allocated_tax_cents -from final_allocations -order by id -`; - -export const TAX_ALLOCATION_METADATA = { - material: ['input_lines', 'floored_allocations', 'ranked_allocations'], - scalarFilterColumns: ['allocation_rank'] -} as const; - -export const TAX_ALLOCATION_FIXTURE_ROWS = [ - { invoice_id: 1, id: 11, amount_cents: 100, tax_rate_basis_points: 1000 }, - { invoice_id: 1, id: 12, amount_cents: 200, tax_rate_basis_points: 1000 }, - { invoice_id: 2, id: 21, amount_cents: 105, tax_rate_basis_points: 1000 }, - { invoice_id: 2, id: 22, amount_cents: 105, tax_rate_basis_points: 1000 }, - { invoice_id: 2, id: 23, amount_cents: 100, tax_rate_basis_points: 1000 }, - { invoice_id: 3, id: 31, amount_cents: 105, tax_rate_basis_points: 1000 }, - { invoice_id: 3, id: 32, amount_cents: 105, tax_rate_basis_points: 1000 }, - { invoice_id: 3, id: 33, amount_cents: 105, tax_rate_basis_points: 1000 }, - { invoice_id: 4, id: 41, amount_cents: 105, tax_rate_basis_points: 1000 }, - { invoice_id: 5, id: 51, amount_cents: 333, tax_rate_basis_points: 1000 }, - { invoice_id: 5, id: 52, amount_cents: 333, tax_rate_basis_points: 1000 }, - { invoice_id: 5, id: 53, amount_cents: 334, tax_rate_basis_points: 1000 }, - { invoice_id: 6, id: 61, amount_cents: 109, tax_rate_basis_points: 1000 }, - { invoice_id: 6, id: 62, amount_cents: 109, tax_rate_basis_points: 1000 }, - { invoice_id: 6, id: 63, amount_cents: 109, tax_rate_basis_points: 1000 } -] as const; - -export const TAX_ALLOCATION_CASES: TaxAllocationCase[] = [ - { - invoiceId: 1, - label: 'no remainder', - expectedRows: [ - { id: 11, amount_cents: 100, allocated_tax_cents: 10 }, - { id: 12, amount_cents: 200, allocated_tax_cents: 20 } - ] - }, - { - invoiceId: 2, - label: 'remainder 1 with deterministic tie-break', - expectedRows: [ - { id: 21, amount_cents: 105, allocated_tax_cents: 11 }, - { id: 22, amount_cents: 105, allocated_tax_cents: 10 }, - { id: 23, amount_cents: 100, allocated_tax_cents: 10 } - ] - }, - { - invoiceId: 3, - label: 'remainder greater than 1', - expectedRows: [ - { id: 31, amount_cents: 105, allocated_tax_cents: 11 }, - { id: 32, amount_cents: 105, allocated_tax_cents: 11 }, - { id: 33, amount_cents: 105, allocated_tax_cents: 10 } - ] - }, - { - invoiceId: 4, - label: 'single row', - expectedRows: [ - { id: 41, amount_cents: 105, allocated_tax_cents: 11 } - ] - }, - { - invoiceId: 5, - label: 'largest fraction wins', - expectedRows: [ - { id: 51, amount_cents: 333, allocated_tax_cents: 33 }, - { id: 52, amount_cents: 333, allocated_tax_cents: 33 }, - { id: 53, amount_cents: 334, allocated_tax_cents: 34 } - ] - }, - { - invoiceId: 6, - label: 'many rows all receive the remainder', - expectedRows: [ - { id: 61, amount_cents: 109, allocated_tax_cents: 11 }, - { id: 62, amount_cents: 109, allocated_tax_cents: 11 }, - { id: 63, amount_cents: 109, allocated_tax_cents: 11 } - ] - } -]; diff --git a/packages/ztd-cli/tests/utils/testCaseCatalog.ts b/packages/ztd-cli/tests/utils/testCaseCatalog.ts deleted file mode 100644 index d2fa5c9cb..000000000 --- a/packages/ztd-cli/tests/utils/testCaseCatalog.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { test } from 'vitest'; - -/** - * A deterministic executable test-case entry. - */ -export interface TestCaseCatalogEntry { - id: string; - title: string; - description?: string; - arrange?: () => Promise | TContext; - act: (context: TContext) => Promise | TResult; - assert: (result: TResult, context: TContext) => Promise | void; - /** - * Literal specification payload used by evidence exporters. - * This keeps review artifacts aligned with test source facts without inference. - */ - evidence?: { - input: unknown; - expected: 'success' | 'throws' | 'errorResult'; - output?: unknown; - error?: { - name: string; - message: string; - match: 'equals' | 'contains'; - }; - tags?: string[]; - focus?: string; - refs?: Array<{ label: string; url: string }>; - }; -} - -/** - * Collection of deterministic test cases grouped under one catalog id. - */ -export interface TestCaseCatalog { - id: string; - title: string; - description?: string; - definitionPath?: string; - refs?: Array<{ label: string; url: string }>; - cases: TestCaseCatalogEntry[]; -} - -/** - * Stable evidence document format exported from test-case catalogs. - */ -export interface TestCaseCatalogEvidenceDocument { - schemaVersion: 1; - catalogs: Array<{ - id: string; - title: string; - description?: string; - definitionPath?: string; - refs?: Array<{ label: string; url: string }>; - cases: Array<{ - id: string; - title: string; - description?: string; - input: unknown; - expected: 'success' | 'throws' | 'errorResult'; - output?: unknown; - error?: { - name: string; - message: string; - match: 'equals' | 'contains'; - }; - tags?: string[]; - focus?: string; - refs?: Array<{ label: string; url: string }>; - }>; - }>; -} - -interface TestCaseCatalogEvidenceInput { - id: string; - title: string; - description?: string; - definitionPath?: string; - refs?: Array<{ label: string; url: string }>; - cases: Array<{ - id: string; - title: string; - description?: string; - input?: unknown; - expected?: 'success' | 'throws' | 'errorResult'; - output?: unknown; - error?: { - name: string; - message: string; - match: 'equals' | 'contains'; - }; - tags?: string[]; - focus?: string; - refs?: Array<{ label: string; url: string }>; - evidence?: { - input: unknown; - expected: 'success' | 'throws' | 'errorResult'; - output?: unknown; - error?: { - name: string; - message: string; - match: 'equals' | 'contains'; - }; - tags?: string[]; - focus?: string; - refs?: Array<{ label: string; url: string }>; - }; - }>; -} - -/** - * Define an executable test-case catalog with stable IDs. - */ -export function defineTestCaseCatalog( - catalog: TestCaseCatalog -): TestCaseCatalog { - return { - ...catalog, - cases: [...catalog.cases].sort((a, b) => a.id.localeCompare(b.id)), - }; -} - -/** - * Register all catalog entries as vitest tests without inferring behavior from source text. - * When `arrange` is omitted, the runner passes `undefined` as context. - */ -export function runTestCaseCatalog( - catalog: TestCaseCatalog, - hooks?: { onCaseExecuted?: (id: string) => void } -): void { - for (const entry of catalog.cases) { - test(`[${catalog.id}] ${entry.id} ${entry.title}`, async () => { - const context = entry.arrange ? await entry.arrange() : (undefined as TContext); - const result = await entry.act(context); - await entry.assert(result, context); - hooks?.onCaseExecuted?.(entry.id); - }); - } -} - -/** - * Convert catalog objects into a deterministic evidence document for specification mode. - */ -export function exportTestCaseCatalogEvidence( - catalogs: TestCaseCatalogEvidenceInput[] -): TestCaseCatalogEvidenceDocument { - return { - schemaVersion: 1, - catalogs: [...catalogs] - .sort((a, b) => a.id.localeCompare(b.id)) - .map((catalog) => ({ - id: catalog.id, - title: catalog.title, - ...(catalog.description ? { description: catalog.description } : {}), - ...(catalog.definitionPath ? { definitionPath: catalog.definitionPath } : {}), - ...(Array.isArray(catalog.refs) && catalog.refs.length > 0 - ? { - refs: [...catalog.refs] - .filter((ref) => typeof ref.label === 'string' && ref.label.trim().length > 0 && typeof ref.url === 'string' && ref.url.trim().length > 0) - .map((ref) => ({ label: ref.label.trim(), url: ref.url.trim() })) - .sort((a, b) => a.label.localeCompare(b.label) || a.url.localeCompare(b.url)) - } - : {}), - cases: [...catalog.cases] - .sort((a, b) => a.id.localeCompare(b.id)) - .map((entry) => { - const source = entry.evidence ?? entry; - const expected = source.expected ?? 'success'; - return { - id: entry.id, - title: entry.title, - ...(entry.description ? { description: entry.description } : {}), - input: source.input, - expected, - ...(expected === 'throws' ? { error: source.error } : { output: source.output }), - ...(source.tags && source.tags.length > 0 - ? { tags: [...source.tags].sort((a, b) => a.localeCompare(b)) } - : {}), - ...(source.focus ? { focus: source.focus } : {}), - ...(Array.isArray(source.refs) && source.refs.length > 0 - ? { - refs: [...source.refs] - .filter((ref) => typeof ref.label === 'string' && ref.label.trim().length > 0 && typeof ref.url === 'string' && ref.url.trim().length > 0) - .map((ref) => ({ label: ref.label.trim(), url: ref.url.trim() })) - .sort((a, b) => a.label.localeCompare(b.label) || a.url.localeCompare(b.url)) - } - : {}), - }; - }), - })), - }; -} diff --git a/packages/ztd-cli/tests/ztdConfig.unit.test.ts b/packages/ztd-cli/tests/ztdConfig.unit.test.ts deleted file mode 100644 index 257290f06..000000000 --- a/packages/ztd-cli/tests/ztdConfig.unit.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { expect, test } from 'vitest'; -import { renderZtdConfigFile, renderZtdFixtureManifestFile, snapshotTableMetadata } from '../src/commands/ztdConfig'; -import { resolveZtdConfigCommandOptions } from '../src/commands/ztdConfigCommand'; -import type { SqlSource } from '../src/utils/collectSqlFiles'; -import { normalizeLineEndings } from './utils/normalize'; - -test('generates ZTD row map from CREATE TABLE statements', () => { - const sources: SqlSource[] = [ - { - path: 'DDL/users.sql', - sql: ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - email text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() - ); - - CREATE TABLE public.profiles ( - user_id bigint NOT NULL, - bio text, - rating numeric - ); - ` - } - ]; - - // Capture the table metadata so the renderer can produce TypeScript declarations. - const tables = snapshotTableMetadata(sources); - expect(tables.map((table) => table.name)).toEqual(['public.profiles', 'public.users']); - - // Normalize line endings so snapshots remain stable across platforms. - const output = normalizeLineEndings(renderZtdConfigFile(tables)); - - expect(output).toMatchSnapshot(); -}); - -test('generates a runtime fixture manifest alongside the row map', () => { - const sources: SqlSource[] = [ - { - path: 'DDL/users.sql', - sql: ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - email text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() - ); - ` - } - ]; - - const tables = snapshotTableMetadata(sources); - const output = normalizeLineEndings(renderZtdFixtureManifestFile(tables)); - - expect(output).toContain("import type { TableDefinitionModel } from 'rawsql-ts';"); - expect(output).not.toContain('tableRows:'); - expect(output).toMatchSnapshot(); -}); - -test('preserves identity column defaults through the runtime fixture manifest', () => { - const sources: SqlSource[] = [ - { - path: 'DDL/users.sql', - sql: ` - CREATE TABLE public.users ( - user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - email text NOT NULL, - display_name text NOT NULL - ); - ` - } - ]; - - const tables = snapshotTableMetadata(sources); - expect(tables).toHaveLength(1); - expect(tables[0]?.name).toBe('public.users'); - expect(tables[0]?.columns).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: 'user_id', - defaultValue: 'row_number() over ()' - }) - ]) - ); - - const manifestOutput = normalizeLineEndings(renderZtdFixtureManifestFile(tables)); - expect(manifestOutput).toContain('name: "user_id"'); - expect(manifestOutput).toContain('defaultValue: "row_number() over ()"'); -}); - -test('handles multiple sources with composite keys and cross-schema references', () => { - const sources: SqlSource[] = [ - { - path: 'DDL/users.sql', - sql: ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - name text NOT NULL - ); - ` - }, - { - path: 'DDL/sales/orders.sql', - sql: ` - CREATE TABLE sales.orders ( - order_id bigint NOT NULL, - line bigint NOT NULL, - customer_id bigint NOT NULL, - total numeric NOT NULL, - PRIMARY KEY (order_id, line), - FOREIGN KEY (customer_id) REFERENCES public.users(id) - ); - - CREATE TABLE analytics.weekly_stats ( - stats_id serial PRIMARY KEY, - order_id bigint NOT NULL, - order_line bigint NOT NULL, - sales_amount numeric NOT NULL, - FOREIGN KEY (order_id, order_line) REFERENCES sales.orders(order_id, line) - ); - ` - } - ]; - - // Collect metadata across the different sources so snapshots reflect the expanded surface. - const tables = snapshotTableMetadata(sources); - expect(tables.map((table) => table.name)).toEqual([ - 'analytics.weekly_stats', - 'public.users', - 'sales.orders' - ]); - - // Ensure the composite primary-key columns remain marked not-null. - const orders = tables.find((table) => table.name === 'sales.orders'); - expect(orders).toBeDefined(); - const nonNullOrders = orders!; - expect(nonNullOrders.columns.filter((column) => !column.isNullable).map((column) => column.name)).toEqual([ - 'order_id', - 'line', - 'customer_id', - 'total' - ]); - - // Verify the stats table captures the cross-schema reference columns and their nullability. - const stats = tables.find((table) => table.name === 'analytics.weekly_stats'); - expect(stats).toBeDefined(); - const nonNullStats = stats!; - expect(nonNullStats.columns.map((column) => column.name)).toEqual([ - 'stats_id', - 'order_id', - 'order_line', - 'sales_amount' - ]); - expect(nonNullStats.columns.slice(1, 3).every((column) => !column.isNullable)).toBe(true); - - // Normalize line endings so snapshots remain stable across platforms. - const output = normalizeLineEndings(renderZtdConfigFile(tables)); - - expect(output).toMatchSnapshot(); -}); - -test('resolveZtdConfigCommandOptions expands json payload fields to command option shapes', () => { - const resolved = resolveZtdConfigCommandOptions({ - json: JSON.stringify({ - ddlDir: 'db/ddl', - extensions: '.sql,.ddl', - searchPath: 'public,app', - dryRun: true - }) - }); - - expect(resolved).toMatchObject({ - ddlDir: ['db/ddl'], - extensions: ['.ddl', '.sql'], - searchPath: ['public', 'app'], - dryRun: true - }); -}); diff --git a/packages/ztd-cli/tests/ztdConfigCommand.telemetry.unit.test.ts b/packages/ztd-cli/tests/ztdConfigCommand.telemetry.unit.test.ts deleted file mode 100644 index 800a54ea5..000000000 --- a/packages/ztd-cli/tests/ztdConfigCommand.telemetry.unit.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; -import { buildProgram } from '../src/index'; -import { configureTelemetry } from '../src/utils/telemetry'; - -const originalOutput = process.env.ZTD_CLI_OUTPUT_FORMAT; -const originalTelemetry = process.env.ZTD_CLI_TELEMETRY; - -function createWorkspace(prefix: string): string { - const tmpRoot = path.join(process.cwd(), 'tmp'); - mkdirSync(tmpRoot, { recursive: true }); - const root = mkdtempSync(path.join(tmpRoot, `${prefix}-`)); - mkdirSync(path.join(root, 'db', 'ddl'), { recursive: true }); - return root; -} - -afterEach(() => { - if (originalOutput === undefined) { - delete process.env.ZTD_CLI_OUTPUT_FORMAT; - } else { - process.env.ZTD_CLI_OUTPUT_FORMAT = originalOutput; - } - if (originalTelemetry === undefined) { - delete process.env.ZTD_CLI_TELEMETRY; - } else { - process.env.ZTD_CLI_TELEMETRY = originalTelemetry; - } - configureTelemetry({ enabled: false }); - vi.restoreAllMocks(); -}); - -test('real CLI root wiring emits telemetry events for ztd-config when --telemetry is enabled', async () => { - const workspace = createWorkspace('ztd-config-telemetry'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const ddlFile = path.join(ddlDir, 'public.sql'); - const outputFile = path.join(workspace, '.ztd', 'generated', 'ztd-row-map.generated.ts'); - const relativeDdlDir = path.relative(process.cwd(), ddlDir); - const relativeOutputFile = path.relative(process.cwd(), outputFile); - - writeFileSync( - ddlFile, - ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - email text NOT NULL - ); - `, - 'utf8', - ); - - const stdout: string[] = []; - const stderrLines: string[] = []; - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stderrLines.push(String(chunk)); - return true; - }) as typeof process.stderr.write); - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stdout.push(String(chunk)); - return true; - }) as typeof process.stdout.write); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - ['node', 'ztd', '--telemetry', '--output', 'json', 'ztd-config', '--ddl-dir', relativeDdlDir, '--out', relativeOutputFile, '--dry-run'], - { from: 'node' }, - ); - - stderrSpy.mockRestore(); - stdoutSpy.mockRestore(); - - const envelope = JSON.parse(stdout.join('')); - expect(envelope).toMatchObject({ - command: 'ztd-config', - ok: true, - data: { - dryRun: true, - }, - }); - - const telemetryEvents = stderrLines - .join('') - .split(/\r?\n/) - .filter((line) => line.includes('"type":"telemetry"')) - .map((line) => JSON.parse(line)); - - expect(telemetryEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'ztd-config' }), - expect.objectContaining({ kind: 'span-start', spanName: 'resolve-command-state' }), - expect.objectContaining({ kind: 'span-start', spanName: 'generate-ztd-config' }), - expect.objectContaining({ kind: 'decision', eventName: 'output.json-envelope' }), - expect.objectContaining({ kind: 'span-end', spanName: 'ztd-config', status: 'ok' }), - ]), - ); -}); - -test('ztd-config does not emit a config.updated decision when the effective config is unchanged', async () => { - const workspace = createWorkspace('ztd-config-noop-update'); - const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(workspace); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const ddlFile = path.join(ddlDir, 'public.sql'); - - writeFileSync( - ddlFile, - ` - CREATE TABLE public.users ( - id serial PRIMARY KEY, - email text NOT NULL - ); - `, - 'utf8', - ); - writeFileSync( - path.join(workspace, 'ztd.config.json'), - JSON.stringify( - { - dialect: 'postgres', - ztdRootDir: '.ztd', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, - null, - 2, - ), - 'utf8', - ); - try { - const stdout: string[] = []; - const stderrLines: string[] = []; - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stderrLines.push(String(chunk)); - return true; - }) as typeof process.stderr.write); - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stdout.push(String(chunk)); - return true; - }) as typeof process.stdout.write); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - [ - 'node', - 'ztd', - '--telemetry', - '--output', - 'json', - 'ztd-config', - '--ddl-dir', - 'db/ddl', - '--out', - '.ztd/generated/ztd-row-map.generated.ts', - '--default-schema', - 'public', - '--search-path', - 'public' - ], - { from: 'node' }, - ); - - stderrSpy.mockRestore(); - stdoutSpy.mockRestore(); - - const envelope = JSON.parse(stdout.join('')); - expect(envelope).toMatchObject({ - command: 'ztd-config', - ok: true, - data: { - configUpdated: false - } - }); - - const telemetryEvents = stderrLines - .join('') - .split(/\r?\n/) - .filter((line) => line.includes('"type":"telemetry"')) - .map((line) => JSON.parse(line)); - - expect(telemetryEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'persist-project-config' }), - expect.objectContaining({ kind: 'span-end', spanName: 'persist-project-config', status: 'ok' }), - expect.objectContaining({ kind: 'decision', eventName: 'output.json-envelope' }), - ]), - ); - expect( - telemetryEvents.some((event) => event.kind === 'decision' && event.eventName === 'config.updated') - ).toBe(false); - } finally { - cwdSpy.mockRestore(); - } -}); - -test('real CLI root wiring enables telemetry from env when the flag is omitted', async () => { - const workspace = createWorkspace('ztd-config-telemetry-env'); - const ddlDir = path.join(workspace, 'db', 'ddl'); - const ddlFile = path.join(ddlDir, 'public.sql'); - const outputFile = path.join(workspace, '.ztd', 'generated', 'ztd-row-map.generated.ts'); - const relativeDdlDir = path.relative(process.cwd(), ddlDir); - const relativeOutputFile = path.relative(process.cwd(), outputFile); - - writeFileSync( - ddlFile, - ` - CREATE TABLE public.accounts ( - id serial PRIMARY KEY, - email text NOT NULL - ); - `, - 'utf8', - ); - - process.env.ZTD_CLI_TELEMETRY = '1'; - - const stderrLines: string[] = []; - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stderrLines.push(String(chunk)); - return true; - }) as typeof process.stderr.write); - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - void chunk; - return true; - }) as typeof process.stdout.write); - - const program = buildProgram(); - program.exitOverride(); - await program.parseAsync( - ['node', 'ztd', '--output', 'json', 'ztd-config', '--ddl-dir', relativeDdlDir, '--out', relativeOutputFile, '--dry-run'], - { from: 'node' }, - ); - - stderrSpy.mockRestore(); - stdoutSpy.mockRestore(); - - const telemetryEvents = stderrLines - .join('') - .split(/\r?\n/) - .filter((line) => line.includes('"type":"telemetry"')) - .map((line) => JSON.parse(line)); - - expect(telemetryEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'span-start', spanName: 'ztd-config' }), - expect.objectContaining({ kind: 'decision', eventName: 'command.selected' }), - expect.objectContaining({ kind: 'span-end', spanName: 'ztd-config', status: 'ok' }), - ]), - ); -}); diff --git a/packages/ztd-cli/tests/ztdLint.test.ts b/packages/ztd-cli/tests/ztdLint.test.ts deleted file mode 100644 index df972f60e..000000000 --- a/packages/ztd-cli/tests/ztdLint.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { Client } from 'pg'; -import { describe, expect, test } from 'vitest'; -import { runSqlLint } from '../src/commands/lint'; - -const createTempDir = (prefix: string): string => - mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); - -const describeIfPg = process.env.TEST_PG_URI ? describe : describe.skip; - -const VALID_JOIN_SQL = ` -select - u.user_id, - o.order_id -from [users] u -inner join [orders] o - on o.user_id = u.user_id -where u.user_id = 1; -`; - -const MISSING_COLUMN_SQL = ` -select missing_column -from [users] -where user_id = 1; -`; - -const SYNTAX_ERROR_SQL = `select missing_function(1);`; -const TRANSFORM_MISSING_FIXTURE_SQL = ` -select * -from [missing] -where 1 = 1; -`; - -describeIfPg('runSqlLint integration', () => { - test('valid join query passes through lint', async () => { - const { ddlDir, sqlPath } = prepareWorkspaceWithSql(VALID_JOIN_SQL); - const client = new Client({ connectionString: process.env.TEST_PG_URI }); - await client.connect(); - try { - const result = await runSqlLint({ - sqlFiles: [sqlPath], - ddlDirectories: [ddlDir], - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict', - client - }); - expect(result.failures).toHaveLength(0); - expect(result.filesChecked).toBe(1); - } finally { - await client.end(); - } - }); - - test('postgres column error surfaces as db failure', async () => { - const { ddlDir, sqlPath } = prepareWorkspaceWithSql(MISSING_COLUMN_SQL); - const client = new Client({ connectionString: process.env.TEST_PG_URI }); - await client.connect(); - try { - const result = await runSqlLint({ - sqlFiles: [sqlPath], - ddlDirectories: [ddlDir], - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict', - client - }); - expect(result.failures.length).toBeGreaterThan(0); - const failure = result.failures[0]; - expect(failure.kind).toBe('db'); - expect(failure.message.toLowerCase()).toContain('column'); - expect(failure.message.toLowerCase()).toContain('does not exist'); - expect(failure.details?.code).toBe('42703'); - } finally { - await client.end(); - } - }); - - test('postgres syntax error surfaces as db failure', async () => { - const { ddlDir, sqlPath } = prepareWorkspaceWithSql(SYNTAX_ERROR_SQL); - const client = new Client({ connectionString: process.env.TEST_PG_URI }); - await client.connect(); - try { - const result = await runSqlLint({ - sqlFiles: [sqlPath], - ddlDirectories: [ddlDir], - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict', - client - }); - expect(result.failures.length).toBeGreaterThan(0); - const failure = result.failures[0]; - expect(failure.kind).toBe('db'); - expect(failure.message.toLowerCase()).toContain('function'); - expect(failure.details?.code).toBe('42883'); - } finally { - await client.end(); - } - }); - - test('ZTD transform errors surface fixture diagnostics', async () => { - const { ddlDir, sqlPath } = prepareWorkspaceWithSql(TRANSFORM_MISSING_FIXTURE_SQL); - const client = new Client({ connectionString: process.env.TEST_PG_URI }); - await client.connect(); - try { - const result = await runSqlLint({ - sqlFiles: [sqlPath], - ddlDirectories: [ddlDir], - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict', - client - }); - expect(result.failures.length).toBeGreaterThan(0); - const failure = result.failures[0]; - expect(failure.kind).toBe('db'); - expect(failure.message.toLowerCase()).toContain('missing'); - expect(failure.details?.code).toBe('42P01'); - } finally { - await client.end(); - } - }); -}); - -function prepareWorkspaceWithSql( - sqlTemplate: string -): { ddlDir: string; sqlPath: string } { - const workspace = createTempDir('ztd-lint-integration'); - const ddlDir = path.join(workspace, 'ddl'); - mkdirSync(ddlDir, { recursive: true }); - const baseName = `lint_${Date.now().toString(36)}_${Math.random() - .toString(36) - .slice(2, 8)}`; - const usersTable = `${baseName}_users`; - const ordersTable = `${baseName}_orders`; - - writeFileSync( - path.join(ddlDir, 'schema.sql'), - ` - CREATE TABLE public.${usersTable} ( - user_id integer PRIMARY KEY, - name text NOT NULL - ); - - CREATE TABLE public.${ordersTable} ( - order_id integer PRIMARY KEY, - user_id integer NOT NULL - ); - `, - 'utf8' - ); - - const sqlPath = path.join(workspace, 'query.sql'); - const missingTable = `${baseName}_missing`; - const sql = sqlTemplate - .replace(/\[users\]/g, usersTable) - .replace(/\[orders\]/g, ordersTable) - .replace(/\[missing\]/g, missingTable); - writeFileSync(sqlPath, sql, 'utf8'); - return { ddlDir, sqlPath }; -} diff --git a/packages/ztd-cli/tests/ztdProjectConfig.unit.test.ts b/packages/ztd-cli/tests/ztdProjectConfig.unit.test.ts deleted file mode 100644 index 8862a31c5..000000000 --- a/packages/ztd-cli/tests/ztdProjectConfig.unit.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; - -const tempDirs: string[] = []; - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - vi.restoreAllMocks(); -}); - -async function loadConfigModule() { - vi.resetModules(); - return import('../src/utils/ztdProjectConfig'); -} - -test('writeZtdProjectConfig skips rewriting when the effective config is unchanged', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-config-write-noop-')); - tempDirs.push(rootDir); - - writeFileSync( - path.join(rootDir, 'ztd.config.json'), - JSON.stringify( - { - ztdRootDir: '.ztd', - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, - null, - 2 - ), - 'utf8' - ); - - const { loadZtdProjectConfig, writeZtdProjectConfig } = await loadConfigModule(); - const config = loadZtdProjectConfig(rootDir); - const beforeContents = readFileSync(path.join(rootDir, 'ztd.config.json'), 'utf8'); - const beforeStat = statSync(path.join(rootDir, 'ztd.config.json')); - - const didWrite = writeZtdProjectConfig(rootDir, {}, config); - - expect(didWrite).toBe(false); - expect(config.defaultSchema).toBe('public'); - expect(config.searchPath).toEqual(['public']); - expect(readFileSync(path.join(rootDir, 'ztd.config.json'), 'utf8')).toBe(beforeContents); - expect(statSync(path.join(rootDir, 'ztd.config.json')).mtimeMs).toBe(beforeStat.mtimeMs); -}); - -test('loadZtdProjectConfig warns when legacy connection config is present', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-config-legacy-')); - tempDirs.push(rootDir); - - writeFileSync( - path.join(rootDir, 'ztd.config.json'), - JSON.stringify( - { - ztdRootDir: '.ztd', - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict', - connection: { - host: 'legacy-db', - user: 'legacy-user', - database: 'legacy-db' - } - }, - null, - 2 - ), - 'utf8' - ); - - const emitWarning = vi.spyOn(process, 'emitWarning').mockImplementation(() => undefined); - const { loadZtdProjectConfig } = await loadConfigModule(); - - const config = loadZtdProjectConfig(rootDir); - - expect(config.connection).toMatchObject({ - host: 'legacy-db', - user: 'legacy-user', - database: 'legacy-db' - }); - expect(config.defaultSchema).toBe('public'); - expect(config.searchPath).toEqual(['public']); - expect(emitWarning).toHaveBeenCalledTimes(1); - expect(emitWarning.mock.calls[0]?.[0]).toContain('ztd.config.json.connection'); - expect(emitWarning.mock.calls[0]?.[1]).toMatchObject({ code: 'ZTD_LEGACY_CONNECTION_CONFIG' }); -}); - -test('loadZtdProjectConfig does not warn when legacy connection config is absent', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-config-modern-')); - tempDirs.push(rootDir); - - writeFileSync( - path.join(rootDir, 'ztd.config.json'), - JSON.stringify( - { - ztdRootDir: '.ztd', - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - defaultSchema: 'public', - searchPath: ['public'], - ddlLint: 'strict' - }, - null, - 2 - ), - 'utf8' - ); - - const emitWarning = vi.spyOn(process, 'emitWarning').mockImplementation(() => undefined); - const { loadZtdProjectConfig } = await loadConfigModule(); - - const config = loadZtdProjectConfig(rootDir); - - expect(config.connection).toBeUndefined(); - expect(config.defaultSchema).toBe('public'); - expect(config.searchPath).toEqual(['public']); - expect(emitWarning).not.toHaveBeenCalled(); -}); - -test('loadZtdProjectConfig rejects removed ddl schema settings', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-config-no-ddl-fallback-')); - tempDirs.push(rootDir); - - writeFileSync( - path.join(rootDir, 'ztd.config.json'), - JSON.stringify( - { - ztdRootDir: '.ztd', - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - ddl: { defaultSchema: 'legacy', searchPath: ['legacy', 'public'] }, - ddlLint: 'strict' - }, - null, - 2 - ), - 'utf8' - ); - - const { loadZtdProjectConfig } = await loadConfigModule(); - - expect(() => loadZtdProjectConfig(rootDir)).toThrow(/removed legacy ddl\.defaultSchema \/ ddl\.searchPath settings/); -}); - -test('loadZtdProjectConfig rejects an empty legacy ddl block', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-config-empty-ddl-')); - tempDirs.push(rootDir); - - writeFileSync( - path.join(rootDir, 'ztd.config.json'), - JSON.stringify( - { - ztdRootDir: '.ztd', - dialect: 'postgres', - ddlDir: 'db/ddl', - testsDir: '.ztd/tests', - ddl: {}, - ddlLint: 'strict' - }, - null, - 2 - ), - 'utf8' - ); - - const { loadZtdProjectConfig } = await loadConfigModule(); - - expect(() => loadZtdProjectConfig(rootDir)).toThrow(/removed legacy ddl\.defaultSchema \/ ddl\.searchPath settings/); -}); diff --git a/packages/ztd-cli/tests/ztdVerifier.unit.test.ts b/packages/ztd-cli/tests/ztdVerifier.unit.test.ts deleted file mode 100644 index 6a12c5ab8..000000000 --- a/packages/ztd-cli/tests/ztdVerifier.unit.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { afterEach, expect, test, vi } from 'vitest'; - -const { - closeMock, - createPostgresTestkitClientMock, - poolClientQueryMock, - poolClientReleaseMock, - poolConnectMock, - poolEndMock, - poolQueryMock -} = vi.hoisted(() => ({ - closeMock: vi.fn().mockResolvedValue(undefined), - createPostgresTestkitClientMock: vi.fn(), - poolClientQueryMock: vi.fn().mockResolvedValue({ rows: [{ result: 1 }], rowCount: 1 }), - poolClientReleaseMock: vi.fn(), - poolConnectMock: vi.fn(), - poolEndMock: vi.fn().mockResolvedValue(undefined), - poolQueryMock: vi.fn().mockResolvedValue({ rows: [{ result: 1 }], rowCount: 1 }) -})); - -vi.mock('pg', () => ({ - Pool: class MockPool { - query = poolQueryMock; - connect = poolConnectMock; - end = poolEndMock; - } -})); - -vi.mock('@rawsql-ts/testkit-postgres', () => ({ - createPostgresTestkitClient: createPostgresTestkitClientMock -})); - -const tempDirs: string[] = []; -const previousEnv = { - ZTD_DB_URL: process.env.ZTD_DB_URL, - ZTD_SQL_TRACE: process.env.ZTD_SQL_TRACE, - ZTD_SQL_TRACE_DIR: process.env.ZTD_SQL_TRACE_DIR -}; - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - closeMock.mockClear(); - createPostgresTestkitClientMock.mockReset(); - poolClientQueryMock.mockClear(); - poolClientReleaseMock.mockClear(); - poolConnectMock.mockReset(); - poolEndMock.mockClear(); - poolQueryMock.mockClear(); - if (previousEnv.ZTD_DB_URL === undefined) { - delete process.env.ZTD_DB_URL; - } else { - process.env.ZTD_DB_URL = previousEnv.ZTD_DB_URL; - } - if (previousEnv.ZTD_SQL_TRACE === undefined) { - delete process.env.ZTD_SQL_TRACE; - } else { - process.env.ZTD_SQL_TRACE = previousEnv.ZTD_SQL_TRACE; - } - if (previousEnv.ZTD_SQL_TRACE_DIR === undefined) { - delete process.env.ZTD_SQL_TRACE_DIR; - } else { - process.env.ZTD_SQL_TRACE_DIR = previousEnv.ZTD_SQL_TRACE_DIR; - } - vi.restoreAllMocks(); -}); - -test('verifyQuerySpecZtdCase explains how to create ZTD_DB_URL when the starter DB env is missing', async () => { - delete process.env.ZTD_DB_URL; - - const { verifyQuerySpecZtdCase } = await import('../templates/tests/support/ztd/verifier'); - - await expect( - verifyQuerySpecZtdCase( - { - name: 'missing-db-url', - beforeDb: {}, - input: {}, - output: { ok: true } - }, - async () => ({ ok: true }) - ) - ).rejects.toThrow(/Copy `\.env\.example` to `\.env`/); -}); - -test('verifyQuerySpecTraditionalCase adds starter recovery steps when Postgres is unreachable', async () => { - process.env.ZTD_DB_URL = 'postgres://ztd:ztd@localhost:55433/ztd'; - const connectionError = new Error('connect ECONNREFUSED 127.0.0.1:55433') as Error & { code?: string }; - connectionError.code = 'ECONNREFUSED'; - poolConnectMock.mockRejectedValue(connectionError); - - const { verifyQuerySpecTraditionalCase } = await import('../templates/tests/support/ztd/verifier'); - - await expect( - verifyQuerySpecTraditionalCase( - { - name: 'db-down', - beforeDb: {}, - input: {}, - output: { ok: true } - }, - async () => ({ ok: true }) - ) - ).rejects.toThrow(/localhost:55433[\s\S]*docker compose up -d/); -}); - -test('verifyQuerySpecZtdCase wraps AggregateError connection failures with starter recovery steps', async () => { - process.env.ZTD_DB_URL = 'postgres://ztd:ztd@localhost:15432/ztd'; - const aggregateError = new AggregateError( - [ - Object.assign(new Error('connect ECONNREFUSED ::1:15432'), { - code: 'ECONNREFUSED', - address: '::1', - port: 15432 - }), - Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:15432'), { - code: 'ECONNREFUSED', - address: '127.0.0.1', - port: 15432 - }) - ], - '' - ); - createPostgresTestkitClientMock.mockReturnValue({ - query: vi.fn().mockRejectedValue(aggregateError), - close: closeMock - }); - - const { verifyQuerySpecZtdCase } = await import('../templates/tests/support/ztd/verifier'); - - await expect( - verifyQuerySpecZtdCase( - { - name: 'aggregate-db-down', - beforeDb: {}, - input: {}, - output: { ok: true } - }, - (client) => client.query('select 1', {}) - ) - ).rejects.toThrow(/localhost:15432[\s\S]*docker compose up -d[\s\S]*ZTD_DB_PORT/); -}); - -test('verifyQuerySpecTraditionalCase physically prepares fixtures and returns traditional evidence', async () => { - const rootDir = mkdtempSync(path.join(tmpdir(), 'ztd-verifier-project-')); - tempDirs.push(rootDir); - writeFileSync( - path.join(rootDir, 'ztd.config.json'), - JSON.stringify({ - defaultSchema: ' public ', - searchPath: [' app ', '', ' $user ', ' pg_temp ', ' pg_temp_3 ', ' public '] - }), - 'utf8' - ); - vi.spyOn(process, 'cwd').mockReturnValue(rootDir); - - process.env.ZTD_DB_URL = 'postgres://localhost:5432/ztd'; - - poolConnectMock.mockResolvedValue({ - query: poolClientQueryMock, - release: poolClientReleaseMock - }); - poolClientQueryMock.mockImplementation((sql: string) => { - if (sql.includes('SELECT * FROM')) { - return Promise.resolve({ rows: [{ user_id: 1, email: 'alice@example.com' }], rowCount: 1 }); - } - return Promise.resolve({ rows: [{ ok: true }], rowCount: 1 }); - }); - - const { verifyQuerySpecTraditionalCase } = await import('../templates/tests/support/ztd/verifier'); - - const evidence = await verifyQuerySpecTraditionalCase( - { - name: 'traditional-read', - beforeDb: { - public: { - users: [{ user_id: 1, email: 'alice@example.com' }] - } - }, - input: { userId: 1 }, - output: [{ ok: true }], - afterDb: { - public: { - users: [{ user_id: 1, email: 'alice@example.com' }] - } - } - }, - (client, input) => - client.query( - "select 'public.users' as literal, true as ok from public.users -- public.comment_table\nwhere note = $$public.dollar_table$$ and user_id = :userId", - input - ) - ); - - expect(evidence).toMatchObject({ - mode: 'traditional', - rewriteApplied: false, - physicalSetupUsed: true, - executedQueryCount: 1 - }); - expect(poolConnectMock).toHaveBeenCalledTimes(1); - expect(poolClientQueryMock).toHaveBeenCalledWith(expect.stringMatching(/^CREATE SCHEMA/)); - expect(poolClientQueryMock).toHaveBeenCalledWith( - expect.stringMatching(/^SET search_path TO "ztd_traditional_[^"]+", "app", \$user, pg_temp, pg_temp_3, "public"$/) - ); - expect(poolClientQueryMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO'), [1, 'alice@example.com']); - expect(poolClientQueryMock).toHaveBeenCalledWith(expect.stringContaining("'public.users' as literal"), [1]); - const executedQuery = poolClientQueryMock.mock.calls - .map((call) => String(call[0])) - .find((sql) => sql.includes("'public.users' as literal")); - expect(executedQuery).toBeDefined(); - expect(executedQuery).toContain("'public.users' as literal"); - expect(executedQuery).toContain('-- public.comment_table'); - expect(executedQuery).toContain('$$public.dollar_table$$'); - expect(executedQuery).toMatch(/from "ztd_traditional_[^"]+"\.users/); - expect(executedQuery).not.toContain('from public.users'); - expect(poolClientQueryMock).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM')); - expect(poolClientQueryMock).toHaveBeenCalledWith(expect.stringMatching(/^DROP SCHEMA IF EXISTS/)); - expect(poolClientReleaseMock).toHaveBeenCalledTimes(1); - expect(poolEndMock).toHaveBeenCalledTimes(1); -}); - -test('verifyQuerySpecZtdCase writes trace artifacts and closes the pool when the assertion fails', async () => { - const traceDir = mkdtempSync(path.join(tmpdir(), 'ztd-verifier-trace-')); - tempDirs.push(traceDir); - - process.env.ZTD_DB_URL = 'postgres://localhost:5432/ztd'; - process.env.ZTD_SQL_TRACE = '1'; - process.env.ZTD_SQL_TRACE_DIR = traceDir; - - createPostgresTestkitClientMock.mockImplementation((options: { - queryExecutor: (sql: string, params: unknown[]) => Promise<{ rows: unknown[]; rowCount?: number }>; - onExecute?: (sql: string, params: unknown[], fixtures: string[]) => void; - }) => ({ - async query(sql: string, params: unknown[]) { - const result = await options.queryExecutor(sql, params); - options.onExecute?.(sql, params, ['public.users']); - return result; - }, - async close() { - await closeMock(); - } - })); - - const { verifyQuerySpecZtdCase } = await import('../templates/tests/support/ztd/verifier'); - - await expect( - verifyQuerySpecZtdCase( - { - name: 'write-user', - beforeDb: {}, - input: {}, - output: { ok: true } - }, - async (client) => { - await client.query('select :value as value', { value: 1 }); - return { ok: false }; - } - ) - ).rejects.toThrow(); - - expect(createPostgresTestkitClientMock).toHaveBeenCalledTimes(1); - expect(closeMock).toHaveBeenCalledTimes(1); - expect(poolEndMock).toHaveBeenCalledTimes(1); - - const traceFiles = readdirSync(traceDir); - expect(traceFiles).toHaveLength(1); - - const payload = JSON.parse(readFileSync(path.join(traceDir, traceFiles[0] ?? ''), 'utf8')) as { - caseName: string; - evidence: { mode: string; executedQueryCount: number; rewriteApplied: boolean }; - failure?: { message?: string }; - trace: Array<{ boundSql: string }>; - }; - - expect(payload.caseName).toBe('write-user'); - expect(payload.evidence).toMatchObject({ - mode: 'ztd', - executedQueryCount: 1, - rewriteApplied: true - }); - expect(payload.failure?.message).toMatch(/expected/i); - expect(payload.trace).toHaveLength(1); -}); diff --git a/packages/ztd-cli/tsconfig.json b/packages/ztd-cli/tsconfig.json deleted file mode 100644 index 512610e7c..000000000 --- a/packages/ztd-cli/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "declarationDir": "dist", - "declaration": true, - "sourceMap": true, - "composite": false, - "paths": { - "rawsql-ts": ["packages/core/dist/src/index.d.ts"], - "rawsql-ts/*": ["packages/core/dist/src/*"], - "@rawsql-ts/test-evidence-core": ["packages/test-evidence-core/dist/index.d.ts"], - "@rawsql-ts/test-evidence-core/*": ["packages/test-evidence-core/dist/*"], - "@rawsql-ts/test-evidence-renderer-md": ["packages/test-evidence-renderer-md/dist/index.d.ts"], - "@rawsql-ts/test-evidence-renderer-md/*": ["packages/test-evidence-renderer-md/dist/*"], - "@rawsql-ts/sql-grep-core": ["packages/sql-grep-core/dist/index.d.ts"], - "@rawsql-ts/sql-grep-core/*": ["packages/sql-grep-core/dist/*"] - }, - "resolveJsonModule": true, - "tsBuildInfoFile": "dist/.tsbuildinfo" - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules", "src/**/*.js", "src/**/*.d.ts"] -} - - diff --git a/packages/ztd-cli/tsconfig.test.json b/packages/ztd-cli/tsconfig.test.json deleted file mode 100644 index ed98a9415..000000000 --- a/packages/ztd-cli/tsconfig.test.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "rawsql-ts": ["../core/src"], - "rawsql-ts/*": ["../core/src/*"], - "@rawsql-ts/testkit-core": ["../testkit-core/src"], - "@rawsql-ts/testkit-core/*": ["../testkit-core/src/*"], - "@rawsql-ts/test-evidence-core": ["../test-evidence-core/src"], - "@rawsql-ts/test-evidence-core/*": ["../test-evidence-core/src/*"], - "@rawsql-ts/test-evidence-renderer-md": ["../test-evidence-renderer-md/src"], - "@rawsql-ts/test-evidence-renderer-md/*": ["../test-evidence-renderer-md/src/*"], - "@rawsql-ts/sql-grep-core": ["../sql-grep-core/src"], - "@rawsql-ts/sql-grep-core/*": ["../sql-grep-core/src/*"] - } - } -} diff --git a/packages/ztd-cli/vitest.config.ts b/packages/ztd-cli/vitest.config.ts deleted file mode 100644 index c9d38f023..000000000 --- a/packages/ztd-cli/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { resolve } from 'node:path'; -import { mergeConfig } from 'vitest/config'; -import rootConfig from '../../vitest.config'; - -export default mergeConfig(rootConfig, { - test: { - root: resolve(__dirname), - include: ['tests/**/*.test.ts'], - exclude: ['dist/**', '**/dist/**', '**/node_modules/**'], - testTimeout: 60_000, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1beb0fa98..248bf2ef9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,9 +162,6 @@ importers: benchmarks/sql-unit-test: devDependencies: - '@rawsql-ts/ztd-cli': - specifier: link:../../packages/ztd-cli - version: link:../../packages/ztd-cli eslint: specifier: ^9.22.0 version: 9.39.2(jiti@2.6.1) @@ -450,13 +447,13 @@ importers: version: 16.6.1 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.2) + version: 10.9.2(@types/node@22.18.7)(typescript@5.9.2) typescript: specifier: ^5.8.2 version: 5.9.2 vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@22.19.19)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.19.19)(jiti@2.6.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@22.18.7)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.9.0)) packages/transfer: dependencies: @@ -473,9 +470,6 @@ importers: '@rawsql-ts/testkit-postgres': specifier: workspace:* version: link:../testkit-postgres - '@rawsql-ts/ztd-cli': - specifier: workspace:* - version: link:../ztd-cli '@testcontainers/postgresql': specifier: ^12.0.1 version: 12.0.1 @@ -510,73 +504,6 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.18.7)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.9.0)) - packages/ztd-cli: - dependencies: - '@rawsql-ts/driver-adapter-core': - specifier: ^0.2.0 - version: link:../drivers/driver-adapter-core - '@rawsql-ts/sql-grep-core': - specifier: ^0.1.12 - version: link:../sql-grep-core - '@rawsql-ts/test-evidence-core': - specifier: ^0.2.0 - version: link:../test-evidence-core - '@rawsql-ts/test-evidence-renderer-md': - specifier: ^0.3.2 - version: link:../test-evidence-renderer-md - chokidar: - specifier: ^5.0.0 - version: 5.0.0 - commander: - specifier: ^12.0.0 - version: 12.1.0 - diff: - specifier: 8.0.3 - version: 8.0.3 - fast-glob: - specifier: ^3.3.3 - version: 3.3.3 - rawsql-ts: - specifier: ^0.23.0 - version: link:../core - yaml: - specifier: ^2.8.3 - version: 2.8.3 - devDependencies: - '@rawsql-ts/adapter-node-pg': - specifier: ^0.15.11 - version: link:../adapters/adapter-node-pg - '@rawsql-ts/testkit-core': - specifier: ^0.17.2 - version: link:../testkit-core - '@rawsql-ts/testkit-postgres': - specifier: ^0.16.2 - version: link:../testkit-postgres - '@testcontainers/postgresql': - specifier: ^12.0.1 - version: 12.0.1 - '@types/diff': - specifier: ^5.0.1 - version: 5.2.3 - '@types/node': - specifier: ^22.13.10 - version: 22.18.7 - dotenv: - specifier: ^16.4.7 - version: 16.6.1 - pg: - specifier: ^8.11.1 - version: 8.16.3 - testcontainers: - specifier: ^12.0.1 - version: 12.0.1 - typescript: - specifier: ^5.8.2 - version: 5.9.2 - vitest: - specifier: ^4.1.8 - version: 4.1.8(@types/node@22.18.7)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.8.3)) - packages: '@algolia/abtesting@1.5.0': @@ -1477,9 +1404,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/diff@5.2.3': - resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} - '@types/docker-modem@3.0.6': resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} @@ -1522,9 +1446,6 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} - '@types/node@22.19.19': - resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} - '@types/node@22.19.6': resolution: {integrity: sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==} @@ -2010,10 +1931,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -2049,10 +1966,6 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -3433,10 +3346,6 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -5143,8 +5052,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/diff@5.2.3': {} - '@types/docker-modem@3.0.6': dependencies: '@types/node': 25.9.1 @@ -5193,10 +5100,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.19.19': - dependencies: - undici-types: 6.21.0 - '@types/node@22.19.6': dependencies: undici-types: 6.21.0 @@ -5410,7 +5313,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@22.18.7)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.9.0)) + vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@25.9.1)(jiti@2.6.1)(yaml@2.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -5421,14 +5324,6 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.8.3))': - dependencies: - '@vitest/spy': 4.1.8 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.8.3) - '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 @@ -5437,14 +5332,6 @@ snapshots: optionalDependencies: vite: 7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.9.0) - '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@22.19.19)(jiti@2.6.1)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.8 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.2(@types/node@22.19.19)(jiti@2.6.1)(yaml@2.9.0) - '@vitest/mocker@4.1.8(vite@7.3.2(@types/node@22.19.6)(jiti@2.6.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 @@ -5838,10 +5725,6 @@ snapshots: dependencies: readdirp: 4.1.2 - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - chownr@1.1.4: {} citty@0.1.6: @@ -5875,8 +5758,6 @@ snapshots: commander@11.1.0: {} - commander@12.1.0: {} - commander@14.0.3: {} commander@2.20.3: {} @@ -7234,8 +7115,6 @@ snapshots: readdirp@4.1.2: {} - readdirp@5.0.0: {} - regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -7627,24 +7506,6 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.2): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.19 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 8.0.3 - make-error: 1.3.6 - typescript: 5.9.2 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -7753,20 +7614,6 @@ snapshots: jiti: 2.6.1 yaml: 2.9.0 - vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.8.3): - dependencies: - esbuild: 0.25.10 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.10 - rollup: 4.59.0 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 22.18.7 - fsevents: 2.3.3 - jiti: 2.6.1 - yaml: 2.8.3 - vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.9.0): dependencies: esbuild: 0.25.10 @@ -7781,20 +7628,6 @@ snapshots: jiti: 2.6.1 yaml: 2.9.0 - vite@7.3.2(@types/node@22.19.19)(jiti@2.6.1)(yaml@2.9.0): - dependencies: - esbuild: 0.25.10 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.10 - rollup: 4.59.0 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 22.19.19 - fsevents: 2.3.3 - jiti: 2.6.1 - yaml: 2.9.0 - vite@7.3.2(@types/node@22.19.6)(jiti@2.6.1)(yaml@2.9.0): dependencies: esbuild: 0.25.10 @@ -7875,34 +7708,6 @@ snapshots: - universal-cookie - yaml - vitest@4.1.8(@types/node@22.18.7)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.8.3)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.2 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.8.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.18.7 - '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) - transitivePeerDependencies: - - msw - vitest@4.1.8(@types/node@22.18.7)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.18.7)(jiti@2.6.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 @@ -7931,34 +7736,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@types/node@22.19.19)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.19.19)(jiti@2.6.1)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.2(@types/node@22.19.19)(jiti@2.6.1)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.2 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@22.19.19)(jiti@2.6.1)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.19.19 - '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) - transitivePeerDependencies: - - msw - vitest@4.1.8(@types/node@22.19.6)(@vitest/coverage-v8@4.1.8)(vite@7.3.2(@types/node@22.19.6)(jiti@2.6.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 diff --git a/scripts/check-generated-mapper-drift.mjs b/scripts/check-generated-mapper-drift.mjs deleted file mode 100644 index 16892c451..000000000 --- a/scripts/check-generated-mapper-drift.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import { execFileSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(process.env.GENERATED_MAPPER_DRIFT_ROOT ?? path.resolve(__dirname, '..')); -const ignoredSegments = new Set(['node_modules', '.git', 'dist', 'tmp']); -const packageManagerExecutable = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; - -function walk(dir, matches = []) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (ignoredSegments.has(entry.name)) { - continue; - } - const absolute = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(absolute, matches); - continue; - } - if (entry.isFile() && entry.name === 'row-mapper.ts' && absolute.includes(`${path.sep}generated${path.sep}`)) { - matches.push(absolute); - } - } - return matches; -} - -function featureRootForGeneratedMapper(filePath) { - const parts = filePath.split(path.sep); - const generatedIndex = parts.lastIndexOf('generated'); - if (generatedIndex < 3 || parts[generatedIndex - 2] !== 'queries') { - return null; - } - return parts.slice(0, generatedIndex - 2).join(path.sep); -} - -function findProjectRoot(startDir) { - let current = startDir; - while (current.startsWith(repoRoot)) { - const packageJsonPath = path.join(current, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if (packageJson.scripts?.ztd) { - return current; - } - } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - return null; -} - -const featureRoots = [...new Set(walk(repoRoot).map(featureRootForGeneratedMapper).filter(Boolean))].sort(); - -if (featureRoots.length === 0) { - console.log('[generated-mapper-drift] no scaffold generated row mappers found; skipping because this root has no RFBA scaffold generated mapper artifacts'); - process.exit(0); -} - -for (const featureRoot of featureRoots) { - const featureName = path.basename(featureRoot); - const cwd = findProjectRoot(featureRoot); - if (!cwd) { - console.error( - [ - `[generated-mapper-drift] cannot check ${path.relative(repoRoot, featureRoot)}; no parent package.json with a ztd script was found.`, - 'Generated row mappers are machine-owned and must be passively checked in CI/test.', - `Add a package-level ztd script or run \`ztd feature generated-mapper check --feature ${featureName}\` from the owning project.`, - ].join('\n') - ); - process.exitCode = 1; - continue; - } - console.log(`[generated-mapper-drift] checking ${path.relative(repoRoot, featureRoot)}`); - execFileSync(packageManagerExecutable, ['ztd', 'feature', 'generated-mapper', 'check', '--feature', featureName], { - cwd, - stdio: 'inherit', - shell: process.platform === 'win32', - }); -} - -if (process.exitCode) { - process.exit(); -} diff --git a/scripts/check-pr-readiness.js b/scripts/check-pr-readiness.js index ae50fc79a..14519ebcf 100644 --- a/scripts/check-pr-readiness.js +++ b/scripts/check-pr-readiness.js @@ -1,20 +1,9 @@ const { execFileSync } = require('node:child_process'); const fs = require('node:fs'); -const CLI_SURFACE_PATTERNS = [ - /^packages\/ztd-cli\/src\/commands\//u, - /^packages\/ztd-cli\/src\/index\.ts$/u, - /^packages\/ztd-cli\/README\.md$/u, - /^docs\/guide\/(?:query-uses-impact-checks|query-uses-overview|sql-first-end-to-end-tutorial|sql-tool-happy-paths|ztd-cli-agent-interface)\.md$/u, -]; - -const SCAFFOLD_CONTRACT_PATTERNS = [ - /^packages\/ztd-cli\/src\/commands\/(?:feature|init)\.ts$/u, - /^packages\/ztd-cli\/templates\//u, - /^packages\/ztd-cli\/tests\/(?:featureScaffold|featureTestsScaffold)\.unit\.test\.ts$/u, - /^packages\/ztd-cli\/README\.md$/u, - /^docs\/guide\/(?:generated-project-verification|sql-first-end-to-end-tutorial|ztd-local-source-dogfooding)\.md$/u, -]; +const CLI_SURFACE_PATTERNS = []; + +const SCAFFOLD_CONTRACT_PATTERNS = []; const MERGE_NO_EXCEPTION_LABEL = 'No baseline exception requested.'; const MERGE_EXCEPTION_LABEL = 'Baseline exception requested and linked below.'; diff --git a/scripts/run-ztd-cli-quality-gates.js b/scripts/run-ztd-cli-quality-gates.js deleted file mode 100644 index 5e94f3f57..000000000 --- a/scripts/run-ztd-cli-quality-gates.js +++ /dev/null @@ -1,63 +0,0 @@ -const { execFileSync } = require('node:child_process'); - -function resolveExecutable(command) { - if (process.platform === 'win32') { - return `${command}.cmd`; - } - return command; -} - -function runCommand(label, command, args) { - console.log(`[ztd-cli-gates] ${label}`); - execFileSync(resolveExecutable(command), args, { - shell: process.platform === 'win32', - stdio: 'inherit', - }); -} - -function getStagedFiles() { - const output = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { - encoding: 'utf8', - }); - return output - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean); -} - -function runPackageScript(scriptName) { - runCommand(`Running ${scriptName}`, 'pnpm', ['--filter', '@rawsql-ts/ztd-cli', 'run', scriptName]); -} - -function runEssentialGateSuite() { - runCommand('Running ztd-cli typecheck', 'pnpm', ['--filter', '@rawsql-ts/ztd-cli', 'exec', 'tsc', '--noEmit', '-p', 'tsconfig.json']); - runPackageScript('test:essential'); - runPackageScript('build'); - runPackageScript('lint'); -} - -function main() { - const [, , mode] = process.argv; - - if (mode === 'pre-commit') { - getStagedFiles(); - runEssentialGateSuite(); - return; - } - - if (mode === 'pr') { - runEssentialGateSuite(); - return; - } - - if (mode === 'soft') { - runPackageScript('test:soft'); - return; - } - - throw new Error('Expected one of: pre-commit, pr, soft'); -} - -if (require.main === module) { - main(); -} diff --git a/scripts/verify-generated-project-mode.mjs b/scripts/verify-generated-project-mode.mjs deleted file mode 100644 index fce7e931e..000000000 --- a/scripts/verify-generated-project-mode.mjs +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { - IS_WINDOWS, - PNPM, - ensureCleanDir, - getWorkspacePackages, - run, - runWithOutput, - writeJson, -} from "./publish-workspace-utils.mjs"; - -const workspaceRoot = process.cwd(); -const outputRoot = path.join(workspaceRoot, "tmp", "generated-project-check"); -const generatedProjectRoot = path.join(outputRoot, "starter-app"); -const cliEntryPoint = path.join(workspaceRoot, "packages", "ztd-cli", "dist", "index.js"); -const postgresContainerName = "generated-project-check-postgres"; - -function isPublishTarget(pkg) { - return ( - !pkg.private - && pkg.dir.startsWith(path.join(workspaceRoot, "packages")) - && !pkg.dir.includes(`${path.sep}templates${path.sep}`) - ); -} - -function runIn(directory, command, args, options = {}) { - return runWithOutput(command, args, { - cwd: directory, - shell: options.shell ?? IS_WINDOWS, - ...options, - }); -} - -function readPackageJson(directory) { - return JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8")); -} - -function writePackageJson(directory, packageJson) { - fs.writeFileSync(path.join(directory, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); -} - -function toLocalFileDependency(rootDir, targetDir) { - const relativeTarget = path.relative(rootDir, targetDir).replace(/\\/gu, "/"); - const withDotPrefix = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; - return `file:${withDotPrefix}`; -} - -function buildLocalSourceOverrides(rootDir) { - const packages = [...getWorkspacePackages(workspaceRoot).values()].filter(isPublishTarget); - return Object.fromEntries( - packages.map((pkg) => [pkg.name, toLocalFileDependency(rootDir, pkg.dir)]), - ); -} - -function applyLocalSourceOverrides(appDir) { - const packageJson = readPackageJson(appDir); - packageJson.pnpm = { - ...(packageJson.pnpm ?? {}), - overrides: buildLocalSourceOverrides(appDir), - }; - writePackageJson(appDir, packageJson); -} - -function assertExists(filePath, description) { - if (!fs.existsSync(filePath)) { - throw new Error(`[generated-project verification] missing ${description}: ${filePath}`); - } -} - -function assertFileContains(filePath, needle, description) { - const contents = fs.readFileSync(filePath, "utf8"); - if (!contents.includes(needle)) { - throw new Error(`[generated-project verification] ${description} missing expected text ${needle}: ${filePath}`); - } -} - -function verifyStarterScaffold(appDir) { - const packageJson = readPackageJson(appDir); - const ztdCliDependency = packageJson.devDependencies?.["@rawsql-ts/ztd-cli"]; - if (!ztdCliDependency) { - throw new Error("[generated-project verification] starter scaffold did not install @rawsql-ts/ztd-cli."); - } - if (!ztdCliDependency.startsWith("file:")) { - throw new Error(`[generated-project verification] starter scaffold resolved @rawsql-ts/ztd-cli from a non-local source: ${ztdCliDependency}`); - } - if (packageJson.type !== "module") { - throw new Error(`[generated-project verification] starter scaffold package.json must set type=module, received: ${String(packageJson.type)}`); - } - const requiredImports = { - "#features/*.js": { - types: "./src/features/*.ts", - default: "./dist/features/*.js", - }, - "#libraries/*.js": { - types: "./src/libraries/*.ts", - default: "./dist/libraries/*.js", - }, - "#adapters/*.js": { - types: "./src/adapters/*.ts", - default: "./dist/adapters/*.js", - }, - "#tests/*.js": { - types: "./tests/*.ts", - default: "./tests/*.ts", - }, - }; - for (const [key, value] of Object.entries(requiredImports)) { - if (JSON.stringify(packageJson.imports?.[key]) !== JSON.stringify(value)) { - throw new Error(`[generated-project verification] starter scaffold package.json is missing ${key}: ${JSON.stringify(packageJson.imports?.[key])}`); - } - } - assertExists(path.join(appDir, "README.md"), "starter README"); - assertExists(path.join(appDir, "src", "features", "smoke", "tests", "smoke.boundary.test.ts"), "starter smoke boundary test"); - assertExists( - path.join(appDir, "src", "features", "smoke", "queries", "smoke", "tests", "smoke.boundary.ztd.test.ts"), - "starter DB-backed smoke test" - ); - assertFileContains(path.join(appDir, "tsconfig.json"), "\"#libraries/*\"", "starter tsconfig"); - assertFileContains(path.join(appDir, "tsconfig.json"), "\"#adapters/*\"", "starter tsconfig"); - assertFileContains(path.join(appDir, "vitest.config.ts"), "'#libraries'", "starter vitest config"); - assertFileContains(path.join(appDir, "vitest.config.ts"), "'#adapters'", "starter vitest config"); - assertFileContains( - path.join(appDir, "src", "adapters", "pg", "sql-client.ts"), - "from '#libraries/sql/sql-client.js'", - "starter pg adapter import" - ); - assertFileContains( - path.join(appDir, "src", "adapters", "console", "repositoryTelemetry.ts"), - "from '#libraries/telemetry/types.js'", - "starter console adapter import" - ); -} - -function verifyFeatureScaffold(appDir) { - const featureRoot = path.join(appDir, "src", "features", "users-insert"); - assertExists(path.join(featureRoot, "boundary.ts"), "feature root boundary"); - assertExists( - path.join(featureRoot, "tests", "users-insert.boundary.test.ts"), - "feature boundary test" - ); - assertExists(path.join(featureRoot, "queries", "insert-users", "boundary.ts"), "feature query boundary"); - assertExists( - path.join(featureRoot, "queries", "insert-users", "insert-users.sql"), - "feature SQL resource" - ); -} - -function writeProjectEnvFile(appDir, dbPort) { - const envExamplePath = path.join(appDir, ".env.example"); - const envPath = path.join(appDir, ".env"); - const envContents = fs.readFileSync(envExamplePath, "utf8").replace( - /ZTD_DB_PORT=\d+/u, - `ZTD_DB_PORT=${dbPort}` - ); - fs.writeFileSync(envPath, envContents, "utf8"); -} - -function sleep(ms) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function waitForPostgres(appDir) { - const maxAttempts = 30; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - runIn(appDir, "docker", ["exec", postgresContainerName, "pg_isready", "-U", "ztd", "-d", "ztd"]); - return; - } catch (error) { - if (attempt === maxAttempts) { - throw error; - } - await sleep(2000); - } - } -} - -function applyStarterSchema(appDir) { - const schemaPath = path.join(appDir, "db", "ddl", "public.sql"); - runIn(appDir, "docker", [ - "cp", - schemaPath, - `${postgresContainerName}:/tmp/public.sql`, - ]); - runIn(appDir, "docker", [ - "exec", - postgresContainerName, - "psql", - "-v", - "ON_ERROR_STOP=1", - "-U", - "ztd", - "-d", - "ztd", - "-f", - "/tmp/public.sql", - ]); -} - -async function main() { - ensureCleanDir(outputRoot); - ensureCleanDir(generatedProjectRoot); - - // Build the workspace first so the generated project can link against the local source checkout. - run(PNPM, ["build:publish"]); - - let dockerStarted = false; - - try { - runIn(generatedProjectRoot, process.execPath, [ - cliEntryPoint, - "init", - "--starter", - "--yes", - "--skip-install", - "--local-source-root", - workspaceRoot, - ], { - shell: false, - }); - - applyLocalSourceOverrides(generatedProjectRoot); - runIn(generatedProjectRoot, PNPM, ["install", "--ignore-workspace", "--no-frozen-lockfile"]); - - verifyStarterScaffold(generatedProjectRoot); - - runIn(generatedProjectRoot, process.execPath, [ - path.join(generatedProjectRoot, "scripts", "local-source-guard.mjs"), - "ztd", - "feature", - "scaffold", - "--table", - "users", - "--action", - "insert", - ], { - shell: false, - }); - verifyFeatureScaffold(generatedProjectRoot); - - runIn(generatedProjectRoot, "docker", [ - "run", - "-d", - "--rm", - "--name", - postgresContainerName, - "-P", - "-e", - "POSTGRES_DB=ztd", - "-e", - "POSTGRES_PASSWORD=ztd", - "-e", - "POSTGRES_USER=ztd", - "postgres:18", - ]); - dockerStarted = true; - - const portOutput = runIn(generatedProjectRoot, "docker", [ - "port", - postgresContainerName, - "5432/tcp", - ]).stdout.trim(); - const match = portOutput.match(/:(\d+)(?:\r?\n)?$/u); - if (!match) { - throw new Error(`[generated-project verification] could not determine the mapped Postgres port from: ${portOutput}`); - } - const starterDbPort = Number(match[1]); - if (!Number.isInteger(starterDbPort)) { - throw new Error(`[generated-project verification] invalid mapped Postgres port: ${match[1]}`); - } - - writeProjectEnvFile(generatedProjectRoot, starterDbPort); - await waitForPostgres(generatedProjectRoot); - runIn(generatedProjectRoot, PNPM, [ - "vitest", - "run", - "src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts", - ]); - applyStarterSchema(generatedProjectRoot); - runIn(generatedProjectRoot, PNPM, ["exec", "--", "ztd", "ztd-config"]); - runIn(generatedProjectRoot, PNPM, ["test"]); - - writeJson(path.join(outputRoot, "summary.json"), { - checkedAt: new Date().toISOString(), - node: process.version, - platform: os.platform(), - generatedProjectRoot, - dbPort: starterDbPort, - commands: [ - "build:publish", - "ztd init --starter --yes --skip-install --local-source-root ", - "write pnpm.overrides for local rawsql-ts workspace packages", - "pnpm install --ignore-workspace --no-frozen-lockfile", - "node scripts/local-source-guard.mjs ztd feature scaffold --table users --action insert", - "docker run -d --rm --name generated-project-check-postgres -P postgres:18", - "docker port generated-project-check-postgres 5432/tcp", - "write .env with the mapped Postgres port", - "pnpm vitest run src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts", - "docker cp db/ddl/public.sql and apply it to the starter Postgres container", - "ztd ztd-config", - "pnpm test", - ], - files: [ - "README.md", - "src/features/smoke/tests/smoke.boundary.test.ts", - "src/features/smoke/queries/smoke/tests/smoke.boundary.ztd.test.ts", - "src/features/users-insert/tests/users-insert.boundary.test.ts", - "src/features/users-insert/queries/insert-users/boundary.ts", - "src/features/users-insert/queries/insert-users/insert-users.sql", - ], - }); - } finally { - if (dockerStarted) { - try { - runIn(generatedProjectRoot, "docker", ["rm", "-f", postgresContainerName]); - } catch { - // Keep the main failure visible; cleanup is best-effort only. - } - } - } -} - -await main(); diff --git a/scripts/verify-published-package-mode.mjs b/scripts/verify-published-package-mode.mjs index 16a2c7d6d..f1e03df9e 100644 --- a/scripts/verify-published-package-mode.mjs +++ b/scripts/verify-published-package-mode.mjs @@ -19,27 +19,6 @@ const tarCommand = "tar"; const outputRoot = path.join(workspaceRoot, "tmp", "published-package-check"); const tarballRoot = path.join(outputRoot, "tarballs"); const packageRoot = path.join(outputRoot, "packages"); -const standalonePackageRoot = path.join(workspaceRoot, "tmp", "published-package-check-standalone"); - -function ensureStandaloneWorkspaceRoot() { - fs.mkdirSync(standalonePackageRoot, { recursive: true }); - fs.writeFileSync( - path.join(standalonePackageRoot, "pnpm-workspace.yaml"), - "packages:\n - '*'\n", - "utf8", - ); -} - -function syncStandaloneWorkspacePackageJson(overrides) { - writePackageJson(standalonePackageRoot, { - name: "published-package-check-standalone", - private: true, - version: "0.0.0", - pnpm: { - overrides, - }, - }); -} function parseArgs(argv) { const options = { @@ -219,14 +198,6 @@ function runIn(directory, command, args, options = {}) { }); } -function getInstalledBinPath(directory, binName) { - return path.join(directory, "node_modules", ".bin", IS_WINDOWS ? `${binName}.cmd` : binName); -} - -function runInstalledZtdCli(directory, args) { - return runIn(directory, getInstalledBinPath(directory, "ztd"), args); -} - function createTarballDependencyMap(packages) { return Object.fromEntries( packages.map((pkg) => [pkg.name, toFileSpecifier(pkg.tarballPath)]), @@ -237,155 +208,6 @@ function hasTarballDependency(tarballDependencies, packageName) { return typeof tarballDependencies[packageName] === "string"; } -function createPublishedDependencyRangeMap(packages) { - const dependencyRanges = new Map(); - - for (const pkg of packages) { - for (const sectionName of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"]) { - const section = pkg.manifest[sectionName]; - if (!section || typeof section !== "object" || Array.isArray(section)) { - continue; - } - - for (const [dependencyName, dependencyRange] of Object.entries(section)) { - if ( - typeof dependencyRange === "string" - && (dependencyName === "rawsql-ts" || dependencyName.startsWith("@rawsql-ts/")) - ) { - dependencyRanges.set(dependencyName, dependencyRange); - } - } - } - } - - return dependencyRanges; -} - -function setPackageTypeModule(directory) { - const packageJsonPath = path.join(directory, "package.json"); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - packageJson.type = "module"; - fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); -} - -function readPackageJson(directory) { - return JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8")); -} - -function restorePublishedDependencyRanges(directory, packages) { - const packageJsonPath = path.join(directory, "package.json"); - const packageJson = readPackageJson(directory); - const tarballDependencies = createTarballDependencyMap(packages); - const publishedDependencyRanges = createPublishedDependencyRangeMap(packages); - let changed = false; - - for (const sectionName of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"]) { - const section = packageJson[sectionName]; - if (!section || typeof section !== "object" || Array.isArray(section)) { - continue; - } - - for (const [dependencyName, currentRange] of Object.entries(section)) { - if (typeof tarballDependencies[dependencyName] === "string") { - if (section[dependencyName] !== tarballDependencies[dependencyName]) { - section[dependencyName] = tarballDependencies[dependencyName]; - changed = true; - } - continue; - } - - if ( - typeof currentRange === "string" - && currentRange.startsWith("file:") - && publishedDependencyRanges.has(dependencyName) - ) { - section[dependencyName] = publishedDependencyRanges.get(dependencyName); - changed = true; - } - } - } - - fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); - - if (changed) { - fs.rmSync(path.join(directory, "package-lock.json"), { force: true }); - fs.rmSync(path.join(directory, "node_modules"), { force: true, recursive: true }); - } -} - -function writeNode16Tsconfig(directory) { - const tsconfigPath = path.join(directory, "tsconfig.node16.json"); - writeJson(tsconfigPath, { - extends: "./tsconfig.json", - compilerOptions: { - module: "Node16", - moduleResolution: "Node16", - noEmit: true, - }, - }); -} - -function assertIncludes(haystack, needle, label) { - if (!haystack.includes(needle)) { - throw new Error(`[${label}] Expected output to include: ${needle}`); - } -} - -function assertExcludes(haystack, needle, label) { - if (haystack.includes(needle)) { - throw new Error(`[${label}] Unexpected output included: ${needle}`); - } -} - -function readJsonFile(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function runInitDryRunPlan(directory, args) { - const result = runInstalledZtdCli(directory, ["init", "--dry-run", "--yes", ...args]); - const stdout = String(result.stdout ?? ""); - const start = stdout.indexOf("{"); - const end = stdout.lastIndexOf("}"); - if (start < 0 || end < start) { - throw new Error(`[runInitDryRunPlan] Could not locate JSON plan in stdout:\n${stdout}`); - } - return JSON.parse(stdout.slice(start, end + 1)); -} - -function assertScaffoldPlanMetadata(plan, expected, label) { - for (const [key, value] of Object.entries(expected)) { - if (plan[key] !== value) { - throw new Error( - `[${label}] Expected init dry-run plan ${key}=${JSON.stringify(value)}, received ${JSON.stringify(plan[key])}.`, - ); - } - } -} - -function assertScaffoldPlanFilesExist(appDir, plan, label) { - for (const relativePath of plan.files ?? []) { - const absolutePath = path.join(appDir, relativePath); - if (!fs.existsSync(absolutePath)) { - throw new Error(`[${label}] Planned scaffold file was not created: ${relativePath}`); - } - } -} - -function assertFileMissing(appDir, relativePath, label) { - if (fs.existsSync(path.join(appDir, relativePath))) { - throw new Error(`[${label}] Unexpected scaffold file was created: ${relativePath}`); - } -} - -function assertPackageScript(packageJson, scriptName, expectedCommand, label) { - const actualCommand = packageJson.scripts?.[scriptName]; - if (actualCommand !== expectedCommand) { - throw new Error( - `[${label}] Expected package.json scripts.${scriptName}=${JSON.stringify(expectedCommand)}, received ${JSON.stringify(actualCommand)}.`, - ); - } -} - function packPublishedPackages() { const workspacePackages = Array.from(getWorkspacePackages(workspaceRoot).values()) .filter(isPublishTarget) @@ -464,10 +286,6 @@ function verifyPackedTarballInstall(packages) { runIn(appDir, NPM, ["install"]); - if (hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) { - runIn(appDir, NPM, ["exec", "--", "ztd", "--help"]); - } - const smokeImportTargets = [ "@rawsql-ts/testkit-core", ].filter((packageName) => hasTarballDependency(tarballDependencies, packageName)); @@ -534,355 +352,6 @@ function verifyCoreGettingStarted(packages) { return appDir; } -function verifyNpmPrimaryPath(packages) { - const appDir = path.join(packageRoot, "npm-primary-path"); - ensureCleanDir(appDir); - const tarballDependencies = createTarballDependencyMap(packages); - - if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) { - return null; - } - - writePackageJson(appDir, { - name: "npm-consumer-smoke", - private: true, - version: "0.0.0", - devDependencies: tarballDependencies, - }); - - runIn(appDir, NPM, ["install"]); - const dryRunPlan = runInitDryRunPlan(appDir, ["--workflow", "demo"]); - assertScaffoldPlanMetadata(dryRunPlan, { - dryRun: true, - schemaVersion: 1, - workflow: "demo", - validator: "none", - starter: false, - }, "phase-a init-dry-run"); - const initResult = runInstalledZtdCli(appDir, ["init", "--yes", "--workflow", "demo"]); - - assertIncludes(initResult.stdout, "ztd-config", "phase-a packaging-npm-primary-path-gate"); - assertIncludes(initResult.stdout, "model-gen", "phase-a packaging-npm-primary-path-gate"); - assertIncludes(initResult.stdout, "npm run test", "phase-a packaging-npm-primary-path-gate"); - assertExcludes(initResult.stdout, "pnpm exec ztd", "phase-a packaging-npm-primary-path-gate"); - assertExcludes(initResult.stdout, "pnpm test", "phase-a packaging-npm-primary-path-gate"); - assertScaffoldPlanFilesExist(appDir, dryRunPlan, "phase-a scaffold-files"); - - const scaffoldPackageJson = readJsonFile(path.join(appDir, "package.json")); - assertPackageScript(scaffoldPackageJson, "test", "node ./scripts/local-source-guard.mjs test --passWithNoTests", "phase-a package-scripts"); - assertPackageScript(scaffoldPackageJson, "typecheck", "node ./scripts/local-source-guard.mjs typecheck", "phase-a package-scripts"); - assertPackageScript(scaffoldPackageJson, "ztd", "node ./scripts/local-source-guard.mjs ztd", "phase-a package-scripts"); - - assertFileMissing(appDir, "compose.yaml", "phase-a default-scaffold-shape"); - assertFileMissing(appDir, "PROMPT_DOGFOOD.md", "phase-a default-scaffold-shape"); - assertFileMissing(appDir, "CONTEXT.md", "phase-a default-scaffold-shape"); - assertFileMissing(appDir, path.join("src", "features", "smoke", "queries", "smoke", "tests", "smoke.boundary.ztd.test.ts"), "phase-a default-scaffold-shape"); - assertFileMissing(appDir, path.join(".ztd", "agents", "manifest.json"), "phase-a default-scaffold-shape"); - - restorePublishedDependencyRanges(appDir, packages); - runIn(appDir, NPM, ["install"]); - - return { appDir }; -} - -function verifyNpmConsumerSmoke(phaseAResult) { - if (phaseAResult == null) { - return null; - } - - const { appDir } = phaseAResult; - - // Phase B proves the first generated smoke test can pass on the npm-first consumer path. - runInstalledZtdCli(appDir, ["ztd-config"]); - - // Keep the published command surface aligned with Further Reading docs by - // exercising the opt-in join-direction flag from the packed CLI artifact. - fs.mkdirSync(path.join(appDir, "tmp"), { recursive: true }); - fs.writeFileSync(path.join(appDir, "tmp", "join-direction-smoke.sql"), "select 1;\n", "utf8"); - const lintHelp = runInstalledZtdCli(appDir, ["query", "lint", "--help"]); - assertIncludes(lintHelp.stdout, "--rules ", "phase-b query-lint-help-surface"); - runInstalledZtdCli(appDir, [ - "query", - "lint", - "--rules", - "join-direction", - "tmp/join-direction-smoke.sql", - ]); - - setPackageTypeModule(appDir); - writeNode16Tsconfig(appDir); - - runIn(appDir, NPM, ["test"]); - runIn(appDir, NPM, ["exec", "--", "tsc", "--noEmit", "-p", "tsconfig.json"]); - runIn(appDir, NPM, ["exec", "--", "tsc", "-p", "tsconfig.node16.json"]); - - return appDir; -} - -function verifyPnpmStarterPath(packages) { - ensureStandaloneWorkspaceRoot(); - const appDir = path.join(standalonePackageRoot, "pnpm-starter-path"); - ensureCleanDir(appDir); - - const tarballDependencies = createTarballDependencyMap(packages); - if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) { - return null; - } - - syncStandaloneWorkspacePackageJson(tarballDependencies); - writePackageJson(appDir, { - name: "pnpm-starter-path-check", - private: true, - version: "0.0.0", - devDependencies: { - "@rawsql-ts/ztd-cli": tarballDependencies["@rawsql-ts/ztd-cli"], - }, - }); - - runIn(appDir, PNPM, ["install", "--no-frozen-lockfile"]); - const dryRunPlan = runInitDryRunPlan(appDir, [ - "--starter", - "--workflow", - "demo", - ]); - assertScaffoldPlanMetadata(dryRunPlan, { - dryRun: true, - schemaVersion: 1, - workflow: "demo", - validator: "none", - starter: true, - }, "phase-c init-dry-run"); - runInstalledZtdCli(appDir, [ - "init", - "--starter", - "--yes", - "--skip-install", - ]); - assertScaffoldPlanFilesExist(appDir, dryRunPlan, "phase-c starter-scaffold-files"); - - const scaffoldPackageJson = readPackageJson(appDir); - scaffoldPackageJson.devDependencies = Object.fromEntries( - Object.entries(scaffoldPackageJson.devDependencies ?? {}).map(([dependencyName, version]) => [ - dependencyName, - tarballDependencies[dependencyName] ?? version, - ]), - ); - assertNoWorkspaceProtocols(scaffoldPackageJson, "starter-scaffold"); - - // Rebind workspace packages to the freshly packed tarballs so the scaffold install exercises the published manifests. - fs.writeFileSync(path.join(appDir, "package.json"), `${JSON.stringify(scaffoldPackageJson, null, 2)}\n`, "utf8"); - runIn(appDir, PNPM, ["install", "--no-frozen-lockfile"]); - runIn(appDir, PNPM, ["exec", "tsc", "--noEmit", "-p", "tsconfig.json"]); - runIn(appDir, PNPM, [ - "exec", - "vitest", - "run", - "src/features/smoke/tests/smoke.boundary.test.ts", - "src/features/smoke/tests/smoke.validation.test.ts", - ]); - runInstalledZtdCli(appDir, ["ztd-config"]); - - return appDir; -} - -function verifyPnpmAdapterInstall(packages) { - ensureStandaloneWorkspaceRoot(); - const appDir = path.join(standalonePackageRoot, "pnpm-adapter-path"); - ensureCleanDir(appDir); - - const tarballDependencies = createTarballDependencyMap(packages); - if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) { - return null; - } - - syncStandaloneWorkspacePackageJson(tarballDependencies); - writePackageJson(appDir, { - name: "pnpm-adapter-path-check", - private: true, - version: "0.0.0", - devDependencies: { - "@rawsql-ts/ztd-cli": tarballDependencies["@rawsql-ts/ztd-cli"], - }, - }); - - runIn(appDir, PNPM, ["install", "--no-frozen-lockfile"]); - runInstalledZtdCli(appDir, [ - "init", - "--starter", - "--yes", - "--skip-install", - ]); - - const scaffoldPackageJson = readPackageJson(appDir); - scaffoldPackageJson.devDependencies = { - ...(scaffoldPackageJson.devDependencies ?? {}), - "@rawsql-ts/adapter-node-pg": tarballDependencies["@rawsql-ts/adapter-node-pg"], - }; - assertNoWorkspaceProtocols(scaffoldPackageJson, "adapter-scaffold"); - - // Rebind the generated scaffold to packed tarballs so the adapter install exercises the published manifests. - fs.writeFileSync(path.join(appDir, "package.json"), `${JSON.stringify(scaffoldPackageJson, null, 2)}\n`, "utf8"); - runIn(appDir, PNPM, ["install", "--no-frozen-lockfile"]); - - return appDir; -} - -function verifyPnpmTutorialModelGen(packages) { - ensureStandaloneWorkspaceRoot(); - const appDir = path.join(standalonePackageRoot, "pnpm-tutorial-model-gen"); - ensureCleanDir(appDir); - - const tarballDependencies = createTarballDependencyMap(packages); - if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) { - return null; - } - - syncStandaloneWorkspacePackageJson(tarballDependencies); - writePackageJson(appDir, { - name: "pnpm-tutorial-model-gen-check", - private: true, - version: "0.0.0", - devDependencies: { - "@rawsql-ts/ztd-cli": tarballDependencies["@rawsql-ts/ztd-cli"], - }, - }); - - runIn(appDir, PNPM, ["install", "--no-frozen-lockfile"]); - runInstalledZtdCli(appDir, [ - "init", - "--starter", - "--yes", - "--skip-install", - ]); - - const scaffoldPackageJson = readPackageJson(appDir); - scaffoldPackageJson.devDependencies = { - ...(scaffoldPackageJson.devDependencies ?? {}), - "@rawsql-ts/adapter-node-pg": tarballDependencies["@rawsql-ts/adapter-node-pg"], - }; - assertNoWorkspaceProtocols(scaffoldPackageJson, "tutorial-model-gen-scaffold"); - - // Rebind the generated scaffold to packed tarballs so the tutorial path exercises the published manifests. - fs.writeFileSync(path.join(appDir, "package.json"), `${JSON.stringify(scaffoldPackageJson, null, 2)}\n`, "utf8"); - runIn(appDir, PNPM, ["install", "--no-frozen-lockfile"]); - - const usersSqlPath = path.join(appDir, "src", "features", "users", "persistence", "users.sql"); - fs.mkdirSync(path.dirname(usersSqlPath), { recursive: true }); - fs.writeFileSync( - usersSqlPath, - [ - "select", - " user_id,", - " email", - "from users", - "where user_id = :user_id", - "", - ].join("\n"), - "utf8", - ); - - runInstalledZtdCli(appDir, ["ztd-config"]); - - const modelGenArgs = [ - "model-gen", - "--probe-mode", - "ztd", - "--sql-root", - "src/features/users/persistence", - "src/features/users/persistence/users.sql", - "--out", - "src/features/users/persistence/users.spec.ts", - ]; - - if (process.env.ZTD_TEST_DATABASE_URL) { - try { - runInstalledZtdCli(appDir, modelGenArgs); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `[packaging contract] tutorial model-gen failed after starter scaffold setup. ` + - `Re-run "pnpm exec ztd ztd-config" and then retry pnpm exec ztd ${modelGenArgs.join(" ")}. ` + - `If Postgres just started, wait for readiness before rerunning. Original error: ${message}`, - ); - } - const generatedSpec = fs.readFileSync( - path.join(appDir, "src", "features", "users", "persistence", "users.spec.ts"), - "utf8", - ); - if (!generatedSpec.includes("export interface")) { - throw new Error( - "[packaging contract] tutorial model-gen did not write the expected spec output. " + - "Re-run pnpm exec ztd ztd-config, then rerun model-gen for src/features/users/persistence/users.sql.", - ); - } - } else { - const describeOutput = runInstalledZtdCli(appDir, [...modelGenArgs, "--describe-output", "--output", "json"]); - - const parsed = JSON.parse(describeOutput.stdout); - if (parsed?.data?.outputs?.spec !== "TypeScript QuerySpec scaffold") { - throw new Error( - `[packaging contract] tutorial model-gen describe output was unexpected. ` + - `Re-run pnpm exec ztd ztd-config, then inspect the packed CLI model-gen output contract. ` + - `Received: ${JSON.stringify(parsed)}`, - ); - } - } - - return appDir; -} - -function verifyOverwriteSafety(packages) { - const appDir = path.join(packageRoot, "overwrite-safety"); - ensureCleanDir(appDir); - const tarballDependencies = createTarballDependencyMap(packages); - - if (!hasTarballDependency(tarballDependencies, "@rawsql-ts/ztd-cli")) { - return null; - } - - writePackageJson(appDir, { - name: "overwrite-safety-check", - private: true, - version: "0.0.0", - devDependencies: tarballDependencies, - }); - - fs.mkdirSync(path.join(appDir, "db", "ddl"), { recursive: true }); - fs.writeFileSync(path.join(appDir, "db", "ddl", "public.sql"), "-- existing\n", "utf8"); - - runIn(appDir, NPM, ["install"]); - const ddlPath = path.join(appDir, "db", "ddl", "public.sql"); - const originalSchema = fs.readFileSync(ddlPath, "utf8"); - - let overwriteFailed = false; - try { - runInstalledZtdCli(appDir, ["init", "--yes", "--workflow", "demo"]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("--force") && !message.includes("already exists")) { - throw error; - } - overwriteFailed = true; - } - - if (!overwriteFailed) { - throw new Error("[scaffold safety] ztd init overwrote an existing DDL file without requiring --force."); - } - const afterFailedInit = fs.readFileSync(ddlPath, "utf8"); - if (afterFailedInit !== originalSchema) { - throw new Error("[scaffold safety] DDL changed even though init without --force failed."); - } - - runInstalledZtdCli(appDir, ["init", "--yes", "--force", "--workflow", "demo"]); - - const schemaContents = fs.readFileSync(ddlPath, "utf8"); - if (schemaContents === originalSchema) { - throw new Error("[scaffold safety] --force did not overwrite the existing DDL file as expected."); - } - - return appDir; -} - function main() { const options = parseArgs(process.argv.slice(2)); ensureCleanDir(outputRoot); @@ -897,12 +366,6 @@ function main() { })(); const packedInstallApp = verifyPackedTarballInstall(packedPackages); const coreGettingStartedApp = verifyCoreGettingStarted(packedPackages); - const npmPrimaryPathApp = verifyNpmPrimaryPath(packedPackages); - const npmSmokeApp = verifyNpmConsumerSmoke(npmPrimaryPathApp); - const pnpmStarterApp = verifyPnpmStarterPath(packedPackages); - const pnpmAdapterApp = verifyPnpmAdapterInstall(packedPackages); - const pnpmTutorialModelGenApp = verifyPnpmTutorialModelGen(packedPackages); - const overwriteSafetyApp = verifyOverwriteSafety(packedPackages); writeJson(path.join(outputRoot, "summary.json"), { checkedAt: new Date().toISOString(), @@ -911,13 +374,6 @@ function main() { verificationMode: options.publishManifestProvided ? "publish-manifest" : "workspace-pack", packedInstallApp, coreGettingStartedApp, - npmPrimaryPathApp: npmPrimaryPathApp?.appDir ?? null, - firstTestGateApp: npmSmokeApp, - npmSmokeApp, - pnpmStarterApp, - pnpmAdapterApp, - pnpmTutorialModelGenApp, - overwriteSafetyApp, packages: packedPackages.map((pkg) => ({ name: pkg.name, version: pkg.manifest.version, diff --git a/tsconfig.json b/tsconfig.json index 7234d2a50..bd5eecf55 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,7 +41,6 @@ "@rawsql-ts/test-evidence-renderer-md/*": ["packages/test-evidence-renderer-md/src/*"], "@rawsql-ts/sql-grep-core": ["packages/sql-grep-core/src"], "@rawsql-ts/sql-grep-core/*": ["packages/sql-grep-core/src/*"], - "@rawsql-ts/ztd-cli": ["packages/ztd-cli/src"], "@rawsql-ts/testkit-sqlite": ["packages/testkit-sqlite/src"], "@rawsql-ts/adapter-node-pg": ["packages/adapters/adapter-node-pg/src"], "@rawsql-ts/driver-adapter-core": ["packages/drivers/driver-adapter-core/src"],