@gregiteen/ssss-cli 0.8.0 → 0.9.1

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 (48) hide show
  1. package/CHANGELOG.md +55 -4
  2. package/README.md +42 -84
  3. package/VERSION +1 -1
  4. package/conformance/README.md +1 -1
  5. package/conformance/fixtures.json +4 -48
  6. package/conformance/reference-bundle.ucw.json +22 -21
  7. package/docs/help/bundle.md +2 -2
  8. package/docs/help/overview.md +9 -6
  9. package/docs/help/semantic.md +5 -4
  10. package/docs/ssss-spec.md +42 -86
  11. package/package.json +22 -3
  12. package/registry/core.json +22 -29
  13. package/scripts/build-reference-bundle.mjs +0 -20
  14. package/scripts/cmd-adapter.mjs +29 -0
  15. package/scripts/cmd-import.mjs +1 -1
  16. package/scripts/cmd-migrate.mjs +26 -0
  17. package/scripts/cmd-new.mjs +5 -5
  18. package/scripts/cmd-primitive.mjs +48 -0
  19. package/scripts/cmd-registry.mjs +25 -0
  20. package/scripts/cmd-semantic.mjs +6 -8
  21. package/scripts/conformance-09.mjs +331 -0
  22. package/scripts/conformance.mjs +153 -186
  23. package/scripts/generate-sbom.mjs +96 -0
  24. package/scripts/phase9-cross-host-corpus.mjs +223 -0
  25. package/scripts/ssss.mjs +8 -2
  26. package/skills/ssss/SKILL.md +32 -208
  27. package/skills/ssss/evals/evals.json +3 -3
  28. package/skills/ssss/references/contract-reference.md +7 -8
  29. package/skills/ssss/scripts/validate-schema.mjs +1 -44
  30. package/src/authorization.mjs +62 -0
  31. package/src/bundle.mjs +41 -5
  32. package/src/engine.mjs +101 -503
  33. package/src/events.mjs +155 -0
  34. package/src/guard.mjs +32 -0
  35. package/src/http.mjs +26 -0
  36. package/src/idempotency.mjs +55 -0
  37. package/src/kernel.mjs +260 -0
  38. package/src/leases.mjs +173 -0
  39. package/src/primitive.mjs +227 -0
  40. package/src/projections.mjs +133 -0
  41. package/src/registry.mjs +190 -39
  42. package/src/semantic.mjs +124 -316
  43. package/src/ui.mjs +100 -0
  44. package/src/validator.mjs +87 -0
  45. package/src/vfs.mjs +203 -0
  46. package/types/kernel.d.ts +48 -0
  47. package/docs/help/localization.md +0 -43
  48. package/scripts/cmd-localize.mjs +0 -44
package/src/registry.mjs CHANGED
@@ -18,7 +18,7 @@ const DEFAULT_REGISTRY_DIR = path.resolve(__dirname, '..', 'registry');
18
18
 
19
19
  const APPEND_TYPES = new Set(['conversation', 'run']);
20
20
  const SAFE_REGISTRY_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
21
- const SAFE_TYPE_NAME = /^[a-z][a-z0-9_-]{0,127}$/;
21
+ const SAFE_TYPE_NAME = /^(?:[a-z][a-z0-9_-]{0,63}:)?[a-z][a-z0-9_-]{0,127}$/;
22
22
  const SAFE_FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
23
23
 
24
24
  function isRecord(value) {
@@ -109,58 +109,124 @@ function validatePrimitiveDefinition(name, def, source, portabilityClasses) {
109
109
  }
110
110
  }
111
111
 
