@texuli/spec 0.3.0 → 0.5.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/README.md +82 -14
- package/dist/build.d.ts +5 -0
- package/dist/build.js +1 -0
- package/dist/canonical.js +20 -13
- package/dist/catalog.d.ts +9 -3
- package/dist/catalog.js +7 -1
- package/dist/codegen/index.d.ts +2 -2
- package/dist/codegen/index.js +2 -2
- package/dist/codegen/kotlin.d.ts +6 -0
- package/dist/codegen/kotlin.js +80 -8
- package/dist/conformance.d.ts +11 -2
- package/dist/conformance.js +93 -2
- package/dist/contract.d.ts +112 -0
- package/dist/contract.js +196 -0
- package/dist/dialect.d.ts +10 -1
- package/dist/dialect.js +6 -1
- package/dist/generated/typescript/blueprint.d.ts +103 -5
- package/dist/generated/typescript/blueprint.js +1 -1
- package/dist/generated/typescript/catalog-schema.d.ts +35 -0
- package/dist/generated/typescript/catalog-schema.js +1 -1
- package/dist/generated/typescript/diagnostics.d.ts +10 -1
- package/dist/generated/typescript/diagnostics.js +1 -1
- package/dist/generated/typescript/manifest.js +1 -1
- package/dist/generated/typescript/vocabulary.d.ts +194 -4
- package/dist/generated/typescript/vocabulary.js +31 -2
- package/dist/generated/typescript/workflow.d.ts +84 -0
- package/dist/generated/typescript/workflow.js +3 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +9 -3
- package/dist/migrations/0.2.0-0.3.0.d.ts +7 -0
- package/dist/migrations/0.2.0-0.3.0.js +10 -0
- package/dist/migrations/0.3.0-0.4.0.d.ts +9 -0
- package/dist/migrations/0.3.0-0.4.0.js +12 -0
- package/dist/migrations/0.4.0-0.5.0.d.ts +8 -0
- package/dist/migrations/0.4.0-0.5.0.js +11 -0
- package/dist/migrations/index.d.ts +15 -0
- package/dist/migrations/index.js +55 -0
- package/dist/migrations/types.d.ts +14 -0
- package/dist/migrations/types.js +1 -0
- package/dist/surface.d.ts +60 -0
- package/dist/surface.js +162 -0
- package/dist/validate.d.ts +89 -14
- package/dist/validate.js +725 -64
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workflow.d.ts +15 -0
- package/dist/workflow.js +256 -0
- package/package.json +13 -1
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The semantic version of the spec documents in this package. */
|
|
2
|
-
export declare const SPEC_VERSION = "0.
|
|
2
|
+
export declare const SPEC_VERSION = "0.5.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The semantic version of the spec documents in this package. */
|
|
2
|
-
export const SPEC_VERSION = '0.
|
|
2
|
+
export const SPEC_VERSION = '0.5.0';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow graphs as data (ADR-008): `schemas/workflow.schema.json` checks
|
|
3
|
+
* the shape and the limits, then {@link validateWorkflowGraph} checks what a
|
|
4
|
+
* schema cannot: unique ids, declared types, groups, fields and ports, edge
|
|
5
|
+
* direction and kind, port multiplicity, duplicate edges and the cycle
|
|
6
|
+
* policy. Every pass is linear in nodes plus edges. The Kotlin twin is
|
|
7
|
+
* `native/texuli-spec/.../WorkflowValidator.kt`; both report in this order.
|
|
8
|
+
*/
|
|
9
|
+
import type { SpecValidation } from './generated/typescript/diagnostics.js';
|
|
10
|
+
import { type Schema } from './dialect.js';
|
|
11
|
+
/**
|
|
12
|
+
* Validate a workflow graph: the schema first (schema issues only, never a
|
|
13
|
+
* cascade), then every graph rule as code `workflow`. Never throws.
|
|
14
|
+
*/
|
|
15
|
+
export declare function validateWorkflowGraph(graph: unknown, workflowSchema: Schema): SpecValidation;
|
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow graphs as data (ADR-008): `schemas/workflow.schema.json` checks
|
|
3
|
+
* the shape and the limits, then {@link validateWorkflowGraph} checks what a
|
|
4
|
+
* schema cannot: unique ids, declared types, groups, fields and ports, edge
|
|
5
|
+
* direction and kind, port multiplicity, duplicate edges and the cycle
|
|
6
|
+
* policy. Every pass is linear in nodes plus edges. The Kotlin twin is
|
|
7
|
+
* `native/texuli-spec/.../WorkflowValidator.kt`; both report in this order.
|
|
8
|
+
*/
|
|
9
|
+
import { SPEC_LIMITS, SPEC_PATTERNS } from "./contract.js";
|
|
10
|
+
import { isRecord, validateInstance } from "./dialect.js";
|
|
11
|
+
const bags = (value) => Array.isArray(value) ? value.map((item) => (isRecord(item) ? item : {})) : [];
|
|
12
|
+
const text = (value) => typeof value === 'string' ? value : undefined;
|
|
13
|
+
const length = (value) => [...value].length;
|
|
14
|
+
const WORKFLOW_TEXT = new RegExp(SPEC_PATTERNS.workflowText, 'u');
|
|
15
|
+
/**
|
|
16
|
+
* Validate a workflow graph: the schema first (schema issues only, never a
|
|
17
|
+
* cascade), then every graph rule as code `workflow`. Never throws.
|
|
18
|
+
*/
|
|
19
|
+
export function validateWorkflowGraph(graph, workflowSchema) {
|
|
20
|
+
const schemaIssues = validateInstance(workflowSchema, graph);
|
|
21
|
+
if (schemaIssues.length > 0) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
diagnostics: schemaIssues.map((issue) => ({
|
|
25
|
+
code: 'schema',
|
|
26
|
+
path: issue.path,
|
|
27
|
+
message: `${issue.code}: ${issue.message}`,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const diagnostics = workflowRules(graph);
|
|
32
|
+
return { ok: diagnostics.length === 0, diagnostics };
|
|
33
|
+
}
|
|
34
|
+
/** The graph rules on a schema-valid graph. */
|
|
35
|
+
function workflowRules(graph) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const fail = (path, message) => {
|
|
38
|
+
out.push({ code: 'workflow', path, message });
|
|
39
|
+
};
|
|
40
|
+
// Node types: unique ids, unique ports and fields, consistent field rules.
|
|
41
|
+
const types = new Map();
|
|
42
|
+
bags(graph.nodeTypes).forEach((type, i) => {
|
|
43
|
+
const path = `$.nodeTypes[${i}]`;
|
|
44
|
+
const id = text(type.id) ?? '';
|
|
45
|
+
if (types.has(id)) {
|
|
46
|
+
fail(`${path}.id`, `Node type "${id}" is declared twice.`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const info = { ports: new Map(), fields: new Map(), fieldOrder: [] };
|
|
50
|
+
bags(type.ports).forEach((port, j) => {
|
|
51
|
+
const portId = text(port.id) ?? '';
|
|
52
|
+
if (info.ports.has(portId)) {
|
|
53
|
+
fail(`${path}.ports[${j}].id`, `Port "${portId}" is declared twice.`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
info.ports.set(portId, {
|
|
57
|
+
direction: text(port.direction) ?? '',
|
|
58
|
+
kind: text(port.kind),
|
|
59
|
+
multiple: port.multiple === true,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
bags(type.fields).forEach((field, j) => {
|
|
63
|
+
const here = `${path}.fields[${j}]`;
|
|
64
|
+
const name = text(field.name) ?? '';
|
|
65
|
+
if (info.fields.has(name)) {
|
|
66
|
+
fail(`${here}.name`, `Field "${name}" is declared twice.`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if ((field.kind === 'enum') !== (field.enumValues !== undefined)) {
|
|
70
|
+
fail(here, 'enumValues is required for an enum field and only allowed there.');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (field.maxLength !== undefined && field.kind !== 'string') {
|
|
74
|
+
fail(`${here}.maxLength`, 'maxLength is only allowed on a string field.');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(field.enumValues) &&
|
|
78
|
+
new Set(field.enumValues).size !== field.enumValues.length) {
|
|
79
|
+
fail(`${here}.enumValues`, 'enumValues must be distinct.');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
info.fields.set(name, field);
|
|
83
|
+
info.fieldOrder.push(field);
|
|
84
|
+
});
|
|
85
|
+
types.set(id, info);
|
|
86
|
+
});
|
|
87
|
+
const groups = new Set();
|
|
88
|
+
bags(graph.groups).forEach((group, i) => {
|
|
89
|
+
const id = text(group.id) ?? '';
|
|
90
|
+
if (groups.has(id))
|
|
91
|
+
fail(`$.groups[${i}].id`, `Group "${id}" is declared twice.`);
|
|
92
|
+
groups.add(id);
|
|
93
|
+
});
|
|
94
|
+
// Nodes: unique ids, declared type and group, field values.
|
|
95
|
+
const nodes = new Map();
|
|
96
|
+
bags(graph.nodes).forEach((node, i) => {
|
|
97
|
+
const path = `$.nodes[${i}]`;
|
|
98
|
+
const id = text(node.id) ?? '';
|
|
99
|
+
if (nodes.has(id)) {
|
|
100
|
+
fail(`${path}.id`, `Node "${id}" is declared twice.`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const type = types.get(text(node.type) ?? '');
|
|
104
|
+
nodes.set(id, type);
|
|
105
|
+
if (!type) {
|
|
106
|
+
fail(`${path}.type`, `Node type "${String(node.type)}" is not declared.`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (node.group !== undefined && !groups.has(text(node.group) ?? '')) {
|
|
110
|
+
fail(`${path}.group`, `Group "${String(node.group)}" is not declared.`);
|
|
111
|
+
}
|
|
112
|
+
const data = isRecord(node.data) ? node.data : {};
|
|
113
|
+
for (const [name, value] of Object.entries(data)) {
|
|
114
|
+
const field = type.fields.get(name);
|
|
115
|
+
const here = `${path}.data.${name}`;
|
|
116
|
+
if (!field)
|
|
117
|
+
fail(here, `"${name}" is not a field of "${String(node.type)}".`);
|
|
118
|
+
else {
|
|
119
|
+
const problem = fieldProblem(field, value);
|
|
120
|
+
if (problem)
|
|
121
|
+
fail(here, problem);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const field of type.fieldOrder) {
|
|
125
|
+
const name = text(field.name) ?? '';
|
|
126
|
+
if (field.required === true && !Object.hasOwn(data, name)) {
|
|
127
|
+
fail(`${path}.data.${name}`, `"${name}" is required.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
// Edges: unique ids, endpoints on declared ports in the right direction,
|
|
132
|
+
// matching kinds, port multiplicity and no duplicate connection.
|
|
133
|
+
const edgeIds = new Set();
|
|
134
|
+
const pairs = new Set();
|
|
135
|
+
const used = new Map();
|
|
136
|
+
const adjacency = new Map();
|
|
137
|
+
bags(graph.edges).forEach((edge, i) => {
|
|
138
|
+
const path = `$.edges[${i}]`;
|
|
139
|
+
const id = text(edge.id) ?? '';
|
|
140
|
+
if (edgeIds.has(id)) {
|
|
141
|
+
fail(`${path}.id`, `Edge "${id}" is declared twice.`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
edgeIds.add(id);
|
|
145
|
+
const from = endpoint(edge.from, 'out', `${path}.from`, nodes, fail);
|
|
146
|
+
const to = endpoint(edge.to, 'in', `${path}.to`, nodes, fail);
|
|
147
|
+
if (!from || !to)
|
|
148
|
+
return;
|
|
149
|
+
if (from.port.kind !== undefined &&
|
|
150
|
+
to.port.kind !== undefined &&
|
|
151
|
+
from.port.kind !== to.port.kind) {
|
|
152
|
+
fail(path, `A "${from.port.kind}" port cannot connect to a "${to.port.kind}" port.`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const pair = `${from.key}\u0000${to.key}`;
|
|
156
|
+
if (pairs.has(pair)) {
|
|
157
|
+
fail(path, 'These two ports are already connected.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
pairs.add(pair);
|
|
161
|
+
for (const [end, label] of [
|
|
162
|
+
[from, 'from'],
|
|
163
|
+
[to, 'to'],
|
|
164
|
+
]) {
|
|
165
|
+
const count = (used.get(end.key) ?? 0) + 1;
|
|
166
|
+
used.set(end.key, count);
|
|
167
|
+
if (!end.port.multiple && count > 1) {
|
|
168
|
+
fail(`${path}.${label}.port`, 'This port takes one edge; set multiple to allow more.');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const targets = adjacency.get(from.node) ?? [];
|
|
172
|
+
targets.push(to.node);
|
|
173
|
+
adjacency.set(from.node, targets);
|
|
174
|
+
});
|
|
175
|
+
if (graph.cycles === 'forbid' && hasCycle(nodes, adjacency)) {
|
|
176
|
+
fail('$.edges', 'The graph has a cycle, and its cycle policy is "forbid".');
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function endpoint(value, direction, path, nodes, fail) {
|
|
181
|
+
const bag = isRecord(value) ? value : {};
|
|
182
|
+
const node = text(bag.node) ?? '';
|
|
183
|
+
const portId = text(bag.port) ?? '';
|
|
184
|
+
if (!nodes.has(node)) {
|
|
185
|
+
fail(`${path}.node`, `Node "${node}" does not exist.`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const type = nodes.get(node);
|
|
189
|
+
if (!type)
|
|
190
|
+
return null;
|
|
191
|
+
const port = type.ports.get(portId);
|
|
192
|
+
if (!port) {
|
|
193
|
+
fail(`${path}.port`, `Node "${node}" has no port "${portId}".`);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
if (port.direction !== direction) {
|
|
197
|
+
fail(`${path}.port`, `Port "${portId}" is not an ${direction} port.`);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return { node, key: `${node}\u0000${portId}`, port };
|
|
201
|
+
}
|
|
202
|
+
/** Why `value` does not fit `field`, or `null`. */
|
|
203
|
+
function fieldProblem(field, value) {
|
|
204
|
+
switch (field.kind) {
|
|
205
|
+
case 'string': {
|
|
206
|
+
if (typeof value !== 'string')
|
|
207
|
+
return 'Must be a string.';
|
|
208
|
+
const max = typeof field.maxLength === 'number' ? field.maxLength : SPEC_LIMITS.workflowStringLength;
|
|
209
|
+
if (length(value) > max)
|
|
210
|
+
return `At most ${max} characters.`;
|
|
211
|
+
if (!WORKFLOW_TEXT.test(value))
|
|
212
|
+
return 'Control and bidirectional characters are not allowed.';
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
case 'number':
|
|
216
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
217
|
+
? null
|
|
218
|
+
: 'Must be a finite number.';
|
|
219
|
+
case 'boolean':
|
|
220
|
+
return typeof value === 'boolean' ? null : 'Must be a boolean.';
|
|
221
|
+
case 'enum':
|
|
222
|
+
return typeof value === 'string' &&
|
|
223
|
+
Array.isArray(field.enumValues) &&
|
|
224
|
+
field.enumValues.includes(value)
|
|
225
|
+
? null
|
|
226
|
+
: "Must be one of the field's enumValues.";
|
|
227
|
+
default:
|
|
228
|
+
return 'Unknown field kind.';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Kahn's algorithm over node ids; a self-loop is a cycle. */
|
|
232
|
+
function hasCycle(nodes, adjacency) {
|
|
233
|
+
const indegree = new Map();
|
|
234
|
+
for (const id of nodes.keys())
|
|
235
|
+
indegree.set(id, 0);
|
|
236
|
+
for (const targets of adjacency.values()) {
|
|
237
|
+
for (const target of targets)
|
|
238
|
+
indegree.set(target, (indegree.get(target) ?? 0) + 1);
|
|
239
|
+
}
|
|
240
|
+
const queue = [];
|
|
241
|
+
for (const [id, degree] of indegree)
|
|
242
|
+
if (degree === 0)
|
|
243
|
+
queue.push(id);
|
|
244
|
+
let seen = 0;
|
|
245
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
246
|
+
const id = queue[head];
|
|
247
|
+
seen += 1;
|
|
248
|
+
for (const target of adjacency.get(id) ?? []) {
|
|
249
|
+
const degree = (indegree.get(target) ?? 0) - 1;
|
|
250
|
+
indegree.set(target, degree);
|
|
251
|
+
if (degree === 0)
|
|
252
|
+
queue.push(target);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return seen < indegree.size;
|
|
256
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@texuli/spec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The schema-first blueprint contract: JSON schemas, the catalog vocabulary, deterministic TypeScript and Kotlin projections, and the conformance harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -27,9 +27,21 @@
|
|
|
27
27
|
"types": "./dist/index.d.ts",
|
|
28
28
|
"default": "./dist/index.js"
|
|
29
29
|
},
|
|
30
|
+
"./workflow": {
|
|
31
|
+
"types": "./dist/workflow.d.ts",
|
|
32
|
+
"default": "./dist/workflow.js"
|
|
33
|
+
},
|
|
34
|
+
"./migrate": {
|
|
35
|
+
"types": "./dist/migrations/index.d.ts",
|
|
36
|
+
"default": "./dist/migrations/index.js"
|
|
37
|
+
},
|
|
30
38
|
"./codegen": {
|
|
31
39
|
"types": "./dist/codegen/index.d.ts",
|
|
32
40
|
"default": "./dist/codegen/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./surface": {
|
|
43
|
+
"types": "./dist/surface.d.ts",
|
|
44
|
+
"default": "./dist/surface.js"
|
|
33
45
|
}
|
|
34
46
|
},
|
|
35
47
|
"files": [
|