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,728 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import fsp from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { compareStableText } from './stable-order.js';
7
+ const MAX_RECEIPT_BYTES = 1024 * 1024;
8
+ const MAX_ITEMS = 256;
9
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,95}$/;
10
+ const RELEASE_SENSITIVE_PATTERNS = [
11
+ /^(?:package|npm-shrinkwrap)(?:-lock)?\.json$/,
12
+ /^yam\.manifest\.json$/,
13
+ /^CHANGELOG\.md$/,
14
+ /^dist\//,
15
+ /^src\/bin\/yam\.ts$/,
16
+ /^scripts\/(?:install|uninstall|status)\.sh$/,
17
+ /^\.github\/workflows\//
18
+ ];
19
+ export async function createVerificationClosureReceipt(input) {
20
+ assertExactKeys(input, [
21
+ 'root', 'receipt_path', 'planned_scope', 'changed_files', 'selected_commands',
22
+ 'executed_commands', 'skipped', 'truncated'
23
+ ], 'verification closure input');
24
+ const root = await canonicalRoot(input.root);
25
+ const receiptPath = await resolveReceiptPath(root, input.receipt_path, false);
26
+ const normalized = normalizeClosureInput(input);
27
+ const observation = observeGitScope(root, normalized.changed_files);
28
+ const finalChangedFiles = observation.changed_files;
29
+ const releaseSensitiveFiles = finalChangedFiles.filter(isReleaseSensitivePath);
30
+ const finalScope = normalized.planned_scope === 'release' || releaseSensitiveFiles.length ? 'release' : 'affected';
31
+ if (finalScope === 'release' && !normalized.selected_commands.some((item) => item.required && item.scope === 'release')) {
32
+ throw new Error('release final scope requires at least one required release-scope command');
33
+ }
34
+ const blockers = normalized.executed_commands
35
+ .filter((item) => item.exit_code !== 0)
36
+ .map((item) => `${item.id}: command exited with code ${item.exit_code}`);
37
+ if (observation.availability === 'unavailable')
38
+ blockers.push(`git_scope_unavailable: ${observation.reason}`);
39
+ if (observation.unplanned_files.length)
40
+ blockers.push(`unplanned_final_files: ${observation.unplanned_files.join(', ')}`);
41
+ if (observation.declared_not_observed.length)
42
+ blockers.push(`declared_files_not_observed: ${observation.declared_not_observed.join(', ')}`);
43
+ const createdAt = new Date().toISOString();
44
+ const canonical = {
45
+ schema: 'yam.verification-closure.v1',
46
+ created_at: createdAt,
47
+ planned_scope: normalized.planned_scope,
48
+ final_scope: finalScope,
49
+ scope_derivation: {
50
+ source: 'git_status_porcelain_v1',
51
+ availability: observation.availability,
52
+ promoted: normalized.planned_scope === 'affected' && finalScope === 'release',
53
+ release_sensitive_files: releaseSensitiveFiles,
54
+ reason: releaseSensitiveFiles.length
55
+ ? `release-sensitive paths require release verification: ${releaseSensitiveFiles.join(', ')}`
56
+ : normalized.planned_scope === 'release'
57
+ ? 'the planned release boundary cannot be silently downgraded'
58
+ : 'no observed release-sensitive path was present'
59
+ },
60
+ declared_changed_files: normalized.changed_files,
61
+ scope_observation: observation,
62
+ changed_files: finalChangedFiles,
63
+ selected_commands: normalized.selected_commands,
64
+ executed_commands: normalized.executed_commands,
65
+ skipped: normalized.skipped,
66
+ truncated: normalized.truncated,
67
+ execution_boundary: {
68
+ command_execution_by_yam: false,
69
+ execution_evidence: 'operator_declared'
70
+ },
71
+ status: blockers.length ? 'failed' : 'passed',
72
+ blockers,
73
+ truth_status: blockers.length ? 'blocked' : 'partial'
74
+ };
75
+ const receipt = {
76
+ ...canonical,
77
+ digest: digest(canonical)
78
+ };
79
+ await writeImmutableJson(root, receiptPath, receipt);
80
+ return {
81
+ schema: 'yam.verification-closure-write.v1',
82
+ receipt_path: receiptPath,
83
+ receipt,
84
+ truth_status: receipt.truth_status,
85
+ next_action: receipt.status === 'passed'
86
+ ? 'retain this receipt with the completion claim; command execution remains operator-declared'
87
+ : `resolve the first verification-closure blocker before claiming completion: ${blockers[0]}`
88
+ };
89
+ }
90
+ export function verifyVerificationClosureReceipt(value) {
91
+ const errors = [];
92
+ const record = isObject(value) ? value : {};
93
+ assertRecordKeys(record, [
94
+ 'schema', 'created_at', 'planned_scope', 'final_scope', 'scope_derivation', 'declared_changed_files',
95
+ 'scope_observation', 'changed_files',
96
+ 'selected_commands', 'executed_commands', 'skipped', 'truncated', 'execution_boundary',
97
+ 'status', 'blockers', 'truth_status', 'digest'
98
+ ], errors, 'receipt');
99
+ if (record.schema !== 'yam.verification-closure.v1')
100
+ errors.push('schema_invalid');
101
+ if (!isIsoDate(record.created_at))
102
+ errors.push('created_at_invalid');
103
+ const plannedScope = scopeValue(record.planned_scope, errors, 'planned_scope');
104
+ const finalScope = scopeValue(record.final_scope, errors, 'final_scope');
105
+ let normalized = null;
106
+ try {
107
+ normalized = normalizeClosureInput({
108
+ planned_scope: plannedScope,
109
+ changed_files: record.declared_changed_files,
110
+ selected_commands: record.selected_commands,
111
+ executed_commands: record.executed_commands,
112
+ skipped: record.skipped,
113
+ truncated: record.truncated
114
+ });
115
+ }
116
+ catch (error) {
117
+ errors.push(`contract_invalid:${message(error)}`);
118
+ }
119
+ const derivation = isObject(record.scope_derivation) ? record.scope_derivation : {};
120
+ assertRecordKeys(derivation, ['source', 'availability', 'promoted', 'release_sensitive_files', 'reason'], errors, 'scope_derivation');
121
+ if (derivation.source !== 'git_status_porcelain_v1')
122
+ errors.push('scope_source_invalid');
123
+ if (derivation.availability !== 'observed' && derivation.availability !== 'unavailable')
124
+ errors.push('scope_availability_invalid');
125
+ if (typeof derivation.promoted !== 'boolean')
126
+ errors.push('scope_promoted_invalid');
127
+ if (typeof derivation.reason !== 'string' || !derivation.reason.trim())
128
+ errors.push('scope_reason_invalid');
129
+ const boundary = isObject(record.execution_boundary) ? record.execution_boundary : {};
130
+ assertRecordKeys(boundary, ['command_execution_by_yam', 'execution_evidence'], errors, 'execution_boundary');
131
+ if (boundary.command_execution_by_yam !== false)
132
+ errors.push('execution_boundary_invalid');
133
+ if (boundary.execution_evidence !== 'operator_declared')
134
+ errors.push('execution_evidence_invalid');
135
+ if (normalized) {
136
+ const observation = normalizeStoredObservation(record.scope_observation, normalized.changed_files, errors);
137
+ const finalChangedFiles = observation.changed_files;
138
+ const releaseSensitiveFiles = finalChangedFiles.filter(isReleaseSensitivePath);
139
+ const expectedFinal = normalized.planned_scope === 'release' || releaseSensitiveFiles.length ? 'release' : 'affected';
140
+ if (!sameJson(record.declared_changed_files, normalized.changed_files))
141
+ errors.push('declared_changed_files_order_invalid');
142
+ if (!sameJson(record.changed_files, finalChangedFiles))
143
+ errors.push('changed_files_inconsistent');
144
+ if (!sameJson(record.selected_commands, normalized.selected_commands))
145
+ errors.push('selected_commands_order_invalid');
146
+ if (!sameJson(record.executed_commands, normalized.executed_commands))
147
+ errors.push('executed_commands_order_invalid');
148
+ if (!sameJson(record.skipped, normalized.skipped))
149
+ errors.push('skipped_order_invalid');
150
+ if (!sameJson(record.truncated, normalized.truncated))
151
+ errors.push('truncated_order_invalid');
152
+ if (finalScope !== expectedFinal)
153
+ errors.push('final_scope_inconsistent');
154
+ if (derivation.availability !== observation.availability)
155
+ errors.push('scope_availability_inconsistent');
156
+ if (derivation.promoted !== (normalized.planned_scope === 'affected' && expectedFinal === 'release'))
157
+ errors.push('scope_promotion_inconsistent');
158
+ if (!sameStringArray(derivation.release_sensitive_files, releaseSensitiveFiles))
159
+ errors.push('release_sensitive_files_inconsistent');
160
+ const expectedReason = releaseSensitiveFiles.length
161
+ ? `release-sensitive paths require release verification: ${releaseSensitiveFiles.join(', ')}`
162
+ : normalized.planned_scope === 'release'
163
+ ? 'the planned release boundary cannot be silently downgraded'
164
+ : 'no observed release-sensitive path was present';
165
+ if (derivation.reason !== expectedReason)
166
+ errors.push('scope_reason_inconsistent');
167
+ if (expectedFinal === 'release' && !normalized.selected_commands.some((item) => item.required && item.scope === 'release')) {
168
+ errors.push('release_command_missing');
169
+ }
170
+ const blockers = normalized.executed_commands
171
+ .filter((item) => item.exit_code !== 0)
172
+ .map((item) => `${item.id}: command exited with code ${item.exit_code}`);
173
+ if (observation.availability === 'unavailable')
174
+ blockers.push(`git_scope_unavailable: ${observation.reason}`);
175
+ if (observation.unplanned_files.length)
176
+ blockers.push(`unplanned_final_files: ${observation.unplanned_files.join(', ')}`);
177
+ if (observation.declared_not_observed.length)
178
+ blockers.push(`declared_files_not_observed: ${observation.declared_not_observed.join(', ')}`);
179
+ if (!sameStringArray(record.blockers, blockers))
180
+ errors.push('blockers_inconsistent');
181
+ if (record.status !== (blockers.length ? 'failed' : 'passed'))
182
+ errors.push('status_inconsistent');
183
+ if (record.truth_status !== (blockers.length ? 'blocked' : 'partial'))
184
+ errors.push('truth_status_inconsistent');
185
+ }
186
+ if (!['passed', 'failed'].includes(String(record.status || '')))
187
+ errors.push('status_invalid');
188
+ if (!['partial', 'blocked'].includes(String(record.truth_status || '')))
189
+ errors.push('truth_status_invalid');
190
+ const canonical = { ...record };
191
+ delete canonical.digest;
192
+ if (typeof record.digest !== 'string' || record.digest !== digest(canonical))
193
+ errors.push('digest_invalid');
194
+ const uniqueErrors = [...new Set(errors)];
195
+ const receiptBlocked = record.truth_status === 'blocked';
196
+ return {
197
+ schema: 'yam.verification-closure-verification.v1',
198
+ valid: uniqueErrors.length === 0,
199
+ errors: uniqueErrors,
200
+ receipt_truth_status: record.truth_status || 'blocked',
201
+ truth_status: uniqueErrors.length || receiptBlocked ? 'blocked' : 'verified',
202
+ next_action: uniqueErrors.length
203
+ ? `reject the verification closure and repair: ${uniqueErrors[0]}`
204
+ : receiptBlocked
205
+ ? `receipt integrity is valid but its completion boundary is blocked: ${Array.isArray(record.blockers) ? record.blockers[0] : 'repair the recorded blocker'}`
206
+ : 'receipt structure, semantics, and digest are intact; command execution evidence remains operator-declared'
207
+ };
208
+ }
209
+ export async function verifyVerificationClosureReceiptFile(input) {
210
+ assertExactKeys(input, ['root', 'receipt_path'], 'verification closure verification input');
211
+ const root = await canonicalRoot(input.root);
212
+ const receiptPath = await resolveReceiptPath(root, input.receipt_path, true);
213
+ const value = await readBoundedJson(root, receiptPath);
214
+ const artifactVerification = verifyVerificationClosureReceipt(value);
215
+ const record = isObject(value) ? value : {};
216
+ const declared = Array.isArray(record.declared_changed_files)
217
+ ? record.declared_changed_files.map((item) => String(item))
218
+ : [];
219
+ const currentScope = observeGitScope(root, declared, [path.relative(root, receiptPath).split(path.sep).join('/')]);
220
+ const currentErrors = [];
221
+ const storedScope = isObject(record.scope_observation) ? record.scope_observation : {};
222
+ if (currentScope.availability === 'unavailable')
223
+ currentErrors.push(`current_git_scope_unavailable:${currentScope.reason}`);
224
+ if (currentScope.availability === 'observed' && !sameJson(currentScope.changed_files, storedScope.changed_files)) {
225
+ currentErrors.push('current_git_scope_drift');
226
+ }
227
+ const errors = [...new Set([...artifactVerification.errors, ...currentErrors])];
228
+ const receiptBlocked = record.truth_status === 'blocked';
229
+ return {
230
+ ...artifactVerification,
231
+ receipt_path: receiptPath,
232
+ current_scope: currentScope,
233
+ valid: errors.length === 0,
234
+ errors,
235
+ truth_status: errors.length || receiptBlocked ? 'blocked' : 'verified',
236
+ next_action: errors.length
237
+ ? `reject the verification closure and repair: ${errors[0]}`
238
+ : artifactVerification.next_action
239
+ };
240
+ }
241
+ export function isReleaseSensitivePath(file) {
242
+ return RELEASE_SENSITIVE_PATTERNS.some((pattern) => pattern.test(file));
243
+ }
244
+ function observeGitScope(root, declaredFiles, ignoredFiles = []) {
245
+ const command = 'git status --porcelain=v1 -z --untracked-files=all';
246
+ const topLevel = spawnSync('git', ['rev-parse', '--show-toplevel'], {
247
+ cwd: root,
248
+ encoding: 'utf8',
249
+ maxBuffer: 64 * 1024,
250
+ timeout: 10000,
251
+ windowsHide: true
252
+ });
253
+ if (topLevel.error || topLevel.status !== 0) {
254
+ const reason = boundedDiagnostic(topLevel.error ? message(topLevel.error) : topLevel.stderr || `git exited ${topLevel.status}`);
255
+ return { command, availability: 'unavailable', changed_files: [], unplanned_files: [], declared_not_observed: [], reason: reason || 'Git repository root was unavailable' };
256
+ }
257
+ let observedRoot = '';
258
+ try {
259
+ observedRoot = path.resolve(String(topLevel.stdout || '').trim());
260
+ }
261
+ catch (error) {
262
+ return { command, availability: 'unavailable', changed_files: [], unplanned_files: [], declared_not_observed: [], reason: `Git repository root parse failed: ${boundedDiagnostic(message(error))}` };
263
+ }
264
+ if (observedRoot !== root) {
265
+ return { command, availability: 'unavailable', changed_files: [], unplanned_files: [], declared_not_observed: [], reason: 'root must equal the Git top-level directory so final paths remain project-confined' };
266
+ }
267
+ const result = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
268
+ cwd: root,
269
+ encoding: 'utf8',
270
+ maxBuffer: MAX_RECEIPT_BYTES,
271
+ timeout: 10000,
272
+ windowsHide: true
273
+ });
274
+ if (result.error || result.status !== 0) {
275
+ const reason = boundedDiagnostic(result.error ? message(result.error) : result.stderr || `git exited ${result.status}`);
276
+ return {
277
+ command,
278
+ availability: 'unavailable',
279
+ changed_files: [],
280
+ unplanned_files: [],
281
+ declared_not_observed: [],
282
+ reason: reason || 'Git status was unavailable; final scope cannot be treated as clean'
283
+ };
284
+ }
285
+ try {
286
+ const fields = String(result.stdout || '').split('\0');
287
+ if (fields.at(-1) === '')
288
+ fields.pop();
289
+ const changedFiles = [];
290
+ for (let index = 0; index < fields.length; index += 1) {
291
+ const row = fields[index];
292
+ if (row.length < 4 || row[2] !== ' ')
293
+ throw new Error('unexpected porcelain-v1 status record');
294
+ const status = row.slice(0, 2);
295
+ changedFiles.push(safeRelativePath(row.slice(3), 'Git changed path'));
296
+ if (/[RC]/.test(status)) {
297
+ index += 1;
298
+ if (index >= fields.length)
299
+ throw new Error('rename/copy status is missing its source path');
300
+ changedFiles.push(safeRelativePath(fields[index], 'Git rename/copy source path'));
301
+ }
302
+ }
303
+ const ignored = new Set(ignoredFiles.map((file, index) => safeRelativePath(file, `ignored_files[${index}]`)));
304
+ const finalFiles = unique(changedFiles).filter((file) => !ignored.has(file)).sort(compareStableText);
305
+ if (finalFiles.length > MAX_ITEMS)
306
+ throw new Error(`Git scope exceeds the ${MAX_ITEMS}-path limit`);
307
+ const declared = new Set(declaredFiles);
308
+ const observed = new Set(finalFiles);
309
+ return {
310
+ command,
311
+ availability: 'observed',
312
+ changed_files: finalFiles,
313
+ unplanned_files: finalFiles.filter((file) => !declared.has(file)),
314
+ declared_not_observed: declaredFiles.filter((file) => !observed.has(file)),
315
+ reason: 'current dirty, staged, and untracked Git paths captured before receipt write'
316
+ };
317
+ }
318
+ catch (error) {
319
+ return {
320
+ command,
321
+ availability: 'unavailable',
322
+ changed_files: [],
323
+ unplanned_files: [],
324
+ declared_not_observed: [],
325
+ reason: `Git scope parse failed: ${boundedDiagnostic(message(error))}`
326
+ };
327
+ }
328
+ }
329
+ function normalizeStoredObservation(value, declaredFiles, errors) {
330
+ const record = isObject(value) ? value : {};
331
+ assertRecordKeys(record, ['command', 'availability', 'changed_files', 'unplanned_files', 'declared_not_observed', 'reason'], errors, 'scope_observation');
332
+ const command = 'git status --porcelain=v1 -z --untracked-files=all';
333
+ if (record.command !== command)
334
+ errors.push('scope_command_invalid');
335
+ const availability = record.availability === 'observed' ? 'observed' : 'unavailable';
336
+ if (record.availability !== 'observed' && record.availability !== 'unavailable')
337
+ errors.push('scope_observation_availability_invalid');
338
+ let changedFiles = [];
339
+ let unplannedFiles = [];
340
+ let declaredNotObserved = [];
341
+ try {
342
+ changedFiles = normalizedPathArray(record.changed_files, 'scope_observation.changed_files');
343
+ unplannedFiles = normalizedPathArray(record.unplanned_files, 'scope_observation.unplanned_files');
344
+ declaredNotObserved = normalizedPathArray(record.declared_not_observed, 'scope_observation.declared_not_observed');
345
+ }
346
+ catch (error) {
347
+ errors.push(`scope_observation_invalid:${message(error)}`);
348
+ }
349
+ const reason = typeof record.reason === 'string' && record.reason.trim() && record.reason.length <= 1024 ? record.reason : '';
350
+ if (!reason)
351
+ errors.push('scope_observation_reason_invalid');
352
+ if (availability === 'unavailable') {
353
+ if (changedFiles.length || unplannedFiles.length || declaredNotObserved.length)
354
+ errors.push('unavailable_scope_with_observed_paths');
355
+ }
356
+ else {
357
+ const declared = new Set(declaredFiles);
358
+ const observed = new Set(changedFiles);
359
+ const expectedUnplanned = changedFiles.filter((file) => !declared.has(file));
360
+ const expectedMissing = declaredFiles.filter((file) => !observed.has(file));
361
+ if (!sameStringArray(unplannedFiles, expectedUnplanned))
362
+ errors.push('unplanned_files_inconsistent');
363
+ if (!sameStringArray(declaredNotObserved, expectedMissing))
364
+ errors.push('declared_not_observed_inconsistent');
365
+ }
366
+ return { command, availability, changed_files: changedFiles, unplanned_files: unplannedFiles, declared_not_observed: declaredNotObserved, reason };
367
+ }
368
+ function normalizedPathArray(value, label) {
369
+ if (!Array.isArray(value) || value.length > MAX_ITEMS)
370
+ throw new Error(`${label} must be an array of at most ${MAX_ITEMS} paths`);
371
+ const paths = value.map((item, index) => safeRelativePath(item, `${label}[${index}]`));
372
+ const normalized = unique(paths).sort(compareStableText);
373
+ if (normalized.length !== paths.length)
374
+ throw new Error(`${label} contains duplicates`);
375
+ if (!sameStringArray(paths, normalized))
376
+ throw new Error(`${label} must use deterministic ordinal order`);
377
+ return normalized;
378
+ }
379
+ function normalizeClosureInput(input) {
380
+ const plannedScope = scopeValue(input.planned_scope);
381
+ if (!Array.isArray(input.changed_files) || !input.changed_files.length)
382
+ throw new Error('changed_files requires at least one declared path');
383
+ if (input.changed_files.length > MAX_ITEMS)
384
+ throw new Error(`changed_files accepts at most ${MAX_ITEMS} items`);
385
+ const changedFiles = unique(input.changed_files.map((item, index) => safeRelativePath(item, `changed_files[${index}]`))).sort(compareStableText);
386
+ if (changedFiles.length !== input.changed_files.length)
387
+ throw new Error('changed_files contains duplicates');
388
+ const selected = normalizeSelected(input.selected_commands);
389
+ const executed = normalizeExecuted(input.executed_commands);
390
+ const skipped = normalizeExceptions(input.skipped, 'skipped');
391
+ const truncated = normalizeExceptions(input.truncated, 'truncated');
392
+ const selectedById = new Map(selected.map((item) => [item.id, item]));
393
+ const executedById = new Map(executed.map((item) => [item.id, item]));
394
+ const skippedById = new Map(skipped.map((item) => [item.check_id, item]));
395
+ for (const item of executed) {
396
+ const expected = selectedById.get(item.id);
397
+ if (!expected)
398
+ throw new Error(`unknown executed command: ${item.id}`);
399
+ if (expected.command !== item.command)
400
+ throw new Error(`executed command does not match selected command: ${item.id}`);
401
+ }
402
+ for (const item of skipped) {
403
+ const expected = selectedById.get(item.check_id);
404
+ if (!expected)
405
+ throw new Error(`unknown skipped command: ${item.check_id}`);
406
+ if (expected.required)
407
+ throw new Error(`required selected command cannot be skipped: ${item.check_id}`);
408
+ if (executedById.has(item.check_id))
409
+ throw new Error(`command cannot be both executed and skipped: ${item.check_id}`);
410
+ }
411
+ for (const item of truncated) {
412
+ if (!executedById.has(item.check_id))
413
+ throw new Error(`truncated output must name an executed command: ${item.check_id}`);
414
+ }
415
+ for (const item of selected) {
416
+ if (item.required && !executedById.has(item.id))
417
+ throw new Error(`selected required command was not executed: ${item.id}`);
418
+ if (!item.required && !executedById.has(item.id) && !skippedById.has(item.id)) {
419
+ throw new Error(`unexecuted optional command requires a skipped reason: ${item.id}`);
420
+ }
421
+ }
422
+ return {
423
+ planned_scope: plannedScope,
424
+ changed_files: changedFiles,
425
+ selected_commands: selected,
426
+ executed_commands: executed,
427
+ skipped,
428
+ truncated
429
+ };
430
+ }
431
+ function normalizeSelected(value) {
432
+ if (!Array.isArray(value) || !value.length)
433
+ throw new Error('selected_commands requires at least one command');
434
+ if (value.length > MAX_ITEMS)
435
+ throw new Error(`selected_commands accepts at most ${MAX_ITEMS} items`);
436
+ const rows = value.map((item, index) => {
437
+ assertExactKeys(item, ['id', 'command', 'scope', 'required'], `selected_commands[${index}]`);
438
+ const record = item;
439
+ return {
440
+ id: idValue(record.id, `selected_commands[${index}].id`),
441
+ command: commandValue(record.command, `selected_commands[${index}].command`),
442
+ scope: scopeValue(record.scope),
443
+ required: booleanValue(record.required, `selected_commands[${index}].required`)
444
+ };
445
+ });
446
+ assertUnique(rows.map((item) => item.id), 'selected command id');
447
+ return rows.sort((left, right) => compareStableText(left.id, right.id));
448
+ }
449
+ function normalizeExecuted(value) {
450
+ if (!Array.isArray(value))
451
+ throw new Error('executed_commands must be an array');
452
+ if (value.length > MAX_ITEMS)
453
+ throw new Error(`executed_commands accepts at most ${MAX_ITEMS} items`);
454
+ const rows = value.map((item, index) => {
455
+ assertExactKeys(item, ['id', 'command', 'exit_code', 'evidence'], `executed_commands[${index}]`);
456
+ const record = item;
457
+ const exitCode = record.exit_code;
458
+ if (typeof exitCode !== 'number' || !Number.isSafeInteger(exitCode) || exitCode < 0 || exitCode > 255) {
459
+ throw new Error(`executed_commands[${index}].exit_code must be an integer from 0 to 255`);
460
+ }
461
+ return {
462
+ id: idValue(record.id, `executed_commands[${index}].id`),
463
+ command: commandValue(record.command, `executed_commands[${index}].command`),
464
+ exit_code: exitCode,
465
+ evidence: boundedText(record.evidence, `executed_commands[${index}].evidence`, 1024)
466
+ };
467
+ });
468
+ assertUnique(rows.map((item) => item.id), 'executed command id');
469
+ return rows.sort((left, right) => compareStableText(left.id, right.id));
470
+ }
471
+ function normalizeExceptions(value, label) {
472
+ if (!Array.isArray(value))
473
+ throw new Error(`${label} must be an array`);
474
+ if (value.length > MAX_ITEMS)
475
+ throw new Error(`${label} accepts at most ${MAX_ITEMS} items`);
476
+ const rows = value.map((item, index) => {
477
+ assertExactKeys(item, ['check_id', 'reason'], `${label}[${index}]`);
478
+ const record = item;
479
+ return {
480
+ check_id: idValue(record.check_id, `${label}[${index}].check_id`),
481
+ reason: boundedText(record.reason, `${label}[${index}].reason`, 1024)
482
+ };
483
+ });
484
+ assertUnique(rows.map((item) => item.check_id), `${label} check id`);
485
+ return rows.sort((left, right) => compareStableText(left.check_id, right.check_id));
486
+ }
487
+ function scopeValue(value, errors, label = 'scope') {
488
+ if (value === 'affected' || value === 'release')
489
+ return value;
490
+ if (errors) {
491
+ errors.push(`${label}_invalid`);
492
+ return 'affected';
493
+ }
494
+ throw new Error(`${label} must be affected or release`);
495
+ }
496
+ function idValue(value, label) {
497
+ const text = String(value || '');
498
+ if (!ID_PATTERN.test(text))
499
+ throw new Error(`${label} is invalid`);
500
+ return text;
501
+ }
502
+ function commandValue(value, label) {
503
+ const text = boundedText(value, label, 2048);
504
+ if (/[\r\n\u0000]/.test(String(value || '')))
505
+ throw new Error(`${label} must be a single bounded command line`);
506
+ return text;
507
+ }
508
+ function booleanValue(value, label) {
509
+ if (typeof value !== 'boolean')
510
+ throw new Error(`${label} must be boolean`);
511
+ return value;
512
+ }
513
+ function safeRelativePath(value, label) {
514
+ const text = String(value || '').trim();
515
+ if (!text || text.length > 1024 || path.isAbsolute(text) || text.includes('\\') || /[\u0000-\u001f\u007f]/.test(text)) {
516
+ throw new Error(`${label} must be a bounded project-relative POSIX path`);
517
+ }
518
+ const normalized = path.posix.normalize(text.replace(/^\.\//, ''));
519
+ if (normalized === '.' || normalized === '..' || normalized.startsWith('../') || normalized.split('/').includes('..')) {
520
+ throw new Error(`${label} escapes the project root`);
521
+ }
522
+ return normalized;
523
+ }
524
+ async function resolveReceiptPath(root, value, mustExist) {
525
+ const relative = safeRelativePath(value, 'receipt_path');
526
+ if (!relative.startsWith('.yam/verification/') || !relative.endsWith('.json')) {
527
+ throw new Error('receipt_path must be a JSON file under .yam/verification/');
528
+ }
529
+ const target = path.join(root, ...relative.split('/'));
530
+ await assertSafeParentChain(root, path.dirname(target));
531
+ if (mustExist) {
532
+ const stat = await fsp.lstat(target);
533
+ if (stat.isSymbolicLink() || !stat.isFile())
534
+ throw new Error('receipt_path must be a regular non-symlink file');
535
+ }
536
+ return target;
537
+ }
538
+ async function canonicalRoot(value) {
539
+ const root = await fsp.realpath(path.resolve(String(value || '.')));
540
+ const stat = await fsp.stat(root);
541
+ if (!stat.isDirectory())
542
+ throw new Error('root must be a directory');
543
+ return root;
544
+ }
545
+ async function assertSafeParentChain(root, parent) {
546
+ const relative = path.relative(root, parent);
547
+ if (relative.startsWith('..') || path.isAbsolute(relative))
548
+ throw new Error('receipt_path escapes the project root');
549
+ let current = root;
550
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
551
+ current = path.join(current, segment);
552
+ try {
553
+ const stat = await fsp.lstat(current);
554
+ if (stat.isSymbolicLink())
555
+ throw new Error(`receipt parent must not be a symlink: ${current}`);
556
+ if (!stat.isDirectory())
557
+ throw new Error(`receipt parent must be a directory: ${current}`);
558
+ }
559
+ catch (error) {
560
+ if (error.code === 'ENOENT')
561
+ break;
562
+ throw error;
563
+ }
564
+ }
565
+ }
566
+ async function captureParentIdentity(root, parent) {
567
+ const relative = path.relative(root, parent);
568
+ if (relative.startsWith('..') || path.isAbsolute(relative))
569
+ throw new Error('receipt parent escapes the project root');
570
+ const identities = [];
571
+ let current = root;
572
+ const rootStat = await fsp.lstat(root);
573
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
574
+ throw new Error('project root identity is not a regular directory');
575
+ identities.push({ path: '.', dev: rootStat.dev, ino: rootStat.ino });
576
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
577
+ current = path.join(current, segment);
578
+ const stat = await fsp.lstat(current);
579
+ if (stat.isSymbolicLink() || !stat.isDirectory())
580
+ throw new Error(`receipt parent must be a regular non-symlink directory: ${current}`);
581
+ identities.push({ path: path.relative(root, current), dev: stat.dev, ino: stat.ino });
582
+ }
583
+ return identities;
584
+ }
585
+ async function ensureAndCaptureParentIdentity(root, parent) {
586
+ const relative = path.relative(root, parent);
587
+ if (relative.startsWith('..') || path.isAbsolute(relative))
588
+ throw new Error('receipt parent escapes the project root');
589
+ let current = root;
590
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
591
+ current = path.join(current, segment);
592
+ try {
593
+ await fsp.mkdir(current, { mode: 0o700 });
594
+ }
595
+ catch (error) {
596
+ if (error.code !== 'EEXIST')
597
+ throw error;
598
+ }
599
+ const stat = await fsp.lstat(current);
600
+ if (stat.isSymbolicLink() || !stat.isDirectory())
601
+ throw new Error(`receipt parent must be a regular non-symlink directory: ${current}`);
602
+ }
603
+ return captureParentIdentity(root, parent);
604
+ }
605
+ async function writeImmutableJson(root, file, value) {
606
+ const parentsBefore = await ensureAndCaptureParentIdentity(root, path.dirname(file));
607
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
608
+ const handle = await fsp.open(file, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
609
+ try {
610
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
611
+ await handle.sync();
612
+ const opened = await handle.stat();
613
+ const after = await fsp.lstat(file);
614
+ const parentsAfter = await captureParentIdentity(root, path.dirname(file));
615
+ if (!opened.isFile() || after.isSymbolicLink() || !after.isFile() || !sameIdentity(opened, after)) {
616
+ throw new Error('verification receipt identity changed while writing');
617
+ }
618
+ if (!sameParentIdentity(parentsBefore, parentsAfter))
619
+ throw new Error('verification receipt parent path identity changed while writing');
620
+ }
621
+ finally {
622
+ await handle.close();
623
+ }
624
+ }
625
+ async function readBoundedJson(root, file) {
626
+ const parentsBefore = await captureParentIdentity(root, path.dirname(file));
627
+ const before = await fsp.lstat(file);
628
+ if (before.isSymbolicLink() || !before.isFile())
629
+ throw new Error('verification receipt must be a regular non-symlink file');
630
+ if (before.size > MAX_RECEIPT_BYTES)
631
+ throw new Error('verification receipt exceeds the 1 MiB limit');
632
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
633
+ const handle = await fsp.open(file, fsConstants.O_RDONLY | noFollow);
634
+ try {
635
+ const opened = await handle.stat();
636
+ if (!opened.isFile() || !sameIdentity(before, opened))
637
+ throw new Error('verification receipt identity changed before open');
638
+ if (opened.size > MAX_RECEIPT_BYTES)
639
+ throw new Error('verification receipt exceeds the 1 MiB limit');
640
+ const bytes = Buffer.alloc(opened.size);
641
+ let offset = 0;
642
+ while (offset < bytes.length) {
643
+ const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
644
+ if (!bytesRead)
645
+ throw new Error('verification receipt became shorter while reading');
646
+ offset += bytesRead;
647
+ }
648
+ const openedAfter = await handle.stat();
649
+ const after = await fsp.lstat(file);
650
+ const parentsAfter = await captureParentIdentity(root, path.dirname(file));
651
+ if (!sameIdentity(opened, openedAfter) || openedAfter.size !== opened.size
652
+ || after.isSymbolicLink() || !after.isFile() || !sameIdentity(opened, after)) {
653
+ throw new Error('verification receipt changed size or identity while reading');
654
+ }
655
+ if (!sameParentIdentity(parentsBefore, parentsAfter))
656
+ throw new Error('verification receipt parent path identity changed while reading');
657
+ return JSON.parse(bytes.toString('utf8'));
658
+ }
659
+ finally {
660
+ await handle.close();
661
+ }
662
+ }
663
+ function sameIdentity(left, right) {
664
+ return left.dev === right.dev && left.ino === right.ino;
665
+ }
666
+ function sameParentIdentity(left, right) {
667
+ return left.length === right.length && left.every((item, index) => item.path === right[index]?.path && sameIdentity(item, right[index]));
668
+ }
669
+ function boundedText(value, label, max) {
670
+ if (typeof value !== 'string')
671
+ throw new Error(`${label} must be a string`);
672
+ const text = value.trim();
673
+ if (!text || text.length > max)
674
+ throw new Error(`${label} must contain 1-${max} characters`);
675
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
676
+ throw new Error(`${label} contains control characters`);
677
+ return text;
678
+ }
679
+ function boundedDiagnostic(value) {
680
+ return String(value || '').replace(/[\r\n\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 1024);
681
+ }
682
+ function assertExactKeys(value, expected, label) {
683
+ if (!isObject(value))
684
+ throw new Error(`${label} must be an object`);
685
+ const actual = Object.keys(value).sort(compareStableText);
686
+ const wanted = [...expected].sort(compareStableText);
687
+ if (!sameStringArray(actual, wanted))
688
+ throw new Error(`${label} keys must be exactly: ${wanted.join(', ')}`);
689
+ }
690
+ function assertRecordKeys(record, expected, errors, label) {
691
+ const actual = Object.keys(record).sort(compareStableText);
692
+ const wanted = [...expected].sort(compareStableText);
693
+ if (!sameStringArray(actual, wanted))
694
+ errors.push(`${label}_keys_invalid`);
695
+ }
696
+ function assertUnique(values, label) {
697
+ if (new Set(values).size !== values.length)
698
+ throw new Error(`duplicate ${label}`);
699
+ }
700
+ function unique(values) {
701
+ return [...new Set(values)];
702
+ }
703
+ function sameStringArray(value, expected) {
704
+ return Array.isArray(value) && value.length === expected.length && value.every((item, index) => item === expected[index]);
705
+ }
706
+ function sameJson(left, right) {
707
+ return stableJson(left) === stableJson(right);
708
+ }
709
+ function isObject(value) {
710
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
711
+ }
712
+ function isIsoDate(value) {
713
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
714
+ }
715
+ function digest(value) {
716
+ return `sha256:${createHash('sha256').update(stableJson(value)).digest('hex')}`;
717
+ }
718
+ function stableJson(value) {
719
+ if (Array.isArray(value))
720
+ return `[${value.map((item) => stableJson(item)).join(',')}]`;
721
+ if (isObject(value)) {
722
+ return `{${Object.keys(value).sort(compareStableText).map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
723
+ }
724
+ return JSON.stringify(value);
725
+ }
726
+ function message(error) {
727
+ return error instanceof Error ? error.message : String(error);
728
+ }