@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.
Files changed (214) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +202 -0
  3. package/README.md +656 -0
  4. package/dist/src/builders/array.d.ts +12 -0
  5. package/dist/src/builders/array.d.ts.map +1 -0
  6. package/dist/src/builders/array.js +29 -0
  7. package/dist/src/builders/array.js.map +1 -0
  8. package/dist/src/builders/object.d.ts +62 -0
  9. package/dist/src/builders/object.d.ts.map +1 -0
  10. package/dist/src/builders/object.js +263 -0
  11. package/dist/src/builders/object.js.map +1 -0
  12. package/dist/src/builders/primitives.d.ts +37 -0
  13. package/dist/src/builders/primitives.d.ts.map +1 -0
  14. package/dist/src/builders/primitives.js +125 -0
  15. package/dist/src/builders/primitives.js.map +1 -0
  16. package/dist/src/builders/s.d.ts +27 -0
  17. package/dist/src/builders/s.d.ts.map +1 -0
  18. package/dist/src/builders/s.js +39 -0
  19. package/dist/src/builders/s.js.map +1 -0
  20. package/dist/src/builders/schema.d.ts +70 -0
  21. package/dist/src/builders/schema.d.ts.map +1 -0
  22. package/dist/src/builders/schema.js +98 -0
  23. package/dist/src/builders/schema.js.map +1 -0
  24. package/dist/src/cli-integration.d.ts +43 -0
  25. package/dist/src/cli-integration.d.ts.map +1 -0
  26. package/dist/src/cli-integration.js +48 -0
  27. package/dist/src/cli-integration.js.map +1 -0
  28. package/dist/src/errors/issue.d.ts +34 -0
  29. package/dist/src/errors/issue.d.ts.map +1 -0
  30. package/dist/src/errors/issue.js +89 -0
  31. package/dist/src/errors/issue.js.map +1 -0
  32. package/dist/src/generators/errors.d.ts +31 -0
  33. package/dist/src/generators/errors.d.ts.map +1 -0
  34. package/dist/src/generators/errors.js +34 -0
  35. package/dist/src/generators/errors.js.map +1 -0
  36. package/dist/src/generators/header.d.ts +42 -0
  37. package/dist/src/generators/header.d.ts.map +1 -0
  38. package/dist/src/generators/header.js +43 -0
  39. package/dist/src/generators/header.js.map +1 -0
  40. package/dist/src/generators/json-schema-meta.d.ts +36 -0
  41. package/dist/src/generators/json-schema-meta.d.ts.map +1 -0
  42. package/dist/src/generators/json-schema-meta.js +35 -0
  43. package/dist/src/generators/json-schema-meta.js.map +1 -0
  44. package/dist/src/generators/json-schema.d.ts +26 -0
  45. package/dist/src/generators/json-schema.d.ts.map +1 -0
  46. package/dist/src/generators/json-schema.js +88 -0
  47. package/dist/src/generators/json-schema.js.map +1 -0
  48. package/dist/src/generators/openapi.d.ts +33 -0
  49. package/dist/src/generators/openapi.d.ts.map +1 -0
  50. package/dist/src/generators/openapi.js +61 -0
  51. package/dist/src/generators/openapi.js.map +1 -0
  52. package/dist/src/generators/schema-fragment.d.ts +55 -0
  53. package/dist/src/generators/schema-fragment.d.ts.map +1 -0
  54. package/dist/src/generators/schema-fragment.js +253 -0
  55. package/dist/src/generators/schema-fragment.js.map +1 -0
  56. package/dist/src/generators/ts.d.ts +19 -0
  57. package/dist/src/generators/ts.d.ts.map +1 -0
  58. package/dist/src/generators/ts.js +252 -0
  59. package/dist/src/generators/ts.js.map +1 -0
  60. package/dist/src/generators/types.d.ts +96 -0
  61. package/dist/src/generators/types.d.ts.map +1 -0
  62. package/dist/src/generators/types.js +10 -0
  63. package/dist/src/generators/types.js.map +1 -0
  64. package/dist/src/generators/version.d.ts +11 -0
  65. package/dist/src/generators/version.d.ts.map +1 -0
  66. package/dist/src/generators/version.js +11 -0
  67. package/dist/src/generators/version.js.map +1 -0
  68. package/dist/src/generators/zod-mapping.d.ts +90 -0
  69. package/dist/src/generators/zod-mapping.d.ts.map +1 -0
  70. package/dist/src/generators/zod-mapping.js +174 -0
  71. package/dist/src/generators/zod-mapping.js.map +1 -0
  72. package/dist/src/generators/zod.d.ts +17 -0
  73. package/dist/src/generators/zod.d.ts.map +1 -0
  74. package/dist/src/generators/zod.js +118 -0
  75. package/dist/src/generators/zod.js.map +1 -0
  76. package/dist/src/index.d.ts +21 -0
  77. package/dist/src/index.d.ts.map +1 -0
  78. package/dist/src/index.js +43 -0
  79. package/dist/src/index.js.map +1 -0
  80. package/dist/src/ir/hash.d.ts +19 -0
  81. package/dist/src/ir/hash.d.ts.map +1 -0
  82. package/dist/src/ir/hash.js +22 -0
  83. package/dist/src/ir/hash.js.map +1 -0
  84. package/dist/src/ir/nodes.d.ts +121 -0
  85. package/dist/src/ir/nodes.d.ts.map +1 -0
  86. package/dist/src/ir/nodes.js +14 -0
  87. package/dist/src/ir/nodes.js.map +1 -0
  88. package/dist/src/ir/serialize.d.ts +8 -0
  89. package/dist/src/ir/serialize.d.ts.map +1 -0
  90. package/dist/src/ir/serialize.js +23 -0
  91. package/dist/src/ir/serialize.js.map +1 -0
  92. package/dist/src/migrations/build-migration-registry.d.ts +46 -0
  93. package/dist/src/migrations/build-migration-registry.d.ts.map +1 -0
  94. package/dist/src/migrations/build-migration-registry.js +134 -0
  95. package/dist/src/migrations/build-migration-registry.js.map +1 -0
  96. package/dist/src/migrations/handlers/list.d.ts +35 -0
  97. package/dist/src/migrations/handlers/list.d.ts.map +1 -0
  98. package/dist/src/migrations/handlers/list.js +55 -0
  99. package/dist/src/migrations/handlers/list.js.map +1 -0
  100. package/dist/src/migrations/handlers/plan.d.ts +26 -0
  101. package/dist/src/migrations/handlers/plan.d.ts.map +1 -0
  102. package/dist/src/migrations/handlers/plan.js +28 -0
  103. package/dist/src/migrations/handlers/plan.js.map +1 -0
  104. package/dist/src/migrations/handlers/stub.d.ts +22 -0
  105. package/dist/src/migrations/handlers/stub.d.ts.map +1 -0
  106. package/dist/src/migrations/handlers/stub.js +24 -0
  107. package/dist/src/migrations/handlers/stub.js.map +1 -0
  108. package/dist/src/migrations/handlers/verify.d.ts +25 -0
  109. package/dist/src/migrations/handlers/verify.d.ts.map +1 -0
  110. package/dist/src/migrations/handlers/verify.js +27 -0
  111. package/dist/src/migrations/handlers/verify.js.map +1 -0
  112. package/dist/src/migrations/parse-provenance.d.ts +78 -0
  113. package/dist/src/migrations/parse-provenance.d.ts.map +1 -0
  114. package/dist/src/migrations/parse-provenance.js +157 -0
  115. package/dist/src/migrations/parse-provenance.js.map +1 -0
  116. package/dist/src/migrations/plan-migration.d.ts +50 -0
  117. package/dist/src/migrations/plan-migration.d.ts.map +1 -0
  118. package/dist/src/migrations/plan-migration.js +256 -0
  119. package/dist/src/migrations/plan-migration.js.map +1 -0
  120. package/dist/src/migrations/stub.d.ts +55 -0
  121. package/dist/src/migrations/stub.d.ts.map +1 -0
  122. package/dist/src/migrations/stub.js +201 -0
  123. package/dist/src/migrations/stub.js.map +1 -0
  124. package/dist/src/migrations/types.d.ts +297 -0
  125. package/dist/src/migrations/types.d.ts.map +1 -0
  126. package/dist/src/migrations/types.js +28 -0
  127. package/dist/src/migrations/types.js.map +1 -0
  128. package/dist/src/migrations/verify-provenance.d.ts +46 -0
  129. package/dist/src/migrations/verify-provenance.d.ts.map +1 -0
  130. package/dist/src/migrations/verify-provenance.js +158 -0
  131. package/dist/src/migrations/verify-provenance.js.map +1 -0
  132. package/dist/src/registry/build-registry.d.ts +65 -0
  133. package/dist/src/registry/build-registry.d.ts.map +1 -0
  134. package/dist/src/registry/build-registry.js +172 -0
  135. package/dist/src/registry/build-registry.js.map +1 -0
  136. package/dist/src/registry/diff.d.ts +25 -0
  137. package/dist/src/registry/diff.d.ts.map +1 -0
  138. package/dist/src/registry/diff.js +497 -0
  139. package/dist/src/registry/diff.js.map +1 -0
  140. package/dist/src/registry/handlers/check.d.ts +57 -0
  141. package/dist/src/registry/handlers/check.d.ts.map +1 -0
  142. package/dist/src/registry/handlers/check.js +181 -0
  143. package/dist/src/registry/handlers/check.js.map +1 -0
  144. package/dist/src/registry/handlers/diff.d.ts +33 -0
  145. package/dist/src/registry/handlers/diff.d.ts.map +1 -0
  146. package/dist/src/registry/handlers/diff.js +61 -0
  147. package/dist/src/registry/handlers/diff.js.map +1 -0
  148. package/dist/src/registry/handlers/generate.d.ts +87 -0
  149. package/dist/src/registry/handlers/generate.d.ts.map +1 -0
  150. package/dist/src/registry/handlers/generate.js +223 -0
  151. package/dist/src/registry/handlers/generate.js.map +1 -0
  152. package/dist/src/registry/handlers/list.d.ts +36 -0
  153. package/dist/src/registry/handlers/list.d.ts.map +1 -0
  154. package/dist/src/registry/handlers/list.js +84 -0
  155. package/dist/src/registry/handlers/list.js.map +1 -0
  156. package/dist/src/registry/parse-provenance.d.ts +63 -0
  157. package/dist/src/registry/parse-provenance.d.ts.map +1 -0
  158. package/dist/src/registry/parse-provenance.js +182 -0
  159. package/dist/src/registry/parse-provenance.js.map +1 -0
  160. package/dist/src/registry/source-hash.d.ts +28 -0
  161. package/dist/src/registry/source-hash.d.ts.map +1 -0
  162. package/dist/src/registry/source-hash.js +32 -0
  163. package/dist/src/registry/source-hash.js.map +1 -0
  164. package/dist/src/registry/types.d.ts +185 -0
  165. package/dist/src/registry/types.d.ts.map +1 -0
  166. package/dist/src/registry/types.js +22 -0
  167. package/dist/src/registry/types.js.map +1 -0
  168. package/dist/src/runtime/compile.d.ts +38 -0
  169. package/dist/src/runtime/compile.d.ts.map +1 -0
  170. package/dist/src/runtime/compile.js +45 -0
  171. package/dist/src/runtime/compile.js.map +1 -0
  172. package/dist/src/runtime/errors.d.ts +25 -0
  173. package/dist/src/runtime/errors.d.ts.map +1 -0
  174. package/dist/src/runtime/errors.js +43 -0
  175. package/dist/src/runtime/errors.js.map +1 -0
  176. package/dist/src/runtime/normalize-issues.d.ts +65 -0
  177. package/dist/src/runtime/normalize-issues.d.ts.map +1 -0
  178. package/dist/src/runtime/normalize-issues.js +208 -0
  179. package/dist/src/runtime/normalize-issues.js.map +1 -0
  180. package/dist/src/runtime/parse.d.ts +62 -0
  181. package/dist/src/runtime/parse.d.ts.map +1 -0
  182. package/dist/src/runtime/parse.js +107 -0
  183. package/dist/src/runtime/parse.js.map +1 -0
  184. package/dist/src/runtime/strip-defaults.d.ts +51 -0
  185. package/dist/src/runtime/strip-defaults.d.ts.map +1 -0
  186. package/dist/src/runtime/strip-defaults.js +81 -0
  187. package/dist/src/runtime/strip-defaults.js.map +1 -0
  188. package/dist/src/runtime/zod-compile.d.ts +27 -0
  189. package/dist/src/runtime/zod-compile.d.ts.map +1 -0
  190. package/dist/src/runtime/zod-compile.js +92 -0
  191. package/dist/src/runtime/zod-compile.js.map +1 -0
  192. package/dist/src/types.d.ts +116 -0
  193. package/dist/src/types.d.ts.map +1 -0
  194. package/dist/src/types.js +2 -0
  195. package/dist/src/types.js.map +1 -0
  196. package/docs/ABSENCE_SEMANTICS.md +37 -0
  197. package/docs/BENCHMARKS.md +64 -0
  198. package/docs/COMPOSITION.md +206 -0
  199. package/docs/DIFF_CLASSIFICATION.md +137 -0
  200. package/docs/EXAMPLES.md +221 -0
  201. package/docs/HEADER_FORMAT.md +66 -0
  202. package/docs/INVARIANTS.md +58 -0
  203. package/docs/IR_CONTRACT.md +67 -0
  204. package/docs/ISSUE_CODES.md +99 -0
  205. package/docs/JSON_SCHEMA_MAPPING.md +149 -0
  206. package/docs/MIGRATIONS.md +406 -0
  207. package/docs/MIGRATION_GUIDE.md +150 -0
  208. package/docs/OPENAPI_MAPPING.md +66 -0
  209. package/docs/REGISTRY.md +336 -0
  210. package/docs/RUNTIME.md +279 -0
  211. package/docs/SCOPE.md +119 -0
  212. package/docs/USAGE.md +376 -0
  213. package/docs/ZOD_MODIFIER_ORDERING.md +77 -0
  214. package/package.json +45 -0
