yam-flow 2.6.0 → 2.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.
@@ -0,0 +1,498 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { compareStableText } from './stable-order.js';
6
+ const MAX_RECEIPT_BYTES = 1024 * 1024;
7
+ const MAX_ASSET_BYTES = 512 * 1024 * 1024;
8
+ const MAX_ASSETS = 128;
9
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,95}$/;
10
+ export async function createMediaProviderReceipt(input) {
11
+ assertExactKeys(input, [
12
+ 'root', 'receipt_path', 'demand_trigger', 'provider', 'provider_calls',
13
+ 'provider_execution', 'dry_run', 'submit', 'assets'
14
+ ], 'media provider input');
15
+ const root = await canonicalRoot(input.root);
16
+ const receiptPath = await resolveReceiptPath(root, input.receipt_path, false);
17
+ const normalized = await normalizeInput(root, input);
18
+ const createdAt = new Date().toISOString();
19
+ const canonical = {
20
+ schema: 'yam.media-provider-receipt.v1',
21
+ created_at: createdAt,
22
+ demand_trigger: {
23
+ ...normalized.demand_trigger,
24
+ evidence_truth: 'operator_asserted'
25
+ },
26
+ provider: normalized.provider,
27
+ provider_calls: normalized.provider_calls,
28
+ provider_execution: normalized.provider_execution,
29
+ dry_run: normalized.dry_run,
30
+ submit: normalized.submit,
31
+ assets: normalized.assets,
32
+ execution_boundary: {
33
+ provider_calls_by_yam: false,
34
+ provider_claim_source: 'operator_asserted',
35
+ optional: true
36
+ },
37
+ claims: {
38
+ asset_integrity: 'digest_bound',
39
+ provider_execution: 'operator_asserted',
40
+ visual_correctness: 'not_verified',
41
+ implementation_correctness: 'not_verified'
42
+ },
43
+ truth_status: 'partial'
44
+ };
45
+ const receipt = { ...canonical, digest: digest(canonical) };
46
+ await writeImmutableJson(root, receiptPath, receipt);
47
+ return {
48
+ schema: 'yam.media-provider-receipt-write.v1',
49
+ receipt_path: receiptPath,
50
+ receipt,
51
+ truth_status: 'partial',
52
+ next_action: input.provider_execution
53
+ ? 'retain the receipt as operator-asserted provider execution evidence; verify visual and implementation correctness separately'
54
+ : 'retain the dry-run receipt only when planning provenance is useful; no provider call occurred'
55
+ };
56
+ }
57
+ export async function verifyMediaProviderReceipt(input) {
58
+ assertExactKeys(input, ['root', 'receipt_path'], 'media provider verification input');
59
+ const root = await canonicalRoot(input.root);
60
+ const receiptPath = await resolveReceiptPath(root, input.receipt_path, true);
61
+ const value = await readBoundedJson(root, receiptPath);
62
+ const errors = [];
63
+ const record = isObject(value) ? value : {};
64
+ assertRecordKeys(record, [
65
+ 'schema', 'created_at', 'demand_trigger', 'provider', 'provider_calls', 'provider_execution',
66
+ 'dry_run', 'submit', 'assets', 'execution_boundary', 'claims', 'truth_status', 'digest'
67
+ ], errors, 'receipt');
68
+ if (record.schema !== 'yam.media-provider-receipt.v1')
69
+ errors.push('schema_invalid');
70
+ if (!isIsoDate(record.created_at))
71
+ errors.push('created_at_invalid');
72
+ validateStoredContracts(record, errors);
73
+ try {
74
+ const normalized = await normalizeInput(root, {
75
+ demand_trigger: stripDemandTruth(record.demand_trigger),
76
+ provider: record.provider,
77
+ provider_calls: record.provider_calls,
78
+ provider_execution: record.provider_execution,
79
+ dry_run: record.dry_run,
80
+ submit: record.submit,
81
+ assets: stripStoredAssetFacts(record.assets)
82
+ }, record.assets, record.created_at);
83
+ if (!sameJson(normalized.assets, record.assets))
84
+ errors.push('asset_integrity_mismatch');
85
+ }
86
+ catch (error) {
87
+ errors.push(`contract_or_asset_invalid:${message(error)}`);
88
+ }
89
+ const canonical = { ...record };
90
+ delete canonical.digest;
91
+ if (typeof record.digest !== 'string' || record.digest !== digest(canonical))
92
+ errors.push('digest_invalid');
93
+ const uniqueErrors = [...new Set(errors)];
94
+ return {
95
+ schema: 'yam.media-provider-receipt-verification.v1',
96
+ receipt_path: receiptPath,
97
+ valid: uniqueErrors.length === 0,
98
+ errors: uniqueErrors,
99
+ receipt_truth_status: 'partial',
100
+ truth_status: uniqueErrors.length ? 'blocked' : 'verified',
101
+ next_action: uniqueErrors.length
102
+ ? `reject the media receipt and repair: ${uniqueErrors[0]}`
103
+ : 'receipt and local asset digests are intact; provider execution, visual quality, and implementation correctness remain outside integrity verification'
104
+ };
105
+ }
106
+ async function normalizeInput(root, input, storedAssets, maximumRecordedAt) {
107
+ assertExactKeys(input.demand_trigger, ['kind', 'evidence'], 'demand_trigger');
108
+ const demand = input.demand_trigger;
109
+ if (demand.kind !== 'media_generation' && demand.kind !== 'media_transformation') {
110
+ throw new Error('demand_trigger.kind must be media_generation or media_transformation');
111
+ }
112
+ const demandTrigger = {
113
+ kind: demand.kind,
114
+ evidence: boundedText(demand.evidence, 'demand_trigger.evidence', 1024)
115
+ };
116
+ assertExactKeys(input.provider, ['name', 'model'], 'provider');
117
+ const provider = input.provider;
118
+ const normalizedProvider = {
119
+ name: boundedText(provider.name, 'provider.name', 200),
120
+ model: boundedText(provider.model, 'provider.model', 200)
121
+ };
122
+ const providerCalls = integerValue(input.provider_calls, 'provider_calls');
123
+ const providerExecution = booleanValue(input.provider_execution, 'provider_execution');
124
+ const dryRun = booleanValue(input.dry_run, 'dry_run');
125
+ const submit = booleanValue(input.submit, 'submit');
126
+ if (dryRun) {
127
+ if (providerCalls !== 0 || providerExecution || submit) {
128
+ throw new Error('dry_run requires provider_calls=0, provider_execution=false, and submit=false');
129
+ }
130
+ }
131
+ else if (!providerExecution || !submit || providerCalls < 1) {
132
+ throw new Error('non-dry-run requires provider_calls>=1, provider_execution=true, and submit=true');
133
+ }
134
+ if (!Array.isArray(input.assets) || !input.assets.length)
135
+ throw new Error('assets requires at least one local asset');
136
+ if (input.assets.length > MAX_ASSETS)
137
+ throw new Error(`assets accepts at most ${MAX_ASSETS} items`);
138
+ const storedRows = Array.isArray(storedAssets) ? storedAssets : [];
139
+ const assets = [];
140
+ for (let index = 0; index < input.assets.length; index += 1) {
141
+ const item = input.assets[index];
142
+ assertExactKeys(item, ['id', 'role', 'file_path', 'provenance'], `assets[${index}]`);
143
+ const record = item;
144
+ const id = idValue(record.id, `assets[${index}].id`);
145
+ if (record.role !== 'input' && record.role !== 'output')
146
+ throw new Error(`assets[${index}].role must be input or output`);
147
+ assertExactKeys(record.provenance, ['kind', 'source_ref', 'recorded_at'], `assets[${index}].provenance`);
148
+ const provenance = record.provenance;
149
+ if (provenance.kind !== 'operator' && provenance.kind !== 'provider') {
150
+ throw new Error(`assets[${index}].provenance.kind must be operator or provider`);
151
+ }
152
+ if (!isIsoDate(provenance.recorded_at))
153
+ throw new Error(`assets[${index}].provenance.recorded_at must be an ISO timestamp`);
154
+ const recordedAt = new Date(String(provenance.recorded_at)).getTime();
155
+ const upperBound = isIsoDate(maximumRecordedAt) ? new Date(String(maximumRecordedAt)).getTime() : Date.now();
156
+ if (recordedAt > upperBound)
157
+ throw new Error(`assets[${index}].provenance.recorded_at cannot be later than the receipt boundary`);
158
+ const inspected = await inspectAsset(root, record.file_path, `assets[${index}].file_path`);
159
+ const stored = storedRows[index];
160
+ if (isObject(stored)) {
161
+ if (stored.sha256 !== inspected.sha256)
162
+ throw new Error(`asset sha256 mismatch: ${id}`);
163
+ if (stored.bytes !== inspected.bytes)
164
+ throw new Error(`asset byte size mismatch: ${id}`);
165
+ }
166
+ assets.push({
167
+ id,
168
+ role: record.role,
169
+ file_path: inspected.file_path,
170
+ provenance: {
171
+ kind: provenance.kind,
172
+ source_ref: boundedText(provenance.source_ref, `assets[${index}].provenance.source_ref`, 1024),
173
+ recorded_at: String(provenance.recorded_at)
174
+ },
175
+ sha256: inspected.sha256,
176
+ bytes: inspected.bytes
177
+ });
178
+ }
179
+ assertUnique(assets.map((item) => item.id), 'asset id');
180
+ assets.sort((left, right) => compareStableText(left.id, right.id));
181
+ if (dryRun && assets.some((item) => item.role === 'output' || item.provenance.kind === 'provider')) {
182
+ throw new Error('dry_run assets cannot claim provider provenance or provider output');
183
+ }
184
+ if (!dryRun && !assets.some((item) => item.role === 'output' && item.provenance.kind === 'provider')) {
185
+ throw new Error('provider execution requires at least one provider-provenance output asset');
186
+ }
187
+ return {
188
+ demand_trigger: demandTrigger,
189
+ provider: normalizedProvider,
190
+ provider_calls: providerCalls,
191
+ provider_execution: providerExecution,
192
+ dry_run: dryRun,
193
+ submit,
194
+ assets
195
+ };
196
+ }
197
+ function validateStoredContracts(record, errors) {
198
+ const demand = isObject(record.demand_trigger) ? record.demand_trigger : {};
199
+ assertRecordKeys(demand, ['kind', 'evidence', 'evidence_truth'], errors, 'demand_trigger');
200
+ if (demand.evidence_truth !== 'operator_asserted')
201
+ errors.push('demand_truth_invalid');
202
+ const boundary = isObject(record.execution_boundary) ? record.execution_boundary : {};
203
+ assertRecordKeys(boundary, ['provider_calls_by_yam', 'provider_claim_source', 'optional'], errors, 'execution_boundary');
204
+ if (boundary.provider_calls_by_yam !== false || boundary.provider_claim_source !== 'operator_asserted' || boundary.optional !== true) {
205
+ errors.push('execution_boundary_invalid');
206
+ }
207
+ const claims = isObject(record.claims) ? record.claims : {};
208
+ assertRecordKeys(claims, ['asset_integrity', 'provider_execution', 'visual_correctness', 'implementation_correctness'], errors, 'claims');
209
+ if (claims.asset_integrity !== 'digest_bound' || claims.provider_execution !== 'operator_asserted'
210
+ || claims.visual_correctness !== 'not_verified' || claims.implementation_correctness !== 'not_verified') {
211
+ errors.push('claims_invalid');
212
+ }
213
+ if (record.truth_status !== 'partial')
214
+ errors.push('truth_status_invalid');
215
+ }
216
+ function stripDemandTruth(value) {
217
+ if (!isObject(value))
218
+ return value;
219
+ return { kind: value.kind, evidence: value.evidence };
220
+ }
221
+ function stripStoredAssetFacts(value) {
222
+ if (!Array.isArray(value))
223
+ return value;
224
+ return value.map((item) => isObject(item) ? {
225
+ id: item.id,
226
+ role: item.role,
227
+ file_path: item.file_path,
228
+ provenance: item.provenance
229
+ } : item);
230
+ }
231
+ async function inspectAsset(root, value, label) {
232
+ const relative = safeRelativePath(value, label);
233
+ const lexical = path.join(root, ...relative.split('/'));
234
+ if (!isWithin(root, lexical))
235
+ throw new Error(`${label} escapes the project root`);
236
+ const inspected = await withIdentityBoundFile(root, lexical, MAX_ASSET_BYTES, label, async (handle, size) => {
237
+ const hash = createHash('sha256');
238
+ const buffer = Buffer.allocUnsafe(64 * 1024);
239
+ let offset = 0;
240
+ while (offset < size) {
241
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, size - offset), offset);
242
+ if (!bytesRead)
243
+ throw new Error(`${label} became shorter while hashing`);
244
+ hash.update(buffer.subarray(0, bytesRead));
245
+ offset += bytesRead;
246
+ }
247
+ return { sha256: `sha256:${hash.digest('hex')}`, bytes: size };
248
+ });
249
+ return {
250
+ file_path: portableRelative(root, lexical),
251
+ sha256: inspected.sha256,
252
+ bytes: inspected.bytes
253
+ };
254
+ }
255
+ async function resolveReceiptPath(root, value, mustExist) {
256
+ const relative = safeRelativePath(value, 'receipt_path');
257
+ if (!relative.startsWith('.yam/media/') || !relative.endsWith('.json')) {
258
+ throw new Error('receipt_path must be a JSON file under .yam/media/');
259
+ }
260
+ const target = path.join(root, ...relative.split('/'));
261
+ await assertSafeParentChain(root, path.dirname(target));
262
+ if (mustExist) {
263
+ const stat = await fsp.lstat(target);
264
+ if (stat.isSymbolicLink() || !stat.isFile())
265
+ throw new Error('receipt_path must be a regular non-symlink file');
266
+ }
267
+ return target;
268
+ }
269
+ async function canonicalRoot(value) {
270
+ const root = await fsp.realpath(path.resolve(String(value || '.')));
271
+ const stat = await fsp.stat(root);
272
+ if (!stat.isDirectory())
273
+ throw new Error('root must be a directory');
274
+ return root;
275
+ }
276
+ async function assertSafeParentChain(root, parent) {
277
+ const relative = path.relative(root, parent);
278
+ if (relative.startsWith('..') || path.isAbsolute(relative))
279
+ throw new Error('receipt_path escapes the project root');
280
+ let current = root;
281
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
282
+ current = path.join(current, segment);
283
+ try {
284
+ const stat = await fsp.lstat(current);
285
+ if (stat.isSymbolicLink())
286
+ throw new Error(`receipt parent must not be a symlink: ${current}`);
287
+ if (!stat.isDirectory())
288
+ throw new Error(`receipt parent must be a directory: ${current}`);
289
+ }
290
+ catch (error) {
291
+ if (error.code === 'ENOENT')
292
+ break;
293
+ throw error;
294
+ }
295
+ }
296
+ }
297
+ async function captureParentIdentity(root, parent) {
298
+ const relative = path.relative(root, parent);
299
+ if (relative.startsWith('..') || path.isAbsolute(relative))
300
+ throw new Error('asset or receipt parent escapes the project root');
301
+ const identities = [];
302
+ let current = root;
303
+ const rootStat = await fsp.lstat(root);
304
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
305
+ throw new Error('project root identity is not a regular directory');
306
+ identities.push({ path: '.', dev: rootStat.dev, ino: rootStat.ino });
307
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
308
+ current = path.join(current, segment);
309
+ const stat = await fsp.lstat(current);
310
+ if (stat.isSymbolicLink() || !stat.isDirectory())
311
+ throw new Error(`parent path must be a regular non-symlink directory: ${current}`);
312
+ identities.push({ path: path.relative(root, current), dev: stat.dev, ino: stat.ino });
313
+ }
314
+ return identities;
315
+ }
316
+ function sameIdentity(left, right) {
317
+ return left.dev === right.dev && left.ino === right.ino;
318
+ }
319
+ function sameParentIdentity(left, right) {
320
+ return left.length === right.length && left.every((item, index) => item.path === right[index]?.path && sameIdentity(item, right[index]));
321
+ }
322
+ async function ensureAndCaptureParentIdentity(root, parent) {
323
+ const relative = path.relative(root, parent);
324
+ if (relative.startsWith('..') || path.isAbsolute(relative))
325
+ throw new Error('receipt parent escapes the project root');
326
+ let current = root;
327
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
328
+ current = path.join(current, segment);
329
+ try {
330
+ await fsp.mkdir(current, { mode: 0o700 });
331
+ }
332
+ catch (error) {
333
+ if (error.code !== 'EEXIST')
334
+ throw error;
335
+ }
336
+ const stat = await fsp.lstat(current);
337
+ if (stat.isSymbolicLink() || !stat.isDirectory())
338
+ throw new Error(`receipt parent must be a regular non-symlink directory: ${current}`);
339
+ }
340
+ return captureParentIdentity(root, parent);
341
+ }
342
+ async function writeImmutableJson(root, file, value) {
343
+ const parentsBefore = await ensureAndCaptureParentIdentity(root, path.dirname(file));
344
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
345
+ const handle = await fsp.open(file, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
346
+ try {
347
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
348
+ await handle.sync();
349
+ const opened = await handle.stat();
350
+ const after = await fsp.lstat(file);
351
+ const parentsAfter = await captureParentIdentity(root, path.dirname(file));
352
+ if (!opened.isFile() || after.isSymbolicLink() || !after.isFile() || !sameIdentity(opened, after)) {
353
+ throw new Error('media receipt identity changed while writing');
354
+ }
355
+ if (!sameParentIdentity(parentsBefore, parentsAfter))
356
+ throw new Error('media receipt parent path identity changed while writing');
357
+ }
358
+ finally {
359
+ await handle.close();
360
+ }
361
+ }
362
+ async function readBoundedJson(root, file) {
363
+ const bytes = await withIdentityBoundFile(root, file, MAX_RECEIPT_BYTES, 'media receipt', async (handle, size) => {
364
+ const value = Buffer.alloc(size);
365
+ let offset = 0;
366
+ while (offset < value.length) {
367
+ const { bytesRead } = await handle.read(value, offset, value.length - offset, offset);
368
+ if (!bytesRead)
369
+ throw new Error('media receipt became shorter while reading');
370
+ offset += bytesRead;
371
+ }
372
+ return value;
373
+ });
374
+ return JSON.parse(bytes.toString('utf8'));
375
+ }
376
+ async function withIdentityBoundFile(root, file, maxBytes, label, reader) {
377
+ const parentsBefore = await captureParentIdentity(root, path.dirname(file));
378
+ const before = await fsp.lstat(file);
379
+ if (before.isSymbolicLink() || !before.isFile())
380
+ throw new Error(`${label} must be a regular non-symlink file`);
381
+ if (before.size > maxBytes)
382
+ throw new Error(`${label} exceeds the ${maxBytes}-byte limit`);
383
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
384
+ const handle = await fsp.open(file, fsConstants.O_RDONLY | noFollow);
385
+ try {
386
+ const opened = await handle.stat();
387
+ if (!opened.isFile() || !sameIdentity(before, opened))
388
+ throw new Error(`${label} identity changed before open`);
389
+ if (opened.size > maxBytes)
390
+ throw new Error(`${label} exceeds the ${maxBytes}-byte limit`);
391
+ const result = await reader(handle, opened.size);
392
+ const openedAfter = await handle.stat();
393
+ if (!sameIdentity(opened, openedAfter) || openedAfter.size !== opened.size)
394
+ throw new Error(`${label} changed size or identity while reading`);
395
+ const after = await fsp.lstat(file);
396
+ const parentsAfter = await captureParentIdentity(root, path.dirname(file));
397
+ if (after.isSymbolicLink() || !after.isFile() || !sameIdentity(opened, after))
398
+ throw new Error(`${label} identity changed while reading`);
399
+ if (!sameParentIdentity(parentsBefore, parentsAfter))
400
+ throw new Error(`${label} parent path identity changed while reading`);
401
+ return result;
402
+ }
403
+ finally {
404
+ await handle.close();
405
+ }
406
+ }
407
+ function safeRelativePath(value, label) {
408
+ const text = String(value || '').trim();
409
+ if (!text || text.length > 1024 || path.isAbsolute(text) || text.includes('\\') || /[\u0000-\u001f\u007f]/.test(text)) {
410
+ throw new Error(`${label} must be a bounded project-relative POSIX path`);
411
+ }
412
+ const normalized = path.posix.normalize(text.replace(/^\.\//, ''));
413
+ if (normalized === '.' || normalized === '..' || normalized.startsWith('../') || normalized.split('/').includes('..')) {
414
+ throw new Error(`${label} escapes the project root`);
415
+ }
416
+ return normalized;
417
+ }
418
+ function portableRelative(root, file) {
419
+ const relative = path.relative(root, file).split(path.sep).join('/');
420
+ if (!relative || relative.startsWith('../'))
421
+ throw new Error('asset path escapes the project root');
422
+ return relative;
423
+ }
424
+ function isWithin(root, target) {
425
+ const relative = path.relative(root, target);
426
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
427
+ }
428
+ function idValue(value, label) {
429
+ const text = String(value || '');
430
+ if (!ID_PATTERN.test(text))
431
+ throw new Error(`${label} is invalid`);
432
+ return text;
433
+ }
434
+ function integerValue(value, label) {
435
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
436
+ throw new Error(`${label} must be an integer >= 0; booleans are rejected`);
437
+ }
438
+ return value;
439
+ }
440
+ function booleanValue(value, label) {
441
+ if (typeof value !== 'boolean')
442
+ throw new Error(`${label} must be boolean`);
443
+ return value;
444
+ }
445
+ function boundedText(value, label, max) {
446
+ if (typeof value !== 'string')
447
+ throw new Error(`${label} must be a string`);
448
+ const text = value.trim();
449
+ if (!text || text.length > max)
450
+ throw new Error(`${label} must contain 1-${max} characters`);
451
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
452
+ throw new Error(`${label} contains control characters`);
453
+ return text;
454
+ }
455
+ function assertExactKeys(value, expected, label) {
456
+ if (!isObject(value))
457
+ throw new Error(`${label} must be an object`);
458
+ const actual = Object.keys(value).sort(compareStableText);
459
+ const wanted = [...expected].sort(compareStableText);
460
+ if (!sameStringArray(actual, wanted))
461
+ throw new Error(`${label} keys must be exactly: ${wanted.join(', ')}`);
462
+ }
463
+ function assertRecordKeys(record, expected, errors, label) {
464
+ const actual = Object.keys(record).sort(compareStableText);
465
+ const wanted = [...expected].sort(compareStableText);
466
+ if (!sameStringArray(actual, wanted))
467
+ errors.push(`${label}_keys_invalid`);
468
+ }
469
+ function assertUnique(values, label) {
470
+ if (new Set(values).size !== values.length)
471
+ throw new Error(`duplicate ${label}`);
472
+ }
473
+ function sameStringArray(value, expected) {
474
+ return Array.isArray(value) && value.length === expected.length && value.every((item, index) => item === expected[index]);
475
+ }
476
+ function sameJson(left, right) {
477
+ return stableJson(left) === stableJson(right);
478
+ }
479
+ function isObject(value) {
480
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
481
+ }
482
+ function isIsoDate(value) {
483
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
484
+ }
485
+ function digest(value) {
486
+ return `sha256:${createHash('sha256').update(stableJson(value)).digest('hex')}`;
487
+ }
488
+ function stableJson(value) {
489
+ if (Array.isArray(value))
490
+ return `[${value.map((item) => stableJson(item)).join(',')}]`;
491
+ if (isObject(value)) {
492
+ return `{${Object.keys(value).sort(compareStableText).map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
493
+ }
494
+ return JSON.stringify(value);
495
+ }
496
+ function message(error) {
497
+ return error instanceof Error ? error.message : String(error);
498
+ }