archctx-contracts 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/fixtures/invalid/recommendation-v3-structural-before-evidence.json +59 -0
  2. package/fixtures/valid/archctx-capabilities.json +3 -3
  3. package/fixtures/valid/product-version-manifest.json +6 -6
  4. package/fixtures/valid/projection-manifest.json +175 -0
  5. package/fixtures/valid/projection-result.json +5 -5
  6. package/fixtures/valid/recommendation-v3.json +69 -0
  7. package/fixtures/valid/runtime-recommendation-show.json +312 -0
  8. package/fixtures/valid/runtime-refactor-scan.json +542 -0
  9. package/package.json +1 -1
  10. package/schemas/runtime/archctx-capabilities.schema.json +7 -2
  11. package/schemas/runtime/architecture-refresh-signal.schema.json +27 -0
  12. package/schemas/runtime/changeset.schema.json +42 -16
  13. package/schemas/runtime/projection-apply-readback.schema.json +351 -10
  14. package/schemas/runtime/projection-apply-recovery.schema.json +24 -3
  15. package/schemas/runtime/projection-manifest.schema.json +491 -0
  16. package/schemas/runtime/projection-request.schema.json +11 -2
  17. package/schemas/runtime/projection-result.schema.json +108 -8
  18. package/schemas/runtime/recommendation-v3.schema.json +1166 -0
  19. package/schemas/runtime/runtime-recommendation-show.schema.json +2449 -0
  20. package/schemas/runtime/runtime-refactor-scan.schema.json +2489 -0
  21. package/schemas/runtime/structural-observation-payload.schema.json +578 -0
  22. package/src/index.ts +1 -0
  23. package/src/ledger.ts +26 -10
  24. package/src/product-version.ts +2 -2
  25. package/src/projection-manifest.ts +346 -0
  26. package/src/projection.ts +338 -6
  27. package/src/refactor.ts +268 -1
  28. package/src/schema.ts +20 -4
  29. package/src/validator.ts +87 -15
