@texuli/spec 0.2.0 → 0.4.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 (46) hide show
  1. package/README.md +80 -14
  2. package/dist/build.d.ts +5 -0
  3. package/dist/build.js +1 -0
  4. package/dist/canonical.js +20 -13
  5. package/dist/catalog.d.ts +9 -3
  6. package/dist/catalog.js +7 -1
  7. package/dist/codegen/index.d.ts +2 -2
  8. package/dist/codegen/index.js +2 -2
  9. package/dist/codegen/kotlin.d.ts +6 -0
  10. package/dist/codegen/kotlin.js +80 -8
  11. package/dist/conformance.d.ts +11 -2
  12. package/dist/conformance.js +93 -2
  13. package/dist/contract.d.ts +112 -0
  14. package/dist/contract.js +196 -0
  15. package/dist/dialect.d.ts +10 -1
  16. package/dist/dialect.js +6 -1
  17. package/dist/generated/typescript/blueprint.d.ts +103 -5
  18. package/dist/generated/typescript/blueprint.js +1 -1
  19. package/dist/generated/typescript/catalog-schema.d.ts +35 -0
  20. package/dist/generated/typescript/catalog-schema.js +1 -1
  21. package/dist/generated/typescript/diagnostics.d.ts +10 -1
  22. package/dist/generated/typescript/diagnostics.js +1 -1
  23. package/dist/generated/typescript/manifest.js +1 -1
  24. package/dist/generated/typescript/vocabulary.d.ts +152 -4
  25. package/dist/generated/typescript/vocabulary.js +27 -2
  26. package/dist/generated/typescript/workflow.d.ts +84 -0
  27. package/dist/generated/typescript/workflow.js +3 -0
  28. package/dist/index.d.ts +13 -5
  29. package/dist/index.js +9 -3
  30. package/dist/migrations/0.2.0-0.3.0.d.ts +7 -0
  31. package/dist/migrations/0.2.0-0.3.0.js +10 -0
  32. package/dist/migrations/0.3.0-0.4.0.d.ts +9 -0
  33. package/dist/migrations/0.3.0-0.4.0.js +12 -0
  34. package/dist/migrations/index.d.ts +15 -0
  35. package/dist/migrations/index.js +53 -0
  36. package/dist/migrations/types.d.ts +14 -0
  37. package/dist/migrations/types.js +1 -0
  38. package/dist/surface.d.ts +60 -0
  39. package/dist/surface.js +162 -0
  40. package/dist/validate.d.ts +89 -14
  41. package/dist/validate.js +725 -64
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/dist/workflow.d.ts +15 -0
  45. package/dist/workflow.js +256 -0
  46. package/package.json +13 -1
package/README.md CHANGED
@@ -14,9 +14,17 @@ by Texuli's Kotlin Multiplatform twin. The package is framework-free: no
14
14
  DOM, no Svelte, no Node APIs and no runtime dependencies.
15
15
 
16
16
  > **Status: 0.x release.** Texuli is in development. The packages are
