@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.
- package/CHANGELOG.md +55 -4
- package/README.md +42 -84
- package/VERSION +1 -1
- package/conformance/README.md +1 -1
- package/conformance/fixtures.json +4 -48
- package/conformance/reference-bundle.ucw.json +22 -21
- package/docs/help/bundle.md +2 -2
- package/docs/help/overview.md +9 -6
- package/docs/help/semantic.md +5 -4
- package/docs/ssss-spec.md +42 -86
- package/package.json +22 -3
- package/registry/core.json +22 -29
- package/scripts/build-reference-bundle.mjs +0 -20
- package/scripts/cmd-adapter.mjs +29 -0
- package/scripts/cmd-import.mjs +1 -1
- package/scripts/cmd-migrate.mjs +26 -0
- package/scripts/cmd-new.mjs +5 -5
- package/scripts/cmd-primitive.mjs +48 -0
- package/scripts/cmd-registry.mjs +25 -0
- package/scripts/cmd-semantic.mjs +6 -8
- package/scripts/conformance-09.mjs +331 -0
- package/scripts/conformance.mjs +153 -186
- package/scripts/generate-sbom.mjs +96 -0
- package/scripts/phase9-cross-host-corpus.mjs +223 -0
- package/scripts/ssss.mjs +8 -2
- package/skills/ssss/SKILL.md +32 -208
- package/skills/ssss/evals/evals.json +3 -3
- package/skills/ssss/references/contract-reference.md +7 -8
- package/skills/ssss/scripts/validate-schema.mjs +1 -44
- package/src/authorization.mjs +62 -0
- package/src/bundle.mjs +41 -5
- package/src/engine.mjs +101 -503
- package/src/events.mjs +155 -0
- package/src/guard.mjs +32 -0
- package/src/http.mjs +26 -0
- package/src/idempotency.mjs +55 -0
- package/src/kernel.mjs +260 -0
- package/src/leases.mjs +173 -0
- package/src/primitive.mjs +227 -0
- package/src/projections.mjs +133 -0
- package/src/registry.mjs +190 -39
- package/src/semantic.mjs +124 -316
- package/src/ui.mjs +100 -0
- package/src/validator.mjs +87 -0
- package/src/vfs.mjs +203 -0
- package/types/kernel.d.ts +48 -0
- package/docs/help/localization.md +0 -43
- package/scripts/cmd-localize.mjs +0 -44
package/scripts/conformance.mjs
CHANGED
|
@@ -20,17 +20,25 @@ import { fileURLToPath } from 'node:url';
|
|
|
20
20
|
import { createEngine } from '../src/engine.mjs';
|
|
21
21
|
import {
|
|
22
22
|
DEFAULT_EXPORTER,
|
|
23
|
+
contentHash,
|
|
23
24
|
exportBundle,
|
|
24
25
|
validateBundle,
|
|
25
26
|
provisionBundle,
|
|
26
27
|
importBundle,
|
|
27
28
|
} from '../src/bundle.mjs';
|
|
28
29
|
import { parseDocument } from '../src/frontmatter.mjs';
|
|
29
|
-
import {
|
|
30
|
+
import { loadRegistries } from '../src/registry.mjs';
|
|
30
31
|
import { createRunEnvelope, planWorkflowTrigger } from '../src/runtime.mjs';
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
buildSemanticIndex,
|
|
34
|
+
enrichSemanticIndex,
|
|
35
|
+
renderSemanticRecord,
|
|
36
|
+
searchSemanticIndex,
|
|
37
|
+
searchSemanticIndexWithAdapter,
|
|
38
|
+
} from '../src/semantic.mjs';
|
|
32
39
|
import { auditRegistryFieldUsage } from './audit-registry-field-usage.mjs';
|
|
33
40
|
import { validateSkills } from './validate-skills.mjs';
|
|
41
|
+
import { runKernel09Conformance } from './conformance-09.mjs';
|
|
34
42
|
|
|
35
43
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
44
|
const FIXTURES = path.resolve(__dirname, '..', 'conformance', 'fixtures.json');
|
|
@@ -155,14 +163,14 @@ function validateRegistry() {
|
|
|
155
163
|
* (spec §12) without needing a live host. Fixtures run in array order so that
|
|
156
164
|
* create → patch → delete dependencies resolve.
|
|
157
165
|
*/
|
|
158
|
-
function runAgainstEngine(doc) {
|
|
166
|
+
async function runAgainstEngine(doc) {
|
|
159
167
|
const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-vault-'));
|
|
160
168
|
const engine = createEngine();
|
|
161
169
|
let pass = 0, fail = 0;
|
|
162
170
|
try {
|
|
163
171
|
for (const f of doc.fixtures) {
|
|
164
172
|
const want = f.expected_response || {};
|
|
165
|
-
const res = engine.processOperation(f.request, vault);
|
|
173
|
+
const res = await engine.processOperation(f.request, vault);
|
|
166
174
|
const okSuccess = want.success == null || res.success === want.success;
|
|
167
175
|
const wantType = want.validation && want.validation.type;
|
|
168
176
|
const okType = wantType == null || (res.validation && res.validation.type === wantType);
|
|
@@ -192,7 +200,7 @@ function runAgainstEngine(doc) {
|
|
|
192
200
|
* - no `tenant_private` primitive ever lands (the §5.5 keystone).
|
|
193
201
|
* Returns true on full pass.
|
|
194
202
|
*/
|
|
195
|
-
function runBundleConformance() {
|
|
203
|
+
async function runBundleConformance() {
|
|
196
204
|
if (!fs.existsSync(REFERENCE_BUNDLE)) {
|
|
197
205
|
console.log(' ⚠ No reference bundle; run `node scripts/build-reference-bundle.mjs` first.');
|
|
198
206
|
return false;
|
|
@@ -260,15 +268,23 @@ function runBundleConformance() {
|
|
|
260
268
|
!unknownExtensionValidation.valid && unknownExtensionValidation.errors.some((e) => e.includes("required extension 'missing-extension'")),
|
|
261
269
|
unknownExtensionValidation.errors.join('; '));
|
|
262
270
|
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
271
|
+
const invalidPatternBundle = JSON.parse(JSON.stringify(bundle));
|
|
272
|
+
const invalidPattern = invalidPatternBundle.files.find((file) => parseDocument(file.content).data.type === 'rule');
|
|
273
|
+
invalidPattern.path = 'primitives/invalid.md';
|
|
274
|
+
invalidPattern.content = [
|
|
275
|
+
'---', 'type: primitive', 'title: Invalid Primitive', 'description: Invalid namespace pattern.',
|
|
276
|
+
'timestamp: 2026-07-10T00:00:00Z', 'primitive_id: invalid!:thing', 'namespace: invalid!',
|
|
277
|
+
'version: 1', 'name: Invalid', 'mutation: replace', 'portability: structural',
|
|
278
|
+
'scopes: [workspace]', 'fields: [placeholder]', '---', '', 'Invalid.', '',
|
|
279
|
+
].join('\n');
|
|
280
|
+
invalidPatternBundle.manifest.primitive_inventory.rule--;
|
|
281
|
+
invalidPatternBundle.manifest.primitive_inventory.primitive = 1;
|
|
282
|
+
invalidPatternBundle.manifest.provenance.content_hash = contentHash(invalidPatternBundle.files);
|
|
283
|
+
const invalidPatternValidation = validateBundle(invalidPatternBundle, { registryDir: REGISTRY_DIR });
|
|
268
284
|
check('bundle validation enforces registry patterns before import',
|
|
269
|
-
!
|
|
270
|
-
|
|
271
|
-
|
|
285
|
+
!invalidPatternValidation.valid &&
|
|
286
|
+
invalidPatternValidation.errors.some((error) => error.includes('registry pattern')),
|
|
287
|
+
invalidPatternValidation.errors.join('; '));
|
|
272
288
|
|
|
273
289
|
const prov = provisionBundle(bundle, { workspaceId: 'ws-conf', parameters: { business_name: 'Demo', domain: 'demo.example' } });
|
|
274
290
|
check('provision resolves params + link integrity (no dangling [[links]])', prov.ok,
|
|
@@ -277,11 +293,11 @@ function runBundleConformance() {
|
|
|
277
293
|
const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-'));
|
|
278
294
|
try {
|
|
279
295
|
const engine = createEngine({ registryDir: REGISTRY_DIR });
|
|
280
|
-
const first = importBundle(prov.plan, vault, engine);
|
|
296
|
+
const first = await importBundle(prov.plan, vault, engine);
|
|
281
297
|
check('import commits every file', first.ok && first.committed === bundle.files.length,
|
|
282
298
|
`ok=${first.ok} committed=${first.committed}/${bundle.files.length}`);
|
|
283
299
|
|
|
284
|
-
const second = importBundle(prov.plan, vault, engine);
|
|
300
|
+
const second = await importBundle(prov.plan, vault, engine);
|
|
285
301
|
check('re-import is idempotent (0 new commits)', second.ok && second.committed === 0,
|
|
286
302
|
`committed=${second.committed}`);
|
|
287
303
|
|
|
@@ -305,17 +321,17 @@ function runBundleConformance() {
|
|
|
305
321
|
},
|
|
306
322
|
{
|
|
307
323
|
type: 'operation', idempotency_key: 'atomic-invalid', workspace_id: 'ws-atomic',
|
|
308
|
-
path: '
|
|
324
|
+
path: 'primitives/invalid.md', actor: { role: 'system' },
|
|
309
325
|
content: [
|
|
310
|
-
'---', 'type:
|
|
326
|
+
'---', 'type: primitive', 'title: Invalid Primitive',
|
|
311
327
|
'description: Must fail before the source commits.', 'timestamp: 2026-07-10T00:00:00Z',
|
|
312
|
-
'
|
|
313
|
-
|
|
314
|
-
'
|
|
328
|
+
'primitive_id: invalid!:thing', 'namespace: invalid!', 'version: 1', 'name: Invalid',
|
|
329
|
+
'mutation: replace', 'portability: structural', 'scopes: [workspace]',
|
|
330
|
+
'fields: [placeholder]', '---', '', 'Invalid.', '',
|
|
315
331
|
].join('\n'),
|
|
316
332
|
},
|
|
317
333
|
];
|
|
318
|
-
const atomicResult = importBundle(badPlan, atomicVault, engine);
|
|
334
|
+
const atomicResult = await importBundle(badPlan, atomicVault, engine);
|
|
319
335
|
check('two-phase import prevents partial commits on a late invalid envelope',
|
|
320
336
|
atomicResult.ok === false && atomicResult.committed === 0 &&
|
|
321
337
|
!fs.existsSync(path.join(atomicVault, 'rules', 'atomic.md')),
|
|
@@ -333,7 +349,7 @@ function runBundleConformance() {
|
|
|
333
349
|
return pass === checks.length;
|
|
334
350
|
}
|
|
335
351
|
|
|
336
|
-
function runOperationContractRegressionConformance() {
|
|
352
|
+
async function runOperationContractRegressionConformance() {
|
|
337
353
|
const checks = [];
|
|
338
354
|
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
339
355
|
|
|
@@ -342,7 +358,7 @@ function runOperationContractRegressionConformance() {
|
|
|
342
358
|
try {
|
|
343
359
|
const engine = createEngine({ registryDir: REGISTRY_DIR, leaseStore });
|
|
344
360
|
const workflowPath = 'workflows/nested.md';
|
|
345
|
-
const createRes = engine.processOperation({
|
|
361
|
+
const createRes = await engine.processOperation({
|
|
346
362
|
type: 'operation',
|
|
347
363
|
idempotency_key: 'nested-create',
|
|
348
364
|
workspace_id: 'ws-reg',
|
|
@@ -368,7 +384,7 @@ function runOperationContractRegressionConformance() {
|
|
|
368
384
|
}, vault);
|
|
369
385
|
check('nested frontmatter fixture commits before patch', createRes.success === true, JSON.stringify(createRes.validation?.errors || []));
|
|
370
386
|
|
|
371
|
-
const patchRes = engine.processOperation({
|
|
387
|
+
const patchRes = await engine.processOperation({
|
|
372
388
|
type: 'patch',
|
|
373
389
|
idempotency_key: 'nested-patch',
|
|
374
390
|
workspace_id: 'ws-reg',
|
|
@@ -398,13 +414,13 @@ function runOperationContractRegressionConformance() {
|
|
|
398
414
|
actor: { role: 'system' },
|
|
399
415
|
content: '---\ntype: rule\ntitle: Leased\ndescription: Lease test.\ntimestamp: 2026-07-02T00:00:00Z\nname: Leased\n---\nBody.\n',
|
|
400
416
|
};
|
|
401
|
-
const missingLease = engine.processOperation(leasedEnvelope, vault);
|
|
417
|
+
const missingLease = await engine.processOperation(leasedEnvelope, vault);
|
|
402
418
|
check('lease conflict rejects missing lease_id', missingLease.success === false && missingLease.validation.errors.some((e) => e.includes('leased')));
|
|
403
419
|
|
|
404
|
-
const mismatchLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-mismatch', lease_id: 'wrong' }, vault);
|
|
420
|
+
const mismatchLease = await engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-mismatch', lease_id: 'wrong' }, vault);
|
|
405
421
|
check('lease conflict rejects mismatched lease_id', mismatchLease.success === false && mismatchLease.validation.errors.some((e) => e.includes('Lease mismatch')));
|
|
406
422
|
|
|
407
|
-
const matchedLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-match', lease_id: 'lease-ok' }, vault);
|
|
423
|
+
const matchedLease = await engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-match', lease_id: 'lease-ok' }, vault);
|
|
408
424
|
check('lease allows matching lease_id', matchedLease.success === true, JSON.stringify(matchedLease.validation?.errors || []));
|
|
409
425
|
|
|
410
426
|
const expiredTarget = 'rules/expired.md';
|
|
@@ -413,13 +429,13 @@ function runOperationContractRegressionConformance() {
|
|
|
413
429
|
lease_id: 'expired',
|
|
414
430
|
expires_at: '2000-01-01T00:00:00.000Z',
|
|
415
431
|
}));
|
|
416
|
-
const expiredLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-expired', path: expiredTarget }, vault);
|
|
432
|
+
const expiredLease = await engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-expired', path: expiredTarget }, vault);
|
|
417
433
|
check('expired lease does not block writes', expiredLease.success === true, JSON.stringify(expiredLease.validation?.errors || []));
|
|
418
434
|
|
|
419
435
|
const unreadableTarget = 'rules/unreadable.md';
|
|
420
436
|
const unreadablePathKey = crypto.createHash('sha256').update(unreadableTarget).digest('hex');
|
|
421
437
|
fs.writeFileSync(path.join(leaseDir, `${unreadablePathKey}.lease.json`), '{not json');
|
|
422
|
-
const unreadableLease = engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-unreadable', path: unreadableTarget }, vault);
|
|
438
|
+
const unreadableLease = await engine.processOperation({ ...leasedEnvelope, idempotency_key: 'lease-unreadable', path: unreadableTarget }, vault);
|
|
423
439
|
check('unreadable lease state fails closed', unreadableLease.success === false && unreadableLease.validation.errors.some((e) => e.includes('unreadable')));
|
|
424
440
|
} finally {
|
|
425
441
|
fs.rmSync(vault, { recursive: true, force: true });
|
|
@@ -435,160 +451,77 @@ function runOperationContractRegressionConformance() {
|
|
|
435
451
|
return pass === checks.length;
|
|
436
452
|
}
|
|
437
453
|
|
|
438
|
-
function runSemanticLocalizationConformance() {
|
|
454
|
+
async function runSemanticLocalizationConformance() {
|
|
439
455
|
const checks = [];
|
|
440
456
|
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
441
457
|
const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-semantic-'));
|
|
442
|
-
const projection = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-locale-'));
|
|
443
458
|
try {
|
|
444
459
|
const engine = createEngine({ registryDir: REGISTRY_DIR });
|
|
445
460
|
const sourcePath = 'rules/refund-policy.md';
|
|
446
461
|
const sourceContent = [
|
|
447
|
-
'---',
|
|
448
|
-
'
|
|
449
|
-
'
|
|
450
|
-
'
|
|
451
|
-
'
|
|
452
|
-
'
|
|
453
|
-
'aliases: [refund terms, cancellation policy]',
|
|
454
|
-
'tags: [tickets, finance]',
|
|
455
|
-
'relations:',
|
|
456
|
-
' - relation: applies_to',
|
|
457
|
-
' target: workflows/box-office.md',
|
|
458
|
-
'---',
|
|
459
|
-
'',
|
|
460
|
-
'Refunds are available until fourteen days before the event.',
|
|
461
|
-
'',
|
|
462
|
+
'---', 'type: rule', 'title: Política de reembolsos',
|
|
463
|
+
'description: Explica cuándo están disponibles los reembolsos de boletos.',
|
|
464
|
+
'timestamp: 2026-07-10T00:00:00Z', 'name: Refund Policy', 'language: es',
|
|
465
|
+
'aliases: [términos de reembolso, política de cancelación]', 'tags: [boletos, finanzas]',
|
|
466
|
+
'relations:', ' - relation: applies_to', ' target: workflows/box-office.md',
|
|
467
|
+
'---', '', 'Los reembolsos están disponibles hasta catorce días antes del evento.', '',
|
|
462
468
|
].join('\n');
|
|
463
|
-
const sourceWrite = engine.processOperation({
|
|
469
|
+
const sourceWrite = await engine.processOperation({
|
|
464
470
|
type: 'operation', idempotency_key: 'sem-source', workspace_id: 'ws-sem',
|
|
465
471
|
path: sourcePath, content: sourceContent, actor: { role: 'system' },
|
|
466
472
|
}, vault);
|
|
467
|
-
check('structural
|
|
468
|
-
JSON.stringify(sourceWrite.validation?.errors || []));
|
|
473
|
+
check('non-English structural source commits', sourceWrite.success === true, JSON.stringify(sourceWrite.validation?.errors || []));
|
|
469
474
|
|
|
470
475
|
const privateContent = [
|
|
471
|
-
'---',
|
|
472
|
-
'
|
|
473
|
-
'
|
|
474
|
-
'description: Private customer refund task.',
|
|
475
|
-
'timestamp: 2026-07-10T00:00:00Z',
|
|
476
|
-
'priority: high',
|
|
477
|
-
'category: support',
|
|
478
|
-
'status: pending',
|
|
479
|
-
'---',
|
|
480
|
-
'',
|
|
481
|
-
'Customer account and payment details.',
|
|
482
|
-
'',
|
|
476
|
+
'---', 'type: task', 'title: Solicitud privada', 'description: Datos privados del cliente.',
|
|
477
|
+
'timestamp: 2026-07-10T00:00:00Z', 'priority: high', 'category: support', 'status: pending',
|
|
478
|
+
'---', '', 'Cuenta y detalles de pago.', '',
|
|
483
479
|
].join('\n');
|
|
484
|
-
const privateWrite = engine.processOperation({
|
|
480
|
+
const privateWrite = await engine.processOperation({
|
|
485
481
|
type: 'operation', idempotency_key: 'sem-private', workspace_id: 'ws-sem',
|
|
486
482
|
path: 'tasks/private-refund.md', content: privateContent, actor: { role: 'system' },
|
|
487
483
|
}, vault);
|
|
488
|
-
check('tenant-private semantic source commits', privateWrite.success === true,
|
|
489
|
-
JSON.stringify(privateWrite.validation?.errors || []));
|
|
490
|
-
|
|
491
|
-
const overlayPath = 'translations/es-MX/rules/refund-policy.md';
|
|
492
|
-
const overlayContent = [
|
|
493
|
-
'---',
|
|
494
|
-
'type: translation',
|
|
495
|
-
'title: Spanish Refund Policy Translation',
|
|
496
|
-
'description: Approved Spanish presentation overlay for the refund policy.',
|
|
497
|
-
'timestamp: 2026-07-10T00:00:00Z',
|
|
498
|
-
'translation_id: refund-policy-es-mx',
|
|
499
|
-
`source_path: ${sourcePath}`,
|
|
500
|
-
`source_hash: ${documentHash(sourceContent)}`,
|
|
501
|
-
'locale: es-MX',
|
|
502
|
-
'status: approved',
|
|
503
|
-
'translated_fields: [title, description, body]',
|
|
504
|
-
'translated_title: Política de reembolsos',
|
|
505
|
-
'translated_description: Explica cuándo están disponibles los reembolsos de boletos.',
|
|
506
|
-
'---',
|
|
507
|
-
'',
|
|
508
|
-
'Los reembolsos están disponibles hasta catorce días antes del evento.',
|
|
509
|
-
'',
|
|
510
|
-
].join('\n');
|
|
511
|
-
const overlayWrite = engine.processOperation({
|
|
512
|
-
type: 'operation', idempotency_key: 'sem-overlay', workspace_id: 'ws-sem',
|
|
513
|
-
path: overlayPath, content: overlayContent, actor: { role: 'system' },
|
|
514
|
-
}, vault);
|
|
515
|
-
check('hash-bound translation overlay commits', overlayWrite.success === true,
|
|
516
|
-
JSON.stringify(overlayWrite.validation?.errors || []));
|
|
517
|
-
|
|
518
|
-
const staleOverlay = overlayContent.replace(documentHash(sourceContent), `sha256:${'0'.repeat(64)}`)
|
|
519
|
-
.replace('translation_id: refund-policy-es-mx', 'translation_id: stale-es-mx');
|
|
520
|
-
const staleWrite = engine.processOperation({
|
|
521
|
-
type: 'operation', idempotency_key: 'sem-stale', workspace_id: 'ws-sem',
|
|
522
|
-
path: 'translations/es-MX/rules/stale.md', content: staleOverlay, actor: { role: 'system' },
|
|
523
|
-
}, vault);
|
|
524
|
-
check('stale translation source hash is rejected', staleWrite.success === false &&
|
|
525
|
-
staleWrite.validation.errors.some((error) => error.includes('Source hash mismatch')),
|
|
526
|
-
JSON.stringify(staleWrite.validation?.errors || []));
|
|
527
|
-
|
|
528
|
-
const traversalOverlay = overlayContent
|
|
529
|
-
.replace('translation_id: refund-policy-es-mx', 'translation_id: traversal-es-mx')
|
|
530
|
-
.replace(`source_path: ${sourcePath}`, 'source_path: ../secrets.md');
|
|
531
|
-
const traversalWrite = engine.processOperation({
|
|
532
|
-
type: 'operation', idempotency_key: 'sem-traversal', workspace_id: 'ws-sem',
|
|
533
|
-
path: 'translations/es-MX/rules/traversal.md', content: traversalOverlay, actor: { role: 'system' },
|
|
534
|
-
}, vault);
|
|
535
|
-
check('translation reference traversal is rejected', traversalWrite.success === false &&
|
|
536
|
-
traversalWrite.validation.errors.some((error) => error.includes('safe vault-relative path')),
|
|
537
|
-
JSON.stringify(traversalWrite.validation?.errors || []));
|
|
484
|
+
check('tenant-private semantic source commits', privateWrite.success === true, JSON.stringify(privateWrite.validation?.errors || []));
|
|
538
485
|
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}, vault);
|
|
543
|
-
check('translation identity fields are immutable', immutablePatch.success === false &&
|
|
544
|
-
immutablePatch.validation.errors.some((error) => error.includes("immutable field 'locale'")),
|
|
545
|
-
JSON.stringify(immutablePatch.validation?.errors || []));
|
|
546
|
-
|
|
547
|
-
const first = buildSemanticIndex(vault, { locale: 'es-MX', registryDir: REGISTRY_DIR });
|
|
548
|
-
const second = buildSemanticIndex(vault, { locale: 'es-mx', registryDir: REGISTRY_DIR });
|
|
549
|
-
check('semantic index is deterministic and locale-normalized',
|
|
550
|
-
first.index_hash === second.index_hash && first.locale === 'es-MX');
|
|
486
|
+
const first = buildSemanticIndex(vault, { registryDir: REGISTRY_DIR });
|
|
487
|
+
const second = buildSemanticIndex(vault, { registryDir: REGISTRY_DIR });
|
|
488
|
+
check('semantic index is deterministic', first.index_hash === second.index_hash);
|
|
551
489
|
check('safe semantic default excludes tenant-private documents',
|
|
552
490
|
first.documents.every((document) => document.portability === 'structural') &&
|
|
553
491
|
!first.documents.some((document) => document.path.startsWith('tasks/')));
|
|
554
|
-
check('semantic graph captures explicit relations',
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
492
|
+
check('semantic graph captures explicit relations', first.edges.some((edge) => edge.source === sourcePath && edge.relation === 'applies_to'));
|
|
493
|
+
const lexical = searchSemanticIndex(first, 'reembolsos boletos', { limit: 3 });
|
|
494
|
+
check('lexical evidence ranks same-language content', lexical[0]?.document.path === sourcePath && lexical[0].evidence.lexical > 0);
|
|
495
|
+
|
|
496
|
+
const embed = async (texts) => texts.map((text) => /refund|reembols/i.test(text) ? [1, 0] : [0, 1]);
|
|
497
|
+
const enriched = await enrichSemanticIndex(first, { embed, model: 'conformance-multilingual-v1' });
|
|
498
|
+
check('embedding provenance records model and dimension', enriched.embedding.model === 'conformance-multilingual-v1' && enriched.embedding.dimension === 2);
|
|
499
|
+
const crossLanguage = await searchSemanticIndexWithAdapter(enriched, 'refund policy', { embed, limit: 3 });
|
|
500
|
+
check('cross-language semantic evidence ranks the authored source',
|
|
501
|
+
crossLanguage[0]?.document.path === sourcePath && crossLanguage[0].evidence.semantic > 0,
|
|
502
|
+
JSON.stringify(crossLanguage.map((match) => ({ path: match.document.path, evidence: match.evidence }))));
|
|
503
|
+
|
|
504
|
+
const rendered = await renderSemanticRecord(first.documents[0], {
|
|
505
|
+
language: 'ja', render: async () => ({ title: '返金ポリシー', description: 'チケット返金の条件。', body: 'イベントの14日前まで返金できます。' }),
|
|
506
|
+
});
|
|
507
|
+
check('runtime rendering changes presentation without changing controls',
|
|
508
|
+
rendered.presentation.language === 'ja' && rendered.presentation.title === '返金ポリシー' &&
|
|
509
|
+
rendered.invariant.type === first.documents[0].type && rendered.invariant.path === sourcePath);
|
|
510
|
+
let forbiddenRejected = false;
|
|
511
|
+
try { await renderSemanticRecord(first.documents[0], { language: 'fr', render: async () => ({ type: 'assistant', title: 'Politique' }) }); }
|
|
512
|
+
catch (error) { forbiddenRejected = error.message.includes('forbidden field'); }
|
|
513
|
+
check('renderer cannot change symbolic controls', forbiddenRejected);
|
|
560
514
|
|
|
561
515
|
const privateIndex = buildSemanticIndex(vault, { includePrivate: true, registryDir: REGISTRY_DIR });
|
|
562
|
-
check('private semantic indexing requires explicit opt-in',
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const manifest = materializeLocale(vault, 'es-mx', projection, { registryDir: REGISTRY_DIR });
|
|
566
|
-
const localizedContent = fs.readFileSync(path.join(projection, sourcePath), 'utf8');
|
|
567
|
-
const localizedDocument = parseDocument(localizedContent);
|
|
568
|
-
check('localization projection preserves symbolic control fields',
|
|
569
|
-
localizedDocument.data.type === 'rule' && localizedDocument.data.name === 'Refund Policy' &&
|
|
570
|
-
localizedDocument.data.title === 'Política de reembolsos' &&
|
|
571
|
-
localizedDocument.data.locale === 'es-MX');
|
|
572
|
-
check('localization projection excludes private documents by default',
|
|
573
|
-
manifest.document_count >= 1 && !fs.existsSync(path.join(projection, 'tasks', 'private-refund.md')));
|
|
574
|
-
check('localization publishes a complete projection without staging residue',
|
|
575
|
-
fs.existsSync(path.join(projection, '.ssss-projection.json')) &&
|
|
576
|
-
!fs.readdirSync(path.dirname(projection)).some((name) =>
|
|
577
|
-
name.startsWith(`.${path.basename(projection)}.`) && name.endsWith('.stage')));
|
|
578
|
-
let insideRejected = false;
|
|
579
|
-
try { materializeLocale(vault, 'es-MX', path.join(vault, 'derived', 'es'), { registryDir: REGISTRY_DIR }); }
|
|
580
|
-
catch (error) { insideRejected = error.message.includes('outside the source vault'); }
|
|
581
|
-
check('localization output inside the source vault is rejected', insideRejected);
|
|
582
|
-
|
|
583
|
-
const invalidRawPath = path.join(vault, 'rules', 'invalid-raw.md');
|
|
584
|
-
fs.writeFileSync(invalidRawPath, '---\ntype: rule\nname: Invalid\n---\nBody.\n');
|
|
516
|
+
check('private semantic indexing requires explicit opt-in', privateIndex.documents.some((document) => document.path === 'tasks/private-refund.md'));
|
|
517
|
+
|
|
518
|
+
fs.writeFileSync(path.join(vault, 'rules', 'invalid-raw.md'), '---\ntype: rule\nname: Invalid\n---\nBody.\n');
|
|
585
519
|
let invalidRawRejected = false;
|
|
586
520
|
try { buildSemanticIndex(vault, { registryDir: REGISTRY_DIR }); }
|
|
587
521
|
catch (error) { invalidRawRejected = error.message.includes("Missing required field 'title'"); }
|
|
588
522
|
check('semantic projection rejects raw documents that violate the registry', invalidRawRejected);
|
|
589
523
|
} finally {
|
|
590
524
|
fs.rmSync(vault, { recursive: true, force: true });
|
|
591
|
-
fs.rmSync(projection, { recursive: true, force: true });
|
|
592
525
|
}
|
|
593
526
|
|
|
594
527
|
let pass = 0;
|
|
@@ -596,7 +529,7 @@ function runSemanticLocalizationConformance() {
|
|
|
596
529
|
if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
|
|
597
530
|
else console.log(` ❌ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
598
531
|
}
|
|
599
|
-
console.log(`\n ${pass}/${checks.length} semantic/
|
|
532
|
+
console.log(`\n ${pass}/${checks.length} semantic/multilingual rendering checks passed (§11.9)`);
|
|
600
533
|
return pass === checks.length;
|
|
601
534
|
}
|
|
602
535
|
|
|
@@ -688,7 +621,7 @@ function runRegistryExtensionConformance() {
|
|
|
688
621
|
return pass === checks.length;
|
|
689
622
|
}
|
|
690
623
|
|
|
691
|
-
function runCliSmokeConformance() {
|
|
624
|
+
async function runCliSmokeConformance() {
|
|
692
625
|
const checks = [];
|
|
693
626
|
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
694
627
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-cli-smoke-'));
|
|
@@ -708,7 +641,7 @@ function runCliSmokeConformance() {
|
|
|
708
641
|
const failures = [];
|
|
709
642
|
for (const abs of files) {
|
|
710
643
|
const rel = path.relative(vault, abs);
|
|
711
|
-
const res = engine.processOperation({
|
|
644
|
+
const res = await engine.processOperation({
|
|
712
645
|
type: 'operation',
|
|
713
646
|
idempotency_key: `scaffold-${rel.replace(/[^A-Za-z0-9._-]/g, '_')}`,
|
|
714
647
|
workspace_id: 'ws-scaffold',
|
|
@@ -732,21 +665,6 @@ function runCliSmokeConformance() {
|
|
|
732
665
|
semanticOutput.results?.some((result) => result.document.path === 'rules/welcome.md'),
|
|
733
666
|
JSON.stringify(semanticOutput.results || []));
|
|
734
667
|
|
|
735
|
-
const localizedVault = path.join(tmp, 'localized-es');
|
|
736
|
-
const localizationOutput = JSON.parse(execFileSync('node', [
|
|
737
|
-
path.join(__dirname, 'ssss.mjs'),
|
|
738
|
-
'localize',
|
|
739
|
-
vault,
|
|
740
|
-
'--locale',
|
|
741
|
-
'es',
|
|
742
|
-
'--out',
|
|
743
|
-
localizedVault,
|
|
744
|
-
], { encoding: 'utf8' }));
|
|
745
|
-
check('ssss localize materializes a safe structural projection',
|
|
746
|
-
localizationOutput.locale === 'es' &&
|
|
747
|
-
fs.existsSync(path.join(localizedVault, 'rules', 'welcome.md')) &&
|
|
748
|
-
!fs.existsSync(path.join(localizedVault, 'tasks', 'first-task.md')));
|
|
749
|
-
|
|
750
668
|
const dryRunVault = path.join(tmp, 'dry-run-vault');
|
|
751
669
|
const dryRunOut = execFileSync('node', [
|
|
752
670
|
path.join(__dirname, 'ssss.mjs'),
|
|
@@ -773,6 +691,53 @@ function runCliSmokeConformance() {
|
|
|
773
691
|
check('ssss import --dry-run reports would-commit counts and leaves target empty',
|
|
774
692
|
dryRunOut.includes('would commit') && dryRunFiles.length === 0,
|
|
775
693
|
dryRunOut.trim());
|
|
694
|
+
|
|
695
|
+
const primitiveOut = JSON.parse(execFileSync('node', [
|
|
696
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
697
|
+
'primitive', 'create',
|
|
698
|
+
'--namespace', 'acme',
|
|
699
|
+
'--name', '顧客予約',
|
|
700
|
+
'--language', 'ja',
|
|
701
|
+
'--fields', JSON.stringify([{ id: 'status', name: '状態', kind: 'enum', values: ['pending', 'confirmed'], required: true }]),
|
|
702
|
+
], { encoding: 'utf8' }));
|
|
703
|
+
check('ssss primitive create emits a stable qualified identity',
|
|
704
|
+
/^acme:p_[a-f0-9]{20}$/.test(primitiveOut.primitive_id),
|
|
705
|
+
JSON.stringify(primitiveOut));
|
|
706
|
+
|
|
707
|
+
const defPath = path.join(tmp, 'definition.json');
|
|
708
|
+
fs.writeFileSync(defPath, `${JSON.stringify(primitiveOut)}\n`);
|
|
709
|
+
const inspectOut = JSON.parse(execFileSync('node', [
|
|
710
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
711
|
+
'primitive', 'inspect',
|
|
712
|
+
defPath,
|
|
713
|
+
], { encoding: 'utf8' }));
|
|
714
|
+
const extensionPath = path.join(tmp, 'extension.json');
|
|
715
|
+
fs.writeFileSync(extensionPath, `${JSON.stringify(inspectOut.registry)}\n`);
|
|
716
|
+
const lockOut = JSON.parse(execFileSync('node', [
|
|
717
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
718
|
+
'registry', 'lock',
|
|
719
|
+
'--extension', extensionPath,
|
|
720
|
+
], { encoding: 'utf8' }));
|
|
721
|
+
check('ssss registry lock emits integrity metadata',
|
|
722
|
+
typeof lockOut.integrity === 'string' && lockOut.integrity.startsWith('sha256:'),
|
|
723
|
+
JSON.stringify(lockOut));
|
|
724
|
+
|
|
725
|
+
const adapterOut = JSON.parse(execFileSync('node', [
|
|
726
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
727
|
+
'adapter', 'conformance',
|
|
728
|
+
], { encoding: 'utf8' }));
|
|
729
|
+
check('ssss adapter conformance report is machine-readable and green',
|
|
730
|
+
adapterOut.passed === true && adapterOut.suites?.vfs_memory?.passed === true,
|
|
731
|
+
JSON.stringify(adapterOut));
|
|
732
|
+
|
|
733
|
+
const migrateOut = JSON.parse(execFileSync('node', [
|
|
734
|
+
path.join(__dirname, 'ssss.mjs'),
|
|
735
|
+
'migrate', '0.8-to-0.9',
|
|
736
|
+
vault,
|
|
737
|
+
], { encoding: 'utf8' }));
|
|
738
|
+
check('ssss migrate 0.8-to-0.9 dry-run emits backup manifest without writes',
|
|
739
|
+
migrateOut.dry_run === true && migrateOut.writes_performed === 0 && Array.isArray(migrateOut.backup_manifest),
|
|
740
|
+
JSON.stringify(migrateOut));
|
|
776
741
|
} finally {
|
|
777
742
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
778
743
|
}
|
|
@@ -791,7 +756,7 @@ function runCliSmokeConformance() {
|
|
|
791
756
|
* workflow frontmatter as the canonical schedule source, then derive trigger,
|
|
792
757
|
* task, and run envelopes without introducing a scheduler database of record.
|
|
793
758
|
*/
|
|
794
|
-
function runRuntimeConformance() {
|
|
759
|
+
async function runRuntimeConformance() {
|
|
795
760
|
const checks = [];
|
|
796
761
|
const check = (name, cond, detail = '') => { checks.push({ name, cond, detail }); };
|
|
797
762
|
|
|
@@ -854,15 +819,15 @@ function runRuntimeConformance() {
|
|
|
854
819
|
const vault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-runtime-'));
|
|
855
820
|
try {
|
|
856
821
|
const engine = createEngine({ registryDir: REGISTRY_DIR });
|
|
857
|
-
const eventRes = engine.processOperation(firstPlan.envelopes[0], vault);
|
|
858
|
-
const taskRes = engine.processOperation(firstPlan.envelopes[1], vault);
|
|
822
|
+
const eventRes = await engine.processOperation(firstPlan.envelopes[0], vault);
|
|
823
|
+
const taskRes = await engine.processOperation(firstPlan.envelopes[1], vault);
|
|
859
824
|
check('trigger event commits through Operation Contract', eventRes.success === true,
|
|
860
825
|
JSON.stringify(eventRes.validation?.errors || []));
|
|
861
826
|
check('instantiated task commits through Operation Contract', taskRes.success === true,
|
|
862
827
|
JSON.stringify(taskRes.validation?.errors || []));
|
|
863
828
|
|
|
864
|
-
const replayEvent = engine.processOperation(secondPlan.envelopes[0], vault);
|
|
865
|
-
const replayTask = engine.processOperation(secondPlan.envelopes[1], vault);
|
|
829
|
+
const replayEvent = await engine.processOperation(secondPlan.envelopes[0], vault);
|
|
830
|
+
const replayTask = await engine.processOperation(secondPlan.envelopes[1], vault);
|
|
866
831
|
check('duplicate daemon tick replays trigger event idempotently', replayEvent.replay != null);
|
|
867
832
|
check('duplicate daemon tick replays task write idempotently', replayTask.replay != null);
|
|
868
833
|
|
|
@@ -873,7 +838,7 @@ function runRuntimeConformance() {
|
|
|
873
838
|
status: 'queued',
|
|
874
839
|
startedAt: scheduledFor,
|
|
875
840
|
});
|
|
876
|
-
const runRes = engine.processOperation(runEnvelope, vault);
|
|
841
|
+
const runRes = await engine.processOperation(runEnvelope, vault);
|
|
877
842
|
check('worker-created run commits through Operation Contract', runRes.success === true,
|
|
878
843
|
JSON.stringify(runRes.validation?.errors || []));
|
|
879
844
|
} finally {
|
|
@@ -953,22 +918,24 @@ async function main() {
|
|
|
953
918
|
process.exit(ok ? 0 : 1);
|
|
954
919
|
} else if (opts.engine) {
|
|
955
920
|
console.log('\nRunning fixtures through the reference engine (src/engine.mjs) ...');
|
|
956
|
-
const engineOk = runAgainstEngine(doc);
|
|
921
|
+
const engineOk = await runAgainstEngine(doc);
|
|
957
922
|
console.log('\nRunning workflow runtime conformance (src/runtime.mjs, §11.8) ...');
|
|
958
|
-
const runtimeOk = runRuntimeConformance();
|
|
923
|
+
const runtimeOk = await runRuntimeConformance();
|
|
959
924
|
console.log('\nRunning operation regression conformance (src/engine.mjs, §6/§7) ...');
|
|
960
|
-
const operationRegressionOk = runOperationContractRegressionConformance();
|
|
925
|
+
const operationRegressionOk = await runOperationContractRegressionConformance();
|
|
961
926
|
console.log('\nRunning extension-registry conformance (src/registry.mjs) ...');
|
|
962
927
|
const extensionRegistryOk = runRegistryExtensionConformance();
|
|
963
|
-
console.log('\nRunning semantic/
|
|
964
|
-
const semanticOk = runSemanticLocalizationConformance();
|
|
928
|
+
console.log('\nRunning semantic/multilingual rendering conformance (src/semantic.mjs, §11.9) ...');
|
|
929
|
+
const semanticOk = await runSemanticLocalizationConformance();
|
|
930
|
+
console.log('\nRunning SSSS 0.9 kernel/adapter/UI conformance ...');
|
|
931
|
+
const kernel09Ok = await runKernel09Conformance();
|
|
965
932
|
console.log('\nRunning bundle/provisioning conformance (src/bundle.mjs, §16/§17) ...');
|
|
966
|
-
const bundleOk = runBundleConformance();
|
|
933
|
+
const bundleOk = await runBundleConformance();
|
|
967
934
|
console.log('\nRunning CLI smoke conformance (scripts/ssss.mjs) ...');
|
|
968
|
-
const cliSmokeOk = runCliSmokeConformance();
|
|
935
|
+
const cliSmokeOk = await runCliSmokeConformance();
|
|
969
936
|
process.exit(
|
|
970
937
|
engineOk && runtimeOk && operationRegressionOk && extensionRegistryOk &&
|
|
971
|
-
semanticOk && bundleOk && cliSmokeOk ? 0 : 1
|
|
938
|
+
semanticOk && kernel09Ok && bundleOk && cliSmokeOk ? 0 : 1
|
|
972
939
|
);
|
|
973
940
|
} else {
|
|
974
941
|
console.log('\nℹ No --endpoint/--engine given; ran structural + registry validation only.');
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate a minimal SPDX-lite SBOM for @gregiteen/ssss-cli publish artifacts.
|
|
4
|
+
* Dependency-free: packages only what is declared in package.json (zero runtime deps).
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
13
|
+
|
|
14
|
+
function walk(dir, out = []) {
|
|
15
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
16
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
17
|
+
const abs = path.join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) walk(abs, out);
|
|
19
|
+
else if (entry.isFile()) out.push(abs);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = walk(root).filter(
|
|
25
|
+
(f) =>
|
|
26
|
+
!f.includes(`${path.sep}artifacts${path.sep}`) &&
|
|
27
|
+
!f.endsWith('package-lock.json'),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const packages = [
|
|
31
|
+
{
|
|
32
|
+
name: pkg.name,
|
|
33
|
+
version: pkg.version,
|
|
34
|
+
downloadLocation: `https://registry.npmjs.org/${pkg.name}/-/${pkg.name.split('/').pop()}-${pkg.version}.tgz`,
|
|
35
|
+
filesAnalyzed: true,
|
|
36
|
+
licenseConcluded: pkg.license || 'NOASSERTION',
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Zero runtime dependencies by design — still list optional peer/dev if present
|
|
41
|
+
for (const [section, deps] of Object.entries({
|
|
42
|
+
dependencies: pkg.dependencies || {},
|
|
43
|
+
optionalDependencies: pkg.optionalDependencies || {},
|
|
44
|
+
})) {
|
|
45
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
46
|
+
packages.push({
|
|
47
|
+
name,
|
|
48
|
+
version: String(version).replace(/^[\^~]/, ''),
|
|
49
|
+
relationship: section,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fileEntries = files.map((abs) => {
|
|
55
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
56
|
+
const buf = fs.readFileSync(abs);
|
|
57
|
+
return {
|
|
58
|
+
fileName: rel,
|
|
59
|
+
checksums: [
|
|
60
|
+
{
|
|
61
|
+
algorithm: 'SHA256',
|
|
62
|
+
checksumValue: crypto.createHash('sha256').update(buf).digest('hex'),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const sbom = {
|
|
69
|
+
spdxVersion: 'SPDX-2.3',
|
|
70
|
+
dataLicense: 'CC0-1.0',
|
|
71
|
+
SPDXID: 'SPDXRef-DOCUMENT',
|
|
72
|
+
name: `${pkg.name}-${pkg.version}`,
|
|
73
|
+
documentNamespace: `https://github.com/gregiteen/ssss/sbom/${pkg.version}`,
|
|
74
|
+
creationInfo: {
|
|
75
|
+
created: new Date().toISOString(),
|
|
76
|
+
creators: ['Tool: ssss-generate-sbom'],
|
|
77
|
+
},
|
|
78
|
+
packages,
|
|
79
|
+
files: fileEntries,
|
|
80
|
+
relationships: [
|
|
81
|
+
{
|
|
82
|
+
spdxElementId: 'SPDXRef-DOCUMENT',
|
|
83
|
+
relationshipType: 'DESCRIBES',
|
|
84
|
+
relatedSpdxElement: 'SPDXRef-Package',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
comment:
|
|
88
|
+
'SSSS reference package is intentionally dependency-free at runtime. Host apps supply adapters.',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const outDir = path.join(root, 'artifacts');
|
|
92
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
93
|
+
const outFile = path.join(outDir, `sbom-${pkg.version}.spdx.json`);
|
|
94
|
+
fs.writeFileSync(outFile, `${JSON.stringify(sbom, null, 2)}\n`);
|
|
95
|
+
console.log(`SBOM written: ${outFile}`);
|
|
96
|
+
console.log(`Packages: ${packages.length}, files hashed: ${fileEntries.length}`);
|