@@ -0,0 +1,346 @@
1
+ import type { ProjectionTargetFormat, ProjectionTargetOwnership, ProjectionTargetScopeKind, ProjectionTargetType } from "./ledger";
2
+ import {
3
+ ARCHITECTURE_DOCS_RENDERER_VERSION,
4
+ ARCHITECTURE_SEMANTIC_FACETS,
5
+ isArchitectureCapabilityProofStatus,
6
+ type ArchitectureCapabilityProofStatusV1,
7
+ type ArchitectureDigestSetV1,
8
+ type ArchitectureSemanticFacet,
9
+ type Sha256Digest
10
+ } from "./projection";
11
+ import { isRepoRelativePosixPath } from "./schema";
12
+
13
+ /**
14
+ * The committed projection manifest, `docs/architecture/.projection-manifest.json` (#264). The
15
+ * projection engine writes it and reads its semantic baseline and provenance back; this module is
16
+ * the only definition of its shape.
17
+ *
18
+ * `schemaVersion` is `v2` from 0.7.0: content-stamp targets (`sourceFootprintDigest`, `scale`) and
19
+ * provenance v3. A `v1` manifest (renderer v4) is not valid here, but the projection still reads its
20
+ * semantic baseline and plans a rewrite of the manifest and every generated document.
21
+ */
22
+ export const ARCHITECTURE_DOCS_PROJECTION_MANIFEST_PATH = "docs/architecture/.projection-manifest.json" as const;
23
+ export const ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION = "archcontext.architecture-docs-projection-manifest/v2" as const;
24
+ export const ARCHITECTURE_DOCS_PROJECTION_PROVENANCE_SCHEMA_VERSION = "archcontext.architecture-docs-projection-provenance/v3" as const;
25
+ export const ARCHITECTURE_SEMANTIC_STATE_SCHEMA_VERSION = "archcontext.architecture-semantic-state/v1" as const;
26
+ export const ARCHITECTURE_DOCS_PROJECTION_PROFILES = ["default", "repo-harness/v1"] as const;
27
+
28
+ export type ArchitectureDocsProjectionProfile = (typeof ARCHITECTURE_DOCS_PROJECTION_PROFILES)[number];
29
+
30
+ export interface ArchitectureCapabilitySemanticStateV1 {
31
+ capabilityId: string;
32
+ memberNodeIds: string[];
33
+ semanticFingerprint: string;
34
+ flowProofFingerprint: string;
35
+ proofStatus: ArchitectureCapabilityProofStatusV1;
36
+ facets: Record<ArchitectureSemanticFacet, string>;
37
+ }
38
+
39
+ export interface ArchitectureSemanticStateV1 {
40
+ schemaVersion: typeof ARCHITECTURE_SEMANTIC_STATE_SCHEMA_VERSION;
41
+ capabilities: ArchitectureCapabilitySemanticStateV1[];
42
+ semanticFingerprint: string;
43
+ flowProofFingerprint: string;
44
+ }
45
+
46
+ export interface ArchitectureProofEvidenceDigestsV1 {
47
+ /** Declared source footprint identity, the same value the committed provenance records. */
48
+ sourceTreeDigest: string;
49
+ /** Digest of the selector-evidence facts the P1/P2 compilation reads. */
50
+ selectorEvidenceDigest: string;
51
+ /** The renderer version that compiled the proofs, so a compiler change is never credited to journals. */
52
+ rendererVersion: string;
53
+ }
54
+
55
+ export interface ArchitectureProjectionSemanticBaselineV1 {
56
+ semanticState: ArchitectureSemanticStateV1;
57
+ digests: ArchitectureDigestSetV1;
58
+ /**
59
+ * The non-model proof inputs this baseline was rendered from, recorded fresh on every render.
60
+ * Absent in manifests written before it existed.
61
+ */
62
+ evidence?: ArchitectureProofEvidenceDigestsV1;
63
+ }
64
+
65
+ /**
66
+ * Committed projection provenance. It records machine-independent values only: content digests of
67
+ * the model and the declared sources, plus renderer, layout and CodeGraph versions, so two machines
68
+ * projecting the same commit write the same bytes (#277). HEAD, the full-worktree digest, the
69
+ * CodeGraph evidence digest, the indexed-worktree digest and the CodeGraph status depend on the
70
+ * checkout path, the index build time, the platform or whether `codegraph init` ran; they are runtime
71
+ * facts (the projection runtime snapshot) and never reach the committed manifest. A provenance of
72
+ * any other schemaVersion is not read as this one; the next projection rewrites it.
73
+ */
74
+ export interface ArchitectureDocumentationProjectionProvenanceV3 {
75
+ schemaVersion: typeof ARCHITECTURE_DOCS_PROJECTION_PROVENANCE_SCHEMA_VERSION;
76
+ sourceTreeDigest: string;
77
+ modelDigest: string;
78
+ projectionInputDigest: string;
79
+ rendererVersion: typeof ARCHITECTURE_DOCS_RENDERER_VERSION;
80
+ layoutVersion: "archcontext.docs-layout/v1";
81
+ /** Reproducible CodeGraph identity only: package name and version (#266, #277). */
82
+ generatedFrom: {
83
+ codeGraphPackage: string;
84
+ codeGraphVersion: string;
85
+ };
86
+ }
87
+
88
+ /**
89
+ * One 1–2–5 magnitude bucket: the half-open range `[lower, upper)` that contains a measured count.
90
+ * `{ lower: 0, upper: 1 }` holds only zero; otherwise `lower` is `1`, `2` or `5` times a power of ten
91
+ * and `upper` is the next step on that ladder.
92
+ */
93
+ export interface ArchitectureScaleBucketV1 {
94
+ lower: number;
95
+ upper: number;
96
+ }
97
+
98
+ /**
99
+ * The measured size of a node's declared `source.include` minus `source.exclude` footprint, as
100
+ * buckets. The module document prints exactly these buckets; the exact counts are never committed.
101
+ */
102
+ export interface ArchitectureCapabilityScaleV1 {
103
+ fileCountBucket: ArchitectureScaleBucketV1;
104
+ lineCountBucket: ArchitectureScaleBucketV1;
105
+ }
106
+
107
+ export interface ArchitectureDocsProjectionManifestTargetV2 {
108
+ targetId: string;
109
+ type: ProjectionTargetType;
110
+ scope: { kind: ProjectionTargetScopeKind; id?: string; entityKind?: string };
111
+ path: string;
112
+ ownership: ProjectionTargetOwnership;
113
+ rendererVersion: typeof ARCHITECTURE_DOCS_RENDERER_VERSION;
114
+ format: ProjectionTargetFormat;
115
+ sourceDigest: string;
116
+ outputDigest: string;
117
+ /**
118
+ * `entity-summary` targets of a node that declares `source.include` only: the content digest of
119
+ * that footprint the document was verified against. Present exactly when `scale` is.
120
+ */
121
+ sourceFootprintDigest?: string;
122
+ /** `entity-summary` targets of a node that declares `source.include` only (#264). */
123
+ scale?: ArchitectureCapabilityScaleV1;
124
+ }
125
+
126
+ export interface ArchitectureDocsProjectionManifestV2 {
127
+ schemaVersion: typeof ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION;
128
+ rendererVersion: typeof ARCHITECTURE_DOCS_RENDERER_VERSION;
129
+ profile: ArchitectureDocsProjectionProfile;
130
+ sourceDigest: string;
131
+ provenance: ArchitectureDocumentationProjectionProvenanceV3;
132
+ projectionDigest: string;
133
+ semanticBaseline: ArchitectureProjectionSemanticBaselineV1;
134
+ receiptDigest: string;
135
+ targetCount: number;
136
+ fileCount: number;
137
+ targets: ArchitectureDocsProjectionManifestTargetV2[];
138
+ }
139
+
140
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
141
+ const TARGET_TYPES: Record<ProjectionTargetType, true> = {
142
+ "architecture-index": true,
143
+ "entity-summary": true,
144
+ "relation-summary": true,
145
+ "decision-index": true,
146
+ "architecture-changelog": true,
147
+ "diagram-mermaid": true,
148
+ "diagram-structurizr": true,
149
+ "diagram-likec4": true,
150
+ "agent-context": true
151
+ };
152
+ const SCOPE_KINDS: Record<ProjectionTargetScopeKind, true> = { repository: true, entity: true, relation: true, decision: true, diagram: true, changelog: true };
153
+ const OWNERSHIPS: Record<ProjectionTargetOwnership, true> = { generated: true, mixed: true };
154
+ const FORMATS: Record<ProjectionTargetFormat, true> = { markdown: true, mermaid: true, "structurizr-json": true, likec4: true };
155
+
156
+ /** Every reason the value is not a current-format projection manifest; empty when it is one. */
157
+ export function architectureDocsProjectionManifestIssues(value: unknown): string[] {
158
+ const issues: string[] = [];
159
+ const manifest = record(value);
160
+ if (!manifest) return ["manifest must be an object"];
161
+ issues.push(...exactKeys(manifest, ["schemaVersion", "rendererVersion", "profile", "sourceDigest", "provenance", "projectionDigest", "semanticBaseline", "receiptDigest", "targetCount", "fileCount", "targets"], [], "manifest"));
162
+ if (manifest.schemaVersion !== ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION) issues.push("manifest.schemaVersion is unsupported");
163
+ if (manifest.rendererVersion !== ARCHITECTURE_DOCS_RENDERER_VERSION) issues.push(`manifest.rendererVersion must be ${ARCHITECTURE_DOCS_RENDERER_VERSION}`);
164
+ if (!(ARCHITECTURE_DOCS_PROJECTION_PROFILES as readonly unknown[]).includes(manifest.profile)) issues.push("manifest.profile is unsupported");
165
+ for (const field of ["sourceDigest", "projectionDigest", "receiptDigest"] as const) {
166
+ if (!isDigest(manifest[field])) issues.push(`manifest.${field} must be a SHA-256 digest`);
167
+ }
168
+ issues.push(...architectureDocsProjectionProvenanceIssues(manifest.provenance, "manifest.provenance"));
169
+ issues.push(...architectureProjectionSemanticBaselineIssues(manifest.semanticBaseline, "manifest.semanticBaseline"));
170
+ const targets = manifest.targets;
171
+ if (!Array.isArray(targets)) {
172
+ issues.push("manifest.targets must be an array");
173
+ return issues;
174
+ }
175
+ if (manifest.targetCount !== targets.length) issues.push("manifest.targetCount must equal the number of targets");
176
+ if (manifest.fileCount !== targets.length) issues.push("manifest.fileCount must equal the number of targets");
177
+ const targetIds = targets.map((target) => record(target)?.targetId);
178
+ if (new Set(targetIds).size !== targetIds.length) issues.push("manifest.targets.targetId must be unique");
179
+ targets.forEach((target, index) => issues.push(...manifestTargetIssues(target, `manifest.targets[${index}]`)));
180
+ return issues;
181
+ }
182
+
183
+ export function architectureDocsProjectionProvenanceIssues(value: unknown, prefix = "provenance"): string[] {
184
+ const provenance = record(value);
185
+ if (!provenance) return [`${prefix} must be an object`];
186
+ const issues = exactKeys(provenance, ["schemaVersion", "sourceTreeDigest", "modelDigest", "projectionInputDigest", "rendererVersion", "layoutVersion", "generatedFrom"], [], prefix);
187
+ if (provenance.schemaVersion !== ARCHITECTURE_DOCS_PROJECTION_PROVENANCE_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is unsupported`);
188
+ for (const field of ["sourceTreeDigest", "modelDigest", "projectionInputDigest"] as const) {
189
+ if (!isDigest(provenance[field])) issues.push(`${prefix}.${field} must be a SHA-256 digest`);
190
+ }
191
+ if (provenance.rendererVersion !== ARCHITECTURE_DOCS_RENDERER_VERSION) issues.push(`${prefix}.rendererVersion must be ${ARCHITECTURE_DOCS_RENDERER_VERSION}`);
192
+ if (provenance.layoutVersion !== "archcontext.docs-layout/v1") issues.push(`${prefix}.layoutVersion is unsupported`);
193
+ const generatedFrom = record(provenance.generatedFrom);
194
+ if (!generatedFrom) {
195
+ issues.push(`${prefix}.generatedFrom must be an object`);
196
+ return issues;
197
+ }
198
+ issues.push(...exactKeys(generatedFrom, ["codeGraphPackage", "codeGraphVersion"], [], `${prefix}.generatedFrom`));
199
+ for (const field of ["codeGraphPackage", "codeGraphVersion"] as const) {
200
+ if (!isNonEmptyString(generatedFrom[field])) issues.push(`${prefix}.generatedFrom.${field} must be a non-empty string`);
201
+ }
202
+ return issues;
203
+ }
204
+
205
+ export function architectureProjectionSemanticBaselineIssues(value: unknown, prefix = "semanticBaseline"): string[] {
206
+ const baseline = record(value);
207
+ if (!baseline) return [`${prefix} must be an object`];
208
+ const issues = exactKeys(baseline, ["semanticState", "digests"], ["evidence"], prefix);
209
+ issues.push(...architectureSemanticStateIssues(baseline.semanticState, `${prefix}.semanticState`));
210
+ issues.push(...architectureDigestSetIssues(baseline.digests, `${prefix}.digests`));
211
+ if (baseline.evidence !== undefined) {
212
+ const evidence = record(baseline.evidence);
213
+ if (!evidence) issues.push(`${prefix}.evidence must be an object`);
214
+ else {
215
+ issues.push(...exactKeys(evidence, ["sourceTreeDigest", "selectorEvidenceDigest", "rendererVersion"], [], `${prefix}.evidence`));
216
+ if (!isDigest(evidence.sourceTreeDigest)) issues.push(`${prefix}.evidence.sourceTreeDigest must be a SHA-256 digest`);
217
+ if (!isDigest(evidence.selectorEvidenceDigest)) issues.push(`${prefix}.evidence.selectorEvidenceDigest must be a SHA-256 digest`);
218
+ if (!isNonEmptyString(evidence.rendererVersion)) issues.push(`${prefix}.evidence.rendererVersion must be a non-empty string`);
219
+ }
220
+ }
221
+ return issues;
222
+ }
223
+
224
+ export function architectureDigestSetIssues(value: unknown, prefix = "digests"): string[] {
225
+ const digests = record(value);
226
+ if (!digests) return [`${prefix} must be an object`];
227
+ const fields = ["modelDigest", "sourceTreeDigest", "flowProofDigest", "projectionDigest"] as const;
228
+ const issues = exactKeys(digests, fields, [], prefix);
229
+ for (const field of fields) if (!isDigest(digests[field])) issues.push(`${prefix}.${field} must be a SHA-256 digest`);
230
+ return issues;
231
+ }
232
+
233
+ export function architectureSemanticStateIssues(value: unknown, prefix = "semanticState"): string[] {
234
+ const state = record(value);
235
+ if (!state) return [`${prefix} must be an object`];
236
+ const issues = exactKeys(state, ["schemaVersion", "capabilities", "semanticFingerprint", "flowProofFingerprint"], [], prefix);
237
+ if (state.schemaVersion !== ARCHITECTURE_SEMANTIC_STATE_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is unsupported`);
238
+ if (!isDigest(state.semanticFingerprint)) issues.push(`${prefix}.semanticFingerprint must be a SHA-256 digest`);
239
+ if (!isDigest(state.flowProofFingerprint)) issues.push(`${prefix}.flowProofFingerprint must be a SHA-256 digest`);
240
+ if (!Array.isArray(state.capabilities)) {
241
+ issues.push(`${prefix}.capabilities must be an array`);
242
+ return issues;
243
+ }
244
+ const ids = state.capabilities.map((entry) => record(entry)?.capabilityId);
245
+ if (!isSortedUniqueStrings(ids)) issues.push(`${prefix}.capabilities must be sorted and unique by capabilityId`);
246
+ state.capabilities.forEach((entry, index) => {
247
+ const entryPrefix = `${prefix}.capabilities[${index}]`;
248
+ const capability = record(entry);
249
+ if (!capability) {
250
+ issues.push(`${entryPrefix} must be an object`);
251
+ return;
252
+ }
253
+ issues.push(...exactKeys(capability, ["capabilityId", "memberNodeIds", "semanticFingerprint", "flowProofFingerprint", "proofStatus", "facets"], [], entryPrefix));
254
+ if (!isNonEmptyString(capability.capabilityId)) issues.push(`${entryPrefix}.capabilityId must be a non-empty string`);
255
+ if (!Array.isArray(capability.memberNodeIds) || !isSortedUniqueStrings(capability.memberNodeIds)) issues.push(`${entryPrefix}.memberNodeIds must be sorted and unique`);
256
+ if (!isDigest(capability.semanticFingerprint)) issues.push(`${entryPrefix}.semanticFingerprint must be a SHA-256 digest`);
257
+ if (!isDigest(capability.flowProofFingerprint)) issues.push(`${entryPrefix}.flowProofFingerprint must be a SHA-256 digest`);
258
+ if (!isArchitectureCapabilityProofStatus(capability.proofStatus)) issues.push(`${entryPrefix}.proofStatus must be a P1/P2 proof status`);
259
+ const facets = record(capability.facets);
260
+ if (!facets) issues.push(`${entryPrefix}.facets must be an object`);
261
+ else {
262
+ issues.push(...exactKeys(facets, [...ARCHITECTURE_SEMANTIC_FACETS], [], `${entryPrefix}.facets`));
263
+ for (const facet of ARCHITECTURE_SEMANTIC_FACETS) {
264
+ if (!isDigest(facets[facet])) issues.push(`${entryPrefix}.facets.${facet} must be a SHA-256 digest`);
265
+ }
266
+ }
267
+ });
268
+ return issues;
269
+ }
270
+
271
+ /** True when the value is a valid 1–2–5 magnitude bucket (see ArchitectureScaleBucketV1). */
272
+ export function isArchitectureScaleBucket(value: unknown): value is ArchitectureScaleBucketV1 {
273
+ const bucket = record(value);
274
+ if (!bucket || Object.keys(bucket).length !== 2) return false;
275
+ const { lower, upper } = bucket;
276
+ if (typeof lower !== "number" || typeof upper !== "number" || !Number.isSafeInteger(lower) || !Number.isSafeInteger(upper)) return false;
277
+ if (lower === 0) return upper === 1;
278
+ let magnitude = 1;
279
+ while (magnitude * 10 <= lower) magnitude *= 10;
280
+ const mantissa = lower / magnitude;
281
+ if (mantissa === 1) return upper === 2 * magnitude;
282
+ if (mantissa === 2) return upper === 5 * magnitude;
283
+ if (mantissa === 5) return upper === 10 * magnitude;
284
+ return false;
285
+ }
286
+
287
+ function manifestTargetIssues(value: unknown, prefix: string): string[] {
288
+ const target = record(value);
289
+ if (!target) return [`${prefix} must be an object`];
290
+ const issues = exactKeys(target, ["targetId", "type", "scope", "path", "ownership", "rendererVersion", "format", "sourceDigest", "outputDigest"], ["sourceFootprintDigest", "scale"], prefix);
291
+ if (!isNonEmptyString(target.targetId)) issues.push(`${prefix}.targetId must be a non-empty string`);
292
+ if (typeof target.type !== "string" || !Object.hasOwn(TARGET_TYPES, target.type)) issues.push(`${prefix}.type is unsupported`);
293
+ const scope = record(target.scope);
294
+ if (!scope || typeof scope.kind !== "string" || !Object.hasOwn(SCOPE_KINDS, scope.kind)) issues.push(`${prefix}.scope.kind is unsupported`);
295
+ else {
296
+ issues.push(...exactKeys(scope, ["kind"], ["id", "entityKind"], `${prefix}.scope`));
297
+ for (const field of ["id", "entityKind"] as const) {
298
+ if (scope[field] !== undefined && typeof scope[field] !== "string") issues.push(`${prefix}.scope.${field} must be a string`);
299
+ }
300
+ }
301
+ if (typeof target.path !== "string" || !isRepoRelativePosixPath(target.path)) issues.push(`${prefix}.path must be a repository-relative POSIX path`);
302
+ if (typeof target.ownership !== "string" || !Object.hasOwn(OWNERSHIPS, target.ownership)) issues.push(`${prefix}.ownership is unsupported`);
303
+ if (target.rendererVersion !== ARCHITECTURE_DOCS_RENDERER_VERSION) issues.push(`${prefix}.rendererVersion must be ${ARCHITECTURE_DOCS_RENDERER_VERSION}`);
304
+ if (typeof target.format !== "string" || !Object.hasOwn(FORMATS, target.format)) issues.push(`${prefix}.format is unsupported`);
305
+ if (!isDigest(target.sourceDigest)) issues.push(`${prefix}.sourceDigest must be a SHA-256 digest`);
306
+ if (!isDigest(target.outputDigest)) issues.push(`${prefix}.outputDigest must be a SHA-256 digest`);
307
+ if (target.sourceFootprintDigest !== undefined && !isDigest(target.sourceFootprintDigest)) issues.push(`${prefix}.sourceFootprintDigest must be a SHA-256 digest`);
308
+ if ((target.sourceFootprintDigest !== undefined || target.scale !== undefined) && target.type !== "entity-summary") {
309
+ issues.push(`${prefix}.sourceFootprintDigest and scale are only allowed on entity-summary targets`);
310
+ }
311
+ if ((target.sourceFootprintDigest === undefined) !== (target.scale === undefined)) issues.push(`${prefix}.sourceFootprintDigest and scale must be present together`);
312
+ if (target.scale !== undefined) {
313
+ const scale = record(target.scale);
314
+ if (!scale) issues.push(`${prefix}.scale must be an object`);
315
+ else {
316
+ issues.push(...exactKeys(scale, ["fileCountBucket", "lineCountBucket"], [], `${prefix}.scale`));
317
+ for (const field of ["fileCountBucket", "lineCountBucket"] as const) {
318
+ if (!isArchitectureScaleBucket(scale[field])) issues.push(`${prefix}.scale.${field} must be a 1-2-5 magnitude bucket`);
319
+ }
320
+ }
321
+ }
322
+ return issues;
323
+ }
324
+
325
+ function record(value: unknown): Record<string, unknown> | undefined {
326
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
327
+ }
328
+
329
+ function exactKeys(value: Record<string, unknown>, required: readonly string[], optional: readonly string[], prefix: string): string[] {
330
+ const issues = required.filter((key) => !(key in value)).map((key) => `${prefix}.${key} is required`);
331
+ const allowed = new Set([...required, ...optional]);
332
+ for (const key of Object.keys(value)) if (!allowed.has(key)) issues.push(`${prefix}.${key} is not allowed`);
333
+ return issues;
334
+ }
335
+
336
+ function isDigest(value: unknown): value is Sha256Digest {
337
+ return typeof value === "string" && SHA256.test(value);
338
+ }
339
+
340
+ function isNonEmptyString(value: unknown): value is string {
341
+ return typeof value === "string" && value.trim() !== "";
342
+ }
343
+
344
+ function isSortedUniqueStrings(values: readonly unknown[]): boolean {
345
+ return values.every((value, index) => typeof value === "string" && (index === 0 || (values[index - 1] as string) < value));
346
+ }