gsdd-cli 0.19.3 → 0.21.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,849 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { isAbsolute, join, relative, resolve } from 'path';
3
+ import { output } from './cli-utils.mjs';
4
+ import { resolveWorkspaceContext } from './workspace-root.mjs';
5
+
6
+ const EVIDENCE_KINDS = Object.freeze(['code', 'test', 'runtime', 'delivery', 'human']);
7
+ const COMPARISON_STATUSES = Object.freeze(['satisfied', 'partial', 'missing', 'waived', 'deferred', 'not_applicable']);
8
+ const CLAIM_STATUSES = Object.freeze(['passed', 'failed', 'partial', 'waived', 'deferred', 'not_applicable']);
9
+ const ARTIFACT_VISIBILITIES = Object.freeze(['local_only', 'repo_tracked', 'public']);
10
+ const RAW_ARTIFACT_TYPES = Object.freeze(['screenshot', 'trace', 'video', 'dom_snapshot', 'dom-snapshot', 'dom', 'report']);
11
+ const PUBLIC_CLAIM_USES = Object.freeze(['public', 'publication', 'tracked', 'delivery', 'release']);
12
+ const CLAIM_USES = Object.freeze([...PUBLIC_CLAIM_USES, 'local', 'local_only']);
13
+ const REQUIRED_BUNDLE_FIELDS = Object.freeze([
14
+ 'proof_bundle_version',
15
+ 'scope',
16
+ 'route_state',
17
+ 'environment',
18
+ 'viewport',
19
+ 'evidence_inputs',
20
+ 'commands_or_manual_steps',
21
+ 'observations',
22
+ 'artifacts',
23
+ 'privacy',
24
+ 'result',
25
+ 'claim_limits',
26
+ ]);
27
+ const REQUIRED_SCOPE_FIELDS = Object.freeze(['work_item', 'claim', 'requirement_ids', 'slot_ids']);
28
+ const REQUIRED_ARTIFACT_FIELDS = Object.freeze(['visibility', 'retention', 'sensitivity', 'safe_to_publish']);
29
+ const REQUIRED_OBSERVATION_FIELDS = Object.freeze(['observation', 'claim', 'route_state', 'evidence_kind', 'artifact_refs', 'privacy', 'result', 'claim_limit']);
30
+ const REQUIRED_PRIVACY_FIELDS = Object.freeze(['data_classification', 'raw_artifacts_safe_to_publish', 'retention']);
31
+
32
+ class UiProofError extends Error {}
33
+
34
+ function fail(message) {
35
+ console.error(message);
36
+ throw new UiProofError(message);
37
+ }
38
+
39
+ function isPlainObject(value) {
40
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
41
+ }
42
+
43
+ function hasValue(value) {
44
+ if (value === undefined || value === null) return false;
45
+ if (typeof value === 'string') return value.trim() !== '';
46
+ if (Array.isArray(value)) return value.length > 0;
47
+ if (isPlainObject(value)) return Object.keys(value).length > 0;
48
+ return true;
49
+ }
50
+
51
+ function pathLabel(basePath, key) {
52
+ return basePath ? `${basePath}.${key}` : key;
53
+ }
54
+
55
+ function addError(errors, code, path, message, fix) {
56
+ errors.push({ code, path, message, fix });
57
+ }
58
+
59
+ function requireField(obj, field, path, errors) {
60
+ if (!isPlainObject(obj) || !hasValue(obj[field])) {
61
+ addError(errors, 'missing_required_field', pathLabel(path, field), `Missing required UI proof field: ${pathLabel(path, field)}`, 'Add the required field to the proof bundle metadata.');
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ function normalizeArray(value) {
68
+ if (Array.isArray(value)) return value;
69
+ if (typeof value === 'string' && value.trim()) return [value.trim()];
70
+ return [];
71
+ }
72
+
73
+ function artifactType(artifact) {
74
+ const explicit = typeof artifact.type === 'string' ? artifact.type.toLowerCase() : '';
75
+ const artifactPath = typeof artifact.path === 'string' ? artifact.path.toLowerCase() : '';
76
+ if (/screenshot|\.png$|\.jpe?g$|\.webp$/.test(artifactPath)) return 'screenshot';
77
+ if (/trace|\.zip$/.test(artifactPath)) return 'trace';
78
+ if (/video|\.mp4$|\.webm$|\.mov$/.test(artifactPath)) return 'video';
79
+ if (/dom|\.html?$/.test(artifactPath)) return 'dom_snapshot';
80
+ if (/report/.test(artifactPath)) return 'report';
81
+ return explicit;
82
+ }
83
+
84
+ function isRawUiArtifact(artifact) {
85
+ return RAW_ARTIFACT_TYPES.includes(artifactType(artifact));
86
+ }
87
+
88
+ function collectClaimUses(bundle, options) {
89
+ const uses = new Set();
90
+ for (const value of normalizeArray(options.claimUse).concat(normalizeArray(options.claimUses))) {
91
+ uses.add(String(value).toLowerCase());
92
+ }
93
+
94
+ const explicitSources = [
95
+ bundle?.proof_claim,
96
+ bundle?.proof_claims,
97
+ bundle?.claim_context?.proof_use,
98
+ bundle?.claim_context?.proof_uses,
99
+ bundle?.publication?.intended_use,
100
+ ];
101
+ for (const source of explicitSources) {
102
+ for (const value of normalizeArray(source)) uses.add(String(value).toLowerCase());
103
+ }
104
+
105
+ return [...uses];
106
+ }
107
+
108
+ function validateClaimUses(bundle, options, errors) {
109
+ for (const value of collectClaimUses(bundle, options)) {
110
+ if (!CLAIM_USES.includes(value)) {
111
+ addError(errors, 'unsupported_claim_use', 'proof_claim', `Unsupported UI proof claim use: ${value}`, `Use only: ${CLAIM_USES.join(', ')}.`);
112
+ }
113
+ }
114
+ }
115
+
116
+ function hasPublicClaim(bundle, options) {
117
+ return collectClaimUses(bundle, options).some((value) => PUBLIC_CLAIM_USES.includes(value));
118
+ }
119
+
120
+ function validateObservationPrivacy(privacy, path, errors) {
121
+ for (const field of REQUIRED_PRIVACY_FIELDS) requireField(privacy, field, path, errors);
122
+ if (hasValue(privacy?.raw_artifacts_safe_to_publish) && typeof privacy.raw_artifacts_safe_to_publish !== 'boolean') {
123
+ addError(errors, 'invalid_raw_artifacts_safe_to_publish', `${path}.raw_artifacts_safe_to_publish`, 'raw_artifacts_safe_to_publish must be a boolean.', 'Use false unless all raw artifacts are explicitly safe to publish.');
124
+ }
125
+ }
126
+
127
+ function validateCommandsOrManualSteps(bundle, errors) {
128
+ for (const [index, step] of normalizeArray(bundle?.commands_or_manual_steps).entries()) {
129
+ const stepPath = `commands_or_manual_steps[${index}]`;
130
+ if (!isPlainObject(step)) {
131
+ addError(errors, 'invalid_proof_step', stepPath, 'UI proof command/manual step entry must be an object.', 'Record a command or manual_step plus its result.');
132
+ continue;
133
+ }
134
+ if (!hasValue(step.command) && !hasValue(step.manual_step)) {
135
+ addError(errors, 'missing_proof_step_action', stepPath, 'UI proof command/manual step must include command or manual_step.', 'Record the exact command or manual step used to generate the observation.');
136
+ }
137
+ if (!hasValue(step.result)) {
138
+ addError(errors, 'missing_proof_step_result', `${stepPath}.result`, 'UI proof command/manual step must include result.', `Record result using: ${CLAIM_STATUSES.join(', ')}.`);
139
+ } else if (!CLAIM_STATUSES.includes(step.result)) {
140
+ addError(errors, 'invalid_proof_step_result', `${stepPath}.result`, `Invalid UI proof command/manual step result: ${step.result}`, `Use only: ${CLAIM_STATUSES.join(', ')}.`);
141
+ }
142
+ }
143
+ }
144
+
145
+ function validateObservations(bundle, errors) {
146
+ for (const [index, observation] of normalizeArray(bundle?.observations).entries()) {
147
+ const observationPath = `observations[${index}]`;
148
+ if (!isPlainObject(observation)) {
149
+ addError(errors, 'invalid_observation', observationPath, 'UI proof observation entry must be an object.', 'Record observation metadata with claim, route_state, evidence_kind, artifact_refs, privacy, result, and claim_limit.');
150
+ continue;
151
+ }
152
+ for (const field of REQUIRED_OBSERVATION_FIELDS) requireField(observation, field, observationPath, errors);
153
+ if (hasValue(observation.evidence_kind) && !EVIDENCE_KINDS.includes(observation.evidence_kind)) {
154
+ addError(errors, 'unsupported_evidence_kind', `${observationPath}.evidence_kind`, `Unsupported UI proof observation evidence kind: ${observation.evidence_kind}`, `Use only: ${EVIDENCE_KINDS.join(', ')}.`);
155
+ }
156
+ if (hasValue(observation.result) && !CLAIM_STATUSES.includes(observation.result)) {
157
+ addError(errors, 'invalid_observation_result', `${observationPath}.result`, `Invalid UI proof observation result: ${observation.result}`, `Use only: ${CLAIM_STATUSES.join(', ')}.`);
158
+ }
159
+ validateObservationPrivacy(observation.privacy, `${observationPath}.privacy`, errors);
160
+ }
161
+ }
162
+
163
+ function validateEvidenceKinds(bundle, errors) {
164
+ const kinds = normalizeArray(bundle?.evidence_inputs?.kinds);
165
+ if (kinds.length === 0) {
166
+ addError(errors, 'missing_evidence_kinds', 'evidence_inputs.kinds', 'Missing UI proof evidence kinds.', 'Record at least one fixed evidence kind: code, test, runtime, delivery, or human.');
167
+ }
168
+ for (const [index, kind] of kinds.entries()) {
169
+ if (!EVIDENCE_KINDS.includes(kind)) {
170
+ addError(errors, 'unsupported_evidence_kind', `evidence_inputs.kinds[${index}]`, `Unsupported UI proof evidence kind: ${kind}`, `Use only: ${EVIDENCE_KINDS.join(', ')}.`);
171
+ }
172
+ }
173
+ }
174
+
175
+ function validateResult(bundle, errors) {
176
+ if (!isPlainObject(bundle?.result)) return;
177
+ if (!hasValue(bundle.result.claim_status)) {
178
+ addError(errors, 'missing_claim_status', 'result.claim_status', 'Missing UI proof result claim status.', `Record claim_status using: ${CLAIM_STATUSES.join(', ')}.`);
179
+ } else if (!CLAIM_STATUSES.includes(bundle.result.claim_status)) {
180
+ addError(errors, 'invalid_claim_status', 'result.claim_status', `Invalid UI proof claim status: ${bundle.result.claim_status}`, `Use only: ${CLAIM_STATUSES.join(', ')}.`);
181
+ }
182
+ }
183
+
184
+ function validateComparisonStatuses(bundle, errors) {
185
+ const statuses = bundle?.result?.comparison_status_by_slot;
186
+ if (!isPlainObject(statuses)) {
187
+ addError(errors, 'missing_comparison_statuses', 'result.comparison_status_by_slot', 'Missing UI proof comparison statuses by slot.', `Record one status per slot using: ${COMPARISON_STATUSES.join(', ')}.`);
188
+ return;
189
+ }
190
+ const slotIds = normalizeArray(bundle?.scope?.slot_ids);
191
+ const slotSet = new Set(slotIds);
192
+ for (const slotId of slotIds) {
193
+ if (!hasValue(statuses[slotId])) {
194
+ addError(errors, 'missing_comparison_status', `result.comparison_status_by_slot.${slotId}`, `Missing UI proof comparison status for slot: ${slotId}`, `Record one status per slot using: ${COMPARISON_STATUSES.join(', ')}.`);
195
+ }
196
+ }
197
+ for (const [slot, status] of Object.entries(statuses)) {
198
+ if (slotSet.size > 0 && !slotSet.has(slot)) {
199
+ addError(errors, 'unknown_comparison_slot', `result.comparison_status_by_slot.${slot}`, `UI proof comparison status references undeclared slot: ${slot}`, 'Use only slot IDs declared in scope.slot_ids.');
200
+ }
201
+ if (!COMPARISON_STATUSES.includes(status)) {
202
+ addError(errors, 'invalid_comparison_status', `result.comparison_status_by_slot.${slot}`, `Invalid UI proof comparison status: ${status}`, `Use only: ${COMPARISON_STATUSES.join(', ')}.`);
203
+ }
204
+ }
205
+ }
206
+
207
+ function validateClaimLimits(bundle, errors) {
208
+ const claimLimits = normalizeArray(bundle?.claim_limits);
209
+ if (claimLimits.length === 0) {
210
+ addError(errors, 'missing_claim_limits', 'claim_limits', 'Missing UI proof claim limits.', 'Add at least one claim limit that narrows what this proof does not prove.');
211
+ }
212
+ }
213
+
214
+ function artifactReference(artifact) {
215
+ if (!isPlainObject(artifact)) return null;
216
+ if (typeof artifact.path === 'string' && artifact.path.trim()) return artifact.path.trim();
217
+ if (typeof artifact.url === 'string' && artifact.url.trim()) return artifact.url.trim();
218
+ return null;
219
+ }
220
+
221
+ function validateArtifactReferenceSafety(ref, path, errors) {
222
+ if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) {
223
+ if (!/^https?:\/\//i.test(ref)) {
224
+ addError(errors, 'invalid_artifact_ref_location', path, `UI proof artifact reference uses an unsupported URL scheme: ${ref}`, 'Use a workspace-relative path or an http(s) URL; do not reference local file URLs.');
225
+ }
226
+ return;
227
+ }
228
+ if (ref.startsWith('//') || isAbsolute(ref) || ref.split(/[\\/]+/).includes('..')) {
229
+ addError(errors, 'invalid_artifact_ref_location', path, `UI proof artifact reference must stay workspace-relative: ${ref}`, 'Use a relative path under the workspace, or an http(s) URL for external sanitized evidence.');
230
+ }
231
+ }
232
+
233
+ function isSanitizedSensitivity(value) {
234
+ return typeof value === 'string' && /(^|[_\s-])(sanitized|public_safe|public-safe)($|[_\s-])/.test(value.toLowerCase());
235
+ }
236
+
237
+ function validateArtifacts(bundle, errors, publicClaim) {
238
+ const artifacts = normalizeArray(bundle?.artifacts);
239
+ if (artifacts.length === 0) {
240
+ addError(errors, 'missing_artifacts', 'artifacts', 'Missing UI proof artifacts list.', 'Record artifact metadata for each referenced proof artifact.');
241
+ return new Set();
242
+ }
243
+
244
+ const artifactRefs = new Set();
245
+ for (const [index, artifact] of artifacts.entries()) {
246
+ const artifactPath = `artifacts[${index}]`;
247
+ if (!isPlainObject(artifact)) {
248
+ addError(errors, 'invalid_artifact', artifactPath, 'UI proof artifact entry must be an object.', 'Record path/type plus privacy metadata for each artifact.');
249
+ continue;
250
+ }
251
+ const ref = artifactReference(artifact);
252
+ if (!ref) {
253
+ addError(errors, 'missing_artifact_ref', artifactPath, 'UI proof artifact must include path or url.', 'Reference raw UI artifacts by path or URL; do not inline them.');
254
+ } else {
255
+ validateArtifactReferenceSafety(ref, artifactPath, errors);
256
+ artifactRefs.add(ref);
257
+ }
258
+ for (const field of REQUIRED_ARTIFACT_FIELDS) {
259
+ requireField(artifact, field, artifactPath, errors);
260
+ }
261
+ if (hasValue(artifact.visibility) && !ARTIFACT_VISIBILITIES.includes(artifact.visibility)) {
262
+ addError(errors, 'invalid_visibility', `${artifactPath}.visibility`, `Invalid UI proof artifact visibility: ${artifact.visibility}`, `Use only: ${ARTIFACT_VISIBILITIES.join(', ')}.`);
263
+ }
264
+ if (hasValue(artifact.safe_to_publish) && typeof artifact.safe_to_publish !== 'boolean') {
265
+ addError(errors, 'invalid_safe_to_publish', `${artifactPath}.safe_to_publish`, 'safe_to_publish must be a boolean.', 'Use true only after explicit safe-to-publish classification; otherwise use false.');
266
+ }
267
+ if (isRawUiArtifact(artifact) && artifact.visibility !== 'local_only' && artifact.safe_to_publish !== true) {
268
+ addError(errors, 'unsafe_raw_artifact', artifactPath, 'Raw UI artifacts are local-only by default unless explicitly classified safe to publish.', 'Set visibility: local_only and safe_to_publish: false, or document sanitized public-safe classification.');
269
+ }
270
+ if (publicClaim && (artifact.visibility === 'local_only' || artifact.safe_to_publish !== true)) {
271
+ addError(errors, 'unsafe_public_proof_claim', artifactPath, 'Public/tracked/delivery UI proof claims cannot rely on local-only or unsafe artifacts.', 'Use local-only claim language, or provide sanitized artifacts with safe_to_publish: true and non-local visibility.');
272
+ }
273
+ if (publicClaim && isRawUiArtifact(artifact) && !isSanitizedSensitivity(artifact.sensitivity)) {
274
+ addError(errors, 'unsafe_public_artifact_sensitivity', `${artifactPath}.sensitivity`, 'Public/tracked/delivery raw UI proof artifacts must be classified sanitized.', 'Set sensitivity to a sanitized/public-safe classification after explicit review, or narrow the proof claim.');
275
+ }
276
+ }
277
+ return artifactRefs;
278
+ }
279
+
280
+ function validatePrivacy(bundle, errors, publicClaim) {
281
+ validateObservationPrivacy(bundle.privacy, 'privacy', errors);
282
+ if (publicClaim && bundle.privacy?.raw_artifacts_safe_to_publish !== true) {
283
+ addError(errors, 'unsafe_public_proof_privacy', 'privacy.raw_artifacts_safe_to_publish', 'Public/tracked/delivery UI proof claims require bundle privacy metadata to classify raw artifacts safe to publish.', 'Use local-only claim language, or set raw_artifacts_safe_to_publish: true after sanitized/public-safe review.');
284
+ }
285
+ }
286
+
287
+ function validatePublicObservationPrivacy(bundle, errors, publicClaim) {
288
+ if (!publicClaim) return;
289
+ for (const [index, observation] of normalizeArray(bundle?.observations).entries()) {
290
+ if (!isPlainObject(observation)) continue;
291
+ if (observation.privacy?.raw_artifacts_safe_to_publish !== true) {
292
+ addError(errors, 'unsafe_public_observation_privacy', `observations[${index}].privacy.raw_artifacts_safe_to_publish`, 'Public/tracked/delivery UI proof claims require observation privacy metadata to classify raw artifacts safe to publish.', 'Use local-only claim language, or set raw_artifacts_safe_to_publish: true after sanitized/public-safe review.');
293
+ }
294
+ }
295
+ }
296
+
297
+ function validateObservationArtifactRefs(bundle, artifactRefs, errors) {
298
+ for (const [index, observation] of normalizeArray(bundle?.observations).entries()) {
299
+ if (!isPlainObject(observation)) continue;
300
+ for (const [refIndex, ref] of normalizeArray(observation.artifact_refs).entries()) {
301
+ if (!artifactRefs.has(ref)) {
302
+ addError(errors, 'unknown_artifact_ref', `observations[${index}].artifact_refs[${refIndex}]`, `Observation references undeclared UI proof artifact: ${ref}`, 'Add the artifact to artifacts[] or correct the observation artifact reference.');
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ function stableString(value) {
309
+ return JSON.stringify(canonicalize(value));
310
+ }
311
+
312
+ function canonicalize(value) {
313
+ if (Array.isArray(value)) return value.map(canonicalize);
314
+ if (!isPlainObject(value)) return value;
315
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
316
+ }
317
+
318
+ function valuesMatch(planned, observed) {
319
+ if (!hasValue(planned)) return true;
320
+ if (!hasValue(observed)) return false;
321
+ return stableString(planned) === stableString(observed);
322
+ }
323
+
324
+ function slotId(slot, index) {
325
+ return slot?.slot_id || slot?.slotId || slot?.id || `ui-proof-slot-${index + 1}`;
326
+ }
327
+
328
+ function observationText(observation) {
329
+ if (typeof observation === 'string') return observation;
330
+ if (isPlainObject(observation) && typeof observation.observation === 'string') return observation.observation;
331
+ return '';
332
+ }
333
+
334
+ function includesObservation(observations, expected) {
335
+ const expectedText = typeof expected === 'string' ? expected.trim() : observationText(expected).trim();
336
+ if (!expectedText) return true;
337
+ return observations.some((observation) => observationText(observation).includes(expectedText));
338
+ }
339
+
340
+ function normalizeObservedBundle(entry) {
341
+ if (entry?.bundle) {
342
+ return {
343
+ bundle: entry.bundle,
344
+ validation: entry.validation || validateUiProofBundle(entry.bundle, entry.options || {}),
345
+ source: entry.source || entry.filePath || 'observed bundle',
346
+ };
347
+ }
348
+ return {
349
+ bundle: entry,
350
+ validation: validateUiProofBundle(entry),
351
+ source: 'observed bundle',
352
+ };
353
+ }
354
+
355
+ function compareSlotToBundle(slot, slotIdValue, observed) {
356
+ const issues = [];
357
+ const bundle = observed.bundle;
358
+ const observations = normalizeArray(bundle?.observations);
359
+ if (!observed.validation.valid) {
360
+ issues.push({
361
+ code: 'invalid_observed_bundle',
362
+ path: observed.source,
363
+ message: `Observed UI proof bundle for slot ${slotIdValue} failed metadata validation.`,
364
+ details: observed.validation.errors,
365
+ });
366
+ }
367
+
368
+ const bundleStatus = bundle?.result?.comparison_status_by_slot?.[slotIdValue];
369
+ if (bundle?.result?.claim_status !== 'passed') {
370
+ issues.push({
371
+ code: 'unsatisfied_observed_claim_status',
372
+ path: 'result.claim_status',
373
+ message: `Observed UI proof bundle claim status is ${bundle?.result?.claim_status || 'missing'} for slot ${slotIdValue}.`,
374
+ });
375
+ }
376
+ if (bundleStatus !== 'satisfied') {
377
+ issues.push({
378
+ code: 'unsatisfied_observed_comparison_status',
379
+ path: `result.comparison_status_by_slot.${slotIdValue}`,
380
+ message: `Observed UI proof bundle reports ${bundleStatus || 'missing'} for slot ${slotIdValue}.`,
381
+ });
382
+ }
383
+
384
+ const requiredKinds = normalizeArray(slot?.required_evidence_kinds || slot?.requiredEvidenceKinds);
385
+ const observedKinds = normalizeArray(bundle?.evidence_inputs?.kinds);
386
+ const missingKinds = requiredKinds.filter((kind) => !observedKinds.includes(kind));
387
+ if (missingKinds.length > 0) {
388
+ issues.push({
389
+ code: 'missing_required_evidence_kind',
390
+ path: 'evidence_inputs.kinds',
391
+ message: `Observed UI proof for slot ${slotIdValue} is missing required evidence kind(s): ${missingKinds.join(', ')}.`,
392
+ });
393
+ }
394
+ const missingNonHuman = missingKinds.filter((kind) => kind !== 'human');
395
+ if (missingNonHuman.length > 0 && observedKinds.includes('human')) {
396
+ issues.push({
397
+ code: 'human_evidence_cannot_bypass_required_non_human_evidence',
398
+ path: 'evidence_inputs.kinds',
399
+ message: `Human evidence cannot satisfy missing non-human UI proof evidence for slot ${slotIdValue}: ${missingNonHuman.join(', ')}.`,
400
+ });
401
+ }
402
+
403
+ if (!valuesMatch(slot?.route_state || slot?.routeState, bundle?.route_state)) {
404
+ issues.push({
405
+ code: 'route_state_mismatch',
406
+ path: 'route_state',
407
+ message: `Observed UI proof route/state does not match planned slot ${slotIdValue}.`,
408
+ });
409
+ }
410
+
411
+ if (!valuesMatch(slot?.environment, bundle?.environment)) {
412
+ issues.push({
413
+ code: 'environment_mismatch',
414
+ path: 'environment',
415
+ message: `Observed UI proof environment does not match planned slot ${slotIdValue}.`,
416
+ });
417
+ }
418
+
419
+ if (!valuesMatch(slot?.viewport, bundle?.viewport)) {
420
+ issues.push({
421
+ code: 'viewport_mismatch',
422
+ path: 'viewport',
423
+ message: `Observed UI proof viewport does not match planned slot ${slotIdValue}.`,
424
+ });
425
+ }
426
+
427
+ const requirementId = slot?.requirement_id || slot?.requirementId;
428
+ if (hasValue(requirementId) && !normalizeArray(bundle?.scope?.requirement_ids).includes(requirementId)) {
429
+ issues.push({
430
+ code: 'requirement_mismatch',
431
+ path: 'scope.requirement_ids',
432
+ message: `Observed UI proof bundle does not declare planned requirement ${requirementId} for slot ${slotIdValue}.`,
433
+ });
434
+ }
435
+
436
+ if (hasValue(slot?.claim) && bundle?.scope?.claim !== slot.claim) {
437
+ issues.push({
438
+ code: 'claim_mismatch',
439
+ path: 'scope.claim',
440
+ message: `Observed UI proof bundle claim does not match planned slot ${slotIdValue}.`,
441
+ });
442
+ }
443
+
444
+ if (hasValue(slot?.claim) && !observations.some((observation) => observation?.claim === slot.claim)) {
445
+ issues.push({
446
+ code: 'observation_claim_mismatch',
447
+ path: 'observations[].claim',
448
+ message: `Observed UI proof observations do not support the exact planned claim for slot ${slotIdValue}.`,
449
+ });
450
+ }
451
+
452
+ const supportingObservations = observations
453
+ .map((observation, index) => ({ observation, index }))
454
+ .filter(({ observation }) => !hasValue(slot?.claim) || observation?.claim === slot.claim);
455
+
456
+ if (hasValue(slot?.route_state || slot?.routeState)) {
457
+ for (const { observation, index } of supportingObservations) {
458
+ if (!valuesMatch(slot?.route_state || slot?.routeState, observation?.route_state)) {
459
+ issues.push({
460
+ code: 'observation_route_state_mismatch',
461
+ path: `observations[${index}].route_state`,
462
+ message: `Observed UI proof observation route/state does not match planned slot ${slotIdValue}.`,
463
+ });
464
+ }
465
+ }
466
+ }
467
+
468
+ const passedSupportingKinds = new Set(
469
+ supportingObservations
470
+ .filter(({ observation }) => observation?.result === 'passed')
471
+ .map(({ observation }) => observation?.evidence_kind)
472
+ .filter(Boolean)
473
+ );
474
+ const missingSupportingKinds = requiredKinds.filter((kind) => !passedSupportingKinds.has(kind));
475
+ if (missingSupportingKinds.length > 0) {
476
+ issues.push({
477
+ code: 'missing_supporting_observation_evidence_kind',
478
+ path: 'observations[].evidence_kind',
479
+ message: `Observed UI proof for slot ${slotIdValue} lacks passed supporting observation(s) for required evidence kind(s): ${missingSupportingKinds.join(', ')}.`,
480
+ });
481
+ }
482
+
483
+ for (const [index, step] of normalizeArray(bundle?.commands_or_manual_steps).entries()) {
484
+ if (step?.result !== 'passed') {
485
+ issues.push({
486
+ code: 'unsatisfied_proof_step',
487
+ path: `commands_or_manual_steps[${index}].result`,
488
+ message: `Observed UI proof command/manual step is ${step?.result || 'missing'} for slot ${slotIdValue}.`,
489
+ });
490
+ }
491
+ }
492
+
493
+ const manualAcceptanceRequired = slot?.manual_acceptance_required === true || slot?.manualAcceptanceRequired === true;
494
+ if (manualAcceptanceRequired) {
495
+ if (!observedKinds.includes('human')) {
496
+ issues.push({
497
+ code: 'missing_manual_acceptance_evidence',
498
+ path: 'evidence_inputs.kinds',
499
+ message: `Observed UI proof for slot ${slotIdValue} is missing required human evidence for manual acceptance.`,
500
+ });
501
+ }
502
+ if (!passedSupportingKinds.has('human')) {
503
+ issues.push({
504
+ code: 'missing_manual_acceptance_observation',
505
+ path: 'observations[].evidence_kind',
506
+ message: `Observed UI proof for slot ${slotIdValue} lacks a passed human observation for manual acceptance.`,
507
+ });
508
+ }
509
+ }
510
+
511
+ for (const { observation, index } of supportingObservations) {
512
+ if (observation?.result !== 'passed') {
513
+ issues.push({
514
+ code: 'unsatisfied_observation_result',
515
+ path: `observations[${index}].result`,
516
+ message: `Observed UI proof observation is ${observation?.result || 'missing'} for slot ${slotIdValue}.`,
517
+ });
518
+ }
519
+ }
520
+
521
+ for (const expected of normalizeArray(slot?.minimum_observations || slot?.minimumObservations)) {
522
+ if (!includesObservation(supportingObservations.map(({ observation }) => observation), expected)) {
523
+ issues.push({
524
+ code: 'missing_minimum_observation',
525
+ path: 'observations',
526
+ message: `Observed UI proof for slot ${slotIdValue} is missing a planned minimum observation.`,
527
+ });
528
+ }
529
+ }
530
+
531
+ if (hasValue(slot?.claim_limit || slot?.claimLimit)) {
532
+ const claimLimit = slot.claim_limit || slot.claimLimit;
533
+ if (!normalizeArray(bundle?.claim_limits).includes(claimLimit)) {
534
+ issues.push({
535
+ code: 'missing_claim_limit',
536
+ path: 'claim_limits',
537
+ message: `Observed UI proof for slot ${slotIdValue} does not preserve the planned claim limit.`,
538
+ });
539
+ }
540
+ }
541
+
542
+ const status = issues.length === 0 ? 'satisfied' : (bundleStatus === 'missing' ? 'missing' : 'partial');
543
+ return { status, issues, source: observed.source };
544
+ }
545
+
546
+ export function compareUiProofSlots(plannedSlots, observedBundles) {
547
+ const slots = normalizeArray(plannedSlots);
548
+ const bundles = normalizeArray(observedBundles).map(normalizeObservedBundle);
549
+ const results = [];
550
+ const errors = [];
551
+
552
+ for (const observed of bundles) {
553
+ if (!observed.validation.valid) {
554
+ errors.push({
555
+ code: 'invalid_observed_bundle',
556
+ path: observed.source,
557
+ message: `Observed UI proof bundle ${observed.source} failed metadata validation.`,
558
+ details: observed.validation.errors,
559
+ });
560
+ }
561
+ }
562
+
563
+ for (const [index, slot] of slots.entries()) {
564
+ const slotIdValue = slotId(slot, index);
565
+ const matchingBundles = bundles.filter((observed) => normalizeArray(observed.bundle?.scope?.slot_ids).includes(slotIdValue));
566
+ if (matchingBundles.length === 0) {
567
+ results.push({
568
+ slot_id: slotIdValue,
569
+ status: 'missing',
570
+ issues: [{
571
+ code: 'missing_observed_bundle',
572
+ path: 'scope.slot_ids',
573
+ message: `No observed UI proof bundle declares planned slot ${slotIdValue}.`,
574
+ }],
575
+ });
576
+ continue;
577
+ }
578
+
579
+ const candidates = matchingBundles.map((observed) => compareSlotToBundle(slot, slotIdValue, observed));
580
+ const satisfied = candidates.find((candidate) => candidate.status === 'satisfied');
581
+ if (satisfied) {
582
+ results.push({ slot_id: slotIdValue, status: 'satisfied', issues: [], source: satisfied.source });
583
+ continue;
584
+ }
585
+ const partial = candidates.find((candidate) => candidate.status === 'partial') || candidates[0];
586
+ results.push({ slot_id: slotIdValue, status: partial.status, issues: partial.issues, source: partial.source });
587
+ }
588
+
589
+ const statuses = results.map((result) => result.status);
590
+ const status = errors.length > 0
591
+ ? 'partial'
592
+ : statuses.length === 0
593
+ ? 'not_applicable'
594
+ : statuses.every((value) => value === 'satisfied')
595
+ ? 'satisfied'
596
+ : statuses.every((value) => value === 'missing')
597
+ ? 'missing'
598
+ : 'partial';
599
+
600
+ return { status, slots: results, errors };
601
+ }
602
+
603
+ export function validateUiProofBundle(bundle, options = {}) {
604
+ const errors = [];
605
+ const warnings = [];
606
+
607
+ if (!isPlainObject(bundle)) {
608
+ addError(errors, 'invalid_bundle', '', 'UI proof bundle must be an object.', 'Provide structured UI proof metadata.');
609
+ return { valid: false, errors, warnings };
610
+ }
611
+
612
+ for (const field of REQUIRED_BUNDLE_FIELDS) requireField(bundle, field, '', errors);
613
+ for (const field of REQUIRED_SCOPE_FIELDS) requireField(bundle.scope, field, 'scope', errors);
614
+ const publicClaim = hasPublicClaim(bundle, options);
615
+ validateClaimUses(bundle, options, errors);
616
+ validateEvidenceKinds(bundle, errors);
617
+ validateCommandsOrManualSteps(bundle, errors);
618
+ validateObservations(bundle, errors);
619
+ validateResult(bundle, errors);
620
+ validateComparisonStatuses(bundle, errors);
621
+ validateClaimLimits(bundle, errors);
622
+ validatePrivacy(bundle, errors, publicClaim);
623
+ validatePublicObservationPrivacy(bundle, errors, publicClaim);
624
+ const artifactRefs = validateArtifacts(bundle, errors, publicClaim);
625
+ validateObservationArtifactRefs(bundle, artifactRefs, errors);
626
+
627
+ return { valid: errors.length === 0, errors, warnings };
628
+ }
629
+
630
+ function parseJsonOrFencedContent(content, filePath, label) {
631
+ const trimmed = content.trim();
632
+ if (!trimmed) {
633
+ return { value: null, errors: [{ code: 'empty_file', path: filePath, message: `${label} file is empty.`, fix: 'Write JSON metadata before validating.' }] };
634
+ }
635
+
636
+ const jsonCandidates = [trimmed];
637
+ const fenceMatches = [...trimmed.matchAll(/```(?:json|ui-proof-json)?\s*([\s\S]*?)```/gi)];
638
+ for (const match of fenceMatches) jsonCandidates.push(match[1].trim());
639
+
640
+ for (const candidate of jsonCandidates) {
641
+ try {
642
+ return { value: JSON.parse(candidate), errors: [] };
643
+ } catch {
644
+ // Try next candidate; final error is reported below.
645
+ }
646
+ }
647
+
648
+ return {
649
+ value: null,
650
+ errors: [{ code: 'unparseable_json', path: filePath, message: `${label} metadata is not valid JSON.`, fix: 'Use a .json file or a markdown fenced JSON block; no YAML parser dependency is installed.' }],
651
+ };
652
+ }
653
+
654
+ export function parseUiProofBundleContent(content, filePath = 'UI proof bundle') {
655
+ const parsed = parseJsonOrFencedContent(content, filePath, 'UI proof bundle');
656
+ return { bundle: parsed.value, errors: parsed.errors.map((error) => ({
657
+ ...error,
658
+ code: error.code === 'empty_file' ? 'empty_bundle_file' : error.code === 'unparseable_json' ? 'unparseable_bundle' : error.code,
659
+ message: error.code === 'empty_file'
660
+ ? 'UI proof bundle file is empty.'
661
+ : error.code === 'unparseable_json'
662
+ ? 'UI proof bundle metadata is not valid JSON.'
663
+ : error.message,
664
+ fix: error.code === 'empty_file'
665
+ ? 'Write JSON UI proof metadata before validating.'
666
+ : error.fix,
667
+ })) };
668
+ }
669
+
670
+ export function parseUiProofSlotsContent(content, filePath = 'UI proof slots') {
671
+ const parsed = parseJsonOrFencedContent(content, filePath, 'UI proof slots');
672
+ if (parsed.errors.length > 0) return { slots: [], errors: parsed.errors };
673
+
674
+ const value = parsed.value;
675
+ const slots = Array.isArray(value)
676
+ ? value
677
+ : normalizeArray(value?.ui_proof_slots || value?.uiProofSlots || value?.planned_slots || value?.plannedSlots);
678
+
679
+ if (slots.length === 0) {
680
+ return {
681
+ slots: [],
682
+ errors: [{
683
+ code: 'missing_planned_slots',
684
+ path: filePath,
685
+ message: 'Planned UI proof input must be an array or contain ui_proof_slots.',
686
+ fix: 'Provide JSON with an array of planned slots or an object with ui_proof_slots.',
687
+ }],
688
+ };
689
+ }
690
+
691
+ return { slots, errors: [] };
692
+ }
693
+
694
+ export function readUiProofBundleFile(filePath) {
695
+ return parseUiProofBundleContent(readFileSync(filePath, 'utf-8'), filePath);
696
+ }
697
+
698
+ function walkForUiProofFiles(dir, results) {
699
+ if (!existsSync(dir)) return;
700
+ for (const entry of readdirSync(dir)) {
701
+ const fullPath = join(dir, entry);
702
+ const stat = statSync(fullPath);
703
+ if (stat.isDirectory()) {
704
+ walkForUiProofFiles(fullPath, results);
705
+ continue;
706
+ }
707
+ const name = entry.toLowerCase();
708
+ if (['ui-proof.json', 'ui-proof.md', 'proof-bundle.json'].includes(name)) {
709
+ results.add(fullPath);
710
+ }
711
+ }
712
+ }
713
+
714
+ export function findUiProofBundleFiles(planningDir) {
715
+ const results = new Set();
716
+ for (const relativePath of [
717
+ 'UI-PROOF.json',
718
+ 'ui-proof.json',
719
+ 'ui-proof.md',
720
+ 'ui-proof/UI-PROOF.json',
721
+ 'ui-proof/proof-bundle.json',
722
+ 'brownfield-change/UI-PROOF.json',
723
+ ]) {
724
+ const fullPath = join(planningDir, relativePath);
725
+ if (existsSync(fullPath)) results.add(fullPath);
726
+ }
727
+ for (const relativeDir of ['phases', 'quick', 'brownfield-change']) {
728
+ walkForUiProofFiles(join(planningDir, relativeDir), results);
729
+ }
730
+ return [...results].sort();
731
+ }
732
+
733
+ function resolveWorkspacePath(cwd, target) {
734
+ const workspaceRoot = resolve(cwd);
735
+ const resolved = resolve(workspaceRoot, target);
736
+ const rel = relative(workspaceRoot, resolved);
737
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return resolved;
738
+ fail(`Path must stay inside the workspace: ${target}`);
739
+ }
740
+
741
+ function parseClaimUse(args) {
742
+ const values = [];
743
+ for (let index = 0; index < args.length; index += 1) {
744
+ const arg = args[index];
745
+ if (arg !== '--claim') fail('Usage: gsdd ui-proof validate <path> [--claim <public|publication|tracked|delivery|release>]');
746
+ const value = args[index + 1];
747
+ if (!value || value.startsWith('--')) fail('Usage: gsdd ui-proof validate <path> [--claim <public|publication|tracked|delivery|release>]');
748
+ values.push(...value.split(',').map((entry) => entry.trim()).filter(Boolean));
749
+ index += 1;
750
+ }
751
+ for (const value of values) {
752
+ if (!PUBLIC_CLAIM_USES.includes(value)) fail(`Unsupported UI proof claim use: ${value}`);
753
+ }
754
+ return values;
755
+ }
756
+
757
+ function cmdValidate(cwd, args) {
758
+ const [targetArg, ...flags] = args;
759
+ if (!targetArg) fail('Usage: gsdd ui-proof validate <path> [--claim <public|publication|tracked|delivery|release>]');
760
+ const target = resolveWorkspacePath(cwd, targetArg);
761
+ if (!existsSync(target) || statSync(target).isDirectory()) fail(`UI proof bundle file does not exist: ${targetArg}`);
762
+
763
+ const parsed = readUiProofBundleFile(target);
764
+ const validation = parsed.errors.length > 0
765
+ ? { valid: false, errors: parsed.errors, warnings: [] }
766
+ : validateUiProofBundle(parsed.bundle, { claimUses: parseClaimUse(flags) });
767
+
768
+ output({ operation: 'ui-proof validate', target: targetArg, valid: validation.valid, errors: validation.errors, warnings: validation.warnings });
769
+ if (!validation.valid) process.exitCode = 1;
770
+ }
771
+
772
+ function cmdCompare(cwd, args) {
773
+ const [plannedArg, ...observedArgs] = args;
774
+ if (!plannedArg) fail('Usage: gsdd ui-proof compare <planned-slots-json> [observed-bundle-json ...]');
775
+
776
+ const plannedPath = resolveWorkspacePath(cwd, plannedArg);
777
+ if (!existsSync(plannedPath) || statSync(plannedPath).isDirectory()) fail(`Planned UI proof slots file does not exist: ${plannedArg}`);
778
+
779
+ const planned = parseUiProofSlotsContent(readFileSync(plannedPath, 'utf-8'), plannedArg);
780
+ const observedBundles = [];
781
+ const observedTargets = [];
782
+ const observedErrors = [];
783
+
784
+ for (const observedArg of observedArgs) {
785
+ const observedPath = resolveWorkspacePath(cwd, observedArg);
786
+ if (!existsSync(observedPath) || statSync(observedPath).isDirectory()) fail(`Observed UI proof bundle file does not exist: ${observedArg}`);
787
+ const parsed = readUiProofBundleFile(observedPath);
788
+ if (parsed.errors.length > 0) {
789
+ observedErrors.push(...parsed.errors.map((error) => ({ ...error, path: observedArg })));
790
+ observedBundles.push({
791
+ bundle: {},
792
+ validation: { valid: false, errors: parsed.errors, warnings: [] },
793
+ source: observedArg,
794
+ });
795
+ } else {
796
+ observedBundles.push({ bundle: parsed.bundle, source: observedArg });
797
+ }
798
+ observedTargets.push(observedArg);
799
+ }
800
+
801
+ const comparison = planned.errors.length > 0
802
+ ? { status: 'missing', slots: [], errors: planned.errors }
803
+ : compareUiProofSlots(planned.slots, observedBundles);
804
+
805
+ output({
806
+ operation: 'ui-proof compare',
807
+ planned: plannedArg,
808
+ observed: observedTargets,
809
+ status: comparison.status,
810
+ slots: comparison.slots,
811
+ errors: [...(comparison.errors || []), ...observedErrors],
812
+ });
813
+ if (!['satisfied', 'not_applicable'].includes(comparison.status)) process.exitCode = 1;
814
+ }
815
+
816
+ export function cmdUiProof(...args) {
817
+ const { args: normalizedArgs, workspaceRoot, invalid, error } = resolveWorkspaceContext(args);
818
+ if (invalid) {
819
+ console.error(error);
820
+ process.exitCode = 1;
821
+ return;
822
+ }
823
+ const [operation, ...rest] = normalizedArgs;
824
+ try {
825
+ switch (operation) {
826
+ case 'validate':
827
+ cmdValidate(workspaceRoot, rest);
828
+ return;
829
+ case 'compare':
830
+ cmdCompare(workspaceRoot, rest);
831
+ return;
832
+ default:
833
+ fail('Usage: gsdd ui-proof <validate|compare> ...');
834
+ }
835
+ } catch (error) {
836
+ if (error instanceof UiProofError) {
837
+ process.exitCode = 1;
838
+ return;
839
+ }
840
+ throw error;
841
+ }
842
+ }
843
+
844
+ export {
845
+ ARTIFACT_VISIBILITIES as UI_PROOF_ARTIFACT_VISIBILITIES,
846
+ COMPARISON_STATUSES as UI_PROOF_COMPARISON_STATUSES,
847
+ EVIDENCE_KINDS as UI_PROOF_EVIDENCE_KINDS,
848
+ RAW_ARTIFACT_TYPES as UI_PROOF_RAW_ARTIFACT_TYPES,
849
+ };