17
- > published to npm as `0.2.0`, a pre-1.0 release (1.0.0 will be the first
18
- > stable release). Under 0.x, a new minor (`0.3.0`) may contain breaking
19
- > changes, so pin the exact version or use `^0.2.0`, which accepts only 0.2.x.
17
+ > published to npm as `0.4.0`, a pre-1.0 release (1.0.0 will be the first
18
+ > stable release). Under 0.x, a new minor (`0.5.0`) may contain breaking
19
+ > changes, so pin the exact version or use `^0.4.0`, which accepts only 0.4.x.
20
+
21
+ > **New in `0.4.0`.** `@texuli/spec@0.4.0` carries **spec 0.4.0**: spec
22
+ > 0.3.0 (state, bindings, closed actions, `reorder`, workflow graphs), spec
23
+ > 0.4.0 (deck model v2 on `layout-deck` and `deck-slide`, below) and the
24
+ > `workflow`, `migrate` and `surface` subpaths described below.
25
+ > `@texuli/spec@0.3.0` carried spec 0.2.0 and only the root and `codegen`
26
+ > subpaths
27
+ > ([RELEASE](https://github.com/DailybotHQ/texuli/blob/main/docs/RELEASE.md)).
20
28
 
21
29
  ## Install
22
30
 
@@ -48,7 +56,7 @@ import {
48
56
  validateInstance,
49
57
  } from '@texuli/spec';
50
58
 
51
- SPEC_VERSION; // the spec version, e.g. '0.2.0'
59
+ SPEC_VERSION; // the spec version: '0.4.0'
52
60
  SPEC_COMPONENT_IDS.includes('alert'); // true: the closed component vocabulary
53
61
 
54
62
  // The dialect validator: a closed subset of JSON Schema 2020-12.
@@ -68,31 +76,51 @@ const digest = sha256(canonicalJson({ b: 1, a: [true, null] }));
68
76
 
69
77
  To check a whole blueprint, pass the blueprint schema and catalog documents
70
78
  from the repository's `packages/spec/schemas/blueprint.schema.json` and
71
- `packages/spec/catalog/components.json`:
79
+ `packages/spec/catalog/components.json`. Workflow graphs and migrations
80
+ are opt-in subpaths, so the root stays small: a host that renders workflow
81
+ graphs passes `schemas/workflow.schema.json` and `validateWorkflowGraph`
82
+ from `@texuli/spec/workflow` (without both, a `workflow` state variable or
83
+ a `workflowGraph` prop fails closed), and a host that reads older
84
+ envelopes runs `migrateBlueprint` from `@texuli/spec/migrate` first:
72
85
 
73
86
  ```ts
74
87
  import { type Schema, type SpecCatalog, validateBlueprint } from '@texuli/spec';
88
+ import { migrateBlueprint } from '@texuli/spec/migrate';
89
+ import { validateWorkflowGraph } from '@texuli/spec/workflow';
75
90
 
76
- const result = validateBlueprint(blueprint, {
91
+ const migrated = migrateBlueprint(blueprint); // 0.2.0 → 0.3.0 → 0.4.0; never throws
92
+ const result = validateBlueprint(migrated.blueprint, {
77
93
  blueprintSchema: blueprintSchemaJson as Schema,
78
94
  catalog: componentsJson as SpecCatalog,
95
+ workflowSchema: workflowSchemaJson as Schema, // only for workflow graphs
96
+ validateWorkflowGraph, // only for workflow graphs
79
97
  });
80
98
  if (!result.ok) {
81
99
  for (const d of result.diagnostics) console.warn(d.code, d.path, d.message);
82
100
  }
101
+ for (const w of result.warnings ?? []) console.info(w.code, w.path); // `deprecated`
83
102
  ```
84
103
 
85
- A node with an id outside the catalog fails with `unknown-id`; other codes
86
- include `schema`, `unknown-prop`, `prop-type`, `enum-value`,
104
+ A node with an id outside the catalog fails with `unknown-id`. The other
105
+ codes are `schema`, `unknown-prop`, `prop-type`, `enum-value`,
87
106
  `missing-required`, `extension-prop`, `catalog-mismatch`,
88
- `unsupported-profile` and `unknown-slot`.
107
+ `unsupported-profile`, `unknown-slot`, and, from spec 0.3.0, `state` (a
108
+ refused state or flow declaration), `unknown-state` (a binding or action
109
+ names no declared variable), `binding` (a malformed, misplaced, read-only or
110
+ ill-fitting binding), `action` (an action with the wrong fields or target),
111
+ `unknown-event` (an `on` key the component does not fire) and `workflow` (a
112
+ workflow graph rule). Deprecated components and props add a `deprecated`
113
+ warning without failing.
89
114
 
90
115
  ## What's inside
91
116
 
92
117
  | Subpath | Purpose | Headline exports |
93
118
  | --- | --- | --- |
94
- | `@texuli/spec` | Runtime: types, vocabulary, validation, hashing | `BlueprintEnvelope`, `SpecCatalog`, `SpecValidation` and the other generated types; `SPEC_COMPONENT_IDS`, `SpecComponentProps`, `SPEC_CATALOG_HASH`, `SPEC_VERSION`; `validateBlueprint`, `validateTree`, `validateInstance`, `checkSchemaDocument`; `canonicalJson`, `sha256` |
95
- | `@texuli/spec/codegen` | Build time: generation and conformance | `buildSpec`, `projectCatalog`, `checkConformance`, `formatJson`, `nextSpecVersion`, `checkVersionHistory` |
119
+ | `@texuli/spec` | Runtime: types, vocabulary, validation, hashing | `BlueprintEnvelope`, `SpecCatalog`, `SpecValidation`, `WorkflowGraph` and the other generated types; `SPEC_COMPONENT_IDS`, `SpecComponentProps`, `SPEC_CATALOG_HASH`, `SPEC_VERSION`; `validateBlueprint`, `validateTree`, `validateInstance`, `checkSchemaDocument`; the contract constants (`SPEC_ACTION_OPS`, `SPEC_EVENT_NAMES`, `SPEC_STATE_TYPES`, `SPEC_LIMITS`, `SPEC_PATTERNS`, `ACTION_FIELDS`); `canonicalJson`, `sha256` |
120
+ | `@texuli/spec/workflow` | Runtime, opt-in: workflow graph validation | `validateWorkflowGraph` (pass it in `SpecDocuments.validateWorkflowGraph`) |
121
+ | `@texuli/spec/migrate` | Runtime, opt-in: envelope migrations | `migrateBlueprint`, `SPEC_MIGRATIONS`, `UNDECLARED_SPEC_VERSION` |
122
+ | `@texuli/spec/surface` | Runtime: the state surface rules every validator shares (no imports) | `checkStateSurface`, `surfaceInputs`, `SPEC_CONTROL_STATE_TYPES`, `SURFACE_PLAYER_CODES`, `LIST_TEXT_PATTERN` |
123
+ | `@texuli/spec/codegen` | Build time: generation and conformance | `buildSpec`, `projectCatalog`, `checkConformance`, `checkCatalogContract`, `formatJson`, `nextSpecVersion`, `checkVersionHistory` |
96
124
 
97
125
  ## Key guarantees
98
126
 
@@ -107,9 +135,36 @@ include `schema`, `unknown-prop`, `prop-type`, `enum-value`,
107
135
  refused when you pass `catalogHash`.
108
136
  - **Deterministic.** Canonical JSON sorts keys at every depth, and every
109
137
  generated file carries the spec version and digest with no timestamps.
138
+ - **Interaction as data, never code.** Spec 0.3.0 adds local state,
139
+ bindings (`{"$state": name}`, read-only `{"$data": name}`), a closed
140
+ action set (`setState`, `toggleState`, `moveItem`, `flowGo`, `emit`,
141
+ `submit`) on a closed event vocabulary, drag as data (`reorder`) and
142
+ workflow graphs (at most 500 nodes and 2,000 edges). Nothing in a
143
+ blueprint is evaluated.
144
+ - **Decks as data.** Spec 0.4.0 (ADR-009) gives
145
+ `layout-deck` and `deck-slide` optional deck model v2 props: the 9:16
146
+ aspect, the `slide`, `zoom` and `morph` transitions, motion, navigation,
147
+ outline, a slide `layout` with its `props`, `background`, step labels and
148
+ component slides. The change is additive: every 0.3.0 envelope is a valid
149
+ 0.4.0 envelope, and the `0.3.0 → 0.4.0` migration only stamps `spec`. The
150
+ deck model itself is validated by `@texuli/deck`, not by this package.
110
151
  - **Structure only.** This validator checks structure. URL policy, tree
111
152
  limits and rendering live in the runtime validator of
112
- [`@texuli/catalog`](https://www.npmjs.com/package/@texuli/catalog).
153
+ [`@texuli/catalog`](https://www.npmjs.com/package/@texuli/catalog). The
154
+ reducers that run the actions belong to the runtime
155
+ ([`@texuli/player`](https://www.npmjs.com/package/@texuli/player)), which
156
+ runs every 0.3.0 action from `0.4.0`).
157
+ - **A blueprint that validates paints.** The state surface rules
158
+ (`@texuli/spec/surface`) refuse exactly what the player refuses at paint
159
+ time: form fields and submits outside an `action-form`, nested forms,
160
+ controls and fields that name no declared variable of their type, flow
161
+ nodes without a matching view or event, and writers of a host-bound
162
+ variable. The spec, `@texuli/catalog`'s `validateDraft` and the Kotlin
163
+ twin run the same rules.
164
+ - **List item text never hides or reorders itself.** String fields of list
165
+ state items refuse control characters, bidirectional overrides and
166
+ isolates, and invisible formatting characters (`SPEC_PATTERNS.listText`;
167
+ ZWNJ and ZWJ stay allowed for scripts and emoji).
113
168
 
114
169
  ## Versioning
115
170
 
@@ -131,8 +186,15 @@ reads. Native (Kotlin) builds compile the generated models from the same
131
186
  spec, and their decoders are closed: a blueprint that uses a field from a
132
187
  newer spec fails to decode on an older native build with a `schema`
133
188
  diagnostic instead of passing silently. A blueprint binds to an exact
134
- catalog hash today; an envelope field for a compatible spec range, and
135
- migrations between versions, are planned and not built.
189
+ catalog hash, and from 0.3.0 may declare the exact spec version it was
190
+ written for (`spec`; omitted means 0.2.0). `migrateBlueprint`
191
+ (`@texuli/spec/migrate`) brings an
192
+ envelope up to the current spec with pure, per-version steps
193
+ (`src/migrations/`: `0.2.0 → 0.3.0` and `0.3.0 → 0.4.0`, both of which only
194
+ stamp `spec`); a compatible version range is planned, not built. The
195
+ repository pins spec `0.4.0` to digest
196
+ `fac29f542b16ddb78ab401c508e5b8346f655991c56e25aa811f4931661a8cb3` in
197
+ `versions.json`.
136
198
 
137
199
  The full rules (the digest, `versions.json` pins, regeneration and the
138
200
  conformance harness) are in
@@ -166,3 +228,7 @@ bugs at [github.com/DailybotHQ/texuli/issues](https://github.com/DailybotHQ/texu
166
228
  ## License
167
229
 
168
230
  MIT © Dailybot, Inc.
231
+
232
+ ---
233
+
234
+ Powered by [Dailybot](https://www.dailybot.com?utm_source=dailybotopensource&utm_medium=texuli-spec).
package/dist/build.d.ts CHANGED
@@ -24,6 +24,11 @@ export declare const SCHEMA_UNITS: readonly [{
24
24
  readonly prefix: "Spec";
25
25
  readonly ts: "manifest.ts";
26
26
  readonly kt: "Manifest.kt";
27
+ }, {
28
+ readonly name: "workflow";
29
+ readonly prefix: "";
30
+ readonly ts: "workflow.ts";
31
+ readonly kt: "Workflow.kt";
27
32
  }];
28
33
  export declare const TS_OUT = "generated/typescript/";
29
34
  export declare const KOTLIN_OUT = "generated/kotlin/com/texuli/spec/";
package/dist/build.js CHANGED
@@ -13,6 +13,7 @@ export const SCHEMA_UNITS = [
13
13
  { name: 'catalog', prefix: 'Spec', ts: 'catalog-schema.ts', kt: 'CatalogSchema.kt' },
14
14
  { name: 'diagnostics', prefix: 'Spec', ts: 'diagnostics.ts', kt: 'Diagnostics.kt' },
15
15
  { name: 'manifest', prefix: 'Spec', ts: 'manifest.ts', kt: 'Manifest.kt' },
16
+ { name: 'workflow', prefix: '', ts: 'workflow.ts', kt: 'Workflow.kt' },
16
17
  ];
17
18
  export const TS_OUT = 'generated/typescript/';
18
19
  export const KOTLIN_OUT = 'generated/kotlin/com/texuli/spec/';
package/dist/canonical.js CHANGED
@@ -46,16 +46,25 @@ function encode(value, stack) {
46
46
  export function prettyJson(value) {
47
47
  return `${JSON.stringify(JSON.parse(canonicalJson(value)), null, 2)}\n`;
48
48
  }
49
- const K = new Uint32Array([
50
- 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
51
- 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
52
- 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
53
- 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
54
- 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
55
- 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
56
- 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
57
- 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
58
- ]);
49
+ /**
50
+ * The fractional part of `p`'s square (`n` 2) or cube (`n` 3) root as a
51
+ * 32-bit word: the SHA-256 constants (FIPS 180-4 §4.2.2, §5.3.3). Newton
52
+ * steps on plain IEEE arithmetic, which every runtime computes bit for bit
53
+ * (`Math.cbrt` and `**` are only approximated); every word sits thousands
54
+ * of ulps from a truncation boundary, and the test vectors cover all 72.
55
+ */
56
+ function rootWord(p, n) {
57
+ let x = 2;
58
+ for (let i = 0; i < 40; i += 1) {
59
+ x -= n === 2 ? (x * x - p) / (2 * x) : (x * x * x - p) / (3 * x * x);
60
+ }
61
+ return ((x - Math.floor(x)) * 0x100000000) >>> 0;
62
+ }
63
+ const PRIMES = [];
64
+ for (let n = 2; PRIMES.length < 64; n += 1)
65
+ if (PRIMES.every((p) => n % p))
66
+ PRIMES.push(n);
67
+ const K = new Uint32Array(PRIMES.map((p) => rootWord(p, 3)));
59
68
  /** UTF-8 bytes of a string (no `TextEncoder` needed). */
60
69
  function utf8(text) {
61
70
  const bytes = [];
@@ -83,9 +92,7 @@ export function sha256(text) {
83
92
  for (const word of [high, bitLength >>> 0]) {
84
93
  bytes.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff);
85
94
  }
86
- const hash = new Uint32Array([
87
- 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
88
- ]);
95
+ const hash = new Uint32Array(PRIMES.slice(0, 8).map((p) => rootWord(p, 2)));
89
96
  const w = new Uint32Array(64);
90
97
  const rotr = (x, n) => (x >>> n) | (x << (32 - n));
91
98
  for (let offset = 0; offset < bytes.length; offset += 64) {
package/dist/catalog.d.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  * projection from a built catalog. The projection is the bootstrap step of
4
4
  * spec/Compose design §2.3; the parity check reruns it to catch drift.
5
5
  */
6
- import type { SpecCatalog, SpecComponent, SpecProp } from './generated/typescript/catalog-schema.js';
7
- export type { SpecCatalog, SpecComponent, SpecProp };
6
+ import type { SpecCatalog, SpecComponent, SpecDeprecation, SpecEventName, SpecProp, SpecPropSchema } from './generated/typescript/catalog-schema.js';
7
+ export type { SpecCatalog, SpecComponent, SpecDeprecation, SpecEventName, SpecProp, SpecPropSchema, };
8
8
  export declare const CAPABILITIES: readonly ["portable-core", "web-only", "native-only", "unknown"];
9
9
  export type Capability = SpecComponent['capability'];
10
10
  export type SpecPropKind = SpecProp['kind'];
@@ -24,17 +24,23 @@ export interface BuiltCatalogLike {
24
24
  group: string;
25
25
  tier: 'app' | 'content';
26
26
  emit: readonly string[];
27
+ events?: readonly SpecEventName[];
28
+ bindable?: readonly string[];
29
+ deprecated?: SpecDeprecation;
27
30
  props: readonly {
28
31
  name: string;
29
32
  kind: SpecPropKind;
30
33
  required?: boolean;
31
34
  enumValues?: readonly string[];
35
+ schema?: SpecPropSchema;
36
+ deprecated?: SpecDeprecation;
32
37
  }[];
33
38
  }[];
34
39
  }
35
40
  /**
36
41
  * Project a built catalog into the spec vocabulary. Deterministic: components
37
- * sorted by id, props by name, emit tiers sorted. Capability is `unknown`
42
+ * sorted by id, props by name, emit tiers, events and bindable props sorted;
43
+ * events, bindable, property schemas and deprecations only when present. Capability is `unknown`
38
44
  * unless `capabilities` (the reviewed `catalog/capabilities.json`) records an
39
45
  * audited value (ADR-003: no id is portable until audited).
40
46
  */
package/dist/catalog.js CHANGED
@@ -13,7 +13,8 @@ export const WEB_ONLY_KINDS = ['slot', 'event', 'unknown'];
13
13
  export const WEB_ONLY_PROP_NAMES = ['class', 'className', 'style', 'renderer'];
14
14
  /**
15
15
  * Project a built catalog into the spec vocabulary. Deterministic: components
16
- * sorted by id, props by name, emit tiers sorted. Capability is `unknown`
16
+ * sorted by id, props by name, emit tiers, events and bindable props sorted;
17
+ * events, bindable, property schemas and deprecations only when present. Capability is `unknown`
17
18
  * unless `capabilities` (the reviewed `catalog/capabilities.json`) records an
18
19
  * audited value (ADR-003: no id is portable until audited).
19
20
  */
@@ -39,7 +40,12 @@ export function projectCatalog(catalog, catalogHash, capabilities = {}) {
39
40
  ...(WEB_ONLY_KINDS.includes(prop.kind) || WEB_ONLY_PROP_NAMES.includes(prop.name)
40
41
  ? { extension: 'web' }
41
42
  : {}),
43
+ ...(prop.schema ? { schema: prop.schema } : {}),
44
+ ...(prop.deprecated ? { deprecated: { ...prop.deprecated } } : {}),
42
45
  })),
46
+ ...(entry.events?.length ? { events: [...entry.events].sort() } : {}),
47
+ ...(entry.bindable?.length ? { bindable: [...entry.bindable].sort() } : {}),
48
+ ...(entry.deprecated ? { deprecated: { ...entry.deprecated } } : {}),
43
49
  }));
44
50
  return { catalogHash, components };
45
51
  }
@@ -7,8 +7,8 @@
7
7
  export { buildSpec, KOTLIN_OUT, NORMATIVE_DIRS, SCHEMA_UNITS, type SpecBuild, TS_OUT, } from '../build.js';
8
8
  export { prettyJson } from '../canonical.js';
9
9
  export { type BuiltCatalogLike, projectCatalog } from '../catalog.js';
10
- export { type ConformanceIssue, checkConformance } from '../conformance.js';
10
+ export { type ConformanceIssue, checkCatalogContract, checkConformance, deprecationExpired, } from '../conformance.js';
11
11
  export { formatJson, LINE_WIDTH } from '../format.js';
12
12
  export { checkVersionHistory, nextSpecVersion, type SpecChangeClass, type SpecVersionHistory, type SpecVersionPin, type VersionIssue, } from '../versioning.js';
13
- export { KOTLIN_PACKAGE, kotlinString } from './kotlin.js';
13
+ export { KOTLIN_PACKAGE, kotlinProperty, kotlinString } from './kotlin.js';
14
14
  export type { SchemaUnit, Stamp } from './shared.js';
@@ -7,7 +7,7 @@
7
7
  export { buildSpec, KOTLIN_OUT, NORMATIVE_DIRS, SCHEMA_UNITS, TS_OUT, } from "../build.js";
8
8
  export { prettyJson } from "../canonical.js";
9
9
  export { projectCatalog } from "../catalog.js";
10
- export { checkConformance } from "../conformance.js";
10
+ export { checkCatalogContract, checkConformance, deprecationExpired, } from "../conformance.js";
11
11
  export { formatJson, LINE_WIDTH } from "../format.js";
12
12
  export { checkVersionHistory, nextSpecVersion, } from "../versioning.js";
13
- export { KOTLIN_PACKAGE, kotlinString } from "./kotlin.js";
13
+ export { KOTLIN_PACKAGE, kotlinProperty, kotlinString } from "./kotlin.js";
@@ -10,6 +10,12 @@ import { type SchemaUnit, type Stamp } from './shared.js';
10
10
  export declare const KOTLIN_PACKAGE = "com.texuli.spec";
11
11
  /** A Kotlin string literal (escapes backslash, quote, dollar and controls). */
12
12
  export declare function kotlinString(value: string): string;
13
+ /**
14
+ * The Kotlin property for a wire name: a leading `$` is stripped (`$state` →
15
+ * `state`), because `$` starts a string template in Kotlin. The wire name
16
+ * itself is always emitted through {@link kotlinString}.
17
+ */
18
+ export declare function kotlinProperty(name: string): string;
13
19
  /** One schema document as a Kotlin file of data classes, enums and decoders. */
14
20
  export declare function schemaToKotlin(unit: SchemaUnit, stamp: Stamp): string;
15
21
  /** The JSON model and decode helpers every generated decoder uses. */
@@ -64,6 +64,18 @@ const KEYWORDS = new Set([
64
64
  function field(name) {
65
65
  return KEYWORDS.has(name) ? `\`${name}\`` : name;
66
66
  }
67
+ /**
68
+ * The Kotlin property for a wire name: a leading `$` is stripped (`$state` →
69
+ * `state`), because `$` starts a string template in Kotlin. The wire name
70
+ * itself is always emitted through {@link kotlinString}.
71
+ */
72
+ export function kotlinProperty(name) {
73
+ return name.startsWith('$') ? name.slice(1) : name;
74
+ }
75
+ /** `$` escaped for a Kotlin string template (`\$state`). */
76
+ function templateText(name) {
77
+ return name.replaceAll('$', '\\$');
78
+ }
67
79
  function kotlinType(node, ctx, inlineName) {
68
80
  if (node.$ref !== undefined)
69
81
  return refType(ctx.unit, node.$ref);
@@ -160,22 +172,27 @@ function dataClass(name, node, ctx) {
160
172
  const props = Object.entries(node.properties ?? {});
161
173
  const params = [];
162
174
  const args = [];
175
+ const seen = new Set();
163
176
  for (const [prop, child] of props) {
164
- const inlineName = `${name}${pascal(prop)}`;
177
+ const property = kotlinProperty(prop);
178
+ if (seen.has(property))
179
+ throw new Error(`${name}: Kotlin property "${property}" collides`);
180
+ seen.add(property);
181
+ const inlineName = `${name}${pascal(property)}`;
165
182
  const type = kotlinType(child, ctx, inlineName);
166
- const path = `"$path.${prop}"`;
183
+ const path = `"$path.${templateText(prop)}"`;
167
184
  if (required.has(prop)) {
168
185
  const defaultValue = typeof child.const === 'string'
169
186
  ? ` = ${kotlinString(child.const)}`
170
187
  : child.const !== undefined
171
188
  ? ` = ${child.const}L`
172
189
  : '';
173
- params.push(`${INDENT}val ${field(prop)}: ${type}${defaultValue},`);
174
- args.push(`${INDENT}${INDENT}${INDENT}${INDENT}${field(prop)} = ${decodeExpr(child, `fields.required("${prop}", path)`, path, ctx, inlineName)},`);
190
+ params.push(`${INDENT}val ${field(property)}: ${type}${defaultValue},`);
191
+ args.push(`${INDENT}${INDENT}${INDENT}${INDENT}${field(property)} = ${decodeExpr(child, `fields.required(${kotlinString(prop)}, path)`, path, ctx, inlineName)},`);
175
192
  }
176
193
  else {
177
- params.push(`${INDENT}val ${field(prop)}: ${type}? = null,`);
178
- args.push(`${INDENT}${INDENT}${INDENT}${INDENT}${field(prop)} = fields["${prop}"]?.let { ${decodeExpr(child, 'it', path, ctx, inlineName)} },`);
194
+ params.push(`${INDENT}val ${field(property)}: ${type}? = null,`);
195
+ args.push(`${INDENT}${INDENT}${INDENT}${INDENT}${field(property)} = fields[${kotlinString(prop)}]?.let { ${decodeExpr(child, 'it', path, ctx, inlineName)} },`);
179
196
  }
180
197
  }
181
198
  const allowed = props.map(([prop]) => kotlinString(prop)).join(', ');
@@ -299,6 +316,45 @@ export function kotlinJsonRuntime(stamp) {
299
316
  ];
300
317
  return lines.join('\n');
301
318
  }
319
+ function deprecationExpr(value) {
320
+ const replacement = value.replacement === undefined ? '' : `, replacement = ${kotlinString(value.replacement)}`;
321
+ return `SpecDeprecation(since = ${kotlinString(value.since)}${replacement})`;
322
+ }
323
+ /** A `SpecPropSchema(...)` constructor call, recursively; only the keywords present. */
324
+ function propSchemaExpr(schema) {
325
+ const args = [];
326
+ const number = (value) => (Number.isInteger(value) ? `${value}.0` : String(value));
327
+ if (schema.type !== undefined)
328
+ args.push(`type = SpecPropSchemaType.${upperSnake(schema.type)}`);
329
+ if (schema.description !== undefined)
330
+ args.push(`description = ${kotlinString(schema.description)}`);
331
+ if (schema.properties !== undefined) {
332
+ const entries = Object.entries(schema.properties).map(([name, child]) => `${kotlinString(name)} to ${propSchemaExpr(child)}`);
333
+ args.push(`properties = mapOf(${entries.join(', ')})`);
334
+ }
335
+ if (schema.required !== undefined)
336
+ args.push(`required = listOf(${schema.required.map(kotlinString).join(', ')})`);
337
+ if (schema.additionalProperties !== undefined) {
338
+ args.push(`additionalProperties = ${propSchemaExpr(schema.additionalProperties)}`);
339
+ }
340
+ if (schema.items !== undefined)
341
+ args.push(`items = ${propSchemaExpr(schema.items)}`);
342
+ if (schema.enum !== undefined)
343
+ args.push(`enum = listOf(${schema.enum.map(kotlinString).join(', ')})`);
344
+ for (const key of ['minLength', 'maxLength', 'minItems', 'maxItems']) {
345
+ const value = schema[key];
346
+ if (value !== undefined)
347
+ args.push(`${key} = ${value}L`);
348
+ }
349
+ for (const key of ['minimum', 'maximum']) {
350
+ const value = schema[key];
351
+ if (value !== undefined)
352
+ args.push(`${key} = ${number(value)}`);
353
+ }
354
+ if (schema.$ref !== undefined)
355
+ args.push(`ref = ${kotlinString(schema.$ref)}`);
356
+ return `SpecPropSchema(${args.join(', ')})`;
357
+ }
302
358
  /** The catalog vocabulary as a Kotlin registry of `SpecComponent` values. */
303
359
  export function vocabularyToKotlin(catalog, stamp) {
304
360
  const ids = catalog.components.map((c) => c.id);
@@ -319,9 +375,25 @@ export function vocabularyToKotlin(catalog, stamp) {
319
375
  const enumValues = p.enumValues
320
376
  ? `listOf(${p.enumValues.map(kotlinString).join(', ')})`
321
377
  : undefined;
322
- lines.push(`${INDENT}${INDENT}${INDENT}${INDENT}SpecProp(`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}name = ${kotlinString(p.name)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}kind = SpecPropKind.${upperSnake(p.kind)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}required = ${optional(p.required === undefined ? undefined : String(p.required))},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}enumValues = ${optional(enumValues)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}extension = ${optional(p.extension ? `SpecPropExtension.${upperSnake(p.extension)}` : undefined)},`, `${INDENT}${INDENT}${INDENT}${INDENT}),`);
378
+ lines.push(`${INDENT}${INDENT}${INDENT}${INDENT}SpecProp(`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}name = ${kotlinString(p.name)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}kind = SpecPropKind.${upperSnake(p.kind)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}required = ${optional(p.required === undefined ? undefined : String(p.required))},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}enumValues = ${optional(enumValues)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}extension = ${optional(p.extension ? `SpecPropExtension.${upperSnake(p.extension)}` : undefined)},`);
379
+ // Emitted only when present, so a catalog without them stays byte-stable.
380
+ if (p.schema)
381
+ lines.push(`${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}schema = ${propSchemaExpr(p.schema)},`);
382
+ if (p.deprecated) {
383
+ lines.push(`${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}deprecated = ${deprecationExpr(p.deprecated)},`);
384
+ }
385
+ lines.push(`${INDENT}${INDENT}${INDENT}${INDENT}),`);
386
+ }
387
+ lines.push(`${INDENT}${INDENT}${INDENT}),`);
388
+ if (c.events) {
389
+ lines.push(`${INDENT}${INDENT}${INDENT}events = listOf(${c.events.map((e) => `SpecEventName.${upperSnake(e)}`).join(', ')}),`);
390
+ }
391
+ if (c.bindable) {
392
+ lines.push(`${INDENT}${INDENT}${INDENT}bindable = listOf(${c.bindable.map(kotlinString).join(', ')}),`);
323
393
  }
324
- lines.push(`${INDENT}${INDENT}${INDENT}),`, `${INDENT}${INDENT}),`);
394
+ if (c.deprecated)
395
+ lines.push(`${INDENT}${INDENT}${INDENT}deprecated = ${deprecationExpr(c.deprecated)},`);
396
+ lines.push(`${INDENT}${INDENT}),`);
325
397
  }
326
398
  lines.push(`${INDENT})`, '', `${INDENT}fun component(id: String): SpecComponent? = components.firstOrNull { it.id == id }`, '}', '');
327
399
  return lines.join('\n');
@@ -1,6 +1,7 @@
1
+ import { type SpecCatalog } from './catalog.js';
1
2
  /** One conformance failure: drift is a failed gate, never a warning. */
2
3
  export interface ConformanceIssue {
3
- check: 'dialect' | 'catalog' | 'capabilities' | 'manifest' | 'generated' | 'golden' | 'invalid';
4
+ check: 'dialect' | 'catalog' | 'capabilities' | 'manifest' | 'generated' | 'golden' | 'invalid' | 'contract' | 'prop-schema' | 'deprecation' | 'workflow';
4
5
  path: string;
5
6
  message: string;
6
7
  }
@@ -14,7 +15,15 @@ export interface ConformanceIssue {
14
15
  * - regenerating gives byte-identical TypeScript and Kotlin, with no file
15
16
  * missing or extra;
16
17
  * - every golden blueprint validates, and every invalid fixture fails with
17
- * exactly its expected diagnostic codes.
18
+ * exactly its expected diagnostic codes (blueprints and workflow graphs);
19
+ * - the blueprint and workflow `$defs` stay disjoint, every catalog property
20
+ * schema is usable (array and object props only, contract `$ref`s only),
21
+ * bindable props are declared portable props, and no deprecation outlives
22
+ * its one-minor warning window.
18
23
  * Runtime-neutral: the caller reads the files.
19
24
  */
20
25
  export declare function checkConformance(normative: Readonly<Record<string, string>>, committed: Readonly<Record<string, string>>): ConformanceIssue[];
26
+ /** The minor-version window: a deprecation since `since` expires one minor later. */
27
+ export declare function deprecationExpired(since: string, specVersion?: string): boolean;
28
+ /** Catalog rules the schema cannot express: property schemas, bindable props, deprecations. */
29
+ export declare function checkCatalogContract(catalog: SpecCatalog, specVersion?: string): ConformanceIssue[];
@@ -1,7 +1,10 @@
1
1
  import { buildSpec } from "./build.js";
2
2
  import { CAPABILITIES } from "./catalog.js";
3
+ import { checkPropSchema, defCollisions } from "./contract.js";
3
4
  import { checkSchemaDocument, isRecord, validateInstance } from "./dialect.js";
4
5
  import { validateBlueprint } from "./validate.js";
6
+ import { SPEC_VERSION } from "./version.js";
7
+ import { validateWorkflowGraph } from "./workflow.js";
5
8
  /**
6
9
  * Run the spec's conformance harness (spec/Compose design §7.1 and §7.3)
7
10
  * over the normative files and the committed outputs (`manifest.json`,
@@ -12,7 +15,11 @@ import { validateBlueprint } from "./validate.js";
12
15
  * - regenerating gives byte-identical TypeScript and Kotlin, with no file
13
16
  * missing or extra;
14
17
  * - every golden blueprint validates, and every invalid fixture fails with
15
- * exactly its expected diagnostic codes.
18
+ * exactly its expected diagnostic codes (blueprints and workflow graphs);
19
+ * - the blueprint and workflow `$defs` stay disjoint, every catalog property
20
+ * schema is usable (array and object props only, contract `$ref`s only),
21
+ * bindable props are declared portable props, and no deprecation outlives
22
+ * its one-minor warning window.
16
23
  * Runtime-neutral: the caller reads the files.
17
24
  */
18
25
  export function checkConformance(normative, committed) {
@@ -32,6 +39,16 @@ export function checkConformance(normative, committed) {
32
39
  }
33
40
  const catalogSchema = schemas['schemas/catalog.schema.json'];
34
41
  const blueprintSchema = schemas['schemas/blueprint.schema.json'];
42
+ const workflowSchema = schemas['schemas/workflow.schema.json'];
43
+ if (blueprintSchema) {
44
+ for (const name of defCollisions(blueprintSchema, workflowSchema)) {
45
+ issues.push({
46
+ check: 'contract',
47
+ path: 'schemas/blueprint.schema.json',
48
+ message: `$defs.${name} is also a workflow definition`,
49
+ });
50
+ }
51
+ }
35
52
  const catalog = json('catalog/components.json');
36
53
  if (catalogSchema) {
37
54
  for (const issue of validateInstance(catalogSchema, catalog)) {
@@ -62,6 +79,7 @@ export function checkConformance(normative, committed) {
62
79
  }
63
80
  }
64
81
  }
82
+ issues.push(...checkCatalogContract(catalog));
65
83
  let build;
66
84
  try {
67
85
  build = buildSpec(normative);
@@ -100,8 +118,23 @@ export function checkConformance(normative, committed) {
100
118
  }
101
119
  }
102
120
  }
121
+ if (workflowSchema) {
122
+ for (const path of Object.keys(normative).filter((p) => p.startsWith('fixtures/workflow/'))) {
123
+ const fixture = json(path);
124
+ const golden = path.startsWith('fixtures/workflow/golden/');
125
+ const got = validateWorkflowGraph(golden ? fixture : fixture.graph, workflowSchema).diagnostics.map((d) => d.code);
126
+ const expected = golden ? [] : (fixture.expect ?? []);
127
+ if (got.join(',') !== expected.join(',')) {
128
+ issues.push({
129
+ check: 'workflow',
130
+ path,
131
+ message: `expected [${expected.join(', ')}], got [${got.join(', ')}]`,
132
+ });
133
+ }
134
+ }
135
+ }
103
136
  if (blueprintSchema) {
104
- const documents = { blueprintSchema, catalog };
137
+ const documents = { blueprintSchema, catalog, workflowSchema, validateWorkflowGraph };
105
138
  for (const path of Object.keys(normative).filter((p) => p.startsWith('fixtures/golden/'))) {
106
139
  const result = validateBlueprint(json(path), documents);
107
140
  for (const d of result.diagnostics) {
@@ -122,3 +155,61 @@ export function checkConformance(normative, committed) {
122
155
  }
123
156
  return issues;
124
157
  }
158
+ /** The minor-version window: a deprecation since `since` expires one minor later. */
159
+ export function deprecationExpired(since, specVersion = SPEC_VERSION) {
160
+ const parse = (v) => v.split('.').map(Number);
161
+ const [major, minor] = parse(specVersion);
162
+ const [sinceMajor, sinceMinor] = parse(since);
163
+ return major > sinceMajor || (major === sinceMajor && minor >= sinceMinor + 1);
164
+ }
165
+ /** Catalog rules the schema cannot express: property schemas, bindable props, deprecations. */
166
+ export function checkCatalogContract(catalog, specVersion = SPEC_VERSION) {
167
+ const issues = [];
168
+ const path = 'catalog/components.json';
169
+ const expired = (where, deprecated) => {
170
+ if (deprecated && deprecationExpired(deprecated.since, specVersion)) {
171
+ issues.push({
172
+ check: 'deprecation',
173
+ path,
174
+ message: `${where}: deprecated since ${deprecated.since}; remove it (one minor of warnings has passed)`,
175
+ });
176
+ }
177
+ };
178
+ for (const component of catalog?.components ?? []) {
179
+ expired(component.id, component.deprecated);
180
+ const portable = new Set(component.props.filter((p) => !p.extension).map((p) => p.name));
181
+ for (const name of component.bindable ?? []) {
182
+ if (!portable.has(name)) {
183
+ issues.push({
184
+ check: 'prop-schema',
185
+ path,
186
+ message: `${component.id}: bindable "${name}" is not a portable prop`,
187
+ });
188
+ }
189
+ }
190
+ if (new Set(component.events ?? []).size !== (component.events ?? []).length) {
191
+ issues.push({ check: 'catalog', path, message: `${component.id}: events repeat` });
192
+ }
193
+ for (const prop of component.props) {
194
+ expired(`${component.id}.${prop.name}`, prop.deprecated);
195
+ if (!prop.schema)
196
+ continue;
197
+ if (prop.kind !== 'array' && prop.kind !== 'object') {
198
+ issues.push({
199
+ check: 'prop-schema',
200
+ path,
201
+ message: `${component.id}.${prop.name}: only array and object props take a schema`,
202
+ });
203
+ continue;
204
+ }
205
+ for (const problem of checkPropSchema(prop.schema)) {
206
+ issues.push({
207
+ check: 'prop-schema',
208
+ path,
209
+ message: `${component.id}.${prop.name}: ${problem}`,
210
+ });
211
+ }
212
+ }
213
+ }
214
+ return issues;
215
+ }