@gregiteen/ssss-cli 0.14.2 → 0.14.3
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 +22 -0
- package/README.md +19 -11
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +19 -32
- package/docs/help/bundle.md +1 -1
- package/docs/help/overview.md +5 -8
- package/docs/help/schema.md +40 -0
- package/package.json +6 -2
- package/registry/core.json +1 -1
- package/schemas/bundle.schema.json +67 -26
- package/schemas/core-registry.schema.json +220 -53
- package/scripts/build-reference-bundle.mjs +9 -10
- package/scripts/check-docs-parity.mjs +38 -0
- package/scripts/cmd-new.mjs +79 -18
- package/scripts/cmd-schema.mjs +104 -14
- package/scripts/conformance-09.mjs +13 -0
- package/scripts/conformance.mjs +40 -2
- package/scripts/sync-docs.mjs +56 -0
- package/skills/ssss/core/references/core-registry.json +1 -1
- package/src/bundle.mjs +1 -0
- package/src/registry.mjs +9 -2
- package/src/schema-validator.mjs +218 -37
- package/types/kernel.d.ts +1 -0
- package/types/schema-validator.d.ts +24 -0
- package/registry/extensions/defaults.json +0 -5
- package/registry/extensions/festech.json +0 -1115
- package/registry/extensions/field-crm.json +0 -1018
package/src/schema-validator.mjs
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Zero-dependency JSON Schema validator for SSSS core
|
|
2
|
+
* Zero-dependency JSON Schema Draft 2020-12 validator for SSSS core, extension,
|
|
3
|
+
* and bundle schemas.
|
|
3
4
|
*
|
|
4
|
-
* Implements validation for
|
|
5
|
-
* used by SSSS schemas:
|
|
5
|
+
* Implements validation for JSON Schema Draft 2020-12 constructs used across SSSS:
|
|
6
6
|
* - type (single or union)
|
|
7
|
-
* -
|
|
7
|
+
* - const & enum
|
|
8
|
+
* - properties, additionalProperties & propertyNames
|
|
8
9
|
* - required
|
|
9
|
-
* - items
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
10
|
+
* - items, minItems, maxItems & uniqueItems
|
|
11
|
+
* - minLength, maxLength & pattern (Unicode-aware)
|
|
12
|
+
* - minimum & maximum
|
|
13
|
+
* - allOf, anyOf & oneOf
|
|
14
|
+
* - if / then / else
|
|
15
|
+
* - $ref (local root-anchored pointers `#/...` evaluated alongside sibling keywords)
|
|
13
16
|
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const SCHEMAS_DIR = path.resolve(__dirname, '..', 'schemas');
|
|
23
|
+
|
|
24
|
+
let cachedCoreSchema = null;
|
|
25
|
+
let cachedBundleSchema = null;
|
|
14
26
|
|
|
15
27
|
function resolveRef(root, ref) {
|
|
16
|
-
if (!ref.startsWith('#/')) {
|
|
28
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
|
|
17
29
|
throw new Error(`Only local root-anchored $ref pointers are supported: ${ref}`);
|
|
18
30
|
}
|
|
19
|
-
const parts = ref.slice(2).split('/');
|
|
31
|
+
const parts = ref.slice(2).split('/').map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
20
32
|
let current = root;
|
|
21
33
|
for (const part of parts) {
|
|
22
34
|
if (current == null || typeof current !== 'object') {
|
|
@@ -24,6 +36,9 @@ function resolveRef(root, ref) {
|
|
|
24
36
|
}
|
|
25
37
|
current = current[part];
|
|
26
38
|
}
|
|
39
|
+
if (current === undefined) {
|
|
40
|
+
throw new Error(`Unable to resolve $ref pointer: ${ref}`);
|
|
41
|
+
}
|
|
27
42
|
return current;
|
|
28
43
|
}
|
|
29
44
|
|
|
@@ -51,23 +66,78 @@ function matchType(value, expectedType) {
|
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
|
|
69
|
+
function itemKey(item) {
|
|
70
|
+
if (item === null) return 'null:null';
|
|
71
|
+
if (typeof item === 'object') return `json:${JSON.stringify(item)}`;
|
|
72
|
+
return `${typeof item}:${String(item)}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
export function validateJsonSchema(data, schema, options = {}) {
|
|
55
76
|
const root = options.rootSchema || schema;
|
|
56
77
|
const errors = [];
|
|
57
78
|
|
|
58
|
-
function validate(value, curSchema,
|
|
79
|
+
function validate(value, curSchema, curPath, seenRefs = new Set()) {
|
|
59
80
|
if (!curSchema || typeof curSchema !== 'object') return;
|
|
60
81
|
|
|
61
82
|
if (curSchema.$ref) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
if (!seenRefs.has(curSchema.$ref)) {
|
|
84
|
+
const nextSeen = new Set(seenRefs);
|
|
85
|
+
nextSeen.add(curSchema.$ref);
|
|
86
|
+
const resolved = resolveRef(root, curSchema.$ref);
|
|
87
|
+
validate(value, resolved, curPath, nextSeen);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(curSchema.allOf)) {
|
|
92
|
+
for (const subSchema of curSchema.allOf) {
|
|
93
|
+
validate(value, subSchema, curPath, seenRefs);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(curSchema.anyOf)) {
|
|
98
|
+
const matched = curSchema.anyOf.some(
|
|
99
|
+
(subSchema) => validateJsonSchema(value, subSchema, { rootSchema: root }).valid
|
|
100
|
+
);
|
|
101
|
+
if (!matched) {
|
|
102
|
+
errors.push({
|
|
103
|
+
path: curPath,
|
|
104
|
+
message: 'Value does not match any allowed schema in anyOf.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (Array.isArray(curSchema.oneOf)) {
|
|
110
|
+
const matchCount = curSchema.oneOf.filter(
|
|
111
|
+
(subSchema) => validateJsonSchema(value, subSchema, { rootSchema: root }).valid
|
|
112
|
+
).length;
|
|
113
|
+
if (matchCount !== 1) {
|
|
114
|
+
errors.push({
|
|
115
|
+
path: curPath,
|
|
116
|
+
message: `Expected value to match exactly one schema in oneOf, matched ${matchCount}.`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (curSchema.if !== undefined && (curSchema.then !== undefined || curSchema.else !== undefined)) {
|
|
122
|
+
const branch = validateJsonSchema(value, curSchema.if, { rootSchema: root }).valid
|
|
123
|
+
? curSchema.then
|
|
124
|
+
: curSchema.else;
|
|
125
|
+
if (branch !== undefined) validate(value, branch, curPath, seenRefs);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (curSchema.const !== undefined) {
|
|
129
|
+
if (JSON.stringify(value) !== JSON.stringify(curSchema.const)) {
|
|
130
|
+
errors.push({
|
|
131
|
+
path: curPath,
|
|
132
|
+
message: `Expected constant ${JSON.stringify(curSchema.const)}, but received ${JSON.stringify(value)}.`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
65
135
|
}
|
|
66
136
|
|
|
67
137
|
if (curSchema.type !== undefined) {
|
|
68
138
|
if (!matchType(value, curSchema.type)) {
|
|
69
139
|
errors.push({
|
|
70
|
-
path,
|
|
140
|
+
path: curPath,
|
|
71
141
|
message: `Expected type ${JSON.stringify(curSchema.type)}, but received ${Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value}.`,
|
|
72
142
|
});
|
|
73
143
|
return;
|
|
@@ -76,17 +146,54 @@ export function validateJsonSchema(data, schema, options = {}) {
|
|
|
76
146
|
|
|
77
147
|
if (curSchema.enum !== undefined && !curSchema.enum.includes(value)) {
|
|
78
148
|
errors.push({
|
|
79
|
-
path,
|
|
149
|
+
path: curPath,
|
|
80
150
|
message: `Value ${JSON.stringify(value)} is not in enum: ${curSchema.enum.map((e) => JSON.stringify(e)).join(', ')}.`,
|
|
81
151
|
});
|
|
82
152
|
}
|
|
83
153
|
|
|
84
|
-
if (typeof value === 'string'
|
|
85
|
-
|
|
86
|
-
|
|
154
|
+
if (typeof value === 'string') {
|
|
155
|
+
if (curSchema.minLength !== undefined && value.length < curSchema.minLength) {
|
|
156
|
+
errors.push({
|
|
157
|
+
path: curPath,
|
|
158
|
+
message: `String length ${value.length} is less than minLength ${curSchema.minLength}.`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (curSchema.maxLength !== undefined && value.length > curSchema.maxLength) {
|
|
87
162
|
errors.push({
|
|
88
|
-
path,
|
|
89
|
-
message: `String
|
|
163
|
+
path: curPath,
|
|
164
|
+
message: `String length ${value.length} exceeds maxLength ${curSchema.maxLength}.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (curSchema.pattern !== undefined) {
|
|
168
|
+
let reg;
|
|
169
|
+
try {
|
|
170
|
+
reg = new RegExp(curSchema.pattern, 'u');
|
|
171
|
+
} catch (err) {
|
|
172
|
+
errors.push({
|
|
173
|
+
path: curPath,
|
|
174
|
+
message: `Invalid schema regular expression ${curSchema.pattern}: ${err.message}`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (reg && !reg.test(value)) {
|
|
178
|
+
errors.push({
|
|
179
|
+
path: curPath,
|
|
180
|
+
message: `String does not match pattern ${curSchema.pattern}.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (typeof value === 'number' && !Number.isNaN(value)) {
|
|
187
|
+
if (curSchema.minimum !== undefined && value < curSchema.minimum) {
|
|
188
|
+
errors.push({
|
|
189
|
+
path: curPath,
|
|
190
|
+
message: `Value ${value} is less than minimum ${curSchema.minimum}.`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (curSchema.maximum !== undefined && value > curSchema.maximum) {
|
|
194
|
+
errors.push({
|
|
195
|
+
path: curPath,
|
|
196
|
+
message: `Value ${value} exceeds maximum ${curSchema.maximum}.`,
|
|
90
197
|
});
|
|
91
198
|
}
|
|
92
199
|
}
|
|
@@ -96,32 +203,76 @@ export function validateJsonSchema(data, schema, options = {}) {
|
|
|
96
203
|
for (const reqKey of curSchema.required) {
|
|
97
204
|
if (value[reqKey] === undefined) {
|
|
98
205
|
errors.push({
|
|
99
|
-
path:
|
|
206
|
+
path: curPath ? `${curPath}.${reqKey}` : reqKey,
|
|
100
207
|
message: `Missing required property '${reqKey}'.`,
|
|
101
208
|
});
|
|
102
209
|
}
|
|
103
210
|
}
|
|
104
211
|
}
|
|
105
212
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
213
|
+
if (curSchema.propertyNames && typeof curSchema.propertyNames === 'object') {
|
|
214
|
+
for (const key of Object.keys(value)) {
|
|
215
|
+
const keyPath = curPath ? `${curPath}.${key}` : key;
|
|
216
|
+
const keyCheck = validateJsonSchema(key, curSchema.propertyNames, { rootSchema: root });
|
|
217
|
+
if (!keyCheck.valid) {
|
|
218
|
+
for (const err of keyCheck.errors) {
|
|
219
|
+
errors.push({
|
|
220
|
+
path: keyPath,
|
|
221
|
+
message: `Invalid property name '${key}': ${err.message}`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (curSchema.properties !== undefined || curSchema.additionalProperties !== undefined) {
|
|
229
|
+
const definedKeys = new Set(Object.keys(curSchema.properties || {}));
|
|
230
|
+
for (const [key, val] of Object.entries(value)) {
|
|
231
|
+
const subPath = curPath ? `${curPath}.${key}` : key;
|
|
232
|
+
if (definedKeys.has(key)) {
|
|
233
|
+
validate(val, curSchema.properties[key], subPath);
|
|
234
|
+
} else if (curSchema.additionalProperties === false) {
|
|
235
|
+
errors.push({
|
|
236
|
+
path: subPath,
|
|
237
|
+
message: `Unexpected property '${key}' not defined in schema.`,
|
|
238
|
+
});
|
|
239
|
+
} else if (typeof curSchema.additionalProperties === 'object' && curSchema.additionalProperties !== null) {
|
|
240
|
+
validate(val, curSchema.additionalProperties, subPath);
|
|
241
|
+
}
|
|
118
242
|
}
|
|
119
243
|
}
|
|
120
244
|
}
|
|
121
245
|
|
|
122
|
-
if (Array.isArray(value)
|
|
123
|
-
|
|
124
|
-
|
|
246
|
+
if (Array.isArray(value)) {
|
|
247
|
+
if (curSchema.minItems !== undefined && value.length < curSchema.minItems) {
|
|
248
|
+
errors.push({
|
|
249
|
+
path: curPath,
|
|
250
|
+
message: `Array length ${value.length} is less than minItems ${curSchema.minItems}.`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (curSchema.maxItems !== undefined && value.length > curSchema.maxItems) {
|
|
254
|
+
errors.push({
|
|
255
|
+
path: curPath,
|
|
256
|
+
message: `Array length ${value.length} exceeds maxItems ${curSchema.maxItems}.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (curSchema.uniqueItems === true) {
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
for (let i = 0; i < value.length; i++) {
|
|
262
|
+
const k = itemKey(value[i]);
|
|
263
|
+
if (seen.has(k)) {
|
|
264
|
+
errors.push({
|
|
265
|
+
path: `${curPath}[${i}]`,
|
|
266
|
+
message: `Duplicate array item ${JSON.stringify(value[i])} violates uniqueItems.`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
seen.add(k);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (curSchema.items) {
|
|
273
|
+
for (let i = 0; i < value.length; i++) {
|
|
274
|
+
validate(value[i], curSchema.items, `${curPath}[${i}]`);
|
|
275
|
+
}
|
|
125
276
|
}
|
|
126
277
|
}
|
|
127
278
|
}
|
|
@@ -132,3 +283,33 @@ export function validateJsonSchema(data, schema, options = {}) {
|
|
|
132
283
|
errors,
|
|
133
284
|
};
|
|
134
285
|
}
|
|
286
|
+
|
|
287
|
+
/** Return the parsed canonical Core Registry JSON Schema (`schemas/core-registry.schema.json`). */
|
|
288
|
+
export function getCoreRegistrySchema() {
|
|
289
|
+
if (!cachedCoreSchema) {
|
|
290
|
+
cachedCoreSchema = JSON.parse(
|
|
291
|
+
fs.readFileSync(path.join(SCHEMAS_DIR, 'core-registry.schema.json'), 'utf8')
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return cachedCoreSchema;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Return the parsed canonical Bundle JSON Schema (`schemas/bundle.schema.json`). */
|
|
298
|
+
export function getBundleSchema() {
|
|
299
|
+
if (!cachedBundleSchema) {
|
|
300
|
+
cachedBundleSchema = JSON.parse(
|
|
301
|
+
fs.readFileSync(path.join(SCHEMAS_DIR, 'bundle.schema.json'), 'utf8')
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return cachedBundleSchema;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Validate a core or extension registry object against `schemas/core-registry.schema.json`. */
|
|
308
|
+
export function validateRegistryAgainstSchema(registry) {
|
|
309
|
+
return validateJsonSchema(registry, getCoreRegistrySchema());
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Validate a `.ucw` bundle object against `schemas/bundle.schema.json`. */
|
|
313
|
+
export function validateBundleAgainstSchema(bundle) {
|
|
314
|
+
return validateJsonSchema(bundle, getBundleSchema());
|
|
315
|
+
}
|
package/types/kernel.d.ts
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface SchemaValidationError {
|
|
2
|
+
path: string;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SchemaValidationResult {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
errors: SchemaValidationError[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ValidateJsonSchemaOptions {
|
|
12
|
+
rootSchema?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateJsonSchema(
|
|
16
|
+
data: unknown,
|
|
17
|
+
schema: Record<string, unknown>,
|
|
18
|
+
options?: ValidateJsonSchemaOptions
|
|
19
|
+
): SchemaValidationResult;
|
|
20
|
+
|
|
21
|
+
export function getCoreRegistrySchema(): Record<string, unknown>;
|
|
22
|
+
export function getBundleSchema(): Record<string, unknown>;
|
|
23
|
+
export function validateRegistryAgainstSchema(registry: unknown): SchemaValidationResult;
|
|
24
|
+
export function validateBundleAgainstSchema(bundle: unknown): SchemaValidationResult;
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ssss.dev/registry/v1",
|
|
3
|
-
"description": "Extensions composed when a caller does not scope a registry load. Extensions NOT listed here are product-specific: a host loads one only when it is operating on that product's vault, passing the extension name explicitly (`ssss export --ext <name>`). This keeps two unrelated products from colliding on a shared primitive name, which §5.6 makes a load-time error.",
|
|
4
|
-
"default_extensions": ["festech"]
|
|
5
|
-
}
|