@nekostack/schema 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +422 -0
- package/LICENSE +202 -0
- package/README.md +656 -0
- package/dist/src/builders/array.d.ts +12 -0
- package/dist/src/builders/array.d.ts.map +1 -0
- package/dist/src/builders/array.js +29 -0
- package/dist/src/builders/array.js.map +1 -0
- package/dist/src/builders/object.d.ts +62 -0
- package/dist/src/builders/object.d.ts.map +1 -0
- package/dist/src/builders/object.js +263 -0
- package/dist/src/builders/object.js.map +1 -0
- package/dist/src/builders/primitives.d.ts +37 -0
- package/dist/src/builders/primitives.d.ts.map +1 -0
- package/dist/src/builders/primitives.js +125 -0
- package/dist/src/builders/primitives.js.map +1 -0
- package/dist/src/builders/s.d.ts +27 -0
- package/dist/src/builders/s.d.ts.map +1 -0
- package/dist/src/builders/s.js +39 -0
- package/dist/src/builders/s.js.map +1 -0
- package/dist/src/builders/schema.d.ts +70 -0
- package/dist/src/builders/schema.d.ts.map +1 -0
- package/dist/src/builders/schema.js +98 -0
- package/dist/src/builders/schema.js.map +1 -0
- package/dist/src/cli-integration.d.ts +43 -0
- package/dist/src/cli-integration.d.ts.map +1 -0
- package/dist/src/cli-integration.js +48 -0
- package/dist/src/cli-integration.js.map +1 -0
- package/dist/src/errors/issue.d.ts +34 -0
- package/dist/src/errors/issue.d.ts.map +1 -0
- package/dist/src/errors/issue.js +89 -0
- package/dist/src/errors/issue.js.map +1 -0
- package/dist/src/generators/errors.d.ts +31 -0
- package/dist/src/generators/errors.d.ts.map +1 -0
- package/dist/src/generators/errors.js +34 -0
- package/dist/src/generators/errors.js.map +1 -0
- package/dist/src/generators/header.d.ts +42 -0
- package/dist/src/generators/header.d.ts.map +1 -0
- package/dist/src/generators/header.js +43 -0
- package/dist/src/generators/header.js.map +1 -0
- package/dist/src/generators/json-schema-meta.d.ts +36 -0
- package/dist/src/generators/json-schema-meta.d.ts.map +1 -0
- package/dist/src/generators/json-schema-meta.js +35 -0
- package/dist/src/generators/json-schema-meta.js.map +1 -0
- package/dist/src/generators/json-schema.d.ts +26 -0
- package/dist/src/generators/json-schema.d.ts.map +1 -0
- package/dist/src/generators/json-schema.js +88 -0
- package/dist/src/generators/json-schema.js.map +1 -0
- package/dist/src/generators/openapi.d.ts +33 -0
- package/dist/src/generators/openapi.d.ts.map +1 -0
- package/dist/src/generators/openapi.js +61 -0
- package/dist/src/generators/openapi.js.map +1 -0
- package/dist/src/generators/schema-fragment.d.ts +55 -0
- package/dist/src/generators/schema-fragment.d.ts.map +1 -0
- package/dist/src/generators/schema-fragment.js +253 -0
- package/dist/src/generators/schema-fragment.js.map +1 -0
- package/dist/src/generators/ts.d.ts +19 -0
- package/dist/src/generators/ts.d.ts.map +1 -0
- package/dist/src/generators/ts.js +252 -0
- package/dist/src/generators/ts.js.map +1 -0
- package/dist/src/generators/types.d.ts +96 -0
- package/dist/src/generators/types.d.ts.map +1 -0
- package/dist/src/generators/types.js +10 -0
- package/dist/src/generators/types.js.map +1 -0
- package/dist/src/generators/version.d.ts +11 -0
- package/dist/src/generators/version.d.ts.map +1 -0
- package/dist/src/generators/version.js +11 -0
- package/dist/src/generators/version.js.map +1 -0
- package/dist/src/generators/zod-mapping.d.ts +90 -0
- package/dist/src/generators/zod-mapping.d.ts.map +1 -0
- package/dist/src/generators/zod-mapping.js +174 -0
- package/dist/src/generators/zod-mapping.js.map +1 -0
- package/dist/src/generators/zod.d.ts +17 -0
- package/dist/src/generators/zod.d.ts.map +1 -0
- package/dist/src/generators/zod.js +118 -0
- package/dist/src/generators/zod.js.map +1 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +43 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/ir/hash.d.ts +19 -0
- package/dist/src/ir/hash.d.ts.map +1 -0
- package/dist/src/ir/hash.js +22 -0
- package/dist/src/ir/hash.js.map +1 -0
- package/dist/src/ir/nodes.d.ts +121 -0
- package/dist/src/ir/nodes.d.ts.map +1 -0
- package/dist/src/ir/nodes.js +14 -0
- package/dist/src/ir/nodes.js.map +1 -0
- package/dist/src/ir/serialize.d.ts +8 -0
- package/dist/src/ir/serialize.d.ts.map +1 -0
- package/dist/src/ir/serialize.js +23 -0
- package/dist/src/ir/serialize.js.map +1 -0
- package/dist/src/migrations/build-migration-registry.d.ts +46 -0
- package/dist/src/migrations/build-migration-registry.d.ts.map +1 -0
- package/dist/src/migrations/build-migration-registry.js +134 -0
- package/dist/src/migrations/build-migration-registry.js.map +1 -0
- package/dist/src/migrations/handlers/list.d.ts +35 -0
- package/dist/src/migrations/handlers/list.d.ts.map +1 -0
- package/dist/src/migrations/handlers/list.js +55 -0
- package/dist/src/migrations/handlers/list.js.map +1 -0
- package/dist/src/migrations/handlers/plan.d.ts +26 -0
- package/dist/src/migrations/handlers/plan.d.ts.map +1 -0
- package/dist/src/migrations/handlers/plan.js +28 -0
- package/dist/src/migrations/handlers/plan.js.map +1 -0
- package/dist/src/migrations/handlers/stub.d.ts +22 -0
- package/dist/src/migrations/handlers/stub.d.ts.map +1 -0
- package/dist/src/migrations/handlers/stub.js +24 -0
- package/dist/src/migrations/handlers/stub.js.map +1 -0
- package/dist/src/migrations/handlers/verify.d.ts +25 -0
- package/dist/src/migrations/handlers/verify.d.ts.map +1 -0
- package/dist/src/migrations/handlers/verify.js +27 -0
- package/dist/src/migrations/handlers/verify.js.map +1 -0
- package/dist/src/migrations/parse-provenance.d.ts +78 -0
- package/dist/src/migrations/parse-provenance.d.ts.map +1 -0
- package/dist/src/migrations/parse-provenance.js +157 -0
- package/dist/src/migrations/parse-provenance.js.map +1 -0
- package/dist/src/migrations/plan-migration.d.ts +50 -0
- package/dist/src/migrations/plan-migration.d.ts.map +1 -0
- package/dist/src/migrations/plan-migration.js +256 -0
- package/dist/src/migrations/plan-migration.js.map +1 -0
- package/dist/src/migrations/stub.d.ts +55 -0
- package/dist/src/migrations/stub.d.ts.map +1 -0
- package/dist/src/migrations/stub.js +201 -0
- package/dist/src/migrations/stub.js.map +1 -0
- package/dist/src/migrations/types.d.ts +297 -0
- package/dist/src/migrations/types.d.ts.map +1 -0
- package/dist/src/migrations/types.js +28 -0
- package/dist/src/migrations/types.js.map +1 -0
- package/dist/src/migrations/verify-provenance.d.ts +46 -0
- package/dist/src/migrations/verify-provenance.d.ts.map +1 -0
- package/dist/src/migrations/verify-provenance.js +158 -0
- package/dist/src/migrations/verify-provenance.js.map +1 -0
- package/dist/src/registry/build-registry.d.ts +65 -0
- package/dist/src/registry/build-registry.d.ts.map +1 -0
- package/dist/src/registry/build-registry.js +172 -0
- package/dist/src/registry/build-registry.js.map +1 -0
- package/dist/src/registry/diff.d.ts +25 -0
- package/dist/src/registry/diff.d.ts.map +1 -0
- package/dist/src/registry/diff.js +497 -0
- package/dist/src/registry/diff.js.map +1 -0
- package/dist/src/registry/handlers/check.d.ts +57 -0
- package/dist/src/registry/handlers/check.d.ts.map +1 -0
- package/dist/src/registry/handlers/check.js +181 -0
- package/dist/src/registry/handlers/check.js.map +1 -0
- package/dist/src/registry/handlers/diff.d.ts +33 -0
- package/dist/src/registry/handlers/diff.d.ts.map +1 -0
- package/dist/src/registry/handlers/diff.js +61 -0
- package/dist/src/registry/handlers/diff.js.map +1 -0
- package/dist/src/registry/handlers/generate.d.ts +87 -0
- package/dist/src/registry/handlers/generate.d.ts.map +1 -0
- package/dist/src/registry/handlers/generate.js +223 -0
- package/dist/src/registry/handlers/generate.js.map +1 -0
- package/dist/src/registry/handlers/list.d.ts +36 -0
- package/dist/src/registry/handlers/list.d.ts.map +1 -0
- package/dist/src/registry/handlers/list.js +84 -0
- package/dist/src/registry/handlers/list.js.map +1 -0
- package/dist/src/registry/parse-provenance.d.ts +63 -0
- package/dist/src/registry/parse-provenance.d.ts.map +1 -0
- package/dist/src/registry/parse-provenance.js +182 -0
- package/dist/src/registry/parse-provenance.js.map +1 -0
- package/dist/src/registry/source-hash.d.ts +28 -0
- package/dist/src/registry/source-hash.d.ts.map +1 -0
- package/dist/src/registry/source-hash.js +32 -0
- package/dist/src/registry/source-hash.js.map +1 -0
- package/dist/src/registry/types.d.ts +185 -0
- package/dist/src/registry/types.d.ts.map +1 -0
- package/dist/src/registry/types.js +22 -0
- package/dist/src/registry/types.js.map +1 -0
- package/dist/src/runtime/compile.d.ts +38 -0
- package/dist/src/runtime/compile.d.ts.map +1 -0
- package/dist/src/runtime/compile.js +45 -0
- package/dist/src/runtime/compile.js.map +1 -0
- package/dist/src/runtime/errors.d.ts +25 -0
- package/dist/src/runtime/errors.d.ts.map +1 -0
- package/dist/src/runtime/errors.js +43 -0
- package/dist/src/runtime/errors.js.map +1 -0
- package/dist/src/runtime/normalize-issues.d.ts +65 -0
- package/dist/src/runtime/normalize-issues.d.ts.map +1 -0
- package/dist/src/runtime/normalize-issues.js +208 -0
- package/dist/src/runtime/normalize-issues.js.map +1 -0
- package/dist/src/runtime/parse.d.ts +62 -0
- package/dist/src/runtime/parse.d.ts.map +1 -0
- package/dist/src/runtime/parse.js +107 -0
- package/dist/src/runtime/parse.js.map +1 -0
- package/dist/src/runtime/strip-defaults.d.ts +51 -0
- package/dist/src/runtime/strip-defaults.d.ts.map +1 -0
- package/dist/src/runtime/strip-defaults.js +81 -0
- package/dist/src/runtime/strip-defaults.js.map +1 -0
- package/dist/src/runtime/zod-compile.d.ts +27 -0
- package/dist/src/runtime/zod-compile.d.ts.map +1 -0
- package/dist/src/runtime/zod-compile.js +92 -0
- package/dist/src/runtime/zod-compile.js.map +1 -0
- package/dist/src/types.d.ts +116 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/docs/ABSENCE_SEMANTICS.md +37 -0
- package/docs/BENCHMARKS.md +64 -0
- package/docs/COMPOSITION.md +206 -0
- package/docs/DIFF_CLASSIFICATION.md +137 -0
- package/docs/EXAMPLES.md +221 -0
- package/docs/HEADER_FORMAT.md +66 -0
- package/docs/INVARIANTS.md +58 -0
- package/docs/IR_CONTRACT.md +67 -0
- package/docs/ISSUE_CODES.md +99 -0
- package/docs/JSON_SCHEMA_MAPPING.md +149 -0
- package/docs/MIGRATIONS.md +406 -0
- package/docs/MIGRATION_GUIDE.md +150 -0
- package/docs/OPENAPI_MAPPING.md +66 -0
- package/docs/REGISTRY.md +336 -0
- package/docs/RUNTIME.md +279 -0
- package/docs/SCOPE.md +119 -0
- package/docs/USAGE.md +376 -0
- package/docs/ZOD_MODIFIER_ORDERING.md +77 -0
- package/package.json +45 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @nekostack/schema — Invariants
|
|
2
|
+
|
|
3
|
+
Doctrine that constrains every future phase. If a change violates one, it must be raised explicitly — not worked around silently. Tests alone are not enough; invariants are how the package stays coherent across years of evolution.
|
|
4
|
+
|
|
5
|
+
1. **IR is the only generator input.** Generators consume `SchemaNode`. They never accept a `Schema` instance, never pattern-match against a builder subclass, never read private fields. The DSL is replaceable; the IR is not.
|
|
6
|
+
|
|
7
|
+
2. **The public API is intentionally small.** Anything not re-exported from `src/index.ts` is internal and may change without a major version bump. New public exports require deliberate justification. The `@nekostack/schema/cli` subpath (v0.7+, [`../src/cli-integration.ts`](../src/cli-integration.ts)) is a **package-internal integration surface** for `@nekostack/cli` — not part of the public consumer API and not subject to the same engine-swap-safety guarantee as the root. External consumers do not import from it.
|
|
8
|
+
|
|
9
|
+
3. **Type inference follows the absence-semantics table.** [`ABSENCE_SEMANTICS.md`](./ABSENCE_SEMANTICS.md) is authoritative. Modifier methods that drift from it must be rejected.
|
|
10
|
+
|
|
11
|
+
4. **Defaults are input-optional and output-required.** A `.default(v)` field accepts a missing input and produces a fully-populated output. The `Schema` base class tracks `TInputKey` and `TOutputKey` separately to encode this at the type level.
|
|
12
|
+
|
|
13
|
+
5. **Object schemas are strict by default.** Unknown keys are rejected unless the consumer opts into `stripUnknown()` or `passthrough()`. Auth, API, and config schemas depend on this; permissive behavior must be deliberate.
|
|
14
|
+
|
|
15
|
+
6. **Builder operations are immutable.** Every chainable method returns a new schema. The underlying IR is deep-frozen on construction; mutation throws in strict mode. No method on any builder may mutate `this`.
|
|
16
|
+
|
|
17
|
+
7. **Runtime-only semantics must be explicitly marked.** Custom refinements, transforms, and `dateObject()` are runtime-only. The IR distinguishes portable refinements from runtime-only ones so non-runtime generators (JSON Schema, OpenAPI) can emit semantic-loss metadata rather than silently lying.
|
|
18
|
+
|
|
19
|
+
8. **No downstream NekoStack package may be imported.** `@nekostack/schema` is foundational. Importing from `@nekostack/api`, `@nekostack/auth`, or any other NekoStack package creates a circular dependency at the architectural level even when the file-level import resolves. External deps (Zod, etc.) are classified per [`SCOPE.md`](./SCOPE.md).
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Phase-specific corollaries
|
|
24
|
+
|
|
25
|
+
These derive from the eight invariants above and apply at specific phases.
|
|
26
|
+
|
|
27
|
+
- **v0.2 (TS + Zod generators):** Generators must take a `SchemaNode` argument. A function signature of `generateZod(schema: StringSchema)` violates Invariant 1.
|
|
28
|
+
- **v0.2 corollary — fail loudly:** Generators throw `UnsupportedNodeKindError` with stable `code` / `kind` / `generator` fields on any IR node kind without a v0.2 generator (date, union, recursiveRef, transform). They MUST NOT silently emit no-op or partial output, and tests MUST assert on the error fields rather than on message text (Invariant 7).
|
|
29
|
+
- **v0.2 corollary — generated Zod modifier order:** The Zod generator applies modifiers in the fixed order documented in [`ZOD_MODIFIER_ORDERING.md`](./ZOD_MODIFIER_ORDERING.md): base → portable refinements → describe → nullable/optional/nullish → default LAST. This is how the v0.1 absence-semantics table is preserved through to Zod's runtime (Invariant 3).
|
|
30
|
+
- **v0.3 (JSON Schema generator):** Non-runtime generators must surface semantic loss via `x-nekostack-*` extension keys when JSON Schema accepts the same input but cannot express a NekoStack runtime action; they must **throw** (not annotate) when emitting would change validation semantics. The rule of thumb: metadata = "JSON Schema validates the same thing, downstream needs to know more"; throw = "emitting would change what the schema validates." See [`JSON_SCHEMA_MAPPING.md`](./JSON_SCHEMA_MAPPING.md) for the worked rules (Decisions #9 + #12 = metadata; Decisions #11 + #11a = throw).
|
|
31
|
+
- **v0.3 (JSON Schema runtime refinements):** Runtime-only refinements MUST throw `UnsupportedNodeKindError({ kind: "runtimeRefinement", generator: "jsonSchema" })`. They are NOT represented with metadata, because emitting JSON Schema that omits the refinement would change validation behavior — JSON Schema would accept inputs the IR rejects. (Metadata is only for cases where JSON Schema validates the same input but downstream consumers need to know more — e.g., `default` → `x-nekostack-default-applied-by`.)
|
|
32
|
+
- **v0.4 (OpenAPI 3.1):** Generated component schemas MUST pass `@redocly/openapi-core` validation when composed into a synthetic OpenAPI 3.1 document. Drift from the spec means the round-trip test fails. The OpenAPI Specification is authoritative; Redocly is the actively-maintained validator we delegate to.
|
|
33
|
+
- **v0.4 (shared schema-fragment emitter):** The IR → schema-fragment translation lives once in [`src/generators/schema-fragment.ts`](./../src/generators/schema-fragment.ts). Both `generateJsonSchema` and `generateOpenApiSchemaComponent` consume it; wrappers own only what genuinely differs (root structure, `$schema` / `$id` decisions, provenance `generator` name). Duplicating the mapping in a parallel implementation would create a drift vector — bug fixes would have to land twice — and v0.4 explicitly rejects that.
|
|
34
|
+
- **v0.5 (composition fail-loudly):** Composition operators MUST throw on collisions (`extend`), missing keys (`override`), unknown keys (`pick` / `omit` / mask forms of `partial` / `required`), field-level merge conflicts (default), and `unknownKeys` policy mismatch during `merge` (default). Silent composition would be the failure mode v0.5 exists to prevent. See [`COMPOSITION.md`](./COMPOSITION.md) for the per-operator rules.
|
|
35
|
+
- **v0.5 (composition strips top-level metadata):** Composed schemas drop `metadata.id` / `version` / `description` / `deprecated`. Callers must re-tag explicitly. Field-level metadata is preserved. Implicit identity propagation through derived schemas would cause v0.7 registry collisions (two distinct schemas both claiming `com.x.User`).
|
|
36
|
+
- **v0.5 (composition):** `merge()` with conflicting fields throws by default. Silent merge replacement violates Invariant 5's spirit (deliberateness).
|
|
37
|
+
- **v0.6 (runtime):** `validate(schema, input)` may not apply defaults or run transforms. `parse(schema, input)` does both. Anything else violates Invariant 3.
|
|
38
|
+
- **v0.6 (engine-swap-safe):** The runtime surface is `Schema<I, O>` / `Result<T>` / `Issue[]` / `ParseError`. No public API may surface a `ZodSchema` or `ZodError`. Replacing the internal engine (e.g., with a pure IR-walker) in a future phase must be a no-op for consumers. A consumer of `@nekostack/schema` does not import Zod for runtime validation. See [`RUNTIME.md`](./RUNTIME.md) for the public-surface contract.
|
|
39
|
+
- **v0.6 (default-semantics split):** `parse` / `safeParse` fill default values and return `s.output<S>`. `validate` accepts a missing default-bearing field (per the v0.1 input-optional rule) and does NOT fill it — it returns `s.input<S>`. The validate-only IR variant — produced by `stripDefaultsForValidate` — drops `modifiers.default` AND sets `modifiers.optional = true` at the same level, leaving every other field untouched. This is the only rule consistent with both halves of the v0.1 absence-semantics contract for the validate path.
|
|
40
|
+
- **v0.6 (Issue is the only public error vocabulary):** Every failure path through `parse` / `safeParse` / `validate` produces `readonly Issue[]` using the v0.1 `IssueCode` set. The Decision #12 mapping table is the contract; changing a mapping in a later phase is a breaking change. Unmapped Zod codes fall back to `custom_refinement_failed` with `metadata.source = "zod"` and `metadata.zodCode = <original>` so the original code is recoverable downstream. Adding a new `IssueCode` requires going through the `ISSUE_CODES` change-control rule in [`src/errors/issue.ts`](../src/errors/issue.ts).
|
|
41
|
+
- **v0.6 (cache invariance):** A `SchemaNode` produces the same compiled Zod schema for the lifetime of the process. The compile cache (`runtime/compile.ts`) keys on `SchemaNode` *identity*, not IR equality — two builder outputs with byte-identical IR but different instance identity do NOT share a compiled value. This is downstream of Invariant 6 (IR immutability); tests in [`tests/runtime-compile-cache.test.ts`](../tests/runtime-compile-cache.test.ts) assert it. Validate-only variants live in their own `WeakMap<SchemaNode, SchemaNode>` keyed by the original node so repeated `validate(sameSchema, ...)` calls reuse the same stripped variant.
|
|
42
|
+
- **v0.6 (Redocly is spec-validity only):** Redocly is the OpenAPI 3.1 structural validator (Decision #19a in [`PHASE_PLAN_v0.6.md`](./PHASE_PLAN_v0.6.md)). It is NOT a runtime input oracle. The four-oracle runtime parity matrix is NekoStack runtime / generated-Zod execution / Ajv 2020 over generated JSON Schema / IR-walker. Treating Redocly as a fourth runtime oracle was the round-2 audit correction; the separation is now permanent.
|
|
43
|
+
- **v0.6 (fail loudly on unsupported runtime IR):** The runtime compile path throws `UnsupportedNodeKindError` for `date`, `union`, `recursiveRef`, `transform`, and any runtime refinement (`{ kind: "runtime", ... }`). Silently dropping any of these would produce a validator that accepts inputs the IR intends to reject — exactly the kind of subtle data-loss bug the package exists to prevent.
|
|
44
|
+
- **v0.7 (registry):** Schema identity collisions across packages at the same version are an error, not "last writer wins." `buildRegistry` surfaces collisions as `Result.failure` with `duplicate_schema_id` Issues; it never throws and never silently lets a duplicate replace the first-seen entry.
|
|
45
|
+
- **v0.7 (pure handlers):** The four registry handlers (`listHandler`, `diffHandler`, `checkHandler`, `generateHandler`) and every function on their module-graph reach are pure: no `fs.*`, no dynamic `import()`, no `process.*`, no `console.*`. The CLI is the only filesystem and process boundary. The gate lives in [`../tests/registry/handler-purity.test.ts`](../tests/registry/handler-purity.test.ts) — hybrid static-file-level import scan plus runtime spies — and includes sentinel tests proving the gate catches what it claims.
|
|
46
|
+
- **v0.7 (subpath boundary):** The v0.7 registry / freshness / generation surface is reachable through `@nekostack/schema/cli` only. Root `@nekostack/schema` does **not** export `buildRegistry`, `diffNodes`, any handler, any `Registry` / `Diff*` / `Generated*` / `Committed*` / `*Opts` / `*Result` type, `sourceHashFromText`, `parseProvenanceFromText`, `suggestedPathFor`, or `GENERATOR_KINDS`. Both directions are gated — positive subpath check in [`../tests/registry-surface.test.ts`](../tests/registry-surface.test.ts), negative root check in [`../tests/public-surface.test.ts`](../tests/public-surface.test.ts). Adding any of these to the root barrel is a breaking-by-policy change; engine-swap-safety lives at the root, not at the subpath.
|
|
47
|
+
- **v0.7 (two-hash freshness discipline):** Generated-artifact staleness is classified by two independent hashes — `irHash` (sha256 of canonical IR; semantic identity) and `sourceHash` (sha256 of raw UTF-8 source bytes; literal identity). The matrix is locked: `irHash` match + `sourceHash` match → `clean`; `irHash` match + `sourceHash` differs → `cosmetic_drift`; `irHash` differs + `sourceHash` differs → `stale`; `irHash` differs + `sourceHash` matches → `integrity_error` (the impossible row — surfaces hand-edited artifacts or tampered provenance, never auto-regenerated by the CLI). The matrix lives in `verdictFor` ([`../src/registry/handlers/check.ts`](../src/registry/handlers/check.ts)).
|
|
48
|
+
- **v0.7 (`sourceHash` is optional generator provenance):** Generators MUST omit the `sourceHash` line/extension entirely when `ProvenanceOptions.sourceHash` is absent. Pre-v0.7 callers — including every v0.2 / v0.3 / v0.4 / v0.5 / v0.6 snapshot test — must continue to produce byte-identical output. A pre-v0.7 artifact missing `sourceHash` is **never** an `integrity_error`; `parseProvenanceFromText` returns `sourceHash: undefined` and `checkHandler` falls back to the irHash-only verdict (`clean` if irHash matches, `stale` otherwise). This is how backward compatibility for v0.6-era committed artifacts is preserved.
|
|
49
|
+
- **v0.7 (diff classification):** Severity is classified by the **input-acceptance lens** — does data the old schema accepted still pass the new schema? `worstSeverity` aggregation is the max over the change list with precedence `breaking > additive > cosmetic`, and `null` exactly when the change list is empty. Unsupported IR kinds (`date`, `union`, `recursiveRef`, `transform`) throw `UnsupportedNodeKindError({ generator: "diff", kind })` — same fail-loud discipline as the v0.3 / v0.6 generators. The full locked classification table lives in [`DIFF_CLASSIFICATION.md`](./DIFF_CLASSIFICATION.md).
|
|
50
|
+
- **v0.8 (no apply, no transform execution):** `@nekostack/schema` ships migration *planning*, *verification*, and *stub generation* only — never an "apply" verb, never a call to a migration's `.transform(...)`. The static purity gate in [`../tests/migrations/handler-purity.test.ts`](../tests/migrations/handler-purity.test.ts) bans `.transform(` (leading-dot anchor — distinct from the stub generator's emitted `transform(input)` template-string content), `fs` / `node:fs`, dynamic `import(...)`, `process.*`, and `console.*` across every module on the migration handlers' reach. This is the hard boundary of the v0.8 contract; rolling it back requires raising it explicitly. Full contract in [`MIGRATIONS.md`](./MIGRATIONS.md).
|
|
51
|
+
- **v0.8 (subpath boundary extends):** The v0.8 migration surface is reachable through `@nekostack/schema/cli` only. Root `@nekostack/schema` does **not** export `parseMigrationProvenanceFromText`, `buildMigrationRegistry`, `planMigration`, `verifyMigrationProvenance`, `stubMigration`, `suggestedMigrationPathFor`, any of the four migration handlers, or any `Migration` / `AnyMigration` / `MigrationRegistry` / `MigrationPlan` / `MigrationVerdict` / `MigrationStub` / `PlanNote` / `Migration*Opts` / `Migration*Result` type. Both gates extend — positive subpath check in [`../tests/registry-surface.test.ts`](../tests/registry-surface.test.ts), negative root check in [`../tests/public-surface.test.ts`](../tests/public-surface.test.ts). Adding any of these to the root barrel is a breaking-by-policy change.
|
|
52
|
+
- **v0.8 (forward-only, one schemaId per migration):** A `Migration<SchemaId, FromVersion, ToVersion, Input, Output>` is identified by the triple `(schemaId, fromVersion, toVersion)`. Decision #2 fixes the direction as forward-only — `from` is always older than `to`; no rollback module shape, no reverse runner. Decision #4 fixes the cardinality at one `schemaId` per authored migration — a single migration may never straddle two schemas. Decision #5's path convention encodes this in the on-disk filename. `buildMigrationRegistry` enforces both via first-seen-wins handling of duplicate triples plus the three-level registry shape.
|
|
53
|
+
- **v0.8 (fail-loud provenance, never silent skip):** `parseMigrationProvenanceFromText` returns `Result<MigrationProvenance>`. Any missing / malformed / unknown-format input returns `failure` with `code: "integrity_error"` and a stable `metadata.reason` (`unknown_format` / `missing_migration_provenance` / `missing_field` / `malformed_hash` / `malformed_field`). The "silently skip files we can't parse" branch was the round-2 audit blocker — it would have masked broken migrations behind a green plan/verify report, exactly the failure mode the package exists to prevent. The same fail-loud discipline carries through `buildMigrationRegistry`, which collects every failure into `Result.failure` rather than short-circuiting.
|
|
54
|
+
- **v0.8 (verifier four-way matrix, mirrors freshness):** `verifyMigrationProvenance({ schemaRegistry, migrationRegistry })` classifies every registry entry into exactly one of `bound` / `cosmetic_drift` / `drift` / `missing_endpoint` — same four-cell shape as the v0.7 two-hash freshness matrix. `bound` and `cosmetic_drift` are success-compatible — `cosmetic_drift` is **warning-class** (provenance `irHash` matches both endpoint schemas; `sourceHash` differs) and lives on the success branch. `drift` and `missing_endpoint` are failure-class. Iteration is deterministic, sorted by `(schemaId, fromVersion, toVersion)`. Scope is provenance integrity only — the verifier never re-runs the transform and has no opinion on transform correctness.
|
|
55
|
+
- **v0.8 (planner consumes both registries):** `planMigration` requires **both** `schemaRegistry` and `migrationRegistry` so it can honor Decision #10's severity → migration-requirement dispatch. With only the migration registry it could not compute the diff between the source and target schema versions, and could not distinguish "additive change, no migration needed" from "breaking change, migration chain required." The Round-3 audit corrected this — the original `planMigration({ migrations, … })` signature contradicted the locked decision table. The full severity dispatch table lives in [`MIGRATIONS.md`](./MIGRATIONS.md).
|
|
56
|
+
- **v0.8 (pure migration handlers):** The four migration handlers (`listMigrationsHandler`, `planMigrationHandler`, `verifyMigrationsHandler`, `stubMigrationHandler`) and every function on their module-graph reach are pure: no `fs.*`, no dynamic `import()`, no `process.*`, no `console.*`, no `.transform(` calls. Same discipline as the v0.7 registry handlers (Invariant 8 corollary, v0.7). The gate is [`../tests/migrations/handler-purity.test.ts`](../tests/migrations/handler-purity.test.ts) — static-import scan over 9 modules with 12 forbidden patterns, plus 15 sentinel rows proving the scanner catches what it claims. The `.transform(` pattern carries a leading-dot anchor so the stub generator's emitted `transform(input)` template-string content is NOT a false positive.
|
|
57
|
+
- **v0.8 (migrations are append-only):** Once a migration for a `(schemaId, fromVersion, toVersion)` triple is authored and committed, it is **not** edited in place when the endpoint schemas later evolve. The on-disk corpus grows by adding new migration files; existing files stay anchored to whatever shape of the two endpoints they were authored against. The verifier surfaces any in-place rewrite as `drift` (provenance `irHash` no longer matches the registered endpoint) or `cosmetic_drift` (only the `sourceHash` changed), and `buildMigrationRegistry`'s first-seen-wins handling means a *second* file claiming the same triple is reported via `duplicate_migration` — never silently replaces the first. This is the audit-trail guarantee: a v0.8 migration is a frozen historical record of how data moved between two specific schema shapes, not a live document tracking the current shape.
|
|
58
|
+
- **v0.8 (migrations are content-addressed by `(fromIrHash, toIrHash)`):** A migration's *binding* to a pair of schemas is defined by the IR-hash pair carried in its 9-field provenance header, not by the human-readable version strings alone. Two migrations with the same `(schemaId, fromVersion, toVersion)` triple but different `(fromIrHash, toIrHash)` describe transitions between *different* schema shapes that happen to share version labels — the verifier catches that as `drift`. Conversely, two semantically-identical endpoint IRs produce identical IR hashes regardless of source-byte differences (the v0.7 two-hash discipline carries through), so a migration stays `bound` across cosmetic-only schema edits. The pair `(fromIrHash, toIrHash)` is the semantic identity; `(fromSourceHash, toSourceHash)` is the literal identity layered on top — `cosmetic_drift` is exactly the row where the IR pair still matches but the source pair has shifted.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# IR Contract
|
|
2
|
+
|
|
3
|
+
> Builders produce IR. Generators consume IR. Generators must not consume builder internals. IR must remain serializable and deterministic.
|
|
4
|
+
|
|
5
|
+
This one sentence is the load-bearing constraint on every future phase. The DSL is replaceable; the IR is not.
|
|
6
|
+
|
|
7
|
+
## What the IR is
|
|
8
|
+
|
|
9
|
+
A `SchemaNode` is a discriminated union of plain-data objects:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
type SchemaNode =
|
|
13
|
+
| StringNode | NumberNode | BooleanNode | DateNode
|
|
14
|
+
| LiteralNode | EnumNode | ArrayNode | ObjectNode
|
|
15
|
+
| UnionNode | RecursiveRefNode | TransformNode;
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It is:
|
|
19
|
+
- **Serializable** — no functions, no symbols, no class instances. JSON-roundtrip-safe.
|
|
20
|
+
- **Deterministic** — `serializeIR(node)` is canonical (keys sorted, undefined dropped). Two structurally identical IRs always produce byte-identical output.
|
|
21
|
+
- **Deep-frozen by construction** — every node returned by a builder, and every nested node beneath it, is `Object.freeze`-d. Mutation in strict mode throws.
|
|
22
|
+
- **Free of builder coupling** — generators read `node.kind`, `node.modifiers`, `node.refinements`, etc. They never reach into `Schema` class internals.
|
|
23
|
+
|
|
24
|
+
## Builder → IR
|
|
25
|
+
|
|
26
|
+
Every builder method returns a *new* schema (no in-place mutation). Each schema instance carries a `readonly node: SchemaNode` that is the canonical IR for that schema.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const u = s.string().min(3).optional();
|
|
30
|
+
u.node; // { kind: "string", refinements: [...], modifiers: { optional: true } }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## v0.1 IR capacity vs capability
|
|
34
|
+
|
|
35
|
+
v0.1 declares the IR shape for all 12 node kinds, but only ships builders for seven:
|
|
36
|
+
|
|
37
|
+
| IR node kind | v0.1 builder? |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `StringNode` | yes |
|
|
40
|
+
| `NumberNode` | yes |
|
|
41
|
+
| `BooleanNode` | yes |
|
|
42
|
+
| `LiteralNode` | yes |
|
|
43
|
+
| `EnumNode` | yes |
|
|
44
|
+
| `ArrayNode` | yes |
|
|
45
|
+
| `ObjectNode` | yes |
|
|
46
|
+
| `DateNode` | **no** — future (isoDateTime / isoDate / epochMs / dateObject variants) |
|
|
47
|
+
| `UnionNode` | **no** — future |
|
|
48
|
+
| `RecursiveRefNode` | **no** — future (requires schema id resolver) |
|
|
49
|
+
| `TransformNode` | **no** — future (runtime-only; needs parse engine) |
|
|
50
|
+
|
|
51
|
+
Node kinds without builders are **declared in the IR module but NOT re-exported from the public API**. They describe capacity, not capability. They will become public when their builders ship.
|
|
52
|
+
|
|
53
|
+
## Constraints on future generators
|
|
54
|
+
|
|
55
|
+
When the v0.2+ generators land (TS, Zod, JSON Schema, OpenAPI), they MUST:
|
|
56
|
+
|
|
57
|
+
1. **Consume only `SchemaNode`** — accept a node, walk it, emit output. Never accept a `Schema` instance.
|
|
58
|
+
2. **Be pure functions of the IR** — same IR + same generator version → byte-identical output.
|
|
59
|
+
3. **Mark semantic loss explicitly** — when an output format cannot faithfully represent a node (e.g., a runtime-only refinement in JSON Schema), emit metadata (`x-nekostack-*`) flagging the gap rather than silently lying.
|
|
60
|
+
4. **Live inside this package** initially. Third-party generator plugins are deferred to v1.0+.
|
|
61
|
+
|
|
62
|
+
If a generator pattern-matches against `StringSchema` (the class) instead of `node.kind === "string"` (the IR), reject it. That's the failure mode this contract exists to prevent.
|
|
63
|
+
|
|
64
|
+
## Tests that prove this
|
|
65
|
+
|
|
66
|
+
- [`tests/builders.test.ts`](../tests/builders.test.ts) — `describe("immutability")` proves freezing applies recursively and mutation throws.
|
|
67
|
+
- [`tests/ir.test.ts`](../tests/ir.test.ts) — `describe("serializeIR")` proves keys sort, undefined drops, equivalent IRs produce byte-identical output, distinct IRs differ.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Issue Codes Catalog
|
|
2
|
+
|
|
3
|
+
> Canonical list of stable, machine-readable issue codes emitted by the `@nekostack/schema` runtime. Every validation failure maps to one of these codes.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Unlike raw validation libraries that leak internal error types, `@nekostack/schema` normalizes all issues into a stable vocabulary. This ensures that downstream consumers (UI forms, API responders, audit loggers) can reason about failures without coupling to the underlying execution engine (Zod).
|
|
8
|
+
|
|
9
|
+
Each `Issue` follows this shape:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
type Issue = {
|
|
13
|
+
code: IssueCode; // Stable machine-readable string
|
|
14
|
+
path: Array<string | number>; // JSON path to the offending field
|
|
15
|
+
message: string; // Human-readable description (English by default)
|
|
16
|
+
expected?: unknown; // Optional: what the validator wanted
|
|
17
|
+
received?: unknown; // Optional: what the input actually provided
|
|
18
|
+
schemaId?: string; // Optional: reverse-DNS schema identity
|
|
19
|
+
schemaVersion?: string; // Optional: schema version
|
|
20
|
+
severity: "error" | "warning"; // Always "error" in v0.6+
|
|
21
|
+
metadata?: Record<string, any>;// Additional context (e.g. min/max values)
|
|
22
|
+
};
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Core Codes
|
|
28
|
+
|
|
29
|
+
### `missing_required`
|
|
30
|
+
- **When:** A required field is absent from the input object or is `undefined`.
|
|
31
|
+
- **Precedence:** This code "wins" over `invalid_type` when a field is missing.
|
|
32
|
+
- **Notes:** In NekoStack, `null` is a value, while `undefined` is absence.
|
|
33
|
+
|
|
34
|
+
### `invalid_type`
|
|
35
|
+
- **When:** The input value exists but its primitive type (string, number, boolean) does not match the schema.
|
|
36
|
+
- **Expected/Received:** Populated with the expected and received types (e.g., `expected: "number"`, `received: "string"`).
|
|
37
|
+
|
|
38
|
+
### `unknown_key`
|
|
39
|
+
- **When:** An object schema is `strict` (default) and the input contains keys not defined in the shape.
|
|
40
|
+
- **Metadata:** One issue is emitted per offending key.
|
|
41
|
+
|
|
42
|
+
### `invalid_literal`
|
|
43
|
+
- **When:** The value does not exactly match a constant literal defined via `s.literal(v)`.
|
|
44
|
+
- **Expected/Received:** Populated with the literal values.
|
|
45
|
+
|
|
46
|
+
### `invalid_enum`
|
|
47
|
+
- **When:** The value is not a member of the allowed set defined via `s.enum([...])`.
|
|
48
|
+
- **Expected:** Contains the full array of allowed enum members.
|
|
49
|
+
|
|
50
|
+
### `invalid_union`
|
|
51
|
+
- **When:** None of the branches in a `s.union` or `s.discriminatedUnion` match the input.
|
|
52
|
+
- **Reporting:** Returns issues from the "best-matching" branch (the one that progressed furthest).
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Constraint Codes (Refinements)
|
|
57
|
+
|
|
58
|
+
### `too_small`
|
|
59
|
+
- **When:** A number is below `min()`, a string is shorter than `min()`, or an array has fewer than `minItems()`.
|
|
60
|
+
- **Metadata:**
|
|
61
|
+
- `minimum`: the threshold value.
|
|
62
|
+
- `inclusive`: boolean (true for `min`, false for `gt`).
|
|
63
|
+
- `type`: `"number"`, `"string"`, or `"array"`.
|
|
64
|
+
|
|
65
|
+
### `too_big`
|
|
66
|
+
- **When:** A number is above `max()`, a string is longer than `max()`, or an array has more than `maxItems()`.
|
|
67
|
+
- **Metadata:**
|
|
68
|
+
- `maximum`: the threshold value.
|
|
69
|
+
- `inclusive`: boolean (true for `max`, false for `lt`).
|
|
70
|
+
- `type`: `"number"`, `"string"`, or `"array"`.
|
|
71
|
+
|
|
72
|
+
### `invalid_format`
|
|
73
|
+
- **When:** A string fails a portable format check like `email()`, `uuid()`, `url()`, or `regex()`.
|
|
74
|
+
- **Metadata:**
|
|
75
|
+
- `validation`: `"email"`, `"uuid"`, `"url"`, or `"regex"`.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Advanced / Internal Codes
|
|
80
|
+
|
|
81
|
+
### `custom_refinement_failed`
|
|
82
|
+
- **When:** A runtime-only custom predicate (`s.refine()`) returns false.
|
|
83
|
+
- **Metadata:** May carry engine-specific context if the refinement was leaked from an underlying provider.
|
|
84
|
+
|
|
85
|
+
### `schema_version_unsupported`
|
|
86
|
+
- **When:** The registry finds a schema with the requested ID but the version is incompatible with the requested operation.
|
|
87
|
+
|
|
88
|
+
### `recursive_reference_unresolved`
|
|
89
|
+
- **When:** A `s.lazy()` reference cannot be resolved during compilation or execution (e.g. a missing ID in the registry).
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Fallback Contract
|
|
94
|
+
|
|
95
|
+
If the underlying engine (Zod) emits a code that NekoStack does not yet recognize, the normalizer maps it to **`custom_refinement_failed`** and populates:
|
|
96
|
+
- `metadata.source = "zod"`
|
|
97
|
+
- `metadata.zodCode = <original_code>`
|
|
98
|
+
|
|
99
|
+
This ensures that future engine updates do not break the NekoStack contract.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# JSON Schema Mapping Contract
|
|
2
|
+
|
|
3
|
+
> How `generateJsonSchema` translates a `SchemaNode` into JSON Schema **draft 2020-12**. This file is the contract; the implementation lives in [`../src/generators/json-schema.ts`](../src/generators/json-schema.ts).
|
|
4
|
+
|
|
5
|
+
## Why a contract doc
|
|
6
|
+
|
|
7
|
+
JSON Schema does not represent NekoStack's absence semantics 1:1 with TypeScript or Zod. A separate contract is needed because:
|
|
8
|
+
|
|
9
|
+
- Object-field optionality is encoded in the parent's `required` array, not at the field type.
|
|
10
|
+
- `null` is a value in JSON Schema's type system; nullability extends `type` into an array (`["string", "null"]`).
|
|
11
|
+
- `default` is **annotation only** — JSON Schema validators do not apply defaults during validation.
|
|
12
|
+
- Mutation (the kind of thing `stripUnknown` does at runtime) can't be expressed at all.
|
|
13
|
+
- `pattern` doesn't take flags.
|
|
14
|
+
|
|
15
|
+
Drift between IR semantics and JSON Schema output would silently produce schemas that accept or reject the wrong inputs. This doc pins the rules so the future implementer (and anyone reviewing generator changes) can check against it.
|
|
16
|
+
|
|
17
|
+
## Output model
|
|
18
|
+
|
|
19
|
+
`generateJsonSchema(node, options?)` returns a **complete JSON document** — `$schema` + (`$id` if named) + the body + provenance. Canonical: every object's keys are sorted at every level; 2-space indent; single trailing newline. Same IR + same generator version → byte-identical output.
|
|
20
|
+
|
|
21
|
+
The output models **accepted input**. The output-shape variant (default-applied, all fields required) is not representable as a single JSON Schema; it is deferred to a later phase if a concrete consumer needs it.
|
|
22
|
+
|
|
23
|
+
## Root structure
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
28
|
+
"$id": "urn:nekostack:schema:<id>:<version>",
|
|
29
|
+
"type": "...",
|
|
30
|
+
"properties": { ... },
|
|
31
|
+
"x-nekostack": {
|
|
32
|
+
"generator": "jsonSchema",
|
|
33
|
+
"generatorVersion": "@nekostack/schema@<version>",
|
|
34
|
+
"irHash": "sha256:<64-hex>",
|
|
35
|
+
"schemaId": "<id or null>",
|
|
36
|
+
"schemaVersion": "<version or null>"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Identity (`$id`)
|
|
42
|
+
|
|
43
|
+
Default: **URN**.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
urn:nekostack:schema:<metadata.id>:<metadata.version>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
URL form is opt-in via `options.idBase`:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
generateJsonSchema(node, { idBase: "https://schemas.example.com" });
|
|
53
|
+
// → $id: "https://schemas.example.com/<metadata.id>/<metadata.version>"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Edge cases:
|
|
57
|
+
- **Anonymous schema** (no `metadata.id`) → no `$id` emitted.
|
|
58
|
+
- **No version** → emit URN/URL without the trailing version segment.
|
|
59
|
+
- **Never emits `$defs`** in v0.3 — inline schemas only. Extraction strategy documented for a future phase (when recursive refs or registry-backed refs ship).
|
|
60
|
+
|
|
61
|
+
## Absence semantics
|
|
62
|
+
|
|
63
|
+
For an object field with the given IR modifiers:
|
|
64
|
+
|
|
65
|
+
| IR modifier | In `required`? | Field `type` | `default` annotation? |
|
|
66
|
+
|---|---|---|---|
|
|
67
|
+
| (none) | yes | base | — |
|
|
68
|
+
| `optional()` | **no** | base | — |
|
|
69
|
+
| `nullable()` | yes | `["base", "null"]` | — |
|
|
70
|
+
| `nullish()` | **no** | `["base", "null"]` | — |
|
|
71
|
+
| `default(v)` | **no** | base | `default: v` + `x-nekostack-default-applied-by: "runtime"` |
|
|
72
|
+
|
|
73
|
+
`default` is JSON Schema annotation only — validators do not apply it. The `x-nekostack-default-applied-by: "runtime"` extension tells NekoStack-aware consumers that the runtime (or the generated Zod) is responsible for filling in the default.
|
|
74
|
+
|
|
75
|
+
The output models input. The asymmetry from v0.1 (default → input-optional, output-required) does not survive cleanly to JSON Schema; the input side is what's represented.
|
|
76
|
+
|
|
77
|
+
## Object unknown-key policy
|
|
78
|
+
|
|
79
|
+
| IR `unknownKeys` | JSON Schema |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `"strict"` | `additionalProperties: false` |
|
|
82
|
+
| `"passthrough"` | `additionalProperties: true` |
|
|
83
|
+
| `"stripUnknown"` | `additionalProperties: true` + `x-nekostack-strip: true` |
|
|
84
|
+
|
|
85
|
+
**Why `true` (not `false`) for `stripUnknown`:** `stripUnknown` means *input may carry unknown keys; the runtime strips them; the result is clean*. JSON Schema models accepted input. Emitting `additionalProperties: false` would make validators **reject** inputs that `stripUnknown` is supposed to **accept** — that's `strict` semantics in disguise.
|
|
86
|
+
|
|
87
|
+
JSON Schema cannot express mutation, so the strip step lives in the runtime; the `x-nekostack-strip: true` extension tells NekoStack-aware consumers to perform it.
|
|
88
|
+
|
|
89
|
+
## Portable refinements
|
|
90
|
+
|
|
91
|
+
| IR refinement | JSON Schema |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `minLength` | `minLength` |
|
|
94
|
+
| `maxLength` | `maxLength` |
|
|
95
|
+
| `length` | `minLength` + `maxLength` (both set to value) |
|
|
96
|
+
| `regex` (no flags) | `pattern` |
|
|
97
|
+
| `regex` (non-empty flags) | **throws** — see "Throw contract" |
|
|
98
|
+
| `email` | `format: "email"` |
|
|
99
|
+
| `uuid` | `format: "uuid"` |
|
|
100
|
+
| `url` | `format: "uri"` |
|
|
101
|
+
| `int` | `type: "integer"` (replaces `type: "number"`) |
|
|
102
|
+
| `min` (number) | `minimum` |
|
|
103
|
+
| `max` (number) | `maximum` |
|
|
104
|
+
| `gt` (number) | `exclusiveMinimum` |
|
|
105
|
+
| `lt` (number) | `exclusiveMaximum` |
|
|
106
|
+
| `multipleOf` | `multipleOf` |
|
|
107
|
+
| `minItems` (array) | `minItems` |
|
|
108
|
+
| `maxItems` (array) | `maxItems` |
|
|
109
|
+
|
|
110
|
+
## Throw contract
|
|
111
|
+
|
|
112
|
+
The generator throws `UnsupportedNodeKindError` rather than silently producing a schema that changes validation semantics:
|
|
113
|
+
|
|
114
|
+
| Case | `kind` | Rationale |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| IR kind `date` / `union` / `recursiveRef` / `transform` | the kind name | No mapping in v0.3 |
|
|
117
|
+
| Runtime refinement (`refinements[i].kind === "runtime"`) | `"runtimeRefinement"` | Emitting JSON Schema without it would silently accept inputs the IR rejects |
|
|
118
|
+
| `regex` with non-empty flags | `"regexFlags"` | JSON Schema `pattern` has no flag support; emitting source-only would drop case-insensitivity / unicode / etc. — that's a behavior change, not metadata loss |
|
|
119
|
+
|
|
120
|
+
Error shape (same as v0.2):
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
{
|
|
124
|
+
code: "UNSUPPORTED_NODE_KIND",
|
|
125
|
+
kind: "date" | "union" | "recursiveRef" | "transform" | "runtimeRefinement" | "regexFlags",
|
|
126
|
+
generator: "jsonSchema",
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Tests assert on `code` / `kind` / `generator` — never on message text.
|
|
131
|
+
|
|
132
|
+
## Semantic-loss extensions (`x-nekostack-*`)
|
|
133
|
+
|
|
134
|
+
When JSON Schema can accept the same input but cannot express a NekoStack runtime action or annotation directly, the generator emits an `x-nekostack-*` extension key:
|
|
135
|
+
|
|
136
|
+
| Extension | Where | Meaning |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `x-nekostack-default-applied-by: "runtime"` | on a node with `default()` | JSON Schema does not apply defaults at validation; the runtime must |
|
|
139
|
+
| `x-nekostack-strip: true` | on an object with `stripUnknown` | The schema accepts unknown keys; the runtime must strip them |
|
|
140
|
+
|
|
141
|
+
When emitting would *change validation behavior* (runtime refinements, regex flags), the generator throws instead. The rule of thumb: metadata = "JSON Schema validates the same thing, downstream needs to know more"; throw = "emitting would change what the schema validates."
|
|
142
|
+
|
|
143
|
+
## Test coverage
|
|
144
|
+
|
|
145
|
+
- [`../tests/generators/json-schema.test.ts`](../tests/generators/json-schema.test.ts) — snapshot + identity + absence-semantics + object policy + throw cases.
|
|
146
|
+
- [`../tests/generators/json-schema-ajv2020-self.test.ts`](../tests/generators/json-schema-ajv2020-self.test.ts) — Ajv2020 `addSchema()` against each generated schema, confirming it is itself a valid draft-2020-12 document.
|
|
147
|
+
- [`../tests/generators/json-schema-ajv2020-exec.test.ts`](../tests/generators/json-schema-ajv2020-exec.test.ts) — Ajv2020 `compile()` then run against expected-pass / expected-fail inputs from the v0.1/v0.2 absence-semantics matrix.
|
|
148
|
+
|
|
149
|
+
If a future change to the generator would break this contract, the relevant test fails. That's the gate.
|