112
+ /** Return true when a primitive identifier is a safe local or qualified name. */
113
+ export function isPrimitiveTypeName(value) {
114
+ return typeof value === 'string' && SAFE_TYPE_NAME.test(value);
115
+ }
116
+
117
+ function normalizeExtensionInput(item, index) {
118
+ if (item?.document_primitives) return { source: item.source || `extension[${index}]`, value: item };
119
+ if (item?.registry?.document_primitives) {
120
+ return { source: item.source || `extension[${index}]`, value: item.registry };
121
+ }
122
+ throw new Error(`extension[${index}] must be an extension registry object.`);
123
+ }
124
+
125
+ function semverSatisfies(version, range) {
126
+ if (range === '*' || range === undefined) return true;
127
+ if (version === range) return true;
128
+ const current = String(version || '').match(/^(\d+)\.(\d+)\.(\d+)/);
129
+ const wanted = String(range).match(/^([~^])(\d+)\.(\d+)\.(\d+)$/);
130
+ if (!current || !wanted) return false;
131
+ const [, operator, major, minor, patch] = wanted;
132
+ const [cm, cn, cp] = current.slice(1).map(Number);
133
+ const [wm, wn, wp] = [major, minor, patch].map(Number);
134
+ if (operator === '^') return cm === wm && (cn > wn || (cn === wn && cp >= wp));
135
+ return cm === wm && cn === wn && cp >= wp;
136
+ }
137
+
112
138
  /**
113
- * Load the core registry and merge extension registries.
114
- * @param {string} [registryDir] - directory containing core.json + extensions/
115
- * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
139
+ * Compose the package core with extension/repository/workspace registry objects.
140
+ * This is the runtime composition path; hosts do not copy core.json into their repos.
116
141
  */
