@gregiteen/ssss-cli 0.7.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 +124 -0
- package/LICENSE +15 -0
- package/README.md +162 -0
- package/VERSION +1 -0
- package/conformance/README.md +72 -0
- package/conformance/fixtures.json +763 -0
- package/conformance/reference-bundle.ucw.json +135 -0
- package/docs/help/autolink.md +15 -0
- package/docs/help/bundle.md +47 -0
- package/docs/help/conformance.md +34 -0
- package/docs/help/export.md +20 -0
- package/docs/help/import.md +19 -0
- package/docs/help/overview.md +33 -0
- package/docs/help/portability.md +27 -0
- package/docs/help/provision.md +19 -0
- package/docs/help/provisioning.md +43 -0
- package/docs/help/roles.md +61 -0
- package/docs/help/runtime.md +78 -0
- package/docs/help/scaffold.md +47 -0
- package/docs/ssss-spec.md +1325 -0
- package/package.json +62 -0
- package/registry/core.json +225 -0
- package/registry/extensions/festech.json +134 -0
- package/scripts/audit-registry-field-usage.mjs +136 -0
- package/scripts/autolink.mjs +341 -0
- package/scripts/build-reference-bundle.mjs +169 -0
- package/scripts/cmd-export.mjs +72 -0
- package/scripts/cmd-help.mjs +60 -0
- package/scripts/cmd-import.mjs +71 -0
- package/scripts/cmd-inspect.mjs +78 -0
- package/scripts/cmd-new.mjs +262 -0
- package/scripts/cmd-provision.mjs +68 -0
- package/scripts/cmd-validate.mjs +43 -0
- package/scripts/conformance.mjs +408 -0
- package/scripts/lib/cli.mjs +92 -0
- package/scripts/release.sh +35 -0
- package/scripts/ssss.mjs +93 -0
- package/scripts/validate-skills.mjs +115 -0
- package/src/bundle.mjs +261 -0
- package/src/engine.mjs +452 -0
- package/src/frontmatter.mjs +109 -0
- package/src/registry.mjs +67 -0
- package/src/runtime.mjs +212 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SSSS conformance runner.
|
|
4
|
+
*
|
|
5
|
+
* ssss conformance structural validation of the fixture set
|
|
6
|
+
* ssss conformance --endpoint <url> [--token <pat>] run fixtures against a live host
|
|
7
|
+
*
|
|
8
|
+
* Structural mode (no endpoint) validates that fixtures.json is well-formed and
|
|
9
|
+
* that every fixture is internally consistent with the declared envelope rules —
|
|
10
|
+
* the check that keeps the canonical contract honest. Endpoint mode POSTs each
|
|
11
|
+
* fixture's `request` to a host's Operation Contract endpoint and diffs the
|
|
12
|
+
* response against `expected_response` (spec §12).
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { createEngine } from '../src/engine.mjs';
|
|
19
|
+
import { validateBundle, provisionBundle, importBundle } from '../src/bundle.mjs';
|
|
20
|
+
import { parseDocument } from '../src/frontmatter.mjs';
|
|
21
|
+
import { createRunEnvelope, planWorkflowTrigger } from '../src/runtime.mjs';
|
|
22
|
+
import { auditRegistryFieldUsage } from './audit-registry-field-usage.mjs';
|
|
23
|
+
import { validateSkills } from './validate-skills.mjs';
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const FIXTURES = path.resolve(__dirname, '..', 'conformance', 'fixtures.json');
|
|
27
|
+
const REFERENCE_BUNDLE = path.resolve(__dirname, '..', 'conformance', 'reference-bundle.ucw.json');
|
|
28
|
+
const REGISTRY_DIR = path.resolve(__dirname, '..', 'registry');
|
|
29
|
+
const PORTABILITY_CLASSES = ['structural', 'tenant_private', 'resource_bound'];
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const o = { endpoint: null, token: null, engine: false };
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
if (argv[i] === '--endpoint') o.endpoint = argv[++i];
|
|
35
|
+
else if (argv[i] === '--token') o.token = argv[++i];
|
|
36
|
+
else if (argv[i] === '--engine') o.engine = true;
|
|
37
|
+
}
|
|
38
|
+
return o;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadFixtures() {
|
|
42
|
+
if (!fs.existsSync(FIXTURES)) {
|
|
43
|
+
console.error(`❌ No fixtures found at ${FIXTURES}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(fs.readFileSync(FIXTURES, 'utf8'));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(`❌ fixtures.json is not valid JSON: ${err.message}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Validate the fixture file is internally consistent. Returns an array of problems. */
|
|
55
|
+
function validateStructure(doc) {
|
|
56
|
+
const problems = [];
|
|
57
|
+
const envelopeTypes = Object.keys(doc.operation_types || {});
|
|
58
|
+
if (envelopeTypes.length === 0) problems.push('operation_types is empty');
|
|
59
|
+
if (!Array.isArray(doc.fixtures) || doc.fixtures.length === 0) {
|
|
60
|
+
problems.push('fixtures array is empty');
|
|
61
|
+
return problems;
|
|
62
|
+
}
|
|
63
|
+
const seenIds = new Set();
|
|
64
|
+
for (const f of doc.fixtures) {
|
|
65
|
+
const id = f.id || '(no id)';
|
|
66
|
+
if (!f.id) problems.push(`fixture "${f.name || id}" is missing an id`);
|
|
67
|
+
if (seenIds.has(f.id)) problems.push(`duplicate fixture id: ${f.id}`);
|
|
68
|
+
seenIds.add(f.id);
|
|
69
|
+
if (!f.request) { problems.push(`${id}: missing request`); continue; }
|
|
70
|
+
if (!f.expected_response) problems.push(`${id}: missing expected_response`);
|
|
71
|
+
|
|
72
|
+
const t = f.request.type;
|
|
73
|
+
// A fixture may deliberately omit `type` to test envelope validation.
|
|
74
|
+
const expectsFailure = f.expected_response && f.expected_response.success === false;
|
|
75
|
+
if (t == null && !expectsFailure) {
|
|
76
|
+
problems.push(`${id}: request has no type but expects success`);
|
|
77
|
+
}
|
|
78
|
+
if (t != null) {
|
|
79
|
+
const spec = doc.operation_types[t];
|
|
80
|
+
if (!spec) {
|
|
81
|
+
problems.push(`${id}: request.type "${t}" not declared in operation_types`);
|
|
82
|
+
} else if (!expectsFailure) {
|
|
83
|
+
for (const rf of spec.required_fields || []) {
|
|
84
|
+
if (f.request[rf] == null) {
|
|
85
|
+
problems.push(`${id}: missing required envelope field "${rf}" for type "${t}"`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return problems;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate the primitive registries (§5.5 portability conformance):
|
|
96
|
+
* - core: every document primitive declares a valid portability class.
|
|
97
|
+
* - extensions: every document primitive declares a valid class, and no
|
|
98
|
+
* extension redefines a core type.
|
|
99
|
+
* Returns an array of problems.
|
|
100
|
+
*/
|
|
101
|
+
function validateRegistry() {
|
|
102
|
+
const problems = [];
|
|
103
|
+
const corePath = path.join(REGISTRY_DIR, 'core.json');
|
|
104
|
+
if (!fs.existsSync(corePath)) return ['registry/core.json not found'];
|
|
105
|
+
|
|
106
|
+
let core;
|
|
107
|
+
try { core = JSON.parse(fs.readFileSync(corePath, 'utf8')); }
|
|
108
|
+
catch (err) { return [`registry/core.json is not valid JSON: ${err.message}`]; }
|
|
109
|
+
|
|
110
|
+
const coreTypes = Object.keys(core.document_primitives || {});
|
|
111
|
+
for (const [name, def] of Object.entries(core.document_primitives || {})) {
|
|
112
|
+
if (!def.portability) problems.push(`core: "${name}" is missing a portability class`);
|
|
113
|
+
else if (!PORTABILITY_CLASSES.includes(def.portability)) {
|
|
114
|
+
problems.push(`core: "${name}" has invalid portability "${def.portability}"`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!core.portability || !core.portability.classes) {
|
|
118
|
+
problems.push('core: missing the portability classes definition block');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const extDir = path.join(REGISTRY_DIR, 'extensions');
|
|
122
|
+
if (fs.existsSync(extDir)) {
|
|
123
|
+
for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json'))) {
|
|
124
|
+
let ext;
|
|
125
|
+
try { ext = JSON.parse(fs.readFileSync(path.join(extDir, file), 'utf8')); }
|
|
126
|
+
catch (err) { problems.push(`extensions/${file}: not valid JSON: ${err.message}`); continue; }
|
|
127
|
+
for (const [name, def] of Object.entries(ext.document_primitives || {})) {
|
|
128
|
+
if (coreTypes.includes(name)) {
|
|
129
|
+
problems.push(`extensions/${file}: redefines core type "${name}" (forbidden)`);
|
|
130
|
+
}
|
|
131
|
+
if (!def.portability) problems.push(`extensions/${file}: "${name}" missing portability class`);
|
|
132
|
+
else if (!PORTABILITY_CLASSES.includes(def.portability)) {
|
|
133
|
+
problems.push(`extensions/${file}: "${name}" invalid portability "${def.portability}"`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return problems;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run every fixture in-process through the canonical engine against a fresh
|
|
143
|
+
* temp vault. Proves the reference engine satisfies the conformance contract
|
|
144
|
+
* (spec §12) without needing a live host. Fixtures run in array order so that
|
|
145
|
+
* create → patch → delete dependencies resolve.
|
|
146
|
+
*/
|
|
147
|
+
function runAgainstEngine(doc) {
|
|
148
|
+
const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-vault-'));
|
|
149
|
+
const engine = createEngine();
|
|
150
|
+
let pass = 0, fail = 0;
|
|
151
|
+
try {
|
|
152
|
+
for (const f of doc.fixtures) {
|
|
153
|
+
const want = f.expected_response || {};
|
|
154
|
+
const res = engine.processOperation(f.request, vault);
|
|
155
|
+
const okSuccess = want.success == null || res.success === want.success;
|
|
156
|
+
const wantType = want.validation && want.validation.type;
|
|
157
|
+
const okType = wantType == null || (res.validation && res.validation.type === wantType);
|
|
158
|
+
const okValid = !want.validation || want.validation.valid == null || res.validation.valid === want.validation.valid;
|
|
159
|
+
if (okSuccess && okType && okValid) { pass++; console.log(` ✅ ${f.id} — ${f.name}`); }
|
|
160
|
+
else {
|
|
161
|
+
fail++;
|
|
162
|
+
console.log(` ❌ ${f.id} — ${f.name}`);
|
|
163
|
+
console.log(` want success=${want.success} valid=${want.validation?.valid} type=${wantType}`);
|
|
164
|
+
console.log(` got success=${res.success} valid=${res.validation?.valid} type=${res.validation?.type} errors=${JSON.stringify(res.validation?.errors)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
fs.rmSync(vault, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
console.log(`\n ${pass}/${pass + fail} fixtures passed against the reference engine`);
|
|
171
|
+
return fail === 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Bundle/provisioning conformance (spec §16/§17). Validates the reference bundle
|
|
176
|
+
* against the registry schema, then round-trips it through provision → import →
|
|
177
|
+
* re-import against a fresh temp vault to prove:
|
|
178
|
+
* - the bundle is schema- and hash-valid;
|
|
179
|
+
* - link integrity holds under id-remap (§17.3);
|
|
180
|
+
* - import commits every file and is idempotent on re-run (§17.1);
|
|
181
|
+
* - no `tenant_private` primitive ever lands (the §5.5 keystone).
|
|
182
|
+
* Returns true on full pass.
|
|
183
|
+
*/
|
|
184
|
+
function runBundleConformance() {
|
|
185
|
+
if (!fs.existsSync(REFERENCE_BUNDLE)) {
|
|
186
|
+
console.log(' ⚠ No reference bundle; run `node scripts/build-reference-bundle.mjs` first.');
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
const bundle = JSON.parse(fs.readFileSync(REFERENCE_BUNDLE, 'utf8'));
|
|
190
|
+
const checks = [];
|
|
191
|
+
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
192
|
+
|
|
193
|
+
const { valid, errors } = validateBundle(bundle, { registryDir: REGISTRY_DIR });
|
|
194
|
+
check('reference bundle is schema- and hash-valid', valid, errors.join('; '));
|
|
195
|
+
check('sale profile carries no tenant_private file',
|
|
196
|
+
bundle.files.every((f) => parseDocument(f.content).data.type !== 'task' && parseDocument(f.content).data.type !== 'conversation'));
|
|
197
|
+
|
|
198
|
+
const prov = provisionBundle(bundle, { workspaceId: 'ws-conf', parameters: { business_name: 'Demo', domain: 'demo.example' } });
|
|
199
|
+
check('provision resolves params + link integrity (no dangling [[links]])', prov.ok,
|
|
200
|
+
`unresolved=${JSON.stringify(prov.unresolved)} dangling=${JSON.stringify(prov.danglingLinks)}`);
|
|
201
|
+
|
|
202
|
+
const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-'));
|
|
203
|
+
try {
|
|
204
|
+
const engine = createEngine({ registryDir: REGISTRY_DIR });
|
|
205
|
+
const first = importBundle(prov.plan, vault, engine);
|
|
206
|
+
check('import commits every file', first.ok && first.committed === bundle.files.length,
|
|
207
|
+
`ok=${first.ok} committed=${first.committed}/${bundle.files.length}`);
|
|
208
|
+
|
|
209
|
+
const second = importBundle(prov.plan, vault, engine);
|
|
210
|
+
check('re-import is idempotent (0 new commits)', second.ok && second.committed === 0,
|
|
211
|
+
`committed=${second.committed}`);
|
|
212
|
+
|
|
213
|
+
const landed = fs.existsSync(path.join(vault, 'tasks')) ? fs.readdirSync(path.join(vault, 'tasks')).filter(f => f !== 'index.md') : [];
|
|
214
|
+
check('no tenant_private task file on disk after import', landed.length === 0, `found=${JSON.stringify(landed)}`);
|
|
215
|
+
} finally {
|
|
216
|
+
fs.rmSync(vault, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let pass = 0;
|
|
220
|
+
for (const c of checks) {
|
|
221
|
+
if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
|
|
222
|
+
else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
223
|
+
}
|
|
224
|
+
console.log(`\n ${pass}/${checks.length} bundle/provisioning checks passed (§16/§17)`);
|
|
225
|
+
return pass === checks.length;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Runtime conformance (spec §11.8). Proves that a conformant daemon can treat
|
|
230
|
+
* workflow frontmatter as the canonical schedule source, then derive trigger,
|
|
231
|
+
* task, and run envelopes without introducing a scheduler database of record.
|
|
232
|
+
*/
|
|
233
|
+
function runRuntimeConformance() {
|
|
234
|
+
const checks = [];
|
|
235
|
+
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
236
|
+
|
|
237
|
+
const workflowPath = 'workflows/daily-digest/WORKFLOW.md';
|
|
238
|
+
const workflowContent = [
|
|
239
|
+
'---',
|
|
240
|
+
'type: workflow',
|
|
241
|
+
'name: "Daily Digest"',
|
|
242
|
+
'priority: 80',
|
|
243
|
+
'triggers:',
|
|
244
|
+
' - type: cron',
|
|
245
|
+
' id: daily-0800',
|
|
246
|
+
' cron: "0 8 * * *"',
|
|
247
|
+
' timezone: "America/Denver"',
|
|
248
|
+
' misfire_policy: run_once',
|
|
249
|
+
' concurrency: skip_if_running',
|
|
250
|
+
'isActive: true',
|
|
251
|
+
'---',
|
|
252
|
+
'',
|
|
253
|
+
'1. Gather unread messages.',
|
|
254
|
+
'2. Summarize.',
|
|
255
|
+
'3. Send the digest.',
|
|
256
|
+
'',
|
|
257
|
+
].join('\n');
|
|
258
|
+
|
|
259
|
+
const trigger = {
|
|
260
|
+
type: 'cron',
|
|
261
|
+
id: 'daily-0800',
|
|
262
|
+
cron: '0 8 * * *',
|
|
263
|
+
timezone: 'America/Denver',
|
|
264
|
+
misfire_policy: 'run_once',
|
|
265
|
+
concurrency: 'skip_if_running',
|
|
266
|
+
};
|
|
267
|
+
const scheduledFor = '2026-07-02T14:00:00.000Z';
|
|
268
|
+
const firstPlan = planWorkflowTrigger({
|
|
269
|
+
workflowPath,
|
|
270
|
+
workflowContent,
|
|
271
|
+
workspaceId: 'TEST_WORKSPACE_ID',
|
|
272
|
+
trigger,
|
|
273
|
+
scheduledFor,
|
|
274
|
+
firedAt: scheduledFor,
|
|
275
|
+
});
|
|
276
|
+
const secondPlan = planWorkflowTrigger({
|
|
277
|
+
workflowPath,
|
|
278
|
+
workflowContent,
|
|
279
|
+
workspaceId: 'TEST_WORKSPACE_ID',
|
|
280
|
+
trigger,
|
|
281
|
+
scheduledFor,
|
|
282
|
+
firedAt: scheduledFor,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
check('same workflow trigger plans the same task path',
|
|
286
|
+
firstPlan.envelopes[1].path === secondPlan.envelopes[1].path,
|
|
287
|
+
`${firstPlan.envelopes[1].path} !== ${secondPlan.envelopes[1].path}`);
|
|
288
|
+
check('same workflow trigger plans the same task idempotency key',
|
|
289
|
+
firstPlan.envelopes[1].idempotency_key === secondPlan.envelopes[1].idempotency_key);
|
|
290
|
+
check('runtime emits event then task envelopes',
|
|
291
|
+
firstPlan.envelopes[0].type === 'event' && firstPlan.envelopes[1].type === 'operation');
|
|
292
|
+
|
|
293
|
+
const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-runtime-'));
|
|
294
|
+
try {
|
|
295
|
+
const engine = createEngine({ registryDir: REGISTRY_DIR });
|
|
296
|
+
const eventRes = engine.processOperation(firstPlan.envelopes[0], vault);
|
|
297
|
+
const taskRes = engine.processOperation(firstPlan.envelopes[1], vault);
|
|
298
|
+
check('trigger event commits through Operation Contract', eventRes.success === true,
|
|
299
|
+
JSON.stringify(eventRes.validation?.errors || []));
|
|
300
|
+
check('instantiated task commits through Operation Contract', taskRes.success === true,
|
|
301
|
+
JSON.stringify(taskRes.validation?.errors || []));
|
|
302
|
+
|
|
303
|
+
const replayEvent = engine.processOperation(secondPlan.envelopes[0], vault);
|
|
304
|
+
const replayTask = engine.processOperation(secondPlan.envelopes[1], vault);
|
|
305
|
+
check('duplicate daemon tick replays trigger event idempotently', replayEvent.replay != null);
|
|
306
|
+
check('duplicate daemon tick replays task write idempotently', replayTask.replay != null);
|
|
307
|
+
|
|
308
|
+
const runEnvelope = createRunEnvelope({
|
|
309
|
+
workflowId: firstPlan.workflow_id,
|
|
310
|
+
workspaceId: 'TEST_WORKSPACE_ID',
|
|
311
|
+
taskPath: firstPlan.envelopes[1].path,
|
|
312
|
+
status: 'queued',
|
|
313
|
+
startedAt: scheduledFor,
|
|
314
|
+
});
|
|
315
|
+
const runRes = engine.processOperation(runEnvelope, vault);
|
|
316
|
+
check('worker-created run commits through Operation Contract', runRes.success === true,
|
|
317
|
+
JSON.stringify(runRes.validation?.errors || []));
|
|
318
|
+
} finally {
|
|
319
|
+
fs.rmSync(vault, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let pass = 0;
|
|
323
|
+
for (const c of checks) {
|
|
324
|
+
if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
|
|
325
|
+
else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
326
|
+
}
|
|
327
|
+
console.log(`\n ${pass}/${checks.length} runtime checks passed (§11.8)`);
|
|
328
|
+
return pass === checks.length;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function runAgainstEndpoint(doc, opts) {
|
|
332
|
+
const headers = { 'content-type': 'application/json' };
|
|
333
|
+
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
|
334
|
+
let pass = 0, fail = 0;
|
|
335
|
+
for (const f of doc.fixtures) {
|
|
336
|
+
try {
|
|
337
|
+
const res = await fetch(opts.endpoint, { method: 'POST', headers, body: JSON.stringify(f.request) });
|
|
338
|
+
const body = await res.json().catch(() => ({}));
|
|
339
|
+
const want = f.expected_response || {};
|
|
340
|
+
const okSuccess = want.success == null || body.success === want.success;
|
|
341
|
+
const okStatus = f.expected_http_status == null || res.status === f.expected_http_status;
|
|
342
|
+
if (okSuccess && okStatus) { pass++; console.log(` ✅ ${f.id} — ${f.name}`); }
|
|
343
|
+
else { fail++; console.log(` ❌ ${f.id} — ${f.name} (got success=${body.success}, status=${res.status})`); }
|
|
344
|
+
} catch (err) {
|
|
345
|
+
fail++; console.log(` ❌ ${f.id} — request failed: ${err.message}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
console.log(`\n ${pass}/${pass + fail} fixtures passed against ${opts.endpoint}`);
|
|
349
|
+
return fail === 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function main() {
|
|
353
|
+
const opts = parseArgs(process.argv.slice(process.argv.indexOf('conformance') + 1));
|
|
354
|
+
const doc = loadFixtures();
|
|
355
|
+
console.log(`\n=== SSSS Conformance — ${doc.fixtures?.length ?? 0} fixtures (v${doc.version}) ===\n`);
|
|
356
|
+
|
|
357
|
+
const problems = validateStructure(doc);
|
|
358
|
+
if (problems.length) {
|
|
359
|
+
console.error('❌ Structural validation failed:');
|
|
360
|
+
for (const p of problems) console.error(` • ${p}`);
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
console.log(`✅ Structure valid: ${doc.fixtures.length} fixtures, envelope types: ${Object.keys(doc.operation_types).join(', ')}`);
|
|
364
|
+
|
|
365
|
+
const registryProblems = validateRegistry();
|
|
366
|
+
if (registryProblems.length) {
|
|
367
|
+
console.error('❌ Registry portability validation failed:');
|
|
368
|
+
for (const p of registryProblems) console.error(` • ${p}`);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
console.log(`✅ Registry valid: every primitive declares a portability class (${PORTABILITY_CLASSES.join(' | ')})`);
|
|
372
|
+
|
|
373
|
+
const parityProblems = auditRegistryFieldUsage();
|
|
374
|
+
if (parityProblems.length) {
|
|
375
|
+
console.error('❌ Registry/engine parity audit failed:');
|
|
376
|
+
for (const p of parityProblems) console.error(` • ${p}`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
console.log('✅ Registry/engine parity: every enforcement-relevant registry key is referenced in src/engine.mjs.');
|
|
380
|
+
|
|
381
|
+
const skillProblems = validateSkills();
|
|
382
|
+
if (skillProblems.length) {
|
|
383
|
+
console.error('❌ Skill conformance failed:');
|
|
384
|
+
for (const p of skillProblems) console.error(` • ${p}`);
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
console.log('✅ Skill conformance: every shipped skill (skills/) validates against the skill primitive and required directory structure.');
|
|
388
|
+
|
|
389
|
+
if (opts.endpoint) {
|
|
390
|
+
console.log(`\nRunning against ${opts.endpoint} ...`);
|
|
391
|
+
const ok = await runAgainstEndpoint(doc, opts);
|
|
392
|
+
process.exit(ok ? 0 : 1);
|
|
393
|
+
} else if (opts.engine) {
|
|
394
|
+
console.log('\nRunning fixtures through the reference engine (src/engine.mjs) ...');
|
|
395
|
+
const engineOk = runAgainstEngine(doc);
|
|
396
|
+
console.log('\nRunning workflow runtime conformance (src/runtime.mjs, §11.8) ...');
|
|
397
|
+
const runtimeOk = runRuntimeConformance();
|
|
398
|
+
console.log('\nRunning bundle/provisioning conformance (src/bundle.mjs, §16/§17) ...');
|
|
399
|
+
const bundleOk = runBundleConformance();
|
|
400
|
+
process.exit(engineOk && runtimeOk && bundleOk ? 0 : 1);
|
|
401
|
+
} else {
|
|
402
|
+
console.log('\nℹ No --endpoint/--engine given; ran structural + registry validation only.');
|
|
403
|
+
console.log(' Reference engine: ssss conformance --engine');
|
|
404
|
+
console.log(' Live host: ssss conformance --endpoint <url> --token <pat>');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
main();
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny dependency-free CLI helpers shared by every `ssss` subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Keeps the command modules terse: a flag parser that understands
|
|
5
|
+
* `--flag value`, `--flag=value`, repeatable flags, and boolean switches; plus
|
|
6
|
+
* a couple of formatting helpers so every command's `--help` looks the same.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse `argv` (already sliced past the subcommand) into { positionals, flags }.
|
|
11
|
+
*
|
|
12
|
+
* @param {string[]} argv
|
|
13
|
+
* @param {object} [spec]
|
|
14
|
+
* @param {Set<string>|string[]} [spec.booleans] - flags that take no value
|
|
15
|
+
* @param {Set<string>|string[]} [spec.multi] - flags that may repeat (→ array)
|
|
16
|
+
* @param {object} [spec.alias] - { short: 'long' } aliases
|
|
17
|
+
*/
|
|
18
|
+
export function parseArgs(argv, spec = {}) {
|
|
19
|
+
const booleans = new Set(spec.booleans || []);
|
|
20
|
+
const multi = new Set(spec.multi || []);
|
|
21
|
+
const alias = spec.alias || {};
|
|
22
|
+
const positionals = [];
|
|
23
|
+
const flags = {};
|
|
24
|
+
|
|
25
|
+
const norm = (name) => alias[name] || name;
|
|
26
|
+
const set = (name, value) => {
|
|
27
|
+
if (multi.has(name)) (flags[name] ||= []).push(value);
|
|
28
|
+
else flags[name] = value;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const tok = argv[i];
|
|
33
|
+
if (tok === '--') { positionals.push(...argv.slice(i + 1)); break; }
|
|
34
|
+
if (tok.startsWith('--')) {
|
|
35
|
+
let name = tok.slice(2);
|
|
36
|
+
let value;
|
|
37
|
+
const eq = name.indexOf('=');
|
|
38
|
+
if (eq !== -1) { value = name.slice(eq + 1); name = name.slice(0, eq); }
|
|
39
|
+
name = norm(name);
|
|
40
|
+
if (booleans.has(name)) { flags[name] = true; continue; }
|
|
41
|
+
if (value === undefined) value = argv[++i];
|
|
42
|
+
set(name, value);
|
|
43
|
+
} else if (tok.startsWith('-') && tok.length === 2) {
|
|
44
|
+
const name = norm(tok.slice(1));
|
|
45
|
+
if (booleans.has(name)) { flags[name] = true; continue; }
|
|
46
|
+
set(name, argv[++i]);
|
|
47
|
+
} else {
|
|
48
|
+
positionals.push(tok);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { positionals, flags };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse repeated `--param key=value` flags into a plain object. */
|
|
55
|
+
export function parseKeyValues(list) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const item of list || []) {
|
|
58
|
+
const eq = String(item).indexOf('=');
|
|
59
|
+
if (eq === -1) { out[item] = true; continue; }
|
|
60
|
+
out[item.slice(0, eq)] = item.slice(eq + 1);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Render a usage block consistently across commands. */
|
|
66
|
+
export function usage({ name, summary, usage: use, options = [], examples = [], seeAlso = [] }) {
|
|
67
|
+
const lines = [];
|
|
68
|
+
if (summary) lines.push(summary, '');
|
|
69
|
+
lines.push(`Usage: ${use}`);
|
|
70
|
+
if (options.length) {
|
|
71
|
+
lines.push('', 'Options:');
|
|
72
|
+
const w = Math.max(...options.map(([f]) => f.length));
|
|
73
|
+
for (const [flag, desc] of options) lines.push(` ${flag.padEnd(w)} ${desc}`);
|
|
74
|
+
}
|
|
75
|
+
if (examples.length) {
|
|
76
|
+
lines.push('', 'Examples:');
|
|
77
|
+
for (const ex of examples) lines.push(` ${ex}`);
|
|
78
|
+
}
|
|
79
|
+
if (seeAlso.length) lines.push('', `See also: ${seeAlso.join(', ')}`);
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Print an error to stderr and exit non-zero. */
|
|
84
|
+
export function die(message, code = 1) {
|
|
85
|
+
console.error(`ssss: ${message}`);
|
|
86
|
+
process.exit(code);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True if the user asked for help on this subcommand. */
|
|
90
|
+
export function wantsHelp(argv) {
|
|
91
|
+
return argv.includes('--help') || argv.includes('-h');
|
|
92
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================
|
|
3
|
+
# SSSS — Version Release Script
|
|
4
|
+
#
|
|
5
|
+
# Bumps the VERSION file and adds a stub in CHANGELOG.md
|
|
6
|
+
# ============================================================
|
|
7
|
+
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
if [ "$#" -ne 1 ]; then
|
|
11
|
+
echo "Usage: $0 <new-version>"
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
NEW_VERSION=$1
|
|
16
|
+
|
|
17
|
+
if [[ ! "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
|
18
|
+
echo "Invalid version: $NEW_VERSION"
|
|
19
|
+
echo "Use semantic version format, e.g. 0.7.0 or 1.0.0-beta.1"
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
echo "$NEW_VERSION" > VERSION
|
|
24
|
+
|
|
25
|
+
# Quick sed to insert new version at the top of the changelog
|
|
26
|
+
sed -i.bak "/^## \[.*\]/i \\
|
|
27
|
+
## [$NEW_VERSION] - $(date +%Y-%m-%d)\\
|
|
28
|
+
### Added\\
|
|
29
|
+
- \\
|
|
30
|
+
\\
|
|
31
|
+
" CHANGELOG.md
|
|
32
|
+
|
|
33
|
+
rm -f CHANGELOG.md.bak
|
|
34
|
+
|
|
35
|
+
echo "✅ Bumped version to $NEW_VERSION"
|
package/scripts/ssss.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @ssss/cli dispatcher — the `ssss` command.
|
|
4
|
+
*
|
|
5
|
+
* Routes the first positional argument to a subcommand. The bundle/provisioning
|
|
6
|
+
* commands (export/validate/inspect/provision/import/help) export a `run(argv)`
|
|
7
|
+
* function; `autolink.mjs` exports `main()` (also reused as a library by
|
|
8
|
+
* `build-reference-bundle.mjs` for `generateIndexes`, so it no longer
|
|
9
|
+
* self-executes on import); `conformance.mjs` is still legacy and self-executes
|
|
10
|
+
* on import, reading process.argv directly.
|
|
11
|
+
*
|
|
12
|
+
* Run `ssss help` for documentation topics, or `ssss <command> --help` for flags.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const VERSION = (() => {
|
|
20
|
+
try { return fs.readFileSync(path.resolve(HERE, '..', 'VERSION'), 'utf8').trim(); }
|
|
21
|
+
catch { return '0.0.0'; }
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
const TOP_LEVEL_HELP = `@ssss/cli v${VERSION} — Structured Semantic Syntax System
|
|
25
|
+
|
|
26
|
+
Usage: ssss <command> [options]
|
|
27
|
+
|
|
28
|
+
Scaffold:
|
|
29
|
+
new <dir> Scaffold a new SSSS project (--with-total-recall, --install).
|
|
30
|
+
|
|
31
|
+
Vault → bundle → tenant lifecycle:
|
|
32
|
+
export <vault> Package a vault into a .ucw bundle (spec §16).
|
|
33
|
+
validate <bundle> Check a bundle against the schema + portability rules.
|
|
34
|
+
inspect <bundle> Human-readable summary of a bundle.
|
|
35
|
+
provision <bundle> Plan an install: params + link integrity → envelopes (§17).
|
|
36
|
+
import <bundle> Replay a bundle/plan into a target vault (idempotent).
|
|
37
|
+
|
|
38
|
+
Authoring & conformance:
|
|
39
|
+
autolink [dir] Generate OKF wiki links across a vault.
|
|
40
|
+
conformance Run the canonical conformance suite (spec §12).
|
|
41
|
+
--engine | --endpoint <url> [--token <pat>]
|
|
42
|
+
|
|
43
|
+
Docs:
|
|
44
|
+
help [topic] Show local documentation (runtime, portability, bundle, provisioning, …).
|
|
45
|
+
version Print the version.
|
|
46
|
+
|
|
47
|
+
Run 'ssss <command> --help' for a command's flags, or 'ssss help' for topics.
|
|
48
|
+
The spec, registry, and conformance suite live in this package:
|
|
49
|
+
docs/ssss-spec.md · registry/core.json · conformance/fixtures.json`;
|
|
50
|
+
|
|
51
|
+
const sub = process.argv[2];
|
|
52
|
+
const rest = process.argv.slice(3);
|
|
53
|
+
|
|
54
|
+
// Commands that export run(argv).
|
|
55
|
+
const MODULES = {
|
|
56
|
+
new: './cmd-new.mjs',
|
|
57
|
+
scaffold: './cmd-new.mjs',
|
|
58
|
+
export: './cmd-export.mjs',
|
|
59
|
+
validate: './cmd-validate.mjs',
|
|
60
|
+
inspect: './cmd-inspect.mjs',
|
|
61
|
+
provision: './cmd-provision.mjs',
|
|
62
|
+
import: './cmd-import.mjs',
|
|
63
|
+
help: './cmd-help.mjs',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (sub in MODULES) {
|
|
67
|
+
const mod = await import(MODULES[sub]);
|
|
68
|
+
await mod.run(rest);
|
|
69
|
+
} else {
|
|
70
|
+
switch (sub) {
|
|
71
|
+
case 'autolink': {
|
|
72
|
+
const mod = await import('./autolink.mjs');
|
|
73
|
+
await mod.main();
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case 'conformance':
|
|
77
|
+
await import('./conformance.mjs');
|
|
78
|
+
break;
|
|
79
|
+
case 'version':
|
|
80
|
+
case '--version':
|
|
81
|
+
case '-v':
|
|
82
|
+
console.log(VERSION);
|
|
83
|
+
break;
|
|
84
|
+
case undefined:
|
|
85
|
+
case '--help':
|
|
86
|
+
case '-h':
|
|
87
|
+
console.log(TOP_LEVEL_HELP);
|
|
88
|
+
break;
|
|
89
|
+
default:
|
|
90
|
+
console.error(`Unknown command: ${sub}\nRun 'ssss --help' for usage, or 'ssss help' for topics.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|