@gregiteen/ssss-cli 0.14.1 → 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 +43 -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/docs/ssss-spec.md +6 -3
- package/package.json +8 -1
- package/registry/core.json +2 -2
- package/schemas/bundle.schema.json +300 -0
- package/schemas/core-registry.schema.json +553 -0
- package/scripts/build-reference-bundle.mjs +9 -10
- package/scripts/check-commit-durability.mjs +1 -0
- package/scripts/check-docs-parity.mjs +38 -0
- package/scripts/cmd-new.mjs +79 -18
- package/scripts/cmd-schema.mjs +152 -0
- package/scripts/conformance-09.mjs +41 -0
- package/scripts/conformance.mjs +40 -2
- package/scripts/ssss.mjs +2 -0
- package/scripts/sync-docs.mjs +56 -0
- package/skills/ssss/SKILL.md +1 -1
- package/skills/ssss/core/SKILL.md +2 -2
- package/skills/ssss/core/references/core-registry.json +2 -2
- package/skills/ssss/core/references/ssss-spec.md +6 -3
- package/src/bundle.mjs +1 -0
- package/src/registry.mjs +9 -2
- package/src/schema-validator.mjs +315 -0
- 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/registry.mjs
CHANGED
|
@@ -408,12 +408,19 @@ function loadExtensionDefaults(registryDir) {
|
|
|
408
408
|
* `extensions/defaults.json` is loaded; failing that, every extension is loaded
|
|
409
409
|
* and any duplicate name remains a hard error rather than a silent preference.
|
|
410
410
|
*
|
|
411
|
-
* @param {string} [registryDir] - directory containing core.json + extensions
|
|
411
|
+
* @param {string} [registryDir] - directory containing core.json + extensions/, or an
|
|
412
|
+
* application `registry/` holding only extensions/ (the package core is used)
|
|
412
413
|
* @param {{ only?: string[]|Set<string> }} [options]
|
|
413
414
|
* @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
|
|
414
415
|
*/
|
|
415
416
|
export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR, options = {}) {
|
|
416
|
-
|
|
417
|
+
// Application repositories hold only `registry/extensions/`; the core always
|
|
418
|
+
// comes from this package (hosts never copy core.json). Fall back to the
|
|
419
|
+
// package core when the given directory has extensions but no core.json.
|
|
420
|
+
const ownCore = path.join(registryDir, 'core.json');
|
|
421
|
+
const corePath = fs.existsSync(ownCore) || !fs.existsSync(path.join(registryDir, 'extensions'))
|
|
422
|
+
? ownCore
|
|
423
|
+
: path.join(DEFAULT_REGISTRY_DIR, 'core.json');
|
|
417
424
|
const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
|
|
418
425
|
const requested = options.only ? new Set(options.only) : null;
|
|
419
426
|
const defaults = requested ? null : loadExtensionDefaults(registryDir);
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency JSON Schema Draft 2020-12 validator for SSSS core, extension,
|
|
3
|
+
* and bundle schemas.
|
|
4
|
+
*
|
|
5
|
+
* Implements validation for JSON Schema Draft 2020-12 constructs used across SSSS:
|
|
6
|
+
* - type (single or union)
|
|
7
|
+
* - const & enum
|
|
8
|
+
* - properties, additionalProperties & propertyNames
|
|
9
|
+
* - required
|
|
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)
|
|
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;
|
|
26
|
+
|
|
27
|
+
function resolveRef(root, ref) {
|
|
28
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
|
|
29
|
+
throw new Error(`Only local root-anchored $ref pointers are supported: ${ref}`);
|
|
30
|
+
}
|
|
31
|
+
const parts = ref.slice(2).split('/').map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
32
|
+
let current = root;
|
|
33
|
+
for (const part of parts) {
|
|
34
|
+
if (current == null || typeof current !== 'object') {
|
|
35
|
+
throw new Error(`Unable to resolve $ref pointer: ${ref}`);
|
|
36
|
+
}
|
|
37
|
+
current = current[part];
|
|
38
|
+
}
|
|
39
|
+
if (current === undefined) {
|
|
40
|
+
throw new Error(`Unable to resolve $ref pointer: ${ref}`);
|
|
41
|
+
}
|
|
42
|
+
return current;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchType(value, expectedType) {
|
|
46
|
+
if (Array.isArray(expectedType)) {
|
|
47
|
+
return expectedType.some((t) => matchType(value, t));
|
|
48
|
+
}
|
|
49
|
+
switch (expectedType) {
|
|
50
|
+
case 'string':
|
|
51
|
+
return typeof value === 'string';
|
|
52
|
+
case 'number':
|
|
53
|
+
return typeof value === 'number' && !Number.isNaN(value);
|
|
54
|
+
case 'integer':
|
|
55
|
+
return Number.isInteger(value);
|
|
56
|
+
case 'boolean':
|
|
57
|
+
return typeof value === 'boolean';
|
|
58
|
+
case 'object':
|
|
59
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
60
|
+
case 'array':
|
|
61
|
+
return Array.isArray(value);
|
|
62
|
+
case 'null':
|
|
63
|
+
return value === null;
|
|
64
|
+
default:
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
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
|
+
|
|
75
|
+
export function validateJsonSchema(data, schema, options = {}) {
|
|
76
|
+
const root = options.rootSchema || schema;
|
|
77
|
+
const errors = [];
|
|
78
|
+
|
|
79
|
+
function validate(value, curSchema, curPath, seenRefs = new Set()) {
|
|
80
|
+
if (!curSchema || typeof curSchema !== 'object') return;
|
|
81
|
+
|
|
82
|
+
if (curSchema.$ref) {
|
|
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
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (curSchema.type !== undefined) {
|
|
138
|
+
if (!matchType(value, curSchema.type)) {
|
|
139
|
+
errors.push({
|
|
140
|
+
path: curPath,
|
|
141
|
+
message: `Expected type ${JSON.stringify(curSchema.type)}, but received ${Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value}.`,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (curSchema.enum !== undefined && !curSchema.enum.includes(value)) {
|
|
148
|
+
errors.push({
|
|
149
|
+
path: curPath,
|
|
150
|
+
message: `Value ${JSON.stringify(value)} is not in enum: ${curSchema.enum.map((e) => JSON.stringify(e)).join(', ')}.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
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) {
|
|
162
|
+
errors.push({
|
|
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}.`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
202
|
+
if (Array.isArray(curSchema.required)) {
|
|
203
|
+
for (const reqKey of curSchema.required) {
|
|
204
|
+
if (value[reqKey] === undefined) {
|
|
205
|
+
errors.push({
|
|
206
|
+
path: curPath ? `${curPath}.${reqKey}` : reqKey,
|
|
207
|
+
message: `Missing required property '${reqKey}'.`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
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
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
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
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
validate(data, schema, '');
|
|
281
|
+
return {
|
|
282
|
+
valid: errors.length === 0,
|
|
283
|
+
errors,
|
|
284
|
+
};
|
|
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
|
-
}
|