@gregiteen/ssss-cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -4
- package/README.md +29 -13
- package/VERSION +1 -1
- package/conformance/README.md +17 -11
- package/conformance/fixtures.json +68 -2
- package/conformance/reference-bundle.ucw.json +39 -5
- package/docs/help/bundle.md +6 -2
- package/docs/help/import.md +6 -2
- package/docs/help/leases.md +31 -0
- package/docs/help/localization.md +43 -0
- package/docs/help/overview.md +9 -5
- package/docs/help/portability.md +11 -3
- package/docs/help/runtime.md +3 -3
- package/docs/help/scaffold.md +2 -2
- package/docs/help/semantic.md +37 -0
- package/docs/ssss-spec.md +112 -7
- package/package.json +8 -3
- package/registry/core.json +43 -3
- package/scripts/audit-registry-field-usage.mjs +10 -1
- package/scripts/build-reference-bundle.mjs +25 -2
- package/scripts/cmd-export.mjs +3 -3
- package/scripts/cmd-import.mjs +5 -1
- package/scripts/cmd-localize.mjs +44 -0
- package/scripts/cmd-new.mjs +11 -11
- package/scripts/cmd-semantic.mjs +88 -0
- package/scripts/conformance.mjs +574 -2
- package/scripts/ssss.mjs +6 -2
- package/skills/ssss/SKILL.md +225 -0
- package/skills/ssss/evals/evals.json +37 -0
- package/skills/ssss/references/contract-reference.md +96 -0
- package/skills/ssss/scripts/validate-schema.mjs +258 -0
- package/skills/ssss/subagents/audit-ssss-contract.md +22 -0
- package/src/bundle.mjs +266 -41
- package/src/engine.mjs +78 -4
- package/src/frontmatter.mjs +141 -38
- package/src/registry.mjs +249 -7
- package/src/semantic.mjs +402 -0
package/src/frontmatter.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* SSSS files are Markdown with a leading `---`-fenced YAML frontmatter block
|
|
5
5
|
* (spec §4.1). The canonical engine avoids a YAML dependency: it parses the
|
|
6
|
-
* subset SSSS actually uses —
|
|
7
|
-
*
|
|
8
|
-
* `type
|
|
6
|
+
* subset SSSS actually uses — scalars, inline `[a, b]` arrays, block `- item`
|
|
7
|
+
* arrays, nested maps, and arrays of shallow maps — which is sufficient to
|
|
8
|
+
* resolve a file's `type`, validate registry fields (§5, §9), and round-trip
|
|
9
|
+
* operation patches without dropping nested frontmatter.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
/** Split a document into its raw frontmatter block and body. */
|
|
@@ -42,43 +43,107 @@ function coerce(raw) {
|
|
|
42
43
|
return v;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
function countIndent(line) {
|
|
47
|
+
const m = line.match(/^ */);
|
|
48
|
+
return m ? m[0].length : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function splitKeyValue(text) {
|
|
52
|
+
const m = text.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
53
|
+
return m ? { key: m[1], rest: m[2] } : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nextSignificant(lines, start) {
|
|
57
|
+
for (let i = start; i < lines.length; i++) {
|
|
58
|
+
if (lines[i].trim() && !lines[i].trim().startsWith('#')) return i;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseMap(lines, start, indent) {
|
|
51
64
|
const data = {};
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
let i = start;
|
|
66
|
+
while (i < lines.length) {
|
|
55
67
|
const line = lines[i];
|
|
56
|
-
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
if (rest
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
|
|
69
|
+
const currentIndent = countIndent(line);
|
|
70
|
+
if (currentIndent < indent) break;
|
|
71
|
+
if (currentIndent > indent) { i++; continue; }
|
|
72
|
+
const kv = splitKeyValue(line.slice(indent));
|
|
73
|
+
if (!kv) { i++; continue; }
|
|
74
|
+
if (kv.rest !== '') {
|
|
75
|
+
data[kv.key] = coerce(kv.rest);
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const next = nextSignificant(lines, i + 1);
|
|
81
|
+
if (next < 0 || countIndent(lines[next]) <= indent) {
|
|
82
|
+
data[kv.key] = {};
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const childIndent = countIndent(lines[next]);
|
|
87
|
+
if (lines[next].slice(childIndent).startsWith('- ')) {
|
|
88
|
+
const parsed = parseList(lines, next, childIndent);
|
|
89
|
+
data[kv.key] = parsed.value;
|
|
90
|
+
i = parsed.next;
|
|
91
|
+
} else {
|
|
92
|
+
const parsed = parseMap(lines, next, childIndent);
|
|
93
|
+
data[kv.key] = parsed.value;
|
|
94
|
+
i = parsed.next;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { value: data, next: i };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseList(lines, start, indent) {
|
|
101
|
+
const items = [];
|
|
102
|
+
let i = start;
|
|
103
|
+
while (i < lines.length) {
|
|
104
|
+
const line = lines[i];
|
|
105
|
+
if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
|
|
106
|
+
const currentIndent = countIndent(line);
|
|
107
|
+
if (currentIndent < indent) break;
|
|
108
|
+
if (currentIndent > indent) { i++; continue; }
|
|
109
|
+
const item = line.slice(indent).match(/^-\s*(.*)$/);
|
|
110
|
+
if (!item) break;
|
|
111
|
+
const raw = item[1];
|
|
112
|
+
const kv = splitKeyValue(raw);
|
|
113
|
+
if (kv) {
|
|
114
|
+
const obj = { [kv.key]: kv.rest === '' ? {} : coerce(kv.rest) };
|
|
115
|
+
i++;
|
|
116
|
+
const next = nextSignificant(lines, i);
|
|
117
|
+
if (next >= 0 && countIndent(lines[next]) > indent) {
|
|
118
|
+
const parsed = parseMap(lines, next, countIndent(lines[next]));
|
|
119
|
+
Object.assign(obj, parsed.value);
|
|
120
|
+
i = parsed.next;
|
|
121
|
+
}
|
|
122
|
+
items.push(obj);
|
|
123
|
+
} else if (raw === '') {
|
|
124
|
+
const next = nextSignificant(lines, i + 1);
|
|
125
|
+
if (next >= 0 && countIndent(lines[next]) > indent) {
|
|
126
|
+
const parsed = parseMap(lines, next, countIndent(lines[next]));
|
|
127
|
+
items.push(parsed.value);
|
|
128
|
+
i = parsed.next;
|
|
129
|
+
} else {
|
|
130
|
+
items.push('');
|
|
131
|
+
i++;
|
|
74
132
|
}
|
|
75
|
-
data[key] = isList ? items : {};
|
|
76
|
-
i = isList ? j - 1 : i;
|
|
77
133
|
} else {
|
|
78
|
-
|
|
134
|
+
items.push(coerce(raw));
|
|
135
|
+
i++;
|
|
79
136
|
}
|
|
80
137
|
}
|
|
81
|
-
return
|
|
138
|
+
return { value: items, next: i };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parse a frontmatter block (the inner YAML, no fences) into an object.
|
|
143
|
+
*/
|
|
144
|
+
export function parseFrontmatter(fm) {
|
|
145
|
+
if (!fm) return {};
|
|
146
|
+
return parseMap(fm.split('\n'), 0, 0).value;
|
|
82
147
|
}
|
|
83
148
|
|
|
84
149
|
/** Parse a full document (with fences) into { data, body }. */
|
|
@@ -96,13 +161,51 @@ function serializeValue(v) {
|
|
|
96
161
|
return s;
|
|
97
162
|
}
|
|
98
163
|
|
|
164
|
+
function serializeEntry(lines, key, value, indent = 0) {
|
|
165
|
+
const pad = ' '.repeat(indent);
|
|
166
|
+
if (Array.isArray(value)) {
|
|
167
|
+
if (!value.length) {
|
|
168
|
+
lines.push(`${pad}${key}: []`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
lines.push(`${pad}${key}:`);
|
|
172
|
+
for (const item of value) {
|
|
173
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
174
|
+
const entries = Object.entries(item);
|
|
175
|
+
if (!entries.length) {
|
|
176
|
+
lines.push(`${pad} - {}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const [first, ...rest] = entries;
|
|
180
|
+
if (first[1] && typeof first[1] === 'object' && !Array.isArray(first[1])) {
|
|
181
|
+
lines.push(`${pad} - ${first[0]}:`);
|
|
182
|
+
serializeObject(lines, first[1], indent + 6);
|
|
183
|
+
} else {
|
|
184
|
+
lines.push(`${pad} - ${first[0]}: ${serializeValue(first[1])}`);
|
|
185
|
+
}
|
|
186
|
+
for (const [childKey, childValue] of rest) serializeEntry(lines, childKey, childValue, indent + 4);
|
|
187
|
+
} else {
|
|
188
|
+
lines.push(`${pad} - ${serializeValue(item)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (value && typeof value === 'object') {
|
|
194
|
+
lines.push(`${pad}${key}:`);
|
|
195
|
+
serializeObject(lines, value, indent + 2);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
lines.push(`${pad}${key}: ${serializeValue(value)}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function serializeObject(lines, obj, indent) {
|
|
202
|
+
for (const [k, v] of Object.entries(obj)) serializeEntry(lines, k, v, indent);
|
|
203
|
+
}
|
|
204
|
+
|
|
99
205
|
/** Serialize a frontmatter object + body back into an SSSS document. */
|
|
100
206
|
export function serializeDocument(data, body) {
|
|
101
207
|
const lines = ['---'];
|
|
102
|
-
|
|
103
|
-
if (v && typeof v === 'object' && !Array.isArray(v)) continue; // skip nested maps we can't round-trip
|
|
104
|
-
lines.push(`${k}: ${serializeValue(v)}`);
|
|
105
|
-
}
|
|
208
|
+
serializeObject(lines, data, 0);
|
|
106
209
|
lines.push('---');
|
|
107
210
|
const b = body == null ? '' : (body.startsWith('\n') ? body : `\n${body}`);
|
|
108
211
|
return lines.join('\n') + b;
|
package/src/registry.mjs
CHANGED
|
@@ -10,41 +10,167 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
13
14
|
import { fileURLToPath } from 'node:url';
|
|
14
15
|
|
|
15
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
17
|
const DEFAULT_REGISTRY_DIR = path.resolve(__dirname, '..', 'registry');
|
|
17
18
|
|
|
18
19
|
const APPEND_TYPES = new Set(['conversation', 'run']);
|
|
20
|
+
const SAFE_REGISTRY_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
21
|
+
const SAFE_TYPE_NAME = /^[a-z][a-z0-9_-]{0,127}$/;
|
|
22
|
+
const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
23
|
+
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertRecord(value, label) {
|
|
29
|
+
if (value === undefined) return;
|
|
30
|
+
if (!isRecord(value)) throw new Error(`${label} must be an object.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertStringArray(value, label) {
|
|
34
|
+
if (value === undefined) return;
|
|
35
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item)) {
|
|
36
|
+
throw new Error(`${label} must be an array of non-empty strings.`);
|
|
37
|
+
}
|
|
38
|
+
if (new Set(value).size !== value.length) throw new Error(`${label} must not contain duplicates.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validatePrimitiveDefinition(name, def, source, portabilityClasses) {
|
|
42
|
+
if (!SAFE_TYPE_NAME.test(name)) throw new Error(`${source}: unsafe primitive name '${name}'.`);
|
|
43
|
+
if (!def || typeof def !== 'object' || Array.isArray(def)) {
|
|
44
|
+
throw new Error(`${source}: primitive '${name}' must be an object.`);
|
|
45
|
+
}
|
|
46
|
+
if (typeof def.append_only !== 'boolean') {
|
|
47
|
+
throw new Error(`${source}: primitive '${name}' must declare append_only as a boolean.`);
|
|
48
|
+
}
|
|
49
|
+
if (!portabilityClasses.includes(def.portability)) {
|
|
50
|
+
throw new Error(`${source}: primitive '${name}' has invalid portability '${def.portability}'.`);
|
|
51
|
+
}
|
|
52
|
+
assertStringArray(def.required_fields, `${source}: primitive '${name}' required_fields`);
|
|
53
|
+
assertStringArray(def.optional_fields, `${source}: primitive '${name}' optional_fields`);
|
|
54
|
+
assertStringArray(def.immutable_fields, `${source}: primitive '${name}' immutable_fields`);
|
|
55
|
+
|
|
56
|
+
assertRecord(def.required_when, `${source}: primitive '${name}' required_when`);
|
|
57
|
+
for (const [condition, fields] of Object.entries(def.required_when || {})) {
|
|
58
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*==.+$/.test(condition)) {
|
|
59
|
+
throw new Error(`${source}: primitive '${name}' has invalid required_when condition '${condition}'.`);
|
|
60
|
+
}
|
|
61
|
+
assertStringArray(fields, `${source}: primitive '${name}' required_when '${condition}'`);
|
|
62
|
+
}
|
|
63
|
+
assertRecord(def.patterns, `${source}: primitive '${name}' patterns`);
|
|
64
|
+
for (const [field, pattern] of Object.entries(def.patterns || {})) {
|
|
65
|
+
if (!SAFE_FIELD_NAME.test(field)) {
|
|
66
|
+
throw new Error(`${source}: primitive '${name}' has unsafe pattern field '${field}'.`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof pattern !== 'string') {
|
|
69
|
+
throw new Error(`${source}: primitive '${name}' pattern for '${field}' must be a string.`);
|
|
70
|
+
}
|
|
71
|
+
try { new RegExp(pattern, 'u'); }
|
|
72
|
+
catch (error) {
|
|
73
|
+
throw new Error(`${source}: primitive '${name}' has invalid pattern for '${field}': ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
assertRecord(def.enums, `${source}: primitive '${name}' enums`);
|
|
77
|
+
for (const [field, values] of Object.entries(def.enums || {})) {
|
|
78
|
+
if (!SAFE_FIELD_NAME.test(field)) {
|
|
79
|
+
throw new Error(`${source}: primitive '${name}' has unsafe enum field '${field}'.`);
|
|
80
|
+
}
|
|
81
|
+
assertStringArray(values, `${source}: primitive '${name}' enum '${field}'`);
|
|
82
|
+
}
|
|
83
|
+
assertRecord(def.references, `${source}: primitive '${name}' references`);
|
|
84
|
+
for (const [field, constraint] of Object.entries(def.references || {})) {
|
|
85
|
+
if (!SAFE_FIELD_NAME.test(field)) {
|
|
86
|
+
throw new Error(`${source}: primitive '${name}' has unsafe reference field '${field}'.`);
|
|
87
|
+
}
|
|
88
|
+
if (!constraint || typeof constraint !== 'object' || Array.isArray(constraint)) {
|
|
89
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' must be an object.`);
|
|
90
|
+
}
|
|
91
|
+
assertStringArray(constraint.allowed_types, `${source}: primitive '${name}' reference '${field}' allowed_types`);
|
|
92
|
+
assertStringArray(constraint.disallowed_types, `${source}: primitive '${name}' reference '${field}' disallowed_types`);
|
|
93
|
+
assertStringArray(constraint.allowed_portability, `${source}: primitive '${name}' reference '${field}' allowed_portability`);
|
|
94
|
+
if (constraint.allowed_portability?.some((value) => !portabilityClasses.includes(value))) {
|
|
95
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' has an invalid allowed_portability value.`);
|
|
96
|
+
}
|
|
97
|
+
if (constraint.must_exist !== undefined && typeof constraint.must_exist !== 'boolean') {
|
|
98
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' must_exist must be a boolean.`);
|
|
99
|
+
}
|
|
100
|
+
const overlap = (constraint.allowed_types || []).filter((value) =>
|
|
101
|
+
constraint.disallowed_types?.includes(value));
|
|
102
|
+
if (overlap.length) {
|
|
103
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' both allows and forbids: ${overlap.join(', ')}.`);
|
|
104
|
+
}
|
|
105
|
+
if (constraint.hash_field !== undefined &&
|
|
106
|
+
(typeof constraint.hash_field !== 'string' || !SAFE_FIELD_NAME.test(constraint.hash_field))) {
|
|
107
|
+
throw new Error(`${source}: primitive '${name}' reference '${field}' hash_field must be a non-empty string.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
19
111
|
|
|
20
112
|
/**
|
|
21
113
|
* Load the core registry and merge extension registries.
|
|
22
114
|
* @param {string} [registryDir] - directory containing core.json + extensions/
|
|
23
|
-
* @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object }}
|
|
115
|
+
* @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
|
|
24
116
|
*/
|
|
25
117
|
export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
|
|
26
118
|
const corePath = path.join(registryDir, 'core.json');
|
|
27
119
|
const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
|
|
120
|
+
const portabilityClasses = Object.keys(core.portability?.classes || {});
|
|
121
|
+
if (!portabilityClasses.length) throw new Error('core.json: portability.classes must not be empty.');
|
|
122
|
+
const extensions = new Set();
|
|
28
123
|
|
|
29
124
|
const types = new Map();
|
|
30
125
|
for (const [name, def] of Object.entries(core.document_primitives || {})) {
|
|
126
|
+
validatePrimitiveDefinition(name, def, 'core.json', portabilityClasses);
|
|
31
127
|
types.set(name, { ...def, type: name, registry: 'core' });
|
|
32
128
|
}
|
|
33
129
|
|
|
34
130
|
const extDir = path.join(registryDir, 'extensions');
|
|
35
131
|
if (fs.existsSync(extDir)) {
|
|
36
|
-
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json'))) {
|
|
37
|
-
const
|
|
132
|
+
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json')).sort()) {
|
|
133
|
+
const extPath = path.join(extDir, file);
|
|
134
|
+
if (fs.lstatSync(extPath).isSymbolicLink()) {
|
|
135
|
+
throw new Error(`extensions/${file}: symlinked registries are not allowed.`);
|
|
136
|
+
}
|
|
137
|
+
const ext = JSON.parse(fs.readFileSync(extPath, 'utf8'));
|
|
138
|
+
if (!SAFE_REGISTRY_NAME.test(ext.registry || '')) {
|
|
139
|
+
throw new Error(`extensions/${file}: registry must be a safe identifier.`);
|
|
140
|
+
}
|
|
141
|
+
if (ext.registry === 'core' || extensions.has(ext.registry)) {
|
|
142
|
+
throw new Error(`extensions/${file}: duplicate or reserved registry name '${ext.registry}'.`);
|
|
143
|
+
}
|
|
144
|
+
if (ext.extends !== undefined && ext.extends !== 'core') {
|
|
145
|
+
throw new Error(`extensions/${file}: extends must be 'core'.`);
|
|
146
|
+
}
|
|
147
|
+
extensions.add(ext.registry);
|
|
38
148
|
for (const [name, def] of Object.entries(ext.document_primitives || {})) {
|
|
39
|
-
|
|
40
|
-
types.
|
|
149
|
+
validatePrimitiveDefinition(name, def, `extensions/${file}`, portabilityClasses);
|
|
150
|
+
if (types.has(name)) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`extensions/${file}: primitive '${name}' collides with registry '${types.get(name).registry}'.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
types.set(name, { ...def, type: name, registry: ext.registry });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const def of types.values()) {
|
|
161
|
+
for (const [field, constraint] of Object.entries(def.references || {})) {
|
|
162
|
+
for (const target of [...(constraint.allowed_types || []), ...(constraint.disallowed_types || [])]) {
|
|
163
|
+
if (!SAFE_TYPE_NAME.test(target) || !types.has(target)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`registry '${def.registry}' primitive '${def.type}' reference '${field}' names unknown type '${target}'.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
41
168
|
}
|
|
42
169
|
}
|
|
43
170
|
}
|
|
44
171
|
|
|
45
172
|
const contractTypes = new Set(Object.keys(core.contract_primitives || {}));
|
|
46
|
-
|
|
47
|
-
return { types, contractTypes, portabilityClasses, core };
|
|
173
|
+
return { types, contractTypes, portabilityClasses, core, extensions };
|
|
48
174
|
}
|
|
49
175
|
|
|
50
176
|
/** Is this document type append-only (events appended, never rewritten)? */
|
|
@@ -65,3 +191,119 @@ export function resolvePortability(typeDef, frontmatter) {
|
|
|
65
191
|
if (override && order[override] != null && order[override] > (order[base] ?? 0)) return override;
|
|
66
192
|
return base;
|
|
67
193
|
}
|
|
194
|
+
|
|
195
|
+
/** Is a registry-declared document reference a safe vault-relative path? */
|
|
196
|
+
export function isSafeDocumentPath(value) {
|
|
197
|
+
if (typeof value !== 'string' || !value || value.includes('\0') || value.includes('\\')) return false;
|
|
198
|
+
if (value.startsWith('/') || path.isAbsolute(value)) return false;
|
|
199
|
+
const parts = value.split('/');
|
|
200
|
+
return !parts.some((part) => !part || part === '.' || part === '..');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Stable hash used to bind derived/translated documents to an exact source. */
|
|
204
|
+
export function documentHash(content) {
|
|
205
|
+
return `sha256:${crypto.createHash('sha256').update(String(content)).digest('hex')}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isEmpty(value) {
|
|
209
|
+
return value === undefined || value === null || value === '' ||
|
|
210
|
+
(Array.isArray(value) && value.length === 0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Validate the registry's field-level schema independently of any storage
|
|
215
|
+
* operation. Used by the engine, bundle transport, and semantic projections.
|
|
216
|
+
*/
|
|
217
|
+
export function validateDataConstraints(typeDef, data, universalRequired = []) {
|
|
218
|
+
const issues = [];
|
|
219
|
+
const required = new Set([...universalRequired, ...(typeDef?.required_fields || [])]);
|
|
220
|
+
for (const field of required) {
|
|
221
|
+
if (isEmpty(data[field])) issues.push({ field, issue: `Missing required field '${field}'.` });
|
|
222
|
+
}
|
|
223
|
+
for (const [condition, fields] of Object.entries(typeDef?.required_when || {})) {
|
|
224
|
+
const match = condition.match(/^([A-Za-z_][A-Za-z0-9_-]*)==(.+)$/);
|
|
225
|
+
if (!match) continue;
|
|
226
|
+
const expected = /^-?\d+$/.test(match[2]) ? Number(match[2]) : match[2];
|
|
227
|
+
if (data[match[1]] !== expected) continue;
|
|
228
|
+
for (const field of fields) {
|
|
229
|
+
if (isEmpty(data[field])) issues.push({ field, issue: `Required when ${condition}.` });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const [field, allowed] of Object.entries(typeDef?.enums || {})) {
|
|
233
|
+
if (isEmpty(data[field])) continue;
|
|
234
|
+
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
235
|
+
for (const value of values) {
|
|
236
|
+
if (!allowed.includes(value)) {
|
|
237
|
+
issues.push({ field, issue: `Invalid value '${value}'; must be one of: ${allowed.join(', ')}.` });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const [field, source] of Object.entries(typeDef?.patterns || {})) {
|
|
242
|
+
if (isEmpty(data[field])) continue;
|
|
243
|
+
const expression = new RegExp(source, 'u');
|
|
244
|
+
const values = Array.isArray(data[field]) ? data[field] : [data[field]];
|
|
245
|
+
for (const value of values) {
|
|
246
|
+
if (typeof value !== 'string' || !expression.test(value)) {
|
|
247
|
+
issues.push({ field, issue: `Value must match registry pattern '${source}'.` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return issues;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Validate registry-declared document references using a caller-provided
|
|
256
|
+
* resolver. This keeps path, type, portability, and hash checks shared by the
|
|
257
|
+
* Operation Contract, bundles, and semantic/localization projections.
|
|
258
|
+
*/
|
|
259
|
+
export function validateReferenceConstraints(typeDef, data, resolveReference, types) {
|
|
260
|
+
const issues = [];
|
|
261
|
+
for (const [field, constraint] of Object.entries(typeDef?.references || {})) {
|
|
262
|
+
const referencePath = data[field];
|
|
263
|
+
if (referencePath === undefined || referencePath === null || referencePath === '') continue;
|
|
264
|
+
if (!isSafeDocumentPath(referencePath)) {
|
|
265
|
+
issues.push({ field, issue: `Reference path '${referencePath}' is not a safe vault-relative path.` });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
let referenced = null;
|
|
269
|
+
try { referenced = resolveReference(referencePath); }
|
|
270
|
+
catch (error) {
|
|
271
|
+
issues.push({ field, issue: `Reference '${referencePath}' could not be resolved: ${error.message}` });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!referenced) {
|
|
275
|
+
if (constraint.must_exist !== false) {
|
|
276
|
+
issues.push({ field, issue: `Referenced document '${referencePath}' does not exist.` });
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const referencedType = referenced.data?.type;
|
|
281
|
+
if (!types.has(referencedType)) {
|
|
282
|
+
issues.push({ field, issue: `Referenced document declares unknown type '${referencedType || '(missing)'}'.` });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (constraint.allowed_types && !constraint.allowed_types.includes(referencedType)) {
|
|
286
|
+
issues.push({ field, issue: `Referenced type '${referencedType}' is not allowed.` });
|
|
287
|
+
}
|
|
288
|
+
if (constraint.disallowed_types?.includes(referencedType)) {
|
|
289
|
+
issues.push({ field, issue: `Referenced type '${referencedType}' is forbidden.` });
|
|
290
|
+
}
|
|
291
|
+
const referencedPortability = resolvePortability(types.get(referencedType), referenced.data);
|
|
292
|
+
if (constraint.allowed_portability && !constraint.allowed_portability.includes(referencedPortability)) {
|
|
293
|
+
issues.push({
|
|
294
|
+
field,
|
|
295
|
+
issue: `Referenced document is '${referencedPortability}'; allowed portability: ${constraint.allowed_portability.join(', ')}.`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (constraint.hash_field) {
|
|
299
|
+
const expected = documentHash(referenced.content);
|
|
300
|
+
if (data[constraint.hash_field] !== expected) {
|
|
301
|
+
issues.push({
|
|
302
|
+
field: constraint.hash_field,
|
|
303
|
+
issue: `Source hash mismatch for '${referencePath}'; expected '${expected}'.`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return issues;
|
|
309
|
+
}
|