@@ -0,0 +1,206 @@
1
+ # Composition Contract
2
+
3
+ > How the seven `ObjectSchema` composition operators behave. This file is the contract; the implementation lives in [`../src/builders/object.ts`](../src/builders/object.ts) and the type helpers in [`../src/types.ts`](../src/types.ts).
4
+
5
+ ## Quick reference
6
+
7
+ ```ts
8
+ const A = s.object({ id: s.string(), name: s.string() });
9
+ const B = s.object({ id: s.number(), email: s.string() });
10
+
11
+ A.extend({ email: s.string() }); // add new fields; throws on key collision
12
+ A.pick({ id: true }); // keep only named keys
13
+ A.omit({ id: true }); // remove named keys
14
+ A.partial(); // all fields optional + default stripped
15
+ A.partial({ name: true }); // granular form: only the named keys
16
+ A.required(); // all fields required + default stripped
17
+ A.required({ name: true }); // granular form
18
+ A.merge(B); // throws: 'id' conflict
19
+ A.merge(B, { conflict: "right" }); // right wins
20
+ A.merge(B, { unknownKeys: "left" }); // resolves an unknownKeys mismatch
21
+ A.override({ id: s.number() }); // replace; throws if key absent
22
+ ```
23
+
24
+ ## The three universal rules
25
+
26
+ Every composition operator follows these:
27
+
28
+ 1. **Returns a new `ObjectSchema`.** No mutation of the receiver or arguments.
29
+ 2. **Fails loudly.** Collisions, unknown keys, missing keys, and unknownKeys mismatches throw — never silently. Silent composition is the failure mode v0.5 exists to prevent.
30
+ 3. **Drops top-level metadata.** Composed schemas lose `id` / `version` / `description` / `deprecated`. The author must re-tag the result explicitly. This prevents implicit identity preservation that would cause v0.7 registry collisions ("two different `com.x.User` schemas — one's the original, one's a `pick`-narrowed view").
31
+
32
+ Field-level metadata is preserved across all operators.
33
+
34
+ ## The seven operators
35
+
36
+ ### `extend(extension)` — add fields
37
+
38
+ ```ts
39
+ const A = s.object({ id: s.string() });
40
+ A.extend({ name: s.string() });
41
+ // { id, name }
42
+
43
+ A.extend({ id: s.number() });
44
+ // throws: key 'id' already exists in base
45
+ ```
46
+
47
+ Throws on any key in `extension` that already exists in the base. To deliberately replace a field, use `override`. To combine two schemas with explicit conflict policy, use `merge`.
48
+
49
+ ### `pick(mask)` / `omit(mask)` — narrow
50
+
51
+ ```ts
52
+ const Base = s.object({ id: s.string(), name: s.string(), age: s.number() });
53
+ Base.pick({ id: true, name: true }); // { id, name }
54
+ Base.omit({ age: true }); // { id, name }
55
+
56
+ Base.pick({ missing: true });
57
+ // throws: key 'missing' does not exist in base shape
58
+ ```
59
+
60
+ Both throw on any key in the mask that's not in the base — catches refactor drift where a key was renamed and a downstream `pick` still references the old name.
61
+
62
+ ### `partial(mask?)` — make optional, strip default
63
+
64
+ ```ts
65
+ const A = s.object({ id: s.string(), role: s.string().default("member") });
66
+ const P = A.partial();
67
+ // Both fields become input-optional + output-optional.
68
+ // IMPORTANT: `role` loses its default. A partial schema should not silently
69
+ // inject defaults into a PATCH/update payload.
70
+
71
+ A.partial({ id: true });
72
+ // granular form: only `id` becomes optional; `role` unchanged
73
+ ```
74
+
75
+ `partial` and `required` are **symmetric on `default`**: both strip it. Rationale: in the v0.1 absence-semantics contract, `default(v)` means input-optional + output-required (runtime fills the missing value). Preserving `default` through `partial()` would leave output-required while claiming to be optional — a direct contradiction. The symmetric strip is the only self-consistent rule.
76
+
77
+ If you need to preserve defaults across composition, don't use `partial` / `required` — re-author the field explicitly or compose at the IR level.
78
+
79
+ ### `required(mask?)` — make required, strip default
80
+
81
+ ```ts
82
+ const A = s.object({
83
+ id: s.string().optional(),
84
+ role: s.string().default("member"),
85
+ });
86
+ const R = A.required();
87
+ // Both fields become input-required + output-required.
88
+ // `role` loses its default — required + default-bearing was semantically
89
+ // contradictory anyway ("the field is required, but accepts missing input?").
90
+ ```
91
+
92
+ ### `merge(other, options?)` — combine
93
+
94
+ ```ts
95
+ const A = s.object({ id: s.string(), shared: s.string() });
96
+ const B = s.object({ name: s.string(), shared: s.number() });
97
+
98
+ A.merge(B);
99
+ // throws: field 'shared' exists in both operands.
100
+ // Pass { conflict: "left" } or { conflict: "right" } to resolve.
101
+
102
+ A.merge(B, { conflict: "left" }); // shared is string (A's type)
103
+ A.merge(B, { conflict: "right" }); // shared is number (B's type)
104
+ ```
105
+
106
+ `merge` has two independent knobs:
107
+
108
+ | Knob | Default | Meaning |
109
+ |---|---|---|
110
+ | `conflict` | `"throw"` | How field-level overlaps are resolved |
111
+ | `unknownKeys` | `"throw"` | How object-level `unknownKeys` policy mismatches are resolved |
112
+
113
+ Both default to `"throw"`. Same-policy merges (both operands `strict`, both `passthrough`, both `stripUnknown`) don't need the `unknownKeys` option. Mismatched merges must resolve explicitly:
114
+
115
+ ```ts
116
+ const Strict = s.object({ id: s.string() }); // strict
117
+ const Loose = s.object({ name: s.string() }).passthrough(); // passthrough
118
+
119
+ Strict.merge(Loose);
120
+ // throws: unknownKeys policies differ.
121
+
122
+ Strict.merge(Loose, { unknownKeys: "right" });
123
+ // passthrough wins; merged object accepts unknown keys
124
+ ```
125
+
126
+ The two knobs are independent: `A.merge(B, { unknownKeys: "right" })` works when fields don't conflict but policies do.
127
+
128
+ Why fail-loudly on `unknownKeys`: it's a real validation-semantics policy (strict vs passthrough changes which inputs validate), not cosmetic. Silently dropping the right operand's policy would be exactly the failure mode v0.5's other operators are built to prevent.
129
+
130
+ #### Type-level overload selection
131
+
132
+ `merge` has three overloads:
133
+
134
+ ```ts
135
+ merge(other) // → ObjectSchema<MergeThrowShape<S, Other>>
136
+ merge(other, { conflict?: "throw", unknownKeys?: ... }) // → ObjectSchema<MergeThrowShape<S, Other>>
137
+ merge(other, { conflict: "left", unknownKeys?: ... }) // → ObjectSchema<MergeLeftShape<S, Other>>
138
+ merge(other, { conflict: "right", unknownKeys?: ... }) // → ObjectSchema<MergeRightShape<S, Other>>
139
+ ```
140
+
141
+ `MergeThrowShape<S, Other>` is `Identity<S & Other>`. It preserves disjoint merges and lets TypeScript surface some conflicts through normal intersection behavior where possible, but **runtime conflict detection is the load-bearing guarantee**. Consumers must not rely on `MergeThrowShape` as the sole conflict detector — call `merge` with explicit `conflict: "left"` / `"right"` when you intend to resolve overlaps, and let the runtime throw catch the unintended ones.
142
+
143
+ `MergeLeftShape` / `MergeRightShape` resolve overlaps by picking the corresponding side's field type.
144
+
145
+ ### `override(overrides)` — replace existing keys
146
+
147
+ ```ts
148
+ const Base = s.object({ id: s.string(), name: s.string() });
149
+
150
+ Base.override({ id: s.number() });
151
+ // id is now a number; name unchanged
152
+
153
+ Base.override({ missing: s.string() });
154
+ // throws: key 'missing' does not exist in base
155
+ ```
156
+
157
+ Throws on any key in `overrides` that's NOT in the base. To add new fields, use `extend`. To replace an existing field's schema with a different type (string → number, etc.), use `override`. The `OverrideMask<S>` constraint permits any `AnySchema` as a value — that's the whole point.
158
+
159
+ `extend` and `override` are deliberately asymmetric: `extend` rejects existing keys, `override` rejects missing keys. The pair covers add and replace without overlap.
160
+
161
+ ## Object-level `unknownKeys` policy
162
+
163
+ Single-object operators (`pick` / `omit` / `partial` / `required` / `extend`) preserve the base's policy. Only `merge` can encounter a mismatch — handled by the `unknownKeys` knob on `MergeOptions` (see above).
164
+
165
+ ## Object-level refinements
166
+
167
+ v0.1 doesn't yet support object-level refinements (only field-level). The `Schema` base class has a `refinements` array, but `ObjectSchema` in practice doesn't accumulate them. If a future feature adds object-level validators (e.g., cross-field constraints), composition needs to revisit them.
168
+
169
+ For now: single-object operators pass through any object-level refinements unchanged; `merge` drops the right operand's object-level refinements. Document re-asserts the policy when object-level refinements ship.
170
+
171
+ ## Type inference contract
172
+
173
+ All operators preserve the v0.1 absence-semantics contract end-to-end. The per-operator type-level behavior:
174
+
175
+ | Operator | Effect on per-field `TInputKey` / `TOutputKey` |
176
+ |---|---|
177
+ | `extend(E)` | Each E field's keys carry through unchanged |
178
+ | `pick` / `omit` | Surviving fields' keys carry through unchanged |
179
+ | `partial()` | Affected fields' keys both become `"optional"`; TInput/TOutput widen with `\| undefined` |
180
+ | `required()` | Affected fields' keys both become `"required"`; TInput/TOutput narrow via `Exclude<…, undefined>` |
181
+ | `merge` (`"left"`) | Left's keys win for overlaps |
182
+ | `merge` (`"right"`) | Right's keys win for overlaps |
183
+ | `merge` (`"throw"`) | TS intersection (`S & Other`); preserves disjoint/compatible merges, but runtime conflict detection is the load-bearing guarantee |
184
+ | `override` | Replacement field's keys fully replace the base field's |
185
+
186
+ `s.input<typeof Composed>` and `s.output<typeof Composed>` produce the right shapes per the v0.1 contract — type-level tests in [`../tests/composition.test-d.ts`](../tests/composition.test-d.ts) cover every operator.
187
+
188
+ ## Generator parity
189
+
190
+ Composition produces a plain `ObjectNode` — no new IR kind, no generator changes. The four generators (TS, Zod, JSON Schema, OpenAPI) handle composed schemas via the shared `emitSchemaFragment`. This is asserted in [`../tests/composition-generator-parity.test.ts`](../tests/composition-generator-parity.test.ts): for each operator, the composed schema's generator output is byte-identical to an equivalent hand-written `s.object({...})`.
191
+
192
+ Notably, `partial`'s default-strip is observable end-to-end: the Zod chain has no `.default()` call; the JSON Schema output has no `default` key or `x-nekostack-default-applied-by` extension.
193
+
194
+ ## What composition does NOT do
195
+
196
+ | Feature | Reason it's deferred |
197
+ |---|---|
198
+ | Composition on non-object schemas (`s.array().extend()`) | No sensible meaning |
199
+ | Deep / recursive composition (nested-field merge) | Adds complexity v0.5 doesn't need; shallow is sufficient |
200
+ | Cross-schema `$ref` | v0.7 registry-lite |
201
+ | Composition history metadata (`metadata.derivedFrom`) | Could help v0.7 diffing; not load-bearing for v0.5 |
202
+ | `merge` with `conflict: "merge"` (recursive type-union) | Too complex; only `"left"` / `"right"` ship in v0.5 |
203
+ | Static `s.merge(A, B)` top-level form | Method-on-instance is sufficient |
204
+ | `omitMatching` / `pickMatching` with predicates | Not needed for the static-key surface |
205
+
206
+ If a real consumer hits one of these, they ship in their own phase plan.
@@ -0,0 +1,137 @@
1
+ # Diff Classification Contract
2
+
3
+ > The locked breaking / additive / cosmetic table that `diffNodes` and `diffHandler` implement, plus the lens, aggregation, and unsupported-kind rules. This file is the contract; the implementations live in [`../src/registry/diff.ts`](../src/registry/diff.ts) and [`../src/registry/handlers/diff.ts`](../src/registry/handlers/diff.ts). Every row in the table below has a fixture pair in [`../tests/registry/diff-classifier.test.ts`](../tests/registry/diff-classifier.test.ts), and the aggregation rule is gated by [`../tests/registry/handlers/diff-handler.test.ts`](../tests/registry/handlers/diff-handler.test.ts).
4
+
5
+ ## Severity lens
6
+
7
+ **Input-acceptance compatibility** is the primary lens.
8
+
9
+ > *Would data that the **old** schema accepted still be accepted by the **new** schema?*
10
+
11
+ - **No** → `breaking`.
12
+ - **Yes**, and the new schema *also* accepts inputs the old one rejected → `additive`.
13
+ - **Same accepted set** (no validation effect) → `cosmetic`.
14
+
15
+ Output-shape compatibility is **a separate concern**. It is reflected in the change's `kind` field — `default_removed`, `default_value_changed`, etc. — but does *not* split the severity. Consumers needing an output-side reading apply their own lens over `change.kind`.
16
+
17
+ This lens is deliberate. The Master plan locked the single-severity rule (Decision #11) so the CLI's exit-code mapping (Decision #13) stays mechanical: any `breaking` row anywhere in a diff means a non-zero exit. A multi-lens classifier would either re-introduce judgment or require a flag-per-lens that v0.7 has not budgeted.
18
+
19
+ ## Severity values
20
+
21
+ | Severity | Meaning | CLI exit (Step 28+) |
22
+ |---|---|---|
23
+ | `breaking` | At least one input the old schema accepted is rejected by the new schema. | non-zero |
24
+ | `additive` | The new schema's accepted set is a strict superset of the old one's. | zero (with notice) |
25
+ | `cosmetic` | The accepted sets are identical; only metadata or ordering changed. | zero |
26
+
27
+ The values are declared in [`../src/registry/types.ts`](../src/registry/types.ts) and the type is re-exported from the integration subpath [`@nekostack/schema/cli`](../src/cli-integration.ts).
28
+
29
+ ## `worstSeverity` aggregation
30
+
31
+ `diffHandler` collapses the change list into one severity for CLI consumption:
32
+
33
+ ```text
34
+ worstSeverity = max(severity over changes), where breaking > additive > cosmetic
35
+ worstSeverity = null ⇔ changes.length === 0
36
+ ```
37
+
38
+ Precedence is locked: `breaking > additive > cosmetic`. The implementation lives in `computeWorstSeverity` ([`../src/registry/handlers/diff.ts`](../src/registry/handlers/diff.ts)). `null` is the sentinel for "no changes detected" — distinct from `cosmetic`, which means "changes exist but none affect validation."
39
+
40
+ This is also the realization rule for Master plan Decision #13: a `schemaVersion` bump paired with structural changes inherits the worst structural severity, because every change keeps its own row in the list and the `schemaVersion` row's `cosmetic` severity loses the `max` against any `breaking`/`additive` structural row.
41
+
42
+ ## Classification table (locked — Master plan Decision #12)
43
+
44
+ Every row is gated by a fixture pair in `diff-classifier.test.ts`. Adding, removing, or reclassifying a row is a contract change and requires a Master plan amendment.
45
+
46
+ | Change | Severity | `DiffChange.kind` | Notes |
47
+ |---|---|---|---|
48
+ | Top-level `kind` mismatch (e.g. `string` → `number`) | breaking | `type_changed` | Walker stops descending; no per-field diff is emitted past this point. |
49
+ | Add **required** field | breaking | `field_added` | Old data lacking the field now fails. |
50
+ | Add **nullable-only** field (required key, value may be `null`) | breaking | `field_added` | `nullable` does not imply `optional`; old data lacking the field still fails. |
51
+ | Add **optional** field | additive | `field_added` | Absence permitted; old data still validates. |
52
+ | Add **nullish** field (`optional + nullable`) | additive | `field_added` | Absence permitted. |
53
+ | Add **default-bearing** field | additive | `field_added` | Input-optional; old data validates and gains the default at output. |
54
+ | Remove field (any modifier) | breaking | `field_removed` | Consumers reading the field break; old data with the field newly becomes an unknown key. |
55
+ | Tighten refinement (`min` ↑, `max` ↓, new `regex`, `length` change, `multipleOf` change) | breaking | `refinement_changed` | Inputs that passed may now fail. |
56
+ | Loosen refinement (`min` ↓, `max` ↑, removed `regex`) | additive | `refinement_changed` | Strictly more accepting. |
57
+ | Tighten unknown-keys (`passthrough` → `strict`, `stripUnknown` → `strict`) | breaking | `unknown_keys_changed` | Inputs with unknowns now rejected. |
58
+ | Loosen unknown-keys (`strict` → `passthrough`, `strict` → `stripUnknown`) | additive | `unknown_keys_changed` | More accepting. |
59
+ | `passthrough` ↔ `stripUnknown` | cosmetic | `unknown_keys_changed` | Same accepted input set; output-side preserve-vs-drop differs but is *not* the lens. |
60
+ | Add enum value | additive | `enum_value_added` | Strictly more accepting. |
61
+ | Remove enum value | breaking | `enum_value_removed` | Inputs with that value now fail. |
62
+ | Change literal value | breaking | `literal_changed` | The only accepted value changed. |
63
+ | Add `default` to existing required field | additive | `default_added` | Input becomes input-optional; output unaffected for existing inputs. Tracked as one row (the implicit `optional` flip from `.default()` is masked — see [`./ABSENCE_SEMANTICS.md`](./ABSENCE_SEMANTICS.md)). |
64
+ | Remove `default` from a default-bearing field | breaking | `default_removed` | Field flips to input-required and output loses the fill. |
65
+ | Change default value | breaking | `default_value_changed` | Downstream observers see a different filled value at output. |
66
+ | `optional` → `nullable` | breaking | `absence_modifier_changed` | Drops absence-permission; old data lacking the key fails. |
67
+ | `nullable` → `optional` | breaking | `absence_modifier_changed` | Drops `null`-permission; old data with `null` fails. |
68
+ | `optional` → `nullish` | additive | `absence_modifier_changed` | Adds `null`-permission on top of absence. |
69
+ | `nullable` → `nullish` | additive | `absence_modifier_changed` | Adds absence-permission on top of `null`. |
70
+ | `nullish` → `optional` or `nullish` → `nullable` | breaking | `absence_modifier_changed` | Drops half of the previously-accepted absence/`null` permission. |
71
+ | Description / metadata-only edit | cosmetic | `metadata_changed` | No validation effect. Also covers `deprecated` flips and `schemaId` edits. |
72
+ | Refinement reorder (same set, different order) | cosmetic | `refinements_reordered` | Semantic equivalence — see [`./ZOD_MODIFIER_ORDERING.md`](./ZOD_MODIFIER_ORDERING.md) for the normalization contract. |
73
+ | `schemaVersion`-only change | cosmetic | `schema_version_changed` | Tracked separately. See the aggregation rule below. |
74
+
75
+ ### Absence-state superset rule
76
+
77
+ The `absence_modifier_changed` severity is computed mechanically from the input-acceptance superset table in `absenceSeverity` ([`../src/registry/diff.ts`](../src/registry/diff.ts)):
78
+
79
+ ```text
80
+ required ⊂ optional
81
+ required ⊂ nullable
82
+ required ⊂ optional ⊂ nullish
83
+ required ⊂ nullable ⊂ nullish
84
+ ```
85
+
86
+ A transition is `additive` if the new state's accepted set is a superset of the old's; otherwise `breaking`. The masked-`optional` rule (a node carrying `.default()` is not separately reported as having become "optional") prevents the same change from being counted twice.
87
+
88
+ ### Refinement-direction rule
89
+
90
+ Refinement param changes route through `refinementParamSeverity`. Numeric lower-bounds (`minLength`, `min`, `minItems`, `gt`) are `additive` when decreased and `breaking` when increased; numeric upper-bounds (`maxLength`, `max`, `maxItems`, `lt`) are the mirror. Equality-style refinements (`length`, `multipleOf`, `regex`) are conservatively `breaking` on any value change — the accepted set transformation is not monotone in the parameter.
91
+
92
+ ## `schemaVersion` aggregation rule (Master plan Decision #13)
93
+
94
+ - A change to `metadata.version` alone, with no other changes, produces a single `schema_version_changed` row at `cosmetic` severity and `worstSeverity: "cosmetic"`.
95
+ - A `metadata.version` change *paired* with structural changes still produces the `schema_version_changed` row, but `worstSeverity` is the worst severity across the full change list — never silently demoted to `cosmetic` by the presence of the version bump.
96
+
97
+ The CLI surfaces `worstSeverity` directly; this rule keeps a version bump from masking a breaking change.
98
+
99
+ ## Unsupported IR kinds
100
+
101
+ Per Master plan Decision #14, `diffNodes` **throws** `UnsupportedNodeKindError({ generator: "diff", kind })` for any IR kind not in the v0.7 supported set:
102
+
103
+ ```text
104
+ supported: string, number, boolean, literal, enum, array, object
105
+ unsupported: date, union, recursiveRef, transform
106
+ ```
107
+
108
+ `diffHandler` does **not** catch — the throw propagates to the CLI dispatcher (Step 28+), which maps it to a non-zero exit code at the CLI boundary. This matches the v0.3 / v0.6 generator discipline (see [`./IR_CONTRACT.md`](./IR_CONTRACT.md)). Wrapping into an `integrity_error` `Issue` would be semantically wrong: the IR is well-formed; v0.7 simply does not know how to diff it.
109
+
110
+ ## Output-side reading (not the lens)
111
+
112
+ Some rows have a divergent output-side reading. They are *not* classified differently — single-lens is the locked contract — but consumers wanting to reason about output shape can branch on the change's `kind`:
113
+
114
+ | `kind` | Input-lens severity | Output-shape implication |
115
+ |---|---|---|
116
+ | `default_added` | additive | New inputs that omit the field now produce a filled value at output; existing inputs unchanged. |
117
+ | `default_removed` | breaking | Output loses the fill; consumers expecting the field to always be present break. |
118
+ | `default_value_changed` | breaking | Output value differs for the same input; downstream observers see new values. |
119
+ | `unknown_keys_changed` (`passthrough` ↔ `stripUnknown`) | cosmetic | Output differs: `passthrough` preserves unknown keys, `stripUnknown` drops them. |
120
+
121
+ The intent is that any consumer with an output-side concern reads `change.kind` and applies its own lens. The classifier itself stays single-severity.
122
+
123
+ ## Migrations are deferred
124
+
125
+ v0.7 classifies diffs; it does **not** propose, generate, or apply migrations. Migration emission is out of scope and is a v0.8+ concern. Consumers who want a migration today read the `DiffChange[]` payload and synthesize one externally.
126
+
127
+ ## Implementation reference
128
+
129
+ | Surface | File |
130
+ |---|---|
131
+ | `diffNodes(before, after): readonly DiffChange[]` | [`../src/registry/diff.ts`](../src/registry/diff.ts) |
132
+ | `diffHandler({ before, after }): DiffResult` | [`../src/registry/handlers/diff.ts`](../src/registry/handlers/diff.ts) |
133
+ | Types (`DiffSeverity`, `DiffKind`, `DiffChange`, `DiffOpts`, `DiffResult`) | [`../src/registry/types.ts`](../src/registry/types.ts) |
134
+ | `UnsupportedNodeKindError` | [`../src/generators/errors.ts`](../src/generators/errors.ts) |
135
+ | Row-by-row classification fixtures | [`../tests/registry/diff-classifier.test.ts`](../tests/registry/diff-classifier.test.ts) |
136
+ | `worstSeverity` aggregation gate | [`../tests/registry/handlers/diff-handler.test.ts`](../tests/registry/handlers/diff-handler.test.ts) |
137
+ | Master plan source of truth | [`./PHASE_PLAN_v0.7.md`](./PHASE_PLAN_v0.7.md) (Decisions #11 / #12 / #13 / #14) |
@@ -0,0 +1,221 @@
1
+ # Examples — `@nekostack/schema`
2
+
3
+ Three realistic example schemas under [`../examples/`](../examples/), each with its committed generated artifacts under [`../examples/generated/`](../examples/generated/). These files are validated by [`../tests/examples/regenerate.test.ts`](../tests/examples/regenerate.test.ts): if a schema changes and the generated files aren't refreshed, the test fails.
4
+
5
+ To regenerate after a deliberate schema change:
6
+
7
+ ```
8
+ cd packages/schema
9
+ npx vitest run tests/examples/regenerate.test.ts -u
10
+ ```
11
+
12
+ (`-u` updates snapshots — the generated files ARE the snapshots.)
13
+
14
+ ## 1. Tenant — basic entity
15
+
16
+ **Source:** [`../examples/tenant.schema.ts`](../examples/tenant.schema.ts)
17
+
18
+ **Generated:**
19
+ - [`../examples/generated/tenant.types.ts`](../examples/generated/tenant.types.ts) — TS output type
20
+ - [`../examples/generated/tenant.zod.ts`](../examples/generated/tenant.zod.ts) — Zod 3.x validator
21
+ - [`../examples/generated/tenant.json.schema.json`](../examples/generated/tenant.json.schema.json) — JSON Schema draft 2020-12 (URN `$id`)
22
+ - [`../examples/generated/tenant.openapi.json`](../examples/generated/tenant.openapi.json) — OpenAPI 3.1 component schema (no `$schema`, no `$id`; identity is the position in the composed document)
23
+
24
+ **What it demonstrates:**
25
+ - Schema metadata (`.id()`, `.version()`, `.describe()`) in the generated header.
26
+ - UUID + email + regex portable refinements.
27
+ - Enum field with a default (`plan: "free"`).
28
+ - Nullable field (`billingEmail` — required key, value may be `null`).
29
+ - Nested object with required + optional fields.
30
+ - Strict-by-default object policy → emitted `.strict()` in Zod.
31
+
32
+ ## 2. AuditEvent — the input/output split
33
+
34
+ **Source:** [`../examples/audit-event.schema.ts`](../examples/audit-event.schema.ts)
35
+
36
+ **Generated:**
37
+ - [`../examples/generated/audit-event.both.ts`](../examples/generated/audit-event.both.ts) — TS, **`mode: "both"`**, emits `AuditEventInput` + `AuditEventOutput` side-by-side
38
+ - [`../examples/generated/audit-event.zod.ts`](../examples/generated/audit-event.zod.ts) — Zod 3.x validator
39
+ - [`../examples/generated/audit-event.json.schema.json`](../examples/generated/audit-event.json.schema.json) — JSON Schema draft 2020-12 (input-validation; default fields omitted from `required`, `x-nekostack-default-applied-by: "runtime"` on `severity`)
40
+ - [`../examples/generated/audit-event.openapi.json`](../examples/generated/audit-event.openapi.json) — OpenAPI 3.1 component schema (same body shape as the JSON Schema above — shared internal fragment emitter; the `irHash` in `x-nekostack` provenance is identical, proving same-source generation)
41
+
42
+ **Why this is the headline example:**
43
+
44
+ `AuditEvent.severity` has `.default("info")`. Look at the generated `audit-event.both.ts` — the difference between `Input` and `Output` is one character:
45
+
46
+ ```ts
47
+ export type AuditEventInput = { ...; severity?: "info" | "warning" | "error"; ... };
48
+ export type AuditEventOutput = { ...; severity: "info" | "warning" | "error"; ... };
49
+ ```
50
+
51
+ The Input accepts missing `severity` (the default fills it in). The Output is fully populated. The v0.1 absence-semantics contract survives generation — that's the entire point of v0.2.
52
+
53
+ **Other absence-semantics rows exercised:**
54
+ - `correlationId.optional()` → `?:` in both Input and Output
55
+ - `actorId.nullable()` → `: string | null` (required key) in both
56
+ - `payload` uses `.passthrough()` → emitted `.passthrough()` in Zod; accepts arbitrary extra keys
57
+
58
+ ## 3. Entitlement — array + deprecated + nullable-as-sentinel
59
+
60
+ **Source:** [`../examples/entitlement.schema.ts`](../examples/entitlement.schema.ts)
61
+
62
+ **Generated:**
63
+ - [`../examples/generated/entitlement.types.ts`](../examples/generated/entitlement.types.ts) — TS output type
64
+ - [`../examples/generated/entitlement.zod.ts`](../examples/generated/entitlement.zod.ts) — Zod 3.x validator
65
+ - [`../examples/generated/entitlement.json.schema.json`](../examples/generated/entitlement.json.schema.json) — JSON Schema draft 2020-12
66
+ - [`../examples/generated/entitlement.openapi.json`](../examples/generated/entitlement.openapi.json) — OpenAPI 3.1 component schema
67
+
68
+ **What it demonstrates:**
69
+ - Boolean with default (`enabled: true`).
70
+ - Numeric field with `int + min` portable refinements + nullable (`quota` — null encodes "unlimited", a common SaaS pattern).
71
+ - Array with `max` items (`tags`).
72
+ - Field-level `.deprecated()` flag (`legacyTier`) — surfaces in JSDoc; consumers using a strict TS-aware tool will see the `@deprecated` tag.
73
+ - Mixed required + optional fields in one object.
74
+
75
+ ## Validating input at runtime (v0.6)
76
+
77
+ Once a schema is defined, `parse` / `safeParse` / `validate` from `@nekostack/schema` are the runtime-validation entry points. The generated Zod file is no longer required to validate input — generators emit artifacts for interoperability and documentation; the runtime API is the in-process path.
78
+
79
+ ### `parse` — throws `ParseError` on failure, fills defaults on success
80
+
81
+ ```ts
82
+ import { parse, ParseError } from "@nekostack/schema";
83
+ import { AuditEvent } from "../examples/audit-event.schema.js";
84
+
85
+ try {
86
+ const event = parse(AuditEvent, {
87
+ id: "evt_01",
88
+ occurredAt: "2026-05-18T10:00:00Z",
89
+ actorId: null,
90
+ action: "tenant.created",
91
+ payload: { tenantId: "t_1" },
92
+ // severity omitted — default("info") fills it
93
+ });
94
+ event.severity; // "info" (default filled — see audit-event.both.ts for the type-level reason this works)
95
+ } catch (e) {
96
+ if (e instanceof ParseError) {
97
+ for (const i of e.issues) console.log(i.code, i.path, i.message);
98
+ } else {
99
+ throw e;
100
+ }
101
+ }
102
+ ```
103
+
104
+ ### `safeParse` — returns `Result`, no throw, also fills defaults
105
+
106
+ ```ts
107
+ import { safeParse } from "@nekostack/schema";
108
+ import { Tenant } from "../examples/tenant.schema.js";
109
+
110
+ const r = safeParse(Tenant, untrustedInput);
111
+ if (r.success) {
112
+ // r.data: s.output<typeof Tenant>
113
+ } else {
114
+ // r.issues: readonly Issue[] — every issue uses the NekoStack IssueCode vocabulary
115
+ }
116
+ ```
117
+
118
+ ### `validate` — structural check; does **not** fill defaults; refinements still run
119
+
120
+ ```ts
121
+ import { validate } from "@nekostack/schema";
122
+ import { AuditEvent } from "../examples/audit-event.schema.js";
123
+
124
+ const r = validate(AuditEvent, {
125
+ id: "evt_01",
126
+ occurredAt: "2026-05-18T10:00:00Z",
127
+ actorId: null,
128
+ action: "tenant.created",
129
+ payload: { tenantId: "t_1" },
130
+ // severity absent — accepted, but NOT filled in r.data
131
+ });
132
+ if (r.success) {
133
+ // r.data shape matches s.input<typeof AuditEvent>
134
+ // "severity" in r.data === false
135
+ }
136
+ ```
137
+
138
+ The split: `parse` / `safeParse` apply the default and return the fully-populated output; `validate` accepts the absence and returns the input verbatim. See [`RUNTIME.md` → Default semantics](./RUNTIME.md#default-semantics) for the table and the v0.1 rationale.
139
+
140
+ ### Unknown-key policies execute at runtime
141
+
142
+ ```ts
143
+ import { s, parse } from "@nekostack/schema";
144
+
145
+ const Strict = s.object({ id: s.string() }); // default
146
+ const Pass = s.object({ id: s.string() }).passthrough();
147
+ const Strip = s.object({ id: s.string() }).stripUnknown();
148
+
149
+ parse(Strict, { id: "x", extra: 1 }); // throws ParseError([{ code: "unknown_key", path: ["extra"], ... }])
150
+ parse(Pass, { id: "x", extra: 1 }); // → { id: "x", extra: 1 }
151
+ parse(Strip, { id: "x", extra: 1 }); // → { id: "x" }
152
+ ```
153
+
154
+ `unrecognized_keys` is split — when an input has two unknown keys, the returned `issues` array contains two `unknown_key` issues, one per key, each with `path: [...originalPath, key]`. See [`RUNTIME.md` → Unknown-key policies](./RUNTIME.md#unknown-key-policies).
155
+
156
+ ## Reading the generated files
157
+
158
+ Every committed generated artifact has the deterministic header:
159
+
160
+ ```ts
161
+ /**
162
+ * @generated by @nekostack/schema
163
+ * schemaId: com.nekostack.tenant.Tenant
164
+ * schemaVersion: 1.0.0
165
+ * irHash: sha256:<64-char-hex>
166
+ * generator: typescript | zod
167
+ * generatorVersion: @nekostack/schema@0.7.0
168
+ *
169
+ * DO NOT EDIT MANUALLY.
170
+ */
171
+ ```
172
+
173
+ Same IR → same `irHash` across runs and across generators. Re-running the regenerate test on an unchanged schema produces byte-identical output. This is what makes v0.7's freshness check possible.
174
+
175
+ ## Composition example (v0.5)
176
+
177
+ The example schemas above can be composed:
178
+
179
+ ```ts
180
+ import { Tenant } from "../examples/tenant.schema.js";
181
+
182
+ // Create-form input: client doesn't send the server-assigned id.
183
+ const TenantCreateInput = Tenant.omit({ id: true });
184
+
185
+ // PATCH/update shape: every field optional, defaults stripped.
186
+ const TenantPatch = Tenant.partial();
187
+
188
+ // Safe-to-expose subset (e.g., for a tenant directory listing).
189
+ const TenantPublic = Tenant.pick({ id: true, slug: true, name: true });
190
+ ```
191
+
192
+ All four generators handle composed schemas via the shared `emitSchemaFragment` — output is byte-identical to a hand-written equivalent. See [`COMPOSITION.md`](./COMPOSITION.md) for the full operator contract.
193
+
194
+ ## Optional `sourceHash` provenance (v0.7+)
195
+
196
+ > The example artifacts in [`../examples/generated/`](../examples/generated/) **include** `sourceHash` because the regenerate test ([`../tests/examples/regenerate.test.ts`](../tests/examples/regenerate.test.ts)) computes it from each schema source file's UTF-8 text. The slice remains **optional** for direct generator callers outside that path — omitting it produces byte-identical output to v0.6 and earlier.
197
+
198
+ Every generator accepts an optional `ProvenanceOptions.sourceHash` slice on its options object. When you provide it, the emitted artifact gains an extra provenance field (`sourceHash:` line in JSDoc-headered TS/Zod output; `x-nekostack.sourceHash` extension in JSON Schema / OpenAPI). When you omit it, the generators emit byte-identical output to v0.6 and earlier — no new field appears anywhere.
199
+
200
+ ```ts
201
+ import { generateTypeScript, sourceHashFromText } from "@nekostack/schema";
202
+ import { readFileSync } from "node:fs";
203
+ import { Tenant } from "../examples/tenant.schema.js";
204
+
205
+ const text = readFileSync("../examples/tenant.schema.ts", "utf8");
206
+ const ts = generateTypeScript(Tenant.node, {
207
+ sourceHash: sourceHashFromText(text),
208
+ });
209
+ ```
210
+
211
+ The slice is **not required**, exists purely for provenance, and is **never** an integrity error when missing — pre-v0.7 artifacts without `sourceHash` continue to parse and validate as `clean` (when `irHash` matches the schema) or `stale` (when it doesn't). The two-hash freshness matrix (full contract in [`REGISTRY.md` → `checkHandler`](./REGISTRY.md#checkhandler--two-hash-freshness-matrix)) is the consumer of the field; in v0.7+ the `neko schema check` CLI is what computes and writes it. Hand-authored generation scripts can plug it in today, but the value adds is provenance-completeness, not correctness.
212
+
213
+ ## What these examples deliberately don't show (yet)
214
+
215
+ - **Full OpenAPI documents** (paths, operations, responses, security schemes) — `@nekostack/api`'s concern. v0.4 ships component schemas only.
216
+ - **Composed-schema example artifacts under `examples/generated/`** — could add `tenant-patch.zod.ts` etc. in a future dogfood pass if the example surface grows enough to warrant it. v0.5 stays focused on the operator contract; ad-hoc consumer-side composition doesn't need its own snapshotted output here.
217
+ - **`Tenant.extend({ ... })`, `pick({ id: true })`, etc.** — v0.5 composition operators; see [`COMPOSITION.md`](./COMPOSITION.md) for the full contract.
218
+ - **Date / union / runtime-refinement schemas** — v0.6's runtime fails loudly (`UnsupportedNodeKindError`) on these IR kinds; builders are deferred to later phases. The v0.7 `diffNodes` likewise throws on these kinds — diffing them is a v0.8+ concern.
219
+ - **A `neko schema` CLI** — v0.7 shipped at [`schema-v0.7.0`](https://github.com/cmclicker/NekoStack/releases/tag/schema-v0.7.0); use `neko schema list / diff / check / generate` directly. The v0.8 `neko schema migrate *` verbs (`list` / `plan` / `verify` / `stub`) are in progress on [`feat/schema-v0.8-candidate`](https://github.com/cmclicker/NekoStack/tree/feat/schema-v0.8-candidate) ([PR #28](https://github.com/cmclicker/NekoStack/pull/28)) and not yet shipped.
220
+ - **Example schema-data migrations under `examples/migrations/`** — v0.8 ships the planning / verification / stub contract in [`MIGRATIONS.md`](./MIGRATIONS.md); committed `.migration.ts` example files (e.g., a `Tenant 1.0.0 → 2.0.0` data migration paired with the existing `tenant.schema.ts` example) could be added in a dogfood pass once the schema versions actually evolve. The example surface is deliberately frozen at the v0.6 set for now to keep the regenerate-test scope tight.
221
+ - **A migration *runner / apply / executor*** — `@nekostack/schema` does **not** ship one and never will. v0.8 owns planning + verification + stub generation only; executing a migration's `transform(input)` against real data is a hard-locked non-goal of the schema package. See [`MIGRATIONS.md`](./MIGRATIONS.md) for the full non-goals table.
@@ -0,0 +1,66 @@
1
+ # Generated-file Header Format
2
+
3
+ > The deterministic preamble prepended to every artifact produced by `generateTypeScript` and `generateZod` (v0.2+). This file is the contract; the implementation lives in [`../src/generators/header.ts`](../src/generators/header.ts).
4
+
5
+ ## Shape
6
+
7
+ A JSDoc block comment, one field per line, in fixed order:
8
+
9
+ ```ts
10
+ /**
11
+ * @generated by @nekostack/schema
12
+ * schemaId: <id-or-null>
13
+ * schemaVersion: <version-or-null>
14
+ * irHash: sha256:<64-char-lowercase-hex>
15
+ * generator: <typescript|zod>
16
+ * generatorVersion: <single-source-of-truth-string>
17
+ *
18
+ * DO NOT EDIT MANUALLY.
19
+ */
20
+ ```
21
+
22
+ Anonymous schemas (no `.id()`) add a `// anonymous schema` line immediately after `@generated by`, so the omission is intentional and visible.
23
+
24
+ ## Fields
25
+
26
+ | Field | Value | Source |
27
+ |---|---|---|
28
+ | `@generated by` | Always `@nekostack/schema` | constant |
29
+ | `schemaId` | The schema's reverse-DNS id, or `null` for anonymous | `node.metadata.id` |
30
+ | `schemaVersion` | The schema's semver, or `null` for unversioned | `node.metadata.version` (falls back to `options.schemaVersion`) |
31
+ | `irHash` | `sha256:<hex>` of canonical IR serialization | [`irHash(node)`](../src/ir/hash.ts) |
32
+ | `generator` | `"typescript"` or `"zod"` | `options.generator` |
33
+ | `generatorVersion` | Single string for all generators in this package version | [`GENERATOR_VERSION`](../src/generators/version.ts) |
34
+
35
+ ## Why two hashes (eventually)
36
+
37
+ `irHash` is here in v0.2. `sourceHash` is **not yet** in the header.
38
+
39
+ - **`irHash`** captures semantic IR identity. If the schema's normalized IR changes, the hash changes. Same IR + same generator version → byte-identical output.
40
+ - **`sourceHash`** would capture the source file's bytes (comments, whitespace, ordering). It's needed for CI to distinguish "the schema author edited the file but the IR is unchanged" (regenerate the header only) from "the schema author changed the IR" (full regeneration). Computing it requires walking source files, which is a CLI concern — deferred to v0.7 (`neko schema check`).
41
+
42
+ Once v0.7 lands, the header gains a `sourceHash:` line above `irHash:`. The v0.2 header format is a strict subset of the eventual v0.7 format.
43
+
44
+ ## Determinism
45
+
46
+ Same IR + same generator + same generator version → byte-identical header (and byte-identical full file).
47
+
48
+ Tested in [`../tests/generators/header.test.ts`](../tests/generators/header.test.ts) — the "is deterministic" case asserts byte equality across two invocations.
49
+
50
+ ## What consumers MAY do with the header
51
+
52
+ - Read `irHash` to verify a committed artifact matches a current IR.
53
+ - Read `schemaId` + `schemaVersion` to identify which schema this file represents.
54
+ - Refuse to consume artifacts whose `generatorVersion` is older than expected.
55
+
56
+ ## What consumers MUST NOT do
57
+
58
+ - Parse or scrape data out of `// anonymous schema` comments. Use the typed fields (`schemaId === null` is the contract).
59
+ - Edit a generated file by hand. The `DO NOT EDIT MANUALLY.` line is informative; CI may enforce it later by re-running the generator and asserting byte-identical output.
60
+ - Depend on field-line ordering changing — it is fixed and part of this contract.
61
+
62
+ ## Version history
63
+
64
+ | Version | Change |
65
+ |---|---|
66
+ | v0.2 | Initial format. `sourceHash` deferred to v0.7 with CLI integration. |