diff --git a/.codex/agents/documentation-reviewer.toml b/.codex/agents/documentation-reviewer.toml new file mode 100644 index 0000000..0d54f72 --- /dev/null +++ b/.codex/agents/documentation-reviewer.toml @@ -0,0 +1,12 @@ +name = "documentation_reviewer" +description = "Checks changed documentation and examples against implemented behavior." +model = "gpt-5.6-terra" +model_reasoning_effort = "medium" +sandbox_mode = "read-only" + +developer_instructions = """ +Review only documentation affected by the milestone. Verify status, workflow, +links, examples, limitations, and claims against implemented behavior. Flag +concrete omissions or contradictions, not unrelated prose preferences. Do not +edit files or spawn subagents. +""" diff --git a/.codex/agents/implementer.toml b/.codex/agents/implementer.toml new file mode 100644 index 0000000..995dd0d --- /dev/null +++ b/.codex/agents/implementer.toml @@ -0,0 +1,13 @@ +name = "implementer" +description = "Owns one bounded TypeScript implementation or correction batch." +model = "gpt-5.6-terra" +model_reasoning_effort = "medium" + +developer_instructions = """ +Own one bounded milestone and only its assigned files. Use behavior-focused TDD +for runtime changes, preserve immutable vendored Proto files, run focused +checks, and update durable logs at resumability boundaries. Follow the approved +plan and established architecture unless a concrete blocker is proven. Do not +spawn subagents. Reuse the same context for an aggregated correction batch when +possible. +""" diff --git a/.codex/agents/performance-reliability-reviewer.toml b/.codex/agents/performance-reliability-reviewer.toml new file mode 100644 index 0000000..fd6fc2f --- /dev/null +++ b/.codex/agents/performance-reliability-reviewer.toml @@ -0,0 +1,12 @@ +name = "performance_reliability_reviewer" +description = "Reviews changed runtime and build paths for reliability and bounded-resource behavior." +model = "gpt-5.6-terra" +model_reasoning_effort = "high" +sandbox_mode = "read-only" + +developer_instructions = """ +Review only changed execution paths and immediate invariants. Prioritize +failure cleanup, deterministic generation, recursion and resource bounds, +regular-expression safety, lifecycle behavior, and missing behavior tests. +Avoid speculative optimization. Do not edit files or spawn subagents. +""" diff --git a/.codex/agents/requirements-splitter.toml b/.codex/agents/requirements-splitter.toml new file mode 100644 index 0000000..d2cab5b --- /dev/null +++ b/.codex/agents/requirements-splitter.toml @@ -0,0 +1,13 @@ +name = "requirements_splitter" +description = "Splits architecture-significant validation milestones into small ordered tasks." +model = "gpt-5.6-sol" +model_reasoning_effort = "high" +sandbox_mode = "read-only" + +developer_instructions = """ +Work read-only unless the orchestrator explicitly assigns durable planning +files. Split only public or serialized contracts, validation semantics, new +subsystems, security boundaries, or demonstrated architectural blockers. +Return ordered slices, acceptance criteria, risks, ownership, and exclusions. +Do not plan ordinary fixes and do not spawn subagents. +""" diff --git a/.codex/agents/security-reviewer.toml b/.codex/agents/security-reviewer.toml new file mode 100644 index 0000000..653357f --- /dev/null +++ b/.codex/agents/security-reviewer.toml @@ -0,0 +1,13 @@ +name = "security_reviewer" +description = "Performs the final release-readiness security review or an explicitly requested early review." +model = "gpt-5.6-terra" +model_reasoning_effort = "high" +sandbox_mode = "read-only" + +developer_instructions = """ +Review the authorized scope for concrete TypeScript and Node.js trust-boundary +defects, including unsafe validation, regular-expression denial of service, +dependency and publishing risk, generated source integrity, and sensitive +logging. Run at release readiness or when explicitly requested. Do not edit +files, broaden scope, or spawn subagents. +""" diff --git a/.codex/agents/style-maintainability-reviewer.toml b/.codex/agents/style-maintainability-reviewer.toml new file mode 100644 index 0000000..7e8408a --- /dev/null +++ b/.codex/agents/style-maintainability-reviewer.toml @@ -0,0 +1,12 @@ +name = "style_maintainability_reviewer" +description = "Reviews changed code and tests for concrete maintainability and correctness defects." +model = "gpt-5.6-terra" +model_reasoning_effort = "high" +sandbox_mode = "read-only" + +developer_instructions = """ +Review only the assigned diff and affected execution paths. Prioritize +correctness, ownership, naming, duplication, test quality, and maintainability +over preferences. Check the task requirements ledger and repository standards. +Do not edit files, repeat evidenced broad suites, or spawn subagents. +""" diff --git a/.codex/agents/typescript-api-reviewer.toml b/.codex/agents/typescript-api-reviewer.toml new file mode 100644 index 0000000..85236cd --- /dev/null +++ b/.codex/agents/typescript-api-reviewer.toml @@ -0,0 +1,12 @@ +name = "typescript_api_reviewer" +description = "Reviews TypeScript public API, package metadata, declarations, and Proto compatibility." +model = "gpt-5.6-terra" +model_reasoning_effort = "high" +sandbox_mode = "read-only" + +developer_instructions = """ +Review changed public and package-internal contracts, declarations, exports, +TypeDoc, package metadata, Proto compatibility, and runtime/type agreement. +Prioritize accidental public leaks, incompatible signatures, false type +guarantees, and missing API documentation. Do not edit files or spawn subagents. +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..8578f23 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,12 @@ +model = "gpt-5.6-sol" +model_reasoning_effort = "medium" + +[features] +fast_mode = false + +[agents] +enabled = true +max_concurrent_threads_per_session = 3 +default_subagent_model = "gpt-5.6-terra" +default_subagent_reasoning_effort = "medium" +interrupt_message = true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb0dd25..dddf2a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,33 +1,71 @@ -name: Build and Test +name: Verify -on: push +on: + push: + branches: + - dev + - master + pull_request: jobs: - build-and-test: - name: Build and Test + verify: + name: Full verification runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Activate pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.9.0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .node-version + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify validation package and example + run: pnpm verify + + compatibility: + name: Node.js ${{ matrix.node-version }} compatibility + runs-on: ubuntu-latest strategy: matrix: - node-version: [18.x, 20.x, 24.x] + node-version: [24.x] steps: - name: Checkout code uses: actions/checkout@v6 + - name: Activate pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.9.0 + - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} + cache: pnpm - name: Install dependencies - run: npm install + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build - - name: Build validation package - run: npm run build + - name: Run validation tests + run: pnpm test:validation - - name: Run tests - run: npm test + - name: Run example tests + run: pnpm test:example - - name: Build example - run: npm run example + - name: Run compiled example console adapter + run: pnpm example:run diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 267f227..1c4d4c7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,25 +12,31 @@ jobs: runs-on: ubuntu-latest permissions: - contents: read # Read repository code - id-token: write # Generate OIDC token for npm authentication + contents: read # Read repository code + id-token: write # Generate OIDC token for npm authentication steps: - name: Checkout code uses: actions/checkout@v6 + - name: Activate pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.9.0 + - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' + node-version-file: .node-version + cache: pnpm + registry-url: "https://registry.npmjs.org" - name: Install dependencies - run: npm install + run: pnpm install --frozen-lockfile - - name: Build validation package - run: npm run build --workspace=@spine-event-engine/validation-ts + - name: Verify release candidate + run: pnpm verify - name: Perform publishing to NPM registry # The version is still a `snapshot`, so NPM requires it to be tagged accordingly. - run: npm publish --workspace=@spine-event-engine/validation-ts --tag snapshot + run: pnpm --filter @spine-event-engine/validation publish --tag snapshot --no-git-checks diff --git a/.gitignore b/.gitignore index 72d4b94..b990579 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules/ # Build output dist/ *.tsbuildinfo +packages/validation/docs/api/reference/ +*.tgz # Generated code (Protobuf) src/generated/ @@ -39,6 +41,8 @@ pnpm-debug.log* *.tmp .cache/ .temp/ +.superpowers/ +.worktrees/ # Environment and local config .env @@ -46,7 +50,5 @@ pnpm-debug.log* .env.*.local .claude/settings.local.json -# Package manager lock files (libraries should not commit lock files) -package-lock.json +# Lock state is committed at the workspace root for reproducible builds. yarn.lock -pnpm-lock.yaml diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.18.0 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0256d96 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +.worktrees +*.code-workspace +dist +coverage +packages/validation/docs/api/reference +packages/*/src/generated +packages/*/tests/generated +packages/*/proto/spine +packages/*/tests/proto/spine +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..90abee2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0ad0204 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,136 @@ +# Validation TS Agent Instructions + +## Canonical Workflow + +`build-protocol/BUILD_PROTOCOL.md` is the canonical autonomous-development +workflow. `build-protocol/PROJECT_PLAN.md` records the active sequence, and +`build-protocol/TECHNICAL_SPEC.md` defines the current product boundary. + +At the start of every task, reconcile the actual Git state and read the active +task record. Preserve unrelated user changes and ignored local files. + +## Human Approval Boundary + +For each user-supplied task: + +1. inspect the relevant code, documentation, and repository state; +2. ask only questions whose answers materially affect the result; +3. propose a concrete implementation and verification plan; +4. wait for explicit human approval; and +5. after approval, execute autonomously until completion or a real blocker. + +Do not pause for routine implementation choices covered by the approved plan. + +## Progress Communication + +Send a concise user-facing update after every subagent completion, verification +result, review result, merge, push, or real blocker. State the outcome, next +action, and whether work continues. Never wait silently while active work +remains. + +## Model Allocation + +Use Standard speed. Do not use Fast/boost mode, Max, or Ultra in the normal +cycle. Always set the model and reasoning explicitly for each child. + +- Main orchestration: `gpt-5.6-sol`, medium reasoning. +- Architecture, requirements splitting, and difficult public-contract + planning: `gpt-5.6-sol`, high reasoning. +- TypeScript implementation, bounded refactoring, mechanical verification, + documentation research, and dependency checks: `gpt-5.6-terra`, medium + reasoning. +- Correctness, compatibility, public API, reliability, or security review: + `gpt-5.6-terra`, high reasoning. + +Record expected dispatch metadata in the task or review log before accepting +child work. Runtime self-introspection is optional when the surface does not +expose it; explicit dispatch fields remain mandatory. + +## Existing Roles + +Use only the project roles defined under `.codex/agents/`: + +- `requirements_splitter` +- `implementer` +- `style_maintainability_reviewer` +- `documentation_reviewer` +- `typescript_api_reviewer` +- `performance_reliability_reviewer` +- `security_reviewer` + +Mechanical checks and repository scans are orchestrator-dispatched functions, +not additional roles. Subagents must not spawn subagents. + +## Ownership And Concurrency + +- Use one production-code writer for overlapping files. +- Parallelize independent read-only research, verification, and review. +- Use task branches and isolated worktrees for standard and high-risk work. +- Collect a complete review wave before sending one deduplicated correction + batch to the implementation owner. +- Close every subagent immediately after its assigned role completes. + +## Branches And Remote State + +- `master` is the release branch. Never merge or push it unless the human + explicitly requests a PR into `master`. +- `dev` is the integration branch. +- Create task branches from current `dev`, named `task/-`. +- Merge completed tasks into `dev` only after review and verification. +- Push the task branch and updated `dev`, then verify remote refs. +- A push to `master` intentionally triggers snapshot publication. + +## Validation Contract Sources + +Runtime behavior is defined primarily by documentation in immutable upstream +Proto sources: + +- `spine/options.proto` from `SpineEventEngine/base-libraries`; +- later extensions from `spine/time_options.proto` in `SpineEventEngine/time`. + +Resolve each intake to an exact upstream commit, record provenance and a +checksum, and never edit the vendored file. Frozen upstream style violations +must not fail project-owned Buf lint rules. Compilation, descriptor use, and +provenance checks still apply. + +Do not treat the JVM Validation implementation as the default design source. +Consult it only when the human or an approved task specifically requires +behavioral comparison. + +## Autonomous Cycle + +1. Classify the approved milestone as micro, standard, or high-risk. +2. Record acceptance criteria, human-imposed requirements, skill selection, + branch/worktree ownership, and risk assumptions. +3. Use deep planning only for public or serialized contracts, validation + semantics, new subsystems, security, persistence, concurrency, or a proven + architectural blocker. +4. Implement with behavior-focused tests when runtime behavior changes. +5. Run focused mechanical checks before specialist review. +6. Invoke only relevant review roles; give every canonical concern a clean, + accepted, or concrete N/A disposition. +7. Aggregate findings once, correct accepted findings, and re-review only + substantively affected concerns. +8. Run the full verification gate at the cadence in `BUILD_PROTOCOL.md`. +9. Commit, push, merge to `dev`, post-merge verify, push `dev`, confirm remote + refs, close agents, and remove clean merged worktrees. + +## Completion Gates + +Do not claim completion without fresh evidence. The canonical full gate is: + +```bash +pnpm verify +``` + +## Version Changes + +Follow the normative [Framework Version Changes](build-protocol/BUILD_PROTOCOL.md#framework-version-changes) +rule. Root framework-version updates are isolated version-only commits with the +exact subject `Bump version -> `. + +Runtime or test changes must preserve the enforced baseline of at least 80% +statements, 80% lines, 70% branches, and 90% functions. Reach 90% across all +coverage dimensions before substantial behavioral expansion. + +Stop only for the blockers defined in `BUILD_PROTOCOL.md`. diff --git a/README.md b/README.md index 8497104..b3d5839 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Spine Validation โ€” TypeScript Client Library -A TypeScript validation library for Protobuf messages using [Spine Validation](https://github.com/SpineEventEngine/validation/) options, +Requires Node.js 24 or later; development and CI pin and test Node.js 24.18.0. + +A TypeScript validation library for Protobuf messages using [Spine Validation](https://github.com/SpineEventEngine/validation/) options, built on [@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es) (Protobuf-ES v2). > **๐Ÿ”ง This library is in its experimental stage, the public API should not be considered stable.** @@ -15,25 +17,23 @@ This library lets you: - โœ… **Maintain a single source of truth** โ€” validation logic lives in your `.proto` files. - โœ… **Keep frontend and backend validation in sync** automatically. - โœ… **Get type-safe validation** with full TypeScript support. -- โœ… **Display the same error messages** to users that your backend generates. +- โœ… **Use error-message templates** defined by the same Proto options. ### For New Users -Even if you're not using Spine Event Engine, this library provides a powerful way +Even if you're not using Spine Event Engine, this library provides a way to add runtime validation to your Protobuf-based TypeScript applications: - โœ… **Define validation in `.proto` files** using declarative [Spine Validation options](https://github.com/SpineEventEngine/base-libraries/blob/master/base/src/main/proto/spine/options.proto). -- โœ… **Type-safe, runtime validation** for your Protobuf messages. +- โœ… **Type-safe, runtime validation** for Protobuf messages. - โœ… **Clear, customizable error messages** for better UX. - โœ… **Works with Protobuf-ES v2** and modern tooling. -- โœ… **Extensible architecture** for custom validation logic. - ## โœจ Features **Comprehensive Validation Support** -- **`(required)`** โ€” Ensure fields have non-default values. +- **`(required)`** โ€” Validate the supported Proto-defined presence targets. - **`(pattern)`** โ€” Regex validation for strings. - **`(min)` / `(max)`** โ€” Numeric bounds with inclusive/exclusive support. - **`(range)`** โ€” Bounded ranges with bracket notation `(min..max]`. @@ -42,44 +42,51 @@ to add runtime validation to your Protobuf-based TypeScript applications: - **`(goes)`** โ€” Field dependency constraints. - **`(require)`** โ€” Complex required field combinations with boolean logic. - **`(choice)`** โ€” Require that a `oneof` group has at least one field set. +- **`(when)`** โ€” Validate Spine Time values against past/future bounds; copy the + official [`spine/time_options.proto`](packages/validation/proto/spine/time_options.proto) + and its required Spine Time Proto files unchanged onto the import path. **Developer Experience** - ๐Ÿš€ Full TypeScript type safety. - ๐Ÿ“ Custom error messages. -- ๐Ÿงช 200+ comprehensive tests. +- ๐Ÿงช Comprehensive contract and regression tests. - ๐Ÿ“š Extensive documentation. - ๐ŸŽจ Clean, readable error formatting. ### โš ๏ธ Known Limitations - **`(set_once)`** โ€” Not currently supported. This option requires state tracking across multiple validations, -which is outside the scope of single-message validation. - + which is outside the scope of single-message validation. +- **`(pattern)`** โ€” Uses ECMAScript `RegExp`; the official Proto documentation uses Java `Pattern` as its syntax + baseline. See the [package regular-expression limitation](packages/validation/README.md#regular-expressions). ## ๐Ÿš€ Getting Started -See the [package-level README](packages/spine-validation-ts/README.md) for complete installation instructions and usage guide. +See the [package guide](packages/validation/README.md), the +[development reference](packages/validation/docs/README.md), the +[development guide](packages/validation/docs/development.md), and the +[executable example](packages/example/README.md). -**Quick install:** - -```bash -npm install @spine-event-engine/validation-ts@snapshot @bufbuild/protobuf -``` +`@spine-event-engine/validation` is not yet available from the public npm +registry. See the [package guide](packages/validation/README.md) for the API +and release status, or run the [executable example](packages/example/README.md) +from this repository. --- ## ๐Ÿ“ฆ What's Included -This repository is structured as an npm workspace: +This repository is structured as a pnpm workspace: ``` validation-ts/ โ”œโ”€โ”€ packages/ -โ”‚ โ”œโ”€โ”€ spine-validation-ts/ # ๐Ÿ“ฆ Main validation package +โ”‚ โ”œโ”€โ”€ validation/ # ๐Ÿ“ฆ Main validation package โ”‚ โ”‚ โ”œโ”€โ”€ src/ # Source code -โ”‚ โ”‚ โ”œโ”€โ”€ tests/ # 200+ comprehensive tests -โ”‚ โ”‚ โ”œโ”€โ”€ proto/ # Spine validation proto definitions +โ”‚ โ”‚ โ”œโ”€โ”€ tests/ # Contract and regression tests +โ”‚ โ”‚ โ”œโ”€โ”€ proto/ # Official Spine and project Proto files +โ”‚ โ”‚ โ”œโ”€โ”€ docs/ # Repository-only development reference โ”‚ โ”‚ โ””โ”€โ”€ README.md # Full package documentation โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ example/ # ๐ŸŽฏ Example project @@ -90,10 +97,11 @@ validation-ts/ โ””โ”€โ”€ README.md # You are here ``` - ## ๐ŸŽ“ Documentation -See the [package-level README](packages/spine-validation-ts/README.md) for more details. +See the [package-level README](packages/validation/README.md) for consumer +setup and API details. The [development reference](packages/validation/docs/README.md) +contains architecture, exact validation behavior, and local development notes. --- @@ -106,41 +114,37 @@ See the [package-level README](packages/spine-validation-ts/README.md) for more git clone cd validation-ts -# Install dependencies -npm install +# Install with the pinned pnpm release without installing a system shim +corepack pnpm install --frozen-lockfile ``` +On a cold host, Corepack may need network access for the pinned pnpm release; +a cold pnpm store may then download the packages in the committed lockfile. + ### Build & Test ```bash -# Build the validation package -npm run build - -# Run all tests -npm test - -# Run the example project -npm run example +# Run the complete local and CI quality gate +corepack pnpm verify ``` ### Workspace Scripts -| Command | Description | -|---------|-------------| -| `npm run build` | Build the validation package | -| `npm test` | Run all validation tests | -| `npm run example` | Run the example project | +| Command | Description | +| ----------------------- | ------------------------------------------------------------------------------------- | +| `corepack pnpm verify` | Run generation, typechecking, lint, format, coverage, docs, Proto, and package checks | +| `corepack pnpm build` | Build the package and example | +| `corepack pnpm test` | Run validation-package and executable-example Vitest tests | +| `corepack pnpm example` | Run the example project | --- ## ๐Ÿค Contributing -Contributions are welcome! Please ensure: - -1. All tests pass: `npm test` -2. Code follows existing patterns -3. New features include tests -4. Documentation is updated +See the [contribution guide](packages/validation/docs/contributing.md) for +review and delivery practices, and the +[development guide](packages/validation/docs/development.md) for local setup, +generated inputs, extension workflows, and verification. --- @@ -161,7 +165,7 @@ Apache 2.0. **Made with โค๏ธ for the Spine Event Engine ecosystem.** -[Documentation](packages/spine-validation-ts/README.md) ยท [Examples](packages/example) ยท [Report Bug](../../issues) +[Documentation](packages/validation/README.md) ยท [Examples](packages/example) ยท [Report Bug](https://github.com/SpineEventEngine/validation-ts/issues) diff --git a/build-protocol/BUILD_PROTOCOL.md b/build-protocol/BUILD_PROTOCOL.md new file mode 100644 index 0000000..66b3f3d --- /dev/null +++ b/build-protocol/BUILD_PROTOCOL.md @@ -0,0 +1,337 @@ +# Build Protocol + +This protocol governs autonomous development of +`@spine-event-engine/validation` with Codex, subagents, Git worktrees, and the +`dev` integration branch. + +## Authority + +Apply sources in this order: + +1. current explicit human instructions and approved plan; +2. accepted entries in `DECISION_LOG.md`; +3. the active task record and technical specification; +4. this protocol and `CODE_QUALITY.md`; +5. historical logs. + +When sources conflict and the higher authority does not resolve the result, +record a blocking question and stop. + +## Prime Directive + +Work must remain resumable after interruption. Record each meaningful boundary +in the task and work logs in the same change as the work it describes: + +1. framing, approval, classification, and ownership; +2. implementation and focused verification; +3. complete review-wave findings and dispositions; +4. corrections and converged review; and +5. integration, post-merge verification, and remote synchronization. + +Human-imposed requirements are binding invariants. Standard and high-risk task +records contain a ledger quoting or precisely linking every applicable rule. + +## Approval Boundary + +Before implementation, the orchestrator: + +1. inspects actual code, documentation, Git state, and relevant external + contracts; +2. asks questions only when an answer materially changes the result; +3. proposes a concrete implementation and verification plan; and +4. waits for explicit human approval. + +After approval, continue autonomously. Do not ask about routine reversible +choices inside the plan. + +## Roles And Models + +Use the project roles under `.codex/agents/`. Do not invent or rename roles. + +| Function | Model | Reasoning | +| -------------------------------------------------------------------------- | --------------- | --------- | +| Main orchestration | `gpt-5.6-sol` | medium | +| Architecture-significant splitting and public-contract planning | `gpt-5.6-sol` | high | +| TypeScript implementation and bounded correction | `gpt-5.6-terra` | medium | +| Mechanical verification, documentation, dependencies, and repository scans | `gpt-5.6-terra` | medium | +| Correctness, compatibility, API, reliability, and security review | `gpt-5.6-terra` | high | + +Every dispatch supplies model and reasoning explicitly and records the expected +values before the result is accepted. Record actual runtime metadata when the +surface exposes it. Lack of self-introspection is a limitation, not a failure, +when immutable dispatch values are available. + +The requirements splitter is selective. Invoke it only for: + +- a new subsystem or public package boundary; +- public or serialized contract changes; +- validation-option semantics; +- security, persistence, concurrency, idempotency, or destructive behavior; +- a demonstrated architectural blocker. + +Ordinary fixes use a short orchestrator plan. Mechanical verification is a +function, not another agent identity. + +## Concurrency And Ownership + +- Subagents must not spawn subagents. +- Use the surface's available capacity; no project-specific numerical cap is + imposed beyond `.codex/config.toml`. +- Only one writer owns overlapping production files. +- Parallelize independent read-only research, test analysis, mechanical + verification, and specialist review. +- Reviewers receive distinct concerns over an immutable diff basis. +- Collect the complete review wave before sending one deduplicated correction + batch. +- Return corrections to the existing implementation context when available. +- Close each child immediately after the role completes. + +## Task Classification + +### Micro + +A micro task changes documentation, comments, formatting, or task metadata, +normally within three files and 150 non-generated lines. It does not change +runtime behavior, public or serialized contracts, generated artifacts, +dependencies, shared tooling, publishing, security, or user workflows. + +The orchestrator may implement it directly. Use one combined micro record, +deterministic checks, and relevant review dispositions. + +### Standard + +A standard task is bounded runtime, test, example, documentation, or tooling +work without a high-risk boundary. Use one implementation owner, focused +checks, a complete relevant review wave, and one aggregated correction batch. + +### High-Risk + +A high-risk task changes: + +- public npm names, exports, declarations, or serialized Proto contracts; +- validation semantics or compatibility guarantees; +- dependency/publishing security; +- destructive behavior or migrations; +- persistence, concurrency, idempotency, or lifecycle ownership; or +- architecture spanning multiple subsystems. + +Use Sol High planning when the selective trigger applies, Terra High review +for the affected risk, focused regression evidence, and the full verification +gate. A task may be promoted at any time and may not be demoted after +implementation to avoid a gate. + +## Work Breakdown + +1. Reconcile `dev`, remotes, dirty state, and active records. +2. Record acceptance criteria, classification, ledger, selected skills, and + high-risk assumptions. +3. Split architecture-significant work into reviewable slices. +4. Create one traceable task branch and worktree from current `dev`. +5. Give one implementation owner the write scope and behavior tests. +6. Run the narrowest useful checks during implementation. +7. Run the pre-review diff/docs/status scan. +8. Dispatch relevant review roles concurrently. +9. Aggregate and classify findings, then assign one correction batch. +10. Re-run affected checks and only substantively affected review concerns. +11. Run the full gate when required. +12. Commit, push the task branch, merge to `dev`, post-merge verify, push + `dev`, confirm remote refs, close agents, and remove the clean worktree. + +## Branch And Worktree Rules + +- `master` is release-only and changes only after an explicit human request + for a PR into `master`. +- `dev` is the integration branch. +- Branches use `task/-` and start from up-to-date `dev`. +- Worktrees live under ignored `.worktrees/`. +- Never share overlapping file ownership. +- Never force-remove a dirty worktree. +- Do not merge before review converges and logs are current. +- Preserve unrelated user changes and ignored local files. + +## Remote Synchronization + +After a task is complete and merged to `dev`: + +1. push the completed task branch to `origin`; +2. push updated `dev`; +3. push task tags, if any; +4. inspect remote refs and prove the intended commits match; and +5. record the remote state at the existing closure boundary. + +Do not push or merge `master` without an explicit human request. A push to +`master` intentionally invokes the repository's automatic npm publication. + +Diagnose authentication, network, policy, and non-fast-forward failures without +rewriting or losing local history. Remote inability becomes a blocker only +after safe in-scope recovery is exhausted. + +## Skills + +The orchestrator performs one task-level applicability check before governed +action: + +1. inspect the exposed skill inventory and + `skills/EXPECTED_SKILLS.md`; +2. select task-relevant skills by metadata before reading bodies; +3. read every selected `SKILL.md` fully; +4. record selected and apparently relevant skipped skills with reasons; +5. pass concise applicable instructions to children; and +6. repeat only when scope, role, or available inventory materially changes. + +Skills are advisory workflow inputs. They cannot override the approved plan, +project protocol, sandbox, approvals, or human authorization. + +Before choosing or upgrading a dependency, verify its current stable release, +maintenance, TypeScript/Node support, and compatibility. Record the decision. +Do not implement common infrastructure before checking existing libraries. + +## Review Loop + +Every task records a disposition for: + +- style/maintainability; +- documentation completeness; +- TypeScript/public API; +- performance/reliability. + +Invoke a reviewer only when its concern can be affected. Otherwise record a +concrete N/A reason. Security is a final release-readiness role unless the +human explicitly requests it earlier or the approved task is itself a +security review. + +Before review, inspect the diff and task records for: + +- stale status or missing evidence; +- accidental public exports or package-name remnants; +- duplicated policy values; +- documentation claims not supported by code; +- modifications to immutable vendored Proto files; +- unrelated user changes. + +Classify findings: + +- **P0 critical:** active data loss, security compromise, corruption, or + availability failure. +- **P1 major:** required behavior or public contract is wrong, or essential + regression coverage is missing. +- **P2 task-scope:** a concrete maintainability, documentation, API, + reliability, or test defect introduced or exposed by this task. +- **P3 advisory:** optional polish, preference, or unchanged baseline debt. + +Wait for the complete wave, deduplicate, and accept or reject each finding with +a reason. P0/P1 block acceptance. Resolve every accepted P2. Record P3 without +expanding scope. + +Run at most two complete review waves. Corrections reopen only substantively +affected lanes. Continue beyond that limit only for unresolved P0/P1 risk or +explicit human direction. + +Review converges when no P0/P1 remains, accepted P2 findings are resolved, P3 +and rejected findings are recorded, and every canonical concern has a clean, +accepted, or justified N/A disposition. + +## Verification + +Use focused tests in inner loops. Run `pnpm verify` once after review +converges when runtime code, tests, public contracts, dependencies, generated +artifacts, publishing, CI, or shared tooling changes. + +The full gate must cover: + +- pinned Node compatibility; +- deterministic dependency installation through the committed lockfile; +- Protobuf generation and immutable-source provenance; +- TypeScript build/typechecking; +- ESLint and formatting; +- Vitest tests and coverage; +- TypeDoc/API generation; +- project-owned Proto lint; +- generated-output cleanliness; +- published-package contents and an installable consumer smoke test; and +- `git diff --check`. + +Coverage initially enforces: + +- statements: 80%; +- lines: 80%; +- branches: 70%; +- functions: 90%. + +Substantial behavioral expansion waits until every metric is at least 90%. +Exceptions require an accepted decision and explicit human approval. + +After merge, repeat the full gate only when integration changed the verified +tree, shared build/dependency/generated infrastructure changed, or high-risk +integration warrants it. Otherwise prove tree equality and run focused checks. + +## Immutable Proto Intake + +For each upstream file: + +1. resolve the upstream branch to a commit; +2. retrieve the raw file at that commit; +3. record repository, commit, source path, raw URL, retrieval date, local path, + and SHA-256; +4. compare the vendored file byte-for-byte; +5. exclude only the immutable upstream file from incompatible style rules; and +6. continue to compile/generate it and validate its provenance. + +Never edit a frozen Proto to make Buf lint pass. Lint project-owned Proto files +normally. A changed checksum requires an explicit intake task and review. + +## Logging + +Standard and high-risk task records include: + +- task ID, status, classification, baseline, branch, and worktree; +- approved plan and human requirements ledger; +- selected skills and child dispatch metadata; +- decisions and questions; +- file ownership and changed files; +- commands, tests, coverage, and limitations; +- review waves and finding dispositions; +- integration, remote refs, and next action. + +Keep chronological commands in `work-logs/` and immutable review evidence in +`reviews/`. Never record credentials, tokens, auth headers, sensitive payloads, +or unnecessary local personal paths. + +## Framework Version Changes + +Every root framework-version change must be an isolated version-only commit. +Only the `version` fields in root `package.json`, +`packages/validation/package.json`, and `packages/example/package.json` may +change in that commit; they change together. Its exact subject is +`Bump version -> `. + +Do not create record-only commits merely to name the immediately preceding +commit. Git history is durable evidence. + +## Blockers + +Stop and ask the human only when: + +- a product or public-contract choice is genuinely human-owned and unresolved; +- a required external source or dependency remains unavailable after approved + attempts; +- repository corruption or conflicting user-owned changes prevent safe work; +- required authority would expand the approved scope materially; or +- a final security residual requires explicit risk acceptance. + +Test failures, coverage gaps, review findings, merge conflicts, difficult +implementation, and ordinary tooling failures are not blockers. Diagnose, +correct, and continue. + +## Completion + +A task is complete only when: + +- acceptance criteria and the approved plan are satisfied; +- affected docs and API reference are current; +- relevant focused checks and the required full gate pass; +- review converges; +- every child is closed; +- the task branch and `dev` are pushed and remote refs verified; +- the merged worktree is clean and safely removed; and +- durable records identify evidence, limitations, and the next milestone. diff --git a/build-protocol/CODE_QUALITY.md b/build-protocol/CODE_QUALITY.md new file mode 100644 index 0000000..63a018c --- /dev/null +++ b/build-protocol/CODE_QUALITY.md @@ -0,0 +1,111 @@ +# Code Quality + +## Design + +- Prefer the smallest API that expresses documented Proto behavior. +- Keep validation-option implementations modular and behavior-focused. +- Do not claim dynamic extensibility unless a supported registration API + exists. +- Do not invent JVM tooling or plugin concepts that have no TypeScript + equivalent. +- Use explicit types at public boundaries and reduce `any` when the affected + task can do so without speculative abstraction. +- Keep errors actionable and avoid leaking entire message payloads. + +## TypeScript And Packaging + +- Use strict TypeScript compilation. +- Document public exports with TSDoc that TypeDoc can render. +- Keep package metadata, exports, declarations, examples, and README imports + consistent with `@spine-event-engine/validation`. +- Use pnpm 11.9.0, Vitest, and ESM for workspace development and CI. +- Generated Protobuf-ES output is ignored and regenerated. +- `pnpm-lock.yaml` is committed; clean installs directly invoke + `corepack pnpm install --frozen-lockfile` without installing a system shim. +- Pin development Node through `.node-version` and enforce supported engines. + +## Source Layout + +- Production source lives under `packages/validation/src/`. +- Tests live under `packages/validation/tests/`. +- The smoke consumer lives under `packages/example/`. +- Generated code stays in ignored `src/generated/` and `tests/generated/`. +- Shared verification scripts live under root `scripts/`. + +## Formatting And Lint + +- Prettier is the canonical formatter. +- ESLint enforces TypeScript correctness and maintainability. +- Prefer 100-character lines; the hard ceiling is 120 where reflow would harm + clarity. +- Generated sources, coverage, distributions, API output, worktrees, and + immutable vendored Proto files are excluded from inappropriate checks. +- `pnpm format:check` and `pnpm lint` are required gates. + +## Testing + +- Use behavior-focused unit tests for each option. +- Add integration tests for combinations and nested field paths. +- Every bug fix receives a regression test. +- Public package changes receive a package-contents and consumer-install test. +- Keep test compilation strict; do not weaken TypeScript only for Vitest. +- The enforced coverage gate is at least 90% statements, branches, functions, + and lines. + +## Protobuf + +- Treat frozen upstream Proto files as immutable contract inputs. +- Record exact upstream commit and SHA-256. +- Never patch their content for local formatting or Buf style. +- Lint project-owned Proto files and compile all required Proto inputs. +- Generated source compatibility patches must fail loudly if expected output + is absent or changes unexpectedly. + +## Documentation + +Update only affected surfaces: + +- root README for repository and contributor workflow; +- package README for installation, public API, and behavior; +- example README for the consumer workflow; +- TypeDoc comments for public declarations; +- technical baseline for behavior or boundary changes; +- decision log for architectural, tooling, dependency, or compatibility + decisions. + +Claims must match verified code. Avoid counts or coverage percentages unless a +gate maintains them automatically or the date/evidence is explicit. + +## Reliability And Security + +Review runtime changes for: + +- unbounded nested validation; +- unsafe or catastrophic regular expressions; +- descriptor and message shape assumptions; +- duplicate or misleading violations; +- deterministic generation; +- memory retention and repeated metadata work; +- dependency install and publishing integrity; +- sensitive values in diagnostic output. + +Security review is required before a ready-for-use release and earlier only +when explicitly requested. + +## Dependency Selection + +Before adding or upgrading a library, record: + +- current stable version and source; +- maintenance and Node/TypeScript support; +- compatibility with the pnpm/Vitest/ESM workspace stack; +- why an existing dependency or platform feature is insufficient; and +- the rejected alternatives that materially affected the choice. + +Pin development tools in the lockfile. Public runtime compatibility belongs in +peer dependencies and engines. + +## Framework Versions + +Follow [Framework Version Changes](BUILD_PROTOCOL.md#framework-version-changes) +in the build protocol. diff --git a/build-protocol/CONTRIBUTOR_WORKFLOW.md b/build-protocol/CONTRIBUTOR_WORKFLOW.md new file mode 100644 index 0000000..c59a888 --- /dev/null +++ b/build-protocol/CONTRIBUTOR_WORKFLOW.md @@ -0,0 +1,46 @@ +# Contributor Workflow + +## Start + +1. Read `AGENTS.md`, the approved plan, active task record, + `BUILD_PROTOCOL.md`, `CODE_QUALITY.md`, and relevant technical documents. +2. Confirm classification, baseline, branch, worktree, ownership, and dirty + state. +3. Reuse the task-level skill applicability check and read selected skills. +4. Record unresolved questions before changing behavior. + +## Implement + +- Stay within assigned ownership. +- Preserve unrelated user changes. +- Use behavior-focused tests for runtime changes. +- Never edit immutable vendored Proto files. +- Run focused typechecks/tests frequently. +- Update task and work logs at meaningful resumability boundaries. + +## Review + +- Run the pre-review diff/docs/status scan. +- Give each reviewer one bounded concern and immutable diff basis. +- Record model/reasoning dispatch metadata. +- Wait for the complete wave. +- Classify and deduplicate findings before correction. +- Reopen only substantively affected lanes. + +## Close + +1. Run the required verification gate and inspect its complete output. +2. Record review convergence, evidence, limitations, and next action. +3. Commit and push the task branch. +4. Merge into `dev`, prove whether the merged tree equals the verified tree, + and run the appropriate post-merge checks. +5. Push `dev`, inspect remote refs, close all agents, and remove the clean + merged worktree. + +Never merge or push `master` without an explicit human request for that release +boundary. + +## Framework Version Changes + +Follow [Framework Version Changes](BUILD_PROTOCOL.md#framework-version-changes) +in the build protocol. diff --git a/build-protocol/DECISION_LOG.md b/build-protocol/DECISION_LOG.md new file mode 100644 index 0000000..7be68e3 --- /dev/null +++ b/build-protocol/DECISION_LOG.md @@ -0,0 +1,93 @@ +# Decision Log + +## D-0001: Adopt the Spine TS agentic protocol structure + +Date: 2026-07-24 + +Use the reusable risk classification, resumability records, selective +specialist reviews, worktree ownership, verification cadence, and remote +closure rules from `/Users/armiol/development/experiments/spine-ts`. Do not +copy its historical task corpus or server-, transport-, DDD-, or example- +specific product rules. + +## D-0002: Require plan approval before implementation + +Date: 2026-07-24 + +For every user-supplied task, investigate, resolve material questions, present +a plan, and wait for explicit approval. After approval, continue autonomously +through verification, review, integration, and push unless a defined blocker +occurs. + +## D-0003: Use only dispatchable Sol and Terra profiles + +Date: 2026-07-24 + +Use Sol Medium for orchestration, Sol High for architecture-significant +planning, Terra Medium for implementation and mechanical/documentation work, +and Terra High for difficult correctness and specialist review. Do not retain +unexecutable Luna profiles. + +## D-0004: Integrate through dev + +Date: 2026-07-24 + +`master` remains the publishing branch and is changed only through a +human-requested PR. `dev` is the primary integration branch. Task branches and +worktrees start from `dev`, merge back into `dev`, and both refs are pushed and +verified at closure. Automatic publication on `master` push remains enabled. + +## D-0005: Rename the npm package + +Date: 2026-07-24 + +Rename every legacy package, workspace, directory, import, and documentation +surface consistently to `@spine-event-engine/validation`. T-0001 advances the version to +`2.0.0-snapshot.5`. + +## D-0006: Establish a coverage ratchet + +Date: 2026-07-24 + +Initially enforce at least 80% statements, 80% lines, 70% branches, and 90% +functions without regression. Reach 90% in every dimension before substantial +behavioral expansion. + +## D-0007: Freeze upstream Proto sources + +Date: 2026-07-24 + +The documentation in `spine/options.proto` and later +`spine/time_options.proto` is the primary contract source. Pin every intake to +an upstream commit and checksum. Never edit vendored Proto files. Exempt their +upstream style from local Buf style enforcement while continuing compilation +and provenance verification. + +## D-0008: Defer broad toolchain migration + +Date: 2026-07-24 + +T-0001 adds a committed npm lockfile, Node/tool pins, verification scripts, +coverage, lint, formatting, API docs, deterministic generation checks, and PR +CI. Migration from npm/Jest/CommonJS to the corresponding current Spine TS +pnpm/Vitest/ESM stack requires a separately approved task. + +## D-0009: Preserve the legacy Proto baseline + +Date: 2026-07-24 + +The three pre-existing local `options.proto` copies are identical to each other +but do not byte-match the current upstream `base-libraries` file. Their original +upstream commit is not established by repository history. Freeze and checksum +the existing files without false provenance, record current upstream +`options.proto` and `time_options.proto` commits separately, and require a +future approved intake task before replacement or addition. + +## D-0010: Use minimal Buf style enforcement during bootstrap + +Date: 2026-07-24 + +Use Buf `STANDARD` lint for every module. Apply path-and-rule-specific +exceptions only where a frozen upstream input or pre-existing fixture layout +cannot comply without changing its contract or location. Compilation, +generation, and checksum verification remain mandatory for frozen inputs. diff --git a/build-protocol/PROJECT_PLAN.md b/build-protocol/PROJECT_PLAN.md new file mode 100644 index 0000000..f446ba9 --- /dev/null +++ b/build-protocol/PROJECT_PLAN.md @@ -0,0 +1,35 @@ +# Project Plan + +## Active Milestone + +| ID | Milestone | Status | +| ------ | --------------------------------------------------------------------------------- | -------- | +| T-0001 | Install the modern agentic build protocol and reproducible verification baseline. | Complete | +| T-0002 | Correct non-regex validation semantics and reach universal 90% coverage. | Complete | +| T-0003 | Modernize the example, execute it in CI, and build agent-ready documentation. | Complete | +| T-0004 | Adopt the current Spine TS pnpm, Vitest, TypeScript, and ESM build stack. | Complete | +| T-0005 | Remove generated-code patching and strengthen runtime type boundaries. | Complete | +| T-0006 | Implement the frozen Spine `(when)` time-validation contract. | Complete | +| T-0007 | Restore clean-CI documentation compilation after the pnpm migration. | Complete | +| T-0008 | Move pnpm workflow setup to its supported Node 24 action runtime. | Complete | +| T-0009 | Restore package guidance and enforce concise, documented source conventions. | Complete | +| T-0010 | Restore beginner guidance, add developer documentation, and govern version bumps. | Complete | +| T-0011 | Teach domain ID messages and beginner-ready Proto examples. | Complete | + +## Accepted Follow-Up Boundaries + +- T-0004 replaces npm, Jest, and CommonJS with the current stack used by the + pinned Spine TS reference. +- Reach at least 90% statements, branches, functions, and lines before + substantial behavioral expansion. +- T-0005 is limited to type-boundary and generated-code integration debt; it + does not create a public validator-extension API or recursion budgets. +- T-0006 adds Validation TS extensions from immutable + `spine/time_options.proto` and `spine/time.proto` definitions, matching the + approved JVM comparison points. +- Keep Java `Pattern` compatibility unresolved during T-0002. Do not add a + third-party or project-owned regex engine without a later approved decision. + +No feature roadmap is inferred here. The human supplies future tasks, the +orchestrator investigates and plans them, and implementation starts only after +explicit approval. diff --git a/build-protocol/README.md b/build-protocol/README.md new file mode 100644 index 0000000..ecdce44 --- /dev/null +++ b/build-protocol/README.md @@ -0,0 +1,27 @@ +# Validation TS Build Protocol + +This directory contains the permanent workflow and technical baseline for +autonomous development of `@spine-event-engine/validation`. + +## Governing Documents + +1. [BUILD_PROTOCOL.md](BUILD_PROTOCOL.md) โ€” approved task lifecycle, roles, + review, Git integration, remote synchronization, and blocker policy. +2. [CODE_QUALITY.md](CODE_QUALITY.md) โ€” TypeScript, testing, documentation, + dependency, generated-source, and compatibility standards. +3. [TECHNICAL_SPEC.md](TECHNICAL_SPEC.md) โ€” current product and contract + boundary. +4. [CONTRIBUTOR_WORKFLOW.md](CONTRIBUTOR_WORKFLOW.md) โ€” concise task-agent and + reviewer operating guide. +5. [PROJECT_PLAN.md](PROJECT_PLAN.md) โ€” active milestones and known follow-up + work. +6. [DECISION_LOG.md](DECISION_LOG.md) โ€” accepted architectural and workflow + decisions. + +Task records live under `tasks/`, branch work logs under `work-logs/`, review +evidence under `reviews/`, unresolved questions under `questions/`, immutable +Proto provenance under `proto/`, and reusable records under `templates/`. + +Historical artifacts from the Spine TS reference project are intentionally not +copied. Only the reusable protocol structure and rules applicable to this +smaller library are retained. diff --git a/build-protocol/TECHNICAL_SPEC.md b/build-protocol/TECHNICAL_SPEC.md new file mode 100644 index 0000000..1024a92 --- /dev/null +++ b/build-protocol/TECHNICAL_SPEC.md @@ -0,0 +1,73 @@ +# Technical Baseline + +## Purpose + +`@spine-event-engine/validation` validates Protobuf-ES message instances at +runtime using Spine validation options documented in upstream Proto files. +It is an experimental TypeScript library; its public API is not stable. + +## Current Public Surface + +- `validate(schema, message)` returns constraint violations. +- `formatViolations(violations)` creates diagnostic text. +- `Violations.formatMessage()` and `Violations.failurePath()` extract + presentation-friendly values. +- Public generated types include `ConstraintViolation`, `ValidationError`, + `TemplateString`, and `FieldPath`. + +The implemented option families are `required`, `pattern`, message-level +`require`, `min`, `max`, `range`, `distinct`, nested `validate`, `goes`, Spine +Time `when`, and oneof `choice`. + +## Contract Authority + +The primary semantic source is the documentation embedded in: + +- `https://github.com/SpineEventEngine/base-libraries/blob/master/base/src/main/proto/spine/options.proto` +- frozen Spine Time inputs `spine/time_options.proto` and `spine/time/time.proto`. + +An intake resolves the moving upstream branch to an immutable commit and +records the raw URL, commit, retrieval date, local destination, and SHA-256. +Vendored Proto contents are immutable. Project code and tests may adapt around +them, but must not rewrite them to satisfy local style. + +The JVM Validation implementation is not the default source for TypeScript +architecture. A task may use it for focused behavioral comparison only when +explicitly approved. + +## Present Architecture + +The package generates Protobuf-ES descriptors with ESM `.js` relative imports, +then applies a fixed sequence of modular option validators. Generated sources +are build artifacts and remain untracked. The generated `require` extension is +imported under the project-local alias `requireFields`; generator output is not +modified. + +Known implementation debt is not silently fixed by the protocol bootstrap: + +- recursion and regular-expression resource limits need explicit future + analysis. + +The public entry point keeps a generated descriptor and its matching message +shape paired at compile time. Its validator sequence and option registry are +internal fixed implementation details; public validator extensibility is not +supported. + +Java regular-expression compatibility remains an explicit open question. The +frozen `(pattern)` documentation defines Java `Pattern.compile()` semantics, +while the current runtime delegates to ECMAScript `RegExp` and does not +implement equivalent full-match, dialect, or modifier behavior. Do not claim +full Java-pattern parity without a separately approved compatibility change. +See Q-0001 in `questions/UNRESOLVED.md`. + +## Compatibility + +- pnpm 11.9.0 is the package manager and its committed lockfile is the + deterministic dependency graph. +- Vitest 4.1.9 with V8 coverage is the test runner. +- The published package is ESM-only and must be consumed through its export + map; CommonJS `require()` is unsupported. +- Node.js 24 or later is required; 24.18.0 is pinned and tested. +- The package name is `@spine-event-engine/validation`. +- Snapshot versions use `2.0.0-snapshot.`. +- `master` pushes publish automatically; `dev` is the integration branch. diff --git a/build-protocol/proto/README.md b/build-protocol/proto/README.md new file mode 100644 index 0000000..5a8e77f --- /dev/null +++ b/build-protocol/proto/README.md @@ -0,0 +1,33 @@ +# Immutable Proto Provenance + +`UPSTREAM_SOURCES.json` is the machine-checked baseline for vendored Spine +Proto files. + +The pre-existing local `options.proto` copies are byte-identical to each other +but not to the upstream `base-libraries` master file observed on 2026-07-24. +Their original upstream commit cannot be established from repository history, +so they are explicitly classified as a frozen legacy baseline rather than +falsely attributed to the current upstream commit. + +The manifest records immutable checksums for the frozen `options.proto`, +`time_options.proto`, and `spine/time/time.proto` inputs. Replacing or adding +a Proto file requires a separately approved intake task, byte-for-byte +retrieval from the recorded commit, compatibility review, and manifest update. + +Run: + +```bash +corepack pnpm proto:verify +``` + +Never edit a frozen Proto to satisfy local Buf style. Every module uses the +`STANDARD` ruleset, with path-and-rule-specific exceptions for immutable Spine +inputs and pre-existing fixture names or package layouts. New project-owned +Proto files receive the full ruleset. Compilation, generation, and checksum +verification remain mandatory. + +# Spine Time intake + +T-0006 freezes `time_options.proto` and `spine/time/time.proto` at the recorded +Spine Time commit. Each validation, test, and example copy is a regular file +with a SHA-256 entry in `UPSTREAM_SOURCES.json`. diff --git a/build-protocol/proto/UPSTREAM_SOURCES.json b/build-protocol/proto/UPSTREAM_SOURCES.json new file mode 100644 index 0000000..3e23fbb --- /dev/null +++ b/build-protocol/proto/UPSTREAM_SOURCES.json @@ -0,0 +1,142 @@ +{ + "schemaVersion": 1, + "recordedAt": "2026-07-28", + "frozenFiles": [ + { + "localPath": "packages/validation/proto/spine/options.proto", + "classification": "legacy-baseline", + "sourceRepository": "SpineEventEngine/base-libraries", + "sourceCommit": null, + "sha256": "c0046a723f88be7f30cf20fb76375244c8230c40b3cafff03daaca6e8594f981" + }, + { + "localPath": "packages/validation/tests/proto/spine/options.proto", + "classification": "legacy-baseline-copy", + "sourceRepository": "SpineEventEngine/base-libraries", + "sourceCommit": null, + "sha256": "c0046a723f88be7f30cf20fb76375244c8230c40b3cafff03daaca6e8594f981" + }, + { + "localPath": "packages/example/proto/spine/options.proto", + "classification": "legacy-baseline-copy", + "sourceRepository": "SpineEventEngine/base-libraries", + "sourceCommit": null, + "sha256": "c0046a723f88be7f30cf20fb76375244c8230c40b3cafff03daaca6e8594f981" + }, + { + "localPath": "packages/validation/proto/spine/base/field_path.proto", + "classification": "legacy-baseline", + "sourceRepository": "SpineEventEngine/base-libraries", + "sourceCommit": null, + "sha256": "c4483fcf828a6aa9b0b4a73f290df0e8be669ed681316b4f4040a9c4fbbe8407" + }, + { + "localPath": "packages/validation/proto/spine/validate/error_message.proto", + "classification": "legacy-baseline", + "sourceRepository": "SpineEventEngine/base-libraries", + "sourceCommit": null, + "sha256": "be2e36e3a4a16886c97f31650732ca645155843c5706fbd92d161639c340090b" + }, + { + "localPath": "packages/validation/proto/spine/validate/validation_error.proto", + "classification": "legacy-baseline", + "sourceRepository": "SpineEventEngine/base-libraries", + "sourceCommit": null, + "sha256": "ad9548e441ba7afc8ea9377ffcc7684fb9fd623ff383050464342283078df1ca" + }, + { + "localPath": "packages/validation/proto/spine/time_options.proto", + "classification": "immutable-upstream-copy", + "sourceRepository": "SpineEventEngine/time", + "sourceCommit": "4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc", + "sourcePath": "time/src/main/proto/spine/time_options.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc/time/src/main/proto/spine/time_options.proto", + "retrievedAt": "2026-07-28", + "sha256": "933858bdbb118930a171d9a2383d884b498d7ed465a35664f2f411a72785f5be" + }, + { + "localPath": "packages/validation/proto/spine/time/time.proto", + "classification": "immutable-upstream-copy", + "sourceRepository": "SpineEventEngine/time", + "sourceCommit": "4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc", + "sourcePath": "time/src/main/proto/spine/time/time.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc/time/src/main/proto/spine/time/time.proto", + "retrievedAt": "2026-07-28", + "sha256": "da0e7482c69fb6e735441a4934222c054ec9795d7d4b46347751fccb5f692062" + }, + { + "localPath": "packages/validation/tests/proto/spine/time_options.proto", + "classification": "immutable-upstream-copy", + "sourceRepository": "SpineEventEngine/time", + "sourceCommit": "4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc", + "sourcePath": "time/src/main/proto/spine/time_options.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc/time/src/main/proto/spine/time_options.proto", + "retrievedAt": "2026-07-28", + "sha256": "933858bdbb118930a171d9a2383d884b498d7ed465a35664f2f411a72785f5be" + }, + { + "localPath": "packages/validation/tests/proto/spine/time/time.proto", + "classification": "immutable-upstream-copy", + "sourceRepository": "SpineEventEngine/time", + "sourceCommit": "4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc", + "sourcePath": "time/src/main/proto/spine/time/time.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc/time/src/main/proto/spine/time/time.proto", + "retrievedAt": "2026-07-28", + "sha256": "da0e7482c69fb6e735441a4934222c054ec9795d7d4b46347751fccb5f692062" + }, + { + "localPath": "packages/example/proto/spine/time_options.proto", + "classification": "immutable-upstream-copy", + "sourceRepository": "SpineEventEngine/time", + "sourceCommit": "4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc", + "sourcePath": "time/src/main/proto/spine/time_options.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc/time/src/main/proto/spine/time_options.proto", + "retrievedAt": "2026-07-28", + "sha256": "933858bdbb118930a171d9a2383d884b498d7ed465a35664f2f411a72785f5be" + }, + { + "localPath": "packages/example/proto/spine/time/time.proto", + "classification": "immutable-upstream-copy", + "sourceRepository": "SpineEventEngine/time", + "sourceCommit": "4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc", + "sourcePath": "time/src/main/proto/spine/time/time.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc/time/src/main/proto/spine/time/time.proto", + "retrievedAt": "2026-07-28", + "sha256": "da0e7482c69fb6e735441a4934222c054ec9795d7d4b46347751fccb5f692062" + } + ], + "currentUpstreamReferences": [ + { + "repository": "SpineEventEngine/base-libraries", + "commit": "7a05857b2adc68dc2f9b28a03ae3073e7c3e9df3", + "sourcePath": "base/src/main/proto/spine/options.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/base-libraries/7a05857b2adc68dc2f9b28a03ae3073e7c3e9df3/base/src/main/proto/spine/options.proto", + "sha256": "0890847e70ae80b9ecff9197de3721a31cc3862b29ad8ba726cab7d7760ffe29", + "vendored": false + }, + { + "repository": "SpineEventEngine/time", + "commit": "57d3dd98fea8efcdc4a3843f91143acc2dce87dc", + "sourcePath": "time/src/main/proto/spine/time_options.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/57d3dd98fea8efcdc4a3843f91143acc2dce87dc/time/src/main/proto/spine/time_options.proto", + "sha256": "70acff3da4ec7e3b0bba4f201948cd5ec2007e29b71c08a266b670be4271adfb", + "vendored": false + }, + { + "repository": "SpineEventEngine/time", + "commit": "4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc", + "sourcePath": "time/src/main/proto/spine/time_options.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc/time/src/main/proto/spine/time_options.proto", + "sha256": "933858bdbb118930a171d9a2383d884b498d7ed465a35664f2f411a72785f5be", + "vendored": true + }, + { + "repository": "SpineEventEngine/time", + "commit": "4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc", + "sourcePath": "time/src/main/proto/spine/time/time.proto", + "rawUrl": "https://raw.githubusercontent.com/SpineEventEngine/time/4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc/time/src/main/proto/spine/time/time.proto", + "sha256": "da0e7482c69fb6e735441a4934222c054ec9795d7d4b46347751fccb5f692062", + "vendored": true + } + ] +} diff --git a/build-protocol/questions/UNRESOLVED.md b/build-protocol/questions/UNRESOLVED.md new file mode 100644 index 0000000..7c8a4bc --- /dev/null +++ b/build-protocol/questions/UNRESOLVED.md @@ -0,0 +1,44 @@ +# Unresolved Questions + +## Q-0001: How should Validation TS execute Java regular expressions? + +Status: Advisory +Task: T-0002 follow-up +Raised: 2026-07-24 + +### Context + +The frozen `(pattern)` option documentation says that `regex` is supplied to +Java `Pattern.compile()`. The current TypeScript implementation uses native +ECMAScript `RegExp`, whose dialect, whole-match behavior, Unicode semantics, +and modifiers are not equivalent. + +Scala.js was considered behaviorally relevant but rejected as a +disproportionately large integration. No small maintained dependency was +identified that implements Java `Pattern` semantics. A project-owned +compatibility engine was proposed and rejected for now because of its +maintenance burden. + +### Options + +1. Adopt a maintained Java-compatible dependency with acceptable runtime and + package cost. +2. Implement and maintain a project-owned compatibility layer. +3. Define a deliberately smaller cross-platform pattern contract in an + upstream Proto revision. +4. Identify another solution that preserves shared JVM/TypeScript schemas. + +### Human Answer Or Decision + +Postpone the issue until the other correctness and coverage work is complete. +Do not integrate a large third-party library or create a project-owned engine +in T-0002. + +### Incorporated In + +- `../PROJECT_PLAN.md` +- `../TECHNICAL_SPEC.md` +- `../tasks/T-0002-validation-correctness/TASK.md` + +Resolved T-0001 questions and human answers remain recorded in +`../tasks/T-0001-protocol-bootstrap/TASK.md` and `../DECISION_LOG.md`. diff --git a/build-protocol/reviews/T-0001.md b/build-protocol/reviews/T-0001.md new file mode 100644 index 0000000..1a4373a --- /dev/null +++ b/build-protocol/reviews/T-0001.md @@ -0,0 +1,82 @@ +# T-0001 Review Log + +Status: Converged +Baseline: `c7527325ce2130e3766bacc6effabc1af238f2b6` +Reviewed ref: `4d4a04b41490e88b0848536cf6271cc484c9f86d` +Correction refs: `cd20671308084bd29db6928c6c045e783abdd356` plus the final +record/config/documentation cleanup. + +## Review Assignments + +| Concern | Agent ID | Model | Reasoning | Scope | +| ----------------------- | ----------------------------- | --------------- | --------- | ----------------------------------------------- | +| Style/maintainability | `/root/style_review` | `gpt-5.6-terra` | high | Protocol, scripts, tests, and affected paths | +| Documentation | `/root/docs_review` | `gpt-5.6-terra` | medium | Current behavior and contributor claims | +| TypeScript/API | `/root/typescript_api_review` | `gpt-5.6-terra` | high | Package rename, declarations, TypeDoc, consumer | +| Performance/reliability | `/root/reliability_review` | `gpt-5.6-terra` | high | Determinism, generation, CI, packaging | + +## Evidence + +- `npm ci`: passed. +- `npm run verify`: passed all 13 root gates. +- Jest: 11 suites and 232 tests passed. +- Coverage: 81.88% statements, 71.01% branches, 92.18% functions, 81.48% + lines. +- Generated digest: + `8b58b42ad69650c0b1f40a4b2d39959ab851cfb845f2be33e537e64a911fe552`. +- Packed-package check: 72 files accepted; installed CommonJS consumer loaded. +- Frozen Proto diff: paths renamed only, no content delta. + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ----- | -------- | --------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| F-001 | P1 | Style/maintainability | Global `MINIMAL` Buf lint contradicted the narrow policy. | Accepted; use `STANDARD` with exact path/rule compatibility exceptions. | +| F-002 | P1 | Style/reliability | Node 18 policy cannot install the pinned toolchain. | Accepted; align all engines, checker, and CI to Spine TS Node 24. | +| F-003 | P2 | Style/maintainability | Review ref and assignments were stale. | Accepted; this record now names the immutable ref and agents. | +| F-004 | P2 | Documentation | `(required)` docs claimed unsupported numeric/boolean behavior. | Accepted; describe only the frozen contract's supported kinds. | +| F-005 | P2 | Documentation | Copyable example used unsupported `(set_once)`. | Accepted; remove the unsupported option from the example. | +| F-006 | P2 | Documentation | Feature lists conflated the contract with incomplete runtime parity. | Accepted; distinguish implemented kinds and record bytes/enum/map debt. | +| F-007 | P2 | Style/maintainability | The first review record used a nonexistent expanded commit SHA. | Accepted; replace it with the verified full SHA. | +| F-008 | P2 | Style/maintainability | Test Buf config contained three no-op ignore paths. | Accepted; remove paths absent from that module. | + +## Correction Batch + +- Switched all Buf modules to `STANDARD`, preserving only explicit rules on + immutable inputs and pre-existing fixture names/package layouts. +- Raised the repository, published package, example, root checker, and CI + compatibility policy to Node 24, matching the reference protocol and pinned + development graph. +- Corrected `(required)` claims in repository, package, and TypeDoc-facing + documentation and removed `(set_once)` from the supported example. +- Updated the lockfile and durable review metadata. +- Follow-up re-review separated the frozen `(required)` contract from current + runtime parity, corrected the recorded implementation SHA, and removed stale + test-module Buf exceptions. + +## Re-review Evidence + +- Style and reliability re-review confirmed Node 24 consistency and + `STANDARD` Buf enforcement; focused node, provenance, Buf, and whitespace + checks passed. +- Documentation re-review confirmed the unsupported `(set_once)` example was + removed and identified the remaining contract/runtime conflation recorded as + F-006. +- The final cleanup corrected F-006 through F-008. `npm run proto:verify`, + `npm run proto:lint`, `npm run docs:check`, `npm run format:check`, + `npm run git:check`, and commit-object validation passed. +- The protocol permits at most two review waves. No P0/P1 remains, all accepted + P2 findings are corrected, and the residual edits are deterministic + documentation/configuration changes covered by the focused checks above. +- Final `npm run verify` passed on the corrected tree with 232 tests, all + thresholds satisfied, deterministic generation, and a packed consumer. + +## Convergence + +- Style/maintainability: Accepted findings corrected; focused re-review and + final evidence clean. +- Documentation: Accepted findings corrected; focused re-review and final + TypeDoc/format evidence clean. +- TypeScript/API: Clean; no correction affected the package API. +- Performance/reliability: Clean on re-review. +- Security: N/A for this non-release task. diff --git a/build-protocol/reviews/T-0002.md b/build-protocol/reviews/T-0002.md new file mode 100644 index 0000000..37dcec8 --- /dev/null +++ b/build-protocol/reviews/T-0002.md @@ -0,0 +1,96 @@ +# T-0002 Review Log + +Status: Complete +Baseline: `09b94d03828fb6f1ed264398dced327dbdaa67b5` +Reviewed ref: Final affected concern through `78b9eed`; canonical task tree +through `a8e1baf` +Dirty state: Clean implementation checkpoint; later dispatch records excluded + +## Review Assignments + +| Concern | Agent ID | Model | Reasoning | Scope | +| ----------------------- | ------------------------- | --------------- | --------- | --------------------------------------------------- | +| Style/maintainability | `/root/style_final` | `gpt-5.6-terra` | high | Runtime, tests, and task records | +| Documentation | `/root/docs_final` | `gpt-5.6-terra` | medium | Proto contract claims and regex limitation | +| TypeScript/API | `/root/api_final` | `gpt-5.6-terra` | high | Public error and serialized violation compatibility | +| Performance/reliability | `/root/reliability_final` | `gpt-5.6-terra` | high | Ordering, recursion, equality, caching, and gates | + +## Evidence + +| Evidence | Result | +| ----------------------------- | --------------------------------------- | +| Task 1 focused tests | Passed: 2 suites and 9 tests | +| Task 1 TypeScript compilation | Passed | +| Task 1 diff whitespace check | Passed | +| Task 1 scoped re-review | Clean: no actionable P0-P2 findings | +| Task 2 affected test wave | Passed: 11 suites and 231 tests | +| Task 2 independent focus | Passed: 4 suites and 52 tests | +| Task 2 TypeScript compilation | Passed | +| Task 2 scoped re-review | Clean: no actionable P0-P2 findings | +| Task 3 full package | Passed: 13 suites and 249 tests | +| Task 3 TypeScript compilation | Passed | +| Task 3 scoped re-review | Clean through `95c520d` | +| Task 4 full package | Passed: 14 suites and 273 tests | +| Task 4 TypeScript compilation | Passed | +| Task 4 scoped re-review | Clean through `d6dc7a3` | +| Task 5 distinct focus | Passed: 33 tests | +| Task 5 full package | Passed: 14 suites and 280 tests | +| Task 5 generated typecheck | Passed | +| Task 5 scoped re-review | Clean through `88bc9b3` | +| Task 6 focused tests | Passed: 2 suites and 57 tests | +| Task 6 full package | Passed: 14 suites and 285 tests | +| Task 6 generated typecheck | Passed | +| Task 6 scoped review | Clean: no actionable P0-P2 findings | +| Task 7 coverage | Passed: 94.72 / 91.44 / 98.05 / 95.87 | +| Task 7 generated typecheck | Passed | +| Task 7 lint and TypeDoc | Passed; TypeDoc had zero errors | +| Final correction focus | Passed: 3 suites and 62 tests | +| Final correction coverage | Passed: 94.72 / 91.53 / 99.03 / 95.87 | +| Final correction checks | TypeScript, lint, docs, format, diff | +| Canonical `npm run verify` | Passed: 14 suites, 293 tests, full gate | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ----- | -------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| F-001 | P1 | Task 1 contract kernel | The kernel requires a field descriptor and cannot construct message-level `(require)` violations without bypassing the shared interface. | Accepted; return one correction batch to the Task 1 implementation owner and re-review. | +| F-002 | P1 | Task 1 contract kernel | Field metadata placeholders are incorrectly gated on a concrete field value, breaking absent-field `(required)` templates. | Accepted; separate descriptor metadata from value packing and add a regression test. | +| F-003 | P1 | Task 1 contract kernel | Scalar `${field.type}` placeholders render Buf's internal numeric enum instead of canonical Proto type names. | Accepted; map scalar descriptors to Proto spellings and correct the test. | +| F-004 | P1 | Task 2 orchestration | `(choice)` restores a oneof group name into `FieldPath`, although the group is not a Proto field. | Accepted; keep the path empty and preserve only `${group.path}`. | +| F-005 | P2 | Task 2 orchestration | Ordering coverage does not distinguish validators on the same field or prove repeated-element sequence. | Accepted; add distinguishable option markers and an exact repeated sequence assertion. | +| F-006 | P1 | Task 2 orchestration | The adapter can pass whole collections to element packers and the kernel silently suppresses all packing failures. | Accepted; select an actual offender or no collection-level value and restore strict packing. | +| F-007 | P1 | Task 3 presence | Enum presence treats `undefined`, `null`, and nonnumeric values as set because it only checks inequality with zero. | Accepted; require a numeric non-zero enum value and add a missing-enum regression. | +| F-008 | P2 | Task 3 coverage | Required-presence acceptance does not exercise bytes or maps. | Accepted; add empty/non-empty fixtures and exact envelope assertions. | +| F-009 | P2 | Task 3 coverage | Goes companion errors and selected numeric-zero/boolean-false choice cases are not covered. | Accepted; add fixtures and complete structured assertions. | +| F-010 | P2 | Task 3 maintainability | Rewritten Task 3 production files lost the repository-standard Apache header. | Accepted; restore the standard header to every affected source file. | +| F-011 | P2 | Task 3 coverage | Boolean rejection and empty/leading/trailing/empty-group require grammar are not covered. | Accepted; add focused negative fixtures and complete structured assertions. | +| F-012 | P1 | Task 4 diagnostics | Range diagnostics normalize declared whitespace instead of annotating references in the original text. | Accepted; preserve declared text and annotate references in place. | +| F-013 | P2 | Task 4 coverage | Numeric coverage omits scalar families, overflow, malformed grammar, cross-type references, and executed 64-bit range. | Accepted; add the complete edge matrix and full public error assertions. | +| F-014 | P2 | Task 4 maintainability | Rewritten min/max and range modules have abbreviated rather than complete standard headers. | Accepted; restore exact headers from `numeric.ts`. | +| F-015 | P2 | Task 4 maintainability | The new numeric contract test has a truncated rather than complete repository-standard header. | Accepted; restore an exact complete test header. | +| F-016 | P1 | Task 5 diagnostics | String duplicate singletons render as quoted JSON arrays rather than the approved `[A]`/`[B]` list representation. | Accepted; use deterministic unquoted element-list formatting. | +| F-017 | P1 | Diagnostic formatting | Dynamic regex matching and replacement strings corrupt dotted placeholder keys and values such as `$&`. | Accepted; use literal tokens and callback replacement with focused regressions. | +| F-018 | P1 | Numeric reliability | `NaN` compares as equal and therefore passes inclusive min, max, and range constraints. | Accepted; make `NaN` fail every numeric constraint and cover singular/repeated values. | +| F-019 | P2 | Technical documentation | The technical specification still lists resolved bytes, enum, and map presence debt. | Accepted; remove the stale debt statement. | +| F-020 | P2 | Root documentation | The root README omits the material ECMAScript-versus-Java regex limitation. | Accepted; summarize and link the package limitation. | +| F-021 | P2 | Public example | The Quick Start uses unsupported `{value}` instead of `${field.value}`. | Accepted; correct the documented placeholder. | +| F-022 | P2 | Public example | The `(require)` example uses parentheses rejected by the frozen grammar and runtime. | Accepted; use the supported unparenthesized expression. | +| F-023 | P2 | TypeScript API | Exported `validateInternal` leaks a deep-import API solely to support an avoidable circular import. | Accepted; inject the recursive callback and keep the helper private. | +| F-024 | P2 | Numeric reliability | The `NaN` regression matrix omits repeated float/max and explicit Infinity/bigint non-regressions. | Accepted; complete the bounded matrix without changing production semantics. | +| F-025 | P1 | Verification gate | TypeDoc exits 5 because inherited JavaScript fences are absent from configured highlight languages. | Accepted; add `js` to the strict TypeDoc highlight allowlist and rerun the full gate. | + +## Correction Batch + +- Accepted findings: F-001 through F-025. +- Rejected findings and reasons: +- Verification: F-001 through F-016 passed their focused checks; F-017 through + F-023 passed focused and full correction checks in `35df598`. +- Re-review: F-017 through F-025 are resolved; all affected concerns are clean. + +## Convergence + +- Style/maintainability: Clean through `35df598`. +- Documentation: Clean through `78b9eed`. +- TypeScript/API: Clean through `35df598`. +- Performance/reliability: Clean through `dca5f17`. +- Security: N/A under D-0004; this is not a release or security review. diff --git a/build-protocol/reviews/T-0003.md b/build-protocol/reviews/T-0003.md new file mode 100644 index 0000000..ee66c87 --- /dev/null +++ b/build-protocol/reviews/T-0003.md @@ -0,0 +1,52 @@ +# T-0003 Review Log + +Status: Converged +Baseline: `d7cfbf74882801373ea171e47453777729edb572` + +## Review Assignments + +| Concern | Agent ID | Model | Reasoning | Scope | +| ----------------------- | ------------------------- | --------------- | --------- | ------------------------------------------------------- | +| Style/maintainability | `/root/t0003_style` | `gpt-5.6-terra` | high | Example, tests, docs checker, and maintained docs | +| Documentation | `/root/t0003_docs` | `gpt-5.6-terra` | medium | Contract accuracy, navigation, and agent reader testing | +| TypeScript/API | `/root/t0003_api` | `gpt-5.6-terra` | high | Package imports, generated schemas, and public claims | +| Performance/reliability | `/root/t0003_reliability` | `gpt-5.6-terra` | high | CI execution, deterministic gates, and runtime examples | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ----- | -------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| F-001 | P1 | Example correctness | Runnable `(pattern)` messages use unsupported namespaced placeholders and no scenario detects the literal output. | Accepted: remove dynamic pattern placeholders and add an exact generated-schema pattern scenario. | +| F-002 | P1 | Contract docs | The `(goes)` key is documented as `${goes.with}` rather than `${goes.companion}`. | Accepted: correct the key. | +| F-003 | P1 | Contract docs | Malformed pattern declarations are described as configuration errors although the legacy adapter emits a normal violation. | Accepted: document the legacy exception without changing runtime behavior. | +| F-004 | P1 | Contract docs | Unescaped `(require)` grammar breaks the option table and obscures alternatives and conjunction semantics. | Accepted: move the exact grammar to safe prose/code and repair the table. | +| F-005 | P2 | Agent navigation | Architecture docs omit the behavior-test and generated-fixture locations. | Accepted: add exact paths and change recipe. | +| F-006 | P1 | Documentation gate | `transpileModule` misses semantic errors and default/namespace import misuse. | Accepted: use a no-emit TypeScript program and add semantic/module/import regressions. | +| F-007 | P1 | TypeDoc | Maintained public TSDoc still shows obsolete unnamespaced placeholder examples and is outside stale-syntax checks. | Accepted: update public comments and include maintained TSDoc source in the gate. | +| F-008 | P2 | Example design | `known_payload_type` exists only as an unclear registry anchor although the file dependency already registers `User`. | Accepted: remove the field/value and preserve the `Any` test. | +| F-009 | P1 | CI reliability | Tests exercise scenario results but `verify`/CI never execute the compiled console adapter. | Accepted: add a built-console execution gate and explicit CI lane. | +| F-010 | P2 | Install docs | `@snapshot` is called a pin although it is a moving dist-tag. | Accepted in part: keep the useful tag command, state that it moves, and show exact-version pinning for reproducibility. | +| F-011 | P2 | Public surface | Architecture calls an abbreviated list the only public seam and omits exported configuration types/internal helper status. | Accepted: describe the complete supported entry point and distinguish the exported internal helper. | +| F-012 | P2 | Proto style | The new project-owned test Proto lacks the repository-standard license header. | Accepted proactively: add the standard header before final review. | +| F-013 | P1 | Pattern documentation | Universal authoring and nested-envelope claims omit legacy pattern placeholder and root/path exceptions. | Complete: static pattern guidance and both postponed normalization gaps are documented. | +| F-014 | P1 | TypeDoc gate | One public TSDoc block still uses `${field}`/`${value}`, and the stale check misses dollar-prefixed legacy forms. | Complete: namespaced text is current; brace-only and legacy dollar-prefixed forms are rejected. | +| F-015 | P2 | TypeDoc examples | Public `@example` fences are not semantically checked and one calls `formatViolations` without importing it. | Complete: examples are standalone and public TSDoc fences use the semantic checker. | +| F-016 | P1 | TypeDoc contract | `validate()` TSDoc states the normalized root/path/value envelope without excluding the documented legacy pattern adapter. | Complete: scoped the guarantee to shared-envelope validators and linked the pattern exception. | + +The reviewers' suggestion that transpilation alone could satisfy F-006 was not +accepted because the task record requires compilable TypeScript examples. +Semantic no-emit compilation is the correction target. + +## Security Disposition + +N/A under D-0004: this standard documentation/example task adds no external +trust boundary, credential handling, or release action. + +## Convergence + +- Style/maintainability: Clean through `3979266`. +- Documentation and fresh-reader testing: Clean through `117d1a5`. +- TypeScript/API: Clean through `3979266`. +- Performance/reliability: F-009 and F-006 resolved in `c98f01d`; no remaining + P0-P2 findings. +- Accepted findings F-001 through F-016 are complete. diff --git a/build-protocol/reviews/T-0004.md b/build-protocol/reviews/T-0004.md new file mode 100644 index 0000000..c365f8d --- /dev/null +++ b/build-protocol/reviews/T-0004.md @@ -0,0 +1,45 @@ +# T-0004 Review Log + +Status: Converged +Baseline: `d60fa4a2c1e0050c4517b52ca49c8de43cdc7d02` + +## Review Assignments + +| Concern | Agent ID | Model | Reasoning | Scope | +| ----------------------- | ------------------------- | --------------- | --------- | -------------------------------------------------------------------------------------------- | +| Style/maintainability | `/root/t0004_style` | `gpt-5.6-terra` | high | Whole migration diff, test parity, maintainability, and configuration clarity | +| TypeScript/API | `/root/t0004_api` | `gpt-5.6-terra` | high | ESM exports, declarations, NodeNext resolution, package metadata, and consumer compatibility | +| Performance/reliability | `/root/t0004_reliability` | `gpt-5.6-terra` | high | pnpm policy/lock, CI, publication, deterministic gates, scripts, and package consumer | +| Documentation | `/root/t0004_docs` | `gpt-5.6-terra` | medium | Maintained docs, executable commands, navigation, agent usability, and root README restraint | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ----- | -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| F-001 | P1 | Reliability | `minimumReleaseAge: 1440` is absent from the committed pnpm policy despite task evidence relying on it. | Resolved: committed policy and regenerated policy-valid lock. | +| F-002 | P1 | Reliability | CI and publication do not activate pnpm `11.9.0` before setup-node caching and later bare `pnpm` commands. | Resolved: pnpm/action-setup pins 11.9.0 before cache setup. | +| F-003 | P2 | Reliability | Publication uses floating Node `24` instead of `.node-version` `24.18.0`. | Resolved: publication reads `.node-version`. | +| F-004 | P2 | Maintainability/reliability | Both generated-source patchers silently succeed when the expected generated declaration disappears. | Complete: explicit cross-platform targets plus canonical missing/changed/success/idempotence/import tests for both patchers. | +| F-005 | P1 | TypeScript/API docs | The user guide omits `import_extension=js` from its Buf ESM generation configuration. | Resolved: documented generator option and `.js` consumer imports. | +| F-006 | P2 | TypeScript/API docs | Technical baseline and consumer docs do not state the pnpm/Vitest/ESM-only contract or unsupported CommonJS `require()`. | Complete: technical baseline, package README, and user guide state the ESM-only contract. | +| F-007 | P2 | Documentation | Current setup/consumer docs do not state Node 24 minimum and 24.18.0 tested/pinned version. | Resolved: root, contributor, user, and package docs state it. | + +## Security Disposition + +N/A unless implementation introduces an unplanned trust boundary, credential +flow, install hook, or publication behavior. + +## Convergence + +- Complete review wave collected before correction dispatch. +- Style/maintainability: F-004. +- TypeScript/API: F-005 and F-006. +- Performance/reliability: F-001 through F-004. +- Documentation/reader: F-006 and F-007. +- Seven deduplicated findings are accepted for one correction batch. +- Narrow re-review confirms F-001, F-002, F-003, F-005, and F-007. +- F-004 passed final performance/reliability confirmation after both patchers + gained canonical missing-target, changed-declaration, success, idempotence, + and import-rewrite fixture coverage. +- F-006 passed final documentation confirmation. +- All review concerns are clean; no P0-P2 finding remains. diff --git a/build-protocol/reviews/T-0005.md b/build-protocol/reviews/T-0005.md new file mode 100644 index 0000000..3218f57 --- /dev/null +++ b/build-protocol/reviews/T-0005.md @@ -0,0 +1,57 @@ +# T-0005 Review Log + +Status: Converged +Baseline: `df8cbb187488f4f9bc170dcc226f18eab27f9f8f` + +## Review Assignments + +| Concern | Agent ID | Model | Reasoning | Scope | +| ----------------------- | ------------------------- | --------------- | --------- | ------------------------------------------------------------------------------------------------------ | +| Style/maintainability | `/root/t0005_style` | `gpt-5.6-terra` | high | Internal validator seam, generic propagation, tests, and removed patch machinery | +| TypeScript/API | `/root/t0005_api` | `gpt-5.6-terra` | high | Public inference/declarations, Buf descriptor/message relations, exact option types, and compatibility | +| Performance/reliability | `/root/t0005_reliability` | `gpt-5.6-terra` | high | Direct generation, deterministic checks, validation ordering, recursion boundary, and canonical gates | +| Documentation | `/root/t0005_docs` | `gpt-5.6-terra` | medium | Maintained architecture, contract, contributor/package docs, and agent usability | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ----- | -------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| F-001 | P1 | Reliability | Direct generation enforcement must reject renamed transforms, lifecycle hooks, and untested regression paths. | Corrected: protected commands reject lifecycle siblings and canonical verification runs the regression suite. | +| F-002 | P2 | Maintainability | The compile-time mismatched-pair regression executes invalid validation at module load. | Accepted: keep the TypeScript check in an uncalled function/block. | +| F-003 | P2 | Durable evidence | The task record's unlabeled coverage numbers are stale baseline values beside fresh implementation evidence. | Accepted: label baseline and implementation coverage unambiguously. | +| F-004 | P2 | TypeScript/API docs | The public declaration uses built-in `NoInfer`, but consumer docs omit the TypeScript 5.4 compiler minimum. | Accepted: document TypeScript >=5.4 in package and user prerequisites. | +| F-005 | P2 | Documentation | Architecture docs omit the explicit lack of depth, cycle, and violation budgets and the invalidity of cyclic ad hoc JS objects. | Accepted: document the approved boundary precisely. | +| F-006 | P2 | Protocol records | Task reviewer statuses remain `Ready` after the completed review wave. | Accepted: mark all reviewers complete and closed. | + +## Security Disposition + +N/A unless the implementation introduces an unplanned trust boundary, +credential flow, install hook, or publication behavior. + +## Convergence + +- Complete review wave collected before correction dispatch. +- Style/maintainability: F-002 and F-003. +- TypeScript/API: F-004. +- Performance/reliability: F-001. +- Documentation/reader: F-005 and F-006. +- Six P2 findings are accepted for one correction batch; no P0/P1 finding. +- Narrow re-review confirms F-002 through F-006. +- Final performance/reliability re-review confirms F-001 clean: every protected + command rejects lifecycle siblings, the bypass regressions are focused and + canonical, and generation/determinism behavior is unchanged. +- All concerns are clean; no P0-P2 finding remains. + +## Correction Evidence + +- F-001: deterministic generation now requires the exact direct Buf commands + and canonical Buf generator configurations, rejects `pre`/`post` lifecycle + siblings for every protected command, and runs its four Node regressions in + canonical `pnpm verify`. +- F-002: the mismatched generated schema/message `@ts-expect-error` is now in + an uncalled function and remains covered by the validation test typecheck. +- F-004: package and user documentation now require TypeScript 5.4 or later + because the public declaration uses built-in `NoInfer`. +- F-005: architecture documentation now states that no depth, cycle, or + violation budget exists and cyclic ad hoc JavaScript objects are outside the + valid Protobuf model. diff --git a/build-protocol/reviews/T-0006.md b/build-protocol/reviews/T-0006.md new file mode 100644 index 0000000..442fc68 --- /dev/null +++ b/build-protocol/reviews/T-0006.md @@ -0,0 +1,50 @@ +# T-0006 Review Log + +Status: Converged +Baseline: `69a885f2f8f8708e93821e444be2d1c95eff38d6` +Review head: `913f6bbb11f84b142e856e8368b55d7a6a378460` + +## Review Assignments + +| Role | Expected model | Reasoning | Concern | +| ---------------------------------- | --------------- | --------- | ---------------------------------------------------------------------------------------------- | +| `typescript_api_reviewer` | `gpt-5.6-terra` | high | Public API, Buf descriptors, ESM declarations, and Proto compatibility | +| `performance_reliability_reviewer` | `gpt-5.6-terra` | high | Temporal arithmetic, clock cardinality, malformed inputs, tzdb behavior, and bounded execution | +| `style_maintainability_reviewer` | `gpt-5.6-terra` | high | Correctness, contract coverage, validator ordering, and maintainability | +| `documentation_reviewer` | `gpt-5.6-terra` | medium | Package/example/user/agent documentation and root README restraint | +| `security_reviewer` | `gpt-5.6-terra` | high | Runtime dependency and untrusted temporal/zone input | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ------ | -------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T6-R1 | P1 | Reliability / correctness | Singular default temporal messages are evaluated instead of skipped. | Accepted; use descriptor equality only for singular values and add default/collection fixtures. | +| T6-R2 | P1 | Reliability / correctness | BCE civil-date era arithmetic is one day early because floor division is adjusted twice. | Accepted; correct floor arithmetic and cover BCE UTC/offset forms. | +| T6-R3 | P1 | Compatibility | Java year and `ZoneOffset` bounds are not enforced, while Temporal's zoned range is narrower than Java/Spine. | Accepted, then superseded by T6-R14: enforce Java component/offset bounds and the effective JVM Validation `Timestamp` domain; no out-of-range zone projection is exposed. | +| T6-R4 | P2 | Reliability | The system clock creates negative nanos for pre-epoch millisecond values. | Accepted; use Euclidean division and add a regression. | +| T6-R5 | P1 | Contract evidence | Exact gap/overlap, clock cardinality, defaults, diagnostics, deprecated `msg_format`, nested leaves/order, and example scenarios lack discriminatory tests. | Accepted; add the full approved fixture matrix. | +| T6-R6 | P1 | Public API | Unsupported-target tests do not assert the public `ValidationConfigurationError` shape. | Accepted; assert code, option, type, and path. | +| T6-R7 | P2 | Security | Unbounded/unsafe zone identifiers can amplify or inject dependency error text through `cause`. | Accepted; bound and syntax-screen IANA IDs, emit a stable cause-free `RangeError`, and test CR/LF and oversized input. | +| T6-R8 | P2 | Maintainability | `(when)` is registered but its validator imports the extension directly. | Accepted; use the closed option registry as the single source. | +| T6-R9 | P1 | Documentation | The normative contract, user setup, package surface, provenance, architecture, contributor guidance, versions, and example description are incomplete or stale. | Accepted; update all maintained documents and make only necessary root README edits. | +| T6-R10 | P1 | Diagnostics | A reviewer requested a `(when)` declaration with no default-message fixture. | Rejected as impossible for the frozen `TimeOption`, which declares a default; the shared envelope's present empty diagnostic is already asserted in `validation-contract.test.ts`. Remove the misleading unused fixture and retain that generic contract test. | +| T6-R11 | P1 | Public API | Unknown serialized `Time.in` enum numbers fell through as `FUTURE`. | Accepted; explicitly allow only undefined/past/future and test `INVALID_OPTION_VALUE` with a generated numeric-enum fixture. | +| T6-R12 | P2 | Documentation | The package supported-family list omitted `(when)` and left its description after development instructions. | Accepted; consolidate `(when)` into the supported surface. | +| T6-R13 | P1 | Contract evidence | The singular-default regression used an absent field rather than an explicitly present `{}` message. | Accepted; assert an explicitly present default is skipped with zero clock reads. | +| T6-R14 | P1 | JVM compatibility | Direct and converted values did not enforce the range checked by protobuf-java-util `Timestamps.compare`. | Accepted; enforce seconds `[-62135596800, 253402300799]`, nanos, and the same resolved-instant range for every Spine temporal conversion; replace the non-JVM projection claim and tests. | +| T6-R15 | P2 | Documentation | Proto provenance, import dependencies, technical specification, and one contract sentence retained contradictory or stale Time wording. | Accepted; replace contradictions, distinguish required `time_options.proto` from conditional `time.proto`, update the technical spec, and repair the final contract paragraph/heading. | + +## Security Disposition + +Clean after correction. Zone identifiers are bounded and syntax-screened, +dependency failures expose neither raw input nor `cause`, the exact dependency +pin has no lifecycle scripts, and all 12 immutable Proto sources verify. + +## Convergence + +All accepted findings are corrected. Final TypeScript/API, +performance/reliability, style/maintainability, documentation, and security +re-reviews are clean. T6-R10 remains deliberately rejected for the reason +recorded above. Independent `pnpm verify` passed on the final runtime head; +the final one-line documentation cleanup passed formatting and documentation +checks and received a clean documentation confirmation. diff --git a/build-protocol/reviews/T-0007.md b/build-protocol/reviews/T-0007.md new file mode 100644 index 0000000..47c2a15 --- /dev/null +++ b/build-protocol/reviews/T-0007.md @@ -0,0 +1,33 @@ +# T-0007 Review Log + +Task: `build-protocol/tasks/T-0007-ci-doc-dependency/TASK.md` +Diff basis: +`33a0a83c5b8707d5957655f2ece06e28fed09834..e83e9b8` + +## Review Wave + +| Concern | Reviewer | Model | Reasoning | Disposition | +| ----------------------- | ------------------------- | --------------- | --------- | -------------------------------------------------------------------------- | +| Style/maintainability | `/root/t0007_style` | `gpt-5.6-terra` | high | One P2 accepted | +| Documentation | N/A | N/A | N/A | No maintained documentation content changes | +| TypeScript/API | N/A | N/A | N/A | No source, declaration, export, or public API changes | +| Performance/reliability | `/root/t0007_reliability` | `gpt-5.6-terra` | high | Clean | +| Security | N/A | N/A | N/A | Existing exact locked package/version; no graph or runtime exposure change | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ----- | -------- | --------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| T7-R1 | P2 | Style/maintainability | The work log still said the full gate remained, while the completed gate was absent from the task verification table. | Accepted. Recorded the completed canonical gate, coverage, external clean-checkout proof, and remaining work accurately. | + +The production/test/manifest/lockfile diff was otherwise clean. In particular, +the regression reads the root manifest and cannot be masked by stale or hoisted +modules, while the root importer pins the already resolved +`@bufbuild/protobuf` `2.13.0` package. + +## Convergence + +The style reviewer re-reviewed the record-only correction at +`e83e9b8..3525361` and found it clean. Both invoked lanes converged with no +remaining P0-P2 findings; all other canonical concerns have concrete N/A +dispositions. diff --git a/build-protocol/reviews/T-0008.md b/build-protocol/reviews/T-0008.md new file mode 100644 index 0000000..2085488 --- /dev/null +++ b/build-protocol/reviews/T-0008.md @@ -0,0 +1,45 @@ +# T-0008 Review Log + +Task: `build-protocol/tasks/T-0008-node24-actions/TASK.md` +Initial diff basis: +`48a3ccab3d4a09de86af115b03469a078be6b4aa..c80faba3200ec28186b1c35726442f1a7a2bdb31` + +## Review Wave + +| Concern | Reviewer | Model | Reasoning | Disposition | +| ----------------------- | ------------------------- | --------------- | --------- | ------------------------------------------------------------------ | +| Style/maintainability | `/root/t0008_style` | `gpt-5.6-terra` | high | Five accepted P2 findings across the correction cycle; final clean | +| Documentation | N/A | N/A | N/A | No maintained user or package documentation contract changes | +| TypeScript/API | N/A | N/A | N/A | No package source, declaration, export, or public API changes | +| Performance/reliability | `/root/t0008_reliability` | `gpt-5.6-terra` | high | Final semantic-parser correction clean | +| Security | `/root/t0008_security` | `gpt-5.6-terra` | high | Final semantic-parser and lock-integrity correction clean | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ----- | -------- | --------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| T8-R1 | P2 | Style/maintainability | A line regex treated action-looking text inside YAML block scalars as workflow action fields. | Accepted. Semantic YAML parsing distinguishes scalar content from mappings; final re-review clean. | +| T8-R2 | P2 | Security | The line regex missed flow-mapping `uses` fields and could allow an obsolete action reference. | Accepted. Semantic parsing finds quoted, multiline, and flow-style action fields; final security re-review clean. | +| T8-R3 | P2 | Correctness | The partial YAML lexer still missed quoted/nested forms and false-triggered on quoted text. | Accepted. The custom lexer was deleted in favor of `yaml@2.9.0`; reliability and security re-reviews clean. | +| T8-R4 | P2 | Correctness | Recursive traversal falsely classified unrelated keys such as `jobs.*.env.uses` as action uses. | Accepted. Inspection is limited to `jobs.*.uses` and `jobs.*.steps[*].uses`; 16 focused tests and re-review passed. | +| T8-R5 | P2 | Dependency evidence | The direct parser choice lacked traceable source, TypeScript support, and alternatives evidence. | Accepted. Task records now cite the exact source and record Node/TypeScript support and rejected alternatives; clean. | + +## Dependency Disposition + +The workflow guard uses exact root dev dependency `yaml@2.9.0`, sourced from +. Registry metadata checked on +2026-07-29 reports Node >=14.6 support, and the package exports bundled +TypeScript declarations. The package was already resolved through Vite; only +the root importer was added. Node has no YAML parser, the custom scanner was +proven incomplete, and promoting the transitive `js-yaml@4.3.0` would add the +same direct dependency contract without bundled types or a smaller lock graph. + +## Convergence + +The final correction range +`12f56faece9d53e7dab4dbfd2846dfb42ba8e54d..78af776fa6b56ff59dc1a86063488a8145a68abe` +was re-reviewed clean for the two remaining maintainability findings. All +invoked lanes converged with no remaining P0-P2 findings. The final independent +`pnpm verify` passed 16 workflow-policy tests, 17 files / 319 runtime tests, +every coverage threshold, deterministic generation, documentation, Proto lint, +build, the compiled example, packed-consumer installation, and Git hygiene. diff --git a/build-protocol/reviews/T-0011.md b/build-protocol/reviews/T-0011.md new file mode 100644 index 0000000..048facb --- /dev/null +++ b/build-protocol/reviews/T-0011.md @@ -0,0 +1,96 @@ +# T-0011 Review Log + +Status: Converged +Baseline: `a34056e7e7f6141116b20c9457863375786aed83` +Reviewed ref: `8e1bad20a89651c8280dbcc55d7db8304d531f47` +Dirty state: Clean + +## Review Assignments + +| Concern | Agent ID | Model | Reasoning | Scope | +| --------------------------- | ---------------------------------- | --------------- | --------- | --------------------------------------------------------------------------------------------------- | +| Style/maintainability | `/root/t0011_style_review` | `gpt-5.6-terra` | high | Schema organization, names, checker design, tests, and scope discipline | +| Documentation | `/root/t0011_docs_review` | `gpt-5.6-terra` | medium | Fresh beginner-reader test, domain wording, comment completeness, spacing, and README presentation | +| TypeScript/API | `/root/t0011_api_review` | `gpt-5.6-terra` | high | Generated message shapes, example wire changes, field options, consumers, and unchanged package API | +| Performance/reliability | `/root/t0011_reliability_review` | `gpt-5.6-terra` | high | Deterministic checker, false positives, coverage, generation, immutable inputs, and gates | +| Final style re-review | `/root/t0011_style_rereview` | `gpt-5.6-terra` | high | F-005 through F-007 checker corrections and maintained scope | +| Final reliability re-review | `/root/t0011_reliability_rereview` | `gpt-5.6-terra` | high | False-negative regressions, structural parsing, and pending completion gate | +| Security | N/A | `gpt-5.6-terra` | high | No dependency, credential, publishing, trust-boundary, or runtime-input security change | + +## Evidence + +| Evidence | Result | +| --------------------- | ----------------------------------------------------------- | +| Implementation report | RED/GREEN and focused checks passed; commit `79e7229` | +| Immutable Proto scope | No changed file under either maintained `proto/spine/` tree | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| ----- | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| F-001 | P1 | Documentation | Package README still uses primitive `int32 id`; neither README teaches the three ID messages, nested initializer shape, or reason for the type. | Accepted | +| F-002 | P1 | Style/reliability | Deterministic checker does not enforce the three ID message definitions and four required/validated accepting fields. | Accepted | +| F-003 | P2 | Reliability | Proto-fence depth counts braces inside comments and quoted strings, allowing false spacing results. | Accepted | +| F-004 | P2 | Style/API/reliability | `Product.category` was made required outside scope and the scenario masks the regression. | Accepted | +| F-005 | P2 | Reliability | `requiredValidatedField` could search past the named message body and accept a primitive or malformed `User.id` when a later message contained a valid `UserId id`. | Accepted | +| F-006 | P2 | Reliability | `protoStructure` stripped only double-quoted strings, so a single-quoted brace could corrupt Proto-fence declaration depth and spacing checks. | Accepted | +| F-007 | P2 | Style/reliability | Both final reviewers found that `protoMessageBody` retained nested declarations, allowing a nested valid `UserId id` to satisfy a malformed direct `User.id`. | Accepted | + +## Correction Batch + +- Accepted findings: F-001 through F-004, deduplicated from the complete wave. +- Rejected findings and reasons: None. +- Verification: Correction owner passed checker regression tests, documentation + check, generation, example tests (10), generated typecheck, immutable Proto + verification (12 files), Proto lint, source conventions, formatting, and + diff checks. +- Re-review: Documentation, style/maintainability, TypeScript/API, and + performance/reliability concerns affected by the corrections will re-open. + +## Final Checker Correction Batch + +- Dispatch: `/root/t0011_checker_correction` as `implementer`, expected + `gpt-5.6-terra` with medium reasoning. +- F-005 RED: `node --test scripts/check-documentation.test.mjs` failed with + `Missing expected exception` after a primitive `User.id` and a later valid + `LaterUser.id` were supplied. +- F-005 GREEN: the checker derives the exact brace-aware named-message body + before matching the required and validated field; the regression test + passed. +- F-006 RED: the same focused test command failed with `Missing expected +exception` after a single-quoted `{` made the spacing violation disappear. +- F-006 GREEN: `protoStructure` tracks the active single- or double-quote + delimiter and ignores escaped characters; the regression test passed. +- Final focused verification: checker tests, the live checker, source + conventions, format check, and `git diff --check` passed. + +## Final Direct-Field Correction Batch + +- Dispatch: `/root/t0011_checker_correction` as `implementer`, expected + `gpt-5.6-terra` with medium reasoning. +- Reviewer agreement: the final style/maintainability and + performance/reliability reviewers independently reported F-007; it is one + accepted P2 finding. +- F-007 RED: `node --test scripts/check-documentation.test.mjs` failed with + `Missing expected exception` after a primitive direct `User.id` and nested + `Counterfeit.UserId id` were supplied. +- F-007 GREEN: the structural matcher retains only text at the named + message's immediate body depth, excluding nested declarations. The focused + checker regression passed while the sibling and quote regressions remained + green. +- Final focused verification: checker tests, the live checker, source + conventions, format check, and `git diff --check` passed. + +## Convergence + +- Style/maintainability: Clean at `8e1bad2`; F-001 through F-007 are resolved + with no new P0/P1/P2 finding. +- Documentation: Clean after the README correction; beginner wording, + declaration comments, spacing, and presentation satisfy the approved scope. +- TypeScript/API: Clean after the schema correction; generated shapes and + consumers match, while the validation package API is unchanged. +- Performance/reliability: Clean at `8e1bad2`; direct-field matching and + single/double-quoted Proto structure are covered deterministically. +- Security: N/A; no security boundary changes. +- Result: review converged. The canonical full verification gate is the next + completion step. diff --git a/build-protocol/skills/EXPECTED_SKILLS.md b/build-protocol/skills/EXPECTED_SKILLS.md new file mode 100644 index 0000000..e85c85d --- /dev/null +++ b/build-protocol/skills/EXPECTED_SKILLS.md @@ -0,0 +1,15 @@ +# Expected Skills + +These skills are commonly applicable to governed work when exposed by the +current session. Availability is checked per task; absence does not authorize +inventing replacements. + +- `using-git-worktrees` +- `implement` +- `test-driven-development` for runtime behavior changes +- `requesting-code-review` +- `verification-before-completion` +- `openai-docs` when changing Codex configuration or durable Codex guidance + +Task records must name selected skills and give a concrete reason for skipping +another apparently relevant skill. diff --git a/build-protocol/tasks/T-0001-protocol-bootstrap/TASK.md b/build-protocol/tasks/T-0001-protocol-bootstrap/TASK.md new file mode 100644 index 0000000..4db2992 --- /dev/null +++ b/build-protocol/tasks/T-0001-protocol-bootstrap/TASK.md @@ -0,0 +1,133 @@ +# T-0001: Protocol And Verification Bootstrap + +Status: Complete +Classification: High-risk +Baseline: `c7527325ce2130e3766bacc6effabc1af238f2b6` +Branch: `task/t-0001-protocol-bootstrap` +Worktree: `.worktrees/t-0001-protocol-bootstrap` +Approved plan: Human-approved on 2026-07-24. + +High-risk reasons: public npm package rename, dependency lock and verification +tooling, CI/publishing-adjacent configuration, and immutable Proto contract +governance. + +## Acceptance Criteria + +- `master` matches fresh origin state and remains untouched by T-0001. +- `dev` is the pushed integration branch. +- Permanent `AGENTS.md`, executable Sol/Terra roles, governing protocol, + templates, task records, and decision records are installed. +- Obsolete local agentic files are removed without touching unrelated user + files. +- All package, workspace, directory, import, and documentation surfaces use + `@spine-event-engine/validation` at `2.0.0-snapshot.5`. +- npm/Jest/CommonJS remain in place. +- A committed npm lockfile, Node/tool pins, PR CI, API docs, coverage ratchet, + generated checks, packaging/consumer checks, and one root verification gate + are operational. +- Upstream Proto inputs are pinned, checksummed, immutable, and exempt only + from incompatible style lint. +- Focused and full checks pass, relevant review converges, the task branch and + merged `dev` are pushed, remote refs match, and all agents/worktrees close. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| --------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------ | +| Adapt Spine TS protocol without its historical/project-specific corpus. | Human answer 1 | Artifact inventory and review | +| Remove obsolete agentic files. | Human answer 1 | Local-state check | +| Use only dispatchable Sol/Terra profiles. | Human answer 2 | `.codex` config validation | +| Create `dev` from fresh remote `master`; integrate there until an explicit master PR request. | Human answer 3 | Git and remote refs | +| Add modern gates and a committed lockfile; defer npm/Jest/CommonJS migration. | Human answer 4 | Package scripts, lock, CI, decisions | +| Rename every surface to `@spine-event-engine/validation`. | Final clarification 1 | Repository search and package smoke | +| Advance to `2.0.0-snapshot.5`. | Final clarification 2 | Metadata and package smoke | +| Keep automatic publishing on `master` push. | Final clarification 3 | Workflow inspection | +| Enforce 80/80/70 baseline and reach 90 before substantial expansion. | Human answer 5 | Jest thresholds and protocol | +| Use documented upstream Proto behavior; freeze and never style-edit copied files. | Human answer 6 | Provenance and checksum gate | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | --------------------------------------------------------------------------------------------------- | +| `using-git-worktrees` | Yes | Approved isolated execution | +| `implement` | Yes | Execute the approved bootstrap plan | +| `openai-docs` | Yes | Current Codex configuration and AGENTS guidance | +| `requesting-code-review` | Yes | Major high-risk task before integration | +| `verification-before-completion` | Yes | Fresh evidence before commit/merge/completion | +| `test-driven-development` | No | No runtime behavior is intentionally changed; configuration checks are added and exercised directly | + +OpenAI Codex manual refreshed on 2026-07-24. Relevant guidance confirmed that +repo instructions belong in concise `AGENTS.md`, project settings in trusted +`.codex/config.toml`, custom role files require `name`, `description`, and +`developer_instructions`, and read-heavy work/reviews are suitable for +subagents. + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ---------------------------------- | ----------------------------- | --------------- | ------------------ | -------------------------------------- | -------- | +| Upstream Proto/provenance research | `/root/proto_provenance` | `gpt-5.6-terra` | medium | Read-only source and Buf strategy | Complete | +| Dependency/tool verification | `/root/tooling_research` | `gpt-5.6-terra` | medium | Read-only retained-stack compatibility | Complete | +| Implementer | Main orchestrator | `gpt-5.6-sol` | medium | Approved bootstrap | Complete | +| Style/maintainability review | `/root/style_review` | `gpt-5.6-terra` | high | Protocol and repository quality | Complete | +| Documentation review | `/root/docs_review` | `gpt-5.6-terra` | medium | Claims and contributor guidance | Complete | +| TypeScript/API review | `/root/typescript_api_review` | `gpt-5.6-terra` | high | Package and public API | Complete | +| Performance/reliability review | `/root/reliability_review` | `gpt-5.6-terra` | high | CI, generation, package reliability | Complete | + +## Scope And Ownership + +- Main orchestrator owns all T-0001 writes. +- Research and review agents are read-only. +- Runtime validation semantics are excluded. +- npm/Jest/CommonJS migration is excluded. +- `master` changes and publication are excluded. + +## Decisions And Questions + +Accepted decisions are D-0001 through D-0010 in `DECISION_LOG.md`. +No unresolved human questions remain. + +## Verification + +| Command | Result | +| ------------------- | ------------------------------------------------------------------------------------ | +| Baseline `npm test` | 11 suites, 232 tests passed | +| `npm ci` | Passed; committed npm lockfile installed | +| `npm run verify` | Passed before review and again on the final corrected tree through all 13 root gates | + +Coverage: 81.88% statements, 71.01% branches, 92.18% functions, and 81.48% +lines. Generated output digest: +`8b58b42ad69650c0b1f40a4b2d39959ab851cfb845f2be33e537e64a911fe552`. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | ----------------------------- | ------------------------------------------------------------- | ------------------------ | +| Style/maintainability | `/root/style_review` | Accepted P1/P2 findings corrected; converged | F-001โ€“F-003, F-007โ€“F-008 | +| Documentation | `/root/docs_review` | Accepted P2 findings corrected; converged | F-004โ€“F-006 | +| TypeScript/API | `/root/typescript_api_review` | Clean | Package/API review | +| Performance/reliability | `/root/reliability_review` | Clean after Node correction re-review | F-002 | +| Security | N/A | Release-readiness review; no release or master push in T-0001 | D-0004 | + +## Integration + +- Task commits: implementation `4d4a04b`, review correction `cd20671`, and + final review cleanup `2653a8a`, plus integration correction `2b6a1bb`. +- Task push: `origin/task/t-0001-protocol-bootstrap` verified at + `2b6a1bbc8214339962995a07dcfc4b1658739229` before this closure record. +- `dev` merge: `7d89378` plus integration correction merge `615649a`. +- Post-merge verification: `npm ci` and full `npm run verify` passed on `dev`. +- Remote refs: initial verified integration was + `dev@615649a0499d3c5763f40451a0c04a4df8621557` and + `task@2b6a1bbc8214339962995a07dcfc4b1658739229`. The closure record itself is + merged and pushed afterward; final refs are verified directly rather than + creating another record-only commit. +- Worktree cleanup: terminal orchestrator action after the closure record is + pushed. + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | Review point | +| -------------------------------------------------------- | -------------------- | ---------------------- | ------------------------- | --------------------------------------- | +| Existing type/runtime debt listed in `TECHNICAL_SPEC.md` | Future approved task | Human-provided roadmap | Deferred | Before related behavioral expansion | +| 90% universal coverage target is not yet met | Future coverage task | `PROJECT_PLAN.md` | Accepted baseline ratchet | Before substantial behavioral expansion | diff --git a/build-protocol/tasks/T-0002-validation-correctness/IMPLEMENTATION_PLAN.md b/build-protocol/tasks/T-0002-validation-correctness/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..0c348a3 --- /dev/null +++ b/build-protocol/tasks/T-0002-validation-correctness/IMPLEMENTATION_PLAN.md @@ -0,0 +1,214 @@ +# T-0002 Implementation Plan + +## Global Constraints + +- The frozen Proto documentation is the runtime contract. Do not edit frozen + Proto files. +- Java-regex compilation, matching, dialect, modifiers, and acceptance behavior + are excluded. Pattern code may only be adapted mechanically to shared + orchestration and violation construction. +- Violation traversal is deterministic: message-level `(require)`, then fields + in declaration order, validators in the fixed order `required`, `pattern`, + `min`, `max`, `range`, `distinct`, `validate`, `goes`, then oneofs in + declaration order for `(choice)`. This order is not a public compatibility + guarantee. +- `FieldPath` contains unqualified Proto field names only. Repeated indices and + map keys are not field names and must not be inserted into the path. +- Every violation uses the root validation-entry `typeName`, the complete field + path, a descriptor-aware packed `fieldValue` when a violating value exists, + and a present `TemplateString`. +- If both custom and default diagnostic text are empty, emit a present empty + `TemplateString`; do not throw, log, or invent fallback wording. +- The public configuration-error code vocabulary is exactly + `UNSUPPORTED_OPTION_TARGET`, `INVALID_OPTION_VALUE`, + `UNKNOWN_FIELD_REFERENCE`, and `INVALID_FIELD_REFERENCE`. Canonical `option` + values omit parentheses. +- One implementation owner sequentially owns all overlapping production code, + tests and fixtures, Jest thresholds, and directly affected README/API docs. +- Use behavior-focused TDD for every runtime change: capture the failing command + and expected failure before production changes, then the passing command. +- Preserve npm, Jest, CommonJS, current dependencies, immutable Proto sources, + and all unrelated user changes. +- Do not add Java-regex dependencies or an engine, change + `spine/time_options.proto`, publish, or merge/push `master`. + +## Task 1: Contract Kernel And Public Diagnostics + +Create the shared validation context and violation-construction kernel: + +- root validation-entry type and current Proto field path; +- descriptor-aware field-value formatting and `Any` packing for scalar wrappers, + bytes, enums, messages, and packable collection elements; +- default/custom/empty `TemplateString` resolution and documented placeholders; +- exported `ValidationConfigurationError` with stable `code`, `option`, + `typeName`, optional `fieldPath`, and optional `cause`. + +`fieldValue` may be absent only when a constraint has no individual offending +value that the frozen violation schema can represent. Do not change frozen +Proto files. + +Acceptance tests must cover primitive, bytes, enum, and message packing; root +type and field path; custom/default/empty templates; and all public error +properties. + +Focused command: + +```bash +npm test --workspace=@spine-event-engine/validation -- --runInBand \ + tests/validation-contract.test.ts tests/basic-validation.test.ts +``` + +## Task 2: Deterministic Orchestration + +Introduce one internal field-validator interface over the shared context and +change `validate()` to the fixed ordering in Global Constraints. Adapt every +implemented option to that interface. The pattern adapter must preserve all +existing regex compilation and matching behavior; only its orchestration and +shared violation envelope may change. + +Acceptance tests must prove field-first ordering, stable validator ordering, +stable repeated-element ordering, root type, and field-name-only paths. + +Focused command: + +```bash +npm test --workspace=@spine-event-engine/validation -- --runInBand \ + tests/ordering.test.ts tests/integration.test.ts +``` + +Depends on Task 1. + +## Task 3: Presence Semantics + +Correct `(required)`, message-level `(require)`, `(goes)`, and oneof `(choice)` +using one descriptor-aware presence implementation: + +- messages and enums are set only when non-default; +- strings and bytes are set only when non-empty; +- repeated fields and maps are set only when non-empty; +- numeric and boolean fields are unsupported targets except when referenced + through a oneof name in `(require)`; +- `(require).fields` supports Proto-documented `&` groups and `|` alternatives, + including oneof names; parentheses are not part of the grammar; +- missing or incompatible targets throw the approved structured configuration + error rather than warning or silently passing. + +Acceptance tests must cover exact default/custom/empty templates, placeholders, +field-name-only paths, packed values, one violation per failed option, invalid +targets, invalid expressions, and oneof presence. + +Focused command: + +```bash +npm test --workspace=@spine-event-engine/validation -- --runInBand \ + tests/required.test.ts tests/required-field.test.ts \ + tests/goes.test.ts tests/choice.test.ts +``` + +Depends on Tasks 1 and 2. + +## Task 4: Exact Numeric Bounds And References + +Correct `(min)`, `(max)`, and `(range)`: + +- complete-string integer and floating grammars; +- scalar range checks and unsigned-negative rejection; +- bigint-safe 64-bit comparison; +- inclusive and exclusive bounds; +- nested numeric field references resolved through descriptors; +- missing, non-numeric, repeated, or map references rejected with the approved + configuration errors; +- documented reference and actual-value placeholders; +- identical behavior for singular and repeated numeric values, using the + collection field path and packing the failing element. + +Acceptance tests must include malformed suffixes, wrong integer/float syntax, +overflow, unsigned negatives, 64-bit precision, nested references, mixed +numeric field types, and reference-driven bounds. + +Focused command: + +```bash +npm test --workspace=@spine-event-engine/validation -- --runInBand \ + tests/min-max.test.ts tests/range.test.ts +``` + +Depends on Tasks 1 through 3. + +## Task 5: Buf-Equality Distinct + +Correct `(distinct)` with Buf equality: + +- `scalarEquals()` for scalar and bytes values; +- numeric equality for enum values; +- `equals()` with the value message schema for message values; +- equality classes retained in first-occurrence order; +- exactly one violation per class occurring more than once; +- collection field path, one packed duplicate representative, the whole + collection in `${field.value}`, and a singleton duplicate list in + `${field.duplicates}`. + +Acceptance tests must cover repeated and map values, structural messages, +bytes, bigint, and `[A, A, A, A, B, B, C]`. + +Focused command: + +```bash +npm test --workspace=@spine-event-engine/validation -- --runInBand \ + tests/distinct.test.ts +``` + +Depends on Tasks 1 and 2 and follows Task 4 to preserve one writer. + +## Task 6: Leaf-Only Nested Validation + +Correct `(validate)`: + +- recurse with the original root context; +- prefix field-name-only paths for singular, repeated, and map values; +- propagate leaf violations only; +- singular absent/default messages remain valid; +- repeated and map default elements are validated; +- known packed `Any` messages are unpacked and validated using a registry built + from the root schema file and dependency descriptor closure; +- empty and unknown `Any` values remain valid; +- preserve packed leaf field values and messages. + +Acceptance tests must cover root `typeName`, complete paths, absence of a +synthetic parent violation, singular/repeated/map messages, and known, +unknown, and empty `Any`. + +Focused command: + +```bash +npm test --workspace=@spine-event-engine/validation -- --runInBand \ + tests/validate.test.ts tests/integration.test.ts +``` + +Depends on all prior semantic tasks. + +## Task 7: Coverage, Documentation, And Gates + +Add branch-focused behavior tests until statements, branches, functions, and +lines are each at least 90%, then commit global Jest thresholds of 90 for all +four metrics. + +Update README and API-facing documentation for corrected option semantics, +structured configuration errors, deterministic ordering, leaf-only recursion, +correct violation values, and the explicit unresolved Java-regex limitation. +Do not claim regex parity. + +Before specialist review, run: + +```bash +npm run typecheck:generated +npm run test:coverage +``` + +After the complete specialist review and accepted correction batch, run: + +```bash +npm run verify +``` + +Depends on all prior tasks. diff --git a/build-protocol/tasks/T-0002-validation-correctness/TASK.md b/build-protocol/tasks/T-0002-validation-correctness/TASK.md new file mode 100644 index 0000000..b86cad6 --- /dev/null +++ b/build-protocol/tasks/T-0002-validation-correctness/TASK.md @@ -0,0 +1,193 @@ +# T-0002: Correct Validation Semantics And Reach 90% Coverage + +Status: Complete +Classification: High-risk +Baseline: `09b94d03828fb6f1ed264398dced327dbdaa67b5` +Branch: `task/t-0002-validation-correctness` +Worktree: `.worktrees/t-0002-validation-correctness` +Approved plan: Human approval in the Codex task on 2026-07-24 + +## Acceptance Criteria + +- `validate(schema, message)` emits violations in deterministic field-declaration + order, with a stable validator order within each field. +- All affected violations use the root validation-entry type, the complete + Proto field path, a correctly packed `field_value`, and the documented + default or custom message placeholders. +- Missing custom and default messages produce a present, empty + `TemplateString`; they do not throw and do not invent fallback wording. +- `ValidationConfigurationError` is public and exposes a stable `code`, + `option`, `typeName`, optional `fieldPath`, and optional `cause`. +- `(required)`, `(require)`, `(goes)`, `(choice)`, `(min)`, `(max)`, and + `(range)` follow the frozen `spine/options.proto` documentation for the + implemented option surface. +- `(distinct)` emits one violation per duplicated equality class. It uses + Buf's Protobuf equality, keeps the collection field path, packs the + duplicate as `field_value`, supplies the full collection as + `${field.value}`, and supplies a singleton duplicate list as + `${field.duplicates}`. +- `(validate)` propagates leaf violations only, preserving the root type and + prefixing the complete nested field path for singular, repeated, map, and + supported `Any` values. +- Java `Pattern` compatibility is not changed in this task and is recorded as + an advisory unresolved question. Documentation does not claim full parity. +- Global Jest coverage is at least 90% for statements, branches, functions, + and lines, enforced by committed thresholds. +- Relevant focused checks, specialist review, and `npm run verify` pass before + integration. The task branch and merged `dev` are pushed and remote refs are + verified. `master` remains untouched. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| -------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------- | +| Proto-defined behavior overrides known-wrong snapshot behavior. | Human decision in the Codex task | Contract tests against frozen Proto documentation | +| Violation order follows field declaration order; exact ordering is not a public compatibility guarantee. | Human decision in the Codex task | Multi-field ordering tests | +| `[A, A, A, A, B, B, C]` produces one duplicate violation for `A` and one for `B`. | Human decision in the Codex task | Repeated and map distinct tests | +| Buf equality must compare Protobuf messages. | Human decision in the Codex task | Structural message and bytes duplicate tests | +| Nested validation follows JVM tests and emits leaf violations only. | Human decision in the Codex task | Nested singular/repeated/map/Any tests | +| `ValidationConfigurationError` exposes structured public diagnostics. | Human decision in the Codex task | TypeScript API and runtime tests | +| A missing `default_message` emits an empty diagnostic because the JVM accepts it. | Human decision in the Codex task | Empty-template regression test | +| Java-regex compatibility is postponed and must be written down as an open question. | Human decision in the Codex task | `questions/UNRESOLVED.md` and public docs | +| Do not integrate a large regex dependency or build a project-owned regex engine in this task. | Human decision in the Codex task | Dependency and diff review | +| Reach at least 90% in every coverage dimension. | Human decision and D-0006 | Final coverage output and thresholds | +| Keep npm, Jest, and CommonJS until separately approved migrations. | Human decision from T-0001 | Package/tooling diff review | +| Work from `dev`; do not merge or push `master`. | Human decision and branch policy | Git history and remote-ref verification | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | ------------------------------------------------------------------------------------------------- | +| `codebase-design` | Yes | Place ordering, diagnostics, equality, and recursion behind a deep internal validation interface. | +| `using-git-worktrees` | Yes | High-risk work requires an isolated task branch and worktree. | +| `implement` | Yes | Execute the approved runtime and test changes. | +| `test-driven-development` | Yes | Every semantic correction begins with a focused failing behavior test. | +| `subagent-driven-development` | Yes | Execute reviewable slices with one production-code writer and task review. | +| `requesting-code-review` | Yes | Required per-slice and whole-branch review. | +| `verification-before-completion` | Yes | Fresh evidence is required before commits, integration, and completion claims. | +| `executing-plans` | No | This task remains in the current session; subagent-driven development is the matching workflow. | +| `openai-docs` | No | No Codex configuration or OpenAI product guidance changes. | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ------------------------------ | ------------------------------- | --------------- | ------------------ | ------------------------------------------------------------------------------ | ------------------------------------------ | +| Requirements splitting | `/root/requirements_split` | `gpt-5.6-sol` | high | Split the approved high-risk contract work into ordered implementation slices | Complete and closed | +| TypeScript implementation | `/root/implementer_nested` | `gpt-5.6-terra` | medium | Own Task 6 nested-validation production code and focused behavior tests | Task 6 complete and closed | +| Task 1 scoped review | `/root/task1_review` | `gpt-5.6-terra` | high | Contract-kernel spec compliance and code quality | Approved after F-001 through F-003; closed | +| Task 2 scoped review | `/root/task1_review` | `gpt-5.6-terra` | high | Deterministic orchestration spec compliance and code quality | Approved after F-004 through F-006; closed | +| Task 3 scoped review | `/root/task1_review` | `gpt-5.6-terra` | high | Presence semantics, diagnostics, configuration errors, and fixture migration | Approved after F-007 through F-011; closed | +| Task 4 scoped review | `/root/task1_review` | `gpt-5.6-terra` | high | Numeric grammar, precision, references, envelopes, and configuration errors | Approved after F-012 through F-015; closed | +| Task 5 scoped review | `/root/task1_review` | `gpt-5.6-terra` | high | Buf equality, duplicate classes, diagnostics, packing, and unsupported targets | Approved after F-016; closed | +| Task 6 scoped review | `/root/task1_review` | `gpt-5.6-terra` | high | Leaf recursion, root context, message paths, Any registry, and target errors | Approved; closed | +| Task 7 implementation | `/root/implementer_coverage` | `gpt-5.6-terra` | medium | Own branch-focused tests, Jest thresholds, README, and affected API comments | Complete and closed | +| Style/maintainability review | `/root/style_final` | `gpt-5.6-terra` | high | Whole task diff and maintainability | Clean; F-017 resolved; closed | +| Documentation review | `/root/docs_final` | `gpt-5.6-terra` | medium | Proto-aligned claims and unresolved regex status | Clean; F-019 through F-022/F-025; closed | +| TypeScript/API review | `/root/api_final` | `gpt-5.6-terra` | high | Public error, declarations, Buf compatibility, serialized violation shape | Clean; F-017/F-023 resolved; closed | +| Performance/reliability review | `/root/reliability_final` | `gpt-5.6-terra` | high | Ordering, recursion, cache behavior, equality cost, deterministic verification | Clean; F-018/F-024 resolved; closed | +| Final correction batch | `/root/implementer_corrections` | `gpt-5.6-terra` | medium | Resolve accepted whole-branch findings F-017 through F-025 | Complete and closed | + +## Scope And Ownership + +- One implementation owner will own `packages/validation/src`, + `packages/validation/tests`, coverage configuration, and directly affected + documentation. +- The orchestrator owns task records, reviews, Git integration, verification, + and remote synchronization. +- Review agents are read-only. +- Excluded: Java-regex compatibility changes, new regex dependencies, + `spine/time_options.proto`, npm/Jest/CommonJS migration, publication, and + all `master` changes. + +## Decisions And Questions + +- Use Buf `equals()` for message values and Buf `scalarEquals()` for scalar + values in `(distinct)`. +- Keep a present empty `TemplateString` when no diagnostic text is available. +- Make violation creation a single internal responsibility shared by option + implementations. +- Use the exact public error codes `UNSUPPORTED_OPTION_TARGET`, + `INVALID_OPTION_VALUE`, `UNKNOWN_FIELD_REFERENCE`, and + `INVALID_FIELD_REFERENCE`; canonical option names omit parentheses. +- `FieldPath` contains Proto field names only, never repeated indices or map + keys. +- The ordered implementation slices are recorded in + `IMPLEMENTATION_PLAN.md`. +- Questions: See `build-protocol/questions/UNRESOLVED.md`. + +## Verification + +| Command | Result | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Baseline `npm ci` | Passed; 430 packages installed from the lockfile. Existing deprecation and allow-script warnings were emitted. | +| Baseline `npm test` | Passed: 11 suites and 232 tests. | +| Baseline `npm run test:coverage` | Passed: 11 suites and 232 tests; 81.88% statements, 71.01% branches, 92.18% functions, and 81.48% lines. | +| Task 1 focused tests | Passed: 2 suites and 9 tests; package TypeScript compilation and diff whitespace checks also passed. | +| Task 2 focused tests | Passed: affected wave 11 suites and 231 tests; independent focused wave 4 suites and 52 tests. | +| Task 3 focused tests | Passed: focused presence 4 suites and 88 tests; full package 13 suites and 249 tests. | +| Task 4 focused tests | Passed: focused 3 suites and 72 tests; full package 14 suites and 260 tests. | +| Task 5 focused tests | Passed: distinct 33 tests; full package 14 suites and 280 tests. | +| Task 6 focused tests | Passed: validate/integration 57 tests; full package 14 suites and 285 tests. | +| Post-Task-6 coverage | Passed tests; 92.23% statements, 87.37% branches, 94.17% functions, and 93.53% lines. | +| Task 7 90% coverage gate | Passed: 94.72% statements, 91.44% branches, 98.05% functions, and 95.87% lines. | +| Final numeric focus | Passed: 27 tests; generated typecheck and diff whitespace checks passed. | +| `npm run verify` | Passed: all canonical gates, 14 suites/293 tests, and 94.72/91.53/99.03/95.87 coverage. | + +Coverage: fresh T-0002 baseline is 81.88% statements, 71.01% branches, 92.18% +functions, and 81.48% lines. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | -------- | ----------------------------------------------------------------------------------------- | --------- | +| Style/maintainability | Clean | F-017 resolved; no remaining actionable P0-P2 findings. | `35df598` | +| Documentation | Clean | F-019 through F-022 resolved; affected public claims and examples are accurate. | `35df598` | +| TypeScript/API | Clean | F-017 and F-023 resolved; no accidental recursive public API or circular import remains. | `35df598` | +| Performance/reliability | Clean | F-018 and F-024 resolved; numeric and recursion reliability concerns are clean. | `dca5f17` | +| Security | N/A | The task is not a release or security review and does not add an external trust boundary. | D-0004 | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| ----- | -------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| F-001 | P1 | Yes | Resolved in `d4a3f3c`; message-level constraints now use optional field context, an empty present path, `${message.type}`, and no packed value. | +| F-002 | P1 | Yes | Resolved in `d2eb687`; descriptor metadata remains present for an absent value while `${field.value}` and packed `fieldValue` remain absent. | +| F-003 | P1 | Yes | Resolved in `4848a4c`; scalar, list-scalar, and map-scalar `${field.type}` values use canonical Proto spellings. | +| F-004 | P1 | Yes | Resolved in `b831f22`; `(choice)` is message-level with an empty `FieldPath`, with the oneof name only in `${group.path}`. | +| F-005 | P2 | Yes | Resolved in `b831f22`; tests distinguish same-field validator order and exact repeated-element output order. | +| F-006 | P1 | Yes | Resolved in `b831f22`; strict packing is restored and the adapter selects an offender or no collection-level value. | +| F-007 | P1 | Yes | Resolved in `b5d72a7`; enum presence requires a numeric non-zero value and missing enum coverage passes. | +| F-008 | P2 | Yes | Resolved in `b5d72a7`; required bytes/maps have empty/non-empty coverage and exact absence envelopes. | +| F-009 | P2 | Yes | Resolved in `b5d72a7`; goes companion errors and selected zero/false choice cases are covered. | +| F-010 | P2 | Yes | Resolved in `95c520d`; all Task 3 production sources use the complete standard Apache header. | +| F-011 | P2 | Yes | Resolved in `cd93dbf`; boolean rejection and the complete invalid require-grammar boundary are covered. | +| F-012 | P1 | Yes | Resolved in `4339341`; range diagnostics preserve declared text and annotate references in place. | +| F-013 | P2 | Yes | Resolved in `4339341`; the complete numeric edge matrix and full error assertions pass. | +| F-014 | P2 | Yes | Resolved in `4339341`; rewritten min/max and range modules use the complete standard header. | +| F-015 | P2 | Yes | Resolved in `d6dc7a3`; the numeric contract test uses the complete standard test header. | +| F-016 | P1 | Yes | Resolved in `88bc9b3`; duplicate singleton diagnostics use `[A]`/`[B]` and deterministic list/map formatting. | + +## Integration + +- Task commit: reviewed and canonically verified task closure at + `a8e1baf763bd42e7b5e6c7009bd5f2eeaa1c3c69`. +- Task push: `origin/task/t-0002-validation-correctness` verified at + `a8e1baf763bd42e7b5e6c7009bd5f2eeaa1c3c69` before this closure record. +- `dev` merge: `86d6092e94e8fb7b5e6b4778237d65acfc927c2c`. +- Post-merge verification: full `npm run verify` passed on merged `dev`, with + 14 suites, 293 tests, and all four coverage dimensions above 90%. +- Remote refs: initial verified integration was + `dev@86d6092e94e8fb7b5e6b4778237d65acfc927c2c` and + `task@a8e1baf763bd42e7b5e6c7009bd5f2eeaa1c3c69`. This closure record is + merged and pushed afterward; final refs are verified directly rather than + creating another record-only commit. `master` remained + `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Worktree cleanup: terminal orchestrator action after this closure record is + pushed. + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | Review point | +| ----------------------------------------------------------------------- | -------------------- | ----------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------- | +| Java `Pattern` and ECMAScript `RegExp` are not behaviorally equivalent. | Future approved task | Q-0001 | Deferred by human; do not add an engine or dependency now. | After non-regex correctness work, before claiming full pattern parity | +| Recursive validation resource limits remain undefined. | Future approved task | Technical specification | Do not invent a limit in T-0002. | Before ready-for-use release | diff --git a/build-protocol/tasks/T-0003-example-and-docs/TASK.md b/build-protocol/tasks/T-0003-example-and-docs/TASK.md new file mode 100644 index 0000000..b968330 --- /dev/null +++ b/build-protocol/tasks/T-0003-example-and-docs/TASK.md @@ -0,0 +1,138 @@ +# T-0003: Modernize The Example And Documentation + +Status: Complete +Classification: Standard +Baseline: `d7cfbf74882801373ea171e47453777729edb572` +Branch: `task/t-0003-example-and-docs` +Worktree: `.worktrees/t-0003-example-and-docs` +Approved plan: Human approval in the Codex task on 2026-07-25 + +## Acceptance Criteria + +- The example uses only the current Proto option contract and namespaced + diagnostic placeholders; the runnable path contains no intentionally invalid + validation declaration. +- Executable examples cover both User and Product schemas and demonstrate + recently corrected presence, exact numeric-bound, distinct, nested leaf-only, + and resolvable `Any` behavior. +- Example logic returns inspectable results behind a small interface; console + output is an adapter over that interface. +- Jest tests execute real generated schemas and assert exact field paths, root + types, diagnostics, duplicate classes, nested leaf-only behavior, supported + `Any`, and the public configuration-error shape. +- Root test and canonical verification scripts execute the example tests, and + GitHub CI exposes validation-package and example-package execution clearly. +- Root and package documentation accurately explain setup, public use, the + supported option contract, diagnostics, traversal, limitations, and + contributor/agent navigation. The root README remains concise. +- A project-owned documentation checker validates local links, compilable + TypeScript examples, public imports, and prohibited stale example syntax. +- Reader testing by a fresh documentation agent finds no material ambiguity or + unsupported behavioral claim. +- `npm run verify` passes with the existing universal 90% validation-package + coverage threshold. The task branch and merged `dev` are pushed; `master` + remains untouched. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| --------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------ | +| Review and modernize the example API. | Human task | Proto/source review and tests | +| Illustrate recently introduced and corrected behavior. | Human task | Executable scenarios and exact assertions | +| Include the example in CI; add tests as needed. | Human task | Root scripts and workflow review | +| Significantly update documentation throughout all packages for humans and agents. | Human task | Documentation review and fresh-agent reader test | +| Keep the root README more-or-less the same. | Human task | Documentation diff review | +| Package identity is `@spine-event-engine/validation`. | Prior human decision | Package metadata and import checks | +| Keep npm, Jest, and the current module-format policy. | Prior human decision | Dependency and configuration diff | +| Work from `dev`; never merge or push `master`. | Branch policy | Git and remote-ref verification | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | --------------------------------------------------------------------------------------------------- | +| `codebase-design` | Yes | Put scenario behavior behind a small result-returning interface and keep console I/O in an adapter. | +| `javascript-testing-patterns` | Yes | Add Jest behavior tests using real generated schemas. | +| `doc-coauthoring` | Yes | Produce structured documentation and finish with fresh-agent reader testing. | +| `using-git-worktrees` | Yes | Standard work is isolated on a task branch and worktree. | +| `implement` | Yes | Execute the approved example, CI, test, and documentation changes. | +| `test-driven-development` | Yes | New scenario behavior and documentation gates begin with observed failing tests. | +| `subagent-driven-development` | Yes | One writer owns overlapping files and specialists review the completed task. | +| `requesting-code-review` | Yes | Required scoped and whole-task review. | +| `verification-before-completion` | Yes | Fresh focused and canonical evidence precede completion claims. | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ------------------------------ | ------------------------------- | --------------- | ------------------ | ---------------------------------------------------------------------- | ------------------- | +| TypeScript implementation | `/root/t0003_implementer` | `gpt-5.6-terra` | medium | Own example schemas/source/tests and CI scripts | Complete and closed | +| Documentation correction | `/root/t0003_docs_implementer` | `gpt-5.6-terra` | medium | Own maintained docs, checker/tests, and permanent baseline corrections | Complete and closed | +| Final correction batch | `/root/t0003_final_corrections` | `gpt-5.6-terra` | medium | Resolve accepted whole-task findings F-001 through F-016 | Complete and closed | +| Style/maintainability review | `/root/t0003_style` | `gpt-5.6-terra` | high | Whole-task maintainability and test quality | Clean and closed | +| Documentation/reader review | `/root/t0003_docs` | `gpt-5.6-terra` | medium | Accuracy, navigation, agent usability, and reader questions | Clean and closed | +| TypeScript/API review | `/root/t0003_api` | `gpt-5.6-terra` | high | Public imports, generated-schema use, package/API claims | Clean and closed | +| Performance/reliability review | `/root/t0003_reliability` | `gpt-5.6-terra` | high | CI determinism, scripts, docs gate, and example execution | Clean and closed | + +## Scope And Ownership + +- One implementation owner owns all overlapping example, CI, tests, package + documentation, TypeDoc comments, and documentation-checker files. +- The orchestrator owns task records, review aggregation, verification, Git + integration, and remote synchronization. +- Review agents are read-only and are closed immediately after reporting. +- Excluded: validation runtime semantic changes, Java-regex compatibility, + `spine/time_options.proto`, npm/Jest/module-format migration, publication, + and all `master` changes. + +## Implementation Plan + +1. Add failing example behavior tests and documentation-gate fixtures, then + implement the testable scenario module and console adapter. +2. Modernize project-owned example Proto declarations and generated use for + current options, placeholders, nested validation, distinct values, and + resolvable `Any`; keep intentionally invalid configuration in a dedicated + test-only fixture. +3. Wire example tests into root scripts, canonical verification, and explicit + CI steps without changing the approved toolchain. +4. Add a curated documentation index, user guide, validation-contract + reference, architecture/contributor guide, and a detailed example guide; + minimally update the root README and align package/TypeDoc/protocol docs. +5. Add deterministic documentation checks for local links, TypeScript snippets, + public imports, and stale syntax. +6. Run focused checks, specialist review, one deduplicated correction wave, + fresh-agent reader testing, and the canonical full gate before integration. + +## Decisions And Questions + +- Runnable examples contain valid schemas only. Invalid option-target behavior + is isolated in a dedicated test fixture and documentation. +- Example tests cross the same result-returning interface used by the console + adapter and use real Buf-generated messages rather than mocks. +- Documentation distinguishes current supported behavior from the unresolved + Java `Pattern` compatibility question. +- No additional human decision is required by the approved plan. + +## Verification + +| Evidence | Result | +| -------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Example behavior | 1 Jest suite / 7 exact generated-schema tests passed | +| Validation coverage | 293 tests; 94.72% statements, 91.53% branches, 99.03% functions, 95.87% lines | +| Documentation gate | Local links, semantic Markdown/TSDoc snippets, public imports, stale syntax, and negative regressions passed | +| Compiled example | Built ESM console adapter executed successfully | +| Specialist review | Style, documentation/fresh-reader, TypeScript/API, and reliability clean through `117d1a5` | +| Independent canonical gate | Fresh `npm run verify` passed on reviewed task head `117d1a5` | + +## Integration + +- Reviewed task closure: `0bf86c68c169923e9885d46e3322f650d03177ed`. +- First `dev` merge: `38ce635427cb4e24ccc6f7361957a518b541ab4c`. +- Post-merge `npm run verify`: Passed all canonical gates, 293 validation + tests, 7 example tests, compiled console execution, and 94.72% / 91.53% / + 99.03% / 95.87% coverage. +- Verified remote refs after the first integration push: + `origin/dev@38ce635427cb4e24ccc6f7361957a518b541ab4c`, + `origin/task/t-0003-example-and-docs@0bf86c68c169923e9885d46e3322f650d03177ed`, + and unchanged + `origin/master@24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- The final closure commit is merged to `dev` after this record is committed + and pushed. diff --git a/build-protocol/tasks/T-0004-spine-ts-toolchain/TASK.md b/build-protocol/tasks/T-0004-spine-ts-toolchain/TASK.md new file mode 100644 index 0000000..f98c7d8 --- /dev/null +++ b/build-protocol/tasks/T-0004-spine-ts-toolchain/TASK.md @@ -0,0 +1,155 @@ +# T-0004: Adopt The Current Spine TS Toolchain + +Status: Complete +Classification: High-risk +Baseline: `d60fa4a2c1e0050c4517b52ca49c8de43cdc7d02` +Branch: `task/T-0004-spine-ts-toolchain` +Worktree: `.worktrees/T-0004-spine-ts-toolchain` +Approved plan: Human approval in the Codex task on 2026-07-28 + +## Acceptance Criteria + +- The workspace uses pnpm `11.9.0`, Node `24.18.0`, TypeScript `6.0.3`, + Vitest `4.1.9` with V8 coverage, ES2024, NodeNext, ESM, strict project + references, and package export maps following the pinned Spine TS reference. +- A committed pnpm lockfile and workspace configuration replace npm workspace + installation and the npm lockfile. +- Jest configuration, dependencies, scripts, and APIs are removed while all + existing validation and example assertions remain behaviorally equivalent. +- CI, publication, generated-source checks, documentation checks, package + checks, and the installed-consumer smoke test execute through pnpm. +- The canonical full gate is `pnpm verify`, enforcing at least 90% statements, + branches, functions, and lines. +- The packed package is consumed as ESM through its public export map. +- Root, package, contributor, architecture, and protocol documentation describe + the current executable toolchain; root README changes remain minimal. +- The reviewed task branch and merged `dev` pass fresh full gates and are + pushed. `master` remains untouched. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| -------------------------------------------------------------------------------------- | -------------------- | ------------------------------------ | +| Match package manager, test runner, and module format used by Spine TS. | Approved plan | Reference pin and configuration diff | +| Perform the approved tasks autonomously and push remote work even before integration. | Human task | Remote-ref verification | +| Do not stop except for a protocol-defined real blocker. | Human task | Work log | +| Update documentation after implementation and keep root README changes necessary only. | Human task | Documentation review | +| Preserve universal 90% coverage before behavioral expansion. | Prior human decision | Vitest coverage report | +| Never merge or push `master`. | Branch policy | Remote-ref verification | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | ------------------------------------------------------------------------------ | +| `executing-plans` | Yes | Execute the approved multi-milestone plan with recorded checkpoints. | +| `subagent-driven-development` | Yes | One writer owns overlapping migration files and specialists review the result. | +| `using-git-worktrees` | Yes | Isolate the high-risk build migration from `dev`. | +| `test-driven-development` | Yes | Preserve behavior while migrating the test harness and consumer contract. | +| `monorepo-management` | Yes | Align workspace, dependency, and task-runner configuration. | +| `javascript-testing-patterns` | Yes | Translate Jest tests to Vitest without weakening assertions. | +| `requesting-code-review` | Yes | Run relevant whole-task specialist review. | +| `verification-before-completion` | Yes | Require fresh focused, canonical, and post-merge evidence. | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| --------------------- | -------------------------- | --------------- | ------------------ | --------------------------------------------------------------------- | ------------------- | +| Requirements split | `/root/t0004_requirements` | `gpt-5.6-sol` | high | Audit the migration sequence and acceptance coverage | Complete and closed | +| Implementation | `/root/t0004_implementer` | `gpt-5.6-terra` | medium | Own all T-0004 production, test, build, CI, and documentation changes | Complete and closed | +| Style review | `/root/t0004_style` | `gpt-5.6-terra` | high | Whole-task maintainability, test parity, and configuration clarity | Complete and closed | +| TypeScript/API review | `/root/t0004_api` | `gpt-5.6-terra` | high | ESM exports, declarations, NodeNext, and package compatibility | Complete and closed | +| Reliability review | `/root/t0004_reliability` | `gpt-5.6-terra` | high | Lock policy, CI, deterministic gates, packaging, and publication | Complete and closed | +| Documentation review | `/root/t0004_docs` | `gpt-5.6-terra` | medium | Maintained docs, commands, navigation, and root README restraint | Complete and closed | + +## Scope And Ownership + +- One implementation owner owns all overlapping T-0004 source, test, + configuration, lockfile, workflow, script, and documentation changes. +- The orchestrator owns task records, review aggregation, verification, Git + integration, remote synchronization, and worktree cleanup. +- Review agents are read-only and are closed immediately after reporting. +- Excluded: runtime semantic changes, type-boundary refactoring assigned to + T-0005, time options assigned to T-0006, Java regex compatibility, + dependency upgrades not required by the migration, and `master`. + +## Implementation Plan + +1. Audit the exact pinned Spine TS tool versions and migration sequence. +2. Establish pnpm and its deterministic lockfile before changing the compiler + or tests; prove frozen installation. +3. Build the strict ES2024/NodeNext project-reference graph and make handwritten + and generated runtime imports NodeNext-safe while retaining compatibility + patching for removal in T-0005. +4. Define the ESM-only package export map, then convert the unchanged validation + and example corpus from Jest to Vitest with universal 90% coverage. +5. Convert every executable pathโ€”scripts, generation checks, package consumer, + CI, and publicationโ€”to pnpm. The installed consumer must import the packed + package only through its public ESM export map. +6. Update maintained toolchain documentation with minimal root README changes. +7. Run focused checks, a complete specialist review wave, one deduplicated + correction batch, the canonical full gate, task push, `dev` integration, + post-merge verification, and remote-ref confirmation. + +## Decisions And Questions + +- Reference Spine TS commit: + `f8a59883e71db0d9f9f0854039c313dbbce61801`. +- Retain current Buf versions unless the toolchain migration proves them + incompatible. +- T-0004 converts generated compatibility patch scripts to ESM only; T-0005 + owns removing them. +- No material question remains open for T-0004. + +## Verification + +| Command | Result | +| -------------------------------- | ---------------------------------------------------------------------------------- | +| Baseline `npm run verify` | Passed on 2026-07-28: 293 library tests, 7 example tests, all canonical gates | +| `pnpm install --frozen-lockfile` | Passed: 225 lock entries accepted by policy; 189 packages installed with integrity | +| Implementation `pnpm verify` | Passed: 15 files and 300 tests; all canonical pnpm/ESM gates | + +Coverage: migrated Vitest/V8 result is 93.85% statements, 91.36% branches, +99.01% functions, and 95.15% lines. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | ------------------------- | ---------------------------------------------------- | ---------- | +| Style/maintainability | `/root/t0004_style` | Clean after F-004 correction | Review log | +| Documentation | `/root/t0004_docs` | Clean after F-006 and F-007 correction | Review log | +| TypeScript/API | `/root/t0004_api` | Clean after F-005 and F-006 correction | Review log | +| Performance/reliability | `/root/t0004_reliability` | Clean after F-001 through F-004 correction | Review log | +| Security | N/A | No new trust boundary or credential flow is planned. | D-0004 | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| ----- | -------- | --------- | --------------------------------------------------------------------------------------- | +| F-001 | P1 | Yes | Confirmed by narrow re-review | +| F-002 | P1 | Yes | Confirmed by narrow re-review | +| F-003 | P2 | Yes | Confirmed by narrow re-review | +| F-004 | P2 | Yes | Complete: both patchers have cross-platform guards and canonical negative/success tests | +| F-005 | P1 | Yes | Confirmed by narrow re-review | +| F-006 | P2 | Yes | Complete: technical, package, and user docs state ESM-only compatibility | +| F-007 | P2 | Yes | Confirmed by narrow re-review | + +## Integration + +- Reviewed task head and push: + `origin/task/T-0004-spine-ts-toolchain@82452226a0538930984bdfbdea13acf868dd85c0`. +- `dev` merge: + `5e667ecee5e7177e260b52af728a5ed6979b016a`. +- Post-merge verification: Fresh `pnpm verify` passed 15 Vitest files / 300 + tests, four patcher contract tests, universal 90% coverage, immutable Proto, + strict TypeScript, lint/format, documentation, deterministic generation, + build, compiled example, packed ESM consumer, and Git hygiene. +- Remote refs: Verified after pushing the integration closure. +- Worktree cleanup: + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | Review point | +| ------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------- | ----------- | ------------------ | +| ESM or TS6 exposes latent package-boundary assumptions. | T-0004 | Focused package consumer and API review | Open | Before integration | +| Toolchain changes mask test-behavior loss. | T-0004 | Assertion inventory and coverage comparison | Open | Before review | +| TS6 strictness, generated import extensions, V8 coverage, or `workspace:*` packing exposes migration defects. | T-0004 | Dependency-ordered focused gates | Open | Before review | diff --git a/build-protocol/tasks/T-0005-runtime-architecture/TASK.md b/build-protocol/tasks/T-0005-runtime-architecture/TASK.md new file mode 100644 index 0000000..63c25c1 --- /dev/null +++ b/build-protocol/tasks/T-0005-runtime-architecture/TASK.md @@ -0,0 +1,145 @@ +# T-0005: Strengthen Runtime Architecture Boundaries + +Status: Complete +Classification: High-risk +Baseline: `df8cbb187488f4f9bc170dcc226f18eab27f9f8f` +Branch: `task/T-0005-runtime-architecture` +Worktree: `.worktrees/T-0005-runtime-architecture` +Approved plan: Human approval in the Codex task on 2026-07-28 + +## Acceptance Criteria + +- Validation schema, message, and registry boundaries use specific generic + Protobuf-ES types; avoid internal `any`. +- Generated compatibility patch scripts are removed. +- Project code aliases the generated `require` extension as `requireFields` + at import sites without modifying generator output. +- The fixed validator sequence remains internal behind a small adapter; no + unsupported public validator-extension API is introduced. +- Runtime behavior and the universal 90% coverage gate remain unchanged. +- No recursion, depth, or violation budget is invented because JVM Validation + defines none and cyclic JavaScript objects are outside the valid Proto model. +- Architecture, contract, and contributor documentation reflect the resulting + boundaries with only necessary root README changes. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| ----------------------------------------------------------------------------------- | ---------------------------- | -------------------------------------- | +| Match JVM Validation where specifically requested. | Human decision | Focused JVM comparison notes and tests | +| Do not invent recursion limits absent from JVM behavior. | Approved analysis | Architecture review | +| Work autonomously, push the task and integration refs, and keep `master` untouched. | Human task and branch policy | Remote-ref verification | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| --------------------- | -------------------------- | --------------- | ------------------ | -------------------------------------------------------------------------- | ------------------- | +| Requirements split | `/root/t0005_requirements` | `gpt-5.6-sol` | high | Resolve the Protobuf-ES generic type model and safe patch-removal sequence | Complete and closed | +| Implementation | `/root/t0005_implementer` | `gpt-5.6-terra` | medium | Own T-0005 runtime, generation, tests, scripts, and maintained docs | Complete and closed | +| Style review | `/root/t0005_style` | `gpt-5.6-terra` | high | Internal seam, type propagation, tests, and patcher removal | Complete and closed | +| TypeScript/API review | `/root/t0005_api` | `gpt-5.6-terra` | high | Public generic inference, declarations, option types, and compatibility | Complete and closed | +| Reliability review | `/root/t0005_reliability` | `gpt-5.6-terra` | high | Direct generation, determinism, ordering, recursion boundary, and gates | Complete and closed | +| Documentation review | `/root/t0005_docs` | `gpt-5.6-terra` | medium | Architecture/contract/contributor/package accuracy and agent usability | Complete and closed | + +## Scope And Ownership + +- Included: validation runtime type boundaries, registry typing, internal + validator assembly, generated import aliases, tests, and maintained docs. +- Excluded: public validator extensibility, behavioral validation changes, + time options, Java regex compatibility, and `master`. + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | --------------------------------------------------------------------------------- | +| `executing-plans` | Yes | Execute the approved architecture-debt milestone with checkpoints. | +| `subagent-driven-development` | Yes | Keep one writer across overlapping runtime and generation seams. | +| `using-git-worktrees` | Yes | Isolate the high-risk public typing and generation refactor. | +| `test-driven-development` | Yes | Preserve runtime and generated-source behavior while changing structure. | +| `codebase-design` | Yes | Deepen the internal validation and registry modules without public extensibility. | +| `typescript-advanced-types` | Yes | Use precise Protobuf-ES generics without assertions that erase the contract. | +| `requesting-code-review` | Yes | Require maintainability, API, and reliability review. | +| `verification-before-completion` | Yes | Require fresh focused, canonical, and post-merge evidence. | + +## Implementation Plan + +1. Use `S extends DescMessage` with `NoInfer>` for the public + `validate()` relationship. Use `DescMessage`, `Message`, and `Registry` at + erased descriptor/registry seams; add a compile-time mismatched-pair + regression. +2. Propagate precise message/schema types through validation context, + orchestration, nested validation, option validators, and the option registry; + remove internal `any` while retaining only documented test casts for invalid + schema fixtures. +3. Remove both generated compatibility patchers and their canonical fixture + suite. Add `import_extension=js` to validation source/test Buf generation + and import generator-owned `require` under the local alias `requireFields`. +4. Keep the fixed validator sequence private behind the existing small adapter; + do not expose registry or extension hooks. +5. Update architecture, contract, contributor, package, TypeDoc, and protocol + documentation with necessary-only root README changes. +6. Run focused generation/type/API tests, the full specialist review wave, one + deduplicated correction batch, fresh `pnpm verify`, task push, `dev` + integration, post-merge verification, and remote-ref confirmation. + +## Decisions And Questions + +- JVM recursion contains no depth, cycle, or violation budget; T-0005 adds none. +- Valid Protobuf messages are the supported object graph. Cyclic ad hoc + JavaScript objects are outside the contract. +- The validator sequence is internal and fixed; public extensibility remains + explicitly excluded. +- The closed option registry uses a keyed generic lookup returning + `OptionRegistry[N]`; it never returns `undefined` and must preserve each + extension's value type. +- One localized descriptor-driven field reader may bridge `Message` to unknown + field values. Do not impose a string index signature on generated messages. +- No material human question remains open. + +## Verification + +| Command | Result | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Baseline `pnpm verify` | Passed: 15 files / 300 tests, four patcher contract tests, all canonical gates | +| Focused implementation | Passed: typecheck, 14 files / 293 tests, lint, formatting, deterministic generation | +| Implementation `pnpm verify` | Passed: 15 files / 300 tests; 94.07% statements, 91.56% branches, 99.03% functions, 95.40% lines | +| Correction `pnpm verify` | Passed: 15 files / 300 tests; 94.07% statements, 91.56% branches, 99.03% functions, 95.40% lines | +| Residual F-001 `pnpm verify` | Passed: four generation-guard tests and 15 files / 300 tests; 94.07% statements, 91.56% branches, 99.03% functions, 95.40% lines | + +Baseline coverage: 93.85% statements, 91.36% branches, 99.01% functions, and +95.15% lines. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | ------------------------- | --------------------------------------------------------------------------- | ---------- | +| Style/maintainability | `/root/t0005_style` | Clean after F-002 and F-003 correction | Review log | +| Documentation | `/root/t0005_docs` | Clean after F-005 and F-006 correction | Review log | +| TypeScript/API | `/root/t0005_api` | Clean after F-004 correction | Review log | +| Performance/reliability | `/root/t0005_reliability` | Clean after F-001 correction | Review log | +| Security | N/A | No new trust boundary, credential flow, or publication behavior is planned. | D-0004 | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| ----- | -------- | --------- | ------------------------------------------------------------------------ | +| F-001 | P1 | Yes | Corrected: lifecycle siblings rejected and guard tests wired into verify | +| F-002 | P2 | Yes | Complete: compile-time fixture is uncalled | +| F-003 | P2 | Yes | Complete: baseline coverage labeled explicitly | +| F-004 | P2 | Yes | Complete: TypeScript >=5.4 documented | +| F-005 | P2 | Yes | Complete: explicit recursion/cycle boundary documented | +| F-006 | P2 | Yes | Complete: reviewer statuses corrected | + +## Integration + +- Task head and push: + `origin/task/T-0005-runtime-architecture@0de3940c93924b2af094f9a0908ab89a67888860`. +- `dev` merge: + `b4b2f79d6857d034fd23c862269e0c5db46f18fb`. +- Post-merge verification: Fresh `pnpm verify` passed four structural + generation-guard tests, 15 Vitest files / 300 tests, universal 90% coverage, + immutable Proto, direct deterministic generation, strict TypeScript, + lint/format, docs, build, compiled example, packed ESM consumer, and Git + hygiene. +- Remote refs: Verified after pushing the integration closure. +- Worktree cleanup: diff --git a/build-protocol/tasks/T-0006-time-options/TASK.md b/build-protocol/tasks/T-0006-time-options/TASK.md new file mode 100644 index 0000000..578e6be --- /dev/null +++ b/build-protocol/tasks/T-0006-time-options/TASK.md @@ -0,0 +1,188 @@ +# T-0006: Implement Spine Time `(when)` Validation + +Status: Complete +Classification: High-risk +Baseline: `69a885f2f8f8708e93821e444be2d1c95eff38d6` +Branch: `task/T-0006-time-options` +Worktree: `.worktrees/T-0006-time-options` +Approved plan: Human approval in the Codex task on 2026-07-28 + +## Acceptance Criteria + +- Freeze immutable `spine/time_options.proto` and required `spine/time.proto` + inputs at Spine Time commit + `4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc`, recording raw URLs, retrieval + date, destinations, and SHA-256 checksums. +- Implement `(when)` for Google `Timestamp` and Spine `YearMonth`, + `LocalDate`, `LocalDateTime`, deprecated `OffsetDateTime`, and + `ZonedDateTime`; reject unsupported option targets. +- Match approved JVM behavior for undefined options, equal-now boundaries, + singular defaults, repeated/map defaults, one violation per offending + element, field paths and values, template selection, unsupported + placeholders, and conversion errors. +- Use Java-compatible IANA overlap and gap behavior for `ZonedDateTime` + without introducing a broad dependency; document runtime tzdb dependence. +- Add exhaustive tests while preserving at least 90% statements, branches, + functions, and lines. +- Add time-based options to the runnable example and update maintained user, + contract, architecture, contributor, example, API, and protocol + documentation. Root README changes remain necessary-only. +- Advance every workspace package consistently to + `2.0.0-snapshot.6`. +- The reviewed task branch and merged `dev` pass fresh full gates and are + pushed. `master` remains untouched. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| ----------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------- | +| JVM Time may be used and runtime behavior must match Spine JVM Validation. | Human decision | JVM comparison notes and behavior tests | +| Proto documentation remains the primary immutable contract source. | Prior human decision | Provenance and checksum gate | +| Include time-based options in the example. | Human task | Example tests and compiled run | +| Silently emit an empty diagnostic if `default_message` is absent and the JVM accepts the declaration. | Prior human decision | Exact tests | +| Do not modify frozen Proto style to satisfy Buf. | Prior human decision | Byte checksum and lint configuration | +| Work autonomously, push task/integration refs, and keep `master` untouched. | Human task and branch policy | Remote-ref verification | + +## Approved JVM Comparison Contract + +- `TIME_UNDEFINED` disables validation. +- Equality with the current instant is valid for both past and future. +- Time is read once per scalar value or collection element. +- Singular default values are skipped; repeated and map default-valued + elements are evaluated. +- Each offending collection element produces one violation on the collection + field path with that element as `fieldValue`. +- `YearMonth` maps to its first day at UTC midnight; `LocalDate` to UTC + midnight; `LocalDateTime` to UTC; `OffsetDateTime` uses its explicit offset; + `ZonedDateTime` uses Java-compatible IANA gap and overlap resolution. +- Invalid temporal conversion throws rather than becoming a violation. +- `error_msg` overrides `default_message`; `msg_format` is ignored. +- Unsupported targets and diagnostic placeholders are configuration errors. +- JVM Validation adds no parent summary for nested validation. + +## Dependency Boundary + +Use `temporal-polyfill@1.0.1` only for the small, tree-shakeable +`ZonedDateTime` conversion seam. Do not integrate a broad date/time framework. +Exact historical IANA offsets depend on the runtime tzdb and must be documented. + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ---------------------------- | -------------------------- | --------------- | ------------------ | ----------------------------------------------------------------------------- | -------------------------------------------- | +| Requirements split (initial) | `/root/t0006_requirements` | `gpt-5.6-sol` | high | Split Proto intake, JVM parity, temporal conversion, tests, example, and docs | Interrupted and closed after bounded timeout | +| Requirements split (final) | `/root/t0006_split_final` | `gpt-5.6-sol` | high | Final implementation/test/gate audit of the approved contract | Complete and closed | +| Implementation | `/root/t0006_implementer` | `gpt-5.6-terra` | medium | Own T-0006 Proto, runtime, tests, dependency, example, version, and docs | Complete and closed | +| TypeScript/API review | `/root/t0006_api` | `gpt-5.6-terra` | high | Public API, descriptors, declarations, and Proto compatibility | Complete and closed | +| Reliability review | `/root/t0006_reliability` | `gpt-5.6-terra` | high | Temporal arithmetic, clocks, malformed values, zones, and bounded execution | Complete and closed | +| Security review | `/root/t0006_security` | `gpt-5.6-terra` | high | Dependency, untrusted temporal/zone inputs, provenance | Complete and closed | +| Documentation review | `/root/t0006_docs` | `gpt-5.6-terra` | medium | Maintained user/agent/protocol/package/example documentation | Complete and closed | +| Style/maintainability review | `/root/t0006_style` | `gpt-5.6-terra` | high | Correctness, contract fixtures, ordering, and module ownership | Complete and closed | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | ----------------------------------------------------------------------------- | +| `executing-plans` | Yes | Execute the approved behavior milestone with durable checkpoints. | +| `subagent-driven-development` | Yes | Keep one writer across immutable Proto, runtime, fixtures, example, and docs. | +| `using-git-worktrees` | Yes | Isolate the high-risk serialized and runtime contract change. | +| `test-driven-development` | Yes | Specify JVM-matching time semantics before implementation. | +| `codebase-design` | Yes | Add a narrow temporal-conversion seam and keep validation orchestration deep. | +| `javascript-testing-patterns` | Yes | Cover deterministic clocks, collections, errors, zones, and public examples. | +| `requesting-code-review` | Yes | Require correctness/API/reliability/documentation specialist review. | +| `verification-before-completion` | Yes | Require focused, canonical, package, and post-merge evidence. | + +## Scope And Ownership + +- One implementation owner owns all overlapping frozen Proto intake, + generation configuration, runtime, tests, dependency/lockfile, example, + versions, and maintained documentation. +- The orchestrator owns task/review records, review aggregation, verification, + Git integration, remote synchronization, and worktree cleanup. +- Review agents are read-only and closed immediately after reporting. +- Excluded: Java regex compatibility, unrelated option behavior, public + validator extensibility, recursion budgets, broad date/time frameworks, + publication, and `master`. + +## Implementation Plan + +1. Freeze byte-identical `time_options.proto` and `spine/time/time.proto` in + every package/test Proto module that needs them; update exact provenance, + compilation, lint exceptions, and deterministic generation checks. +2. Add behavior-first fixtures and a deterministic clock seam for scalar, + repeated, and map `(when)` validation across Timestamp and all approved + Spine Temporal messages, including exact diagnostics and configuration + errors. +3. Implement a narrow internal temporal conversion module. Use native + arithmetic for UTC/offset types and `temporal-polyfill@1.0.1` only for + Java-compatible `ZonedDateTime` IANA gap/overlap resolution. +4. Add `(when)` to the fixed internal field-validator sequence and exact option + registry; preserve field order, collection element order, and one violation + per offending element. +5. Advance every package/workspace to `2.0.0-snapshot.6`, add time scenarios to + the tested/compiled example, and update maintained contract, user, + architecture, contributor, example, package, TypeDoc, Proto, and protocol + docs with necessary-only root README changes. +6. Run focused semantic/type/provenance/example checks, a complete specialist + review wave, one deduplicated correction batch, independent `pnpm verify`, + task push, `dev` integration, post-merge verification, and remote-ref + confirmation. + +Test groups must separately cover control flow/clock reads/default handling, +collection violation shape and order, every temporal conversion and invalid +value, diagnostics/configuration errors, nested leaf-only behavior and +validator order, and example execution. New York fixtures pin the Java +compatible gap result `2024-03-10T02:30 -> 07:30Z` and overlap result +`2024-11-03T01:30 -> 05:30Z`. + +## Decisions And Questions + +- Exact upstream checksums: + `time_options.proto=933858bdbb118930a171d9a2383d884b498d7ed465a35664f2f411a72785f5be`; + `spine/time/time.proto=da0e7482c69fb6e735441a4934222c054ec9795d7d4b46347751fccb5f692062`. +- JVM comparison source: + Spine Time `4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc` and JVM Validation + `336d6f2bfab2ca6288283dbb64762456c61b31e0`. +- The approved contract above resolves all material human questions. + +## Verification + +| Command | Result | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Baseline `pnpm verify` | Passed: four generation-guard tests, 15 files / 300 tests, all canonical gates | +| Final focused `(when)` suites | Passed: 18 tests | +| Final independent `pnpm verify` | Passed: 17 files / 319 tests; 94.71% statements, 91.51% branches, 99.19% functions, 95.96% lines; all canonical gates and packed consumer | +| Final documentation correction | Prettier, `pnpm docs:check`, `git diff --check`, and documentation re-review passed | + +Coverage: 94.07% statements, 91.56% branches, 99.03% functions, and 95.40% +lines. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | ------------------------- | ----------- | -------------------------------------------------------------------------------------------- | +| Style/maintainability | `/root/t0006_style` | Clean | Exact JVM Timestamp range, conversion, registry, ordering, and corrected tests reviewed. | +| Documentation | `/root/t0006_docs` | Clean | Contract, imports, provenance, technical spec, package/example, and root README reviewed. | +| TypeScript/API | `/root/t0006_api` | Clean | Descriptor/public error shape, unknown enum handling, package/API surface reviewed. | +| Performance/reliability | `/root/t0006_reliability` | Clean | Defaults, clocks, BCE, ranges, DST gap/overlap, collections, and malformed values reviewed. | +| Security | `/root/t0006_security` | Clean | Dependency, immutable provenance, bounded zone input, and stable cause-free errors reviewed. | + +## Findings + +See the canonical, deduplicated findings and dispositions in +`build-protocol/reviews/T-0006.md`. + +## Integration + +- Task head and push: review/documentation closure + `03f38f042a5f812fa0b6c873baf16f9892a803aa` pushed to + `origin/task/T-0006-time-options`. +- `dev` merge: `7eaf7bfa58944ed72230a332f0bffc201475a4d6` + (`Merge T-0006 Spine Time validation`). +- Post-merge verification: fresh frozen install and `pnpm verify` passed 17 + files / 319 tests, 94.71% statements, 91.51% branches, 99.19% functions, + 95.96% lines, and every canonical gate. +- Remote refs: task branch and updated `dev` pushed and checked directly; + `master` remained `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Worktree cleanup: clean merged task worktree removed after remote + confirmation. diff --git a/build-protocol/tasks/T-0007-ci-doc-dependency/TASK.md b/build-protocol/tasks/T-0007-ci-doc-dependency/TASK.md new file mode 100644 index 0000000..32b7d5c --- /dev/null +++ b/build-protocol/tasks/T-0007-ci-doc-dependency/TASK.md @@ -0,0 +1,127 @@ +# T-0007: Restore Clean-CI Documentation Compilation + +Status: Complete +Classification: Standard +Baseline: `33a0a83c5b8707d5957655f2ece06e28fed09834` +Branch: `task/T-0007-ci-doc-dependency` +Worktree: `.worktrees/T-0007-ci-doc-dependency` +Approved plan: Human instruction to address the attached CI failure on +2026-07-29 + +## Acceptance Criteria + +- A clean pnpm installation provides every external package imported by the + repository-owned documentation compiler. +- The documentation regression suite fails when the root documentation tooling + does not directly declare `@bufbuild/protobuf`. +- `pnpm docs:check` and the canonical `pnpm verify` gate pass after a frozen + lockfile installation. +- The task branch and merged `dev` are pushed, remote refs are confirmed, and + the corresponding GitHub Actions run succeeds. +- `master` remains untouched. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| -------------------------------------------------------- | ------------------- | ------------------------------------------------- | +| Address the CI error that began with T-0004 integration. | Human task | Clean-layout regression and remote Actions result | +| Execute the fix without another approval pause. | Human clarification | Autonomous implementation through remote CI | +| Preserve the established task-branch and `dev` workflow. | Repository protocol | Branch, merge, and remote-ref evidence | +| Do not merge or push `master`. | Branch policy | Remote-ref comparison | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | -------------------------------------------------------------------- | +| `systematic-debugging` | Yes | Reproduce and isolate the known CI regression before changing files. | +| `using-git-worktrees` | Yes | Isolate the shared-tooling and lockfile correction. | +| `test-driven-development` | Yes | Add and observe a failing dependency-ownership regression first. | +| `implement` | Yes | Give one bounded owner the test, manifest, lockfile, and task logs. | +| `subagent-driven-development` | Yes | Use the project implementer and specialist review roles. | +| `requesting-code-review` | Yes | Review the complete immutable task diff before integration. | +| `verification-before-completion` | Yes | Require fresh focused, full-gate, and remote-CI evidence. | +| `openai-docs` | No | No Codex configuration or durable Codex guidance changes. | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ---------------------------- | ------------------------- | --------------- | ------------------ | ------------------------------------------------------ | --------- | +| Implementation | `/root/t0007_implementer` | `gpt-5.6-terra` | medium | Own regression test, root manifest, lockfile, and logs | Completed | +| Style/maintainability review | `/root/t0007_style` | `gpt-5.6-terra` | high | Test quality, dependency ownership, minimality | Completed | +| Reliability review | `/root/t0007_reliability` | `gpt-5.6-terra` | high | Clean-install determinism and CI-path reliability | Completed | + +## Scope And Ownership + +- The implementation owner owns `scripts/check-documentation.test.mjs`, + `package.json`, `pnpm-lock.yaml`, this task record, and the T-0007 work log. +- The orchestrator owns review aggregation, final verification, Git + integration, remote synchronization, Actions confirmation, and cleanup. +- Excluded: workflow restructuring, Node or pnpm upgrades, package migrations, + public runtime behavior, documentation content, publication, and `master`. + +## Decisions And Questions + +- Root cause: T-0004 replaced npm workspace hoisting with pnpm's isolated + layout. The root documentation compiler directly consumes + `@bufbuild/protobuf`, but the root manifest did not declare it. +- Existing stale npm-hoisted files masked the defect locally. A clean temporary + layout reproduced the attached CI error exactly. +- Declare the already locked `@bufbuild/protobuf` `2.13.0` as a root + development dependency. Do not add a TypeScript path into package-local + `node_modules`, because that would depend on package-manager layout. +- No material questions remain. + +## Verification + +| Command | Result | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `node --test scripts/check-documentation.test.mjs` (RED) | Failed as expected: root `devDependencies` lacks `@bufbuild/protobuf` `2.13.0`. | +| `pnpm install --frozen-lockfile` | Passed; frozen lockfile supplied the new direct root development dependency. | +| `pnpm docs:check` | Passed: regression suite, TypeDoc, and maintained documentation checker. | +| `pnpm format:check` | Passed after formatting the two T-0007 durable records. | +| `git diff --check` | Passed. | +| `pnpm verify` | Passed: all canonical gates, 17 files / 319 tests, and packed consumer. | +| External clean checkout: frozen install, generation, and `pnpm docs:check` | Passed without access to the parent checkout or stale root modules. | + +Coverage: 94.71% statements, 91.51% branches, 99.19% functions, and 95.96% +lines. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------- | +| Style/maintainability | `/root/t0007_style` | Clean | Accepted record correction re-reviewed clean; dependency ownership, test, and evidence are current. | +| Documentation | N/A | N/A | No maintained documentation content changes. | +| TypeScript/API | N/A | N/A | No package source, declaration, export, or public API changes. | +| Performance/reliability | `/root/t0007_reliability` | Clean | Frozen install, lock importer, clean Ubuntu path, and layout-independent guard reviewed. | +| Security | N/A | N/A | Existing exact locked package/version; no dependency graph or runtime exposure change. | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| ----- | -------- | --------- | -------------------------------------------------------------------------------- | +| T7-R1 | P2 | Yes | Recorded the full gate and clean-checkout evidence; focused re-review was clean. | + +## Integration + +- Task commit and push: + `b4a0f6d089147790104fa724f0a281786706f837` on + `origin/task/T-0007-ci-doc-dependency`. +- `dev` merge: `3eacc0819450b29d1243670e2ea18b6e8ccaf4b6` + (`Merge T-0007 CI documentation dependency repair`). +- Post-merge verification: Fresh `pnpm install --frozen-lockfile` and + `pnpm verify` passed 17 files / 319 tests, all coverage thresholds, and every + canonical gate. +- Remote refs: Verified the task ref and `origin/dev` merge directly. + `master` remained `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Remote CI: GitHub Actions + [Build and Test #32](https://github.com/SpineEventEngine/validation-ts/actions/runs/30438431930) + completed successfully for the exact `dev` merge. +- Worktree cleanup: Removed the clean, merged T-0007 worktree and its temporary + external verification checkout. + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | Review point | +| ---------------------------------------------------------------------- | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------ | +| Clean CI may expose another previously hoisted package after this fix. | Orchestrator | Run the entire frozen-install gate and remote Actions workflow. | Resolved: local, external clean-checkout, post-merge, and Ubuntu Actions gates passed | Complete | diff --git a/build-protocol/tasks/T-0008-node24-actions/TASK.md b/build-protocol/tasks/T-0008-node24-actions/TASK.md new file mode 100644 index 0000000..8eae297 --- /dev/null +++ b/build-protocol/tasks/T-0008-node24-actions/TASK.md @@ -0,0 +1,194 @@ +# T-0008: Move pnpm Workflow Setup to Node 24 + +Status: Complete +Classification: High-risk +Baseline: `48a3ccab3d4a09de86af115b03469a078be6b4aa` +Branch: `task/T-0008-node24-actions` +Worktree: `.worktrees/T-0008-node24-actions` +Approved plan: Human instruction to address the remaining +`pnpm/action-setup@v4` Node 20 deprecation warning on 2026-07-29 + +## Acceptance Criteria + +- Every project-owned GitHub workflow uses the current Node 24-compatible + `pnpm/action-setup@v6` major line. +- A repository-owned regression test fails if a workflow reintroduces another + `pnpm/action-setup` major and runs in the canonical verification gate. +- Verification and automatic publication keep pnpm `11.9.0`, the existing + Node setup, cache behavior, commands, permissions, and branch triggers. +- The task branch and merged `dev` pass the canonical gate and are pushed. +- The final `dev` GitHub Actions run succeeds without the Node 20 action-runtime + warning. `master` remains untouched and no publication is triggered. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| ----------------------------------------------------- | -------------- | ------------------------------------------------------ | +| Address the remaining `pnpm/action-setup@v4` warning. | Human task | Warning-free remote Actions annotations | +| Keep automatic publication on pushes to `master`. | Prior decision | Publish workflow trigger and commands remain unchanged | +| Work through task branches and integrate into `dev`. | Branch policy | Task/dev remote-ref evidence | +| Do not merge or push `master`. | Branch policy | Remote master comparison | + +## Upstream Evidence + +- Official `pnpm/action-setup` release `v6.0.8`, published 2026-05-12, is the + current stable release and is signed/verified. +- Official `v6` `action.yml` declares `runs.using: node24`. +- Existing project workflows pin GitHub Actions by supported major tags + (`actions/checkout@v6`, `actions/setup-node@v6`). T-0008 preserves that + established update convention rather than introducing a partial SHA-pinning + policy. + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | ------------------------------------------------------------------------------------- | +| `systematic-debugging` | Yes | Trace the warning to the action runtime metadata and prove the supported replacement. | +| `using-git-worktrees` | Yes | Isolate verification and publishing workflow changes. | +| `test-driven-development` | Yes | Establish a failing repository workflow-policy regression before editing workflows. | +| `implement` | Yes | Give one owner the guard, workflow edits, and durable logs. | +| `subagent-driven-development` | Yes | Use project implementation and specialist review roles. | +| `requesting-code-review` | Yes | Review the full workflow/test diff before integration. | +| `verification-before-completion` | Yes | Require local gates and warning-free remote Actions evidence. | +| `openai-docs` | No | No OpenAI/Codex configuration or guidance changes. | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ---------------------------- | -------------------------- | --------------- | ------------------ | ------------------------------------------------------------ | --------- | +| Requirements split | `/root/t0008_requirements` | `gpt-5.6-sol` | high | Audit runtime, test, publishing, and verification boundaries | Completed | +| Implementation | `/root/t0008_implementer` | `gpt-5.6-terra` | medium | Own workflow guard, workflows, package script, and logs | Completed | +| Style/maintainability review | `/root/t0008_style` | `gpt-5.6-terra` | high | Guard quality, minimality, task-record accuracy | Completed | +| Reliability review | `/root/t0008_reliability` | `gpt-5.6-terra` | high | CI parity, supported action runtime, deterministic gates | Completed | +| Security review | `/root/t0008_security` | `gpt-5.6-terra` | high | Publishing workflow integrity and action supply-chain risk | Completed | + +## Scope And Ownership + +- The implementation owner owns `.github/workflows/build.yml`, + `.github/workflows/publish.yml`, the workflow regression under `scripts/`, + the root verification script entry, direct test-only `yaml` dependency and + root lock importer, this task record, and the T-0008 work log. +- The orchestrator owns upstream verification, review aggregation, final gates, + Git integration, remote synchronization, warning inspection, and cleanup. +- Excluded: action SHA-pinning policy migration, workflow restructuring, + pnpm/Node upgrades, publishing behavior changes, dependencies other than the + approved direct test-only `yaml@2.9.0`, + publication, runtime code, and `master`. + +## Decisions And Questions + +- Root cause: `pnpm/action-setup@v4` declares a Node 20 JavaScript action + runtime. GitHub now forces that action onto Node 24 and emits a deprecation + annotation for each invocation. +- Hypothesis: Replacing all three project-owned uses with the supported `v6` + major removes the annotation because upstream `v6/action.yml` declares + `node24`, without changing pnpm or workflow behavior. +- The regression will inspect all project-owned workflow files and require + every `pnpm/action-setup` reference to use `@v6`; it must not depend on remote + Actions execution. +- The regression discovers both `.yml` and `.yaml`, accepts quoted or unquoted + `uses:` values, and fails if it finds no workflow files or no pnpm setup + references. Focused fixtures cover accepted `@v6`, rejected other versions, + both extensions, and non-vacuity. +- Re-review proved the hand-written lexical scanner was incomplete: it missed + quoted keys, multiline/nested flow maps, and could false-trigger on scalar + content. This concrete evidence supersedes the earlier no-dependency plan. + The guard now uses the maintained `yaml@2.9.0` package as a direct root + test-only dependency and parses each workflow semantically; the pre-existing + lock resolution is reused. +- Semantic scope is restricted to GitHub Actions action locations only: + `jobs..uses` for reusable-workflow jobs and + `jobs..steps[*].uses` for step actions. Other keys named `uses`, such + as `env.uses`, are not action references and must not affect this policy. +- Dependency evidence: Node has no built-in YAML parser, and the custom scanner + was rejected as provably incomplete. `yaml@2.9.0` is the direct test-only + contract: official source is https://github.com/eemeli/yaml/tree/v2.9.0; + registry metadata checked 2026-07-29 identifies 2.9.0 as current, modified + 2026-05-11, with Node >=14.6 support. It exports bundled declarations at + `./dist/index.d.ts` and is compatible with this Node 24 ESM workspace. + The transitive `js-yaml@4.3.0` is not root-declared, lacks a bundled-types + export in its installed manifest, and promoting it would create a direct + contract without reducing the graph; it is therefore weaker TypeScript + tooling. `yaml@2.9.0` already enters the lock graph through Vite. +- No material human questions remain. + +## Verification + +| Command | Result | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| Baseline `pnpm install --frozen-lockfile` and `pnpm verify` | Passed: 17 files / 319 tests, all canonical gates, and packed consumer. | +| RED `pnpm test:workflow-pnpm-action-setup` | Expected failure: repository assertion found `@v4`; scan found all three obsolete uses. | +| GREEN `pnpm test:workflow-pnpm-action-setup` | Passed: 5/5 fixture and live-repository assertions. | +| `pnpm format:check` | Passed after formatting the assigned task record and new regression. | +| Verify-script presence check | Passed: root `verify` invokes `pnpm test:workflow-pnpm-action-setup`. | +| `git diff --check` | Passed; workflow diff contains exactly the three approved major-tag substitutions. | +| Independent `pnpm verify` | Passed: six workflow-policy tests, 17 files / 319 tests, and every canonical gate. | +| Review-correction focused guard | Passed: 9/9 cases, including block-scalar, flow-mapping, and comment boundaries. | +| Frozen install and semantic-parser focused guard | Passed: `pnpm install --frozen-lockfile`; 14/14 guard cases. | +| Action-location scope focused guard | Passed: 16/16 cases; `env.uses` ignored while job/step action refs are enforced. | +| Final independent `pnpm verify` | Passed: 16 workflow-policy tests, 17 files / 319 tests, and every canonical gate. | + +Coverage: 94.71% statements, 91.51% branches, 99.19% functions, and 95.96% +lines. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | ------------------------- | ----------- | ---------------------------------------------------------------------------- | +| Style/maintainability | `/root/t0008_style` | Clean | Final action-location and dependency-evidence corrections re-reviewed clean. | +| Documentation | N/A | N/A | No maintained user/package documentation contract changes. | +| TypeScript/API | N/A | N/A | No package source, declarations, exports, or API changes. | +| Performance/reliability | `/root/t0008_reliability` | Clean | Semantic parser correction re-reviewed clean. | +| Security | `/root/t0008_security` | Clean | Semantic parser, lock integrity, and policy bypasses re-reviewed clean. | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| ----- | ---------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T8-R1 | P2 style | Yes | The line regex matched `uses:` text inside YAML literal/folded scalar bodies. Semantic YAML parsing now treats those bodies as scalar values; corrected and re-reviewed clean. | +| T8-R2 | P2 security | Yes | The line regex omitted `uses:` fields in YAML flow mappings, including after another key. Semantic parser support at step locations finds them; corrected and re-reviewed clean. | +| T8-R3 | P2 correctness | Yes | Re-review rejected the partial lexical extractor: quoted keys, multiline/nested flow maps, and quoted scalar boundaries remained incomplete. Replaced with semantic `yaml@2.9.0` parsing; corrected and re-reviewed clean. | +| T8-R4 | P2 correctness | Yes | Recursive semantic traversal falsely treated unrelated values such as `env.uses` as actions. Restricted inspection to `jobs.*.uses` and `jobs.*.steps[*].uses`; corrected and re-reviewed clean. | +| T8-R5 | P2 dependency evidence | Yes | Node has no YAML parser; custom scanning was rejected, and transitive `js-yaml` is a weaker undeclared/untyped direct contract. Direct test-only `yaml@2.9.0` provides maintained Node 24 ESM support and bundled declarations; accepted and re-reviewed clean. | + +## Implementation Self-Review + +- Changed only the owned workflow scalars, root script wiring, workflow-policy + regression, direct test-only `yaml@2.9.0` metadata/root importer, and T-0008 + durable records; no runtime package source, lock graph node, immutable Proto, + public documentation, or unrelated project-plan file was edited. +- Workflow diff retains all existing triggers, permissions, environments, job and + step order, Node setup/cache settings, pnpm version, install flags, and commands; + only the three `pnpm/action-setup` refs changed from `@v4` to `@v6`. +- The fixture-backed Node test discovers both workflow extensions, quoted and + unquoted `uses:` values, rejects every non-`v6` pnpm setup reference, and fails + closed when workflows or pnpm setup references are absent. +- The semantic parser is direct test-only tooling; it reports malformed YAML + with its workflow path, examines only reusable-job and step action `uses` + fields, and reuses the already locked `yaml@2.9.0` graph node. + +## Integration + +- Task commit: + `81da42d86fc7d8db4c1811cc5fb506e597cbfba8`. +- Task push: `origin/task/T-0008-node24-actions` at the task commit. +- `dev` merge: `82ce0cc92e5db5c99a7c5c8f1438a8e22b6baf71`. +- Post-merge verification: `pnpm install --frozen-lockfile` and `pnpm verify` + passed, including 16 policy tests, 17 files / 319 runtime tests, package + packing, and installed-consumer loading. +- Remote refs after the integration push: `origin/dev` at the merge, + `origin/task/T-0008-node24-actions` at the task commit, and `origin/master` + unchanged at `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Remote Actions: [Build and Test #34](https://github.com/SpineEventEngine/validation-ts/actions/runs/30443648028) + completed successfully for the exact merge commit. Both full verification + and Node 24 compatibility passed; the Annotations region was empty, with no + Node 20 deprecation warning. +- Worktree cleanup: The clean merged worktree and immutable temporary review + packages were removed after remote success. + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | Review point | +| ------------------------------------------------------------------------ | ------------ | --------------------------------------------------------------------------------------- | ----------- | ---------------- | +| A major action update can change setup behavior despite the same inputs. | Orchestrator | Focused structural test, full gate, compatibility job, and security/reliability review. | Closed | Remote run #34 | +| The publish workflow does not run on `dev`. | Orchestrator | Structural parity guard plus review; do not trigger publication for this task. | Mitigated | Completed review | diff --git a/build-protocol/tasks/T-0009-docs-source-conventions/IMPLEMENTATION_PLAN.md b/build-protocol/tasks/T-0009-docs-source-conventions/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..a27de65 --- /dev/null +++ b/build-protocol/tasks/T-0009-docs-source-conventions/IMPLEMENTATION_PLAN.md @@ -0,0 +1,497 @@ +# T-0009 Implementation Plan + +## Outcome And Invariants + +T-0009 restores `packages/validation/README.md` as the authoritative consumer +guide, relocates repository-only development material to +`packages/validation/docs/`, and makes the approved source conventions +deterministically enforceable. It intentionally changes the experimental +public package surface but not validation results, traversal, ordering, +configuration errors, template substitution, or formatted text. + +The implementation owner must preserve these invariants throughout every +slice: + +- `validate(schema, message)` remains the only standalone function in + `packages/validation/src/` and `packages/example/src/`. +- The public value exports are `validate`, `Violations`, and + `ValidationConfigurationError`. The existing public generated types and + configuration-error types remain exported. +- `formatViolations` and `formatTemplateString` disappear from the package + entry point and generated declarations without aliases or a deprecation + cycle. +- `Violations.formatAll(violations)` produces exactly the former + `formatViolations(violations)` output. Template substitution remains an + internal operation owned by `TemplateStrings` and is exercised through + `Violations.formatMessage()` and `Violations.formatAll()`. +- All other module-scope production and example functions move behind cohesive + object or class interfaces. Inline callbacks and methods are not standalone + functions. +- Runtime validator sequence, field traversal, diagnostics, error codes, + clock-read cadence, nested registry construction, and all existing test + results remain unchanged. +- The package README is the only consumer guide. Development documents are + repository-only, live below `packages/validation/docs/`, link back to + `../README.md`, and are absent from the packed npm artifact. +- Quick-install paths show one executable command. The primary root and package + commands use the moving `snapshot` tag; an exact preview-version command may + appear only in a separately labelled alternative section. +- TypeScript convention checks use the TypeScript compiler API. Proto + convention checks use a project-owned tokenizer; neither check is based on + generated output or Buf comment lint. +- Every Proto path listed in + `build-protocol/proto/UPSTREAM_SOURCES.json` is immutable and excluded from + project-owned comment and naming remediation. Its bytes and checksum must not + change. +- No dependency, package manager, test runner, module format, publishing + workflow, or Buf lint policy is changed. In particular, no Buf comment rule + is added. + +## Ownership And Change Boundaries + +One `implementer` using `gpt-5.6-terra` with medium reasoning owns all +overlapping files sequentially: + +- production and example TypeScript sources and tests; +- project-owned example and test Proto sources; +- `scripts/check-documentation.*` and the new source-convention checker; +- root/package/example Markdown, `typedoc.json`, and package metadata/checks; +- root scripts in `package.json`; and +- T-0009 work logs and task evidence. + +The orchestrator owns review dispatch and aggregation, final verification, Git +integration, remote synchronization, and cleanup. Reviewers are read-only. +Generated sources, all frozen Proto files, `master`, publishing, unrelated +baseline debt, Java regular-expression compatibility, validation semantics, +new dependencies, and speculative public helpers are excluded. + +The following ownership map is fixed so the refactor deepens existing modules +instead of creating pass-through namespaces: + +| Source area | Owning interface | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Validation traversal and registry closure | Internal `ValidationEngine` object | +| Template placeholder substitution | Internal `TemplateStrings` object | +| Public violation presentation | Public `Violations` object, including `formatAll` | +| Clock reads and test override | Internal `ValidationClock` object | +| Descriptor presence | Internal `Presence` object | +| Generated option lookup | Internal `ValidationOptions` object | +| Context creation and field access | `ValidationContext` plus internal `MessageFields` | +| Violation envelope construction and legacy normalization | Internal `ViolationFactory` and `ValidationOrchestration` objects | +| Numeric parsing, reference resolution, and comparison | Internal `NumericValues` object | +| Each option implementation | One existing option module object such as `Required`, `Pattern`, `Range`, or `When`; private helpers become methods on the same owner | +| Runnable example scenarios | `ExampleScenarios` object | +| Example console presentation | Internal `ConsoleOutput` object | + +Names may be adjusted only when the convention checker demonstrates a +violation, but the replacement must remain at most four semantic words and +must not alter behavior. Generated names are handled with local aliases rather +than generated-file edits. + +## Deterministic Checker Contract + +Create `scripts/check-source-conventions.mjs` and +`scripts/check-source-conventions.test.mjs`, then expose them through +`pnpm source:check` and the canonical `verify` chain. + +The checker contract is: + +- Scan checked-in `.ts` files below `packages/validation/` and + `packages/example/`, excluding generated output, `dist/`, `coverage/`, and + dependency directories. Apply documentation and standalone-function rules + only to the two production/example `src/` trees; apply naming rules to + project-owned source and test TypeScript. +- Parse TypeScript with `ts.createSourceFile`; do not enforce declarations with + regular expressions. +- Reject every module-scope function declaration or function-valued variable + except the exported `validate` declaration. Object/class methods and inline + callbacks are allowed. +- Require TSDoc on module-scope functions, classes, interfaces, type aliases, + enums, and named object declarations, plus their declared methods, + properties, enum members, and constructors. Local variables and anonymous + callback parameters are not documentation declarations. +- Require a callable summary beginning with a third-person present-tense verb, + one `@param` for every declared parameter, and `@returns` for every + non-`void`/non-`never` return. The deterministic summary check accepts the + irregular first words `is`, `has`, and `does`, or a first word ending in + `s`; documentation review rejects nouns or meaningless prose that happen to + match. Validate tag names and parameter coverage through AST/JSDoc nodes. +- Reject task IDs, chat transcripts, implementation-history shorthand, and + workflow/agent language in product TSDoc. Protocol and task files are outside + the scan. +- Count semantic words by splitting snake/kebab separators and camel/Pascal + transitions, with a contiguous initialism or numeric run counting as one + word. Check named TypeScript declarations, members, parameters, local + variables, and imported bindings across the project-owned source and test + roots; reject more than four words and report file, line, name, and count in + stable path/order. +- Read `build-protocol/proto/UPSTREAM_SOURCES.json` and exclude every + `frozenFiles[].localPath`. Scan the remaining checked-in `.proto` files below + `packages/validation/proto/`, `packages/validation/tests/proto/`, and + `packages/example/proto/`. +- Tokenize whitespace, line/block comments, strings, identifiers, punctuation, + and braces. Associate the immediately preceding documentation comment with + every message, field, enum, enum value, and oneof, including nested, + multiline, map, option-bearing, and one-line declarations. +- Reject missing declaration comments and names over four semantic words. + Report findings deterministically. Do not inspect comment wording with Buf + or add any `COMMENTS` rule to a `buf.yaml`. + +Fixture tests must cover valid and invalid TypeScript declarations, aliases, +generics, optional/rest parameters, explicit and inferred return types, +constructors, object methods, function-valued variables, the `validate` +exception, word splitting, nested/multiline/one-line Proto declarations, +comments containing declaration-like text, strings containing braces, map +fields, enum values, oneofs, and manifest-based frozen-file exclusion. + +## Task 1: Lock Convention Tooling With RED/GREEN + +Write checker fixtures before implementation. + +### RED + +Add one minimal fixture expectation for each checker behavior and run: + +```bash +node --test scripts/check-source-conventions.test.mjs +``` + +Record failure because the checker behavior is absent, not because the fixture +cannot load. Implement the TypeScript AST traversal and Proto tokenizer only +after observing the expected failures. + +### GREEN + +Make the fixture suite pass, add `source:check` to `package.json`, and invoke +the checker against the repository once. The live scan is expected to fail at +this point and its stable report becomes the remediation inventory for Slices +2 and 3. + +```bash +node --test scripts/check-source-conventions.test.mjs +pnpm source:check +``` + +### Acceptance + +- Fixture tests prove every rule in the deterministic checker contract. +- Repeated live runs produce byte-identical, path-sorted diagnostics. +- The live failure contains only actionable project-owned TypeScript/Proto + findings and never reports a generated or manifest-frozen source. +- Existing `buf.yaml` files contain no newly added comment lint configuration. + +### Risks + +AST aliases, inferred returns, overload-like declarations, nested Proto +declarations, and braces inside strings can create false results. Resolve them +in tokenizer/AST fixtures rather than with path-specific exemptions. + +## Task 2: Change The Public Interface And Deepen Source Ownership + +Write public-surface and behavior tests before editing production sources. +Update existing internal tests to cross the new owning interfaces instead of +retaining aliases for removed standalone functions. + +### RED + +Add expectations that: + +- `Violations.formatAll()` exists and preserves empty, field, and fallback + formatting; +- package entry exports do not include `formatViolations` or + `formatTemplateString`; +- `validate` remains a callable standalone export with its descriptor/message + type pairing; +- template replacement still treats placeholder keys literally and preserves + dollar-valued replacements; and +- the example uses `ExampleScenarios.run()` and retains all scenario outputs. + +Run: + +```bash +pnpm generate +pnpm exec vitest run packages/validation/tests/basic-validation.test.ts \ + packages/validation/tests/validation-contract.test.ts \ + packages/validation/tests/integration.test.ts \ + packages/validation/tests/ordering.test.ts \ + packages/example/tests/scenarios.test.ts +``` + +Confirm RED is caused by the wished-for object methods/export removals. + +### GREEN + +Apply the fixed ownership map across all production/example modules. Preserve +existing method bodies and call order while moving them; do not mix semantic +cleanup into the ownership change. Remove both helper exports from +`packages/validation/src/index.ts`, add `Violations.formatAll`, update all +imports/call sites, and keep `TemplateStrings` internal. + +Run: + +```bash +pnpm generate +pnpm exec vitest run packages/validation/tests \ + packages/example/tests/scenarios.test.ts +pnpm typecheck:generated +``` + +Then run `pnpm source:check`; standalone-function findings must be gone, while +documentation/naming findings may remain for Slice 3. + +### Acceptance + +- `validate` is the only standalone function in both checked source roots. +- The package entry point and emitted declarations omit both removed names and + expose `Violations.formatAll`. +- Formatting output, template substitution, all 319 baseline test behaviors, + validator ordering, time-clock behavior, and example scenario values remain + unchanged. +- No compatibility alias or deprecated forwarding export exists. +- New owning objects reduce caller knowledge and keep private helpers within + their existing module rather than introducing new cross-module seams. + +### Risks + +Object-method extraction can change `this`, initialization order, recursion, or +callback binding. Use explicit owner references, preserve array ordering, and +verify recursive validation, clock reads, and mapped method callbacks +specifically. + +## Task 3: Complete TypeScript TSDoc, Names, And Project-Owned Proto Comments + +Use the stable live checker inventory from Slice 1. Work file-by-file, with +TypeScript source first and project-owned Proto fixtures second. Do not touch a +manifest-frozen Proto even if its style would fail a project rule. + +### RED + +For each rule category, retain a minimal failing fixture test and capture a +representative live failure: + +```bash +pnpm source:check +``` + +The failure must identify the exact undocumented/overlong declaration before +remediation. + +### GREEN + +- Add reader-facing TSDoc to every checked declaration and member. Callable + summaries start with a third-person verb; all parameters and non-void returns + have tags. +- Enable TypeDoc missing-documentation validation for the exported API, + configure the required reflection kinds, and keep validation warnings fatal. +- Replace historical/task/chat wording with current purpose, inputs, results, + invariants, and error behavior. +- Rename only checker-proven names over four semantic words, updating tests and + imports. Alias unavoidable generated names locally. +- Add meaningful comments to every message, field, enum, enum value, and oneof + in project-owned production, example, and test Proto files. Comments on + deliberately invalid fixtures describe the fixture purpose without implying + supported behavior. + +Run: + +```bash +pnpm source:check +pnpm proto:verify +pnpm proto:lint +pnpm generate +pnpm typecheck:generated +pnpm exec vitest run packages/validation/tests packages/example/tests +``` + +### Acceptance + +- The full convention scan is clean without suppressing a project-owned path. +- TypeDoc accepts all public comments without warnings. +- Every project-owned Proto declaration is documented and within the naming + limit. +- `pnpm proto:verify` proves all frozen checksums unchanged. +- Generated sources remain deterministic and are not hand-edited. +- Validation and example tests prove comment/naming-only changes did not alter + runtime behavior. + +### Risks + +Fixture renames change generated symbol imports, while option-bearing one-line +fixtures are easy to parse or document incorrectly. Regenerate after each +bounded fixture batch and keep invalid declarations semantically identical. + +## Task 4: Restore The Authoritative Guide And Relocate Development Docs + +Use the 507-line `packages/validation/README.md` from `23335b1^` as the +editorial baseline, not as text to copy blindly. Preserve its useful +prerequisites, setup, quick start, public interface, option overview, behavior, +examples, and limitations while reconciling every claim with current code and +package metadata. + +### RED + +Extend `scripts/check-documentation.test.mjs` first. Fixtures must fail for: + +- two executable commands in one quick-install sequence; +- a moving-tag and exact-version command in the same sequence; +- an exact preview command outside a separately labelled alternative section; +- a broken package-local link; +- package docs that do not link back to the package README; +- removed public helper imports; and +- product Markdown/TSDoc containing prohibited historical workflow language. + +Run: + +```bash +node --test scripts/check-documentation.test.mjs +``` + +Confirm the new expectations fail for the intended missing rules. + +### GREEN + +- Rebuild `packages/validation/README.md` from the historical baseline with the + current package name/version, Node/Buf/Protobuf-ES/pnpm toolchain, implemented + options including Spine Time `(when)`, error behavior, regex limitation, + `validate`, `ValidationConfigurationError`, and all `Violations` methods. +- Put the primary `@snapshot` install command in its own one-command sequence. + Put the manifest's exact preview version in a clearly separate alternative + section, also with one command. +- Move `docs/README.md`, `architecture.md`, `contributing.md`, and + `validation-contract.md` to `packages/validation/docs/`, fixing links. + Fold useful consumer material from `docs/user-guide.md` into the package + README and remove that duplicate guide. +- Make the package-local index link to `../README.md`. Keep development docs + optimized for direct navigation and source ownership without task history, + chat transcripts, or agent-workflow prose; link to internal protocol + artifacts when workflow detail is needed. +- Update root and example READMEs to point first to the package guide and then + to repository-only development references. +- Move TypeDoc output to + `packages/validation/docs/api/reference/`. Do not add package docs to the + published `files` allowlist. +- Update all TSDoc and Markdown examples to use `Violations.formatAll` and the + current public interface. + +Run: + +```bash +node --test scripts/check-documentation.test.mjs +pnpm docs:check +pnpm source:check +pnpm format:check +``` + +### Acceptance + +- The package README is recognizably restored from the pre-`23335b1` guide but + contains no obsolete npm/Jest/CommonJS/generated-patching or unsupported + behavior claims. +- No second consumer guide remains. +- Root, package, example, and development documentation have valid local links + and compilable TypeScript examples using only public exports. +- Each quick-install sequence contains exactly one command, and exact/moving + preview installs are visibly alternative choices. +- Reader-facing Markdown and TSDoc contain no task history, chat language, or + implementation-history shorthand. +- TypeDoc generates below the validation package and reports no warnings. + +### Risks + +The historical guide contains obsolete generator patching and pre-pnpm +instructions. Treat its organization and explanatory depth as the baseline; +verify every technical statement against current manifests, source, examples, +and frozen contracts. + +## Task 5: Package Contract And Canonical Gate Integration + +Update `scripts/check-package.mjs` test-first so the installed-consumer smoke +test asserts the new public interface and repository-only docs boundary. + +### RED + +Require the packed module to expose `validate`, `Violations.formatAll`, and +`ValidationConfigurationError`, reject both removed helper exports, and reject +`docs/` from the archive: + +```bash +pnpm package:check +``` + +Observe the expected failure against the old smoke assertions or package +surface before changing the checker/remaining metadata. + +### GREEN + +Finish the package checker, ensure `pnpm source:check` is in `pnpm verify`, and +run the pre-review mechanical set: + +```bash +pnpm source:check +pnpm docs:check +pnpm proto:verify +pnpm proto:lint +pnpm typecheck:generated +pnpm lint +pnpm format:check +pnpm test:coverage +pnpm proto:check-generated +pnpm package:check +pnpm git:check +git diff --check +``` + +### Acceptance + +- The packed ESM consumer loads the intended new interface and proves both old + helper names absent. +- Repository-only package docs are not published; the authoritative package + README is published. +- All four coverage dimensions remain at least 90%. +- Immutable-source verification and generated-output cleanliness pass. +- `verify` invokes deterministic source and documentation convention checks. +- The diff contains no unrelated file, generated-file edit, frozen Proto edit, + Buf comment policy, dependency change, or `master` change. + +## Review And Correction Cadence + +The implementer records each observed RED and GREEN command/result in the +T-0009 work log before starting the next slice. After every slice, the +orchestrator receives a concise outcome and next action. Focused checks happen +inside each slice; do not dispatch partial specialist reviews against a moving +diff. + +After Slice 5 and the pre-review scan, freeze one diff basis and dispatch one +complete concurrent wave: + +- `style_maintainability_reviewer` (`gpt-5.6-terra`, high): module depth, + ownership, naming, deterministic checker quality, and accidental behavior + change; +- `documentation_reviewer` (`gpt-5.6-terra`, medium): historical restoration, + authoritative/package-local split, install alternatives, TSDoc, Proto + comments, links, and reader-facing language; +- `typescript_api_reviewer` (`gpt-5.6-terra`, high): export removal, + `Violations.formatAll`, declarations, TypeDoc, type pairing, and installed + consumer surface; and +- `performance_reliability_reviewer` (`gpt-5.6-terra`, high): bounded, + deterministic AST/token scans, immutable exclusions, generation, and gate + wiring. + +Security remains a concrete N/A because this task adds no dependency, trust +boundary, publishing change, credential handling, or runtime input behavior. + +Collect the whole wave, deduplicate findings, and send one correction batch to +the same implementer. Re-run focused commands for affected slices and reopen +only substantively affected review lanes. Use no more than two complete waves +unless a P0/P1 remains. + +After review converges, run fresh: + +```bash +pnpm verify +``` + +The task may proceed to integration only when the full gate passes, coverage is +at least 90% in every dimension, every review concern has a clean/accepted/N/A +disposition, frozen checksums match, and the final diff satisfies every +invariant and exclusion above. diff --git a/build-protocol/tasks/T-0009-docs-source-conventions/TASK.md b/build-protocol/tasks/T-0009-docs-source-conventions/TASK.md new file mode 100644 index 0000000..01bc72d --- /dev/null +++ b/build-protocol/tasks/T-0009-docs-source-conventions/TASK.md @@ -0,0 +1,194 @@ +# T-0009: Restore Package Guidance And Source Conventions + +Status: Complete +Classification: High-risk +Baseline: `1f39ab5d910a240d04283ada489b8f044c307475` +Branch: `task/T-0009-docs-source-conventions` +Worktree: `.worktrees/T-0009-docs-source-conventions` +Approved plan: Human-approved documentation, API, naming, and source-ownership +plan completed on 2026-07-29 + +## Acceptance Criteria + +- Restore the full package guide from immediately before `23335b1` as an + editorial baseline, then update it for the current package, toolchain, + behavior, time options, error handling, and public API. +- Present exactly one command in every quick-install sequence. Put an exact + preview-version command only in a clearly separate alternative section. +- Move the top-level maintained documentation under + `packages/validation/docs/`. Make the package README the authoritative user + guide and the package-local docs a repository-only development reference + optimized for developers and agents. +- Remove task history, chat terminology, and implementation-history shorthand + from reader-facing documentation and TSDoc. Keep workflow terminology only + in explicitly internal protocol and contributor workflow artifacts. +- Document production and example TypeScript declarations completely. Callable + summaries start with a third-person verb, every parameter and non-void return + is documented, and types, interfaces, objects, properties, and constructors + explain their purpose and inputs. +- Keep public `validate()` as the deliberate standalone entry point. Move other + production and example standalone functions to documented owning objects. +- Remove the public `formatViolations` and `formatTemplateString` exports + without a deprecation cycle. Provide public collection formatting through + `Violations.formatAll()` and keep template substitution behind a documented + internal owner. +- Keep project-owned TypeScript and Proto names to at most four semantic words, + preferably three. Use local aliases for unavoidable generated names. +- Document every project-owned Proto message, field, enum, enum value, and + oneof, including test fixtures. Never modify immutable upstream Proto files. +- Enforce documentation, naming, and standalone-function rules with tested, + deterministic repository tooling based on the TypeScript compiler API and a + small Proto tokenizer. Do not add Buf comment linting. +- Preserve runtime validation behavior, validator ordering, immutable Proto + checksums, generated-source determinism, and at least 90% coverage in every + dimension. +- Pass the canonical gate, specialist review, task/dev integration, remote + synchronization, and branch cleanup without touching `master`. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| -------------------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------- | +| Restore and update the former full package guide instead of replacing it. | Human task | Historical comparison and documentation review | +| Never present moving-tag and exact-version installs as commands to run together. | Human task | Documentation checker fixture and maintained-doc scan | +| Use reader-facing language in product docs and TSDoc. | Human task | Source checker and documentation review | +| Complete TSDoc for callables, types, interfaces, objects, properties, and constructors. | Human task | TypeScript AST checker and TypeDoc validation | +| Use standalone production/example functions only as a last resort. | Human task | AST checker and style review | +| Limit project-owned TypeScript and Proto names to four semantic words. | Human task | Deterministic naming checks | +| Document all project-owned Proto declarations without editing upstream files. | Human task | Proto checker plus immutable-source verification | +| Do not use Buf comment linting. | Human decision | Configuration diff and focused checker tests | +| Remove formatting helper exports without deprecation aliases. | Human decision | Package export/type tests | +| Move maintained docs into the validation package as an agent-oriented development reference. | Human task | Link/navigation and package-content checks | +| Integrate only into `dev`; do not touch `master`. | Branch policy | Remote-ref evidence | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | -------------------------------------------------------------------------- | +| `codebase-design` | Yes | Assign helper behavior to cohesive owners instead of superficial grouping. | +| `using-git-worktrees` | Yes | Isolate the broad source and documentation refactor. | +| `test-driven-development` | Yes | Establish failing convention and public-API checks before implementation. | +| `subagent-driven-development` | Yes | Use the project implementer and specialist review roles continuously. | +| `requesting-code-review` | Yes | Review each implementation slice and the complete branch. | +| `verification-before-completion` | Yes | Require fresh focused and full-gate evidence before integration. | + +## Agent Dispatch + +| Role/function | Expected model | Expected reasoning | Scope | Status | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------- | -------- | +| Requirements split | `/root/t0009_requirements`; `gpt-5.6-sol` | high | Order public API, source ownership, documentation, and deterministic-gate slices | Complete | +| Implementation | `/root/t0009_implementer`, `/root/t0009_core_owner`, `/root/t0009_options_a`, `/root/t0009_options_b`, `/root/t0009_tsdoc_fix`, `/root/t0009_tsdoc_owner`, `/root/t0009_docs_fix`, `/root/t0009_tsdoc_final`, `/root/t0009_gate_owner`, `/root/t0009_review_fix`; `gpt-5.6-terra` | medium | Sequentially own the public/example, core, option, documentation, gate-integration, and accepted-review correction tranches | Complete | +| Style/maintainability review | `/root/t0009_style_review`; `gpt-5.6-terra` | high | Ownership, naming, checker quality, and behavior preservation | Complete | +| Documentation review | `/root/t0009_docs_review`; `gpt-5.6-terra` | medium | Restored guide, development reference, TSDoc, Proto comments, and links | Complete | +| TypeScript/API review | `/root/t0009_api_review`; `gpt-5.6-terra` | high | Export removal, `Violations` API, declarations, TypeDoc, and package surface | Complete | +| Performance/reliability review | `/root/t0009_reliability_review`; `gpt-5.6-terra` | high | Deterministic checkers, verification integration, and bounded scans | Complete | +| Security review | `gpt-5.6-terra` | high | No new trust boundary, dependency, publishing, or security-sensitive behavior expected | N/A | + +## Scope And Ownership + +- One implementation owner owns all overlapping production, example, + documentation, Proto-comment, checker, configuration, and durable task-log + changes. +- The orchestrator owns requirements splitting, review aggregation, final + verification, Git integration, remote synchronization, and cleanup. +- The requirements splitter and reviewers are read-only and must not spawn + subagents. +- Excluded: validation behavior changes, Java regular-expression compatibility, + new dependencies, package-manager/test-runner/module-format changes, + publication, `master`, generated-source hand editing, and immutable upstream + Proto changes. + +## Decisions And Questions + +- `packages/validation/README.md` is the authoritative consumer guide. +- `packages/validation/docs/` is repository-only development reference + material. Its index links back to the package README. +- Useful material from the former top-level user guide is incorporated into the + restored package guide instead of preserving a second consumer guide. +- Explicitly internal workflow artifacts may use task and agent terminology. + Product documentation and TSDoc may not use historical task language. +- Root and package quick-install sections use the moving `snapshot` tag. An + exact version is presented only as a separately labelled alternative. +- Existing non-comment Buf checks remain in place. No Buf comment rules are + added. +- No material human questions remain. + +## Verification + +| Command | Result | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Baseline `pnpm install --frozen-lockfile` | Passed with the committed lockfile. | +| Baseline canonical gate | Passed through the example; the package smoke check required network access for its temporary consumer install. | +| Baseline `pnpm package:check && pnpm git:check` | Passed with network access: packed 112 files and loaded the installed ESM API. | +| Final `pnpm package:check` | Passed with network access: packed 112 files, compiled the installed public declarations, and loaded the ESM API. | +| Final independent `pnpm verify` | Passed all canonical checks, 320 tests, immutable-source verification, documentation, package, and Git gates. | + +Final coverage: 94.86% statements, 91.68% branches, 99.19% functions, and +96.12% lines across 18 files and 320 tests. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | -------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------- | +| Style/maintainability | `/root/t0009_style_review` | Clean | Entry-point and callable-property checks re-reviewed clean; ownership, naming, and runtime order passed. | +| Documentation | `/root/t0009_docs_review` | Clean | Install, message-option, time, TSDoc, Proto, link, and package/development-reference scopes passed. | +| TypeScript/API | `/root/t0009_api_review` | Clean | Installed declarations and runtime expose the intended API; removed exports and mismatched pairs reject. | +| Performance/reliability | `/root/t0009_reliability_review` | Clean | Shell, path, comment, ordering, tokenizer, generation, and runtime concerns re-reviewed clean. | +| Security | N/A | N/A | No new dependency, trust boundary, publishing behavior, or security-sensitive runtime input. | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| ----- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------ | +| T9-R1 | P2 | Yes | Restricted the standalone `validate` exception to its single public declaration and added path/count fixtures. | +| T9-R2 | P2 | Yes | Enforced callable TSDoc for class and object function-valued properties with positive and negative fixtures. | +| T9-R3 | P2 | Yes | Documented `(if_missing)` and `(if_has_duplicates)` custom-message ownership and the TypeScript 5.4 minimum. | +| T9-R4 | P2 | Yes | Added an installed TypeScript consumer that proves current APIs, removed exports, and schema/message pairing. | +| T9-R5 | P1 | Yes | Rejected shell chaining, background operators, and continuations in one-command install fences. | +| T9-R6 | P1 | Yes | Rejected empty TypeScript and project-owned Proto documentation comments. | +| T9-R7 | P2 | Yes | Confined local documentation links to the real repository tree, including traversal and symlink cases. | +| T9-R8 | P2 | Yes | Sorted Markdown, package-documentation, and source-TSDoc traversal and added deterministic first-failure fixtures. | + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | +| ----------------------------------------------------------------------- | ---------------------- | -------------------------------------------------------- | ----------- | +| Broad helper regrouping accidentally changes runtime order or values. | Implementation owner | API/runtime regression tests and full coverage gate | Closed | +| Automated prose checks accept meaningless text or reject valid wording. | Implementation owner | Narrow deterministic rules plus documentation review | Closed | +| Proto comment parsing mishandles nested or multiline declarations. | Implementation owner | Tokenizer fixtures and complete maintained-source scan | Closed | +| Restored historical instructions reintroduce obsolete behavior. | Documentation reviewer | Compare every guide claim with current code and examples | Closed | +| Moving docs leaves broken links or unpublished-package links. | Implementation owner | Link checker, package-content check, and review | Closed | + +## Implementation Evidence + +| Boundary | Outcome | Evidence | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Final TSDoc correction audit | Replaced all reported generic, history, and duplicate TSDoc blocks in the owned source inventory; internal all-fields adapter naming now describes behavior without changing the public API. | Exact banned-word and adjacent-block inventory scans returned no matches. | +| Checker regression coverage | Added RED fixtures for generic filler, detached history wording, and duplicate declaration blocks; hardened scanning to inspect every TSDoc block and emit deterministic duplicate diagnostics. | Initial `node --test scripts/check-source-conventions.test.mjs` failed at the new fixture; it passed after the checker change. | +| Focused source convention gate | Passed after the complete inventory remediation. | `pnpm source:check` | +| Final correction verification | Passed TypeDoc/document checks, generated typechecking, lint/format, all validation and example tests, checker fixtures, and whitespace validation after the internal adapter test call was renamed. | `pnpm docs:check`; `pnpm typecheck`; `pnpm lint`; `pnpm format:check`; `pnpm test:validation`; `pnpm test:example`; `node --test scripts/check-source-conventions.test.mjs`; `git diff --check` | +| Task 5 RED package smoke | Attempted the pre-change package check. The stale smoke still required removed `formatViolations`, but the temporary consumer install did not reach the smoke because the sandbox could not resolve registry packages. | `pnpm package:check` stopped during `pnpm add` with `ENOTFOUND` for `temporal-polyfill`, `temporal-utils`, and `temporal-spec`; no checker or package metadata was changed before this observation. | +| Task 5 GREEN gate integration | Updated the package contract smoke to require callable `validate`, `ValidationConfigurationError`, and `Violations` format/path methods, reject both removed exports, reject packed `docs/`, and run `source:check` before lint/docs in `verify`. | `pnpm source:check`; `pnpm docs:check`; `pnpm proto:verify`; `pnpm proto:lint`; `pnpm typecheck:generated`; `pnpm lint`; `pnpm test:coverage`; `pnpm proto:check-generated`; `pnpm git:check`; `git diff --check` passed. The first package rerun hit registry DNS; the final network-enabled package and canonical gates passed. | +| Consolidated review corrections | Accepted P1/P2 findings corrected: package guidance names the nested required/distinct message options and TypeScript prerequisite; source and documentation checkers reject the reviewed bypasses deterministically; installed-consumer smoke compiles the tarball public types. Corrections were sent to focused re-review. | RED/GREEN fixture runs: `node --test scripts/check-source-conventions.test.mjs` and `node scripts/check-documentation.test.mjs`; the canonical `pnpm verify` passed, including the installed-tarball runtime and TypeScript smoke. Coverage: 94.86% statements, 91.68% branches, 99.19% functions, and 96.12% lines. | +| Re-review correction follow-up | Added object-literal callable coverage, unquoted background-operator rejection, deterministic source-TSDoc traversal, and distinct `GenMessage` schema/message smoke types. The affected lanes were sent to final narrow re-review. | `node --test scripts/check-source-conventions.test.mjs`; `node scripts/check-documentation.test.mjs`; `pnpm source:check`; `pnpm docs:check`; `pnpm typecheck`; `pnpm lint`; `pnpm format:check`; `pnpm test:validation`; `pnpm test:example`; `git diff --check`; `pnpm git:check` passed. The first package attempt hit registry DNS; the final network-enabled package check passed. | +| Final review and gate | Every accepted finding was corrected and re-reviewed clean; the installed tarball's declaration/runtime smoke and canonical gate passed with network access. | `pnpm package:check` packed 112 files and verified the installed consumer; final `pnpm verify` passed 320 tests and every canonical gate. | + +## Integration + +- Task commit: + `2373a86a5dfe47b620411f7e7e3082d66a58a752`. +- Task branch was pushed to `origin`, then merged into `dev` as + `4bd013c5b981c2fc91d4d0d2b3d9e11db5515ccd`. +- Post-merge `pnpm install --frozen-lockfile` and `pnpm verify` passed after + removing obsolete generated output from the former top-level TypeDoc path. + The gate passed all 320 tests, package/declaration smoke checks, and every + coverage threshold. +- Remote refs after the integration push placed `origin/dev` at the merge and + left `origin/master` unchanged at + `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Remote Actions: + [Verify #36](https://github.com/SpineEventEngine/validation-ts/actions/runs/30469858265) + completed successfully for the exact merge commit. +- The clean merged worktree was removed, and the integrated local and remote + task branches were deleted. The user-owned untracked `.pnpm-store/` and + `validation-ts.code-workspace` remain untouched. diff --git a/build-protocol/tasks/T-0010-development-guides/TASK.md b/build-protocol/tasks/T-0010-development-guides/TASK.md new file mode 100644 index 0000000..94f3b2c --- /dev/null +++ b/build-protocol/tasks/T-0010-development-guides/TASK.md @@ -0,0 +1,204 @@ +# T-0010: Restore Beginner And Development Guides + +Status: Complete +Classification: Standard +Baseline: `60fd57ec66d73d769f0ce4029846ad3726e3e41e` +Branch: `task/T-0010-development-guides` +Worktree: `.worktrees/T-0010-development-guides` +Approved plan: Human-approved documentation, permanent version policy, and +snapshot-bump plan on 2026-07-31 + +## Acceptance Criteria + +- Restore `packages/example/README.md` from the `master` version as the + editorial baseline, preserving its beginner audience, style, and wording + while updating commands and current validation scenarios. +- Create a comprehensive `packages/validation/docs/development.md` with + accurate prerequisites, supported environments, setup, commands, and + copy-ready workflows for ordinary maintenance and extension work. +- Rewrite `packages/validation/docs/contributing.md` as a contribution guide + and link both guides from the package README, documentation index, and + necessary root navigation. +- Record a permanent rule that every root framework-version change occurs in + an isolated commit whose exact subject is `Bump version -> `. +- Correct maintained development policy that still describes the retired + npm, Jest, CommonJS, and `package-lock.json` toolchain. Preserve historical + task, review, and work logs unchanged. +- After documentation and policy work, advance all three synchronized + workspace manifests to `2.0.0-snapshot.7` in a version-only commit named + exactly `Bump version -> 2.0.0-snapshot.7`. +- Preserve runtime behavior, public TypeScript declarations, immutable Proto + files, dependencies, generated sources, and at least 90% coverage in every + dimension. +- Integrate only into `dev`, push the task and integration branches, verify + remote refs, and remove the merged task branch locally and remotely without + touching `master`. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| --------------------------------------------------------------------------- | ------------ | ------------------------------------------- | +| Use the original example README from `master` as the editorial baseline. | Human item 1 | Historical diff and documentation review | +| Write the example for beginner humans in the root README's visual style. | Human item 1 | Reader test and documentation review | +| Create a long development guide with copy-ready setup and change workflows. | Human item 2 | Documentation review and command audit | +| Give `contributing.md` a truthful title and contribution-specific content. | Human item 2 | Documentation review | +| Link the development and contribution guides from README navigation. | Human item 2 | Documentation link check | +| Make version changes in a separate version-only commit. | Human item 3 | Commit diff inspection | +| Use the exact subject template `Bump version -> `. | Human item 3 | Commit subject inspection | +| Advance to the next snapshot and integrate into `dev` only. | Human item 4 | Manifest, branch, and remote-ref inspection | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | ------------------------------------------------------------------------------------------- | +| `doc-coauthoring` | Yes | Separate beginner, maintainer, and contributor information and reader-test the results. | +| `using-git-worktrees` | Yes | Isolate the standard multi-document and protocol task from the main checkout. | +| `subagent-driven-development` | Yes | Use one documentation owner followed by focused specialist review. | +| `requesting-code-review` | Yes | Review reader fit, maintained policy, package metadata, and reliability before integration. | +| `verification-before-completion` | Yes | Require fresh focused and complete evidence before commits, merge, and completion. | +| `test-driven-development` | Yes | Add the clean-host/order checker behavior with a verified RED/GREEN cycle. | +| `implement` | No | The approved repository plan and project-specific subagent cycle already define execution. | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ------------------------------ | -------------------------------- | --------------- | ------------------ | ---------------------------------------------------------------------------------------- | -------- | +| Implementation | `/root/t0010_docs` | `gpt-5.6-terra` | medium | Own maintained README, development, contribution, protocol, and task-record changes | Complete | +| Documentation review | `/root/t0010_docs_review` | `gpt-5.6-terra` | medium | Beginner-reader test, guide completeness, commands, examples, and navigation | Complete | +| Style/maintainability review | `/root/t0010_style_review` | `gpt-5.6-terra` | high | Structure, duplication, durable policy placement, and historical-log boundary | Complete | +| TypeScript/API review | `/root/t0010_api_review` | `gpt-5.6-terra` | high | Package names, versions, public API claims, supported environments, and install guidance | Complete | +| Performance/reliability review | `/root/t0010_reliability_review` | `gpt-5.6-terra` | high | Copy-ready commands, clean-checkout sequencing, gates, commit isolation, and delivery | Complete | +| Security review | N/A | `gpt-5.6-terra` | high | No dependency, trust-boundary, runtime-input, or release publication change | N/A | + +## Scope And Ownership + +- The implementation owner may change maintained root/package/example + READMEs, package-local development documents, current protocol guidance, + T-0010 records, and the synchronized manifest versions. +- The orchestrator owns Git worktree creation, the isolated version commit, + review aggregation, final verification, integration, remote synchronization, + and cleanup. +- Historical task, review, decision, and work-log evidence remains unchanged + except for new T-0010 records and the active project-plan row. +- Excluded: runtime behavior, TypeScript API, dependencies, generated files, + immutable Proto inputs, CI behavior, publication, and `master`. + +## Decisions And Questions + +- `2.0.0-snapshot.7` is the next version under the established + `2.0.0-snapshot.` scheme. +- Public npm returned E404 for `@spine-event-engine/validation`; the renamed + package has not yet been published because `origin/master` retains the old + package layout and version. Consumer docs describe only a conditional future + snapshot install from `master`. +- All three workspace manifests remain synchronized in the isolated version + commit; the lockfile does not encode workspace manifest versions. +- `packages/validation/README.md` is the primary navigation point for both new + guides. The package documentation index and necessary root navigation also + expose them. +- No unresolved human question remains. + +## Verification + +| Command | Result | +| ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `corepack pnpm install --frozen-lockfile` | Passed from the committed lockfile after approved network access. | +| `corepack pnpm test:validation` through initial `pnpm test` | Passed 17 files and 312 tests. | +| Initial `corepack pnpm test:example` before build | Failed because the fresh worktree had no validation-package `dist`; recorded as a setup sequencing requirement. | +| `corepack pnpm build` | Passed and created the workspace build output. | +| `corepack pnpm test:example` after build | Passed 1 file and 8 tests. | +| `pnpm docs:check` | Passed: documentation checker regression tests, TypeDoc generation, and 8 maintained Markdown files. | +| `pnpm source:check` | Passed. | +| `git diff --check` | Passed. | +| `pnpm --filter @spine-event-engine/example-smoke test` | Failed: the package-local Vitest process finds no tests under the root include pattern; guides now use build then root test. | +| `pnpm build && pnpm test:example` | Passed: build completed and the example suite passed 1 file and 8 tests. | +| Corrected `pnpm docs:check`, `pnpm source:check`, `pnpm format:check`, and `git diff --check` | Passed. | +| RED: `node scripts/check-documentation.test.mjs` | Failed as expected: the new `corepack enable pnpm` fixture was accepted before checker implementation. | +| GREEN: `node scripts/check-documentation.test.mjs` | Passed after the direct-Corepack and build-before-example-test checker rule. | +| Final `pnpm docs:check`, `pnpm source:check`, `pnpm format:check`, `pnpm build && pnpm test:example`, and `git diff --check` | Passed; the example suite passed 1 file and 8 tests. | +| `corepack pnpm install --frozen-lockfile` after the `.7` version bump | Blocked by DNS `ENOTFOUND` while restoring the cold local store; no lockfile change. The orchestrator will run docs, formatting, package, and full gates with approved network access. | +| Final `git diff --check` | Passed. | +| Network-enabled post-bump `corepack pnpm install --frozen-lockfile` | Passed with the unchanged lockfile; all 192 packages were reused from the restored store. | +| Post-registry-correction docs, source, formatting, and diff checks | Passed after formatting this active task record. | +| `corepack pnpm package:check` | Passed: packed 112 files, installed local `2.0.0-snapshot.7`, compiled the consumer, and loaded the ESM API. | +| Final `corepack pnpm verify` | Passed all canonical gates: 320 tests, docs, TypeDoc, Proto, deterministic generation, example, package, and Git checks. | + +Coverage: 94.86% statements, 91.68% branches, 99.19% functions, and +96.12% lines across 18 files and 320 tests. + +## Implementation Evidence + +- Restored the example README from `master` as the editorial starting point, + retaining the beginner-focused quick start and updating it for pnpm, + current scenarios, and `(when)` time examples. +- Added the development guide and rewrote the contribution guide, with + package, documentation-index, example, and root navigation links. +- Corrected maintained pnpm/Vitest/ESM policy and command references in the + active governance and immutable-Proto guidance. Consumer docs now state that + the renamed package is not public on npm and show only a conditional future + snapshot command. +- Added the isolated synchronized-manifest version-commit rule to current + governance and contributor guidance. The orchestrator retains the later + version-only commit. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | -------------------------------- | ----------- | ----------------------------------------------------------------------- | +| Style/maintainability | `/root/t0010_style_review` | Clean | Corrected structure, canonical policy, and copy-ready sample confirmed. | +| Documentation | `/root/t0010_docs_review` | Clean | Beginner and maintainer reader tests passed after targeted correction. | +| TypeScript/API | `/root/t0010_api_review` | Clean | Registry wording, local package contract, and isolated bump confirmed. | +| Performance/reliability | `/root/t0010_reliability_review` | Clean | Direct Corepack, Proto, checker, and command corrections confirmed. | +| Security | N/A | N/A | No security-sensitive scope. | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| ----- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| F-001 | P2 | Yes | Replaced the failing package-scoped example test guidance with the verified `pnpm build` then `pnpm test:example` clean-checkout sequence. | +| F-002 | P2 | Yes | Restored the master beginner README title, introduction, feature list, and quick-start structure; separated the tests-only configuration fixture from console scenarios. | +| F-003 | P2 | Yes | Expanded the option workflow around real `(when)` Proto, generated extension, registry, owner, validation ordering, fixture, and Vitest paths. | +| F-004 | P2 | Yes | Added a real test-first `(when)` boundary-change example and removed generic executable-command placeholders. | +| F-005 | P2 | Yes | Added ordered public API, example, documentation, and dependency maintenance workflows. | +| F-006 | P2 | Yes | Rewrote human-facing contribution and development guidance without internal workflow terminology. | +| F-007 | P2 | Yes | Added explicit Ubuntu CI verification and Linux/macOS/WSL guidance, with native Windows clearly unverified. | +| F-008 | P2 | Yes | Reduced the package READMEโ€™s maintainer command list to concise guide links. | +| F-009 | P2 | Yes | Made `BUILD_PROTOCOL.md` the canonical version rule and replaced duplicate policy copies with references. | +| F-010 | P2 | Yes | Replaced shim setup with direct `corepack pnpm` and cold-cache network guidance in clean-host sequences. | +| F-011 | P2 | Yes | Replaced the `(when)` illustration with real current schema and clock imports, fixed clock lifecycle, and a deliberately failing equality assertion. | +| F-012 | P2 | Yes | Replaced shim-installation guidance with direct `corepack pnpm` command blocks and cold-cache explanation. | +| F-013 | P2 | Yes | Clarified that Proto verification checks local immutable file checksums against the recorded manifest. | +| F-014 | P2 | Yes | Required separate immutable-Proto intake approval and compatibility review before vendored input or manifest changes. | +| F-015 | P2 | Yes | Added RED/GREEN checker regression coverage for direct Corepack setup and build-before-root-example-test ordering. | +| F-016 | P2 | Yes | Replaced the resolving but noncanonical generated-schema import with the exact local path used by `when.test.ts`. | +| F-017 | P1 | Yes | Removed false public npm availability and exact-preview claims after public-registry E404 evidence; documented only a conditional future snapshot install from `master`. | + +## Integration + +- Final task head `f2ff606327d117f2a48bdf271339d0aa0614ec38` was pushed + to `origin/task/T-0010-development-guides`. +- The task was merged into `dev` as + `0066571e7aaa16382d249ade4e27752a43fc94bc` and pushed. +- Post-merge frozen installation and `corepack pnpm verify` passed with all + 320 tests, coverage thresholds, documentation, package, example, Proto, + deterministic-generation, and Git checks. +- GitHub Actions + [Verify #30648799174](https://github.com/SpineEventEngine/validation-ts/actions/runs/30648799174) + completed successfully for the exact merge commit. +- Before this closure record, `origin/dev` matched the verified merge and + `origin/master` remained unchanged at + `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- The clean task worktree was removed and the integrated local and remote task + branches were deleted. The user-owned `.pnpm-store/` and + `validation-ts.code-workspace` remain untouched. + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | Review point | +| ------------------------------------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------- | +| Copy-ready commands omit a required clean-checkout prerequisite. | Implementation owner | Run focused commands in the isolated worktree and reliability review | Closed | Direct-Corepack/order checker and targeted review passed. | +| Beginner documentation drifts into maintainer or internal terminology. | Documentation reviewer | Fresh-reader questions and editorial review | Closed | Final reader matrix passed. | +| Maintained protocol still contradicts the pnpm/Vitest/ESM baseline. | Implementation owner | Targeted current-document scan and style review | Closed | Current guidance and style review agree. | +| Version metadata is mixed with unrelated documentation changes. | Orchestrator | Inspect the exact version commit tree and subject | Closed | `0d852fd` changes only the three manifest version fields. | +| Repository-wide formatting includes a pre-existing active project-plan edit outside implementation ownership. | Orchestrator | Format or disposition that edit before final verification | Closed | The T-0010 records and project-plan row are formatted. | +| Package-scoped example test resolves no files because its Vitest include is rooted at the workspace. | Maintainers | Keep user guidance on the verified build-then-root-test sequence; change the package script only in a dedicated tooling task. | Accepted follow-up | Future tooling work | diff --git a/build-protocol/tasks/T-0011-domain-id-docs/TASK.md b/build-protocol/tasks/T-0011-domain-id-docs/TASK.md new file mode 100644 index 0000000..67759dd --- /dev/null +++ b/build-protocol/tasks/T-0011-domain-id-docs/TASK.md @@ -0,0 +1,173 @@ +# T-0011: Teach Domain ID Messages In Beginner Examples + +Status: Complete +Classification: High-risk +Baseline: `a34056e7e7f6141116b20c9457863375786aed83` +Branch: `task/T-0011-domain-id-docs` +Worktree: `.worktrees/T-0011-domain-id-docs` +Approved plan: Human-approved domain-ID, beginner Proto documentation, example, +and deterministic-check plan on 2026-08-01 + +## Acceptance Criteria + +- Model every domain identifier in the runnable example with a dedicated + message: `UserId`, `ProductId`, and `CategoryId`, each containing a required + string value. +- Type `User.id` and `GetUserRequest.user_id` as `UserId`, `Product.id` as + `ProductId`, and `Category.id` as `CategoryId`; validate the ID value wherever + an ID message is accepted and require each containing ID field. +- Update executable scenarios and assertions for the generated message shapes + and reject both an absent ID field and a present ID message whose value is + empty. Cover `GetUserRequest.user_id` directly. +- Preserve the structure and visual style of `packages/validation/README.md` + while documenting every message and field in every Proto fence with simple + terms from the example domain. +- Leave one empty line between documented Proto declaration blocks in the + maintained README snippets and synchronized project-owned example schemas. +- Update `packages/example/README.md` and project-owned example Protos where + they repeat the package-guide examples; keep all text suitable for complete + beginners. +- Add deterministic documentation-checker coverage for README Proto comments, + declaration-block spacing, and message-typed domain IDs. +- Preserve validation-library runtime behavior, public package exports, + package versions, dependencies, immutable upstream Proto files, and the + current README look and feel. +- Integrate only into `dev`, push the task and integration branches, verify + remote refs, and remove the merged task branch locally and remotely without + touching `master`. + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| ---------------------------------------------------------------------- | -------------------------- | ---------------------------------------------------- | +| No domain identifier in the example may be typed as a primitive. | Human scope decision | Proto scan, generated typecheck, example tests | +| Document every message and every field. | Human item 2 | Deterministic fence checker and documentation review | +| Separate documented declaration blocks with one empty line. | Human item 2 | Deterministic fence checker and source inspection | +| Use simple terms from the respective domain and no internal jargon. | Human item 2 | Beginner-reader test and documentation review | +| Synchronize repeated README declarations with executable example code. | Human synchronization rule | README/schema comparison and example tests | +| Preserve the README look and feel. | Human presentation rule | Diff inspection and documentation review | +| Check correctness again for complete beginners. | Human final-check rule | Focused checks, full gate, fresh reader test | + +## Skills + +| Skill | Selected? | Reason | +| -------------------------------- | --------- | --------------------------------------------------------------------------------------------------------- | +| `using-git-worktrees` | Yes | Isolates the high-risk example Proto contract change from `dev`. | +| `subagent-driven-development` | Yes | Uses one writer and a complete specialist review wave. | +| `test-driven-development` | Yes | Proves the new deterministic documentation and example behavior checks fail before implementation. | +| `doc-coauthoring` | Yes | Preserves the existing guide while testing it with a fresh beginner reader. | +| `requesting-code-review` | Yes | Reviews the complete branch before integration. | +| `verification-before-completion` | Yes | Requires fresh focused and canonical evidence before commits and delivery. | +| `implement` | No | The approved project protocol and its dedicated implementer role define execution. | +| `domain-modeling` | No | The human already fixed the domain-ID rule and exact domain type names; no terminology discovery remains. | +| `monorepo-management` | No | Workspace topology, dependencies, and package management remain unchanged. | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ------------------------------ | ---------------------------------- | --------------- | ------------------ | --------------------------------------------------------------------------------------------------- | -------- | +| Requirements split | `/root/t0011_requirements` | `gpt-5.6-sol` | high | Confirm ordered slices and high-risk acceptance coverage | Complete | +| Implementation | `/root/t0011_implementation` | `gpt-5.6-terra` | medium | Own checker tests/tooling, maintained READMEs, example Protos, scenarios, tests, and active records | Complete | +| Style/maintainability review | `/root/t0011_style_review` | `gpt-5.6-terra` | high | Naming, schema organization, checker maintainability, and diff scope | Complete | +| Documentation review | `/root/t0011_docs_review` | `gpt-5.6-terra` | medium | Beginner reader test, simple domain wording, completeness, spacing, and presentation | Complete | +| TypeScript/API review | `/root/t0011_api_review` | `gpt-5.6-terra` | high | Generated message shapes, serialized example compatibility, and unchanged package API | Complete | +| Performance/reliability review | `/root/t0011_reliability_review` | `gpt-5.6-terra` | high | Deterministic checker behavior, gate coverage, generation, and delivery | Complete | +| Correction batch | `/root/t0011_implementation` | `gpt-5.6-terra` | medium | Resolve the complete accepted P1/P2 review wave and re-run affected focused checks | Complete | +| Final correction batch | `/root/t0011_checker_correction` | `gpt-5.6-terra` | medium | Resolve accepted F-005/F-006 checker false-negative findings with verified RED/GREEN regressions | Complete | +| Final direct-field correction | `/root/t0011_checker_correction` | `gpt-5.6-terra` | medium | Resolve accepted F-007 nested-field false-negative with verified RED/GREEN regression | Complete | +| Final style re-review | `/root/t0011_style_rereview` | `gpt-5.6-terra` | high | Confirm F-001 through F-007 resolution, immediate-depth matching, and scope discipline | Complete | +| Final reliability re-review | `/root/t0011_reliability_rereview` | `gpt-5.6-terra` | high | Confirm deterministic regressions, structural parsing, and completion-gate readiness | Complete | +| Security review | N/A | `gpt-5.6-terra` | high | No dependency, credential, publishing, trust-boundary, or runtime-input security change | N/A | + +## Scope And Ownership + +- One implementation owner may change `packages/validation/README.md`, + `packages/example/README.md`, project-owned example Protos, example scenarios + and tests, documentation checker code/tests, current T-0011 records, and the + active project-plan row. +- The orchestrator owns worktree creation, review aggregation, full + verification, integration, remote synchronization, and cleanup. +- Immutable files under example and validation `proto/spine/` trees must remain + byte-for-byte unchanged. +- Excluded: validation runtime semantics, public validation-package exports, + dependencies, package versions, CI, publication, and `master`. + +## Decisions And Questions + +- All three example domains use string-backed ID messages. Validation options + formerly attached to primitive ID fields move to the corresponding ID value + or to required/nested validation on the containing field. +- `UserId.value` and `CategoryId.value` gain only the approved required-string + rule; no unapproved numeric-string format is invented. `ProductId.value` + retains the existing `prod-[0-9]+` pattern. +- README Proto comments document domain meaning; they do not explain generator, + descriptor, task, or implementation mechanics. +- `oneof` declarations and enum values remain documented and spaced consistently + even though the minimum human rule names messages and fields. +- The example-only serialized schema changes do not alter the public npm + package contract, but the field-kind changes warrant high-risk verification. +- No unresolved human question remains. + +## Verification + +| Command | Result | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Baseline `corepack pnpm verify` through example execution | Passed 320 tests and all preceding gates; package smoke required network access. | +| Baseline network-enabled `corepack pnpm package:check` | Passed; packed 112 files and loaded the installed ESM consumer. | +| Baseline `corepack pnpm git:check` | Passed. | +| T-0011 focused implementation checks | Passed `generate`, checker tests/checker, example tests (10), immutable Proto verification (12 files), Proto lint, generated typecheck, build, source check, formatting, and `git diff --check`. | +| Orchestrator correction verification | Passed checker tests/checker, source check, immutable Proto verification, Proto lint, formatting, build/generation, example tests (10), and `git diff --check`. | +| T-0011 correction checks | Passed checker tests/checker, generation, example tests (10), generated typecheck, immutable Proto verification (12 files), Proto lint, source check, formatting, and `git diff --check`. | +| Final checker correction checks | Passed checker tests/checker, source check, formatting, and `git diff --check`; RED evidence for F-005 and F-006 is recorded in the work log. | +| Final direct-field correction checks | Passed checker tests/checker, source check, formatting, and `git diff --check`; RED evidence for F-007 is recorded in the work log. | +| Canonical task verification | Passed all canonical gates across `corepack pnpm verify`, the network-enabled package smoke, and the final Git check; 18 files and 322 tests passed. | + +Coverage: 94.86% statements, 91.68% branches, 99.19% functions, and 96.12% +lines across 18 files and 322 tests. + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | ---------------------------------- | ----------- | ------------------------------------------------------------------------------- | +| Style/maintainability | `/root/t0011_style_rereview` | Clean | F-001 through F-007 resolved at `8e1bad2`; no new P0/P1/P2. | +| Documentation | `/root/t0011_docs_review` | Clean | Beginner wording, complete comments, spacing, and README presentation verified. | +| TypeScript/API | `/root/t0011_api_review` | Clean | Generated message shapes and consumers match; package API unchanged. | +| Performance/reliability | `/root/t0011_reliability_rereview` | Clean | Direct-field and quote regressions pass; no new P0/P1/P2. | +| Security | N/A | N/A | No security boundary changes. | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| ----- | -------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- | +| F-001 | P1 | Yes | Teach message IDs in the package and example READMEs; remove the remaining primitive README ID. | +| F-002 | P1 | Yes | Add deterministic enforcement and negative tests for all three ID messages and four accepting fields. | +| F-003 | P2 | Yes | Ignore comments and quoted strings while tracking Proto-fence braces; add regressions. | +| F-004 | P2 | Yes | Restore optional `Product.category` behavior and its category-free scenario. | +| F-005 | P2 | Yes | Bound required-and-validated field checks to the brace-aware named message body; add a later-message counterfeit regression. | +| F-006 | P2 | Yes | Track both single- and double-quoted Proto strings while stripping brace structure; add a single-quoted brace regression. | +| F-007 | P2 | Yes | Limit required-and-validated matching to immediate fields of the named message; add a nested-counterfeit regression. | + +## Integration + +- Task head: `18d747944e880c5f0a62e5449b2e6f684e63bd9a`; pushed before + integration. +- `dev` merge: `d3deaf1b8fc04d30cb5852c875e32fee11a20453`; pushed and confirmed + at `origin/dev`. +- Post-merge verification: All canonical sub-gates passed, including 322 tests, + coverage, documentation, Proto/generation, build/example execution, the + network-enabled package consumer, and Git checks. +- Remote refs before the closure record: `origin/dev` matched `d3deaf1` and + `origin/master` remained `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- CI observation: The local `gh` credential returned HTTP 401, so the remote + workflow result could not be queried from this environment. This does not + affect the completed canonical gates or SSH synchronization. +- Worktree cleanup: Removed the clean T-0011 worktree and deleted the merged + task branch locally and remotely. Preserved `.pnpm-store/` and + `validation-ts.code-workspace` in the main checkout. + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | Review point | +| --------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------- | +| Message-kind changes alter the example's serialized field wire types. | T-0011 | Regenerate and compile every consumer; document the example-only boundary. | In scope | TypeScript/API review and full gate | +| A present ID message with an empty value is the generated default nested message. | T-0011 | Preserve runtime behavior: `(required)` reports the containing ID field and recursive validation skips the default nested message. | Accepted | Example tests and TypeScript/API review | diff --git a/build-protocol/templates/DECISION_RECORD_TEMPLATE.md b/build-protocol/templates/DECISION_RECORD_TEMPLATE.md new file mode 100644 index 0000000..8b95689 --- /dev/null +++ b/build-protocol/templates/DECISION_RECORD_TEMPLATE.md @@ -0,0 +1,17 @@ +# D-: + +Date: +Status: Proposed | Accepted | Superseded + +## Context + +## Decision + +## Alternatives + +## Consequences + +## Verification And Follow-Up + +| Follow-up | Owner | Route | Disposition | Review point | +| --------- | ----- | ----- | ----------- | ------------ | diff --git a/build-protocol/templates/MICRO_TASK_RECORD_TEMPLATE.md b/build-protocol/templates/MICRO_TASK_RECORD_TEMPLATE.md new file mode 100644 index 0000000..0412fb6 --- /dev/null +++ b/build-protocol/templates/MICRO_TASK_RECORD_TEMPLATE.md @@ -0,0 +1,36 @@ +# <Task ID>: <Title> + +Status: Draft +Classification reason: +Baseline/branch: +Approved plan: + +## Requirements And Scope + +- Requirements: +- Changed files: +- Exclusions: + +## Skills + +- Selected: +- Skipped: + +## Verification + +- Commands: +- Results: + +## Review Dispositions + +- Style/maintainability: +- Documentation: +- TypeScript/API: +- Performance/reliability: +- Security: + +## Integration + +- Commit/push: +- `dev` merge/push: +- Remote refs: diff --git a/build-protocol/templates/REVIEW_LOG_TEMPLATE.md b/build-protocol/templates/REVIEW_LOG_TEMPLATE.md new file mode 100644 index 0000000..2cedd6c --- /dev/null +++ b/build-protocol/templates/REVIEW_LOG_TEMPLATE.md @@ -0,0 +1,36 @@ +# <Task ID> Review Log + +Status: Pending +Baseline: `<commit>` +Reviewed ref: `<commit or immutable diff>` +Dirty state: `<status>` + +## Review Assignments + +| Concern | Agent ID | Model | Reasoning | Scope | +| ------- | -------- | ----- | --------- | ----- | + +## Evidence + +| Evidence | Result | +| -------- | ------ | + +## Findings + +| ID | Severity | Concern | Finding | Disposition | +| --- | -------- | ------- | ------- | ----------- | + +## Correction Batch + +- Accepted findings: +- Rejected findings and reasons: +- Verification: +- Re-review: + +## Convergence + +- Style/maintainability: +- Documentation: +- TypeScript/API: +- Performance/reliability: +- Security: diff --git a/build-protocol/templates/TASK_LOG_TEMPLATE.md b/build-protocol/templates/TASK_LOG_TEMPLATE.md new file mode 100644 index 0000000..69c7865 --- /dev/null +++ b/build-protocol/templates/TASK_LOG_TEMPLATE.md @@ -0,0 +1,77 @@ +# <Task ID>: <Title> + +Status: Draft +Classification: Micro | Standard | High-risk +Baseline: `<commit>` +Branch: `<branch>` +Worktree: `<path>` +Approved plan: `<reference>` + +## Acceptance Criteria + +- <behavior-focused criterion> + +## Human-Imposed Requirements Ledger + +| Requirement | Source | Verification | +| ----------- | --------------------------------------- | ------------ | +| `<rule>` | `<human message or governing decision>` | `<evidence>` | + +## Skills + +| Skill | Selected? | Reason | +| --------- | --------- | ---------- | +| `<skill>` | Yes/No | `<reason>` | + +## Agent Dispatch + +| Role/function | Agent ID | Expected model | Expected reasoning | Scope | Status | +| ------------- | -------- | -------------- | ------------------ | --------- | ---------- | +| `<role>` | `<id>` | `<model>` | `<effort>` | `<scope>` | `<status>` | + +## Scope And Ownership + +- Owned files: +- Excluded work: + +## Decisions And Questions + +- Decisions: +- Questions: See `build-protocol/questions/UNRESOLVED.md`. + +## Verification + +| Command | Result | +| ----------- | ---------- | +| `<command>` | `<result>` | + +Coverage: + +## Review Dispositions + +| Concern | Reviewer | Disposition | Evidence | +| ----------------------- | ------------- | ----------- | -------- | +| Style/maintainability | `<id or N/A>` | Pending | | +| Documentation | `<id or N/A>` | Pending | | +| TypeScript/API | `<id or N/A>` | Pending | | +| Performance/reliability | `<id or N/A>` | Pending | | +| Security | `<id or N/A>` | Pending | | + +## Findings + +| ID | Severity | Accepted? | Resolution | +| --- | -------- | --------- | ---------- | + +## Integration + +- Task commit: +- Task push: +- `dev` merge: +- Post-merge verification: +- Remote refs: +- Worktree cleanup: + +## Open Risks And Follow-Up + +| Risk | Owner | Route | Disposition | Review point | +| ---- | ----- | ----- | ----------- | ------------ | diff --git a/build-protocol/templates/UNRESOLVED_QUESTIONS_TEMPLATE.md b/build-protocol/templates/UNRESOLVED_QUESTIONS_TEMPLATE.md new file mode 100644 index 0000000..71d06fc --- /dev/null +++ b/build-protocol/templates/UNRESOLVED_QUESTIONS_TEMPLATE.md @@ -0,0 +1,15 @@ +# Unresolved Questions + +## Q-<ID>: <Question> + +Status: Blocking | Advisory | Resolved +Task: +Raised: + +### Context + +### Options + +### Human Answer Or Decision + +### Incorporated In diff --git a/build-protocol/templates/WORK_LOG_TEMPLATE.md b/build-protocol/templates/WORK_LOG_TEMPLATE.md new file mode 100644 index 0000000..b13b734 --- /dev/null +++ b/build-protocol/templates/WORK_LOG_TEMPLATE.md @@ -0,0 +1,16 @@ +# <Task ID> Work Log + +Task: `<task record>` +Branch: `<branch>` +Baseline: `<commit>` + +## Entries + +### <timestamp> โ€” <boundary> + +- Work: +- Files: +- Commands and results: +- Decisions: +- Risks: +- Next action: diff --git a/build-protocol/work-logs/T-0001.md b/build-protocol/work-logs/T-0001.md new file mode 100644 index 0000000..87c489e --- /dev/null +++ b/build-protocol/work-logs/T-0001.md @@ -0,0 +1,106 @@ +# T-0001 Work Log + +Task: `build-protocol/tasks/T-0001-protocol-bootstrap/TASK.md` +Branch: `task/t-0001-protocol-bootstrap` +Baseline: `c7527325ce2130e3766bacc6effabc1af238f2b6` + +## Entries + +### 2026-07-24T12:31:07Z โ€” Framing And Isolation + +- Verified `origin/master` at `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Fast-forwarded local `master`, created and pushed `dev`. +- Added the ignored `.worktrees/` prerequisite on `dev`. +- Created the isolated task branch and worktree. +- Installed baseline dependencies and ran 11 passing suites / 232 tests. +- Refreshed the official Codex manual and selected task skills. +- Began the permanent governance and role configuration. +- Next: complete read-only provenance/dependency research, then package and + verification implementation. + +### 2026-07-24 โ€” Research Resolution + +- The Proto research agent resolved current upstream references to + `SpineEventEngine/base-libraries@7a05857b2adc68dc2f9b28a03ae3073e7c3e9df3` + and `SpineEventEngine/time@57d3dd98fea8efcdc4a3843f91143acc2dce87dc`. +- The existing local `options.proto` copies are identical to one another but + do not match current upstream. They are retained byte-for-byte as the frozen + legacy baseline; current upstream commits and checksums are recorded for a + separately approved intake. +- The dependency research agent confirmed exact modern quality-tool pins + compatible with the retained npm/Jest/CommonJS stack and aligned them with + the current Spine TS reference. +- Both research agents completed read-only assignments and were closed. + +### 2026-07-24 โ€” Implementation And Mechanical Verification + +- Installed the permanent protocol, current Sol/Terra role profiles, durable + task/review/decision templates, and immutable Proto provenance manifest. +- Renamed the package directory and all package/import/documentation surfaces + to `@spine-event-engine/validation`; advanced all workspace versions to + `2.0.0-snapshot.5`. +- Added the committed npm lockfile, exact tool pins, Node policy, strict test + typecheck, ESLint, Prettier, TypeDoc, coverage thresholds, Buf lint, + generated determinism, package-content/consumer, and Git hygiene gates. +- Preserved npm, Jest, and CommonJS. Kept publishing automatic on `master` + push, while making it consume the same full root verification gate. +- `npm ci` passed. +- `npm run verify` passed all 13 root gates: node policy, Proto provenance, + generation, strict type checking, lint, formatting, coverage, API docs, Buf + lint, generation determinism, builds, package consumer, and Git hygiene. +- Test result: 11 suites and 232 tests passed. Coverage was 81.88% statements, + 71.01% branches, 92.18% functions, and 81.48% lines. +- Packed 72 published files and loaded the installed CommonJS API from an + isolated consumer. +- Next: freeze the implementation commit and run the complete independent + review wave. + +### 2026-07-24 โ€” Review Wave And Correction Batch + +- Froze implementation ref `4d4a04b41490e88b0848536cf6271cc484c9f86d`. +- Completed four read-only review lanes and closed every reviewer. +- TypeScript/API review was clean. +- Accepted five findings: broad Buf relaxation, incompatible Node 18 policy, + stale review metadata, overstated `(required)` documentation, and an + unsupported `(set_once)` example. +- Switched all modules from `MINIMAL` to `STANDARD` Buf lint with exact + path-and-rule exceptions for immutable sources and pre-existing fixture + compatibility. Focused `npm run proto:lint` passed. +- Aligned engines, the root checker, and CI to Node 24, matching the reference + protocol and all pinned development tools. +- Corrected the affected README and TypeDoc-facing claims and refreshed the + package lock. +- Re-review found three residual P2s: the initial record expanded the + implementation SHA incorrectly, the test Buf config had no-op paths, and the + supported-feature lists still conflated the frozen contract with current + enum/map runtime gaps. Corrected all three without changing runtime behavior. +- Focused Proto provenance/lint, TypeDoc, formatting, Git hygiene, and + commit-object checks passed after the final cleanup. +- Two review waves are complete, no P0/P1 remains, and every accepted P2 is + resolved. +- Final `npm run verify` passed all 13 gates on the corrected tree. Jest passed + 232 tests with 81.88% statements, 71.01% branches, 92.18% functions, and + 81.48% lines. Generated output retained digest + `8b58b42ad69650c0b1f40a4b2d39959ab851cfb845f2be33e537e64a911fe552`; + the packed 72-file CommonJS consumer loaded successfully. +- Next: commit branch HEAD, push the task branch, merge to `dev`, verify the + integration tree, push `dev`, confirm remote refs, and remove the worktree. +- The first post-merge gate correctly preserved but attempted to format the + user's untracked `validation-ts.code-workspace`. Added a generic + `*.code-workspace` ignore so editor-local state cannot break repository + verification; the file itself remains untouched. + +### 2026-07-24 โ€” Integration And Remote Closure + +- Merged the reviewed task through `7d89378`, then merged the editor-ignore + correction through `615649a`. +- Removed only reproducible generated/dist/coverage remnants under the obsolete + `packages/spine-validation-ts/` path. Preserved the unrelated untracked + `validation-ts.code-workspace`. +- On merged `dev`, `npm ci` and the full 13-gate `npm run verify` passed. +- Verified remote integration refs: + `dev@615649a0499d3c5763f40451a0c04a4df8621557` and + `task/t-0001-protocol-bootstrap@2b6a1bbc8214339962995a07dcfc4b1658739229`. +- This closure record is the final task change. After it is merged, final refs + are checked directly, all agents remain closed, and the clean worktree is + removed. diff --git a/build-protocol/work-logs/T-0002.md b/build-protocol/work-logs/T-0002.md new file mode 100644 index 0000000..9aa4892 --- /dev/null +++ b/build-protocol/work-logs/T-0002.md @@ -0,0 +1,608 @@ +# T-0002 Work Log + +Task: `../tasks/T-0002-validation-correctness/TASK.md` +Branch: `task/t-0002-validation-correctness` +Baseline: `09b94d03828fb6f1ed264398dced327dbdaa67b5` + +## Entries + +### 2026-07-24T23:15:00+01:00 โ€” Task 6 dispatched + +- Work: Reconciled leaf-only nested validation against the approved human + decisions, frozen `(validate)` documentation, and the JVM-test outcome. +- Dispatch: `/root/implementer_nested`, project role `implementer`, + `gpt-5.6-terra`, medium reasoning, with sole Task 6 production ownership. +- Boundaries: Preserve the root validation context, emit leaf violations only, + use field-name-only paths, recurse explicit repeated/map defaults, and + resolve `Any` only through the root descriptor dependency closure. +- Exclusions: No frozen Proto changes, dependencies, regex work, recursion + limits, or deprecated parent-summary diagnostics. +- Next action: Await TDD implementation, independently run the focused and + full gates, and submit the immutable Task 6 diff for scoped review. + +### 2026-07-24T23:45:00+01:00 โ€” Task 6 implementation verified + +- Work: Independently verified commit `e8c9d87` and built the immutable + `bb0895a..e8c9d87` review package. +- TDD evidence: Seven focused assertions first failed against legacy parent + summaries, collection indexes, and nested root resets; a later compile + failure exposed and resolved the descriptor/generator seam. +- Verification: Validate and integration tests passed 2 suites and 57 tests; + the full package passed 14 suites and 285 tests; generated TypeScript and + diff whitespace checks passed. +- Dispatch: Task 6 scoped review assigned to `/root/task1_review`, + `gpt-5.6-terra`, high reasoning. +- Next action: Resolve any concrete Task 6 findings, then record the clean + checkpoint before the coverage and documentation slice. + +### 2026-07-24T23:50:00+01:00 โ€” Post-Task-6 coverage measured + +- Verification: `npm run test:coverage` passed all 14 suites and 285 tests at + 92.23% statements, 87.37% branches, 94.17% functions, and 93.53% lines. +- Decision: Task 7 must raise branch coverage by at least 2.63 percentage + points and then enforce 90% globally across all four dimensions. Statements, + functions, and lines already exceed the target but remain non-regression + gates. +- Next action: Complete Task 6 review, then dispatch the narrow + coverage/documentation slice. + +### 2026-07-24T23:55:00+01:00 โ€” Task 6 accepted + +- Review: The scoped reviewer approved `bb0895a..e8c9d87` with no actionable + P0-P2 findings. +- Outcome: Leaf-only recursion, root context, complete field-name-only paths, + collection defaults, bounded Any visibility, packed leaf envelopes, and + configuration errors are accepted. +- Next action: Commit this accepted checkpoint and dispatch Task 7 coverage, + thresholds, and public documentation. + +### 2026-07-25T00:00:00+01:00 โ€” Task 7 dispatched + +- Work: Converted the fresh coverage report and corrected runtime contract + into a bounded tests, threshold, and documentation brief. +- Dispatch: `/root/implementer_coverage`, project role `implementer`, + `gpt-5.6-terra`, medium reasoning, with sole Task 7 write ownership. +- Boundaries: Reach and enforce 90% across all dimensions through meaningful + branch coverage; correct stale public claims; document structured errors, + violation envelopes, leaf recursion, distinct behavior, and the unresolved + Java-regex limitation. +- Exclusions: No dependencies, tooling/module migrations, regex behavior, + frozen Proto changes, or orchestrator record edits. +- Next action: Independently verify the 90% coverage gate and documentation + checks, then begin the whole-branch specialist review wave. + +### 2026-07-25T00:20:00+01:00 โ€” Task 7 implementation verified + +- Work: Independently verified commit `ede45ea`, which adds branch-focused + contract tests, exact 90% global thresholds, corrected public documentation, + and affected API comments. +- Verification: Generated TypeScript passed; coverage passed 14 suites and 289 + tests at 94.72% statements, 91.44% branches, 98.05% functions, and 95.87% + lines; ESLint and TypeDoc passed. TypeDoc retained two existing + highlight-language warnings and zero errors. +- Outcome: All four coverage dimensions exceed and enforce the human-approved + 90% target. The README no longer claims resolved presence debt or Java + regex parity. +- Next action: Record whole-branch review dispatch metadata and run the + complete specialist review wave. + +### 2026-07-25T00:25:00+01:00 โ€” Whole-branch specialist review dispatched + +- Reviewed ref: `9448506`; immutable range + `09b94d03828fb6f1ed264398dced327dbdaa67b5..9448506`. +- Parallel dispatch: `/root/style_final`, project role + `style_maintainability_reviewer`, `gpt-5.6-terra`, high reasoning; + `/root/docs_final`, project role `documentation_reviewer`, + `gpt-5.6-terra`, medium reasoning; `/root/api_final`, project role + `typescript_api_reviewer`, `gpt-5.6-terra`, high reasoning. +- Queued dispatch: `/root/reliability_final`, project role + `performance_reliability_reviewer`, `gpt-5.6-terra`, high reasoning, starts + when one parallel slot closes. +- Review method: Read-only, P0-P2 findings only, one deduplicated correction + batch after the complete wave. Security remains N/A under D-0004. +- Next action: Collect all four reviews before accepting or correcting any + finding. + +### 2026-07-25T00:50:00+01:00 โ€” Specialist findings aggregated + +- Review outcome: Collected and closed all four reviewers before correction. + Accepted F-017 through F-023; no findings were rejected. +- Runtime findings: Replace regex/replacement-string placeholder substitution + with literal callback substitution; make `NaN` fail numeric constraints; and + remove the exported recursion helper plus its avoidable circular import. +- Documentation findings: Remove resolved presence debt, surface the regex + caveat in the root README, correct the Quick Start placeholder, and remove + unsupported parentheses from the `(require)` example. +- Dispatch: One deduplicated correction batch assigned to + `/root/implementer_corrections`, project role `implementer`, + `gpt-5.6-terra`, medium reasoning. +- Next action: Independently verify the correction, then re-review only the + substantively affected style, API, reliability, and documentation concerns. + +### 2026-07-25T01:15:00+01:00 โ€” Final correction batch verified + +- Work: Independently verified commit `35df598`, resolving F-017 through F-023 + in one implementation-owner batch. +- TDD evidence: The new formatter and `NaN` regressions failed before their + runtime corrections. +- Verification: Focused formatter, numeric, and nested tests passed 3 suites + and 62 tests; the coverage gate passed all 14 suites and 292 tests at 94.72% + statements, 91.53% branches, 99.03% functions, and 95.87% lines. Generated + TypeScript, ESLint, TypeDoc, Prettier, and diff whitespace checks passed. +- Re-review: Redispatched only F-017 through F-023 to their affected + specialists with the immutable correction range `0690ce8..35df598`. +- Next action: Confirm convergence, then run the canonical full verification + gate. + +### 2026-07-25T01:25:00+01:00 โ€” Reliability test gap accepted + +- Re-review: Style, documentation, and TypeScript/API concerns are clean. + Reliability confirmed the `NaN` runtime fix but identified F-024: the + repeated regression matrix omits float/max coverage and explicit Infinity + and bigint non-regressions. +- Decision: Accepted F-024 as a bounded P2 test-protection gap; production + semantics remain unchanged. +- Dispatch: Returned only F-024 to `/root/implementer_corrections`, + `gpt-5.6-terra`, medium reasoning. +- Next action: Verify the added matrix and request one final reliability + affected-concern re-review. + +### 2026-07-25T01:35:00+01:00 โ€” Specialist review converged + +- Work: Independently verified `dca5f17`; the numeric contract suite passed 27 + tests, generated TypeScript passed, and the diff whitespace check passed. +- Re-review: Reliability confirmed F-024 resolved with repeated float/max + `NaN`, Infinity, bigint boundary, path, and ordering coverage. All other + affected specialist re-reviews were already clean. +- Convergence: F-001 through F-024 are resolved. Style/maintainability, + documentation, TypeScript/API, and performance/reliability have no remaining + actionable P0-P2 findings. Security remains N/A under D-0004. +- Next action: Run the canonical `npm run verify` completion gate. + +### 2026-07-25T01:45:00+01:00 โ€” Canonical gate stopped at TypeDoc + +- Failure: `npm run verify` passed Node policy, immutable Proto verification, + generation, generated typing, lint, formatting, and 293-test coverage, then + stopped because TypeDoc returned exit code 5 for two warnings. +- Root cause: `treatWarningsAsErrors` is intentional, while `typedoc.json` + omits `js` from `highlightLanguages`. Inherited `Error` documentation exposed + through `ValidationConfigurationError` contains two JavaScript code fences. +- Evidence: `npm run docs:check` reproduces exit 5. Overriding the configured + highlight set with only `js` removes those two warnings and reveals expected + warnings for every omitted project language, confirming the configuration + mismatch. +- Dispatch: Accepted F-025 and returned the one-line highlighting correction + to `/root/implementer_corrections`, `gpt-5.6-terra`, medium reasoning. +- Next action: Prove TypeDoc exits zero, obtain bounded documentation + re-review, and rerun `npm run verify` from the beginning. + +### 2026-07-25T01:55:00+01:00 โ€” TypeDoc gate correction accepted + +- Work: Independently verified `78b9eed`; TypeDoc exits zero without warnings, + strict `treatWarningsAsErrors` remains enabled, formatting passes, and the + correction diff is clean. +- Re-review: Documentation approved the single allowlist addition with no new + actionable P0-P2 finding. F-025 is resolved and the reviewer is closed. +- Next action: Rerun the canonical `npm run verify` gate from the beginning. + +### 2026-07-25T02:05:00+01:00 โ€” Canonical verification passed + +- Verification: `npm run verify` passed Node 24 policy, six-file immutable + Proto provenance, generation, generated typing, ESLint, Prettier, 14 suites + and 293 tests, TypeDoc without warnings, project-owned Buf lint, + deterministic generation, TypeScript builds, packed CommonJS consumer + installation, and Git hygiene. +- Coverage: 94.72% statements, 91.53% branches, 99.03% functions, and 95.87% + lines; all committed 90% thresholds pass. +- Outcome: The reviewed task tree is ready for integration. `master` remains + untouched. +- Next action: Commit closure evidence, push the task branch, merge to `dev`, + and perform the high-risk post-merge full gate. + +### 2026-07-25T02:20:00+01:00 โ€” Integration and remote closure + +- Integration: Pushed the verified task branch, merged it to `dev` as + `86d6092e94e8fb7b5e6b4778237d65acfc927c2c`, and preserved the unrelated + untracked `validation-ts.code-workspace`. +- Post-merge verification: Full `npm run verify` passed again on merged `dev`, + including 293 tests and 94.72% statements, 91.53% branches, 99.03% + functions, and 95.87% lines. +- Remote verification: Confirmed + `origin/dev@86d6092e94e8fb7b5e6b4778237d65acfc927c2c`, + `origin/task/t-0002-validation-correctness@a8e1baf763bd42e7b5e6c7009bd5f2eeaa1c3c69`, + and unchanged + `origin/master@24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Closure: This is the final task record. After merging and pushing it, final + refs are checked directly and the clean task worktree is removed. + +### 2026-07-24T17:12:48+01:00 โ€” Approval, reconciliation, and isolated setup + +- Work: Recorded the approved high-risk correctness milestone, selected + workflow skills, human requirements, exclusions, and expected agent + dispatch metadata. +- Files: Task record, project plan, technical specification, unresolved + questions, review log, and this work log. +- Commands and results: Refreshed `origin`; confirmed `dev` and `origin/dev` + both at `09b94d0`; preserved the untracked user-owned + `validation-ts.code-workspace`; verified `.worktrees/` is ignored; created + `task/t-0002-validation-correctness` in the approved worktree. +- Decisions: Java-regex compatibility remains unchanged and advisory; Buf + equality governs `(distinct)`; missing message text remains an empty + diagnostic. +- Risks: This task changes validation semantics and serialized violation + content and is therefore high-risk. +- Next action: Install the locked dependency tree, run the baseline suite, and + dispatch requirements splitting. + +### 2026-07-24T17:15:00+01:00 โ€” Baseline verification and requirements split + +- Work: Installed the exact lockfile state, verified the pre-change test + baseline, and dispatched the selective requirements splitter for the + approved validation-semantics work. +- Files: Task and work logs only. +- Commands and results: `npm ci` passed with 430 packages installed and + existing dependency deprecation/allow-script warnings; `npm test` passed + all 11 suites and 232 tests; `npm run test:coverage` passed with 81.88% + statements, 71.01% branches, 92.18% functions, and 81.48% lines. +- Decisions: The baseline warnings are recorded but do not expand this task + into dependency migration. +- Risks: None beyond the already recorded high-risk semantic scope. +- Next action: Incorporate the requirements split, commit the task boundary, + and dispatch the single TypeScript implementation owner. + +### 2026-07-24T17:21:00+01:00 โ€” Requirements split incorporated + +- Work: Closed the requirements splitter and recorded seven ordered + implementation slices in `IMPLEMENTATION_PLAN.md`. +- Files: Implementation plan, task record, and work log. +- Commands and results: The splitter reported no blocking conflict. Focused + Markdown formatting identified three layout changes, which were applied. +- Decisions: Locked the four-code public configuration-error vocabulary. + Corrected the proposed collection-path treatment against the frozen + `FieldPath` contract: indices and map keys are not Proto field names and + will not be inserted into paths. +- Risks: Message equality has no matching structural hash and may be quadratic; + correctness takes precedence. Recursive-cycle limits and Java regex remain + excluded future work. +- Next action: Verify and commit the setup boundary, then dispatch Task 1 to + the single implementation owner with TDD. + +### 2026-07-24T18:00:00+01:00 โ€” Task 1 contract kernel implementation boundary + +- Work: Added the shared validation context, descriptor-aware violation construction and `Any` packing, present template resolution, and public `ValidationConfigurationError` diagnostics. Added focused contract coverage without changing frozen Proto sources, generated artifacts, or regex behavior. +- TDD evidence: expected feature-absence REDs for the missing public error export and missing `validation-contract`; final focused command passed 2 suites and 7 tests. +- Verification: package TypeScript check, targeted Prettier and ESLint, and the complete package suite passed (12 suites, 236 tests). +- Decisions: scalar and collection-element values pack through current Buf wrapper schemas, enum values through `Int32Value`, and message values through their descriptor. Custom text takes precedence over default; with neither, a present empty `TemplateString` is emitted. +- Risks: Task 1 intentionally introduces the kernel without rewiring existing validators. Task 2 owns adaptation to the shared orchestration path. +- Next action: Commit this Task 1 boundary, then submit the immutable commit for the planned review wave and Task 2 handoff. + +### 2026-07-24T18:15:00+01:00 โ€” Task 1 F-001 message-level correction + +- Work: Accepted F-001 and made the shared violation kernel support + message-level constraints without a field descriptor or offending value. +- TDD evidence: the added regression test failed as expected with TS2345 because + `undefined` was not assignable to `DescField`; after the minimal interface + correction, the focused suite passed 2 suites and 8 tests. +- Verification: `npx tsc -p packages/validation/tsconfig.json --noEmit` passed. +- Decisions: message-level violations have a present empty `FieldPath`, no + `fieldValue`, a present `TemplateString`, and `${message.type}`. Field + placeholders and packing occur only with both a descriptor and concrete value. +- Next action: Commit the correction while preserving the orchestrator-owned + task and review records. + +### 2026-07-24T18:25:00+01:00 โ€” Task 1 F-002 placeholder correction + +- Work: Accepted F-002 and split descriptor metadata placeholders from + concrete-field-value handling in the contract kernel. +- TDD evidence: the focused regression failed with the expected absent + `parent.type`, `field.path`, and `field.type` placeholders; after the + minimal split, the focused suite passed 2 suites and 9 tests. +- Verification: `npx tsc -p packages/validation/tsconfig.json --noEmit` and + `git diff --check` passed. +- Decisions: `${message.type}`, `${parent.type}`, `${field.path}`, and + `${field.type}` exist whenever a field descriptor exists; only + `${field.value}` and packed `fieldValue` need a concrete value. +- Next action: Commit the correction while preserving orchestrator-owned task + and review records. + +### 2026-07-24T18:35:00+01:00 โ€” Task 1 F-003 scalar type formatting correction + +- Work: Accepted F-003 and replaced numeric `ScalarType.toString()` rendering + with an explicit canonical Proto scalar-name mapping. +- TDD evidence: singular `string` expectation first failed with received value + `9`; after the mapping, focused tests passed 2 suites and 9 tests. +- Verification: `npx tsc -p packages/validation/tsconfig.json --noEmit` and + `git diff --check` passed. +- Coverage: contract tests assert canonical `string` for singular and repeated + scalar descriptors and `int32` for a scalar map value; message and enum + type-name behavior remains unchanged. +- Next action: Commit this focused correction while preserving orchestrator + records. + +### 2026-07-24T18:45:00+01:00 โ€” Task 1 accepted + +- Work: Independently verified the complete four-commit Task 1 implementation + and submitted the immutable `2ccab8a..4848a4c` review package for final + scoped re-review. +- Verification: focused tests passed 2 suites and 9 tests; package TypeScript + compilation and `git diff --check 2ccab8a..HEAD` passed. +- Review: The Task 1 reviewer approved specification compliance and task + quality with no actionable P0-P2 findings and confirmed F-001 through F-003 + resolved. +- Next action: Commit the accepted checkpoint and dispatch deterministic + orchestration to the same sequential production-code owner. + +### 2026-07-24T18:50:00+01:00 โ€” Task 2 dispatched + +- Work: Dispatched deterministic orchestration to the sole production-code + owner with the immutable Task 1 checkpoint `716cc27` as baseline. +- Dispatch: `/root/implementer`, `gpt-5.6-terra`, medium reasoning. +- Boundaries: field-first traversal, fixed validator order, shared violation + envelopes, field-name-only paths, and stable repeated-element ordering. + Option semantics remain reserved for Tasks 3 through 6, and pattern behavior + is mechanically adapted without regex changes. +- Next action: Await implementation, independently run the focused gate, and + submit the complete Task 2 diff for scoped review. + +### 2026-07-24T19:10:00+01:00 โ€” Task 2 implementation verified + +- Work: Independently verified `da26780` and built the immutable + `716cc27..da26780` review package. +- TDD evidence: the new ordering test first exposed validator-first output; + after implementation, the field-first expectation passed. +- Verification: ordering/integration passed 2 suites and 31 tests; the direct + option regression set passed 10 suites and 229 tests; package TypeScript + compilation and diff whitespace checks passed. +- Dispatch: Task 2 scoped review assigned to `/root/task1_review`, + `gpt-5.6-terra`, high reasoning. +- Next action: Resolve any concrete Task 2 findings, then record the clean + checkpoint before presence semantics. + +### 2026-07-24T19:20:00+01:00 โ€” Task 2 correction batch dispatched + +- Review: Accepted F-004 and F-005: `(choice)` must not place a oneof name in + `FieldPath`, and ordering coverage must distinguish same-field validators + and exact repeated-element sequence. +- Orchestrator finding: Accepted F-006 after reconciling the compatibility + adapter with the Task 1 contract. Whole collections must not be passed to + element packers, and the kernel must not silently suppress packing failures. +- Dispatch: Returned one deduplicated batch to `/root/implementer`, + `gpt-5.6-terra`, medium reasoning. +- Next action: Independently verify the correction and re-review only the + affected Task 2 concerns. + +### 2026-07-24T19:10:00+01:00 โ€” Task 2 deterministic orchestration implementation + +- Work: Added the internal field-validator adapter contract and changed + `validate()` to message-first, field-first, fixed-validator traversal, with + oneof choice last. Legacy option decision logic is retained behind adapters; + each adapter normalizes its output through the shared violation envelope. +- TDD evidence: the new ordering regression first failed with validator-first + paths (`email`, `password`, and `age` preceding the message constraint and + `id`); after orchestration it passed. +- Verification: ordering/integration focused gate passed 2 suites and 31 + tests. Direct option regression wave passed 11 suites and 230 tests. + Package TypeScript compilation, targeted ESLint/Prettier, and diff whitespace + checks passed. +- Decisions: repeated indexes and map keys are removed from `FieldPath`; nested + Proto field names remain. The shared envelope packs representative offending + collection elements where Buf can represent them. +- Next action: Commit only implementation-owner files and submit the diff for + scoped review; keep orchestrator task/log records uncommitted. + +### 2026-07-24T19:25:00+01:00 โ€” Task 2 F-004 through F-006 correction batch + +- Work: choice now remains message-level with an empty path; adapter extraction + handles legacy bracketed repeated paths; strict shared-kernel packing restored. +- TDD: choice path and repeated-pattern Any regressions failed before the + correction and passed after it. +- Verification: affected suite wave passed 11 suites and 231 tests; TypeScript, + targeted lint/format, and diff whitespace checks passed. +- Next action: commit implementation-only correction and retain orchestration + records for the parent agent. + +### 2026-07-24T19:35:00+01:00 โ€” Task 2 accepted + +- Work: Independently verified `b831f22` and submitted the complete + `716cc27..b831f22` Task 2 package for affected-concern re-review. +- Verification: ordering, integration, choice, and pattern passed 4 suites and + 52 tests; package TypeScript compilation and diff whitespace checks passed. +- Review: Task 2 specification and quality approved with no remaining + actionable P0-P2 findings. F-004 through F-006 are resolved. +- Next action: Commit the accepted Task 2 checkpoint and dispatch Task 3 + presence semantics. + +### 2026-07-24T19:40:00+01:00 โ€” Task 3 dispatched + +- Work: Reconciled Task 3 against the frozen `(required)`, `(require)`, + `(goes)`, `(choice)`, `IfMissingOption`, `RequireOption`, `GoesOption`, and + `ChoiceOption` documentation and dispatched the presence slice. +- Dispatch: `/root/implementer`, `gpt-5.6-terra`, medium reasoning. +- Decisions: One descriptor-aware supported-field predicate governs presence; + oneof selection is the documented exception. Invalid configuration throws + one of the four approved public codes. Parentheses and malformed `&`/`|` + expressions are invalid, not an extended grammar. +- Next action: Await TDD implementation, run the focused presence gate, and + submit the complete slice for scoped review. + +### 2026-07-24T20:00:00+01:00 โ€” Task 3 implementation owner replaced + +- Work: Closed `/root/implementer` after repeated bounded turns retained only + the green presence/required groundwork and did not complete `(require)`. +- State: No incomplete parser or Task 3 commit was accepted. The retained + `required` suite passes 8 tests and package TypeScript compilation passes. +- Dispatch: Assigned the required/require half to + `/root/implementer_presence`, project role `implementer`, + `gpt-5.6-terra`, medium reasoning, with sole production ownership. +- Next action: Independently verify the required/require commit, then continue + the goes/choice half with no overlapping writer. + +### 2026-07-24T20:25:00+01:00 โ€” Task 3 required/require half verified + +- Work: The replacement owner completed `2494e26`, migrating `(required)` and + `(require)` to shared presence, shared diagnostics, strict expression grammar, + oneof references, and public configuration errors. +- Verification: Independent focused tests passed 2 suites and 43 tests; package + TypeScript compilation and diff whitespace checks passed. +- Dispatch: Continued `/root/implementer_presence`, `gpt-5.6-terra`, medium + reasoning, as sole owner for the remaining `(goes)` and `(choice)` half. +- Next action: Verify the full four-option Task 3 gate and submit the complete + slice for scoped review. + +### 2026-07-24T20:50:00+01:00 โ€” Task 3 full-suite fixture correction + +- Work: Independent full-package verification exposed 17 legacy fixtures in + min/max, range, and nested-validation suites that still declared numeric + `(required)` targets. The production behavior correctly rejected them. +- Resolution: The implementation owner audited all project-owned test Proto + files, migrated every non-negative unsupported presence declaration while + retaining intentional invalid-schema coverage, and committed the correction + in `b3c5b1f`. +- Verification: Independent full package passed 13 suites and 244 tests; + package TypeScript compilation and diff whitespace checks passed. +- Dispatch: Submitted the complete `d1b21de..b3c5b1f` Task 3 slice to + `/root/task1_review`, `gpt-5.6-terra`, high reasoning. +- Next action: Resolve concrete review findings and record the clean Task 3 + checkpoint before numeric bounds. + +### 2026-07-24T21:10:00+01:00 โ€” Task 3 correction batch dispatched + +- Review: Accepted F-007 through F-009. Enum presence needs an explicit numeric + guard; bytes/maps need required-presence coverage; goes companion errors and + numeric-zero/boolean-false choice selection need regression coverage. +- Dispatch: Returned the deduplicated batch to + `/root/implementer_presence`, `gpt-5.6-terra`, medium reasoning. +- Next action: Independently run the full suite and re-review only the affected + Task 3 concerns. + +### 2026-07-24T21:30:00+01:00 โ€” Task 3 final correction batch dispatched + +- Re-review: F-007 through F-009 cleared. Accepted F-010 and F-011 for restored + source headers and completion of boolean/grammar negative coverage. +- Dispatch: Returned the bounded batch to + `/root/implementer_presence`, `gpt-5.6-terra`, medium reasoning. +- Next action: Run the complete Task 3 gate and one affected-concern re-review. + +### 2026-07-24T21:45:00+01:00 โ€” Task 3 accepted + +- Work: Verified the final header-only correction `95c520d` by exact comparison + with `validation-contract.ts`, package TypeScript compilation, and diff + whitespace checks. +- Verification: The final Task 3 full package passed 13 suites and 249 tests. +- Review: Task 3 is clean with no remaining P0-P2 findings; F-007 through + F-011 are resolved. +- Next action: Commit the accepted Task 3 checkpoint and dispatch exact numeric + bounds and references. + +### 2026-07-24T21:55:00+01:00 โ€” Task 4 dispatched + +- Work: Reconciled exact numeric literal/range grammar, scalar limits, + bigint-safe comparisons, nested descriptor references, default intermediate + messages, diagnostics, and configuration errors against the frozen Proto. +- Dispatch: `/root/implementer_numeric`, project role `implementer`, + `gpt-5.6-terra`, medium reasoning, with sole numeric production ownership. +- Boundaries: No frozen Proto edits, dependencies, warnings/silent skips, + bigint-to-number coercion, or changes to regex/distinct/nested semantics. +- Next action: Independently verify coherent numeric commit boundaries and + submit the full Task 4 package for scoped review. + +### 2026-07-24T22:20:00+01:00 โ€” Task 4 implementation complete + +- Work: Replaced the legacy partial numeric parsers and warning-based skips + with a shared exact numeric kernel. `(min)`, `(max)`, and `(range)` now + validate targets and declarations before values, preserve 64-bit values as + `bigint`, resolve descriptor references through default intermediate + messages, use field-name-only collection paths, and create shared packed + violation envelopes. +- Commit: `e9ae368 fix(validation): enforce exact numeric bounds`. +- TDD: New contract tests first failed because later invalid declarations in a + single fixture prevented earlier value assertions; fixtures were split into + one-declaration schemas, then passed after the direct validators were wired. +- Verification: focused numeric wave passed 3 suites and 72 tests; full + package passed 14 suites and 260 tests; TypeScript compilation, focused + ESLint, Prettier, and diff whitespace checks passed. +- Risk: Existing project-owned custom diagnostics using legacy `value`/`other` + placeholders retain those aliases alongside the documented namespaced + placeholders. Java-regex behavior remains untouched. +- Next action: Submit the numeric slice for scoped correctness and + maintainability review. + +### 2026-07-24T22:25:00+01:00 โ€” Task 4 independently verified + +- Work: Independently verified `e9ae368` and generated the immutable + `509b031..e9ae368` numeric review package. +- Verification: Full package passed 14 suites and 260 tests; package TypeScript + compilation and diff whitespace checks passed. +- Dispatch: Assigned scoped review to `/root/task1_review`, + `gpt-5.6-terra`, high reasoning. +- Next action: Correct concrete numeric findings and record the clean Task 4 + checkpoint. + +### 2026-07-24T22:40:00+01:00 โ€” Task 4 correction batch dispatched + +- Review: Accepted F-012 through F-014 for exact range-text preservation, + completion of the numeric edge matrix, full structured error assertions, and + restored standard source headers. +- Dispatch: Returned the deduplicated batch to + `/root/implementer_numeric`, `gpt-5.6-terra`, medium reasoning. +- Next action: Independently run the full numeric/package gates and re-review + the affected concerns. + +### 2026-07-24T23:00:00+01:00 โ€” Task 4 header correction dispatched + +- Re-review: F-012 through F-014 cleared. Accepted F-015 for the truncated + header in the new numeric contract test. +- Dispatch: Returned the header-only correction to + `/root/implementer_numeric`, `gpt-5.6-terra`, medium reasoning. +- Next action: Verify exact header equality and close Task 4 review. + +### 2026-07-24T23:10:00+01:00 โ€” Task 4 accepted + +- Work: Independently verified `d6dc7a3` by exact test-header comparison, + package TypeScript compilation, and diff whitespace checks. +- Verification: Final Task 4 full package passed 14 suites and 273 tests. +- Review: Task 4 is clean with no remaining P0-P2 findings; F-012 through + F-015 are resolved. +- Next action: Commit the accepted Task 4 checkpoint and dispatch Buf-equality + distinct semantics. + +### 2026-07-24T23:20:00+01:00 โ€” Task 5 dispatched + +- Work: Reconciled Buf scalar/message equality, equality-class ordering, + one-violation-per-class behavior, and the approved duplicate representation. +- Dispatch: `/root/implementer_distinct`, project role `implementer`, + `gpt-5.6-terra`, medium reasoning, with sole distinct production ownership. +- Boundaries: No custom Protobuf comparator, object identity, indices/map keys + in paths, dependencies, frozen Proto edits, or regex/nested changes. +- Next action: Independently verify the distinct and full-package gates and + submit Task 5 for scoped review. + +### 2026-07-24T23:45:00+01:00 โ€” Task 5 implementation verified + +- Work: Independently verified `43a7b60`, including Buf equality dispatch, + equality-class ordering, representative packing, and collection diagnostics. +- Verification: Full package passed 14 suites and 280 tests; + `npm run typecheck:generated` and diff whitespace checks passed. +- Dispatch: Submitted `0567357..43a7b60` to `/root/task1_review`, + `gpt-5.6-terra`, high reasoning. +- Next action: Correct concrete distinct findings and record the clean Task 5 + checkpoint. + +### 2026-07-24T23:55:00+01:00 โ€” Task 5 correction dispatched + +- Review: Accepted F-016. Buf equality, equality-class order, representative + packing, and target behavior are clean; the string singleton diagnostic must + use `[A]`/`[B]` rather than JSON quotes. +- Dispatch: Returned the formatter correction to + `/root/implementer_distinct`, `gpt-5.6-terra`, medium reasoning. +- Next action: Verify the approved representation and re-review Task 5. + +### 2026-07-25T00:10:00+01:00 โ€” Task 5 accepted + +- Work: Independently verified `88bc9b3` and submitted the complete + `0567357..88bc9b3` package for affected-concern re-review. +- Verification: Distinct passed 33 tests; full package passed 14 suites and + 280 tests; generated typechecks and diff whitespace checks passed. +- Review: Task 5 is clean with no remaining P0-P2 findings; F-016 is resolved. +- Next action: Commit the accepted Task 5 checkpoint and dispatch leaf-only + nested validation. diff --git a/build-protocol/work-logs/T-0003.md b/build-protocol/work-logs/T-0003.md new file mode 100644 index 0000000..20e61c5 --- /dev/null +++ b/build-protocol/work-logs/T-0003.md @@ -0,0 +1,183 @@ +# T-0003 Work Log + +### 2026-07-25 โ€” Approval, reconciliation, and isolated setup + +- Work: Recorded the approved standard milestone and its exact acceptance + criteria, exclusions, skill selection, ownership, and expected dispatch. +- Git: Refreshed `origin`; confirmed local and remote `dev` at + `d7cfbf74882801373ea171e47453777729edb572`; preserved the unrelated untracked + `validation-ts.code-workspace`; created `task/t-0003-example-and-docs` in + the ignored project worktree. +- Setup: Installed 430 packages from the committed lockfile. Existing npm + deprecation and install-script approval warnings were emitted. +- Baseline: Root `npm test` passed 14 suites and 293 tests. A direct clean + `npm start --workspace=@spine-event-engine/example-smoke` built the example + but failed at execution because the workspace dependency + `@spine-event-engine/validation/dist/index.js` had not been built. This + pre-existing clean-checkout integration defect is in the approved CI/example + scope. +- Next action: Dispatch the single implementation owner with test-first + requirements for the example, CI, and documentation slices. + +### 2026-07-25 โ€” Implementation and focused verification + +- RED: `npm test --workspace=@spine-event-engine/example-smoke -- --runInBand` + initially failed because the requested scenario interface did not exist; after + adding it, generation exposed the example's missing `require` export patch; + the next run exposed invalid historical `(required)` option targets. Each was + corrected before proceeding. +- GREEN: the example scenario test now exercises generated User and Product + schemas, exact numeric minimum, distinct diagnostics, leaf-only nested paths, + and a resolvable `Any`; it passes with no mocks. +- Clean checkout: example `start` now builds the validation workspace package + before compiling and executing its own output. +- Checks: root `npm test` passed 15 suites / 294 tests; `npm run example` + executed all five scenarios; typechecking and Proto lint passed. Canonical + verification reached its documentation checker after passing Node, immutable + Proto, generation, typechecking, lint, formatting, and 90% coverage gates; + the checker was corrected to exclude TypeDoc output, then `npm run docs:check` + passed. A final canonical rerun remains for the orchestrator after review. + +### 2026-07-25 โ€” Correction batch + +- Replaced the runnable deprecated `(is_required)` option with `(choice).required`. +- Split validation/example test scripts, invoked the example suite from the canonical gate, and made CI test lanes explicit. +- Removed the scenario seam's `any` types, expanded maintained docs/navigation, and strengthened the checker for fenced TypeScript transpilation, public imports, links, stale placeholders, and deprecated active example options. +- Full `npm run verify` passed, including 293 validation tests, 1 executable-example test, 90% coverage thresholds, documentation, lint, generation, packaging, and Git checks. + +### 2026-07-25 โ€” Documentation and checker correction batch + +- Scope: Replaced the thin curated guides with agent- and reader-oriented user, + contract, architecture, contribution, and navigation documentation. Aligned + package/example READMEs and the permanent quality/technical baselines without + changing validation runtime, Proto inputs, dependencies, or publishing. +- Checker TDD: Added `scripts/check-documentation.test.mjs` first. RED command + `node scripts/check-documentation.test.mjs` failed because the checker did + not export `checkDocumentation`. GREEN refactored the checker into exported + functions, replaced regex export discovery with TypeScript-AST export + discovery (including type exports and aliases), and added fixture assertions + for invalid TS, non-public imports, broken local links, stale placeholders, + deprecated runnable options, and a passing repository scan. +- Focused evidence: `node scripts/check-documentation.test.mjs`, `npm run +docs:check`, `npm run format:check`, `npm run lint`, `npm run +typecheck:generated`, and root `npm test` passed. Root tests reported 14 + validation suites / 293 tests and one example suite / 6 tests. +- Self-review: confirmed generated TypeDoc and historical build-protocol logs + are excluded, local links resolve, named public imports are checked against + the package entry point, no vendored Proto changed, and Java-vs-ECMAScript + regex remains explicitly unresolved. +- Next action: run the fresh canonical `npm run verify` gate, inspect its diff, + then commit this correction batch if the gate is green. + +### 2026-07-25 โ€” Documentation correction full gate + +- Full gate: fresh `npm run verify` passed. It verified immutable Proto inputs, + generation/typechecking, lint/format, 14 validation suites / 293 tests at + 94.72% statements, 91.53% branches, 99.03% functions, and 95.87% lines; + one example suite / 6 tests; documentation and checker regressions; Proto + lint/generation determinism; build/package consumer; and Git hygiene. +- Closure handoff: correction files are ready for the parent orchestrator's + review/integration workflow. No frozen Proto, validation runtime, example + scenario behavior, dependency, module-format, or publishing change is in + this batch. + +### 2026-07-25 โ€” Specialist correction batch + +- Corrections: applied accepted findings F-001 through F-012 in one batch. + The example now has static legacy-pattern diagnostics plus an exact invalid + pattern scenario; the documentation checker uses no-emit semantic TypeScript + programs with a narrowly scoped generated-schema stub; the canonical gate + executes the compiled console adapter. +- Focused evidence: the example suite passed 1 suite / 7 tests; documentation + checker regressions passed; generated typechecking and Proto lint passed; + the built console adapter ran; root tests passed 14 validation suites / 293 + tests plus the example suite. +- Full gate: `npm run verify` passed through lint, then stopped at formatting + for pre-existing modified parent-owned records + `build-protocol/reviews/T-0003.md` and + `build-protocol/tasks/T-0003-example-and-docs/TASK.md`. All correction-batch + files were formatted; these records were preserved for their owner. + +### 2026-07-25 โ€” Specialist correction batch final verification + +- Full gate: after the protocol-record formatting correction, fresh `npm run +verify` passed completely. It covered immutable Proto verification, + generation/typechecking, lint/format, 14 validation suites / 293 tests at + 94.72% statements, 91.53% branches, 99.03% functions, and 95.87% lines; one + example suite / 7 tests; documentation checks; Proto lint and deterministic + generation; package build; compiled console execution; package contents; and + Git hygiene. +- Self-review: F-001 through F-012 are each implemented and covered by the + focused behavior/checker/documentation/CI changes. No frozen Proto, runtime + semantics, regex engine, dependency, module-format, publication, or master + branch change was made. + +### 2026-07-25 โ€” Final pattern and TypeDoc correction batch + +- Corrections: documented the legacy pattern adapter's static-message and + nested type/path exceptions as postponed normalization gaps. Updated public + TSDoc examples to be standalone, and extended the documentation checker to + reject both brace-only and dollar-prefixed legacy placeholders while + semantically checking public TSDoc fences. +- Focused evidence: documentation checker regressions, `npm run docs:check`, + and generated typechecking passed. The checker tests include legacy Markdown + and TSDoc placeholders, allowed namespaced placeholders, and a broken TSDoc + TypeScript fence. +- Full gate: fresh `npm run verify` passed with 14 validation suites / 293 + tests at 94.72% statements, 91.53% branches, 99.03% functions, and 95.87% + lines, plus one example suite / 7 tests. It also passed immutable Proto, + semantic documentation, lint/format, generation determinism, build, compiled + console, packaging, and Git hygiene checks. +- Self-review: F-013 documents both legacy pattern gaps under postponed work; + F-014 rejects exact dollar-prefixed and brace-only legacy forms without + rejecting namespaced or generic placeholders; F-015 compiles public TSDoc + examples through the same no-emit program as Markdown examples. + +### 2026-07-25 โ€” Final TypeDoc envelope correction + +- F-016: scoped `validate()`'s root type, complete path, packed value, and + normalized diagnostic guarantees to shared-envelope validators. The TSDoc + identifies `(pattern)` as the legacy exception and links to the contract's + pattern section. No runtime behavior changed. +- Verification: documentation checker regressions, `npm run docs:check`, + generated typechecking, formatting, and fresh `npm run verify` passed. The + full gate reported 14 validation suites / 293 tests and one example suite / + 7 tests at 94.72% statements, 91.53% branches, 99.03% functions, and 95.87% + lines. + +### 2026-07-25 โ€” Review convergence and independent canonical gate + +- Review: Style/maintainability, documentation/fresh-reader, TypeScript/API, + and performance/reliability concerns converged. F-001 through F-016 are + complete; security remains N/A under D-0004. +- Independent verification: The orchestrator ran fresh `npm run verify` on + reviewed head `117d1a5`. All gates passed, including 293 validation tests, + 7 example tests, semantic Markdown and public TSDoc checks, project-owned + Proto lint, deterministic generation, the compiled console adapter, and the + packed consumer. +- Coverage: 94.72% statements, 91.53% branches, 99.03% functions, and 95.87% + lines. +- Next action: Commit the convergence record, push the task branch, merge it to + `dev`, and repeat the canonical gate on the merged integration branch. + +### 2026-07-25 โ€” Integration and remote verification + +- Task push: Pushed reviewed closure + `0bf86c68c169923e9885d46e3322f650d03177ed` to + `origin/task/t-0003-example-and-docs`. +- Integration: Merged the task into `dev` as + `38ce635427cb4e24ccc6f7361957a518b541ab4c` while preserving the unrelated + untracked `validation-ts.code-workspace`. +- Post-merge verification: Fresh `npm run verify` passed all canonical gates, + including 293 validation tests, 7 example tests, semantic documentation, + project-owned Proto lint, deterministic generation, compiled console + execution, packed consumer installation, and Git hygiene. +- Coverage: 94.72% statements, 91.53% branches, 99.03% functions, and 95.87% + lines. +- Remote refs: Confirmed + `origin/dev@38ce635427cb4e24ccc6f7361957a518b541ab4c`, + `origin/task/t-0003-example-and-docs@0bf86c68c169923e9885d46e3322f650d03177ed`, + and unchanged + `origin/master@24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Next action: Commit and push this closure record, merge it to `dev`, run the + exact-head gate, verify final refs, and remove the clean task worktree. diff --git a/build-protocol/work-logs/T-0004.md b/build-protocol/work-logs/T-0004.md new file mode 100644 index 0000000..0b05530 --- /dev/null +++ b/build-protocol/work-logs/T-0004.md @@ -0,0 +1,158 @@ +# T-0004 Work Log + +### 2026-07-28 โ€” Approval, reconciliation, and isolated setup + +- Approval: Recorded the approved high-risk migration from npm, Jest, + CommonJS, and the current TypeScript configuration to the current pinned + Spine TS pnpm, Vitest, ESM, NodeNext, and strict project-reference stack. +- Git: Confirmed `dev`, `origin/dev`, and the task baseline at + `d60fa4a2c1e0050c4517b52ca49c8de43cdc7d02`; preserved the unrelated + untracked root file `validation-ts.code-workspace`; created + `task/T-0004-spine-ts-toolchain` in the ignored project worktree. +- Reference: Resolved Spine TS `origin/main` to + `f8a59883e71db0d9f9f0854039c313dbbce61801` and recorded pnpm `11.9.0`, + Node `24.18.0`, TypeScript `6.0.3`, Vitest/V8 `4.1.9`, ES2024, NodeNext, + ESM, strict project references, and 90% universal coverage. +- Baseline: Installed the committed npm lockfile and ran fresh + `npm run verify`. All gates passed: immutable Proto provenance, + deterministic generation, generated/source typechecking, lint, formatting, + 293 validation tests, 7 example tests, documentation checks, Proto lint, + builds, compiled examples, packed CommonJS consumer, and Git hygiene. +- Coverage: 94.72% statements, 91.53% branches, 99.03% functions, and 95.87% + lines. +- Next action: Dispatch the requirements splitter, then the single + implementation owner. + +### 2026-07-28 โ€” Requirements split + +- Dispatch: `/root/t0004_requirements`, requirements splitter, + `gpt-5.6-sol` high reasoning, read-only migration audit. The agent was + explicitly closed after returning its result. +- Sequence: pnpm/frozen lockfile first; strict ES2024/NodeNext project graph + second; NodeNext-safe generated and handwritten imports plus ESM export map + third; unchanged Jest-to-Vitest corpus migration fourth; all scripts, + workflows, package checks, and publication fifth; maintained documentation + and the complete gate last. +- Boundaries: Keep the generated compatibility patches, converting their + execution format only as needed. Their removal and internal `any` cleanup + remain T-0005. +- Risks: TS6 strictness, extensionless generated imports, V8 coverage drift, + `workspace:*` packing, lingering npm subprocesses, and accidental T-0005 + scope expansion. None is a blocker. +- Next action: Dispatch the single T-0004 implementation owner. + +### 2026-07-28 โ€” Toolchain migration implementation boundary + +- Replaced npm workspace execution with pnpm `11.9.0`, a committed pnpm + workspace configuration and lockfile, and `workspace:*` linking for the + example consumer. The npm lockfile and Jest/ts-jest configuration were + removed. +- Converted package execution, CI verification, master-only publication, + generated-source patchers, deterministic-generation check, and packed + consumer smoke check to pnpm and ESM. The consumer now imports the packed + package from its public export map through an `.mjs` entry point. +- Added strict ES2024/NodeNext composite project references, ESM package + metadata/export map, Vitest/V8 configuration with 90% thresholds, and + NodeNext `.js` specifiers across maintained handwritten source and tests. + Generated compatibility patching is retained and now executes as ESM for + T-0005 to remove later. +- Focused static evidence: `git diff --check` and `node --check` passed for + the migrated executable scripts. `pnpm install --lockfile-only --offline` + created the lockfile successfully. A full frozen offline install cannot run + on this host because required tarballs are not cached; its attempted install + tried restricted registry access and therefore did not reach generation, + typecheck, tests, or the canonical gate. +- Next action: hand the task commit and the environment-limited verification + evidence to orchestration; do not mark review or integration complete. + +### 2026-07-28 โ€” C-001 supply-chain lock correction + +- Retained `minimumReleaseAge: 1440` without exclusions and added supported + root-workspace pnpm overrides for the pinned reference-compatible transitive + releases: `acorn@8.17.0`, `minimatch@10.2.5`, `postcss@8.5.15`, and + `tinyrainbow@3.1.0`. The reference versions avoid the four releases that + were too recent for the policy. +- The approved network context rebuilt the lock through pnpm's clean-lockfile + recovery path. A local `pnpm install --frozen-lockfile` then accepted the + 225-entry lockfile and policy before attempting package materialization. +- This sandbox cannot fetch the required tarballs (restricted registry DNS), + so the frozen install did not finish and `pnpm verify` did not start here. + The orchestrator owns the approved network-enabled frozen install and full + verification evidence. + +### 2026-07-28 โ€” C-002 lock integrity and full-gate corrections + +- Added the reference-matching `path-key@3.1.1` and + `strip-json-comments@3.1.1` workspace overrides. The regenerated frozen + lockfile resolves both at those versions with their reference checksums; no + registry checksum was accepted or rewritten manually. +- Corrected NodeNext migration boundaries found by the full gate: all generated + TypeScript imports receive `.js` specifiers through the retained ESM + patchers; test imports target `src/index.js`; project references compile + generated output; Vitest enables its existing global test API; and TS6 + documentation snippet checks acknowledge the baseUrl deprecation while + preserving their current resolver behavior. +- Corrected pnpm pack JSON handling and the ESM packed-consumer local + Protobuf runtime path. The consumer imports only the public package export. +- Verification: fresh `pnpm verify` passed the Node/provenance/generation, + strict typecheck, lint, format, Vitest/V8 coverage, documentation, Proto, + deterministic generation, build, example, package consumer, and Git + hygiene gates. Vitest passed 15 files / 300 tests. Coverage was 93.85% + statements, 91.36% branches, 99.01% functions, and 95.15% lines. + +### 2026-07-28 โ€” F-001..F-007 review correction batch + +- Added the enforced `minimumReleaseAge: 1440` policy and regenerated the + pnpm 11.9.0 lockfile. CI and publication now activate pinned pnpm before + setup-node caching; publication reads the committed Node 24.18.0 pin. +- Generated patchers now fail loudly if their target declaration disappears, + remain idempotent, and continue NodeNext import rewriting. Consumer Buf + documentation declares `import_extension=js`. +- Current technical, consumer, root, contributor, and user documentation now + state pnpm/Vitest/ESM-only behavior, unsupported CommonJS `require()`, and + Node >=24 with tested/pinned 24.18.0. +- Fresh full-gate evidence: Vitest passed 15 files / 300 tests; V8 coverage + was 93.85% statements, 91.36% branches, 99.01% functions, and 95.15% lines. + +### 2026-07-28 โ€” Review convergence and independent gate + +- Narrow correction review confirmed release-age enforcement, pinned pnpm + activation, pinned publication Node, ESM generator documentation, explicit + consumer compatibility, and Node prerequisites. +- Both retained generated-source patchers now require explicit + cross-platform targets, fail on missing or changed output, remain + idempotent, and rewrite generated imports for NodeNext. The canonical + `pnpm test:patchers` fixture suite covers both patchers and all four + contract paths. +- Final review: style/maintainability, TypeScript/API, documentation, and + performance/reliability concerns are clean; security remains N/A. +- Independent orchestrator evidence on + `0d17e45fcc55fe1144482153d03c116da3a7a69c`: fresh `pnpm verify` passed + immutable Proto verification, generation, strict typechecking, lint, + formatting, 15 Vitest files / 300 tests, four patcher contract tests, + documentation, Proto lint, deterministic generation, build, compiled + example, packed ESM consumer, and Git hygiene. +- Coverage: 93.85% statements, 91.36% branches, 99.01% functions, and 95.15% + lines. +- Next action: commit and push the convergence record, merge to `dev`, run the + exact post-merge full gate, and confirm remote refs. + +### 2026-07-28 โ€” Integration and post-merge verification + +- Task push: Pushed reviewed task head + `82452226a0538930984bdfbdea13acf868dd85c0` to + `origin/task/T-0004-spine-ts-toolchain`. +- Integration: Merged the task into `dev` as + `5e667ecee5e7177e260b52af728a5ed6979b016a`, preserving the unrelated + untracked `.pnpm-store/` and `validation-ts.code-workspace`. +- Install: Fresh `pnpm install --frozen-lockfile` accepted the policy-valid + lock and materialized all 189 packages. +- Post-merge verification: Fresh `pnpm verify` passed immutable Proto + verification, generation, strict typechecking, lint, formatting, 15 Vitest + files / 300 tests, four patcher contract tests, documentation, Proto lint, + deterministic generation, build, compiled example, packed ESM consumer, + and Git hygiene. +- Coverage: 93.85% statements, 91.36% branches, 99.01% functions, and 95.15% + lines. +- Next action: commit and push this integration closure, confirm remote refs, + remove the clean merged worktree, and activate T-0005. diff --git a/build-protocol/work-logs/T-0005.md b/build-protocol/work-logs/T-0005.md new file mode 100644 index 0000000..4a06eba --- /dev/null +++ b/build-protocol/work-logs/T-0005.md @@ -0,0 +1,176 @@ +# T-0005 Work Log + +### 2026-07-28 โ€” Activation, reconciliation, and baseline + +- Git: Created `task/T-0005-runtime-architecture` at verified `dev` + `df8cbb187488f4f9bc170dcc226f18eab27f9f8f` in the ignored project + worktree. Preserved root untracked `.pnpm-store/` and + `validation-ts.code-workspace`. +- Install: Fresh `pnpm install --frozen-lockfile` accepted all lock policies + and materialized 189 packages. +- Baseline: Fresh `pnpm verify` passed immutable Proto verification, + generation, strict typechecking, lint, formatting, 15 Vitest files / 300 + tests, four generated-patcher contract tests, documentation, Proto lint, + deterministic generation, build, compiled example, packed ESM consumer, + and Git hygiene. +- Coverage: 93.85% statements, 91.36% branches, 99.01% functions, and 95.15% + lines. +- Intake scan: Remaining production `any` occurs at schema/message, + orchestration, registry, field-value, nested-validation, and pattern legacy + boundaries. Both package generation paths still invoke compatibility + patchers solely to rename `require` and rewrite generated imports. +- Next action: dispatch the requirements splitter, then the single + implementation owner. + +### 2026-07-28 โ€” Requirements split + +- Dispatch: `/root/t0005_requirements`, requirements splitter, + `gpt-5.6-sol` high reasoning, read-only type/generation audit. The agent was + explicitly closed after reporting. +- Public type kernel: `validate<S extends DescMessage>(schema: S, message: +NoInfer<MessageShape<S>>)`. `NoInfer` prevents a mismatched message argument + from widening the schema type. +- Internal seams: Use `DescMessage`, `MessageShape<S>`, `Message`, and + non-generic `Registry` according to whether a schema remains concrete or + comes from reflection. Use a generic nested-validator contract and one + localized reflective field reader rather than string-indexing generated + messages. +- Registry: A keyed `OptionRegistry[N]` lookup preserves each generated + extension value type and cannot return `undefined`. +- Generation: Configure both validation Buf generators with + `import_extension=js`, alias generated `require` at its project import, and + delete patchers, patcher scripts, and patcher tests. +- Risks: `GenMessage` branding at reflective seams, schema/message variance, + loss of option extension value types, and overly broad record assertions. + None is a blocker. +- Next action: dispatch the single T-0005 implementation owner. + +### 2026-07-28 โ€” Implementation checkpoint and focused verification + +- Public typing: `validate()` now uses `S extends DescMessage` and + `NoInfer<MessageShape<S>>`; a generated-schema compile-time regression proves + a mismatched descriptor/message pair is rejected. +- Runtime seams: concrete schema/message types flow through the internal + validator adapter and nested callback; reflection uses `DescMessage`, + `Message`, and `Registry`. A single descriptor-driven field reader contains + the unknown-property bridge. `rg -n '\\bany\\b' packages/validation/src` + returned no production matches. +- Registry: keyed lookup returns its exact registered extension type, without + an optional result or downstream option-value casts. +- Generation: both validation Buf configurations now specify + `import_extension=js`; both package patchers, the root patcher suite, and + the gate invocation are removed. The generated `require` export is imported + locally as `requireFields`. Determinism now also rejects patcher invocations + and extensionless generated relative imports. +- Focused checks: validation typecheck passed; 14 validation suites / 293 + tests passed; lint and formatting passed; deterministic generation passed + with digest `3d6a683de402e7c84ef4727de65ccf2fb17ad0ccc7da2a0db2dd27451a2b5f5d`. +- Next action: run documentation checks and the canonical full verification + gate before implementation handoff. + +### 2026-07-28 โ€” Canonical verification evidence + +- `pnpm verify` passed its Node 24.18.0 check, immutable Proto verification, + generation, generated typechecks, lint, formatting, and coverage. The fresh + coverage result is 94.07% statements, 91.56% branches, 99.03% functions, + and 95.40% lines across 15 files / 300 tests. +- The remaining canonical checks were re-run after the full-gate session: + documentation (including TypeDoc), Buf lint, deterministic generation, + build, compiled ESM example, packed-consumer install, and Git hygiene all + passed. Deterministic generation retained digest + `3d6a683de402e7c84ef4727de65ccf2fb17ad0ccc7da2a0db2dd27451a2b5f5d`. +- Safety scan: `git diff --check` passed; immutable vendored Proto files have + no diff; production `any` scan remains empty. +- Next action: implementation owner hands the reviewed diff and evidence to + the orchestrator; review and integration status remain unchanged. + +### 2026-07-28 โ€” Review correction batch F-001 through F-006 + +- Preserved protocol-only F-003 and F-006 corrections from `49a9845`. +- F-001: replaced the patcher-name check with exact generation-command and Buf + configuration enforcement. `scripts/check-generated-determinism.test.mjs` + passes three regressions, including rejection of a renamed transform. +- F-002: moved the mismatched schema/message compile-time fixture into an + uncalled function; validation test typechecking passes without evaluating + invalid input at module load. +- F-004/F-005: documented TypeScript >=5.4 for public `NoInfer` declarations + and the approved absence of depth, cycle, and violation budgets for valid + Protobuf object graphs. +- Focused checks passed: direct-generation regressions, validation test + typecheck, 9 contract tests, documentation, and deterministic generation. +- Next action: run a fresh canonical gate, then provide the correction commit + for targeted re-review. + +### 2026-07-28 โ€” Correction verification evidence + +- Fresh `pnpm verify` passed Node/provenance, generation, generated + typechecks, lint, formatting, 15 files / 300 tests, and coverage of 94.07% + statements, 91.56% branches, 99.03% functions, and 95.40% lines. +- The remaining canonical stages were executed after the gate's coverage/docs + segment: TypeDoc documentation checks, Buf lint, exact-configuration + deterministic generation, build, compiled example, packed ESM consumer, and + Git hygiene all passed. +- Final safety checks passed: `git diff --check` and an empty production + `any` scan. The correction diff preserves immutable vendored Proto files. +- Next action: commit the correction batch for the orchestrator's targeted + re-review; integration remains unchanged. + +### 2026-07-28 โ€” Residual F-001 lifecycle hardening + +- Direct-generation enforcement now rejects `pre` and `post` lifecycle siblings + for every protected `generate` command, including `pregenerate:tests` and + `postgenerate:tests` for the validation test generator. +- The explicit `test:generated-guard` script runs the four Node regressions and + is part of canonical `pnpm verify`; direct generation behavior is unchanged. +- Focused guard, deterministic-generation, lint, and formatting checks passed. +- Next action: fresh canonical verification and targeted reliability re-review. + +### 2026-07-28 โ€” Residual F-001 verification evidence + +- Fresh canonical verification ran `test:generated-guard` as part of + `pnpm verify`: all four guard regressions passed before the 15-file / 300-test + coverage suite, which retained 94.07% statements, 91.56% branches, 99.03% + functions, and 95.40% lines. +- Documentation, Buf lint, deterministic generation, build, compiled example, + packed ESM consumer, Git hygiene, and `git diff --check` all passed. +- Next action: commit for targeted reliability re-review; integration remains + unchanged. + +### 2026-07-28 โ€” Review convergence and independent gate + +- Targeted reliability re-review confirmed that all protected direct-generation + commands reject lifecycle siblings, renamed-transform and lifecycle bypasses + are covered, and the four Node regressions run in canonical `pnpm verify`. +- Style/maintainability, TypeScript/API, documentation, and + performance/reliability concerns are clean; F-001 through F-006 are complete + and security remains N/A. +- Independent orchestrator `pnpm verify` passed immutable Proto verification, + direct generation, strict typechecking, lint, formatting, four structural + generation-guard tests, 15 Vitest files / 300 tests, documentation, Proto + lint, deterministic generation, build, compiled example, packed ESM + consumer, and Git hygiene. +- Coverage: 94.07% statements, 91.56% branches, 99.03% functions, and 95.40% + lines. +- Production `any` scan remains empty; immutable Proto sources are unchanged. +- Next action: commit and push convergence, merge to `dev`, run the exact + post-merge full gate, and confirm remote refs. + +### 2026-07-28 โ€” Integration and post-merge verification + +- Task push: Pushed reviewed head + `0de3940c93924b2af094f9a0908ab89a67888860` to + `origin/task/T-0005-runtime-architecture`. +- Integration: Merged the task into `dev` as + `b4b2f79d6857d034fd23c862269e0c5db46f18fb`, preserving root untracked + `.pnpm-store/` and `validation-ts.code-workspace`. +- Install: `pnpm install --frozen-lockfile` confirmed the merged dependency + tree was current and policy-valid. +- Post-merge verification: Fresh `pnpm verify` passed immutable Proto + verification, direct generation, strict typechecking, lint, formatting, four + structural generation-guard tests, 15 Vitest files / 300 tests, + documentation, Proto lint, deterministic generation, build, compiled + example, packed ESM consumer, and Git hygiene. +- Coverage: 94.07% statements, 91.56% branches, 99.03% functions, and 95.40% + lines. +- Next action: commit and push integration closure, verify remote refs, remove + the clean merged worktree, and activate T-0006. diff --git a/build-protocol/work-logs/T-0006.md b/build-protocol/work-logs/T-0006.md new file mode 100644 index 0000000..fcceea7 --- /dev/null +++ b/build-protocol/work-logs/T-0006.md @@ -0,0 +1,140 @@ +# T-0006 Work Log + +### 2026-07-28 โ€” Activation, upstream reconciliation, and baseline + +- Git: Created `task/T-0006-time-options` at verified `dev` + `69a885f2f8f8708e93821e444be2d1c95eff38d6` in the ignored project + worktree. Preserved root untracked `.pnpm-store/` and + `validation-ts.code-workspace`. +- Install: Fresh `pnpm install --frozen-lockfile` accepted lock policies and + materialized 189 packages. +- Baseline: Fresh `pnpm verify` passed immutable Proto verification, direct + generation, strict typechecking, lint, formatting, four structural + generation-guard tests, 15 Vitest files / 300 tests, documentation, Proto + lint, deterministic generation, build, compiled example, packed ESM + consumer, and Git hygiene. +- Coverage: 94.07% statements, 91.56% branches, 99.03% functions, and 95.40% + lines. +- Upstream: Confirmed Spine Time + `4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc`, + `time_options.proto` SHA-256 + `933858bdbb118930a171d9a2383d884b498d7ed465a35664f2f411a72785f5be`, + and `spine/time/time.proto` SHA-256 + `da0e7482c69fb6e735441a4934222c054ec9795d7d4b46347751fccb5f692062`. + Confirmed JVM Validation comparison commit + `336d6f2bfab2ca6288283dbb64762456c61b31e0`. +- Next action: dispatch the requirements splitter, then the single + implementation owner. + +### 2026-07-28 โ€” Requirements split + +- Dispatch: The initial `/root/t0006_requirements` Sol/high audit exceeded its + bounded return window despite stop requests and was interrupted/closed. A + tightly scoped replacement, `/root/t0006_split_final`, completed the + implementation audit and was closed. +- Proto/generation: Vendor both approved upstream files into validation, + validation-test, and example modules; record all six paths/checksums; apply + lint exceptions only to immutable upstream style; require compilation and + deterministic generation. +- Runtime: Convert all supported values to epoch nanoseconds. Read the injected + clock once per scalar or collection element. Keep ZonedDateTime conversion + isolated through Temporal `compatible` disambiguation; New York gap and + overlap fixtures pin Java behavior. +- Test matrix: Separate control-flow/default/clock, collection envelope/order, + temporal conversion/errors, diagnostics/configuration, and nested/order + suites. Each approved JVM comparison bullet has an exact assertion. +- Integration: Register `(when)` in the fixed internal sequence, advance all + packages to snapshot.6, add deterministic time example scenarios, and update + maintained docs/provenance with necessary-only root README changes. +- Correction: The replacement audit mentioned historical `npm run verify`; + canonical T-0006 verification remains `pnpm verify`. +- Risks: nanosecond arithmetic, diagnostic-presence semantics, runtime tzdb + variation, and frozen-source lint isolation. No blocker. +- Next action: dispatch the single T-0006 implementation owner. + +### 2026-07-28 โ€” Implementation checkpoint + +- Vendored all six required immutable Spine Time Proto copies at + `4daeedfb6a1f961c0fa8dc3692b330d370dbfbbc`; provenance verification reports + the approved checksums and regular-file status. +- Added the internal `(when)` validator, one-read clock seam, Temporal-only + ZonedDateTime conversion seam, generated registry entry, focused behavior + fixture, and the exact `temporal-polyfill@1.0.1` runtime dependency. +- Focused suite: 5 tests pass. Remaining implementation work is expanded + behavior coverage, documentation completion, and the full verification gate. + +### 2026-07-28 โ€” Review correction batch + +- Applied accepted T6-R1 through T6-R9 corrections: descriptor-default singular + time values skip without a clock read, calendar and offset bounds are checked, + system-clock pre-epoch division is Euclidean, and zone failures are bounded + and stable. The initial zone-projection correction was later superseded by + the exact effective JVM `Timestamp` range after reviewing + protobuf-java-util `Timestamps.compare`. +- Next action: full verification and focused reviewer re-check. + +### 2026-07-28 โ€” First implementation batch + +- Commit: `d344c923afa695c79f7e842147ad830205ef891c` + (`feat: validate Spine Time when options`) pushed to + `origin/task/T-0006-time-options`. +- Verification: `pnpm verify` passed 309 tests with 94.36% statements, 90.90% + branches, 99.18% functions, and 95.62% lines. +- Provenance: all 12 immutable source entries passed checksum, regular-file, + compilation, lint-isolation, and deterministic-generation checks. +- Integration: all manifests and lock metadata are at + `2.0.0-snapshot.6`; the compiled example includes accepted and rejected + `(when)` timestamp scenarios. +- Review caution: the implementation owner reported that several approved + contract fixtures were not yet explicit. The review wave must treat exact + zone-boundary resolution, clock-read counters, deprecated `msg_format`, + placeholder errors, and nested ordering as possible incompleteness rather + than accepting aggregate coverage alone. +- Next action: complete the specialist review wave and return one deduplicated + correction batch. + +### 2026-07-28 โ€” Correction convergence + +- Correction commits: `7e942800`, `b19efd293`, `7bcfd458`, + `7b36a1a7`, and `913f6bbb`. +- Runtime: fixed singular defaults, BCE civil arithmetic, Java offset/component + bounds, Euclidean pre-epoch clock reads, registry ownership, unknown enum + values, bounded stable zone errors, and the exact JVM/Protobuf Timestamp + instant domain for direct and converted temporal values. +- Contract tests: exact one-nanosecond New York gap/overlap boundaries, + collection envelopes/order, per-value clock reads, singular/list/map + defaults, BCE/offset boundaries, malformed values, diagnostic selection, + placeholders, deprecated `msg_format`, nested leaf order, public + configuration errors, and runnable time examples. +- Documentation: reconciled root/package/example guides, normative contract, + user workflow, architecture, contributing guidance, immutable Proto + provenance, and the technical specification. Root README changes remain + limited to snapshot.6 and the `(when)` surface/import note. +- Reviews: final API, reliability, style, documentation, and security + dispositions are clean. The no-default-message request is inapplicable to + frozen `TimeOption` and remains covered at the shared envelope seam. +- Independent gate: `pnpm verify` passed 17 files / 319 tests, 94.71% + statements, 91.51% branches, 99.19% functions, and 95.96% lines, plus all + provenance, generation, type, lint, docs, example, package, consumer, and Git + checks. +- Next action: commit the final documentation/review closure, push the task + branch, merge to `dev`, and run the post-merge gate. + +### 2026-07-28 โ€” Integration closure + +- Task: Pushed converged review/documentation head + `03f38f042a5f812fa0b6c873baf16f9892a803aa` to + `origin/task/T-0006-time-options`. +- Merge: Merged the task into `dev` as + `7eaf7bfa58944ed72230a332f0bffc201475a4d6`. +- Post-merge install: `pnpm install --frozen-lockfile` accepted the supply-chain + policies and materialized only the three new locked temporal packages. +- Post-merge gate: `pnpm verify` passed 17 files / 319 tests, 94.71% + statements, 91.51% branches, 99.19% functions, and 95.96% lines. Immutable + provenance, deterministic generation, type/lint/format, documentation, Proto + lint, build, compiled time examples, packed package, installed ESM consumer, + and Git checks all passed. +- Remote policy: Push the integration closure to `origin/dev`, confirm the task + and integration refs directly, and leave `master` unchanged at + `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Cleanup: Remove the clean merged T-0006 worktree after remote confirmation. diff --git a/build-protocol/work-logs/T-0007.md b/build-protocol/work-logs/T-0007.md new file mode 100644 index 0000000..abf8093 --- /dev/null +++ b/build-protocol/work-logs/T-0007.md @@ -0,0 +1,124 @@ +# T-0007 Work Log + +Task: `build-protocol/tasks/T-0007-ci-doc-dependency/TASK.md` +Branch: `task/T-0007-ci-doc-dependency` +Baseline: `33a0a83c5b8707d5957655f2ece06e28fed09834` + +## Entries + +### 2026-07-29 โ€” Diagnosis, approval, and isolation + +- Work: Inspected the attached Ubuntu CI log, reconciled `dev` with + `origin/dev`, reproduced the documentation compilation failure in a clean + temporary pnpm-style layout, and created the ignored isolated task worktree. +- Files: Recorded T-0007 and corrected the already-completed T-0006 milestone + status in `PROJECT_PLAN.md`. +- Commands and results: The CI log passes installation, generation, + typechecking, lint, formatting, and 319 tests before failing in + `scripts/check-documentation.test.mjs` because `@bufbuild/protobuf` is not + resolvable from the repository root. Clean-layout reproduction produced the + same error; providing the missing root resolution made the checker pass. +- Decisions: Add the already locked exact package version as a direct root + development dependency and guard that ownership in the documentation + regression test. +- Risks: Local stale root `node_modules` can mask the regression; verification + must use a fresh frozen installation and remote GitHub Actions. +- Next action: Establish the failing ownership regression, implement the + minimal manifest/lockfile correction, and run focused checks. + +### 2026-07-29 โ€” RED: direct root dependency ownership + +- Work: Added a regression assertion that parses the repository-root + `package.json`, independent of the installed module layout. +- Command and result: `node --test scripts/check-documentation.test.mjs` failed + as expected because root `devDependencies` did not directly own + `@bufbuild/protobuf`: actual `undefined`, expected `2.13.0`. +- Decision: Keep the guard in the documentation checker regression suite and + add only the root direct development dependency plus its root-importer + lockfile entry. +- Next action: Apply the exact `2.13.0` declaration and re-run focused checks. + +### 2026-07-29 โ€” GREEN: clean root documentation tooling dependency + +- Work: Declared `@bufbuild/protobuf` `2.13.0` in root `devDependencies` and + added its sole root-importer entry to `pnpm-lock.yaml`. The package resolution + and integrity entry were already locked for the workspace, so no package graph + change was needed. +- Commands and results: `pnpm install --frozen-lockfile` passed after a + sandbox-network retry; `pnpm docs:check`, `pnpm format:check`, and + `git diff --check` passed. The focused regression test also passed. +- Self-review: The test reads only the repository manifest rather than any + `node_modules` path; the manifest and lockfile use the exact required version; + the diff contains no runtime, documentation-content, workflow, or vendored + Proto changes. +- Risks: Specialist review, integration, and remote CI remain with the + orchestrator. + +### 2026-07-29 โ€” Independent gate and clean-checkout proof + +- Work: Reinstalled from the frozen lockfile, independently inspected the task + commit, and ran the canonical gate. Exported the committed task head to an + external temporary checkout so TypeScript could not climb into the parent + checkout's stale `node_modules`. +- Commands and results: `pnpm verify` passed all canonical gates, 17 test files + / 319 tests, deterministic generation, documentation compilation, the + runnable example, package packing, and an installed consumer. Coverage was + 94.71% statements, 91.51% branches, 99.19% functions, and 95.96% lines. The + external checkout passed `pnpm install --frozen-lockfile`, `pnpm generate`, + and `pnpm docs:check`. +- Decision: The direct root importer edge is sufficient; no workflow or module + resolution workaround is required. +- Risks: Only specialist convergence, integration, and remote GitHub Actions + confirmation remain. +- Next action: Complete the specialist review wave and correct accepted + findings once. + +### 2026-07-29 โ€” Specialist review wave + +- Style/maintainability: Found one P2 durable-record defect: the completed full + gate was not recorded in the task verification ledger and the work log still + listed it as pending. The dependency ownership, lockfile, regression test, + and scope were otherwise clean. +- Performance/reliability: Clean. Confirmed the exact root importer, existing + integrity-pinned resolution, layout-independent manifest regression, + clean-Ubuntu workflow sequence, and platform-independent package. +- Documentation, TypeScript/API, and security: Concrete N/A. No maintained + documentation, package source/declaration/export, new package/version, + dependency graph, or runtime exposure changed. +- Disposition: Accepted T7-R1 and recorded the completed local and external + verification evidence. Return the record-only correction to the style + reviewer, then integrate after convergence. + +### 2026-07-29 โ€” Review convergence + +- Style/maintainability re-review: Clean. The task and work logs now record the + completed 319-test full gate, coverage, and independent clean-checkout proof. +- Reliability: Remains clean because the correction changed durable records + only. +- Convergence: No P0-P2 finding remains. Documentation, TypeScript/API, and + security retain their concrete N/A dispositions. +- Next action: Push the reviewed task branch, merge it into `dev`, perform the + required integrated verification, push `dev`, and confirm remote refs and + GitHub Actions. + +### 2026-07-29 โ€” Integration and remote CI closure + +- Task branch: Pushed reviewed head + `b4a0f6d089147790104fa724f0a281786706f837` to + `origin/task/T-0007-ci-doc-dependency`. +- Integration: Merged the reviewed task into `dev` as + `3eacc0819450b29d1243670e2ea18b6e8ccaf4b6` without conflicts. +- Post-merge gate: Fresh `pnpm install --frozen-lockfile` and `pnpm verify` + passed 17 files / 319 tests, 94.71% statements, 91.51% branches, 99.19% + functions, 95.96% lines, and every canonical gate. +- Remote proof: Verified `origin/dev` at the merge, the task ref at its reviewed + head, and `master` unchanged at + `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- GitHub Actions: + [Build and Test #32](https://github.com/SpineEventEngine/validation-ts/actions/runs/30438431930) + completed successfully for the exact `dev` merge in 1m 14s, ending the + T-0004-through-T-0006 failure sequence. +- Cleanup: Removed the clean merged task worktree and the external temporary + verification checkout. All child roles are completed. +- Next action: Push this permanent closure record to `origin/dev` and confirm + its workflow run. `master` remains untouched. diff --git a/build-protocol/work-logs/T-0008.md b/build-protocol/work-logs/T-0008.md new file mode 100644 index 0000000..8e29e2f --- /dev/null +++ b/build-protocol/work-logs/T-0008.md @@ -0,0 +1,234 @@ +# T-0008 Work Log + +Task: `build-protocol/tasks/T-0008-node24-actions/TASK.md` +Branch: `task/T-0008-node24-actions` +Baseline: `48a3ccab3d4a09de86af115b03469a078be6b4aa` + +## Entries + +### 2026-07-29 โ€” Diagnosis, upstream evidence, and isolation + +- Work: Traced both GitHub Actions run #33 annotations to the two + `pnpm/action-setup@v4` invocations in `build.yml`; found the same obsolete + action in the release-only `publish.yml`; reconciled `dev` and created the + ignored T-0008 task worktree. +- Upstream: Confirmed official stable `pnpm/action-setup` `v6.0.8` and verified + that official `v6/action.yml` declares the Node 24 action runtime. +- Decision: Upgrade every project-owned pnpm setup invocation to `@v6`, retain + explicit pnpm `11.9.0`, and add a repository-wide workflow policy regression + to the canonical gate. +- Risks: `publish.yml` cannot be executed on `dev`; structural parity, + specialist review, and unchanged triggers/permissions/commands must cover + that boundary without publishing. +- Next action: Run the selective high-risk requirements audit, then establish + the failing workflow-policy test before implementation. + +### 2026-07-29 โ€” Requirements audit and baseline + +- Requirements splitter: Completed and closed with no blocker or human-owned + question. Tightened the guard to discover `.yml` and `.yaml`, handle quoted + and unquoted `uses:` values, reject every non-`v6` pnpm setup reference, and + fail on missing workflows or missing pnpm setup uses. +- Ordered implementation: Add fixture-backed structural guard and canonical + script entry; observe RED on the three current `@v4` uses; replace exactly + those three scalars; verify unchanged workflow behavior and full gates. +- Baseline: Fresh `pnpm install --frozen-lockfile` and `pnpm verify` passed 17 + files / 319 tests, 94.71% statements, 91.51% branches, 99.19% functions, + 95.96% lines, and every canonical gate. +- Risks: The `@v6` floating major and unexecuted `master`-only publish path + remain explicit review concerns. SHA pinning and publication are excluded. +- Next action: Dispatch the single implementation owner for the test-first + workflow migration. + +### 2026-07-29 โ€” TDD workflow guard and Node 24 action migration + +- Ownership: `/root/t0008_implementer` (`gpt-5.6-terra`, medium) changed only + `.github/workflows/build.yml`, `.github/workflows/publish.yml`, + `scripts/check-pnpm-action-setup.test.mjs`, `package.json`, and the assigned + T-0008 task/work-log records. Existing metadata and unrelated changes remain + preserved. +- RED: Added `test:workflow-pnpm-action-setup` and wired it into root `verify`. + The focused Node test passed all four temporary-fixture checks but failed its + live-repository assertion on unchanged `pnpm/action-setup@v4`. A repository + scan confirmed the expected three obsolete references: two in `build.yml` and + one in `publish.yml`. +- GREEN: Replaced exactly those three references with + `pnpm/action-setup@v6`. `pnpm test:workflow-pnpm-action-setup` then passed + all 5 assertions: quoted/unquoted accepted v6 uses across `.yml` and `.yaml`, + a rejected non-v6 reference, missing-workflow rejection, missing-reference + rejection, and the live repository assertion. +- Guard behavior: Uses only Node built-ins; discovers both workflow extensions; + extracts quoted or unquoted `uses:` values; rejects each pnpm setup reference + other than the exact `pnpm/action-setup@v6`; and fails closed for no workflows + or no pnpm setup references. +- Focused checks: `pnpm format:check` initially identified only the newly added + regression and assigned task record, then passed after `prettier --write` on + those files. A manifest assertion confirmed root `verify` includes the new + focused test. `git diff --check` passed; `pnpm-lock.yaml` is unchanged; and + the workflow diff contains exactly the three approved `@v4` to `@v6` scalar + substitutions. Implementation commit: `e735387` (`ci: update pnpm action setup to node24`). +- Self-review: The workflow changes are limited to the three action major tags; + pnpm `11.9.0`, triggers, permissions, environment, job/step order, Node setup, + cache setup, install flags, verification/test/build/example commands, and the + publish command are unchanged. No dependency, lockfile, runtime, Proto, public + documentation, or `PROJECT_PLAN.md` change was made. + +### 2026-07-29 โ€” Pre-review bare-reference guard correction + +- RED: Added a temporary-fixture case containing both accepted + `pnpm/action-setup@v6` and invalid bare `pnpm/action-setup`. The focused test + failed only that new case with โ€œMissing expected exception,โ€ proving the + former `pnpm/action-setup@` collection predicate skipped the bare reference. +- GREEN: Broadened only the collection predicate to recognize every + `pnpm/action-setup`-prefixed `uses:` value; the existing exact-value assertion + now rejects the bare reference while non-pnpm uses remain ignored. Focused + `pnpm test:workflow-pnpm-action-setup` passed all 6 assertions. +- Checks: `pnpm format:check`, `pnpm test:workflow-pnpm-action-setup` (6/6), + and `git diff --check` passed. Correction commit: + `88063bab7e84ab2aae5d2b834da107bd7f374e62`. + +### 2026-07-29 โ€” Independent full gate + +- Work: Inspected both implementation commits, confirmed the workflow diff is + exactly three `@v4` to `@v6` substitutions, confirmed `pnpm-lock.yaml` is + unchanged, and independently ran the canonical gate. +- Result: `pnpm verify` passed the six workflow-policy tests, four generation + guards, 17 files / 319 tests, deterministic generation, documentation, + Proto lint, build, compiled example, package packing, installed consumer, and + Git hygiene. +- Coverage: 94.71% statements, 91.51% branches, 99.19% functions, and 95.96% + lines. +- Next action: Run the complete style, reliability, and publishing-security + review wave over the immutable task diff. + +### 2026-07-29 โ€” Consolidated review correction batch + +- Review findings accepted: **T8-R1 (P2 style)** found that the line regex + treated `- uses: pnpm/action-setup@v4` in a literal/folded block-scalar body + as an action reference. **T8-R2 (P2 security)** found that it missed a flow + mapping `uses:` field, including one following another key, allowing a v4 + reference to bypass the non-vacuity guard when a separate v6 reference exists. +- RED: Added fixtures with a real v6 action plus textual v4 in a `run: |2-` + body, and with real v6 plus a flow-mapping v4 action after `name`. The focused + guard failed exactly those two new tests: an unwanted block-scalar rejection + and a missing flow-mapping exception. The existing comment fixture remained + green. +- GREEN: Replaced the global line regex with a built-in-only, indentation-aware + extractor. It skips literal/folded scalar bodies (including chomping and + indentation indicators), strips comments outside quotes, extracts ordinary + block-step `uses:` fields, and finds top-level flow-mapping `uses:` fields in + any key order. The exact v6 assertion remains the policy boundary; unrelated + uses and scalar/comment text are ignored. +- Evidence: `pnpm test:workflow-pnpm-action-setup` passed 9/9 cases after the + correction. `pnpm format:check` and `git diff --check` passed; the correction + left both workflows, `package.json`, and `pnpm-lock.yaml` unchanged. This + correction is recorded in its implementation commit. +- Review disposition: both accepted P2 findings are corrected but must remain + **re-review pending**; this entry does not close the review wave. + +### 2026-07-29 โ€” Semantic YAML parser correction after re-review + +- Re-review evidence: The indentation-aware lexical scanner remained + insufficient. It missed a quoted `"uses"` key, deeply nested flow-map uses, + and malformed YAML; its finite syntax handling could not reliably cover + multiline mappings, quoted keys, and scalar boundaries. This concrete review + evidence supersedes the earlier built-in-only/no-dependency implementation + plan. +- Dependency decision: Added exact root test-only devDependency `yaml@2.9.0`. + The approved upstream evidence identifies `eemeli/yaml` 2.9.0 as current and + maintained on 2026-05-11, requiring Node >=14.6 and compatible with Node 24. + The existing lock already contained the identical package/resolution, so the + only intended lock change is the root importer entry; no graph node or version + was introduced. +- RED: Added fixtures for folded-block text, inline comments, quoted scalar + text, multiline flow mappings, a quoted `uses` key, deeply nested flow-map + uses, and malformed YAML path reporting. Against the lexical scanner, the + focused run failed the quoted-key, deeply nested-flow, and parse-error cases + (11/14 passed), exposing bypasses and missing failure context. +- GREEN: Deleted all custom comment, block-scalar, and flow-map lexical helpers. + `yaml.parseDocument` now semantically parses each workflow; parse errors name + the workflow path. A recursive `WeakSet`-guarded walk inspects every exact + `uses` key at arbitrary array/object nesting, retaining the exact + `pnpm/action-setup@v6` and non-vacuity policy. Literal/folded text, comments, + and quoted unrelated scalar text are values, not action fields. +- Evidence: `pnpm install --frozen-lockfile` passed after the sandboxed attempt + hit registry DNS while materializing `node_modules`; the approved-network + retry reused all 192 locked packages with no downloads. The focused guard then + passed 14/14 cases. `pnpm format:check` and `git diff --check` passed; both + workflows remain unchanged, and `pnpm-lock.yaml` changes only its root + importer with `yaml` specifier/version `2.9.0`. This correction is recorded + in its implementation commit. +- Review disposition: T8-R1, T8-R2, and the lexical-parser re-review finding + T8-R3 are corrected but remain **re-review pending**; this does not close the + review wave. + +### 2026-07-29 โ€” Final action-location scope correction + +- Accepted **T8-R4 (P2 correctness)**: the first semantic walker inspected every + parsed `uses` key and falsely treated `jobs.<job_id>.env.uses` as an action. + RED added a structurally valid workflow with real v6 step setup plus + `env.uses: pnpm/action-setup@v4`; the focused guard failed only that new case + (15/16). +- GREEN: Deleted the recursive `WeakSet` traversal. The guard now examines only + `jobs.<job_id>.uses` (reusable-workflow jobs) and + `jobs.<job_id>.steps[*].uses` (step actions), retaining semantic parsing, + quoted-key/flow-map support at those locations, exact v6 enforcement, + non-vacuity, and workflow-path parse failures. Fixtures are now structurally + valid workflows and cover both step and reusable-job rejection; the focused + guard passed 16/16. +- Accepted **T8-R5 (P2 dependency evidence)**: Node supplies no YAML parser and + the custom scanner was proven incomplete. The direct test-only + `yaml@2.9.0` choice is traceable to + https://github.com/eemeli/yaml/tree/v2.9.0; registry metadata checked + 2026-07-29 reports 2.9.0 current, modified 2026-05-11, Node >=14.6. Its + package export provides built-in TypeScript declarations at + `./dist/index.d.ts` and supports this Node 24 ESM workspace. Transitive + `js-yaml@4.3.0` was rejected because it is not root-declared, its installed + manifest has no bundled types export, and promotion would add a direct + contract without reducing the graph. `yaml@2.9.0` already exists through + Vite, so only the root importer changed in the preceding dependency commit. +- Checks: `pnpm format:check`, the 16/16 focused guard, and `git diff --check` + passed. `package.json` and `pnpm-lock.yaml` are unchanged from `12f56fa`. + This correction is recorded in its implementation commit. T8-R1 through T8-R5 + are corrected/accepted but remain **re-review pending**; this does not close + the review wave. + +### 2026-07-29 โ€” Review convergence and final task-branch gate + +- Review: Style/maintainability re-reviewed the action-location and dependency + evidence correction at `12f56fa..78af776` and returned clean. The final + semantic-parser reliability and security re-reviews were also clean. + Documentation and TypeScript/API concerns are concrete N/A because no + maintained user documentation, source, declarations, exports, or public API + changed. All canonical concerns have converged with no open P0-P2 findings. +- Verification: The final independent `pnpm verify` passed 16 workflow-policy + tests, four generation-guard tests, 17 files / 319 runtime tests, + deterministic generation, documentation checks, both Proto lint surfaces, + build, compiled example execution, package packing, installed-consumer + loading, and Git hygiene. The first sandboxed package-consumer attempt was + stopped after registry DNS retries; the complete network-enabled rerun passed. +- Coverage: 94.71% statements, 91.51% branches, 99.19% functions, and 95.96% + lines. +- Next: Commit the convergence record, push the task branch, merge it into + `dev`, repeat the full gate, push `dev`, and inspect the remote workflow + annotations. + +### 2026-07-29 โ€” Integration closure + +- Task branch: Pushed `task/T-0008-node24-actions` at + `81da42d86fc7d8db4c1811cc5fb506e597cbfba8`. +- Integration: Merged without conflicts into `dev` at + `82ce0cc92e5db5c99a7c5c8f1438a8e22b6baf71`. The post-merge frozen install + and complete `pnpm verify` passed with the same 319 tests, coverage, package + consumer, and integrity evidence as the task branch. +- Remote: Pushed `dev`. [Build and Test #34](https://github.com/SpineEventEngine/validation-ts/actions/runs/30443648028) + succeeded for the exact merge commit in 1m 27s. Full verification and the + Node 24 compatibility matrix job both passed. The run's Annotations region + was empty, proving the prior `pnpm/action-setup@v4` Node 20 deprecation + warning no longer appears. +- Release safety: `origin/master` remained unchanged at + `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`; no publication workflow was + triggered. +- Cleanup: Removed the clean merged task worktree and all immutable T-0008 + review packages from `/tmp`. All dispatched T-0008 agents were closed after + their assigned work. diff --git a/build-protocol/work-logs/T-0009.md b/build-protocol/work-logs/T-0009.md new file mode 100644 index 0000000..b0dfba4 --- /dev/null +++ b/build-protocol/work-logs/T-0009.md @@ -0,0 +1,310 @@ +# T-0009 Work Log + +Task: `build-protocol/tasks/T-0009-docs-source-conventions/TASK.md` +Branch: `task/T-0009-docs-source-conventions` +Baseline: `1f39ab5d910a240d04283ada489b8f044c307475` + +## 2026-07-29 โ€” Task 1 deterministic convention tooling + +- Scope: Added the tested `scripts/check-source-conventions.mjs` TypeScript + AST and project-owned Proto tokenizer checker plus `pnpm source:check`. + The checker excludes generated/dependency output and all manifest-frozen + Proto paths; no production, documentation, project Proto, vendored Proto, + dependency, or Buf-policy remediation occurred. +- RED: `node --test scripts/check-source-conventions.test.mjs` initially + failed because the checker module was absent. Subsequent test-first edge + fixtures exposed the uninitialized-variable AST crash and oneof `option` + false positive before their focused fixes. +- GREEN: `node --test scripts/check-source-conventions.test.mjs` passed 8/8; + focused ESLint, Prettier, and `git diff --check` passed. The initial live + `pnpm source:check` intentionally exited 1 with 828 remediation findings: + 509 `proto-missing-comment`, 33 `ts-name-too-long`, 90 + `ts-standalone-function`, 2 `tsdoc-callable-summary`, 1 + `tsdoc-forbidden-wording`, 116 `tsdoc-missing`, 68 + `tsdoc-missing-param`, and 9 `tsdoc-missing-returns`. Two 830-line CLI runs + had SHA-256 `79ce52a200db6b6acd1a05dd9c15a1c1e27bff6111d9ec1260b1565d42277d92`. + All 12 manifest-frozen Proto paths produced no findings. +- Commit: `cf5dfb4` (`feat: add deterministic source convention check`). + +## 2026-07-29 โ€” Task 1 accepted correction batch + +- RED: Added fixtures for empty `@param`/`@returns` descriptions, + undocumented named object data/accessor properties, and a trailing Proto + comment immediately before an undocumented next field. The focused command + failed the empty-description assertion and incorrectly accepted the trailing + comment as documentation for the next declaration. +- GREEN: The checker now rejects empty tag descriptions, includes named object + data properties and accessors in TSDoc validation, and considers a Proto + comment documentation only when it begins its own line. `node --test +scripts/check-source-conventions.test.mjs` passed 9/9 after the correction. +- Focused evidence: ESLint and Prettier on the changed checker/test files and + `git diff --check HEAD` passed after the 9/9 fixture run. +- Fresh live inventory: `pnpm source:check` still exits 1 intentionally with + 838 remediation findings: 509 `proto-missing-comment`, 33 + `ts-name-too-long`, 90 `ts-standalone-function`, 2 + `tsdoc-callable-summary`, 1 `tsdoc-forbidden-wording`, 126 `tsdoc-missing`, + 68 `tsdoc-missing-param`, and 9 `tsdoc-missing-returns`. Two 840-line CLI + runs have identical SHA-256 + `f47b8af45b89c063f0f82857c1370b37e428389bfc7aac9d4cbca73ee0b28bbf`. +- Next: Commit this bounded correction, then return the branch to orchestration + for review. + +## 2026-07-29 โ€” Task 2 public and core ownership checkpoint + +- RED: Updated the public/API and contract tests to use the planned owning + interfaces. `pnpm exec vitest run packages/validation/tests/validation-contract.test.ts` + failed 7 assertions because `ValidationContext.create` and + `ValidationOrchestration` were absent, which confirmed the intended missing + owner methods. +- GREEN: Preserved the validation traversal, diagnostic packing, legacy + normalization, and formatting values while moving them behind + `ValidationContext`, `ViolationFactory`, `ValidationOrchestration`, and the + internal `ValidationEngine`. Updated option call sites to use + `ViolationFactory.create`; no compatibility aliases remain. +- Focused evidence: contract test passed 9/9; `pnpm typecheck:generated` + passed; the full validation and example scenario wave passed 17 suites and + 319 tests. `pnpm source:check` retains 61 standalone-function findings in + option modules for the later option-ownership slice, with zero findings in + the core (`orchestration.ts`, `validation-contract.ts`, `validation.ts`) and + example source files. + +## 2026-07-29 โ€” Task 2 option ownership tranche A + +- RED: Added `options-owners.test.ts`; it failed because the expected internal + `NumericValues` owner was absent (`undefined.parseLiteral`). +- GREEN: Moved numeric parsing/reference/comparison and the `(min)`, `(max)`, + `(range)`, `(required)`, `(goes)`, `(choice)`, and `(require)` option bodies + behind `NumericValues`, `MinMax`, `Range`, `Required`, `Goes`, `Choice`, and + `Require`. `validation.ts` now invokes those owners in the unchanged + orchestration order. No validation behavior was changed. +- Focused evidence: the owner regression passed; the numeric, min/max, range, + required, require, goes, choice, validation contract, validation, integration, + and ordering suites passed 12/12 files and 245/245 tests. `pnpm +typecheck:generated`, ESLint, Prettier, and `git diff --check` passed. +- Source inventory: `pnpm source:check` intentionally remains nonzero for + deferred documentation, naming, and Proto work. It reports zero + `ts-standalone-function` findings in the seven owned modules and 32 + standalone-function findings in unassigned modules. + +## 2026-07-29 โ€” Task 2 option ownership tranche B + +- RED: Extended `options-owners.test.ts` with the desired `Distinct`, + `Pattern`, `NestedValidation`, and `When` owner interfaces. The focused run + failed at `Distinct.validate` because the owner did not yet exist. +- GREEN: Moved all module-scope option functions into the four fixed-owner + objects and updated `validation.ts` to call them in the existing sequence. + `Pattern` remains the legacy all-fields adapter; recursive nested callbacks, + distinct Buf equality, violation construction/order, and per-element time + clock reads retain their existing paths. +- Focused evidence: owner plus distinct, pattern, nested-validate, and when + suites passed 6 files and 91 tests. The full validation/example wave passed + 18 suites and 320 tests. `pnpm typecheck:generated`, focused ESLint, + Prettier, and `git diff --check HEAD` passed. +- Source inventory: `pnpm source:check` exits 1 only for deferred + documentation, naming, and Proto findings; its stderr inventory has zero + `ts-standalone-function` findings. No frozen Proto files changed. + +## 2026-07-29 โ€” Task 3 TypeScript TSDoc and naming tranche + +- RED: Captured the live source-convention inventory before remediation: 30 + `ts-name-too-long`, one `tsdoc-forbidden-wording`, 146 `tsdoc-missing`, 25 + `tsdoc-missing-param`, and two `tsdoc-missing-returns` findings. The scan + also reported 509 deferred project-owned Proto comment findings. +- GREEN: Documented the checked production and example TypeScript declarations, + members, callables, inputs, and results; replaced stale workflow wording; + introduced local aliases for checker-proven overlong generated symbols; and + replaced overlong test matcher member access with concise local helpers. + TypeDoc now treats missing exported API documentation as fatal for the + configured reflection kinds. +- Focused evidence: `pnpm source:check` reports zero TypeScript findings and + only the 509 deferred Proto findings; `pnpm docs:api`, + `pnpm typecheck:generated`, full validation/example Vitest (18 files, 320 + tests), `pnpm lint`, `pnpm format:check`, `pnpm proto:verify`, and `git diff +--check` passed. Immutable verification confirmed all 12 frozen Proto files. + +## 2026-07-29 โ€” Task 3 Proto documentation tranche + +- RED: The initial `pnpm source:check` inventory recorded 509 missing comments + in project-owned Proto fixtures. Documentation review then rejected the + mechanically added generic field prose, duplicated comment stacks, and + comments embedded inside option expressions. +- Correction: Rewrote both disjoint fixture groups with concise, + declaration-specific documentation. Invalid configuration fixtures now name + their exact invalid option value, target, reference, or expression. The + correction preserves every non-comment Proto token after comment and + whitespace stripping and does not modify any immutable upstream source. +- Checker RED/GREEN: A new focused fixture initially reproduced a false + `proto-missing-comment` report for sibling aggregate `(min)` and `(max)` + options. The Proto tokenizer now scans statements with balanced nested + braces, brackets, and parentheses, while quoted brace text remains opaque; + `node --test scripts/check-source-conventions.test.mjs` passes 10/10. +- GREEN: `pnpm source:check`, `pnpm proto:verify` (12 immutable sources), + `pnpm proto:lint`, `pnpm generate`, `pnpm proto:check-generated`, `pnpm +typecheck:generated`, checker ESLint and Prettier, `git diff --check HEAD`, + and the validation/example suite (18 files, 320 tests) passed. Semantic-token + comparison against `HEAD` passed for all six Group B Proto files. +- Group commits: Group A is `682e671` (`docs(proto): clarify group A +validation fixtures`); Group B records the final six fixtures and the + delimiter-aware source-convention correction. + +## 2026-07-29 โ€” Task 3 final Proto re-review correction + +- Re-review RED: Documentation review found remaining generated-sounding + `Fixture โ€ฆ named validation scenario` and `Stores โ€ฆ fixture` prose in the + five Group B fixtures. It also required explicit unsupported-target wording + for `(required)` on `int32`/`bool` and `(validate)` on `string`. +- GREEN: Replaced every occurrence with declaration-specific descriptions of + bounds, references, optional controls, required-group operands, oneof + members, and nested-validation inputs. The three invalid-target comments now + name the option and unsupported type explicitly. The boilerplate-pattern scan + returns zero matches repository-wide. +- Evidence: `pnpm source:check`, Proto verification/lint/generation/determinism, + generated typechecking, the focused and complete validation/example tests, + and `git diff --check HEAD` passed. A comment-and-whitespace-stripped semantic + comparison against each fixture's parent commit passed, confirming no Proto + semantic token changed. + +## 2026-07-29 โ€” Task 3 final enum re-review correction + +- Re-review finding: `Status` and its values in the `(required)` fixture still + used generic enum prose rather than documenting default, active, and inactive + meanings. +- Resolution: Documented the enum as the required account-status input and + distinguished the rejected default state from the active and inactive states + that satisfy `(required)`. Comments only changed. +- Evidence: `pnpm source:check`, Proto verification/lint, focused required + tests, semantic comparison against the parent commit, and `git diff --check` + passed. + +## 2026-07-29 โ€” Task 4 package-guide restoration and documentation relocation + +- RED: Extended the documentation-checker fixtures for multi-command preview + installs, mixed moving/exact preview instructions, exact previews outside an + alternative section, missing package-guide backlinks, removed public helper + imports, and historical workflow language. The focused run initially failed + because the quick-install rule was absent. +- GREEN: Restored the package README as the authoritative consumer guide using + the historical guide's structure while verifying the current ESM, Node 24, + pnpm, Protobuf-ES v2, `(when)`, `validate`, `Violations.formatAll`, and + configuration-error behavior. Relocated repository-only development material + below `packages/validation/docs/`, removed the duplicate consumer guide, and + moved TypeDoc output with matching Git, Prettier, and ESLint exclusions. +- Focused evidence: `node --test scripts/check-documentation.test.mjs`, `pnpm +docs:check`, and `pnpm source:check` passed. The initial format scan exposed + the moved TypeDoc output and an obsolete output directory; exclusions and the + stale generated directory were corrected before the remaining verification + wave. + +## 2026-07-29 โ€” Task 4 documentation correction + +- Inspection failure: the first restored package guide was too compressed and + the root guide lost useful navigation, workspace, development, and project + sections. Reader-facing documentation also used internal delivery shorthand, + and the `(require)` contract-table row rendered incorrectly. +- RED/GREEN: Added checker fixtures for the prohibited shorthand while allowing + the literal `--frozen-lockfile`; the new fixture failed before the rule and + passed after it. Restored the root guide's original structure, expanded the + package guide with the historical guide's API, example, option, and behavior + sections, corrected the table, and replaced process terms with plain product + descriptions. +- Evidence: documentation checker tests, `pnpm docs:check` with TypeDoc, + `pnpm source:check`, Prettier, ESLint, generated typechecking, validation + tests (17 files, 312 tests), example tests (1 file, 8 tests), and `git diff +--check` passed. + +## 2026-07-29 โ€” TSDoc filler correction tranche (in progress) + +- RED: Added a source-convention fixture covering mechanically generic callable + summaries, parameter/return descriptions, type prose, and product-history + phrases. The focused test failed with zero `tsdoc-filler-wording` findings. +- GREEN: Added narrow phrase checks for the documented filler and history forms + without treating the domain use of `provenance` as forbidden. The focused + checker suite passed 11/11. Rewrote the clock, presence, and example-scenario + declarations with purpose-specific summaries and input/result descriptions. +- Inventory: the first full `pnpm source:check` after enabling the rule reports + 91 remaining TypeScript filler findings across the validation owners. No + runtime behavior or immutable Proto source changed. Remaining remediation is + not yet complete; no completion gate has been run. + +## 2026-07-29 โ€” TSDoc filler correction tranche completion + +- Remediation: Rewrote all 91 reported filler blocks across the validation + owners and example console adapter. The completed TSDoc identifies the + option, descriptor, candidate message, diagnostic collection, conversion, + and packing roles rather than describing generic inputs or implementation + history. Adjacent duplicate blocks were consolidated; no runtime code or + public API changed. +- Focused evidence: `node --test scripts/check-source-conventions.test.mjs` + passed 11/11; `pnpm source:check`, `pnpm docs:check`, and + `pnpm typecheck:generated` passed. The full validation/example wave passed + 18 files and 320 tests. `pnpm lint`, `pnpm format:check`, and + `git diff --check` passed after formatting the task record. +- Next: Run the canonical verification gate, record its result, and commit the + complete correction tranche as one focused change. + +- Canonical gate: `pnpm verify` passed after the focused checks. It verified + all 12 immutable Proto files, regeneration and generated-source determinism, + TypeDoc/documentation checks, lint and formatting, package and Git checks, + and all 18 validation/example test files (320 tests). Coverage was 94.86% + statements, 91.68% branches, 99.19% functions, and 96.12% lines. + +## 2026-07-29 โ€” Reader-documentation correction tranche + +- RED: Added deterministic documentation-checker fixtures for a missing + GitHub-style Markdown heading anchor and for the complete Proto example + without `(when)`. The anchor fixture initially passed because fragments were + ignored; the Proto fixture then failed with the expected missing-`(when)` + diagnostic. +- GREEN: The checker now resolves local Markdown fragments with a deterministic + GitHub-style slugger, including duplicate-heading suffixes, while excluding + external URLs and generated TypeDoc links. It also verifies that the package + guide's complete Proto fence imports `Timestamp`, shows + `expires_at = 11 [(when).in = FUTURE]`, and has no immediately duplicated + message declaration. The root limitation link now targets the regular- + expression heading, the package configuration-error example handles only + invalid option declarations at the validation boundary, and the validation + contract typo is corrected. +- Evidence: checker regression tests and `pnpm docs:check` (including TypeDoc), + `pnpm source:check`, `pnpm typecheck`, ESLint, `pnpm format:check`, + validation tests (17 files, 312 tests), example tests (1 file, 8 tests), and + `pnpm git:check` passed. The first lint pass caught one unnecessary regular- + expression escape introduced in the slugger; it was corrected before the + final green run. No immutable vendored Proto file changed. + +## 2026-07-29 โ€” Review convergence and final gate + +- The complete specialist wave covered style/maintainability, documentation, + TypeScript/public API, and performance/reliability. Eight P1/P2 findings were + accepted, corrected in one batch plus one focused follow-up, and re-reviewed + clean. Security remained N/A because this task adds no dependency, trust + boundary, publishing change, credential handling, or runtime input behavior. +- The corrections restrict the standalone `validate` exception, check callable + object properties, reject empty documentation and shell chaining, confine and + deterministically check local documentation links, sort source scans, clarify + custom-message option ownership, state TypeScript 5.4+, and compile the + installed package declarations against current and removed APIs. +- Network-enabled `pnpm package:check` packed 112 files, compiled the installed + TypeScript consumer, consumed both mismatched schema/message assertions and + both removed-export assertions, and loaded the ESM runtime. +- Final independent `pnpm verify` passed all 320 tests, all 12 immutable Proto + checks, deterministic generation, source/documentation checks, TypeDoc, + Proto lint, build/example execution, installed package checks, and Git checks. + Coverage was 94.86% statements, 91.68% branches, 99.19% functions, and + 96.12% lines. + +## 2026-07-29 โ€” Integration closure + +- Pushed task commit `2373a86a5dfe47b620411f7e7e3082d66a58a752`, + merged it into `dev` as + `4bd013c5b981c2fc91d4d0d2b3d9e11db5515ccd`, and pushed the verified merge. +- The first integration-checkout gate found only obsolete generated + `docs/api/reference` output from the former TypeDoc path. Removed that + reproducible local output; no tracked or user-owned file was removed. +- Post-merge `pnpm install --frozen-lockfile` and `pnpm verify` passed. GitHub + Actions + [Verify #36](https://github.com/SpineEventEngine/validation-ts/actions/runs/30469858265) + also completed successfully for the exact merge commit. +- Verified `origin/dev` at the merge and `origin/master` unchanged at + `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. Removed the clean worktree and + deleted the integrated local and remote task branches. Preserved the + user-owned untracked `.pnpm-store/` and `validation-ts.code-workspace`. diff --git a/build-protocol/work-logs/T-0010.md b/build-protocol/work-logs/T-0010.md new file mode 100644 index 0000000..72cea2e --- /dev/null +++ b/build-protocol/work-logs/T-0010.md @@ -0,0 +1,164 @@ +# T-0010 Work Log + +Task: `build-protocol/tasks/T-0010-development-guides/TASK.md` +Branch: `task/T-0010-development-guides` +Baseline: `60fd57ec66d73d769f0ce4029846ad3726e3e41e` + +## Entries + +### 2026-07-31 โ€” Approval, isolation, and baseline + +- Work: Recorded the approved documentation and version-policy task, created + an isolated task branch and worktree from the exact `dev` head, and installed + the committed dependency graph. +- Files: T-0010 task/work records and the active project-plan row. +- Commands and results: Dependency installation passed. Validation tests + passed 312 tests. The example suite reproduced its clean-worktree dependency + on built package output, then passed 8 tests after `pnpm build`. +- Decisions: Keep the current published exact install at snapshot 6 while the + private development manifests advance to snapshot 7. Treat the clean-build + sequence as required development-guide content rather than a runtime change. +- Risks: Commands and environment claims need independent reliability review. +- Next action: Dispatch the single documentation implementation owner. + +### 2026-07-31 โ€” Documentation and governance implementation + +- Work: Restored the beginner-oriented example guide from the `master` + editorial baseline; added the maintainer development guide; rewrote the + contribution guide; linked the guides from package, documentation-index, + example, and root navigation; and corrected current pnpm/Vitest/ESM policy. +- Policy: Recorded that root, validation-package, and example manifest version + changes move together in an isolated version-only commit named + `Bump version -> <version>`. The published install example remains + `2.0.0-snapshot.6`; no manifest version changed in this tranche. +- Verification: `pnpm docs:check` passed its regression tests, TypeDoc + generation, and 8 maintained Markdown files. `pnpm source:check` and + `git diff --check` passed. `pnpm format:check` reported the pre-existing + orchestrator-owned `build-protocol/PROJECT_PLAN.md` and the T-0010 task + record; the implementation owner will format the owned record before retrying. +- Next action: Finish focused formatting checks, inspect the owned diff, commit + the documentation/policy tranche, then hand it to the review wave. + +### 2026-07-31 โ€” Consolidated review correction + +- Review: Accepted ten documentation, maintainability, API, and reliability + findings in one correction batch. The guides now restore the master beginner + example structure, distinguish the tests-only invalid configuration fixture, + give real `(when)` implementation paths, explain supported development hosts, + and move the complete version rule to the build protocol. +- Command audit: `pnpm --filter @spine-event-engine/example-smoke test` builds + dependencies but fails because Vitest finds no files from the package working + directory. The human-facing guides now use the verified clean sequence + `pnpm build` followed by `pnpm test:example`. +- Setup: Added `corepack enable pnpm` before bare pnpm commands and documented + cold-Corepack and cold-store network requirements. +- Next action: Run the corrected clean example sequence and documentation, + source, formatting, and diff checks; then commit the correction batch. + +### 2026-07-31 โ€” Second-wave clean-host and checker correction + +- RED: Added three minimal clean-host/order regression fixtures to + `scripts/check-documentation.test.mjs`. The focused checker test failed as + expected because it accepted `corepack enable pnpm`. +- GREEN: Added the narrow repository-guide checker in + `scripts/check-documentation.mjs`; it rejects shim installation, requires + direct Corepack setup, and requires build before the root example test in the + same setup block. The focused checker test then passed. +- Documentation: Replaced human-facing repository command blocks with direct + `corepack pnpm` invocation, added cold-cache network explanation, corrected + immutable-Proto checksum wording, and made the `(when)` test-first example + use the real current imports and clock lifecycle. +- Next action: Run the final focused documentation, source, formatting, build, + example, and diff checks; then commit the correction batch. + +### 2026-07-31 โ€” Targeted review confirmation + +- Review: Reliability confirmed direct Corepack use, checksum wording, + immutable-input approval and compatibility review, and the deterministic + setup-order checker. Style confirmed the real clock lifecycle and generated + schema in the test-first example. +- Correction: Documentation review noted that the generated-schema import + resolved but did not use the canonical path from `when.test.ts`; changed it + to `./generated/test-when_pb.js`. +- Next action: Re-run affected documentation checks, commit the focused + correction, then create the isolated snapshot-version commit. + +### 2026-07-31 โ€” API registry-availability correction + +- P1 evidence: Unauthenticated `npm view @spine-event-engine/validation` + returned E404 from the public npm registry. `origin/master` still has the old + package layout and version, so it has not published the renamed package. +- Correction: Removed current, moving, and exact npm-install claims from the + root README. The package README now states that public npm availability is + pending and gives one conditional future snapshot command for a release from + `master`. +- Version boundary: Confirmed `0d852fd` remains the isolated + `Bump version -> 2.0.0-snapshot.7` commit with exactly the three synchronized + manifest `version` fields changed. No manifest was changed by this correction. +- Next action: Run docs, formatting, package, and diff checks; commit the + documentation correction. + +### 2026-07-31 โ€” Verification handoff limitation + +- The post-bump worktree required a frozen install-state refresh before pnpm + would run the requested checks. The cold-store refresh reached the registry + but stopped on DNS `ENOTFOUND` failures; it made no lockfile change. +- The implementation owner ran the network-independent diff check. The + orchestrator retains the docs, formatting, package, and full verification + gates with approved network access. +- Next action: Commit the documentation and task-record correction without + expanding scope into dependency recovery. + +### 2026-07-31 โ€” Review convergence and restored verification + +- Recovery: With approved network access, the frozen post-bump install passed + against the unchanged lockfile and restored the workspace dependencies. +- Verification: Documentation, source conventions, formatting, and diff checks + passed. `corepack pnpm package:check` packed 112 files, installed the local + `2.0.0-snapshot.7` package, compiled its consumer, and loaded the ESM API. +- API review: Confirmed the E404 correction is resolved. Root and package + READMEs now distinguish current public-registry unavailability from the + conditional future `master` snapshot command. The isolated `0d852fd` version + commit remains unchanged and valid. +- Review convergence: Documentation, style, TypeScript/API, and reliability + concerns are clean after accepted corrections; security remains N/A. +- Next action: Commit review convergence, run the canonical full gate, then + push and integrate the verified task into `dev`. + +### 2026-07-31 โ€” Canonical task verification + +- Full gate: `corepack pnpm verify` passed Node policy, 12 immutable Proto + checksums, generation, TypeScript, source conventions, ESLint, Prettier, + checker regressions, 320 tests, TypeDoc, maintained documentation, Buf lint, + deterministic output, build, compiled example, package smoke, and Git checks. +- Coverage: 94.86% statements, 91.68% branches, 99.19% functions, and 96.12% + lines. +- Package: Packed 112 files, installed the local + `@spine-event-engine/validation@2.0.0-snapshot.7`, compiled its consumer, and + loaded the ESM API. +- Next action: Commit this verification boundary, push the task branch, merge + it into `dev`, and run post-merge verification. + +### 2026-07-31 โ€” Integration closure + +- Task push: Pushed final task head + `f2ff606327d117f2a48bdf271339d0aa0614ec38` to the remote task branch. +- Integration: Merged the task into `dev` as + `0066571e7aaa16382d249ade4e27752a43fc94bc` and pushed the verified merge. +- Post-merge gate: Frozen installation and `corepack pnpm verify` passed on + `dev` with all 320 tests and the same 94.86/91.68/99.19/96.12 coverage. +- CI: GitHub Actions Verify run + `https://github.com/SpineEventEngine/validation-ts/actions/runs/30648799174` + completed successfully for the exact merge commit. +- Remote state: Before the closure commit, `origin/dev` matched the merge and + `origin/master` remained + `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- Cleanup: Removed the clean task worktree and deleted the integrated local and + remote task branches. Preserved `.pnpm-store/` and + `validation-ts.code-workspace` in the main checkout. +- Follow-up: The package-scoped example test script finds no tests because the + root Vitest include is resolved from the package working directory. Current + guides use the verified build-then-root-test sequence; a dedicated tooling + task can repair the package script later. +- Next action: Commit and push this closure record, confirm final remote refs, + and verify the closure CI run. diff --git a/build-protocol/work-logs/T-0011.md b/build-protocol/work-logs/T-0011.md new file mode 100644 index 0000000..dbc5a75 --- /dev/null +++ b/build-protocol/work-logs/T-0011.md @@ -0,0 +1,283 @@ +# T-0011 Work Log + +Task: `build-protocol/tasks/T-0011-domain-id-docs/TASK.md` +Branch: `task/T-0011-domain-id-docs` +Baseline: `a34056e7e7f6141116b20c9457863375786aed83` + +## Entries + +### 2026-08-01 โ€” Approval, isolation, and baseline + +- Work: Reconciled local and remote `dev`, preserved the main checkout's + untracked `.pnpm-store/` and `validation-ts.code-workspace`, created the + isolated task branch/worktree, installed the committed lockfile, and ran the + clean baseline. +- Files: Added the current T-0011 task and work records and activated the + milestone in the project plan. +- Commands and results: Remote `dev` and the worktree baseline resolve to + `a34056e`; baseline validation and example tests passed 18 files and 320 + tests. Documentation, source, lint, formatting, Proto verification, + generation, TypeScript, example execution, package consumer, and Git checks + passed. The package smoke needed network access after the sandboxed run could + not resolve the npm registry. +- Decisions: Classify the example Proto field-kind changes as high-risk while + keeping one writer because the affected README, schema, generated shapes, + scenarios, and checker are tightly coupled. +- Risks: Example serialized field kinds change; validation package runtime and + public exports remain out of scope. +- Next action: Obtain the requirements split, then begin the checker and + scenario work with verified RED failures. + +### 2026-08-01 โ€” Requirements split + +- Work: The requirements splitter organized the coupled work into checker RED + tests, checker implementation, then schema/consumer/documentation updates. +- Decisions: Every containing ID field is both required and recursively + validated. Tests reject both an absent ID and a present ID whose value is + empty and exercise `GetUserRequest.user_id` directly. User and category ID + values receive no invented numeric-string format; the product ID keeps its + existing pattern. +- Risks: None beyond the recorded example wire-kind change. +- Next action: Dispatch the single implementation owner for test-first delivery. + +### 2026-08-01 โ€” RED: documentation and domain-ID behavior + +- Work: Added deterministic checker expectations for documented Proto declarations + and exact one-empty-line spacing, plus example behavior tests for absent and + present-but-empty `User.id` and `GetUserRequest.user_id` values. +- Commands and results: `node scripts/check-documentation.test.mjs` failed at + the new missing-leading-comment expectation because the checker accepted the + field. `corepack pnpm exec vitest run packages/example/tests/scenarios.test.ts` + failed two new tests because IDs remain primitive `int32` fields; the empty + request-ID shape could not encode as an `int32`. +- Next action: Add the checker rule, convert the example IDs to messages, then + regenerate and make the tests green. + +### 2026-08-01 โ€” Preserved default nested-ID behavior + +- Decision: Keep validation runtime behavior unchanged. A present ID message + with an empty value equals the generated default nested message, so recursive + validation skips it and `(required)` reports the containing `id` or + `user_id` field. Tests cover both absent and present-empty inputs, including + `GetUserRequest.user_id`, and require both to be rejected. +- Reason: The approved work changes the example schema and documentation, not + validation-library runtime behavior; the observed behavior is documented and + must remain stable. + +### 2026-08-01 โ€” GREEN: documentation and domain-ID behavior + +- Commands and results: `corepack pnpm generate` completed all three Buf + generation steps; `node scripts/check-documentation.test.mjs` passed; + `node scripts/check-documentation.mjs` checked eight maintained Markdown + files; and `corepack pnpm exec vitest run packages/example/tests/scenarios.test.ts` + passed 10 tests. +- Next action: Run focused Proto, TypeScript, build, formatting, and diff + checks before implementation review. + +### 2026-08-01 โ€” Focused implementation verification + +- Commands and results: `corepack pnpm proto:verify` verified 12 immutable + Proto files; `corepack pnpm proto:lint`, `corepack pnpm typecheck:generated`, + `corepack pnpm build`, `corepack pnpm test:example` (10 tests), + `corepack pnpm source:check`, `corepack pnpm format:check`, and + `git diff --check` all passed. +- Self-review: Confirmed each example identifier is a message field with both + `(required)` and `(validate)`, Product ID retains its `prod-[0-9]+` rule, + user and category values have no invented format, and the checker names the + README declaration that violates comment or spacing rules. +- Next action: Commit the implementation and hand the branch to the review + wave. + +### 2026-08-01 โ€” Implementation handoff + +- Commit: `79e7229ca5f3795fbc606b3a212837f50dec4175` + (`feat(example): model domain IDs as messages`). +- Evidence: The implementation report records the verified RED/GREEN cycle, + focused checks, immutable-Proto proof, and the preserved default nested-ID + behavior. +- Next action: Record the immutable review basis and dispatch the complete + specialist wave. + +### 2026-08-01 โ€” Complete review wave + +- Documentation: P1; the package guide still showed primitive `int32 id`, and + neither README taught the three message IDs, their nested initializer shape, + or why domain-specific ID types are used. +- Style/maintainability and reliability: P1; the checker did not enforce the + exact domain-ID schema contract promised by the task. +- Reliability: P2; raw brace counting in Proto fences could be confused by a + comment or quoted string. +- Style, API, and reliability: P2; `Product.category` was accidentally made + required and its scenario was changed to hide the unrelated regression. +- Disposition: Accepted all four deduplicated findings. No reviewer reported a + P0, and no finding was rejected. +- Next action: Return one correction batch to the implementation owner, run + affected checks, and re-review only the affected concerns. + +### 2026-08-01 โ€” Correction batch RED/GREEN + +- RED: The checker test failed because it accepted a UserId value without the + required rule. +- GREEN: Added deterministic checks for the three ID messages and four + required-and-validated accepting fields, brace tracking that ignores comments + and quoted strings, beginner ID explanations in both READMEs, and restored + optional Product category behavior with its category-free exact-minimum + scenario. +- Commands and results: checker tests and documentation check passed; + generation, example tests (10), generated typecheck, immutable Proto + verification (12 files), Proto lint, source conventions, formatting, and + diff checks passed. +- Next action: Hand the correction diff to the affected re-review concerns; + the orchestrator owns staging and commit. + +### 2026-08-01 โ€” README artifact correction + +- RED: New Complete Proto Example fixtures rejected the primitive integer ID + form and quoted text artifacts. +- GREEN: Removed the unused quick-start ID block, repaired the Complete Proto + Example and both Domain IDs prose sections, and added Product ID pattern + preservation to deterministic checks. +- Commands and results: checker tests and documentation check passed; + generation and the example suite passed 10 tests. Formatting required no + guide or checker changes. + +### 2026-08-01 โ€” Final README and source wording correction + +- RED: Added a Product ID pattern mutation and a quoted-artifact Complete Proto + Example fixture with the required Timestamp rule; the quoted form is rejected + for its missing documented UserId declaration. +- GREEN: Moved the general ID explanation after the Complete Proto Example, + retained the example README section, and replaced internal source-comment + wording with account and catalog terms. +- Commands and results: checker tests and documentation check, source + conventions, generation, example tests (10), formatting, and diff checks + passed. Direct Prettier formatting of Proto files is unsupported by the + configured formatter; the repository format check passed. + +### 2026-08-01 โ€” Independent correction verification + +- Commands and results: `node scripts/check-documentation.test.mjs`, the live + documentation checker, source conventions, immutable Proto verification (12 + files), Proto lint, repository formatting, build/generation, example tests + (10), and `git diff --check` passed from the orchestrator context. +- Inspection: No quoted array artifacts or primitive domain-ID declarations + remain; maintained example Proto comments use account and catalog terms. +- Next action: Commit the correction basis and dispatch affected re-review. + +### 2026-08-06 โ€” Final checker correction for accepted F-005/F-006 + +- Dispatch: `/root/t0011_checker_correction` (`implementer`, expected + `gpt-5.6-terra`, medium reasoning) owns only the documentation checker, + checker regressions, and current T-0011 records. +- F-005 RED: Added a fixture with primitive `User.id` followed by a later + `LaterUser` containing a valid required-and-validated `UserId id`. + `node --test scripts/check-documentation.test.mjs` failed at line 585 with + `AssertionError: Missing expected exception`, proving the old cross-message + search accepted the counterfeit field. +- F-005 GREEN: Added brace-aware `protoMessageBody` extraction after existing + Proto comment/string stripping, then matched the required and validated + field only within that named message body. The focused test passed. +- F-006 RED: Added a missing-spacing Proto-fence fixture whose default value + contains a single-quoted `{`. The same focused test command failed at line + 686 with `AssertionError: Missing expected exception`, proving the old + double-quote-only stripper corrupted declaration depth. +- F-006 GREEN: Changed `protoStructure` to retain the active quote delimiter + for both `'` and `"`, preserving escaped-character handling. The focused + test passed. +- Final commands and results: `node --test scripts/check-documentation.test.mjs`, + `node scripts/check-documentation.mjs`, `corepack pnpm source:check`, + `corepack pnpm format:check`, and `git diff --check` passed after formatting + the assigned files. An initial format check failed only because the edited + task record needed Prettier formatting; no checker or source check failed. +- Next action: Parent orchestration can re-review affected reliability and + maintainability concerns, then run the task-level completion gate. + +### 2026-08-06 โ€” Final direct-field correction for accepted F-007 + +- Dispatch: `/root/t0011_checker_correction` (`implementer`, expected + `gpt-5.6-terra`, medium reasoning) retains the bounded checker and T-0011 + record scope. +- Reviewer agreement: final style/maintainability and performance/reliability + review both identified the same P2: a nested declaration inside `User` could + satisfy the required-and-validated field check for a primitive direct + `User.id`. The deduplicated finding is accepted as F-007. +- F-007 RED: Added a `User` fixture with primitive direct `id` and nested + `Counterfeit` containing a valid `UserId id`. The focused checker test + command failed at line 596 with + `AssertionError: Missing expected exception`, proving nested fields were + included in the prior message body. +- F-007 GREEN: Changed `protoMessageBody` to retain only characters at the + named message's immediate brace depth and discard nested declaration bodies. + The focused checker regression passed, retaining the sibling-counterfeit and + single/double-quote, comment, and escape handling. +- Final verification: `node --test scripts/check-documentation.test.mjs`, + `node scripts/check-documentation.mjs`, `corepack pnpm source:check`, + `corepack pnpm format:check`, and `git diff --check` passed. +- Next action: Parent orchestration can re-review the converged checker + correction and continue the task-level completion process. + +### 2026-08-06 โ€” Review convergence + +- Reviewed ref: `8e1bad20a89651c8280dbcc55d7db8304d531f47`. +- Style/maintainability: `/root/t0011_style_rereview` (`gpt-5.6-terra`, high) + reported clean. Immediate-depth matching prevents nested counterfeit fields + from satisfying the containing message contract; F-001 through F-007 remain + resolved with no new P0/P1/P2 finding. +- Performance/reliability: `/root/t0011_reliability_rereview` + (`gpt-5.6-terra`, high) independently reported clean with the same F-007 + result. Focused checker tests, the live checker, source conventions, + formatting, and diff checks passed. +- Documentation and TypeScript/API: Their prior clean dispositions remain + valid because the final correction changed only checker structure, + regressions, and task records. +- Agent lifecycle: Both final reviewers were closed immediately after their + reports; no child remains active. +- Result: Review converged with all accepted P2 findings resolved. The next + action is the canonical `corepack pnpm verify` completion gate. + +### 2026-08-06 โ€” Canonical task verification + +- Initial full-gate result: `corepack pnpm verify` reached ESLint after Node, + immutable Proto, generation, TypeScript, and source checks passed. ESLint + reported four `no-useless-escape` errors in the new quoted-brace fixture. +- Root cause and correction: The fixture used a single-quoted JavaScript string + while escaping embedded double quotes. Removed only those unnecessary + escapes. `corepack pnpm lint`, the checker regression suite, and + `git diff --check` then passed. +- Canonical gate: A fresh `corepack pnpm verify` passed Node 24 policy, all 12 + immutable Proto checksums, generation, generated and test TypeScript, + source conventions, ESLint, Prettier, generated guards, workflow guards, + coverage, TypeDoc, maintained documentation, project Proto lint, + deterministic output, build, and the compiled example. +- Tests and coverage: 18 files and 322 tests passed. Coverage was 94.86% + statements, 91.68% branches, 99.19% functions, and 96.12% lines. +- Package boundary: The sandboxed package smoke stopped only because npm DNS + was unavailable. The network-enabled `corepack pnpm package:check` then + packed 112 files, installed `@spine-event-engine/validation@2.0.0-snapshot.7` + into a fresh consumer, and loaded the packed ESM API. +- Final gate: `corepack pnpm git:check` and `git diff --check` passed after the + package smoke. Collectively, every canonical `pnpm verify` sub-gate passed. +- Next action: Commit this verification boundary, push the task branch, merge + it into `dev`, run post-merge verification, push `dev`, and confirm remote + refs before cleanup. + +### 2026-08-06 โ€” Integration closure + +- Task delivery: Pushed verified task head + `18d747944e880c5f0a62e5449b2e6f684e63bd9a`, then merged it into `dev` as + `d3deaf1b8fc04d30cb5852c875e32fee11a20453` without conflicts. +- Post-merge verification: Repeated every local canonical gate on `dev`; 18 + files and 322 tests passed with 94.86% statements, 91.68% branches, 99.19% + functions, and 96.12% lines. The network-enabled package check packed 112 + files and loaded the installed ESM consumer; final Git checks passed. +- Remote synchronization: Pushed merge commit `d3deaf1` to `origin/dev` and + fetched the refs back. Local and remote `dev` matched; local and remote + `master` remained `24b6ffb8de85fcc8958d1652dd928a0142c3cdd2`. +- CI observation: `gh run list` could not query the workflow because the local + GitHub CLI credential returned HTTP 401. SSH Git fetch and push remained + operational. CI observation is not a canonical completion gate. +- Cleanup: Removed the clean task worktree, pruned its Git metadata, and + deleted `task/T-0011-domain-id-docs` locally and remotely. Preserved the + user's `.pnpm-store/` and `validation-ts.code-workspace` files. +- Result: T-0011 is complete. The closure record is the only remaining commit + to push to `dev`; `master` remains untouched. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..314e467 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,43 @@ +import js from "@eslint/js"; +import eslintConfigPrettier from "eslint-config-prettier"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "**/dist/**", + "**/coverage/**", + ".worktrees/**", + "packages/validation/docs/api/reference/**", + "**/generated/**", + "eslint.config.mjs", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended.map((config) => ({ + ...config, + files: ["**/*.ts"], + })), + { + files: ["**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-require-imports": "off", + }, + }, + { + files: ["scripts/**/*.mjs", "packages/*/scripts/**/*.mjs"], + languageOptions: { + globals: { + __dirname: "readonly", + Buffer: "readonly", + console: "readonly", + module: "readonly", + process: "readonly", + require: "readonly", + URL: "readonly", + }, + }, + }, + eslintConfigPrettier, +); diff --git a/package.json b/package.json index e103a50..11c93c4 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,58 @@ { - "name": "@spine-event-engine/validation-ts-workspace", - "version": "2.0.0-snapshot.4", + "name": "@spine-event-engine/validation-workspace", + "version": "2.0.0-snapshot.7", "private": true, + "type": "module", + "packageManager": "pnpm@11.9.0", + "engines": { + "node": ">=24.0.0" + }, "workspaces": [ "packages/*" ], "scripts": { - "build": "npm run build --workspace=@spine-event-engine/validation-ts", - "test": "npm test --workspace=@spine-event-engine/validation-ts", - "example": "npm start --workspace=@spine-event-engine/example-smoke" + "check:node": "node scripts/check-node-version.mjs", + "source:check": "node scripts/check-source-conventions.mjs", + "generate": "pnpm --filter @spine-event-engine/validation generate && pnpm --filter @spine-event-engine/validation generate:tests && pnpm --filter @spine-event-engine/example-smoke generate", + "build": "pnpm generate && tsc -b", + "typecheck": "pnpm generate && pnpm typecheck:generated", + "typecheck:generated": "tsc -b && tsc --noEmit -p packages/validation/tsconfig.tests.json && tsc --noEmit -p packages/example/tsconfig.tests.json", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test:validation": "pnpm generate && vitest run packages/validation/tests", + "test:example": "pnpm generate && vitest run packages/example/tests", + "test": "pnpm test:validation && pnpm test:example", + "test:coverage": "pnpm generate && vitest run --coverage", + "test:generated-guard": "node --test scripts/check-generated-determinism.test.mjs", + "test:workflow-pnpm-action-setup": "node --test scripts/check-pnpm-action-setup.test.mjs", + "docs:api": "typedoc --options typedoc.json", + "docs:check": "node scripts/check-documentation.test.mjs && typedoc --options typedoc.json && node scripts/check-documentation.mjs", + "proto:lint": "pnpm --filter @spine-event-engine/validation proto:lint && pnpm --filter @spine-event-engine/example-smoke proto:lint", + "proto:verify": "node scripts/verify-proto-sources.mjs", + "proto:check-generated": "node scripts/check-generated-determinism.mjs", + "package:check": "node scripts/check-package.mjs", + "git:check": "node scripts/check-git-diff.mjs", + "example": "pnpm --filter @spine-event-engine/example-smoke start", + "example:run": "pnpm --filter @spine-event-engine/example-smoke start:built", + "verify": "pnpm check:node && pnpm proto:verify && pnpm generate && pnpm typecheck:generated && pnpm source:check && pnpm lint && pnpm format:check && pnpm test:generated-guard && pnpm test:workflow-pnpm-action-setup && pnpm test:coverage && pnpm docs:check && pnpm proto:lint && pnpm proto:check-generated && pnpm build && pnpm example:run && pnpm package:check && pnpm git:check" }, "keywords": [], "author": "", "license": "Apache-2.0", - "description": "TypeScript validation library for Protobuf messages with Spine validation options" + "description": "TypeScript validation library for Protobuf messages with Spine validation options", + "devDependencies": { + "@bufbuild/protobuf": "2.13.0", + "@eslint/js": "9.39.1", + "eslint": "9.39.1", + "eslint-config-prettier": "10.1.8", + "prettier": "3.9.0", + "typedoc": "0.28.19", + "@types/node": "24.13.2", + "@vitest/coverage-v8": "4.1.9", + "typescript": "6.0.3", + "typescript-eslint": "8.62.0", + "vitest": "4.1.9", + "yaml": "2.9.0" + } } diff --git a/packages/example/README.md b/packages/example/README.md index 723b01c..c90e349 100644 --- a/packages/example/README.md +++ b/packages/example/README.md @@ -1,33 +1,96 @@ # Spine Validation TypeScript - Example Project -A standalone example demonstrating runtime validation of Protobuf messages +A standalone example demonstrating runtime validation of Protobuf messages with [Spine Validation](https://github.com/SpineEventEngine/validation/) constraints. ## What This Example Shows -- Defining Protobuf messages with Spine Validation options. -- Validating messages at runtime. -- Programmatically handling validation violations. -- Various validation scenarios (required fields, patterns, ranges, etc.). +- โœ… Defining Protobuf messages with Spine Validation options. +- โœ… Validating messages at runtime and formatting violations. +- โœ… Programmatically handling validation violations. +- โœ… Required values, patterns, ranges, distinct collections, nested messages, + known `Any` payloads, and Spine Time `(when)` checks. + +For public API details and option-by-option behavior, read the +[package guide](../validation/README.md). + +## Domain IDs + +The account, catalog, and category identifiers use `UserId`, `ProductId`, and +`CategoryId` messages. A domain message keeps an ID connected to what it names +and leaves room for future ID details. Set an ID with its `value`, such as +`{ value: "user-ada" }`, `{ value: "prod-1" }`, or +`{ value: "cat-keyboards" }`. ## Quick Start ### Install dependencies +From the repository root, use the Node.js version in +[`.node-version`](../../.node-version): + ```bash -npm install +corepack pnpm install --frozen-lockfile ``` +On a cold host, Corepack may need network access for the pinned pnpm release; +a cold pnpm store may then download the locked packages. + ### Run the example ```bash -npm start +corepack pnpm example +``` + +This generates TypeScript from `.proto` files, builds the validation package and +example, then prints the runnable scenarios. + +### Run the example tests + +```bash +corepack pnpm build +corepack pnpm test:example +``` + +This clean-checkout-safe sequence builds validation `dist`, generates example +schemas, and runs the exampleโ€™s Vitest tests. + +## Scenarios + +The console shows messages with missing user values, duplicate tags, an invalid +email pattern, accepted and rejected timestamp `(when)` constraints, a product +at its exact minimum price, nested category leaf violations, and known +`google.protobuf.Any` payload leaf violations. + +The runnable schemas are in [`proto/`](proto/), scenarios are in +[`src/scenarios.ts`](src/scenarios.ts), and assertions are in +[`tests/scenarios.test.ts`](tests/scenarios.test.ts). + +`proto/testing/invalid_configuration.proto` is tests-only. It demonstrates a +configuration error and is not a console scenario or runnable example schema. + +## Time Options + +`proto/user.proto` imports `spine/time_options.proto` and applies `(when)` to +two `google.protobuf.Timestamp` fields: + +```protobuf +// Stores the account issuance time. +google.protobuf.Timestamp issued_at = 6 [(when).in = PAST]; + +// Stores the account expiration time. +google.protobuf.Timestamp expires_at = 7 [(when).in = FUTURE]; ``` -This will: -1. Generate TypeScript code from `.proto` files. -2. Build the TypeScript code. -3. Run the example showing various validation scenarios. +The example includes one message that satisfies both rules and one that violates +both. See the [validation contract](../validation/docs/validation-contract.md) +for supported Spine Time message types and conversion details. + +## Next Steps + +- [Package guide](../validation/README.md) โ€” install and use the library. +- [Development guide](../validation/docs/development.md) โ€” build, test, and extend the workspace. +- [Contribution guide](../validation/docs/contributing.md) โ€” prepare a pull request. ## License diff --git a/packages/example/buf.gen.yaml b/packages/example/buf.gen.yaml index fa2f561..687e33a 100644 --- a/packages/example/buf.gen.yaml +++ b/packages/example/buf.gen.yaml @@ -1,7 +1,7 @@ version: v2 plugins: - - local: protoc-gen-es - out: src/generated - opt: - - target=ts - - import_extension=js + - local: protoc-gen-es + out: src/generated + opt: + - target=ts + - import_extension=js diff --git a/packages/example/buf.yaml b/packages/example/buf.yaml index eb0c845..e958507 100644 --- a/packages/example/buf.yaml +++ b/packages/example/buf.yaml @@ -1,9 +1,43 @@ version: v2 modules: - - path: proto + - path: proto lint: - use: - - STANDARD + use: + - STANDARD + ignore_only: + PACKAGE_DEFINED: + - proto/spine/options.proto + - proto/spine/time_options.proto + PACKAGE_SAME_JAVA_PACKAGE: + - proto/spine/options.proto + - proto/spine/time_options.proto + PACKAGE_VERSION_SUFFIX: + - proto/product.proto + - proto/user.proto + - proto/testing/invalid_configuration.proto + - proto/spine/time/time.proto + IMPORT_USED: + # Keep User in ProductEnvelope's file dependency closure so a packed User + # Any is resolvable without a synthetic schema field. + - proto/product.proto + FIELD_LOWER_SNAKE_CASE: + - proto/spine/options.proto + - proto/spine/time_options.proto + ENUM_NO_ALLOW_ALIAS: + - proto/spine/options.proto + - proto/spine/time_options.proto + ENUM_VALUE_PREFIX: + - proto/spine/options.proto + - proto/spine/time/time.proto + - proto/spine/time_options.proto + ENUM_ZERO_VALUE_SUFFIX: + - proto/spine/options.proto + - proto/spine/time/time.proto + - proto/spine/time_options.proto + PACKAGE_DIRECTORY_MATCH: + - proto/product.proto + - proto/user.proto + - proto/testing/invalid_configuration.proto breaking: - use: - - FILE + use: + - FILE diff --git a/packages/example/package.json b/packages/example/package.json index 61e2ead..e97e4e8 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -1,23 +1,29 @@ { - "name": "@spine-event-engine/example-smoke", - "version": "2.0.0-snapshot.4", - "private": true, - "description": "Example project demonstrating @spine-event-engine/validation-ts usage", - "type": "module", - "scripts": { - "generate": "buf generate", - "build": "npm run generate && tsc", - "start": "npm run build && node dist/index.js", - "clean": "rm -rf dist src/generated" - }, - "dependencies": { - "@bufbuild/protobuf": "^2.10.2", - "@spine-event-engine/validation-ts": "*" - }, - "devDependencies": { - "@bufbuild/buf": "^1.61.0", - "@bufbuild/protoc-gen-es": "^2.10.2", - "@types/node": "^25.0.3", - "typescript": "^5.9.3" - } + "name": "@spine-event-engine/example-smoke", + "version": "2.0.0-snapshot.7", + "private": true, + "description": "Example project demonstrating @spine-event-engine/validation usage", + "type": "module", + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "generate": "buf generate", + "build": "pnpm generate && tsc -b", + "start": "pnpm --filter @spine-event-engine/validation build && pnpm build && pnpm start:built", + "start:built": "node dist/index.js", + "test": "pnpm --filter @spine-event-engine/validation build && pnpm generate && vitest run", + "clean": "rm -rf dist src/generated", + "proto:lint": "buf lint" + }, + "dependencies": { + "@bufbuild/protobuf": "2.13.0", + "@spine-event-engine/validation": "workspace:*" + }, + "devDependencies": { + "@bufbuild/buf": "1.72.0", + "@bufbuild/protoc-gen-es": "2.13.0", + "@types/node": "24.13.2", + "typescript": "6.0.3" + } } diff --git a/packages/example/proto/product.proto b/packages/example/proto/product.proto index 3701b87..c4ca66c 100644 --- a/packages/example/proto/product.proto +++ b/packages/example/proto/product.proto @@ -27,93 +27,146 @@ syntax = "proto3"; package example; +import "google/protobuf/any.proto"; import "google/protobuf/timestamp.proto"; import "spine/options.proto"; +import "user.proto"; + +// Identifies one catalog product. +message ProductId { + // Stores the required product code in the prod-number format. + string value = 1 [(required) = true, + (pattern).regex = "^prod-[0-9]+$", + (pattern).error_msg = "Product ID must follow format 'prod-XXX'."]; +} +// Represents a catalog product returned by the example storefront. message Product { - string id = 1 [(required) = true, - (pattern).regex = "^prod-[0-9]+$", - (pattern).error_msg = "Product ID must follow format 'prod-XXX'. Provided: `{value}`."]; + // Stores the required catalog product identifier. + ProductId id = 1 [(required) = true, + (validate) = true]; + // Supplies the required customer-facing product name. string name = 2 [(required) = true, (if_missing).error_msg = "Product name is required."]; + // Describes the product for catalog display. string description = 3; - double price = 4 [(required) = true, - (min).value = "0.01", - (min).error_msg = "Price must be at least {other}. Provided: {value}."]; + // Records the sale price, which must be at least one cent. + double price = 4 [(min).value = "0.01", + (min).error_msg = "Price must be at least `${min.value}`. Provided: `${field.value}`."]; + // Records available inventory within the supported stock range. int32 stock = 5 [(min).value = "0", (range).value = "[0..1000000)"]; - google.protobuf.Timestamp created_at = 6 [(required) = true]; + // Captures when the catalog entry was created. + google.protobuf.Timestamp created_at = 6; - Category category = 7 [(required) = true, - (validate) = true]; + // Carries the validated category assigned to the product. + Category category = 7 [(validate) = true]; - // Display settings, demonstrates "goes" option. - // Text color can only be set when highlight color is set, and vice versa. + // Sets the text color only when a highlight color is also supplied. Color text_color = 8 [(goes).with = "highlight_color"]; + + // Sets the highlight color only when a text color is also supplied. Color highlight_color = 9 [(goes).with = "text_color"]; } +// Represents the RGB color components used by product display settings. message Color { + // Stores the red component on the inclusive 0โ€“255 scale. int32 red = 1 [(range).value = "[0..255]"]; + + // Stores the green component on the inclusive 0โ€“255 scale. int32 green = 2 [(range).value = "[0..255]"]; + + // Stores the blue component on the inclusive 0โ€“255 scale. int32 blue = 3 [(range).value = "[0..255]"]; } +// Identifies one catalog category. +message CategoryId { + // Stores the catalog category identifier text. + string value = 1 [(required) = true]; +} + +// Identifies the catalog category assigned to a product. message Category { - int32 id = 1 [(required) = true, - (min).value = "1"]; + // Holds the required catalog category identifier. + CategoryId id = 1 [(required) = true, + (validate) = true]; + // Holds the required category label. string name = 2 [(required) = true]; + + // Holds optional category context for display or filtering. + string context = 3; } +// Captures one required payment method for a checkout request. message PaymentMethod { + // Requires exactly one supported checkout payment method. oneof method { - option (is_required) = true; + option (choice).required = true; + // Carries validated card payment details. PaymentCardNumber payment_card = 1 [(validate) = true]; + // Carries validated bank-transfer details. BankAccount bank_account = 2 [(validate) = true]; } } +// Contains the details needed to charge a payment card. message PaymentCardNumber { + // Stores the required 13-to-19 digit card number. string number = 1 [(required) = true, (pattern).regex = "^[0-9]{13,19}$", (pattern).error_msg = "Card number must be 13-19 digits."]; - int32 expiry_month = 2 [(required) = true, - (range).value = "[1..12]"]; - int32 expiry_year = 3 [(required) = true, - (min).value = "2024"]; + + // Stores the card expiration month. + int32 expiry_month = 2 [(range).value = "[1..12]"]; + + // Stores the card expiration year, beginning in 2024. + int32 expiry_year = 3 [(min).value = "2024"]; } +// Contains the details needed to route a bank payment. message BankAccount { + // Stores the required 8-to-17 digit account number. string account_number = 1 [(required) = true, (pattern).regex = "^[0-9]{8,17}$"]; + + // Stores the required nine-digit routing number. string routing_number = 2 [(required) = true, (pattern).regex = "^[0-9]{9}$"]; } +// Requests one page of catalog products. message ListProductsRequest { - int32 page = 1 [(required) = true, - (min).value = "1", - (if_missing).error_msg = "Page number is required."]; + // Selects the one-based page to retrieve. + int32 page = 1 [(min).value = "1"]; - int32 page_size = 2 [(required) = true, - (range).value = "[1..100]", - (if_missing).error_msg = "Page size is required."]; + // Limits each returned page to 1 through 100 products. + int32 page_size = 2 [(range).value = "[1..100]"]; - // Optional search query. + // Narrows results to products matching the optional query. string search_query = 3; } +// Returns a page of catalog products and the total match count. message ListProductsResponse { - // List of products with the nested validation enabled. + // Lists the returned catalog products with checks enabled. repeated Product products = 1 [(validate) = true]; + // Reports the non-negative number of matching products. int32 total_count = 2 [(min).value = "0"]; } + +// Wraps catalog account details for examples. +message ProductEnvelope { + // Carries the Any value whose account details are checked. + google.protobuf.Any payload = 1 [(validate) = true]; +} diff --git a/packages/example/proto/spine/time/time.proto b/packages/example/proto/spine/time/time.proto new file mode 100644 index 0000000..4d2d6e0 --- /dev/null +++ b/packages/example/proto/spine/time/time.proto @@ -0,0 +1,221 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +package spine.time; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_multiple_files = true; +option java_outer_classname = "TimeProto"; +option java_package = "io.spine.time"; + +// Enum representing the 12 months of the year. +enum Month { + MONTH_UNDEFINED = 0; + JANUARY = 1; + FEBRUARY = 2; + MARCH = 3; + APRIL = 4; + MAY = 5; + JUNE = 6; + JULY = 7; + AUGUST = 8; + SEPTEMBER = 9; + OCTOBER = 10; + NOVEMBER = 11; + DECEMBER = 12; +} + +// A combination of year and month in the ISO-8601 calendar system, such as `2018-06`. +// +// The purpose of this type is to store values like "June 2018". +// +// Instances of `YearMonth` can be ordered chronologically. +// +message YearMonth { + option (is).java_type = "YearMonthTemporal"; + option (compare_by) = { + field: "year" + field: "month" + }; + + // A year with `1` being year 1 CE and `-1` being 1 BC. + int32 year = 1 [(min).value = "-999999999", (max).value = "999999999"]; + + // One of 12 Gregorian calendar months specified by `Month`. + Month month = 2 [(required) = true]; +} + +// A day of week. +// +// The int value follows the ISO-8601 standard, from 1 (Monday) to 7 (Sunday). +// +enum DayOfWeek { + DOW_UNDEFINED = 0; + MONDAY = 1; + TUESDAY = 2; + WEDNESDAY = 3; + THURSDAY = 4; + FRIDAY = 5; + SATURDAY = 6; + SUNDAY = 7; +} + +// A date without a time-zone. +// +// Use this message for describing a date (e.g. a birthday). +// +// Instances of `LocalDate` can be ordered chronologically. +// +message LocalDate { + option (is).java_type = "LocalDateTemporal"; + option (compare_by) = { + field: "year" + field: "month" + field: "day" + }; + + // A year with `1` being year 1 CE and `-1` being 1 BC. + int32 year = 1 [(min).value = "-999999999", (max).value = "999999999"]; + + // One of 12 Gregorian calendar months specified by `Month`. + Month month = 2 [(required) = true]; + + // A day that must be from 1 to 31 and valid for the year and month. + // + // In generated code for Kotlin/Java this is checked by + // `io.spine.time.validation.LocalDateValidator`. + // + int32 day = 3 [(min).value = "1", (max).value = "31"]; +} + +// A time without a time-zone. +// +// It is a description of a time, not an instant on a time-line. +// +// Instances of `LocalTime` can be ordered chronologically. +// +message LocalTime { + option (is).java_type = "LocalTimeMixin"; + option (compare_by) = { + field: "hour" + field: "minute" + field: "second" + field: "nano" + }; + + // An hour from 0 to 23. + int32 hour = 1 [(min).value = "0", (max).value = "23"]; + + // Minutes of an hour from 0 to 59. + int32 minute = 2 [(min).value = "0", (max).value = "59"]; + + // Seconds of a minute, specified from 0 to 59. + int32 second = 3 [(min).value = "0", (max).value = "59"]; + + // Fractions of a second from 0 to 999,999,999. + int32 nano = 4 [(min).value = "0", (max).value = "999999999"]; +} + +// A date-time without a time-zone. +// +// Instances of `LocalDateTime` can be ordered chronologically. +// +message LocalDateTime { + option (is).java_type = "LocalDateTimeTemporal"; + option (compare_by) = { + field: "date" + field: "time" + }; + + LocalDate date = 1 [(required) = true]; + LocalTime time = 2; +} + +// A time-zone offset from UTC, such as `+02:00`. +message ZoneOffset { + + // Please use [ZoneId] instead. + option deprecated = true; + + // The total offset in seconds. + int32 amount_seconds = 1; +} + +// A time with an offset from UTC. +message OffsetTime { + + // Please use [LocalTime] in combination with [ZoneId] instead. + option deprecated = true; + + // The local time. + LocalTime time = 1; + + // The offset of the time-zone from UTC. + ZoneOffset offset = 2; +} + +// A date-time with an offset from UTC. +message OffsetDateTime { + + // Usage history has shown that this type causes much confusion. + // Please use [ZonedDateTime] instead. + option deprecated = true; + + option (is).java_type = "OffsetDateTimeTemporal"; + + // The local date-time. + LocalDateTime date_time = 1 [(required) = true]; + + // The offset of the time-zone from UTC. + ZoneOffset offset = 2; +} + +// An ID of a time-zone, such as `Europe/Amsterdam`. +message ZoneId { + option (is).java_type = "ZoneIdMixin"; + + string value = 1; +} + +// A date-time with a time-zone in the ISO-8601 calendar system, +// such as `2018-06-25T19:22:45+01:00 Europe/Amsterdam`. +// +// Instances of `ZonedDateTime` cannot be ordered by field values, since the chronological order +// depends on interpretation of time in different time zones. +// +message ZonedDateTime { + option (is).java_type = "ZonedDateTimeTemporal"; + + // The local date-time. + LocalDateTime date_time = 1 [(required) = true]; + + // The time-zone. + ZoneId zone = 2 [(required) = true]; +} diff --git a/packages/example/proto/spine/time_options.proto b/packages/example/proto/spine/time_options.proto new file mode 100644 index 0000000..e069e87 --- /dev/null +++ b/packages/example/proto/spine/time_options.proto @@ -0,0 +1,106 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +// API Note on Packaging +// --------------------- +// We do not define the package for this file to allow shorter options for user-defined types. +// This allows writing: +// +// [(when).in = FUTURE]; +// +// instead of: +// +// [(spine.time.when).in = FUTURE]; +// + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.time.validation"; +option java_outer_classname = "TimeOptionsProto"; +option java_multiple_files = true; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + + // See `TimeOption`. + TimeOption when = 73819; +} + +// Specifies that the field value is a point in time lying either in the future or in the past. +// +// Applicable to `google.protobuf.Timestamp` and types introduced in the `spine.time` package +// that describe time-related concepts. +// +// Repeated fields are supported. +// +// Example: Using the `(when)` option. +// +// message ScheduleMeeting { +// spine.time.ZonedDateTime start = 1 [(when).in = FUTURE]; +// } +// +message TimeOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " of the type `${field.type}` must be in the `${when.in}`." + " The encountered value: `${field.value}`."; + + // Defines a restriction for the timestamp. + Time in = 1; + + // Deprecated: please use `error_msg` instead. + string msg_format = 2 [deprecated = true]; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` โ€“ the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` โ€“ the fully qualified name of the field type. + // 4. `${parent.type}` โ€“ the fully qualified name of the validated message. + // 5. `${when.in}` โ€“ the specified timestamp restriction. It is either "past" or "future". + // + string error_msg = 3; +} + +// This enumeration defines a restriction for date/time values. +enum Time { + + // The default value (if the time option is not set). + TIME_UNDEFINED = 0; + + // The value must be in the past. + PAST = 1; + + // The value must be in the future. + FUTURE = 2; +} diff --git a/packages/spine-validation-ts/tests/basic-validation.test.ts b/packages/example/proto/testing/invalid_configuration.proto similarity index 64% rename from packages/spine-validation-ts/tests/basic-validation.test.ts rename to packages/example/proto/testing/invalid_configuration.proto index 8285902..42ea218 100644 --- a/packages/spine-validation-ts/tests/basic-validation.test.ts +++ b/packages/example/proto/testing/invalid_configuration.proto @@ -23,28 +23,15 @@ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +// TEST-ONLY: deliberately invalid option target; never import from runnable example code. +syntax = "proto3"; -/** - * Unit tests for `@spine-event-engine/validation-ts` package. - * - * Tests basic validation functionality and violation formatting. - */ - -import { validate, formatViolations } from '../src'; - -describe('Basic Validation', () => { - it('should export `validate` function', () => { - expect(typeof validate).toBe('function'); - }); +package example.testing; - it('should export `formatViolations` function', () => { - expect(typeof formatViolations).toBe('function'); - }); -}); +import "spine/options.proto"; -describe('Format Violations', () => { - it('should return "No violations" for empty array', () => { - const result = formatViolations([]); - expect(result).toBe('No violations'); - }); -}); +// Deliberately applies `(required)` to an unsupported scalar target. +message InvalidRequiredTarget { + // Is the int32 field that makes the required-option configuration invalid. + int32 quantity = 1 [(required) = true]; +} diff --git a/packages/example/proto/user.proto b/packages/example/proto/user.proto index 826f00e..05f7296 100644 --- a/packages/example/proto/user.proto +++ b/packages/example/proto/user.proto @@ -28,39 +28,72 @@ syntax = "proto3"; package example; import "spine/options.proto"; +import "spine/time_options.proto"; +import "google/protobuf/timestamp.proto"; -message User { - option (required_field) = "id | email"; +// Identifies one account user. +message UserId { + // Stores the account user identifier text. + string value = 1 [(required) = true]; +} - int32 id = 1 [(min).value = "1"]; +// Represents a user exposed by the example account API. +message User { + // Stores the required account user identifier. + UserId id = 1 [(required) = true, + (validate) = true]; + // Stores the required display name with the accepted character pattern. string name = 2 [(required) = true, (pattern).regex = "^[A-Za-z][A-Za-z0-9 ]{1,49}$", - (pattern).error_msg = "Name must start with a letter and be 2-50 characters. Provided: `{value}`."]; + (pattern).error_msg = "Name must start with a letter and be 2-50 characters."]; + // Stores the required account email address. string email = 3 [(required) = true, (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", - (pattern).error_msg = "Email must be valid. Provided: `{value}`."]; + (pattern).error_msg = "Email must be valid."]; + + // Assigns the user's access role. + Role role = 4; + + // Lists unique labels associated with the user. + repeated string tags = 5 [(distinct) = true, + (if_has_duplicates).error_msg = "Tags must be unique; duplicates: `${field.duplicates}`."]; - Role role = 4 [(required) = true]; + // Records an issuance time that must be in the past. + google.protobuf.Timestamp issued_at = 6 [(when).in = PAST]; - repeated string tags = 5 [(distinct) = true]; + // Records an expiration time that must be in the future. + google.protobuf.Timestamp expires_at = 7 [(when).in = FUTURE]; } +// Defines access roles available to example users. enum Role { + // Represents an unspecified role when none has been assigned. ROLE_UNSPECIFIED = 0; + + // Grants ordinary user permissions. ROLE_USER = 1; + + // Grants administrative permissions. ROLE_ADMIN = 2; + + // Grants moderation permissions. ROLE_MODERATOR = 3; } +// Requests a single user by identifier. message GetUserRequest { - int32 user_id = 1 [(required) = true, - (min).value = "1", - (if_missing).error_msg = "User ID is required."]; + // Supplies the required identifier of the requested user. + UserId user_id = 1 [(required) = true, + (validate) = true]; } +// Returns the requested user and whether it was found. message GetUserResponse { + // Carries the account user record. User user = 1 [(validate) = true]; + + // Indicates whether a matching user exists. bool found = 2; } diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 60ca908..0f8bb15 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,171 +1,33 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Example demonstrating the `@spine-event-engine/validation-ts` package. - * - * This example shows how to validate Protobuf messages with Spine validation constraints. - */ - -import {create} from '@bufbuild/protobuf'; -import {UserSchema, Role} from './generated/user_pb.js'; -import {validate, Violations} from '@spine-event-engine/validation-ts'; - -/** - * Helper function to display violations in a readable format. - */ -function displayViolations(violations: any[]): void { +/** Console adapter for the inspectable runnable validation scenarios. */ +import { Violations } from "@spine-event-engine/validation"; +import { ExampleScenarios } from "./scenarios.js"; + +/** Renders each runnable scenario and its violations to the console. */ +const ConsoleOutput = { + /** Prints each scenario violation in the same presentation used by the example. + * @param violations Scenario violations to render, or an empty collection for a success message. + */ + displayViolations( + violations: ReturnType<typeof ExampleScenarios.run>[number]["violations"], + ): void { if (violations.length === 0) { - console.log('โœ“ No violations - message is valid!'); - return; + console.log("โœ“ No violations - message is valid!"); + return; } - - violations.forEach((v, i) => { - const fieldPath = Violations.failurePath(v); - const message = Violations.formatMessage(v); - console.log(`${i + 1}. ${v.typeName}.${fieldPath}: ${message}`); + violations.forEach((violation, index) => { + console.log( + `${index + 1}. ${violation.typeName}.${Violations.failurePath(violation)}: ${Violations.formatMessage(violation)}`, + ); }); + }, +}; + +console.log("=== Spine Validation Example ===\n"); +for (const scenario of ExampleScenarios.run()) { + console.log(scenario.name); + console.log("-".repeat(scenario.name.length)); + console.log("Violations:", scenario.violationCount); + ConsoleOutput.displayViolations(scenario.violations); + console.log(); } - -console.log('=== Spine Validation Example ===\n'); - -// Example 1: Valid user - all required fields provided -console.log('Example 1: Valid User'); -console.log('---------------------'); -const validUser = create(UserSchema, { - id: 1, - name: 'John Doe', - email: 'john.doe@example.com', - role: Role.ADMIN, - tags: ['developer', 'typescript'] -}); - -const validUserViolations = validate(UserSchema, validUser); -console.log('Violations:', validUserViolations.length); -displayViolations(validUserViolations); -console.log(); - -// Example 2: Invalid user - missing required email -console.log('Example 2: Missing Required Email'); -console.log('----------------------------------'); -const invalidUser1 = create(UserSchema, { - id: 2, - name: 'Jane Smith', - email: '', // Required but empty - role: Role.USER, - tags: [] -}); - -const violations1 = validate(UserSchema, invalidUser1); -console.log('Violations:', violations1.length); -displayViolations(violations1); -console.log(); - -// Example 3: Invalid user - missing required name -console.log('Example 3: Missing Required Name'); -console.log('---------------------------------'); -const invalidUser2 = create(UserSchema, { - id: 3, - name: '', // Required but empty - email: 'alice@example.com', - role: Role.USER, - tags: [] -}); - -const violations2 = validate(UserSchema, invalidUser2); -console.log('Violations:', violations2.length); -displayViolations(violations2); -console.log(); - -// Example 4: Multiple violations -console.log('Example 4: Multiple Violations'); -console.log('-------------------------------'); -const invalidUser3 = create(UserSchema, { - id: 4, - name: '', // Required but empty - email: '', // Required but empty - role: 0, // ROLE_UNSPECIFIED - tags: [] -}); - -const violations3 = validate(UserSchema, invalidUser3); -console.log('Violations:', violations3.length); -displayViolations(violations3); -console.log(); - -// Example 5: Pattern validation - invalid name format -console.log('Example 5: Pattern Validation (Invalid Name)'); -console.log('----------------------------------------------'); -const invalidPattern1 = create(UserSchema, { - id: 5, - name: '123Invalid', // Starts with number, violates pattern - email: 'valid@example.com', - role: Role.USER, - tags: [] -}); - -const violations4 = validate(UserSchema, invalidPattern1); -console.log('Violations:', violations4.length); -displayViolations(violations4); -console.log(); - -// Example 6: Pattern validation - invalid email format -console.log('Example 6: Pattern Validation (Invalid Email)'); -console.log('-----------------------------------------------'); -const invalidPattern2 = create(UserSchema, { - id: 6, - name: 'Bob Wilson', - email: 'notanemail', // Invalid email format - role: Role.USER, - tags: [] -}); - -const violations5 = validate(UserSchema, invalidPattern2); -console.log('Violations:', violations5.length); -displayViolations(violations5); -console.log(); - -// Example 7: Multiple validation types -console.log('Example 7: Multiple Validation Types'); -console.log('-------------------------------------'); -const multipleInvalid = create(UserSchema, { - id: 7, - name: '', // Required violation - email: 'bad@', // Pattern violation - role: 0, - tags: [] -}); - -const violations6 = validate(UserSchema, multipleInvalid); -console.log('Violations:', violations6.length); -violations6.forEach((v, i) => { - const fieldPath = Violations.failurePath(v); - const message = Violations.formatMessage(v); - console.log(`${i + 1}. Field "${fieldPath}": ${message}`); -}); -console.log(); - -console.log('=== Example Complete ==='); +console.log("=== Example Complete ==="); diff --git a/packages/example/src/scenarios.ts b/packages/example/src/scenarios.ts new file mode 100644 index 0000000..3e5f717 --- /dev/null +++ b/packages/example/src/scenarios.ts @@ -0,0 +1,130 @@ +import { create } from "@bufbuild/protobuf"; +import type { Message } from "@bufbuild/protobuf"; +import type { GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { anyPack } from "@bufbuild/protobuf/wkt"; +import { validate, type ConstraintViolation } from "@spine-event-engine/validation"; + +import { ProductEnvelopeSchema, ProductSchema } from "./generated/product_pb.js"; +import { Role, UserSchema } from "./generated/user_pb.js"; + +/** Captures the input identity and validation outcome of one runnable example scenario. */ +export interface ExampleScenarioResult { + /** Identifies the scenario for console output and test assertions. */ + name: string; + /** Names the Protobuf message type used by the scenario. */ + typeName: string; + /** Counts violations returned while validating the scenario message. */ + violationCount: number; + /** Lists dot-separated paths for violations that identify a field. */ + fieldPaths: string[]; + /** Contains the complete validation violations for the scenario message. */ + violations: ConstraintViolation[]; +} + +/** Runs generated-schema scenarios used by the console adapter and tests. */ +export const ExampleScenarios = { + /** Produces the fixed set of executable validation scenarios. + * @returns Results for every example scenario in display order. + */ + run(): ExampleScenarioResult[] { + return [ + ExampleScenarios.result( + "missing user values", + UserSchema, + create(UserSchema, { role: Role.USER }), + ), + ExampleScenarios.result( + "duplicate user tags", + UserSchema, + create(UserSchema, { + id: { value: "user-ada" }, + name: "Ada Lovelace", + email: "ada@example.test", + role: Role.USER, + tags: ["typescript", "typescript"], + }), + ), + ExampleScenarios.result( + "invalid user email pattern", + UserSchema, + create(UserSchema, { + id: { value: "user-ada" }, + name: "Ada Lovelace", + email: "not-an-email", + role: Role.USER, + }), + ), + ExampleScenarios.result( + "past and future time constraints", + UserSchema, + create(UserSchema, { + id: { value: "user-ada" }, + name: "Ada Lovelace", + email: "ada@example.test", + role: Role.USER, + issuedAt: { seconds: 0n }, + expiresAt: { seconds: 4_102_444_800n }, + }), + ), + ExampleScenarios.result( + "violated past and future time constraints", + UserSchema, + create(UserSchema, { + id: { value: "user-ada" }, + name: "Ada Lovelace", + email: "ada@example.test", + role: Role.USER, + issuedAt: { seconds: 4_102_444_800n }, + expiresAt: { seconds: 1n }, + }), + ), + ExampleScenarios.result( + "product at its exact minimum price", + ProductSchema, + create(ProductSchema, { + id: { value: "prod-1" }, + name: "Keyboard", + price: 0.01, + }), + ), + ExampleScenarios.result( + "nested product category leaf violations", + ProductSchema, + create(ProductSchema, { + id: { value: "prod-2" }, + name: "Keyboard", + price: 1, + category: { id: { value: "" }, name: "", context: "present" }, + }), + ), + ExampleScenarios.result( + "known Any payload leaf violations", + ProductEnvelopeSchema, + create(ProductEnvelopeSchema, { + payload: anyPack(UserSchema, create(UserSchema, { role: Role.USER })), + }), + ), + ]; + }, + + /** Validates one scenario message and records presentation-ready details. + * @param name Display name for the scenario. + * @param schema Generated descriptor used to validate the message. + * @param message Message instance supplied to validation. + * @returns Scenario identity, violations, and derived display fields. + */ + result<T extends Message>( + name: string, + schema: GenMessage<T>, + message: T, + ): ExampleScenarioResult { + const violations = validate(schema, message); + return { + name, + typeName: schema.typeName, + violationCount: violations.length, + fieldPaths: violations.map((violation) => violation.fieldPath?.fieldName.join(".") ?? ""), + violations, + }; + }, +}; diff --git a/packages/example/tests/scenarios.test.ts b/packages/example/tests/scenarios.test.ts new file mode 100644 index 0000000..6258545 --- /dev/null +++ b/packages/example/tests/scenarios.test.ts @@ -0,0 +1,111 @@ +import { create } from "@bufbuild/protobuf"; +import { anyUnpack, StringValueSchema } from "@bufbuild/protobuf/wkt"; +import { ValidationConfigurationError, Violations, validate } from "@spine-event-engine/validation"; + +import { ExampleScenarios } from "../src/scenarios.js"; +import { InvalidRequiredTargetSchema } from "../src/generated/testing/invalid_configuration_pb.js"; +import { GetUserRequestSchema, UserSchema } from "../src/generated/user_pb.js"; + +function scenario(name: string) { + const value = ExampleScenarios.run().find((item) => item.name === name); + if (!value) throw new Error(`Missing example scenario: ${name}`); + return value; +} + +describe("runnable validation scenarios", () => { + it("reports missing User values with an exact root, paths, and diagnostics", () => { + const value = scenario("missing user values"); + expect(value.typeName).toBe("example.User"); + expect(value.fieldPaths).toEqual(["id", "name", "email"]); + expect(value.violations.map(Violations.formatMessage)).toEqual([ + "The field `example.User.id` of the type `example.UserId` must have a non-default value.", + "The field `example.User.name` of the type `string` must have a non-default value.", + "The field `example.User.email` of the type `string` must have a non-default value.", + ]); + }); + + it("rejects both an absent user ID and a present empty user ID", () => { + expect(validate(UserSchema, create(UserSchema)).map(Violations.failurePath)).toContain("id"); + expect( + validate(UserSchema, create(UserSchema, { id: { value: "" } })).map(Violations.failurePath), + ).toContain("id"); + }); + + it("rejects both an absent request ID and a present empty request ID", () => { + expect( + validate(GetUserRequestSchema, create(GetUserRequestSchema)).map(Violations.failurePath), + ).toEqual(["user_id"]); + expect( + validate(GetUserRequestSchema, create(GetUserRequestSchema, { userId: { value: "" } })).map( + Violations.failurePath, + ), + ).toEqual(["user_id"]); + }); + + it("reports one duplicate equality class with its packed representative and diagnostics", () => { + const value = scenario("duplicate user tags"); + expect(value.violations).toHaveLength(1); + const [violation] = value.violations; + expect(violation.typeName).toBe("example.User"); + expect(violation.fieldPath?.fieldName).toEqual(["tags"]); + expect(anyUnpack(violation.fieldValue!, StringValueSchema)).toMatchObject({ + value: "typescript", + }); + expect(violation.message?.placeholderValue).toMatchObject({ + "field.value": "[typescript, typescript]", + "field.duplicates": "[typescript]", + }); + expect(Violations.formatMessage(violation)).toBe( + "Tags must be unique; duplicates: `[typescript]`.", + ); + }); + + it("formats the invalid User pattern with its exact root and field path", () => { + const value = scenario("invalid user email pattern"); + expect(value.typeName).toBe("example.User"); + expect(value.fieldPaths).toEqual(["email"]); + expect(value.violations.map(Violations.formatMessage)).toEqual(["Email must be valid."]); + }); + + it("accepts the Product exact minimum price", () => { + expect(scenario("product at its exact minimum price").violations).toEqual([]); + }); + + it("shows accepted and rejected deterministic time scenarios", () => { + expect(scenario("past and future time constraints").violations).toEqual([]); + const rejected = scenario("violated past and future time constraints"); + expect(rejected.fieldPaths).toEqual(["issued_at", "expires_at"]); + expect(rejected.violations.map(Violations.formatMessage)).toEqual([ + expect.stringContaining("past"), + expect.stringContaining("future"), + ]); + }); + + it("keeps nested Category reports leaf-only under the Product root", () => { + const value = scenario("nested product category leaf violations"); + expect(value.typeName).toBe("example.Product"); + expect(value.fieldPaths).toEqual(["category.id", "category.name"]); + expect(value.fieldPaths).not.toContain("category"); + }); + + it("keeps known Any payload reports as prefixed leaves under the envelope root", () => { + const value = scenario("known Any payload leaf violations"); + expect(value.typeName).toBe("example.ProductEnvelope"); + expect(value.fieldPaths).toEqual(["payload.id", "payload.name", "payload.email"]); + }); + + it("exposes the public configuration-error shape for a test-only invalid target", () => { + try { + validate(InvalidRequiredTargetSchema, create(InvalidRequiredTargetSchema)); + throw new Error("Expected configuration error"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationConfigurationError); + expect(error).toMatchObject({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "required", + typeName: "example.testing.InvalidRequiredTarget", + fieldPath: ["quantity"], + }); + } + }); +}); diff --git a/packages/example/tsconfig.json b/packages/example/tsconfig.json index 8546ebd..7d3ebfb 100644 --- a/packages/example/tsconfig.json +++ b/packages/example/tsconfig.json @@ -1,21 +1,12 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "node", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/example/tsconfig.tests.json b/packages/example/tsconfig.tests.json new file mode 100644 index 0000000..fe4a691 --- /dev/null +++ b/packages/example/tsconfig.tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules", "dist", "coverage"] +} diff --git a/packages/spine-validation-ts/README.md b/packages/spine-validation-ts/README.md deleted file mode 100644 index 0fa1485..0000000 --- a/packages/spine-validation-ts/README.md +++ /dev/null @@ -1,438 +0,0 @@ -# @spine-event-engine/validation-ts - -TypeScript validation library for Protobuf messages with [Spine Validation](https://github.com/SpineEventEngine/validation/) options. - -> **๐Ÿ”ง This package is in its experimental stage, the public API should not be considered stable.** - -## Features - -- โœ… Runtime validation of Protobuf messages against Spine validation constraints -- โœ… Support for all major Spine validation options -- โœ… Custom error messages with placeholder substitution -- โœ… Type-safe validation with full TypeScript support -- โœ… Works with [@bufbuild/protobuf](https://github.com/bufbuild/protobuf-es) (Protobuf-ES v2) - -## Prerequisites - -**Important:** This library is specifically designed for TypeScript code -generated by [Buf](https://buf.build/) using the Protobuf-ES code generator. - -This library requires: -- **[Buf](https://buf.build/)** for Protobuf code generation -- **`@bufbuild/protobuf`** v2.10.2 or later for TypeScript/JavaScript runtime -- TypeScript code generated using `@bufbuild/protoc-gen-es` - -**This library will NOT work with:** -- Code generated by `protoc` with other plugins (e.g., `ts-proto`, `protobuf.js`) -- Hand-written Protobuf TypeScript bindings -- Other Protobuf code generators - -The package includes: -- Spine validation Proto definitions (`spine/options.proto`) -- TypeScript validation implementation -- Pre-configured TypeScript build - -## Installation - -This package is currently published as a **pre-release (snapshot)** version. -Install it using the `@snapshot` dist-tag: - -```bash -npm install @spine-event-engine/validation-ts@snapshot @bufbuild/protobuf -``` - -**Note:** `@bufbuild/protobuf` is a peer dependency and must be installed explicitly. You'll use it for creating and working with Protobuf messages in your application code. - -## Quick Start - -### Usage Guide - -#### Step 1: Configure Buf for code generation - -Create a `buf.gen.yaml` file in your project root: - -```yaml -version: v2 -plugins: - - remote: buf.build/protocolbuffers/es:v2.2.3 - out: src/generated -``` - -#### Step 2: Define validation in your Proto files - -Create your `.proto` file with Spine validation options: - -```protobuf -syntax = "proto3"; - -import "spine/options.proto"; - -message User { - string name = 1 [(required) = true]; - - string email = 2 [ - (required) = true, - (pattern).regex = "^[^@]+@[^@]+\\.[^@]+$", - (pattern).error_msg = "Email must be valid. Provided: `{value}`." - ]; - - int32 age = 3 [ - (min).value = "0", - (max).value = "150" - ]; - - repeated string tags = 4 [(distinct) = true]; -} -``` - -#### Step 3: Generate TypeScript code - -Run Buf to generate TypeScript code from your proto files: - -```bash -buf generate -``` - -This generates TypeScript schemas in `src/generated/` that include all validation metadata. - -#### Step 4: Use Validation library in your TypeScript code - -```typescript -import { create } from '@bufbuild/protobuf'; -import { validate, Violations } from '@spine-event-engine/validation-ts'; -import { UserSchema } from './generated/user_pb'; - -const user = create(UserSchema, { - name: '', // Missing required field - email: 'invalid-email' // Invalid pattern -}); - -const violations = validate(UserSchema, user); - -if (violations.length > 0) { - violations.forEach(violation => { - const fieldPath = Violations.failurePath(violation); - const message = Violations.formatMessage(violation); - - console.error(`${violation.typeName}.${fieldPath}: ${message}`); - }); -} -``` - -## API Reference - -### `validate(schema, message)` - -Validates a Protobuf message against its Spine validation constraints. - -**Parameters:** -- `schema`: the message schema (e.g., `UserSchema`) -- `message`: the message instance to validate - -**Returns:** array of `ConstraintViolation` objects (empty if valid) - -Each `ConstraintViolation` contains: -- `typeName` โ€” the message type that failed validation -- `fieldPath` โ€” the path to the field that violated the constraint -- `message` โ€” the error message with placeholders replaced -- `param` โ€” additional parameters related to the violation -- `violation` โ€” nested violations for complex constraints - -### `Violations` Utility - -The `Violations` provides convenient methods for working with constraint violations. - -#### `Violations.formatMessage(violation)` - -Returns the formatted error message from a violation with all placeholders replaced by actual values. - -**Parameters:** -- `violation`: the `ConstraintViolation` object - -**Returns:** formatted error message string - -**Example:** -```typescript -const message = Violations.formatMessage(violation); -// Returns: "Email must be valid. Provided: `invalid@`." -``` - -#### `Violations.failurePath(violation)` - -Returns the field path as a dot-separated string. - -**Parameters:** -- `violation`: the `ConstraintViolation` object - -**Returns:** field path string (e.g., `"user.email"`) - -**Example:** -```typescript -const fieldPath = Violations.failurePath(violation); -// Returns: "user.email" -``` - -### `formatViolations(violations)` - -Formats an array of violations into a human-readable numbered list string. - -Mostly usable for debugging. - -**Parameters:** -- `violations`: array of `ConstraintViolation` objects - -**Returns:** formatted string with one violation per line - -**Example:** -```typescript -const violations = validate(UserSchema, user); -console.log(formatViolations(violations)); -// Output: -// 1. example.User.name: A value must be set. -// 2. example.User.email: Email must be valid. Provided: `invalid@`. -``` - -**Note:** For production use, consider using `Violations.formatMessage()` and `Violations.failurePath()` -to build custom error displays tailored to your application. - -## Supported Validation Options - -### Field-level options - -- โœ… **`(required)`** โ€” Ensures field has a non-default value -- โœ… **`(if_missing)`** โ€” Custom error message for required fields -- โœ… **`(pattern)`** โ€” Regex validation for string fields -- โœ… **`(min)` / `(max)`** โ€” Numeric range validation with inclusive/exclusive bounds -- โœ… **`(range)`** โ€” Bounded numeric ranges using bracket notation `[min..max]`, with custom error messages -- โœ… **`(distinct)`** โ€” Ensures unique elements in repeated fields and map values -- โœ… **`(validate)`** โ€” Enables recursive validation of nested messages -- โœ… **`(goes)`** โ€” Field dependency validation (field can only be set if another field is set) - -### Message-level options - -- โœ… **`(require)`** โ€” Requires specific field combinations using boolean logic - -### `oneof`-Level Options - -- โœ… **`(choice)`** โ€” Requires that a `oneof` group has at least one field set - -### Not Supported - -- โŒ **`(if_invalid)`** โ€” Deprecated field-level option -- โŒ **`(set_once)`** โ€” Requires state tracking across validations (not feasible in TypeScript runtime) -- โŒ **`(if_set_again)`** โ€” Companion to `(set_once)` -- โŒ **`(is_required)`** โ€” Deprecated, replaced by `(choice)` -- โŒ **`(required_field)`** โ€” Deprecated, replaced by `(require)` - -## Example - -```protobuf -syntax = "proto3"; - -import "spine/options.proto"; - -message User { - option (require).fields = "id | email"; - - int32 id = 1 [ - (set_once) = true, - (min).value = "1" - ]; - - string name = 2 [ - (required) = true, - (pattern).regex = "^[A-Za-z][A-Za-z0-9 ]{1,49}$", - (pattern).error_msg = "Name must start with a letter and be 2-50 characters." - ]; - - string email = 3 [ - (required) = true, - (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", - (pattern).error_msg = "Email must be valid." - ]; - - int32 age = 4 [ - (range).value = "[13..120]" - ]; - - repeated string tags = 5 [ - (distinct) = true - ]; - - map<string, string> preferences = 6 [ - (distinct) = true // Values must be unique. - ]; -} - -message Address { - string street = 1 [(required) = true]; - string city = 2 [(required) = true]; - string zip_code = 3 [ - (pattern).regex = "^[0-9]{5}$" - ]; -} - -message UserProfile { - User user = 1 [ - (required) = true, - (validate) = true - ]; - - Address address = 2 [ - (validate) = true - ]; -} -``` - -## Validation Behavior - -### Proto3 field semantics - -In `proto3`, fields have default values: -- Numeric fields default to `0` -- String fields default to `""` -- Bool fields default to `false` -- Message fields default to `undefined` - -The `(required)` validator considers a field "set" when: -- String fields are non-empty -- Numeric fields are non-zero -- Bool fields are `true` or `false` (both count as set) -- Message fields are not `undefined` -- Repeated fields have at least one element - -### Nested validation - -Use `(validate) = true` on message fields to recursively validate nested messages: - -```protobuf -message Order { - Product product = 1 [ - (required) = true, - (validate) = true // Validates Product's constraints too. - ]; -} -``` - -### Field dependencies - -Use `(goes)` to enforce field dependencies: - -```protobuf -message ShippingDetails { - string tracking_number = 1 [ - (goes).with = "carrier", - (goes).error_msg = "Tracking number requires carrier to be set." - ]; - string carrier = 2 [(goes).with = "tracking_number"]; -} -``` - -### Required field combinations - -Use `(require)` for complex field requirements: - -```protobuf -message ContactInfo { - option (require).fields = "(phone & country_code) | email"; - - string phone = 1; - string country_code = 2; - string email = 3; -} -``` - -### `oneof` Constraints - -Use `(choice)` to require that a `oneof` group has a field set: - -```protobuf -message PaymentMethod { - oneof method { - option (choice).required = true; - option (choice).error_msg = "Payment method is required."; - - CreditCard credit_card = 1; - BankAccount bank_account = 2; - PayPal paypal = 3; - } -} -``` - -## Testing - -The production code is covered at ~80% of statements with 200+ tests across 11 test suites: - -- `basic-validation.test.ts` - Basic validation and formatting -- `required.test.ts` - `(required)` and `(if_missing)` options -- `pattern.test.ts` - `(pattern)` regex validation -- `required-field.test.ts` - `(require)` message-level option -- `min-max.test.ts` - `(min)` and `(max)` numeric validation -- `range.test.ts` - `(range)` bracket notation -- `distinct.test.ts` - `(distinct)` uniqueness validation -- `validate.test.ts` - `(validate)` nested validation -- `goes.test.ts` - `(goes)` field dependency validation -- `choice.test.ts` - `(choice)` `oneof` validation -- `integration.test.ts` - Complex multi-option scenarios - -Run tests with: - -```bash -npm test -``` - -## Architecture - -The validation system is built with extensibility in mind: - -- **`validation.ts`** โ€” Core validation engine using the visitor pattern -- **`options-registry.ts`** โ€” Dynamic registration of validation options -- **`options/`** โ€” Modular validators for each Spine option -- **Proto-first** โ€” Validation rules defined in `.proto` files -- **Type-safe** โ€” Full TypeScript support with generated types - -## Development Notes - -### Generated Code Patching - -The package uses a post-generation script ([scripts/patch-generated.js](scripts/patch-generated.js)) to handle -JavaScript reserved word conflicts in generated Protobuf code. - -**Issue:** - -The Spine `(require)` option generates an export named `require` in the TypeScript output: - -```typescript -export const require: GenExtension<MessageOptions, RequireOption> -``` - -However, `require` is a reserved identifier in Node.js/CommonJS, which can cause issues with module systems and tooling. - -**Solution:** - -After running `buf generate`, the patch script automatically renames the export to `requireFields`: - -```typescript -export const requireFields: GenExtension<MessageOptions, RequireOption> -``` - -This happens automatically as part of the build process: - -```json -{ - "scripts": { - "generate": "buf generate && node scripts/patch-generated.js" - } -} -``` - -The script patches both the main generated files and test generated files, ensuring consistency across the codebase. -This approach allows us to use the standard `(require)` option name in proto files while avoiding conflicts in the generated TypeScript code. - -## License - -Apache License 2.0 - -## Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/packages/spine-validation-ts/buf.yaml b/packages/spine-validation-ts/buf.yaml deleted file mode 100644 index c7e30e3..0000000 --- a/packages/spine-validation-ts/buf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -version: v2 -modules: - - path: proto -lint: - use: - - STANDARD -breaking: - use: - - FILE diff --git a/packages/spine-validation-ts/jest.config.js b/packages/spine-validation-ts/jest.config.js deleted file mode 100644 index c44c9c7..0000000 --- a/packages/spine-validation-ts/jest.config.js +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['<rootDir>/tests'], - testMatch: ['**/*.test.ts'], - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.d.ts', - '!src/generated/**', - ], - moduleFileExtensions: ['ts', 'js', 'json'], - coverageDirectory: 'coverage', - verbose: true, - transform: { - '^.+\\.ts$': ['ts-jest', { - tsconfig: { - skipLibCheck: true, - strict: false, - }, - }], - }, -}; diff --git a/packages/spine-validation-ts/package.json b/packages/spine-validation-ts/package.json deleted file mode 100644 index 5427e8c..0000000 --- a/packages/spine-validation-ts/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@spine-event-engine/validation-ts", - "version": "2.0.0-snapshot.4", - "description": "TypeScript validation library for Protobuf messages with Spine Validation options", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "generate": "buf generate && node scripts/patch-generated.js", - "generate:tests": "cd tests && buf generate && cd .. && node scripts/patch-generated.js", - "build": "npm run generate && tsc", - "test": "npm run generate && npm run generate:tests && jest", - "test:watch": "npm run generate && npm run generate:tests && jest --watch", - "test:coverage": "npm run generate && npm run generate:tests && jest --coverage", - "prepublishOnly": "npm run build" - }, - "keywords": [ - "protobuf", - "validation", - "spine", - "typescript", - "protobuf-es" - ], - "author": "Spine Event Engine team <developers@spine.io>", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/SpineEventEngine/validation-ts.git", - "directory": "packages/validation-ts" - }, - "peerDependencies": { - "@bufbuild/protobuf": "^2.10.2" - }, - "devDependencies": { - "@bufbuild/buf": "^1.61.0", - "@bufbuild/protobuf": "^2.10.2", - "@bufbuild/protoc-gen-es": "^2.10.2", - "@types/jest": "^30.0.0", - "@types/node": "^25.0.3", - "jest": "^30.2.0", - "ts-jest": "^29.4.6", - "typescript": "^5.9.3" - }, - "files": [ - "dist", - "src", - "proto", - "buf.yaml", - "buf.gen.yaml", - "README.md", - "!src/generated/examples", - "!tests" - ] -} diff --git a/packages/spine-validation-ts/scripts/patch-generated.js b/packages/spine-validation-ts/scripts/patch-generated.js deleted file mode 100755 index de24093..0000000 --- a/packages/spine-validation-ts/scripts/patch-generated.js +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node - -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Post-generation script to patch generated TypeScript files. - * - * Renames `require` export to `requireFields` to avoid JavaScript reserved word conflict. - */ - -const fs = require('fs'); -const path = require('path'); - -function patchFile(filePath) { - const content = fs.readFileSync(filePath, 'utf8'); - - // Replace export const require with export const requireFields - const patched = content.replace( - /export const require: GenExtension<MessageOptions, RequireOption>/g, - 'export const requireFields: GenExtension<MessageOptions, RequireOption>' - ); - - if (content !== patched) { - fs.writeFileSync(filePath, patched, 'utf8'); - console.log(`Patched: ${filePath}`); - } -} - -// Patch main generated file -const mainFile = path.join(__dirname, '../src/generated/spine/options_pb.ts'); -if (fs.existsSync(mainFile)) { - patchFile(mainFile); -} - -// Patch test generated file -const testFile = path.join(__dirname, '../tests/generated/spine/options_pb.ts'); -if (fs.existsSync(testFile)) { - patchFile(testFile); -} - -console.log('Patching complete'); diff --git a/packages/spine-validation-ts/src/options/choice.ts b/packages/spine-validation-ts/src/options/choice.ts deleted file mode 100644 index 7cdd5a6..0000000 --- a/packages/spine-validation-ts/src/options/choice.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(choice)` option. - * - * The `(choice)` option is a `oneof`-level constraint that ensures at least one - * field in a `oneof` group is set. - * - * Features: - * - Validates that a `oneof` group has at least one field set when required - * - Supports custom error messages via ChoiceOption.errorMsg - * - Works with any field types within the `oneof` group - * - * Examples: - * ```protobuf - * message PaymentMethod { - * oneof method { - * option (choice).required = true; - * option (choice).error_msg = "Payment method is required."; - * - * CreditCard credit_card = 1; - * BankAccount bank_account = 2; - * PayPal paypal = 3; - * } - * } - * ``` - */ - -import type { Message } from '@bufbuild/protobuf'; -import { getOption, hasOption, create } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import type { ChoiceOption } from '../generated/spine/options_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Creates a constraint violation for `(choice)` validation failures. - * - * @param typeName The fully qualified message type name. - * @param oneofName The name of the `oneof` group. - * @param customErrorMsg Optional custom error message from `ChoiceOption`. - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - oneofName: string, - customErrorMsg?: string -): ConstraintViolation { - const errorMsg = customErrorMsg || - `The \`oneof\` group '${oneofName}' must have one of its fields set.`; - - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName: [oneofName] - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: errorMsg, - placeholderValue: { - 'group.path': oneofName, - 'parent.type': typeName - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Checks if any field in a `oneof` group is set. - * - * In Protobuf-ES v2, `oneof`s are represented as a single property with - * `case` and `value` fields. The `oneof` is set if `case` is defined. - * - * @param message The message instance. - * @param oneof The `oneof` descriptor. - * @returns `true` if at least one field is set, `false` otherwise. - */ -function isOneofSet(message: any, oneof: any): boolean { - const oneofValue = message[oneof.localName]; - return oneofValue !== undefined && oneofValue !== null && oneofValue.case !== undefined; -} - -/** - * Validates a single oneof group for `(choice)` constraint. - * - * @param schema The message schema containing oneof descriptors. - * @param message The message instance being validated. - * @param oneof The oneof descriptor to validate. - * @param violations Array to collect constraint violations. - */ -function validateOneofChoice<T extends Message>( - schema: GenMessage<T>, - message: any, - oneof: any, - violations: ConstraintViolation[] -): void { - const choiceOpt = getRegisteredOption('choice'); - - if (!choiceOpt || !hasOption(oneof, choiceOpt)) { - return; - } - - const choiceOption = getOption(oneof, choiceOpt) as ChoiceOption; - - // Only validate if required is true - if (choiceOption.required === true) { - if (!isOneofSet(message, oneof)) { - violations.push(createViolation( - schema.typeName, - oneof.name, - choiceOption.errorMsg || undefined - )); - } - } -} - -/** - * Validates the `(choice)` option for all oneof groups in a message. - * - * This is a `oneof`-level constraint that ensures at least one field - * in the `oneof` group is set when the option is enabled. - * - * @param schema The message schema containing oneof descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validateChoiceFields<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - if (!schema.oneofs || schema.oneofs.length === 0) { - return; - } - - for (const oneof of schema.oneofs) { - validateOneofChoice(schema, message, oneof, violations); - } -} diff --git a/packages/spine-validation-ts/src/options/distinct.ts b/packages/spine-validation-ts/src/options/distinct.ts deleted file mode 100644 index f789761..0000000 --- a/packages/spine-validation-ts/src/options/distinct.ts +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(distinct)` option. - * - * The `(distinct)` option is a field-level constraint that enforces uniqueness - * of elements in repeated fields and map field values. - * - * Supported field types: - * - All repeated scalar types (`int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64`, - * `fixed32`, `fixed64`, `sfixed32`, `sfixed64`, `float`, `double`, `bool`, `string`, `bytes`) - * - Repeated enum fields - * - Map fields (validates that all values are unique; keys are inherently unique) - * - * Features: - * - Ensures all elements in a repeated field are unique - * - Ensures all values in a map field are unique (keys are always unique by definition) - * - Detects duplicate values and reports violations with element indices or keys - * - Works with primitive types (numbers, strings, booleans) - * - Works with enum values - * - * Examples: - * ```protobuf - * repeated string tags = 1 [(distinct) = true]; - * repeated int32 product_ids = 2 [(distinct) = true]; - * repeated Status statuses = 3 [(distinct) = true]; - * map<string, Email> emails = 4 [(distinct) = true]; // Email values must be unique - * ``` - */ - -import type { Message } from '@bufbuild/protobuf'; -import { getOption, hasOption, create } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Creates a constraint violation for `(distinct)` validation failures. - * - * @param typeName The fully qualified message type name. - * @param fieldName Array representing the field path (including index or key). - * @param duplicateValue The duplicate value found. - * @param firstLocation Index (for repeated fields) or key (for map fields) of the first occurrence. - * @param duplicateLocation Index (for repeated fields) or key (for map fields) of the duplicate occurrence. - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - fieldName: string[], - duplicateValue: any, - firstLocation: number | string, - duplicateLocation: number | string -): ConstraintViolation { - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: `Duplicate value found. Value {value} at location {duplicate_index} is a duplicate of the value at location {first_index}.`, - placeholderValue: { - 'value': String(duplicateValue), - 'first_index': String(firstLocation), - 'duplicate_index': String(duplicateLocation) - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Checks if two values are considered equal for distinctness purposes. - * - * Uses strict equality for primitives (number, string, boolean, bigint). - * - * @param val1 The first value to compare. - * @param val2 The second value to compare. - * @returns `true` if the values are equal, `false` otherwise. - */ -function valuesAreEqual(val1: any, val2: any): boolean { - return val1 === val2; -} - -/** - * Validates `(distinct)` constraint for a single field. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance being validated. - * @param field The field descriptor to validate. - * @param violations Array to collect constraint violations. - */ -function validateFieldDistinct<T extends Message>( - schema: GenMessage<T>, - message: any, - field: any, - violations: ConstraintViolation[] -): void { - const distinctOpt = getRegisteredOption('distinct'); - - if (!distinctOpt) { - return; - } - - if (field.fieldKind !== 'list' && field.fieldKind !== 'map') { - return; - } - - if (!hasOption(field, distinctOpt)) { - return; - } - - const distinctValue = getOption(field, distinctOpt); - if (distinctValue !== true) { - return; - } - - const fieldValue = (message as any)[field.localName]; - - if (field.fieldKind === 'list') { - if (!Array.isArray(fieldValue) || fieldValue.length <= 1) { - return; - } - - const seenValues = new Map<any, number>(); - - fieldValue.forEach((element: any, index: number) => { - let isDuplicate = false; - let firstIndex = -1; - - for (const [seenValue, seenIndex] of seenValues.entries()) { - if (valuesAreEqual(element, seenValue)) { - isDuplicate = true; - firstIndex = seenIndex; - break; - } - } - - if (isDuplicate) { - violations.push(createViolation( - schema.typeName, - [field.name, String(index)], - element, - firstIndex, - index - )); - } else { - seenValues.set(element, index); - } - }); - } else if (field.fieldKind === 'map') { - if (!fieldValue || Object.keys(fieldValue).length <= 1) { - return; - } - - const seenValues = new Map<any, string>(); - const entries = Object.entries(fieldValue); - - entries.forEach(([key, value]) => { - let isDuplicate = false; - let firstKey = ''; - - for (const [seenValue, seenKey] of seenValues.entries()) { - if (valuesAreEqual(value, seenValue)) { - isDuplicate = true; - firstKey = seenKey; - break; - } - } - - if (isDuplicate) { - violations.push(createViolation( - schema.typeName, - [field.name, key], - value, - firstKey, - key - )); - } else { - seenValues.set(value, key); - } - }); - } -} - -/** - * Validates the `(distinct)` option for all fields in a message. - * - * This is a field-level constraint that enforces uniqueness of elements - * in repeated fields. Only applies to repeated fields (lists). - * - * @param schema The message schema containing field descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validateDistinctFields<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - for (const field of schema.fields) { - validateFieldDistinct(schema, message, field, violations); - } -} diff --git a/packages/spine-validation-ts/src/options/goes.ts b/packages/spine-validation-ts/src/options/goes.ts deleted file mode 100644 index 413c989..0000000 --- a/packages/spine-validation-ts/src/options/goes.ts +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(goes)` option. - * - * The `(goes)` option is a field-level constraint that enforces field dependency: - * a field can only be set if another specified field is also set. - * - * Semantics: - * - If field A has `(goes).with = "B"`: - * - A is set AND B is NOT set โ€” VIOLATION - * - A is set AND B is set โ€” VALID - * - A is NOT set โ€” VALID (regardless of B) - * - * Examples: - * ```protobuf - * string time = 3 [(goes).with = "date"]; - * // time can only be set when date is also set - * - * string text_color = 1 [(goes).with = "highlight_color"]; - * string highlight_color = 2 [(goes).with = "text_color"]; - * // Mutual dependency: both must be set or both unset - * ``` - */ - -import type { Message } from '@bufbuild/protobuf'; -import { getOption, hasOption, create } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import type { GoesOption } from '../generated/spine/options_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Checks if a field has a non-default value (is "set") in proto3. - * - * For `proto3` fields: - * - Message fields โ€” non-default instance (not `undefined`/`null`) - * - String fields โ€” non-empty string - * - Numeric fields โ€” non-zero value - * - Bool fields โ€” any value (`true` or `false` both count as "set") - * - Enum fields โ€” non-zero value - * - * @param value The field value to check. - * @returns `true` if the field is considered set, `false` otherwise. - */ -function isFieldSet(value: any): boolean { - if (value === undefined || value === null) { - return false; - } - - if (typeof value === 'string') { - return value !== ''; - } - - if (typeof value === 'number') { - return value !== 0; - } - - if (typeof value === 'boolean') { - return true; - } - - if (typeof value === 'object') { - return true; - } - - return false; -} - -/** - * Creates a constraint violation for `(goes)` validation failures. - * - * @param typeName The fully qualified message type name. - * @param fieldName The name of the field that violated the constraint. - * @param requiredFieldName The name of the field that must be set. - * @param fieldValue The actual value of the violating field. - * @param customErrorMsg Optional custom error message from `(goes).error_msg`. - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - fieldName: string, - requiredFieldName: string, - fieldValue: any, - customErrorMsg?: string -): ConstraintViolation { - const errorMessage = customErrorMsg || - `The field \`${fieldName}\` can only be set when the field \`${requiredFieldName}\` is defined.`; - - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName: [fieldName] - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: errorMessage, - placeholderValue: { - 'value': fieldValue !== undefined ? String(fieldValue) : '' - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Validates `(goes)` constraint for a single field. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance being validated. - * @param field The field descriptor to validate. - * @param violations Array to collect constraint violations. - */ -function validateFieldGoes<T extends Message>( - schema: GenMessage<T>, - message: any, - field: any, - violations: ConstraintViolation[] -): void { - const goesOpt = getRegisteredOption('goes'); - - if (!goesOpt) { - return; - } - - if (!hasOption(field, goesOpt)) { - return; - } - - const goesOption = getOption(field, goesOpt) as GoesOption; - const requiredFieldName = goesOption.with; - - if (!requiredFieldName) { - return; - } - - const fieldValue = (message as any)[field.localName]; - - if (!isFieldSet(fieldValue)) { - return; - } - - const requiredField = schema.fields.find(f => f.name === requiredFieldName); - - if (!requiredField) { - violations.push(createViolation( - schema.typeName, - field.name, - requiredFieldName, - fieldValue, - `Field \`${field.name}\` references non-existent field \`${requiredFieldName}\` in (goes).with option.` - )); - return; - } - - const requiredFieldValue = (message as any)[requiredField.localName]; - - if (!isFieldSet(requiredFieldValue)) { - violations.push(createViolation( - schema.typeName, - field.name, - requiredFieldName, - fieldValue, - goesOption.errorMsg - )); - } -} - -/** - * Validates the `(goes)` option for all fields in a message. - * - * The `(goes)` option enforces field dependency validation: a field can only - * be set if another specified field is also set. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validateGoesFields<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - for (const field of schema.fields) { - validateFieldGoes(schema, message, field, violations); - } -} diff --git a/packages/spine-validation-ts/src/options/min-max.ts b/packages/spine-validation-ts/src/options/min-max.ts deleted file mode 100644 index f70733e..0000000 --- a/packages/spine-validation-ts/src/options/min-max.ts +++ /dev/null @@ -1,357 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(min)` and `(max)` options. - * - * The `(min)` and `(max)` options are field-level constraints that enforce - * numeric range validation on scalar numeric fields. - * - * Supported field types: - * - `int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64` - * - `fixed32`, `fixed64`, `sfixed32`, `sfixed64` - * - `float`, `double` - * - * Features: - * - Inclusive bounds by default (value >= min, value <= max) - * - Exclusive bounds via the `exclusive` flag (value > min, value < max) - * - Custom error messages with token replacement (`{value}`, `{other}`) - * - Validation applies to repeated fields (each element checked independently) - * - * Examples: - * ```protobuf - * int32 age = 1 [(min).value = "0"]; // age >= 0 - * double price = 2 [(min) = {value: "0.0", exclusive: true}]; // price > 0.0 - * int32 percentage = 3 [(max).value = "100"]; // percentage <= 100 - * ``` - */ - -import type { Message } from '@bufbuild/protobuf'; -import { getOption, hasOption, create, ScalarType } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import type { MinOption, MaxOption } from '../generated/spine/options_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Creates a constraint violation for `(min)` or `(max)` validation failures. - * - * @param typeName The fully qualified message type name. - * @param fieldName Array representing the field path. - * @param fieldValue The actual value of the field. - * @param errorMessage The error message describing the violation. - * @param thresholdValue The threshold value that was violated. - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - fieldName: string[], - fieldValue: any, - errorMessage: string, - thresholdValue: string -): ConstraintViolation { - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: errorMessage, - placeholderValue: { - 'value': String(fieldValue), - 'other': thresholdValue - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Checks if a scalar type is numeric. - * - * @param scalarType The scalar type to check. - * @returns `true` if the type is numeric, `false` otherwise. - */ -function isNumericType(scalarType: ScalarType): boolean { - return scalarType !== ScalarType.STRING && - scalarType !== ScalarType.BYTES && - scalarType !== ScalarType.BOOL; -} - -/** - * Parses a threshold value string based on the field's scalar type. - * - * @param valueStr The threshold value as a string. - * @param scalarType The scalar type of the field. - * @returns The parsed numeric threshold value. - */ -function parseThreshold(valueStr: string, scalarType: ScalarType): number { - if (scalarType === ScalarType.FLOAT || scalarType === ScalarType.DOUBLE) { - return parseFloat(valueStr); - } else { - return parseInt(valueStr, 10); - } -} - -/** - * Validates a single numeric value against `(min)` constraint. - * - * @param value The numeric value to validate. - * @param minOption The `(min)` option configuration. - * @param scalarType The scalar type of the field. - * @returns `true` if the value meets the constraint, `false` otherwise. - */ -function validateMinValue( - value: number, - minOption: MinOption, - scalarType: ScalarType -): boolean { - const threshold = parseThreshold(minOption.value, scalarType); - - if (isNaN(threshold)) { - console.warn(`Invalid min threshold value: "${minOption.value}"`); - return true; - } - - if (minOption.exclusive) { - return value > threshold; - } else { - return value >= threshold; - } -} - -/** - * Validates a single numeric value against `(max)` constraint. - * - * @param value The numeric value to validate. - * @param maxOption The `(max)` option configuration. - * @param scalarType The scalar type of the field. - * @returns `true` if the value meets the constraint, `false` otherwise. - */ -function validateMaxValue( - value: number, - maxOption: MaxOption, - scalarType: ScalarType -): boolean { - const threshold = parseThreshold(maxOption.value, scalarType); - - if (isNaN(threshold)) { - console.warn(`Invalid max threshold value: "${maxOption.value}"`); - return true; - } - - if (maxOption.exclusive) { - return value < threshold; - } else { - return value <= threshold; - } -} - -/** - * Gets the error message for `(min)` constraint violations. - * - * @param minOption The `(min)` option configuration. - * @returns The error message (custom or default). - */ -function getMinErrorMessage(minOption: MinOption): string { - if (minOption.errorMsg) { - return minOption.errorMsg; - } - - const comparator = minOption.exclusive ? 'greater than' : 'at least'; - return `The number must be ${comparator} {other}.`; -} - -/** - * Gets the error message for `(max)` constraint violations. - * - * @param maxOption The `(max)` option configuration. - * @returns The error message (custom or default). - */ -function getMaxErrorMessage(maxOption: MaxOption): string { - if (maxOption.errorMsg) { - return maxOption.errorMsg; - } - - const comparator = maxOption.exclusive ? 'less than' : 'at most'; - return `The number must be ${comparator} {other}.`; -} - -/** - * Validates `(min)` and `(max)` constraints for a single field. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance being validated. - * @param field The field descriptor to validate. - * @param violations Array to collect constraint violations. - */ -function validateFieldMinMax<T extends Message>( - schema: GenMessage<T>, - message: any, - field: any, - violations: ConstraintViolation[] -): void { - const minOpt = getRegisteredOption('min'); - const maxOpt = getRegisteredOption('max'); - - if (!minOpt && !maxOpt) { - return; - } - - const fieldValue = (message as any)[field.localName]; - - if (field.fieldKind === 'list') { - if (!field.listKind || field.listKind !== 'scalar' || !field.scalar) { - return; - } - - const scalarType = field.scalar; - if (!isNumericType(scalarType)) { - return; - } - - if (!Array.isArray(fieldValue) || fieldValue.length === 0) { - return; - } - - fieldValue.forEach((element: number, index: number) => { - validateSingleValue( - schema, - field, - element, - [field.name, String(index)], - scalarType, - violations - ); - }); - } else if (field.fieldKind === 'scalar') { - if (!field.scalar) { - return; - } - - const scalarType = field.scalar; - if (!isNumericType(scalarType)) { - return; - } - - if (fieldValue === undefined || fieldValue === null) { - return; - } - - validateSingleValue( - schema, - field, - fieldValue, - [field.name], - scalarType, - violations - ); - } -} - -/** - * Validates a single numeric value against `(min)` and `(max)` constraints. - * - * @param schema The message schema containing field descriptors. - * @param field The field descriptor being validated. - * @param value The numeric value to validate. - * @param fieldPath Array representing the field path. - * @param scalarType The scalar type of the field. - * @param violations Array to collect constraint violations. - */ -function validateSingleValue( - schema: GenMessage<any>, - field: any, - value: number, - fieldPath: string[], - scalarType: ScalarType, - violations: ConstraintViolation[] -): void { - const minOpt = getRegisteredOption('min'); - const maxOpt = getRegisteredOption('max'); - - if (minOpt && hasOption(field, minOpt)) { - const minOption = getOption(field, minOpt) as MinOption; - - if (minOption && minOption.value) { - const isValid = validateMinValue(value, minOption, scalarType); - - if (!isValid) { - violations.push(createViolation( - schema.typeName, - fieldPath, - value, - getMinErrorMessage(minOption), - minOption.value - )); - } - } - } - - if (maxOpt && hasOption(field, maxOpt)) { - const maxOption = getOption(field, maxOpt) as MaxOption; - - if (maxOption && maxOption.value) { - const isValid = validateMaxValue(value, maxOption, scalarType); - - if (!isValid) { - violations.push(createViolation( - schema.typeName, - fieldPath, - value, - getMaxErrorMessage(maxOption), - maxOption.value - )); - } - } - } -} - -/** - * Validates the `(min)` and `(max)` options for all fields in a message. - * - * These are field-level constraints that enforce numeric range validation. - * Only applies to numeric scalar types (integers, floats, doubles). - * - * @param schema The message schema containing field descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validateMinMaxFields<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - for (const field of schema.fields) { - validateFieldMinMax(schema, message, field, violations); - } -} diff --git a/packages/spine-validation-ts/src/options/pattern.ts b/packages/spine-validation-ts/src/options/pattern.ts deleted file mode 100644 index 157dde7..0000000 --- a/packages/spine-validation-ts/src/options/pattern.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(pattern)` option. - * - * The `(pattern)` option validates that a string field matches a given regular expression. - */ - -import type { Message } from '@bufbuild/protobuf'; -import { hasOption, getOption, create, ScalarType } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Creates a constraint violation object for `(pattern)` validation failures. - * - * @param typeName The fully qualified message type name. - * @param fieldName The name of the field that violated the constraint. - * @param fieldValue The actual value of the field. - * @param violationMessage The error message describing the violation. - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - fieldName: string, - fieldValue: any, - violationMessage: string -): ConstraintViolation { - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName: [fieldName] - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: violationMessage, - placeholderValue: { - 'field': fieldName, - 'value': String(fieldValue ?? '') - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Validates a single string value against a regex pattern with modifiers. - * - * @param value The string value to validate. - * @param regex The regular expression pattern. - * @param patternOption The pattern option object with optional modifiers. - * @returns `true` if the value matches the pattern, `false` otherwise. - */ -function validatePatternValue(value: string, regex: string, patternOption: any): boolean { - if (typeof value !== 'string') { - return false; - } - - try { - let flags = ''; - const modifier = patternOption.modifier; - - if (modifier) { - if (modifier.caseInsensitive) { - flags += 'i'; - } - if (modifier.multiline) { - flags += 'm'; - } - if (modifier.dotAll) { - flags += 's'; - } - if (modifier.unicode) { - flags += 'u'; - } - } - - const pattern = new RegExp(regex, flags); - const partialMatch = modifier?.partialMatch || false; - - if (partialMatch) { - return pattern.test(value); - } else { - return pattern.test(value); - } - } catch (error) { - console.error(`Invalid regex pattern: ${regex}`, error); - return false; - } -} - -/** - * Validates the `(pattern)` option for string fields. - * - * This function checks if string field values match the specified regular expression pattern. - * Supports pattern modifiers like `case_insensitive`, `multiline`, `dot_all`, etc. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validatePatternFields<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - const patternOption = getRegisteredOption('pattern'); - - if (!patternOption) { - return; - } - - for (const field of schema.fields) { - if (!hasOption(field, patternOption)) { - continue; - } - - const patternValue = getOption(field, patternOption); - if (!patternValue || typeof patternValue !== 'object' || !('regex' in patternValue)) { - continue; - } - - const regex = (patternValue as any).regex; - const errorMsg = (patternValue as any).errorMsg || - `The string must match the regular expression \`${regex}\`.`; - - const fieldValue = (message as any)[field.localName]; - - if (field.fieldKind === 'list') { - if (Array.isArray(fieldValue)) { - for (let i = 0; i < fieldValue.length; i++) { - const itemValue = fieldValue[i]; - if (typeof itemValue === 'string' && !validatePatternValue(itemValue, regex, patternValue)) { - violations.push(createViolation( - schema.typeName, - `${field.name}[${i}]`, - itemValue, - errorMsg - )); - } - } - } - } else if (field.fieldKind === 'scalar' && field.scalar === ScalarType.STRING) { - if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { - if (!validatePatternValue(fieldValue, regex, patternValue)) { - violations.push(createViolation( - schema.typeName, - field.name, - fieldValue, - errorMsg - )); - } - } - } - } -} diff --git a/packages/spine-validation-ts/src/options/range.ts b/packages/spine-validation-ts/src/options/range.ts deleted file mode 100644 index 35e5b34..0000000 --- a/packages/spine-validation-ts/src/options/range.ts +++ /dev/null @@ -1,348 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(range)` option. - * - * The `(range)` option is a field-level constraint that enforces bounded numeric ranges - * using bracket notation for inclusive/exclusive bounds. - * - * Supported field types: - * - `int32`, `int64`, `uint32`, `uint64`, `sint32`, `sint64` - * - `fixed32`, `fixed64`, `sfixed32`, `sfixed64` - * - `float`, `double` - * - * Features: - * - Inclusive bounds (closed intervals) โ€” `[min..max]` - * - Exclusive bounds (open intervals) โ€” `(min..max)` - * - Half-open intervals โ€” `[min..max)` or `(min..max]` - * - Validation applies to repeated fields (each element checked independently) - * - * Syntax: - * - `"[0..100]"` โ†’ 0 <= value <= 100 - * - `"(0..100)"` โ†’ 0 < value < 100 - * - `"[0..100)"` โ†’ 0 <= value < 100 - * - `"(0..100]"` โ†’ 0 < value <= 100 - * - * Examples: - * ```protobuf - * int32 rgb_value = 1 [(range).value = "[0..255]"]; // RGB color value - * int32 hour = 2 [(range).value = "[0..24)"]; // Hour (0-23) - * double percentage = 3 [(range).value = "(0.0..1.0)"]; // Exclusive percentage - * // With custom error message: - * int32 age = 4 [(range) = {value: "[18..120]", error_msg: "Age must be between 18 and 120"}]; - * ``` - */ - -import type { Message } from '@bufbuild/protobuf'; -import { getOption, hasOption, create, ScalarType } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import type { RangeOption } from '../generated/spine/options_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Represents a parsed range with bounds and inclusivity flags. - */ -interface ParsedRange { - min: number; - max: number; - minInclusive: boolean; - maxInclusive: boolean; -} - -/** - * Creates a constraint violation for `(range)` validation failures. - * - * @param typeName The fully qualified message type name. - * @param fieldName Array representing the field path. - * @param fieldValue The actual value of the field. - * @param rangeStr The range string that was violated. - * @param customErrorMsg Optional custom error message from RangeOption. - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - fieldName: string[], - fieldValue: any, - rangeStr: string, - customErrorMsg?: string -): ConstraintViolation { - const errorMsg = customErrorMsg || `The number must be in range ${rangeStr}.`; - - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: errorMsg, - placeholderValue: { - 'value': String(fieldValue), - 'range': rangeStr - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Checks if a scalar type is numeric. - * - * @param scalarType The scalar type to check. - * @returns `true` if the type is numeric, `false` otherwise. - */ -function isNumericType(scalarType: ScalarType): boolean { - return scalarType !== ScalarType.STRING && - scalarType !== ScalarType.BYTES && - scalarType !== ScalarType.BOOL; -} - -/** - * Parses a range string like `"[0..100]"` into a ParsedRange object. - * - * Syntax: - * - `[` or `]` = inclusive bound - * - `(` or `)` = exclusive bound - * - `..` = separator between min and max - * - * @param rangeStr The range string from the proto option. - * @param scalarType The field's scalar type for parsing numbers. - * @returns ParsedRange object or `null` if parsing fails. - */ -function parseRange(rangeStr: string, scalarType: ScalarType): ParsedRange | null { - const trimmed = rangeStr.trim(); - - if (trimmed.length < 5) { - console.warn(`Invalid range format (too short): "${rangeStr}"`); - return null; - } - - const firstChar = trimmed[0]; - const lastChar = trimmed[trimmed.length - 1]; - - if (!['[', '('].includes(firstChar) || ![')',']'].includes(lastChar)) { - console.warn(`Invalid range format (missing brackets): "${rangeStr}"`); - return null; - } - - const minInclusive = firstChar === '['; - const maxInclusive = lastChar === ']'; - - const middle = trimmed.substring(1, trimmed.length - 1); - - const parts = middle.split('..'); - if (parts.length !== 2) { - console.warn(`Invalid range format (missing .. separator): "${rangeStr}"`); - return null; - } - - const [minStr, maxStr] = parts; - - let min: number; - let max: number; - - if (scalarType === ScalarType.FLOAT || scalarType === ScalarType.DOUBLE) { - min = parseFloat(minStr); - max = parseFloat(maxStr); - } else { - min = parseInt(minStr, 10); - max = parseInt(maxStr, 10); - } - - if (isNaN(min) || isNaN(max)) { - console.warn(`Invalid range format (NaN values): "${rangeStr}"`); - return null; - } - - if (min > max) { - console.warn(`Invalid range format (min > max): "${rangeStr}"`); - return null; - } - - return { - min, - max, - minInclusive, - maxInclusive - }; -} - -/** - * Validates a single numeric value against a range constraint. - * - * @param value The numeric value to validate. - * @param range The parsed range object with bounds and inclusivity flags. - * @returns `true` if the value is within the range, `false` otherwise. - */ -function validateRangeValue(value: number, range: ParsedRange): boolean { - if (range.minInclusive) { - if (value < range.min) return false; - } else { - if (value <= range.min) return false; - } - - if (range.maxInclusive) { - if (value > range.max) return false; - } else { - if (value >= range.max) return false; - } - - return true; -} - -/** - * Validates `(range)` constraints for a single field. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance being validated. - * @param field The field descriptor to validate. - * @param violations Array to collect constraint violations. - */ -function validateFieldRange<T extends Message>( - schema: GenMessage<T>, - message: any, - field: any, - violations: ConstraintViolation[] -): void { - const rangeOpt = getRegisteredOption('range'); - - if (!rangeOpt) { - return; - } - - const fieldValue = (message as any)[field.localName]; - - if (field.fieldKind === 'list') { - if (!field.listKind || field.listKind !== 'scalar' || !field.scalar) { - return; - } - - const scalarType = field.scalar; - if (!isNumericType(scalarType)) { - return; - } - - if (!hasOption(field, rangeOpt)) { - return; - } - - const rangeOption = getOption(field, rangeOpt) as RangeOption | undefined; - if (!rangeOption || !rangeOption.value) { - return; - } - - const rangeStr = rangeOption.value; - const customErrorMsg = rangeOption.errorMsg || undefined; - - const range = parseRange(rangeStr, scalarType); - if (!range) { - return; - } - - if (!Array.isArray(fieldValue) || fieldValue.length === 0) { - return; - } - - fieldValue.forEach((element: number, index: number) => { - if (!validateRangeValue(element, range)) { - violations.push(createViolation( - schema.typeName, - [field.name, String(index)], - element, - rangeStr, - customErrorMsg - )); - } - }); - } else if (field.fieldKind === 'scalar') { - if (!field.scalar) { - return; - } - - const scalarType = field.scalar; - if (!isNumericType(scalarType)) { - return; - } - - if (!hasOption(field, rangeOpt)) { - return; - } - - const rangeOption = getOption(field, rangeOpt) as RangeOption | undefined; - if (!rangeOption || !rangeOption.value) { - return; - } - - const rangeStr = rangeOption.value; - const customErrorMsg = rangeOption.errorMsg || undefined; - - const range = parseRange(rangeStr, scalarType); - if (!range) { - return; - } - - if (fieldValue === undefined || fieldValue === null) { - return; - } - - if (!validateRangeValue(fieldValue, range)) { - violations.push(createViolation( - schema.typeName, - [field.name], - fieldValue, - rangeStr, - customErrorMsg - )); - } - } -} - -/** - * Validates the `(range)` option for all fields in a message. - * - * This is a field-level constraint that enforces bounded numeric ranges. - * Only applies to numeric scalar types (integers, floats, doubles). - * - * @param schema The message schema containing field descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validateRangeFields<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - for (const field of schema.fields) { - validateFieldRange(schema, message, field, violations); - } -} diff --git a/packages/spine-validation-ts/src/options/required-field.ts b/packages/spine-validation-ts/src/options/required-field.ts deleted file mode 100644 index 2b903b2..0000000 --- a/packages/spine-validation-ts/src/options/required-field.ts +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(required_field)` option. - * - * The `(required_field)` option is a message-level constraint that requires - * at least one field from a set of alternatives or combinations of fields. - * - * Syntax: - * - `|` (pipe) โ€” OR operator, at least one field must be set - * - `&` (ampersand) โ€” AND operator, all fields must be set together - * - Parentheses for grouping โ€” `(field1 & field2) | field3` - * - * Examples: - * ```protobuf - * message User { - * option (required_field) = "id | email"; // Either id OR email must be set - * string id = 1; - * string email = 2; - * } - * - * message PhoneNumber { - * option (required_field) = "phone & country_code"; // Both phone AND country_code must be set - * string phone = 1; - * string country_code = 2; - * } - * - * message PersonName { - * option (required_field) = "given_name | (honorific_prefix & family_name)"; - * // Either given_name alone OR both honorific_prefix AND family_name - * string given_name = 1; - * string honorific_prefix = 2; - * string family_name = 3; - * } - * ``` - */ - -import type { Message } from '@bufbuild/protobuf'; -import { hasOption, getOption, create, getExtension, hasExtension, ScalarType } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import type { RequireOption } from '../generated/spine/options_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Creates a constraint violation for `(required_field)` at the message level. - * - * @param typeName The fully qualified message type name. - * @param expression The required field expression that was not satisfied. - * @param violationMessage The error message describing the violation. - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - expression: string, - violationMessage: string -): ConstraintViolation { - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName: [] - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: violationMessage, - placeholderValue: { - 'expression': expression - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Checks if a field is set (has a non-default value). - * - * @param message The message instance to check. - * @param fieldName The name of the field to check. - * @param schema The message schema containing field descriptors. - * @returns `true` if the field is set, `false` otherwise. - */ -function isFieldSet(message: any, fieldName: string, schema: GenMessage<any>): boolean { - const field = schema.fields.find(f => f.name === fieldName); - if (!field) { - console.warn(`Field "${fieldName}" not found in schema ${schema.typeName}`); - return false; - } - - const fieldValue = (message as any)[field.localName]; - - if (field.fieldKind === 'scalar') { - if (field.scalar) { - const scalarType = field.scalar; - if (scalarType === ScalarType.STRING || scalarType === ScalarType.BYTES) { - return fieldValue !== undefined && fieldValue !== null && fieldValue !== ''; - } else if (scalarType === ScalarType.BOOL) { - return fieldValue !== undefined && fieldValue !== null; - } else { - return fieldValue !== undefined && fieldValue !== null && fieldValue !== 0; - } - } - } else if (field.fieldKind === 'message') { - return fieldValue !== undefined && fieldValue !== null; - } else if (field.fieldKind === 'enum') { - return fieldValue !== undefined && fieldValue !== null && fieldValue !== 0; - } else if (field.fieldKind === 'list' || field.fieldKind === 'map') { - return fieldValue !== undefined && fieldValue !== null && - (Array.isArray(fieldValue) ? fieldValue.length > 0 : Object.keys(fieldValue).length > 0); - } - - return false; -} - -/** - * Tokenizes the `(required_field)` expression into tokens. - * - * @param expression The expression string to tokenize. - * @returns Array of tokens (field names, operators, parentheses). - */ -function tokenize(expression: string): string[] { - const tokens: string[] = []; - let current = ''; - - for (let i = 0; i < expression.length; i++) { - const char = expression[i]; - - if (char === '(' || char === ')' || char === '|' || char === '&') { - if (current.trim()) { - tokens.push(current.trim()); - current = ''; - } - tokens.push(char); - } else if (char === ' ' || char === '\t' || char === '\n') { - if (current.trim()) { - tokens.push(current.trim()); - current = ''; - } - } else { - current += char; - } - } - - if (current.trim()) { - tokens.push(current.trim()); - } - - return tokens; -} - -/** - * Parses and evaluates the `(required_field)` expression. - * - * @param expression The expression string to evaluate. - * @param message The message instance to validate. - * @param schema The message schema containing field descriptors. - * @returns `true` if the expression is satisfied, `false` otherwise. - */ -function evaluateExpression( - expression: string, - message: any, - schema: GenMessage<any> -): boolean { - const tokens = tokenize(expression); - - let index = 0; - - function parseOr(): boolean { - let result = parseAnd(); - - while (index < tokens.length && tokens[index] === '|') { - index++; - const right = parseAnd(); - result = result || right; - } - - return result; - } - - function parseAnd(): boolean { - let result = parsePrimary(); - - while (index < tokens.length && tokens[index] === '&') { - index++; - const right = parsePrimary(); - result = result && right; - } - - return result; - } - - function parsePrimary(): boolean { - if (index >= tokens.length) { - return false; - } - - const token = tokens[index]; - - if (token === '(') { - index++; - const result = parseOr(); - if (index < tokens.length && tokens[index] === ')') { - index++; - } - return result; - } else if (token === '|' || token === '&' || token === ')') { - return false; - } else { - index++; - return isFieldSet(message, token, schema); - } - } - - return parseOr(); -} - -/** - * Validates the `(required_field)` option for messages. - * - * This is a message-level constraint that requires specific combinations - * of fields to be set according to the expression. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validateRequiredFieldOption<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - const requireFieldsOption = getRegisteredOption('requireFields'); - - if (!requireFieldsOption) { - return; - } - - const options = (schema.proto as any).options; - if (!options) { - return; - } - - if (!hasExtension(options, requireFieldsOption)) { - return; - } - - const requireOption = getExtension(options, requireFieldsOption) as RequireOption; - if (!requireOption || !requireOption.fields) { - return; - } - - const expression = requireOption.fields; - - const satisfied = evaluateExpression(expression, message, schema); - - if (!satisfied) { - const violationMessage = `At least one of the required field combinations must be satisfied: ${expression}`; - violations.push(createViolation( - schema.typeName, - expression, - violationMessage - )); - } -} diff --git a/packages/spine-validation-ts/src/options/required.ts b/packages/spine-validation-ts/src/options/required.ts deleted file mode 100644 index 92b2021..0000000 --- a/packages/spine-validation-ts/src/options/required.ts +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(required)` option. - * - * The `(required)` option ensures that a field has a non-default value set. - */ - -import type { Message } from '@bufbuild/protobuf'; -import { hasOption, getOption, create } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Creates a constraint violation object for `(required)` validation failures. - * - * @param typeName The fully qualified message type name. - * @param fieldName The name of the field that violated the constraint. - * @param fieldValue The actual value of the field. - * @param violationMessage The error message describing the violation. - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - fieldName: string, - fieldValue: any, - violationMessage: string -): ConstraintViolation { - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName: [fieldName] - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: violationMessage, - placeholderValue: { - 'field': fieldName, - 'value': String(fieldValue ?? '') - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Validates the `(required)` option for all fields in a message. - * - * This function checks each field with the `(required)` option to ensure it has - * a non-default value. Custom error messages can be provided via the `(if_missing)` option. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validateRequiredFields<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - const requiredOption = getRegisteredOption('required'); - const ifMissingOption = getRegisteredOption('if_missing'); - - for (const field of schema.fields) { - if (!requiredOption || !hasOption(field, requiredOption) || !getOption(field, requiredOption)) { - continue; - } - - let violationMessage = 'A value must be set.'; - - if (ifMissingOption && hasOption(field, ifMissingOption)) { - const ifMissingOpt = getOption(field, ifMissingOption); - if (ifMissingOpt && typeof ifMissingOpt === 'object' && 'errorMsg' in ifMissingOpt) { - violationMessage = (ifMissingOpt as any).errorMsg || violationMessage; - } - } - - const fieldValue = (message as any)[field.localName]; - let isViolated = false; - - if (field.fieldKind === 'scalar') { - if (field.scalar) { - switch (field.scalar.toString()) { - case 'ScalarType.STRING': - isViolated = !fieldValue || fieldValue === ''; - break; - case 'ScalarType.BYTES': - isViolated = !fieldValue || fieldValue.length === 0; - break; - case 'ScalarType.INT32': - case 'ScalarType.INT64': - case 'ScalarType.UINT32': - case 'ScalarType.UINT64': - case 'ScalarType.SINT32': - case 'ScalarType.SINT64': - case 'ScalarType.FIXED32': - case 'ScalarType.FIXED64': - case 'ScalarType.SFIXED32': - case 'ScalarType.SFIXED64': - case 'ScalarType.FLOAT': - case 'ScalarType.DOUBLE': - isViolated = fieldValue === undefined || fieldValue === null; - break; - case 'ScalarType.BOOL': - isViolated = fieldValue === undefined || fieldValue === null; - break; - default: - isViolated = !fieldValue; - } - } - } else if (field.fieldKind === 'message') { - isViolated = !fieldValue; - } else if (field.fieldKind === 'enum') { - isViolated = fieldValue === undefined || fieldValue === null; - } - - if (field.fieldKind === 'list') { - isViolated = !fieldValue || !Array.isArray(fieldValue) || fieldValue.length === 0; - if (isViolated && !violationMessage.includes('at least')) { - violationMessage = 'At least one element must be present.'; - } - } - - if (isViolated) { - violations.push(createViolation( - schema.typeName, - field.name, - fieldValue, - violationMessage - )); - } - } -} diff --git a/packages/spine-validation-ts/src/options/validate.ts b/packages/spine-validation-ts/src/options/validate.ts deleted file mode 100644 index c78da1b..0000000 --- a/packages/spine-validation-ts/src/options/validate.ts +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation logic for the `(validate)` option. - * - * The `(validate)` option is a field-level constraint that enables recursive - * validation of nested message fields, repeated message fields, and map fields. - * - * Supported field types: - * - Message fields (singular) - * - Repeated message fields - * - Map fields (validates each entry) - * - * Features: - * - Recursive validation โ€” validates constraints in nested messages - * - Validates each item in repeated fields - * - Validates each value in map entries - * - * Examples: - * ```protobuf - * message Address { - * string street = 1 [(required) = true]; - * } - * Address address = 1 [(validate) = true]; - * repeated Product products = 2 [(validate) = true]; - * Customer customer = 3 [(validate) = true]; - * ``` - */ - -import type { Message } from '@bufbuild/protobuf'; -import { getOption, hasOption, create } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; -import type { ConstraintViolation } from '../generated/spine/validate/validation_error_pb'; -import { ConstraintViolationSchema } from '../generated/spine/validate/validation_error_pb'; -import { FieldPathSchema } from '../generated/spine/base/field_path_pb'; -import { TemplateStringSchema } from '../generated/spine/validate/error_message_pb'; -import { getRegisteredOption } from '../options-registry'; - -/** - * Creates a constraint violation for nested validation failure. - * - * @param typeName The fully qualified message type name. - * @param fieldName Array representing the field path. - * @param errorMessage The error message describing the violation. - * @param fieldValue The actual value of the field (optional). - * @returns A `ConstraintViolation` object. - */ -function createViolation( - typeName: string, - fieldName: string[], - errorMessage: string, - fieldValue?: any -): ConstraintViolation { - return create(ConstraintViolationSchema, { - typeName, - fieldPath: create(FieldPathSchema, { - fieldName - }), - fieldValue: undefined, - message: create(TemplateStringSchema, { - withPlaceholders: errorMessage, - placeholderValue: { - 'value': fieldValue ? String(fieldValue) : '' - } - }), - msgFormat: '', - param: [], - violation: [] - }); -} - -/** - * Gets the default error message for nested validation failures. - * - * @returns The default error message. - */ -function getErrorMessage(): string { - return 'Nested message validation failed.'; -} - -/** - * Validates a single message field by recursively calling validate on it. - * - * @param parentTypeName The fully qualified parent message type name. - * @param fieldPath Array representing the field path from parent. - * @param nestedMessage The nested message instance to validate. - * @param nestedSchema The schema of the nested message. - * @param violations Array to collect constraint violations. - */ -function validateNestedMessage( - parentTypeName: string, - fieldPath: string[], - nestedMessage: any, - nestedSchema: GenMessage<any>, - violations: ConstraintViolation[] -): void { - const { validate } = require('../validation'); - - const nestedViolations = validate(nestedSchema, nestedMessage); - - if (nestedViolations.length > 0) { - const errorMessage = getErrorMessage(); - - violations.push(createViolation( - parentTypeName, - fieldPath, - errorMessage, - nestedMessage - )); - - for (const nestedViolation of nestedViolations) { - const adjustedViolation = create(ConstraintViolationSchema, { - typeName: nestedViolation.typeName, - fieldPath: create(FieldPathSchema, { - fieldName: [...fieldPath, ...(nestedViolation.fieldPath?.fieldName || [])] - }), - fieldValue: nestedViolation.fieldValue, - message: nestedViolation.message, - msgFormat: nestedViolation.msgFormat, - param: nestedViolation.param, - violation: nestedViolation.violation - }); - violations.push(adjustedViolation); - } - } -} - -/** - * Validates `(validate)` constraint for a single field. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance being validated. - * @param field The field descriptor to validate. - * @param violations Array to collect constraint violations. - */ -function validateFieldValidate<T extends Message>( - schema: GenMessage<T>, - message: any, - field: any, - violations: ConstraintViolation[] -): void { - const validateOpt = getRegisteredOption('validate'); - - if (!validateOpt) { - return; - } - - if (!hasOption(field, validateOpt)) { - return; - } - - const validateValue = getOption(field, validateOpt); - if (validateValue !== true) { - return; - } - - const fieldValue = (message as any)[field.localName]; - - if (field.fieldKind === 'message') { - if (!fieldValue) { - return; - } - - const nestedSchema = field.message; - if (!nestedSchema) { - return; - } - - validateNestedMessage( - schema.typeName, - [field.name], - fieldValue, - nestedSchema, - violations - ); - } else if (field.fieldKind === 'list') { - if (!Array.isArray(fieldValue) || fieldValue.length === 0) { - return; - } - - if (field.listKind !== 'message' || !field.message) { - return; - } - - const nestedSchema = field.message; - - fieldValue.forEach((element: any, index: number) => { - if (element) { - validateNestedMessage( - schema.typeName, - [field.name, String(index)], - element, - nestedSchema, - violations - ); - } - }); - } else if (field.fieldKind === 'map') { - if (!fieldValue || Object.keys(fieldValue).length === 0) { - return; - } - - if (!field.mapValue || field.mapKind !== 'message' || !field.message) { - return; - } - - const nestedSchema = field.message; - - for (const [key, value] of Object.entries(fieldValue)) { - if (value) { - validateNestedMessage( - schema.typeName, - [field.name, key], - value, - nestedSchema, - violations - ); - } - } - } -} - -/** - * Validates the `(validate)` and `(if_invalid)` options for all fields in a message. - * - * This enables recursive validation of nested message fields. When `(validate) = true` - * is set on a message field, the validation framework will recursively validate - * all constraints defined in that nested message. - * - * @param schema The message schema containing field descriptors. - * @param message The message instance to validate. - * @param violations Array to collect constraint violations. - */ -export function validateNestedFields<T extends Message>( - schema: GenMessage<T>, - message: any, - violations: ConstraintViolation[] -): void { - for (const field of schema.fields) { - validateFieldValidate(schema, message, field, violations); - } -} diff --git a/packages/spine-validation-ts/src/validation.ts b/packages/spine-validation-ts/src/validation.ts deleted file mode 100644 index 8b97d88..0000000 --- a/packages/spine-validation-ts/src/validation.ts +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Validation module for Protobuf messages with Spine validation options. - * - * This module provides the main validation API and utility functions - * for validating Protobuf messages against Spine validation constraints. - */ - -import type { Message } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv2'; - -import type { ConstraintViolation } from './generated/spine/validate/validation_error_pb'; -import type { TemplateString } from './generated/spine/validate/error_message_pb'; - -import { validateRequiredFields } from './options/required'; -import { validatePatternFields } from './options/pattern'; -import { validateRequiredFieldOption } from './options/required-field'; -import { validateMinMaxFields } from './options/min-max'; -import { validateRangeFields } from './options/range'; -import { validateDistinctFields } from './options/distinct'; -import { validateNestedFields } from './options/validate'; -import { validateGoesFields } from './options/goes'; -import { validateChoiceFields } from './options/choice'; - -export type { ConstraintViolation, ValidationError } from './generated/spine/validate/validation_error_pb'; -export type { TemplateString } from './generated/spine/validate/error_message_pb'; -export type { FieldPath } from './generated/spine/base/field_path_pb'; - -/** - * Validates a message against its Spine validation constraints. - * - * This function applies all registered validation rules to the given message - * and returns an array of constraint violations. An empty array indicates - * the message is valid. - * - * Currently supported validation options: - * - `(required)` โ€” ensures field has a non-default value - * - `(pattern)` โ€” validates string fields against regular expressions - * - `(required_field)` โ€” requires specific combinations of fields at message level - * - `(min)` / `(max)` โ€” numeric range validation with inclusive/exclusive bounds - * - `(range)` โ€” bounded numeric ranges using bracket notation for inclusive/exclusive bounds - * - `(distinct)` โ€” ensures all elements in repeated fields are unique - * - `(validate)` โ€” enables recursive validation of nested message fields - * - `(goes)` โ€” enforces field dependency (field can only be set if another field is set) - * - `(choice)` โ€” requires that a `oneof` group has at least one field set - * - * @param schema The message schema containing validation metadata. - * @param message The message instance to validate. - * @returns Array of constraint violations (empty if valid). - * - * @example - * ```typescript - * import { validate } from '@spine-event-engine/validation-ts'; - * import { UserSchema } from './generated/user_pb'; - * import { create } from '@bufbuild/protobuf'; - * - * const user = create(UserSchema, { name: '', email: '' }); - * const violations = validate(UserSchema, user); - * - * if (violations.length > 0) { - * console.log('Validation failed:', formatViolations(violations)); - * } - * ``` - */ -export function validate<T extends Message>( - schema: GenMessage<T>, - message: any -): ConstraintViolation[] { - const violations: ConstraintViolation[] = []; - - validateRequiredFields(schema, message, violations); - validatePatternFields(schema, message, violations); - validateRequiredFieldOption(schema, message, violations); - validateMinMaxFields(schema, message, violations); - validateRangeFields(schema, message, violations); - validateDistinctFields(schema, message, violations); - validateNestedFields(schema, message, violations); - validateGoesFields(schema, message, violations); - validateChoiceFields(schema, message, violations); - - return violations; -} - -/** - * Formats a `TemplateString` by replacing all placeholders with their values. - * - * Placeholders in the format `${key}` are replaced with corresponding values - * from the `placeholderValue` map. - * - * @param template The template string with placeholders. - * @returns Formatted string with placeholders replaced. - * - * @example - * ```typescript - * const template = { - * withPlaceholders: 'Field ${field} has invalid value: ${value}', - * placeholderValue: { field: 'email', value: 'invalid@' } - * }; - * const result = formatTemplateString(template); - * // Result: "Field email has invalid value: invalid@" - * ``` - */ -export function formatTemplateString(template: TemplateString): string { - let result = template.withPlaceholders; - for (const [key, value] of Object.entries(template.placeholderValue)) { - result = result.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value); - } - return result; -} - -/** - * Formats an array of constraint violations into a human-readable string. - * - * Each violation is formatted as: `<index>. <typeName>.<fieldPath>: <message>` - * - * @param violations Array of constraint violations to format. - * @returns Formatted string describing all violations, or "No violations" if empty. - * - * @example - * ```typescript - * const user = create(UserSchema, { name: '', email: '' }); - * const violations = validate(UserSchema, user); - * console.log(formatViolations(violations)); - * // Output: - * // 1. example.User.name: A value must be set. - * // 2. example.User.email: A value must be set. - * ``` - */ -export function formatViolations(violations: ConstraintViolation[]): string { - if (violations.length === 0) { - return 'No violations'; - } - - return violations.map((v, index) => { - const fieldPath = v.fieldPath?.fieldName.join('.') || 'unknown'; - const message = v.message ? formatTemplateString(v.message) : 'Validation failed'; - return `${index + 1}. ${v.typeName}.${fieldPath}: ${message}`; - }).join('\n'); -} - -/** - * Utility object for working with constraint violations. - * - * Provides helper methods to extract formatted information from `ConstraintViolation` objects. - * - * @example - * ```typescript - * const violations = validate(UserSchema, user); - * violations.forEach(v => { - * const path = Violations.failurePath(v); - * const message = Violations.formatMessage(v); - * console.error(`${v.typeName}.${path}: ${message}`); - * }); - * ``` - */ -export const Violations = { - /** - * Returns the formatted error message from a violation with all placeholders replaced. - * - * Placeholders in the error message (e.g., `${field}`, `${value}`) are substituted - * with their corresponding values from the violation context. - * - * @param violation The constraint violation to format. - * @returns The formatted error message, or 'Validation failed' if no message is present. - * - * @example - * ```typescript - * const message = Violations.formatMessage(violation); - * // Returns: "Email must be valid. Provided: `invalid@`." - * ``` - */ - formatMessage(violation: ConstraintViolation): string { - return violation.message ? formatTemplateString(violation.message) : 'Validation failed'; - }, - - /** - * Returns the field path from a violation as a dot-separated string. - * - * Converts the field path array (e.g., `['user', 'email']`) into a single - * dot-separated string (e.g., `'user.email'`). - * - * @param violation The constraint violation. - * @returns The field path as a string, or 'unknown' if no field path is present. - * - * @example - * ```typescript - * const path = Violations.failurePath(violation); - * // Returns: "user.email" - * ``` - */ - failurePath(violation: ConstraintViolation): string { - return violation.fieldPath?.fieldName.join('.') || 'unknown'; - } -} as const; diff --git a/packages/spine-validation-ts/tests/buf.yaml b/packages/spine-validation-ts/tests/buf.yaml deleted file mode 100644 index c7e30e3..0000000 --- a/packages/spine-validation-ts/tests/buf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -version: v2 -modules: - - path: proto -lint: - use: - - STANDARD -breaking: - use: - - FILE diff --git a/packages/spine-validation-ts/tests/choice.test.ts b/packages/spine-validation-ts/tests/choice.test.ts deleted file mode 100644 index 890c0c1..0000000 --- a/packages/spine-validation-ts/tests/choice.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src/validation'; -import { - PaymentMethodSchema, - ContactMethodSchema, - ShippingOptionSchema -} from './generated/test-choice_pb'; - -describe('Choice Option Validation (oneof)', () => { - describe('Basic Choice Validation', () => { - it('should pass when one field in oneof is set', () => { - const payment = create(PaymentMethodSchema, { - method: { - case: 'creditCard', - value: '4111111111111111' - } - }); - - const violations = validate(PaymentMethodSchema, payment); - expect(violations).toHaveLength(0); - }); - - it('should fail when no field in required oneof is set', () => { - const payment = create(PaymentMethodSchema, { - // method `oneof` not set - }); - - const violations = validate(PaymentMethodSchema, payment); - expect(violations.length).toBeGreaterThan(0); - - const choiceViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'method' - ); - expect(choiceViolation).toBeDefined(); - expect(choiceViolation?.message?.withPlaceholders).toContain('oneof'); - }); - - it('should pass when different field in oneof is set', () => { - const payment = create(PaymentMethodSchema, { - method: { - case: 'bankAccount', - value: '123456789' - } - }); - - const violations = validate(PaymentMethodSchema, payment); - expect(violations).toHaveLength(0); - }); - }); - - describe('Custom Error Messages', () => { - it('should use custom error message when provided', () => { - const contact = create(ContactMethodSchema, { - // contact `oneof` not set, has custom error message - }); - - const violations = validate(ContactMethodSchema, contact); - expect(violations.length).toBeGreaterThan(0); - - const choiceViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'contact' - ); - expect(choiceViolation).toBeDefined(); - expect(choiceViolation?.message?.withPlaceholders).toContain( - 'must provide a contact method' - ); - }); - }); - - - describe('Optional Oneofs', () => { - it('should pass when optional oneof is not set', () => { - const shipping = create(ShippingOptionSchema, { - // delivery `oneof` is optional (choice.required = false) - }); - - const violations = validate(ShippingOptionSchema, shipping); - expect(violations).toHaveLength(0); - }); - - it('should pass when optional oneof has a field set', () => { - const shipping = create(ShippingOptionSchema, { - delivery: { - case: 'standard', - value: true - } - }); - - const violations = validate(ShippingOptionSchema, shipping); - expect(violations).toHaveLength(0); - }); - }); - - describe('Multiple Oneofs in Same Message', () => { - it('should validate all oneofs independently', () => { - // Test case would require a proto with multiple oneofs - // For now, we verify that each oneof is validated separately - const payment = create(PaymentMethodSchema, { - method: { - case: 'paypal', - value: 'user@example.com' - } - }); - - const violations = validate(PaymentMethodSchema, payment); - expect(violations).toHaveLength(0); - }); - }); - - describe('Edge Cases', () => { - it('should handle message with no oneofs', () => { - // Most messages don't have oneofs, should not cause errors - const payment = create(PaymentMethodSchema, { - method: { - case: 'creditCard', - value: '4111111111111111' - } - }); - - const violations = validate(PaymentMethodSchema, payment); - expect(violations).toHaveLength(0); - }); - - it('should provide clear field path in violation', () => { - const payment = create(PaymentMethodSchema, {}); - - const violations = validate(PaymentMethodSchema, payment); - const choiceViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'method' - ); - - expect(choiceViolation?.fieldPath?.fieldName).toEqual(['method']); - expect(choiceViolation?.typeName).toBe('test.PaymentMethod'); - }); - }); -}); diff --git a/packages/spine-validation-ts/tests/distinct.test.ts b/packages/spine-validation-ts/tests/distinct.test.ts deleted file mode 100644 index d83ae1d..0000000 --- a/packages/spine-validation-ts/tests/distinct.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Unit tests for `(distinct)` validation option. - * - * Tests uniqueness validation for repeated fields. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src'; - -import { - DistinctPrimitivesSchema, - DistinctEnumsSchema, - Status as DistinctStatus, - NonDistinctFieldsSchema, - CombinedConstraintsSchema as DistinctCombinedConstraintsSchema, - OptionalDistinctSchema, - UserProfileSchema, - ShoppingCartSchema, - DistinctNumericTypesSchema, - DistinctEdgeCasesSchema -} from './generated/test-distinct_pb'; - -describe('Distinct Validation', () => { - describe('Primitive Types with Distinct', () => { - it('should pass when all elements are unique', () => { - const valid = create(DistinctPrimitivesSchema, { - numbers: [1, 2, 3, 4, 5], - tags: ['alpha', 'beta', 'gamma'], - scores: [85.5, 92.3, 78.9], - flags: [true, false] - }); - - const violations = validate(DistinctPrimitivesSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when numbers have duplicates', () => { - const invalid = create(DistinctPrimitivesSchema, { - numbers: [1, 2, 3, 2, 4], // 2 is duplicated at indices 1 and 3. - tags: ['alpha', 'beta', 'gamma'], - scores: [85.5, 92.3, 78.9], - flags: [true, false] - }); - - const violations = validate(DistinctPrimitivesSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const numberViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'numbers' && v.fieldPath?.fieldName[1] === '3' - ); - expect(numberViolation).toBeDefined(); - expect(numberViolation?.message?.placeholderValue?.['value']).toBe('2'); - expect(numberViolation?.message?.placeholderValue?.['first_index']).toBe('1'); - expect(numberViolation?.message?.placeholderValue?.['duplicate_index']).toBe('3'); - }); - - it('should fail when strings have duplicates', () => { - const invalid = create(DistinctPrimitivesSchema, { - numbers: [1, 2, 3], - tags: ['alpha', 'beta', 'alpha', 'gamma'], // 'alpha' duplicated. - scores: [85.5, 92.3, 78.9], - flags: [true, false] - }); - - const violations = validate(DistinctPrimitivesSchema, invalid); - const tagViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'tags'); - expect(tagViolation).toBeDefined(); - expect(tagViolation?.message?.placeholderValue?.['value']).toBe('alpha'); - }); - - it('should fail when doubles have duplicates', () => { - const invalid = create(DistinctPrimitivesSchema, { - numbers: [1, 2, 3], - tags: ['alpha', 'beta', 'gamma'], - scores: [85.5, 92.3, 85.5, 78.9], // 85.5 duplicated. - flags: [true, false] - }); - - const violations = validate(DistinctPrimitivesSchema, invalid); - const scoreViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'scores'); - expect(scoreViolation).toBeDefined(); - }); - - it('should detect multiple duplicates in same field', () => { - const invalid = create(DistinctPrimitivesSchema, { - numbers: [1, 2, 1, 3, 2, 4], // Both 1 and 2 duplicated. - tags: ['alpha'], - scores: [85.5], - flags: [true] - }); - - const violations = validate(DistinctPrimitivesSchema, invalid); - const numberViolations = violations.filter(v => v.fieldPath?.fieldName[0] === 'numbers'); - expect(numberViolations.length).toBe(2); // Two violations for two duplicates. - }); - }); - - describe('Enum Fields with Distinct', () => { - it('should pass when all enum values are unique', () => { - const valid = create(DistinctEnumsSchema, { - statuses: [DistinctStatus.ACTIVE, DistinctStatus.INACTIVE, DistinctStatus.PENDING] - }); - - const violations = validate(DistinctEnumsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when enum values are duplicated', () => { - const invalid = create(DistinctEnumsSchema, { - statuses: [DistinctStatus.ACTIVE, DistinctStatus.INACTIVE, DistinctStatus.ACTIVE] - }); - - const violations = validate(DistinctEnumsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const statusViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'statuses'); - expect(statusViolation).toBeDefined(); - }); - }); - - describe('Non-Distinct Fields (Control Group)', () => { - it('should allow duplicates when `distinct` is not set', () => { - const withDuplicates = create(NonDistinctFieldsSchema, { - numbers: [1, 2, 2, 3, 3, 3], // Duplicates allowed. - tags: ['alpha', 'alpha', 'beta'] // Duplicates allowed. - }); - - const violations = validate(NonDistinctFieldsSchema, withDuplicates); - expect(violations).toHaveLength(0); // No violations - duplicates are OK. - }); - }); - - describe('Combined Constraints (Distinct + Other Options)', () => { - it('should pass when all constraints are satisfied', () => { - const valid = create(DistinctCombinedConstraintsSchema, { - productIds: [1, 100, 500, 999], // Distinct and within range. - emails: ['user1@example.com', 'user2@example.com'], // Distinct and match pattern. - scores: [75, 85, 92] // Distinct and within min/max. - }); - - const violations = validate(DistinctCombinedConstraintsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect `distinct` violation even when `range` is satisfied', () => { - const invalid = create(DistinctCombinedConstraintsSchema, { - productIds: [100, 200, 100], // Duplicate but within range. - emails: ['user1@example.com', 'user2@example.com'], - scores: [75, 85, 92] - }); - - const violations = validate(DistinctCombinedConstraintsSchema, invalid); - const distinctViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'product_ids' && - v.message?.withPlaceholders.includes('Duplicate') - ); - expect(distinctViolation).toBeDefined(); - }); - - it('should detect `distinct` violation in repeated emails', () => { - const invalid = create(DistinctCombinedConstraintsSchema, { - productIds: [100, 200, 300], - emails: ['user1@example.com', 'user2@example.com', 'user1@example.com'], // Duplicate. - scores: [75, 85, 92] - }); - - const violations = validate(DistinctCombinedConstraintsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Should have distinct violation for duplicate email. - const distinctViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'emails' && - v.message?.withPlaceholders.includes('Duplicate') - ); - expect(distinctViolation).toBeDefined(); - expect(distinctViolation?.message?.placeholderValue?.['value']).toBe('user1@example.com'); - }); - - it('should detect both `distinct` and `range` violations', () => { - const invalid = create(DistinctCombinedConstraintsSchema, { - productIds: [100, 200, 300], - emails: ['user1@example.com', 'user2@example.com'], - scores: [75, 101, 75] // 101 violates max, 75 is duplicate. - }); - - const violations = validate(DistinctCombinedConstraintsSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(2); - - const rangeViolation = violations.find(v => - v.fieldPath?.fieldName[1] === '1' && - v.message?.withPlaceholders.includes('at most') - ); - expect(rangeViolation).toBeDefined(); - - const distinctViolation = violations.find(v => - v.message?.withPlaceholders.includes('Duplicate') - ); - expect(distinctViolation).toBeDefined(); - }); - }); - - describe('Optional/Empty Repeated Fields', () => { - it('should pass when repeated fields are empty', () => { - const empty = create(OptionalDistinctSchema, { - optionalNumbers: [], - optionalTags: [] - }); - - const violations = validate(OptionalDistinctSchema, empty); - expect(violations).toHaveLength(0); - }); - - it('should pass when repeated field has single element', () => { - const singleElement = create(OptionalDistinctSchema, { - optionalNumbers: [42], - optionalTags: ['solo'] - }); - - const violations = validate(OptionalDistinctSchema, singleElement); - expect(violations).toHaveLength(0); - }); - - it('should `validate` when optional fields have multiple elements', () => { - const invalid = create(OptionalDistinctSchema, { - optionalNumbers: [1, 2, 1], // Duplicate. - optionalTags: ['tag1', 'tag2'] - }); - - const violations = validate(OptionalDistinctSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - }); - - describe('Real-World Scenarios', () => { - it('should `validate` user profile with `distinct` tags', () => { - const valid = create(UserProfileSchema, { - username: 'johndoe', - tags: ['developer', 'typescript', 'nodejs'], - skills: ['javascript', 'react', 'python'] - }); - - const violations = validate(UserProfileSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should reject user profile with duplicate tags', () => { - const invalid = create(UserProfileSchema, { - username: 'johndoe', - tags: ['developer', 'typescript', 'developer'], // Duplicate. - skills: ['javascript', 'react', 'python'] - }); - - const violations = validate(UserProfileSchema, invalid); - const tagViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'tags'); - expect(tagViolation).toBeDefined(); - }); - - it('should `validate` shopping cart with unique product IDs', () => { - const valid = create(ShoppingCartSchema, { - productIds: [101, 202, 303], - couponCodes: ['SUMMER2024', 'FREESHIP'] - }); - - const violations = validate(ShoppingCartSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should reject shopping cart with duplicate product IDs', () => { - const invalid = create(ShoppingCartSchema, { - productIds: [101, 202, 101], // Duplicate product. - couponCodes: ['SUMMER2024', 'FREESHIP'] - }); - - const violations = validate(ShoppingCartSchema, invalid); - const productViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'product_ids'); - expect(productViolation).toBeDefined(); - }); - - it('should reject duplicate coupon codes', () => { - const invalid = create(ShoppingCartSchema, { - productIds: [101, 202, 303], - couponCodes: ['SUMMER2024', 'FREESHIP', 'SUMMER2024'] // Duplicate. - }); - - const violations = validate(ShoppingCartSchema, invalid); - const couponViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'coupon_codes'); - expect(couponViolation).toBeDefined(); - }); - }); - - describe('Different Numeric Types with Distinct', () => { - it('should `validate` `distinct` for all numeric types', () => { - const valid = create(DistinctNumericTypesSchema, { - int32Values: [1, 2, 3], - int64Values: [100n, 200n, 300n], - uint32Values: [10, 20, 30], - uint64Values: [1000n, 2000n, 3000n], - floatValues: [1.1, 2.2, 3.3], - doubleValues: [10.1, 20.2, 30.3] - }); - - const violations = validate(DistinctNumericTypesSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect duplicates in int64 fields', () => { - const invalid = create(DistinctNumericTypesSchema, { - int32Values: [1, 2, 3], - int64Values: [100n, 200n, 100n], // Duplicate. - uint32Values: [10, 20, 30], - uint64Values: [1000n, 2000n, 3000n], - floatValues: [1.1, 2.2, 3.3], - doubleValues: [10.1, 20.2, 30.3] - }); - - const violations = validate(DistinctNumericTypesSchema, invalid); - const int64Violation = violations.find(v => v.fieldPath?.fieldName[0] === 'int64_values'); - expect(int64Violation).toBeDefined(); - }); - }); - - describe('Edge Cases', () => { - it('should treat empty strings as duplicates', () => { - const invalid = create(DistinctEdgeCasesSchema, { - emptyStrings: ['', 'value', ''], // Two empty strings. - zeros: [0, 1, 2], - caseSensitive: ['Tag', 'tag'] - }); - - const violations = validate(DistinctEdgeCasesSchema, invalid); - const emptyViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'empty_strings'); - expect(emptyViolation).toBeDefined(); - }); - - it('should treat zeros as duplicates', () => { - const invalid = create(DistinctEdgeCasesSchema, { - emptyStrings: ['value1', 'value2'], - zeros: [0, 1, 0], // Two zeros. - caseSensitive: ['Tag', 'tag'] - }); - - const violations = validate(DistinctEdgeCasesSchema, invalid); - const zeroViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'zeros'); - expect(zeroViolation).toBeDefined(); - }); - - it('should be case-sensitive for strings', () => { - const valid = create(DistinctEdgeCasesSchema, { - emptyStrings: ['value1', 'value2'], - zeros: [0, 1, 2], - caseSensitive: ['Tag', 'tag', 'TAG'] // All different due to case. - }); - - const violations = validate(DistinctEdgeCasesSchema, valid); - expect(violations).toHaveLength(0); // No violations - case matters. - }); - - it('should detect case-insensitive duplicates correctly', () => { - const invalid = create(DistinctEdgeCasesSchema, { - emptyStrings: ['value1', 'value2'], - zeros: [0, 1, 2], - caseSensitive: ['Tag', 'tag', 'Tag'] // 'Tag' duplicated (exact match). - }); - - const violations = validate(DistinctEdgeCasesSchema, invalid); - const caseViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'case_sensitive'); - expect(caseViolation).toBeDefined(); - }); - }); -}); - diff --git a/packages/spine-validation-ts/tests/goes.test.ts b/packages/spine-validation-ts/tests/goes.test.ts deleted file mode 100644 index db2f4d8..0000000 --- a/packages/spine-validation-ts/tests/goes.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Unit tests for `(goes)` validation option. - * - * Tests field dependency validation (field can only be set if another field is set). - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src'; - -import { - ScheduledEventSchema, - ShippingDetailsSchema, - ColorSettingsSchema, - PaymentInfoSchema, - ProfileSettingsSchema, - DocumentMetadataSchema, - TimestampSchema, - SecureAccountSchema, - SimpleConfigSchema, - FeatureFlagsSchema, - FeatureLevel, - ReportGenerationSchema, - OptionalSettingsSchema, - AdvancedConfigSchema -} from './generated/test-goes_pb'; - -describe('Field Dependency Validation (goes)', () => { - describe('Basic Goes Constraint', () => { - it('should pass when dependent field is not set', () => { - const valid = create(ScheduledEventSchema, { - eventName: 'Team Meeting', - date: '' - // time not set - valid because time is only required when date is set. - }); - - const violations = validate(ScheduledEventSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when both fields are set', () => { - const valid = create(ScheduledEventSchema, { - eventName: 'Team Meeting', - date: '2024-12-25', - time: '14:30' - }); - - const violations = validate(ScheduledEventSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when dependent field is set but `required` field is not', () => { - const invalid = create(ScheduledEventSchema, { - eventName: 'Team Meeting', - date: '', // Not set. - time: '14:30' // Set - violates (goes).with = "date". - }); - - const violations = validate(ScheduledEventSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const goesViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'time' - ); - expect(goesViolation).toBeDefined(); - expect(goesViolation?.message?.withPlaceholders).toContain('date'); - }); - - it('should pass when both fields are unset', () => { - const valid = create(ScheduledEventSchema, { - eventName: 'Team Meeting' - // Both date and time are unset - valid. - }); - - const violations = validate(ScheduledEventSchema, valid); - expect(violations).toHaveLength(0); - }); - }); - - describe('Custom Error Messages', () => { - it('should use custom error message from (`goes`).error_msg', () => { - const invalid = create(ShippingDetailsSchema, { - address: '', // Not set. - trackingNumber: 'TRACK123' // Set - violates goes constraint. - }); - - const violations = validate(ShippingDetailsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const goesViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'tracking_number' - ); - expect(goesViolation).toBeDefined(); - expect(goesViolation?.message?.withPlaceholders).toBe( - 'Tracking number requires a shipping address: {value}.' - ); - }); - - it('should pass when both fields are set', () => { - const valid = create(ShippingDetailsSchema, { - address: '123 Main St', - trackingNumber: 'TRACK123' - }); - - const violations = validate(ShippingDetailsSchema, valid); - expect(violations).toHaveLength(0); - }); - }); - - describe('Mutual Dependencies (Bidirectional)', () => { - it('should pass when both fields are set', () => { - const valid = create(ColorSettingsSchema, { - textColor: '#000000', - highlightColor: '#FFFF00' - }); - - const violations = validate(ColorSettingsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when both fields are unset', () => { - const valid = create(ColorSettingsSchema, { - // Both unset. - }); - - const violations = validate(ColorSettingsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when only text_color is set', () => { - const invalid = create(ColorSettingsSchema, { - textColor: '#000000', - highlightColor: '' // Not set. - }); - - const violations = validate(ColorSettingsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const textColorViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'text_color' - ); - expect(textColorViolation).toBeDefined(); - }); - - it('should fail when only highlight_color is set', () => { - const invalid = create(ColorSettingsSchema, { - textColor: '', // Not set. - highlightColor: '#FFFF00' - }); - - const violations = validate(ColorSettingsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const highlightViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'highlight_color' - ); - expect(highlightViolation).toBeDefined(); - }); - }); - - describe('Multiple Independent Goes Constraints', () => { - it('should pass when all fields are set', () => { - const valid = create(PaymentInfoSchema, { - cardholderName: 'John Doe', - cardNumber: '4111111111111111', - cvv: '123', - expiryMonth: 12 - }); - - const violations = validate(PaymentInfoSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when card_number is set but cardholder_name is not', () => { - const invalid = create(PaymentInfoSchema, { - cardholderName: '', // Not set. - cardNumber: '4111111111111111', // Violates goes constraint. - cvv: '', - expiryMonth: 0 - }); - - const violations = validate(PaymentInfoSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const cardNumberViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'card_number' - ); - expect(cardNumberViolation).toBeDefined(); - }); - - it('should fail when cvv is set but card_number is not', () => { - const invalid = create(PaymentInfoSchema, { - cardholderName: 'John Doe', - cardNumber: '', // Not set. - cvv: '123', // Violates goes constraint. - expiryMonth: 0 - }); - - const violations = validate(PaymentInfoSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const cvvViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'cvv' - ); - expect(cvvViolation).toBeDefined(); - }); - - it('should detect multiple `goes` violations', () => { - const invalid = create(PaymentInfoSchema, { - cardholderName: '', // Not set. - cardNumber: '4111111111111111', // Violates (cardholder_name missing). - cvv: '123', // Violates (card_number dependency). - expiryMonth: 12 // Violates (card_number dependency). - }); - - const violations = validate(PaymentInfoSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const cardNumberViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'card_number' - ); - expect(cardNumberViolation).toBeDefined(); - - // Note: cvv and `expiry_month` don't violate because `card_number` IS set. - // Only `card_number` violates because `cardholder_name` is NOT set. - }); - }); - - describe('Different Field Types', () => { - it('should `validate` `goes` constraint on int32 field', () => { - const invalid = create(ProfileSettingsSchema, { - username: '', // Not set. - displayId: 12345 // Set - violates goes constraint. - }); - - const violations = validate(ProfileSettingsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const displayIdViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'display_id' - ); - expect(displayIdViolation).toBeDefined(); - }); - - it('should `validate` `goes` constraint on bool field', () => { - const invalid = create(ProfileSettingsSchema, { - username: '', // Not set. - isVerified: true // Set - violates goes constraint. - }); - - const violations = validate(ProfileSettingsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const verifiedViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'is_verified' - ); - expect(verifiedViolation).toBeDefined(); - }); - - it('should `validate` `goes` constraint on double field', () => { - const invalid = create(ProfileSettingsSchema, { - username: '', // Not set. - rating: 4.5 // Set - violates goes constraint. - }); - - const violations = validate(ProfileSettingsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const ratingViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'rating' - ); - expect(ratingViolation).toBeDefined(); - }); - - it('should `validate` `goes` constraint on message field', () => { - const invalid = create(DocumentMetadataSchema, { - title: '', // Not set. - createdAt: create(TimestampSchema, { - seconds: BigInt(1234567890), - nanos: 0 - }) // Set - violates goes constraint. - }); - - const violations = validate(DocumentMetadataSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const createdAtViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'created_at' - ); - expect(createdAtViolation).toBeDefined(); - }); - }); - - describe('Without Goes Constraint (Control Group)', () => { - it('should allow independent fields without `goes` constraint', () => { - const valid = create(SimpleConfigSchema, { - primaryOption: '', - secondaryOption: 'some value' // Can be set independently. - }); - - const violations = validate(SimpleConfigSchema, valid); - expect(violations).toHaveLength(0); - }); - }); - - describe('Goes with Enum Fields', () => { - it('should pass when both enum and dependent field are set', () => { - const valid = create(FeatureFlagsSchema, { - level: FeatureLevel.PREMIUM, - customConfig: 'advanced-settings' - }); - - const violations = validate(FeatureFlagsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when dependent field is set but enum is unspecified', () => { - const invalid = create(FeatureFlagsSchema, { - level: FeatureLevel.UNSPECIFIED, // Default/unset. - customConfig: 'advanced-settings' // Violates goes constraint. - }); - - const violations = validate(FeatureFlagsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const configViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'custom_config' - ); - expect(configViolation).toBeDefined(); - }); - }); - - describe('Chain Dependencies', () => { - it('should `validate` independent chain dependencies', () => { - const valid = create(ReportGenerationSchema, { - reportType: 'monthly', - outputFormat: 'pdf', - emailRecipient: 'admin@example.com', - schedule: 'daily' - }); - - const violations = validate(ReportGenerationSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when output_format is set but report_type is not', () => { - const invalid = create(ReportGenerationSchema, { - reportType: '', // Not set. - outputFormat: 'pdf', // Violates goes constraint. - emailRecipient: '', - schedule: '' - }); - - const violations = validate(ReportGenerationSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const formatViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'output_format' - ); - expect(formatViolation).toBeDefined(); - }); - - it('should fail when schedule is set but output_format is not', () => { - const invalid = create(ReportGenerationSchema, { - reportType: 'monthly', - outputFormat: '', // Not set. - emailRecipient: '', - schedule: 'daily' // Violates goes constraint (depends on output_format). - }); - - const violations = validate(ReportGenerationSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const scheduleViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'schedule' - ); - expect(scheduleViolation).toBeDefined(); - }); - }); - - describe('Optional Fields with Goes', () => { - it('should pass when base field and dependent fields are all set', () => { - const valid = create(OptionalSettingsSchema, { - baseUrl: 'https://api.example.com', - port: 8080, - path: '/v1/api' - }); - - const violations = validate(OptionalSettingsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when all fields are unset', () => { - const valid = create(OptionalSettingsSchema, { - // All unset. - }); - - const violations = validate(OptionalSettingsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when port is set without base_url', () => { - const invalid = create(OptionalSettingsSchema, { - baseUrl: '', // Not set. - port: 8080, // Violates goes constraint. - path: '' - }); - - const violations = validate(OptionalSettingsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const portViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'port' - ); - expect(portViolation).toBeDefined(); - }); - }); - - describe('Combined Constraints (Goes + Other Options)', () => { - it('should pass when all constraints are satisfied', () => { - const valid = create(SecureAccountSchema, { - username: 'john_doe', - password: 'securepass123', - recoveryEmail: 'john@example.com', - recoveryPhone: '+1234567890' - }); - - const violations = validate(SecureAccountSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect `goes` violation when recovery_phone is set without recovery_email', () => { - const invalid = create(SecureAccountSchema, { - username: 'john_doe', - password: 'securepass123', - recoveryEmail: '', // Not set. - recoveryPhone: '+1234567890' // Violates goes constraint. - }); - - const violations = validate(SecureAccountSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const phoneViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'recovery_phone' - ); - expect(phoneViolation).toBeDefined(); - }); - - it('should detect both `pattern` and `goes` violations', () => { - const invalid = create(SecureAccountSchema, { - username: 'ab', // Too short - violates pattern. - password: 'short', // Too short - violates pattern. - recoveryEmail: 'invalid', // Invalid format - violates pattern (but is "set" for goes purposes). - recoveryPhone: '+1234567890' // Does NOT violate goes because recovery_email IS set (even though invalid). - }); - - const violations = validate(SecureAccountSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Should have pattern violations for username, password, and `recovery_email`. - const usernameViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'username' - ); - expect(usernameViolation).toBeDefined(); - - const passwordViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'password' - ); - expect(passwordViolation).toBeDefined(); - - const emailViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'recovery_email' - ); - expect(emailViolation).toBeDefined(); - - // Note: `recovery_phone` does NOT violate goes constraint because `recovery_email` IS set. - // (goes checks if field is set, not if it's valid). - }); - - it('should `validate` `goes` combined with `range` constraint', () => { - const valid = create(AdvancedConfigSchema, { - configName: 'production', - maxConnections: 500, // Within range [1..1000]. - timeoutSeconds: 30.0 // Above min 0.1. - }); - - const violations = validate(AdvancedConfigSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect `range` violation even when `goes` constraint is satisfied', () => { - const invalid = create(AdvancedConfigSchema, { - configName: 'production', - maxConnections: 2000, // Exceeds range [1..1000]. - timeoutSeconds: 30.0 - }); - - const violations = validate(AdvancedConfigSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const rangeViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'max_connections' - ); - expect(rangeViolation).toBeDefined(); - expect(rangeViolation?.message?.withPlaceholders).toContain('[1..1000]'); - }); - - it('should detect `goes` violation when max_connections is set without config_name', () => { - const invalid = create(AdvancedConfigSchema, { - configName: '', // Not set. - maxConnections: 500, // Violates goes constraint. - timeoutSeconds: 0 - }); - - const violations = validate(AdvancedConfigSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const goesViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'max_connections' - ); - expect(goesViolation).toBeDefined(); - }); - }); -}); - diff --git a/packages/spine-validation-ts/tests/integration.test.ts b/packages/spine-validation-ts/tests/integration.test.ts deleted file mode 100644 index 6eeb272..0000000 --- a/packages/spine-validation-ts/tests/integration.test.ts +++ /dev/null @@ -1,668 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Integration tests combining multiple validation options. - * - * Tests real-world scenarios with complex validation constraints. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate, formatViolations } from '../src'; - -import { UserSchema, Role, GetUserResponseSchema } from './generated/integration-user_pb'; -import { AccountSchema, AccountType } from './generated/integration-account_pb'; -import { SecureAccountSchema, AdvancedConfigSchema, FeatureLevel, ColorSettingsSchema, ScheduledEventSchema } from './generated/test-goes_pb'; - -describe('Integration Tests', () => { - it('should `validate` User message with multiple constraint types', () => { - const validUser = create(UserSchema, { - id: 1, - name: 'John Doe', - email: 'john.doe@example.com', - role: Role.ADMIN, - tags: ['developer', 'typescript'] - }); - - const violations = validate(UserSchema, validUser); - expect(violations).toHaveLength(0); - }); - - it('should detect both `required` and `pattern` violations', () => { - const invalidUser = create(UserSchema, { - id: 1, - name: '', // Required violation. - email: 'bad@', // Pattern violation. - role: Role.USER, - tags: [] - }); - - const violations = validate(UserSchema, invalidUser); - expect(violations.length).toBeGreaterThanOrEqual(2); - - const fieldNames = violations.map(v => v.fieldPath?.fieldName[0]); - expect(fieldNames).toContain('name'); - expect(fieldNames).toContain('email'); - }); - - it('should format violations correctly', () => { - const invalidUser = create(UserSchema, { - id: 6, - name: '', - email: '', - role: Role.USER, - tags: [] - }); - - const violations = validate(UserSchema, invalidUser); - const formatted = formatViolations(violations); - - expect(formatted).toContain('spine.validation.testing.integration.User.name'); - expect(formatted).toContain('spine.validation.testing.integration.User.email'); - expect(formatted).toContain('A value must be set'); - }); - - it('should `validate` User with `distinct` tags', () => { - const validUser = create(UserSchema, { - id: 1, - name: 'John Doe', - email: 'john.doe@example.com', - role: Role.ADMIN, - tags: ['developer', 'typescript', 'nodejs', 'react'] // All distinct. - }); - - const violations = validate(UserSchema, validUser); - expect(violations).toHaveLength(0); - }); - - it('should detect duplicate tags in User', () => { - const invalidUser = create(UserSchema, { - id: 1, - name: 'John Doe', - email: 'john.doe@example.com', - role: Role.ADMIN, - tags: ['developer', 'typescript', 'developer', 'nodejs'] // 'developer' duplicated. - }); - - const violations = validate(UserSchema, invalidUser); - expect(violations.length).toBeGreaterThan(0); - - const tagViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'tags' && - v.message?.withPlaceholders.includes('Duplicate') - ); - expect(tagViolation).toBeDefined(); - expect(tagViolation?.message?.placeholderValue?.['value']).toBe('developer'); - }); - - it('should detect multiple constraint violations including `distinct`', () => { - const invalidUser = create(UserSchema, { - id: 1, - name: '1', // Too short (pattern violation). - email: 'invalid', // Pattern violation. - role: Role.USER, - tags: ['tag1', 'tag2', 'tag1'] // Distinct violation. - }); - - const violations = validate(UserSchema, invalidUser); - expect(violations.length).toBeGreaterThanOrEqual(3); - - const fieldNames = violations.map(v => v.fieldPath?.fieldName[0]); - expect(fieldNames).toContain('name'); - expect(fieldNames).toContain('email'); - expect(fieldNames).toContain('tags'); - }); - - it('should `validate` Account with combined `required_field`, `required`, `pattern`, `min`/`max`, and `range` constraints', () => { - // Valid account with `id` provided (satisfies `required_field`). - const validAccount = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.PREMIUM, - age: 25, // Within range [13..120]. - balance: 5000.0, // Within min/max [0.0..1000000.0]. - failedLoginAttempts: 0, // Within range [0..5]. - rating: 4.5 // Within range [1.0..5.0]. - }); - - const violations = validate(AccountSchema, validAccount); - expect(violations).toHaveLength(0); - }); - - it('should `validate` Account with second field provided instead of first', () => { - // Note: `id` has `(min).value="1"`, so we provide a valid ID even though. - // the `required_field` "id | email" would be satisfied by email alone. - // Proto3 doesn't allow truly "unset" numeric fields (they default to 0). - const validAccount = create(AccountSchema, { - id: 1, // Provide valid ID (>= 1) to avoid min violation. - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.FREE, - age: 18, // Within range. - balance: 100.0, - failedLoginAttempts: 2, - rating: 3.0 - }); - - const violations = validate(AccountSchema, validAccount); - expect(violations).toHaveLength(0); - }); - - it('should detect `required_field` violation when neither `required` field is provided', () => { - const invalid = create(AccountSchema, { - id: 0, - email: '', // Violates both (required_field) and (required). - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.FREE - }); - - const violations = validate(AccountSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Should have violations for `required_field`, required email, or both. - const hasRequiredFieldViolation = violations.some(v => - v.message?.withPlaceholders.includes('id | email') - ); - const hasRequiredEmailViolation = violations.some(v => - v.fieldPath?.fieldName[0] === 'email' - ); - - expect(hasRequiredFieldViolation || hasRequiredEmailViolation).toBe(true); - }); - - it('should detect `pattern` violation in username field', () => { - const invalid = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'ab', // Too short, violates pattern. - password: 'secure_password_123', - accountType: AccountType.FREE - }); - - const violations = validate(AccountSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const usernameViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'username'); - expect(usernameViolation).toBeDefined(); - expect(usernameViolation?.message?.withPlaceholders).toContain('3-20 characters'); - }); - - it('should detect `pattern` violation in email field', () => { - const invalid = create(AccountSchema, { - id: 123, - email: 'invalid-email', // Invalid email format. - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.FREE - }); - - const violations = validate(AccountSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const emailViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'email'); - expect(emailViolation).toBeDefined(); - expect(emailViolation?.message?.withPlaceholders).toContain('Invalid email format'); - }); - - it('should detect multiple violations across different constraint types', () => { - const invalid = create(AccountSchema, { - id: 0, // Doesn't satisfy required_field. - email: '', // Empty (violates required) and doesn't satisfy required_field. - username: 'a', // Too short (violates pattern). - password: 'short', // Too short (violates pattern). - accountType: 0, // UNSPECIFIED (violates required). - age: 10, // Violates range [13..120]. - balance: -100.0, // Violates min 0.0. - failedLoginAttempts: 10, // Violates range [0..5]. - rating: 0.5 // Violates range [1.0..5.0]. - }); - - const violations = validate(AccountSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(7); - - // Check for various types of violations. - const fieldPaths = violations.map(v => v.fieldPath?.fieldName[0] || ''); - const hasMessageLevelViolation = violations.some(v => - v.fieldPath?.fieldName.length === 0 - ); - - // Should have violations for username, password, `account_type`, age, balance, `failed_login_attempts`, rating. - expect(fieldPaths.includes('username') || fieldPaths.includes('password')).toBe(true); - expect(fieldPaths.includes('age')).toBe(true); - expect(fieldPaths.includes('failed_login_attempts')).toBe(true); - }); - - it('should detect `range` violations while other fields are valid', () => { - const invalid = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.FREE, - age: 150, // Violates range [13..120]. - balance: 50000.0, - failedLoginAttempts: 6, // Violates range [0..5]. - rating: 3.5 - }); - - const violations = validate(AccountSchema, invalid); - expect(violations.length).toBe(2); - - const ageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'age'); - expect(ageViolation).toBeDefined(); - expect(ageViolation?.message?.withPlaceholders).toContain('[13..120]'); - - const attemptsViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'failed_login_attempts'); - expect(attemptsViolation).toBeDefined(); - expect(attemptsViolation?.message?.withPlaceholders).toContain('[0..5]'); - }); - - it('should detect both `required` and `range` violations on age field', () => { - const invalid = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.FREE, - age: 0, // Violates both (required) and range [13..120]. - balance: 1000.0, - failedLoginAttempts: 0, - rating: 4.0 - }); - - const violations = validate(AccountSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(1); - - // Age 0 should violate range constraint (and possibly required). - const ageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'age'); - expect(ageViolation).toBeDefined(); - }); - - it('should `validate` balance with `min`/`max` constraints', () => { - const validBalance = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.PREMIUM, - age: 30, - balance: 999999.99, // Just under max. - failedLoginAttempts: 0, - rating: 5.0 - }); - - const violations1 = validate(AccountSchema, validBalance); - expect(violations1).toHaveLength(0); - - const invalidBalance = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.PREMIUM, - age: 30, - balance: 1000001.0, // Violates max 1000000.0. - failedLoginAttempts: 0, - rating: 5.0 - }); - - const violations2 = validate(AccountSchema, invalidBalance); - const balanceViolation = violations2.find(v => v.fieldPath?.fieldName[0] === 'balance'); - expect(balanceViolation).toBeDefined(); - }); - - it('should `validate` rating `range` boundaries', () => { - const validMin = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.FREE, - age: 25, - balance: 1000.0, - failedLoginAttempts: 0, - rating: 1.0 // Min boundary. - }); - - const violations1 = validate(AccountSchema, validMin); - expect(violations1).toHaveLength(0); - - const validMax = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.FREE, - age: 25, - balance: 1000.0, - failedLoginAttempts: 0, - rating: 5.0 // Max boundary. - }); - - const violations2 = validate(AccountSchema, validMax); - expect(violations2).toHaveLength(0); - - const invalidRating = create(AccountSchema, { - id: 123, - email: 'user@example.com', - username: 'johndoe', - password: 'secure_password_123', - accountType: AccountType.FREE, - age: 25, - balance: 1000.0, - failedLoginAttempts: 0, - rating: 5.5 // Violates range [1.0..5.0]. - }); - - const violations3 = validate(AccountSchema, invalidRating); - const ratingViolation = violations3.find(v => v.fieldPath?.fieldName[0] === 'rating'); - expect(ratingViolation).toBeDefined(); - expect(ratingViolation?.message?.withPlaceholders).toContain('[1.0..5.0]'); - }); - - describe('Nested Validation (validate) Integration', () => { - it('should `validate` GetUserResponse with valid nested User', () => { - const validResponse = create(GetUserResponseSchema, { - user: create(UserSchema, { - id: 1, - name: 'Alice Smith', - email: 'alice@example.com', - role: Role.ADMIN, - tags: ['developer', 'typescript'] - }), - found: true - }); - - const violations = validate(GetUserResponseSchema, validResponse); - expect(violations).toHaveLength(0); - }); - - it('should detect nested User violations with default error message', () => { - const invalidResponse = create(GetUserResponseSchema, { - user: create(UserSchema, { - id: 1, - name: '', // Required violation. - email: 'alice@example.com', - role: Role.USER, - tags: [] - }), - found: true - }); - - const violations = validate(GetUserResponseSchema, invalidResponse); - expect(violations.length).toBeGreaterThan(0); - - // Should have parent-level violation with default message. - const parentViolation = violations.find(v => - v.fieldPath?.fieldName.length === 1 && - v.fieldPath?.fieldName[0] === 'user' - ); - expect(parentViolation).toBeDefined(); - expect(parentViolation?.message?.withPlaceholders).toBe('Nested message validation failed.'); - - // Should also have nested violation for name field. - const nameViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'user' && - v.fieldPath?.fieldName[1] === 'name' - ); - expect(nameViolation).toBeDefined(); - }); - - it('should detect multiple nested constraint violations (`required` + `pattern` + `distinct`)', () => { - const invalidResponse = create(GetUserResponseSchema, { - user: create(UserSchema, { - id: 0, // Violates min constraint. - name: '123', // Violates pattern (must start with letter). - email: 'not-an-email', // Violates pattern. - role: Role.USER, - tags: ['dev', 'dev', 'ops'] // Violates distinct. - }), - found: true - }); - - const violations = validate(GetUserResponseSchema, invalidResponse); - expect(violations.length).toBeGreaterThan(0); - - // Check for various nested violations. - const idViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'user' && - v.fieldPath?.fieldName[1] === 'id' - ); - expect(idViolation).toBeDefined(); - - const nameViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'user' && - v.fieldPath?.fieldName[1] === 'name' - ); - expect(nameViolation).toBeDefined(); - - const emailViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'user' && - v.fieldPath?.fieldName[1] === 'email' - ); - expect(emailViolation).toBeDefined(); - - const tagsViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'user' && - v.fieldPath?.fieldName[1] === 'tags' - ); - expect(tagsViolation).toBeDefined(); - }); - - it('should detect `required_field` violation in nested User', () => { - const invalidResponse = create(GetUserResponseSchema, { - user: create(UserSchema, { - // Neither `id` nor `email` provided - violates `required_field` option. - name: 'Bob Jones', - role: Role.USER, - tags: [] - }), - found: true - }); - - const violations = validate(GetUserResponseSchema, invalidResponse); - expect(violations.length).toBeGreaterThan(0); - - // Should have `required_field` violation. - const requiredFieldViolation = violations.find(v => - v.message?.withPlaceholders.includes('id | email') - ); - expect(requiredFieldViolation).toBeDefined(); - }); - - it('should format nested violations correctly', () => { - const invalidResponse = create(GetUserResponseSchema, { - user: create(UserSchema, { - id: 1, - name: '', // Required. - email: '', // Required. - role: Role.USER, - tags: [] - }), - found: true - }); - - const violations = validate(GetUserResponseSchema, invalidResponse); - const formatted = formatViolations(violations); - - // Should contain nested field paths. - expect(formatted).toContain('user'); - expect(formatted).toContain('name'); - expect(formatted).toContain('email'); - }); - - it('should pass when nested User is not set `(optional)`', () => { - const responseWithoutUser = create(GetUserResponseSchema, { - found: false - // user field not set. - }); - - const violations = validate(GetUserResponseSchema, responseWithoutUser); - expect(violations).toHaveLength(0); - }); - }); - - describe('Field Dependency (goes) Integration', () => { - it('should `validate` `goes` with `required` and `pattern` constraints', () => { - const valid = create(SecureAccountSchema, { - username: 'alice_secure', - password: 'strongpass123', - recoveryEmail: 'alice@example.com', - recoveryPhone: '+1234567890' - }); - - const violations = validate(SecureAccountSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect `goes` violation independently from `pattern` violations', () => { - const invalid = create(SecureAccountSchema, { - username: 'alice_secure', - password: 'strongpass123', - recoveryEmail: '', // Not set. - recoveryPhone: '+1234567890' // Violates goes constraint. - }); - - const violations = validate(SecureAccountSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const goesViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'recovery_phone' && - v.message?.withPlaceholders.includes('recovery_email') - ); - expect(goesViolation).toBeDefined(); - }); - - it('should detect both `required` and `goes` violations together', () => { - const invalid = create(SecureAccountSchema, { - username: '', // Required violation. - password: '', // Required violation. - recoveryEmail: '', - recoveryPhone: '+1234567890' // Goes violation. - }); - - const violations = validate(SecureAccountSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(3); - - const usernameViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'username' - ); - expect(usernameViolation).toBeDefined(); - - const passwordViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'password' - ); - expect(passwordViolation).toBeDefined(); - - const goesViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'recovery_phone' - ); - expect(goesViolation).toBeDefined(); - }); - - it('should `validate` `goes` with `range` and `min` constraints', () => { - const valid = create(AdvancedConfigSchema, { - configName: 'staging', - maxConnections: 100, - timeoutSeconds: 15.5 - }); - - const violations = validate(AdvancedConfigSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect `goes` and `range` violations independently', () => { - const invalid1 = create(AdvancedConfigSchema, { - configName: 'production', - maxConnections: 5000, // Violates range [1..1000]. - timeoutSeconds: 10.0 - }); - - const violations1 = validate(AdvancedConfigSchema, invalid1); - const rangeViolation = violations1.find(v => - v.fieldPath?.fieldName[0] === 'max_connections' - ); - expect(rangeViolation).toBeDefined(); - expect(rangeViolation?.message?.withPlaceholders).toContain('[1..1000]'); - - const invalid2 = create(AdvancedConfigSchema, { - configName: '', // Not set. - maxConnections: 500, // Violates goes constraint. - timeoutSeconds: 10.0 - }); - - const violations2 = validate(AdvancedConfigSchema, invalid2); - const goesViolation = violations2.find(v => - v.fieldPath?.fieldName[0] === 'max_connections' - ); - expect(goesViolation).toBeDefined(); - }); - - it('should handle mutual dependencies with multiple constraint types', () => { - const valid = create(ColorSettingsSchema, { - textColor: '#FF0000', - highlightColor: '#00FF00' - }); - - const violations = validate(ColorSettingsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect violations in mutual dependencies', () => { - const invalid = create(ColorSettingsSchema, { - textColor: '#FF0000', - highlightColor: '' // Not set - violates mutual dependency. - }); - - const violations = validate(ColorSettingsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const textColorViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'text_color' - ); - expect(textColorViolation).toBeDefined(); - expect(textColorViolation?.message?.withPlaceholders).toContain('highlight_color'); - }); - - it('should format `goes` violations correctly', () => { - const invalid = create(ScheduledEventSchema, { - eventName: 'Conference', - date: '', - time: '10:00 AM' - }); - - const violations = validate(ScheduledEventSchema, invalid); - const formatted = formatViolations(violations); - - expect(formatted).toContain('time'); - expect(formatted).toContain('date'); - }); - }); -}); diff --git a/packages/spine-validation-ts/tests/min-max.test.ts b/packages/spine-validation-ts/tests/min-max.test.ts deleted file mode 100644 index 7b5dfbd..0000000 --- a/packages/spine-validation-ts/tests/min-max.test.ts +++ /dev/null @@ -1,483 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Unit tests for `(min)` and `(max)` validation options. - * - * Tests numeric range validation with inclusive/exclusive bounds. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src'; - -import { - MinValueSchema, - MaxValueSchema, - MinMaxRangeSchema, - ExclusiveBoundsSchema, - CustomErrorMessagesSchema, - NumericTypesSchema, - RepeatedMinMaxSchema, - CombinedConstraintsSchema, - OptionalMinMaxSchema -} from './generated/test-min-max_pb'; - -describe('Min/Max Validation', () => { - describe('Basic Min Constraint', () => { - it('should pass when value meets minimum `(inclusive)`', () => { - const valid = create(MinValueSchema, { - positiveId: 1, - nonNegative: 0, - price: 0.01 - }); - - const violations = validate(MinValueSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when value exceeds minimum', () => { - const valid = create(MinValueSchema, { - positiveId: 100, - nonNegative: 50, - price: 19.99 - }); - - const violations = validate(MinValueSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when value is below minimum', () => { - const invalid = create(MinValueSchema, { - positiveId: 0, // Violates min = 1. - nonNegative: 5, - price: 0.01 - }); - - const violations = validate(MinValueSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const positiveIdViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'positive_id'); - expect(positiveIdViolation).toBeDefined(); - expect(positiveIdViolation?.message?.withPlaceholders).toContain('at least'); - }); - - it('should fail when price is below minimum', () => { - const invalid = create(MinValueSchema, { - positiveId: 1, - nonNegative: 0, - price: 0.001 // Violates min = 0.01. - }); - - const violations = validate(MinValueSchema, invalid); - const priceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'price'); - expect(priceViolation).toBeDefined(); - }); - - it('should `validate` zero values (`proto3` cannot distinguish unset from zero)', () => { - const withDefaults = create(MinValueSchema, { - positiveId: 0, - nonNegative: 0, - price: 0 - }); - - // `positive_id` violates `min=1`, price violates `min=0.01`, nonNegative is valid. - const violations = validate(MinValueSchema, withDefaults); - expect(violations.length).toBeGreaterThanOrEqual(2); - - const positiveIdViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'positive_id'); - expect(positiveIdViolation).toBeDefined(); - - const priceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'price'); - expect(priceViolation).toBeDefined(); - }); - }); - - describe('Basic Max Constraint', () => { - it('should pass when value meets maximum `(inclusive)`', () => { - const valid = create(MaxValueSchema, { - percentage: 100, - altitude: 8848.86, - year: 2100n - }); - - const violations = validate(MaxValueSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when value is below maximum', () => { - const valid = create(MaxValueSchema, { - percentage: 50, - altitude: 1000.0, - year: 2025n - }); - - const violations = validate(MaxValueSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when value exceeds maximum', () => { - const invalid = create(MaxValueSchema, { - percentage: 101, // Violates max = 100. - altitude: 8000.0, - year: 2050n - }); - - const violations = validate(MaxValueSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const percentageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'percentage'); - expect(percentageViolation).toBeDefined(); - expect(percentageViolation?.message?.withPlaceholders).toContain('at most'); - }); - - it('should fail when altitude exceeds maximum', () => { - const invalid = create(MaxValueSchema, { - percentage: 100, - altitude: 9000.0, // Violates max = 8848.86. - year: 2050n - }); - - const violations = validate(MaxValueSchema, invalid); - const altitudeViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'altitude'); - expect(altitudeViolation).toBeDefined(); - }); - }); - - describe('Combined Min and Max Constraints', () => { - it('should pass when value is within `range`', () => { - const valid = create(MinMaxRangeSchema, { - age: 25, - temperature: 20.5, - percentage: 50 - }); - - const violations = validate(MinMaxRangeSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass at boundary values', () => { - const valid = create(MinMaxRangeSchema, { - age: 0, // min boundary. - temperature: -273.15, // min boundary. - percentage: 100 // max boundary. - }); - - const violations = validate(MinMaxRangeSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when value is below minimum', () => { - const invalid = create(MinMaxRangeSchema, { - age: -1, // Violates min = 0. - temperature: 20.0, - percentage: 50 - }); - - const violations = validate(MinMaxRangeSchema, invalid); - const ageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'age'); - expect(ageViolation).toBeDefined(); - }); - - it('should fail when value exceeds maximum', () => { - const invalid = create(MinMaxRangeSchema, { - age: 25, - temperature: 1001.0, // Violates max = 1000.0. - percentage: 50 - }); - - const violations = validate(MinMaxRangeSchema, invalid); - const tempViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'temperature'); - expect(tempViolation).toBeDefined(); - }); - - it('should detect multiple violations', () => { - const invalid = create(MinMaxRangeSchema, { - age: 151, // Violates max = 150. - temperature: -300.0, // Violates min = -273.15. - percentage: 101 // Violates max = 100. - }); - - const violations = validate(MinMaxRangeSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(3); - }); - }); - - describe('Exclusive Bounds', () => { - it('should pass when value is strictly greater than exclusive minimum', () => { - const valid = create(ExclusiveBoundsSchema, { - positiveValue: 0.1, - temperatureKelvin: 100.0, - belowLimit: 50 - }); - - const violations = validate(ExclusiveBoundsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when value equals exclusive minimum', () => { - const invalid = create(ExclusiveBoundsSchema, { - positiveValue: 0.0, // Violates exclusive min = 0.0. - temperatureKelvin: 100.0, - belowLimit: 50 - }); - - const violations = validate(ExclusiveBoundsSchema, invalid); - const positiveValueViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'positive_value'); - expect(positiveValueViolation).toBeDefined(); - expect(positiveValueViolation?.message?.withPlaceholders).toContain('greater than'); - }); - - it('should fail when value equals exclusive maximum', () => { - const invalid = create(ExclusiveBoundsSchema, { - positiveValue: 0.1, - temperatureKelvin: 100.0, - belowLimit: 100 // Violates exclusive max = 100. - }); - - const violations = validate(ExclusiveBoundsSchema, invalid); - const belowLimitViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'below_limit'); - expect(belowLimitViolation).toBeDefined(); - expect(belowLimitViolation?.message?.withPlaceholders).toContain('less than'); - }); - - it('should use custom error message for temperature', () => { - const invalid = create(ExclusiveBoundsSchema, { - positiveValue: 0.1, - temperatureKelvin: 0.0, // Violates exclusive min with custom message. - belowLimit: 50 - }); - - const violations = validate(ExclusiveBoundsSchema, invalid); - const tempViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'temperature_kelvin'); - expect(tempViolation).toBeDefined(); - expect(tempViolation?.message?.withPlaceholders).toContain('Temperature cannot reach'); - expect(tempViolation?.message?.placeholderValue?.['other']).toBe('0.0'); - expect(tempViolation?.message?.placeholderValue?.['value']).toBe('0'); - }); - }); - - describe('Custom Error Messages', () => { - it('should use custom error message for age minimum', () => { - const invalid = create(CustomErrorMessagesSchema, { - age: 17, // Violates min = 18. - balance: 100.0 - }); - - const violations = validate(CustomErrorMessagesSchema, invalid); - const ageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'age'); - expect(ageViolation).toBeDefined(); - expect(ageViolation?.message?.withPlaceholders).toContain('Must be at least'); - expect(ageViolation?.message?.withPlaceholders).toContain('years old'); - expect(ageViolation?.message?.placeholderValue?.['other']).toBe('18'); - expect(ageViolation?.message?.placeholderValue?.['value']).toBe('17'); - }); - - it('should use custom error message for balance minimum', () => { - const invalid = create(CustomErrorMessagesSchema, { - age: 25, - balance: 0.001 // Violates min = 0.01. - }); - - const violations = validate(CustomErrorMessagesSchema, invalid); - const balanceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'balance'); - expect(balanceViolation).toBeDefined(); - expect(balanceViolation?.message?.withPlaceholders).toContain('Balance must be at least'); - }); - - it('should use custom error message for balance maximum', () => { - const invalid = create(CustomErrorMessagesSchema, { - age: 25, - balance: 1000001.0 // Violates max = 1000000.0. - }); - - const violations = validate(CustomErrorMessagesSchema, invalid); - const balanceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'balance'); - expect(balanceViolation).toBeDefined(); - expect(balanceViolation?.message?.withPlaceholders).toContain('Balance cannot exceed'); - }); - }); - - describe('Different Numeric Types', () => { - it('should `validate` all numeric types correctly', () => { - const valid = create(NumericTypesSchema, { - int32Field: 100, - int64Field: 1000n, - uint32Field: 1000, - uint64Field: 1n, - floatField: 50.0, - doubleField: 0.0 - }); - - const violations = validate(NumericTypesSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect violations across different types', () => { - const invalid = create(NumericTypesSchema, { - int32Field: -1, // Violates min = 0. - int64Field: -1n, // Violates min = 0. - uint32Field: 5000000000, // Violates max (too large). - uint64Field: 0n, // Violates min = 1. - floatField: 101.0, // Violates max = 100.0. - doubleField: 1001.0 // Violates max = 1000.0. - }); - - const violations = validate(NumericTypesSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(4); - }); - }); - - describe('Repeated Fields', () => { - it('should `validate` all elements in repeated field', () => { - const valid = create(RepeatedMinMaxSchema, { - scores: [0, 50, 100], - prices: [0.01, 10.0, 99.99] - }); - - const violations = validate(RepeatedMinMaxSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect violation in one element of repeated field', () => { - const invalid = create(RepeatedMinMaxSchema, { - scores: [50, 101, 75], // Second element violates max = 100. - prices: [10.0, 20.0] - }); - - const violations = validate(RepeatedMinMaxSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const scoreViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'scores' && v.fieldPath?.fieldName[1] === '1' - ); - expect(scoreViolation).toBeDefined(); - }); - - it('should detect multiple violations in repeated field', () => { - const invalid = create(RepeatedMinMaxSchema, { - scores: [-1, 50, 101], // First and third violate constraints. - prices: [0.001, 10.0] // First violates min = 0.01. - }); - - const violations = validate(RepeatedMinMaxSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(3); - }); - - it('should not `validate` empty repeated fields', () => { - const empty = create(RepeatedMinMaxSchema, { - scores: [], - prices: [] - }); - - const violations = validate(RepeatedMinMaxSchema, empty); - expect(violations).toHaveLength(0); - }); - }); - - describe('Combined with Required', () => { - it('should pass when `required` field meets `min` constraint', () => { - const valid = create(CombinedConstraintsSchema, { - productId: 1, - price: 0.01, - stock: 100 - }); - - const violations = validate(CombinedConstraintsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect `required` violation', () => { - const invalid = create(CombinedConstraintsSchema, { - productId: 0, // Required but set to default. - price: 0, // Required but set to default. - stock: 10 - }); - - const violations = validate(CombinedConstraintsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Should have violations for required fields. - const hasRequiredViolation = violations.some(v => - v.message?.withPlaceholders.includes('value must be set') - ); - expect(hasRequiredViolation).toBe(true); - }); - - it('should detect `min` violation on `required` field', () => { - const invalid = create(CombinedConstraintsSchema, { - productId: 0, // Violates min = 1 AND required. - price: 10.0, - stock: 5 - }); - - const violations = validate(CombinedConstraintsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - - it('should use custom error message from `min` option', () => { - const invalid = create(CombinedConstraintsSchema, { - productId: 10, - price: 0.001, // Violates min = 0.01. - stock: 5 - }); - - const violations = validate(CombinedConstraintsSchema, invalid); - const priceViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'price'); - expect(priceViolation).toBeDefined(); - expect(priceViolation?.message?.withPlaceholders).toContain('Price must be at least'); - expect(priceViolation?.message?.placeholderValue?.['other']).toBe('0.01'); - }); - }); - - describe('Optional Fields', () => { - it('should `validate` even zero values in `proto3`', () => { - const withDefaults = create(OptionalMinMaxSchema, { - optionalCount: 0, // Violates min = 1 (proto3 treats 0 as set). - optionalRating: 0 // Within max = 5.0, so valid. - }); - - const violations = validate(OptionalMinMaxSchema, withDefaults); - expect(violations.length).toBeGreaterThan(0); - - const countViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_count'); - expect(countViolation).toBeDefined(); - }); - - it('should `validate` when optional fields have non-default values', () => { - const invalid = create(OptionalMinMaxSchema, { - optionalCount: 2, // Valid. - optionalRating: 5.5 // Violates max = 5.0. - }); - - const violations = validate(OptionalMinMaxSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const ratingViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_rating'); - expect(ratingViolation).toBeDefined(); - }); - }); -}); - diff --git a/packages/spine-validation-ts/tests/pattern.test.ts b/packages/spine-validation-ts/tests/pattern.test.ts deleted file mode 100644 index a2519db..0000000 --- a/packages/spine-validation-ts/tests/pattern.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Unit tests for `(pattern)` validation option. - * - * Tests regex pattern validation for string fields. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src'; - -import { - PatternValidationSchema, - RepeatedPatternValidationSchema, - CaseInsensitivePatternSchema, - OptionalPatternSchema -} from './generated/test-pattern_pb'; - -describe('Pattern Field Validation', () => { - describe('Single Pattern Fields', () => { - it('should validate alpha-only field', () => { - const valid = create(PatternValidationSchema, { - alphaField: 'HelloWorld', - alphanumericField: 'Test123', - email: 'test@example.com', - phone: '555-123-4567', - website: 'https://example.com', - colorHex: '#FF5733', - username: 'user_name-123' - }); - - const violations = validate(PatternValidationSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect invalid alpha-only field (contains numbers)', () => { - const invalid = create(PatternValidationSchema, { - alphaField: 'Hello123', // Invalid: contains numbers. - alphanumericField: 'Test', - email: 'test@example.com', - phone: '555-123-4567', - website: 'https://example.com', - colorHex: '#FF5733', - username: 'username' - }); - - const violations = validate(PatternValidationSchema, invalid); - const alphaViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'alpha_field'); - expect(alphaViolation).toBeDefined(); - expect(alphaViolation?.message?.withPlaceholders).toContain('must contain only letters'); - }); - - it('should detect invalid email `pattern`', () => { - const invalid = create(PatternValidationSchema, { - alphaField: 'Test', - alphanumericField: 'Test', - email: 'notanemail', // Invalid email. - phone: '555-123-4567', - website: 'https://example.com', - colorHex: '#FF5733', - username: 'username' - }); - - const violations = validate(PatternValidationSchema, invalid); - const emailViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'email'); - expect(emailViolation).toBeDefined(); - expect(emailViolation?.message?.withPlaceholders).toContain('Invalid email format'); - }); - - it('should detect invalid phone `pattern`', () => { - const invalid = create(PatternValidationSchema, { - alphaField: 'Test', - alphanumericField: 'Test', - email: 'test@example.com', - phone: '1234567890', // Invalid: missing dashes. - website: 'https://example.com', - colorHex: '#FF5733', - username: 'username' - }); - - const violations = validate(PatternValidationSchema, invalid); - const phoneViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'phone'); - expect(phoneViolation).toBeDefined(); - expect(phoneViolation?.message?.withPlaceholders).toContain('XXX-XXX-XXXX'); - }); - - it('should detect invalid hex color', () => { - const invalid = create(PatternValidationSchema, { - alphaField: 'Test', - alphanumericField: 'Test', - email: 'test@example.com', - phone: '555-123-4567', - website: 'https://example.com', - colorHex: 'FF5733', // Invalid: missing #. - username: 'username' - }); - - const violations = validate(PatternValidationSchema, invalid); - const colorViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'color_hex'); - expect(colorViolation).toBeDefined(); - expect(colorViolation?.message?.withPlaceholders).toContain('hex code'); - }); - - it('should detect invalid username (too short)', () => { - const invalid = create(PatternValidationSchema, { - alphaField: 'Test', - alphanumericField: 'Test', - email: 'test@example.com', - phone: '555-123-4567', - website: 'https://example.com', - colorHex: '#FF5733', - username: 'ab' // Invalid: too short (needs 3-20). - }); - - const violations = validate(PatternValidationSchema, invalid); - const usernameViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'username'); - expect(usernameViolation).toBeDefined(); - expect(usernameViolation?.message?.withPlaceholders).toContain('3-20 characters'); - }); - }); - - describe('Repeated Pattern Fields', () => { - it('should validate repeated fields with all valid values', () => { - const valid = create(RepeatedPatternValidationSchema, { - emails: ['user1@example.com', 'user2@test.org'], - tags: ['tag1', 'tag2', 'tag3'] - }); - - const violations = validate(RepeatedPatternValidationSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect invalid email in repeated field', () => { - const invalid = create(RepeatedPatternValidationSchema, { - emails: ['valid@example.com', 'invalid-email', 'another@test.org'], - tags: ['tag1'] - }); - - const violations = validate(RepeatedPatternValidationSchema, invalid); - const emailViolation = violations.find(v => v.fieldPath?.fieldName[0]?.startsWith('emails')); - expect(emailViolation).toBeDefined(); - }); - - it('should detect invalid tag in repeated field', () => { - const invalid = create(RepeatedPatternValidationSchema, { - emails: ['valid@example.com'], - tags: ['validtag', 'invalid-tag!', 'another'] // Middle tag has special char. - }); - - const violations = validate(RepeatedPatternValidationSchema, invalid); - const tagViolation = violations.find(v => v.fieldPath?.fieldName[0]?.startsWith('tags')); - expect(tagViolation).toBeDefined(); - }); - }); - - describe('Optional Pattern Fields', () => { - it('should not `validate` `pattern` on empty optional fields', () => { - const valid = create(OptionalPatternSchema, { - optionalEmail: '', - optionalPhone: '' - }); - - const violations = validate(OptionalPatternSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should validate pattern when optional field has value', () => { - const invalid = create(OptionalPatternSchema, { - optionalEmail: 'invalid', // Invalid email format. - optionalPhone: '' - }); - - const violations = validate(OptionalPatternSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - const emailViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_email'); - expect(emailViolation).toBeDefined(); - }); - }); -}); - diff --git a/packages/spine-validation-ts/tests/proto/test-distinct.proto b/packages/spine-validation-ts/tests/proto/test-distinct.proto deleted file mode 100644 index ddda3a8..0000000 --- a/packages/spine-validation-ts/tests/proto/test-distinct.proto +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -syntax = "proto3"; - -package spine.validation.testing.distinct_suite; - -// Test messages for the `(distinct)` validation option. -// -// This file contains test cases for uniqueness validation on repeated fields. -// The `(distinct)` constraint ensures all elements in a repeated field are unique. - -import "spine/options.proto"; - -// Tests distinct constraint on primitive types. -message DistinctPrimitives { - repeated int32 numbers = 1 [(distinct) = true]; - repeated string tags = 2 [(distinct) = true]; - repeated double scores = 3 [(distinct) = true]; - repeated bool flags = 4 [(distinct) = true]; -} - -// Tests distinct constraint on enum values. -message DistinctEnums { - repeated Status statuses = 1 [(distinct) = true]; -} - -enum Status { - STATUS_UNSPECIFIED = 0; - STATUS_ACTIVE = 1; - STATUS_INACTIVE = 2; - STATUS_PENDING = 3; -} - -// Tests repeated fields without distinct constraint. -message NonDistinctFields { - repeated int32 numbers = 1; - repeated string tags = 2; -} - -// Tests distinct combined with other constraints. -message CombinedConstraints { - repeated int32 product_ids = 1 [ - (distinct) = true, - (range).value = "[1..999999]" - ]; - // Each email must be a valid email address (basic format validation). - repeated string emails = 2 [ - (distinct) = true, - (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" - ]; - repeated int32 scores = 3 [ - (distinct) = true, - (min).value = "0", - (max).value = "100" - ]; -} - -// Tests distinct on optional repeated fields. -message OptionalDistinct { - repeated int32 optional_numbers = 1 [(distinct) = true]; - repeated string optional_tags = 2 [(distinct) = true]; -} - -// Tests distinct for user profile with unique tags. -message UserProfile { - string username = 1 [(required) = true]; - repeated string tags = 2 [(distinct) = true]; - repeated string skills = 3 [(distinct) = true]; -} - -// Tests distinct for shopping cart with unique items. -message ShoppingCart { - repeated int32 product_ids = 1 [(distinct) = true]; - repeated string coupon_codes = 2 [(distinct) = true]; -} - -// Tests distinct across different numeric types. -message DistinctNumericTypes { - repeated int32 int32_values = 1 [(distinct) = true]; - repeated int64 int64_values = 2 [(distinct) = true]; - repeated uint32 uint32_values = 3 [(distinct) = true]; - repeated uint64 uint64_values = 4 [(distinct) = true]; - repeated float float_values = 5 [(distinct) = true]; - repeated double double_values = 6 [(distinct) = true]; -} - -// Tests edge cases for distinct validation. -message DistinctEdgeCases { - repeated string empty_strings = 1 [(distinct) = true]; - repeated int32 zeros = 2 [(distinct) = true]; - repeated string case_sensitive = 3 [(distinct) = true]; -} diff --git a/packages/spine-validation-ts/tests/proto/test-goes.proto b/packages/spine-validation-ts/tests/proto/test-goes.proto deleted file mode 100644 index 03230db..0000000 --- a/packages/spine-validation-ts/tests/proto/test-goes.proto +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -syntax = "proto3"; - -package spine.validation.testing.goes_suite; - -// Test messages for the `(goes)` field dependency validation option. -// -// This file contains test cases for the `(goes)` constraint that enforces field -// dependencies. A field with `(goes).with = "other_field"` can only be set if -// the referenced field is also set. - -import "spine/options.proto"; - -// Tests basic goes constraint. -message ScheduledEvent { - string event_name = 1 [(required) = true]; - string date = 2; - string time = 3 [(goes).with = "date"]; -} - -// Tests custom error message via `(goes).error_msg`. -message ShippingDetails { - string address = 1; - string tracking_number = 2 [ - (goes).with = "address", - (goes).error_msg = "Tracking number requires a shipping address: {value}." - ]; -} - -// Tests mutual dependencies (bidirectional). -message ColorSettings { - string text_color = 1 [(goes).with = "highlight_color"]; - string highlight_color = 2 [(goes).with = "text_color"]; -} - -// Tests multiple independent goes constraints. -message PaymentInfo { - string cardholder_name = 1; - string card_number = 2 [(goes).with = "cardholder_name"]; - string cvv = 3 [(goes).with = "card_number"]; - int32 expiry_month = 4 [(goes).with = "card_number"]; -} - -// Tests goes constraint on different field types. -message ProfileSettings { - string username = 1; - int32 display_id = 2 [(goes).with = "username"]; - bool is_verified = 3 [(goes).with = "username"]; - double rating = 4 [(goes).with = "username"]; -} - -// Tests goes constraint on message field type. -message DocumentMetadata { - string title = 1; - Timestamp created_at = 2 [(goes).with = "title"]; -} - -message Timestamp { - int64 seconds = 1; - int32 nanos = 2; -} - -// Tests goes combined with pattern constraints. -message SecureAccount { - // Must be 3-20 characters containing only letters, numbers, and underscores. - string username = 1 [(required) = true, (pattern).regex = "^[a-zA-Z0-9_]{3,20}$"]; - // Must be at least 8 characters long. - string password = 2 [ - (required) = true, - (pattern).regex = "^.{8,}$" - ]; - // If provided, must be a valid email address (basic format validation). - string recovery_email = 3 [(pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"]; - string recovery_phone = 4 [(goes).with = "recovery_email"]; -} - -// Tests message without goes constraint. -message SimpleConfig { - string primary_option = 1; - string secondary_option = 2; -} - -// Tests goes constraint with enum field. -message FeatureFlags { - FeatureLevel level = 1; - string custom_config = 2 [(goes).with = "level"]; -} - -enum FeatureLevel { - FEATURE_LEVEL_UNSPECIFIED = 0; - FEATURE_LEVEL_BASIC = 1; - FEATURE_LEVEL_ADVANCED = 2; - FEATURE_LEVEL_PREMIUM = 3; -} - -// Tests chain dependencies. -message ReportGeneration { - string report_type = 1; - string output_format = 2 [(goes).with = "report_type"]; - string email_recipient = 3 [(goes).with = "report_type"]; - string schedule = 4 [(goes).with = "output_format"]; -} - -// Tests goes constraint on optional fields. -message OptionalSettings { - string base_url = 1; - int32 port = 2 [(goes).with = "base_url"]; - string path = 3 [(goes).with = "base_url"]; -} - -// Tests goes combined with min/max/range constraints. -message AdvancedConfig { - string config_name = 1; - int32 max_connections = 2 [ - (goes).with = "config_name", - (range).value = "[1..1000]" - ]; - double timeout_seconds = 3 [ - (goes).with = "config_name", - (min).value = "0.1" - ]; -} diff --git a/packages/spine-validation-ts/tests/proto/test-min-max.proto b/packages/spine-validation-ts/tests/proto/test-min-max.proto deleted file mode 100644 index 557a542..0000000 --- a/packages/spine-validation-ts/tests/proto/test-min-max.proto +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -syntax = "proto3"; - -package spine.validation.testing.minmax_suite; - -// Test messages for the `(min)` and `(max)` validation options. -// -// This file contains test cases for numeric range validation using minimum and -// maximum constraints. Supports inclusive/exclusive bounds, custom error messages, -// and validation across different numeric types and repeated fields. - -import "spine/options.proto"; - -// Tests basic min constraint (inclusive by default). -message MinValue { - int32 positive_id = 1 [(min).value = "1"]; - int32 non_negative = 2 [(min).value = "0"]; - double price = 3 [(min).value = "0.01"]; -} - -// Tests basic max constraint (inclusive by default). -message MaxValue { - int32 percentage = 1 [(max).value = "100"]; - double altitude = 2 [(max).value = "8848.86"]; - int64 year = 3 [(max).value = "2100"]; -} - -// Tests combined min and max constraints. -message MinMaxRange { - int32 age = 1 [(min).value = "0", (max).value = "150"]; - double temperature = 2 [(min).value = "-273.15", (max).value = "1000.0"]; - int32 percentage = 3 [(min).value = "0", (max).value = "100"]; -} - -// Tests exclusive bounds. -message ExclusiveBounds { - double positive_value = 1 [(min) = { - value: "0.0", - exclusive: true - }]; - double temperature_kelvin = 2 [(min) = { - value: "0.0", - exclusive: true, - error_msg: "Temperature cannot reach {other}K, but provided {value}." - }]; - int32 below_limit = 3 [(max) = { - value: "100", - exclusive: true - }]; -} - -// Tests custom error messages. -message CustomErrorMessages { - int32 age = 1 [(min) = { - value: "18", - error_msg: "Must be at least {other} years old. Provided: {value}." - }]; - double balance = 2 [(min) = { - value: "0.01", - error_msg: "Balance must be at least ${other}. Current: ${value}." - }, (max) = { - value: "1000000.0", - error_msg: "Balance cannot exceed ${other}. Current: ${value}." - }]; -} - -// Tests min/max validation across different numeric types. -message NumericTypes { - int32 int32_field = 1 [(min).value = "0", (max).value = "2147483647"]; - int64 int64_field = 2 [(min).value = "0"]; - uint32 uint32_field = 3 [(max).value = "4294967295"]; - uint64 uint64_field = 4 [(min).value = "1"]; - float float_field = 5 [(min).value = "0.0", (max).value = "100.0"]; - double double_field = 6 [(min).value = "-1000.0", (max).value = "1000.0"]; -} - -// Tests min/max validation on repeated fields. -message RepeatedMinMax { - repeated int32 scores = 1 [(min).value = "0", (max).value = "100"]; - repeated double prices = 2 [(min).value = "0.01"]; -} - -// Tests combined required and min/max constraints. -message CombinedConstraints { - int32 product_id = 1 [(required) = true, (min).value = "1"]; - double price = 2 [ - (required) = true, - (min) = { - value: "0.01", - error_msg: "Price must be at least {other}." - } - ]; - int32 stock = 3 [(min).value = "0"]; -} - -// Tests optional fields with min/max constraints. -message OptionalMinMax { - int32 optional_count = 1 [(min).value = "1"]; - double optional_rating = 2 [(max).value = "5.0"]; -} diff --git a/packages/spine-validation-ts/tests/proto/test-range.proto b/packages/spine-validation-ts/tests/proto/test-range.proto deleted file mode 100644 index fcc665a..0000000 --- a/packages/spine-validation-ts/tests/proto/test-range.proto +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -syntax = "proto3"; - -package spine.validation.testing.range_suite; - -// Test messages for the `(range)` validation option. -// -// This file contains test cases for bounded numeric range validation using -// bracket notation. Supports inclusive bounds `[min..max]`, exclusive bounds -// `(min..max)`, and half-open intervals `[min..max)` or `(min..max]`. - -import "spine/options.proto"; - -// Tests closed (inclusive) ranges. -message ClosedRange { - int32 percentage = 1 [(range).value = "[0..100]"]; - int32 rgb_value = 2 [(range).value = "[0..255]"]; - double temperature_c = 3 [(range).value = "[-273.15..1000.0]"]; -} - -// Tests open (exclusive) ranges. -message OpenRange { - double positive_value = 1 [(range).value = "(0.0..100.0)"]; - int32 exclusive_count = 2 [(range).value = "(0..10)"]; -} - -// Tests half-open ranges. -message HalfOpenRange { - int32 hour = 1 [(range).value = "[0..24)"]; - int32 minute = 2 [(range).value = "[0..60)"]; - float degree = 3 [(range).value = "[0.0..360.0)"]; - double angle = 4 [(range).value = "(0.0..180.0]"]; -} - -// Tests range validation across different numeric types. -message NumericTypeRanges { - int32 int32_field = 1 [(range).value = "[1..100]"]; - int64 int64_field = 2 [(range).value = "[0..1000000]"]; - uint32 uint32_field = 3 [(range).value = "[1..65535]"]; - uint64 uint64_field = 4 [(range).value = "[1..4294967295]"]; - float float_field = 5 [(range).value = "[0.0..1.0]"]; - double double_field = 6 [(range).value = "[-1000.0..1000.0]"]; -} - -// Tests range validation on repeated fields. -message RepeatedRange { - repeated int32 scores = 1 [(range).value = "[0..100]"]; - repeated double percentages = 2 [(range).value = "[0.0..100.0]"]; -} - -// Tests combined required and range constraints. -message CombinedConstraints { - int32 product_id = 1 [(required) = true, (range).value = "[1..999999]"]; - int32 quantity = 2 [(required) = true, (range).value = "[1..1000]"]; - double discount = 3 [(range).value = "[0.0..1.0]"]; -} - -// Tests range validation for payment card fields. -message PaymentCard { - int32 expiry_month = 1 [(required) = true, (range).value = "[1..12]"]; - int32 expiry_year = 2 [(required) = true, (range).value = "[2024..2050]"]; - int32 cvv = 3 [(required) = true, (range).value = "[0..999]"]; -} - -// Tests range validation for RGB color values. -message RGBColor { - int32 red = 1 [(range).value = "[0..255]"]; - int32 green = 2 [(range).value = "[0..255]"]; - int32 blue = 3 [(range).value = "[0..255]"]; - double alpha = 4 [(range).value = "[0.0..1.0]"]; -} - -// Tests range validation for pagination parameters. -message PaginationRequest { - int32 page = 1 [(required) = true, (range).value = "[1..10000]"]; - int32 page_size = 2 [(required) = true, (range).value = "[1..100]"]; -} - -// Tests optional fields with range constraints. -message OptionalRange { - int32 optional_score = 1 [(range).value = "[1..100]"]; - double optional_rating = 2 [(range).value = "[1.0..5.0]"]; -} - -// Tests edge cases with single-value ranges. -message EdgeCaseRanges { - int32 exact_value = 1 [(range).value = "[42..42]"]; - double pi_approx = 2 [(range).value = "[3.14..3.15]"]; -} diff --git a/packages/spine-validation-ts/tests/proto/test-required-field.proto b/packages/spine-validation-ts/tests/proto/test-required-field.proto deleted file mode 100644 index 14a4d25..0000000 --- a/packages/spine-validation-ts/tests/proto/test-required-field.proto +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -syntax = "proto3"; - -package spine.validation.testing.requiredfield_suite; - -// Test messages for the `(require)` message-level validation option. -// -// This file contains test cases for the `(require)` constraint that -// requires specific combinations of fields using boolean logic (OR, AND, and -// parentheses for grouping). The constraint is specified at the message level. - -import "spine/options.proto"; - -// Tests simple OR logic: at least one field must be set. -message UserIdentifier { - option (require).fields = "id | email"; - - int32 id = 1; - string email = 2; -} - -// Tests AND logic: both fields must be set together. -message ContactInfo { - option (require).fields = "phone & country_code"; - - string phone = 1; - string country_code = 2; -} - -// Tests complex OR with AND groups. -message PersonName { - option (require).fields = "given_name | (honorific_prefix & family_name)"; - - string honorific_prefix = 1; - string given_name = 2; - string middle_name = 3; - string family_name = 4; - string honorific_suffix = 5; -} - -// Tests multiple OR alternatives. -message PaymentMethod { - option (require).fields = "credit_card | bank_account | paypal_email"; - - string credit_card = 1; - string bank_account = 2; - string paypal_email = 3; -} - -// Tests multiple AND requirements. -message ShippingAddress { - option (require).fields = "street & city & postal_code & country"; - - string street = 1; - string city = 2; - string postal_code = 3; - string country = 4; - string state = 5; -} - -// Tests complex nested logic with grouping. -message AccountCreation { - option (require).fields = "(username & password) | oauth_token"; - - string username = 1; - string password = 2; - string oauth_token = 3; -} - -// Tests message without required_field constraint (all fields optional). -message OptionalData { - string field1 = 1; - string field2 = 2; - int32 field3 = 3; -} diff --git a/packages/spine-validation-ts/tests/proto/test-validate.proto b/packages/spine-validation-ts/tests/proto/test-validate.proto deleted file mode 100644 index 84539eb..0000000 --- a/packages/spine-validation-ts/tests/proto/test-validate.proto +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -syntax = "proto3"; - -package spine.validation.testing.validate_suite; - -// Test messages for the `(validate)` and `(if_invalid)` validation options. -// -// This file contains test cases for recursive validation of nested message fields. -// The `(validate)` option enables validation of constraints in nested messages, -// and `(if_invalid)` provides custom error messages for validation failures. - -import "spine/options.proto"; - -// Tests basic nested message validation. -message PersonWithAddress { - string name = 1 [(required) = true]; - Address address = 2 [(validate) = true]; -} - -message Address { - string street = 1 [(required) = true]; - string city = 2 [(required) = true]; - // Must be a 5-digit US ZIP code. - string zip_code = 3 [ - (required) = true, - (pattern).regex = "^[0-9]{5}$" - ]; -} - -// Tests custom error messages via `(if_invalid)`. -message OrderWithCustomError { - int32 order_id = 1 [(required) = true]; - Customer customer = 2 [ - (validate) = true, - (if_invalid).error_msg = "Customer information is invalid: {value}." - ]; -} - -message Customer { - // Must be a valid email address (basic format validation). - string email = 1 [ - (required) = true, - (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" - ]; - int32 age = 2 [(required) = true, (range).value = "[18..120]"]; -} - -// Tests validation on repeated message fields. -message TeamWithMembers { - string team_name = 1 [(required) = true]; - repeated Member members = 2 [(validate) = true]; -} - -message Member { - string name = 1 [(required) = true]; - // Must be a valid email address (basic format validation). - string email = 2 [ - (required) = true, - (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" - ]; -} - -// Tests deeply nested validation. -message CompanyStructure { - string company_name = 1 [(required) = true]; - Department department = 2 [(validate) = true]; -} - -message Department { - string dept_name = 1 [(required) = true]; - Manager manager = 2 [(validate) = true]; -} - -message Manager { - string name = 1 [(required) = true]; - // Must be a valid email address (basic format validation). - string email = 2 [ - (required) = true, - (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" - ]; -} - -// Tests validation on optional nested fields. -message ProfileWithOptionalData { - string username = 1 [(required) = true]; - OptionalData optional_data = 2 [(validate) = true]; -} - -message OptionalData { - string bio = 1; - int32 followers = 2 [(min).value = "0"]; -} - -// Tests message without validate option. -message PersonWithoutValidation { - string name = 1 [(required) = true]; - Address address = 2; -} - -// Tests combining multiple validation types. -message ProductOrder { - int32 product_id = 1 [(required) = true, (min).value = "1"]; - ProductDetails product = 2 [ - (validate) = true, - (if_invalid).error_msg = "Product details are invalid." - ]; - repeated Review reviews = 3 [(validate) = true]; - ShippingInfo shipping = 4 [ - (required) = true, - (validate) = true - ]; -} - -message ProductDetails { - string name = 1 [(required) = true]; - double price = 2 [(required) = true, (min).value = "0.01"]; - repeated string tags = 3 [(distinct) = true]; -} - -message Review { - int32 rating = 1 [(required) = true, (range).value = "[1..5]"]; - string comment = 2; -} - -message ShippingInfo { - Address address = 1 [(required) = true, (validate) = true]; - string method = 2 [(required) = true]; -} - -// Tests validation on message field without constraints. -message ContainerWithEmptyMessage { - string id = 1 [(required) = true]; - EmptyValidated empty = 2 [(validate) = true]; -} - -message EmptyValidated { - string note = 1; -} - -// Tests nested validation combined with distinct. -message ProjectWithTasks { - string project_name = 1 [(required) = true]; - repeated Task tasks = 2 [(validate) = true]; - repeated string tags = 3 [(distinct) = true]; -} - -message Task { - string title = 1 [(required) = true]; - int32 priority = 2 [(range).value = "[1..5]"]; - repeated string assignees = 3 [(distinct) = true]; -} diff --git a/packages/spine-validation-ts/tests/range.test.ts b/packages/spine-validation-ts/tests/range.test.ts deleted file mode 100644 index 782cc41..0000000 --- a/packages/spine-validation-ts/tests/range.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Unit tests for `(range)` validation option. - * - * Tests numeric range validation using bracket notation. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src'; - -import { - ClosedRangeSchema, - OpenRangeSchema, - HalfOpenRangeSchema, - NumericTypeRangesSchema, - RepeatedRangeSchema, - CombinedConstraintsSchema as RangeCombinedConstraintsSchema, - PaymentCardSchema, - RGBColorSchema, - PaginationRequestSchema, - OptionalRangeSchema, - EdgeCaseRangesSchema -} from './generated/test-range_pb'; - -describe('Range Validation', () => { - describe('Closed (Inclusive) Ranges', () => { - it('should pass when value is within closed `range`', () => { - const valid = create(ClosedRangeSchema, { - percentage: 50, - rgbValue: 128, - temperatureC: 25.0 - }); - - const violations = validate(ClosedRangeSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass at boundary values `(inclusive)`', () => { - const valid = create(ClosedRangeSchema, { - percentage: 0, // Min boundary. - rgbValue: 255, // Max boundary. - temperatureC: -273.15 // Min boundary. - }); - - const violations = validate(ClosedRangeSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when value is below minimum', () => { - const invalid = create(ClosedRangeSchema, { - percentage: -1, // Violates [0..100]. - rgbValue: 128, - temperatureC: 25.0 - }); - - const violations = validate(ClosedRangeSchema, invalid); - const percentageViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'percentage'); - expect(percentageViolation).toBeDefined(); - expect(percentageViolation?.message?.withPlaceholders).toContain('[0..100]'); - }); - - it('should fail when value exceeds maximum', () => { - const invalid = create(ClosedRangeSchema, { - percentage: 50, - rgbValue: 256, // Violates [0..255]. - temperatureC: 25.0 - }); - - const violations = validate(ClosedRangeSchema, invalid); - const rgbViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'rgb_value'); - expect(rgbViolation).toBeDefined(); - expect(rgbViolation?.message?.withPlaceholders).toContain('[0..255]'); - }); - }); - - describe('Open (Exclusive) Ranges', () => { - it('should pass when value is within exclusive `range`', () => { - const valid = create(OpenRangeSchema, { - positiveValue: 50.0, - exclusiveCount: 5 - }); - - const violations = validate(OpenRangeSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail at boundary values `(exclusive)`', () => { - const invalidMin = create(OpenRangeSchema, { - positiveValue: 0.0, // Violates (0.0..100.0) - must be > 0. - exclusiveCount: 5 - }); - - const violationsMin = validate(OpenRangeSchema, invalidMin); - const minViolation = violationsMin.find(v => v.fieldPath?.fieldName[0] === 'positive_value'); - expect(minViolation).toBeDefined(); - - const invalidMax = create(OpenRangeSchema, { - positiveValue: 50.0, - exclusiveCount: 10 // Violates (0..10) - must be < 10. - }); - - const violationsMax = validate(OpenRangeSchema, invalidMax); - const maxViolation = violationsMax.find(v => v.fieldPath?.fieldName[0] === 'exclusive_count'); - expect(maxViolation).toBeDefined(); - }); - }); - - describe('Half-Open Ranges', () => { - it('should pass when value is within half-open `range`', () => { - const valid = create(HalfOpenRangeSchema, { - hour: 12, // [0..24). - minute: 30, // [0..60). - degree: 180.0, // [0.0..360.0). - angle: 90.0 // (0.0..180.0]. - }); - - const violations = validate(HalfOpenRangeSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass at inclusive boundary and fail at exclusive boundary', () => { - // Test [0..24) - 0 is valid, 24 is not. - const validHour = create(HalfOpenRangeSchema, { - hour: 0, // Min is inclusive. - minute: 0, - degree: 0.0, - angle: 90.0 - }); - - const violations1 = validate(HalfOpenRangeSchema, validHour); - expect(violations1).toHaveLength(0); - - const invalidHour = create(HalfOpenRangeSchema, { - hour: 24, // Violates [0..24) - max is exclusive. - minute: 0, - degree: 0.0, - angle: 90.0 - }); - - const violations2 = validate(HalfOpenRangeSchema, invalidHour); - const hourViolation = violations2.find(v => v.fieldPath?.fieldName[0] === 'hour'); - expect(hourViolation).toBeDefined(); - }); - - it('should handle (`min`..`max`] correctly', () => { - // Test (0.0..180.0] - 0 is not valid, 180 is valid. - const invalidAngle = create(HalfOpenRangeSchema, { - hour: 12, - minute: 30, - degree: 180.0, - angle: 0.0 // Violates (0.0..180.0] - min is exclusive. - }); - - const violations1 = validate(HalfOpenRangeSchema, invalidAngle); - const angleViolation = violations1.find(v => v.fieldPath?.fieldName[0] === 'angle'); - expect(angleViolation).toBeDefined(); - - const validAngle = create(HalfOpenRangeSchema, { - hour: 12, - minute: 30, - degree: 180.0, - angle: 180.0 // Max is inclusive. - }); - - const violations2 = validate(HalfOpenRangeSchema, validAngle); - expect(violations2).toHaveLength(0); - }); - }); - - describe('Different Numeric Types', () => { - it('should `validate` ranges for all numeric types', () => { - const valid = create(NumericTypeRangesSchema, { - int32Field: 50, - int64Field: 500000n, - uint32Field: 30000, - uint64Field: 1000000n, - floatField: 0.5, - doubleField: 250.0 - }); - - const violations = validate(NumericTypeRangesSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when any numeric type violates its `range`', () => { - const invalid = create(NumericTypeRangesSchema, { - int32Field: 101, // Violates [1..100]. - int64Field: 500000n, - uint32Field: 30000, - uint64Field: 1000000n, - floatField: 1.5, // Violates [0.0..1.0]. - doubleField: 250.0 - }); - - const violations = validate(NumericTypeRangesSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(2); - - const int32Violation = violations.find(v => v.fieldPath?.fieldName[0] === 'int32_field'); - expect(int32Violation).toBeDefined(); - - const floatViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'float_field'); - expect(floatViolation).toBeDefined(); - }); - }); - - describe('Repeated Fields with Range', () => { - it('should pass when all repeated elements are within `range`', () => { - const valid = create(RepeatedRangeSchema, { - scores: [85, 92, 78, 100, 0], - percentages: [25.5, 50.0, 75.3, 100.0] - }); - - const violations = validate(RepeatedRangeSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when any repeated element violates `range`', () => { - const invalid = create(RepeatedRangeSchema, { - scores: [85, 92, 105, 78], // 105 violates [0..100]. - percentages: [25.5, 50.0] - }); - - const violations = validate(RepeatedRangeSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const scoreViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'scores' && v.fieldPath?.fieldName[1] === '2' - ); - expect(scoreViolation).toBeDefined(); - expect(scoreViolation?.message?.placeholderValue?.['value']).toBe('105'); - }); - - it('should report violations for multiple invalid elements', () => { - const invalid = create(RepeatedRangeSchema, { - scores: [85, 101, 92, 102], // 101 and 102 both violate [0..100]. - percentages: [25.5, 50.0] - }); - - const violations = validate(RepeatedRangeSchema, invalid); - const scoreViolations = violations.filter(v => v.fieldPath?.fieldName[0] === 'scores'); - expect(scoreViolations.length).toBe(2); - }); - }); - - describe('Combined Constraints (Required + Range)', () => { - it('should pass when all constraints are satisfied', () => { - const valid = create(RangeCombinedConstraintsSchema, { - productId: 12345, - quantity: 50, - discount: 0.15 - }); - - const violations = validate(RangeCombinedConstraintsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail `range` validation even when `required` is satisfied', () => { - const invalid = create(RangeCombinedConstraintsSchema, { - productId: 12345, - quantity: 1001, // Violates [1..1000]. - discount: 0.15 - }); - - const violations = validate(RangeCombinedConstraintsSchema, invalid); - const quantityViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'quantity'); - expect(quantityViolation).toBeDefined(); - expect(quantityViolation?.message?.withPlaceholders).toContain('[1..1000]'); - }); - - it('should detect both `required` and `range` violations', () => { - const invalid = create(RangeCombinedConstraintsSchema, { - productId: 0, // Violates both (required) and range [1..999999]. - quantity: 1001, // Violates range [1..1000]. - discount: 0.15 - }); - - const violations = validate(RangeCombinedConstraintsSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(2); - }); - }); - - describe('Real-World Scenarios', () => { - it('should `validate` payment card expiry dates', () => { - const valid = create(PaymentCardSchema, { - expiryMonth: 12, - expiryYear: 2026, - cvv: 123 - }); - - const violations = validate(PaymentCardSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should reject invalid expiry month', () => { - const invalid = create(PaymentCardSchema, { - expiryMonth: 13, // Violates [1..12]. - expiryYear: 2026, - cvv: 123 - }); - - const violations = validate(PaymentCardSchema, invalid); - const monthViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'expiry_month'); - expect(monthViolation).toBeDefined(); - }); - - it('should `validate` RGB color values', () => { - const valid = create(RGBColorSchema, { - red: 255, - green: 128, - blue: 0, - alpha: 0.8 - }); - - const violations = validate(RGBColorSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should reject invalid RGB values', () => { - const invalid = create(RGBColorSchema, { - red: 256, // Violates [0..255]. - green: 128, - blue: 0, - alpha: 1.5 // Violates [0.0..1.0]. - }); - - const violations = validate(RGBColorSchema, invalid); - expect(violations.length).toBe(2); - - const redViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'red'); - expect(redViolation).toBeDefined(); - - const alphaViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'alpha'); - expect(alphaViolation).toBeDefined(); - }); - - it('should `validate` pagination parameters', () => { - const valid = create(PaginationRequestSchema, { - page: 5, - pageSize: 25 - }); - - const violations = validate(PaginationRequestSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should reject invalid pagination', () => { - const invalid = create(PaginationRequestSchema, { - page: 0, // Violates [1..10000]. - pageSize: 150 // Violates [1..100]. - }); - - const violations = validate(PaginationRequestSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(2); - }); - }); - - describe('Optional Fields with Range', () => { - it('should `validate` zero values in `proto3`', () => { - const withDefaults = create(OptionalRangeSchema, { - optionalScore: 0, // Violates [1..100] (proto3 treats 0 as set). - optionalRating: 0 // Violates [1.0..5.0]. - }); - - const violations = validate(OptionalRangeSchema, withDefaults); - expect(violations.length).toBeGreaterThanOrEqual(2); - - const scoreViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_score'); - expect(scoreViolation).toBeDefined(); - - const ratingViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'optional_rating'); - expect(ratingViolation).toBeDefined(); - }); - - it('should `validate` when optional fields have non-default values', () => { - const valid = create(OptionalRangeSchema, { - optionalScore: 75, - optionalRating: 4.5 - }); - - const violations = validate(OptionalRangeSchema, valid); - expect(violations).toHaveLength(0); - }); - }); - - describe('Edge Cases', () => { - it('should handle single-value ranges (exact value)', () => { - const valid = create(EdgeCaseRangesSchema, { - exactValue: 42, // Must be exactly 42. - piApprox: 3.14 - }); - - const violations = validate(EdgeCaseRangesSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should reject values outside single-value `range`', () => { - const invalid = create(EdgeCaseRangesSchema, { - exactValue: 43, // Violates [42..42]. - piApprox: 3.14 - }); - - const violations = validate(EdgeCaseRangesSchema, invalid); - const exactViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'exact_value'); - expect(exactViolation).toBeDefined(); - }); - - it('should handle narrow ranges for doubles', () => { - const valid = create(EdgeCaseRangesSchema, { - exactValue: 42, - piApprox: 3.1415 // Within [3.14..3.15]. - }); - - const violations = validate(EdgeCaseRangesSchema, valid); - expect(violations).toHaveLength(0); - }); - }); -}); - diff --git a/packages/spine-validation-ts/tests/required-field.test.ts b/packages/spine-validation-ts/tests/required-field.test.ts deleted file mode 100644 index 8359ab6..0000000 --- a/packages/spine-validation-ts/tests/required-field.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Unit tests for `(required_field)` message-level validation option. - * - * Tests boolean logic for required field combinations. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src'; - -import { - UserIdentifierSchema, - ContactInfoSchema, - PersonNameSchema, - PaymentMethodSchema, - ShippingAddressSchema, - AccountCreationSchema, - OptionalDataSchema -} from './generated/test-required-field_pb'; - -describe('Required Field Option Validation', () => { - describe('Simple OR Logic', () => { - it('should pass when first `required` field is provided', () => { - const valid = create(UserIdentifierSchema, { - id: 123, - email: '' - }); - - const violations = validate(UserIdentifierSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when second `required` field is provided', () => { - const valid = create(UserIdentifierSchema, { - id: 0, - email: 'user@example.com' - }); - - const violations = validate(UserIdentifierSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when both `required` fields are provided', () => { - const valid = create(UserIdentifierSchema, { - id: 123, - email: 'user@example.com' - }); - - const violations = validate(UserIdentifierSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when neither `required` field is provided', () => { - const invalid = create(UserIdentifierSchema, { - id: 0, - email: '' - }); - - const violations = validate(UserIdentifierSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - expect(violations[0].message?.withPlaceholders).toContain('id | email'); - }); - }); - - describe('Simple AND Logic', () => { - it('should pass when both required fields are provided', () => { - const valid = create(ContactInfoSchema, { - phone: '555-1234', - countryCode: '+1' - }); - - const violations = validate(ContactInfoSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when only first field is provided', () => { - const invalid = create(ContactInfoSchema, { - phone: '555-1234', - countryCode: '' - }); - - const violations = validate(ContactInfoSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - expect(violations[0].message?.withPlaceholders).toContain('phone & country_code'); - }); - - it('should fail when only second field is provided', () => { - const invalid = create(ContactInfoSchema, { - phone: '', - countryCode: '+1' - }); - - const violations = validate(ContactInfoSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - - it('should fail when neither field is provided', () => { - const invalid = create(ContactInfoSchema, { - phone: '', - countryCode: '' - }); - - const violations = validate(ContactInfoSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - }); - - describe('Complex OR with AND Groups', () => { - it('should pass when only given_name is provided', () => { - const valid = create(PersonNameSchema, { - givenName: 'John', - honorificPrefix: '', - familyName: '', - middleName: '', - honorificSuffix: '' - }); - - const violations = validate(PersonNameSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when honorific_prefix and family_name are both provided', () => { - const valid = create(PersonNameSchema, { - givenName: '', - honorificPrefix: 'Dr.', - familyName: 'Smith', - middleName: '', - honorificSuffix: '' - }); - - const violations = validate(PersonNameSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when all fields are provided', () => { - const valid = create(PersonNameSchema, { - givenName: 'John', - honorificPrefix: 'Dr.', - familyName: 'Smith', - middleName: 'M.', - honorificSuffix: 'Jr.' - }); - - const violations = validate(PersonNameSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when only honorific_prefix is provided (missing family_name)', () => { - const invalid = create(PersonNameSchema, { - givenName: '', - honorificPrefix: 'Dr.', - familyName: '', - middleName: '', - honorificSuffix: '' - }); - - const violations = validate(PersonNameSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - expect(violations[0].message?.withPlaceholders).toContain('given_name | (honorific_prefix & family_name)'); - }); - - it('should fail when only family_name is provided (missing honorific_prefix)', () => { - const invalid = create(PersonNameSchema, { - givenName: '', - honorificPrefix: '', - familyName: 'Smith', - middleName: '', - honorificSuffix: '' - }); - - const violations = validate(PersonNameSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - - it('should fail when no `required` fields are provided', () => { - const invalid = create(PersonNameSchema, { - givenName: '', - honorificPrefix: '', - familyName: '', - middleName: '', - honorificSuffix: '' - }); - - const violations = validate(PersonNameSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - }); - - describe('Multiple OR Alternatives', () => { - it('should pass when credit_card is provided', () => { - const valid = create(PaymentMethodSchema, { - creditCard: '4111-1111-1111-1111', - bankAccount: '', - paypalEmail: '' - }); - - const violations = validate(PaymentMethodSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when bank_account is provided', () => { - const valid = create(PaymentMethodSchema, { - creditCard: '', - bankAccount: 'ACC123456', - paypalEmail: '' - }); - - const violations = validate(PaymentMethodSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when paypal_email is provided', () => { - const valid = create(PaymentMethodSchema, { - creditCard: '', - bankAccount: '', - paypalEmail: 'user@paypal.com' - }); - - const violations = validate(PaymentMethodSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when no payment method is provided', () => { - const invalid = create(PaymentMethodSchema, { - creditCard: '', - bankAccount: '', - paypalEmail: '' - }); - - const violations = validate(PaymentMethodSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - expect(violations[0].message?.withPlaceholders).toContain('credit_card | bank_account | paypal_email'); - }); - }); - - describe('Multiple AND Requirements', () => { - it('should pass when all `required` fields are provided', () => { - const valid = create(ShippingAddressSchema, { - street: '123 Main St', - city: 'Boston', - postalCode: '02101', - country: 'USA', - state: '' - }); - - const violations = validate(ShippingAddressSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when street is missing', () => { - const invalid = create(ShippingAddressSchema, { - street: '', - city: 'Boston', - postalCode: '02101', - country: 'USA', - state: '' - }); - - const violations = validate(ShippingAddressSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - expect(violations[0].message?.withPlaceholders).toContain('street & city & postal_code & country'); - }); - - it('should fail when multiple fields are missing', () => { - const invalid = create(ShippingAddressSchema, { - street: '123 Main St', - city: '', - postalCode: '', - country: 'USA', - state: '' - }); - - const violations = validate(ShippingAddressSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - }); - - describe('Nested AND/OR Logic', () => { - it('should pass when username and password are both provided', () => { - const valid = create(AccountCreationSchema, { - username: 'johndoe', - password: 'secret123', - oauthToken: '' - }); - - const violations = validate(AccountCreationSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when oauth_token is provided', () => { - const valid = create(AccountCreationSchema, { - username: '', - password: '', - oauthToken: 'oauth_abc123' - }); - - const violations = validate(AccountCreationSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when all fields are provided', () => { - const valid = create(AccountCreationSchema, { - username: 'johndoe', - password: 'secret123', - oauthToken: 'oauth_abc123' - }); - - const violations = validate(AccountCreationSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when only username is provided (missing password)', () => { - const invalid = create(AccountCreationSchema, { - username: 'johndoe', - password: '', - oauthToken: '' - }); - - const violations = validate(AccountCreationSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - expect(violations[0].message?.withPlaceholders).toContain('(username & password) | oauth_token'); - }); - - it('should fail when only password is provided (missing username)', () => { - const invalid = create(AccountCreationSchema, { - username: '', - password: 'secret123', - oauthToken: '' - }); - - const violations = validate(AccountCreationSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - - it('should fail when no fields are provided', () => { - const invalid = create(AccountCreationSchema, { - username: '', - password: '', - oauthToken: '' - }); - - const violations = validate(AccountCreationSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - }); - - describe('Optional Fields (No required_field option)', () => { - it('should pass when all fields are empty', () => { - const valid = create(OptionalDataSchema, { - field1: '', - field2: '', - field3: 0 - }); - - const violations = validate(OptionalDataSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should pass when some fields are set', () => { - const valid = create(OptionalDataSchema, { - field1: 'test', - field2: '', - field3: 0 - }); - - const violations = validate(OptionalDataSchema, valid); - expect(violations).toHaveLength(0); - }); - }); -}); - diff --git a/packages/spine-validation-ts/tests/required.test.ts b/packages/spine-validation-ts/tests/required.test.ts deleted file mode 100644 index d1fad3e..0000000 --- a/packages/spine-validation-ts/tests/required.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Unit tests for `(required)` and `(if_missing)` validation options. - * - * Tests the `(required)` option for ensuring fields have non-default values. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src'; - -import { - RequiredFieldsSchema, - CustomErrorMessagesSchema as RequiredCustomErrorMessagesSchema, - OptionalFieldsSchema, - Status -} from './generated/test-required_pb'; - -describe('Required Field Validation', () => { - describe('Basic Required Fields', () => { - it('should validate message with all `required` fields present', () => { - const valid = create(RequiredFieldsSchema, { - name: 'John Doe', - age: 30, - address: { street: '123 Main St', city: 'Boston' }, - status: Status.ACTIVE, - tags: ['tag1'] - }); - - const violations = validate(RequiredFieldsSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect missing `required` string field', () => { - const invalid = create(RequiredFieldsSchema, { - name: '', // Required but empty. - age: 30, - address: { street: '123 Main St', city: 'Boston' }, - status: Status.ACTIVE, - tags: ['tag1'] - }); - - const violations = validate(RequiredFieldsSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const nameViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'name'); - expect(nameViolation).toBeDefined(); - expect(nameViolation?.message?.withPlaceholders).toBe('A value must be set.'); - }); - - it('should detect missing `required` message field', () => { - const invalid = create(RequiredFieldsSchema, { - name: 'John Doe', - age: 30, - address: undefined, // Required but missing. - status: Status.ACTIVE, - tags: ['tag1'] - }); - - const violations = validate(RequiredFieldsSchema, invalid); - const addressViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'address'); - expect(addressViolation).toBeDefined(); - }); - - it('should detect empty `required` repeated field', () => { - const invalid = create(RequiredFieldsSchema, { - name: 'John Doe', - age: 30, - address: { street: '123 Main St', city: 'Boston' }, - status: Status.ACTIVE, - tags: [] // Required but empty. - }); - - const violations = validate(RequiredFieldsSchema, invalid); - const tagsViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'tags'); - expect(tagsViolation).toBeDefined(); - }); - - it('should detect multiple missing `required` fields', () => { - const invalid = create(RequiredFieldsSchema, { - name: '', - age: 0, - address: undefined, - status: 0, - tags: [] - }); - - const violations = validate(RequiredFieldsSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(3); - }); - }); - - describe('Custom Error Messages', () => { - it('should use custom error message from (`if_missing`) option', () => { - const invalid = create(RequiredCustomErrorMessagesSchema, { - username: '', // Required with custom message. - email: 'valid@example.com' - }); - - const violations = validate(RequiredCustomErrorMessagesSchema, invalid); - const usernameViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'username'); - expect(usernameViolation).toBeDefined(); - expect(usernameViolation?.message?.withPlaceholders).toBe('Username is mandatory for account creation.'); - }); - - it('should use custom error message for field with custom error message', () => { - const invalid = create(RequiredCustomErrorMessagesSchema, { - username: 'johndoe', - email: '' // Required with custom message. - }); - - const violations = validate(RequiredCustomErrorMessagesSchema, invalid); - const emailViolation = violations.find(v => v.fieldPath?.fieldName[0] === 'email'); - expect(emailViolation).toBeDefined(); - expect(emailViolation?.message?.withPlaceholders).toBe('Email address must be provided.'); - }); - }); - - describe('Optional Fields', () => { - it('should not validate optional fields when empty', () => { - const valid = create(OptionalFieldsSchema, { - nickname: '', - score: 0 - }); - - const violations = validate(OptionalFieldsSchema, valid); - expect(violations).toHaveLength(0); - }); - }); -}); - diff --git a/packages/spine-validation-ts/tests/validate.test.ts b/packages/spine-validation-ts/tests/validate.test.ts deleted file mode 100644 index 2e863d2..0000000 --- a/packages/spine-validation-ts/tests/validate.test.ts +++ /dev/null @@ -1,512 +0,0 @@ -/* - * Copyright 2026, TeamDev. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Redistribution and use in source and/or binary forms, with or without - * modification, must retain the above copyright notice and the following - * disclaimer. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Unit tests for `(validate)` and `(if_invalid)` validation options. - * - * Tests recursive validation of nested message fields. - */ - -import { create } from '@bufbuild/protobuf'; -import { validate } from '../src'; - -import { - PersonWithAddressSchema, - AddressSchema, - OrderWithCustomErrorSchema, - CustomerSchema, - TeamWithMembersSchema, - MemberSchema, - CompanyStructureSchema, - DepartmentSchema, - ManagerSchema, - ProfileWithOptionalDataSchema, - OptionalDataSchema as ValidateOptionalDataSchema, - PersonWithoutValidationSchema, - ProductOrderSchema, - ProductDetailsSchema, - ReviewSchema, - ShippingInfoSchema, - ContainerWithEmptyMessageSchema, - EmptyValidatedSchema, - ProjectWithTasksSchema, - TaskSchema -} from './generated/test-validate_pb'; - -describe('Nested Message Validation (validate)', () => { - describe('Basic Nested Validation', () => { - it('should pass when nested message is valid', () => { - const valid = create(PersonWithAddressSchema, { - name: 'John Doe', - address: create(AddressSchema, { - street: '123 Main St', - city: 'Boston', - zipCode: '02101' - }) - }); - - const violations = validate(PersonWithAddressSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should fail when nested message violates constraints', () => { - const invalid = create(PersonWithAddressSchema, { - name: 'John Doe', - address: create(AddressSchema, { - street: '', // Required violation. - city: 'Boston', - zipCode: '02101' - }) - }); - - const violations = validate(PersonWithAddressSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Should have violation for nested field. - const nestedViolation = violations.find(v => - v.fieldPath?.fieldName.includes('address') - ); - expect(nestedViolation).toBeDefined(); - }); - - it('should report violations with correct nested field path', () => { - const invalid = create(PersonWithAddressSchema, { - name: 'John Doe', - address: create(AddressSchema, { - street: '123 Main St', - city: '', // Required violation. - zipCode: '02101' - }) - }); - - const violations = validate(PersonWithAddressSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Check for nested field path: `address.city`. - const cityViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'address' && - v.fieldPath?.fieldName[1] === 'city' - ); - expect(cityViolation).toBeDefined(); - }); - - it('should `validate` multiple constraints in nested message', () => { - const invalid = create(PersonWithAddressSchema, { - name: 'John Doe', - address: create(AddressSchema, { - street: '123 Main St', - city: 'Boston', - zipCode: 'ABCDE' // Pattern violation (should be 5 digits). - }) - }); - - const violations = validate(PersonWithAddressSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const zipViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'address' && - v.fieldPath?.fieldName[1] === 'zip_code' - ); - expect(zipViolation).toBeDefined(); - }); - }); - - describe('Custom Error Messages (if_invalid)', () => { - it('should use default error message when nested validation fails', () => { - const invalid = create(OrderWithCustomErrorSchema, { - orderId: 123, - customer: create(CustomerSchema, { - email: 'invalid-email', // Pattern violation. - age: 25 - }) - }); - - const violations = validate(OrderWithCustomErrorSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Should have parent-level violation with default message. - const parentViolation = violations.find(v => - v.fieldPath?.fieldName.length === 1 && - v.fieldPath?.fieldName[0] === 'customer' && - v.message?.withPlaceholders.includes('Nested message validation failed') - ); - expect(parentViolation).toBeDefined(); - }); - - it('should include both parent and nested violations', () => { - const invalid = create(OrderWithCustomErrorSchema, { - orderId: 123, - customer: create(CustomerSchema, { - email: 'invalid-email', - age: 15 // Violates range [18..120]. - }) - }); - - const violations = validate(OrderWithCustomErrorSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(3); // Parent + 2 nested. - - // Parent violation. - const parentViolation = violations.find(v => - v.fieldPath?.fieldName.length === 1 && - v.fieldPath?.fieldName[0] === 'customer' - ); - expect(parentViolation).toBeDefined(); - - // Nested violations. - const emailViolation = violations.find(v => - v.fieldPath?.fieldName[1] === 'email' - ); - expect(emailViolation).toBeDefined(); - - const ageViolation = violations.find(v => - v.fieldPath?.fieldName[1] === 'age' - ); - expect(ageViolation).toBeDefined(); - }); - }); - - describe('Repeated Message Fields', () => { - it('should `validate` all elements in repeated message field', () => { - const valid = create(TeamWithMembersSchema, { - teamName: 'Engineering', - members: [ - create(MemberSchema, { name: 'Alice', email: 'alice@example.com' }), - create(MemberSchema, { name: 'Bob', email: 'bob@example.com' }) - ] - }); - - const violations = validate(TeamWithMembersSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect violation in one member', () => { - const invalid = create(TeamWithMembersSchema, { - teamName: 'Engineering', - members: [ - create(MemberSchema, { name: 'Alice', email: 'alice@example.com' }), - create(MemberSchema, { name: '', email: 'bob@example.com' }) // Name required. - ] - }); - - const violations = validate(TeamWithMembersSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Check for violation at `members[1].name`. - const nameViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'members' && - v.fieldPath?.fieldName[1] === '1' && - v.fieldPath?.fieldName[2] === 'name' - ); - expect(nameViolation).toBeDefined(); - }); - - it('should detect violations in multiple members', () => { - const invalid = create(TeamWithMembersSchema, { - teamName: 'Engineering', - members: [ - create(MemberSchema, { name: '', email: 'alice@example.com' }), // Name violation. - create(MemberSchema, { name: 'Bob', email: 'invalid' }) // Email violation. - ] - }); - - const violations = validate(TeamWithMembersSchema, invalid); - expect(violations.length).toBeGreaterThanOrEqual(4); // 2 parent + 2 nested. - }); - }); - - describe('Deeply Nested Validation', () => { - it('should `validate` multiple levels of nesting', () => { - const valid = create(CompanyStructureSchema, { - companyName: 'Tech Corp', - department: create(DepartmentSchema, { - deptName: 'Engineering', - manager: create(ManagerSchema, { - name: 'Jane Smith', - email: 'jane@techcorp.com' - }) - }) - }); - - const violations = validate(CompanyStructureSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect violations in deeply nested messages', () => { - const invalid = create(CompanyStructureSchema, { - companyName: 'Tech Corp', - department: create(DepartmentSchema, { - deptName: 'Engineering', - manager: create(ManagerSchema, { - name: '', // Required violation. - email: 'jane@techcorp.com' - }) - }) - }); - - const violations = validate(CompanyStructureSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Check for nested path: `department.manager.name`. - const deepViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'department' && - v.fieldPath?.fieldName[1] === 'manager' && - v.fieldPath?.fieldName[2] === 'name' - ); - expect(deepViolation).toBeDefined(); - }); - }); - - describe('Optional Nested Fields', () => { - it('should pass when optional nested field is not set', () => { - const valid = create(ProfileWithOptionalDataSchema, { - username: 'johndoe' - // `optional_data` not set. - }); - - const violations = validate(ProfileWithOptionalDataSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should `validate` when optional nested field is set', () => { - const valid = create(ProfileWithOptionalDataSchema, { - username: 'johndoe', - optionalData: create(ValidateOptionalDataSchema, { - bio: 'Software engineer', - followers: 100 - }) - }); - - const violations = validate(ProfileWithOptionalDataSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect violations in optional nested field when set', () => { - const invalid = create(ProfileWithOptionalDataSchema, { - username: 'johndoe', - optionalData: create(ValidateOptionalDataSchema, { - bio: 'Software engineer', - followers: -5 // Violates min = 0. - }) - }); - - const violations = validate(ProfileWithOptionalDataSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - }); - }); - - describe('Without Validate Option (Control Group)', () => { - it('should not `validate` nested message without (`validate`) = true', () => { - const invalid = create(PersonWithoutValidationSchema, { - name: 'John Doe', - address: create(AddressSchema, { - street: '', // Would violate required, but not validated. - city: '', // Would violate required, but not validated. - zipCode: '' // Would violate required, but not validated. - }) - }); - - const violations = validate(PersonWithoutValidationSchema, invalid); - expect(violations).toHaveLength(0); // No violations because validate is not enabled. - }); - }); - - describe('Complex Combined Validation', () => { - it('should `validate` complex message with multiple nested fields', () => { - const valid = create(ProductOrderSchema, { - productId: 123, - product: create(ProductDetailsSchema, { - name: 'Widget', - price: 19.99, - tags: ['electronics', 'gadget'] - }), - reviews: [ - create(ReviewSchema, { rating: 5, comment: 'Great!' }), - create(ReviewSchema, { rating: 4, comment: 'Good' }) - ], - shipping: create(ShippingInfoSchema, { - address: create(AddressSchema, { - street: '123 Main St', - city: 'Boston', - zipCode: '02101' - }), - method: 'Express' - }) - }); - - const violations = validate(ProductOrderSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect `distinct` violation in nested product', () => { - const invalid = create(ProductOrderSchema, { - productId: 123, - product: create(ProductDetailsSchema, { - name: 'Widget', - price: 19.99, - tags: ['electronics', 'gadget', 'electronics'] // Duplicate tag. - }), - reviews: [], - shipping: create(ShippingInfoSchema, { - address: create(AddressSchema, { - street: '123 Main St', - city: 'Boston', - zipCode: '02101' - }), - method: 'Express' - }) - }); - - const violations = validate(ProductOrderSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const tagsViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'product' && - v.fieldPath?.fieldName[1] === 'tags' - ); - expect(tagsViolation).toBeDefined(); - }); - - it('should detect violations in repeated reviews', () => { - const invalid = create(ProductOrderSchema, { - productId: 123, - product: create(ProductDetailsSchema, { - name: 'Widget', - price: 19.99, - tags: ['electronics'] - }), - reviews: [ - create(ReviewSchema, { rating: 5, comment: 'Great!' }), - create(ReviewSchema, { rating: 6, comment: 'Good' }) // Rating out of range. - ], - shipping: create(ShippingInfoSchema, { - address: create(AddressSchema, { - street: '123 Main St', - city: 'Boston', - zipCode: '02101' - }), - method: 'Express' - }) - }); - - const violations = validate(ProductOrderSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const ratingViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'reviews' && - v.fieldPath?.fieldName[1] === '1' && - v.fieldPath?.fieldName[2] === 'rating' - ); - expect(ratingViolation).toBeDefined(); - }); - - it('should detect violations in doubly-nested shipping address', () => { - const invalid = create(ProductOrderSchema, { - productId: 123, - product: create(ProductDetailsSchema, { - name: 'Widget', - price: 19.99, - tags: ['electronics'] - }), - reviews: [], - shipping: create(ShippingInfoSchema, { - address: create(AddressSchema, { - street: '123 Main St', - city: 'Boston', - zipCode: 'INVALID' // Pattern violation. - }), - method: 'Express' - }) - }); - - const violations = validate(ProductOrderSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - // Path: `shipping.address.zip_code`. - const zipViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'shipping' && - v.fieldPath?.fieldName[1] === 'address' && - v.fieldPath?.fieldName[2] === 'zip_code' - ); - expect(zipViolation).toBeDefined(); - }); - }); - - describe('Edge Cases', () => { - it('should pass when validating message with no constraints', () => { - const valid = create(ContainerWithEmptyMessageSchema, { - id: 'test-123', - empty: create(EmptyValidatedSchema, { - note: 'Some note' - }) - }); - - const violations = validate(ContainerWithEmptyMessageSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should `validate` nested message with its own nested validation', () => { - const valid = create(ProjectWithTasksSchema, { - projectName: 'Project Alpha', - tasks: [ - create(TaskSchema, { - title: 'Task 1', - priority: 3, - assignees: ['alice', 'bob'] - }) - ], - tags: ['urgent', 'backend'] - }); - - const violations = validate(ProjectWithTasksSchema, valid); - expect(violations).toHaveLength(0); - }); - - it('should detect `distinct` violation in nested task assignees', () => { - const invalid = create(ProjectWithTasksSchema, { - projectName: 'Project Alpha', - tasks: [ - create(TaskSchema, { - title: 'Task 1', - priority: 3, - assignees: ['alice', 'bob', 'alice'] // Duplicate assignee. - }) - ], - tags: ['urgent', 'backend'] - }); - - const violations = validate(ProjectWithTasksSchema, invalid); - expect(violations.length).toBeGreaterThan(0); - - const assigneeViolation = violations.find(v => - v.fieldPath?.fieldName[0] === 'tasks' && - v.fieldPath?.fieldName[1] === '0' && - v.fieldPath?.fieldName[2] === 'assignees' - ); - expect(assigneeViolation).toBeDefined(); - }); - }); -}); - diff --git a/packages/spine-validation-ts/tsconfig.json b/packages/spine-validation-ts/tsconfig.json deleted file mode 100644 index bb72e37..0000000 --- a/packages/spine-validation-ts/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", - "resolveJsonModule": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] -} diff --git a/packages/spine-validation-ts/.gitignore b/packages/validation/.gitignore similarity index 100% rename from packages/spine-validation-ts/.gitignore rename to packages/validation/.gitignore diff --git a/packages/validation/README.md b/packages/validation/README.md new file mode 100644 index 0000000..1917d2a --- /dev/null +++ b/packages/validation/README.md @@ -0,0 +1,508 @@ +# @spine-event-engine/validation + +TypeScript runtime validation for Protobuf messages with [Spine Validation](https://github.com/SpineEventEngine/validation/) options. + +> **๐Ÿ”ง This package is experimental. Its public API is not yet stable.** + +## Features + +- Runtime validation of Protobuf-ES v2 messages against Spine constraints. +- Field, message, oneof, and Spine Time validation options. +- Custom error messages with placeholder substitution. +- Type-safe schemas and generated message values. +- ESM support for Node.js 24 or later. + +## Prerequisites + +Use Buf and `@bufbuild/protoc-gen-es` 2.x to generate the Protobuf-ES schemas +that this package validates. You need Node.js 24 or later, +`@bufbuild/protobuf` 2.10.2 or later (a peer dependency), Buf, and the ES +generator. TypeScript 5.4 or later is required because the public API uses +`NoInfer`. Copy the official `spine/options.proto` files unchanged onto the +Proto import path. `(when)` also needs `spine/time_options.proto` and its +Spine Time imports. + +This package does not support `ts-proto`, `protobuf.js`, handwritten bindings, +or schemas generated by another TypeScript Protobuf generator. + +## Installation + +The current package name is not yet published to the public npm registry while +development remains on `dev`. When a snapshot is released from `master`, install +it with its peer dependency: + +```bash +npm install @spine-event-engine/validation@snapshot @bufbuild/protobuf +``` + +Install the matching generator for local development: + +```bash +npm install --save-dev @bufbuild/protoc-gen-es@2.13.0 +``` + +## Quick Start + +### Step 1: Configure Buf for code generation + +Create `buf.gen.yaml` in the project root: + +```yaml +version: v2 +plugins: + - local: protoc-gen-es + out: src/generated + opt: + - target=ts +``` + +### Step 2: Define validation in Proto files + +```protobuf +syntax = "proto3"; + +import "spine/options.proto"; + +// Describes an account user. +message User { + // Stores the required account name. + string name = 1 [ + (required) = true, + (if_missing).error_msg = "Name is required." + ]; + + // Stores the required account email address. + string email = 2 [ + (required) = true, + (pattern).regex = "^[^@]+@[^@]+\\.[^@]+$", + (pattern).error_msg = "Email must be valid." + ]; + + // Stores the account age for eligibility checks. + int32 age = 3 [(range).value = "[13..120]"]; + + // Lists unique labels for the account. + repeated string tags = 4 [ + (distinct) = true, + (if_has_duplicates).error_msg = "Tags must be unique." + ]; +} +``` + +### Step 3: Generate TypeScript code + +Run Buf after changing the declaration: + +```bash +buf generate +``` + +### Step 4: Use the validation library + +```ts +import { create } from "@bufbuild/protobuf"; +import { validate, Violations } from "@spine-event-engine/validation"; +import { UserSchema } from "./generated/user_pb.js"; + +const user = create(UserSchema, { name: "", email: "not-an-email" }); +const violations = validate(UserSchema, user); + +if (violations.length > 0) { + console.error(Violations.formatAll(violations)); +} +``` + +## API Reference + +### `validate(schema, message)` + +Validates a generated Protobuf message against the Spine constraints declared +on its schema. + +**Parameters:** + +- `schema`: the generated message schema, such as `UserSchema`. +- `message`: a value created for that same generated schema. + +**Returns:** `ConstraintViolation[]`; an empty array means the message is +valid. TypeScript requires the schema and message to be a matching pair. + +```ts +import { create } from "@bufbuild/protobuf"; +import { validate } from "@spine-event-engine/validation"; +import { UserSchema } from "./generated/user_pb.js"; + +const violations = validate(UserSchema, create(UserSchema, { email: "bad" })); +console.log(violations.length); +``` + +### `ValidationConfigurationError` + +Invalid declarations of supported options throw this public error rather than +adding a data violation. Ordinary user data does not trigger it. Its fields are +`code`, `option`, `typeName`, optional `fieldPath`, and optional `cause`. `code` is one of +`UNSUPPORTED_OPTION_TARGET`, `INVALID_OPTION_VALUE`, +`UNKNOWN_FIELD_REFERENCE`, or `INVALID_FIELD_REFERENCE`; `option` has the +canonical name without Proto parentheses. Handle it at the validation boundary: +the branch runs only when the generated schema contains an invalid supported +option declaration. + +```ts +import { create } from "@bufbuild/protobuf"; +import { ValidationConfigurationError, validate } from "@spine-event-engine/validation"; +import { UserSchema } from "./generated/user_pb.js"; + +const user = create(UserSchema, { email: "reader@example.test" }); + +try { + const violations = validate(UserSchema, user); + console.log(violations); +} catch (error) { + if (error instanceof ValidationConfigurationError) { + console.error(`${error.option}: ${error.code}`); + throw error; + } + throw error; +} +``` + +### `Violations.formatAll(violations)` + +Formats a collection as a numbered, human-readable list. + +**Parameter:** `violations`, the `ConstraintViolation[]` returned by `validate`. + +**Returns:** a string with one line per violation, or `"No violations"` for an +empty collection. + +```ts +import { Violations, type ConstraintViolation } from "@spine-event-engine/validation"; + +declare const violations: ConstraintViolation[]; + +console.error(Violations.formatAll(violations)); +``` + +### `Violations.formatMessage(violation)` + +Applies a violation's template placeholders to its values. + +**Parameter:** `violation`, one `ConstraintViolation`. + +**Returns:** formatted text, or `"Validation failed"` when no message exists. + +```ts +import { Violations, type ConstraintViolation } from "@spine-event-engine/validation"; + +declare const violations: ConstraintViolation[]; + +for (const violation of violations) console.error(Violations.formatMessage(violation)); +``` + +### `Violations.failurePath(violation)` + +Returns a violation's Proto field path as dot-separated field names. + +**Parameter:** `violation`, one `ConstraintViolation`. + +**Returns:** a path such as `"user.email"`, or `"unknown"` when no field path +exists. + +```ts +import { Violations, type ConstraintViolation } from "@spine-event-engine/validation"; + +declare const violations: ConstraintViolation[]; + +for (const violation of violations) console.error(Violations.failurePath(violation)); +``` + +## Supported Validation Options + +### Field-level options + +- โœ… **`(required)`** โ€” Requires presence for message, enum, string, bytes, + repeated, and map fields. Set a custom absence message with + `(if_missing).error_msg`. +- โœ… **`(pattern)`** โ€” Tests a string with ECMAScript `RegExp`. +- โœ… **`(min)` / `(max)`** โ€” Applies numeric bounds and supported references. +- โœ… **`(range)`** โ€” Applies numeric ranges written with bracket notation. +- โœ… **`(when)`** โ€” Checks timestamps and Spine Time values against past/future bounds. +- โœ… **`(distinct)`** โ€” Finds duplicate classes in repeated fields and map values. + Set a custom duplicate message with `(if_has_duplicates).error_msg`. +- โœ… **`(validate)`** โ€” Validates nested messages and known `Any` values. +- โœ… **`(goes)`** โ€” Requires a companion field when the declaring field is set. + +### Message-level and oneof options + +- โœ… **`(require)`** โ€” Requires fields using `|` alternatives and `&` conjunctions. +- โœ… **`(choice)`** โ€” Requires a selected `oneof` member when `required = true`. + +### Unsupported or replaced options + +- โŒ **`(set_once)`** and **`(if_set_again)`** require state across validations. +- โŒ **`(if_invalid)`** is not an implemented authoring option. +- โŒ **`(is_required)`** is replaced by `(choice)`. +- โŒ **`(required_field)`** is replaced by `(require)`. +- โŒ **`msg_format`** is replaced by option `error_msg` fields. + +## Complete Proto Example + +```protobuf +syntax = "proto3"; + +import "google/protobuf/any.proto"; +import "google/protobuf/timestamp.proto"; +import "spine/options.proto"; +import "spine/time_options.proto"; + +package example; + +// Identifies an account user. +message UserId { + // Stores the required account user identifier text. + string value = 1 [(required) = true]; +} + +// Describes a shipping address. +message Address { + // Stores the shipping street. + string street = 1 [(required) = true]; + + // Stores the shipping city. + string city = 2 [(required) = true]; + + // Stores the shipping postal code. + string zip_code = 3 [(pattern).regex = "^[0-9]{5}$"]; +} + +// Describes an account user. +message User { + option (require).fields = "id | email"; + + // Stores the required account user identifier. + UserId id = 1 [(required) = true, + (validate) = true]; + + // Stores the account name. + string name = 2 [(required) = true]; + + // Stores the account email address. + string email = 3 [(pattern).regex = "^[^@]+@[^@]+\\.[^@]+$"]; + + // Stores the account age. + int32 age = 4 [(range).value = "[13..120]"]; + + // Lists account labels without duplicates. + repeated string tags = 5 [(distinct) = true]; + + // Stores account preferences without duplicate values. + map<string, string> preferences = 6 [(distinct) = true]; + + // Stores the shipping address. + Address address = 7 [(validate) = true]; + + // Stores additional account details. + google.protobuf.Any details = 8 [(validate) = true]; + + // Stores the shipping tracking number. + string tracking_number = 9 [(goes).with = "carrier"]; + + // Stores the shipping carrier. + string carrier = 10 [(goes).with = "tracking_number"]; + + // Stores the account expiration time. + google.protobuf.Timestamp expires_at = 11 [(when).in = FUTURE]; +} + +// Describes a checkout payment method. +message PaymentMethod { + // Selects one checkout payment option. + oneof method { + option (choice).required = true; + option (choice).error_msg = "Payment method is required."; + + // Stores a card token for checkout. + string card_token = 1; + + // Stores a bank account for checkout. + string bank_account = 2; + } +} +``` + +Use `UserId`, `ProductId`, and `CategoryId` for account, catalog, and category +identifiers. Each ID is a domain message, so its `value` stays connected to what +it identifies and can gain more ID details later. Provide an ID as +`{ value: "user-ada" }`, `{ value: "prod-1" }`, or +`{ value: "cat-keyboards" }`. + +For `(when)`, use `Timestamp` or a supported Spine Time message type and set +the direction, for example `(when).in = FUTURE`. + +## Validation Behavior + +### Proto3 presence + +Proto3 numeric values default to `0`, strings to `""`, and booleans to `false`. +`(required)` is defined for message, enum, string, bytes, repeated, and map +fields. Use numeric constraints for numeric values. Use +`(if_missing).error_msg` to customize the message emitted when a required +field is absent. + +### Nested messages and `Any` + +`(validate) = true` recursively validates singular messages, repeated-message +elements, map-message values, and known `google.protobuf.Any` payloads. It +reports leaf violations only. Nested leaves keep the entry type name and full +Proto field path. Empty and unknown `Any` values are valid. + +### Regular expressions + +`(pattern)` uses ECMAScript `RegExp`. Official Spine documentation names Java +`Pattern` as its syntax baseline, but Java-specific syntax, flags, and matching +rules are not guaranteed. Use portable expressions and explicit anchors. + +Pattern results can contain a nested type name and local or indexed path where +other options report the entry type and full field path. Use a static +`(pattern).error_msg` when that distinction matters to a display. + +### Field dependencies + +```protobuf +// Describes shipping details for an order. +message ShippingDetails { + // Stores the shipment tracking number. + string tracking_number = 1 [(goes).with = "carrier"]; + + // Stores the shipment carrier. + string carrier = 2 [(goes).with = "tracking_number"]; +} +``` + +### Required field combinations + +```protobuf +// Describes ways to contact an account user. +message ContactInfo { + option (require).fields = "phone & country_code | email"; + + // Stores the contact phone number. + string phone = 1; + + // Stores the contact country code. + string country_code = 2; + + // Stores the contact email address. + string email = 3; +} +``` + +This accepts either `email` or both `phone` and `country_code`. + +### Oneof constraints + +```protobuf +// Describes a checkout payment choice. +message Payment { + // Selects one checkout payment option. + oneof method { + option (choice).required = true; + + // Stores a payment card. + string card = 1; + + // Stores a bank payment reference. + string bank = 2; + } +} +``` + +### Numeric values and references + +`(min)`, `(max)`, and `(range)` parse supported integer and floating-point +declarations. Integer values preserve 64-bit precision. A bound can name a +scalar field through a dotted path. Invalid numeric text, missing fields, or +incompatible references throw `ValidationConfigurationError`. + +### Distinct collections + +`(distinct)` uses Protobuf-ES equality, not JavaScript object identity. For +`[A, A, A, B, B, C]`, it produces one violation for `A` and one for `B`. +`${field.value}` is the collection and `${field.duplicates}` is the duplicate +class. Use `(if_has_duplicates).error_msg` to customize that violation. + +### Spine Time `(when)` + +`(when)` supports `Timestamp`, `YearMonth`, `LocalDate`, `LocalDateTime`, +deprecated `OffsetDateTime`, and `ZonedDateTime`. `TIME_UNDEFINED` disables the +option. Singular descriptor-default messages are skipped; list and map values +are checked independently. Its diagnostic value `when.in` is `past` or `future`. + +### Violation paths and messages + +Every standard `ConstraintViolation` includes the entry message `typeName`, a +field path when a field is responsible, and a descriptor-packed offending value +when one exists. A nested failure keeps the entry message type and joins its +Proto field names with dots. Collection indices and map keys are not added to +the path. + +Message `(require)` and oneof `(choice)` failures have no field path. In those +cases `Violations.failurePath()` returns `"unknown"`. `Violations.formatAll()` +uses the same fallback and presents one numbered line per violation. + +Option messages can contain placeholders. Standard option messages use keys +such as `${field.path}`, `${field.type}`, `${field.value}`, and +`${parent.type}`; options may add keys such as `${range.value}` or +`${when.in}`. `Violations.formatMessage()` replaces the available values. + +### Ordering + +Validation evaluates message `(require)`, then fields in descriptor order, then +oneof `(choice)`. Each field uses a fixed option sequence. The current order is +deterministic and useful for diagnostics, but consumers should not depend on it +as a compatibility guarantee. + +### Configuration failures + +Configuration failures are distinct from invalid message data. An unsupported +target type, malformed numeric declaration, or nonexistent field reference +throws `ValidationConfigurationError`. Catch that error where Proto +declarations are loaded or tested; ordinary request handling usually receives +only returned data violations. + +### Time conversion details + +Spine Time `ZonedDateTime` conversion uses installed time-zone data and +Temporal-compatible gap/overlap resolution. Converted values must fit the JVM +`Timestamp` instant range from year 1 through year 9999. Invalid conversions +and values outside that range throw `RangeError`. + +## Testing and Development + +For setup, supported development environments, commands, and extension +workflows, see the [development guide](docs/development.md). For branches, +pull requests, and version commits, see the +[contribution guide](docs/contributing.md). + +## Architecture + +`validate` builds a descriptor registry from the entry schema file and its +imports, then invokes option implementations in the documented sequence. The +package entry point intentionally exposes only `validate`, `Violations`, +`ValidationConfigurationError`, and diagnostic type declarations. Option +modules, descriptor lookup, placeholder formatting, and traversal objects are +internal details. + +The [architecture guide](docs/architecture.md) describes source areas. The +[validation contract](docs/validation-contract.md) gives exact targets, +diagnostics, numeric grammar, and time conversion rules. + +## Contributing + +See the [development guide](docs/development.md) for local setup, generated +inputs, extension workflows, and verification. See the +[contribution guide](docs/contributing.md) for review and delivery practices. + +## License + +Apache License 2.0. diff --git a/packages/spine-validation-ts/buf.gen.yaml b/packages/validation/buf.gen.yaml similarity index 77% rename from packages/spine-validation-ts/buf.gen.yaml rename to packages/validation/buf.gen.yaml index c2a80a0..687e33a 100644 --- a/packages/spine-validation-ts/buf.gen.yaml +++ b/packages/validation/buf.gen.yaml @@ -4,3 +4,4 @@ plugins: out: src/generated opt: - target=ts + - import_extension=js diff --git a/packages/validation/buf.yaml b/packages/validation/buf.yaml new file mode 100644 index 0000000..3220c20 --- /dev/null +++ b/packages/validation/buf.yaml @@ -0,0 +1,35 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD + ignore_only: + PACKAGE_DEFINED: + - proto/spine/options.proto + - proto/spine/time_options.proto + PACKAGE_SAME_JAVA_PACKAGE: + - proto/spine/options.proto + - proto/spine/time_options.proto + PACKAGE_VERSION_SUFFIX: + - proto/spine/base/field_path.proto + - proto/spine/validate/error_message.proto + - proto/spine/validate/validation_error.proto + - proto/spine/time/time.proto + FIELD_LOWER_SNAKE_CASE: + - proto/spine/options.proto + - proto/spine/time_options.proto + ENUM_NO_ALLOW_ALIAS: + - proto/spine/options.proto + - proto/spine/time_options.proto + ENUM_VALUE_PREFIX: + - proto/spine/options.proto + - proto/spine/time/time.proto + - proto/spine/time_options.proto + ENUM_ZERO_VALUE_SUFFIX: + - proto/spine/options.proto + - proto/spine/time/time.proto + - proto/spine/time_options.proto +breaking: + use: + - FILE diff --git a/packages/validation/docs/README.md b/packages/validation/docs/README.md new file mode 100644 index 0000000..7880214 --- /dev/null +++ b/packages/validation/docs/README.md @@ -0,0 +1,14 @@ +# Validation development reference + +The [package guide](../README.md) is the authoritative consumer guide. This +directory is repository-only reference material for maintainers and automated +contributors; it is not included in the published package. + +- [Architecture](architecture.md) explains runtime ownership and source layout. +- [Validation contract](validation-contract.md) records exact supported + behavior, diagnostics, configuration errors, and limitations. +- [Development guide](development.md) covers prerequisites, installation, + commands, extension workflows, and verification. +- [Contribution guide](contributing.md) covers review-ready changes and + repository delivery practices. +- [API reference](api/reference/index.html) is generated by TypeDoc. diff --git a/packages/validation/docs/architecture.md b/packages/validation/docs/architecture.md new file mode 100644 index 0000000..b04cbe7 --- /dev/null +++ b/packages/validation/docs/architecture.md @@ -0,0 +1,49 @@ +# Architecture + +The [package guide](../README.md) describes the public interface. This page +maps the repository-owned source areas. + +| Area | Responsibility | +| ---------------------------- | ---------------------------------------------------------------------------------------- | +| `src/index.ts` | Small public API and generated diagnostic type exports. | +| `src/validation.ts` | `validate`, violation presentation, root registry construction, and fixed orchestration. | +| `src/validation-contract.ts` | Context, paths, packed values, template envelopes, and descriptor access. | +| `src/options/` | One owner for each option family. | +| `src/options-registry.ts` | Generated option-extension lookup. | +| `proto/` | Immutable upstream inputs plus project-owned supporting Proto files. | +| `packages/example/` | Runnable consumer schemas, scenarios, console presentation, and tests. | +| `scripts/` | Deterministic documentation, source, package, and generated-output checks. | + +Generated TypeScript is disposable and ignored. Official Spine Proto files are +copied unchanged and checked by checksum; they are not a local style-editing target. + +## Runtime flow + +1. Buf generates Protobuf-ES schemas with descriptors and option extensions. +2. `validate(schema, message)` pairs the generated schema with its message + shape, creates the root context, and builds a registry from the schema file + dependency closure. +3. The runtime evaluates message `(require)`, each field in descriptor order, + then oneof `(choice)`. +4. Option owners append data violations or throw a + `ValidationConfigurationError` for invalid supported declarations. +5. Consumers render diagnostics with `Violations.formatMessage()` or + `Violations.formatAll()`. + +The current order is deterministic but not a public compatibility guarantee. + +## Sources of truth + +When changing behavior or a claim, read the official upstream Proto +documentation at the version used here, the current technical specification, +and runtime code and behavior tests. This reference summarizes those sources. +Consult the JVM implementation only when a direct behavior comparison is needed. + +The validation contract documents the open Java `Pattern` boundary: this +runtime uses ECMAScript `RegExp` and neither implements nor promises Java +regular-expression compatibility. + +## TypeDoc + +TypeDoc is generated below this directory at [API reference](api/reference/index.html). +It covers the public entry point and requires complete exported declarations. diff --git a/packages/validation/docs/contributing.md b/packages/validation/docs/contributing.md new file mode 100644 index 0000000..f8524c6 --- /dev/null +++ b/packages/validation/docs/contributing.md @@ -0,0 +1,68 @@ +# Contributing to Spine Validation for TypeScript + +Thank you for improving `@spine-event-engine/validation`. For setup, commands, +and detailed change recipes, start with the [development guide](development.md). +The [package guide](../README.md) is the consumer-facing API reference. + +## Prepare Your Change + +1. Create a branch from `dev` and check `git status` before editing. Keep + unrelated changes out of your pull request. +2. Use Node.js 24.18.0 from [`.node-version`](../../../.node-version). +3. Install with `corepack pnpm install --frozen-lockfile`, then use + `corepack pnpm` for repository commands. Direct invocation avoids installing + a system shim beside Node. A cold Corepack cache may need network access for + pnpm; a cold pnpm store may then download the locked packages. +4. Do not edit generated TypeScript or immutable vendored Spine Proto files. + +For runtime behavior, write or change a focused failing test first, make the +smallest passing change, and keep valid example schemas separate from invalid +test fixtures. + +## Test and Document + +Run focused checks while working. From a clean checkout, build before the root +example test: + +```bash +corepack pnpm build +corepack pnpm test:example +``` + +`corepack pnpm build` creates the validation packageโ€™s `dist` output needed by +root `corepack pnpm test:example`. Before opening a pull request, run the +checks appropriate to the change; `corepack pnpm verify` is the complete local +gate. + +Update public API declarations, package documentation, and executable examples +with any consumer-visible behavior. `corepack pnpm docs:check` validates maintained +Markdown links and TypeScript examples, checks public imports and documentation +rules, and generates TypeDoc. + +## Commit and Open a Pull Request + +Write focused conventional commits. Target pull requests at `dev`, describe the +behavior change and checks run, and request review after the branch is ready. +Do not merge or push `master` without explicit human approval. + +## Version Changes + +For a root framework-version change, update the `version` fieldโ€”and only that +fieldโ€”in all three synchronized manifests: root `package.json`, +`packages/validation/package.json`, and `packages/example/package.json`. Make +that change in an isolated version-only commit with this exact subject: + +```text +Bump version -> <version> +``` + +Do not combine the version change with source, documentation, dependency, +lockfile, or generated-output work. The lockfile does not encode workspace +manifest versions. The full policy is in +[`BUILD_PROTOCOL.md`](../../../build-protocol/BUILD_PROTOCOL.md#framework-version-changes). + +## Need Help? + +Open an issue with the expected behavior, a minimal Proto or TypeScript +reproduction, the command and output, and the Node and pnpm versions. Do not +include credentials, tokens, or sensitive message payloads. diff --git a/packages/validation/docs/development.md b/packages/validation/docs/development.md new file mode 100644 index 0000000..c2577cf --- /dev/null +++ b/packages/validation/docs/development.md @@ -0,0 +1,307 @@ +# Development Guide + +This guide is for people maintaining this repository. To install and use the +published library, see the [package guide](../README.md). To prepare a pull +request, see the [contribution guide](contributing.md). + +## System Requirements + +The published package supports Node.js 24 or later. Repository development is +verified with Node.js 24.18.0, recorded in +[`.node-version`](../../../.node-version), and pnpm 11.9.0, pinned by the root +`packageManager` field. The workspace is ESM, uses Vitest, and generates +Protobuf-ES v2 schemas. + +GitHub Actions verifies the repository on Ubuntu. Its POSIX-oriented scripts are +suitable for Linux and macOS, and for Windows through WSL. Native Windows is not +CI-verified. This is development-environment guidance, not a restriction on the +published packageโ€™s Node.js runtime support. + +### Supported and verified environments + +| Surface | Supported | Verified here | +| ------------------------- | ------------------------------------ | --------------------------- | +| Published package runtime | Node.js 24 or later | Node.js 24.18.0 | +| Repository development | Linux, macOS, or Windows through WSL | Ubuntu | +| Package manager | pnpm 11.9.0 through Corepack | pnpm 11.9.0 | +| Module format | ESM | ESM | +| Test runner | Vitest | Vitest 4.1.9 | +| Protobuf runtime | Protobuf-ES v2 | `@bufbuild/protobuf` 2.13.0 | + +Buf and `protoc-gen-es` are workspace development dependencies; no global +installation is needed. Use pnpm rather than creating a `package-lock.json`. + +## Clean Installation and Build Order + +From a fresh checkout at the repository root: + +```bash +corepack pnpm install --frozen-lockfile +corepack pnpm build +corepack pnpm test:validation +corepack pnpm test:example +``` + +Direct `corepack pnpm` invocation avoids installing a system shim beside Node. +On a cold host, Corepack may need network access to obtain pnpm 11.9.0; a cold +pnpm store then needs network access to download the packages locked in +[pnpm-lock.yaml](../../../pnpm-lock.yaml). The lockfile remains the dependency +authority. + +`pnpm build` generates schemas before compiling TypeScript project references. +Root `pnpm test:example` expects the validation packageโ€™s `dist` directory, so +the ordered `pnpm build` then `pnpm test:example` sequence is safe in a clean +checkout. + +Use `pnpm example` to build and run the console example. Use `pnpm example:run` +only after a workspace build when you want to run compiled output. + +## Repository Layout + +```text +validation-ts/ +โ”œโ”€โ”€ packages/ +โ”‚ โ”œโ”€โ”€ validation/ published package: source, tests, Proto inputs, and docs +โ”‚ โ””โ”€โ”€ example/ executable consumer and its Vitest scenarios +โ”œโ”€โ”€ scripts/ repository verification and documentation checks +โ”œโ”€โ”€ build-protocol/ repository governance and release policy +โ”œโ”€โ”€ pnpm-lock.yaml locked workspace dependency graph +โ””โ”€โ”€ package.json workspace scripts and pinned package-manager version +``` + +Generated TypeScript is ignored under package `src/generated/` and test +generated directories. Distribution output is also generated. Do not hand-edit +either; run the relevant script. + +## Commands + +Run these commands from the repository root after the clean installation steps. + +| Command | Use it for | +| ----------------------------------- | ---------------------------------------------------------------------------- | +| `corepack pnpm generate` | Generate package, test, and example Protobuf-ES schemas. | +| `corepack pnpm build` | Generate schemas and compile TypeScript project references. | +| `corepack pnpm typecheck:generated` | Build and typecheck package and example tests. | +| `corepack pnpm test:validation` | Generate schemas and run validation-package Vitest tests. | +| `corepack pnpm test:example` | Run example tests after `corepack pnpm build` has created validation `dist`. | +| `corepack pnpm docs:check` | Check maintained docs and examples and generate TypeDoc. | +| `corepack pnpm source:check` | Check project-owned TypeScript and Proto conventions. | +| `corepack pnpm proto:verify` | Verify local immutable Proto checksums against the recorded manifest. | +| `corepack pnpm proto:lint` | Lint project-owned Proto while honoring immutable-input exceptions. | +| `corepack pnpm format:check` | Check Prettier formatting without modifying files. | +| `corepack pnpm lint` | Run ESLint. | +| `corepack pnpm verify` | Run the complete local and CI gate. | + +`corepack pnpm verify` includes Node compatibility, Proto checksum verification, generation, +typechecking, source and formatting checks, linting, deterministic generation, +coverage, documentation, Proto linting, build output, the executable example, +package contents, and Git-diff checks. + +## Copy-ready Workflows + +### Add a newly supported official validation option + +There is no consumer registration API. A newly supported official Spine option +follows the repository-owned pattern used by `(when)`; it does not alter an +immutable upstream Proto file. + +1. Confirm the option is defined by the official Proto input already vendored + under `packages/validation/proto/spine/`. `(when)` is defined by + `packages/validation/proto/spine/time_options.proto`. If the official input + is absent or must change, stop: it needs the separate immutable-Proto intake + described below. +2. Add the smallest valid declaration to + `packages/validation/tests/proto/test-when.proto` (or a new project-owned + fixture beside it). For `(when)`, `TimeValidation.future_timestamp` declares + `google.protobuf.Timestamp future_timestamp = 2 [(when).in = FUTURE];`. +3. Add a failing generated-schema test in + `packages/validation/tests/when.test.ts`. The existing model fixes the clock + with `ValidationClock.set()` and validates `TimeValidationSchema` created by + the fixture. Start with the expected field path and diagnostic shape, not an + implementation detail. +4. Run `corepack pnpm generate`. This refreshes the generated extension module at + `packages/validation/src/generated/spine/time_options_pb.ts` and the test + fixture module at `packages/validation/tests/generated/test-when_pb.ts`. + Generated files are output, not editing targets. +5. Import the generated extension in + `packages/validation/src/options-registry.ts` and add its stable name to + `optionRegistry`. `(when)` imports `when` from + `./generated/spine/time_options_pb.js` and registers it as `when`. +6. Implement the option owner under `packages/validation/src/options/`; the + `(when)` owner is `packages/validation/src/options/when.ts` and reads its + extension with `ValidationOptions.get("when")`. Add the narrowest validation, + configuration-error, and diagnostic behavior needed by the test. +7. Wire field-level behavior into the ordered `fieldValidators` array in + `packages/validation/src/validation.ts`. `(when)` calls `When.validate`. + Use `ValidationOrchestration.adaptAllFieldsValidator()` only when the option + already evaluates all fields together, as `(pattern)` does; ordinary + field-level options do not need a new orchestration abstraction. +8. Update the [package guide](../README.md), + [validation contract](validation-contract.md), and the executable example + when the option is useful to consumers. Add public TSDoc only if an exported + API changes. + +Run the sequence below after each relevant step: + +```bash +corepack pnpm generate +corepack pnpm exec vitest run packages/validation/tests/when.test.ts +corepack pnpm test:validation +corepack pnpm proto:lint +corepack pnpm docs:check +``` + +### Modify existing behavior test-first + +For a concrete `(when)` example, suppose equality with the clock must become a +failure instead of the current accepted boundary. In +`packages/validation/tests/when.test.ts`, change the first testโ€™s final +assertion before changing source: + +```text +import { create } from "@bufbuild/protobuf"; +import { ValidationClock } from "../src/clock.js"; +import { validate } from "../src/index.js"; +import { TimeValidationSchema } from "./generated/test-when_pb.js"; + +const now = { seconds: 1_704_067_200n, nanos: 0 }; + +beforeEach(() => ValidationClock.set(() => now)); +afterEach(() => ValidationClock.set()); + +it("rejects equality with now", () => { + const message = create(TimeValidationSchema, { + pastTimestamp: now, + futureTimestamp: now, + disabled: { seconds: 0n, nanos: 0 }, + }); + + expect(validate(TimeValidationSchema, message)).toHaveLength(2); +}); +``` + +That assertion fails against the current behavior. The smallest implementation +target is the `valid` comparison in `When.validate` in +`packages/validation/src/options/when.ts`; update only the relevant inclusive +comparison, then rerun: + +```bash +corepack pnpm generate +corepack pnpm exec vitest run packages/validation/tests/when.test.ts +corepack pnpm exec vitest run packages/validation/tests/when-contract.test.ts +corepack pnpm test:validation +``` + +Use generated schemas and real descriptors instead of mocks. Add an integration +test when traversal, nesting, paths, or option composition changes. + +### Change project-owned Proto fixtures or immutable upstream inputs + +For a project-owned fixture, edit a file under +`packages/validation/tests/proto/` or `packages/example/proto/`, then run: + +```bash +corepack pnpm generate +corepack pnpm proto:lint +corepack pnpm test:validation +``` + +Never edit vendored Spine inputs such as `spine/options.proto`, +`spine/time_options.proto`, or `spine/time/time.proto`. Before fetching, +changing, or recording a vendored file or its manifest entry, obtain separate +approval for immutable-Proto intake and compatibility review. The approved +intake retrieves the input byte-for-byte at an exact upstream commit and records +its repository, commit, path, URL, retrieval date, local path, and SHA-256 in +the source manifest. Then run `corepack pnpm proto:verify`, generation, and linting. +See [the immutable Proto guide](../../../build-protocol/proto/README.md). + +### Update a public API + +1. Update `packages/validation/src/index.ts` and the affected exported source + declaration. +2. Update the package README TypeScript example and public TSDoc together. +3. Run: + +```bash +corepack pnpm generate +corepack pnpm typecheck:generated +corepack pnpm test:validation +corepack pnpm docs:check +``` + +### Update the executable example + +1. Update the valid runnable Proto under `packages/example/proto/` and the + matching scenario in `packages/example/src/scenarios.ts`. +2. Update `packages/example/tests/scenarios.test.ts` and + `packages/example/README.md`. +3. Run: + +```bash +corepack pnpm generate +corepack pnpm build +corepack pnpm test:example +corepack pnpm docs:check +``` + +Invalid option declarations belong only under `packages/example/proto/testing/`. + +### Update documentation + +1. Update the consumer-facing package README, example README, or package-local + reference that owns the claim. +2. Run: + +```bash +corepack pnpm docs:check +corepack pnpm format:check +git diff --check +``` + +### Update a dependency + +1. Update the owning manifest: root `package.json` for shared tooling, + `packages/validation/package.json` for published-package dependencies, or + `packages/example/package.json` for example-only dependencies. +2. Refresh the lockfile and verify the exact workspace: + +```bash +corepack pnpm install --lockfile-only +corepack pnpm install --frozen-lockfile +corepack pnpm typecheck:generated +corepack pnpm test:validation +corepack pnpm build +corepack pnpm test:example +``` + +Do not edit the lockfile by hand. Explain why the dependency is needed and +check its Node.js and TypeScript compatibility before proposing the change. + +### Change the framework version + +Follow the concise version-change rule in [the contribution guide](contributing.md). + +## Troubleshooting + +| Symptom | Likely cause and resolution | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| pnpm is not available as a shell command | Run the command as `corepack pnpm <script>`; this does not install a system shim. | +| Corepack cannot obtain pnpm | Connect the host to the network so Corepack can download the pinned pnpm release. | +| Install cannot find packages | Connect the host to the network so pnpm can fill its cold package store. | +| Example root test cannot resolve validation `dist` | Run `corepack pnpm build`, then rerun `corepack pnpm test:example`. | +| Generated imports or fixtures are missing | Run `corepack pnpm generate`; never add generated files manually. | +| `corepack pnpm proto:verify` reports a checksum mismatch | Restore the immutable file; use the separate approved intake procedure for an intended upstream replacement. | +| A docs TypeScript snippet fails | Import only public package exports and use ESM `.js` relative imports. | +| A time check differs by zone or range | Read the `(when)` conversion details in the validation contract and include the exact input in a focused test. | + +## Verification Before a Pull Request + +Inspect the diff and run the checks that cover the change. For broad changes, +run the complete gate: + +```bash +corepack pnpm verify +``` + +The contribution guide explains branches, commits, pull requests, and the +restriction on `master`. diff --git a/packages/validation/docs/validation-contract.md b/packages/validation/docs/validation-contract.md new file mode 100644 index 0000000..dd7c077 --- /dev/null +++ b/packages/validation/docs/validation-contract.md @@ -0,0 +1,90 @@ +# Validation contract + +The [package guide](../README.md) is the consumer entry point. This reference +defines the currently implemented Spine option surface. + +The official [upstream options source](../proto/spine/options.proto) defines +option intent. Runtime code and generated-schema tests define implemented +TypeScript behavior where it differs. `validate(schema, message)` returns +ordered `ConstraintViolation` records for invalid data and throws +`ValidationConfigurationError` for invalid supported declarations. It starts +with message `(require)`, evaluates fields in descriptor order through a fixed +internal sequence, and finishes with oneof `(choice)`; that order is not a +public compatibility promise. + +## Violations + +For validators that use the standard `ConstraintViolation` structure, `typeName` is the fully qualified entry schema +name even for nested leaves. `fieldPath.fieldName` contains unqualified Proto +names joined by dots, without list indices or map keys. Message `(require)` and +oneof `(choice)` failures have an empty path. `fieldValue` is a descriptor-packed +`Any` when an offending value exists. `message` is present for these validators +and may have an empty template. + +`Violations.failurePath()` returns the dot-separated path or `"unknown"`. +`Violations.formatMessage()` substitutes the template map, and +`Violations.formatAll()` produces the numbered collection presentation. Custom +`error_msg` overrides a default. These placeholders use namespaced +keys such as `${field.path}`, `${field.type}`, `${field.value}`, and +`${parent.type}`. + +## Implemented options + +| Option | Valid target and behavior | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `(required)` | Message, enum, string, bytes, repeated, and map fields. It rejects absent or default presence targets; numeric and boolean scalars are unsupported. | +| `(pattern)` | String fields evaluated with ECMAScript `RegExp`. Pattern results can use a different nested path and template shape; use a static `error_msg` when portable diagnostics matter. | +| `(min)` / `(max)` | Singular or repeated numeric scalars with exact inclusive/exclusive bounds and supported field references. | +| `(range)` | Singular or repeated numeric scalars using bracket notation, with inclusive/exclusive endpoints. | +| `(when)` | `Timestamp` and Spine `YearMonth`, `LocalDate`, `LocalDateTime`, deprecated `OffsetDateTime`, or `ZonedDateTime`, including lists/maps. `TIME_UNDEFINED` disables it; singular defaults are skipped and collection elements are checked. | +| `(distinct)` | Repeated or map fields; one violation per duplicated Protobuf-ES equality class. | +| `(validate)` | Singular/repeated/map message values and known `Any` payloads; returns descendant leaves only. | +| `(goes)` | A presence-supported field whose named companion must also be present. | +| `(require)` | Message expression using `\|` alternatives and `&` conjunctions over fields or oneof names. | +| `(choice)` | Oneof; when `required = true`, rejects no selected member. | + +`(validate)` preserves the root type name for nested leaves. Empty and unknown +`Any` values are valid. `(distinct)` follows descriptor-aware Protobuf-ES +equality, not JavaScript object identity. + +## Numeric and references + +Integer declarations accept signed decimal digits inside the target type range. +Float and double declarations require a decimal point, with an optional +exponent. A bound may reference a dotted singular-message path ending in a +numeric scalar. Missing, repeated, map, or incompatible references are +configuration errors. A range is `[|(` + lower + `..` + upper + `]|)`, with +nonempty bounds and lower not greater than upper. + +## Configuration errors + +`ValidationConfigurationError` exposes `code`, `option`, `typeName`, optional +`fieldPath`, and optional `cause`; `option` is canonical and has no Proto +parentheses. + +| Code | Meaning | +| --------------------------- | ------------------------------------------------------------------- | +| `UNSUPPORTED_OPTION_TARGET` | An option was declared on an unsupported field type or cardinality. | +| `INVALID_OPTION_VALUE` | A required declaration value is empty or malformed. | +| `UNKNOWN_FIELD_REFERENCE` | A named companion, bound, or require token does not exist. | +| `INVALID_FIELD_REFERENCE` | A named field exists but cannot serve as that option's target. | + +The human-readable error message is not a stable parsing surface. `(pattern)` +handles unsupported targets by ignoring them and malformed +regular expressions produce an ordinary violation. + +## Limitations + +Use `(choice)` instead of deprecated `(is_required)` and `(require)` instead +of deprecated `(required_field)`. `(set_once)`, `(if_set_again)`, `(if_invalid)`, +and deprecated `msg_format` are unsupported. + +Official Spine documentation uses Java `Pattern` as its syntax baseline. The runtime +uses ECMAScript `RegExp`, does not contain a Java-pattern engine, and does not +promise Java dialect, flags, or full-match equivalence. Use portable, anchored +expressions. + +Spine Time conversion follows Temporal compatible gap/overlap resolution and +the installed tzdb. Converted values must fit the JVM `Timestamp` instant range +from year 1 through year 9999; invalid or out-of-range conversions throw +`RangeError`. diff --git a/packages/validation/package.json b/packages/validation/package.json new file mode 100644 index 0000000..0e89fc7 --- /dev/null +++ b/packages/validation/package.json @@ -0,0 +1,61 @@ +{ + "name": "@spine-event-engine/validation", + "version": "2.0.0-snapshot.7", + "description": "TypeScript validation library for Protobuf messages with Spine Validation options", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "types": "dist/index.d.ts", + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "generate": "buf generate", + "generate:tests": "cd tests && buf generate", + "build": "pnpm generate && tsc -b", + "test": "pnpm generate && vitest run", + "test:watch": "pnpm generate && vitest", + "test:coverage": "pnpm generate && vitest run --coverage", + "proto:lint": "buf lint && cd tests && buf lint", + "prepublishOnly": "pnpm build" + }, + "keywords": [ + "protobuf", + "validation", + "spine", + "typescript", + "protobuf-es" + ], + "author": "Spine Event Engine team <developers@spine.io>", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/SpineEventEngine/validation-ts.git", + "directory": "packages/validation" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.10.2" + }, + "dependencies": { + "temporal-polyfill": "1.0.1" + }, + "devDependencies": { + "@bufbuild/buf": "1.72.0", + "@bufbuild/protobuf": "2.13.0", + "@bufbuild/protoc-gen-es": "2.13.0", + "@types/node": "24.13.2", + "typescript": "6.0.3" + }, + "files": [ + "dist", + "proto", + "buf.yaml", + "buf.gen.yaml", + "README.md", + "!tests" + ] +} diff --git a/packages/spine-validation-ts/proto/spine/base/field_path.proto b/packages/validation/proto/spine/base/field_path.proto similarity index 100% rename from packages/spine-validation-ts/proto/spine/base/field_path.proto rename to packages/validation/proto/spine/base/field_path.proto diff --git a/packages/spine-validation-ts/proto/spine/options.proto b/packages/validation/proto/spine/options.proto similarity index 100% rename from packages/spine-validation-ts/proto/spine/options.proto rename to packages/validation/proto/spine/options.proto diff --git a/packages/validation/proto/spine/time/time.proto b/packages/validation/proto/spine/time/time.proto new file mode 100644 index 0000000..4d2d6e0 --- /dev/null +++ b/packages/validation/proto/spine/time/time.proto @@ -0,0 +1,221 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +package spine.time; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_multiple_files = true; +option java_outer_classname = "TimeProto"; +option java_package = "io.spine.time"; + +// Enum representing the 12 months of the year. +enum Month { + MONTH_UNDEFINED = 0; + JANUARY = 1; + FEBRUARY = 2; + MARCH = 3; + APRIL = 4; + MAY = 5; + JUNE = 6; + JULY = 7; + AUGUST = 8; + SEPTEMBER = 9; + OCTOBER = 10; + NOVEMBER = 11; + DECEMBER = 12; +} + +// A combination of year and month in the ISO-8601 calendar system, such as `2018-06`. +// +// The purpose of this type is to store values like "June 2018". +// +// Instances of `YearMonth` can be ordered chronologically. +// +message YearMonth { + option (is).java_type = "YearMonthTemporal"; + option (compare_by) = { + field: "year" + field: "month" + }; + + // A year with `1` being year 1 CE and `-1` being 1 BC. + int32 year = 1 [(min).value = "-999999999", (max).value = "999999999"]; + + // One of 12 Gregorian calendar months specified by `Month`. + Month month = 2 [(required) = true]; +} + +// A day of week. +// +// The int value follows the ISO-8601 standard, from 1 (Monday) to 7 (Sunday). +// +enum DayOfWeek { + DOW_UNDEFINED = 0; + MONDAY = 1; + TUESDAY = 2; + WEDNESDAY = 3; + THURSDAY = 4; + FRIDAY = 5; + SATURDAY = 6; + SUNDAY = 7; +} + +// A date without a time-zone. +// +// Use this message for describing a date (e.g. a birthday). +// +// Instances of `LocalDate` can be ordered chronologically. +// +message LocalDate { + option (is).java_type = "LocalDateTemporal"; + option (compare_by) = { + field: "year" + field: "month" + field: "day" + }; + + // A year with `1` being year 1 CE and `-1` being 1 BC. + int32 year = 1 [(min).value = "-999999999", (max).value = "999999999"]; + + // One of 12 Gregorian calendar months specified by `Month`. + Month month = 2 [(required) = true]; + + // A day that must be from 1 to 31 and valid for the year and month. + // + // In generated code for Kotlin/Java this is checked by + // `io.spine.time.validation.LocalDateValidator`. + // + int32 day = 3 [(min).value = "1", (max).value = "31"]; +} + +// A time without a time-zone. +// +// It is a description of a time, not an instant on a time-line. +// +// Instances of `LocalTime` can be ordered chronologically. +// +message LocalTime { + option (is).java_type = "LocalTimeMixin"; + option (compare_by) = { + field: "hour" + field: "minute" + field: "second" + field: "nano" + }; + + // An hour from 0 to 23. + int32 hour = 1 [(min).value = "0", (max).value = "23"]; + + // Minutes of an hour from 0 to 59. + int32 minute = 2 [(min).value = "0", (max).value = "59"]; + + // Seconds of a minute, specified from 0 to 59. + int32 second = 3 [(min).value = "0", (max).value = "59"]; + + // Fractions of a second from 0 to 999,999,999. + int32 nano = 4 [(min).value = "0", (max).value = "999999999"]; +} + +// A date-time without a time-zone. +// +// Instances of `LocalDateTime` can be ordered chronologically. +// +message LocalDateTime { + option (is).java_type = "LocalDateTimeTemporal"; + option (compare_by) = { + field: "date" + field: "time" + }; + + LocalDate date = 1 [(required) = true]; + LocalTime time = 2; +} + +// A time-zone offset from UTC, such as `+02:00`. +message ZoneOffset { + + // Please use [ZoneId] instead. + option deprecated = true; + + // The total offset in seconds. + int32 amount_seconds = 1; +} + +// A time with an offset from UTC. +message OffsetTime { + + // Please use [LocalTime] in combination with [ZoneId] instead. + option deprecated = true; + + // The local time. + LocalTime time = 1; + + // The offset of the time-zone from UTC. + ZoneOffset offset = 2; +} + +// A date-time with an offset from UTC. +message OffsetDateTime { + + // Usage history has shown that this type causes much confusion. + // Please use [ZonedDateTime] instead. + option deprecated = true; + + option (is).java_type = "OffsetDateTimeTemporal"; + + // The local date-time. + LocalDateTime date_time = 1 [(required) = true]; + + // The offset of the time-zone from UTC. + ZoneOffset offset = 2; +} + +// An ID of a time-zone, such as `Europe/Amsterdam`. +message ZoneId { + option (is).java_type = "ZoneIdMixin"; + + string value = 1; +} + +// A date-time with a time-zone in the ISO-8601 calendar system, +// such as `2018-06-25T19:22:45+01:00 Europe/Amsterdam`. +// +// Instances of `ZonedDateTime` cannot be ordered by field values, since the chronological order +// depends on interpretation of time in different time zones. +// +message ZonedDateTime { + option (is).java_type = "ZonedDateTimeTemporal"; + + // The local date-time. + LocalDateTime date_time = 1 [(required) = true]; + + // The time-zone. + ZoneId zone = 2 [(required) = true]; +} diff --git a/packages/validation/proto/spine/time_options.proto b/packages/validation/proto/spine/time_options.proto new file mode 100644 index 0000000..e069e87 --- /dev/null +++ b/packages/validation/proto/spine/time_options.proto @@ -0,0 +1,106 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +// API Note on Packaging +// --------------------- +// We do not define the package for this file to allow shorter options for user-defined types. +// This allows writing: +// +// [(when).in = FUTURE]; +// +// instead of: +// +// [(spine.time.when).in = FUTURE]; +// + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.time.validation"; +option java_outer_classname = "TimeOptionsProto"; +option java_multiple_files = true; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + + // See `TimeOption`. + TimeOption when = 73819; +} + +// Specifies that the field value is a point in time lying either in the future or in the past. +// +// Applicable to `google.protobuf.Timestamp` and types introduced in the `spine.time` package +// that describe time-related concepts. +// +// Repeated fields are supported. +// +// Example: Using the `(when)` option. +// +// message ScheduleMeeting { +// spine.time.ZonedDateTime start = 1 [(when).in = FUTURE]; +// } +// +message TimeOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " of the type `${field.type}` must be in the `${when.in}`." + " The encountered value: `${field.value}`."; + + // Defines a restriction for the timestamp. + Time in = 1; + + // Deprecated: please use `error_msg` instead. + string msg_format = 2 [deprecated = true]; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` โ€“ the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` โ€“ the fully qualified name of the field type. + // 4. `${parent.type}` โ€“ the fully qualified name of the validated message. + // 5. `${when.in}` โ€“ the specified timestamp restriction. It is either "past" or "future". + // + string error_msg = 3; +} + +// This enumeration defines a restriction for date/time values. +enum Time { + + // The default value (if the time option is not set). + TIME_UNDEFINED = 0; + + // The value must be in the past. + PAST = 1; + + // The value must be in the future. + FUTURE = 2; +} diff --git a/packages/spine-validation-ts/proto/spine/validate/error_message.proto b/packages/validation/proto/spine/validate/error_message.proto similarity index 100% rename from packages/spine-validation-ts/proto/spine/validate/error_message.proto rename to packages/validation/proto/spine/validate/error_message.proto diff --git a/packages/spine-validation-ts/proto/spine/validate/validation_error.proto b/packages/validation/proto/spine/validate/validation_error.proto similarity index 100% rename from packages/spine-validation-ts/proto/spine/validate/validation_error.proto rename to packages/validation/proto/spine/validate/validation_error.proto diff --git a/packages/validation/src/clock.ts b/packages/validation/src/clock.ts new file mode 100644 index 0000000..cb2b05b --- /dev/null +++ b/packages/validation/src/clock.ts @@ -0,0 +1,33 @@ +/** Represents the timestamp components returned by the validation clock. */ +interface ClockInstant { + /** Counts whole seconds since the Unix epoch. */ + seconds: bigint; + /** Stores the sub-second nanosecond adjustment. */ + nanos: number; +} + +/** Supplies clock instants to temporal validators and permits deterministic test overrides. */ +export const ValidationClock = { + /** Returns the instant produced by the configured clock source. + * @returns Current epoch seconds and nanosecond adjustment. + */ + read(): ClockInstant { + return clock(); + }, + /** Sets the clock source used by subsequent temporal validations. + * @param replacement Optional clock source; omitting it restores the system clock. + */ + set(replacement?: () => ClockInstant): void { + clock = replacement ?? ValidationClock.system; + }, + /** Reads the current system time as Protobuf timestamp components. + * @returns Current epoch seconds and nanosecond adjustment. + */ + system(): ClockInstant { + const milliseconds = BigInt(Date.now()); + const seconds = milliseconds >= 0n ? milliseconds / 1000n : (milliseconds - 999n) / 1000n; + return { seconds, nanos: Number((milliseconds - seconds * 1000n) * 1_000_000n) }; + }, +}; + +let clock: () => ClockInstant = ValidationClock.system; diff --git a/packages/spine-validation-ts/src/index.ts b/packages/validation/src/index.ts similarity index 71% rename from packages/spine-validation-ts/src/index.ts rename to packages/validation/src/index.ts index ed36a2f..498d24b 100644 --- a/packages/spine-validation-ts/src/index.ts +++ b/packages/validation/src/index.ts @@ -32,28 +32,18 @@ * @packageDocumentation */ +export { validate, Violations } from "./validation.js"; export { - validate, - formatViolations, - Violations -} from './validation'; - -/** - * Internal utility function for formatting template strings. - * End-users typically don't need to use this directly. Use `Violations.formatMessage()` instead. - * @internal - */ -export { formatTemplateString } from './validation'; + ValidationConfigurationError, + type ValidationConfigurationErrorCode, + type ValidationConfigurationErrorInit, +} from "./validation-configuration-error.js"; export type { - ConstraintViolation, - ValidationError -} from './generated/spine/validate/validation_error_pb'; + ConstraintViolation, + ValidationError, +} from "./generated/spine/validate/validation_error_pb.js"; -export type { - TemplateString -} from './generated/spine/validate/error_message_pb'; +export type { TemplateString } from "./generated/spine/validate/error_message_pb.js"; -export type { - FieldPath -} from './generated/spine/base/field_path_pb'; +export type { FieldPath } from "./generated/spine/base/field_path_pb.js"; diff --git a/packages/spine-validation-ts/src/options-registry.ts b/packages/validation/src/options-registry.ts similarity index 65% rename from packages/spine-validation-ts/src/options-registry.ts rename to packages/validation/src/options-registry.ts index 0f3cc2b..a2232dc 100644 --- a/packages/spine-validation-ts/src/options-registry.ts +++ b/packages/validation/src/options-registry.ts @@ -31,19 +31,20 @@ */ import { - required, - if_missing, - pattern, - min, - max, - range, - distinct, - validate, - goes, - if_has_duplicates, - choice, - requireFields -} from './generated/spine/options_pb'; + required, + if_missing, + pattern, + min, + max, + range, + distinct, + validate, + goes, + if_has_duplicates, + choice, + require as requireFields, +} from "./generated/spine/options_pb.js"; +import { when } from "./generated/spine/time_options_pb.js"; /** * Registry storing option extension references. @@ -58,32 +59,33 @@ import { * - `is_required` (73891) โ€” Deprecated, replaced by `choice` */ const optionRegistry = { - required, - if_missing, - pattern, - min, - max, - range, - distinct, - validate, - goes, - if_has_duplicates, - choice, - requireFields, + required, + if_missing, + pattern, + min, + max, + range, + distinct, + validate, + goes, + if_has_duplicates, + choice, + requireFields, + when, } as const; -/** - * Type representing the names of all registered options. - */ -type OptionName = keyof typeof optionRegistry; +/** Names a generated validation option extension registered by this package. */ +export type OptionName = keyof typeof optionRegistry; +/** Maps every supported option name to its generated extension definition. */ +type OptionRegistry = typeof optionRegistry; -/** - * Gets a registered option extension by name. - * - * @param name The name of the option to retrieve. - * @returns The option extension, or `undefined` if not found. - * @internal - */ -export function getRegisteredOption(name: OptionName): any | undefined { +/** Exposes generated option extensions by their stable validation names. */ +export const ValidationOptions = { + /** Retrieves the generated extension registered under an option name. + * @param name Name of the validation option extension to retrieve. + * @returns The extension associated with `name`. + */ + get<N extends OptionName>(name: N): OptionRegistry[N] { return optionRegistry[name]; -} + }, +}; diff --git a/packages/validation/src/options/choice.ts b/packages/validation/src/options/choice.ts new file mode 100644 index 0000000..fbb5175 --- /dev/null +++ b/packages/validation/src/options/choice.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Validation of the descriptor-defined oneof `(choice)` option. */ + +import { getOption, hasOption } from "@bufbuild/protobuf"; +import type { DescMessage, Message } from "@bufbuild/protobuf"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { ChoiceOptionSchema, default_message } from "../generated/spine/options_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { Presence } from "../presence.js"; +import { ViolationFactory, type ValidationContext } from "../validation-contract.js"; + +/** Owns `(choice)` option validation. */ +export const Choice = { + /** Adds violations for required oneof groups that have no selected member. + * @param context Root type and path carried into created violations. + * @param schema Descriptor whose oneof declarations carry `(choice)` options. + * @param message Candidate message whose oneof presence is inspected. + * @param violations Mutable collection receiving failed choice diagnostics. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + violations: ConstraintViolation[], + ): void { + const choiceOption = ValidationOptions.get("choice"); + if (!choiceOption) return; + + for (const oneof of schema.oneofs) { + if (!hasOption(oneof, choiceOption)) continue; + const option = getOption(oneof, choiceOption); + if (!option.required || Presence.isOneof(oneof, message)) continue; + + violations.push( + ViolationFactory.create(context, undefined, undefined, { + customMessage: option.errorMsg, + defaultMessage: Choice.defaultMessage(), + placeholders: { "group.path": oneof.name, "parent.type": context.rootTypeName }, + }), + ); + } + }, + + /** Retrieves the extension-level fallback message for `(choice)` violations. + * @returns The configured fallback template, when the option schema supplies one. + */ + defaultMessage(): string | undefined { + return getOption(ChoiceOptionSchema, default_message); + }, +} as const; diff --git a/packages/validation/src/options/distinct.ts b/packages/validation/src/options/distinct.ts new file mode 100644 index 0000000..cc0f85b --- /dev/null +++ b/packages/validation/src/options/distinct.ts @@ -0,0 +1,185 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Validation of the descriptor-defined `(distinct)` option. */ + +import { equals, getOption, hasOption } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message } from "@bufbuild/protobuf"; +import { scalarEquals } from "@bufbuild/protobuf/reflect"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { + default_message, + IfHasDuplicatesOptionSchema as DuplicatesOptionSchema, + type IfHasDuplicatesOption, +} from "../generated/spine/options_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { ViolationFactory, MessageFields, ValidationContext } from "../validation-contract.js"; +import { ValidationConfigurationError } from "../validation-configuration-error.js"; + +/** Groups collection values that compare equal under a field descriptor. */ +interface EqualityClass { + /** Value used to compare later members of this equality group. */ + representative: unknown; + /** Number of collection values in this equality group. */ + count: number; +} + +/** Owns descriptor-defined `(distinct)` validation and its private formatting helpers. */ +export const Distinct = { + /** Adds one diagnostic for each duplicate equality class in a marked collection. + * @param context Root type and path carried into created violations. + * @param schema Descriptor used to describe unsupported option targets. + * @param message Candidate message supplying the list or map values. + * @param field Collection field declaring `(distinct)`. + * @param violations Mutable collection receiving duplicate diagnostics. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + field: DescField, + violations: ConstraintViolation[], + ): void { + const extension = ValidationOptions.get("distinct"); + if (!extension || !hasOption(field, extension)) return; + if (getOption(field, extension) !== true) return; + if (field.fieldKind !== "list" && field.fieldKind !== "map") { + throw new ValidationConfigurationError({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "distinct", + typeName: schema.typeName, + fieldPath: [field.name], + }); + } + + const collection = MessageFields.read(message, field); + const values = Distinct.collectionValues(field, collection); + if (values.length < 2) return; + + const classes: EqualityClass[] = []; + for (const value of values) { + const existing = classes.find((candidate) => + Distinct.valuesAreEqual(field, candidate.representative, value), + ); + if (existing) { + existing.count++; + } else { + classes.push({ representative: value, count: 1 }); + } + } + + const custom = Distinct.diagnostic(field); + for (const duplicate of classes) { + if (duplicate.count < 2) continue; + violations.push( + ViolationFactory.create(context.atField(field), field, duplicate.representative, { + customMessage: custom?.errorMsg || undefined, + defaultMessage: getOption(DuplicatesOptionSchema, default_message), + placeholders: { + "field.value": Distinct.formatCollection(collection), + "field.duplicates": Distinct.formatCollection([duplicate.representative]), + }, + }), + ); + } + }, + + /** Applies `(distinct)` validation to every field in a message descriptor. + * @param schema Descriptor whose fields are inspected for `(distinct)`. + * @param message Candidate message supplying collection values. + * @param violations Mutable collection receiving duplicate diagnostics. + */ + validateAll(schema: DescMessage, message: Message, violations: ConstraintViolation[]): void { + const context = new ValidationContext(schema.typeName); + for (const field of schema.fields) + Distinct.validate(context, schema, message, field, violations); + }, + + /** Extracts comparable elements from a list or map field value. + * @param field Collection descriptor that determines list or map handling. + * @param collection Runtime collection read from the candidate message. + * @returns The list elements or map values, or an empty array for another value. + */ + collectionValues(field: DescField, collection: unknown): unknown[] { + if (field.fieldKind === "list") return Array.isArray(collection) ? collection : []; + if (collection === null || typeof collection !== "object") return []; + return Object.values(collection); + }, + + /** Compares two collection elements with the equality semantics of their descriptor. + * @param field Collection descriptor that selects scalar, enum, or message equality. + * @param left First collection element to compare. + * @param right Second collection element to compare. + * @returns Whether both elements belong to the same equality class. + */ + valuesAreEqual(field: DescField, left: unknown, right: unknown): boolean { + if (field.fieldKind === "list") { + if (field.listKind === "scalar") + return scalarEquals(field.scalar, left as never, right as never); + if (field.listKind === "enum") return Number(left) === Number(right); + return equals(field.message, left as never, right as never); + } + if (field.fieldKind !== "map") { + throw new Error("distinct values must come from a repeated or map field"); + } + if (field.mapKind === "scalar") + return scalarEquals(field.scalar, left as never, right as never); + if (field.mapKind === "enum") return Number(left) === Number(right); + return equals(field.message, left as never, right as never); + }, + + /** Reads the optional duplicate-message configuration from a collection field. + * @param field Field whose `(if_has_duplicates)` extension is read. + * @returns The configured duplicate diagnostic, if the field declares one. + */ + diagnostic(field: DescField): IfHasDuplicatesOption | undefined { + const extension = ValidationOptions.get("if_has_duplicates"); + return hasOption(field, extension) ? getOption(field, extension) : undefined; + }, + + /** Renders a collection value for a duplicate diagnostic placeholder. + * @param value Runtime collection or duplicate representative to render. + * @returns A stable diagnostic representation of `value`. + */ + formatCollection(value: unknown): string { + return Distinct.formatValue(value); + }, + + /** Renders a nested runtime value without losing bytes or bigint information. + * @param value Scalar, collection, map, or message-like value to render. + * @returns A stable diagnostic representation of `value`. + */ + formatValue(value: unknown): string { + if (value instanceof Uint8Array) return Distinct.bytesToHex(value); + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) return `[${value.map(Distinct.formatValue).join(", ")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.entries(value) + .map(([key, nested]) => `${key}=${Distinct.formatValue(nested)}`) + .join(", ")}}`; + } + return String(value); + }, + + /** Encodes binary field content as lower-case hexadecimal for diagnostics. + * @param value Bytes from a field or collection element. + * @returns The hexadecimal encoding of `value`. + */ + bytesToHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); + }, +} as const; diff --git a/packages/validation/src/options/goes.ts b/packages/validation/src/options/goes.ts new file mode 100644 index 0000000..c9bfd16 --- /dev/null +++ b/packages/validation/src/options/goes.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Validation of the descriptor-defined `(goes)` option. */ + +import { getOption, hasOption } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message } from "@bufbuild/protobuf"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { default_message, GoesOptionSchema } from "../generated/spine/options_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { Presence } from "../presence.js"; +import { ViolationFactory, MessageFields, type ValidationContext } from "../validation-contract.js"; +import { ValidationConfigurationError } from "../validation-configuration-error.js"; + +/** Owns `(goes)` option validation. */ +export const Goes = { + /** Adds a violation when a present field lacks its configured companion. + * @param context Root type and path carried into created violations. + * @param schema Descriptor used to find the configured companion field. + * @param message Candidate message whose field presence is compared. + * @param field Field declaring the `(goes)` option. + * @param violations Mutable collection receiving dependency diagnostics. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + field: DescField, + violations: ConstraintViolation[], + ): void { + const goesOption = ValidationOptions.get("goes"); + if (!goesOption || !hasOption(field, goesOption)) return; + + if (!Presence.supports(field)) { + throw new ValidationConfigurationError({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "goes", + typeName: schema.typeName, + fieldPath: [field.name], + }); + } + + const option = getOption(field, goesOption); + if (!option.with) { + throw new ValidationConfigurationError({ + code: "INVALID_OPTION_VALUE", + option: "goes", + typeName: schema.typeName, + fieldPath: [field.name], + }); + } + + const companion = schema.fields.find((candidate) => candidate.name === option.with); + if (companion === undefined) { + throw new ValidationConfigurationError({ + code: "UNKNOWN_FIELD_REFERENCE", + option: "goes", + typeName: schema.typeName, + fieldPath: [field.name], + }); + } + if (!Presence.supports(companion)) { + throw new ValidationConfigurationError({ + code: "INVALID_FIELD_REFERENCE", + option: "goes", + typeName: schema.typeName, + fieldPath: [companion.name], + }); + } + + const value = MessageFields.read(message, field); + if ( + !Presence.is(field, value) || + Presence.is(companion, MessageFields.read(message, companion)) + ) + return; + + violations.push( + ViolationFactory.create(context.atField(field), field, value, { + customMessage: option.errorMsg, + defaultMessage: Goes.defaultMessage(), + placeholders: { "goes.companion": companion.name }, + }), + ); + }, + + /** Retrieves the extension-level fallback message for `(goes)` violations. + * @returns The configured fallback template, when present. + */ + defaultMessage(): string | undefined { + return getOption(GoesOptionSchema, default_message); + }, +} as const; diff --git a/packages/validation/src/options/min-max.ts b/packages/validation/src/options/min-max.ts new file mode 100644 index 0000000..9e505e6 --- /dev/null +++ b/packages/validation/src/options/min-max.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getOption, hasOption } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message } from "@bufbuild/protobuf"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { + default_message, + MaxOptionSchema, + MinOptionSchema, +} from "../generated/spine/options_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { ViolationFactory, MessageFields, type ValidationContext } from "../validation-contract.js"; +import { NumericValues } from "./numeric.js"; + +/** Evaluates `(min)` and `(max)` option bounds for numeric fields. */ +export const MinMax = { + /** Checks the minimum and maximum bounds declared on one numeric field. + * @param context Root type and path carried into created violations. + * @param schema Descriptor used to resolve numeric references. + * @param message Candidate message supplying the compared values. + * @param field Numeric field declaring `(min)` or `(max)`. + * @param violations Mutable collection receiving bound diagnostics. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + field: DescField, + violations: ConstraintViolation[], + ): void { + MinMax.validateBound("min", context, schema, message, field, violations); + MinMax.validateBound("max", context, schema, message, field, violations); + }, + + /** Checks one named numeric bound and adds its diagnostic when it is exceeded. + * @param name Option name identifying the bound extension to evaluate. + * @param context Root type and path carried into created violations. + * @param schema Descriptor used to resolve numeric references. + * @param message Candidate message supplying the compared values. + * @param field Numeric field declaring the named bound. + * @param violations Mutable collection receiving the bound diagnostic. + */ + validateBound( + name: "min" | "max", + context: ValidationContext, + schema: DescMessage, + message: Message, + field: DescField, + violations: ConstraintViolation[], + ): void { + const extension = ValidationOptions.get(name); + if (!extension || !hasOption(field, extension)) return; + const option = getOption(field, extension); + const scalar = NumericValues.assertTarget(name, schema, field); + const declaration = option.value; + const bound = NumericValues.resolveBound(declaration, scalar, name, schema, message, field); + const exclusive = "exclusive" in option && option.exclusive; + const fieldValue = MessageFields.read(message, field); + const values = field.fieldKind === "list" ? fieldValue : [fieldValue]; + if (!Array.isArray(values)) return; + for (const raw of values) { + const value = NumericValues.runtime(raw, scalar); + const comparison = NumericValues.compare(value, bound.value); + const valid = + !NumericValues.isNaN(value) && + (name === "min" + ? exclusive + ? comparison > 0 + : comparison >= 0 + : exclusive + ? comparison < 0 + : comparison <= 0); + if (valid) continue; + const defaultMessage = getOption( + name === "min" ? MinOptionSchema : MaxOptionSchema, + default_message, + ); + const customMessage = option.errorMsg || undefined; + violations.push( + ViolationFactory.create(context.atField(field), field, raw, { + customMessage, + defaultMessage, + placeholders: { + [`${name}.value`]: bound.display, + [`${name}.operator`]: + name === "min" ? (exclusive ? ">" : ">=") : exclusive ? "<" : "<=", + // Supplies unnamespaced aliases alongside the documented placeholders. + value: String(raw), + other: bound.display, + }, + }), + ); + } + }, +} as const; diff --git a/packages/validation/src/options/numeric.ts b/packages/validation/src/options/numeric.ts new file mode 100644 index 0000000..4ac862d --- /dev/null +++ b/packages/validation/src/options/numeric.ts @@ -0,0 +1,259 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { create, isMessage, ScalarType } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message } from "@bufbuild/protobuf"; + +import { ValidationConfigurationError } from "../validation-configuration-error.js"; +import { MessageFields } from "../validation-contract.js"; + +/** Represents a parsed numeric option value as a JavaScript number or bigint. */ +export type NumericValue = number | bigint; + +const INTEGER = /^[+-]?\d+$/; +const FLOAT = /^[+-]?(?:\d+\.\d*|\d*\.\d+)(?:[eE][+-]?\d+)?$/; +const FLOAT_MAX = 3.4028234663852886e38; + +/** Maps each integer scalar kind to its inclusive lower and upper limits. */ +const integerLimits: Readonly<Partial<Record<ScalarType, readonly [bigint, bigint]>>> = { + [ScalarType.INT32]: [-2147483648n, 2147483647n], + [ScalarType.SINT32]: [-2147483648n, 2147483647n], + [ScalarType.SFIXED32]: [-2147483648n, 2147483647n], + [ScalarType.UINT32]: [0n, 4294967295n], + [ScalarType.FIXED32]: [0n, 4294967295n], + [ScalarType.INT64]: [-9223372036854775808n, 9223372036854775807n], + [ScalarType.SINT64]: [-9223372036854775808n, 9223372036854775807n], + [ScalarType.SFIXED64]: [-9223372036854775808n, 9223372036854775807n], + [ScalarType.UINT64]: [0n, 18446744073709551615n], + [ScalarType.FIXED64]: [0n, 18446744073709551615n], +}; + +/** Couples a comparison-ready numeric bound with its diagnostic representation. */ +export interface ResolvedBound { + /** Parsed bound used for numeric comparison. */ + value: NumericValue; + /** Literal or resolved bound text included in a diagnostic. */ + display: string; +} + +/** Owns numeric parsing, reference resolution, and comparison for numeric options. */ +export const NumericValues = { + /** Identifies the supported numeric scalar type of a field. + * @param field Field descriptor whose scalar type is inspected. + * @returns The numeric scalar type, or `undefined` for a nonnumeric field. + */ + numericScalar(field: DescField): ScalarType | undefined { + if (field.fieldKind === "scalar") + return NumericValues.isNumeric(field.scalar) ? field.scalar : undefined; + if (field.fieldKind === "list" && field.listKind === "scalar") + return NumericValues.isNumeric(field.scalar) ? field.scalar : undefined; + return undefined; + }, + + /** Rejects a numeric option applied to an unsupported field target. + * @param option Name of the numeric option being configured. + * @param schema Descriptor owning the configured field. + * @param field Field descriptor whose target compatibility is checked. + * @returns The field's supported numeric scalar type. + */ + assertTarget(option: string, schema: DescMessage, field: DescField): ScalarType { + const scalar = NumericValues.numericScalar(field); + if (scalar !== undefined) return scalar; + throw NumericValues.configurationError("UNSUPPORTED_OPTION_TARGET", option, schema.typeName, [ + field.name, + ]); + }, + + /** Parses a numeric literal according to the target scalar type. + * @param declaration Literal text from an option declaration. + * @param scalar Scalar type that constrains parsing. + * @param option Name of the option owning the literal. + * @param typeName Message type reported in configuration errors. + * @param fieldPath Field path reported in configuration errors. + * @returns The parsed number or bigint value. + */ + parseLiteral( + declaration: string, + scalar: ScalarType, + option: string, + typeName: string, + fieldPath: readonly string[], + ): NumericValue { + if (NumericValues.isFloating(scalar)) { + if (!FLOAT.test(declaration)) + throw NumericValues.configurationError("INVALID_OPTION_VALUE", option, typeName, fieldPath); + const value = Number(declaration); + if (!Number.isFinite(value) || (scalar === ScalarType.FLOAT && Math.abs(value) > FLOAT_MAX)) + throw NumericValues.configurationError("INVALID_OPTION_VALUE", option, typeName, fieldPath); + return value; + } + if (!INTEGER.test(declaration)) + throw NumericValues.configurationError("INVALID_OPTION_VALUE", option, typeName, fieldPath); + const value = BigInt(declaration); + const limit = integerLimits[scalar]; + if (!limit || value < limit[0] || value > limit[1]) + throw NumericValues.configurationError("INVALID_OPTION_VALUE", option, typeName, fieldPath); + return NumericValues.is64Bit(scalar) ? value : Number(value); + }, + + /** Resolves a numeric bound from either a literal or a message-field reference. + * @param declaration Literal or reference declared by the option. + * @param scalar Scalar type expected for the resolved bound. + * @param option Name of the option owning the bound. + * @param schema Descriptor used to resolve references. + * @param message Candidate message used to read referenced fields. + * @param target Field being constrained by the bound. + * @returns The resolved comparable bound. + */ + resolveBound( + declaration: string, + scalar: ScalarType, + option: string, + schema: DescMessage, + message: Message, + target: DescField, + ): ResolvedBound { + if (!NumericValues.looksLikeReference(declaration)) { + return { + value: NumericValues.parseLiteral(declaration, scalar, option, schema.typeName, [ + target.name, + ]), + display: declaration, + }; + } + const segments = declaration.split("."); + let descriptor: DescMessage = schema; + let current: Message = message; + for (let index = 0; index < segments.length; index++) { + const name = segments[index]; + const field = descriptor.fields.find((candidate) => candidate.name === name); + if (!field) + throw NumericValues.configurationError("UNKNOWN_FIELD_REFERENCE", option, schema.typeName, [ + target.name, + ]); + const finalSegment = index === segments.length - 1; + if (finalSegment) { + const referencedScalar = NumericValues.numericScalar(field); + if (referencedScalar === undefined || field.fieldKind !== "scalar") + throw NumericValues.configurationError( + "INVALID_FIELD_REFERENCE", + option, + schema.typeName, + [target.name], + ); + const raw = MessageFields.read(current, field); + const value = NumericValues.runtime(raw ?? field.getDefaultValue(), referencedScalar); + return { value, display: `${declaration} (${String(value)})` }; + } + if (field.fieldKind !== "message") + throw NumericValues.configurationError("INVALID_FIELD_REFERENCE", option, schema.typeName, [ + target.name, + ]); + const nested = MessageFields.read(current, field); + current = isMessage(nested, field.message) ? nested : create(field.message); + descriptor = field.message; + } + throw NumericValues.configurationError("UNKNOWN_FIELD_REFERENCE", option, schema.typeName, [ + target.name, + ]); + }, + + /** Orders two numeric values without coercing bigint values through numbers. + * @param left First numeric operand. + * @param right Second numeric operand. + * @returns A negative, zero, or positive comparison result. + */ + compare(left: NumericValue, right: NumericValue): number { + return left < right ? -1 : left > right ? 1 : 0; + }, + + /** Detects a floating-point `NaN` value. + * @param value Runtime numeric value to inspect. + * @returns Whether `value` is a number whose value is `NaN`. + */ + isNaN(value: NumericValue): boolean { + return typeof value === "number" && Number.isNaN(value); + }, + + /** Normalizes a declared numeric value to its runtime scalar representation. + * @param value Parsed numeric value. + * @param scalar Target Protobuf scalar type. + * @returns The number or bigint representation used by message fields. + */ + runtime(value: unknown, scalar: ScalarType): NumericValue { + if (NumericValues.is64Bit(scalar)) + return typeof value === "bigint" ? value : BigInt(String(value)); + return Number(value); + }, + + /** Creates a numeric-option configuration error with its location details. + * @param code Configuration failure classification. + * @param option Name of the invalid numeric option. + * @param typeName Message type containing the invalid declaration. + * @param fieldPath Field path containing the invalid declaration. + * @returns A structured error ready to throw. + */ + configurationError( + code: + | "UNSUPPORTED_OPTION_TARGET" + | "INVALID_OPTION_VALUE" + | "UNKNOWN_FIELD_REFERENCE" + | "INVALID_FIELD_REFERENCE", + option: string, + typeName: string, + fieldPath: readonly string[], + ): ValidationConfigurationError { + return new ValidationConfigurationError({ code, option, typeName, fieldPath }); + }, + + /** Determines whether a Protobuf scalar supports numeric bounds. + * @param scalar Protobuf scalar type to classify. + * @returns Whether validation accepts numeric values of this scalar type. + */ + isNumeric(scalar: ScalarType): boolean { + return integerLimits[scalar] !== undefined || NumericValues.isFloating(scalar); + }, + + /** Determines whether a scalar uses floating-point comparison. + * @param scalar Protobuf scalar type to classify. + * @returns Whether `scalar` is float or double. + */ + isFloating(scalar: ScalarType): boolean { + return scalar === ScalarType.FLOAT || scalar === ScalarType.DOUBLE; + }, + + /** Determines whether a scalar uses a 64-bit integer representation. + * @param scalar Protobuf scalar type to classify. + * @returns Whether `scalar` is a 64-bit integer variant. + */ + is64Bit(scalar: ScalarType): boolean { + return ( + scalar === ScalarType.INT64 || + scalar === ScalarType.SINT64 || + scalar === ScalarType.SFIXED64 || + scalar === ScalarType.UINT64 || + scalar === ScalarType.FIXED64 + ); + }, + + /** Detects the field-reference form accepted by numeric option declarations. + * @param value Declared bound text to classify. + * @returns Whether `value` names another message field. + */ + looksLikeReference(value: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(value); + }, +} as const; diff --git a/packages/validation/src/options/pattern.ts b/packages/validation/src/options/pattern.ts new file mode 100644 index 0000000..98ea394 --- /dev/null +++ b/packages/validation/src/options/pattern.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Validation logic for the `(pattern)` option. + * + * The `(pattern)` option validates that a string field matches a given regular expression. + */ + +import type { DescMessage, Message } from "@bufbuild/protobuf"; +import { hasOption, getOption, create, ScalarType } from "@bufbuild/protobuf"; +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { ConstraintViolationSchema } from "../generated/spine/validate/validation_error_pb.js"; +import { FieldPathSchema } from "../generated/spine/base/field_path_pb.js"; +import { TemplateStringSchema } from "../generated/spine/validate/error_message_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { MessageFields } from "../validation-contract.js"; +import type { PatternOption } from "../generated/spine/options_pb.js"; + +/** Owns descriptor-defined `(pattern)` validation and its private diagnostics. */ +export const Pattern = { + /** Creates the `(pattern)` violation representation for a failing field value. + * @param typeName Type name reported by the violation. + * @param fieldName Failing field name. + * @param fieldValue String value that failed the expression. + * @param violationMessage Custom or default pattern message. + * @returns A pattern-specific constraint violation. + */ + createViolation( + typeName: string, + fieldName: string, + fieldValue: unknown, + violationMessage: string, + ): ConstraintViolation { + return create(ConstraintViolationSchema, { + typeName, + fieldPath: create(FieldPathSchema, { + fieldName: [fieldName], + }), + fieldValue: undefined, + message: create(TemplateStringSchema, { + withPlaceholders: violationMessage, + placeholderValue: { + field: fieldName, + value: String(fieldValue ?? ""), + }, + }), + msgFormat: "", + param: [], + violation: [], + }); + }, + + /** Tests a string against a configured regular expression. + * @param value Candidate string to test. + * @param regex Compiled expression from the option. + * @param patternOption Pattern configuration defining inversion and message. + * @returns Whether the candidate string satisfies the option. + */ + validateValue(value: string, regex: string, patternOption: PatternOption): boolean { + if (typeof value !== "string") { + return false; + } + + try { + let flags = ""; + const modifier = patternOption.modifier; + + if (modifier) { + if (modifier.caseInsensitive) { + flags += "i"; + } + if (modifier.multiline) { + flags += "m"; + } + if (modifier.dotAll) { + flags += "s"; + } + if (modifier.unicode) { + flags += "u"; + } + } + + const pattern = new RegExp(regex, flags); + const partialMatch = modifier?.partialMatch || false; + + if (partialMatch) { + return pattern.test(value); + } else { + return pattern.test(value); + } + } catch (error) { + console.error(`Invalid regex pattern: ${regex}`, error); + return false; + } + }, + + /** Applies `(pattern)` expressions to configured string fields in a message. + * @param schema Descriptor containing pattern-configured fields. + * @param message Candidate message supplying string values. + * @param violations Collection receiving pattern diagnostics. + */ + validate<S extends DescMessage>( + schema: S, + message: Message, + violations: ConstraintViolation[], + ): void { + const patternOption = ValidationOptions.get("pattern"); + + if (!patternOption) { + return; + } + + for (const field of schema.fields) { + if (!hasOption(field, patternOption)) { + continue; + } + + const patternValue = getOption(field, patternOption); + const regex = patternValue.regex; + const errorMsg = + patternValue.errorMsg || `The string must match the regular expression \`${regex}\`.`; + + const fieldValue = MessageFields.read(message, field); + + if (field.fieldKind === "list") { + if (Array.isArray(fieldValue)) { + for (let i = 0; i < fieldValue.length; i++) { + const itemValue = fieldValue[i]; + if ( + typeof itemValue === "string" && + !Pattern.validateValue(itemValue, regex, patternValue) + ) { + violations.push( + Pattern.createViolation( + schema.typeName, + `${field.name}[${i}]`, + itemValue, + errorMsg, + ), + ); + } + } + } + } else if (field.fieldKind === "scalar" && field.scalar === ScalarType.STRING) { + if (typeof fieldValue === "string" && fieldValue !== "") { + if (!Pattern.validateValue(fieldValue, regex, patternValue)) { + violations.push( + Pattern.createViolation(schema.typeName, field.name, fieldValue, errorMsg), + ); + } + } + } + } + }, +} as const; diff --git a/packages/validation/src/options/range.ts b/packages/validation/src/options/range.ts new file mode 100644 index 0000000..7f9a3a8 --- /dev/null +++ b/packages/validation/src/options/range.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getOption, hasOption } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message } from "@bufbuild/protobuf"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { default_message, RangeOptionSchema } from "../generated/spine/options_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { ViolationFactory, MessageFields, type ValidationContext } from "../validation-contract.js"; +import { NumericValues, type ResolvedBound } from "./numeric.js"; + +/** Evaluates `(range)` interval declarations for numeric fields. */ +export const Range = { + /** Adds a violation when a numeric field lies outside its `(range)` interval. + * @param context Root type and path carried into created violations. + * @param schema Descriptor used to resolve range references. + * @param message Candidate message supplying the compared value. + * @param field Numeric field declaring `(range)`. + * @param violations Mutable collection receiving range diagnostics. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + field: DescField, + violations: ConstraintViolation[], + ): void { + const extension = ValidationOptions.get("range"); + if (!extension || !hasOption(field, extension)) return; + const option = getOption(field, extension); + const scalar = NumericValues.assertTarget("range", schema, field); + const parsed = Range.parse(option.value, scalar, schema, message, field); + const fieldValue = MessageFields.read(message, field); + const values = field.fieldKind === "list" ? fieldValue : [fieldValue]; + if (!Array.isArray(values)) return; + for (const raw of values) { + const value = NumericValues.runtime(raw, scalar); + const lowerComparison = NumericValues.compare(value, parsed.lower.value); + const upperComparison = NumericValues.compare(value, parsed.upper.value); + const validLower = parsed.lowerInclusive ? lowerComparison >= 0 : lowerComparison > 0; + const validUpper = parsed.upperInclusive ? upperComparison <= 0 : upperComparison < 0; + if (!NumericValues.isNaN(value) && validLower && validUpper) continue; + violations.push( + ViolationFactory.create(context.atField(field), field, raw, { + customMessage: option.errorMsg || undefined, + defaultMessage: getOption(RangeOptionSchema, default_message), + placeholders: { + "range.value": parsed.display, + value: String(raw), + range: parsed.display, + }, + }), + ); + } + }, + + /** Parses a `(range)` declaration into comparable lower and upper bounds. + * @param declaration Range expression from the field option. + * @param scalar Numeric scalar type accepted by the field. + * @param schema Descriptor used to resolve field references. + * @param message Candidate message used to read referenced bounds. + * @param field Field whose range expression is interpreted. + * @returns Parsed bounds and their inclusive or exclusive delimiters. + */ + parse( + declaration: string, + scalar: ReturnType<typeof NumericValues.assertTarget>, + schema: DescMessage, + message: Message, + field: DescField, + ) { + const match = /^(\s*)(\[|\()([\s\S]*?)(\.\.)([\s\S]*?)(\]|\))(\s*)$/.exec(declaration); + if (!match || !match[3].trim() || !match[5].trim()) + throw NumericValues.configurationError("INVALID_OPTION_VALUE", "range", schema.typeName, [ + field.name, + ]); + const lowerToken = match[3].trim(); + const upperToken = match[5].trim(); + const lower = NumericValues.resolveBound(lowerToken, scalar, "range", schema, message, field); + const upper = NumericValues.resolveBound(upperToken, scalar, "range", schema, message, field); + if (NumericValues.compare(lower.value, upper.value) > 0) + throw NumericValues.configurationError("INVALID_OPTION_VALUE", "range", schema.typeName, [ + field.name, + ]); + return { + lower, + upper, + lowerInclusive: match[2] === "[", + upperInclusive: match[6] === "]", + display: `${match[1]}${match[2]}${Range.renderBound(match[3], lowerToken, lower)}${match[4]}${Range.renderBound(match[5], upperToken, upper)}${match[6]}${match[7]}`, + }; + }, + + /** Renders a parsed bound for use in a range diagnostic. + * @param raw Original range expression. + * @param token Text identifying the bound within the expression. + * @param bound Resolved numeric bound value. + * @returns The literal token or resolved numeric value shown to callers. + */ + renderBound(raw: string, token: string, bound: ResolvedBound): string { + return bound.display === token ? raw : raw.replace(token, bound.display); + }, +} as const; diff --git a/packages/validation/src/options/required-field.ts b/packages/validation/src/options/required-field.ts new file mode 100644 index 0000000..fd97505 --- /dev/null +++ b/packages/validation/src/options/required-field.ts @@ -0,0 +1,154 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Validation of the message-level `(require)` option. */ + +import { getExtension, getOption, hasExtension } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, DescOneof, Message } from "@bufbuild/protobuf"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { default_message, RequireOptionSchema } from "../generated/spine/options_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { Presence } from "../presence.js"; +import { ViolationFactory, MessageFields, type ValidationContext } from "../validation-contract.js"; +import { ValidationConfigurationError } from "../validation-configuration-error.js"; + +/** Represents an operand accepted by a message-level `(require)` declaration. */ +interface Requirement { + /** Identifies the required field when the expression names a field. */ + readonly field?: DescField; + /** Identifies the required oneof when the expression names a oneof. */ + readonly oneof?: DescOneof; +} + +/** Owns `(require)` option parsing and validation. */ +export const Require = { + /** Adds a violation when a `(require)` expression is not satisfied. + * @param context Violation location for the validated message. + * @param schema Descriptor declaring `(require)` expressions. + * @param message Candidate message whose required fields are inspected. + * @param violations Collection receiving requirement diagnostics. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + violations: ConstraintViolation[], + ): void { + const requireOption = ValidationOptions.get("requireFields"); + const options = schema.proto.options; + if (!options || !hasExtension(options, requireOption)) return; + + const require = getExtension(options, requireOption); + const expression = require.fields; + const groups = Require.parseRequirements(expression, schema); + if ( + groups.some((group) => + group.every((requirement) => Require.requirementIsPresent(requirement, message)), + ) + ) { + return; + } + + violations.push( + ViolationFactory.create(context, undefined, undefined, { + customMessage: require.errorMsg, + defaultMessage: Require.defaultMessage(), + placeholders: { "require.fields": expression }, + }), + ); + }, + + /** Retrieves the extension-level fallback message for `(require)` violations. + * @returns The configured fallback template, when present. + */ + defaultMessage(): string | undefined { + return getOption(RequireOptionSchema, default_message); + }, + + /** Creates an error for an invalid `(require)` option expression. + * @param schema Descriptor containing the invalid expression. + */ + invalidOption(schema: DescMessage): never { + throw new ValidationConfigurationError({ + code: "INVALID_OPTION_VALUE", + option: "require", + typeName: schema.typeName, + }); + }, + + /** Parses the field groups encoded by a `(require)` expression. + * @param expression Option expression to parse. + * @param schema Descriptor used to resolve named fields. + * @returns Resolved field groups that represent the requirement. + */ + parseRequirements(expression: string, schema: DescMessage): readonly (readonly Requirement[])[] { + if (!expression.trim() || /[()]/.test(expression)) Require.invalidOption(schema); + + const groups = expression.split("|").map((group) => group.trim()); + if (groups.some((group) => !group)) Require.invalidOption(schema); + + return groups.map((group) => { + const tokens = group.split("&").map((token) => token.trim()); + if (tokens.some((token) => !token || /\s/.test(token))) Require.invalidOption(schema); + return tokens.map((token) => Require.resolve(token, schema)); + }); + }, + + /** Resolves one field name used in a `(require)` expression. + * @param token Field name from the option expression. + * @param schema Descriptor whose fields are searched. + * @returns The matching field descriptor. + */ + resolve(token: string, schema: DescMessage): Requirement { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(token)) Require.invalidOption(schema); + + const field = schema.fields.find((candidate) => candidate.name === token); + if (field !== undefined) { + if (!Presence.supports(field)) { + throw new ValidationConfigurationError({ + code: "INVALID_FIELD_REFERENCE", + option: "require", + typeName: schema.typeName, + fieldPath: [field.name], + }); + } + return { field }; + } + + const oneof = schema.oneofs.find((candidate) => candidate.name === token); + if (oneof !== undefined) return { oneof }; + + throw new ValidationConfigurationError({ + code: "UNKNOWN_FIELD_REFERENCE", + option: "require", + typeName: schema.typeName, + fieldPath: [token], + }); + }, + + /** Determines whether at least one field in a required group is present. + * @param requirement Resolved fields forming one required group. + * @param message Candidate message whose fields are read. + * @returns Whether the group has a present field. + */ + requirementIsPresent(requirement: Requirement, message: Message): boolean { + if (requirement.field !== undefined) { + return Presence.is(requirement.field, MessageFields.read(message, requirement.field)); + } + return Presence.isOneof(requirement.oneof as DescOneof, message); + }, +} as const; diff --git a/packages/validation/src/options/required.ts b/packages/validation/src/options/required.ts new file mode 100644 index 0000000..f8ed7bb --- /dev/null +++ b/packages/validation/src/options/required.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Validation of the descriptor-defined `(required)` field option. */ + +import { getOption, hasOption } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message } from "@bufbuild/protobuf"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { default_message, IfMissingOptionSchema } from "../generated/spine/options_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { Presence } from "../presence.js"; +import { ViolationFactory, MessageFields, type ValidationContext } from "../validation-contract.js"; +import { ValidationConfigurationError } from "../validation-configuration-error.js"; + +/** Owns `(required)` option validation. */ +export const Required = { + /** Adds a violation when a required field is absent from the message. + * @param context Root type and path carried into created violations. + * @param schema Descriptor used to report unsupported option targets. + * @param message Candidate message whose field presence is checked. + * @param field Field declaring the `(required)` option. + * @param violations Mutable collection receiving required-field diagnostics. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + field: DescField, + violations: ConstraintViolation[], + ): void { + const requiredOption = ValidationOptions.get("required"); + if (!requiredOption || !hasOption(field, requiredOption) || !getOption(field, requiredOption)) + return; + + if (!Presence.supports(field)) { + throw new ValidationConfigurationError({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "required", + typeName: schema.typeName, + fieldPath: [field.name], + }); + } + + const value = MessageFields.read(message, field); + if (Presence.is(field, value)) return; + + const ifMissingOption = ValidationOptions.get("if_missing"); + const ifMissing = hasOption(field, ifMissingOption) + ? getOption(field, ifMissingOption) + : undefined; + const customMessage = ifMissing?.errorMsg || undefined; + + violations.push( + ViolationFactory.create(context.atField(field), field, undefined, { + customMessage, + defaultMessage: Required.defaultMessage(), + }), + ); + }, + + /** Retrieves the extension-level fallback message for `(required)` violations. + * @returns The configured fallback template, when present. + */ + defaultMessage(): string | undefined { + return getOption(IfMissingOptionSchema, default_message); + }, +} as const; diff --git a/packages/validation/src/options/validate.ts b/packages/validation/src/options/validate.ts new file mode 100644 index 0000000..37dfac7 --- /dev/null +++ b/packages/validation/src/options/validate.ts @@ -0,0 +1,193 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** Leaf-only recursion for the descriptor-defined `(validate)` option. */ + +import { create, equals, getOption, hasOption, isMessage } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message, MessageShape, Registry } from "@bufbuild/protobuf"; +import { anyUnpack } from "@bufbuild/protobuf/wkt"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { ValidationOptions } from "../options-registry.js"; +import { MessageFields, type ValidationContext } from "../validation-contract.js"; +import { ValidationConfigurationError } from "../validation-configuration-error.js"; + +/** Validates a nested message with its accumulated root context and descriptor registry. */ +export type NestedValidator = <S extends DescMessage>( + schema: S, + message: MessageShape<S>, + context: ValidationContext, + registry: Registry, +) => ConstraintViolation[]; + +/** Owns descriptor-defined recursive `(validate)` option processing. */ +export const NestedValidation = { + /** Traverses nested messages selected by a `(validate)` field option. + * @param context Root type and path for nested violations. + * @param schema Descriptor containing the nested field. + * @param message Candidate message supplying nested values. + * @param field Field declaring `(validate)`. + * @param violations Collection receiving leaf violations. + * @param registry Descriptor registry used for `Any` resolution. + * @param validateNested Callback that validates each nested message. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + field: DescField, + violations: ConstraintViolation[], + registry: Registry, + validateNested: NestedValidator, + ): void { + const option = ValidationOptions.get("validate"); + if (!option || !hasOption(field, option) || !getOption(field, option)) return; + + const nestedSchema = NestedValidation.messageSchema(field); + if (!nestedSchema) { + throw new ValidationConfigurationError({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "validate", + typeName: schema.typeName, + fieldPath: [field.name], + }); + } + + const value = MessageFields.read(message, field); + const nestedContext = context.atField(field); + if (field.fieldKind === "message") { + if (value === undefined || value === null || NestedValidation.isDefault(nestedSchema, value)) + return; + NestedValidation.append( + nestedSchema, + value, + nestedContext, + registry, + violations, + validateNested, + ); + return; + } + + if (field.fieldKind === "list") { + if (!Array.isArray(value)) return; + for (const element of value) + NestedValidation.append( + nestedSchema, + element, + nestedContext, + registry, + violations, + validateNested, + ); + return; + } + + if (value === null || typeof value !== "object") return; + for (const element of Object.values(value)) { + NestedValidation.append( + nestedSchema, + element, + nestedContext, + registry, + violations, + validateNested, + ); + } + }, + + /** Extracts the message descriptor carried by a nested-validation field. + * @param field Field descriptor to inspect. + * @returns The nested message descriptor, when the field contains messages. + */ + messageSchema(field: DescField): DescMessage | undefined { + if (field.fieldKind === "message") return field.message; + if (field.fieldKind === "list" && field.listKind === "message") return field.message; + if (field.fieldKind === "map" && field.mapKind === "message") return field.message; + return undefined; + }, + + /** Determines whether a nested message equals its schema default instance. + * @param schema Descriptor used for equality comparison. + * @param value Nested runtime value to compare. + * @returns Whether the value is absent or equal to the default message. + */ + isDefault(schema: DescMessage, value: unknown): boolean { + return equals(schema, value as never, create(schema)); + }, + + /** Appends leaf violations produced by one nested message value. + * @param schema Descriptor of the nested message. + * @param value Nested runtime message to validate. + * @param context Parent violation context. + * @param registry Descriptor registry used by recursive validation. + * @param violations Collection receiving nested leaf violations. + * @param validateNested Callback that performs recursive validation. + */ + append( + schema: DescMessage, + value: unknown, + context: ValidationContext, + registry: Registry, + violations: ConstraintViolation[], + validateNested: NestedValidator, + ): void { + if (schema.typeName === "google.protobuf.Any") { + NestedValidation.appendPackedAny(value, context, registry, violations, validateNested); + return; + } + if (!isMessage(value, schema)) return; + violations.push(...validateNested(schema, value, context, registry)); + }, + + /** Unpacks a known `Any` payload and appends its nested leaf violations. + * @param value Runtime `Any` message to unpack. + * @param context Parent violation context. + * @param registry Registry used to resolve the packed message type. + * @param violations Collection receiving nested leaf violations. + * @param validateNested Callback that performs recursive validation. + */ + appendPackedAny( + value: unknown, + context: ValidationContext, + registry: Registry, + violations: ConstraintViolation[], + validateNested: NestedValidator, + ): void { + if (!value || typeof value !== "object") return; + let unpacked; + try { + unpacked = anyUnpack(value as Parameters<typeof anyUnpack>[0], registry); + } catch { + // A malformed or unrecognized type URL cannot be unpacked and is valid by contract. + return; + } + if (!unpacked) return; + const schema = registry.getMessage(unpacked.$typeName); + if (schema && isMessage(unpacked, schema)) + violations.push(...validateNested(schema, unpacked, context, registry)); + }, +} as const; diff --git a/packages/validation/src/options/when.ts b/packages/validation/src/options/when.ts new file mode 100644 index 0000000..447e27a --- /dev/null +++ b/packages/validation/src/options/when.ts @@ -0,0 +1,359 @@ +import { create, equals, getOption, hasOption } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message } from "@bufbuild/protobuf"; +import { Temporal } from "temporal-polyfill"; + +import type { ConstraintViolation } from "../generated/spine/validate/validation_error_pb.js"; +import { default_message } from "../generated/spine/options_pb.js"; +import { Time, TimeOptionSchema } from "../generated/spine/time_options_pb.js"; +import { ValidationClock } from "../clock.js"; +import { ValidationOptions } from "../options-registry.js"; +import { ValidationConfigurationError } from "../validation-configuration-error.js"; +import { ViolationFactory, MessageFields, type ValidationContext } from "../validation-contract.js"; + +const NANOSECONDS_PER_SECOND = 1_000_000_000n; +const MIN_YEAR = -999_999_999; +const MAX_YEAR = 999_999_999; +const TIMESTAMP_MIN_SECONDS = -62_135_596_800n; +const TIMESTAMP_MAX_SECONDS = 253_402_300_799n; +const ZONE_IDENTIFIER = /^(?:UTC|[A-Za-z0-9._+-]+(?:\/[A-Za-z0-9._+-]+)*)$/; +const allowedPlaceholders = new Set([ + "field.path", + "field.value", + "field.type", + "parent.type", + "when.in", +]); +const supportedTypes = new Set([ + "google.protobuf.Timestamp", + "spine.time.YearMonth", + "spine.time.LocalDate", + "spine.time.LocalDateTime", + "spine.time.OffsetDateTime", + "spine.time.ZonedDateTime", +]); + +/** Owns immutable Spine Time `(when)` validation and temporal conversion helpers. */ +export const When = { + /** Adds a violation when a temporal value is not in its required past or future. + * @param context Root type and path carried into created violations. + * @param schema Descriptor used to report invalid `(when)` declarations. + * @param message Candidate message supplying temporal values. + * @param field Temporal field declaring `(when)`. + * @param violations Mutable collection receiving temporal diagnostics. + */ + validate( + context: ValidationContext, + schema: DescMessage, + message: Message, + field: DescField, + violations: ConstraintViolation[], + ): void { + const extension = ValidationOptions.get("when"); + if (!hasOption(field, extension)) return; + const option = getOption(field, extension); + if (option.in === Time.TIME_UNDEFINED) return; + if (option.in !== Time.PAST && option.in !== Time.FUTURE) + throw When.configurationError("INVALID_OPTION_VALUE", schema, field); + const typeName = When.temporalType(field); + if (!supportedTypes.has(typeName)) + throw When.configurationError("UNSUPPORTED_OPTION_TARGET", schema, field); + When.assertPlaceholders(option.errorMsg, schema, field); + const value = MessageFields.read(message, field); + if ( + field.fieldKind === "message" && + (!value || equals(field.message, value as never, create(field.message))) + ) + return; + const values = When.collectionValues(field, value); + for (const element of values) { + const now = When.toEpochNanoseconds(ValidationClock.read()); + const instant = When.toEpochNanoseconds(element, typeName); + const valid = option.in === Time.PAST ? instant <= now : instant >= now; + if (valid) continue; + violations.push( + ViolationFactory.create(context.atField(field), field, element, { + customMessage: option.errorMsg || undefined, + defaultMessage: getOption(TimeOptionSchema, default_message) || undefined, + placeholders: { "when.in": option.in === Time.PAST ? "past" : "future" }, + }), + ); + } + }, + + /** Flattens a temporal field value into values that must each satisfy `(when)`. + * @param field Descriptor that distinguishes scalar, list, and map handling. + * @param value Runtime field value from the candidate message. + * @returns Individual temporal values to compare with the clock. + */ + collectionValues(field: DescField, value: unknown): unknown[] { + if (field.fieldKind === "list") return Array.isArray(value) ? value : []; + if (field.fieldKind === "map") + return value && typeof value === "object" ? Object.values(value) : []; + return [value]; + }, + + /** Obtains the message type name carried by a temporal field. + * @param field Field descriptor to inspect. + * @returns The temporal message type name, or an empty string for other fields. + */ + temporalType(field: DescField): string { + if ( + field.fieldKind === "message" || + (field.fieldKind === "list" && field.listKind === "message") || + (field.fieldKind === "map" && field.mapKind === "message") + ) + return field.message.typeName; + return ""; + }, + + /** Converts a supported temporal message to an epoch-nanosecond instant. + * @param value Runtime temporal message to convert. + * @param typeName Declared temporal message type; omitted for a clock timestamp. + * @returns The validated instant in epoch nanoseconds. + */ + toEpochNanoseconds(value: unknown, typeName?: string): bigint { + if (!typeName) return When.checkedTimestamp(value); + const temporal = value as Record<string, unknown>; + let epoch: bigint; + switch (typeName) { + case "google.protobuf.Timestamp": + return When.checkedTimestamp(temporal); + case "spine.time.YearMonth": + epoch = When.localDateEpoch(temporal.year, temporal.month, 1, 0, 0, 0, 0); + break; + case "spine.time.LocalDate": + epoch = When.localDateEpoch(temporal.year, temporal.month, temporal.day, 0, 0, 0, 0); + break; + case "spine.time.LocalDateTime": + epoch = When.localDateTimeEpoch(temporal); + break; + case "spine.time.OffsetDateTime": { + const dateTime = When.object(temporal.dateTime); + const offset = When.object(temporal.offset); + const seconds = When.integer(offset.amountSeconds); + if (seconds < -64_800 || seconds > 64_800) throw new RangeError("Invalid offset"); + epoch = When.localDateTimeEpoch(dateTime) - BigInt(seconds) * NANOSECONDS_PER_SECOND; + break; + } + case "spine.time.ZonedDateTime": + epoch = When.zonedDateTimeEpoch(temporal); + break; + default: + throw new RangeError(`Unsupported temporal value ${typeName}`); + } + return When.checkedEpoch(epoch); + }, + + /** Converts a Protobuf timestamp after checking its nanosecond component. + * @param value Timestamp-shaped runtime value. + * @returns The timestamp as a valid epoch-nanosecond instant. + */ + checkedTimestamp(value: unknown): bigint { + const timestamp = When.object(value); + const seconds = When.bigint(timestamp.seconds); + const nanos = When.integer(timestamp.nanos); + if (nanos < 0 || nanos >= 1_000_000_000) + throw new RangeError("Timestamp nanos must be within 0..999999999"); + return When.checkedEpoch(seconds * NANOSECONDS_PER_SECOND + BigInt(nanos)); + }, + + /** Checks that an epoch instant fits the Protobuf timestamp range. + * @param epoch Candidate epoch-nanosecond instant. + * @returns The same instant after range validation. + */ + checkedEpoch(epoch: bigint): bigint { + if ( + epoch < TIMESTAMP_MIN_SECONDS * NANOSECONDS_PER_SECOND || + epoch > TIMESTAMP_MAX_SECONDS * NANOSECONDS_PER_SECOND + 999_999_999n + ) + throw new RangeError("Timestamp is outside the valid range"); + return epoch; + }, + + /** Converts a Spine local date-time value to an epoch-nanosecond instant. + * @param value Local date-time record containing date and time components. + * @returns The corresponding UTC epoch-nanosecond instant. + */ + localDateTimeEpoch(value: Record<string, unknown>): bigint { + const date = When.object(value.date); + const time = When.object(value.time); + return When.localDateEpoch( + date.year, + date.month, + date.day, + time.hour, + time.minute, + time.second, + time.nano, + ); + }, + + /** Converts checked local date-time components to an epoch-nanosecond instant. + * @param yearValue Calendar year component. + * @param monthValue One-based calendar month component. + * @param dayValue Day within the supplied month. + * @param hourValue Hour within the day. + * @param minuteValue Minute within the hour. + * @param secondValue Second within the minute. + * @param nanoValue Nanosecond within the second. + * @returns The corresponding UTC epoch-nanosecond instant. + */ + localDateEpoch( + yearValue: unknown, + monthValue: unknown, + dayValue: unknown, + hourValue: unknown, + minuteValue: unknown, + secondValue: unknown, + nanoValue: unknown, + ): bigint { + const year = When.integer(yearValue); + const month = When.integer(monthValue); + const day = When.integer(dayValue); + const hour = When.integer(hourValue); + const minute = When.integer(minuteValue); + const second = When.integer(secondValue); + const nano = When.integer(nanoValue); + if ( + year < MIN_YEAR || + year > MAX_YEAR || + month < 1 || + month > 12 || + day < 1 || + day > When.daysInMonth(year, month) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 || + second < 0 || + second > 59 || + nano < 0 || + nano >= 1_000_000_000 + ) + throw new RangeError("Invalid local date-time"); + return ( + (When.daysFromCivil(year, month, day) * 86_400n + + BigInt(hour * 3600 + minute * 60 + second)) * + NANOSECONDS_PER_SECOND + + BigInt(nano) + ); + }, + + /** Converts a Spine zoned date-time through its named IANA time zone. + * @param value Zoned date-time record containing local date-time and zone. + * @returns The resolved epoch-nanosecond instant. + */ + zonedDateTimeEpoch(value: Record<string, unknown>): bigint { + const date = When.object(When.object(value.dateTime).date); + const time = When.object(When.object(value.dateTime).time); + const zone = String(When.object(value.zone).value ?? ""); + if (zone.length === 0 || zone.length > 255 || !ZONE_IDENTIFIER.test(zone)) + throw new RangeError("Invalid zoned date-time"); + try { + return Temporal.ZonedDateTime.from( + { + timeZone: zone, + year: When.integer(date.year), + month: When.integer(date.month), + day: When.integer(date.day), + hour: When.integer(time.hour), + minute: When.integer(time.minute), + second: When.integer(time.second), + millisecond: 0, + microsecond: 0, + nanosecond: When.integer(time.nano), + }, + { disambiguation: "compatible" }, + ).epochNanoseconds; + } catch { + throw new RangeError("Invalid zoned date-time"); + } + }, + + /** Counts days from the Unix epoch for a Gregorian calendar date. + * @param year Gregorian calendar year. + * @param month One-based Gregorian calendar month. + * @param day Day within the supplied month. + * @returns Whole days from 1970-01-01 to the date. + */ + daysFromCivil(year: number, month: number, day: number): bigint { + const adjustedYear = year - (month <= 2 ? 1 : 0); + const era = Math.floor(adjustedYear / 400); + const yoe = adjustedYear - era * 400; + const mp = month + (month > 2 ? -3 : 9); + const doy = Math.floor((153 * mp + 2) / 5) + day - 1; + const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy; + return BigInt(era * 146097 + doe - 719468); + }, + /** Calculates the number of days in a Gregorian calendar month. + * @param year Gregorian calendar year, used for leap-year handling. + * @param month One-based Gregorian calendar month. + * @returns The number of days in the specified month. + */ + daysInMonth(year: number, month: number): number { + return month === 2 + ? year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) + ? 29 + : 28 + : [4, 6, 9, 11].includes(month) + ? 30 + : 31; + }, + /** Requires a temporal component to be an object record. + * @param value Runtime temporal component to normalize. + * @returns The component as an object record, or an empty record for `undefined`. + */ + object(value: unknown): Record<string, unknown> { + if (value === undefined) return {}; + if (!value || typeof value !== "object") throw new RangeError("Missing temporal value"); + return value as Record<string, unknown>; + }, + /** Converts a temporal component to an integer. + * @param value Runtime temporal component to convert. + * @returns The converted integer component. + */ + integer(value: unknown): number { + const result = Number(value ?? 0); + if (!Number.isInteger(result)) throw new RangeError("Expected an integer temporal component"); + return result; + }, + /** Converts timestamp seconds to a bigint. + * @param value Runtime timestamp-seconds component. + * @returns The converted bigint seconds value. + */ + bigint(value: unknown): bigint { + try { + return BigInt(value as bigint | number | string); + } catch { + throw new RangeError("Expected timestamp seconds"); + } + }, + /** Rejects a `(when)` message template that uses an unsupported placeholder. + * @param template Custom error-message template to inspect. + * @param schema Descriptor used to locate a configuration error. + * @param field Field declaring the invalid template. + */ + assertPlaceholders(template: string, schema: DescMessage, field: DescField): void { + for (const [, key] of template.matchAll(/\$\{([^}]+)\}/g)) + if (!allowedPlaceholders.has(key)) + throw When.configurationError("INVALID_OPTION_VALUE", schema, field); + }, + /** Creates a location-aware configuration error for a `(when)` declaration. + * @param code Configuration failure classification. + * @param schema Descriptor containing the invalid option. + * @param field Field declaring the invalid option. + * @returns A structured error ready to throw. + */ + configurationError( + code: "UNSUPPORTED_OPTION_TARGET" | "INVALID_OPTION_VALUE", + schema: DescMessage, + field: DescField, + ): ValidationConfigurationError { + return new ValidationConfigurationError({ + code, + option: "when", + typeName: schema.typeName, + fieldPath: [field.name], + }); + }, +} as const; diff --git a/packages/validation/src/orchestration.ts b/packages/validation/src/orchestration.ts new file mode 100644 index 0000000..4771491 --- /dev/null +++ b/packages/validation/src/orchestration.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { create } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message, MessageShape, Registry } from "@bufbuild/protobuf"; + +import type { ConstraintViolation } from "./generated/spine/validate/validation_error_pb.js"; +import { FieldPathSchema } from "./generated/spine/base/field_path_pb.js"; +import { MessageFields, ViolationFactory, type ValidationContext } from "./validation-contract.js"; + +/** Validates every field exposed by a descriptor in one invocation. */ +type AllFieldsValidator = <S extends DescMessage>( + schema: S, + message: MessageShape<S>, + violations: ConstraintViolation[], +) => void; + +/** Defines the field-by-field contract used by the validation pass. */ +export interface FieldValidator { + /** Validates one field during the ordered validation pass. + * @param context Root type and path for produced violations. + * @param schema Descriptor containing the current field. + * @param message Candidate message being validated. + * @param field Current field descriptor. + * @param violations Collection receiving failures. + * @param registry Descriptor registry for nested validation. + */ + validate<S extends DescMessage>( + context: ValidationContext, + schema: S, + message: MessageShape<S>, + field: DescField, + violations: ConstraintViolation[], + registry: Registry, + ): void; +} + +/** + * Coordinates all-fields validators with the field-by-field validation pass. + */ +export const ValidationOrchestration = { + /** Adapts an all-fields validator to the field-by-field orchestration contract. + * @param allFieldsValidator Validator that evaluates a descriptor's fields together. + * @returns A field validator that normalizes the validator's output. + */ + adaptAllFieldsValidator(allFieldsValidator: AllFieldsValidator): FieldValidator { + return { + /** Validates only the current field through the all-fields implementation. + * @param context Root type and path for normalized violations. + * @param schema Descriptor containing the current field. + * @param message Candidate message being validated. + * @param field Field exposed to the adapted validator. + * @param violations Collection receiving normalized failures. + */ + validate<S extends DescMessage>( + context: ValidationContext, + schema: S, + message: MessageShape<S>, + field: DescField, + violations: ConstraintViolation[], + ) { + const allFieldsViolations: ConstraintViolation[] = []; + const fields = [field] as typeof schema.fields; + fields.find = schema.fields.find.bind(schema.fields); + const fieldSchema = { ...schema, fields } as S; + allFieldsValidator(fieldSchema, message, allFieldsViolations); + + for (const allFieldsViolation of allFieldsViolations) { + const allFieldsMessage = allFieldsViolation.message; + const normalized = ViolationFactory.create( + context.atField(field), + field, + ValidationOrchestration.offendingValue(message, field, allFieldsViolation), + { + defaultMessage: allFieldsMessage?.withPlaceholders, + placeholders: allFieldsMessage?.placeholderValue, + }, + ); + const nestedPath = ValidationOrchestration.nestedFieldPath(field, allFieldsViolation); + if (nestedPath.length > 0) { + normalized.fieldPath = create(FieldPathSchema, { + fieldName: [field.name, ...nestedPath], + }); + } + violations.push(normalized); + } + }, + }; + }, + + /** Normalizes and appends a message-level or oneof-level violation. + * @param context Root type and path for the normalized violation. + * @param allFieldsViolation Existing violation to normalize. + * @param violations Collection receiving the normalized violation. + */ + appendMessageViolation( + context: ValidationContext, + allFieldsViolation: ConstraintViolation, + violations: ConstraintViolation[], + ): void { + const allFieldsMessage = allFieldsViolation.message; + const normalized = ViolationFactory.create(context, undefined, undefined, { + defaultMessage: allFieldsMessage?.withPlaceholders, + placeholders: allFieldsMessage?.placeholderValue, + }); + violations.push(normalized); + }, + + /** Locates the runtime value named by an all-fields violation's nested path. + * @param message Candidate message holding the value. + * @param field Top-level field named by the violation. + * @param violation Violation supplying list or map path details. + * @returns The offending nested value, when it can be located. + */ + offendingValue(message: Message, field: DescField, violation: ConstraintViolation): unknown { + const value = MessageFields.read(message, field); + const path = violation.fieldPath?.fieldName ?? []; + + if (field.fieldKind === "list") { + if (!Array.isArray(value)) return undefined; + const bracketedIndex = path[0]?.match(new RegExp(`^${field.name}\\[(\\d+)]$`)); + if (bracketedIndex) return value[Number(bracketedIndex[1])]; + if (path.length >= 2) return value[Number(path[1])]; + return undefined; + } + if (field.fieldKind === "map" && value && typeof value === "object") + return Object.entries(value).find(([key]) => key === path[1])?.[1]; + return value; + }, + + /** Removes the top-level field and collection segment from a nested failure path. + * @param field Top-level field used to interpret the path. + * @param violation Violation whose field path is normalized. + * @returns Remaining nested field-name segments. + */ + nestedFieldPath(field: DescField, violation: ConstraintViolation): string[] { + const path = violation.fieldPath?.fieldName ?? []; + if (path.length <= 1 || path[0] !== field.name) return []; + if (field.fieldKind === "list" || field.fieldKind === "map") return path.slice(2); + return path.slice(1); + }, +} as const; diff --git a/packages/validation/src/presence.ts b/packages/validation/src/presence.ts new file mode 100644 index 0000000..d0695a1 --- /dev/null +++ b/packages/validation/src/presence.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { create, equals, ScalarType } from "@bufbuild/protobuf"; +import type { DescField, DescOneof, Message } from "@bufbuild/protobuf"; +import { MessageFields } from "./validation-contract.js"; + +/** Determines whether descriptor values count as present for validation options. */ +export const Presence = { + /** Identifies field kinds whose default values can be distinguished from presence. + * @param field Field descriptor to inspect. + * @returns Whether the field supports presence-aware validation. + */ + supports(field: DescField): boolean { + return ( + field.fieldKind === "message" || + field.fieldKind === "enum" || + field.fieldKind === "list" || + field.fieldKind === "map" || + (field.fieldKind === "scalar" && + (field.scalar === ScalarType.STRING || field.scalar === ScalarType.BYTES)) + ); + }, + + /** Determines whether a field value is present rather than its Protobuf default. + * @param field Descriptor defining the value's field kind. + * @param value Runtime field value to evaluate. + * @returns Whether the value is present according to its field kind. + */ + is(field: DescField, value: unknown): boolean { + if (field.fieldKind === "message") { + return ( + value !== undefined && + value !== null && + !equals(field.message, value as never, create(field.message)) + ); + } + if (field.fieldKind === "enum") return typeof value === "number" && value !== 0; + if (field.fieldKind === "list") return Array.isArray(value) && value.length > 0; + if (field.fieldKind === "map") + return !!value && typeof value === "object" && Object.keys(value).length > 0; + if (field.scalar === ScalarType.STRING) return typeof value === "string" && value.length > 0; + return value instanceof Uint8Array && value.length > 0; + }, + + /** Determines whether a oneof currently selects a member. + * @param oneof Oneof descriptor to inspect. + * @param message Message containing the oneof value. + * @returns Whether the message selects a oneof member. + */ + isOneof(oneof: DescOneof, message: Message): boolean { + const value = MessageFields.read(message, oneof); + return ( + typeof value === "object" && value !== null && "case" in value && value.case !== undefined + ); + }, +}; diff --git a/packages/validation/src/validation-configuration-error.ts b/packages/validation/src/validation-configuration-error.ts new file mode 100644 index 0000000..98d25c5 --- /dev/null +++ b/packages/validation/src/validation-configuration-error.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Classifies why a validation option declaration cannot be applied. */ +export type ValidationConfigurationErrorCode = + | "UNSUPPORTED_OPTION_TARGET" + | "INVALID_OPTION_VALUE" + | "UNKNOWN_FIELD_REFERENCE" + | "INVALID_FIELD_REFERENCE"; + +/** Identifies the invalid option declaration used to create a configuration error. */ +export interface ValidationConfigurationErrorInit { + /** Classification of the invalid declaration. */ + code: ValidationConfigurationErrorCode; + /** Canonical validation option name without Proto parentheses. */ + option: string; + /** Fully qualified Proto type declaring the option. */ + typeName: string; + /** Optional Proto field-name path locating the option declaration. */ + fieldPath?: readonly string[]; + /** Underlying reason supplied by option parsing or resolution. */ + cause?: unknown; +} + +/** + * Indicates that a validation option cannot be applied as declared. + * + * The `option` value is the canonical option name without Proto parentheses. + */ +export class ValidationConfigurationError extends Error { + /** Classification of the invalid declaration. */ + readonly code: ValidationConfigurationErrorCode; + /** Canonical validation option name without Proto parentheses. */ + readonly option: string; + /** Fully qualified Proto type declaring the option. */ + readonly typeName: string; + /** Optional Proto field-name path locating the option declaration. */ + readonly fieldPath?: readonly string[]; + /** Underlying reason supplied by option parsing or resolution. */ + readonly cause?: unknown; + + /** Creates an error that identifies an invalid validation-option declaration. + * @param init Structured location and classification of the invalid declaration. + */ + constructor(init: ValidationConfigurationErrorInit) { + super( + `Invalid ${init.option} validation configuration for ${init.typeName}` + + (init.fieldPath?.length ? ` at ${init.fieldPath.join(".")}` : ""), + ); + this.name = "ValidationConfigurationError"; + this.code = init.code; + this.option = init.option; + this.typeName = init.typeName; + this.fieldPath = init.fieldPath; + this.cause = init.cause; + } +} diff --git a/packages/validation/src/validation-contract.ts b/packages/validation/src/validation-contract.ts new file mode 100644 index 0000000..2611835 --- /dev/null +++ b/packages/validation/src/validation-contract.ts @@ -0,0 +1,286 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { create, ScalarType } from "@bufbuild/protobuf"; +import type { DescField, DescMessage, Message } from "@bufbuild/protobuf"; +import { + anyPack, + BoolValueSchema, + BytesValueSchema, + DoubleValueSchema, + FloatValueSchema, + Int32ValueSchema, + Int64ValueSchema, + StringValueSchema, + UInt32ValueSchema, + UInt64ValueSchema, +} from "@bufbuild/protobuf/wkt"; + +import { FieldPathSchema } from "./generated/spine/base/field_path_pb.js"; +import { + ConstraintViolationSchema, + type ConstraintViolation, +} from "./generated/spine/validate/validation_error_pb.js"; +import { TemplateStringSchema } from "./generated/spine/validate/error_message_pb.js"; + +/** Carries a validation root type and the Proto field path currently being evaluated. */ +export class ValidationContext { + /** Fully qualified name of the message where validation began. */ + readonly rootTypeName: string; + /** Proto field names from the root message to the current location. */ + readonly fieldPath: readonly string[]; + + /** Creates a context for a root message and its current nested field path. + * @param rootTypeName Fully qualified type name of the root message. + * @param fieldPath Proto field names from the root to the current location. + */ + constructor(rootTypeName: string, fieldPath: readonly string[] = []) { + this.rootTypeName = rootTypeName; + this.fieldPath = fieldPath; + } + + /** Creates the empty-path context for a root message descriptor. + * @param schema Descriptor whose type name identifies the root message. + * @returns A context rooted at `schema`. + */ + static create(schema: DescMessage): ValidationContext { + return new ValidationContext(schema.typeName); + } + + /** Extends this context with one descriptor field. + * @param field Field whose Proto name is appended to the path. + * @returns A new context for `field`. + */ + atField(field: DescField): ValidationContext { + return new ValidationContext(this.rootTypeName, [...this.fieldPath, field.name]); + } +} + +/** Reads descriptor-named properties from generated message instances. */ +export const MessageFields = { + /** Reads a generated message property identified by its descriptor local name. + * @param message Generated message to inspect. + * @param field Descriptor view containing the generated property name. + * @returns The field's runtime value. + */ + read(message: Message, field: Pick<DescField, "localName">): unknown { + return (message as unknown as Record<string, unknown>)[field.localName]; + }, +}; + +/** Supplies the message template and substitutions for a created violation. */ +export interface ViolationMessage { + /** Option-specific message that takes precedence when it is nonempty. */ + customMessage?: string; + /** Built-in option message used when no custom message is supplied. */ + defaultMessage?: string; + /** Additional placeholder values merged into the generated diagnostic. */ + placeholders?: Readonly<Record<string, string>>; +} + +/** Creates shared violation envelopes from descriptor-aware field values. */ +export const ViolationFactory = { + /** Builds a descriptor-packed violation with resolved message placeholders. + * @param context Root type and field path of the failure. + * @param field Failing field, when the failure is field-scoped. + * @param fieldValue Runtime value that failed validation. + * @param message Custom, default, and placeholder message content. + * @returns The normalized constraint violation. + */ + create( + context: ValidationContext, + field: DescField | undefined, + fieldValue: unknown, + message: ViolationMessage, + ): ConstraintViolation { + const hasFieldValue = field !== undefined && fieldValue !== undefined; + const placeholderValue: Record<string, string> = { + "message.type": context.rootTypeName, + }; + + if (field !== undefined) { + Object.assign(placeholderValue, { + "parent.type": context.rootTypeName, + "field.path": context.fieldPath.join("."), + "field.type": ViolationFactory.fieldTypeName(field), + }); + } + + if (hasFieldValue) { + placeholderValue["field.value"] = ViolationFactory.formatFieldValue(fieldValue); + } + + return create(ConstraintViolationSchema, { + typeName: context.rootTypeName, + fieldPath: create(FieldPathSchema, { + fieldName: [...context.fieldPath], + }), + fieldValue: hasFieldValue ? ViolationFactory.packFieldValue(field, fieldValue) : undefined, + message: create(TemplateStringSchema, { + withPlaceholders: message.customMessage || message.defaultMessage || "", + placeholderValue: { + ...placeholderValue, + ...message.placeholders, + }, + }), + }); + }, + + /** Packs a field value into the appropriate `Any` representation. + * @param field Descriptor selecting the packing strategy. + * @param value Runtime field value to pack. + * @returns The packed field value. + */ + packFieldValue(field: DescField, value: unknown) { + if (field.fieldKind === "message") return ViolationFactory.packMessage(field.message, value); + if (field.fieldKind === "enum") return ViolationFactory.packWrapper(Int32ValueSchema, value); + if (field.fieldKind === "scalar") return ViolationFactory.packScalar(field.scalar, value); + if (field.fieldKind === "list") { + if (field.listKind === "message") return ViolationFactory.packMessage(field.message, value); + if (field.listKind === "enum") return ViolationFactory.packWrapper(Int32ValueSchema, value); + return ViolationFactory.packScalar(field.scalar, value); + } + if (field.mapKind === "message") return ViolationFactory.packMessage(field.message, value); + if (field.mapKind === "enum") return ViolationFactory.packWrapper(Int32ValueSchema, value); + return ViolationFactory.packScalar(field.scalar, value); + }, + + /** Packs a scalar into its matching Protobuf wrapper message. + * @param scalar Scalar type selecting the wrapper schema. + * @param value Runtime scalar value to pack. + * @returns The scalar wrapper packed in `Any`. + */ + packScalar(scalar: ScalarType, value: unknown) { + switch (scalar) { + case ScalarType.DOUBLE: + return ViolationFactory.packWrapper(DoubleValueSchema, value); + case ScalarType.FLOAT: + return ViolationFactory.packWrapper(FloatValueSchema, value); + case ScalarType.INT64: + case ScalarType.SINT64: + case ScalarType.SFIXED64: + return ViolationFactory.packWrapper(Int64ValueSchema, value); + case ScalarType.UINT64: + case ScalarType.FIXED64: + return ViolationFactory.packWrapper(UInt64ValueSchema, value); + case ScalarType.INT32: + case ScalarType.SINT32: + case ScalarType.SFIXED32: + return ViolationFactory.packWrapper(Int32ValueSchema, value); + case ScalarType.UINT32: + case ScalarType.FIXED32: + return ViolationFactory.packWrapper(UInt32ValueSchema, value); + case ScalarType.BOOL: + return ViolationFactory.packWrapper(BoolValueSchema, value); + case ScalarType.BYTES: + return ViolationFactory.packWrapper(BytesValueSchema, value); + case ScalarType.STRING: + return ViolationFactory.packWrapper(StringValueSchema, value); + } + }, + + /** Creates and packs a scalar wrapper message. + * @param schema Wrapper message schema. + * @param value Scalar value assigned to the wrapper. + * @returns The packed wrapper message. + */ + packWrapper(schema: DescMessage, value: unknown) { + return anyPack(schema, create(schema, { value })); + }, + + /** Packs a message-valued field without changing its shape. + * @param schema Message schema for the value. + * @param value Runtime message value to pack. + * @returns The packed message. + */ + packMessage(schema: DescMessage, value: unknown) { + return anyPack(schema, value as never); + }, + + /** Derives the Protobuf type name displayed for a field. + * @param field Descriptor whose field kind is inspected. + * @returns A message, enum, or scalar Protobuf type name. + */ + fieldTypeName(field: DescField): string { + if (field.fieldKind === "message") return field.message.typeName; + if (field.fieldKind === "enum") return field.enum.typeName; + if (field.fieldKind === "scalar") return ViolationFactory.scalarProtoTypeName(field.scalar); + if (field.fieldKind === "list") { + if (field.listKind === "message") return field.message.typeName; + if (field.listKind === "enum") return field.enum.typeName; + return ViolationFactory.scalarProtoTypeName(field.scalar); + } + if (field.mapKind === "message") return field.message.typeName; + if (field.mapKind === "enum") return field.enum.typeName; + return ViolationFactory.scalarProtoTypeName(field.scalar); + }, + + /** Maps a scalar enum value to its Protobuf spelling. + * @param scalar Scalar type to render. + * @returns The canonical Protobuf scalar name. + */ + scalarProtoTypeName(scalar: ScalarType): string { + switch (scalar) { + case ScalarType.DOUBLE: + return "double"; + case ScalarType.FLOAT: + return "float"; + case ScalarType.INT64: + return "int64"; + case ScalarType.UINT64: + return "uint64"; + case ScalarType.INT32: + return "int32"; + case ScalarType.FIXED64: + return "fixed64"; + case ScalarType.FIXED32: + return "fixed32"; + case ScalarType.BOOL: + return "bool"; + case ScalarType.STRING: + return "string"; + case ScalarType.BYTES: + return "bytes"; + case ScalarType.UINT32: + return "uint32"; + case ScalarType.SFIXED32: + return "sfixed32"; + case ScalarType.SFIXED64: + return "sfixed64"; + case ScalarType.SINT32: + return "sint32"; + case ScalarType.SINT64: + return "sint64"; + } + }, + + /** Renders a field value for a diagnostic placeholder. + * @param value Runtime field value to render. + * @returns A string representation that preserves bytes and bigint values. + */ + formatFieldValue(value: unknown): string { + if (value instanceof Uint8Array) { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + if (typeof value === "bigint") return value.toString(); + if (typeof value === "object" && value !== null) { + return JSON.stringify(value, (_, nested) => + typeof nested === "bigint" ? nested.toString() : nested, + ); + } + return String(value); + }, +} as const; diff --git a/packages/validation/src/validation.ts b/packages/validation/src/validation.ts new file mode 100644 index 0000000..dc9b4eb --- /dev/null +++ b/packages/validation/src/validation.ts @@ -0,0 +1,378 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Validation module for Protobuf messages with Spine validation options. + * + * This module provides the main validation API and utility functions + * for validating Protobuf messages against Spine validation constraints. + */ + +import { createRegistry } from "@bufbuild/protobuf"; +import type { DescFile, DescMessage, MessageShape, Registry } from "@bufbuild/protobuf"; + +import type { ConstraintViolation } from "./generated/spine/validate/validation_error_pb.js"; +import type { TemplateString } from "./generated/spine/validate/error_message_pb.js"; + +import { Required } from "./options/required.js"; +import { Pattern } from "./options/pattern.js"; +import { Require } from "./options/required-field.js"; +import { MinMax } from "./options/min-max.js"; +import { Range } from "./options/range.js"; +import { When } from "./options/when.js"; +import { Distinct } from "./options/distinct.js"; +import { NestedValidation } from "./options/validate.js"; +import { Goes } from "./options/goes.js"; +import { Choice } from "./options/choice.js"; +import { ValidationOrchestration, type FieldValidator } from "./orchestration.js"; +import { ValidationContext } from "./validation-contract.js"; + +const fieldValidators: readonly FieldValidator[] = [ + { + /** Applies `(required)` to the current field. + * @param context Violation location for the validated message. + * @param schema Descriptor containing the field. + * @param message Candidate message being validated. + * @param field Current field descriptor. + * @param violations Collection receiving validation failures. + */ + validate(context, schema, message, field, violations) { + Required.validate(context, schema, message, field, violations); + }, + }, + ValidationOrchestration.adaptAllFieldsValidator(Pattern.validate), + { + /** Applies `(min)` and `(max)` to the current field. + * @param context Violation location for the validated message. + * @param schema Descriptor containing the field. + * @param message Candidate message being validated. + * @param field Current field descriptor. + * @param violations Collection receiving validation failures. + */ + validate(context, schema, message, field, violations) { + MinMax.validate(context, schema, message, field, violations); + }, + }, + { + /** Applies `(range)` to the current field. + * @param context Violation location for the validated message. + * @param schema Descriptor containing the field. + * @param message Candidate message being validated. + * @param field Current field descriptor. + * @param violations Collection receiving validation failures. + */ + validate(context, schema, message, field, violations) { + Range.validate(context, schema, message, field, violations); + }, + }, + { + /** Applies `(when)` to the current field. + * @param context Violation location for the validated message. + * @param schema Descriptor containing the field. + * @param message Candidate message being validated. + * @param field Current field descriptor. + * @param violations Collection receiving validation failures. + */ + validate(context, schema, message, field, violations) { + When.validate(context, schema, message, field, violations); + }, + }, + { + /** Applies `(distinct)` to the current field. + * @param context Violation location for the validated message. + * @param schema Descriptor containing the field. + * @param message Candidate message being validated. + * @param field Current field descriptor. + * @param violations Collection receiving validation failures. + */ + validate(context, schema, message, field, violations) { + Distinct.validate(context, schema, message, field, violations); + }, + }, + { + /** Applies nested `(validate)` traversal to the current field. + * @param context Violation location for the validated message. + * @param schema Descriptor containing the field. + * @param message Candidate message being validated. + * @param field Current field descriptor. + * @param violations Collection receiving validation failures. + * @param registry Registry for nested message descriptors. + */ + validate(context, schema, message, field, violations, registry) { + NestedValidation.validate( + context, + schema, + message, + field, + violations, + registry, + ValidationEngine.validateInternal, + ); + }, + }, + { + /** Applies `(goes)` to the current field. + * @param context Violation location for the validated message. + * @param schema Descriptor containing the field. + * @param message Candidate message being validated. + * @param field Current field descriptor. + * @param violations Collection receiving validation failures. + */ + validate(context, schema, message, field, violations) { + Goes.validate(context, schema, message, field, violations); + }, + }, +]; + +export type { + ConstraintViolation, + ValidationError, +} from "./generated/spine/validate/validation_error_pb.js"; +export type { TemplateString } from "./generated/spine/validate/error_message_pb.js"; +export type { FieldPath } from "./generated/spine/base/field_path_pb.js"; + +/** + * Validates a message against its Spine validation constraints. + * + * This function applies all registered validation rules to the given message + * and returns an array of constraint violations. An empty array indicates + * the message is valid. + * + * Traversal follows declaration order and the internal validator order, but + * callers must not treat that order as a public compatibility guarantee. + * + * Validators using the standard `ConstraintViolation` structure retain the root entry type, a complete path of + * Proto field names, and a descriptor-packed offending value when one exists. + * Their diagnostic is always present; an option without a custom or default + * message produces an empty template string. `(pattern)` diagnostics use its + * documented pattern-specific representation; see the package validation contract for details. + * + * Currently supported validation options: + * - `(required)` โ€” validates supported presence targets + * - `(pattern)` โ€” validates string fields against regular expressions + * - `(require)` โ€” requires specific combinations of fields at message level + * - `(min)` / `(max)` โ€” numeric range validation with inclusive/exclusive bounds + * - `(range)` โ€” bounded numeric ranges using bracket notation for inclusive/exclusive bounds + * - `(when)` โ€” verifies supported timestamps and Spine temporal values are in the past or future + * - `(distinct)` โ€” emits one violation for each duplicated Buf-equality class + * - `(validate)` โ€” returns only leaf violations from nested values and known `Any` payloads + * - `(goes)` โ€” enforces field dependency (field can only be set if another field is set) + * - `(choice)` โ€” requires that a `oneof` group has at least one field set + * + * @param schema The message schema containing validation metadata. + * @param message The message instance to validate. + * @returns Array of constraint violations (empty if valid). + * + * @example + * ```typescript + * import { validate, Violations } from '@spine-event-engine/validation'; + * import { UserSchema } from './generated/user_pb.js'; + * import { create } from '@bufbuild/protobuf'; + * + * const user = create(UserSchema, { name: '', email: '' }); + * const violations = validate(UserSchema, user); + * + * if (violations.length > 0) { + * console.log('Validation failed:', Violations.formatAll(violations)); + * } + * ``` + */ +export function validate<S extends DescMessage>( + schema: S, + message: NoInfer<MessageShape<S>>, +): ConstraintViolation[] { + return ValidationEngine.validateInternal( + schema, + message, + ValidationContext.create(schema), + ValidationEngine.createRootRegistry(schema), + ); +} + +/** Coordinates internal traversal while preserving context and registry state. */ +const ValidationEngine = { + /** Traverses one message descriptor and accumulates its constraint violations. + * @param schema Descriptor whose options are evaluated. + * @param message Candidate message being validated. + * @param context Root type and nested path for resulting violations. + * @param registry Descriptor registry used by nested validation. + * @returns The violations emitted while traversing this message. + */ + validateInternal<S extends DescMessage>( + schema: S, + message: MessageShape<S>, + context: ValidationContext, + registry: Registry, + ): ConstraintViolation[] { + const violations: ConstraintViolation[] = []; + + Require.validate(context, schema, message, violations); + + for (const field of schema.fields) { + for (const validator of fieldValidators) { + validator.validate(context, schema, message, field, violations, registry); + } + } + + Choice.validate(context, schema, message, violations); + + return violations; + }, + + /** Builds a descriptor registry containing the root file and its dependencies. + * @param schema Root message descriptor. + * @returns A registry usable for nested-message lookup. + */ + createRootRegistry(schema: DescMessage): Registry { + return createRegistry(...ValidationEngine.dependencyClosure(schema.file)); + }, + + /** Collects a file and every transitive descriptor dependency exactly once. + * @param root File from which to begin traversal. + * @returns The dependency closure in traversal order. + */ + dependencyClosure(root: DescFile): DescFile[] { + const files: DescFile[] = []; + const visited = new Set<string>(); + const visit = (file: DescFile): void => { + if (visited.has(file.name)) return; + visited.add(file.name); + files.push(file); + for (const dependency of file.dependencies) visit(dependency); + }; + visit(root); + return files; + }, +} as const; + +/** Formats diagnostic template strings by substituting their named placeholder values. */ +const TemplateStrings = { + /** Replaces literal template placeholders with their supplied diagnostic values. + * @param template Message template containing placeholder values. + * @returns The rendered diagnostic text. + */ + format(template: TemplateString): string { + let result = template.withPlaceholders; + for (const [key, value] of Object.entries(template.placeholderValue)) { + result = result.split(`\${${key}}`).join(value); + } + return result; + }, +}; + +/** + * Formats public constraint violations for display and exposes their message and Proto field path. + * + * @example + * ```typescript + * import { create } from '@bufbuild/protobuf'; + * import { validate, Violations } from '@spine-event-engine/validation'; + * import { UserSchema } from './generated/user_pb.js'; + * + * const user = create(UserSchema, { name: '', email: '' }); + * const violations = validate(UserSchema, user); + * violations.forEach(v => { + * const path = Violations.failurePath(v); + * const message = Violations.formatMessage(v); + * console.error(`${v.typeName}.${path}: ${message}`); + * }); + * ``` + */ +export const Violations = { + /** + * Formats an array of constraint violations into a human-readable string. + * + * Each violation is formatted as `<index>. <typeName>.<fieldPath>: <message>`. + * + * @param violations The constraint violations to format. + * @returns A formatted list, or `"No violations"` for an empty collection. + * + * @example + * ```typescript + * import { create } from '@bufbuild/protobuf'; + * import { validate, Violations } from '@spine-event-engine/validation'; + * import { UserSchema } from './generated/user_pb.js'; + * + * const user = create(UserSchema, { name: '', email: '' }); + * console.log(Violations.formatAll(validate(UserSchema, user))); + * ``` + */ + formatAll(violations: ConstraintViolation[]): string { + if (violations.length === 0) return "No violations"; + return violations + .map((violation, index) => { + const fieldPath = violation.fieldPath?.fieldName.join(".") || "unknown"; + const message = violation.message + ? TemplateStrings.format(violation.message) + : "Validation failed"; + return `${index + 1}. ${violation.typeName}.${fieldPath}: ${message}`; + }) + .join("\n"); + }, + /** + * Returns the formatted error message from a violation with all placeholders replaced. + * + * Namespaced placeholders such as `${field.path}` and `${field.value}` are + * substituted with their corresponding values from the violation context. + * + * @param violation The constraint violation to format. + * @returns The formatted error message, or 'Validation failed' if no message is present. + * + * @example + * ```typescript + * import { type ConstraintViolation, Violations } from '@spine-event-engine/validation'; + * + * declare const violation: ConstraintViolation; + * const message = Violations.formatMessage(violation); + * // Returns: "Email must be valid. Provided: `invalid@`." + * ``` + */ + formatMessage(violation: ConstraintViolation): string { + return violation.message ? TemplateStrings.format(violation.message) : "Validation failed"; + }, + + /** + * Returns the field path from a violation as a dot-separated string. + * + * Converts the field path array (e.g., `['user', 'email']`) into a single + * dot-separated string (e.g., `'user.email'`). + * + * @param violation The constraint violation. + * @returns The field path as a string, or 'unknown' if no field path is present. + * + * @example + * ```typescript + * import { type ConstraintViolation, Violations } from '@spine-event-engine/validation'; + * + * declare const violation: ConstraintViolation; + * const path = Violations.failurePath(violation); + * // Returns: "user.email" + * ``` + */ + failurePath(violation: ConstraintViolation): string { + return violation.fieldPath?.fieldName.join(".") || "unknown"; + }, +} as const; diff --git a/packages/validation/tests/basic-validation.test.ts b/packages/validation/tests/basic-validation.test.ts new file mode 100644 index 0000000..ef8bdc0 --- /dev/null +++ b/packages/validation/tests/basic-validation.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `@spine-event-engine/validation` package. + * + * Tests basic validation functionality and violation formatting. + */ + +import { create } from "@bufbuild/protobuf"; +import { validate, Violations } from "../src/index.js"; +import { ConstraintViolationSchema } from "../src/generated/spine/validate/validation_error_pb.js"; + +describe("Basic Validation", () => { + it("should export `validate` function", () => { + expect(typeof validate).toBe("function"); + }); + + it("formats violations through the public Violations owner", () => { + expect(typeof Violations.formatAll).toBe("function"); + }); +}); + +describe("Format Violations", () => { + it('should return "No violations" for empty array', () => { + const result = Violations.formatAll([]); + expect(result).toBe("No violations"); + }); + + it("formats a field violation and gives useful fallbacks for incomplete diagnostics", () => { + const fieldViolation = create(ConstraintViolationSchema, { + typeName: "example.User", + fieldPath: { fieldName: ["email"] }, + message: { + withPlaceholders: "Invalid ${field.value}", + placeholderValue: { "field.value": "not-an-email" }, + }, + }); + const messageViolation = create(ConstraintViolationSchema, { typeName: "example.User" }); + + expect(Violations.formatAll([fieldViolation, messageViolation])).toBe( + "1. example.User.email: Invalid not-an-email\n2. example.User.unknown: Validation failed", + ); + expect(Violations.failurePath(fieldViolation)).toBe("email"); + expect(Violations.failurePath(messageViolation)).toBe("unknown"); + expect(Violations.formatMessage(messageViolation)).toBe("Validation failed"); + }); +}); diff --git a/packages/spine-validation-ts/tests/buf.gen.yaml b/packages/validation/tests/buf.gen.yaml similarity index 76% rename from packages/spine-validation-ts/tests/buf.gen.yaml rename to packages/validation/tests/buf.gen.yaml index cd57dcf..3213786 100644 --- a/packages/spine-validation-ts/tests/buf.gen.yaml +++ b/packages/validation/tests/buf.gen.yaml @@ -4,3 +4,4 @@ plugins: out: generated opt: - target=ts + - import_extension=js diff --git a/packages/validation/tests/buf.yaml b/packages/validation/tests/buf.yaml new file mode 100644 index 0000000..d0f1d57 --- /dev/null +++ b/packages/validation/tests/buf.yaml @@ -0,0 +1,87 @@ +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD + ignore_only: + PACKAGE_DEFINED: + - proto/spine/options.proto + - proto/spine/time_options.proto + PACKAGE_SAME_JAVA_PACKAGE: + - proto/spine/options.proto + - proto/spine/time_options.proto + PACKAGE_VERSION_SUFFIX: + - proto/integration-account.proto + - proto/integration-product.proto + - proto/integration-user.proto + - proto/test-choice.proto + - proto/test-distinct.proto + - proto/test-goes.proto + - proto/test-min-max.proto + - proto/test-pattern.proto + - proto/test-range.proto + - proto/test-required-field.proto + - proto/test-required.proto + - proto/test-validate.proto + - proto/spine/time/time.proto + - proto/test-when.proto + FILE_LOWER_SNAKE_CASE: + - proto/integration-account.proto + - proto/integration-product.proto + - proto/integration-user.proto + - proto/test-choice.proto + - proto/test-distinct.proto + - proto/test-goes.proto + - proto/test-min-max.proto + - proto/test-pattern.proto + - proto/test-range.proto + - proto/test-required-field.proto + - proto/test-required.proto + - proto/test-validate.proto + - proto/test-when.proto + FIELD_LOWER_SNAKE_CASE: + - proto/spine/options.proto + - proto/spine/time_options.proto + ENUM_NO_ALLOW_ALIAS: + - proto/spine/options.proto + - proto/spine/time_options.proto + ENUM_VALUE_PREFIX: + - proto/spine/options.proto + - proto/spine/time/time.proto + - proto/spine/time_options.proto + ENUM_ZERO_VALUE_SUFFIX: + - proto/spine/options.proto + - proto/spine/time/time.proto + - proto/spine/time_options.proto + DIRECTORY_SAME_PACKAGE: + - proto/integration-account.proto + - proto/integration-product.proto + - proto/integration-user.proto + - proto/test-choice.proto + - proto/test-distinct.proto + - proto/test-goes.proto + - proto/test-min-max.proto + - proto/test-pattern.proto + - proto/test-range.proto + - proto/test-required-field.proto + - proto/test-required.proto + - proto/test-validate.proto + - proto/test-when.proto + PACKAGE_DIRECTORY_MATCH: + - proto/integration-account.proto + - proto/integration-product.proto + - proto/integration-user.proto + - proto/test-choice.proto + - proto/test-distinct.proto + - proto/test-goes.proto + - proto/test-min-max.proto + - proto/test-pattern.proto + - proto/test-range.proto + - proto/test-required-field.proto + - proto/test-required.proto + - proto/test-validate.proto + - proto/test-when.proto +breaking: + use: + - FILE diff --git a/packages/validation/tests/choice.test.ts b/packages/validation/tests/choice.test.ts new file mode 100644 index 0000000..30afc1d --- /dev/null +++ b/packages/validation/tests/choice.test.ts @@ -0,0 +1,165 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { create } from "@bufbuild/protobuf"; +import { validate } from "../src/validation.js"; +import { + PaymentMethodSchema, + ContactMethodSchema, + ShippingOptionSchema, + MultipleRequiredChoicesSchema, +} from "./generated/test-choice_pb.js"; + +describe("Choice Option Validation (oneof)", () => { + describe("Basic Choice Validation", () => { + it("should pass when one field in oneof is set", () => { + const payment = create(PaymentMethodSchema, { + method: { + case: "creditCard", + value: "4111111111111111", + }, + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations).toHaveLength(0); + }); + + it("should fail when no field in required oneof is set", () => { + const payment = create(PaymentMethodSchema, { + // method `oneof` not set + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations.length).toBeGreaterThan(0); + + const choiceViolation = violations[0]; + expect(choiceViolation).toBeDefined(); + expect(choiceViolation?.fieldPath?.fieldName).toEqual([]); + expect(choiceViolation?.message?.placeholderValue?.["group.path"]).toBe("method"); + expect(choiceViolation?.message?.withPlaceholders).toContain("oneof"); + }); + + it("should pass when different field in oneof is set", () => { + const payment = create(PaymentMethodSchema, { + method: { + case: "bankAccount", + value: "123456789", + }, + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations).toHaveLength(0); + }); + + it("treats selected numeric zero and boolean false cases as present", () => { + const numeric = create(MultipleRequiredChoicesSchema, { + first: { case: "count", value: 0 }, + second: { case: "enabled", value: false }, + }); + expect(validate(MultipleRequiredChoicesSchema, numeric)).toHaveLength(0); + }); + }); + + describe("Custom Error Messages", () => { + it("should use custom error message when provided", () => { + const contact = create(ContactMethodSchema, { + // contact `oneof` not set, has custom error message + }); + + const violations = validate(ContactMethodSchema, contact); + expect(violations.length).toBeGreaterThan(0); + + const choiceViolation = violations[0]; + expect(choiceViolation).toBeDefined(); + expect(choiceViolation?.fieldPath?.fieldName).toEqual([]); + expect(choiceViolation?.message?.placeholderValue?.["group.path"]).toBe("contact"); + expect(choiceViolation?.message?.withPlaceholders).toContain("must provide a contact method"); + }); + }); + + describe("Optional Oneofs", () => { + it("should pass when optional oneof is not set", () => { + const shipping = create(ShippingOptionSchema, { + // delivery `oneof` is optional (choice.required = false) + }); + + const violations = validate(ShippingOptionSchema, shipping); + expect(violations).toHaveLength(0); + }); + + it("should pass when optional oneof has a field set", () => { + const shipping = create(ShippingOptionSchema, { + delivery: { + case: "standard", + value: true, + }, + }); + + const violations = validate(ShippingOptionSchema, shipping); + expect(violations).toHaveLength(0); + }); + }); + + describe("Multiple Oneofs in Same Message", () => { + it("emits one message-level violation per unset group in declaration order", () => { + const violations = validate( + MultipleRequiredChoicesSchema, + create(MultipleRequiredChoicesSchema), + ); + expect(violations).toHaveLength(2); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([[], []]); + expect( + violations.map((violation) => violation.message?.placeholderValue["group.path"]), + ).toEqual(["first", "second"]); + }); + }); + + describe("Edge Cases", () => { + it("should handle message with no oneofs", () => { + // Most messages don't have oneofs, should not cause errors + const payment = create(PaymentMethodSchema, { + method: { + case: "creditCard", + value: "4111111111111111", + }, + }); + + const violations = validate(PaymentMethodSchema, payment); + expect(violations).toHaveLength(0); + }); + + it("should provide clear field path in violation", () => { + const payment = create(PaymentMethodSchema, {}); + + const violations = validate(PaymentMethodSchema, payment); + const choiceViolation = violations[0]; + + expect(choiceViolation?.fieldPath?.fieldName).toEqual([]); + expect(choiceViolation?.message?.placeholderValue?.["group.path"]).toBe("method"); + expect(choiceViolation?.typeName).toBe("test.PaymentMethod"); + }); + }); +}); diff --git a/packages/validation/tests/distinct.test.ts b/packages/validation/tests/distinct.test.ts new file mode 100644 index 0000000..a2feafc --- /dev/null +++ b/packages/validation/tests/distinct.test.ts @@ -0,0 +1,552 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `(distinct)` validation option. + * + * Tests uniqueness validation for repeated fields. + */ + +import { create } from "@bufbuild/protobuf"; + +const atLeast = (value: number, minimum: number): void => + expect(value)["toBeGreaterThanOrEqual"](minimum); +import { + anyUnpack, + BytesValueSchema, + Int64ValueSchema, + StringValueSchema, +} from "@bufbuild/protobuf/wkt"; +import { ValidationConfigurationError, validate } from "../src/index.js"; + +import { + DistinctPrimitivesSchema, + DistinctEnumsSchema, + Status as DistinctStatus, + NonDistinctFieldsSchema, + CombinedConstraintsSchema as DistinctCombinedConstraintsSchema, + OptionalDistinctSchema, + UserProfileSchema, + ShoppingCartSchema, + DistinctNumericTypesSchema, + DistinctEdgeCasesSchema, + DistinctAdvancedSchema, + DistinctCustomMessageSchema, + DistinctDisabledSchema, + DistinctUnsupportedTargetSchema, + DistinctValueSchema, +} from "./generated/test-distinct_pb.js"; + +describe("Distinct Validation", () => { + describe("Primitive Types with Distinct", () => { + it("should pass when all elements are unique", () => { + const valid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 3, 4, 5], + tags: ["alpha", "beta", "gamma"], + scores: [85.5, 92.3, 78.9], + flags: [true, false], + }); + + const violations = validate(DistinctPrimitivesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when numbers have duplicates", () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 3, 2, 4], // 2 is duplicated at indices 1 and 3. + tags: ["alpha", "beta", "gamma"], + scores: [85.5, 92.3, 78.9], + flags: [true, false], + }); + + const violations = validate(DistinctPrimitivesSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const numberViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "numbers"); + expect(numberViolation).toBeDefined(); + expect(numberViolation?.fieldPath?.fieldName).toEqual(["numbers"]); + expect(numberViolation?.message?.placeholderValue?.["field.value"]).toBe("[1, 2, 3, 2, 4]"); + expect(numberViolation?.message?.placeholderValue?.["field.duplicates"]).toBe("[2]"); + }); + + it("should fail when strings have duplicates", () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 3], + tags: ["alpha", "beta", "alpha", "gamma"], // 'alpha' duplicated. + scores: [85.5, 92.3, 78.9], + flags: [true, false], + }); + + const violations = validate(DistinctPrimitivesSchema, invalid); + const tagViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "tags"); + expect(tagViolation).toBeDefined(); + expect(tagViolation?.message?.placeholderValue?.["field.duplicates"]).toBe("[alpha]"); + }); + + it("should fail when doubles have duplicates", () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 3], + tags: ["alpha", "beta", "gamma"], + scores: [85.5, 92.3, 85.5, 78.9], // 85.5 duplicated. + flags: [true, false], + }); + + const violations = validate(DistinctPrimitivesSchema, invalid); + const scoreViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "scores"); + expect(scoreViolation).toBeDefined(); + }); + + it("should detect multiple duplicates in same field", () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 1, 3, 2, 4], // Both 1 and 2 duplicated. + tags: ["alpha"], + scores: [85.5], + flags: [true], + }); + + const violations = validate(DistinctPrimitivesSchema, invalid); + const numberViolations = violations.filter((v) => v.fieldPath?.fieldName[0] === "numbers"); + expect(numberViolations.length).toBe(2); // Two violations for two duplicates. + }); + + it("emits one violation per duplicate class in first-occurrence order", () => { + const invalid = create(DistinctPrimitivesSchema, { + numbers: [1, 2, 1, 2, 1, 2], + tags: ["A", "A", "A", "A", "B", "B", "C", "D"], + scores: [], + flags: [], + }); + + const violations = validate(DistinctPrimitivesSchema, invalid).filter( + (violation) => violation.fieldPath?.fieldName[0] === "tags", + ); + + expect(violations).toHaveLength(2); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["tags"], + ["tags"], + ]); + expect( + violations.map((violation) => anyUnpack(violation.fieldValue!, StringValueSchema)?.value), + ).toEqual(["A", "B"]); + expect( + violations.map((violation) => violation.message?.placeholderValue?.["field.value"]), + ).toEqual(["[A, A, A, A, B, B, C, D]", "[A, A, A, A, B, B, C, D]"]); + expect( + violations.map((violation) => violation.message?.placeholderValue?.["field.duplicates"]), + ).toEqual(["[A]", "[B]"]); + }); + }); + + describe("Enum Fields with Distinct", () => { + it("should pass when all enum values are unique", () => { + const valid = create(DistinctEnumsSchema, { + statuses: [DistinctStatus.ACTIVE, DistinctStatus.INACTIVE, DistinctStatus.PENDING], + }); + + const violations = validate(DistinctEnumsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when enum values are duplicated", () => { + const invalid = create(DistinctEnumsSchema, { + statuses: [DistinctStatus.ACTIVE, DistinctStatus.INACTIVE, DistinctStatus.ACTIVE], + }); + + const violations = validate(DistinctEnumsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const statusViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "statuses"); + expect(statusViolation).toBeDefined(); + }); + }); + + describe("Non-Distinct Fields (Control Group)", () => { + it("should allow duplicates when `distinct` is not set", () => { + const withDuplicates = create(NonDistinctFieldsSchema, { + numbers: [1, 2, 2, 3, 3, 3], // Duplicates allowed. + tags: ["alpha", "alpha", "beta"], // Duplicates allowed. + }); + + const violations = validate(NonDistinctFieldsSchema, withDuplicates); + expect(violations).toHaveLength(0); // No violations - duplicates are OK. + }); + }); + + describe("Combined Constraints (Distinct + Other Options)", () => { + it("should pass when all constraints are satisfied", () => { + const valid = create(DistinctCombinedConstraintsSchema, { + productIds: [1, 100, 500, 999], // Distinct and within range. + emails: ["user1@example.com", "user2@example.com"], // Distinct and match pattern. + scores: [75, 85, 92], // Distinct and within min/max. + }); + + const violations = validate(DistinctCombinedConstraintsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect `distinct` violation even when `range` is satisfied", () => { + const invalid = create(DistinctCombinedConstraintsSchema, { + productIds: [100, 200, 100], // Duplicate but within range. + emails: ["user1@example.com", "user2@example.com"], + scores: [75, 85, 92], + }); + + const violations = validate(DistinctCombinedConstraintsSchema, invalid); + const distinctViolation = violations.find( + (v) => + v.fieldPath?.fieldName[0] === "product_ids" && + v.message?.withPlaceholders.includes("must not contain duplicates"), + ); + expect(distinctViolation).toBeDefined(); + }); + + it("should detect `distinct` violation in repeated emails", () => { + const invalid = create(DistinctCombinedConstraintsSchema, { + productIds: [100, 200, 300], + emails: ["user1@example.com", "user2@example.com", "user1@example.com"], // Duplicate. + scores: [75, 85, 92], + }); + + const violations = validate(DistinctCombinedConstraintsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have distinct violation for duplicate email. + const distinctViolation = violations.find( + (v) => + v.fieldPath?.fieldName[0] === "emails" && + v.message?.withPlaceholders.includes("must not contain duplicates"), + ); + expect(distinctViolation).toBeDefined(); + expect(distinctViolation?.message?.placeholderValue?.["field.duplicates"]).toBe( + "[user1@example.com]", + ); + }); + + it("should detect both `distinct` and `range` violations", () => { + const invalid = create(DistinctCombinedConstraintsSchema, { + productIds: [100, 200, 300], + emails: ["user1@example.com", "user2@example.com"], + scores: [75, 101, 75], // 101 violates max, 75 is duplicate. + }); + + const violations = validate(DistinctCombinedConstraintsSchema, invalid); + atLeast(violations.length, 2); + + const rangeViolation = violations.find( + (v) => + v.fieldPath?.fieldName[0] === "scores" && + v.message?.placeholderValue?.["max.operator"] === "<=", + ); + expect(rangeViolation).toBeDefined(); + + const distinctViolation = violations.find((v) => + v.message?.withPlaceholders.includes("must not contain duplicates"), + ); + expect(distinctViolation).toBeDefined(); + }); + }); + + describe("Optional/Empty Repeated Fields", () => { + it("should pass when repeated fields are empty", () => { + const empty = create(OptionalDistinctSchema, { + optionalNumbers: [], + optionalTags: [], + }); + + const violations = validate(OptionalDistinctSchema, empty); + expect(violations).toHaveLength(0); + }); + + it("should pass when repeated field has single element", () => { + const singleElement = create(OptionalDistinctSchema, { + optionalNumbers: [42], + optionalTags: ["solo"], + }); + + const violations = validate(OptionalDistinctSchema, singleElement); + expect(violations).toHaveLength(0); + }); + + it("should `validate` when optional fields have multiple elements", () => { + const invalid = create(OptionalDistinctSchema, { + optionalNumbers: [1, 2, 1], // Duplicate. + optionalTags: ["tag1", "tag2"], + }); + + const violations = validate(OptionalDistinctSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe("Real-World Scenarios", () => { + it("should `validate` user profile with `distinct` tags", () => { + const valid = create(UserProfileSchema, { + username: "johndoe", + tags: ["developer", "typescript", "nodejs"], + skills: ["javascript", "react", "python"], + }); + + const violations = validate(UserProfileSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should reject user profile with duplicate tags", () => { + const invalid = create(UserProfileSchema, { + username: "johndoe", + tags: ["developer", "typescript", "developer"], // Duplicate. + skills: ["javascript", "react", "python"], + }); + + const violations = validate(UserProfileSchema, invalid); + const tagViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "tags"); + expect(tagViolation).toBeDefined(); + }); + + it("should `validate` shopping cart with unique product IDs", () => { + const valid = create(ShoppingCartSchema, { + productIds: [101, 202, 303], + couponCodes: ["SUMMER2024", "FREESHIP"], + }); + + const violations = validate(ShoppingCartSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should reject shopping cart with duplicate product IDs", () => { + const invalid = create(ShoppingCartSchema, { + productIds: [101, 202, 101], // Duplicate product. + couponCodes: ["SUMMER2024", "FREESHIP"], + }); + + const violations = validate(ShoppingCartSchema, invalid); + const productViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "product_ids"); + expect(productViolation).toBeDefined(); + }); + + it("should reject duplicate coupon codes", () => { + const invalid = create(ShoppingCartSchema, { + productIds: [101, 202, 303], + couponCodes: ["SUMMER2024", "FREESHIP", "SUMMER2024"], // Duplicate. + }); + + const violations = validate(ShoppingCartSchema, invalid); + const couponViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "coupon_codes"); + expect(couponViolation).toBeDefined(); + }); + }); + + describe("Different Numeric Types with Distinct", () => { + it("should `validate` `distinct` for all numeric types", () => { + const valid = create(DistinctNumericTypesSchema, { + int32Values: [1, 2, 3], + int64Values: [100n, 200n, 300n], + uint32Values: [10, 20, 30], + uint64Values: [1000n, 2000n, 3000n], + floatValues: [1.1, 2.2, 3.3], + doubleValues: [10.1, 20.2, 30.3], + }); + + const violations = validate(DistinctNumericTypesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect duplicates in int64 fields", () => { + const invalid = create(DistinctNumericTypesSchema, { + int32Values: [1, 2, 3], + int64Values: [100n, 200n, 100n], // Duplicate. + uint32Values: [10, 20, 30], + uint64Values: [1000n, 2000n, 3000n], + floatValues: [1.1, 2.2, 3.3], + doubleValues: [10.1, 20.2, 30.3], + }); + + const violations = validate(DistinctNumericTypesSchema, invalid); + const int64Violation = violations.find((v) => v.fieldPath?.fieldName[0] === "int64_values"); + expect(int64Violation).toBeDefined(); + }); + }); + + describe("Edge Cases", () => { + it("should treat empty strings as duplicates", () => { + const invalid = create(DistinctEdgeCasesSchema, { + emptyStrings: ["", "value", ""], // Two empty strings. + zeros: [0, 1, 2], + caseSensitive: ["Tag", "tag"], + }); + + const violations = validate(DistinctEdgeCasesSchema, invalid); + const emptyViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "empty_strings"); + expect(emptyViolation).toBeDefined(); + }); + + it("should treat zeros as duplicates", () => { + const invalid = create(DistinctEdgeCasesSchema, { + emptyStrings: ["value1", "value2"], + zeros: [0, 1, 0], // Two zeros. + caseSensitive: ["Tag", "tag"], + }); + + const violations = validate(DistinctEdgeCasesSchema, invalid); + const zeroViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "zeros"); + expect(zeroViolation).toBeDefined(); + }); + + it("should be case-sensitive for strings", () => { + const valid = create(DistinctEdgeCasesSchema, { + emptyStrings: ["value1", "value2"], + zeros: [0, 1, 2], + caseSensitive: ["Tag", "tag", "TAG"], // All different due to case. + }); + + const violations = validate(DistinctEdgeCasesSchema, valid); + expect(violations).toHaveLength(0); // No violations - case matters. + }); + + it("should detect case-insensitive duplicates correctly", () => { + const invalid = create(DistinctEdgeCasesSchema, { + emptyStrings: ["value1", "value2"], + zeros: [0, 1, 2], + caseSensitive: ["Tag", "tag", "Tag"], // 'Tag' duplicated (exact match). + }); + + const violations = validate(DistinctEdgeCasesSchema, invalid); + const caseViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "case_sensitive"); + expect(caseViolation).toBeDefined(); + }); + }); +}); + +describe("descriptor-aware distinct equality", () => { + it("uses Buf scalar equality for bytes and bigint values", () => { + const invalid = create(DistinctAdvancedSchema, { + byteValues: [new Uint8Array([0xca, 0xfe]), new Uint8Array([0xca, 0xfe])], + int64Values: [9007199254740993n, 9007199254740993n], + }); + + const violations = validate(DistinctAdvancedSchema, invalid); + const byteViolation = violations.find( + (violation) => violation.fieldPath?.fieldName[0] === "byte_values", + ); + const int64Violation = violations.find( + (violation) => violation.fieldPath?.fieldName[0] === "int64_values", + ); + + expect(byteViolation).toBeDefined(); + expect(int64Violation).toBeDefined(); + expect(anyUnpack(byteViolation!.fieldValue!, BytesValueSchema)?.value).toEqual( + new Uint8Array([0xca, 0xfe]), + ); + expect(anyUnpack(int64Violation!.fieldValue!, Int64ValueSchema)?.value).toBe(9007199254740993n); + }); + + it("uses numeric enum equality and structural message equality", () => { + const first = create(DistinctValueSchema, { name: "same", sequence: 7n }); + const second = create(DistinctValueSchema, { name: "same", sequence: 7n }); + const invalid = create(DistinctAdvancedSchema, { + statuses: [DistinctStatus.ACTIVE, DistinctStatus.ACTIVE], + messages: [first, second], + }); + + const violations = validate(DistinctAdvancedSchema, invalid); + expect( + violations.filter((violation) => violation.fieldPath?.fieldName[0] === "statuses"), + ).toHaveLength(1); + const messageViolation = violations.find( + (violation) => violation.fieldPath?.fieldName[0] === "messages", + ); + expect(messageViolation).toBeDefined(); + expect(anyUnpack(messageViolation!.fieldValue!, DistinctValueSchema)).toEqual(first); + }); + + it("compares scalar, enum, and message map values in entry order", () => { + const duplicate = create(DistinctValueSchema, { name: "duplicate", sequence: 1n }); + const invalid = create(DistinctAdvancedSchema, { + names: { first: "A", second: "A", third: "B", fourth: "B" }, + stateByName: { first: DistinctStatus.ACTIVE, second: DistinctStatus.ACTIVE }, + valueByName: { + first: duplicate, + second: create(DistinctValueSchema, { name: "duplicate", sequence: 1n }), + }, + }); + + const violations = validate(DistinctAdvancedSchema, invalid); + const nameViolations = violations.filter( + (violation) => violation.fieldPath?.fieldName[0] === "names", + ); + expect(nameViolations).toHaveLength(2); + expect( + nameViolations.map((violation) => anyUnpack(violation.fieldValue!, StringValueSchema)?.value), + ).toEqual(["A", "B"]); + expect( + nameViolations.map((violation) => violation.message?.placeholderValue?.["field.value"]), + ).toEqual(["{first=A, second=A, third=B, fourth=B}", "{first=A, second=A, third=B, fourth=B}"]); + expect( + nameViolations.map((violation) => violation.message?.placeholderValue?.["field.duplicates"]), + ).toEqual(["[A]", "[B]"]); + expect( + violations.filter((violation) => violation.fieldPath?.fieldName[0] === "state_by_name"), + ).toHaveLength(1); + expect( + violations.filter((violation) => violation.fieldPath?.fieldName[0] === "value_by_name"), + ).toHaveLength(1); + }); + + it("uses the frozen default message or a non-empty custom message", () => { + const defaultViolation = validate( + DistinctPrimitivesSchema, + create(DistinctPrimitivesSchema, { numbers: [1, 1] }), + )[0]; + const customViolation = validate( + DistinctCustomMessageSchema, + create(DistinctCustomMessageSchema, { values: ["A", "A"] }), + )[0]; + + expect(defaultViolation.message?.withPlaceholders).toContain("must not contain duplicates"); + expect(customViolation.message?.withPlaceholders).toBe( + "Duplicate class: `${field.duplicates}`.", + ); + }); + + it("rejects distinct on a singular field with a structured configuration error", () => { + expect(() => + validate(DistinctUnsupportedTargetSchema, create(DistinctUnsupportedTargetSchema)), + ).toThrow( + expect.objectContaining({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "distinct", + typeName: DistinctUnsupportedTargetSchema.typeName, + fieldPath: ["name"], + }) satisfies Partial<ValidationConfigurationError>, + ); + }); + + it("treats an explicit false declaration as a no-op", () => { + expect( + validate(DistinctDisabledSchema, create(DistinctDisabledSchema, { values: ["A", "A"] })), + ).toEqual([]); + }); +}); diff --git a/packages/validation/tests/goes.test.ts b/packages/validation/tests/goes.test.ts new file mode 100644 index 0000000..0f35cb9 --- /dev/null +++ b/packages/validation/tests/goes.test.ts @@ -0,0 +1,506 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `(goes)` validation option. + * + * Tests field dependency validation (field can only be set if another field is set). + */ + +import { create } from "@bufbuild/protobuf"; +import { validate } from "../src/index.js"; + +import { + ScheduledEventSchema, + ShippingDetailsSchema, + ColorSettingsSchema, + PaymentInfoSchema, + DocumentMetadataSchema, + TimestampSchema, + SecureAccountSchema, + SimpleConfigSchema, + FeatureFlagsSchema, + FeatureLevel, + ReportGenerationSchema, + OptionalSettingsSchema, + AdvancedConfigSchema, + InvalidGoesTargetSchema, + InvalidGoesUnknownCompanionSchema as InvalidGoesUnknownSchema, + InvalidGoesNumericCompanionSchema as InvalidGoesNumericSchema, +} from "./generated/test-goes_pb.js"; + +describe("Field Dependency Validation (goes)", () => { + describe("Basic Goes Constraint", () => { + it("should pass when dependent field is not set", () => { + const valid = create(ScheduledEventSchema, { + eventName: "Team Meeting", + date: "", + // time not set - valid because time is only required when date is set. + }); + + const violations = validate(ScheduledEventSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when both fields are set", () => { + const valid = create(ScheduledEventSchema, { + eventName: "Team Meeting", + date: "2024-12-25", + time: "14:30", + }); + + const violations = validate(ScheduledEventSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when dependent field is set but `required` field is not", () => { + const invalid = create(ScheduledEventSchema, { + eventName: "Team Meeting", + date: "", // Not set. + time: "14:30", // Set - violates (goes).with = "date". + }); + + const violations = validate(ScheduledEventSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const goesViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "time"); + expect(goesViolation).toBeDefined(); + expect(goesViolation?.message?.placeholderValue).toMatchObject({ + "goes.companion": "date", + "field.path": "time", + "field.value": "14:30", + }); + expect(goesViolation?.fieldValue?.typeUrl).toContain("StringValue"); + }); + + it("should pass when both fields are unset", () => { + const valid = create(ScheduledEventSchema, { + eventName: "Team Meeting", + // Both date and time are unset - valid. + }); + + const violations = validate(ScheduledEventSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe("Custom Error Messages", () => { + it("should use custom error message from (`goes`).error_msg", () => { + const invalid = create(ShippingDetailsSchema, { + address: "", // Not set. + trackingNumber: "TRACK123", // Set - violates goes constraint. + }); + + const violations = validate(ShippingDetailsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const goesViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "tracking_number"); + expect(goesViolation).toBeDefined(); + expect(goesViolation?.message?.withPlaceholders).toBe( + "Tracking number requires a shipping address: {value}.", + ); + }); + + it("should pass when both fields are set", () => { + const valid = create(ShippingDetailsSchema, { + address: "123 Main St", + trackingNumber: "TRACK123", + }); + + const violations = validate(ShippingDetailsSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe("Mutual Dependencies (Bidirectional)", () => { + it("should pass when both fields are set", () => { + const valid = create(ColorSettingsSchema, { + textColor: "#000000", + highlightColor: "#FFFF00", + }); + + const violations = validate(ColorSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when both fields are unset", () => { + const valid = create(ColorSettingsSchema, { + // Both unset. + }); + + const violations = validate(ColorSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when only text_color is set", () => { + const invalid = create(ColorSettingsSchema, { + textColor: "#000000", + highlightColor: "", // Not set. + }); + + const violations = validate(ColorSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const textColorViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "text_color"); + expect(textColorViolation).toBeDefined(); + }); + + it("should fail when only highlight_color is set", () => { + const invalid = create(ColorSettingsSchema, { + textColor: "", // Not set. + highlightColor: "#FFFF00", + }); + + const violations = validate(ColorSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const highlightViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "highlight_color", + ); + expect(highlightViolation).toBeDefined(); + }); + }); + + describe("Multiple Independent Goes Constraints", () => { + it("should pass when all fields are set", () => { + const valid = create(PaymentInfoSchema, { + cardholderName: "John Doe", + cardNumber: "4111111111111111", + cvv: "123", + expiryMonth: 12, + }); + + const violations = validate(PaymentInfoSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when card_number is set but cardholder_name is not", () => { + const invalid = create(PaymentInfoSchema, { + cardholderName: "", // Not set. + cardNumber: "4111111111111111", // Violates goes constraint. + cvv: "", + expiryMonth: 0, + }); + + const violations = validate(PaymentInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const cardNumberViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "card_number", + ); + expect(cardNumberViolation).toBeDefined(); + }); + + it("should fail when cvv is set but card_number is not", () => { + const invalid = create(PaymentInfoSchema, { + cardholderName: "John Doe", + cardNumber: "", // Not set. + cvv: "123", // Violates goes constraint. + expiryMonth: 0, + }); + + const violations = validate(PaymentInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const cvvViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "cvv"); + expect(cvvViolation).toBeDefined(); + }); + + it("should detect multiple `goes` violations", () => { + const invalid = create(PaymentInfoSchema, { + cardholderName: "", // Not set. + cardNumber: "4111111111111111", // Violates (cardholder_name missing). + cvv: "123", // Violates (card_number dependency). + expiryMonth: 12, // Violates (card_number dependency). + }); + + const violations = validate(PaymentInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const cardNumberViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "card_number", + ); + expect(cardNumberViolation).toBeDefined(); + + // Note: cvv and `expiry_month` don't violate because `card_number` IS set. + // Only `card_number` violates because `cardholder_name` is NOT set. + }); + }); + + describe("Configuration errors", () => { + it("rejects a numeric `(goes)` target even when it is unset", () => { + expect(() => validate(InvalidGoesTargetSchema, create(InvalidGoesTargetSchema))).toThrow( + expect.objectContaining({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "goes", + fieldPath: ["display_id"], + }), + ); + }); + it("rejects unknown and unsupported companions", () => { + expect(() => validate(InvalidGoesUnknownSchema, create(InvalidGoesUnknownSchema))).toThrow( + expect.objectContaining({ + code: "UNKNOWN_FIELD_REFERENCE", + option: "goes", + typeName: InvalidGoesUnknownSchema.typeName, + fieldPath: ["value"], + }), + ); + expect(() => validate(InvalidGoesNumericSchema, create(InvalidGoesNumericSchema))).toThrow( + expect.objectContaining({ + code: "INVALID_FIELD_REFERENCE", + option: "goes", + typeName: InvalidGoesNumericSchema.typeName, + fieldPath: ["number"], + }), + ); + }); + + it("should `validate` `goes` constraint on message field", () => { + const invalid = create(DocumentMetadataSchema, { + title: "", // Not set. + createdAt: create(TimestampSchema, { + seconds: BigInt(1234567890), + nanos: 0, + }), // Set - violates goes constraint. + }); + + const violations = validate(DocumentMetadataSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const createdAtViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "created_at"); + expect(createdAtViolation).toBeDefined(); + }); + }); + + describe("Without Goes Constraint (Control Group)", () => { + it("should allow independent fields without `goes` constraint", () => { + const valid = create(SimpleConfigSchema, { + primaryOption: "", + secondaryOption: "some value", // Can be set independently. + }); + + const violations = validate(SimpleConfigSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe("Goes with Enum Fields", () => { + it("should pass when both enum and dependent field are set", () => { + const valid = create(FeatureFlagsSchema, { + level: FeatureLevel.PREMIUM, + customConfig: "advanced-settings", + }); + + const violations = validate(FeatureFlagsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when dependent field is set but enum is unspecified", () => { + const invalid = create(FeatureFlagsSchema, { + level: FeatureLevel.UNSPECIFIED, // Default/unset. + customConfig: "advanced-settings", // Violates goes constraint. + }); + + const violations = validate(FeatureFlagsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const configViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "custom_config"); + expect(configViolation).toBeDefined(); + }); + }); + + describe("Chain Dependencies", () => { + it("should `validate` independent chain dependencies", () => { + const valid = create(ReportGenerationSchema, { + reportType: "monthly", + outputFormat: "pdf", + emailRecipient: "admin@example.com", + schedule: "daily", + }); + + const violations = validate(ReportGenerationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when output_format is set but report_type is not", () => { + const invalid = create(ReportGenerationSchema, { + reportType: "", // Not set. + outputFormat: "pdf", // Violates goes constraint. + emailRecipient: "", + schedule: "", + }); + + const violations = validate(ReportGenerationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const formatViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "output_format"); + expect(formatViolation).toBeDefined(); + }); + + it("should fail when schedule is set but output_format is not", () => { + const invalid = create(ReportGenerationSchema, { + reportType: "monthly", + outputFormat: "", // Not set. + emailRecipient: "", + schedule: "daily", // Violates goes constraint (depends on output_format). + }); + + const violations = validate(ReportGenerationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const scheduleViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "schedule"); + expect(scheduleViolation).toBeDefined(); + }); + }); + + describe("Optional Fields with Goes", () => { + it("should pass when base field and dependent fields are all set", () => { + const valid = create(OptionalSettingsSchema, { + baseUrl: "https://api.example.com", + port: 8080, + path: "/v1/api", + }); + + const violations = validate(OptionalSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when all fields are unset", () => { + const valid = create(OptionalSettingsSchema, { + // All unset. + }); + + const violations = validate(OptionalSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("does not apply `(goes)` to an unsupported numeric field", () => { + const invalid = create(OptionalSettingsSchema, { + baseUrl: "", // Not set. + port: 8080, + path: "", + }); + + const violations = validate(OptionalSettingsSchema, invalid); + expect(violations).toHaveLength(0); + }); + }); + + describe("Combined Constraints (Goes + Other Options)", () => { + it("should pass when all constraints are satisfied", () => { + const valid = create(SecureAccountSchema, { + username: "john_doe", + password: "securepass123", + recoveryEmail: "john@example.com", + recoveryPhone: "+1234567890", + }); + + const violations = validate(SecureAccountSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect `goes` violation when recovery_phone is set without recovery_email", () => { + const invalid = create(SecureAccountSchema, { + username: "john_doe", + password: "securepass123", + recoveryEmail: "", // Not set. + recoveryPhone: "+1234567890", // Violates goes constraint. + }); + + const violations = validate(SecureAccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const phoneViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "recovery_phone"); + expect(phoneViolation).toBeDefined(); + }); + + it("should detect both `pattern` and `goes` violations", () => { + const invalid = create(SecureAccountSchema, { + username: "ab", // Too short - violates pattern. + password: "short", // Too short - violates pattern. + recoveryEmail: "invalid", // Invalid format - violates pattern (but is "set" for goes purposes). + recoveryPhone: "+1234567890", // Does NOT violate goes because recovery_email IS set (even though invalid). + }); + + const violations = validate(SecureAccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have pattern violations for username, password, and `recovery_email`. + const usernameViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "username"); + expect(usernameViolation).toBeDefined(); + + const passwordViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "password"); + expect(passwordViolation).toBeDefined(); + + const emailViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "recovery_email"); + expect(emailViolation).toBeDefined(); + + // Note: `recovery_phone` does NOT violate goes constraint because `recovery_email` IS set. + // (goes checks if field is set, not if it's valid). + }); + + it("should `validate` `goes` combined with `range` constraint", () => { + const valid = create(AdvancedConfigSchema, { + configName: "production", + maxConnections: 500, // Within range [1..1000]. + timeoutSeconds: 30.0, // Above min 0.1. + }); + + const violations = validate(AdvancedConfigSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect `range` violation even when `goes` constraint is satisfied", () => { + const invalid = create(AdvancedConfigSchema, { + configName: "production", + maxConnections: 2000, // Exceeds range [1..1000]. + timeoutSeconds: 30.0, + }); + + const violations = validate(AdvancedConfigSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const rangeViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "max_connections", + ); + expect(rangeViolation).toBeDefined(); + expect(rangeViolation?.message?.placeholderValue?.["range.value"]).toBe("[1..1000]"); + }); + + it("continues numeric range validation without an unsupported `(goes)` target", () => { + const invalid = create(AdvancedConfigSchema, { + configName: "", // Not set. + maxConnections: 500, + timeoutSeconds: 0, + }); + + const violations = validate(AdvancedConfigSchema, invalid); + expect(violations).toHaveLength(1); + expect(violations[0].fieldPath?.fieldName).toEqual(["timeout_seconds"]); + }); + }); +}); diff --git a/packages/validation/tests/integration.test.ts b/packages/validation/tests/integration.test.ts new file mode 100644 index 0000000..5c828a1 --- /dev/null +++ b/packages/validation/tests/integration.test.ts @@ -0,0 +1,657 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Integration tests combining multiple validation options. + * + * Tests real-world scenarios with complex validation constraints. + */ + +import { create } from "@bufbuild/protobuf"; + +const atLeast = (value: number, minimum: number): void => + expect(value)["toBeGreaterThanOrEqual"](minimum); +import { validate, Violations } from "../src/index.js"; + +import { UserSchema, Role, GetUserResponseSchema } from "./generated/integration-user_pb.js"; +import { AccountSchema, AccountType } from "./generated/integration-account_pb.js"; +import { + SecureAccountSchema, + AdvancedConfigSchema, + ColorSettingsSchema, + ScheduledEventSchema, +} from "./generated/test-goes_pb.js"; + +describe("Integration Tests", () => { + it("should `validate` User message with multiple constraint types", () => { + const validUser = create(UserSchema, { + id: 1, + name: "John Doe", + email: "john.doe@example.com", + role: Role.ADMIN, + tags: ["developer", "typescript"], + }); + + const violations = validate(UserSchema, validUser); + expect(violations).toHaveLength(0); + }); + + it("should detect both `required` and `pattern` violations", () => { + const invalidUser = create(UserSchema, { + id: 1, + name: "", // Required violation. + email: "bad@", // Pattern violation. + role: Role.USER, + tags: [], + }); + + const violations = validate(UserSchema, invalidUser); + atLeast(violations.length, 2); + + const fieldNames = violations.map((v) => v.fieldPath?.fieldName[0]); + expect(fieldNames).toContain("name"); + expect(fieldNames).toContain("email"); + }); + + it("should format violations correctly", () => { + const invalidUser = create(UserSchema, { + id: 6, + name: "", + email: "", + role: Role.USER, + tags: [], + }); + + const violations = validate(UserSchema, invalidUser); + const formatted = Violations.formatAll(violations); + + expect(formatted).toContain("spine.validation.testing.integration.User.name"); + expect(formatted).toContain("spine.validation.testing.integration.User.email"); + expect(formatted).toContain("must have a non-default value"); + }); + + it("should `validate` User with `distinct` tags", () => { + const validUser = create(UserSchema, { + id: 1, + name: "John Doe", + email: "john.doe@example.com", + role: Role.ADMIN, + tags: ["developer", "typescript", "nodejs", "react"], // All distinct. + }); + + const violations = validate(UserSchema, validUser); + expect(violations).toHaveLength(0); + }); + + it("should detect duplicate tags in User", () => { + const invalidUser = create(UserSchema, { + id: 1, + name: "John Doe", + email: "john.doe@example.com", + role: Role.ADMIN, + tags: ["developer", "typescript", "developer", "nodejs"], // 'developer' duplicated. + }); + + const violations = validate(UserSchema, invalidUser); + expect(violations.length).toBeGreaterThan(0); + + const tagViolation = violations.find( + (v) => + v.fieldPath?.fieldName[0] === "tags" && + v.message?.withPlaceholders.includes("must not contain duplicates"), + ); + expect(tagViolation).toBeDefined(); + expect(tagViolation?.message?.placeholderValue?.["field.duplicates"]).toBe("[developer]"); + }); + + it("should detect multiple constraint violations including `distinct`", () => { + const invalidUser = create(UserSchema, { + id: 1, + name: "1", // Too short (pattern violation). + email: "invalid", // Pattern violation. + role: Role.USER, + tags: ["tag1", "tag2", "tag1"], // Distinct violation. + }); + + const violations = validate(UserSchema, invalidUser); + atLeast(violations.length, 3); + + const fieldNames = violations.map((v) => v.fieldPath?.fieldName[0]); + expect(fieldNames).toContain("name"); + expect(fieldNames).toContain("email"); + expect(fieldNames).toContain("tags"); + }); + + it("should `validate` Account with combined `required_field`, `required`, `pattern`, `min`/`max`, and `range` constraints", () => { + // Valid account with `id` provided (satisfies `required_field`). + const validAccount = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.PREMIUM, + age: 25, // Within range [13..120]. + balance: 5000.0, // Within min/max [0.0..1000000.0]. + failedLoginAttempts: 0, // Within range [0..5]. + rating: 4.5, // Within range [1.0..5.0]. + }); + + const violations = validate(AccountSchema, validAccount); + expect(violations).toHaveLength(0); + }); + + it("should `validate` Account with second field provided instead of first", () => { + // Note: `id` has `(min).value="1"`, so we provide a valid ID even though. + // the `required_field` "id | email" would be satisfied by email alone. + // Proto3 doesn't allow truly "unset" numeric fields (they default to 0). + const validAccount = create(AccountSchema, { + id: 1, // Provide valid ID (>= 1) to avoid min violation. + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.FREE, + age: 18, // Within range. + balance: 100.0, + failedLoginAttempts: 2, + rating: 3.0, + }); + + const violations = validate(AccountSchema, validAccount); + expect(violations).toHaveLength(0); + }); + + it("should detect `required_field` violation when neither `required` field is provided", () => { + const invalid = create(AccountSchema, { + id: 0, + email: "", // Violates both (required_field) and (required). + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.FREE, + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have violations for `required_field`, required email, or both. + const hasRequiredFieldViolation = violations.some((v) => + v.message?.withPlaceholders.includes("id | email"), + ); + const hasRequiredEmailViolation = violations.some((v) => v.fieldPath?.fieldName[0] === "email"); + + expect(hasRequiredFieldViolation || hasRequiredEmailViolation).toBe(true); + }); + + it("should detect `pattern` violation in username field", () => { + const invalid = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "ab", // Too short, violates pattern. + password: "secure_password_123", + accountType: AccountType.FREE, + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const usernameViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "username"); + expect(usernameViolation).toBeDefined(); + expect(usernameViolation?.message?.withPlaceholders).toContain("3-20 characters"); + }); + + it("should detect `pattern` violation in email field", () => { + const invalid = create(AccountSchema, { + id: 123, + email: "invalid-email", // Invalid email format. + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.FREE, + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const emailViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "email"); + expect(emailViolation).toBeDefined(); + expect(emailViolation?.message?.withPlaceholders).toContain("Invalid email format"); + }); + + it("should detect multiple violations across different constraint types", () => { + const invalid = create(AccountSchema, { + id: 0, // Doesn't satisfy required_field. + email: "", // Empty (violates required) and doesn't satisfy required_field. + username: "a", // Too short (violates pattern). + password: "short", // Too short (violates pattern). + accountType: 0, // UNSPECIFIED (violates required). + age: 10, // Violates range [13..120]. + balance: -100.0, // Violates min 0.0. + failedLoginAttempts: 10, // Violates range [0..5]. + rating: 0.5, // Violates range [1.0..5.0]. + }); + + const violations = validate(AccountSchema, invalid); + atLeast(violations.length, 7); + + // Check for various types of violations. + const fieldPaths = violations.map((v) => v.fieldPath?.fieldName[0] || ""); + // Should have violations for username, password, `account_type`, age, balance, `failed_login_attempts`, rating. + expect(fieldPaths.includes("username") || fieldPaths.includes("password")).toBe(true); + expect(fieldPaths.includes("age")).toBe(true); + expect(fieldPaths.includes("failed_login_attempts")).toBe(true); + }); + + it("should detect `range` violations while other fields are valid", () => { + const invalid = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.FREE, + age: 150, // Violates range [13..120]. + balance: 50000.0, + failedLoginAttempts: 6, // Violates range [0..5]. + rating: 3.5, + }); + + const violations = validate(AccountSchema, invalid); + expect(violations.length).toBe(2); + + const ageViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "age"); + expect(ageViolation).toBeDefined(); + expect(ageViolation?.message?.placeholderValue?.["range.value"]).toBe("[13..120]"); + + const attemptsViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "failed_login_attempts", + ); + expect(attemptsViolation).toBeDefined(); + expect(attemptsViolation?.message?.placeholderValue?.["range.value"]).toBe("[0..5]"); + }); + + it("should detect both `required` and `range` violations on age field", () => { + const invalid = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.FREE, + age: 0, // Violates both (required) and range [13..120]. + balance: 1000.0, + failedLoginAttempts: 0, + rating: 4.0, + }); + + const violations = validate(AccountSchema, invalid); + atLeast(violations.length, 1); + + // Age 0 should violate range constraint (and possibly required). + const ageViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "age"); + expect(ageViolation).toBeDefined(); + }); + + it("should `validate` balance with `min`/`max` constraints", () => { + const validBalance = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.PREMIUM, + age: 30, + balance: 999999.99, // Just under max. + failedLoginAttempts: 0, + rating: 5.0, + }); + + const violations1 = validate(AccountSchema, validBalance); + expect(violations1).toHaveLength(0); + + const invalidBalance = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.PREMIUM, + age: 30, + balance: 1000001.0, // Violates max 1000000.0. + failedLoginAttempts: 0, + rating: 5.0, + }); + + const violations2 = validate(AccountSchema, invalidBalance); + const balanceViolation = violations2.find((v) => v.fieldPath?.fieldName[0] === "balance"); + expect(balanceViolation).toBeDefined(); + }); + + it("should `validate` rating `range` boundaries", () => { + const validMin = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.FREE, + age: 25, + balance: 1000.0, + failedLoginAttempts: 0, + rating: 1.0, // Min boundary. + }); + + const violations1 = validate(AccountSchema, validMin); + expect(violations1).toHaveLength(0); + + const validMax = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.FREE, + age: 25, + balance: 1000.0, + failedLoginAttempts: 0, + rating: 5.0, // Max boundary. + }); + + const violations2 = validate(AccountSchema, validMax); + expect(violations2).toHaveLength(0); + + const invalidRating = create(AccountSchema, { + id: 123, + email: "user@example.com", + username: "johndoe", + password: "secure_password_123", + accountType: AccountType.FREE, + age: 25, + balance: 1000.0, + failedLoginAttempts: 0, + rating: 5.5, // Violates range [1.0..5.0]. + }); + + const violations3 = validate(AccountSchema, invalidRating); + const ratingViolation = violations3.find((v) => v.fieldPath?.fieldName[0] === "rating"); + expect(ratingViolation).toBeDefined(); + expect(ratingViolation?.message?.placeholderValue?.["range.value"]).toBe("[1.0..5.0]"); + }); + + describe("Nested Validation (validate) Integration", () => { + it("should `validate` GetUserResponse with valid nested User", () => { + const validResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + id: 1, + name: "Alice Smith", + email: "alice@example.com", + role: Role.ADMIN, + tags: ["developer", "typescript"], + }), + found: true, + }); + + const violations = validate(GetUserResponseSchema, validResponse); + expect(violations).toHaveLength(0); + }); + + it("should propagate nested User leaf violations without a parent summary", () => { + const invalidResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + id: 1, + name: "", // Required violation. + email: "alice@example.com", + role: Role.USER, + tags: [], + }), + found: true, + }); + + const violations = validate(GetUserResponseSchema, invalidResponse); + expect(violations.length).toBeGreaterThan(0); + + const parentSummary = violations.find( + (v) => v.fieldPath?.fieldName.length === 1 && v.fieldPath?.fieldName[0] === "user", + ); + expect(parentSummary).toBeUndefined(); + + // Should also have nested violation for name field. + const nameViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "user" && v.fieldPath?.fieldName[1] === "name", + ); + expect(nameViolation).toBeDefined(); + }); + + it("should detect multiple nested constraint violations (`required` + `pattern` + `distinct`)", () => { + const invalidResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + id: 0, // Violates min constraint. + name: "123", // Violates pattern (must start with letter). + email: "not-an-email", // Violates pattern. + role: Role.USER, + tags: ["dev", "dev", "ops"], // Violates distinct. + }), + found: true, + }); + + const violations = validate(GetUserResponseSchema, invalidResponse); + expect(violations.length).toBeGreaterThan(0); + + // Check for various nested violations. + const idViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "user" && v.fieldPath?.fieldName[1] === "id", + ); + expect(idViolation).toBeDefined(); + + const nameViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "user" && v.fieldPath?.fieldName[1] === "name", + ); + expect(nameViolation).toBeDefined(); + + const emailViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "user" && v.fieldPath?.fieldName[1] === "email", + ); + expect(emailViolation).toBeDefined(); + + const tagsViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "user" && v.fieldPath?.fieldName[1] === "tags", + ); + expect(tagsViolation).toBeDefined(); + }); + + it("should detect `required_field` violation in nested User", () => { + const invalidResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + // Neither `id` nor `email` provided - violates `required_field` option. + name: "Bob Jones", + role: Role.USER, + tags: [], + }), + found: true, + }); + + const violations = validate(GetUserResponseSchema, invalidResponse); + expect(violations.length).toBeGreaterThan(0); + + // Should have `required_field` violation. + const requiredFieldViolation = violations.find( + (v) => v.message?.placeholderValue["require.fields"] === "email", + ); + expect(requiredFieldViolation).toBeDefined(); + }); + + it("should format nested violations correctly", () => { + const invalidResponse = create(GetUserResponseSchema, { + user: create(UserSchema, { + id: 1, + name: "", // Required. + email: "", // Required. + role: Role.USER, + tags: [], + }), + found: true, + }); + + const violations = validate(GetUserResponseSchema, invalidResponse); + const formatted = Violations.formatAll(violations); + + // Should contain nested field paths. + expect(formatted).toContain("user"); + expect(formatted).toContain("name"); + expect(formatted).toContain("email"); + }); + + it("should pass when nested User is not set `(optional)`", () => { + const responseWithoutUser = create(GetUserResponseSchema, { + found: false, + // user field not set. + }); + + const violations = validate(GetUserResponseSchema, responseWithoutUser); + expect(violations).toHaveLength(0); + }); + }); + + describe("Field Dependency (goes) Integration", () => { + it("should `validate` `goes` with `required` and `pattern` constraints", () => { + const valid = create(SecureAccountSchema, { + username: "alice_secure", + password: "strongpass123", + recoveryEmail: "alice@example.com", + recoveryPhone: "+1234567890", + }); + + const violations = validate(SecureAccountSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect `goes` violation independently from `pattern` violations", () => { + const invalid = create(SecureAccountSchema, { + username: "alice_secure", + password: "strongpass123", + recoveryEmail: "", // Not set. + recoveryPhone: "+1234567890", // Violates goes constraint. + }); + + const violations = validate(SecureAccountSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const goesViolation = violations.find( + (v) => + v.fieldPath?.fieldName[0] === "recovery_phone" && + v.message?.placeholderValue["goes.companion"] === "recovery_email", + ); + expect(goesViolation).toBeDefined(); + }); + + it("should detect both `required` and `goes` violations together", () => { + const invalid = create(SecureAccountSchema, { + username: "", // Required violation. + password: "", // Required violation. + recoveryEmail: "", + recoveryPhone: "+1234567890", // Goes violation. + }); + + const violations = validate(SecureAccountSchema, invalid); + atLeast(violations.length, 3); + + const usernameViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "username"); + expect(usernameViolation).toBeDefined(); + + const passwordViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "password"); + expect(passwordViolation).toBeDefined(); + + const goesViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "recovery_phone"); + expect(goesViolation).toBeDefined(); + }); + + it("should `validate` `goes` with `range` and `min` constraints", () => { + const valid = create(AdvancedConfigSchema, { + configName: "staging", + maxConnections: 100, + timeoutSeconds: 15.5, + }); + + const violations = validate(AdvancedConfigSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect `goes` and `range` violations independently", () => { + const invalid1 = create(AdvancedConfigSchema, { + configName: "production", + maxConnections: 5000, // Violates range [1..1000]. + timeoutSeconds: 10.0, + }); + + const violations1 = validate(AdvancedConfigSchema, invalid1); + const rangeViolation = violations1.find( + (v) => v.fieldPath?.fieldName[0] === "max_connections", + ); + expect(rangeViolation).toBeDefined(); + expect(rangeViolation?.message?.placeholderValue?.["range.value"]).toBe("[1..1000]"); + + const invalid2 = create(AdvancedConfigSchema, { + configName: "", // Not set. + maxConnections: 500, // Violates goes constraint. + timeoutSeconds: 10.0, + }); + + const violations2 = validate(AdvancedConfigSchema, invalid2); + expect(violations2).toHaveLength(0); + }); + + it("should handle mutual dependencies with multiple constraint types", () => { + const valid = create(ColorSettingsSchema, { + textColor: "#FF0000", + highlightColor: "#00FF00", + }); + + const violations = validate(ColorSettingsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect violations in mutual dependencies", () => { + const invalid = create(ColorSettingsSchema, { + textColor: "#FF0000", + highlightColor: "", // Not set - violates mutual dependency. + }); + + const violations = validate(ColorSettingsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const textColorViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "text_color"); + expect(textColorViolation).toBeDefined(); + expect(textColorViolation?.message?.placeholderValue["goes.companion"]).toBe( + "highlight_color", + ); + }); + + it("should format `goes` violations correctly", () => { + const invalid = create(ScheduledEventSchema, { + eventName: "Conference", + date: "", + time: "10:00 AM", + }); + + const violations = validate(ScheduledEventSchema, invalid); + const formatted = Violations.formatAll(violations); + + expect(formatted).toContain("time"); + expect(formatted).toContain("date"); + }); + }); +}); diff --git a/packages/validation/tests/min-max.test.ts b/packages/validation/tests/min-max.test.ts new file mode 100644 index 0000000..0d6e4ef --- /dev/null +++ b/packages/validation/tests/min-max.test.ts @@ -0,0 +1,494 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `(min)` and `(max)` validation options. + * + * Tests numeric range validation with inclusive/exclusive bounds. + */ + +import { create } from "@bufbuild/protobuf"; + +const atLeast = (value: number, minimum: number): void => + expect(value)["toBeGreaterThanOrEqual"](minimum); +import { validate } from "../src/index.js"; + +import { + MinValueSchema, + MaxValueSchema, + MinMaxRangeSchema, + ExclusiveBoundsSchema, + CustomErrorMessagesSchema, + NumericTypesSchema, + RepeatedMinMaxSchema, + CombinedConstraintsSchema, + OptionalMinMaxSchema, +} from "./generated/test-min-max_pb.js"; + +describe("Min/Max Validation", () => { + describe("Basic Min Constraint", () => { + it("should pass when value meets minimum `(inclusive)`", () => { + const valid = create(MinValueSchema, { + positiveId: 1, + nonNegative: 0, + price: 0.01, + }); + + const violations = validate(MinValueSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when value exceeds minimum", () => { + const valid = create(MinValueSchema, { + positiveId: 100, + nonNegative: 50, + price: 19.99, + }); + + const violations = validate(MinValueSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when value is below minimum", () => { + const invalid = create(MinValueSchema, { + positiveId: 0, // Violates min = 1. + nonNegative: 5, + price: 0.01, + }); + + const violations = validate(MinValueSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const positiveIdViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "positive_id", + ); + expect(positiveIdViolation).toBeDefined(); + expect(positiveIdViolation?.message?.withPlaceholders).toContain("${min.operator}"); + }); + + it("should fail when price is below minimum", () => { + const invalid = create(MinValueSchema, { + positiveId: 1, + nonNegative: 0, + price: 0.001, // Violates min = 0.01. + }); + + const violations = validate(MinValueSchema, invalid); + const priceViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "price"); + expect(priceViolation).toBeDefined(); + }); + + it("should `validate` zero values (`proto3` cannot distinguish unset from zero)", () => { + const withDefaults = create(MinValueSchema, { + positiveId: 0, + nonNegative: 0, + price: 0, + }); + + // `positive_id` violates `min=1`, price violates `min=0.01`, nonNegative is valid. + const violations = validate(MinValueSchema, withDefaults); + atLeast(violations.length, 2); + + const positiveIdViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "positive_id", + ); + expect(positiveIdViolation).toBeDefined(); + + const priceViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "price"); + expect(priceViolation).toBeDefined(); + }); + }); + + describe("Basic Max Constraint", () => { + it("should pass when value meets maximum `(inclusive)`", () => { + const valid = create(MaxValueSchema, { + percentage: 100, + altitude: 8848.86, + year: 2100n, + }); + + const violations = validate(MaxValueSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when value is below maximum", () => { + const valid = create(MaxValueSchema, { + percentage: 50, + altitude: 1000.0, + year: 2025n, + }); + + const violations = validate(MaxValueSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when value exceeds maximum", () => { + const invalid = create(MaxValueSchema, { + percentage: 101, // Violates max = 100. + altitude: 8000.0, + year: 2050n, + }); + + const violations = validate(MaxValueSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const percentageViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "percentage", + ); + expect(percentageViolation).toBeDefined(); + expect(percentageViolation?.message?.withPlaceholders).toContain("${max.operator}"); + }); + + it("should fail when altitude exceeds maximum", () => { + const invalid = create(MaxValueSchema, { + percentage: 100, + altitude: 9000.0, // Violates max = 8848.86. + year: 2050n, + }); + + const violations = validate(MaxValueSchema, invalid); + const altitudeViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "altitude"); + expect(altitudeViolation).toBeDefined(); + }); + }); + + describe("Combined Min and Max Constraints", () => { + it("should pass when value is within `range`", () => { + const valid = create(MinMaxRangeSchema, { + age: 25, + temperature: 20.5, + percentage: 50, + }); + + const violations = validate(MinMaxRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass at boundary values", () => { + const valid = create(MinMaxRangeSchema, { + age: 0, // min boundary. + temperature: -273.15, // min boundary. + percentage: 100, // max boundary. + }); + + const violations = validate(MinMaxRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when value is below minimum", () => { + const invalid = create(MinMaxRangeSchema, { + age: -1, // Violates min = 0. + temperature: 20.0, + percentage: 50, + }); + + const violations = validate(MinMaxRangeSchema, invalid); + const ageViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "age"); + expect(ageViolation).toBeDefined(); + }); + + it("should fail when value exceeds maximum", () => { + const invalid = create(MinMaxRangeSchema, { + age: 25, + temperature: 1001.0, // Violates max = 1000.0. + percentage: 50, + }); + + const violations = validate(MinMaxRangeSchema, invalid); + const tempViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "temperature"); + expect(tempViolation).toBeDefined(); + }); + + it("should detect multiple violations", () => { + const invalid = create(MinMaxRangeSchema, { + age: 151, // Violates max = 150. + temperature: -300.0, // Violates min = -273.15. + percentage: 101, // Violates max = 100. + }); + + const violations = validate(MinMaxRangeSchema, invalid); + atLeast(violations.length, 3); + }); + }); + + describe("Exclusive Bounds", () => { + it("should pass when value is strictly greater than exclusive minimum", () => { + const valid = create(ExclusiveBoundsSchema, { + positiveValue: 0.1, + temperatureKelvin: 100.0, + belowLimit: 50, + }); + + const violations = validate(ExclusiveBoundsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when value equals exclusive minimum", () => { + const invalid = create(ExclusiveBoundsSchema, { + positiveValue: 0.0, // Violates exclusive min = 0.0. + temperatureKelvin: 100.0, + belowLimit: 50, + }); + + const violations = validate(ExclusiveBoundsSchema, invalid); + const positiveValueViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "positive_value", + ); + expect(positiveValueViolation).toBeDefined(); + expect(positiveValueViolation?.message?.placeholderValue?.["min.operator"]).toBe(">"); + }); + + it("should fail when value equals exclusive maximum", () => { + const invalid = create(ExclusiveBoundsSchema, { + positiveValue: 0.1, + temperatureKelvin: 100.0, + belowLimit: 100, // Violates exclusive max = 100. + }); + + const violations = validate(ExclusiveBoundsSchema, invalid); + const belowLimitViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "below_limit", + ); + expect(belowLimitViolation).toBeDefined(); + expect(belowLimitViolation?.message?.placeholderValue?.["max.operator"]).toBe("<"); + }); + + it("should use custom error message for temperature", () => { + const invalid = create(ExclusiveBoundsSchema, { + positiveValue: 0.1, + temperatureKelvin: 0.0, // Violates exclusive min with custom message. + belowLimit: 50, + }); + + const violations = validate(ExclusiveBoundsSchema, invalid); + const tempViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "temperature_kelvin", + ); + expect(tempViolation).toBeDefined(); + expect(tempViolation?.message?.withPlaceholders).toContain("Temperature cannot reach"); + expect(tempViolation?.message?.placeholderValue?.["other"]).toBe("0.0"); + expect(tempViolation?.message?.placeholderValue?.["value"]).toBe("0"); + }); + }); + + describe("Custom Error Messages", () => { + it("should use custom error message for age minimum", () => { + const invalid = create(CustomErrorMessagesSchema, { + age: 17, // Violates min = 18. + balance: 100.0, + }); + + const violations = validate(CustomErrorMessagesSchema, invalid); + const ageViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "age"); + expect(ageViolation).toBeDefined(); + expect(ageViolation?.message?.withPlaceholders).toContain("Must be at least"); + expect(ageViolation?.message?.withPlaceholders).toContain("years old"); + expect(ageViolation?.message?.placeholderValue?.["other"]).toBe("18"); + expect(ageViolation?.message?.placeholderValue?.["value"]).toBe("17"); + }); + + it("should use custom error message for balance minimum", () => { + const invalid = create(CustomErrorMessagesSchema, { + age: 25, + balance: 0.001, // Violates min = 0.01. + }); + + const violations = validate(CustomErrorMessagesSchema, invalid); + const balanceViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "balance"); + expect(balanceViolation).toBeDefined(); + expect(balanceViolation?.message?.withPlaceholders).toContain("Balance must be at least"); + }); + + it("should use custom error message for balance maximum", () => { + const invalid = create(CustomErrorMessagesSchema, { + age: 25, + balance: 1000001.0, // Violates max = 1000000.0. + }); + + const violations = validate(CustomErrorMessagesSchema, invalid); + const balanceViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "balance"); + expect(balanceViolation).toBeDefined(); + expect(balanceViolation?.message?.withPlaceholders).toContain("Balance cannot exceed"); + }); + }); + + describe("Different Numeric Types", () => { + it("should `validate` all numeric types correctly", () => { + const valid = create(NumericTypesSchema, { + int32Field: 100, + int64Field: 1000n, + uint32Field: 1000, + uint64Field: 1n, + floatField: 50.0, + doubleField: 0.0, + }); + + const violations = validate(NumericTypesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect violations across different types", () => { + const invalid = create(NumericTypesSchema, { + int32Field: -1, // Violates min = 0. + int64Field: -1n, // Violates min = 0. + uint32Field: 4000000000, // Valid uint32 value; overflow semantics are Task 4 scope. + uint64Field: 0n, // Violates min = 1. + floatField: 101.0, // Violates max = 100.0. + doubleField: 1001.0, // Violates max = 1000.0. + }); + + const violations = validate(NumericTypesSchema, invalid); + atLeast(violations.length, 4); + }); + }); + + describe("Repeated Fields", () => { + it("should `validate` all elements in repeated field", () => { + const valid = create(RepeatedMinMaxSchema, { + scores: [0, 50, 100], + prices: [0.01, 10.0, 99.99], + }); + + const violations = validate(RepeatedMinMaxSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect violation in one element of repeated field", () => { + const invalid = create(RepeatedMinMaxSchema, { + scores: [50, 101, 75], // Second element violates max = 100. + prices: [10.0, 20.0], + }); + + const violations = validate(RepeatedMinMaxSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const scoreViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "scores"); + expect(scoreViolation).toBeDefined(); + }); + + it("should detect multiple violations in repeated field", () => { + const invalid = create(RepeatedMinMaxSchema, { + scores: [-1, 50, 101], // First and third violate constraints. + prices: [0.001, 10.0], // First violates min = 0.01. + }); + + const violations = validate(RepeatedMinMaxSchema, invalid); + atLeast(violations.length, 3); + }); + + it("should not `validate` empty repeated fields", () => { + const empty = create(RepeatedMinMaxSchema, { + scores: [], + prices: [], + }); + + const violations = validate(RepeatedMinMaxSchema, empty); + expect(violations).toHaveLength(0); + }); + }); + + describe("Combined with Required", () => { + it("should pass when `required` field meets `min` constraint", () => { + const valid = create(CombinedConstraintsSchema, { + productId: 1, + price: 0.01, + stock: 100, + }); + + const violations = validate(CombinedConstraintsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect numeric `min` violations", () => { + const invalid = create(CombinedConstraintsSchema, { + productId: 0, // Required but set to default. + price: 0, // Required but set to default. + stock: 10, + }); + + const violations = validate(CombinedConstraintsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + expect(violations.some((v) => v.fieldPath?.fieldName[0] === "product_id")).toBe(true); + expect(violations.some((v) => v.fieldPath?.fieldName[0] === "price")).toBe(true); + }); + + it("should detect `min` violation on `required` field", () => { + const invalid = create(CombinedConstraintsSchema, { + productId: 0, // Violates min = 1 AND required. + price: 10.0, + stock: 5, + }); + + const violations = validate(CombinedConstraintsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + + it("should use custom error message from `min` option", () => { + const invalid = create(CombinedConstraintsSchema, { + productId: 10, + price: 0.001, // Violates min = 0.01. + stock: 5, + }); + + const violations = validate(CombinedConstraintsSchema, invalid); + const priceViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "price"); + expect(priceViolation).toBeDefined(); + expect(priceViolation?.message?.withPlaceholders).toContain("Price must be at least"); + expect(priceViolation?.message?.placeholderValue?.["other"]).toBe("0.01"); + }); + }); + + describe("Optional Fields", () => { + it("should `validate` even zero values in `proto3`", () => { + const withDefaults = create(OptionalMinMaxSchema, { + optionalCount: 0, // Violates min = 1 (proto3 treats 0 as set). + optionalRating: 0, // Within max = 5.0, so valid. + }); + + const violations = validate(OptionalMinMaxSchema, withDefaults); + expect(violations.length).toBeGreaterThan(0); + + const countViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "optional_count"); + expect(countViolation).toBeDefined(); + }); + + it("should `validate` when optional fields have non-default values", () => { + const invalid = create(OptionalMinMaxSchema, { + optionalCount: 2, // Valid. + optionalRating: 5.5, // Violates max = 5.0. + }); + + const violations = validate(OptionalMinMaxSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const ratingViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "optional_rating", + ); + expect(ratingViolation).toBeDefined(); + }); + }); +}); diff --git a/packages/validation/tests/numeric-contract.test.ts b/packages/validation/tests/numeric-contract.test.ts new file mode 100644 index 0000000..8490d69 --- /dev/null +++ b/packages/validation/tests/numeric-contract.test.ts @@ -0,0 +1,304 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { create } from "@bufbuild/protobuf"; + +import { validate } from "../src/index.js"; +import { + NumericBoundsContractSchema, + NumericReferencesSchema, + InvalidMinSuffixSchema, + InvalidMinFloatSchema, + InvalidMinUnsignedSchema, + InvalidMinTargetSchema, + MissingNumericReferenceSchema, + IncompatibleNumericReferenceSchema, + NumericScalarMatrixSchema, + CrossTypeReferencesSchema, + InvalidInt32OverflowSchema, + InvalidUint32OverflowSchema, + InvalidInt64OverflowSchema, + InvalidUint64OverflowSchema, + InvalidUint64NegativeSchema, + InvalidIntegerDecimalSchema, + InvalidFloatExponentSchema, + InvalidFloatOverflowSchema, + InvalidDoubleOverflowSchema, + NumericTypesSchema, + RepeatedMinMaxSchema, +} from "./generated/test-min-max_pb.js"; +import { + ExactLongRangesSchema, + InvalidRangeTargetSchema, + MalformedRangeSchema, + RangeTextReferencesSchema, + ReversedRangeSchema, + NumericTypeRangesSchema, + RepeatedRangeSchema, +} from "./generated/test-range_pb.js"; + +describe("exact numeric validation contract", () => { + it("keeps 64-bit integer bounds exact and packs a repeated offending value", () => { + const exactValues = 9007199254740993n; + expect( + validate( + NumericBoundsContractSchema, + create(NumericBoundsContractSchema, { + preciseMin: exactValues, + preciseMax: exactValues, + repeatedPrecise: [exactValues], + }), + ), + ).toEqual([]); + + const violations = validate( + NumericBoundsContractSchema, + create(NumericBoundsContractSchema, { + preciseMin: 9007199254740992n, + preciseMax: 9007199254740994n, + repeatedPrecise: [9007199254740992n, 9007199254740993n], + }), + ); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["precise_min"], + ["precise_max"], + ["repeated_precise"], + ]); + expect(violations[2].fieldValue?.typeUrl).toContain("google.protobuf.Int64Value"); + }); + + it.each([ + [InvalidMinSuffixSchema, "INVALID_OPTION_VALUE"], + [InvalidMinFloatSchema, "INVALID_OPTION_VALUE"], + [InvalidMinUnsignedSchema, "INVALID_OPTION_VALUE"], + [InvalidMinTargetSchema, "UNSUPPORTED_OPTION_TARGET"], + ])("rejects invalid min configuration", (schema, code) => { + expect(() => validate(schema as any, create(schema as any))).toThrow( + expect.objectContaining({ + code, + option: "min", + typeName: schema.typeName, + fieldPath: ["value"], + }), + ); + }); + + it.each([ + [InvalidInt32OverflowSchema, "min"], + [InvalidUint32OverflowSchema, "max"], + [InvalidInt64OverflowSchema, "min"], + [InvalidUint64OverflowSchema, "max"], + [InvalidUint64NegativeSchema, "min"], + [InvalidIntegerDecimalSchema, "min"], + [InvalidFloatExponentSchema, "min"], + [InvalidFloatOverflowSchema, "min"], + [InvalidDoubleOverflowSchema, "min"], + ])("rejects scalar limit and complete-grammar errors", (schema, option) => { + expect(() => validate(schema as any, create(schema as any))).toThrow( + expect.objectContaining({ + code: "INVALID_OPTION_VALUE", + option, + typeName: schema.typeName, + fieldPath: ["value"], + }), + ); + }); + + it("uses default nested-message values for a referenced bound and renders it", () => { + const violations = validate( + NumericReferencesSchema, + create(NumericReferencesSchema, { actual: -1n, measured: 1 }), + ); + expect(violations).toHaveLength(2); + expect(violations[0].message?.placeholderValue["min.value"]).toBe("limits.lower (0)"); + }); + + it.each([ + [MissingNumericReferenceSchema, "UNKNOWN_FIELD_REFERENCE"], + [IncompatibleNumericReferenceSchema, "INVALID_FIELD_REFERENCE"], + ])("reports reference errors", (schema, code) => { + expect(() => validate(schema as any, create(schema as any))).toThrow( + expect.objectContaining({ + code, + option: "min", + typeName: schema.typeName, + fieldPath: ["value"], + }), + ); + }); + + it("compares referenced numeric fields across runtime scalar types", () => { + const violations = validate( + CrossTypeReferencesSchema, + create(CrossTypeReferencesSchema, { + doubleBound: 1.5, + int64Bound: 3n, + integerValue: 1n, + floatingValue: 3.5, + }), + ); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["integer_value"], + ["floating_value"], + ]); + expect(violations[0].message?.placeholderValue["min.value"]).toBe("double_bound (1.5)"); + expect(violations[1].message?.placeholderValue["max.value"]).toBe("int64_bound (3)"); + }); + + it("executes all remaining signed and fixed scalar families", () => { + const violations = validate( + NumericScalarMatrixSchema, + create(NumericScalarMatrixSchema, { + sint32Value: -2, + sint64Value: 9007199254740994n, + fixed32Value: 0, + fixed64Value: 9007199254740994n, + sfixed32Value: -2, + sfixed64Value: 9007199254740994n, + }), + ); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["sint32_value"], + ["sint64_value"], + ["fixed32_value"], + ["fixed64_value"], + ["sfixed32_value"], + ["sfixed64_value"], + ]); + }); + + it.each([ + [ReversedRangeSchema, "INVALID_OPTION_VALUE"], + [MalformedRangeSchema, "INVALID_OPTION_VALUE"], + [InvalidRangeTargetSchema, "UNSUPPORTED_OPTION_TARGET"], + ])("rejects invalid range declarations", (schema, code) => { + expect(() => validate(schema as any, create(schema as any))).toThrow( + expect.objectContaining({ + code, + option: "range", + typeName: schema.typeName, + fieldPath: ["value"], + }), + ); + }); + + it("preserves exact range declaration text while annotating references", () => { + const violations = validate( + RangeTextReferencesSchema, + create(RangeTextReferencesSchema, { value: 1, literal: 0 }), + ); + expect( + violations.map((violation) => violation.message?.placeholderValue["range.value"]), + ).toEqual(["[ -1 .. limits.upper (0) ]", "[ 1 .. 2 ]"]); + }); + + it("keeps signed and unsigned 64-bit range endpoints exact", () => { + const violations = validate( + ExactLongRangesSchema, + create(ExactLongRangesSchema, { + signedValue: 9007199254740992n, + unsignedValue: 9007199254740996n, + }), + ); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["signed_value"], + ["unsigned_value"], + ]); + expect( + violations.every( + (violation) => + violation.fieldValue?.typeUrl.endsWith("Int64Value") || + violation.fieldValue?.typeUrl.endsWith("UInt64Value"), + ), + ).toBe(true); + }); + + it("rejects NaN for singular min, max, and range constraints", () => { + const minAndMax = validate( + NumericTypesSchema, + create(NumericTypesSchema, { + uint64Field: 1n, + floatField: Number.NaN, + doubleField: Number.NaN, + }), + ); + const ranged = validate( + NumericTypeRangesSchema, + create(NumericTypeRangesSchema, { + int32Field: 1, + int64Field: 0n, + uint32Field: 1, + uint64Field: 1n, + floatField: Number.NaN, + doubleField: Number.NaN, + }), + ); + + expect(minAndMax.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["float_field"], + ["float_field"], + ["double_field"], + ["double_field"], + ]); + expect(ranged.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["float_field"], + ["double_field"], + ]); + }); + + it("rejects NaN in repeated numeric values", () => { + const minViolations = validate( + RepeatedMinMaxSchema, + create(RepeatedMinMaxSchema, { + prices: [Number.NaN], + measurements: [Number.NaN], + }), + ); + const rangeViolations = validate( + RepeatedRangeSchema, + create(RepeatedRangeSchema, { percentages: [Number.NaN] }), + ); + + expect(minViolations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["prices"], + ["measurements"], + ["measurements"], + ]); + expect(rangeViolations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["percentages"], + ]); + }); + + it("keeps Infinity subject to its numeric bounds", () => { + const violations = validate( + NumericTypesSchema, + create(NumericTypesSchema, { + uint64Field: 1n, + floatField: Number.POSITIVE_INFINITY, + doubleField: Number.NEGATIVE_INFINITY, + }), + ); + + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["float_field"], + ["double_field"], + ]); + expect( + violations.map((violation) => violation.message?.placeholderValue["max.operator"]), + ).toEqual(["<=", undefined]); + expect(violations[1].message?.placeholderValue["min.operator"]).toBe(">="); + }); +}); diff --git a/packages/validation/tests/options-owners.test.ts b/packages/validation/tests/options-owners.test.ts new file mode 100644 index 0000000..384acff --- /dev/null +++ b/packages/validation/tests/options-owners.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Choice } from "../src/options/choice.js"; +import { Distinct } from "../src/options/distinct.js"; +import { Goes } from "../src/options/goes.js"; +import { MinMax } from "../src/options/min-max.js"; +import { NumericValues } from "../src/options/numeric.js"; +import { Pattern } from "../src/options/pattern.js"; +import { Range } from "../src/options/range.js"; +import { Require } from "../src/options/required-field.js"; +import { Required } from "../src/options/required.js"; +import { NestedValidation } from "../src/options/validate.js"; +import { When } from "../src/options/when.js"; + +describe("option owners", () => { + it("exposes each option implementation through its cohesive owner", () => { + expect(NumericValues.parseLiteral).toBeTypeOf("function"); + expect(MinMax.validate).toBeTypeOf("function"); + expect(Range.validate).toBeTypeOf("function"); + expect(Required.validate).toBeTypeOf("function"); + expect(Goes.validate).toBeTypeOf("function"); + expect(Choice.validate).toBeTypeOf("function"); + expect(Require.validate).toBeTypeOf("function"); + expect(Distinct.validate).toBeTypeOf("function"); + expect(Pattern.validate).toBeTypeOf("function"); + expect(NestedValidation.validate).toBeTypeOf("function"); + expect(When.validate).toBeTypeOf("function"); + }); +}); diff --git a/packages/validation/tests/ordering.test.ts b/packages/validation/tests/ordering.test.ts new file mode 100644 index 0000000..55b0e33 --- /dev/null +++ b/packages/validation/tests/ordering.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { create } from "@bufbuild/protobuf"; +import { anyUnpack, StringValueSchema } from "@bufbuild/protobuf/wkt"; + +import { validate } from "../src/index.js"; +import { AccountSchema } from "./generated/integration-account_pb.js"; +import { RepeatedPatternValidationSchema } from "./generated/test-pattern_pb.js"; + +describe("deterministic validation orchestration", () => { + it("runs message constraints first and then validators field by field", () => { + const account = create(AccountSchema, { + id: 0, + email: "", + username: "!", + password: "", + accountType: 0, + age: 0, + }); + + const violations = validate(AccountSchema, account); + + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + [], + ["id"], + ["email"], + ["username"], + ["password"], + ["account_type"], + ["age"], + ["rating"], + ]); + expect(violations.every((violation) => violation.typeName === AccountSchema.typeName)).toBe( + true, + ); + expect(violations.map((violation) => violation.message?.withPlaceholders)).toEqual([ + expect.stringContaining("at least one"), + expect.stringContaining("${min.operator}"), + expect.stringContaining("must have a non-default value"), + expect.stringContaining("Username must"), + expect.stringContaining("must have a non-default value"), + expect.stringContaining("must have a non-default value"), + expect.stringContaining("range"), + expect.stringContaining("range"), + ]); + }); + + it("keeps repeated pattern offenders ordered, packed, and on the collection path", () => { + const message = create(RepeatedPatternValidationSchema, { + tags: ["good", "bad!", "wrong?"], + }); + + const violations = validate(RepeatedPatternValidationSchema, message); + + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["tags"], + ["tags"], + ]); + expect( + violations.map((violation) => anyUnpack(violation.fieldValue!, StringValueSchema)?.value), + ).toEqual(["bad!", "wrong?"]); + }); +}); diff --git a/packages/validation/tests/pattern.test.ts b/packages/validation/tests/pattern.test.ts new file mode 100644 index 0000000..e25e56f --- /dev/null +++ b/packages/validation/tests/pattern.test.ts @@ -0,0 +1,219 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `(pattern)` validation option. + * + * Tests regex pattern validation for string fields. + */ + +import { create } from "@bufbuild/protobuf"; +import { validate } from "../src/index.js"; + +import { + PatternValidationSchema, + RepeatedPatternValidationSchema, + OptionalPatternSchema, + CaseInsensitivePatternSchema, +} from "./generated/test-pattern_pb.js"; + +describe("Pattern Field Validation", () => { + describe("Single Pattern Fields", () => { + it("should validate alpha-only field", () => { + const valid = create(PatternValidationSchema, { + alphaField: "HelloWorld", + alphanumericField: "Test123", + email: "test@example.com", + phone: "555-123-4567", + website: "https://example.com", + colorHex: "#FF5733", + username: "user_name-123", + }); + + const violations = validate(PatternValidationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect invalid alpha-only field (contains numbers)", () => { + const invalid = create(PatternValidationSchema, { + alphaField: "Hello123", // Invalid: contains numbers. + alphanumericField: "Test", + email: "test@example.com", + phone: "555-123-4567", + website: "https://example.com", + colorHex: "#FF5733", + username: "username", + }); + + const violations = validate(PatternValidationSchema, invalid); + const alphaViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "alpha_field"); + expect(alphaViolation).toBeDefined(); + expect(alphaViolation?.message?.withPlaceholders).toContain("must contain only letters"); + }); + + it("should detect invalid email `pattern`", () => { + const invalid = create(PatternValidationSchema, { + alphaField: "Test", + alphanumericField: "Test", + email: "notanemail", // Invalid email. + phone: "555-123-4567", + website: "https://example.com", + colorHex: "#FF5733", + username: "username", + }); + + const violations = validate(PatternValidationSchema, invalid); + const emailViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "email"); + expect(emailViolation).toBeDefined(); + expect(emailViolation?.message?.withPlaceholders).toContain("Invalid email format"); + }); + + it("should detect invalid phone `pattern`", () => { + const invalid = create(PatternValidationSchema, { + alphaField: "Test", + alphanumericField: "Test", + email: "test@example.com", + phone: "1234567890", // Invalid: missing dashes. + website: "https://example.com", + colorHex: "#FF5733", + username: "username", + }); + + const violations = validate(PatternValidationSchema, invalid); + const phoneViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "phone"); + expect(phoneViolation).toBeDefined(); + expect(phoneViolation?.message?.withPlaceholders).toContain("XXX-XXX-XXXX"); + }); + + it("should detect invalid hex color", () => { + const invalid = create(PatternValidationSchema, { + alphaField: "Test", + alphanumericField: "Test", + email: "test@example.com", + phone: "555-123-4567", + website: "https://example.com", + colorHex: "FF5733", // Invalid: missing #. + username: "username", + }); + + const violations = validate(PatternValidationSchema, invalid); + const colorViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "color_hex"); + expect(colorViolation).toBeDefined(); + expect(colorViolation?.message?.withPlaceholders).toContain("hex code"); + }); + + it("should detect invalid username (too short)", () => { + const invalid = create(PatternValidationSchema, { + alphaField: "Test", + alphanumericField: "Test", + email: "test@example.com", + phone: "555-123-4567", + website: "https://example.com", + colorHex: "#FF5733", + username: "ab", // Invalid: too short (needs 3-20). + }); + + const violations = validate(PatternValidationSchema, invalid); + const usernameViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "username"); + expect(usernameViolation).toBeDefined(); + expect(usernameViolation?.message?.withPlaceholders).toContain("3-20 characters"); + }); + }); + + describe("Repeated Pattern Fields", () => { + it("should validate repeated fields with all valid values", () => { + const valid = create(RepeatedPatternValidationSchema, { + emails: ["user1@example.com", "user2@test.org"], + tags: ["tag1", "tag2", "tag3"], + }); + + const violations = validate(RepeatedPatternValidationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect invalid email in repeated field", () => { + const invalid = create(RepeatedPatternValidationSchema, { + emails: ["valid@example.com", "invalid-email", "another@test.org"], + tags: ["tag1"], + }); + + const violations = validate(RepeatedPatternValidationSchema, invalid); + const emailViolation = violations.find((v) => + v.fieldPath?.fieldName[0]?.startsWith("emails"), + ); + expect(emailViolation).toBeDefined(); + }); + + it("should detect invalid tag in repeated field", () => { + const invalid = create(RepeatedPatternValidationSchema, { + emails: ["valid@example.com"], + tags: ["validtag", "invalid-tag!", "another"], // Middle tag has special char. + }); + + const violations = validate(RepeatedPatternValidationSchema, invalid); + const tagViolation = violations.find((v) => v.fieldPath?.fieldName[0]?.startsWith("tags")); + expect(tagViolation).toBeDefined(); + }); + }); + + describe("Optional Pattern Fields", () => { + it("should not `validate` `pattern` on empty optional fields", () => { + const valid = create(OptionalPatternSchema, { + optionalEmail: "", + optionalPhone: "", + }); + + const violations = validate(OptionalPatternSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should validate pattern when optional field has value", () => { + const invalid = create(OptionalPatternSchema, { + optionalEmail: "invalid", // Invalid email format. + optionalPhone: "", + }); + + const violations = validate(OptionalPatternSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + const emailViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "optional_email"); + expect(emailViolation).toBeDefined(); + }); + }); + + describe("Pattern modifiers", () => { + it("honors the case-insensitive modifier exposed by the Proto option", () => { + const valid = create(CaseInsensitivePatternSchema, { yesOrNo: "YES" }); + const invalid = create(CaseInsensitivePatternSchema, { yesOrNo: "perhaps" }); + + expect(validate(CaseInsensitivePatternSchema, valid)).toHaveLength(0); + expect(validate(CaseInsensitivePatternSchema, invalid)).toEqual([ + expect.objectContaining({ + fieldPath: expect.objectContaining({ fieldName: ["yes_or_no"] }), + }), + ]); + }); + }); +}); diff --git a/packages/spine-validation-ts/tests/proto/integration-account.proto b/packages/validation/tests/proto/integration-account.proto similarity index 70% rename from packages/spine-validation-ts/tests/proto/integration-account.proto rename to packages/validation/tests/proto/integration-account.proto index 798d2c3..1e2599e 100644 --- a/packages/spine-validation-ts/tests/proto/integration-account.proto +++ b/packages/validation/tests/proto/integration-account.proto @@ -27,54 +27,57 @@ syntax = "proto3"; package spine.validation.testing.integration; -// Integration test messages demonstrating multiple validation constraints. -// -// This file showcases complex validation scenarios combining required_field, -// required, pattern, min/max, and range constraints in an account management -// context. +// Fixtures for combined account-management validation constraints. import "spine/options.proto"; -// Account message combining multiple validation constraints. +// Exercises account fields that combine required, pattern, and numeric rules. message Account { - option (require).fields = "id | email"; + option (require).fields = "email"; + // Supplies the positive account identifier. int32 id = 1 [(min).value = "1"]; - // Must be a valid email address (basic format validation). + // Requires an email address and verifies its basic format. string email = 2 [ (required) = true, (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", (pattern).error_msg = "Invalid email format: `{value}`." ]; - // Must be 3-20 characters containing only letters, numbers, underscores, or hyphens. + // Requires a 3-to-20 character username using the allowed characters. string username = 3 [ (required) = true, (pattern).regex = "^[A-Za-z0-9_-]{3,20}$", (pattern).error_msg = "Username must be 3-20 characters (letters, numbers, _, -). Got: `{value}`." ]; - // Must be at least 8 characters long. + // Requires a password of at least eight characters. string password = 4 [ (required) = true, (pattern).regex = "^.{8,}$", (pattern).error_msg = "Password must be at least 8 characters. Got length: {value}." ]; + // Requires the account's service tier. AccountType account_type = 5 [(required) = true]; - int32 age = 6 [ - (required) = true, - (range).value = "[13..120]" - ]; + // Limits account-holder age to 13 through 120. + int32 age = 6 [(range).value = "[13..120]"]; + // Limits the account balance to the supported monetary range. double balance = 7 [ (min).value = "0.0", (max).value = "1000000.0" ]; + // Limits recorded failed login attempts to zero through five. int32 failed_login_attempts = 8 [(range).value = "[0..5]"]; + // Limits the service rating to one through five. double rating = 9 [(range).value = "[1.0..5.0]"]; } -// Account type enumeration. +// Defines account service tiers used by this integration fixture. enum AccountType { + // Marks an account whose tier has not been selected. ACCOUNT_TYPE_UNSPECIFIED = 0; + // Selects the free service tier. ACCOUNT_TYPE_FREE = 1; + // Selects the premium service tier. ACCOUNT_TYPE_PREMIUM = 2; + // Selects the enterprise service tier. ACCOUNT_TYPE_ENTERPRISE = 3; } diff --git a/packages/spine-validation-ts/tests/proto/integration-product.proto b/packages/validation/tests/proto/integration-product.proto similarity index 65% rename from packages/spine-validation-ts/tests/proto/integration-product.proto rename to packages/validation/tests/proto/integration-product.proto index ce2095f..ee723c8 100644 --- a/packages/spine-validation-ts/tests/proto/integration-product.proto +++ b/packages/validation/tests/proto/integration-product.proto @@ -27,122 +27,141 @@ syntax = "proto3"; package spine.validation.testing.integration; -// Integration test messages demonstrating validation in a product management system. -// -// This file showcases complex validation scenarios including nested message -// validation, field dependencies with `(goes)`, pattern validation, and -// range constraints across multiple message types. +// Fixtures for product-management validation across nested catalog data. import "google/protobuf/timestamp.proto"; import "spine/options.proto"; -// Product message representing a product entity with validation constraints. +// Exercises validation of a catalog product and its dependent display fields. message Product { - // Must follow format 'prod-XXX' (e.g., prod-123). + // Requires the prod-number identifier format. string id = 1 [ (required) = true, (pattern).regex = "^prod-[0-9]+$", (pattern).error_msg = "Product ID must follow format 'prod-XXX'. Provided: `{value}`." ]; + // Requires the product's customer-facing name. string name = 2 [ (required) = true, (if_missing).error_msg = "Product name is required." ]; + // Carries optional catalog copy for the product. string description = 3; + // Requires a sale price of at least one cent. double price = 4 [ (required) = true, (min).value = "0.01", (min).error_msg = "Price must be at least {other}. Provided: {value}." ]; + // Limits available inventory to the supported stock range. int32 stock = 5 [ (min).value = "0", (range).value = "[0..1000000)" ]; + // Requires the timestamp when the product was created. google.protobuf.Timestamp created_at = 6 [(required) = true]; + // Requires and validates the assigned category. Category category = 7 [ (required) = true, (validate) = true, (if_invalid).error_msg = "Category is invalid." ]; + // Allows text color only together with a highlight color. Color text_color = 8 [(goes).with = "highlight_color"]; + // Allows highlight color only together with a text color. Color highlight_color = 9 [(goes).with = "text_color"]; } -// Color message for display settings. +// Holds RGB components for catalog display colors. message Color { + // Limits red intensity to 0 through 255. int32 red = 1 [(range).value = "[0..255]"]; + // Limits green intensity to 0 through 255. int32 green = 2 [(range).value = "[0..255]"]; + // Limits blue intensity to 0 through 255. int32 blue = 3 [(range).value = "[0..255]"]; } -// Category message with validation. +// Identifies a product category for nested validation. message Category { + // Requires a positive category identifier. int32 id = 1 [ (required) = true, (min).value = "1" ]; + // Requires the category's display name. string name = 2 [(required) = true]; } -// Payment method demonstrating choice oneof option. +// Exercises required selection of one checkout payment method. message PaymentMethod { + // Requires exactly one card or bank-account payment detail. oneof method { option (choice).required = true; + // Carries nested card details for validation. PaymentCardNumber payment_card = 1 [(validate) = true]; + // Carries nested bank-account details for validation. BankAccount bank_account = 2 [(validate) = true]; } } -// Payment card number with validation. +// Holds card-payment values subject to format and date constraints. message PaymentCardNumber { - // Must be 13-19 digits. + // Requires a card number containing 13 through 19 digits. string number = 1 [ (required) = true, (pattern).regex = "^[0-9]{13,19}$", (pattern).error_msg = "Card number must be 13-19 digits." ]; + // Requires an expiration month from one through twelve. int32 expiry_month = 2 [ (required) = true, (range).value = "[1..12]" ]; + // Requires an expiration year no earlier than 2024. int32 expiry_year = 3 [ (required) = true, (min).value = "2024" ]; } -// Bank account with validation. +// Holds bank-routing values subject to required format constraints. message BankAccount { - // Must be 8-17 digits. + // Requires an account number containing 8 through 17 digits. string account_number = 1 [ (required) = true, (pattern).regex = "^[0-9]{8,17}$" ]; - // Must be exactly 9 digits (US routing number format). + // Requires a nine-digit US routing number. string routing_number = 2 [ (required) = true, (pattern).regex = "^[0-9]{9}$" ]; } -// Request message for listing products with pagination. +// Requests a bounded page of catalog products. message ListProductsRequest { + // Requires a one-based page number. int32 page = 1 [ (required) = true, (min).value = "1", (if_missing).error_msg = "Page number is required." ]; + // Requires a page size from one through 100. int32 page_size = 2 [ (required) = true, (range).value = "[1..100]", (if_missing).error_msg = "Page size is required." ]; + // Narrows results using an optional search string. string search_query = 3; } -// Response message for listing products. +// Returns validated products and the number of matching records. message ListProductsResponse { + // Carries each returned product through nested validation. repeated Product products = 1 [(validate) = true]; + // Reports a non-negative total number of matches. int32 total_count = 2 [(min).value = "0"]; } diff --git a/packages/spine-validation-ts/tests/proto/integration-user.proto b/packages/validation/tests/proto/integration-user.proto similarity index 71% rename from packages/spine-validation-ts/tests/proto/integration-user.proto rename to packages/validation/tests/proto/integration-user.proto index 14ae37c..443ffad 100644 --- a/packages/spine-validation-ts/tests/proto/integration-user.proto +++ b/packages/validation/tests/proto/integration-user.proto @@ -27,45 +27,49 @@ syntax = "proto3"; package spine.validation.testing.integration; -// Integration test messages demonstrating validation in a complete user management API. -// -// This file showcases how multiple validation options work together in real-world -// scenarios including required fields, pattern validation, distinct values, and -// nested message validation. +// Fixtures for user-management validation across request and response records. import "spine/options.proto"; -// User message representing a user entity with validation constraints. +// Exercises a user record with required, pattern, and distinct constraints. message User { - option (require).fields = "id | email"; + option (require).fields = "email"; + // Supplies the positive user identifier. int32 id = 1 [(min).value = "1"]; - // Must start with a letter and be 2-50 characters (letters, numbers, spaces allowed). + // Requires a 2-to-50 character name beginning with a letter. string name = 2 [ (required) = true, (pattern).regex = "^[A-Za-z][A-Za-z0-9 ]{1,49}$", (pattern).error_msg = "Name must start with a letter and be 2-50 characters. Provided: `{value}`." ]; - // Must be a valid email address (basic format validation). + // Requires an email address and verifies its basic format. string email = 3 [ (required) = true, (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", (pattern).error_msg = "Email must be valid. Provided: `{value}`." ]; + // Requires the user's role selection. Role role = 4 [(required) = true]; + // Requires all user tags to be unique. repeated string tags = 5 [(distinct) = true]; } -// Role enumeration. +// Defines roles used by the user-management fixture. enum Role { + // Marks a user with no role selected. ROLE_UNSPECIFIED = 0; + // Selects the ordinary user role. ROLE_USER = 1; + // Selects the administrative role. ROLE_ADMIN = 2; + // Selects the moderation role. ROLE_MODERATOR = 3; } -// Request message for getting a user. +// Requests one user by a required positive identifier. message GetUserRequest { + // Requires the identifier of the requested user. int32 user_id = 1 [ (required) = true, (min).value = "1", @@ -73,11 +77,13 @@ message GetUserRequest { ]; } -// Response message for getting a user. +// Returns the requested user together with its lookup result. message GetUserResponse { + // Carries a nested user record that must validate. User user = 1 [ (validate) = true, (if_invalid).error_msg = "User data is invalid." ]; + // Indicates whether the requested user was found. bool found = 2; } diff --git a/packages/spine-validation-ts/tests/proto/spine/options.proto b/packages/validation/tests/proto/spine/options.proto similarity index 100% rename from packages/spine-validation-ts/tests/proto/spine/options.proto rename to packages/validation/tests/proto/spine/options.proto diff --git a/packages/validation/tests/proto/spine/time/time.proto b/packages/validation/tests/proto/spine/time/time.proto new file mode 100644 index 0000000..4d2d6e0 --- /dev/null +++ b/packages/validation/tests/proto/spine/time/time.proto @@ -0,0 +1,221 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +package spine.time; + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_multiple_files = true; +option java_outer_classname = "TimeProto"; +option java_package = "io.spine.time"; + +// Enum representing the 12 months of the year. +enum Month { + MONTH_UNDEFINED = 0; + JANUARY = 1; + FEBRUARY = 2; + MARCH = 3; + APRIL = 4; + MAY = 5; + JUNE = 6; + JULY = 7; + AUGUST = 8; + SEPTEMBER = 9; + OCTOBER = 10; + NOVEMBER = 11; + DECEMBER = 12; +} + +// A combination of year and month in the ISO-8601 calendar system, such as `2018-06`. +// +// The purpose of this type is to store values like "June 2018". +// +// Instances of `YearMonth` can be ordered chronologically. +// +message YearMonth { + option (is).java_type = "YearMonthTemporal"; + option (compare_by) = { + field: "year" + field: "month" + }; + + // A year with `1` being year 1 CE and `-1` being 1 BC. + int32 year = 1 [(min).value = "-999999999", (max).value = "999999999"]; + + // One of 12 Gregorian calendar months specified by `Month`. + Month month = 2 [(required) = true]; +} + +// A day of week. +// +// The int value follows the ISO-8601 standard, from 1 (Monday) to 7 (Sunday). +// +enum DayOfWeek { + DOW_UNDEFINED = 0; + MONDAY = 1; + TUESDAY = 2; + WEDNESDAY = 3; + THURSDAY = 4; + FRIDAY = 5; + SATURDAY = 6; + SUNDAY = 7; +} + +// A date without a time-zone. +// +// Use this message for describing a date (e.g. a birthday). +// +// Instances of `LocalDate` can be ordered chronologically. +// +message LocalDate { + option (is).java_type = "LocalDateTemporal"; + option (compare_by) = { + field: "year" + field: "month" + field: "day" + }; + + // A year with `1` being year 1 CE and `-1` being 1 BC. + int32 year = 1 [(min).value = "-999999999", (max).value = "999999999"]; + + // One of 12 Gregorian calendar months specified by `Month`. + Month month = 2 [(required) = true]; + + // A day that must be from 1 to 31 and valid for the year and month. + // + // In generated code for Kotlin/Java this is checked by + // `io.spine.time.validation.LocalDateValidator`. + // + int32 day = 3 [(min).value = "1", (max).value = "31"]; +} + +// A time without a time-zone. +// +// It is a description of a time, not an instant on a time-line. +// +// Instances of `LocalTime` can be ordered chronologically. +// +message LocalTime { + option (is).java_type = "LocalTimeMixin"; + option (compare_by) = { + field: "hour" + field: "minute" + field: "second" + field: "nano" + }; + + // An hour from 0 to 23. + int32 hour = 1 [(min).value = "0", (max).value = "23"]; + + // Minutes of an hour from 0 to 59. + int32 minute = 2 [(min).value = "0", (max).value = "59"]; + + // Seconds of a minute, specified from 0 to 59. + int32 second = 3 [(min).value = "0", (max).value = "59"]; + + // Fractions of a second from 0 to 999,999,999. + int32 nano = 4 [(min).value = "0", (max).value = "999999999"]; +} + +// A date-time without a time-zone. +// +// Instances of `LocalDateTime` can be ordered chronologically. +// +message LocalDateTime { + option (is).java_type = "LocalDateTimeTemporal"; + option (compare_by) = { + field: "date" + field: "time" + }; + + LocalDate date = 1 [(required) = true]; + LocalTime time = 2; +} + +// A time-zone offset from UTC, such as `+02:00`. +message ZoneOffset { + + // Please use [ZoneId] instead. + option deprecated = true; + + // The total offset in seconds. + int32 amount_seconds = 1; +} + +// A time with an offset from UTC. +message OffsetTime { + + // Please use [LocalTime] in combination with [ZoneId] instead. + option deprecated = true; + + // The local time. + LocalTime time = 1; + + // The offset of the time-zone from UTC. + ZoneOffset offset = 2; +} + +// A date-time with an offset from UTC. +message OffsetDateTime { + + // Usage history has shown that this type causes much confusion. + // Please use [ZonedDateTime] instead. + option deprecated = true; + + option (is).java_type = "OffsetDateTimeTemporal"; + + // The local date-time. + LocalDateTime date_time = 1 [(required) = true]; + + // The offset of the time-zone from UTC. + ZoneOffset offset = 2; +} + +// An ID of a time-zone, such as `Europe/Amsterdam`. +message ZoneId { + option (is).java_type = "ZoneIdMixin"; + + string value = 1; +} + +// A date-time with a time-zone in the ISO-8601 calendar system, +// such as `2018-06-25T19:22:45+01:00 Europe/Amsterdam`. +// +// Instances of `ZonedDateTime` cannot be ordered by field values, since the chronological order +// depends on interpretation of time in different time zones. +// +message ZonedDateTime { + option (is).java_type = "ZonedDateTimeTemporal"; + + // The local date-time. + LocalDateTime date_time = 1 [(required) = true]; + + // The time-zone. + ZoneId zone = 2 [(required) = true]; +} diff --git a/packages/validation/tests/proto/spine/time_options.proto b/packages/validation/tests/proto/spine/time_options.proto new file mode 100644 index 0000000..e069e87 --- /dev/null +++ b/packages/validation/tests/proto/spine/time_options.proto @@ -0,0 +1,106 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +syntax = "proto3"; + +// API Note on Packaging +// --------------------- +// We do not define the package for this file to allow shorter options for user-defined types. +// This allows writing: +// +// [(when).in = FUTURE]; +// +// instead of: +// +// [(spine.time.when).in = FUTURE]; +// + +import "spine/options.proto"; + +option (type_url_prefix) = "type.spine.io"; +option java_package = "io.spine.time.validation"; +option java_outer_classname = "TimeOptionsProto"; +option java_multiple_files = true; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + + // See `TimeOption`. + TimeOption when = 73819; +} + +// Specifies that the field value is a point in time lying either in the future or in the past. +// +// Applicable to `google.protobuf.Timestamp` and types introduced in the `spine.time` package +// that describe time-related concepts. +// +// Repeated fields are supported. +// +// Example: Using the `(when)` option. +// +// message ScheduleMeeting { +// spine.time.ZonedDateTime start = 1 [(when).in = FUTURE]; +// } +// +message TimeOption { + + // The default error message. + option (default_message) = "The field `${parent.type}.${field.path}`" + " of the type `${field.type}` must be in the `${when.in}`." + " The encountered value: `${field.value}`."; + + // Defines a restriction for the timestamp. + Time in = 1; + + // Deprecated: please use `error_msg` instead. + string msg_format = 2 [deprecated = true]; + + // A user-defined error message. + // + // The specified message may include the following placeholders: + // + // 1. `${field.path}` โ€“ the field path. + // 2. `${field.value}` - the field value. + // 3. `${field.type}` โ€“ the fully qualified name of the field type. + // 4. `${parent.type}` โ€“ the fully qualified name of the validated message. + // 5. `${when.in}` โ€“ the specified timestamp restriction. It is either "past" or "future". + // + string error_msg = 3; +} + +// This enumeration defines a restriction for date/time values. +enum Time { + + // The default value (if the time option is not set). + TIME_UNDEFINED = 0; + + // The value must be in the past. + PAST = 1; + + // The value must be in the future. + FUTURE = 2; +} diff --git a/packages/spine-validation-ts/tests/proto/test-choice.proto b/packages/validation/tests/proto/test-choice.proto similarity index 60% rename from packages/spine-validation-ts/tests/proto/test-choice.proto rename to packages/validation/tests/proto/test-choice.proto index e8e9ad8..e105d79 100644 --- a/packages/spine-validation-ts/tests/proto/test-choice.proto +++ b/packages/validation/tests/proto/test-choice.proto @@ -30,35 +30,62 @@ package test; import "spine/options.proto"; -// Test message with required choice option. +// Requires one checkout payment instrument. message PaymentMethod { + // Requires exactly one supported payment instrument. oneof method { option (choice).required = true; + // Supplies a credit-card payment reference. string credit_card = 1; + // Supplies a bank-account payment reference. string bank_account = 2; + // Supplies a PayPal payment reference. string paypal = 3; } } -// Test message with custom error message. +// Requires a contact channel and verifies its custom missing-choice message. message ContactMethod { + // Requires either an email address or a phone number. oneof contact { option (choice).required = true; option (choice).error_msg = "You must provide a contact method (email or phone)."; + // Supplies the email contact channel. string email = 1; + // Supplies the phone contact channel. string phone = 2; } } -// Test message with optional oneof (choice.required = false). +// Exercises a delivery-method choice that may be omitted. message ShippingOption { + // Allows at most one delivery speed without requiring a selection. oneof delivery { option (choice).required = false; + // Selects standard delivery. bool standard = 1; + // Selects express delivery. bool express = 2; + // Selects overnight delivery. bool overnight = 3; } } + +// Exercises two independently required oneof groups in one message. +message MultipleRequiredChoices { + // Requires the first choice group to receive a value. + oneof first { + option (choice).required = true; + // Supplies the required count for the first group. + int32 count = 1; + } + // Requires the second choice group to receive a value. + oneof second { + option (choice).required = true; + // Supplies the required enabled flag for the second group. + bool enabled = 2; + } +} diff --git a/packages/validation/tests/proto/test-distinct.proto b/packages/validation/tests/proto/test-distinct.proto new file mode 100644 index 0000000..dd9cccc --- /dev/null +++ b/packages/validation/tests/proto/test-distinct.proto @@ -0,0 +1,189 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.validation.testing.distinct_suite; + +// Fixtures covering `(distinct)` uniqueness on supported collection shapes. + +import "spine/options.proto"; + +// Exercises uniqueness checks for repeated primitive values. +message DistinctPrimitives { + // Requires each repeated integer to occur once. + repeated int32 numbers = 1 [(distinct) = true]; + // Requires each repeated tag to occur once. + repeated string tags = 2 [(distinct) = true]; + // Requires each repeated score to occur once. + repeated double scores = 3 [(distinct) = true]; + // Requires each repeated boolean flag to occur once. + repeated bool flags = 4 [(distinct) = true]; +} + +// Exercises uniqueness checks for repeated enum values. +message DistinctEnums { + // Requires each repeated status to occur once. + repeated Status statuses = 1 [(distinct) = true]; +} + +// Defines statuses used by the distinct-enum fixture. +enum Status { + // Represents an unset status. + STATUS_UNSPECIFIED = 0; + // Represents an active status. + STATUS_ACTIVE = 1; + // Represents an inactive status. + STATUS_INACTIVE = 2; + // Represents a pending status. + STATUS_PENDING = 3; +} + +// Verifies that repeated fields without `(distinct)` allow duplicates. +message NonDistinctFields { + // Holds numbers that intentionally have no uniqueness constraint. + repeated int32 numbers = 1; + // Holds tags that intentionally have no uniqueness constraint. + repeated string tags = 2; +} + +// Combines `(distinct)` with range, pattern, and boundary rules. +message CombinedConstraints { + // Requires unique product identifiers in the allowed range. + repeated int32 product_ids = 1 [ + (distinct) = true, + (range).value = "[1..999999]" + ]; + // Requires unique email addresses matching the fixture pattern. + repeated string emails = 2 [ + (distinct) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ]; + // Requires unique scores from zero through 100. + repeated int32 scores = 3 [ + (distinct) = true, + (min).value = "0", + (max).value = "100" + ]; +} + +// Exercises uniqueness on repeated fields that may be absent. +message OptionalDistinct { + // Holds optional numbers that must be unique when supplied. + repeated int32 optional_numbers = 1 [(distinct) = true]; + // Holds optional tags that must be unique when supplied. + repeated string optional_tags = 2 [(distinct) = true]; +} + +// Represents a profile with a required name and unique label sets. +message UserProfile { + // Requires the profile's username. + string username = 1 [(required) = true]; + // Requires the profile's tags to be unique. + repeated string tags = 2 [(distinct) = true]; + // Requires the profile's skills to be unique. + repeated string skills = 3 [(distinct) = true]; +} + +// Represents a cart whose products and coupons cannot repeat. +message ShoppingCart { + // Requires each product identifier to occur once. + repeated int32 product_ids = 1 [(distinct) = true]; + // Requires each coupon code to occur once. + repeated string coupon_codes = 2 [(distinct) = true]; +} + +// Exercises uniqueness across all supported repeated numeric types. +message DistinctNumericTypes { + // Requires each 32-bit signed integer to occur once. + repeated int32 int32_values = 1 [(distinct) = true]; + // Requires each 64-bit signed integer to occur once. + repeated int64 int64_values = 2 [(distinct) = true]; + // Requires each 32-bit unsigned integer to occur once. + repeated uint32 uint32_values = 3 [(distinct) = true]; + // Requires each 64-bit unsigned integer to occur once. + repeated uint64 uint64_values = 4 [(distinct) = true]; + // Requires each floating-point value to occur once. + repeated float float_values = 5 [(distinct) = true]; + // Requires each double-precision value to occur once. + repeated double double_values = 6 [(distinct) = true]; +} + +// Exercises uniqueness for empty, zero, and case-sensitive values. +message DistinctEdgeCases { + // Requires each empty-or-populated string to occur once. + repeated string empty_strings = 1 [(distinct) = true]; + // Requires each zero-or-nonzero integer to occur once. + repeated int32 zeros = 2 [(distinct) = true]; + // Treats differently cased strings as distinct values. + repeated string case_sensitive = 3 [(distinct) = true]; +} + +// Exercises the descriptor-aware equality paths used by `(distinct)`. +message DistinctAdvanced { + // Requires byte sequences to be unique by content. + repeated bytes byte_values = 1 [(distinct) = true]; + // Requires 64-bit integers to be unique. + repeated int64 int64_values = 2 [(distinct) = true]; + // Requires enum statuses to be unique. + repeated Status statuses = 3 [(distinct) = true]; + // Requires nested messages to be unique by descriptor-aware equality. + repeated DistinctValue messages = 4 [(distinct) = true]; + // Exercises `(distinct)` on a string-valued map. + map<string, string> names = 5 [(distinct) = true]; + // Exercises `(distinct)` on an enum-valued map. + map<string, Status> state_by_name = 6 [(distinct) = true]; + // Exercises `(distinct)` on a message-valued map. + map<string, DistinctValue> value_by_name = 7 [(distinct) = true]; +} + +// Provides nested values for descriptor-aware equality checks. +message DistinctValue { + // Names the nested value. + string name = 1; + // Orders otherwise similar nested values. + int64 sequence = 2; +} + +// Deliberately applies `(distinct)` to a scalar field, which is invalid. +message DistinctUnsupportedTarget { + // Is the unsupported scalar target for the `(distinct)` option. + string name = 1 [(distinct) = true]; +} + +// Verifies that an explicit false `(distinct)` option is a no-op. +message DistinctDisabled { + // Holds values for the disabled uniqueness option. + repeated string values = 1 [(distinct) = false]; +} + +// Verifies the custom duplicate-message override. +message DistinctCustomMessage { + // Holds values whose duplicates use the configured error message. + repeated string values = 1 [ + (distinct) = true, + (if_has_duplicates).error_msg = "Duplicate class: `${field.duplicates}`." + ]; +} diff --git a/packages/validation/tests/proto/test-goes.proto b/packages/validation/tests/proto/test-goes.proto new file mode 100644 index 0000000..c5ebe93 --- /dev/null +++ b/packages/validation/tests/proto/test-goes.proto @@ -0,0 +1,198 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.validation.testing.goes_suite; + +// Fixtures for `(goes)` dependencies between companion fields. + +import "spine/options.proto"; + +// Requires an event time to be accompanied by an event date. +message ScheduledEvent { + // Requires a name for the scheduled event. + string event_name = 1 [(required) = true]; + // Supplies the event date required by a provided time. + string date = 2; + // Supplies an event time only when a date is present. + string time = 3 [(goes).with = "date"]; +} + +// Verifies a custom error message for an unmet shipping dependency. +message ShippingDetails { + // Supplies the shipping destination. + string address = 1; + // Allows tracking details only when an address is present. + string tracking_number = 2 [ + (goes).with = "address", + (goes).error_msg = "Tracking number requires a shipping address: {value}." + ]; +} + +// Requires text and highlight colors to be supplied together. +message ColorSettings { + // Allows text color only with a highlight color. + string text_color = 1 [(goes).with = "highlight_color"]; + // Allows highlight color only with a text color. + string highlight_color = 2 [(goes).with = "text_color"]; +} + +// Exercises a chain of dependencies in card payment data. +message PaymentInfo { + // Names the cardholder. + string cardholder_name = 1; + // Allows a card number only when the cardholder is present. + string card_number = 2 [(goes).with = "cardholder_name"]; + // Allows a CVV only when a card number is present. + string cvv = 3 [(goes).with = "card_number"]; + // Stores the card expiration month without a dependency. + int32 expiry_month = 4; +} + +// Provides independent field types for companion-target configuration tests. +message ProfileSettings { + // Supplies the profile username target. + string username = 1; + // Supplies the numeric display identifier target. + int32 display_id = 2; + // Supplies the boolean verification target. + bool is_verified = 3; + // Supplies the floating-point rating target. + double rating = 4; +} + +// Deliberately points `(goes)` from an int32 field to a string companion. +message InvalidGoesTarget { + // Is the string companion used by the invalid target-type fixture. + string username = 1; + // Is the incompatible int32 field carrying the `(goes)` option. + int32 display_id = 2 [(goes).with = "username"]; +} + +// Deliberately references a missing `(goes)` companion field. +message InvalidGoesUnknownCompanion { + // Is the field whose `(goes)` option names the absent companion. + string value = 1 [(goes).with = "missing"]; +} + +// Deliberately uses a numeric companion for a string `(goes)` field. +message InvalidGoesNumericCompanion { + // Is the string field with the incompatible numeric companion. + string value = 1 [(goes).with = "number"]; + // Is the numeric companion that makes the configuration invalid. + int32 number = 2; +} + +// Verifies a message-valued field's dependency on a title. +message DocumentMetadata { + // Supplies the document title required by metadata. + string title = 1; + // Supplies creation metadata only when a title is present. + Timestamp created_at = 2 [(goes).with = "title"]; +} + +// Represents the timestamp payload used by document metadata. +message Timestamp { + // Stores whole seconds since the epoch. + int64 seconds = 1; + // Stores nanoseconds within the current second. + int32 nanos = 2; +} + +// Combines account-format validation with a recovery-contact dependency. +message SecureAccount { + // Requires a 3-to-20 character username using letters, numbers, or underscores. + string username = 1 [(required) = true, (pattern).regex = "^[a-zA-Z0-9_]{3,20}$"]; + // Requires a password of at least eight characters. + string password = 2 [ + (required) = true, + (pattern).regex = "^.{8,}$" + ]; + // Validates the optional recovery email's basic format. + string recovery_email = 3 [(pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"]; + // Allows a recovery phone only when a recovery email is present. + string recovery_phone = 4 [(goes).with = "recovery_email"]; +} + +// Verifies fields without `(goes)` impose no companion requirement. +message SimpleConfig { + // Holds the primary configuration setting. + string primary_option = 1; + // Holds the independent secondary configuration setting. + string secondary_option = 2; +} + +// Verifies a string configuration's dependency on an enum selection. +message FeatureFlags { + // Selects the feature level required by custom configuration. + FeatureLevel level = 1; + // Allows custom configuration only when a feature level is selected. + string custom_config = 2 [(goes).with = "level"]; +} + +// Defines feature levels used by the enum-companion fixture. +enum FeatureLevel { + // Represents an unselected feature level. + FEATURE_LEVEL_UNSPECIFIED = 0; + // Selects the basic feature tier. + FEATURE_LEVEL_BASIC = 1; + // Selects the advanced feature tier. + FEATURE_LEVEL_ADVANCED = 2; + // Selects the premium feature tier. + FEATURE_LEVEL_PREMIUM = 3; +} + +// Exercises report fields with chained `(goes)` requirements. +message ReportGeneration { + // Selects the report type required by dependent fields. + string report_type = 1; + // Allows an output format only when a report type is selected. + string output_format = 2 [(goes).with = "report_type"]; + // Allows an email recipient only when a report type is selected. + string email_recipient = 3 [(goes).with = "report_type"]; + // Allows a schedule only when an output format is selected. + string schedule = 4 [(goes).with = "output_format"]; +} + +// Verifies optional URL settings with a path-to-base dependency. +message OptionalSettings { + // Supplies the base URL required by a provided path. + string base_url = 1; + // Supplies an independent optional port. + int32 port = 2; + // Allows a path only when a base URL is present. + string path = 3 [(goes).with = "base_url"]; +} + +// Combines basic numeric constraints with independent configuration fields. +message AdvancedConfig { + // Names the advanced configuration. + string config_name = 1; + // Limits maximum connections to one through 1,000. + int32 max_connections = 2 [(range).value = "[1..1000]"]; + // Requires a timeout of at least one tenth of a second. + double timeout_seconds = 3 [(min).value = "0.1"]; +} diff --git a/packages/validation/tests/proto/test-min-max.proto b/packages/validation/tests/proto/test-min-max.proto new file mode 100644 index 0000000..3d53209 --- /dev/null +++ b/packages/validation/tests/proto/test-min-max.proto @@ -0,0 +1,271 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.validation.testing.minmax_suite; + +import "spine/options.proto"; +// Fixture `MinValue` exercises `(min)`. +message MinValue { + // Validates `positive_id` with `(min).value = "1"`. + int32 positive_id = 1 [(min).value = "1"]; + // Validates `non_negative` with `(min).value = "0"`. + int32 non_negative = 2 [(min).value = "0"]; + // Validates `price` with `(min).value = "0.01"`. + double price = 3 [(min).value = "0.01"]; +} +// Fixture `MaxValue` exercises `(max)`. +message MaxValue { + // Validates `percentage` with `(max).value = "100"`. + int32 percentage = 1 [(max).value = "100"]; + // Validates `altitude` with `(max).value = "8848.86"`. + double altitude = 2 [(max).value = "8848.86"]; + // Validates `year` with `(max).value = "2100"`. + int64 year = 3 [(max).value = "2100"]; +} +// Fixture `MinMaxRange` exercises `(min)`, `(max)`. +message MinMaxRange { + // Validates `age` with `(min).value = "0"`, `(max).value = "150"`. + int32 age = 1 [(min).value = "0", (max).value = "150"]; + // Validates `temperature` with `(min).value = "-273.15"`, `(max).value = "1000.0"`. + double temperature = 2 [(min).value = "-273.15", (max).value = "1000.0"]; + // Validates `percentage` with `(min).value = "0"`, `(max).value = "100"`. + int32 percentage = 3 [(min).value = "0", (max).value = "100"]; +} +// Tests strictly exclusive numeric lower and upper bounds. +message ExclusiveBounds { + // Requires a value strictly greater than zero. + double positive_value = 1 [(min) = { + value: "0.0", + exclusive: true + }]; + // Requires a positive Kelvin temperature with a custom failure message. + double temperature_kelvin = 2 [(min) = { + value: "0.0", + exclusive: true, + error_msg: "Temperature cannot reach {other}K, but provided {value}." + }]; + // Requires a value strictly below 100. + int32 below_limit = 3 [(max) = { + value: "100", + exclusive: true + }]; +} +// Tests custom error messages for minimum and maximum bounds. +message CustomErrorMessages { + // Enforces an adult-age lower bound with a custom message. + int32 age = 1 [(min) = { + value: "18", + error_msg: "Must be at least {other} years old. Provided: {value}." + }]; + // Enforces custom lower and upper balance messages. + double balance = 2 [(min) = { + value: "0.01", + error_msg: "Balance must be at least ${other}. Current: ${value}." + }, (max) = { + value: "1000000.0", + error_msg: "Balance cannot exceed ${other}. Current: ${value}." + }]; +} +// Fixture `NumericTypes` exercises `(min)`, `(max)`. +message NumericTypes { + // Validates `int32_field` with `(min).value = "0"`, `(max).value = "2147483647"`. + int32 int32_field = 1 [(min).value = "0", (max).value = "2147483647"]; + // Validates `int64_field` with `(min).value = "0"`. + int64 int64_field = 2 [(min).value = "0"]; + // Validates `uint32_field` with `(max).value = "4294967295"`. + uint32 uint32_field = 3 [(max).value = "4294967295"]; + // Validates `uint64_field` with `(min).value = "1"`. + uint64 uint64_field = 4 [(min).value = "1"]; + // Validates `float_field` with `(min).value = "0.0"`, `(max).value = "100.0"`. + float float_field = 5 [(min).value = "0.0", (max).value = "100.0"]; + // Validates `double_field` with `(min).value = "-1000.0"`, `(max).value = "1000.0"`. + double double_field = 6 [(min).value = "-1000.0", (max).value = "1000.0"]; +} +// Fixture `RepeatedMinMax` exercises `(min)`, `(max)`. +message RepeatedMinMax { + // Validates `scores` with `(min).value = "0"`, `(max).value = "100"`. + repeated int32 scores = 1 [(min).value = "0", (max).value = "100"]; + // Validates `prices` with `(min).value = "0.01"`. + repeated double prices = 2 [(min).value = "0.01"]; + // Validates `measurements` with `(min).value = "0.0"`, `(max).value = "100.0"`. + repeated float measurements = 3 [(min).value = "0.0", (max).value = "100.0"]; +} +// Fixture `CombinedConstraints` exercises `(min)`. +message CombinedConstraints { + // Validates `product_id` with `(min).value = "1"`. + int32 product_id = 1 [(min).value = "1"]; + // Enforces the custom minimum price of 0.01. + double price = 2 [ + (min) = { + value: "0.01", + error_msg: "Price must be at least {other}." + } + ]; + // Validates `stock` with `(min).value = "0"`. + int32 stock = 3 [(min).value = "0"]; +} +// Fixture `OptionalMinMax` exercises `(min)`, `(max)`. +message OptionalMinMax { + // Validates `optional_count` with `(min).value = "1"`. + int32 optional_count = 1 [(min).value = "1"]; + // Validates `optional_rating` with `(max).value = "5.0"`. + double optional_rating = 2 [(max).value = "5.0"]; +} +// Fixture `NumericBoundsContract` exercises `(min)`, `(max)`. +message NumericBoundsContract { + // Validates `precise_min` with `(min).value = "9007199254740993"`. + int64 precise_min = 1 [(min).value = "9007199254740993"]; + // Validates `precise_max` with `(max).value = "9007199254740993"`. + uint64 precise_max = 2 [(max).value = "9007199254740993"]; + // Validates `repeated_precise` with `(min).value = "9007199254740993"`. + repeated int64 repeated_precise = 3 [(min).value = "9007199254740993"]; +} + +// Deliberately tests invalid configuration `(min).value = "1x"`. +message InvalidMinSuffix { + // Validates `value` with `(min).value = "1x"`. + int32 value = 1 [(min).value = "1x"]; +} +// Deliberately tests invalid configuration `(min).value = "1"`. +message InvalidMinFloat { + // Validates `value` with `(min).value = "1"`. + double value = 1 [(min).value = "1"]; +} +// Deliberately tests invalid configuration `(min).value = "-1"`. +message InvalidMinUnsigned { + // Validates `value` with `(min).value = "-1"`. + uint32 value = 1 [(min).value = "-1"]; +} +// Deliberately tests invalid configuration `(min).value = "1"`. +message InvalidMinTarget { + // Validates `value` with `(min).value = "1"`. + string value = 1 [(min).value = "1"]; +} + +// Supplies numeric bounds used by reference-based minimum and maximum checks. +message NumericLimits { + // Supplies the referenced signed lower bound. + int64 lower = 1; + // Supplies the referenced floating-point upper bound. + double upper = 2; +} + +// Fixture `NumericReferences` exercises `(min)`, `(max)`. +message NumericReferences { + // Supplies the object containing named numeric bounds. + NumericLimits limits = 1; + // Validates `actual` with `(min).value = "limits.lower"`. + int64 actual = 2 [(min).value = "limits.lower"]; + // Validates `measured` with `(max).value = "limits.upper"`. + double measured = 3 [(max).value = "limits.upper"]; +} +// Deliberately tests invalid configuration `(min).value = "does_not_exist"`. +message MissingNumericReference { + // Validates `value` with `(min).value = "does_not_exist"`. + int32 value = 1 [(min).value = "does_not_exist"]; +} +// Deliberately tests invalid configuration `(min).value = "limits"`. +message IncompatibleNumericReference { + // Stores the referenced numeric bounds. + NumericLimits limits = 1; + // Deliberately references the message-valued `limits` target as a minimum. + int32 value = 2 [(min).value = "limits"]; +} + +// Fixture `NumericScalarMatrix` exercises `(min)`, `(max)`. +message NumericScalarMatrix { + // Validates `sint32_value` with `(min).value = "-1"`. + sint32 sint32_value = 1 [(min).value = "-1"]; + // Validates `sint64_value` with `(max).value = "9007199254740993"`. + sint64 sint64_value = 2 [(max).value = "9007199254740993"]; + // Validates `fixed32_value` with `(min).value = "1"`. + fixed32 fixed32_value = 3 [(min).value = "1"]; + // Validates `fixed64_value` with `(max).value = "9007199254740993"`. + fixed64 fixed64_value = 4 [(max).value = "9007199254740993"]; + // Validates `sfixed32_value` with `(min).value = "-1"`. + sfixed32 sfixed32_value = 5 [(min).value = "-1"]; + // Validates `sfixed64_value` with `(max).value = "9007199254740993"`. + sfixed64 sfixed64_value = 6 [(max).value = "9007199254740993"]; +} + +// Fixture `CrossTypeReferences` exercises `(min)`, `(max)`. +message CrossTypeReferences { + // Supplies the double-valued lower-bound reference. + double double_bound = 1; + // Supplies the 64-bit upper-bound reference. + int64 int64_bound = 2; + // Validates `integer_value` with `(min).value = "double_bound"`. + int64 integer_value = 3 [(min).value = "double_bound"]; + // Validates `floating_value` with `(max).value = "int64_bound"`. + double floating_value = 4 [(max).value = "int64_bound"]; +} + +// Deliberately tests invalid configuration `(min).value = "2147483648"`. +message InvalidInt32Overflow { + // Validates `value` with `(min).value = "2147483648"`. + int32 value = 1 [(min).value = "2147483648"]; +} +// Deliberately tests invalid configuration `(max).value = "4294967296"`. +message InvalidUint32Overflow { + // Validates `value` with `(max).value = "4294967296"`. + uint32 value = 1 [(max).value = "4294967296"]; +} +// Deliberately tests invalid configuration `(min).value = "9223372036854775808"`. +message InvalidInt64Overflow { + // Validates `value` with `(min).value = "9223372036854775808"`. + int64 value = 1 [(min).value = "9223372036854775808"]; +} +// Deliberately tests invalid configuration `(max).value = "18446744073709551616"`. +message InvalidUint64Overflow { + // Validates `value` with `(max).value = "18446744073709551616"`. + uint64 value = 1 [(max).value = "18446744073709551616"]; +} +// Deliberately tests invalid configuration `(min).value = "-1"`. +message InvalidUint64Negative { + // Validates `value` with `(min).value = "-1"`. + uint64 value = 1 [(min).value = "-1"]; +} +// Deliberately tests invalid configuration `(min).value = "1.0"`. +message InvalidIntegerDecimal { + // Validates `value` with `(min).value = "1.0"`. + sint32 value = 1 [(min).value = "1.0"]; +} +// Deliberately tests invalid configuration `(min).value = "1.0e"`. +message InvalidFloatExponent { + // Validates `value` with `(min).value = "1.0e"`. + double value = 1 [(min).value = "1.0e"]; +} +// Deliberately tests invalid configuration `(min).value = "3.5e38"`. +message InvalidFloatOverflow { + // Validates `value` with `(min).value = "3.5e38"`. + float value = 1 [(min).value = "3.5e38"]; +} +// Deliberately tests invalid configuration `(min).value = "1.8e308"`. +message InvalidDoubleOverflow { + // Validates `value` with `(min).value = "1.8e308"`. + double value = 1 [(min).value = "1.8e308"]; +} diff --git a/packages/spine-validation-ts/tests/proto/test-pattern.proto b/packages/validation/tests/proto/test-pattern.proto similarity index 100% rename from packages/spine-validation-ts/tests/proto/test-pattern.proto rename to packages/validation/tests/proto/test-pattern.proto diff --git a/packages/validation/tests/proto/test-range.proto b/packages/validation/tests/proto/test-range.proto new file mode 100644 index 0000000..58b7425 --- /dev/null +++ b/packages/validation/tests/proto/test-range.proto @@ -0,0 +1,175 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.validation.testing.range_suite; + +import "spine/options.proto"; +// Fixture `ClosedRange` exercises `(range)`. +message ClosedRange { + // Validates `percentage` with `(range).value = "[0..100]"`. + int32 percentage = 1 [(range).value = "[0..100]"]; + // Validates `rgb_value` with `(range).value = "[0..255]"`. + int32 rgb_value = 2 [(range).value = "[0..255]"]; + // Validates `temperature_c` with `(range).value = "[-273.15..1000.0]"`. + double temperature_c = 3 [(range).value = "[-273.15..1000.0]"]; +} +// Fixture `OpenRange` exercises `(range)`. +message OpenRange { + // Validates `positive_value` with `(range).value = "(0.0..100.0)"`. + double positive_value = 1 [(range).value = "(0.0..100.0)"]; + // Validates `exclusive_count` with `(range).value = "(0..10)"`. + int32 exclusive_count = 2 [(range).value = "(0..10)"]; +} +// Fixture `HalfOpenRange` exercises `(range)`. +message HalfOpenRange { + // Validates `hour` with `(range).value = "[0..24)"`. + int32 hour = 1 [(range).value = "[0..24)"]; + // Validates `minute` with `(range).value = "[0..60)"`. + int32 minute = 2 [(range).value = "[0..60)"]; + // Validates `degree` with `(range).value = "[0.0..360.0)"`. + float degree = 3 [(range).value = "[0.0..360.0)"]; + // Validates `angle` with `(range).value = "(0.0..180.0]"`. + double angle = 4 [(range).value = "(0.0..180.0]"]; +} +// Fixture `NumericTypeRanges` exercises `(range)`. +message NumericTypeRanges { + // Validates `int32_field` with `(range).value = "[1..100]"`. + int32 int32_field = 1 [(range).value = "[1..100]"]; + // Validates `int64_field` with `(range).value = "[0..1000000]"`. + int64 int64_field = 2 [(range).value = "[0..1000000]"]; + // Validates `uint32_field` with `(range).value = "[1..65535]"`. + uint32 uint32_field = 3 [(range).value = "[1..65535]"]; + // Validates `uint64_field` with `(range).value = "[1..4294967295]"`. + uint64 uint64_field = 4 [(range).value = "[1..4294967295]"]; + // Validates `float_field` with `(range).value = "[0.0..1.0]"`. + float float_field = 5 [(range).value = "[0.0..1.0]"]; + // Validates `double_field` with `(range).value = "[-1000.0..1000.0]"`. + double double_field = 6 [(range).value = "[-1000.0..1000.0]"]; +} +// Fixture `RepeatedRange` exercises `(range)`. +message RepeatedRange { + // Validates `scores` with `(range).value = "[0..100]"`. + repeated int32 scores = 1 [(range).value = "[0..100]"]; + // Validates `percentages` with `(range).value = "[0.0..100.0]"`. + repeated double percentages = 2 [(range).value = "[0.0..100.0]"]; +} +// Fixture `CombinedConstraints` exercises `(range)`. +message CombinedConstraints { + // Validates `product_id` with `(range).value = "[1..999999]"`. + int32 product_id = 1 [(range).value = "[1..999999]"]; + // Validates `quantity` with `(range).value = "[1..1000]"`. + int32 quantity = 2 [(range).value = "[1..1000]"]; + // Validates `discount` with `(range).value = "[0.0..1.0]"`. + double discount = 3 [(range).value = "[0.0..1.0]"]; +} +// Fixture `PaymentCard` exercises `(range)`. +message PaymentCard { + // Validates `expiry_month` with `(range).value = "[1..12]"`. + int32 expiry_month = 1 [(range).value = "[1..12]"]; + // Validates `expiry_year` with `(range).value = "[2024..2050]"`. + int32 expiry_year = 2 [(range).value = "[2024..2050]"]; + // Validates `cvv` with `(range).value = "[0..999]"`. + int32 cvv = 3 [(range).value = "[0..999]"]; +} +// Fixture `RGBColor` exercises `(range)`. +message RGBColor { + // Validates `red` with `(range).value = "[0..255]"`. + int32 red = 1 [(range).value = "[0..255]"]; + // Validates `green` with `(range).value = "[0..255]"`. + int32 green = 2 [(range).value = "[0..255]"]; + // Validates `blue` with `(range).value = "[0..255]"`. + int32 blue = 3 [(range).value = "[0..255]"]; + // Validates `alpha` with `(range).value = "[0.0..1.0]"`. + double alpha = 4 [(range).value = "[0.0..1.0]"]; +} +// Fixture `PaginationRequest` exercises `(range)`. +message PaginationRequest { + // Validates `page` with `(range).value = "[1..10000]"`. + int32 page = 1 [(range).value = "[1..10000]"]; + // Validates `page_size` with `(range).value = "[1..100]"`. + int32 page_size = 2 [(range).value = "[1..100]"]; +} +// Fixture `OptionalRange` exercises `(range)`. +message OptionalRange { + // Validates `optional_score` with `(range).value = "[1..100]"`. + int32 optional_score = 1 [(range).value = "[1..100]"]; + // Validates `optional_rating` with `(range).value = "[1.0..5.0]"`. + double optional_rating = 2 [(range).value = "[1.0..5.0]"]; +} +// Fixture `EdgeCaseRanges` exercises `(range)`. +message EdgeCaseRanges { + // Validates `exact_value` with `(range).value = "[42..42]"`. + int32 exact_value = 1 [(range).value = "[42..42]"]; + // Validates `pi_approx` with `(range).value = "[3.14..3.15]"`. + double pi_approx = 2 [(range).value = "[3.14..3.15]"]; +} + +// Fixture `RangeContract` exercises `(range)`. +message RangeContract { + // Validates `precise` with `(range).value = "[9007199254740993..9007199254740995]"`. + int64 precise = 1 [(range).value = "[9007199254740993..9007199254740995]"]; + // Validates `repeated` with `(range).value = "[0..10]"`. + repeated int32 repeated = 2 [(range).value = "[0..10]"]; +} +// Deliberately tests invalid configuration `(range).value = "[10..0]"`. +message ReversedRange { + // Validates `value` with `(range).value = "[10..0]"`. + int32 value = 1 [(range).value = "[10..0]"]; +} +// Deliberately tests invalid configuration `(range).value = "[0..1..2]"`. +message MalformedRange { + // Validates `value` with `(range).value = "[0..1..2]"`. + int32 value = 1 [(range).value = "[0..1..2]"]; +} +// Deliberately tests invalid configuration `(range).value = "[0..1]"`. +message InvalidRangeTarget { + // Validates `value` with `(range).value = "[0..1]"`. + string value = 1 [(range).value = "[0..1]"]; +} + +// Supplies the named upper bound used by a range expression. +message RangeBounds { + // Provides the upper endpoint referenced by `limits.upper`. + int32 upper = 1; +} +// Fixture `RangeTextReferences` exercises `(range)`. +message RangeTextReferences { + // Provides the range-bound object used by the textual reference. + RangeBounds limits = 1; + // Validates `value` with `(range).value = "[ -1 .. limits.upper ]"`. + int32 value = 2 [(range).value = "[ -1 .. limits.upper ]"]; + // Validates `literal` with `(range).value = "[ 1 .. 2 ]"`. + int32 literal = 3 [(range).value = "[ 1 .. 2 ]"]; +} + +// Fixture `ExactLongRanges` exercises `(range)`. +message ExactLongRanges { + // Validates `signed_value` with `(range).value = "[9007199254740993..9007199254740995]"`. + int64 signed_value = 1 [(range).value = "[9007199254740993..9007199254740995]"]; + // Validates `unsigned_value` with `(range).value = "[9007199254740993..9007199254740995]"`. + uint64 unsigned_value = 2 [(range).value = "[9007199254740993..9007199254740995]"]; +} diff --git a/packages/validation/tests/proto/test-required-field.proto b/packages/validation/tests/proto/test-required-field.proto new file mode 100644 index 0000000..ac4d71c --- /dev/null +++ b/packages/validation/tests/proto/test-required-field.proto @@ -0,0 +1,198 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.validation.testing.requiredfield_suite; + +import "spine/options.proto"; +// Fixture `UserIdentifier` exercises `(require)`. +message UserIdentifier { + option (require).fields = "id | email"; + + // Supplies the first alternative required identifier. + string id = 1; + // Supplies the second alternative required identifier. + string email = 2; +} +// Fixture `ContactInfo` exercises `(require)`. +message ContactInfo { + option (require).fields = "phone & country_code"; + + // Supplies the required phone conjunct. + string phone = 1; + // Supplies the required country-code conjunct. + string country_code = 2; +} +// Fixture `PersonName` exercises `(require)`. +message PersonName { + option (require).fields = "given_name | honorific_prefix & family_name"; + + // Supplies the prefix operand in the secondary name group. + string honorific_prefix = 1; + // Supplies the standalone given-name alternative. + string given_name = 2; + // Supplies an unconstrained middle-name control. + string middle_name = 3; + // Supplies the family-name operand paired with the prefix. + string family_name = 4; + // Supplies an unconstrained honorific-suffix control. + string honorific_suffix = 5; +} +// Fixture `PaymentMethod` exercises `(require)`. +message PaymentMethod { + option (require).fields = "credit_card | bank_account | paypal_email"; + + // Supplies the credit-card alternative. + string credit_card = 1; + // Supplies the bank-account alternative. + string bank_account = 2; + // Supplies the PayPal-email alternative. + string paypal_email = 3; +} +// Fixture `ShippingAddress` exercises `(require)`. +message ShippingAddress { + option (require).fields = "street & city & postal_code & country"; + + // Supplies the required street operand. + string street = 1; + // Supplies the required city operand. + string city = 2; + // Supplies the required postal-code operand. + string postal_code = 3; + // Supplies the required country operand. + string country = 4; + // Supplies an optional state control outside the required group. + string state = 5; +} +// Fixture `AccountCreation` exercises `(require)`. +message AccountCreation { + option (require).fields = "username & password | oauth_token"; + + // Supplies the username conjunct in the credential alternative. + string username = 1; + // Supplies the password conjunct in the credential alternative. + string password = 2; + // Supplies the OAuth-token alternative. + string oauth_token = 3; +} +// Provides controls omitted from every `(require)` expression. +message OptionalData { + // Supplies the first unconstrained optional control. + string field1 = 1; + // Supplies the second unconstrained optional control. + string field2 = 2; + // Supplies the numeric unconstrained optional control. + int32 field3 = 3; +} + +// Deliberately tests invalid configuration `(require).fields = "number"`. +message InvalidRequireDirectNumeric { + option (require).fields = "number"; + // Is the unsupported numeric operand named by `(require).fields`. + int32 number = 1; +} + +// Deliberately tests invalid configuration `(require).fields = "(name)"`. +message InvalidRequireParentheses { + option (require).fields = "(name)"; + // Is the operand wrapped by the unsupported parenthesized expression. + string name = 1; +} + +// Deliberately tests invalid configuration `(require).fields = "missing"`. +message InvalidRequireUnknown { + option (require).fields = "missing"; + // Provides the available field while `missing` remains unresolved. + string name = 1; +} + +// Deliberately tests invalid configuration `(require).fields = "name && other"`. +message InvalidRequireGrammar { + option (require).fields = "name && other"; + // Supplies the left operand around the invalid doubled ampersand. + string name = 1; + // Supplies the right operand around the invalid doubled ampersand. + string other = 2; +} + +// Deliberately tests invalid configuration `(require).fields = "enabled"`. +message InvalidRequireBoolean { + option (require).fields = "enabled"; + // Is the unsupported boolean operand named by `(require).fields`. + bool enabled = 1; +} + +// Deliberately tests invalid configuration `(require).fields = ""`. +message InvalidRequireEmpty { + option (require).fields = ""; + // Names the field omitted by the empty `(require).fields` expression. + string name = 1; +} +// Deliberately tests invalid configuration `(require).fields = "|name"`. +message InvalidRequireLeadingPipe { + option (require).fields = "|name"; + // Names the operand after the leading pipe in `(require).fields`. + string name = 1; +} +// Deliberately tests invalid configuration `(require).fields = "&name"`. +message InvalidRequireLeadingAnd { + option (require).fields = "&name"; + // Names the operand after the leading ampersand in `(require).fields`. + string name = 1; +} +// Deliberately tests invalid configuration `(require).fields = "name|"`. +message InvalidRequireTrailingPipe { + option (require).fields = "name|"; + // Names the operand before the trailing pipe in `(require).fields`. + string name = 1; +} +// Deliberately tests invalid configuration `(require).fields = "name&"`. +message InvalidRequireTrailingAnd { + option (require).fields = "name&"; + // Names the operand before the trailing ampersand in `(require).fields`. + string name = 1; +} +// Deliberately tests invalid configuration `(require).fields = "name||other"`. +message InvalidRequireEmptyGroup { + option (require).fields = "name||other"; + // Names the operand before the empty disjunction group. + string name = 1; + // Names the operand after the empty disjunction group. + string other = 2; +} + +// Fixture `RequireOneof` exercises `(require)`. +message RequireOneof { + option (require).fields = "selection"; + + // Selects the `selection` alternative for this fixture. + oneof selection { + // Supplies the numeric member of the required oneof selection. + int32 numeric_value = 1; + // Supplies the boolean member of the required oneof selection. + bool boolean_value = 2; + } +} diff --git a/packages/spine-validation-ts/tests/proto/test-required.proto b/packages/validation/tests/proto/test-required.proto similarity index 54% rename from packages/spine-validation-ts/tests/proto/test-required.proto rename to packages/validation/tests/proto/test-required.proto index a577bba..8f2fa41 100644 --- a/packages/spine-validation-ts/tests/proto/test-required.proto +++ b/packages/validation/tests/proto/test-required.proto @@ -27,51 +27,70 @@ syntax = "proto3"; package spine.validation.testing.required_suite; -// Test messages for the `(required)` and `(if_missing)` validation options. -// -// This file contains test cases for required field validation across different -// field types (strings, numbers, messages, enums, repeated fields) and custom -// error messages via the `(if_missing)` option. - import "spine/options.proto"; - -// Tests required field validation on various field types. +// Fixture `RequiredFields` exercises `(required)`. message RequiredFields { + // Validates `name` with `(required) = true`. string name = 1 [(required) = true]; - int32 age = 2 [(required) = true]; + // Provides an optional scalar control beside required fields. + int32 age = 2; + // Validates `address` with `(required) = true`. Address address = 3 [(required) = true]; + // Validates `status` with `(required) = true`. Status status = 4 [(required) = true]; + // Validates `tags` with `(required) = true`. repeated string tags = 5 [(required) = true]; + // Validates `payload` with `(required) = true`. + bytes payload = 6 [(required) = true]; + // Validates `scores` with `(required) = true`. + map<string, int32> scores = 7 [(required) = true]; +} + +// Deliberately applies `(required)` to an unsupported int32 target. +message InvalidRequiredNumeric { + // Is the unsupported int32 target for `(required)`. + int32 age = 1 [(required) = true]; } -// Nested message for testing required message fields. +// Deliberately applies `(required)` to an unsupported bool target. +message InvalidRequiredBoolean { + // Is the unsupported bool target for `(required)`. + bool enabled = 1 [(required) = true]; +} +// Provides nested address input for the required-message case. message Address { + // Supplies the unconstrained street component of the address. string street = 1; + // Supplies the unconstrained city component of the address. string city = 2; } - -// Enum for testing required enum fields. +// Identifies the required account status in `RequiredFields`. enum Status { + // Is the default unset status rejected by `(required)`. STATUS_UNSPECIFIED = 0; + // Represents an active status that satisfies `(required)`. STATUS_ACTIVE = 1; + // Represents an inactive status that still satisfies `(required)`. STATUS_INACTIVE = 2; } - -// Tests custom error messages via the `(if_missing)` option. +// Fixture `CustomErrorMessages` exercises `(required)`. message CustomErrorMessages { + // Validates `username` with `(required) = true`. string username = 1 [ (required) = true, (if_missing).error_msg = "Username is mandatory for account creation." ]; + // Validates `email` with `(required) = true`. string email = 2 [ (required) = true, (if_missing).error_msg = "Email address must be provided." ]; } - -// Tests optional fields without required constraints. +// Provides fields that remain optional without `(required)`. message OptionalFields { + // Supplies an optional text control. string nickname = 1; + // Supplies an optional numeric control. int32 score = 2; } diff --git a/packages/validation/tests/proto/test-validate.proto b/packages/validation/tests/proto/test-validate.proto new file mode 100644 index 0000000..5f9ae41 --- /dev/null +++ b/packages/validation/tests/proto/test-validate.proto @@ -0,0 +1,277 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +syntax = "proto3"; + +package spine.validation.testing.validate_suite; + +import "spine/options.proto"; +import "google/protobuf/any.proto"; +// Fixture `PersonWithAddress` exercises `(required)`, `(validate)`. +message PersonWithAddress { + // Validates `name` with `(required) = true`. + string name = 1 [(required) = true]; + // Validates `address` with `(validate) = true`. + Address address = 2 [(validate) = true]; +} + +// Fixture `Address` exercises `(required)`, `(pattern)`. +message Address { + // Validates `street` with `(required) = true`. + string street = 1 [(required) = true]; + // Validates `city` with `(required) = true`. + string city = 2 [(required) = true]; + // Validates `zip_code` with `(required) = true`, `(pattern).regex = "^[0-9]{5}$"`. + string zip_code = 3 [ + (required) = true, + (pattern).regex = "^[0-9]{5}$" + ]; +} +// Fixture `OrderWithCustomError` exercises `(validate)`. +message OrderWithCustomError { + // Supplies the order identifier outside nested validation. + int32 order_id = 1; + // Validates `customer` with `(validate) = true`. + Customer customer = 2 [ + (validate) = true, + (if_invalid).error_msg = "Customer information is invalid: {value}." + ]; +} + +// Fixture `Customer` exercises `(required)`, `(pattern)`, `(range)`. +message Customer { + // Validates `email` with `(required) = true`, `(pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"`. + string email = 1 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ]; + // Validates `age` with `(range).value = "[18..120]"`. + int32 age = 2 [(range).value = "[18..120]"]; +} +// Fixture `TeamWithMembers` exercises `(required)`, `(validate)`. +message TeamWithMembers { + // Validates `team_name` with `(required) = true`. + string team_name = 1 [(required) = true]; + // Validates `members` with `(validate) = true`. + repeated Member members = 2 [(validate) = true]; +} + +// Fixture `Member` exercises `(required)`, `(pattern)`. +message Member { + // Validates `name` with `(required) = true`. + string name = 1 [(required) = true]; + // Validates `email` with `(required) = true`, `(pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"`. + string email = 2 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ]; +} +// Fixture `CompanyStructure` exercises `(required)`, `(validate)`. +message CompanyStructure { + // Validates `company_name` with `(required) = true`. + string company_name = 1 [(required) = true]; + // Validates `department` with `(validate) = true`. + Department department = 2 [(validate) = true]; +} + +// Fixture `Department` exercises `(required)`, `(validate)`. +message Department { + // Validates `dept_name` with `(required) = true`. + string dept_name = 1 [(required) = true]; + // Validates `manager` with `(validate) = true`. + Manager manager = 2 [(validate) = true]; +} + +// Fixture `Manager` exercises `(required)`, `(pattern)`. +message Manager { + // Validates `name` with `(required) = true`. + string name = 1 [(required) = true]; + // Validates `email` with `(required) = true`, `(pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"`. + string email = 2 [ + (required) = true, + (pattern).regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ]; +} +// Fixture `ProfileWithOptionalData` exercises `(required)`, `(validate)`. +message ProfileWithOptionalData { + // Validates `username` with `(required) = true`. + string username = 1 [(required) = true]; + // Validates `optional_data` with `(validate) = true`. + OptionalData optional_data = 2 [(validate) = true]; +} + +// Fixture `OptionalData` exercises `(min)`. +message OptionalData { + // Supplies optional biography text beside the bounded follower count. + string bio = 1; + // Validates `followers` with `(min).value = "0"`. + int32 followers = 2 [(min).value = "0"]; +} +// Fixture `PersonWithoutValidation` exercises `(required)`. +message PersonWithoutValidation { + // Validates `name` with `(required) = true`. + string name = 1 [(required) = true]; + // Supplies an address control that intentionally skips nested validation. + Address address = 2; +} +// Fixture `ProductOrder` exercises `(min)`, `(validate)`, `(required)`. +message ProductOrder { + // Validates `product_id` with `(min).value = "1"`. + int32 product_id = 1 [(min).value = "1"]; + // Validates `product` with `(validate) = true`. + ProductDetails product = 2 [ + (validate) = true, + (if_invalid).error_msg = "Product details are invalid." + ]; + // Validates `reviews` with `(validate) = true`. + repeated Review reviews = 3 [(validate) = true]; + // Validates `shipping` with `(required) = true`, `(validate) = true`. + ShippingInfo shipping = 4 [ + (required) = true, + (validate) = true + ]; +} + +// Fixture `ProductDetails` exercises `(required)`, `(min)`, `(distinct)`. +message ProductDetails { + // Validates `name` with `(required) = true`. + string name = 1 [(required) = true]; + // Validates `price` with `(min).value = "0.01"`. + double price = 2 [(min).value = "0.01"]; + // Validates `tags` with `(distinct) = true`. + repeated string tags = 3 [(distinct) = true]; +} + +// Fixture `Review` exercises `(range)`. +message Review { + // Validates `rating` with `(range).value = "[1..5]"`. + int32 rating = 1 [(range).value = "[1..5]"]; + // Supplies free-form review text outside the rating constraint. + string comment = 2; +} + +// Fixture `ShippingInfo` exercises `(required)`, `(validate)`. +message ShippingInfo { + // Validates `address` with `(required) = true`, `(validate) = true`. + Address address = 1 [(required) = true, (validate) = true]; + // Validates `method` with `(required) = true`. + string method = 2 [(required) = true]; +} +// Fixture `ContainerWithEmptyMessage` exercises `(required)`, `(validate)`. +message ContainerWithEmptyMessage { + // Validates `id` with `(required) = true`. + string id = 1 [(required) = true]; + // Validates `empty` with `(validate) = true`. + EmptyValidated empty = 2 [(validate) = true]; +} + +// Provides a recursively validated child with no validation options. +message EmptyValidated { + // Supplies unconstrained text in the empty validated child. + string note = 1; +} +// Fixture `ProjectWithTasks` exercises `(required)`, `(validate)`, `(distinct)`. +message ProjectWithTasks { + // Validates `project_name` with `(required) = true`. + string project_name = 1 [(required) = true]; + // Validates `tasks` with `(validate) = true`. + repeated Task tasks = 2 [(validate) = true]; + // Validates `tags` with `(distinct) = true`. + repeated string tags = 3 [(distinct) = true]; +} + +// Fixture `Task` exercises `(required)`, `(range)`, `(distinct)`. +message Task { + // Validates `title` with `(required) = true`. + string title = 1 [(required) = true]; + // Validates `priority` with `(range).value = "[1..5]"`. + int32 priority = 2 [(range).value = "[1..5]"]; + // Validates `assignees` with `(distinct) = true`. + repeated string assignees = 3 [(distinct) = true]; +} +// Fixture `Leaf` exercises `(required)`, `(min)`. +message Leaf { + // Validates `value` with `(required) = true`. + string value = 1 [(required) = true]; + // Validates `quantity` with `(min).value = "1"`. + int32 quantity = 2 [(min).value = "1"]; +} + +// Fixture `NestedValidationContainers` exercises `(validate)`. +message NestedValidationContainers { + // Validates `singular` with `(validate) = true`. + Leaf singular = 1 [(validate) = true]; + // Validates `repeated` with `(validate) = true`. + repeated Leaf repeated = 2 [(validate) = true]; + // Validates `mapped` with `(validate) = true`. + map<string, Leaf> mapped = 3 [(validate) = true]; + // Validates `packed` with `(validate) = true`. + google.protobuf.Any packed = 4 [(validate) = true]; + // Validates `packed_repeated` with `(validate) = true`. + repeated google.protobuf.Any packed_repeated = 5 [(validate) = true]; + // Validates `packed_mapped` with `(validate) = true`. + map<string, google.protobuf.Any> packed_mapped = 6 [(validate) = true]; +} + +// Fixture `ValidateDisabled` exercises `(validate)`. +message ValidateDisabled { + // Validates `leaf` with `(validate) = false`. + Leaf leaf = 1 [(validate) = false]; +} + +// Deliberately applies `(validate)` to an unsupported string target. +message ValidateUnsupportedTarget { + // Is the unsupported string target for `(validate)`. + string value = 1 [(validate) = true]; +} + +// Fixture `RequireLeaf` exercises `(require)`. +message RequireLeaf { + option (require).fields = "value"; + // Supplies the `(require).fields` operand named `value`. + string value = 1; + // Supplies a non-required marker control. + string marker = 2; +} + +// Fixture `ChoiceLeaf` exercises `(choice)`. +message ChoiceLeaf { + // Selects the `selection` alternative for this fixture. + oneof selection { + option (choice).required = true; + // Supplies the selected oneof value required by `(choice)`. + string value = 1; + } + // Supplies a marker outside the required oneof choice. + string marker = 2; +} + +// Fixture `NestedMessageOptionContainers` exercises `(validate)`. +message NestedMessageOptionContainers { + // Validates `require_child` with `(validate) = true`. + RequireLeaf require_child = 1 [(validate) = true]; + // Validates `choice_child` with `(validate) = true`. + ChoiceLeaf choice_child = 2 [(validate) = true]; +} diff --git a/packages/validation/tests/proto/test-when.proto b/packages/validation/tests/proto/test-when.proto new file mode 100644 index 0000000..38cdc15 --- /dev/null +++ b/packages/validation/tests/proto/test-when.proto @@ -0,0 +1,70 @@ + +syntax = "proto3"; + +package tests; + +import "google/protobuf/timestamp.proto"; +import "spine/options.proto"; +import "spine/time/time.proto"; +import "spine/time_options.proto"; + +// Fixture `TimeValidation` exercises `(when)`. +message TimeValidation { + // Validates `past_timestamp` with `(when).in = PAST`. + google.protobuf.Timestamp past_timestamp = 1 [(when).in = PAST]; + // Validates `future_timestamp` with `(when).in = FUTURE`. + google.protobuf.Timestamp future_timestamp = 2 [(when).in = FUTURE]; + // Validates `past_year_month` with `(when).in = PAST`. + spine.time.YearMonth past_year_month = 3 [(when).in = PAST]; + // Validates `future_date` with `(when).in = FUTURE`. + spine.time.LocalDate future_date = 4 [(when).in = FUTURE]; + // Validates `past_date_time` with `(when).in = PAST`. + spine.time.LocalDateTime past_date_time = 5 [(when).in = PAST]; + // Validates `future_offset_date_time` with `(when).in = FUTURE`. + spine.time.OffsetDateTime future_offset_date_time = 6 [(when).in = FUTURE]; + // Validates `past_zoned_date_time` with `(when).in = PAST`. + spine.time.ZonedDateTime past_zoned_date_time = 7 [(when).in = PAST]; + // Validates `future_timestamps` with `(when).in = FUTURE`. + repeated google.protobuf.Timestamp future_timestamps = 8 [(when).in = FUTURE]; + // Validates `past_timestamp_by_name` with `(when).in = PAST`. + map<string, google.protobuf.Timestamp> past_timestamp_by_name = 9 [(when).in = PAST]; + // Validates `disabled` with `(when).in = TIME_UNDEFINED`. + google.protobuf.Timestamp disabled = 10 [(when).in = TIME_UNDEFINED]; + // Validates `custom_message` with `(when).in = PAST`, `(when).error_msg = "custom ${when.in} ${field.path}"`. + google.protobuf.Timestamp custom_message = 11 [(when).in = PAST, (when).error_msg = "custom ${when.in} ${field.path}"]; + // Validates `legacy_message` with `(when).in = PAST`, `(when).msg_format = "ignored"`. + google.protobuf.Timestamp legacy_message = 12 [(when).in = PAST, (when).msg_format = "ignored"]; + // Validates `past_offset_date_time` with `(when).in = PAST`. + spine.time.OffsetDateTime past_offset_date_time = 13 [(when).in = PAST]; +} + +// Deliberately tests invalid configuration `(when).in = PAST`. +message UnsupportedWhenTarget { + // Validates `unsupported` with `(when).in = PAST`. + string unsupported = 1 [(when).in = PAST]; +} +// Deliberately tests invalid configuration `(when).in = PAST`, `(when).error_msg = "${bad}"`. +message InvalidWhenPlaceholder { + // Validates `value` with `(when).in = PAST`, `(when).error_msg = "${bad}"`. + google.protobuf.Timestamp value = 1 [(when).in = PAST, (when).error_msg = "${bad}"]; +} +// Deliberately tests invalid configuration `(when).in = 99`. +message InvalidWhenValue { + // Validates `value` with `(when).in = 99`. + google.protobuf.Timestamp value = 1 [(when).in = 99]; +} + +// Fixture `NestedWhenValue` exercises `(when)`, `(required)`. +message NestedWhenValue { + // Validates `future` with `(when).in = FUTURE`. + google.protobuf.Timestamp future = 1 [(when).in = FUTURE]; + // Validates `label` with `(required) = true`. + string label = 2 [(required) = true]; +} +// Fixture `NestedWhenEnvelope` exercises `(when)`, `(validate)`. +message NestedWhenEnvelope { + // Validates `first_future` with `(when).in = FUTURE`. + google.protobuf.Timestamp first_future = 1 [(when).in = FUTURE]; + // Validates `nested` with `(validate) = true`. + NestedWhenValue nested = 2 [(validate) = true]; +} diff --git a/packages/validation/tests/range.test.ts b/packages/validation/tests/range.test.ts new file mode 100644 index 0000000..8d64b27 --- /dev/null +++ b/packages/validation/tests/range.test.ts @@ -0,0 +1,451 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `(range)` validation option. + * + * Tests numeric range validation using bracket notation. + */ + +import { create } from "@bufbuild/protobuf"; + +const atLeast = (value: number, minimum: number): void => + expect(value)["toBeGreaterThanOrEqual"](minimum); +import { validate } from "../src/index.js"; + +import { + ClosedRangeSchema, + OpenRangeSchema, + HalfOpenRangeSchema, + NumericTypeRangesSchema, + RepeatedRangeSchema, + CombinedConstraintsSchema as RangeCombinedConstraintsSchema, + PaymentCardSchema, + RGBColorSchema, + PaginationRequestSchema, + OptionalRangeSchema, + EdgeCaseRangesSchema, +} from "./generated/test-range_pb.js"; + +describe("Range Validation", () => { + describe("Closed (Inclusive) Ranges", () => { + it("should pass when value is within closed `range`", () => { + const valid = create(ClosedRangeSchema, { + percentage: 50, + rgbValue: 128, + temperatureC: 25.0, + }); + + const violations = validate(ClosedRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass at boundary values `(inclusive)`", () => { + const valid = create(ClosedRangeSchema, { + percentage: 0, // Min boundary. + rgbValue: 255, // Max boundary. + temperatureC: -273.15, // Min boundary. + }); + + const violations = validate(ClosedRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when value is below minimum", () => { + const invalid = create(ClosedRangeSchema, { + percentage: -1, // Violates [0..100]. + rgbValue: 128, + temperatureC: 25.0, + }); + + const violations = validate(ClosedRangeSchema, invalid); + const percentageViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "percentage", + ); + expect(percentageViolation).toBeDefined(); + expect(percentageViolation?.message?.placeholderValue?.["range.value"]).toBe("[0..100]"); + }); + + it("should fail when value exceeds maximum", () => { + const invalid = create(ClosedRangeSchema, { + percentage: 50, + rgbValue: 256, // Violates [0..255]. + temperatureC: 25.0, + }); + + const violations = validate(ClosedRangeSchema, invalid); + const rgbViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "rgb_value"); + expect(rgbViolation).toBeDefined(); + expect(rgbViolation?.message?.placeholderValue?.["range.value"]).toBe("[0..255]"); + }); + }); + + describe("Open (Exclusive) Ranges", () => { + it("should pass when value is within exclusive `range`", () => { + const valid = create(OpenRangeSchema, { + positiveValue: 50.0, + exclusiveCount: 5, + }); + + const violations = validate(OpenRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail at boundary values `(exclusive)`", () => { + const invalidMin = create(OpenRangeSchema, { + positiveValue: 0.0, // Violates (0.0..100.0) - must be > 0. + exclusiveCount: 5, + }); + + const violationsMin = validate(OpenRangeSchema, invalidMin); + const minViolation = violationsMin.find( + (v) => v.fieldPath?.fieldName[0] === "positive_value", + ); + expect(minViolation).toBeDefined(); + + const invalidMax = create(OpenRangeSchema, { + positiveValue: 50.0, + exclusiveCount: 10, // Violates (0..10) - must be < 10. + }); + + const violationsMax = validate(OpenRangeSchema, invalidMax); + const maxViolation = violationsMax.find( + (v) => v.fieldPath?.fieldName[0] === "exclusive_count", + ); + expect(maxViolation).toBeDefined(); + }); + }); + + describe("Half-Open Ranges", () => { + it("should pass when value is within half-open `range`", () => { + const valid = create(HalfOpenRangeSchema, { + hour: 12, // [0..24). + minute: 30, // [0..60). + degree: 180.0, // [0.0..360.0). + angle: 90.0, // (0.0..180.0]. + }); + + const violations = validate(HalfOpenRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass at inclusive boundary and fail at exclusive boundary", () => { + // Test [0..24) - 0 is valid, 24 is not. + const validHour = create(HalfOpenRangeSchema, { + hour: 0, // Min is inclusive. + minute: 0, + degree: 0.0, + angle: 90.0, + }); + + const violations1 = validate(HalfOpenRangeSchema, validHour); + expect(violations1).toHaveLength(0); + + const invalidHour = create(HalfOpenRangeSchema, { + hour: 24, // Violates [0..24) - max is exclusive. + minute: 0, + degree: 0.0, + angle: 90.0, + }); + + const violations2 = validate(HalfOpenRangeSchema, invalidHour); + const hourViolation = violations2.find((v) => v.fieldPath?.fieldName[0] === "hour"); + expect(hourViolation).toBeDefined(); + }); + + it("should handle (`min`..`max`] correctly", () => { + // Test (0.0..180.0] - 0 is not valid, 180 is valid. + const invalidAngle = create(HalfOpenRangeSchema, { + hour: 12, + minute: 30, + degree: 180.0, + angle: 0.0, // Violates (0.0..180.0] - min is exclusive. + }); + + const violations1 = validate(HalfOpenRangeSchema, invalidAngle); + const angleViolation = violations1.find((v) => v.fieldPath?.fieldName[0] === "angle"); + expect(angleViolation).toBeDefined(); + + const validAngle = create(HalfOpenRangeSchema, { + hour: 12, + minute: 30, + degree: 180.0, + angle: 180.0, // Max is inclusive. + }); + + const violations2 = validate(HalfOpenRangeSchema, validAngle); + expect(violations2).toHaveLength(0); + }); + }); + + describe("Different Numeric Types", () => { + it("should `validate` ranges for all numeric types", () => { + const valid = create(NumericTypeRangesSchema, { + int32Field: 50, + int64Field: 500000n, + uint32Field: 30000, + uint64Field: 1000000n, + floatField: 0.5, + doubleField: 250.0, + }); + + const violations = validate(NumericTypeRangesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when any numeric type violates its `range`", () => { + const invalid = create(NumericTypeRangesSchema, { + int32Field: 101, // Violates [1..100]. + int64Field: 500000n, + uint32Field: 30000, + uint64Field: 1000000n, + floatField: 1.5, // Violates [0.0..1.0]. + doubleField: 250.0, + }); + + const violations = validate(NumericTypeRangesSchema, invalid); + atLeast(violations.length, 2); + + const int32Violation = violations.find((v) => v.fieldPath?.fieldName[0] === "int32_field"); + expect(int32Violation).toBeDefined(); + + const floatViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "float_field"); + expect(floatViolation).toBeDefined(); + }); + }); + + describe("Repeated Fields with Range", () => { + it("should pass when all repeated elements are within `range`", () => { + const valid = create(RepeatedRangeSchema, { + scores: [85, 92, 78, 100, 0], + percentages: [25.5, 50.0, 75.3, 100.0], + }); + + const violations = validate(RepeatedRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when any repeated element violates `range`", () => { + const invalid = create(RepeatedRangeSchema, { + scores: [85, 92, 105, 78], // 105 violates [0..100]. + percentages: [25.5, 50.0], + }); + + const violations = validate(RepeatedRangeSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const scoreViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "scores"); + expect(scoreViolation).toBeDefined(); + expect(scoreViolation?.message?.placeholderValue?.["value"]).toBe("105"); + }); + + it("should report violations for multiple invalid elements", () => { + const invalid = create(RepeatedRangeSchema, { + scores: [85, 101, 92, 102], // 101 and 102 both violate [0..100]. + percentages: [25.5, 50.0], + }); + + const violations = validate(RepeatedRangeSchema, invalid); + const scoreViolations = violations.filter((v) => v.fieldPath?.fieldName[0] === "scores"); + expect(scoreViolations.length).toBe(2); + }); + }); + + describe("Combined Constraints (Required + Range)", () => { + it("should pass when all constraints are satisfied", () => { + const valid = create(RangeCombinedConstraintsSchema, { + productId: 12345, + quantity: 50, + discount: 0.15, + }); + + const violations = validate(RangeCombinedConstraintsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail `range` validation even when `required` is satisfied", () => { + const invalid = create(RangeCombinedConstraintsSchema, { + productId: 12345, + quantity: 1001, // Violates [1..1000]. + discount: 0.15, + }); + + const violations = validate(RangeCombinedConstraintsSchema, invalid); + const quantityViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "quantity"); + expect(quantityViolation).toBeDefined(); + expect(quantityViolation?.message?.placeholderValue?.["range.value"]).toBe("[1..1000]"); + }); + + it("should detect both `required` and `range` violations", () => { + const invalid = create(RangeCombinedConstraintsSchema, { + productId: 0, // Violates both (required) and range [1..999999]. + quantity: 1001, // Violates range [1..1000]. + discount: 0.15, + }); + + const violations = validate(RangeCombinedConstraintsSchema, invalid); + atLeast(violations.length, 2); + }); + }); + + describe("Real-World Scenarios", () => { + it("should `validate` payment card expiry dates", () => { + const valid = create(PaymentCardSchema, { + expiryMonth: 12, + expiryYear: 2026, + cvv: 123, + }); + + const violations = validate(PaymentCardSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should reject invalid expiry month", () => { + const invalid = create(PaymentCardSchema, { + expiryMonth: 13, // Violates [1..12]. + expiryYear: 2026, + cvv: 123, + }); + + const violations = validate(PaymentCardSchema, invalid); + const monthViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "expiry_month"); + expect(monthViolation).toBeDefined(); + }); + + it("should `validate` RGB color values", () => { + const valid = create(RGBColorSchema, { + red: 255, + green: 128, + blue: 0, + alpha: 0.8, + }); + + const violations = validate(RGBColorSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should reject invalid RGB values", () => { + const invalid = create(RGBColorSchema, { + red: 256, // Violates [0..255]. + green: 128, + blue: 0, + alpha: 1.5, // Violates [0.0..1.0]. + }); + + const violations = validate(RGBColorSchema, invalid); + expect(violations.length).toBe(2); + + const redViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "red"); + expect(redViolation).toBeDefined(); + + const alphaViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "alpha"); + expect(alphaViolation).toBeDefined(); + }); + + it("should `validate` pagination parameters", () => { + const valid = create(PaginationRequestSchema, { + page: 5, + pageSize: 25, + }); + + const violations = validate(PaginationRequestSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should reject invalid pagination", () => { + const invalid = create(PaginationRequestSchema, { + page: 0, // Violates [1..10000]. + pageSize: 150, // Violates [1..100]. + }); + + const violations = validate(PaginationRequestSchema, invalid); + atLeast(violations.length, 2); + }); + }); + + describe("Optional Fields with Range", () => { + it("should `validate` zero values in `proto3`", () => { + const withDefaults = create(OptionalRangeSchema, { + optionalScore: 0, // Violates [1..100] (proto3 treats 0 as set). + optionalRating: 0, // Violates [1.0..5.0]. + }); + + const violations = validate(OptionalRangeSchema, withDefaults); + atLeast(violations.length, 2); + + const scoreViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "optional_score"); + expect(scoreViolation).toBeDefined(); + + const ratingViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "optional_rating", + ); + expect(ratingViolation).toBeDefined(); + }); + + it("should `validate` when optional fields have non-default values", () => { + const valid = create(OptionalRangeSchema, { + optionalScore: 75, + optionalRating: 4.5, + }); + + const violations = validate(OptionalRangeSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe("Edge Cases", () => { + it("should handle single-value ranges (exact value)", () => { + const valid = create(EdgeCaseRangesSchema, { + exactValue: 42, // Must be exactly 42. + piApprox: 3.14, + }); + + const violations = validate(EdgeCaseRangesSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should reject values outside single-value `range`", () => { + const invalid = create(EdgeCaseRangesSchema, { + exactValue: 43, // Violates [42..42]. + piApprox: 3.14, + }); + + const violations = validate(EdgeCaseRangesSchema, invalid); + const exactViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "exact_value"); + expect(exactViolation).toBeDefined(); + }); + + it("should handle narrow ranges for doubles", () => { + const valid = create(EdgeCaseRangesSchema, { + exactValue: 42, + piApprox: 3.1415, // Within [3.14..3.15]. + }); + + const violations = validate(EdgeCaseRangesSchema, valid); + expect(violations).toHaveLength(0); + }); + }); +}); diff --git a/packages/validation/tests/required-field.test.ts b/packages/validation/tests/required-field.test.ts new file mode 100644 index 0000000..5497848 --- /dev/null +++ b/packages/validation/tests/required-field.test.ts @@ -0,0 +1,501 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `(required_field)` message-level validation option. + * + * Tests boolean logic for required field combinations. + */ + +import { create } from "@bufbuild/protobuf"; +import { ValidationConfigurationError, validate } from "../src/index.js"; + +import { + UserIdentifierSchema, + ContactInfoSchema, + PersonNameSchema, + PaymentMethodSchema, + ShippingAddressSchema, + AccountCreationSchema, + OptionalDataSchema, + InvalidRequireDirectNumericSchema as InvalidRequireNumericSchema, + InvalidRequireParenthesesSchema, + InvalidRequireUnknownSchema, + InvalidRequireGrammarSchema, + InvalidRequireBooleanSchema, + InvalidRequireEmptySchema, + InvalidRequireLeadingPipeSchema as InvalidRequirePipeSchema, + InvalidRequireLeadingAndSchema as InvalidRequireAndSchema, + InvalidRequireTrailingPipeSchema as InvalidRequirePipeEnd, + InvalidRequireTrailingAndSchema as InvalidRequireAndEnd, + InvalidRequireEmptyGroupSchema as InvalidRequireEmptyGroup, + RequireOneofSchema, +} from "./generated/test-required-field_pb.js"; + +describe("Required Field Option Validation", () => { + describe("Simple OR Logic", () => { + it("should pass when first `required` field is provided", () => { + const valid = create(UserIdentifierSchema, { + id: "id-123", + email: "", + }); + + const violations = validate(UserIdentifierSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when second `required` field is provided", () => { + const valid = create(UserIdentifierSchema, { + id: "", + email: "user@example.com", + }); + + const violations = validate(UserIdentifierSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when both `required` fields are provided", () => { + const valid = create(UserIdentifierSchema, { + id: "id-123", + email: "user@example.com", + }); + + const violations = validate(UserIdentifierSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when neither `required` field is provided", () => { + const invalid = create(UserIdentifierSchema, { + id: "", + email: "", + }); + + const violations = validate(UserIdentifierSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.placeholderValue["require.fields"]).toBe("id | email"); + }); + }); + + describe("Simple AND Logic", () => { + it("should pass when both required fields are provided", () => { + const valid = create(ContactInfoSchema, { + phone: "555-1234", + countryCode: "+1", + }); + + const violations = validate(ContactInfoSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when only first field is provided", () => { + const invalid = create(ContactInfoSchema, { + phone: "555-1234", + countryCode: "", + }); + + const violations = validate(ContactInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.placeholderValue["require.fields"]).toBe( + "phone & country_code", + ); + }); + + it("should fail when only second field is provided", () => { + const invalid = create(ContactInfoSchema, { + phone: "", + countryCode: "+1", + }); + + const violations = validate(ContactInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + + it("should fail when neither field is provided", () => { + const invalid = create(ContactInfoSchema, { + phone: "", + countryCode: "", + }); + + const violations = validate(ContactInfoSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe("Complex OR with AND Groups", () => { + it("should pass when only given_name is provided", () => { + const valid = create(PersonNameSchema, { + givenName: "John", + honorificPrefix: "", + familyName: "", + middleName: "", + honorificSuffix: "", + }); + + const violations = validate(PersonNameSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when honorific_prefix and family_name are both provided", () => { + const valid = create(PersonNameSchema, { + givenName: "", + honorificPrefix: "Dr.", + familyName: "Smith", + middleName: "", + honorificSuffix: "", + }); + + const violations = validate(PersonNameSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when all fields are provided", () => { + const valid = create(PersonNameSchema, { + givenName: "John", + honorificPrefix: "Dr.", + familyName: "Smith", + middleName: "M.", + honorificSuffix: "Jr.", + }); + + const violations = validate(PersonNameSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when only honorific_prefix is provided (missing family_name)", () => { + const invalid = create(PersonNameSchema, { + givenName: "", + honorificPrefix: "Dr.", + familyName: "", + middleName: "", + honorificSuffix: "", + }); + + const violations = validate(PersonNameSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.placeholderValue["require.fields"]).toBe( + "given_name | honorific_prefix & family_name", + ); + }); + + it("should fail when only family_name is provided (missing honorific_prefix)", () => { + const invalid = create(PersonNameSchema, { + givenName: "", + honorificPrefix: "", + familyName: "Smith", + middleName: "", + honorificSuffix: "", + }); + + const violations = validate(PersonNameSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + + it("should fail when no `required` fields are provided", () => { + const invalid = create(PersonNameSchema, { + givenName: "", + honorificPrefix: "", + familyName: "", + middleName: "", + honorificSuffix: "", + }); + + const violations = validate(PersonNameSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe("Multiple OR Alternatives", () => { + it("should pass when credit_card is provided", () => { + const valid = create(PaymentMethodSchema, { + creditCard: "4111-1111-1111-1111", + bankAccount: "", + paypalEmail: "", + }); + + const violations = validate(PaymentMethodSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when bank_account is provided", () => { + const valid = create(PaymentMethodSchema, { + creditCard: "", + bankAccount: "ACC123456", + paypalEmail: "", + }); + + const violations = validate(PaymentMethodSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when paypal_email is provided", () => { + const valid = create(PaymentMethodSchema, { + creditCard: "", + bankAccount: "", + paypalEmail: "user@paypal.com", + }); + + const violations = validate(PaymentMethodSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when no payment method is provided", () => { + const invalid = create(PaymentMethodSchema, { + creditCard: "", + bankAccount: "", + paypalEmail: "", + }); + + const violations = validate(PaymentMethodSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.placeholderValue["require.fields"]).toBe( + "credit_card | bank_account | paypal_email", + ); + }); + }); + + describe("Multiple AND Requirements", () => { + it("should pass when all `required` fields are provided", () => { + const valid = create(ShippingAddressSchema, { + street: "123 Main St", + city: "Boston", + postalCode: "02101", + country: "USA", + state: "", + }); + + const violations = validate(ShippingAddressSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when street is missing", () => { + const invalid = create(ShippingAddressSchema, { + street: "", + city: "Boston", + postalCode: "02101", + country: "USA", + state: "", + }); + + const violations = validate(ShippingAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.placeholderValue["require.fields"]).toBe( + "street & city & postal_code & country", + ); + }); + + it("should fail when multiple fields are missing", () => { + const invalid = create(ShippingAddressSchema, { + street: "123 Main St", + city: "", + postalCode: "", + country: "USA", + state: "", + }); + + const violations = validate(ShippingAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe("Nested AND/OR Logic", () => { + it("should pass when username and password are both provided", () => { + const valid = create(AccountCreationSchema, { + username: "johndoe", + password: "secret123", + oauthToken: "", + }); + + const violations = validate(AccountCreationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when oauth_token is provided", () => { + const valid = create(AccountCreationSchema, { + username: "", + password: "", + oauthToken: "oauth_abc123", + }); + + const violations = validate(AccountCreationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when all fields are provided", () => { + const valid = create(AccountCreationSchema, { + username: "johndoe", + password: "secret123", + oauthToken: "oauth_abc123", + }); + + const violations = validate(AccountCreationSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when only username is provided (missing password)", () => { + const invalid = create(AccountCreationSchema, { + username: "johndoe", + password: "", + oauthToken: "", + }); + + const violations = validate(AccountCreationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message?.placeholderValue["require.fields"]).toBe( + "username & password | oauth_token", + ); + }); + + it("should fail when only password is provided (missing username)", () => { + const invalid = create(AccountCreationSchema, { + username: "", + password: "secret123", + oauthToken: "", + }); + + const violations = validate(AccountCreationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + + it("should fail when no fields are provided", () => { + const invalid = create(AccountCreationSchema, { + username: "", + password: "", + oauthToken: "", + }); + + const violations = validate(AccountCreationSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe("Optional Fields (No required_field option)", () => { + it("should pass when all fields are empty", () => { + const valid = create(OptionalDataSchema, { + field1: "", + field2: "", + field3: 0, + }); + + const violations = validate(OptionalDataSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should pass when some fields are set", () => { + const valid = create(OptionalDataSchema, { + field1: "test", + field2: "", + field3: 0, + }); + + const violations = validate(OptionalDataSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + describe("Configuration errors", () => { + it("rejects a direct numeric field reference", () => { + expect(() => + validate(InvalidRequireNumericSchema, create(InvalidRequireNumericSchema)), + ).toThrow( + expect.objectContaining({ + code: "INVALID_FIELD_REFERENCE", + option: "require", + typeName: InvalidRequireNumericSchema.typeName, + fieldPath: ["number"], + }), + ); + }); + + it("rejects unsupported parentheses in the documented grammar", () => { + expect(() => + validate(InvalidRequireParenthesesSchema, create(InvalidRequireParenthesesSchema)), + ).toThrow(expect.objectContaining({ code: "INVALID_OPTION_VALUE", option: "require" })); + }); + + it("rejects unknown field references", () => { + expect(() => + validate(InvalidRequireUnknownSchema, create(InvalidRequireUnknownSchema)), + ).toThrow(ValidationConfigurationError); + expect(() => + validate(InvalidRequireUnknownSchema, create(InvalidRequireUnknownSchema)), + ).toThrow( + expect.objectContaining({ + code: "UNKNOWN_FIELD_REFERENCE", + option: "require", + fieldPath: ["missing"], + }), + ); + }); + + it("rejects doubled operators", () => { + expect(() => + validate(InvalidRequireGrammarSchema, create(InvalidRequireGrammarSchema)), + ).toThrow(expect.objectContaining({ code: "INVALID_OPTION_VALUE", option: "require" })); + }); + it("rejects boolean references and grammar boundaries", () => { + expect(() => + validate(InvalidRequireBooleanSchema, create(InvalidRequireBooleanSchema)), + ).toThrow( + expect.objectContaining({ + code: "INVALID_FIELD_REFERENCE", + option: "require", + typeName: InvalidRequireBooleanSchema.typeName, + fieldPath: ["enabled"], + }), + ); + for (const schema of [ + InvalidRequireEmptyGroup, + InvalidRequirePipeSchema, + InvalidRequireAndSchema, + InvalidRequirePipeEnd, + InvalidRequireAndEnd, + InvalidRequireEmptySchema, + ]) { + expect(() => validate(schema as any, create(schema as any))).toThrow( + expect.objectContaining({ + code: "INVALID_OPTION_VALUE", + option: "require", + typeName: schema.typeName, + }), + ); + } + }); + }); + + describe("Oneof references", () => { + it("treats any selected oneof case as present, including numeric cases", () => { + expect( + validate( + RequireOneofSchema, + create(RequireOneofSchema, { selection: { case: "numericValue", value: 0 } }), + ), + ).toHaveLength(0); + const violations = validate(RequireOneofSchema, create(RequireOneofSchema)); + expect(violations).toHaveLength(1); + expect(violations[0].fieldPath?.fieldName).toEqual([]); + }); + }); +}); diff --git a/packages/validation/tests/required.test.ts b/packages/validation/tests/required.test.ts new file mode 100644 index 0000000..2779534 --- /dev/null +++ b/packages/validation/tests/required.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `(required)` and `(if_missing)` validation options. + * + * Tests the `(required)` option for ensuring fields have non-default values. + */ + +import { create } from "@bufbuild/protobuf"; + +const atLeast = (value: number, minimum: number): void => + expect(value)["toBeGreaterThanOrEqual"](minimum); +import { ValidationConfigurationError, validate } from "../src/index.js"; + +import { + RequiredFieldsSchema, + CustomErrorMessagesSchema as RequiredErrorsSchema, + OptionalFieldsSchema, + InvalidRequiredNumericSchema, + InvalidRequiredBooleanSchema, + Status, +} from "./generated/test-required_pb.js"; + +describe("Required Field Validation", () => { + describe("Basic Required Fields", () => { + it("should validate message with all `required` fields present", () => { + const valid = create(RequiredFieldsSchema, { + name: "John Doe", + age: 30, + address: { street: "123 Main St", city: "Boston" }, + status: Status.ACTIVE, + tags: ["tag1"], + payload: new Uint8Array([1]), + scores: { a: 1 }, + }); + + const violations = validate(RequiredFieldsSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect missing `required` string field", () => { + const invalid = create(RequiredFieldsSchema, { + name: "", // Required but empty. + age: 30, + address: { street: "123 Main St", city: "Boston" }, + status: Status.ACTIVE, + tags: ["tag1"], + }); + + const violations = validate(RequiredFieldsSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const nameViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "name"); + expect(nameViolation).toBeDefined(); + expect(nameViolation?.message?.withPlaceholders).toBe( + "The field `${parent.type}.${field.path}` of the type `${field.type}` must have a non-default value.", + ); + expect(nameViolation?.fieldValue).toBeUndefined(); + expect(nameViolation?.message?.placeholderValue).toMatchObject({ + "parent.type": RequiredFieldsSchema.typeName, + "field.path": "name", + "field.type": "string", + }); + }); + + it("should detect missing `required` message field", () => { + const invalid = create(RequiredFieldsSchema, { + name: "John Doe", + age: 30, + address: undefined, // Required but missing. + status: Status.ACTIVE, + tags: ["tag1"], + }); + + const violations = validate(RequiredFieldsSchema, invalid); + const addressViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "address"); + expect(addressViolation).toBeDefined(); + }); + + it("should detect empty `required` repeated field", () => { + const invalid = create(RequiredFieldsSchema, { + name: "John Doe", + age: 30, + address: { street: "123 Main St", city: "Boston" }, + status: Status.ACTIVE, + tags: [], // Required but empty. + }); + + const violations = validate(RequiredFieldsSchema, invalid); + const tagsViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "tags"); + expect(tagsViolation).toBeDefined(); + }); + + it("should detect multiple missing `required` fields", () => { + const invalid = create(RequiredFieldsSchema, { + name: "", + age: 0, + address: undefined, + status: 0, + tags: [], + }); + + const violations = validate(RequiredFieldsSchema, invalid); + atLeast(violations.length, 3); + }); + }); + + describe("Custom Error Messages", () => { + it("should use custom error message from (`if_missing`) option", () => { + const invalid = create(RequiredErrorsSchema, { + username: "", // Required with custom message. + email: "valid@example.com", + }); + + const violations = validate(RequiredErrorsSchema, invalid); + const usernameViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "username"); + expect(usernameViolation).toBeDefined(); + expect(usernameViolation?.message?.withPlaceholders).toBe( + "Username is mandatory for account creation.", + ); + }); + + it("should use custom error message for field with custom error message", () => { + const invalid = create(RequiredErrorsSchema, { + username: "johndoe", + email: "", // Required with custom message. + }); + + const violations = validate(RequiredErrorsSchema, invalid); + const emailViolation = violations.find((v) => v.fieldPath?.fieldName[0] === "email"); + expect(emailViolation).toBeDefined(); + expect(emailViolation?.message?.withPlaceholders).toBe("Email address must be provided."); + }); + }); + + it("requires non-empty bytes and maps with field-only paths", () => { + const violations = validate( + RequiredFieldsSchema, + create(RequiredFieldsSchema, { + name: "name", + address: { street: "street" }, + status: Status.ACTIVE, + tags: ["tag"], + payload: new Uint8Array(), + scores: {}, + }), + ); + expect(violations.map((v) => v.fieldPath?.fieldName)).toEqual([["payload"], ["scores"]]); + for (const violation of violations) { + expect(violation.fieldValue).toBeUndefined(); + expect(violation.message?.placeholderValue).toMatchObject({ + "parent.type": RequiredFieldsSchema.typeName, + "field.path": violation.fieldPath?.fieldName[0], + }); + } + expect( + validate( + RequiredFieldsSchema, + create(RequiredFieldsSchema, { + name: "name", + address: { street: "street" }, + status: Status.ACTIVE, + tags: ["tag"], + payload: new Uint8Array([1]), + scores: { a: 1 }, + }), + ), + ).toHaveLength(0); + }); + + describe("Optional Fields", () => { + it("should not validate optional fields when empty", () => { + const valid = create(OptionalFieldsSchema, { + nickname: "", + score: 0, + }); + + const violations = validate(OptionalFieldsSchema, valid); + expect(violations).toHaveLength(0); + }); + }); + + it("rejects numeric `(required)` targets before validating values", () => { + expect(() => + validate(InvalidRequiredNumericSchema, create(InvalidRequiredNumericSchema)), + ).toThrow(ValidationConfigurationError); + expect(() => + validate(InvalidRequiredNumericSchema, create(InvalidRequiredNumericSchema)), + ).toThrow( + expect.objectContaining({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "required", + typeName: InvalidRequiredNumericSchema.typeName, + fieldPath: ["age"], + }), + ); + }); + + it("rejects boolean `(required)` targets", () => { + expect(() => + validate(InvalidRequiredBooleanSchema, create(InvalidRequiredBooleanSchema)), + ).toThrow( + expect.objectContaining({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "required", + typeName: InvalidRequiredBooleanSchema.typeName, + fieldPath: ["enabled"], + }), + ); + }); +}); diff --git a/packages/validation/tests/validate.test.ts b/packages/validation/tests/validate.test.ts new file mode 100644 index 0000000..5d7a22b --- /dev/null +++ b/packages/validation/tests/validate.test.ts @@ -0,0 +1,627 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * Unit tests for `(validate)` and `(if_invalid)` validation options. + * + * Tests recursive validation of nested message fields. + */ + +import { create } from "@bufbuild/protobuf"; +import { anyPack, AnySchema } from "@bufbuild/protobuf/wkt"; +import { ValidationConfigurationError, validate } from "../src/index.js"; + +import { + PersonWithAddressSchema, + AddressSchema, + OrderWithCustomErrorSchema as OrderCustomErrorSchema, + CustomerSchema, + TeamWithMembersSchema, + MemberSchema, + CompanyStructureSchema, + DepartmentSchema, + ManagerSchema, + ProfileWithOptionalDataSchema as ProfileOptionalDataSchema, + OptionalDataSchema as ValidateOptionalDataSchema, + PersonWithoutValidationSchema, + ProductOrderSchema, + ProductDetailsSchema, + ReviewSchema, + ShippingInfoSchema, + ContainerWithEmptyMessageSchema as ContainerEmptyMessageSchema, + EmptyValidatedSchema, + ProjectWithTasksSchema, + TaskSchema, + LeafSchema, + NestedValidationContainersSchema, + ValidateDisabledSchema, + ValidateUnsupportedTargetSchema, + NestedMessageOptionContainersSchema as NestedOptionContainersSchema, + RequireLeafSchema, + ChoiceLeafSchema, +} from "./generated/test-validate_pb.js"; +import { UserIdentifierSchema } from "./generated/test-required-field_pb.js"; + +describe("Nested Message Validation (validate)", () => { + describe("Basic Nested Validation", () => { + it("should pass when nested message is valid", () => { + const valid = create(PersonWithAddressSchema, { + name: "John Doe", + address: create(AddressSchema, { + street: "123 Main St", + city: "Boston", + zipCode: "02101", + }), + }); + + const violations = validate(PersonWithAddressSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should fail when nested message violates constraints", () => { + const invalid = create(PersonWithAddressSchema, { + name: "John Doe", + address: create(AddressSchema, { + street: "", // Required violation. + city: "Boston", + zipCode: "02101", + }), + }); + + const violations = validate(PersonWithAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Should have violation for nested field. + const nestedViolation = violations.find((v) => v.fieldPath?.fieldName.includes("address")); + expect(nestedViolation).toBeDefined(); + }); + + it("should report violations with correct nested field path", () => { + const invalid = create(PersonWithAddressSchema, { + name: "John Doe", + address: create(AddressSchema, { + street: "123 Main St", + city: "", // Required violation. + zipCode: "02101", + }), + }); + + const violations = validate(PersonWithAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Check for nested field path: `address.city`. + const cityViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "address" && v.fieldPath?.fieldName[1] === "city", + ); + expect(cityViolation).toBeDefined(); + }); + + it("should `validate` multiple constraints in nested message", () => { + const invalid = create(PersonWithAddressSchema, { + name: "John Doe", + address: create(AddressSchema, { + street: "123 Main St", + city: "Boston", + zipCode: "ABCDE", // Pattern violation (should be 5 digits). + }), + }); + + const violations = validate(PersonWithAddressSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const zipViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "address" && v.fieldPath?.fieldName[1] === "zip_code", + ); + expect(zipViolation).toBeDefined(); + }); + }); + + describe("Deprecated parent diagnostics", () => { + it("does not emit a deprecated parent summary when nested validation fails", () => { + const invalid = create(OrderCustomErrorSchema, { + orderId: 123, + customer: create(CustomerSchema, { + email: "invalid-email", // Pattern violation. + age: 25, + }), + }); + + const violations = validate(OrderCustomErrorSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + expect(violations).toHaveLength(1); + expect(violations[0].fieldPath?.fieldName).toEqual(["customer", "email"]); + }); + + it("propagates only leaves when multiple nested constraints fail", () => { + const invalid = create(OrderCustomErrorSchema, { + orderId: 123, + customer: create(CustomerSchema, { + email: "invalid-email", + age: 15, // Violates range [18..120]. + }), + }); + + const violations = validate(OrderCustomErrorSchema, invalid); + expect(violations).toHaveLength(2); + const emailViolation = violations.find((v) => v.fieldPath?.fieldName[1] === "email"); + expect(emailViolation).toBeDefined(); + + const ageViolation = violations.find((v) => v.fieldPath?.fieldName[1] === "age"); + expect(ageViolation).toBeDefined(); + }); + }); + + describe("Repeated Message Fields", () => { + it("should `validate` all elements in repeated message field", () => { + const valid = create(TeamWithMembersSchema, { + teamName: "Engineering", + members: [ + create(MemberSchema, { name: "Alice", email: "alice@example.com" }), + create(MemberSchema, { name: "Bob", email: "bob@example.com" }), + ], + }); + + const violations = validate(TeamWithMembersSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect violation in one member", () => { + const invalid = create(TeamWithMembersSchema, { + teamName: "Engineering", + members: [ + create(MemberSchema, { name: "Alice", email: "alice@example.com" }), + create(MemberSchema, { name: "", email: "bob@example.com" }), // Name required. + ], + }); + + const violations = validate(TeamWithMembersSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Check for violation at `members.name`; collection indexes are not field names. + const nameViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "members" && v.fieldPath?.fieldName[1] === "name", + ); + expect(nameViolation).toBeDefined(); + }); + + it("should detect violations in multiple members", () => { + const invalid = create(TeamWithMembersSchema, { + teamName: "Engineering", + members: [ + create(MemberSchema, { name: "", email: "alice@example.com" }), // Name violation. + create(MemberSchema, { name: "Bob", email: "invalid" }), // Email violation. + ], + }); + + const violations = validate(TeamWithMembersSchema, invalid); + expect(violations).toHaveLength(2); + }); + }); + + describe("Deeply Nested Validation", () => { + it("should `validate` multiple levels of nesting", () => { + const valid = create(CompanyStructureSchema, { + companyName: "Tech Corp", + department: create(DepartmentSchema, { + deptName: "Engineering", + manager: create(ManagerSchema, { + name: "Jane Smith", + email: "jane@techcorp.com", + }), + }), + }); + + const violations = validate(CompanyStructureSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect violations in deeply nested messages", () => { + const invalid = create(CompanyStructureSchema, { + companyName: "Tech Corp", + department: create(DepartmentSchema, { + deptName: "Engineering", + manager: create(ManagerSchema, { + name: "", // Required violation. + email: "jane@techcorp.com", + }), + }), + }); + + const violations = validate(CompanyStructureSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Check for nested path: `department.manager.name`. + const deepViolation = violations.find( + (v) => + v.fieldPath?.fieldName[0] === "department" && + v.fieldPath?.fieldName[1] === "manager" && + v.fieldPath?.fieldName[2] === "name", + ); + expect(deepViolation).toBeDefined(); + }); + }); + + describe("Optional Nested Fields", () => { + it("should pass when optional nested field is not set", () => { + const valid = create(ProfileOptionalDataSchema, { + username: "johndoe", + // `optional_data` not set. + }); + + const violations = validate(ProfileOptionalDataSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should `validate` when optional nested field is set", () => { + const valid = create(ProfileOptionalDataSchema, { + username: "johndoe", + optionalData: create(ValidateOptionalDataSchema, { + bio: "Software engineer", + followers: 100, + }), + }); + + const violations = validate(ProfileOptionalDataSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect violations in optional nested field when set", () => { + const invalid = create(ProfileOptionalDataSchema, { + username: "johndoe", + optionalData: create(ValidateOptionalDataSchema, { + bio: "Software engineer", + followers: -5, // Violates min = 0. + }), + }); + + const violations = validate(ProfileOptionalDataSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe("Without Validate Option (Control Group)", () => { + it("should not `validate` nested message without (`validate`) = true", () => { + const invalid = create(PersonWithoutValidationSchema, { + name: "John Doe", + address: create(AddressSchema, { + street: "", // Would violate required, but not validated. + city: "", // Would violate required, but not validated. + zipCode: "", // Would violate required, but not validated. + }), + }); + + const violations = validate(PersonWithoutValidationSchema, invalid); + expect(violations).toHaveLength(0); // No violations because validate is not enabled. + }); + }); + + describe("Complex Combined Validation", () => { + it("should `validate` complex message with multiple nested fields", () => { + const valid = create(ProductOrderSchema, { + productId: 123, + product: create(ProductDetailsSchema, { + name: "Widget", + price: 19.99, + tags: ["electronics", "gadget"], + }), + reviews: [ + create(ReviewSchema, { rating: 5, comment: "Great!" }), + create(ReviewSchema, { rating: 4, comment: "Good" }), + ], + shipping: create(ShippingInfoSchema, { + address: create(AddressSchema, { + street: "123 Main St", + city: "Boston", + zipCode: "02101", + }), + method: "Express", + }), + }); + + const violations = validate(ProductOrderSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect `distinct` violation in nested product", () => { + const invalid = create(ProductOrderSchema, { + productId: 123, + product: create(ProductDetailsSchema, { + name: "Widget", + price: 19.99, + tags: ["electronics", "gadget", "electronics"], // Duplicate tag. + }), + reviews: [], + shipping: create(ShippingInfoSchema, { + address: create(AddressSchema, { + street: "123 Main St", + city: "Boston", + zipCode: "02101", + }), + method: "Express", + }), + }); + + const violations = validate(ProductOrderSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const tagsViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "product" && v.fieldPath?.fieldName[1] === "tags", + ); + expect(tagsViolation).toBeDefined(); + }); + + it("should detect violations in repeated reviews", () => { + const invalid = create(ProductOrderSchema, { + productId: 123, + product: create(ProductDetailsSchema, { + name: "Widget", + price: 19.99, + tags: ["electronics"], + }), + reviews: [ + create(ReviewSchema, { rating: 5, comment: "Great!" }), + create(ReviewSchema, { rating: 6, comment: "Good" }), // Rating out of range. + ], + shipping: create(ShippingInfoSchema, { + address: create(AddressSchema, { + street: "123 Main St", + city: "Boston", + zipCode: "02101", + }), + method: "Express", + }), + }); + + const violations = validate(ProductOrderSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const ratingViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "reviews" && v.fieldPath?.fieldName[1] === "rating", + ); + expect(ratingViolation).toBeDefined(); + }); + + it("should detect violations in doubly-nested shipping address", () => { + const invalid = create(ProductOrderSchema, { + productId: 123, + product: create(ProductDetailsSchema, { + name: "Widget", + price: 19.99, + tags: ["electronics"], + }), + reviews: [], + shipping: create(ShippingInfoSchema, { + address: create(AddressSchema, { + street: "123 Main St", + city: "Boston", + zipCode: "INVALID", // Pattern violation. + }), + method: "Express", + }), + }); + + const violations = validate(ProductOrderSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + // Path: `shipping.address.zip_code`. + const zipViolation = violations.find( + (v) => + v.fieldPath?.fieldName[0] === "shipping" && + v.fieldPath?.fieldName[1] === "address" && + v.fieldPath?.fieldName[2] === "zip_code", + ); + expect(zipViolation).toBeDefined(); + }); + }); + + describe("Edge Cases", () => { + it("should pass when validating message with no constraints", () => { + const valid = create(ContainerEmptyMessageSchema, { + id: "test-123", + empty: create(EmptyValidatedSchema, { + note: "Some note", + }), + }); + + const violations = validate(ContainerEmptyMessageSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should `validate` nested message with its own nested validation", () => { + const valid = create(ProjectWithTasksSchema, { + projectName: "Project Alpha", + tasks: [ + create(TaskSchema, { + title: "Task 1", + priority: 3, + assignees: ["alice", "bob"], + }), + ], + tags: ["urgent", "backend"], + }); + + const violations = validate(ProjectWithTasksSchema, valid); + expect(violations).toHaveLength(0); + }); + + it("should detect `distinct` violation in nested task assignees", () => { + const invalid = create(ProjectWithTasksSchema, { + projectName: "Project Alpha", + tasks: [ + create(TaskSchema, { + title: "Task 1", + priority: 3, + assignees: ["alice", "bob", "alice"], // Duplicate assignee. + }), + ], + tags: ["urgent", "backend"], + }); + + const violations = validate(ProjectWithTasksSchema, invalid); + expect(violations.length).toBeGreaterThan(0); + + const assigneeViolation = violations.find( + (v) => v.fieldPath?.fieldName[0] === "tasks" && v.fieldPath?.fieldName[1] === "assignees", + ); + expect(assigneeViolation).toBeDefined(); + }); + }); + + describe("Task 6 nested validation contract", () => { + it("keeps the root type and leaf envelope through singular, repeated, and map values", () => { + const invalid = create(NestedValidationContainersSchema, { + singular: create(LeafSchema, { value: "set", quantity: 0 }), + repeated: [create(LeafSchema, { value: "" })], + mapped: { first: create(LeafSchema, { value: "" }) }, + }); + + const violations = validate(NestedValidationContainersSchema, invalid); + expect(violations).toHaveLength(5); + expect(violations.map((violation) => violation.typeName)).toEqual([ + NestedValidationContainersSchema.typeName, + NestedValidationContainersSchema.typeName, + NestedValidationContainersSchema.typeName, + NestedValidationContainersSchema.typeName, + NestedValidationContainersSchema.typeName, + ]); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["singular", "quantity"], + ["repeated", "value"], + ["repeated", "quantity"], + ["mapped", "value"], + ["mapped", "quantity"], + ]); + expect(violations[0].fieldValue?.typeUrl).toContain("google.protobuf.Int32Value"); + }); + + it("skips the singular default message but validates explicit default collection values", () => { + const valid = create(NestedValidationContainersSchema, { + singular: create(LeafSchema), + }); + expect(validate(NestedValidationContainersSchema, valid)).toHaveLength(0); + + const invalid = create(NestedValidationContainersSchema, { + repeated: [create(LeafSchema)], + mapped: { default: create(LeafSchema) }, + }); + expect(validate(NestedValidationContainersSchema, invalid)).toHaveLength(4); + }); + + it("unpacks known Any values and leaves empty or unknown Any values valid", () => { + const invalidLeaf = create(LeafSchema, { value: "" }); + const validLeaf = create(LeafSchema, { value: "set", quantity: 1 }); + const result = validate( + NestedValidationContainersSchema, + create(NestedValidationContainersSchema, { + packed: anyPack(LeafSchema, invalidLeaf), + packedRepeated: [anyPack(LeafSchema, validLeaf)], + packedMapped: { invalid: anyPack(LeafSchema, invalidLeaf) }, + }), + ); + expect(result.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["packed", "value"], + ["packed", "quantity"], + ["packed_mapped", "value"], + ["packed_mapped", "quantity"], + ]); + const packedQuantity = result.find( + (violation) => violation.fieldPath?.fieldName.join(".") === "packed.quantity", + ); + expect(packedQuantity?.fieldValue?.typeUrl).toContain("google.protobuf.Int32Value"); + expect(packedQuantity?.message?.withPlaceholders).toContain("${min.value}"); + + expect( + validate( + NestedValidationContainersSchema, + create(NestedValidationContainersSchema, { + packed: { typeUrl: "type.googleapis.com/example.Unknown", value: new Uint8Array([1]) }, + }), + ), + ).toHaveLength(0); + + expect( + validate( + NestedValidationContainersSchema, + create(NestedValidationContainersSchema, { + packed: create(AnySchema), + packedRepeated: [create(AnySchema)], + packedMapped: { empty: create(AnySchema) }, + }), + ), + ).toHaveLength(0); + + expect( + validate( + NestedValidationContainersSchema, + create(NestedValidationContainersSchema, { + packed: anyPack(UserIdentifierSchema, create(UserIdentifierSchema)), + }), + ), + ).toHaveLength(0); + }); + + it("prefixes nested message-level require and choice violations", () => { + const violations = validate( + NestedOptionContainersSchema, + create(NestedOptionContainersSchema, { + requireChild: create(RequireLeafSchema, { marker: "set" }), + choiceChild: create(ChoiceLeafSchema, { marker: "set" }), + }), + ); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["require_child"], + ["choice_child"], + ]); + expect(violations.map((violation) => violation.typeName)).toEqual([ + NestedOptionContainersSchema.typeName, + NestedOptionContainersSchema.typeName, + ]); + }); + + it("treats false as a no-op and rejects unsupported true targets", () => { + expect( + validate( + ValidateDisabledSchema, + create(ValidateDisabledSchema, { leaf: create(LeafSchema) }), + ), + ).toHaveLength(0); + expect(() => + validate(ValidateUnsupportedTargetSchema, create(ValidateUnsupportedTargetSchema)), + ).toThrow(ValidationConfigurationError); + try { + validate(ValidateUnsupportedTargetSchema, create(ValidateUnsupportedTargetSchema)); + } catch (error) { + expect(error).toMatchObject({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "validate", + typeName: ValidateUnsupportedTargetSchema.typeName, + fieldPath: ["value"], + }); + } + }); + }); +}); diff --git a/packages/validation/tests/validation-contract.test.ts b/packages/validation/tests/validation-contract.test.ts new file mode 100644 index 0000000..5d32fee --- /dev/null +++ b/packages/validation/tests/validation-contract.test.ts @@ -0,0 +1,277 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { create } from "@bufbuild/protobuf"; +import { + anyUnpack, + BytesValueSchema, + Int32ValueSchema, + StringValueSchema, +} from "@bufbuild/protobuf/wkt"; +import { validate, ValidationConfigurationError, Violations } from "../src/index.js"; +import { ValidationOrchestration } from "../src/orchestration.js"; +import { ValidationContext, ViolationFactory } from "../src/validation-contract.js"; +import { ConstraintViolationSchema } from "../src/generated/spine/validate/validation_error_pb.js"; +import { TemplateStringSchema } from "../src/generated/spine/validate/error_message_pb.js"; +import { AddressSchema, RequiredFieldsSchema, Status } from "./generated/test-required_pb.js"; +import { PaymentMethodSchema } from "./generated/test-choice_pb.js"; + +// `validate()` must keep a generated descriptor paired with only its own message shape. +function invalidSchemaMessagePair(): void { + const requiredFieldsMessage = create(RequiredFieldsSchema); + // @ts-expect-error A PaymentMethod descriptor cannot validate a RequiredFields message. + validate(PaymentMethodSchema, requiredFieldsMessage); +} +void invalidSchemaMessagePair; + +describe("ValidationConfigurationError", () => { + it("exposes stable public diagnostic properties", () => { + const cause = new Error("unknown field"); + const error = new ValidationConfigurationError({ + code: "UNKNOWN_FIELD_REFERENCE", + option: "min", + typeName: "example.Measurement", + fieldPath: ["reading", "minimum"], + cause, + }); + + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe("UNKNOWN_FIELD_REFERENCE"); + expect(error.option).toBe("min"); + expect(error.typeName).toBe("example.Measurement"); + expect(error.fieldPath).toEqual(["reading", "minimum"]); + expect(error.cause).toBe(cause); + }); +}); + +describe("validation contract kernel", () => { + it("formats only literal placeholder tokens and preserves dollar-valued replacements", () => { + expect( + Violations.formatMessage({ + message: create(TemplateStringSchema, { + withPlaceholders: "${field.value}; ${fieldXvalue}; ${field.value.extra}; ${other}", + placeholderValue: { + "field.value": "$& $1 $$", + other: "done", + }, + }), + } as never), + ).toBe("$& $1 $$; ${fieldXvalue}; ${field.value.extra}; done"); + }); + + it("normalizes legacy nested, repeated, and map diagnostics through the common envelope", () => { + const message = create(RequiredFieldsSchema, { + name: "Ada", + tags: ["duplicate"], + scores: { primary: 7 }, + }); + const violations = [] as ReturnType<typeof ViolationFactory.create>[]; + const context = ValidationContext.create(RequiredFieldsSchema); + const adapter = ValidationOrchestration.adaptAllFieldsValidator((_schema, _message, output) => { + output.push( + create(ConstraintViolationSchema, { + fieldPath: { fieldName: ["tags", "0", "nested"] }, + message: { withPlaceholders: "Legacy list diagnostic." }, + }), + ); + }); + + adapter.validate( + context, + RequiredFieldsSchema, + message, + RequiredFieldsSchema.field.tags, + violations, + undefined as never, + ); + expect(violations[0]).toEqual( + expect.objectContaining({ + typeName: RequiredFieldsSchema.typeName, + fieldPath: expect.objectContaining({ fieldName: ["tags", "nested"] }), + message: expect.objectContaining({ withPlaceholders: "Legacy list diagnostic." }), + }), + ); + expect(anyUnpack(violations[0].fieldValue!, StringValueSchema)?.value).toBe("duplicate"); + + const mapAdapter = ValidationOrchestration.adaptAllFieldsValidator( + (_schema, _message, output) => { + output.push( + create(ConstraintViolationSchema, { + fieldPath: { fieldName: ["scores", "primary", "nested"] }, + }), + ); + }, + ); + mapAdapter.validate( + context, + RequiredFieldsSchema, + message, + RequiredFieldsSchema.field.scores, + violations, + undefined as never, + ); + expect(violations[1].fieldPath?.fieldName).toEqual(["scores", "nested"]); + expect(anyUnpack(violations[1].fieldValue!, Int32ValueSchema)?.value).toBe(7); + }); + + it("normalizes legacy message-level diagnostics without inventing a field value", () => { + const violations = [] as ReturnType<typeof ViolationFactory.create>[]; + ValidationOrchestration.appendMessageViolation( + ValidationContext.create(RequiredFieldsSchema), + create(ConstraintViolationSchema, { + message: { withPlaceholders: "Legacy message diagnostic." }, + }), + violations, + ); + + expect(violations).toHaveLength(1); + expect(violations[0].typeName).toBe(RequiredFieldsSchema.typeName); + expect(violations[0].fieldPath?.fieldName).toEqual([]); + expect(violations[0].fieldValue).toBeUndefined(); + }); + + it("keeps the root type and Proto field path while packing a primitive value", () => { + const field = RequiredFieldsSchema.field.name; + const context = ValidationContext.create(RequiredFieldsSchema).atField(field); + const violation = ViolationFactory.create(context, field, "not-empty", { + defaultMessage: "Default `${parent.type}.${field.path}`: `${field.value}`.", + }); + + expect(context.rootTypeName).toBe(RequiredFieldsSchema.typeName); + expect(context.fieldPath).toEqual(["name"]); + expect(violation.typeName).toBe(RequiredFieldsSchema.typeName); + expect(violation.fieldPath?.fieldName).toEqual(["name"]); + expect(anyUnpack(violation.fieldValue!, StringValueSchema)?.value).toBe("not-empty"); + expect(violation.message).toEqual( + expect.objectContaining({ + withPlaceholders: "Default `${parent.type}.${field.path}`: `${field.value}`.", + placeholderValue: expect.objectContaining({ + "parent.type": RequiredFieldsSchema.typeName, + "field.path": "name", + "field.value": "not-empty", + }), + }), + ); + }); + + it("packs bytes, enum, message, and repeated-element values by descriptor", () => { + const context = ValidationContext.create(RequiredFieldsSchema); + const bytesViolation = ViolationFactory.create( + context.atField(RequiredFieldsSchema.field.payload), + RequiredFieldsSchema.field.payload, + new Uint8Array([0xde, 0xad]), + {}, + ); + const enumViolation = ViolationFactory.create( + context.atField(RequiredFieldsSchema.field.status), + RequiredFieldsSchema.field.status, + Status.ACTIVE, + {}, + ); + const address = create(AddressSchema, { street: "Main", city: "Lisbon" }); + const messageViolation = ViolationFactory.create( + context.atField(RequiredFieldsSchema.field.address), + RequiredFieldsSchema.field.address, + address, + {}, + ); + const elementViolation = ViolationFactory.create( + context.atField(RequiredFieldsSchema.field.tags), + RequiredFieldsSchema.field.tags, + "duplicate-tag", + {}, + ); + + expect(anyUnpack(bytesViolation.fieldValue!, BytesValueSchema)?.value).toEqual( + new Uint8Array([0xde, 0xad]), + ); + expect(anyUnpack(enumViolation.fieldValue!, Int32ValueSchema)?.value).toBe(Status.ACTIVE); + expect(anyUnpack(messageViolation.fieldValue!, AddressSchema)).toEqual(address); + expect(anyUnpack(elementViolation.fieldValue!, StringValueSchema)?.value).toBe("duplicate-tag"); + }); + + it("resolves custom, default, and empty template strings", () => { + const context = ValidationContext.create(RequiredFieldsSchema).atField( + RequiredFieldsSchema.field.name, + ); + + expect( + ViolationFactory.create(context, RequiredFieldsSchema.field.name, "value", { + customMessage: "Custom diagnostic.", + defaultMessage: "Default diagnostic.", + }).message?.withPlaceholders, + ).toBe("Custom diagnostic."); + expect( + ViolationFactory.create(context, RequiredFieldsSchema.field.name, "value", { + defaultMessage: "Default diagnostic.", + }).message?.withPlaceholders, + ).toBe("Default diagnostic."); + expect( + ViolationFactory.create(context, RequiredFieldsSchema.field.name, "value", {}).message + ?.withPlaceholders, + ).toBe(""); + }); + + it("creates a message-level violation without a field value", () => { + const context = ValidationContext.create(RequiredFieldsSchema); + const violation = ViolationFactory.create(context, undefined, undefined, { + defaultMessage: "`${message.type}` has incompatible fields.", + }); + + expect(violation.typeName).toBe(RequiredFieldsSchema.typeName); + expect(violation.fieldPath?.fieldName).toEqual([]); + expect(violation.fieldValue).toBeUndefined(); + expect(violation.message).toEqual( + expect.objectContaining({ + withPlaceholders: "`${message.type}` has incompatible fields.", + placeholderValue: { "message.type": RequiredFieldsSchema.typeName }, + }), + ); + }); + + it("keeps field metadata placeholders when no concrete field value exists", () => { + const context = ValidationContext.create(RequiredFieldsSchema).atField( + RequiredFieldsSchema.field.name, + ); + const violation = ViolationFactory.create(context, RequiredFieldsSchema.field.name, undefined, { + defaultMessage: "No value for `${field.path}`.", + }); + + const listViolation = ViolationFactory.create( + ValidationContext.create(RequiredFieldsSchema).atField(RequiredFieldsSchema.field.tags), + RequiredFieldsSchema.field.tags, + undefined, + {}, + ); + const mapViolation = ViolationFactory.create( + ValidationContext.create(RequiredFieldsSchema).atField(RequiredFieldsSchema.field.scores), + RequiredFieldsSchema.field.scores, + undefined, + {}, + ); + + expect(violation.fieldValue).toBeUndefined(); + expect(violation.message?.placeholderValue).toEqual({ + "message.type": RequiredFieldsSchema.typeName, + "parent.type": RequiredFieldsSchema.typeName, + "field.path": "name", + "field.type": "string", + }); + expect(listViolation.message?.placeholderValue["field.type"]).toBe("string"); + expect(mapViolation.message?.placeholderValue["field.type"]).toBe("int32"); + expect(violation.message?.placeholderValue).not.toHaveProperty("field.value"); + }); +}); diff --git a/packages/validation/tests/when-contract.test.ts b/packages/validation/tests/when-contract.test.ts new file mode 100644 index 0000000..038508b --- /dev/null +++ b/packages/validation/tests/when-contract.test.ts @@ -0,0 +1,180 @@ +import { create } from "@bufbuild/protobuf"; +import { vi } from "vitest"; + +import { ValidationClock } from "../src/clock.js"; +import { validate } from "../src/index.js"; +import { + InvalidWhenValueSchema, + NestedWhenEnvelopeSchema, + TimeValidationSchema, +} from "./generated/test-when_pb.js"; + +describe("(when) collection and temporal contract", () => { + afterEach(() => ValidationClock.set()); + + it("skips singular descriptor defaults but evaluates default list and map elements once", () => { + let reads = 0; + ValidationClock.set(() => { + reads++; + return { seconds: 1_704_067_200n, nanos: 0 }; + }); + const defaults = create(TimeValidationSchema); + expect(validate(TimeValidationSchema, defaults)).toEqual([]); + expect(reads).toBe(0); + expect( + validate(TimeValidationSchema, create(TimeValidationSchema, { futureTimestamp: {} })), + ).toEqual([]); + expect(reads).toBe(0); + const values = create(TimeValidationSchema, { + pastTimestamp: { seconds: 1n }, + futureTimestamps: [{ seconds: 0n }], + pastTimestampByName: { default: { seconds: 0n } }, + }); + expect(validate(TimeValidationSchema, values)).toHaveLength(1); + expect(reads).toBe(3); + }); + + it("rejects unknown Time enum numbers with the public configuration shape", () => { + expect(() => + validate(InvalidWhenValueSchema, create(InvalidWhenValueSchema, { value: { seconds: 1n } })), + ).toThrow( + expect.objectContaining({ + code: "INVALID_OPTION_VALUE", + option: "when", + typeName: "tests.InvalidWhenValue", + fieldPath: ["value"], + }), + ); + }); + + it("reads the clock once per scalar and collection element", () => { + let reads = 0; + ValidationClock.set(() => ({ seconds: (reads++, 1_704_067_200n), nanos: 0 })); + expect( + validate( + TimeValidationSchema, + create(TimeValidationSchema, { pastTimestamp: { seconds: 1n } }), + ), + ).toEqual([]); + expect(reads).toBe(1); + reads = 0; + expect( + validate( + TimeValidationSchema, + create(TimeValidationSchema, { + futureTimestamps: [{ seconds: 1n }, { seconds: 2n }], + pastTimestampByName: { one: { seconds: 1n } }, + }), + ), + ).toHaveLength(2); + expect(reads).toBe(3); + }); + + it("rejects year and offset ranges and hides unsafe zone input", () => { + expect(() => + validate( + TimeValidationSchema, + create(TimeValidationSchema, { + futureDate: { year: 1_000_000_000, month: 1, day: 1 }, + }), + ), + ).toThrow(RangeError); + expect(() => + validate( + TimeValidationSchema, + create(TimeValidationSchema, { + futureOffsetDateTime: { + dateTime: { date: { year: 2024, month: 1, day: 1 } }, + offset: { amountSeconds: 64_801 }, + }, + }), + ), + ).toThrow(RangeError); + let failures = 0; + for (const zone of ["x".repeat(256), "America/New_York\nleak"]) { + try { + validate( + TimeValidationSchema, + create(TimeValidationSchema, { + pastZonedDateTime: { + dateTime: { date: { year: 2024, month: 1, day: 1 } }, + zone: { value: zone }, + }, + }), + ); + } catch (error) { + failures++; + expect(error).toBeInstanceOf(RangeError); + expect((error as Error).message).toBe("Invalid zoned date-time"); + expect((error as Error).message).not.toContain(zone); + expect((error as Error & { cause?: unknown }).cause).toBeUndefined(); + expect(String(error)).not.toContain(zone); + } + } + expect(failures).toBe(2); + }); + + it("rejects otherwise-valid Spine years outside the JVM Timestamp instant range", () => { + const zoned = (year: number) => + create(TimeValidationSchema, { + pastZonedDateTime: { + dateTime: { date: { year, month: 7, day: 1 }, time: { hour: 12 } }, + zone: { value: "America/New_York" }, + }, + }); + expect(() => validate(TimeValidationSchema, zoned(-999_999_999))).toThrow(RangeError); + expect(() => validate(TimeValidationSchema, zoned(999_999_999))).toThrow(RangeError); + }); + + it("accepts JVM Timestamp bounds and rejects seconds outside them", () => { + ValidationClock.set(() => ({ seconds: 0n, nanos: 0 })); + expect( + validate( + TimeValidationSchema, + create(TimeValidationSchema, { + pastTimestamp: { seconds: -62_135_596_800n }, + futureTimestamp: { seconds: 253_402_300_799n, nanos: 999_999_999 }, + }), + ), + ).toEqual([]); + expect(() => + validate( + TimeValidationSchema, + create(TimeValidationSchema, { pastTimestamp: { seconds: -62_135_596_801n } }), + ), + ).toThrow(RangeError); + expect(() => + validate( + TimeValidationSchema, + create(TimeValidationSchema, { futureTimestamp: { seconds: 253_402_300_800n } }), + ), + ).toThrow(RangeError); + }); + + it("uses Euclidean pre-epoch system clock division", () => { + const now = vi.spyOn(Date, "now").mockReturnValue(-1); + try { + ValidationClock.set(); + expect( + validate( + TimeValidationSchema, + create(TimeValidationSchema, { pastTimestamp: { seconds: -1n, nanos: 999_000_000 } }), + ), + ).toEqual([]); + } finally { + now.mockRestore(); + } + }); + + it("keeps when and nested validation leaf-only in validator order", () => { + ValidationClock.set(() => ({ seconds: 1_704_067_200n, nanos: 0 })); + const message = create(NestedWhenEnvelopeSchema, { + firstFuture: { seconds: 1n }, + nested: { future: { seconds: 1_800_000_000n }, label: "" }, + }); + expect(validate(NestedWhenEnvelopeSchema, message).map((v) => v.fieldPath?.fieldName)).toEqual([ + ["first_future"], + ["nested", "label"], + ]); + }); +}); diff --git a/packages/validation/tests/when.test.ts b/packages/validation/tests/when.test.ts new file mode 100644 index 0000000..bdc456f --- /dev/null +++ b/packages/validation/tests/when.test.ts @@ -0,0 +1,180 @@ +import { create } from "@bufbuild/protobuf"; +import { anyUnpack } from "@bufbuild/protobuf/wkt"; + +import { ValidationClock } from "../src/clock.js"; +import { validate } from "../src/index.js"; +import { + InvalidWhenPlaceholderSchema, + TimeValidationSchema, + UnsupportedWhenTargetSchema, +} from "./generated/test-when_pb.js"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; + +const now = { seconds: 1_704_067_200n, nanos: 0 }; // 2024-01-01T00:00:00Z + +describe("(when) time validation", () => { + beforeEach(() => ValidationClock.set(() => now)); + afterEach(() => ValidationClock.set()); + + it("treats equality with now as valid for both bounds and disables TIME_UNDEFINED", () => { + const message = create(TimeValidationSchema, { + pastTimestamp: now, + futureTimestamp: now, + disabled: { seconds: 0n, nanos: 0 }, + }); + expect(validate(TimeValidationSchema, message)).toEqual([]); + }); + + it("reports repeated and map offenders on the collection field with the element value", () => { + const message = create(TimeValidationSchema, { + futureTimestamps: [{ seconds: 0n }, { seconds: 1_800_000_000n }, { seconds: 1n }], + pastTimestampByName: { + first: { seconds: 1_800_000_000n }, + second: { seconds: 0n }, + }, + }); + const violations = validate(TimeValidationSchema, message); + expect(violations.map((violation) => violation.fieldPath?.fieldName)).toEqual([ + ["future_timestamps"], + ["future_timestamps"], + ["past_timestamp_by_name"], + ]); + expect( + violations.map((violation) => anyUnpack(violation.fieldValue!, TimestampSchema)?.seconds), + ).toEqual([0n, 1n, 1_800_000_000n]); + }); + + it("converts all supported temporal values using UTC or explicit-offset semantics", () => { + const message = temporalMessage({ + pastTimestamp: { seconds: 0n }, + futureTimestamp: { seconds: 1n }, + pastYearMonth: { year: 2025, month: 1 }, + futureDate: { year: 2020, month: 1, day: 1 }, + pastDateTime: { date: { year: 2025, month: 1, day: 1 } }, + futureOffsetDateTime: { dateTime: { date: { year: 2020, month: 1, day: 1 } } }, + }); + expect( + validate(TimeValidationSchema, message).map((violation) => violation.fieldPath?.fieldName[0]), + ).toEqual([ + "future_timestamp", + "past_year_month", + "future_date", + "past_date_time", + "future_offset_date_time", + ]); + }); + + it("resolves New York 2024 compatible gap and overlap like Java", () => { + const gap = temporalMessage({ + pastZonedDateTime: { + dateTime: { date: { year: 2024, month: 3, day: 10 }, time: { hour: 2, minute: 30 } }, + zone: { value: "America/New_York" }, + }, + }); + const overlap = temporalMessage({ + pastZonedDateTime: { + dateTime: { date: { year: 2024, month: 11, day: 3 }, time: { hour: 1, minute: 30 } }, + zone: { value: "America/New_York" }, + }, + }); + ValidationClock.set(() => ({ seconds: 1_710_055_800n, nanos: 0 })); // 07:30Z + expect(validate(TimeValidationSchema, gap)).toEqual([]); + ValidationClock.set(() => ({ seconds: 1_710_055_799n, nanos: 999_999_999 })); + expect(validate(TimeValidationSchema, gap).map((v) => v.fieldPath?.fieldName)).toEqual([ + ["past_zoned_date_time"], + ]); + ValidationClock.set(() => ({ seconds: 1_730_611_800n, nanos: 0 })); // 05:30Z + expect(validate(TimeValidationSchema, overlap)).toEqual([]); + ValidationClock.set(() => ({ seconds: 1_730_611_799n, nanos: 999_999_999 })); + expect(validate(TimeValidationSchema, overlap).map((v) => v.fieldPath?.fieldName)).toEqual([ + ["past_zoned_date_time"], + ]); + }); + + it("uses error_msg over the default message and supplies the documented when.in placeholder", () => { + const violations = validate( + TimeValidationSchema, + temporalMessage({ customMessage: { seconds: 1_800_000_000n } }), + ); + expect(violations).toHaveLength(1); + expect(violations[0].message?.withPlaceholders).toBe("custom ${when.in} ${field.path}"); + expect(violations[0].message?.placeholderValue["when.in"]).toBe("past"); + }); + + it("rejects unsupported targets as a configuration error", () => { + expect(() => + validate(UnsupportedWhenTargetSchema, create(UnsupportedWhenTargetSchema)), + ).toThrow( + expect.objectContaining({ + code: "UNSUPPORTED_OPTION_TARGET", + option: "when", + typeName: "tests.UnsupportedWhenTarget", + fieldPath: ["unsupported"], + }), + ); + }); + + it("rejects unsupported placeholders and ignores deprecated msg_format", () => { + expect(() => + validate( + InvalidWhenPlaceholderSchema, + create(InvalidWhenPlaceholderSchema, { value: { seconds: 1n } }), + ), + ).toThrow( + expect.objectContaining({ + code: "INVALID_OPTION_VALUE", + option: "when", + typeName: "tests.InvalidWhenPlaceholder", + fieldPath: ["value"], + }), + ); + const violations = validate( + TimeValidationSchema, + temporalMessage({ legacyMessage: { seconds: 1_800_000_000n } }), + ); + expect(violations[0].message?.withPlaceholders).not.toBe("ignored"); + }); + + it("throws for malformed timestamp and zoned temporal values", () => { + expect(() => + validate(TimeValidationSchema, temporalMessage({ pastTimestamp: { nanos: -1 } })), + ).toThrow(RangeError); + expect(() => + validate( + TimeValidationSchema, + temporalMessage({ + pastZonedDateTime: { + dateTime: { date: { year: 2024, month: 1, day: 1 } }, + zone: { value: "No/Such_Zone" }, + }, + }), + ), + ).toThrow(RangeError); + }); + + it("uses the system clock after test injection is reset", () => { + ValidationClock.set(); + expect( + validate(TimeValidationSchema, temporalMessage({ pastTimestamp: { seconds: 0n } })), + ).toEqual([]); + }); + + it("checks Gregorian leap-day components", () => { + expect( + validate( + TimeValidationSchema, + temporalMessage({ futureDate: { year: 2024, month: 2, day: 29 } }), + ), + ).toEqual([]); + expect(() => + validate( + TimeValidationSchema, + temporalMessage({ futureDate: { year: 2023, month: 2, day: 29 } }), + ), + ).toThrow(RangeError); + }); +}); + +function temporalMessage(value: Record<string, unknown>) { + return create(TimeValidationSchema, value as never); +} diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json new file mode 100644 index 0000000..22b2b71 --- /dev/null +++ b/packages/validation/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/validation/tsconfig.tests.json b/packages/validation/tsconfig.tests.json new file mode 100644 index 0000000..70a0b95 --- /dev/null +++ b/packages/validation/tsconfig.tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*", "tests/**/*.ts"], + "exclude": ["node_modules", "dist", "coverage"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..419fb47 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2824 @@ +lockfileVersion: "9.0" + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + acorn: 8.17.0 + minimatch: 10.2.5 + postcss: 8.5.15 + path-key: 3.1.1 + strip-json-comments: 3.1.1 + tinyrainbow: 3.1.0 + +importers: + .: + devDependencies: + "@bufbuild/protobuf": + specifier: 2.13.0 + version: 2.13.0 + "@eslint/js": + specifier: 9.39.1 + version: 9.39.1 + "@types/node": + specifier: 24.13.2 + version: 24.13.2 + "@vitest/coverage-v8": + specifier: 4.1.9 + version: 4.1.9(vitest@4.1.9) + eslint: + specifier: 9.39.1 + version: 9.39.1 + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8(eslint@9.39.1) + prettier: + specifier: 3.9.0 + version: 3.9.0 + typedoc: + specifier: 0.28.19 + version: 0.28.19(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: 8.62.0 + version: 8.62.0(eslint@9.39.1)(typescript@6.0.3) + vitest: + specifier: 4.1.9 + version: 4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@24.13.2)(yaml@2.9.0)) + yaml: + specifier: 2.9.0 + version: 2.9.0 + + packages/example: + dependencies: + "@bufbuild/protobuf": + specifier: 2.13.0 + version: 2.13.0 + "@spine-event-engine/validation": + specifier: workspace:* + version: link:../validation + devDependencies: + "@bufbuild/buf": + specifier: 1.72.0 + version: 1.72.0 + "@bufbuild/protoc-gen-es": + specifier: 2.13.0 + version: 2.13.0(@bufbuild/protobuf@2.13.0) + "@types/node": + specifier: 24.13.2 + version: 24.13.2 + typescript: + specifier: 6.0.3 + version: 6.0.3 + + packages/validation: + dependencies: + temporal-polyfill: + specifier: 1.0.1 + version: 1.0.1 + devDependencies: + "@bufbuild/buf": + specifier: 1.72.0 + version: 1.72.0 + "@bufbuild/protobuf": + specifier: 2.13.0 + version: 2.13.0 + "@bufbuild/protoc-gen-es": + specifier: 2.13.0 + version: 2.13.0(@bufbuild/protobuf@2.13.0) + "@types/node": + specifier: 24.13.2 + version: 24.13.2 + typescript: + specifier: 6.0.3 + version: 6.0.3 + +packages: + "@babel/helper-string-parser@7.29.7": + resolution: + { + integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==, + } + engines: { node: ">=6.9.0" } + + "@babel/helper-validator-identifier@7.29.7": + resolution: + { + integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==, + } + engines: { node: ">=6.9.0" } + + "@babel/parser@7.29.7": + resolution: + { + integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==, + } + engines: { node: ">=6.0.0" } + hasBin: true + + "@babel/types@7.29.7": + resolution: + { + integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==, + } + engines: { node: ">=6.9.0" } + + "@bcoe/v8-coverage@1.0.2": + resolution: + { + integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==, + } + engines: { node: ">=18" } + + "@bufbuild/buf-darwin-arm64@1.72.0": + resolution: + { + integrity: sha512-rKHRvjwAThapxIoOn92vIoTjYSz5FmRemDRLU4BYT4T6QWMEC13PM3/pPnqVgsNKZ5aW7iYDm9ztnisEqSi5yA==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [darwin] + + "@bufbuild/buf-darwin-x64@1.72.0": + resolution: + { + integrity: sha512-4TQ1AGft8sGspNg9NMsEjsKKis7nGaVV8tZLnNa3cKUBmx22gwOnB6VRhgKWwjf+BDqr85lUEzQ6wHCboNUutg==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [darwin] + + "@bufbuild/buf-linux-aarch64@1.72.0": + resolution: + { + integrity: sha512-cbIsUcgM5bHhbZWcDaAXqaYOAi8N0c0u+NiDydwVmZ04Et3s1EZ3TDqfQDRzwvoBPDP+lsO6YuTRXX6nI28x4w==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [linux] + + "@bufbuild/buf-linux-armv7@1.72.0": + resolution: + { + integrity: sha512-v/bXVsFL8YNm2HgosGb9r3+nAt4jQiUc3r3JipYuiVY3DAJZAjoEvcak6/BkxQMTEQz9Zb8gRRlule9IFkbc5g==, + } + engines: { node: ">=12" } + cpu: [arm] + os: [linux] + + "@bufbuild/buf-linux-x64@1.72.0": + resolution: + { + integrity: sha512-4xHGXEjqFxo1wX1zMGq4CzhYt5++nrj4C7k30j+YmGtvqCnipfdSe+V6kknBYRfYswVZEUwUbQOh6pnMTcGcrA==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [linux] + + "@bufbuild/buf-win32-arm64@1.72.0": + resolution: + { + integrity: sha512-WH7ClsoB9A0e/5fFhx0DLqLzillYPRdHBhlwzihgvjGci0bBdyJVHSQGf0B9uspCMU6sn6W/N1S9/2vvQBNMug==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [win32] + + "@bufbuild/buf-win32-x64@1.72.0": + resolution: + { + integrity: sha512-X3eWqFzhDmu8CYQZz+Fu7i+PgH+yUl8UwJ5+x+bhZRYAIdcijikthodk60c5u/qq42m1Z2XAnAGyp/mTf7IffA==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [win32] + + "@bufbuild/buf@1.72.0": + resolution: + { + integrity: sha512-BwBKTX/WXkhAhqWJGrEKnqU03/4tK1O0OozSlwUMBCOEo8pLL3xu3M24RT3+umExEeM0wjlANO6axqGWMqtt4Q==, + } + engines: { node: ">=12" } + hasBin: true + + "@bufbuild/protobuf@2.13.0": + resolution: + { + integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==, + } + + "@bufbuild/protoc-gen-es@2.13.0": + resolution: + { + integrity: sha512-ylI1vrLksdnXrVZRs9xGxmrQxKGhUm6pPszv26kqBvNiO3qPTktk+hgfwbLISBY4M/reShkT2dFLGT9fbydBXg==, + } + engines: { node: ">=20" } + hasBin: true + peerDependencies: + "@bufbuild/protobuf": 2.13.0 + peerDependenciesMeta: + "@bufbuild/protobuf": + optional: true + + "@bufbuild/protoplugin@2.13.0": + resolution: + { + integrity: sha512-32eMChKaL/A8Hh5AfMmXSdnuyznN85uoEjoyWiWeRrvtQOtpqX/v1R9PDe0g9vMIgzznK9inMT3CUaal0kjLUQ==, + } + + "@emnapi/core@1.11.1": + resolution: + { + integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==, + } + + "@emnapi/runtime@1.11.1": + resolution: + { + integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==, + } + + "@emnapi/wasi-threads@1.2.2": + resolution: + { + integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==, + } + + "@eslint-community/eslint-utils@4.10.1": + resolution: + { + integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + "@eslint-community/regexpp@4.12.2": + resolution: + { + integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==, + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + "@eslint/config-array@0.21.2": + resolution: + { + integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/config-helpers@0.4.2": + resolution: + { + integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/core@0.17.0": + resolution: + { + integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/eslintrc@3.3.6": + resolution: + { + integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/js@9.39.1": + resolution: + { + integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/object-schema@2.1.7": + resolution: + { + integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/plugin-kit@0.4.1": + resolution: + { + integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@gerrit0/mini-shiki@3.23.0": + resolution: + { + integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==, + } + + "@humanfs/core@0.19.2": + resolution: + { + integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==, + } + engines: { node: ">=18.18.0" } + + "@humanfs/node@0.16.8": + resolution: + { + integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==, + } + engines: { node: ">=18.18.0" } + + "@humanfs/types@0.15.0": + resolution: + { + integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==, + } + engines: { node: ">=18.18.0" } + + "@humanwhocodes/module-importer@1.0.1": + resolution: + { + integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, + } + engines: { node: ">=12.22" } + + "@humanwhocodes/retry@0.4.3": + resolution: + { + integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, + } + engines: { node: ">=18.18" } + + "@jridgewell/resolve-uri@3.1.2": + resolution: + { + integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, + } + engines: { node: ">=6.0.0" } + + "@jridgewell/sourcemap-codec@1.5.5": + resolution: + { + integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, + } + + "@jridgewell/trace-mapping@0.3.31": + resolution: + { + integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, + } + + "@napi-rs/wasm-runtime@1.1.6": + resolution: + { + integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==, + } + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + + "@oxc-project/types@0.139.0": + resolution: + { + integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==, + } + + "@rolldown/binding-android-arm64@1.1.5": + resolution: + { + integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [android] + + "@rolldown/binding-darwin-arm64@1.1.5": + resolution: + { + integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [darwin] + + "@rolldown/binding-darwin-x64@1.1.5": + resolution: + { + integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [darwin] + + "@rolldown/binding-freebsd-x64@1.1.5": + resolution: + { + integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [freebsd] + + "@rolldown/binding-linux-arm-gnueabihf@1.1.5": + resolution: + { + integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + "@rolldown/binding-linux-arm64-gnu@1.1.5": + resolution: + { + integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-arm64-musl@1.1.5": + resolution: + { + integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@rolldown/binding-linux-ppc64-gnu@1.1.5": + resolution: + { + integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-s390x-gnu@1.1.5": + resolution: + { + integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [s390x] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-x64-gnu@1.1.5": + resolution: + { + integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@rolldown/binding-linux-x64-musl@1.1.5": + resolution: + { + integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [musl] + + "@rolldown/binding-openharmony-arm64@1.1.5": + resolution: + { + integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [openharmony] + + "@rolldown/binding-wasm32-wasi@1.1.5": + resolution: + { + integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [wasm32] + + "@rolldown/binding-win32-arm64-msvc@1.1.5": + resolution: + { + integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [win32] + + "@rolldown/binding-win32-x64-msvc@1.1.5": + resolution: + { + integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [win32] + + "@rolldown/pluginutils@1.0.1": + resolution: + { + integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==, + } + + "@shikijs/engine-oniguruma@3.23.0": + resolution: + { + integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==, + } + + "@shikijs/langs@3.23.0": + resolution: + { + integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==, + } + + "@shikijs/themes@3.23.0": + resolution: + { + integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==, + } + + "@shikijs/types@3.23.0": + resolution: + { + integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==, + } + + "@shikijs/vscode-textmate@10.0.2": + resolution: + { + integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==, + } + + "@standard-schema/spec@1.1.0": + resolution: + { + integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, + } + + "@tybys/wasm-util@0.10.3": + resolution: + { + integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==, + } + + "@types/chai@5.2.3": + resolution: + { + integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==, + } + + "@types/deep-eql@4.0.2": + resolution: + { + integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, + } + + "@types/estree@1.0.9": + resolution: + { + integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, + } + + "@types/hast@3.0.5": + resolution: + { + integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==, + } + + "@types/json-schema@7.0.15": + resolution: + { + integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, + } + + "@types/node@24.13.2": + resolution: + { + integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==, + } + + "@types/unist@3.0.3": + resolution: + { + integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, + } + + "@typescript-eslint/eslint-plugin@8.62.0": + resolution: + { + integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + "@typescript-eslint/parser": ^8.62.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/parser@8.62.0": + resolution: + { + integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/project-service@8.62.0": + resolution: + { + integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/scope-manager@8.62.0": + resolution: + { + integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@typescript-eslint/tsconfig-utils@8.62.0": + resolution: + { + integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/type-utils@8.62.0": + resolution: + { + integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/types@8.62.0": + resolution: + { + integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@typescript-eslint/typescript-estree@8.62.0": + resolution: + { + integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/utils@8.62.0": + resolution: + { + integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/visitor-keys@8.62.0": + resolution: + { + integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@typescript/vfs@1.6.4": + resolution: + { + integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==, + } + peerDependencies: + typescript: "*" + + "@vitest/coverage-v8@4.1.9": + resolution: + { + integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==, + } + peerDependencies: + "@vitest/browser": 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + "@vitest/browser": + optional: true + + "@vitest/expect@4.1.9": + resolution: + { + integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==, + } + + "@vitest/mocker@4.1.9": + resolution: + { + integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==, + } + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + "@vitest/pretty-format@4.1.9": + resolution: + { + integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==, + } + + "@vitest/runner@4.1.9": + resolution: + { + integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==, + } + + "@vitest/snapshot@4.1.9": + resolution: + { + integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==, + } + + "@vitest/spy@4.1.9": + resolution: + { + integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==, + } + + "@vitest/utils@4.1.9": + resolution: + { + integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==, + } + + acorn-jsx@5.3.2: + resolution: + { + integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, + } + peerDependencies: + acorn: 8.17.0 + + acorn@8.17.0: + resolution: + { + integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==, + } + engines: { node: ">=0.4.0" } + hasBin: true + + ajv@6.15.0: + resolution: + { + integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==, + } + + ansi-styles@4.3.0: + resolution: + { + integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, + } + engines: { node: ">=8" } + + argparse@2.0.1: + resolution: + { + integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, + } + + assertion-error@2.0.1: + resolution: + { + integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, + } + engines: { node: ">=12" } + + ast-v8-to-istanbul@1.0.5: + resolution: + { + integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==, + } + + balanced-match@4.0.4: + resolution: + { + integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==, + } + engines: { node: 18 || 20 || >=22 } + + brace-expansion@5.0.8: + resolution: + { + integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==, + } + engines: { node: 20 || >=22 } + + callsites@3.1.0: + resolution: + { + integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, + } + engines: { node: ">=6" } + + chai@6.2.2: + resolution: + { + integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==, + } + engines: { node: ">=18" } + + chalk@4.1.2: + resolution: + { + integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, + } + engines: { node: ">=10" } + + color-convert@2.0.1: + resolution: + { + integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, + } + engines: { node: ">=7.0.0" } + + color-name@1.1.4: + resolution: + { + integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, + } + + convert-source-map@2.0.0: + resolution: + { + integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, + } + + cross-spawn@7.0.6: + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, + } + engines: { node: ">= 8" } + + debug@4.4.3: + resolution: + { + integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, + } + engines: { node: ">=6.0" } + peerDependencies: + supports-color: "*" + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: + { + integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, + } + + detect-libc@2.1.2: + resolution: + { + integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, + } + engines: { node: ">=8" } + + entities@4.5.0: + resolution: + { + integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, + } + engines: { node: ">=0.12" } + + es-module-lexer@2.3.1: + resolution: + { + integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==, + } + + escape-string-regexp@4.0.0: + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, + } + engines: { node: ">=10" } + + eslint-config-prettier@10.1.8: + resolution: + { + integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==, + } + hasBin: true + peerDependencies: + eslint: ">=7.0.0" + + eslint-scope@8.4.0: + resolution: + { + integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint-visitor-keys@3.4.3: + resolution: + { + integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + eslint-visitor-keys@4.2.1: + resolution: + { + integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint-visitor-keys@5.0.1: + resolution: + { + integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint@9.39.1: + resolution: + { + integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + hasBin: true + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: + { + integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + esquery@1.7.0: + resolution: + { + integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==, + } + engines: { node: ">=0.10" } + + esrecurse@4.3.0: + resolution: + { + integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, + } + engines: { node: ">=4.0" } + + estraverse@5.3.0: + resolution: + { + integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, + } + engines: { node: ">=4.0" } + + estree-walker@3.0.3: + resolution: + { + integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, + } + + esutils@2.0.3: + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, + } + engines: { node: ">=0.10.0" } + + expect-type@1.4.0: + resolution: + { + integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==, + } + engines: { node: ">=12.0.0" } + + fast-deep-equal@3.1.3: + resolution: + { + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, + } + + fast-json-stable-stringify@2.1.0: + resolution: + { + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, + } + + fast-levenshtein@2.0.6: + resolution: + { + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, + } + + fdir@6.5.0: + resolution: + { + integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, + } + engines: { node: ">=12.0.0" } + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: + { + integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, + } + engines: { node: ">=16.0.0" } + + find-up@5.0.0: + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, + } + engines: { node: ">=10" } + + flat-cache@4.0.1: + resolution: + { + integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, + } + engines: { node: ">=16" } + + flatted@3.4.3: + resolution: + { + integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==, + } + + fsevents@2.3.3: + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + + glob-parent@6.0.2: + resolution: + { + integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, + } + engines: { node: ">=10.13.0" } + + globals@14.0.0: + resolution: + { + integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==, + } + engines: { node: ">=18" } + + has-flag@4.0.0: + resolution: + { + integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, + } + engines: { node: ">=8" } + + html-escaper@2.0.2: + resolution: + { + integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, + } + + ignore@5.3.2: + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, + } + engines: { node: ">= 4" } + + ignore@7.0.6: + resolution: + { + integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==, + } + engines: { node: ">= 4" } + + import-fresh@3.3.1: + resolution: + { + integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, + } + engines: { node: ">=6" } + + imurmurhash@0.1.4: + resolution: + { + integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, + } + engines: { node: ">=0.8.19" } + + is-extglob@2.1.1: + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, + } + engines: { node: ">=0.10.0" } + + is-glob@4.0.3: + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, + } + engines: { node: ">=0.10.0" } + + isexe@2.0.0: + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, + } + + istanbul-lib-coverage@3.2.2: + resolution: + { + integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==, + } + engines: { node: ">=8" } + + istanbul-lib-report@3.0.1: + resolution: + { + integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==, + } + engines: { node: ">=10" } + + istanbul-reports@3.2.0: + resolution: + { + integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==, + } + engines: { node: ">=8" } + + js-tokens@10.0.0: + resolution: + { + integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==, + } + + js-yaml@4.3.0: + resolution: + { + integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==, + } + hasBin: true + + json-buffer@3.0.1: + resolution: + { + integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, + } + + json-schema-traverse@0.4.1: + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, + } + + json-stable-stringify-without-jsonify@1.0.1: + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, + } + + keyv@4.5.4: + resolution: + { + integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, + } + + levn@0.4.1: + resolution: + { + integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, + } + engines: { node: ">= 0.8.0" } + + lightningcss-android-arm64@1.33.0: + resolution: + { + integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==, + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: + { + integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==, + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: + { + integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==, + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: + { + integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==, + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: + { + integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==, + } + engines: { node: ">= 12.0.0" } + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: + { + integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==, + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: + { + integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==, + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: + { + integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==, + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: + { + integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==, + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: + { + integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==, + } + engines: { node: ">= 12.0.0" } + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: + { + integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==, + } + engines: { node: ">= 12.0.0" } + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: + { + integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==, + } + engines: { node: ">= 12.0.0" } + + linkify-it@5.0.2: + resolution: + { + integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==, + } + + locate-path@6.0.0: + resolution: + { + integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, + } + engines: { node: ">=10" } + + lodash.merge@4.6.2: + resolution: + { + integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, + } + + lunr@2.3.9: + resolution: + { + integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==, + } + + magic-string@0.30.21: + resolution: + { + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, + } + + magicast@0.5.3: + resolution: + { + integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==, + } + + make-dir@4.0.0: + resolution: + { + integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, + } + engines: { node: ">=10" } + + markdown-it@14.3.0: + resolution: + { + integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==, + } + hasBin: true + + mdurl@2.1.0: + resolution: + { + integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==, + } + + minimatch@10.2.5: + resolution: + { + integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==, + } + engines: { node: 18 || 20 || >=22 } + + ms@2.1.3: + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, + } + + nanoid@3.3.16: + resolution: + { + integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + natural-compare@1.4.0: + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, + } + + obug@2.1.4: + resolution: + { + integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==, + } + engines: { node: ">=12.20.0" } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + } + engines: { node: ">= 0.8.0" } + + p-limit@3.1.0: + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, + } + engines: { node: ">=10" } + + p-locate@5.0.0: + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, + } + engines: { node: ">=10" } + + parent-module@1.0.1: + resolution: + { + integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, + } + engines: { node: ">=6" } + + path-exists@4.0.0: + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + } + engines: { node: ">=8" } + + path-key@3.1.1: + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + } + engines: { node: ">=8" } + + pathe@2.0.3: + resolution: + { + integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, + } + + picocolors@1.1.1: + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + } + + picomatch@4.0.5: + resolution: + { + integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==, + } + engines: { node: ">=12" } + + postcss@8.5.15: + resolution: + { + integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==, + } + engines: { node: ^10 || ^12 || >=14 } + + prelude-ls@1.2.1: + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, + } + engines: { node: ">= 0.8.0" } + + prettier@3.9.0: + resolution: + { + integrity: sha512-LjIqSIC5VYLzs9WedVmJ2ljNAGnU+DteIClbahu4L/DBeWjZ6iT/k1lAYyu9JUh+1xINxWadaPw/Pl63y/agAw==, + } + engines: { node: ">=14" } + hasBin: true + + punycode.js@2.3.1: + resolution: + { + integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==, + } + engines: { node: ">=6" } + + punycode@2.3.1: + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, + } + engines: { node: ">=6" } + + resolve-from@4.0.0: + resolution: + { + integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, + } + engines: { node: ">=4" } + + rolldown@1.1.5: + resolution: + { + integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + + semver@7.8.5: + resolution: + { + integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==, + } + engines: { node: ">=10" } + hasBin: true + + shebang-command@2.0.0: + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, + } + engines: { node: ">=8" } + + shebang-regex@3.0.0: + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, + } + engines: { node: ">=8" } + + siginfo@2.0.0: + resolution: + { + integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, + } + + source-map-js@1.2.1: + resolution: + { + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, + } + engines: { node: ">=0.10.0" } + + stackback@0.0.2: + resolution: + { + integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, + } + + std-env@4.2.0: + resolution: + { + integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==, + } + + strip-json-comments@3.1.1: + resolution: + { + integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, + } + engines: { node: ">=8" } + + supports-color@7.2.0: + resolution: + { + integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, + } + engines: { node: ">=8" } + + temporal-polyfill@1.0.1: + resolution: + { + integrity: sha512-N2SoI9olnW7BUsU8RosDphZQl9s+WJ8O7PoJMFCr/e5/1rFkVI4GNOWaSeySG+UoP04foPYsnLWbJmbXOiShZg==, + } + + temporal-spec@1.0.0: + resolution: + { + integrity: sha512-00Ahj1e1ifaERTMOIIGpOCdOo9IEk2m6GGSMedsn9a2SIsGLdOTbmME1Htv6IM82b6VHrzSUTIVc7YHy6hdhFQ==, + } + + temporal-utils@1.0.1: + resolution: + { + integrity: sha512-HAixuesxFQIUaQk3ptX2jhfO/FsOkgVkDDMawvp6n/fkB1q6BKfs3lURw9I+pK/2e2e/q/vrLrxmeqauBoyGMQ==, + } + + tinybench@2.9.0: + resolution: + { + integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, + } + + tinyexec@1.2.4: + resolution: + { + integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==, + } + engines: { node: ">=18" } + + tinyglobby@0.2.17: + resolution: + { + integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, + } + engines: { node: ">=12.0.0" } + + tinyrainbow@3.1.0: + resolution: + { + integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==, + } + engines: { node: ">=14.0.0" } + + ts-api-utils@2.5.0: + resolution: + { + integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==, + } + engines: { node: ">=18.12" } + peerDependencies: + typescript: ">=4.8.4" + + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + + type-check@0.4.0: + resolution: + { + integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, + } + engines: { node: ">= 0.8.0" } + + typedoc@0.28.19: + resolution: + { + integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==, + } + engines: { node: ">= 18", pnpm: ">= 10" } + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + + typescript-eslint@8.62.0: + resolution: + { + integrity: sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + typescript@5.4.5: + resolution: + { + integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==, + } + engines: { node: ">=14.17" } + hasBin: true + + typescript@6.0.3: + resolution: + { + integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==, + } + engines: { node: ">=14.17" } + hasBin: true + + uc.micro@2.1.0: + resolution: + { + integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==, + } + + undici-types@7.18.2: + resolution: + { + integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==, + } + + uri-js@4.4.1: + resolution: + { + integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, + } + + vite@8.1.5: + resolution: + { + integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: + { + integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==, + } + engines: { node: ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.9 + "@vitest/browser-preview": 4.1.9 + "@vitest/browser-webdriverio": 4.1.9 + "@vitest/coverage-istanbul": 4.1.9 + "@vitest/coverage-v8": 4.1.9 + "@vitest/ui": 4.1.9 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, + } + engines: { node: ">= 8" } + hasBin: true + + why-is-node-running@2.3.0: + resolution: + { + integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==, + } + engines: { node: ">=8" } + hasBin: true + + word-wrap@1.2.5: + resolution: + { + integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, + } + engines: { node: ">=0.10.0" } + + yaml@2.9.0: + resolution: + { + integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==, + } + engines: { node: ">= 14.6" } + hasBin: true + + yocto-queue@0.1.0: + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, + } + engines: { node: ">=10" } + +snapshots: + "@babel/helper-string-parser@7.29.7": {} + + "@babel/helper-validator-identifier@7.29.7": {} + + "@babel/parser@7.29.7": + dependencies: + "@babel/types": 7.29.7 + + "@babel/types@7.29.7": + dependencies: + "@babel/helper-string-parser": 7.29.7 + "@babel/helper-validator-identifier": 7.29.7 + + "@bcoe/v8-coverage@1.0.2": {} + + "@bufbuild/buf-darwin-arm64@1.72.0": + optional: true + + "@bufbuild/buf-darwin-x64@1.72.0": + optional: true + + "@bufbuild/buf-linux-aarch64@1.72.0": + optional: true + + "@bufbuild/buf-linux-armv7@1.72.0": + optional: true + + "@bufbuild/buf-linux-x64@1.72.0": + optional: true + + "@bufbuild/buf-win32-arm64@1.72.0": + optional: true + + "@bufbuild/buf-win32-x64@1.72.0": + optional: true + + "@bufbuild/buf@1.72.0": + optionalDependencies: + "@bufbuild/buf-darwin-arm64": 1.72.0 + "@bufbuild/buf-darwin-x64": 1.72.0 + "@bufbuild/buf-linux-aarch64": 1.72.0 + "@bufbuild/buf-linux-armv7": 1.72.0 + "@bufbuild/buf-linux-x64": 1.72.0 + "@bufbuild/buf-win32-arm64": 1.72.0 + "@bufbuild/buf-win32-x64": 1.72.0 + + "@bufbuild/protobuf@2.13.0": {} + + "@bufbuild/protoc-gen-es@2.13.0(@bufbuild/protobuf@2.13.0)": + dependencies: + "@bufbuild/protoplugin": 2.13.0 + optionalDependencies: + "@bufbuild/protobuf": 2.13.0 + transitivePeerDependencies: + - supports-color + + "@bufbuild/protoplugin@2.13.0": + dependencies: + "@bufbuild/protobuf": 2.13.0 + "@typescript/vfs": 1.6.4(typescript@5.4.5) + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + "@emnapi/core@1.11.1": + dependencies: + "@emnapi/wasi-threads": 1.2.2 + tslib: 2.8.1 + optional: true + + "@emnapi/runtime@1.11.1": + dependencies: + tslib: 2.8.1 + optional: true + + "@emnapi/wasi-threads@1.2.2": + dependencies: + tslib: 2.8.1 + optional: true + + "@eslint-community/eslint-utils@4.10.1(eslint@9.39.1)": + dependencies: + eslint: 9.39.1 + eslint-visitor-keys: 3.4.3 + + "@eslint-community/regexpp@4.12.2": {} + + "@eslint/config-array@0.21.2": + dependencies: + "@eslint/object-schema": 2.1.7 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + "@eslint/config-helpers@0.4.2": + dependencies: + "@eslint/core": 0.17.0 + + "@eslint/core@0.17.0": + dependencies: + "@types/json-schema": 7.0.15 + + "@eslint/eslintrc@3.3.6": + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 10.2.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + "@eslint/js@9.39.1": {} + + "@eslint/object-schema@2.1.7": {} + + "@eslint/plugin-kit@0.4.1": + dependencies: + "@eslint/core": 0.17.0 + levn: 0.4.1 + + "@gerrit0/mini-shiki@3.23.0": + dependencies: + "@shikijs/engine-oniguruma": 3.23.0 + "@shikijs/langs": 3.23.0 + "@shikijs/themes": 3.23.0 + "@shikijs/types": 3.23.0 + "@shikijs/vscode-textmate": 10.0.2 + + "@humanfs/core@0.19.2": + dependencies: + "@humanfs/types": 0.15.0 + + "@humanfs/node@0.16.8": + dependencies: + "@humanfs/core": 0.19.2 + "@humanfs/types": 0.15.0 + "@humanwhocodes/retry": 0.4.3 + + "@humanfs/types@0.15.0": {} + + "@humanwhocodes/module-importer@1.0.1": {} + + "@humanwhocodes/retry@0.4.3": {} + + "@jridgewell/resolve-uri@3.1.2": {} + + "@jridgewell/sourcemap-codec@1.5.5": {} + + "@jridgewell/trace-mapping@0.3.31": + dependencies: + "@jridgewell/resolve-uri": 3.1.2 + "@jridgewell/sourcemap-codec": 1.5.5 + + "@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)": + dependencies: + "@emnapi/core": 1.11.1 + "@emnapi/runtime": 1.11.1 + "@tybys/wasm-util": 0.10.3 + optional: true + + "@oxc-project/types@0.139.0": {} + + "@rolldown/binding-android-arm64@1.1.5": + optional: true + + "@rolldown/binding-darwin-arm64@1.1.5": + optional: true + + "@rolldown/binding-darwin-x64@1.1.5": + optional: true + + "@rolldown/binding-freebsd-x64@1.1.5": + optional: true + + "@rolldown/binding-linux-arm-gnueabihf@1.1.5": + optional: true + + "@rolldown/binding-linux-arm64-gnu@1.1.5": + optional: true + + "@rolldown/binding-linux-arm64-musl@1.1.5": + optional: true + + "@rolldown/binding-linux-ppc64-gnu@1.1.5": + optional: true + + "@rolldown/binding-linux-s390x-gnu@1.1.5": + optional: true + + "@rolldown/binding-linux-x64-gnu@1.1.5": + optional: true + + "@rolldown/binding-linux-x64-musl@1.1.5": + optional: true + + "@rolldown/binding-openharmony-arm64@1.1.5": + optional: true + + "@rolldown/binding-wasm32-wasi@1.1.5": + dependencies: + "@emnapi/core": 1.11.1 + "@emnapi/runtime": 1.11.1 + "@napi-rs/wasm-runtime": 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + "@rolldown/binding-win32-arm64-msvc@1.1.5": + optional: true + + "@rolldown/binding-win32-x64-msvc@1.1.5": + optional: true + + "@rolldown/pluginutils@1.0.1": {} + + "@shikijs/engine-oniguruma@3.23.0": + dependencies: + "@shikijs/types": 3.23.0 + "@shikijs/vscode-textmate": 10.0.2 + + "@shikijs/langs@3.23.0": + dependencies: + "@shikijs/types": 3.23.0 + + "@shikijs/themes@3.23.0": + dependencies: + "@shikijs/types": 3.23.0 + + "@shikijs/types@3.23.0": + dependencies: + "@shikijs/vscode-textmate": 10.0.2 + "@types/hast": 3.0.5 + + "@shikijs/vscode-textmate@10.0.2": {} + + "@standard-schema/spec@1.1.0": {} + + "@tybys/wasm-util@0.10.3": + dependencies: + tslib: 2.8.1 + optional: true + + "@types/chai@5.2.3": + dependencies: + "@types/deep-eql": 4.0.2 + assertion-error: 2.0.1 + + "@types/deep-eql@4.0.2": {} + + "@types/estree@1.0.9": {} + + "@types/hast@3.0.5": + dependencies: + "@types/unist": 3.0.3 + + "@types/json-schema@7.0.15": {} + + "@types/node@24.13.2": + dependencies: + undici-types: 7.18.2 + + "@types/unist@3.0.3": {} + + "@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.1)(typescript@6.0.3))(eslint@9.39.1)(typescript@6.0.3)": + dependencies: + "@eslint-community/regexpp": 4.12.2 + "@typescript-eslint/parser": 8.62.0(eslint@9.39.1)(typescript@6.0.3) + "@typescript-eslint/scope-manager": 8.62.0 + "@typescript-eslint/type-utils": 8.62.0(eslint@9.39.1)(typescript@6.0.3) + "@typescript-eslint/utils": 8.62.0(eslint@9.39.1)(typescript@6.0.3) + "@typescript-eslint/visitor-keys": 8.62.0 + eslint: 9.39.1 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/parser@8.62.0(eslint@9.39.1)(typescript@6.0.3)": + dependencies: + "@typescript-eslint/scope-manager": 8.62.0 + "@typescript-eslint/types": 8.62.0 + "@typescript-eslint/typescript-estree": 8.62.0(typescript@6.0.3) + "@typescript-eslint/visitor-keys": 8.62.0 + debug: 4.4.3 + eslint: 9.39.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/project-service@8.62.0(typescript@6.0.3)": + dependencies: + "@typescript-eslint/tsconfig-utils": 8.62.0(typescript@6.0.3) + "@typescript-eslint/types": 8.62.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/scope-manager@8.62.0": + dependencies: + "@typescript-eslint/types": 8.62.0 + "@typescript-eslint/visitor-keys": 8.62.0 + + "@typescript-eslint/tsconfig-utils@8.62.0(typescript@6.0.3)": + dependencies: + typescript: 6.0.3 + + "@typescript-eslint/type-utils@8.62.0(eslint@9.39.1)(typescript@6.0.3)": + dependencies: + "@typescript-eslint/types": 8.62.0 + "@typescript-eslint/typescript-estree": 8.62.0(typescript@6.0.3) + "@typescript-eslint/utils": 8.62.0(eslint@9.39.1)(typescript@6.0.3) + debug: 4.4.3 + eslint: 9.39.1 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/types@8.62.0": {} + + "@typescript-eslint/typescript-estree@8.62.0(typescript@6.0.3)": + dependencies: + "@typescript-eslint/project-service": 8.62.0(typescript@6.0.3) + "@typescript-eslint/tsconfig-utils": 8.62.0(typescript@6.0.3) + "@typescript-eslint/types": 8.62.0 + "@typescript-eslint/visitor-keys": 8.62.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/utils@8.62.0(eslint@9.39.1)(typescript@6.0.3)": + dependencies: + "@eslint-community/eslint-utils": 4.10.1(eslint@9.39.1) + "@typescript-eslint/scope-manager": 8.62.0 + "@typescript-eslint/types": 8.62.0 + "@typescript-eslint/typescript-estree": 8.62.0(typescript@6.0.3) + eslint: 9.39.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/visitor-keys@8.62.0": + dependencies: + "@typescript-eslint/types": 8.62.0 + eslint-visitor-keys: 5.0.1 + + "@typescript/vfs@1.6.4(typescript@5.4.5)": + dependencies: + debug: 4.4.3 + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + "@vitest/coverage-v8@4.1.9(vitest@4.1.9)": + dependencies: + "@bcoe/v8-coverage": 1.0.2 + "@vitest/utils": 4.1.9 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@24.13.2)(yaml@2.9.0)) + + "@vitest/expect@4.1.9": + dependencies: + "@standard-schema/spec": 1.1.0 + "@types/chai": 5.2.3 + "@vitest/spy": 4.1.9 + "@vitest/utils": 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + "@vitest/mocker@4.1.9(vite@8.1.5(@types/node@24.13.2)(yaml@2.9.0))": + dependencies: + "@vitest/spy": 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@24.13.2)(yaml@2.9.0) + + "@vitest/pretty-format@4.1.9": + dependencies: + tinyrainbow: 3.1.0 + + "@vitest/runner@4.1.9": + dependencies: + "@vitest/utils": 4.1.9 + pathe: 2.0.3 + + "@vitest/snapshot@4.1.9": + dependencies: + "@vitest/pretty-format": 4.1.9 + "@vitest/utils": 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + "@vitest/spy@4.1.9": {} + + "@vitest/utils@4.1.9": + dependencies: + "@vitest/pretty-format": 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + "@jridgewell/trace-mapping": 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + + callsites@3.1.0: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + entities@4.5.0: {} + + es-module-lexer@2.3.1: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.1): + dependencies: + eslint: 9.39.1 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.1: + dependencies: + "@eslint-community/eslint-utils": 4.10.1(eslint@9.39.1) + "@eslint-community/regexpp": 4.12.2 + "@eslint/config-array": 0.21.2 + "@eslint/config-helpers": 0.4.2 + "@eslint/core": 0.17.0 + "@eslint/eslintrc": 3.3.6 + "@eslint/js": 9.39.1 + "@eslint/plugin-kit": 0.4.1 + "@humanfs/node": 0.16.8 + "@humanwhocodes/module-importer": 1.0.1 + "@humanwhocodes/retry": 0.4.3 + "@types/estree": 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + "@types/estree": 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.3 + keyv: 4.5.4 + + flatted@3.4.3: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lunr@2.3.9: {} + + magic-string@0.30.21: + dependencies: + "@jridgewell/sourcemap-codec": 1.5.5 + + magicast@0.5.3: + dependencies: + "@babel/parser": 7.29.7 + "@babel/types": 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + mdurl@2.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + obug@2.1.4: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.0: {} + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + resolve-from@4.0.0: {} + + rolldown@1.1.5: + dependencies: + "@oxc-project/types": 0.139.0 + "@rolldown/pluginutils": 1.0.1 + optionalDependencies: + "@rolldown/binding-android-arm64": 1.1.5 + "@rolldown/binding-darwin-arm64": 1.1.5 + "@rolldown/binding-darwin-x64": 1.1.5 + "@rolldown/binding-freebsd-x64": 1.1.5 + "@rolldown/binding-linux-arm-gnueabihf": 1.1.5 + "@rolldown/binding-linux-arm64-gnu": 1.1.5 + "@rolldown/binding-linux-arm64-musl": 1.1.5 + "@rolldown/binding-linux-ppc64-gnu": 1.1.5 + "@rolldown/binding-linux-s390x-gnu": 1.1.5 + "@rolldown/binding-linux-x64-gnu": 1.1.5 + "@rolldown/binding-linux-x64-musl": 1.1.5 + "@rolldown/binding-openharmony-arm64": 1.1.5 + "@rolldown/binding-wasm32-wasi": 1.1.5 + "@rolldown/binding-win32-arm64-msvc": 1.1.5 + "@rolldown/binding-win32-x64-msvc": 1.1.5 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + temporal-polyfill@1.0.1: + dependencies: + temporal-spec: 1.0.0 + temporal-utils: 1.0.1 + + temporal-spec@1.0.0: {} + + temporal-utils@1.0.1: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typedoc@0.28.19(typescript@6.0.3): + dependencies: + "@gerrit0/mini-shiki": 3.23.0 + lunr: 2.3.9 + markdown-it: 14.3.0 + minimatch: 10.2.5 + typescript: 6.0.3 + yaml: 2.9.0 + + typescript-eslint@8.62.0(eslint@9.39.1)(typescript@6.0.3): + dependencies: + "@typescript-eslint/eslint-plugin": 8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.1)(typescript@6.0.3))(eslint@9.39.1)(typescript@6.0.3) + "@typescript-eslint/parser": 8.62.0(eslint@9.39.1)(typescript@6.0.3) + "@typescript-eslint/typescript-estree": 8.62.0(typescript@6.0.3) + "@typescript-eslint/utils": 8.62.0(eslint@9.39.1)(typescript@6.0.3) + eslint: 9.39.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@5.4.5: {} + + typescript@6.0.3: {} + + uc.micro@2.1.0: {} + + undici-types@7.18.2: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.1.5(@types/node@24.13.2)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.15 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + "@types/node": 24.13.2 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@4.1.9(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@24.13.2)(yaml@2.9.0)): + dependencies: + "@vitest/expect": 4.1.9 + "@vitest/mocker": 4.1.9(vite@8.1.5(@types/node@24.13.2)(yaml@2.9.0)) + "@vitest/pretty-format": 4.1.9 + "@vitest/runner": 4.1.9 + "@vitest/snapshot": 4.1.9 + "@vitest/spy": 4.1.9 + "@vitest/utils": 4.1.9 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@24.13.2)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + "@types/node": 24.13.2 + "@vitest/coverage-v8": 4.1.9(vitest@4.1.9) + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..45073a4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,17 @@ +packages: + - "packages/*" +engineStrict: true +confirmModulesPurge: false +minimumReleaseAge: 1440 +overrides: + acorn: 8.17.0 + minimatch: 10.2.5 + postcss: 8.5.15 + path-key: 3.1.1 + strip-json-comments: 3.1.1 + tinyrainbow: 3.1.0 +verifyDepsBeforeRun: error +onlyBuiltDependencies: + - "@bufbuild/buf" +allowBuilds: + "@bufbuild/buf": true diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs new file mode 100644 index 0000000..bba5793 --- /dev/null +++ b/scripts/check-documentation.mjs @@ -0,0 +1,613 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const stalePlaceholder = + /(?:\$\{(?:value|other|field|regex)\}|(?<!\$)\{(?:value|other|field|regex)\})/; +const markdownLink = /\[[^\]]*\]\(([^)\s]+)\)/g; +const typeScriptFence = /```(?:ts|typescript)\s*\r?\n([\s\S]*?)```/gi; +const shellFence = /```(?:bash|sh|shell)\s*\r?\n([\s\S]*?)```/gi; +const protobufFence = /```protobuf\s*\r?\n([\s\S]*?)```/gi; +const publicPackage = "@spine-event-engine/validation"; +const previewInstall = /(?:pnpm|npm)\s+(?:add|install)\s+[^\n]*@spine-event-engine\/validation@/; +const exactPreview = /@spine-event-engine\/validation@\d+\.\d+\.\d+-snapshot\.\d+/; +const historicalWorkflowLanguage = + /(?:\bimplementation[- ]history\b|\bchat(?:\s+transcript)?\b|\btask(?:\s+(?:record|log|branch|history))?\b|(?<!-)\bfrozen\b|\bprovenance\b|\bintake record\b|\bshared-envelope\b|\blegacy (?:adapter|behavior)\b|\bimplementation seams\b|\bapproved (?:direction|comparison)\b)/i; +const repositorySetupGuides = [ + "README.md", + "packages/example/README.md", + "packages/validation/docs/development.md", + "packages/validation/docs/contributing.md", +]; + +/** Returns maintained Markdown files, excluding generated TypeDoc and protocol records. */ +export function findMaintainedMarkdown(root) { + const markdown = [resolve(root, "README.md")]; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + )) { + if (["node_modules", ".worktrees", "api", "build-protocol"].includes(entry.name)) continue; + const path = resolve(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (extname(entry.name) === ".md") markdown.push(path); + } + }; + for (const directory of [resolve(root, "docs"), resolve(root, "packages")]) { + if (existsSync(directory)) visit(directory); + } + return markdown.sort((left, right) => left.localeCompare(right)); +} + +function executableLines(fence) { + return fence.split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith("#")); +} + +function checkPreviewInstallSequences(content, file) { + for (const match of content.matchAll(shellFence)) { + const fence = match[1]; + if (!previewInstall.test(fence)) continue; + if (hasShellOperator(fence)) + throw new Error(`Quick-install sequence in ${file} must not use shell chaining or operators`); + const commands = executableLines(fence); + if (commands.length !== 1) + throw new Error( + `Quick-install sequence in ${file} must contain exactly one executable command`, + ); + if (exactPreview.test(fence)) { + const beforeFence = content.slice(0, match.index); + const headings = [...beforeFence.matchAll(/^#{1,6}\s+(.+)$/gm)]; + const precedingHeading = headings.at(-1)?.[1] ?? ""; + if (!/alternative/i.test(precedingHeading)) + throw new Error( + `Exact preview install in ${file} must be in a separately labelled alternative section`, + ); + } + } +} + +/** Checks direct-Corepack setup and clean example-test ordering in repository guides. */ +function checkRepositorySetup(root, file, content) { + const relativePath = relative(root, file); + if (!repositorySetupGuides.includes(relativePath)) return; + if (!repositorySetupGuides.every((guide) => existsSync(resolve(root, guide)))) return; + if (/\bcorepack enable pnpm\b/.test(content)) + throw new Error(`Repository guide ${file} must not use corepack enable pnpm`); + if (!/corepack pnpm install --frozen-lockfile/.test(content)) + throw new Error(`Repository guide ${file} must include direct corepack pnpm setup`); + + for (const match of content.matchAll(shellFence)) { + const commands = executableLines(match[1]); + for (const command of commands) { + if (/^pnpm\s/.test(command)) + throw new Error(`Repository command in ${file} must begin with corepack pnpm`); + } + const exampleTest = commands.indexOf("corepack pnpm test:example"); + if (exampleTest !== -1 && !commands.slice(0, exampleTest).includes("corepack pnpm build")) + throw new Error( + `Repository guide ${file} must run corepack pnpm build before corepack pnpm test:example`, + ); + } +} + +/** Detects shell control operators outside quoted package arguments. */ +function hasShellOperator(command) { + let quote; + for (let index = 0; index < command.length; index += 1) { + const character = command[index]; + if (character === "\\") { + if (!quote && (command[index + 1] === "\n" || command.slice(index + 1, index + 3) === "\r\n")) + return true; + index += 1; + continue; + } + if (quote) { + if (character === quote) quote = undefined; + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if (character === ";" || character === "|") return true; + if (character === "&") return true; + } + return false; +} + +function checkPackageDocumentationLinks(root) { + const docs = resolve(root, "packages/validation/docs"); + if (!existsSync(docs)) return; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + )) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (extname(entry.name) === ".md") { + const content = readFileSync(path, "utf8"); + if (!/\]\(\.\.\/README\.md(?:#[^)]+)?\)/.test(content)) + throw new Error(`Package documentation ${path} must link back to the package README`); + } + } + }; + visit(docs); +} + +function headingAnchors(content) { + const counts = new Map(); + const anchors = new Set(); + for (const match of content.matchAll(/^ {0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) { + const slug = match[1] + .trim() + .toLowerCase() + .replace(/[\]`*_~()]/g, "") + .replace(/[^\p{L}\p{N}\s-]/gu, "") + .replace(/\s+/g, "-"); + if (!slug) continue; + const duplicate = counts.get(slug) ?? 0; + counts.set(slug, duplicate + 1); + anchors.add(duplicate === 0 ? slug : `${slug}-${duplicate}`); + } + return anchors; +} + +/** Throws when a resolved local path leaves the real repository root. */ +function assertWithinRepository(repositoryRoot, targetPath, destination, file) { + const resolvedRoot = resolve(repositoryRoot); + const realRepositoryRoot = realpathSync(repositoryRoot); + const resolvedTarget = resolve(targetPath); + const contained = (root, path) => { + const pathRelative = relative(root, path); + return pathRelative === "" || (!pathRelative.startsWith("..") && !isAbsolute(pathRelative)); + }; + if (!contained(resolvedRoot, resolvedTarget)) + throw new Error(`Local link ${destination} in ${file} escapes the repository root`); + if (existsSync(resolvedTarget) && !contained(realRepositoryRoot, realpathSync(resolvedTarget))) + throw new Error(`Local link ${destination} in ${file} escapes the repository root`); +} + +function checkLocalMarkdownLinks(content, file, root) { + for (const match of content.matchAll(markdownLink)) { + const destination = match[1]; + if (/^[a-z]+:/i.test(destination) || destination.startsWith("api/reference/")) continue; + const hashIndex = destination.indexOf("#"); + const target = hashIndex === -1 ? destination : destination.slice(0, hashIndex); + const anchor = + hashIndex === -1 ? undefined : decodeURIComponent(destination.slice(hashIndex + 1)); + if (target && isAbsolute(target)) + throw new Error(`Local link ${destination} in ${file} escapes the repository root`); + const targetPath = target ? resolve(dirname(file), target) : file; + assertWithinRepository(root, targetPath, destination, file); + if (!existsSync(targetPath)) throw new Error(`Broken local link ${target} in ${file}`); + if ( + anchor && + extname(targetPath) === ".md" && + !headingAnchors(readFileSync(targetPath, "utf8")).has(anchor) + ) + throw new Error(`Broken local anchor ${anchor} in ${file}`); + } +} + +function checkCompleteProtoExample(root) { + const packageReadme = resolve(root, "packages/validation/README.md"); + const content = readFileSync(packageReadme, "utf8"); + const example = content.match( + /^## Complete Proto Example\s*\n\n```protobuf\s*\n([\s\S]*?)```/m, + )?.[1]; + if (!example) return; + if (!/import "google\/protobuf\/timestamp\.proto";/.test(example)) + throw new Error("Complete Proto Example must import google/protobuf/timestamp.proto"); + if ( + !/google\.protobuf\.Timestamp\s+expires_at\s*=\s*11\s+\[\(when\)\.in\s*=\s*FUTURE\];/.test( + example, + ) + ) + throw new Error("Complete Proto Example must demonstrate (when) with expires_at"); + if (/^\s*message\s+(\w+)\s*\{\s*\n(?:\s*\/\/[^\n]*\n)*\s*message\s+\1\s*\{/m.test(example)) + throw new Error("Complete Proto Example must not immediately duplicate a message declaration"); + if ( + !/^\/\/[^\n]+\nmessage\s+UserId\s*\{\s*\n\s*\/\/[^\n]+\n\s*string\s+value\s*=\s*1\s*\[\(required\)\s*=\s*true\];\s*\n\}/m.test( + example, + ) + ) + throw new Error("Complete Proto Example must declare a documented required UserId value"); + if ( + !/UserId\s+id\s*=\s*1\s*\[(?=[^\]]*\(required\)\s*=\s*true)(?=[^\]]*\(validate\)\s*=\s*true)[^\]]*\]/.test( + example, + ) + ) + throw new Error("Complete Proto Example must use a required validated UserId id"); +} + +function protoDeclaration(line) { + const message = /^\s*message\s+(\w+)\s*\{/.exec(line); + if (message) return { kind: "message", name: message[1], block: true }; + const enumeration = /^\s*enum\s+(\w+)\s*\{/.exec(line); + if (enumeration) return { kind: "enum", name: enumeration[1], block: true }; + const oneof = /^\s*oneof\s+(\w+)\s*\{/.exec(line); + if (oneof) return { kind: "oneof", name: oneof[1], block: true }; + const enumValue = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*\d+/.exec(line); + if (enumValue) return { kind: "enum value", name: enumValue[1], block: false }; + const field = /^\s*(?:(?:repeated|optional)\s+)?(?:map<[^>]+>|[.\w]+)\s+(\w+)\s*=\s*\d+/.exec( + line, + ); + if (field) return { kind: "field", name: field[1], block: false }; + return undefined; +} + +function protoStructure(lines) { + let inBlockComment = false; + return lines.map((line) => { + let result = ""; + let quote; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + const next = line[index + 1]; + if (inBlockComment) { + if (character === "*" && next === "/") { + inBlockComment = false; + index += 1; + } + continue; + } + if (quote) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = undefined; + continue; + } + if (character === "/" && next === "/") break; + if (character === "/" && next === "*") { + inBlockComment = true; + index += 1; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + result += character; + } + return result; + }); +} + +function findProtoDeclarationEnd(lines, start, block) { + if (!block) { + for (let index = start; index < lines.length; index += 1) + if (lines[index].includes(";")) return index; + return start; + } + let depth = 0; + for (let index = start; index < lines.length; index += 1) { + depth += (lines[index].match(/\{/g) ?? []).length; + depth -= (lines[index].match(/\}/g) ?? []).length; + if (depth === 0) return index; + } + return start; +} + +/** Checks that maintained README Proto fences have comments and readable declaration spacing. */ +function requireProtoMatch(source, expression, description, file) { + if (!expression.test(source)) throw new Error(file + ": " + description); +} + +function protoMessageBody(source, message) { + const lines = protoStructure(source.split(/\r?\n/)); + const declaration = new RegExp("^\\s*message\\s+" + message + "\\s*\\{"); + for (let index = 0; index < lines.length; index += 1) { + const match = declaration.exec(lines[index]); + if (!match) continue; + let depth = 1; + let body = ""; + for (let lineIndex = index; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + const start = lineIndex === index ? match[0].length : 0; + for (let characterIndex = start; characterIndex < line.length; characterIndex += 1) { + const character = line[characterIndex]; + if (character === "{") { + depth += 1; + continue; + } + if (character === "}") { + depth -= 1; + if (depth === 0) return body; + continue; + } + if (depth === 1) body += character; + } + if (depth === 1) body += "\n"; + } + return undefined; + } + return undefined; +} + +function checkExampleDomainIds(root) { + const userProto = resolve(root, "packages/example/proto/user.proto"); + const productProto = resolve(root, "packages/example/proto/product.proto"); + const user = readFileSync(userProto, "utf8"); + const product = readFileSync(productProto, "utf8"); + const requiredValue = (source, id, file) => + requireProtoMatch( + source, + new RegExp( + "message\\s+" + + id + + "\\s*\\{[^}]*?string\\s+value\\s*=\\s*1\\s*\\[[^\\]]*\\(required\\)\\s*=\\s*true[^\\]]*\\]", + ), + id + ".value must be required", + file, + ); + const requiredValidatedField = (source, message, type, field, file) => { + const body = protoMessageBody(source, message); + requireProtoMatch( + body ?? "", + new RegExp( + type + + "\\s+" + + field + + "\\s*=\\s*1\\s*\\[(?=[^\\]]*\\(required\\)\\s*=\\s*true)(?=[^\\]]*\\(validate\\)\\s*=\\s*true)[^\\]]*\\]", + ), + message + "." + field + " must be required and validate", + file, + ); + }; + + requiredValue(user, "UserId", userProto); + requiredValue(product, "ProductId", productProto); + requireProtoMatch( + product, + /message\s+ProductId\s*\{[^}]*?\(pattern\)\.regex\s*=\s*"\^prod-\[0-9\]\+\$"/, + "ProductId.value must preserve the prod-[0-9]+ pattern", + productProto, + ); + requiredValue(product, "CategoryId", productProto); + requiredValidatedField(user, "User", "UserId", "id", userProto); + requiredValidatedField(user, "GetUserRequest", "UserId", "user_id", userProto); + requiredValidatedField(product, "Product", "ProductId", "id", productProto); + requiredValidatedField(product, "Category", "CategoryId", "id", productProto); +} + +function checkProtoFenceDocumentation(content, file) { + if (basename(file) !== "README.md") return; + for (const fence of content.matchAll(protobufFence)) { + const lines = fence[1].split(/\r?\n/); + const structure = protoStructure(lines); + const declarations = []; + let depth = 0; + for (let index = 0; index < lines.length; index += 1) { + const declaration = protoDeclaration(lines[index]); + if (declaration) { + const comment = lines[index - 1]?.trim(); + if (!comment?.startsWith("//") || comment.slice(2).trim() === "") + throw new Error( + `${file}: ${declaration.kind} ${declaration.name} requires a non-empty leading comment`, + ); + declarations.push({ + ...declaration, + depth, + commentStart: index - 1, + end: findProtoDeclarationEnd(structure, index, declaration.block), + }); + } + depth += (structure[index].match(/\{/g) ?? []).length; + depth -= (structure[index].match(/\}/g) ?? []).length; + } + for (let index = 1; index < declarations.length; index += 1) { + const previous = declarations[index - 1]; + const current = declarations[index]; + if (previous.depth !== current.depth) continue; + const emptyLines = lines + .slice(previous.end + 1, current.commentStart) + .filter((line) => line.trim() === "").length; + if (emptyLines !== 1) + throw new Error( + `${file}: ${previous.kind} ${previous.name} must have exactly one empty line before ${current.kind} ${current.name}`, + ); + } + } +} + +function checkSourceTsDoc(root, index, publicExports) { + const sourceRoots = [ + resolve(root, "packages/validation/src"), + resolve(root, "packages/example/src"), + ]; + let publicImportCount = 0; + const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + )) { + if (entry.name === "generated") continue; + const path = resolve(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (extname(entry.name) === ".ts") { + const source = readFileSync(path, "utf8"); + for (const comment of source.matchAll(/\/\*\*([\s\S]*?)\*\//g)) { + if (historicalWorkflowLanguage.test(comment[1])) + throw new Error(`Prohibited historical workflow language in ${path}`); + } + publicImportCount += checkTypeScriptFences( + tsDocTypeScriptFences(source), + path, + root, + index, + publicExports, + ); + } + } + }; + for (const sourceRoot of sourceRoots) { + if (existsSync(sourceRoot)) visit(sourceRoot); + } + return publicImportCount; +} + +/** Discovers public value and type names from the package entry point via the TypeScript AST. */ +export function discoverPublicExports(indexSource) { + const source = ts.createSourceFile("index.ts", indexSource, ts.ScriptTarget.Latest, true); + const names = new Set(); + for (const statement of source.statements) { + if (!ts.isExportDeclaration(statement) || !statement.exportClause) continue; + if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) names.add(element.name.text); + continue; + } + if (ts.isNamespaceExport(statement.exportClause)) names.add(statement.exportClause.name.text); + } + return names; +} + +function namedPublicImports(markdown) { + const source = ts.createSourceFile("snippet.ts", markdown, ts.ScriptTarget.Latest, true); + const names = []; + const visit = (node) => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === publicPackage && + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) + ) { + for (const element of node.importClause.namedBindings.elements) + names.push(element.propertyName?.text ?? element.name.text); + } + ts.forEachChild(node, visit); + }; + visit(source); + return names; +} + +function typecheckSnippet(snippet, file, root, index) { + const temporaryRoot = mkdtempSync(join(root, ".documentation-snippets-")); + try { + const snippetPath = join(temporaryRoot, "snippet.ts"); + writeFileSync(snippetPath, snippet); + + // Maintained examples may import this documented generated module. It is the + // sole virtual relative module; all other relative imports must resolve. + mkdirSync(join(temporaryRoot, "generated")); + writeFileSync( + join(temporaryRoot, "generated", "user_pb.ts"), + "export declare const UserSchema: any;\n", + ); + + const program = ts.createProgram([snippetPath], { + noEmit: true, + strict: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + ignoreDeprecations: "6.0", + baseUrl: root, + paths: { [publicPackage]: [index] }, + }); + const diagnostic = ts + .getPreEmitDiagnostics(program) + .find((entry) => entry.category === ts.DiagnosticCategory.Error); + if (diagnostic) { + throw new Error( + `Non-compilable TypeScript snippet in ${file}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`, + ); + } + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function tsDocTypeScriptFences(source) { + const fences = []; + for (const comment of source.matchAll(/\/\*\*([\s\S]*?)\*\//g)) { + const text = comment[1].replace(/^\s*\*\s?/gm, ""); + for (const fence of text.matchAll(typeScriptFence)) fences.push(fence[1]); + } + return fences; +} + +function checkTypeScriptFences(content, file, root, index, publicExports) { + let publicImportCount = 0; + for (const fence of content) { + for (const imported of namedPublicImports(fence)) { + publicImportCount++; + if (!publicExports.has(imported)) throw new Error(`Non-public import ${imported} in ${file}`); + } + typecheckSnippet(fence, file, root, index); + } + return publicImportCount; +} + +/** Runs all project-owned documentation checks and returns checked Markdown paths. */ +export function checkDocumentation({ root }) { + const markdown = findMaintainedMarkdown(root); + const index = resolve(root, "packages/validation/src/index.ts"); + const publicExports = discoverPublicExports(readFileSync(index, "utf8")); + let publicImportCount = 0; + + for (const file of markdown) { + const content = readFileSync(file, "utf8"); + if (historicalWorkflowLanguage.test(content)) + throw new Error(`Prohibited historical workflow language in ${file}`); + if (stalePlaceholder.test(content)) + throw new Error(`Stale unnamespaced placeholder in ${file}`); + checkPreviewInstallSequences(content, file); + checkRepositorySetup(root, file, content); + checkProtoFenceDocumentation(content, file); + publicImportCount += checkTypeScriptFences( + [...content.matchAll(typeScriptFence)].map((fence) => fence[1]), + file, + root, + index, + publicExports, + ); + checkLocalMarkdownLinks(content, file, root); + } + + checkPackageDocumentationLinks(root); + checkCompleteProtoExample(root); + + const publicTsDoc = resolve(root, "packages/validation/src/validation.ts"); + const publicTsDocSource = readFileSync(publicTsDoc, "utf8"); + if (stalePlaceholder.test(publicTsDocSource)) + throw new Error(`Stale unnamespaced placeholder in ${publicTsDoc}`); + publicImportCount += checkTypeScriptFences( + tsDocTypeScriptFences(publicTsDocSource), + publicTsDoc, + root, + index, + publicExports, + ); + publicImportCount += checkSourceTsDoc(root, index, publicExports); + + for (const proto of [ + "packages/example/proto/user.proto", + "packages/example/proto/product.proto", + ]) { + if (/\((?:is_required|required_field)\)/.test(readFileSync(resolve(root, proto), "utf8"))) + throw new Error(`Deprecated active option in ${proto}`); + } + checkExampleDomainIds(root); + if (publicImportCount === 0) + throw new Error("Documentation must demonstrate a named public package import"); + return markdown; +} + +const modulePath = fileURLToPath(import.meta.url); +if (process.argv[1] && resolve(process.argv[1]) === modulePath) { + const root = resolve(dirname(modulePath), ".."); + const markdown = checkDocumentation({ root }); + console.log(`Checked ${markdown.length} maintained Markdown files and documentation examples.`); +} diff --git a/scripts/check-documentation.test.mjs b/scripts/check-documentation.test.mjs new file mode 100644 index 0000000..4ce8e14 --- /dev/null +++ b/scripts/check-documentation.test.mjs @@ -0,0 +1,711 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { checkDocumentation, findMaintainedMarkdown } from "./check-documentation.mjs"; + +function createFixture() { + const root = mkdtempSync(join(tmpdir(), "validation-docs-")); + mkdirSync(join(root, "docs")); + mkdirSync(join(root, "packages", "validation", "src"), { recursive: true }); + mkdirSync(join(root, "packages", "validation", "docs"), { recursive: true }); + mkdirSync(join(root, "packages", "example", "proto"), { recursive: true }); + writeFileSync( + join(root, "packages", "validation", "src", "index.ts"), + "export { publicValue as aliasedValue } from './value';\nexport type { PublicType } from './types';\n", + ); + writeFileSync( + join(root, "packages", "validation", "src", "value.ts"), + "export const publicValue = 1;\n", + ); + writeFileSync( + join(root, "packages", "validation", "src", "types.ts"), + "export interface PublicType {}\n", + ); + writeFileSync( + join(root, "packages", "validation", "src", "validation.ts"), + "/** Current ${field.path}. */\n", + ); + writeFileSync(join(root, "packages", "validation", "README.md"), "# Package\n"); + writeFileSync(join(root, "docs", "target.md"), "# Target\n"); + writeFileSync(join(root, "packages", "example", "proto", "user.proto"), 'syntax = "proto3";\n'); + writeFileSync( + join(root, "packages", "example", "proto", "user.proto"), + [ + 'syntax = "proto3";', + "message UserId { string value = 1 [(required) = true]; }", + "message User { UserId id = 1 [(required) = true, (validate) = true]; }", + "message GetUserRequest { UserId user_id = 1 [(required) = true, (validate) = true]; }", + "", + ].join("\n"), + ); + writeFileSync( + join(root, "packages", "example", "proto", "product.proto"), + [ + 'syntax = "proto3";', + 'message ProductId { string value = 1 [(required) = true, (pattern).regex = "^prod-[0-9]+$"]; }', + "message Product { ProductId id = 1 [(required) = true, (validate) = true]; }", + "message CategoryId { string value = 1 [(required) = true]; }", + "message Category { CategoryId id = 1 [(required) = true, (validate) = true]; }", + "", + ].join("\n"), + ); + return root; +} + +function writeReadme(root, content) { + writeFileSync(join(root, "README.md"), content); +} + +function withPublicImport(content) { + return `${content}\n\n\`\`\`typescript\nimport { aliasedValue } from "@spine-event-engine/validation";\nconsole.log(aliasedValue);\n\`\`\``; +} + +function expectFailure(root, expression) { + assert.throws(() => checkDocumentation({ root }), expression); +} + +function writeRepositoryGuides(root, { developmentSetup, exampleSetup, rootSetup }) { + writeReadme(root, withPublicImport(rootSetup)); + writeFileSync( + join(root, "packages", "example", "README.md"), + ["# Example", "", exampleSetup].join("\n"), + ); + writeFileSync( + join(root, "packages", "validation", "docs", "development.md"), + ["# Development", "", "See the [package guide](../README.md).", "", developmentSetup].join( + "\n", + ), + ); + writeFileSync( + join(root, "packages", "validation", "docs", "contributing.md"), + ["# Contributing", "", "See the [package guide](../README.md).", "", developmentSetup].join( + "\n", + ), + ); +} + +{ + const root = createFixture(); + try { + writeReadme( + root, + [ + "[target](docs/target.md)", + "```typescript", + 'import { aliasedValue, type PublicType } from "@spine-event-engine/validation";', + "const valid: PublicType = {} as PublicType;", + "console.log(aliasedValue, valid);", + "```", + ].join("\n"), + ); + assert.equal(checkDocumentation({ root }).length, 3); + + writeFileSync( + join(root, "packages", "validation", "src", "validation.ts"), + "/** Stale {value}. */\n", + ); + expectFailure(root, /Stale unnamespaced placeholder/); + writeFileSync( + join(root, "packages", "validation", "src", "validation.ts"), + "/** Current ${field.path}. */\n", + ); + + writeReadme( + root, + '```typescript\nimport { aliasedValue } from "@spine-event-engine/validation";\nconsole.log(aliasedValue);\n```', + ); + writeFileSync( + join(root, "packages", "validation", "src", "validation.ts"), + "/** Stale ${value}. */\n", + ); + expectFailure(root, /Stale unnamespaced placeholder/); + writeFileSync( + join(root, "packages", "validation", "src", "validation.ts"), + "/** Current ${field.path}. */\n", + ); + + writeReadme(root, "Legacy ${field} placeholder."); + expectFailure(root, /Stale unnamespaced placeholder/); + writeReadme( + root, + '```typescript\nimport { aliasedValue } from "@spine-event-engine/validation";\nconsole.log(aliasedValue);\n```', + ); + assert.equal(checkDocumentation({ root }).length, 3); + + writeFileSync( + join(root, "packages", "validation", "src", "validation.ts"), + "/** @example\n * ```typescript\n * const value: string = 1;\n * ```\n */\n", + ); + expectFailure(root, /Non-compilable TypeScript snippet/); + writeFileSync( + join(root, "packages", "validation", "src", "validation.ts"), + "/** Current ${field.path}. */\n", + ); + + writeReadme(root, "```typescript\nconst = ;\n```"); + expectFailure(root, /Non-compilable TypeScript snippet/); + + writeReadme(root, "```typescript\nconst value: string = 1;\n```"); + expectFailure(root, /Non-compilable TypeScript snippet/); + + writeReadme( + root, + '```typescript\nimport validation from "@spine-event-engine/validation";\nconsole.log(validation);\n```', + ); + expectFailure(root, /Non-compilable TypeScript snippet/); + + writeReadme( + root, + '```typescript\nimport * as validation from "@spine-event-engine/validation";\nvalidation.notExported();\n```', + ); + expectFailure(root, /Non-compilable TypeScript snippet/); + + writeReadme( + root, + '```typescript\nimport { aliasedValue, type PublicType as PublicAlias } from "@spine-event-engine/validation";\nconst valid: PublicAlias = {} as PublicAlias;\nconsole.log(aliasedValue, valid);\n```', + ); + assert.equal(checkDocumentation({ root }).length, 3); + + writeReadme( + root, + '```typescript\nimport { value } from "./missing";\nconsole.log(value);\n```', + ); + expectFailure(root, /Non-compilable TypeScript snippet/); + + writeReadme( + root, + '```typescript\nimport { privateValue } from "@spine-event-engine/validation";\n```', + ); + expectFailure(root, /Non-public import privateValue/); + + writeReadme(root, "[missing](docs/missing.md)"); + expectFailure(root, /Broken local link docs\/missing.md/); + + writeReadme(root, withPublicImport("[absolute](/tmp/outside.md)")); + expectFailure(root, /escapes the repository root/); + + writeReadme(root, withPublicImport("[traversal](../../outside.md)")); + expectFailure(root, /escapes the repository root/); + + const outside = mkdtempSync(join(tmpdir(), "validation-docs-outside-")); + try { + writeFileSync(join(outside, "outside.md"), "# Outside\n"); + symlinkSync(outside, join(root, "docs", "outside"), "dir"); + writeReadme(root, withPublicImport("[symlink](docs/outside/outside.md)")); + expectFailure(root, /escapes the repository root/); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(join(root, "docs", "outside"), { force: true }); + } + + writeReadme(root, withPublicImport("[missing heading](docs/target.md#missing-heading)")); + expectFailure(root, /Broken local anchor missing-heading/); + + writeReadme(root, withPublicImport("[target heading](docs/target.md#target)")); + assert.equal(checkDocumentation({ root }).length, 3); + + writeReadme( + root, + withPublicImport( + [ + "```sh", + "pnpm add @spine-event-engine/validation@snapshot @bufbuild/protobuf", + "pnpm add @spine-event-engine/validation@2.0.0-snapshot.6 @bufbuild/protobuf", + "```", + ].join("\n"), + ), + ); + expectFailure(root, /exactly one executable command/); + + for (const chainedInstall of [ + "pnpm add @spine-event-engine/validation@snapshot @bufbuild/protobuf && echo done", + "pnpm add @spine-event-engine/validation@snapshot @bufbuild/protobuf; echo done", + "pnpm add @spine-event-engine/validation@snapshot @bufbuild/protobuf || echo done", + "pnpm add @spine-event-engine/validation@snapshot @bufbuild/protobuf | tee install.log", + "pnpm add @spine-event-engine/validation@snapshot @bufbuild/protobuf & echo done", + "pnpm add @spine-event-engine/validation@snapshot @bufbuild/protobuf \\\n+echo done", + ]) { + writeReadme(root, withPublicImport(`\`\`\`sh\n${chainedInstall}\n\`\`\``)); + expectFailure(root, /must not use shell chaining or operators/); + } + + writeReadme( + root, + withPublicImport( + "```sh\npnpm add '@spine-event-engine/validation@snapshot&&literal' @bufbuild/protobuf\n```", + ), + ); + assert.equal(checkDocumentation({ root }).length, 3); + + writeReadme( + root, + withPublicImport( + [ + "## Install", + "```sh", + "pnpm add @spine-event-engine/validation@2.0.0-snapshot.6 @bufbuild/protobuf", + "```", + ].join("\n"), + ), + ); + expectFailure(root, /separately labelled alternative/); + + writeReadme( + root, + withPublicImport( + [ + "## Install", + "```sh", + "pnpm add @spine-event-engine/validation@snapshot @bufbuild/protobuf", + "```", + "## Alternative: exact preview", + "```sh", + "pnpm add @spine-event-engine/validation@2.0.0-snapshot.6 @bufbuild/protobuf", + "```", + ].join("\n"), + ), + ); + writeFileSync( + join(root, "packages", "validation", "docs", "architecture.md"), + "# Architecture\n", + ); + expectFailure(root, /link back to the package README/); + writeFileSync( + join(root, "packages", "validation", "docs", "architecture.md"), + "# Architecture\n\nSee the [package guide](../README.md).\n", + ); + + writeReadme( + root, + withPublicImport("A historical implementation task is not reader documentation."), + ); + expectFailure(root, /Prohibited historical workflow language/); + + writeReadme(root, withPublicImport("The frozen contract has an intake record.")); + expectFailure(root, /Prohibited historical workflow language/); + + writeReadme(root, withPublicImport("A shared-envelope uses a legacy adapter.")); + expectFailure(root, /Prohibited historical workflow language/); + + writeReadme(root, withPublicImport("The implementation seams follow approved direction.")); + expectFailure(root, /Prohibited historical workflow language/); + + writeReadme(root, withPublicImport("Use `pnpm install --frozen-lockfile`.")); + assert.equal(checkDocumentation({ root }).length, 4); + + writeReadme(root, "{field}"); + expectFailure(root, /Stale unnamespaced placeholder/); + + writeReadme(root, "No imports needed for this failure."); + writeFileSync( + join(root, "packages", "example", "proto", "user.proto"), + "bool legacy = 1 [(is_required) = true];\n", + ); + expectFailure(root, /Deprecated active option/); + + writeFileSync( + join(root, "packages", "example", "proto", "user.proto"), + [ + 'syntax = "proto3";', + "message UserId { string value = 1 [(required) = true]; }", + "message User { UserId id = 1 [(required) = true, (validate) = true]; }", + "message GetUserRequest { UserId user_id = 1 [(required) = true, (validate) = true]; }", + "", + ].join("\n"), + ); + writeReadme(root, withPublicImport("[target heading](docs/target.md#target)")); + writeFileSync( + join(root, "packages", "validation", "README.md"), + [ + "## Complete Proto Example", + "", + "```protobuf", + 'import "google/protobuf/timestamp.proto";', + "// Describes an account user.", + "message User {}", + "```", + "", + ].join("\n"), + ); + expectFailure(root, /must demonstrate \(when\)/); + + writeFileSync( + join(root, "packages", "validation", "README.md"), + [ + "## Complete Proto Example", + "", + "```protobuf", + 'import "google/protobuf/timestamp.proto";', + "// Describes an account user.", + "message User {", + "// Describes a duplicate account user.", + "message User {", + " // Stores the account expiration time.", + " google.protobuf.Timestamp expires_at = 11 [(when).in = FUTURE];", + "}", + "```", + "", + ].join("\n"), + ); + expectFailure(root, /immediately duplicate a message declaration/); + + writeFileSync( + join(root, "packages", "validation", "README.md"), + [ + "## Complete Proto Example", + "", + "```protobuf", + 'import "google/protobuf/timestamp.proto";', + "// Describes an account user.", + "message User {", + " // Stores the primitive account identifier.", + ' int32 id = 1 [(min).value = "1"];', + "", + " // Stores the account expiration time.", + " google.protobuf.Timestamp expires_at = 11 [(when).in = FUTURE];", + "}", + "```", + "", + ].join("\n"), + ); + expectFailure(root, /must declare a documented required UserId value/); + + writeFileSync( + join(root, "packages", "validation", "README.md"), + [ + "## Complete Proto Example", + "", + "```protobuf", + 'import "google/protobuf/timestamp.proto";', + '"// Identifies an account user.",', + '"message UserId {",', + '" // Stores the required account user identifier text.",', + '" string value = 1 [(required) = true];",', + '"}",', + "// Describes an account user.", + "message User {", + " // Stores the required account user identifier.", + " UserId id = 1 [(required) = true, (validate) = true];", + "", + " // Stores the account expiration time.", + " google.protobuf.Timestamp expires_at = 11 [(when).in = FUTURE];", + "}", + "```", + "", + ].join("\n"), + ); + expectFailure(root, /must declare a documented required UserId value/); + + writeFileSync( + join(root, "packages", "validation", "README.md"), + [ + "## Complete Proto Example", + "", + "```protobuf", + 'import "google/protobuf/timestamp.proto";', + "// Identifies an account user.", + "message UserId {", + " // Stores the required account user identifier text.", + " string value = 1 [(required) = true];", + "}", + "", + "// Describes an account user.", + "message User {", + " // Stores the required account user identifier.", + " UserId id = 1 [(required) = true, (validate) = true];", + "", + " // Stores the account expiration time.", + " google.protobuf.Timestamp expires_at = 11 [(when).in = FUTURE];", + "}", + "```", + "", + ].join("\n"), + ); + assert.equal(checkDocumentation({ root }).length, 4); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +{ + const root = createFixture(); + try { + const documentedFence = [ + "```protobuf", + "// Describes an account user.", + "message User {", + " // Stores the account identifier.", + " string id = 1;", + "", + " // Lists account roles.", + " enum Role {", + " // Marks a user without a selected role.", + " ROLE_UNSPECIFIED = 0;", + "", + " // Marks a regular account user.", + " ROLE_MEMBER = 1;", + " }", + "", + " // Selects one contact method.", + " oneof contact {", + " // Stores an email address.", + " string email = 2;", + "", + " // Stores a phone number.", + " string phone = 3;", + " }", + "}", + "```", + ].join("\n"); + writeReadme(root, withPublicImport(documentedFence)); + assert.equal(checkDocumentation({ root }).length, 3); + + writeReadme( + root, + withPublicImport(documentedFence.replace(" // Stores the account identifier.\n", "")), + ); + expectFailure(root, /README\.md.*field id.*leading comment/); + + writeReadme( + root, + withPublicImport(documentedFence.replace(" string id = 1;\n\n", " string id = 1;\n")), + ); + expectFailure(root, /README\.md.*field id.*exactly one empty line/); + + writeReadme( + root, + withPublicImport(documentedFence.replace(" string id = 1;\n\n", " string id = 1;\n\n\n")), + ); + expectFailure(root, /README\.md.*field id.*exactly one empty line/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +{ + const root = createFixture(); + try { + writeReadme(root, withPublicImport("# Root")); + writeFileSync(join(root, "docs", "z-last.md"), "{field}"); + writeFileSync(join(root, "docs", "a-first.md"), "{value}"); + const markdown = findMaintainedMarkdown(root); + assert.deepEqual( + markdown, + [...markdown].sort((left, right) => left.localeCompare(right)), + ); + expectFailure(root, /docs\/a-first\.md/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +{ + const root = createFixture(); + try { + writeReadme(root, withPublicImport("# Root")); + writeFileSync( + join(root, "packages", "validation", "src", "z-last.ts"), + "/** legacy adapter. */\nexport const last = 1;\n", + ); + writeFileSync( + join(root, "packages", "validation", "src", "a-first.ts"), + "/** legacy adapter. */\nexport const first = 1;\n", + ); + expectFailure(root, /packages\/validation\/src\/a-first\.ts/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +{ + const root = createFixture(); + try { + const directSetup = + "```bash\ncorepack pnpm install --frozen-lockfile\ncorepack pnpm build\ncorepack pnpm test:example\n```"; + writeRepositoryGuides(root, { + rootSetup: directSetup, + exampleSetup: directSetup, + developmentSetup: directSetup, + }); + assert.equal(checkDocumentation({ root }).length, 6); + + writeRepositoryGuides(root, { + rootSetup: "```bash\ncorepack enable pnpm\ncorepack pnpm install --frozen-lockfile\n```", + exampleSetup: directSetup, + developmentSetup: directSetup, + }); + expectFailure(root, /must not use corepack enable pnpm/); + + writeRepositoryGuides(root, { + rootSetup: + "Run `corepack pnpm install --frozen-lockfile`.\n\n```bash\npnpm install --frozen-lockfile\n```", + exampleSetup: directSetup, + developmentSetup: directSetup, + }); + expectFailure(root, /must begin with corepack pnpm/); + + writeRepositoryGuides(root, { + rootSetup: + "```bash\ncorepack pnpm install --frozen-lockfile\ncorepack pnpm test:example\n```", + exampleSetup: directSetup, + developmentSetup: directSetup, + }); + expectFailure(root, /must run corepack pnpm build before corepack pnpm test:example/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const workspaceRoot = join(import.meta.dirname, ".."); +{ + const root = createFixture(); + try { + writeReadme(root, withPublicImport("# Root")); + assert.equal(checkDocumentation({ root }).length, 3); + + const userProto = join(root, "packages", "example", "proto", "user.proto"); + const validUser = readFileSync(userProto, "utf8"); + writeFileSync( + userProto, + readFileSync(userProto, "utf8").replace("[(required) = true]; }", "; }"), + ); + expectFailure(root, /UserId\.value.*required/); + + writeFileSync(userProto, validUser); + + writeFileSync( + userProto, + `${validUser.replace( + "UserId id = 1 [(required) = true, (validate) = true]", + "string id = 1 [(required) = true, (validate) = true]", + )}message LaterUser { UserId id = 1 [(required) = true, (validate) = true]; }\n`, + ); + expectFailure(root, /User\.id.*required and validate/); + + writeFileSync(userProto, validUser); + + writeFileSync( + userProto, + validUser.replace( + "UserId id = 1 [(required) = true, (validate) = true]; }", + "string id = 1 [(required) = true, (validate) = true]; message Counterfeit { UserId id = 1 [(required) = true, (validate) = true]; } }", + ), + ); + expectFailure(root, /User\.id.*required and validate/); + + writeFileSync(userProto, validUser); + + writeFileSync( + userProto, + readFileSync(userProto, "utf8").replace(", (validate) = true]; }", "]; }"), + ); + expectFailure(root, /User\.id.*required and validate/); + + writeFileSync(userProto, validUser); + writeFileSync( + userProto, + readFileSync(userProto, "utf8").replace( + "UserId user_id = 1 [(required) = true, (validate) = true]", + "UserId user_id = 1 [(required) = true]", + ), + ); + expectFailure(root, /GetUserRequest\.user_id.*required and validate/); + + writeFileSync(userProto, validUser); + + const productProto = join(root, "packages", "example", "proto", "product.proto"); + const validProduct = readFileSync(productProto, "utf8"); + writeFileSync( + productProto, + readFileSync(productProto, "utf8").replace( + /(message ProductId \{ string value = 1 \[)\(required\) = true, /, + "$1", + ), + ); + expectFailure(root, /ProductId\.value.*required/); + + writeFileSync(productProto, validProduct); + writeFileSync( + productProto, + readFileSync(productProto, "utf8").replace("^prod-[0-9]+$", "^product-[0-9]+$"), + ); + expectFailure(root, /ProductId\.value must preserve the prod-\[0-9\]\+ pattern/); + + writeFileSync(productProto, validProduct); + writeFileSync( + productProto, + readFileSync(productProto, "utf8").replace( + "ProductId id = 1 [(required) = true, (validate) = true]", + "ProductId id = 1 [(required) = true]", + ), + ); + expectFailure(root, /Product\.id.*required and validate/); + + writeFileSync(productProto, validProduct); + writeFileSync( + productProto, + readFileSync(productProto, "utf8").replace( + "message CategoryId { string value = 1 [(required) = true]; }", + "message CategoryId { string value = 1; }", + ), + ); + expectFailure(root, /CategoryId\.value.*required/); + + writeFileSync(productProto, validProduct); + + writeFileSync( + productProto, + readFileSync(productProto, "utf8").replace( + "CategoryId id = 1 [(required) = true, (validate) = true]", + "CategoryId id = 1 [(required) = true]", + ), + ); + expectFailure(root, /Category\.id.*required and validate/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +{ + const root = createFixture(); + try { + const documentedFence = (secondComment) => + [ + "```protobuf", + "// Describes an account user.", + "message User {", + ' // Stores the account identifier as "{".', + ' string id = 1 [default = "{"];', + secondComment, + " string name = 2;", + "}", + "```", + ].join("\n"); + writeReadme(root, withPublicImport(documentedFence(" // Stores the account name."))); + expectFailure(root, /field id.*exactly one empty line/); + + writeReadme(root, withPublicImport(documentedFence(" // Stores the { account name."))); + expectFailure(root, /field id.*exactly one empty line/); + + const documentedSingleQuoteFence = documentedFence(" // Stores the account name.").replace( + 'default = "{"', + "default = '{'", + ); + writeReadme(root, withPublicImport(documentedSingleQuoteFence)); + expectFailure(root, /field id.*exactly one empty line/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const workspaceManifest = JSON.parse(readFileSync(join(workspaceRoot, "package.json"), "utf8")); + +assert.equal( + workspaceManifest.devDependencies["@bufbuild/protobuf"], + "2.13.0", + "root documentation tooling must directly own @bufbuild/protobuf", +); +assert.ok(checkDocumentation({ root: workspaceRoot }).length > 0); +console.log("Documentation checker regression tests passed."); diff --git a/scripts/check-generated-determinism.mjs b/scripts/check-generated-determinism.mjs new file mode 100644 index 0000000..893f5dd --- /dev/null +++ b/scripts/check-generated-determinism.mjs @@ -0,0 +1,158 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, rm, lstat } from "node:fs/promises"; +import { relative, resolve, basename } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const generatedRoots = [ + "packages/validation/src/generated", + "packages/validation/tests/generated", + "packages/example/src/generated", +].map((path) => resolve(repositoryRoot, path)); + +const directGenerationCommands = { + "package.json": { + generate: + "pnpm --filter @spine-event-engine/validation generate && pnpm --filter @spine-event-engine/validation generate:tests && pnpm --filter @spine-event-engine/example-smoke generate", + }, + "packages/validation/package.json": { + generate: "buf generate", + "generate:tests": "cd tests && buf generate", + }, + "packages/example/package.json": { generate: "buf generate" }, +}; + +const generatorConfigurations = { + "packages/validation/buf.gen.yaml": + "version: v2\nplugins:\n - local: protoc-gen-es\n out: src/generated\n opt:\n - target=ts\n - import_extension=js\n", + "packages/validation/tests/buf.gen.yaml": + "version: v2\nplugins:\n - local: protoc-gen-es\n out: generated\n opt:\n - target=ts\n - import_extension=js\n", + "packages/example/buf.gen.yaml": + "version: v2\nplugins:\n - local: protoc-gen-es\n out: src/generated\n opt:\n - target=ts\n - import_extension=js\n", +}; + +export function assertDirectGenerationCommands(manifests) { + for (const [path, expectedScripts] of Object.entries(directGenerationCommands)) { + const scripts = manifests[path]?.scripts; + for (const [name, expected] of Object.entries(expectedScripts)) { + if (scripts?.[name] !== expected) { + throw new Error(`${path} ${name} must be exactly ${JSON.stringify(expected)}.`); + } + for (const lifecycleName of [`pre${name}`, `post${name}`]) { + if (scripts?.[lifecycleName] !== undefined) { + throw new Error(`${path} must not define lifecycle sibling ${lifecycleName}.`); + } + } + } + } +} + +export function assertGeneratorConfiguration(path, source) { + const expected = generatorConfigurations[path]; + if (source !== expected) { + throw new Error( + `${path} must use the direct ESM Buf generator configuration with import_extension=js.`, + ); + } +} + +function assertSafeGeneratedPath(path) { + const relativePath = relative(repositoryRoot, path); + if ( + relativePath.startsWith("..") || + !relativePath.startsWith("packages/") || + basename(path) !== "generated" + ) { + throw new Error(`Refusing to remove unsafe generated path: ${path}`); + } +} + +async function listFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const path = resolve(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Generated output must not contain symlinks: ${path}`); + } + if (entry.isDirectory()) { + files.push(...(await listFiles(path))); + } else if (entry.isFile()) { + files.push(path); + } + } + return files; +} + +async function treeDigest() { + const digest = createHash("sha256"); + for (const root of generatedRoots) { + const metadata = await lstat(root); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`Expected a generated directory: ${root}`); + } + const files = (await listFiles(root)).sort(); + if (files.length === 0) { + throw new Error(`Generated directory is empty: ${root}`); + } + for (const file of files) { + digest.update(relative(repositoryRoot, file)); + digest.update(await readFile(file)); + } + } + return digest.digest("hex"); +} + +async function assertDirectGenerationConfiguration() { + const manifests = {}; + for (const path of Object.keys(directGenerationCommands)) { + manifests[path] = JSON.parse(await readFile(resolve(repositoryRoot, path), "utf8")); + } + assertDirectGenerationCommands(manifests); + + for (const path of Object.keys(generatorConfigurations)) { + assertGeneratorConfiguration(path, await readFile(resolve(repositoryRoot, path), "utf8")); + } + + for (const root of generatedRoots) { + for (const file of await listFiles(root)) { + if (!file.endsWith(".ts")) continue; + const contents = await readFile(file, "utf8"); + if (/from ["']\.\.?\/[^"']+(?<!\.js)["']/.test(contents)) { + throw new Error(`Generated relative import is missing its .js extension: ${file}`); + } + } + } +} + +async function main() { + const firstDigest = await treeDigest(); + await assertDirectGenerationConfiguration(); + for (const root of generatedRoots) { + assertSafeGeneratedPath(root); + await rm(root, { recursive: true, force: true }); + } + + const generation = spawnSync("pnpm", ["generate"], { + cwd: repositoryRoot, + encoding: "utf8", + stdio: "inherit", + }); + if (generation.status !== 0) { + process.exit(generation.status ?? 1); + } + + const secondDigest = await treeDigest(); + await assertDirectGenerationConfiguration(); + if (firstDigest !== secondDigest) { + console.error( + `Generated output changed across identical runs: ${firstDigest} != ${secondDigest}`, + ); + process.exit(1); + } + + console.log(`Generated output is deterministic (${secondDigest}).`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/check-generated-determinism.test.mjs b/scripts/check-generated-determinism.test.mjs new file mode 100644 index 0000000..76b4e0d --- /dev/null +++ b/scripts/check-generated-determinism.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertDirectGenerationCommands, + assertGeneratorConfiguration, +} from "./check-generated-determinism.mjs"; + +test("accepts only the direct Buf generation commands", () => { + assert.doesNotThrow(() => + assertDirectGenerationCommands({ + "package.json": { + scripts: { + generate: + "pnpm --filter @spine-event-engine/validation generate && pnpm --filter @spine-event-engine/validation generate:tests && pnpm --filter @spine-event-engine/example-smoke generate", + }, + }, + "packages/validation/package.json": { + scripts: { + generate: "buf generate", + "generate:tests": "cd tests && buf generate", + }, + }, + "packages/example/package.json": { scripts: { generate: "buf generate" } }, + }), + ); +}); + +test("rejects a renamed post-generation transform", () => { + assert.throws( + () => + assertDirectGenerationCommands({ + "package.json": { + scripts: { generate: "pnpm generate && node scripts/rewrite-output.mjs" }, + }, + "packages/validation/package.json": { + scripts: { + generate: "buf generate", + "generate:tests": "cd tests && buf generate", + }, + }, + "packages/example/package.json": { scripts: { generate: "buf generate" } }, + }), + /must be exactly/, + ); +}); + +test("rejects lifecycle siblings for protected generation commands", () => { + const manifests = { + "package.json": { + scripts: { + generate: + "pnpm --filter @spine-event-engine/validation generate && pnpm --filter @spine-event-engine/validation generate:tests && pnpm --filter @spine-event-engine/example-smoke generate", + postgenerate: "node scripts/rewrite-output.mjs", + }, + }, + "packages/validation/package.json": { + scripts: { + generate: "buf generate", + "generate:tests": "cd tests && buf generate", + "postgenerate:tests": "node scripts/rewrite-test-output.mjs", + }, + }, + "packages/example/package.json": { scripts: { generate: "buf generate" } }, + }; + + assert.throws(() => assertDirectGenerationCommands(manifests), /lifecycle sibling/); +}); + +test("requires the ESM import extension in each validation generator config", () => { + assert.doesNotThrow(() => + assertGeneratorConfiguration( + "packages/validation/buf.gen.yaml", + "version: v2\nplugins:\n - local: protoc-gen-es\n out: src/generated\n opt:\n - target=ts\n - import_extension=js\n", + ), + ); + assert.throws( + () => + assertGeneratorConfiguration( + "packages/validation/buf.gen.yaml", + "version: v2\nplugins:\n - local: protoc-gen-es\n out: src/generated\n opt:\n - target=ts\n", + ), + /import_extension=js/, + ); +}); diff --git a/scripts/check-git-diff.mjs b/scripts/check-git-diff.mjs new file mode 100644 index 0000000..10efa7c --- /dev/null +++ b/scripts/check-git-diff.mjs @@ -0,0 +1,41 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); + +function git(args, allowNoMatches = false) { + const result = spawnSync("git", args, { + cwd: repositoryRoot, + encoding: "utf8", + }); + if (result.status !== 0 && !(allowNoMatches && result.status === 1)) { + process.stderr.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + process.exit(result.status ?? 1); + } + return result.stdout ?? ""; +} + +git(["diff", "--check"]); + +const legacyNames = git( + [ + "grep", + "-n", + "-E", + "@spine-event-engine/validation-ts|spine-validation-ts|validation-ts-workspace|2\\.0\\.0-snapshot\\.4", + "--", + ".", + ":(exclude)scripts/check-git-diff.mjs", + ":(exclude)build-protocol/work-logs/**", + ], + true, +); +if (legacyNames.trim().length > 0) { + console.error("Legacy package names or versions remain:"); + console.error(legacyNames); + process.exit(1); +} + +console.log("Git whitespace and legacy-name checks passed."); diff --git a/scripts/check-node-version.mjs b/scripts/check-node-version.mjs new file mode 100644 index 0000000..fd4d481 --- /dev/null +++ b/scripts/check-node-version.mjs @@ -0,0 +1,18 @@ +const minimum = [24, 0, 0]; +const current = process.versions.node.split(".").map(Number); + +function isAtLeast(actual, expected) { + return expected.every((part, index) => { + const prefixMatches = expected + .slice(0, index) + .every((value, prefixIndex) => actual[prefixIndex] === value); + return !prefixMatches || actual[index] >= part; + }); +} + +if (!isAtLeast(current, minimum)) { + console.error(`Node ${process.versions.node} is unsupported; use Node >=24.0.0.`); + process.exit(1); +} + +console.log(`Node ${process.versions.node} satisfies the >=24.0.0 requirement.`); diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs new file mode 100644 index 0000000..a082d03 --- /dev/null +++ b/scripts/check-package.mjs @@ -0,0 +1,179 @@ +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const temporaryRoot = await mkdtemp(join(tmpdir(), "validation-package-check-")); + +function run(command, args, cwd, capture = false) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + env: { + ...process.env, + npm_config_cache: join(temporaryRoot, "npm-cache"), + }, + stdio: capture ? "pipe" : "inherit", + }); + if (result.status !== 0) { + if (capture) { + process.stderr.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + } + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status}`); + } + return result.stdout ?? ""; +} + +try { + const output = run( + "pnpm", + [ + "--filter", + "@spine-event-engine/validation", + "pack", + `--pack-destination=${temporaryRoot}`, + "--json", + ], + repositoryRoot, + true, + ); + const parsedPackResult = JSON.parse(output); + const packResult = Array.isArray(parsedPackResult) ? parsedPackResult[0] : parsedPackResult; + const paths = new Set(packResult.files.map((file) => file.path)); + const required = [ + "package.json", + "README.md", + "dist/index.js", + "dist/index.d.ts", + "proto/spine/options.proto", + ]; + for (const path of required) { + if (!paths.has(path)) { + throw new Error(`Packed package is missing ${path}`); + } + } + + const forbidden = [...paths].filter( + (path) => + path.startsWith("src/") || + path.startsWith("tests/") || + path.startsWith("coverage/") || + path === "docs" || + path.startsWith("docs/"), + ); + if (forbidden.length > 0) { + throw new Error(`Packed package contains forbidden paths: ${forbidden.join(", ")}`); + } + + const archives = (await readdir(temporaryRoot)).filter((name) => name.endsWith(".tgz")); + if (archives.length !== 1) { + throw new Error(`Expected one package archive, found ${archives.length}`); + } + + const consumerRoot = join(temporaryRoot, "consumer"); + await mkdir(consumerRoot); + await writeFile(join(temporaryRoot, "package.json"), JSON.stringify({ private: true }, null, 2)); + const archive = join(temporaryRoot, archives[0]); + const protobufRuntime = resolve( + repositoryRoot, + "packages/validation/node_modules/@bufbuild/protobuf", + ); + await writeFile( + join(consumerRoot, "package.json"), + JSON.stringify({ private: true, type: "module" }, null, 2), + ); + run("pnpm", ["add", "--ignore-scripts", archive, protobufRuntime], consumerRoot); + + const typeSmokePath = join(consumerRoot, "smoke.ts"); + await writeFile( + typeSmokePath, + [ + 'import type { Message } from "@bufbuild/protobuf";', + 'import type { GenMessage } from "@bufbuild/protobuf/codegenv2";', + 'import * as validation from "@spine-event-engine/validation";', + 'import { ValidationConfigurationError, validate, Violations, type ConstraintViolation } from "@spine-event-engine/validation";', + "", + 'type SmokeMessage = Message<"smoke.Message"> & { text: string };', + 'type OtherMessage = Message<"smoke.Other"> & { count: number };', + "type SmokeSchema = GenMessage<SmokeMessage>;", + "type OtherSchema = GenMessage<OtherMessage>;", + "declare const schema: SmokeSchema;", + "declare const otherSchema: OtherSchema;", + "declare const message: SmokeMessage;", + "declare const otherMessage: OtherMessage;", + "declare const violation: ConstraintViolation;", + "", + "const violations = validate(schema, message);", + "Violations.formatAll(violations);", + "Violations.formatMessage(violation);", + "Violations.failurePath(violation);", + 'const configurationError = new ValidationConfigurationError({ code: "INVALID_OPTION_VALUE", option: "range", typeName: "smoke.Message" });', + "const configurationCode: string = configurationError.code;", + "void configurationCode;", + "", + "// @ts-expect-error validate requires a message matching the schema.", + "validate(schema, otherMessage);", + "// @ts-expect-error validate requires a schema matching the message.", + "validate(otherSchema, message);", + "// @ts-expect-error Removed collection formatter is not public.", + "validation.formatViolations(violations);", + "// @ts-expect-error Removed template formatter is not public.", + "validation.formatTemplateString(violation.message);", + "", + ].join("\n"), + ); + await writeFile( + join(consumerRoot, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + strict: true, + skipLibCheck: true, + target: "ES2024", + }, + files: ["smoke.ts"], + }, + null, + 2, + ), + ); + run(resolve(repositoryRoot, "node_modules/.bin/tsc"), ["-p", "tsconfig.json"], consumerRoot); + + const smokePath = join(consumerRoot, "smoke.mjs"); + await writeFile( + smokePath, + [ + 'import * as validation from "@spine-event-engine/validation";', + 'for (const name of ["validate", "ValidationConfigurationError"]) {', + ' if (typeof validation[name] !== "function") {', + " throw new Error(`Expected callable runtime export: ${name}`);", + " }", + "}", + 'if (typeof validation.Violations !== "object" || validation.Violations === null) {', + ' throw new Error("Expected runtime export Violations to be an object");', + "}", + 'for (const name of ["formatAll", "formatMessage", "failurePath"]) {', + ' if (typeof validation.Violations[name] !== "function") {', + " throw new Error(`Expected Violations.${name} to be callable`);", + " }", + "}", + 'for (const name of ["formatViolations", "formatTemplateString"]) {', + " if (name in validation) {", + " throw new Error(`Removed runtime export is present: ${name}`);", + " }", + "}", + 'console.log("Consumer loaded the packed ESM API.");', + "", + ].join("\n"), + ); + run(process.execPath, [smokePath], consumerRoot); + console.log(`Packed ${paths.size} files and verified an installed consumer.`); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/scripts/check-pnpm-action-setup.test.mjs b/scripts/check-pnpm-action-setup.test.mjs new file mode 100644 index 0000000..08e2bd0 --- /dev/null +++ b/scripts/check-pnpm-action-setup.test.mjs @@ -0,0 +1,298 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { extname, join, resolve } from "node:path"; +import test from "node:test"; +import { parseDocument } from "yaml"; + +const pnpmActionSetup = "pnpm/action-setup"; + +function actionSetupReferences(source, workflow) { + let parsed; + try { + const document = parseDocument(source); + if (document.errors.length > 0) + throw new Error(document.errors.map((error) => error.message).join("; ")); + parsed = document.toJS(); + } catch (error) { + throw new Error(`Unable to parse workflow ${workflow}: ${error.message}`, { cause: error }); + } + + const references = []; + const jobs = parsed?.jobs; + if (jobs === null || typeof jobs !== "object" || Array.isArray(jobs)) return references; + + for (const job of Object.values(jobs)) { + if (job === null || typeof job !== "object" || Array.isArray(job)) continue; + if (typeof job.uses === "string" && job.uses.startsWith(pnpmActionSetup)) + references.push(job.uses); + if (!Array.isArray(job.steps)) continue; + for (const step of job.steps) { + if ( + step !== null && + typeof step === "object" && + !Array.isArray(step) && + typeof step.uses === "string" && + step.uses.startsWith(pnpmActionSetup) + ) + references.push(step.uses); + } + } + return references; +} + +function findWorkflowFiles(root) { + const workflows = join(root, ".github", "workflows"); + if (!readdirSync(workflows, { withFileTypes: true }).some((entry) => entry.isFile())) + throw new Error(`No workflow files found in ${workflows}`); + + const files = readdirSync(workflows, { withFileTypes: true }) + .filter((entry) => entry.isFile() && [".yml", ".yaml"].includes(extname(entry.name))) + .map((entry) => join(workflows, entry.name)); + if (files.length === 0) throw new Error(`No workflow files found in ${workflows}`); + return files; +} + +function assertPnpmActionSetupV6({ root }) { + const references = []; + for (const workflow of findWorkflowFiles(root)) { + const source = readFileSync(workflow, "utf8"); + for (const value of actionSetupReferences(source, workflow)) + references.push({ value, workflow }); + } + + if (references.length === 0) throw new Error("No pnpm/action-setup references found"); + for (const reference of references) { + assert.equal( + reference.value, + "pnpm/action-setup@v6", + `${reference.workflow} must use pnpm/action-setup@v6`, + ); + } +} + +function createFixture() { + const root = mkdtempSync(join(tmpdir(), "validation-pnpm-action-setup-")); + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + return root; +} + +function writeWorkflow(root, filename, source) { + writeFileSync(join(root, ".github", "workflows", filename), source); +} + +function writeStepsWorkflow(root, filename, steps) { + const indentedSteps = steps + .split("\n") + .map((line) => (line.length === 0 ? line : ` ${line}`)) + .join("\n"); + writeWorkflow( + root, + filename, + `name: Fixture\non: push\njobs:\n verify:\n runs-on: ubuntu-latest\n steps:\n${indentedSteps}\n`, + ); +} + +test("accepts quoted and unquoted v6 references in yml and yaml workflows", () => { + const root = createFixture(); + try { + writeStepsWorkflow(root, "verify.yml", "- uses: pnpm/action-setup@v6"); + writeStepsWorkflow(root, "publish.yaml", "- uses: 'pnpm/action-setup@v6'"); + assert.doesNotThrow(() => assertPnpmActionSetupV6({ root })); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects every pnpm action-setup reference other than v6", () => { + const root = createFixture(); + try { + writeStepsWorkflow(root, "verify.yml", '- uses: "pnpm/action-setup@v4"'); + assert.throws(() => assertPnpmActionSetupV6({ root }), /pnpm\/action-setup@v6/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a bare pnpm action-setup reference alongside v6", () => { + const root = createFixture(); + try { + writeStepsWorkflow( + root, + "verify.yml", + "- uses: pnpm/action-setup@v6\n- uses: pnpm/action-setup", + ); + assert.throws(() => assertPnpmActionSetupV6({ root }), /pnpm\/action-setup@v6/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("ignores pnpm action-setup text in a literal run block", () => { + const root = createFixture(); + try { + writeStepsWorkflow( + root, + "verify.yml", + "- uses: pnpm/action-setup@v6\n- run: |2-\n - uses: pnpm/action-setup@v4", + ); + assert.doesNotThrow(() => assertPnpmActionSetupV6({ root })); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a non-v6 flow-mapping action field after another key", () => { + const root = createFixture(); + try { + writeStepsWorkflow( + root, + "verify.yml", + "- uses: pnpm/action-setup@v6\n- { name: Activate pnpm, uses: pnpm/action-setup@v4, with: { version: 11.9.0 } }", + ); + assert.throws(() => assertPnpmActionSetupV6({ root }), /pnpm\/action-setup@v6/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("ignores pnpm action-setup text in comments", () => { + const root = createFixture(); + try { + writeStepsWorkflow( + root, + "verify.yml", + "- uses: pnpm/action-setup@v6\n# - uses: pnpm/action-setup@v4", + ); + assert.doesNotThrow(() => assertPnpmActionSetupV6({ root })); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("ignores pnpm action-setup text in folded blocks, inline comments, and quoted scalars", () => { + const root = createFixture(); + try { + writeStepsWorkflow( + root, + "verify.yml", + '- uses: pnpm/action-setup@v6 # pnpm/action-setup@v4\n- run: >-\n pnpm/action-setup@v4\n- name: "uses: pnpm/action-setup@v4"', + ); + assert.doesNotThrow(() => assertPnpmActionSetupV6({ root })); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects non-v6 uses fields in multiline and quoted-key mappings", () => { + const root = createFixture(); + try { + writeStepsWorkflow( + root, + "verify.yml", + '- uses: pnpm/action-setup@v6\n- {\n name: Activate pnpm,\n uses: pnpm/action-setup@v4\n }\n- "uses": pnpm/action-setup@v4', + ); + assert.throws(() => assertPnpmActionSetupV6({ root }), /pnpm\/action-setup@v6/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a non-v6 uses field with a quoted key", () => { + const root = createFixture(); + try { + writeStepsWorkflow( + root, + "verify.yml", + '- uses: pnpm/action-setup@v6\n- "uses": pnpm/action-setup@v4', + ); + assert.throws(() => assertPnpmActionSetupV6({ root }), /pnpm\/action-setup@v6/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects non-v6 uses fields in nested flow-style jobs", () => { + const root = createFixture(); + try { + writeWorkflow( + root, + "verify.yml", + "name: Fixture\non: push\njobs: { verify: { runs-on: ubuntu-latest, steps: [ { uses: pnpm/action-setup@v6 }, { uses: pnpm/action-setup@v4 } ] } }\n", + ); + assert.throws(() => assertPnpmActionSetupV6({ root }), /pnpm\/action-setup@v6/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a non-v6 reusable-workflow job uses field", () => { + const root = createFixture(); + try { + writeWorkflow( + root, + "reusable.yml", + "name: Fixture\non: push\njobs:\n reusable:\n uses: pnpm/action-setup@v4\n", + ); + assert.throws(() => assertPnpmActionSetupV6({ root }), /pnpm\/action-setup@v6/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("ignores env uses values outside GitHub Actions action locations", () => { + const root = createFixture(); + try { + writeWorkflow( + root, + "verify.yml", + "name: Fixture\non: push\njobs:\n verify:\n runs-on: ubuntu-latest\n env:\n uses: pnpm/action-setup@v4\n steps:\n - uses: pnpm/action-setup@v6\n", + ); + assert.doesNotThrow(() => assertPnpmActionSetupV6({ root })); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("reports the workflow path when YAML parsing fails", () => { + const root = createFixture(); + try { + writeWorkflow( + root, + "verify.yml", + "name: Fixture\non: push\njobs:\n verify:\n runs-on: ubuntu-latest\n steps:\n - uses: pnpm/action-setup@v6\n - [\n", + ); + assert.throws( + () => assertPnpmActionSetupV6({ root }), + /Unable to parse workflow .*verify\.yml/i, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("fails when no workflow files exist", () => { + const root = createFixture(); + try { + assert.throws(() => assertPnpmActionSetupV6({ root }), /No workflow files found/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("fails when workflows omit pnpm action-setup", () => { + const root = createFixture(); + try { + writeStepsWorkflow(root, "verify.yaml", "- uses: actions/checkout@v6"); + assert.throws( + () => assertPnpmActionSetupV6({ root }), + /No pnpm\/action-setup references found/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("the repository workflows use pnpm action-setup v6", () => { + assertPnpmActionSetupV6({ root: resolve(import.meta.dirname, "..") }); +}); diff --git a/scripts/check-source-conventions.mjs b/scripts/check-source-conventions.mjs new file mode 100644 index 0000000..b776f66 --- /dev/null +++ b/scripts/check-source-conventions.mjs @@ -0,0 +1,600 @@ +import { existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; +import ts from "typescript"; + +const TYPE_SCRIPT_ROOTS = ["packages/validation", "packages/example"]; +const PRODUCTION_SOURCE_ROOTS = ["packages/validation/src", "packages/example/src"]; +const PROTO_ROOTS = [ + "packages/validation/proto", + "packages/validation/tests/proto", + "packages/example/proto", +]; +const EXCLUDED_DIRECTORY_NAMES = new Set(["coverage", "dist", "generated", "node_modules"]); +const FORBIDDEN_TSDOC = + /\b(?:t-\d+|task|agent|workflow|chat|transcript|implementation[ -]history|implemented|legacy|historical)\b/i; +const FILLER_TSDOC = [ + /\bdescribes the purpose of\b/i, + /\bprocesses inputs for\b/i, + /\bsupplies the [^\n]* input\b/i, + /\breturns the computed result\b/i, + /\bdescribes the [^\n]*\b(?:value|data)\b/i, + /\brepresents the [^\n]*\bdata\b/i, + /\bprovides helper methods\b/i, + /\blegacy (?:adapter|behavior|exception)\b/i, + /\bfrozen (?:proto|contract)\b/i, + /\bshared-envelope\b/i, + /\b(?:documents|records|preserves) (?:the )?(?:source|contract) provenance\b/i, + /\b(?:documents|records|preserves) (?:the )?(?:source|contract) intake\b/i, +]; + +/** Counts semantic words in an identifier. */ +export function countSemanticWords(name) { + return name + .replace(/([a-z\d])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(/[\s_-]+/) + .filter(Boolean).length; +} + +/** Finds source files beneath a root while excluding generated and dependency output. */ +async function findFiles(rootDir, roots, suffix) { + const files = []; + async function visit(relativeDirectory) { + const directory = join(rootDir, relativeDirectory); + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + for (const entry of entries) { + const entryPath = join(relativeDirectory, entry.name); + if (entry.isDirectory()) { + if (!EXCLUDED_DIRECTORY_NAMES.has(entry.name)) await visit(entryPath); + } else if (entry.isFile() && entry.name.endsWith(suffix)) { + files.push(entryPath); + } + } + } + await Promise.all(roots.map(visit)); + return files.sort(); +} + +/** Returns normalized prose from a documentation comment. */ +function normalizedCommentProse(comment) { + return comment + .replace(/^\/\*\*?|\*\/$/g, "") + .split("\n") + .map((line) => line.replace(/^\s*\*?\s?/, "").trim()) + .filter((line) => line && !line.startsWith("@")) + .join(" ") + .trim(); +} + +/** Reads meaningful JSDoc blocks directly leading a declaration. */ +function leadingJsDocs(sourceFile, node) { + const ranges = ts.getLeadingCommentRanges(sourceFile.text, node.getFullStart()) ?? []; + return ranges + .filter(({ pos, end }) => sourceFile.text.slice(pos, end).startsWith("/**")) + .map((range) => ({ ...range, text: sourceFile.text.slice(range.pos, range.end) })) + .filter((range) => normalizedCommentProse(range.text).length > 0); +} + +/** Reads the last JSDoc block directly leading a declaration. */ +function leadingJsDoc(sourceFile, node) { + return leadingJsDocs(sourceFile, node).at(-1)?.text; +} + +/** Adds a normalized diagnostic. */ +function addFinding(findings, path, sourceFile, position, rule, message) { + const { line } = sourceFile.getLineAndCharacterOfPosition(position); + findings.push({ line: line + 1, message, path, rule }); +} + +/** Returns the identifier text when a node has an identifier name. */ +function nodeName(node) { + return node.name && ts.isIdentifier(node.name) ? node.name.text : undefined; +} + +/** Determines whether a function declaration is the deliberate validate exception. */ +function isAllowedValidate(path, node) { + return ( + path === "packages/validation/src/validation.ts" && + ts.isFunctionDeclaration(node) && + node.name?.text === "validate" && + Boolean(node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) + ); +} + +/** Determines whether a callable has a non-void return based on syntax. */ +function hasNonVoidReturn(node) { + if (node.type) + return ( + node.type.kind !== ts.SyntaxKind.VoidKeyword && node.type.kind !== ts.SyntaxKind.NeverKeyword + ); + if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) return true; + let hasValue = false; + const visit = (child) => { + if (hasValue || ts.isFunctionLike(child)) return; + if (ts.isReturnStatement(child) && child.expression) hasValue = true; + ts.forEachChild(child, visit); + }; + if (node.body) ts.forEachChild(node.body, visit); + return hasValue; +} + +/** Checks the TSDoc contract for a declaration that requires documentation. */ +function checkDocumentation(findings, path, sourceFile, node, callable = false) { + const documentationTarget = + ts.isVariableDeclaration(node) && ts.isVariableStatement(node.parent.parent) + ? node.parent.parent + : ts.isArrowFunction(node) || ts.isFunctionExpression(node) + ? node.parent + : node; + const comment = leadingJsDoc(sourceFile, documentationTarget); + const name = + nodeName(node) ?? + nodeName(documentationTarget) ?? + (ts.isConstructorDeclaration(node) ? "constructor" : "declaration"); + if (!comment) { + addFinding( + findings, + path, + sourceFile, + node.getStart(sourceFile), + "tsdoc-missing", + `Missing TSDoc for ${name}.`, + ); + return; + } + if (!callable) return; + const documentationText = comment.replace(/^\/\*\*|\*\/$/g, ""); + const description = normalizedCommentProse(comment); + const firstWord = description?.match(/^[A-Za-z]+/)?.[0]?.toLowerCase(); + if (!firstWord || !(["is", "has", "does"].includes(firstWord) || firstWord.endsWith("s"))) { + addFinding( + findings, + path, + sourceFile, + node.getStart(sourceFile), + "tsdoc-callable-summary", + `TSDoc summary for ${name} must start with a third-person verb.`, + ); + } + const parameterTags = new Map( + [...documentationText.matchAll(/@param\s+(?:\{[^}]*\}\s+)?([\w$]+)([^\r\n@]*)/g)].map( + (match) => [match[1], match[2].trim()], + ), + ); + for (const parameter of node.parameters ?? []) { + if (ts.isIdentifier(parameter.name) && !parameterTags.has(parameter.name.text)) { + addFinding( + findings, + path, + sourceFile, + parameter.getStart(sourceFile), + "tsdoc-missing-param", + `Missing @param for ${parameter.name.text}.`, + ); + } else if (ts.isIdentifier(parameter.name) && !parameterTags.get(parameter.name.text)) { + addFinding( + findings, + path, + sourceFile, + parameter.getStart(sourceFile), + "tsdoc-missing-param-description", + `Missing @param description for ${parameter.name.text}.`, + ); + } + } + if (hasNonVoidReturn(node)) { + const returns = documentationText.match(/@returns?\b([^\r\n@]*)/); + if (!returns) { + addFinding( + findings, + path, + sourceFile, + node.getStart(sourceFile), + "tsdoc-missing-returns", + `Missing @returns for ${name}.`, + ); + } else if (!returns[1].trim()) { + addFinding( + findings, + path, + sourceFile, + node.getStart(sourceFile), + "tsdoc-missing-returns-description", + `Missing @returns description for ${name}.`, + ); + } + } +} + +/** Checks every TSDoc block, including blocks detached from a declaration. */ +function checkTsDocBlocks(findings, path, sourceFile) { + for (const match of sourceFile.text.matchAll(/\/\*\*[\s\S]*?\*\//g)) { + const comment = match[0]; + const position = match.index; + if (FORBIDDEN_TSDOC.test(comment)) + addFinding( + findings, + path, + sourceFile, + position, + "tsdoc-forbidden-wording", + "TSDoc contains workflow or history wording.", + ); + if (FILLER_TSDOC.some((pattern) => pattern.test(comment))) + addFinding( + findings, + path, + sourceFile, + position, + "tsdoc-filler-wording", + "TSDoc uses generic or implementation-history wording.", + ); + } +} + +/** Checks named TypeScript identifiers against the semantic-word convention. */ +function checkName(findings, path, sourceFile, identifier) { + if (!identifier || !ts.isIdentifier(identifier)) return; + const count = countSemanticWords(identifier.text); + if (count > 4) { + addFinding( + findings, + path, + sourceFile, + identifier.getStart(sourceFile), + "ts-name-too-long", + `${identifier.text} has ${count} semantic words.`, + ); + } +} + +/** Checks TypeScript declarations in one source file. */ +function checkTypeScriptFile(findings, path, contents, productionSource) { + const sourceFile = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true); + let allowedValidateCount = 0; + if (productionSource) checkTsDocBlocks(findings, path, sourceFile); + const visit = (node) => { + const moduleScoped = node.parent === sourceFile; + const callable = ts.isFunctionLike(node); + const named = nodeName(node); + if (named) checkName(findings, path, sourceFile, node.name); + + const isNamedObject = + ts.isVariableDeclaration(node) && + node.initializer && + ts.isObjectLiteralExpression(node.initializer) && + node.parent.parent.parent === sourceFile; + const isNamedObjectMember = + node.parent && + ts.isObjectLiteralExpression(node.parent) && + ts.isVariableDeclaration(node.parent.parent) && + node.parent.parent.initializer === node.parent && + node.parent.parent.parent.parent.parent === sourceFile; + const functionValuedProperty = + (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && + (ts.isPropertyDeclaration(node.parent) || ts.isPropertyAssignment(node.parent)); + const documentationDeclaration = + (moduleScoped && + (ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node))) || + isNamedObject || + ts.isMethodDeclaration(node) || + ts.isMethodSignature(node) || + ts.isPropertyDeclaration(node) || + ts.isPropertySignature(node) || + ts.isConstructorDeclaration(node) || + ts.isEnumMember(node) || + (isNamedObjectMember && + !ts.isComputedPropertyName(node.name) && + (ts.isPropertyAssignment(node) || + ts.isShorthandPropertyAssignment(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node))) || + functionValuedProperty; + if (productionSource && documentationDeclaration) { + const documentationTarget = + ts.isVariableDeclaration(node) && ts.isVariableStatement(node.parent.parent) + ? node.parent.parent + : node; + const docs = leadingJsDocs(sourceFile, documentationTarget); + if (docs.length > 1) + addFinding( + findings, + path, + sourceFile, + docs[1].pos, + "tsdoc-duplicate", + `Consecutive TSDoc blocks document ${named ?? "this declaration"}.`, + ); + checkDocumentation( + findings, + path, + sourceFile, + node, + callable || + functionValuedProperty || + ts.isConstructorDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isMethodSignature(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node), + ); + } + + if ( + productionSource && + moduleScoped && + ts.isFunctionDeclaration(node) && + !isAllowedValidate(path, node) + ) { + addFinding( + findings, + path, + sourceFile, + node.getStart(sourceFile), + "ts-standalone-function", + `Module-scope function ${node.name?.text ?? "<anonymous>"} is not allowed.`, + ); + } + if (productionSource && moduleScoped && isAllowedValidate(path, node)) + allowedValidateCount += 1; + if ( + productionSource && + ts.isVariableDeclaration(node) && + node.parent.parent.parent === sourceFile && + node.initializer && + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + ) { + addFinding( + findings, + path, + sourceFile, + node.getStart(sourceFile), + "ts-standalone-function", + `Module-scope function-valued variable ${nodeName(node) ?? "<anonymous>"} is not allowed.`, + ); + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + if ( + productionSource && + path === "packages/validation/src/validation.ts" && + allowedValidateCount !== 1 + ) + addFinding( + findings, + path, + sourceFile, + 0, + "ts-validate-entry-point", + "packages/validation/src/validation.ts must export validate exactly once.", + ); +} + +/** Tokenizes the Proto grammar subset required for declaration conventions. */ +function tokenizeProto(contents) { + const tokens = []; + for (let index = 0; index < contents.length;) { + const remaining = contents.slice(index); + if (/^\s/.test(remaining)) { + index += 1; + continue; + } + if (remaining.startsWith("//")) { + const end = contents.indexOf("\n", index); + tokens.push({ + position: index, + text: contents.slice(index, end === -1 ? contents.length : end), + type: "comment", + }); + index = end === -1 ? contents.length : end; + continue; + } + if (remaining.startsWith("/*")) { + const end = contents.indexOf("*/", index + 2); + const final = end === -1 ? contents.length : end + 2; + tokens.push({ position: index, text: contents.slice(index, final), type: "comment" }); + index = final; + continue; + } + if (remaining.startsWith('"') || remaining.startsWith("'")) { + const quote = remaining[0]; + let end = index + 1; + while (end < contents.length) { + if (contents[end] === "\\") end += 2; + else if (contents[end++] === quote) break; + } + tokens.push({ position: index, text: contents.slice(index, end), type: "string" }); + index = end; + continue; + } + const identifier = remaining.match(/^[A-Za-z_][A-Za-z0-9_]*/); + if (identifier) { + tokens.push({ position: index, text: identifier[0], type: "identifier" }); + index += identifier[0].length; + continue; + } + tokens.push({ position: index, text: contents[index], type: "punctuation" }); + index += 1; + } + return tokens; +} + +/** Checks a Proto declaration and records its comment and name diagnostics. */ +function checkProtoDeclaration(findings, path, sourceFile, token, comment) { + if (!comment) + addFinding( + findings, + path, + sourceFile, + token.position, + "proto-missing-comment", + `Missing comment for ${token.text}.`, + ); + const count = countSemanticWords(token.text); + if (count > 4) + addFinding( + findings, + path, + sourceFile, + token.position, + "proto-name-too-long", + `${token.text} has ${count} semantic words.`, + ); +} + +/** Determines whether a Proto comment begins its own meaningful documentation line. */ +function isLeadingProtoComment(contents, comment) { + const lineStart = contents.lastIndexOf("\n", comment.position) + 1; + const prose = comment.text + .replace(/^\/\/|^\/\*|\*\/$/g, "") + .replace(/^\s*\*?\s?/gm, "") + .trim(); + return contents.slice(lineStart, comment.position).trim().length === 0 && prose.length > 0; +} + +/** Checks project-owned Proto declarations using the small declaration tokenizer. */ +function checkProtoFile(findings, path, contents) { + const sourceFile = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true); + const tokens = tokenizeProto(contents); + function statementEnd(start) { + const delimiters = []; + const closing = new Map([ + [")", "("], + ["]", "["], + ["}", "{"], + ]); + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index].text; + if (["(", "[", "{"].includes(token)) delimiters.push(token); + else if (closing.has(token) && closing.get(token) === delimiters.at(-1)) delimiters.pop(); + else if (token === ";" && delimiters.length === 0) return index; + else if (token === "}" && delimiters.length === 0) return index; + } + return tokens.length; + } + function parseBody(start, context) { + let index = start; + let comment; + while (index < tokens.length && tokens[index].text !== "}") { + const token = tokens[index]; + if (token.type === "comment") { + comment = isLeadingProtoComment(contents, token) ? token : undefined; + index += 1; + continue; + } + if ( + ["message", "enum", "oneof"].includes(token.text) && + tokens[index + 1]?.type === "identifier" + ) { + const name = tokens[index + 1]; + checkProtoDeclaration(findings, path, sourceFile, name, comment); + comment = undefined; + index += 2; + while (index < tokens.length && tokens[index].text !== "{" && tokens[index].text !== ";") + index += 1; + if (tokens[index]?.text === "{") index = parseBody(index + 1, token.text) + 1; + else index += 1; + continue; + } + if (token.text === "option") { + index = statementEnd(index); + comment = undefined; + index += 1; + continue; + } + if ( + (context === "message" || context === "oneof") && + token.text !== "option" && + (token.type === "identifier" || token.text === "map") + ) { + const end = statementEnd(index); + const equals = tokens.slice(index, end).findIndex((candidate) => candidate.text === "="); + if (equals >= 0) { + const beforeEquals = tokens + .slice(index, index + equals) + .filter((candidate) => candidate.type === "identifier"); + const name = beforeEquals.at(-1); + if (name) checkProtoDeclaration(findings, path, sourceFile, name, comment); + comment = undefined; + index = end + 1; + continue; + } + } + if (context === "enum" && token.type === "identifier" && tokens[index + 1]?.text === "=") { + checkProtoDeclaration(findings, path, sourceFile, token, comment); + comment = undefined; + while (index < tokens.length && tokens[index].text !== ";") index += 1; + index += 1; + continue; + } + comment = undefined; + index += 1; + } + return index; + } + parseBody(0, "file"); +} + +/** Reads immutable upstream Proto paths from the upstream-source manifest. */ +async function frozenProtoPaths(rootDir) { + const manifestPath = join(rootDir, "build-protocol/proto/UPSTREAM_SOURCES.json"); + if (!existsSync(manifestPath)) return new Set(); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + return new Set((manifest.frozenFiles ?? []).map((entry) => entry.localPath)); +} + +/** Runs the deterministic TypeScript and Proto convention checks. */ +export async function checkSourceConventions({ rootDir = process.cwd() } = {}) { + const findings = []; + const typeScriptPaths = await findFiles(rootDir, TYPE_SCRIPT_ROOTS, ".ts"); + await Promise.all( + typeScriptPaths.map(async (path) => { + const contents = await readFile(join(rootDir, path), "utf8"); + checkTypeScriptFile( + findings, + path, + contents, + PRODUCTION_SOURCE_ROOTS.some((root) => path.startsWith(`${root}/`)), + ); + }), + ); + const frozenPaths = await frozenProtoPaths(rootDir); + const protoPaths = (await findFiles(rootDir, PROTO_ROOTS, ".proto")).filter( + (path) => !frozenPaths.has(path), + ); + await Promise.all( + protoPaths.map(async (path) => + checkProtoFile(findings, path, await readFile(join(rootDir, path), "utf8")), + ), + ); + findings.sort( + (left, right) => + left.path.localeCompare(right.path) || + left.line - right.line || + left.rule.localeCompare(right.rule) || + left.message.localeCompare(right.message), + ); + const output = findings + .map((finding) => `${finding.path}:${finding.line} ${finding.rule} ${finding.message}`) + .join("\n"); + return { findings, output }; +} + +if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) { + const result = await checkSourceConventions(); + if (result.output) process.stderr.write(`${result.output}\n`); + process.exitCode = result.findings.length === 0 ? 0 : 1; +} diff --git a/scripts/check-source-conventions.test.mjs b/scripts/check-source-conventions.test.mjs new file mode 100644 index 0000000..e8ef361 --- /dev/null +++ b/scripts/check-source-conventions.test.mjs @@ -0,0 +1,427 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +import { checkSourceConventions, countSemanticWords } from "./check-source-conventions.mjs"; + +/** Writes a fixture file, creating its parent directory when needed. */ +async function writeFixture(rootDir, relativePath, contents) { + const filePath = join(rootDir, relativePath); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, contents); +} + +/** Creates a disposable repository fixture and removes it after the assertion. */ +async function withFixture(files, assertion) { + const rootDir = await mkdtemp(join(tmpdir(), "source-conventions-")); + try { + await Promise.all( + Object.entries(files).map(([path, contents]) => writeFixture(rootDir, path, contents)), + ); + await assertion(rootDir); + } finally { + await rm(rootDir, { force: true, recursive: true }); + } +} + +/** Returns diagnostic rule identifiers from a checker result. */ +function rules(result) { + return result.findings.map((finding) => finding.rule); +} + +test("accepts documented TypeScript declarations and allowed function forms", async () => { + await withFixture( + { + "packages/validation/src/validation.ts": ` + /** Validates a message. @param schema Describes the schema. @param message Describes the message. @returns Returns violations. */ + export function validate<T>(schema: T, message?: T): T { return schema; } + /** Describes a documented owner. */ + export class ValidOwner { + /** Creates an owner. */ + constructor() {} + /** Returns the supplied value unchanged. @param value Value to return unchanged. @returns The supplied value. */ + method<T>(value?: T): T | undefined { return value; } + /** Joins the supplied values. @param values Values to join. @returns The joined values. */ + callback = (...values: string[]) => values.join(','); + /** Returns the supplied value. @param value Value to return. @returns The supplied value. */ + expression = function (value: string) { return value; }; + } + /** Describes a named object. */ + export const namedObject = { + /** Describes a data property. */ + label: "value", + /** Returns an accessor value. @returns Returns the label. */ + get summary() { return this.label; }, + /** Sets an accessor value. @param value Describes the label. */ + set summary(value: string) { this.label = value; }, + /** Returns the supplied value unchanged. @param value Value to return unchanged. @returns The supplied value. */ + method(value: string) { return value; }, + }; + /** Describes a type alias. */ + export type ValidAlias = string; + /** Describes an interface. */ + export interface ValidInterface { + /** Describes a property. */ + value: string; + } + /** Describes an enum. */ + export enum ValidEnum { + /** Describes a member. */ + VALUE = 'value', + } + let later: string; + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.deepEqual(rules(result), [], result.output); + }, + ); +}); + +test("allows exactly one exported validate from validation.ts and documents function-valued properties", async () => { + await withFixture( + { + "packages/validation/src/validation.ts": ` + /** Validates a message. @param value Value to validate. @returns The validated value. */ + export function validate(value: string) { return value; } + `, + "packages/validation/src/other.ts": ` + /** Validates a message. @param value Value to validate. @returns The validated value. */ + export function validate(value: string) { return value; } + /** Describes an owner. */ + export class Owner { + /** Callback for values. */ + callback = (value: string) => value; + /** Returns a value. @returns A value. */ + expression = function (value: string) { return value; }; + } + `, + "packages/example/src/example.ts": ` + /** Validates a message. @param value Value to validate. @returns The validated value. */ + export function validate(value: string) { return value; } + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.equal(rules(result).filter((rule) => rule === "ts-standalone-function").length, 2); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-missing-param").length, 2); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-missing-returns").length, 1); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-callable-summary").length, 1); + }, + ); +}); + +test("checks function-valued object properties with the property TSDoc", async () => { + await withFixture( + { + "packages/validation/src/objects.ts": ` + /** Describes callable object properties. */ + export const validCallbacks = { + /** Returns the supplied value. @param value Value to return. @returns The supplied value. */ + callback: (value: string) => value, + /** Returns the supplied value. @param value Value to return. @returns The supplied value. */ + expression: function (value: string) { return value; }, + }; + /** Describes invalid callable object properties. */ + export const invalidCallbacks = { + /** Callback value. */ + callback: (value: string) => value, + /** Returns a value. @returns A value. */ + expression: function (value: string) { return value; }, + }; + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-callable-summary").length, 1); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-missing-param").length, 2); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-missing-returns").length, 1); + }, + ); +}); + +test("requires the validation entry point to export validate exactly once", async () => { + await withFixture( + { + "packages/validation/src/validation.ts": ` + /** Validates a message. @param value Value to validate. @returns The validated value. */ + export function validate(value: string) { return value; } + /** Validates a second message. @param value Value to validate. @returns The validated value. */ + export function validate(value: string) { return value; } + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.deepEqual(rules(result), ["ts-validate-entry-point"]); + }, + ); +}); + +test("rejects whitespace-only declaration comments", async () => { + await withFixture( + { + "packages/validation/src/empty.ts": ` + /** */ export class EmptyClass {} + /**\n *\n */ export interface EmptyInterface {} + `, + "packages/validation/proto/empty.proto": ` + /** */ message EmptyMessage {} + // + message EmptyLineComment {} + /* */ message EmptyBlockComment {} + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-missing").length, 2); + assert.equal(rules(result).filter((rule) => rule === "proto-missing-comment").length, 3); + }, + ); +}); + +test("reports TypeScript documentation, standalone functions, and forbidden product wording", async () => { + await withFixture( + { + "packages/example/src/invalid.ts": ` + export function helper(value: string) { return value; } + /** Task T-0009 implements an owner. */ + export class MissingDetails { + value: string; + method(value: string) { return value; } + } + /** Makes a value. @param missing Describes another parameter. @returns Returns a value. */ + export function documented(value: string): string { return value; } + /** Returns a value. @param value @returns */ + export function emptyTagDescriptions(value: string): string { return value; } + /** Describes an object. */ + export const undocumentedObject = { + data: "value", + get value() { return this.data; }, + set value(next: string) { this.data = next; }, + }; + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.ok(rules(result).includes("tsdoc-forbidden-wording")); + assert.ok(rules(result).includes("tsdoc-missing-param")); + assert.ok(rules(result).includes("tsdoc-missing-param-description")); + assert.ok(rules(result).includes("tsdoc-missing-returns-description")); + assert.ok(rules(result).filter((rule) => rule === "tsdoc-missing").length >= 6); + assert.equal(rules(result).filter((rule) => rule === "ts-standalone-function").length, 3); + }, + ); +}); + +test("rejects generic and implementation-history TSDoc without banning domain terms", async () => { + await withFixture( + { + "packages/validation/src/filler.ts": ` + /** Processes inputs for a field. @param value Supplies the value input. @returns Returns the computed result. */ + export function validate(value: string): string { return value; } + /** Describes the validation data. */ + export interface ValidationData {} + /** Provides helper methods for a frozen Proto contract intake. */ + export const helper = { /** Returns a value. @returns Returns a value. */ value() { return "value"; } }; + /** Documents source provenance for a product contract. */ + export type HistoricalNotes = string; + /** Reports the provenance field supplied by a message. */ + export interface DomainTerms { /** Identifies the provenance field. */ provenance: string; } + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-filler-wording").length, 4); + assert.doesNotMatch(result.output, /Reports the provenance field/); + }, + ); +}); + +test("scans detached TSDoc blocks and reports duplicate declaration documentation", async () => { + await withFixture( + { + "packages/validation/src/detached.ts": ` + /** Explains a validation result for readers. */ + /** Describes the purpose of this declaration. */ + /** Retains historical behavior for a configured rule. */ + /** Identifies an accepted message. */ + export interface AcceptedMessage {} + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-filler-wording").length, 1); + assert.equal(rules(result).filter((rule) => rule === "tsdoc-forbidden-wording").length, 1); + assert.deepEqual( + rules(result).filter((rule) => rule === "tsdoc-duplicate"), + ["tsdoc-duplicate"], + ); + }, + ); +}); + +test("reports overlong TypeScript names across source and test roots but not generated output", async () => { + await withFixture( + { + "packages/validation/src/names.ts": ` + /** Describes a class. */ export class ThisNameHasFiveWords {} + /** Returns a value. @param this_parameter_has_five_words Describes a parameter. @returns Returns a value. */ + export function validate(this_parameter_has_five_words: string) { return this_parameter_has_five_words; } + `, + "packages/validation/tests/names.test.ts": ` + import { source as imported_binding_has_five_words } from 'fixture'; + void imported_binding_has_five_words; + `, + "packages/validation/src/generated/generated.ts": + "const generated_name_has_five_words = 1;\n", + "packages/validation/dist/bundle.ts": "const dist_name_has_five_words = 1;\n", + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.deepEqual(rules(result), [ + "ts-name-too-long", + "ts-name-too-long", + "ts-standalone-function", + "ts-name-too-long", + ]); + assert.match(result.output, /ThisNameHasFiveWords/); + assert.doesNotMatch(result.output, /generated_name_has_five_words|dist_name_has_five_words/); + }, + ); +}); + +test("counts camel, separator, initialism, and numeric semantic words", () => { + assert.equal(countSemanticWords("two_words-here"), 3); + assert.equal(countSemanticWords("parseHTTP2ResponseValue"), 4); + assert.equal(countSemanticWords("HTTP2ResponseValue"), 3); +}); + +test("reports undocumented and overlong nested Proto declarations while ignoring comment and string lookalikes", async () => { + await withFixture( + { + "packages/validation/proto/project.proto": ` + syntax = "proto3"; + // message Pretend { string fake = 1; } + message Outer { + // Keeps a literal brace { safe. + string title = 1 [json_name = "{safe}"]; + message Nested { string no_comment = 1; } + // Documents a choice. + oneof selection { string selected_value = 2; } + enum ExampleEnum { EXAMPLE_ENUM_VALUE = 0; } + map<string, string> values_by_key = 3; + } + message ThisMessageNameHasFiveWords {} + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.equal(rules(result).filter((rule) => rule === "proto-missing-comment").length, 7); + assert.equal(rules(result).filter((rule) => rule === "proto-name-too-long").length, 1); + assert.doesNotMatch(result.output, /Pretend|fake/); + }, + ); +}); + +test("excludes Proto paths frozen by the upstream-source manifest", async () => { + await withFixture( + { + "build-protocol/proto/UPSTREAM_SOURCES.json": JSON.stringify({ + frozenFiles: [{ localPath: "packages/validation/proto/frozen.proto" }], + }), + "packages/validation/proto/frozen.proto": "message MissingFrozenComment {}\n", + "packages/example/proto/project.proto": "message MissingProjectComment {}\n", + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.deepEqual(rules(result), ["proto-missing-comment"]); + assert.doesNotMatch(result.output, /frozen\.proto/); + }, + ); +}); + +test("does not treat option assignments as Proto field declarations", async () => { + await withFixture( + { + "packages/validation/proto/options.proto": ` + // Documents an option-bearing message. + message OptionBearing { + // Documents the value field. + string value = 1 [(required) = true, (pattern).error_msg = "{safe}"]; + // Documents the selection. + oneof selection { + option (choice).required = true; + // Documents a selected value. + string selected_value = 2; + } + } + `, + }, + async (rootDir) => { + assert.deepEqual(rules(await checkSourceConventions({ rootDir })), []); + }, + ); +}); + +test("keeps sibling aggregate field options within their documented field", async () => { + await withFixture( + { + "packages/validation/proto/nested-options.proto": ` + // Documents a field with aggregate validation options. + message NumericBounds { + // Documents the bounded value. + double value = 1 [ + (min) = { value: "0.01", error_msg: "Literal { braces } stay quoted." }, + (max) = { value: "1000000.0", exclusive: true } + ]; + } + `, + }, + async (rootDir) => { + assert.deepEqual(rules(await checkSourceConventions({ rootDir })), []); + }, + ); +}); + +test("does not associate a trailing Proto comment with the next declaration", async () => { + await withFixture( + { + "packages/validation/proto/trailing.proto": ` + syntax = "proto3"; + // Documents the message. + message TrailingComment { + // Documents the first field. + string first = 1; // This is not documentation for the next field. + string second = 2; + } + `, + }, + async (rootDir) => { + const result = await checkSourceConventions({ rootDir }); + assert.deepEqual(rules(result), ["proto-missing-comment"]); + assert.match(result.output, /second/); + }, + ); +}); + +test("emits byte-identical path-sorted diagnostics", async () => { + await withFixture( + { + "packages/example/src/z.ts": "export function zed() {}\n", + "packages/validation/src/a.ts": "export function alpha() {}\n", + }, + async (rootDir) => { + const first = await checkSourceConventions({ rootDir }); + const second = await checkSourceConventions({ rootDir }); + assert.equal(first.output, second.output); + assert.match(first.output, /packages\/example\/src\/z\.ts/); + assert.ok( + first.output.indexOf("packages/example/src/z.ts") < + first.output.indexOf("packages/validation/src/a.ts"), + ); + }, + ); +}); diff --git a/scripts/verify-proto-sources.mjs b/scripts/verify-proto-sources.mjs new file mode 100644 index 0000000..054fb28 --- /dev/null +++ b/scripts/verify-proto-sources.mjs @@ -0,0 +1,42 @@ +import { createHash } from "node:crypto"; +import { readFile, lstat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const manifestPath = resolve(repositoryRoot, "build-protocol/proto/UPSTREAM_SOURCES.json"); +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); +const failures = []; + +for (const source of manifest.frozenFiles) { + const absolutePath = resolve(repositoryRoot, source.localPath); + if (!absolutePath.startsWith(`${repositoryRoot}/`)) { + failures.push(`${source.localPath}: path escapes the repository`); + continue; + } + + try { + const metadata = await lstat(absolutePath); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + failures.push(`${source.localPath}: expected a regular, non-symlink file`); + continue; + } + const content = await readFile(absolutePath); + const actual = createHash("sha256").update(content).digest("hex"); + if (actual !== source.sha256) { + failures.push(`${source.localPath}: expected ${source.sha256}, found ${actual}`); + } + } catch (error) { + failures.push(`${source.localPath}: ${error.message}`); + } +} + +if (failures.length > 0) { + console.error("Immutable Proto verification failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log(`Verified ${manifest.frozenFiles.length} immutable Proto files.`); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..0931d99 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2024"], + "strict": true, + "useUnknownInCatchVariables": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9c5ef7d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./packages/validation" }, { "path": "./packages/example" }] +} diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..43d740d --- /dev/null +++ b/typedoc.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["packages/validation/src/index.ts"], + "tsconfig": "packages/validation/tsconfig.json", + "out": "packages/validation/docs/api/reference", + "exclude": ["**/dist/**", "**/coverage/**", "**/*.test.ts"], + "cleanOutputDir": true, + "highlightLanguages": ["bash", "js", "json", "protobuf", "typescript", "yaml"], + "includeVersion": true, + "treatWarningsAsErrors": true, + "validation": { + "notDocumented": true + }, + "requiredToBeDocumented": [ + "Class", + "Constructor", + "Enum", + "EnumMember", + "Function", + "Interface", + "Method", + "Property", + "TypeAlias", + "Variable" + ], + "blockTags": ["@deprecated", "@example", "@generated", "@param", "@returns"], + "excludeTags": ["@generated"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c02bc15 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["packages/*/tests/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["packages/validation/src/**/*.ts", "packages/example/src/**/*.ts"], + exclude: ["**/*.test.ts", "**/generated/**", "packages/example/src/index.ts"], + thresholds: { branches: 90, functions: 90, lines: 90, statements: 90 }, + }, + }, +});