117
- export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
118
- const corePath = path.join(registryDir, 'core.json');
119
- const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
142
+ export function composeRegistries({ core, extensions: extensionInputs = [] } = {}) {
143
+ if (!isRecord(core)) throw new Error('core registry must be an object.');
120
144
  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();
145
+ if (!portabilityClasses.length) throw new Error('core registry: portability.classes must not be empty.');
123
146
 
147
+ const extensions = new Set();
148
+ const extensionVersions = new Map();
149
+ const extensionRequirements = [];
124
150
  const types = new Map();
151
+ const aliases = new Map();
152
+
125
153
  for (const [name, def] of Object.entries(core.document_primitives || {})) {
126
- validatePrimitiveDefinition(name, def, 'core.json', portabilityClasses);
127
- types.set(name, { ...def, type: name, registry: 'core' });
154
+ validatePrimitiveDefinition(name, def, 'core registry', portabilityClasses);
155
+ const value = { ...def, type: name, qualified_type: `ssss:${name}`, registry: 'core' };
156
+ types.set(name, value);
157
+ aliases.set(`ssss:${name}`, name);
128
158
  }
129
159
 
130
- const extDir = path.join(registryDir, 'extensions');
131
- if (fs.existsSync(extDir)) {
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.`);
160
+ for (const [index, item] of extensionInputs.entries()) {
161
+ const { source, value: ext } = normalizeExtensionInput(item, index);
162
+ if (!SAFE_REGISTRY_NAME.test(ext.registry || '')) {
163
+ throw new Error(`${source}: registry must be a safe identifier.`);
164
+ }
165
+ if (ext.registry === 'core' || ext.registry === 'ssss' || extensions.has(ext.registry)) {
166
+ throw new Error(`${source}: duplicate or reserved registry name '${ext.registry}'.`);
167
+ }
168
+ if (ext.extends !== undefined && ext.extends !== 'core' && ext.extends !== 'ssss') {
169
+ throw new Error(`${source}: extends must be 'core' or 'ssss'.`);
170
+ }
171
+ if (ext.version !== undefined &&
172
+ (typeof ext.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(ext.version))) {
173
+ throw new Error(`${source}: version must be a semantic version string.`);
174
+ }
175
+ assertRecord(ext.requires, `${source}: requires`);
176
+ extensions.add(ext.registry);
177
+ extensionVersions.set(ext.registry, ext.version || '0.0.0');
178
+ extensionRequirements.push({ source, registry: ext.registry, requires: ext.requires || {} });
179
+
180
+ for (const [name, def] of Object.entries(ext.document_primitives || {})) {
181
+ validatePrimitiveDefinition(name, def, source, portabilityClasses);
182
+ const qualified = name.includes(':') ? name : `${ext.registry}:${name}`;
183
+ if (name.includes(':') && !name.startsWith(`${ext.registry}:`)) {
184
+ throw new Error(`${source}: primitive '${name}' must use registry namespace '${ext.registry}'.`);
140
185
  }
141
- if (ext.registry === 'core' || extensions.has(ext.registry)) {
142
- throw new Error(`extensions/${file}: duplicate or reserved registry name '${ext.registry}'.`);
186
+ if (types.has(name) || aliases.has(name) || types.has(qualified) || aliases.has(qualified)) {
187
+ const existing = types.get(name) || types.get(aliases.get(name)) ||
188
+ types.get(qualified) || types.get(aliases.get(qualified));
189
+ throw new Error(
190
+ `${source}: primitive '${name}' collides with registry '${existing?.registry || 'unknown'}'.`
191
+ );
143
192
  }
144
- if (ext.extends !== undefined && ext.extends !== 'core') {
145
- throw new Error(`extensions/${file}: extends must be 'core'.`);
193
+ const value = { ...def, type: name, qualified_type: qualified, registry: ext.registry };
194
+ types.set(name, value);
195
+ if (qualified !== name) aliases.set(qualified, name);
196
+ for (const alias of def.aliases || []) {
197
+ if (!isPrimitiveTypeName(alias)) throw new Error(`${source}: primitive '${name}' has unsafe alias '${alias}'.`);
198
+ if (types.has(alias) || aliases.has(alias)) throw new Error(`${source}: primitive alias '${alias}' collides with an existing primitive.`);
199
+ aliases.set(alias, name);
146
200
  }
147
- extensions.add(ext.registry);
148
- for (const [name, def] of Object.entries(ext.document_primitives || {})) {
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 });
201
+ }
202
+ }
203
+
204
+ for (const requirement of extensionRequirements) {
205
+ for (const [dependency, range] of Object.entries(requirement.requires)) {
206
+ if (!extensionVersions.has(dependency)) throw new Error(`${requirement.source}: missing required extension '${dependency}'.`);
207
+ if (typeof range !== 'string' || !semverSatisfies(extensionVersions.get(dependency), range)) {
208
+ throw new Error(`${requirement.source}: extension '${dependency}' version '${extensionVersions.get(dependency)}' does not satisfy '${range}'.`);
156
209
  }
157
210
  }
158
211
  }
159
212
 
213
+ const registrySet = {
214
+ types,
215
+ aliases,
216
+ contractTypes: new Set(Object.keys(core.contract_primitives || {})),
217
+ portabilityClasses,
218
+ core,
219
+ extensions,
220
+ extensionVersions,
221
+ };
222
+ // Backward-compatible Map surface used by the existing engine/bundle APIs.
223
+ // The alias table lets shared validators resolve qualified 0.9 identifiers.
224
+ types.aliases = aliases;
225
+
160
226
  for (const def of types.values()) {
161
227
  for (const [field, constraint] of Object.entries(def.references || {})) {
162
228
  for (const target of [...(constraint.allowed_types || []), ...(constraint.disallowed_types || [])]) {
163
- if (!SAFE_TYPE_NAME.test(target) || !types.has(target)) {
229
+ if (!isPrimitiveTypeName(target) || !resolvePrimitiveDefinition(registrySet, target)) {
164
230
  throw new Error(
165
231
  `registry '${def.registry}' primitive '${def.type}' reference '${field}' names unknown type '${target}'.`
166
232
  );
@@ -169,8 +235,91 @@ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
169
235
  }
170
236
  }
171
237
 
172
- const contractTypes = new Set(Object.keys(core.contract_primitives || {}));
173
- return { types, contractTypes, portabilityClasses, core, extensions };
238
+ return registrySet;
239
+ }
240
+
241
+ /** Compose named ownership layers without giving lower scopes override semantics. */
242
+ export function composeRegistryLayers({ core, installed = [], repository = [], workspace = [], user = [], policyFloors = {} } = {}) {
243
+ const extensions = [
244
+ ...installed.map((registry) => ({ source: 'installed', registry })),
245
+ ...repository.map((registry) => ({ source: 'repository', registry })),
246
+ ...workspace.map((registry) => ({ source: 'workspace', registry })),
247
+ ...user.map((registry) => ({ source: 'user', registry })),
248
+ ];
249
+ const registrySet = composeRegistries({ core, extensions });
250
+ for (const [type, actions] of Object.entries(policyFloors)) {
251
+ const definition = resolvePrimitiveDefinition(registrySet, type);
252
+ if (!definition) throw new Error(`Policy floor names unknown primitive '${type}'.`);
253
+ for (const [action, required] of Object.entries(actions || {})) {
254
+ assertStringArray(required, `policy floor '${type}.${action}'`);
255
+ const declared = new Set(definition.capabilities?.[action] || []);
256
+ const missing = required.filter((capability) => !declared.has(capability));
257
+ if (missing.length) throw new Error(`Primitive '${type}' weakens policy floor for '${action}': ${missing.join(', ')}.`);
258
+ }
259
+ }
260
+ return registrySet;
261
+ }
262
+
263
+ function stableObject(value) {
264
+ if (Array.isArray(value)) return value.map(stableObject);
265
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableObject(value[key])]));
266
+ return value;
267
+ }
268
+
269
+ export function createRegistryLock(registrySet) {
270
+ const primitives = [...registrySet.types.values()]
271
+ .map((definition) => ({ ...definition }))
272
+ .sort((a, b) => a.qualified_type.localeCompare(b.qualified_type));
273
+ const payload = {
274
+ version: 1,
275
+ core_spec_version: registrySet.core.spec_version,
276
+ extensions: Object.fromEntries([...registrySet.extensionVersions.entries()].sort()),
277
+ primitives,
278
+ };
279
+ return { ...payload, integrity: `sha256:${crypto.createHash('sha256').update(JSON.stringify(stableObject(payload))).digest('hex')}` };
280
+ }
281
+
282
+ export function verifyRegistryLock(registrySet, lock) {
283
+ const expected = createRegistryLock(registrySet);
284
+ return {
285
+ valid: !!lock && lock.integrity === expected.integrity,
286
+ expected_integrity: expected.integrity,
287
+ actual_integrity: lock?.integrity || null,
288
+ };
289
+ }
290
+
291
+ /** Resolve local, qualified, or explicit migration-alias primitive identifiers. */
292
+ export function resolvePrimitiveDefinition(registrySet, type) {
293
+ if (!registrySet || typeof type !== 'string') return null;
294
+ const direct = registrySet.types?.get(type);
295
+ if (direct) return direct;
296
+ const local = registrySet.aliases?.get(type);
297
+ return local ? registrySet.types.get(local) || null : null;
298
+ }
299
+
300
+ /**
301
+ * Load the core registry and merge extension registries.
302
+ * @param {string} [registryDir] - directory containing core.json + extensions/
303
+ * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
304
+ */
305
+ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
306
+ const corePath = path.join(registryDir, 'core.json');
307
+ const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
308
+ const extensionInputs = [];
309
+ const extDir = path.join(registryDir, 'extensions');
310
+ if (fs.existsSync(extDir)) {
311
+ for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json')).sort()) {
312
+ const extPath = path.join(extDir, file);
313
+ if (fs.lstatSync(extPath).isSymbolicLink()) {
314
+ throw new Error(`extensions/${file}: symlinked registries are not allowed.`);
315
+ }
316
+ extensionInputs.push({
317
+ source: `extensions/${file}`,
318
+ registry: JSON.parse(fs.readFileSync(extPath, 'utf8')),
319
+ });
320
+ }
321
+ }
322
+ return composeRegistries({ core, extensions: extensionInputs });
174
323
  }
175
324
 
176
325
  /** Is this document type append-only (events appended, never rewritten)? */
@@ -258,6 +407,8 @@ export function validateDataConstraints(typeDef, data, universalRequired = []) {
258
407
  */
259
408
  export function validateReferenceConstraints(typeDef, data, resolveReference, types) {
260
409
  const issues = [];
410
+ const lookupType = (name) => types.get(name) ||
411
+ (types.aliases?.get(name) ? types.get(types.aliases.get(name)) : null);
261
412
  for (const [field, constraint] of Object.entries(typeDef?.references || {})) {
262
413
  const referencePath = data[field];
263
414
  if (referencePath === undefined || referencePath === null || referencePath === '') continue;
@@ -278,7 +429,7 @@ export function validateReferenceConstraints(typeDef, data, resolveReference, ty
278
429
  continue;
279
430
  }
280
431
  const referencedType = referenced.data?.type;
281
- if (!types.has(referencedType)) {
432
+ if (!lookupType(referencedType)) {
282
433
  issues.push({ field, issue: `Referenced document declares unknown type '${referencedType || '(missing)'}'.` });
283
434
  continue;
284
435
  }
@@ -288,7 +439,7 @@ export function validateReferenceConstraints(typeDef, data, resolveReference, ty
288
439
  if (constraint.disallowed_types?.includes(referencedType)) {
289
440
  issues.push({ field, issue: `Referenced type '${referencedType}' is forbidden.` });
290
441
  }
291
- const referencedPortability = resolvePortability(types.get(referencedType), referenced.data);
442
+ const referencedPortability = resolvePortability(lookupType(referencedType), referenced.data);
292
443
  if (constraint.allowed_portability && !constraint.allowed_portability.includes(referencedPortability)) {
293
444
  issues.push({
294
445
  field,